From fa3b4b23ea56f077ac9a748333645e338884ea91 Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Mon, 25 May 2026 11:26:25 -0400 Subject: [PATCH 1/9] fix(controls): include direct policy/task links in custom framework view Custom frameworks never populate FrameworkControlPolicyLink/TaskLink junction tables, so findOneForFramework returned empty policies/tasks. Now merges both framework-scoped and direct relationships with dedup. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../api/src/controls/controls.service.spec.ts | 166 ++++++++++++++++++ apps/api/src/controls/controls.service.ts | 19 +- 2 files changed, 183 insertions(+), 2 deletions(-) create mode 100644 apps/api/src/controls/controls.service.spec.ts diff --git a/apps/api/src/controls/controls.service.spec.ts b/apps/api/src/controls/controls.service.spec.ts new file mode 100644 index 000000000..3b91f0025 --- /dev/null +++ b/apps/api/src/controls/controls.service.spec.ts @@ -0,0 +1,166 @@ +import { NotFoundException } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { ControlsService } from './controls.service'; + +jest.mock('../auth/auth.server', () => ({ + auth: { api: { getSession: jest.fn() } }, +})); + +jest.mock('@trycompai/auth', () => ({ + statement: { control: ['create', 'read', 'update', 'delete'] }, + BUILT_IN_ROLE_PERMISSIONS: {}, +})); + +const mockDb = { + frameworkInstance: { findUnique: jest.fn() }, + control: { findUnique: jest.fn() }, + evidenceFormSetting: { findMany: jest.fn() }, + evidenceSubmission: { groupBy: jest.fn() }, +}; + +jest.mock('@db', () => ({ + db: new Proxy( + {}, + { + get: (_target, prop) => + (mockDb as Record)[prop as string] ?? {}, + }, + ), + EvidenceFormType: {}, + Prisma: { SortOrder: { asc: 'asc', desc: 'desc' } }, +})); + +describe('ControlsService', () => { + let service: ControlsService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ControlsService], + }).compile(); + + service = module.get(ControlsService); + jest.clearAllMocks(); + }); + + describe('findOne with frameworkInstanceId (findOneForFramework)', () => { + const orgId = 'org_1'; + const controlId = 'ctrl_1'; + const frameworkInstanceId = 'fi_custom_1'; + + const policyA = { + id: 'pol_a', + name: 'Policy A', + status: 'published', + archivedAt: null, + }; + const policyB = { + id: 'pol_b', + name: 'Policy B', + status: 'draft', + archivedAt: null, + }; + const taskA = { + id: 'task_a', + title: 'Task A', + status: 'done', + archivedAt: null, + }; + const taskB = { + id: 'task_b', + title: 'Task B', + status: 'todo', + archivedAt: null, + }; + + beforeEach(() => { + mockDb.frameworkInstance.findUnique.mockResolvedValue({ + id: frameworkInstanceId, + }); + mockDb.evidenceFormSetting.findMany.mockResolvedValue([]); + mockDb.evidenceSubmission.groupBy.mockResolvedValue([]); + }); + + it('should include directly-linked policies/tasks for custom frameworks with no framework-scoped links', async () => { + mockDb.control.findUnique.mockResolvedValue({ + id: controlId, + organizationId: orgId, + policies: [policyA, policyB], + tasks: [taskA, taskB], + frameworkPolicyLinks: [], + frameworkTaskLinks: [], + frameworkDocumentLinks: [], + requirementsMapped: [], + }); + + const result = await service.findOne( + controlId, + orgId, + frameworkInstanceId, + ); + + expect(result.policies).toEqual([policyA, policyB]); + expect(result.tasks).toEqual([taskA, taskB]); + expect(result.progress.total).toBe(4); + }); + + it('should include framework-scoped links when they exist', async () => { + mockDb.control.findUnique.mockResolvedValue({ + id: controlId, + organizationId: orgId, + policies: [], + tasks: [], + frameworkPolicyLinks: [{ policy: policyA }], + frameworkTaskLinks: [{ task: taskA }], + frameworkDocumentLinks: [], + requirementsMapped: [], + }); + + const result = await service.findOne( + controlId, + orgId, + frameworkInstanceId, + ); + + expect(result.policies).toEqual([policyA]); + expect(result.tasks).toEqual([taskA]); + }); + + it('should deduplicate when a policy/task exists in both direct and framework-scoped links', async () => { + mockDb.control.findUnique.mockResolvedValue({ + id: controlId, + organizationId: orgId, + policies: [policyA, policyB], + tasks: [taskA], + frameworkPolicyLinks: [{ policy: policyA }], + frameworkTaskLinks: [{ task: taskA }, { task: taskB }], + frameworkDocumentLinks: [], + requirementsMapped: [], + }); + + const result = await service.findOne( + controlId, + orgId, + frameworkInstanceId, + ); + + expect(result.policies).toHaveLength(2); + expect(result.policies.map((p: { id: string }) => p.id)).toEqual([ + 'pol_a', + 'pol_b', + ]); + expect(result.tasks).toHaveLength(2); + expect(result.tasks.map((t: { id: string }) => t.id)).toEqual([ + 'task_a', + 'task_b', + ]); + }); + + it('should throw NotFoundException when control does not exist', async () => { + mockDb.control.findUnique.mockResolvedValue(null); + + await expect( + service.findOne(controlId, orgId, frameworkInstanceId), + ).rejects.toThrow(NotFoundException); + }); + }); +}); diff --git a/apps/api/src/controls/controls.service.ts b/apps/api/src/controls/controls.service.ts index 9476b28ba..f72e49da0 100644 --- a/apps/api/src/controls/controls.service.ts +++ b/apps/api/src/controls/controls.service.ts @@ -11,6 +11,15 @@ import { CreateControlDto } from './dto/create-control.dto'; // directly to the FI itself (per-instance custom requirement on a platform // framework). The CustomRequirement schema's CHECK enforces that exactly one // of customFrameworkId / frameworkInstanceId is set. +function deduplicateById(items: T[]): T[] { + const seen = new Set(); + return items.filter((item) => { + if (seen.has(item.id)) return false; + seen.add(item.id); + return true; + }); +} + function isCustomReqOnInstance( req: { customFrameworkId: string | null; @@ -209,6 +218,8 @@ export class ControlsService { const control = await db.control.findUnique({ where: { id: controlId, organizationId }, include: { + policies: { where: { archivedAt: null } }, + tasks: { where: { archivedAt: null } }, frameworkPolicyLinks: { where: { frameworkInstanceId, @@ -243,8 +254,10 @@ export class ControlsService { throw new NotFoundException('Control not found'); } - const policies = control.frameworkPolicyLinks.map((link) => link.policy); - const tasks = control.frameworkTaskLinks.map((link) => link.task); + const frameworkPolicies = control.frameworkPolicyLinks.map((link) => link.policy); + const frameworkTasks = control.frameworkTaskLinks.map((link) => link.task); + const policies = deduplicateById([...frameworkPolicies, ...control.policies]); + const tasks = deduplicateById([...frameworkTasks, ...control.tasks]); const controlDocumentTypes = control.frameworkDocumentLinks; const formTypes = controlDocumentTypes.map((d) => d.formType); const notRelevantSettings = @@ -287,6 +300,8 @@ export class ControlsService { frameworkPolicyLinks, frameworkTaskLinks, frameworkDocumentLinks, + policies: _directPolicies, + tasks: _directTasks, ...controlData } = control; From 2636057fd42d3ef4b724eb1a8d886cf3d81007e3 Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Mon, 25 May 2026 11:32:24 -0400 Subject: [PATCH 2/9] fix(controls): scope direct-link fallback to custom frameworks only Avoids regression for built-in frameworks where framework-scoped links are intentionally per-framework. Also applies the same fix to findRequirement() in frameworks service for list/detail consistency. Extracts deduplicateById to shared util. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../api/src/controls/controls.service.spec.ts | 163 ++++++++++-------- apps/api/src/controls/controls.service.ts | 21 +-- apps/api/src/frameworks/frameworks.service.ts | 11 +- apps/api/src/utils/deduplicate.ts | 8 + 4 files changed, 115 insertions(+), 88 deletions(-) create mode 100644 apps/api/src/utils/deduplicate.ts diff --git a/apps/api/src/controls/controls.service.spec.ts b/apps/api/src/controls/controls.service.spec.ts index 3b91f0025..9bb229fb2 100644 --- a/apps/api/src/controls/controls.service.spec.ts +++ b/apps/api/src/controls/controls.service.spec.ts @@ -22,8 +22,9 @@ jest.mock('@db', () => ({ db: new Proxy( {}, { - get: (_target, prop) => - (mockDb as Record)[prop as string] ?? {}, + get(_target, prop) { + return mockDb[prop] ?? {}; + }, }, ), EvidenceFormType: {}, @@ -38,14 +39,13 @@ describe('ControlsService', () => { providers: [ControlsService], }).compile(); - service = module.get(ControlsService); + service = module.get(ControlsService); jest.clearAllMocks(); }); - describe('findOne with frameworkInstanceId (findOneForFramework)', () => { + describe('findOne with frameworkInstanceId', () => { const orgId = 'org_1'; const controlId = 'ctrl_1'; - const frameworkInstanceId = 'fi_custom_1'; const policyA = { id: 'pol_a', @@ -73,93 +73,108 @@ describe('ControlsService', () => { }; beforeEach(() => { - mockDb.frameworkInstance.findUnique.mockResolvedValue({ - id: frameworkInstanceId, - }); mockDb.evidenceFormSetting.findMany.mockResolvedValue([]); mockDb.evidenceSubmission.groupBy.mockResolvedValue([]); }); - it('should include directly-linked policies/tasks for custom frameworks with no framework-scoped links', async () => { - mockDb.control.findUnique.mockResolvedValue({ - id: controlId, - organizationId: orgId, - policies: [policyA, policyB], - tasks: [taskA, taskB], - frameworkPolicyLinks: [], - frameworkTaskLinks: [], - frameworkDocumentLinks: [], - requirementsMapped: [], - }); - - const result = await service.findOne( - controlId, - orgId, - frameworkInstanceId, - ); - - expect(result.policies).toEqual([policyA, policyB]); - expect(result.tasks).toEqual([taskA, taskB]); - expect(result.progress.total).toBe(4); - }); + describe('custom framework', () => { + const frameworkInstanceId = 'fi_custom_1'; - it('should include framework-scoped links when they exist', async () => { - mockDb.control.findUnique.mockResolvedValue({ - id: controlId, - organizationId: orgId, - policies: [], - tasks: [], - frameworkPolicyLinks: [{ policy: policyA }], - frameworkTaskLinks: [{ task: taskA }], - frameworkDocumentLinks: [], - requirementsMapped: [], + beforeEach(() => { + mockDb.frameworkInstance.findUnique.mockResolvedValue({ + id: frameworkInstanceId, + customFrameworkId: 'cf_1', + }); }); - const result = await service.findOne( - controlId, - orgId, - frameworkInstanceId, - ); + it('should include directly-linked policies/tasks when no framework-scoped links exist', async () => { + mockDb.control.findUnique.mockResolvedValue({ + id: controlId, + organizationId: orgId, + policies: [policyA, policyB], + tasks: [taskA, taskB], + frameworkPolicyLinks: [], + frameworkTaskLinks: [], + frameworkDocumentLinks: [], + requirementsMapped: [], + }); + + const result = await service.findOne( + controlId, + orgId, + frameworkInstanceId, + ); + + expect(result.policies).toEqual([policyA, policyB]); + expect(result.tasks).toEqual([taskA, taskB]); + expect(result.progress.total).toBe(4); + }); - expect(result.policies).toEqual([policyA]); - expect(result.tasks).toEqual([taskA]); + it('should deduplicate when policies exist in both direct and framework-scoped links', async () => { + mockDb.control.findUnique.mockResolvedValue({ + id: controlId, + organizationId: orgId, + policies: [policyA, policyB], + tasks: [taskA], + frameworkPolicyLinks: [{ policy: policyA }], + frameworkTaskLinks: [{ task: taskA }, { task: taskB }], + frameworkDocumentLinks: [], + requirementsMapped: [], + }); + + const result = await service.findOne( + controlId, + orgId, + frameworkInstanceId, + ); + + expect(result.policies).toHaveLength(2); + expect(result.tasks).toHaveLength(2); + }); }); - it('should deduplicate when a policy/task exists in both direct and framework-scoped links', async () => { - mockDb.control.findUnique.mockResolvedValue({ - id: controlId, - organizationId: orgId, - policies: [policyA, policyB], - tasks: [taskA], - frameworkPolicyLinks: [{ policy: policyA }], - frameworkTaskLinks: [{ task: taskA }, { task: taskB }], - frameworkDocumentLinks: [], - requirementsMapped: [], + describe('built-in framework', () => { + const frameworkInstanceId = 'fi_builtin_1'; + + beforeEach(() => { + mockDb.frameworkInstance.findUnique.mockResolvedValue({ + id: frameworkInstanceId, + customFrameworkId: null, + }); }); - const result = await service.findOne( - controlId, - orgId, - frameworkInstanceId, - ); - - expect(result.policies).toHaveLength(2); - expect(result.policies.map((p: { id: string }) => p.id)).toEqual([ - 'pol_a', - 'pol_b', - ]); - expect(result.tasks).toHaveLength(2); - expect(result.tasks.map((t: { id: string }) => t.id)).toEqual([ - 'task_a', - 'task_b', - ]); + it('should only show framework-scoped links, not direct links', async () => { + mockDb.control.findUnique.mockResolvedValue({ + id: controlId, + organizationId: orgId, + policies: [policyA, policyB], + tasks: [taskA, taskB], + frameworkPolicyLinks: [{ policy: policyA }], + frameworkTaskLinks: [{ task: taskA }], + frameworkDocumentLinks: [], + requirementsMapped: [], + }); + + const result = await service.findOne( + controlId, + orgId, + frameworkInstanceId, + ); + + expect(result.policies).toEqual([policyA]); + expect(result.tasks).toEqual([taskA]); + }); }); it('should throw NotFoundException when control does not exist', async () => { + mockDb.frameworkInstance.findUnique.mockResolvedValue({ + id: 'fi_1', + customFrameworkId: null, + }); mockDb.control.findUnique.mockResolvedValue(null); await expect( - service.findOne(controlId, orgId, frameworkInstanceId), + service.findOne(controlId, orgId, 'fi_1'), ).rejects.toThrow(NotFoundException); }); }); diff --git a/apps/api/src/controls/controls.service.ts b/apps/api/src/controls/controls.service.ts index f72e49da0..98cb3c785 100644 --- a/apps/api/src/controls/controls.service.ts +++ b/apps/api/src/controls/controls.service.ts @@ -5,21 +5,13 @@ import { } from '@nestjs/common'; import { db, EvidenceFormType, Prisma } from '@db'; import { CreateControlDto } from './dto/create-control.dto'; +import { deduplicateById } from '../utils/deduplicate'; // A CustomRequirement is valid for a given FrameworkInstance when its parent // matches: either it lives on the FI's CustomFramework, or it was attached // directly to the FI itself (per-instance custom requirement on a platform // framework). The CustomRequirement schema's CHECK enforces that exactly one // of customFrameworkId / frameworkInstanceId is set. -function deduplicateById(items: T[]): T[] { - const seen = new Set(); - return items.filter((item) => { - if (seen.has(item.id)) return false; - seen.add(item.id); - return true; - }); -} - function isCustomReqOnInstance( req: { customFrameworkId: string | null; @@ -214,7 +206,8 @@ export class ControlsService { organizationId: string, frameworkInstanceId: string, ) { - await this.ensureFrameworkInstance(frameworkInstanceId, organizationId); + const fi = await this.ensureFrameworkInstance(frameworkInstanceId, organizationId); + const isCustomFramework = fi.customFrameworkId !== null; const control = await db.control.findUnique({ where: { id: controlId, organizationId }, include: { @@ -256,8 +249,10 @@ export class ControlsService { const frameworkPolicies = control.frameworkPolicyLinks.map((link) => link.policy); const frameworkTasks = control.frameworkTaskLinks.map((link) => link.task); - const policies = deduplicateById([...frameworkPolicies, ...control.policies]); - const tasks = deduplicateById([...frameworkTasks, ...control.tasks]); + const directPolicies = isCustomFramework ? (control.policies ?? []) : []; + const directTasks = isCustomFramework ? (control.tasks ?? []) : []; + const policies = deduplicateById([...frameworkPolicies, ...directPolicies]); + const tasks = deduplicateById([...frameworkTasks, ...directTasks]); const controlDocumentTypes = control.frameworkDocumentLinks; const formTypes = controlDocumentTypes.map((d) => d.formType); const notRelevantSettings = @@ -625,7 +620,7 @@ export class ControlsService { ) { const frameworkInstance = await db.frameworkInstance.findUnique({ where: { id: frameworkInstanceId, organizationId }, - select: { id: true }, + select: { id: true, customFrameworkId: true }, }); if (!frameworkInstance) { throw new NotFoundException('Framework instance not found'); diff --git a/apps/api/src/frameworks/frameworks.service.ts b/apps/api/src/frameworks/frameworks.service.ts index 99b58d839..7dfc5b73d 100644 --- a/apps/api/src/frameworks/frameworks.service.ts +++ b/apps/api/src/frameworks/frameworks.service.ts @@ -5,6 +5,7 @@ import { NotFoundException, } from '@nestjs/common'; import { db, type EvidenceFormType } from '@db'; +import { deduplicateById } from '../utils/deduplicate'; import { tasks } from '@trigger.dev/sdk'; import { @@ -765,6 +766,7 @@ export class FrameworksService { throw new NotFoundException('Framework instance not found'); } + const isCustomFramework = fi.customFrameworkId !== null; const allReqDefs = await this.loadRequirementDefinitions(fi); const requirement = allReqDefs.find((r) => r.id === requirementKey); if (!requirement) { @@ -783,6 +785,10 @@ export class FrameworksService { include: { control: { include: { + policies: { + where: { archivedAt: null }, + select: { id: true, name: true, status: true }, + }, frameworkPolicyLinks: { where: { frameworkInstanceId, @@ -849,11 +855,14 @@ export class FrameworksService { const { frameworkPolicyLinks, frameworkDocumentLinks, + policies: directPolicies, ...control } = relatedControl.control; + const frameworkPolicies = frameworkPolicyLinks.map((link) => link.policy); + const extraPolicies = isCustomFramework ? directPolicies : []; return { ...control, - policies: frameworkPolicyLinks.map((link) => link.policy), + policies: deduplicateById([...frameworkPolicies, ...extraPolicies]), controlDocumentTypes: frameworkDocumentLinks.map( (documentType) => ({ ...documentType, diff --git a/apps/api/src/utils/deduplicate.ts b/apps/api/src/utils/deduplicate.ts new file mode 100644 index 000000000..a4646ba5c --- /dev/null +++ b/apps/api/src/utils/deduplicate.ts @@ -0,0 +1,8 @@ +export function deduplicateById(items: T[]): T[] { + const seen = new Set(); + return items.filter((item) => { + if (seen.has(item.id)) return false; + seen.add(item.id); + return true; + }); +} From 5d1c2bc8f099fe11570970a6647fbe5244a14f89 Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Mon, 25 May 2026 12:30:57 -0400 Subject: [PATCH 3/9] fix(controls): write-side sync + complete read fallback for custom frameworks Write side: syncDirectLinksToCustomFrameworks mirrors direct policy/task/ document links into framework-scoped junction tables for all custom FIs using a control. Called from linkPolicies/linkTasks/linkDocumentTypes (no frameworkInstanceId) and linkControlsToRequirement (custom FI). Read side: all 4 read paths (findOneForFramework, findOne, findAll, findRequirement) fall back to direct relationships for custom frameworks, covering existing data without a migration. Extracts mergeControlLinks helper to deduplicate mapping logic between findOne and findRequirement. Collapses deduplicateById/deduplicateByFormType into a generic deduplicateBy. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../api/src/controls/controls.service.spec.ts | 91 +++++- apps/api/src/controls/controls.service.ts | 18 +- .../sync-custom-framework-links.spec.ts | 136 +++++++++ .../controls/sync-custom-framework-links.ts | 87 ++++++ apps/api/src/frameworks/frameworks.service.ts | 276 ++++++++++++------ apps/api/src/utils/deduplicate.ts | 20 +- 6 files changed, 527 insertions(+), 101 deletions(-) create mode 100644 apps/api/src/controls/sync-custom-framework-links.spec.ts create mode 100644 apps/api/src/controls/sync-custom-framework-links.ts diff --git a/apps/api/src/controls/controls.service.spec.ts b/apps/api/src/controls/controls.service.spec.ts index 9bb229fb2..c30abc154 100644 --- a/apps/api/src/controls/controls.service.spec.ts +++ b/apps/api/src/controls/controls.service.spec.ts @@ -11,11 +11,19 @@ jest.mock('@trycompai/auth', () => ({ BUILT_IN_ROLE_PERMISSIONS: {}, })); +jest.mock('./sync-custom-framework-links', () => ({ + syncDirectLinksToCustomFrameworks: jest.fn().mockResolvedValue(undefined), +})); + const mockDb = { frameworkInstance: { findUnique: jest.fn() }, - control: { findUnique: jest.fn() }, + control: { findUnique: jest.fn(), update: jest.fn() }, + policy: { findMany: jest.fn() }, + task: { findMany: jest.fn() }, evidenceFormSetting: { findMany: jest.fn() }, evidenceSubmission: { groupBy: jest.fn() }, + frameworkControlPolicyLink: { createMany: jest.fn() }, + frameworkControlTaskLink: { createMany: jest.fn() }, }; jest.mock('@db', () => ({ @@ -93,6 +101,7 @@ describe('ControlsService', () => { organizationId: orgId, policies: [policyA, policyB], tasks: [taskA, taskB], + controlDocumentTypes: [], frameworkPolicyLinks: [], frameworkTaskLinks: [], frameworkDocumentLinks: [], @@ -116,6 +125,7 @@ describe('ControlsService', () => { organizationId: orgId, policies: [policyA, policyB], tasks: [taskA], + controlDocumentTypes: [], frameworkPolicyLinks: [{ policy: policyA }], frameworkTaskLinks: [{ task: taskA }, { task: taskB }], frameworkDocumentLinks: [], @@ -131,6 +141,29 @@ describe('ControlsService', () => { expect(result.policies).toHaveLength(2); expect(result.tasks).toHaveLength(2); }); + + it('should include direct document types', async () => { + mockDb.control.findUnique.mockResolvedValue({ + id: controlId, + organizationId: orgId, + policies: [], + tasks: [], + controlDocumentTypes: [{ formType: 'SOC2_TYPE2' }], + frameworkPolicyLinks: [], + frameworkTaskLinks: [], + frameworkDocumentLinks: [], + requirementsMapped: [], + }); + + const result = await service.findOne( + controlId, + orgId, + frameworkInstanceId, + ); + + expect(result.controlDocumentTypes).toHaveLength(1); + expect(result.controlDocumentTypes[0].formType).toBe('SOC2_TYPE2'); + }); }); describe('built-in framework', () => { @@ -149,6 +182,7 @@ describe('ControlsService', () => { organizationId: orgId, policies: [policyA, policyB], tasks: [taskA, taskB], + controlDocumentTypes: [{ formType: 'SOC2_TYPE2' }], frameworkPolicyLinks: [{ policy: policyA }], frameworkTaskLinks: [{ task: taskA }], frameworkDocumentLinks: [], @@ -163,6 +197,7 @@ describe('ControlsService', () => { expect(result.policies).toEqual([policyA]); expect(result.tasks).toEqual([taskA]); + expect(result.controlDocumentTypes).toHaveLength(0); }); }); @@ -178,4 +213,58 @@ describe('ControlsService', () => { ).rejects.toThrow(NotFoundException); }); }); + + describe('linkPolicies', () => { + const { syncDirectLinksToCustomFrameworks } = jest.requireMock( + './sync-custom-framework-links', + ); + + it('should sync to custom frameworks when linking without frameworkInstanceId', async () => { + mockDb.control.findUnique.mockResolvedValue({ id: 'ctrl_1' }); + mockDb.policy.findMany.mockResolvedValue([{ id: 'pol_1' }]); + mockDb.control.update.mockResolvedValue({}); + + await service.linkPolicies('ctrl_1', 'org_1', ['pol_1']); + + expect(syncDirectLinksToCustomFrameworks).toHaveBeenCalledWith({ + controlId: 'ctrl_1', + organizationId: 'org_1', + }); + }); + + it('should NOT sync when linking with frameworkInstanceId', async () => { + mockDb.control.findUnique.mockResolvedValue({ id: 'ctrl_1' }); + mockDb.policy.findMany.mockResolvedValue([{ id: 'pol_1' }]); + mockDb.frameworkInstance.findUnique.mockResolvedValue({ + id: 'fi_1', + customFrameworkId: null, + }); + mockDb.frameworkControlPolicyLink.createMany.mockResolvedValue({ + count: 1, + }); + + await service.linkPolicies('ctrl_1', 'org_1', ['pol_1'], 'fi_1'); + + expect(syncDirectLinksToCustomFrameworks).not.toHaveBeenCalled(); + }); + }); + + describe('linkTasks', () => { + const { syncDirectLinksToCustomFrameworks } = jest.requireMock( + './sync-custom-framework-links', + ); + + it('should sync to custom frameworks when linking without frameworkInstanceId', async () => { + mockDb.control.findUnique.mockResolvedValue({ id: 'ctrl_1' }); + mockDb.task.findMany.mockResolvedValue([{ id: 'task_1' }]); + mockDb.control.update.mockResolvedValue({}); + + await service.linkTasks('ctrl_1', 'org_1', ['task_1']); + + expect(syncDirectLinksToCustomFrameworks).toHaveBeenCalledWith({ + controlId: 'ctrl_1', + organizationId: 'org_1', + }); + }); + }); }); diff --git a/apps/api/src/controls/controls.service.ts b/apps/api/src/controls/controls.service.ts index 98cb3c785..c21558577 100644 --- a/apps/api/src/controls/controls.service.ts +++ b/apps/api/src/controls/controls.service.ts @@ -5,7 +5,8 @@ import { } from '@nestjs/common'; import { db, EvidenceFormType, Prisma } from '@db'; import { CreateControlDto } from './dto/create-control.dto'; -import { deduplicateById } from '../utils/deduplicate'; +import { deduplicateById, deduplicateByFormType } from '../utils/deduplicate'; +import { syncDirectLinksToCustomFrameworks } from './sync-custom-framework-links'; // A CustomRequirement is valid for a given FrameworkInstance when its parent // matches: either it lives on the FI's CustomFramework, or it was attached @@ -213,6 +214,7 @@ export class ControlsService { include: { policies: { where: { archivedAt: null } }, tasks: { where: { archivedAt: null } }, + controlDocumentTypes: true, frameworkPolicyLinks: { where: { frameworkInstanceId, @@ -253,7 +255,11 @@ export class ControlsService { const directTasks = isCustomFramework ? (control.tasks ?? []) : []; const policies = deduplicateById([...frameworkPolicies, ...directPolicies]); const tasks = deduplicateById([...frameworkTasks, ...directTasks]); - const controlDocumentTypes = control.frameworkDocumentLinks; + const directDocTypes = isCustomFramework ? control.controlDocumentTypes : []; + const controlDocumentTypes = deduplicateByFormType([ + ...control.frameworkDocumentLinks, + ...directDocTypes, + ]); const formTypes = controlDocumentTypes.map((d) => d.formType); const notRelevantSettings = formTypes.length > 0 @@ -295,8 +301,9 @@ export class ControlsService { frameworkPolicyLinks, frameworkTaskLinks, frameworkDocumentLinks, - policies: _directPolicies, - tasks: _directTasks, + policies: _policies, + tasks: _tasks, + controlDocumentTypes: _controlDocumentTypes, ...controlData } = control; @@ -659,6 +666,7 @@ export class ControlsService { where: { id: controlId }, data: { policies: { connect: policies.map((p) => ({ id: p.id })) } }, }); + await syncDirectLinksToCustomFrameworks({ controlId, organizationId }); } return { count: policies.length }; @@ -695,6 +703,7 @@ export class ControlsService { where: { id: controlId }, data: { tasks: { connect: tasks.map((t) => ({ id: t.id })) } }, }); + await syncDirectLinksToCustomFrameworks({ controlId, organizationId }); } return { count: tasks.length }; @@ -820,6 +829,7 @@ export class ControlsService { data: formTypes.map((formType) => ({ controlId, formType })), skipDuplicates: true, }); + await syncDirectLinksToCustomFrameworks({ controlId, organizationId }); return { count: result.count }; } diff --git a/apps/api/src/controls/sync-custom-framework-links.spec.ts b/apps/api/src/controls/sync-custom-framework-links.spec.ts new file mode 100644 index 000000000..363fe6d6a --- /dev/null +++ b/apps/api/src/controls/sync-custom-framework-links.spec.ts @@ -0,0 +1,136 @@ +import { syncDirectLinksToCustomFrameworks } from './sync-custom-framework-links'; + +const mockDb = { + requirementMap: { findMany: jest.fn() }, + control: { findUnique: jest.fn() }, + frameworkControlPolicyLink: { createMany: jest.fn() }, + frameworkControlTaskLink: { createMany: jest.fn() }, + frameworkControlDocumentTypeLink: { createMany: jest.fn() }, +}; + +jest.mock('@db', () => ({ + db: new Proxy( + {}, + { + get(_target, prop) { + return mockDb[prop] ?? {}; + }, + }, + ), + Prisma: {}, +})); + +describe('syncDirectLinksToCustomFrameworks', () => { + beforeEach(() => jest.clearAllMocks()); + + it('should do nothing when control is not mapped to any custom framework', async () => { + mockDb.requirementMap.findMany.mockResolvedValue([]); + + await syncDirectLinksToCustomFrameworks({ + controlId: 'ctrl_1', + organizationId: 'org_1', + }); + + expect(mockDb.control.findUnique).not.toHaveBeenCalled(); + }); + + it('should create framework-scoped links for all custom FIs', async () => { + mockDb.requirementMap.findMany.mockResolvedValue([ + { frameworkInstanceId: 'fi_1' }, + { frameworkInstanceId: 'fi_2' }, + ]); + mockDb.control.findUnique.mockResolvedValue({ + id: 'ctrl_1', + policies: [{ id: 'pol_a' }, { id: 'pol_b' }], + tasks: [{ id: 'task_a' }], + controlDocumentTypes: [{ formType: 'SOC2_TYPE2' }], + }); + mockDb.frameworkControlPolicyLink.createMany.mockResolvedValue({ + count: 4, + }); + mockDb.frameworkControlTaskLink.createMany.mockResolvedValue({ count: 2 }); + mockDb.frameworkControlDocumentTypeLink.createMany.mockResolvedValue({ + count: 2, + }); + + await syncDirectLinksToCustomFrameworks({ + controlId: 'ctrl_1', + organizationId: 'org_1', + }); + + expect(mockDb.frameworkControlPolicyLink.createMany).toHaveBeenCalledWith({ + data: [ + { + frameworkInstanceId: 'fi_1', + controlId: 'ctrl_1', + policyId: 'pol_a', + }, + { + frameworkInstanceId: 'fi_1', + controlId: 'ctrl_1', + policyId: 'pol_b', + }, + { + frameworkInstanceId: 'fi_2', + controlId: 'ctrl_1', + policyId: 'pol_a', + }, + { + frameworkInstanceId: 'fi_2', + controlId: 'ctrl_1', + policyId: 'pol_b', + }, + ], + skipDuplicates: true, + }); + + expect(mockDb.frameworkControlTaskLink.createMany).toHaveBeenCalledWith({ + data: [ + { frameworkInstanceId: 'fi_1', controlId: 'ctrl_1', taskId: 'task_a' }, + { frameworkInstanceId: 'fi_2', controlId: 'ctrl_1', taskId: 'task_a' }, + ], + skipDuplicates: true, + }); + + expect( + mockDb.frameworkControlDocumentTypeLink.createMany, + ).toHaveBeenCalledWith({ + data: [ + { + frameworkInstanceId: 'fi_1', + controlId: 'ctrl_1', + formType: 'SOC2_TYPE2', + }, + { + frameworkInstanceId: 'fi_2', + controlId: 'ctrl_1', + formType: 'SOC2_TYPE2', + }, + ], + skipDuplicates: true, + }); + }); + + it('should skip empty direct relationships', async () => { + mockDb.requirementMap.findMany.mockResolvedValue([ + { frameworkInstanceId: 'fi_1' }, + ]); + mockDb.control.findUnique.mockResolvedValue({ + id: 'ctrl_1', + policies: [], + tasks: [], + controlDocumentTypes: [], + }); + + await syncDirectLinksToCustomFrameworks({ + controlId: 'ctrl_1', + organizationId: 'org_1', + }); + + expect(mockDb.frameworkControlPolicyLink.createMany).not.toHaveBeenCalled(); + expect(mockDb.frameworkControlTaskLink.createMany).not.toHaveBeenCalled(); + expect( + mockDb.frameworkControlDocumentTypeLink.createMany, + ).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/controls/sync-custom-framework-links.ts b/apps/api/src/controls/sync-custom-framework-links.ts new file mode 100644 index 000000000..6e0a70076 --- /dev/null +++ b/apps/api/src/controls/sync-custom-framework-links.ts @@ -0,0 +1,87 @@ +import { db, Prisma } from '@db'; + +type DbClient = Prisma.TransactionClient | typeof db; + +export async function syncDirectLinksToCustomFrameworks({ + controlId, + organizationId, + client, +}: { + controlId: string; + organizationId: string; + client?: DbClient; +}) { + const prisma = client ?? db; + + const customFiIds = await prisma.requirementMap.findMany({ + where: { + controlId, + archivedAt: null, + frameworkInstance: { + organizationId, + customFrameworkId: { not: null }, + }, + }, + select: { frameworkInstanceId: true }, + distinct: ['frameworkInstanceId'], + }); + + if (customFiIds.length === 0) return; + + const control = await prisma.control.findUnique({ + where: { id: controlId, organizationId }, + include: { + policies: { + where: { archivedAt: null }, + select: { id: true }, + }, + tasks: { + where: { archivedAt: null }, + select: { id: true }, + }, + controlDocumentTypes: { + select: { formType: true }, + }, + }, + }); + + if (!control) return; + + const fiIds = customFiIds.map((r) => r.frameworkInstanceId); + + await Promise.all([ + control.policies.length > 0 && + prisma.frameworkControlPolicyLink.createMany({ + data: fiIds.flatMap((frameworkInstanceId) => + control.policies.map((p) => ({ + frameworkInstanceId, + controlId, + policyId: p.id, + })), + ), + skipDuplicates: true, + }), + control.tasks.length > 0 && + prisma.frameworkControlTaskLink.createMany({ + data: fiIds.flatMap((frameworkInstanceId) => + control.tasks.map((t) => ({ + frameworkInstanceId, + controlId, + taskId: t.id, + })), + ), + skipDuplicates: true, + }), + control.controlDocumentTypes.length > 0 && + prisma.frameworkControlDocumentTypeLink.createMany({ + data: fiIds.flatMap((frameworkInstanceId) => + control.controlDocumentTypes.map((d) => ({ + frameworkInstanceId, + controlId, + formType: d.formType, + })), + ), + skipDuplicates: true, + }), + ]); +} diff --git a/apps/api/src/frameworks/frameworks.service.ts b/apps/api/src/frameworks/frameworks.service.ts index 7dfc5b73d..a79833718 100644 --- a/apps/api/src/frameworks/frameworks.service.ts +++ b/apps/api/src/frameworks/frameworks.service.ts @@ -5,7 +5,8 @@ import { NotFoundException, } from '@nestjs/common'; import { db, type EvidenceFormType } from '@db'; -import { deduplicateById } from '../utils/deduplicate'; +import { deduplicateById, deduplicateByFormType } from '../utils/deduplicate'; +import { syncDirectLinksToCustomFrameworks } from '../controls/sync-custom-framework-links'; import { tasks } from '@trigger.dev/sdk'; import { @@ -33,6 +34,50 @@ type RequirementDef = { kind: 'platform' | 'custom'; }; +function mergeControlLinks( + control: { + id: string; + frameworkPolicyLinks: { policy: { id: string; name: string; status: string } }[]; + frameworkDocumentLinks: { formType: EvidenceFormType }[]; + policies: { id: string; name: string; status: string }[]; + controlDocumentTypes: { formType: EvidenceFormType }[]; + [key: string]: unknown; + }, + opts: { + isCustomFramework: boolean; + frameworkInstanceId: string; + notRelevantFormTypes: Set; + }, +) { + const { + frameworkPolicyLinks, + frameworkDocumentLinks, + policies: directPolicies, + controlDocumentTypes: directDocTypes, + ...rest + } = control; + const frameworkPolicies = frameworkPolicyLinks.map((link) => link.policy); + const extraPolicies = opts.isCustomFramework ? directPolicies : []; + const extraDocTypes = opts.isCustomFramework + ? directDocTypes.map((d) => ({ + ...d, + frameworkInstanceId: opts.frameworkInstanceId, + controlId: control.id, + })) + : []; + return { + ...rest, + policies: deduplicateById([...frameworkPolicies, ...extraPolicies]), + controlDocumentTypes: deduplicateByFormType([ + ...(frameworkDocumentLinks || []), + ...extraDocTypes, + ]).map((documentType) => ({ + ...documentType, + isNotRelevant: opts.notRelevantFormTypes.has(documentType.formType), + })), + }; +} + @Injectable() export class FrameworksService { private readonly logger = new Logger(FrameworksService.name); @@ -272,6 +317,11 @@ export class FrameworksService { include: { control: { include: { + policies: { + where: { archivedAt: null }, + select: { id: true, name: true, status: true }, + }, + controlDocumentTypes: true, frameworkPolicyLinks: { where: { frameworkInstanceId, @@ -298,29 +348,19 @@ export class FrameworksService { throw new NotFoundException('Framework instance not found'); } + const isCustomFramework = fi.customFrameworkId !== null; const notRelevantFormTypes = await this.getNotRelevantFormTypes(organizationId); + const mergeOpts = { isCustomFramework, frameworkInstanceId, notRelevantFormTypes }; const controlsMap = new Map(); for (const rm of fi.requirementsMapped) { if (rm.control && !controlsMap.has(rm.control.id)) { - const { - requirementsMapped: _, - frameworkPolicyLinks, - frameworkDocumentLinks, - ...controlData - } = rm.control; + const { requirementsMapped: _reqs, ...controlForMerge } = rm.control; + const merged = mergeControlLinks(controlForMerge, mergeOpts); controlsMap.set(rm.control.id, { - ...controlData, - policies: - rm.control.frameworkPolicyLinks?.map((link) => link.policy) || [], + ...merged, requirementsMapped: rm.control.requirementsMapped || [], - controlDocumentTypes: (rm.control.frameworkDocumentLinks || []).map( - (documentType) => ({ - ...documentType, - isNotRelevant: notRelevantFormTypes.has(documentType.formType), - }), - ), }); } } @@ -334,9 +374,11 @@ export class FrameworksService { } } + const controlIds = Array.from(controlsMap.keys()); const [ requirementDefinitions, - tasks, + frameworkTasks, + directTasks, requirementMaps, evidenceSubmissions, ] = await Promise.all([ @@ -354,6 +396,20 @@ export class FrameworksService { }, }, }), + isCustomFramework && controlIds.length > 0 + ? db.task.findMany({ + where: { + organizationId, + archivedAt: null, + controls: { some: { id: { in: controlIds } } }, + }, + include: { + controls: { + where: { id: { in: controlIds } }, + }, + }, + }) + : Promise.resolve([]), db.requirementMap.findMany({ where: { frameworkInstanceId, archivedAt: null }, include: { control: true }, @@ -370,14 +426,28 @@ export class FrameworksService { : Promise.resolve([]), ]); + const mappedFrameworkTasks = frameworkTasks.map( + ({ frameworkControlLinks, ...task }) => ({ + ...task, + controls: frameworkControlLinks.map((link) => link.control), + }), + ); + const mappedDirectTasks = directTasks.map( + ({ controls, ...task }: (typeof directTasks)[number]) => ({ + ...task, + controls, + }), + ); + const allTasks = deduplicateById([ + ...mappedFrameworkTasks, + ...mappedDirectTasks, + ]); + return { ...rest, controls: Array.from(controlsMap.values()), requirementDefinitions, - tasks: tasks.map(({ frameworkControlLinks, ...task }) => ({ - ...task, - controls: frameworkControlLinks.map((link) => link.control), - })), + tasks: allTasks, requirementMaps, evidenceSubmissions, }; @@ -586,6 +656,17 @@ export class FrameworksService { skipDuplicates: true, }); + if (fi.customFrameworkId) { + await Promise.all( + controls.map((c) => + syncDirectLinksToCustomFrameworks({ + controlId: c.id, + organizationId, + }), + ), + ); + } + return { count: result.count }; } @@ -773,60 +854,83 @@ export class FrameworksService { throw new NotFoundException('Requirement not found'); } - const [relatedControls, tasks, notRelevantFormTypes] = await Promise.all([ - db.requirementMap.findMany({ - where: { - frameworkInstanceId, - archivedAt: null, - ...(requirement.kind === 'custom' - ? { customRequirementId: requirementKey } - : { requirementId: requirementKey }), - }, - include: { - control: { - include: { - policies: { - where: { archivedAt: null }, - select: { id: true, name: true, status: true }, - }, - frameworkPolicyLinks: { - where: { - frameworkInstanceId, - policy: { archivedAt: null }, + const [relatedControls, frameworkTasks, notRelevantFormTypes] = + await Promise.all([ + db.requirementMap.findMany({ + where: { + frameworkInstanceId, + archivedAt: null, + ...(requirement.kind === 'custom' + ? { customRequirementId: requirementKey } + : { requirementId: requirementKey }), + }, + include: { + control: { + include: { + policies: { + where: { archivedAt: null }, + select: { id: true, name: true, status: true }, }, - include: { - policy: { - select: { id: true, name: true, status: true }, + controlDocumentTypes: true, + frameworkPolicyLinks: { + where: { + frameworkInstanceId, + policy: { archivedAt: null }, + }, + include: { + policy: { + select: { id: true, name: true, status: true }, + }, }, }, - }, - frameworkDocumentLinks: { - where: { frameworkInstanceId }, + frameworkDocumentLinks: { + where: { frameworkInstanceId }, + }, }, }, }, - }, - }), - db.task.findMany({ - where: { - organizationId, - archivedAt: null, - frameworkControlLinks: { some: { frameworkInstanceId } }, - }, - include: { - frameworkControlLinks: { - where: { frameworkInstanceId }, - include: { control: true }, + }), + db.task.findMany({ + where: { + organizationId, + archivedAt: null, + frameworkControlLinks: { some: { frameworkInstanceId } }, }, - }, - }), - this.getNotRelevantFormTypes(organizationId), - ]); + include: { + frameworkControlLinks: { + where: { frameworkInstanceId }, + include: { control: true }, + }, + }, + }), + this.getNotRelevantFormTypes(organizationId), + ]); + + const controlIds = relatedControls.map((rc) => rc.control.id); + const directTasks = + isCustomFramework && controlIds.length > 0 + ? await db.task.findMany({ + where: { + organizationId, + archivedAt: null, + controls: { some: { id: { in: controlIds } } }, + }, + include: { + controls: { where: { id: { in: controlIds } } }, + }, + }) + : []; + + const mergeOpts = { isCustomFramework, frameworkInstanceId, notRelevantFormTypes }; + const mappedRelatedControls = relatedControls.map((relatedControl) => ({ + ...relatedControl, + control: mergeControlLinks(relatedControl.control, mergeOpts), + })); const formTypes = new Set(); - for (const rc of relatedControls) { - for (const dt of rc.control.frameworkDocumentLinks || []) { - if (notRelevantFormTypes.has(dt.formType)) continue; + for (const rc of mappedRelatedControls) { + for (const dt of rc.control.controlDocumentTypes || []) { + if (dt.isNotRelevant) continue; formTypes.add(dt.formType); } } @@ -847,35 +951,21 @@ export class FrameworksService { .filter((r) => r.id !== requirementKey) .map((r) => ({ id: r.id, name: r.name })); - return { - requirement, - relatedControls: relatedControls.map((relatedControl) => ({ - ...relatedControl, - control: (() => { - const { - frameworkPolicyLinks, - frameworkDocumentLinks, - policies: directPolicies, - ...control - } = relatedControl.control; - const frameworkPolicies = frameworkPolicyLinks.map((link) => link.policy); - const extraPolicies = isCustomFramework ? directPolicies : []; - return { - ...control, - policies: deduplicateById([...frameworkPolicies, ...extraPolicies]), - controlDocumentTypes: frameworkDocumentLinks.map( - (documentType) => ({ - ...documentType, - isNotRelevant: notRelevantFormTypes.has(documentType.formType), - }), - ), - }; - })(), - })), - tasks: tasks.map(({ frameworkControlLinks, ...task }) => ({ + const mappedFrameworkTasks = frameworkTasks.map( + ({ frameworkControlLinks, ...task }) => ({ ...task, controls: frameworkControlLinks.map((link) => link.control), - })), + }), + ); + const mappedDirectTasks = directTasks.map(({ controls, ...task }) => ({ + ...task, + controls, + })); + + return { + requirement, + relatedControls: mappedRelatedControls, + tasks: deduplicateById([...mappedFrameworkTasks, ...mappedDirectTasks]), evidenceSubmissions, siblingRequirements, }; diff --git a/apps/api/src/utils/deduplicate.ts b/apps/api/src/utils/deduplicate.ts index a4646ba5c..f98f53336 100644 --- a/apps/api/src/utils/deduplicate.ts +++ b/apps/api/src/utils/deduplicate.ts @@ -1,8 +1,22 @@ -export function deduplicateById(items: T[]): T[] { +export function deduplicateBy( + items: T[], + key: (item: T) => string, +): T[] { const seen = new Set(); return items.filter((item) => { - if (seen.has(item.id)) return false; - seen.add(item.id); + const k = key(item); + if (seen.has(k)) return false; + seen.add(k); return true; }); } + +export function deduplicateById(items: T[]): T[] { + return deduplicateBy(items, (item) => item.id); +} + +export function deduplicateByFormType( + items: T[], +): T[] { + return deduplicateBy(items, (item) => item.formType); +} From e8e238494b817c68bd96b05a0f013e9bbf6a7f60 Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Mon, 25 May 2026 12:34:49 -0400 Subject: [PATCH 4/9] perf(controls): early exit sync when org has no custom frameworks Avoids the requirementMap query on every direct-link operation for orgs that don't use custom frameworks (vast majority of traffic). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../controls/sync-custom-framework-links.spec.ts | 16 ++++++++++++++++ .../src/controls/sync-custom-framework-links.ts | 5 +++++ 2 files changed, 21 insertions(+) diff --git a/apps/api/src/controls/sync-custom-framework-links.spec.ts b/apps/api/src/controls/sync-custom-framework-links.spec.ts index 363fe6d6a..20b732906 100644 --- a/apps/api/src/controls/sync-custom-framework-links.spec.ts +++ b/apps/api/src/controls/sync-custom-framework-links.spec.ts @@ -1,6 +1,7 @@ import { syncDirectLinksToCustomFrameworks } from './sync-custom-framework-links'; const mockDb = { + frameworkInstance: { count: jest.fn() }, requirementMap: { findMany: jest.fn() }, control: { findUnique: jest.fn() }, frameworkControlPolicyLink: { createMany: jest.fn() }, @@ -23,7 +24,20 @@ jest.mock('@db', () => ({ describe('syncDirectLinksToCustomFrameworks', () => { beforeEach(() => jest.clearAllMocks()); + it('should skip entirely when org has no custom frameworks', async () => { + mockDb.frameworkInstance.count.mockResolvedValue(0); + + await syncDirectLinksToCustomFrameworks({ + controlId: 'ctrl_1', + organizationId: 'org_1', + }); + + expect(mockDb.requirementMap.findMany).not.toHaveBeenCalled(); + expect(mockDb.control.findUnique).not.toHaveBeenCalled(); + }); + it('should do nothing when control is not mapped to any custom framework', async () => { + mockDb.frameworkInstance.count.mockResolvedValue(1); mockDb.requirementMap.findMany.mockResolvedValue([]); await syncDirectLinksToCustomFrameworks({ @@ -35,6 +49,7 @@ describe('syncDirectLinksToCustomFrameworks', () => { }); it('should create framework-scoped links for all custom FIs', async () => { + mockDb.frameworkInstance.count.mockResolvedValue(2); mockDb.requirementMap.findMany.mockResolvedValue([ { frameworkInstanceId: 'fi_1' }, { frameworkInstanceId: 'fi_2' }, @@ -112,6 +127,7 @@ describe('syncDirectLinksToCustomFrameworks', () => { }); it('should skip empty direct relationships', async () => { + mockDb.frameworkInstance.count.mockResolvedValue(1); mockDb.requirementMap.findMany.mockResolvedValue([ { frameworkInstanceId: 'fi_1' }, ]); diff --git a/apps/api/src/controls/sync-custom-framework-links.ts b/apps/api/src/controls/sync-custom-framework-links.ts index 6e0a70076..bd6c679d4 100644 --- a/apps/api/src/controls/sync-custom-framework-links.ts +++ b/apps/api/src/controls/sync-custom-framework-links.ts @@ -13,6 +13,11 @@ export async function syncDirectLinksToCustomFrameworks({ }) { const prisma = client ?? db; + const hasCustomFrameworks = await prisma.frameworkInstance.count({ + where: { organizationId, customFrameworkId: { not: null } }, + }); + if (hasCustomFrameworks === 0) return; + const customFiIds = await prisma.requirementMap.findMany({ where: { controlId, From 12b617165dee8b8fefcbdada816b3c70c8ce6edc Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Mon, 25 May 2026 12:36:00 -0400 Subject: [PATCH 5/9] fix(controls): clean up framework-scoped doc links on direct unlink When unlinking a direct ControlDocumentType, also delete the corresponding FrameworkControlDocumentTypeLink rows for custom framework instances. Prevents stale evidence showing up in custom framework views after the direct link is removed. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/api/src/controls/controls.service.ts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/apps/api/src/controls/controls.service.ts b/apps/api/src/controls/controls.service.ts index c21558577..42a089ef6 100644 --- a/apps/api/src/controls/controls.service.ts +++ b/apps/api/src/controls/controls.service.ts @@ -850,6 +850,29 @@ export class ControlsService { await db.controlDocumentType.deleteMany({ where: { controlId, formType }, }); + const customFiIds = await db.requirementMap.findMany({ + where: { + controlId, + archivedAt: null, + frameworkInstance: { + organizationId, + customFrameworkId: { not: null }, + }, + }, + select: { frameworkInstanceId: true }, + distinct: ['frameworkInstanceId'], + }); + if (customFiIds.length > 0) { + await db.frameworkControlDocumentTypeLink.deleteMany({ + where: { + controlId, + formType, + frameworkInstanceId: { + in: customFiIds.map((r) => r.frameworkInstanceId), + }, + }, + }); + } return { success: true }; } From f6f4b7bbcca2b340f162e80387a05f89c12cbfdf Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Mon, 25 May 2026 12:41:57 -0400 Subject: [PATCH 6/9] fix(controls): only cascade doc-link cleanup when direct link existed Skip framework-scoped cleanup if deleteMany removed 0 direct rows, preventing deletion of explicitly-scoped custom framework links. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/api/src/controls/controls.service.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/api/src/controls/controls.service.ts b/apps/api/src/controls/controls.service.ts index 42a089ef6..9d0aeb677 100644 --- a/apps/api/src/controls/controls.service.ts +++ b/apps/api/src/controls/controls.service.ts @@ -847,9 +847,10 @@ export class ControlsService { }); return { success: true }; } - await db.controlDocumentType.deleteMany({ + const deleted = await db.controlDocumentType.deleteMany({ where: { controlId, formType }, }); + if (deleted.count === 0) return { success: true }; const customFiIds = await db.requirementMap.findMany({ where: { controlId, From ab6a508cd3e71148131af032bd7c0628fc620b83 Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Mon, 25 May 2026 12:53:03 -0400 Subject: [PATCH 7/9] fix(controls): complete custom framework link coverage - removePolicyControl: cascade-delete framework-scoped policy links for custom FIs when disconnecting a policy from a control - findAll: add custom framework fallback for policies, documents, and tasks so dashboard compliance scores are correct - create: sync framework-scoped links within the creation transaction when the control is mapped to custom framework requirements Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/api/src/controls/controls.service.ts | 8 + apps/api/src/frameworks/frameworks.service.ts | 152 ++++++++++++------ apps/api/src/policies/policies.controller.ts | 11 ++ 3 files changed, 119 insertions(+), 52 deletions(-) diff --git a/apps/api/src/controls/controls.service.ts b/apps/api/src/controls/controls.service.ts index 9d0aeb677..62d16dbaa 100644 --- a/apps/api/src/controls/controls.service.ts +++ b/apps/api/src/controls/controls.service.ts @@ -477,6 +477,14 @@ export class ControlsService { }); } + if (scopedRequirementMappings.length > 0) { + await syncDirectLinksToCustomFrameworks({ + controlId: control.id, + organizationId, + client: tx, + }); + } + return control; }); } diff --git a/apps/api/src/frameworks/frameworks.service.ts b/apps/api/src/frameworks/frameworks.service.ts index a79833718..46108a1ef 100644 --- a/apps/api/src/frameworks/frameworks.service.ts +++ b/apps/api/src/frameworks/frameworks.service.ts @@ -202,6 +202,11 @@ export class FrameworksService { include: { control: { include: { + policies: { + where: { archivedAt: null }, + select: { id: true, name: true, status: true }, + }, + controlDocumentTypes: true, frameworkPolicyLinks: { where: { policy: { archivedAt: null } }, include: { @@ -228,35 +233,29 @@ export class FrameworksService { await this.getNotRelevantFormTypes(organizationId); const frameworksWithControls = frameworkInstances.map((fi: any) => { + const isCustomFramework = fi.customFrameworkId !== null; const controlsMap = new Map(); for (const rm of fi.requirementsMapped || []) { if (rm.control && !controlsMap.has(rm.control.id)) { - const { - requirementsMapped: _, - frameworkPolicyLinks, - frameworkDocumentLinks, - ...controlData - } = rm.control; - const policyLinks = rm.control.frameworkPolicyLinks.filter( - (link: { frameworkInstanceId: string }) => - link.frameworkInstanceId === fi.id, - ); - const documentLinks = rm.control.frameworkDocumentLinks.filter( - (link: { frameworkInstanceId: string }) => - link.frameworkInstanceId === fi.id, - ); - controlsMap.set(rm.control.id, { - ...controlData, - policies: policyLinks.map( - (link: { policy: { id: string; name: string; status: string } }) => - link.policy, + const { requirementsMapped: _reqs, ...controlForMerge } = rm.control; + const scopedControl = { + ...controlForMerge, + frameworkPolicyLinks: controlForMerge.frameworkPolicyLinks.filter( + (link: { frameworkInstanceId: string }) => + link.frameworkInstanceId === fi.id, ), - controlDocumentTypes: documentLinks.map( - (documentType: { formType: EvidenceFormType }) => ({ - ...documentType, - isNotRelevant: notRelevantFormTypes.has(documentType.formType), - }), + frameworkDocumentLinks: controlForMerge.frameworkDocumentLinks.filter( + (link: { frameworkInstanceId: string }) => + link.frameworkInstanceId === fi.id, ), + }; + const merged = mergeControlLinks(scopedControl, { + isCustomFramework, + frameworkInstanceId: fi.id, + notRelevantFormTypes, + }); + controlsMap.set(rm.control.id, { + ...merged, requirementsMapped: rm.control.requirementsMapped || [], }); } @@ -269,41 +268,90 @@ export class FrameworksService { return frameworksWithControls; } - const [tasks, evidenceSubmissions] = await Promise.all([ - db.task.findMany({ - where: { - organizationId, - archivedAt: null, - frameworkControlLinks: { - some: { frameworkInstance: { organizationId } }, + const hasCustomFrameworks = frameworkInstances.some( + (fi: any) => fi.customFrameworkId !== null, + ); + const allControlIds = hasCustomFrameworks + ? [ + ...new Set( + frameworksWithControls.flatMap((fw: any) => + fw.controls.map((c: any) => c.id), + ), + ), + ] + : []; + + const [frameworkTasks, directTasks, evidenceSubmissions] = await Promise.all( + [ + db.task.findMany({ + where: { + organizationId, + archivedAt: null, + frameworkControlLinks: { + some: { frameworkInstance: { organizationId } }, + }, }, - }, - include: { - frameworkControlLinks: { - where: { frameworkInstance: { organizationId } }, - include: { control: true }, + include: { + frameworkControlLinks: { + where: { frameworkInstance: { organizationId } }, + include: { control: true }, + }, }, - }, - }), - db.evidenceSubmission.findMany({ - where: { organizationId }, - select: { formType: true, submittedAt: true }, - }), - ]); + }), + hasCustomFrameworks && allControlIds.length > 0 + ? db.task.findMany({ + where: { + organizationId, + archivedAt: null, + controls: { + some: { id: { in: allControlIds as string[] } }, + }, + }, + include: { + controls: { + where: { id: { in: allControlIds as string[] } }, + }, + }, + }) + : Promise.resolve([]), + db.evidenceSubmission.findMany({ + where: { organizationId }, + select: { formType: true, submittedAt: true }, + }), + ], + ); - return frameworksWithControls.map((fw: any) => ({ - ...fw, - complianceScore: computeFrameworkComplianceScore( - fw, - tasks.map(({ frameworkControlLinks, ...task }) => ({ + return frameworksWithControls.map((fw: any) => { + const isCustomFw = fw.customFrameworkId !== null; + const fwControlIds = new Set(fw.controls.map((c: any) => c.id)); + const mappedFrameworkTasks = frameworkTasks.map( + ({ frameworkControlLinks, ...task }) => ({ ...task, controls: frameworkControlLinks .filter((link) => link.frameworkInstanceId === fw.id) .map((link) => link.control), - })), - evidenceSubmissions, - ), - })); + }), + ); + const mappedDirectTasks = isCustomFw + ? directTasks.map(({ controls, ...task }: (typeof directTasks)[number]) => ({ + ...task, + controls: (controls as any[]).filter((c) => fwControlIds.has(c.id)), + })) + : []; + const allTasks = deduplicateById([ + ...mappedFrameworkTasks, + ...mappedDirectTasks, + ]).filter((t) => t.controls.length > 0); + + return { + ...fw, + complianceScore: computeFrameworkComplianceScore( + fw, + allTasks, + evidenceSubmissions, + ), + }; + }); } async findOne(frameworkInstanceId: string, organizationId: string) { diff --git a/apps/api/src/policies/policies.controller.ts b/apps/api/src/policies/policies.controller.ts index 0eab46dc3..8684ede64 100644 --- a/apps/api/src/policies/policies.controller.ts +++ b/apps/api/src/policies/policies.controller.ts @@ -789,6 +789,17 @@ export class PoliciesController { }, }); + await db.frameworkControlPolicyLink.deleteMany({ + where: { + controlId, + policyId: id, + frameworkInstance: { + organizationId, + customFrameworkId: { not: null }, + }, + }, + }); + return { success: true, authType: authContext.authType, From afdd1084c32909cd971bfcd9d2860283aeeed5ee Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Mon, 25 May 2026 13:05:20 -0400 Subject: [PATCH 8/9] fix(controls): guard policy cleanup and fix task dedup ordering - removePolicyControl: check link exists before cascading framework- scoped cleanup, preventing deletion of explicitly-scoped links - findAll: filter empty-controls tasks before dedup and prioritize direct tasks, so custom framework tasks aren't dropped by empty framework-scoped entries shadowing valid direct entries Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/api/src/frameworks/frameworks.service.ts | 4 ++-- apps/api/src/policies/policies.controller.ts | 12 ++++++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/apps/api/src/frameworks/frameworks.service.ts b/apps/api/src/frameworks/frameworks.service.ts index b7b2740e0..66217295e 100644 --- a/apps/api/src/frameworks/frameworks.service.ts +++ b/apps/api/src/frameworks/frameworks.service.ts @@ -351,9 +351,9 @@ export class FrameworksService { })) : []; const allTasks = deduplicateById([ - ...mappedFrameworkTasks, ...mappedDirectTasks, - ]).filter((t) => t.controls.length > 0); + ...mappedFrameworkTasks, + ].filter((t) => t.controls.length > 0)); return { ...fw, diff --git a/apps/api/src/policies/policies.controller.ts b/apps/api/src/policies/policies.controller.ts index 8684ede64..383fcc046 100644 --- a/apps/api/src/policies/policies.controller.ts +++ b/apps/api/src/policies/policies.controller.ts @@ -780,6 +780,10 @@ export class PoliciesController { @OrganizationId() organizationId: string, @AuthContext() authContext: AuthContextType, ) { + const before = await db.policy.findUnique({ + where: { id, organizationId }, + select: { controls: { where: { id: controlId }, select: { id: true } } }, + }); await db.policy.update({ where: { id, organizationId }, data: { @@ -789,6 +793,14 @@ export class PoliciesController { }, }); + if (!before?.controls.length) return { + success: true, + authType: authContext.authType, + ...(authContext.userId && { + authenticatedUser: { id: authContext.userId, email: authContext.userEmail }, + }), + }; + await db.frameworkControlPolicyLink.deleteMany({ where: { controlId, From aa349ab720183f209602bbd0bbf444c82f5dd437 Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Tue, 26 May 2026 09:49:36 -0400 Subject: [PATCH 9/9] fix(controls): wrap unlink cascades in transactions Both removePolicyControl and unlinkDocumentType now run their direct-link removal and framework-scoped cleanup in a single transaction, preventing partial state on failure. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/api/src/controls/controls.service.ts | 48 +++++++++--------- apps/api/src/policies/policies.controller.ts | 51 +++++++++----------- 2 files changed, 47 insertions(+), 52 deletions(-) diff --git a/apps/api/src/controls/controls.service.ts b/apps/api/src/controls/controls.service.ts index 62d16dbaa..ace5ca48c 100644 --- a/apps/api/src/controls/controls.service.ts +++ b/apps/api/src/controls/controls.service.ts @@ -855,34 +855,36 @@ export class ControlsService { }); return { success: true }; } - const deleted = await db.controlDocumentType.deleteMany({ - where: { controlId, formType }, - }); - if (deleted.count === 0) return { success: true }; - const customFiIds = await db.requirementMap.findMany({ - where: { - controlId, - archivedAt: null, - frameworkInstance: { - organizationId, - customFrameworkId: { not: null }, - }, - }, - select: { frameworkInstanceId: true }, - distinct: ['frameworkInstanceId'], - }); - if (customFiIds.length > 0) { - await db.frameworkControlDocumentTypeLink.deleteMany({ + return db.$transaction(async (tx) => { + const deleted = await tx.controlDocumentType.deleteMany({ + where: { controlId, formType }, + }); + if (deleted.count === 0) return { success: true }; + const customFiIds = await tx.requirementMap.findMany({ where: { controlId, - formType, - frameworkInstanceId: { - in: customFiIds.map((r) => r.frameworkInstanceId), + archivedAt: null, + frameworkInstance: { + organizationId, + customFrameworkId: { not: null }, }, }, + select: { frameworkInstanceId: true }, + distinct: ['frameworkInstanceId'], }); - } - return { success: true }; + if (customFiIds.length > 0) { + await tx.frameworkControlDocumentTypeLink.deleteMany({ + where: { + controlId, + formType, + frameworkInstanceId: { + in: customFiIds.map((r) => r.frameworkInstanceId), + }, + }, + }); + } + return { success: true }; + }); } async delete(controlId: string, organizationId: string) { diff --git a/apps/api/src/policies/policies.controller.ts b/apps/api/src/policies/policies.controller.ts index 383fcc046..553dba274 100644 --- a/apps/api/src/policies/policies.controller.ts +++ b/apps/api/src/policies/policies.controller.ts @@ -780,36 +780,29 @@ export class PoliciesController { @OrganizationId() organizationId: string, @AuthContext() authContext: AuthContextType, ) { - const before = await db.policy.findUnique({ - where: { id, organizationId }, - select: { controls: { where: { id: controlId }, select: { id: true } } }, - }); - await db.policy.update({ - where: { id, organizationId }, - data: { - controls: { - disconnect: { id: controlId }, - }, - }, - }); - - if (!before?.controls.length) return { - success: true, - authType: authContext.authType, - ...(authContext.userId && { - authenticatedUser: { id: authContext.userId, email: authContext.userEmail }, - }), - }; - - await db.frameworkControlPolicyLink.deleteMany({ - where: { - controlId, - policyId: id, - frameworkInstance: { - organizationId, - customFrameworkId: { not: null }, + await db.$transaction(async (tx) => { + const before = await tx.policy.findUnique({ + where: { id, organizationId }, + select: { + controls: { where: { id: controlId }, select: { id: true } }, }, - }, + }); + await tx.policy.update({ + where: { id, organizationId }, + data: { controls: { disconnect: { id: controlId } } }, + }); + if (before?.controls.length) { + await tx.frameworkControlPolicyLink.deleteMany({ + where: { + controlId, + policyId: id, + frameworkInstance: { + organizationId, + customFrameworkId: { not: null }, + }, + }, + }); + } }); return {