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 0000000000..c30abc154e --- /dev/null +++ b/apps/api/src/controls/controls.service.spec.ts @@ -0,0 +1,270 @@ +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: {}, +})); + +jest.mock('./sync-custom-framework-links', () => ({ + syncDirectLinksToCustomFrameworks: jest.fn().mockResolvedValue(undefined), +})); + +const mockDb = { + frameworkInstance: { 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', () => ({ + db: new Proxy( + {}, + { + get(_target, prop) { + return mockDb[prop] ?? {}; + }, + }, + ), + 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', () => { + const orgId = 'org_1'; + const controlId = 'ctrl_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.evidenceFormSetting.findMany.mockResolvedValue([]); + mockDb.evidenceSubmission.groupBy.mockResolvedValue([]); + }); + + describe('custom framework', () => { + const frameworkInstanceId = 'fi_custom_1'; + + beforeEach(() => { + mockDb.frameworkInstance.findUnique.mockResolvedValue({ + id: frameworkInstanceId, + customFrameworkId: 'cf_1', + }); + }); + + 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], + controlDocumentTypes: [], + 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 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], + controlDocumentTypes: [], + 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 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', () => { + const frameworkInstanceId = 'fi_builtin_1'; + + beforeEach(() => { + mockDb.frameworkInstance.findUnique.mockResolvedValue({ + id: frameworkInstanceId, + customFrameworkId: null, + }); + }); + + 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], + controlDocumentTypes: [{ formType: 'SOC2_TYPE2' }], + 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]); + expect(result.controlDocumentTypes).toHaveLength(0); + }); + }); + + 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, 'fi_1'), + ).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 9476b28ba6..ace5ca48cf 100644 --- a/apps/api/src/controls/controls.service.ts +++ b/apps/api/src/controls/controls.service.ts @@ -5,6 +5,8 @@ import { } from '@nestjs/common'; import { db, EvidenceFormType, Prisma } from '@db'; import { CreateControlDto } from './dto/create-control.dto'; +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 @@ -205,10 +207,14 @@ 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: { + policies: { where: { archivedAt: null } }, + tasks: { where: { archivedAt: null } }, + controlDocumentTypes: true, frameworkPolicyLinks: { where: { frameworkInstanceId, @@ -243,9 +249,17 @@ 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 controlDocumentTypes = control.frameworkDocumentLinks; + const frameworkPolicies = control.frameworkPolicyLinks.map((link) => link.policy); + const frameworkTasks = control.frameworkTaskLinks.map((link) => link.task); + const directPolicies = isCustomFramework ? (control.policies ?? []) : []; + const directTasks = isCustomFramework ? (control.tasks ?? []) : []; + const policies = deduplicateById([...frameworkPolicies, ...directPolicies]); + const tasks = deduplicateById([...frameworkTasks, ...directTasks]); + const directDocTypes = isCustomFramework ? control.controlDocumentTypes : []; + const controlDocumentTypes = deduplicateByFormType([ + ...control.frameworkDocumentLinks, + ...directDocTypes, + ]); const formTypes = controlDocumentTypes.map((d) => d.formType); const notRelevantSettings = formTypes.length > 0 @@ -287,6 +301,9 @@ export class ControlsService { frameworkPolicyLinks, frameworkTaskLinks, frameworkDocumentLinks, + policies: _policies, + tasks: _tasks, + controlDocumentTypes: _controlDocumentTypes, ...controlData } = control; @@ -460,6 +477,14 @@ export class ControlsService { }); } + if (scopedRequirementMappings.length > 0) { + await syncDirectLinksToCustomFrameworks({ + controlId: control.id, + organizationId, + client: tx, + }); + } + return control; }); } @@ -610,7 +635,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'); @@ -649,6 +674,7 @@ export class ControlsService { where: { id: controlId }, data: { policies: { connect: policies.map((p) => ({ id: p.id })) } }, }); + await syncDirectLinksToCustomFrameworks({ controlId, organizationId }); } return { count: policies.length }; @@ -685,6 +711,7 @@ export class ControlsService { where: { id: controlId }, data: { tasks: { connect: tasks.map((t) => ({ id: t.id })) } }, }); + await syncDirectLinksToCustomFrameworks({ controlId, organizationId }); } return { count: tasks.length }; @@ -810,6 +837,7 @@ export class ControlsService { data: formTypes.map((formType) => ({ controlId, formType })), skipDuplicates: true, }); + await syncDirectLinksToCustomFrameworks({ controlId, organizationId }); return { count: result.count }; } @@ -827,10 +855,36 @@ export class ControlsService { }); return { success: true }; } - await db.controlDocumentType.deleteMany({ - where: { controlId, formType }, + 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, + archivedAt: null, + frameworkInstance: { + organizationId, + customFrameworkId: { not: null }, + }, + }, + select: { frameworkInstanceId: true }, + distinct: ['frameworkInstanceId'], + }); + if (customFiIds.length > 0) { + await tx.frameworkControlDocumentTypeLink.deleteMany({ + where: { + controlId, + formType, + frameworkInstanceId: { + in: customFiIds.map((r) => r.frameworkInstanceId), + }, + }, + }); + } + return { success: true }; }); - return { success: true }; } async delete(controlId: string, organizationId: string) { 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 0000000000..20b732906b --- /dev/null +++ b/apps/api/src/controls/sync-custom-framework-links.spec.ts @@ -0,0 +1,152 @@ +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() }, + 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 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({ + controlId: 'ctrl_1', + organizationId: 'org_1', + }); + + expect(mockDb.control.findUnique).not.toHaveBeenCalled(); + }); + + 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' }, + ]); + 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.frameworkInstance.count.mockResolvedValue(1); + 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 0000000000..bd6c679d41 --- /dev/null +++ b/apps/api/src/controls/sync-custom-framework-links.ts @@ -0,0 +1,92 @@ +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 hasCustomFrameworks = await prisma.frameworkInstance.count({ + where: { organizationId, customFrameworkId: { not: null } }, + }); + if (hasCustomFrameworks === 0) return; + + 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/framework-editor-versions/framework-manifest-builder.ts b/apps/api/src/framework-editor-versions/framework-manifest-builder.ts index 094d47d90c..18c3a49061 100644 --- a/apps/api/src/framework-editor-versions/framework-manifest-builder.ts +++ b/apps/api/src/framework-editor-versions/framework-manifest-builder.ts @@ -60,6 +60,7 @@ export async function buildManifestForFramework(frameworkId: string): Promise r.id) .filter((id) => ownRequirementIds.has(id)), diff --git a/apps/api/src/framework-editor/control-template/control-template.service.ts b/apps/api/src/framework-editor/control-template/control-template.service.ts index 44be100d1e..63d0251a1d 100644 --- a/apps/api/src/framework-editor/control-template/control-template.service.ts +++ b/apps/api/src/framework-editor/control-template/control-template.service.ts @@ -105,6 +105,7 @@ export class ControlTemplateService { data: { name: dto.name, description: dto.description ?? '', + controlFamily: dto.controlFamily || null, }, }); this.logger.log(`Created control template: ${ct.name} (${ct.id})`); @@ -120,6 +121,7 @@ export class ControlTemplateService { data: { name: dto.name, description: dto.description ?? '', + controlFamily: dto.controlFamily || null, }, }); await tx.frameworkEditorControlDocumentTypeLink.createMany({ @@ -144,6 +146,7 @@ export class ControlTemplateService { data: { ...(dto.name !== undefined && { name: dto.name }), ...(dto.description !== undefined && { description: dto.description }), + ...(dto.controlFamily !== undefined && { controlFamily: dto.controlFamily || null }), }, }); this.logger.log(`Updated control template: ${updated.name} (${id})`); @@ -163,6 +166,7 @@ export class ControlTemplateService { data: { ...(dto.name !== undefined && { name: dto.name }), ...(dto.description !== undefined && { description: dto.description }), + ...(dto.controlFamily !== undefined && { controlFamily: dto.controlFamily || null }), }, }); await tx.frameworkEditorControlDocumentTypeLink.deleteMany({ diff --git a/apps/api/src/framework-editor/control-template/dto/create-control-template.dto.ts b/apps/api/src/framework-editor/control-template/dto/create-control-template.dto.ts index 58b523b980..6e595750bb 100644 --- a/apps/api/src/framework-editor/control-template/dto/create-control-template.dto.ts +++ b/apps/api/src/framework-editor/control-template/dto/create-control-template.dto.ts @@ -19,6 +19,12 @@ export class CreateControlTemplateDto { @MaxLength(5000) description: string; + @ApiPropertyOptional({ example: 'AC - Access Control' }) + @IsString() + @IsOptional() + @MaxLength(255) + controlFamily?: string; + @ApiPropertyOptional({ example: ['penetration-test', 'rbac-matrix'] }) @IsArray() @IsString({ each: true }) diff --git a/apps/api/src/framework-editor/framework/dto/import-framework.dto.ts b/apps/api/src/framework-editor/framework/dto/import-framework.dto.ts index 1cede340f7..90bcdd88b4 100644 --- a/apps/api/src/framework-editor/framework/dto/import-framework.dto.ts +++ b/apps/api/src/framework-editor/framework/dto/import-framework.dto.ts @@ -80,6 +80,12 @@ class ImportControlTemplateDto { @MaxLength(5000) description: string; + @ApiPropertyOptional({ example: 'AC - Access Control' }) + @IsString() + @IsOptional() + @MaxLength(255) + controlFamily?: string; + @ApiPropertyOptional() @IsArray() @ArrayMaxSize(50) diff --git a/apps/api/src/framework-editor/framework/framework-export.service.ts b/apps/api/src/framework-editor/framework/framework-export.service.ts index cbb1627c49..009b56fe9f 100644 --- a/apps/api/src/framework-editor/framework/framework-export.service.ts +++ b/apps/api/src/framework-editor/framework/framework-export.service.ts @@ -24,6 +24,7 @@ export interface ExportedFramework { controlTemplates: Array<{ name: string; description: string; + controlFamily: string | null; documentTypes: string[]; requirementIndices: number[]; policyTemplateIndices: number[]; @@ -131,6 +132,7 @@ export class FrameworkExportService { controlTemplates: controlTemplates.map((ct) => ({ name: ct.name, description: ct.description, + controlFamily: ct.controlFamily ?? null, documentTypes: ct.frameworkDocumentLinks.map((link) => link.formType), requirementIndices: ct.requirements .map((r) => reqIdToIndex.get(r.id)) @@ -225,6 +227,7 @@ export class FrameworkExportService { data: { name: ct.name, description: ct.description, + controlFamily: ct.controlFamily ?? null, requirements: { connect: (ct.requirementIndices ?? []).map((i) => ({ id: createdRequirements[i].id, diff --git a/apps/api/src/frameworks/framework-versioning/framework-diff.ts b/apps/api/src/frameworks/framework-versioning/framework-diff.ts index 0901263eb6..f065968142 100644 --- a/apps/api/src/frameworks/framework-versioning/framework-diff.ts +++ b/apps/api/src/frameworks/framework-versioning/framework-diff.ts @@ -169,7 +169,7 @@ function edgesFromControls( } function controlEqual(a: ManifestControl, b: ManifestControl): boolean { - return a.name === b.name && a.description === b.description; + return a.name === b.name && a.description === b.description && (a.controlFamily ?? null) === (b.controlFamily ?? null); } function requirementEqual(a: ManifestRequirement, b: ManifestRequirement): boolean { diff --git a/apps/api/src/frameworks/framework-versioning/framework-rollback.service.ts b/apps/api/src/frameworks/framework-versioning/framework-rollback.service.ts index dc962bab18..180fa415d9 100644 --- a/apps/api/src/frameworks/framework-versioning/framework-rollback.service.ts +++ b/apps/api/src/frameworks/framework-versioning/framework-rollback.service.ts @@ -311,6 +311,30 @@ async function replayUndo( }); } + // Restore control families — guarded so older undo payloads without + // this bucket don't break rollback. + if (ctx.undo.controlFamilies) { + for (const entry of ctx.undo.controlFamilies.created) { + await tx.frameworkControlFamily.deleteMany({ + where: { frameworkInstanceId: entry.frameworkInstanceId, controlId: entry.controlId }, + }); + } + for (const entry of ctx.undo.controlFamilies.updated) { + await tx.frameworkControlFamily.upsert({ + where: { frameworkInstanceId_controlId: { frameworkInstanceId: entry.frameworkInstanceId, controlId: entry.controlId } }, + create: { frameworkInstanceId: entry.frameworkInstanceId, controlId: entry.controlId, controlFamily: entry.prevFamily }, + update: { controlFamily: entry.prevFamily }, + }); + } + for (const entry of ctx.undo.controlFamilies.deleted) { + await tx.frameworkControlFamily.upsert({ + where: { frameworkInstanceId_controlId: { frameworkInstanceId: entry.frameworkInstanceId, controlId: entry.controlId } }, + create: { frameworkInstanceId: entry.frameworkInstanceId, controlId: entry.controlId, controlFamily: entry.prevFamily }, + update: { controlFamily: entry.prevFamily }, + }); + } + } + // Revert framework instance version pointer await tx.frameworkInstance.update({ where: { id: ctx.syncOp.frameworkInstanceId }, diff --git a/apps/api/src/frameworks/framework-versioning/framework-sync-apply.ts b/apps/api/src/frameworks/framework-versioning/framework-sync-apply.ts index 1ad1ab1e12..b19fa5b9d6 100644 --- a/apps/api/src/frameworks/framework-versioning/framework-sync-apply.ts +++ b/apps/api/src/frameworks/framework-versioning/framework-sync-apply.ts @@ -60,6 +60,7 @@ export async function applySync( frameworkControlPolicyLinks: { connected: [], disconnected: [] }, frameworkControlTaskLinks: { connected: [], disconnected: [] }, frameworkControlDocumentTypeLinks: { connected: [], disconnected: [] }, + controlFamilies: { created: [], updated: [], deleted: [] }, }; const summary: SyncSummary = { controlsAdded: 0, controlsArchived: 0, controlsUpdatedApplied: 0, controlsUpdatedPreserved: 0, @@ -83,6 +84,20 @@ export async function applySync( ctlByTemplate.set(targetControl.id, created); undo.controls.created.push(created.id); summary.controlsAdded += 1; + // Per-instance family entry for the new control + if (targetControl.controlFamily) { + await tx.frameworkControlFamily.create({ + data: { + frameworkInstanceId: ctx.instance.id, + controlId: created.id, + controlFamily: targetControl.controlFamily, + }, + }); + undo.controlFamilies!.created.push({ + frameworkInstanceId: ctx.instance.id, + controlId: created.id, + }); + } } for (const removed of diff.controls.removed) { const inst = ctlByTemplate.get(removed.id); @@ -96,6 +111,42 @@ export async function applySync( for (const u of diff.controls.updated) { const inst = ctlByTemplate.get(u.id); if (!inst) continue; + + // Sync family assignment regardless of whether the control content was edited. + // Family is structural metadata, not user-authored content. + const existingFamily = await tx.frameworkControlFamily.findUnique({ + where: { frameworkInstanceId_controlId: { frameworkInstanceId: ctx.instance.id, controlId: inst.id } }, + select: { controlFamily: true }, + }); + if (u.to.controlFamily) { + await tx.frameworkControlFamily.upsert({ + where: { frameworkInstanceId_controlId: { frameworkInstanceId: ctx.instance.id, controlId: inst.id } }, + create: { frameworkInstanceId: ctx.instance.id, controlId: inst.id, controlFamily: u.to.controlFamily }, + update: { controlFamily: u.to.controlFamily }, + }); + if (existingFamily) { + undo.controlFamilies!.updated.push({ + frameworkInstanceId: ctx.instance.id, + controlId: inst.id, + prevFamily: existingFamily.controlFamily, + }); + } else { + undo.controlFamilies!.created.push({ + frameworkInstanceId: ctx.instance.id, + controlId: inst.id, + }); + } + } else if (existingFamily) { + await tx.frameworkControlFamily.deleteMany({ + where: { frameworkInstanceId: ctx.instance.id, controlId: inst.id }, + }); + undo.controlFamilies!.deleted.push({ + frameworkInstanceId: ctx.instance.id, + controlId: inst.id, + prevFamily: existingFamily.controlFamily, + }); + } + if (isControlEdited(inst, u.from)) { summary.controlsUpdatedPreserved += 1; continue; diff --git a/apps/api/src/frameworks/framework-versioning/manifest.types.ts b/apps/api/src/frameworks/framework-versioning/manifest.types.ts index 8cd564455d..5bb93c910e 100644 --- a/apps/api/src/frameworks/framework-versioning/manifest.types.ts +++ b/apps/api/src/frameworks/framework-versioning/manifest.types.ts @@ -26,6 +26,7 @@ export interface ManifestControl { id: string; // frk_ct_* name: string; description: string; + controlFamily?: string | null; requirementIds: string[]; policyIds: string[]; taskIds: string[]; diff --git a/apps/api/src/frameworks/framework-versioning/undo-payload.types.ts b/apps/api/src/frameworks/framework-versioning/undo-payload.types.ts index 8adac1ac8e..11b062d6bd 100644 --- a/apps/api/src/frameworks/framework-versioning/undo-payload.types.ts +++ b/apps/api/src/frameworks/framework-versioning/undo-payload.types.ts @@ -15,6 +15,8 @@ export interface UndoPayload { frameworkControlPolicyLinks?: ImplicitEdgeBucket; frameworkControlTaskLinks?: ImplicitEdgeBucket; frameworkControlDocumentTypeLinks?: ImplicitEdgeBucket; + // Per-instance control family assignments. Older syncs may not have this bucket. + controlFamilies?: ControlFamilyUndoBucket; } export interface EntityUndoBucket { @@ -75,6 +77,17 @@ export interface ImplicitEdgeBucket { disconnected: Array<{ controlId: string; otherId: string }>; } +/** + * Tracks FrameworkControlFamily changes so rollback can restore prior state. + * `created` entries are deleted on rollback; `updated` entries are restored to + * prevFamily; `deleted` entries are recreated with prevFamily. + */ +export interface ControlFamilyUndoBucket { + created: Array<{ frameworkInstanceId: string; controlId: string }>; + updated: Array<{ frameworkInstanceId: string; controlId: string; prevFamily: string }>; + deleted: Array<{ frameworkInstanceId: string; controlId: string; prevFamily: string }>; +} + export interface SyncSummary { controlsAdded: number; controlsArchived: number; diff --git a/apps/api/src/frameworks/frameworks-source-loader.helper.ts b/apps/api/src/frameworks/frameworks-source-loader.helper.ts index fa726f786d..9c16337f16 100644 --- a/apps/api/src/frameworks/frameworks-source-loader.helper.ts +++ b/apps/api/src/frameworks/frameworks-source-loader.helper.ts @@ -19,6 +19,7 @@ export interface LoadedFrameworkSources { id: string; name: string; description: string; + controlFamily?: string | null; documentTypes: EvidenceFormType[]; }>; policyTemplates: Array<{ @@ -133,6 +134,7 @@ export async function loadFrameworkSources({ id: c.id, name: c.name, description: c.description, + controlFamily: c.controlFamily, documentTypes: (c.documentTypes ?? []) as EvidenceFormType[], }); } @@ -211,7 +213,7 @@ export async function loadFrameworkSources({ const liveControls = await tx.frameworkEditorControlTemplate.findMany({ where: { requirements: { some: { id: { in: fallbackRequirementIds } } } }, - select: { id: true, name: true, description: true, documentTypes: true }, + select: { id: true, name: true, description: true, controlFamily: true, documentTypes: true }, }); for (const lc of liveControls) { if (!controlsMap.has(lc.id)) { @@ -219,6 +221,7 @@ export async function loadFrameworkSources({ id: lc.id, name: lc.name, description: lc.description, + controlFamily: lc.controlFamily ?? undefined, documentTypes: lc.documentTypes, }); } diff --git a/apps/api/src/frameworks/frameworks-upsert.helper.ts b/apps/api/src/frameworks/frameworks-upsert.helper.ts index 51b7035755..4b5ecff086 100644 --- a/apps/api/src/frameworks/frameworks-upsert.helper.ts +++ b/apps/api/src/frameworks/frameworks-upsert.helper.ts @@ -285,6 +285,7 @@ export async function upsertOrgFrameworkStructure({ const frameworkControlPolicyEntries: Prisma.FrameworkControlPolicyLinkCreateManyInput[] = []; const frameworkControlTaskEntries: Prisma.FrameworkControlTaskLinkCreateManyInput[] = []; const frameworkControlDocumentTypeEntries: Prisma.FrameworkControlDocumentTypeLinkCreateManyInput[] = []; + const frameworkControlFamilyEntries: Prisma.FrameworkControlFamilyCreateManyInput[] = []; const controlTemplateById = new Map(controlTemplates.map((c) => [c.id, c])); for (const relation of groupedRelations) { @@ -360,6 +361,16 @@ export async function upsertOrgFrameworkStructure({ formType, }); } + + // FrameworkControlFamily: per-instance family grouping from the template. + const template = controlTemplateById.get(relation.controlTemplateId); + if (template?.controlFamily) { + frameworkControlFamilyEntries.push({ + frameworkInstanceId, + controlId, + controlFamily: template.controlFamily, + }); + } } if (requirementMapEntries.length > 0) { @@ -397,6 +408,13 @@ export async function upsertOrgFrameworkStructure({ }); } + if (frameworkControlFamilyEntries.length > 0) { + await tx.frameworkControlFamily.createMany({ + data: frameworkControlFamilyEntries, + skipDuplicates: true, + }); + } + return { processedFrameworks: frameworkEditorFrameworks, controlTemplates, diff --git a/apps/api/src/frameworks/frameworks.service.ts b/apps/api/src/frameworks/frameworks.service.ts index 99b58d839a..66217295ee 100644 --- a/apps/api/src/frameworks/frameworks.service.ts +++ b/apps/api/src/frameworks/frameworks.service.ts @@ -5,6 +5,8 @@ import { NotFoundException, } from '@nestjs/common'; import { db, type EvidenceFormType } from '@db'; +import { deduplicateById, deduplicateByFormType } from '../utils/deduplicate'; +import { syncDirectLinksToCustomFrameworks } from '../controls/sync-custom-framework-links'; import { tasks } from '@trigger.dev/sdk'; import { @@ -32,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); @@ -156,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: { @@ -165,6 +216,9 @@ export class FrameworksService { }, }, frameworkDocumentLinks: true, + frameworkControlFamilies: { + select: { frameworkInstanceId: true, controlFamily: true }, + }, requirementsMapped: { where: { archivedAt: null } }, }, }, @@ -182,35 +236,38 @@ 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 + requirementsMapped: _reqs, + frameworkControlFamilies, + ...controlForMerge } = 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 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, + }); + const familyEntry = (frameworkControlFamilies ?? []).find( + (f: { frameworkInstanceId: string }) => + f.frameworkInstanceId === fi.id, + ); + controlsMap.set(rm.control.id, { + ...merged, + controlFamily: familyEntry?.controlFamily ?? null, requirementsMapped: rm.control.requirementsMapped || [], }); } @@ -223,41 +280,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([ + ...mappedDirectTasks, + ...mappedFrameworkTasks, + ].filter((t) => t.controls.length > 0)); + + return { + ...fw, + complianceScore: computeFrameworkComplianceScore( + fw, + allTasks, + evidenceSubmissions, + ), + }; + }); } async findOne(frameworkInstanceId: string, organizationId: string) { @@ -271,6 +377,11 @@ export class FrameworksService { include: { control: { include: { + policies: { + where: { archivedAt: null }, + select: { id: true, name: true, status: true }, + }, + controlDocumentTypes: true, frameworkPolicyLinks: { where: { frameworkInstanceId, @@ -286,6 +397,11 @@ export class FrameworksService { frameworkDocumentLinks: { where: { frameworkInstanceId }, }, + frameworkControlFamilies: { + where: { frameworkInstanceId }, + select: { controlFamily: true }, + take: 1, + }, }, }, }, @@ -297,29 +413,24 @@ 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 + requirementsMapped: _reqs, + frameworkControlFamilies, + ...controlForMerge } = rm.control; + const merged = mergeControlLinks(controlForMerge, mergeOpts); controlsMap.set(rm.control.id, { - ...controlData, - policies: - rm.control.frameworkPolicyLinks?.map((link) => link.policy) || [], + ...merged, + controlFamily: frameworkControlFamilies?.[0]?.controlFamily ?? null, requirementsMapped: rm.control.requirementsMapped || [], - controlDocumentTypes: (rm.control.frameworkDocumentLinks || []).map( - (documentType) => ({ - ...documentType, - isNotRelevant: notRelevantFormTypes.has(documentType.formType), - }), - ), }); } } @@ -333,9 +444,11 @@ export class FrameworksService { } } + const controlIds = Array.from(controlsMap.keys()); const [ requirementDefinitions, - tasks, + frameworkTasks, + directTasks, requirementMaps, evidenceSubmissions, ] = await Promise.all([ @@ -353,6 +466,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 }, @@ -369,14 +496,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, }; @@ -585,6 +726,17 @@ export class FrameworksService { skipDuplicates: true, }); + if (fi.customFrameworkId) { + await Promise.all( + controls.map((c) => + syncDirectLinksToCustomFrameworks({ + controlId: c.id, + organizationId, + }), + ), + ); + } + return { count: result.count }; } @@ -765,62 +917,103 @@ 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) { 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: { - 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 }, + }, + frameworkControlFamilies: { + where: { frameworkInstanceId }, + select: { controlFamily: true }, + take: 1, + }, }, }, }, - }, - }), - 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 } }, }, + 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) => { + const { frameworkControlFamilies, ...controlForMerge } = + relatedControl.control; + return { + ...relatedControl, + control: { + ...mergeControlLinks(controlForMerge, mergeOpts), + controlFamily: + frameworkControlFamilies?.[0]?.controlFamily ?? null, }, - }), - this.getNotRelevantFormTypes(organizationId), - ]); + }; + }); 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); } } @@ -841,32 +1034,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, - ...control - } = relatedControl.control; - return { - ...control, - policies: frameworkPolicyLinks.map((link) => link.policy), - 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/policies/policies.controller.ts b/apps/api/src/policies/policies.controller.ts index 0eab46dc33..553dba274d 100644 --- a/apps/api/src/policies/policies.controller.ts +++ b/apps/api/src/policies/policies.controller.ts @@ -780,13 +780,29 @@ export class PoliciesController { @OrganizationId() organizationId: string, @AuthContext() authContext: AuthContextType, ) { - await db.policy.update({ - where: { id, organizationId }, - data: { - controls: { - disconnect: { id: controlId }, + 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 { diff --git a/apps/api/src/utils/deduplicate.ts b/apps/api/src/utils/deduplicate.ts new file mode 100644 index 0000000000..f98f53336e --- /dev/null +++ b/apps/api/src/utils/deduplicate.ts @@ -0,0 +1,22 @@ +export function deduplicateBy( + items: T[], + key: (item: T) => string, +): T[] { + const seen = new Set(); + return items.filter((item) => { + 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); +} diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/FamilyFilterDropdown.tsx b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/FamilyFilterDropdown.tsx new file mode 100644 index 0000000000..ab2299b81f --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/FamilyFilterDropdown.tsx @@ -0,0 +1,109 @@ +'use client'; + +import { Button, Text } from '@trycompai/design-system'; +import { + Checkbox, + CheckboxCheckedFilled, + Close, + Filter, +} from '@trycompai/design-system/icons'; +import { useEffect, useRef, useState } from 'react'; +import { getFamilyDisplayLabel } from './framework-controls-shared'; + +interface FamilyFilterDropdownProps { + allFamilyNames: string[]; + familyCounts: Map; + selectedFamilies: Set; + onToggleFamily: (family: string) => void; + onClear: () => void; +} + +export function FamilyFilterDropdown({ + allFamilyNames, + familyCounts, + selectedFamilies, + onToggleFamily, + onClear, +}: FamilyFilterDropdownProps) { + const [open, setOpen] = useState(false); + const [searchTerm, setSearchTerm] = useState(''); + const containerRef = useRef(null); + + useEffect(() => { + if (!open) { + setSearchTerm(''); + return; + } + + const handleClickOutside = (e: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + setOpen(false); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, [open]); + + const hasFilter = selectedFamilies.size > 0; + const label = hasFilter ? `Families (${selectedFamilies.size})` : 'Families'; + + const filteredFamilies = allFamilyNames.filter((f) => + getFamilyDisplayLabel(f).toLowerCase().includes(searchTerm.toLowerCase()), + ); + + return ( +
+
+ + {hasFilter && ( + + )} +
+ + {open && ( +
+ setSearchTerm(e.target.value)} + className="w-full border-b border-border bg-transparent px-3 py-1.5 text-sm outline-none" + autoFocus + /> +
+ {filteredFamilies.map((family) => { + const isSelected = selectedFamilies.has(family); + const Icon = isSelected ? CheckboxCheckedFilled : Checkbox; + + return ( + + ); + })} + {filteredFamilies.length === 0 && ( +

No matching families.

+ )} +
+
+ )} +
+ ); +} diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/FrameworkControls.tsx b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/FrameworkControls.tsx index 88adb2dfe7..cffdff5e40 100644 --- a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/FrameworkControls.tsx +++ b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/FrameworkControls.tsx @@ -1,6 +1,5 @@ 'use client'; -import type { StatusType } from '@/components/status-indicator'; import { type EvidenceSubmissionInfo, getControlProgressPercent, @@ -9,6 +8,13 @@ import { } from '@/lib/control-compliance'; import type { FrameworkInstanceWithControls } from '@/lib/types/framework'; import type { Control, FrameworkEditorRequirement, Task } from '@db'; +import { + buildControlItems, + buildRequirementMap, + type ControlItem, + getStatusBadge, + PAGE_SIZE_OPTIONS, +} from './framework-controls-shared'; import { Badge, Heading, @@ -28,29 +34,6 @@ import Link from 'next/link'; import { useParams, useRouter } from 'next/navigation'; import { useEffect, useMemo, useState } from 'react'; -const PAGE_SIZE_OPTIONS = [10, 25, 50, 100]; - -function getStatusBadge(status: StatusType): { - label: string; - variant: 'default' | 'secondary' | 'destructive'; -} { - switch (status) { - case 'completed': - return { label: 'Satisfied', variant: 'default' }; - case 'in_progress': - return { label: 'In Progress', variant: 'secondary' }; - case 'not_relevant': - return { label: 'Not Relevant', variant: 'secondary' }; - default: - return { label: 'Not Started', variant: 'destructive' }; - } -} - -interface ControlItem { - control: FrameworkInstanceWithControls['controls'][number]; - requirements: Array<{ id: string; name: string; identifier: string }>; -} - export function FrameworkControls({ frameworkInstanceWithControls, requirementDefinitions, @@ -71,23 +54,15 @@ export function FrameworkControls({ const [page, setPage] = useState(1); const [pageSize, setPageSize] = useState(25); - const requirementMap = useMemo(() => { - const map = new Map(); - for (const req of requirementDefinitions) { - map.set(req.id, { id: req.id, name: req.name, identifier: req.identifier ?? '' }); - } - return map; - }, [requirementDefinitions]); - - const items: ControlItem[] = useMemo(() => { - return frameworkInstanceWithControls.controls.map((control) => { - const requirements = (control.requirementsMapped ?? []) - .map((rm) => (rm.requirementId ? requirementMap.get(rm.requirementId) : undefined)) - .filter((r): r is { id: string; name: string; identifier: string } => r != null); + const requirementMap = useMemo( + () => buildRequirementMap(requirementDefinitions), + [requirementDefinitions], + ); - return { control, requirements }; - }); - }, [frameworkInstanceWithControls.controls, requirementMap]); + const items: ControlItem[] = useMemo( + () => buildControlItems(frameworkInstanceWithControls.controls, requirementMap), + [frameworkInstanceWithControls.controls, requirementMap], + ); const filteredItems = useMemo(() => { if (!searchTerm.trim()) return items; diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/FrameworkControlsGrouped.tsx b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/FrameworkControlsGrouped.tsx new file mode 100644 index 0000000000..9d7b4bdf6a --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/FrameworkControlsGrouped.tsx @@ -0,0 +1,271 @@ +'use client'; + +import type { EvidenceSubmissionInfo } from '@/lib/control-compliance'; +import type { FrameworkInstanceWithControls } from '@/lib/types/framework'; +import type { Control, FrameworkEditorRequirement, Task } from '@db'; +import { + Button, + Heading, + InputGroup, + InputGroupAddon, + InputGroupInput, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, + Text, +} from '@trycompai/design-system'; +import { ChevronDown, ChevronRight, Search } from '@trycompai/design-system/icons'; +import { useParams, useRouter } from 'next/navigation'; +import { parseAsArrayOf, parseAsString, useQueryState } from 'nuqs'; +import { useCallback, useMemo, useState } from 'react'; +import { FamilyFilterDropdown } from './FamilyFilterDropdown'; +import { + buildControlItems, + buildRequirementMap, + getFamilyDisplayLabel, + groupByFamily, + type ControlItem, + type FamilyGroup, +} from './framework-controls-shared'; +import { GroupedControlRow } from './GroupedControlRow'; + +const COLUMN_COUNT = 7; + +export function FrameworkControlsGrouped({ + frameworkInstanceWithControls, + requirementDefinitions, + tasks, + evidenceSubmissions = [], +}: { + frameworkInstanceWithControls: FrameworkInstanceWithControls; + requirementDefinitions: FrameworkEditorRequirement[]; + tasks: (Task & { controls: Control[] })[]; + evidenceSubmissions?: EvidenceSubmissionInfo[]; +}) { + const { orgId, frameworkInstanceId } = useParams<{ orgId: string; frameworkInstanceId: string }>(); + const router = useRouter(); + + const handleRowClick = useCallback( + (controlId: string) => { + router.push(`/${orgId}/frameworks/${frameworkInstanceId}/controls/${controlId}`); + }, + [orgId, frameworkInstanceId, router], + ); + + const [searchTerm, setSearchTerm] = useQueryState('q', parseAsString.withDefault('').withOptions({ shallow: true, throttleMs: 300 })); + const [familyFilterParam, setFamilyFilterParam] = useQueryState('families', parseAsArrayOf(parseAsString, '|').withDefault([]).withOptions({ shallow: true })); + const [collapsedFamilies, setCollapsedFamilies] = useState>(new Set()); + + const selectedFamilyFilter = useMemo(() => new Set(familyFilterParam), [familyFilterParam]); + + const requirementMap = useMemo( + () => buildRequirementMap(requirementDefinitions), + [requirementDefinitions], + ); + + const allItems = useMemo( + () => buildControlItems(frameworkInstanceWithControls.controls, requirementMap), + [frameworkInstanceWithControls.controls, requirementMap], + ); + + const filteredItems = useMemo(() => { + if (!searchTerm.trim()) return allItems; + const lower = searchTerm.toLowerCase(); + return allItems.filter( + (item) => + item.control.name.toLowerCase().includes(lower) || + item.control.description?.toLowerCase().includes(lower) || + item.requirements.some( + (r) => r.name.toLowerCase().includes(lower) || r.identifier.toLowerCase().includes(lower), + ), + ); + }, [allItems, searchTerm]); + + const allGroups = useMemo(() => groupByFamily(filteredItems), [filteredItems]); + + const groups = useMemo(() => { + if (selectedFamilyFilter.size === 0) return allGroups; + return allGroups.filter((g) => selectedFamilyFilter.has(g.family)); + }, [allGroups, selectedFamilyFilter]); + + const allFamilyNames = useMemo(() => allGroups.map((g) => g.family), [allGroups]); + const familyCounts = useMemo(() => new Map(allGroups.map((g) => [g.family, g.items.length])), [allGroups]); + + const isSearching = searchTerm.trim().length > 0; + const allCollapsed = groups.length > 0 && groups.every((g) => collapsedFamilies.has(g.family)); + + const handleToggleFamily = (family: string) => { + setCollapsedFamilies((prev) => { + const next = new Set(prev); + if (next.has(family)) { + next.delete(family); + } else { + next.add(family); + } + return next; + }); + }; + + const handleToggleAll = () => { + if (allCollapsed) { + setCollapsedFamilies(new Set()); + } else { + setCollapsedFamilies(new Set(allFamilyNames)); + } + }; + + const handleSearchChange = (e: React.ChangeEvent) => { + setSearchTerm(e.target.value || null); + }; + + const handleToggleFamilyFilter = (family: string) => { + const next = new Set(selectedFamilyFilter); + if (next.has(family)) { + next.delete(family); + } else { + next.add(family); + } + setFamilyFilterParam(next.size > 0 ? [...next].sort() : null); + }; + + const handleClearFamilyFilter = () => { + setFamilyFilterParam(null); + }; + + const isFamilyExpanded = (family: string) => isSearching || !collapsedFamilies.has(family); + + return ( +
+ Controls ({filteredItems.length}) +
+
+ + + + + + +
+ + {!isSearching && ( + + )} +
+ + + + Name + Requirement + Compliance + Status + Policies + Tasks + Documents + + + + {groups.length === 0 ? ( + + + + No controls found. + + + + ) : ( + groups.map((group) => ( + handleToggleFamily(group.family)} + tasks={tasks} + evidenceSubmissions={evidenceSubmissions} + orgId={orgId} + frameworkInstanceId={frameworkInstanceId} + onRowClick={handleRowClick} + /> + )) + )} + +
+
+ ); +} + +function FamilySection({ + group, + expanded, + onToggle, + tasks, + evidenceSubmissions, + orgId, + frameworkInstanceId, + onRowClick, +}: { + group: FamilyGroup; + expanded: boolean; + onToggle: () => void; + tasks: (Task & { controls: Control[] })[]; + evidenceSubmissions: EvidenceSubmissionInfo[]; + orgId: string; + frameworkInstanceId: string; + onRowClick: (controlId: string) => void; +}) { + const ChevronIcon = expanded ? ChevronDown : ChevronRight; + + return ( + <> + + + + + + {expanded && + group.items.map(({ control, requirements }) => ( + + ))} + + ); +} diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/FrameworkDetailContent.tsx b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/FrameworkDetailContent.tsx index 31b19ed28b..5ce1bed0b4 100644 --- a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/FrameworkDetailContent.tsx +++ b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/FrameworkDetailContent.tsx @@ -24,9 +24,10 @@ import { } from '@trycompai/ui/dropdown-menu'; import Link from 'next/link'; import { usePathname, useRouter, useSearchParams } from 'next/navigation'; -import { useCallback, useState } from 'react'; +import { Suspense, useCallback, useMemo, useState } from 'react'; import { AddCustomRequirementSheet } from './AddCustomRequirementSheet'; import { FrameworkControls } from './FrameworkControls'; +import { FrameworkControlsGrouped } from './FrameworkControlsGrouped'; import { FrameworkDeleteDialog } from './FrameworkDeleteDialog'; import { FrameworkProgress } from './FrameworkProgress'; import { FrameworkRequirements } from './FrameworkRequirements'; @@ -75,6 +76,14 @@ export function FrameworkDetailContent({ const evidenceSubmissions = framework.evidenceSubmissions || []; const requirementDefinitions = framework.requirementDefinitions || []; + const hasControlFamilies = useMemo( + () => + frameworkInstanceWithControls.controls.some( + (c: { controlFamily?: string | null }) => c.controlFamily, + ), + [frameworkInstanceWithControls.controls], + ); + // Tab state synced to ?tab= // Progress tab only exists when the compliance timeline flag is on — when // it's off, the lightweight FrameworkProgress renders above the tabs. @@ -194,12 +203,23 @@ export function FrameworkDetailContent({ )} - + {hasControlFamilies ? ( + + + + ) : ( + + )} diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/GroupedControlRow.tsx b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/GroupedControlRow.tsx new file mode 100644 index 0000000000..4d45d7fd20 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/GroupedControlRow.tsx @@ -0,0 +1,126 @@ +'use client'; + +import { + type EvidenceSubmissionInfo, + getControlProgressPercent, + getControlStatus, + getRequirementArtifactCounts, +} from '@/lib/control-compliance'; +import type { FrameworkInstanceWithControls } from '@/lib/types/framework'; +import type { Control, Task } from '@db'; +import { Badge, TableCell, TableRow, Text } from '@trycompai/design-system'; +import { Launch } from '@trycompai/design-system/icons'; +import Link from 'next/link'; +import { getStatusBadge } from './framework-controls-shared'; + +export function GroupedControlRow({ + control, + requirements, + tasks, + evidenceSubmissions, + orgId, + frameworkInstanceId, + onRowClick, +}: { + control: FrameworkInstanceWithControls['controls'][number]; + requirements: Array<{ id: string; name: string; identifier: string }>; + tasks: (Task & { controls: Control[] })[]; + evidenceSubmissions: EvidenceSubmissionInfo[]; + orgId: string; + frameworkInstanceId: string; + onRowClick: (controlId: string) => void; +}) { + const policies = control.policies ?? []; + const documentTypes = control.controlDocumentTypes ?? []; + const counts = getRequirementArtifactCounts([control], tasks, evidenceSubmissions); + const status = getControlStatus(policies, tasks, control.id, documentTypes, evidenceSubmissions); + const badge = getStatusBadge(status); + const compliancePercent = getControlProgressPercent( + policies, + tasks, + control.id, + documentTypes, + evidenceSubmissions, + ); + + const controlHref = `/${orgId}/frameworks/${frameworkInstanceId}/controls/${control.id}`; + + const handleRowClick = () => { + onRowClick(control.id); + }; + + const reqLabel = + requirements.length > 0 + ? requirements.map((r) => r.identifier || r.name).join(', ') + : null; + + return ( + + + e.stopPropagation()} + className="group flex items-center gap-2 pl-6" + > + + {control.name} + + + + + + {reqLabel ? ( + + {reqLabel} + + ) : ( + + — + + )} + + +
+
+
+
+
+ + {compliancePercent}% + +
+
+ + + {badge.label} + + +
+ + {counts.policies.completed}/{counts.policies.total} + +
+
+ +
+ + {counts.tasks.completed}/{counts.tasks.total} + +
+
+ +
+ + {counts.documents.completed}/{counts.documents.total} + +
+
+ + ); +} diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/framework-controls-shared.test.ts b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/framework-controls-shared.test.ts new file mode 100644 index 0000000000..45e1c79423 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/framework-controls-shared.test.ts @@ -0,0 +1,266 @@ +import type { FrameworkEditorRequirement } from '@db'; +import { describe, expect, it } from 'vitest'; +import type { FrameworkInstanceWithControls } from '@/lib/types/framework'; +import { + buildControlItems, + buildRequirementMap, + getStatusBadge, + groupByFamily, + UNCATEGORIZED_FAMILY, + type ControlItem, +} from './framework-controls-shared'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +type ControlEntry = FrameworkInstanceWithControls['controls'][number]; + +function makeControlItem(overrides: { + id?: string; + name: string; + controlFamily?: string | null; +}): ControlItem { + return { + control: { + id: overrides.id ?? `ctrl_${overrides.name}`, + name: overrides.name, + controlFamily: overrides.controlFamily ?? null, + policies: [], + requirementsMapped: [], + } as unknown as ControlEntry, + requirements: [], + }; +} + +function makeRequirement(overrides: Partial = {}) { + return { + id: overrides.id ?? 'req_1', + frameworkId: 'fw_1', + name: overrides.name ?? 'Requirement 1', + identifier: overrides.identifier ?? 'R-1', + description: 'desc', + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + } as FrameworkEditorRequirement; +} + +// --------------------------------------------------------------------------- +// getStatusBadge +// --------------------------------------------------------------------------- + +describe('getStatusBadge', () => { + it('returns Satisfied / default for completed', () => { + expect(getStatusBadge('completed')).toEqual({ + label: 'Satisfied', + variant: 'default', + }); + }); + + it('returns In Progress / secondary for in_progress', () => { + expect(getStatusBadge('in_progress')).toEqual({ + label: 'In Progress', + variant: 'secondary', + }); + }); + + it('returns Not Relevant / secondary for not_relevant', () => { + expect(getStatusBadge('not_relevant')).toEqual({ + label: 'Not Relevant', + variant: 'secondary', + }); + }); + + it('returns Not Started / destructive for not_started', () => { + expect(getStatusBadge('not_started')).toEqual({ + label: 'Not Started', + variant: 'destructive', + }); + }); + + it('returns Not Started / destructive for any unrecognized status', () => { + expect(getStatusBadge('draft')).toEqual({ + label: 'Not Started', + variant: 'destructive', + }); + }); +}); + +// --------------------------------------------------------------------------- +// buildRequirementMap +// --------------------------------------------------------------------------- + +describe('buildRequirementMap', () => { + it('builds a map keyed by requirement id', () => { + const reqs = [ + makeRequirement({ id: 'r1', name: 'Privacy', identifier: 'cc1-1' }), + makeRequirement({ id: 'r2', name: 'Security', identifier: 'cc2-1' }), + ]; + + const map = buildRequirementMap(reqs); + + expect(map.size).toBe(2); + expect(map.get('r1')).toEqual({ id: 'r1', name: 'Privacy', identifier: 'cc1-1' }); + expect(map.get('r2')).toEqual({ id: 'r2', name: 'Security', identifier: 'cc2-1' }); + }); + + it('defaults identifier to empty string when null', () => { + const reqs = [makeRequirement({ id: 'r1', identifier: null as unknown as string })]; + const map = buildRequirementMap(reqs); + + expect(map.get('r1')?.identifier).toBe(''); + }); + + it('returns empty map for empty input', () => { + expect(buildRequirementMap([]).size).toBe(0); + }); +}); + +// --------------------------------------------------------------------------- +// buildControlItems +// --------------------------------------------------------------------------- + +describe('buildControlItems', () => { + it('maps controls to items with resolved requirements', () => { + const reqMap = new Map([ + ['r1', { id: 'r1', name: 'Privacy', identifier: 'cc1-1' }], + ['r2', { id: 'r2', name: 'Security', identifier: 'cc2-1' }], + ]); + + const controls = [ + { + id: 'c1', + name: 'Control 1', + policies: [], + requirementsMapped: [{ requirementId: 'r1' }, { requirementId: 'r2' }], + }, + ] as unknown as Parameters[0]; + + const items = buildControlItems(controls, reqMap); + + expect(items).toHaveLength(1); + expect(items[0].requirements).toEqual([ + { id: 'r1', name: 'Privacy', identifier: 'cc1-1' }, + { id: 'r2', name: 'Security', identifier: 'cc2-1' }, + ]); + }); + + it('filters out requirementIds that are not in the map', () => { + const reqMap = new Map([['r1', { id: 'r1', name: 'Privacy', identifier: 'cc1-1' }]]); + + const controls = [ + { + id: 'c1', + name: 'Control 1', + policies: [], + requirementsMapped: [{ requirementId: 'r1' }, { requirementId: 'r_missing' }], + }, + ] as unknown as Parameters[0]; + + const items = buildControlItems(controls, reqMap); + + expect(items[0].requirements).toHaveLength(1); + expect(items[0].requirements[0].id).toBe('r1'); + }); + + it('handles controls with no requirementsMapped', () => { + const controls = [ + { id: 'c1', name: 'Control 1', policies: [], requirementsMapped: undefined }, + ] as unknown as Parameters[0]; + + const items = buildControlItems(controls, new Map()); + + expect(items[0].requirements).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// groupByFamily +// --------------------------------------------------------------------------- + +describe('groupByFamily', () => { + it('groups controls by controlFamily field', () => { + const items = [ + makeControlItem({ name: 'C1', controlFamily: 'Access Control' }), + makeControlItem({ name: 'C2', controlFamily: 'Audit' }), + makeControlItem({ name: 'C3', controlFamily: 'Access Control' }), + ]; + + const groups = groupByFamily(items); + + expect(groups).toHaveLength(2); + expect(groups[0].family).toBe('Access Control'); + expect(groups[0].items).toHaveLength(2); + expect(groups[1].family).toBe('Audit'); + expect(groups[1].items).toHaveLength(1); + }); + + it('sorts groups alphabetically by family name', () => { + const items = [ + makeControlItem({ name: 'C1', controlFamily: 'Zoning' }), + makeControlItem({ name: 'C2', controlFamily: 'Access Control' }), + makeControlItem({ name: 'C3', controlFamily: 'Media Protection' }), + ]; + + const families = groupByFamily(items).map((g) => g.family); + + expect(families).toEqual(['Access Control', 'Media Protection', 'Zoning']); + }); + + it('sorts controls within each group by name', () => { + const items = [ + makeControlItem({ name: 'Zulu', controlFamily: 'Access Control' }), + makeControlItem({ name: 'Alpha', controlFamily: 'Access Control' }), + makeControlItem({ name: 'Mike', controlFamily: 'Access Control' }), + ]; + + const names = groupByFamily(items)[0].items.map((i) => i.control.name); + + expect(names).toEqual(['Alpha', 'Mike', 'Zulu']); + }); + + it('places controls without a family into "Other" at the bottom', () => { + const items = [ + makeControlItem({ name: 'C1', controlFamily: 'Audit' }), + makeControlItem({ name: 'C2', controlFamily: null }), + makeControlItem({ name: 'C3', controlFamily: undefined }), + ]; + + const groups = groupByFamily(items); + + expect(groups).toHaveLength(2); + expect(groups[0].family).toBe('Audit'); + expect(groups[1].family).toBe(UNCATEGORIZED_FAMILY); + expect(groups[1].items).toHaveLength(2); + }); + + it('returns empty array for empty input', () => { + expect(groupByFamily([])).toEqual([]); + }); + + it('returns single group when all controls share one family', () => { + const items = [ + makeControlItem({ name: 'C1', controlFamily: 'Risk Assessment' }), + makeControlItem({ name: 'C2', controlFamily: 'Risk Assessment' }), + ]; + + const groups = groupByFamily(items); + + expect(groups).toHaveLength(1); + expect(groups[0].family).toBe('Risk Assessment'); + }); + + it('returns single "Other" group when no controls have families', () => { + const items = [ + makeControlItem({ name: 'C1', controlFamily: null }), + makeControlItem({ name: 'C2', controlFamily: undefined }), + ]; + + const groups = groupByFamily(items); + + expect(groups).toHaveLength(1); + expect(groups[0].family).toBe(UNCATEGORIZED_FAMILY); + expect(groups[0].items).toHaveLength(2); + }); +}); diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/framework-controls-shared.ts b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/framework-controls-shared.ts new file mode 100644 index 0000000000..b280df91af --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/framework-controls-shared.ts @@ -0,0 +1,101 @@ +import type { StatusType } from '@/components/status-indicator'; +import type { FrameworkInstanceWithControls } from '@/lib/types/framework'; +import type { FrameworkEditorRequirement } from '@db'; + +export const PAGE_SIZE_OPTIONS = [10, 25, 50, 100]; + +export interface ControlItem { + control: FrameworkInstanceWithControls['controls'][number]; + requirements: Array<{ id: string; name: string; identifier: string }>; +} + +export function getStatusBadge(status: StatusType): { + label: string; + variant: 'default' | 'secondary' | 'destructive'; +} { + switch (status) { + case 'completed': + return { label: 'Satisfied', variant: 'default' }; + case 'in_progress': + return { label: 'In Progress', variant: 'secondary' }; + case 'not_relevant': + return { label: 'Not Relevant', variant: 'secondary' }; + default: + return { label: 'Not Started', variant: 'destructive' }; + } +} + +export function buildRequirementMap( + requirementDefinitions: FrameworkEditorRequirement[], +): Map { + const map = new Map(); + for (const req of requirementDefinitions) { + map.set(req.id, { id: req.id, name: req.name, identifier: req.identifier ?? '' }); + } + return map; +} + +export function buildControlItems( + controls: FrameworkInstanceWithControls['controls'], + requirementMap: Map, +): ControlItem[] { + return controls.map((control) => { + const requirements = (control.requirementsMapped ?? []) + .map((rm) => (rm.requirementId ? requirementMap.get(rm.requirementId) : undefined)) + .filter((r): r is { id: string; name: string; identifier: string } => r != null); + return { control, requirements }; + }); +} + +/** Sentinel value for uncategorized controls — avoids collision with a real family named "Other". */ +export const UNCATEGORIZED_FAMILY = '__uncategorized__'; + +/** Display label for the uncategorized family group. */ +export const UNCATEGORIZED_FAMILY_LABEL = 'Other'; + +export interface FamilyGroup { + family: string; + items: ControlItem[]; +} + +export function groupByFamily(items: ControlItem[]): FamilyGroup[] { + const familyMap = new Map(); + const otherItems: ControlItem[] = []; + + for (const item of items) { + const family = item.control.controlFamily; + if (family) { + const existing = familyMap.get(family); + if (existing) { + existing.push(item); + } else { + familyMap.set(family, [item]); + } + } else { + otherItems.push(item); + } + } + + const sortedFamilies = Array.from(familyMap.entries()).sort(([a], [b]) => + a.localeCompare(b), + ); + + const groups: FamilyGroup[] = sortedFamilies.map(([family, items]) => ({ + family, + items: items.sort((a, b) => a.control.name.localeCompare(b.control.name, undefined, { numeric: true })), + })); + + if (otherItems.length > 0) { + groups.push({ + family: UNCATEGORIZED_FAMILY, + items: otherItems.sort((a, b) => a.control.name.localeCompare(b.control.name, undefined, { numeric: true })), + }); + } + + return groups; +} + +/** Returns the display label for a family key (handles the uncategorized sentinel). */ +export function getFamilyDisplayLabel(family: string): string { + return family === UNCATEGORIZED_FAMILY ? UNCATEGORIZED_FAMILY_LABEL : family; +} diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/review-update/components/ReviewUpdateContent.test.ts b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/review-update/components/ReviewUpdateContent.test.ts new file mode 100644 index 0000000000..93bd4ec492 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/review-update/components/ReviewUpdateContent.test.ts @@ -0,0 +1,81 @@ +import type { ManifestControl } from '@/types/framework-versioning'; +import { describe, expect, it } from 'vitest'; +import { describeControlChanges } from './ReviewUpdateContent'; + +function makeManifest(overrides: Partial = {}): ManifestControl { + return { + id: 'mc_1', + name: 'Control A', + description: 'Desc A', + controlFamily: null, + requirementIds: [], + policyIds: [], + taskIds: [], + ...overrides, + }; +} + +describe('describeControlChanges', () => { + it('returns "Control family set to X" when family added', () => { + const from = makeManifest({ controlFamily: null }); + const to = makeManifest({ controlFamily: 'Access Control' }); + + expect(describeControlChanges(from, to)).toBe( + 'Control family set to "Access Control"', + ); + }); + + it('returns "Control family removed" when family removed', () => { + const from = makeManifest({ controlFamily: 'Audit' }); + const to = makeManifest({ controlFamily: null }); + + expect(describeControlChanges(from, to)).toBe('Control family removed'); + }); + + it('returns "Control family changed from X to Y" when family renamed', () => { + const from = makeManifest({ controlFamily: 'Audit' }); + const to = makeManifest({ controlFamily: 'Logging' }); + + expect(describeControlChanges(from, to)).toBe( + 'Control family changed from "Audit" to "Logging"', + ); + }); + + it('returns "Name updated" when name changes', () => { + const from = makeManifest({ name: 'Old Name' }); + const to = makeManifest({ name: 'New Name' }); + + expect(describeControlChanges(from, to)).toBe('Name updated'); + }); + + it('returns combined message when multiple fields change', () => { + const from = makeManifest({ name: 'Old', controlFamily: null }); + const to = makeManifest({ name: 'New', controlFamily: 'AC' }); + + expect(describeControlChanges(from, to)).toBe( + 'Name updated. Control family set to "AC"', + ); + }); + + it('returns "Description updated" when only description changes', () => { + const from = makeManifest({ description: 'Old desc' }); + const to = makeManifest({ description: 'New desc' }); + + expect(describeControlChanges(from, to)).toBe('Description updated'); + }); + + it('returns "Modified" when nothing visibly changed', () => { + const manifest = makeManifest(); + + expect(describeControlChanges(manifest, manifest)).toBe('Modified'); + }); + + it('treats undefined controlFamily the same as null', () => { + const from = makeManifest({ controlFamily: undefined }); + const to = makeManifest({ controlFamily: 'Security' }); + + expect(describeControlChanges(from, to)).toBe( + 'Control family set to "Security"', + ); + }); +}); diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/review-update/components/ReviewUpdateContent.tsx b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/review-update/components/ReviewUpdateContent.tsx index cbf4d312f2..336979c1e8 100644 --- a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/review-update/components/ReviewUpdateContent.tsx +++ b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/review-update/components/ReviewUpdateContent.tsx @@ -37,6 +37,7 @@ interface ChangeRow { identifier?: string; name: string; description?: string | null; + changeSummary?: string | null; kind: ChangeKind; } @@ -323,6 +324,11 @@ function ItemRow({ row }: { row: ChangeRow }) { {row.name} + {row.changeSummary && ( + + {row.changeSummary} + + )} {row.description && ( {row.description} @@ -502,6 +508,27 @@ function LinkRowItem({ row }: { row: LinkRow }) { ); } +export function describeControlChanges( + from: UpdatePreview['controls']['updatedApplied'][number]['manifestFrom'], + to: UpdatePreview['controls']['updatedApplied'][number]['manifestTo'], +): string { + const changes: string[] = []; + if (from.name !== to.name) changes.push('Name updated'); + if (from.description !== to.description) changes.push('Description updated'); + const fromFamily = from.controlFamily ?? null; + const toFamily = to.controlFamily ?? null; + if (fromFamily !== toFamily) { + if (!fromFamily && toFamily) { + changes.push(`Control family set to "${toFamily}"`); + } else if (fromFamily && !toFamily) { + changes.push('Control family removed'); + } else { + changes.push(`Control family changed from "${fromFamily}" to "${toFamily}"`); + } + } + return changes.join('. ') || 'Modified'; +} + function buildGroups(preview: UpdatePreview): ChangeGroup[] { const out: ChangeGroup[] = []; @@ -625,10 +652,11 @@ function buildGroups(preview: UpdatePreview): ChangeGroup[] { out.push({ title: 'MODIFIED CONTROLS', kind: 'modified', - rows: preview.controls.updatedApplied.map(({ instance, manifestTo }) => ({ + rows: preview.controls.updatedApplied.map(({ instance, manifestFrom, manifestTo }) => ({ key: `ctl-mod-${instance.id}`, name: manifestTo.name, description: manifestTo.description, + changeSummary: describeControlChanges(manifestFrom, manifestTo), kind: 'modified' as const, })), }); diff --git a/apps/app/src/lib/types/framework.ts b/apps/app/src/lib/types/framework.ts index a494fc9880..87d2d38de4 100644 --- a/apps/app/src/lib/types/framework.ts +++ b/apps/app/src/lib/types/framework.ts @@ -11,6 +11,7 @@ export type FrameworkInstanceWithControls = FrameworkInstance & { framework: FrameworkEditorFramework | null; customFramework: CustomFramework | null; controls: (Control & { + controlFamily?: string | null; policies: Array<{ id: string; name: string; diff --git a/apps/app/src/types/framework-versioning.ts b/apps/app/src/types/framework-versioning.ts index 03e43c2dde..ffb3a166f3 100644 --- a/apps/app/src/types/framework-versioning.ts +++ b/apps/app/src/types/framework-versioning.ts @@ -20,6 +20,7 @@ export interface ManifestControl { id: string; name: string; description: string; + controlFamily?: string | null; requirementIds: string[]; policyIds: string[]; taskIds: string[]; diff --git a/apps/framework-editor/app/(pages)/controls/ControlsClientPage.tsx b/apps/framework-editor/app/(pages)/controls/ControlsClientPage.tsx index eb73f364c7..f4f5d17172 100644 --- a/apps/framework-editor/app/(pages)/controls/ControlsClientPage.tsx +++ b/apps/framework-editor/app/(pages)/controls/ControlsClientPage.tsx @@ -10,22 +10,24 @@ import { type SortingState, } from '@tanstack/react-table'; import { Button } from '@trycompai/ui'; -import { ArrowDown, ArrowUp, ArrowUpDown, Link, Plus, Trash2 } from 'lucide-react'; +import { ArrowDown, ArrowUp, ArrowUpDown, Link, Plus, Settings, Trash2 } from 'lucide-react'; import { useCallback, useMemo, useState } from 'react'; import { AddExistingItemDialog, type ExistingItemRaw, } from '../../components/AddExistingItemDialog'; +import { ManageFamiliesDialog } from './ManageFamiliesDialog'; import { + ComboboxCell, DateCell, EditableCell, MultiSelectCell, - type MultiSelectOption, RelationalCell, type RelationalItem, } from '../../components/table'; import { DOCUMENT_TYPE_OPTIONS } from './document-type-options'; import { simpleUUID, useChangeTracking, type ControlMutations } from './hooks/useChangeTracking'; +import { useFamiliesManagement } from './hooks/useFamiliesManagement'; import type { ControlsPageGridData, FrameworkEditorControlTemplateWithRelatedData } from './types'; interface RequirementApiItem { @@ -94,6 +96,7 @@ export function ControlsClientPage({ initialControls, emptyMessage, frameworkId createControl: (data: { name: string | null; description: string | null; + controlFamily: string | null; documentTypes: string[]; }) => apiClient<{ id: string }>('/control-template', { @@ -102,7 +105,7 @@ export function ControlsClientPage({ initialControls, emptyMessage, frameworkId }), updateControl: ( id: string, - data: { name: string; description: string; documentTypes: string[] }, + data: { name: string; description: string; controlFamily: string | null; documentTypes: string[] }, ) => apiClient(`/control-template/${id}`, { method: 'PATCH', @@ -121,6 +124,7 @@ export function ControlsClientPage({ initialControls, emptyMessage, frameworkId id: control.id || simpleUUID(), name: control.name ?? null, description: control.description ?? null, + controlFamily: control.controlFamily ?? null, policyTemplates: control.policyTemplates?.map((pt) => ({ id: pt.id, name: pt.name })) ?? [], requirements: control.requirements?.map((r) => ({ @@ -143,6 +147,7 @@ export function ControlsClientPage({ initialControls, emptyMessage, frameworkId const { data, updateCell, + batchUpdateCells, updateRelational, addRow, deleteRow, @@ -154,6 +159,15 @@ export function ControlsClientPage({ initialControls, emptyMessage, frameworkId changesSummary, } = useChangeTracking(initialGridData, mutations); + const { + families, + uniqueFamilies, + manageFamiliesOpen, + setManageFamiliesOpen, + handleRenameFamily, + handleDeleteFamily, + } = useFamiliesManagement({ data, batchUpdateCells }); + const handleDocumentTypesUpdate = useCallback( (rowId: string, values: string[]) => { updateCell(rowId, 'documentTypes', values); @@ -188,6 +202,20 @@ export function ControlsClientPage({ initialControls, emptyMessage, frameworkId /> ), }), + columnHelper.accessor('controlFamily', { + header: 'Control Family', + size: 200, + cell: ({ row, getValue }) => ( + + ), + }), columnHelper.accessor('policyTemplates', { header: 'Linked Policies', size: 220, @@ -318,7 +346,7 @@ export function ControlsClientPage({ initialControls, emptyMessage, frameworkId ), }), ], - [updateCell, updateRelational, deleteRow, createdIds, handleDocumentTypesUpdate, frameworkId], + [uniqueFamilies, updateCell, updateRelational, deleteRow, createdIds, handleDocumentTypesUpdate, frameworkId], ); const [sorting, setSorting] = useState([]); @@ -350,6 +378,7 @@ export function ControlsClientPage({ initialControls, emptyMessage, frameworkId id: simpleUUID(), name: 'New Control', description: '', + controlFamily: null, policyTemplates: [], requirements: [], taskTemplates: [], @@ -380,6 +409,17 @@ export function ControlsClientPage({ initialControls, emptyMessage, frameworkId )}
+ {families.length > 0 && ( + + )} {frameworkId && (