Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 105 additions & 0 deletions apps/api/src/frameworks/frameworks-people-score.helper.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,12 @@ describe('computePeopleScore', () => {
(mockDb.backgroundCheckRequest.findMany as jest.Mock).mockResolvedValue([
{ memberId: 'mem_1' },
]);
(mockDb.member.findMany as jest.Mock).mockImplementation(
async (args: { where?: { backgroundCheckExempt?: boolean } }) => {
if (args?.where?.backgroundCheckExempt === true) return [];
return [];
},
);
});

it('requires a completed or uploaded background check for people completion', async () => {
Expand Down Expand Up @@ -125,4 +131,103 @@ describe('computePeopleScore', () => {

expect(mockDb.backgroundCheckRequest.findMany).not.toHaveBeenCalled();
});

it('treats an exempt member as complete without a BG check (org-level on)', async () => {
// mem_1 has no completed BG check; mem_2 has none either. Mark mem_1 exempt.
(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' }];
}
return [];
},
);

const score = await computePeopleScore({
organizationId: 'org_1',
allPolicies: [],
employees: members,
securityTrainingStepEnabled: false,
deviceAgentStepEnabled: false,
backgroundCheckStepEnabled: true,
hasHipaaFramework: false,
});

// mem_1 exempt → counts complete; mem_2 not exempt + no BG check → not complete
expect(score).toEqual({ total: 2, completed: 1 });
});

it('counts a mix of completed BG checks and exempt members correctly', async () => {
// mem_1 has a completed BG check; mem_2 is exempt
(mockDb.backgroundCheckRequest.findMany as jest.Mock).mockResolvedValue([
{ memberId: 'mem_1' },
]);
(mockDb.member.findMany as jest.Mock).mockImplementation(
async (args: { where?: { backgroundCheckExempt?: boolean } }) => {
if (args?.where?.backgroundCheckExempt === true) {
return [{ id: 'mem_2' }];
}
return [];
},
);

const score = await computePeopleScore({
organizationId: 'org_1',
allPolicies: [],
employees: members,
securityTrainingStepEnabled: false,
deviceAgentStepEnabled: false,
backgroundCheckStepEnabled: true,
hasHipaaFramework: false,
});

expect(score).toEqual({ total: 2, completed: 2 });
});

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 [];
});

const score = await computePeopleScore({
organizationId: 'org_1',
allPolicies: [],
employees: members,
securityTrainingStepEnabled: false,
deviceAgentStepEnabled: false,
backgroundCheckStepEnabled: true,
hasHipaaFramework: false,
});

expect(score).toEqual({ total: 2, completed: 2 });
});

it('skips the exempt query entirely when backgroundCheckStepEnabled is false', async () => {
(mockDb.member.findMany as jest.Mock).mockClear();

await computePeopleScore({
organizationId: 'org_1',
allPolicies: [],
employees: members,
securityTrainingStepEnabled: false,
deviceAgentStepEnabled: false,
backgroundCheckStepEnabled: false,
hasHipaaFramework: false,
});

// The exempt query targets backgroundCheckExempt: true. Confirm it was not called with that arg.
const findManyCalls = (mockDb.member.findMany as jest.Mock).mock.calls;
const exemptQueryCalled = findManyCalls.some(
([args]: [{ where?: { backgroundCheckExempt?: boolean } }]) =>
args?.where?.backgroundCheckExempt === true,
);
expect(exemptQueryCalled).toBe(false);
});
});
27 changes: 26 additions & 1 deletion apps/api/src/frameworks/frameworks-people-score.helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ export async function computePeopleScore({
membersWithInstalledDevices,
trainingCompletions,
membersWithCompletedBackgroundChecks,
exemptMemberIds,
] = await Promise.all([
getMembersWithInstalledDevices({
organizationId,
Expand All @@ -75,6 +76,9 @@ export async function computePeopleScore({
backgroundCheckStepEnabled
? getMembersWithCompletedBackgroundChecks({ organizationId, memberIds })
: Promise.resolve(new Set<string>()),
backgroundCheckStepEnabled
? getExemptMemberIds({ organizationId, memberIds })
: Promise.resolve(new Set<string>()),
]);

const completed = activeEmployees.filter((employee) => {
Expand All @@ -101,7 +105,9 @@ export async function computePeopleScore({
const hasInstalledDevice = deviceAgentStepEnabled
? membersWithInstalledDevices.has(employee.id)
: true;
const hasCompletedBackgroundCheck = backgroundCheckStepEnabled
const memberRequiresBgCheck =
backgroundCheckStepEnabled && !exemptMemberIds.has(employee.id);
const hasCompletedBackgroundCheck = memberRequiresBgCheck
? membersWithCompletedBackgroundChecks.has(employee.id)
: true;

Expand Down Expand Up @@ -205,3 +211,22 @@ async function getMembersWithCompletedBackgroundChecks({

return new Set(completedBackgroundChecks.map((check) => check.memberId));
}

async function getExemptMemberIds({
organizationId,
memberIds,
}: {
organizationId: string;
memberIds: string[];
}) {
const exemptMembers = await db.member.findMany({
where: {
organizationId,
id: { in: memberIds },
backgroundCheckExempt: true,
},
select: { id: true },
});

return new Set(exemptMembers.map((member) => member.id));
}
1 change: 1 addition & 0 deletions apps/api/src/organization/dto/update-organization.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,5 @@ export interface UpdateOrganizationDto {
isFleetSetupCompleted?: boolean;
primaryColor?: string;
advancedModeEnabled?: boolean;
backgroundCheckStepEnabled?: boolean;
}
21 changes: 21 additions & 0 deletions apps/api/src/organization/organization.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
jest.mock('@db', () => ({
db: {},
}));

import { Test, TestingModule } from '@nestjs/testing';
import { BadRequestException } from '@nestjs/common';
import { OrganizationController } from './organization.controller';
Expand Down Expand Up @@ -169,6 +173,23 @@ describe('OrganizationController', () => {
});
});

describe('updateOrganization', () => {
it('passes backgroundCheckStepEnabled through to the service', async () => {
mockOrganizationService.updateById.mockResolvedValue({ id: 'org_123' });

await controller.updateOrganization(
'org_123',
sessionAuthContext,
{ backgroundCheckStepEnabled: false },
);

expect(mockOrganizationService.updateById).toHaveBeenCalledWith(
'org_123',
{ backgroundCheckStepEnabled: false },
);
});
});

describe('updateRoleNotifications', () => {
const validSettings = [
{
Expand Down
10 changes: 10 additions & 0 deletions apps/api/src/people/dto/update-people.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,4 +44,14 @@ export class UpdatePeopleDto extends PartialType(CreatePeopleDto) {
@IsOptional()
@IsDateString()
createdAt?: string;

@ApiProperty({
description:
'When true, this member is exempt from the org-level background check requirement and will count as complete in people scores.',
example: false,
required: false,
})
@IsOptional()
@IsBoolean()
backgroundCheckExempt?: boolean;
}
54 changes: 54 additions & 0 deletions apps/api/src/people/people.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,42 @@ import { PermissionGuard } from '../auth/permission.guard';
import { PeopleController } from './people.controller';
import { BadRequestException } from '@nestjs/common';

// Mock @db to avoid PrismaClient initialization in controller tests
jest.mock('@db', () => ({
db: {
member: {
findMany: jest.fn(),
findFirst: jest.fn(),
findFirstOrThrow: jest.fn(),
create: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
createMany: jest.fn(),
},
user: { update: jest.fn() },
organization: { findUnique: jest.fn() },
session: { deleteMany: jest.fn() },
task: { findMany: jest.fn(), updateMany: jest.fn() },
policy: { findMany: jest.fn(), updateMany: jest.fn() },
risk: { findMany: jest.fn(), updateMany: jest.fn() },
vendor: { findMany: jest.fn(), updateMany: jest.fn() },
organizationChart: { findUnique: jest.fn(), update: jest.fn() },
},
BackgroundCheckStatus: {
pending: 'pending',
in_progress: 'in_progress',
completed: 'completed',
completed_with_flags: 'completed_with_flags',
cancelled: 'cancelled',
},
FindingType: { soc2: 'soc2', iso27001: 'iso27001' },
FindingStatus: { open: 'open', closed: 'closed' },
PhaseCompletionType: { manual: 'manual', auto: 'auto' },
TimelinePhaseStatus: { pending: 'pending', completed: 'completed' },
TimelineStatus: { draft: 'draft', active: 'active' },
Departments: { it: 'it', none: 'none' },
}));

// Mock auth.server to avoid importing better-auth ESM in Jest
jest.mock('../auth/auth.server', () => ({
auth: {
Expand Down Expand Up @@ -220,6 +256,24 @@ describe('PeopleController', () => {
'usr_123',
);
});

it('passes backgroundCheckExempt through to the service', async () => {
mockPeopleService.updateById.mockResolvedValue({ id: 'mem_1' });

await controller.updateMember(
'mem_1',
{ backgroundCheckExempt: true } as any,
'org_123',
mockAuthContext,
);

expect(mockPeopleService.updateById).toHaveBeenCalledWith(
'mem_1',
'org_123',
{ backgroundCheckExempt: true },
'usr_123',
);
});
});

describe('deleteMember', () => {
Expand Down
1 change: 1 addition & 0 deletions apps/api/src/people/utils/member-queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export class MemberQueries {
jobTitle: true,
isActive: true,
deactivated: true,
backgroundCheckExempt: true,
fleetDmLabelId: true,
user: {
select: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ export default async function EmployeeBackgroundCheckPage({
}
}
backgroundCheckStepEnabled={backgroundCheckStepEnabled}
memberBackgroundCheckExempt={employee.backgroundCheckExempt === true}
/>
</PageLayout>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ interface EmployeeProps {
initialBackgroundCheck: BackgroundCheckRecord | null;
initialBackgroundCheckBillingStatus: BackgroundCheckBillingStatus;
backgroundCheckStepEnabled: boolean;
memberBackgroundCheckExempt: boolean;
}

export function Employee({
Expand All @@ -59,6 +60,7 @@ export function Employee({
initialBackgroundCheck,
initialBackgroundCheckBillingStatus,
backgroundCheckStepEnabled,
memberBackgroundCheckExempt,
}: EmployeeProps) {
const searchParams = useSearchParams();
const querySelectedTab: EmployeeTab =
Expand All @@ -67,6 +69,13 @@ export function Employee({
? 'background-check'
: 'details';
const [activeTab, setActiveTab] = useState<EmployeeTab>(querySelectedTab);
const [memberExempt, setMemberExempt] = useState(memberBackgroundCheckExempt);
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
const [lastSyncedExempt, setLastSyncedExempt] = useState(memberBackgroundCheckExempt);

if (memberBackgroundCheckExempt !== lastSyncedExempt) {
setLastSyncedExempt(memberBackgroundCheckExempt);
setMemberExempt(memberBackgroundCheckExempt);
}

useEffect(() => {
if (querySelectedTab === 'background-check') {
Expand All @@ -82,6 +91,7 @@ export function Employee({
orgId={orgId}
backgroundCheck={initialBackgroundCheck}
backgroundCheckStepEnabled={backgroundCheckStepEnabled}
memberBackgroundCheckExempt={memberExempt}
/>
}
>
Expand Down Expand Up @@ -140,6 +150,8 @@ export function Employee({
initialBackgroundCheck={initialBackgroundCheck}
initialBillingStatus={initialBackgroundCheckBillingStatus}
backgroundCheckStepEnabled={backgroundCheckStepEnabled}
memberBackgroundCheckExempt={memberExempt}
onMemberBackgroundCheckExemptChange={setMemberExempt}
/>
</TabsContent>
)}
Expand Down
Loading
Loading