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:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -123,6 +123,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
||||
return $this->hasRole('ROLE_DOCTOR')
|
||||
|| $this->hasRole('ROLE_CLINIC')
|
||||
|| $this->hasRole('ROLE_SECRETARY')
|
||||
|| $this->hasRole('ROLE_STAFF') // پرسنل کلینیک/مطب — داشبورد محدود خودش
|
||||
|| $this->hasRole('ROLE_ADMIN')
|
||||
|| $this->hasRole('ROLE_REPRESENTATION') // نماینده — لاگین با نامکاربری/رمز مجاز است
|
||||
|| $this->hasRole('ROLE_IMPORTER'); // کاربر سیستمی کرالر — لاگین با رمز؛ دسترسی فقط اندپوینت ایمپورت
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\ClinicService\Repository;
|
||||
|
||||
use App\ClinicService\Entity\ServiceItem;
|
||||
use App\ClinicService\Entity\ServiceSection;
|
||||
use App\Staff\Entity\ClinicStaff;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
@@ -41,6 +42,28 @@ class ServiceItemRepository extends ServiceEntityRepository
|
||||
->getResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* سرویسهای فعالِ تخصیصیافته به یک پرسنل، محدود به محیط خودش.
|
||||
*
|
||||
* ردیفهای قبل از مهاجرتِ چندپرسنلی فقط ستون تکیِ `staff` را دارند، پس هر دو
|
||||
* سمت رابطه بررسی میشود.
|
||||
*
|
||||
* @return ServiceItem[]
|
||||
*/
|
||||
public function findByStaff(ClinicStaff $staff): array
|
||||
{
|
||||
return $this->createQueryBuilder('i')
|
||||
->join('i.section', 's')
|
||||
->leftJoin('i.staffMembers', 'm')
|
||||
->where('s.entityType = :type')->setParameter('type', $staff->getEntityType())
|
||||
->andWhere('s.entityId = :id')->setParameter('id', $staff->getEntityId())
|
||||
->andWhere('i.active = true')
|
||||
->andWhere('m = :staff OR i.staff = :staff')->setParameter('staff', $staff)
|
||||
->orderBy('i.name', 'ASC')
|
||||
->getQuery()
|
||||
->getResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* Count services per section in a single query (avoids N+1 in the section list).
|
||||
*
|
||||
|
||||
@@ -4,6 +4,8 @@ namespace App\Dashboard\Controller;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Clinic\Repository\ClinicRepository;
|
||||
use App\ClinicService\Entity\ServiceItem;
|
||||
use App\ClinicService\Repository\ServiceItemRepository;
|
||||
use App\Doctor\Repository\DoctorRepository;
|
||||
use App\Patient\Repository\PatientRecordRepository;
|
||||
use App\Patient\Repository\PatientSessionRepository;
|
||||
@@ -13,6 +15,8 @@ use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Context\EntityContextResolver;
|
||||
use App\Shared\Controller\BaseController;
|
||||
use App\Sms\Service\SmsWalletService;
|
||||
use App\Staff\Repository\ClinicStaffRepository;
|
||||
use App\Staff\Security\StaffPermissions;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
@@ -35,6 +39,8 @@ class DashboardController extends BaseController
|
||||
private readonly EntityContextResolver $contextResolver,
|
||||
private readonly \App\Clinic\Security\ClinicDoctorPermissionChecker $permChecker,
|
||||
private readonly \App\Doctor\Repository\DoctorAddressRepository $addressRepo,
|
||||
private readonly ClinicStaffRepository $staffRepo,
|
||||
private readonly ServiceItemRepository $serviceItemRepo,
|
||||
) {}
|
||||
|
||||
// ── Clinic Dashboard ────────────────────────────────────────────────────
|
||||
@@ -668,5 +674,75 @@ class DashboardController extends BaseController
|
||||
'today_appointments' => $todayAppts,
|
||||
]);
|
||||
}
|
||||
|
||||
// ── Staff Dashboard ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* داشبورد پرسنل: سرویسهایی که به او تخصیص یافته و نوبتهای امروزِ خودش.
|
||||
*
|
||||
* نقش تنها کافی نیست — ردیف فعالِ پرسنل در محیط فعال هم باید وجود داشته باشد،
|
||||
* چون توکنِ صادرشده تا انقضا معتبر میماند و غیرفعالشدنِ پرسنل باید همان لحظه
|
||||
* دسترسی را ببندد.
|
||||
*/
|
||||
#[Route('/api/v1/dashboard/staff', methods: ['GET'])]
|
||||
#[IsGranted('ROLE_STAFF')]
|
||||
public function staff(#[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$context = $this->contextResolver->resolve($user);
|
||||
if (!$context->isResolved()) {
|
||||
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'محیط کاری پرسنل تنظیم نشده', 403);
|
||||
}
|
||||
|
||||
[$entityType, $entityId] = $context->toEntityPair();
|
||||
$staff = $this->staffRepo->findActiveByUserAndEntity($user, $entityType, $entityId);
|
||||
if ($staff === null) {
|
||||
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'دسترسی پرسنل تنظیم نشده', 403);
|
||||
}
|
||||
|
||||
$todayStart = strtotime('today midnight');
|
||||
$todayEnd = strtotime('tomorrow midnight') - 1;
|
||||
|
||||
$todayAppts = $this->em->createQuery('
|
||||
SELECT a.uuid, u.realName AS patient_name, u.mobileNumber AS patient_mobile,
|
||||
a.slotStart AS slot_start, a.status
|
||||
FROM App\Appointment\Entity\Appointment a
|
||||
JOIN a.user u
|
||||
WHERE a.staff = :staff AND a.slotStart >= :s AND a.slotStart <= :e
|
||||
ORDER BY a.slotStart ASC
|
||||
')->setParameters(['staff' => $staff, 's' => $todayStart, 'e' => $todayEnd])
|
||||
->getArrayResult();
|
||||
|
||||
$services = array_map(
|
||||
static fn(ServiceItem $item) => [
|
||||
'uuid' => $item->getUuid(),
|
||||
'name' => $item->getName(),
|
||||
'section_name' => $item->getSection()->getName(),
|
||||
'price_rials' => $item->getPriceRials(),
|
||||
'duration_minutes' => $item->getDurationMinutes(),
|
||||
],
|
||||
$this->serviceItemRepo->findByStaff($staff),
|
||||
);
|
||||
|
||||
return $this->success([
|
||||
'scope' => $entityType,
|
||||
'staff' => [
|
||||
'uuid' => $staff->getUuid(),
|
||||
'full_name' => $staff->getFullName(),
|
||||
'job_title' => $staff->getJobTitle(),
|
||||
],
|
||||
'owner' => [
|
||||
'name' => $context->isClinic()
|
||||
? ($context->clinic?->getName() ?? '')
|
||||
: ($context->doctor?->getName() ?? ''),
|
||||
],
|
||||
'permissions' => StaffPermissions::DEFAULT,
|
||||
'stats' => [
|
||||
'today_appointments' => count($todayAppts),
|
||||
'services' => count($services),
|
||||
],
|
||||
'services' => $services,
|
||||
'today_appointments' => $todayAppts,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 => 'تریال در حال حاضر غیرفعال است',
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user