diff --git a/apps/api/src/cloud-security/cloud-security-query.service.spec.ts b/apps/api/src/cloud-security/cloud-security-query.service.spec.ts new file mode 100644 index 000000000..6dcba2828 --- /dev/null +++ b/apps/api/src/cloud-security/cloud-security-query.service.spec.ts @@ -0,0 +1,247 @@ +// Regression tests for CS-702: the automated daily cloud scan appeared to +// return far fewer results than a manual scan. Root cause was in the +// latest-run lookups here — they picked the newest IntegrationCheckRun per +// connection with NO checkId scope, so the ~06:00 per-task evidence runs (a +// handful of results each) shadowed the ~05:00 full cloud-security scan +// (hundreds of results). These tests reproduce that exact ordering via a +// Prisma mock that emulates where + orderBy + distinct. + +interface RunRow { + id: string; + connectionId: string; + checkId: string; + taskId: string | null; + status: string; + completedAt: Date; + durationMs: number | null; + totalChecked: number | null; + passedCount: number | null; + failedCount: number | null; +} + +interface ResultRow { + id: string; + title: string; + description: string | null; + remediation: string | null; + severity: string | null; + collectedAt: Date; + checkRunId: string; + passed: boolean; + evidence: Record | null; + resourceId: string | null; + resourceType: string | null; +} + +// In-memory fixtures the mocked Prisma layer reads from. Set per-test. +let runRows: RunRow[] = []; +let resultRows: ResultRow[] = []; + +// Emulate the slice of Prisma semantics these queries rely on: filter by the +// provided `where`, order by completedAt desc, then distinct-by-connectionId +// (Postgres DISTINCT ON keeps the first row after ordering). +function checkRunFindMany(args: { + where: { + connectionId?: { in: string[] }; + checkId?: { in: string[] }; + status?: { in: string[] }; + }; + distinct?: string[]; +}): Promise { + const { where } = args; + let rows = runRows.filter((r) => { + if (where.connectionId && !where.connectionId.in.includes(r.connectionId)) { + return false; + } + if (where.checkId && !where.checkId.in.includes(r.checkId)) return false; + if (where.status && !where.status.in.includes(r.status)) return false; + return true; + }); + rows = [...rows].sort( + (a, b) => b.completedAt.getTime() - a.completedAt.getTime(), + ); + if (args.distinct?.includes('connectionId')) { + const seen = new Set(); + rows = rows.filter((r) => { + if (seen.has(r.connectionId)) return false; + seen.add(r.connectionId); + return true; + }); + } + return Promise.resolve(rows); +} + +function checkResultFindMany(args: { + where: { checkRunId: { in: string[] } }; +}): Promise { + const ids = args.where.checkRunId.in; + return Promise.resolve(resultRows.filter((r) => ids.includes(r.checkRunId))); +} + +const dbMock = { + integrationConnection: { findMany: jest.fn() }, + integration: { findMany: jest.fn() }, + integrationCheckRun: { findMany: jest.fn(checkRunFindMany) }, + integrationCheckResult: { findMany: jest.fn(checkResultFindMany) }, +}; + +jest.mock('@db', () => ({ db: dbMock })); +jest.mock('@trycompai/integration-platform', () => ({ getManifest: jest.fn() })); +jest.mock('./evidence-sanitizer', () => ({ + sanitizeEvidence: (v: unknown) => v, +})); +jest.mock('./check-definition.utils', () => ({ + resolveCheckKey: jest.fn(() => 'check-key'), +})); +jest.mock('./cloud-security-query.legacy', () => ({ + getLegacyFindings: jest.fn().mockResolvedValue([]), +})); +jest.mock('./finding-exceptions', () => ({ + loadActiveExceptionSet: jest.fn().mockResolvedValue({ size: 0 }), +})); + +import { getManifest } from '@trycompai/integration-platform'; +import { CloudSecurityQueryService } from './cloud-security-query.service'; + +const ORG_ID = 'org_test'; +const CONNECTION_ID = 'icn_aws'; +const SCAN_AT = new Date('2026-07-02T05:00:00Z'); // full scan +const TASK_AT = new Date('2026-07-02T06:00:00Z'); // per-task run — NEWER + +const awsConnection = { + id: CONNECTION_ID, + organizationId: ORG_ID, + provider: { slug: 'aws', name: 'AWS' }, + metadata: {}, + variables: {}, + status: 'active', + lastSyncAt: TASK_AT, + createdAt: SCAN_AT, + updatedAt: TASK_AT, +}; + +// The 05:00 full scan: many results, taskId null, checkId 'aws-security-scan'. +const scanRun: RunRow = { + id: 'run_scan', + connectionId: CONNECTION_ID, + checkId: 'aws-security-scan', + taskId: null, + status: 'success', + completedAt: SCAN_AT, + durationMs: 5000, + totalChecked: 300, + passedCount: 250, + failedCount: 50, +}; + +// The 06:00 per-task evidence run: a handful of results, taskId set, checkId +// is a manifest check id. This is the run that used to shadow the scan. +const taskRun: RunRow = { + id: 'run_task', + connectionId: CONNECTION_ID, + checkId: 'aws-iam-account-security', + taskId: 'tsk_iam', + status: 'failed', + completedAt: TASK_AT, + durationMs: 120, + totalChecked: 3, + passedCount: 1, + failedCount: 2, +}; + +function scanResult(id: string): ResultRow { + return { + id, + title: `scan-${id}`, + description: null, + remediation: null, + severity: 'medium', + collectedAt: SCAN_AT, + checkRunId: 'run_scan', + passed: false, + evidence: {}, + resourceId: `res-${id}`, + resourceType: 'AwsResource', + }; +} + +const taskResult: ResultRow = { + id: 'task_only', + title: 'task-only', + description: null, + remediation: null, + severity: 'high', + collectedAt: TASK_AT, + checkRunId: 'run_task', + passed: false, + evidence: {}, + resourceId: 'res-task', + resourceType: 'AwsResource', +}; + +describe('CloudSecurityQueryService — latest-run scoping (CS-702)', () => { + let service: CloudSecurityQueryService; + + beforeEach(() => { + jest.clearAllMocks(); + service = new CloudSecurityQueryService(); + + // Both runs exist for the same connection; the per-task run is newer. + runRows = [scanRun, taskRun]; + resultRows = [ + scanResult('a'), + scanResult('b'), + scanResult('c'), + taskResult, + ]; + + dbMock.integrationConnection.findMany.mockResolvedValue([awsConnection]); + dbMock.integration.findMany.mockResolvedValue([]); + dbMock.integrationCheckRun.findMany.mockImplementation(checkRunFindMany); + dbMock.integrationCheckResult.findMany.mockImplementation( + checkResultFindMany, + ); + (getManifest as jest.Mock).mockReturnValue({ + supportsMultipleConnections: true, + variables: [], + checks: [], + }); + }); + + it('getFindings returns the full-scan results, not the newer per-task run', async () => { + const findings = await service.getFindings(ORG_ID); + + // The three scan findings — and none from the per-task run. + expect(findings).toHaveLength(3); + expect(findings.map((f) => f.id).sort()).toEqual(['a', 'b', 'c']); + expect(findings.every((f) => f.checkId === 'aws-security-scan')).toBe(true); + expect(findings.some((f) => f.id === 'task_only')).toBe(false); + }); + + it('getProviders reports the full-scan summary, not the per-task run summary', async () => { + const providers = await service.getProviders(ORG_ID); + + const aws = providers.find((p) => p.id === CONNECTION_ID); + expect(aws?.latestRun).toEqual( + expect.objectContaining({ + totalChecked: 300, + passedCount: 250, + failedCount: 50, + }), + ); + }); + + it('scopes the latest-run lookup to cloud-security scan checkIds', async () => { + await service.getFindings(ORG_ID); + + expect(dbMock.integrationCheckRun.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + checkId: { + in: ['aws-security-scan', 'gcp-security-scan', 'azure-security-scan'], + }, + }), + }), + ); + }); +}); diff --git a/apps/api/src/cloud-security/cloud-security-query.service.ts b/apps/api/src/cloud-security/cloud-security-query.service.ts index 0fe5241f5..e409ae7dd 100644 --- a/apps/api/src/cloud-security/cloud-security-query.service.ts +++ b/apps/api/src/cloud-security/cloud-security-query.service.ts @@ -17,6 +17,19 @@ export type { CloudFinding, CloudProvider, CloudProviderLatestRun }; const CLOUD_PROVIDER_SLUGS = ['aws', 'gcp', 'azure'] as const; +// The cloud-security scan persists exactly one run per connection under this +// coarse, run-level checkId (`storeFindings` in cloud-security.service.ts). The +// SAME connection also accumulates OTHER IntegrationCheckRun rows on different +// schedules — per-task evidence checks (checkId = manifest check id, taskId set, +// written ~06:00 UTC) and the on-connect "All Checks (Auto)" run (checkId +// 'all'), each holding only a handful of results. The latest-run lookups below +// MUST scope to these scan runs; otherwise a later per-task run shadows the +// full daily scan and the Cloud Tests dashboard shows a fraction of the +// findings (CS-702). +const CLOUD_SCAN_CHECK_IDS = CLOUD_PROVIDER_SLUGS.map( + (slug) => `${slug}-security-scan`, +); + /** Extract project ID from a GCP resource path like //iam.googleapis.com/projects/my-proj/... */ function extractProjectIdFromResource( resourceId: string | null, @@ -222,6 +235,9 @@ export class CloudSecurityQueryService { const runs = await db.integrationCheckRun.findMany({ where: { connectionId: { in: connectionIds }, + // Only the full cloud-security scan run — not later per-task / 'all' + // runs on the same connection (see CLOUD_SCAN_CHECK_IDS). + checkId: { in: CLOUD_SCAN_CHECK_IDS }, status: { in: ['success', 'failed'] }, }, orderBy: { completedAt: 'desc' }, @@ -285,6 +301,9 @@ export class CloudSecurityQueryService { const latestRuns = await db.integrationCheckRun.findMany({ where: { connectionId: { in: connectionIds }, + // Only the full cloud-security scan run — not later per-task / 'all' + // runs on the same connection (see CLOUD_SCAN_CHECK_IDS). + checkId: { in: CLOUD_SCAN_CHECK_IDS }, status: { in: ['success', 'failed'] }, }, orderBy: { completedAt: 'desc' }, diff --git a/apps/api/src/email/templates/access-reclaim.tsx b/apps/api/src/email/templates/access-reclaim.tsx index 4a42d7bda..b614a5a06 100644 --- a/apps/api/src/email/templates/access-reclaim.tsx +++ b/apps/api/src/email/templates/access-reclaim.tsx @@ -88,7 +88,7 @@ export const AccessReclaimEmail = ({
- This link will expire in 24 hours. Your grant expires on:{' '} + This link will remain valid until your access expires on:{' '} {expiresAt.toLocaleDateString('en-US', { year: 'numeric', diff --git a/apps/api/src/people/utils/member-queries.ts b/apps/api/src/people/utils/member-queries.ts index abf56d24f..19f488359 100644 --- a/apps/api/src/people/utils/member-queries.ts +++ b/apps/api/src/people/utils/member-queries.ts @@ -66,7 +66,7 @@ export class MemberQueries { return db.member.findMany({ where: { organizationId, - ...(includeDeactivated ? {} : { deactivated: false }), + ...(includeDeactivated ? {} : { deactivated: false, isActive: true }), ...(filters?.onboardAfter || filters?.onboardBefore ? { onboardDate: { diff --git a/apps/api/src/trust-portal/dto/update-security-questionnaire.dto.ts b/apps/api/src/trust-portal/dto/update-security-questionnaire.dto.ts new file mode 100644 index 000000000..f36d70e29 --- /dev/null +++ b/apps/api/src/trust-portal/dto/update-security-questionnaire.dto.ts @@ -0,0 +1,9 @@ +import { z } from 'zod'; + +export const UpdateSecurityQuestionnaireSchema = z.object({ + enabled: z.boolean(), +}); + +export type UpdateSecurityQuestionnaireDto = z.infer< + typeof UpdateSecurityQuestionnaireSchema +>; diff --git a/apps/api/src/trust-portal/trust-access.controller.ts b/apps/api/src/trust-portal/trust-access.controller.ts index 05dc1358c..8db04c19b 100644 --- a/apps/api/src/trust-portal/trust-access.controller.ts +++ b/apps/api/src/trust-portal/trust-access.controller.ts @@ -680,6 +680,37 @@ export class TrustAccessController { return this.trustAccessService.getPublicOverview(friendlyUrl); } + @Get(':friendlyUrl/security-questionnaire') + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: 'Get Security Questionnaire visibility for a trust portal', + description: + "Whether the org offers the AI-assisted Security Questionnaire on its public trust portal. Defaults to enabled when the portal can't be resolved.", + }) + @ApiParam({ + name: 'friendlyUrl', + description: 'Trust Portal friendly URL or Organization ID', + }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Security Questionnaire visibility retrieved successfully', + schema: { + type: 'object', + properties: { + enabled: { type: 'boolean' }, + }, + }, + }) + async getPublicSecurityQuestionnaire( + @Param('friendlyUrl') friendlyUrl: string, + ) { + const enabled = + await this.trustAccessService.getPublicSecurityQuestionnaireEnabled( + friendlyUrl, + ); + return { enabled }; + } + @Get(':friendlyUrl/custom-links') @HttpCode(HttpStatus.OK) @ApiOperation({ diff --git a/apps/api/src/trust-portal/trust-access.security-questionnaire.service.spec.ts b/apps/api/src/trust-portal/trust-access.security-questionnaire.service.spec.ts new file mode 100644 index 000000000..dbd5d7590 --- /dev/null +++ b/apps/api/src/trust-portal/trust-access.security-questionnaire.service.spec.ts @@ -0,0 +1,84 @@ +import { db } from '@db'; +import { TrustAccessService } from './trust-access.service'; + +jest.mock('@db', () => ({ + db: { + trust: { + findUnique: jest.fn(), + }, + }, + Prisma: {}, + TrustFramework: {}, +})); + +jest.mock('../app/s3', () => ({ + APP_AWS_ORG_ASSETS_BUCKET: 'org-assets', + s3Client: { send: jest.fn() }, + getSignedUrl: jest.fn(), +})); + +const mockDb = db as unknown as { + trust: { findUnique: jest.Mock }; +}; + +describe('TrustAccessService.getPublicSecurityQuestionnaireEnabled', () => { + const service = new TrustAccessService( + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + ); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('resolves by friendlyUrl first', async () => { + mockDb.trust.findUnique.mockResolvedValue({ + securityQuestionnaireEnabled: false, + }); + + const result = await service.getPublicSecurityQuestionnaireEnabled('acme'); + + expect(result).toBe(false); + expect(mockDb.trust.findUnique).toHaveBeenCalledTimes(1); + expect(mockDb.trust.findUnique).toHaveBeenNthCalledWith(1, { + where: { friendlyUrl: 'acme' }, + select: { securityQuestionnaireEnabled: true }, + }); + }); + + it('falls back to organizationId when friendlyUrl does not match', async () => { + mockDb.trust.findUnique + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ securityQuestionnaireEnabled: false }); + + const result = + await service.getPublicSecurityQuestionnaireEnabled('org_123'); + + expect(result).toBe(false); + expect(mockDb.trust.findUnique).toHaveBeenNthCalledWith(2, { + where: { organizationId: 'org_123' }, + select: { securityQuestionnaireEnabled: true }, + }); + }); + + it('returns true when the flag is enabled', async () => { + mockDb.trust.findUnique.mockResolvedValue({ + securityQuestionnaireEnabled: true, + }); + + await expect( + service.getPublicSecurityQuestionnaireEnabled('acme'), + ).resolves.toBe(true); + }); + + it('defaults to enabled when the portal cannot be resolved', async () => { + mockDb.trust.findUnique.mockResolvedValue(null); + + await expect( + service.getPublicSecurityQuestionnaireEnabled('unknown'), + ).resolves.toBe(true); + }); +}); diff --git a/apps/api/src/trust-portal/trust-access.service.spec.ts b/apps/api/src/trust-portal/trust-access.service.spec.ts index 544380748..e0bb9604c 100644 --- a/apps/api/src/trust-portal/trust-access.service.spec.ts +++ b/apps/api/src/trust-portal/trust-access.service.spec.ts @@ -380,6 +380,23 @@ describe('TrustAccessService resendAccessGrantEmail NDA copy', () => { expect.objectContaining({ ndaBypassed: false }), ); }); + + it('rotates an expired token to expire with the grant, not a fixed 24h window', async () => { + const grantExpiresAt = new Date(Date.now() + 10 * 24 * 60 * 60 * 1000); + mockDb.trustAccessGrant.findFirst.mockResolvedValue({ + ...baseGrant, + expiresAt: grantExpiresAt, + accessTokenExpiresAt: new Date(Date.now() - 1000), + ndaAgreement: null, + }); + + await service.resendAccessGrantEmail('org_1', 'tag_1'); + + expect(mockDb.trustAccessGrant.update).toHaveBeenCalledWith({ + where: { id: 'tag_1' }, + data: expect.objectContaining({ accessTokenExpiresAt: grantExpiresAt }), + }); + }); }); describe('TrustAccessService signNda NDA copy', () => { @@ -447,6 +464,55 @@ describe('TrustAccessService signNda NDA copy', () => { }); }); +describe('TrustAccessService reclaimAccess token rotation', () => { + const emailService = { + sendAccessReclaimEmail: jest.fn(), + }; + const service = new TrustAccessService( + {} as any, + emailService as any, + {} as any, + {} as any, + {} as any, + ); + jest + .spyOn(service as any, 'buildPortalAccessUrl') + .mockResolvedValue('https://portal.example.com/access/token'); + + beforeEach(() => { + jest.clearAllMocks(); + mockDb.trust.findUnique.mockResolvedValue({ + organizationId: 'org_1', + friendlyUrl: 'acme-security', + status: 'published', + }); + }); + + it('rotates an expired access token to expire with the grant, not a fixed 24h window', async () => { + const grantExpiresAt = new Date(Date.now() + 20 * 24 * 60 * 60 * 1000); + mockDb.trustAccessGrant.findFirst.mockResolvedValue({ + id: 'tag_1', + subjectEmail: 'chang.liu@client.com', + status: 'active', + expiresAt: grantExpiresAt, + accessToken: 'stale-token', + accessTokenExpiresAt: new Date(Date.now() - 1000), + accessRequest: { + name: 'Chang Liu', + organization: { name: 'Acme Security' }, + }, + ndaAgreement: null, + }); + + await service.reclaimAccess('acme-security', 'chang.liu@client.com'); + + expect(mockDb.trustAccessGrant.update).toHaveBeenCalledWith({ + where: { id: 'tag_1' }, + data: expect.objectContaining({ accessTokenExpiresAt: grantExpiresAt }), + }); + }); +}); + describe('TrustAccessService access request notification', () => { const emailService = { sendAccessRequestNotification: jest.fn(), diff --git a/apps/api/src/trust-portal/trust-access.service.ts b/apps/api/src/trust-portal/trust-access.service.ts index 899aad5c8..0ada81b0e 100644 --- a/apps/api/src/trust-portal/trust-access.service.ts +++ b/apps/api/src/trust-portal/trust-access.service.ts @@ -305,8 +305,9 @@ export class TrustAccessService { accessTokenExpiresAt < new Date() ) { accessToken = this.generateToken(32); - accessTokenExpiresAt = new Date(); - accessTokenExpiresAt.setHours(accessTokenExpiresAt.getHours() + 24); + // Mirror the grant's own expiry rather than a fixed window, so the + // emailed link stays valid for the whole approved duration. + accessTokenExpiresAt = existingGrant.expiresAt; await db.trustAccessGrant.update({ where: { id: existingGrant.id }, @@ -706,8 +707,9 @@ export class TrustAccessService { expiresAt.setDate(expiresAt.getDate() + durationDays); const accessToken = this.generateToken(32); - const accessTokenExpiresAt = new Date(); - accessTokenExpiresAt.setHours(accessTokenExpiresAt.getHours() + 24); + // Mirror the grant's own expiry rather than a fixed window, so the + // emailed link stays valid for the whole approved duration. + const accessTokenExpiresAt = expiresAt; const result = await db.$transaction(async (tx) => { const updatedRequest = await tx.trustAccessRequest.update({ @@ -1011,9 +1013,9 @@ export class TrustAccessService { (grant.accessTokenExpiresAt && grant.accessTokenExpiresAt < now) ) { accessToken = this.generateToken(32); - const accessTokenExpiresAt = new Date( - now.getTime() + 24 * 60 * 60 * 1000, - ); + // Mirror the grant's own expiry rather than a fixed window, so the + // emailed link stays valid for the whole approved duration. + const accessTokenExpiresAt = grant.expiresAt; await db.trustAccessGrant.update({ where: { id: grantId }, @@ -1156,9 +1158,10 @@ export class TrustAccessService { : null; const accessToken = nda.grant.accessToken || this.generateToken(32); + // Mirror the grant's own expiry rather than a fixed window, so the + // emailed link stays valid for the whole approved duration. const accessTokenExpiresAt = - nda.grant.accessTokenExpiresAt || - new Date(Date.now() + 24 * 60 * 60 * 1000); + nda.grant.accessTokenExpiresAt || nda.grant.expiresAt; if (!nda.grant.accessToken) { await db.trustAccessGrant.update({ @@ -1204,8 +1207,9 @@ export class TrustAccessService { expiresAt.setDate(expiresAt.getDate() + durationDays); const accessToken = this.generateToken(32); - const accessTokenExpiresAt = new Date(); - accessTokenExpiresAt.setHours(accessTokenExpiresAt.getHours() + 24); + // Mirror the grant's own expiry rather than a fixed window, so the + // emailed link stays valid for the whole approved duration. + const accessTokenExpiresAt = expiresAt; const result = await db.$transaction(async (tx) => { const grant = await tx.trustAccessGrant.create({ @@ -1442,8 +1446,9 @@ export class TrustAccessService { accessTokenExpiresAt < new Date() ) { accessToken = this.generateToken(32); - accessTokenExpiresAt = new Date(); - accessTokenExpiresAt.setHours(accessTokenExpiresAt.getHours() + 24); + // Mirror the grant's own expiry rather than a fixed window, so the + // emailed link stays valid for the whole approved duration. + accessTokenExpiresAt = grant.expiresAt; await db.trustAccessGrant.update({ where: { id: grant.id }, @@ -1524,6 +1529,7 @@ export class TrustAccessService { organizationName: grant.accessRequest.organization.name, friendlyUrl: branding.friendlyUrl, faviconUrl: branding.faviconUrl, + securityQuestionnaireEnabled: branding.securityQuestionnaireEnabled, expiresAt: grant.expiresAt, subjectEmail: grant.subjectEmail, ndaPdfUrl, @@ -1532,10 +1538,18 @@ export class TrustAccessService { private async getTrustBrandingByOrganizationId( organizationId: string, - ): Promise<{ friendlyUrl: string; faviconUrl: string | null }> { + ): Promise<{ + friendlyUrl: string; + faviconUrl: string | null; + securityQuestionnaireEnabled: boolean; + }> { const trust = await db.trust.findUnique({ where: { organizationId }, - select: { friendlyUrl: true, favicon: true }, + select: { + friendlyUrl: true, + favicon: true, + securityQuestionnaireEnabled: true, + }, }); const friendlyUrl = trust?.friendlyUrl ?? organizationId; @@ -1543,7 +1557,11 @@ export class TrustAccessService { ? await this.getFaviconSignedUrl(trust.favicon) : null; - return { friendlyUrl, faviconUrl }; + return { + friendlyUrl, + faviconUrl, + securityQuestionnaireEnabled: trust?.securityQuestionnaireEnabled ?? true, + }; } private async getFaviconSignedUrl( @@ -2713,6 +2731,22 @@ export class TrustAccessService { }; } + /** + * Whether the public trust portal should offer the AI-assisted Security + * Questionnaire. The public portal passes either the friendly URL or the + * organization ID, so we resolve on both. Defaults to enabled when the portal + * can't be resolved so the questionnaire never disappears by accident. + */ + async getPublicSecurityQuestionnaireEnabled( + friendlyUrl: string, + ): Promise { + const trust = await this.resolveTrustByFriendlyUrl(friendlyUrl, { + securityQuestionnaireEnabled: true, + }); + + return trust?.securityQuestionnaireEnabled ?? true; + } + async getPublicCustomLinks(friendlyUrl: string) { const trust = await db.trust.findUnique({ where: { friendlyUrl }, @@ -2738,19 +2772,30 @@ export class TrustAccessService { }); } + /** + * Resolve a Trust by friendlyUrl, falling back to organizationId — the public + * portal passes either. Two findUnique calls on unique columns give explicit + * precedence (friendlyUrl wins), so an org whose friendlyUrl happens to equal + * another org's id can't shadow it. Shared by the public read endpoints. + */ + private async resolveTrustByFriendlyUrl( + friendlyUrl: string, + select: S, + ): Promise | null> { + return ( + (await db.trust.findUnique({ where: { friendlyUrl }, select })) ?? + (await db.trust.findUnique({ + where: { organizationId: friendlyUrl }, + select, + })) + ); + } + async getPublicFavicon(friendlyUrl: string): Promise { - let trust = await db.trust.findUnique({ - where: { friendlyUrl }, - select: { favicon: true }, + const trust = await this.resolveTrustByFriendlyUrl(friendlyUrl, { + favicon: true, }); - if (!trust) { - trust = await db.trust.findUnique({ - where: { organizationId: friendlyUrl }, - select: { favicon: true }, - }); - } - if (!trust?.favicon) { return null; } diff --git a/apps/api/src/trust-portal/trust-portal.controller.ts b/apps/api/src/trust-portal/trust-portal.controller.ts index 40747e7de..1f9d8cd87 100644 --- a/apps/api/src/trust-portal/trust-portal.controller.ts +++ b/apps/api/src/trust-portal/trust-portal.controller.ts @@ -56,6 +56,8 @@ import { } from './dto/trust-custom-link.dto'; import type { UpdateTrustOverviewDto } from './dto/update-trust-overview.dto'; import { UpdateTrustOverviewSchema } from './dto/update-trust-overview.dto'; +import type { UpdateSecurityQuestionnaireDto } from './dto/update-security-questionnaire.dto'; +import { UpdateSecurityQuestionnaireSchema } from './dto/update-security-questionnaire.dto'; import { UpdateAllowedEmailsDto } from './dto/update-allowed-emails.dto'; import type { UpdateVendorTrustSettingsDto } from './dto/trust-vendor.dto'; import { UpdateVendorTrustSettingsSchema } from './dto/trust-vendor.dto'; @@ -420,6 +422,35 @@ export class TrustPortalController { return this.trustPortalService.updateFrameworks(organizationId, body); } + @Put('settings/security-questionnaire') + @RequirePermission('trust', 'update') + @ApiOperation({ + summary: 'Show or hide the Security Questionnaire on the public trust portal', + }) + @ApiBody({ + schema: { + type: 'object', + required: ['enabled'], + properties: { + enabled: { + type: 'boolean', + description: + 'When false, the Security Questionnaire is hidden from the public trust portal.', + }, + }, + }, + }) + async updateSecurityQuestionnaire( + @OrganizationId() organizationId: string, + @Body(new ZodValidationPipe(UpdateSecurityQuestionnaireSchema)) + body: UpdateSecurityQuestionnaireDto, + ) { + return this.trustPortalService.updateSecurityQuestionnaireEnabled( + organizationId, + body.enabled, + ); + } + @Get('custom-frameworks') @HttpCode(HttpStatus.OK) @RequirePermission('trust', 'read') diff --git a/apps/api/src/trust-portal/trust-portal.security-questionnaire.service.spec.ts b/apps/api/src/trust-portal/trust-portal.security-questionnaire.service.spec.ts new file mode 100644 index 000000000..1202862cd --- /dev/null +++ b/apps/api/src/trust-portal/trust-portal.security-questionnaire.service.spec.ts @@ -0,0 +1,68 @@ +import { NotFoundException } from '@nestjs/common'; +import { db } from '@db'; +import { TrustPortalService } from './trust-portal.service'; + +jest.mock('@db', () => ({ + db: { + trust: { + findUnique: jest.fn(), + update: jest.fn(), + }, + }, + Prisma: {}, + TrustFramework: {}, +})); + +jest.mock('../app/s3', () => ({ + APP_AWS_ORG_ASSETS_BUCKET: 'org-assets', + s3Client: { send: jest.fn() }, + getSignedUrl: jest.fn(), +})); + +const mockDb = db as unknown as { + trust: { findUnique: jest.Mock; update: jest.Mock }; +}; + +describe('TrustPortalService.updateSecurityQuestionnaireEnabled', () => { + const service = new TrustPortalService(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('persists the flag when the trust portal exists', async () => { + mockDb.trust.findUnique.mockResolvedValue({ organizationId: 'org_1' }); + mockDb.trust.update.mockResolvedValue({ + organizationId: 'org_1', + securityQuestionnaireEnabled: false, + }); + + await service.updateSecurityQuestionnaireEnabled('org_1', false); + + expect(mockDb.trust.update).toHaveBeenCalledWith({ + where: { organizationId: 'org_1' }, + data: { securityQuestionnaireEnabled: false }, + }); + }); + + it('re-enables the questionnaire when set to true', async () => { + mockDb.trust.findUnique.mockResolvedValue({ organizationId: 'org_1' }); + mockDb.trust.update.mockResolvedValue({}); + + await service.updateSecurityQuestionnaireEnabled('org_1', true); + + expect(mockDb.trust.update).toHaveBeenCalledWith({ + where: { organizationId: 'org_1' }, + data: { securityQuestionnaireEnabled: true }, + }); + }); + + it('throws NotFound and does not write when the trust portal is missing', async () => { + mockDb.trust.findUnique.mockResolvedValue(null); + + await expect( + service.updateSecurityQuestionnaireEnabled('org_missing', false), + ).rejects.toBeInstanceOf(NotFoundException); + expect(mockDb.trust.update).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/trust-portal/trust-portal.service.ts b/apps/api/src/trust-portal/trust-portal.service.ts index 00d9a05fe..4ef2b358c 100644 --- a/apps/api/src/trust-portal/trust-portal.service.ts +++ b/apps/api/src/trust-portal/trust-portal.service.ts @@ -1475,6 +1475,24 @@ export class TrustPortalService { }); } + async updateSecurityQuestionnaireEnabled( + organizationId: string, + enabled: boolean, + ) { + const trust = await db.trust.findUnique({ + where: { organizationId }, + }); + + if (!trust) { + throw new NotFoundException('Trust portal not found'); + } + + return db.trust.update({ + where: { organizationId }, + data: { securityQuestionnaireEnabled: enabled }, + }); + } + async getOverview(organizationId: string) { const trust = await db.trust.findUnique({ where: { organizationId }, @@ -1730,6 +1748,8 @@ export class TrustPortalService { overviewTitle: trust.overviewTitle ?? null, overviewContent: trust.overviewContent ?? defaultOverviewContent, showOverview: trust.showOverview ?? false, + // Security questionnaire visibility on the public portal + securityQuestionnaireEnabled: trust.securityQuestionnaireEnabled ?? true, // Favicon faviconUrl, // Organization data diff --git a/apps/app/src/app/(app)/[orgId]/trust/page.tsx b/apps/app/src/app/(app)/[orgId]/trust/page.tsx index aba9167cc..60a682171 100644 --- a/apps/app/src/app/(app)/[orgId]/trust/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/trust/page.tsx @@ -158,6 +158,7 @@ export default async function TrustPage({ params }: { params: Promise<{ orgId: s vendors={vendors} customFrameworks={customFrameworks} faviconUrl={settings?.faviconUrl ?? null} + securityQuestionnaireEnabled={settings?.securityQuestionnaireEnabled ?? true} /> ); diff --git a/apps/app/src/app/(app)/[orgId]/trust/portal-settings/components/TrustPortalQuestionnaire.test.tsx b/apps/app/src/app/(app)/[orgId]/trust/portal-settings/components/TrustPortalQuestionnaire.test.tsx new file mode 100644 index 000000000..2ff962895 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/trust/portal-settings/components/TrustPortalQuestionnaire.test.tsx @@ -0,0 +1,106 @@ +import { + ADMIN_PERMISSIONS, + AUDITOR_PERMISSIONS, + mockHasPermission, + setMockPermissions, +} from '@/test-utils/mocks/permissions'; +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { mockUpdate } = vi.hoisted(() => ({ mockUpdate: vi.fn() })); + +vi.mock('@/hooks/use-permissions', () => ({ + usePermissions: () => ({ + permissions: {}, + hasPermission: mockHasPermission, + }), +})); + +vi.mock('@/hooks/use-trust-portal-settings', () => ({ + useTrustPortalSettings: () => ({ + updateSecurityQuestionnaireEnabled: mockUpdate, + }), +})); + +vi.mock('sonner', () => ({ + toast: { success: vi.fn(), error: vi.fn() }, +})); + +vi.mock('@trycompai/design-system/icons', () => ({ + View: () => , + ViewOff: () => , +})); + +import { TrustPortalQuestionnaire } from './TrustPortalQuestionnaire'; + +describe('TrustPortalQuestionnaire', () => { + const defaultProps = { initialEnabled: true, orgId: 'org-1' }; + + beforeEach(() => { + vi.clearAllMocks(); + mockUpdate.mockResolvedValue(undefined); + }); + + it('renders explanatory copy regardless of permissions', () => { + setMockPermissions({}); + render(); + expect(screen.getByText(/receive AI-assisted answers/i)).toBeInTheDocument(); + }); + + it('enables the toggle buttons when the user has trust:update', () => { + setMockPermissions(ADMIN_PERMISSIONS); + render(); + expect(screen.getByText('Visible').closest('button')).not.toBeDisabled(); + expect(screen.getByText('Hidden').closest('button')).not.toBeDisabled(); + }); + + it('disables the toggle buttons when the user lacks trust:update', () => { + setMockPermissions(AUDITOR_PERMISSIONS); + render(); + expect(screen.getByText('Visible').closest('button')).toBeDisabled(); + expect(screen.getByText('Hidden').closest('button')).toBeDisabled(); + }); + + it('saves enabled=false when an admin clicks Hidden', async () => { + setMockPermissions(ADMIN_PERMISSIONS); + render(); + await act(async () => { + fireEvent.click(screen.getByText('Hidden')); + }); + expect(mockUpdate).toHaveBeenCalledWith(false); + }); + + it('does not call the API when a read-only user clicks Hidden', async () => { + setMockPermissions(AUDITOR_PERMISSIONS); + render(); + await act(async () => { + fireEvent.click(screen.getByText('Hidden')); + }); + expect(mockUpdate).not.toHaveBeenCalled(); + }); + + it('saves enabled=true when an admin re-enables a hidden questionnaire', async () => { + setMockPermissions(ADMIN_PERMISSIONS); + render(); + await act(async () => { + fireEvent.click(screen.getByText('Visible')); + }); + expect(mockUpdate).toHaveBeenCalledWith(true); + }); + + it('exposes the active state to assistive tech via aria-pressed', () => { + setMockPermissions(ADMIN_PERMISSIONS); + render(); + expect(screen.getByText('Visible').closest('button')).toHaveAttribute('aria-pressed', 'true'); + expect(screen.getByText('Hidden').closest('button')).toHaveAttribute('aria-pressed', 'false'); + }); + + it('resyncs local state when initialEnabled changes', () => { + setMockPermissions(ADMIN_PERMISSIONS); + const { rerender } = render(); + expect(screen.getByText('Visible').closest('button')).toHaveAttribute('aria-pressed', 'true'); + + rerender(); + expect(screen.getByText('Hidden').closest('button')).toHaveAttribute('aria-pressed', 'true'); + }); +}); diff --git a/apps/app/src/app/(app)/[orgId]/trust/portal-settings/components/TrustPortalQuestionnaire.tsx b/apps/app/src/app/(app)/[orgId]/trust/portal-settings/components/TrustPortalQuestionnaire.tsx new file mode 100644 index 000000000..7d578e547 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/trust/portal-settings/components/TrustPortalQuestionnaire.tsx @@ -0,0 +1,98 @@ +'use client'; + +import { usePermissions } from '@/hooks/use-permissions'; +import { useTrustPortalSettings } from '@/hooks/use-trust-portal-settings'; +import { View, ViewOff } from '@trycompai/design-system/icons'; +import { useEffect, useState } from 'react'; +import { toast } from 'sonner'; + +interface TrustPortalQuestionnaireProps { + initialEnabled: boolean; + orgId: string; +} + +export function TrustPortalQuestionnaire({ initialEnabled, orgId }: TrustPortalQuestionnaireProps) { + const { hasPermission } = usePermissions(); + const canUpdate = hasPermission('trust', 'update'); + const { updateSecurityQuestionnaireEnabled } = useTrustPortalSettings(); + const [enabled, setEnabled] = useState(initialEnabled); + const [isSaving, setIsSaving] = useState(false); + + // Resync when the server-provided value changes (e.g. parent refetch), so the + // toggle never reflects stale visibility. + useEffect(() => { + setEnabled(initialEnabled); + }, [initialEnabled]); + + const handleToggleChange = async (checked: boolean) => { + if (!canUpdate || checked === enabled || isSaving) return; + // Optimistically flip, revert if the save fails. + const previous = enabled; + setEnabled(checked); + setIsSaving(true); + try { + await updateSecurityQuestionnaireEnabled(checked); + toast.success( + checked + ? 'Security Questionnaire is now visible on your trust portal' + : 'Security Questionnaire is now hidden from your trust portal', + ); + } catch { + setEnabled(previous); + toast.error('Failed to update Security Questionnaire visibility'); + } finally { + setIsSaving(false); + } + }; + + return ( +
+ {/* Visibility Toggle */} +
+
+ + +
+
+ +
+

Security Questionnaire

+

+ When visible, visitors to your public trust portal can submit a security questionnaire and + receive AI-assisted answers drawn from your policy library. +

+

+ Hide it if you'd rather review answers before they reach customers — hiding removes the + questionnaire button from your trust portal and the questionnaire tab from the access + area, so questionnaires can no longer be started from the portal. +

+
+
+ ); +} diff --git a/apps/app/src/app/(app)/[orgId]/trust/portal-settings/components/TrustPortalSwitch.test.tsx b/apps/app/src/app/(app)/[orgId]/trust/portal-settings/components/TrustPortalSwitch.test.tsx index 90dd66695..a57597b2a 100644 --- a/apps/app/src/app/(app)/[orgId]/trust/portal-settings/components/TrustPortalSwitch.test.tsx +++ b/apps/app/src/app/(app)/[orgId]/trust/portal-settings/components/TrustPortalSwitch.test.tsx @@ -245,6 +245,7 @@ describe('TrustPortalSwitch permission gating', () => { customLinks: [], vendors: [], customFrameworks: [], + securityQuestionnaireEnabled: true, }; beforeEach(() => { diff --git a/apps/app/src/app/(app)/[orgId]/trust/portal-settings/components/TrustPortalSwitch.tsx b/apps/app/src/app/(app)/[orgId]/trust/portal-settings/components/TrustPortalSwitch.tsx index da3d045ad..8ad5bb815 100644 --- a/apps/app/src/app/(app)/[orgId]/trust/portal-settings/components/TrustPortalSwitch.tsx +++ b/apps/app/src/app/(app)/[orgId]/trust/portal-settings/components/TrustPortalSwitch.tsx @@ -21,6 +21,7 @@ import { TrustPortalBrandingSettings } from './TrustPortalBrandingSettings'; import { TrustPortalCustomLinks } from './TrustPortalCustomLinks'; import { TrustPortalFaqBuilder } from './TrustPortalFaqBuilder'; import { TrustPortalOverview } from './TrustPortalOverview'; +import { TrustPortalQuestionnaire } from './TrustPortalQuestionnaire'; import { TrustPortalVendors } from './TrustPortalVendors'; // Client-side form schema (includes all fields for form state) @@ -170,6 +171,7 @@ export function TrustPortalSwitch({ vendors, customFrameworks, faviconUrl, + securityQuestionnaireEnabled, }: { enabled: boolean; slug: string; @@ -221,6 +223,7 @@ export function TrustPortalSwitch({ vendors: TrustVendor[]; customFrameworks: TrustCustomFrameworkItem[]; faviconUrl?: string | null; + securityQuestionnaireEnabled: boolean; }) { const { hasPermission } = usePermissions(); const canUpdate = hasPermission('trust', 'update'); @@ -486,6 +489,7 @@ export function TrustPortalSwitch({ Links FAQ Documents + Questionnaire {/* Compliance Frameworks Tab */} @@ -1025,6 +1029,16 @@ export function TrustPortalSwitch({ /> + + {/* Security Questionnaire Tab */} + +
+ +
+
diff --git a/apps/app/src/components/SelectAssignee.tsx b/apps/app/src/components/SelectAssignee.tsx index 0e924b1c9..0dcece026 100644 --- a/apps/app/src/components/SelectAssignee.tsx +++ b/apps/app/src/components/SelectAssignee.tsx @@ -22,7 +22,13 @@ export const SelectAssignee = ({ }: SelectAssigneeProps) => { const { data: activeMember } = authClient.useActiveMember(); // Exclude platform admins from assignee selection - const assignees = rawAssignees.filter((a) => a.user.role !== 'admin'); + const assignees = rawAssignees + .filter((a) => a.user.role !== 'admin') + .sort((a, b) => + (a.user.name || a.user.email || '').localeCompare( + b.user.name || b.user.email || '', + ), + ); const [selectedAssignee, setSelectedAssignee] = useState<(Member & { user: User }) | null>(null); // Initialize selectedAssignee based on assigneeId prop diff --git a/apps/app/src/hooks/use-trust-portal-settings.ts b/apps/app/src/hooks/use-trust-portal-settings.ts index c8ba67ece..e4d2433bf 100644 --- a/apps/app/src/hooks/use-trust-portal-settings.ts +++ b/apps/app/src/hooks/use-trust-portal-settings.ts @@ -201,6 +201,17 @@ export function useTrustPortalSettings() { [api], ); + const updateSecurityQuestionnaireEnabled = useCallback( + async (enabled: boolean) => { + const response = await api.put('/v1/trust-portal/settings/security-questionnaire', { + enabled, + }); + if (response.error) throw new Error(response.error); + return response.data; + }, + [api], + ); + const updateVendorTrustSettings = useCallback( async (vendorId: string, data: VendorTrustSettingsData) => { const response = await api.post(`/v1/trust-portal/vendors/${vendorId}/trust-settings`, data); @@ -289,6 +300,7 @@ export function useTrustPortalSettings() { uploadCustomFrameworkBadge, removeCustomFrameworkBadge, saveOverview, + updateSecurityQuestionnaireEnabled, updateVendorTrustSettings, updateAllowedDomains, updateAllowedEmails, diff --git a/apps/app/src/lib/embedding/embedding.spec.ts b/apps/app/src/lib/embedding/embedding.spec.ts index e1ee39ea6..3c6d6a20b 100644 --- a/apps/app/src/lib/embedding/embedding.spec.ts +++ b/apps/app/src/lib/embedding/embedding.spec.ts @@ -4,12 +4,16 @@ import { describe, expect, it, vi, beforeEach } from 'vitest'; const upsertMock = vi.fn(); const queryMock = vi.fn(); const infoMock = vi.fn(); +const deleteMock = vi.fn(); +const rangeMock = vi.fn(); vi.mock('@upstash/vector', () => ({ Index: vi.fn().mockImplementation(() => ({ upsert: upsertMock, query: queryMock, info: infoMock, + delete: deleteMock, + range: rangeMock, })), })); @@ -29,13 +33,15 @@ import { upsertEntityEmbeddings, findSimilarTasks, waitForIndexed, - type EntityKind, + pruneOrphanTaskVectors, } from './index'; beforeEach(() => { upsertMock.mockReset(); queryMock.mockReset(); infoMock.mockReset(); + deleteMock.mockReset(); + rangeMock.mockReset(); process.env.UPSTASH_VECTOR_REST_URL = 'https://test.upstash.io'; process.env.UPSTASH_VECTOR_REST_TOKEN = 'test-token'; }); @@ -196,6 +202,22 @@ describe('findSimilarTasks', () => { expect(results).toEqual([]); expect(queryMock).not.toHaveBeenCalled(); }); + + it('recovers the raw task id from the prefix when metadata.sourceId is missing', async () => { + // Legacy vector with no sourceId metadata. Returning the prefixed embedding + // id would make runLinkage drop it as "not in live scope" (taskById is keyed + // by raw ids), silently starving suggestions. (cubic P1) + queryMock.mockResolvedValueOnce([ + { id: 'task_org_1_tsk_legacy', score: 0.8, metadata: {} }, + ]); + + const results = await findSimilarTasks({ + organizationId: 'org_1', + queryText: 'phishing risk', + }); + + expect(results).toEqual([{ id: 'tsk_legacy', score: 0.8, department: undefined }]); + }); }); describe('waitForIndexed', () => { @@ -245,3 +267,175 @@ describe('waitForIndexed', () => { expect(infoMock.mock.calls.length).toBeGreaterThanOrEqual(2); }); }); + +describe('pruneOrphanTaskVectors', () => { + // Single-page range result: `nextCursor: ''` tells the sweep it's drained. + function onePage(vectors: Array<{ id: string; metadata?: { sourceId?: string } }>) { + rangeMock.mockResolvedValueOnce({ nextCursor: '', vectors }); + } + + it('deletes only vectors whose sourceId is not in the live task set', async () => { + onePage([ + { id: 'task_org_1_tsk_live', metadata: { sourceId: 'tsk_live' } }, + { id: 'task_org_1_tsk_orphan1', metadata: { sourceId: 'tsk_orphan1' } }, + { id: 'task_org_1_tsk_orphan2', metadata: { sourceId: 'tsk_orphan2' } }, + ]); + deleteMock.mockResolvedValueOnce({ deleted: 2 }); + + const result = await pruneOrphanTaskVectors({ + organizationId: 'org_1', + liveTaskIds: new Set(['tsk_live']), + }); + + // Enumerates the org's task vectors via a prefix range (no topK ceiling), + // starting from cursor '0'. + expect(rangeMock).toHaveBeenCalledWith( + expect.objectContaining({ + cursor: '0', + prefix: 'task_org_1_', + includeMetadata: true, + }), + ); + // No cosine query is used to enumerate for the prune. + expect(queryMock).not.toHaveBeenCalled(); + // Deletes the orphan vectors by their prefixed embedding id — never the live one. + expect(deleteMock).toHaveBeenCalledTimes(1); + expect(deleteMock).toHaveBeenCalledWith([ + 'task_org_1_tsk_orphan1', + 'task_org_1_tsk_orphan2', + ]); + expect(result.deletedSourceIds).toEqual(['tsk_orphan1', 'tsk_orphan2']); + expect(result.scanned).toBe(3); + }); + + it('does not call delete when every task vector is still live', async () => { + onePage([ + { id: 'task_org_1_tsk_a', metadata: { sourceId: 'tsk_a' } }, + { id: 'task_org_1_tsk_b', metadata: { sourceId: 'tsk_b' } }, + ]); + + const result = await pruneOrphanTaskVectors({ + organizationId: 'org_1', + liveTaskIds: new Set(['tsk_a', 'tsk_b']), + }); + + expect(deleteMock).not.toHaveBeenCalled(); + expect(result.deletedSourceIds).toEqual([]); + expect(result.scanned).toBe(2); + }); + + it('batches deletes at 100 ids per call', async () => { + // 250 orphans → three delete batches (100 + 100 + 50). + const vectors = Array.from({ length: 250 }, (_, i) => ({ + id: `task_org_1_tsk_${i}`, + metadata: { sourceId: `tsk_${i}` }, + })); + onePage(vectors); + deleteMock.mockResolvedValue({ deleted: 100 }); + + const result = await pruneOrphanTaskVectors({ + organizationId: 'org_1', + liveTaskIds: new Set(), + }); + + expect(deleteMock).toHaveBeenCalledTimes(3); + expect(deleteMock.mock.calls[0][0]).toHaveLength(100); + expect(deleteMock.mock.calls[1][0]).toHaveLength(100); + expect(deleteMock.mock.calls[2][0]).toHaveLength(50); + expect(result.deletedSourceIds).toHaveLength(250); + }); + + it('paginates the whole index via the cursor — orphans past page 1 are not missed', async () => { + // Two pages: page 1 hands back a cursor, page 2 drains it. An orphan on the + // SECOND page must still be found — the old top-1000 query would miss it on + // a large org. (CS-681, cubic P2) + rangeMock + .mockResolvedValueOnce({ + nextCursor: 'cursor_2', + vectors: [ + { id: 'task_org_1_tsk_live1', metadata: { sourceId: 'tsk_live1' } }, + { id: 'task_org_1_tsk_orphan_p1', metadata: { sourceId: 'tsk_orphan_p1' } }, + ], + }) + .mockResolvedValueOnce({ + nextCursor: '', + vectors: [ + { id: 'task_org_1_tsk_live2', metadata: { sourceId: 'tsk_live2' } }, + { id: 'task_org_1_tsk_orphan_p2', metadata: { sourceId: 'tsk_orphan_p2' } }, + ], + }); + deleteMock.mockResolvedValue({ deleted: 1 }); + + const result = await pruneOrphanTaskVectors({ + organizationId: 'org_1', + liveTaskIds: new Set(['tsk_live1', 'tsk_live2']), + }); + + expect(rangeMock).toHaveBeenCalledTimes(2); + // Second call reuses the returned cursor. + expect(rangeMock.mock.calls[1][0]).toEqual( + expect.objectContaining({ cursor: 'cursor_2', prefix: 'task_org_1_' }), + ); + // Both orphans deleted, including the one on page 2. + expect(result.deletedSourceIds.sort()).toEqual(['tsk_orphan_p1', 'tsk_orphan_p2']); + expect(result.scanned).toBe(4); + }); + + it('falls back to the id-prefix when metadata.sourceId is missing (does not prune a live vector)', async () => { + // Legacy vectors written before sourceId was stored in metadata. The raw + // task id must be parsed from the `task_${org}_` prefix — treating the whole + // prefixed id as a sourceId would (a) fail the liveTaskIds check and delete + // a LIVE vector, and (b) push a bogus id into deletedSourceIds. (cubic P1) + onePage([ + // Live task, but its vector has no sourceId metadata. + { id: 'task_org_1_tsk_live', metadata: {} }, + // Genuine orphan, also missing sourceId metadata. + { id: 'task_org_1_tsk_orphan', metadata: {} }, + ]); + deleteMock.mockResolvedValueOnce({ deleted: 1 }); + + const result = await pruneOrphanTaskVectors({ + organizationId: 'org_1', + liveTaskIds: new Set(['tsk_live']), + }); + + // The live vector is preserved; only the true orphan is deleted... + expect(deleteMock).toHaveBeenCalledTimes(1); + expect(deleteMock).toHaveBeenCalledWith(['task_org_1_tsk_orphan']); + // ...and deletedSourceIds carries the RAW task id, so the caller's + // embeddingHash clear targets a real row. + expect(result.deletedSourceIds).toEqual(['tsk_orphan']); + }); + + it('continues past a failing delete batch and returns only successfully-deleted sourceIds', async () => { + // 150 orphans → two batches (100 + 50). The first batch's delete throws + // (transient Upstash error); the sweep must still run batch 2 and must NOT + // report batch 1's sourceIds as deleted — clearing their hashes while the + // vectors survive is wasteful, but reporting them deleted while the vectors + // are gone-but-cached would break re-embedding. (cubic P2) + const vectors = Array.from({ length: 150 }, (_, i) => ({ + id: `task_org_1_tsk_${i}`, + metadata: { sourceId: `tsk_${i}` }, + })); + onePage(vectors); + deleteMock + .mockRejectedValueOnce(new Error('upstash 500')) + .mockResolvedValueOnce({ deleted: 50 }); + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const result = await pruneOrphanTaskVectors({ + organizationId: 'org_1', + liveTaskIds: new Set(), + }); + + // Both batches attempted despite the first throwing. + expect(deleteMock).toHaveBeenCalledTimes(2); + // Only the second (successful) batch's 50 sourceIds are returned. + expect(result.deletedSourceIds).toHaveLength(50); + expect(result.deletedSourceIds).toEqual( + vectors.slice(100).map((v) => v.metadata.sourceId), + ); + expect(errSpy).toHaveBeenCalled(); + errSpy.mockRestore(); + }); +}); diff --git a/apps/app/src/lib/embedding/index.ts b/apps/app/src/lib/embedding/index.ts index bcfe629c6..7d8b4765e 100644 --- a/apps/app/src/lib/embedding/index.ts +++ b/apps/app/src/lib/embedding/index.ts @@ -1,6 +1,6 @@ import 'server-only'; -import { Index } from '@upstash/vector'; +import { Index, type RangeResult } from '@upstash/vector'; import { openai } from '@ai-sdk/openai'; import { embedMany } from 'ai'; import { createHash } from 'node:crypto'; @@ -57,6 +57,13 @@ export interface SimilarTaskResult { const EMBEDDING_MODEL = 'text-embedding-3-large'; const EMBEDDING_DIMENSIONS = 1536; const DEFAULT_TOP_K = 25; +// Page size for the orphan-vector sweep. `pruneOrphanTaskVectors` paginates an +// org's task vectors via `range` over their shared id prefix, so there is no +// top-K ceiling — this only controls how many vectors come back per round trip. +const ORPHAN_SCAN_PAGE_SIZE = 1000; +// Backstop so a misbehaving cursor can't loop forever. 500 pages × 1000 = 500k +// task vectors, far beyond any real org — hitting it signals a bug, not scale. +const ORPHAN_SCAN_MAX_PAGES = 500; let cachedIndex: Index | null = null; @@ -73,8 +80,29 @@ function getIndex(): Index { return cachedIndex; } +function embeddingIdPrefix(kind: EntityKind, organizationId: string): string { + return `${kind}_${organizationId}_`; +} + function embeddingId(kind: EntityKind, organizationId: string, sourceId: string): string { - return `${kind}_${organizationId}_${sourceId}`; + return `${embeddingIdPrefix(kind, organizationId)}${sourceId}`; +} + +/** + * Inverse of `embeddingId`: recover the raw source id from a prefixed embedding + * id. Used only as a fallback for vectors written before `metadata.sourceId` + * existed — treating the prefixed id as a raw source id would misidentify the + * entity, so `pruneOrphanTaskVectors` could delete a live task's vector (its + * prefixed id never matches the raw-id `liveTaskIds` set) and clear the hash of + * a row that doesn't exist. + */ +function sourceIdFromEmbeddingId( + kind: EntityKind, + organizationId: string, + id: string, +): string { + const prefix = embeddingIdPrefix(kind, organizationId); + return id.startsWith(prefix) ? id.slice(prefix.length) : id; } /** @@ -187,13 +215,115 @@ export async function findSimilarTasks({ return results.map((r) => { const meta = (r.metadata ?? {}) as { sourceId?: string; department?: string }; return { - id: meta.sourceId ?? String(r.id), + id: meta.sourceId ?? sourceIdFromEmbeddingId('task', organizationId, String(r.id)), score: r.score, department: meta.department ?? undefined, }; }); } +export interface PruneOrphanTaskVectorsResult { + /** sourceIds of the task vectors that were deleted from Upstash. */ + deletedSourceIds: string[]; + /** How many of the org's task vectors were examined. */ + scanned: number; +} + +/** + * Delete an org's task vectors whose `sourceId` is no longer in the live task + * scope. `lib/embedding` only ever UPSERTS task vectors — it has no other + * delete path — so tasks that are deleted, or whose controls all get archived + * (dropping them from `runLinkage`'s scope query), leave orphan vectors behind + * indefinitely. Those orphans are still returned by `findSimilarTasks` (which + * filters on organizationId + sourceType only) and, being embedded from real + * compliance work, sit cosine-near the live tasks — crowding them out of the + * top-K recall entirely and starving risks/vendors of suggestions (CS-681). + * + * Every task vector is keyed `task_${organizationId}_${sourceId}` (see + * `embeddingId`), so a cursor-paginated `range` over that id prefix enumerates + * exactly this org's task vectors with no top-K ceiling — a bounded `query` + * would silently miss any orphans past its window on a large org. The returned + * `deletedSourceIds` let the caller clear each task's stored `embeddingHash`, + * keeping "no vector" consistent with "no hash" so a task that later re-enters + * scope re-embeds instead of being skipped by the dedup guard. Only sourceIds + * whose vector was actually deleted are returned, so a transient delete failure + * never desynchronizes that pairing. + */ +export async function pruneOrphanTaskVectors({ + organizationId, + liveTaskIds, +}: { + organizationId: string; + /** sourceIds of the tasks currently in the linkage scope. */ + liveTaskIds: Set; +}): Promise { + const index = getIndex(); + const prefix = embeddingIdPrefix('task', organizationId); + + // Enumerate the org's task vectors page by page over their shared id prefix. + // Upstash returns `nextCursor === ''` when the last page is drained; the page + // cap is a runaway backstop (a healthy cursor always terminates first). + const orphans: Array<{ vectorId: string; sourceId: string }> = []; + let scanned = 0; + let cursor: string | number = '0'; + let enumeratedFully = false; + for (let page = 0; page < ORPHAN_SCAN_MAX_PAGES; page++) { + // Annotate the result explicitly: `cursor = nextCursor` would otherwise make + // TS infer `nextCursor`'s type from a binding that depends on it (TS7022). + const { vectors, nextCursor }: RangeResult = await index.range({ + cursor, + limit: ORPHAN_SCAN_PAGE_SIZE, + prefix, + includeMetadata: true, + }); + scanned += vectors.length; + for (const r of vectors) { + const meta = (r.metadata ?? {}) as { sourceId?: string }; + const sourceId = + meta.sourceId ?? sourceIdFromEmbeddingId('task', organizationId, String(r.id)); + if (liveTaskIds.has(sourceId)) continue; + orphans.push({ vectorId: String(r.id), sourceId }); + } + if (!nextCursor) { + enumeratedFully = true; + break; + } + cursor = nextCursor; + } + if (!enumeratedFully) { + console.warn( + `[embedding] orphan sweep hit the ${ORPHAN_SCAN_MAX_PAGES}-page cap for org ${organizationId} after scanning ${scanned} vector(s); enumeration may be incomplete`, + ); + } + + if (orphans.length === 0) { + return { deletedSourceIds: [], scanned }; + } + + // Delete in batches, resilient to transient failures: a failing batch is + // logged and skipped so later batches still run, and only the sourceIds whose + // vector was actually deleted are returned. That keeps hash-clearing aligned + // with real deletions — clearing a hash whose vector survived would leave a + // task that re-embeds needlessly, but never one with a cached hash and no + // vector (which would be skipped by the dedup guard and never re-embed). + const deletedSourceIds: string[] = []; + const BATCH = 100; + for (let i = 0; i < orphans.length; i += BATCH) { + const batch = orphans.slice(i, i + BATCH); + try { + await index.delete(batch.map((o) => o.vectorId)); + deletedSourceIds.push(...batch.map((o) => o.sourceId)); + } catch (err) { + console.error( + `[embedding] orphan vector delete batch failed for org ${organizationId} (${batch.length} id(s)); continuing`, + err, + ); + } + } + + return { deletedSourceIds, scanned }; +} + interface WaitForIndexedOptions { /** Hard ceiling. Resolves with `pendingAtTimeout` set if exceeded. */ maxWaitMs?: number; diff --git a/apps/app/src/lib/embedding/run-linkage.spec.ts b/apps/app/src/lib/embedding/run-linkage.spec.ts index 661dce997..a4a651091 100644 --- a/apps/app/src/lib/embedding/run-linkage.spec.ts +++ b/apps/app/src/lib/embedding/run-linkage.spec.ts @@ -2,15 +2,16 @@ import { Departments } from '@db'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { LinkagePhase } from './run-linkage'; -const { dbMock, upsertMock, findSimilarTasksMock, waitForIndexedMock, rerankMock } = vi.hoisted(() => ({ +const { dbMock, upsertMock, findSimilarTasksMock, waitForIndexedMock, pruneMock, rerankMock } = vi.hoisted(() => ({ dbMock: { risk: { findMany: vi.fn(), update: vi.fn() }, vendor: { findMany: vi.fn(), update: vi.fn() }, - task: { findMany: vi.fn(), update: vi.fn() }, + task: { findMany: vi.fn(), update: vi.fn(), updateMany: vi.fn() }, }, upsertMock: vi.fn(), findSimilarTasksMock: vi.fn(), waitForIndexedMock: vi.fn(), + pruneMock: vi.fn(), rerankMock: vi.fn(), })); @@ -25,6 +26,7 @@ vi.mock('./index', () => ({ upsertEntityEmbeddings: upsertMock, findSimilarTasks: findSimilarTasksMock, waitForIndexed: waitForIndexedMock, + pruneOrphanTaskVectors: pruneMock, })); vi.mock('../rerank-suggestions', () => ({ @@ -41,6 +43,10 @@ beforeEach(() => { // Tests that exercise the race resolve `findSimilarTasks` differently // depending on call order rather than blocking on the wait. waitForIndexedMock.mockResolvedValue({ waitedMs: 0, polls: 1 }); + pruneMock.mockReset(); + // Default: a clean index — nothing to prune. Tests that exercise the sweep + // override this per-test. + pruneMock.mockResolvedValue({ deletedSourceIds: [], scanned: 0 }); // Default: every entity is embedded as if for the first time. Tests that // exercise the cache-skip path override this per-test. upsertMock.mockImplementation(async ({ entities }: { entities: Array<{ id: string }> }) => ({ @@ -437,6 +443,60 @@ describe('runLinkage onPhase', () => { expect(result.suggestions?.tasks.map((t) => t.id)).toEqual(['tsk_b', 'tsk_a']); }); + it('suggestionsOnly=true drops stale/orphan task vectors before the rerank-input slice (CS-681)', async () => { + // Regression: findSimilarTasks filters only by org + sourceType, so it can + // return orphan vectors for tasks no longer in the live scope (deleted, or + // all controls archived after a framework change) — lib/embedding never + // prunes them. When those orphans are the cosine-nearest they fill the + // top-30 rerank-input slots and get dropped in the taskById intersection, + // leaving the risk with zero suggestions. The one in-scope task must + // survive by being filtered in BEFORE the slice. + dbMock.risk.findMany.mockResolvedValueOnce([ + { + id: 'rsk_1', + title: 'Data Leakage', + description: 'Sensitive data exposure', + category: 'technology', + department: null, + }, + ]); + dbMock.vendor.findMany.mockResolvedValueOnce([]); + // Live task scope contains ONLY tsk_live — the orphans below are NOT here. + dbMock.task.findMany + .mockResolvedValueOnce([ + { id: 'tsk_live', title: 'Encrypt Data at Rest', description: 'KMS', department: null }, + ]) + // Enrichment lookup for buildSuggestions (only reached once tsk_live survives). + .mockResolvedValueOnce([ + { id: 'tsk_live', title: 'Encrypt Data at Rest', status: 'todo', controls: [] }, + ]); + + // 32 orphan vectors, all cosine-nearer than the one live task, followed by + // the live task at the bottom. 32 > SUGGESTIONS_RERANK_INPUT_TOP_K (30), so + // pre-fix the top-30 slice is entirely orphans and tsk_live never reaches + // the reranker → zero suggestions. + const orphans = Array.from({ length: 32 }, (_, i) => ({ + id: `tsk_orphan_${i}`, + score: 0.99 - i * 0.01, + department: undefined, + })); + findSimilarTasksMock.mockResolvedValueOnce([ + ...orphans, + { id: 'tsk_live', score: 0.5, department: undefined }, + ]); + + const result = await runLinkage({ + organizationId: 'org_1', + riskId: 'rsk_1', + suggestionsOnly: true, + }); + + // The in-scope task survives the slice → non-empty suggestions. + expect(result.suggestions?.tasks.map((t) => t.id)).toEqual(['tsk_live']); + // No orphan leaked into the suggestions. + expect(result.suggestions?.tasks.some((t) => t.id.startsWith('tsk_orphan_'))).toBe(false); + }); + it('replace=false (default) does not disconnect existing links', async () => { dbMock.risk.findMany.mockResolvedValueOnce([ { @@ -703,3 +763,98 @@ describe('runLinkage waits for the vector index to drain before matching', () => warnSpy.mockRestore(); }); }); + +describe('runLinkage prunes orphan task vectors before matching (CS-681)', () => { + // The real root cause: findSimilarTasks filters only by org + sourceType, so + // stale/orphan task vectors (deleted tasks, or tasks whose controls were all + // archived) accumulate in Upstash — lib/embedding has no delete path — and + // crowd the live tasks out of the top-K cosine recall. When a risk's nearest + // vectors are ALL orphans, post-recall filtering can't help (nothing real is + // recalled), so the index itself must be cleaned before matching. + + it('calls pruneOrphanTaskVectors with the full live task scope', async () => { + dbMock.risk.findMany.mockResolvedValueOnce([ + { id: 'rsk_1', title: 'a', description: '', category: 'people', department: Departments.hr }, + ]); + dbMock.vendor.findMany.mockResolvedValueOnce([]); + dbMock.task.findMany.mockResolvedValueOnce([ + { id: 'tsk_a', title: 'A', description: '', department: Departments.hr }, + { id: 'tsk_b', title: 'B', description: '', department: Departments.hr }, + ]); + findSimilarTasksMock.mockResolvedValueOnce([ + { id: 'tsk_a', score: 0.9, department: Departments.hr }, + ]); + + await runLinkage({ organizationId: 'org_1', riskId: 'rsk_1' }); + + expect(pruneMock).toHaveBeenCalledTimes(1); + const arg = pruneMock.mock.calls[0][0]; + expect(arg.organizationId).toBe('org_1'); + // liveTaskIds is the whole org task scope (not just this risk's matches). + expect([...arg.liveTaskIds].sort()).toEqual(['tsk_a', 'tsk_b']); + }); + + it('clears the embeddingHash of pruned orphan tasks, scoped by org', async () => { + dbMock.risk.findMany.mockResolvedValueOnce([ + { id: 'rsk_1', title: 'a', description: '', category: 'people', department: Departments.hr }, + ]); + dbMock.vendor.findMany.mockResolvedValueOnce([]); + dbMock.task.findMany.mockResolvedValueOnce([ + { id: 'tsk_a', title: 'A', description: '', department: Departments.hr }, + ]); + findSimilarTasksMock.mockResolvedValueOnce([ + { id: 'tsk_a', score: 0.9, department: Departments.hr }, + ]); + pruneMock.mockResolvedValueOnce({ + deletedSourceIds: ['tsk_gone1', 'tsk_gone2'], + scanned: 3, + }); + + await runLinkage({ organizationId: 'org_1', riskId: 'rsk_1' }); + + expect(dbMock.task.updateMany).toHaveBeenCalledWith({ + where: { id: { in: ['tsk_gone1', 'tsk_gone2'] }, organizationId: 'org_1' }, + data: { embeddingHash: null }, + }); + }); + + it('does not touch embeddingHash when nothing was pruned', async () => { + dbMock.risk.findMany.mockResolvedValueOnce([ + { id: 'rsk_1', title: 'a', description: '', category: 'people', department: Departments.hr }, + ]); + dbMock.vendor.findMany.mockResolvedValueOnce([]); + dbMock.task.findMany.mockResolvedValueOnce([ + { id: 'tsk_a', title: 'A', description: '', department: Departments.hr }, + ]); + findSimilarTasksMock.mockResolvedValueOnce([ + { id: 'tsk_a', score: 0.9, department: Departments.hr }, + ]); + // Default pruneMock → deletedSourceIds: []. + + await runLinkage({ organizationId: 'org_1', riskId: 'rsk_1' }); + + expect(dbMock.task.updateMany).not.toHaveBeenCalled(); + }); + + it('still completes the run when pruning throws (best-effort cleanup)', async () => { + dbMock.risk.findMany.mockResolvedValueOnce([ + { id: 'rsk_1', title: 'a', description: '', category: 'people', department: Departments.hr }, + ]); + dbMock.vendor.findMany.mockResolvedValueOnce([]); + dbMock.task.findMany.mockResolvedValueOnce([ + { id: 'tsk_a', title: 'A', description: '', department: Departments.hr }, + ]); + findSimilarTasksMock.mockResolvedValueOnce([ + { id: 'tsk_a', score: 0.9, department: Departments.hr }, + ]); + pruneMock.mockRejectedValueOnce(new Error('upstash down')); + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const result = await runLinkage({ organizationId: 'org_1', riskId: 'rsk_1' }); + + // Matching still runs — tsk_a is linked despite the failed prune. + expect(result.riskLinks).toBe(1); + expect(errSpy).toHaveBeenCalled(); + errSpy.mockRestore(); + }); +}); diff --git a/apps/app/src/lib/embedding/run-linkage.ts b/apps/app/src/lib/embedding/run-linkage.ts index 138cca6f7..0c8d8f17e 100644 --- a/apps/app/src/lib/embedding/run-linkage.ts +++ b/apps/app/src/lib/embedding/run-linkage.ts @@ -1,5 +1,10 @@ import { db } from '@db/server'; -import { upsertEntityEmbeddings, findSimilarTasks, waitForIndexed } from './index'; +import { + upsertEntityEmbeddings, + findSimilarTasks, + waitForIndexed, + pruneOrphanTaskVectors, +} from './index'; import { linkSuggestions } from '../link-suggestions'; import { rerankSuggestions, @@ -593,6 +598,34 @@ export async function runLinkage({ // without an extra DB round trip. const taskById = new Map(tasks.map((t) => [t.id, t])); + // Prune orphan task vectors BEFORE matching. findSimilarTasks filters only by + // org + sourceType, so stale vectors — tasks since deleted, or whose controls + // were all archived (dropping them from the scope query above) — otherwise sit + // cosine-near real work and crowd live tasks out of the top-K recall, starving + // risks/vendors of suggestions. lib/embedding has no other delete path, so this + // is where the cleanup has to happen. Best-effort: the in-scope filter in each + // matching loop still keeps any straggler orphan out of this run's results if + // the prune fails or a delete hasn't propagated yet. (CS-681) + try { + const { deletedSourceIds } = await pruneOrphanTaskVectors({ + organizationId, + liveTaskIds: new Set(taskById.keys()), + }); + if (deletedSourceIds.length > 0) { + console.warn( + `[linkage] pruned ${deletedSourceIds.length} orphan task vector(s) for org ${organizationId}`, + ); + // Keep "no vector" consistent with "no hash": a task re-entering scope + // must re-embed rather than be skipped by the dedup guard. + await db.task.updateMany({ + where: { id: { in: deletedSourceIds }, organizationId }, + data: { embeddingHash: null }, + }); + } + } catch (err) { + console.error('[linkage] orphan vector prune failed; continuing', err); + } + let suggestions: RunLinkageOutput['suggestions']; // Emit initial matching phases before the parallel fan-out so the UI shows @@ -625,22 +658,31 @@ export async function runLinkage({ .join(', ')})` : ''), ); + // Intersect the cosine results with the live task scope BEFORE the + // top-K slice inside linkSuggestions. findSimilarTasks filters only by + // org + sourceType, so it can return stale/orphan vectors — tasks since + // deleted, or whose controls were all archived after a framework change — + // that lib/embedding never prunes (it has no delete path). If those + // orphans are the cosine-nearest, slicing first lets them consume every + // rerank-input slot and they'd then be dropped in the taskById lookup + // inside the reranker, leaving the risk with zero suggestions. Filtering + // here keeps the slice full of real, linkable tasks. (CS-681) + const inScopeSimilar = similar.filter((s) => taskById.has(s.id)); + if (inScopeSimilar.length !== similar.length) { + console.warn( + `[linkage] risk "${risk.title}" → ${similar.length - inScopeSimilar.length} of ${similar.length} cosine matches dropped (stale vectors not in live task scope)`, + ); + } // Both paths feed the reranker — autonomous needs it just as badly to // get past the 0.4-0.6 cosine band that dominates compliance prose. const links = linkSuggestions({ source: { department: risk.department ?? undefined }, - candidates: similar.map((s) => ({ id: s.id, score: s.score, department: s.department })), + candidates: inScopeSimilar.map((s) => ({ id: s.id, score: s.score, department: s.department })), threshold: 0, topK: suggestionsOnly ? SUGGESTIONS_RERANK_INPUT_TOP_K : AUTONOMOUS_RERANK_INPUT_TOP_K, }); let count = 0; let perEntitySuggestions: RunLinkageOutput['suggestions']; - const inScopeMatches = links.filter((l) => taskById.has(l.id)).length; - if (inScopeMatches !== links.length) { - console.warn( - `[linkage] risk "${risk.title}" → ${links.length - inScopeMatches} of ${links.length} cosine matches dropped (not in task scope after filter)`, - ); - } if (links.length > 0) { const source: RerankSource = { kind: 'risk', @@ -693,9 +735,17 @@ export async function runLinkage({ queryText: vendorQueryText(vendor), topK: suggestionsOnly ? SUGGESTIONS_QUERY_TOP_K : AUTONOMOUS_QUERY_TOP_K, }); + // Same live-scope intersection as the risk loop above: drop stale/orphan + // task vectors before the top-K slice so they can't starve real matches. (CS-681) + const inScopeSimilar = similar.filter((s) => taskById.has(s.id)); + if (inScopeSimilar.length !== similar.length) { + console.warn( + `[linkage] vendor "${vendor.name}" → ${similar.length - inScopeSimilar.length} of ${similar.length} cosine matches dropped (stale vectors not in live task scope)`, + ); + } const links = linkSuggestions({ source: {}, - candidates: similar.map((s) => ({ id: s.id, score: s.score, department: s.department })), + candidates: inScopeSimilar.map((s) => ({ id: s.id, score: s.score, department: s.department })), threshold: 0, topK: suggestionsOnly ? SUGGESTIONS_RERANK_INPUT_TOP_K : AUTONOMOUS_RERANK_INPUT_TOP_K, }); diff --git a/packages/db/prisma/migrations/20260706120000_add_trust_security_questionnaire_enabled/migration.sql b/packages/db/prisma/migrations/20260706120000_add_trust_security_questionnaire_enabled/migration.sql new file mode 100644 index 000000000..f92755e48 --- /dev/null +++ b/packages/db/prisma/migrations/20260706120000_add_trust_security_questionnaire_enabled/migration.sql @@ -0,0 +1,5 @@ +-- Add a per-org toggle for the public trust portal's AI-assisted Security +-- Questionnaire. Defaults to true so existing portals keep showing it; org +-- owners can hide it when they'd rather review answers before customers run it. +ALTER TABLE "Trust" + ADD COLUMN "securityQuestionnaireEnabled" BOOLEAN NOT NULL DEFAULT true; diff --git a/packages/db/prisma/schema/trust.prisma b/packages/db/prisma/schema/trust.prisma index 8d2bb70c0..717147e88 100644 --- a/packages/db/prisma/schema/trust.prisma +++ b/packages/db/prisma/schema/trust.prisma @@ -53,6 +53,11 @@ model Trust { // Favicon for trust portal (stored in S3) favicon String? + // Whether the AI-assisted Security Questionnaire is offered on the public + // trust portal. Defaults to true so existing portals keep their current + // behavior; org owners can hide it when they'd rather review answers first. + securityQuestionnaireEnabled Boolean @default(true) + @@id([status, organizationId]) @@unique([organizationId]) @@index([organizationId])