diff --git a/apps/api/src/trigger/background-checks/reconcile-background-checks-schedule.spec.ts b/apps/api/src/trigger/background-checks/reconcile-background-checks-schedule.spec.ts new file mode 100644 index 000000000..0de863170 --- /dev/null +++ b/apps/api/src/trigger/background-checks/reconcile-background-checks-schedule.spec.ts @@ -0,0 +1,235 @@ +import { db } from '@db'; +import { + parseIdentityCheckState, + runReconciliation, +} from './reconcile-background-checks-schedule'; + +// Mock @db at the module boundary so importing the task does not connect to +// Postgres. +jest.mock('@db', () => ({ + db: { + backgroundCheckRequest: { findMany: jest.fn(), updateMany: jest.fn() }, + }, + BackgroundCheckStatus: { + invited: 'invited', + in_progress: 'in_progress', + in_review: 'in_review', + completed: 'completed', + completed_with_flags: 'completed_with_flags', + failed: 'failed', + cancelled: 'cancelled', + }, + Prisma: {}, +})); + +jest.mock('@trigger.dev/sdk', () => ({ + logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() }, + schedules: { task: (config: unknown) => config }, +})); + +const mockGetBackgroundCheck = jest.fn(); +jest.mock('../../background-checks/background-check-identity.client', () => ({ + BackgroundCheckIdentityClient: jest.fn().mockImplementation(() => ({ + getBackgroundCheck: mockGetBackgroundCheck, + })), +})); + +const mockFetchSnapshot = jest.fn(); +jest.mock('../../background-checks/background-check-report-snapshot', () => ({ + fetchCompletedReportSnapshot: (...args: unknown[]) => + mockFetchSnapshot(...args), +})); + +const mockedDb = db as jest.Mocked; +const findMany = mockedDb.backgroundCheckRequest.findMany as jest.Mock; +const updateMany = mockedDb.backgroundCheckRequest.updateMany as jest.Mock; + +const NON_TERMINAL = ['invited', 'in_progress', 'in_review']; + +describe('parseIdentityCheckState', () => { + it('extracts status and sub-statuses from a well-formed response', () => { + const result = parseIdentityCheckState({ + status: 'completed', + statuses: { identity: 'passed', employment: 'verified' }, + }); + expect(result.status).toBe('completed'); + expect(result.statuses).toEqual({ + identity: 'passed', + employment: 'verified', + }); + }); + + it('returns no status when the field is absent', () => { + expect(parseIdentityCheckState({ id: 'check_1' }).status).toBeUndefined(); + }); + + it('returns no status when the value is not a known status', () => { + expect( + parseIdentityCheckState({ status: 'totally_made_up' }).status, + ).toBeUndefined(); + }); + + it('keeps a valid status even when the statuses object is malformed', () => { + const garbage = parseIdentityCheckState({ + status: 'completed', + statuses: 'not-an-object', + }); + expect(garbage.status).toBe('completed'); + expect(garbage.statuses).toBeUndefined(); + + const badField = parseIdentityCheckState({ + status: 'in_review', + statuses: { identity: 123 }, + }); + expect(badField.status).toBe('in_review'); + expect(badField.statuses).toBeUndefined(); + }); + + it('returns nothing for non-object input', () => { + expect(parseIdentityCheckState(null).status).toBeUndefined(); + expect(parseIdentityCheckState('nope').status).toBeUndefined(); + }); +}); + +describe('runReconciliation', () => { + const originalEnv = { ...process.env }; + + beforeEach(() => { + jest.clearAllMocks(); + process.env = { ...originalEnv, BACKGROUND_CHECK_API_KEY: 'bc_test' }; + mockFetchSnapshot.mockResolvedValue(null); + updateMany.mockResolvedValue({ count: 1 }); + }); + + afterAll(() => { + process.env = originalEnv; + }); + + it('skips entirely when the API key is not configured', async () => { + delete process.env.BACKGROUND_CHECK_API_KEY; + const result = await runReconciliation(); + expect(findMany).not.toHaveBeenCalled(); + expect(result).toEqual({ + success: true, + checked: 0, + updated: 0, + unparseable: 0, + }); + }); + + it('applies a newly-reported status (and report snapshot) guarded on non-terminal state', async () => { + findMany.mockResolvedValue([ + { + id: 'bcr_1', + identityBackgroundCheckId: 'check_1', + status: 'in_progress', + }, + ]); + mockGetBackgroundCheck.mockResolvedValue({ + status: 'completed', + statuses: { identity: 'passed', employment: 'verified' }, + }); + mockFetchSnapshot.mockResolvedValue({ report: 'x' }); + + const result = await runReconciliation(); + + expect(updateMany).toHaveBeenCalledWith({ + where: { id: 'bcr_1', status: { in: NON_TERMINAL } }, + data: expect.objectContaining({ + status: 'completed', + identityStatus: 'passed', + employmentStatus: 'verified', + reportSnapshot: { report: 'x' }, + reportSyncedAt: expect.any(Date), + }), + }); + expect(result.updated).toBe(1); + }); + + it('refreshes a changed sub-status even when the top-level status is unchanged', async () => { + findMany.mockResolvedValue([ + { + id: 'bcr_1', + identityBackgroundCheckId: 'check_1', + status: 'in_progress', + identityStatus: 'pending', + }, + ]); + mockGetBackgroundCheck.mockResolvedValue({ + status: 'in_progress', + statuses: { identity: 'passed' }, + }); + + const result = await runReconciliation(); + + const call = updateMany.mock.calls[0][0]; + expect(call.data).toMatchObject({ identityStatus: 'passed' }); + expect(call.data).not.toHaveProperty('status'); + expect(result.updated).toBe(1); + }); + + it('only bumps lastSyncedAt when nothing changed', async () => { + findMany.mockResolvedValue([ + { + id: 'bcr_1', + identityBackgroundCheckId: 'check_1', + status: 'in_progress', + }, + ]); + mockGetBackgroundCheck.mockResolvedValue({ status: 'in_progress' }); + + const result = await runReconciliation(); + + expect(updateMany).toHaveBeenCalledWith({ + where: { id: 'bcr_1', status: { in: NON_TERMINAL } }, + data: { lastSyncedAt: expect.any(Date) }, + }); + expect(result.updated).toBe(0); + }); + + it('counts checks whose Identity status cannot be determined and leaves them untouched', async () => { + findMany.mockResolvedValue([ + { + id: 'bcr_1', + identityBackgroundCheckId: 'check_1', + status: 'in_progress', + }, + ]); + mockGetBackgroundCheck.mockResolvedValue({ id: 'check_1' }); + + const result = await runReconciliation(); + + expect(updateMany).not.toHaveBeenCalled(); + expect(result).toEqual({ + success: true, + checked: 1, + updated: 0, + unparseable: 1, + }); + }); + + it('queries only stale, non-terminal checks with an Identity id', async () => { + findMany.mockResolvedValue([]); + await runReconciliation(); + expect(findMany).toHaveBeenCalledWith({ + where: { + status: { in: NON_TERMINAL }, + identityBackgroundCheckId: { not: null }, + OR: [ + { lastSyncedAt: null }, + { lastSyncedAt: { lt: expect.any(Date) } }, + ], + }, + select: { + id: true, + identityBackgroundCheckId: true, + status: true, + identityStatus: true, + employmentStatus: true, + referenceStatus: true, + rightToWorkStatus: true, + adjudicationStatus: true, + }, + }); + }); +}); diff --git a/apps/api/src/trigger/background-checks/reconcile-background-checks-schedule.ts b/apps/api/src/trigger/background-checks/reconcile-background-checks-schedule.ts new file mode 100644 index 000000000..075eb7053 --- /dev/null +++ b/apps/api/src/trigger/background-checks/reconcile-background-checks-schedule.ts @@ -0,0 +1,220 @@ +import { BackgroundCheckStatus, db, Prisma } from '@db'; +import { logger, schedules } from '@trigger.dev/sdk'; +import { z } from 'zod'; +import { BackgroundCheckIdentityClient } from '../../background-checks/background-check-identity.client'; +import { fetchCompletedReportSnapshot } from '../../background-checks/background-check-report-snapshot'; +import { backgroundCheckStatuses } from '../../background-checks/background-checks.types'; + +// Checks in these states are still in flight and can still advance. Terminal +// states (completed/completed_with_flags/failed/cancelled) are left untouched. +const NON_TERMINAL_STATUSES: BackgroundCheckStatus[] = [ + BackgroundCheckStatus.invited, + BackgroundCheckStatus.in_progress, + BackgroundCheckStatus.in_review, +]; + +// Only reconcile checks whose last sync is older than this, so the poller backs +// off and lets the Identity webhook stay the primary update path. +const STALE_AFTER_MS = 60 * 60 * 1000; + +const SUB_STATUS_SCHEMA = z + .object({ + identity: z.string(), + employment: z.string(), + references: z.string(), + rightToWork: z.string(), + adjudication: z.string(), + }) + .partial(); + +interface ReconciliationResult { + success: boolean; + checked: number; + updated: number; + unparseable: number; +} + +/** + * Identity's GET /v1/background-checks/:id returns the full check resource. We + * only need the lifecycle `status` (+ granular sub-statuses) to recover a check + * whose webhook never arrived (CS-473). The response is loosely structured, so + * parse `status` and `statuses` INDEPENDENTLY: a malformed `statuses` must not + * drop an otherwise-valid `status`. An absent/invalid `status` means "can't + * determine" and the record is left untouched. + */ +export function parseIdentityCheckState(raw: unknown): { + status?: BackgroundCheckStatus; + statuses?: z.infer; +} { + const record = z.record(z.string(), z.unknown()).safeParse(raw); + if (!record.success) return {}; + + const status = z.enum(backgroundCheckStatuses).safeParse(record.data.status); + const statuses = SUB_STATUS_SCHEMA.safeParse(record.data.statuses); + + return { + status: status.success ? status.data : undefined, + statuses: statuses.success ? statuses.data : undefined, + }; +} + +/** + * Polls Identity for stale in-flight background checks and applies any status it + * reports — recovering checks whose webhook was missed (CS-473). Background + * check status is normally driven by Identity webhooks; this is the fallback. + */ +export async function runReconciliation(): Promise { + if (!process.env.BACKGROUND_CHECK_API_KEY) { + logger.warn( + 'BACKGROUND_CHECK_API_KEY not configured — skipping reconciliation', + ); + return { success: true, checked: 0, updated: 0, unparseable: 0 }; + } + + // Base the stale cutoff on the ACTUAL run time, not the scheduled time — a + // cron that starts late would otherwise narrow the window and delay recovery. + const staleBefore = new Date(Date.now() - STALE_AFTER_MS); + + const stuckChecks = await db.backgroundCheckRequest.findMany({ + where: { + status: { in: NON_TERMINAL_STATUSES }, + identityBackgroundCheckId: { not: null }, + OR: [{ lastSyncedAt: null }, { lastSyncedAt: { lt: staleBefore } }], + }, + select: { + id: true, + identityBackgroundCheckId: true, + status: true, + identityStatus: true, + employmentStatus: true, + referenceStatus: true, + rightToWorkStatus: true, + adjudicationStatus: true, + }, + }); + + if (stuckChecks.length === 0) { + logger.info('No stale in-flight background checks to reconcile'); + return { success: true, checked: 0, updated: 0, unparseable: 0 }; + } + + logger.info(`Reconciling ${stuckChecks.length} stale background check(s)`); + + const identityClient = new BackgroundCheckIdentityClient(); + let updated = 0; + let unparseable = 0; + + for (const check of stuckChecks) { + const identityId = check.identityBackgroundCheckId; + if (!identityId) continue; + + let raw: unknown; + try { + raw = await identityClient.getBackgroundCheck(identityId); + } catch (error) { + logger.error('Failed to fetch Identity background check', { + backgroundCheckRequestId: check.id, + error: error instanceof Error ? error.message : String(error), + }); + continue; + } + + const { status: nextStatus, statuses } = parseIdentityCheckState(raw); + if (!nextStatus) { + unparseable += 1; + continue; + } + + // Apply only the fields Identity actually reported AND that differ from what + // we already have. Never null out a sub-status the GET omitted, and refresh + // sub-statuses even when the top-level status is unchanged — a check can sit + // in `in_progress` while `Identity:Pending` advances to passed (CS-473). + const data: Prisma.BackgroundCheckRequestUpdateManyMutationInput = {}; + if (nextStatus !== check.status) { + data.status = nextStatus; + } + if ( + statuses?.identity !== undefined && + statuses.identity !== check.identityStatus + ) { + data.identityStatus = statuses.identity; + } + if ( + statuses?.employment !== undefined && + statuses.employment !== check.employmentStatus + ) { + data.employmentStatus = statuses.employment; + } + if ( + statuses?.references !== undefined && + statuses.references !== check.referenceStatus + ) { + data.referenceStatus = statuses.references; + } + if ( + statuses?.rightToWork !== undefined && + statuses.rightToWork !== check.rightToWorkStatus + ) { + data.rightToWorkStatus = statuses.rightToWork; + } + if ( + statuses?.adjudication !== undefined && + statuses.adjudication !== check.adjudicationStatus + ) { + data.adjudicationStatus = statuses.adjudication; + } + + const hasChange = Object.keys(data).length > 0; + if (hasChange) { + const reportSnapshot = await fetchCompletedReportSnapshot({ + identityClient, + identityBackgroundCheckId: identityId, + eventType: 'reconcile', + status: nextStatus, + }); + if (reportSnapshot) { + data.reportSnapshot = reportSnapshot; + data.reportSyncedAt = new Date(); + } + } + data.lastSyncedAt = new Date(); + + // Concurrency-safe: re-assert the row is still non-terminal in the WHERE, so + // a check cancelled/completed between selection and now is never resurrected. + const result = await db.backgroundCheckRequest.updateMany({ + where: { id: check.id, status: { in: NON_TERMINAL_STATUSES } }, + data, + }); + + if (result.count > 0 && hasChange) { + updated += 1; + if (data.status) { + logger.info('Reconciled background check status', { + backgroundCheckRequestId: check.id, + from: check.status, + to: nextStatus, + }); + } + } + } + + logger.info('Background-check reconciliation complete', { + checked: stuckChecks.length, + updated, + unparseable, + }); + + return { success: true, checked: stuckChecks.length, updated, unparseable }; +} + +/** + * Hourly schedule (CS-473). Needs the latest deployment to run in prod/staging, + * and the dev CLI running locally. + */ +export const reconcileBackgroundChecksSchedule = schedules.task({ + id: 'reconcile-background-checks-schedule', + cron: '0 * * * *', // hourly (UTC) + maxDuration: 30 * 60, // 30 minutes — Trigger.dev maxDuration is in SECONDS + + run: () => runReconciliation(), +});