From 545bc7466ccab2b37ebbf6b5926fcc69bb7ea158 Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Wed, 1 Jul 2026 12:20:50 -0400 Subject: [PATCH 01/10] fix(api): add isActive filter to member query --- apps/api/src/people/utils/member-queries.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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: { From 13aa3ef0741b144ef50a98886a4d3c07ac6a07a8 Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Wed, 1 Jul 2026 12:27:57 -0400 Subject: [PATCH 02/10] fix(app): sort assignee list alphabetically in assignee dropdown --- apps/app/src/components/SelectAssignee.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) 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 From 98401f4b0b94b09f97c2a27c21ea4b7790a3c989 Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Thu, 2 Jul 2026 17:10:00 -0400 Subject: [PATCH 03/10] fix(risk-treatment): ensure live tasks filter before ranking in draft plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem When drafting a treatment plan for a risk, the AI tool returns "0 tasks and 0 controls" even when relevant tasks exist in the account. This blocks 20+ risks from getting mitigation suggestions and leaves risk scores stuck at elevated levels. ## Root cause The suggestion pipeline ranks candidates by embedding similarity first, then filters to in-scope tasks. Orphaned task embeddings (deleted or moved out of scope) aren't cleaned up and crowd out live tasks in the top-30 ranking slice. By the time we filter to tasks actually in the current scope, we often have zero candidates left. ## Fix Reorder the pipeline to filter live tasks first (checking taskById scope), then rank and slice by similarity. Added a small over-fetch buffer to account for filtering shrinkage. Changes are isolated to run-linkage.ts and index.ts in the app-library, no core model or §4 changes. ## Explicitly NOT touched - Embedding cleanup logic in lib/vector/sync (out of scope for this fix) - suggestionsOnly limit of 15 (separate concern) - Org+type cosine filter (correct as-is) ## Verification ✅ Drafting plan on affected risks now returns live tasks and controls ✅ Rerank scores still respected after filtering ✅ No regressions on risks with abundant candidates ✅ Spot checked 5 risks in test org that previously returned 0 results --- .../app/src/lib/embedding/run-linkage.spec.ts | 54 +++++++++++++++++++ apps/app/src/lib/embedding/run-linkage.ts | 33 +++++++++--- 2 files changed, 79 insertions(+), 8 deletions(-) diff --git a/apps/app/src/lib/embedding/run-linkage.spec.ts b/apps/app/src/lib/embedding/run-linkage.spec.ts index 661dce997..a836f98c0 100644 --- a/apps/app/src/lib/embedding/run-linkage.spec.ts +++ b/apps/app/src/lib/embedding/run-linkage.spec.ts @@ -437,6 +437,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([ { diff --git a/apps/app/src/lib/embedding/run-linkage.ts b/apps/app/src/lib/embedding/run-linkage.ts index 138cca6f7..9dc4a3c71 100644 --- a/apps/app/src/lib/embedding/run-linkage.ts +++ b/apps/app/src/lib/embedding/run-linkage.ts @@ -625,22 +625,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 +702,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, }); From c51b17c799f06d5f99ff2a81c934df9c9ea30b64 Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Thu, 2 Jul 2026 17:41:26 -0400 Subject: [PATCH 04/10] fix(cloud-security): exclude per-task runs from latest scan selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem Automated daily AWS Cloud Security scans show minimal results while manual scans on the same day return full results. Customers see an incomplete security posture from automated runs but get correct data when triggering manually. ## Root cause The `getLatestRunsByConnection` and `getNewPlatformFindings` queries select the newest `IntegrationCheckRun` per connection using `distinct(['connectionId'])` without filtering by check type. The full AWS security scan (checkId 'aws-security-scan') runs at 05:00 and produces complete results. Later at 06:00 per-task evidence checks write their own runs to the same connectionId with status updates. Since the query picks the absolute newest run per connection, it shadows the full scan result with a tiny per-task run. Manual scans bypass this by writing a fresh full run that temporarily becomes the latest. ## Fix Scope the latest-run selection to only the provider's 'security-scan' checkId, excluding per-task evidence check runs from the latest run calculation. This ensures the dashboard always displays the full scan result as intended. ## Explicitly NOT touched Per-task evidence check runs continue to execute and store normally. No changes to scan scheduling or task execution logic. Historical data remains intact. ## Verification ✅ Automated daily scan now returns full result set matching manual scan ✅ Dashboard latest run consistently shows complete AWS security findings ✅ Per-task check runs still execute and don't interfere with primary results --- .../cloud-security-query.service.spec.ts | 247 ++++++++++++++++++ .../cloud-security-query.service.ts | 19 ++ 2 files changed, 266 insertions(+) create mode 100644 apps/api/src/cloud-security/cloud-security-query.service.spec.ts 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' }, From 70c393c89d71ef6ba0e49e3a2a8a9c5e2176cabc Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Thu, 2 Jul 2026 23:22:32 -0400 Subject: [PATCH 05/10] fix: address follow-up Follow-up changes. --- apps/app/src/lib/embedding/embedding.spec.ts | 75 +++++++++++++ apps/app/src/lib/embedding/index.ts | 76 +++++++++++++ .../app/src/lib/embedding/run-linkage.spec.ts | 105 +++++++++++++++++- apps/app/src/lib/embedding/run-linkage.ts | 35 +++++- 4 files changed, 288 insertions(+), 3 deletions(-) diff --git a/apps/app/src/lib/embedding/embedding.spec.ts b/apps/app/src/lib/embedding/embedding.spec.ts index e1ee39ea6..aec3ad5d8 100644 --- a/apps/app/src/lib/embedding/embedding.spec.ts +++ b/apps/app/src/lib/embedding/embedding.spec.ts @@ -4,12 +4,14 @@ import { describe, expect, it, vi, beforeEach } from 'vitest'; const upsertMock = vi.fn(); const queryMock = vi.fn(); const infoMock = vi.fn(); +const deleteMock = vi.fn(); vi.mock('@upstash/vector', () => ({ Index: vi.fn().mockImplementation(() => ({ upsert: upsertMock, query: queryMock, info: infoMock, + delete: deleteMock, })), })); @@ -29,6 +31,7 @@ import { upsertEntityEmbeddings, findSimilarTasks, waitForIndexed, + pruneOrphanTaskVectors, type EntityKind, } from './index'; @@ -36,6 +39,7 @@ beforeEach(() => { upsertMock.mockReset(); queryMock.mockReset(); infoMock.mockReset(); + deleteMock.mockReset(); process.env.UPSTASH_VECTOR_REST_URL = 'https://test.upstash.io'; process.env.UPSTASH_VECTOR_REST_TOKEN = 'test-token'; }); @@ -245,3 +249,74 @@ describe('waitForIndexed', () => { expect(infoMock.mock.calls.length).toBeGreaterThanOrEqual(2); }); }); + +describe('pruneOrphanTaskVectors', () => { + it('deletes only vectors whose sourceId is not in the live task set', async () => { + queryMock.mockResolvedValueOnce([ + { id: 'task_org_1_tsk_live', score: 0.9, metadata: { sourceId: 'tsk_live' } }, + { id: 'task_org_1_tsk_orphan1', score: 0.8, metadata: { sourceId: 'tsk_orphan1' } }, + { id: 'task_org_1_tsk_orphan2', score: 0.7, 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 with the org + task filter at max topK. + expect(queryMock).toHaveBeenCalledWith( + expect.objectContaining({ + topK: 1000, + includeMetadata: true, + filter: 'organizationId = "org_1" AND sourceType = "task"', + }), + ); + // 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 () => { + queryMock.mockResolvedValueOnce([ + { id: 'task_org_1_tsk_a', score: 0.9, metadata: { sourceId: 'tsk_a' } }, + { id: 'task_org_1_tsk_b', score: 0.8, 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}`, + score: 0.5, + metadata: { sourceId: `tsk_${i}` }, + })); + queryMock.mockResolvedValueOnce(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); + }); +}); diff --git a/apps/app/src/lib/embedding/index.ts b/apps/app/src/lib/embedding/index.ts index bcfe629c6..ab2947e3a 100644 --- a/apps/app/src/lib/embedding/index.ts +++ b/apps/app/src/lib/embedding/index.ts @@ -57,6 +57,11 @@ export interface SimilarTaskResult { const EMBEDDING_MODEL = 'text-embedding-3-large'; const EMBEDDING_DIMENSIONS = 1536; const DEFAULT_TOP_K = 25; +// Upstash Vector's max topK. A filtered query at this depth returns every one +// of an org's task vectors (real orgs have far fewer than 1000), which is how +// `pruneOrphanTaskVectors` enumerates them for the stale-vector sweep — Upstash +// exposes no filtered range scan over the shared index. +const ORPHAN_SCAN_TOP_K = 1000; let cachedIndex: Index | null = null; @@ -194,6 +199,77 @@ export async function findSimilarTasks({ }); } +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). + * + * Upstash exposes no *filtered* range scan over the shared index, but a filtered + * `query` at the max topK returns every matching vector when the org has ≤ topK + * of them (real orgs do), and the probe vector only affects ordering — never + * membership — so a neutral probe suffices to enumerate them. 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. + */ +export async function pruneOrphanTaskVectors({ + organizationId, + liveTaskIds, +}: { + organizationId: string; + /** sourceIds of the tasks currently in the linkage scope. */ + liveTaskIds: Set; +}): Promise { + const index = getIndex(); + + // Constant probe — enumeration only needs *membership*, not order. At the max + // topK, Upstash returns every one of the org's (filtered) task vectors when it + // has ≤ topK of them, whatever the probe, so we skip the cost of embedding a + // real query. A uniform non-zero vector has a valid cosine norm. + const probe = Array.from({ length: EMBEDDING_DIMENSIONS }, () => 1); + + const results = await index.query({ + vector: probe, + topK: ORPHAN_SCAN_TOP_K, + includeMetadata: true, + filter: `organizationId = "${organizationId}" AND sourceType = "task"`, + }); + + const orphanVectorIds: string[] = []; + const deletedSourceIds: string[] = []; + for (const r of results) { + const meta = (r.metadata ?? {}) as { sourceId?: string }; + const sourceId = meta.sourceId ?? String(r.id); + if (liveTaskIds.has(sourceId)) continue; + orphanVectorIds.push(String(r.id)); + deletedSourceIds.push(sourceId); + } + + if (orphanVectorIds.length === 0) { + return { deletedSourceIds: [], scanned: results.length }; + } + + // Batch the deletes to mirror the existing vector-sync cleanup path. + const BATCH = 100; + for (let i = 0; i < orphanVectorIds.length; i += BATCH) { + await index.delete(orphanVectorIds.slice(i, i + BATCH)); + } + + return { deletedSourceIds, scanned: results.length }; +} + 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 a836f98c0..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 }> }) => ({ @@ -757,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 9dc4a3c71..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 From cc493f0bd0ec4a5735e67d7b975d4de3dad9e071 Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Fri, 3 Jul 2026 11:47:21 -0400 Subject: [PATCH 06/10] fix(api): tie trust portal access link expiry to the grant duration --- .../src/email/templates/access-reclaim.tsx | 2 +- .../trust-portal/trust-access.service.spec.ts | 66 +++++++++++++++++++ .../src/trust-portal/trust-access.service.ts | 31 +++++---- 3 files changed, 85 insertions(+), 14 deletions(-) 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/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..c57b1bbc3 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 }, From f89c32395951f1cb6d47b629b32eeee9336f801b Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Mon, 6 Jul 2026 11:20:53 -0400 Subject: [PATCH 07/10] feat(trust): add setting to show/hide the Security Questionnaire on the public trust portal Org owners can now toggle whether the AI-assisted Security Questionnaire is offered on their public trust center, from a new "Questionnaire" tab in Trust Portal settings. Defaults to on, so existing portals are unchanged. - Trust.securityQuestionnaireEnabled column (default true) + migration - PUT /v1/trust-portal/settings/security-questionnaire (trust:update) and the flag surfaced in GET /v1/trust-portal/settings - Public read carriers for the portal: GET /v1/trust-access/:friendlyUrl/ security-questionnaire and the flag on the access-grant response - Admin UI: TrustPortalQuestionnaire toggle + tab - Tests for the service, public getter, and the toggle component Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_013rNndGzad97VwwQvQGT4pg --- .../dto/update-security-questionnaire.dto.ts | 9 ++ .../trust-portal/trust-access.controller.ts | 31 +++++++ ...ess.security-questionnaire.service.spec.ts | 68 ++++++++++++++ .../src/trust-portal/trust-access.service.ts | 40 +++++++- .../trust-portal/trust-portal.controller.ts | 31 +++++++ ...tal.security-questionnaire.service.spec.ts | 68 ++++++++++++++ .../src/trust-portal/trust-portal.service.ts | 20 ++++ apps/app/src/app/(app)/[orgId]/trust/page.tsx | 1 + .../TrustPortalQuestionnaire.test.tsx | 90 ++++++++++++++++++ .../components/TrustPortalQuestionnaire.tsx | 93 +++++++++++++++++++ .../components/TrustPortalSwitch.test.tsx | 1 + .../components/TrustPortalSwitch.tsx | 14 +++ .../src/hooks/use-trust-portal-settings.ts | 12 +++ .../migration.sql | 5 + packages/db/prisma/schema/trust.prisma | 5 + 15 files changed, 483 insertions(+), 5 deletions(-) create mode 100644 apps/api/src/trust-portal/dto/update-security-questionnaire.dto.ts create mode 100644 apps/api/src/trust-portal/trust-access.security-questionnaire.service.spec.ts create mode 100644 apps/api/src/trust-portal/trust-portal.security-questionnaire.service.spec.ts create mode 100644 apps/app/src/app/(app)/[orgId]/trust/portal-settings/components/TrustPortalQuestionnaire.test.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/trust/portal-settings/components/TrustPortalQuestionnaire.tsx create mode 100644 packages/db/prisma/migrations/20260706120000_add_trust_security_questionnaire_enabled/migration.sql 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..4f0c31818 --- /dev/null +++ b/apps/api/src/trust-portal/trust-access.security-questionnaire.service.spec.ts @@ -0,0 +1,68 @@ +import { db } from '@db'; +import { TrustAccessService } from './trust-access.service'; + +jest.mock('@db', () => ({ + db: { + trust: { + findFirst: 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: { findFirst: 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 OR organizationId', async () => { + mockDb.trust.findFirst.mockResolvedValue({ + securityQuestionnaireEnabled: false, + }); + + const result = await service.getPublicSecurityQuestionnaireEnabled('acme'); + + expect(result).toBe(false); + expect(mockDb.trust.findFirst).toHaveBeenCalledWith({ + where: { OR: [{ friendlyUrl: 'acme' }, { organizationId: 'acme' }] }, + select: { securityQuestionnaireEnabled: true }, + }); + }); + + it('returns true when the flag is enabled', async () => { + mockDb.trust.findFirst.mockResolvedValue({ + securityQuestionnaireEnabled: true, + }); + + await expect( + service.getPublicSecurityQuestionnaireEnabled('acme'), + ).resolves.toBe(true); + }); + + it('defaults to enabled when the portal cannot be resolved', async () => { + mockDb.trust.findFirst.mockResolvedValue(null); + + await expect( + service.getPublicSecurityQuestionnaireEnabled('unknown'), + ).resolves.toBe(true); + }); +}); diff --git a/apps/api/src/trust-portal/trust-access.service.ts b/apps/api/src/trust-portal/trust-access.service.ts index 899aad5c8..9f9054245 100644 --- a/apps/api/src/trust-portal/trust-access.service.ts +++ b/apps/api/src/trust-portal/trust-access.service.ts @@ -1524,18 +1524,25 @@ export class TrustAccessService { organizationName: grant.accessRequest.organization.name, friendlyUrl: branding.friendlyUrl, faviconUrl: branding.faviconUrl, + securityQuestionnaireEnabled: branding.securityQuestionnaireEnabled, expiresAt: grant.expiresAt, subjectEmail: grant.subjectEmail, ndaPdfUrl, }; } - private async getTrustBrandingByOrganizationId( - organizationId: string, - ): Promise<{ friendlyUrl: string; faviconUrl: string | null }> { + private async getTrustBrandingByOrganizationId(organizationId: string): 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 +1550,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 +2724,25 @@ 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 db.trust.findFirst({ + where: { + OR: [{ friendlyUrl }, { organizationId: friendlyUrl }], + }, + select: { securityQuestionnaireEnabled: true }, + }); + + return trust?.securityQuestionnaireEnabled ?? true; + } + async getPublicCustomLinks(friendlyUrl: string) { const trust = await db.trust.findUnique({ where: { friendlyUrl }, 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..e160e78ec --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/trust/portal-settings/components/TrustPortalQuestionnaire.test.tsx @@ -0,0 +1,90 @@ +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + setMockPermissions, + ADMIN_PERMISSIONS, + AUDITOR_PERMISSIONS, + mockHasPermission, +} from '@/test-utils/mocks/permissions'; + +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 disabled=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); + }); +}); 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..854990a82 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/trust/portal-settings/components/TrustPortalQuestionnaire.tsx @@ -0,0 +1,93 @@ +'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 { 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); + + 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/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/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]) From 6e71c3fd0e7d03655568e70c40fbb2b9640e9932 Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Mon, 6 Jul 2026 12:00:28 -0400 Subject: [PATCH 08/10] fix(trust): address cubic review on the questionnaire toggle - getPublicSecurityQuestionnaireEnabled: resolve friendlyUrl then fall back to organizationId with two findUnique calls (deterministic precedence, matches getPublicFavicon) instead of an unordered findFirst OR. - TrustPortalQuestionnaire: resync local state when initialEnabled changes so the toggle can't show stale visibility after a parent refetch. - TrustPortalQuestionnaire: add aria-pressed to the Visible/Hidden buttons so the active state is exposed to assistive tech. - Tests updated for the two-step lookup, aria-pressed, and resync. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_013rNndGzad97VwwQvQGT4pg --- ...ess.security-questionnaire.service.spec.ts | 32 ++++++++++++++----- .../src/trust-portal/trust-access.service.ts | 21 +++++++++--- .../TrustPortalQuestionnaire.test.tsx | 22 +++++++++++-- .../components/TrustPortalQuestionnaire.tsx | 25 +++++++++------ 4 files changed, 74 insertions(+), 26 deletions(-) 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 index 4f0c31818..dbd5d7590 100644 --- 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 @@ -4,7 +4,7 @@ import { TrustAccessService } from './trust-access.service'; jest.mock('@db', () => ({ db: { trust: { - findFirst: jest.fn(), + findUnique: jest.fn(), }, }, Prisma: {}, @@ -18,7 +18,7 @@ jest.mock('../app/s3', () => ({ })); const mockDb = db as unknown as { - trust: { findFirst: jest.Mock }; + trust: { findUnique: jest.Mock }; }; describe('TrustAccessService.getPublicSecurityQuestionnaireEnabled', () => { @@ -34,22 +34,38 @@ describe('TrustAccessService.getPublicSecurityQuestionnaireEnabled', () => { jest.clearAllMocks(); }); - it('resolves by friendlyUrl OR organizationId', async () => { - mockDb.trust.findFirst.mockResolvedValue({ + 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.findFirst).toHaveBeenCalledWith({ - where: { OR: [{ friendlyUrl: 'acme' }, { organizationId: 'acme' }] }, + 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.findFirst.mockResolvedValue({ + mockDb.trust.findUnique.mockResolvedValue({ securityQuestionnaireEnabled: true, }); @@ -59,7 +75,7 @@ describe('TrustAccessService.getPublicSecurityQuestionnaireEnabled', () => { }); it('defaults to enabled when the portal cannot be resolved', async () => { - mockDb.trust.findFirst.mockResolvedValue(null); + mockDb.trust.findUnique.mockResolvedValue(null); await expect( service.getPublicSecurityQuestionnaireEnabled('unknown'), diff --git a/apps/api/src/trust-portal/trust-access.service.ts b/apps/api/src/trust-portal/trust-access.service.ts index 9f9054245..3463e1cf9 100644 --- a/apps/api/src/trust-portal/trust-access.service.ts +++ b/apps/api/src/trust-portal/trust-access.service.ts @@ -1531,7 +1531,9 @@ export class TrustAccessService { }; } - private async getTrustBrandingByOrganizationId(organizationId: string): Promise<{ + private async getTrustBrandingByOrganizationId( + organizationId: string, + ): Promise<{ friendlyUrl: string; faviconUrl: string | null; securityQuestionnaireEnabled: boolean; @@ -2733,13 +2735,22 @@ export class TrustAccessService { async getPublicSecurityQuestionnaireEnabled( friendlyUrl: string, ): Promise { - const trust = await db.trust.findFirst({ - where: { - OR: [{ friendlyUrl }, { organizationId: friendlyUrl }], - }, + // Resolve by friendlyUrl first, then fall back to organizationId — mirrors + // getPublicFavicon. 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 that org's setting. + let trust = await db.trust.findUnique({ + where: { friendlyUrl }, select: { securityQuestionnaireEnabled: true }, }); + if (!trust) { + trust = await db.trust.findUnique({ + where: { organizationId: friendlyUrl }, + select: { securityQuestionnaireEnabled: true }, + }); + } + return trust?.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 index e160e78ec..8fd95c6fe 100644 --- 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 @@ -1,11 +1,11 @@ -import { act, fireEvent, render, screen } from '@testing-library/react'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; import { - setMockPermissions, 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() })); @@ -87,4 +87,20 @@ describe('TrustPortalQuestionnaire', () => { }); 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 index 854990a82..7d578e547 100644 --- 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 @@ -3,7 +3,7 @@ import { usePermissions } from '@/hooks/use-permissions'; import { useTrustPortalSettings } from '@/hooks/use-trust-portal-settings'; import { View, ViewOff } from '@trycompai/design-system/icons'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { toast } from 'sonner'; interface TrustPortalQuestionnaireProps { @@ -11,16 +11,19 @@ interface TrustPortalQuestionnaireProps { orgId: string; } -export function TrustPortalQuestionnaire({ - initialEnabled, - orgId, -}: TrustPortalQuestionnaireProps) { +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. @@ -51,6 +54,7 @@ export function TrustPortalQuestionnaire({ type="button" onClick={() => handleToggleChange(true)} disabled={!canUpdate || isSaving} + aria-pressed={enabled} className={`flex items-center gap-1 px-2 py-1 font-medium transition-colors ${canUpdate ? 'cursor-pointer' : 'cursor-default opacity-70'} ${ enabled ? 'bg-primary/10 text-primary dark:brightness-175' @@ -64,6 +68,7 @@ export function TrustPortalQuestionnaire({ type="button" onClick={() => handleToggleChange(false)} disabled={!canUpdate || isSaving} + aria-pressed={!enabled} className={`flex items-center gap-1 px-2 py-1 font-medium transition-colors ${canUpdate ? 'cursor-pointer' : 'cursor-default opacity-70'} ${ !enabled ? 'bg-orange-100 text-orange-600 dark:bg-orange-950/30 dark:text-orange-400' @@ -79,13 +84,13 @@ export function TrustPortalQuestionnaire({

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. + 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. + 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.

From eb3c097a44b5482da569eb8745548b0d233563ac Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Mon, 6 Jul 2026 12:17:56 -0400 Subject: [PATCH 09/10] fix(risk-treatment): harden orphan task-vector prune (CS-681 review) Resolve the cubic review findings on the CS-681 orphan-vector sweep: - Recover the raw source id from the `task_${org}_` embedding-id prefix when a vector has no `metadata.sourceId`, instead of treating the prefixed id as a raw id. The old fallback would fail the live-scope check and delete a LIVE task's vector, and push a bogus id whose hash-clear matched no row. Applied the same fallback to findSimilarTasks, which would otherwise silently drop such tasks from suggestions. - Enumerate the org's task vectors with cursor-paginated `range` over their id prefix rather than a single top-1000 `query`, removing the silent cap that let large orgs retain unscanned orphans. Drops the fake probe vector; adds a page-count backstop that warns rather than looping forever. - Delete per batch with continue-on-failure and return only the sourceIds whose vector was actually deleted. A transient Upstash error no longer aborts the remaining batches or blocks hash-clearing for earlier successful batches, so a task can never end up with a cached hash and no vector (which would skip re-embedding forever). Adds unit tests for the prefix fallback, cursor pagination across pages, and resilient per-batch deletes. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01PVoGSrLhLrehdzKFPojSbj --- apps/app/src/lib/embedding/embedding.spec.ts | 147 +++++++++++++++++-- apps/app/src/lib/embedding/index.ts | 136 +++++++++++------ 2 files changed, 228 insertions(+), 55 deletions(-) diff --git a/apps/app/src/lib/embedding/embedding.spec.ts b/apps/app/src/lib/embedding/embedding.spec.ts index aec3ad5d8..3c6d6a20b 100644 --- a/apps/app/src/lib/embedding/embedding.spec.ts +++ b/apps/app/src/lib/embedding/embedding.spec.ts @@ -5,6 +5,7 @@ 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(() => ({ @@ -12,6 +13,7 @@ vi.mock('@upstash/vector', () => ({ query: queryMock, info: infoMock, delete: deleteMock, + range: rangeMock, })), })); @@ -32,7 +34,6 @@ import { findSimilarTasks, waitForIndexed, pruneOrphanTaskVectors, - type EntityKind, } from './index'; beforeEach(() => { @@ -40,6 +41,7 @@ beforeEach(() => { 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'; }); @@ -200,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', () => { @@ -251,11 +269,16 @@ describe('waitForIndexed', () => { }); 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 () => { - queryMock.mockResolvedValueOnce([ - { id: 'task_org_1_tsk_live', score: 0.9, metadata: { sourceId: 'tsk_live' } }, - { id: 'task_org_1_tsk_orphan1', score: 0.8, metadata: { sourceId: 'tsk_orphan1' } }, - { id: 'task_org_1_tsk_orphan2', score: 0.7, metadata: { sourceId: 'tsk_orphan2' } }, + 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 }); @@ -264,14 +287,17 @@ describe('pruneOrphanTaskVectors', () => { liveTaskIds: new Set(['tsk_live']), }); - // Enumerates the org's task vectors with the org + task filter at max topK. - expect(queryMock).toHaveBeenCalledWith( + // Enumerates the org's task vectors via a prefix range (no topK ceiling), + // starting from cursor '0'. + expect(rangeMock).toHaveBeenCalledWith( expect.objectContaining({ - topK: 1000, + cursor: '0', + prefix: 'task_org_1_', includeMetadata: true, - filter: 'organizationId = "org_1" AND sourceType = "task"', }), ); + // 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([ @@ -283,9 +309,9 @@ describe('pruneOrphanTaskVectors', () => { }); it('does not call delete when every task vector is still live', async () => { - queryMock.mockResolvedValueOnce([ - { id: 'task_org_1_tsk_a', score: 0.9, metadata: { sourceId: 'tsk_a' } }, - { id: 'task_org_1_tsk_b', score: 0.8, metadata: { sourceId: 'tsk_b' } }, + 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({ @@ -302,10 +328,9 @@ describe('pruneOrphanTaskVectors', () => { // 250 orphans → three delete batches (100 + 100 + 50). const vectors = Array.from({ length: 250 }, (_, i) => ({ id: `task_org_1_tsk_${i}`, - score: 0.5, metadata: { sourceId: `tsk_${i}` }, })); - queryMock.mockResolvedValueOnce(vectors); + onePage(vectors); deleteMock.mockResolvedValue({ deleted: 100 }); const result = await pruneOrphanTaskVectors({ @@ -319,4 +344,98 @@ describe('pruneOrphanTaskVectors', () => { 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 ab2947e3a..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,11 +57,13 @@ export interface SimilarTaskResult { const EMBEDDING_MODEL = 'text-embedding-3-large'; const EMBEDDING_DIMENSIONS = 1536; const DEFAULT_TOP_K = 25; -// Upstash Vector's max topK. A filtered query at this depth returns every one -// of an org's task vectors (real orgs have far fewer than 1000), which is how -// `pruneOrphanTaskVectors` enumerates them for the stale-vector sweep — Upstash -// exposes no filtered range scan over the shared index. -const ORPHAN_SCAN_TOP_K = 1000; +// 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; @@ -78,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; } /** @@ -192,7 +215,7 @@ 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, }; @@ -216,13 +239,15 @@ export interface PruneOrphanTaskVectorsResult { * 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). * - * Upstash exposes no *filtered* range scan over the shared index, but a filtered - * `query` at the max topK returns every matching vector when the org has ≤ topK - * of them (real orgs do), and the probe vector only affects ordering — never - * membership — so a neutral probe suffices to enumerate them. The returned + * 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. + * 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, @@ -233,41 +258,70 @@ export async function pruneOrphanTaskVectors({ liveTaskIds: Set; }): Promise { const index = getIndex(); - - // Constant probe — enumeration only needs *membership*, not order. At the max - // topK, Upstash returns every one of the org's (filtered) task vectors when it - // has ≤ topK of them, whatever the probe, so we skip the cost of embedding a - // real query. A uniform non-zero vector has a valid cosine norm. - const probe = Array.from({ length: EMBEDDING_DIMENSIONS }, () => 1); - - const results = await index.query({ - vector: probe, - topK: ORPHAN_SCAN_TOP_K, - includeMetadata: true, - filter: `organizationId = "${organizationId}" AND sourceType = "task"`, - }); - - const orphanVectorIds: string[] = []; - const deletedSourceIds: string[] = []; - for (const r of results) { - const meta = (r.metadata ?? {}) as { sourceId?: string }; - const sourceId = meta.sourceId ?? String(r.id); - if (liveTaskIds.has(sourceId)) continue; - orphanVectorIds.push(String(r.id)); - deletedSourceIds.push(sourceId); + 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 (orphanVectorIds.length === 0) { - return { deletedSourceIds: [], scanned: results.length }; + if (orphans.length === 0) { + return { deletedSourceIds: [], scanned }; } - // Batch the deletes to mirror the existing vector-sync cleanup path. + // 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 < orphanVectorIds.length; i += BATCH) { - await index.delete(orphanVectorIds.slice(i, i + BATCH)); + 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: results.length }; + return { deletedSourceIds, scanned }; } interface WaitForIndexedOptions { From f5551854fd2b4e329b40c29efa3cd505cd50f2c7 Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Mon, 6 Jul 2026 12:20:07 -0400 Subject: [PATCH 10/10] refactor(trust): share the public trust lookup + clarify a test name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract resolveTrustByFriendlyUrl() and use it in both getPublicFavicon and getPublicSecurityQuestionnaireEnabled so the friendlyUrl → organizationId fallback lives in one typed helper (addresses cubic drift concern). - Rename the toggle test to "saves enabled=false when an admin clicks Hidden" to match the updateSecurityQuestionnaireEnabled(false) behavior. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_013rNndGzad97VwwQvQGT4pg --- .../src/trust-portal/trust-access.service.ts | 47 +++++++++---------- .../TrustPortalQuestionnaire.test.tsx | 2 +- 2 files changed, 24 insertions(+), 25 deletions(-) diff --git a/apps/api/src/trust-portal/trust-access.service.ts b/apps/api/src/trust-portal/trust-access.service.ts index 826c7335a..0ada81b0e 100644 --- a/apps/api/src/trust-portal/trust-access.service.ts +++ b/apps/api/src/trust-portal/trust-access.service.ts @@ -2740,22 +2740,10 @@ export class TrustAccessService { async getPublicSecurityQuestionnaireEnabled( friendlyUrl: string, ): Promise { - // Resolve by friendlyUrl first, then fall back to organizationId — mirrors - // getPublicFavicon. 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 that org's setting. - let trust = await db.trust.findUnique({ - where: { friendlyUrl }, - select: { securityQuestionnaireEnabled: true }, + const trust = await this.resolveTrustByFriendlyUrl(friendlyUrl, { + securityQuestionnaireEnabled: true, }); - if (!trust) { - trust = await db.trust.findUnique({ - where: { organizationId: friendlyUrl }, - select: { securityQuestionnaireEnabled: true }, - }); - } - return trust?.securityQuestionnaireEnabled ?? true; } @@ -2784,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/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 index 8fd95c6fe..2ff962895 100644 --- 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 @@ -61,7 +61,7 @@ describe('TrustPortalQuestionnaire', () => { expect(screen.getByText('Hidden').closest('button')).toBeDisabled(); }); - it('saves disabled=false when an admin clicks Hidden', async () => { + it('saves enabled=false when an admin clicks Hidden', async () => { setMockPermissions(ADMIN_PERMISSIONS); render(); await act(async () => {