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` استفاده کنند — طراحی جدید ساخته نشود.