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:
hamed
2026-07-30 10:18:41 +03:30
parent 6ec011e3ad
commit 57aeb40934
28 changed files with 1960 additions and 29 deletions
+623
View File
@@ -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` استفاده کنند — طراحی جدید ساخته نشود.
+4 -1
View File
@@ -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();
}
});
});
+111
View File
@@ -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>
</>
);
}
+33 -2
View File
@@ -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>
);
}
+2 -2
View File
@@ -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;
+12
View File
@@ -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
View File
@@ -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های ویرایش کلینیک مالکیت را چک می‌کنند (۴۰۳).
+65
View File
@@ -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
View File
@@ -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 | دسترسی ندارید |
---
+35
View File
@@ -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');
}
}
+28
View File
@@ -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;
}
+1
View File
@@ -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,
]);
}
}
+5 -1
View File
@@ -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 => 'تریال در حال حاضر غیرفعال است',
+20 -4
View File
@@ -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();
}
}
+29 -1
View File
@@ -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')) {
+16
View File
@@ -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);
+25
View File
@@ -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;
}
}
+85
View File
@@ -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;
}
}
+159
View File
@@ -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());
}
}
+141
View File
@@ -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));
}
}
+120
View File
@@ -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());
}
}