fix(secretary): allow insurance pages; add grantable subscription resource

Insurance pages redirected to the dashboard: the insurance-pricing/claims
routes never listed `secretary`, so RoleRoute bounced a secretary who had
insurances.view and saw the menu item. Added secretary + permission
['insurances','view'] to both routes; also gated my-financial with
['payments','view'] for consistency.

«خرید اشتراک» was owner-only with no permission toggle, so it could not be
granted. Added a `subscription` secretary resource (view/create) end-to-end:
- entity DEFAULT_PERMISSIONS + SecretaryPermissions type + both secretary forms.
- backend: SubscriptionController::my (view) and trial (create),
  PaymentController::initiateSubscription (create). resolveEntity in
  SubscriptionController was already secretary-aware.
- frontend: subscription + subscription/success routes accept secretary +
  permission; settings navs gate «خرید اشتراک» by ['subscription','view'].

Tests: subscription denied-by-default / allowed-when-granted. docs/api
secretary.md updated (resource list, enforcement map, JSON example).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-07-23 18:21:53 +03:30
co-authored by Claude Opus 4.8
parent 2fbb7233ef
commit 43db753942
11 changed files with 56 additions and 9 deletions
+5 -5
View File
@@ -242,10 +242,10 @@ export default function App() {
<Route path="my-patients/:recordUuid/session/:sessionUuid/pay" element={<RoleRoute roles={['doctor', 'secretary', 'clinic']} blockClinicScope><SessionPaymentPage /></RoleRoute>} />
<Route path="patients/:recordUuid/session/:sessionUuid/edit" element={<RoleRoute roles={['doctor', 'secretary', 'clinic']} blockClinicScope><EditSessionPage /></RoleRoute>} />
<Route path="my-patients/:recordUuid/session/:sessionUuid/edit" element={<RoleRoute roles={['doctor', 'secretary', 'clinic']} blockClinicScope><EditSessionPage /></RoleRoute>} />
<Route path="insurance-pricing" element={<RoleRoute roles={['doctor', 'clinic']} blockClinicScope><InsurancePricingPage /></RoleRoute>} />
<Route path="claims" element={<RoleRoute roles={['doctor', 'clinic']} blockClinicScope><ClaimsPage /></RoleRoute>} />
<Route path="insurance-pricing" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['insurances', 'view']}><InsurancePricingPage /></RoleRoute>} />
<Route path="claims" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['insurances', 'view']}><ClaimsPage /></RoleRoute>} />
<Route path="claims/:patientUuid" element={<RoleRoute roles={['doctor', 'clinic']} blockClinicScope><ClaimPatientDetailPage /></RoleRoute>} />
<Route path="my-financial" element={<RoleRoute roles={['doctor', 'secretary', 'clinic']}><MyFinancialPage /></RoleRoute>} />
<Route path="my-financial" element={<RoleRoute roles={['doctor', 'secretary', 'clinic']} permission={['payments', 'view']}><MyFinancialPage /></RoleRoute>} />
{/* فاز ۲ — دکتر / کلینیک */}
<Route path="staff" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['staff', 'view']}><StaffPage /></RoleRoute>} />
@@ -253,9 +253,9 @@ export default function App() {
<Route path="account-settings" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']}><AccountSettingsPage /></RoleRoute>} />
<Route path="tags-settings" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['tags', 'view']}><TagsSettingsPage /></RoleRoute>} />
<Route path="appointment-settings" element={<RoleRoute roles={['doctor', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><AppointmentSettingsPage /></RoleRoute>} />
<Route path="subscription" element={<RoleRoute roles={['doctor', 'clinic']} blockClinicScope><SubscriptionPage /></RoleRoute>} />
<Route path="subscription" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['subscription', 'view']}><SubscriptionPage /></RoleRoute>} />
<Route path="discounts" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['discounts', 'view']}><DiscountsPage /></RoleRoute>} />
<Route path="subscription/success" element={<RoleRoute roles={['doctor', 'clinic']} blockClinicScope><PaymentSuccessPage /></RoleRoute>} />
<Route path="subscription/success" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['subscription', 'view']}><PaymentSuccessPage /></RoleRoute>} />
<Route path="clinic-services" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['services', 'view']}><ClinicServicesPage /></RoleRoute>} />
<Route path="clinic-services/:uuid" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['services', 'view']}><ServiceDetailPage /></RoleRoute>} />
<Route path="inventory" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['inventory', 'view']}><InventoryPage /></RoleRoute>} />
@@ -31,7 +31,7 @@ type NavItem = {
};
const NAV_ITEMS: NavItem[] = [
{ key: 'subscription', label: 'خرید اشتراک', to: '/admin/subscription' },
{ key: 'subscription', label: 'خرید اشتراک', to: '/admin/subscription', perm: ['subscription', 'view'] },
{ key: 'payment', label: 'مدیریت پرداخت', to: '/admin/my-financial', perm: ['payments', 'view'] },
{ key: 'appointment', label: 'مدیریت نوبت دهی', to: '/admin/appointment-settings', roles: ['doctor'], perm: ['appointment_settings', 'view'] },
{ key: 'appointment', label: 'مدیریت نوبت دهی', to: '/admin/settings/appointment-settings', roles: ['clinic'], perm: ['appointment_settings', 'view'] },
@@ -24,7 +24,7 @@ export type SettingsMenuItem = {
};
export const SETTINGS_MENU: SettingsMenuItem[] = [
{ key: 'subscription', label: 'خرید اشتراک', icon: CreditCardIcon, to: '/admin/subscription' },
{ key: 'subscription', label: 'خرید اشتراک', icon: CreditCardIcon, to: '/admin/subscription', perm: ['subscription', 'view'] },
{ key: 'doctor', label: 'مدیریت پزشک', icon: UserIcon, to: '/admin/profile', roles: ['doctor'] },
{ key: 'appointment', label: 'مدیریت نوبت دهی', icon: CalendarDaysIcon, to: '/admin/appointment-settings', roles: ['doctor'], perm: ['appointment_settings', 'view'] },
{ key: 'appointment', label: 'مدیریت نوبت دهی', icon: CalendarDaysIcon, to: '/admin/settings/appointment-settings', roles: ['clinic'], perm: ['appointment_settings', 'view'] },
+9
View File
@@ -81,6 +81,7 @@ const EMPTY_PERMISSIONS: SecretaryPermissions = {
sms: { view: false, create: false, update: false, delete: false },
appointment_settings: { view: false, update: false },
clinic_doctors: { view: false, create: false, update: false, delete: false },
subscription: { view: false, create: false },
};
type PermSection = keyof SecretaryPermissions;
@@ -229,6 +230,14 @@ const PERMISSION_SECTIONS: {
{ key: "delete", label: "حذف پزشک" },
],
},
{
key: "subscription",
title: "خرید اشتراک",
items: [
{ key: "view", label: "مشاهده اشتراک" },
{ key: "create", label: "خرید/فعال‌سازی اشتراک" },
],
},
];
function PermissionAccordions({
+8
View File
@@ -27,6 +27,7 @@ const DEFAULT_PERMISSIONS: SecretaryPermissions = {
sms: { view: false, create: false, update: false, delete: false },
appointment_settings: { view: false, update: false },
clinic_doctors: { view: false, create: false, update: false, delete: false },
subscription: { view: false, create: false },
};
type PermSection = keyof SecretaryPermissions;
@@ -154,6 +155,13 @@ const PERMISSION_LABELS: Record<PermSection, { label: string; actions: { key: st
{ key: 'delete', label: 'حذف' },
],
},
subscription: {
label: 'خرید اشتراک',
actions: [
{ key: 'view', label: 'مشاهده' },
{ key: 'create', label: 'خرید' },
],
},
};
function PermissionsMatrix({
+4
View File
@@ -536,6 +536,10 @@ export interface SecretaryPermissions {
update: boolean;
delete: boolean;
};
subscription: {
view: boolean;
create: boolean;
};
}
export interface Specialty {
+4 -2
View File
@@ -92,7 +92,8 @@ Create a secretary for a doctor.
"discounts": { "view": false, "create": false, "update": false, "delete": false },
"sms": { "view": false, "create": false, "update": false, "delete": false },
"appointment_settings": { "view": false, "update": false },
"clinic_doctors": { "view": false, "create": false, "update": false, "delete": false }
"clinic_doctors": { "view": false, "create": false, "update": false, "delete": false },
"subscription": { "view": false, "create": false }
}
}
}
@@ -130,7 +131,7 @@ Create a secretary for a doctor.
**Permissions Structure:**
مجموعهٔ منابع (resources) بر اساس صفحات و ماژول‌های در دسترسِ منشی است: `appointments`, `patients`, `payments`, `insurances`, `addresses`, `clinic_info`, `inventory`, `tags`, `services`, `staff`, `discounts`, `sms`, `appointment_settings`, `clinic_doctors`. `mergePermissions` هر منبع/اکشن ارسال‌شده را deep-merge می‌کند. منابعِ `inventory`, `tags`, `services`, `staff`, `discounts`, `sms`, `appointment_settings`, `clinic_doctors` به‌صورت پیش‌فرض همه `false`‌اند (default-deny)؛ بقیه طبق `DEFAULT_PERMISSIONS`. منبعِ `appointment_settings` فقط `view/update` دارد. منبعِ `clinic_doctors` **فقط در حالت کلینیک** معنا دارد (پزشک مستقل نه toggle نه منو).
مجموعهٔ منابع (resources) بر اساس صفحات و ماژول‌های در دسترسِ منشی است: `appointments`, `patients`, `payments`, `insurances`, `addresses`, `clinic_info`, `inventory`, `tags`, `services`, `staff`, `discounts`, `sms`, `appointment_settings`, `clinic_doctors`, `subscription`. منبعِ `subscription` فقط `view/create` دارد. `mergePermissions` هر منبع/اکشن ارسال‌شده را deep-merge می‌کند. منابعِ `inventory`, `tags`, `services`, `staff`, `discounts`, `sms`, `appointment_settings`, `clinic_doctors` به‌صورت پیش‌فرض همه `false`‌اند (default-deny)؛ بقیه طبق `DEFAULT_PERMISSIONS`. منبعِ `appointment_settings` فقط `view/update` دارد. منبعِ `clinic_doctors` **فقط در حالت کلینیک** معنا دارد (پزشک مستقل نه toggle نه منو).
**اعمال (enforcement):** همهٔ منابع در بک‌اند enforce می‌شوند، نه فقط `appointments`. منبعِ حقیقت، ستون JSON `permission` روی ردیفِ فعالِ `DoctorSecretary` در محیطِ فعالِ کاربر (`UserActiveContext.db_uuid`) است؛ نقطهٔ مرکزی `App\Secretary\Security\SecretaryAccessChecker` (`can` / `canOrNonSecretary` / `denyUnlessGranted`). نبودِ مجوز → `403 ERR_FORBIDDEN_001`. نقشه:
@@ -148,6 +149,7 @@ Create a secretary for a doctor.
| `sms` | `SmsWalletController` (balance/charge/logs/settings). endpointهای admin (قالب/ارسال) همچنان `ROLE_ADMIN` | view/create/update |
| `appointment_settings` | `AppointmentSettingsController::denyDoctorAccess``SecretaryAccessChecker::canForDoctor` (اسکوپِ پزشکِ تخصیص‌یافته + توگل). `clinic_uuid` برای محیطِ کلینیک لازم است | view/update |
| `clinic_doctors` (فقط کلینیک) | `ClinicController::detachDoctor` (delete)، `ClinicDoctorPermissionController` (view/update)، `ClinicInvitationController` (create/view/update/delete) via `SecretaryAccessChecker::canForClinic` | view/create/update/delete |
| `subscription` | `SubscriptionController::my` (view) و `trial` (create)، `PaymentController::initiateSubscription` (create). resolveEntity از قبل منشی‌آگاه است | view/create |
نقش‌های غیرمنشی (`ROLE_CLINIC`/`ROLE_DOCTOR`/`ROLE_ADMIN`) از این چک عبور می‌کنند (`canOrNonSecretary` برایشان `true`). منشیِ بدون رابطهٔ فعال/context هیچ مجوزی ندارد → همه‌چیز `403`.
@@ -386,6 +386,7 @@ class PaymentController extends BaseController
#[Route('/api/v1/subscription-payment', methods: ['POST'])]
public function initiateSubscription(Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'subscription', 'create');
$data = json_decode($request->getContent(), true) ?? [];
$gatewayName = trim($data['gateway'] ?? 'mellat');
$frontendAddress = trim($data['frontend_address'] ?? '');
+1
View File
@@ -34,6 +34,7 @@ class DoctorSecretary
'sms' => ['view' => false, 'create' => false, 'update' => false, 'delete' => false],
'appointment_settings' => ['view' => false, 'update' => false],
'clinic_doctors' => ['view' => false, 'create' => false, 'update' => false, 'delete' => false],
'subscription' => ['view' => false, 'create' => false],
],
];
@@ -35,6 +35,7 @@ class SubscriptionController extends BaseController
private readonly ClinicRepository $clinicRepo,
private readonly UserActiveContextRepository $contextRepo,
private readonly EntityManagerInterface $em,
private readonly \App\Secretary\Security\SecretaryAccessChecker $secretaryAccess,
) {}
// ── Public ──────────────────────────────────────────────────────────────
@@ -56,6 +57,7 @@ class SubscriptionController extends BaseController
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function my(#[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'subscription', 'view');
[$entityType, $entityId] = $this->resolveEntity($user);
if ($entityId === null) {
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403);
@@ -76,6 +78,7 @@ class SubscriptionController extends BaseController
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function trial(#[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'subscription', 'create');
[$entityType, $entityId] = $this->resolveEntity($user);
if ($entityId === null) {
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403);
@@ -226,6 +226,25 @@ class SecretaryResourceEnforcementTest extends ApiTestCase
$this->assertSame(200, $this->responseCode());
}
public function testSubscriptionDeniedByDefault(): void
{
[$secretary] = $this->makeClinicSecretary();
$this->em->flush();
$this->authJson('GET', '/api/v1/subscription/my', $secretary);
$this->assertSame(403, $this->responseCode());
}
public function testSubscriptionAllowedWhenGranted(): void
{
[$secretary, $rel] = $this->makeClinicSecretary();
$rel->mergePermissions(['resources' => ['subscription' => ['view' => true]]]);
$this->em->flush();
$this->authJson('GET', '/api/v1/subscription/my', $secretary);
$this->assertSame(200, $this->responseCode());
}
public function testPatientDeleteSeparateFromUpdate(): void
{
// منشی با patients.update ولی بدون patients.delete نباید بتواند حذف کند.