diff --git a/apps/api/src/frameworks/frameworks-people-score.helper.spec.ts b/apps/api/src/frameworks/frameworks-people-score.helper.spec.ts index ee76cc0f2..da98db0d5 100644 --- a/apps/api/src/frameworks/frameworks-people-score.helper.spec.ts +++ b/apps/api/src/frameworks/frameworks-people-score.helper.spec.ts @@ -187,14 +187,14 @@ describe('computePeopleScore', () => { it('counts every member as complete when all members are exempt', async () => { (mockDb.backgroundCheckRequest.findMany as jest.Mock).mockResolvedValue([]); - (mockDb.member.findMany as jest.Mock).mockImplementation(async (args: { - where?: { backgroundCheckExempt?: boolean }; - }) => { - if (args?.where?.backgroundCheckExempt === true) { - return [{ id: 'mem_1' }, { id: 'mem_2' }]; - } - return []; - }); + (mockDb.member.findMany as jest.Mock).mockImplementation( + async (args: { where?: { backgroundCheckExempt?: boolean } }) => { + if (args?.where?.backgroundCheckExempt === true) { + return [{ id: 'mem_1' }, { id: 'mem_2' }]; + } + return []; + }, + ); const score = await computePeopleScore({ organizationId: 'org_1', @@ -209,6 +209,64 @@ describe('computePeopleScore', () => { expect(score).toEqual({ total: 2, completed: 2 }); }); + it('does not require a background check for auditor-only members', async () => { + const auditorMembers = [ + { + id: 'mem_1', + role: 'auditor', + user: { id: 'usr_1', email: 'a@example.com', role: 'auditor' }, + }, + { + id: 'mem_2', + role: 'owner', + user: { id: 'usr_2', email: 'b@example.com', role: 'owner' }, + }, + ]; + mockFilterComplianceMembers.mockResolvedValue(auditorMembers); + (mockDb.backgroundCheckRequest.findMany as jest.Mock).mockResolvedValue([]); + (mockDb.member.findMany as jest.Mock).mockResolvedValue([]); + + const score = await computePeopleScore({ + organizationId: 'org_1', + allPolicies: [], + employees: auditorMembers, + securityTrainingStepEnabled: false, + deviceAgentStepEnabled: false, + backgroundCheckStepEnabled: true, + hasHipaaFramework: false, + }); + + // mem_1 (auditor-only) → no BG check required → complete + // mem_2 (owner) → no BG check, not exempt → not complete + expect(score).toEqual({ total: 2, completed: 1 }); + }); + + it('still requires a background check for members with auditor plus another role', async () => { + const mixedMembers = [ + { + id: 'mem_1', + role: 'auditor,employee', + user: { id: 'usr_1', email: 'a@example.com', role: 'employee' }, + }, + ]; + mockFilterComplianceMembers.mockResolvedValue(mixedMembers); + (mockDb.backgroundCheckRequest.findMany as jest.Mock).mockResolvedValue([]); + (mockDb.member.findMany as jest.Mock).mockResolvedValue([]); + + const score = await computePeopleScore({ + organizationId: 'org_1', + allPolicies: [], + employees: mixedMembers, + securityTrainingStepEnabled: false, + deviceAgentStepEnabled: false, + backgroundCheckStepEnabled: true, + hasHipaaFramework: false, + }); + + // auditor+employee is NOT auditor-only → still requires a BG check → not complete + expect(score).toEqual({ total: 1, completed: 0 }); + }); + it('skips the exempt query entirely when backgroundCheckStepEnabled is false', async () => { (mockDb.member.findMany as jest.Mock).mockClear(); diff --git a/apps/api/src/frameworks/frameworks-people-score.helper.ts b/apps/api/src/frameworks/frameworks-people-score.helper.ts index 427ff88da..ac0b11671 100644 --- a/apps/api/src/frameworks/frameworks-people-score.helper.ts +++ b/apps/api/src/frameworks/frameworks-people-score.helper.ts @@ -8,6 +8,20 @@ const COMPLETED_BACKGROUND_CHECK_STATUSES = [ BackgroundCheckStatus.completed_with_flags, ]; +/** + * Auditor-only members are not subject to people-security requirements like + * background checks (CS-416). A member counts as auditor-only when every one of + * their roles is `auditor` — a member with `auditor` plus another role still + * carries that other role's obligations. + */ +function isAuditorOnly(role: string): boolean { + const roles = role + .split(',') + .map((r) => r.trim()) + .filter(Boolean); + return roles.length > 0 && roles.every((r) => r === 'auditor'); +} + interface ScorePolicy { isRequiredToSign: boolean; status: string; @@ -106,7 +120,9 @@ export async function computePeopleScore({ ? membersWithInstalledDevices.has(employee.id) : true; const memberRequiresBgCheck = - backgroundCheckStepEnabled && !exemptMemberIds.has(employee.id); + backgroundCheckStepEnabled && + !exemptMemberIds.has(employee.id) && + !isAuditorOnly(employee.role); const hasCompletedBackgroundCheck = memberRequiresBgCheck ? membersWithCompletedBackgroundChecks.has(employee.id) : true; diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Employee.tsx b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Employee.tsx index e5757f902..c526b494e 100644 --- a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Employee.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Employee.tsx @@ -20,6 +20,7 @@ import { EmployeeDevice } from './EmployeeDevice'; import { EmployeePageHeader } from './EmployeePageHeader'; import { EmployeePolicies } from './EmployeePolicies'; import { EmployeeHipaaTraining, EmployeeTrainingVideos } from './EmployeeTraining'; +import { isAuditorOnly } from './isAuditorOnly'; import { OffboardingChecklist } from './OffboardingChecklist'; type EmployeeTab = @@ -74,19 +75,23 @@ export function Employee({ const pathname = usePathname(); const router = useRouter(); + // CS-416: auditor-only members aren't subject to background checks, so the + // tab, header status, and content are hidden for them. + const showBackgroundCheck = backgroundCheckStepEnabled && !isAuditorOnly(employee.role); + const availableTabs: EmployeeTab[] = [ 'details', 'policies', 'training', ...(hasHipaaFramework ? (['hipaa'] as EmployeeTab[]) : []), 'device', - ...(backgroundCheckStepEnabled ? (['background-check'] as EmployeeTab[]) : []), + ...(showBackgroundCheck ? (['background-check'] as EmployeeTab[]) : []), ...(employee.offboardDate ? (['offboarding'] as EmployeeTab[]) : []), ]; const resolveTab = (): EmployeeTab => { if ( - backgroundCheckStepEnabled && + showBackgroundCheck && (searchParams.get('background_check_step') || searchParams.get('background_check_billing')) ) { return 'background-check'; @@ -131,7 +136,7 @@ export function Employee({ employeeName={employee.user.name ?? 'Employee'} orgId={orgId} backgroundCheck={initialBackgroundCheck} - backgroundCheckStepEnabled={backgroundCheckStepEnabled} + backgroundCheckStepEnabled={showBackgroundCheck} memberBackgroundCheckExempt={memberExempt} /> } @@ -149,12 +154,10 @@ export function Employee({ Training Videos {hasHipaaFramework && HIPAA Training} Device - {backgroundCheckStepEnabled && ( + {showBackgroundCheck && ( Background Check )} - {employee.offboardDate && ( - Offboarding - )} + {employee.offboardDate && Offboarding} @@ -195,7 +198,7 @@ export function Employee({ /> )} - {backgroundCheckStepEnabled && ( + {showBackgroundCheck && ( { + it('is true when the only role is auditor', () => { + expect(isAuditorOnly('auditor')).toBe(true); + }); + + it('is true when every role entry is auditor', () => { + expect(isAuditorOnly('auditor, auditor')).toBe(true); + }); + + it('is false when another role is present alongside auditor', () => { + expect(isAuditorOnly('auditor,employee')).toBe(false); + expect(isAuditorOnly('owner')).toBe(false); + }); + + it('is false for empty or missing roles', () => { + expect(isAuditorOnly('')).toBe(false); + expect(isAuditorOnly(null)).toBe(false); + expect(isAuditorOnly(undefined)).toBe(false); + }); +}); diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/isAuditorOnly.ts b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/isAuditorOnly.ts new file mode 100644 index 000000000..2bb83019b --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/isAuditorOnly.ts @@ -0,0 +1,13 @@ +/** + * A member is "auditor-only" when every one of their (comma-separated) roles is + * `auditor`. Auditor-only members are external reviewers and are not subject to + * people-security requirements such as background checks (CS-416). A member with + * `auditor` plus another role still carries that other role's obligations. + */ +export function isAuditorOnly(role: string | null | undefined): boolean { + const roles = (role ?? '') + .split(',') + .map((r) => r.trim()) + .filter(Boolean); + return roles.length > 0 && roles.every((r) => r === 'auditor'); +}