Files
clinicpro/.claude/prompt/staff-user-account-login.md
T
hamed 57aeb40934 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.
2026-07-30 10:18:41 +03:30

32 KiB
Raw Blame History

حساب کاربری برای پرسنل — نقش 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/staff200 با 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/staff403.
  • ⚠️ مرزی:
    • موبایلی که از قبل 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 ندارد

#[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 — گیت ورود به پنل

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 بدون این متد لاگین را ۴۰۳ می‌کند:

if (!$user->isStaff()) {
    return new JsonResponse([... ErrorCodes::ERR_AUTH_006 ...], 403);
}

src/Auth/Controller/AuthController.php:690 — نقش اصلی و لیست محیط‌ها

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 می‌گیرد (الگوی مرجع برای پرسنل):

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 — الگوی مرجع ساخت کاربر

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 — رابطهٔ سرویس ↔ پرسنل (منبع «سرویس‌های من»)

#[ORM\ManyToMany(targetEntity: ClinicStaff::class, fetch: 'EAGER')]
#[ORM\JoinTable(name: 'service_item_staff')]
private Collection $staffMembers;

assets/admin/App.tsx:83 — نقش‌های مجاز پنل

const ALLOWED_ROLES = ['admin', 'doctor', 'clinic', 'secretary', 'representation'] as const;

assets/admin/hooks/usePermissions.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:

#[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():

'has_account' => $this->user !== null,
'user_uuid'   => $this->user?->getUuid(),

ایندکس لازم: #[ORM\Index(columns: ['user_id', 'active'], name: 'idx_staff_user_active')] (چون findActiveByUser در هر بار userinfo صدا زده می‌شود).

ClinicStaffRepository:

/** @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:

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 است، اما با اعتبارسنجی موبایل و قاعدهٔ «مالک نمی‌تواند پرسنل خودش باشد»:

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):

$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] می‌افتد و ۴۰۳ می‌دهد؛ همین رفتار درست است، دست نخورد (پرسنل حق مدیریت پرسنل ندارد).

نحوه تست:

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.phpisStaff() را با || $this->hasRole('ROLE_STAFF') کامل کن (بدون آن، لاگین پرسنل ۴۰۳ می‌گیرد).

ب) AuthController::resolvePrimaryRole() — بعد از secretary و قبل از representation:

if (in_array('ROLE_STAFF', $roles, true)) return 'staff';

(ترتیب عمدی است: کسی که هم منشی است هم پرسنل، منشی می‌ماند چون نقش پرتوان‌تر است.)

ج) AuthController::buildAvailableContexts() — بلوک جدید در انتها:

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):

public const DEFAULT = [
    'version'   => 1,
    'resources' => [
        'services'     => ['view' => true],
        'appointments' => ['view' => true],
    ],
];

د) EntityContextResolver — تا وقتی staff در canActInClinic() / canActForDoctor() شناخته نشود، fromActiveContext() برای او null برمی‌گرداند و داشبورد ۴۰۳ می‌دهد:

// 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 می‌آید، چون می‌تواند پرسنل چند محیط باشد.

نحوه تست:

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:

/**
 * کاربری که «فقط» 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). طبق قاعدهٔ «اول بگرد، بعد بساز»: اندپوینت موجودی وجود ندارد که خروجی محدودشده به یک پرسنل بدهد، پس ساختش لازم است.

#[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).

نحوه تست: بعد از تخصیص یک سرویس به پرسنل از صفحهٔ سرویس‌ها:

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.tsClinicStaff با has_account: boolean; user_uuid: string | null.

ب) assets/admin/App.tsx:

const ALLOWED_ROLES = ['admin', 'doctor', 'clinic', 'secretary', 'representation', 'staff'] as const;

و روت جدید داخل AdminLayout:

<Route path="/admin/my-services" element={<RoleRoute roles={['staff']}><StaffMyServicesPage /></RoleRoute>} />

ج) assets/admin/components/layout/Sidebar.tsx — بلوک if (primaryRole === "staff") قبل از representation، دقیقاً با ساختار بقیه (بخش «عمومی» با داشبورد + «مدیریت» با «سرویس‌های من»). هیچ آیتم تنظیمات/مالی/بیمار نداشته باشد. ROLE_LABELS هم مقدار staff: 'پرسنل' بگیرد.

د) assets/admin/pages/DashboardPage.tsx — کامپوننت StaffDashboard قرینهٔ SecretaryDashboard (همان LoadingSkeleton، همان کارت‌های KPI، همان حالت خطا) و در dispatcher: if (primaryRole === 'staff') return <StaffDashboard />;

ه) assets/admin/pages/StaffMyServicesPage.tsx — جدول سرویس‌های تخصیص‌یافته با DataTable + PageHeader (بدون دکمهٔ ایجاد/ویرایش؛ فقط خواندنی). چون از داشبورد باز می‌شود، backTo="/admin/dashboard" بدهد.

نحوه تست:

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