From 0456df6e832b78a8ef4938bcdc5a5416cf597b6b Mon Sep 17 00:00:00 2001 From: Lewis Carhart Date: Wed, 29 Apr 2026 20:03:40 +0100 Subject: [PATCH 1/8] feat(background-checks): add employee background checks --- apps/api/.env.example | 11 +- apps/api/src/app.module.ts | 2 + .../background-check-billing.controller.ts | 70 +++ .../background-check-billing.service.ts | 253 +++++++++ .../background-check-custom.service.spec.ts | 122 ++++ .../background-check-custom.service.ts | 92 +++ .../background-check-identity.client.ts | 127 +++++ .../background-check-payment.service.spec.ts | 75 +++ .../background-check-payment.service.ts | 104 ++++ .../background-check-report-snapshot.ts | 48 ++ .../background-check-webhook-signature.ts | 51 ++ .../background-check-webhook.service.spec.ts | 278 +++++++++ .../background-checks.controller.ts | 132 +++++ .../background-checks.module.ts | 31 + .../background-checks.service.spec.ts | 418 ++++++++++++++ .../background-checks.service.ts | 290 ++++++++++ .../background-checks.types.ts | 50 ++ .../dto/attach-custom-background-check.dto.ts | 18 + .../dto/background-check-billing.dto.ts | 19 + .../dto/request-background-check.dto.ts | 15 + .../frameworks-compliance-score.helper.ts | 101 ++++ .../frameworks-people-score.helper.spec.ts | 79 +++ .../frameworks-people-score.helper.ts | 200 +++++++ .../frameworks-scores.helper.spec.ts | 16 +- .../frameworks/frameworks-scores.helper.ts | 221 +------- apps/api/src/main.ts | 11 +- .../src/people/dto/people-responses.dto.ts | 26 +- apps/api/src/people/utils/member-queries.ts | 9 + apps/app/.env.example | 3 + .../[employeeId]/background-check/page.tsx | 94 +++ .../components/BackgroundCheckDetailsForm.tsx | 115 ++++ .../BackgroundCheckExternalReport.tsx | 220 ++++++++ .../components/BackgroundCheckReport.tsx | 265 +++++++++ .../BackgroundCheckShareableSummary.tsx | 188 ++++++ .../BackgroundCheckStatusView.test.tsx | 294 ++++++++++ .../components/BackgroundCheckStatusView.tsx | 242 ++++++++ .../components/BackgroundCheckWizardParts.tsx | 94 +++ .../CustomBackgroundCheckUpload.test.tsx | 84 +++ .../CustomBackgroundCheckUpload.tsx | 141 +++++ .../[employeeId]/components/Employee.tsx | 122 +++- .../EmployeeBackgroundCheck.test.tsx | 292 ++++++++++ .../components/EmployeeBackgroundCheck.tsx | 296 ++++++++++ .../components/EmployeeDevice.tsx | 182 ++++++ .../components/EmployeePageHeader.test.tsx | 48 ++ .../components/EmployeePageHeader.tsx | 38 ++ .../components/EmployeePolicies.tsx | 40 ++ .../components/EmployeeTasks.test.tsx | 60 +- .../[employeeId]/components/EmployeeTasks.tsx | 464 --------------- .../components/EmployeeTraining.tsx | 230 ++++++++ .../components/backgroundCheckForm.ts | 87 +++ .../backgroundCheckShareableSummaryData.ts | 163 ++++++ .../components/backgroundCheckTypes.ts | 41 ++ .../components/downloadBase64Pdf.ts | 18 + .../[orgId]/people/[employeeId]/page.tsx | 27 +- .../people/all/components/MemberRow.test.tsx | 87 +-- .../people/all/components/MemberRow.tsx | 226 ++++---- .../MemberRowBackgroundCheck.test.tsx | 84 +++ .../all/components/PendingInvitationRow.tsx | 7 - .../people/all/components/TeamMembers.tsx | 15 + .../all/components/TeamMembersClient.tsx | 8 +- .../BackgroundCheckVerifiedTick.tsx | 33 ++ bun.lock | 2 +- .../migration.sql | 89 +++ .../migration.sql | 2 + .../migration.sql | 3 + .../migration.sql | 1 + packages/db/prisma/schema/attachments.prisma | 1 + packages/db/prisma/schema/auth.prisma | 1 + .../db/prisma/schema/background-check.prisma | 63 +++ .../prisma/schema/organization-billing.prisma | 12 +- packages/db/prisma/schema/organization.prisma | 3 + packages/docs/openapi.json | 533 +++++++++++++++++- 72 files changed, 6892 insertions(+), 995 deletions(-) create mode 100644 apps/api/src/background-checks/background-check-billing.controller.ts create mode 100644 apps/api/src/background-checks/background-check-billing.service.ts create mode 100644 apps/api/src/background-checks/background-check-custom.service.spec.ts create mode 100644 apps/api/src/background-checks/background-check-custom.service.ts create mode 100644 apps/api/src/background-checks/background-check-identity.client.ts create mode 100644 apps/api/src/background-checks/background-check-payment.service.spec.ts create mode 100644 apps/api/src/background-checks/background-check-payment.service.ts create mode 100644 apps/api/src/background-checks/background-check-report-snapshot.ts create mode 100644 apps/api/src/background-checks/background-check-webhook-signature.ts create mode 100644 apps/api/src/background-checks/background-check-webhook.service.spec.ts create mode 100644 apps/api/src/background-checks/background-checks.controller.ts create mode 100644 apps/api/src/background-checks/background-checks.module.ts create mode 100644 apps/api/src/background-checks/background-checks.service.spec.ts create mode 100644 apps/api/src/background-checks/background-checks.service.ts create mode 100644 apps/api/src/background-checks/background-checks.types.ts create mode 100644 apps/api/src/background-checks/dto/attach-custom-background-check.dto.ts create mode 100644 apps/api/src/background-checks/dto/background-check-billing.dto.ts create mode 100644 apps/api/src/background-checks/dto/request-background-check.dto.ts create mode 100644 apps/api/src/frameworks/frameworks-compliance-score.helper.ts create mode 100644 apps/api/src/frameworks/frameworks-people-score.helper.spec.ts create mode 100644 apps/api/src/frameworks/frameworks-people-score.helper.ts create mode 100644 apps/app/src/app/(app)/[orgId]/people/[employeeId]/background-check/page.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/BackgroundCheckDetailsForm.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/BackgroundCheckExternalReport.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/BackgroundCheckReport.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/BackgroundCheckShareableSummary.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/BackgroundCheckStatusView.test.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/BackgroundCheckStatusView.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/BackgroundCheckWizardParts.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/CustomBackgroundCheckUpload.test.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/CustomBackgroundCheckUpload.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeBackgroundCheck.test.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeBackgroundCheck.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeDevice.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeePageHeader.test.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeePageHeader.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeePolicies.tsx delete mode 100644 apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeTasks.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeTraining.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/backgroundCheckForm.ts create mode 100644 apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/backgroundCheckShareableSummaryData.ts create mode 100644 apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/backgroundCheckTypes.ts create mode 100644 apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/downloadBase64Pdf.ts create mode 100644 apps/app/src/app/(app)/[orgId]/people/all/components/MemberRowBackgroundCheck.test.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/people/components/BackgroundCheckVerifiedTick.tsx create mode 100644 packages/db/prisma/migrations/20260429120000_background_checks/migration.sql create mode 100644 packages/db/prisma/migrations/20260429124500_background_check_requester_notes/migration.sql create mode 100644 packages/db/prisma/migrations/20260429141500_background_check_report_snapshot/migration.sql create mode 100644 packages/db/prisma/migrations/20260429153000_background_check_attachments/migration.sql create mode 100644 packages/db/prisma/schema/background-check.prisma diff --git a/apps/api/.env.example b/apps/api/.env.example index fb21b77b4f..42ca4371fe 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -1,5 +1,7 @@ BASE_URL="http://localhost:3333" BETTER_AUTH_URL="http://localhost:3000" +NEXT_PUBLIC_APP_URL="http://localhost:3000" +NODE_EXTRA_CA_CERTS=/etc/ssl/cert.pem PORT="3333" APP_AWS_BUCKET_NAME= @@ -43,4 +45,11 @@ RESEND_API_KEY= RESEND_FROM_SYSTEM= # e.g., noreply@mail.trycomp.ai RESEND_FROM_DEFAULT= # e.g., hello@mail.trycomp.ai -SECURITY_HUB_ROLE_ASSUMER_ARN= \ No newline at end of file +# Background checks +BACKGROUND_CHECK_API_BASE_URL=https://glad-sturgeon-729.convex.site +BACKGROUND_CHECK_API_KEY= +BACKGROUND_CHECK_WEBHOOK_SECRET= +BACKGROUND_WH_ENDPOINT= +STRIPE_BACKGROUND_CHECK_PRICE_ID=price_1TRWckCkFWhKYvHIA1GLv1sO + +SECURITY_HUB_ROLE_ASSUMER_ARN= diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index a6cc6f1e04..ce20e1782d 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -52,6 +52,7 @@ import { StripeModule } from './stripe/stripe.module'; import { AdminOrganizationsModule } from './admin-organizations/admin-organizations.module'; import { AdminFeatureFlagsModule } from './admin-feature-flags/admin-feature-flags.module'; import { TimelinesModule } from './timelines/timelines.module'; +import { BackgroundChecksModule } from './background-checks/background-checks.module'; @Module({ imports: [ @@ -113,6 +114,7 @@ import { TimelinesModule } from './timelines/timelines.module'; SecretsModule, SecurityPenetrationTestsModule, StripeModule, + BackgroundChecksModule, AdminOrganizationsModule, AdminFeatureFlagsModule, TimelinesModule, diff --git a/apps/api/src/background-checks/background-check-billing.controller.ts b/apps/api/src/background-checks/background-check-billing.controller.ts new file mode 100644 index 0000000000..708d0dd2c1 --- /dev/null +++ b/apps/api/src/background-checks/background-check-billing.controller.ts @@ -0,0 +1,70 @@ +import { Body, Controller, Get, HttpCode, Post, UseGuards } from '@nestjs/common'; +import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger'; +import { OrganizationId } from '../auth/auth-context.decorator'; +import { HybridAuthGuard } from '../auth/hybrid-auth.guard'; +import { PermissionGuard } from '../auth/permission.guard'; +import { RequirePermission } from '../auth/require-permission.decorator'; +import { BackgroundCheckBillingService } from './background-check-billing.service'; +import { + BackgroundCheckBillingPortalDto, + BackgroundCheckSetupSessionDto, + BackgroundCheckSetupSuccessDto, +} from './dto/background-check-billing.dto'; + +@ApiTags('Background Check Billing') +@Controller({ path: 'background-check-billing', version: '1' }) +@UseGuards(HybridAuthGuard, PermissionGuard) +@ApiSecurity('apikey') +export class BackgroundCheckBillingController { + constructor(private readonly billingService: BackgroundCheckBillingService) {} + + @Get('status') + @RequirePermission('organization', 'read') + @ApiOperation({ summary: 'Get background check billing status' }) + async getStatus(@OrganizationId() organizationId: string) { + return this.billingService.getStatus(organizationId); + } + + @Post('setup-session') + @RequirePermission('organization', 'update') + @HttpCode(200) + @ApiOperation({ summary: 'Create a Stripe setup session for background checks' }) + async setupSession( + @OrganizationId() organizationId: string, + @Body() body: BackgroundCheckSetupSessionDto, + ) { + return this.billingService.createSetupSession({ + organizationId, + successUrl: body.successUrl, + cancelUrl: body.cancelUrl, + }); + } + + @Post('setup-success') + @RequirePermission('organization', 'update') + @HttpCode(200) + @ApiOperation({ summary: 'Handle successful background check billing setup' }) + async setupSuccess( + @OrganizationId() organizationId: string, + @Body() body: BackgroundCheckSetupSuccessDto, + ) { + return this.billingService.handleSetupSuccess({ + organizationId, + sessionId: body.sessionId, + }); + } + + @Post('portal') + @RequirePermission('organization', 'update') + @HttpCode(200) + @ApiOperation({ summary: 'Create a Stripe billing portal session' }) + async portal( + @OrganizationId() organizationId: string, + @Body() body: BackgroundCheckBillingPortalDto, + ) { + return this.billingService.createBillingPortalSession({ + organizationId, + returnUrl: body.returnUrl, + }); + } +} diff --git a/apps/api/src/background-checks/background-check-billing.service.ts b/apps/api/src/background-checks/background-check-billing.service.ts new file mode 100644 index 0000000000..676fe5f581 --- /dev/null +++ b/apps/api/src/background-checks/background-check-billing.service.ts @@ -0,0 +1,253 @@ +import { + BadRequestException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { db } from '@db'; +import { StripeService } from '../stripe/stripe.service'; + +@Injectable() +export class BackgroundCheckBillingService { + constructor(private readonly stripeService: StripeService) {} + + async getStatus(organizationId: string): Promise<{ + hasBilling: boolean; + hasPaymentMethod: boolean; + setupAt: Date | null; + }> { + const billing = await db.organizationBilling.findUnique({ + where: { organizationId }, + select: { + stripeCustomerId: true, + stripeBackgroundCheckPaymentMethodId: true, + backgroundCheckPaymentMethodSetupAt: true, + }, + }); + + return { + hasBilling: !!billing, + hasPaymentMethod: !!billing?.stripeBackgroundCheckPaymentMethodId, + setupAt: billing?.backgroundCheckPaymentMethodSetupAt ?? null, + }; + } + + async createSetupSession({ + organizationId, + successUrl, + cancelUrl, + }: { + organizationId: string; + successUrl: string; + cancelUrl: string; + }): Promise<{ url: string }> { + this.validateRedirectUrl(successUrl); + this.validateRedirectUrl(cancelUrl); + + const stripe = this.stripeService.getClient(); + const customerId = await this.findOrCreateCustomer(organizationId); + const price = await this.getBackgroundCheckPrice(); + + const session = await stripe.checkout.sessions.create({ + mode: 'setup', + customer: customerId, + currency: price.currency, + success_url: successUrl, + cancel_url: cancelUrl, + metadata: { + organizationId, + source: 'comp-background-check', + }, + }); + + if (!session.url) { + throw new BadRequestException('Failed to create Stripe Checkout session.'); + } + + return { url: session.url }; + } + + async handleSetupSuccess({ + organizationId, + sessionId, + }: { + organizationId: string; + sessionId: string; + }): Promise<{ success: true }> { + const stripe = this.stripeService.getClient(); + const session = await stripe.checkout.sessions.retrieve(sessionId, { + expand: ['setup_intent'], + }); + + const stripeCustomerId = this.extractStripeId(session.customer); + if (!stripeCustomerId) { + throw new BadRequestException('Checkout session is missing a customer.'); + } + + await this.assertCustomerBelongsToOrganization({ + organizationId, + stripeCustomerId, + }); + + const setupIntent = session.setup_intent; + if (!setupIntent || typeof setupIntent === 'string') { + throw new BadRequestException('Checkout session is missing a setup intent.'); + } + + const paymentMethodId = this.extractStripeId(setupIntent.payment_method); + if (!paymentMethodId) { + throw new BadRequestException('Setup intent is missing a payment method.'); + } + + await stripe.customers.update(stripeCustomerId, { + invoice_settings: { + default_payment_method: paymentMethodId, + }, + }); + + await db.organizationBilling.upsert({ + where: { organizationId }, + create: { + organizationId, + stripeCustomerId, + stripeBackgroundCheckPaymentMethodId: paymentMethodId, + backgroundCheckPaymentMethodSetupAt: new Date(), + }, + update: { + stripeCustomerId, + stripeBackgroundCheckPaymentMethodId: paymentMethodId, + backgroundCheckPaymentMethodSetupAt: new Date(), + }, + }); + + return { success: true }; + } + + async createBillingPortalSession({ + organizationId, + returnUrl, + }: { + organizationId: string; + returnUrl: string; + }): Promise<{ url: string }> { + this.validateRedirectUrl(returnUrl); + + const stripe = this.stripeService.getClient(); + const billing = await db.organizationBilling.findUnique({ + where: { organizationId }, + select: { stripeCustomerId: true }, + }); + + if (!billing) { + throw new NotFoundException('No billing record found for this organization.'); + } + + const portalSession = await stripe.billingPortal.sessions.create({ + customer: billing.stripeCustomerId, + return_url: returnUrl, + }); + + return { url: portalSession.url }; + } + + async findOrCreateCustomer(organizationId: string): Promise { + const existingBilling = await db.organizationBilling.findUnique({ + where: { organizationId }, + select: { stripeCustomerId: true }, + }); + + if (existingBilling) { + return existingBilling.stripeCustomerId; + } + + const organization = await db.organization.findUnique({ + where: { id: organizationId }, + select: { name: true }, + }); + + if (!organization) { + throw new NotFoundException('Organization not found.'); + } + + const stripe = this.stripeService.getClient(); + const customer = await stripe.customers.create({ + name: organization.name, + metadata: { organizationId }, + }); + + await db.organizationBilling.create({ + data: { + organizationId, + stripeCustomerId: customer.id, + }, + }); + + return customer.id; + } + + async getBackgroundCheckPrice(): Promise<{ id: string; unitAmount: number; currency: string }> { + const priceId = process.env.STRIPE_BACKGROUND_CHECK_PRICE_ID; + if (!priceId) { + throw new BadRequestException('STRIPE_BACKGROUND_CHECK_PRICE_ID is not configured.'); + } + + const stripe = this.stripeService.getClient(); + const price = await stripe.prices.retrieve(priceId); + if (!price.unit_amount) { + throw new BadRequestException('Background check price has no unit amount.'); + } + + return { + id: price.id, + unitAmount: price.unit_amount, + currency: price.currency, + }; + } + + private validateRedirectUrl(url: string): void { + const appUrl = + process.env.NEXT_PUBLIC_APP_URL || process.env.APP_URL || process.env.BETTER_AUTH_URL; + if (!appUrl) { + throw new BadRequestException('App URL is not configured on the server.'); + } + + let parsed: URL; + try { + parsed = new URL(url); + } catch { + throw new BadRequestException('Invalid redirect URL.'); + } + + if (parsed.origin !== new URL(appUrl).origin) { + throw new BadRequestException('Redirect URL must belong to the application origin.'); + } + } + + private extractStripeId(value: string | { id?: string } | null): string | null { + if (!value) return null; + if (typeof value === 'string') return value; + return value.id ?? null; + } + + private async assertCustomerBelongsToOrganization({ + organizationId, + stripeCustomerId, + }: { + organizationId: string; + stripeCustomerId: string; + }): Promise { + const billing = await db.organizationBilling.findUnique({ + where: { organizationId }, + select: { stripeCustomerId: true }, + }); + + if (billing?.stripeCustomerId === stripeCustomerId) { + return; + } + + const stripe = this.stripeService.getClient(); + const customer = await stripe.customers.retrieve(stripeCustomerId); + if (customer.deleted || customer.metadata?.organizationId !== organizationId) { + throw new BadRequestException('Checkout session does not belong to this organization.'); + } + } +} diff --git a/apps/api/src/background-checks/background-check-custom.service.spec.ts b/apps/api/src/background-checks/background-check-custom.service.spec.ts new file mode 100644 index 0000000000..1bd12e2bdd --- /dev/null +++ b/apps/api/src/background-checks/background-check-custom.service.spec.ts @@ -0,0 +1,122 @@ +import { AttachmentEntityType, BackgroundCheckStatus, db } from '@db'; +import { AttachmentsService } from '../attachments/attachments.service'; +import { BackgroundCheckCustomService } from './background-check-custom.service'; + +jest.mock('@db', () => ({ + AttachmentEntityType: { + background_check: 'background_check', + }, + BackgroundCheckStatus: { + completed: 'completed', + }, + db: { + backgroundCheckRequest: { + findUnique: jest.fn(), + upsert: jest.fn(), + }, + member: { + findFirst: jest.fn(), + }, + }, +})); + +const mockedDb = db as jest.Mocked; + +function mockAsync(fn: unknown): jest.MockedFunction<() => Promise> { + return fn as jest.MockedFunction<() => Promise>; +} + +describe('BackgroundCheckCustomService', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('creates a completed background check and stores the uploaded report as an attachment', async () => { + const uploadAttachment = jest.fn().mockResolvedValue({ + id: 'att_1', + name: 'report.pdf', + type: 'document', + downloadUrl: 'https://s3.example.com/report.pdf', + createdAt: new Date('2026-04-29T12:00:00.000Z'), + }); + const service = new BackgroundCheckCustomService({ + uploadAttachment, + } as unknown as AttachmentsService); + + mockAsync>>( + mockedDb.member.findFirst, + ).mockResolvedValueOnce({ + id: 'mem_1', + user: { name: 'Ada Lovelace', email: 'ada@work.example' }, + } as unknown as Awaited>); + mockAsync>>( + mockedDb.backgroundCheckRequest.upsert, + ).mockResolvedValueOnce({ + id: 'bcr_1', + status: BackgroundCheckStatus.completed, + } as Awaited>); + + await service.attachForMember({ + organizationId: 'org_1', + memberId: 'mem_1', + upload: { + fileName: 'report.pdf', + fileType: 'application/pdf', + fileData: 'cmVwb3J0', + }, + userId: 'usr_1', + }); + + expect(mockedDb.backgroundCheckRequest.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + create: expect.objectContaining({ + employeeName: 'Ada Lovelace', + employeeEmail: 'ada@work.example', + status: BackgroundCheckStatus.completed, + }), + }), + ); + expect(uploadAttachment).toHaveBeenCalledWith( + 'org_1', + 'bcr_1', + AttachmentEntityType.background_check, + expect.objectContaining({ + fileName: 'report.pdf', + fileType: 'application/pdf', + }), + 'usr_1', + ); + }); + + it('returns custom attachments for an existing background check', async () => { + const getAttachmentMetadata = jest.fn().mockResolvedValue([ + { + id: 'att_1', + name: 'report.pdf', + type: 'document', + createdAt: new Date('2026-04-29T12:00:00.000Z'), + }, + ]); + const service = new BackgroundCheckCustomService({ + getAttachmentMetadata, + } as unknown as AttachmentsService); + + mockAsync>>( + mockedDb.backgroundCheckRequest.findUnique, + ).mockResolvedValueOnce({ + id: 'bcr_1', + } as Awaited>); + + const attachments = await service.getAttachmentsForMember({ + organizationId: 'org_1', + memberId: 'mem_1', + }); + + expect(attachments).toHaveLength(1); + expect(getAttachmentMetadata).toHaveBeenCalledWith( + 'org_1', + 'bcr_1', + AttachmentEntityType.background_check, + ); + }); +}); diff --git a/apps/api/src/background-checks/background-check-custom.service.ts b/apps/api/src/background-checks/background-check-custom.service.ts new file mode 100644 index 0000000000..717dca68b9 --- /dev/null +++ b/apps/api/src/background-checks/background-check-custom.service.ts @@ -0,0 +1,92 @@ +import { AttachmentEntityType, BackgroundCheckStatus, db } from '@db'; +import { Injectable, NotFoundException } from '@nestjs/common'; +import { AttachmentsService } from '../attachments/attachments.service'; +import type { UploadAttachmentDto } from '../attachments/upload-attachment.dto'; + +@Injectable() +export class BackgroundCheckCustomService { + constructor(private readonly attachmentsService: AttachmentsService) {} + + async getAttachmentsForMember({ + organizationId, + memberId, + }: { + organizationId: string; + memberId: string; + }) { + const record = await db.backgroundCheckRequest.findUnique({ + where: { organizationId_memberId: { organizationId, memberId } }, + select: { id: true }, + }); + + if (!record) return []; + + return this.attachmentsService.getAttachmentMetadata( + organizationId, + record.id, + AttachmentEntityType.background_check, + ); + } + + async attachForMember({ + organizationId, + memberId, + employeeName, + employeeEmail, + requesterNotes, + upload, + userId, + }: { + organizationId: string; + memberId: string; + employeeName?: string; + employeeEmail?: string; + requesterNotes?: string; + upload: UploadAttachmentDto; + userId?: string; + }) { + const member = await db.member.findFirst({ + where: { id: memberId, organizationId, deactivated: false }, + select: { + id: true, + user: { select: { email: true, name: true } }, + }, + }); + + if (!member) { + throw new NotFoundException('Member not found.'); + } + + const record = await db.backgroundCheckRequest.upsert({ + where: { organizationId_memberId: { organizationId, memberId } }, + create: { + organizationId, + memberId, + employeeName: employeeName?.trim() || member.user.name || member.user.email, + employeeEmail: employeeEmail?.trim().toLowerCase() || member.user.email, + requesterNotes, + status: BackgroundCheckStatus.completed, + lastSyncedAt: new Date(), + reportSyncedAt: new Date(), + }, + update: { + employeeName: employeeName?.trim() || member.user.name || member.user.email, + employeeEmail: employeeEmail?.trim().toLowerCase() || member.user.email, + requesterNotes, + status: BackgroundCheckStatus.completed, + lastSyncedAt: new Date(), + reportSyncedAt: new Date(), + }, + }); + + await this.attachmentsService.uploadAttachment( + organizationId, + record.id, + AttachmentEntityType.background_check, + upload, + userId, + ); + + return record; + } +} diff --git a/apps/api/src/background-checks/background-check-identity.client.ts b/apps/api/src/background-checks/background-check-identity.client.ts new file mode 100644 index 0000000000..ae8fb6b923 --- /dev/null +++ b/apps/api/src/background-checks/background-check-identity.client.ts @@ -0,0 +1,127 @@ +import { BadRequestException, Injectable, Logger } from '@nestjs/common'; +import { + identityCreateResponseSchema, + type IdentityCreateResponse, +} from './background-checks.types'; + +@Injectable() +export class BackgroundCheckIdentityClient { + private readonly logger = new Logger(BackgroundCheckIdentityClient.name); + + async createBackgroundCheck(params: { + organizationId: string; + memberId: string; + employeeName: string; + employeeEmail: string; + requesterEmail: string; + }): Promise { + const apiKey = process.env.BACKGROUND_CHECK_API_KEY; + if (!apiKey) { + throw new BadRequestException('BACKGROUND_CHECK_API_KEY is not configured.'); + } + + 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, + }, + 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.'); + } + + return identityCreateResponseSchema.parse(json); + } + + async getBackgroundCheck(identityBackgroundCheckId: string): Promise { + const apiKey = process.env.BACKGROUND_CHECK_API_KEY; + if (!apiKey) return null; + + const response = await this.fetchIdentity( + `${this.baseUrl()}/v1/background-checks/${identityBackgroundCheckId}`, + { headers: { Authorization: `Bearer ${apiKey}` } }, + ); + + if (!response.ok) { + return null; + } + + return this.readJson(response); + } + + private baseUrl(): string { + const baseUrl = + 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( + /\/+$/, + '', + ); + } + + private async fetchIdentity(url: string, init: RequestInit): Promise { + try { + return await fetch(url, init); + } catch (error) { + this.logger.error('Identity background check network request failed', { + url, + error: this.describeError(error), + }); + throw new BadRequestException( + 'Identity background check service is unreachable from the API server.', + ); + } + } + + private async readJson(response: Response): Promise { + const body = await response.text(); + if (!body) return null; + + try { + return JSON.parse(body) as unknown; + } catch { + return { error: body }; + } + } + + private describeError(error: unknown): string { + if (error instanceof Error) { + const cause = 'cause' in error ? error.cause : undefined; + if (cause instanceof Error) { + return `${error.message}: ${cause.message}`; + } + return error.message; + } + + return 'Unknown error'; + } +} diff --git a/apps/api/src/background-checks/background-check-payment.service.spec.ts b/apps/api/src/background-checks/background-check-payment.service.spec.ts new file mode 100644 index 0000000000..6068372a2b --- /dev/null +++ b/apps/api/src/background-checks/background-check-payment.service.spec.ts @@ -0,0 +1,75 @@ +import { HttpException } from '@nestjs/common'; +import { db } from '@db'; +import { StripeService } from '../stripe/stripe.service'; +import { BackgroundCheckBillingService } from './background-check-billing.service'; +import { BackgroundCheckPaymentService } from './background-check-payment.service'; + +jest.mock('@db', () => ({ + db: { + organizationBilling: { + findUnique: jest.fn(), + }, + }, +})); + +const mockedDb = db as jest.Mocked; + +function mockAsync(fn: unknown): jest.MockedFunction<() => Promise> { + return fn as jest.MockedFunction<() => Promise>; +} + +describe('BackgroundCheckPaymentService', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('throws payment required when no background check payment method exists', async () => { + mockAsync>>( + mockedDb.organizationBilling.findUnique, + ).mockResolvedValueOnce(null); + const service = new BackgroundCheckPaymentService( + { getClient: jest.fn() } as unknown as StripeService, + { getBackgroundCheckPrice: jest.fn() } as unknown as BackgroundCheckBillingService, + ); + + await expect( + service.charge({ organizationId: 'org_1', memberId: 'mem_1' }), + ).rejects.toBeInstanceOf(HttpException); + }); + + it('charges Stripe with payment-method scoped idempotency key', async () => { + mockAsync>>( + mockedDb.organizationBilling.findUnique, + ).mockResolvedValueOnce({ + stripeCustomerId: 'cus_1', + stripeBackgroundCheckPaymentMethodId: 'pm_1', + } as Awaited>); + const paymentIntentsCreate = jest.fn().mockResolvedValue({ + id: 'pi_1', + status: 'succeeded', + }); + const service = new BackgroundCheckPaymentService( + { + getClient: () => ({ paymentIntents: { create: paymentIntentsCreate } }), + } as unknown as StripeService, + { + getBackgroundCheckPrice: jest.fn().mockResolvedValue({ + id: 'price_bg', + unitAmount: 1250, + currency: 'usd', + }), + } as unknown as BackgroundCheckBillingService, + ); + + await service.charge({ organizationId: 'org_1', memberId: 'mem_1' }); + + expect(paymentIntentsCreate).toHaveBeenCalledWith( + expect.objectContaining({ + amount: 1250, + customer: 'cus_1', + payment_method: 'pm_1', + }), + { idempotencyKey: 'background-check:org_1:mem_1:price_bg:pm_1' }, + ); + }); +}); diff --git a/apps/api/src/background-checks/background-check-payment.service.ts b/apps/api/src/background-checks/background-check-payment.service.ts new file mode 100644 index 0000000000..79f0f7f270 --- /dev/null +++ b/apps/api/src/background-checks/background-check-payment.service.ts @@ -0,0 +1,104 @@ +import { HttpException, HttpStatus, Injectable, Logger } from '@nestjs/common'; +import { db } from '@db'; +import { StripeService } from '../stripe/stripe.service'; +import { BackgroundCheckBillingService } from './background-check-billing.service'; + +@Injectable() +export class BackgroundCheckPaymentService { + private readonly logger = new Logger(BackgroundCheckPaymentService.name); + + constructor( + private readonly stripeService: StripeService, + private readonly billingService: BackgroundCheckBillingService, + ) {} + + async charge(params: { + organizationId: string; + memberId: string; + }): Promise<{ paymentIntentId: string; status: string; amount: number; currency: string }> { + const billing = await db.organizationBilling.findUnique({ + where: { organizationId: params.organizationId }, + select: { + stripeCustomerId: true, + stripeBackgroundCheckPaymentMethodId: true, + }, + }); + + if (!billing?.stripeBackgroundCheckPaymentMethodId) { + throw new HttpException( + 'No background check payment method on file. Update billing first.', + HttpStatus.PAYMENT_REQUIRED, + ); + } + + const price = await this.billingService.getBackgroundCheckPrice(); + const stripe = this.stripeService.getClient(); + const paymentIntent = await stripe.paymentIntents.create( + { + customer: billing.stripeCustomerId, + amount: price.unitAmount, + currency: price.currency, + payment_method: billing.stripeBackgroundCheckPaymentMethodId, + off_session: true, + confirm: true, + automatic_payment_methods: { + enabled: true, + allow_redirects: 'never', + }, + metadata: { + source: 'comp-background-check', + compOrganizationId: params.organizationId, + compMemberId: params.memberId, + }, + }, + { + idempotencyKey: [ + 'background-check', + params.organizationId, + params.memberId, + price.id, + billing.stripeBackgroundCheckPaymentMethodId, + ].join(':'), + }, + ); + + if (paymentIntent.status !== 'succeeded') { + throw new HttpException( + 'Background check payment failed. Update billing and try again.', + HttpStatus.PAYMENT_REQUIRED, + ); + } + + return { + paymentIntentId: paymentIntent.id, + status: paymentIntent.status, + amount: price.unitAmount, + currency: price.currency, + }; + } + + async refund(params: { + organizationId: string; + memberId: string; + paymentIntentId: string; + }): Promise { + try { + const stripe = this.stripeService.getClient(); + const refund = await stripe.refunds.create( + { payment_intent: params.paymentIntentId }, + { + idempotencyKey: [ + 'background-check-refund', + params.organizationId, + params.memberId, + params.paymentIntentId, + ].join(':'), + }, + ); + return refund.id; + } catch (error) { + this.logger.error('Failed to refund background check payment', error); + return null; + } + } +} diff --git a/apps/api/src/background-checks/background-check-report-snapshot.ts b/apps/api/src/background-checks/background-check-report-snapshot.ts new file mode 100644 index 0000000000..47097ca087 --- /dev/null +++ b/apps/api/src/background-checks/background-check-report-snapshot.ts @@ -0,0 +1,48 @@ +import { Prisma } from '@db'; +import type { BackgroundCheckStatus } from '@db'; +import type { BackgroundCheckIdentityClient } from './background-check-identity.client'; + +export function shouldSyncReportSnapshot({ + status, + eventType, +}: { + status: BackgroundCheckStatus; + eventType: string; +}): boolean { + return ( + eventType === 'background_check.completed' || + status === 'completed' || + status === 'completed_with_flags' + ); +} + +export function toInputJsonValue(value: unknown): Prisma.InputJsonValue | null { + if (value === null || value === undefined) { + return null; + } + + return JSON.parse(JSON.stringify(value)) as Prisma.InputJsonValue; +} + +export async function fetchCompletedReportSnapshot({ + identityClient, + identityBackgroundCheckId, + eventType, + status, +}: { + identityClient: BackgroundCheckIdentityClient; + identityBackgroundCheckId: string; + eventType: string; + status: BackgroundCheckStatus; +}): Promise { + if (!shouldSyncReportSnapshot({ status, eventType })) { + return null; + } + + try { + const snapshot = await identityClient.getBackgroundCheck(identityBackgroundCheckId); + return toInputJsonValue(snapshot); + } catch { + return null; + } +} diff --git a/apps/api/src/background-checks/background-check-webhook-signature.ts b/apps/api/src/background-checks/background-check-webhook-signature.ts new file mode 100644 index 0000000000..e52eb0f2e9 --- /dev/null +++ b/apps/api/src/background-checks/background-check-webhook-signature.ts @@ -0,0 +1,51 @@ +import { UnauthorizedException } from '@nestjs/common'; +import { createHmac, timingSafeEqual } from 'node:crypto'; + +const WEBHOOK_MAX_SKEW_MS = 5 * 60 * 1000; + +export function verifyBackgroundCheckWebhookSignature({ + rawBody, + headers, +}: { + rawBody: Buffer; + headers: Record; +}): void { + const secret = process.env.BACKGROUND_CHECK_WEBHOOK_SECRET; + if (!secret) { + throw new UnauthorizedException('Webhook secret is not configured.'); + } + + const timestampHeader = headerValue(headers, 'x-background-check-timestamp'); + const signature = headerValue(headers, 'x-background-check-signature'); + if (!timestampHeader || !signature) { + throw new UnauthorizedException('Missing webhook signature headers.'); + } + + const timestamp = Number(timestampHeader); + if (!Number.isFinite(timestamp) || Math.abs(Date.now() - timestamp) > WEBHOOK_MAX_SKEW_MS) { + throw new UnauthorizedException('Webhook timestamp is invalid.'); + } + + const expected = createHmac('sha256', secret) + .update(`${timestampHeader}.${rawBody.toString('utf8')}`) + .digest('hex'); + + const expectedBuffer = Buffer.from(expected, 'hex'); + const signatureBuffer = Buffer.from(signature, 'hex'); + const matches = + expectedBuffer.length === signatureBuffer.length && + timingSafeEqual(expectedBuffer, signatureBuffer); + + if (!matches) { + throw new UnauthorizedException('Invalid webhook signature.'); + } +} + +export function headerValue( + headers: Record, + name: string, +): string | null { + const value = headers[name] ?? headers[name.toLowerCase()]; + if (Array.isArray(value)) return value[0] ?? null; + return value ?? null; +} 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 new file mode 100644 index 0000000000..a8961ceedc --- /dev/null +++ b/apps/api/src/background-checks/background-check-webhook.service.spec.ts @@ -0,0 +1,278 @@ +import { UnauthorizedException } from '@nestjs/common'; +import { db, Prisma } from '@db'; +import { createHmac } from 'node:crypto'; +import { BackgroundCheckIdentityClient } from './background-check-identity.client'; +import { BackgroundCheckPaymentService } from './background-check-payment.service'; +import { BackgroundChecksService } from './background-checks.service'; + +jest.mock('@db', () => { + class PrismaClientKnownRequestError extends Error { + code: string; + + constructor(message: string, options: { code: string }) { + super(message); + this.code = options.code; + } + } + + return { + Prisma: { PrismaClientKnownRequestError }, + db: { + backgroundCheckRequest: { + findFirst: jest.fn(), + update: jest.fn(), + }, + backgroundCheckWebhookEvent: { + create: jest.fn(), + }, + }, + }; +}); + +const mockedDb = db as jest.Mocked; + +function mockAsync(fn: unknown): jest.MockedFunction<() => Promise> { + return fn as jest.MockedFunction<() => Promise>; +} + +function makeSignature(rawBody: string, timestamp: string): string { + return createHmac('sha256', 'whsec_test') + .update(`${timestamp}.${rawBody}`) + .digest('hex'); +} + +function webhookPayload() { + return { + eventId: 'evt_1', + type: 'background_check.status_changed', + data: { + id: 'check_1', + status: 'completed_with_flags', + candidateName: 'Ada Lovelace', + candidateEmail: 'ada@example.com', + metadata: { compOrganizationId: 'org_1', compMemberId: 'mem_1' }, + statuses: { + identity: 'passed', + employment: 'verified', + references: 'partially_verified', + rightToWork: 'extracted', + adjudication: 'candidate_available', + }, + createdAt: 1777464000000, + updatedAt: 1777464000000, + completedAt: null, + }, + }; +} + +describe('BackgroundChecksService webhooks', () => { + const originalEnv = { ...process.env }; + + beforeEach(() => { + jest.clearAllMocks(); + process.env = { + ...originalEnv, + BACKGROUND_CHECK_WEBHOOK_SECRET: 'whsec_test', + }; + }); + + afterAll(() => { + process.env = originalEnv; + }); + + it('rejects invalid and stale webhook signatures', async () => { + const service = new BackgroundChecksService( + {} as unknown as BackgroundCheckIdentityClient, + {} as unknown as BackgroundCheckPaymentService, + ); + + await expect( + service.handleWebhook({ + rawBody: Buffer.from('{}'), + headers: { + 'x-background-check-timestamp': String(Date.now()), + 'x-background-check-signature': 'bad', + }, + }), + ).rejects.toBeInstanceOf(UnauthorizedException); + + const staleTimestamp = String(Date.now() - 10 * 60 * 1000); + await expect( + service.handleWebhook({ + rawBody: Buffer.from('{}'), + headers: { + 'x-background-check-timestamp': staleTimestamp, + 'x-background-check-signature': makeSignature('{}', staleTimestamp), + }, + }), + ).rejects.toBeInstanceOf(UnauthorizedException); + }); + + it('updates status fields and report snapshots from webhook payloads', async () => { + const payload = webhookPayload(); + const rawBody = JSON.stringify(payload); + const timestamp = String(Date.now()); + mockAsync>>( + mockedDb.backgroundCheckRequest.findFirst, + ).mockResolvedValueOnce({ + id: 'bcr_1', + employeeName: 'Ada', + employeeEmail: 'old@example.com', + } as Awaited>); + mockAsync>>( + mockedDb.backgroundCheckWebhookEvent.create, + ).mockResolvedValueOnce( + {} as Awaited>, + ); + const reportSnapshot = { + identityVerification: { status: 'passed' }, + report: { flags: ['Manual review required'] }, + }; + const identityClient = { + getBackgroundCheck: jest.fn().mockResolvedValue(reportSnapshot), + }; + const service = new BackgroundChecksService( + identityClient as unknown as BackgroundCheckIdentityClient, + {} as unknown as BackgroundCheckPaymentService, + ); + + await service.handleWebhook({ + rawBody: Buffer.from(rawBody), + headers: { + 'x-background-check-timestamp': timestamp, + 'x-background-check-signature': makeSignature(rawBody, timestamp), + }, + }); + + expect(identityClient.getBackgroundCheck).toHaveBeenCalledWith('check_1'); + expect(mockedDb.backgroundCheckRequest.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + status: 'completed_with_flags', + identityStatus: 'passed', + employmentStatus: 'verified', + referenceStatus: 'partially_verified', + reportSnapshot, + reportSyncedAt: expect.any(Date), + }), + }), + ); + }); + + it('updates terminal status when report snapshot fetch fails', async () => { + const payload = webhookPayload(); + const rawBody = JSON.stringify(payload); + const timestamp = String(Date.now()); + mockAsync>>( + mockedDb.backgroundCheckRequest.findFirst, + ).mockResolvedValueOnce({ + id: 'bcr_1', + employeeName: 'Ada', + employeeEmail: 'old@example.com', + } as Awaited>); + mockAsync>>( + mockedDb.backgroundCheckWebhookEvent.create, + ).mockResolvedValueOnce( + {} as Awaited>, + ); + const identityClient = { + getBackgroundCheck: jest.fn().mockRejectedValue(new Error('unavailable')), + }; + const service = new BackgroundChecksService( + identityClient as unknown as BackgroundCheckIdentityClient, + {} as unknown as BackgroundCheckPaymentService, + ); + + await service.handleWebhook({ + rawBody: Buffer.from(rawBody), + headers: { + 'x-background-check-timestamp': timestamp, + 'x-background-check-signature': makeSignature(rawBody, timestamp), + }, + }); + + expect(mockedDb.backgroundCheckRequest.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.not.objectContaining({ + reportSnapshot: expect.anything(), + reportSyncedAt: expect.anything(), + }), + }), + ); + }); + + it('does not fetch report snapshots for non-terminal webhooks', async () => { + const payload = webhookPayload(); + payload.data.status = 'in_progress'; + const rawBody = JSON.stringify(payload); + const timestamp = String(Date.now()); + mockAsync>>( + mockedDb.backgroundCheckRequest.findFirst, + ).mockResolvedValueOnce({ + id: 'bcr_1', + employeeName: 'Ada', + employeeEmail: 'old@example.com', + } as Awaited>); + mockAsync>>( + mockedDb.backgroundCheckWebhookEvent.create, + ).mockResolvedValueOnce( + {} as Awaited>, + ); + const identityClient = { getBackgroundCheck: jest.fn() }; + const service = new BackgroundChecksService( + identityClient as unknown as BackgroundCheckIdentityClient, + {} as unknown as BackgroundCheckPaymentService, + ); + + await service.handleWebhook({ + rawBody: Buffer.from(rawBody), + headers: { + 'x-background-check-timestamp': timestamp, + 'x-background-check-signature': makeSignature(rawBody, timestamp), + }, + }); + + expect(identityClient.getBackgroundCheck).not.toHaveBeenCalled(); + expect(mockedDb.backgroundCheckRequest.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ status: 'in_progress' }), + }), + ); + }); + + it('dedupes webhook events', async () => { + const payload = webhookPayload(); + const rawBody = JSON.stringify(payload); + const timestamp = String(Date.now()); + mockAsync>>( + mockedDb.backgroundCheckRequest.findFirst, + ).mockResolvedValueOnce({ + id: 'bcr_1', + employeeName: 'Ada', + employeeEmail: 'old@example.com', + } as Awaited>); + mockAsync>>( + mockedDb.backgroundCheckWebhookEvent.create, + ).mockRejectedValueOnce( + new Prisma.PrismaClientKnownRequestError('duplicate', { + code: 'P2002', + clientVersion: 'test', + }), + ); + const service = new BackgroundChecksService( + {} 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(result).toEqual({ ok: true, duplicate: true }); + expect(mockedDb.backgroundCheckRequest.update).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/background-checks/background-checks.controller.ts b/apps/api/src/background-checks/background-checks.controller.ts new file mode 100644 index 0000000000..75fd1725bd --- /dev/null +++ b/apps/api/src/background-checks/background-checks.controller.ts @@ -0,0 +1,132 @@ +import { + Body, + Controller, + Get, + Headers, + HttpCode, + Param, + Post, + Req, + UseGuards, + type RawBodyRequest, +} from '@nestjs/common'; +import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger'; +import type { Request } from 'express'; +import { AuthContext, OrganizationId } from '../auth/auth-context.decorator'; +import { HybridAuthGuard } from '../auth/hybrid-auth.guard'; +import { PermissionGuard } from '../auth/permission.guard'; +import { Public } from '../auth/public.decorator'; +import { RequirePermission } from '../auth/require-permission.decorator'; +import type { AuthContext as AuthContextType } from '../auth/types'; +import { BackgroundCheckCustomService } from './background-check-custom.service'; +import { AttachCustomBackgroundCheckDto } from './dto/attach-custom-background-check.dto'; +import { RequestBackgroundCheckDto } from './dto/request-background-check.dto'; +import { BackgroundChecksService } from './background-checks.service'; + +@ApiTags('Background Checks') +@Controller({ path: 'people', version: '1' }) +@UseGuards(HybridAuthGuard, PermissionGuard) +@ApiSecurity('apikey') +export class PeopleBackgroundChecksController { + constructor( + private readonly backgroundChecksService: BackgroundChecksService, + private readonly customService: BackgroundCheckCustomService, + ) {} + + @Get(':id/background-check') + @RequirePermission('member', 'read') + @ApiOperation({ summary: 'Get member background check request' }) + async getForMember( + @Param('id') memberId: string, + @OrganizationId() organizationId: string, + ) { + return this.backgroundChecksService.getForMember({ organizationId, memberId }); + } + + @Post(':id/background-check') + @HttpCode(200) + @RequirePermission('member', 'update') + @ApiOperation({ summary: 'Request a background check for a member' }) + async requestForMember( + @Param('id') memberId: string, + @OrganizationId() organizationId: string, + @AuthContext() authContext: AuthContextType, + @Body() body: RequestBackgroundCheckDto, + ) { + return this.backgroundChecksService.requestForMember({ + organizationId, + memberId, + employeeName: body.employeeName.trim(), + employeeEmail: body.employeeEmail.trim().toLowerCase(), + requesterNotes: body.requesterNotes?.trim() || undefined, + requesterEmail: authContext.userEmail ?? 'api-key@trycomp.ai', + }); + } + + @Get(':id/background-check/custom-attachments') + @RequirePermission('member', 'read') + @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 }); + } + + @Post(':id/background-check/custom') + @HttpCode(200) + @RequirePermission('member', 'update') + @ApiOperation({ summary: 'Attach a custom background check for a member' }) + async attachCustomForMember( + @Param('id') memberId: string, + @OrganizationId() organizationId: string, + @AuthContext() authContext: AuthContextType, + @Body() body: AttachCustomBackgroundCheckDto, + ) { + return this.customService.attachForMember({ + organizationId, + memberId, + employeeName: body.employeeName, + employeeEmail: body.employeeEmail, + requesterNotes: body.requesterNotes?.trim() || undefined, + upload: { + fileName: body.fileName, + fileType: body.fileType, + fileData: body.fileData, + }, + userId: authContext.userId, + }); + } +} + +@ApiTags('Background Checks') +@Controller({ path: 'background-checks', version: '1' }) +@UseGuards(HybridAuthGuard, PermissionGuard) +@ApiSecurity('apikey') +export class BackgroundChecksController { + constructor(private readonly backgroundChecksService: BackgroundChecksService) {} + + @Get(':id') + @RequirePermission('member', 'read') + @ApiOperation({ summary: 'Get a background check by local or Identity ID' }) + async getById( + @Param('id') id: string, + @OrganizationId() organizationId: string, + ) { + return this.backgroundChecksService.getById({ organizationId, id }); + } + + @Post('webhook') + @Public() + @HttpCode(200) + @ApiOperation({ summary: 'Receive Identity background check webhook events' }) + async handleWebhook( + @Headers() headers: Record, + @Req() req: RawBodyRequest, + ) { + return this.backgroundChecksService.handleWebhook({ + rawBody: req.rawBody, + headers, + }); + } +} diff --git a/apps/api/src/background-checks/background-checks.module.ts b/apps/api/src/background-checks/background-checks.module.ts new file mode 100644 index 0000000000..b1e16339b6 --- /dev/null +++ b/apps/api/src/background-checks/background-checks.module.ts @@ -0,0 +1,31 @@ +import { Module } from '@nestjs/common'; +import { AttachmentsModule } from '../attachments/attachments.module'; +import { AuthModule } from '../auth/auth.module'; +import { BackgroundCheckBillingController } from './background-check-billing.controller'; +import { BackgroundCheckBillingService } from './background-check-billing.service'; +import { BackgroundCheckCustomService } from './background-check-custom.service'; +import { BackgroundCheckIdentityClient } from './background-check-identity.client'; +import { BackgroundCheckPaymentService } from './background-check-payment.service'; +import { + BackgroundChecksController, + PeopleBackgroundChecksController, +} from './background-checks.controller'; +import { BackgroundChecksService } from './background-checks.service'; + +@Module({ + imports: [AuthModule, AttachmentsModule], + controllers: [ + BackgroundChecksController, + PeopleBackgroundChecksController, + BackgroundCheckBillingController, + ], + providers: [ + BackgroundChecksService, + BackgroundCheckBillingService, + BackgroundCheckCustomService, + BackgroundCheckIdentityClient, + BackgroundCheckPaymentService, + ], + exports: [BackgroundChecksService, BackgroundCheckBillingService], +}) +export class BackgroundChecksModule {} diff --git a/apps/api/src/background-checks/background-checks.service.spec.ts b/apps/api/src/background-checks/background-checks.service.spec.ts new file mode 100644 index 0000000000..c8ed23e628 --- /dev/null +++ b/apps/api/src/background-checks/background-checks.service.spec.ts @@ -0,0 +1,418 @@ +import { BackgroundCheckIdentityClient } from './background-check-identity.client'; +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 type { StripeService } from '../stripe/stripe.service'; + +jest.mock('@db', () => { + class PrismaClientKnownRequestError extends Error { + code: string; + + constructor(message: string, options: { code: string }) { + super(message); + this.code = options.code; + } + } + + return { + BackgroundCheckStatus: { + failed: 'failed', + invited: 'invited', + }, + Prisma: { + PrismaClientKnownRequestError, + }, + db: { + backgroundCheckRequest: { + findUnique: jest.fn(), + findFirst: jest.fn(), + upsert: jest.fn(), + update: jest.fn(), + }, + backgroundCheckWebhookEvent: { + create: jest.fn(), + }, + member: { + findFirst: jest.fn(), + }, + organizationBilling: { + findUnique: jest.fn(), + create: jest.fn(), + upsert: jest.fn(), + }, + organization: { + findUnique: jest.fn(), + }, + }, + }; +}); + +const mockedDb = db as jest.Mocked; + +function mockAsync(fn: unknown): jest.MockedFunction<() => Promise> { + return fn as jest.MockedFunction<() => Promise>; +} + +function invocationOrder(fn: unknown, index = 0): number { + return ( + (fn as { mock: { invocationCallOrder: number[] } }).mock.invocationCallOrder[index] ?? 0 + ); +} + +describe('background checks', () => { + const originalEnv = { ...process.env }; + + beforeEach(() => { + jest.clearAllMocks(); + process.env = { + ...originalEnv, + BACKGROUND_CHECK_API_KEY: 'bc_test', + BACKGROUND_CHECK_API_BASE_URL: 'https://glad-sturgeon-729.convex.site/', + BACKGROUND_CHECK_WEBHOOK_SECRET: 'whsec_test', + BACKGROUND_WH_ENDPOINT: '', + STRIPE_BACKGROUND_CHECK_PRICE_ID: 'price_bg', + NEXT_PUBLIC_APP_URL: 'https://app.trycomp.ai', + }; + }); + + afterAll(() => { + process.env = originalEnv; + }); + + it('creates an Identity request with expected headers and body', async () => { + const fetchSpy = jest.spyOn(global, 'fetch').mockResolvedValueOnce( + new Response( + JSON.stringify({ + id: 'check_1', + status: 'invited', + candidateUrl: 'https://identity.trycomp.ai/cand_1', + }), + { status: 200 }, + ), + ); + + const client = new BackgroundCheckIdentityClient(); + await client.createBackgroundCheck({ + organizationId: 'org_1', + memberId: 'mem_1', + employeeName: 'Ada Lovelace', + employeeEmail: 'ada@example.com', + requesterEmail: 'admin@example.com', + }); + + expect(fetchSpy).toHaveBeenCalledWith( + 'https://glad-sturgeon-729.convex.site/v1/background-checks', + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + Authorization: 'Bearer bc_test', + 'Idempotency-Key': 'comp-background-check:mem_1', + }), + }), + ); + const request = fetchSpy.mock.calls[0]?.[1]; + const body = JSON.parse(String(request?.body)) as { + candidate: { name: string; email: string }; + metadata: { compOrganizationId: string; compMemberId: string }; + callbackUrl: string; + requesterNotes?: string; + }; + expect(body.candidate).toEqual({ + name: 'Ada Lovelace', + email: 'ada@example.com', + }); + expect(body.metadata).toEqual({ + source: 'comp', + compOrganizationId: 'org_1', + compMemberId: 'mem_1', + }); + expect(body.callbackUrl).toBe('https://api.trycomp.ai/v1/background-checks/webhook'); + expect(body.requesterNotes).toBeUndefined(); + }); + + it('uses BACKGROUND_WH_ENDPOINT as the Identity callback URL when configured', async () => { + process.env.BACKGROUND_WH_ENDPOINT = + 'https://delbert-unhopeful-misti.ngrok-free.dev/v1/background-checks/webhook/'; + const fetchSpy = jest.spyOn(global, 'fetch').mockResolvedValueOnce( + new Response( + JSON.stringify({ + id: 'check_1', + status: 'invited', + candidateUrl: 'https://identity.trycomp.ai/cand_1', + }), + { status: 200 }, + ), + ); + + const client = new BackgroundCheckIdentityClient(); + await client.createBackgroundCheck({ + organizationId: 'org_1', + memberId: 'mem_1', + employeeName: 'Ada Lovelace', + employeeEmail: 'ada@example.com', + requesterEmail: 'admin@example.com', + }); + + const request = fetchSpy.mock.calls[0]?.[1]; + const body = JSON.parse(String(request?.body)) as { callbackUrl: string }; + expect(body.callbackUrl).toBe( + 'https://delbert-unhopeful-misti.ngrok-free.dev/v1/background-checks/webhook', + ); + }); + + it('reports non-json Identity failures without throwing a parse error', async () => { + jest.spyOn(global, 'fetch').mockResolvedValueOnce( + new Response('No matching routes found', { + status: 404, + headers: { 'content-type': 'text/plain; charset=utf-8' }, + }), + ); + + const client = new BackgroundCheckIdentityClient(); + + await expect( + client.createBackgroundCheck({ + organizationId: 'org_1', + memberId: 'mem_1', + employeeName: 'Ada Lovelace', + employeeEmail: 'ada@example.com', + requesterEmail: 'admin@example.com', + }), + ).rejects.toThrow('Identity background check request failed.'); + }); + + it('returns an existing request without charging or calling Identity', async () => { + const existing = { id: 'bcr_1', status: 'invited' }; + mockAsync>>( + mockedDb.backgroundCheckRequest.findUnique, + ).mockResolvedValueOnce( + existing as Awaited>, + ); + 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, + ); + + const result = await service.requestForMember({ + organizationId: 'org_1', + memberId: 'mem_1', + employeeName: 'Ada Lovelace', + employeeEmail: 'ada@example.com', + requesterEmail: 'admin@example.com', + }); + + expect(result).toBe(existing); + expect(paymentService.charge).not.toHaveBeenCalled(); + expect(identityClient.createBackgroundCheck).not.toHaveBeenCalled(); + }); + + it('refunds the payment and stores failed status when Identity fails', async () => { + mockAsync>>( + mockedDb.backgroundCheckRequest.findUnique, + ).mockResolvedValueOnce(null); + mockAsync>>( + mockedDb.member.findFirst, + ).mockResolvedValueOnce({ + id: 'mem_1', + organizationId: 'org_1', + } as Awaited>); + mockAsync>>( + mockedDb.backgroundCheckRequest.upsert, + ) + .mockResolvedValueOnce({ + id: 'bcr_1', + status: 'invited', + } as Awaited>) + .mockResolvedValueOnce({ + id: 'bcr_1', + status: 'failed', + } as Awaited>); + + const identityClient = { + createBackgroundCheck: jest.fn().mockRejectedValue(new Error('identity down')), + }; + const paymentService = { + charge: jest.fn().mockResolvedValue({ + paymentIntentId: 'pi_1', + status: 'succeeded', + amount: 1000, + currency: 'usd', + }), + refund: jest.fn().mockResolvedValue('re_1'), + }; + const service = new BackgroundChecksService( + identityClient as unknown as BackgroundCheckIdentityClient, + paymentService as unknown as BackgroundCheckPaymentService, + ); + + await expect( + service.requestForMember({ + organizationId: 'org_1', + memberId: 'mem_1', + employeeName: 'Ada Lovelace', + employeeEmail: 'ada@example.com', + requesterEmail: 'admin@example.com', + }), + ).rejects.toThrow('identity down'); + + expect(paymentService.refund).toHaveBeenCalledWith({ + organizationId: 'org_1', + memberId: 'mem_1', + paymentIntentId: 'pi_1', + }); + expect(mockedDb.backgroundCheckRequest.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + create: expect.objectContaining({ + status: 'failed', + stripeRefundId: 're_1', + }), + }), + ); + }); + + it('stores internal requester notes on successful requests', async () => { + mockAsync>>( + mockedDb.backgroundCheckRequest.findUnique, + ).mockResolvedValueOnce(null); + mockAsync>>( + mockedDb.member.findFirst, + ).mockResolvedValueOnce({ + id: 'mem_1', + organizationId: 'org_1', + } as Awaited>); + mockAsync>>( + mockedDb.backgroundCheckRequest.upsert, + ) + .mockResolvedValueOnce({ + id: 'bcr_1', + status: 'invited', + } as Awaited>) + .mockResolvedValueOnce({ + id: 'bcr_1', + status: 'invited', + } as Awaited>); + + const identityClient = { + createBackgroundCheck: jest.fn().mockResolvedValue({ + id: 'check_1', + status: 'invited', + candidateUrl: 'https://identity.trycomp.ai/cand_1', + }), + }; + const paymentService = { + charge: jest.fn().mockResolvedValue({ + paymentIntentId: 'pi_1', + status: 'succeeded', + amount: 4900, + currency: 'usd', + }), + refund: jest.fn(), + }; + const service = new BackgroundChecksService( + identityClient as unknown as BackgroundCheckIdentityClient, + paymentService as unknown as BackgroundCheckPaymentService, + ); + + await service.requestForMember({ + organizationId: 'org_1', + memberId: 'mem_1', + employeeName: 'Ada Lovelace', + employeeEmail: 'ada@example.com', + requesterEmail: 'admin@example.com', + requesterNotes: 'Expedite this check.', + }); + + expect(mockedDb.backgroundCheckRequest.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + create: expect.objectContaining({ + requesterNotes: 'Expedite this check.', + }), + }), + ); + expect(mockedDb.backgroundCheckRequest.upsert).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + create: expect.objectContaining({ + status: 'invited', + stripePaymentIntentId: 'pi_1', + }), + }), + ); + expect(mockedDb.backgroundCheckRequest.upsert).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + update: expect.objectContaining({ + identityBackgroundCheckId: 'check_1', + candidateUrl: 'https://identity.trycomp.ai/cand_1', + }), + }), + ); + expect(invocationOrder(mockedDb.backgroundCheckRequest.upsert)).toBeLessThan( + invocationOrder(identityClient.createBackgroundCheck), + ); + expect(identityClient.createBackgroundCheck).toHaveBeenCalledWith( + expect.not.objectContaining({ + requesterNotes: expect.any(String), + }), + ); + }); + + it('uses BETTER_AUTH_URL as the local app URL fallback for setup redirects', async () => { + process.env = { + ...process.env, + NEXT_PUBLIC_APP_URL: '', + APP_URL: '', + BETTER_AUTH_URL: 'http://localhost:3000', + }; + mockAsync>>( + mockedDb.organizationBilling.findUnique, + ).mockResolvedValueOnce(null); + mockAsync>>( + mockedDb.organization.findUnique, + ).mockResolvedValueOnce({ + name: 'Acme', + } as Awaited>); + mockAsync>>( + mockedDb.organizationBilling.create, + ).mockResolvedValueOnce({ + organizationId: 'org_1', + stripeCustomerId: 'cus_1', + } as Awaited>); + + const stripe = { + checkout: { + sessions: { + create: jest.fn().mockResolvedValue({ + url: 'https://checkout.stripe.com/c/session_1', + }), + }, + }, + customers: { + create: jest.fn().mockResolvedValue({ id: 'cus_1' }), + }, + prices: { + retrieve: jest.fn().mockResolvedValue({ + id: 'price_bg', + unit_amount: 4900, + currency: 'usd', + }), + }, + }; + const stripeService = { + getClient: () => stripe, + } as unknown as StripeService; + const service = new BackgroundCheckBillingService(stripeService); + + await expect( + service.createSetupSession({ + organizationId: 'org_1', + successUrl: + 'http://localhost:3000/org_1/people/mem_1?background_check_billing=success', + cancelUrl: 'http://localhost:3000/org_1/people/mem_1', + }), + ).resolves.toEqual({ url: 'https://checkout.stripe.com/c/session_1' }); + }); +}); diff --git a/apps/api/src/background-checks/background-checks.service.ts b/apps/api/src/background-checks/background-checks.service.ts new file mode 100644 index 0000000000..e7a85af590 --- /dev/null +++ b/apps/api/src/background-checks/background-checks.service.ts @@ -0,0 +1,290 @@ +import { + BadRequestException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { BackgroundCheckStatus, db, Prisma } from '@db'; +import { BackgroundCheckIdentityClient } from './background-check-identity.client'; +import { BackgroundCheckPaymentService } from './background-check-payment.service'; +import { + headerValue, + verifyBackgroundCheckWebhookSignature, +} from './background-check-webhook-signature'; +import { + identityWebhookPayloadSchema, +} from './background-checks.types'; +import { + fetchCompletedReportSnapshot, +} from './background-check-report-snapshot'; + +@Injectable() +export class BackgroundChecksService { + constructor( + private readonly identityClient: BackgroundCheckIdentityClient, + private readonly paymentService: BackgroundCheckPaymentService, + ) {} + + async getForMember({ + organizationId, + memberId, + }: { + organizationId: string; + memberId: string; + }) { + return db.backgroundCheckRequest.findUnique({ + where: { organizationId_memberId: { organizationId, memberId } }, + }); + } + + async requestForMember({ + organizationId, + memberId, + employeeName, + employeeEmail, + requesterNotes, + requesterEmail, + }: { + organizationId: string; + memberId: string; + employeeName: string; + employeeEmail: string; + requesterNotes?: string; + requesterEmail: string; + }) { + const existing = await this.getForMember({ organizationId, memberId }); + if (existing) { + return existing; + } + + const member = await db.member.findFirst({ + where: { id: memberId, organizationId, deactivated: false }, + select: { id: true, organizationId: true }, + }); + + if (!member) { + throw new NotFoundException('Member not found.'); + } + + const payment = await this.paymentService.charge({ + organizationId, + memberId, + }); + + await db.backgroundCheckRequest.upsert({ + where: { organizationId_memberId: { organizationId, memberId } }, + create: { + organizationId, + memberId, + employeeName, + employeeEmail, + requesterNotes, + status: BackgroundCheckStatus.invited, + stripePaymentIntentId: payment.paymentIntentId, + stripePaymentStatus: payment.status, + stripeAmountCents: payment.amount, + stripeCurrency: payment.currency, + lastSyncedAt: new Date(), + }, + update: { + employeeName, + employeeEmail, + requesterNotes, + stripePaymentIntentId: payment.paymentIntentId, + stripePaymentStatus: payment.status, + stripeAmountCents: payment.amount, + stripeCurrency: payment.currency, + lastSyncedAt: new Date(), + }, + }); + + try { + const identityResult = await this.identityClient.createBackgroundCheck({ + organizationId, + memberId, + employeeName, + employeeEmail, + requesterEmail, + }); + + return db.backgroundCheckRequest.upsert({ + where: { organizationId_memberId: { organizationId, memberId } }, + create: { + organizationId, + memberId, + employeeName, + employeeEmail, + requesterNotes, + identityBackgroundCheckId: identityResult.id, + candidateUrl: identityResult.candidateUrl ?? null, + status: identityResult.status, + stripePaymentIntentId: payment.paymentIntentId, + stripePaymentStatus: payment.status, + stripeAmountCents: payment.amount, + stripeCurrency: payment.currency, + lastSyncedAt: new Date(), + }, + update: { + employeeName, + employeeEmail, + requesterNotes, + identityBackgroundCheckId: identityResult.id, + candidateUrl: identityResult.candidateUrl ?? null, + status: identityResult.status, + stripePaymentIntentId: payment.paymentIntentId, + stripePaymentStatus: payment.status, + stripeAmountCents: payment.amount, + stripeCurrency: payment.currency, + lastSyncedAt: new Date(), + }, + }); + } catch (error) { + const refundId = await this.paymentService.refund({ + organizationId, + memberId, + paymentIntentId: payment.paymentIntentId, + }); + + await db.backgroundCheckRequest.upsert({ + where: { organizationId_memberId: { organizationId, memberId } }, + create: { + organizationId, + memberId, + employeeName, + employeeEmail, + requesterNotes, + status: BackgroundCheckStatus.failed, + stripePaymentIntentId: payment.paymentIntentId, + stripePaymentStatus: payment.status, + stripeRefundId: refundId, + stripeAmountCents: payment.amount, + stripeCurrency: payment.currency, + lastSyncedAt: new Date(), + }, + update: { + status: BackgroundCheckStatus.failed, + stripeRefundId: refundId, + lastSyncedAt: new Date(), + }, + }); + + throw error; + } + } + + async getById({ + organizationId, + id, + }: { + organizationId: string; + id: string; + }): Promise<{ record: unknown; identity?: unknown }> { + const record = await db.backgroundCheckRequest.findFirst({ + where: { + organizationId, + OR: [{ id }, { identityBackgroundCheckId: id }], + }, + }); + + if (!record) { + throw new NotFoundException('Background check not found.'); + } + + if (!record.identityBackgroundCheckId || !process.env.BACKGROUND_CHECK_API_KEY) { + return { record }; + } + + const identity = await this.identityClient.getBackgroundCheck( + record.identityBackgroundCheckId, + ); + return { record, identity }; + } + + async handleWebhook({ + rawBody, + headers, + }: { + rawBody: Buffer | undefined; + headers: Record; + }): Promise<{ ok: true; duplicate?: true }> { + if (!rawBody) { + throw new BadRequestException('Raw body unavailable.'); + } + + verifyBackgroundCheckWebhookSignature({ rawBody, headers }); + 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; + + const record = await db.backgroundCheckRequest.findFirst({ + where: { + organizationId: payload.data.metadata.compOrganizationId, + memberId: payload.data.metadata.compMemberId, + OR: [ + { identityBackgroundCheckId: payload.data.id }, + { identityBackgroundCheckId: null }, + ], + }, + }); + + if (!record) { + throw new NotFoundException('Background check request not found.'); + } + + try { + await db.backgroundCheckWebhookEvent.create({ + data: { + eventId, + eventType, + backgroundCheckRequestId: record.id, + identityBackgroundCheckId: payload.data.id, + payload: payload as Prisma.InputJsonValue, + }, + }); + } catch (error) { + if (this.isUniqueConstraintError(error)) { + return { ok: true, duplicate: true }; + } + throw error; + } + + const reportSnapshot = await fetchCompletedReportSnapshot({ + identityClient: this.identityClient, + identityBackgroundCheckId: payload.data.id, + eventType, + status: payload.data.status, + }); + + await db.backgroundCheckRequest.update({ + where: { id: record.id }, + data: { + identityBackgroundCheckId: payload.data.id, + employeeName: payload.data.candidateName ?? record.employeeName, + employeeEmail: payload.data.candidateEmail ?? record.employeeEmail, + status: payload.data.status, + identityStatus: payload.data.statuses?.identity ?? null, + employmentStatus: payload.data.statuses?.employment ?? null, + referenceStatus: payload.data.statuses?.references ?? null, + rightToWorkStatus: payload.data.statuses?.rightToWork ?? null, + adjudicationStatus: payload.data.statuses?.adjudication ?? null, + lastWebhookEventId: eventId, + lastSyncedAt: new Date(), + ...(reportSnapshot + ? { + reportSnapshot, + reportSyncedAt: new Date(), + } + : {}), + }, + }); + + return { ok: true }; + } + + private isUniqueConstraintError(error: unknown): boolean { + return ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === 'P2002' + ); + } +} diff --git a/apps/api/src/background-checks/background-checks.types.ts b/apps/api/src/background-checks/background-checks.types.ts new file mode 100644 index 0000000000..7783218360 --- /dev/null +++ b/apps/api/src/background-checks/background-checks.types.ts @@ -0,0 +1,50 @@ +import { z } from 'zod'; + +export const backgroundCheckStatuses = [ + 'invited', + 'in_progress', + 'in_review', + 'completed', + 'completed_with_flags', + 'failed', + 'cancelled', +] as const; + +export const identityCreateResponseSchema = z.object({ + id: z.string(), + status: z.enum(backgroundCheckStatuses), + candidateUrl: z.string().url().nullable().optional(), +}); + +export const identityWebhookPayloadSchema = z.object({ + eventId: z.string(), + type: z.string(), + apiVersion: z.string().optional(), + data: z.object({ + id: z.string(), + status: z.enum(backgroundCheckStatuses), + candidateName: z.string().optional(), + candidateEmail: z.string().email().optional(), + metadata: z.object({ + source: z.string().optional(), + compOrganizationId: z.string(), + compMemberId: z.string(), + }), + statuses: z + .object({ + identity: z.string().optional(), + employment: z.string().optional(), + references: z.string().optional(), + rightToWork: z.string().optional(), + adjudication: z.string().optional(), + }) + .optional(), + createdAt: z.number().nullable().optional(), + updatedAt: z.number().nullable().optional(), + completedAt: z.number().nullable().optional(), + }), +}); + +export type IdentityCreateResponse = z.infer; +export type IdentityWebhookPayload = z.infer; +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 new file mode 100644 index 0000000000..e41db725ac --- /dev/null +++ b/apps/api/src/background-checks/dto/attach-custom-background-check.dto.ts @@ -0,0 +1,18 @@ +import { IsEmail, IsOptional, IsString, MaxLength, MinLength } from 'class-validator'; +import { UploadAttachmentDto } from '../../attachments/upload-attachment.dto'; + +export class AttachCustomBackgroundCheckDto extends UploadAttachmentDto { + @IsOptional() + @IsString() + @MinLength(1) + employeeName?: string; + + @IsOptional() + @IsEmail() + employeeEmail?: string; + + @IsOptional() + @IsString() + @MaxLength(2000) + requesterNotes?: string; +} diff --git a/apps/api/src/background-checks/dto/background-check-billing.dto.ts b/apps/api/src/background-checks/dto/background-check-billing.dto.ts new file mode 100644 index 0000000000..38c1fb9186 --- /dev/null +++ b/apps/api/src/background-checks/dto/background-check-billing.dto.ts @@ -0,0 +1,19 @@ +import { IsString } from 'class-validator'; + +export class BackgroundCheckSetupSessionDto { + @IsString() + successUrl: string; + + @IsString() + cancelUrl: string; +} + +export class BackgroundCheckSetupSuccessDto { + @IsString() + sessionId: string; +} + +export class BackgroundCheckBillingPortalDto { + @IsString() + returnUrl: string; +} 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 new file mode 100644 index 0000000000..df078e6362 --- /dev/null +++ b/apps/api/src/background-checks/dto/request-background-check.dto.ts @@ -0,0 +1,15 @@ +import { IsEmail, IsOptional, IsString, MaxLength, MinLength } from 'class-validator'; + +export class RequestBackgroundCheckDto { + @IsString() + @MinLength(1) + employeeName: string; + + @IsEmail() + employeeEmail: string; + + @IsOptional() + @IsString() + @MaxLength(2000) + requesterNotes?: string; +} diff --git a/apps/api/src/frameworks/frameworks-compliance-score.helper.ts b/apps/api/src/frameworks/frameworks-compliance-score.helper.ts new file mode 100644 index 0000000000..66a23940a9 --- /dev/null +++ b/apps/api/src/frameworks/frameworks-compliance-score.helper.ts @@ -0,0 +1,101 @@ +const SIX_MONTHS_MS = 6 * 30 * 24 * 60 * 60 * 1000; + +interface ControlForScoring { + id: string; + policies: { id: string; status: string }[]; + controlDocumentTypes?: { formType: string }[]; +} + +interface FrameworkWithControlsForScoring { + controls: ControlForScoring[]; +} + +interface TaskWithControls { + id: string; + status: string; + controls: { id: string }[]; +} + +interface EvidenceSubmissionForScoring { + formType: string; + submittedAt: Date | string; +} + +function hasAnyArtifact( + control: ControlForScoring, + tasks: TaskWithControls[], +): boolean { + const policies = control.policies ?? []; + const documentTypes = control.controlDocumentTypes ?? []; + const controlTasks = tasks.filter((task) => + task.controls.some((controlRef) => controlRef.id === control.id), + ); + return ( + policies.length > 0 || controlTasks.length > 0 || documentTypes.length > 0 + ); +} + +function isControlCompleted({ + control, + tasks, + evidenceSubmissions, +}: { + control: ControlForScoring; + tasks: TaskWithControls[]; + evidenceSubmissions: EvidenceSubmissionForScoring[]; +}): boolean { + const policies = control.policies ?? []; + const documentTypes = control.controlDocumentTypes ?? []; + const controlTasks = tasks.filter((task) => + task.controls.some((controlRef) => controlRef.id === control.id), + ); + + const policiesComplete = + policies.length === 0 || + policies.every((policy) => policy.status === 'published'); + const tasksComplete = + controlTasks.length === 0 || + controlTasks.every( + (task) => task.status === 'done' || task.status === 'not_relevant', + ); + + let documentsComplete = true; + if (documentTypes.length > 0) { + const sorted = [...evidenceSubmissions].sort( + (a, b) => + new Date(b.submittedAt).getTime() - new Date(a.submittedAt).getTime(), + ); + const now = Date.now(); + for (const documentType of documentTypes) { + const latest = sorted.find( + (submission) => submission.formType === documentType.formType, + ); + if ( + !latest || + now - new Date(latest.submittedAt).getTime() > SIX_MONTHS_MS + ) { + documentsComplete = false; + break; + } + } + } + + return policiesComplete && tasksComplete && documentsComplete; +} + +export function computeFrameworkComplianceScore( + framework: FrameworkWithControlsForScoring, + tasks: TaskWithControls[], + evidenceSubmissions: EvidenceSubmissionForScoring[] = [], +): number { + const controls = (framework.controls ?? []).filter((control) => + hasAnyArtifact(control, tasks), + ); + if (controls.length === 0) return 0; + + const completed = controls.filter((control) => + isControlCompleted({ control, tasks, evidenceSubmissions }), + ).length; + + return Math.round((completed / controls.length) * 100); +} diff --git a/apps/api/src/frameworks/frameworks-people-score.helper.spec.ts b/apps/api/src/frameworks/frameworks-people-score.helper.spec.ts new file mode 100644 index 0000000000..160df11f15 --- /dev/null +++ b/apps/api/src/frameworks/frameworks-people-score.helper.spec.ts @@ -0,0 +1,79 @@ +jest.mock('@db', () => ({ + BackgroundCheckStatus: { + completed: 'completed', + completed_with_flags: 'completed_with_flags', + }, + db: { + employeeTrainingVideoCompletion: { findMany: jest.fn() }, + device: { findMany: jest.fn() }, + member: { findMany: jest.fn() }, + fleetPolicyResult: { findMany: jest.fn() }, + backgroundCheckRequest: { findMany: jest.fn() }, + }, +})); + +jest.mock('../utils/compliance-filters', () => ({ + filterComplianceMembers: jest.fn(), +})); + +import { db } from '@db'; +import { filterComplianceMembers } from '../utils/compliance-filters'; +import { computePeopleScore } from './frameworks-people-score.helper'; + +const mockDb = db as jest.Mocked; +const mockFilterComplianceMembers = + filterComplianceMembers as jest.MockedFunction< + typeof filterComplianceMembers + >; + +const members: Array<{ + id: string; + role: string; + user: { id: string; email: string; role: string }; +}> = [ + { + id: 'mem_1', + role: 'owner', + user: { id: 'usr_1', email: 'a@example.com', role: 'owner' }, + }, + { + id: 'mem_2', + role: 'owner', + user: { id: 'usr_2', email: 'b@example.com', role: 'owner' }, + }, +]; + +describe('computePeopleScore', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockFilterComplianceMembers.mockResolvedValue(members); + ( + mockDb.employeeTrainingVideoCompletion.findMany as jest.Mock + ).mockResolvedValue([]); + (mockDb.backgroundCheckRequest.findMany as jest.Mock).mockResolvedValue([ + { memberId: 'mem_1' }, + ]); + }); + + it('requires a completed or uploaded background check for people completion', async () => { + const score = await computePeopleScore({ + organizationId: 'org_1', + allPolicies: [], + employees: members, + securityTrainingStepEnabled: false, + deviceAgentStepEnabled: false, + hasHipaaFramework: false, + }); + + expect(score).toEqual({ total: 2, completed: 1 }); + expect(mockDb.backgroundCheckRequest.findMany).toHaveBeenCalledWith({ + where: { + organizationId: 'org_1', + memberId: { in: ['mem_1', 'mem_2'] }, + status: { in: ['completed', 'completed_with_flags'] }, + }, + select: { memberId: true }, + distinct: ['memberId'], + }); + }); +}); diff --git a/apps/api/src/frameworks/frameworks-people-score.helper.ts b/apps/api/src/frameworks/frameworks-people-score.helper.ts new file mode 100644 index 0000000000..5d86a85cef --- /dev/null +++ b/apps/api/src/frameworks/frameworks-people-score.helper.ts @@ -0,0 +1,200 @@ +import { BackgroundCheckStatus, db } from '@db'; +import { filterComplianceMembers } from '../utils/compliance-filters'; + +const GENERAL_TRAINING_IDS = ['sat-1', 'sat-2', 'sat-3', 'sat-4', 'sat-5']; +const HIPAA_TRAINING_ID = 'hipaa-sat-1'; +const COMPLETED_BACKGROUND_CHECK_STATUSES = [ + BackgroundCheckStatus.completed, + BackgroundCheckStatus.completed_with_flags, +]; + +interface ScorePolicy { + isRequiredToSign: boolean; + status: string; + isArchived: boolean; + signedBy: string[]; +} + +interface ScoreMember { + id: string; + userId?: string | null; + role: string; + user?: { role?: string | null } | null; +} + +interface ComputePeopleScoreParams { + organizationId: string; + allPolicies: ScorePolicy[]; + employees: ScoreMember[]; + securityTrainingStepEnabled: boolean; + deviceAgentStepEnabled: boolean; + hasHipaaFramework: boolean; +} + +export async function computePeopleScore({ + organizationId, + allPolicies, + employees, + securityTrainingStepEnabled, + deviceAgentStepEnabled, + hasHipaaFramework, +}: ComputePeopleScoreParams) { + const activeEmployees = await filterComplianceMembers( + employees, + organizationId, + ); + if (activeEmployees.length === 0) { + return { total: 0, completed: 0 }; + } + + const requiredPolicies = allPolicies.filter( + (p) => p.isRequiredToSign && p.status === 'published' && !p.isArchived, + ); + const memberIds = activeEmployees.map((employee) => employee.id); + const memberUserIds = activeEmployees + .map((employee) => employee.userId) + .filter((id): id is string => !!id); + + const [ + membersWithInstalledDevices, + trainingCompletions, + membersWithCompletedBackgroundChecks, + ] = await Promise.all([ + getMembersWithInstalledDevices({ + organizationId, + memberIds, + memberUserIds, + deviceAgentStepEnabled, + }), + getTrainingCompletions({ + memberIds, + needsCompletions: securityTrainingStepEnabled || hasHipaaFramework, + }), + getMembersWithCompletedBackgroundChecks({ organizationId, memberIds }), + ]); + + const completed = activeEmployees.filter((employee) => { + const hasAcceptedAllPolicies = + requiredPolicies.length === 0 || + requiredPolicies.every((policy) => policy.signedBy.includes(employee.id)); + + const completedVideoIds = trainingCompletions + .filter( + (completion) => + completion.memberId === employee.id && + completion.completedAt !== null, + ) + .map((completion) => completion.videoId); + + const hasCompletedAllTraining = securityTrainingStepEnabled + ? GENERAL_TRAINING_IDS.every((videoId) => + completedVideoIds.includes(videoId), + ) + : true; + const hasCompletedHipaa = hasHipaaFramework + ? completedVideoIds.includes(HIPAA_TRAINING_ID) + : true; + const hasInstalledDevice = deviceAgentStepEnabled + ? membersWithInstalledDevices.has(employee.id) + : true; + + return ( + hasAcceptedAllPolicies && + hasCompletedAllTraining && + hasCompletedHipaa && + hasInstalledDevice && + membersWithCompletedBackgroundChecks.has(employee.id) + ); + }).length; + + return { total: activeEmployees.length, completed }; +} + +async function getMembersWithInstalledDevices({ + organizationId, + memberIds, + memberUserIds, + deviceAgentStepEnabled, +}: { + organizationId: string; + memberIds: string[]; + memberUserIds: string[]; + deviceAgentStepEnabled: boolean; +}) { + if (!deviceAgentStepEnabled) return new Set(); + + const [installedDevices, membersWithFleetLabels, fleetPolicyResults] = + await Promise.all([ + db.device.findMany({ + where: { + organizationId, + memberId: { in: memberIds }, + }, + select: { memberId: true }, + distinct: ['memberId'], + }), + db.member.findMany({ + where: { + organizationId, + id: { in: memberIds }, + NOT: { fleetDmLabelId: null }, + }, + select: { id: true, userId: true }, + }), + memberUserIds.length > 0 + ? db.fleetPolicyResult.findMany({ + where: { + organizationId, + userId: { in: memberUserIds }, + }, + select: { userId: true }, + distinct: ['userId'], + }) + : Promise.resolve([]), + ]); + + const fleetUserIdsWithData = new Set( + fleetPolicyResults.map((result) => result.userId), + ); + const memberIdsWithFleetData = membersWithFleetLabels + .filter((member) => fleetUserIdsWithData.has(member.userId)) + .map((member) => member.id); + + return new Set([ + ...installedDevices.map((device) => device.memberId), + ...memberIdsWithFleetData, + ]); +} + +async function getTrainingCompletions({ + memberIds, + needsCompletions, +}: { + memberIds: string[]; + needsCompletions: boolean; +}) { + if (!needsCompletions) return []; + return db.employeeTrainingVideoCompletion.findMany({ + where: { memberId: { in: memberIds } }, + }); +} + +async function getMembersWithCompletedBackgroundChecks({ + organizationId, + memberIds, +}: { + organizationId: string; + memberIds: string[]; +}) { + const completedBackgroundChecks = await db.backgroundCheckRequest.findMany({ + where: { + organizationId, + memberId: { in: memberIds }, + status: { in: COMPLETED_BACKGROUND_CHECK_STATUSES }, + }, + select: { memberId: true }, + distinct: ['memberId'], + }); + + return new Set(completedBackgroundChecks.map((check) => check.memberId)); +} diff --git a/apps/api/src/frameworks/frameworks-scores.helper.spec.ts b/apps/api/src/frameworks/frameworks-scores.helper.spec.ts index 7cb00fbaa8..6fbb4b1e5b 100644 --- a/apps/api/src/frameworks/frameworks-scores.helper.spec.ts +++ b/apps/api/src/frameworks/frameworks-scores.helper.spec.ts @@ -1,14 +1,19 @@ jest.mock('@db', () => ({ + BackgroundCheckStatus: { + completed: 'completed', + completed_with_flags: 'completed_with_flags', + }, db: { policy: { findMany: jest.fn() }, task: { findMany: jest.fn() }, member: { findMany: jest.fn() }, onboarding: { findUnique: jest.fn() }, organization: { findUnique: jest.fn() }, - frameworkInstance: { findFirst: jest.fn() }, + frameworkInstance: { findFirst: jest.fn(), findMany: jest.fn() }, employeeTrainingVideoCompletion: { findMany: jest.fn() }, device: { findMany: jest.fn() }, fleetPolicyResult: { findMany: jest.fn() }, + backgroundCheckRequest: { findMany: jest.fn() }, evidenceSubmission: { groupBy: jest.fn() }, finding: { findMany: jest.fn() }, }, @@ -36,10 +41,15 @@ describe('frameworks-scores.helper', () => { (mockDb.task.findMany as jest.Mock).mockResolvedValue([]); (mockDb.onboarding.findUnique as jest.Mock).mockResolvedValue(null); (mockDb.frameworkInstance.findFirst as jest.Mock).mockResolvedValue(null); + (mockDb.frameworkInstance.findMany as jest.Mock).mockResolvedValue([]); ( mockDb.employeeTrainingVideoCompletion.findMany as jest.Mock ).mockResolvedValue([]); (mockDb.fleetPolicyResult.findMany as jest.Mock).mockResolvedValue([]); + (mockDb.backgroundCheckRequest.findMany as jest.Mock).mockResolvedValue([ + { memberId: 'mem_1' }, + { memberId: 'mem_2' }, + ]); (mockDb.evidenceSubmission.groupBy as jest.Mock).mockResolvedValue([]); (mockDb.finding.findMany as jest.Mock).mockResolvedValue([]); }); @@ -272,6 +282,8 @@ describe('frameworks-scores.helper', () => { expect(scores.people.total).toBe(2); expect(scores.people.completed).toBe(2); - expect(mockDb.employeeTrainingVideoCompletion.findMany).not.toHaveBeenCalled(); + expect( + mockDb.employeeTrainingVideoCompletion.findMany, + ).not.toHaveBeenCalled(); }); }); diff --git a/apps/api/src/frameworks/frameworks-scores.helper.ts b/apps/api/src/frameworks/frameworks-scores.helper.ts index ea843a86d5..2d37e1ec12 100644 --- a/apps/api/src/frameworks/frameworks-scores.helper.ts +++ b/apps/api/src/frameworks/frameworks-scores.helper.ts @@ -6,17 +6,18 @@ import { } from '@trycompai/company'; import { db } from '@db'; import { ISO27001_FRAMEWORK_NAMES } from '../soa/utils/constants'; -import { filterComplianceMembers } from '../utils/compliance-filters'; +import { computePeopleScore } from './frameworks-people-score.helper'; const SIX_MONTHS_MS = 6 * 30 * 24 * 60 * 60 * 1000; -const GENERAL_TRAINING_IDS = ['sat-1', 'sat-2', 'sat-3', 'sat-4', 'sat-5']; -const HIPAA_TRAINING_ID = 'hipaa-sat-1'; +export { computeFrameworkComplianceScore } from './frameworks-compliance-score.helper'; export async function getOverviewScores(organizationId: string) { const [allPolicies, allTasks, employees, onboarding, org, hipaaInstance] = await Promise.all([ - db.policy.findMany({ where: { organizationId, isArchived: false, archivedAt: null } }), + db.policy.findMany({ + where: { organizationId, isArchived: false, archivedAt: null }, + }), db.task.findMany({ where: { organizationId, archivedAt: null } }), db.member.findMany({ where: { organizationId, deactivated: false }, @@ -43,7 +44,6 @@ export async function getOverviewScores(organizationId: string) { const deviceAgentStepEnabled = org?.deviceAgentStepEnabled === true; const hasHipaaFramework = !!hipaaInstance; - // Policy breakdown const publishedPolicies = allPolicies.filter((p) => p.status === 'published'); const draftPolicies = allPolicies.filter((p) => p.status === 'draft'); const policiesInReview = allPolicies.filter( @@ -53,7 +53,6 @@ export async function getOverviewScores(organizationId: string) { (p) => p.status === 'draft' || p.status === 'needs_review', ); - // Task breakdown const doneTasks = allTasks.filter( (t) => t.status === 'done' || t.status === 'not_relevant', ); @@ -61,106 +60,14 @@ export async function getOverviewScores(organizationId: string) { (t) => t.status === 'todo' || t.status === 'in_progress', ); - // People score — filter to members with compliance:required permission - const activeEmployees = await filterComplianceMembers( - employees, + const people = await computePeopleScore({ organizationId, - ); - - let completedMembers = 0; - - if (activeEmployees.length > 0) { - const requiredPolicies = allPolicies.filter( - (p) => p.isRequiredToSign && p.status === 'published' && !p.isArchived, - ); - - const memberIds = activeEmployees.map((e) => e.id); - const memberUserIds = activeEmployees - .map((e) => e.userId) - .filter((id): id is string => !!id); - const needsCompletions = securityTrainingStepEnabled || hasHipaaFramework; - let membersWithInstalledDevices = new Set(); - - if (deviceAgentStepEnabled) { - const [installedDevices, membersWithFleetLabels, fleetPolicyResults] = - await Promise.all([ - db.device.findMany({ - where: { - organizationId, - memberId: { in: memberIds }, - }, - select: { memberId: true }, - distinct: ['memberId'], - }), - db.member.findMany({ - where: { - organizationId, - id: { in: memberIds }, - NOT: { fleetDmLabelId: null }, - }, - select: { id: true, userId: true }, - }), - memberUserIds.length > 0 - ? db.fleetPolicyResult.findMany({ - where: { - organizationId, - userId: { in: memberUserIds }, - }, - select: { userId: true }, - distinct: ['userId'], - }) - : Promise.resolve([]), - ]); - - const fleetUserIdsWithData = new Set( - fleetPolicyResults.map((result) => result.userId), - ); - const memberIdsWithFleetData = membersWithFleetLabels - .filter((member) => fleetUserIdsWithData.has(member.userId)) - .map((member) => member.id); - - membersWithInstalledDevices = new Set([ - ...installedDevices.map((device) => device.memberId), - ...memberIdsWithFleetData, - ]); - } - - const trainingCompletions = needsCompletions - ? await db.employeeTrainingVideoCompletion.findMany({ - where: { memberId: { in: memberIds } }, - }) - : []; - - for (const emp of activeEmployees) { - const hasAcceptedAllPolicies = - requiredPolicies.length === 0 || - requiredPolicies.every((p) => p.signedBy.includes(emp.id)); - - const completedVideoIds = trainingCompletions - .filter((c) => c.memberId === emp.id && c.completedAt !== null) - .map((c) => c.videoId); - - const hasCompletedAllTraining = securityTrainingStepEnabled - ? GENERAL_TRAINING_IDS.every((vid) => completedVideoIds.includes(vid)) - : true; - - const hasCompletedHipaa = hasHipaaFramework - ? completedVideoIds.includes(HIPAA_TRAINING_ID) - : true; - const hasInstalledDevice = deviceAgentStepEnabled - ? membersWithInstalledDevices.has(emp.id) - : true; - - if ( - hasAcceptedAllPolicies && - hasCompletedAllTraining && - hasCompletedHipaa && - hasInstalledDevice - ) { - completedMembers++; - } - } - } + allPolicies, + employees, + securityTrainingStepEnabled, + deviceAgentStepEnabled, + hasHipaaFramework, + }); return { policies: { @@ -175,10 +82,7 @@ export async function getOverviewScores(organizationId: string) { done: doneTasks.length, incompleteTasks, }, - people: { - total: activeEmployees.length, - completed: completedMembers, - }, + people, onboardingTriggerJobId: onboarding?.triggerJobId ?? null, documents: await computeDocumentsScore(organizationId), findings: await getOrganizationFindings(organizationId), @@ -263,9 +167,11 @@ async function computeDocumentsScore(organizationId: string) { } const soaTotalDocuments = hasSOADocumentRequirement ? 1 : 0; - const soaOutstandingDocuments = hasSOADocumentRequirement && !soaCompleted ? 1 : 0; + const soaOutstandingDocuments = + hasSOADocumentRequirement && !soaCompleted ? 1 : 0; const totalDocuments = includedForms.length + soaTotalDocuments; - const outstandingDocuments = nonSOAOutstandingDocuments + soaOutstandingDocuments; + const outstandingDocuments = + nonSOAOutstandingDocuments + soaOutstandingDocuments; return { totalDocuments, @@ -305,96 +211,3 @@ export async function getCurrentMember(organizationId: string, userId: string) { }); return member; } - -interface ControlForScoring { - id: string; - policies: { id: string; status: string }[]; - controlDocumentTypes?: { formType: string }[]; -} - -interface FrameworkWithControlsForScoring { - controls: ControlForScoring[]; -} - -interface TaskWithControls { - id: string; - status: string; - controls: { id: string }[]; -} - -interface EvidenceSubmissionForScoring { - formType: string; - submittedAt: Date | string; -} - -function hasAnyArtifact( - control: ControlForScoring, - tasks: TaskWithControls[], -): boolean { - const policies = control.policies ?? []; - const documentTypes = control.controlDocumentTypes ?? []; - const controlTasks = tasks.filter((t) => - t.controls.some((c) => c.id === control.id), - ); - return ( - policies.length > 0 || controlTasks.length > 0 || documentTypes.length > 0 - ); -} - -function isControlCompleted( - control: ControlForScoring, - tasks: TaskWithControls[], - evidenceSubmissions: EvidenceSubmissionForScoring[], -): boolean { - const policies = control.policies ?? []; - const documentTypes = control.controlDocumentTypes ?? []; - const controlTasks = tasks.filter((t) => - t.controls.some((c) => c.id === control.id), - ); - - const policiesComplete = - policies.length === 0 || - policies.every((p) => p.status === 'published'); - - const tasksComplete = - controlTasks.length === 0 || - controlTasks.every( - (t) => t.status === 'done' || t.status === 'not_relevant', - ); - - let documentsComplete = true; - if (documentTypes.length > 0) { - const sorted = [...evidenceSubmissions].sort( - (a, b) => - new Date(b.submittedAt).getTime() - new Date(a.submittedAt).getTime(), - ); - const now = Date.now(); - for (const dt of documentTypes) { - const latest = sorted.find((es) => es.formType === dt.formType); - if ( - !latest || - now - new Date(latest.submittedAt).getTime() > SIX_MONTHS_MS - ) { - documentsComplete = false; - break; - } - } - } - - return policiesComplete && tasksComplete && documentsComplete; -} - -export function computeFrameworkComplianceScore( - framework: FrameworkWithControlsForScoring, - tasks: TaskWithControls[], - evidenceSubmissions: EvidenceSubmissionForScoring[] = [], -): number { - const controls = (framework.controls ?? []).filter((c) => - hasAnyArtifact(c, tasks), - ); - if (controls.length === 0) return 0; - const completed = controls.filter((c) => - isControlCompleted(c, tasks, evidenceSubmissions), - ).length; - return Math.round((completed / controls.length) * 100); -} diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 75585eb752..4c5ebb927c 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -15,6 +15,10 @@ import { mkdirSync, writeFileSync, existsSync } from 'fs'; let app: INestApplication | null = null; +interface ExpressRawBodyRequest extends express.Request { + rawBody?: Buffer; +} + function describeServer(baseUrl: string): string { if (baseUrl.includes('api.staging.trycomp.ai')) return 'Staging API Server'; if (baseUrl.includes('api.trycomp.ai')) return 'Production API Server'; @@ -78,7 +82,12 @@ async function bootstrap(): Promise { // request stream to properly read the body (including OAuth callbackURL). // Express-level middleware runs BEFORE NestJS module middleware, so without this // skip, express.json() would consume the stream before better-auth's handler. - const jsonParser = express.json({ limit: '150mb' }); + const jsonParser = express.json({ + limit: '150mb', + verify: (req, _res, buf) => { + (req as ExpressRawBodyRequest).rawBody = Buffer.from(buf); + }, + }); const urlencodedParser = express.urlencoded({ limit: '150mb', extended: true, diff --git a/apps/api/src/people/dto/people-responses.dto.ts b/apps/api/src/people/dto/people-responses.dto.ts index 65668b52a4..d48a2db556 100644 --- a/apps/api/src/people/dto/people-responses.dto.ts +++ b/apps/api/src/people/dto/people-responses.dto.ts @@ -1,5 +1,22 @@ import { ApiProperty } from '@nestjs/swagger'; -import { Departments } from '@db'; +import { BackgroundCheckStatus, Departments } from '@db'; + +export class BackgroundCheckSummaryDto { + @ApiProperty({ + description: 'Background check request ID', + example: 'bcr_abc123def456', + }) + id: string; + + @ApiProperty({ + description: 'Background check status', + enum: BackgroundCheckStatus, + example: BackgroundCheckStatus.invited, + }) + status: BackgroundCheckStatus; + + requesterNotes?: string | null; +} export class UserResponseDto { @ApiProperty({ @@ -130,4 +147,11 @@ export class PeopleResponseDto { type: UserResponseDto, }) user: UserResponseDto; + + @ApiProperty({ + description: 'Background check requests for this member', + type: [BackgroundCheckSummaryDto], + required: false, + }) + backgroundCheckRequests?: BackgroundCheckSummaryDto[]; } diff --git a/apps/api/src/people/utils/member-queries.ts b/apps/api/src/people/utils/member-queries.ts index 3e789f0b2b..92ab728914 100644 --- a/apps/api/src/people/utils/member-queries.ts +++ b/apps/api/src/people/utils/member-queries.ts @@ -34,6 +34,15 @@ export class MemberQueries { role: true, }, }, + backgroundCheckRequests: { + select: { + id: true, + status: true, + requesterNotes: true, + }, + take: 1, + orderBy: { createdAt: 'desc' }, + }, } as const; /** diff --git a/apps/app/.env.example b/apps/app/.env.example index 63af5301c2..7d22b4924e 100644 --- a/apps/app/.env.example +++ b/apps/app/.env.example @@ -75,3 +75,6 @@ STRIPE_SECRET_KEY= # Stripe secret key (sk_live_... or sk_test_...) STRIPE_PENTEST_SUBSCRIPTION_PRICE_ID= # Monthly subscription price ID (price_...) STRIPE_PENTEST_OVERAGE_PRICE_ID= # Per-run overage price ID (price_...) STRIPE_PENTEST_WEBHOOK_SECRET= # Webhook signing secret (whsec_...) from Stripe dashboard or CLI + +# Background Check Billing +STRIPE_BACKGROUND_CHECK_PRICE_ID=price_1TRWckCkFWhKYvHIA1GLv1sO diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/background-check/page.tsx b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/background-check/page.tsx new file mode 100644 index 0000000000..b961279fb7 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/background-check/page.tsx @@ -0,0 +1,94 @@ +import { auth } from '@/utils/auth'; +import { serverApi } from '@/lib/server-api-client'; +import type { Member, User } from '@db'; +import { db } from '@db/server'; +import { + PageHeader, + PageLayout, +} from '@trycompai/design-system'; +import type { Metadata } from 'next'; +import { headers } from 'next/headers'; +import { notFound, redirect } from 'next/navigation'; +import { + EmployeeBackgroundCheck, + type BackgroundCheckBillingStatus, + type BackgroundCheckRecord, +} from '../components/EmployeeBackgroundCheck'; + +export default async function EmployeeBackgroundCheckPage({ + params, +}: { + params: Promise<{ employeeId: string; orgId: string }>; +}) { + const { employeeId, orgId } = await params; + const employee = await getEmployee({ employeeId, orgId }); + + if (!employee) { + notFound(); + } + + const [backgroundCheckRes, backgroundCheckBillingRes] = await Promise.all([ + serverApi.get(`/v1/people/${employeeId}/background-check`), + serverApi.get('/v1/background-check-billing/status'), + ]); + + return ( + + } + > + + + ); +} + +export async function generateMetadata(): Promise { + return { + title: 'Employee Background Check', + }; +} + +async function getEmployee({ + employeeId, + orgId, +}: { + employeeId: string; + orgId: string; +}): Promise<(Member & { user: User }) | null> { + const session = await auth.api.getSession({ + headers: await headers(), + }); + const organizationId = session?.session.activeOrganizationId; + + if (!organizationId || organizationId !== orgId) { + redirect('/'); + } + + return db.member.findFirst({ + where: { + id: employeeId, + organizationId, + }, + include: { + user: true, + }, + }); +} diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/BackgroundCheckDetailsForm.tsx b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/BackgroundCheckDetailsForm.tsx new file mode 100644 index 0000000000..513f37194f --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/BackgroundCheckDetailsForm.tsx @@ -0,0 +1,115 @@ +'use client'; + +import { + Button, + Grid, + Input, + Label, + Stack, + Text, + Textarea, +} from '@trycompai/design-system'; +import type { UseFormReturn } from 'react-hook-form'; +import { BillingCallout } from './BackgroundCheckWizardParts'; +import type { BackgroundCheckFormValues } from './backgroundCheckForm'; + +export function BackgroundCheckDetailsForm({ + canRequest, + form, + isOpeningBilling, + isRequesting, + billingSetupComplete, + hasPaymentMethod, + canGoBack, + onBack, + onSubmit, +}: { + canRequest: boolean; + form: UseFormReturn; + isOpeningBilling: boolean; + isRequesting: boolean; + billingSetupComplete: boolean; + hasPaymentMethod: boolean; + canGoBack: boolean; + onBack: () => void; + onSubmit: (values: BackgroundCheckFormValues) => Promise; +}) { + return ( +
+ + {billingSetupComplete && ( + + )} + + + + + {form.formState.errors.employeeName && ( + + {form.formState.errors.employeeName.message} + + )} + + + + + {form.formState.errors.employeeEmail && ( + + {form.formState.errors.employeeEmail.message} + + )} + + + + +