diff --git a/apps/api/src/background-checks/background-check-custom.service.ts b/apps/api/src/background-checks/background-check-custom.service.ts index 453eaf1466..34352882af 100644 --- a/apps/api/src/background-checks/background-check-custom.service.ts +++ b/apps/api/src/background-checks/background-check-custom.service.ts @@ -57,8 +57,10 @@ export class BackgroundCheckCustomService { throw new NotFoundException('Member not found.'); } - const resolvedName = employeeName?.trim() || member.user.name || member.user.email; - const resolvedEmail = employeeEmail?.trim().toLowerCase() || member.user.email; + const resolvedName = + employeeName?.trim() || member.user.name || member.user.email; + const resolvedEmail = + employeeEmail?.trim().toLowerCase() || member.user.email; // Create/update the record without marking it completed yet const record = await db.backgroundCheckRequest.upsert({ diff --git a/apps/api/src/background-checks/background-check-identity.client.spec.ts b/apps/api/src/background-checks/background-check-identity.client.spec.ts new file mode 100644 index 0000000000..52654b3e83 --- /dev/null +++ b/apps/api/src/background-checks/background-check-identity.client.spec.ts @@ -0,0 +1,61 @@ +import { BackgroundCheckIdentityClient } from './background-check-identity.client'; + +describe('BackgroundCheckIdentityClient idempotency key', () => { + const originalEnv = { ...process.env }; + const originalFetch = global.fetch; + + beforeEach(() => { + process.env = { + ...originalEnv, + BACKGROUND_CHECK_API_KEY: 'bc_test', + BACKGROUND_CHECK_API_BASE_URL: 'https://identity.test', + }; + }); + + afterEach(() => { + process.env = originalEnv; + global.fetch = originalFetch; + }); + + function mockFetchOk() { + const fetchMock = jest.fn().mockResolvedValue({ + ok: true, + text: async () => JSON.stringify({ id: 'check_1', status: 'invited' }), + }); + global.fetch = fetchMock as unknown as typeof fetch; + return fetchMock; + } + + function keyFrom(fetchMock: jest.Mock): string { + const init = fetchMock.mock.calls[0][1] as { + headers: Record; + }; + return init.headers['Idempotency-Key']; + } + + const params = { + organizationId: 'org_1', + memberId: 'mem_1', + employeeName: 'Ada', + employeeEmail: 'ada@example.com', + requesterEmail: 'admin@example.com', + }; + + it('forwards the provided idempotency key as the Idempotency-Key header', async () => { + const fetchMock = mockFetchOk(); + await new BackgroundCheckIdentityClient().createBackgroundCheck({ + ...params, + idempotencyKey: 'comp-background-check:bcr_1', + }); + expect(keyFrom(fetchMock)).toBe('comp-background-check:bcr_1'); + }); + + it('forwards a per-attempt retry idempotency key unchanged', async () => { + const fetchMock = mockFetchOk(); + await new BackgroundCheckIdentityClient().createBackgroundCheck({ + ...params, + idempotencyKey: 'comp-background-check:bcr_1:2', + }); + expect(keyFrom(fetchMock)).toBe('comp-background-check:bcr_1:2'); + }); +}); diff --git a/apps/api/src/background-checks/background-check-identity.client.ts b/apps/api/src/background-checks/background-check-identity.client.ts index ef37fd724e..46043e2c38 100644 --- a/apps/api/src/background-checks/background-check-identity.client.ts +++ b/apps/api/src/background-checks/background-check-identity.client.ts @@ -14,51 +14,61 @@ export class BackgroundCheckIdentityClient { employeeName: string; employeeEmail: string; requesterEmail: string; + idempotencyKey: string; }): Promise { const apiKey = process.env.BACKGROUND_CHECK_API_KEY; if (!apiKey) { - throw new BadRequestException('Background check service is not configured. Contact support.'); + throw new BadRequestException( + 'Background check service is not configured. Contact support.', + ); } const baseUrl = this.baseUrl(); const callbackUrl = this.callbackUrl(); - const response = await this.fetchIdentity(`${baseUrl}/v1/background-checks`, { - method: 'POST', - headers: { - Authorization: `Bearer ${apiKey}`, - 'Content-Type': 'application/json', - 'Idempotency-Key': `comp-background-check:${params.memberId}`, - }, - body: JSON.stringify({ - candidate: { - name: params.employeeName, - email: params.employeeEmail, - }, - requester: { - email: params.requesterEmail, - }, - employmentHistory: [], - references: [], - metadata: { - source: 'comp', - compOrganizationId: params.organizationId, - compMemberId: params.memberId, + const response = await this.fetchIdentity( + `${baseUrl}/v1/background-checks`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + 'Idempotency-Key': params.idempotencyKey, }, - callbackUrl, - }), - }); + body: JSON.stringify({ + candidate: { + name: params.employeeName, + email: params.employeeEmail, + }, + requester: { + email: params.requesterEmail, + }, + employmentHistory: [], + references: [], + metadata: { + source: 'comp', + compOrganizationId: params.organizationId, + compMemberId: params.memberId, + }, + callbackUrl, + }), + }, + ); const json = await this.readJson(response); if (!response.ok) { this.logger.error('Identity background check request failed', json); - throw new BadRequestException('Identity background check request failed.'); + throw new BadRequestException( + 'Identity background check request failed.', + ); } return identityCreateResponseSchema.parse(json); } - async getBackgroundCheck(identityBackgroundCheckId: string): Promise { + async getBackgroundCheck( + identityBackgroundCheckId: string, + ): Promise { const apiKey = process.env.BACKGROUND_CHECK_API_KEY; if (!apiKey) return null; @@ -76,19 +86,22 @@ export class BackgroundCheckIdentityClient { private baseUrl(): string { const baseUrl = - process.env.BACKGROUND_CHECK_API_BASE_URL ?? 'https://glad-sturgeon-729.convex.site'; + process.env.BACKGROUND_CHECK_API_BASE_URL ?? + 'https://glad-sturgeon-729.convex.site'; return baseUrl.replace(/\/+$/, ''); } private callbackUrl(): string { const endpoint = process.env.BACKGROUND_WH_ENDPOINT?.trim(); - return (endpoint || 'https://api.trycomp.ai/v1/background-checks/webhook').replace( - /\/+$/, - '', - ); + return ( + endpoint || 'https://api.trycomp.ai/v1/background-checks/webhook' + ).replace(/\/+$/, ''); } - private async fetchIdentity(url: string, init: RequestInit): Promise { + private async fetchIdentity( + url: string, + init: RequestInit, + ): Promise { try { return await fetch(url, { ...init, diff --git a/apps/api/src/background-checks/background-check-report-snapshot.ts b/apps/api/src/background-checks/background-check-report-snapshot.ts index 47097ca087..9e50e220da 100644 --- a/apps/api/src/background-checks/background-check-report-snapshot.ts +++ b/apps/api/src/background-checks/background-check-report-snapshot.ts @@ -40,7 +40,9 @@ export async function fetchCompletedReportSnapshot({ } try { - const snapshot = await identityClient.getBackgroundCheck(identityBackgroundCheckId); + const snapshot = await identityClient.getBackgroundCheck( + identityBackgroundCheckId, + ); return toInputJsonValue(snapshot); } catch { return null; diff --git a/apps/api/src/background-checks/background-check-retry.ts b/apps/api/src/background-checks/background-check-retry.ts new file mode 100644 index 0000000000..1c842ca515 --- /dev/null +++ b/apps/api/src/background-checks/background-check-retry.ts @@ -0,0 +1,145 @@ +import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { BackgroundCheckStatus, db, Prisma } from '@db'; +import type { BackgroundCheckIdentityClient } from './background-check-identity.client'; + +type GetForMemberFn = (params: { + organizationId: string; + memberId: string; +}) => Promise<{ + id: string; + rerunCount: number; + employeeName: string; + employeeEmail: string; + status: BackgroundCheckStatus; +} | null>; + +export function assertTransitionAllowed( + action: 'cancel' | 'retry', + status: BackgroundCheckStatus, +): void { + const allowed: Record<'cancel' | 'retry', BackgroundCheckStatus[]> = { + cancel: [ + BackgroundCheckStatus.invited, + BackgroundCheckStatus.in_progress, + BackgroundCheckStatus.in_review, + ], + retry: [BackgroundCheckStatus.failed, BackgroundCheckStatus.cancelled], + }; + if (!allowed[action].includes(status)) { + throw new BadRequestException( + `Cannot ${action} a background check in '${status}' status.`, + ); + } +} + +export async function cancelForMember({ + organizationId, + memberId, + getForMember, +}: { + organizationId: string; + memberId: string; + getForMember: GetForMemberFn; +}) { + const existing = await getForMember({ organizationId, memberId }); + if (!existing) { + throw new NotFoundException('Background check not found.'); + } + assertTransitionAllowed('cancel', existing.status); + + return db.backgroundCheckRequest.update({ + where: { organizationId_memberId: { organizationId, memberId } }, + data: { + status: BackgroundCheckStatus.cancelled, + lastSyncedAt: new Date(), + }, + }); +} + +export async function deleteForMember({ + organizationId, + memberId, + getForMember, +}: { + organizationId: string; + memberId: string; + getForMember: GetForMemberFn; +}): Promise<{ ok: true }> { + const existing = await getForMember({ organizationId, memberId }); + if (!existing) { + throw new NotFoundException('Background check not found.'); + } + // Hard delete; webhookEvents cascade via the FK. Frees the + // @@unique([organizationId, memberId]) constraint for a fresh request. + await db.backgroundCheckRequest.delete({ + where: { organizationId_memberId: { organizationId, memberId } }, + }); + return { ok: true }; +} + +export async function retryForMember({ + organizationId, + memberId, + requesterEmail, + identityClient, + getForMember, +}: { + organizationId: string; + memberId: string; + requesterEmail: string; + identityClient: BackgroundCheckIdentityClient; + getForMember: GetForMemberFn; +}) { + const existing = await getForMember({ organizationId, memberId }); + if (!existing) { + throw new NotFoundException('Background check not found.'); + } + assertTransitionAllowed('retry', existing.status); + + const attempt = existing.rerunCount + 1; + const where = { organizationId_memberId: { organizationId, memberId } }; + + // Free retry: no charge. Create a fresh Identity check first (varied + // idempotency key) so a late webhook from the prior check cannot match + // the row after we swap in the new id. + let identityResult; + try { + identityResult = await identityClient.createBackgroundCheck({ + organizationId, + memberId, + employeeName: existing.employeeName, + employeeEmail: existing.employeeEmail, + requesterEmail, + // Per-record, per-attempt key so each retry creates a fresh vendor + // check rather than colliding with a prior attempt's idempotency key. + idempotencyKey: `comp-background-check:${existing.id}:${attempt}`, + }); + } catch (error) { + // Restore the prior status (retry is only allowed from 'failed' or + // 'cancelled'). Forcing 'failed' here would strip a cancelled check of the + // webhook terminal-guard and let a late vendor webhook resurrect it. + await db.backgroundCheckRequest.update({ + where, + data: { status: existing.status, lastSyncedAt: new Date() }, + }); + throw error; + } + + return db.backgroundCheckRequest.update({ + where, + data: { + identityBackgroundCheckId: identityResult.id, + candidateUrl: identityResult.candidateUrl ?? null, + status: identityResult.status, + rerunCount: attempt, + identityStatus: null, + employmentStatus: null, + referenceStatus: null, + rightToWorkStatus: null, + adjudicationStatus: null, + reportSnapshot: Prisma.JsonNull, + reportSyncedAt: null, + lastSyncedAt: new Date(), + }, + }); +} diff --git a/apps/api/src/background-checks/background-check-webhook-signature.ts b/apps/api/src/background-checks/background-check-webhook-signature.ts index e52eb0f2e9..b4faba31fa 100644 --- a/apps/api/src/background-checks/background-check-webhook-signature.ts +++ b/apps/api/src/background-checks/background-check-webhook-signature.ts @@ -22,7 +22,10 @@ export function verifyBackgroundCheckWebhookSignature({ } const timestamp = Number(timestampHeader); - if (!Number.isFinite(timestamp) || Math.abs(Date.now() - timestamp) > WEBHOOK_MAX_SKEW_MS) { + if ( + !Number.isFinite(timestamp) || + Math.abs(Date.now() - timestamp) > WEBHOOK_MAX_SKEW_MS + ) { throw new UnauthorizedException('Webhook timestamp is invalid.'); } diff --git a/apps/api/src/background-checks/background-check-webhook.service.spec.ts b/apps/api/src/background-checks/background-check-webhook.service.spec.ts index 36e15bf507..d1190dc65e 100644 --- a/apps/api/src/background-checks/background-check-webhook.service.spec.ts +++ b/apps/api/src/background-checks/background-check-webhook.service.spec.ts @@ -17,6 +17,7 @@ jest.mock('@db', () => { return { Prisma: { PrismaClientKnownRequestError }, + BackgroundCheckStatus: { cancelled: 'cancelled' }, db: { backgroundCheckRequest: { findFirst: jest.fn(), @@ -119,9 +120,9 @@ describe('BackgroundChecksService webhooks', () => { employeeName: 'Ada', employeeEmail: 'old@example.com', } as Awaited>); - mockAsync>>( - mockedDb.backgroundCheckWebhookEvent.create, - ).mockResolvedValueOnce( + mockAsync< + Awaited> + >(mockedDb.backgroundCheckWebhookEvent.create).mockResolvedValueOnce( {} as Awaited>, ); const reportSnapshot = { @@ -170,9 +171,9 @@ describe('BackgroundChecksService webhooks', () => { employeeName: 'Ada', employeeEmail: 'old@example.com', } as Awaited>); - mockAsync>>( - mockedDb.backgroundCheckWebhookEvent.create, - ).mockResolvedValueOnce( + mockAsync< + Awaited> + >(mockedDb.backgroundCheckWebhookEvent.create).mockResolvedValueOnce( {} as Awaited>, ); const identityClient = { @@ -213,9 +214,9 @@ describe('BackgroundChecksService webhooks', () => { employeeName: 'Ada', employeeEmail: 'old@example.com', } as Awaited>); - mockAsync>>( - mockedDb.backgroundCheckWebhookEvent.create, - ).mockResolvedValueOnce( + mockAsync< + Awaited> + >(mockedDb.backgroundCheckWebhookEvent.create).mockResolvedValueOnce( {} as Awaited>, ); const identityClient = { getBackgroundCheck: jest.fn() }; @@ -240,6 +241,43 @@ describe('BackgroundChecksService webhooks', () => { ); }); + it('does not change status when the local record is already cancelled', async () => { + const payload = webhookPayload(); + const rawBody = JSON.stringify(payload); + const timestamp = String(Date.now()); + mockAsync>>( + mockedDb.backgroundCheckRequest.findFirst, + ).mockResolvedValueOnce({ + id: 'bcr_1', + status: 'cancelled', + employeeName: 'Ada', + employeeEmail: 'ada@example.com', + } as Awaited>); + mockAsync< + Awaited> + >(mockedDb.backgroundCheckWebhookEvent.create).mockResolvedValueOnce( + {} as Awaited>, + ); + const service = new BackgroundChecksService( + { + getBackgroundCheck: jest.fn(), + } as unknown as BackgroundCheckIdentityClient, + {} as unknown as BackgroundCheckPaymentService, + ); + + const result = await service.handleWebhook({ + rawBody: Buffer.from(rawBody), + headers: { + 'x-background-check-timestamp': timestamp, + 'x-background-check-signature': makeSignature(rawBody, timestamp), + }, + }); + + expect(mockedDb.backgroundCheckWebhookEvent.create).toHaveBeenCalled(); + expect(mockedDb.backgroundCheckRequest.update).not.toHaveBeenCalled(); + expect(result).toEqual({ ok: true }); + }); + it('reconciles state even on duplicate webhook events', async () => { const payload = webhookPayload(); const rawBody = JSON.stringify(payload); @@ -251,16 +289,18 @@ describe('BackgroundChecksService webhooks', () => { employeeName: 'Ada', employeeEmail: 'old@example.com', } as Awaited>); - mockAsync>>( - mockedDb.backgroundCheckWebhookEvent.create, - ).mockRejectedValueOnce( + mockAsync< + Awaited> + >(mockedDb.backgroundCheckWebhookEvent.create).mockRejectedValueOnce( new Prisma.PrismaClientKnownRequestError('duplicate', { code: 'P2002', clientVersion: 'test', }), ); const identityClient = { - getBackgroundCheck: jest.fn().mockResolvedValue({ status: 'completed_with_flags' }), + getBackgroundCheck: jest + .fn() + .mockResolvedValue({ status: 'completed_with_flags' }), }; const service = new BackgroundChecksService( identityClient as unknown as BackgroundCheckIdentityClient, diff --git a/apps/api/src/background-checks/background-checks.controller.spec.ts b/apps/api/src/background-checks/background-checks.controller.spec.ts new file mode 100644 index 0000000000..97800f9a93 --- /dev/null +++ b/apps/api/src/background-checks/background-checks.controller.spec.ts @@ -0,0 +1,84 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { PeopleBackgroundChecksController } from './background-checks.controller'; +import { BackgroundChecksService } from './background-checks.service'; +import { BackgroundCheckCustomService } from './background-check-custom.service'; +import { HybridAuthGuard } from '../auth/hybrid-auth.guard'; +import { PermissionGuard } from '../auth/permission.guard'; +import type { AuthContext as AuthContextType } from '../auth/types'; + +jest.mock('../auth/auth.server', () => ({ + auth: { api: { getSession: jest.fn() } }, +})); + +jest.mock('@trycompai/auth', () => ({ + statement: { member: ['create', 'read', 'update', 'delete'] }, + BUILT_IN_ROLE_PERMISSIONS: {}, +})); + +jest.mock('@db', () => ({ db: {}, Prisma: {} })); + +describe('PeopleBackgroundChecksController admin actions', () => { + let controller: PeopleBackgroundChecksController; + + const service = { + retryForMember: jest.fn(), + cancelForMember: jest.fn(), + deleteForMember: jest.fn(), + }; + + const mockGuard = { canActivate: jest.fn().mockReturnValue(true) }; + + const authContext: AuthContextType = { + authType: 'session', + userId: 'usr_1', + userEmail: 'user@example.com', + organizationId: 'org_1', + memberId: 'mem_admin', + isApiKey: false, + isPlatformAdmin: false, + userRoles: null, + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [PeopleBackgroundChecksController], + providers: [ + { provide: BackgroundChecksService, useValue: service }, + { provide: BackgroundCheckCustomService, useValue: {} }, + ], + }) + .overrideGuard(HybridAuthGuard) + .useValue(mockGuard) + .overrideGuard(PermissionGuard) + .useValue(mockGuard) + .compile(); + + controller = module.get(PeopleBackgroundChecksController); + jest.clearAllMocks(); + }); + + it('delegates retry with the requester email', async () => { + await controller.retryForMember('mem_1', 'org_1', authContext); + expect(service.retryForMember).toHaveBeenCalledWith({ + organizationId: 'org_1', + memberId: 'mem_1', + requesterEmail: 'user@example.com', + }); + }); + + it('delegates cancel', async () => { + await controller.cancelForMember('mem_1', 'org_1'); + expect(service.cancelForMember).toHaveBeenCalledWith({ + organizationId: 'org_1', + memberId: 'mem_1', + }); + }); + + it('delegates delete', async () => { + await controller.deleteForMember('mem_1', 'org_1'); + expect(service.deleteForMember).toHaveBeenCalledWith({ + organizationId: 'org_1', + memberId: 'mem_1', + }); + }); +}); diff --git a/apps/api/src/background-checks/background-checks.controller.ts b/apps/api/src/background-checks/background-checks.controller.ts index 576b08603b..e691d07205 100644 --- a/apps/api/src/background-checks/background-checks.controller.ts +++ b/apps/api/src/background-checks/background-checks.controller.ts @@ -1,6 +1,7 @@ import { Body, Controller, + Delete, Get, Headers, HttpCode, @@ -40,7 +41,10 @@ export class PeopleBackgroundChecksController { @Param('id') memberId: string, @OrganizationId() organizationId: string, ) { - return this.backgroundChecksService.getForMember({ organizationId, memberId }); + return this.backgroundChecksService.getForMember({ + organizationId, + memberId, + }); } @Post(':id/background-check') @@ -65,12 +69,17 @@ export class PeopleBackgroundChecksController { @Get(':id/background-check/custom-attachments') @RequirePermission('member', 'read') - @ApiOperation({ summary: 'Get custom background check attachments for a member' }) + @ApiOperation({ + summary: 'Get custom background check attachments for a member', + }) async getCustomAttachmentsForMember( @Param('id') memberId: string, @OrganizationId() organizationId: string, ) { - return this.customService.getAttachmentsForMember({ organizationId, memberId }); + return this.customService.getAttachmentsForMember({ + organizationId, + memberId, + }); } @Post(':id/background-check/custom') @@ -97,6 +106,52 @@ export class PeopleBackgroundChecksController { userId: authContext.userId, }); } + + @Post(':id/background-check/retry') + @HttpCode(200) + @RequirePermission('member', 'update') + @ApiOperation({ + summary: 'Retry a failed or cancelled background check (free)', + }) + async retryForMember( + @Param('id') memberId: string, + @OrganizationId() organizationId: string, + @AuthContext() authContext: AuthContextType, + ) { + return this.backgroundChecksService.retryForMember({ + organizationId, + memberId, + requesterEmail: authContext.userEmail ?? 'api-key@trycomp.ai', + }); + } + + @Post(':id/background-check/cancel') + @HttpCode(200) + @RequirePermission('member', 'update') + @ApiOperation({ summary: 'Cancel an in-flight background check' }) + async cancelForMember( + @Param('id') memberId: string, + @OrganizationId() organizationId: string, + ) { + return this.backgroundChecksService.cancelForMember({ + organizationId, + memberId, + }); + } + + @Delete(':id/background-check') + @HttpCode(200) + @RequirePermission('member', 'delete') + @ApiOperation({ summary: 'Delete a member background check' }) + async deleteForMember( + @Param('id') memberId: string, + @OrganizationId() organizationId: string, + ) { + return this.backgroundChecksService.deleteForMember({ + organizationId, + memberId, + }); + } } @ApiTags('Background Checks') @@ -104,7 +159,9 @@ export class PeopleBackgroundChecksController { @UseGuards(HybridAuthGuard, PermissionGuard) @ApiSecurity('apikey') export class BackgroundChecksController { - constructor(private readonly backgroundChecksService: BackgroundChecksService) {} + constructor( + private readonly backgroundChecksService: BackgroundChecksService, + ) {} @Get(':id') @RequirePermission('member', 'read') diff --git a/apps/api/src/background-checks/background-checks.service.spec.ts b/apps/api/src/background-checks/background-checks.service.spec.ts index 2d23c240c9..cad3060f79 100644 --- a/apps/api/src/background-checks/background-checks.service.spec.ts +++ b/apps/api/src/background-checks/background-checks.service.spec.ts @@ -3,7 +3,7 @@ import { BillingService } from '../billing/billing.service'; import { BackgroundCheckBillingService } from './background-check-billing.service'; import { BackgroundCheckPaymentService } from './background-check-payment.service'; import { BackgroundChecksService } from './background-checks.service'; -import { db } from '@db'; +import { db, Prisma } from '@db'; jest.mock('@db', () => { class PrismaClientKnownRequestError extends Error { @@ -17,11 +17,17 @@ jest.mock('@db', () => { return { BackgroundCheckStatus: { - failed: 'failed', invited: 'invited', + in_progress: 'in_progress', + in_review: 'in_review', + completed: 'completed', + completed_with_flags: 'completed_with_flags', + failed: 'failed', + cancelled: 'cancelled', }, Prisma: { PrismaClientKnownRequestError, + JsonNull: 'JsonNull', }, db: { backgroundCheckRequest: { @@ -31,6 +37,7 @@ jest.mock('@db', () => { create: jest.fn(), upsert: jest.fn(), update: jest.fn(), + delete: jest.fn(), }, backgroundCheckWebhookEvent: { create: jest.fn(), @@ -105,6 +112,7 @@ describe('background checks', () => { employeeName: 'Ada Lovelace', employeeEmail: 'ada@example.com', requesterEmail: 'admin@example.com', + idempotencyKey: 'comp-background-check:mem_1', }); expect(fetchSpy).toHaveBeenCalledWith( @@ -160,6 +168,7 @@ describe('background checks', () => { employeeName: 'Ada Lovelace', employeeEmail: 'ada@example.com', requesterEmail: 'admin@example.com', + idempotencyKey: 'comp-background-check:mem_1', }); const request = fetchSpy.mock.calls[0]?.[1]; @@ -186,6 +195,7 @@ describe('background checks', () => { employeeName: 'Ada Lovelace', employeeEmail: 'ada@example.com', requesterEmail: 'admin@example.com', + idempotencyKey: 'comp-background-check:mem_1', }), ).rejects.toThrow('Identity background check request failed.'); }); @@ -461,6 +471,278 @@ describe('background checks', () => { }); }); + describe('cancelForMember', () => { + function makeService() { + const identityClient = { createBackgroundCheck: jest.fn() }; + const paymentService = { charge: jest.fn(), refund: jest.fn() }; + const service = new BackgroundChecksService( + identityClient as unknown as BackgroundCheckIdentityClient, + paymentService as unknown as BackgroundCheckPaymentService, + ); + return { service, identityClient, paymentService }; + } + + it('sets status to cancelled for an in_progress check', async () => { + mockAsync< + Awaited> + >(mockedDb.backgroundCheckRequest.findUnique).mockResolvedValueOnce({ + id: 'bcr_1', + status: 'in_progress', + } as Awaited>); + mockAsync>>( + mockedDb.backgroundCheckRequest.update, + ).mockResolvedValueOnce({ id: 'bcr_1', status: 'cancelled' } as Awaited< + ReturnType + >); + + const { service } = makeService(); + const result = await service.cancelForMember({ + organizationId: 'org_1', + memberId: 'mem_1', + }); + + expect(mockedDb.backgroundCheckRequest.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ status: 'cancelled' }), + }), + ); + expect((result as { status: string }).status).toBe('cancelled'); + }); + + it('rejects cancelling a completed check', async () => { + mockAsync< + Awaited> + >(mockedDb.backgroundCheckRequest.findUnique).mockResolvedValueOnce({ + id: 'bcr_1', + status: 'completed', + } as Awaited>); + const { service } = makeService(); + await expect( + service.cancelForMember({ organizationId: 'org_1', memberId: 'mem_1' }), + ).rejects.toThrow( + "Cannot cancel a background check in 'completed' status.", + ); + expect(mockedDb.backgroundCheckRequest.update).not.toHaveBeenCalled(); + }); + + it('throws when no check exists', async () => { + mockAsync< + Awaited> + >(mockedDb.backgroundCheckRequest.findUnique).mockResolvedValueOnce( + null as Awaited< + ReturnType + >, + ); + const { service } = makeService(); + await expect( + service.cancelForMember({ organizationId: 'org_1', memberId: 'mem_1' }), + ).rejects.toThrow('Background check not found.'); + }); + }); + + describe('retryForMember', () => { + it('resubmits a failed check for free with an incremented attempt key', async () => { + mockAsync< + Awaited> + >(mockedDb.backgroundCheckRequest.findUnique).mockResolvedValueOnce({ + id: 'bcr_1', + status: 'failed', + rerunCount: 1, + employeeName: 'Ada Lovelace', + employeeEmail: 'ada@example.com', + } as Awaited>); + mockAsync>>( + mockedDb.backgroundCheckRequest.update, + ).mockResolvedValueOnce({ id: 'bcr_1', status: 'invited' } as Awaited< + ReturnType + >); + + const identityClient = { + createBackgroundCheck: jest + .fn() + .mockResolvedValue({ + id: 'check_new', + status: 'invited', + candidateUrl: 'https://c/x', + }), + }; + const paymentService = { charge: jest.fn(), refund: jest.fn() }; + const service = new BackgroundChecksService( + identityClient as unknown as BackgroundCheckIdentityClient, + paymentService as unknown as BackgroundCheckPaymentService, + ); + + await service.retryForMember({ + organizationId: 'org_1', + memberId: 'mem_1', + requesterEmail: 'admin@example.com', + }); + + expect(paymentService.charge).not.toHaveBeenCalled(); + expect(identityClient.createBackgroundCheck).toHaveBeenCalledWith( + expect.objectContaining({ + memberId: 'mem_1', + idempotencyKey: 'comp-background-check:bcr_1:2', + }), + ); + expect(mockedDb.backgroundCheckRequest.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + identityBackgroundCheckId: 'check_new', + status: 'invited', + rerunCount: 2, + identityStatus: null, + reportSnapshot: Prisma.JsonNull, + reportSyncedAt: null, + }), + }), + ); + }); + + it('rejects retrying an in_progress check', async () => { + mockAsync< + Awaited> + >(mockedDb.backgroundCheckRequest.findUnique).mockResolvedValueOnce({ + id: 'bcr_1', + status: 'in_progress', + rerunCount: 0, + } as Awaited>); + const identityClient = { createBackgroundCheck: jest.fn() }; + const service = new BackgroundChecksService( + identityClient as unknown as BackgroundCheckIdentityClient, + {} as unknown as BackgroundCheckPaymentService, + ); + await expect( + service.retryForMember({ + organizationId: 'org_1', + memberId: 'mem_1', + requesterEmail: 'a@b.c', + }), + ).rejects.toThrow( + "Cannot retry a background check in 'in_progress' status.", + ); + expect(identityClient.createBackgroundCheck).not.toHaveBeenCalled(); + }); + + it('keeps a cancelled check cancelled (no resurrection) and rethrows when Identity errors', async () => { + mockAsync< + Awaited> + >(mockedDb.backgroundCheckRequest.findUnique).mockResolvedValueOnce({ + id: 'bcr_1', + status: 'cancelled', + rerunCount: 0, + employeeName: 'Ada', + employeeEmail: 'ada@example.com', + } as Awaited>); + mockAsync>>( + mockedDb.backgroundCheckRequest.update, + ).mockResolvedValue( + {} as Awaited>, + ); + const identityClient = { + createBackgroundCheck: jest + .fn() + .mockRejectedValue(new Error('identity down')), + }; + const service = new BackgroundChecksService( + identityClient as unknown as BackgroundCheckIdentityClient, + { + charge: jest.fn(), + refund: jest.fn(), + } as unknown as BackgroundCheckPaymentService, + ); + + await expect( + service.retryForMember({ + organizationId: 'org_1', + memberId: 'mem_1', + requesterEmail: 'a@b.c', + }), + ).rejects.toThrow('identity down'); + expect(mockedDb.backgroundCheckRequest.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ status: 'cancelled' }), + }), + ); + }); + + it('throws when no check exists', async () => { + mockAsync< + Awaited> + >(mockedDb.backgroundCheckRequest.findUnique).mockResolvedValueOnce( + null as Awaited< + ReturnType + >, + ); + const identityClient = { createBackgroundCheck: jest.fn() }; + const service = new BackgroundChecksService( + identityClient as unknown as BackgroundCheckIdentityClient, + {} as unknown as BackgroundCheckPaymentService, + ); + await expect( + service.retryForMember({ + organizationId: 'org_1', + memberId: 'mem_1', + requesterEmail: 'a@b.c', + }), + ).rejects.toThrow('Background check not found.'); + expect(identityClient.createBackgroundCheck).not.toHaveBeenCalled(); + }); + }); + + describe('deleteForMember', () => { + it('hard-deletes an existing check', async () => { + mockAsync< + Awaited> + >(mockedDb.backgroundCheckRequest.findUnique).mockResolvedValueOnce({ + id: 'bcr_1', + status: 'failed', + } as Awaited>); + mockAsync>>( + mockedDb.backgroundCheckRequest.delete, + ).mockResolvedValueOnce({ id: 'bcr_1' } as Awaited< + ReturnType + >); + + const service = new BackgroundChecksService( + {} as unknown as BackgroundCheckIdentityClient, + {} as unknown as BackgroundCheckPaymentService, + ); + const result = await service.deleteForMember({ + organizationId: 'org_1', + memberId: 'mem_1', + }); + + expect(mockedDb.backgroundCheckRequest.delete).toHaveBeenCalledWith({ + where: { + organizationId_memberId: { + organizationId: 'org_1', + memberId: 'mem_1', + }, + }, + }); + expect(result).toEqual({ ok: true }); + }); + + it('throws when no check exists', async () => { + mockAsync< + Awaited> + >(mockedDb.backgroundCheckRequest.findUnique).mockResolvedValueOnce( + null as Awaited< + ReturnType + >, + ); + const service = new BackgroundChecksService( + {} as unknown as BackgroundCheckIdentityClient, + {} as unknown as BackgroundCheckPaymentService, + ); + await expect( + service.deleteForMember({ organizationId: 'org_1', memberId: 'mem_1' }), + ).rejects.toThrow('Background check not found.'); + expect(mockedDb.backgroundCheckRequest.delete).not.toHaveBeenCalled(); + }); + }); + it('includes background check and penetration test usage in billing status', async () => { const billingService = { getStatus: jest.fn().mockResolvedValue({ diff --git a/apps/api/src/background-checks/background-checks.service.ts b/apps/api/src/background-checks/background-checks.service.ts index 964305e548..7e87d96240 100644 --- a/apps/api/src/background-checks/background-checks.service.ts +++ b/apps/api/src/background-checks/background-checks.service.ts @@ -10,12 +10,13 @@ import { headerValue, verifyBackgroundCheckWebhookSignature, } from './background-check-webhook-signature'; +import { identityWebhookPayloadSchema } from './background-checks.types'; +import { fetchCompletedReportSnapshot } from './background-check-report-snapshot'; import { - identityWebhookPayloadSchema, -} from './background-checks.types'; -import { - fetchCompletedReportSnapshot, -} from './background-check-report-snapshot'; + cancelForMember as cancelForMemberFn, + deleteForMember as deleteForMemberFn, + retryForMember as retryForMemberFn, +} from './background-check-retry'; @Injectable() export class BackgroundChecksService { @@ -67,8 +68,11 @@ export class BackgroundChecksService { // Step 1: Claim the record slot before charging. Catches the TOCTOU race // where two concurrent requests both pass the getForMember check. + let created: Awaited< + ReturnType + >; try { - await db.backgroundCheckRequest.create({ + created = await db.backgroundCheckRequest.create({ data: { organizationId, memberId, @@ -123,6 +127,10 @@ export class BackgroundChecksService { employeeName, employeeEmail, requesterEmail, + // Key on the record's unique id (not memberId) so a delete + + // re-request creates a genuinely fresh vendor check instead of + // colliding with the original request's idempotency key. + idempotencyKey: `comp-background-check:${created.id}`, }); } catch (error) { const refundId = await this.paymentService.refund({ @@ -173,7 +181,10 @@ export class BackgroundChecksService { throw new NotFoundException('Background check not found.'); } - if (!record.identityBackgroundCheckId || !process.env.BACKGROUND_CHECK_API_KEY) { + if ( + !record.identityBackgroundCheckId || + !process.env.BACKGROUND_CHECK_API_KEY + ) { return { record }; } @@ -195,8 +206,11 @@ export class BackgroundChecksService { } verifyBackgroundCheckWebhookSignature({ rawBody, headers }); - const payload = identityWebhookPayloadSchema.parse(JSON.parse(rawBody.toString('utf8'))); - const eventId = headerValue(headers, 'x-background-check-event-id') ?? payload.eventId; + const payload = identityWebhookPayloadSchema.parse( + JSON.parse(rawBody.toString('utf8')), + ); + const eventId = + headerValue(headers, 'x-background-check-event-id') ?? payload.eventId; const eventType = headerValue(headers, 'x-background-check-event-type') ?? payload.type; @@ -234,6 +248,12 @@ export class BackgroundChecksService { } } + // Cancelled is terminal Comp-side: record the event for audit but never + // let a late Identity webhook resurrect the status. + if (record.status === BackgroundCheckStatus.cancelled) { + return { ok: true, ...(isDuplicate ? { duplicate: true } : {}) }; + } + const reportSnapshot = await fetchCompletedReportSnapshot({ identityClient: this.identityClient, identityBackgroundCheckId: payload.data.id, @@ -267,6 +287,32 @@ export class BackgroundChecksService { return { ok: true, ...(isDuplicate ? { duplicate: true } : {}) }; } + async cancelForMember(params: { organizationId: string; memberId: string }) { + return cancelForMemberFn({ + ...params, + getForMember: (p) => this.getForMember(p), + }); + } + + async retryForMember(params: { + organizationId: string; + memberId: string; + requesterEmail: string; + }) { + return retryForMemberFn({ + ...params, + identityClient: this.identityClient, + getForMember: (p) => this.getForMember(p), + }); + } + + async deleteForMember(params: { organizationId: string; memberId: string }) { + return deleteForMemberFn({ + ...params, + getForMember: (p) => this.getForMember(p), + }); + } + private isUniqueConstraintError(error: unknown): boolean { return ( error instanceof Prisma.PrismaClientKnownRequestError && diff --git a/apps/api/src/background-checks/background-checks.types.ts b/apps/api/src/background-checks/background-checks.types.ts index 7783218360..7966f18c14 100644 --- a/apps/api/src/background-checks/background-checks.types.ts +++ b/apps/api/src/background-checks/background-checks.types.ts @@ -45,6 +45,11 @@ export const identityWebhookPayloadSchema = z.object({ }), }); -export type IdentityCreateResponse = z.infer; -export type IdentityWebhookPayload = z.infer; -export type BackgroundCheckStatusValue = (typeof backgroundCheckStatuses)[number]; +export type IdentityCreateResponse = z.infer< + typeof identityCreateResponseSchema +>; +export type IdentityWebhookPayload = z.infer< + typeof identityWebhookPayloadSchema +>; +export type BackgroundCheckStatusValue = + (typeof backgroundCheckStatuses)[number]; diff --git a/apps/api/src/background-checks/dto/attach-custom-background-check.dto.ts b/apps/api/src/background-checks/dto/attach-custom-background-check.dto.ts index e41db725ac..07ad421f76 100644 --- a/apps/api/src/background-checks/dto/attach-custom-background-check.dto.ts +++ b/apps/api/src/background-checks/dto/attach-custom-background-check.dto.ts @@ -1,4 +1,10 @@ -import { IsEmail, IsOptional, IsString, MaxLength, MinLength } from 'class-validator'; +import { + IsEmail, + IsOptional, + IsString, + MaxLength, + MinLength, +} from 'class-validator'; import { UploadAttachmentDto } from '../../attachments/upload-attachment.dto'; export class AttachCustomBackgroundCheckDto extends UploadAttachmentDto { diff --git a/apps/api/src/background-checks/dto/request-background-check.dto.ts b/apps/api/src/background-checks/dto/request-background-check.dto.ts index 0662a3c532..9a31bbee15 100644 --- a/apps/api/src/background-checks/dto/request-background-check.dto.ts +++ b/apps/api/src/background-checks/dto/request-background-check.dto.ts @@ -1,4 +1,10 @@ -import { IsEmail, IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator'; +import { + IsEmail, + IsNotEmpty, + IsOptional, + IsString, + MaxLength, +} from 'class-validator'; import { Transform } from 'class-transformer'; export class RequestBackgroundCheckDto { 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 ee76cc0f2f..da98db0d51 100644 --- a/apps/api/src/frameworks/frameworks-people-score.helper.spec.ts +++ b/apps/api/src/frameworks/frameworks-people-score.helper.spec.ts @@ -187,14 +187,14 @@ describe('computePeopleScore', () => { 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 []; - }); + (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', @@ -209,6 +209,64 @@ describe('computePeopleScore', () => { expect(score).toEqual({ total: 2, completed: 2 }); }); + it('does not require a background check for auditor-only members', async () => { + const auditorMembers = [ + { + id: 'mem_1', + role: 'auditor', + user: { id: 'usr_1', email: 'a@example.com', role: 'auditor' }, + }, + { + id: 'mem_2', + role: 'owner', + user: { id: 'usr_2', email: 'b@example.com', role: 'owner' }, + }, + ]; + mockFilterComplianceMembers.mockResolvedValue(auditorMembers); + (mockDb.backgroundCheckRequest.findMany as jest.Mock).mockResolvedValue([]); + (mockDb.member.findMany as jest.Mock).mockResolvedValue([]); + + const score = await computePeopleScore({ + organizationId: 'org_1', + allPolicies: [], + employees: auditorMembers, + securityTrainingStepEnabled: false, + deviceAgentStepEnabled: false, + backgroundCheckStepEnabled: true, + hasHipaaFramework: false, + }); + + // mem_1 (auditor-only) → no BG check required → complete + // mem_2 (owner) → no BG check, not exempt → not complete + expect(score).toEqual({ total: 2, completed: 1 }); + }); + + it('still requires a background check for members with auditor plus another role', async () => { + const mixedMembers = [ + { + id: 'mem_1', + role: 'auditor,employee', + user: { id: 'usr_1', email: 'a@example.com', role: 'employee' }, + }, + ]; + mockFilterComplianceMembers.mockResolvedValue(mixedMembers); + (mockDb.backgroundCheckRequest.findMany as jest.Mock).mockResolvedValue([]); + (mockDb.member.findMany as jest.Mock).mockResolvedValue([]); + + const score = await computePeopleScore({ + organizationId: 'org_1', + allPolicies: [], + employees: mixedMembers, + securityTrainingStepEnabled: false, + deviceAgentStepEnabled: false, + backgroundCheckStepEnabled: true, + hasHipaaFramework: false, + }); + + // auditor+employee is NOT auditor-only → still requires a BG check → not complete + expect(score).toEqual({ total: 1, completed: 0 }); + }); + it('skips the exempt query entirely when backgroundCheckStepEnabled is false', async () => { (mockDb.member.findMany as jest.Mock).mockClear(); diff --git a/apps/api/src/frameworks/frameworks-people-score.helper.ts b/apps/api/src/frameworks/frameworks-people-score.helper.ts index 427ff88da5..ac0b116717 100644 --- a/apps/api/src/frameworks/frameworks-people-score.helper.ts +++ b/apps/api/src/frameworks/frameworks-people-score.helper.ts @@ -8,6 +8,20 @@ const COMPLETED_BACKGROUND_CHECK_STATUSES = [ BackgroundCheckStatus.completed_with_flags, ]; +/** + * Auditor-only members are not subject to people-security requirements like + * background checks (CS-416). A member counts as auditor-only when every one of + * their roles is `auditor` — a member with `auditor` plus another role still + * carries that other role's obligations. + */ +function isAuditorOnly(role: string): boolean { + const roles = role + .split(',') + .map((r) => r.trim()) + .filter(Boolean); + return roles.length > 0 && roles.every((r) => r === 'auditor'); +} + interface ScorePolicy { isRequiredToSign: boolean; status: string; @@ -106,7 +120,9 @@ export async function computePeopleScore({ ? membersWithInstalledDevices.has(employee.id) : true; const memberRequiresBgCheck = - backgroundCheckStepEnabled && !exemptMemberIds.has(employee.id); + backgroundCheckStepEnabled && + !exemptMemberIds.has(employee.id) && + !isAuditorOnly(employee.role); const hasCompletedBackgroundCheck = memberRequiresBgCheck ? membersWithCompletedBackgroundChecks.has(employee.id) : true; diff --git a/apps/api/src/soa/soa.service.ts b/apps/api/src/soa/soa.service.ts index ef43aea18b..aec8b39be4 100644 --- a/apps/api/src/soa/soa.service.ts +++ b/apps/api/src/soa/soa.service.ts @@ -557,12 +557,18 @@ export class SOAService { // Generate answer from pre-fetched content const soaResult = await generateSOAControlAnswer(question, similarContent); - // If no answer, default to YES + // If no answer, default to YES with a family-appropriate justification if (!soaResult.answer) { - return createDefaultYesResult(question.id, index, send); + return createDefaultYesResult(question.id, index, send, controlClosure); } - return parseAndProcessSOAAnswer(question.id, index, soaResult.answer, send); + return parseAndProcessSOAAnswer( + question.id, + index, + soaResult.answer, + send, + controlClosure, + ); } async saveAnswersToDatabase( diff --git a/apps/api/src/soa/utils/constants.spec.ts b/apps/api/src/soa/utils/constants.spec.ts new file mode 100644 index 0000000000..126c260bcc --- /dev/null +++ b/apps/api/src/soa/utils/constants.spec.ts @@ -0,0 +1,75 @@ +import { + DEFAULT_INCLUSION_JUSTIFICATION, + INCLUSION_JUSTIFICATIONS, + getInclusionJustification, +} from './constants'; + +describe('getInclusionJustification', () => { + it('returns the access-control justification for organisational and technical access controls', () => { + const accessClosures = ['5.15', '5.16', '5.17', '5.18', '8.2', '8.3', '8.4', '8.5']; + for (const closure of accessClosures) { + expect(getInclusionJustification(closure)).toBe( + INCLUSION_JUSTIFICATIONS.accessControl, + ); + } + }); + + it('returns the supplier/cloud justification for 5.19–5.23', () => { + for (const closure of ['5.19', '5.20', '5.21', '5.22', '5.23']) { + expect(getInclusionJustification(closure)).toBe( + INCLUSION_JUSTIFICATIONS.supplierCloud, + ); + } + }); + + it('returns the incident-management justification for 5.24–5.30 and 6.8', () => { + const incidentClosures = ['5.24', '5.25', '5.26', '5.27', '5.28', '5.29', '5.30', '6.8']; + for (const closure of incidentClosures) { + expect(getInclusionJustification(closure)).toBe( + INCLUSION_JUSTIFICATIONS.incidentManagement, + ); + } + }); + + it('returns the secure-development justification for 8.25–8.34', () => { + const devClosures = ['8.25', '8.26', '8.27', '8.28', '8.29', '8.30', '8.31', '8.32', '8.33', '8.34']; + for (const closure of devClosures) { + expect(getInclusionJustification(closure)).toBe( + INCLUSION_JUSTIFICATIONS.secureDevelopment, + ); + } + }); + + it('returns the legal/privacy/compliance justification for 5.31–5.36 and data-protection technical controls', () => { + const legalClosures = ['5.31', '5.32', '5.33', '5.34', '5.35', '5.36', '8.10', '8.11', '8.12']; + for (const closure of legalClosures) { + expect(getInclusionJustification(closure)).toBe( + INCLUSION_JUSTIFICATIONS.legalPrivacyCompliance, + ); + } + }); + + it('returns the physical/remote-working justification for 6.7 and every section-7 control', () => { + expect(getInclusionJustification('6.7')).toBe( + INCLUSION_JUSTIFICATIONS.physicalRemoteWorking, + ); + for (let n = 1; n <= 14; n += 1) { + expect(getInclusionJustification(`7.${n}`)).toBe( + INCLUSION_JUSTIFICATIONS.physicalRemoteWorking, + ); + } + }); + + it('returns the generic ISMS-scope justification for controls outside the six named families', () => { + // Organisational policies, HR, general technical controls outside the named families. + for (const closure of ['5.1', '5.2', '6.1', '6.2', '8.1', '8.15', '8.20']) { + expect(getInclusionJustification(closure)).toBe(DEFAULT_INCLUSION_JUSTIFICATION); + } + }); + + it('returns null when the closure is missing', () => { + expect(getInclusionJustification(null)).toBeNull(); + expect(getInclusionJustification(undefined)).toBeNull(); + expect(getInclusionJustification('')).toBeNull(); + }); +}); diff --git a/apps/api/src/soa/utils/constants.ts b/apps/api/src/soa/utils/constants.ts index 5fa5db8769..a5954d1268 100644 --- a/apps/api/src/soa/utils/constants.ts +++ b/apps/api/src/soa/utils/constants.ts @@ -13,6 +13,99 @@ export const ISO27001_FRAMEWORK_NAMES = ['ISO 27001', 'iso27001', 'ISO27001']; export const FULLY_REMOTE_JUSTIFICATION = 'This control is not applicable as our organization operates fully remotely.'; +// Generic fallback inclusion justification used when no family-specific text applies. +export const DEFAULT_INCLUSION_JUSTIFICATION = + 'Applicable because this control is within our ISMS scope and requires documented implementation and rationale.'; + +/** + * Default inclusion justifications by ISO 27001:2022 control family. + * Used when a control is deemed Applicable but the LLM did not supply a justification. + * Controls outside these named families intentionally receive no default justification. + */ +export const INCLUSION_JUSTIFICATIONS = { + accessControl: + 'Applicable because the organisation must restrict access to systems and information based on business need, user role, and information security risk.', + supplierCloud: + 'Applicable because third-party and cloud services are used within the ISMS scope and must be governed to manage supplier and service-provider risk.', + incidentManagement: + 'Applicable because the organisation requires defined processes to identify, report, assess, respond to, and learn from information security events and incidents.', + secureDevelopment: + 'Applicable because software or system changes are developed, configured, tested, or deployed within the ISMS scope.', + legalPrivacyCompliance: + 'Applicable because legal, regulatory, contractual, privacy, and records-protection obligations must be identified and met.', + physicalRemoteWorking: + 'Applicable only where physical, endpoint, home-working, or off-premises asset risks exist; otherwise the control should be excluded with a clear rationale.', +} as const; + +// Maps each ISO 27001:2022 control closure code to a family key in INCLUSION_JUSTIFICATIONS. +const CLOSURE_TO_FAMILY: Record = { + // Access control (organizational + technical) + '5.15': 'accessControl', + '5.16': 'accessControl', + '5.17': 'accessControl', + '5.18': 'accessControl', + '8.2': 'accessControl', + '8.3': 'accessControl', + '8.4': 'accessControl', + '8.5': 'accessControl', + // Supplier and cloud + '5.19': 'supplierCloud', + '5.20': 'supplierCloud', + '5.21': 'supplierCloud', + '5.22': 'supplierCloud', + '5.23': 'supplierCloud', + // Incident management and continuity + '5.24': 'incidentManagement', + '5.25': 'incidentManagement', + '5.26': 'incidentManagement', + '5.27': 'incidentManagement', + '5.28': 'incidentManagement', + '5.29': 'incidentManagement', + '5.30': 'incidentManagement', + '6.8': 'incidentManagement', + // Secure development + '8.25': 'secureDevelopment', + '8.26': 'secureDevelopment', + '8.27': 'secureDevelopment', + '8.28': 'secureDevelopment', + '8.29': 'secureDevelopment', + '8.30': 'secureDevelopment', + '8.31': 'secureDevelopment', + '8.32': 'secureDevelopment', + '8.33': 'secureDevelopment', + '8.34': 'secureDevelopment', + // Legal, privacy, compliance, data protection + '5.31': 'legalPrivacyCompliance', + '5.32': 'legalPrivacyCompliance', + '5.33': 'legalPrivacyCompliance', + '5.34': 'legalPrivacyCompliance', + '5.35': 'legalPrivacyCompliance', + '5.36': 'legalPrivacyCompliance', + '8.10': 'legalPrivacyCompliance', + '8.11': 'legalPrivacyCompliance', + '8.12': 'legalPrivacyCompliance', + // Physical and remote working (all section 7 plus 6.7) + '6.7': 'physicalRemoteWorking', +}; + +/** + * Returns a default inclusion justification appropriate to the control's family, + * or null when the control does not fall into one of the named families. + */ +export function getInclusionJustification( + closure: string | null | undefined, +): string | null { + if (!closure) return null; + + // All of section 7 (7.1–7.14) is physical security. + if (closure.startsWith('7.')) { + return INCLUSION_JUSTIFICATIONS.physicalRemoteWorking; + } + + const family = CLOSURE_TO_FAMILY[closure]; + return family ? INCLUSION_JUSTIFICATIONS[family] : DEFAULT_INCLUSION_JUSTIFICATION; +} + // System prompt for SOA RAG generation export const SOA_RAG_SYSTEM_PROMPT = `You are an expert organizational analyst conducting a comprehensive assessment of a company for ISO 27001 compliance. diff --git a/apps/api/src/soa/utils/soa-answer-parser.spec.ts b/apps/api/src/soa/utils/soa-answer-parser.spec.ts new file mode 100644 index 0000000000..abf348a38b --- /dev/null +++ b/apps/api/src/soa/utils/soa-answer-parser.spec.ts @@ -0,0 +1,44 @@ +import { createDefaultYesResult, parseAndProcessSOAAnswer } from './soa-answer-parser'; +import { DEFAULT_INCLUSION_JUSTIFICATION } from './constants'; + +describe('createDefaultYesResult', () => { + const send = jest.fn(); + + beforeEach(() => { + send.mockClear(); + }); + + it('always produces a non-null justification, even when closure is missing', () => { + for (const closure of [undefined, null, '']) { + const result = createDefaultYesResult('q1', 0, send, closure); + expect(result.isApplicable).toBe(true); + expect(result.justification).toBe(DEFAULT_INCLUSION_JUSTIFICATION); + } + }); + + it('uses the family-specific justification when closure matches a named family', () => { + const result = createDefaultYesResult('q1', 0, send, '5.15'); + expect(result.justification).not.toBeNull(); + expect(result.justification).toContain('access'); + }); +}); + +describe('parseAndProcessSOAAnswer YES branch', () => { + const send = jest.fn(); + + beforeEach(() => { + send.mockClear(); + }); + + it('falls back to a non-null justification on YES when both the LLM and closure are empty', () => { + const result = parseAndProcessSOAAnswer( + 'q1', + 0, + JSON.stringify({ isApplicable: 'YES', justification: null }), + send, + null, + ); + expect(result.isApplicable).toBe(true); + expect(result.justification).toBe(DEFAULT_INCLUSION_JUSTIFICATION); + }); +}); diff --git a/apps/api/src/soa/utils/soa-answer-parser.ts b/apps/api/src/soa/utils/soa-answer-parser.ts index bd3e57aeee..e0c773778c 100644 --- a/apps/api/src/soa/utils/soa-answer-parser.ts +++ b/apps/api/src/soa/utils/soa-answer-parser.ts @@ -1,6 +1,8 @@ import { isInsufficientDataAnswer, FULLY_REMOTE_JUSTIFICATION, + DEFAULT_INCLUSION_JUSTIFICATION, + getInclusionJustification, } from './constants'; export interface SOAQuestionResult { @@ -35,19 +37,25 @@ export type SOAStreamSender = (data: { }) => void; /** - * Creates a default YES result (used when insufficient data) + * Creates a default YES result (used when insufficient data). + * Populates a family-appropriate inclusion justification so ISO 27001's + * requirement of a justification for every control is satisfied. */ export function createDefaultYesResult( questionId: string, index: number, send: SOAStreamSender, + closure?: string | null, ): SOAQuestionResult { + const justification = + getInclusionJustification(closure) ?? DEFAULT_INCLUSION_JUSTIFICATION; + send({ type: 'answer', questionId, questionIndex: index, isApplicable: true, - justification: null, + justification, success: true, insufficientData: false, }); @@ -55,7 +63,7 @@ export function createDefaultYesResult( return { questionId, isApplicable: true, - justification: null, + justification, success: true, insufficientData: false, }; @@ -104,6 +112,7 @@ export function parseAndProcessSOAAnswer( index: number, answerText: string, send: SOAStreamSender, + closure?: string | null, ): SOAQuestionResult { // Parse JSON response let parsedAnswer: { @@ -119,7 +128,7 @@ export function parseAndProcessSOAAnswer( // Check for insufficient data indicators - if insufficient, default to YES if (isInsufficientDataAnswer(trimmedAnswer)) { - return createDefaultYesResult(questionId, index, send); + return createDefaultYesResult(questionId, index, send, closure); } // Try to extract YES/NO and justification from text @@ -145,7 +154,7 @@ export function parseAndProcessSOAAnswer( parsedAnswer.isApplicable === 'INSUFFICIENT_DATA' || parsedAnswer.isApplicable.toUpperCase().includes('INSUFFICIENT') ) { - return createDefaultYesResult(questionId, index, send); + return createDefaultYesResult(questionId, index, send, closure); } // Parse isApplicable @@ -163,12 +172,24 @@ export function parseAndProcessSOAAnswer( finalIsApplicable = false; } else { // Can't determine YES/NO - default to YES - return createDefaultYesResult(questionId, index, send); + return createDefaultYesResult(questionId, index, send, closure); } - // Get justification (only if NO) + // Trim and normalise the LLM-provided justification. + const llmJustification = + typeof parsedAnswer.justification === 'string' + ? parsedAnswer.justification.trim() || null + : null; + + // For NO: keep the LLM's exclusion justification (may be null and edited later). + // For YES: keep the LLM's inclusion justification, or fall back to the family default + // so ISO 27001's "justify every control" requirement is always satisfied. const justification = - finalIsApplicable === false ? parsedAnswer.justification || null : null; + finalIsApplicable === false + ? llmJustification + : llmJustification && !isInsufficientDataAnswer(llmJustification) + ? llmJustification + : (getInclusionJustification(closure) ?? DEFAULT_INCLUSION_JUSTIFICATION); send({ type: 'answer', diff --git a/apps/api/src/soa/utils/soa-storage.ts b/apps/api/src/soa/utils/soa-storage.ts index e1e64309c7..70dd7c538c 100644 --- a/apps/api/src/soa/utils/soa-storage.ts +++ b/apps/api/src/soa/utils/soa-storage.ts @@ -52,9 +52,9 @@ export async function saveAnswersToDatabase( }); } - // Store justification in answer field only if isApplicable is NO - const answerValue = - result.isApplicable === false ? result.justification : null; + // Store justification in the answer field for both YES and NO so the + // SoA always carries a justification for every control (per ISO 27001). + const answerValue = result.justification ?? null; // Create new answer await db.sOAAnswer.create({ 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 0000000000..0de8631702 --- /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 0000000000..075eb70539 --- /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(), +}); diff --git a/apps/app/src/app/(app)/[orgId]/admin/components/AdminSidebar.tsx b/apps/app/src/app/(app)/[orgId]/admin/components/AdminSidebar.tsx index 58cffeea82..94e35dd116 100644 --- a/apps/app/src/app/(app)/[orgId]/admin/components/AdminSidebar.tsx +++ b/apps/app/src/app/(app)/[orgId]/admin/components/AdminSidebar.tsx @@ -14,7 +14,16 @@ export function AdminSidebar({ orgId }: AdminSidebarProps) { const items = [ { id: 'organizations', label: 'Organizations', path: `/${orgId}/admin/organizations` }, { id: 'integrations', label: 'Integrations', path: `/${orgId}/admin/integrations` }, - { id: 'timeline-templates', label: 'Timeline Templates', path: `/${orgId}/admin/timeline-templates` }, + { + id: 'timeline-templates', + label: 'Timeline Templates', + path: `/${orgId}/admin/timeline-templates`, + }, + { + id: 'finding-templates', + label: 'Finding Templates', + path: `/${orgId}/admin/finding-templates`, + }, ]; const isPathActive = (path: string) => pathname.startsWith(path); @@ -23,9 +32,7 @@ export function AdminSidebar({ orgId }: AdminSidebarProps) { {items.map((item) => ( - - {item.label} - + {item.label} ))} diff --git a/apps/app/src/app/(app)/[orgId]/admin/finding-templates/components/DeleteTemplateDialog.tsx b/apps/app/src/app/(app)/[orgId]/admin/finding-templates/components/DeleteTemplateDialog.tsx new file mode 100644 index 0000000000..e911395c12 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/admin/finding-templates/components/DeleteTemplateDialog.tsx @@ -0,0 +1,65 @@ +'use client'; + +import { + useAdminFindingTemplates, + type FindingTemplate, +} from '@/hooks/use-admin-finding-templates'; +import { api } from '@/lib/api-client'; +import { + AlertDialog, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + Button, +} from '@trycompai/design-system'; +import { useState } from 'react'; +import { toast } from 'sonner'; + +interface DeleteTemplateDialogProps { + template: FindingTemplate | null; + onClose: () => void; +} + +export function DeleteTemplateDialog({ template, onClose }: DeleteTemplateDialogProps) { + const { mutate } = useAdminFindingTemplates(); + const [deleting, setDeleting] = useState(false); + + const handleDelete = async () => { + if (!template) return; + setDeleting(true); + const res = await api.delete(`/v1/finding-template/${template.id}`); + setDeleting(false); + + if (res.error) { + toast.error(res.error); + return; + } + + toast.success('Template deleted'); + mutate(); + onClose(); + }; + + return ( + !o && onClose()}> + + + Delete template + + Are you sure you want to delete “{template?.title}”? This action cannot be + undone. + + + + Cancel + + + + + ); +} diff --git a/apps/app/src/app/(app)/[orgId]/admin/finding-templates/components/FindingTemplatesList.tsx b/apps/app/src/app/(app)/[orgId]/admin/finding-templates/components/FindingTemplatesList.tsx new file mode 100644 index 0000000000..a23a4cc10a --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/admin/finding-templates/components/FindingTemplatesList.tsx @@ -0,0 +1,233 @@ +'use client'; + +import { + useAdminFindingTemplates, + type FindingTemplate, +} from '@/hooks/use-admin-finding-templates'; +import { + Badge, + Button, + Input, + PageHeader, + PageLayout, + Section, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, + Text, +} from '@trycompai/design-system'; +import { Add, Edit } from '@trycompai/design-system/icons'; +import { useMemo, useState } from 'react'; +import { CATEGORY_LABELS, FINDING_TEMPLATE_CATEGORIES } from './constants'; +import { DeleteTemplateDialog } from './DeleteTemplateDialog'; +import { TemplateFormDialog } from './TemplateFormDialog'; + +const PAGE_SIZE = 10; + +export function FindingTemplatesList() { + const { templates, isLoading } = useAdminFindingTemplates(); + + const [search, setSearch] = useState(''); + const [categoryFilter, setCategoryFilter] = useState('all'); + const [page, setPage] = useState(1); + + const [formOpen, setFormOpen] = useState(false); + const [editing, setEditing] = useState(null); + const [deleteTarget, setDeleteTarget] = useState(null); + + const filtered = useMemo(() => { + const query = search.trim().toLowerCase(); + return templates.filter((template) => { + if (categoryFilter !== 'all' && template.category !== categoryFilter) { + return false; + } + if (!query) return true; + return ( + template.title.toLowerCase().includes(query) || + template.content.toLowerCase().includes(query) + ); + }); + }, [templates, search, categoryFilter]); + + const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE)); + const currentPage = Math.min(page, totalPages); + const pageStart = (currentPage - 1) * PAGE_SIZE; + const pageItems = filtered.slice(pageStart, pageStart + PAGE_SIZE); + + const openCreate = () => { + setEditing(null); + setFormOpen(true); + }; + + const openEdit = (template: FindingTemplate) => { + setEditing(template); + setFormOpen(true); + }; + + if (isLoading) { + return ( + }> +
+ Loading templates... +
+
+ ); + } + + return ( + } onClick={openCreate}> + Add Template + + } + /> + } + > +
+
+
+ { + setSearch(e.target.value); + setPage(1); + }} + placeholder="Search title or content..." + /> +
+
+ +
+
+ + {filtered.length === 0 ? ( +
+ {templates.length === 0 + ? "No finding templates yet. Click 'Add Template' to create one." + : 'No templates match your filters.'} +
+ ) : ( + <> + + + + Title + Category + Content + Order + Actions + + + + {pageItems.map((template) => ( + + + + {template.title} + + + + + {CATEGORY_LABELS[template.category] ?? template.category} + + + +
+ {template.content} +
+
+ + {template.order} + + +
+ + +
+
+
+ ))} +
+
+ + {filtered.length > PAGE_SIZE && ( +
+ + {pageStart + 1}–{Math.min(pageStart + PAGE_SIZE, filtered.length)} of{' '} + {filtered.length} + +
+ + + {currentPage} / {totalPages} + + +
+
+ )} + + )} +
+ + setFormOpen(false)} /> + setDeleteTarget(null)} /> +
+ ); +} diff --git a/apps/app/src/app/(app)/[orgId]/admin/finding-templates/components/TemplateFormDialog.tsx b/apps/app/src/app/(app)/[orgId]/admin/finding-templates/components/TemplateFormDialog.tsx new file mode 100644 index 0000000000..186f3e28d7 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/admin/finding-templates/components/TemplateFormDialog.tsx @@ -0,0 +1,188 @@ +'use client'; + +import { + useAdminFindingTemplates, + type FindingTemplate, +} from '@/hooks/use-admin-finding-templates'; +import { api } from '@/lib/api-client'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { + Button, + Input, + Label, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + Sheet, + SheetBody, + SheetContent, + SheetHeader, + SheetTitle, + Stack, + Text, + Textarea, +} from '@trycompai/design-system'; +import { useEffect, useState } from 'react'; +import { Controller, useForm } from 'react-hook-form'; +import { toast } from 'sonner'; +import { z } from 'zod'; +import { FINDING_TEMPLATE_CATEGORIES } from './constants'; + +const templateSchema = z.object({ + category: z.string().min(1, 'Category is required'), + title: z.string().min(1, 'Title is required').max(500), + content: z.string().min(1, 'Content is required').max(50000), + order: z.number().int().min(0), +}); + +type TemplateFormValues = z.infer; + +interface TemplateFormDialogProps { + open: boolean; + template: FindingTemplate | null; + onClose: () => void; +} + +const emptyDefaults: TemplateFormValues = { + category: FINDING_TEMPLATE_CATEGORIES[0].value, + title: '', + content: '', + order: 0, +}; + +export function TemplateFormDialog({ open, template, onClose }: TemplateFormDialogProps) { + const { mutate } = useAdminFindingTemplates(); + const [saving, setSaving] = useState(false); + const isEdit = Boolean(template); + + const { + register, + handleSubmit, + reset, + control, + formState: { errors }, + } = useForm({ + resolver: zodResolver(templateSchema), + defaultValues: emptyDefaults, + }); + + // Populate the form whenever it opens (create -> blank, edit -> template). + useEffect(() => { + if (!open) return; + reset( + template + ? { + category: template.category, + title: template.title, + content: template.content, + order: template.order, + } + : emptyDefaults, + ); + }, [open, template, reset]); + + const handleSave = async (values: TemplateFormValues) => { + setSaving(true); + const res = isEdit + ? await api.patch(`/v1/finding-template/${template!.id}`, values) + : await api.post('/v1/finding-template', values); + setSaving(false); + + if (res.error) { + toast.error(res.error); + return; + } + + toast.success(isEdit ? 'Template updated' : 'Template created'); + mutate(); + onClose(); + }; + + return ( + !o && onClose()}> + + + {isEdit ? 'Edit Template' : 'New Template'} + + +
+ +
+ + ( + + )} + /> + {errors.category && ( + + {errors.category.message} + + )} +
+ +
+ + + {errors.title && ( + + {errors.title.message} + + )} +
+ +
+ +