From 6988e37e7ad65ca6b662782cc4a7631a40995c9e Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Fri, 15 May 2026 18:15:38 -0400 Subject: [PATCH 1/2] fix(background-check): persist exemption reason + justification on member MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Confirm exemption" was failing for every customer with a "Failed to confirm exemption" toast. Root cause: apps/api/src/main.ts sets `forbidNonWhitelisted: true` on the global ValidationPipe. The V1 frontend sends `backgroundCheckExempt` together with `backgroundCheckExemptReason` + `backgroundCheckExemptJustification`, but the latter two were never declared on UpdatePeopleDto — so the PATCH /v1/people/:id request was rejected with 400 before the service ever ran. This fix: 1. Adds nullable columns `backgroundCheckExemptReason` (varchar) and `backgroundCheckExemptJustification` (text) to the Member model (named prisma migrate dev migration — not hand-written SQL). 2. Whitelists both fields on UpdatePeopleDto as optional strings with sensible length caps (100 / 2000 chars). 3. Persists both fields on member.update; clears them to null when backgroundCheckExempt is set to false so a future re-exemption starts from a clean state. Audit log retains the prior values from the original exempt-true request via AuditLogInterceptor. 4. Adds 4 unit tests in member-queries.spec.ts covering: persist on exempt=true, clear on exempt=false, clear overrides incoming stale reason on exempt=false, untouched when patch omits exempt. Pre-existing typecheck failures in unrelated specs and a pre-existing people.service.spec runtime error were verified to also occur on the baseline (origin/main) before this change. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/api/src/people/dto/update-people.dto.ts | 23 +++++ .../src/people/utils/member-queries.spec.ts | 90 +++++++++++++++++++ apps/api/src/people/utils/member-queries.ts | 10 +++ .../migration.sql | 3 + packages/db/prisma/schema/auth.prisma | 2 + 5 files changed, 128 insertions(+) create mode 100644 apps/api/src/people/utils/member-queries.spec.ts create mode 100644 packages/db/prisma/migrations/20260515221108_add_background_check_exemption_reason_justification/migration.sql 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/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[] From ada894dbbfbbadf5fb57d96a804d03ab695a302d Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Fri, 15 May 2026 18:28:50 -0400 Subject: [PATCH 2/2] fix(app): add new exemption fields to createMockMember MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Prisma schema change in this branch added two non-optional (nullable) columns to Member — backgroundCheckExemptReason + backgroundCheckExemptJustification. The strict-typed test mock createMockMember returns Member, so the mock had to include both keys or the spread of overrides would surface them as undefined and fail the Vercel build: Type 'undefined' is not assignable to type 'string | null'. Initializing both to null in the default mock matches the DB default. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/app/src/test-utils/mocks/auth.ts | 2 ++ 1 file changed, 2 insertions(+) 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, });