diff --git a/apps/api/src/people/dto/update-people.dto.ts b/apps/api/src/people/dto/update-people.dto.ts index 951118b401..8987919ea8 100644 --- a/apps/api/src/people/dto/update-people.dto.ts +++ b/apps/api/src/people/dto/update-people.dto.ts @@ -5,6 +5,7 @@ import { IsString, IsEmail, IsDateString, + MaxLength, } from 'class-validator'; import { CreatePeopleDto } from './create-people.dto'; @@ -54,4 +55,26 @@ export class UpdatePeopleDto extends PartialType(CreatePeopleDto) { @IsOptional() @IsBoolean() backgroundCheckExempt?: boolean; + + @ApiProperty({ + description: + 'Reason code for the exemption (e.g. "contractor_with_vendor_check", "other"). Persisted alongside backgroundCheckExempt and cleared when the member becomes non-exempt.', + example: 'other', + required: false, + }) + @IsOptional() + @IsString() + @MaxLength(100) + backgroundCheckExemptReason?: string; + + @ApiProperty({ + description: + 'Free-text justification for the exemption, attached to the audit log. Cleared when the member becomes non-exempt.', + example: 'Contractor with existing background check on file from staffing agency.', + required: false, + }) + @IsOptional() + @IsString() + @MaxLength(2000) + backgroundCheckExemptJustification?: string; } diff --git a/apps/api/src/people/utils/member-queries.spec.ts b/apps/api/src/people/utils/member-queries.spec.ts new file mode 100644 index 0000000000..90f53ba7a3 --- /dev/null +++ b/apps/api/src/people/utils/member-queries.spec.ts @@ -0,0 +1,90 @@ +import { MemberQueries } from './member-queries'; + +jest.mock('@db', () => ({ + db: { + member: { + update: jest.fn(), + }, + user: { + update: jest.fn(), + }, + }, +})); + +import { db } from '@db'; + +const mockedDb = db as jest.Mocked; + +describe('MemberQueries.updateMember — background-check exemption fields', () => { + beforeEach(() => { + jest.clearAllMocks(); + (mockedDb.member.update as jest.Mock).mockResolvedValue({ id: 'mem_1' }); + }); + + it('persists reason and justification when backgroundCheckExempt is true', async () => { + await MemberQueries.updateMember('mem_1', 'org_1', { + backgroundCheckExempt: true, + backgroundCheckExemptReason: 'other', + backgroundCheckExemptJustification: 'Founder', + }); + + expect(mockedDb.member.update).toHaveBeenCalledTimes(1); + expect(mockedDb.member.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'mem_1', organizationId: 'org_1' }, + data: expect.objectContaining({ + backgroundCheckExempt: true, + backgroundCheckExemptReason: 'other', + backgroundCheckExemptJustification: 'Founder', + }), + }), + ); + }); + + it('clears reason and justification when backgroundCheckExempt is set to false', async () => { + await MemberQueries.updateMember('mem_1', 'org_1', { + backgroundCheckExempt: false, + }); + + expect(mockedDb.member.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + backgroundCheckExempt: false, + backgroundCheckExemptReason: null, + backgroundCheckExemptJustification: null, + }), + }), + ); + }); + + it('overrides incoming reason/justification when un-exempting', async () => { + // Defensive: if a client sends contradictory data, false wins — + // an un-exempt request must not retain stale reason text. + await MemberQueries.updateMember('mem_1', 'org_1', { + backgroundCheckExempt: false, + backgroundCheckExemptReason: 'stale_reason', + backgroundCheckExemptJustification: 'stale text', + }); + + expect(mockedDb.member.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + backgroundCheckExempt: false, + backgroundCheckExemptReason: null, + backgroundCheckExemptJustification: null, + }), + }), + ); + }); + + it('does not touch reason or justification when the patch omits backgroundCheckExempt', async () => { + await MemberQueries.updateMember('mem_1', 'org_1', { + jobTitle: 'Engineer', + }); + + expect(mockedDb.member.update).toHaveBeenCalledTimes(1); + const call = (mockedDb.member.update as jest.Mock).mock.calls[0][0]; + expect(call.data).not.toHaveProperty('backgroundCheckExemptReason'); + expect(call.data).not.toHaveProperty('backgroundCheckExemptJustification'); + }); +}); diff --git a/apps/api/src/people/utils/member-queries.ts b/apps/api/src/people/utils/member-queries.ts index 05ced2524c..da7ecc92fa 100644 --- a/apps/api/src/people/utils/member-queries.ts +++ b/apps/api/src/people/utils/member-queries.ts @@ -21,6 +21,8 @@ export class MemberQueries { isActive: true, deactivated: true, backgroundCheckExempt: true, + backgroundCheckExemptReason: true, + backgroundCheckExemptJustification: true, fleetDmLabelId: true, user: { select: { @@ -128,6 +130,14 @@ export class MemberQueries { updatePayload.fleetDmLabelId = null; } + // Un-exempting clears reason + justification so a future re-exemption + // starts from a clean state. The audit log retains the prior values + // from the original exempt-true request. + if (updatePayload.backgroundCheckExempt === false) { + updatePayload.backgroundCheckExemptReason = null; + updatePayload.backgroundCheckExemptJustification = null; + } + const hasUserUpdates = name !== undefined || email !== undefined; const hasMemberUpdates = Object.keys(updatePayload).length > 0; diff --git a/apps/app/src/test-utils/mocks/auth.ts b/apps/app/src/test-utils/mocks/auth.ts index 135b192afc..0a3a89c5d4 100644 --- a/apps/app/src/test-utils/mocks/auth.ts +++ b/apps/app/src/test-utils/mocks/auth.ts @@ -83,6 +83,8 @@ export const createMockMember = (overrides?: Partial): Member => ({ externalUserId: null, externalUserSource: null, backgroundCheckExempt: false, + backgroundCheckExemptReason: null, + backgroundCheckExemptJustification: null, ...overrides, }); diff --git a/packages/db/prisma/migrations/20260515221108_add_background_check_exemption_reason_justification/migration.sql b/packages/db/prisma/migrations/20260515221108_add_background_check_exemption_reason_justification/migration.sql new file mode 100644 index 0000000000..a1f9e91e38 --- /dev/null +++ b/packages/db/prisma/migrations/20260515221108_add_background_check_exemption_reason_justification/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "Member" ADD COLUMN "backgroundCheckExemptJustification" TEXT, +ADD COLUMN "backgroundCheckExemptReason" TEXT; diff --git a/packages/db/prisma/schema/auth.prisma b/packages/db/prisma/schema/auth.prisma index 801083b868..956015bd93 100644 --- a/packages/db/prisma/schema/auth.prisma +++ b/packages/db/prisma/schema/auth.prisma @@ -119,6 +119,8 @@ model Member { isActive Boolean @default(true) deactivated Boolean @default(false) backgroundCheckExempt Boolean @default(false) + backgroundCheckExemptReason String? + backgroundCheckExemptJustification String? @db.Text externalUserId String? externalUserSource String? employeeTrainingVideoCompletion EmployeeTrainingVideoCompletion[]