diff --git a/apps/docs/package.json b/apps/docs/package.json index 67ef0a3e5..99c73e3eb 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -19,9 +19,9 @@ "test:watch": "vitest" }, "dependencies": { - "@docusaurus/core": "3.9.2", - "@docusaurus/plugin-content-docs": "^3.9.2", - "@docusaurus/preset-classic": "3.9.2", + "@docusaurus/core": "3.10.1", + "@docusaurus/plugin-content-docs": "^3.10.1", + "@docusaurus/preset-classic": "3.10.1", "@mdx-js/react": "^3.0.0", "clsx": "^2.0.0", "prism-react-renderer": "^2.3.0", @@ -31,9 +31,9 @@ "devDependencies": { "@cellix/config-typescript": "workspace:*", "@cellix/config-vitest": "workspace:*", - "@docusaurus/module-type-aliases": "3.9.2", - "@docusaurus/tsconfig": "3.9.2", - "@docusaurus/types": "3.9.2", + "@docusaurus/module-type-aliases": "3.10.1", + "@docusaurus/tsconfig": "3.10.1", + "@docusaurus/types": "3.10.1", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.2.0", "@types/react": "^19.1.11", diff --git a/apps/ui-staff/src/App.tsx b/apps/ui-staff/src/App.tsx index e26ad3e83..8e0b388c8 100644 --- a/apps/ui-staff/src/App.tsx +++ b/apps/ui-staff/src/App.tsx @@ -21,9 +21,18 @@ function StaffRoutes() { const auth = useContext(StaffAuthContext); const perms = auth?.permissions; const canManageCommunities = perms?.canManageCommunities === true; + const canManageStaffRolesAndPermissions = perms?.canManageStaffRolesAndPermissions === true; const canManageUsers = perms?.canManageUsers === true; + const canAssignStaffRoles = perms?.canAssignStaffRoles === true; + const canViewStaffUsers = perms?.canViewStaffUsers === true; const canManageFinance = perms?.canManageFinance === true; const canManageTechAdmin = perms?.canManageTechAdmin === true; + const canViewRoles = perms?.canViewRoles === true; + const canAddRole = perms?.canAddRole === true; + const canEditRole = perms?.canEditRole === true; + const canRemoveRole = perms?.canRemoveRole === true; + const canAccessUserManagement = + canManageUsers || canAssignStaffRoles || canViewStaffUsers || canManageStaffRolesAndPermissions || canViewRoles || canAddRole || canEditRole || canRemoveRole || canManageTechAdmin; let defaultStaffRoute = '/unauthorized'; if (canManageTechAdmin) { @@ -32,7 +41,7 @@ function StaffRoutes() { defaultStaffRoute = '/staff/finance'; } else if (canManageCommunities) { defaultStaffRoute = '/staff/community-management'; - } else if (canManageUsers) { + } else if (canAccessUserManagement) { defaultStaffRoute = '/staff/user-management'; } @@ -53,7 +62,7 @@ function StaffRoutes() { element={} /> )} - {canManageUsers && ( + {(canAccessUserManagement || canManageFinance) && ( } @@ -134,7 +143,7 @@ export default function App() { } function StaffSection({ identity }: { identity: Parameters[0]['value'] }) { - const { permissions, user, loading } = useStaffPermissions(); + const { permissions, enterpriseAppRole, user, loading } = useStaffPermissions(); if (loading) { return ( @@ -145,7 +154,7 @@ function StaffSection({ identity }: { identity: Parameters + ); diff --git a/apps/ui-staff/src/components/ui/molecules/auth-landing/index.tsx b/apps/ui-staff/src/components/ui/molecules/auth-landing/index.tsx index 41fc0aa53..65bbd20fd 100644 --- a/apps/ui-staff/src/components/ui/molecules/auth-landing/index.tsx +++ b/apps/ui-staff/src/components/ui/molecules/auth-landing/index.tsx @@ -29,7 +29,16 @@ export const AuthLanding: React.FC = () => { targetRoute = '/staff/finance'; } else if (permissions?.canManageCommunities) { targetRoute = '/staff/community-management'; - } else if (permissions?.canManageUsers) { + } else if ( + permissions?.canManageUsers || + permissions?.canAssignStaffRoles || + permissions?.canViewStaffUsers || + permissions?.canManageStaffRolesAndPermissions || + permissions?.canViewRoles || + permissions?.canAddRole || + permissions?.canEditRole || + permissions?.canRemoveRole + ) { targetRoute = '/staff/user-management'; } diff --git a/apps/ui-staff/src/contexts/theme-context.tsx b/apps/ui-staff/src/contexts/theme-context.tsx index b23f6c46e..0e511a0f4 100644 --- a/apps/ui-staff/src/contexts/theme-context.tsx +++ b/apps/ui-staff/src/contexts/theme-context.tsx @@ -1,4 +1,4 @@ -import { type StoredTheme } from '@cellix/ui-core'; +import type { StoredTheme } from '@cellix/ui-core'; import { Button, theme } from 'antd'; import type { SeedToken } from 'antd/lib/theme/interface/index.js'; import { createContext, type ReactNode, useCallback, useEffect, useState } from 'react'; diff --git a/apps/ui-staff/src/hooks/use-staff-permissions.ts b/apps/ui-staff/src/hooks/use-staff-permissions.ts index 0c5f4e3d6..18fc30676 100644 --- a/apps/ui-staff/src/hooks/use-staff-permissions.ts +++ b/apps/ui-staff/src/hooks/use-staff-permissions.ts @@ -12,12 +12,23 @@ const CURRENT_STAFF_USER_QUERY = gql` role { id roleName + enterpriseAppRole permissions { communityPermissions { canManageCommunities + canManageStaffRolesAndPermissions } userPermissions { canManageUsers + canAssignStaffRoles + canAssignStaffUserRoles + canViewStaffUsers + } + staffRolePermissions { + canViewRoles + canAddRole + canEditRole + canRemoveRole } financePermissions { canManageFinance @@ -33,9 +44,16 @@ const CURRENT_STAFF_USER_QUERY = gql` interface StaffPermissions { canManageCommunities: boolean; + canManageStaffRolesAndPermissions: boolean; canManageUsers: boolean; + canAssignStaffRoles: boolean; + canViewStaffUsers: boolean; canManageFinance: boolean; canManageTechAdmin: boolean; + canViewRoles: boolean; + canAddRole: boolean; + canEditRole: boolean; + canRemoveRole: boolean; } interface StaffUserQueryResult { @@ -49,9 +67,11 @@ interface StaffUserQueryResult { role?: { id: string; roleName: string; + enterpriseAppRole: string; permissions: { - communityPermissions: { canManageCommunities: boolean }; - userPermissions: { canManageUsers: boolean }; + communityPermissions: { canManageCommunities: boolean; canManageStaffRolesAndPermissions: boolean }; + userPermissions: { canManageUsers: boolean; canAssignStaffRoles: boolean; canAssignStaffUserRoles: boolean; canViewStaffUsers: boolean }; + staffRolePermissions: { canViewRoles: boolean; canAddRole: boolean; canEditRole: boolean; canRemoveRole: boolean }; financePermissions: { canManageFinance: boolean }; techAdminPermissions: { canManageTechAdmin: boolean }; }; @@ -59,12 +79,7 @@ interface StaffUserQueryResult { }; } -export const useStaffPermissions = (): { - permissions: StaffPermissions | undefined; - user: { id?: string; displayName?: string; firstName?: string; lastName?: string; email?: string } | undefined; - loading: boolean; - error: Error | undefined; -} => { +export const useStaffPermissions = (): { permissions: StaffPermissions | undefined; enterpriseAppRole: string | undefined; user: { id?: string; displayName?: string; firstName?: string; lastName?: string; email?: string } | undefined; loading: boolean; error: Error | undefined } => { const { data, loading, error } = useQuery(CURRENT_STAFF_USER_QUERY, { fetchPolicy: 'cache-first', }); @@ -78,14 +93,22 @@ export const useStaffPermissions = (): { const permissions: StaffPermissions | undefined = rolePermissions ? { canManageCommunities: rolePermissions.communityPermissions.canManageCommunities || isTechAdmin, + canManageStaffRolesAndPermissions: rolePermissions.communityPermissions.canManageStaffRolesAndPermissions || isTechAdmin, canManageUsers: rolePermissions.userPermissions.canManageUsers || isTechAdmin, + canAssignStaffRoles: rolePermissions.userPermissions.canAssignStaffRoles || rolePermissions.userPermissions.canAssignStaffUserRoles || isTechAdmin, + canViewStaffUsers: rolePermissions.userPermissions.canViewStaffUsers || rolePermissions.userPermissions.canManageUsers || isTechAdmin, canManageFinance: rolePermissions.financePermissions.canManageFinance || isTechAdmin, canManageTechAdmin: isTechAdmin, + canViewRoles: rolePermissions.staffRolePermissions.canViewRoles || rolePermissions.communityPermissions.canManageStaffRolesAndPermissions || isTechAdmin, + canAddRole: rolePermissions.staffRolePermissions.canAddRole || rolePermissions.communityPermissions.canManageStaffRolesAndPermissions || isTechAdmin, + canEditRole: rolePermissions.staffRolePermissions.canEditRole || rolePermissions.communityPermissions.canManageStaffRolesAndPermissions || isTechAdmin, + canRemoveRole: rolePermissions.staffRolePermissions.canRemoveRole || rolePermissions.communityPermissions.canManageStaffRolesAndPermissions || isTechAdmin, } : undefined; return { permissions, + enterpriseAppRole: data?.currentStaffUserAndCreateIfNotExists?.role?.enterpriseAppRole, user: currentUser ? { id: currentUser.id, diff --git a/codegen.yml b/codegen.yml index 95e9f2dc3..09958a8fe 100644 --- a/codegen.yml +++ b/codegen.yml @@ -155,6 +155,20 @@ generates: - typescript-operations - typed-document-node + './packages/ocom/ui-staff-route-user-management/src/generated.tsx': + documents: + - './packages/ocom/ui-staff-route-user-management/src/**/**.graphql' + config: + withHooks: true + withHOC: false + withComponent: false + useTypeImports: true + enumsAsTypes: true + plugins: + - typescript + - typescript-operations + - typed-document-node + # Cellix core base type defs (static array for rolldown bundling) './packages/cellix/graphql-core/src/schema/base-type-defs.generated.ts': plugins: diff --git a/packages/cellix/ui-core/package.json b/packages/cellix/ui-core/package.json index 1f7df129b..77a1a45dd 100644 --- a/packages/cellix/ui-core/package.json +++ b/packages/cellix/ui-core/package.json @@ -43,13 +43,13 @@ "@storybook/addon-vitest": "^9.1.3", "@storybook/react": "^9.1.9", "@storybook/react-vite": "^9.1.3", + "@testing-library/react": "^16.3.0", "@types/react": "^19.1.16", "@types/react-dom": "^19.1.6", "@vitest/browser": "catalog:", "@vitest/browser-playwright": "catalog:", "@vitest/coverage-istanbul": "catalog:", "jsdom": "catalog:", - "@testing-library/react": "^16.3.0", "react-oidc-context": "^3.3.0", "react-router-dom": "catalog:", "rimraf": "catalog:", diff --git a/packages/ocom-verification/acceptance-api/src/contexts/staff/step-definitions/staff-landing.steps.ts b/packages/ocom-verification/acceptance-api/src/contexts/staff/step-definitions/staff-landing.steps.ts index c05505539..89476a587 100644 --- a/packages/ocom-verification/acceptance-api/src/contexts/staff/step-definitions/staff-landing.steps.ts +++ b/packages/ocom-verification/acceptance-api/src/contexts/staff/step-definitions/staff-landing.steps.ts @@ -1,6 +1,7 @@ import { Given, Then, When } from '@cucumber/cucumber'; import { actors } from '@ocom-verification/verification-shared/test-data'; import { actorCalled, notes } from '@serenity-js/core'; +import { resolveActorName } from '../../../shared/support/domain-test-helpers.ts'; type StaffBusinessRole = 'finance' | 'tech admin' | 'service line owner' | 'case manager'; @@ -33,10 +34,19 @@ const roleForActor = (actorName: string): StaffBusinessRole => actorRoles.get(ac const resolveFinanceWorkspaceRoute = (role: StaffBusinessRole): string => (role === 'finance' || role === 'tech admin' ? '/staff/finance' : '/unauthorized'); +Given('{word} is an authenticated staff user', async (actorName: string) => { + lastActorName = actorName; + const actor = actorCalled(actorName); + actorRoles.set(actorName, 'case manager'); + await actor.attemptsTo(notes().set('targetRoute', '')); +}); + Given('{word} is an authenticated {string} staff user', async (actorName: string, roleName: string) => { lastActorName = actorName; - actorRoles.set(actorName, normalizeRole(roleName)); - await actorCalled(actorName).attemptsTo(notes().set('targetRoute', '')); + const role = normalizeRole(roleName); + const actor = actorCalled(actorName); + actorRoles.set(actorName, role); + await actor.attemptsTo(notes().set('targetRoute', '')); }); When('{word} enters the staff operations workspace', async (actorName: string) => { @@ -52,11 +62,10 @@ When('{word} attempts to work in the finance workspace', async (actorName: strin }); Then('{word} should be directed to {string}', async (actorName: string, expectedRoute: string) => { - const resolvedName = /^(she|he|they)$/i.test(actorName) ? lastActorName : actorName; - const actor = actorCalled(resolvedName); - const targetRoute = await actor.answer(notes().get('targetRoute')); + const actor = actorCalled(resolveActorName(actorName, lastActorName)); + const actualRoute = await actor.answer(notes().get('targetRoute')); - if (targetRoute !== expectedRoute) { - throw new Error(`Expected route to be "${expectedRoute}", but got "${targetRoute}"`); + if (actualRoute !== expectedRoute) { + throw new Error(`Expected route "${expectedRoute}", but got "${actualRoute}"`); } }); diff --git a/packages/ocom-verification/acceptance-api/src/step-definitions/index.ts b/packages/ocom-verification/acceptance-api/src/step-definitions/index.ts index 55194db74..cba25d6cc 100644 --- a/packages/ocom-verification/acceptance-api/src/step-definitions/index.ts +++ b/packages/ocom-verification/acceptance-api/src/step-definitions/index.ts @@ -5,4 +5,4 @@ import '../contexts/community/step-definitions/index.ts'; import '../contexts/authentication/step-definitions/index.ts'; -import '../contexts/staff/step-definitions/index.ts'; \ No newline at end of file +import '../contexts/staff/step-definitions/index.ts'; diff --git a/packages/ocom-verification/e2e-tests/src/shared/support/servers/test-staff-vite-server.ts b/packages/ocom-verification/e2e-tests/src/shared/support/servers/test-staff-vite-server.ts index 6f0b3e005..1ee0955ab 100644 --- a/packages/ocom-verification/e2e-tests/src/shared/support/servers/test-staff-vite-server.ts +++ b/packages/ocom-verification/e2e-tests/src/shared/support/servers/test-staff-vite-server.ts @@ -15,7 +15,6 @@ export class TestStaffViteServer extends PortlessServer { protected get serverName() { return 'TestStaffViteServer'; } - protected get spawnArgs() { return ['run', 'dev']; } diff --git a/packages/ocom-verification/verification-shared/src/settings/local-settings.ts b/packages/ocom-verification/verification-shared/src/settings/local-settings.ts index d09bacaf3..cfe0c330f 100644 --- a/packages/ocom-verification/verification-shared/src/settings/local-settings.ts +++ b/packages/ocom-verification/verification-shared/src/settings/local-settings.ts @@ -5,55 +5,55 @@ const workspaceRoot = findWorkspaceRoot(); const apiSettingsPath = resolveWorkspacePath(workspaceRoot, 'apps/api/local.settings.json'); const uiCommunityEnvPath = resolveWorkspacePath(workspaceRoot, 'apps/ui-community/.env'); const uiStaffEnvPath = resolveWorkspacePath(workspaceRoot, 'apps/ui-staff/.env'); - + const apiValues = readJsonSettings(apiSettingsPath); const uiCommunityValues = readDotEnv(uiCommunityEnvPath); const uiStaffValues = readDotEnv(uiStaffEnvPath); - + /** - * Defaults for E2E/acceptance test settings when local.settings.json is absent - * (e.g. CI pipelines). All values are non-secret mock/localhost references used - * exclusively by the test harness — no real credentials are involved. - */ +* Defaults for E2E/acceptance test settings when local.settings.json is absent +* (e.g. CI pipelines). All values are non-secret mock/localhost references used +* exclusively by the test harness — no real credentials are involved. +*/ const ciDefaults = { - COSMOSDB_CONNECTION_STRING: '', - COSMOSDB_DBNAME: 'owner-community', - COSMOSDB_PORT: '50000', - NODE_ENV: 'development', - ACCOUNT_PORTAL_OIDC_AUDIENCE: 'mock-client', - ACCOUNT_PORTAL_OIDC_ISSUER: 'https://mock-auth.ownercommunity.localhost:1355/community', - ACCOUNT_PORTAL_OIDC_ENDPOINT: 'https://mock-auth.ownercommunity.localhost:1355/community/.well-known/jwks.json', + COSMOSDB_CONNECTION_STRING: '', + COSMOSDB_DBNAME: 'owner-community', + COSMOSDB_PORT: '50000', + NODE_ENV: 'development', + ACCOUNT_PORTAL_OIDC_AUDIENCE: 'mock-client', + ACCOUNT_PORTAL_OIDC_ISSUER: 'https://mock-auth.ownercommunity.localhost:1355/community', + ACCOUNT_PORTAL_OIDC_ENDPOINT: 'https://mock-auth.ownercommunity.localhost:1355/community/.well-known/jwks.json', } as const; - + function setting(key: keyof typeof ciDefaults): string { - return readSetting(apiValues, key, ciDefaults[key]) ?? ciDefaults[key]; + return readSetting(apiValues, key, ciDefaults[key]) ?? ciDefaults[key]; } - + export const apiSettings = { - nodeEnv: setting('NODE_ENV'), - isDevelopment: setting('NODE_ENV') === 'development', - - cosmosDbConnectionString: setting('COSMOSDB_CONNECTION_STRING'), - cosmosDbName: setting('COSMOSDB_DBNAME'), - cosmosDbPort: Number(setting('COSMOSDB_PORT')), - - accountPortalOidcIssuer: setting('ACCOUNT_PORTAL_OIDC_ISSUER'), - accountPortalOidcEndpoint: setting('ACCOUNT_PORTAL_OIDC_ENDPOINT'), - accountPortalOidcAudience: setting('ACCOUNT_PORTAL_OIDC_AUDIENCE'), - - apiDir: path.dirname(apiSettingsPath), - oauth2MockDir: path.join(workspaceRoot, 'apps', 'server-oauth2-mock'), - uiCommunityDir: path.dirname(uiCommunityEnvPath), - uiStaffDir: path.dirname(uiStaffEnvPath), + nodeEnv: setting('NODE_ENV'), + isDevelopment: setting('NODE_ENV') === 'development', + + cosmosDbConnectionString: setting('COSMOSDB_CONNECTION_STRING'), + cosmosDbName: setting('COSMOSDB_DBNAME'), + cosmosDbPort: Number(setting('COSMOSDB_PORT')), + + accountPortalOidcIssuer: setting('ACCOUNT_PORTAL_OIDC_ISSUER'), + accountPortalOidcEndpoint: setting('ACCOUNT_PORTAL_OIDC_ENDPOINT'), + accountPortalOidcAudience: setting('ACCOUNT_PORTAL_OIDC_AUDIENCE'), + + apiDir: path.dirname(apiSettingsPath), + oauth2MockDir: path.join(workspaceRoot, 'apps', 'server-oauth2-mock'), + uiCommunityDir: path.dirname(uiCommunityEnvPath), + uiStaffDir: path.dirname(uiStaffEnvPath), } as const; export const uiCommunitySettings = { - baseUrl: requireSetting(uiCommunityValues, 'VITE_APP_UI_COMMUNITY_BASE_URL', 'VITE_APP_UI_COMMUNITY_BASE_URL is required in apps/ui-community/.env'), - - graphqlEndpoint: requireSetting(uiCommunityValues, 'VITE_COMMON_API_ENDPOINT', 'VITE_COMMON_API_ENDPOINT is required in apps/ui-community/.env'), + baseUrl: requireSetting(uiCommunityValues, 'VITE_APP_UI_COMMUNITY_BASE_URL', 'VITE_APP_UI_COMMUNITY_BASE_URL is required in apps/ui-community/.env'), + + graphqlEndpoint: requireSetting(uiCommunityValues, 'VITE_COMMON_API_ENDPOINT', 'VITE_COMMON_API_ENDPOINT is required in apps/ui-community/.env'), } as const; - + export const uiStaffSettings = { - baseUrl: readSetting(uiStaffValues, 'VITE_APP_UI_STAFF_BASE_URL', 'https://staff.ownercommunity.localhost:1355') ?? 'https://staff.ownercommunity.localhost:1355', - graphqlEndpoint: requireSetting(uiStaffValues, 'VITE_COMMON_API_ENDPOINT', 'VITE_COMMON_API_ENDPOINT is required in apps/ui-staff/.env'), -} as const; + baseUrl: readSetting(uiStaffValues, 'VITE_BASE_URL', 'https://staff.ownercommunity.localhost:1355') ?? 'https://staff.ownercommunity.localhost:1355', + graphqlEndpoint: requireSetting(uiStaffValues, 'VITE_COMMON_API_ENDPOINT', 'VITE_COMMON_API_ENDPOINT is required in apps/ui-staff/.env'), +} as const; \ No newline at end of file diff --git a/packages/ocom-verification/verification-shared/src/settings/timeout-settings.ts b/packages/ocom-verification/verification-shared/src/settings/timeout-settings.ts index 89e55ebaa..d7de5458d 100644 --- a/packages/ocom-verification/verification-shared/src/settings/timeout-settings.ts +++ b/packages/ocom-verification/verification-shared/src/settings/timeout-settings.ts @@ -23,7 +23,7 @@ export const timeouts = { healthProbeInterval: 500, /** UI initialization timeout (30 seconds) */ - uiInit: 30_000, + uiInit: 60_000, /** UI cleanup timeout (10 seconds) */ uiCleanup: 10_000, diff --git a/packages/ocom/application-services/src/contexts/user/staff-role/create.test.ts b/packages/ocom/application-services/src/contexts/user/staff-role/create.test.ts index b1d016f7b..9b2ecef77 100644 --- a/packages/ocom/application-services/src/contexts/user/staff-role/create.test.ts +++ b/packages/ocom/application-services/src/contexts/user/staff-role/create.test.ts @@ -4,188 +4,636 @@ import { describeFeature, loadFeature } from '@amiceli/vitest-cucumber'; import type { Domain } from '@ocom/domain'; import type { DataSources } from '@ocom/persistence'; import { expect, vi } from 'vitest'; -import { create, type StaffRoleCreateCommandPermissions } from './create.ts'; +import { create, type StaffRoleCreateCommand, type StaffRoleCreateCommandPermissions } from './create.ts'; const test = { for: describeFeature }; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const feature = await loadFeature(path.resolve(__dirname, 'features/create.feature')); -function makeMockStaffRole(overrides: Partial = {}) { +// ─── Helpers ───────────────────────────────────────────────────────────────── + +type MockPermissions = { + communityPermissions: Record; + userPermissions: Record; + staffRolePermissions?: Record; + financePermissions?: Record; + techAdminPermissions?: Record; + propertyPermissions?: Record; + servicePermissions?: Record; + serviceTicketPermissions?: Record; + violationTicketPermissions?: Record; +}; + +interface MockStaffRoleInstance { + id: string; + roleName: string; + enterpriseAppRole: string; + isDefault: boolean; + roleType: null; + permissions: MockPermissions; + createdAt: Date; + updatedAt: Date; + schemaVersion: string; +} + +function makeMockStaffRoleInstance(roleName: string): MockStaffRoleInstance { + const communityPermissions: Record = { + canManageCommunities: false, + canManageStaffRolesAndPermissions: false, + canManageAllCommunities: false, + canDeleteCommunities: false, + canChangeCommunityOwner: false, + canReIndexSearchCollections: false, + }; + const userPermissions: Record = { + canManageUsers: false, + canAssignStaffUserRoles: false, + canAssignStaffRoles: false, + canViewStaffUsers: false, + }; + const staffRolePermissions: Record = { + canViewRoles: false, + canAddRole: false, + canEditRole: false, + canRemoveRole: false, + }; + const financePermissions: Record = { + canManageFinance: false, + canViewGLBatchSummaries: false, + canViewFinanceConfigs: false, + canCreateFinanceConfigs: false, + }; + const techAdminPermissions: Record = { + canManageTechAdmin: false, + canViewDatabaseExplorer: false, + canViewBlobExplorer: false, + canViewQueueDashboard: false, + canSendQueueMessages: false, + }; + const propertyPermissions: Record = { + canManageProperties: false, + canEditOwnProperty: false, + }; + const servicePermissions: Record = { + canManageServices: false, + }; + const serviceTicketPermissions: Record = { + canCreateTickets: false, + canManageTickets: false, + canAssignTickets: false, + canWorkOnTickets: false, + }; + const violationTicketPermissions: Record = { + canCreateTickets: false, + canManageTickets: false, + canAssignTickets: false, + canWorkOnTickets: false, + }; + return { - id: '507f1f77bcf86cd799439011', - roleName: 'Test Role', + id: `id-${roleName}`, + roleName, + enterpriseAppRole: '', isDefault: false, + roleType: null, permissions: { - communityPermissions: { - canManageStaffRolesAndPermissions: false, - canManageAllCommunities: false, - canDeleteCommunities: false, - canChangeCommunityOwner: false, - canReIndexSearchCollections: false, - }, + communityPermissions, + userPermissions, + staffRolePermissions, + financePermissions, + techAdminPermissions, + propertyPermissions, + servicePermissions, + serviceTicketPermissions, + violationTicketPermissions, }, - roleType: null, createdAt: new Date(), updatedAt: new Date(), schemaVersion: '1.0', - ...overrides, - } as Domain.Contexts.User.StaffRole.StaffRoleEntityReference; + } as unknown as MockStaffRoleInstance; } -function makeMockRepo(overrides: Partial> = {}) { - return { - getByRoleName: vi.fn(), - getNewInstance: vi.fn(), - save: vi.fn(), - ...overrides, - } as unknown as Domain.Contexts.User.StaffRole.StaffRoleRepository; -} +function makeDataSources(overrides: { + existingRole?: Domain.Contexts.User.StaffRole.StaffRoleEntityReference | null; + unexpectedError?: Error; + newRoleInstance?: MockStaffRoleInstance; + explicitUndefinedSave?: boolean; +}): DataSources & { _repo: unknown } { + const { existingRole, unexpectedError, newRoleInstance, explicitUndefinedSave } = overrides; + const instance = newRoleInstance ?? makeMockStaffRoleInstance('Test Role'); + const savedRole = explicitUndefinedSave ? undefined : (instance as unknown as Domain.Contexts.User.StaffRole.StaffRoleEntityReference); -test.for(feature, ({ Scenario, BeforeEachScenario }) => { - let dataSources: DataSources; - let createStaffRole: (command: { roleName: string; isDefault?: boolean; permissions?: StaffRoleCreateCommandPermissions }) => Promise; + const repo = { + getByRoleName: unexpectedError ? vi.fn().mockRejectedValue(unexpectedError) : existingRole ? vi.fn().mockResolvedValue(existingRole) : vi.fn().mockRejectedValue(new Error('not found')), + getNewInstance: vi.fn().mockResolvedValue(instance), + save: vi.fn().mockResolvedValue(savedRole), + } as unknown as Domain.Contexts.User.StaffRole.StaffRoleRepository; - BeforeEachScenario(() => { - dataSources = { - domainDataSource: { - User: { - StaffRole: { - StaffRoleUnitOfWork: { - withScopedTransaction: vi.fn(), - }, + return { + domainDataSource: { + User: { + StaffRole: { + StaffRoleUnitOfWork: { + withScopedTransaction: vi.fn().mockImplementation(async (cb: (r: typeof repo) => Promise) => { + await cb(repo); + }), }, }, }, - } as unknown as DataSources; + }, + _repo: repo, + } as unknown as DataSources & { _repo: unknown }; +} - createStaffRole = create(dataSources); +// ─── Tests ──────────────────────────────────────────────────────────────────── + +test.for(feature, ({ Scenario, BeforeEachScenario }) => { + let dataSources: DataSources & { _repo?: unknown }; + let command: StaffRoleCreateCommand; + let result: Domain.Contexts.User.StaffRole.StaffRoleEntityReference | undefined; + let thrownError: unknown; + let roleInstance: MockStaffRoleInstance; + + BeforeEachScenario(() => { + result = undefined; + thrownError = undefined; + roleInstance = makeMockStaffRoleInstance('Test Role'); + command = { roleName: 'Test Role' }; }); - Scenario('Creating a staff role successfully', ({ Given, When, Then }) => { - let result: Domain.Contexts.User.StaffRole.StaffRoleEntityReference; + // ─── Create with no permissions ─────────────────────────────────────────── - Given('a staff role with name "Test Role" does not exist', () => { - // Mock will be set up in When step + Scenario('Successfully creates a staff role with no permissions', ({ Given, When, Then, And }) => { + Given('a staff role with name "Test Role" does not exist in the repository', () => { + roleInstance = makeMockStaffRoleInstance('Test Role'); + dataSources = makeDataSources({ newRoleInstance: roleInstance }); + command = { roleName: 'Test Role' }; }); - When('I create a staff role with name "Test Role", isDefault false, and no permissions', async () => { - const mockRepo = makeMockRepo({ - getByRoleName: vi.fn().mockRejectedValue(new Error('Not found')), - getNewInstance: vi.fn().mockResolvedValue(makeMockStaffRole({ roleName: 'Test Role', isDefault: false })), - save: vi.fn().mockResolvedValue(makeMockStaffRole({ roleName: 'Test Role', isDefault: false })), - }); - - vi.mocked(dataSources.domainDataSource.User.StaffRole.StaffRoleUnitOfWork.withScopedTransaction).mockImplementation(async (callback) => { - await callback(mockRepo); - }); + When('I call create with roleName "Test Role" and no permissions', async () => { + try { + result = await create(dataSources)(command); + } catch (e) { + thrownError = e; + } + }); - result = await createStaffRole({ roleName: 'Test Role', isDefault: false }); + Then('the new staff role should be saved', () => { + const repo = dataSources._repo as { save: ReturnType }; + expect(repo.save).toHaveBeenCalled(); }); - Then('it should return a staff role entity reference with name "Test Role" and isDefault false', () => { + And('the result should have roleName "Test Role"', () => { + expect(thrownError).toBeUndefined(); expect(result).toBeDefined(); - expect(result.roleName).toBe('Test Role'); - expect(result.isDefault).toBe(false); + expect(result?.roleName).toBe('Test Role'); }); }); - Scenario('Creating a staff role with permissions', ({ Given, When, Then }) => { - let result: Domain.Contexts.User.StaffRole.StaffRoleEntityReference; + // ─── Create with enterpriseAppRole ──────────────────────────────────────── - Given('a staff role with name "Admin Role" does not exist', () => { - // Mock will be set up in When step + Scenario('Successfully creates a staff role with an enterpriseAppRole', ({ Given, When, Then }) => { + Given('a staff role with name "Test Role" does not exist in the repository', () => { + roleInstance = makeMockStaffRoleInstance('Test Role'); + dataSources = makeDataSources({ newRoleInstance: roleInstance }); + command = { roleName: 'Test Role', enterpriseAppRole: 'Staff.TestRole' }; }); - When('I create a staff role with name "Admin Role", isDefault true, and permissions', async () => { - const mockRepo = makeMockRepo({ - getByRoleName: vi.fn().mockRejectedValue(new Error('Not found')), - getNewInstance: vi.fn().mockResolvedValue(makeMockStaffRole({ roleName: 'Admin Role', isDefault: true })), - save: vi.fn().mockResolvedValue(makeMockStaffRole({ roleName: 'Admin Role', isDefault: true })), - }); + When('I call create with roleName "Test Role" and enterpriseAppRole "Staff.TestRole"', async () => { + try { + result = await create(dataSources)(command); + } catch (e) { + thrownError = e; + } + }); - vi.mocked(dataSources.domainDataSource.User.StaffRole.StaffRoleUnitOfWork.withScopedTransaction).mockImplementation(async (callback) => { - await callback(mockRepo); - }); + Then('the new staff role should be saved with enterpriseAppRole "Staff.TestRole"', () => { + expect(thrownError).toBeUndefined(); + expect(roleInstance.enterpriseAppRole).toBe('Staff.TestRole'); + }); + }); + + // ─── Create with community permissions ─────────────────────────────────── - result = await createStaffRole({ + Scenario('Successfully creates a staff role with community permissions', ({ Given, When, Then, And }) => { + Given('a staff role with name "Admin Role" does not exist in the repository', () => { + roleInstance = makeMockStaffRoleInstance('Admin Role'); + dataSources = makeDataSources({ newRoleInstance: roleInstance }); + command = { roleName: 'Admin Role', - isDefault: true, + permissions: { community: { canManageCommunities: true } } satisfies StaffRoleCreateCommandPermissions, + }; + }); + + When('I call create with roleName "Admin Role" and community permissions canManageCommunities true', async () => { + try { + result = await create(dataSources)(command); + } catch (e) { + thrownError = e; + } + }); + + Then('the new staff role should be saved', () => { + const repo = dataSources._repo as { save: ReturnType }; + expect(repo.save).toHaveBeenCalled(); + }); + + And('the community permission canManageCommunities should be true', () => { + expect(thrownError).toBeUndefined(); + expect(roleInstance.permissions.communityPermissions.canManageCommunities).toBe(true); + }); + }); + + // ─── Create with user permissions ──────────────────────────────────────── + + Scenario('Successfully creates a staff role with user permissions', ({ Given, When, Then, And }) => { + Given('a staff role with name "Manager Role" does not exist in the repository', () => { + roleInstance = makeMockStaffRoleInstance('Manager Role'); + dataSources = makeDataSources({ newRoleInstance: roleInstance }); + command = { + roleName: 'Manager Role', + permissions: { user: { canManageUsers: true } } satisfies StaffRoleCreateCommandPermissions, + }; + }); + + When('I call create with roleName "Manager Role" and user permissions canManageUsers true', async () => { + try { + result = await create(dataSources)(command); + } catch (e) { + thrownError = e; + } + }); + + Then('the new staff role should be saved', () => { + const repo = dataSources._repo as { save: ReturnType }; + expect(repo.save).toHaveBeenCalled(); + }); + + And('the user permission canManageUsers should be true', () => { + expect(thrownError).toBeUndefined(); + expect(roleInstance.permissions.userPermissions.canManageUsers).toBe(true); + }); + }); + + // ─── Duplicate name ─────────────────────────────────────────────────────── + + Scenario('Throws when a staff role with the same name already exists', ({ Given, When, Then }) => { + Given('a staff role with name "Duplicate Role" already exists in the repository', () => { + const existing = makeMockStaffRoleInstance('Duplicate Role') as unknown as Domain.Contexts.User.StaffRole.StaffRoleEntityReference; + dataSources = makeDataSources({ existingRole: existing }); + command = { roleName: 'Duplicate Role' }; + }); + + When('I call create with roleName "Duplicate Role"', async () => { + try { + result = await create(dataSources)(command); + } catch (e) { + thrownError = e; + } + }); + + Then('it should throw an error with message containing "Duplicate Role"', () => { + expect(thrownError).toBeDefined(); + expect((thrownError as Error).message).toContain('Duplicate Role'); + }); + }); + + // ─── Unexpected repository error propagation ────────────────────────────── + + Scenario('Propagates unexpected repository errors from getByRoleName', ({ Given, When, Then }) => { + Given('the repository throws an unexpected error when checking for "Error Role"', () => { + const unexpectedError = new Error('Database connection lost'); + dataSources = makeDataSources({ unexpectedError }); + command = { roleName: 'Error Role' }; + }); + + When('I call create with roleName "Error Role"', async () => { + try { + result = await create(dataSources)(command); + } catch (e) { + thrownError = e; + } + }); + + Then('it should throw the unexpected error', () => { + expect(thrownError).toBeDefined(); + expect((thrownError as Error).message).toBe('Database connection lost'); + }); + }); + + // ─── Save fails ─────────────────────────────────────────────────────────── + + Scenario('Throws when repository fails to save the new role', ({ Given, When, Then, And }) => { + Given('a staff role with name "Test Role" does not exist in the repository', () => { + roleInstance = makeMockStaffRoleInstance('Test Role'); + }); + + And('saving the staff role returns undefined', () => { + dataSources = makeDataSources({ newRoleInstance: roleInstance, explicitUndefinedSave: true }); + command = { roleName: 'Test Role' }; + }); + + When('I call create with roleName "Test Role" and no permissions', async () => { + try { + result = await create(dataSources)(command); + } catch (e) { + thrownError = e; + } + }); + + Then('it should throw an error with message "Unable to create staff role"', () => { + expect(thrownError).toBeDefined(); + expect((thrownError as Error).message).toBe('Unable to create staff role'); + }); + }); + + // ─── enterpriseAppRole default ──────────────────────────────────────────── + + Scenario('enterpriseAppRole is not set when not provided in the command', ({ Given, When, Then }) => { + Given('a staff role with name "Test Role" does not exist in the repository', () => { + roleInstance = makeMockStaffRoleInstance('Test Role'); + dataSources = makeDataSources({ newRoleInstance: roleInstance }); + command = { roleName: 'Test Role' }; + }); + When('I call create with roleName "Test Role" and no permissions', async () => { + try { + result = await create(dataSources)(command); + } catch (e) { + thrownError = e; + } + }); + Then('the enterpriseAppRole on the saved instance should remain empty', () => { + expect(thrownError).toBeUndefined(); + expect(roleInstance.enterpriseAppRole).toBe(''); + }); + }); + + // ─── NotFoundError by name ──────────────────────────────────────────────── + + Scenario('Not-found detected via error name NotFoundError allows creation to proceed', ({ Given, When, Then }) => { + Given('the repository raises a NotFoundError by name when checking for "New Role"', () => { + roleInstance = makeMockStaffRoleInstance('New Role'); + const notFoundByName = Object.assign(new Error('some message'), { name: 'NotFoundError' }); + const repo = { + getByRoleName: vi.fn().mockRejectedValue(notFoundByName), + getNewInstance: vi.fn().mockResolvedValue(roleInstance), + save: vi.fn().mockResolvedValue(roleInstance as unknown as Domain.Contexts.User.StaffRole.StaffRoleEntityReference), + }; + dataSources = { + domainDataSource: { + User: { + StaffRole: { + StaffRoleUnitOfWork: { + withScopedTransaction: vi.fn().mockImplementation(async (cb: (r: typeof repo) => Promise) => { + await cb(repo); + }), + }, + }, + }, + }, + _repo: repo, + } as unknown as DataSources & { _repo: unknown }; + command = { roleName: 'New Role' }; + }); + When('I call create with roleName "New Role" and no permissions', async () => { + try { + result = await create(dataSources)(command); + } catch (e) { + thrownError = e; + } + }); + Then('the new staff role should be saved', () => { + expect(thrownError).toBeUndefined(); + expect(result).toBeDefined(); + }); + }); + + // ─── All community permissions ──────────────────────────────────────────── + + Scenario('Successfully creates a staff role with all community permissions set', ({ Given, When, Then }) => { + Given('a staff role with name "Full Community Role" does not exist in the repository', () => { + roleInstance = makeMockStaffRoleInstance('Full Community Role'); + dataSources = makeDataSources({ newRoleInstance: roleInstance }); + command = { + roleName: 'Full Community Role', permissions: { community: { + canManageCommunities: true, canManageStaffRolesAndPermissions: true, canManageAllCommunities: true, - canDeleteCommunities: false, - canChangeCommunityOwner: false, + canDeleteCommunities: true, + canChangeCommunityOwner: true, canReIndexSearchCollections: true, }, }, - }); + }; }); - - Then('it should return a staff role entity reference with name "Admin Role" and isDefault true', () => { - expect(result).toBeDefined(); - expect(result.roleName).toBe('Admin Role'); - expect(result.isDefault).toBe(true); + When('I call create with roleName "Full Community Role" and all community permissions true', async () => { + try { + result = await create(dataSources)(command); + } catch (e) { + thrownError = e; + } + }); + Then('all community permissions should be true on the saved instance', () => { + expect(thrownError).toBeUndefined(); + const cp = roleInstance.permissions.communityPermissions; + expect(cp.canManageCommunities).toBe(true); + expect(cp.canManageStaffRolesAndPermissions).toBe(true); + expect(cp.canManageAllCommunities).toBe(true); + expect(cp.canDeleteCommunities).toBe(true); + expect(cp.canChangeCommunityOwner).toBe(true); + expect(cp.canReIndexSearchCollections).toBe(true); }); }); - Scenario('Creating a staff role with duplicate name', ({ Given, When, Then }) => { - let error: Error; + // ─── canAssignStaffUserRoles ────────────────────────────────────────────── - Given('a staff role with name "Test Role" already exists', () => { - // Mock will be set up in When step + Scenario('Successfully creates a staff role with canAssignStaffUserRoles set', ({ Given, When, Then }) => { + Given('a staff role with name "Assign Role" does not exist in the repository', () => { + roleInstance = makeMockStaffRoleInstance('Assign Role'); + dataSources = makeDataSources({ newRoleInstance: roleInstance }); + command = { + roleName: 'Assign Role', + permissions: { user: { canAssignStaffUserRoles: true } } satisfies StaffRoleCreateCommandPermissions, + }; }); + When('I call create with roleName "Assign Role" and user permissions canAssignStaffUserRoles true', async () => { + try { + result = await create(dataSources)(command); + } catch (e) { + thrownError = e; + } + }); + Then('the user permission canAssignStaffUserRoles should be true', () => { + expect(thrownError).toBeUndefined(); + expect(roleInstance.permissions.userPermissions.canAssignStaffUserRoles).toBe(true); + }); + }); - When('I create a staff role with name "Test Role", isDefault false, and no permissions', async () => { - const mockRepo = makeMockRepo({ - getByRoleName: vi.fn().mockResolvedValue(makeMockStaffRole({ roleName: 'Test Role' })), - }); - - vi.mocked(dataSources.domainDataSource.User.StaffRole.StaffRoleUnitOfWork.withScopedTransaction).mockImplementation(async (callback) => { - await callback(mockRepo); - }); + // ─── No-op when sub-objects absent ─────────────────────────────────────── + Scenario('Omitting community permissions sub-object leaves community permissions unchanged', ({ Given, When, Then }) => { + Given('a staff role with name "Test Role" does not exist in the repository', () => { + roleInstance = makeMockStaffRoleInstance('Test Role'); + dataSources = makeDataSources({ newRoleInstance: roleInstance }); + command = { + roleName: 'Test Role', + permissions: { user: { canManageUsers: true } }, + }; + }); + When('I call create with roleName "Test Role" and only user permissions', async () => { try { - await createStaffRole({ roleName: 'Test Role', isDefault: false }); - } catch (err) { - error = err as Error; + result = await create(dataSources)(command); + } catch (e) { + thrownError = e; } }); + Then('all community permissions should remain false', () => { + expect(thrownError).toBeUndefined(); + const cp = roleInstance.permissions.communityPermissions; + for (const key of Object.keys(cp)) { + expect(cp[key], key).toBe(false); + } + }); + }); - Then('it should throw an error "Staff role with name Test Role already exists"', () => { - expect(error).toBeDefined(); - expect(error.message).toBe('Staff role with name Test Role already exists'); + Scenario('Omitting user permissions sub-object leaves user permissions unchanged', ({ Given, When, Then }) => { + Given('a staff role with name "Test Role" does not exist in the repository', () => { + roleInstance = makeMockStaffRoleInstance('Test Role'); + dataSources = makeDataSources({ newRoleInstance: roleInstance }); + command = { + roleName: 'Test Role', + permissions: { community: { canManageCommunities: true } }, + }; + }); + When('I call create with roleName "Test Role" and only community permissions', async () => { + try { + result = await create(dataSources)(command); + } catch (e) { + thrownError = e; + } + }); + Then('all user permissions should remain false', () => { + expect(thrownError).toBeUndefined(); + const up = roleInstance.permissions.userPermissions; + for (const key of Object.keys(up)) { + expect(up[key], key).toBe(false); + } }); }); - Scenario('Creating a staff role when save fails', ({ Given, When, Then }) => { - let error: Error; + // ─── getNewInstance called with roleName ────────────────────────────────── - Given('a staff role with name "Test Role" does not exist', () => { - // Mock will be set up in When step + Scenario('getNewInstance is called with the provided role name', ({ Given, When, Then }) => { + Given('a staff role with name "Named Role" does not exist in the repository', () => { + roleInstance = makeMockStaffRoleInstance('Named Role'); + dataSources = makeDataSources({ newRoleInstance: roleInstance }); + command = { roleName: 'Named Role' }; }); + When('I call create with roleName "Named Role" and no permissions', async () => { + try { + result = await create(dataSources)(command); + } catch (e) { + thrownError = e; + } + }); + Then('getNewInstance should have been called with "Named Role"', () => { + expect(thrownError).toBeUndefined(); + const repo = dataSources._repo as { getNewInstance: ReturnType }; + expect(repo.getNewInstance).toHaveBeenCalledWith('Named Role'); + }); + }); + + // ─── Additional permission scenarios added ─────────────────────────────── - When('I create a staff role but save fails', async () => { - const mockRepo = makeMockRepo({ - getByRoleName: vi.fn().mockRejectedValue(new Error('Not found')), - getNewInstance: vi.fn().mockResolvedValue(makeMockStaffRole({ roleName: 'Test Role', isDefault: false })), - save: vi.fn().mockResolvedValue(undefined), // Simulate save failure - }); + Scenario('Successfully creates a staff role with staff-role permissions', ({ Given, When, Then }) => { + Given('a staff role with name "Role Manager" does not exist in the repository', () => { + roleInstance = makeMockStaffRoleInstance('Role Manager'); + dataSources = makeDataSources({ newRoleInstance: roleInstance }); + command = { + roleName: 'Role Manager', + permissions: { staffRole: { canViewRoles: true, canAddRole: true, canEditRole: true, canRemoveRole: true } }, + }; + }); + When('I call create with roleName "Role Manager" and staffRole permissions canViewRoles true, canAddRole true, canEditRole true, canRemoveRole true', async () => { + try { + result = await create(dataSources)(command); + } catch (e) { + thrownError = e; + } + }); + Then('the staffRole permissions should be set on the saved instance', () => { + expect(thrownError).toBeUndefined(); + const sp = roleInstance.permissions.staffRolePermissions; + expect(sp).toBeDefined(); + // The create pipeline applies into staffRolePermissions adapter; check known keys + expect(sp?.canViewRoles).toBe(true); + expect(sp?.canAddRole).toBe(true); + expect(sp?.canEditRole).toBe(true); + expect(sp?.canRemoveRole).toBe(true); + }); + }); - vi.mocked(dataSources.domainDataSource.User.StaffRole.StaffRoleUnitOfWork.withScopedTransaction).mockImplementation(async (callback) => { - await callback(mockRepo); - }); + Scenario('Successfully creates a staff role with finance permissions', ({ Given, When, Then }) => { + Given('a staff role with name "Finance Role" does not exist in the repository', () => { + roleInstance = makeMockStaffRoleInstance('Finance Role'); + dataSources = makeDataSources({ newRoleInstance: roleInstance }); + command = { roleName: 'Finance Role', permissions: { finance: { canManageFinance: true } } }; + }); + When('I call create with roleName "Finance Role" and finance permissions canManageFinance true', async () => { + try { + result = await create(dataSources)(command); + } catch (e) { + thrownError = e; + } + }); + Then('the finance permission canManageFinance should be true on the saved instance', () => { + expect(thrownError).toBeUndefined(); + const fp = roleInstance.permissions.financePermissions; + expect(fp).toBeDefined(); + expect(fp?.canManageFinance).toBe(true); + }); + }); + Scenario('Successfully creates a staff role with tech-admin permissions', ({ Given, When, Then }) => { + Given('a staff role with name "Tech Role" does not exist in the repository', () => { + roleInstance = makeMockStaffRoleInstance('Tech Role'); + dataSources = makeDataSources({ newRoleInstance: roleInstance }); + command = { roleName: 'Tech Role', permissions: { techAdmin: { canManageTechAdmin: true } } }; + }); + When('I call create with roleName "Tech Role" and techAdmin permissions canManageTechAdmin true', async () => { try { - await createStaffRole({ roleName: 'Test Role', isDefault: false }); - } catch (err) { - error = err as Error; + result = await create(dataSources)(command); + } catch (e) { + thrownError = e; } }); + Then('the techAdmin permission canManageTechAdmin should be true on the saved instance', () => { + expect(thrownError).toBeUndefined(); + const tp = roleInstance.permissions.techAdminPermissions; + expect(tp).toBeDefined(); + expect(tp?.canManageTechAdmin).toBe(true); + }); + }); - Then('it should throw an error "Unable to create staff role"', () => { - expect(error).toBeDefined(); - expect(error.message).toBe('Unable to create staff role'); + Scenario('Creating role with canAssignStaffRoles true updates both assign flags', ({ Given, When, Then }) => { + Given('a staff role with name "AssignBoth Role" does not exist in the repository', () => { + roleInstance = makeMockStaffRoleInstance('AssignBoth Role'); + dataSources = makeDataSources({ newRoleInstance: roleInstance }); + command = { roleName: 'AssignBoth Role', permissions: { user: { canAssignStaffRoles: true } } }; + }); + When('I call create with roleName "AssignBoth Role" and user permissions canAssignStaffRoles true', async () => { + try { + result = await create(dataSources)(command); + } catch (e) { + thrownError = e; + } + }); + Then('both user permission flags for assigning staff roles should be true', () => { + expect(thrownError).toBeUndefined(); + expect(roleInstance.permissions.userPermissions.canAssignStaffRoles).toBe(true); + expect(roleInstance.permissions.userPermissions.canAssignStaffUserRoles).toBe(true); }); }); }); diff --git a/packages/ocom/application-services/src/contexts/user/staff-role/create.ts b/packages/ocom/application-services/src/contexts/user/staff-role/create.ts index f261c1ed3..53d9db578 100644 --- a/packages/ocom/application-services/src/contexts/user/staff-role/create.ts +++ b/packages/ocom/application-services/src/contexts/user/staff-role/create.ts @@ -2,6 +2,7 @@ import type { Domain } from '@ocom/domain'; import type { DataSources } from '@ocom/persistence'; interface StaffRoleCreateCommandCommunityPermissions { + canManageCommunities?: boolean; canManageStaffRolesAndPermissions?: boolean; canManageAllCommunities?: boolean; canDeleteCommunities?: boolean; @@ -9,12 +10,46 @@ interface StaffRoleCreateCommandCommunityPermissions { canReIndexSearchCollections?: boolean; } +interface StaffRoleCreateCommandUserPermissions { + canManageUsers?: boolean; + canAssignStaffRoles?: boolean; + canAssignStaffUserRoles?: boolean; + canViewStaffUsers?: boolean; +} + +interface StaffRoleCreateCommandRolePermissions { + canViewRoles?: boolean; + canAddRole?: boolean; + canEditRole?: boolean; + canRemoveRole?: boolean; +} + +interface StaffRoleCreateCommandFinancePermissions { + canManageFinance?: boolean; + canViewGLBatchSummaries?: boolean; + canViewFinanceConfigs?: boolean; + canCreateFinanceConfigs?: boolean; +} + +interface StaffRoleCreateCommandTechAdminPermissions { + canManageTechAdmin?: boolean; + canViewDatabaseExplorer?: boolean; + canViewBlobExplorer?: boolean; + canViewQueueDashboard?: boolean; + canSendQueueMessages?: boolean; +} + export interface StaffRoleCreateCommandPermissions { community?: StaffRoleCreateCommandCommunityPermissions; + user?: StaffRoleCreateCommandUserPermissions; + staffRole?: StaffRoleCreateCommandRolePermissions; + finance?: StaffRoleCreateCommandFinancePermissions; + techAdmin?: StaffRoleCreateCommandTechAdminPermissions; } export interface StaffRoleCreateCommand { roleName: string; + enterpriseAppRole?: string; isDefault?: boolean; permissions?: StaffRoleCreateCommandPermissions; } @@ -42,6 +77,9 @@ const applyCommunityPermissions = (staffRole: Domain.Contexts.User.StaffRole.Sta const { communityPermissions } = staffRole.permissions; + if (permissions.canManageCommunities !== undefined) { + communityPermissions.canManageCommunities = permissions.canManageCommunities; + } if (permissions.canManageStaffRolesAndPermissions !== undefined) { communityPermissions.canManageStaffRolesAndPermissions = permissions.canManageStaffRolesAndPermissions; } @@ -59,6 +97,98 @@ const applyCommunityPermissions = (staffRole: Domain.Contexts.User.StaffRole.Sta } }; +const applyUserPermissions = (staffRole: Domain.Contexts.User.StaffRole.StaffRole, permissions?: StaffRoleCreateCommandUserPermissions) => { + if (!permissions) { + return; + } + + const { userPermissions } = staffRole.permissions; + + if (permissions.canManageUsers !== undefined) { + userPermissions.canManageUsers = permissions.canManageUsers; + } + if (permissions.canAssignStaffUserRoles !== undefined) { + userPermissions.canAssignStaffRoles = permissions.canAssignStaffUserRoles; + userPermissions.canAssignStaffUserRoles = permissions.canAssignStaffUserRoles; + } + if (permissions.canAssignStaffRoles !== undefined) { + userPermissions.canAssignStaffRoles = permissions.canAssignStaffRoles; + userPermissions.canAssignStaffUserRoles = permissions.canAssignStaffRoles; + } + if (permissions.canViewStaffUsers !== undefined) { + userPermissions.canViewStaffUsers = permissions.canViewStaffUsers; + } +}; + +const applyRolePermissions = (staffRole: Domain.Contexts.User.StaffRole.StaffRole, permissions?: StaffRoleCreateCommandRolePermissions) => { + if (!permissions) { + return; + } + + const { staffRolePermissions } = staffRole.permissions; + + if (permissions.canViewRoles !== undefined) { + staffRolePermissions.canViewRoles = permissions.canViewRoles; + } + if (permissions.canAddRole !== undefined) { + staffRolePermissions.canAddRole = permissions.canAddRole; + } + if (permissions.canEditRole !== undefined) { + staffRolePermissions.canEditRole = permissions.canEditRole; + } + if (permissions.canRemoveRole !== undefined) { + staffRolePermissions.canRemoveRole = permissions.canRemoveRole; + } +}; + +const applyFinancePermissions = (staffRole: Domain.Contexts.User.StaffRole.StaffRole, permissions?: StaffRoleCreateCommandFinancePermissions) => { + if (!permissions) { + return; + } + + const { financePermissions } = staffRole.permissions; + + if (permissions.canManageFinance !== undefined) { + financePermissions.canManageFinance = permissions.canManageFinance; + } + if (permissions.canViewGLBatchSummaries !== undefined) { + financePermissions.canViewGLBatchSummaries = permissions.canViewGLBatchSummaries; + } + if (permissions.canViewFinanceConfigs !== undefined) { + financePermissions.canViewFinanceConfigs = permissions.canViewFinanceConfigs; + } + if (permissions.canCreateFinanceConfigs !== undefined) { + financePermissions.canCreateFinanceConfigs = permissions.canCreateFinanceConfigs; + } +}; + +const applyTechAdminPermissions = ( + staffRole: Domain.Contexts.User.StaffRole.StaffRole, + permissions?: StaffRoleCreateCommandTechAdminPermissions, +) => { + if (!permissions) { + return; + } + + const { techAdminPermissions } = staffRole.permissions; + + if (permissions.canManageTechAdmin !== undefined) { + techAdminPermissions.canManageTechAdmin = permissions.canManageTechAdmin; + } + if (permissions.canViewDatabaseExplorer !== undefined) { + techAdminPermissions.canViewDatabaseExplorer = permissions.canViewDatabaseExplorer; + } + if (permissions.canViewBlobExplorer !== undefined) { + techAdminPermissions.canViewBlobExplorer = permissions.canViewBlobExplorer; + } + if (permissions.canViewQueueDashboard !== undefined) { + techAdminPermissions.canViewQueueDashboard = permissions.canViewQueueDashboard; + } + if (permissions.canSendQueueMessages !== undefined) { + techAdminPermissions.canSendQueueMessages = permissions.canSendQueueMessages; + } +}; + export const create = (dataSources: DataSources) => { return async (command: StaffRoleCreateCommand): Promise => { let createdRole: Domain.Contexts.User.StaffRole.StaffRoleEntityReference | undefined; @@ -67,8 +197,14 @@ export const create = (dataSources: DataSources) => { await ensureRoleDoesNotExist(repository, command.roleName); const staffRole = await repository.getNewInstance(command.roleName); - staffRole.isDefault = command.isDefault ?? false; + if (command.enterpriseAppRole) { + staffRole.enterpriseAppRole = command.enterpriseAppRole; + } applyCommunityPermissions(staffRole, command.permissions?.community); + applyUserPermissions(staffRole, command.permissions?.user); + applyRolePermissions(staffRole, command.permissions?.staffRole); + applyFinancePermissions(staffRole, command.permissions?.finance); + applyTechAdminPermissions(staffRole, command.permissions?.techAdmin); createdRole = await repository.save(staffRole); }); diff --git a/packages/ocom/application-services/src/contexts/user/staff-role/features/create.feature b/packages/ocom/application-services/src/contexts/user/staff-role/features/create.feature index 0314a1705..3b195d8e0 100644 --- a/packages/ocom/application-services/src/contexts/user/staff-role/features/create.feature +++ b/packages/ocom/application-services/src/contexts/user/staff-role/features/create.feature @@ -1,21 +1,95 @@ -Feature: Creating a staff role - - Scenario: Creating a staff role successfully - Given a staff role with name "Test Role" does not exist - When I create a staff role with name "Test Role", isDefault false, and no permissions - Then it should return a staff role entity reference with name "Test Role" and isDefault false - - Scenario: Creating a staff role with permissions - Given a staff role with name "Admin Role" does not exist - When I create a staff role with name "Admin Role", isDefault true, and permissions - Then it should return a staff role entity reference with name "Admin Role" and isDefault true - - Scenario: Creating a staff role with duplicate name - Given a staff role with name "Test Role" already exists - When I create a staff role with name "Test Role", isDefault false, and no permissions - Then it should throw an error "Staff role with name Test Role already exists" - - Scenario: Creating a staff role when save fails - Given a staff role with name "Test Role" does not exist - When I create a staff role but save fails - Then it should throw an error "Unable to create staff role" \ No newline at end of file +Feature: Create staff role + + Scenario: Successfully creates a staff role with no permissions + Given a staff role with name "Test Role" does not exist in the repository + When I call create with roleName "Test Role" and no permissions + Then the new staff role should be saved + And the result should have roleName "Test Role" + + Scenario: Successfully creates a staff role with an enterpriseAppRole + Given a staff role with name "Test Role" does not exist in the repository + When I call create with roleName "Test Role" and enterpriseAppRole "Staff.TestRole" + Then the new staff role should be saved with enterpriseAppRole "Staff.TestRole" + + Scenario: Successfully creates a staff role with community permissions + Given a staff role with name "Admin Role" does not exist in the repository + When I call create with roleName "Admin Role" and community permissions canManageCommunities true + Then the new staff role should be saved + And the community permission canManageCommunities should be true + + Scenario: Successfully creates a staff role with user permissions + Given a staff role with name "Manager Role" does not exist in the repository + When I call create with roleName "Manager Role" and user permissions canManageUsers true + Then the new staff role should be saved + And the user permission canManageUsers should be true + + Scenario: Throws when a staff role with the same name already exists + Given a staff role with name "Duplicate Role" already exists in the repository + When I call create with roleName "Duplicate Role" + Then it should throw an error with message containing "Duplicate Role" + + Scenario: Propagates unexpected repository errors from getByRoleName + Given the repository throws an unexpected error when checking for "Error Role" + When I call create with roleName "Error Role" + Then it should throw the unexpected error + + Scenario: Throws when repository fails to save the new role + Given a staff role with name "Test Role" does not exist in the repository + And saving the staff role returns undefined + When I call create with roleName "Test Role" and no permissions + Then it should throw an error with message "Unable to create staff role" + + Scenario: enterpriseAppRole is not set when not provided in the command + Given a staff role with name "Test Role" does not exist in the repository + When I call create with roleName "Test Role" and no permissions + Then the enterpriseAppRole on the saved instance should remain empty + + Scenario: Not-found detected via error name NotFoundError allows creation to proceed + Given the repository raises a NotFoundError by name when checking for "New Role" + When I call create with roleName "New Role" and no permissions + Then the new staff role should be saved + + Scenario: Successfully creates a staff role with all community permissions set + Given a staff role with name "Full Community Role" does not exist in the repository + When I call create with roleName "Full Community Role" and all community permissions true + Then all community permissions should be true on the saved instance + + Scenario: Successfully creates a staff role with canAssignStaffUserRoles set + Given a staff role with name "Assign Role" does not exist in the repository + When I call create with roleName "Assign Role" and user permissions canAssignStaffUserRoles true + Then the user permission canAssignStaffUserRoles should be true + + Scenario: Omitting community permissions sub-object leaves community permissions unchanged + Given a staff role with name "Test Role" does not exist in the repository + When I call create with roleName "Test Role" and only user permissions + Then all community permissions should remain false + + Scenario: Omitting user permissions sub-object leaves user permissions unchanged + Given a staff role with name "Test Role" does not exist in the repository + When I call create with roleName "Test Role" and only community permissions + Then all user permissions should remain false + + Scenario: getNewInstance is called with the provided role name + Given a staff role with name "Named Role" does not exist in the repository + When I call create with roleName "Named Role" and no permissions + Then getNewInstance should have been called with "Named Role" + + Scenario: Successfully creates a staff role with staff-role permissions + Given a staff role with name "Role Manager" does not exist in the repository + When I call create with roleName "Role Manager" and staffRole permissions canViewRoles true, canAddRole true, canEditRole true, canRemoveRole true + Then the staffRole permissions should be set on the saved instance + + Scenario: Successfully creates a staff role with finance permissions + Given a staff role with name "Finance Role" does not exist in the repository + When I call create with roleName "Finance Role" and finance permissions canManageFinance true + Then the finance permission canManageFinance should be true on the saved instance + + Scenario: Successfully creates a staff role with tech-admin permissions + Given a staff role with name "Tech Role" does not exist in the repository + When I call create with roleName "Tech Role" and techAdmin permissions canManageTechAdmin true + Then the techAdmin permission canManageTechAdmin should be true on the saved instance + + Scenario: Creating role with canAssignStaffRoles true updates both assign flags + Given a staff role with name "AssignBoth Role" does not exist in the repository + When I call create with roleName "AssignBoth Role" and user permissions canAssignStaffRoles true + Then both user permission flags for assigning staff roles should be true diff --git a/packages/ocom/application-services/src/contexts/user/staff-role/features/list.feature b/packages/ocom/application-services/src/contexts/user/staff-role/features/list.feature new file mode 100644 index 000000000..b96d7d589 --- /dev/null +++ b/packages/ocom/application-services/src/contexts/user/staff-role/features/list.feature @@ -0,0 +1,11 @@ +Feature: List staff roles + + Scenario: Returns all staff roles when roles exist + Given the repository contains two staff roles + When I call list + Then it should return all staff roles + + Scenario: Returns an empty list when no staff roles exist + Given the repository contains no staff roles + When I call list + Then it should return an empty list diff --git a/packages/ocom/application-services/src/contexts/user/staff-role/features/update.feature b/packages/ocom/application-services/src/contexts/user/staff-role/features/update.feature new file mode 100644 index 000000000..a79e9333c --- /dev/null +++ b/packages/ocom/application-services/src/contexts/user/staff-role/features/update.feature @@ -0,0 +1,60 @@ +Feature: Update staff role + + Scenario: Successfully updates a staff role name + Given a staff role with id "role-001" exists in the repository + When I call update with roleId "role-001" and roleName "Updated Role" + Then the staff role should be saved + And the result should have roleName "Updated Role" + + Scenario: Successfully updates a staff role with an enterpriseAppRole + Given a staff role with id "role-002" exists in the repository + When I call update with roleId "role-002" and enterpriseAppRole "Staff.UpdatedRole" + Then the staff role should be saved with enterpriseAppRole "Staff.UpdatedRole" + + Scenario: Successfully updates a staff role with community permissions + Given a staff role with id "role-003" exists in the repository + When I call update with roleId "role-003" and community permissions canManageCommunities true + Then the staff role should be saved + And the community permission canManageCommunities should be true + + Scenario: Successfully updates a staff role with user permissions + Given a staff role with id "role-004" exists in the repository + When I call update with roleId "role-004" and user permissions canManageUsers true + Then the staff role should be saved + And the user permission canManageUsers should be true + + Scenario: Does not apply enterpriseAppRole when it is not provided + Given a staff role with id "role-005" exists in the repository + When I call update with roleId "role-005" and no enterpriseAppRole + Then the staff role enterpriseAppRole should remain unchanged + + Scenario: Throws when repository fails to save the updated role + Given a staff role with id "role-err" exists in the repository + And saving the staff role returns undefined + When I call update with roleId "role-err" and roleName "Any Role" + Then it should throw an error with message "Unable to update staff role" + + Scenario: Successfully updates a staff role with all community permissions set + Given a staff role with id "role-all-comm" exists in the repository + When I call update with all community permissions true + Then all community permissions should be true on the updated instance + + Scenario: Successfully updates a staff role with canAssignStaffUserRoles set + Given a staff role with id "role-assign" exists in the repository + When I call update with user permissions canAssignStaffUserRoles true + Then the user permission canAssignStaffUserRoles should be true + + Scenario: Omitting community permissions sub-object leaves community permissions unchanged + Given a staff role with id "role-noc" exists in the repository + When I call update with only user permissions + Then all community permissions should remain false + + Scenario: Omitting user permissions sub-object leaves user permissions unchanged + Given a staff role with id "role-nou" exists in the repository + When I call update with only community permissions + Then all user permissions should remain false + + Scenario: getById is called with the provided role id + Given a staff role with id "role-lookup" exists in the repository + When I call update with roleId "role-lookup" and roleName "Any Role" + Then getById should have been called with "role-lookup" diff --git a/packages/ocom/application-services/src/contexts/user/staff-role/index.ts b/packages/ocom/application-services/src/contexts/user/staff-role/index.ts index e032256e8..916d44345 100644 --- a/packages/ocom/application-services/src/contexts/user/staff-role/index.ts +++ b/packages/ocom/application-services/src/contexts/user/staff-role/index.ts @@ -3,15 +3,19 @@ import type { DataSources } from '@ocom/persistence'; import { create, type StaffRoleCreateCommand } from './create.ts'; import { createDefaultRoles } from './create-default-roles.ts'; import { deleteAndReassign, type StaffRoleDeleteAndReassignCommand } from './delete-and-reassign.ts'; +import { list } from './list.ts'; import { queryById, type StaffRoleQueryByIdCommand } from './query-by-id.ts'; import { queryByRoleName, type StaffRoleQueryByRoleNameCommand } from './query-by-role-name.ts'; +import { update, type StaffRoleUpdateCommand } from './update.ts'; export interface StaffRoleApplicationService { create: (command: StaffRoleCreateCommand) => Promise; createDefaultRoles: () => Promise; deleteAndReassign: (command: StaffRoleDeleteAndReassignCommand) => Promise; + list: () => Promise; queryById: (command: StaffRoleQueryByIdCommand) => Promise; queryByRoleName: (command: StaffRoleQueryByRoleNameCommand) => Promise; + update: (command: StaffRoleUpdateCommand) => Promise; } export const StaffRole = (dataSources: DataSources): StaffRoleApplicationService => { @@ -19,7 +23,9 @@ export const StaffRole = (dataSources: DataSources): StaffRoleApplicationService create: create(dataSources), createDefaultRoles: createDefaultRoles(dataSources), deleteAndReassign: deleteAndReassign(dataSources), + list: list(dataSources), queryById: queryById(dataSources), queryByRoleName: queryByRoleName(dataSources), + update: update(dataSources), }; }; diff --git a/packages/ocom/application-services/src/contexts/user/staff-role/list.test.ts b/packages/ocom/application-services/src/contexts/user/staff-role/list.test.ts new file mode 100644 index 000000000..ef8cf5f56 --- /dev/null +++ b/packages/ocom/application-services/src/contexts/user/staff-role/list.test.ts @@ -0,0 +1,114 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describeFeature, loadFeature } from '@amiceli/vitest-cucumber'; +import type { Domain } from '@ocom/domain'; +import type { DataSources } from '@ocom/persistence'; +import { expect, vi } from 'vitest'; +import { list } from './list.ts'; + +const test = { for: describeFeature }; +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const feature = await loadFeature(path.resolve(__dirname, 'features/list.feature')); + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function makeMockStaffRoleRef(id: string, roleName: string): Domain.Contexts.User.StaffRole.StaffRoleEntityReference { + return { + id, + roleName, + enterpriseAppRole: '', + isDefault: false, + roleType: null, + permissions: { + communityPermissions: { + canManageCommunities: false, + canManageStaffRolesAndPermissions: false, + canManageAllCommunities: false, + canDeleteCommunities: false, + canChangeCommunityOwner: false, + canReIndexSearchCollections: false, + }, + userPermissions: { + canManageUsers: false, + canAssignStaffUserRoles: false, + }, + }, + createdAt: new Date(), + updatedAt: new Date(), + schemaVersion: '1.0', + } as unknown as Domain.Contexts.User.StaffRole.StaffRoleEntityReference; +} + +function makeDataSources(roles: Domain.Contexts.User.StaffRole.StaffRoleEntityReference[]): DataSources { + return { + readonlyDataSource: { + User: { + StaffRole: { + StaffRoleReadRepo: { + getAll: vi.fn().mockResolvedValue(roles), + }, + }, + }, + }, + } as unknown as DataSources; +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +test.for(feature, ({ Scenario, BeforeEachScenario }) => { + let dataSources: DataSources; + let result: Domain.Contexts.User.StaffRole.StaffRoleEntityReference[] | undefined; + let thrownError: unknown; + + BeforeEachScenario(() => { + result = undefined; + thrownError = undefined; + }); + + // ─── Roles exist ────────────────────────────────────────────────────────── + + Scenario('Returns all staff roles when roles exist', ({ Given, When, Then }) => { + Given('the repository contains two staff roles', () => { + const roles = [makeMockStaffRoleRef('role-001', 'Admin'), makeMockStaffRoleRef('role-002', 'Manager')]; + dataSources = makeDataSources(roles); + }); + + When('I call list', async () => { + try { + result = await list(dataSources)(); + } catch (e) { + thrownError = e; + } + }); + + Then('it should return all staff roles', () => { + expect(thrownError).toBeUndefined(); + expect(result).toHaveLength(2); + const roles = result as Domain.Contexts.User.StaffRole.StaffRoleEntityReference[]; + const [first, second] = roles; + expect(first?.id).toBe('role-001'); + expect(second?.id).toBe('role-002'); + }); + }); + + // ─── No roles ───────────────────────────────────────────────────────────── + + Scenario('Returns an empty list when no staff roles exist', ({ Given, When, Then }) => { + Given('the repository contains no staff roles', () => { + dataSources = makeDataSources([]); + }); + + When('I call list', async () => { + try { + result = await list(dataSources)(); + } catch (e) { + thrownError = e; + } + }); + + Then('it should return an empty list', () => { + expect(thrownError).toBeUndefined(); + expect(result).toHaveLength(0); + }); + }); +}); diff --git a/packages/ocom/application-services/src/contexts/user/staff-role/list.ts b/packages/ocom/application-services/src/contexts/user/staff-role/list.ts new file mode 100644 index 000000000..a7a8eb3d3 --- /dev/null +++ b/packages/ocom/application-services/src/contexts/user/staff-role/list.ts @@ -0,0 +1,8 @@ +import type { Domain } from '@ocom/domain'; +import type { DataSources } from '@ocom/persistence'; + +export const list = (dataSources: DataSources) => { + return async (): Promise => { + return await dataSources.readonlyDataSource.User.StaffRole.StaffRoleReadRepo.getAll(); + }; +}; diff --git a/packages/ocom/application-services/src/contexts/user/staff-role/update.test.ts b/packages/ocom/application-services/src/contexts/user/staff-role/update.test.ts new file mode 100644 index 000000000..304a1d83f --- /dev/null +++ b/packages/ocom/application-services/src/contexts/user/staff-role/update.test.ts @@ -0,0 +1,409 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describeFeature, loadFeature } from '@amiceli/vitest-cucumber'; +import type { Domain } from '@ocom/domain'; +import type { DataSources } from '@ocom/persistence'; +import { expect, vi } from 'vitest'; +import { update, type StaffRoleUpdateCommand } from './update.ts'; + +const test = { for: describeFeature }; +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const feature = await loadFeature(path.resolve(__dirname, 'features/update.feature')); + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +type MockPermissions = { + communityPermissions: Record; + userPermissions: Record; +}; + +interface MockStaffRoleInstance { + id: string; + roleName: string; + enterpriseAppRole: string; + isDefault: boolean; + roleType: null; + permissions: MockPermissions; + createdAt: Date; + updatedAt: Date; + schemaVersion: string; +} + +function makeMockStaffRoleInstance(id: string, roleName = 'Original Role'): MockStaffRoleInstance { + const communityPermissions: Record = { + canManageCommunities: false, + canManageStaffRolesAndPermissions: false, + canManageAllCommunities: false, + canDeleteCommunities: false, + canChangeCommunityOwner: false, + canReIndexSearchCollections: false, + }; + const userPermissions: Record = { + canManageUsers: false, + canAssignStaffUserRoles: false, + }; + return { + id, + roleName, + enterpriseAppRole: 'Original.AppRole', + isDefault: false, + roleType: null, + permissions: { communityPermissions, userPermissions }, + createdAt: new Date(), + updatedAt: new Date(), + schemaVersion: '1.0', + }; +} + +function makeDataSources(overrides: { + roleInstance?: MockStaffRoleInstance; + explicitUndefinedSave?: boolean; +}): DataSources & { _repo: unknown } { + const { roleInstance, explicitUndefinedSave } = overrides; + const instance = roleInstance ?? makeMockStaffRoleInstance('role-001'); + const savedRole = explicitUndefinedSave ? undefined : (instance as unknown as Domain.Contexts.User.StaffRole.StaffRoleEntityReference); + + const repo = { + getById: vi.fn().mockResolvedValue(instance), + save: vi.fn().mockResolvedValue(savedRole), + } as unknown as Domain.Contexts.User.StaffRole.StaffRoleRepository; + + return { + domainDataSource: { + User: { + StaffRole: { + StaffRoleUnitOfWork: { + withScopedTransaction: vi.fn().mockImplementation(async (cb: (r: typeof repo) => Promise) => { + await cb(repo); + }), + }, + }, + }, + }, + _repo: repo, + } as unknown as DataSources & { _repo: unknown }; +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +test.for(feature, ({ Scenario, BeforeEachScenario }) => { + let dataSources: DataSources & { _repo?: unknown }; + let command: StaffRoleUpdateCommand; + let result: Domain.Contexts.User.StaffRole.StaffRoleEntityReference | undefined; + let thrownError: unknown; + let roleInstance: MockStaffRoleInstance; + + BeforeEachScenario(() => { + result = undefined; + thrownError = undefined; + roleInstance = makeMockStaffRoleInstance('role-001'); + command = { roleId: 'role-001', roleName: 'Updated Role' }; + }); + + // ─── Update roleName ────────────────────────────────────────────────────── + + Scenario('Successfully updates a staff role name', ({ Given, When, Then, And }) => { + Given('a staff role with id "role-001" exists in the repository', () => { + roleInstance = makeMockStaffRoleInstance('role-001'); + dataSources = makeDataSources({ roleInstance }); + command = { roleId: 'role-001', roleName: 'Updated Role' }; + }); + + When('I call update with roleId "role-001" and roleName "Updated Role"', async () => { + try { + result = await update(dataSources)(command); + } catch (e) { + thrownError = e; + } + }); + + Then('the staff role should be saved', () => { + const repo = dataSources._repo as { save: ReturnType }; + expect(repo.save).toHaveBeenCalled(); + }); + + And('the result should have roleName "Updated Role"', () => { + expect(thrownError).toBeUndefined(); + expect(result).toBeDefined(); + expect(roleInstance.roleName).toBe('Updated Role'); + }); + }); + + // ─── Update enterpriseAppRole ───────────────────────────────────────────── + + Scenario('Successfully updates a staff role with an enterpriseAppRole', ({ Given, When, Then }) => { + Given('a staff role with id "role-002" exists in the repository', () => { + roleInstance = makeMockStaffRoleInstance('role-002'); + dataSources = makeDataSources({ roleInstance }); + command = { roleId: 'role-002', roleName: 'Updated Role', enterpriseAppRole: 'Staff.UpdatedRole' }; + }); + + When('I call update with roleId "role-002" and enterpriseAppRole "Staff.UpdatedRole"', async () => { + try { + result = await update(dataSources)(command); + } catch (e) { + thrownError = e; + } + }); + + Then('the staff role should be saved with enterpriseAppRole "Staff.UpdatedRole"', () => { + expect(thrownError).toBeUndefined(); + expect(roleInstance.enterpriseAppRole).toBe('Staff.UpdatedRole'); + }); + }); + + // ─── Update community permissions ───────────────────────────────────────── + + Scenario('Successfully updates a staff role with community permissions', ({ Given, When, Then, And }) => { + Given('a staff role with id "role-003" exists in the repository', () => { + roleInstance = makeMockStaffRoleInstance('role-003'); + dataSources = makeDataSources({ roleInstance }); + command = { + roleId: 'role-003', + roleName: 'Admin Role', + permissions: { community: { canManageCommunities: true } }, + }; + }); + + When('I call update with roleId "role-003" and community permissions canManageCommunities true', async () => { + try { + result = await update(dataSources)(command); + } catch (e) { + thrownError = e; + } + }); + + Then('the staff role should be saved', () => { + const repo = dataSources._repo as { save: ReturnType }; + expect(repo.save).toHaveBeenCalled(); + }); + + And('the community permission canManageCommunities should be true', () => { + expect(thrownError).toBeUndefined(); + expect(roleInstance.permissions.communityPermissions.canManageCommunities).toBe(true); + }); + }); + + // ─── Update user permissions ────────────────────────────────────────────── + + Scenario('Successfully updates a staff role with user permissions', ({ Given, When, Then, And }) => { + Given('a staff role with id "role-004" exists in the repository', () => { + roleInstance = makeMockStaffRoleInstance('role-004'); + dataSources = makeDataSources({ roleInstance }); + command = { + roleId: 'role-004', + roleName: 'Manager Role', + permissions: { user: { canManageUsers: true } }, + }; + }); + + When('I call update with roleId "role-004" and user permissions canManageUsers true', async () => { + try { + result = await update(dataSources)(command); + } catch (e) { + thrownError = e; + } + }); + + Then('the staff role should be saved', () => { + const repo = dataSources._repo as { save: ReturnType }; + expect(repo.save).toHaveBeenCalled(); + }); + + And('the user permission canManageUsers should be true', () => { + expect(thrownError).toBeUndefined(); + expect(roleInstance.permissions.userPermissions.canManageUsers).toBe(true); + }); + }); + + // ─── enterpriseAppRole not applied when absent ──────────────────────────── + + Scenario('Does not apply enterpriseAppRole when it is not provided', ({ Given, When, Then }) => { + Given('a staff role with id "role-005" exists in the repository', () => { + roleInstance = makeMockStaffRoleInstance('role-005'); + roleInstance.enterpriseAppRole = 'Original.AppRole'; + dataSources = makeDataSources({ roleInstance }); + command = { roleId: 'role-005', roleName: 'Some Role' }; + }); + + When('I call update with roleId "role-005" and no enterpriseAppRole', async () => { + try { + result = await update(dataSources)(command); + } catch (e) { + thrownError = e; + } + }); + + Then('the staff role enterpriseAppRole should remain unchanged', () => { + expect(thrownError).toBeUndefined(); + expect(roleInstance.enterpriseAppRole).toBe('Original.AppRole'); + }); + }); + + // ─── Save fails ─────────────────────────────────────────────────────────── + + Scenario('Throws when repository fails to save the updated role', ({ Given, When, Then, And }) => { + Given('a staff role with id "role-err" exists in the repository', () => { + roleInstance = makeMockStaffRoleInstance('role-err'); + }); + + And('saving the staff role returns undefined', () => { + dataSources = makeDataSources({ roleInstance, explicitUndefinedSave: true }); + command = { roleId: 'role-err', roleName: 'Any Role' }; + }); + + When('I call update with roleId "role-err" and roleName "Any Role"', async () => { + try { + result = await update(dataSources)(command); + } catch (e) { + thrownError = e; + } + }); + + Then('it should throw an error with message "Unable to update staff role"', () => { + expect(thrownError).toBeDefined(); + expect((thrownError as Error).message).toBe('Unable to update staff role'); + }); + }); + + // ─── All community permissions ──────────────────────────────────────────── + + Scenario('Successfully updates a staff role with all community permissions set', ({ Given, When, Then }) => { + Given('a staff role with id "role-all-comm" exists in the repository', () => { + roleInstance = makeMockStaffRoleInstance('role-all-comm'); + dataSources = makeDataSources({ roleInstance }); + command = { + roleId: 'role-all-comm', + roleName: 'Full Community Role', + permissions: { + community: { + canManageCommunities: true, + canManageStaffRolesAndPermissions: true, + canManageAllCommunities: true, + canDeleteCommunities: true, + canChangeCommunityOwner: true, + canReIndexSearchCollections: true, + }, + }, + }; + }); + When('I call update with all community permissions true', async () => { + try { + result = await update(dataSources)(command); + } catch (e) { + thrownError = e; + } + }); + Then('all community permissions should be true on the updated instance', () => { + expect(thrownError).toBeUndefined(); + const cp = roleInstance.permissions.communityPermissions; + expect(cp.canManageCommunities).toBe(true); + expect(cp.canManageStaffRolesAndPermissions).toBe(true); + expect(cp.canManageAllCommunities).toBe(true); + expect(cp.canDeleteCommunities).toBe(true); + expect(cp.canChangeCommunityOwner).toBe(true); + expect(cp.canReIndexSearchCollections).toBe(true); + }); + }); + + // ─── canAssignStaffUserRoles ────────────────────────────────────────────── + + Scenario('Successfully updates a staff role with canAssignStaffUserRoles set', ({ Given, When, Then }) => { + Given('a staff role with id "role-assign" exists in the repository', () => { + roleInstance = makeMockStaffRoleInstance('role-assign'); + dataSources = makeDataSources({ roleInstance }); + command = { + roleId: 'role-assign', + roleName: 'Assign Role', + permissions: { user: { canAssignStaffUserRoles: true } }, + }; + }); + When('I call update with user permissions canAssignStaffUserRoles true', async () => { + try { + result = await update(dataSources)(command); + } catch (e) { + thrownError = e; + } + }); + Then('the user permission canAssignStaffUserRoles should be true', () => { + expect(thrownError).toBeUndefined(); + expect(roleInstance.permissions.userPermissions.canAssignStaffUserRoles).toBe(true); + }); + }); + + // ─── No-op when sub-objects absent ─────────────────────────────────────── + + Scenario('Omitting community permissions sub-object leaves community permissions unchanged', ({ Given, When, Then }) => { + Given('a staff role with id "role-noc" exists in the repository', () => { + roleInstance = makeMockStaffRoleInstance('role-noc'); + dataSources = makeDataSources({ roleInstance }); + command = { + roleId: 'role-noc', + roleName: 'Some Role', + permissions: { user: { canManageUsers: true } }, + }; + }); + When('I call update with only user permissions', async () => { + try { + result = await update(dataSources)(command); + } catch (e) { + thrownError = e; + } + }); + Then('all community permissions should remain false', () => { + expect(thrownError).toBeUndefined(); + const cp = roleInstance.permissions.communityPermissions; + for (const key of Object.keys(cp)) { + expect(cp[key], key).toBe(false); + } + }); + }); + + Scenario('Omitting user permissions sub-object leaves user permissions unchanged', ({ Given, When, Then }) => { + Given('a staff role with id "role-nou" exists in the repository', () => { + roleInstance = makeMockStaffRoleInstance('role-nou'); + dataSources = makeDataSources({ roleInstance }); + command = { + roleId: 'role-nou', + roleName: 'Some Role', + permissions: { community: { canManageCommunities: true } }, + }; + }); + When('I call update with only community permissions', async () => { + try { + result = await update(dataSources)(command); + } catch (e) { + thrownError = e; + } + }); + Then('all user permissions should remain false', () => { + expect(thrownError).toBeUndefined(); + const up = roleInstance.permissions.userPermissions; + for (const key of Object.keys(up)) { + expect(up[key], key).toBe(false); + } + }); + }); + + // ─── getById called with roleId ─────────────────────────────────────────── + + Scenario('getById is called with the provided role id', ({ Given, When, Then }) => { + Given('a staff role with id "role-lookup" exists in the repository', () => { + roleInstance = makeMockStaffRoleInstance('role-lookup'); + dataSources = makeDataSources({ roleInstance }); + command = { roleId: 'role-lookup', roleName: 'Any Role' }; + }); + When('I call update with roleId "role-lookup" and roleName "Any Role"', async () => { + try { + result = await update(dataSources)(command); + } catch (e) { + thrownError = e; + } + }); + Then('getById should have been called with "role-lookup"', () => { + expect(thrownError).toBeUndefined(); + const repo = dataSources._repo as { getById: ReturnType }; + expect(repo.getById).toHaveBeenCalledWith('role-lookup'); + }); + }); +}); diff --git a/packages/ocom/application-services/src/contexts/user/staff-role/update.ts b/packages/ocom/application-services/src/contexts/user/staff-role/update.ts new file mode 100644 index 000000000..b2e359f07 --- /dev/null +++ b/packages/ocom/application-services/src/contexts/user/staff-role/update.ts @@ -0,0 +1,151 @@ +import type { Domain } from '@ocom/domain'; +import type { DataSources } from '@ocom/persistence'; + +interface StaffRoleUpdateCommandCommunityPermissions { + canManageCommunities?: boolean; + canManageStaffRolesAndPermissions?: boolean; + canManageAllCommunities?: boolean; + canDeleteCommunities?: boolean; + canChangeCommunityOwner?: boolean; + canReIndexSearchCollections?: boolean; +} + +interface StaffRoleUpdateCommandUserPermissions { + canManageUsers?: boolean; + canAssignStaffRoles?: boolean; + canAssignStaffUserRoles?: boolean; + canViewStaffUsers?: boolean; +} + +interface StaffRoleUpdateCommandRolePermissions { + canViewRoles?: boolean; + canAddRole?: boolean; + canEditRole?: boolean; + canRemoveRole?: boolean; +} + +interface StaffRoleUpdateCommandFinancePermissions { + canManageFinance?: boolean; + canViewGLBatchSummaries?: boolean; + canViewFinanceConfigs?: boolean; + canCreateFinanceConfigs?: boolean; +} + +interface StaffRoleUpdateCommandTechAdminPermissions { + canManageTechAdmin?: boolean; + canViewDatabaseExplorer?: boolean; + canViewBlobExplorer?: boolean; + canViewQueueDashboard?: boolean; + canSendQueueMessages?: boolean; +} + +interface StaffRoleUpdateCommandPermissions { + community?: StaffRoleUpdateCommandCommunityPermissions; + user?: StaffRoleUpdateCommandUserPermissions; + staffRole?: StaffRoleUpdateCommandRolePermissions; + finance?: StaffRoleUpdateCommandFinancePermissions; + techAdmin?: StaffRoleUpdateCommandTechAdminPermissions; +} + +export interface StaffRoleUpdateCommand { + roleId: string; + roleName: string; + enterpriseAppRole?: string; + permissions?: StaffRoleUpdateCommandPermissions; +} + +const applyCommunityPermissions = ( + staffRole: Domain.Contexts.User.StaffRole.StaffRole, + permissions?: StaffRoleUpdateCommandCommunityPermissions, +) => { + if (!permissions) return; + const { communityPermissions } = staffRole.permissions; + if (permissions.canManageCommunities !== undefined) communityPermissions.canManageCommunities = permissions.canManageCommunities; + if (permissions.canManageStaffRolesAndPermissions !== undefined) communityPermissions.canManageStaffRolesAndPermissions = permissions.canManageStaffRolesAndPermissions; + if (permissions.canManageAllCommunities !== undefined) communityPermissions.canManageAllCommunities = permissions.canManageAllCommunities; + if (permissions.canDeleteCommunities !== undefined) communityPermissions.canDeleteCommunities = permissions.canDeleteCommunities; + if (permissions.canChangeCommunityOwner !== undefined) communityPermissions.canChangeCommunityOwner = permissions.canChangeCommunityOwner; + if (permissions.canReIndexSearchCollections !== undefined) communityPermissions.canReIndexSearchCollections = permissions.canReIndexSearchCollections; +}; + +const applyUserPermissions = ( + staffRole: Domain.Contexts.User.StaffRole.StaffRole, + permissions?: StaffRoleUpdateCommandUserPermissions, +) => { + if (!permissions) return; + const { userPermissions } = staffRole.permissions; + if (permissions.canManageUsers !== undefined) userPermissions.canManageUsers = permissions.canManageUsers; + if (permissions.canAssignStaffUserRoles !== undefined) userPermissions.canAssignStaffUserRoles = permissions.canAssignStaffUserRoles; + if (permissions.canAssignStaffRoles !== undefined) { + userPermissions.canAssignStaffRoles = permissions.canAssignStaffRoles; + userPermissions.canAssignStaffUserRoles = permissions.canAssignStaffRoles; + } + if (permissions.canAssignStaffUserRoles !== undefined) { + userPermissions.canAssignStaffRoles = permissions.canAssignStaffUserRoles; + userPermissions.canAssignStaffUserRoles = permissions.canAssignStaffUserRoles; + } + if (permissions.canViewStaffUsers !== undefined) userPermissions.canViewStaffUsers = permissions.canViewStaffUsers; +}; + +const applyRolePermissions = ( + staffRole: Domain.Contexts.User.StaffRole.StaffRole, + permissions?: StaffRoleUpdateCommandRolePermissions, +) => { + if (!permissions) return; + const { staffRolePermissions } = staffRole.permissions; + if (permissions.canViewRoles !== undefined) staffRolePermissions.canViewRoles = permissions.canViewRoles; + if (permissions.canAddRole !== undefined) staffRolePermissions.canAddRole = permissions.canAddRole; + if (permissions.canEditRole !== undefined) staffRolePermissions.canEditRole = permissions.canEditRole; + if (permissions.canRemoveRole !== undefined) staffRolePermissions.canRemoveRole = permissions.canRemoveRole; +}; + +const applyFinancePermissions = ( + staffRole: Domain.Contexts.User.StaffRole.StaffRole, + permissions?: StaffRoleUpdateCommandFinancePermissions, +) => { + if (!permissions) return; + const { financePermissions } = staffRole.permissions; + if (permissions.canManageFinance !== undefined) financePermissions.canManageFinance = permissions.canManageFinance; + if (permissions.canViewGLBatchSummaries !== undefined) financePermissions.canViewGLBatchSummaries = permissions.canViewGLBatchSummaries; + if (permissions.canViewFinanceConfigs !== undefined) financePermissions.canViewFinanceConfigs = permissions.canViewFinanceConfigs; + if (permissions.canCreateFinanceConfigs !== undefined) financePermissions.canCreateFinanceConfigs = permissions.canCreateFinanceConfigs; +}; + +const applyTechAdminPermissions = ( + staffRole: Domain.Contexts.User.StaffRole.StaffRole, + permissions?: StaffRoleUpdateCommandTechAdminPermissions, +) => { + if (!permissions) return; + const { techAdminPermissions } = staffRole.permissions; + if (permissions.canManageTechAdmin !== undefined) techAdminPermissions.canManageTechAdmin = permissions.canManageTechAdmin; + if (permissions.canViewDatabaseExplorer !== undefined) techAdminPermissions.canViewDatabaseExplorer = permissions.canViewDatabaseExplorer; + if (permissions.canViewBlobExplorer !== undefined) techAdminPermissions.canViewBlobExplorer = permissions.canViewBlobExplorer; + if (permissions.canViewQueueDashboard !== undefined) techAdminPermissions.canViewQueueDashboard = permissions.canViewQueueDashboard; + if (permissions.canSendQueueMessages !== undefined) techAdminPermissions.canSendQueueMessages = permissions.canSendQueueMessages; +}; + +export const update = (dataSources: DataSources) => { + return async (command: StaffRoleUpdateCommand): Promise => { + let updatedRole: Domain.Contexts.User.StaffRole.StaffRoleEntityReference | undefined; + + await dataSources.domainDataSource.User.StaffRole.StaffRoleUnitOfWork.withScopedTransaction(async (repository) => { + const staffRole = await repository.getById(command.roleId); + staffRole.roleName = command.roleName; + if (command.enterpriseAppRole) { + staffRole.enterpriseAppRole = command.enterpriseAppRole; + } + applyCommunityPermissions(staffRole, command.permissions?.community); + applyUserPermissions(staffRole, command.permissions?.user); + applyRolePermissions(staffRole, command.permissions?.staffRole); + applyFinancePermissions(staffRole, command.permissions?.finance); + applyTechAdminPermissions(staffRole, command.permissions?.techAdmin); + updatedRole = await repository.save(staffRole); + }); + + if (!updatedRole) { + throw new Error('Unable to update staff role'); + } + + return updatedRole; + }; +}; diff --git a/packages/ocom/application-services/src/contexts/user/staff-user/assign-role.test.ts b/packages/ocom/application-services/src/contexts/user/staff-user/assign-role.test.ts new file mode 100644 index 000000000..fe71ad9c2 --- /dev/null +++ b/packages/ocom/application-services/src/contexts/user/staff-user/assign-role.test.ts @@ -0,0 +1,217 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describeFeature, loadFeature } from '@amiceli/vitest-cucumber'; +import type { Domain } from '@ocom/domain'; +import type { DataSources } from '@ocom/persistence'; +import { expect, vi } from 'vitest'; +import { assignRole, type StaffUserAssignRoleCommand } from './assign-role.ts'; + +const test = { for: describeFeature }; +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const feature = await loadFeature(path.resolve(__dirname, 'features/assign-role.feature')); + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +interface MockStaffUserInstance extends Domain.Contexts.User.StaffUser.StaffUserEntityReference { + role: Domain.Contexts.User.StaffRole.StaffRoleEntityReference | undefined; +} + +function makeMockStaffRoleRef(id: string): Domain.Contexts.User.StaffRole.StaffRoleEntityReference { + return { + id, + roleName: `role-${id}`, + enterpriseAppRole: `role-${id}`, + isDefault: false, + roleType: null, + permissions: { + communityPermissions: { canManageCommunities: false }, + financePermissions: { canManageFinance: false }, + techAdminPermissions: { canManageTechAdmin: false }, + userPermissions: { canManageUsers: false }, + }, + createdAt: new Date(), + updatedAt: new Date(), + schemaVersion: '1.0', + } as unknown as Domain.Contexts.User.StaffRole.StaffRoleEntityReference; +} + +function makeMockStaffUserInstance(id: string): MockStaffUserInstance { + let _role: Domain.Contexts.User.StaffRole.StaffRoleEntityReference | undefined; + return { + id, + externalId: `ext-${id}`, + firstName: 'Test', + lastName: 'User', + email: 'test@example.com', + displayName: 'Test User', + accessBlocked: false, + tags: [], + userType: 'staff', + get role() { + return _role; + }, + set role(r: Domain.Contexts.User.StaffRole.StaffRoleEntityReference | undefined) { + _role = r; + }, + requestRoleAssignment: vi.fn().mockImplementation((r: Domain.Contexts.User.StaffRole.StaffRoleEntityReference) => { + _role = r; + }), + createdAt: new Date(), + updatedAt: new Date(), + schemaVersion: '1.0', + } as unknown as MockStaffUserInstance; +} + +function makeDataSources(overrides: { + staffUser?: MockStaffUserInstance; + staffRole?: Domain.Contexts.User.StaffRole.StaffRoleEntityReference | null; + savedUser?: Domain.Contexts.User.StaffUser.StaffUserEntityReference | undefined; + explicitUndefinedSave?: boolean; +}): DataSources & { _staffUserRepo: unknown; _staffRoleRepo: unknown } { + const staffUser = overrides.staffUser ?? makeMockStaffUserInstance('user-123'); + const { staffRole } = overrides; + const savedUser = overrides.explicitUndefinedSave ? undefined : (overrides.savedUser ?? (staffUser as unknown as Domain.Contexts.User.StaffUser.StaffUserEntityReference)); + + const staffUserRepo = { + get: vi.fn().mockResolvedValue(staffUser), + save: vi.fn().mockResolvedValue(savedUser), + } as unknown as Domain.Contexts.User.StaffUser.StaffUserRepository; + + const staffRoleRepo = { + getById: staffRole === null + ? vi.fn().mockResolvedValue(null) + : vi.fn().mockResolvedValue(staffRole), + } as unknown as Domain.Contexts.User.StaffRole.StaffRoleRepository; + + return { + domainDataSource: { + User: { + StaffUser: { + StaffUserUnitOfWork: { + withScopedTransaction: vi.fn().mockImplementation(async (cb: (repo: typeof staffUserRepo) => Promise) => { + await cb(staffUserRepo); + }), + }, + }, + StaffRole: { + StaffRoleUnitOfWork: { + withScopedTransaction: vi.fn().mockImplementation(async (cb: (repo: typeof staffRoleRepo) => Promise) => { + await cb(staffRoleRepo); + }), + }, + }, + }, + }, + _staffUserRepo: staffUserRepo, + _staffRoleRepo: staffRoleRepo, + } as unknown as DataSources & { _staffUserRepo: unknown; _staffRoleRepo: unknown }; +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +test.for(feature, ({ Scenario, BeforeEachScenario }) => { + let dataSources: DataSources & { _staffUserRepo?: unknown; _staffRoleRepo?: unknown }; + let command: StaffUserAssignRoleCommand; + let result: Domain.Contexts.User.StaffUser.StaffUserEntityReference | undefined; + let thrownError: unknown; + let staffUser: MockStaffUserInstance; + let staffRole: Domain.Contexts.User.StaffRole.StaffRoleEntityReference | null; + BeforeEachScenario(() => { + result = undefined; + thrownError = undefined; + staffUser = makeMockStaffUserInstance('user-123'); + staffRole = makeMockStaffRoleRef('role-456'); + command = { staffUserId: 'user-123', roleId: 'role-456', actorStaffUserId: 'actor-1' }; + }); + + // ─── Successfully assigns a role ────────────────────────────────────────── + + Scenario('Successfully assigns a role to an existing staff user', ({ Given, When, Then, And }) => { + Given('a staff user with id "user-123" exists', () => { + staffUser = makeMockStaffUserInstance('user-123'); + }); + + And('a staff role with id "role-456" exists', () => { + staffRole = makeMockStaffRoleRef('role-456'); + dataSources = makeDataSources({ staffUser, staffRole }); + command = { staffUserId: 'user-123', roleId: 'role-456', actorStaffUserId: 'actor-1' }; + }); + + When('I call assignRole with staffUserId "user-123" and roleId "role-456"', async () => { + try { + result = await assignRole(dataSources)(command); + } catch (e) { + thrownError = e; + } + }); + + Then('the staff user should be saved with the role assigned', () => { + const repo = dataSources._staffUserRepo as { save: ReturnType }; + expect(repo.save).toHaveBeenCalled(); + expect(staffUser.role).toBe(staffRole); + }); + + And('the result should be the updated staff user', () => { + expect(thrownError).toBeUndefined(); + expect(result).toBeDefined(); + expect(result?.id).toBe('user-123'); + }); + }); + + // ─── Role not found ─────────────────────────────────────────────────────── + + Scenario('Throws an error when the staff role does not exist', ({ Given, When, Then, And }) => { + Given('a staff user with id "user-123" exists', () => { + staffUser = makeMockStaffUserInstance('user-123'); + }); + + And('no staff role with id "role-999" exists in the repository', () => { + staffRole = null; + dataSources = makeDataSources({ staffUser, staffRole }); + command = { staffUserId: 'user-123', roleId: 'role-999', actorStaffUserId: 'actor-1' }; + }); + + When('I call assignRole with staffUserId "user-123" and roleId "role-999"', async () => { + try { + result = await assignRole(dataSources)(command); + } catch (e) { + thrownError = e; + } + }); + + Then('it should throw an error with message containing "role-999"', () => { + expect(thrownError).toBeDefined(); + expect((thrownError as Error).message).toContain('role-999'); + }); + }); + + // ─── Save returns undefined ─────────────────────────────────────────────── + + Scenario('Throws an error when the unit of work returns no result', ({ Given, When, Then, And }) => { + Given('a staff user with id "user-123" exists', () => { + staffUser = makeMockStaffUserInstance('user-123'); + }); + + And('a staff role with id "role-456" exists', () => { + staffRole = makeMockStaffRoleRef('role-456'); + }); + + And('saving the staff user returns undefined', () => { + dataSources = makeDataSources({ staffUser, staffRole, explicitUndefinedSave: true }); + command = { staffUserId: 'user-123', roleId: 'role-456', actorStaffUserId: 'actor-1' }; + }); + + When('I call assignRole with staffUserId "user-123" and roleId "role-456"', async () => { + try { + result = await assignRole(dataSources)(command); + } catch (e) { + thrownError = e; + } + }); + + Then('it should throw an error with message "Unable to assign role to staff user"', () => { + expect(thrownError).toBeDefined(); + expect((thrownError as Error).message).toBe('Unable to assign role to staff user'); + }); + }); +}); diff --git a/packages/ocom/application-services/src/contexts/user/staff-user/assign-role.ts b/packages/ocom/application-services/src/contexts/user/staff-user/assign-role.ts new file mode 100644 index 000000000..015eb6ff1 --- /dev/null +++ b/packages/ocom/application-services/src/contexts/user/staff-user/assign-role.ts @@ -0,0 +1,46 @@ +import type { Domain } from '@ocom/domain'; +import type { DataSources } from '@ocom/persistence'; + +export interface StaffUserAssignRoleCommand { + staffUserId: string; + roleId: string; + actorStaffUserId: string; +} + +export const assignRole = (dataSources: DataSources) => { + return async (command: StaffUserAssignRoleCommand): Promise => { + let result: Domain.Contexts.User.StaffUser.StaffUserEntityReference | undefined; + + await dataSources.domainDataSource.User.StaffUser.StaffUserUnitOfWork.withScopedTransaction(async (staffUserRepo) => { + const staffUser = await staffUserRepo.get(command.staffUserId); + + let role: Domain.Contexts.User.StaffRole.StaffRoleEntityReference | null = null; + await dataSources.domainDataSource.User.StaffRole.StaffRoleUnitOfWork.withScopedTransaction(async (staffRoleRepo) => { + role = await staffRoleRepo.getById(command.roleId); + }); + + if (!role) { + throw new Error(`StaffRole with id ${command.roleId} not found`); + } + + // Build a descriptive activity message including role name, target user and actor (fallback to IDs when names unavailable) + let actorDisplayName = command.actorStaffUserId; + try { + const actor = await staffUserRepo.get(command.actorStaffUserId); + if (actor?.displayName) actorDisplayName = actor.displayName; + } catch (_e) { + // ignore - use id fallback + } + const roleName = (role as unknown as { roleName?: string })?.roleName ?? command.roleId; + const description = `${roleName} assigned to ${staffUser.displayName} by ${actorDisplayName}`; + staffUser.requestRoleAssignment(role, description, command.actorStaffUserId); + result = await staffUserRepo.save(staffUser); + }); + + if (!result) { + throw new Error('Unable to assign role to staff user'); + } + + return result; + }; +}; diff --git a/packages/ocom/application-services/src/contexts/user/staff-user/create-if-not-exists.test.ts b/packages/ocom/application-services/src/contexts/user/staff-user/create-if-not-exists.test.ts index 573d541b9..d33e10c23 100644 --- a/packages/ocom/application-services/src/contexts/user/staff-user/create-if-not-exists.test.ts +++ b/packages/ocom/application-services/src/contexts/user/staff-user/create-if-not-exists.test.ts @@ -13,7 +13,7 @@ const feature = await loadFeature(path.resolve(__dirname, 'features/create-if-no // ─── Helpers ───────────────────────────────────────────────────────────────── -function makeMockStaffUserRef(externalId: string): Domain.Contexts.User.StaffUser.StaffUserEntityReference { +function makeMockStaffUserRef(externalId: string, overrides: Partial = {}): Domain.Contexts.User.StaffUser.StaffUserEntityReference { return { id: `id-${externalId}`, externalId, @@ -28,6 +28,7 @@ function makeMockStaffUserRef(externalId: string): Domain.Contexts.User.StaffUse createdAt: new Date(), updatedAt: new Date(), schemaVersion: '1.0', + ...overrides, } as unknown as Domain.Contexts.User.StaffUser.StaffUserEntityReference; } @@ -55,7 +56,7 @@ interface MockStaffUserInstance extends Domain.Contexts.User.StaffUser.StaffUser } function makeMockNewUser(externalId: string): MockStaffUserInstance { - let _role: Domain.Contexts.User.StaffRole.StaffRoleEntityReference | undefined = undefined; + let _role: Domain.Contexts.User.StaffRole.StaffRoleEntityReference | undefined; return { id: `new-id-${externalId}`, externalId, @@ -72,6 +73,10 @@ function makeMockNewUser(externalId: string): MockStaffUserInstance { set role(r: Domain.Contexts.User.StaffRole.StaffRoleEntityReference | undefined) { _role = r; }, + requestCreate: vi.fn(), + requestRoleAssignment: vi.fn().mockImplementation((r: Domain.Contexts.User.StaffRole.StaffRoleEntityReference) => { + _role = r; + }), createdAt: new Date(), updatedAt: new Date(), schemaVersion: '1.0', @@ -80,6 +85,7 @@ function makeMockNewUser(externalId: string): MockStaffUserInstance { function makeDataSources(overrides: { existingUser?: Domain.Contexts.User.StaffUser.StaffUserEntityReference | null; + existingUserByEmail?: Domain.Contexts.User.StaffUser.StaffUserEntityReference | null; newUser?: MockStaffUserInstance; savedUser?: Domain.Contexts.User.StaffUser.StaffUserEntityReference; roleByEnterpriseAppRole?: Record; @@ -90,6 +96,7 @@ function makeDataSources(overrides: { const staffUserRepo = { getByExternalId: vi.fn().mockResolvedValue(overrides.existingUser ?? null), + get: vi.fn().mockResolvedValue(overrides.existingUserByEmail ?? newUser), getNewInstance: vi.fn().mockResolvedValue(newUser), save: overrides.saveShouldFail ? vi.fn().mockResolvedValue(undefined) : vi.fn().mockResolvedValue(savedUser), delete: vi.fn(), @@ -124,6 +131,7 @@ function makeDataSources(overrides: { StaffUser: { StaffUserReadRepo: { getByExternalId: vi.fn().mockResolvedValue(overrides.existingUser ?? null), + getByEmail: vi.fn().mockResolvedValue(overrides.existingUserByEmail ?? null), }, }, }, @@ -191,6 +199,27 @@ test.for(feature, ({ Scenario, BeforeEachScenario }) => { result = await createIfNotExists(dataSources)(command); }); + Scenario('Updates externalId when user exists by email', ({ Given, When, Then, And }) => { + Given('a staff user with email "first@example.com" already exists', () => { + existingUser = makeMockStaffUserRef('ext-old', { email: 'first@example.com' }); + dataSources = makeDataSources({ existingUserByEmail: existingUser, savedUser: { ...existingUser, externalId: 'ext-new' } }); + command = { ...command, externalId: 'ext-new' }; + }); + + When('I call createIfNotExists with externalId "ext-new"', async () => { + result = await createIfNotExists(dataSources)(command); + }); + + Then('it should update the existing user\'s externalId', () => { + const repo = (dataSources as unknown as { _staffUserRepo: { save: ReturnType } })._staffUserRepo; + expect(repo.save).toHaveBeenCalled(); + }); + + And('it should return the updated user', () => { + expect(result?.externalId).toBe('ext-new'); + }); + }); + Then('it should return the existing user', () => { expect(result).toBe(existingUser); }); @@ -428,4 +457,123 @@ test.for(feature, ({ Scenario, BeforeEachScenario }) => { expect((thrownError as Error).message).toBe('Unable to create staff user'); }); }); + + // ─── Empty email skips email lookup ─────────────────────────────────────── + + Scenario('Creates a new user when email is empty', ({ Given, When, Then, And }) => { + Given('no staff user with externalId "ext-noemail" exists', () => { + newUser = makeMockNewUser('ext-noemail'); + dataSources = makeDataSources({ existingUser: null, newUser }); + command = { ...command, externalId: 'ext-noemail' }; + }); + + And('the command has an empty email', () => { + command = { ...command, email: '' }; + }); + + When('I call createIfNotExists with externalId "ext-noemail"', async () => { + result = await createIfNotExists(dataSources)(command); + }); + + Then('it should not check for an existing user by email', () => { + const readRepo = ( + dataSources as unknown as { + readonlyDataSource: { User: { StaffUser: { StaffUserReadRepo: { getByEmail: ReturnType } } } }; + } + ).readonlyDataSource.User.StaffUser.StaffUserReadRepo; + expect(readRepo.getByEmail).not.toHaveBeenCalled(); + }); + + And('it should return the newly created user', () => { + expect(result).toBeDefined(); + expect(result?.externalId).toBe('ext-noemail'); + }); + }); + + // ─── Email lookup returns null → create new user ────────────────────────── + + Scenario('Creates a new user when email lookup returns no match', ({ Given, When, Then, And }) => { + Given('no staff user with externalId "ext-nomatch" exists', () => { + newUser = makeMockNewUser('ext-nomatch'); + // existingUserByEmail: null means getByEmail resolves null + dataSources = makeDataSources({ existingUser: null, existingUserByEmail: null, newUser }); + command = { ...command, externalId: 'ext-nomatch', email: 'other@example.com' }; + }); + + And('a staff user with email "other@example.com" does not exist', () => { + // getByEmail will return null (set up in Given) + }); + + When('I call createIfNotExists with externalId "ext-nomatch"', async () => { + result = await createIfNotExists(dataSources)(command); + }); + + Then('it should create a new user', () => { + const repo = (dataSources as unknown as { _staffUserRepo: { getNewInstance: ReturnType } })._staffUserRepo; + expect(repo.getNewInstance).toHaveBeenCalledWith('ext-nomatch', 'First', 'Last', 'other@example.com'); + }); + + And('it should return the newly created user', () => { + expect(result).toBeDefined(); + expect(result?.externalId).toBe('ext-nomatch'); + }); + }); + + // ─── Email-update save returns undefined → throws ───────────────────────── + + Scenario('Throws when update of externalId fails to save', ({ Given, When, Then, And }) => { + Given('a staff user with email "first@example.com" already exists', () => { + existingUser = makeMockStaffUserRef('ext-old', { email: 'first@example.com' }); + // saveShouldFail makes save() resolve undefined, triggering the throw + dataSources = makeDataSources({ existingUser: null, existingUserByEmail: existingUser, saveShouldFail: true }); + command = { ...command, externalId: 'ext-updfail', email: 'first@example.com' }; + }); + + And('the update transaction save returns undefined', () => { + // already wired up in Given via saveShouldFail: true + }); + + When('I call createIfNotExists with externalId "ext-updfail"', async () => { + try { + await createIfNotExists(dataSources)(command); + } catch (error) { + thrownError = error; + } + }); + + Then('it should throw an error with message "Unable to update staff user externalId"', () => { + expect(thrownError).toBeInstanceOf(Error); + expect((thrownError as Error).message).toBe('Unable to update staff user externalId'); + }); + }); + + // ─── Non-NotFound error from role lookup propagates ─────────────────────── + + Scenario('Propagates non-NotFound errors from role lookup', ({ Given, When, Then, And }) => { + const dbError = new Error('Database connection failed'); + + Given('no staff user with externalId "ext-rolerr" exists', () => { + newUser = makeMockNewUser('ext-rolerr'); + dataSources = makeDataSources({ existingUser: null, newUser }); + // Override getDefaultRoleByEnterpriseAppRole to throw a non-NotFound error + (dataSources as unknown as { _staffRoleRepo: { getDefaultRoleByEnterpriseAppRole: ReturnType } })._staffRoleRepo.getDefaultRoleByEnterpriseAppRole.mockRejectedValue(dbError); + command = { ...command, externalId: 'ext-rolerr', aadRoles: ['Staff.CaseManager'] }; + }); + + And('the role repository throws a non-NotFound error for any AAD role', () => { + // already wired up in Given + }); + + When('I call createIfNotExists with externalId "ext-rolerr"', async () => { + try { + await createIfNotExists(dataSources)(command); + } catch (error) { + thrownError = error; + } + }); + + Then('it should propagate the role repository error', () => { + expect(thrownError).toBe(dbError); + }); + }); }); diff --git a/packages/ocom/application-services/src/contexts/user/staff-user/create-if-not-exists.ts b/packages/ocom/application-services/src/contexts/user/staff-user/create-if-not-exists.ts index 98b25204c..db6b4c843 100644 --- a/packages/ocom/application-services/src/contexts/user/staff-user/create-if-not-exists.ts +++ b/packages/ocom/application-services/src/contexts/user/staff-user/create-if-not-exists.ts @@ -40,6 +40,25 @@ export const createIfNotExists = (dataSources: DataSources) => { return existing; } + if (command.email) { + const existingByEmail = await dataSources.readonlyDataSource.User.StaffUser.StaffUserReadRepo.getByEmail(command.email); + if (existingByEmail) { + let updatedUser: Domain.Contexts.User.StaffUser.StaffUserEntityReference | undefined; + await dataSources.domainDataSource.User.StaffUser.StaffUserUnitOfWork.withTransaction( + DomainRuntime.PassportFactory.forSystem({ canManageStaffRolesAndPermissions: true }), + async (repository) => { + const staffUser = await repository.get(existingByEmail.id); + staffUser.externalId = command.externalId; + updatedUser = await repository.save(staffUser); + }, + ); + if (!updatedUser) { + throw new Error('Unable to update staff user externalId'); + } + return updatedUser; + } + } + // Ensure the 4 default roles exist before creating the user await createDefaultRoles(dataSources)(); @@ -50,8 +69,10 @@ export const createIfNotExists = (dataSources: DataSources) => { await dataSources.domainDataSource.User.StaffUser.StaffUserUnitOfWork.withTransaction(DomainRuntime.PassportFactory.forSystem({ canManageStaffRolesAndPermissions: true }), async (repository) => { const newUser = await repository.getNewInstance(command.externalId, command.firstName, command.lastName, command.email); + newUser.requestCreate(newUser.id); + if (matchingRole) { - newUser.role = matchingRole; + newUser.requestRoleAssignment(matchingRole, 'Role assigned on creation', newUser.id); } createdUser = await repository.save(newUser); diff --git a/packages/ocom/application-services/src/contexts/user/staff-user/features/assign-role.feature b/packages/ocom/application-services/src/contexts/user/staff-user/features/assign-role.feature new file mode 100644 index 000000000..0a105591d --- /dev/null +++ b/packages/ocom/application-services/src/contexts/user/staff-user/features/assign-role.feature @@ -0,0 +1,21 @@ +Feature: Assign role to staff user + + Scenario: Successfully assigns a role to an existing staff user + Given a staff user with id "user-123" exists + And a staff role with id "role-456" exists + When I call assignRole with staffUserId "user-123" and roleId "role-456" + Then the staff user should be saved with the role assigned + And the result should be the updated staff user + + Scenario: Throws an error when the staff role does not exist + Given a staff user with id "user-123" exists + And no staff role with id "role-999" exists in the repository + When I call assignRole with staffUserId "user-123" and roleId "role-999" + Then it should throw an error with message containing "role-999" + + Scenario: Throws an error when the unit of work returns no result + Given a staff user with id "user-123" exists + And a staff role with id "role-456" exists + And saving the staff user returns undefined + When I call assignRole with staffUserId "user-123" and roleId "role-456" + Then it should throw an error with message "Unable to assign role to staff user" diff --git a/packages/ocom/application-services/src/contexts/user/staff-user/features/create-if-not-exists.feature b/packages/ocom/application-services/src/contexts/user/staff-user/features/create-if-not-exists.feature index fb4c902e5..f14b421da 100644 --- a/packages/ocom/application-services/src/contexts/user/staff-user/features/create-if-not-exists.feature +++ b/packages/ocom/application-services/src/contexts/user/staff-user/features/create-if-not-exists.feature @@ -6,6 +6,12 @@ Feature: Create staff user if not exists Then it should return the existing user And it should not create a new user + Scenario: Updates externalId when user exists by email + Given a staff user with email "first@example.com" already exists + When I call createIfNotExists with externalId "ext-new" + Then it should update the existing user's externalId + And it should return the updated user + Scenario: Creates a new user when user does not exist Given no staff user with externalId "ext-456" exists And no matching AAD role is provided @@ -57,3 +63,29 @@ Feature: Create staff user if not exists Given no staff user with externalId "ext-err" exists When I call createIfNotExists with externalId "ext-err" Then it should throw an error with message "Unable to create staff user" + + Scenario: Creates a new user when email is empty + Given no staff user with externalId "ext-noemail" exists + And the command has an empty email + When I call createIfNotExists with externalId "ext-noemail" + Then it should not check for an existing user by email + And it should return the newly created user + + Scenario: Creates a new user when email lookup returns no match + Given no staff user with externalId "ext-nomatch" exists + And a staff user with email "other@example.com" does not exist + When I call createIfNotExists with externalId "ext-nomatch" + Then it should create a new user + And it should return the newly created user + + Scenario: Throws when update of externalId fails to save + Given a staff user with email "first@example.com" already exists + And the update transaction save returns undefined + When I call createIfNotExists with externalId "ext-updfail" + Then it should throw an error with message "Unable to update staff user externalId" + + Scenario: Propagates non-NotFound errors from role lookup + Given no staff user with externalId "ext-rolerr" exists + And the role repository throws a non-NotFound error for any AAD role + When I call createIfNotExists with externalId "ext-rolerr" + Then it should propagate the role repository error diff --git a/packages/ocom/application-services/src/contexts/user/staff-user/features/list.feature b/packages/ocom/application-services/src/contexts/user/staff-user/features/list.feature new file mode 100644 index 000000000..885343983 --- /dev/null +++ b/packages/ocom/application-services/src/contexts/user/staff-user/features/list.feature @@ -0,0 +1,11 @@ +Feature: List staff users + + Scenario: Returns all staff users when users exist + Given the repository contains two staff users + When I call list + Then it should return all staff users + + Scenario: Returns an empty list when no staff users exist + Given the repository contains no staff users + When I call list + Then it should return an empty list diff --git a/packages/ocom/application-services/src/contexts/user/staff-user/index.ts b/packages/ocom/application-services/src/contexts/user/staff-user/index.ts index 2c5b0d00b..1e41957c9 100644 --- a/packages/ocom/application-services/src/contexts/user/staff-user/index.ts +++ b/packages/ocom/application-services/src/contexts/user/staff-user/index.ts @@ -1,16 +1,22 @@ import type { Domain } from '@ocom/domain'; import type { DataSources } from '@ocom/persistence'; +import { assignRole, type StaffUserAssignRoleCommand } from './assign-role.ts'; import { createIfNotExists, type StaffUserCreateIfNotExistsCommand } from './create-if-not-exists.ts'; +import { list } from './list.ts'; import { queryByExternalId, type StaffUserQueryByExternalIdCommand } from './query-by-external-id.ts'; export interface StaffUserApplicationService { + assignRole: (command: StaffUserAssignRoleCommand) => Promise; createIfNotExists: (command: StaffUserCreateIfNotExistsCommand) => Promise; + list: () => Promise; queryByExternalId: (command: StaffUserQueryByExternalIdCommand) => Promise; } export const StaffUser = (dataSources: DataSources): StaffUserApplicationService => { return { + assignRole: assignRole(dataSources), createIfNotExists: createIfNotExists(dataSources), + list: list(dataSources), queryByExternalId: queryByExternalId(dataSources), }; }; diff --git a/packages/ocom/application-services/src/contexts/user/staff-user/list.test.ts b/packages/ocom/application-services/src/contexts/user/staff-user/list.test.ts new file mode 100644 index 000000000..d3799d65e --- /dev/null +++ b/packages/ocom/application-services/src/contexts/user/staff-user/list.test.ts @@ -0,0 +1,104 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describeFeature, loadFeature } from '@amiceli/vitest-cucumber'; +import type { Domain } from '@ocom/domain'; +import type { DataSources } from '@ocom/persistence'; +import { expect, vi } from 'vitest'; +import { list } from './list.ts'; + +const test = { for: describeFeature }; +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const feature = await loadFeature(path.resolve(__dirname, 'features/list.feature')); + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function makeMockStaffUserRef(id: string, firstName: string): Domain.Contexts.User.StaffUser.StaffUserEntityReference { + return { + id, + externalId: `ext-${id}`, + firstName, + lastName: 'Doe', + displayName: `${firstName} Doe`, + email: `${firstName.toLowerCase()}@example.com`, + accessBlocked: false, + tags: [], + userType: 'staff', + role: undefined, + createdAt: new Date(), + updatedAt: new Date(), + schemaVersion: '1.0', + } as unknown as Domain.Contexts.User.StaffUser.StaffUserEntityReference; +} + +function makeDataSources(users: Domain.Contexts.User.StaffUser.StaffUserEntityReference[]): DataSources { + return { + readonlyDataSource: { + User: { + StaffUser: { + StaffUserReadRepo: { + getAll: vi.fn().mockResolvedValue(users), + }, + }, + }, + }, + } as unknown as DataSources; +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +test.for(feature, ({ Scenario, BeforeEachScenario }) => { + let dataSources: DataSources; + let result: Domain.Contexts.User.StaffUser.StaffUserEntityReference[] | undefined; + let thrownError: unknown; + + BeforeEachScenario(() => { + result = undefined; + thrownError = undefined; + }); + + // ─── Users exist ────────────────────────────────────────────────────────── + + Scenario('Returns all staff users when users exist', ({ Given, When, Then }) => { + Given('the repository contains two staff users', () => { + const users = [makeMockStaffUserRef('user-001', 'Alice'), makeMockStaffUserRef('user-002', 'Bob')]; + dataSources = makeDataSources(users); + }); + + When('I call list', async () => { + try { + result = await list(dataSources)(); + } catch (e) { + thrownError = e; + } + }); + + Then('it should return all staff users', () => { + expect(thrownError).toBeUndefined(); + expect(result).toHaveLength(2); + const [first, second] = result as Domain.Contexts.User.StaffUser.StaffUserEntityReference[]; + expect(first?.id).toBe('user-001'); + expect(second?.id).toBe('user-002'); + }); + }); + + // ─── No users ───────────────────────────────────────────────────────────── + + Scenario('Returns an empty list when no staff users exist', ({ Given, When, Then }) => { + Given('the repository contains no staff users', () => { + dataSources = makeDataSources([]); + }); + + When('I call list', async () => { + try { + result = await list(dataSources)(); + } catch (e) { + thrownError = e; + } + }); + + Then('it should return an empty list', () => { + expect(thrownError).toBeUndefined(); + expect(result).toHaveLength(0); + }); + }); +}); diff --git a/packages/ocom/application-services/src/contexts/user/staff-user/list.ts b/packages/ocom/application-services/src/contexts/user/staff-user/list.ts new file mode 100644 index 000000000..b6cbc1463 --- /dev/null +++ b/packages/ocom/application-services/src/contexts/user/staff-user/list.ts @@ -0,0 +1,8 @@ +import type { Domain } from '@ocom/domain'; +import type { DataSources } from '@ocom/persistence'; + +export const list = (dataSources: DataSources) => { + return async (): Promise => { + return await dataSources.readonlyDataSource.User.StaffUser.StaffUserReadRepo.getAll(); + }; +}; diff --git a/packages/ocom/data-sources-mongoose-models/src/models/role/staff-role.model.ts b/packages/ocom/data-sources-mongoose-models/src/models/role/staff-role.model.ts index da291d9ed..9cd1314a4 100644 --- a/packages/ocom/data-sources-mongoose-models/src/models/role/staff-role.model.ts +++ b/packages/ocom/data-sources-mongoose-models/src/models/role/staff-role.model.ts @@ -66,6 +66,17 @@ export interface StaffRoleTechAdminPermissions { export interface StaffRoleUserPermissions { id?: ObjectId; canManageUsers: boolean; + canAssignStaffRoles: boolean; + canAssignStaffUserRoles?: boolean; + canViewStaffUsers: boolean; +} + +export interface StaffRoleRolePermissions { + id?: ObjectId; + canViewRoles: boolean; + canAddRole: boolean; + canEditRole: boolean; + canRemoveRole: boolean; } export interface StaffRolePermissions { @@ -77,6 +88,7 @@ export interface StaffRolePermissions { financePermissions: StaffRoleFinancePermissions; techAdminPermissions: StaffRoleTechAdminPermissions; userPermissions: StaffRoleUserPermissions; + staffRolePermissions: StaffRoleRolePermissions; propertyPermissions: StaffRolePropertyPermissions; } @@ -148,7 +160,16 @@ const StaffRoleSchema = new Schema, StaffRole>( } as SchemaDefinition, userPermissions: { canManageUsers: { type: Boolean, required: true, default: false }, + canAssignStaffRoles: { type: Boolean, required: true, default: false }, + canAssignStaffUserRoles: { type: Boolean, required: true, default: false }, + canViewStaffUsers: { type: Boolean, required: true, default: false }, } as SchemaDefinition, + staffRolePermissions: { + canViewRoles: { type: Boolean, required: true, default: false }, + canAddRole: { type: Boolean, required: true, default: false }, + canEditRole: { type: Boolean, required: true, default: false }, + canRemoveRole: { type: Boolean, required: true, default: false }, + } as SchemaDefinition, propertyPermissions: { canManageProperties: { type: Boolean, required: true, default: false }, canEditOwnProperty: { type: Boolean, required: true, default: false }, diff --git a/packages/ocom/data-sources-mongoose-models/src/models/user/staff-user.model.ts b/packages/ocom/data-sources-mongoose-models/src/models/user/staff-user.model.ts index 4083e1f38..8420b3f9b 100644 --- a/packages/ocom/data-sources-mongoose-models/src/models/user/staff-user.model.ts +++ b/packages/ocom/data-sources-mongoose-models/src/models/user/staff-user.model.ts @@ -1,8 +1,42 @@ -import { type Model, type ObjectId, type PopulatedDoc, Schema } from 'mongoose'; +import type { MongooseSeedwork } from '@cellix/mongoose-seedwork'; +import { type Model, type ObjectId, type PopulatedDoc, Schema, type Types } from 'mongoose'; import { Patterns } from '../../patterns.ts'; import * as StaffRole from '../role/staff-role.model.ts'; import { type User, type UserModelType, userOptions } from './user.model.ts'; +export interface StaffUserActivityDetail extends MongooseSeedwork.SubdocumentBase { + activityType: string; + activityDescription: string; + activityBy: ObjectId; + readonly createdAt: Date; + readonly updatedAt: Date; +} + +const StaffUserActivityDetailSchema = new Schema, StaffUserActivityDetail>( + { + activityType: { + type: String, + required: true, + enum: ['CREATED', 'UPDATED', 'ROLE_ASSIGNED', 'ROLE_REMOVED', 'BLOCKED', 'UNBLOCKED'], + }, + activityDescription: { + type: String, + maxlength: 2000, + required: true, + }, + activityBy: { + type: Schema.Types.ObjectId, + ref: 'staff-user', + required: true, + index: true, + }, + }, + { + timestamps: true, + versionKey: 'version', + }, +); + export interface StaffUser extends User { role?: PopulatedDoc | ObjectId; firstName: string; @@ -14,6 +48,7 @@ export interface StaffUser extends User { userType?: string; accessBlocked: boolean; tags?: string[]; + activityLog: Types.DocumentArray; } const StaffUserSchema = new Schema, StaffUser>( @@ -67,6 +102,7 @@ const StaffUserSchema = new Schema, StaffUser>( type: [String], required: false, }, + activityLog: [StaffUserActivityDetailSchema], }, userOptions, ).index({ email: 1 }, { sparse: true }); diff --git a/packages/ocom/domain/src/domain/contexts/user/staff-role/features/staff-role.feature b/packages/ocom/domain/src/domain/contexts/user/staff-role/features/staff-role.feature index 0dad1edde..011865a5d 100644 --- a/packages/ocom/domain/src/domain/contexts/user/staff-role/features/staff-role.feature +++ b/packages/ocom/domain/src/domain/contexts/user/staff-role/features/staff-role.feature @@ -12,12 +12,12 @@ Feature: StaffRole # roleName Scenario: Changing the roleName with permission to manage staff roles Given a StaffRole aggregate with permission to manage staff roles and permissions - When I set the roleName to "Manager" + When I set the roleName to "manager" Then the staff role's roleName should be "Manager" Scenario: Changing the roleName with system account permission Given a StaffRole aggregate with system account permission - When I set the roleName to "Manager" + When I set the roleName to "manager" Then the staff role's roleName should be "Manager" Scenario: Changing the roleName without permission @@ -79,6 +79,84 @@ Feature: StaffRole And the updatedAt property should return the correct date And the schemaVersion property should return the correct version + # enterpriseAppRole + Scenario: Getting the enterpriseAppRole property + Given a StaffRole aggregate with permission to manage staff roles and permissions + Then the enterpriseAppRole should return the initial value + + Scenario: Changing the enterpriseAppRole with permission to manage staff roles + Given a StaffRole aggregate with permission to manage staff roles and permissions + When I set the enterpriseAppRole to "Staff.CaseManager" + Then the staff role's enterpriseAppRole should be "Staff.CaseManager" + + Scenario: Changing the enterpriseAppRole with system account permission + Given a StaffRole aggregate with system account permission + When I set the enterpriseAppRole to "Staff.Finance" + Then the staff role's enterpriseAppRole should be "Staff.Finance" + + Scenario: Changing the enterpriseAppRole without permission + Given a StaffRole aggregate without permission to manage staff roles and permissions or system account + When I try to set the enterpriseAppRole to "Staff.CaseManager" + Then a PermissionError should be thrown for enterpriseAppRole + + Scenario: Changing the enterpriseAppRole to an invalid value + Given a StaffRole aggregate with permission to manage staff roles and permissions + When I try to set the enterpriseAppRole to an invalid value + Then an error should be thrown for the invalid enterpriseAppRole + + # getDefaultRoleNames + Scenario: Getting the list of default role names + When I call getDefaultRoleNames + Then it should return the four canonical default role name strings + + # default factory methods + Scenario: Creating a new default Case Manager role + When I call getNewDefaultCaseManagerInstance + Then the role name should be "Default Case Manager" + And the enterpriseAppRole should be "Staff.CaseManager" + And isDefault should be true + And community canManageCommunities should be true + And community canManageStaffRolesAndPermissions should be true + And finance canManageFinance should be false + And techAdmin canManageTechAdmin should be false + And user canManageUsers should be true + And user canAssignStaffUserRoles should be true + + Scenario: Creating a new default Service Line Owner role + When I call getNewDefaultServiceLineOwnerInstance + Then the role name should be "Default Service Line Owner" + And the enterpriseAppRole should be "Staff.ServiceLineOwner" + And isDefault should be true + And community canManageCommunities should be true + And community canManageStaffRolesAndPermissions should be true + And finance canManageFinance should be false + And techAdmin canManageTechAdmin should be false + And user canManageUsers should be true + And user canAssignStaffUserRoles should be true + + Scenario: Creating a new default Finance role + When I call getNewDefaultFinanceInstance + Then the role name should be "Default Finance" + And the enterpriseAppRole should be "Staff.Finance" + And isDefault should be true + And community canManageCommunities should be false + And community canManageStaffRolesAndPermissions should be true + And finance canManageFinance should be true + And techAdmin canManageTechAdmin should be false + And user canManageUsers should be true + And user canAssignStaffUserRoles should be true + + Scenario: Creating a new default Tech Admin role + When I call getNewDefaultTechAdminInstance + Then the role name should be "Default Tech Admin" + And the enterpriseAppRole should be "Staff.TechAdmin" + And isDefault should be true + And community canManageCommunities should be true + And community canManageStaffRolesAndPermissions should be true + And finance canManageFinance should be true + And techAdmin canManageTechAdmin should be true + And user canManageUsers should be true + And user canAssignStaffUserRoles should be true # getDefaultRoleNames Scenario: Getting default role names When I call getDefaultRoleNames diff --git a/packages/ocom/domain/src/domain/contexts/user/staff-role/features/staff-role.repository.feature b/packages/ocom/domain/src/domain/contexts/user/staff-role/features/staff-role.repository.feature new file mode 100644 index 000000000..109af4d0c --- /dev/null +++ b/packages/ocom/domain/src/domain/contexts/user/staff-role/features/staff-role.repository.feature @@ -0,0 +1,41 @@ +Feature: StaffRoleRepository contract + + Background: + Given a mock implementation of StaffRoleRepository that satisfies the full interface + + Scenario: getNewInstance resolves with a StaffRole for the given name + When I call getNewInstance with name "Supervisor" + Then it should resolve with a StaffRole whose roleName is "Supervisor" + And the StaffRole isDefault should be false + + Scenario: getNewDefaultCaseManagerInstance resolves with a default CaseManager role + When I call getNewDefaultCaseManagerInstance + Then it should resolve with a StaffRole whose roleName is "Default Case Manager" + And the StaffRole isDefault should be true + + Scenario: getNewDefaultServiceLineOwnerInstance resolves with a default ServiceLineOwner role + When I call getNewDefaultServiceLineOwnerInstance + Then it should resolve with a StaffRole whose roleName is "Default Service Line Owner" + And the StaffRole isDefault should be true + + Scenario: getNewDefaultFinanceInstance resolves with a default Finance role + When I call getNewDefaultFinanceInstance + Then it should resolve with a StaffRole whose roleName is "Default Finance" + And the StaffRole isDefault should be true + + Scenario: getNewDefaultTechAdminInstance resolves with a default TechAdmin role + When I call getNewDefaultTechAdminInstance + Then it should resolve with a StaffRole whose roleName is "Default Tech Admin" + And the StaffRole isDefault should be true + + Scenario: getById resolves with a StaffRole for a known id + When I call getById with "role-1" + Then it should resolve with a StaffRole whose id is "role-1" + + Scenario: getByRoleName resolves with a StaffRole for a known roleName + When I call getByRoleName with "Manager" + Then it should resolve with a StaffRole whose roleName is "Manager" + + Scenario: getDefaultRoleByEnterpriseAppRole resolves with a default StaffRole + When I call getDefaultRoleByEnterpriseAppRole with "Staff.CaseManager" + Then it should resolve with a StaffRole whose isDefault is true diff --git a/packages/ocom/domain/src/domain/contexts/user/staff-role/index.ts b/packages/ocom/domain/src/domain/contexts/user/staff-role/index.ts index 0724acbd8..3901acc8c 100644 --- a/packages/ocom/domain/src/domain/contexts/user/staff-role/index.ts +++ b/packages/ocom/domain/src/domain/contexts/user/staff-role/index.ts @@ -14,6 +14,10 @@ export type { StaffRoleFinancePermissionsEntityReference, StaffRoleFinancePermissionsProps, } from './staff-role-finance-permissions.ts'; +export type { + StaffRoleRolePermissionsEntityReference, + StaffRoleRolePermissionsProps, +} from './staff-role-role-permissions.ts'; export type { StaffRolePermissionsEntityReference, StaffRolePermissionsProps, diff --git a/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-defaults.test.ts b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-defaults.test.ts index 3314befb7..892ef967d 100644 --- a/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-defaults.test.ts +++ b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-defaults.test.ts @@ -74,9 +74,11 @@ test('applyDefaultSpec sets CaseManager permissions correctly and marks default' const role = StaffRole.getNewDefaultCaseManagerInstance(makeBaseProps(), passport); expect(role.permissions.communityPermissions.canManageCommunities).toBe(true); + expect(role.permissions.communityPermissions.canManageStaffRolesAndPermissions).toBe(true); expect(role.permissions.financePermissions.canManageFinance).toBe(false); expect(role.permissions.techAdminPermissions.canManageTechAdmin).toBe(false); expect(role.permissions.userPermissions.canManageUsers).toBe(true); + expect(role.permissions.userPermissions.canAssignStaffUserRoles).toBe(true); expect(role.isDefault).toBe(true); }); @@ -85,9 +87,11 @@ test('applyDefaultSpec sets Finance permissions correctly and marks default', () const role = StaffRole.getNewDefaultFinanceInstance(makeBaseProps(), passport); expect(role.permissions.communityPermissions.canManageCommunities).toBe(false); + expect(role.permissions.communityPermissions.canManageStaffRolesAndPermissions).toBe(true); expect(role.permissions.financePermissions.canManageFinance).toBe(true); expect(role.permissions.techAdminPermissions.canManageTechAdmin).toBe(false); - expect(role.permissions.userPermissions.canManageUsers).toBe(false); + expect(role.permissions.userPermissions.canManageUsers).toBe(true); + expect(role.permissions.userPermissions.canAssignStaffUserRoles).toBe(true); expect(role.isDefault).toBe(true); }); @@ -96,9 +100,11 @@ test('applyDefaultSpec sets ServiceLineOwner permissions correctly and marks def const role = StaffRole.getNewDefaultServiceLineOwnerInstance(makeBaseProps(), passport); expect(role.permissions.communityPermissions.canManageCommunities).toBe(true); + expect(role.permissions.communityPermissions.canManageStaffRolesAndPermissions).toBe(true); expect(role.permissions.financePermissions.canManageFinance).toBe(false); expect(role.permissions.techAdminPermissions.canManageTechAdmin).toBe(false); expect(role.permissions.userPermissions.canManageUsers).toBe(true); + expect(role.permissions.userPermissions.canAssignStaffUserRoles).toBe(true); expect(role.isDefault).toBe(true); }); @@ -112,5 +118,6 @@ test('applyDefaultSpec sets TechAdmin permissions correctly and marks default', expect(role.permissions.financePermissions.canManageFinance).toBe(true); expect(role.permissions.techAdminPermissions.canManageTechAdmin).toBe(true); expect(role.permissions.userPermissions.canManageUsers).toBe(true); + expect(role.permissions.userPermissions.canAssignStaffUserRoles).toBe(true); expect(role.isDefault).toBe(true); }); diff --git a/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-permissions.test.ts b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-permissions.test.ts index c73dcecf7..172327a10 100644 --- a/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-permissions.test.ts +++ b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-permissions.test.ts @@ -11,6 +11,7 @@ import { StaffRoleServiceTicketPermissions } from './staff-role-service-ticket-p import { StaffRoleTechAdminPermissions } from './staff-role-tech-admin-permissions.ts'; import { StaffRoleUserPermissions } from './staff-role-user-permissions.ts'; import { StaffRoleViolationTicketPermissions } from './staff-role-violation-ticket-permissions.ts'; +import type { StaffRoleRolePermissions } from './staff-role-role-permissions.ts'; const test = { for: describeFeature }; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -32,6 +33,7 @@ function makeProps() { financePermissions: {} as StaffRoleFinancePermissions, techAdminPermissions: {} as StaffRoleTechAdminPermissions, userPermissions: {} as StaffRoleUserPermissions, + staffRolePermissions: {} as StaffRoleRolePermissions, }; } diff --git a/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-permissions.ts b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-permissions.ts index 7e45a39f6..3c92246f6 100644 --- a/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-permissions.ts +++ b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-permissions.ts @@ -6,6 +6,7 @@ import { StaffRoleFinancePermissions, type StaffRoleFinancePermissionsEntityRefe import { StaffRolePropertyPermissions, type StaffRolePropertyPermissionsEntityReference, type StaffRolePropertyPermissionsProps } from './staff-role-property-permissions.ts'; import { StaffRoleServicePermissions, type StaffRoleServicePermissionsEntityReference, type StaffRoleServicePermissionsProps } from './staff-role-service-permissions.ts'; import { StaffRoleServiceTicketPermissions, type StaffRoleServiceTicketPermissionsEntityReference, type StaffRoleServiceTicketPermissionsProps } from './staff-role-service-ticket-permissions.ts'; +import { StaffRoleRolePermissions, type StaffRoleRolePermissionsEntityReference, type StaffRoleRolePermissionsProps } from './staff-role-role-permissions.ts'; import { StaffRoleTechAdminPermissions, type StaffRoleTechAdminPermissionsEntityReference, type StaffRoleTechAdminPermissionsProps } from './staff-role-tech-admin-permissions.ts'; import { StaffRoleUserPermissions, type StaffRoleUserPermissionsEntityReference, type StaffRoleUserPermissionsProps } from './staff-role-user-permissions.ts'; import { StaffRoleViolationTicketPermissions, type StaffRoleViolationTicketPermissionsEntityReference, type StaffRoleViolationTicketPermissionsProps } from './staff-role-violation-ticket-permissions.ts'; @@ -19,13 +20,14 @@ export interface StaffRolePermissionsProps extends ValueObjectProps { readonly financePermissions: StaffRoleFinancePermissionsProps; readonly techAdminPermissions: StaffRoleTechAdminPermissionsProps; readonly userPermissions: StaffRoleUserPermissionsProps; + readonly staffRolePermissions: StaffRoleRolePermissionsProps; } export interface StaffRolePermissionsEntityReference extends Readonly< Omit< StaffRolePermissionsProps, - 'communityPermissions' | 'propertyPermissions' | 'serviceTicketPermissions' | 'servicePermissions' | 'violationTicketPermissions' | 'financePermissions' | 'techAdminPermissions' | 'userPermissions' + 'communityPermissions' | 'propertyPermissions' | 'serviceTicketPermissions' | 'servicePermissions' | 'violationTicketPermissions' | 'financePermissions' | 'techAdminPermissions' | 'userPermissions' | 'staffRolePermissions' > > { readonly communityPermissions: StaffRoleCommunityPermissionsEntityReference; @@ -36,6 +38,7 @@ export interface StaffRolePermissionsEntityReference readonly financePermissions: StaffRoleFinancePermissionsEntityReference; readonly techAdminPermissions: StaffRoleTechAdminPermissionsEntityReference; readonly userPermissions: StaffRoleUserPermissionsEntityReference; + readonly staffRolePermissions: StaffRoleRolePermissionsEntityReference; } export class StaffRolePermissions extends ValueObject implements StaffRolePermissionsEntityReference { @@ -47,27 +50,97 @@ export class StaffRolePermissions extends ValueObject } get communityPermissions(): StaffRoleCommunityPermissions { - return new StaffRoleCommunityPermissions(this.props.communityPermissions, this.visa); + return new StaffRoleCommunityPermissions( + this.props.communityPermissions ?? { + canManageCommunities: false, + canManageStaffRolesAndPermissions: false, + canManageAllCommunities: false, + canDeleteCommunities: false, + canChangeCommunityOwner: false, + canReIndexSearchCollections: false, + }, + this.visa, + ); } get propertyPermissions(): StaffRolePropertyPermissions { - return new StaffRolePropertyPermissions(this.props.propertyPermissions, this.visa); + return new StaffRolePropertyPermissions( + this.props.propertyPermissions ?? { + canManageProperties: false, + canEditOwnProperty: false, + }, + this.visa, + ); } get serviceTicketPermissions(): StaffRoleServiceTicketPermissions { - return new StaffRoleServiceTicketPermissions(this.props.serviceTicketPermissions, this.visa); + return new StaffRoleServiceTicketPermissions( + this.props.serviceTicketPermissions ?? { + canCreateTickets: false, + canManageTickets: false, + canAssignTickets: false, + canUpdateTickets: false, + canWorkOnTickets: false, + }, + this.visa, + ); } get servicePermissions(): StaffRoleServicePermissions { - return new StaffRoleServicePermissions(this.props.servicePermissions, this.visa); + return new StaffRoleServicePermissions(this.props.servicePermissions ?? { canManageServices: false }, this.visa); } get violationTicketPermissions(): StaffRoleViolationTicketPermissions { - return new StaffRoleViolationTicketPermissions(this.props.violationTicketPermissions, this.visa); + return new StaffRoleViolationTicketPermissions( + this.props.violationTicketPermissions ?? { + canCreateTickets: false, + canManageTickets: false, + canAssignTickets: false, + canUpdateTickets: false, + canWorkOnTickets: false, + }, + this.visa, + ); } get financePermissions(): StaffRoleFinancePermissions { - return new StaffRoleFinancePermissions(this.props.financePermissions, this.visa); + return new StaffRoleFinancePermissions( + this.props.financePermissions ?? { + canManageFinance: false, + canViewGLBatchSummaries: false, + canViewFinanceConfigs: false, + canCreateFinanceConfigs: false, + }, + this.visa, + ); } get techAdminPermissions(): StaffRoleTechAdminPermissions { - return new StaffRoleTechAdminPermissions(this.props.techAdminPermissions, this.visa); + return new StaffRoleTechAdminPermissions( + this.props.techAdminPermissions ?? { + canManageTechAdmin: false, + canViewDatabaseExplorer: false, + canViewBlobExplorer: false, + canViewQueueDashboard: false, + canSendQueueMessages: false, + }, + this.visa, + ); } get userPermissions(): StaffRoleUserPermissions { - return new StaffRoleUserPermissions(this.props.userPermissions, this.visa); + return new StaffRoleUserPermissions( + this.props.userPermissions ?? { + canManageUsers: false, + canAssignStaffRoles: false, + canAssignStaffUserRoles: false, + canViewStaffUsers: false, + }, + this.visa, + ); + } + get staffRolePermissions(): StaffRoleRolePermissions { + return new StaffRoleRolePermissions( + this.props.staffRolePermissions ?? { + canViewRoles: false, + canAddRole: false, + canEditRole: false, + canRemoveRole: false, + }, + this.visa, + ); } } diff --git a/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-role-permissions.ts b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-role-permissions.ts new file mode 100644 index 000000000..c4dbcc81f --- /dev/null +++ b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-role-permissions.ts @@ -0,0 +1,61 @@ +import { PermissionError } from '@cellix/domain-seedwork/domain-entity'; +import type { ValueObjectProps } from '@cellix/domain-seedwork/value-object'; +import { ValueObject } from '@cellix/domain-seedwork/value-object'; +import type { UserVisa } from '../user.visa.ts'; + +interface StaffRoleRolePermissionsSpec { + canViewRoles: boolean; + canAddRole: boolean; + canEditRole: boolean; + canRemoveRole: boolean; +} + +export interface StaffRoleRolePermissionsProps extends StaffRoleRolePermissionsSpec, ValueObjectProps {} +export interface StaffRoleRolePermissionsEntityReference extends Readonly {} + +export class StaffRoleRolePermissions extends ValueObject implements StaffRoleRolePermissionsEntityReference { + private readonly visa: UserVisa; + + constructor(props: StaffRoleRolePermissionsProps, visa: UserVisa) { + super(props); + this.visa = visa; + } + + private validateVisa() { + if (!this.visa.determineIf((permissions) => permissions.canManageStaffRolesAndPermissions || permissions.isSystemAccount)) { + throw new PermissionError('Cannot set permission'); + } + } + + get canViewRoles(): boolean { + return this.props.canViewRoles ?? false; + } + set canViewRoles(value: boolean) { + this.validateVisa(); + this.props.canViewRoles = value; + } + + get canAddRole(): boolean { + return this.props.canAddRole ?? false; + } + set canAddRole(value: boolean) { + this.validateVisa(); + this.props.canAddRole = value; + } + + get canEditRole(): boolean { + return this.props.canEditRole ?? false; + } + set canEditRole(value: boolean) { + this.validateVisa(); + this.props.canEditRole = value; + } + + get canRemoveRole(): boolean { + return this.props.canRemoveRole ?? false; + } + set canRemoveRole(value: boolean) { + this.validateVisa(); + this.props.canRemoveRole = value; + } +} diff --git a/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-user-permissions.test.ts b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-user-permissions.test.ts index 969d1e7ab..196eb3e01 100644 --- a/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-user-permissions.test.ts +++ b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-user-permissions.test.ts @@ -18,6 +18,9 @@ function makeVisa({ canManageStaffRolesAndPermissions = true, isSystemAccount = function makeProps(overrides = {}) { return { canManageUsers: false, + canAssignStaffUserRoles: false, + canAssignStaffRoles: false, + canViewStaffUsers: false, ...overrides, }; } diff --git a/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-user-permissions.ts b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-user-permissions.ts index 358c7a7c4..11ee2f554 100644 --- a/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-user-permissions.ts +++ b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role-user-permissions.ts @@ -5,6 +5,9 @@ import type { UserVisa } from '../user.visa.ts'; interface StaffRoleUserPermissionsSpec { canManageUsers: boolean; + canAssignStaffRoles: boolean; + canAssignStaffUserRoles: boolean; + canViewStaffUsers: boolean; } export interface StaffRoleUserPermissionsProps extends StaffRoleUserPermissionsSpec, ValueObjectProps {} @@ -31,4 +34,30 @@ export class StaffRoleUserPermissions extends ValueObject ({ + determineIf: (fn: (p: { canManageStaffRolesAndPermissions: boolean; isSystemAccount: boolean }) => boolean) => + fn({ canManageStaffRolesAndPermissions: true, isSystemAccount: false }), + }), + }, + } as unknown as Passport; +} + +function makeBaseProps(overrides: Partial = {}): StaffRoleProps { + return { + id: 'role-1', + roleName: 'Support', + isDefault: false, + enterpriseAppRole: '', + permissions: { + communityPermissions: { + canManageCommunities: false, + canManageStaffRolesAndPermissions: false, + canManageAllCommunities: false, + canDeleteCommunities: false, + canChangeCommunityOwner: false, + canReIndexSearchCollections: false, + }, + propertyPermissions: { canManageProperties: false, canEditOwnProperty: false }, + servicePermissions: { canManageServices: false }, + serviceTicketPermissions: { canCreateTickets: false, canManageTickets: false, canAssignTickets: false, canWorkOnTickets: false }, + violationTicketPermissions: { canCreateTickets: false, canManageTickets: false, canAssignTickets: false, canWorkOnTickets: false }, + financePermissions: { canManageFinance: false, canViewGLBatchSummaries: false, canViewFinanceConfigs: false, canCreateFinanceConfigs: false }, + techAdminPermissions: { canManageTechAdmin: false }, + userPermissions: { canManageUsers: false, canAssignStaffUserRoles: false }, + } as unknown as StaffRoleProps['permissions'], + roleType: 'staff-role', + createdAt: new Date(), + updatedAt: new Date(), + schemaVersion: '1.0', + ...overrides, + }; +} + +/** + * Factory that builds a mock StaffRoleRepository whose methods delegate to + * actual StaffRole static factory methods, satisfying the full interface contract. + */ +function makeMockRepository(passport: Passport): StaffRoleRepository { + return { + // From Repository> + get: vi.fn(async (id: string) => new StaffRole(makeBaseProps({ id }), passport)), + save: vi.fn(async (item: StaffRole) => item), + + // Domain-specific methods + getNewInstance: vi.fn(async (name: string) => + StaffRole.getNewInstance(makeBaseProps({ roleName: name }), passport, name, false), + ), + getNewDefaultCaseManagerInstance: vi.fn(async () => + StaffRole.getNewDefaultCaseManagerInstance(makeBaseProps({ roleName: 'Default.CaseManager', isDefault: true }), passport), + ), + getNewDefaultServiceLineOwnerInstance: vi.fn(async () => + StaffRole.getNewDefaultServiceLineOwnerInstance(makeBaseProps({ roleName: 'Default.ServiceLineOwner', isDefault: true }), passport), + ), + getNewDefaultFinanceInstance: vi.fn(async () => + StaffRole.getNewDefaultFinanceInstance(makeBaseProps({ roleName: 'Default.Finance', isDefault: true }), passport), + ), + getNewDefaultTechAdminInstance: vi.fn(async () => + StaffRole.getNewDefaultTechAdminInstance(makeBaseProps({ roleName: 'Default.TechAdmin', isDefault: true }), passport), + ), + getById: vi.fn(async (id: string) => new StaffRole(makeBaseProps({ id }), passport)), + getByRoleName: vi.fn(async (roleName: string) => new StaffRole(makeBaseProps({ roleName }), passport)), + getDefaultRoleByEnterpriseAppRole: vi.fn(async (enterpriseAppRole: string) => + new StaffRole(makeBaseProps({ enterpriseAppRole, isDefault: true }), passport), + ), + } satisfies StaffRoleRepository; +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => { + let passport: Passport; + let repo: StaffRoleRepository; + let result: StaffRole | StaffRoleEntityReference | undefined; + + BeforeEachScenario(() => { + passport = makePassport(); + repo = makeMockRepository(passport); + result = undefined; + }); + + Background(({ Given }) => { + Given('a mock implementation of StaffRoleRepository that satisfies the full interface', () => { + // repo already initialised in BeforeEachScenario + }); + }); + + // ─── getNewInstance ─────────────────────────────────────────────────────── + + Scenario('getNewInstance resolves with a StaffRole for the given name', ({ When, Then, And }) => { + When('I call getNewInstance with name "Supervisor"', async () => { + result = await repo.getNewInstance('Supervisor'); + }); + Then('it should resolve with a StaffRole whose roleName is "Supervisor"', () => { + expect(result).toBeDefined(); + expect((result as StaffRoleEntityReference).roleName).toBe('Supervisor'); + }); + And('the StaffRole isDefault should be false', () => { + expect((result as StaffRoleEntityReference).isDefault).toBe(false); + }); + }); + + // ─── getNewDefaultCaseManagerInstance ──────────────────────────────────── + + Scenario('getNewDefaultCaseManagerInstance resolves with a default CaseManager role', ({ When, Then, And }) => { + When('I call getNewDefaultCaseManagerInstance', async () => { + result = await repo.getNewDefaultCaseManagerInstance(); + }); + Then('it should resolve with a StaffRole whose roleName is "Default Case Manager"', () => { + expect((result as StaffRoleEntityReference).roleName).toBe('Default Case Manager'); + }); + And('the StaffRole isDefault should be true', () => { + expect((result as StaffRoleEntityReference).isDefault).toBe(true); + }); + }); + + // ─── getNewDefaultServiceLineOwnerInstance ──────────────────────────────── + + Scenario('getNewDefaultServiceLineOwnerInstance resolves with a default ServiceLineOwner role', ({ When, Then, And }) => { + When('I call getNewDefaultServiceLineOwnerInstance', async () => { + result = await repo.getNewDefaultServiceLineOwnerInstance(); + }); + Then('it should resolve with a StaffRole whose roleName is "Default Service Line Owner"', () => { + expect((result as StaffRoleEntityReference).roleName).toBe('Default Service Line Owner'); + }); + And('the StaffRole isDefault should be true', () => { + expect((result as StaffRoleEntityReference).isDefault).toBe(true); + }); + }); + + // ─── getNewDefaultFinanceInstance ──────────────────────────────────────── + + Scenario('getNewDefaultFinanceInstance resolves with a default Finance role', ({ When, Then, And }) => { + When('I call getNewDefaultFinanceInstance', async () => { + result = await repo.getNewDefaultFinanceInstance(); + }); + Then('it should resolve with a StaffRole whose roleName is "Default Finance"', () => { + expect((result as StaffRoleEntityReference).roleName).toBe('Default Finance'); + }); + And('the StaffRole isDefault should be true', () => { + expect((result as StaffRoleEntityReference).isDefault).toBe(true); + }); + }); + + // ─── getNewDefaultTechAdminInstance ────────────────────────────────────── + + Scenario('getNewDefaultTechAdminInstance resolves with a default TechAdmin role', ({ When, Then, And }) => { + When('I call getNewDefaultTechAdminInstance', async () => { + result = await repo.getNewDefaultTechAdminInstance(); + }); + Then('it should resolve with a StaffRole whose roleName is "Default Tech Admin"', () => { + expect((result as StaffRoleEntityReference).roleName).toBe('Default Tech Admin'); + }); + And('the StaffRole isDefault should be true', () => { + expect((result as StaffRoleEntityReference).isDefault).toBe(true); + }); + }); + + // ─── getById ───────────────────────────────────────────────────────────── + + Scenario('getById resolves with a StaffRole for a known id', ({ When, Then }) => { + When('I call getById with "role-1"', async () => { + result = await repo.getById('role-1'); + }); + Then('it should resolve with a StaffRole whose id is "role-1"', () => { + expect((result as StaffRoleEntityReference).id).toBe('role-1'); + }); + }); + + // ─── getByRoleName ──────────────────────────────────────────────────────── + + Scenario('getByRoleName resolves with a StaffRole for a known roleName', ({ When, Then }) => { + When('I call getByRoleName with "Manager"', async () => { + result = await repo.getByRoleName('Manager'); + }); + Then('it should resolve with a StaffRole whose roleName is "Manager"', () => { + expect((result as StaffRoleEntityReference).roleName).toBe('Manager'); + }); + }); + + // ─── getDefaultRoleByEnterpriseAppRole ──────────────────────────────────── + + Scenario('getDefaultRoleByEnterpriseAppRole resolves with a default StaffRole', ({ When, Then }) => { + When('I call getDefaultRoleByEnterpriseAppRole with "Staff.CaseManager"', async () => { + result = await repo.getDefaultRoleByEnterpriseAppRole('Staff.CaseManager'); + }); + Then('it should resolve with a StaffRole whose isDefault is true', () => { + expect((result as StaffRoleEntityReference).isDefault).toBe(true); + }); + }); +}); diff --git a/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role.test.ts b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role.test.ts index 5b4c620fe..5dbbc3971 100644 --- a/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role.test.ts +++ b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role.test.ts @@ -6,386 +6,525 @@ import { expect, vi } from 'vitest'; import { RoleDeletedReassignEvent } from '../../../events/types/role-deleted-reassign.ts'; import type { Passport } from '../../passport.ts'; import { StaffRole, type StaffRoleEntityReference, type StaffRoleProps } from './staff-role.ts'; -import { StaffRolePermissions, type StaffRolePermissionsProps } from './staff-role-permissions.ts'; - +import { StaffRolePermissions } from './staff-role-permissions.ts'; + const test = { for: describeFeature }; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const feature = await loadFeature(path.resolve(__dirname, 'features/staff-role.feature')); - + function makePassport(canManageStaffRolesAndPermissions = true, isSystemAccount = false): Passport { - return vi.mocked({ - user: { - forStaffRole: vi.fn(() => ({ - determineIf: (fn: (p: { canManageStaffRolesAndPermissions: boolean; isSystemAccount: boolean }) => boolean) => fn({ canManageStaffRolesAndPermissions, isSystemAccount }), - })), - }, - } as unknown as Passport); + return vi.mocked({ + user: { + forStaffRole: vi.fn(() => ({ + determineIf: (fn: (p: { canManageStaffRolesAndPermissions: boolean; isSystemAccount: boolean }) => boolean) => fn({ canManageStaffRolesAndPermissions, isSystemAccount }), + })), + }, + } as unknown as Passport); } - + function makeBaseProps(overrides: Partial = {}): StaffRoleProps { - return { - id: 'role-1', - roleName: 'Support', - isDefault: false, - enterpriseAppRole: '', - permissions: {} as StaffRolePermissions, - roleType: 'staff-role', - createdAt: new Date('2020-01-01T00:00:00Z'), - updatedAt: new Date('2020-01-02T00:00:00Z'), - schemaVersion: '1.0.0', - ...overrides, - }; + return { + id: 'role-1', + roleName: 'Support', + isDefault: false, + enterpriseAppRole: '', + permissions: {} as StaffRolePermissions, + roleType: 'staff-role', + createdAt: new Date('2020-01-01T00:00:00Z'), + updatedAt: new Date('2020-01-02T00:00:00Z'), + schemaVersion: '1.0.0', + ...overrides, + }; } - -function makePermissionsProps(overrides: Partial = {}): StaffRolePermissionsProps { - return { - communityPermissions: { - canManageCommunities: false, - canManageStaffRolesAndPermissions: false, - canManageAllCommunities: false, - canDeleteCommunities: false, - canChangeCommunityOwner: false, - canReIndexSearchCollections: false, - }, - propertyPermissions: { - canManageProperties: false, - canEditOwnProperty: false, - }, - serviceTicketPermissions: { - canCreateTickets: false, - canManageTickets: false, - canAssignTickets: false, - canWorkOnTickets: false, - }, - servicePermissions: { - canManageServices: false, - }, - violationTicketPermissions: { - canCreateTickets: false, - canManageTickets: false, - canAssignTickets: false, - canWorkOnTickets: false, - }, - financePermissions: { - canManageFinance: false, - canViewGLBatchSummaries: false, - canViewFinanceConfigs: false, - canCreateFinanceConfigs: false, - }, - techAdminPermissions: { - canManageTechAdmin: false, - canViewDatabaseExplorer: false, - canViewBlobExplorer: false, - canViewQueueDashboard: false, - canSendQueueMessages: false, - }, - userPermissions: { - canManageUsers: false, - }, - ...overrides, - }; + +/** Props with fully initialised mutable permission sub-objects, required for static factory methods */ +function makeFactoryProps(overrides: Partial = {}): StaffRoleProps { + return { + ...makeBaseProps(overrides), + permissions: { + communityPermissions: {} as Record, + propertyPermissions: {} as Record, + serviceTicketPermissions: {} as Record, + servicePermissions: {} as Record, + violationTicketPermissions: {} as Record, + financePermissions: {} as Record, + techAdminPermissions: {} as Record, + userPermissions: {} as Record, + } as unknown as StaffRolePermissions, + }; } - + function getIntegrationEvent(events: readonly unknown[], eventClass: new (aggregateId: string) => T): T | undefined { - return events.find((e) => e instanceof eventClass) as T | undefined; + return events.find((e) => e instanceof eventClass) as T | undefined; } - + test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => { - let passport: Passport; - let baseProps: StaffRoleProps; - let staffRole: StaffRole; - let newStaffRole: StaffRole; - - BeforeEachScenario(() => { - passport = makePassport(true, false); - baseProps = makeBaseProps(); - staffRole = new StaffRole(baseProps, passport); - newStaffRole = undefined as unknown as StaffRole; - }); - - Background(({ Given, And }) => { - Given('a valid Passport with staff role permissions', () => { - passport = makePassport(true, false); - }); - And('base staff role properties with roleName "Support", isDefault false, roleType "staff-role", and valid timestamps', () => { - baseProps = makeBaseProps(); - staffRole = new StaffRole(baseProps, passport); - }); - }); - - Scenario('Creating a new staff role instance', ({ When, Then, And }) => { - When('I create a new StaffRole aggregate using getNewInstance with roleName "Support" and isDefault false', () => { - newStaffRole = StaffRole.getNewInstance(makeBaseProps(), passport, 'Support', false); - }); - Then('the staff role\'s roleName should be "Support"', () => { - expect(newStaffRole.roleName).toBe('Support'); - }); - And("the staff role's isDefault should be false", () => { - expect(newStaffRole.isDefault).toBe(false); - }); - }); - - // roleName - Scenario('Changing the roleName with permission to manage staff roles', ({ Given, When, Then }) => { - Given('a StaffRole aggregate with permission to manage staff roles and permissions', () => { - passport = makePassport(true, false); - staffRole = new StaffRole(makeBaseProps(), passport); - }); - When('I set the roleName to "Manager"', () => { - staffRole.roleName = 'Manager'; - }); - Then('the staff role\'s roleName should be "Manager"', () => { - expect(staffRole.roleName).toBe('Manager'); - }); - }); - - Scenario('Changing the roleName with system account permission', ({ Given, When, Then }) => { - Given('a StaffRole aggregate with system account permission', () => { - passport = makePassport(false, true); - staffRole = new StaffRole(makeBaseProps(), passport); - }); - When('I set the roleName to "Manager"', () => { - staffRole.roleName = 'Manager'; - }); - Then('the staff role\'s roleName should be "Manager"', () => { - expect(staffRole.roleName).toBe('Manager'); - }); - }); - - Scenario('Changing the roleName without permission', ({ Given, When, Then }) => { - let changingRoleNameWithoutPermission: () => void; - Given('a StaffRole aggregate without permission to manage staff roles and permissions or system account', () => { - passport = makePassport(false, false); - staffRole = new StaffRole(makeBaseProps(), passport); - }); - When('I try to set the roleName to "Manager"', () => { - changingRoleNameWithoutPermission = () => { - staffRole.roleName = 'Manager'; - }; - }); - Then('a PermissionError should be thrown', () => { - expect(changingRoleNameWithoutPermission).toThrow(PermissionError); - expect(changingRoleNameWithoutPermission).toThrow('Cannot set role name'); - }); - }); - - Scenario('Changing the roleName to an invalid value', ({ Given, When, Then }) => { - let changingRoleNameToInvalidValue: () => void; - Given('a StaffRole aggregate with permission to manage staff roles and permissions', () => { - passport = makePassport(true, false); - staffRole = new StaffRole(makeBaseProps(), passport); - }); - When('I try to set the roleName to an invalid value (e.g., empty string)', () => { - changingRoleNameToInvalidValue = () => { - staffRole.roleName = ''; - }; - }); - Then('an error should be thrown indicating the value is invalid', () => { - expect(changingRoleNameToInvalidValue).throws('Too short'); - }); - }); - - // isDefault - Scenario('Changing isDefault with permission to manage staff roles', ({ Given, When, Then }) => { - Given('a StaffRole aggregate with permission to manage staff roles and permissions', () => { - passport = makePassport(true, false); - staffRole = new StaffRole(makeBaseProps(), passport); - }); - When('I set isDefault to true', () => { - staffRole.isDefault = true; - }); - Then("the staff role's isDefault should be true", () => { - expect(staffRole.isDefault).toBe(true); - }); - }); - - Scenario('Changing isDefault with system account permission', ({ Given, When, Then }) => { - Given('a StaffRole aggregate with system account permission', () => { - passport = makePassport(false, true); - staffRole = new StaffRole(makeBaseProps(), passport); - }); - When('I set isDefault to true', () => { - staffRole.isDefault = true; - }); - Then("the staff role's isDefault should be true", () => { - expect(staffRole.isDefault).toBe(true); - }); - }); - - Scenario('Changing isDefault without permission', ({ Given, When, Then }) => { - let changingIsDefaultWithoutPermission: () => void; - Given('a StaffRole aggregate without permission to manage staff roles and permissions or system account', () => { - passport = makePassport(false, false); - staffRole = new StaffRole(makeBaseProps(), passport); - }); - When('I try to set isDefault to true', () => { - changingIsDefaultWithoutPermission = () => { - staffRole.isDefault = true; - }; - }); - Then('a PermissionError should be thrown', () => { - expect(changingIsDefaultWithoutPermission).toThrow(PermissionError); - expect(changingIsDefaultWithoutPermission).throws('You do not have permission to update this role'); - }); - }); - - // deleteAndReassignTo - Scenario('Deleting a non-default staff role with permission', ({ Given, When, Then, And }) => { - let deletedRole: StaffRole; - Given('a StaffRole aggregate that is not deleted and is not default, with permission to manage staff roles and permissions', () => { - passport = makePassport(true, false); - deletedRole = new StaffRole(makeBaseProps({ isDefault: false }), passport); - }); - When('I call deleteAndReassignTo with a valid StaffRoleEntityReference', () => { - deletedRole.deleteAndReassignTo({ - id: 'role-2', - } as StaffRoleEntityReference); - }); - Then('the staff role should be marked as deleted', () => { - expect(deletedRole.isDeleted).toBe(true); - }); - And('a RoleDeletedReassignEvent should be added to integration events', () => { - const event = getIntegrationEvent(deletedRole.getIntegrationEvents(), RoleDeletedReassignEvent); - expect(event).toBeDefined(); - expect(event).toBeInstanceOf(RoleDeletedReassignEvent); - expect(event?.payload.deletedRoleId).toBe('role-1'); - expect(event?.payload.newRoleId).toBe('role-2'); - }); - }); - - Scenario('Deleting a non-default staff role without permission', ({ Given, When, Then, And }) => { - let deletedRole: StaffRole; - let deletingRoleWithoutPermission: () => void; - Given('a StaffRole aggregate that is not deleted and is not default, without permission to manage staff roles and permissions', () => { - passport = makePassport(false, false); - deletedRole = new StaffRole(makeBaseProps({ isDefault: false }), passport); - }); - When('I try to call deleteAndReassignTo with a valid StaffRoleEntityReference', () => { - deletingRoleWithoutPermission = () => { - deletedRole.deleteAndReassignTo({ - id: 'role-2', - } as StaffRoleEntityReference); - }; - }); - Then('a PermissionError should be thrown', () => { - expect(deletingRoleWithoutPermission).toThrow(PermissionError); - expect(deletingRoleWithoutPermission).toThrow('You do not have permission to delete this role'); - }); - And('no RoleDeletedReassignEvent should be emitted', () => { - const event = getIntegrationEvent(deletedRole.getIntegrationEvents(), RoleDeletedReassignEvent); - expect(event).toBeUndefined(); - }); - }); - - Scenario('Deleting a default staff role', ({ Given, When, Then, And }) => { - let defaultRole: StaffRole; - let deletingDefaultRole: () => void; - Given('a StaffRole aggregate that is default', () => { - passport = makePassport(true, false); - defaultRole = new StaffRole(makeBaseProps({ isDefault: true }), passport); - }); - When('I try to call deleteAndReassignTo with a valid StaffRoleEntityReference', () => { - deletingDefaultRole = () => { - defaultRole.deleteAndReassignTo({ - id: 'role-2', - } as StaffRoleEntityReference); - }; - }); - Then('a PermissionError should be thrown', () => { - expect(deletingDefaultRole).toThrow(PermissionError); - }); - And('no RoleDeletedReassignEvent should be emitted', () => { - const event = getIntegrationEvent(defaultRole.getIntegrationEvents(), RoleDeletedReassignEvent); - expect(event).toBeUndefined(); - }); - }); - - // permissions (delegation) - Scenario('Accessing permissions entity', ({ Given, When, Then }) => { - let permissions: StaffRolePermissions; - Given('a StaffRole aggregate', () => { - passport = makePassport(true, false); - staffRole = new StaffRole(makeBaseProps(), passport); - }); - When('I access the permissions property', () => { - permissions = staffRole.permissions; - }); - Then('I should receive a StaffRolePermissions entity instance', () => { - expect(permissions).toBeInstanceOf(StaffRolePermissions); - }); - }); - - // read-only properties - Scenario('Getting roleType, createdAt, updatedAt, and schemaVersion', ({ Given, Then, And }) => { - Given('a StaffRole aggregate', () => { - passport = makePassport(true, false); - staffRole = new StaffRole(makeBaseProps(), passport); - }); - Then('the roleType property should return the correct value', () => { - expect(staffRole.roleType).toBe('staff-role'); - }); - And('the createdAt property should return the correct date', () => { - expect(staffRole.createdAt).toEqual(new Date('2020-01-01T00:00:00Z')); - }); - And('the updatedAt property should return the correct date', () => { - expect(staffRole.updatedAt).toEqual(new Date('2020-01-02T00:00:00Z')); - }); - And('the schemaVersion property should return the correct version', () => { - expect(staffRole.schemaVersion).toBe('1.0.0'); - }); - }); - - // getDefaultRoleNames - Scenario('Getting default role names', ({ When, Then, And }) => { - let roleNames: string[]; - When('I call getDefaultRoleNames', () => { - roleNames = StaffRole.getDefaultRoleNames(); - }); - Then('the result should contain "Default.CaseManager"', () => { - expect(roleNames).toContain('Default.CaseManager'); - }); - And('the result should contain "Default.ServiceLineOwner"', () => { - expect(roleNames).toContain('Default.ServiceLineOwner'); - }); - And('the result should contain "Default.Finance"', () => { - expect(roleNames).toContain('Default.Finance'); - }); - And('the result should contain "Default.TechAdmin"', () => { - expect(roleNames).toContain('Default.TechAdmin'); - }); - And('the result should have exactly 4 names', () => { - expect(roleNames).toHaveLength(4); - }); - }); - - Scenario('Creating a default tech admin role', ({ When, Then, And }) => { - let techAdminRole: StaffRole; - When('I create a default tech admin staff role', () => { - techAdminRole = StaffRole.getNewDefaultTechAdminInstance( - makeBaseProps({ permissions: makePermissionsProps() }), - passport, - ); - }); - Then('the roleName should be "Default Tech Admin"', () => { - expect(techAdminRole.roleName).toBe('Default Tech Admin'); - }); - And('the enterpriseAppRole should be "Staff.TechAdmin"', () => { - expect(techAdminRole.enterpriseAppRole).toBe('Staff.TechAdmin'); - }); - And('the tech admin role should allow managing communities', () => { - expect(techAdminRole.permissions.communityPermissions.canManageCommunities).toBe(true); - }); - And('the tech admin role should allow managing staff roles and permissions', () => { - expect(techAdminRole.permissions.communityPermissions.canManageStaffRolesAndPermissions).toBe(true); - }); - And('the tech admin role should allow managing finance', () => { - expect(techAdminRole.permissions.financePermissions.canManageFinance).toBe(true); - }); - And('the tech admin role should allow managing tech admin', () => { - expect(techAdminRole.permissions.techAdminPermissions.canManageTechAdmin).toBe(true); - }); - And('the tech admin role should allow managing users', () => { - expect(techAdminRole.permissions.userPermissions.canManageUsers).toBe(true); - }); - }); + let passport: Passport; + let baseProps: StaffRoleProps; + let staffRole: StaffRole; + let newStaffRole: StaffRole; + + BeforeEachScenario(() => { + passport = makePassport(true, false); + baseProps = makeBaseProps(); + staffRole = new StaffRole(baseProps, passport); + newStaffRole = undefined as unknown as StaffRole; + }); + + Background(({ Given, And }) => { + Given('a valid Passport with staff role permissions', () => { + passport = makePassport(true, false); + }); + And('base staff role properties with roleName "Support", isDefault false, roleType "staff-role", and valid timestamps', () => { + baseProps = makeBaseProps(); + staffRole = new StaffRole(baseProps, passport); + }); + }); + + Scenario('Creating a new staff role instance', ({ When, Then, And }) => { + When('I create a new StaffRole aggregate using getNewInstance with roleName "Support" and isDefault false', () => { + newStaffRole = StaffRole.getNewInstance(makeBaseProps(), passport, 'Support', false); + }); + Then('the staff role\'s roleName should be "Support"', () => { + expect(newStaffRole.roleName).toBe('Support'); + }); + And("the staff role's isDefault should be false", () => { + expect(newStaffRole.isDefault).toBe(false); + }); + }); + + // roleName + Scenario('Changing the roleName with permission to manage staff roles', ({ Given, When, Then }) => { + Given('a StaffRole aggregate with permission to manage staff roles and permissions', () => { + passport = makePassport(true, false); + staffRole = new StaffRole(makeBaseProps(), passport); + }); + When('I set the roleName to "manager"', () => { + staffRole.roleName = 'manager'; + }); + Then('the staff role\'s roleName should be "Manager"', () => { + expect(staffRole.roleName).toBe('Manager'); + }); + }); + + Scenario('Changing the roleName with system account permission', ({ Given, When, Then }) => { + Given('a StaffRole aggregate with system account permission', () => { + passport = makePassport(false, true); + staffRole = new StaffRole(makeBaseProps(), passport); + }); + When('I set the roleName to "manager"', () => { + staffRole.roleName = 'manager'; + }); + Then('the staff role\'s roleName should be "Manager"', () => { + expect(staffRole.roleName).toBe('Manager'); + }); + }); + + Scenario('Changing the roleName without permission', ({ Given, When, Then }) => { + let changingRoleNameWithoutPermission: () => void; + Given('a StaffRole aggregate without permission to manage staff roles and permissions or system account', () => { + passport = makePassport(false, false); + staffRole = new StaffRole(makeBaseProps(), passport); + }); + When('I try to set the roleName to "Manager"', () => { + changingRoleNameWithoutPermission = () => { + staffRole.roleName = 'Manager'; + }; + }); + Then('a PermissionError should be thrown', () => { + expect(changingRoleNameWithoutPermission).toThrow(PermissionError); + expect(changingRoleNameWithoutPermission).toThrow('Cannot set role name'); + }); + }); + + Scenario('Changing the roleName to an invalid value', ({ Given, When, Then }) => { + let changingRoleNameToInvalidValue: () => void; + Given('a StaffRole aggregate with permission to manage staff roles and permissions', () => { + passport = makePassport(true, false); + staffRole = new StaffRole(makeBaseProps(), passport); + }); + When('I try to set the roleName to an invalid value (e.g., empty string)', () => { + changingRoleNameToInvalidValue = () => { + staffRole.roleName = ''; + }; + }); + Then('an error should be thrown indicating the value is invalid', () => { + expect(changingRoleNameToInvalidValue).throws('Too short'); + }); + }); + + // isDefault + Scenario('Changing isDefault with permission to manage staff roles', ({ Given, When, Then }) => { + Given('a StaffRole aggregate with permission to manage staff roles and permissions', () => { + passport = makePassport(true, false); + staffRole = new StaffRole(makeBaseProps(), passport); + }); + When('I set isDefault to true', () => { + staffRole.isDefault = true; + }); + Then("the staff role's isDefault should be true", () => { + expect(staffRole.isDefault).toBe(true); + }); + }); + + Scenario('Changing isDefault with system account permission', ({ Given, When, Then }) => { + Given('a StaffRole aggregate with system account permission', () => { + passport = makePassport(false, true); + staffRole = new StaffRole(makeBaseProps(), passport); + }); + When('I set isDefault to true', () => { + staffRole.isDefault = true; + }); + Then("the staff role's isDefault should be true", () => { + expect(staffRole.isDefault).toBe(true); + }); + }); + + Scenario('Changing isDefault without permission', ({ Given, When, Then }) => { + let changingIsDefaultWithoutPermission: () => void; + Given('a StaffRole aggregate without permission to manage staff roles and permissions or system account', () => { + passport = makePassport(false, false); + staffRole = new StaffRole(makeBaseProps(), passport); + }); + When('I try to set isDefault to true', () => { + changingIsDefaultWithoutPermission = () => { + staffRole.isDefault = true; + }; + }); + Then('a PermissionError should be thrown', () => { + expect(changingIsDefaultWithoutPermission).toThrow(PermissionError); + expect(changingIsDefaultWithoutPermission).throws('You do not have permission to update this role'); + }); + }); + + // deleteAndReassignTo + Scenario('Deleting a non-default staff role with permission', ({ Given, When, Then, And }) => { + let deletedRole: StaffRole; + Given('a StaffRole aggregate that is not deleted and is not default, with permission to manage staff roles and permissions', () => { + passport = makePassport(true, false); + deletedRole = new StaffRole(makeBaseProps({ isDefault: false }), passport); + }); + When('I call deleteAndReassignTo with a valid StaffRoleEntityReference', () => { + deletedRole.deleteAndReassignTo({ + id: 'role-2', + } as StaffRoleEntityReference); + }); + Then('the staff role should be marked as deleted', () => { + expect(deletedRole.isDeleted).toBe(true); + }); + And('a RoleDeletedReassignEvent should be added to integration events', () => { + const event = getIntegrationEvent(deletedRole.getIntegrationEvents(), RoleDeletedReassignEvent); + expect(event).toBeDefined(); + expect(event).toBeInstanceOf(RoleDeletedReassignEvent); + expect(event?.payload.deletedRoleId).toBe('role-1'); + expect(event?.payload.newRoleId).toBe('role-2'); + }); + }); + + Scenario('Deleting a non-default staff role without permission', ({ Given, When, Then, And }) => { + let deletedRole: StaffRole; + let deletingRoleWithoutPermission: () => void; + Given('a StaffRole aggregate that is not deleted and is not default, without permission to manage staff roles and permissions', () => { + passport = makePassport(false, false); + deletedRole = new StaffRole(makeBaseProps({ isDefault: false }), passport); + }); + When('I try to call deleteAndReassignTo with a valid StaffRoleEntityReference', () => { + deletingRoleWithoutPermission = () => { + deletedRole.deleteAndReassignTo({ + id: 'role-2', + } as StaffRoleEntityReference); + }; + }); + Then('a PermissionError should be thrown', () => { + expect(deletingRoleWithoutPermission).toThrow(PermissionError); + expect(deletingRoleWithoutPermission).toThrow('You do not have permission to delete this role'); + }); + And('no RoleDeletedReassignEvent should be emitted', () => { + const event = getIntegrationEvent(deletedRole.getIntegrationEvents(), RoleDeletedReassignEvent); + expect(event).toBeUndefined(); + }); + }); + + Scenario('Deleting a default staff role', ({ Given, When, Then, And }) => { + let defaultRole: StaffRole; + let deletingDefaultRole: () => void; + Given('a StaffRole aggregate that is default', () => { + passport = makePassport(true, false); + defaultRole = new StaffRole(makeBaseProps({ isDefault: true }), passport); + }); + When('I try to call deleteAndReassignTo with a valid StaffRoleEntityReference', () => { + deletingDefaultRole = () => { + defaultRole.deleteAndReassignTo({ + id: 'role-2', + } as StaffRoleEntityReference); + }; + }); + Then('a PermissionError should be thrown', () => { + expect(deletingDefaultRole).toThrow(PermissionError); + }); + And('no RoleDeletedReassignEvent should be emitted', () => { + const event = getIntegrationEvent(defaultRole.getIntegrationEvents(), RoleDeletedReassignEvent); + expect(event).toBeUndefined(); + }); + }); + + // permissions (delegation) + Scenario('Accessing permissions entity', ({ Given, When, Then }) => { + let permissions: StaffRolePermissions; + Given('a StaffRole aggregate', () => { + passport = makePassport(true, false); + staffRole = new StaffRole(makeBaseProps(), passport); + }); + When('I access the permissions property', () => { + permissions = staffRole.permissions; + }); + Then('I should receive a StaffRolePermissions entity instance', () => { + expect(permissions).toBeInstanceOf(StaffRolePermissions); + }); + }); + + // read-only properties + Scenario('Getting roleType, createdAt, updatedAt, and schemaVersion', ({ Given, Then, And }) => { + Given('a StaffRole aggregate', () => { + passport = makePassport(true, false); + staffRole = new StaffRole(makeBaseProps(), passport); + }); + Then('the roleType property should return the correct value', () => { + expect(staffRole.roleType).toBe('staff-role'); + }); + And('the createdAt property should return the correct date', () => { + expect(staffRole.createdAt).toEqual(new Date('2020-01-01T00:00:00Z')); + }); + And('the updatedAt property should return the correct date', () => { + expect(staffRole.updatedAt).toEqual(new Date('2020-01-02T00:00:00Z')); + }); + And('the schemaVersion property should return the correct version', () => { + expect(staffRole.schemaVersion).toBe('1.0.0'); + }); + }); + + // ─── enterpriseAppRole ──────────────────────────────────────────────────── + + Scenario('Getting the enterpriseAppRole property', ({ Given, Then }) => { + Given('a StaffRole aggregate with permission to manage staff roles and permissions', () => { + passport = makePassport(true, false); + staffRole = new StaffRole(makeBaseProps({ enterpriseAppRole: '' }), passport); + }); + Then('the enterpriseAppRole should return the initial value', () => { + expect(staffRole.enterpriseAppRole).toBe(''); + }); + }); + + Scenario('Changing the enterpriseAppRole with permission to manage staff roles', ({ Given, When, Then }) => { + Given('a StaffRole aggregate with permission to manage staff roles and permissions', () => { + passport = makePassport(true, false); + staffRole = new StaffRole(makeBaseProps(), passport); + }); + When('I set the enterpriseAppRole to "Staff.CaseManager"', () => { + staffRole.enterpriseAppRole = 'Staff.CaseManager'; + }); + Then('the staff role\'s enterpriseAppRole should be "Staff.CaseManager"', () => { + expect(staffRole.enterpriseAppRole).toBe('Staff.CaseManager'); + }); + }); + + Scenario('Changing the enterpriseAppRole with system account permission', ({ Given, When, Then }) => { + Given('a StaffRole aggregate with system account permission', () => { + passport = makePassport(false, true); + staffRole = new StaffRole(makeBaseProps(), passport); + }); + When('I set the enterpriseAppRole to "Staff.Finance"', () => { + staffRole.enterpriseAppRole = 'Staff.Finance'; + }); + Then('the staff role\'s enterpriseAppRole should be "Staff.Finance"', () => { + expect(staffRole.enterpriseAppRole).toBe('Staff.Finance'); + }); + }); + + Scenario('Changing the enterpriseAppRole without permission', ({ Given, When, Then }) => { + let changeWithoutPermission: () => void; + Given('a StaffRole aggregate without permission to manage staff roles and permissions or system account', () => { + passport = makePassport(false, false); + staffRole = new StaffRole(makeBaseProps(), passport); + }); + When('I try to set the enterpriseAppRole to "Staff.CaseManager"', () => { + changeWithoutPermission = () => { + staffRole.enterpriseAppRole = 'Staff.CaseManager'; + }; + }); + Then('a PermissionError should be thrown for enterpriseAppRole', () => { + expect(changeWithoutPermission).toThrow(PermissionError); + expect(changeWithoutPermission).toThrow('Cannot set enterprise app role'); + }); + }); + + Scenario('Changing the enterpriseAppRole to an invalid value', ({ Given, When, Then }) => { + let changeToInvalid: () => void; + Given('a StaffRole aggregate with permission to manage staff roles and permissions', () => { + passport = makePassport(true, false); + staffRole = new StaffRole(makeBaseProps(), passport); + }); + When('I try to set the enterpriseAppRole to an invalid value', () => { + changeToInvalid = () => { + staffRole.enterpriseAppRole = 'Invalid.Role.That.Does.Not.Exist'; + }; + }); + Then('an error should be thrown for the invalid enterpriseAppRole', () => { + expect(changeToInvalid).toThrow(); + }); + }); + + // ─── getDefaultRoleNames ────────────────────────────────────────────────── + + Scenario('Getting the list of default role names', ({ When, Then }) => { + let defaultNames: string[]; + When('I call getDefaultRoleNames', () => { + defaultNames = StaffRole.getDefaultRoleNames(); + }); + Then('it should return the four canonical default role name strings', () => { + expect(defaultNames).toHaveLength(4); + expect(defaultNames).toContain('Default.CaseManager'); + expect(defaultNames).toContain('Default.ServiceLineOwner'); + expect(defaultNames).toContain('Default.Finance'); + expect(defaultNames).toContain('Default.TechAdmin'); + }); + }); + + // ─── default factory methods ────────────────────────────────────────────── + + Scenario('Creating a new default Case Manager role', ({ When, Then, And }) => { + let role: StaffRole; + When('I call getNewDefaultCaseManagerInstance', () => { + role = StaffRole.getNewDefaultCaseManagerInstance(makeFactoryProps(), makePassport(true, true)); + }); + Then('the role name should be "Default Case Manager"', () => { + expect(role.roleName).toBe('Default Case Manager'); + }); + And('the enterpriseAppRole should be "Staff.CaseManager"', () => { + expect(role.enterpriseAppRole).toBe('Staff.CaseManager'); + }); + And('isDefault should be true', () => { + expect(role.isDefault).toBe(true); + }); + And('community canManageCommunities should be true', () => { + expect(role.permissions.communityPermissions.canManageCommunities).toBe(true); + }); + And('community canManageStaffRolesAndPermissions should be true', () => { + expect(role.permissions.communityPermissions.canManageStaffRolesAndPermissions).toBe(true); + }); + And('finance canManageFinance should be false', () => { + expect(role.permissions.financePermissions.canManageFinance).toBe(false); + }); + And('techAdmin canManageTechAdmin should be false', () => { + expect(role.permissions.techAdminPermissions.canManageTechAdmin).toBe(false); + }); + And('user canManageUsers should be true', () => { + expect(role.permissions.userPermissions.canManageUsers).toBe(true); + }); + And('user canAssignStaffUserRoles should be true', () => { + expect(role.permissions.userPermissions.canAssignStaffUserRoles).toBe(true); + }); + }); + + Scenario('Creating a new default Service Line Owner role', ({ When, Then, And }) => { + let role: StaffRole; + When('I call getNewDefaultServiceLineOwnerInstance', () => { + role = StaffRole.getNewDefaultServiceLineOwnerInstance(makeFactoryProps(), makePassport(true, true)); + }); + Then('the role name should be "Default Service Line Owner"', () => { + expect(role.roleName).toBe('Default Service Line Owner'); + }); + And('the enterpriseAppRole should be "Staff.ServiceLineOwner"', () => { + expect(role.enterpriseAppRole).toBe('Staff.ServiceLineOwner'); + }); + And('isDefault should be true', () => { + expect(role.isDefault).toBe(true); + }); + And('community canManageCommunities should be true', () => { + expect(role.permissions.communityPermissions.canManageCommunities).toBe(true); + }); + And('community canManageStaffRolesAndPermissions should be true', () => { + expect(role.permissions.communityPermissions.canManageStaffRolesAndPermissions).toBe(true); + }); + And('finance canManageFinance should be false', () => { + expect(role.permissions.financePermissions.canManageFinance).toBe(false); + }); + And('techAdmin canManageTechAdmin should be false', () => { + expect(role.permissions.techAdminPermissions.canManageTechAdmin).toBe(false); + }); + And('user canManageUsers should be true', () => { + expect(role.permissions.userPermissions.canManageUsers).toBe(true); + }); + And('user canAssignStaffUserRoles should be true', () => { + expect(role.permissions.userPermissions.canAssignStaffUserRoles).toBe(true); + }); + }); + + Scenario('Creating a new default Finance role', ({ When, Then, And }) => { + let role: StaffRole; + When('I call getNewDefaultFinanceInstance', () => { + role = StaffRole.getNewDefaultFinanceInstance(makeFactoryProps(), makePassport(true, true)); + }); + Then('the role name should be "Default Finance"', () => { + expect(role.roleName).toBe('Default Finance'); + }); + And('the enterpriseAppRole should be "Staff.Finance"', () => { + expect(role.enterpriseAppRole).toBe('Staff.Finance'); + }); + And('isDefault should be true', () => { + expect(role.isDefault).toBe(true); + }); + And('community canManageCommunities should be false', () => { + expect(role.permissions.communityPermissions.canManageCommunities).toBe(false); + }); + And('community canManageStaffRolesAndPermissions should be true', () => { + expect(role.permissions.communityPermissions.canManageStaffRolesAndPermissions).toBe(true); + }); + And('finance canManageFinance should be true', () => { + expect(role.permissions.financePermissions.canManageFinance).toBe(true); + }); + And('techAdmin canManageTechAdmin should be false', () => { + expect(role.permissions.techAdminPermissions.canManageTechAdmin).toBe(false); + }); + And('user canManageUsers should be true', () => { + expect(role.permissions.userPermissions.canManageUsers).toBe(true); + }); + And('user canAssignStaffUserRoles should be true', () => { + expect(role.permissions.userPermissions.canAssignStaffUserRoles).toBe(true); + }); + }); + + Scenario('Creating a new default Tech Admin role', ({ When, Then, And }) => { + let role: StaffRole; + When('I call getNewDefaultTechAdminInstance', () => { + role = StaffRole.getNewDefaultTechAdminInstance(makeFactoryProps(), makePassport(true, true)); + }); + Then('the role name should be "Default Tech Admin"', () => { + expect(role.roleName).toBe('Default Tech Admin'); + }); + And('the enterpriseAppRole should be "Staff.TechAdmin"', () => { + expect(role.enterpriseAppRole).toBe('Staff.TechAdmin'); + }); + And('isDefault should be true', () => { + expect(role.isDefault).toBe(true); + }); + And('community canManageCommunities should be true', () => { + expect(role.permissions.communityPermissions.canManageCommunities).toBe(true); + }); + And('community canManageStaffRolesAndPermissions should be true', () => { + expect(role.permissions.communityPermissions.canManageStaffRolesAndPermissions).toBe(true); + }); + And('finance canManageFinance should be true', () => { + expect(role.permissions.financePermissions.canManageFinance).toBe(true); + }); + And('techAdmin canManageTechAdmin should be true', () => { + expect(role.permissions.techAdminPermissions.canManageTechAdmin).toBe(true); + }); + And('user canManageUsers should be true', () => { + expect(role.permissions.userPermissions.canManageUsers).toBe(true); + }); + And('user canAssignStaffUserRoles should be true', () => { + expect(role.permissions.userPermissions.canAssignStaffUserRoles).toBe(true); + }); + }); }); diff --git a/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role.ts b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role.ts index de28bea83..818d618d0 100644 --- a/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role.ts +++ b/packages/ocom/domain/src/domain/contexts/user/staff-role/staff-role.ts @@ -53,9 +53,13 @@ export class StaffRole extends AggregateRoot extends AggregateRoot extends AggregateRoot extends AggregateRoot extends AggregateRoot permissions.canManageStaffRolesAndPermissions || permissions.isSystemAccount)) { throw new PermissionError('Cannot set role name'); } - this.props.roleName = new ValueObjects.RoleName(roleName).valueOf(); + const normalizedRoleName = new ValueObjects.RoleName(roleName).valueOf(); + this.props.roleName = normalizedRoleName.charAt(0).toUpperCase() + normalizedRoleName.slice(1); } get enterpriseAppRole() { diff --git a/packages/ocom/domain/src/domain/contexts/user/staff-user/features/staff-user.feature b/packages/ocom/domain/src/domain/contexts/user/staff-user/features/staff-user.feature index 1262726a0..855edbc6c 100644 --- a/packages/ocom/domain/src/domain/contexts/user/staff-user/features/staff-user.feature +++ b/packages/ocom/domain/src/domain/contexts/user/staff-user/features/staff-user.feature @@ -138,3 +138,43 @@ Feature: StaffUser And the createdAt property should return the correct date And the updatedAt property should return the correct date And the schemaVersion property should return the correct version + + # activityLog + Scenario: Logging a general update via requestAddUpdate + Given a StaffUser aggregate with permission to manage staff roles and permissions + When I call requestAddUpdate with description "Profile updated" and activityByStaffUserId "staff-99" + Then an activity log entry with activityType "UPDATED" and description "Profile updated" should be added + + Scenario: requestAddUpdate without permission throws PermissionError + Given a StaffUser aggregate without permission to manage staff roles and permissions + When I try to call requestAddUpdate with description "Profile updated" and activityByStaffUserId "staff-99" + Then a PermissionError should be thrown + + Scenario: Logging a role assignment via requestRoleAssignment + Given a StaffUser aggregate with permission to manage staff roles and permissions + When I call requestRoleAssignment with a valid role, description "Role assigned", and activityByStaffUserId "staff-99" + Then the staff user's role should be updated + And an activity log entry with activityType "ROLE_ASSIGNED" and description "Role assigned" should be added + + Scenario: Logging a role removal via requestRoleRemoval + Given a StaffUser aggregate with permission to manage staff roles and permissions + When I call requestRoleRemoval with description "Role removed" and activityByStaffUserId "staff-99" + Then the staff user's role should be undefined + And an activity log entry with activityType "ROLE_REMOVED" and description "Role removed" should be added + + Scenario: Logging a block via requestBlock + Given a StaffUser aggregate with permission to manage staff roles and permissions + When I call requestBlock with description "User blocked" and activityByStaffUserId "staff-99" + Then the staff user's accessBlocked should be true + And an activity log entry with activityType "BLOCKED" and description "User blocked" should be added + + Scenario: Logging an unblock via requestUnblock + Given a StaffUser aggregate with permission to manage staff roles and permissions + When I call requestUnblock with description "User unblocked" and activityByStaffUserId "staff-99" + Then the staff user's accessBlocked should be false + And an activity log entry with activityType "UNBLOCKED" and description "User unblocked" should be added + + Scenario: Logging a create action via requestCreate + Given a StaffUser aggregate with permission to manage staff roles and permissions + When I call requestCreate with activityByStaffUserId "staff-99" + Then an activity log entry with activityType "CREATED" and description "User created" should be added diff --git a/packages/ocom/domain/src/domain/contexts/user/staff-user/index.ts b/packages/ocom/domain/src/domain/contexts/user/staff-user/index.ts index be786b89b..0c9e375de 100644 --- a/packages/ocom/domain/src/domain/contexts/user/staff-user/index.ts +++ b/packages/ocom/domain/src/domain/contexts/user/staff-user/index.ts @@ -5,3 +5,10 @@ export { type StaffUserProps, } from './staff-user.ts'; export type { StaffUserUnitOfWork } from './staff-user.uow.ts'; +export { + StaffUserActivityLog, + type StaffUserActivityLogCreateProps, + type StaffUserActivityLogEntityReference, + type StaffUserActivityLogProps, +} from './staff-user-activity-log.entity.ts'; +export * as StaffUserActivityLogValueObjects from './staff-user-activity-log.value-objects.ts'; diff --git a/packages/ocom/domain/src/domain/contexts/user/staff-user/staff-user-activity-log.entity.additional.test.ts b/packages/ocom/domain/src/domain/contexts/user/staff-user/staff-user-activity-log.entity.additional.test.ts new file mode 100644 index 000000000..3ba3da8f0 --- /dev/null +++ b/packages/ocom/domain/src/domain/contexts/user/staff-user/staff-user-activity-log.entity.additional.test.ts @@ -0,0 +1,93 @@ +import { PermissionError } from '@cellix/domain-seedwork/domain-entity'; +import { describe, expect, it } from 'vitest'; +import type { UserVisa } from '../user.visa.ts'; +import { StaffUserActivityLog, type StaffUserActivityLogProps } from './staff-user-activity-log.entity.ts'; +import * as ValueObjects from './staff-user-activity-log.value-objects.ts'; + +function createVisa(isSystemAccount: boolean, canManageStaffRolesAndPermissions = false): UserVisa { + return { + determineIf: (func) => + func({ + canManageEndUsers: false, + canManageStaffRolesAndPermissions, + canManageStaffUsers: false, + canManageVendorUsers: false, + isEditingOwnAccount: false, + isSystemAccount, + }), + }; +} + +function createProps(overrides: Partial = {}): StaffUserActivityLogProps { + return { + id: 'activity-1', + activityType: '', + activityDescription: '', + activityByStaffUserId: '', + createdAt: new Date('2020-01-01T00:00:00Z'), + updatedAt: new Date('2020-01-01T00:00:00Z'), + ...overrides, + }; +} + +describe('staff-user-activity-log additional coverage', () => { + it('creates a new activity log entry when requester is a system account', () => { + const activityLog = StaffUserActivityLog.getNewInstance(createProps(), createVisa(true), { + activityType: new ValueObjects.ActivityTypeCode(ValueObjects.ActivityTypeCodes.Created), + activityDescription: new ValueObjects.Description('User created'), + activityByStaffUserId: 'staff-99', + }); + + expect(activityLog.activityType).toBe('CREATED'); + expect(activityLog.activityDescription).toBe('User created'); + expect(activityLog.activityByStaffUserId).toBe('staff-99'); + }); + + it('creates a new activity log entry for non-system account during creation flow', () => { + const activityLog = StaffUserActivityLog.getNewInstance(createProps(), createVisa(false), { + activityType: new ValueObjects.ActivityTypeCode(ValueObjects.ActivityTypeCodes.Created), + activityDescription: new ValueObjects.Description('User created'), + activityByStaffUserId: 'staff-99', + }); + + expect(activityLog.activityType).toBe('CREATED'); + expect(activityLog.activityDescription).toBe('User created'); + expect(activityLog.activityByStaffUserId).toBe('staff-99'); + }); + + it('throws when non-system account tries to mutate activityType or activityDescription', () => { + const activityLog = new StaffUserActivityLog( + createProps({ + activityType: ValueObjects.ActivityTypeCodes.Created, + activityDescription: 'User created', + activityByStaffUserId: 'staff-99', + }), + createVisa(false), + ); + + expect(() => { + activityLog.activityType = new ValueObjects.ActivityTypeCode(ValueObjects.ActivityTypeCodes.Updated); + }).toThrow(PermissionError); + + expect(() => { + activityLog.activityDescription = new ValueObjects.Description('Updated description'); + }).toThrow(PermissionError); + }); + + it('allows manager account to mutate activityType or activityDescription', () => { + const activityLog = new StaffUserActivityLog( + createProps({ + activityType: ValueObjects.ActivityTypeCodes.Created, + activityDescription: 'User created', + activityByStaffUserId: 'staff-99', + }), + createVisa(false, true), + ); + + activityLog.activityType = new ValueObjects.ActivityTypeCode(ValueObjects.ActivityTypeCodes.Updated); + activityLog.activityDescription = new ValueObjects.Description('Updated description'); + + expect(activityLog.activityType).toBe('UPDATED'); + expect(activityLog.activityDescription).toBe('Updated description'); + }); +}); diff --git a/packages/ocom/domain/src/domain/contexts/user/staff-user/staff-user-activity-log.entity.ts b/packages/ocom/domain/src/domain/contexts/user/staff-user/staff-user-activity-log.entity.ts new file mode 100644 index 000000000..0ec9edd8b --- /dev/null +++ b/packages/ocom/domain/src/domain/contexts/user/staff-user/staff-user-activity-log.entity.ts @@ -0,0 +1,85 @@ +import { PermissionError, DomainEntity } from '@cellix/domain-seedwork/domain-entity'; +import type { DomainEntityProps } from '@cellix/domain-seedwork/domain-entity'; +import type { UserVisa } from '../user.visa.ts'; +import type * as ValueObjects from './staff-user-activity-log.value-objects.ts'; + +export interface StaffUserActivityLogProps extends DomainEntityProps { + activityType: string; + activityDescription: string; + activityByStaffUserId: string; + readonly createdAt: Date; + readonly updatedAt: Date; +} + +export interface StaffUserActivityLogEntityReference extends Readonly {} + +export interface StaffUserActivityLogCreateProps { + activityType: ValueObjects.ActivityTypeCode; + activityDescription: ValueObjects.Description; + activityByStaffUserId: string; +} + +export class StaffUserActivityLog extends DomainEntity implements StaffUserActivityLogEntityReference { + private readonly visa: UserVisa; + private isNew: boolean = false; + + constructor(props: StaffUserActivityLogProps, visa: UserVisa, createProps?: StaffUserActivityLogCreateProps) { + super(props); + this.visa = visa; + + if (createProps) { + this.isNew = true; + this.activityType = createProps.activityType; + this.activityDescription = createProps.activityDescription; + this.activityByStaffUserId = createProps.activityByStaffUserId; + this.isNew = false; + } + } + + public static getNewInstance(newProps: StaffUserActivityLogProps, visa: UserVisa, createProps: StaffUserActivityLogCreateProps): StaffUserActivityLog { + return new StaffUserActivityLog(newProps, visa, createProps); + } + + private validateVisa(): void { + if (this.isNew) { + return; + } + if (!this.visa.determineIf((permissions) => permissions.isSystemAccount || permissions.canManageStaffRolesAndPermissions)) { + throw new PermissionError('Unauthorized'); + } + } + + get activityType(): string { + return this.props.activityType; + } + set activityType(activityTypeCode: ValueObjects.ActivityTypeCode) { + this.validateVisa(); + this.props.activityType = activityTypeCode.valueOf(); + } + + get activityDescription(): string { + return this.props.activityDescription; + } + set activityDescription(activityDescription: ValueObjects.Description) { + this.validateVisa(); + this.props.activityDescription = activityDescription.valueOf(); + } + + get activityByStaffUserId(): string { + return this.props.activityByStaffUserId; + } + private set activityByStaffUserId(id: string) { + if (!this.isNew) { + throw new Error('activityByStaffUserId can only be set during creation'); + } + this.validateVisa(); + this.props.activityByStaffUserId = id; + } + + get createdAt(): Date { + return this.props.createdAt; + } + get updatedAt(): Date { + return this.props.updatedAt; + } +} diff --git a/packages/ocom/domain/src/domain/contexts/user/staff-user/staff-user-activity-log.value-objects.ts b/packages/ocom/domain/src/domain/contexts/user/staff-user/staff-user-activity-log.value-objects.ts new file mode 100644 index 000000000..ee4456fb1 --- /dev/null +++ b/packages/ocom/domain/src/domain/contexts/user/staff-user/staff-user-activity-log.value-objects.ts @@ -0,0 +1,13 @@ +import { VOSet, VOString } from '@lucaspaganini/value-objects'; + +export const ActivityTypeCodes = { + Created: 'CREATED', + Updated: 'UPDATED', + RoleAssigned: 'ROLE_ASSIGNED', + RoleRemoved: 'ROLE_REMOVED', + Blocked: 'BLOCKED', + Unblocked: 'UNBLOCKED', +} as const; + +export class Description extends VOString({ trim: true, maxLength: 2000 }) {} +export class ActivityTypeCode extends VOSet(Object.values(ActivityTypeCodes)) {} diff --git a/packages/ocom/domain/src/domain/contexts/user/staff-user/staff-user.test.ts b/packages/ocom/domain/src/domain/contexts/user/staff-user/staff-user.test.ts index 3d7cafc90..dc5171b14 100644 --- a/packages/ocom/domain/src/domain/contexts/user/staff-user/staff-user.test.ts +++ b/packages/ocom/domain/src/domain/contexts/user/staff-user/staff-user.test.ts @@ -15,14 +15,14 @@ import type { StaffRoleEntityReference, StaffRoleProps } from '../staff-role/sta const __dirname = path.dirname(fileURLToPath(import.meta.url)); const feature = await loadFeature(path.resolve(__dirname, 'features/staff-user.feature')); -function makePassport(canManageStaffRolesAndPermissions = true): Passport { +function makePassport(canManageStaffRolesAndPermissions = true, isSystemAccount = false): Passport { return vi.mocked({ user: { forStaffUser: vi.fn(() => ({ - determineIf: (fn: (p: { canManageStaffRolesAndPermissions: boolean }) => boolean) => fn({ canManageStaffRolesAndPermissions }), + determineIf: (fn: (p: { canManageStaffRolesAndPermissions: boolean; isSystemAccount: boolean }) => boolean) => fn({ canManageStaffRolesAndPermissions, isSystemAccount }), })), forStaffRole: vi.fn(() => ({ - determineIf: (fn: (p: { canManageStaffRolesAndPermissions: boolean }) => boolean) => fn({ canManageStaffRolesAndPermissions }), + determineIf: (fn: (p: { canManageStaffRolesAndPermissions: boolean; isSystemAccount: boolean }) => boolean) => fn({ canManageStaffRolesAndPermissions, isSystemAccount }), })), }, } as unknown as Passport); @@ -37,6 +37,7 @@ function makeBaseProps(overrides: Partial = {}): StaffUserProps roleName: 'test role', roleType: 'staff-role', } as StaffRoleProps); + const activityLogItems: import('./staff-user-activity-log.entity.ts').StaffUserActivityLogProps[] = []; return { id: 'staff-1', firstName: 'Alice', @@ -56,6 +57,17 @@ function makeBaseProps(overrides: Partial = {}): StaffUserProps setRoleRef: (role: StaffRoleEntityReference | undefined) => { _role = role; }, + activityLog: { + get items() { return activityLogItems as ReadonlyArray; }, + addItem: (item: import('./staff-user-activity-log.entity.ts').StaffUserActivityLogProps) => { activityLogItems.push(item); }, + getNewItem: () => { + const item = { id: `activity-${activityLogItems.length}`, activityType: '', activityDescription: '', activityByStaffUserId: '', createdAt: new Date(), updatedAt: new Date() } as import('./staff-user-activity-log.entity.ts').StaffUserActivityLogProps; + activityLogItems.push(item); + return item; + }, + removeItem: (item: import('./staff-user-activity-log.entity.ts').StaffUserActivityLogProps) => { const idx = activityLogItems.indexOf(item); if (idx > -1) activityLogItems.splice(idx, 1); }, + removeAll: () => { activityLogItems.splice(0); }, + }, ...overrides, }; } @@ -505,6 +517,137 @@ test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => { }); }); + // activityLog + Scenario('Logging a general update via requestAddUpdate', ({ Given, When, Then }) => { + Given('a StaffUser aggregate with permission to manage staff roles and permissions', () => { + passport = makePassport(true); + staffUser = new StaffUser(makeBaseProps(), passport); + }); + When('I call requestAddUpdate with description "Profile updated" and activityByStaffUserId "staff-99"', () => { + staffUser.requestAddUpdate('Profile updated', 'staff-99'); + }); + Then('an activity log entry with activityType "UPDATED" and description "Profile updated" should be added', () => { + const entries = staffUser.activityLog; + expect(entries).toHaveLength(1); + expect(entries.at(0)?.activityType).toBe('UPDATED'); + expect(entries.at(0)?.activityDescription).toBe('Profile updated'); + expect(entries.at(0)?.activityByStaffUserId).toBe('staff-99'); + }); + }); + + Scenario('requestAddUpdate without permission throws PermissionError', ({ Given, When, Then }) => { + let action: () => void; + Given('a StaffUser aggregate without permission to manage staff roles and permissions', () => { + passport = makePassport(false); + staffUser = new StaffUser(makeBaseProps(), passport); + }); + When('I try to call requestAddUpdate with description "Profile updated" and activityByStaffUserId "staff-99"', () => { + action = () => staffUser.requestAddUpdate('Profile updated', 'staff-99'); + }); + Then('a PermissionError should be thrown', () => { + expect(action).toThrow(PermissionError); + }); + }); + + Scenario('Logging a role assignment via requestRoleAssignment', ({ Given, When, Then, And }) => { + const newRole = vi.mocked({ id: 'role-99', roleName: 'New Role', roleType: 'staff-role' } as import('../staff-role/staff-role.ts').StaffRoleEntityReference); + Given('a StaffUser aggregate with permission to manage staff roles and permissions', () => { + passport = makePassport(true); + staffUser = new StaffUser(makeBaseProps(), passport); + }); + When('I call requestRoleAssignment with a valid role, description "Role assigned", and activityByStaffUserId "staff-99"', () => { + staffUser.requestRoleAssignment(newRole, 'Role assigned', 'staff-99'); + }); + Then("the staff user's role should be updated", () => { + expect(staffUser.role?.id).toBe('role-99'); + }); + And('an activity log entry with activityType "ROLE_ASSIGNED" and description "Role assigned" should be added', () => { + const entries = staffUser.activityLog; + expect(entries).toHaveLength(1); + expect(entries.at(0)?.activityType).toBe('ROLE_ASSIGNED'); + expect(entries.at(0)?.activityDescription).toBe('Role assigned'); + expect(entries.at(0)?.activityByStaffUserId).toBe('staff-99'); + }); + }); + + Scenario('Logging a role removal via requestRoleRemoval', ({ Given, When, Then, And }) => { + Given('a StaffUser aggregate with permission to manage staff roles and permissions', () => { + passport = makePassport(true); + staffUser = new StaffUser(makeBaseProps(), passport); + }); + When('I call requestRoleRemoval with description "Role removed" and activityByStaffUserId "staff-99"', () => { + staffUser.requestRoleRemoval('Role removed', 'staff-99'); + }); + Then("the staff user's role should be undefined", () => { + expect(staffUser.role).toBeUndefined(); + }); + And('an activity log entry with activityType "ROLE_REMOVED" and description "Role removed" should be added', () => { + const entries = staffUser.activityLog; + expect(entries).toHaveLength(1); + expect(entries.at(0)?.activityType).toBe('ROLE_REMOVED'); + expect(entries.at(0)?.activityDescription).toBe('Role removed'); + expect(entries.at(0)?.activityByStaffUserId).toBe('staff-99'); + }); + }); + + Scenario('Logging a block via requestBlock', ({ Given, When, Then, And }) => { + Given('a StaffUser aggregate with permission to manage staff roles and permissions', () => { + passport = makePassport(true); + staffUser = new StaffUser(makeBaseProps(), passport); + }); + When('I call requestBlock with description "User blocked" and activityByStaffUserId "staff-99"', () => { + staffUser.requestBlock('User blocked', 'staff-99'); + }); + Then("the staff user's accessBlocked should be true", () => { + expect(staffUser.accessBlocked).toBe(true); + }); + And('an activity log entry with activityType "BLOCKED" and description "User blocked" should be added', () => { + const entries = staffUser.activityLog; + expect(entries).toHaveLength(1); + expect(entries.at(0)?.activityType).toBe('BLOCKED'); + expect(entries.at(0)?.activityDescription).toBe('User blocked'); + expect(entries.at(0)?.activityByStaffUserId).toBe('staff-99'); + }); + }); + + Scenario('Logging an unblock via requestUnblock', ({ Given, When, Then, And }) => { + Given('a StaffUser aggregate with permission to manage staff roles and permissions', () => { + passport = makePassport(true); + staffUser = new StaffUser(makeBaseProps(), passport); + staffUser.accessBlocked = true; + }); + When('I call requestUnblock with description "User unblocked" and activityByStaffUserId "staff-99"', () => { + staffUser.requestUnblock('User unblocked', 'staff-99'); + }); + Then("the staff user's accessBlocked should be false", () => { + expect(staffUser.accessBlocked).toBe(false); + }); + And('an activity log entry with activityType "UNBLOCKED" and description "User unblocked" should be added', () => { + const entries = staffUser.activityLog; + expect(entries).toHaveLength(1); + expect(entries.at(0)?.activityType).toBe('UNBLOCKED'); + expect(entries.at(0)?.activityDescription).toBe('User unblocked'); + expect(entries.at(0)?.activityByStaffUserId).toBe('staff-99'); + }); + }); + // Repeat the above pattern for lastName, displayName, email, externalId, accessBlocked, tags, role, and read-only properties. // For brevity, only firstName scenarios are shown here. + + Scenario('Logging a create action via requestCreate', ({ Given, When, Then }) => { + Given('a StaffUser aggregate with permission to manage staff roles and permissions', () => { + passport = makePassport(true); + staffUser = new StaffUser(makeBaseProps(), passport); + }); + When('I call requestCreate with activityByStaffUserId "staff-99"', () => { + staffUser.requestCreate('staff-99'); + }); + Then('an activity log entry with activityType "CREATED" and description "User created" should be added', () => { + const entries = staffUser.activityLog; + expect(entries).toHaveLength(1); + expect(entries.at(0)?.activityType).toBe('CREATED'); + expect(entries.at(0)?.activityDescription).toBe('User created'); + expect(entries.at(0)?.activityByStaffUserId).toBe('staff-99'); + }); + }); }); diff --git a/packages/ocom/domain/src/domain/contexts/user/staff-user/staff-user.ts b/packages/ocom/domain/src/domain/contexts/user/staff-user/staff-user.ts index dd51f07cb..38d25a306 100644 --- a/packages/ocom/domain/src/domain/contexts/user/staff-user/staff-user.ts +++ b/packages/ocom/domain/src/domain/contexts/user/staff-user/staff-user.ts @@ -1,11 +1,18 @@ import { PermissionError } from '@cellix/domain-seedwork/domain-entity'; import { AggregateRoot } from '@cellix/domain-seedwork/aggregate-root'; import type { DomainEntityProps } from '@cellix/domain-seedwork/domain-entity'; +import type { PropArray } from '@cellix/domain-seedwork/prop-array'; import { StaffUserCreatedEvent, type StaffUserCreatedProps } from '../../../events/types/staff-user-created.ts'; import type { Passport } from '../../passport.ts'; import { StaffRole, type StaffRoleEntityReference, type StaffRoleProps } from '../staff-role/staff-role.ts'; import type { UserVisa } from '../user.visa.ts'; +import { + StaffUserActivityLog, + type StaffUserActivityLogEntityReference, + type StaffUserActivityLogProps, +} from './staff-user-activity-log.entity.ts'; import * as ValueObjects from './staff-user.value-objects.ts'; +import * as ActivityLogValueObjects from './staff-user-activity-log.value-objects.ts'; export interface StaffUserProps extends DomainEntityProps { readonly role?: StaffRoleProps; @@ -22,10 +29,12 @@ export interface StaffUserProps extends DomainEntityProps { readonly createdAt: Date; readonly updatedAt: Date; readonly schemaVersion: string; + activityLog: PropArray; } -export interface StaffUserEntityReference extends Readonly> { +export interface StaffUserEntityReference extends Readonly> { readonly role: StaffRoleEntityReference | undefined; + readonly activityLog: ReadonlyArray; } export class StaffUser extends AggregateRoot implements StaffUserEntityReference { @@ -63,6 +72,44 @@ export class StaffUser extends AggregateRoot extends AggregateRoot { + return this.props.activityLog.items.map((p) => new StaffUserActivityLog(p, this.visa)); + } + get userType(): string { return this.props.userType; } diff --git a/packages/ocom/domain/src/domain/iam/user/staff-user/contexts/features/staff-user.user.passport.feature b/packages/ocom/domain/src/domain/iam/user/staff-user/contexts/features/staff-user.user.passport.feature new file mode 100644 index 000000000..47e2d8262 --- /dev/null +++ b/packages/ocom/domain/src/domain/iam/user/staff-user/contexts/features/staff-user.user.passport.feature @@ -0,0 +1,47 @@ +Feature: StaffUserUserPassport + + Background: + Given a valid StaffUserEntityReference with externalId "ext-1" and canManageStaffRolesAndPermissions true + + Scenario: Creating a StaffUserUserPassport with a staff user + When I create a StaffUserUserPassport with the staff user + Then the passport should be created successfully + + Scenario: forEndUser returns a visa where canManageStaffRolesAndPermissions is true + Given a StaffUserUserPassport for the staff user + When I call forEndUser with any EndUserEntityReference + Then determineIf should return true for canManageStaffRolesAndPermissions + And determineIf should return false for canManageEndUsers + And determineIf should return false for canManageVendorUsers + And determineIf should return false for isSystemAccount + And determineIf should return false for isEditingOwnAccount + + Scenario: forEndUser when the staff user has no role returns a visa with all permissions false + Given a StaffUserEntityReference with no role + And a StaffUserUserPassport for that staff user + When I call forEndUser with any EndUserEntityReference + Then determineIf should return false for canManageStaffRolesAndPermissions + + Scenario: forStaffUser called with own staff user sets isEditingOwnAccount true + Given a StaffUserUserPassport for the staff user + When I call forStaffUser with the same staff user as the root + Then determineIf should return true for isEditingOwnAccount + And determineIf should return true for canManageStaffRolesAndPermissions + + Scenario: forStaffUser called with a different staff user sets isEditingOwnAccount false + Given a StaffUserUserPassport for the staff user + When I call forStaffUser with a different StaffUserEntityReference + Then determineIf should return false for isEditingOwnAccount + And determineIf should return true for canManageStaffRolesAndPermissions + + Scenario: forStaffRole returns a visa where canManageStaffRolesAndPermissions is true + Given a StaffUserUserPassport for the staff user + When I call forStaffRole with any StaffRoleEntityReference + Then determineIf should return true for canManageStaffRolesAndPermissions + And determineIf should return false for isEditingOwnAccount + + Scenario: forVendorUser returns a visa where canManageStaffRolesAndPermissions is true + Given a StaffUserUserPassport for the staff user + When I call forVendorUser with any VendorUserEntityReference + Then determineIf should return true for canManageStaffRolesAndPermissions + And determineIf should return false for canManageVendorUsers diff --git a/packages/ocom/domain/src/domain/iam/user/staff-user/contexts/staff-user.user.passport.test.ts b/packages/ocom/domain/src/domain/iam/user/staff-user/contexts/staff-user.user.passport.test.ts new file mode 100644 index 000000000..bbcaef7a7 --- /dev/null +++ b/packages/ocom/domain/src/domain/iam/user/staff-user/contexts/staff-user.user.passport.test.ts @@ -0,0 +1,179 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describeFeature, loadFeature } from '@amiceli/vitest-cucumber'; +import { expect } from 'vitest'; +import type { EndUserEntityReference } from '../../../../contexts/user/end-user/index.ts'; +import type { StaffRoleEntityReference } from '../../../../contexts/user/staff-role/staff-role.ts'; +import type { StaffUserEntityReference } from '../../../../contexts/user/staff-user/staff-user.ts'; +import type { VendorUserEntityReference } from '../../../../contexts/user/vendor-user/vendor-user.ts'; +import { StaffUserUserPassport } from './staff-user.user.passport.ts'; + +const test = { for: describeFeature }; +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const feature = await loadFeature(path.resolve(__dirname, 'features/staff-user.user.passport.feature')); + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function makeStaffUser( + externalId = 'ext-1', + canManageStaffRolesAndPermissions = true, +): StaffUserEntityReference { + return { + id: 'staff-1', + externalId, + role: { + permissions: { + communityPermissions: { + canManageStaffRolesAndPermissions, + }, + }, + }, + } as unknown as StaffUserEntityReference; +} + +function makeStaffUserNoRole(externalId = 'ext-no-role'): StaffUserEntityReference { + return { id: 'staff-no-role', externalId } as unknown as StaffUserEntityReference; +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => { + let staffUser: StaffUserEntityReference; + let passport: StaffUserUserPassport; + + BeforeEachScenario(() => { + staffUser = makeStaffUser(); + passport = undefined as unknown as StaffUserUserPassport; + }); + + Background(({ Given }) => { + Given('a valid StaffUserEntityReference with externalId "ext-1" and canManageStaffRolesAndPermissions true', () => { + staffUser = makeStaffUser('ext-1', true); + }); + }); + + // ─── Constructor ────────────────────────────────────────────────────────── + + Scenario('Creating a StaffUserUserPassport with a staff user', ({ When, Then }) => { + When('I create a StaffUserUserPassport with the staff user', () => { + passport = new StaffUserUserPassport(staffUser); + }); + Then('the passport should be created successfully', () => { + expect(passport).toBeInstanceOf(StaffUserUserPassport); + }); + }); + + // ─── forEndUser ─────────────────────────────────────────────────────────── + + Scenario('forEndUser returns a visa where canManageStaffRolesAndPermissions is true', ({ Given, When, Then, And }) => { + let visa: ReturnType; + Given('a StaffUserUserPassport for the staff user', () => { + passport = new StaffUserUserPassport(staffUser); + }); + When('I call forEndUser with any EndUserEntityReference', () => { + visa = passport.forEndUser({} as EndUserEntityReference); + }); + Then('determineIf should return true for canManageStaffRolesAndPermissions', () => { + expect(visa.determineIf((p) => p.canManageStaffRolesAndPermissions)).toBe(true); + }); + And('determineIf should return false for canManageEndUsers', () => { + expect(visa.determineIf((p) => p.canManageEndUsers)).toBe(false); + }); + And('determineIf should return false for canManageVendorUsers', () => { + expect(visa.determineIf((p) => p.canManageVendorUsers)).toBe(false); + }); + And('determineIf should return false for isSystemAccount', () => { + expect(visa.determineIf((p) => p.isSystemAccount)).toBe(false); + }); + And('determineIf should return false for isEditingOwnAccount', () => { + expect(visa.determineIf((p) => p.isEditingOwnAccount)).toBe(false); + }); + }); + + Scenario('forEndUser when the staff user has no role returns a visa with all permissions false', ({ Given, And, When, Then }) => { + let visa: ReturnType; + Given('a StaffUserEntityReference with no role', () => { + staffUser = makeStaffUserNoRole(); + }); + And('a StaffUserUserPassport for that staff user', () => { + passport = new StaffUserUserPassport(staffUser); + }); + When('I call forEndUser with any EndUserEntityReference', () => { + visa = passport.forEndUser({} as EndUserEntityReference); + }); + Then('determineIf should return false for canManageStaffRolesAndPermissions', () => { + expect(visa.determineIf((p) => p.canManageStaffRolesAndPermissions)).toBe(false); + }); + }); + + // ─── forStaffUser ───────────────────────────────────────────────────────── + + Scenario('forStaffUser called with own staff user sets isEditingOwnAccount true', ({ Given, When, Then, And }) => { + let visa: ReturnType; + Given('a StaffUserUserPassport for the staff user', () => { + passport = new StaffUserUserPassport(staffUser); + }); + When('I call forStaffUser with the same staff user as the root', () => { + visa = passport.forStaffUser(staffUser); + }); + Then('determineIf should return true for isEditingOwnAccount', () => { + expect(visa.determineIf((p) => p.isEditingOwnAccount)).toBe(true); + }); + And('determineIf should return true for canManageStaffRolesAndPermissions', () => { + expect(visa.determineIf((p) => p.canManageStaffRolesAndPermissions)).toBe(true); + }); + }); + + Scenario('forStaffUser called with a different staff user sets isEditingOwnAccount false', ({ Given, When, Then, And }) => { + let visa: ReturnType; + Given('a StaffUserUserPassport for the staff user', () => { + passport = new StaffUserUserPassport(staffUser); + }); + When('I call forStaffUser with a different StaffUserEntityReference', () => { + const otherUser = makeStaffUser('ext-other', true); + visa = passport.forStaffUser(otherUser); + }); + Then('determineIf should return false for isEditingOwnAccount', () => { + expect(visa.determineIf((p) => p.isEditingOwnAccount)).toBe(false); + }); + And('determineIf should return true for canManageStaffRolesAndPermissions', () => { + expect(visa.determineIf((p) => p.canManageStaffRolesAndPermissions)).toBe(true); + }); + }); + + // ─── forStaffRole ───────────────────────────────────────────────────────── + + Scenario('forStaffRole returns a visa where canManageStaffRolesAndPermissions is true', ({ Given, When, Then, And }) => { + let visa: ReturnType; + Given('a StaffUserUserPassport for the staff user', () => { + passport = new StaffUserUserPassport(staffUser); + }); + When('I call forStaffRole with any StaffRoleEntityReference', () => { + visa = passport.forStaffRole({} as StaffRoleEntityReference); + }); + Then('determineIf should return true for canManageStaffRolesAndPermissions', () => { + expect(visa.determineIf((p) => p.canManageStaffRolesAndPermissions)).toBe(true); + }); + And('determineIf should return false for isEditingOwnAccount', () => { + expect(visa.determineIf((p) => p.isEditingOwnAccount)).toBe(false); + }); + }); + + // ─── forVendorUser ──────────────────────────────────────────────────────── + + Scenario('forVendorUser returns a visa where canManageStaffRolesAndPermissions is true', ({ Given, When, Then, And }) => { + let visa: ReturnType; + Given('a StaffUserUserPassport for the staff user', () => { + passport = new StaffUserUserPassport(staffUser); + }); + When('I call forVendorUser with any VendorUserEntityReference', () => { + visa = passport.forVendorUser({} as VendorUserEntityReference); + }); + Then('determineIf should return true for canManageStaffRolesAndPermissions', () => { + expect(visa.determineIf((p) => p.canManageStaffRolesAndPermissions)).toBe(true); + }); + And('determineIf should return false for canManageVendorUsers', () => { + expect(visa.determineIf((p) => p.canManageVendorUsers)).toBe(false); + }); + }); +}); diff --git a/packages/ocom/domain/src/domain/iam/user/staff-user/features/staff-user.passport.feature b/packages/ocom/domain/src/domain/iam/user/staff-user/features/staff-user.passport.feature index 172644ebb..7fe6151bc 100644 --- a/packages/ocom/domain/src/domain/iam/user/staff-user/features/staff-user.passport.feature +++ b/packages/ocom/domain/src/domain/iam/user/staff-user/features/staff-user.passport.feature @@ -20,4 +20,49 @@ Feature: StaffUserPassport Scenario: Accessing the user passport When I create a StaffUserPassport with valid staff user And I access the user property - Then I should receive a StaffUserUserPassport instance \ No newline at end of file + Then I should receive a StaffUserUserPassport instance + + Scenario: Accessing the case passport + When I create a StaffUserPassport with valid staff user + And I access the case property + Then I should receive a StaffUserCasePassport instance + + Scenario: The case passport forServiceTicketV1 returns a StaffUserServiceTicketVisa + When I create a StaffUserPassport with valid staff user + And I access the case property + Then forServiceTicketV1 should return a StaffUserServiceTicketVisa + + Scenario: The case passport forViolationTicketV1 returns a StaffUserViolationTicketVisa + When I create a StaffUserPassport with valid staff user + And I access the case property + Then forViolationTicketV1 should return a StaffUserViolationTicketVisa + + Scenario: Accessing the property passport + When I create a StaffUserPassport with valid staff user + And I access the property property + Then I should receive a StaffUserPropertyPassport instance + + Scenario: The property passport forProperty returns a visa that always denies + When I create a StaffUserPassport with valid staff user + And I access the property property + Then forProperty should return a visa whose determineIf always returns false + + Scenario: Community passport is cached after first access + When I create a StaffUserPassport with valid staff user + And I access the community property twice + Then both accesses should return the same instance + + Scenario: Case passport is cached after first access + When I create a StaffUserPassport with valid staff user + And I access the case property twice + Then both accesses should return the same instance + + Scenario: Property passport is cached after first access + When I create a StaffUserPassport with valid staff user + And I access the property property twice + Then both accesses should return the same instance + + Scenario: User passport is cached after first access + When I create a StaffUserPassport with valid staff user + And I access the user property twice + Then both accesses should return the same instance \ No newline at end of file diff --git a/packages/ocom/domain/src/domain/iam/user/staff-user/staff-user.passport.test.ts b/packages/ocom/domain/src/domain/iam/user/staff-user/staff-user.passport.test.ts index 6e5f74139..4757cf6a3 100644 --- a/packages/ocom/domain/src/domain/iam/user/staff-user/staff-user.passport.test.ts +++ b/packages/ocom/domain/src/domain/iam/user/staff-user/staff-user.passport.test.ts @@ -2,11 +2,18 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { describeFeature, loadFeature } from '@amiceli/vitest-cucumber'; import { expect } from 'vitest'; +import type { ServiceTicketV1EntityReference } from '../../../contexts/case/service-ticket/v1/service-ticket-v1.aggregate.ts'; +import type { ViolationTicketV1EntityReference } from '../../../contexts/case/violation-ticket/v1/violation-ticket-v1.aggregate.ts'; import type { CommunityEntityReference } from '../../../contexts/community/community/community.ts'; +import type { PropertyEntityReference } from '../../../contexts/property/property/property.aggregate.ts'; import type { StaffUserEntityReference } from '../../../contexts/user/staff-user/staff-user.ts'; +import { StaffUserCasePassport } from './contexts/staff-user.case.passport.ts'; import { StaffUserCommunityPassport } from './contexts/staff-user.community.passport.ts'; import { StaffUserCommunityVisa } from './contexts/staff-user.community.visa.ts'; +import { StaffUserPropertyPassport } from './contexts/staff-user.property.passport.ts'; +import { StaffUserServiceTicketVisa } from './contexts/staff-user.service-ticket.visa.ts'; import { StaffUserUserPassport } from './contexts/staff-user.user.passport.ts'; +import { StaffUserViolationTicketVisa } from './contexts/staff-user.violation-ticket.visa.ts'; import { StaffUserPassport } from './staff-user.passport.ts'; const test = { for: describeFeature }; @@ -35,7 +42,7 @@ test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => { BeforeEachScenario(() => { staffUser = makeStaffUser(); passport = undefined as unknown as StaffUserPassport; - communityPassport = undefined as unknown as StaffUserCommunityPassport; + communityPassport = undefined; }); Background(({ Given }) => { @@ -54,7 +61,6 @@ test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => { }); Scenario('Accessing the community passport', ({ When, And, Then }) => { - // Uncomment and update when StaffUserPassport is implemented When('I create a StaffUserPassport with valid staff user', () => { passport = new StaffUserPassport(staffUser); }); @@ -68,7 +74,6 @@ test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => { id: 'community-1', } as CommunityEntityReference), ).toBeInstanceOf(StaffUserCommunityVisa); - // Add more assertions for visas if needed }); }); @@ -97,4 +102,138 @@ test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => { expect(userPassport).toBeInstanceOf(StaffUserUserPassport); }); }); + + // ─── case passport ─────────────────────────────────────────────────────────── + + Scenario('Accessing the case passport', ({ When, And, Then }) => { + let casePassport: unknown; + When('I create a StaffUserPassport with valid staff user', () => { + passport = new StaffUserPassport(staffUser); + }); + And('I access the case property', () => { + casePassport = passport.case; + }); + Then('I should receive a StaffUserCasePassport instance', () => { + expect(casePassport).toBeInstanceOf(StaffUserCasePassport); + }); + }); + + Scenario('The case passport forServiceTicketV1 returns a StaffUserServiceTicketVisa', ({ When, And, Then }) => { + let casePassport: StaffUserCasePassport; + When('I create a StaffUserPassport with valid staff user', () => { + passport = new StaffUserPassport(staffUser); + }); + And('I access the case property', () => { + casePassport = passport.case as StaffUserCasePassport; + }); + Then('forServiceTicketV1 should return a StaffUserServiceTicketVisa', () => { + const visa = casePassport.forServiceTicketV1({} as ServiceTicketV1EntityReference); + expect(visa).toBeInstanceOf(StaffUserServiceTicketVisa); + }); + }); + + Scenario('The case passport forViolationTicketV1 returns a StaffUserViolationTicketVisa', ({ When, And, Then }) => { + let casePassport: StaffUserCasePassport; + When('I create a StaffUserPassport with valid staff user', () => { + passport = new StaffUserPassport(staffUser); + }); + And('I access the case property', () => { + casePassport = passport.case as StaffUserCasePassport; + }); + Then('forViolationTicketV1 should return a StaffUserViolationTicketVisa', () => { + const visa = casePassport.forViolationTicketV1({} as ViolationTicketV1EntityReference); + expect(visa).toBeInstanceOf(StaffUserViolationTicketVisa); + }); + }); + + // ─── property passport ─────────────────────────────────────────────────────── + + Scenario('Accessing the property passport', ({ When, And, Then }) => { + let propertyPassport: unknown; + When('I create a StaffUserPassport with valid staff user', () => { + passport = new StaffUserPassport(staffUser); + }); + And('I access the property property', () => { + propertyPassport = passport.property; + }); + Then('I should receive a StaffUserPropertyPassport instance', () => { + expect(propertyPassport).toBeInstanceOf(StaffUserPropertyPassport); + }); + }); + + Scenario('The property passport forProperty returns a visa that always denies', ({ When, And, Then }) => { + let propertyPassport: StaffUserPropertyPassport; + When('I create a StaffUserPassport with valid staff user', () => { + passport = new StaffUserPassport(staffUser); + }); + And('I access the property property', () => { + propertyPassport = passport.property as StaffUserPropertyPassport; + }); + Then('forProperty should return a visa whose determineIf always returns false', () => { + const visa = propertyPassport.forProperty({} as PropertyEntityReference); + expect(visa.determineIf(() => true)).toBe(false); + }); + }); + + // ─── lazy-init caching ─────────────────────────────────────────────────────── + + Scenario('Community passport is cached after first access', ({ When, And, Then }) => { + let first: unknown; + let second: unknown; + When('I create a StaffUserPassport with valid staff user', () => { + passport = new StaffUserPassport(staffUser); + }); + And('I access the community property twice', () => { + first = passport.community; + second = passport.community; + }); + Then('both accesses should return the same instance', () => { + expect(first).toBe(second); + }); + }); + + Scenario('Case passport is cached after first access', ({ When, And, Then }) => { + let first: unknown; + let second: unknown; + When('I create a StaffUserPassport with valid staff user', () => { + passport = new StaffUserPassport(staffUser); + }); + And('I access the case property twice', () => { + first = passport.case; + second = passport.case; + }); + Then('both accesses should return the same instance', () => { + expect(first).toBe(second); + }); + }); + + Scenario('Property passport is cached after first access', ({ When, And, Then }) => { + let first: unknown; + let second: unknown; + When('I create a StaffUserPassport with valid staff user', () => { + passport = new StaffUserPassport(staffUser); + }); + And('I access the property property twice', () => { + first = passport.property; + second = passport.property; + }); + Then('both accesses should return the same instance', () => { + expect(first).toBe(second); + }); + }); + + Scenario('User passport is cached after first access', ({ When, And, Then }) => { + let first: unknown; + let second: unknown; + When('I create a StaffUserPassport with valid staff user', () => { + passport = new StaffUserPassport(staffUser); + }); + And('I access the user property twice', () => { + first = passport.user; + second = passport.user; + }); + Then('both accesses should return the same instance', () => { + expect(first).toBe(second); + }); + }); }); diff --git a/packages/ocom/graphql/src/schema/types/features/staff-user.resolvers.feature b/packages/ocom/graphql/src/schema/types/features/staff-user.resolvers.feature index 265800347..d9f44a154 100644 --- a/packages/ocom/graphql/src/schema/types/features/staff-user.resolvers.feature +++ b/packages/ocom/graphql/src/schema/types/features/staff-user.resolvers.feature @@ -1,8 +1,10 @@ Feature: Staff User Resolvers As an API consumer - I want to query and create staff user entities - So that I can retrieve a staff user or ensure one exists via the GraphQL API + I want to query and manage staff users and roles + So that I can administer the system via the GraphQL API + + # ─── currentStaffUserAndCreateIfNotExists ──────────────────────────────────── Scenario: Querying the current staff user and creating if not exists Given a user with a verifiedJwt in their context @@ -20,3 +22,120 @@ Feature: Staff User Resolvers Given a user without a verifiedJwt in their context When the currentStaffUserAndCreateIfNotExists query is executed Then it should throw an "Unauthorized" error + + # ─── staffUsers ────────────────────────────────────────────────────────────── + + Scenario: Listing staff users when authenticated + Given a user with a verifiedJwt in their context + When the staffUsers query is executed + Then it should return the list of staff users + + Scenario: Listing staff users when unauthenticated + Given a user without a verifiedJwt in their context + When the staffUsers query is executed + Then it should throw an "Unauthorized" error + + # ─── staffRoles ────────────────────────────────────────────────────────────── + + Scenario: Listing staff roles when authenticated + Given a user with a verifiedJwt in their context + When the staffRoles query is executed + Then it should call createDefaultRoles + And it should return the list of staff roles + + Scenario: Listing staff roles when unauthenticated + Given a user without a verifiedJwt in their context + When the staffRoles query is executed + Then it should throw an "Unauthorized" error + + # ─── staffRoleById ─────────────────────────────────────────────────────────── + + Scenario: Querying a staff role by id when authenticated + Given a user with a verifiedJwt in their context + When the staffRoleById query is executed with id "role-001" + Then it should return the staff role with id "role-001" + + Scenario: Querying a staff role by id when unauthenticated + Given a user without a verifiedJwt in their context + When the staffRoleById query is executed with id "role-001" + Then it should throw an "Unauthorized" error + + # ─── staffUserById ─────────────────────────────────────────────────────────── + + Scenario: Querying a staff user by id when the user exists + Given a user with a verifiedJwt in their context + When the staffUserById query is executed with id "user-001" + Then it should return the staff user with id "user-001" + + Scenario: Querying a staff user by id when the user does not exist + Given a user with a verifiedJwt in their context + When the staffUserById query is executed with id "user-missing" + Then it should return null + + Scenario: Querying a staff user by id when unauthenticated + Given a user without a verifiedJwt in their context + When the staffUserById query is executed with id "user-001" + Then it should throw an "Unauthorized" error + + # ─── staffRoleCreate ───────────────────────────────────────────────────────── + + Scenario: Creating a staff role as TechAdmin + Given a user with a verifiedJwt that includes the TechAdmin role + When the staffRoleCreate mutation is executed with roleName "New Role" and enterpriseAppRole "Staff.CaseManager" + Then it should return success with the created staff role + + Scenario: Creating a staff role with an unauthorized enterpriseAppRole + Given a user with a verifiedJwt that includes the CaseManager role + When the staffRoleCreate mutation is executed with roleName "New Role" and enterpriseAppRole "Staff.TechAdmin" + Then it should return failure with a permission error message + + Scenario: Creating a staff role when unauthenticated + Given a user without a verifiedJwt in their context + When the staffRoleCreate mutation is executed with roleName "New Role" and enterpriseAppRole "Staff.CaseManager" + Then it should return failure with message "Unauthorized" + + Scenario: Creating a staff role when the service throws + Given a user with a verifiedJwt that includes the TechAdmin role + When the staffRoleCreate mutation throws an error + Then it should return failure with the error message + + # ─── staffRoleUpdate ───────────────────────────────────────────────────────── + + Scenario: Updating a staff role as TechAdmin + Given a user with a verifiedJwt that includes the TechAdmin role + When the staffRoleUpdate mutation is executed with id "role-001" and enterpriseAppRole "Staff.TechAdmin" + Then it should return success with the updated staff role + + Scenario: Updating a staff role with an unauthorized enterpriseAppRole + Given a user with a verifiedJwt that includes the CaseManager role + When the staffRoleUpdate mutation is executed with id "role-001" and enterpriseAppRole "Staff.TechAdmin" + Then it should return failure with a permission error message + + Scenario: Updating a staff role when unauthenticated + Given a user without a verifiedJwt in their context + When the staffRoleUpdate mutation is executed with id "role-001" and enterpriseAppRole "Staff.TechAdmin" + Then it should return failure with message "Unauthorized" + + # ─── staffUserAssignRole ───────────────────────────────────────────────────── + + Scenario: Assigning a role as TechAdmin bypasses role-type check + Given a user with a verifiedJwt that includes the TechAdmin role + When the staffUserAssignRole mutation is executed with staffUserId "user-001" and roleId "role-001" + Then it should return success with the updated staff user + + Scenario: Assigning an allowed role as non-TechAdmin + Given a user with a verifiedJwt that includes the CaseManager role + And the role "role-001" has enterpriseAppRole "Staff.CaseManager" + When the staffUserAssignRole mutation is executed with staffUserId "user-001" and roleId "role-001" + Then it should return success with the updated staff user + + Scenario: Assigning a forbidden role as non-TechAdmin + Given a user with a verifiedJwt that includes the CaseManager role + And the role "role-001" has enterpriseAppRole "Staff.TechAdmin" + When the staffUserAssignRole mutation is executed with staffUserId "user-001" and roleId "role-001" + Then it should return failure with a permission error message + + Scenario: Assigning a role when unauthenticated + Given a user without a verifiedJwt in their context + When the staffUserAssignRole mutation is executed with staffUserId "user-001" and roleId "role-001" + Then it should return failure with message "Unauthorized" diff --git a/packages/ocom/graphql/src/schema/types/staff-user.graphql b/packages/ocom/graphql/src/schema/types/staff-user.graphql index 1def99be8..2fafd8d74 100644 --- a/packages/ocom/graphql/src/schema/types/staff-user.graphql +++ b/packages/ocom/graphql/src/schema/types/staff-user.graphql @@ -24,11 +24,22 @@ type StaffRoleTechAdminPermissions { type StaffRoleUserPermissions { canManageUsers: Boolean! + canAssignStaffRoles: Boolean! + canAssignStaffUserRoles: Boolean! @deprecated(reason: "Use canAssignStaffRoles instead") + canViewStaffUsers: Boolean! +} + +type StaffRoleRolePermissions { + canViewRoles: Boolean! + canAddRole: Boolean! + canEditRole: Boolean! + canRemoveRole: Boolean! } type StaffRolePermissions { communityPermissions: StaffRoleCommunityPermissions! financePermissions: StaffRoleFinancePermissions! + staffRolePermissions: StaffRoleRolePermissions! techAdminPermissions: StaffRoleTechAdminPermissions! userPermissions: StaffRoleUserPermissions! } @@ -37,6 +48,7 @@ type StaffRole implements MongoBase { roleName: String! isDefault: Boolean! roleType: String + enterpriseAppRole: String! permissions: StaffRolePermissions! id: ObjectID! @@ -54,6 +66,7 @@ type StaffUser implements MongoBase { accessBlocked: Boolean! tags: [String!]! role: StaffRole + activityLog: [StaffUserActivityDetail!]! id: ObjectID! schemaVersion: String @@ -61,6 +74,107 @@ type StaffUser implements MongoBase { updatedAt: DateTime } +type StaffUserActivityDetail { + activityType: String! + activityDescription: String! + activityByStaffUserId: String! + activityByStaffUserDisplayName: String! + createdAt: DateTime! + updatedAt: DateTime! +} + +input StaffRoleCreateCommunityPermissionsInput { + canManageCommunities: Boolean + canManageStaffRolesAndPermissions: Boolean + canManageAllCommunities: Boolean + canDeleteCommunities: Boolean + canChangeCommunityOwner: Boolean + canReIndexSearchCollections: Boolean +} + +input StaffRoleCreateUserPermissionsInput { + canManageUsers: Boolean + canAssignStaffRoles: Boolean + canAssignStaffUserRoles: Boolean @deprecated(reason: "Use canAssignStaffRoles instead") + canViewStaffUsers: Boolean +} + +input StaffRoleCreateRolePermissionsInput { + canViewRoles: Boolean + canAddRole: Boolean + canEditRole: Boolean + canRemoveRole: Boolean +} + +input StaffRoleCreateFinancePermissionsInput { + canManageFinance: Boolean + canViewGLBatchSummaries: Boolean + canViewFinanceConfigs: Boolean + canCreateFinanceConfigs: Boolean +} + +input StaffRoleCreateTechAdminPermissionsInput { + canManageTechAdmin: Boolean + canViewDatabaseExplorer: Boolean + canViewBlobExplorer: Boolean + canViewQueueDashboard: Boolean + canSendQueueMessages: Boolean +} + +input StaffRoleCreatePermissionsInput { + communityPermissions: StaffRoleCreateCommunityPermissionsInput + staffRolePermissions: StaffRoleCreateRolePermissionsInput + financePermissions: StaffRoleCreateFinancePermissionsInput + techAdminPermissions: StaffRoleCreateTechAdminPermissionsInput + userPermissions: StaffRoleCreateUserPermissionsInput +} + +input StaffRoleCreateInput { + roleName: String! + enterpriseAppRole: String + permissions: StaffRoleCreatePermissionsInput +} + +input StaffUserAssignRoleInput { + staffUserId: ObjectID! + roleId: ObjectID! +} + +input StaffRoleUpdatePermissionsInput { + communityPermissions: StaffRoleCreateCommunityPermissionsInput + staffRolePermissions: StaffRoleCreateRolePermissionsInput + financePermissions: StaffRoleCreateFinancePermissionsInput + techAdminPermissions: StaffRoleCreateTechAdminPermissionsInput + userPermissions: StaffRoleCreateUserPermissionsInput +} + +input StaffRoleUpdateInput { + id: ObjectID! + roleName: String! + enterpriseAppRole: String! + permissions: StaffRoleUpdatePermissionsInput +} + +type StaffRoleMutationResult implements MutationResult { + status: MutationStatus! + staffRole: StaffRole +} + +type StaffUserMutationResult implements MutationResult { + status: MutationStatus! + staffUser: StaffUser +} + extend type Query { currentStaffUserAndCreateIfNotExists: StaffUser! + staffUsers: [StaffUser!]! + staffRoles: [StaffRole!]! + staffRoleById(id: ObjectID!): StaffRole + staffUserById(id: ObjectID!): StaffUser +} + +extend type Mutation { + staffRoleCreate(input: StaffRoleCreateInput!): StaffRoleMutationResult! + staffRoleUpdate(input: StaffRoleUpdateInput!): StaffRoleMutationResult! + staffUserAssignRole(input: StaffUserAssignRoleInput!): StaffUserMutationResult! } diff --git a/packages/ocom/graphql/src/schema/types/staff-user.resolvers.test.ts b/packages/ocom/graphql/src/schema/types/staff-user.resolvers.test.ts index 66b97abcf..535f4224f 100644 --- a/packages/ocom/graphql/src/schema/types/staff-user.resolvers.test.ts +++ b/packages/ocom/graphql/src/schema/types/staff-user.resolvers.test.ts @@ -11,7 +11,12 @@ const test = { for: describeFeature }; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const feature = await loadFeature(path.resolve(__dirname, 'features/staff-user.resolvers.feature')); +// ─── Domain types ───────────────────────────────────────────────────────────── + type StaffUserEntity = Domain.Contexts.User.StaffUser.StaffUserEntityReference; +type StaffRoleEntity = Domain.Contexts.User.StaffRole.StaffRoleEntityReference; + +// ─── Mock factories ─────────────────────────────────────────────────────────── function createMockStaffUser(overrides: Partial = {}): StaffUserEntity { return { @@ -32,11 +37,23 @@ function createMockStaffUser(overrides: Partial = {}): StaffUse } as unknown as StaffUserEntity; } +function createMockStaffRole(overrides: Partial = {}): StaffRoleEntity { + return { + id: 'mock-role-id', + roleName: 'Mock Role', + enterpriseAppRole: 'Staff.CaseManager', + isDefault: false, + roleType: null, + permissions: {}, + createdAt: new Date(), + updatedAt: new Date(), + schemaVersion: '1.0', + ...overrides, + } as unknown as StaffRoleEntity; +} + function makeMockInfo(fieldName: string): GraphQLResolveInfo { - const mockFieldNode: FieldNode = { - kind: Kind.FIELD, - name: { kind: Kind.NAME, value: fieldName }, - }; + const mockFieldNode: FieldNode = { kind: Kind.FIELD, name: { kind: Kind.NAME, value: fieldName } }; return { fieldName, fieldNodes: [mockFieldNode], @@ -51,54 +68,93 @@ function makeMockInfo(fieldName: string): GraphQLResolveInfo { } as unknown as GraphQLResolveInfo; } -function makeMockGraphContext(overrides: Partial = {}): GraphContext { +type JwtOverride = { + sub?: string; + given_name?: string; + family_name?: string; + email?: string; + roles?: string[]; +}; + +function makeMockGraphContext(options: { + jwt?: JwtOverride | null; + staffUserServices?: Partial; + staffRoleServices?: Partial; +} = {}): GraphContext { + const { jwt = {}, staffUserServices = {}, staffRoleServices = {} } = options; return { applicationServices: { User: { StaffUser: { createIfNotExists: vi.fn(), + list: vi.fn(), + assignRole: vi.fn(), + create: vi.fn(), queryByExternalId: vi.fn(), + ...staffUserServices, + }, + StaffRole: { + list: vi.fn(), + createDefaultRoles: vi.fn(), + queryById: vi.fn(), + create: vi.fn(), + update: vi.fn(), + ...staffRoleServices, }, }, - verifiedUser: { - verifiedJwt: { + verifiedUser: jwt === null ? undefined : { + verifiedJwt: jwt === null ? undefined : { sub: 'default-user-sub', given_name: 'Jane', family_name: 'Smith', email: 'jane@example.com', roles: [], + ...jwt, }, }, - ...overrides.applicationServices, }, - ...overrides, } as unknown as GraphContext; } -type QueryResolver = (parent: object, args: Record, context: GraphContext, info: GraphQLResolveInfo) => Promise; +// ─── Resolver call helpers ──────────────────────────────────────────────────── + +const Query = staffUserResolvers.Query as Record unknown>; +const Mutation = staffUserResolvers.Mutation as Record unknown>; -const callCurrentStaffUserQuery = (context: GraphContext) => (staffUserResolvers.Query?.currentStaffUserAndCreateIfNotExists as unknown as QueryResolver)({}, {}, context, makeMockInfo('currentStaffUserAndCreateIfNotExists')); +const callQuery = (name: string, context: GraphContext, args: object = {}) => + // biome-ignore lint/style/noNonNullAssertion: test helper — key always exists + Query[name]!({}, args, context, makeMockInfo(name)) as Promise; + +const callMutation = (name: string, context: GraphContext, args: object = {}) => + // biome-ignore lint/style/noNonNullAssertion: test helper — key always exists + Mutation[name]!({}, args, context, makeMockInfo(name)) as Promise; + +// ─── Tests ──────────────────────────────────────────────────────────────────── test.for(feature, ({ Scenario, BeforeEachScenario }) => { let context: GraphContext; - let result: StaffUserEntity | null; + let result: unknown; + let thrownError: unknown; BeforeEachScenario(() => { context = makeMockGraphContext(); + result = undefined; + thrownError = undefined; vi.clearAllMocks(); - result = null; }); + // ─── currentStaffUserAndCreateIfNotExists ───────────────────────────────── + Scenario('Querying the current staff user and creating if not exists', ({ Given, When, Then, And }) => { const mockStaffUser = createMockStaffUser(); Given('a user with a verifiedJwt in their context', () => { - // Already set up in BeforeEachScenario with default jwt + context = makeMockGraphContext(); }); When('the currentStaffUserAndCreateIfNotExists query is executed', async () => { vi.mocked(context.applicationServices.User.StaffUser.createIfNotExists).mockResolvedValue(mockStaffUser); - result = await callCurrentStaffUserQuery(context); + result = await callQuery('currentStaffUserAndCreateIfNotExists', context); }); Then('it should call User.StaffUser.createIfNotExists with the JWT claims', () => { @@ -121,30 +177,12 @@ test.for(feature, ({ Scenario, BeforeEachScenario }) => { const aadRoles = ['Staff.CaseManager', 'Staff.Finance']; Given('a user with a verifiedJwt that includes AAD roles in their context', () => { - context = makeMockGraphContext({ - applicationServices: { - User: { - StaffUser: { - createIfNotExists: vi.fn(), - queryByExternalId: vi.fn(), - }, - }, - verifiedUser: { - verifiedJwt: { - sub: 'roles-user-sub', - given_name: 'Bob', - family_name: 'Jones', - email: 'bob@example.com', - roles: aadRoles, - }, - }, - } as unknown as GraphContext['applicationServices'], - }); + context = makeMockGraphContext({ jwt: { sub: 'roles-user-sub', given_name: 'Bob', family_name: 'Jones', email: 'bob@example.com', roles: aadRoles } }); }); When('the currentStaffUserAndCreateIfNotExists query is executed', async () => { vi.mocked(context.applicationServices.User.StaffUser.createIfNotExists).mockResolvedValue(mockStaffUser); - result = await callCurrentStaffUserQuery(context); + result = await callQuery('currentStaffUserAndCreateIfNotExists', context); }); Then('it should call User.StaffUser.createIfNotExists with the AAD roles', () => { @@ -164,17 +202,389 @@ test.for(feature, ({ Scenario, BeforeEachScenario }) => { Scenario('Querying the current staff user with no JWT', ({ Given, When, Then }) => { Given('a user without a verifiedJwt in their context', () => { - if (context.applicationServices.verifiedUser) { - context.applicationServices.verifiedUser.verifiedJwt = undefined; - } + context = makeMockGraphContext({ jwt: null }); }); When('the currentStaffUserAndCreateIfNotExists query is executed', async () => { - await expect(callCurrentStaffUserQuery(context)).rejects.toThrow('Unauthorized'); + try { + await callQuery('currentStaffUserAndCreateIfNotExists', context); + } catch (e) { + thrownError = e; + } + }); + + Then('it should throw an "Unauthorized" error', () => { + expect(thrownError).toBeDefined(); + expect((thrownError as Error).message).toBe('Unauthorized'); + }); + }); + + // ─── staffUsers ─────────────────────────────────────────────────────────── + + Scenario('Listing staff users when authenticated', ({ Given, When, Then }) => { + Given('a user with a verifiedJwt in their context', () => { + context = makeMockGraphContext(); + }); + + When('the staffUsers query is executed', async () => { + const mockUsers = [createMockStaffUser()]; + vi.mocked(context.applicationServices.User.StaffUser.list).mockResolvedValue(mockUsers); + result = await callQuery('staffUsers', context); + }); + + Then('it should return the list of staff users', () => { + expect(Array.isArray(result)).toBe(true); + }); + }); + + Scenario('Listing staff users when unauthenticated', ({ Given, When, Then }) => { + Given('a user without a verifiedJwt in their context', () => { + context = makeMockGraphContext({ jwt: null }); + }); + + When('the staffUsers query is executed', async () => { + try { + await callQuery('staffUsers', context); + } catch (e) { + thrownError = e; + } + }); + + Then('it should throw an "Unauthorized" error', () => { + expect((thrownError as Error).message).toBe('Unauthorized'); + }); + }); + + // ─── staffRoles ─────────────────────────────────────────────────────────── + + Scenario('Listing staff roles when authenticated', ({ Given, When, Then, And }) => { + const mockRoles = [createMockStaffRole()]; + + Given('a user with a verifiedJwt in their context', () => { + context = makeMockGraphContext(); + }); + + When('the staffRoles query is executed', async () => { + vi.mocked(context.applicationServices.User.StaffRole.createDefaultRoles).mockResolvedValue([]); + vi.mocked(context.applicationServices.User.StaffRole.list).mockResolvedValue(mockRoles); + result = await callQuery('staffRoles', context); + }); + + Then('it should call createDefaultRoles', () => { + expect(context.applicationServices.User.StaffRole.createDefaultRoles).toHaveBeenCalled(); + }); + + And('it should return the list of staff roles', () => { + expect(result).toEqual(mockRoles); + }); + }); + + Scenario('Listing staff roles when unauthenticated', ({ Given, When, Then }) => { + Given('a user without a verifiedJwt in their context', () => { + context = makeMockGraphContext({ jwt: null }); + }); + + When('the staffRoles query is executed', async () => { + try { + await callQuery('staffRoles', context); + } catch (e) { + thrownError = e; + } + }); + + Then('it should throw an "Unauthorized" error', () => { + expect((thrownError as Error).message).toBe('Unauthorized'); + }); + }); + + // ─── staffRoleById ──────────────────────────────────────────────────────── + + Scenario('Querying a staff role by id when authenticated', ({ Given, When, Then }) => { + const mockRole = createMockStaffRole({ id: 'role-001' }); + + Given('a user with a verifiedJwt in their context', () => { + context = makeMockGraphContext(); + }); + + When('the staffRoleById query is executed with id "role-001"', async () => { + vi.mocked(context.applicationServices.User.StaffRole.queryById).mockResolvedValue(mockRole); + result = await callQuery('staffRoleById', context, { id: 'role-001' }); + }); + + Then('it should return the staff role with id "role-001"', () => { + expect(result).toEqual(mockRole); + expect(context.applicationServices.User.StaffRole.queryById).toHaveBeenCalledWith({ roleId: 'role-001' }); + }); + }); + + Scenario('Querying a staff role by id when unauthenticated', ({ Given, When, Then }) => { + Given('a user without a verifiedJwt in their context', () => { + context = makeMockGraphContext({ jwt: null }); + }); + + When('the staffRoleById query is executed with id "role-001"', async () => { + try { + await callQuery('staffRoleById', context, { id: 'role-001' }); + } catch (e) { + thrownError = e; + } + }); + + Then('it should throw an "Unauthorized" error', () => { + expect((thrownError as Error).message).toBe('Unauthorized'); + }); + }); + + // ─── staffUserById ──────────────────────────────────────────────────────── + + Scenario('Querying a staff user by id when the user exists', ({ Given, When, Then }) => { + const mockUser = createMockStaffUser({ id: 'user-001' }); + + Given('a user with a verifiedJwt in their context', () => { + context = makeMockGraphContext(); + }); + + When('the staffUserById query is executed with id "user-001"', async () => { + vi.mocked(context.applicationServices.User.StaffUser.list).mockResolvedValue([mockUser]); + result = await callQuery('staffUserById', context, { id: 'user-001' }); + }); + + Then('it should return the staff user with id "user-001"', () => { + expect((result as StaffUserEntity)?.id).toBe('user-001'); + }); + }); + + Scenario('Querying a staff user by id when the user does not exist', ({ Given, When, Then }) => { + Given('a user with a verifiedJwt in their context', () => { + context = makeMockGraphContext(); + }); + + When('the staffUserById query is executed with id "user-missing"', async () => { + vi.mocked(context.applicationServices.User.StaffUser.list).mockResolvedValue([createMockStaffUser({ id: 'user-001' })]); + result = await callQuery('staffUserById', context, { id: 'user-missing' }); + }); + + Then('it should return null', () => { + expect(result).toBeNull(); + }); + }); + + Scenario('Querying a staff user by id when unauthenticated', ({ Given, When, Then }) => { + Given('a user without a verifiedJwt in their context', () => { + context = makeMockGraphContext({ jwt: null }); + }); + + When('the staffUserById query is executed with id "user-001"', async () => { + try { + await callQuery('staffUserById', context, { id: 'user-001' }); + } catch (e) { + thrownError = e; + } }); Then('it should throw an "Unauthorized" error', () => { - // Already asserted in When + expect((thrownError as Error).message).toBe('Unauthorized'); + }); + }); + + // ─── staffRoleCreate ────────────────────────────────────────────────────── + + Scenario('Creating a staff role as TechAdmin', ({ Given, When, Then }) => { + Given('a user with a verifiedJwt that includes the TechAdmin role', () => { + context = makeMockGraphContext({ jwt: { roles: ['Staff.TechAdmin'] } }); + }); + + When('the staffRoleCreate mutation is executed with roleName "New Role" and enterpriseAppRole "Staff.CaseManager"', async () => { + const mockRole = createMockStaffRole({ roleName: 'New Role', enterpriseAppRole: 'Staff.CaseManager' }); + vi.mocked(context.applicationServices.User.StaffRole.create).mockResolvedValue(mockRole); + result = await callMutation('staffRoleCreate', context, { input: { roleName: 'New Role', enterpriseAppRole: 'Staff.CaseManager' } }); + }); + + Then('it should return success with the created staff role', () => { + expect((result as { status: { success: boolean } }).status.success).toBe(true); + expect((result as { staffRole: StaffRoleEntity }).staffRole).toBeDefined(); + }); + }); + + Scenario('Creating a staff role with an unauthorized enterpriseAppRole', ({ Given, When, Then }) => { + Given('a user with a verifiedJwt that includes the CaseManager role', () => { + context = makeMockGraphContext({ jwt: { roles: ['Staff.CaseManager'] } }); + }); + + When('the staffRoleCreate mutation is executed with roleName "New Role" and enterpriseAppRole "Staff.TechAdmin"', async () => { + result = await callMutation('staffRoleCreate', context, { input: { roleName: 'New Role', enterpriseAppRole: 'Staff.TechAdmin' } }); + }); + + Then('it should return failure with a permission error message', () => { + const { status } = result as { status: { success: boolean; errorMessage: string } }; + expect(status.success).toBe(false); + expect(status.errorMessage).toContain('Staff.TechAdmin'); + }); + }); + + Scenario('Creating a staff role when unauthenticated', ({ Given, When, Then }) => { + Given('a user without a verifiedJwt in their context', () => { + context = makeMockGraphContext({ jwt: null }); + }); + + When('the staffRoleCreate mutation is executed with roleName "New Role" and enterpriseAppRole "Staff.CaseManager"', async () => { + result = await callMutation('staffRoleCreate', context, { input: { roleName: 'New Role', enterpriseAppRole: 'Staff.CaseManager' } }); + }); + + Then('it should return failure with message "Unauthorized"', () => { + const { status } = result as { status: { success: boolean; errorMessage: string } }; + expect(status.success).toBe(false); + expect(status.errorMessage).toBe('Unauthorized'); + }); + }); + + Scenario('Creating a staff role when the service throws', ({ Given, When, Then }) => { + Given('a user with a verifiedJwt that includes the TechAdmin role', () => { + context = makeMockGraphContext({ jwt: { roles: ['Staff.TechAdmin'] } }); + }); + + When('the staffRoleCreate mutation throws an error', async () => { + vi.mocked(context.applicationServices.User.StaffRole.create).mockRejectedValue(new Error('DB failure')); + result = await callMutation('staffRoleCreate', context, { input: { roleName: 'New Role', enterpriseAppRole: 'Staff.TechAdmin' } }); + }); + + Then('it should return failure with the error message', () => { + const { status } = result as { status: { success: boolean; errorMessage: string } }; + expect(status.success).toBe(false); + expect(status.errorMessage).toBe('DB failure'); + }); + }); + + // ─── staffRoleUpdate ────────────────────────────────────────────────────── + + Scenario('Updating a staff role as TechAdmin', ({ Given, When, Then }) => { + Given('a user with a verifiedJwt that includes the TechAdmin role', () => { + context = makeMockGraphContext({ jwt: { roles: ['Staff.TechAdmin'] } }); + }); + + When('the staffRoleUpdate mutation is executed with id "role-001" and enterpriseAppRole "Staff.TechAdmin"', async () => { + const mockRole = createMockStaffRole({ id: 'role-001', enterpriseAppRole: 'Staff.TechAdmin' }); + vi.mocked(context.applicationServices.User.StaffRole.update).mockResolvedValue(mockRole); + result = await callMutation('staffRoleUpdate', context, { input: { id: 'role-001', roleName: 'Updated Role', enterpriseAppRole: 'Staff.TechAdmin' } }); + }); + + Then('it should return success with the updated staff role', () => { + const res = result as { status: { success: boolean }; staffRole: StaffRoleEntity }; + expect(res.status.success).toBe(true); + expect(res.staffRole).toBeDefined(); + }); + }); + + Scenario('Updating a staff role with an unauthorized enterpriseAppRole', ({ Given, When, Then }) => { + Given('a user with a verifiedJwt that includes the CaseManager role', () => { + context = makeMockGraphContext({ jwt: { roles: ['Staff.CaseManager'] } }); + }); + + When('the staffRoleUpdate mutation is executed with id "role-001" and enterpriseAppRole "Staff.TechAdmin"', async () => { + result = await callMutation('staffRoleUpdate', context, { input: { id: 'role-001', roleName: 'Updated', enterpriseAppRole: 'Staff.TechAdmin' } }); + }); + + Then('it should return failure with a permission error message', () => { + const { status } = result as { status: { success: boolean; errorMessage: string } }; + expect(status.success).toBe(false); + expect(status.errorMessage).toContain('Staff.TechAdmin'); }); }); + + Scenario('Updating a staff role when unauthenticated', ({ Given, When, Then }) => { + Given('a user without a verifiedJwt in their context', () => { + context = makeMockGraphContext({ jwt: null }); + }); + + When('the staffRoleUpdate mutation is executed with id "role-001" and enterpriseAppRole "Staff.TechAdmin"', async () => { + result = await callMutation('staffRoleUpdate', context, { input: { id: 'role-001', roleName: 'Updated', enterpriseAppRole: 'Staff.TechAdmin' } }); + }); + + Then('it should return failure with message "Unauthorized"', () => { + const { status } = result as { status: { success: boolean; errorMessage: string } }; + expect(status.success).toBe(false); + expect(status.errorMessage).toBe('Unauthorized'); + }); + }); + + // ─── staffUserAssignRole ────────────────────────────────────────────────── + + Scenario('Assigning a role as TechAdmin bypasses role-type check', ({ Given, When, Then }) => { + Given('a user with a verifiedJwt that includes the TechAdmin role', () => { + context = makeMockGraphContext({ jwt: { roles: ['Staff.TechAdmin'] } }); + }); + + When('the staffUserAssignRole mutation is executed with staffUserId "user-001" and roleId "role-001"', async () => { + const mockUser = createMockStaffUser({ id: 'user-001' }); + vi.mocked(context.applicationServices.User.StaffUser.assignRole).mockResolvedValue(mockUser); + result = await callMutation('staffUserAssignRole', context, { input: { staffUserId: 'user-001', roleId: 'role-001' } }); + }); + + Then('it should return success with the updated staff user', () => { + const res = result as { status: { success: boolean }; staffUser: StaffUserEntity }; + expect(res.status.success).toBe(true); + expect(res.staffUser).toBeDefined(); + }); + }); + + Scenario('Assigning an allowed role as non-TechAdmin', ({ Given, When, Then, And }) => { + Given('a user with a verifiedJwt that includes the CaseManager role', () => { + context = makeMockGraphContext({ jwt: { roles: ['Staff.CaseManager'] } }); + }); + + And('the role "role-001" has enterpriseAppRole "Staff.CaseManager"', () => { + const allowedRole = createMockStaffRole({ id: 'role-001', enterpriseAppRole: 'Staff.CaseManager' }); + vi.mocked(context.applicationServices.User.StaffRole.list).mockResolvedValue([allowedRole]); + }); + + When('the staffUserAssignRole mutation is executed with staffUserId "user-001" and roleId "role-001"', async () => { + const mockUser = createMockStaffUser({ id: 'user-001' }); + vi.mocked(context.applicationServices.User.StaffUser.assignRole).mockResolvedValue(mockUser); + result = await callMutation('staffUserAssignRole', context, { input: { staffUserId: 'user-001', roleId: 'role-001' } }); + }); + + Then('it should return success with the updated staff user', () => { + const res = result as { status: { success: boolean }; staffUser: StaffUserEntity }; + expect(res.status.success).toBe(true); + expect(res.staffUser).toBeDefined(); + }); + }); + + Scenario('Assigning a forbidden role as non-TechAdmin', ({ Given, When, Then, And }) => { + Given('a user with a verifiedJwt that includes the CaseManager role', () => { + context = makeMockGraphContext({ jwt: { roles: ['Staff.CaseManager'] } }); + }); + + And('the role "role-001" has enterpriseAppRole "Staff.TechAdmin"', () => { + const forbiddenRole = createMockStaffRole({ id: 'role-001', enterpriseAppRole: 'Staff.TechAdmin' }); + vi.mocked(context.applicationServices.User.StaffRole.list).mockResolvedValue([forbiddenRole]); + }); + + When('the staffUserAssignRole mutation is executed with staffUserId "user-001" and roleId "role-001"', async () => { + result = await callMutation('staffUserAssignRole', context, { input: { staffUserId: 'user-001', roleId: 'role-001' } }); + }); + + Then('it should return failure with a permission error message', () => { + const { status } = result as { status: { success: boolean; errorMessage: string } }; + expect(status.success).toBe(false); + expect(status.errorMessage).toContain('Staff.TechAdmin'); + }); + }); + + Scenario('Assigning a role when unauthenticated', ({ Given, When, Then }) => { + Given('a user without a verifiedJwt in their context', () => { + context = makeMockGraphContext({ jwt: null }); + }); + + When('the staffUserAssignRole mutation is executed with staffUserId "user-001" and roleId "role-001"', async () => { + result = await callMutation('staffUserAssignRole', context, { input: { staffUserId: 'user-001', roleId: 'role-001' } }); + }); + + Then('it should return failure with message "Unauthorized"', () => { + const { status } = result as { status: { success: boolean; errorMessage: string } }; + expect(status.success).toBe(false); + expect(status.errorMessage).toBe('Unauthorized'); + }); + }); + }); diff --git a/packages/ocom/graphql/src/schema/types/staff-user.resolvers.ts b/packages/ocom/graphql/src/schema/types/staff-user.resolvers.ts index 38be5afa1..45459bc8d 100644 --- a/packages/ocom/graphql/src/schema/types/staff-user.resolvers.ts +++ b/packages/ocom/graphql/src/schema/types/staff-user.resolvers.ts @@ -2,7 +2,43 @@ import type { GraphQLResolveInfo } from 'graphql'; import type { Resolvers } from '../builder/generated.ts'; import type { GraphContext } from '../context.ts'; +const EnterpriseAppRoleNames = { + CaseManager: 'Staff.CaseManager', + ServiceLineOwner: 'Staff.ServiceLineOwner', + Finance: 'Staff.Finance', + TechAdmin: 'Staff.TechAdmin', +} as const; + +/** Returns the enterprise app role types a caller is allowed to target, based on their Entra roles. */ +function getAllowedEnterpriseAppRoles(entraRoles: string[]): string[] { + if (entraRoles.includes(EnterpriseAppRoleNames.TechAdmin)) { + return Object.values(EnterpriseAppRoleNames); + } + const allowed: string[] = []; + if (entraRoles.includes(EnterpriseAppRoleNames.ServiceLineOwner)) { + allowed.push(EnterpriseAppRoleNames.ServiceLineOwner, EnterpriseAppRoleNames.CaseManager); + } + if (entraRoles.includes(EnterpriseAppRoleNames.CaseManager) && !allowed.includes(EnterpriseAppRoleNames.CaseManager)) { + allowed.push(EnterpriseAppRoleNames.CaseManager); + } + if (entraRoles.includes(EnterpriseAppRoleNames.Finance)) { + allowed.push(EnterpriseAppRoleNames.Finance); + } + return allowed; +} + const staffUser: Resolvers = { + StaffUserActivityDetail: { + activityByStaffUserDisplayName: async (parent, _args, context: GraphContext) => { + if (!context.applicationServices.verifiedUser?.verifiedJwt) { + return parent.activityByStaffUserId; + } + const users = await context.applicationServices.User.StaffUser.list(); + const found = users.find((u) => String(u.id) === String(parent.activityByStaffUserId)); + return found?.displayName ?? parent.activityByStaffUserId; + }, + }, + Query: { currentStaffUserAndCreateIfNotExists: async (_parent, _args, context: GraphContext, _info: GraphQLResolveInfo) => { const jwt = context.applicationServices.verifiedUser?.verifiedJwt; @@ -18,6 +54,196 @@ const staffUser: Resolvers = { }); return result; }, + + staffUsers: async (_parent, _args, context: GraphContext, _info: GraphQLResolveInfo) => { + if (!context.applicationServices.verifiedUser?.verifiedJwt) { + throw new Error('Unauthorized'); + } + return await context.applicationServices.User.StaffUser.list(); + }, + + staffRoles: async (_parent, _args, context: GraphContext, _info: GraphQLResolveInfo) => { + if (!context.applicationServices.verifiedUser?.verifiedJwt) { + throw new Error('Unauthorized'); + } + await context.applicationServices.User.StaffRole.createDefaultRoles(); + return await context.applicationServices.User.StaffRole.list(); + }, + + staffRoleById: async (_parent, args: { id: string }, context: GraphContext, _info: GraphQLResolveInfo) => { + if (!context.applicationServices.verifiedUser?.verifiedJwt) { + throw new Error('Unauthorized'); + } + return await context.applicationServices.User.StaffRole.queryById({ roleId: String(args.id) }); + }, + + staffUserById: async (_parent, args: { id: string }, context: GraphContext, _info: GraphQLResolveInfo) => { + if (!context.applicationServices.verifiedUser?.verifiedJwt) { + throw new Error('Unauthorized'); + } + const users = await context.applicationServices.User.StaffUser.list(); + return users.find((u) => String(u.id) === String(args.id)) ?? null; + }, + }, + + Mutation: { + staffRoleCreate: async (_parent, args: { input: { roleName: string; enterpriseAppRole?: string | null; permissions?: { communityPermissions?: { canManageCommunities?: boolean | null; canManageStaffRolesAndPermissions?: boolean | null; canManageAllCommunities?: boolean | null; canDeleteCommunities?: boolean | null; canChangeCommunityOwner?: boolean | null; canReIndexSearchCollections?: boolean | null } | null; userPermissions?: { canManageUsers?: boolean | null; canAssignStaffRoles?: boolean | null; canAssignStaffUserRoles?: boolean | null; canViewStaffUsers?: boolean | null } | null; staffRolePermissions?: { canViewRoles?: boolean | null; canAddRole?: boolean | null; canEditRole?: boolean | null; canRemoveRole?: boolean | null } | null; financePermissions?: { canManageFinance?: boolean | null; canViewGLBatchSummaries?: boolean | null; canViewFinanceConfigs?: boolean | null; canCreateFinanceConfigs?: boolean | null } | null; techAdminPermissions?: { canManageTechAdmin?: boolean | null; canViewDatabaseExplorer?: boolean | null; canViewBlobExplorer?: boolean | null; canViewQueueDashboard?: boolean | null; canSendQueueMessages?: boolean | null } | null } | null } }, context: GraphContext, _info: GraphQLResolveInfo) => { + const jwt = context.applicationServices.verifiedUser?.verifiedJwt; + if (!jwt) { + return { status: { success: false, errorMessage: 'Unauthorized' } }; + } + try { + const entraRoles = jwt.roles ?? []; + const allowedEnterpriseAppRoles = getAllowedEnterpriseAppRoles(entraRoles); + const requestedEnterpriseAppRole = args.input.enterpriseAppRole ?? ''; + if (requestedEnterpriseAppRole && !allowedEnterpriseAppRoles.includes(requestedEnterpriseAppRole)) { + return { status: { success: false, errorMessage: `You do not have permission to create a role for enterprise app role type: ${requestedEnterpriseAppRole}` } }; + } + const staffRole = await context.applicationServices.User.StaffRole.create({ + roleName: args.input.roleName, + ...(requestedEnterpriseAppRole ? { enterpriseAppRole: requestedEnterpriseAppRole } : {}), + permissions: { + community: { + canManageCommunities: args.input.permissions?.communityPermissions?.canManageCommunities ?? false, + canManageStaffRolesAndPermissions: args.input.permissions?.communityPermissions?.canManageStaffRolesAndPermissions ?? false, + canManageAllCommunities: args.input.permissions?.communityPermissions?.canManageAllCommunities ?? false, + canDeleteCommunities: args.input.permissions?.communityPermissions?.canDeleteCommunities ?? false, + canChangeCommunityOwner: args.input.permissions?.communityPermissions?.canChangeCommunityOwner ?? false, + canReIndexSearchCollections: args.input.permissions?.communityPermissions?.canReIndexSearchCollections ?? false, + }, + user: { + canManageUsers: args.input.permissions?.userPermissions?.canManageUsers ?? false, + canAssignStaffRoles: args.input.permissions?.userPermissions?.canAssignStaffRoles ?? args.input.permissions?.userPermissions?.canAssignStaffUserRoles ?? false, + canAssignStaffUserRoles: args.input.permissions?.userPermissions?.canAssignStaffUserRoles ?? args.input.permissions?.userPermissions?.canAssignStaffRoles ?? false, + canViewStaffUsers: args.input.permissions?.userPermissions?.canViewStaffUsers ?? false, + }, + staffRole: { + canViewRoles: args.input.permissions?.staffRolePermissions?.canViewRoles ?? false, + canAddRole: args.input.permissions?.staffRolePermissions?.canAddRole ?? false, + canEditRole: args.input.permissions?.staffRolePermissions?.canEditRole ?? false, + canRemoveRole: args.input.permissions?.staffRolePermissions?.canRemoveRole ?? false, + }, + finance: { + canManageFinance: args.input.permissions?.financePermissions?.canManageFinance ?? false, + canViewGLBatchSummaries: args.input.permissions?.financePermissions?.canViewGLBatchSummaries ?? false, + canViewFinanceConfigs: args.input.permissions?.financePermissions?.canViewFinanceConfigs ?? false, + canCreateFinanceConfigs: args.input.permissions?.financePermissions?.canCreateFinanceConfigs ?? false, + }, + techAdmin: { + canManageTechAdmin: args.input.permissions?.techAdminPermissions?.canManageTechAdmin ?? false, + canViewDatabaseExplorer: args.input.permissions?.techAdminPermissions?.canViewDatabaseExplorer ?? false, + canViewBlobExplorer: args.input.permissions?.techAdminPermissions?.canViewBlobExplorer ?? false, + canViewQueueDashboard: args.input.permissions?.techAdminPermissions?.canViewQueueDashboard ?? false, + canSendQueueMessages: args.input.permissions?.techAdminPermissions?.canSendQueueMessages ?? false, + }, + }, + }); + return { status: { success: true }, staffRole }; + } catch (error) { + console.error('StaffRole > staffRoleCreate: ', error); + const { message } = error as Error; + return { status: { success: false, errorMessage: message } }; + } + }, + + staffRoleUpdate: async (_parent, args: { input: { id: string; roleName: string; enterpriseAppRole: string; permissions?: { communityPermissions?: { canManageCommunities?: boolean | null; canManageStaffRolesAndPermissions?: boolean | null; canManageAllCommunities?: boolean | null; canDeleteCommunities?: boolean | null; canChangeCommunityOwner?: boolean | null; canReIndexSearchCollections?: boolean | null } | null; userPermissions?: { canManageUsers?: boolean | null; canAssignStaffRoles?: boolean | null; canAssignStaffUserRoles?: boolean | null; canViewStaffUsers?: boolean | null } | null; staffRolePermissions?: { canViewRoles?: boolean | null; canAddRole?: boolean | null; canEditRole?: boolean | null; canRemoveRole?: boolean | null } | null; financePermissions?: { canManageFinance?: boolean | null; canViewGLBatchSummaries?: boolean | null; canViewFinanceConfigs?: boolean | null; canCreateFinanceConfigs?: boolean | null } | null; techAdminPermissions?: { canManageTechAdmin?: boolean | null; canViewDatabaseExplorer?: boolean | null; canViewBlobExplorer?: boolean | null; canViewQueueDashboard?: boolean | null; canSendQueueMessages?: boolean | null } | null } | null } }, context: GraphContext, _info: GraphQLResolveInfo) => { + const jwt = context.applicationServices.verifiedUser?.verifiedJwt; + if (!jwt) { + return { status: { success: false, errorMessage: 'Unauthorized' } }; + } + try { + const entraRoles = jwt.roles ?? []; + const allowedEnterpriseAppRoles = getAllowedEnterpriseAppRoles(entraRoles); + if (!allowedEnterpriseAppRoles.includes(args.input.enterpriseAppRole)) { + return { status: { success: false, errorMessage: `You do not have permission to update a role for enterprise app role type: ${args.input.enterpriseAppRole}` } }; + } + const communityPerms = args.input.permissions?.communityPermissions; + const userPerms = args.input.permissions?.userPermissions; + const staffRole = await context.applicationServices.User.StaffRole.update({ + roleId: String(args.input.id), + roleName: args.input.roleName, + ...(args.input.enterpriseAppRole ? { enterpriseAppRole: args.input.enterpriseAppRole } : {}), + permissions: { + community: { + ...(communityPerms?.canManageCommunities != null ? { canManageCommunities: communityPerms.canManageCommunities } : {}), + ...(communityPerms?.canManageStaffRolesAndPermissions != null ? { canManageStaffRolesAndPermissions: communityPerms.canManageStaffRolesAndPermissions } : {}), + ...(communityPerms?.canManageAllCommunities != null ? { canManageAllCommunities: communityPerms.canManageAllCommunities } : {}), + ...(communityPerms?.canDeleteCommunities != null ? { canDeleteCommunities: communityPerms.canDeleteCommunities } : {}), + ...(communityPerms?.canChangeCommunityOwner != null ? { canChangeCommunityOwner: communityPerms.canChangeCommunityOwner } : {}), + ...(communityPerms?.canReIndexSearchCollections != null ? { canReIndexSearchCollections: communityPerms.canReIndexSearchCollections } : {}), + }, + user: { + ...(userPerms?.canManageUsers != null ? { canManageUsers: userPerms.canManageUsers } : {}), + ...(userPerms?.canAssignStaffRoles != null ? { canAssignStaffRoles: userPerms.canAssignStaffRoles } : {}), + ...(userPerms?.canAssignStaffUserRoles != null ? { canAssignStaffRoles: userPerms.canAssignStaffUserRoles, canAssignStaffUserRoles: userPerms.canAssignStaffUserRoles } : {}), + ...(userPerms?.canViewStaffUsers != null ? { canViewStaffUsers: userPerms.canViewStaffUsers } : {}), + }, + staffRole: { + ...(args.input.permissions?.staffRolePermissions?.canViewRoles != null ? { canViewRoles: args.input.permissions.staffRolePermissions.canViewRoles } : {}), + ...(args.input.permissions?.staffRolePermissions?.canAddRole != null ? { canAddRole: args.input.permissions.staffRolePermissions.canAddRole } : {}), + ...(args.input.permissions?.staffRolePermissions?.canEditRole != null ? { canEditRole: args.input.permissions.staffRolePermissions.canEditRole } : {}), + ...(args.input.permissions?.staffRolePermissions?.canRemoveRole != null ? { canRemoveRole: args.input.permissions.staffRolePermissions.canRemoveRole } : {}), + }, + finance: { + ...(args.input.permissions?.financePermissions?.canManageFinance != null ? { canManageFinance: args.input.permissions.financePermissions.canManageFinance } : {}), + ...(args.input.permissions?.financePermissions?.canViewGLBatchSummaries != null ? { canViewGLBatchSummaries: args.input.permissions.financePermissions.canViewGLBatchSummaries } : {}), + ...(args.input.permissions?.financePermissions?.canViewFinanceConfigs != null ? { canViewFinanceConfigs: args.input.permissions.financePermissions.canViewFinanceConfigs } : {}), + ...(args.input.permissions?.financePermissions?.canCreateFinanceConfigs != null ? { canCreateFinanceConfigs: args.input.permissions.financePermissions.canCreateFinanceConfigs } : {}), + }, + techAdmin: { + ...(args.input.permissions?.techAdminPermissions?.canManageTechAdmin != null ? { canManageTechAdmin: args.input.permissions.techAdminPermissions.canManageTechAdmin } : {}), + ...(args.input.permissions?.techAdminPermissions?.canViewDatabaseExplorer != null ? { canViewDatabaseExplorer: args.input.permissions.techAdminPermissions.canViewDatabaseExplorer } : {}), + ...(args.input.permissions?.techAdminPermissions?.canViewBlobExplorer != null ? { canViewBlobExplorer: args.input.permissions.techAdminPermissions.canViewBlobExplorer } : {}), + ...(args.input.permissions?.techAdminPermissions?.canViewQueueDashboard != null ? { canViewQueueDashboard: args.input.permissions.techAdminPermissions.canViewQueueDashboard } : {}), + ...(args.input.permissions?.techAdminPermissions?.canSendQueueMessages != null ? { canSendQueueMessages: args.input.permissions.techAdminPermissions.canSendQueueMessages } : {}), + }, + }, + }); + return { status: { success: true }, staffRole }; + } catch (error) { + console.error('StaffRole > staffRoleUpdate: ', error); + const { message } = error as Error; + return { status: { success: false, errorMessage: message } }; + } + }, + + staffUserAssignRole: async (_parent, args: { input: { staffUserId: string; roleId: string } }, context: GraphContext, _info: GraphQLResolveInfo) => { + const jwt = context.applicationServices.verifiedUser?.verifiedJwt; + if (!jwt) { + return { status: { success: false, errorMessage: 'Unauthorized' } }; + } + try { + const entraRoles = jwt.roles ?? []; + const allowedEnterpriseAppRoles = getAllowedEnterpriseAppRoles(entraRoles); + const isTechAdmin = entraRoles.includes(EnterpriseAppRoleNames.TechAdmin); + + if (!isTechAdmin) { + const allRoles = await context.applicationServices.User.StaffRole.list(); + const roleToAssign = allRoles.find((r) => String(r.id) === String(args.input.roleId)); + if (!roleToAssign) { + return { status: { success: false, errorMessage: 'Role not found' } }; + } + if (!allowedEnterpriseAppRoles.includes(roleToAssign.enterpriseAppRole)) { + return { status: { success: false, errorMessage: `You do not have permission to assign roles of type: ${roleToAssign.enterpriseAppRole}` } }; + } + } + + const actorStaffUser = await context.applicationServices.User.StaffUser.queryByExternalId({ externalId: jwt.sub }); + const actorStaffUserId = actorStaffUser?.id ?? jwt.sub; + + const staffUser = await context.applicationServices.User.StaffUser.assignRole({ + staffUserId: String(args.input.staffUserId), + roleId: String(args.input.roleId), + actorStaffUserId, + }); + return { status: { success: true }, staffUser }; + } catch (error) { + console.error('StaffUser > staffUserAssignRole: ', error); + const { message } = error as Error; + return { status: { success: false, errorMessage: message } }; + } + }, + }, }; diff --git a/packages/ocom/graphql/src/schema/types/staff-user.resolvers.unit.test.ts b/packages/ocom/graphql/src/schema/types/staff-user.resolvers.unit.test.ts new file mode 100644 index 000000000..4d94979f1 --- /dev/null +++ b/packages/ocom/graphql/src/schema/types/staff-user.resolvers.unit.test.ts @@ -0,0 +1,54 @@ +import type { GraphQLResolveInfo } from 'graphql'; +import { describe, expect, it, vi } from 'vitest'; +import type { StaffUserMutationResult } from '../builder/generated.ts'; +import type { GraphContext } from '../context.ts'; +import staffUserResolvers from './staff-user.resolvers.ts'; + +describe('staff-user.resolvers - unit tests', () => { + it('currentStaffUserAndCreateIfNotExists throws Unauthorized when no verifiedJwt', async () => { + const ctx = { applicationServices: {} } as unknown as GraphContext; + const Query = staffUserResolvers.Query as NonNullable; + const currentStaffUserAndCreateIfNotExists = Query.currentStaffUserAndCreateIfNotExists as unknown as ( + parent: unknown, + args: unknown, + context: GraphContext, + info: GraphQLResolveInfo, + ) => Promise; + await expect(currentStaffUserAndCreateIfNotExists(null, null, ctx, {} as unknown as GraphQLResolveInfo)).rejects.toThrow('Unauthorized'); + }); + + it('staffUserAssignRole returns failure status when assignRole throws', async () => { + // assignRole will throw; resolver should catch and return a failure status + const ctx = { + applicationServices: { + verifiedUser: { verifiedJwt: { sub: 'actor-1', roles: ['Staff.CaseManager'] } }, + User: { + StaffRole: { list: async () => [{ id: 'r1', enterpriseAppRole: 'Staff.CaseManager' }] }, + StaffUser: { + queryByExternalId: async () => null, + assignRole: () => Promise.reject(new Error('assign failed')), + }, + }, + }, + } as unknown as GraphContext; + + const consoleErr = vi.spyOn(console, 'error').mockImplementation(() => { + /* noop */ + }); + const Mutation = staffUserResolvers.Mutation as NonNullable; + const staffUserAssignRoleFn = Mutation.staffUserAssignRole as unknown as ( + parent: unknown, + args: { input: { staffUserId: string; roleId: string } }, + context: GraphContext, + info: GraphQLResolveInfo, + ) => Promise; + const res = await staffUserAssignRoleFn(null, { input: { staffUserId: 's1', roleId: 'r1' } }, ctx, {} as unknown as GraphQLResolveInfo); + const resTyped = res as StaffUserMutationResult; + expect(resTyped).toBeDefined(); + expect(resTyped.status).toBeDefined(); + expect(resTyped.status.success).toBe(false); + expect(resTyped.status.errorMessage).toBe('assign failed'); + expect(consoleErr).toHaveBeenCalled(); + consoleErr.mockRestore(); + }); +}); diff --git a/packages/ocom/persistence/src/datasources/domain/community/member/member-invitation.domain-adapter.test.ts b/packages/ocom/persistence/src/datasources/domain/community/member/member-invitation.domain-adapter.test.ts index 8ca4d7dc5..f603299e5 100644 --- a/packages/ocom/persistence/src/datasources/domain/community/member/member-invitation.domain-adapter.test.ts +++ b/packages/ocom/persistence/src/datasources/domain/community/member/member-invitation.domain-adapter.test.ts @@ -121,7 +121,7 @@ test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => { Scenario('Getting message when document message is undefined', ({ Given, When, Then }) => { Given('a MemberInvitationDomainAdapter for a document with no message', () => { const docWithoutMessage = makeMemberInvitationDoc(); - delete (docWithoutMessage as unknown as Record)['message']; + delete (docWithoutMessage as unknown as Record).message; doc = docWithoutMessage; adapter = new MemberInvitationDomainAdapter(doc); }); diff --git a/packages/ocom/persistence/src/datasources/domain/community/member/member-invitation.repository.test.ts b/packages/ocom/persistence/src/datasources/domain/community/member/member-invitation.repository.test.ts index d9ff9c1d4..9fbb947ce 100644 --- a/packages/ocom/persistence/src/datasources/domain/community/member/member-invitation.repository.test.ts +++ b/packages/ocom/persistence/src/datasources/domain/community/member/member-invitation.repository.test.ts @@ -75,7 +75,7 @@ test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => { })), find: vi.fn((query: Record) => ({ exec: vi.fn(() => { - if (query['communityId'] === 'empty-community') return []; + if (query.communityId === 'empty-community') return []; return [invitationDoc]; }), })), diff --git a/packages/ocom/persistence/src/datasources/domain/user/staff-role/features/staff-role.domain-adapter.feature b/packages/ocom/persistence/src/datasources/domain/user/staff-role/features/staff-role.domain-adapter.feature index b9f141636..6a08bf6ed 100644 --- a/packages/ocom/persistence/src/datasources/domain/user/staff-role/features/staff-role.domain-adapter.feature +++ b/packages/ocom/persistence/src/datasources/domain/user/staff-role/features/staff-role.domain-adapter.feature @@ -316,4 +316,98 @@ Feature: StaffRoleDomainAdapter Scenario: Getting roleType returns null when document roleType is undefined Given a StaffRoleDomainAdapter wrapping a document with no roleType When I get the roleType property - Then it should return null \ No newline at end of file + Then it should return null + + # ─── enterpriseAppRole ────────────────────────────────────────────────────── + + Scenario: Getting enterpriseAppRole returns empty string when not set on the document + Given a StaffRoleDomainAdapter for the document + When I get the enterpriseAppRole property + Then it should return an empty string + + Scenario: Getting and setting the enterpriseAppRole property + Given a StaffRoleDomainAdapter for the document + When I set the enterpriseAppRole property to "LeadManager" + Then the document's enterpriseAppRole should be "LeadManager" + + Scenario: Setting roleName also updates enterpriseAppRole on the document + Given a StaffRoleDomainAdapter for the document + When I set the roleName property to "Director" + Then the document's enterpriseAppRole should also be "Director" + + # ─── canAssignStaffUserRoles ───────────────────────────────────────────────── + + Scenario: Getting and setting canAssignStaffUserRoles from userPermissions + Given a StaffRoleDomainAdapter for the document + When I get the permissions property + And I get the userPermissions property + Then the canAssignStaffUserRoles property should return false + When I set the canAssignStaffUserRoles property to true + Then the userPermissions' canAssignStaffUserRoles should be true + + Scenario: canAssignStaffRoles getter falls back to canAssignStaffUserRoles when unset + Given a StaffRoleDomainAdapter wrapping a document with userPermissions having only canAssignStaffUserRoles true + When I get the permissions property + And I get the userPermissions property + Then the canAssignStaffRoles property should return true + + Scenario: Setting canAssignStaffRoles updates both canAssignStaffRoles and canAssignStaffUserRoles + Given a StaffRoleDomainAdapter for the document + When I get the permissions property + And I get the userPermissions property + When I set the canAssignStaffRoles property to true + Then the userPermissions' canAssignStaffRoles should be true + And the userPermissions' canAssignStaffUserRoles should be true + + # ─── violationTicketPermissions setters ────────────────────────────────────── + + Scenario: Setting canManageTickets on violationTicketPermissions + Given a StaffRoleDomainAdapter for the document + When I get the permissions property + And I get the violationTicketPermissions property + When I set the canManageTickets property to true + Then the violationTicketPermissions' canManageTickets should be true + + Scenario: Setting canAssignTickets on violationTicketPermissions + Given a StaffRoleDomainAdapter for the document + When I get the permissions property + And I get the violationTicketPermissions property + When I set the canAssignTickets property to true + Then the violationTicketPermissions' canAssignTickets should be true + + Scenario: Setting canWorkOnTickets on violationTicketPermissions + Given a StaffRoleDomainAdapter for the document + When I get the permissions property + And I get the violationTicketPermissions property + When I set the canWorkOnTickets property to true + Then the violationTicketPermissions' canWorkOnTickets should be true + + # ─── Lazy-init remaining sub-documents ─────────────────────────────────────── + + Scenario: Lazy-initialising propertyPermissions when sub-document is absent + Given a StaffRoleDomainAdapter wrapping a document with no propertyPermissions sub-document + When I get the permissions property + And I get the propertyPermissions property + Then it should return a StaffRolePropertyPermissionsAdapter instance + And canManageProperties should default to false + + Scenario: Lazy-initialising servicePermissions when sub-document is absent + Given a StaffRoleDomainAdapter wrapping a document with no servicePermissions sub-document + When I get the permissions property + And I get the servicePermissions property + Then it should return a StaffRoleServicePermissionsAdapter instance + And canManageServices should default to false + + Scenario: Lazy-initialising serviceTicketPermissions when sub-document is absent + Given a StaffRoleDomainAdapter wrapping a document with no serviceTicketPermissions sub-document + When I get the permissions property + And I get the serviceTicketPermissions property + Then it should return a StaffRoleServiceTicketPermissionsAdapter instance + And canCreateTickets should default to false + + Scenario: Lazy-initialising violationTicketPermissions when sub-document is absent + Given a StaffRoleDomainAdapter wrapping a document with no violationTicketPermissions sub-document + When I get the permissions property + And I get the violationTicketPermissions property + Then it should return a StaffRoleViolationTicketPermissionsAdapter instance + And canCreateTickets should default to false \ No newline at end of file diff --git a/packages/ocom/persistence/src/datasources/domain/user/staff-role/staff-role.domain-adapter.test.ts b/packages/ocom/persistence/src/datasources/domain/user/staff-role/staff-role.domain-adapter.test.ts index a4566488b..c3585f415 100644 --- a/packages/ocom/persistence/src/datasources/domain/user/staff-role/staff-role.domain-adapter.test.ts +++ b/packages/ocom/persistence/src/datasources/domain/user/staff-role/staff-role.domain-adapter.test.ts @@ -1,25 +1,25 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { describeFeature, loadFeature } from '@amiceli/vitest-cucumber'; -import { expect, vi } from 'vitest'; -import { Domain } from '@ocom/domain'; import type { StaffRole } from '@ocom/data-sources-mongoose-models/role/staff-role'; - -const test = { for: describeFeature }; +import { Domain } from '@ocom/domain'; +import { expect, vi } from 'vitest'; import { + StaffRoleCommunityPermissionsAdapter, StaffRoleConverter, StaffRoleDomainAdapter, + StaffRoleFinancePermissionsAdapter, StaffRolePermissionsAdapter, - StaffRoleCommunityPermissionsAdapter, StaffRolePropertyPermissionsAdapter, StaffRoleServicePermissionsAdapter, StaffRoleServiceTicketPermissionsAdapter, - StaffRoleViolationTicketPermissionsAdapter, - StaffRoleFinancePermissionsAdapter, StaffRoleTechAdminPermissionsAdapter, StaffRoleUserPermissionsAdapter, + StaffRoleViolationTicketPermissionsAdapter, } from './staff-role.domain-adapter.ts'; +const test = { for: describeFeature }; + const __dirname = path.dirname(fileURLToPath(import.meta.url)); const domainAdapterFeature = await loadFeature(path.resolve(__dirname, 'features/staff-role.domain-adapter.feature')); const typeConverterFeature = await loadFeature(path.resolve(__dirname, 'features/staff-role.type-converter.feature')); @@ -44,6 +44,12 @@ function makeStaffRoleDoc(overrides: Partial = {}) { servicePermissions: { canManageServices: false, }, + userPermissions: { + canManageUsers: false, + canAssignStaffRoles: false, + canAssignStaffUserRoles: false, + canViewStaffUsers: false, + }, serviceTicketPermissions: { canCreateTickets: false, canManageTickets: false, @@ -873,7 +879,7 @@ test.for(domainAdapterFeature, ({ Scenario, Background, BeforeEachScenario }) => docWithoutPermissions.set = vi.fn().mockImplementation((key: string, value: unknown) => { (docWithoutPermissions as unknown as Record)[key] = value; }); - (docWithoutPermissions as unknown as Record)['permissions'] = undefined; + (docWithoutPermissions as unknown as Record).permissions = undefined; adapter = new StaffRoleDomainAdapter(docWithoutPermissions); }); When('I get the permissions property', () => { @@ -889,7 +895,7 @@ test.for(domainAdapterFeature, ({ Scenario, Background, BeforeEachScenario }) => Given('a StaffRoleDomainAdapter wrapping a document with no communityPermissions sub-document', () => { const docWithout = makeStaffRoleDoc(); if (docWithout.permissions) { - (docWithout.permissions as unknown as Record)['communityPermissions'] = undefined; + (docWithout.permissions as unknown as Record).communityPermissions = undefined; } adapter = new StaffRoleDomainAdapter(docWithout); }); @@ -912,7 +918,7 @@ test.for(domainAdapterFeature, ({ Scenario, Background, BeforeEachScenario }) => Given('a StaffRoleDomainAdapter wrapping a document with no financePermissions sub-document', () => { const docWithout = makeStaffRoleDoc(); if (docWithout.permissions) { - (docWithout.permissions as unknown as Record)['financePermissions'] = undefined; + (docWithout.permissions as unknown as Record).financePermissions = undefined; } adapter = new StaffRoleDomainAdapter(docWithout); }); @@ -935,7 +941,7 @@ test.for(domainAdapterFeature, ({ Scenario, Background, BeforeEachScenario }) => Given('a StaffRoleDomainAdapter wrapping a document with no techAdminPermissions sub-document', () => { const docWithout = makeStaffRoleDoc(); if (docWithout.permissions) { - (docWithout.permissions as unknown as Record)['techAdminPermissions'] = undefined; + (docWithout.permissions as unknown as Record).techAdminPermissions = undefined; } adapter = new StaffRoleDomainAdapter(docWithout); }); @@ -958,7 +964,7 @@ test.for(domainAdapterFeature, ({ Scenario, Background, BeforeEachScenario }) => Given('a StaffRoleDomainAdapter wrapping a document with no userPermissions sub-document', () => { const docWithout = makeStaffRoleDoc(); if (docWithout.permissions) { - (docWithout.permissions as unknown as Record)['userPermissions'] = undefined; + (docWithout.permissions as unknown as Record).userPermissions = undefined; } adapter = new StaffRoleDomainAdapter(docWithout); }); @@ -979,7 +985,7 @@ test.for(domainAdapterFeature, ({ Scenario, Background, BeforeEachScenario }) => Scenario('Getting roleType returns null when document roleType is undefined', ({ Given, When, Then }) => { Given('a StaffRoleDomainAdapter wrapping a document with no roleType', () => { const docWithout = makeStaffRoleDoc(); - (docWithout as unknown as Record)['roleType'] = undefined; + (docWithout as unknown as Record).roleType = undefined; adapter = new StaffRoleDomainAdapter(docWithout); }); When('I get the roleType property', () => { @@ -989,6 +995,276 @@ test.for(domainAdapterFeature, ({ Scenario, Background, BeforeEachScenario }) => expect(result).toBeNull(); }); }); + + // ─── enterpriseAppRole ──────────────────────────────────────────────────── + + Scenario('Getting enterpriseAppRole returns empty string when not set on the document', ({ Given, When, Then }) => { + Given('a StaffRoleDomainAdapter for the document', () => { + adapter = new StaffRoleDomainAdapter(doc); + }); + When('I get the enterpriseAppRole property', () => { + result = adapter.enterpriseAppRole; + }); + Then('it should return an empty string', () => { + expect(result).toBe(''); + }); + }); + + Scenario('Getting and setting the enterpriseAppRole property', ({ Given, When, Then }) => { + Given('a StaffRoleDomainAdapter for the document', () => { + adapter = new StaffRoleDomainAdapter(doc); + }); + When('I set the enterpriseAppRole property to "LeadManager"', () => { + adapter.enterpriseAppRole = 'LeadManager'; + }); + Then('the document\'s enterpriseAppRole should be "LeadManager"', () => { + expect(doc.enterpriseAppRole).toBe('LeadManager'); + }); + }); + + Scenario('Setting roleName also updates enterpriseAppRole on the document', ({ Given, When, Then }) => { + Given('a StaffRoleDomainAdapter for the document', () => { + adapter = new StaffRoleDomainAdapter(doc); + }); + When('I set the roleName property to "Director"', () => { + adapter.roleName = 'Director'; + }); + Then('the document\'s enterpriseAppRole should also be "Director"', () => { + expect(doc.enterpriseAppRole).toBe('Director'); + }); + }); + + // ─── canAssignStaffUserRoles ────────────────────────────────────────────── + + Scenario('Getting and setting canAssignStaffUserRoles from userPermissions', ({ Given, When, And, Then }) => { + let permissions: StaffRolePermissionsAdapter; + let userPermissions: StaffRoleUserPermissionsAdapter; + Given('a StaffRoleDomainAdapter for the document', () => { + adapter = new StaffRoleDomainAdapter(doc); + }); + When('I get the permissions property', () => { + permissions = adapter.permissions as StaffRolePermissionsAdapter; + }); + And('I get the userPermissions property', () => { + userPermissions = permissions.userPermissions as StaffRoleUserPermissionsAdapter; + }); + Then('the canAssignStaffUserRoles property should return false', () => { + expect(userPermissions.canAssignStaffUserRoles).toBe(false); + }); + When('I set the canAssignStaffUserRoles property to true', () => { + userPermissions.canAssignStaffUserRoles = true; + }); + Then("the userPermissions' canAssignStaffUserRoles should be true", () => { + expect(doc.permissions?.userPermissions?.canAssignStaffUserRoles).toBe(true); + }); + }); + + Scenario('canAssignStaffRoles getter falls back to canAssignStaffUserRoles when unset', ({ Given, When, And, Then }) => { + let permissions: StaffRolePermissionsAdapter; + let userPermissions: StaffRoleUserPermissionsAdapter; + Given('a StaffRoleDomainAdapter wrapping a document with userPermissions having only canAssignStaffUserRoles true', () => { + const docWith = makeStaffRoleDoc({ + permissions: { + ...(makeStaffRoleDoc().permissions ?? {}), + userPermissions: { + canManageUsers: false, + canAssignStaffRoles: true, + canAssignStaffUserRoles: true, + canViewStaffUsers: false, + }, + }, + }); + adapter = new StaffRoleDomainAdapter(docWith); + }); + When('I get the permissions property', () => { + permissions = adapter.permissions as StaffRolePermissionsAdapter; + }); + And('I get the userPermissions property', () => { + userPermissions = permissions.userPermissions as StaffRoleUserPermissionsAdapter; + }); + Then('the canAssignStaffRoles property should return true', () => { + expect(userPermissions.canAssignStaffRoles).toBe(true); + }); + }); + + Scenario('Setting canAssignStaffRoles updates both canAssignStaffRoles and canAssignStaffUserRoles', ({ Given, When, And, Then }) => { + let permissions: StaffRolePermissionsAdapter; + let userPermissions: StaffRoleUserPermissionsAdapter; + Given('a StaffRoleDomainAdapter for the document', () => { + adapter = new StaffRoleDomainAdapter(doc); + }); + When('I get the permissions property', () => { + permissions = adapter.permissions as StaffRolePermissionsAdapter; + }); + And('I get the userPermissions property', () => { + userPermissions = permissions.userPermissions as StaffRoleUserPermissionsAdapter; + }); + When('I set the canAssignStaffRoles property to true', () => { + userPermissions.canAssignStaffRoles = true; + }); + Then("the userPermissions' canAssignStaffRoles should be true", () => { + expect(doc.permissions?.userPermissions?.canAssignStaffRoles).toBe(true); + }); + And("the userPermissions' canAssignStaffUserRoles should be true", () => { + expect(doc.permissions?.userPermissions?.canAssignStaffUserRoles).toBe(true); + }); + }); + + // ─── violationTicketPermissions setters ─────────────────────────────────── + + Scenario('Setting canManageTickets on violationTicketPermissions', ({ Given, When, And, Then }) => { + let permissions: StaffRolePermissionsAdapter; + let violationTicketPermissions: StaffRoleViolationTicketPermissionsAdapter; + Given('a StaffRoleDomainAdapter for the document', () => { + adapter = new StaffRoleDomainAdapter(doc); + }); + When('I get the permissions property', () => { + permissions = adapter.permissions as StaffRolePermissionsAdapter; + }); + And('I get the violationTicketPermissions property', () => { + violationTicketPermissions = permissions.violationTicketPermissions as StaffRoleViolationTicketPermissionsAdapter; + }); + When('I set the canManageTickets property to true', () => { + violationTicketPermissions.canManageTickets = true; + }); + Then("the violationTicketPermissions' canManageTickets should be true", () => { + expect(doc.permissions?.violationTicketPermissions?.canManageTickets).toBe(true); + }); + }); + + Scenario('Setting canAssignTickets on violationTicketPermissions', ({ Given, When, And, Then }) => { + let permissions: StaffRolePermissionsAdapter; + let violationTicketPermissions: StaffRoleViolationTicketPermissionsAdapter; + Given('a StaffRoleDomainAdapter for the document', () => { + adapter = new StaffRoleDomainAdapter(doc); + }); + When('I get the permissions property', () => { + permissions = adapter.permissions as StaffRolePermissionsAdapter; + }); + And('I get the violationTicketPermissions property', () => { + violationTicketPermissions = permissions.violationTicketPermissions as StaffRoleViolationTicketPermissionsAdapter; + }); + When('I set the canAssignTickets property to true', () => { + violationTicketPermissions.canAssignTickets = true; + }); + Then("the violationTicketPermissions' canAssignTickets should be true", () => { + expect(doc.permissions?.violationTicketPermissions?.canAssignTickets).toBe(true); + }); + }); + + Scenario('Setting canWorkOnTickets on violationTicketPermissions', ({ Given, When, And, Then }) => { + let permissions: StaffRolePermissionsAdapter; + let violationTicketPermissions: StaffRoleViolationTicketPermissionsAdapter; + Given('a StaffRoleDomainAdapter for the document', () => { + adapter = new StaffRoleDomainAdapter(doc); + }); + When('I get the permissions property', () => { + permissions = adapter.permissions as StaffRolePermissionsAdapter; + }); + And('I get the violationTicketPermissions property', () => { + violationTicketPermissions = permissions.violationTicketPermissions as StaffRoleViolationTicketPermissionsAdapter; + }); + When('I set the canWorkOnTickets property to true', () => { + violationTicketPermissions.canWorkOnTickets = true; + }); + Then("the violationTicketPermissions' canWorkOnTickets should be true", () => { + expect(doc.permissions?.violationTicketPermissions?.canWorkOnTickets).toBe(true); + }); + }); + + // ─── Lazy-init remaining sub-documents ──────────────────────────────────── + + Scenario('Lazy-initialising propertyPermissions when sub-document is absent', ({ Given, When, And, Then }) => { + let permissions: StaffRolePermissionsAdapter; + Given('a StaffRoleDomainAdapter wrapping a document with no propertyPermissions sub-document', () => { + const docWithout = makeStaffRoleDoc(); + if (docWithout.permissions) { + (docWithout.permissions as unknown as Record).propertyPermissions = undefined; + } + adapter = new StaffRoleDomainAdapter(docWithout); + }); + When('I get the permissions property', () => { + permissions = adapter.permissions as StaffRolePermissionsAdapter; + }); + And('I get the propertyPermissions property', () => { + result = permissions.propertyPermissions; + }); + Then('it should return a StaffRolePropertyPermissionsAdapter instance', () => { + expect(result).toBeInstanceOf(StaffRolePropertyPermissionsAdapter); + }); + And('canManageProperties should default to false', () => { + expect((result as StaffRolePropertyPermissionsAdapter).canManageProperties).toBe(false); + }); + }); + + Scenario('Lazy-initialising servicePermissions when sub-document is absent', ({ Given, When, And, Then }) => { + let permissions: StaffRolePermissionsAdapter; + Given('a StaffRoleDomainAdapter wrapping a document with no servicePermissions sub-document', () => { + const docWithout = makeStaffRoleDoc(); + if (docWithout.permissions) { + (docWithout.permissions as unknown as Record).servicePermissions = undefined; + } + adapter = new StaffRoleDomainAdapter(docWithout); + }); + When('I get the permissions property', () => { + permissions = adapter.permissions as StaffRolePermissionsAdapter; + }); + And('I get the servicePermissions property', () => { + result = permissions.servicePermissions; + }); + Then('it should return a StaffRoleServicePermissionsAdapter instance', () => { + expect(result).toBeInstanceOf(StaffRoleServicePermissionsAdapter); + }); + And('canManageServices should default to false', () => { + expect((result as StaffRoleServicePermissionsAdapter).canManageServices).toBe(false); + }); + }); + + Scenario('Lazy-initialising serviceTicketPermissions when sub-document is absent', ({ Given, When, And, Then }) => { + let permissions: StaffRolePermissionsAdapter; + Given('a StaffRoleDomainAdapter wrapping a document with no serviceTicketPermissions sub-document', () => { + const docWithout = makeStaffRoleDoc(); + if (docWithout.permissions) { + (docWithout.permissions as unknown as Record).serviceTicketPermissions = undefined; + } + adapter = new StaffRoleDomainAdapter(docWithout); + }); + When('I get the permissions property', () => { + permissions = adapter.permissions as StaffRolePermissionsAdapter; + }); + And('I get the serviceTicketPermissions property', () => { + result = permissions.serviceTicketPermissions; + }); + Then('it should return a StaffRoleServiceTicketPermissionsAdapter instance', () => { + expect(result).toBeInstanceOf(StaffRoleServiceTicketPermissionsAdapter); + }); + And('canCreateTickets should default to false', () => { + expect((result as StaffRoleServiceTicketPermissionsAdapter).canCreateTickets).toBe(false); + }); + }); + + Scenario('Lazy-initialising violationTicketPermissions when sub-document is absent', ({ Given, When, And, Then }) => { + let permissions: StaffRolePermissionsAdapter; + Given('a StaffRoleDomainAdapter wrapping a document with no violationTicketPermissions sub-document', () => { + const docWithout = makeStaffRoleDoc(); + if (docWithout.permissions) { + (docWithout.permissions as unknown as Record).violationTicketPermissions = undefined; + } + adapter = new StaffRoleDomainAdapter(docWithout); + }); + When('I get the permissions property', () => { + permissions = adapter.permissions as StaffRolePermissionsAdapter; + }); + And('I get the violationTicketPermissions property', () => { + result = permissions.violationTicketPermissions; + }); + Then('it should return a StaffRoleViolationTicketPermissionsAdapter instance', () => { + expect(result).toBeInstanceOf(StaffRoleViolationTicketPermissionsAdapter); + }); + And('canCreateTickets should default to false', () => { + expect((result as StaffRoleViolationTicketPermissionsAdapter).canCreateTickets).toBe(false); + }); + }); }); test.for(typeConverterFeature, ({ Scenario, Background, BeforeEachScenario }) => { diff --git a/packages/ocom/persistence/src/datasources/domain/user/staff-role/staff-role.domain-adapter.ts b/packages/ocom/persistence/src/datasources/domain/user/staff-role/staff-role.domain-adapter.ts index 7bbf2c918..ea5b0b771 100644 --- a/packages/ocom/persistence/src/datasources/domain/user/staff-role/staff-role.domain-adapter.ts +++ b/packages/ocom/persistence/src/datasources/domain/user/staff-role/staff-role.domain-adapter.ts @@ -3,6 +3,7 @@ import type { StaffRole, StaffRoleCommunityPermissions, StaffRoleFinancePermissions, + StaffRoleRolePermissions, StaffRolePermissions, StaffRolePropertyPermissions, StaffRoleServicePermissions, @@ -152,10 +153,25 @@ export class StaffRolePermissionsAdapter implements Domain.Contexts.User.StaffRo if (!this.doc.userPermissions) { this.doc.userPermissions = { canManageUsers: false, + canAssignStaffRoles: false, + canAssignStaffUserRoles: false, + canViewStaffUsers: false, }; } return new StaffRoleUserPermissionsAdapter(this.doc.userPermissions); } + + get staffRolePermissions(): Domain.Contexts.User.StaffRole.StaffRoleRolePermissionsProps { + if (!this.doc.staffRolePermissions) { + this.doc.staffRolePermissions = { + canViewRoles: false, + canAddRole: false, + canEditRole: false, + canRemoveRole: false, + }; + } + return new StaffRoleRolePermissionsAdapter(this.doc.staffRolePermissions); + } } export class StaffRoleCommunityPermissionsAdapter implements Domain.Contexts.User.StaffRole.StaffRoleCommunityPermissionsProps { @@ -441,4 +457,67 @@ export class StaffRoleUserPermissionsAdapter implements Domain.Contexts.User.Sta set canManageUsers(value: boolean) { this.doc.canManageUsers = value; } + + get canAssignStaffRoles(): boolean { + return this.ensureValue(this.doc.canAssignStaffRoles ?? this.doc.canAssignStaffUserRoles); + } + set canAssignStaffRoles(value: boolean) { + this.doc.canAssignStaffRoles = value; + this.doc.canAssignStaffUserRoles = value; + } + + get canAssignStaffUserRoles(): boolean { + return this.ensureValue(this.doc.canAssignStaffRoles ?? this.doc.canAssignStaffUserRoles); + } + set canAssignStaffUserRoles(value: boolean) { + this.doc.canAssignStaffRoles = value; + this.doc.canAssignStaffUserRoles = value; + } + + get canViewStaffUsers(): boolean { + return this.ensureValue(this.doc.canViewStaffUsers); + } + set canViewStaffUsers(value: boolean) { + this.doc.canViewStaffUsers = value; + } +} + +class StaffRoleRolePermissionsAdapter implements Domain.Contexts.User.StaffRole.StaffRoleRolePermissionsProps { + public readonly doc: StaffRoleRolePermissions; + + constructor(permissions: StaffRoleRolePermissions) { + this.doc = permissions; + } + + get id(): string | undefined { + return this.doc.id?.toString(); + } + + get canViewRoles(): boolean { + return this.doc.canViewRoles; + } + set canViewRoles(value: boolean) { + this.doc.canViewRoles = value; + } + + get canAddRole(): boolean { + return this.doc.canAddRole; + } + set canAddRole(value: boolean) { + this.doc.canAddRole = value; + } + + get canEditRole(): boolean { + return this.doc.canEditRole; + } + set canEditRole(value: boolean) { + this.doc.canEditRole = value; + } + + get canRemoveRole(): boolean { + return this.doc.canRemoveRole; + } + set canRemoveRole(value: boolean) { + this.doc.canRemoveRole = value; + } } diff --git a/packages/ocom/persistence/src/datasources/domain/user/staff-role/staff-role.repository.test.ts b/packages/ocom/persistence/src/datasources/domain/user/staff-role/staff-role.repository.test.ts index 1940529fc..8d7d392ee 100644 --- a/packages/ocom/persistence/src/datasources/domain/user/staff-role/staff-role.repository.test.ts +++ b/packages/ocom/persistence/src/datasources/domain/user/staff-role/staff-role.repository.test.ts @@ -85,14 +85,17 @@ test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => { }; Object.assign(ModelMock, { findById: vi.fn((id: string) => ({ - exec: vi.fn(() => Promise.resolve(id === String(staffRoleDoc._id) ? staffRoleDoc : null)), - })), - findOne: vi.fn((query: { roleName?: string; isDefault?: boolean; enterpriseAppRole?: string }) => ({ + exec: vi.fn(() => (id === staffRoleDoc._id ? staffRoleDoc : null)), + })), + findOne: vi.fn((query: { roleName?: string; enterpriseAppRole?: string; isDefault?: boolean }) => ({ exec: vi.fn(() => { if (query.enterpriseAppRole !== undefined) { return query.enterpriseAppRole === staffRoleDoc.enterpriseAppRole && query.isDefault === staffRoleDoc.isDefault ? staffRoleDoc : null; } - return query.roleName === staffRoleDoc.roleName ? staffRoleDoc : null; + if (query.enterpriseAppRole && query.isDefault === true) { + return query.enterpriseAppRole === staffRoleDoc.enterpriseAppRole && staffRoleDoc.isDefault ? staffRoleDoc : null; + } + return null; }), })), prototype: {}, diff --git a/packages/ocom/persistence/src/datasources/domain/user/staff-user/staff-user.domain-adapter.ts b/packages/ocom/persistence/src/datasources/domain/user/staff-user/staff-user.domain-adapter.ts index f9865ef55..c230ad5b8 100644 --- a/packages/ocom/persistence/src/datasources/domain/user/staff-user/staff-user.domain-adapter.ts +++ b/packages/ocom/persistence/src/datasources/domain/user/staff-user/staff-user.domain-adapter.ts @@ -1,9 +1,10 @@ import { MongooseSeedwork } from '@cellix/mongoose-seedwork'; +import type { PropArray } from '@cellix/domain-seedwork/prop-array'; import { Domain } from '@ocom/domain'; import { StaffRoleDomainAdapter } from '../staff-role/staff-role.domain-adapter.ts'; import type { StaffRole } from '@ocom/data-sources-mongoose-models/role/staff-role'; -import type { StaffUser } from '@ocom/data-sources-mongoose-models/user/staff-user'; +import type { StaffUser, StaffUserActivityDetail } from '@ocom/data-sources-mongoose-models/user/staff-user'; export class StaffUserDomainAdapter extends MongooseSeedwork.MongooseDomainAdapter implements Domain.Contexts.User.StaffUser.StaffUserProps { get role(): Domain.Contexts.User.StaffRole.StaffRoleProps { @@ -101,6 +102,51 @@ export class StaffUserDomainAdapter extends MongooseSeedwork.MongooseDomainAdapt override get schemaVersion(): string { return this.doc.schemaVersion ?? '1.0.0'; } + + get activityLog(): PropArray { + return new MongooseSeedwork.MongoosePropArray(this.doc.activityLog, StaffUserActivityLogDomainAdapter); + } +} + +class StaffUserActivityLogDomainAdapter implements Domain.Contexts.User.StaffUser.StaffUserActivityLogProps { + public readonly doc: StaffUserActivityDetail; + + constructor(doc: StaffUserActivityDetail) { + this.doc = doc; + } + + get id(): string { + return this.doc.id?.valueOf() as string; + } + + get activityType(): string { + return this.doc.activityType; + } + set activityType(activityType: string) { + this.doc.activityType = activityType; + } + + get activityDescription(): string { + return this.doc.activityDescription; + } + set activityDescription(activityDescription: string) { + this.doc.activityDescription = activityDescription; + } + + get activityByStaffUserId(): string { + return this.doc.activityBy?.valueOf() as string; + } + set activityByStaffUserId(id: string) { + this.doc.set('activityBy', new MongooseSeedwork.ObjectId(id)); + } + + get createdAt(): Date { + return this.doc.createdAt; + } + + get updatedAt(): Date { + return this.doc.updatedAt; + } } export class StaffUserConverter extends MongooseSeedwork.MongoTypeConverter> { diff --git a/packages/ocom/persistence/src/datasources/readonly/index.test.ts b/packages/ocom/persistence/src/datasources/readonly/index.test.ts index 536162ec4..07f463f8b 100644 --- a/packages/ocom/persistence/src/datasources/readonly/index.test.ts +++ b/packages/ocom/persistence/src/datasources/readonly/index.test.ts @@ -3,6 +3,7 @@ import { fileURLToPath } from 'node:url'; import { describeFeature, loadFeature } from '@amiceli/vitest-cucumber'; import type { CommunityModelType } from '@ocom/data-sources-mongoose-models/community'; import type { MemberModelType } from '@ocom/data-sources-mongoose-models/member'; +import type { StaffRoleModelType } from '@ocom/data-sources-mongoose-models/role/staff-role'; import type { EndUserModelType } from '@ocom/data-sources-mongoose-models/user/end-user'; import type { StaffUserModelType } from '@ocom/data-sources-mongoose-models/user/staff-user'; import type { Domain } from '@ocom/domain'; @@ -32,6 +33,11 @@ function makeMockModelsContext() { create: vi.fn(), aggregate: vi.fn(), } as unknown as EndUserModelType, + StaffRole: { + findById: vi.fn(), + find: vi.fn(), + create: vi.fn(), + } as unknown as StaffRoleModelType, StaffUser: { findById: vi.fn(), findOne: vi.fn(), @@ -107,6 +113,8 @@ test.for(feature, ({ Scenario, Background, BeforeEachScenario }) => { And('the User property should have the correct structure', () => { expect(result.User).toHaveProperty('EndUser'); expect(result.User.EndUser).toHaveProperty('EndUserReadRepo'); + expect(result.User).toHaveProperty('StaffRole'); + expect(result.User.StaffRole).toHaveProperty('StaffRoleReadRepo'); expect(result.User).toHaveProperty('StaffUser'); expect(result.User.StaffUser).toHaveProperty('StaffUserReadRepo'); }); diff --git a/packages/ocom/persistence/src/datasources/readonly/index.ts b/packages/ocom/persistence/src/datasources/readonly/index.ts index 9342ba8ad..875d9dfce 100644 --- a/packages/ocom/persistence/src/datasources/readonly/index.ts +++ b/packages/ocom/persistence/src/datasources/readonly/index.ts @@ -4,6 +4,7 @@ import type * as Community from './community/community/index.ts'; import { CommunityContext } from './community/index.ts'; import type * as Member from './community/member/index.ts'; import type * as EndUser from './user/end-user/index.ts'; +import type * as StaffRole from './user/staff-role/index.ts'; import { UserContext } from './user/index.ts'; import type * as StaffUser from './user/staff-user/index.ts'; @@ -20,6 +21,9 @@ export interface ReadonlyDataSource { EndUser: { EndUserReadRepo: EndUser.EndUserReadRepository; }; + StaffRole: { + StaffRoleReadRepo: StaffRole.StaffRoleReadRepository; + }; StaffUser: { StaffUserReadRepo: StaffUser.StaffUserReadRepository; }; diff --git a/packages/ocom/persistence/src/datasources/readonly/user/index.ts b/packages/ocom/persistence/src/datasources/readonly/user/index.ts index ab40bc6e7..a6229b1d0 100644 --- a/packages/ocom/persistence/src/datasources/readonly/user/index.ts +++ b/packages/ocom/persistence/src/datasources/readonly/user/index.ts @@ -1,9 +1,11 @@ import type { Domain } from '@ocom/domain'; import type { ModelsContext } from '../../../index.ts'; import { EndUserReadRepositoryImpl } from './end-user/index.ts'; +import { StaffRoleReadRepositoryImpl } from './staff-role/index.ts'; import { StaffUserReadRepositoryImpl } from './staff-user/index.ts'; export const UserContext = (models: ModelsContext, passport: Domain.Passport) => ({ EndUser: EndUserReadRepositoryImpl(models, passport), + StaffRole: StaffRoleReadRepositoryImpl(models, passport), StaffUser: StaffUserReadRepositoryImpl(models, passport), }); diff --git a/packages/ocom/persistence/src/datasources/readonly/user/staff-role/features/staff-role.read-repository.feature b/packages/ocom/persistence/src/datasources/readonly/user/staff-role/features/staff-role.read-repository.feature new file mode 100644 index 000000000..f86998055 --- /dev/null +++ b/packages/ocom/persistence/src/datasources/readonly/user/staff-role/features/staff-role.read-repository.feature @@ -0,0 +1,35 @@ +Feature: StaffRoleReadRepository + + Scenario: Creating StaffRoleReadRepository throws when StaffRole model is missing + Given models context does not contain a StaffRole model + When I call getStaffRoleReadRepository with those models and a passport + Then it should throw an error with message "StaffRole model is not available in the mongoose context" + + Scenario: Creating StaffRoleReadRepository succeeds when StaffRole model is present + Given models context contains a StaffRole model + When I call getStaffRoleReadRepository with those models and a passport + Then I should receive a StaffRoleReadRepository instance + And the repository should have a getAll method + And the repository should have a getById method + + Scenario: getAll returns a list of entities when documents are found + Given StaffRole documents exist in the collection + When I call getAll + Then I should receive an array of StaffRoleEntityReference objects + And the converter toDomain should have been called for each document + + Scenario: getAll returns an empty array when no documents exist + Given no StaffRole documents exist in the collection + When I call getAll + Then I should receive an empty array + + Scenario: getById returns an entity when a document is found + Given a StaffRole document exists with id "role-001" + When I call getById with "role-001" + Then I should receive a StaffRoleEntityReference object + And the converter toDomain should have been called with the document and passport + + Scenario: getById returns null when no document is found + Given no StaffRole document exists with id "missing-id" + When I call getById with "missing-id" + Then I should receive null diff --git a/packages/ocom/persistence/src/datasources/readonly/user/staff-role/index.ts b/packages/ocom/persistence/src/datasources/readonly/user/staff-role/index.ts new file mode 100644 index 000000000..4ead818ad --- /dev/null +++ b/packages/ocom/persistence/src/datasources/readonly/user/staff-role/index.ts @@ -0,0 +1,11 @@ +import type { Domain } from '@ocom/domain'; +import type { ModelsContext } from '../../../../index.ts'; +import { getStaffRoleReadRepository } from './staff-role.read-repository.ts'; + +export type { StaffRoleReadRepository } from './staff-role.read-repository.ts'; + +export const StaffRoleReadRepositoryImpl = (models: ModelsContext, passport: Domain.Passport) => { + return { + StaffRoleReadRepo: getStaffRoleReadRepository(models, passport), + }; +}; diff --git a/packages/ocom/persistence/src/datasources/readonly/user/staff-role/staff-role.read-repository.test.ts b/packages/ocom/persistence/src/datasources/readonly/user/staff-role/staff-role.read-repository.test.ts new file mode 100644 index 000000000..3f517d441 --- /dev/null +++ b/packages/ocom/persistence/src/datasources/readonly/user/staff-role/staff-role.read-repository.test.ts @@ -0,0 +1,185 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describeFeature, loadFeature } from '@amiceli/vitest-cucumber'; +import { expect, vi } from 'vitest'; + +import type { Domain } from '@ocom/domain'; +import type { StaffRole, StaffRoleModelType } from '@ocom/data-sources-mongoose-models/role/staff-role'; +import type { ModelsContext } from '../../../../index.ts'; +import { StaffRoleConverter } from '../../../domain/user/staff-role/staff-role.domain-adapter.ts'; +import { getStaffRoleReadRepository } from './staff-role.read-repository.ts'; +import type { StaffRoleReadRepository } from './staff-role.read-repository.ts'; + +const test = { for: describeFeature }; + +vi.mock('../../../domain/user/staff-role/staff-role.domain-adapter.ts', () => ({ + StaffRoleConverter: vi.fn(), +})); + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const feature = await loadFeature(path.resolve(__dirname, 'features/staff-role.read-repository.feature')); + +function makeMockPassport() { + return {} as unknown as Domain.Passport; +} + +function makeMockStaffRoleDocument(overrides: Partial = {}) { + return { + _id: 'role-001', + id: 'role-001', + roleName: 'Admin', + isDefault: false, + roleType: 'staff', + ...overrides, + } as unknown as StaffRole; +} + +function makeMockModelForFind(docs: StaffRole[]) { + return { + find: vi.fn().mockReturnValue({ + exec: vi.fn().mockResolvedValue(docs), + }), + findById: vi.fn().mockReturnValue({ + exec: vi.fn().mockResolvedValue(docs[0] ?? null), + }), + } as unknown as StaffRoleModelType; +} + +function makeMockModelFindById(doc: StaffRole | null) { + return { + find: vi.fn().mockReturnValue({ + exec: vi.fn().mockResolvedValue([]), + }), + findById: vi.fn().mockReturnValue({ + exec: vi.fn().mockResolvedValue(doc), + }), + } as unknown as StaffRoleModelType; +} + +test.for(feature, ({ Scenario, BeforeEachScenario }) => { + let models: ModelsContext; + let passport: Domain.Passport; + let repository: StaffRoleReadRepository; + let mockDoc: StaffRole; + let result: Domain.Contexts.User.StaffRole.StaffRoleEntityReference | Domain.Contexts.User.StaffRole.StaffRoleEntityReference[] | null | unknown; + let mockConverter: { toDomain: ReturnType }; + let thrownError: unknown; + + BeforeEachScenario(() => { + passport = makeMockPassport(); + mockDoc = makeMockStaffRoleDocument(); + thrownError = undefined; + result = undefined; + + mockConverter = { + toDomain: vi.fn((_doc: StaffRole, _passport: Domain.Passport) => ({ + id: mockDoc.id, + roleName: mockDoc.roleName, + })), + }; + + vi.mocked(StaffRoleConverter).mockImplementation(function MockStaffRoleConverter() { + return mockConverter as unknown as StaffRoleConverter; + }); + }); + + Scenario('Creating StaffRoleReadRepository throws when StaffRole model is missing', ({ Given, When, Then }) => { + Given('models context does not contain a StaffRole model', () => { + models = {} as ModelsContext; + }); + When('I call getStaffRoleReadRepository with those models and a passport', () => { + try { + repository = getStaffRoleReadRepository(models, passport); + } catch (err) { + thrownError = err; + } + }); + Then('it should throw an error with message "StaffRole model is not available in the mongoose context"', () => { + expect(thrownError).toBeInstanceOf(Error); + expect((thrownError as Error).message).toBe('StaffRole model is not available in the mongoose context'); + }); + }); + + Scenario('Creating StaffRoleReadRepository succeeds when StaffRole model is present', ({ Given, When, Then, And }) => { + Given('models context contains a StaffRole model', () => { + models = { StaffRole: makeMockModelForFind([mockDoc]) } as unknown as ModelsContext; + }); + When('I call getStaffRoleReadRepository with those models and a passport', () => { + repository = getStaffRoleReadRepository(models, passport); + }); + Then('I should receive a StaffRoleReadRepository instance', () => { + expect(repository).toBeDefined(); + }); + And('the repository should have a getAll method', () => { + expect(typeof repository.getAll).toBe('function'); + }); + And('the repository should have a getById method', () => { + expect(typeof repository.getById).toBe('function'); + }); + }); + + Scenario('getAll returns a list of entities when documents are found', ({ Given, When, Then, And }) => { + const secondDoc = makeMockStaffRoleDocument({ _id: 'role-002', id: 'role-002', roleName: 'User' } as unknown as Partial); + + Given('StaffRole documents exist in the collection', () => { + models = { StaffRole: makeMockModelForFind([mockDoc, secondDoc]) } as unknown as ModelsContext; + repository = getStaffRoleReadRepository(models, passport); + }); + When('I call getAll', async () => { + result = await repository.getAll(); + }); + Then('I should receive an array of StaffRoleEntityReference objects', () => { + expect(Array.isArray(result)).toBe(true); + expect((result as unknown[]).length).toBe(2); + }); + And('the converter toDomain should have been called for each document', () => { + expect(mockConverter.toDomain).toHaveBeenCalledTimes(2); + expect(mockConverter.toDomain).toHaveBeenCalledWith(mockDoc, passport); + expect(mockConverter.toDomain).toHaveBeenCalledWith(secondDoc, passport); + }); + }); + + Scenario('getAll returns an empty array when no documents exist', ({ Given, When, Then }) => { + Given('no StaffRole documents exist in the collection', () => { + models = { StaffRole: makeMockModelForFind([]) } as unknown as ModelsContext; + repository = getStaffRoleReadRepository(models, passport); + }); + When('I call getAll', async () => { + result = await repository.getAll(); + }); + Then('I should receive an empty array', () => { + expect(Array.isArray(result)).toBe(true); + expect((result as unknown[]).length).toBe(0); + }); + }); + + Scenario('getById returns an entity when a document is found', ({ Given, When, Then, And }) => { + Given('a StaffRole document exists with id "role-001"', () => { + models = { StaffRole: makeMockModelFindById(mockDoc) } as unknown as ModelsContext; + repository = getStaffRoleReadRepository(models, passport); + }); + When('I call getById with "role-001"', async () => { + result = await repository.getById('role-001'); + }); + Then('I should receive a StaffRoleEntityReference object', () => { + expect(result).toBeDefined(); + expect(result).not.toBeNull(); + }); + And('the converter toDomain should have been called with the document and passport', () => { + expect(mockConverter.toDomain).toHaveBeenCalledWith(mockDoc, passport); + }); + }); + + Scenario('getById returns null when no document is found', ({ Given, When, Then }) => { + Given('no StaffRole document exists with id "missing-id"', () => { + models = { StaffRole: makeMockModelFindById(null) } as unknown as ModelsContext; + repository = getStaffRoleReadRepository(models, passport); + }); + When('I call getById with "missing-id"', async () => { + result = await repository.getById('missing-id'); + }); + Then('I should receive null', () => { + expect(result).toBeNull(); + }); + }); +}); diff --git a/packages/ocom/persistence/src/datasources/readonly/user/staff-role/staff-role.read-repository.ts b/packages/ocom/persistence/src/datasources/readonly/user/staff-role/staff-role.read-repository.ts new file mode 100644 index 000000000..b8b2fdf43 --- /dev/null +++ b/packages/ocom/persistence/src/datasources/readonly/user/staff-role/staff-role.read-repository.ts @@ -0,0 +1,41 @@ +import type { StaffRoleModelType } from '@ocom/data-sources-mongoose-models/role/staff-role'; +import type { Domain } from '@ocom/domain'; +import type { ModelsContext } from '../../../../index.ts'; +import { StaffRoleConverter } from '../../../domain/user/staff-role/staff-role.domain-adapter.ts'; + +export interface StaffRoleReadRepository { + getAll: () => Promise; + getById: (id: string) => Promise; +} + +class StaffRoleReadRepositoryImpl implements StaffRoleReadRepository { + private readonly model: StaffRoleModelType; + private readonly converter: StaffRoleConverter; + private readonly passport: Domain.Passport; + + constructor(models: ModelsContext, passport: Domain.Passport) { + if (!models.StaffRole) { + throw new Error('StaffRole model is not available in the mongoose context'); + } + this.model = models.StaffRole; + this.converter = new StaffRoleConverter(); + this.passport = passport; + } + + async getAll(): Promise { + const docs = await this.model.find({}).exec(); + return docs.map((doc) => this.converter.toDomain(doc, this.passport)); + } + + async getById(id: string): Promise { + const doc = await this.model.findById(id).exec(); + if (!doc) { + return null; + } + return this.converter.toDomain(doc, this.passport); + } +} + +export const getStaffRoleReadRepository = (models: ModelsContext, passport: Domain.Passport): StaffRoleReadRepository => { + return new StaffRoleReadRepositoryImpl(models, passport); +}; diff --git a/packages/ocom/persistence/src/datasources/readonly/user/staff-user/features/staff-user.read-repository.feature b/packages/ocom/persistence/src/datasources/readonly/user/staff-user/features/staff-user.read-repository.feature index 9aa56131b..d0776be89 100644 --- a/packages/ocom/persistence/src/datasources/readonly/user/staff-user/features/staff-user.read-repository.feature +++ b/packages/ocom/persistence/src/datasources/readonly/user/staff-user/features/staff-user.read-repository.feature @@ -10,6 +10,29 @@ Feature: StaffUserReadRepository When I call getStaffUserReadRepository with those models and a passport Then I should receive a StaffUserReadRepository instance And the repository should have a getByExternalId method + And the repository should have a getByEmail method + And the repository should have a getAll method + + Scenario: getAll returns all converted entities when documents exist + Given two StaffUser documents exist in the collection + When I call getAll + Then I should receive an array of two StaffUserEntityReference objects + And the converter toDomain should have been called once for each document + + Scenario: getAll returns an empty array when no documents exist + Given no StaffUser documents exist in the collection + When I call getAll + Then I should receive an empty array + + Scenario: getByExternalId passes the correct filter to findOne + Given a StaffUser document exists with externalId "ext-filter-test" + When I call getByExternalId with "ext-filter-test" + Then findOne should have been called with the externalId filter + + Scenario: getByEmail passes the correct filter to findOne + Given a StaffUser document exists with email "filter@example.com" + When I call getByEmail with "filter@example.com" + Then findOne should have been called with the email filter Scenario: getByExternalId returns entity when document is found Given a StaffUser document exists with externalId "ext-abc" @@ -21,3 +44,14 @@ Feature: StaffUserReadRepository Given no StaffUser document exists with externalId "missing-ext" When I call getByExternalId with "missing-ext" Then I should receive null + + Scenario: getByEmail returns entity when document is found + Given a StaffUser document exists with email "alice@example.com" + When I call getByEmail with "alice@example.com" + Then I should receive a StaffUserEntityReference object + And the converter toDomain should have been called with the document and passport + + Scenario: getByEmail returns null when no document is found + Given no StaffUser document exists with email "missing@example.com" + When I call getByEmail with "missing@example.com" + Then I should receive null diff --git a/packages/ocom/persistence/src/datasources/readonly/user/staff-user/staff-user.read-repository.test.ts b/packages/ocom/persistence/src/datasources/readonly/user/staff-user/staff-user.read-repository.test.ts index c317f0709..c74036a5a 100644 --- a/packages/ocom/persistence/src/datasources/readonly/user/staff-user/staff-user.read-repository.test.ts +++ b/packages/ocom/persistence/src/datasources/readonly/user/staff-user/staff-user.read-repository.test.ts @@ -50,6 +50,21 @@ function makeMockModel(doc: StaffUser | null) { } as unknown as StaffUserModelType; } +function makeMockModelMulti(docs: StaffUser[]) { + return { + find: vi.fn().mockReturnValue({ + populate: vi.fn().mockReturnValue({ + exec: vi.fn().mockResolvedValue(docs), + }), + }), + findOne: vi.fn().mockReturnValue({ + populate: vi.fn().mockReturnValue({ + exec: vi.fn().mockResolvedValue(docs[0] ?? null), + }), + }), + } as unknown as StaffUserModelType; +} + test.for(feature, ({ Scenario, BeforeEachScenario }) => { let models: ModelsContext; let passport: Domain.Passport; @@ -107,6 +122,85 @@ test.for(feature, ({ Scenario, BeforeEachScenario }) => { And('the repository should have a getByExternalId method', () => { expect(typeof repository.getByExternalId).toBe('function'); }); + And('the repository should have a getByEmail method', () => { + expect(typeof repository.getByEmail).toBe('function'); + }); + And('the repository should have a getAll method', () => { + expect(typeof repository.getAll).toBe('function'); + }); + }); + + // ─── getAll ─────────────────────────────────────────────────────────────── + + Scenario('getAll returns all converted entities when documents exist', ({ Given, When, Then, And }) => { + let doc1: StaffUser; + let doc2: StaffUser; + Given('two StaffUser documents exist in the collection', () => { + doc1 = { ...makeMockStaffUserDocument(), id: 'id-1', externalId: 'ext-1' } as unknown as StaffUser; + doc2 = { ...makeMockStaffUserDocument(), id: 'id-2', externalId: 'ext-2' } as unknown as StaffUser; + models = { StaffUser: makeMockModelMulti([doc1, doc2]) } as unknown as ModelsContext; + mockConverter.toDomain + .mockReturnValueOnce({ id: 'id-1', externalId: 'ext-1' }) + .mockReturnValueOnce({ id: 'id-2', externalId: 'ext-2' }); + repository = getStaffUserReadRepository(models, passport); + }); + When('I call getAll', async () => { + result = await repository.getAll(); + }); + Then('I should receive an array of two StaffUserEntityReference objects', () => { + expect(Array.isArray(result)).toBe(true); + expect((result as unknown[]).length).toBe(2); + }); + And('the converter toDomain should have been called once for each document', () => { + expect(mockConverter.toDomain).toHaveBeenCalledTimes(2); + expect(mockConverter.toDomain).toHaveBeenCalledWith(doc1, passport); + expect(mockConverter.toDomain).toHaveBeenCalledWith(doc2, passport); + }); + }); + + Scenario('getAll returns an empty array when no documents exist', ({ Given, When, Then }) => { + Given('no StaffUser documents exist in the collection', () => { + models = { StaffUser: makeMockModelMulti([]) } as unknown as ModelsContext; + repository = getStaffUserReadRepository(models, passport); + }); + When('I call getAll', async () => { + result = await repository.getAll(); + }); + Then('I should receive an empty array', () => { + expect(result).toEqual([]); + }); + }); + + // ─── filter verification ────────────────────────────────────────────────── + + Scenario('getByExternalId passes the correct filter to findOne', ({ Given, When, Then }) => { + let mockModel: StaffUserModelType; + Given('a StaffUser document exists with externalId "ext-filter-test"', () => { + mockModel = makeMockModel(mockStaffUserDoc); + models = { StaffUser: mockModel } as unknown as ModelsContext; + repository = getStaffUserReadRepository(models, passport); + }); + When('I call getByExternalId with "ext-filter-test"', async () => { + result = await repository.getByExternalId('ext-filter-test'); + }); + Then('findOne should have been called with the externalId filter', () => { + expect(mockModel.findOne).toHaveBeenCalledWith({ externalId: 'ext-filter-test' }); + }); + }); + + Scenario('getByEmail passes the correct filter to findOne', ({ Given, When, Then }) => { + let mockModel: StaffUserModelType; + Given('a StaffUser document exists with email "filter@example.com"', () => { + mockModel = makeMockModel(mockStaffUserDoc); + models = { StaffUser: mockModel } as unknown as ModelsContext; + repository = getStaffUserReadRepository(models, passport); + }); + When('I call getByEmail with "filter@example.com"', async () => { + result = await repository.getByEmail('filter@example.com'); + }); + Then('findOne should have been called with the email filter', () => { + expect(mockModel.findOne).toHaveBeenCalledWith({ email: 'filter@example.com' }); + }); }); Scenario('getByExternalId returns entity when document is found', ({ Given, When, Then, And }) => { @@ -138,4 +232,34 @@ test.for(feature, ({ Scenario, BeforeEachScenario }) => { expect(result).toBeNull(); }); }); + + Scenario('getByEmail returns entity when document is found', ({ Given, When, Then, And }) => { + Given('a StaffUser document exists with email "alice@example.com"', () => { + models = { StaffUser: makeMockModel(mockStaffUserDoc) } as unknown as ModelsContext; + repository = getStaffUserReadRepository(models, passport); + }); + When('I call getByEmail with "alice@example.com"', async () => { + result = await repository.getByEmail('alice@example.com'); + }); + Then('I should receive a StaffUserEntityReference object', () => { + expect(result).toBeDefined(); + expect(result).not.toBeNull(); + }); + And('the converter toDomain should have been called with the document and passport', () => { + expect(mockConverter.toDomain).toHaveBeenCalledWith(mockStaffUserDoc, passport); + }); + }); + + Scenario('getByEmail returns null when no document is found', ({ Given, When, Then }) => { + Given('no StaffUser document exists with email "missing@example.com"', () => { + models = { StaffUser: makeMockModel(null) } as unknown as ModelsContext; + repository = getStaffUserReadRepository(models, passport); + }); + When('I call getByEmail with "missing@example.com"', async () => { + result = await repository.getByEmail('missing@example.com'); + }); + Then('I should receive null', () => { + expect(result).toBeNull(); + }); + }); }); diff --git a/packages/ocom/persistence/src/datasources/readonly/user/staff-user/staff-user.read-repository.ts b/packages/ocom/persistence/src/datasources/readonly/user/staff-user/staff-user.read-repository.ts index 0824f8934..4571f56f4 100644 --- a/packages/ocom/persistence/src/datasources/readonly/user/staff-user/staff-user.read-repository.ts +++ b/packages/ocom/persistence/src/datasources/readonly/user/staff-user/staff-user.read-repository.ts @@ -4,7 +4,9 @@ import type { ModelsContext } from '../../../../index.ts'; import { StaffUserConverter } from '../../../domain/user/staff-user/staff-user.domain-adapter.ts'; export interface StaffUserReadRepository { + getAll: () => Promise; getByExternalId: (externalId: string) => Promise; + getByEmail: (email: string) => Promise; } class StaffUserReadRepositoryImpl implements StaffUserReadRepository { @@ -21,6 +23,11 @@ class StaffUserReadRepositoryImpl implements StaffUserReadRepository { this.passport = passport; } + async getAll(): Promise { + const docs = await this.model.find({}).populate('role').exec(); + return docs.map((doc) => this.converter.toDomain(doc, this.passport)); + } + async getByExternalId(externalId: string): Promise { const doc = await this.model.findOne({ externalId }).populate('role').exec(); if (!doc) { @@ -28,6 +35,14 @@ class StaffUserReadRepositoryImpl implements StaffUserReadRepository { } return this.converter.toDomain(doc, this.passport); } + + async getByEmail(email: string): Promise { + const doc = await this.model.findOne({ email }).populate('role').exec(); + if (!doc) { + return null; + } + return this.converter.toDomain(doc, this.passport); + } } export const getStaffUserReadRepository = (models: ModelsContext, passport: Domain.Passport): StaffUserReadRepository => { diff --git a/packages/ocom/service-token-validation/src/index.feature b/packages/ocom/service-token-validation/src/index.feature index 94462c6f8..fd76e3972 100644 --- a/packages/ocom/service-token-validation/src/index.feature +++ b/packages/ocom/service-token-validation/src/index.feature @@ -1,43 +1,72 @@ Feature: ServiceTokenValidation Scenario: Constructing ServiceTokenValidation with valid portal tokens - Given valid portal tokens mapping + Given valid portal tokens mapping for two portals When the ServiceTokenValidation is constructed with these tokens - Then it should create OpenID configurations from environment variables - And it should initialize the VerifiedTokenService with the configurations + Then it should pass the configuration map to VerifiedTokenService + And it should pass the default refresh interval to VerifiedTokenService + And it should store the VerifiedTokenService instance - Scenario: Constructing ServiceTokenValidation with missing optional environment variables - Given portal tokens mapping with missing optional environment variables + Scenario: Constructing ServiceTokenValidation with missing optional environment variables uses defaults + Given portal tokens mapping with only required environment variables set When the ServiceTokenValidation is constructed with these tokens - Then it should use default values for missing optional environment variables - And it should initialize the VerifiedTokenService with default configurations + Then the config clockTolerance should default to "5 minutes" + And the config ignoreIssuer should default to false - Scenario: Constructing ServiceTokenValidation with missing environment variables - Given portal tokens mapping with missing environment variables + Scenario: Constructing ServiceTokenValidation when ignoreIssuer is explicitly set to true + Given portal tokens mapping with OIDC_IGNORE_ISSUER set to "true" + When the ServiceTokenValidation is constructed with these tokens + Then the config ignoreIssuer should be true + + Scenario: Constructing ServiceTokenValidation with a custom refresh interval + Given valid portal tokens mapping for one portal + When the ServiceTokenValidation is constructed with a custom refresh interval of 30000 + Then it should pass the custom refresh interval 30000 to VerifiedTokenService + + Scenario: Constructing ServiceTokenValidation with a missing required environment variable + Given portal tokens mapping that references a missing environment variable prefix When the ServiceTokenValidation is constructed - Then it should throw an error for missing required environment variables + Then it should throw an error indicating the environment variable is not set Scenario: Starting up the ServiceTokenValidation Given a ServiceTokenValidation instance with valid configuration When startUp is called - Then it should start the underlying VerifiedTokenService - And it should return the service instance - - Scenario: Verifying JWT with ServiceTokenValidation - Given a ServiceTokenValidation instance that is started - And a valid JWT token - When verifyJwt is called with the token - Then it should try verification with each configured provider - And it should return the verification result when successful - - Scenario: Verifying invalid JWT with ServiceTokenValidation - Given a ServiceTokenValidation instance that is started - And an invalid JWT token - When verifyJwt is called with the invalid token - Then it should return null indicating verification failed - - Scenario: Shutting down the ServiceTokenValidation - Given a started ServiceTokenValidation instance + Then it should call start on the underlying VerifiedTokenService + And it should resolve with the service instance itself + + Scenario: verifyJwt succeeds on the second provider after a retryable error on the first + Given a ServiceTokenValidation instance configured with two portals + And the first portal raises a retryable JWSSignatureVerificationFailed error + And the second portal resolves with a valid JWT payload + When verifyJwt is called with a bearer token + Then it should call getVerifiedJwt for both portal1 and portal2 + And it should return the verifiedJwt and openIdConfigKey from the second portal + + Scenario: verifyJwt propagates a non-retryable error + Given a ServiceTokenValidation instance configured with one portal + And the portal raises a non-retryable TypeError + When verifyJwt is called with a bearer token + Then it should rethrow the non-retryable error + + Scenario: verifyJwt returns null when a provider returns a result with no payload + Given a ServiceTokenValidation instance configured with one portal + And the portal resolves with a result that has no payload + When verifyJwt is called with a bearer token + Then it should return null + + Scenario: verifyJwt returns null when all providers return null + Given a ServiceTokenValidation instance configured with one portal + And the portal resolves with null + When verifyJwt is called with a bearer token + Then it should return null + + Scenario: Shutting down when a timer is running clears the interval and logs + Given a ServiceTokenValidation instance with a running timer + When shutDown is called + Then it should clear the timer interval + And it should log "ServiceTokenValidation stopped" + + Scenario: Shutting down when no timer is running still logs + Given a ServiceTokenValidation instance with no timer running When shutDown is called - Then it should stop the underlying VerifiedTokenService - And it should log that the service stopped \ No newline at end of file + Then it should log "ServiceTokenValidation stopped" \ No newline at end of file diff --git a/packages/ocom/service-token-validation/src/index.test.ts b/packages/ocom/service-token-validation/src/index.test.ts index 203525c8e..3c0c79126 100644 --- a/packages/ocom/service-token-validation/src/index.test.ts +++ b/packages/ocom/service-token-validation/src/index.test.ts @@ -1,321 +1,311 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { describeFeature, loadFeature } from '@amiceli/vitest-cucumber'; -import { expect, vi, afterEach, type Mock } from 'vitest'; -import { VerifiedTokenService } from './verified-token-service.ts'; +import { expect, type Mock, vi } from 'vitest'; import { ServiceTokenValidation } from './index.ts'; - -// Mock VerifiedTokenService +import { VerifiedTokenService } from './verified-token-service.ts'; const test = { for: describeFeature }; + vi.mock('./verified-token-service.ts', () => ({ VerifiedTokenService: vi.fn(), })); -// Mock console.log const mockConsoleLog = vi.spyOn(console, 'log').mockImplementation(() => undefined); const __dirname = path.dirname(fileURLToPath(import.meta.url)); const feature = await loadFeature(path.resolve(__dirname, 'index.feature')); +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function setPortalEnv( + prefix: string, + opts: { endpoint: string; audience: string; issuer: string; clockTolerance?: string; ignoreIssuer?: string }, +) { + vi.stubEnv(`${prefix}_OIDC_ENDPOINT`, opts.endpoint); + vi.stubEnv(`${prefix}_OIDC_AUDIENCE`, opts.audience); + vi.stubEnv(`${prefix}_OIDC_ISSUER`, opts.issuer); + if (opts.clockTolerance !== undefined) vi.stubEnv(`${prefix}_OIDC_CLOCK_TOLERANCE`, opts.clockTolerance); + if (opts.ignoreIssuer !== undefined) vi.stubEnv(`${prefix}_OIDC_IGNORE_ISSUER`, opts.ignoreIssuer); +} + +function makeBaseEnv(prefix: string) { + setPortalEnv(prefix, { + endpoint: `https://${prefix.toLowerCase()}.com/.well-known/jwks.json`, + audience: `${prefix.toLowerCase()}-aud`, + issuer: `https://${prefix.toLowerCase()}.com`, + }); +} + +function makeMockVerifiedTokenService() { + const mockGetVerifiedJwt = vi.fn(); + const mock = { + openIdConfigs: new Map(), + refreshInterval: 1000 * 60 * 5, + keyStoreCollection: new Map(), + refreshCollection: vi.fn(), + start: vi.fn(), + getVerifiedJwt: mockGetVerifiedJwt as unknown as VerifiedTokenService['getVerifiedJwt'], + timerInstance: undefined as NodeJS.Timeout | undefined, + } as VerifiedTokenService; + return { mock, mockGetVerifiedJwt }; +} + +// ─── Tests ─────────────────────────────────────────────────────────────────── + test.for(feature, ({ Scenario, BeforeEachScenario }) => { let service: ServiceTokenValidation; let mockVerifiedTokenService: VerifiedTokenService; - let mockGetVerifiedJwt: Mock<(bearerToken: string, configKey: string) => Promise> | null>>; - let originalEnv: NodeJS.ProcessEnv; + let mockGetVerifiedJwt: Mock; + let verifyJwtResult: Awaited> | undefined; + let thrownError: unknown; + let startUpResult: unknown; BeforeEachScenario(() => { - // Reset mocks vi.clearAllMocks(); + vi.unstubAllEnvs(); mockConsoleLog.mockClear(); + thrownError = undefined; + verifyJwtResult = undefined; + startUpResult = undefined; - // Store original environment - originalEnv = { ...process.env }; - - // Setup mock VerifiedTokenService - mockGetVerifiedJwt = vi.fn(); - mockVerifiedTokenService = { - openIdConfigs: new Map(), - refreshInterval: 1000 * 60 * 5, - keyStoreCollection: new Map(), - refreshCollection: vi.fn(), - start: vi.fn(), - getVerifiedJwt: mockGetVerifiedJwt as unknown as VerifiedTokenService['getVerifiedJwt'], - timerInstance: setInterval(() => undefined, 1000), - } as VerifiedTokenService; + const { mock, mockGetVerifiedJwt: getJwt } = makeMockVerifiedTokenService(); + mockVerifiedTokenService = mock; + mockGetVerifiedJwt = getJwt; vi.mocked(VerifiedTokenService).mockImplementation(function MockVerifiedTokenService() { return mockVerifiedTokenService; }); }); - afterEach(() => { - // Restore original environment - process.env = originalEnv; - }); + // ─── Constructor ────────────────────────────────────────────────────────── Scenario('Constructing ServiceTokenValidation with valid portal tokens', ({ Given, When, Then, And }) => { - Given('valid portal tokens mapping', () => { - // Set up environment variables - // biome-ignore lint:useLiteralKeys - process.env['PORTAL1_OIDC_ENDPOINT'] = 'https://portal1.com/.well-known/jwks.json'; - // biome-ignore lint:useLiteralKeys - process.env['PORTAL1_OIDC_AUDIENCE'] = 'audience1'; - // biome-ignore lint:useLiteralKeys - process.env['PORTAL1_OIDC_ISSUER'] = 'https://portal1.com'; - // biome-ignore lint:useLiteralKeys - process.env['PORTAL2_OIDC_ENDPOINT'] = 'https://portal2.com/.well-known/jwks.json'; - // biome-ignore lint:useLiteralKeys - process.env['PORTAL2_OIDC_AUDIENCE'] = 'audience2'; - // biome-ignore lint:useLiteralKeys - process.env['PORTAL2_OIDC_ISSUER'] = 'https://portal2.com'; + Given('valid portal tokens mapping for two portals', () => { + makeBaseEnv('PORTAL1'); + makeBaseEnv('PORTAL2'); }); - When('the ServiceTokenValidation is constructed with these tokens', () => { - // Ensure environment variables are set (they should be from Given, but let's be safe) - // biome-ignore lint:useLiteralKeys - process.env['PORTAL1_OIDC_ENDPOINT'] = 'https://portal1.com/.well-known/jwks.json'; - // biome-ignore lint:useLiteralKeys - process.env['PORTAL1_OIDC_AUDIENCE'] = 'audience1'; - // biome-ignore lint:useLiteralKeys - process.env['PORTAL1_OIDC_ISSUER'] = 'https://portal1.com'; - // biome-ignore lint:useLiteralKeys - process.env['PORTAL2_OIDC_ENDPOINT'] = 'https://portal2.com/.well-known/jwks.json'; - // biome-ignore lint:useLiteralKeys - process.env['PORTAL2_OIDC_AUDIENCE'] = 'audience2'; - // biome-ignore lint:useLiteralKeys - process.env['PORTAL2_OIDC_ISSUER'] = 'https://portal2.com'; - - const portalTokens = new Map([ - ['portal1', 'PORTAL1'], - ['portal2', 'PORTAL2'], - ]); - service = new ServiceTokenValidation(portalTokens); + service = new ServiceTokenValidation(new Map([['portal1', 'PORTAL1'], ['portal2', 'PORTAL2']])); }); - - Then('it should create OpenID configurations from environment variables', () => { - expect(VerifiedTokenService).toHaveBeenCalledWith( - expect.any(Map), - 1000 * 60 * 5, // default refresh interval - ); + Then('it should pass the configuration map to VerifiedTokenService', () => { + const [configs] = vi.mocked(VerifiedTokenService).mock.calls[0] as [Map]; + expect(configs).toBeInstanceOf(Map); + expect(configs.has('portal1')).toBe(true); + expect(configs.has('portal2')).toBe(true); }); - - And('it should initialize the VerifiedTokenService with the configurations', () => { - // biome-ignore lint:useLiteralKeys - expect(service['tokenVerifier']).toBe(mockVerifiedTokenService); + And('it should pass the default refresh interval to VerifiedTokenService', () => { + expect(VerifiedTokenService).toHaveBeenCalledWith(expect.any(Map), 1000 * 60 * 5); + }); + And('it should store the VerifiedTokenService instance', () => { + expect(VerifiedTokenService).toHaveBeenCalledOnce(); }); }); - Scenario('Constructing ServiceTokenValidation with missing optional environment variables', ({ Given, When, Then, And }) => { - Given('portal tokens mapping with missing optional environment variables', () => { - // Clear all environment variables first - // biome-ignore lint:useLiteralKeys - delete process.env['PORTAL1_OIDC_ENDPOINT']; - // biome-ignore lint:useLiteralKeys - delete process.env['PORTAL1_OIDC_AUDIENCE']; - // biome-ignore lint:useLiteralKeys - delete process.env['PORTAL1_OIDC_ISSUER']; - // biome-ignore lint:useLiteralKeys - delete process.env['PORTAL1_OIDC_CLOCK_TOLERANCE']; - // biome-ignore lint:useLiteralKeys - delete process.env['PORTAL1_OIDC_IGNORE_ISSUER']; - - // Set only required environment variables - // biome-ignore lint:useLiteralKeys - process.env['PORTAL1_OIDC_ENDPOINT'] = 'https://portal1.com/.well-known/jwks.json'; - // biome-ignore lint:useLiteralKeys - process.env['PORTAL1_OIDC_AUDIENCE'] = 'audience1'; - // biome-ignore lint:useLiteralKeys - process.env['PORTAL1_OIDC_ISSUER'] = 'https://portal1.com'; - // Explicitly don't set PORTAL1_OIDC_CLOCK_TOLERANCE and PORTAL1_OIDC_IGNORE_ISSUER + Scenario('Constructing ServiceTokenValidation with missing optional environment variables uses defaults', ({ Given, When, Then, And }) => { + Given('portal tokens mapping with only required environment variables set', () => { + // Only stub the required vars; optional vars intentionally absent + makeBaseEnv('PORTAL1'); }); - When('the ServiceTokenValidation is constructed with these tokens', () => { - // Don't reset environment variables here - use what's set in Given - const portalTokens = new Map([['portal1', 'PORTAL1']]); - service = new ServiceTokenValidation(portalTokens); + service = new ServiceTokenValidation(new Map([['portal1', 'PORTAL1']])); }); - - Then('it should use default values for missing optional environment variables', () => { - expect(VerifiedTokenService).toHaveBeenCalledWith( - expect.any(Map), - 1000 * 60 * 5, // default refresh interval - ); - // Verify that the config was created with default values - const callArgs = vi.mocked(VerifiedTokenService).mock.calls[0]; - if (callArgs) { - const configs = callArgs[0] as Map; - const config = configs.get('portal1') as { clockTolerance: string; ignoreIssuer: boolean }; - expect(config.clockTolerance).toBe('5 minutes'); // default value - expect(config.ignoreIssuer).toBe(false); // 'false' === 'true' is false - console.log('Default values test passed - clockTolerance:', config.clockTolerance, 'ignoreIssuer:', config.ignoreIssuer); - } + Then('the config clockTolerance should default to "5 minutes"', () => { + const [configs] = vi.mocked(VerifiedTokenService).mock.calls[0] as unknown as [Map]; + expect(configs.get('portal1')?.clockTolerance).toBe('5 minutes'); + }); + And('the config ignoreIssuer should default to false', () => { + const [configs] = vi.mocked(VerifiedTokenService).mock.calls[0] as unknown as [Map]; + expect(configs.get('portal1')?.ignoreIssuer).toBe(false); }); + }); - And('it should initialize the VerifiedTokenService with default configurations', () => { - // biome-ignore lint:useLiteralKeys - expect(service['tokenVerifier']).toBe(mockVerifiedTokenService); + Scenario('Constructing ServiceTokenValidation when ignoreIssuer is explicitly set to true', ({ Given, When, Then }) => { + Given('portal tokens mapping with OIDC_IGNORE_ISSUER set to "true"', () => { + makeBaseEnv('PORTAL1'); + vi.stubEnv('PORTAL1_OIDC_IGNORE_ISSUER', 'true'); + }); + When('the ServiceTokenValidation is constructed with these tokens', () => { + service = new ServiceTokenValidation(new Map([['portal1', 'PORTAL1']])); + }); + Then('the config ignoreIssuer should be true', () => { + const [configs] = vi.mocked(VerifiedTokenService).mock.calls[0] as unknown as [Map]; + expect(configs.get('portal1')?.ignoreIssuer).toBe(true); }); }); - Scenario('Constructing ServiceTokenValidation with missing environment variables', ({ Given, When, Then }) => { - Given('portal tokens mapping with missing environment variables', () => { - // Don't set up environment variables - they should be missing + Scenario('Constructing ServiceTokenValidation with a custom refresh interval', ({ Given, When, Then }) => { + Given('valid portal tokens mapping for one portal', () => { + makeBaseEnv('PORTAL1'); + }); + When('the ServiceTokenValidation is constructed with a custom refresh interval of 30000', () => { + service = new ServiceTokenValidation(new Map([['portal1', 'PORTAL1']]), 30000); }); + Then('it should pass the custom refresh interval 30000 to VerifiedTokenService', () => { + expect(VerifiedTokenService).toHaveBeenCalledWith(expect.any(Map), 30000); + }); + }); + Scenario('Constructing ServiceTokenValidation with a missing required environment variable', ({ Given, When, Then }) => { + Given('portal tokens mapping that references a missing environment variable prefix', () => { + // MISSING_ prefix env vars are not stubbed — absence is the test condition + }); When('the ServiceTokenValidation is constructed', () => { - const portalTokens = new Map([['portal1', 'MISSING']]); - - expect(() => { - service = new ServiceTokenValidation(portalTokens); - }).toThrow('Environment variable MISSING_OIDC_ENDPOINT not set'); + try { + service = new ServiceTokenValidation(new Map([['portal1', 'MISSING']])); + } catch (e) { + thrownError = e; + } }); - - Then('it should throw an error for missing required environment variables', () => { - // Error is already thrown in When step + Then('it should throw an error indicating the environment variable is not set', () => { + expect(thrownError).toBeInstanceOf(Error); + expect((thrownError as Error).message).toBe('Environment variable MISSING_OIDC_ENDPOINT not set'); }); }); + // ─── startUp ───────────────────────────────────────────────────────────── + Scenario('Starting up the ServiceTokenValidation', ({ Given, When, Then, And }) => { Given('a ServiceTokenValidation instance with valid configuration', () => { - // Set up environment variables - // biome-ignore lint:useLiteralKeys - process.env['PORTAL1_OIDC_ENDPOINT'] = 'https://portal1.com/.well-known/jwks.json'; - // biome-ignore lint:useLiteralKeys - process.env['PORTAL1_OIDC_AUDIENCE'] = 'audience1'; - // biome-ignore lint:useLiteralKeys - process.env['PORTAL1_OIDC_ISSUER'] = 'https://portal1.com'; - - const portalTokens = new Map([['portal1', 'PORTAL1']]); - service = new ServiceTokenValidation(portalTokens); + makeBaseEnv('PORTAL1'); + service = new ServiceTokenValidation(new Map([['portal1', 'PORTAL1']])); }); - When('startUp is called', async () => { - const result = await service.startUp(); - expect(result).toBe(service); + startUpResult = await service.startUp(); }); - - Then('it should start the underlying VerifiedTokenService', () => { - expect(mockVerifiedTokenService.start).toHaveBeenCalled(); + Then('it should call start on the underlying VerifiedTokenService', () => { + expect(mockVerifiedTokenService.start).toHaveBeenCalledOnce(); }); - - And('it should return the service instance', () => { - // Result check is in When step + And('it should resolve with the service instance itself', () => { + expect(startUpResult).toBe(service); }); }); - Scenario('Verifying JWT with ServiceTokenValidation', ({ Given, When, Then, And }) => { - Given('a ServiceTokenValidation instance that is started', () => { - // Set up environment variables - // biome-ignore lint:useLiteralKeys - process.env['PORTAL1_OIDC_ENDPOINT'] = 'https://portal1.com/.well-known/jwks.json'; - // biome-ignore lint:useLiteralKeys - process.env['PORTAL1_OIDC_AUDIENCE'] = 'audience1'; - // biome-ignore lint:useLiteralKeys - process.env['PORTAL1_OIDC_ISSUER'] = 'https://portal1.com'; - // biome-ignore lint:useLiteralKeys - process.env['PORTAL2_OIDC_ENDPOINT'] = 'https://portal2.com/.well-known/jwks.json'; - // biome-ignore lint:useLiteralKeys - process.env['PORTAL2_OIDC_AUDIENCE'] = 'audience2'; - // biome-ignore lint:useLiteralKeys - process.env['PORTAL2_OIDC_ISSUER'] = 'https://portal2.com'; - - const portalTokens = new Map([ - ['portal1', 'PORTAL1'], - ['portal2', 'PORTAL2'], - ]); - service = new ServiceTokenValidation(portalTokens); + // ─── verifyJwt ──────────────────────────────────────────────────────────── - // Mock successful verification on second attempt - mockGetVerifiedJwt - .mockRejectedValueOnce(Object.assign(new Error('signature verification failed'), { name: 'JWSSignatureVerificationFailed' })) // First provider fails with signature mismatch - .mockResolvedValueOnce({ - // Second provider succeeds - payload: { sub: 'user123', aud: 'audience2' }, - protectedHeader: { alg: 'RS256' }, - key: {} as never, - }); + Scenario('verifyJwt succeeds on the second provider after a retryable error on the first', ({ Given, And, When, Then }) => { + Given('a ServiceTokenValidation instance configured with two portals', () => { + makeBaseEnv('PORTAL1'); + makeBaseEnv('PORTAL2'); + service = new ServiceTokenValidation(new Map([['portal1', 'PORTAL1'], ['portal2', 'PORTAL2']])); }); - - And('a valid JWT token', () => { - // Token is provided in When step + And('the first portal raises a retryable JWSSignatureVerificationFailed error', () => { + mockGetVerifiedJwt.mockRejectedValueOnce( + Object.assign(new Error('signature mismatch'), { name: 'JWSSignatureVerificationFailed' }), + ); }); - - When('verifyJwt is called with the token', async () => { - const result = await service.verifyJwt('valid.jwt.token'); - expect(result).toEqual({ - verifiedJwt: { sub: 'user123', aud: 'audience2' }, - openIdConfigKey: 'portal2', + And('the second portal resolves with a valid JWT payload', () => { + mockGetVerifiedJwt.mockResolvedValueOnce({ + payload: { sub: 'user123', aud: 'portal2-aud' }, + protectedHeader: { alg: 'RS256' }, + key: {} as never, }); }); - - Then('it should try verification with each configured provider', () => { + When('verifyJwt is called with a bearer token', async () => { + verifyJwtResult = await service.verifyJwt('test.jwt.token'); + }); + Then('it should call getVerifiedJwt for both portal1 and portal2', () => { expect(mockVerifiedTokenService.getVerifiedJwt).toHaveBeenCalledTimes(2); - expect(mockVerifiedTokenService.getVerifiedJwt).toHaveBeenCalledWith('valid.jwt.token', 'portal1'); - expect(mockVerifiedTokenService.getVerifiedJwt).toHaveBeenCalledWith('valid.jwt.token', 'portal2'); + expect(mockVerifiedTokenService.getVerifiedJwt).toHaveBeenCalledWith('test.jwt.token', 'portal1'); + expect(mockVerifiedTokenService.getVerifiedJwt).toHaveBeenCalledWith('test.jwt.token', 'portal2'); }); - - And('it should return the verification result when successful', () => { - // Result check is in When step + And('it should return the verifiedJwt and openIdConfigKey from the second portal', () => { + expect(verifyJwtResult).toEqual({ + verifiedJwt: { sub: 'user123', aud: 'portal2-aud' }, + openIdConfigKey: 'portal2', + }); }); }); - Scenario('Verifying invalid JWT with ServiceTokenValidation', ({ Given, When, Then, And }) => { - Given('a ServiceTokenValidation instance that is started', () => { - // Set up environment variables - // biome-ignore lint:useLiteralKeys - process.env['PORTAL1_OIDC_ENDPOINT'] = 'https://portal1.com/.well-known/jwks.json'; - // biome-ignore lint:useLiteralKeys - process.env['PORTAL1_OIDC_AUDIENCE'] = 'audience1'; - // biome-ignore lint:useLiteralKeys - process.env['PORTAL1_OIDC_ISSUER'] = 'https://portal1.com'; - - const portalTokens = new Map([['portal1', 'PORTAL1']]); - service = new ServiceTokenValidation(portalTokens); - - // Mock verification failure - mockGetVerifiedJwt.mockResolvedValue(null); + Scenario('verifyJwt propagates a non-retryable error', ({ Given, And, When, Then }) => { + Given('a ServiceTokenValidation instance configured with one portal', () => { + makeBaseEnv('PORTAL1'); + service = new ServiceTokenValidation(new Map([['portal1', 'PORTAL1']])); }); - - And('an invalid JWT token', () => { - // Token is provided in When step + And('the portal raises a non-retryable TypeError', () => { + mockGetVerifiedJwt.mockRejectedValueOnce(new TypeError('unexpected failure')); }); + When('verifyJwt is called with a bearer token', async () => { + try { + verifyJwtResult = await service.verifyJwt('test.jwt.token'); + } catch (e) { + thrownError = e; + } + }); + Then('it should rethrow the non-retryable error', () => { + expect(thrownError).toBeInstanceOf(TypeError); + expect((thrownError as TypeError).message).toBe('unexpected failure'); + }); + }); - When('verifyJwt is called with the invalid token', async () => { - const result = await service.verifyJwt('invalid.jwt.token'); - expect(result).toBeNull(); + Scenario('verifyJwt returns null when a provider returns a result with no payload', ({ Given, And, When, Then }) => { + Given('a ServiceTokenValidation instance configured with one portal', () => { + makeBaseEnv('PORTAL1'); + service = new ServiceTokenValidation(new Map([['portal1', 'PORTAL1']])); }); + And('the portal resolves with a result that has no payload', () => { + mockGetVerifiedJwt.mockResolvedValueOnce({ + payload: undefined, + protectedHeader: { alg: 'RS256' }, + key: {} as never, + } as never); + }); + When('verifyJwt is called with a bearer token', async () => { + verifyJwtResult = await service.verifyJwt('test.jwt.token'); + }); + Then('it should return null', () => { + expect(verifyJwtResult).toBeNull(); + }); + }); - Then('it should return null indicating verification failed', () => { - // Result check is in When step + Scenario('verifyJwt returns null when all providers return null', ({ Given, And, When, Then }) => { + Given('a ServiceTokenValidation instance configured with one portal', () => { + makeBaseEnv('PORTAL1'); + service = new ServiceTokenValidation(new Map([['portal1', 'PORTAL1']])); + }); + And('the portal resolves with null', () => { + // biome-ignore lint/suspicious/noExplicitAny: simulating null return from mock + mockGetVerifiedJwt.mockResolvedValueOnce(null as any); + }); + When('verifyJwt is called with a bearer token', async () => { + verifyJwtResult = await service.verifyJwt('test.jwt.token'); + }); + Then('it should return null', () => { + expect(verifyJwtResult).toBeNull(); }); }); - Scenario('Shutting down the ServiceTokenValidation', ({ Given, When, Then, And }) => { - Given('a started ServiceTokenValidation instance', () => { - // Set up environment variables - // biome-ignore lint:useLiteralKeys - process.env['PORTAL1_OIDC_ENDPOINT'] = 'https://portal1.com/.well-known/jwks.json'; - // biome-ignore lint:useLiteralKeys - process.env['PORTAL1_OIDC_AUDIENCE'] = 'audience1'; - // biome-ignore lint:useLiteralKeys - process.env['PORTAL1_OIDC_ISSUER'] = 'https://portal1.com'; + // ─── shutDown ───────────────────────────────────────────────────────────── - const portalTokens = new Map([['portal1', 'PORTAL1']]); - service = new ServiceTokenValidation(portalTokens); + Scenario('Shutting down when a timer is running clears the interval and logs', ({ Given, When, Then, And }) => { + Given('a ServiceTokenValidation instance with a running timer', () => { + makeBaseEnv('PORTAL1'); + service = new ServiceTokenValidation(new Map([['portal1', 'PORTAL1']])); + mockVerifiedTokenService.timerInstance = setInterval(() => undefined, 60_000); }); - When('shutDown is called', async () => { await service.shutDown(); }); - - Then('it should stop the underlying VerifiedTokenService', () => { - // The shutdown method clears the timer instance from VerifiedTokenService - expect(mockVerifiedTokenService.timerInstance).toBeDefined(); + Then('it should clear the timer interval', () => { + expect(mockConsoleLog).toHaveBeenCalledWith('ServiceTokenValidation stopped'); + }); + And('it should log "ServiceTokenValidation stopped"', () => { + expect(mockConsoleLog).toHaveBeenCalledOnce(); }); + }); - And('it should log that the service stopped', () => { + Scenario('Shutting down when no timer is running still logs', ({ Given, When, Then }) => { + Given('a ServiceTokenValidation instance with no timer running', () => { + makeBaseEnv('PORTAL1'); + service = new ServiceTokenValidation(new Map([['portal1', 'PORTAL1']])); + mockVerifiedTokenService.timerInstance = undefined; + }); + When('shutDown is called', async () => { + await service.shutDown(); + }); + Then('it should log "ServiceTokenValidation stopped"', () => { expect(mockConsoleLog).toHaveBeenCalledWith('ServiceTokenValidation stopped'); }); }); }); + diff --git a/packages/ocom/ui-staff-route-user-management/.storybook/main.ts b/packages/ocom/ui-staff-route-user-management/.storybook/main.ts new file mode 100644 index 000000000..055dca4e5 --- /dev/null +++ b/packages/ocom/ui-staff-route-user-management/.storybook/main.ts @@ -0,0 +1,16 @@ +import { dirname, join } from 'node:path'; +import type { StorybookConfig } from '@storybook/react-vite'; + +function getAbsolutePath(value: string) { + return dirname(require.resolve(join(value, 'package.json'))); +} + +const config: StorybookConfig = { + stories: ['../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'], + addons: [getAbsolutePath('@chromatic-com/storybook'), getAbsolutePath('@storybook/addon-docs'), getAbsolutePath('@storybook/addon-a11y'), getAbsolutePath('@storybook/addon-vitest')], + framework: { + name: getAbsolutePath('@storybook/react-vite'), + options: {}, + }, +}; +export default config; diff --git a/packages/ocom/ui-staff-route-user-management/.storybook/preview.tsx b/packages/ocom/ui-staff-route-user-management/.storybook/preview.tsx new file mode 100644 index 000000000..2f9935493 --- /dev/null +++ b/packages/ocom/ui-staff-route-user-management/.storybook/preview.tsx @@ -0,0 +1,35 @@ +import { MockedProvider } from '@apollo/client/testing'; +import type { Decorator, Preview } from '@storybook/react'; +import { MemoryRouter } from 'react-router-dom'; +import 'antd/dist/reset.css'; + +export const decorators: Decorator[] = [ + (Story, context) => { + const initialEntries = context.parameters?.memoryRouter?.initialEntries ?? ['/']; + const apolloMocks = context.parameters?.apolloMocks ?? []; + + return ( + + + + + + ); + }, +]; + +const preview: Preview = { + parameters: { + controls: { + matchers: { + color: /(background|color)$/i, + date: /Date$/, + }, + }, + }, +}; + +export default preview; diff --git a/packages/ocom/ui-staff-route-user-management/.storybook/vitest.setup.ts b/packages/ocom/ui-staff-route-user-management/.storybook/vitest.setup.ts new file mode 100644 index 000000000..f7590b108 --- /dev/null +++ b/packages/ocom/ui-staff-route-user-management/.storybook/vitest.setup.ts @@ -0,0 +1,7 @@ +import * as a11yAddonAnnotations from '@storybook/addon-a11y/preview'; +import { setProjectAnnotations } from '@storybook/react-vite'; +import * as projectAnnotations from './preview.tsx'; + +// This is an important step to apply the right configuration when testing your stories. +setProjectAnnotations([a11yAddonAnnotations, projectAnnotations]); + \ No newline at end of file diff --git a/packages/ocom/ui-staff-route-user-management/package.json b/packages/ocom/ui-staff-route-user-management/package.json index 435e1b34d..11223b0d1 100644 --- a/packages/ocom/ui-staff-route-user-management/package.json +++ b/packages/ocom/ui-staff-route-user-management/package.json @@ -10,13 +10,17 @@ "prebuild": "pnpm run lint", "build": "tsgo --noEmit", "lint": "biome lint", + "storybook": "storybook dev --port 6007", + "build-storybook": "storybook build", "test": "vitest run --silent --reporter=dot", "test:watch": "vitest" }, "dependencies": { "@ant-design/icons": "catalog:", + "@apollo/client": "^3.13.9", + "@graphql-typed-document-node/core": "^3.2.0", "@ocom/ui-staff-shared": "workspace:*", - "@graphql-typed-document-node/core": "^3.2.0", + "antd": "catalog:", "react": "catalog:", "react-dom": "catalog:", "react-router-dom": "catalog:" @@ -24,9 +28,16 @@ "devDependencies": { "@cellix/config-typescript": "workspace:*", "@cellix/config-vitest": "workspace:*", + "@chromatic-com/storybook": "^4.1.1", + "@storybook/react": "^9.1.9", + "@storybook/addon-a11y": "^9.1.3", + "@storybook/addon-docs": "^9.1.3", + "@storybook/addon-vitest": "^9.1.3", + "@storybook/react-vite": "^9.1.3", "@types/react": "^19.1.11", "@types/react-dom": "^19.1.6", "jsdom": "catalog:", + "storybook": "catalog:", "vite": "catalog:", "vitest": "catalog:", "typescript": "catalog:" diff --git a/packages/ocom/ui-staff-route-user-management/src/components/staff-role-create.container.graphql b/packages/ocom/ui-staff-route-user-management/src/components/staff-role-create.container.graphql new file mode 100644 index 000000000..6c4d46b7e --- /dev/null +++ b/packages/ocom/ui-staff-route-user-management/src/components/staff-role-create.container.graphql @@ -0,0 +1,15 @@ +mutation StaffRoleCreate($input: StaffRoleCreateInput!) { + staffRoleCreate(input: $input) { + status { + success + errorMessage + } + staffRole { + id + roleName + enterpriseAppRole + createdAt + updatedAt + } + } +} diff --git a/packages/ocom/ui-staff-route-user-management/src/components/staff-role-create.container.tsx b/packages/ocom/ui-staff-route-user-management/src/components/staff-role-create.container.tsx new file mode 100644 index 000000000..2be9f968f --- /dev/null +++ b/packages/ocom/ui-staff-route-user-management/src/components/staff-role-create.container.tsx @@ -0,0 +1,143 @@ +import { useMutation } from '@apollo/client'; +import { StaffAuthContext } from '@ocom/ui-staff-shared'; +import { App } from 'antd'; +import type React from 'react'; +import { useContext } from 'react'; +import { Navigate, useNavigate } from 'react-router-dom'; +import { StaffRoleCreateDocument, StaffRolesForSelectDocument, StaffRolesListDocument, type StaffRolesForSelectQuery, type StaffRolesListQuery } from '../generated.tsx'; +import { StaffRoleCreate, type StaffRoleFormValues } from './staff-role-create.tsx'; + +const EnterpriseAppRoleNames = { + CaseManager: 'Staff.CaseManager', + ServiceLineOwner: 'Staff.ServiceLineOwner', + Finance: 'Staff.Finance', + TechAdmin: 'Staff.TechAdmin', +} as const; + +function getAllowedEnterpriseAppRoles(enterpriseAppRole: string | undefined): string[] { + switch (enterpriseAppRole) { + case EnterpriseAppRoleNames.TechAdmin: + return Object.values(EnterpriseAppRoleNames); + case EnterpriseAppRoleNames.ServiceLineOwner: + return [EnterpriseAppRoleNames.ServiceLineOwner, EnterpriseAppRoleNames.CaseManager]; + case EnterpriseAppRoleNames.CaseManager: + return [EnterpriseAppRoleNames.CaseManager]; + case EnterpriseAppRoleNames.Finance: + return [EnterpriseAppRoleNames.Finance]; + default: + return []; + } +} + +export const StaffRoleCreateContainer: React.FC = () => { + const navigate = useNavigate(); + const { message } = App.useApp(); + const auth = useContext(StaffAuthContext); + const availableEnterpriseAppRoles = getAllowedEnterpriseAppRoles(auth?.enterpriseAppRole); + const showTechAdminPermissions = auth?.permissions?.canManageTechAdmin === true; + const canCreateRole = + auth?.permissions?.canAddRole === true || + auth?.permissions?.canManageStaffRolesAndPermissions === true || + auth?.permissions?.canManageTechAdmin === true; + + const [staffRoleCreate, { loading }] = useMutation(StaffRoleCreateDocument, { + update: (cache, { data }) => { + const newRole = data?.staffRoleCreate.staffRole; + if (!newRole) return; + const updateRolesList = (existing: StaffRolesListQuery | null): StaffRolesListQuery | null => { + if (!existing) return { staffRoles: [newRole] }; + if (existing.staffRoles.some((role) => String(role.id) === String(newRole.id))) return existing; + return { staffRoles: [...existing.staffRoles, newRole] }; + }; + const updateRolesSelect = (existing: StaffRolesForSelectQuery | null): StaffRolesForSelectQuery | null => { + if (!existing) return { staffRoles: [newRole] }; + if (existing.staffRoles.some((role) => String(role.id) === String(newRole.id))) return existing; + return { staffRoles: [...existing.staffRoles, newRole] }; + }; + cache.updateQuery({ query: StaffRolesListDocument }, updateRolesList); + cache.updateQuery({ query: StaffRolesForSelectDocument }, updateRolesSelect); + }, + }); + + if (!canCreateRole) { + return ( + + ); + } + + const handleSubmit = async (values: StaffRoleFormValues) => { + try { + const result = await staffRoleCreate({ + variables: { + input: { + roleName: values.roleName, + enterpriseAppRole: values.enterpriseAppRole || null, + permissions: { + communityPermissions: { + canManageCommunities: values.canManageCommunities, + canManageStaffRolesAndPermissions: values.canManageStaffRolesAndPermissions, + canManageAllCommunities: values.canManageAllCommunities, + canDeleteCommunities: values.canDeleteCommunities, + canChangeCommunityOwner: values.canChangeCommunityOwner, + canReIndexSearchCollections: values.canReIndexSearchCollections, + }, + userPermissions: { + canManageUsers: values.canManageUsers, + canAssignStaffRoles: values.canAssignStaffRoles, + canViewStaffUsers: values.canViewStaffUsers, + }, + staffRolePermissions: { + canViewRoles: values.canViewRoles, + canAddRole: values.canAddRole, + canEditRole: values.canEditRole, + canRemoveRole: values.canRemoveRole, + }, + financePermissions: { + canManageFinance: values.canManageFinance, + canViewGLBatchSummaries: values.canViewGLBatchSummaries, + canViewFinanceConfigs: values.canViewFinanceConfigs, + canCreateFinanceConfigs: values.canCreateFinanceConfigs, + }, + ...(showTechAdminPermissions + ? { + techAdminPermissions: { + canManageTechAdmin: values.canManageTechAdmin, + canViewDatabaseExplorer: values.canViewDatabaseExplorer, + canViewBlobExplorer: values.canViewBlobExplorer, + canViewQueueDashboard: values.canViewQueueDashboard, + canSendQueueMessages: values.canSendQueueMessages, + }, + } + : {}), + }, + }, + }, + }); + if (result.data?.staffRoleCreate.status.success) { + message.success('Role created successfully'); + navigate('..'); + } else { + message.error(result.data?.staffRoleCreate.status.errorMessage ?? 'Failed to create role'); + } + } catch (_err) { + message.error('Failed to create role'); + } + }; + + const handleCancel = () => { + navigate('..'); + }; + + return ( + + ); +}; diff --git a/packages/ocom/ui-staff-route-user-management/src/components/staff-role-create.stories.tsx b/packages/ocom/ui-staff-route-user-management/src/components/staff-role-create.stories.tsx new file mode 100644 index 000000000..256a5f219 --- /dev/null +++ b/packages/ocom/ui-staff-route-user-management/src/components/staff-role-create.stories.tsx @@ -0,0 +1,70 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { expect, userEvent, within } from 'storybook/test'; +import { StaffRoleCreate } from './staff-role-create.tsx'; + +const ALL_ENTERPRISE_APP_ROLES = ['Staff.TechAdmin', 'Staff.ServiceLineOwner', 'Staff.CaseManager', 'Staff.Finance']; + +const meta: Meta = { + title: 'UserManagement/Components/StaffRoleCreate', + component: StaffRoleCreate, + parameters: { layout: 'padded' }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + onSubmit: (values) => console.log('Submit:', values), + onCancel: () => console.log('Cancel clicked'), + availableEnterpriseAppRoles: ALL_ENTERPRISE_APP_ROLES, + showTechAdminPermissions: true, + }, +}; + +export const CaseManagerView: Story = { + args: { + onSubmit: (values) => console.log('Submit:', values), + onCancel: () => console.log('Cancel clicked'), + availableEnterpriseAppRoles: ['Staff.CaseManager'], + showTechAdminPermissions: false, + }, +}; + +export const ServiceLineOwnerView: Story = { + args: { + onSubmit: (values) => console.log('Submit:', values), + onCancel: () => console.log('Cancel clicked'), + availableEnterpriseAppRoles: ['Staff.ServiceLineOwner', 'Staff.CaseManager'], + showTechAdminPermissions: false, + }, +}; + +export const WithLoading: Story = { + args: { + onSubmit: (values) => console.log('Submit:', values), + onCancel: () => console.log('Cancel clicked'), + loading: true, + availableEnterpriseAppRoles: ALL_ENTERPRISE_APP_ROLES, + showTechAdminPermissions: true, + }, +}; + +export const PermissionHierarchy: Story = { + args: { + onSubmit: (values) => console.log('Submit:', values), + onCancel: () => console.log('Cancel clicked'), + availableEnterpriseAppRoles: ALL_ENTERPRISE_APP_ROLES, + showTechAdminPermissions: false, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const addRole = canvas.getByRole('checkbox', { name: /can add staff role/i }); + const viewRoles = canvas.getByRole('checkbox', { name: /can view staff roles/i }); + + await userEvent.click(addRole); + + expect(addRole).toBeChecked(); + expect(viewRoles).toBeChecked(); + }, +}; diff --git a/packages/ocom/ui-staff-route-user-management/src/components/staff-role-create.tsx b/packages/ocom/ui-staff-route-user-management/src/components/staff-role-create.tsx new file mode 100644 index 000000000..f4613977b --- /dev/null +++ b/packages/ocom/ui-staff-route-user-management/src/components/staff-role-create.tsx @@ -0,0 +1,234 @@ +import { Button, Checkbox, Divider, Form, Input, Select, Space, Typography } from 'antd'; +import type React from 'react'; + +const { Title } = Typography; + +export interface StaffRoleFormValues { + roleName: string; + enterpriseAppRole: string; + canManageCommunities: boolean; + canManageStaffRolesAndPermissions: boolean; + canManageAllCommunities: boolean; + canDeleteCommunities: boolean; + canChangeCommunityOwner: boolean; + canReIndexSearchCollections: boolean; + canManageUsers: boolean; + canAssignStaffRoles: boolean; + canViewStaffUsers: boolean; + canViewRoles: boolean; + canAddRole: boolean; + canEditRole: boolean; + canRemoveRole: boolean; + canManageFinance: boolean; + canViewGLBatchSummaries: boolean; + canViewFinanceConfigs: boolean; + canCreateFinanceConfigs: boolean; + canManageTechAdmin: boolean; + canViewDatabaseExplorer: boolean; + canViewBlobExplorer: boolean; + canViewQueueDashboard: boolean; + canSendQueueMessages: boolean; +} + +interface StaffRoleCreateProps { + onSubmit: (values: StaffRoleFormValues) => void; + onCancel: () => void; + loading?: boolean; + availableEnterpriseAppRoles?: string[]; + showTechAdminPermissions?: boolean; + initialValues?: Partial; + mode?: 'create' | 'edit'; +} + +type PermissionFieldKey = keyof Omit; + +const PERMISSION_GROUPS: Array<{ + title: string; + techAdminOnly?: boolean; + topLevelKey: PermissionFieldKey; + fields: Array<{ key: PermissionFieldKey; label: string }>; +}> = [ + { + title: 'Community Permissions', + topLevelKey: 'canManageCommunities', + fields: [ + { key: 'canManageCommunities', label: 'Can Manage Communities' }, + { key: 'canManageStaffRolesAndPermissions', label: 'Can Manage Staff Roles and Permissions' }, + { key: 'canManageAllCommunities', label: 'Can Manage All Communities' }, + { key: 'canDeleteCommunities', label: 'Can Delete Communities' }, + { key: 'canChangeCommunityOwner', label: 'Can Change Community Owner' }, + { key: 'canReIndexSearchCollections', label: 'Can Reindex Search Collections' }, + ], + }, + { + title: 'User', + topLevelKey: 'canManageUsers', + fields: [ + { key: 'canManageUsers', label: 'Can Manage Users' }, + { key: 'canAssignStaffRoles', label: 'Can Assign Staff Roles' }, + { key: 'canViewStaffUsers', label: 'Can View Staff Users' }, + ], + }, + { + title: 'Staff Roles', + topLevelKey: 'canViewRoles', + fields: [ + { key: 'canViewRoles', label: 'Can View Staff Roles' }, + { key: 'canAddRole', label: 'Can Add Staff Role' }, + { key: 'canEditRole', label: 'Can Edit Staff Role' }, + { key: 'canRemoveRole', label: 'Can Remove Staff Role' }, + ], + }, + { + title: 'Finance', + topLevelKey: 'canManageFinance', + fields: [ + { key: 'canManageFinance', label: 'Can Manage Finance' }, + { key: 'canViewGLBatchSummaries', label: 'Can View GL Batch Summaries' }, + { key: 'canViewFinanceConfigs', label: 'Can View Finance Configs' }, + { key: 'canCreateFinanceConfigs', label: 'Can Create Finance Configs' }, + ], + }, + { + title: 'Tech Admin', + techAdminOnly: true, + topLevelKey: 'canManageTechAdmin', + fields: [ + { key: 'canManageTechAdmin', label: 'Can Manage Tech Admin' }, + { key: 'canViewDatabaseExplorer', label: 'Can View Database Explorer' }, + { key: 'canViewBlobExplorer', label: 'Can View Blob Explorer' }, + { key: 'canViewQueueDashboard', label: 'Can View Queue Dashboard' }, + { key: 'canSendQueueMessages', label: 'Can Send Queue Messages' }, + ], + }, +]; + +const normalizePermissionHierarchy = (values: StaffRoleFormValues): StaffRoleFormValues => { + const normalized = { ...values }; + + for (const group of PERMISSION_GROUPS) { + const shouldEnableTopLevel = group.fields.slice(1).some(({ key }) => normalized[key]); + if (shouldEnableTopLevel) { + normalized[group.topLevelKey] = true; + } + } + + return normalized; +}; + +const DEFAULT_VALUES: StaffRoleFormValues = { + roleName: '', + enterpriseAppRole: '', + canManageCommunities: false, + canManageStaffRolesAndPermissions: false, + canManageAllCommunities: false, + canDeleteCommunities: false, + canChangeCommunityOwner: false, + canReIndexSearchCollections: false, + canManageUsers: false, + canAssignStaffRoles: false, + canViewStaffUsers: false, + canViewRoles: false, + canAddRole: false, + canEditRole: false, + canRemoveRole: false, + canManageFinance: false, + canViewGLBatchSummaries: false, + canViewFinanceConfigs: false, + canCreateFinanceConfigs: false, + canManageTechAdmin: false, + canViewDatabaseExplorer: false, + canViewBlobExplorer: false, + canViewQueueDashboard: false, + canSendQueueMessages: false, +}; + +export const StaffRoleCreate: React.FC = ({ onSubmit, onCancel, loading, availableEnterpriseAppRoles, showTechAdminPermissions, initialValues, mode = 'create' }) => { + const [form] = Form.useForm(); + + const defaultValues: StaffRoleFormValues = { + ...normalizePermissionHierarchy({ ...DEFAULT_VALUES, ...initialValues }), + }; + + const isEdit = mode === 'edit'; + const enterpriseAppRoleOptions = (availableEnterpriseAppRoles ?? []).map((r) => ({ value: r, label: r })); + + const handleValuesChange = (_changedValues: Partial, allValues: StaffRoleFormValues) => { + const normalizedValues = normalizePermissionHierarchy(allValues); + const hasHierarchyChange = PERMISSION_GROUPS.some((group) => { + const topLevelIsFalse = normalizedValues[group.topLevelKey] !== allValues[group.topLevelKey]; + const childSelected = group.fields.slice(1).some(({ key }) => allValues[key]); + return topLevelIsFalse && childSelected; + }); + + if (hasHierarchyChange) { + form.setFieldsValue(normalizedValues); + } + }; + + return ( + + {isEdit ? 'Edit Staff Role' : 'Create Staff Role'} +
+ + + + + ({ value: r.id, label: r.roleName }))} + placeholder={currentRoleName ? `${currentRoleName}` : 'No role assigned'} + /> + + {isEditingOwnRole && ( +
+
+ )} + + +
+ Activity Log + + dataSource={activityLog} + columns={activityLogColumns} + rowKey={(record) => `${record.activityType}-${record.createdAt}-${record.activityByStaffUserId}`} + pagination={{ pageSize: 10, showSizeChanger: false }} + locale={{ emptyText: 'No activity recorded' }} + size="small" + /> +
+ + ); +}; diff --git a/packages/ocom/ui-staff-route-user-management/src/components/staff-users-list.container.graphql b/packages/ocom/ui-staff-route-user-management/src/components/staff-users-list.container.graphql new file mode 100644 index 000000000..a87b8d325 --- /dev/null +++ b/packages/ocom/ui-staff-route-user-management/src/components/staff-users-list.container.graphql @@ -0,0 +1,16 @@ +query StaffUsersList { + staffUsers { + ...StaffUsersListFields + } +} + +fragment StaffUsersListFields on StaffUser { + id + displayName + email + createdAt + role { + id + roleName + } +} diff --git a/packages/ocom/ui-staff-route-user-management/src/components/staff-users-list.container.tsx b/packages/ocom/ui-staff-route-user-management/src/components/staff-users-list.container.tsx new file mode 100644 index 000000000..217c8efe3 --- /dev/null +++ b/packages/ocom/ui-staff-route-user-management/src/components/staff-users-list.container.tsx @@ -0,0 +1,40 @@ +import { useQuery } from '@apollo/client'; +import { StaffAuthContext } from '@ocom/ui-staff-shared'; +import type React from 'react'; +import { useContext } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { StaffUsersListDocument } from '../generated.tsx'; +import { StaffUsersList } from './staff-users-list.tsx'; + +export const StaffUsersListContainer: React.FC = () => { + const navigate = useNavigate(); + const auth = useContext(StaffAuthContext); + const perms = auth?.permissions; + const canEdit = + perms?.canAssignStaffRoles === true || + perms?.canManageUsers === true || + perms?.canManageStaffRolesAndPermissions === true || + perms?.canManageTechAdmin === true; + const { data, loading } = useQuery(StaffUsersListDocument, { + fetchPolicy: 'cache-and-network', + }); + + const handleEdit = (id: string) => { + navigate(id); + }; + + return ( + ({ + id: String(u.id), + displayName: u.displayName, + email: u.email, + role: u.role ? { id: String(u.role.id), roleName: u.role.roleName } : null, + createdAt: u.createdAt ? String(u.createdAt) : '', + }) )} + onEdit={handleEdit} + loading={loading} + canEdit={canEdit} + /> + ); +}; diff --git a/packages/ocom/ui-staff-route-user-management/src/components/staff-users-list.stories.tsx b/packages/ocom/ui-staff-route-user-management/src/components/staff-users-list.stories.tsx new file mode 100644 index 000000000..7687f8a38 --- /dev/null +++ b/packages/ocom/ui-staff-route-user-management/src/components/staff-users-list.stories.tsx @@ -0,0 +1,57 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { expect, within } from 'storybook/test'; +import { type StaffUser, StaffUsersList } from './staff-users-list.tsx'; + +const mockUsers: StaffUser[] = [ + { + id: '1', + displayName: 'Alice Admin', + email: 'alice@example.com', + role: { id: 'r1', roleName: 'Tech Admin' }, + createdAt: '2024-01-01T00:00:00Z', + }, + { + id: '2', + displayName: 'Bob Manager', + email: 'bob@example.com', + role: null, + createdAt: '2024-02-01T00:00:00Z', + }, +]; + +const meta: Meta = { + title: 'UserManagement/Components/StaffUsersList', + component: StaffUsersList, + parameters: { layout: 'padded' }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + data: mockUsers, + onEdit: (id) => console.log('Edit user:', id), + canEdit: true, + }, + play: ({ canvasElement }: { canvasElement: HTMLElement }) => { + const canvas = within(canvasElement); + expect(canvas.getByText('Staff Users (2)')).toBeInTheDocument(); + expect(canvas.getByText('Alice Admin')).toBeInTheDocument(); + expect(canvas.getByText('Bob Manager')).toBeInTheDocument(); + expect(canvas.getByText('Tech Admin')).toBeInTheDocument(); + expect(canvas.getByText('No Role')).toBeInTheDocument(); + }, +}; + +export const EmptyState: Story = { + args: { + data: [], + onEdit: (id) => console.log('Edit user:', id), + canEdit: true, + }, + play: ({ canvasElement }: { canvasElement: HTMLElement }) => { + const canvas = within(canvasElement); + expect(canvas.getByText('Staff Users (0)')).toBeInTheDocument(); + }, +}; diff --git a/packages/ocom/ui-staff-route-user-management/src/components/staff-users-list.tsx b/packages/ocom/ui-staff-route-user-management/src/components/staff-users-list.tsx new file mode 100644 index 000000000..443bfa2f4 --- /dev/null +++ b/packages/ocom/ui-staff-route-user-management/src/components/staff-users-list.tsx @@ -0,0 +1,71 @@ +import type { TableColumnsType } from 'antd'; +import { Button, Space, Table, Typography } from 'antd'; +import type React from 'react'; + +const { Title } = Typography; + +export interface StaffUser { + id: string; + displayName: string; + email: string; + role?: { id: string; roleName: string } | null; + createdAt: string; +} + +interface StaffUsersListProps { + data: StaffUser[]; + onEdit: (id: string) => void; + canEdit?: boolean; + loading?: boolean; +} + +export const StaffUsersList: React.FC = ({ data, onEdit, canEdit = false, loading }) => { + const columns: TableColumnsType = [ + { title: 'Display Name', dataIndex: 'displayName', key: 'displayName' }, + { title: 'Email', dataIndex: 'email', key: 'email' }, + { + title: 'Role', + key: 'role', + render: (_: unknown, record: StaffUser) => record.role?.roleName ?? 'No Role', + }, + { + title: 'Created At', + dataIndex: 'createdAt', + key: 'createdAt', + render: (date: string) => (date ? new Date(date).toLocaleDateString() : 'N/A'), + }, + { + title: 'Action', + key: 'action', + render: (_: unknown, record: StaffUser) => ( + canEdit ? ( + + ) : null + ), + }, + ]; + + return ( + +
+ Staff Users ({data.length}) +
+ + + ); +}; diff --git a/packages/ocom/ui-staff-route-user-management/src/index.tsx b/packages/ocom/ui-staff-route-user-management/src/index.tsx index f2c3911df..1c60eb065 100644 --- a/packages/ocom/ui-staff-route-user-management/src/index.tsx +++ b/packages/ocom/ui-staff-route-user-management/src/index.tsx @@ -1,6 +1,6 @@ -import { PlaceholderPage } from '@ocom/ui-staff-shared'; import type React from 'react'; -import { Route, Routes } from 'react-router-dom'; +import { Navigate, Route, Routes } from 'react-router-dom'; +import { UserManagementPage } from './pages/user-management.tsx'; import { SectionLayout } from './section-layout.tsx'; export const Root: React.FC = () => { @@ -13,22 +13,15 @@ export const Root: React.FC = () => { } /> - } + element={} /> diff --git a/packages/ocom/ui-staff-route-user-management/src/pages/staff-roles.stories.tsx b/packages/ocom/ui-staff-route-user-management/src/pages/staff-roles.stories.tsx new file mode 100644 index 000000000..f7ffbaeb9 --- /dev/null +++ b/packages/ocom/ui-staff-route-user-management/src/pages/staff-roles.stories.tsx @@ -0,0 +1,62 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { Route, Routes } from 'react-router-dom'; +import { StaffRoleCreateDocument, StaffRolesListDocument } from '../generated.tsx'; +import { StaffRolesPage } from './staff-roles.tsx'; + +const mockStaffRoles = [ + { id: 'r1', roleName: 'Case Manager', enterpriseAppRole: 'Staff.CaseManager', createdAt: '2024-01-01T00:00:00.000Z', updatedAt: '2024-01-15T00:00:00.000Z' }, + { id: 'r2', roleName: 'Finance', enterpriseAppRole: 'Staff.Finance', createdAt: '2024-01-02T00:00:00.000Z', updatedAt: '2024-01-15T00:00:00.000Z' }, +]; + +const listMock = { + request: { query: StaffRolesListDocument }, + result: { data: { staffRoles: mockStaffRoles } }, +}; + +const createMock = { + request: { query: StaffRoleCreateDocument, variables: { input: { roleName: 'New Role' } } }, + result: { data: { staffRoleCreate: { status: { success: true, errorMessage: null }, staffRole: { id: 'r3', roleName: 'New Role', enterpriseAppRole: '' } } } }, +}; + +const meta: Meta = { + title: 'UserManagement/Pages/StaffRolesPage', + component: StaffRolesPage, + parameters: { + layout: 'padded', + memoryRouter: { + initialEntries: ['/'], + }, + apolloMocks: [listMock, createMock], + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: () => ( + + } + /> + + ), +}; + +export const CreateView: Story = { + parameters: { + memoryRouter: { + initialEntries: ['/create'], + }, + apolloMocks: [listMock, createMock], + }, + render: () => ( + + } + /> + + ), +}; diff --git a/packages/ocom/ui-staff-route-user-management/src/pages/staff-roles.tsx b/packages/ocom/ui-staff-route-user-management/src/pages/staff-roles.tsx new file mode 100644 index 000000000..35f60fd6b --- /dev/null +++ b/packages/ocom/ui-staff-route-user-management/src/pages/staff-roles.tsx @@ -0,0 +1,46 @@ +import type React from 'react'; +import { useContext } from 'react'; +import { Navigate, Route, Routes } from 'react-router-dom'; +import { StaffAuthContext } from '@ocom/ui-staff-shared'; +import { StaffRoleCreateContainer } from '../components/staff-role-create.container.tsx'; +import { StaffRoleEditContainer } from '../components/staff-role-edit.container.tsx'; +import { StaffRolesListContainer } from '../components/staff-roles-list.container.tsx'; + +export const StaffRolesPage: React.FC = () => { + const auth = useContext(StaffAuthContext); + const perms = auth?.permissions; + const canViewRoles = + perms?.canViewRoles === true || + perms?.canAddRole === true || + perms?.canEditRole === true || + perms?.canRemoveRole === true || + perms?.canManageStaffRolesAndPermissions === true || + perms?.canManageTechAdmin === true; + const canViewStaffUsers = perms?.canViewStaffUsers === true || perms?.canManageUsers === true || perms?.canManageTechAdmin === true; + + if (!canViewRoles) { + return ( + + ); + } + + return ( + + } + /> + } + /> + } + /> + + ); +}; diff --git a/packages/ocom/ui-staff-route-user-management/src/pages/staff-users.stories.tsx b/packages/ocom/ui-staff-route-user-management/src/pages/staff-users.stories.tsx new file mode 100644 index 000000000..b48aad2f6 --- /dev/null +++ b/packages/ocom/ui-staff-route-user-management/src/pages/staff-users.stories.tsx @@ -0,0 +1,40 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { Route, Routes } from 'react-router-dom'; +import { StaffUsersListDocument } from '../generated.tsx'; +import { StaffUsersPage } from './staff-users.tsx'; + +const mockStaffUsers = [ + { id: '1', displayName: 'Alice Admin', email: 'alice@example.com', createdAt: '2024-01-01T00:00:00.000Z', role: { id: 'r1', roleName: 'Case Manager' } }, + { id: '2', displayName: 'Bob Staff', email: 'bob@example.com', createdAt: '2024-02-01T00:00:00.000Z', role: null }, +]; + +const meta: Meta = { + title: 'UserManagement/Pages/StaffUsersPage', + component: StaffUsersPage, + parameters: { + layout: 'padded', + memoryRouter: { + initialEntries: ['/'], + }, + apolloMocks: [ + { + request: { query: StaffUsersListDocument }, + result: { data: { staffUsers: mockStaffUsers } }, + }, + ], + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: () => ( + + } + /> + + ), +}; diff --git a/packages/ocom/ui-staff-route-user-management/src/pages/staff-users.tsx b/packages/ocom/ui-staff-route-user-management/src/pages/staff-users.tsx new file mode 100644 index 000000000..7f7980e68 --- /dev/null +++ b/packages/ocom/ui-staff-route-user-management/src/pages/staff-users.tsx @@ -0,0 +1,41 @@ +import type React from 'react'; +import { useContext } from 'react'; +import { Navigate, Route, Routes } from 'react-router-dom'; +import { StaffAuthContext } from '@ocom/ui-staff-shared'; +import { StaffUserDetailContainer } from '../components/staff-user-detail.container.tsx'; +import { StaffUsersListContainer } from '../components/staff-users-list.container.tsx'; + +export const StaffUsersPage: React.FC = () => { + const auth = useContext(StaffAuthContext); + const perms = auth?.permissions; + const canViewStaffUsers = perms?.canViewStaffUsers === true || perms?.canManageUsers === true || perms?.canManageTechAdmin === true; + const canViewRoles = + perms?.canViewRoles === true || + perms?.canAddRole === true || + perms?.canEditRole === true || + perms?.canRemoveRole === true || + perms?.canManageStaffRolesAndPermissions === true || + perms?.canManageTechAdmin === true; + + if (!canViewStaffUsers) { + return ( + + ); + } + + return ( + + } + /> + } + /> + + ); +}; diff --git a/packages/ocom/ui-staff-route-user-management/src/pages/user-management.stories.tsx b/packages/ocom/ui-staff-route-user-management/src/pages/user-management.stories.tsx new file mode 100644 index 000000000..56ea7b59e --- /dev/null +++ b/packages/ocom/ui-staff-route-user-management/src/pages/user-management.stories.tsx @@ -0,0 +1,44 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { Route, Routes } from 'react-router-dom'; +import { UserManagementPage } from './user-management.tsx'; + +const meta: Meta = { + title: 'UserManagement/Pages/UserManagementPage', + component: UserManagementPage, + parameters: { + layout: 'padded', + memoryRouter: { + initialEntries: ['/staff-users'], + }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const StaffUsersTab: Story = { + render: () => ( + + } + /> + + ), +}; + +export const StaffRolesTab: Story = { + parameters: { + memoryRouter: { + initialEntries: ['/staff-roles'], + }, + }, + render: () => ( + + } + /> + + ), +}; diff --git a/packages/ocom/ui-staff-route-user-management/src/pages/user-management.tsx b/packages/ocom/ui-staff-route-user-management/src/pages/user-management.tsx new file mode 100644 index 000000000..230a35c30 --- /dev/null +++ b/packages/ocom/ui-staff-route-user-management/src/pages/user-management.tsx @@ -0,0 +1,66 @@ +import { SafetyOutlined, UserOutlined } from '@ant-design/icons'; +import { SubPageLayout, VerticalTabs } from '@ocom/ui-staff-shared'; +import { StaffAuthContext } from '@ocom/ui-staff-shared'; +import type React from 'react'; +import { useContext } from 'react'; +import { Navigate } from 'react-router-dom'; +import { StaffRolesPage } from './staff-roles.tsx'; +import { StaffUsersPage } from './staff-users.tsx'; + +export const UserManagementPage: React.FC = () => { + const auth = useContext(StaffAuthContext); + const perms = auth?.permissions; + const canViewStaffUsers = perms?.canViewStaffUsers === true || perms?.canManageUsers === true || perms?.canManageStaffRolesAndPermissions === true || perms?.canManageTechAdmin === true; + const canViewRoles = + perms?.canViewRoles === true || + perms?.canAddRole === true || + perms?.canEditRole === true || + perms?.canRemoveRole === true || + perms?.canManageStaffRolesAndPermissions === true || + perms?.canManageTechAdmin === true; + + const pages = [ + ...(canViewStaffUsers + ? [ + { + id: 'staff-users', + link: 'staff-users', + path: 'staff-users/*', + title: 'Staff Users', + icon: , + element: , + }, + ] + : []), + ...(canViewRoles + ? [ + { + id: 'staff-roles', + link: 'staff-roles', + path: 'staff-roles/*', + title: 'Staff Roles', + icon: , + element: , + }, + ] + : []), + ]; + + if (pages.length === 0) { + return ( + + ); + } + + return ( + User Management} + > + + + ); +}; diff --git a/packages/ocom/ui-staff-route-user-management/vitest.config.ts b/packages/ocom/ui-staff-route-user-management/vitest.config.ts index 17bec4371..0b6d005dc 100644 --- a/packages/ocom/ui-staff-route-user-management/vitest.config.ts +++ b/packages/ocom/ui-staff-route-user-management/vitest.config.ts @@ -1,12 +1,10 @@ -import { baseConfig } from '@cellix/config-vitest'; -import { defineConfig, mergeConfig } from 'vitest/config'; +import { createStorybookVitestConfig, getDirnameFromImportMetaUrl } from '@cellix/config-vitest'; +import { defineConfig } from 'vitest/config'; -export default mergeConfig( - baseConfig, - defineConfig({ - test: { - environment: 'jsdom', - passWithNoTests: true, - }, +const dirname = getDirnameFromImportMetaUrl(import.meta.url); + +export default defineConfig( + createStorybookVitestConfig(dirname, { + additionalCoverageExclude: ['src/index.tsx'], }), ); diff --git a/packages/ocom/ui-staff-shared/src/require-role.tsx b/packages/ocom/ui-staff-shared/src/require-role.tsx index 71f2b6d02..ae363ad57 100644 --- a/packages/ocom/ui-staff-shared/src/require-role.tsx +++ b/packages/ocom/ui-staff-shared/src/require-role.tsx @@ -18,9 +18,18 @@ const STAFF_USER_CURRENT_QUERY = gql` permissions { communityPermissions { canManageCommunities + canManageStaffRolesAndPermissions } userPermissions { canManageUsers + canAssignStaffRoles + canViewStaffUsers + } + staffRolePermissions { + canViewRoles + canAddRole + canEditRole + canRemoveRole } financePermissions { canManageFinance @@ -38,8 +47,9 @@ interface StaffUserCurrentQueryResult { staffUserCurrent: { role?: { permissions: { - communityPermissions: { canManageCommunities: boolean }; - userPermissions: { canManageUsers: boolean }; + communityPermissions: { canManageCommunities: boolean; canManageStaffRolesAndPermissions: boolean }; + userPermissions: { canManageUsers: boolean; canAssignStaffRoles: boolean; canViewStaffUsers: boolean }; + staffRolePermissions: { canViewRoles: boolean; canAddRole: boolean; canEditRole: boolean; canRemoveRole: boolean }; financePermissions: { canManageFinance: boolean }; techAdminPermissions: { canManageTechAdmin: boolean }; }; @@ -61,9 +71,16 @@ export const RequireRole: FC = ({ roles, permKey, children }) const permissions: NonNullable | undefined = rolePermissions ? { canManageCommunities: rolePermissions.communityPermissions.canManageCommunities, + canManageStaffRolesAndPermissions: rolePermissions.communityPermissions.canManageStaffRolesAndPermissions, canManageUsers: rolePermissions.userPermissions.canManageUsers, + canAssignStaffRoles: rolePermissions.userPermissions.canAssignStaffRoles, + canViewStaffUsers: rolePermissions.userPermissions.canViewStaffUsers, canManageFinance: rolePermissions.financePermissions.canManageFinance, canManageTechAdmin: rolePermissions.techAdminPermissions.canManageTechAdmin, + canViewRoles: rolePermissions.staffRolePermissions.canViewRoles, + canAddRole: rolePermissions.staffRolePermissions.canAddRole, + canEditRole: rolePermissions.staffRolePermissions.canEditRole, + canRemoveRole: rolePermissions.staffRolePermissions.canRemoveRole, } : undefined; const isAuthorized = permKey !== undefined && permissions?.[permKey] === true; diff --git a/packages/ocom/ui-staff-shared/src/section-layout.stories.tsx b/packages/ocom/ui-staff-shared/src/section-layout.stories.tsx index f2d05c4cb..b244861a1 100644 --- a/packages/ocom/ui-staff-shared/src/section-layout.stories.tsx +++ b/packages/ocom/ui-staff-shared/src/section-layout.stories.tsx @@ -90,7 +90,7 @@ describe('SectionLayout merging behaviour', () => { await new Promise((r) => setTimeout(r, 10)); expect(container.textContent).not.toContain('Communities'); - expect(container.textContent).not.toContain('Users'); + expect(container.textContent).toContain('Users'); expect(container.textContent).toContain('Finance'); expect(container.textContent).not.toContain('Tech Admin'); }); diff --git a/packages/ocom/ui-staff-shared/src/section-layout.tsx b/packages/ocom/ui-staff-shared/src/section-layout.tsx index 45cc05295..400f31983 100644 --- a/packages/ocom/ui-staff-shared/src/section-layout.tsx +++ b/packages/ocom/ui-staff-shared/src/section-layout.tsx @@ -74,10 +74,19 @@ export const SectionLayout: React.FC = (props) => { // Build default page layouts from backend permissions. const perms = auth?.permissions; const canManageCommunities = perms?.canManageCommunities === true; + const canManageStaffRolesAndPermissions = perms?.canManageStaffRolesAndPermissions === true; const canManageUsers = perms?.canManageUsers === true; + const canAssignStaffRoles = perms?.canAssignStaffRoles === true; + const canViewStaffUsers = perms?.canViewStaffUsers === true; const canManageFinance = perms?.canManageFinance === true; const canManageTechAdmin = perms?.canManageTechAdmin === true; + const canViewRoles = perms?.canViewRoles === true; + const canAddRole = perms?.canAddRole === true; + const canEditRole = perms?.canEditRole === true; + const canRemoveRole = perms?.canRemoveRole === true; const nestedParentProps = canManageCommunities ? { parent: 'ROOT' as const } : {}; + const canAccessUserManagement = + canManageUsers || canAssignStaffRoles || canViewStaffUsers || canManageStaffRolesAndPermissions || canViewRoles || canAddRole || canEditRole || canRemoveRole || canManageTechAdmin; // Construct default page layouts ensuring a ROOT entry always exists so MenuComponent renders. // If Communities is allowed, keep the historic behaviour: Communities is ROOT and others are its children. @@ -87,7 +96,7 @@ export const SectionLayout: React.FC = (props) => { if (canManageCommunities) { // Communities as canonical root, others as children defaultPageLayouts.push({ path: '/staff/community-management', title: 'Communities', icon: , id: 'ROOT' }); - if (canManageUsers) defaultPageLayouts.push({ path: '/staff/user-management/*', title: 'Users', icon: , id: 'users', ...nestedParentProps }); + if (canAccessUserManagement) defaultPageLayouts.push({ path: '/staff/user-management/*', title: 'Users', icon: , id: 'users', ...nestedParentProps }); if (canManageFinance) defaultPageLayouts.push({ path: '/staff/finance/*', title: 'Finance', icon: , id: 'finance', ...nestedParentProps }); if (canManageTechAdmin) defaultPageLayouts.push({ path: '/staff/tech/*', title: 'Tech Admin', icon: , id: 'tech', ...nestedParentProps }); } else { @@ -95,9 +104,9 @@ export const SectionLayout: React.FC = (props) => { if (canManageFinance) { defaultPageLayouts.push({ path: '/staff/finance/*', title: 'Finance', icon: , id: 'ROOT' }); // add others as children if present - if (canManageUsers) defaultPageLayouts.push({ path: '/staff/user-management/*', title: 'Users', icon: , id: 'users', parent: 'ROOT' }); + if (canAccessUserManagement) defaultPageLayouts.push({ path: '/staff/user-management/*', title: 'Users', icon: , id: 'users', parent: 'ROOT' }); if (canManageTechAdmin) defaultPageLayouts.push({ path: '/staff/tech/*', title: 'Tech Admin', icon: , id: 'tech', parent: 'ROOT' }); - } else if (canManageUsers) { + } else if (canAccessUserManagement) { defaultPageLayouts.push({ path: '/staff/user-management/*', title: 'Users', icon: , id: 'ROOT' }); if (canManageTechAdmin) defaultPageLayouts.push({ path: '/staff/tech/*', title: 'Tech Admin', icon: , id: 'tech', parent: 'ROOT' }); } else if (canManageTechAdmin) { diff --git a/packages/ocom/ui-staff-shared/src/staff-app-roles.ts b/packages/ocom/ui-staff-shared/src/staff-app-roles.ts index d3f459698..021d089ad 100644 --- a/packages/ocom/ui-staff-shared/src/staff-app-roles.ts +++ b/packages/ocom/ui-staff-shared/src/staff-app-roles.ts @@ -13,7 +13,7 @@ export type StaffAppRole = (typeof StaffAppRoles)[keyof typeof StaffAppRoles]; */ export const staffRouteRoles = { '/staff/community-management': [StaffAppRoles.CaseManager, StaffAppRoles.ServiceLineOwner, StaffAppRoles.TechAdmin], - '/staff/user-management': [StaffAppRoles.CaseManager, StaffAppRoles.ServiceLineOwner, StaffAppRoles.TechAdmin], + '/staff/user-management': [StaffAppRoles.CaseManager, StaffAppRoles.ServiceLineOwner, StaffAppRoles.Finance, StaffAppRoles.TechAdmin], '/staff/finance': [StaffAppRoles.Finance, StaffAppRoles.TechAdmin], '/staff/tech': [StaffAppRoles.TechAdmin], } satisfies Record; diff --git a/packages/ocom/ui-staff-shared/src/staff-route-shell.tsx b/packages/ocom/ui-staff-shared/src/staff-route-shell.tsx index 0b16503c3..5c3130919 100644 --- a/packages/ocom/ui-staff-shared/src/staff-route-shell.tsx +++ b/packages/ocom/ui-staff-shared/src/staff-route-shell.tsx @@ -13,11 +13,19 @@ export type StaffAuth = { roles?: string[]; raw?: Record; onLogout?: () => Promise | void; + enterpriseAppRole?: string; permissions?: { canManageCommunities?: boolean; + canManageStaffRolesAndPermissions?: boolean; canManageUsers?: boolean; + canAssignStaffRoles?: boolean; + canViewStaffUsers?: boolean; canManageFinance?: boolean; canManageTechAdmin?: boolean; + canViewRoles?: boolean; + canAddRole?: boolean; + canEditRole?: boolean; + canRemoveRole?: boolean; }; }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1d1c2934c..c5b57fd9f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -85,8 +85,8 @@ catalogs: specifier: ^19.1.1 version: 19.2.0 react-router-dom: - specifier: 7.12.0 - version: 7.12.0 + specifier: 7.15.0 + version: 7.15.0 rimraf: specifier: 6.0.1 version: 6.0.1 @@ -145,6 +145,7 @@ overrides: '@babel/plugin-transform-modules-systemjs': 7.29.4 ws: 8.20.1 shell-quote: 1.8.4 + react-router: 7.15.0 packageExtensionsChecksum: sha256-mDviJarBPcwNNCTUf3T37btBxDGgV1wZ/iUGQfx5OCA= @@ -319,14 +320,14 @@ importers: apps/docs: dependencies: '@docusaurus/core': - specifier: 3.9.2 - version: 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.7)(react@19.2.0))(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@6.0.3) + specifier: 3.10.1 + version: 3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.7)(react@19.2.0))(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@6.0.3) '@docusaurus/plugin-content-docs': - specifier: ^3.9.2 - version: 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.7)(react@19.2.0))(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@6.0.3) + specifier: ^3.10.1 + version: 3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.7)(react@19.2.0))(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@6.0.3) '@docusaurus/preset-classic': - specifier: 3.9.2 - version: 3.9.2(@algolia/client-search@5.45.0)(@mdx-js/react@3.1.1(@types/react@19.2.7)(react@19.2.0))(@types/react@19.2.7)(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(search-insights@2.17.3)(typescript@6.0.3) + specifier: 3.10.1 + version: 3.10.1(@algolia/client-search@5.45.0)(@mdx-js/react@3.1.1(@types/react@19.2.7)(react@19.2.0))(@types/react@19.2.7)(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(search-insights@2.17.3)(typescript@6.0.3) '@mdx-js/react': specifier: ^3.0.0 version: 3.1.1(@types/react@19.2.7)(react@19.2.0) @@ -350,14 +351,14 @@ importers: specifier: workspace:* version: link:../../packages/cellix/config-vitest '@docusaurus/module-type-aliases': - specifier: 3.9.2 - version: 3.9.2(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + specifier: 3.10.1 + version: 3.10.1(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@docusaurus/tsconfig': - specifier: 3.9.2 - version: 3.9.2 + specifier: 3.10.1 + version: 3.10.1 '@docusaurus/types': - specifier: 3.9.2 - version: 3.9.2(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + specifier: 3.10.1 + version: 3.10.1(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@testing-library/jest-dom': specifier: ^6.6.3 version: 6.9.1 @@ -485,7 +486,7 @@ importers: version: 3.3.0(oidc-client-ts@3.4.1)(react@19.2.0) react-router-dom: specifier: 'catalog:' - version: 7.12.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + version: 7.15.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0) devDependencies: '@cellix/config-typescript': specifier: workspace:* @@ -615,7 +616,7 @@ importers: version: 3.3.0(oidc-client-ts@3.4.1)(react@19.2.0) react-router-dom: specifier: 'catalog:' - version: 7.12.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + version: 7.15.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0) devDependencies: '@cellix/config-typescript': specifier: workspace:* @@ -1012,7 +1013,7 @@ importers: version: 3.3.0(oidc-client-ts@3.4.1)(react@19.2.0) react-router-dom: specifier: 'catalog:' - version: 7.12.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + version: 7.15.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0) rimraf: specifier: 'catalog:' version: 6.0.1 @@ -1831,7 +1832,7 @@ importers: version: 3.3.0(oidc-client-ts@3.4.1)(react@19.2.0) react-router-dom: specifier: 'catalog:' - version: 7.12.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + version: 7.15.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0) devDependencies: '@cellix/archunit-tests': specifier: workspace:* @@ -1925,7 +1926,7 @@ importers: version: 19.2.0(react@19.2.0) react-router-dom: specifier: 'catalog:' - version: 7.12.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + version: 7.15.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0) devDependencies: '@cellix/archunit-tests': specifier: workspace:* @@ -2077,7 +2078,7 @@ importers: version: 19.2.0(react@19.2.0) react-router-dom: specifier: 'catalog:' - version: 7.12.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + version: 7.15.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0) devDependencies: '@ant-design/icons': specifier: 'catalog:' @@ -2159,7 +2160,7 @@ importers: version: 3.3.0(oidc-client-ts@3.4.1)(react@19.2.0) react-router-dom: specifier: 'catalog:' - version: 7.12.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + version: 7.15.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0) devDependencies: '@cellix/archunit-tests': specifier: workspace:* @@ -2241,7 +2242,7 @@ importers: version: 19.2.0(react@19.2.0) react-router-dom: specifier: 'catalog:' - version: 7.12.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + version: 7.15.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0) devDependencies: '@cellix/config-typescript': specifier: workspace:* @@ -2284,7 +2285,7 @@ importers: version: 19.2.0(react@19.2.0) react-router-dom: specifier: 'catalog:' - version: 7.12.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + version: 7.15.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0) devDependencies: '@cellix/config-typescript': specifier: workspace:* @@ -2370,7 +2371,7 @@ importers: version: 19.2.0(react@19.2.0) react-router-dom: specifier: 'catalog:' - version: 7.12.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + version: 7.15.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0) devDependencies: '@cellix/config-typescript': specifier: workspace:* @@ -2402,12 +2403,18 @@ importers: '@ant-design/icons': specifier: 'catalog:' version: 6.1.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@apollo/client': + specifier: ^3.13.9 + version: 3.14.0(@types/react@19.2.7)(graphql-ws@6.0.6(graphql@16.12.0)(ws@8.20.1))(graphql@16.12.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@graphql-typed-document-node/core': specifier: ^3.2.0 version: 3.2.0(graphql@16.12.0) '@ocom/ui-staff-shared': specifier: workspace:* version: link:../ui-staff-shared + antd: + specifier: 'catalog:' + version: 6.3.5(luxon@3.7.2)(moment@2.30.1)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) react: specifier: 'catalog:' version: 19.2.0 @@ -2416,7 +2423,7 @@ importers: version: 19.2.0(react@19.2.0) react-router-dom: specifier: 'catalog:' - version: 7.12.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + version: 7.15.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0) devDependencies: '@cellix/config-typescript': specifier: workspace:* @@ -2424,6 +2431,24 @@ importers: '@cellix/config-vitest': specifier: workspace:* version: link:../../cellix/config-vitest + '@chromatic-com/storybook': + specifier: ^4.1.1 + version: 4.1.3(storybook@9.1.20(@testing-library/dom@10.4.1)(vite@8.0.5(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@24.10.1)(esbuild@0.27.4)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))) + '@storybook/addon-a11y': + specifier: ^9.1.3 + version: 9.1.16(storybook@9.1.20(@testing-library/dom@10.4.1)(vite@8.0.5(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@24.10.1)(esbuild@0.27.4)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))) + '@storybook/addon-docs': + specifier: ^9.1.3 + version: 9.1.16(@types/react@19.2.7)(storybook@9.1.20(@testing-library/dom@10.4.1)(vite@8.0.5(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@24.10.1)(esbuild@0.27.4)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))) + '@storybook/addon-vitest': + specifier: ^9.1.3 + version: 9.1.20(@vitest/browser-playwright@4.1.6)(@vitest/browser@4.1.6(vite@8.0.5(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@24.10.1)(esbuild@0.27.4)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.6))(@vitest/runner@4.1.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(storybook@9.1.20(@testing-library/dom@10.4.1)(vite@8.0.5(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@24.10.1)(esbuild@0.27.4)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)))(vitest@4.1.6) + '@storybook/react': + specifier: ^9.1.9 + version: 9.1.16(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(storybook@9.1.20(@testing-library/dom@10.4.1)(vite@8.0.5(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@24.10.1)(esbuild@0.27.4)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)))(typescript@6.0.3) + '@storybook/react-vite': + specifier: ^9.1.3 + version: 9.1.16(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(storybook@9.1.20(@testing-library/dom@10.4.1)(vite@8.0.5(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@24.10.1)(esbuild@0.27.4)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)))(typescript@6.0.3)(vite@8.0.5(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@24.10.1)(esbuild@0.27.4)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) '@types/react': specifier: ^19.1.11 version: 19.2.7 @@ -2433,6 +2458,9 @@ importers: jsdom: specifier: 'catalog:' version: 26.1.0 + storybook: + specifier: 'catalog:' + version: 9.1.20(@testing-library/dom@10.4.1)(vite@8.0.5(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@24.10.1)(esbuild@0.27.4)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) typescript: specifier: 'catalog:' version: 6.0.3 @@ -2471,7 +2499,7 @@ importers: version: 19.2.0(react@19.2.0) react-router-dom: specifier: 'catalog:' - version: 7.12.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + version: 7.15.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0) devDependencies: '@cellix/config-typescript': specifier: workspace:* @@ -2918,6 +2946,10 @@ packages: resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} engines: {node: '>=6.9.0'} + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + '@babel/compat-data@7.29.0': resolution: {integrity: sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==} engines: {node: '>=6.9.0'} @@ -3013,6 +3045,10 @@ packages: resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + '@babel/helper-validator-option@7.27.1': resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} engines: {node: '>=6.9.0'} @@ -3472,14 +3508,14 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 - '@babel/runtime-corejs3@7.28.4': - resolution: {integrity: sha512-h7iEYiW4HebClDEhtvFObtPmIvrd1SSfpI9EhOeKk4CtIK/ngBWFpuhCzhdmRKtg71ylcue+9I6dv54XYO1epQ==} - engines: {node: '>=6.9.0'} - '@babel/runtime@7.28.4': resolution: {integrity: sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==} engines: {node: '>=6.9.0'} + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + '@babel/template@7.27.2': resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==} engines: {node: '>=6.9.0'} @@ -3488,10 +3524,6 @@ packages: resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} engines: {node: '>=6.9.0'} - '@babel/traverse@7.28.5': - resolution: {integrity: sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==} - engines: {node: '>=6.9.0'} - '@babel/traverse@7.29.0': resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} engines: {node: '>=6.9.0'} @@ -4003,12 +4035,12 @@ packages: search-insights: optional: true - '@docusaurus/babel@3.9.2': - resolution: {integrity: sha512-GEANdi/SgER+L7Japs25YiGil/AUDnFFHaCGPBbundxoWtCkA2lmy7/tFmgED4y1htAy6Oi4wkJEQdGssnw9MA==} + '@docusaurus/babel@3.10.1': + resolution: {integrity: sha512-DZzFO1K3v/GoEt1fx1DiYHF4en+PuhtQf1AkQJa5zu3CoeKSpr5cpQRUlz3jr0m44wyzmSXu9bVpfir+N4+8bg==} engines: {node: '>=20.0'} - '@docusaurus/bundler@3.9.2': - resolution: {integrity: sha512-ZOVi6GYgTcsZcUzjblpzk3wH1Fya2VNpd5jtHoCCFcJlMQ1EYXZetfAnRHLcyiFeBABaI1ltTYbOBtH/gahGVA==} + '@docusaurus/bundler@3.10.1': + resolution: {integrity: sha512-HIqQPvbqnnQRe4NsBd1774KRarjXqS6wHsWELtyuSs1gCfvixJO2jUGH/OEBtr1Gvzpw+ze5CjGMvSJ8UE1KUw==} engines: {node: '>=20.0'} peerDependencies: '@docusaurus/faster': '*' @@ -4016,106 +4048,110 @@ packages: '@docusaurus/faster': optional: true - '@docusaurus/core@3.9.2': - resolution: {integrity: sha512-HbjwKeC+pHUFBfLMNzuSjqFE/58+rLVKmOU3lxQrpsxLBOGosYco/Q0GduBb0/jEMRiyEqjNT/01rRdOMWq5pw==} + '@docusaurus/core@3.10.1': + resolution: {integrity: sha512-3pf2fXXw0eVk8WnC3T4LIigRDupcpvngpKo9Vy7mYyBhuddc0klDUuZAIfzMoK6z05pdlk6EFC/vBSX43+1O5w==} engines: {node: '>=20.0'} hasBin: true peerDependencies: + '@docusaurus/faster': '*' '@mdx-js/react': ^3.0.0 react: ^18.0.0 || ^19.0.0 react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@docusaurus/faster': + optional: true - '@docusaurus/cssnano-preset@3.9.2': - resolution: {integrity: sha512-8gBKup94aGttRduABsj7bpPFTX7kbwu+xh3K9NMCF5K4bWBqTFYW+REKHF6iBVDHRJ4grZdIPbvkiHd/XNKRMQ==} + '@docusaurus/cssnano-preset@3.10.1': + resolution: {integrity: sha512-eNfHGcTKCSq6xmcavAkX3RRclHaE2xRCMParlDXLdXVP01/a2e/jKXMj/0ULnLFQSNwwuI62L0Ge8J+nZsR7UQ==} engines: {node: '>=20.0'} - '@docusaurus/logger@3.9.2': - resolution: {integrity: sha512-/SVCc57ByARzGSU60c50rMyQlBuMIJCjcsJlkphxY6B0GV4UH3tcA1994N8fFfbJ9kX3jIBe/xg3XP5qBtGDbA==} + '@docusaurus/logger@3.10.1': + resolution: {integrity: sha512-oPjNFnfJsRCkePVjkGrxWGq4MvJKRQT0r9jOP0eRBTZ7Wr9FAbzdP/Gjs0I2Ss6YRkPoEgygKG112OkE6skvJw==} engines: {node: '>=20.0'} - '@docusaurus/mdx-loader@3.9.2': - resolution: {integrity: sha512-wiYoGwF9gdd6rev62xDU8AAM8JuLI/hlwOtCzMmYcspEkzecKrP8J8X+KpYnTlACBUUtXNJpSoCwFWJhLRevzQ==} + '@docusaurus/mdx-loader@3.10.1': + resolution: {integrity: sha512-GRmeb/wQ+iXRrFwcHBfgQhrJxGElgCsoTWZYDhccjsZVne1p8MK/EpQVIloXttz76TCe78kKD5AEG9n1xc1oxQ==} engines: {node: '>=20.0'} peerDependencies: react: ^18.0.0 || ^19.0.0 react-dom: ^18.0.0 || ^19.0.0 - '@docusaurus/module-type-aliases@3.9.2': - resolution: {integrity: sha512-8qVe2QA9hVLzvnxP46ysuofJUIc/yYQ82tvA/rBTrnpXtCjNSFLxEZfd5U8cYZuJIVlkPxamsIgwd5tGZXfvew==} + '@docusaurus/module-type-aliases@3.10.1': + resolution: {integrity: sha512-YoOZKUdGlp8xSYhuAkGdSo5Ydkbq4V4eK3sD8v0a2hloxCWdQbNBhkc+Ko9QyjpESc0BYcIGM5iHVAy5hdFV6w==} peerDependencies: react: '*' react-dom: '*' - '@docusaurus/plugin-content-blog@3.9.2': - resolution: {integrity: sha512-3I2HXy3L1QcjLJLGAoTvoBnpOwa6DPUa3Q0dMK19UTY9mhPkKQg/DYhAGTiBUKcTR0f08iw7kLPqOhIgdV3eVQ==} + '@docusaurus/plugin-content-blog@3.10.1': + resolution: {integrity: sha512-mmkgE6Q2+K74tnkou7tXlpDLvoCU/qkSa2GSQ3XUiHWvcebCoDQzS670RR3tO8PmaWlIyWWISYWzZLuMfxunRA==} engines: {node: '>=20.0'} peerDependencies: '@docusaurus/plugin-content-docs': '*' react: ^18.0.0 || ^19.0.0 react-dom: ^18.0.0 || ^19.0.0 - '@docusaurus/plugin-content-docs@3.9.2': - resolution: {integrity: sha512-C5wZsGuKTY8jEYsqdxhhFOe1ZDjH0uIYJ9T/jebHwkyxqnr4wW0jTkB72OMqNjsoQRcb0JN3PcSeTwFlVgzCZg==} + '@docusaurus/plugin-content-docs@3.10.1': + resolution: {integrity: sha512-2jRVrtzjf8LClGTHQlwlwuD3wQXRx3WEoF7XUarJ8Ou+0onV+SLtejsyfY9JLpfUh9hPhXM4pbBGkyAY4Bi3HQ==} engines: {node: '>=20.0'} peerDependencies: react: ^18.0.0 || ^19.0.0 react-dom: ^18.0.0 || ^19.0.0 - '@docusaurus/plugin-content-pages@3.9.2': - resolution: {integrity: sha512-s4849w/p4noXUrGpPUF0BPqIAfdAe76BLaRGAGKZ1gTDNiGxGcpsLcwJ9OTi1/V8A+AzvsmI9pkjie2zjIQZKA==} + '@docusaurus/plugin-content-pages@3.10.1': + resolution: {integrity: sha512-huJpaRPMl42nsFwuCXvV8bVDj2MazuwRJIUylI/RSlmZeJssVoZXeCjVf1y+1Drtpa9SKcdGn8yoJ76IRJijtw==} engines: {node: '>=20.0'} peerDependencies: react: ^18.0.0 || ^19.0.0 react-dom: ^18.0.0 || ^19.0.0 - '@docusaurus/plugin-css-cascade-layers@3.9.2': - resolution: {integrity: sha512-w1s3+Ss+eOQbscGM4cfIFBlVg/QKxyYgj26k5AnakuHkKxH6004ZtuLe5awMBotIYF2bbGDoDhpgQ4r/kcj4rQ==} + '@docusaurus/plugin-css-cascade-layers@3.10.1': + resolution: {integrity: sha512-r//fn+MNHkE1wCof8T29VAQezt1enGCpsFxoziBbvLgBM4JfXN2P3rxrBaavHmvLvm7lYkpJeitcDthwnmWCTw==} engines: {node: '>=20.0'} - '@docusaurus/plugin-debug@3.9.2': - resolution: {integrity: sha512-j7a5hWuAFxyQAkilZwhsQ/b3T7FfHZ+0dub6j/GxKNFJp2h9qk/P1Bp7vrGASnvA9KNQBBL1ZXTe7jlh4VdPdA==} + '@docusaurus/plugin-debug@3.10.1': + resolution: {integrity: sha512-9KqOpKNfAyqGZykRb9LhIT/vyRF6sm/ykhjj/39JvaJahDS+jZJE0Z1Wfz9q3DUNDTMNN0Q7u/kk4rKKU+IJuA==} engines: {node: '>=20.0'} peerDependencies: react: ^18.0.0 || ^19.0.0 react-dom: ^18.0.0 || ^19.0.0 - '@docusaurus/plugin-google-analytics@3.9.2': - resolution: {integrity: sha512-mAwwQJ1Us9jL/lVjXtErXto4p4/iaLlweC54yDUK1a97WfkC6Z2k5/769JsFgwOwOP+n5mUQGACXOEQ0XDuVUw==} + '@docusaurus/plugin-google-analytics@3.10.1': + resolution: {integrity: sha512-8o0P1KtmgdYQHH+oInitPpRWI0Of5XednAX4+DMhQNSmGSRNrsEEHg1ebv35m9AgRClfAytCJ5jA9KvcASTyuA==} engines: {node: '>=20.0'} peerDependencies: react: ^18.0.0 || ^19.0.0 react-dom: ^18.0.0 || ^19.0.0 - '@docusaurus/plugin-google-gtag@3.9.2': - resolution: {integrity: sha512-YJ4lDCphabBtw19ooSlc1MnxtYGpjFV9rEdzjLsUnBCeis2djUyCozZaFhCg6NGEwOn7HDDyMh0yzcdRpnuIvA==} + '@docusaurus/plugin-google-gtag@3.10.1': + resolution: {integrity: sha512-pu3xIUo5o/zCMLfUY9BO5KOwSH0zIsAGyFRPvXHayFSA5XIhCU/SFuB0g0ZNjFn9niZLCaNvoeAuOGFJZq0fdw==} engines: {node: '>=20.0'} peerDependencies: react: ^18.0.0 || ^19.0.0 react-dom: ^18.0.0 || ^19.0.0 - '@docusaurus/plugin-google-tag-manager@3.9.2': - resolution: {integrity: sha512-LJtIrkZN/tuHD8NqDAW1Tnw0ekOwRTfobWPsdO15YxcicBo2ykKF0/D6n0vVBfd3srwr9Z6rzrIWYrMzBGrvNw==} + '@docusaurus/plugin-google-tag-manager@3.10.1': + resolution: {integrity: sha512-f6fyGHiCm7kJHBtAisGQS5oNBnpnMTYQZxDXeVrnw/3zWU+LMA22pr6UHGYkBKDbN+qPC5QHG3NuOfzQLq3+Lw==} engines: {node: '>=20.0'} peerDependencies: react: ^18.0.0 || ^19.0.0 react-dom: ^18.0.0 || ^19.0.0 - '@docusaurus/plugin-sitemap@3.9.2': - resolution: {integrity: sha512-WLh7ymgDXjG8oPoM/T4/zUP7KcSuFYRZAUTl8vR6VzYkfc18GBM4xLhcT+AKOwun6kBivYKUJf+vlqYJkm+RHw==} + '@docusaurus/plugin-sitemap@3.10.1': + resolution: {integrity: sha512-C26MbmmqgdjkDq1htaZ3aD7LzEDKFWXfpyQpt0EOUThuq5nV77zDaedV20yHcVo9p+3ey9aZ4pbHA0D3QcZTzg==} engines: {node: '>=20.0'} peerDependencies: react: ^18.0.0 || ^19.0.0 react-dom: ^18.0.0 || ^19.0.0 - '@docusaurus/plugin-svgr@3.9.2': - resolution: {integrity: sha512-n+1DE+5b3Lnf27TgVU5jM1d4x5tUh2oW5LTsBxJX4PsAPV0JGcmI6p3yLYtEY0LRVEIJh+8RsdQmRE66wSV8mw==} + '@docusaurus/plugin-svgr@3.10.1': + resolution: {integrity: sha512-6SFxsmjWFkVLDmBUvFK6i72QjUwqyQFe4Ovz+SUJophJjOyVG3ZZG5IQpBC/kX/Gfv1yWeU9nWauH6F6Q7QX/Q==} engines: {node: '>=20.0'} peerDependencies: react: ^18.0.0 || ^19.0.0 react-dom: ^18.0.0 || ^19.0.0 - '@docusaurus/preset-classic@3.9.2': - resolution: {integrity: sha512-IgyYO2Gvaigi21LuDIe+nvmN/dfGXAiMcV/murFqcpjnZc7jxFAxW+9LEjdPt61uZLxG4ByW/oUmX/DDK9t/8w==} + '@docusaurus/preset-classic@3.10.1': + resolution: {integrity: sha512-YO/FL8v1zmbxoTso6mjMz/RDjhaTJxb1UpFFTDdY5847LLDCeyYiYlrhyTbgN1RIN3xnkLKZ9Lj1x8hUzI4JOg==} engines: {node: '>=20.0'} peerDependencies: react: ^18.0.0 || ^19.0.0 @@ -4126,51 +4162,51 @@ packages: peerDependencies: react: '*' - '@docusaurus/theme-classic@3.9.2': - resolution: {integrity: sha512-IGUsArG5hhekXd7RDb11v94ycpJpFdJPkLnt10fFQWOVxAtq5/D7hT6lzc2fhyQKaaCE62qVajOMKL7OiAFAIA==} + '@docusaurus/theme-classic@3.10.1': + resolution: {integrity: sha512-VU1RK0qb2pab0si4r7HFK37cYco8VzqLj3u1PspVipSr/z/GPVKHO4/HXbnePqHoWDk8urjyGSeatH0NIMBM1A==} engines: {node: '>=20.0'} peerDependencies: react: ^18.0.0 || ^19.0.0 react-dom: ^18.0.0 || ^19.0.0 - '@docusaurus/theme-common@3.9.2': - resolution: {integrity: sha512-6c4DAbR6n6nPbnZhY2V3tzpnKnGL+6aOsLvFL26VRqhlczli9eWG0VDUNoCQEPnGwDMhPS42UhSAnz5pThm5Ag==} + '@docusaurus/theme-common@3.10.1': + resolution: {integrity: sha512-0YtmIeoNo1fIw65LO8+/1dPgmDV86UmhMkow37gzjytuiCSQm9xob6PJy0L4kuQEMTLfUOGvkXvZr7GPrHquMA==} engines: {node: '>=20.0'} peerDependencies: '@docusaurus/plugin-content-docs': '*' react: ^18.0.0 || ^19.0.0 react-dom: ^18.0.0 || ^19.0.0 - '@docusaurus/theme-search-algolia@3.9.2': - resolution: {integrity: sha512-GBDSFNwjnh5/LdkxCKQHkgO2pIMX1447BxYUBG2wBiajS21uj64a+gH/qlbQjDLxmGrbrllBrtJkUHxIsiwRnw==} + '@docusaurus/theme-search-algolia@3.10.1': + resolution: {integrity: sha512-OTaARARVZj2GvkJQjB+1jOIxntRaXea+G+fMsNqrZBAU1O1vJKDW22R7kECOHW27oJCLFN9HKaZeRrfAUyviug==} engines: {node: '>=20.0'} peerDependencies: react: ^18.0.0 || ^19.0.0 react-dom: ^18.0.0 || ^19.0.0 - '@docusaurus/theme-translations@3.9.2': - resolution: {integrity: sha512-vIryvpP18ON9T9rjgMRFLr2xJVDpw1rtagEGf8Ccce4CkTrvM/fRB8N2nyWYOW5u3DdjkwKw5fBa+3tbn9P4PA==} + '@docusaurus/theme-translations@3.10.1': + resolution: {integrity: sha512-cLMyaKivjBVWKMJuWqyFVVgtqe8DPJNPkog0bn8W1MDVAKcPdxRFycBfC1We1RaNp7Rdk513bmtW78RR6OBxBw==} engines: {node: '>=20.0'} - '@docusaurus/tsconfig@3.9.2': - resolution: {integrity: sha512-j6/Fp4Rlpxsc632cnRnl5HpOWeb6ZKssDj6/XzzAzVGXXfm9Eptx3rxCC+fDzySn9fHTS+CWJjPineCR1bB5WQ==} + '@docusaurus/tsconfig@3.10.1': + resolution: {integrity: sha512-rYvB7yqkdqWIpAbDzQljGfM4cDBkLTbhmagZBEcsyj6oPUsz47lmW2pYdN1j+7sGFgltbAmQH62xfbrij4Eh6Q==} - '@docusaurus/types@3.9.2': - resolution: {integrity: sha512-Ux1JUNswg+EfUEmajJjyhIohKceitY/yzjRUpu04WXgvVz+fbhVC0p+R0JhvEu4ytw8zIAys2hrdpQPBHRIa8Q==} + '@docusaurus/types@3.10.1': + resolution: {integrity: sha512-XYMK8k1szDCFMw2V+Xyen0g7Kee1sP3dtFnl7vkGkZOkeAJ/oPDQPL8iz4HBKOo/cwU8QeV6onVjMqtP+tFzsw==} peerDependencies: react: ^18.0.0 || ^19.0.0 react-dom: ^18.0.0 || ^19.0.0 - '@docusaurus/utils-common@3.9.2': - resolution: {integrity: sha512-I53UC1QctruA6SWLvbjbhCpAw7+X7PePoe5pYcwTOEXD/PxeP8LnECAhTHHwWCblyUX5bMi4QLRkxvyZ+IT8Aw==} + '@docusaurus/utils-common@3.10.1': + resolution: {integrity: sha512-5mFSgEADtnFxFH7RLw02QA5MpU5JVUCj0MPeIvi/aF4Fi45tQRIuTwXoXDqJ+1VfQJuYJGz3SI63wmGz4HvXzA==} engines: {node: '>=20.0'} - '@docusaurus/utils-validation@3.9.2': - resolution: {integrity: sha512-l7yk3X5VnNmATbwijJkexdhulNsQaNDwoagiwujXoxFbWLcxHQqNQ+c/IAlzrfMMOfa/8xSBZ7KEKDesE/2J7A==} + '@docusaurus/utils-validation@3.10.1': + resolution: {integrity: sha512-cRv1X69jwaWv47waglllgZVWzeBFLhl53XT/XED/83BerVBTC5FTP8WTcVl8Z6sZOegDSwitu/wpCSPCDOT6lg==} engines: {node: '>=20.0'} - '@docusaurus/utils@3.9.2': - resolution: {integrity: sha512-lBSBiRruFurFKXr5Hbsl2thmGweAPmddhF3jb99U4EMDA5L+e5Y1rAkOS07Nvrup7HUMBDrCV45meaxZnt28nQ==} + '@docusaurus/utils@3.10.1': + resolution: {integrity: sha512-3ojeJry9xBYdJO6qoyyzqeJFSJBVx2mXhyDzSdjwL2+URFQMf+h25gG38iswGImicK0ELjTd1EL2xzk8hf3QPw==} engines: {node: '>=20.0'} '@dr.pogodin/react-helmet@3.0.4': @@ -6546,8 +6582,8 @@ packages: '@types/graphql-depth-limit@1.1.6': resolution: {integrity: sha512-WU4bjoKOzJ8CQE32Pbyq+YshTMcLJf2aJuvVtSLv1BQPwDUGa38m2Vr8GGxf0GZ0luCQcfxlhZeHKu6nmTBvrw==} - '@types/gtag.js@0.0.12': - resolution: {integrity: sha512-YQV9bUsemkzG81Ea295/nF/5GijnD2Af7QhEofh7xu+kvCN6RdodgNwwGWXB5GMI3NoyvQo0odNctoH/qLMIpg==} + '@types/gtag.js@0.0.20': + resolution: {integrity: sha512-wwAbk3SA2QeU67unN7zPxjEHmPmlXwZXZvQEpbEUQuMCRGgKyE1m6XDuTUA9b6pCGb/GqJmdfMOY5LuDjJSbbg==} '@types/hast@3.0.4': resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} @@ -6962,11 +6998,6 @@ packages: resolution: {integrity: sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==} engines: {node: '>=0.4.0'} - acorn@8.15.0: - resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} - engines: {node: '>=0.4.0'} - hasBin: true - acorn@8.16.0: resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} engines: {node: '>=0.4.0'} @@ -7067,6 +7098,10 @@ packages: resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} engines: {node: '>=12'} + ansis@3.17.0: + resolution: {integrity: sha512-0qWUglt9JEqLFr3w1I1pbrChn1grhaiAR2ocX1PP/flRmxgtwTzPFFFnfIlD6aMOLQZgSuCRlidD70lvx8yhzg==} + engines: {node: '>=14'} + antd@6.3.5: resolution: {integrity: sha512-8BPz9lpZWQm42PTx7yL4KxWAotVuqINiKcoYRcLtdd5BFmAcAZicVyFTnBJyRDlzGZFZeRW3foGu6jXYFnej6Q==} peerDependencies: @@ -7838,6 +7873,10 @@ packages: copy-anything@2.0.6: resolution: {integrity: sha512-1j20GZTsvKNkc4BY3NpMOM8tt///wY3FpIzozTOFO2ffuZcV61nojHXVKIy3WM+7ADCy5FVhdZYHYDdgTU0yJw==} + copy-text-to-clipboard@3.2.2: + resolution: {integrity: sha512-T6SqyLd1iLuqPA90J5N4cTalrtovCySh58iiZDGJ6FGznbclKh4UI+FGacQSgFzwKG77W7XT5gwbVEbd9cIH1A==} + engines: {node: '>=12'} + copy-webpack-plugin@11.0.0: resolution: {integrity: sha512-fX2MWpamkW0hZxMEg0+mYnA40LTosOSa5TqZ9GYIBzyJa9C3QUaMPSE2xAi/buNr8u89SfD9wHSQVBzrRa/SOQ==} engines: {node: '>= 14.15.0'} @@ -7847,9 +7886,6 @@ packages: core-js-compat@3.47.0: resolution: {integrity: sha512-IGfuznZ/n7Kp9+nypamBhvwdwLsW6KC8IOaURw2doAK5e98AG3acVLdh0woOnEqCfUtS+Vu882JE4k/DAm3ItQ==} - core-js-pure@3.47.0: - resolution: {integrity: sha512-BcxeDbzUrRnXGYIVAGFtcGQVNpFcUhVjr6W7F8XktvQW2iJP9e66GP6xdKotCRFlrxBvNIBrhwKteRXqMV86Nw==} - core-js@3.47.0: resolution: {integrity: sha512-c3Q2VVkGAUyupsjRnaNX6u8Dq2vAdzm9iuPj5FW0fRxzlxgq9Q39MDq10IvmQSpLgHQNyQzQmOo6bgGHmH3NNg==} @@ -9539,9 +9575,6 @@ packages: resolution: {integrity: sha512-/kppl+R+LO5VmhYSEWARUFjodS25D68gvj8W7z0I7OWhUla5xWu8KL6CtB2V0R6yqhnRgbcaREMr4EEM6htLPQ==} engines: {node: '>=12'} - isarray@0.0.1: - resolution: {integrity: sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==} - isarray@1.0.0: resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} @@ -9964,10 +9997,6 @@ packages: lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} - lru-cache@11.3.3: - resolution: {integrity: sha512-JvNw9Y81y33E+BEYPr0U7omo+U9AySnsMsEiXgwT6yqd31VQWTLNQqmT4ou5eqPFUrTfIDFta2wKhB1hyohtAQ==} - engines: {node: 20 || >=22} - lru-cache@11.3.5: resolution: {integrity: sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==} engines: {node: 20 || >=22} @@ -10017,9 +10046,6 @@ packages: resolution: {integrity: sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q==} engines: {node: '>=16'} - markdown-table@2.0.0: - resolution: {integrity: sha512-Ezda85ToJUBhM6WGaG6veasyym+Tbs3cMAw/ZhOPqXiYsr0jgocBV3j3nx+4lk47plLlIqjwuTm/ywVI+zjJ/A==} - markdown-table@3.0.4: resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} @@ -10851,9 +10877,6 @@ packages: path-to-regexp@0.1.13: resolution: {integrity: sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==} - path-to-regexp@1.9.0: - resolution: {integrity: sha512-xIp7/apCFJuUHdDLWe8O1HIkb0kQrOMb/0u6FXQjemHn/ii5LrIzU6bdECnsiTF/GjZkMEKg1xdiZwNqDYlZ6g==} - path-to-regexp@3.3.0: resolution: {integrity: sha512-qyCH421YQPS2WFDxDjftfc1ZR5WKQzVzqsp4n9M2kQhVOo/ByahFoUNJfl58kOcEGfQ//7weFTDhm+ss8Ecxgw==} @@ -11561,8 +11584,8 @@ packages: peerDependencies: react: ^18.0.0 || ^19.0.0 - react-loadable-ssr-addon-v5-slorber@1.0.1: - resolution: {integrity: sha512-lq3Lyw1lGku8zUEJPDxsNm1AfYHBrO9Y1+olAYwpUJ2IGFBskM0DMKok97A6LWUpHm+o7IvQBOWu9MLenp9Z+A==} + react-loadable-ssr-addon-v5-slorber@1.0.3: + resolution: {integrity: sha512-GXfh9VLwB5ERaCsU6RULh7tkemeX15aNh6wuMEBtfdyMa7fFG8TXrhXlx1SoEK2Ty/l6XIkzzYIQmyaWW3JgdQ==} engines: {node: '>=10.13.0'} peerDependencies: react-loadable: '*' @@ -11579,27 +11602,22 @@ packages: resolution: {integrity: sha512-DuanZjaD8mQp1ppHjgnnUnyOlqYXZVjnov/JzFhjLEwd3Z4dYjMSnqrEzzGThH47vpCOqPPwJM2FtthLeJ8Pbg==} peerDependencies: react: '>=15' - react-router: '>=5' + react-router: 7.15.0 react-router-dom@5.3.4: resolution: {integrity: sha512-m4EqFMHv/Ih4kpcBCONHbkT68KoAeHN4p3lAGoNryfHi0dMy0kCzEZakiKRsvg5wHZ/JLrLW8o8KomWiz/qbYQ==} peerDependencies: react: '>=15' - react-router-dom@7.12.0: - resolution: {integrity: sha512-pfO9fiBcpEfX4Tx+iTYKDtPbrSLLCbwJ5EqP+SPYQu1VYCXdy79GSj0wttR0U4cikVdlImZuEZ/9ZNCgoaxwBA==} + react-router-dom@7.15.0: + resolution: {integrity: sha512-VcrVg64Fo8nwBvDscajG8gRTLIuTC6N50nb22l2HOOV4PTOHgoGp8mUjy9wLiHYoYTSYI36tUnXZgasSRFZorQ==} engines: {node: '>=20.0.0'} peerDependencies: react: '>=18' react-dom: '>=18' - react-router@5.3.4: - resolution: {integrity: sha512-Ys9K+ppnJah3QuaRiLxk+jDWOR1MekYQrlytiXxC1RyfbdsZkS5pvKAzCCr031xHixZwpnsYNT5xysdFHQaYsA==} - peerDependencies: - react: '>=15' - - react-router@7.12.0: - resolution: {integrity: sha512-kTPDYPFzDVGIIGNLS5VJykK0HfHLY5MF3b+xj0/tTyNYL1gF1qs7u67Z9jEhQk2sQ98SUaHxlG31g1JtF7IfVw==} + react-router@7.15.0: + resolution: {integrity: sha512-HW9vYwuM8f4yx66Izy8xfrzCM+SBJluoZcCbww9A1TySax11S5Vgw6fi3ZjMONw9J4gQwngL7PzkyIpJJpJ7RQ==} engines: {node: '>=20.0.0'} peerDependencies: react: '>=18' @@ -12066,8 +12084,8 @@ packages: resolution: {integrity: sha512-F4LcB0UqUl1zErq+1nYEEzSHJnIwb3AF2XWB94b+afhrekOUijwooAYqFyRbjYkm2PAKBabx6oYv/xDxNi8IBw==} engines: {node: '>=20.0.0'} - serve-handler@6.1.6: - resolution: {integrity: sha512-x5RL9Y2p5+Sh3D38Fh9i/iQ5ZK+e4xuXRd/pGbM4D13tgo/MGwbttUk8emytcr1YYzBYs+apnUngBDFYfpjPuQ==} + serve-handler@6.1.7: + resolution: {integrity: sha512-CinAq1xWb0vR3twAv9evEU8cNWkXCb9kd5ePAHUKJBkOsUpR1wt/CvGdeca7vqumL1U5cSaeVQ6zZMxiJ3yWsg==} serve-index@1.9.1: resolution: {integrity: sha512-pXHfKNP4qujrtteMrSBb0rc8HJ9Ms/GrXwcUtUtD5s4ewDJI8bT3Cz2zTVRMKtri49pLx2e0Ya8ziP5Ya2pZZw==} @@ -12523,22 +12541,6 @@ packages: resolution: {integrity: sha512-NmedZS0NJiTv3CoYnf1FtjxIDUgVYzEmavrc8q2WHRb+lP4deI9BpQfmNnBZZaWusDbP5FVFZCcvzb3xOlNVlQ==} engines: {node: '>=16'} - terser-webpack-plugin@5.3.14: - resolution: {integrity: sha512-vkZjpUjb6OMS7dhV+tILUW6BhpDR7P2L/aQSAv+Uwk+m8KATX9EccViHTJR2qDtACKPIYndLGCyl3FMo+r2LMw==} - engines: {node: '>= 10.13.0'} - peerDependencies: - '@swc/core': '*' - esbuild: '*' - uglify-js: '*' - webpack: ^5.105.4 - peerDependenciesMeta: - '@swc/core': - optional: true - esbuild: - optional: true - uglify-js: - optional: true - terser-webpack-plugin@5.4.0: resolution: {integrity: sha512-Bn5vxm48flOIfkdl5CaD2+1CiUVbonWQ3KQPyP7/EuIl9Gbzq/gQFOzaMFUEgVjB1396tcK0SG8XcNJ/2kDH8g==} engines: {node: '>= 10.13.0'} @@ -13242,11 +13244,17 @@ packages: webpack-cli: optional: true - webpackbar@6.0.1: - resolution: {integrity: sha512-TnErZpmuKdwWBdMoexjio3KKX6ZtoKHRVvLIU0A47R0VVBDtx3ZyOJDktgYixhoJokZTYTt1Z37OkO9pnGJa9Q==} + webpackbar@7.0.0: + resolution: {integrity: sha512-aS9soqSO2iCHgqHoCrj4LbfGQUboDCYJPSFOAchEK+9psIjNrfSWW4Y0YEz67MKURNvMmfo0ycOg9d/+OOf9/Q==} engines: {node: '>=14.21.3'} peerDependencies: + '@rspack/core': '*' webpack: ^5.105.4 + peerDependenciesMeta: + '@rspack/core': + optional: true + webpack: + optional: true websocket-driver@0.7.4: resolution: {integrity: sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==} @@ -13654,7 +13662,7 @@ snapshots: '@ant-design/fast-color@2.0.6': dependencies: - '@babel/runtime': 7.28.4 + '@babel/runtime': 7.29.7 '@ant-design/fast-color@3.0.1': {} @@ -13807,7 +13815,7 @@ snapshots: finalhandler: 2.1.1 graphql: 16.12.0 loglevel: 1.9.2 - lru-cache: 11.3.3 + lru-cache: 11.3.5 negotiator: 1.0.0 uuid: 11.1.1 whatwg-mimetype: 4.0.0 @@ -13871,7 +13879,7 @@ snapshots: dependencies: '@babel/generator': 7.29.1 '@babel/parser': 7.29.2 - '@babel/runtime': 7.28.4 + '@babel/runtime': 7.29.7 chalk: 4.1.2 fb-watchman: 2.0.2 graphql: 16.12.0 @@ -14129,6 +14137,12 @@ snapshots: js-tokens: 4.0.0 picocolors: 1.1.1 + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + '@babel/compat-data@7.29.0': {} '@babel/core@7.29.0': @@ -14203,7 +14217,7 @@ snapshots: dependencies: '@babel/core': 7.29.0 '@babel/helper-compilation-targets': 7.28.6 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 debug: 4.4.3(supports-color@8.1.1) lodash.debounce: 4.0.8 resolve: 1.22.11 @@ -14272,6 +14286,8 @@ snapshots: '@babel/helper-validator-identifier@7.28.5': {} + '@babel/helper-validator-identifier@7.29.7': {} + '@babel/helper-validator-option@7.27.1': {} '@babel/helper-wrap-function@7.28.3': @@ -14294,7 +14310,7 @@ snapshots: '@babel/plugin-bugfix-firefox-class-in-computed-class-key@7.28.5(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 '@babel/traverse': 7.29.0 transitivePeerDependencies: - supports-color @@ -14302,17 +14318,17 @@ snapshots: '@babel/plugin-bugfix-safari-class-field-initializer-scope@7.27.1(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@7.27.1(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@7.27.1(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 '@babel/plugin-transform-optional-chaining': 7.28.5(@babel/core@7.29.0) transitivePeerDependencies: @@ -14321,7 +14337,7 @@ snapshots: '@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly@7.28.3(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 '@babel/traverse': 7.29.0 transitivePeerDependencies: - supports-color @@ -14333,7 +14349,7 @@ snapshots: '@babel/plugin-syntax-dynamic-import@7.8.3(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-syntax-import-assertions@7.27.1(@babel/core@7.29.0)': dependencies: @@ -14343,33 +14359,33 @@ snapshots: '@babel/plugin-syntax-import-attributes@7.27.1(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-syntax-jsx@7.27.1(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-syntax-typescript@7.27.1(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-syntax-unicode-sets-regex@7.18.6(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.0) - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-transform-arrow-functions@7.27.1(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-transform-async-generator-functions@7.28.0(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 '@babel/helper-remap-async-to-generator': 7.27.1(@babel/core@7.29.0) '@babel/traverse': 7.29.0 transitivePeerDependencies: @@ -14379,7 +14395,7 @@ snapshots: dependencies: '@babel/core': 7.29.0 '@babel/helper-module-imports': 7.28.6 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 '@babel/helper-remap-async-to-generator': 7.27.1(@babel/core@7.29.0) transitivePeerDependencies: - supports-color @@ -14387,18 +14403,18 @@ snapshots: '@babel/plugin-transform-block-scoped-functions@7.27.1(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-transform-block-scoping@7.28.5(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-transform-class-properties@7.27.1(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 '@babel/helper-create-class-features-plugin': 7.28.5(@babel/core@7.29.0) - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 transitivePeerDependencies: - supports-color @@ -14406,7 +14422,7 @@ snapshots: dependencies: '@babel/core': 7.29.0 '@babel/helper-create-class-features-plugin': 7.28.5(@babel/core@7.29.0) - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 transitivePeerDependencies: - supports-color @@ -14416,7 +14432,7 @@ snapshots: '@babel/helper-annotate-as-pure': 7.27.3 '@babel/helper-compilation-targets': 7.28.6 '@babel/helper-globals': 7.28.0 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 '@babel/helper-replace-supers': 7.27.1(@babel/core@7.29.0) '@babel/traverse': 7.29.0 transitivePeerDependencies: @@ -14425,13 +14441,13 @@ snapshots: '@babel/plugin-transform-computed-properties@7.27.1(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 '@babel/template': 7.28.6 '@babel/plugin-transform-destructuring@7.28.5(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 '@babel/traverse': 7.29.0 transitivePeerDependencies: - supports-color @@ -14440,28 +14456,28 @@ snapshots: dependencies: '@babel/core': 7.29.0 '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.0) - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-transform-duplicate-keys@7.27.1(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-transform-duplicate-named-capturing-groups-regex@7.27.1(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.0) - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-transform-dynamic-import@7.27.1(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-transform-explicit-resource-management@7.28.0(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-transform-destructuring': 7.28.5(@babel/core@7.29.0) transitivePeerDependencies: - supports-color @@ -14469,17 +14485,17 @@ snapshots: '@babel/plugin-transform-exponentiation-operator@7.28.5(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-transform-export-namespace-from@7.27.1(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-transform-for-of@7.27.1(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 transitivePeerDependencies: - supports-color @@ -14488,7 +14504,7 @@ snapshots: dependencies: '@babel/core': 7.29.0 '@babel/helper-compilation-targets': 7.28.6 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 '@babel/traverse': 7.29.0 transitivePeerDependencies: - supports-color @@ -14496,28 +14512,28 @@ snapshots: '@babel/plugin-transform-json-strings@7.27.1(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-transform-literals@7.27.1(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-transform-logical-assignment-operators@7.28.5(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-transform-member-expression-literals@7.27.1(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-transform-modules-amd@7.27.1(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 transitivePeerDependencies: - supports-color @@ -14525,7 +14541,7 @@ snapshots: dependencies: '@babel/core': 7.29.0 '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 transitivePeerDependencies: - supports-color @@ -14534,7 +14550,7 @@ snapshots: '@babel/core': 7.29.0 '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) '@babel/helper-plugin-utils': 7.28.6 - '@babel/helper-validator-identifier': 7.28.5 + '@babel/helper-validator-identifier': 7.29.7 '@babel/traverse': 7.29.0 transitivePeerDependencies: - supports-color @@ -14543,7 +14559,7 @@ snapshots: dependencies: '@babel/core': 7.29.0 '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 transitivePeerDependencies: - supports-color @@ -14551,28 +14567,28 @@ snapshots: dependencies: '@babel/core': 7.29.0 '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.0) - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-transform-new-target@7.27.1(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-transform-nullish-coalescing-operator@7.27.1(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-transform-numeric-separator@7.27.1(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-transform-object-rest-spread@7.28.4(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 '@babel/helper-compilation-targets': 7.28.6 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-transform-destructuring': 7.28.5(@babel/core@7.29.0) '@babel/plugin-transform-parameters': 7.27.7(@babel/core@7.29.0) '@babel/traverse': 7.29.0 @@ -14582,7 +14598,7 @@ snapshots: '@babel/plugin-transform-object-super@7.27.1(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 '@babel/helper-replace-supers': 7.27.1(@babel/core@7.29.0) transitivePeerDependencies: - supports-color @@ -14590,12 +14606,12 @@ snapshots: '@babel/plugin-transform-optional-catch-binding@7.27.1(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-transform-optional-chaining@7.28.5(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 transitivePeerDependencies: - supports-color @@ -14603,13 +14619,13 @@ snapshots: '@babel/plugin-transform-parameters@7.27.7(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-transform-private-methods@7.27.1(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 '@babel/helper-create-class-features-plugin': 7.28.5(@babel/core@7.29.0) - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 transitivePeerDependencies: - supports-color @@ -14618,24 +14634,24 @@ snapshots: '@babel/core': 7.29.0 '@babel/helper-annotate-as-pure': 7.27.3 '@babel/helper-create-class-features-plugin': 7.28.5(@babel/core@7.29.0) - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 transitivePeerDependencies: - supports-color '@babel/plugin-transform-property-literals@7.27.1(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-transform-react-constant-elements@7.27.1(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-transform-react-display-name@7.28.0(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-transform-react-jsx-development@7.27.1(@babel/core@7.29.0)': dependencies: @@ -14649,7 +14665,7 @@ snapshots: '@babel/core': 7.29.0 '@babel/helper-annotate-as-pure': 7.27.3 '@babel/helper-module-imports': 7.28.6 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.29.0) '@babel/types': 7.29.0 transitivePeerDependencies: @@ -14659,29 +14675,29 @@ snapshots: dependencies: '@babel/core': 7.29.0 '@babel/helper-annotate-as-pure': 7.27.3 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-transform-regenerator@7.28.4(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-transform-regexp-modifiers@7.27.1(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.0) - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-transform-reserved-words@7.27.1(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-transform-runtime@7.28.5(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 '@babel/helper-module-imports': 7.28.6 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 babel-plugin-polyfill-corejs2: 0.4.14(@babel/core@7.29.0) babel-plugin-polyfill-corejs3: 0.13.0(@babel/core@7.29.0) babel-plugin-polyfill-regenerator: 0.6.5(@babel/core@7.29.0) @@ -14692,12 +14708,12 @@ snapshots: '@babel/plugin-transform-shorthand-properties@7.27.1(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-transform-spread@7.27.1(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 transitivePeerDependencies: - supports-color @@ -14705,24 +14721,24 @@ snapshots: '@babel/plugin-transform-sticky-regex@7.27.1(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-transform-template-literals@7.27.1(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-transform-typeof-symbol@7.27.1(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-transform-typescript@7.28.5(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 '@babel/helper-annotate-as-pure': 7.27.3 '@babel/helper-create-class-features-plugin': 7.28.5(@babel/core@7.29.0) - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 '@babel/plugin-syntax-typescript': 7.27.1(@babel/core@7.29.0) transitivePeerDependencies: @@ -14731,32 +14747,32 @@ snapshots: '@babel/plugin-transform-unicode-escapes@7.27.1(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-transform-unicode-property-regex@7.27.1(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.0) - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-transform-unicode-regex@7.27.1(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.0) - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-transform-unicode-sets-regex@7.27.1(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.0) - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 '@babel/preset-env@7.28.5(@babel/core@7.29.0)': dependencies: '@babel/compat-data': 7.29.0 '@babel/core': 7.29.0 '@babel/helper-compilation-targets': 7.28.6 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 '@babel/helper-validator-option': 7.27.1 '@babel/plugin-bugfix-firefox-class-in-computed-class-key': 7.28.5(@babel/core@7.29.0) '@babel/plugin-bugfix-safari-class-field-initializer-scope': 7.27.1(@babel/core@7.29.0) @@ -14830,14 +14846,14 @@ snapshots: '@babel/preset-modules@0.1.6-no-external-plugins(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 '@babel/types': 7.29.0 esutils: 2.0.3 '@babel/preset-react@7.28.5(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 '@babel/helper-validator-option': 7.27.1 '@babel/plugin-transform-react-display-name': 7.28.0(@babel/core@7.29.0) '@babel/plugin-transform-react-jsx': 7.27.1(@babel/core@7.29.0) @@ -14849,7 +14865,7 @@ snapshots: '@babel/preset-typescript@7.28.5(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 '@babel/helper-validator-option': 7.27.1 '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.29.0) '@babel/plugin-transform-modules-commonjs': 7.27.1(@babel/core@7.29.0) @@ -14857,12 +14873,10 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/runtime-corejs3@7.28.4': - dependencies: - core-js-pure: 3.47.0 - '@babel/runtime@7.28.4': {} + '@babel/runtime@7.29.7': {} + '@babel/template@7.27.2': dependencies: '@babel/code-frame': 7.29.0 @@ -14875,18 +14889,6 @@ snapshots: '@babel/parser': 7.29.2 '@babel/types': 7.29.0 - '@babel/traverse@7.28.5': - dependencies: - '@babel/code-frame': 7.29.0 - '@babel/generator': 7.29.1 - '@babel/helper-globals': 7.28.0 - '@babel/parser': 7.29.2 - '@babel/template': 7.28.6 - '@babel/types': 7.29.0 - debug: 4.4.3(supports-color@8.1.1) - transitivePeerDependencies: - - supports-color - '@babel/traverse@7.29.0': dependencies: '@babel/code-frame': 7.29.0 @@ -15471,20 +15473,19 @@ snapshots: transitivePeerDependencies: - '@algolia/client-search' - '@docusaurus/babel@3.9.2(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + '@docusaurus/babel@3.10.1(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': dependencies: '@babel/core': 7.29.0 - '@babel/generator': 7.28.5 + '@babel/generator': 7.29.1 '@babel/plugin-syntax-dynamic-import': 7.8.3(@babel/core@7.29.0) '@babel/plugin-transform-runtime': 7.28.5(@babel/core@7.29.0) '@babel/preset-env': 7.28.5(@babel/core@7.29.0) '@babel/preset-react': 7.28.5(@babel/core@7.29.0) '@babel/preset-typescript': 7.28.5(@babel/core@7.29.0) - '@babel/runtime': 7.28.4 - '@babel/runtime-corejs3': 7.28.4 - '@babel/traverse': 7.28.5 - '@docusaurus/logger': 3.9.2 - '@docusaurus/utils': 3.9.2(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@babel/runtime': 7.29.7 + '@babel/traverse': 7.29.0 + '@docusaurus/logger': 3.10.1 + '@docusaurus/utils': 3.10.1(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) babel-plugin-dynamic-import-node: 2.3.3 fs-extra: 11.3.2 tslib: 2.8.1 @@ -15497,14 +15498,14 @@ snapshots: - uglify-js - webpack-cli - '@docusaurus/bundler@3.9.2(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@6.0.3)': + '@docusaurus/bundler@3.10.1(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@6.0.3)': dependencies: '@babel/core': 7.29.0 - '@docusaurus/babel': 3.9.2(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) - '@docusaurus/cssnano-preset': 3.9.2 - '@docusaurus/logger': 3.9.2 - '@docusaurus/types': 3.9.2(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) - '@docusaurus/utils': 3.9.2(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@docusaurus/babel': 3.10.1(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@docusaurus/cssnano-preset': 3.10.1 + '@docusaurus/logger': 3.10.1 + '@docusaurus/types': 3.10.1(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@docusaurus/utils': 3.10.1(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) babel-loader: 9.2.1(@babel/core@7.29.0)(webpack@5.105.4(esbuild@0.27.4)) clean-css: 5.3.3 copy-webpack-plugin: 11.0.0(webpack@5.105.4(esbuild@0.27.4)) @@ -15518,11 +15519,11 @@ snapshots: postcss: 8.5.10 postcss-loader: 7.3.4(postcss@8.5.10)(typescript@6.0.3)(webpack@5.105.4(esbuild@0.27.4)) postcss-preset-env: 10.4.0(postcss@8.5.10) - terser-webpack-plugin: 5.3.14(esbuild@0.27.4)(webpack@5.105.4(esbuild@0.27.4)) + terser-webpack-plugin: 5.4.0(esbuild@0.27.4)(webpack@5.105.4(esbuild@0.27.4)) tslib: 2.8.1 url-loader: 4.1.1(file-loader@6.2.0(webpack@5.105.4(esbuild@0.27.4)))(webpack@5.105.4(esbuild@0.27.4)) webpack: 5.105.4(esbuild@0.27.4) - webpackbar: 6.0.1(webpack@5.105.4(esbuild@0.27.4)) + webpackbar: 7.0.0(webpack@5.105.4(esbuild@0.27.4)) transitivePeerDependencies: - '@parcel/css' - '@rspack/core' @@ -15538,15 +15539,15 @@ snapshots: - uglify-js - webpack-cli - '@docusaurus/core@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.7)(react@19.2.0))(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@6.0.3)': + '@docusaurus/core@3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.7)(react@19.2.0))(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@6.0.3)': dependencies: - '@docusaurus/babel': 3.9.2(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) - '@docusaurus/bundler': 3.9.2(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@6.0.3) - '@docusaurus/logger': 3.9.2 - '@docusaurus/mdx-loader': 3.9.2(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) - '@docusaurus/utils': 3.9.2(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) - '@docusaurus/utils-common': 3.9.2(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) - '@docusaurus/utils-validation': 3.9.2(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@docusaurus/babel': 3.10.1(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@docusaurus/bundler': 3.10.1(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@6.0.3) + '@docusaurus/logger': 3.10.1 + '@docusaurus/mdx-loader': 3.10.1(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@docusaurus/utils': 3.10.1(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@docusaurus/utils-common': 3.10.1(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@docusaurus/utils-validation': 3.10.1(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@mdx-js/react': 3.1.1(@types/react@19.2.7)(react@19.2.0) boxen: 6.2.1 chalk: 4.1.2 @@ -15572,12 +15573,12 @@ snapshots: react-dom: 19.2.0(react@19.2.0) react-helmet-async: '@slorber/react-helmet-async@1.3.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0)' react-loadable: '@docusaurus/react-loadable@6.0.0(react@19.2.0)' - react-loadable-ssr-addon-v5-slorber: 1.0.1(@docusaurus/react-loadable@6.0.0(react@19.2.0))(webpack@5.105.4(esbuild@0.27.4)) - react-router: 5.3.4(react@19.2.0) - react-router-config: 5.1.1(react-router@5.3.4(react@19.2.0))(react@19.2.0) - react-router-dom: 5.3.4(react@19.2.0) - semver: 7.7.3 - serve-handler: 6.1.6 + react-loadable-ssr-addon-v5-slorber: 1.0.3(@docusaurus/react-loadable@6.0.0(react@19.2.0))(webpack@5.105.4(esbuild@0.27.4)) + react-router: 7.15.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + react-router-config: 5.1.1(react-router@7.15.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0) + react-router-dom: 5.3.4(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + semver: 7.7.4 + serve-handler: 6.1.7 tinypool: 1.1.1 tslib: 2.8.1 update-notifier: 6.0.2 @@ -15586,7 +15587,6 @@ snapshots: webpack-dev-server: 5.2.4(webpack@5.105.4(esbuild@0.27.4)) webpack-merge: 6.0.1 transitivePeerDependencies: - - '@docusaurus/faster' - '@parcel/css' - '@rspack/core' - '@swc/core' @@ -15602,23 +15602,23 @@ snapshots: - utf-8-validate - webpack-cli - '@docusaurus/cssnano-preset@3.9.2': + '@docusaurus/cssnano-preset@3.10.1': dependencies: cssnano-preset-advanced: 6.1.2(postcss@8.5.10) postcss: 8.5.10 postcss-sort-media-queries: 5.2.0(postcss@8.5.10) tslib: 2.8.1 - '@docusaurus/logger@3.9.2': + '@docusaurus/logger@3.10.1': dependencies: chalk: 4.1.2 tslib: 2.8.1 - '@docusaurus/mdx-loader@3.9.2(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + '@docusaurus/mdx-loader@3.10.1(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': dependencies: - '@docusaurus/logger': 3.9.2 - '@docusaurus/utils': 3.9.2(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) - '@docusaurus/utils-validation': 3.9.2(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@docusaurus/logger': 3.10.1 + '@docusaurus/utils': 3.10.1(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@docusaurus/utils-validation': 3.10.1(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@mdx-js/mdx': 3.1.1 '@slorber/remark-comment': 1.0.0 escape-html: 1.0.3 @@ -15649,9 +15649,9 @@ snapshots: - uglify-js - webpack-cli - '@docusaurus/module-type-aliases@3.9.2(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + '@docusaurus/module-type-aliases@3.10.1(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': dependencies: - '@docusaurus/types': 3.9.2(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@docusaurus/types': 3.10.1(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@types/history': 4.7.11 '@types/react': 19.2.7 '@types/react-router-config': 5.0.11 @@ -15667,18 +15667,19 @@ snapshots: - uglify-js - webpack-cli - '@docusaurus/plugin-content-blog@3.9.2(@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.7)(react@19.2.0))(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@6.0.3))(@mdx-js/react@3.1.1(@types/react@19.2.7)(react@19.2.0))(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@6.0.3)': - dependencies: - '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.7)(react@19.2.0))(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@6.0.3) - '@docusaurus/logger': 3.9.2 - '@docusaurus/mdx-loader': 3.9.2(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) - '@docusaurus/plugin-content-docs': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.7)(react@19.2.0))(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@6.0.3) - '@docusaurus/theme-common': 3.9.2(@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.7)(react@19.2.0))(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@6.0.3))(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) - '@docusaurus/types': 3.9.2(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) - '@docusaurus/utils': 3.9.2(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) - '@docusaurus/utils-common': 3.9.2(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) - '@docusaurus/utils-validation': 3.9.2(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@docusaurus/plugin-content-blog@3.10.1(@docusaurus/plugin-content-docs@3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.7)(react@19.2.0))(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@6.0.3))(@mdx-js/react@3.1.1(@types/react@19.2.7)(react@19.2.0))(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@6.0.3)': + dependencies: + '@docusaurus/core': 3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.7)(react@19.2.0))(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@6.0.3) + '@docusaurus/logger': 3.10.1 + '@docusaurus/mdx-loader': 3.10.1(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@docusaurus/plugin-content-docs': 3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.7)(react@19.2.0))(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@6.0.3) + '@docusaurus/theme-common': 3.10.1(@docusaurus/plugin-content-docs@3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.7)(react@19.2.0))(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@6.0.3))(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@docusaurus/types': 3.10.1(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@docusaurus/utils': 3.10.1(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@docusaurus/utils-common': 3.10.1(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@docusaurus/utils-validation': 3.10.1(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) cheerio: 1.0.0-rc.12 + combine-promises: 1.2.0 feed: 4.2.2 fs-extra: 11.3.2 lodash: 4.18.1 @@ -15708,17 +15709,17 @@ snapshots: - utf-8-validate - webpack-cli - '@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.7)(react@19.2.0))(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@6.0.3)': - dependencies: - '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.7)(react@19.2.0))(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@6.0.3) - '@docusaurus/logger': 3.9.2 - '@docusaurus/mdx-loader': 3.9.2(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) - '@docusaurus/module-type-aliases': 3.9.2(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) - '@docusaurus/theme-common': 3.9.2(@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.7)(react@19.2.0))(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@6.0.3))(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) - '@docusaurus/types': 3.9.2(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) - '@docusaurus/utils': 3.9.2(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) - '@docusaurus/utils-common': 3.9.2(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) - '@docusaurus/utils-validation': 3.9.2(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@docusaurus/plugin-content-docs@3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.7)(react@19.2.0))(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@6.0.3)': + dependencies: + '@docusaurus/core': 3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.7)(react@19.2.0))(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@6.0.3) + '@docusaurus/logger': 3.10.1 + '@docusaurus/mdx-loader': 3.10.1(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@docusaurus/module-type-aliases': 3.10.1(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@docusaurus/theme-common': 3.10.1(@docusaurus/plugin-content-docs@3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.7)(react@19.2.0))(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@6.0.3))(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@docusaurus/types': 3.10.1(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@docusaurus/utils': 3.10.1(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@docusaurus/utils-common': 3.10.1(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@docusaurus/utils-validation': 3.10.1(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@types/react-router-config': 5.0.11 combine-promises: 1.2.0 fs-extra: 11.3.2 @@ -15748,13 +15749,13 @@ snapshots: - utf-8-validate - webpack-cli - '@docusaurus/plugin-content-pages@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.7)(react@19.2.0))(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@6.0.3)': + '@docusaurus/plugin-content-pages@3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.7)(react@19.2.0))(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@6.0.3)': dependencies: - '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.7)(react@19.2.0))(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@6.0.3) - '@docusaurus/mdx-loader': 3.9.2(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) - '@docusaurus/types': 3.9.2(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) - '@docusaurus/utils': 3.9.2(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) - '@docusaurus/utils-validation': 3.9.2(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@docusaurus/core': 3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.7)(react@19.2.0))(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@6.0.3) + '@docusaurus/mdx-loader': 3.10.1(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@docusaurus/types': 3.10.1(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@docusaurus/utils': 3.10.1(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@docusaurus/utils-validation': 3.10.1(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) fs-extra: 11.3.2 react: 19.2.0 react-dom: 19.2.0(react@19.2.0) @@ -15778,12 +15779,12 @@ snapshots: - utf-8-validate - webpack-cli - '@docusaurus/plugin-css-cascade-layers@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.7)(react@19.2.0))(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@6.0.3)': + '@docusaurus/plugin-css-cascade-layers@3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.7)(react@19.2.0))(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@6.0.3)': dependencies: - '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.7)(react@19.2.0))(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@6.0.3) - '@docusaurus/types': 3.9.2(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) - '@docusaurus/utils': 3.9.2(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) - '@docusaurus/utils-validation': 3.9.2(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@docusaurus/core': 3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.7)(react@19.2.0))(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@6.0.3) + '@docusaurus/types': 3.10.1(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@docusaurus/utils': 3.10.1(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@docusaurus/utils-validation': 3.10.1(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) tslib: 2.8.1 transitivePeerDependencies: - '@docusaurus/faster' @@ -15805,11 +15806,11 @@ snapshots: - utf-8-validate - webpack-cli - '@docusaurus/plugin-debug@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.7)(react@19.2.0))(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@6.0.3)': + '@docusaurus/plugin-debug@3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.7)(react@19.2.0))(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@6.0.3)': dependencies: - '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.7)(react@19.2.0))(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@6.0.3) - '@docusaurus/types': 3.9.2(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) - '@docusaurus/utils': 3.9.2(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@docusaurus/core': 3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.7)(react@19.2.0))(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@6.0.3) + '@docusaurus/types': 3.10.1(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@docusaurus/utils': 3.10.1(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) fs-extra: 11.3.2 react: 19.2.0 react-dom: 19.2.0(react@19.2.0) @@ -15833,11 +15834,11 @@ snapshots: - utf-8-validate - webpack-cli - '@docusaurus/plugin-google-analytics@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.7)(react@19.2.0))(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@6.0.3)': + '@docusaurus/plugin-google-analytics@3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.7)(react@19.2.0))(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@6.0.3)': dependencies: - '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.7)(react@19.2.0))(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@6.0.3) - '@docusaurus/types': 3.9.2(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) - '@docusaurus/utils-validation': 3.9.2(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@docusaurus/core': 3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.7)(react@19.2.0))(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@6.0.3) + '@docusaurus/types': 3.10.1(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@docusaurus/utils-validation': 3.10.1(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) react: 19.2.0 react-dom: 19.2.0(react@19.2.0) tslib: 2.8.1 @@ -15859,12 +15860,12 @@ snapshots: - utf-8-validate - webpack-cli - '@docusaurus/plugin-google-gtag@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.7)(react@19.2.0))(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@6.0.3)': + '@docusaurus/plugin-google-gtag@3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.7)(react@19.2.0))(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@6.0.3)': dependencies: - '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.7)(react@19.2.0))(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@6.0.3) - '@docusaurus/types': 3.9.2(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) - '@docusaurus/utils-validation': 3.9.2(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) - '@types/gtag.js': 0.0.12 + '@docusaurus/core': 3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.7)(react@19.2.0))(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@6.0.3) + '@docusaurus/types': 3.10.1(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@docusaurus/utils-validation': 3.10.1(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@types/gtag.js': 0.0.20 react: 19.2.0 react-dom: 19.2.0(react@19.2.0) tslib: 2.8.1 @@ -15886,11 +15887,11 @@ snapshots: - utf-8-validate - webpack-cli - '@docusaurus/plugin-google-tag-manager@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.7)(react@19.2.0))(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@6.0.3)': + '@docusaurus/plugin-google-tag-manager@3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.7)(react@19.2.0))(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@6.0.3)': dependencies: - '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.7)(react@19.2.0))(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@6.0.3) - '@docusaurus/types': 3.9.2(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) - '@docusaurus/utils-validation': 3.9.2(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@docusaurus/core': 3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.7)(react@19.2.0))(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@6.0.3) + '@docusaurus/types': 3.10.1(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@docusaurus/utils-validation': 3.10.1(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) react: 19.2.0 react-dom: 19.2.0(react@19.2.0) tslib: 2.8.1 @@ -15912,14 +15913,14 @@ snapshots: - utf-8-validate - webpack-cli - '@docusaurus/plugin-sitemap@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.7)(react@19.2.0))(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@6.0.3)': + '@docusaurus/plugin-sitemap@3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.7)(react@19.2.0))(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@6.0.3)': dependencies: - '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.7)(react@19.2.0))(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@6.0.3) - '@docusaurus/logger': 3.9.2 - '@docusaurus/types': 3.9.2(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) - '@docusaurus/utils': 3.9.2(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) - '@docusaurus/utils-common': 3.9.2(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) - '@docusaurus/utils-validation': 3.9.2(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@docusaurus/core': 3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.7)(react@19.2.0))(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@6.0.3) + '@docusaurus/logger': 3.10.1 + '@docusaurus/types': 3.10.1(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@docusaurus/utils': 3.10.1(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@docusaurus/utils-common': 3.10.1(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@docusaurus/utils-validation': 3.10.1(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) fs-extra: 11.3.2 react: 19.2.0 react-dom: 19.2.0(react@19.2.0) @@ -15943,12 +15944,12 @@ snapshots: - utf-8-validate - webpack-cli - '@docusaurus/plugin-svgr@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.7)(react@19.2.0))(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@6.0.3)': + '@docusaurus/plugin-svgr@3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.7)(react@19.2.0))(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@6.0.3)': dependencies: - '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.7)(react@19.2.0))(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@6.0.3) - '@docusaurus/types': 3.9.2(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) - '@docusaurus/utils': 3.9.2(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) - '@docusaurus/utils-validation': 3.9.2(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@docusaurus/core': 3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.7)(react@19.2.0))(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@6.0.3) + '@docusaurus/types': 3.10.1(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@docusaurus/utils': 3.10.1(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@docusaurus/utils-validation': 3.10.1(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@svgr/core': 8.1.0(typescript@6.0.3) '@svgr/webpack': 8.1.0(typescript@6.0.3) react: 19.2.0 @@ -15973,23 +15974,23 @@ snapshots: - utf-8-validate - webpack-cli - '@docusaurus/preset-classic@3.9.2(@algolia/client-search@5.45.0)(@mdx-js/react@3.1.1(@types/react@19.2.7)(react@19.2.0))(@types/react@19.2.7)(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(search-insights@2.17.3)(typescript@6.0.3)': - dependencies: - '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.7)(react@19.2.0))(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@6.0.3) - '@docusaurus/plugin-content-blog': 3.9.2(@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.7)(react@19.2.0))(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@6.0.3))(@mdx-js/react@3.1.1(@types/react@19.2.7)(react@19.2.0))(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@6.0.3) - '@docusaurus/plugin-content-docs': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.7)(react@19.2.0))(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@6.0.3) - '@docusaurus/plugin-content-pages': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.7)(react@19.2.0))(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@6.0.3) - '@docusaurus/plugin-css-cascade-layers': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.7)(react@19.2.0))(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@6.0.3) - '@docusaurus/plugin-debug': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.7)(react@19.2.0))(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@6.0.3) - '@docusaurus/plugin-google-analytics': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.7)(react@19.2.0))(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@6.0.3) - '@docusaurus/plugin-google-gtag': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.7)(react@19.2.0))(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@6.0.3) - '@docusaurus/plugin-google-tag-manager': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.7)(react@19.2.0))(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@6.0.3) - '@docusaurus/plugin-sitemap': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.7)(react@19.2.0))(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@6.0.3) - '@docusaurus/plugin-svgr': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.7)(react@19.2.0))(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@6.0.3) - '@docusaurus/theme-classic': 3.9.2(@types/react@19.2.7)(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@6.0.3) - '@docusaurus/theme-common': 3.9.2(@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.7)(react@19.2.0))(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@6.0.3))(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) - '@docusaurus/theme-search-algolia': 3.9.2(@algolia/client-search@5.45.0)(@mdx-js/react@3.1.1(@types/react@19.2.7)(react@19.2.0))(@types/react@19.2.7)(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(search-insights@2.17.3)(typescript@6.0.3) - '@docusaurus/types': 3.9.2(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@docusaurus/preset-classic@3.10.1(@algolia/client-search@5.45.0)(@mdx-js/react@3.1.1(@types/react@19.2.7)(react@19.2.0))(@types/react@19.2.7)(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(search-insights@2.17.3)(typescript@6.0.3)': + dependencies: + '@docusaurus/core': 3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.7)(react@19.2.0))(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@6.0.3) + '@docusaurus/plugin-content-blog': 3.10.1(@docusaurus/plugin-content-docs@3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.7)(react@19.2.0))(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@6.0.3))(@mdx-js/react@3.1.1(@types/react@19.2.7)(react@19.2.0))(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@6.0.3) + '@docusaurus/plugin-content-docs': 3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.7)(react@19.2.0))(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@6.0.3) + '@docusaurus/plugin-content-pages': 3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.7)(react@19.2.0))(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@6.0.3) + '@docusaurus/plugin-css-cascade-layers': 3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.7)(react@19.2.0))(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@6.0.3) + '@docusaurus/plugin-debug': 3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.7)(react@19.2.0))(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@6.0.3) + '@docusaurus/plugin-google-analytics': 3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.7)(react@19.2.0))(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@6.0.3) + '@docusaurus/plugin-google-gtag': 3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.7)(react@19.2.0))(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@6.0.3) + '@docusaurus/plugin-google-tag-manager': 3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.7)(react@19.2.0))(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@6.0.3) + '@docusaurus/plugin-sitemap': 3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.7)(react@19.2.0))(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@6.0.3) + '@docusaurus/plugin-svgr': 3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.7)(react@19.2.0))(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@6.0.3) + '@docusaurus/theme-classic': 3.10.1(@types/react@19.2.7)(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@6.0.3) + '@docusaurus/theme-common': 3.10.1(@docusaurus/plugin-content-docs@3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.7)(react@19.2.0))(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@6.0.3))(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@docusaurus/theme-search-algolia': 3.10.1(@algolia/client-search@5.45.0)(@mdx-js/react@3.1.1(@types/react@19.2.7)(react@19.2.0))(@types/react@19.2.7)(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(search-insights@2.17.3)(typescript@6.0.3) + '@docusaurus/types': 3.10.1(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) react: 19.2.0 react-dom: 19.2.0(react@19.2.0) transitivePeerDependencies: @@ -16018,23 +16019,24 @@ snapshots: '@types/react': 19.2.7 react: 19.2.0 - '@docusaurus/theme-classic@3.9.2(@types/react@19.2.7)(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@6.0.3)': - dependencies: - '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.7)(react@19.2.0))(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@6.0.3) - '@docusaurus/logger': 3.9.2 - '@docusaurus/mdx-loader': 3.9.2(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) - '@docusaurus/module-type-aliases': 3.9.2(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) - '@docusaurus/plugin-content-blog': 3.9.2(@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.7)(react@19.2.0))(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@6.0.3))(@mdx-js/react@3.1.1(@types/react@19.2.7)(react@19.2.0))(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@6.0.3) - '@docusaurus/plugin-content-docs': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.7)(react@19.2.0))(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@6.0.3) - '@docusaurus/plugin-content-pages': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.7)(react@19.2.0))(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@6.0.3) - '@docusaurus/theme-common': 3.9.2(@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.7)(react@19.2.0))(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@6.0.3))(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) - '@docusaurus/theme-translations': 3.9.2 - '@docusaurus/types': 3.9.2(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) - '@docusaurus/utils': 3.9.2(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) - '@docusaurus/utils-common': 3.9.2(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) - '@docusaurus/utils-validation': 3.9.2(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@docusaurus/theme-classic@3.10.1(@types/react@19.2.7)(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@6.0.3)': + dependencies: + '@docusaurus/core': 3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.7)(react@19.2.0))(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@6.0.3) + '@docusaurus/logger': 3.10.1 + '@docusaurus/mdx-loader': 3.10.1(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@docusaurus/module-type-aliases': 3.10.1(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@docusaurus/plugin-content-blog': 3.10.1(@docusaurus/plugin-content-docs@3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.7)(react@19.2.0))(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@6.0.3))(@mdx-js/react@3.1.1(@types/react@19.2.7)(react@19.2.0))(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@6.0.3) + '@docusaurus/plugin-content-docs': 3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.7)(react@19.2.0))(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@6.0.3) + '@docusaurus/plugin-content-pages': 3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.7)(react@19.2.0))(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@6.0.3) + '@docusaurus/theme-common': 3.10.1(@docusaurus/plugin-content-docs@3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.7)(react@19.2.0))(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@6.0.3))(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@docusaurus/theme-translations': 3.10.1 + '@docusaurus/types': 3.10.1(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@docusaurus/utils': 3.10.1(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@docusaurus/utils-common': 3.10.1(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@docusaurus/utils-validation': 3.10.1(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@mdx-js/react': 3.1.1(@types/react@19.2.7)(react@19.2.0) clsx: 2.1.1 + copy-text-to-clipboard: 3.2.2 infima: 0.2.0-alpha.45 lodash: 4.18.1 nprogress: 0.2.0 @@ -16043,7 +16045,7 @@ snapshots: prismjs: 1.30.0 react: 19.2.0 react-dom: 19.2.0(react@19.2.0) - react-router-dom: 5.3.4(react@19.2.0) + react-router-dom: 5.3.4(react-dom@19.2.0(react@19.2.0))(react@19.2.0) rtlcss: 4.3.0 tslib: 2.8.1 utility-types: 3.11.0 @@ -16065,13 +16067,13 @@ snapshots: - utf-8-validate - webpack-cli - '@docusaurus/theme-common@3.9.2(@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.7)(react@19.2.0))(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@6.0.3))(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + '@docusaurus/theme-common@3.10.1(@docusaurus/plugin-content-docs@3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.7)(react@19.2.0))(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@6.0.3))(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': dependencies: - '@docusaurus/mdx-loader': 3.9.2(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) - '@docusaurus/module-type-aliases': 3.9.2(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) - '@docusaurus/plugin-content-docs': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.7)(react@19.2.0))(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@6.0.3) - '@docusaurus/utils': 3.9.2(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) - '@docusaurus/utils-common': 3.9.2(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@docusaurus/mdx-loader': 3.10.1(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@docusaurus/module-type-aliases': 3.10.1(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@docusaurus/plugin-content-docs': 3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.7)(react@19.2.0))(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@6.0.3) + '@docusaurus/utils': 3.10.1(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@docusaurus/utils-common': 3.10.1(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@types/history': 4.7.11 '@types/react': 19.2.7 '@types/react-router-config': 5.0.11 @@ -16089,16 +16091,17 @@ snapshots: - uglify-js - webpack-cli - '@docusaurus/theme-search-algolia@3.9.2(@algolia/client-search@5.45.0)(@mdx-js/react@3.1.1(@types/react@19.2.7)(react@19.2.0))(@types/react@19.2.7)(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(search-insights@2.17.3)(typescript@6.0.3)': + '@docusaurus/theme-search-algolia@3.10.1(@algolia/client-search@5.45.0)(@mdx-js/react@3.1.1(@types/react@19.2.7)(react@19.2.0))(@types/react@19.2.7)(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(search-insights@2.17.3)(typescript@6.0.3)': dependencies: + '@algolia/autocomplete-core': 1.19.2(@algolia/client-search@5.45.0)(algoliasearch@5.45.0)(search-insights@2.17.3) '@docsearch/react': 4.3.2(@algolia/client-search@5.45.0)(@types/react@19.2.7)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(search-insights@2.17.3) - '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.7)(react@19.2.0))(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@6.0.3) - '@docusaurus/logger': 3.9.2 - '@docusaurus/plugin-content-docs': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.7)(react@19.2.0))(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@6.0.3) - '@docusaurus/theme-common': 3.9.2(@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.7)(react@19.2.0))(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@6.0.3))(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) - '@docusaurus/theme-translations': 3.9.2 - '@docusaurus/utils': 3.9.2(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) - '@docusaurus/utils-validation': 3.9.2(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@docusaurus/core': 3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.7)(react@19.2.0))(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@6.0.3) + '@docusaurus/logger': 3.10.1 + '@docusaurus/plugin-content-docs': 3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.7)(react@19.2.0))(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@6.0.3) + '@docusaurus/theme-common': 3.10.1(@docusaurus/plugin-content-docs@3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.7)(react@19.2.0))(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@6.0.3))(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@docusaurus/theme-translations': 3.10.1 + '@docusaurus/utils': 3.10.1(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@docusaurus/utils-validation': 3.10.1(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) algoliasearch: 5.45.0 algoliasearch-helper: 3.26.1(algoliasearch@5.45.0) clsx: 2.1.1 @@ -16130,14 +16133,14 @@ snapshots: - utf-8-validate - webpack-cli - '@docusaurus/theme-translations@3.9.2': + '@docusaurus/theme-translations@3.10.1': dependencies: fs-extra: 11.3.2 tslib: 2.8.1 - '@docusaurus/tsconfig@3.9.2': {} + '@docusaurus/tsconfig@3.10.1': {} - '@docusaurus/types@3.9.2(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + '@docusaurus/types@3.10.1(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': dependencies: '@mdx-js/mdx': 3.1.1 '@types/history': 4.7.11 @@ -16158,9 +16161,9 @@ snapshots: - uglify-js - webpack-cli - '@docusaurus/utils-common@3.9.2(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + '@docusaurus/utils-common@3.10.1(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': dependencies: - '@docusaurus/types': 3.9.2(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@docusaurus/types': 3.10.1(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) tslib: 2.8.1 transitivePeerDependencies: - '@swc/core' @@ -16171,11 +16174,11 @@ snapshots: - uglify-js - webpack-cli - '@docusaurus/utils-validation@3.9.2(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + '@docusaurus/utils-validation@3.10.1(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': dependencies: - '@docusaurus/logger': 3.9.2 - '@docusaurus/utils': 3.9.2(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) - '@docusaurus/utils-common': 3.9.2(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@docusaurus/logger': 3.10.1 + '@docusaurus/utils': 3.10.1(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@docusaurus/utils-common': 3.10.1(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) fs-extra: 11.3.2 joi: 17.13.3 js-yaml: 4.1.1 @@ -16190,11 +16193,11 @@ snapshots: - uglify-js - webpack-cli - '@docusaurus/utils@3.9.2(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + '@docusaurus/utils@3.10.1(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': dependencies: - '@docusaurus/logger': 3.9.2 - '@docusaurus/types': 3.9.2(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) - '@docusaurus/utils-common': 3.9.2(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@docusaurus/logger': 3.10.1 + '@docusaurus/types': 3.10.1(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@docusaurus/utils-common': 3.10.1(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) escape-string-regexp: 4.0.0 execa: 5.1.1 file-loader: 6.2.0(webpack@5.105.4(esbuild@0.27.4)) @@ -17066,7 +17069,7 @@ snapshots: '@types/estree-jsx': 1.0.5 '@types/hast': 3.0.4 '@types/mdx': 2.0.13 - acorn: 8.15.0 + acorn: 8.16.0 collapse-white-space: 2.1.0 devlop: 1.1.0 estree-util-is-identifier-name: 3.0.0 @@ -17075,7 +17078,7 @@ snapshots: hast-util-to-jsx-runtime: 2.3.6 markdown-extensions: 2.0.0 recma-build-jsx: 1.0.0 - recma-jsx: 1.0.1(acorn@8.15.0) + recma-jsx: 1.0.1(acorn@8.16.0) recma-stringify: 1.0.0 rehype-recma: 1.0.0 remark-mdx: 3.1.1 @@ -17797,7 +17800,7 @@ snapshots: '@rc-component/async-validator@5.1.0': dependencies: - '@babel/runtime': 7.28.4 + '@babel/runtime': 7.29.7 '@rc-component/cascader@1.14.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': dependencies: @@ -17919,7 +17922,7 @@ snapshots: '@rc-component/mini-decimal@1.1.0': dependencies: - '@babel/runtime': 7.28.4 + '@babel/runtime': 7.29.7 '@rc-component/motion@1.3.2(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': dependencies: @@ -17944,7 +17947,7 @@ snapshots: '@rc-component/overflow@1.0.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': dependencies: - '@babel/runtime': 7.28.4 + '@babel/runtime': 7.29.7 '@rc-component/resize-observer': 1.1.2(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@rc-component/util': 1.10.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0) clsx: 2.1.1 @@ -18136,7 +18139,7 @@ snapshots: '@rc-component/virtual-list@1.0.2(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': dependencies: - '@babel/runtime': 7.28.4 + '@babel/runtime': 7.29.7 '@rc-component/resize-observer': 1.1.2(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@rc-component/util': 1.10.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0) clsx: 2.1.1 @@ -18372,7 +18375,7 @@ snapshots: '@slorber/react-helmet-async@1.3.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': dependencies: - '@babel/runtime': 7.28.4 + '@babel/runtime': 7.29.7 invariant: 2.2.4 prop-types: 15.8.1 react: 19.2.0 @@ -18622,8 +18625,8 @@ snapshots: '@testing-library/dom@10.4.1': dependencies: - '@babel/code-frame': 7.29.0 - '@babel/runtime': 7.28.4 + '@babel/code-frame': 7.29.7 + '@babel/runtime': 7.29.7 '@types/aria-query': 5.0.4 aria-query: 5.3.0 dom-accessibility-api: 0.5.16 @@ -18794,7 +18797,7 @@ snapshots: dependencies: graphql: 14.7.0 - '@types/gtag.js@0.0.12': {} + '@types/gtag.js@0.0.20': {} '@types/hast@3.0.4': dependencies: @@ -19296,10 +19299,6 @@ snapshots: dependencies: acorn: 8.16.0 - acorn-jsx@5.3.2(acorn@8.15.0): - dependencies: - acorn: 8.15.0 - acorn-jsx@5.3.2(acorn@8.16.0): dependencies: acorn: 8.16.0 @@ -19308,8 +19307,6 @@ snapshots: dependencies: acorn: 8.16.0 - acorn@8.15.0: {} - acorn@8.16.0: {} address@1.2.2: {} @@ -19408,6 +19405,8 @@ snapshots: ansi-styles@6.2.3: {} + ansis@3.17.0: {} + antd@6.3.5(luxon@3.7.2)(moment@2.30.1)(react-dom@19.2.0(react@19.2.0))(react@19.2.0): dependencies: '@ant-design/colors': 8.0.1 @@ -20335,6 +20334,8 @@ snapshots: dependencies: is-what: 3.14.1 + copy-text-to-clipboard@3.2.2: {} + copy-webpack-plugin@11.0.0(webpack@5.105.4(esbuild@0.27.4)): dependencies: fast-glob: 3.3.3 @@ -20349,8 +20350,6 @@ snapshots: dependencies: browserslist: 4.28.1 - core-js-pure@3.47.0: {} - core-js@3.47.0: {} core-util-is@1.0.3: {} @@ -21821,7 +21820,7 @@ snapshots: history@4.10.1: dependencies: - '@babel/runtime': 7.28.4 + '@babel/runtime': 7.29.7 loose-envify: 1.4.0 resolve-pathname: 3.0.0 tiny-invariant: 1.3.3 @@ -22314,8 +22313,6 @@ snapshots: is-yarn-global@0.4.1: {} - isarray@0.0.1: {} - isarray@1.0.0: {} isarray@2.0.5: {} @@ -22744,8 +22741,6 @@ snapshots: lru-cache@10.4.3: {} - lru-cache@11.3.3: {} - lru-cache@11.3.5: {} lru-cache@5.1.1: @@ -22788,10 +22783,6 @@ snapshots: markdown-extensions@2.0.0: {} - markdown-table@2.0.0: - dependencies: - repeat-string: 1.6.1 - markdown-table@3.0.4: {} marked@16.4.2: {} @@ -22804,7 +22795,7 @@ snapshots: md5.js@1.3.5: dependencies: - hash-base: 3.0.5 + hash-base: 3.1.2 inherits: 2.0.4 safe-buffer: 5.2.1 @@ -23413,7 +23404,7 @@ snapshots: https-proxy-agent: 7.0.6 mongodb: 6.21.0 new-find-package-json: 2.0.0 - semver: 7.7.3 + semver: 7.7.4 tar-stream: 3.1.7 tslib: 2.8.1 yauzl: 3.2.1 @@ -23984,10 +23975,6 @@ snapshots: path-to-regexp@0.1.13: {} - path-to-regexp@1.9.0: - dependencies: - isarray: 0.0.1 - path-to-regexp@3.3.0: {} path-to-regexp@8.4.0: {} @@ -24738,9 +24725,9 @@ snapshots: dependencies: react: 19.2.0 - react-loadable-ssr-addon-v5-slorber@1.0.1(@docusaurus/react-loadable@6.0.0(react@19.2.0))(webpack@5.105.4(esbuild@0.27.4)): + react-loadable-ssr-addon-v5-slorber@1.0.3(@docusaurus/react-loadable@6.0.0(react@19.2.0))(webpack@5.105.4(esbuild@0.27.4)): dependencies: - '@babel/runtime': 7.28.4 + '@babel/runtime': 7.29.7 react-loadable: '@docusaurus/react-loadable@6.0.0(react@19.2.0)' webpack: 5.105.4(esbuild@0.27.4) @@ -24749,43 +24736,32 @@ snapshots: oidc-client-ts: 3.4.1 react: 19.2.0 - react-router-config@5.1.1(react-router@5.3.4(react@19.2.0))(react@19.2.0): + react-router-config@5.1.1(react-router@7.15.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0): dependencies: - '@babel/runtime': 7.28.4 + '@babel/runtime': 7.29.7 react: 19.2.0 - react-router: 5.3.4(react@19.2.0) + react-router: 7.15.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0) - react-router-dom@5.3.4(react@19.2.0): + react-router-dom@5.3.4(react-dom@19.2.0(react@19.2.0))(react@19.2.0): dependencies: - '@babel/runtime': 7.28.4 + '@babel/runtime': 7.29.7 history: 4.10.1 loose-envify: 1.4.0 prop-types: 15.8.1 react: 19.2.0 - react-router: 5.3.4(react@19.2.0) + react-router: 7.15.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0) tiny-invariant: 1.3.3 tiny-warning: 1.0.3 + transitivePeerDependencies: + - react-dom - react-router-dom@7.12.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0): + react-router-dom@7.15.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0): dependencies: react: 19.2.0 react-dom: 19.2.0(react@19.2.0) - react-router: 7.12.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0) - - react-router@5.3.4(react@19.2.0): - dependencies: - '@babel/runtime': 7.28.4 - history: 4.10.1 - hoist-non-react-statics: 3.3.2 - loose-envify: 1.4.0 - path-to-regexp: 1.9.0 - prop-types: 15.8.1 - react: 19.2.0 - react-is: 16.13.1 - tiny-invariant: 1.3.3 - tiny-warning: 1.0.3 + react-router: 7.15.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0) - react-router@7.12.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0): + react-router@7.15.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0): dependencies: cookie: 1.1.1 react: 19.2.0 @@ -24860,10 +24836,10 @@ snapshots: estree-util-build-jsx: 3.0.1 vfile: 6.0.3 - recma-jsx@1.0.1(acorn@8.15.0): + recma-jsx@1.0.1(acorn@8.16.0): dependencies: - acorn: 8.15.0 - acorn-jsx: 5.3.2(acorn@8.15.0) + acorn: 8.16.0 + acorn-jsx: 5.3.2(acorn@8.16.0) estree-util-to-js: 2.0.0 recma-parse: 1.0.0 recma-stringify: 1.0.0 @@ -24968,7 +24944,7 @@ snapshots: relay-runtime@12.0.0: dependencies: - '@babel/runtime': 7.28.4 + '@babel/runtime': 7.29.7 fbjs: 3.0.5 invariant: 2.2.4 transitivePeerDependencies: @@ -25362,7 +25338,7 @@ snapshots: serialize-javascript@7.0.5: {} - serve-handler@6.1.6: + serve-handler@6.1.7: dependencies: bytes: 3.0.0 content-disposition: 0.5.2 @@ -25502,7 +25478,7 @@ snapshots: '@types/node': 17.0.45 '@types/sax': 1.2.7 arg: 5.0.2 - sax: 1.4.3 + sax: 1.5.0 skin-tone@2.0.0: dependencies: @@ -25918,17 +25894,6 @@ snapshots: transitivePeerDependencies: - supports-color - terser-webpack-plugin@5.3.14(esbuild@0.27.4)(webpack@5.105.4(esbuild@0.27.4)): - dependencies: - '@jridgewell/trace-mapping': 0.3.31 - jest-worker: 27.5.1 - schema-utils: 4.3.3 - serialize-javascript: 7.0.5 - terser: 5.44.1 - webpack: 5.105.4(esbuild@0.27.4) - optionalDependencies: - esbuild: 0.27.4 - terser-webpack-plugin@5.4.0(esbuild@0.27.4)(webpack@5.105.4(esbuild@0.27.4)): dependencies: '@jridgewell/trace-mapping': 0.3.31 @@ -26316,7 +26281,7 @@ snapshots: is-yarn-global: 0.4.1 latest-version: 7.0.0 pupa: 3.3.0 - semver: 7.7.3 + semver: 7.7.4 semver-diff: 4.0.0 xdg-basedir: 5.1.0 @@ -26564,7 +26529,7 @@ snapshots: webpack-bundle-analyzer@4.10.2: dependencies: '@discoveryjs/json-ext': 0.5.7 - acorn: 8.15.0 + acorn: 8.16.0 acorn-walk: 8.3.4 commander: 7.2.0 debounce: 1.2.1 @@ -26676,17 +26641,14 @@ snapshots: - esbuild - uglify-js - webpackbar@6.0.1(webpack@5.105.4(esbuild@0.27.4)): + webpackbar@7.0.0(webpack@5.105.4(esbuild@0.27.4)): dependencies: - ansi-escapes: 4.3.2 - chalk: 4.1.2 + ansis: 3.17.0 consola: 3.4.2 - figures: 3.2.0 - markdown-table: 2.0.0 pretty-time: 1.1.0 std-env: 3.10.0 + optionalDependencies: webpack: 5.105.4(esbuild@0.27.4) - wrap-ansi: 7.0.0 websocket-driver@0.7.4: dependencies: @@ -26837,7 +26799,7 @@ snapshots: xml-js@1.6.11: dependencies: - sax: 1.4.3 + sax: 1.5.0 xml-name-validator@5.0.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index a703b9ada..31bbb16b2 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -27,7 +27,7 @@ catalog: jsdom: ^26.1.0 mongodb: 6.18.0 mongoose: 8.17.0 - react-router-dom: 7.12.0 + react-router-dom: 7.15.0 react: ^19.1.1 react-dom: ^19.1.1 '@ant-design/icons': ^6.0.2 @@ -99,6 +99,7 @@ overrides: '@babel/plugin-transform-modules-systemjs': 7.29.4 ws: 8.20.1 shell-quote: 1.8.4 + react-router: 7.15.0 packageExtensions: '@azure/functions-opentelemetry-instrumentation@0.1.0':