feat: add staff role functionality with dashboard access and service management
- Implemented SidebarStaff component tests to ensure staff users see only their dashboard and services. - Created StaffMyServicesPage to display assigned services for staff users. - Added migration to link clinic staff rows to user accounts for ROLE_STAFF access. - Defined StaffPermissions class for static permissions related to staff role. - Introduced StaffRouteGuardSubscriber to restrict API access for staff users. - Developed StaffAccountService for managing staff user accounts and linking them to clinic staff. - Added comprehensive tests for StaffAccountService to validate user creation, mobile number handling, and account attachment. - Implemented tests for staff dashboard access to ensure proper permissions and access control. - Created tests for staff login context to verify correct environment visibility based on user roles.
This commit is contained in:
@@ -0,0 +1,623 @@
|
||||
# حساب کاربری برای پرسنل — نقش `ROLE_STAFF`، ورود به پنل، داشبورد اختصاصی و مشاهدهٔ سرویسهای تخصیصیافته
|
||||
|
||||
## پروژه
|
||||
|
||||
`clinicpro` (بکاند Symfony + پنل ادمین React). cross-repo نیست؛ `nobat724_front` تغییری ندارد.
|
||||
|
||||
## زمینه
|
||||
|
||||
امروز پرسنل (`clinic_staff`) فقط یک «رکورد اطلاعاتی» است: کلینیک یا پزشک از
|
||||
`/admin/staff` یک ردیف با نام/تلفن/سمت/کد ملی میسازد و همان ردیف در جاهای دیگر
|
||||
بهعنوان «مجری سرویس» انتخاب میشود:
|
||||
|
||||
- `ServiceItem::$staffMembers` (جدول `service_item_staff`) — پرسنل تخصیصیافته به هر سرویس
|
||||
- `Appointment::$staff` (`appointments.staff_id`) — پرسنل نوبت
|
||||
- `SessionService::$staff` — پرسنل مجری سرویس در جلسهٔ بیمار
|
||||
|
||||
اما `ClinicStaff` هیچ ارتباطی با `users` ندارد، پس پرسنل نه میتواند لاگین کند و نه
|
||||
داشبوردی دارد. الگوی مشابهی که در پروژه **کار میکند** «منشی» است: منشی یک `User`
|
||||
است با `ROLE_SECRETARY` که از طریق ردیف `DoctorSecretary` به مالک (پزشک/کلینیک) وصل
|
||||
میشود (`SecretaryService::resolveSecretaryUser`). همین الگو باید برای پرسنل تکرار شود.
|
||||
|
||||
## مشکل / هدف
|
||||
|
||||
وقتی کلینیک یا پزشک در `/admin/staff` پرسنل اضافه میکند، اگر شمارهٔ موبایل بدهد و
|
||||
گزینهٔ «ایجاد حساب کاربری» را بزند:
|
||||
|
||||
1. یک `User` با نقش `ROLE_STAFF` ساخته/بهروزرسانی شود و به همان ردیف `ClinicStaff` وصل شود.
|
||||
2. آن کاربر بتواند با موبایل/رمز در `/admin/login` وارد شود.
|
||||
3. بعد از ورود، `primary_role = 'staff'` بگیرد و محیط کاریاش همان مطب/کلینیکِ مالک باشد.
|
||||
4. داشبورد اختصاصی «پرسنل» ببیند: سرویسهایی که به او تخصیص داده شده + نوبتهای خودش.
|
||||
5. **به هیچ چیز دیگری دسترسی نداشته باشد** — نه لیست بیماران، نه سرویسهای کل کلینیک،
|
||||
نه مالی، نه مدیریت پرسنل.
|
||||
|
||||
### تحلیل — نکتهٔ امنیتی که نباید نادیده گرفته شود
|
||||
|
||||
بند ۵ سختترین بخش کار است و اگر ساده گرفته شود یک نشت اطلاعات کامل میسازد:
|
||||
|
||||
- اکثر کنترلرها فقط `#[IsGranted('IS_AUTHENTICATED_FULLY')]` دارند و tenant را از
|
||||
`EntityContextResolver` میگیرند.
|
||||
- `SecretaryAccessChecker::denyUnlessGranted` و `ClinicDoctorAccessChecker` برای
|
||||
کاربری که منشی/پزشکِ مهمان **نیست** عملاً no-op هستند (فقط نقش خودشان را میسنجند).
|
||||
- پس بهمحض اینکه `EntityContextResolver` برای کاربر staff محیط کلینیک را resolve کند،
|
||||
`GET /api/v1/service-items` **همهٔ** سرویسهای کلینیک را برمیگرداند، `/api/v1/patients`
|
||||
همهٔ بیماران را، و…
|
||||
|
||||
بنابراین طراحی این تسک **default-deny** است: یک `StaffRouteGuardSubscriber` روی رویداد
|
||||
`kernel.controller` که برای کاربرِ «فقط staff» هر مسیر خارج از allowlist را ۴۰۳ میکند.
|
||||
دلیل انتخاب Subscriber بهجای افزودن `denyUnlessGranted` به دهها کنترلر: تکنقطهای
|
||||
بودن تصمیم (اگر فردا کنترلر جدیدی اضافه شود، بهصورت پیشفرض بسته است، نه باز).
|
||||
|
||||
## معیار پذیرش
|
||||
|
||||
- ✅ موفق:
|
||||
- `POST /api/v1/staff` با `{"full_name":"زهرا احمدی","phone":"09121110000","has_account":true,"password":"Staff@1234"}`
|
||||
توسط توکن کلینیک → `201` و در بدنه `has_account: true` و `user_uuid` غیرتهی؛ در DB
|
||||
یک `users` با `roles` شامل `ROLE_STAFF` و `clinic_staff.user_id` پرشده.
|
||||
- `POST /api/v1/user/login` با همان موبایل/رمز → `200` و `access_token`.
|
||||
- `GET /oauth/userinfo` با آن توکن → `primary_role: "staff"` و در `available_contexts`
|
||||
یک آیتم با `role: "staff"` و `db_uuid` برابر uuid کلینیک/پزشکِ مالک و
|
||||
`permissions.resources` فقط شامل `{"services":{"view":true},"appointments":{"view":true}}`.
|
||||
- `GET /api/v1/dashboard/staff` → `200` با `stats.today_appointments`، `services` (فقط
|
||||
سرویسهایی که این پرسنل در `service_item_staff` آنهاست) و `today_appointments`.
|
||||
- در پنل: ورود با آن کاربر → ریدایرکت به `/admin/dashboard` و نمایش «داشبورد پرسنل»؛
|
||||
سایدبار فقط «داشبورد» و «سرویسهای من» را دارد.
|
||||
- ❌ خطا:
|
||||
- `GET /api/v1/service-items` با توکن پرسنل → `403` با `ERR_FORBIDDEN_001` (نه ۲۰۰ با
|
||||
سرویسهای کلینیک). همینطور `/api/v1/staff` (GET/POST)، `/api/v1/patients`،
|
||||
`/api/v1/appointments`، `/api/v1/dashboard/clinic`.
|
||||
- `POST /api/v1/staff` با `has_account: true` و `phone` خالی یا نامعتبر →
|
||||
`422` با `ERR_STAFF_MOBILE_INVALID`.
|
||||
- ورود پرسنلِ `active=false` → `/oauth/userinfo` هیچ context با `role: "staff"` ندارد و
|
||||
`GET /api/v1/dashboard/staff` → `403`.
|
||||
- ⚠️ مرزی:
|
||||
- موبایلی که **از قبل** `User` دارد (مثلاً بیمار یا منشی): کاربر جدید ساخته نشود؛
|
||||
فقط `ROLE_STAFF` به نقشهایش اضافه شود و ردیف پرسنل به همان کاربر وصل شود. اگر آن
|
||||
کاربر هم منشی است و هم پرسنل → `primary_role` باید `secretary` بماند (نقش قویتر) و
|
||||
context مربوط به staff هم در `available_contexts` بیاید.
|
||||
- یک نفر پرسنلِ **دو** کلینیک: دو ردیف `clinic_staff` با یک `user_id` → دو context در
|
||||
لیست؛ بعد از `switch-context` داشبورد دادههای همان کلینیک را بدهد.
|
||||
- همان موبایل دوباره در همان کلینیک ثبت شود → `409` با `ERR_STAFF_MOBILE_TAKEN`
|
||||
(نه ساخت ردیف تکراری).
|
||||
- پرسنل بدون هیچ سرویس تخصیصیافته → `services: []` و پیام خالی در UI، نه ۵۰۰.
|
||||
- موبایل مالک (خودِ پزشک/کلینیک) بهعنوان پرسنل → `422` با `ERR_STAFF_MOBILE_INVALID`
|
||||
و پیام «شماره مالک نمیتواند پرسنل باشد» (جلوگیری از تنزل نقش/سردرگمی context).
|
||||
|
||||
## فایلهای مرتبط
|
||||
|
||||
| فایل | نقش |
|
||||
|------|-----|
|
||||
| `src/Staff/Entity/ClinicStaff.php` | افزودن رابطهٔ `user` |
|
||||
| `src/Staff/Repository/ClinicStaffRepository.php` | کوئریهای `findActiveByUser`، `findActiveByUserAndEntity`، `findByEntityAndPhone` |
|
||||
| `src/Staff/Service/StaffAccountService.php` | **جدید** — ساخت/اتصال/قطع حساب کاربری پرسنل |
|
||||
| `src/Staff/Controller/StaffController.php` | پذیرش `has_account`/`password` در create/update |
|
||||
| `src/Staff/Controller/StaffDashboardController.php` یا `src/Dashboard/Controller/DashboardController.php` | اندپوینت داشبورد پرسنل |
|
||||
| `src/Staff/Security/StaffRouteGuardSubscriber.php` | **جدید** — default-deny برای کاربر staff |
|
||||
| `src/Auth/Entity/User.php` | `isStaff()` باید `ROLE_STAFF` را هم بپذیرد |
|
||||
| `src/Auth/Controller/AuthController.php` | `resolvePrimaryRole()` + `buildAvailableContexts()` |
|
||||
| `src/Shared/Context/EntityContextResolver.php` | resolve محیط برای کاربر staff |
|
||||
| `src/Shared/Constant/ErrorCodes.php` | کدهای خطای جدید |
|
||||
| `src/ClinicService/Repository/ServiceItemRepository.php` | `findByStaff(ClinicStaff)` |
|
||||
| `assets/admin/pages/StaffPage.tsx` | فیلد موبایل/حساب کاربری + ستون «حساب» |
|
||||
| `assets/admin/pages/DashboardPage.tsx` | `StaffDashboard` + dispatcher |
|
||||
| `assets/admin/pages/StaffMyServicesPage.tsx` | **جدید** — صفحهٔ «سرویسهای من» |
|
||||
| `assets/admin/App.tsx` | `ALLOWED_ROLES` + روتهای نقش staff |
|
||||
| `assets/admin/components/layout/Sidebar.tsx` | منوی نقش staff |
|
||||
| `assets/admin/types/index.ts` | فیلدهای جدید `ClinicStaff` |
|
||||
| `docs/api/staff.md`، `docs/api/auth.md`، `docs/api/dashboard.md` | مستندسازی (قانون ثابت پروژه) |
|
||||
|
||||
## وضعیت فعلی
|
||||
|
||||
### `src/Staff/Entity/ClinicStaff.php` — هیچ ارتباطی با `User` ندارد
|
||||
|
||||
```php
|
||||
#[ORM\Entity(repositoryClass: ClinicStaffRepository::class)]
|
||||
#[ORM\Table(name: 'clinic_staff')]
|
||||
#[ORM\Index(columns: ['entity_type', 'entity_id', 'active'], name: 'idx_staff_entity_active')]
|
||||
class ClinicStaff
|
||||
{
|
||||
#[ORM\Column(name: 'entity_type', type: 'string', length: 10)]
|
||||
private string $entityType;
|
||||
|
||||
#[ORM\Column(name: 'entity_id', type: 'integer')]
|
||||
private int $entityId;
|
||||
|
||||
#[ORM\Column(name: 'full_name', type: 'string', length: 200)]
|
||||
private string $fullName;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 20, nullable: true)]
|
||||
private ?string $phone = null;
|
||||
// …
|
||||
}
|
||||
```
|
||||
|
||||
### `src/Auth/Entity/User.php:121` — گیت ورود به پنل
|
||||
|
||||
```php
|
||||
public function isStaff(): bool
|
||||
{
|
||||
return $this->hasRole('ROLE_DOCTOR')
|
||||
|| $this->hasRole('ROLE_CLINIC')
|
||||
|| $this->hasRole('ROLE_SECRETARY')
|
||||
|| $this->hasRole('ROLE_ADMIN')
|
||||
|| $this->hasRole('ROLE_REPRESENTATION')
|
||||
|| $this->hasRole('ROLE_IMPORTER');
|
||||
}
|
||||
```
|
||||
|
||||
`PasswordAuthenticator::onAuthenticationSuccess:79` بدون این متد لاگین را ۴۰۳ میکند:
|
||||
|
||||
```php
|
||||
if (!$user->isStaff()) {
|
||||
return new JsonResponse([... ErrorCodes::ERR_AUTH_006 ...], 403);
|
||||
}
|
||||
```
|
||||
|
||||
### `src/Auth/Controller/AuthController.php:690` — نقش اصلی و لیست محیطها
|
||||
|
||||
```php
|
||||
private function resolvePrimaryRole(User $user): string
|
||||
{
|
||||
$roles = $user->getRoles();
|
||||
if (in_array('ROLE_ADMIN', $roles, true)) return 'admin';
|
||||
if (in_array('ROLE_CLINIC', $roles, true)) return 'clinic';
|
||||
if (in_array('ROLE_DOCTOR', $roles, true)) return 'doctor';
|
||||
if (in_array('ROLE_SECRETARY', $roles, true)) return 'secretary';
|
||||
if (in_array('ROLE_REPRESENTATION', $roles, true)) return 'representation';
|
||||
return 'user';
|
||||
}
|
||||
```
|
||||
|
||||
و در `buildAvailableContexts()` منشی اینطور context میگیرد (الگوی مرجع برای پرسنل):
|
||||
|
||||
```php
|
||||
foreach ($this->secretaryRepo->findAllActiveBySecretary($user) as $rel) {
|
||||
// …
|
||||
$contexts[] = [
|
||||
'type' => 'doctor',
|
||||
'db_uuid' => $rel->getDoctor()->getUuid(),
|
||||
'name' => 'مطب ' . $rel->getDoctor()->getName(),
|
||||
'role' => 'secretary',
|
||||
'scope' => 'doctor',
|
||||
'permissions' => $rel->getPermissions(),
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
### `src/Secretary/Service/SecretaryService.php:38` — الگوی مرجع ساخت کاربر
|
||||
|
||||
```php
|
||||
public function resolveSecretaryUser(string $mobile, ?string $name = null, ?string $password = null): User
|
||||
{
|
||||
$user = $this->userRepo->findByMobile($mobile);
|
||||
if ($user === null) {
|
||||
$user = new User($mobile);
|
||||
if (!empty($password)) {
|
||||
$user->setPasswordHash($this->hasher->hashPassword($user, $password));
|
||||
}
|
||||
}
|
||||
if (!empty($name)) {
|
||||
$user->setRealName(trim($name));
|
||||
}
|
||||
|
||||
$roles = $user->getRoles();
|
||||
if (!in_array('ROLE_SECRETARY', $roles, true)) {
|
||||
$roles[] = 'ROLE_SECRETARY';
|
||||
$user->setRoles(array_values(array_unique($roles)));
|
||||
}
|
||||
$this->userRepo->save($user);
|
||||
|
||||
return $user;
|
||||
}
|
||||
```
|
||||
|
||||
### `src/ClinicService/Entity/ServiceItem.php:44` — رابطهٔ سرویس ↔ پرسنل (منبع «سرویسهای من»)
|
||||
|
||||
```php
|
||||
#[ORM\ManyToMany(targetEntity: ClinicStaff::class, fetch: 'EAGER')]
|
||||
#[ORM\JoinTable(name: 'service_item_staff')]
|
||||
private Collection $staffMembers;
|
||||
```
|
||||
|
||||
### `assets/admin/App.tsx:83` — نقشهای مجاز پنل
|
||||
|
||||
```tsx
|
||||
const ALLOWED_ROLES = ['admin', 'doctor', 'clinic', 'secretary', 'representation'] as const;
|
||||
```
|
||||
|
||||
### `assets/admin/hooks/usePermissions.ts` — نکتهٔ حیاتی
|
||||
|
||||
```ts
|
||||
const perms = context?.permissions as { resources?: ... } | undefined | null;
|
||||
if (!perms?.resources) return true; // نبودِ permissions یعنی «آزاد»، نه «بسته»
|
||||
```
|
||||
|
||||
پس context پرسنل **حتماً** باید `permissions.resources` صریح داشته باشد، وگرنه UI همهچیز
|
||||
را باز میکند.
|
||||
|
||||
## وظایف
|
||||
|
||||
### ۱. مدل داده: اتصال `ClinicStaff` به `User`
|
||||
|
||||
`src/Staff/Entity/ClinicStaff.php`:
|
||||
|
||||
```php
|
||||
#[ORM\ManyToOne(targetEntity: \App\Auth\Entity\User::class)]
|
||||
#[ORM\JoinColumn(name: 'user_id', nullable: true, onDelete: 'SET NULL')]
|
||||
private ?User $user = null;
|
||||
|
||||
public function getUser(): ?User { return $this->user; }
|
||||
public function hasAccount(): bool { return $this->user !== null; }
|
||||
public function setUser(?User $user): self { $this->user = $user; $this->updatedAt = time(); return $this; }
|
||||
```
|
||||
|
||||
و در `toArray()`:
|
||||
|
||||
```php
|
||||
'has_account' => $this->user !== null,
|
||||
'user_uuid' => $this->user?->getUuid(),
|
||||
```
|
||||
|
||||
ایندکس لازم: `#[ORM\Index(columns: ['user_id', 'active'], name: 'idx_staff_user_active')]`
|
||||
(چون `findActiveByUser` در هر بار `userinfo` صدا زده میشود).
|
||||
|
||||
`ClinicStaffRepository`:
|
||||
|
||||
```php
|
||||
/** @return ClinicStaff[] ردیفهای فعالِ این کاربر در همهٔ محیطها */
|
||||
public function findActiveByUser(User $user): array;
|
||||
|
||||
public function findActiveByUserAndEntity(User $user, string $entityType, int $entityId): ?ClinicStaff;
|
||||
|
||||
/** برای جلوگیری از ثبت تکراری یک موبایل در همان محیط */
|
||||
public function findByEntityAndPhone(string $entityType, int $entityId, string $phone): ?ClinicStaff;
|
||||
```
|
||||
|
||||
سپس migration:
|
||||
|
||||
```bash
|
||||
ddev exec php bin/console doctrine:migrations:diff --no-interaction
|
||||
ddev exec php bin/console doctrine:migrations:migrate --no-interaction
|
||||
```
|
||||
|
||||
**نحوه تست:** `ddev exec php bin/console doctrine:schema:validate` باید سبز باشد؛
|
||||
`DESCRIBE clinic_staff` ستون `user_id` را نشان دهد.
|
||||
|
||||
### ۲. `StaffAccountService` — تنها نقطهٔ ساخت/اتصال حساب پرسنل
|
||||
|
||||
`src/Staff/Service/StaffAccountService.php` (جدید). قرینهٔ `SecretaryService::resolveSecretaryUser`
|
||||
است، اما با اعتبارسنجی موبایل و قاعدهٔ «مالک نمیتواند پرسنل خودش باشد»:
|
||||
|
||||
```php
|
||||
class StaffAccountService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly UserRepository $userRepo,
|
||||
private readonly ClinicStaffRepository $staffRepo,
|
||||
private readonly UserPasswordHasherInterface $hasher,
|
||||
private readonly SmsService $smsService,
|
||||
private readonly string $appUrl,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* حساب کاربری پرسنل را میسازد یا به کاربر موجود وصل میکند و ROLE_STAFF میدهد.
|
||||
*
|
||||
* @throws AppException ERR_STAFF_MOBILE_INVALID | ERR_STAFF_MOBILE_TAKEN
|
||||
*/
|
||||
public function attachAccount(ClinicStaff $staff, string $mobile, ?string $password, User $owner): User
|
||||
{
|
||||
$mobile = $this->normalizeMobile($mobile); // ارقام فارسی → لاتین
|
||||
if (!preg_match('/^09\d{9}$/', $mobile)) {
|
||||
throw new AppException(ErrorCodes::ERR_STAFF_MOBILE_INVALID, null, 422);
|
||||
}
|
||||
if ($mobile === $owner->getMobileNumber()) {
|
||||
throw new AppException(ErrorCodes::ERR_STAFF_MOBILE_INVALID, 'شماره مالک نمیتواند پرسنل باشد', 422);
|
||||
}
|
||||
|
||||
$user = $this->userRepo->findByMobile($mobile);
|
||||
|
||||
// یک موبایل، در یک محیط، فقط یک ردیف پرسنل
|
||||
$duplicate = $this->staffRepo->findByEntityAndPhone($staff->getEntityType(), $staff->getEntityId(), $mobile);
|
||||
if ($duplicate !== null && $duplicate->getId() !== $staff->getId()) {
|
||||
throw new AppException(ErrorCodes::ERR_STAFF_MOBILE_TAKEN, null, 409);
|
||||
}
|
||||
|
||||
if ($user === null) {
|
||||
$user = new User($mobile);
|
||||
}
|
||||
if (!empty($password)) {
|
||||
$user->setPasswordHash($this->hasher->hashPassword($user, $password));
|
||||
}
|
||||
$user->setRealName($staff->getFullName());
|
||||
$user->addRole('ROLE_STAFF');
|
||||
$this->userRepo->save($user);
|
||||
|
||||
$staff->setUser($user)->setPhone($mobile);
|
||||
$this->staffRepo->save($staff);
|
||||
|
||||
$this->sendWelcomeSms($mobile, $ownerName); // TAG_STAFF، مشابه TAG_SECRETARY
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
/** قطع دسترسی بدون حذف ردیف پرسنل (سوابق سرویس/نوبت حفظ میشود). */
|
||||
public function detachAccount(ClinicStaff $staff): void;
|
||||
}
|
||||
```
|
||||
|
||||
نکتهها:
|
||||
- `addRole()` روی `User` از قبل هست (`src/Auth/Entity/User.php:107`) — از آن استفاده کن،
|
||||
آرایهٔ roles را دستی دستکاری نکن.
|
||||
- برای SMS: `SmsLog::TAG_STAFF` را به ثابتها و `SmsMessageTemplate` اضافه کن (الگوی
|
||||
`SmsLog::TAG_SECRETARY => [...]` در `src/Sms/Entity/SmsMessageTemplate.php:58`). اگر
|
||||
افزودن قالب پیامک ریسک/هزینه دارد، همان `TAG_SECRETARY` را استفاده نکن — بهجایش
|
||||
ارسال SMS را در این فاز حذف کن و در پاسخ API فقط `has_account` را برگردان.
|
||||
- کدهای خطای جدید در `src/Shared/Constant/ErrorCodes.php`:
|
||||
`ERR_STAFF_MOBILE_INVALID => 'شماره موبایل پرسنل معتبر نیست'`،
|
||||
`ERR_STAFF_MOBILE_TAKEN => 'برای این شماره قبلاً پرسنلی ثبت شده است'`.
|
||||
|
||||
**نحوه تست:** یونیتتست `tests/Staff/StaffAccountServiceTest.php` با سه سناریو:
|
||||
کاربر جدید ساخته میشود / کاربر موجود فقط نقش میگیرد و رمز قبلیاش پاک نمیشود اگر
|
||||
`password` خالی باشد / موبایل مالک → `AppException` با کد ۴۲۲.
|
||||
|
||||
### ۳. `StaffController` — پذیرش حساب کاربری در create/update
|
||||
|
||||
در `create()` و `update()` (فایل `src/Staff/Controller/StaffController.php`) بعد از
|
||||
`$this->staffRepo->save($staff)`:
|
||||
|
||||
```php
|
||||
$wantsAccount = (bool) ($data['has_account'] ?? false);
|
||||
if ($wantsAccount) {
|
||||
$this->staffAccounts->attachAccount($staff, (string) ($data['phone'] ?? ''), $data['password'] ?? null, $user);
|
||||
} elseif ($staff->hasAccount() && array_key_exists('has_account', $data)) {
|
||||
$this->staffAccounts->detachAccount($staff);
|
||||
}
|
||||
|
||||
return $this->success($staff->toArray(), 201);
|
||||
```
|
||||
|
||||
کنترلر نازک بماند: هیچ منطق hash/نقش/اعتبارسنجی موبایل داخل کنترلر نوشته نشود
|
||||
(`AppException` را `ExceptionSubscriber` به envelope خطا تبدیل میکند).
|
||||
|
||||
**مهم:** `resolveEntity()` همین کنترلر نباید برای `ROLE_STAFF` چیزی برگرداند — امروز به
|
||||
`['unknown', null]` میافتد و ۴۰۳ میدهد؛ همین رفتار درست است، دست نخورد (پرسنل حق
|
||||
مدیریت پرسنل ندارد).
|
||||
|
||||
**نحوه تست:**
|
||||
```bash
|
||||
TOKEN=$(curl -s -X POST https://clinic-pro.ddev.site/api/v1/user/login \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"mobile_number":"09390039833","password":"09390039833"}' | jq -r .access_token)
|
||||
|
||||
curl -s -X POST https://clinic-pro.ddev.site/api/v1/staff \
|
||||
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
|
||||
-d '{"full_name":"زهرا احمدی","phone":"09121110000","job_title":"پرستار","has_account":true,"password":"Staff@1234"}' | jq
|
||||
# انتظار: 201، has_account:true، user_uuid غیرتهی
|
||||
```
|
||||
|
||||
### ۴. Auth — نقش، محیط کاری و مجوزهای پرسنل
|
||||
|
||||
الف) `src/Auth/Entity/User.php` → `isStaff()` را با `|| $this->hasRole('ROLE_STAFF')` کامل کن
|
||||
(بدون آن، لاگین پرسنل ۴۰۳ میگیرد).
|
||||
|
||||
ب) `AuthController::resolvePrimaryRole()` — بعد از `secretary` و قبل از `representation`:
|
||||
|
||||
```php
|
||||
if (in_array('ROLE_STAFF', $roles, true)) return 'staff';
|
||||
```
|
||||
|
||||
(ترتیب عمدی است: کسی که هم منشی است هم پرسنل، منشی میماند چون نقش پرتوانتر است.)
|
||||
|
||||
ج) `AuthController::buildAvailableContexts()` — بلوک جدید در انتها:
|
||||
|
||||
```php
|
||||
foreach ($this->staffRepo->findActiveByUser($user) as $row) {
|
||||
$owner = $row->getEntityType() === 'clinic'
|
||||
? $this->clinicRepo->find($row->getEntityId())
|
||||
: $this->doctorRepo->find($row->getEntityId());
|
||||
if ($owner === null) { continue; }
|
||||
|
||||
$contexts[] = [
|
||||
'type' => $row->getEntityType(),
|
||||
'db_uuid' => $owner->getUuid(),
|
||||
'name' => $row->getEntityType() === 'clinic' ? ($owner->getName() ?? '') : 'مطب ' . $owner->getName(),
|
||||
'role' => 'staff',
|
||||
'scope' => $row->getEntityType(),
|
||||
'permissions' => StaffPermissions::DEFAULT, // ثابت، نه قابل ویرایش در این فاز
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
با ثابتِ صریح (مثلاً `src/Staff/Security/StaffPermissions.php`):
|
||||
|
||||
```php
|
||||
public const DEFAULT = [
|
||||
'version' => 1,
|
||||
'resources' => [
|
||||
'services' => ['view' => true],
|
||||
'appointments' => ['view' => true],
|
||||
],
|
||||
];
|
||||
```
|
||||
|
||||
د) `EntityContextResolver` — تا وقتی staff در `canActInClinic()` / `canActForDoctor()`
|
||||
شناخته نشود، `fromActiveContext()` برای او `null` برمیگرداند و داشبورد ۴۰۳ میدهد:
|
||||
|
||||
```php
|
||||
// canActInClinic()
|
||||
if ($this->staffRepo->findActiveByUserAndEntity($user, 'clinic', $clinic->getId()) !== null) {
|
||||
return true;
|
||||
}
|
||||
// canActForDoctor()
|
||||
if ($this->staffRepo->findActiveByUserAndEntity($user, 'doctor', $doctor->getId()) !== null) {
|
||||
return true;
|
||||
}
|
||||
```
|
||||
|
||||
`fromRole()` برای staff هیچ fallback ندهد (مثل منشی) — محیطش فقط از `UserActiveContext`
|
||||
میآید، چون میتواند پرسنل چند محیط باشد.
|
||||
|
||||
**نحوه تست:**
|
||||
```bash
|
||||
STAFF=$(curl -s -X POST https://clinic-pro.ddev.site/api/v1/user/login \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"mobile_number":"09121110000","password":"Staff@1234"}' | jq -r .access_token)
|
||||
curl -s https://clinic-pro.ddev.site/oauth/userinfo -H "Authorization: Bearer $STAFF" | jq '.data.primary_role, .data.available_contexts'
|
||||
# انتظار: "staff" و یک context با role=staff و permissions محدود
|
||||
```
|
||||
|
||||
### ۵. Default-deny: `StaffRouteGuardSubscriber`
|
||||
|
||||
`src/Staff/Security/StaffRouteGuardSubscriber.php` (جدید) روی `KernelEvents::CONTROLLER`:
|
||||
|
||||
```php
|
||||
/**
|
||||
* کاربری که «فقط» ROLE_STAFF دارد به هیچ اندپوینتی جز allowlist دسترسی ندارد.
|
||||
*
|
||||
* چرایی: بیشتر کنترلرها tenant را از EntityContextResolver میگیرند و مجوز را فقط
|
||||
* برای منشی/پزشکِ مهمان میسنجند؛ بدون این گارد، کاربر staff با context حلشده به
|
||||
* دادهٔ کل کلینیک میرسد. تصمیم در یک نقطه متمرکز است تا کنترلرِ جدید هم بهصورت
|
||||
* پیشفرض بسته باشد.
|
||||
*/
|
||||
private const ALLOWED_PREFIXES = [
|
||||
'/api/v1/dashboard/staff',
|
||||
'/api/v1/staff/me',
|
||||
'/api/v1/auth/switch-context',
|
||||
'/api/v1/user/change-password',
|
||||
'/oauth/',
|
||||
];
|
||||
```
|
||||
|
||||
قواعد:
|
||||
- فقط وقتی فعال شود که کاربر `ROLE_STAFF` دارد و **هیچکدام** از
|
||||
`ROLE_ADMIN/ROLE_CLINIC/ROLE_DOCTOR/ROLE_SECRETARY/ROLE_REPRESENTATION` را ندارد.
|
||||
- در غیر allowlist: `AppException(ErrorCodes::ERR_FORBIDDEN_001, null, 403)`.
|
||||
- مسیرهای عمومی (غیر `/api`) دستنخورده بمانند.
|
||||
|
||||
**نحوه تست:** `tests/Staff/StaffRouteGuardTest.php` — با توکن پرسنل روی اینها ۴۰۳:
|
||||
`/api/v1/service-items`، `/api/v1/staff`، `/api/v1/patients`، `/api/v1/appointments`،
|
||||
`/api/v1/dashboard/clinic`؛ و روی `/api/v1/dashboard/staff` و `/oauth/userinfo` ۲۰۰.
|
||||
|
||||
### ۶. اندپوینت داشبورد پرسنل
|
||||
|
||||
`GET /api/v1/dashboard/staff` — قرینهٔ `/api/v1/dashboard/secretary`
|
||||
(`src/Dashboard/Controller/DashboardController.php:529`). طبق قاعدهٔ «اول بگرد، بعد بساز»:
|
||||
اندپوینت موجودی وجود ندارد که خروجی محدودشده به یک پرسنل بدهد، پس ساختش لازم است.
|
||||
|
||||
```php
|
||||
#[Route('/api/v1/dashboard/staff', methods: ['GET'])]
|
||||
#[IsGranted('ROLE_STAFF')]
|
||||
public function staff(#[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$context = $this->contextResolver->resolve($user);
|
||||
if (!$context->isResolved()) {
|
||||
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'محیط کاری پرسنل تنظیم نشده', 403);
|
||||
}
|
||||
[$entityType, $entityId] = $context->toEntityPair();
|
||||
|
||||
$row = $this->staffRepo->findActiveByUserAndEntity($user, $entityType, $entityId);
|
||||
if ($row === null) {
|
||||
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'دسترسی پرسنل تنظیم نشده', 403);
|
||||
}
|
||||
|
||||
return $this->success([
|
||||
'scope' => $entityType,
|
||||
'staff' => ['uuid' => $row->getUuid(), 'full_name' => $row->getFullName(), 'job_title' => $row->getJobTitle()],
|
||||
'owner' => ['name' => $ownerName],
|
||||
'stats' => ['today_appointments' => $todayCount, 'services' => count($services)],
|
||||
'services' => $services, // از ServiceItemRepository::findByStaff()
|
||||
'today_appointments' => $todayAppointments, // appointments.staff_id = این پرسنل، امروز
|
||||
]);
|
||||
}
|
||||
```
|
||||
|
||||
`ServiceItemRepository::findByStaff(ClinicStaff $staff): array` — DQL با
|
||||
`INNER JOIN i.staffMembers s WHERE s = :staff AND i.active = true`، محدود به همان tenant.
|
||||
خروجی سرویسها فقط فیلدهای لازم: `uuid, name, price_rials, duration_minutes, section_name, active`
|
||||
(قیمت لازم است چون پرسنل باید بداند چه سرویسی با چه تعرفهای به او تخصیص یافته).
|
||||
|
||||
نوبتهای امروز: DQL روی `Appointment` با `a.staff = :staff` و بازهٔ
|
||||
`strtotime('today midnight')` تا `strtotime('tomorrow midnight') - 1` (تایماستمپ صحیح، نه DateTime).
|
||||
|
||||
**نحوه تست:** بعد از تخصیص یک سرویس به پرسنل از صفحهٔ سرویسها:
|
||||
```bash
|
||||
curl -s https://clinic-pro.ddev.site/api/v1/dashboard/staff -H "Authorization: Bearer $STAFF" | jq '.data.services, .data.stats'
|
||||
```
|
||||
|
||||
### ۷. پنل: فرم پرسنل + نقش staff در روتینگ و سایدبار
|
||||
|
||||
الف) `assets/admin/pages/StaffPage.tsx`:
|
||||
- در `schema` فیلدهای `has_account: z.boolean().optional()` و `password: z.string().optional()`
|
||||
اضافه شود؛ با `superRefine`: اگر `has_account` روشن است، `phone` باید `^09\d{9}$` باشد.
|
||||
- در `StaffFormFields` یک چکباکس «ایجاد حساب کاربری برای ورود به پنل» و ورودی رمز
|
||||
(فقط وقتی چکباکس روشن است). ورودی موبایل همان `phone` فعلی است با `numericField(..., 11)`.
|
||||
- یک ستون جدید در `columns`: «حساب کاربری» با `ActiveBadge`/متن «دارد / ندارد» از
|
||||
`s.has_account`.
|
||||
- `assets/admin/types/index.ts` → `ClinicStaff` با `has_account: boolean; user_uuid: string | null`.
|
||||
|
||||
ب) `assets/admin/App.tsx`:
|
||||
```tsx
|
||||
const ALLOWED_ROLES = ['admin', 'doctor', 'clinic', 'secretary', 'representation', 'staff'] as const;
|
||||
```
|
||||
و روت جدید داخل `AdminLayout`:
|
||||
```tsx
|
||||
<Route path="/admin/my-services" element={<RoleRoute roles={['staff']}><StaffMyServicesPage /></RoleRoute>} />
|
||||
```
|
||||
|
||||
ج) `assets/admin/components/layout/Sidebar.tsx` — بلوک `if (primaryRole === "staff")`
|
||||
قبل از `representation`، دقیقاً با ساختار بقیه (بخش «عمومی» با داشبورد + «مدیریت» با
|
||||
«سرویسهای من»). هیچ آیتم تنظیمات/مالی/بیمار نداشته باشد. `ROLE_LABELS` هم مقدار
|
||||
`staff: 'پرسنل'` بگیرد.
|
||||
|
||||
د) `assets/admin/pages/DashboardPage.tsx` — کامپوننت `StaffDashboard` قرینهٔ
|
||||
`SecretaryDashboard` (همان `LoadingSkeleton`، همان کارتهای KPI، همان حالت خطا) و در
|
||||
dispatcher: `if (primaryRole === 'staff') return <StaffDashboard />;`
|
||||
|
||||
ه) `assets/admin/pages/StaffMyServicesPage.tsx` — جدول سرویسهای تخصیصیافته با
|
||||
`DataTable` + `PageHeader` (بدون دکمهٔ ایجاد/ویرایش؛ فقط خواندنی). چون از داشبورد باز
|
||||
میشود، `backTo="/admin/dashboard"` بدهد.
|
||||
|
||||
**نحوه تست:**
|
||||
```bash
|
||||
ddev exec npx tsc --noEmit --project tsconfig.json
|
||||
ddev exec yarn dev
|
||||
ddev exec yarn test
|
||||
```
|
||||
سپس دستی: ورود با `09121110000 / Staff@1234` در `/admin/login` →
|
||||
داشبورد پرسنل، سایدبار دو آیتمی، ورود مستقیم به `/admin/patients` → ریدایرکت به داشبورد.
|
||||
|
||||
### ۸. تستها و مستندات
|
||||
|
||||
- `tests/Staff/StaffAccountServiceTest.php` — یونیت (وظیفهٔ ۲).
|
||||
- `tests/Staff/StaffRouteGuardTest.php` — فانکشنال default-deny (وظیفهٔ ۵).
|
||||
- `tests/Staff/StaffDashboardTest.php` — موفق (۲۰۰ با سرویسهای خودش) / خطا (پرسنل
|
||||
غیرفعال → ۴۰۳) / مرزی (بدون سرویس → `services: []`).
|
||||
- `TenantSchemaCoverageTest` باید همچنان سبز باشد (`clinic_staff` از قبل tenant-keyed است؛
|
||||
ستون `user_id` طبقهبندی آن را عوض نمیکند — اگر تست قرمز شد، دلیلش را بررسی کن، نه
|
||||
اینکه entity را به `GlobalTables` اضافه کنی).
|
||||
- اجرای کامل: `ddev exec php bin/phpunit` و `ddev exec php vendor/bin/phpstan analyse`.
|
||||
- مستندات (قانون ثابت پروژه): `docs/api/staff.md` (فیلدهای جدید create/update + اندپوینت
|
||||
`/api/v1/staff/me` اگر ساخته شد)، `docs/api/auth.md` (نقش `staff` در `primary_role` و
|
||||
context جدید)، `docs/api/dashboard.md` (اندپوینت `/api/v1/dashboard/staff`).
|
||||
|
||||
## نکات مهم
|
||||
|
||||
- **پرسنل ≠ منشی.** منشی مجوزهای قابلویرایش دارد (`DoctorSecretary.permission`)؛ پرسنل در
|
||||
این فاز مجوز ثابت و حداقلی دارد (`StaffPermissions::DEFAULT`). ویرایشگر مجوز پرسنل
|
||||
ساخته نشود — abstraction «برای آینده» ممنوع است.
|
||||
- **`usePermissions` نبودِ `permissions` را «آزاد» تفسیر میکند** — context پرسنل حتماً
|
||||
آبجکت صریح `resources` داشته باشد، وگرنه UI همهچیز را باز میکند.
|
||||
- **غیرفعالسازی پرسنل باید دسترسی را قطع کند:** `PATCH /api/v1/staff/{uuid}/toggle` وقتی
|
||||
`active=false` میشود، `findActiveByUser` دیگر آن ردیف را برنمیگرداند، پس context حذف
|
||||
میشود. اما توکن JWT قبلی تا انقضا معتبر است؛ به همین دلیل گارد وظیفهٔ ۶ (بررسی
|
||||
`findActiveByUserAndEntity` در هر درخواست داشبورد) لازم است و نمیتوان فقط به context
|
||||
اکتفا کرد.
|
||||
- **حذف نشدن سوابق:** `detachAccount` فقط `user_id` را `null` میکند؛ ردیف `clinic_staff` و
|
||||
ارجاعات `service_item_staff` / `appointments.staff_id` / `session_services.staff_id` دست
|
||||
نمیخورند.
|
||||
- **تایماستمپها `int` Unix** و تاریخها در UI شمسی با `formatDate` — طبق قواعد پروژه.
|
||||
- **الگو:** `StaffAccountService` نقش Service Layer را دارد (قرینهٔ `SecretaryService`) و
|
||||
`StaffRouteGuardSubscriber` الگوی Guard/Interceptor است؛ انتخابشان برای تکنقطهای کردن
|
||||
دو تصمیم است: «چه کسی حساب دارد» و «چه چیزی برای staff باز است».
|
||||
- **رشتههای UI فارسی** بمانند و صفحات جدید از همان `PageHeader` / `DataTable` /
|
||||
`SettingsLayout` و توکنهای `styles.css` استفاده کنند — طراحی جدید ساخته نشود.
|
||||
@@ -59,6 +59,7 @@ import MyFinancialPage from './pages/MyFinancialPage';
|
||||
import ClinicFormPage from './pages/ClinicFormPage';
|
||||
import PreRegistrationsPage from './pages/PreRegistrationsPage';
|
||||
import StaffPage from './pages/StaffPage';
|
||||
import StaffMyServicesPage from './pages/StaffMyServicesPage';
|
||||
import SubscriptionPage from './pages/SubscriptionPage';
|
||||
import DiscountsPage from './pages/DiscountsPage';
|
||||
import ClinicServicesPage from './pages/ClinicServicesPage';
|
||||
@@ -80,7 +81,7 @@ import PwaInstallBanner from './components/ui/PwaInstallBanner';
|
||||
|
||||
// ── Guards ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const ALLOWED_ROLES = ['admin', 'doctor', 'clinic', 'secretary', 'representation'] as const;
|
||||
const ALLOWED_ROLES = ['admin', 'doctor', 'clinic', 'secretary', 'staff', 'representation'] as const;
|
||||
|
||||
function PrivateRoute({ children }: { children: React.ReactNode }) {
|
||||
const { isAuthenticated, primaryRole, availableContexts, dbUuid, fetchMe, logout } = useAuthStore();
|
||||
@@ -262,6 +263,8 @@ export default function App() {
|
||||
|
||||
{/* فاز ۲ — دکتر / کلینیک */}
|
||||
<Route path="staff" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['staff', 'view']}><StaffPage /></RoleRoute>} />
|
||||
{/* پرسنل: تنها صفحهٔ دادهٔ این نقش، کنار داشبورد */}
|
||||
<Route path="my-services" element={<RoleRoute roles={['staff']}><StaffMyServicesPage /></RoleRoute>} />
|
||||
<Route path="settings-menu" element={<RoleRoute roles={['doctor', 'clinic']} blockClinicScope><SettingsMenuPage /></RoleRoute>} />
|
||||
<Route path="account-settings" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']}><AccountSettingsPage /></RoleRoute>} />
|
||||
<Route path="tags-settings" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['tags', 'view']}><TagsSettingsPage /></RoleRoute>} />
|
||||
|
||||
@@ -490,6 +490,27 @@ function buildSections(
|
||||
];
|
||||
}
|
||||
|
||||
if (primaryRole === "staff") {
|
||||
// پرسنل فقط داشبورد خودش و سرویسهای تخصیصیافته را دارد؛ بقیهٔ مسیرها
|
||||
// سمت API هم برایش بسته است (StaffRouteGuardSubscriber).
|
||||
return [
|
||||
{
|
||||
label: "عمومی",
|
||||
items: [{ to: "/admin/dashboard", icon: ChartBarIcon, label: "داشبورد" }],
|
||||
},
|
||||
{
|
||||
label: "مدیریت",
|
||||
items: [
|
||||
{
|
||||
to: "/admin/my-services",
|
||||
icon: WrenchScrewdriverIcon,
|
||||
label: "سرویسهای من",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (primaryRole === "representation") {
|
||||
return [
|
||||
{
|
||||
@@ -570,6 +591,7 @@ const ROLE_LABELS: Record<string, string> = {
|
||||
clinic: "مالک کلینیک",
|
||||
doctor: "پزشک",
|
||||
secretary: "منشی",
|
||||
staff: "پرسنل",
|
||||
representation: "نماینده",
|
||||
user: "کاربر",
|
||||
};
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderWithProviders } from "../../test/utils";
|
||||
|
||||
vi.mock("../../hooks/useSubscription", () => ({
|
||||
useSubscription: () => ({ hasFeature: () => true }),
|
||||
}));
|
||||
|
||||
import { useAuthStore } from "../../stores/authStore";
|
||||
import Sidebar from "./Sidebar";
|
||||
|
||||
/**
|
||||
* پرسنل فقط داشبورد و سرویسهای خودش را دارد؛ هیچ آیتم مدیریتی نباید در منویش
|
||||
* ظاهر شود — قرینهٔ enforcement سمت API (StaffRouteGuardSubscriber).
|
||||
*/
|
||||
describe("Sidebar — نقش پرسنل", () => {
|
||||
const asStaff = () =>
|
||||
useAuthStore.setState({
|
||||
primaryRole: "staff",
|
||||
dbUuid: "d1",
|
||||
userName: "زهرا احمدی",
|
||||
availableContexts: [],
|
||||
context: {
|
||||
type: "doctor",
|
||||
db_uuid: "d1",
|
||||
name: "مطب تست",
|
||||
role: "staff",
|
||||
scope: "doctor",
|
||||
permissions: { version: 1, resources: { services: { view: true }, appointments: { view: true } } },
|
||||
},
|
||||
} as any);
|
||||
|
||||
it("shows only داشبورد and سرویسهای من", () => {
|
||||
asStaff();
|
||||
renderWithProviders(<Sidebar />, { route: "/admin/dashboard" });
|
||||
|
||||
expect(screen.getByText("داشبورد").closest("a")).toHaveAttribute("href", "/admin/dashboard");
|
||||
expect(screen.getByText("سرویسهای من").closest("a")).toHaveAttribute("href", "/admin/my-services");
|
||||
expect(screen.getByText("پرسنل")).toBeInTheDocument(); // برچسب نقش در فوتر
|
||||
});
|
||||
|
||||
it("hides management entries", () => {
|
||||
asStaff();
|
||||
renderWithProviders(<Sidebar />, { route: "/admin/dashboard" });
|
||||
|
||||
for (const label of ["پرونده بیماران", "تنظیمات", "نوبتها", "پرداختها"]) {
|
||||
expect(screen.queryByText(label)).not.toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -953,6 +953,116 @@ function SecretaryDashboard() {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Staff Dashboard ───────────────────────────────────────────────────────
|
||||
|
||||
interface StaffDashboardData {
|
||||
scope: 'doctor' | 'clinic';
|
||||
staff: { uuid: string; full_name: string; job_title: string | null };
|
||||
owner: { name: string };
|
||||
stats: { today_appointments: number; services: number };
|
||||
services: { uuid: string; name: string; section_name: string; price_rials: number; duration_minutes: number | null }[];
|
||||
today_appointments: ApptRow[];
|
||||
}
|
||||
|
||||
function StaffDashboard() {
|
||||
const q = useQuery({
|
||||
queryKey: ['dashboard-staff'],
|
||||
queryFn: () => api.get<ApiResponse<StaffDashboardData>>('/api/v1/dashboard/staff'),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const d = useMemo<StaffDashboardData | undefined>(() => (q.data?.data as any)?.data ?? q.data?.data, [q.data]);
|
||||
const today = new Date().toLocaleDateString('fa-IR', { year: 'numeric', month: 'long', day: 'numeric' });
|
||||
|
||||
if (q.isLoading) return <LoadingSkeleton />;
|
||||
|
||||
if (q.isError || !d) {
|
||||
return (
|
||||
<div className="card card-pad" style={{ marginTop: 'var(--gap)', textAlign: 'center', padding: '2rem' }}>
|
||||
<UserIcon style={{ width: 40, height: 40, color: 'var(--text-3)', margin: '0 auto 1rem' }} />
|
||||
<p className="muted" style={{ fontSize: 13.5 }}>دسترسی شما به این محیط فعال نیست. با مدیر مطب/کلینیک تماس بگیرید.</p>
|
||||
<button className="btn ghost sm" style={{ marginTop: 12 }} onClick={() => q.refetch()}>
|
||||
<ArrowPathIcon style={{ width: 14, height: 14 }} />
|
||||
تلاش دوباره
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const scopeLabel = d.scope === 'clinic' ? 'کلینیک' : 'مطب';
|
||||
const kpiCards = [
|
||||
{ label: 'نوبتهای امروز من', value: formatNumber(d.stats?.today_appointments ?? 0), icon: CalendarDaysIcon, color: 'var(--warning)', bg: 'var(--warning-bg)' },
|
||||
{ label: 'سرویسهای من', value: formatNumber(d.stats?.services ?? 0), icon: ClockIcon, color: 'var(--info)', bg: 'var(--info-bg)' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="fade-in">
|
||||
<div className="card-title-row" style={{ marginBottom: 'var(--gap)' }}>
|
||||
<div>
|
||||
<h1 className="section-title">داشبورد پرسنل</h1>
|
||||
<div className="muted" style={{ fontSize: 13, marginTop: 2 }}>{today} · {scopeLabel} {d.owner?.name ?? ''}</div>
|
||||
</div>
|
||||
<button className="btn ghost sm" onClick={() => q.refetch()}>
|
||||
<ArrowPathIcon style={{ width: 14, height: 14 }} />
|
||||
بهروزرسانی
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="card card-pad" style={{ marginBottom: 'var(--gap)', display: 'flex', alignItems: 'center', gap: 16 }}>
|
||||
<AvatarEl initials={(d.staff?.full_name || 'P').slice(0, 1)} hue={162} size="lg" />
|
||||
<div>
|
||||
<div style={{ fontWeight: 700, fontSize: 16 }}>{d.staff?.full_name ?? '—'}</div>
|
||||
{d.staff?.job_title && <div className="muted" style={{ fontSize: 13, marginTop: 3 }}>{d.staff.job_title}</div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="stat-grid">
|
||||
{kpiCards.map(c => (
|
||||
<div key={c.label} className="stat">
|
||||
<div className="ico" style={{ background: c.bg, color: c.color }}>
|
||||
<c.icon style={{ width: 21, height: 21 }} />
|
||||
</div>
|
||||
<div className="lbl">{c.label}</div>
|
||||
<div className="val">{c.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="card card-pad" style={{ marginTop: 'var(--gap)' }}>
|
||||
<div className="card-title-row">
|
||||
<h3 style={{ fontSize: 16 }}>سرویسهای تخصیصیافته</h3>
|
||||
<Link to="/admin/my-services" className="link">همه سرویسها</Link>
|
||||
</div>
|
||||
{d.services.length === 0 ? (
|
||||
<p className="muted" style={{ fontSize: 13.5, padding: '1.5rem 0', textAlign: 'center' }}>
|
||||
هنوز سرویسی به شما تخصیص نیافته است.
|
||||
</p>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginTop: 8 }}>
|
||||
{d.services.slice(0, 5).map(s => (
|
||||
<div key={s.uuid} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 12px', borderRadius: 'var(--r-sm)', background: 'var(--surface-2)' }}>
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, fontSize: 14 }}>{s.name}</div>
|
||||
<div className="muted" style={{ fontSize: 12, marginTop: 2 }}>{s.section_name}</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 13 }}>{formatRial(s.price_rials)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="card card-pad" style={{ marginTop: 'var(--gap)' }}>
|
||||
<div className="card-title-row">
|
||||
<h3 style={{ fontSize: 16 }}>نوبتهای امروز من</h3>
|
||||
</div>
|
||||
<TodayAppointmentsTable appts={d?.today_appointments ?? []} loading={q.isLoading} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface RepSummary {
|
||||
appointments: { today: number; week: number; month: number; total: number };
|
||||
income: {
|
||||
@@ -1173,6 +1283,7 @@ export default function DashboardPage() {
|
||||
if (primaryRole === 'doctor' && scope === 'clinic') return <InvitedDoctorDashboard />;
|
||||
if (primaryRole === 'doctor') return <DoctorDashboard />;
|
||||
if (primaryRole === 'secretary') return <SecretaryDashboard />;
|
||||
if (primaryRole === 'staff') return <StaffDashboard />;
|
||||
if (primaryRole === 'representation') return <RepresentationDashboard />;
|
||||
|
||||
return <AdminDashboard />;
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { WrenchScrewdriverIcon } from '@heroicons/react/24/outline';
|
||||
import { api } from '../lib/api';
|
||||
import type { ApiResponse } from '../lib/api';
|
||||
import type { StaffAssignedService } from '../types';
|
||||
import { formatRial } from '../lib/utils';
|
||||
import DataTable, { type Column } from '../components/ui/DataTable';
|
||||
import PageHeader from '../components/ui/PageHeader';
|
||||
|
||||
interface StaffDashboardData {
|
||||
services: StaffAssignedService[];
|
||||
}
|
||||
|
||||
const EMPTY: StaffAssignedService[] = [];
|
||||
|
||||
/**
|
||||
* سرویسهای تخصیصیافته به پرسنل — فقط خواندنی.
|
||||
* داده از همان اندپوینت داشبورد پرسنل میآید؛ نقش staff اندپوینت دیگری ندارد.
|
||||
*/
|
||||
export default function StaffMyServicesPage() {
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['dashboard-staff'],
|
||||
queryFn: () => api.get<ApiResponse<StaffDashboardData>>('/api/v1/dashboard/staff'),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const services = data?.data?.services ?? EMPTY;
|
||||
|
||||
const columns: Column<StaffAssignedService>[] = [
|
||||
{
|
||||
key: 'name',
|
||||
header: 'سرویس',
|
||||
render: (s) => (
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, fontSize: 14 }}>{s.name}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-3)', marginTop: 2 }}>{s.section_name}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'price_rials',
|
||||
header: 'تعرفه',
|
||||
render: (s) => <span style={{ fontSize: 13 }}>{formatRial(s.price_rials)}</span>,
|
||||
},
|
||||
{
|
||||
key: 'duration_minutes',
|
||||
header: 'مدت',
|
||||
render: (s) => (
|
||||
<span style={{ fontSize: 13, color: 'var(--text-3)' }}>
|
||||
{s.duration_minutes ? `${s.duration_minutes} دقیقه` : '—'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="سرویسهای من"
|
||||
description="سرویسهایی که به شما تخصیص داده شده است"
|
||||
backTo="/admin/dashboard"
|
||||
/>
|
||||
|
||||
<div className="card">
|
||||
{services.length === 0 && !isLoading ? (
|
||||
<div style={{ textAlign: 'center', padding: '60px 24px', color: 'var(--text-3)' }}>
|
||||
<WrenchScrewdriverIcon style={{ width: 48, margin: '0 auto 16px', display: 'block', opacity: 0.4 }} />
|
||||
<div style={{ fontWeight: 600, fontSize: 15, marginBottom: 8, color: 'var(--text-2)' }}>
|
||||
هنوز سرویسی به شما تخصیص نیافته
|
||||
</div>
|
||||
<div style={{ fontSize: 13 }}>پس از تخصیص سرویس توسط مطب/کلینیک، اینجا نمایش داده میشود.</div>
|
||||
</div>
|
||||
) : (
|
||||
<DataTable columns={columns} data={services} loading={isLoading} />
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -18,12 +18,22 @@ import { ActiveBadge } from '../components/ui/StatusBadge';
|
||||
import { numericField } from '../lib/forms';
|
||||
import { usePermissions } from '../hooks/usePermissions';
|
||||
|
||||
// هر پرسنل حساب کاربری ورود دارد، پس موبایل همان نامکاربری است و اجباری.
|
||||
const schema = z.object({
|
||||
full_name: z.string().min(2, 'نام حداقل ۲ کاراکتر باید باشد'),
|
||||
phone: z.string().optional(),
|
||||
phone: z.string().regex(/^09\d{9}$/, 'شماره موبایل معتبر (۱۱ رقمی) وارد کنید'),
|
||||
job_title: z.string().optional(),
|
||||
address: z.string().optional(),
|
||||
national_code: z.string().optional(),
|
||||
password: z.string().optional(),
|
||||
}).superRefine((data, ctx) => {
|
||||
if (data.password && data.password.length < 8) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['password'],
|
||||
message: 'رمز عبور حداقل ۸ کاراکتر باشد',
|
||||
});
|
||||
}
|
||||
});
|
||||
type StaffFormData = z.infer<typeof schema>;
|
||||
|
||||
@@ -91,6 +101,7 @@ export default function StaffPage() {
|
||||
job_title: s.job_title ?? '',
|
||||
address: s.address ?? '',
|
||||
national_code: s.national_code ?? '',
|
||||
password: '',
|
||||
});
|
||||
setEditTarget(s);
|
||||
};
|
||||
@@ -131,6 +142,15 @@ export default function StaffPage() {
|
||||
header: 'وضعیت',
|
||||
render: (s) => <ActiveBadge active={s.active} />,
|
||||
},
|
||||
{
|
||||
key: 'has_account',
|
||||
header: 'حساب کاربری',
|
||||
render: (s) => (
|
||||
<span className={`badge ${s.has_account ? 'green' : ''}`} style={{ fontSize: 12 }}>
|
||||
{s.has_account ? 'دارد' : 'ندارد'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'created_at',
|
||||
header: 'تاریخ ثبت',
|
||||
@@ -275,8 +295,9 @@ function StaffFormFields({ form }: { form: ReturnType<typeof useForm<StaffFormDa
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||
<div className="field">
|
||||
<label>تلفن</label>
|
||||
<label>موبایل (نام کاربری ورود) *</label>
|
||||
<input {...numericField(register('phone'), 11)} placeholder="09121234567" />
|
||||
{errors.phone && <span className="field-error">{errors.phone.message}</span>}
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>کد ملی</label>
|
||||
@@ -287,6 +308,16 @@ function StaffFormFields({ form }: { form: ReturnType<typeof useForm<StaffFormDa
|
||||
<label>آدرس</label>
|
||||
<input {...register('address')} placeholder="آدرس محل سکونت" />
|
||||
</div>
|
||||
|
||||
<div className="field" style={{ borderTop: '1px solid var(--border)', paddingTop: 12 }}>
|
||||
<label>رمز عبور ورود به پنل</label>
|
||||
<input type="password" autoComplete="new-password" {...register('password')} placeholder="حداقل ۸ کاراکتر — خالی یعنی بدون تغییر" />
|
||||
{errors.password && <span className="field-error">{errors.password.message}</span>}
|
||||
<div style={{ fontSize: 12, color: 'var(--text-3)', marginTop: 4 }}>
|
||||
برای هر پرسنل حساب کاربری ساخته میشود: با همین شماره موبایل وارد پنل میشود و فقط
|
||||
داشبورد و سرویسهای خودش را میبیند.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ export interface ContextItem {
|
||||
type: 'doctor' | 'clinic';
|
||||
db_uuid: string;
|
||||
name: string;
|
||||
role: 'admin' | 'clinic' | 'doctor' | 'secretary' | 'representation' | 'user';
|
||||
role: 'admin' | 'clinic' | 'doctor' | 'secretary' | 'staff' | 'representation' | 'user';
|
||||
scope?: string | null;
|
||||
doctor_uuid?: string;
|
||||
permissions?: Record<string, any>;
|
||||
@@ -17,7 +17,7 @@ interface AuthState {
|
||||
isAuthenticated: boolean;
|
||||
userUuid: string | null;
|
||||
userName: string | null;
|
||||
primaryRole: 'admin' | 'clinic' | 'doctor' | 'secretary' | 'representation' | 'user' | null;
|
||||
primaryRole: 'admin' | 'clinic' | 'doctor' | 'secretary' | 'staff' | 'representation' | 'user' | null;
|
||||
dbUuid: string | null;
|
||||
dbKey: string | null;
|
||||
doctorUuid: string | null;
|
||||
|
||||
@@ -607,9 +607,21 @@ export interface ClinicStaff {
|
||||
address: string | null;
|
||||
national_code: string | null;
|
||||
active: boolean;
|
||||
/** حساب ورود به پنل با نقش ROLE_STAFF */
|
||||
has_account: boolean;
|
||||
user_uuid: string | null;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
/** یک قلم از سرویسهای تخصیصیافته به پرسنل — خروجی GET /api/v1/dashboard/staff */
|
||||
export interface StaffAssignedService {
|
||||
uuid: string;
|
||||
name: string;
|
||||
section_name: string;
|
||||
price_rials: number;
|
||||
duration_minutes: number | null;
|
||||
}
|
||||
|
||||
export interface SubscriptionPlan {
|
||||
uuid: string;
|
||||
name: string;
|
||||
|
||||
+14
-1
@@ -309,7 +309,7 @@ Authorization: Bearer <token>
|
||||
|
||||
| فیلد | نوع | توضیح |
|
||||
|------|-----|-------|
|
||||
| `primary_role` | string | نقش اصلی: `admin` \| `clinic` \| `doctor` \| `secretary` \| `representation` \| `user` |
|
||||
| `primary_role` | string | نقش اصلی: `admin` \| `clinic` \| `doctor` \| `secretary` \| `staff` \| `representation` \| `user` |
|
||||
| `db_uuid` | string\|null | UUID موجودیت فعال (null = هنوز context انتخاب نشده) |
|
||||
| `db_key` | string\|null | `HMAC-SHA256(db_uuid, APP_SECRET)` برای اعتبارسنجی |
|
||||
| `doctor_uuid` | string\|null | UUID دکتر — ثابت است حتی در context کلینیک که `db_uuid` برابر UUID کلینیک است. برای کاربران غیر دکتر: `null` |
|
||||
@@ -327,6 +327,7 @@ Authorization: Bearer <token>
|
||||
| پزشکِ عضو کلینیک (`role: doctor`، `scope: clinic`) | envelope کامل `{version, resources}` از `clinic_doctor_permissions` |
|
||||
| پزشکِ عضوی که دسترسیاش غیرفعال شده | `{version: 1, resources: {}}` — یعنی هیچ دسترسی |
|
||||
| منشی (`role: secretary`) | envelope کامل از `doctor_secretaries` |
|
||||
| پرسنل (`role: staff`) | envelope ثابت `{"version":1,"resources":{"services":{"view":true},"appointments":{"view":true}}}` — قابل ویرایش نیست |
|
||||
|
||||
نکتهٔ مهم برای کلاینت: **نبودِ `permissions` (یا `null`) یعنی «بدون محدودیت»، نه «بدون دسترسی».** ساختار و کلیدهای مجوز پزشکِ عضو کلینیک در `docs/api/clinic.md` → بخش *Clinic Doctor Permissions* آمده است.
|
||||
|
||||
@@ -335,14 +336,26 @@ Authorization: Bearer <token>
|
||||
- `ROLE_CLINIC` → `"clinic"`
|
||||
- `ROLE_DOCTOR` → `"doctor"`
|
||||
- `ROLE_SECRETARY` → `"secretary"`
|
||||
- `ROLE_STAFF` → `"staff"` (پرسنل کلینیک/مطب؛ عمداً بعد از منشی: کاربری که هر دو نقش را دارد منشی میماند)
|
||||
- `ROLE_REPRESENTATION` → `"representation"` (نماینده؛ دسترسی محدود به پنل ادمین: افزودن پزشک/کلینیک، نوبتهای پزشکانِ زیرمجموعه، داشبورد نماینده)
|
||||
- بقیه → `"user"`
|
||||
|
||||
**نقش `staff`** — کاربری که از `POST /api/v1/staff` با `has_account: true` ساخته شده
|
||||
(رجوع به [staff.md](staff.md)):
|
||||
|
||||
- هر ردیف **فعالِ** `clinic_staff` که به این کاربر وصل است، یک context با `role: "staff"` و
|
||||
`scope` برابر `doctor` یا `clinic` میسازد. پرسنلِ غیرفعال هیچ context نمیگیرد.
|
||||
- ورود با رمز مجاز است (`User::isStaff()` شامل `ROLE_STAFF` است).
|
||||
- دسترسی API این کاربر **پیشفرض بسته** است: فقط `GET /api/v1/dashboard/staff`،
|
||||
`POST /api/v1/auth/switch-context`، `POST /api/v1/user/change-password` و مسیرهای `/oauth/*`؛
|
||||
بقیهٔ `/api/v1/*` با `ERR_FORBIDDEN_001` و ۴۰۳ رد میشود (`StaffRouteGuardSubscriber`).
|
||||
|
||||
**قانون `context.role`** — نقشی که در آن محیط کاری فعال است:
|
||||
- context مطب شخصی دکتر: `"doctor"`
|
||||
- context کلینیک که دکتر **عضو** آن است (مالک نیست): `"doctor"` + `"scope": "clinic"` — پزشک میماند و فقط نوبتهای خودش در آن کلینیک را میبیند؛ دسترسی مدیریتی پنل کلینیک ندارد
|
||||
- context کلینیک که دکتر **صاحب** آن است: `"clinic"` (دسترسی کامل مالک)
|
||||
- context منشی: `"secretary"`
|
||||
- context پرسنل: `"staff"` + `scope` برابر نوع محیط (`doctor` یا `clinic`)
|
||||
|
||||
> **نکته frontend:** پس از `switchContext`، `primaryRole` در store از `context.role` و `scope` از `context.scope` آپدیت میشود. وقتی `role:"doctor"` و `scope:"clinic"` است (پزشکِ مهمان)، Sidebar فقط «داشبورد» و «نوبتها» را نشان میدهد و مسیرهای مدیریتی (`staff`, `clinic-services`, `subscription`, `my-secretaries`, `my-patients`, `profile`) به داشبورد ریدایرکت میشوند. در سمت backend هم endpointهای مدیریتی برای پزشک فقط scope **شخصیِ** خودش را برمیگردانند (نه کلینیک) و endpointهای ویرایش کلینیک مالکیت را چک میکنند (۴۰۳).
|
||||
|
||||
|
||||
@@ -242,6 +242,71 @@ Returns stats for the authenticated secretary and (conditionally) today's appoin
|
||||
|
||||
---
|
||||
|
||||
## GET /api/v1/dashboard/staff
|
||||
|
||||
داشبورد پرسنل: سرویسهایی که به این پرسنل تخصیص یافته و نوبتهای امروزِ خودش.
|
||||
|
||||
**Auth:** `ROLE_STAFF` — و علاوه بر نقش، باید ردیف **فعالِ** `clinic_staff` در محیط فعال وجود
|
||||
داشته باشد. توکن تا انقضا معتبر میماند، پس غیرفعالکردن پرسنل همان لحظه با همین بررسی
|
||||
دسترسی را میبندد.
|
||||
|
||||
این تنها اندپوینت دادهٔ نقش `staff` است؛ بقیهٔ `/api/v1/*` برای این نقش ۴۰۳ میدهد
|
||||
(`StaffRouteGuardSubscriber` — رجوع به [auth.md](auth.md)).
|
||||
|
||||
### Response `200` (خروجی واقعی)
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"scope": "doctor",
|
||||
"staff": {
|
||||
"uuid": "c99320e2-257a-4d96-9b3a-7723fe198e79",
|
||||
"full_name": "زهرا احمدی",
|
||||
"job_title": "پرستار"
|
||||
},
|
||||
"owner": { "name": "09390039833" },
|
||||
"permissions": {
|
||||
"version": 1,
|
||||
"resources": { "services": { "view": true }, "appointments": { "view": true } }
|
||||
},
|
||||
"stats": { "today_appointments": 0, "services": 1 },
|
||||
"services": [
|
||||
{
|
||||
"uuid": "f9ffb607-f137-4ffb-8327-76427fd6fe55",
|
||||
"name": "سرم",
|
||||
"section_name": "تزریقات",
|
||||
"price_rials": 1000000,
|
||||
"duration_minutes": null
|
||||
}
|
||||
],
|
||||
"today_appointments": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| فیلد | نوع | توضیح |
|
||||
|------|-----|-------|
|
||||
| `scope` | `doctor` \| `clinic` | نوع محیطِ فعال |
|
||||
| `owner.name` | string | نام مطب/کلینیکِ مالک |
|
||||
| `services` | array | سرویسهای **فعالِ** تخصیصیافته به این پرسنل (`service_item_staff` و ستون legacy تکی) |
|
||||
| `today_appointments` | array | نوبتهای امروز با `appointments.staff_id` برابر این پرسنل — `uuid`, `patient_name`, `patient_mobile`, `slot_start`, `status` |
|
||||
| `permissions` | object | ثابت است و ویرایشپذیر نیست |
|
||||
|
||||
### Errors
|
||||
|
||||
| Code | HTTP | Description |
|
||||
|------|------|-------------|
|
||||
| `ERR_FORBIDDEN_001` | 403 | محیط کاری تنظیم نشده، یا ردیف پرسنل در آن محیط فعال نیست |
|
||||
| `ERR_AUTH_001` | 401 | بدون توکن |
|
||||
|
||||
خروجی واقعی حالت غیرفعال:
|
||||
```json
|
||||
{"success":false,"data":null,"errors":[{"code":"ERR_FORBIDDEN_001","message":"محیط کاری پرسنل تنظیم نشده"}]}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## GET /api/v1/admin/dashboard/charts
|
||||
|
||||
Returns time-series chart data for admin dashboard. All series are filtered to the given `from`–`to` window.
|
||||
|
||||
+58
-17
@@ -2,6 +2,18 @@
|
||||
|
||||
مدیریت پرسنل مطب/کلینیک (بدون حذف — فقط toggle فعال/غیرفعال).
|
||||
|
||||
**هر پرسنل حساب کاربری ورود دارد.** هنگام ایجاد/ویرایش، یک `User` با نقش `ROLE_STAFF` ساخته
|
||||
(یا کاربر موجودِ همان موبایل استفاده) و به ردیف پرسنل وصل میشود؛ بنابراین `phone` اجباری و
|
||||
معتبر (`^09\d{9}$`) است و همان **نامکاربری ورود** است. تغییر `phone` یعنی تغییر نامکاربری.
|
||||
|
||||
چنین کاربری در `/admin` وارد میشود، ولی دسترسیاش به `GET /api/v1/dashboard/staff` و چند
|
||||
مسیر حساب کاربری محدود است (بقیهٔ `/api/v1/*` برای او ۴۰۳ است — رجوع به
|
||||
[dashboard.md](dashboard.md) و [auth.md](auth.md)). قطع دسترسی با
|
||||
`PATCH /api/v1/staff/{uuid}/toggle` انجام میشود، نه با حذف حساب.
|
||||
|
||||
> ردیفهای پرسنلِ ساختهشده پیش از این تغییر ممکن است `has_account: false` باشند؛ با اولین
|
||||
> ویرایش (که `phone` معتبر میخواهد) صاحب حساب میشوند.
|
||||
|
||||
---
|
||||
|
||||
## GET /api/v1/staff
|
||||
@@ -25,6 +37,8 @@
|
||||
"address": null,
|
||||
"national_code": "0012345678",
|
||||
"active": true,
|
||||
"has_account": false,
|
||||
"user_uuid": null,
|
||||
"created_at": 1718000000,
|
||||
"updated_at": 1718000000
|
||||
}
|
||||
@@ -32,6 +46,11 @@
|
||||
}
|
||||
```
|
||||
|
||||
| فیلد | نوع | توضیح |
|
||||
|------|-----|-------|
|
||||
| has_account | bool | حساب ورود دارد یا نه — برای ردیفهای جدید همیشه `true`؛ `false` فقط در ردیفهای قدیمیِ پیش از این قابلیت |
|
||||
| user_uuid | string\|null | uuid کاربرِ متصل؛ `null` یعنی حساب ندارد |
|
||||
|
||||
---
|
||||
|
||||
## POST /api/v1/staff
|
||||
@@ -43,38 +62,45 @@
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"full_name": "علی محمدی",
|
||||
"phone": "09121234567",
|
||||
"job_title": "منشی",
|
||||
"full_name": "محمد رحیمی",
|
||||
"phone": "09121110002",
|
||||
"job_title": "پرستار",
|
||||
"address": "تهران، خیابان ولیعصر",
|
||||
"national_code": "0012345678"
|
||||
"national_code": "0012345678",
|
||||
"password": "Staff@1234"
|
||||
}
|
||||
```
|
||||
|
||||
| فیلد | نوع | الزامی |
|
||||
|------|-----|--------|
|
||||
| full_name | string | ✅ |
|
||||
| phone | string | ❌ |
|
||||
| phone | string — `^09\d{9}$`، ارقام فارسی به لاتین تبدیل میشوند | ✅ نامکاربری ورود |
|
||||
| job_title | string | ❌ |
|
||||
| address | string | ❌ |
|
||||
| national_code | string(15) — ارقام فارسی به لاتین تبدیل میشوند | ❌ |
|
||||
| password | string | ❌ — رمز ورود؛ خالی بگذارید تا کاربر با «فراموشی رمز» تعیینش کند. روی کاربر موجود، رمز فعلی پاک نمیشود |
|
||||
|
||||
**Response 201:**
|
||||
اگر موبایل قبلاً `User` داشته باشد، کاربر جدید ساخته نمیشود؛ فقط `ROLE_STAFF` به نقشهایش
|
||||
اضافه و به این ردیف پرسنل وصل میشود.
|
||||
|
||||
**Response 201** (خروجی واقعی):
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"uuid": "a1b2c3d4-...",
|
||||
"entity_type": "clinic",
|
||||
"entity_id": 5,
|
||||
"full_name": "علی محمدی",
|
||||
"phone": "09121234567",
|
||||
"job_title": "منشی",
|
||||
"address": "تهران، خیابان ولیعصر",
|
||||
"national_code": "0012345678",
|
||||
"uuid": "d64826bc-5e5d-4df6-83bb-3ddc70a99636",
|
||||
"entity_type": "doctor",
|
||||
"entity_id": 1,
|
||||
"full_name": "محمد رحیمی",
|
||||
"phone": "09121110002",
|
||||
"job_title": "پرستار",
|
||||
"address": null,
|
||||
"national_code": null,
|
||||
"active": true,
|
||||
"created_at": 1718000000,
|
||||
"updated_at": 1718000000
|
||||
"has_account": true,
|
||||
"user_uuid": "4d79b9b0-f330-4dea-9f02-e38fc716b115",
|
||||
"created_at": 1785393740,
|
||||
"updated_at": 1785393740
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -83,8 +109,16 @@
|
||||
| Code | HTTP | توضیح |
|
||||
|------|------|-------|
|
||||
| ERR_VALIDATION_001 | 422 | full_name خالی است |
|
||||
| ERR_STAFF_MOBILE_INVALID | 422 | شماره خالی/نامعتبر است یا شمارهٔ خودِ مالک محیط است (`field: "phone"`) |
|
||||
| ERR_STAFF_MOBILE_TAKEN | 409 | در همین محیط، پرسنل دیگری با این شماره ثبت شده است (`field: "phone"`) |
|
||||
| ERR_FORBIDDEN_001 | 403 | پروفایل doctor/clinic یافت نشد |
|
||||
|
||||
خروجی واقعی خطاها:
|
||||
```json
|
||||
{"success":false,"data":null,"errors":[{"code":"ERR_STAFF_MOBILE_INVALID","message":"شماره موبایل پرسنل معتبر نیست","field":"phone"}]}
|
||||
{"success":false,"data":null,"errors":[{"code":"ERR_STAFF_MOBILE_TAKEN","message":"برای این شماره قبلاً پرسنلی ثبت شده است","field":"phone"}]}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## PATCH /api/v1/staff/{uuid}
|
||||
@@ -100,16 +134,23 @@
|
||||
"phone": "09129999999",
|
||||
"job_title": "منشی ارشد",
|
||||
"address": null,
|
||||
"national_code": null
|
||||
"national_code": null,
|
||||
"password": "NewPass@123"
|
||||
}
|
||||
```
|
||||
|
||||
ویرایش هم حساب را میسازد/بهروز میکند: اگر `phone` ارسال نشود، شمارهٔ فعلی همان ردیف
|
||||
استفاده میشود؛ اگر شمارهٔ جدید بیاید، نامکاربری ورود عوض میشود. `password` خالی رمز فعلی
|
||||
را پاک نمیکند.
|
||||
|
||||
**Response 200:** همان ساختار staff object
|
||||
|
||||
**Errors:**
|
||||
| Code | HTTP | توضیح |
|
||||
|------|------|-------|
|
||||
| ERR_STAFF_NOT_FOUND | 404 | پرسنل یافت نشد |
|
||||
| ERR_STAFF_MOBILE_INVALID | 422 | شمارهٔ نامعتبر یا شمارهٔ مالک محیط |
|
||||
| ERR_STAFF_MOBILE_TAKEN | 409 | شماره در همین محیط قبلاً ثبت شده |
|
||||
| ERR_FORBIDDEN_001 | 403 | دسترسی ندارید |
|
||||
|
||||
---
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
<?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 Version20260730060615 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Link clinic_staff rows to a login account (users.id) for the ROLE_STAFF panel access.';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE clinic_staff ADD user_id INT DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE clinic_staff ADD CONSTRAINT FK_CBEA5AA1A76ED395 FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE SET NULL');
|
||||
$this->addSql('CREATE INDEX IDX_CBEA5AA1A76ED395 ON clinic_staff (user_id)');
|
||||
$this->addSql('CREATE INDEX idx_staff_user_active ON clinic_staff (user_id, active)');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE clinic_staff DROP FOREIGN KEY FK_CBEA5AA1A76ED395');
|
||||
$this->addSql('DROP INDEX IDX_CBEA5AA1A76ED395 ON clinic_staff');
|
||||
$this->addSql('DROP INDEX idx_staff_user_active ON clinic_staff');
|
||||
$this->addSql('ALTER TABLE clinic_staff DROP user_id');
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,8 @@ use App\Secretary\Repository\DoctorSecretaryRepository;
|
||||
use App\Shared\Captcha\CaptchaGuard;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Controller\BaseController;
|
||||
use App\Staff\Repository\ClinicStaffRepository;
|
||||
use App\Staff\Security\StaffPermissions;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use OpenApi\Attributes as OA;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
@@ -42,6 +44,7 @@ class AuthController extends BaseController
|
||||
private readonly ClinicDoctorPermissionRepository $clinicDoctorPermRepo,
|
||||
private readonly DoctorSecretaryRepository $secretaryRepo,
|
||||
private readonly UserActiveContextRepository $contextRepo,
|
||||
private readonly ClinicStaffRepository $staffRepo,
|
||||
private readonly UserPasswordHasherInterface $hasher,
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly CaptchaGuard $captcha,
|
||||
@@ -694,6 +697,8 @@ class AuthController extends BaseController
|
||||
if (in_array('ROLE_CLINIC', $roles, true)) return 'clinic';
|
||||
if (in_array('ROLE_DOCTOR', $roles, true)) return 'doctor';
|
||||
if (in_array('ROLE_SECRETARY', $roles, true)) return 'secretary';
|
||||
// پرسنل عمداً بعد از منشی: کسی که هر دو نقش را دارد، نقش پرتوانترش میماند.
|
||||
if (in_array('ROLE_STAFF', $roles, true)) return 'staff';
|
||||
if (in_array('ROLE_REPRESENTATION', $roles, true)) return 'representation';
|
||||
return 'user';
|
||||
}
|
||||
@@ -778,6 +783,29 @@ class AuthController extends BaseController
|
||||
}
|
||||
}
|
||||
|
||||
// پرسنل: هر ردیف فعالِ ClinicStaff یک محیط است. مجوزها ثابتاند (نه قابل
|
||||
// ویرایش مثل منشی) تا پنل بداند این نقش فقط حق دیدن دارد.
|
||||
foreach ($this->staffRepo->findActiveByUser($user) as $row) {
|
||||
$owner = $row->getEntityType() === 'clinic'
|
||||
? $this->clinicRepo->find($row->getEntityId())
|
||||
: $this->doctorRepo->find($row->getEntityId());
|
||||
|
||||
if ($owner === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$contexts[] = [
|
||||
'type' => $row->getEntityType(),
|
||||
'db_uuid' => $owner->getUuid(),
|
||||
'name' => $row->getEntityType() === 'clinic'
|
||||
? ($owner->getName() ?? '')
|
||||
: 'مطب ' . $owner->getName(),
|
||||
'role' => 'staff',
|
||||
'scope' => $row->getEntityType(),
|
||||
'permissions' => StaffPermissions::DEFAULT,
|
||||
];
|
||||
}
|
||||
|
||||
return $contexts;
|
||||
}
|
||||
|
||||
|
||||
@@ -123,6 +123,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
||||
return $this->hasRole('ROLE_DOCTOR')
|
||||
|| $this->hasRole('ROLE_CLINIC')
|
||||
|| $this->hasRole('ROLE_SECRETARY')
|
||||
|| $this->hasRole('ROLE_STAFF') // پرسنل کلینیک/مطب — داشبورد محدود خودش
|
||||
|| $this->hasRole('ROLE_ADMIN')
|
||||
|| $this->hasRole('ROLE_REPRESENTATION') // نماینده — لاگین با نامکاربری/رمز مجاز است
|
||||
|| $this->hasRole('ROLE_IMPORTER'); // کاربر سیستمی کرالر — لاگین با رمز؛ دسترسی فقط اندپوینت ایمپورت
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\ClinicService\Repository;
|
||||
|
||||
use App\ClinicService\Entity\ServiceItem;
|
||||
use App\ClinicService\Entity\ServiceSection;
|
||||
use App\Staff\Entity\ClinicStaff;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
@@ -41,6 +42,28 @@ class ServiceItemRepository extends ServiceEntityRepository
|
||||
->getResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* سرویسهای فعالِ تخصیصیافته به یک پرسنل، محدود به محیط خودش.
|
||||
*
|
||||
* ردیفهای قبل از مهاجرتِ چندپرسنلی فقط ستون تکیِ `staff` را دارند، پس هر دو
|
||||
* سمت رابطه بررسی میشود.
|
||||
*
|
||||
* @return ServiceItem[]
|
||||
*/
|
||||
public function findByStaff(ClinicStaff $staff): array
|
||||
{
|
||||
return $this->createQueryBuilder('i')
|
||||
->join('i.section', 's')
|
||||
->leftJoin('i.staffMembers', 'm')
|
||||
->where('s.entityType = :type')->setParameter('type', $staff->getEntityType())
|
||||
->andWhere('s.entityId = :id')->setParameter('id', $staff->getEntityId())
|
||||
->andWhere('i.active = true')
|
||||
->andWhere('m = :staff OR i.staff = :staff')->setParameter('staff', $staff)
|
||||
->orderBy('i.name', 'ASC')
|
||||
->getQuery()
|
||||
->getResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* Count services per section in a single query (avoids N+1 in the section list).
|
||||
*
|
||||
|
||||
@@ -4,6 +4,8 @@ namespace App\Dashboard\Controller;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Clinic\Repository\ClinicRepository;
|
||||
use App\ClinicService\Entity\ServiceItem;
|
||||
use App\ClinicService\Repository\ServiceItemRepository;
|
||||
use App\Doctor\Repository\DoctorRepository;
|
||||
use App\Patient\Repository\PatientRecordRepository;
|
||||
use App\Patient\Repository\PatientSessionRepository;
|
||||
@@ -13,6 +15,8 @@ use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Context\EntityContextResolver;
|
||||
use App\Shared\Controller\BaseController;
|
||||
use App\Sms\Service\SmsWalletService;
|
||||
use App\Staff\Repository\ClinicStaffRepository;
|
||||
use App\Staff\Security\StaffPermissions;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
@@ -35,6 +39,8 @@ class DashboardController extends BaseController
|
||||
private readonly EntityContextResolver $contextResolver,
|
||||
private readonly \App\Clinic\Security\ClinicDoctorPermissionChecker $permChecker,
|
||||
private readonly \App\Doctor\Repository\DoctorAddressRepository $addressRepo,
|
||||
private readonly ClinicStaffRepository $staffRepo,
|
||||
private readonly ServiceItemRepository $serviceItemRepo,
|
||||
) {}
|
||||
|
||||
// ── Clinic Dashboard ────────────────────────────────────────────────────
|
||||
@@ -668,5 +674,75 @@ class DashboardController extends BaseController
|
||||
'today_appointments' => $todayAppts,
|
||||
]);
|
||||
}
|
||||
|
||||
// ── Staff Dashboard ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* داشبورد پرسنل: سرویسهایی که به او تخصیص یافته و نوبتهای امروزِ خودش.
|
||||
*
|
||||
* نقش تنها کافی نیست — ردیف فعالِ پرسنل در محیط فعال هم باید وجود داشته باشد،
|
||||
* چون توکنِ صادرشده تا انقضا معتبر میماند و غیرفعالشدنِ پرسنل باید همان لحظه
|
||||
* دسترسی را ببندد.
|
||||
*/
|
||||
#[Route('/api/v1/dashboard/staff', methods: ['GET'])]
|
||||
#[IsGranted('ROLE_STAFF')]
|
||||
public function staff(#[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$context = $this->contextResolver->resolve($user);
|
||||
if (!$context->isResolved()) {
|
||||
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'محیط کاری پرسنل تنظیم نشده', 403);
|
||||
}
|
||||
|
||||
[$entityType, $entityId] = $context->toEntityPair();
|
||||
$staff = $this->staffRepo->findActiveByUserAndEntity($user, $entityType, $entityId);
|
||||
if ($staff === null) {
|
||||
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'دسترسی پرسنل تنظیم نشده', 403);
|
||||
}
|
||||
|
||||
$todayStart = strtotime('today midnight');
|
||||
$todayEnd = strtotime('tomorrow midnight') - 1;
|
||||
|
||||
$todayAppts = $this->em->createQuery('
|
||||
SELECT a.uuid, u.realName AS patient_name, u.mobileNumber AS patient_mobile,
|
||||
a.slotStart AS slot_start, a.status
|
||||
FROM App\Appointment\Entity\Appointment a
|
||||
JOIN a.user u
|
||||
WHERE a.staff = :staff AND a.slotStart >= :s AND a.slotStart <= :e
|
||||
ORDER BY a.slotStart ASC
|
||||
')->setParameters(['staff' => $staff, 's' => $todayStart, 'e' => $todayEnd])
|
||||
->getArrayResult();
|
||||
|
||||
$services = array_map(
|
||||
static fn(ServiceItem $item) => [
|
||||
'uuid' => $item->getUuid(),
|
||||
'name' => $item->getName(),
|
||||
'section_name' => $item->getSection()->getName(),
|
||||
'price_rials' => $item->getPriceRials(),
|
||||
'duration_minutes' => $item->getDurationMinutes(),
|
||||
],
|
||||
$this->serviceItemRepo->findByStaff($staff),
|
||||
);
|
||||
|
||||
return $this->success([
|
||||
'scope' => $entityType,
|
||||
'staff' => [
|
||||
'uuid' => $staff->getUuid(),
|
||||
'full_name' => $staff->getFullName(),
|
||||
'job_title' => $staff->getJobTitle(),
|
||||
],
|
||||
'owner' => [
|
||||
'name' => $context->isClinic()
|
||||
? ($context->clinic?->getName() ?? '')
|
||||
: ($context->doctor?->getName() ?? ''),
|
||||
],
|
||||
'permissions' => StaffPermissions::DEFAULT,
|
||||
'stats' => [
|
||||
'today_appointments' => count($todayAppts),
|
||||
'services' => count($services),
|
||||
],
|
||||
'services' => $services,
|
||||
'today_appointments' => $todayAppts,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -48,7 +48,9 @@ class ErrorCodes
|
||||
public const ERR_SECRETARY_001 = 'ERR_SECRETARY_001';
|
||||
|
||||
// Staff
|
||||
public const ERR_STAFF_NOT_FOUND = 'ERR_STAFF_NOT_FOUND';
|
||||
public const ERR_STAFF_NOT_FOUND = 'ERR_STAFF_NOT_FOUND';
|
||||
public const ERR_STAFF_MOBILE_INVALID = 'ERR_STAFF_MOBILE_INVALID';
|
||||
public const ERR_STAFF_MOBILE_TAKEN = 'ERR_STAFF_MOBILE_TAKEN';
|
||||
|
||||
// Subscription
|
||||
public const ERR_SUBSCRIPTION_REQUIRED = 'ERR_SUBSCRIPTION_REQUIRED';
|
||||
@@ -141,6 +143,8 @@ class ErrorCodes
|
||||
self::ERR_RATE_LIMIT_001 => 'درخواستهای زیاد. لطفاً بعداً تلاش کنید',
|
||||
self::ERR_CAPTCHA_001 => 'تأیید امنیتی ناموفق بود. لطفاً صفحه را رفرش کنید و دوباره تلاش کنید',
|
||||
self::ERR_STAFF_NOT_FOUND => 'پرسنل یافت نشد',
|
||||
self::ERR_STAFF_MOBILE_INVALID => 'شماره موبایل پرسنل معتبر نیست',
|
||||
self::ERR_STAFF_MOBILE_TAKEN => 'برای این شماره قبلاً پرسنلی ثبت شده است',
|
||||
self::ERR_SUBSCRIPTION_REQUIRED => 'این قابلیت نیاز به پنل Basic یا بالاتر دارد',
|
||||
self::ERR_TRIAL_ALREADY_USED => 'قبلاً از تریال استفاده کردهاید',
|
||||
self::ERR_TRIAL_DISABLED => 'تریال در حال حاضر غیرفعال است',
|
||||
|
||||
@@ -11,6 +11,7 @@ use App\Doctor\Repository\DoctorRepository;
|
||||
use App\Secretary\Repository\DoctorSecretaryRepository;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Exception\AppException;
|
||||
use App\Staff\Repository\ClinicStaffRepository;
|
||||
|
||||
/**
|
||||
* تنها نقطهٔ تصمیمگیری دربارهٔ «این درخواست در کدام محیط اجرا میشود؟».
|
||||
@@ -34,6 +35,7 @@ class EntityContextResolver
|
||||
private readonly ClinicRepository $clinicRepo,
|
||||
private readonly UserActiveContextRepository $activeContextRepo,
|
||||
private readonly DoctorSecretaryRepository $secretaryRepo,
|
||||
private readonly ClinicStaffRepository $staffRepo,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -92,7 +94,13 @@ class EntityContextResolver
|
||||
return $clinic !== null ? EntityContext::forClinic($clinic) : EntityContext::unknown();
|
||||
}
|
||||
|
||||
/** مالک کلینیک، ادمین، پزشکِ عضو همان کلینیک، یا منشیِ دارای رابطهٔ فعال در آن. */
|
||||
/**
|
||||
* مالک کلینیک، ادمین، پزشکِ عضو همان کلینیک، منشیِ دارای رابطهٔ فعال در آن، یا
|
||||
* پرسنلِ فعالِ همان کلینیک.
|
||||
*
|
||||
* «میتواند در این محیط بایستد» یعنی محیطش حل میشود — نه اینکه هر کاری در آن
|
||||
* مجاز است؛ محدودهٔ پرسنل را StaffRouteGuardSubscriber تعیین میکند.
|
||||
*/
|
||||
public function canActInClinic(User $user, Clinic $clinic): bool
|
||||
{
|
||||
if ($user->hasRole('ROLE_ADMIN') || $clinic->getUser()->getId() === $user->getId()) {
|
||||
@@ -104,6 +112,10 @@ class EntityContextResolver
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($this->staffRepo->findActiveByUserAndEntity($user, EntityContext::TYPE_CLINIC, $clinic->getId()) !== null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $this->secretaryRepo->findActiveBySecretaryForClinic($user, $clinic) !== null;
|
||||
}
|
||||
|
||||
@@ -148,6 +160,10 @@ class EntityContextResolver
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($this->staffRepo->findActiveByUserAndEntity($user, EntityContext::TYPE_DOCTOR, $doctor->getId()) !== null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $this->secretaryRepo->findActiveBySecretaryForDoctor($user, $doctor) !== null;
|
||||
}
|
||||
|
||||
@@ -163,9 +179,9 @@ class EntityContextResolver
|
||||
return $clinic !== null ? EntityContext::forClinic($clinic) : EntityContext::unknown();
|
||||
}
|
||||
|
||||
// منشی fallback نقشی ندارد: محیطش فقط از UserActiveContext میآید، چون یک
|
||||
// منشی میتواند همزمان به چند پزشک و کلینیک وصل باشد و نقش تنها، انتخاب
|
||||
// بین آنها را تعیین نمیکند.
|
||||
// منشی و پرسنل fallback نقشی ندارند: محیطشان فقط از UserActiveContext میآید،
|
||||
// چون هر دو میتوانند همزمان به چند پزشک و کلینیک وصل باشند و نقش تنها،
|
||||
// انتخاب بین آنها را تعیین نمیکند.
|
||||
return EntityContext::unknown();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,8 +8,10 @@ use App\Doctor\Repository\DoctorRepository;
|
||||
use App\Secretary\Security\SecretaryAccessChecker;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Controller\BaseController;
|
||||
use App\Shared\Exception\AppException;
|
||||
use App\Staff\Entity\ClinicStaff;
|
||||
use App\Staff\Repository\ClinicStaffRepository;
|
||||
use App\Staff\Service\StaffAccountService;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
@@ -27,6 +29,7 @@ class StaffController extends BaseController
|
||||
private readonly ClinicRepository $clinicRepo,
|
||||
private readonly SecretaryAccessChecker $secretaryAccess,
|
||||
private readonly \App\Clinic\Security\ClinicDoctorAccessChecker $clinicDoctorAccess,
|
||||
private readonly StaffAccountService $staffAccounts,
|
||||
) {}
|
||||
|
||||
#[Route('/api/v1/staff', methods: ['GET'])]
|
||||
@@ -70,7 +73,10 @@ class StaffController extends BaseController
|
||||
$staff->setAddress($data['address'] ?? null);
|
||||
$staff->setNationalCode($this->toLatinDigits($data['national_code'] ?? null));
|
||||
|
||||
$this->staffRepo->save($staff);
|
||||
// هر پرسنل حساب ورود دارد؛ attachAccount خودش ردیف را ذخیره میکند. ترتیب
|
||||
// عمدی است: اگر شماره نامعتبر/تکراری باشد، ردیف نیمکارهای در clinic_staff
|
||||
// نمیماند چون هنوز ذخیره نشده است.
|
||||
$this->staffAccounts->attachAccount($staff, $data['phone'] ?? null, $data['password'] ?? null, $this->ownerUser($entityType, $entityId));
|
||||
|
||||
return $this->success($staff->toArray(), 201);
|
||||
}
|
||||
@@ -101,6 +107,11 @@ class StaffController extends BaseController
|
||||
|
||||
$this->staffRepo->save($staff);
|
||||
|
||||
// ویرایش هم حساب را میسازد/بهروز میکند: ردیفهای قدیمیِ بدون حساب با اولین
|
||||
// ویرایش صاحب حساب میشوند، و تغییر شماره یعنی تغییر نامکاربری ورود.
|
||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||
$this->staffAccounts->attachAccount($staff, $data['phone'] ?? $staff->getPhone(), $data['password'] ?? null, $this->ownerUser($entityType, $entityId));
|
||||
|
||||
return $this->success($staff->toArray());
|
||||
}
|
||||
|
||||
@@ -124,6 +135,23 @@ class StaffController extends BaseController
|
||||
return $this->success($staff->toArray());
|
||||
}
|
||||
|
||||
/**
|
||||
* صاحبِ محیط — نه لزوماً کاربرِ درخواست: منشی هم میتواند پرسنل ثبت کند، ولی
|
||||
* قاعدهٔ «شمارهٔ مالک پرسنل نمیشود» باید روی مالک واقعی سنجیده شود.
|
||||
*/
|
||||
private function ownerUser(string $entityType, int $entityId): User
|
||||
{
|
||||
$owner = $entityType === 'clinic'
|
||||
? $this->clinicRepo->find($entityId)
|
||||
: $this->doctorRepo->find($entityId);
|
||||
|
||||
if ($owner === null) {
|
||||
throw new AppException(ErrorCodes::ERR_FORBIDDEN_001, 'محیط کاری یافت نشد', 403);
|
||||
}
|
||||
|
||||
return $owner->getUser();
|
||||
}
|
||||
|
||||
private function resolveEntity(User $user): array
|
||||
{
|
||||
if ($user->hasRole('ROLE_DOCTOR')) {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Staff\Entity;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Staff\Repository\ClinicStaffRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
@@ -9,6 +10,7 @@ use Symfony\Component\Uid\Uuid;
|
||||
#[ORM\Entity(repositoryClass: ClinicStaffRepository::class)]
|
||||
#[ORM\Table(name: 'clinic_staff')]
|
||||
#[ORM\Index(columns: ['entity_type', 'entity_id', 'active'], name: 'idx_staff_entity_active')]
|
||||
#[ORM\Index(columns: ['user_id', 'active'], name: 'idx_staff_user_active')]
|
||||
class ClinicStaff
|
||||
{
|
||||
#[ORM\Id]
|
||||
@@ -43,6 +45,15 @@ class ClinicStaff
|
||||
#[ORM\Column(type: 'boolean')]
|
||||
private bool $active = true;
|
||||
|
||||
/**
|
||||
* حساب کاربری پرسنل برای ورود به پنل. nullable است چون پرسنل میتواند صرفاً یک
|
||||
* رکورد اطلاعاتی باشد؛ قطع دسترسی هم با null کردن همین ستون انجام میشود تا
|
||||
* ارجاعات سرویس/نوبت به این ردیف دستنخورده بماند.
|
||||
*/
|
||||
#[ORM\ManyToOne(targetEntity: User::class)]
|
||||
#[ORM\JoinColumn(name: 'user_id', nullable: true, onDelete: 'SET NULL')]
|
||||
private ?User $user = null;
|
||||
|
||||
#[ORM\Column(name: 'created_at', type: 'integer')]
|
||||
private int $createdAt;
|
||||
|
||||
@@ -69,6 +80,8 @@ class ClinicStaff
|
||||
public function getAddress(): ?string { return $this->address; }
|
||||
public function getNationalCode(): ?string { return $this->nationalCode; }
|
||||
public function isActive(): bool { return $this->active; }
|
||||
public function getUser(): ?User { return $this->user; }
|
||||
public function hasAccount(): bool { return $this->user !== null; }
|
||||
public function getCreatedAt(): int { return $this->createdAt; }
|
||||
public function getUpdatedAt(): int { return $this->updatedAt; }
|
||||
|
||||
@@ -78,6 +91,7 @@ class ClinicStaff
|
||||
public function setAddress(?string $address): self { $this->address = $address; $this->updatedAt = time(); return $this; }
|
||||
public function setNationalCode(?string $code): self { $this->nationalCode = $code; $this->updatedAt = time(); return $this; }
|
||||
public function setActive(bool $active): self { $this->active = $active; $this->updatedAt = time(); return $this; }
|
||||
public function setUser(?User $user): self { $this->user = $user; $this->updatedAt = time(); return $this; }
|
||||
|
||||
public function toggleActive(): self
|
||||
{
|
||||
@@ -98,6 +112,8 @@ class ClinicStaff
|
||||
'address' => $this->address,
|
||||
'national_code' => $this->nationalCode,
|
||||
'active' => $this->active,
|
||||
'has_account' => $this->user !== null,
|
||||
'user_uuid' => $this->user?->getUuid(),
|
||||
'created_at' => $this->createdAt,
|
||||
'updated_at' => $this->updatedAt,
|
||||
];
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Staff\Repository;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Staff\Entity\ClinicStaff;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
@@ -34,6 +35,42 @@ class ClinicStaffRepository extends ServiceEntityRepository
|
||||
return $qb->getQuery()->getResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* ردیفهای فعالِ این کاربر در همهٔ محیطها — یک نفر میتواند پرسنل چند کلینیک/مطب باشد.
|
||||
*
|
||||
* @return ClinicStaff[]
|
||||
*/
|
||||
public function findActiveByUser(User $user): array
|
||||
{
|
||||
return $this->createQueryBuilder('s')
|
||||
->where('s.user = :user')
|
||||
->andWhere('s.active = true')
|
||||
->setParameter('user', $user)
|
||||
->orderBy('s.fullName', 'ASC')
|
||||
->getQuery()
|
||||
->getResult();
|
||||
}
|
||||
|
||||
public function findActiveByUserAndEntity(User $user, string $entityType, int $entityId): ?ClinicStaff
|
||||
{
|
||||
return $this->findOneBy([
|
||||
'user' => $user,
|
||||
'entityType' => $entityType,
|
||||
'entityId' => $entityId,
|
||||
'active' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
/** برای جلوگیری از ثبت دو پرسنل با یک شماره در یک محیط. */
|
||||
public function findByEntityAndPhone(string $entityType, int $entityId, string $phone): ?ClinicStaff
|
||||
{
|
||||
return $this->findOneBy([
|
||||
'entityType' => $entityType,
|
||||
'entityId' => $entityId,
|
||||
'phone' => $phone,
|
||||
]);
|
||||
}
|
||||
|
||||
public function save(ClinicStaff $staff): void
|
||||
{
|
||||
$this->getEntityManager()->persist($staff);
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Staff\Security;
|
||||
|
||||
/**
|
||||
* مجوزهای ثابتِ نقش پرسنل.
|
||||
*
|
||||
* برخلاف منشی که مجوزهایش در ستون JSON قابل ویرایش است، پرسنل در حال حاضر یک
|
||||
* مجموعهٔ ثابت و حداقلی دارد: فقط دیدنِ سرویسهایی که به او تخصیص یافته و
|
||||
* نوبتهای خودش. شکل ساختار عمداً همان شکل مجوزهای منشی است تا `usePermissions`
|
||||
* در پنل بدون شاخهٔ اضافه کار کند.
|
||||
*
|
||||
* `usePermissions` نبودِ `resources` را «آزاد» تفسیر میکند، پس context پرسنل
|
||||
* باید همیشه این آرایه را همراه داشته باشد.
|
||||
*/
|
||||
final class StaffPermissions
|
||||
{
|
||||
public const DEFAULT = [
|
||||
'version' => 1,
|
||||
'resources' => [
|
||||
'services' => ['view' => true],
|
||||
'appointments' => ['view' => true],
|
||||
],
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
namespace App\Staff\Security;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Exception\AppException;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||
use Symfony\Component\HttpKernel\Event\RequestEvent;
|
||||
use Symfony\Component\HttpKernel\KernelEvents;
|
||||
|
||||
/**
|
||||
* کاربری که «فقط» پرسنل است، به هیچ مسیر API جز allowlist دسترسی ندارد.
|
||||
*
|
||||
* بیشتر کنترلرها tenant را از EntityContextResolver میگیرند و مجوز را فقط برای
|
||||
* منشی و پزشکِ مهمان میسنجند (SecretaryAccessChecker و ClinicDoctorAccessChecker
|
||||
* برای بقیهٔ نقشها no-op هستند). حالا که محیطِ پرسنل هم حل میشود، بدون این گارد
|
||||
* یک کاربر پرسنل به دادهٔ کل کلینیک میرسید. تصمیم عمداً در یک نقطه متمرکز است تا
|
||||
* کنترلر جدید هم بهصورت پیشفرض بسته باشد، نه اینکه یادمان برود deny اضافه کنیم.
|
||||
*/
|
||||
class StaffRouteGuardSubscriber implements EventSubscriberInterface
|
||||
{
|
||||
/** مسیرهایی که نقش پرسنل مجاز است صدا بزند. */
|
||||
private const ALLOWED_PREFIXES = [
|
||||
'/api/v1/dashboard/staff',
|
||||
'/api/v1/auth/switch-context',
|
||||
'/api/v1/user/change-password',
|
||||
];
|
||||
|
||||
/**
|
||||
* نقشهایی که اگر کاربر یکیشان را داشته باشد، این گارد کنار میرود: کاربر
|
||||
* علاوه بر پرسنل بودن، نقش پرتوانتری هم دارد و محدودهٔ دسترسیاش را همان
|
||||
* نقش تعیین میکند.
|
||||
*/
|
||||
private const OVERRIDING_ROLES = [
|
||||
'ROLE_ADMIN', 'ROLE_CLINIC', 'ROLE_DOCTOR', 'ROLE_SECRETARY', 'ROLE_REPRESENTATION',
|
||||
];
|
||||
|
||||
public function __construct(private readonly Security $security) {}
|
||||
|
||||
public static function getSubscribedEvents(): array
|
||||
{
|
||||
// بعد از firewall (priority 8) تا توکن ست شده باشد.
|
||||
return [KernelEvents::REQUEST => ['onKernelRequest', 6]];
|
||||
}
|
||||
|
||||
public function onKernelRequest(RequestEvent $event): void
|
||||
{
|
||||
if (!$event->isMainRequest()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$path = $event->getRequest()->getPathInfo();
|
||||
if (!str_starts_with($path, '/api/')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$user = $this->security->getUser();
|
||||
if (!$user instanceof User || !$this->isStaffOnly($user)) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (self::ALLOWED_PREFIXES as $prefix) {
|
||||
if (str_starts_with($path, $prefix)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
throw new AppException(ErrorCodes::ERR_FORBIDDEN_001, 'دسترسی پرسنل به این بخش مجاز نیست', 403);
|
||||
}
|
||||
|
||||
private function isStaffOnly(User $user): bool
|
||||
{
|
||||
if (!$user->hasRole('ROLE_STAFF')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (self::OVERRIDING_ROLES as $role) {
|
||||
if ($user->hasRole($role)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
namespace App\Staff\Service;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Auth\Repository\UserRepository;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Exception\AppException;
|
||||
use App\Shared\Util\PersianText;
|
||||
use App\Staff\Entity\ClinicStaff;
|
||||
use App\Staff\Repository\ClinicStaffRepository;
|
||||
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||
|
||||
/**
|
||||
* تنها نقطهٔ ساخت/اتصال/قطع حساب کاربری پرسنل.
|
||||
*
|
||||
* پرسنل مثل منشی یک `User` است که با شمارهٔ موبایل وارد پنل میشود؛ تفاوتش این
|
||||
* است که رابطهٔ او با محیط، همان ردیف `ClinicStaff` است (نه یک جدول واسط جدا).
|
||||
* قرینهٔ {@see \App\Secretary\Service\SecretaryService::resolveSecretaryUser()}.
|
||||
*/
|
||||
class StaffAccountService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly UserRepository $userRepo,
|
||||
private readonly ClinicStaffRepository $staffRepo,
|
||||
private readonly UserPasswordHasherInterface $hasher,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* حساب ورود پرسنل را میسازد یا به کاربر موجودِ همان موبایل وصل میکند و
|
||||
* ROLE_STAFF میدهد. شمارهٔ نرمالشده روی خود ردیف پرسنل هم ذخیره میشود تا
|
||||
* «شمارهٔ تماس» و «نام کاربری ورود» یکی بمانند.
|
||||
*
|
||||
* @param User $owner کاربرِ مالکِ محیط (پزشک/کلینیک) که این پرسنل را ثبت میکند
|
||||
*
|
||||
* @throws AppException ERR_STAFF_MOBILE_INVALID | ERR_STAFF_MOBILE_TAKEN
|
||||
*/
|
||||
public function attachAccount(ClinicStaff $staff, ?string $mobile, ?string $password, User $owner): User
|
||||
{
|
||||
$mobile = $this->normalizeMobile($mobile);
|
||||
|
||||
if ($mobile === $owner->getMobileNumber()) {
|
||||
throw new AppException(
|
||||
ErrorCodes::ERR_STAFF_MOBILE_INVALID,
|
||||
'شمارهٔ مالک نمیتواند بهعنوان پرسنل ثبت شود',
|
||||
422,
|
||||
'phone',
|
||||
);
|
||||
}
|
||||
|
||||
$duplicate = $this->staffRepo->findByEntityAndPhone($staff->getEntityType(), $staff->getEntityId(), $mobile);
|
||||
if ($duplicate !== null && $duplicate->getId() !== $staff->getId()) {
|
||||
throw new AppException(ErrorCodes::ERR_STAFF_MOBILE_TAKEN, null, 409, 'phone');
|
||||
}
|
||||
|
||||
$user = $this->userRepo->findByMobile($mobile) ?? new User($mobile);
|
||||
|
||||
// رمز خالی روی کاربر موجود، رمز فعلیاش را پاک نمیکند؛ کاربر تازهساخته هم
|
||||
// بدون رمز میماند و باید از «فراموشی رمز» استفاده کند.
|
||||
if ($password !== null && $password !== '') {
|
||||
$user->setPasswordHash($this->hasher->hashPassword($user, $password));
|
||||
}
|
||||
if ($user->getRealName() === null || $user->getRealName() === '') {
|
||||
$user->setRealName($staff->getFullName());
|
||||
}
|
||||
$user->addRole('ROLE_STAFF');
|
||||
$this->userRepo->save($user);
|
||||
|
||||
$staff->setUser($user)->setPhone($mobile);
|
||||
$this->staffRepo->save($staff);
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
private function normalizeMobile(?string $mobile): string
|
||||
{
|
||||
$normalized = preg_replace('/\D+/', '', PersianText::digits((string) $mobile)) ?? '';
|
||||
|
||||
if (!preg_match('/^09\d{9}$/', $normalized)) {
|
||||
throw new AppException(ErrorCodes::ERR_STAFF_MOBILE_INVALID, null, 422, 'phone');
|
||||
}
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Staff;
|
||||
|
||||
use App\Auth\Repository\UserRepository;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Exception\AppException;
|
||||
use App\Staff\Entity\ClinicStaff;
|
||||
use App\Staff\Repository\ClinicStaffRepository;
|
||||
use App\Staff\Service\StaffAccountService;
|
||||
use App\Tests\ApiTestCase;
|
||||
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||
|
||||
/**
|
||||
* ساخت/اتصال/قطع حساب کاربری پرسنل: یک ردیف ClinicStaff به یک User با
|
||||
* ROLE_STAFF وصل میشود بدون اینکه کاربر موجود آسیب ببیند.
|
||||
*/
|
||||
class StaffAccountServiceTest extends ApiTestCase
|
||||
{
|
||||
/** سرویس تکمصرفه است و کانتینر inlineاش میکند، پس با وابستگیهای واقعی ساخته میشود. */
|
||||
private function service(): StaffAccountService
|
||||
{
|
||||
return new StaffAccountService(
|
||||
static::getContainer()->get(UserRepository::class),
|
||||
static::getContainer()->get(ClinicStaffRepository::class),
|
||||
static::getContainer()->get(UserPasswordHasherInterface::class),
|
||||
);
|
||||
}
|
||||
|
||||
private function newStaff(int $entityId, string $name = 'زهرا احمدی'): ClinicStaff
|
||||
{
|
||||
$staff = new ClinicStaff('doctor', $entityId, $name);
|
||||
$this->em->persist($staff);
|
||||
$this->em->flush();
|
||||
|
||||
return $staff;
|
||||
}
|
||||
|
||||
private function newDoctorOwner(): Doctor
|
||||
{
|
||||
$owner = $this->createUser(['ROLE_DOCTOR']);
|
||||
$doctor = new Doctor($owner, 'دکتر تست');
|
||||
$this->em->persist($doctor);
|
||||
$this->em->flush();
|
||||
|
||||
return $doctor;
|
||||
}
|
||||
|
||||
public function testCreatesNewUserWithStaffRole(): void
|
||||
{
|
||||
$doctor = $this->newDoctorOwner();
|
||||
$staff = $this->newStaff($doctor->getId());
|
||||
$mobile = '0912' . str_pad((string) random_int(0, 9_999_999), 7, '0', STR_PAD_LEFT);
|
||||
|
||||
$user = $this->service()->attachAccount($staff, $mobile, 'Staff@1234', $doctor->getUser());
|
||||
|
||||
self::assertContains('ROLE_STAFF', $user->getRoles());
|
||||
self::assertSame($mobile, $user->getMobileNumber());
|
||||
self::assertSame('زهرا احمدی', $user->getRealName());
|
||||
self::assertTrue($staff->hasAccount());
|
||||
self::assertSame($user->getId(), $staff->getUser()?->getId());
|
||||
self::assertSame($mobile, $staff->getPhone());
|
||||
|
||||
$hasher = static::getContainer()->get(UserPasswordHasherInterface::class);
|
||||
self::assertTrue($hasher->isPasswordValid($user, 'Staff@1234'));
|
||||
}
|
||||
|
||||
/** کاربر موجود فقط نقش میگیرد؛ رمز و نامش پاک نمیشود. */
|
||||
public function testReusesExistingUserAndKeepsPassword(): void
|
||||
{
|
||||
$doctor = $this->newDoctorOwner();
|
||||
$existing = $this->createUser(['ROLE_USER']);
|
||||
$existing->setRealName('نام قبلی');
|
||||
$hasher = static::getContainer()->get(UserPasswordHasherInterface::class);
|
||||
$existing->setPasswordHash($hasher->hashPassword($existing, 'Old@12345'));
|
||||
$this->em->flush();
|
||||
|
||||
$staff = $this->newStaff($doctor->getId());
|
||||
$user = $this->service()->attachAccount($staff, $existing->getMobileNumber(), null, $doctor->getUser());
|
||||
|
||||
self::assertSame($existing->getId(), $user->getId());
|
||||
self::assertContains('ROLE_STAFF', $user->getRoles());
|
||||
self::assertContains('ROLE_USER', $user->getRoles());
|
||||
self::assertSame('نام قبلی', $user->getRealName());
|
||||
self::assertTrue($hasher->isPasswordValid($user, 'Old@12345'));
|
||||
}
|
||||
|
||||
public function testRejectsInvalidMobile(): void
|
||||
{
|
||||
$doctor = $this->newDoctorOwner();
|
||||
$staff = $this->newStaff($doctor->getId());
|
||||
|
||||
$this->expectException(AppException::class);
|
||||
$this->expectExceptionMessage(ErrorCodes::message(ErrorCodes::ERR_STAFF_MOBILE_INVALID));
|
||||
|
||||
$this->service()->attachAccount($staff, '12345', 'Staff@1234', $doctor->getUser());
|
||||
}
|
||||
|
||||
public function testRejectsOwnerMobile(): void
|
||||
{
|
||||
$doctor = $this->newDoctorOwner();
|
||||
$staff = $this->newStaff($doctor->getId());
|
||||
|
||||
try {
|
||||
$this->service()->attachAccount($staff, $doctor->getUser()->getMobileNumber(), null, $doctor->getUser());
|
||||
self::fail('owner mobile must be rejected');
|
||||
} catch (AppException $e) {
|
||||
self::assertSame(ErrorCodes::ERR_STAFF_MOBILE_INVALID, $e->getErrorCode());
|
||||
self::assertSame(422, $e->getHttpStatus());
|
||||
}
|
||||
}
|
||||
|
||||
/** مرزی: همان شماره، همان محیط، ردیف دوم → 409. */
|
||||
public function testRejectsDuplicateMobileInSameEntity(): void
|
||||
{
|
||||
$doctor = $this->newDoctorOwner();
|
||||
$first = $this->newStaff($doctor->getId(), 'پرسنل اول');
|
||||
$second = $this->newStaff($doctor->getId(), 'پرسنل دوم');
|
||||
$mobile = '0912' . str_pad((string) random_int(0, 9_999_999), 7, '0', STR_PAD_LEFT);
|
||||
|
||||
$this->service()->attachAccount($first, $mobile, null, $doctor->getUser());
|
||||
|
||||
try {
|
||||
$this->service()->attachAccount($second, $mobile, null, $doctor->getUser());
|
||||
self::fail('duplicate mobile in the same entity must be rejected');
|
||||
} catch (AppException $e) {
|
||||
self::assertSame(ErrorCodes::ERR_STAFF_MOBILE_TAKEN, $e->getErrorCode());
|
||||
self::assertSame(409, $e->getHttpStatus());
|
||||
}
|
||||
}
|
||||
|
||||
/** ارقام فارسی از هر کلاینتی بیاید، شمارهٔ ذخیرهشده لاتین است. */
|
||||
public function testNormalizesPersianDigits(): void
|
||||
{
|
||||
$doctor = $this->newDoctorOwner();
|
||||
$staff = $this->newStaff($doctor->getId());
|
||||
|
||||
$user = $this->service()->attachAccount($staff, '۰۹۱۲۳۴۵۶۷۸۹', null, $doctor->getUser());
|
||||
|
||||
self::assertSame('09123456789', $user->getMobileNumber());
|
||||
}
|
||||
|
||||
/** تغییر شماره یعنی تغییر نامکاربری ورود؛ ردیف پرسنل همان میماند. */
|
||||
public function testReattachWithNewMobileMovesTheAccount(): void
|
||||
{
|
||||
$doctor = $this->newDoctorOwner();
|
||||
$staff = $this->newStaff($doctor->getId());
|
||||
$first = '0912' . str_pad((string) random_int(0, 9_999_999), 7, '0', STR_PAD_LEFT);
|
||||
$second = '0913' . str_pad((string) random_int(0, 9_999_999), 7, '0', STR_PAD_LEFT);
|
||||
|
||||
$this->service()->attachAccount($staff, $first, null, $doctor->getUser());
|
||||
$user = $this->service()->attachAccount($staff, $second, null, $doctor->getUser());
|
||||
|
||||
self::assertSame($second, $user->getMobileNumber());
|
||||
self::assertSame($second, $staff->getPhone());
|
||||
self::assertSame($user->getId(), $staff->getUser()?->getId());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Staff;
|
||||
|
||||
use App\Auth\Repository\UserRepository;
|
||||
use App\ClinicService\Entity\ServiceItem;
|
||||
use App\ClinicService\Entity\ServiceSection;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Staff\Entity\ClinicStaff;
|
||||
use App\Staff\Repository\ClinicStaffRepository;
|
||||
use App\Staff\Service\StaffAccountService;
|
||||
use App\Tests\ApiTestCase;
|
||||
use Lexik\Bundle\JWTAuthenticationBundle\Services\JWTTokenManagerInterface;
|
||||
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||
|
||||
/**
|
||||
* داشبورد پرسنل + گاردِ default-deny: نقش staff فقط داشبورد خودش را میبیند و
|
||||
* بقیهٔ API برایش بسته است.
|
||||
*/
|
||||
class StaffDashboardAccessTest extends ApiTestCase
|
||||
{
|
||||
private function accounts(): StaffAccountService
|
||||
{
|
||||
return new StaffAccountService(
|
||||
static::getContainer()->get(UserRepository::class),
|
||||
static::getContainer()->get(ClinicStaffRepository::class),
|
||||
static::getContainer()->get(UserPasswordHasherInterface::class),
|
||||
);
|
||||
}
|
||||
|
||||
/** @return array{0: Doctor, 1: ClinicStaff, 2: string} پزشکِ مالک، ردیف پرسنل، توکن پرسنل */
|
||||
private function staffFixture(bool $active = true): array
|
||||
{
|
||||
$ownerUser = $this->createUser(['ROLE_DOCTOR']);
|
||||
$doctor = new Doctor($ownerUser, 'دکتر تست');
|
||||
$this->em->persist($doctor);
|
||||
$this->em->flush();
|
||||
|
||||
$staff = new ClinicStaff('doctor', $doctor->getId(), 'زهرا احمدی');
|
||||
$staff->setJobTitle('پرستار');
|
||||
$staff->setActive($active);
|
||||
$this->em->persist($staff);
|
||||
$this->em->flush();
|
||||
|
||||
$mobile = '0912' . str_pad((string) random_int(0, 9_999_999), 7, '0', STR_PAD_LEFT);
|
||||
$staffUser = $this->accounts()->attachAccount($staff, $mobile, 'Staff@1234', $ownerUser);
|
||||
$token = static::getContainer()->get(JWTTokenManagerInterface::class)->create($staffUser);
|
||||
|
||||
// محیط فعال؛ در حالت واقعی /oauth/userinfo آن را برای تکمحیطیها ست میکند.
|
||||
$this->client->request('GET', '/oauth/userinfo', [], [], ['HTTP_AUTHORIZATION' => 'Bearer ' . $token]);
|
||||
|
||||
return [$doctor, $staff, $token];
|
||||
}
|
||||
|
||||
private function get(string $path, string $token): int
|
||||
{
|
||||
$this->client->request('GET', $path, [], [], ['HTTP_AUTHORIZATION' => 'Bearer ' . $token]);
|
||||
|
||||
return $this->client->getResponse()->getStatusCode();
|
||||
}
|
||||
|
||||
private function json(): array
|
||||
{
|
||||
return json_decode($this->client->getResponse()->getContent(), true);
|
||||
}
|
||||
|
||||
public function testDashboardListsAssignedServices(): void
|
||||
{
|
||||
[$doctor, $staff, $token] = $this->staffFixture();
|
||||
|
||||
$section = new ServiceSection('doctor', $doctor->getId(), 'تزریقات');
|
||||
$item = new ServiceItem($section, 'سرم', 1_000_000);
|
||||
$item->setStaffMembers([$staff]);
|
||||
$other = new ServiceItem($section, 'سرویس بدون پرسنل', 500_000);
|
||||
$this->em->persist($section);
|
||||
$this->em->persist($item);
|
||||
$this->em->persist($other);
|
||||
$this->em->flush();
|
||||
|
||||
self::assertSame(200, $this->get('/api/v1/dashboard/staff', $token));
|
||||
$data = $this->json()['data'];
|
||||
|
||||
self::assertSame('doctor', $data['scope']);
|
||||
self::assertSame($staff->getUuid(), $data['staff']['uuid']);
|
||||
self::assertSame(1, $data['stats']['services']);
|
||||
self::assertCount(1, $data['services']);
|
||||
self::assertSame('سرم', $data['services'][0]['name']);
|
||||
self::assertSame('تزریقات', $data['services'][0]['section_name']);
|
||||
}
|
||||
|
||||
/** مرزی: پرسنلِ بدون سرویس → لیست خالی، نه خطا. */
|
||||
public function testDashboardWithoutServicesReturnsEmptyList(): void
|
||||
{
|
||||
[, , $token] = $this->staffFixture();
|
||||
|
||||
self::assertSame(200, $this->get('/api/v1/dashboard/staff', $token));
|
||||
$data = $this->json()['data'];
|
||||
|
||||
self::assertSame([], $data['services']);
|
||||
self::assertSame(0, $data['stats']['services']);
|
||||
self::assertSame([], $data['today_appointments']);
|
||||
}
|
||||
|
||||
public function testInactiveStaffIsDenied(): void
|
||||
{
|
||||
[, , $token] = $this->staffFixture(active: false);
|
||||
|
||||
self::assertSame(403, $this->get('/api/v1/dashboard/staff', $token));
|
||||
self::assertSame('ERR_FORBIDDEN_001', $this->json()['errors'][0]['code']);
|
||||
}
|
||||
|
||||
/** گارد: هر مسیر API خارج از allowlist برای نقش staff بسته است. */
|
||||
public function testOtherApiRoutesAreForbidden(): void
|
||||
{
|
||||
[, , $token] = $this->staffFixture();
|
||||
|
||||
foreach (['/api/v1/service-items', '/api/v1/staff', '/api/v1/patients', '/api/v1/dashboard/clinic'] as $path) {
|
||||
self::assertSame(403, $this->get($path, $token), $path . ' must be forbidden for staff');
|
||||
self::assertSame('ERR_FORBIDDEN_001', $this->json()['errors'][0]['code'], $path);
|
||||
}
|
||||
|
||||
// مسیرهای مجاز دستنخوردهاند
|
||||
self::assertSame(200, $this->get('/oauth/userinfo', $token));
|
||||
}
|
||||
|
||||
/** گارد نباید نقشهای دیگر را بگیرد، حتی اگر کاربر همزمان پرسنل باشد. */
|
||||
public function testGuardSkipsUsersWithStrongerRole(): void
|
||||
{
|
||||
[, , $token] = $this->staffFixture();
|
||||
|
||||
$data = json_decode($this->client->getResponse()->getContent(), true);
|
||||
$staffUser = static::getContainer()->get(UserRepository::class)->findByUuid($data['data']['uuid'] ?? '');
|
||||
self::assertNotNull($staffUser);
|
||||
|
||||
$staffUser->addRole('ROLE_DOCTOR');
|
||||
$this->em->flush();
|
||||
$token = static::getContainer()->get(JWTTokenManagerInterface::class)->create($staffUser);
|
||||
|
||||
self::assertNotSame(403, $this->get('/api/v1/service-items', $token));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Staff;
|
||||
|
||||
use App\Auth\Repository\UserRepository;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Staff\Entity\ClinicStaff;
|
||||
use App\Staff\Repository\ClinicStaffRepository;
|
||||
use App\Staff\Service\StaffAccountService;
|
||||
use App\Tests\ApiTestCase;
|
||||
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||
|
||||
/**
|
||||
* پرسنلِ دارای حساب باید مثل بقیهٔ نقشهای پنل لاگین کند و محیط کاریاش را در
|
||||
* userinfo ببیند؛ پرسنل غیرفعال هیچ محیطی نمیگیرد.
|
||||
*/
|
||||
class StaffLoginContextTest extends ApiTestCase
|
||||
{
|
||||
private function service(): StaffAccountService
|
||||
{
|
||||
return new StaffAccountService(
|
||||
static::getContainer()->get(UserRepository::class),
|
||||
static::getContainer()->get(ClinicStaffRepository::class),
|
||||
static::getContainer()->get(UserPasswordHasherInterface::class),
|
||||
);
|
||||
}
|
||||
|
||||
/** @return array{0: Doctor, 1: ClinicStaff, 2: string} */
|
||||
private function staffWithAccount(bool $active = true): array
|
||||
{
|
||||
$ownerUser = $this->createUser(['ROLE_DOCTOR']);
|
||||
$doctor = new Doctor($ownerUser, 'دکتر تست');
|
||||
$this->em->persist($doctor);
|
||||
$this->em->flush();
|
||||
|
||||
$staff = new ClinicStaff('doctor', $doctor->getId(), 'زهرا احمدی');
|
||||
$staff->setActive($active);
|
||||
$this->em->persist($staff);
|
||||
$this->em->flush();
|
||||
|
||||
$mobile = '0912' . str_pad((string) random_int(0, 9_999_999), 7, '0', STR_PAD_LEFT);
|
||||
$this->service()->attachAccount($staff, $mobile, 'Staff@1234', $ownerUser);
|
||||
|
||||
return [$doctor, $staff, $mobile];
|
||||
}
|
||||
|
||||
private function json(): array
|
||||
{
|
||||
return json_decode($this->client->getResponse()->getContent(), true);
|
||||
}
|
||||
|
||||
public function testStaffCanLogInAndSeesOwnEnvironment(): void
|
||||
{
|
||||
[$doctor, , $mobile] = $this->staffWithAccount();
|
||||
|
||||
$this->client->request('POST', '/api/v1/user/login', [], [], ['CONTENT_TYPE' => 'application/json'], json_encode([
|
||||
'mobile_number' => $mobile,
|
||||
'password' => 'Staff@1234',
|
||||
]));
|
||||
|
||||
self::assertSame(200, $this->client->getResponse()->getStatusCode());
|
||||
$token = $this->json()['access_token'] ?? null;
|
||||
self::assertNotNull($token);
|
||||
|
||||
$this->client->request('GET', '/oauth/userinfo', [], [], ['HTTP_AUTHORIZATION' => 'Bearer ' . $token]);
|
||||
self::assertSame(200, $this->client->getResponse()->getStatusCode());
|
||||
|
||||
$data = $this->json()['data'];
|
||||
self::assertSame('staff', $data['primary_role']);
|
||||
|
||||
$staffContexts = array_values(array_filter($data['available_contexts'], fn(array $c) => $c['role'] === 'staff'));
|
||||
self::assertCount(1, $staffContexts);
|
||||
self::assertSame($doctor->getUuid(), $staffContexts[0]['db_uuid']);
|
||||
self::assertTrue($staffContexts[0]['permissions']['resources']['services']['view']);
|
||||
self::assertArrayNotHasKey('patients', $staffContexts[0]['permissions']['resources']);
|
||||
}
|
||||
|
||||
/** پرسنل غیرفعال: لاگین باز است ولی هیچ محیطی ندارد. */
|
||||
public function testInactiveStaffGetsNoContext(): void
|
||||
{
|
||||
[, , $mobile] = $this->staffWithAccount(active: false);
|
||||
|
||||
$this->client->request('POST', '/api/v1/user/login', [], [], ['CONTENT_TYPE' => 'application/json'], json_encode([
|
||||
'mobile_number' => $mobile,
|
||||
'password' => 'Staff@1234',
|
||||
]));
|
||||
$token = $this->json()['access_token'] ?? null;
|
||||
self::assertNotNull($token);
|
||||
|
||||
$this->client->request('GET', '/oauth/userinfo', [], [], ['HTTP_AUTHORIZATION' => 'Bearer ' . $token]);
|
||||
$data = $this->json()['data'];
|
||||
|
||||
self::assertSame('staff', $data['primary_role']);
|
||||
self::assertSame([], array_values(array_filter($data['available_contexts'], fn(array $c) => $c['role'] === 'staff')));
|
||||
}
|
||||
|
||||
/** مرزی: منشیای که پرسنل هم هست، نقش قویترش را نگه میدارد. */
|
||||
public function testSecretaryRoleWinsOverStaffRole(): void
|
||||
{
|
||||
[, $staff, $mobile] = $this->staffWithAccount();
|
||||
|
||||
$user = static::getContainer()->get(UserRepository::class)->findByMobile($mobile);
|
||||
$user->addRole('ROLE_SECRETARY');
|
||||
$this->em->flush();
|
||||
|
||||
$this->client->request('POST', '/api/v1/user/login', [], [], ['CONTENT_TYPE' => 'application/json'], json_encode([
|
||||
'mobile_number' => $mobile,
|
||||
'password' => 'Staff@1234',
|
||||
]));
|
||||
$token = $this->json()['access_token'];
|
||||
|
||||
$this->client->request('GET', '/oauth/userinfo', [], [], ['HTTP_AUTHORIZATION' => 'Bearer ' . $token]);
|
||||
$data = $this->json()['data'];
|
||||
|
||||
self::assertSame('secretary', $data['primary_role']);
|
||||
// محیطِ پرسنلیاش همچنان در فهرست هست
|
||||
self::assertNotEmpty(array_filter($data['available_contexts'], fn(array $c) => $c['role'] === 'staff'));
|
||||
self::assertTrue($staff->hasAccount());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user