From 57aeb409347703bf292df344ac7c36c04bb49cfc Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Thu, 30 Jul 2026 10:18:41 +0330 Subject: [PATCH] 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. --- .claude/prompt/staff-user-account-login.md | 623 ++++++++++++++++++ assets/admin/App.tsx | 5 +- assets/admin/components/layout/Sidebar.tsx | 22 + .../components/layout/SidebarStaff.test.tsx | 50 ++ assets/admin/pages/DashboardPage.tsx | 111 ++++ assets/admin/pages/StaffMyServicesPage.tsx | 79 +++ assets/admin/pages/StaffPage.tsx | 35 +- assets/admin/stores/authStore.ts | 4 +- assets/admin/types/index.ts | 12 + docs/api/auth.md | 15 +- docs/api/dashboard.md | 65 ++ docs/api/staff.md | 75 ++- migrations/Version20260730060615.php | 35 + src/Auth/Controller/AuthController.php | 28 + src/Auth/Entity/User.php | 1 + .../Repository/ServiceItemRepository.php | 23 + .../Controller/DashboardController.php | 76 +++ src/Shared/Constant/ErrorCodes.php | 6 +- src/Shared/Context/EntityContextResolver.php | 24 +- src/Staff/Controller/StaffController.php | 30 +- src/Staff/Entity/ClinicStaff.php | 16 + .../Repository/ClinicStaffRepository.php | 37 ++ src/Staff/Security/StaffPermissions.php | 25 + .../Security/StaffRouteGuardSubscriber.php | 87 +++ src/Staff/Service/StaffAccountService.php | 85 +++ tests/Staff/StaffAccountServiceTest.php | 159 +++++ tests/Staff/StaffDashboardAccessTest.php | 141 ++++ tests/Staff/StaffLoginContextTest.php | 120 ++++ 28 files changed, 1960 insertions(+), 29 deletions(-) create mode 100644 .claude/prompt/staff-user-account-login.md create mode 100644 assets/admin/components/layout/SidebarStaff.test.tsx create mode 100644 assets/admin/pages/StaffMyServicesPage.tsx create mode 100644 migrations/Version20260730060615.php create mode 100644 src/Staff/Security/StaffPermissions.php create mode 100644 src/Staff/Security/StaffRouteGuardSubscriber.php create mode 100644 src/Staff/Service/StaffAccountService.php create mode 100644 tests/Staff/StaffAccountServiceTest.php create mode 100644 tests/Staff/StaffDashboardAccessTest.php create mode 100644 tests/Staff/StaffLoginContextTest.php diff --git a/.claude/prompt/staff-user-account-login.md b/.claude/prompt/staff-user-account-login.md new file mode 100644 index 00000000..b0cf42bd --- /dev/null +++ b/.claude/prompt/staff-user-account-login.md @@ -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 +} /> +``` + +ج) `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 ;` + +ه) `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` استفاده کنند — طراحی جدید ساخته نشود. diff --git a/assets/admin/App.tsx b/assets/admin/App.tsx index 610c8d45..2092e88b 100644 --- a/assets/admin/App.tsx +++ b/assets/admin/App.tsx @@ -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() { {/* فاز ۲ — دکتر / کلینیک */} } /> + {/* پرسنل: تنها صفحهٔ دادهٔ این نقش، کنار داشبورد */} + } /> } /> } /> } /> diff --git a/assets/admin/components/layout/Sidebar.tsx b/assets/admin/components/layout/Sidebar.tsx index 8c016e6d..95f803ce 100644 --- a/assets/admin/components/layout/Sidebar.tsx +++ b/assets/admin/components/layout/Sidebar.tsx @@ -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 = { clinic: "مالک کلینیک", doctor: "پزشک", secretary: "منشی", + staff: "پرسنل", representation: "نماینده", user: "کاربر", }; diff --git a/assets/admin/components/layout/SidebarStaff.test.tsx b/assets/admin/components/layout/SidebarStaff.test.tsx new file mode 100644 index 00000000..09bb7778 --- /dev/null +++ b/assets/admin/components/layout/SidebarStaff.test.tsx @@ -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(, { 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(, { route: "/admin/dashboard" }); + + for (const label of ["پرونده بیماران", "تنظیمات", "نوبت‌ها", "پرداخت‌ها"]) { + expect(screen.queryByText(label)).not.toBeInTheDocument(); + } + }); +}); diff --git a/assets/admin/pages/DashboardPage.tsx b/assets/admin/pages/DashboardPage.tsx index 28928c20..0f07eca7 100644 --- a/assets/admin/pages/DashboardPage.tsx +++ b/assets/admin/pages/DashboardPage.tsx @@ -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>('/api/v1/dashboard/staff'), + staleTime: 60_000, + }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const d = useMemo(() => (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 ; + + if (q.isError || !d) { + return ( +
+ +

دسترسی شما به این محیط فعال نیست. با مدیر مطب/کلینیک تماس بگیرید.

+ +
+ ); + } + + 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 ( +
+
+
+

داشبورد پرسنل

+
{today} · {scopeLabel} {d.owner?.name ?? ''}
+
+ +
+ +
+ +
+
{d.staff?.full_name ?? '—'}
+ {d.staff?.job_title &&
{d.staff.job_title}
} +
+
+ +
+ {kpiCards.map(c => ( +
+
+ +
+
{c.label}
+
{c.value}
+
+ ))} +
+ +
+
+

سرویس‌های تخصیص‌یافته

+ همه سرویس‌ها +
+ {d.services.length === 0 ? ( +

+ هنوز سرویسی به شما تخصیص نیافته است. +

+ ) : ( +
+ {d.services.slice(0, 5).map(s => ( +
+
+
{s.name}
+
{s.section_name}
+
+
{formatRial(s.price_rials)}
+
+ ))} +
+ )} +
+ +
+
+

نوبت‌های امروز من

+
+ +
+
+ ); +} + 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 ; if (primaryRole === 'doctor') return ; if (primaryRole === 'secretary') return ; + if (primaryRole === 'staff') return ; if (primaryRole === 'representation') return ; return ; diff --git a/assets/admin/pages/StaffMyServicesPage.tsx b/assets/admin/pages/StaffMyServicesPage.tsx new file mode 100644 index 00000000..3b4c042b --- /dev/null +++ b/assets/admin/pages/StaffMyServicesPage.tsx @@ -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>('/api/v1/dashboard/staff'), + staleTime: 60_000, + }); + + const services = data?.data?.services ?? EMPTY; + + const columns: Column[] = [ + { + key: 'name', + header: 'سرویس', + render: (s) => ( +
+
{s.name}
+
{s.section_name}
+
+ ), + }, + { + key: 'price_rials', + header: 'تعرفه', + render: (s) => {formatRial(s.price_rials)}, + }, + { + key: 'duration_minutes', + header: 'مدت', + render: (s) => ( + + {s.duration_minutes ? `${s.duration_minutes} دقیقه` : '—'} + + ), + }, + ]; + + return ( + <> + + +
+ {services.length === 0 && !isLoading ? ( +
+ +
+ هنوز سرویسی به شما تخصیص نیافته +
+
پس از تخصیص سرویس توسط مطب/کلینیک، اینجا نمایش داده می‌شود.
+
+ ) : ( + + )} +
+ + ); +} diff --git a/assets/admin/pages/StaffPage.tsx b/assets/admin/pages/StaffPage.tsx index ccbdb43d..bf3c0c70 100644 --- a/assets/admin/pages/StaffPage.tsx +++ b/assets/admin/pages/StaffPage.tsx @@ -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; @@ -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) => , }, + { + key: 'has_account', + header: 'حساب کاربری', + render: (s) => ( + + {s.has_account ? 'دارد' : 'ندارد'} + + ), + }, { key: 'created_at', header: 'تاریخ ثبت', @@ -275,8 +295,9 @@ function StaffFormFields({ form }: { form: ReturnType
- + + {errors.phone && {errors.phone.message}}
@@ -287,6 +308,16 @@ function StaffFormFields({ form }: { form: ReturnTypeآدرس
+ +
+ + + {errors.password && {errors.password.message}} +
+ برای هر پرسنل حساب کاربری ساخته می‌شود: با همین شماره موبایل وارد پنل می‌شود و فقط + داشبورد و سرویس‌های خودش را می‌بیند. +
+
); } diff --git a/assets/admin/stores/authStore.ts b/assets/admin/stores/authStore.ts index 06bc627b..be4bb984 100644 --- a/assets/admin/stores/authStore.ts +++ b/assets/admin/stores/authStore.ts @@ -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; @@ -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; diff --git a/assets/admin/types/index.ts b/assets/admin/types/index.ts index fb574c12..03a0d452 100644 --- a/assets/admin/types/index.ts +++ b/assets/admin/types/index.ts @@ -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; diff --git a/docs/api/auth.md b/docs/api/auth.md index 25be9a05..a767bc51 100644 --- a/docs/api/auth.md +++ b/docs/api/auth.md @@ -309,7 +309,7 @@ Authorization: Bearer | فیلد | نوع | توضیح | |------|-----|-------| -| `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 | پزشکِ عضو کلینیک (`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 - `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های ویرایش کلینیک مالکیت را چک می‌کنند (۴۰۳). diff --git a/docs/api/dashboard.md b/docs/api/dashboard.md index ad065bef..efdab7e3 100644 --- a/docs/api/dashboard.md +++ b/docs/api/dashboard.md @@ -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. diff --git a/docs/api/staff.md b/docs/api/staff.md index ad02bdce..70fea682 100644 --- a/docs/api/staff.md +++ b/docs/api/staff.md @@ -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 | دسترسی ندارید | --- diff --git a/migrations/Version20260730060615.php b/migrations/Version20260730060615.php new file mode 100644 index 00000000..cd1139c2 --- /dev/null +++ b/migrations/Version20260730060615.php @@ -0,0 +1,35 @@ +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'); + } +} diff --git a/src/Auth/Controller/AuthController.php b/src/Auth/Controller/AuthController.php index d9f1d3eb..71c8f9b0 100644 --- a/src/Auth/Controller/AuthController.php +++ b/src/Auth/Controller/AuthController.php @@ -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; } diff --git a/src/Auth/Entity/User.php b/src/Auth/Entity/User.php index fed122d6..3cf751b7 100644 --- a/src/Auth/Entity/User.php +++ b/src/Auth/Entity/User.php @@ -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'); // کاربر سیستمی کرالر — لاگین با رمز؛ دسترسی فقط اندپوینت ایمپورت diff --git a/src/ClinicService/Repository/ServiceItemRepository.php b/src/ClinicService/Repository/ServiceItemRepository.php index 7d998481..1ea6d391 100644 --- a/src/ClinicService/Repository/ServiceItemRepository.php +++ b/src/ClinicService/Repository/ServiceItemRepository.php @@ -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). * diff --git a/src/Dashboard/Controller/DashboardController.php b/src/Dashboard/Controller/DashboardController.php index 01c88772..3b647b3f 100644 --- a/src/Dashboard/Controller/DashboardController.php +++ b/src/Dashboard/Controller/DashboardController.php @@ -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, + ]); + } } diff --git a/src/Shared/Constant/ErrorCodes.php b/src/Shared/Constant/ErrorCodes.php index 72393a93..f3d75c1f 100644 --- a/src/Shared/Constant/ErrorCodes.php +++ b/src/Shared/Constant/ErrorCodes.php @@ -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 => 'تریال در حال حاضر غیرفعال است', diff --git a/src/Shared/Context/EntityContextResolver.php b/src/Shared/Context/EntityContextResolver.php index 3b90986c..7c985256 100644 --- a/src/Shared/Context/EntityContextResolver.php +++ b/src/Shared/Context/EntityContextResolver.php @@ -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(); } } diff --git a/src/Staff/Controller/StaffController.php b/src/Staff/Controller/StaffController.php index dc1c6545..0dbb021e 100644 --- a/src/Staff/Controller/StaffController.php +++ b/src/Staff/Controller/StaffController.php @@ -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')) { diff --git a/src/Staff/Entity/ClinicStaff.php b/src/Staff/Entity/ClinicStaff.php index e2835215..8cda376b 100644 --- a/src/Staff/Entity/ClinicStaff.php +++ b/src/Staff/Entity/ClinicStaff.php @@ -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, ]; diff --git a/src/Staff/Repository/ClinicStaffRepository.php b/src/Staff/Repository/ClinicStaffRepository.php index 77795919..3042b251 100644 --- a/src/Staff/Repository/ClinicStaffRepository.php +++ b/src/Staff/Repository/ClinicStaffRepository.php @@ -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); diff --git a/src/Staff/Security/StaffPermissions.php b/src/Staff/Security/StaffPermissions.php new file mode 100644 index 00000000..5e24cf35 --- /dev/null +++ b/src/Staff/Security/StaffPermissions.php @@ -0,0 +1,25 @@ + 1, + 'resources' => [ + 'services' => ['view' => true], + 'appointments' => ['view' => true], + ], + ]; +} diff --git a/src/Staff/Security/StaffRouteGuardSubscriber.php b/src/Staff/Security/StaffRouteGuardSubscriber.php new file mode 100644 index 00000000..741639d9 --- /dev/null +++ b/src/Staff/Security/StaffRouteGuardSubscriber.php @@ -0,0 +1,87 @@ + ['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; + } +} diff --git a/src/Staff/Service/StaffAccountService.php b/src/Staff/Service/StaffAccountService.php new file mode 100644 index 00000000..42696eac --- /dev/null +++ b/src/Staff/Service/StaffAccountService.php @@ -0,0 +1,85 @@ +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; + } +} diff --git a/tests/Staff/StaffAccountServiceTest.php b/tests/Staff/StaffAccountServiceTest.php new file mode 100644 index 00000000..9097e670 --- /dev/null +++ b/tests/Staff/StaffAccountServiceTest.php @@ -0,0 +1,159 @@ +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()); + } +} diff --git a/tests/Staff/StaffDashboardAccessTest.php b/tests/Staff/StaffDashboardAccessTest.php new file mode 100644 index 00000000..f4937c3c --- /dev/null +++ b/tests/Staff/StaffDashboardAccessTest.php @@ -0,0 +1,141 @@ +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)); + } +} diff --git a/tests/Staff/StaffLoginContextTest.php b/tests/Staff/StaffLoginContextTest.php new file mode 100644 index 00000000..3cf78e71 --- /dev/null +++ b/tests/Staff/StaffLoginContextTest.php @@ -0,0 +1,120 @@ +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()); + } +}