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 a2348bf4d8..ee76cc0f2f 100644 --- a/apps/api/src/frameworks/frameworks-people-score.helper.spec.ts +++ b/apps/api/src/frameworks/frameworks-people-score.helper.spec.ts @@ -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 () => { @@ -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); + }); }); diff --git a/apps/api/src/frameworks/frameworks-people-score.helper.ts b/apps/api/src/frameworks/frameworks-people-score.helper.ts index e313edf517..427ff88da5 100644 --- a/apps/api/src/frameworks/frameworks-people-score.helper.ts +++ b/apps/api/src/frameworks/frameworks-people-score.helper.ts @@ -61,6 +61,7 @@ export async function computePeopleScore({ membersWithInstalledDevices, trainingCompletions, membersWithCompletedBackgroundChecks, + exemptMemberIds, ] = await Promise.all([ getMembersWithInstalledDevices({ organizationId, @@ -75,6 +76,9 @@ export async function computePeopleScore({ backgroundCheckStepEnabled ? getMembersWithCompletedBackgroundChecks({ organizationId, memberIds }) : Promise.resolve(new Set()), + backgroundCheckStepEnabled + ? getExemptMemberIds({ organizationId, memberIds }) + : Promise.resolve(new Set()), ]); const completed = activeEmployees.filter((employee) => { @@ -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; @@ -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)); +} diff --git a/apps/api/src/organization/dto/update-organization.dto.ts b/apps/api/src/organization/dto/update-organization.dto.ts index de0f3f6f73..18c301ffc0 100644 --- a/apps/api/src/organization/dto/update-organization.dto.ts +++ b/apps/api/src/organization/dto/update-organization.dto.ts @@ -10,4 +10,5 @@ export interface UpdateOrganizationDto { isFleetSetupCompleted?: boolean; primaryColor?: string; advancedModeEnabled?: boolean; + backgroundCheckStepEnabled?: boolean; } diff --git a/apps/api/src/organization/organization.controller.spec.ts b/apps/api/src/organization/organization.controller.spec.ts index 8f89a27b33..7e44303b07 100644 --- a/apps/api/src/organization/organization.controller.spec.ts +++ b/apps/api/src/organization/organization.controller.spec.ts @@ -1,3 +1,7 @@ +jest.mock('@db', () => ({ + db: {}, +})); + import { Test, TestingModule } from '@nestjs/testing'; import { BadRequestException } from '@nestjs/common'; import { OrganizationController } from './organization.controller'; @@ -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 = [ { diff --git a/apps/api/src/people/dto/update-people.dto.ts b/apps/api/src/people/dto/update-people.dto.ts index 8c5c53f070..951118b401 100644 --- a/apps/api/src/people/dto/update-people.dto.ts +++ b/apps/api/src/people/dto/update-people.dto.ts @@ -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; } diff --git a/apps/api/src/people/people.controller.spec.ts b/apps/api/src/people/people.controller.spec.ts index d418cd8be5..e93c62580d 100644 --- a/apps/api/src/people/people.controller.spec.ts +++ b/apps/api/src/people/people.controller.spec.ts @@ -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: { @@ -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', () => { diff --git a/apps/api/src/people/utils/member-queries.ts b/apps/api/src/people/utils/member-queries.ts index 92ab728914..05ced2524c 100644 --- a/apps/api/src/people/utils/member-queries.ts +++ b/apps/api/src/people/utils/member-queries.ts @@ -20,6 +20,7 @@ export class MemberQueries { jobTitle: true, isActive: true, deactivated: true, + backgroundCheckExempt: true, fleetDmLabelId: true, user: { select: { diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/background-check/page.tsx b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/background-check/page.tsx index 1d67176648..a951990f36 100644 --- a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/background-check/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/background-check/page.tsx @@ -59,6 +59,7 @@ export default async function EmployeeBackgroundCheckPage({ } } backgroundCheckStepEnabled={backgroundCheckStepEnabled} + memberBackgroundCheckExempt={employee.backgroundCheckExempt === 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 7cf635b1f4..1a0e8e19f4 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 @@ -42,6 +42,7 @@ interface EmployeeProps { initialBackgroundCheck: BackgroundCheckRecord | null; initialBackgroundCheckBillingStatus: BackgroundCheckBillingStatus; backgroundCheckStepEnabled: boolean; + memberBackgroundCheckExempt: boolean; } export function Employee({ @@ -59,6 +60,7 @@ export function Employee({ initialBackgroundCheck, initialBackgroundCheckBillingStatus, backgroundCheckStepEnabled, + memberBackgroundCheckExempt, }: EmployeeProps) { const searchParams = useSearchParams(); const querySelectedTab: EmployeeTab = @@ -67,6 +69,13 @@ export function Employee({ ? 'background-check' : 'details'; const [activeTab, setActiveTab] = useState(querySelectedTab); + const [memberExempt, setMemberExempt] = useState(memberBackgroundCheckExempt); + const [lastSyncedExempt, setLastSyncedExempt] = useState(memberBackgroundCheckExempt); + + if (memberBackgroundCheckExempt !== lastSyncedExempt) { + setLastSyncedExempt(memberBackgroundCheckExempt); + setMemberExempt(memberBackgroundCheckExempt); + } useEffect(() => { if (querySelectedTab === 'background-check') { @@ -82,6 +91,7 @@ export function Employee({ orgId={orgId} backgroundCheck={initialBackgroundCheck} backgroundCheckStepEnabled={backgroundCheckStepEnabled} + memberBackgroundCheckExempt={memberExempt} /> } > @@ -140,6 +150,8 @@ export function Employee({ initialBackgroundCheck={initialBackgroundCheck} initialBillingStatus={initialBackgroundCheckBillingStatus} backgroundCheckStepEnabled={backgroundCheckStepEnabled} + memberBackgroundCheckExempt={memberExempt} + onMemberBackgroundCheckExemptChange={setMemberExempt} /> )} diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeBackgroundCheck.test.tsx b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeBackgroundCheck.test.tsx index aec2ae0918..4e1d530962 100644 --- a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeBackgroundCheck.test.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeBackgroundCheck.test.tsx @@ -25,6 +25,7 @@ vi.mock('@/lib/api-client', () => ({ apiClient: { get: vi.fn(), post: vi.fn(), + patch: vi.fn(), }, })); @@ -84,6 +85,7 @@ function renderSection(props?: Partial , @@ -308,6 +310,7 @@ describe('EmployeeBackgroundCheck', () => { initialBackgroundCheck={null} initialBillingStatus={{ hasPaymentMethod: false, setupAt: null }} backgroundCheckStepEnabled={false} + memberBackgroundCheckExempt={false} />, ); @@ -323,6 +326,7 @@ describe('EmployeeBackgroundCheck', () => { initialBackgroundCheck={null} initialBillingStatus={{ hasPaymentMethod: false, setupAt: null }} backgroundCheckStepEnabled={false} + memberBackgroundCheckExempt={false} />, ); @@ -332,4 +336,137 @@ describe('EmployeeBackgroundCheck', () => { expect(apiClient.get).not.toHaveBeenCalled(); }); + + it('renders the exempt info card when memberBackgroundCheckExempt is true', () => { + render( + , + ); + + expect( + screen.getByText(/this employee is exempt/i), + ).toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: /get started/i }), + ).not.toBeInTheDocument(); + }); + + it('prefers the org-level bypass over per-member exempt when both are set', () => { + render( + , + ); + + // Org-level bypass card wins; per-member exempt toggle should not appear. + expect( + screen.getByText(/background checks are disabled for your organization/i), + ).toBeInTheDocument(); + expect( + screen.queryByRole('switch', { name: /exempt this employee/i }), + ).not.toBeInTheDocument(); + }); + + it('calls onMemberBackgroundCheckExemptChange when toggled (controlled mode)', async () => { + const onChange = vi.fn(); + vi.mocked(apiClient.patch).mockResolvedValue({ data: { id: 'mem_1' }, status: 200 }); + const user = userEvent.setup(); + + render( + , + ); + + const toggle = screen.getByRole('switch', { + name: /exempt this employee/i, + }); + await user.click(toggle); + + await waitFor(() => { + expect(onChange).toHaveBeenCalledWith(true); + }); + }); + + it('toggles exempt on and PATCHes /v1/people/:id', async () => { + const user = userEvent.setup(); + vi.mocked(apiClient.patch).mockResolvedValue({ data: { id: 'mem_1' }, status: 200 }); + + render( + , + ); + + const toggle = screen.getByRole('switch', { + name: /exempt this employee/i, + }); + await user.click(toggle); + + await waitFor(() => { + expect(apiClient.patch).toHaveBeenCalledWith( + '/v1/people/mem_1', + { backgroundCheckExempt: true }, + 'org_1', + ); + }); + }); + + it('resyncs internal exempt state when the prop changes (uncontrolled mode)', () => { + const { rerender } = render( + , + ); + + // Wizard is rendered (member is not exempt) + expect( + screen.getByRole('switch', { name: /exempt this employee/i }), + ).toBeInTheDocument(); + expect(screen.queryByText(/this employee is exempt/i)).not.toBeInTheDocument(); + + // Parent re-renders with the prop flipped + rerender( + , + ); + + // Exempt info card is now rendered + expect( + screen.getByText(/this employee is exempt/i), + ).toBeInTheDocument(); + }); }); diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeBackgroundCheck.tsx b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeBackgroundCheck.tsx index f2d31e3fed..06fc84e578 100644 --- a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeBackgroundCheck.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeBackgroundCheck.tsx @@ -4,7 +4,7 @@ import { usePermissions } from '@/hooks/use-permissions'; import { apiClient } from '@/lib/api-client'; import type { Member, User } from '@db'; import { zodResolver } from '@hookform/resolvers/zod'; -import { Text } from '@trycompai/design-system'; +import { Stack, Switch, Text } from '@trycompai/design-system'; import { Information } from '@trycompai/design-system/icons'; import { usePathname, useRouter, useSearchParams } from 'next/navigation'; import { useCallback, useEffect, useRef, useState } from 'react'; @@ -35,6 +35,8 @@ interface EmployeeBackgroundCheckProps { initialBackgroundCheck: BackgroundCheckRecord | null; initialBillingStatus: BackgroundCheckBillingStatus; backgroundCheckStepEnabled: boolean; + memberBackgroundCheckExempt: boolean; + onMemberBackgroundCheckExemptChange?: (next: boolean) => void; } export function EmployeeBackgroundCheck({ @@ -43,6 +45,8 @@ export function EmployeeBackgroundCheck({ initialBackgroundCheck, initialBillingStatus, backgroundCheckStepEnabled, + memberBackgroundCheckExempt, + onMemberBackgroundCheckExemptChange, }: EmployeeBackgroundCheckProps) { const router = useRouter(); const pathname = usePathname(); @@ -57,6 +61,23 @@ export function EmployeeBackgroundCheck({ const [billingSetupComplete, setBillingSetupComplete] = useState(false); const [paymentIssue, setPaymentIssue] = useState(null); const [requestConfirmation, setRequestConfirmation] = useState(null); + const [internalExempt, setInternalExempt] = useState(memberBackgroundCheckExempt); + const [lastSyncedExempt, setLastSyncedExempt] = useState(memberBackgroundCheckExempt); + + if (memberBackgroundCheckExempt !== lastSyncedExempt) { + setLastSyncedExempt(memberBackgroundCheckExempt); + setInternalExempt(memberBackgroundCheckExempt); + } + const isExemptControlled = onMemberBackgroundCheckExemptChange !== undefined; + const exempt = isExemptControlled ? memberBackgroundCheckExempt : internalExempt; + const setExempt = (next: boolean) => { + if (isExemptControlled) { + onMemberBackgroundCheckExemptChange(next); + } else { + setInternalExempt(next); + } + }; + const [savingExempt, setSavingExempt] = useState(false); const { hasPermission } = usePermissions(); const { data: backgroundCheck, mutate: mutateBackgroundCheck } = useBackgroundCheckRecord({ @@ -225,6 +246,30 @@ export function EmployeeBackgroundCheck({ setIsOpeningBilling(false); }; + const handleToggleExempt = async (next: boolean) => { + const previous = exempt; + setExempt(next); + setSavingExempt(true); + + const res = await apiClient.patch( + `/v1/people/${employee.id}`, + { backgroundCheckExempt: next }, + organizationId, + ); + + setSavingExempt(false); + + if (res.error) { + setExempt(previous); + toast.error('Failed to update exempt status'); + return; + } + + toast.success( + next ? 'Employee exempted from background check' : 'Employee no longer exempt', + ); + }; + const handleComplete = async (values: BackgroundCheckFormValues) => { if (!hasBackgroundCheckAllowance) { writePendingRequest(values); @@ -244,72 +289,143 @@ export function EmployeeBackgroundCheck({
Background checks are not required for your organization - Comp AI support disabled this requirement. Existing background-check requests, if any, - remain accessible from your billing portal. + Background checks are disabled for your organization. This can be changed in People + > Settings. Existing background-check requests, if any, remain accessible from your + billing portal.
); } + if (exempt) { + return ( + + +
+ + + +
+ This employee is exempt from background checks + + Toggle off above to require this employee to complete a background check. + +
+
+
+ ); + } + if (backgroundCheck) { return ( - + + + + ); } return ( - <> - {visibleWizardStep === 'overview' && ( - + + <> + {visibleWizardStep === 'overview' && ( + setWizardStep('details')} + onOpenBilling={() => + router.push(`/${organizationId}/settings/billing/add-ons/background-checks`) + } + /> + )} + {visibleWizardStep === 'details' && ( + setWizardStep('overview')} + onSubmit={handleComplete} + /> + )} + setWizardStep('details')} - onOpenBilling={() => - router.push(`/${organizationId}/settings/billing/add-ons/background-checks`) - } + issue={paymentIssue} + open={paymentIssue !== null} + onOpenChange={(open) => !open && setPaymentIssue(null)} + onUpdatePaymentMethod={() => void handleOpenBilling(form.getValues())} /> - )} - {visibleWizardStep === 'details' && ( - setWizardStep('overview')} - onSubmit={handleComplete} + employeeEmail={employee.user.email} + employeeId={employee.id} + employeeName={employee.user.name ?? employee.user.email} + organizationId={organizationId} + onUploaded={async (uploadedBackgroundCheck) => { + await mutateBackgroundCheck(uploadedBackgroundCheck, { revalidate: false }); + }} /> - )} - !open && setPaymentIssue(null)} - onUpdatePaymentMethod={() => void handleOpenBilling(form.getValues())} - /> - { - await mutateBackgroundCheck(uploadedBackgroundCheck, { revalidate: false }); - }} + + + ); +} + +function ExemptToggleCard({ + exempt, + saving, + canUpdate, + onToggle, +}: { + exempt: boolean; + saving: boolean; + canUpdate: boolean; + onToggle: (next: boolean) => void; +}) { + return ( +
+
+ Exempt this employee from background check + + When on, this employee won't be required to pass a background check to count toward + people completion. + +
+ - +
); } diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeePageHeader.test.tsx b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeePageHeader.test.tsx index 7874eb08fc..a15ad2095d 100644 --- a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeePageHeader.test.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeePageHeader.test.tsx @@ -28,6 +28,7 @@ describe('EmployeePageHeader', () => { orgId="org_123" backgroundCheck={backgroundCheck} backgroundCheckStepEnabled={true} + memberBackgroundCheckExempt={false} />, ); @@ -42,6 +43,7 @@ describe('EmployeePageHeader', () => { orgId="org_123" backgroundCheck={{ ...backgroundCheck, status: 'invited' }} backgroundCheckStepEnabled={true} + memberBackgroundCheckExempt={false} />, ); @@ -55,10 +57,28 @@ describe('EmployeePageHeader', () => { orgId="org_123" backgroundCheck={backgroundCheck} backgroundCheckStepEnabled={false} + memberBackgroundCheckExempt={false} />, ); expect(screen.getByRole('heading', { name: 'Jane Doe' })).toBeInTheDocument(); expect(screen.queryByLabelText('Employee has completed a background check')).not.toBeInTheDocument(); }); + + it('hides the verified tick when the member is exempt', () => { + render( + , + ); + + expect(screen.getByRole('heading', { name: 'Jane Doe' })).toBeInTheDocument(); + expect( + screen.queryByLabelText('Employee has completed a background check'), + ).not.toBeInTheDocument(); + }); }); diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeePageHeader.tsx b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeePageHeader.tsx index 2b797254be..dd18956804 100644 --- a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeePageHeader.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeePageHeader.tsx @@ -10,11 +10,13 @@ export function EmployeePageHeader({ orgId, backgroundCheck, backgroundCheckStepEnabled, + memberBackgroundCheckExempt, }: { employeeName: string; orgId: string; backgroundCheck: BackgroundCheckRecord | null; backgroundCheckStepEnabled: boolean; + memberBackgroundCheckExempt: boolean; }) { const isVerified = backgroundCheck ? isCompletedBackgroundCheck(backgroundCheck.status) @@ -33,7 +35,7 @@ export function EmployeePageHeader({
{employeeName} - {backgroundCheckStepEnabled && isVerified && ( + {backgroundCheckStepEnabled && !memberBackgroundCheckExempt && isVerified && ( )}
diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/page.tsx b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/page.tsx index 216d8dd117..80960daa74 100644 --- a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/page.tsx @@ -106,6 +106,7 @@ export default async function EmployeeDetailsPage({ } } backgroundCheckStepEnabled={organization.backgroundCheckStepEnabled === true} + memberBackgroundCheckExempt={employee.backgroundCheckExempt === true} /> ); } diff --git a/apps/app/src/app/(app)/[orgId]/people/all/components/MemberRow.test.tsx b/apps/app/src/app/(app)/[orgId]/people/all/components/MemberRow.test.tsx index d56c9de68c..0dd6c16e15 100644 --- a/apps/app/src/app/(app)/[orgId]/people/all/components/MemberRow.test.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/all/components/MemberRow.test.tsx @@ -245,4 +245,37 @@ describe('MemberRow device status', () => { screen.queryByLabelText('Employee has completed a background check'), ).not.toBeInTheDocument(); }); + + it('hides the background-check counter and verified tick for an exempt member', () => { + render( + + + + +
, + ); + + expect(screen.queryByText(/background check/i)).not.toBeInTheDocument(); + expect( + screen.queryByLabelText('Employee has completed a background check'), + ).not.toBeInTheDocument(); + }); }); diff --git a/apps/app/src/app/(app)/[orgId]/people/all/components/MemberRow.tsx b/apps/app/src/app/(app)/[orgId]/people/all/components/MemberRow.tsx index c54b0d9f1d..0f52dff877 100644 --- a/apps/app/src/app/(app)/[orgId]/people/all/components/MemberRow.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/all/components/MemberRow.tsx @@ -149,6 +149,7 @@ export function MemberRow({ const isDeactivated = member.deactivated || !member.isActive; const profileHref = `/${orgId}/people/${memberId}`; const hasCompletedBackgroundCheck = isBackgroundCheckComplete(backgroundCheckStatus); + const memberExempt = member.backgroundCheckExempt === true; const shouldShowTaskRequirements = !isPlatformAdmin && !isDeactivated; const taskItems: TaskCountItem[] = []; @@ -184,7 +185,7 @@ export function MemberRow({ }); } - if (shouldShowTaskRequirements && backgroundCheckStepEnabled) { + if (shouldShowTaskRequirements && backgroundCheckStepEnabled && !memberExempt) { taskItems.push({ label: 'Background check', completed: hasCompletedBackgroundCheck ? 1 : 0, @@ -274,7 +275,7 @@ export function MemberRow({ > {memberName} - {backgroundCheckStepEnabled && hasCompletedBackgroundCheck && ( + {backgroundCheckStepEnabled && !memberExempt && hasCompletedBackgroundCheck && ( )} diff --git a/apps/app/src/app/(app)/[orgId]/people/components/PeoplePageTabs.tsx b/apps/app/src/app/(app)/[orgId]/people/components/PeoplePageTabs.tsx index 562f4315c2..82523307d8 100644 --- a/apps/app/src/app/(app)/[orgId]/people/components/PeoplePageTabs.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/components/PeoplePageTabs.tsx @@ -24,6 +24,8 @@ interface PeoplePageTabsProps { roleMappingContent: ReactNode | null; showRoleMapping: boolean; showEmployeeTasks: boolean; + settingsContent: ReactNode | null; + showSettings: boolean; canInviteUsers: boolean; canManageMembers: boolean; organizationId: string; @@ -34,6 +36,7 @@ function tabParamToInternal( tabParam: string | null, showEmployeeTasks: boolean, showRoleMapping: boolean, + showSettings: boolean, ): string { if (!tabParam || tabParam === 'people') { return 'people'; @@ -50,6 +53,9 @@ function tabParamToInternal( if (tabParam === 'role-mapping') { return showRoleMapping ? 'role-mapping' : 'people'; } + if (tabParam === 'settings') { + return showSettings ? 'settings' : 'people'; + } return 'people'; } @@ -63,6 +69,7 @@ function internalValueToTabParam(value: string): string { case 'people': case 'devices': case 'role-mapping': + case 'settings': return value; default: return 'people'; @@ -78,6 +85,8 @@ export function PeoplePageTabs({ roleMappingContent, showRoleMapping, showEmployeeTasks, + settingsContent, + showSettings, canInviteUsers, canManageMembers, organizationId, @@ -91,6 +100,7 @@ export function PeoplePageTabs({ searchParams.get('tab'), showEmployeeTasks, showRoleMapping, + showSettings, ); const handleTabChange = useCallback( @@ -121,6 +131,7 @@ export function PeoplePageTabs({ Devices Chart {showRoleMapping && Role Mapping} + {showSettings && Settings} } actions={ @@ -144,6 +155,9 @@ export function PeoplePageTabs({ {showRoleMapping && ( {roleMappingContent} )} + {showSettings && ( + {settingsContent} + )} }) { const { orgId } = await params; @@ -34,6 +37,23 @@ export default async function PeoplePage({ params }: { params: Promise<{ orgId: const canInviteUsers = canManageMembers || isAuditor; const isCurrentUserOwner = currentUserRoles.includes('owner'); + const userPermissions = await resolveUserPermissions( + currentUserMember?.role ?? null, + orgId, + ); + const canManageOrgSettings = hasPermission( + userPermissions, + 'organization', + 'update', + ); + + const organization = canManageOrgSettings + ? await db.organization.findUnique({ + where: { id: orgId }, + select: { backgroundCheckStepEnabled: true }, + }) + : null; + return ( + ) : null + } showEmployeeTasks canInviteUsers={canInviteUsers} canManageMembers={canManageMembers} diff --git a/apps/app/src/app/(app)/[orgId]/people/settings/components/PeopleSettings.test.tsx b/apps/app/src/app/(app)/[orgId]/people/settings/components/PeopleSettings.test.tsx new file mode 100644 index 0000000000..eabae8cf65 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/people/settings/components/PeopleSettings.test.tsx @@ -0,0 +1,62 @@ +import '@testing-library/jest-dom/vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { PeopleSettings } from './PeopleSettings'; + +const patchMock = vi.fn(); +vi.mock('@/lib/api-client', () => ({ + apiClient: { + patch: (...args: unknown[]) => patchMock(...args), + }, +})); +vi.mock('@/hooks/use-permissions', () => ({ + usePermissions: () => ({ hasPermission: () => true }), +})); + +describe('PeopleSettings — background-check toggle', () => { + beforeEach(() => { + patchMock.mockReset(); + patchMock.mockResolvedValue({ data: { success: true } }); + }); + + it('shows the toggle in its current state', () => { + render(); + const toggle = screen.getByRole('switch', { + name: /require background checks/i, + }); + expect(toggle).toBeChecked(); + }); + + it('toggles off and PATCHes /v1/organization', async () => { + const user = userEvent.setup(); + render(); + const toggle = screen.getByRole('switch', { + name: /require background checks/i, + }); + + await user.click(toggle); + + await waitFor(() => { + expect(patchMock).toHaveBeenCalledWith('/v1/organization', { + backgroundCheckStepEnabled: false, + }); + }); + }); + + it('rolls back to checked state when PATCH fails', async () => { + patchMock.mockResolvedValue({ error: 'server error' }); + const user = userEvent.setup(); + render(); + const toggle = screen.getByRole('switch', { + name: /require background checks/i, + }); + + await user.click(toggle); + + await waitFor(() => { + expect(toggle).toBeChecked(); + }); + }); +}); diff --git a/apps/app/src/app/(app)/[orgId]/people/settings/components/PeopleSettings.tsx b/apps/app/src/app/(app)/[orgId]/people/settings/components/PeopleSettings.tsx new file mode 100644 index 0000000000..390b4b8c36 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/people/settings/components/PeopleSettings.tsx @@ -0,0 +1,68 @@ +'use client'; + +import { useState } from 'react'; +import { toast } from 'sonner'; +import { usePermissions } from '@/hooks/use-permissions'; +import { apiClient } from '@/lib/api-client'; +import { Section, Stack, Switch, Text } from '@trycompai/design-system'; + +interface PeopleSettingsProps { + backgroundCheckStepEnabled: boolean; +} + +export function PeopleSettings({ + backgroundCheckStepEnabled: initialEnabled, +}: PeopleSettingsProps) { + const { hasPermission } = usePermissions(); + const canUpdate = hasPermission('organization', 'update'); + + const [enabled, setEnabled] = useState(initialEnabled); + const [saving, setSaving] = useState(false); + + const handleToggle = async (next: boolean) => { + const previous = enabled; + setEnabled(next); + setSaving(true); + + const res = await apiClient.patch('/v1/organization', { + backgroundCheckStepEnabled: next, + }); + + setSaving(false); + + if (res.error) { + setEnabled(previous); + toast.error('Failed to update background check setting'); + return; + } + + toast.success( + next + ? 'Background checks now required' + : 'Background checks bypassed for your organization', + ); + }; + + return ( + +
+
+
+ Require background checks + + When off, your organization's members do not need to pass a + background check to count toward people completion. Individual + members can also be exempted from their profile. + +
+ +
+
+
+ ); +} diff --git a/apps/app/src/test-utils/mocks/auth.ts b/apps/app/src/test-utils/mocks/auth.ts index 2df6690459..135b192afc 100644 --- a/apps/app/src/test-utils/mocks/auth.ts +++ b/apps/app/src/test-utils/mocks/auth.ts @@ -82,6 +82,7 @@ export const createMockMember = (overrides?: Partial): Member => ({ deactivated: false, externalUserId: null, externalUserSource: null, + backgroundCheckExempt: false, ...overrides, }); diff --git a/apps/app/vitest.config.mts b/apps/app/vitest.config.mts index 3bd809780b..8154e80599 100644 --- a/apps/app/vitest.config.mts +++ b/apps/app/vitest.config.mts @@ -29,6 +29,7 @@ export default defineConfig({ resolve: { alias: { '@': resolve(__dirname, './src'), + '@trycompai/billing': resolve(__dirname, '../../packages/billing/src/index.ts'), }, }, }); diff --git a/packages/db/prisma/migrations/20260505091858_add_member_background_check_exempt/migration.sql b/packages/db/prisma/migrations/20260505091858_add_member_background_check_exempt/migration.sql new file mode 100644 index 0000000000..7400b270ee --- /dev/null +++ b/packages/db/prisma/migrations/20260505091858_add_member_background_check_exempt/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Member" ADD COLUMN "backgroundCheckExempt" BOOLEAN NOT NULL DEFAULT false; diff --git a/packages/db/prisma/schema/auth.prisma b/packages/db/prisma/schema/auth.prisma index 21af680245..801083b868 100644 --- a/packages/db/prisma/schema/auth.prisma +++ b/packages/db/prisma/schema/auth.prisma @@ -118,6 +118,7 @@ model Member { jobTitle String? isActive Boolean @default(true) deactivated Boolean @default(false) + backgroundCheckExempt Boolean @default(false) externalUserId String? externalUserSource String? employeeTrainingVideoCompletion EmployeeTrainingVideoCompletion[]