- 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.
14 KiB
Dashboard API
Role-specific dashboard endpoints. Each endpoint requires the corresponding role JWT.
GET /api/v1/dashboard/clinic
Returns stats and today's schedule for the authenticated clinic owner.
Auth: ROLE_CLINIC required
Query params
| Param | Type | Default | Description |
|---|---|---|---|
from |
int (unix) | start of current month | Period start for patient/revenue stats |
to |
int (unix) | now | Period end for patient/revenue stats |
patients_year |
int (Jalali) | current Jalali year | Year of the month drawn by charts.appointments_by_day |
patients_month |
int 1..12 |
current Jalali month | Jalali month drawn by charts.appointments_by_day |
revenue_year |
int (Jalali) | current Jalali year | Jalali year drawn by charts.revenue_by_month |
patients_year / revenue_year are clamped to 1300..1500 and patients_month to 1..12.
Response 200
{
"success": true,
"data": {
"clinic": {
"uuid": "string",
"name": "string",
"is_active": true,
"logo": "string | null"
},
"stats": {
"total_doctors": 5,
"today_appointments": 12,
"this_month_appointments": 87,
"pending_invitations": 2,
"sms_wallet_balance": 50000,
"unique_patients_count": 34,
"total_patients": 210,
"revenue_period_rials": 12500000,
"today_payments_rials": 5225000,
"week_payments_rials": 560000200
},
"charts": {
"revenue_by_day": [
{ "label": "۷ خرداد", "amount_rials": 3200000 }
],
"revenue_by_month": [
{ "label": "فروردین", "amount_rials": 3200000 }
],
"appointments_by_day": [
{ "label": "۱", "count": 9 }
]
},
"charts_period": { "patients_year": 1405, "patients_month": 4, "revenue_year": 1405 },
"period": { "from": 1717200000, "to": 1719792000 },
"today_appointments": [
{
"uuid": "string",
"patient_name": "string | null",
"patient_mobile": "string | null",
"doctor_name": "string",
"service_name": "string | null",
"slot_start": 1700000000,
"slot_end": 1700001800,
"status": "reserved"
}
],
"doctors": [
{
"uuid": "string",
"name": "string",
"today_count": 3
}
]
}
}
Field notes:
sms_wallet_balance— current SMS wallet balance in Rials (0 if wallet not yet created)unique_patients_count— distinct patients with at least one session in thefrom–toperiodtotal_patients— distinct patients ever (no period filter)revenue_period_rials— sum offinal_price_rialsfrom all patient sessions in the periodtoday_payments_rials/week_payments_rials— revenue for today / the last 7 dayscharts.appointments_by_day— «نمودار تعداد بیماران»: one entry per day of the requested Jalali month (28–31 entries), label = Jalali day number in Persian digits, empty days arecount: 0charts.revenue_by_month— «میزان درآمد»: exactly 12 entries, one per Jalali month ofrevenue_year, label = Persian month namecharts.revenue_by_day— last 7 days; kept only as the source oftoday_payments_rials/week_payments_rials, not drawn by the dashboard chartscharts_period— the effective (post-clamp) chart period, so the UI can reflect what was actually renderedtoday_appointments— up to 5 records, ordered byslot_start ASC; each row carriespatient_mobile,doctor_name(personnel),service_name(nullable — booked service item), andslot_endfor the «لیست نوبتهای جدید» dashboard table (that table renders read-only —statusis still returned but no longer shown)doctors— all doctors belonging to this clinic; each includes their appointment count for today
Errors
| Code | HTTP | Description |
|---|---|---|
ERR_NOT_FOUND_001 |
404 | Clinic not found for this user |
GET /api/v1/dashboard/doctor
Returns stats and today's schedule for the authenticated doctor.
Auth: ROLE_DOCTOR required
Query params
| Param | Type | Default | Description |
|---|---|---|---|
from |
int (unix) | start of current month | Period start for patient/revenue stats |
to |
int (unix) | now | Period end for patient/revenue stats |
patients_year |
int (Jalali) | current Jalali year | Year of the month drawn by charts.appointments_by_day |
patients_month |
int 1..12 |
current Jalali month | Jalali month drawn by charts.appointments_by_day |
revenue_year |
int (Jalali) | current Jalali year | Jalali year drawn by charts.revenue_by_month |
Response 200
{
"success": true,
"data": {
"doctor": {
"uuid": "string",
"name": "string",
"degree": "string | null"
},
"stats": {
"today_appointments": 8,
"tomorrow_appointments": 5,
"this_month_appointments": 62,
"avg_rating": 4.6,
"total_ratings": 34,
"sms_wallet_balance": 25000,
"unique_patients_count": 18,
"total_patients": 140,
"revenue_period_rials": 6800000,
"today_payments_rials": 5225000,
"week_payments_rials": 42000000
},
"charts": {
"revenue_by_day": [ { "label": "۷ خرداد", "amount_rials": 3200000 } ],
"revenue_by_month": [ { "label": "فروردین", "amount_rials": 3200000 } ],
"appointments_by_day": [ { "label": "۱", "count": 4 } ]
},
"charts_period": { "patients_year": 1405, "patients_month": 4, "revenue_year": 1405 },
"period": { "from": 1717200000, "to": 1719792000 },
"today_appointments": [
{
"uuid": "string",
"patient_name": "string | null",
"patient_mobile": "string",
"doctor_name": "string",
"service_name": "string | null",
"slot_start": 1700000000,
"slot_end": 1700001800,
"status": "reserved"
}
],
"clinics": [
{
"uuid": "string",
"name": "string",
"logo": "string | null"
}
]
}
}
Field notes:
today_appointments— up to 10 records, ordered byslot_start ASC; each row carriespatient_mobile,doctor_name,service_name(nullable), andslot_endfor the dashboard appointments tableavg_rating— rounded to 1 decimal;nullif no ratings yetclinics— all clinics the doctor belongs tosms_wallet_balance,unique_patients_count,revenue_period_rials— same semantics as clinic dashboardcharts.*,charts_period— same Jalali-period semantics as the clinic dashboard;revenue_by_day/revenue_by_monthare omitted entirely when the caller may not see financials (see the note at the end of this file)
Errors
| Code | HTTP | Description |
|---|---|---|
ERR_NOT_FOUND_001 |
404 | Doctor profile not found for this user |
GET /api/v1/dashboard/secretary
Returns stats for the authenticated secretary and (conditionally) today's appointments.
Auth: ROLE_SECRETARY required
Response 200
پاسخ بر اساس scope محیطِ فعالِ منشی دو شکل دارد. کلید scope تمایزدهنده است: در scope=doctor فیلد doctor هست (نه clinic) و در scope=clinic فیلد clinic (نه doctor). کلاینت باید هر دو را مدیریت کند و بهصورت مستقیم به data.doctor.name دسترسی نگیرد.
منشیِ مطبِ شخصی (scope=doctor):
{
"success": true,
"data": {
"scope": "doctor",
"doctor": { "uuid": "string", "name": "string", "degree": "string | null" },
"permissions": { "resources": { "appointments": { "view": true, "update_status": false } } },
"stats": { "today_appointments": 8, "tomorrow_appointments": 5 },
"today_appointments": [
{ "uuid": "string", "patient_name": "string | null", "patient_mobile": "string", "slot_start": 1700000000, "status": "reserved" }
]
}
}
منشیِ کلینیک (scope=clinic) — بدون کلید doctor:
{
"success": true,
"data": {
"scope": "clinic",
"clinic": { "uuid": "string", "name": "string" },
"permissions": { "resources": { "appointments": { "view": true } } },
"stats": { "today_appointments": 12, "tomorrow_appointments": 7 },
"today_appointments": [
{ "uuid": "string", "patient_name": "string | null", "patient_mobile": "string", "slot_start": 1700000000, "status": "reserved", "doctor_name": "string" }
]
}
}
today_appointments — only populated when permissions.resources.appointments.view === true; up to 10 records (doctor scope) / 20 (clinic scope) when visible. در scope کلینیک هر ردیف doctor_name هم دارد.
Errors
| Code | HTTP | Description |
|---|---|---|
ERR_FORBIDDEN_001 |
403 | Secretary relation not configured or inactive |
GET /api/v1/dashboard/staff
داشبورد پرسنل: سرویسهایی که به این پرسنل تخصیص یافته و نوبتهای امروزِ خودش.
Auth: ROLE_STAFF — و علاوه بر نقش، باید ردیف فعالِ clinic_staff در محیط فعال وجود
داشته باشد. توکن تا انقضا معتبر میماند، پس غیرفعالکردن پرسنل همان لحظه با همین بررسی
دسترسی را میبندد.
این تنها اندپوینت دادهٔ نقش staff است؛ بقیهٔ /api/v1/* برای این نقش ۴۰۳ میدهد
(StaffRouteGuardSubscriber — رجوع به auth.md).
Response 200 (خروجی واقعی)
{
"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 | بدون توکن |
خروجی واقعی حالت غیرفعال:
{"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.
Auth: ROLE_ADMIN required
Query params
| Param | Type | Default | Description |
|---|---|---|---|
from |
int (unix) | 30 days ago | Period start |
to |
int (unix) | now | Period end |
Response 200
{
"success": true,
"data": {
"appointments_by_day": [
{ "date": "06/01", "count": 12 }
],
"revenue_by_day": [
{ "date": "06/01", "amount": 3500000 }
],
"appointment_status": [
{ "status": "confirmed", "count": 320 }
],
"top_specialties": [
{ "name": "قلب و عروق", "count": 85 }
],
"subscription_sales_by_plan": [
{ "plan": "basic", "count": 14, "revenue": 4060000 }
],
"period": { "from": 1717200000, "to": 1719792000 }
}
}
Field notes:
appointments_by_day/revenue_by_day— one entry per calendar day in the period; days with no data appear ascount: 0/amount: 0appointment_status— all-time counts, not filtered by periodtop_specialties— top 8 by appointment volume, all-timesubscription_sales_by_plan— subscriptions created in period, grouped by plan;revenuesums only payments with statusreceived
Doctor dashboard is context-scoped (2026-07)
GET /api/v1/dashboard/doctor now accepts an optional clinic_uuid. When absent it falls back
to the caller's stored active context (user_active_context), then to their role.
In a clinic context the response is restricted to that clinic:
- appointment counts and
today_appointmentsonly include appointments whoseaddress_idbelongs to that clinic; - the financial fields are omitted entirely —
revenue_period_rials,today_payments_rials,week_payments_rials,sms_wallet_balance,unique_patients_count,total_patients, andcharts.revenue_by_day, andcharts.revenue_by_month. They describe the doctor's personal practice and have no meaning inside someone else's clinic. They return only in the personal context, or for the clinic's own owner holdingpayments.view; clinicscomes back as[]— the "کلینیکهای من" list belongs to the personal dashboard.
A clinic_uuid the caller has no access to is ignored and the personal context is used.
New response field:
"context": { "type": "personal" | "clinic", "clinic_uuid": "…|null", "clinic_name": "…|null" }
The admin SPA dispatches on this: primaryRole === 'doctor' && context.scope === 'clinic' renders
InvitedDoctorDashboard (appointment tiles + today's list only) instead of the full doctor
dashboard. Hiding the cards client-side was not enough — the endpoint is directly callable.