- 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.
32 KiB
حساب کاربری برای پرسنل — نقش 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 پرسنل اضافه میکند، اگر شمارهٔ موبایل بدهد و
گزینهٔ «ایجاد حساب کاربری» را بزند:
- یک
Userبا نقشROLE_STAFFساخته/بهروزرسانی شود و به همان ردیفClinicStaffوصل شود. - آن کاربر بتواند با موبایل/رمز در
/admin/loginوارد شود. - بعد از ورود،
primary_role = 'staff'بگیرد و محیط کاریاش همان مطب/کلینیکِ مالک باشد. - داشبورد اختصاصی «پرسنل» ببیند: سرویسهایی که به او تخصیص داده شده + نوبتهای خودش.
- به هیچ چیز دیگری دسترسی نداشته باشد — نه لیست بیماران، نه سرویسهای کل کلینیک، نه مالی، نه مدیریت پرسنل.
تحلیل — نکتهٔ امنیتی که نباید نادیده گرفته شود
بند ۵ سختترین بخش کار است و اگر ساده گرفته شود یک نشت اطلاعات کامل میسازد:
- اکثر کنترلرها فقط
#[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 ندارد
#[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.php → isStaff() را با || $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.ts→ClinicStaffبا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دست نمیخورند. - تایماستمپها
intUnix و تاریخها در UI شمسی باformatDate— طبق قواعد پروژه. - الگو:
StaffAccountServiceنقش Service Layer را دارد (قرینهٔSecretaryService) وStaffRouteGuardSubscriberالگوی Guard/Interceptor است؛ انتخابشان برای تکنقطهای کردن دو تصمیم است: «چه کسی حساب دارد» و «چه چیزی برای staff باز است». - رشتههای UI فارسی بمانند و صفحات جدید از همان
PageHeader/DataTable/SettingsLayoutو توکنهایstyles.cssاستفاده کنند — طراحی جدید ساخته نشود.