From ee2639c0d29123293a0c095cf7fddd1d6698451f Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Tue, 2 Jun 2026 15:18:33 -0400 Subject: [PATCH 1/4] feat(background-checks): hourly reconciliation for stuck checks (CS-473) Background check status is normally driven by Identity webhooks; when one is missed the check stays stuck (e.g. Identity:Pending forever). Add an hourly Trigger.dev scheduled task that polls Identity for stale in-flight checks and applies any status it reports (same fields the webhook writes). Parses the Identity GET response defensively and no-ops when status can't be determined. Co-Authored-By: Claude Opus 4.8 --- ...concile-background-checks-schedule.spec.ts | 201 ++++++++++++++++++ .../reconcile-background-checks-schedule.ts | 176 +++++++++++++++ 2 files changed, 377 insertions(+) create mode 100644 apps/api/src/trigger/background-checks/reconcile-background-checks-schedule.spec.ts create mode 100644 apps/api/src/trigger/background-checks/reconcile-background-checks-schedule.ts 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..160074ccd --- /dev/null +++ b/apps/api/src/trigger/background-checks/reconcile-background-checks-schedule.spec.ts @@ -0,0 +1,201 @@ +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(), update: 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 update = mockedDb.backgroundCheckRequest.update as jest.Mock; + +const payload = { timestamp: new Date('2026-06-02T12:00:00.000Z') }; + +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('tolerates extra fields and a missing statuses object', () => { + const result = parseIdentityCheckState({ + status: 'in_review', + report: { identity: { foo: 'bar' } }, + }); + expect(result.status).toBe('in_review'); + expect(result.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); + }); + + 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(payload); + expect(findMany).not.toHaveBeenCalled(); + expect(result).toEqual({ + success: true, + checked: 0, + updated: 0, + unparseable: 0, + }); + }); + + it('applies a newly-reported status (and report snapshot) to a stuck check', 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(payload); + + expect(update).toHaveBeenCalledWith({ + where: { id: 'bcr_1' }, + data: expect.objectContaining({ + status: 'completed', + identityStatus: 'passed', + employmentStatus: 'verified', + reportSnapshot: { report: 'x' }, + reportSyncedAt: expect.any(Date), + }), + }); + expect(result).toEqual({ + success: true, + checked: 1, + updated: 1, + unparseable: 0, + }); + }); + + it('only bumps lastSyncedAt when the status has not changed', async () => { + findMany.mockResolvedValue([ + { + id: 'bcr_1', + identityBackgroundCheckId: 'check_1', + status: 'in_progress', + }, + ]); + mockGetBackgroundCheck.mockResolvedValue({ status: 'in_progress' }); + + const result = await runReconciliation(payload); + + expect(update).toHaveBeenCalledWith({ + where: { id: 'bcr_1' }, + 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(payload); + + expect(update).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(payload); + expect(findMany).toHaveBeenCalledWith({ + where: { + status: { in: ['invited', 'in_progress', 'in_review'] }, + identityBackgroundCheckId: { not: null }, + OR: [ + { lastSyncedAt: null }, + { lastSyncedAt: { lt: new Date('2026-06-02T11:00:00.000Z') } }, + ], + }, + select: { id: true, identityBackgroundCheckId: true, status: 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..701294f84 --- /dev/null +++ b/apps/api/src/trigger/background-checks/reconcile-background-checks-schedule.ts @@ -0,0 +1,176 @@ +import { BackgroundCheckStatus, db } 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 otherwise loosely + * structured, so parse defensively: an absent/invalid `status` means "can't + * determine", and we leave the record untouched rather than guess. + */ +export function parseIdentityCheckState(raw: unknown): { + status?: BackgroundCheckStatus; + statuses?: z.infer; +} { + const parsed = z + .object({ + status: z.enum(backgroundCheckStatuses).optional(), + statuses: SUB_STATUS_SCHEMA.optional(), + }) + .passthrough() + .safeParse(raw); + + if (!parsed.success) return {}; + return { status: parsed.data.status, statuses: parsed.data.statuses }; +} + +/** + * 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(payload: { + timestamp: Date; +}): 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 }; + } + + const staleBefore = new Date(payload.timestamp.getTime() - 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 }, + }); + + 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; + } + + if (nextStatus === check.status) { + // No change yet — bump the sync time so we back off next tick. + await db.backgroundCheckRequest.update({ + where: { id: check.id }, + data: { lastSyncedAt: new Date() }, + }); + continue; + } + + const reportSnapshot = await fetchCompletedReportSnapshot({ + identityClient, + identityBackgroundCheckId: identityId, + eventType: 'reconcile', + status: nextStatus, + }); + + await db.backgroundCheckRequest.update({ + where: { id: check.id }, + data: { + status: nextStatus, + identityStatus: statuses?.identity ?? null, + employmentStatus: statuses?.employment ?? null, + referenceStatus: statuses?.references ?? null, + rightToWorkStatus: statuses?.rightToWork ?? null, + adjudicationStatus: statuses?.adjudication ?? null, + lastSyncedAt: new Date(), + ...(reportSnapshot + ? { reportSnapshot, reportSyncedAt: new Date() } + : {}), + }, + }); + + updated += 1; + 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: 1000 * 60 * 30, + run: (payload) => runReconciliation(payload), +}); From 4a87e775abdb111571f2e5c759041a188bd725e9 Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Tue, 2 Jun 2026 15:36:59 -0400 Subject: [PATCH 2/4] fix(background-checks): address Cubic review on reconciliation job MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - parseIdentityCheckState parses status/statuses independently so a malformed statuses object no longer drops a valid status (P2) - refresh sub-statuses even when the top-level status is unchanged, and never null out sub-statuses the GET omitted (P2 — the CS-473 symptom) - write via updateMany guarded on status IN non-terminal, so a check that became terminal/cancelled between select and write isn't resurrected (P1) Co-Authored-By: Claude Opus 4.8 --- ...concile-background-checks-schedule.spec.ts | 77 +++++++--- .../reconcile-background-checks-schedule.ts | 139 ++++++++++++------ 2 files changed, 147 insertions(+), 69 deletions(-) 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 index 160074ccd..7e2d681db 100644 --- 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 @@ -8,7 +8,7 @@ import { // Postgres. jest.mock('@db', () => ({ db: { - backgroundCheckRequest: { findMany: jest.fn(), update: jest.fn() }, + backgroundCheckRequest: { findMany: jest.fn(), updateMany: jest.fn() }, }, BackgroundCheckStatus: { invited: 'invited', @@ -42,9 +42,10 @@ jest.mock('../../background-checks/background-check-report-snapshot', () => ({ const mockedDb = db as jest.Mocked; const findMany = mockedDb.backgroundCheckRequest.findMany as jest.Mock; -const update = mockedDb.backgroundCheckRequest.update as jest.Mock; +const updateMany = mockedDb.backgroundCheckRequest.updateMany as jest.Mock; const payload = { timestamp: new Date('2026-06-02T12:00:00.000Z') }; +const NON_TERMINAL = ['invited', 'in_progress', 'in_review']; describe('parseIdentityCheckState', () => { it('extracts status and sub-statuses from a well-formed response', () => { @@ -69,13 +70,20 @@ describe('parseIdentityCheckState', () => { ).toBeUndefined(); }); - it('tolerates extra fields and a missing statuses object', () => { - const result = parseIdentityCheckState({ + 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', - report: { identity: { foo: 'bar' } }, + statuses: { identity: 123 }, }); - expect(result.status).toBe('in_review'); - expect(result.statuses).toBeUndefined(); + expect(badField.status).toBe('in_review'); + expect(badField.statuses).toBeUndefined(); }); it('returns nothing for non-object input', () => { @@ -91,6 +99,7 @@ describe('runReconciliation', () => { jest.clearAllMocks(); process.env = { ...originalEnv, BACKGROUND_CHECK_API_KEY: 'bc_test' }; mockFetchSnapshot.mockResolvedValue(null); + updateMany.mockResolvedValue({ count: 1 }); }); afterAll(() => { @@ -109,7 +118,7 @@ describe('runReconciliation', () => { }); }); - it('applies a newly-reported status (and report snapshot) to a stuck check', async () => { + it('applies a newly-reported status (and report snapshot) guarded on non-terminal state', async () => { findMany.mockResolvedValue([ { id: 'bcr_1', @@ -125,8 +134,8 @@ describe('runReconciliation', () => { const result = await runReconciliation(payload); - expect(update).toHaveBeenCalledWith({ - where: { id: 'bcr_1' }, + expect(updateMany).toHaveBeenCalledWith({ + where: { id: 'bcr_1', status: { in: NON_TERMINAL } }, data: expect.objectContaining({ status: 'completed', identityStatus: 'passed', @@ -135,15 +144,32 @@ describe('runReconciliation', () => { reportSyncedAt: expect.any(Date), }), }); - expect(result).toEqual({ - success: true, - checked: 1, - updated: 1, - unparseable: 0, + 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(payload); + + 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 the status has not changed', async () => { + it('only bumps lastSyncedAt when nothing changed', async () => { findMany.mockResolvedValue([ { id: 'bcr_1', @@ -155,8 +181,8 @@ describe('runReconciliation', () => { const result = await runReconciliation(payload); - expect(update).toHaveBeenCalledWith({ - where: { id: 'bcr_1' }, + expect(updateMany).toHaveBeenCalledWith({ + where: { id: 'bcr_1', status: { in: NON_TERMINAL } }, data: { lastSyncedAt: expect.any(Date) }, }); expect(result.updated).toBe(0); @@ -174,7 +200,7 @@ describe('runReconciliation', () => { const result = await runReconciliation(payload); - expect(update).not.toHaveBeenCalled(); + expect(updateMany).not.toHaveBeenCalled(); expect(result).toEqual({ success: true, checked: 1, @@ -188,14 +214,23 @@ describe('runReconciliation', () => { await runReconciliation(payload); expect(findMany).toHaveBeenCalledWith({ where: { - status: { in: ['invited', 'in_progress', 'in_review'] }, + status: { in: NON_TERMINAL }, identityBackgroundCheckId: { not: null }, OR: [ { lastSyncedAt: null }, { lastSyncedAt: { lt: new Date('2026-06-02T11:00:00.000Z') } }, ], }, - select: { id: true, identityBackgroundCheckId: true, status: true }, + 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 index 701294f84..d5c04f591 100644 --- a/apps/api/src/trigger/background-checks/reconcile-background-checks-schedule.ts +++ b/apps/api/src/trigger/background-checks/reconcile-background-checks-schedule.ts @@ -1,4 +1,4 @@ -import { BackgroundCheckStatus, db } from '@db'; +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'; @@ -37,24 +37,25 @@ interface ReconciliationResult { /** * 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 otherwise loosely - * structured, so parse defensively: an absent/invalid `status` means "can't - * determine", and we leave the record untouched rather than guess. + * 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 parsed = z - .object({ - status: z.enum(backgroundCheckStatuses).optional(), - statuses: SUB_STATUS_SCHEMA.optional(), - }) - .passthrough() - .safeParse(raw); - - if (!parsed.success) return {}; - return { status: parsed.data.status, statuses: parsed.data.statuses }; + 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, + }; } /** @@ -80,7 +81,16 @@ export async function runReconciliation(payload: { identityBackgroundCheckId: { not: null }, OR: [{ lastSyncedAt: null }, { lastSyncedAt: { lt: staleBefore } }], }, - select: { id: true, identityBackgroundCheckId: true, status: true }, + select: { + id: true, + identityBackgroundCheckId: true, + status: true, + identityStatus: true, + employmentStatus: true, + referenceStatus: true, + rightToWorkStatus: true, + adjudicationStatus: true, + }, }); if (stuckChecks.length === 0) { @@ -115,44 +125,77 @@ export async function runReconciliation(payload: { continue; } - if (nextStatus === check.status) { - // No change yet — bump the sync time so we back off next tick. - await db.backgroundCheckRequest.update({ - where: { id: check.id }, - data: { lastSyncedAt: new Date() }, - }); - 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 reportSnapshot = await fetchCompletedReportSnapshot({ - identityClient, - identityBackgroundCheckId: identityId, - eventType: 'reconcile', - status: nextStatus, - }); - - await db.backgroundCheckRequest.update({ - where: { id: check.id }, - data: { + const hasChange = Object.keys(data).length > 0; + if (hasChange) { + const reportSnapshot = await fetchCompletedReportSnapshot({ + identityClient, + identityBackgroundCheckId: identityId, + eventType: 'reconcile', status: nextStatus, - identityStatus: statuses?.identity ?? null, - employmentStatus: statuses?.employment ?? null, - referenceStatus: statuses?.references ?? null, - rightToWorkStatus: statuses?.rightToWork ?? null, - adjudicationStatus: statuses?.adjudication ?? null, - lastSyncedAt: new Date(), - ...(reportSnapshot - ? { reportSnapshot, reportSyncedAt: new Date() } - : {}), - }, - }); + }); + if (reportSnapshot) { + data.reportSnapshot = reportSnapshot; + data.reportSyncedAt = new Date(); + } + } + data.lastSyncedAt = new Date(); - updated += 1; - logger.info('Reconciled background check status', { - backgroundCheckRequestId: check.id, - from: check.status, - to: nextStatus, + // 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', { From abc82601af33b4f92bb1b2c6f317d598552eaa9d Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Tue, 2 Jun 2026 15:43:59 -0400 Subject: [PATCH 3/4] fix(background-checks): maxDuration is in seconds, not ms (CS-473) Trigger.dev maxDuration is specified in seconds; 1000*60*30 was ~20 days instead of 30 minutes. Use 30*60. Co-Authored-By: Claude Opus 4.8 --- .../background-checks/reconcile-background-checks-schedule.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 index d5c04f591..a047f6194 100644 --- a/apps/api/src/trigger/background-checks/reconcile-background-checks-schedule.ts +++ b/apps/api/src/trigger/background-checks/reconcile-background-checks-schedule.ts @@ -214,6 +214,7 @@ export async function runReconciliation(payload: { export const reconcileBackgroundChecksSchedule = schedules.task({ id: 'reconcile-background-checks-schedule', cron: '0 * * * *', // hourly (UTC) - maxDuration: 1000 * 60 * 30, + maxDuration: 30 * 60, // 30 minutes — Trigger.dev maxDuration is in SECONDS + run: (payload) => runReconciliation(payload), }); From ca786cdde60582861b0b890f4756db13f9d7e709 Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Tue, 2 Jun 2026 15:50:49 -0400 Subject: [PATCH 4/4] fix(background-checks): base stale cutoff on actual run time (CS-473) Use Date.now() instead of the scheduled payload.timestamp so a late cron start doesn't narrow the reconciliation window and delay recovery. Co-Authored-By: Claude Opus 4.8 --- .../reconcile-background-checks-schedule.spec.ts | 15 +++++++-------- .../reconcile-background-checks-schedule.ts | 10 +++++----- 2 files changed, 12 insertions(+), 13 deletions(-) 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 index 7e2d681db..0de863170 100644 --- 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 @@ -44,7 +44,6 @@ const mockedDb = db as jest.Mocked; const findMany = mockedDb.backgroundCheckRequest.findMany as jest.Mock; const updateMany = mockedDb.backgroundCheckRequest.updateMany as jest.Mock; -const payload = { timestamp: new Date('2026-06-02T12:00:00.000Z') }; const NON_TERMINAL = ['invited', 'in_progress', 'in_review']; describe('parseIdentityCheckState', () => { @@ -108,7 +107,7 @@ describe('runReconciliation', () => { it('skips entirely when the API key is not configured', async () => { delete process.env.BACKGROUND_CHECK_API_KEY; - const result = await runReconciliation(payload); + const result = await runReconciliation(); expect(findMany).not.toHaveBeenCalled(); expect(result).toEqual({ success: true, @@ -132,7 +131,7 @@ describe('runReconciliation', () => { }); mockFetchSnapshot.mockResolvedValue({ report: 'x' }); - const result = await runReconciliation(payload); + const result = await runReconciliation(); expect(updateMany).toHaveBeenCalledWith({ where: { id: 'bcr_1', status: { in: NON_TERMINAL } }, @@ -161,7 +160,7 @@ describe('runReconciliation', () => { statuses: { identity: 'passed' }, }); - const result = await runReconciliation(payload); + const result = await runReconciliation(); const call = updateMany.mock.calls[0][0]; expect(call.data).toMatchObject({ identityStatus: 'passed' }); @@ -179,7 +178,7 @@ describe('runReconciliation', () => { ]); mockGetBackgroundCheck.mockResolvedValue({ status: 'in_progress' }); - const result = await runReconciliation(payload); + const result = await runReconciliation(); expect(updateMany).toHaveBeenCalledWith({ where: { id: 'bcr_1', status: { in: NON_TERMINAL } }, @@ -198,7 +197,7 @@ describe('runReconciliation', () => { ]); mockGetBackgroundCheck.mockResolvedValue({ id: 'check_1' }); - const result = await runReconciliation(payload); + const result = await runReconciliation(); expect(updateMany).not.toHaveBeenCalled(); expect(result).toEqual({ @@ -211,14 +210,14 @@ describe('runReconciliation', () => { it('queries only stale, non-terminal checks with an Identity id', async () => { findMany.mockResolvedValue([]); - await runReconciliation(payload); + await runReconciliation(); expect(findMany).toHaveBeenCalledWith({ where: { status: { in: NON_TERMINAL }, identityBackgroundCheckId: { not: null }, OR: [ { lastSyncedAt: null }, - { lastSyncedAt: { lt: new Date('2026-06-02T11:00:00.000Z') } }, + { lastSyncedAt: { lt: expect.any(Date) } }, ], }, select: { 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 index a047f6194..075eb7053 100644 --- a/apps/api/src/trigger/background-checks/reconcile-background-checks-schedule.ts +++ b/apps/api/src/trigger/background-checks/reconcile-background-checks-schedule.ts @@ -63,9 +63,7 @@ export function parseIdentityCheckState(raw: unknown): { * 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(payload: { - timestamp: Date; -}): Promise { +export async function runReconciliation(): Promise { if (!process.env.BACKGROUND_CHECK_API_KEY) { logger.warn( 'BACKGROUND_CHECK_API_KEY not configured — skipping reconciliation', @@ -73,7 +71,9 @@ export async function runReconciliation(payload: { return { success: true, checked: 0, updated: 0, unparseable: 0 }; } - const staleBefore = new Date(payload.timestamp.getTime() - STALE_AFTER_MS); + // 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: { @@ -216,5 +216,5 @@ export const reconcileBackgroundChecksSchedule = schedules.task({ cron: '0 * * * *', // hourly (UTC) maxDuration: 30 * 60, // 30 minutes — Trigger.dev maxDuration is in SECONDS - run: (payload) => runReconciliation(payload), + run: () => runReconciliation(), });