feat: add staff role functionality with dashboard access and service management
- Implemented SidebarStaff component tests to ensure staff users see only their dashboard and services. - Created StaffMyServicesPage to display assigned services for staff users. - Added migration to link clinic staff rows to user accounts for ROLE_STAFF access. - Defined StaffPermissions class for static permissions related to staff role. - Introduced StaffRouteGuardSubscriber to restrict API access for staff users. - Developed StaffAccountService for managing staff user accounts and linking them to clinic staff. - Added comprehensive tests for StaffAccountService to validate user creation, mobile number handling, and account attachment. - Implemented tests for staff dashboard access to ensure proper permissions and access control. - Created tests for staff login context to verify correct environment visibility based on user roles.
This commit is contained in:
@@ -0,0 +1,623 @@
|
||||
# حساب کاربری برای پرسنل — نقش `ROLE_STAFF`، ورود به پنل، داشبورد اختصاصی و مشاهدهٔ سرویسهای تخصیصیافته
|
||||
|
||||
## پروژه
|
||||
|
||||
`clinicpro` (بکاند Symfony + پنل ادمین React). cross-repo نیست؛ `nobat724_front` تغییری ندارد.
|
||||
|
||||
## زمینه
|
||||
|
||||
امروز پرسنل (`clinic_staff`) فقط یک «رکورد اطلاعاتی» است: کلینیک یا پزشک از
|
||||
`/admin/staff` یک ردیف با نام/تلفن/سمت/کد ملی میسازد و همان ردیف در جاهای دیگر
|
||||
بهعنوان «مجری سرویس» انتخاب میشود:
|
||||
|
||||
- `ServiceItem::$staffMembers` (جدول `service_item_staff`) — پرسنل تخصیصیافته به هر سرویس
|
||||
- `Appointment::$staff` (`appointments.staff_id`) — پرسنل نوبت
|
||||
- `SessionService::$staff` — پرسنل مجری سرویس در جلسهٔ بیمار
|
||||
|
||||
اما `ClinicStaff` هیچ ارتباطی با `users` ندارد، پس پرسنل نه میتواند لاگین کند و نه
|
||||
داشبوردی دارد. الگوی مشابهی که در پروژه **کار میکند** «منشی» است: منشی یک `User`
|
||||
است با `ROLE_SECRETARY` که از طریق ردیف `DoctorSecretary` به مالک (پزشک/کلینیک) وصل
|
||||
میشود (`SecretaryService::resolveSecretaryUser`). همین الگو باید برای پرسنل تکرار شود.
|
||||
|
||||
## مشکل / هدف
|
||||
|
||||
وقتی کلینیک یا پزشک در `/admin/staff` پرسنل اضافه میکند، اگر شمارهٔ موبایل بدهد و
|
||||
گزینهٔ «ایجاد حساب کاربری» را بزند:
|
||||
|
||||
1. یک `User` با نقش `ROLE_STAFF` ساخته/بهروزرسانی شود و به همان ردیف `ClinicStaff` وصل شود.
|
||||
2. آن کاربر بتواند با موبایل/رمز در `/admin/login` وارد شود.
|
||||
3. بعد از ورود، `primary_role = 'staff'` بگیرد و محیط کاریاش همان مطب/کلینیکِ مالک باشد.
|
||||
4. داشبورد اختصاصی «پرسنل» ببیند: سرویسهایی که به او تخصیص داده شده + نوبتهای خودش.
|
||||
5. **به هیچ چیز دیگری دسترسی نداشته باشد** — نه لیست بیماران، نه سرویسهای کل کلینیک،
|
||||
نه مالی، نه مدیریت پرسنل.
|
||||
|
||||
### تحلیل — نکتهٔ امنیتی که نباید نادیده گرفته شود
|
||||
|
||||
بند ۵ سختترین بخش کار است و اگر ساده گرفته شود یک نشت اطلاعات کامل میسازد:
|
||||
|
||||
- اکثر کنترلرها فقط `#[IsGranted('IS_AUTHENTICATED_FULLY')]` دارند و tenant را از
|
||||
`EntityContextResolver` میگیرند.
|
||||
- `SecretaryAccessChecker::denyUnlessGranted` و `ClinicDoctorAccessChecker` برای
|
||||
کاربری که منشی/پزشکِ مهمان **نیست** عملاً no-op هستند (فقط نقش خودشان را میسنجند).
|
||||
- پس بهمحض اینکه `EntityContextResolver` برای کاربر staff محیط کلینیک را resolve کند،
|
||||
`GET /api/v1/service-items` **همهٔ** سرویسهای کلینیک را برمیگرداند، `/api/v1/patients`
|
||||
همهٔ بیماران را، و…
|
||||
|
||||
بنابراین طراحی این تسک **default-deny** است: یک `StaffRouteGuardSubscriber` روی رویداد
|
||||
`kernel.controller` که برای کاربرِ «فقط staff» هر مسیر خارج از allowlist را ۴۰۳ میکند.
|
||||
دلیل انتخاب Subscriber بهجای افزودن `denyUnlessGranted` به دهها کنترلر: تکنقطهای
|
||||
بودن تصمیم (اگر فردا کنترلر جدیدی اضافه شود، بهصورت پیشفرض بسته است، نه باز).
|
||||
|
||||
## معیار پذیرش
|
||||
|
||||
- ✅ موفق:
|
||||
- `POST /api/v1/staff` با `{"full_name":"زهرا احمدی","phone":"09121110000","has_account":true,"password":"Staff@1234"}`
|
||||
توسط توکن کلینیک → `201` و در بدنه `has_account: true` و `user_uuid` غیرتهی؛ در DB
|
||||
یک `users` با `roles` شامل `ROLE_STAFF` و `clinic_staff.user_id` پرشده.
|
||||
- `POST /api/v1/user/login` با همان موبایل/رمز → `200` و `access_token`.
|
||||
- `GET /oauth/userinfo` با آن توکن → `primary_role: "staff"` و در `available_contexts`
|
||||
یک آیتم با `role: "staff"` و `db_uuid` برابر uuid کلینیک/پزشکِ مالک و
|
||||
`permissions.resources` فقط شامل `{"services":{"view":true},"appointments":{"view":true}}`.
|
||||
- `GET /api/v1/dashboard/staff` → `200` با `stats.today_appointments`، `services` (فقط
|
||||
سرویسهایی که این پرسنل در `service_item_staff` آنهاست) و `today_appointments`.
|
||||
- در پنل: ورود با آن کاربر → ریدایرکت به `/admin/dashboard` و نمایش «داشبورد پرسنل»؛
|
||||
سایدبار فقط «داشبورد» و «سرویسهای من» را دارد.
|
||||
- ❌ خطا:
|
||||
- `GET /api/v1/service-items` با توکن پرسنل → `403` با `ERR_FORBIDDEN_001` (نه ۲۰۰ با
|
||||
سرویسهای کلینیک). همینطور `/api/v1/staff` (GET/POST)، `/api/v1/patients`،
|
||||
`/api/v1/appointments`، `/api/v1/dashboard/clinic`.
|
||||
- `POST /api/v1/staff` با `has_account: true` و `phone` خالی یا نامعتبر →
|
||||
`422` با `ERR_STAFF_MOBILE_INVALID`.
|
||||
- ورود پرسنلِ `active=false` → `/oauth/userinfo` هیچ context با `role: "staff"` ندارد و
|
||||
`GET /api/v1/dashboard/staff` → `403`.
|
||||
- ⚠️ مرزی:
|
||||
- موبایلی که **از قبل** `User` دارد (مثلاً بیمار یا منشی): کاربر جدید ساخته نشود؛
|
||||
فقط `ROLE_STAFF` به نقشهایش اضافه شود و ردیف پرسنل به همان کاربر وصل شود. اگر آن
|
||||
کاربر هم منشی است و هم پرسنل → `primary_role` باید `secretary` بماند (نقش قویتر) و
|
||||
context مربوط به staff هم در `available_contexts` بیاید.
|
||||
- یک نفر پرسنلِ **دو** کلینیک: دو ردیف `clinic_staff` با یک `user_id` → دو context در
|
||||
لیست؛ بعد از `switch-context` داشبورد دادههای همان کلینیک را بدهد.
|
||||
- همان موبایل دوباره در همان کلینیک ثبت شود → `409` با `ERR_STAFF_MOBILE_TAKEN`
|
||||
(نه ساخت ردیف تکراری).
|
||||
- پرسنل بدون هیچ سرویس تخصیصیافته → `services: []` و پیام خالی در UI، نه ۵۰۰.
|
||||
- موبایل مالک (خودِ پزشک/کلینیک) بهعنوان پرسنل → `422` با `ERR_STAFF_MOBILE_INVALID`
|
||||
و پیام «شماره مالک نمیتواند پرسنل باشد» (جلوگیری از تنزل نقش/سردرگمی context).
|
||||
|
||||
## فایلهای مرتبط
|
||||
|
||||
| فایل | نقش |
|
||||
|------|-----|
|
||||
| `src/Staff/Entity/ClinicStaff.php` | افزودن رابطهٔ `user` |
|
||||
| `src/Staff/Repository/ClinicStaffRepository.php` | کوئریهای `findActiveByUser`، `findActiveByUserAndEntity`، `findByEntityAndPhone` |
|
||||
| `src/Staff/Service/StaffAccountService.php` | **جدید** — ساخت/اتصال/قطع حساب کاربری پرسنل |
|
||||
| `src/Staff/Controller/StaffController.php` | پذیرش `has_account`/`password` در create/update |
|
||||
| `src/Staff/Controller/StaffDashboardController.php` یا `src/Dashboard/Controller/DashboardController.php` | اندپوینت داشبورد پرسنل |
|
||||
| `src/Staff/Security/StaffRouteGuardSubscriber.php` | **جدید** — default-deny برای کاربر staff |
|
||||
| `src/Auth/Entity/User.php` | `isStaff()` باید `ROLE_STAFF` را هم بپذیرد |
|
||||
| `src/Auth/Controller/AuthController.php` | `resolvePrimaryRole()` + `buildAvailableContexts()` |
|
||||
| `src/Shared/Context/EntityContextResolver.php` | resolve محیط برای کاربر staff |
|
||||
| `src/Shared/Constant/ErrorCodes.php` | کدهای خطای جدید |
|
||||
| `src/ClinicService/Repository/ServiceItemRepository.php` | `findByStaff(ClinicStaff)` |
|
||||
| `assets/admin/pages/StaffPage.tsx` | فیلد موبایل/حساب کاربری + ستون «حساب» |
|
||||
| `assets/admin/pages/DashboardPage.tsx` | `StaffDashboard` + dispatcher |
|
||||
| `assets/admin/pages/StaffMyServicesPage.tsx` | **جدید** — صفحهٔ «سرویسهای من» |
|
||||
| `assets/admin/App.tsx` | `ALLOWED_ROLES` + روتهای نقش staff |
|
||||
| `assets/admin/components/layout/Sidebar.tsx` | منوی نقش staff |
|
||||
| `assets/admin/types/index.ts` | فیلدهای جدید `ClinicStaff` |
|
||||
| `docs/api/staff.md`، `docs/api/auth.md`، `docs/api/dashboard.md` | مستندسازی (قانون ثابت پروژه) |
|
||||
|
||||
## وضعیت فعلی
|
||||
|
||||
### `src/Staff/Entity/ClinicStaff.php` — هیچ ارتباطی با `User` ندارد
|
||||
|
||||
```php
|
||||
#[ORM\Entity(repositoryClass: ClinicStaffRepository::class)]
|
||||
#[ORM\Table(name: 'clinic_staff')]
|
||||
#[ORM\Index(columns: ['entity_type', 'entity_id', 'active'], name: 'idx_staff_entity_active')]
|
||||
class ClinicStaff
|
||||
{
|
||||
#[ORM\Column(name: 'entity_type', type: 'string', length: 10)]
|
||||
private string $entityType;
|
||||
|
||||
#[ORM\Column(name: 'entity_id', type: 'integer')]
|
||||
private int $entityId;
|
||||
|
||||
#[ORM\Column(name: 'full_name', type: 'string', length: 200)]
|
||||
private string $fullName;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 20, nullable: true)]
|
||||
private ?string $phone = null;
|
||||
// …
|
||||
}
|
||||
```
|
||||
|
||||
### `src/Auth/Entity/User.php:121` — گیت ورود به پنل
|
||||
|
||||
```php
|
||||
public function isStaff(): bool
|
||||
{
|
||||
return $this->hasRole('ROLE_DOCTOR')
|
||||
|| $this->hasRole('ROLE_CLINIC')
|
||||
|| $this->hasRole('ROLE_SECRETARY')
|
||||
|| $this->hasRole('ROLE_ADMIN')
|
||||
|| $this->hasRole('ROLE_REPRESENTATION')
|
||||
|| $this->hasRole('ROLE_IMPORTER');
|
||||
}
|
||||
```
|
||||
|
||||
`PasswordAuthenticator::onAuthenticationSuccess:79` بدون این متد لاگین را ۴۰۳ میکند:
|
||||
|
||||
```php
|
||||
if (!$user->isStaff()) {
|
||||
return new JsonResponse([... ErrorCodes::ERR_AUTH_006 ...], 403);
|
||||
}
|
||||
```
|
||||
|
||||
### `src/Auth/Controller/AuthController.php:690` — نقش اصلی و لیست محیطها
|
||||
|
||||
```php
|
||||
private function resolvePrimaryRole(User $user): string
|
||||
{
|
||||
$roles = $user->getRoles();
|
||||
if (in_array('ROLE_ADMIN', $roles, true)) return 'admin';
|
||||
if (in_array('ROLE_CLINIC', $roles, true)) return 'clinic';
|
||||
if (in_array('ROLE_DOCTOR', $roles, true)) return 'doctor';
|
||||
if (in_array('ROLE_SECRETARY', $roles, true)) return 'secretary';
|
||||
if (in_array('ROLE_REPRESENTATION', $roles, true)) return 'representation';
|
||||
return 'user';
|
||||
}
|
||||
```
|
||||
|
||||
و در `buildAvailableContexts()` منشی اینطور context میگیرد (الگوی مرجع برای پرسنل):
|
||||
|
||||
```php
|
||||
foreach ($this->secretaryRepo->findAllActiveBySecretary($user) as $rel) {
|
||||
// …
|
||||
$contexts[] = [
|
||||
'type' => 'doctor',
|
||||
'db_uuid' => $rel->getDoctor()->getUuid(),
|
||||
'name' => 'مطب ' . $rel->getDoctor()->getName(),
|
||||
'role' => 'secretary',
|
||||
'scope' => 'doctor',
|
||||
'permissions' => $rel->getPermissions(),
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
### `src/Secretary/Service/SecretaryService.php:38` — الگوی مرجع ساخت کاربر
|
||||
|
||||
```php
|
||||
public function resolveSecretaryUser(string $mobile, ?string $name = null, ?string $password = null): User
|
||||
{
|
||||
$user = $this->userRepo->findByMobile($mobile);
|
||||
if ($user === null) {
|
||||
$user = new User($mobile);
|
||||
if (!empty($password)) {
|
||||
$user->setPasswordHash($this->hasher->hashPassword($user, $password));
|
||||
}
|
||||
}
|
||||
if (!empty($name)) {
|
||||
$user->setRealName(trim($name));
|
||||
}
|
||||
|
||||
$roles = $user->getRoles();
|
||||
if (!in_array('ROLE_SECRETARY', $roles, true)) {
|
||||
$roles[] = 'ROLE_SECRETARY';
|
||||
$user->setRoles(array_values(array_unique($roles)));
|
||||
}
|
||||
$this->userRepo->save($user);
|
||||
|
||||
return $user;
|
||||
}
|
||||
```
|
||||
|
||||
### `src/ClinicService/Entity/ServiceItem.php:44` — رابطهٔ سرویس ↔ پرسنل (منبع «سرویسهای من»)
|
||||
|
||||
```php
|
||||
#[ORM\ManyToMany(targetEntity: ClinicStaff::class, fetch: 'EAGER')]
|
||||
#[ORM\JoinTable(name: 'service_item_staff')]
|
||||
private Collection $staffMembers;
|
||||
```
|
||||
|
||||
### `assets/admin/App.tsx:83` — نقشهای مجاز پنل
|
||||
|
||||
```tsx
|
||||
const ALLOWED_ROLES = ['admin', 'doctor', 'clinic', 'secretary', 'representation'] as const;
|
||||
```
|
||||
|
||||
### `assets/admin/hooks/usePermissions.ts` — نکتهٔ حیاتی
|
||||
|
||||
```ts
|
||||
const perms = context?.permissions as { resources?: ... } | undefined | null;
|
||||
if (!perms?.resources) return true; // نبودِ permissions یعنی «آزاد»، نه «بسته»
|
||||
```
|
||||
|
||||
پس context پرسنل **حتماً** باید `permissions.resources` صریح داشته باشد، وگرنه UI همهچیز
|
||||
را باز میکند.
|
||||
|
||||
## وظایف
|
||||
|
||||
### ۱. مدل داده: اتصال `ClinicStaff` به `User`
|
||||
|
||||
`src/Staff/Entity/ClinicStaff.php`:
|
||||
|
||||
```php
|
||||
#[ORM\ManyToOne(targetEntity: \App\Auth\Entity\User::class)]
|
||||
#[ORM\JoinColumn(name: 'user_id', nullable: true, onDelete: 'SET NULL')]
|
||||
private ?User $user = null;
|
||||
|
||||
public function getUser(): ?User { return $this->user; }
|
||||
public function hasAccount(): bool { return $this->user !== null; }
|
||||
public function setUser(?User $user): self { $this->user = $user; $this->updatedAt = time(); return $this; }
|
||||
```
|
||||
|
||||
و در `toArray()`:
|
||||
|
||||
```php
|
||||
'has_account' => $this->user !== null,
|
||||
'user_uuid' => $this->user?->getUuid(),
|
||||
```
|
||||
|
||||
ایندکس لازم: `#[ORM\Index(columns: ['user_id', 'active'], name: 'idx_staff_user_active')]`
|
||||
(چون `findActiveByUser` در هر بار `userinfo` صدا زده میشود).
|
||||
|
||||
`ClinicStaffRepository`:
|
||||
|
||||
```php
|
||||
/** @return ClinicStaff[] ردیفهای فعالِ این کاربر در همهٔ محیطها */
|
||||
public function findActiveByUser(User $user): array;
|
||||
|
||||
public function findActiveByUserAndEntity(User $user, string $entityType, int $entityId): ?ClinicStaff;
|
||||
|
||||
/** برای جلوگیری از ثبت تکراری یک موبایل در همان محیط */
|
||||
public function findByEntityAndPhone(string $entityType, int $entityId, string $phone): ?ClinicStaff;
|
||||
```
|
||||
|
||||
سپس migration:
|
||||
|
||||
```bash
|
||||
ddev exec php bin/console doctrine:migrations:diff --no-interaction
|
||||
ddev exec php bin/console doctrine:migrations:migrate --no-interaction
|
||||
```
|
||||
|
||||
**نحوه تست:** `ddev exec php bin/console doctrine:schema:validate` باید سبز باشد؛
|
||||
`DESCRIBE clinic_staff` ستون `user_id` را نشان دهد.
|
||||
|
||||
### ۲. `StaffAccountService` — تنها نقطهٔ ساخت/اتصال حساب پرسنل
|
||||
|
||||
`src/Staff/Service/StaffAccountService.php` (جدید). قرینهٔ `SecretaryService::resolveSecretaryUser`
|
||||
است، اما با اعتبارسنجی موبایل و قاعدهٔ «مالک نمیتواند پرسنل خودش باشد»:
|
||||
|
||||
```php
|
||||
class StaffAccountService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly UserRepository $userRepo,
|
||||
private readonly ClinicStaffRepository $staffRepo,
|
||||
private readonly UserPasswordHasherInterface $hasher,
|
||||
private readonly SmsService $smsService,
|
||||
private readonly string $appUrl,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* حساب کاربری پرسنل را میسازد یا به کاربر موجود وصل میکند و ROLE_STAFF میدهد.
|
||||
*
|
||||
* @throws AppException ERR_STAFF_MOBILE_INVALID | ERR_STAFF_MOBILE_TAKEN
|
||||
*/
|
||||
public function attachAccount(ClinicStaff $staff, string $mobile, ?string $password, User $owner): User
|
||||
{
|
||||
$mobile = $this->normalizeMobile($mobile); // ارقام فارسی → لاتین
|
||||
if (!preg_match('/^09\d{9}$/', $mobile)) {
|
||||
throw new AppException(ErrorCodes::ERR_STAFF_MOBILE_INVALID, null, 422);
|
||||
}
|
||||
if ($mobile === $owner->getMobileNumber()) {
|
||||
throw new AppException(ErrorCodes::ERR_STAFF_MOBILE_INVALID, 'شماره مالک نمیتواند پرسنل باشد', 422);
|
||||
}
|
||||
|
||||
$user = $this->userRepo->findByMobile($mobile);
|
||||
|
||||
// یک موبایل، در یک محیط، فقط یک ردیف پرسنل
|
||||
$duplicate = $this->staffRepo->findByEntityAndPhone($staff->getEntityType(), $staff->getEntityId(), $mobile);
|
||||
if ($duplicate !== null && $duplicate->getId() !== $staff->getId()) {
|
||||
throw new AppException(ErrorCodes::ERR_STAFF_MOBILE_TAKEN, null, 409);
|
||||
}
|
||||
|
||||
if ($user === null) {
|
||||
$user = new User($mobile);
|
||||
}
|
||||
if (!empty($password)) {
|
||||
$user->setPasswordHash($this->hasher->hashPassword($user, $password));
|
||||
}
|
||||
$user->setRealName($staff->getFullName());
|
||||
$user->addRole('ROLE_STAFF');
|
||||
$this->userRepo->save($user);
|
||||
|
||||
$staff->setUser($user)->setPhone($mobile);
|
||||
$this->staffRepo->save($staff);
|
||||
|
||||
$this->sendWelcomeSms($mobile, $ownerName); // TAG_STAFF، مشابه TAG_SECRETARY
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
/** قطع دسترسی بدون حذف ردیف پرسنل (سوابق سرویس/نوبت حفظ میشود). */
|
||||
public function detachAccount(ClinicStaff $staff): void;
|
||||
}
|
||||
```
|
||||
|
||||
نکتهها:
|
||||
- `addRole()` روی `User` از قبل هست (`src/Auth/Entity/User.php:107`) — از آن استفاده کن،
|
||||
آرایهٔ roles را دستی دستکاری نکن.
|
||||
- برای SMS: `SmsLog::TAG_STAFF` را به ثابتها و `SmsMessageTemplate` اضافه کن (الگوی
|
||||
`SmsLog::TAG_SECRETARY => [...]` در `src/Sms/Entity/SmsMessageTemplate.php:58`). اگر
|
||||
افزودن قالب پیامک ریسک/هزینه دارد، همان `TAG_SECRETARY` را استفاده نکن — بهجایش
|
||||
ارسال SMS را در این فاز حذف کن و در پاسخ API فقط `has_account` را برگردان.
|
||||
- کدهای خطای جدید در `src/Shared/Constant/ErrorCodes.php`:
|
||||
`ERR_STAFF_MOBILE_INVALID => 'شماره موبایل پرسنل معتبر نیست'`،
|
||||
`ERR_STAFF_MOBILE_TAKEN => 'برای این شماره قبلاً پرسنلی ثبت شده است'`.
|
||||
|
||||
**نحوه تست:** یونیتتست `tests/Staff/StaffAccountServiceTest.php` با سه سناریو:
|
||||
کاربر جدید ساخته میشود / کاربر موجود فقط نقش میگیرد و رمز قبلیاش پاک نمیشود اگر
|
||||
`password` خالی باشد / موبایل مالک → `AppException` با کد ۴۲۲.
|
||||
|
||||
### ۳. `StaffController` — پذیرش حساب کاربری در create/update
|
||||
|
||||
در `create()` و `update()` (فایل `src/Staff/Controller/StaffController.php`) بعد از
|
||||
`$this->staffRepo->save($staff)`:
|
||||
|
||||
```php
|
||||
$wantsAccount = (bool) ($data['has_account'] ?? false);
|
||||
if ($wantsAccount) {
|
||||
$this->staffAccounts->attachAccount($staff, (string) ($data['phone'] ?? ''), $data['password'] ?? null, $user);
|
||||
} elseif ($staff->hasAccount() && array_key_exists('has_account', $data)) {
|
||||
$this->staffAccounts->detachAccount($staff);
|
||||
}
|
||||
|
||||
return $this->success($staff->toArray(), 201);
|
||||
```
|
||||
|
||||
کنترلر نازک بماند: هیچ منطق hash/نقش/اعتبارسنجی موبایل داخل کنترلر نوشته نشود
|
||||
(`AppException` را `ExceptionSubscriber` به envelope خطا تبدیل میکند).
|
||||
|
||||
**مهم:** `resolveEntity()` همین کنترلر نباید برای `ROLE_STAFF` چیزی برگرداند — امروز به
|
||||
`['unknown', null]` میافتد و ۴۰۳ میدهد؛ همین رفتار درست است، دست نخورد (پرسنل حق
|
||||
مدیریت پرسنل ندارد).
|
||||
|
||||
**نحوه تست:**
|
||||
```bash
|
||||
TOKEN=$(curl -s -X POST https://clinic-pro.ddev.site/api/v1/user/login \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"mobile_number":"09390039833","password":"09390039833"}' | jq -r .access_token)
|
||||
|
||||
curl -s -X POST https://clinic-pro.ddev.site/api/v1/staff \
|
||||
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
|
||||
-d '{"full_name":"زهرا احمدی","phone":"09121110000","job_title":"پرستار","has_account":true,"password":"Staff@1234"}' | jq
|
||||
# انتظار: 201، has_account:true، user_uuid غیرتهی
|
||||
```
|
||||
|
||||
### ۴. Auth — نقش، محیط کاری و مجوزهای پرسنل
|
||||
|
||||
الف) `src/Auth/Entity/User.php` → `isStaff()` را با `|| $this->hasRole('ROLE_STAFF')` کامل کن
|
||||
(بدون آن، لاگین پرسنل ۴۰۳ میگیرد).
|
||||
|
||||
ب) `AuthController::resolvePrimaryRole()` — بعد از `secretary` و قبل از `representation`:
|
||||
|
||||
```php
|
||||
if (in_array('ROLE_STAFF', $roles, true)) return 'staff';
|
||||
```
|
||||
|
||||
(ترتیب عمدی است: کسی که هم منشی است هم پرسنل، منشی میماند چون نقش پرتوانتر است.)
|
||||
|
||||
ج) `AuthController::buildAvailableContexts()` — بلوک جدید در انتها:
|
||||
|
||||
```php
|
||||
foreach ($this->staffRepo->findActiveByUser($user) as $row) {
|
||||
$owner = $row->getEntityType() === 'clinic'
|
||||
? $this->clinicRepo->find($row->getEntityId())
|
||||
: $this->doctorRepo->find($row->getEntityId());
|
||||
if ($owner === null) { continue; }
|
||||
|
||||
$contexts[] = [
|
||||
'type' => $row->getEntityType(),
|
||||
'db_uuid' => $owner->getUuid(),
|
||||
'name' => $row->getEntityType() === 'clinic' ? ($owner->getName() ?? '') : 'مطب ' . $owner->getName(),
|
||||
'role' => 'staff',
|
||||
'scope' => $row->getEntityType(),
|
||||
'permissions' => StaffPermissions::DEFAULT, // ثابت، نه قابل ویرایش در این فاز
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
با ثابتِ صریح (مثلاً `src/Staff/Security/StaffPermissions.php`):
|
||||
|
||||
```php
|
||||
public const DEFAULT = [
|
||||
'version' => 1,
|
||||
'resources' => [
|
||||
'services' => ['view' => true],
|
||||
'appointments' => ['view' => true],
|
||||
],
|
||||
];
|
||||
```
|
||||
|
||||
د) `EntityContextResolver` — تا وقتی staff در `canActInClinic()` / `canActForDoctor()`
|
||||
شناخته نشود، `fromActiveContext()` برای او `null` برمیگرداند و داشبورد ۴۰۳ میدهد:
|
||||
|
||||
```php
|
||||
// canActInClinic()
|
||||
if ($this->staffRepo->findActiveByUserAndEntity($user, 'clinic', $clinic->getId()) !== null) {
|
||||
return true;
|
||||
}
|
||||
// canActForDoctor()
|
||||
if ($this->staffRepo->findActiveByUserAndEntity($user, 'doctor', $doctor->getId()) !== null) {
|
||||
return true;
|
||||
}
|
||||
```
|
||||
|
||||
`fromRole()` برای staff هیچ fallback ندهد (مثل منشی) — محیطش فقط از `UserActiveContext`
|
||||
میآید، چون میتواند پرسنل چند محیط باشد.
|
||||
|
||||
**نحوه تست:**
|
||||
```bash
|
||||
STAFF=$(curl -s -X POST https://clinic-pro.ddev.site/api/v1/user/login \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"mobile_number":"09121110000","password":"Staff@1234"}' | jq -r .access_token)
|
||||
curl -s https://clinic-pro.ddev.site/oauth/userinfo -H "Authorization: Bearer $STAFF" | jq '.data.primary_role, .data.available_contexts'
|
||||
# انتظار: "staff" و یک context با role=staff و permissions محدود
|
||||
```
|
||||
|
||||
### ۵. Default-deny: `StaffRouteGuardSubscriber`
|
||||
|
||||
`src/Staff/Security/StaffRouteGuardSubscriber.php` (جدید) روی `KernelEvents::CONTROLLER`:
|
||||
|
||||
```php
|
||||
/**
|
||||
* کاربری که «فقط» ROLE_STAFF دارد به هیچ اندپوینتی جز allowlist دسترسی ندارد.
|
||||
*
|
||||
* چرایی: بیشتر کنترلرها tenant را از EntityContextResolver میگیرند و مجوز را فقط
|
||||
* برای منشی/پزشکِ مهمان میسنجند؛ بدون این گارد، کاربر staff با context حلشده به
|
||||
* دادهٔ کل کلینیک میرسد. تصمیم در یک نقطه متمرکز است تا کنترلرِ جدید هم بهصورت
|
||||
* پیشفرض بسته باشد.
|
||||
*/
|
||||
private const ALLOWED_PREFIXES = [
|
||||
'/api/v1/dashboard/staff',
|
||||
'/api/v1/staff/me',
|
||||
'/api/v1/auth/switch-context',
|
||||
'/api/v1/user/change-password',
|
||||
'/oauth/',
|
||||
];
|
||||
```
|
||||
|
||||
قواعد:
|
||||
- فقط وقتی فعال شود که کاربر `ROLE_STAFF` دارد و **هیچکدام** از
|
||||
`ROLE_ADMIN/ROLE_CLINIC/ROLE_DOCTOR/ROLE_SECRETARY/ROLE_REPRESENTATION` را ندارد.
|
||||
- در غیر allowlist: `AppException(ErrorCodes::ERR_FORBIDDEN_001, null, 403)`.
|
||||
- مسیرهای عمومی (غیر `/api`) دستنخورده بمانند.
|
||||
|
||||
**نحوه تست:** `tests/Staff/StaffRouteGuardTest.php` — با توکن پرسنل روی اینها ۴۰۳:
|
||||
`/api/v1/service-items`، `/api/v1/staff`، `/api/v1/patients`، `/api/v1/appointments`،
|
||||
`/api/v1/dashboard/clinic`؛ و روی `/api/v1/dashboard/staff` و `/oauth/userinfo` ۲۰۰.
|
||||
|
||||
### ۶. اندپوینت داشبورد پرسنل
|
||||
|
||||
`GET /api/v1/dashboard/staff` — قرینهٔ `/api/v1/dashboard/secretary`
|
||||
(`src/Dashboard/Controller/DashboardController.php:529`). طبق قاعدهٔ «اول بگرد، بعد بساز»:
|
||||
اندپوینت موجودی وجود ندارد که خروجی محدودشده به یک پرسنل بدهد، پس ساختش لازم است.
|
||||
|
||||
```php
|
||||
#[Route('/api/v1/dashboard/staff', methods: ['GET'])]
|
||||
#[IsGranted('ROLE_STAFF')]
|
||||
public function staff(#[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$context = $this->contextResolver->resolve($user);
|
||||
if (!$context->isResolved()) {
|
||||
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'محیط کاری پرسنل تنظیم نشده', 403);
|
||||
}
|
||||
[$entityType, $entityId] = $context->toEntityPair();
|
||||
|
||||
$row = $this->staffRepo->findActiveByUserAndEntity($user, $entityType, $entityId);
|
||||
if ($row === null) {
|
||||
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'دسترسی پرسنل تنظیم نشده', 403);
|
||||
}
|
||||
|
||||
return $this->success([
|
||||
'scope' => $entityType,
|
||||
'staff' => ['uuid' => $row->getUuid(), 'full_name' => $row->getFullName(), 'job_title' => $row->getJobTitle()],
|
||||
'owner' => ['name' => $ownerName],
|
||||
'stats' => ['today_appointments' => $todayCount, 'services' => count($services)],
|
||||
'services' => $services, // از ServiceItemRepository::findByStaff()
|
||||
'today_appointments' => $todayAppointments, // appointments.staff_id = این پرسنل، امروز
|
||||
]);
|
||||
}
|
||||
```
|
||||
|
||||
`ServiceItemRepository::findByStaff(ClinicStaff $staff): array` — DQL با
|
||||
`INNER JOIN i.staffMembers s WHERE s = :staff AND i.active = true`، محدود به همان tenant.
|
||||
خروجی سرویسها فقط فیلدهای لازم: `uuid, name, price_rials, duration_minutes, section_name, active`
|
||||
(قیمت لازم است چون پرسنل باید بداند چه سرویسی با چه تعرفهای به او تخصیص یافته).
|
||||
|
||||
نوبتهای امروز: DQL روی `Appointment` با `a.staff = :staff` و بازهٔ
|
||||
`strtotime('today midnight')` تا `strtotime('tomorrow midnight') - 1` (تایماستمپ صحیح، نه DateTime).
|
||||
|
||||
**نحوه تست:** بعد از تخصیص یک سرویس به پرسنل از صفحهٔ سرویسها:
|
||||
```bash
|
||||
curl -s https://clinic-pro.ddev.site/api/v1/dashboard/staff -H "Authorization: Bearer $STAFF" | jq '.data.services, .data.stats'
|
||||
```
|
||||
|
||||
### ۷. پنل: فرم پرسنل + نقش staff در روتینگ و سایدبار
|
||||
|
||||
الف) `assets/admin/pages/StaffPage.tsx`:
|
||||
- در `schema` فیلدهای `has_account: z.boolean().optional()` و `password: z.string().optional()`
|
||||
اضافه شود؛ با `superRefine`: اگر `has_account` روشن است، `phone` باید `^09\d{9}$` باشد.
|
||||
- در `StaffFormFields` یک چکباکس «ایجاد حساب کاربری برای ورود به پنل» و ورودی رمز
|
||||
(فقط وقتی چکباکس روشن است). ورودی موبایل همان `phone` فعلی است با `numericField(..., 11)`.
|
||||
- یک ستون جدید در `columns`: «حساب کاربری» با `ActiveBadge`/متن «دارد / ندارد» از
|
||||
`s.has_account`.
|
||||
- `assets/admin/types/index.ts` → `ClinicStaff` با `has_account: boolean; user_uuid: string | null`.
|
||||
|
||||
ب) `assets/admin/App.tsx`:
|
||||
```tsx
|
||||
const ALLOWED_ROLES = ['admin', 'doctor', 'clinic', 'secretary', 'representation', 'staff'] as const;
|
||||
```
|
||||
و روت جدید داخل `AdminLayout`:
|
||||
```tsx
|
||||
<Route path="/admin/my-services" element={<RoleRoute roles={['staff']}><StaffMyServicesPage /></RoleRoute>} />
|
||||
```
|
||||
|
||||
ج) `assets/admin/components/layout/Sidebar.tsx` — بلوک `if (primaryRole === "staff")`
|
||||
قبل از `representation`، دقیقاً با ساختار بقیه (بخش «عمومی» با داشبورد + «مدیریت» با
|
||||
«سرویسهای من»). هیچ آیتم تنظیمات/مالی/بیمار نداشته باشد. `ROLE_LABELS` هم مقدار
|
||||
`staff: 'پرسنل'` بگیرد.
|
||||
|
||||
د) `assets/admin/pages/DashboardPage.tsx` — کامپوننت `StaffDashboard` قرینهٔ
|
||||
`SecretaryDashboard` (همان `LoadingSkeleton`، همان کارتهای KPI، همان حالت خطا) و در
|
||||
dispatcher: `if (primaryRole === 'staff') return <StaffDashboard />;`
|
||||
|
||||
ه) `assets/admin/pages/StaffMyServicesPage.tsx` — جدول سرویسهای تخصیصیافته با
|
||||
`DataTable` + `PageHeader` (بدون دکمهٔ ایجاد/ویرایش؛ فقط خواندنی). چون از داشبورد باز
|
||||
میشود، `backTo="/admin/dashboard"` بدهد.
|
||||
|
||||
**نحوه تست:**
|
||||
```bash
|
||||
ddev exec npx tsc --noEmit --project tsconfig.json
|
||||
ddev exec yarn dev
|
||||
ddev exec yarn test
|
||||
```
|
||||
سپس دستی: ورود با `09121110000 / Staff@1234` در `/admin/login` →
|
||||
داشبورد پرسنل، سایدبار دو آیتمی، ورود مستقیم به `/admin/patients` → ریدایرکت به داشبورد.
|
||||
|
||||
### ۸. تستها و مستندات
|
||||
|
||||
- `tests/Staff/StaffAccountServiceTest.php` — یونیت (وظیفهٔ ۲).
|
||||
- `tests/Staff/StaffRouteGuardTest.php` — فانکشنال default-deny (وظیفهٔ ۵).
|
||||
- `tests/Staff/StaffDashboardTest.php` — موفق (۲۰۰ با سرویسهای خودش) / خطا (پرسنل
|
||||
غیرفعال → ۴۰۳) / مرزی (بدون سرویس → `services: []`).
|
||||
- `TenantSchemaCoverageTest` باید همچنان سبز باشد (`clinic_staff` از قبل tenant-keyed است؛
|
||||
ستون `user_id` طبقهبندی آن را عوض نمیکند — اگر تست قرمز شد، دلیلش را بررسی کن، نه
|
||||
اینکه entity را به `GlobalTables` اضافه کنی).
|
||||
- اجرای کامل: `ddev exec php bin/phpunit` و `ddev exec php vendor/bin/phpstan analyse`.
|
||||
- مستندات (قانون ثابت پروژه): `docs/api/staff.md` (فیلدهای جدید create/update + اندپوینت
|
||||
`/api/v1/staff/me` اگر ساخته شد)، `docs/api/auth.md` (نقش `staff` در `primary_role` و
|
||||
context جدید)، `docs/api/dashboard.md` (اندپوینت `/api/v1/dashboard/staff`).
|
||||
|
||||
## نکات مهم
|
||||
|
||||
- **پرسنل ≠ منشی.** منشی مجوزهای قابلویرایش دارد (`DoctorSecretary.permission`)؛ پرسنل در
|
||||
این فاز مجوز ثابت و حداقلی دارد (`StaffPermissions::DEFAULT`). ویرایشگر مجوز پرسنل
|
||||
ساخته نشود — abstraction «برای آینده» ممنوع است.
|
||||
- **`usePermissions` نبودِ `permissions` را «آزاد» تفسیر میکند** — context پرسنل حتماً
|
||||
آبجکت صریح `resources` داشته باشد، وگرنه UI همهچیز را باز میکند.
|
||||
- **غیرفعالسازی پرسنل باید دسترسی را قطع کند:** `PATCH /api/v1/staff/{uuid}/toggle` وقتی
|
||||
`active=false` میشود، `findActiveByUser` دیگر آن ردیف را برنمیگرداند، پس context حذف
|
||||
میشود. اما توکن JWT قبلی تا انقضا معتبر است؛ به همین دلیل گارد وظیفهٔ ۶ (بررسی
|
||||
`findActiveByUserAndEntity` در هر درخواست داشبورد) لازم است و نمیتوان فقط به context
|
||||
اکتفا کرد.
|
||||
- **حذف نشدن سوابق:** `detachAccount` فقط `user_id` را `null` میکند؛ ردیف `clinic_staff` و
|
||||
ارجاعات `service_item_staff` / `appointments.staff_id` / `session_services.staff_id` دست
|
||||
نمیخورند.
|
||||
- **تایماستمپها `int` Unix** و تاریخها در UI شمسی با `formatDate` — طبق قواعد پروژه.
|
||||
- **الگو:** `StaffAccountService` نقش Service Layer را دارد (قرینهٔ `SecretaryService`) و
|
||||
`StaffRouteGuardSubscriber` الگوی Guard/Interceptor است؛ انتخابشان برای تکنقطهای کردن
|
||||
دو تصمیم است: «چه کسی حساب دارد» و «چه چیزی برای staff باز است».
|
||||
- **رشتههای UI فارسی** بمانند و صفحات جدید از همان `PageHeader` / `DataTable` /
|
||||
`SettingsLayout` و توکنهای `styles.css` استفاده کنند — طراحی جدید ساخته نشود.
|
||||
Reference in New Issue
Block a user