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
+14 -1
View File
@@ -309,7 +309,7 @@ Authorization: Bearer <token>
| فیلد | نوع | توضیح |
|------|-----|-------|
| `primary_role` | string | نقش اصلی: `admin` \| `clinic` \| `doctor` \| `secretary` \| `representation` \| `user` |
| `primary_role` | string | نقش اصلی: `admin` \| `clinic` \| `doctor` \| `secretary` \| `staff` \| `representation` \| `user` |
| `db_uuid` | string\|null | UUID موجودیت فعال (null = هنوز context انتخاب نشده) |
| `db_key` | string\|null | `HMAC-SHA256(db_uuid, APP_SECRET)` برای اعتبارسنجی |
| `doctor_uuid` | string\|null | UUID دکتر — ثابت است حتی در context کلینیک که `db_uuid` برابر UUID کلینیک است. برای کاربران غیر دکتر: `null` |
@@ -327,6 +327,7 @@ Authorization: Bearer <token>
| پزشکِ عضو کلینیک (`role: doctor`، `scope: clinic`) | envelope کامل `{version, resources}` از `clinic_doctor_permissions` |
| پزشکِ عضوی که دسترسی‌اش غیرفعال شده | `{version: 1, resources: {}}` — یعنی هیچ دسترسی |
| منشی (`role: secretary`) | envelope کامل از `doctor_secretaries` |
| پرسنل (`role: staff`) | envelope ثابت `{"version":1,"resources":{"services":{"view":true},"appointments":{"view":true}}}` — قابل ویرایش نیست |
نکتهٔ مهم برای کلاینت: **نبودِ `permissions` (یا `null`) یعنی «بدون محدودیت»، نه «بدون دسترسی».** ساختار و کلیدهای مجوز پزشکِ عضو کلینیک در `docs/api/clinic.md` → بخش *Clinic Doctor Permissions* آمده است.
@@ -335,14 +336,26 @@ Authorization: Bearer <token>
- `ROLE_CLINIC``"clinic"`
- `ROLE_DOCTOR``"doctor"`
- `ROLE_SECRETARY``"secretary"`
- `ROLE_STAFF``"staff"` (پرسنل کلینیک/مطب؛ عمداً بعد از منشی: کاربری که هر دو نقش را دارد منشی می‌ماند)
- `ROLE_REPRESENTATION``"representation"` (نماینده؛ دسترسی محدود به پنل ادمین: افزودن پزشک/کلینیک، نوبت‌های پزشکانِ زیرمجموعه، داشبورد نماینده)
- بقیه → `"user"`
**نقش `staff`** — کاربری که از `POST /api/v1/staff` با `has_account: true` ساخته شده
(رجوع به [staff.md](staff.md)):
- هر ردیف **فعالِ** `clinic_staff` که به این کاربر وصل است، یک context با `role: "staff"` و
`scope` برابر `doctor` یا `clinic` می‌سازد. پرسنلِ غیرفعال هیچ context نمی‌گیرد.
- ورود با رمز مجاز است (`User::isStaff()` شامل `ROLE_STAFF` است).
- دسترسی API این کاربر **پیش‌فرض بسته** است: فقط `GET /api/v1/dashboard/staff`،
`POST /api/v1/auth/switch-context`، `POST /api/v1/user/change-password` و مسیرهای `/oauth/*`؛
بقیهٔ `/api/v1/*` با `ERR_FORBIDDEN_001` و ۴۰۳ رد می‌شود (`StaffRouteGuardSubscriber`).
**قانون `context.role`** — نقشی که در آن محیط کاری فعال است:
- context مطب شخصی دکتر: `"doctor"`
- context کلینیک که دکتر **عضو** آن است (مالک نیست): `"doctor"` + `"scope": "clinic"` — پزشک می‌ماند و فقط نوبت‌های خودش در آن کلینیک را می‌بیند؛ دسترسی مدیریتی پنل کلینیک ندارد
- context کلینیک که دکتر **صاحب** آن است: `"clinic"` (دسترسی کامل مالک)
- context منشی: `"secretary"`
- context پرسنل: `"staff"` + `scope` برابر نوع محیط (`doctor` یا `clinic`)
> **نکته frontend:** پس از `switchContext`، `primaryRole` در store از `context.role` و `scope` از `context.scope` آپدیت می‌شود. وقتی `role:"doctor"` و `scope:"clinic"` است (پزشکِ مهمان)، Sidebar فقط «داشبورد» و «نوبت‌ها» را نشان می‌دهد و مسیرهای مدیریتی (`staff`, `clinic-services`, `subscription`, `my-secretaries`, `my-patients`, `profile`) به داشبورد ریدایرکت می‌شوند. در سمت backend هم endpointهای مدیریتی برای پزشک فقط scope **شخصیِ** خودش را برمی‌گردانند (نه کلینیک) و endpointهای ویرایش کلینیک مالکیت را چک می‌کنند (۴۰۳).
+65
View File
@@ -242,6 +242,71 @@ Returns stats for the authenticated secretary and (conditionally) today's appoin
---
## GET /api/v1/dashboard/staff
داشبورد پرسنل: سرویس‌هایی که به این پرسنل تخصیص یافته و نوبت‌های امروزِ خودش.
**Auth:** `ROLE_STAFF` — و علاوه بر نقش، باید ردیف **فعالِ** `clinic_staff` در محیط فعال وجود
داشته باشد. توکن تا انقضا معتبر می‌ماند، پس غیرفعال‌کردن پرسنل همان لحظه با همین بررسی
دسترسی را می‌بندد.
این تنها اندپوینت دادهٔ نقش `staff` است؛ بقیهٔ `/api/v1/*` برای این نقش ۴۰۳ می‌دهد
(`StaffRouteGuardSubscriber` — رجوع به [auth.md](auth.md)).
### Response `200` (خروجی واقعی)
```json
{
"success": true,
"data": {
"scope": "doctor",
"staff": {
"uuid": "c99320e2-257a-4d96-9b3a-7723fe198e79",
"full_name": "زهرا احمدی",
"job_title": "پرستار"
},
"owner": { "name": "09390039833" },
"permissions": {
"version": 1,
"resources": { "services": { "view": true }, "appointments": { "view": true } }
},
"stats": { "today_appointments": 0, "services": 1 },
"services": [
{
"uuid": "f9ffb607-f137-4ffb-8327-76427fd6fe55",
"name": "سرم",
"section_name": "تزریقات",
"price_rials": 1000000,
"duration_minutes": null
}
],
"today_appointments": []
}
}
```
| فیلد | نوع | توضیح |
|------|-----|-------|
| `scope` | `doctor` \| `clinic` | نوع محیطِ فعال |
| `owner.name` | string | نام مطب/کلینیکِ مالک |
| `services` | array | سرویس‌های **فعالِ** تخصیص‌یافته به این پرسنل (`service_item_staff` و ستون legacy تکی) |
| `today_appointments` | array | نوبت‌های امروز با `appointments.staff_id` برابر این پرسنل — `uuid`, `patient_name`, `patient_mobile`, `slot_start`, `status` |
| `permissions` | object | ثابت است و ویرایش‌پذیر نیست |
### Errors
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_FORBIDDEN_001` | 403 | محیط کاری تنظیم نشده، یا ردیف پرسنل در آن محیط فعال نیست |
| `ERR_AUTH_001` | 401 | بدون توکن |
خروجی واقعی حالت غیرفعال:
```json
{"success":false,"data":null,"errors":[{"code":"ERR_FORBIDDEN_001","message":"محیط کاری پرسنل تنظیم نشده"}]}
```
---
## GET /api/v1/admin/dashboard/charts
Returns time-series chart data for admin dashboard. All series are filtered to the given `from``to` window.
+58 -17
View File
@@ -2,6 +2,18 @@
مدیریت پرسنل مطب/کلینیک (بدون حذف — فقط toggle فعال/غیرفعال).
**هر پرسنل حساب کاربری ورود دارد.** هنگام ایجاد/ویرایش، یک `User` با نقش `ROLE_STAFF` ساخته
(یا کاربر موجودِ همان موبایل استفاده) و به ردیف پرسنل وصل می‌شود؛ بنابراین `phone` اجباری و
معتبر (`^09\d{9}$`) است و همان **نام‌کاربری ورود** است. تغییر `phone` یعنی تغییر نام‌کاربری.
چنین کاربری در `/admin` وارد می‌شود، ولی دسترسی‌اش به `GET /api/v1/dashboard/staff` و چند
مسیر حساب کاربری محدود است (بقیهٔ `/api/v1/*` برای او ۴۰۳ است — رجوع به
[dashboard.md](dashboard.md) و [auth.md](auth.md)). قطع دسترسی با
`PATCH /api/v1/staff/{uuid}/toggle` انجام می‌شود، نه با حذف حساب.
> ردیف‌های پرسنلِ ساخته‌شده پیش از این تغییر ممکن است `has_account: false` باشند؛ با اولین
> ویرایش (که `phone` معتبر می‌خواهد) صاحب حساب می‌شوند.
---
## GET /api/v1/staff
@@ -25,6 +37,8 @@
"address": null,
"national_code": "0012345678",
"active": true,
"has_account": false,
"user_uuid": null,
"created_at": 1718000000,
"updated_at": 1718000000
}
@@ -32,6 +46,11 @@
}
```
| فیلد | نوع | توضیح |
|------|-----|-------|
| has_account | bool | حساب ورود دارد یا نه — برای ردیف‌های جدید همیشه `true`؛ `false` فقط در ردیف‌های قدیمیِ پیش از این قابلیت |
| user_uuid | string\|null | uuid کاربرِ متصل؛ `null` یعنی حساب ندارد |
---
## POST /api/v1/staff
@@ -43,38 +62,45 @@
**Request Body:**
```json
{
"full_name": "علی محمدی",
"phone": "09121234567",
"job_title": "منشی",
"full_name": "محمد رحیمی",
"phone": "09121110002",
"job_title": "پرستار",
"address": "تهران، خیابان ولیعصر",
"national_code": "0012345678"
"national_code": "0012345678",
"password": "Staff@1234"
}
```
| فیلد | نوع | الزامی |
|------|-----|--------|
| full_name | string | ✅ |
| phone | string | ❌ |
| phone | string `^09\d{9}$`، ارقام فارسی به لاتین تبدیل می‌شوند | ✅ نام‌کاربری ورود |
| job_title | string | ❌ |
| address | string | ❌ |
| national_code | string(15) — ارقام فارسی به لاتین تبدیل می‌شوند | ❌ |
| password | string | ❌ — رمز ورود؛ خالی بگذارید تا کاربر با «فراموشی رمز» تعیینش کند. روی کاربر موجود، رمز فعلی پاک نمی‌شود |
**Response 201:**
اگر موبایل قبلاً `User` داشته باشد، کاربر جدید ساخته نمی‌شود؛ فقط `ROLE_STAFF` به نقش‌هایش
اضافه و به این ردیف پرسنل وصل می‌شود.
**Response 201** (خروجی واقعی):
```json
{
"success": true,
"data": {
"uuid": "a1b2c3d4-...",
"entity_type": "clinic",
"entity_id": 5,
"full_name": "علی محمدی",
"phone": "09121234567",
"job_title": "منشی",
"address": "تهران، خیابان ولیعصر",
"national_code": "0012345678",
"uuid": "d64826bc-5e5d-4df6-83bb-3ddc70a99636",
"entity_type": "doctor",
"entity_id": 1,
"full_name": "محمد رحیمی",
"phone": "09121110002",
"job_title": "پرستار",
"address": null,
"national_code": null,
"active": true,
"created_at": 1718000000,
"updated_at": 1718000000
"has_account": true,
"user_uuid": "4d79b9b0-f330-4dea-9f02-e38fc716b115",
"created_at": 1785393740,
"updated_at": 1785393740
}
}
```
@@ -83,8 +109,16 @@
| Code | HTTP | توضیح |
|------|------|-------|
| ERR_VALIDATION_001 | 422 | full_name خالی است |
| ERR_STAFF_MOBILE_INVALID | 422 | شماره خالی/نامعتبر است یا شمارهٔ خودِ مالک محیط است (`field: "phone"`) |
| ERR_STAFF_MOBILE_TAKEN | 409 | در همین محیط، پرسنل دیگری با این شماره ثبت شده است (`field: "phone"`) |
| ERR_FORBIDDEN_001 | 403 | پروفایل doctor/clinic یافت نشد |
خروجی واقعی خطاها:
```json
{"success":false,"data":null,"errors":[{"code":"ERR_STAFF_MOBILE_INVALID","message":"شماره موبایل پرسنل معتبر نیست","field":"phone"}]}
{"success":false,"data":null,"errors":[{"code":"ERR_STAFF_MOBILE_TAKEN","message":"برای این شماره قبلاً پرسنلی ثبت شده است","field":"phone"}]}
```
---
## PATCH /api/v1/staff/{uuid}
@@ -100,16 +134,23 @@
"phone": "09129999999",
"job_title": "منشی ارشد",
"address": null,
"national_code": null
"national_code": null,
"password": "NewPass@123"
}
```
ویرایش هم حساب را می‌سازد/به‌روز می‌کند: اگر `phone` ارسال نشود، شمارهٔ فعلی همان ردیف
استفاده می‌شود؛ اگر شمارهٔ جدید بیاید، نام‌کاربری ورود عوض می‌شود. `password` خالی رمز فعلی
را پاک نمی‌کند.
**Response 200:** همان ساختار staff object
**Errors:**
| Code | HTTP | توضیح |
|------|------|-------|
| ERR_STAFF_NOT_FOUND | 404 | پرسنل یافت نشد |
| ERR_STAFF_MOBILE_INVALID | 422 | شمارهٔ نامعتبر یا شمارهٔ مالک محیط |
| ERR_STAFF_MOBILE_TAKEN | 409 | شماره در همین محیط قبلاً ثبت شده |
| ERR_FORBIDDEN_001 | 403 | دسترسی ندارید |
---