feat: add staff role functionality with dashboard access and service management

- Implemented SidebarStaff component tests to ensure staff users see only their dashboard and services.
- Created StaffMyServicesPage to display assigned services for staff users.
- Added migration to link clinic staff rows to user accounts for ROLE_STAFF access.
- Defined StaffPermissions class for static permissions related to staff role.
- Introduced StaffRouteGuardSubscriber to restrict API access for staff users.
- Developed StaffAccountService for managing staff user accounts and linking them to clinic staff.
- Added comprehensive tests for StaffAccountService to validate user creation, mobile number handling, and account attachment.
- Implemented tests for staff dashboard access to ensure proper permissions and access control.
- Created tests for staff login context to verify correct environment visibility based on user roles.
This commit is contained in:
hamed
2026-07-30 10:18:41 +03:30
parent 6ec011e3ad
commit 57aeb40934
28 changed files with 1960 additions and 29 deletions
+29 -1
View File
@@ -8,8 +8,10 @@ use App\Doctor\Repository\DoctorRepository;
use App\Secretary\Security\SecretaryAccessChecker;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Controller\BaseController;
use App\Shared\Exception\AppException;
use App\Staff\Entity\ClinicStaff;
use App\Staff\Repository\ClinicStaffRepository;
use App\Staff\Service\StaffAccountService;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
@@ -27,6 +29,7 @@ class StaffController extends BaseController
private readonly ClinicRepository $clinicRepo,
private readonly SecretaryAccessChecker $secretaryAccess,
private readonly \App\Clinic\Security\ClinicDoctorAccessChecker $clinicDoctorAccess,
private readonly StaffAccountService $staffAccounts,
) {}
#[Route('/api/v1/staff', methods: ['GET'])]
@@ -70,7 +73,10 @@ class StaffController extends BaseController
$staff->setAddress($data['address'] ?? null);
$staff->setNationalCode($this->toLatinDigits($data['national_code'] ?? null));
$this->staffRepo->save($staff);
// هر پرسنل حساب ورود دارد؛ attachAccount خودش ردیف را ذخیره می‌کند. ترتیب
// عمدی است: اگر شماره نامعتبر/تکراری باشد، ردیف نیم‌کاره‌ای در clinic_staff
// نمی‌ماند چون هنوز ذخیره نشده است.
$this->staffAccounts->attachAccount($staff, $data['phone'] ?? null, $data['password'] ?? null, $this->ownerUser($entityType, $entityId));
return $this->success($staff->toArray(), 201);
}
@@ -101,6 +107,11 @@ class StaffController extends BaseController
$this->staffRepo->save($staff);
// ویرایش هم حساب را می‌سازد/به‌روز می‌کند: ردیف‌های قدیمیِ بدون حساب با اولین
// ویرایش صاحب حساب می‌شوند، و تغییر شماره یعنی تغییر نام‌کاربری ورود.
[$entityType, $entityId] = $this->resolveEntity($user);
$this->staffAccounts->attachAccount($staff, $data['phone'] ?? $staff->getPhone(), $data['password'] ?? null, $this->ownerUser($entityType, $entityId));
return $this->success($staff->toArray());
}
@@ -124,6 +135,23 @@ class StaffController extends BaseController
return $this->success($staff->toArray());
}
/**
* صاحبِ محیط — نه لزوماً کاربرِ درخواست: منشی هم می‌تواند پرسنل ثبت کند، ولی
* قاعدهٔ «شمارهٔ مالک پرسنل نمی‌شود» باید روی مالک واقعی سنجیده شود.
*/
private function ownerUser(string $entityType, int $entityId): User
{
$owner = $entityType === 'clinic'
? $this->clinicRepo->find($entityId)
: $this->doctorRepo->find($entityId);
if ($owner === null) {
throw new AppException(ErrorCodes::ERR_FORBIDDEN_001, 'محیط کاری یافت نشد', 403);
}
return $owner->getUser();
}
private function resolveEntity(User $user): array
{
if ($user->hasRole('ROLE_DOCTOR')) {
+16
View File
@@ -2,6 +2,7 @@
namespace App\Staff\Entity;
use App\Auth\Entity\User;
use App\Staff\Repository\ClinicStaffRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
@@ -9,6 +10,7 @@ use Symfony\Component\Uid\Uuid;
#[ORM\Entity(repositoryClass: ClinicStaffRepository::class)]
#[ORM\Table(name: 'clinic_staff')]
#[ORM\Index(columns: ['entity_type', 'entity_id', 'active'], name: 'idx_staff_entity_active')]
#[ORM\Index(columns: ['user_id', 'active'], name: 'idx_staff_user_active')]
class ClinicStaff
{
#[ORM\Id]
@@ -43,6 +45,15 @@ class ClinicStaff
#[ORM\Column(type: 'boolean')]
private bool $active = true;
/**
* حساب کاربری پرسنل برای ورود به پنل. nullable است چون پرسنل می‌تواند صرفاً یک
* رکورد اطلاعاتی باشد؛ قطع دسترسی هم با null کردن همین ستون انجام می‌شود تا
* ارجاعات سرویس/نوبت به این ردیف دست‌نخورده بماند.
*/
#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\JoinColumn(name: 'user_id', nullable: true, onDelete: 'SET NULL')]
private ?User $user = null;
#[ORM\Column(name: 'created_at', type: 'integer')]
private int $createdAt;
@@ -69,6 +80,8 @@ class ClinicStaff
public function getAddress(): ?string { return $this->address; }
public function getNationalCode(): ?string { return $this->nationalCode; }
public function isActive(): bool { return $this->active; }
public function getUser(): ?User { return $this->user; }
public function hasAccount(): bool { return $this->user !== null; }
public function getCreatedAt(): int { return $this->createdAt; }
public function getUpdatedAt(): int { return $this->updatedAt; }
@@ -78,6 +91,7 @@ class ClinicStaff
public function setAddress(?string $address): self { $this->address = $address; $this->updatedAt = time(); return $this; }
public function setNationalCode(?string $code): self { $this->nationalCode = $code; $this->updatedAt = time(); return $this; }
public function setActive(bool $active): self { $this->active = $active; $this->updatedAt = time(); return $this; }
public function setUser(?User $user): self { $this->user = $user; $this->updatedAt = time(); return $this; }
public function toggleActive(): self
{
@@ -98,6 +112,8 @@ class ClinicStaff
'address' => $this->address,
'national_code' => $this->nationalCode,
'active' => $this->active,
'has_account' => $this->user !== null,
'user_uuid' => $this->user?->getUuid(),
'created_at' => $this->createdAt,
'updated_at' => $this->updatedAt,
];
@@ -2,6 +2,7 @@
namespace App\Staff\Repository;
use App\Auth\Entity\User;
use App\Staff\Entity\ClinicStaff;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
@@ -34,6 +35,42 @@ class ClinicStaffRepository extends ServiceEntityRepository
return $qb->getQuery()->getResult();
}
/**
* ردیف‌های فعالِ این کاربر در همهٔ محیط‌ها — یک نفر می‌تواند پرسنل چند کلینیک/مطب باشد.
*
* @return ClinicStaff[]
*/
public function findActiveByUser(User $user): array
{
return $this->createQueryBuilder('s')
->where('s.user = :user')
->andWhere('s.active = true')
->setParameter('user', $user)
->orderBy('s.fullName', 'ASC')
->getQuery()
->getResult();
}
public function findActiveByUserAndEntity(User $user, string $entityType, int $entityId): ?ClinicStaff
{
return $this->findOneBy([
'user' => $user,
'entityType' => $entityType,
'entityId' => $entityId,
'active' => true,
]);
}
/** برای جلوگیری از ثبت دو پرسنل با یک شماره در یک محیط. */
public function findByEntityAndPhone(string $entityType, int $entityId, string $phone): ?ClinicStaff
{
return $this->findOneBy([
'entityType' => $entityType,
'entityId' => $entityId,
'phone' => $phone,
]);
}
public function save(ClinicStaff $staff): void
{
$this->getEntityManager()->persist($staff);
+25
View File
@@ -0,0 +1,25 @@
<?php
namespace App\Staff\Security;
/**
* مجوزهای ثابتِ نقش پرسنل.
*
* برخلاف منشی که مجوزهایش در ستون JSON قابل ویرایش است، پرسنل در حال حاضر یک
* مجموعهٔ ثابت و حداقلی دارد: فقط دیدنِ سرویس‌هایی که به او تخصیص یافته و
* نوبت‌های خودش. شکل ساختار عمداً همان شکل مجوزهای منشی است تا `usePermissions`
* در پنل بدون شاخهٔ اضافه کار کند.
*
* `usePermissions` نبودِ `resources` را «آزاد» تفسیر می‌کند، پس context پرسنل
* باید همیشه این آرایه را همراه داشته باشد.
*/
final class StaffPermissions
{
public const DEFAULT = [
'version' => 1,
'resources' => [
'services' => ['view' => true],
'appointments' => ['view' => true],
],
];
}
@@ -0,0 +1,87 @@
<?php
namespace App\Staff\Security;
use App\Auth\Entity\User;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Exception\AppException;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\KernelEvents;
/**
* کاربری که «فقط» پرسنل است، به هیچ مسیر API جز allowlist دسترسی ندارد.
*
* بیشتر کنترلرها tenant را از EntityContextResolver می‌گیرند و مجوز را فقط برای
* منشی و پزشکِ مهمان می‌سنجند (SecretaryAccessChecker و ClinicDoctorAccessChecker
* برای بقیهٔ نقش‌ها no-op هستند). حالا که محیطِ پرسنل هم حل می‌شود، بدون این گارد
* یک کاربر پرسنل به دادهٔ کل کلینیک می‌رسید. تصمیم عمداً در یک نقطه متمرکز است تا
* کنترلر جدید هم به‌صورت پیش‌فرض بسته باشد، نه اینکه یادمان برود deny اضافه کنیم.
*/
class StaffRouteGuardSubscriber implements EventSubscriberInterface
{
/** مسیرهایی که نقش پرسنل مجاز است صدا بزند. */
private const ALLOWED_PREFIXES = [
'/api/v1/dashboard/staff',
'/api/v1/auth/switch-context',
'/api/v1/user/change-password',
];
/**
* نقش‌هایی که اگر کاربر یکی‌شان را داشته باشد، این گارد کنار می‌رود: کاربر
* علاوه بر پرسنل بودن، نقش پرتوان‌تری هم دارد و محدودهٔ دسترسی‌اش را همان
* نقش تعیین می‌کند.
*/
private const OVERRIDING_ROLES = [
'ROLE_ADMIN', 'ROLE_CLINIC', 'ROLE_DOCTOR', 'ROLE_SECRETARY', 'ROLE_REPRESENTATION',
];
public function __construct(private readonly Security $security) {}
public static function getSubscribedEvents(): array
{
// بعد از firewall (priority 8) تا توکن ست شده باشد.
return [KernelEvents::REQUEST => ['onKernelRequest', 6]];
}
public function onKernelRequest(RequestEvent $event): void
{
if (!$event->isMainRequest()) {
return;
}
$path = $event->getRequest()->getPathInfo();
if (!str_starts_with($path, '/api/')) {
return;
}
$user = $this->security->getUser();
if (!$user instanceof User || !$this->isStaffOnly($user)) {
return;
}
foreach (self::ALLOWED_PREFIXES as $prefix) {
if (str_starts_with($path, $prefix)) {
return;
}
}
throw new AppException(ErrorCodes::ERR_FORBIDDEN_001, 'دسترسی پرسنل به این بخش مجاز نیست', 403);
}
private function isStaffOnly(User $user): bool
{
if (!$user->hasRole('ROLE_STAFF')) {
return false;
}
foreach (self::OVERRIDING_ROLES as $role) {
if ($user->hasRole($role)) {
return false;
}
}
return true;
}
}
+85
View File
@@ -0,0 +1,85 @@
<?php
namespace App\Staff\Service;
use App\Auth\Entity\User;
use App\Auth\Repository\UserRepository;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Exception\AppException;
use App\Shared\Util\PersianText;
use App\Staff\Entity\ClinicStaff;
use App\Staff\Repository\ClinicStaffRepository;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
/**
* تنها نقطهٔ ساخت/اتصال/قطع حساب کاربری پرسنل.
*
* پرسنل مثل منشی یک `User` است که با شمارهٔ موبایل وارد پنل می‌شود؛ تفاوتش این
* است که رابطهٔ او با محیط، همان ردیف `ClinicStaff` است (نه یک جدول واسط جدا).
* قرینهٔ {@see \App\Secretary\Service\SecretaryService::resolveSecretaryUser()}.
*/
class StaffAccountService
{
public function __construct(
private readonly UserRepository $userRepo,
private readonly ClinicStaffRepository $staffRepo,
private readonly UserPasswordHasherInterface $hasher,
) {}
/**
* حساب ورود پرسنل را می‌سازد یا به کاربر موجودِ همان موبایل وصل می‌کند و
* ROLE_STAFF می‌دهد. شمارهٔ نرمال‌شده روی خود ردیف پرسنل هم ذخیره می‌شود تا
* «شمارهٔ تماس» و «نام کاربری ورود» یکی بمانند.
*
* @param User $owner کاربرِ مالکِ محیط (پزشک/کلینیک) که این پرسنل را ثبت می‌کند
*
* @throws AppException ERR_STAFF_MOBILE_INVALID | ERR_STAFF_MOBILE_TAKEN
*/
public function attachAccount(ClinicStaff $staff, ?string $mobile, ?string $password, User $owner): User
{
$mobile = $this->normalizeMobile($mobile);
if ($mobile === $owner->getMobileNumber()) {
throw new AppException(
ErrorCodes::ERR_STAFF_MOBILE_INVALID,
'شمارهٔ مالک نمی‌تواند به‌عنوان پرسنل ثبت شود',
422,
'phone',
);
}
$duplicate = $this->staffRepo->findByEntityAndPhone($staff->getEntityType(), $staff->getEntityId(), $mobile);
if ($duplicate !== null && $duplicate->getId() !== $staff->getId()) {
throw new AppException(ErrorCodes::ERR_STAFF_MOBILE_TAKEN, null, 409, 'phone');
}
$user = $this->userRepo->findByMobile($mobile) ?? new User($mobile);
// رمز خالی روی کاربر موجود، رمز فعلی‌اش را پاک نمی‌کند؛ کاربر تازه‌ساخته هم
// بدون رمز می‌ماند و باید از «فراموشی رمز» استفاده کند.
if ($password !== null && $password !== '') {
$user->setPasswordHash($this->hasher->hashPassword($user, $password));
}
if ($user->getRealName() === null || $user->getRealName() === '') {
$user->setRealName($staff->getFullName());
}
$user->addRole('ROLE_STAFF');
$this->userRepo->save($user);
$staff->setUser($user)->setPhone($mobile);
$this->staffRepo->save($staff);
return $user;
}
private function normalizeMobile(?string $mobile): string
{
$normalized = preg_replace('/\D+/', '', PersianText::digits((string) $mobile)) ?? '';
if (!preg_match('/^09\d{9}$/', $normalized)) {
throw new AppException(ErrorCodes::ERR_STAFF_MOBILE_INVALID, null, 422, 'phone');
}
return $normalized;
}
}