From 8d23f156a47593beac1c3b42ca23757971a7c12b Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Wed, 13 May 2026 11:49:51 -0400 Subject: [PATCH 1/3] feat(controls): add frameworkInstanceId support for control linking and retrieval - Updated ControlsController to accept frameworkInstanceId as an optional query parameter for findOne, linkPolicies, linkTasks, linkDocumentTypes, and unlinkDocumentType methods. - Enhanced ControlsService to handle frameworkInstanceId in findOne and linking methods, allowing for framework-specific control retrieval and linking. - Introduced new private methods to ensure framework instance validity and to fetch controls scoped to a specific framework. - Updated tests to cover new functionality and ensure proper linking behavior with framework context. --- apps/api/src/controls/controls.controller.ts | 17 +- apps/api/src/controls/controls.service.ts | 198 +++++++++++- .../framework-manifest-builder.spec.ts | 61 +++- .../framework-manifest-builder.ts | 30 +- .../control-template.controller.ts | 38 ++- .../control-template.service.spec.ts | 88 +++++- .../control-template.service.ts | 242 +++++++++++++-- .../dto/create-control-template.dto.ts | 5 + .../framework/framework-export.service.ts | 86 ++++-- .../framework/framework.service.ts | 94 ++++-- .../policy-template.service.ts | 40 ++- .../task-template/task-template.controller.ts | 28 ++ .../task-template/task-template.service.ts | 82 ++++- .../framework-rollback.service.ts | 73 +++++ .../framework-sync-apply.spec.ts | 3 + .../framework-sync-apply.ts | 152 ++++++++-- .../framework-update-preview.spec.ts | 8 + .../framework-versioning/manifest.types.ts | 2 +- .../undo-payload.types.ts | 5 + .../frameworks-source-loader.helper.ts | 74 ++++- .../frameworks/frameworks-upsert.helper.ts | 68 ++++- apps/api/src/frameworks/frameworks.service.ts | 116 +++++-- .../[controlId]/components/DocumentsTable.tsx | 4 +- .../components/FrameworkControlShell.tsx | 28 +- .../components/LinkDocumentTypeSheet.tsx | 4 +- .../components/LinkPolicySheet.tsx | 4 +- .../[controlId]/components/LinkTaskSheet.tsx | 4 +- .../controls/[controlId]/page.tsx | 5 +- .../(pages)/controls/ControlsClientPage.tsx | 24 +- .../documents/DocumentControlsCell.tsx | 38 +-- .../(pages)/documents/DocumentsClientPage.tsx | 6 +- .../[frameworkId]/documents/page.tsx | 2 +- .../app/(pages)/tasks/TasksClientPage.tsx | 10 +- .../migration.sql | 161 ++++++++++ .../migration.sql | 284 ++++++++++++++++++ packages/db/prisma/schema/control.prisma | 19 +- .../db/prisma/schema/framework-editor.prisma | 78 ++++- packages/db/prisma/schema/framework.prisma | 59 +++- packages/db/prisma/schema/policy.prisma | 29 +- packages/db/prisma/schema/task.prisma | 23 +- packages/docs/openapi.json | 40 +++ 41 files changed, 2023 insertions(+), 309 deletions(-) create mode 100644 packages/db/prisma/migrations/20260513150611_framework_scoped_control_links/migration.sql create mode 100644 packages/db/prisma/migrations/20260513153000_backfill_framework_scoped_control_links/migration.sql diff --git a/apps/api/src/controls/controls.controller.ts b/apps/api/src/controls/controls.controller.ts index 1b98543931..039a397d0b 100644 --- a/apps/api/src/controls/controls.controller.ts +++ b/apps/api/src/controls/controls.controller.ts @@ -84,8 +84,9 @@ export class ControlsController { async findOne( @OrganizationId() organizationId: string, @Param('id') id: string, + @Query('frameworkInstanceId') frameworkInstanceId?: string, ) { - return this.controlsService.findOne(id, organizationId); + return this.controlsService.findOne(id, organizationId, frameworkInstanceId); } @Post() @@ -105,11 +106,13 @@ export class ControlsController { @OrganizationId() organizationId: string, @Param('id') id: string, @Body() dto: LinkPoliciesDto, + @Query('frameworkInstanceId') frameworkInstanceId?: string, ) { return this.controlsService.linkPolicies( id, organizationId, dto.policyIds, + frameworkInstanceId, ); } @@ -120,8 +123,14 @@ export class ControlsController { @OrganizationId() organizationId: string, @Param('id') id: string, @Body() dto: LinkTasksDto, + @Query('frameworkInstanceId') frameworkInstanceId?: string, ) { - return this.controlsService.linkTasks(id, organizationId, dto.taskIds); + return this.controlsService.linkTasks( + id, + organizationId, + dto.taskIds, + frameworkInstanceId, + ); } @Post(':id/requirements/link') @@ -146,11 +155,13 @@ export class ControlsController { @OrganizationId() organizationId: string, @Param('id') id: string, @Body() dto: LinkDocumentTypesDto, + @Query('frameworkInstanceId') frameworkInstanceId?: string, ) { return this.controlsService.linkDocumentTypes( id, organizationId, dto.formTypes, + frameworkInstanceId, ); } @@ -162,11 +173,13 @@ export class ControlsController { @Param('id') id: string, @Param('formType', new ParseEnumPipe(EvidenceFormType)) formType: EvidenceFormType, + @Query('frameworkInstanceId') frameworkInstanceId?: string, ) { return this.controlsService.unlinkDocumentType( id, organizationId, formType, + frameworkInstanceId, ); } diff --git a/apps/api/src/controls/controls.service.ts b/apps/api/src/controls/controls.service.ts index c4c30ba173..57494eca9c 100644 --- a/apps/api/src/controls/controls.service.ts +++ b/apps/api/src/controls/controls.service.ts @@ -93,7 +93,15 @@ export class ControlsService { }; } - async findOne(controlId: string, organizationId: string) { + async findOne( + controlId: string, + organizationId: string, + frameworkInstanceId?: string, + ) { + if (frameworkInstanceId) { + return this.findOneForFramework(controlId, organizationId, frameworkInstanceId); + } + const control = await db.control.findUnique({ where: { id: controlId, organizationId }, include: { @@ -117,7 +125,11 @@ export class ControlsService { throw new NotFoundException('Control not found'); } - const formTypes = (control.controlDocumentTypes ?? []).map( + const policies = control.policies || []; + const tasks = control.tasks || []; + const controlDocumentTypes = control.controlDocumentTypes || []; + + const formTypes = controlDocumentTypes.map( (d) => d.formType, ); const notRelevantSettings = @@ -150,8 +162,6 @@ export class ControlsService { } // Compute progress - const policies = control.policies || []; - const tasks = control.tasks || []; const totalItems = policies.length + tasks.length; let policyCompleted = 0; @@ -168,7 +178,9 @@ export class ControlsService { return { ...control, - controlDocumentTypes: (control.controlDocumentTypes ?? []).map( + policies, + tasks, + controlDocumentTypes: controlDocumentTypes.map( (documentType) => ({ ...documentType, isNotRelevant: notRelevantFormTypes.has(documentType.formType), @@ -188,6 +200,105 @@ export class ControlsService { }; } + private async findOneForFramework( + controlId: string, + organizationId: string, + frameworkInstanceId: string, + ) { + await this.ensureFrameworkInstance(frameworkInstanceId, organizationId); + const control = await db.control.findUnique({ + where: { id: controlId, organizationId }, + include: { + frameworkPolicyLinks: { + where: { frameworkInstanceId }, + include: { policy: true }, + }, + frameworkTaskLinks: { + where: { frameworkInstanceId }, + include: { task: true }, + }, + frameworkDocumentLinks: { + where: { frameworkInstanceId }, + }, + requirementsMapped: { + where: { archivedAt: null }, + include: { + frameworkInstance: { + include: { framework: true, customFramework: true }, + }, + requirement: true, + customRequirement: true, + }, + }, + }, + }); + + if (!control) { + 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 formTypes = controlDocumentTypes.map((d) => d.formType); + const notRelevantSettings = + formTypes.length > 0 + ? await db.evidenceFormSetting.findMany({ + where: { + organizationId, + formType: { in: formTypes }, + isNotRelevant: true, + }, + select: { formType: true }, + }) + : []; + const notRelevantFormTypes = new Set( + notRelevantSettings.map((setting) => setting.formType), + ); + const submissionCountsByFormType: Record = {}; + if (formTypes.length > 0) { + const grouped = await db.evidenceSubmission.groupBy({ + by: ['formType'], + where: { + organizationId, + formType: { in: formTypes }, + }, + _count: { _all: true }, + }); + for (const g of grouped) { + submissionCountsByFormType[g.formType] = g._count._all; + } + } + + const policyCompleted = policies.filter((p) => p.status === 'published').length; + const taskCompleted = tasks.filter( + (t) => t.status === 'done' || t.status === 'not_relevant', + ).length; + const completed = policyCompleted + taskCompleted; + const totalItems = policies.length + tasks.length; + + return { + ...control, + policies, + tasks, + controlDocumentTypes: controlDocumentTypes.map((documentType) => ({ + ...documentType, + isNotRelevant: notRelevantFormTypes.has(documentType.formType), + })), + submissionCountsByFormType, + progress: { + total: totalItems, + completed, + progress: + totalItems > 0 ? Math.round((completed / totalItems) * 100) : 0, + byType: { + policy: { total: policies.length, completed: policyCompleted }, + task: { total: tasks.length, completed: taskCompleted }, + }, + }, + }; + } + async getOptions(organizationId: string) { const [policies, tasks, frameworkInstances] = await Promise.all([ db.policy.findMany({ @@ -480,10 +591,25 @@ export class ControlsService { return control; } + private async ensureFrameworkInstance( + frameworkInstanceId: string, + organizationId: string, + ) { + const frameworkInstance = await db.frameworkInstance.findUnique({ + where: { id: frameworkInstanceId, organizationId }, + select: { id: true }, + }); + if (!frameworkInstance) { + throw new NotFoundException('Framework instance not found'); + } + return frameworkInstance; + } + async linkPolicies( controlId: string, organizationId: string, policyIds: string[], + frameworkInstanceId?: string, ) { await this.ensureControl(controlId, organizationId); @@ -495,10 +621,22 @@ export class ControlsService { throw new BadRequestException('No valid policies to link'); } - await db.control.update({ - where: { id: controlId }, - data: { policies: { connect: policies.map((p) => ({ id: p.id })) } }, - }); + if (frameworkInstanceId) { + await this.ensureFrameworkInstance(frameworkInstanceId, organizationId); + await db.frameworkControlPolicyLink.createMany({ + data: policies.map((policy) => ({ + frameworkInstanceId, + controlId, + policyId: policy.id, + })), + skipDuplicates: true, + }); + } else { + await db.control.update({ + where: { id: controlId }, + data: { policies: { connect: policies.map((p) => ({ id: p.id })) } }, + }); + } return { count: policies.length }; } @@ -507,6 +645,7 @@ export class ControlsService { controlId: string, organizationId: string, taskIds: string[], + frameworkInstanceId?: string, ) { await this.ensureControl(controlId, organizationId); @@ -518,10 +657,22 @@ export class ControlsService { throw new BadRequestException('No valid tasks to link'); } - await db.control.update({ - where: { id: controlId }, - data: { tasks: { connect: tasks.map((t) => ({ id: t.id })) } }, - }); + if (frameworkInstanceId) { + await this.ensureFrameworkInstance(frameworkInstanceId, organizationId); + await db.frameworkControlTaskLink.createMany({ + data: tasks.map((task) => ({ + frameworkInstanceId, + controlId, + taskId: task.id, + })), + skipDuplicates: true, + }); + } else { + await db.control.update({ + where: { id: controlId }, + data: { tasks: { connect: tasks.map((t) => ({ id: t.id })) } }, + }); + } return { count: tasks.length }; } @@ -627,8 +778,21 @@ export class ControlsService { controlId: string, organizationId: string, formTypes: EvidenceFormType[], + frameworkInstanceId?: string, ) { await this.ensureControl(controlId, organizationId); + if (frameworkInstanceId) { + await this.ensureFrameworkInstance(frameworkInstanceId, organizationId); + const result = await db.frameworkControlDocumentTypeLink.createMany({ + data: formTypes.map((formType) => ({ + frameworkInstanceId, + controlId, + formType, + })), + skipDuplicates: true, + }); + return { count: result.count }; + } const result = await db.controlDocumentType.createMany({ data: formTypes.map((formType) => ({ controlId, formType })), skipDuplicates: true, @@ -640,8 +804,16 @@ export class ControlsService { controlId: string, organizationId: string, formType: EvidenceFormType, + frameworkInstanceId?: string, ) { await this.ensureControl(controlId, organizationId); + if (frameworkInstanceId) { + await this.ensureFrameworkInstance(frameworkInstanceId, organizationId); + await db.frameworkControlDocumentTypeLink.deleteMany({ + where: { frameworkInstanceId, controlId, formType }, + }); + return { success: true }; + } await db.controlDocumentType.deleteMany({ where: { controlId, formType }, }); diff --git a/apps/api/src/framework-editor-versions/framework-manifest-builder.spec.ts b/apps/api/src/framework-editor-versions/framework-manifest-builder.spec.ts index 77fd7816e2..bd4c5b2b6b 100644 --- a/apps/api/src/framework-editor-versions/framework-manifest-builder.spec.ts +++ b/apps/api/src/framework-editor-versions/framework-manifest-builder.spec.ts @@ -29,13 +29,13 @@ describe('buildManifestForFramework', () => { name: 'Logical Access Controls', description: 'desc', requirements: [{ id: 'frk_rq_cc61' }], - policyTemplates: [ - { id: 'frk_pt_acc', name: 'Access Policy', description: null, content: [{}], frequency: 'yearly', department: 'it' }, + frameworkPolicyLinks: [ + { policyTemplate: { id: 'frk_pt_acc', name: 'Access Policy', description: null, content: [{}], frequency: 'yearly', department: 'it' } }, ], - taskTemplates: [ - { id: 'frk_tt_rev', name: 'Review Access', description: 'Review quarterly', frequency: 'quarterly', department: 'it' }, + frameworkTaskLinks: [ + { taskTemplate: { id: 'frk_tt_rev', name: 'Review Access', description: 'Review quarterly', frequency: 'quarterly', department: 'it' } }, ], - documentTypes: ['rbac_matrix'], + frameworkDocumentLinks: [{ formType: 'rbac_matrix' }], }, ], }, @@ -70,9 +70,9 @@ describe('buildManifestForFramework', () => { { id: 'ct_shared', name: 'Shared', description: 'd', requirements: [{ id: 'rq_a' }, { id: 'rq_b' }], - policyTemplates: [{ id: 'pt_shared', name: 'P', description: null, content: [], frequency: null, department: null }], - taskTemplates: [{ id: 'tt_shared', name: 'T', description: '', frequency: null, department: null }], - documentTypes: [], + frameworkPolicyLinks: [{ policyTemplate: { id: 'pt_shared', name: 'P', description: null, content: [], frequency: null, department: null } }], + frameworkTaskLinks: [{ taskTemplate: { id: 'tt_shared', name: 'T', description: '', frequency: null, department: null } }], + frameworkDocumentLinks: [], }, ], }, @@ -82,9 +82,9 @@ describe('buildManifestForFramework', () => { { id: 'ct_shared', name: 'Shared', description: 'd', requirements: [{ id: 'rq_a' }, { id: 'rq_b' }], - policyTemplates: [{ id: 'pt_shared', name: 'P', description: null, content: [], frequency: null, department: null }], - taskTemplates: [{ id: 'tt_shared', name: 'T', description: '', frequency: null, department: null }], - documentTypes: [], + frameworkPolicyLinks: [{ policyTemplate: { id: 'pt_shared', name: 'P', description: null, content: [], frequency: null, department: null } }], + frameworkTaskLinks: [{ taskTemplate: { id: 'tt_shared', name: 'T', description: '', frequency: null, department: null } }], + frameworkDocumentLinks: [], }, ], }, @@ -103,4 +103,43 @@ describe('buildManifestForFramework', () => { (db.frameworkEditorFramework.findUnique as jest.Mock).mockResolvedValue(null); await expect(buildManifestForFramework('missing')).rejects.toThrow('Framework not found'); }); + + it('only includes policy task and document links scoped to the requested framework', async () => { + (db.frameworkEditorFramework.findUnique as jest.Mock).mockResolvedValue({ + id: 'frk_hipaa', + name: 'HIPAA', + version: '2026', + description: null, + requirements: [ + { + id: 'rq_hipaa', + identifier: 'H1', + name: 'HIPAA requirement', + description: null, + controlTemplates: [ + { + id: 'ct_shared', + name: 'Shared', + description: 'd', + requirements: [{ id: 'rq_hipaa' }, { id: 'rq_pci' }], + frameworkPolicyLinks: [ + { policyTemplate: { id: 'pt_hipaa', name: 'HIPAA Policy', description: null, content: [], frequency: null, department: null } }, + ], + frameworkTaskLinks: [ + { taskTemplate: { id: 'tt_hipaa', name: 'HIPAA Task', description: '', frequency: null, department: null } }, + ], + frameworkDocumentLinks: [{ formType: 'access_request' }], + }, + ], + }, + ], + }); + + const manifest = await buildManifestForFramework('frk_hipaa'); + + expect(manifest.controls[0].policyIds).toEqual(['pt_hipaa']); + expect(manifest.controls[0].taskIds).toEqual(['tt_hipaa']); + expect(manifest.controls[0].documentTypes).toEqual(['access_request']); + expect(manifest.controls[0].requirementIds).toEqual(['rq_hipaa']); + }); }); 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 f6c8c13b3b..094d47d90c 100644 --- a/apps/api/src/framework-editor-versions/framework-manifest-builder.ts +++ b/apps/api/src/framework-editor-versions/framework-manifest-builder.ts @@ -16,8 +16,18 @@ export async function buildManifestForFramework(frameworkId: string): Promise link.policyTemplate, + ); + const taskTemplates = ct.frameworkTaskLinks.map( + (link) => link.taskTemplate, + ); if (!controlsMap.has(ct.id)) { controlsMap.set(ct.id, { id: ct.id, @@ -47,12 +63,12 @@ export async function buildManifestForFramework(frameworkId: string): Promise r.id) .filter((id) => ownRequirementIds.has(id)), - policyIds: ct.policyTemplates.map((p) => p.id), - taskIds: ct.taskTemplates.map((t) => t.id), - documentTypes: [...ct.documentTypes], + policyIds: policyTemplates.map((p) => p.id), + taskIds: taskTemplates.map((t) => t.id), + documentTypes: ct.frameworkDocumentLinks.map((link) => link.formType), }); } - for (const pt of ct.policyTemplates) { + for (const pt of policyTemplates) { if (!policiesMap.has(pt.id)) { policiesMap.set(pt.id, { id: pt.id, @@ -64,7 +80,7 @@ export async function buildManifestForFramework(frameworkId: string): Promise ({ frameworkEditorRequirement: { findMany: jest.fn(), }, + frameworkEditorFramework: { + findUnique: jest.fn(), + }, + frameworkEditorControlDocumentTypeLink: { + deleteMany: jest.fn(), + createMany: jest.fn(), + }, + frameworkEditorControlPolicyTemplateLink: { + createMany: jest.fn(), + deleteMany: jest.fn(), + }, + frameworkEditorControlTaskTemplateLink: { + createMany: jest.fn(), + deleteMany: jest.fn(), + }, + $transaction: jest.fn((operations) => Promise.all(operations)), }, Prisma: { PrismaClientKnownRequestError: class {} }, })); @@ -29,6 +45,19 @@ describe('ControlTemplateService', () => { id: 'frk_ct_new', name: 'New Control', }); + (mockDb.frameworkEditorFramework.findUnique as jest.Mock).mockResolvedValue({ + id: 'frk_soc2', + }); + (mockDb.frameworkEditorControlTemplate.findUnique as jest.Mock).mockResolvedValue({ + id: 'frk_ct_new', + name: 'New Control', + }); + (mockDb.frameworkEditorControlDocumentTypeLink.createMany as jest.Mock).mockResolvedValue({ + count: 1, + }); + (mockDb.frameworkEditorControlDocumentTypeLink.deleteMany as jest.Mock).mockResolvedValue({ + count: 0, + }); }); describe('create', () => { @@ -66,12 +95,23 @@ describe('ControlTemplateService', () => { }); }); - it('persists documentTypes when provided', async () => { - await service.create({ ...baseDto, documentTypes: ['penetration-test'] }); + it('persists documentTypes as framework-scoped links when provided', async () => { + await service.create({ + ...baseDto, + frameworkId: 'frk_soc2', + documentTypes: ['penetration-test'], + }); - const createArgs = (mockDb.frameworkEditorControlTemplate.create as jest.Mock).mock - .calls[0][0]; - expect(createArgs.data.documentTypes).toEqual(['penetration-test']); + expect(mockDb.frameworkEditorControlDocumentTypeLink.createMany).toHaveBeenCalledWith({ + data: [ + { + frameworkId: 'frk_soc2', + controlTemplateId: 'frk_ct_new', + formType: 'penetration-test', + }, + ], + skipDuplicates: true, + }); }); it('omits documentTypes when not provided', async () => { @@ -81,5 +121,43 @@ describe('ControlTemplateService', () => { .calls[0][0]; expect(createArgs.data).not.toHaveProperty('documentTypes'); }); + + it('requires frameworkId when documentTypes are provided', async () => { + await expect( + service.create({ ...baseDto, documentTypes: ['penetration-test'] }), + ).rejects.toThrow('frameworkId is required'); + }); + }); + + describe('scoped links', () => { + it('links policy templates with framework context', async () => { + await service.linkPolicyTemplate('frk_ct_new', 'frk_pt_1', 'frk_soc2'); + + expect(mockDb.frameworkEditorControlPolicyTemplateLink.createMany).toHaveBeenCalledWith({ + data: [ + { + frameworkId: 'frk_soc2', + controlTemplateId: 'frk_ct_new', + policyTemplateId: 'frk_pt_1', + }, + ], + skipDuplicates: true, + }); + }); + + it('links task templates with framework context', async () => { + await service.linkTaskTemplate('frk_ct_new', 'frk_tt_1', 'frk_soc2'); + + expect(mockDb.frameworkEditorControlTaskTemplateLink.createMany).toHaveBeenCalledWith({ + data: [ + { + frameworkId: 'frk_soc2', + controlTemplateId: 'frk_ct_new', + taskTemplateId: 'frk_tt_1', + }, + ], + skipDuplicates: true, + }); + }); }); }); 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 565fc4a151..c9b2c05f61 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 @@ -1,4 +1,5 @@ import { + BadRequestException, Injectable, NotFoundException, ConflictException, @@ -14,13 +15,56 @@ export class ControlTemplateService { private readonly logger = new Logger(ControlTemplateService.name); async findAll(take = 500, skip = 0, frameworkId?: string) { - return db.frameworkEditorControlTemplate.findMany({ + if (frameworkId) { + const controls = await db.frameworkEditorControlTemplate.findMany({ + take, + skip, + orderBy: { createdAt: 'asc' }, + where: { requirements: { some: { frameworkId } } }, + include: { + requirements: { + select: { + id: true, + name: true, + framework: { select: { name: true } }, + }, + }, + frameworkPolicyLinks: { + where: { frameworkId }, + select: { policyTemplate: { select: { id: true, name: true } } }, + }, + frameworkTaskLinks: { + where: { frameworkId }, + select: { taskTemplate: { select: { id: true, name: true } } }, + }, + frameworkDocumentLinks: { + where: { frameworkId }, + select: { formType: true }, + }, + }, + }); + + return controls.map( + ({ + frameworkPolicyLinks, + frameworkTaskLinks, + frameworkDocumentLinks, + ...control + }) => ({ + ...control, + policyTemplates: frameworkPolicyLinks.map( + (link) => link.policyTemplate, + ), + taskTemplates: frameworkTaskLinks.map((link) => link.taskTemplate), + documentTypes: frameworkDocumentLinks.map((link) => link.formType), + }), + ); + } + + const controls = await db.frameworkEditorControlTemplate.findMany({ take, skip, orderBy: { createdAt: 'asc' }, - where: frameworkId - ? { requirements: { some: { frameworkId } } } - : undefined, include: { policyTemplates: { select: { id: true, name: true } }, requirements: { @@ -33,6 +77,7 @@ export class ControlTemplateService { taskTemplates: { select: { id: true, name: true } }, }, }); + return controls; } async findById(id: string) { @@ -59,11 +104,15 @@ export class ControlTemplateService { data: { name: dto.name, description: dto.description ?? '', - ...(dto.documentTypes && { - documentTypes: dto.documentTypes as EvidenceFormType[], - }), }, }); + if (dto.documentTypes !== undefined) { + await this.replaceDocumentTypeLinks({ + controlId: ct.id, + frameworkId: dto.frameworkId, + formTypes: dto.documentTypes as EvidenceFormType[], + }); + } this.logger.log(`Created control template: ${ct.name} (${ct.id})`); return ct; } @@ -75,11 +124,15 @@ export class ControlTemplateService { data: { ...(dto.name !== undefined && { name: dto.name }), ...(dto.description !== undefined && { description: dto.description }), - ...(dto.documentTypes !== undefined && { - documentTypes: dto.documentTypes as EvidenceFormType[], - }), }, }); + if (dto.documentTypes !== undefined) { + await this.replaceDocumentTypeLinks({ + controlId: id, + frameworkId: dto.frameworkId, + formTypes: dto.documentTypes as EvidenceFormType[], + }); + } this.logger.log(`Updated control template: ${updated.name} (${id})`); return updated; } @@ -119,35 +172,172 @@ export class ControlTemplateService { return { message: 'Requirement unlinked' }; } - async linkPolicyTemplate(controlId: string, policyTemplateId: string) { - await db.frameworkEditorControlTemplate.update({ - where: { id: controlId }, - data: { policyTemplates: { connect: { id: policyTemplateId } } }, + async linkPolicyTemplate( + controlId: string, + policyTemplateId: string, + frameworkId?: string, + ) { + const scopedFrameworkId = await this.ensureFrameworkScopedControl({ + controlId, + frameworkId, + }); + await db.frameworkEditorControlPolicyTemplateLink.createMany({ + data: [{ + frameworkId: scopedFrameworkId, + controlTemplateId: controlId, + policyTemplateId, + }], + skipDuplicates: true, }); return { message: 'Policy template linked' }; } - async unlinkPolicyTemplate(controlId: string, policyTemplateId: string) { - await db.frameworkEditorControlTemplate.update({ - where: { id: controlId }, - data: { policyTemplates: { disconnect: { id: policyTemplateId } } }, + async unlinkPolicyTemplate( + controlId: string, + policyTemplateId: string, + frameworkId?: string, + ) { + const scopedFrameworkId = await this.ensureFrameworkScopedControl({ + controlId, + frameworkId, + }); + await db.frameworkEditorControlPolicyTemplateLink.deleteMany({ + where: { + frameworkId: scopedFrameworkId, + controlTemplateId: controlId, + policyTemplateId, + }, }); return { message: 'Policy template unlinked' }; } - async linkTaskTemplate(controlId: string, taskTemplateId: string) { - await db.frameworkEditorControlTemplate.update({ - where: { id: controlId }, - data: { taskTemplates: { connect: { id: taskTemplateId } } }, + async linkTaskTemplate( + controlId: string, + taskTemplateId: string, + frameworkId?: string, + ) { + const scopedFrameworkId = await this.ensureFrameworkScopedControl({ + controlId, + frameworkId, + }); + await db.frameworkEditorControlTaskTemplateLink.createMany({ + data: [{ + frameworkId: scopedFrameworkId, + controlTemplateId: controlId, + taskTemplateId, + }], + skipDuplicates: true, }); return { message: 'Task template linked' }; } - async unlinkTaskTemplate(controlId: string, taskTemplateId: string) { - await db.frameworkEditorControlTemplate.update({ - where: { id: controlId }, - data: { taskTemplates: { disconnect: { id: taskTemplateId } } }, + async unlinkTaskTemplate( + controlId: string, + taskTemplateId: string, + frameworkId?: string, + ) { + const scopedFrameworkId = await this.ensureFrameworkScopedControl({ + controlId, + frameworkId, + }); + await db.frameworkEditorControlTaskTemplateLink.deleteMany({ + where: { + frameworkId: scopedFrameworkId, + controlTemplateId: controlId, + taskTemplateId, + }, }); return { message: 'Task template unlinked' }; } + + async linkDocumentType( + controlId: string, + formType: EvidenceFormType, + frameworkId?: string, + ) { + const scopedFrameworkId = await this.ensureFrameworkScopedControl({ + controlId, + frameworkId, + }); + await db.frameworkEditorControlDocumentTypeLink.createMany({ + data: [{ + frameworkId: scopedFrameworkId, + controlTemplateId: controlId, + formType, + }], + skipDuplicates: true, + }); + return { message: 'Document type linked' }; + } + + async unlinkDocumentType( + controlId: string, + formType: EvidenceFormType, + frameworkId?: string, + ) { + const scopedFrameworkId = await this.ensureFrameworkScopedControl({ + controlId, + frameworkId, + }); + await db.frameworkEditorControlDocumentTypeLink.deleteMany({ + where: { frameworkId: scopedFrameworkId, controlTemplateId: controlId, formType }, + }); + return { message: 'Document type unlinked' }; + } + + private async replaceDocumentTypeLinks(params: { + controlId: string; + frameworkId?: string; + formTypes: EvidenceFormType[]; + }) { + const scopedFrameworkId = await this.ensureFrameworkScopedControl({ + controlId: params.controlId, + frameworkId: params.frameworkId, + }); + const uniqueFormTypes = Array.from(new Set(params.formTypes)); + await db.$transaction([ + db.frameworkEditorControlDocumentTypeLink.deleteMany({ + where: { + frameworkId: scopedFrameworkId, + controlTemplateId: params.controlId, + }, + }), + db.frameworkEditorControlDocumentTypeLink.createMany({ + data: uniqueFormTypes.map((formType) => ({ + frameworkId: scopedFrameworkId, + controlTemplateId: params.controlId, + formType, + })), + skipDuplicates: true, + }), + ]); + } + + private async ensureFrameworkScopedControl(params: { + controlId: string; + frameworkId?: string; + }): Promise { + if (!params.frameworkId) { + throw new BadRequestException( + 'frameworkId is required for policy, task, and document links', + ); + } + const [framework, control] = await Promise.all([ + db.frameworkEditorFramework.findUnique({ + where: { id: params.frameworkId }, + select: { id: true }, + }), + db.frameworkEditorControlTemplate.findUnique({ + where: { id: params.controlId }, + select: { id: true }, + }), + ]); + if (!framework) throw new NotFoundException('Framework not found'); + if (!control) { + throw new NotFoundException( + `Control template ${params.controlId} not found`, + ); + } + return params.frameworkId; + } } 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 d83d20c310..58b523b980 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 @@ -24,4 +24,9 @@ export class CreateControlTemplateDto { @IsString({ each: true }) @IsOptional() documentTypes?: string[]; + + @ApiPropertyOptional({ example: 'frk_soc2' }) + @IsString() + @IsOptional() + frameworkId?: string; } 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 6637eb4c74..cbb1627c49 100644 --- a/apps/api/src/framework-editor/framework/framework-export.service.ts +++ b/apps/api/src/framework-editor/framework/framework-export.service.ts @@ -67,17 +67,31 @@ export class FrameworkExportService { where: { requirements: { some: { frameworkId } } }, include: { requirements: { select: { id: true }, where: { frameworkId } }, - policyTemplates: { select: { id: true } }, - taskTemplates: { select: { id: true } }, + frameworkPolicyLinks: { + where: { frameworkId }, + select: { policyTemplateId: true }, + }, + frameworkTaskLinks: { + where: { frameworkId }, + select: { taskTemplateId: true }, + }, + frameworkDocumentLinks: { + where: { frameworkId }, + select: { formType: true }, + }, }, orderBy: { name: 'asc' }, }); const policyIds = new Set( - controlTemplates.flatMap((ct) => ct.policyTemplates.map((p) => p.id)), + controlTemplates.flatMap((ct) => + ct.frameworkPolicyLinks.map((link) => link.policyTemplateId), + ), ); const taskIds = new Set( - controlTemplates.flatMap((ct) => ct.taskTemplates.map((t) => t.id)), + controlTemplates.flatMap((ct) => + ct.frameworkTaskLinks.map((link) => link.taskTemplateId), + ), ); const policyTemplates = await db.frameworkEditorPolicyTemplate.findMany({ @@ -117,15 +131,15 @@ export class FrameworkExportService { controlTemplates: controlTemplates.map((ct) => ({ name: ct.name, description: ct.description, - documentTypes: ct.documentTypes as string[], + documentTypes: ct.frameworkDocumentLinks.map((link) => link.formType), requirementIndices: ct.requirements .map((r) => reqIdToIndex.get(r.id)) .filter((i): i is number => i !== undefined), - policyTemplateIndices: ct.policyTemplates - .map((p) => policyIdToIndex.get(p.id)) + policyTemplateIndices: ct.frameworkPolicyLinks + .map((link) => policyIdToIndex.get(link.policyTemplateId)) .filter((i): i is number => i !== undefined), - taskTemplateIndices: ct.taskTemplates - .map((t) => taskIdToIndex.get(t.id)) + taskTemplateIndices: ct.frameworkTaskLinks + .map((link) => taskIdToIndex.get(link.taskTemplateId)) .filter((i): i is number => i !== undefined), })), policyTemplates: policyTemplates.map((p) => ({ @@ -205,33 +219,63 @@ export class FrameworkExportService { ), ); - await Promise.all( + const createdControls = await Promise.all( (dto.controlTemplates ?? []).map((ct) => tx.frameworkEditorControlTemplate.create({ data: { name: ct.name, description: ct.description, - documentTypes: ct.documentTypes ?? [], requirements: { connect: (ct.requirementIndices ?? []).map((i) => ({ id: createdRequirements[i].id, })), }, - policyTemplates: { - connect: (ct.policyTemplateIndices ?? []).map((i) => ({ - id: createdPolicies[i].id, - })), - }, - taskTemplates: { - connect: (ct.taskTemplateIndices ?? []).map((i) => ({ - id: createdTasks[i].id, - })), - }, }, }), ), ); + const policyLinks = (dto.controlTemplates ?? []).flatMap((ct, controlIndex) => + (ct.policyTemplateIndices ?? []).map((policyIndex) => ({ + frameworkId: framework.id, + controlTemplateId: createdControls[controlIndex].id, + policyTemplateId: createdPolicies[policyIndex].id, + })), + ); + const taskLinks = (dto.controlTemplates ?? []).flatMap((ct, controlIndex) => + (ct.taskTemplateIndices ?? []).map((taskIndex) => ({ + frameworkId: framework.id, + controlTemplateId: createdControls[controlIndex].id, + taskTemplateId: createdTasks[taskIndex].id, + })), + ); + const documentLinks = (dto.controlTemplates ?? []).flatMap((ct, controlIndex) => + (ct.documentTypes ?? []).map((formType) => ({ + frameworkId: framework.id, + controlTemplateId: createdControls[controlIndex].id, + formType: formType as EvidenceFormType, + })), + ); + + if (policyLinks.length > 0) { + await tx.frameworkEditorControlPolicyTemplateLink.createMany({ + data: policyLinks, + skipDuplicates: true, + }); + } + if (taskLinks.length > 0) { + await tx.frameworkEditorControlTaskTemplateLink.createMany({ + data: taskLinks, + skipDuplicates: true, + }); + } + if (documentLinks.length > 0) { + await tx.frameworkEditorControlDocumentTypeLink.createMany({ + data: documentLinks, + skipDuplicates: true, + }); + } + this.logger.log( `Imported framework "${framework.name}" (${framework.id}): ` + `${createdRequirements.length} requirements, ` + diff --git a/apps/api/src/framework-editor/framework/framework.service.ts b/apps/api/src/framework-editor/framework/framework.service.ts index 1b9f5c5327..26bc26a267 100644 --- a/apps/api/src/framework-editor/framework/framework.service.ts +++ b/apps/api/src/framework-editor/framework/framework.service.ts @@ -127,10 +127,9 @@ export class FrameworkEditorFrameworkService { async getControls(frameworkId: string) { await this.findById(frameworkId); - return db.frameworkEditorControlTemplate.findMany({ + const controls = await db.frameworkEditorControlTemplate.findMany({ where: { requirements: { some: { frameworkId } } }, include: { - policyTemplates: { select: { id: true, name: true } }, requirements: { select: { id: true, @@ -138,10 +137,37 @@ export class FrameworkEditorFrameworkService { framework: { select: { name: true } }, }, }, - taskTemplates: { select: { id: true, name: true } }, + frameworkPolicyLinks: { + where: { frameworkId }, + select: { policyTemplate: { select: { id: true, name: true } } }, + }, + frameworkTaskLinks: { + where: { frameworkId }, + select: { taskTemplate: { select: { id: true, name: true } } }, + }, + frameworkDocumentLinks: { + where: { frameworkId }, + select: { formType: true }, + }, }, orderBy: { createdAt: 'asc' }, }); + + return controls.map( + ({ + frameworkPolicyLinks, + frameworkTaskLinks, + frameworkDocumentLinks, + ...control + }) => ({ + ...control, + policyTemplates: frameworkPolicyLinks.map( + (link) => link.policyTemplate, + ), + taskTemplates: frameworkTaskLinks.map((link) => link.taskTemplate), + documentTypes: frameworkDocumentLinks.map((link) => link.formType), + }), + ); } async getPolicies(frameworkId: string) { @@ -149,8 +175,8 @@ export class FrameworkEditorFrameworkService { return db.frameworkEditorPolicyTemplate.findMany({ where: { - controlTemplates: { - some: { requirements: { some: { frameworkId } } }, + frameworkControlLinks: { + some: { frameworkId }, }, }, orderBy: { name: 'asc' }, @@ -162,25 +188,47 @@ export class FrameworkEditorFrameworkService { return db.frameworkEditorTaskTemplate.findMany({ where: { - controlTemplates: { - some: { requirements: { some: { frameworkId } } }, + frameworkControlLinks: { + some: { frameworkId }, }, }, include: { - controlTemplates: { select: { id: true, name: true } }, + frameworkControlLinks: { + where: { frameworkId }, + select: { controlTemplate: { select: { id: true, name: true } } }, + }, }, orderBy: { name: 'asc' }, - }); + }).then((tasks) => + tasks.map(({ frameworkControlLinks, ...task }) => ({ + ...task, + controlTemplates: frameworkControlLinks.map( + (link) => link.controlTemplate, + ), + })), + ); } async getDocuments(frameworkId: string) { await this.findById(frameworkId); - return db.frameworkEditorControlTemplate.findMany({ + const controls = await db.frameworkEditorControlTemplate.findMany({ where: { requirements: { some: { frameworkId } } }, - select: { id: true, name: true, documentTypes: true }, + select: { + id: true, + name: true, + frameworkDocumentLinks: { + where: { frameworkId }, + select: { formType: true }, + }, + }, orderBy: { name: 'asc' }, }); + + return controls.map(({ frameworkDocumentLinks, ...control }) => ({ + ...control, + documentTypes: frameworkDocumentLinks.map((link) => link.formType), + })); } async linkControl(frameworkId: string, controlId: string) { @@ -213,7 +261,7 @@ export class FrameworkEditorFrameworkService { where: { requirements: { some: { frameworkId } } }, select: { id: true }, }) - .then((cts) => cts.map((ct) => ({ id: ct.id }))); + .then((cts) => cts.map((ct) => ct.id)); if (controlIds.length === 0) { throw new ConflictException( @@ -221,9 +269,13 @@ export class FrameworkEditorFrameworkService { ); } - await db.frameworkEditorTaskTemplate.update({ - where: { id: taskId }, - data: { controlTemplates: { connect: controlIds } }, + await db.frameworkEditorControlTaskTemplateLink.createMany({ + data: controlIds.map((controlTemplateId) => ({ + frameworkId, + controlTemplateId, + taskTemplateId: taskId, + })), + skipDuplicates: true, }); this.logger.log(`Linked task ${taskId} to framework ${frameworkId}`); @@ -238,7 +290,7 @@ export class FrameworkEditorFrameworkService { where: { requirements: { some: { frameworkId } } }, select: { id: true }, }) - .then((cts) => cts.map((ct) => ({ id: ct.id }))); + .then((cts) => cts.map((ct) => ct.id)); if (controlIds.length === 0) { throw new ConflictException( @@ -246,9 +298,13 @@ export class FrameworkEditorFrameworkService { ); } - await db.frameworkEditorPolicyTemplate.update({ - where: { id: policyId }, - data: { controlTemplates: { connect: controlIds } }, + await db.frameworkEditorControlPolicyTemplateLink.createMany({ + data: controlIds.map((controlTemplateId) => ({ + frameworkId, + controlTemplateId, + policyTemplateId: policyId, + })), + skipDuplicates: true, }); this.logger.log(`Linked policy ${policyId} to framework ${frameworkId}`); diff --git a/apps/api/src/framework-editor/policy-template/policy-template.service.ts b/apps/api/src/framework-editor/policy-template/policy-template.service.ts index d97b332eba..234a479e02 100644 --- a/apps/api/src/framework-editor/policy-template/policy-template.service.ts +++ b/apps/api/src/framework-editor/policy-template/policy-template.service.ts @@ -13,17 +13,43 @@ export class PolicyTemplateService { private readonly logger = new Logger(PolicyTemplateService.name); async findAll(take = 500, skip = 0, frameworkId?: string) { + if (frameworkId) { + const policies = await db.frameworkEditorPolicyTemplate.findMany({ + take, + skip, + orderBy: { name: 'asc' }, + where: { frameworkControlLinks: { some: { frameworkId } } }, + include: { + frameworkControlLinks: { + where: { frameworkId }, + select: { + controlTemplate: { + select: { + id: true, + name: true, + requirements: { + select: { + framework: { select: { id: true, name: true } }, + }, + }, + }, + }, + }, + }, + }, + }); + return policies.map(({ frameworkControlLinks, ...policy }) => ({ + ...policy, + controlTemplates: frameworkControlLinks.map( + (link) => link.controlTemplate, + ), + })); + } + return db.frameworkEditorPolicyTemplate.findMany({ take, skip, orderBy: { name: 'asc' }, - where: frameworkId - ? { - controlTemplates: { - some: { requirements: { some: { frameworkId } } }, - }, - } - : undefined, include: { controlTemplates: { select: { diff --git a/apps/api/src/framework-editor/task-template/task-template.controller.ts b/apps/api/src/framework-editor/task-template/task-template.controller.ts index f0cd7a7170..8b15b71a25 100644 --- a/apps/api/src/framework-editor/task-template/task-template.controller.ts +++ b/apps/api/src/framework-editor/task-template/task-template.controller.ts @@ -111,4 +111,32 @@ export class TaskTemplateController { ) { return await this.taskTemplateService.deleteById(taskTemplateId); } + + @Post(':id/control-templates/:controlTemplateId') + @ApiOperation({ summary: 'Link a control template to a task template' }) + async linkControlTemplate( + @Param('id', ValidateIdPipe) taskTemplateId: string, + @Param('controlTemplateId') controlTemplateId: string, + @Query('frameworkId') frameworkId?: string, + ) { + return this.taskTemplateService.linkControlTemplate( + taskTemplateId, + controlTemplateId, + frameworkId, + ); + } + + @Delete(':id/control-templates/:controlTemplateId') + @ApiOperation({ summary: 'Unlink a control template from a task template' }) + async unlinkControlTemplate( + @Param('id', ValidateIdPipe) taskTemplateId: string, + @Param('controlTemplateId') controlTemplateId: string, + @Query('frameworkId') frameworkId?: string, + ) { + return this.taskTemplateService.unlinkControlTemplate( + taskTemplateId, + controlTemplateId, + frameworkId, + ); + } } diff --git a/apps/api/src/framework-editor/task-template/task-template.service.ts b/apps/api/src/framework-editor/task-template/task-template.service.ts index 217b865803..0c660fcc0e 100644 --- a/apps/api/src/framework-editor/task-template/task-template.service.ts +++ b/apps/api/src/framework-editor/task-template/task-template.service.ts @@ -29,15 +29,43 @@ export class TaskTemplateService { async findAll(frameworkId?: string) { try { + if (frameworkId) { + const taskTemplates = await db.frameworkEditorTaskTemplate.findMany({ + orderBy: { name: 'asc' }, + where: { frameworkControlLinks: { some: { frameworkId } } }, + include: { + frameworkControlLinks: { + where: { frameworkId }, + select: { + controlTemplate: { + select: { + id: true, + name: true, + requirements: { + select: { + framework: { select: { id: true, name: true } }, + }, + }, + }, + }, + }, + }, + }, + }); + + this.logger.log( + `Retrieved ${taskTemplates.length} framework editor task templates`, + ); + return taskTemplates.map(({ frameworkControlLinks, ...task }) => ({ + ...task, + controlTemplates: frameworkControlLinks.map( + (link) => link.controlTemplate, + ), + })); + } + const taskTemplates = await db.frameworkEditorTaskTemplate.findMany({ orderBy: { name: 'asc' }, - where: frameworkId - ? { - controlTemplates: { - some: { requirements: { some: { frameworkId } } }, - }, - } - : undefined, include: { controlTemplates: { select: { @@ -66,6 +94,46 @@ export class TaskTemplateService { } } + async linkControlTemplate( + taskTemplateId: string, + controlTemplateId: string, + frameworkId?: string, + ) { + if (!frameworkId) { + throw new NotFoundException('Framework not found'); + } + await Promise.all([ + this.findById(taskTemplateId), + db.frameworkEditorControlTemplate.findUniqueOrThrow({ + where: { id: controlTemplateId }, + select: { id: true }, + }), + db.frameworkEditorFramework.findUniqueOrThrow({ + where: { id: frameworkId }, + select: { id: true }, + }), + ]); + await db.frameworkEditorControlTaskTemplateLink.createMany({ + data: [{ frameworkId, controlTemplateId, taskTemplateId }], + skipDuplicates: true, + }); + return { message: 'Control linked' }; + } + + async unlinkControlTemplate( + taskTemplateId: string, + controlTemplateId: string, + frameworkId?: string, + ) { + if (!frameworkId) { + throw new NotFoundException('Framework not found'); + } + await db.frameworkEditorControlTaskTemplateLink.deleteMany({ + where: { frameworkId, controlTemplateId, taskTemplateId }, + }); + return { message: 'Control unlinked' }; + } + async findById(id: string) { try { const taskTemplate = await db.frameworkEditorTaskTemplate.findUnique({ 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 f376d4fcfe..dc962bab18 100644 --- a/apps/api/src/frameworks/framework-versioning/framework-rollback.service.ts +++ b/apps/api/src/frameworks/framework-versioning/framework-rollback.service.ts @@ -238,6 +238,79 @@ async function replayUndo( await tx.$executeRaw`INSERT INTO "_ControlToTask" ("A", "B") VALUES ${rows} ON CONFLICT ("A", "B") DO NOTHING`; } + const scopedPolicyLinks = ctx.undo.frameworkControlPolicyLinks ?? { + connected: [], + disconnected: [], + }; + const scopedTaskLinks = ctx.undo.frameworkControlTaskLinks ?? { + connected: [], + disconnected: [], + }; + const scopedDocumentLinks = ctx.undo.frameworkControlDocumentTypeLinks ?? { + connected: [], + disconnected: [], + }; + + for (const link of scopedPolicyLinks.connected) { + await tx.frameworkControlPolicyLink.deleteMany({ + where: { + frameworkInstanceId: ctx.syncOp.frameworkInstanceId, + controlId: link.controlId, + policyId: link.otherId, + }, + }); + } + if (scopedPolicyLinks.disconnected.length > 0) { + await tx.frameworkControlPolicyLink.createMany({ + data: scopedPolicyLinks.disconnected.map((link) => ({ + frameworkInstanceId: ctx.syncOp.frameworkInstanceId, + controlId: link.controlId, + policyId: link.otherId, + })), + skipDuplicates: true, + }); + } + + for (const link of scopedTaskLinks.connected) { + await tx.frameworkControlTaskLink.deleteMany({ + where: { + frameworkInstanceId: ctx.syncOp.frameworkInstanceId, + controlId: link.controlId, + taskId: link.otherId, + }, + }); + } + if (scopedTaskLinks.disconnected.length > 0) { + await tx.frameworkControlTaskLink.createMany({ + data: scopedTaskLinks.disconnected.map((link) => ({ + frameworkInstanceId: ctx.syncOp.frameworkInstanceId, + controlId: link.controlId, + taskId: link.otherId, + })), + skipDuplicates: true, + }); + } + + for (const link of scopedDocumentLinks.connected) { + await tx.frameworkControlDocumentTypeLink.deleteMany({ + where: { + frameworkInstanceId: ctx.syncOp.frameworkInstanceId, + controlId: link.controlId, + formType: normalizeFormType(link.otherId) as never, + }, + }); + } + if (scopedDocumentLinks.disconnected.length > 0) { + await tx.frameworkControlDocumentTypeLink.createMany({ + data: scopedDocumentLinks.disconnected.map((link) => ({ + frameworkInstanceId: ctx.syncOp.frameworkInstanceId, + controlId: link.controlId, + formType: normalizeFormType(link.otherId) as never, + })), + skipDuplicates: true, + }); + } + // 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.spec.ts b/apps/api/src/frameworks/framework-versioning/framework-sync-apply.spec.ts index 5b5e359e76..73ba9d8abe 100644 --- a/apps/api/src/frameworks/framework-versioning/framework-sync-apply.spec.ts +++ b/apps/api/src/frameworks/framework-versioning/framework-sync-apply.spec.ts @@ -44,6 +44,9 @@ function mockTx() { frameworkInstance: { findMany: jest.fn().mockResolvedValue([]), update: jest.fn() }, frameworkSyncOperation: { create: jest.fn().mockResolvedValue({ id: 'fso_new' }) }, controlDocumentType: { findUnique: jest.fn().mockResolvedValue(null), create: jest.fn().mockResolvedValue({ id: 'cdt_new' }), delete: jest.fn() }, + frameworkControlPolicyLink: { findMany: jest.fn().mockResolvedValue([]), createMany: jest.fn().mockResolvedValue({ count: 0 }), deleteMany: jest.fn().mockResolvedValue({ count: 0 }) }, + frameworkControlTaskLink: { findMany: jest.fn().mockResolvedValue([]), createMany: jest.fn().mockResolvedValue({ count: 0 }), deleteMany: jest.fn().mockResolvedValue({ count: 0 }) }, + frameworkControlDocumentTypeLink: { findUnique: jest.fn().mockResolvedValue(null), create: jest.fn().mockResolvedValue({ id: 'fdl_new' }), createMany: jest.fn().mockResolvedValue({ count: 0 }), deleteMany: jest.fn().mockResolvedValue({ count: 0 }) }, $executeRaw: jest.fn().mockResolvedValue(0), $queryRaw: jest.fn().mockResolvedValue([]), } as any; 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 cf9f06b19e..1ad1ab1e12 100644 --- a/apps/api/src/frameworks/framework-versioning/framework-sync-apply.ts +++ b/apps/api/src/frameworks/framework-versioning/framework-sync-apply.ts @@ -57,6 +57,9 @@ export async function applySync( controlDocumentTypes: { created: [], deleted: [] }, controlPolicyLinks: { connected: [], disconnected: [] }, controlTaskLinks: { connected: [], disconnected: [] }, + frameworkControlPolicyLinks: { connected: [], disconnected: [] }, + frameworkControlTaskLinks: { connected: [], disconnected: [] }, + frameworkControlDocumentTypeLinks: { connected: [], disconnected: [] }, }; const summary: SyncSummary = { controlsAdded: 0, controlsArchived: 0, controlsUpdatedApplied: 0, controlsUpdatedPreserved: 0, @@ -309,20 +312,47 @@ export async function applySync( ` : []; const existingCpKey = new Set(existingCp.map((r) => `${r.A}::${r.B}`)); + const existingScopedCp = await tx.frameworkControlPolicyLink.findMany({ + where: { frameworkInstanceId: ctx.instance.id, controlId: { in: ctlInstIds } }, + select: { controlId: true, policyId: true }, + }); + const existingScopedCpKey = new Set( + existingScopedCp.map((r) => `${r.controlId}::${r.policyId}`), + ); const cpAdded: Array<{ controlId: string; policyId: string }> = []; + const scopedCpAdded: Array<{ controlId: string; policyId: string }> = []; for (const c of to.controls) { const ctlInst = ctlByTemplate.get(c.id); if (!ctlInst) continue; for (const pid of c.policyIds) { const polInst = polByTemplate.get(pid); if (!polInst) continue; - if (existingCpKey.has(`${ctlInst.id}::${polInst.id}`)) continue; + const key = `${ctlInst.id}::${polInst.id}`; + if (!existingScopedCpKey.has(key)) { + scopedCpAdded.push({ controlId: ctlInst.id, policyId: polInst.id }); + undo.frameworkControlPolicyLinks?.connected.push({ + controlId: ctlInst.id, + otherId: polInst.id, + }); + existingScopedCpKey.add(key); + } + if (existingCpKey.has(key)) continue; cpAdded.push({ controlId: ctlInst.id, policyId: polInst.id }); undo.controlPolicyLinks.connected.push({ controlId: ctlInst.id, otherId: polInst.id }); - existingCpKey.add(`${ctlInst.id}::${polInst.id}`); + existingCpKey.add(key); } } + if (scopedCpAdded.length > 0) { + await tx.frameworkControlPolicyLink.createMany({ + data: scopedCpAdded.map(({ controlId, policyId }) => ({ + frameworkInstanceId: ctx.instance.id, + controlId, + policyId, + })), + skipDuplicates: true, + }); + } if (cpAdded.length > 0) { const rows = Prisma.join( cpAdded.map(({ controlId, policyId }) => Prisma.sql`(${controlId}::text, ${policyId}::text)`), @@ -331,19 +361,23 @@ export async function applySync( } // Diff-based removal: only edges v1 claimed and v2 dropped. - const cpRemoved: Array<{ controlId: string; policyId: string }> = []; for (const edge of diff.controlPolicyEdges.removed) { const ctlInst = ctlByTemplate.get(edge.controlTemplateId); const polInst = polByTemplate.get(edge.policyTemplateId); if (!ctlInst || !polInst) continue; - cpRemoved.push({ controlId: ctlInst.id, policyId: polInst.id }); - undo.controlPolicyLinks.disconnected.push({ controlId: ctlInst.id, otherId: polInst.id }); - } - if (cpRemoved.length > 0) { - const pairs = Prisma.join( - cpRemoved.map(({ controlId, policyId }) => Prisma.sql`(${controlId}::text, ${policyId}::text)`), - ); - await tx.$executeRaw`DELETE FROM "_ControlToPolicy" WHERE ("A", "B") IN (${pairs})`; + const deleted = await tx.frameworkControlPolicyLink.deleteMany({ + where: { + frameworkInstanceId: ctx.instance.id, + controlId: ctlInst.id, + policyId: polInst.id, + }, + }); + if (deleted.count > 0) { + undo.frameworkControlPolicyLinks?.disconnected.push({ + controlId: ctlInst.id, + otherId: polInst.id, + }); + } } const existingCt = @@ -354,20 +388,47 @@ export async function applySync( ` : []; const existingCtKey = new Set(existingCt.map((r) => `${r.A}::${r.B}`)); + const existingScopedCt = await tx.frameworkControlTaskLink.findMany({ + where: { frameworkInstanceId: ctx.instance.id, controlId: { in: ctlInstIds } }, + select: { controlId: true, taskId: true }, + }); + const existingScopedCtKey = new Set( + existingScopedCt.map((r) => `${r.controlId}::${r.taskId}`), + ); const ctAdded: Array<{ controlId: string; taskId: string }> = []; + const scopedCtAdded: Array<{ controlId: string; taskId: string }> = []; for (const c of to.controls) { const ctlInst = ctlByTemplate.get(c.id); if (!ctlInst) continue; for (const tid of c.taskIds) { const tInst = taskByTemplate.get(tid); if (!tInst) continue; - if (existingCtKey.has(`${ctlInst.id}::${tInst.id}`)) continue; + const key = `${ctlInst.id}::${tInst.id}`; + if (!existingScopedCtKey.has(key)) { + scopedCtAdded.push({ controlId: ctlInst.id, taskId: tInst.id }); + undo.frameworkControlTaskLinks?.connected.push({ + controlId: ctlInst.id, + otherId: tInst.id, + }); + existingScopedCtKey.add(key); + } + if (existingCtKey.has(key)) continue; ctAdded.push({ controlId: ctlInst.id, taskId: tInst.id }); undo.controlTaskLinks.connected.push({ controlId: ctlInst.id, otherId: tInst.id }); - existingCtKey.add(`${ctlInst.id}::${tInst.id}`); + existingCtKey.add(key); } } + if (scopedCtAdded.length > 0) { + await tx.frameworkControlTaskLink.createMany({ + data: scopedCtAdded.map(({ controlId, taskId }) => ({ + frameworkInstanceId: ctx.instance.id, + controlId, + taskId, + })), + skipDuplicates: true, + }); + } if (ctAdded.length > 0) { const rows = Prisma.join( ctAdded.map(({ controlId, taskId }) => Prisma.sql`(${controlId}::text, ${taskId}::text)`), @@ -375,19 +436,23 @@ export async function applySync( await tx.$executeRaw`INSERT INTO "_ControlToTask" ("A", "B") VALUES ${rows} ON CONFLICT ("A", "B") DO NOTHING`; } - const ctRemoved: Array<{ controlId: string; taskId: string }> = []; for (const edge of diff.controlTaskEdges.removed) { const ctlInst = ctlByTemplate.get(edge.controlTemplateId); const tInst = taskByTemplate.get(edge.taskTemplateId); if (!ctlInst || !tInst) continue; - ctRemoved.push({ controlId: ctlInst.id, taskId: tInst.id }); - undo.controlTaskLinks.disconnected.push({ controlId: ctlInst.id, otherId: tInst.id }); - } - if (ctRemoved.length > 0) { - const pairs = Prisma.join( - ctRemoved.map(({ controlId, taskId }) => Prisma.sql`(${controlId}::text, ${taskId}::text)`), - ); - await tx.$executeRaw`DELETE FROM "_ControlToTask" WHERE ("A", "B") IN (${pairs})`; + const deleted = await tx.frameworkControlTaskLink.deleteMany({ + where: { + frameworkInstanceId: ctx.instance.id, + controlId: ctlInst.id, + taskId: tInst.id, + }, + }); + if (deleted.count > 0) { + undo.frameworkControlTaskLinks?.disconnected.push({ + controlId: ctlInst.id, + otherId: tInst.id, + }); + } } // --- Control <-> DocumentType (explicit junction table ControlDocumentType) --- @@ -402,6 +467,29 @@ export async function applySync( if (!ctlInst) continue; for (const rawFormType of c.documentTypes ?? []) { const formType = normalizeFormType(rawFormType); + const scopedExisting = await tx.frameworkControlDocumentTypeLink.findUnique({ + where: { + frameworkInstanceId_controlId_formType: { + frameworkInstanceId: ctx.instance.id, + controlId: ctlInst.id, + formType: formType as never, + }, + }, + select: { id: true }, + }); + if (!scopedExisting) { + await tx.frameworkControlDocumentTypeLink.create({ + data: { + frameworkInstanceId: ctx.instance.id, + controlId: ctlInst.id, + formType: formType as never, + }, + }); + undo.frameworkControlDocumentTypeLinks?.connected.push({ + controlId: ctlInst.id, + otherId: formType, + }); + } const existing = await tx.controlDocumentType.findUnique({ where: { controlId_formType: { controlId: ctlInst.id, formType: formType as never } }, select: { id: true }, @@ -418,14 +506,20 @@ export async function applySync( const ctlInst = ctlByTemplate.get(edge.controlTemplateId); if (!ctlInst) continue; const formType = normalizeFormType(edge.formType); - const existing = await tx.controlDocumentType.findUnique({ - where: { controlId_formType: { controlId: ctlInst.id, formType: formType as never } }, - select: { id: true }, + const deleted = await tx.frameworkControlDocumentTypeLink.deleteMany({ + where: { + frameworkInstanceId: ctx.instance.id, + controlId: ctlInst.id, + formType: formType as never, + }, }); - if (!existing) continue; - await tx.controlDocumentType.delete({ where: { id: existing.id } }); - undo.controlDocumentTypes.deleted.push({ controlId: ctlInst.id, formType }); - summary.controlDocumentTypesArchived += 1; + if (deleted.count > 0) { + undo.frameworkControlDocumentTypeLinks?.disconnected.push({ + controlId: ctlInst.id, + otherId: formType, + }); + summary.controlDocumentTypesArchived += 1; + } } // --- Persist sync op + update currentVersionId --- diff --git a/apps/api/src/frameworks/framework-versioning/framework-update-preview.spec.ts b/apps/api/src/frameworks/framework-versioning/framework-update-preview.spec.ts index b061835c93..ff6abbb004 100644 --- a/apps/api/src/frameworks/framework-versioning/framework-update-preview.spec.ts +++ b/apps/api/src/frameworks/framework-versioning/framework-update-preview.spec.ts @@ -5,6 +5,10 @@ const empty: FrameworkManifest = { framework: { id: 'f', name: 'n', catalogVersion: '1', description: null }, requirements: [], controls: [], policies: [], tasks: [], }; +const labels = { + fromVersionLabel: { id: 'fvr_1', version: '1.0.0' }, + toVersionLabel: { id: 'fvr_2', version: '1.1.0' }, +}; describe('buildUpdatePreview', () => { it('classifies added control', () => { @@ -14,6 +18,7 @@ describe('buildUpdatePreview', () => { instanceControls: [], instanceTasks: [], instancePolicies: [], + ...labels, }); expect(preview.controls.added).toHaveLength(1); expect(preview.controls.archived).toHaveLength(0); @@ -26,6 +31,7 @@ describe('buildUpdatePreview', () => { instanceControls: [{ id: 'ctl_1', controlTemplateId: 'c1', name: 'C', description: 'd' }], instanceTasks: [], instancePolicies: [], + ...labels, }); expect(preview.controls.archived).toHaveLength(1); }); @@ -37,6 +43,7 @@ describe('buildUpdatePreview', () => { instanceControls: [{ id: 'ctl_1', controlTemplateId: 'c1', name: 'Old', description: 'd' }], instanceTasks: [], instancePolicies: [], + ...labels, }); expect(preview.controls.updatedApplied).toHaveLength(1); expect(preview.controls.updatedPreserved).toHaveLength(0); @@ -49,6 +56,7 @@ describe('buildUpdatePreview', () => { instanceControls: [{ id: 'ctl_1', controlTemplateId: 'c1', name: 'My edit', description: 'd' }], instanceTasks: [], instancePolicies: [], + ...labels, }); expect(preview.controls.updatedPreserved).toHaveLength(1); expect(preview.controls.updatedApplied).toHaveLength(0); diff --git a/apps/api/src/frameworks/framework-versioning/manifest.types.ts b/apps/api/src/frameworks/framework-versioning/manifest.types.ts index d417da1a17..8cd564455d 100644 --- a/apps/api/src/frameworks/framework-versioning/manifest.types.ts +++ b/apps/api/src/frameworks/framework-versioning/manifest.types.ts @@ -29,7 +29,7 @@ export interface ManifestControl { requirementIds: string[]; policyIds: string[]; taskIds: string[]; - documentTypes: string[]; // EvidenceFormType enum values + documentTypes?: string[]; // EvidenceFormType enum values } export interface ManifestPolicy { 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 3be87ab54c..8adac1ac8e 100644 --- a/apps/api/src/frameworks/framework-versioning/undo-payload.types.ts +++ b/apps/api/src/frameworks/framework-versioning/undo-payload.types.ts @@ -10,6 +10,11 @@ export interface UndoPayload { // disconnect between a Control and a Policy / Task instance. controlPolicyLinks: ImplicitEdgeBucket; controlTaskLinks: ImplicitEdgeBucket; + // Framework-instance scoped equivalents for reusable controls. New syncs + // write these; older sync operations may not have the buckets. + frameworkControlPolicyLinks?: ImplicitEdgeBucket; + frameworkControlTaskLinks?: ImplicitEdgeBucket; + frameworkControlDocumentTypeLinks?: ImplicitEdgeBucket; } export interface EntityUndoBucket { diff --git a/apps/api/src/frameworks/frameworks-source-loader.helper.ts b/apps/api/src/frameworks/frameworks-source-loader.helper.ts index 8d23ff8a17..fa726f786d 100644 --- a/apps/api/src/frameworks/frameworks-source-loader.helper.ts +++ b/apps/api/src/frameworks/frameworks-source-loader.helper.ts @@ -38,10 +38,12 @@ export interface LoadedFrameworkSources { automationStatus: TaskAutomationStatus; }>; groupedRelations: Array<{ + frameworkId: string; controlTemplateId: string; requirementTemplateIds: string[]; policyTemplateIds: string[]; taskTemplateIds: string[]; + documentTypes: EvidenceFormType[]; }>; latestVersionByFrameworkId: Map; frameworksWithoutVersion: string[]; @@ -87,28 +89,33 @@ export async function loadFrameworkSources({ const policiesMap = new Map(); const tasksMap = new Map(); - // groupedRelations accumulates per-control edges; when a control appears in - // multiple frameworks we union its requirement/policy/task id sets. + // groupedRelations accumulates per-framework control edges. A reusable + // control can carry different policy/task/document links in each framework. const relationsByControl = new Map< string, { + frameworkId: string; controlTemplateId: string; requirementTemplateIds: Set; policyTemplateIds: Set; taskTemplateIds: Set; + documentTypes: Set; } >(); - const getOrCreateRelation = (controlTemplateId: string) => { - let rel = relationsByControl.get(controlTemplateId); + const getOrCreateRelation = (frameworkId: string, controlTemplateId: string) => { + const key = `${frameworkId}::${controlTemplateId}`; + let rel = relationsByControl.get(key); if (!rel) { rel = { + frameworkId, controlTemplateId, requirementTemplateIds: new Set(), policyTemplateIds: new Set(), taskTemplateIds: new Set(), + documentTypes: new Set(), }; - relationsByControl.set(controlTemplateId, rel); + relationsByControl.set(key, rel); } return rel; }; @@ -129,10 +136,13 @@ export async function loadFrameworkSources({ documentTypes: (c.documentTypes ?? []) as EvidenceFormType[], }); } - const rel = getOrCreateRelation(c.id); + const rel = getOrCreateRelation(frameworkId, c.id); for (const rid of c.requirementIds) rel.requirementTemplateIds.add(rid); for (const pid of c.policyIds) rel.policyTemplateIds.add(pid); for (const tid of c.taskIds) rel.taskTemplateIds.add(tid); + for (const formType of c.documentTypes ?? []) { + rel.documentTypes.add(formType as EvidenceFormType); + } } } @@ -223,19 +233,53 @@ export async function loadFrameworkSources({ where: { id: { in: fallbackRequirementIds } }, select: { id: true }, }, - policyTemplates: { select: { id: true } }, - taskTemplates: { select: { id: true } }, + frameworkPolicyLinks: { + where: { frameworkId: { in: frameworksWithoutVersion } }, + select: { frameworkId: true, policyTemplateId: true }, + }, + frameworkTaskLinks: { + where: { frameworkId: { in: frameworksWithoutVersion } }, + select: { frameworkId: true, taskTemplateId: true }, + }, + frameworkDocumentLinks: { + where: { frameworkId: { in: frameworksWithoutVersion } }, + select: { frameworkId: true, formType: true }, + }, }, }); for (const cr of controlRelationsLive) { - const rel = getOrCreateRelation(cr.id); - for (const r of cr.requirements) rel.requirementTemplateIds.add(r.id); - for (const p of cr.policyTemplates) rel.policyTemplateIds.add(p.id); - for (const t of cr.taskTemplates) rel.taskTemplateIds.add(t.id); + const frameworkIds = new Set( + cr.requirements + .map((r) => requirementToFrameworkId.get(r.id)) + .filter((id): id is string => Boolean(id)), + ); + for (const frameworkId of frameworkIds) { + const rel = getOrCreateRelation(frameworkId, cr.id); + for (const r of cr.requirements) { + if (requirementToFrameworkId.get(r.id) === frameworkId) { + rel.requirementTemplateIds.add(r.id); + } + } + for (const link of cr.frameworkPolicyLinks) { + if (link.frameworkId === frameworkId) { + rel.policyTemplateIds.add(link.policyTemplateId); + } + } + for (const link of cr.frameworkTaskLinks) { + if (link.frameworkId === frameworkId) { + rel.taskTemplateIds.add(link.taskTemplateId); + } + } + for (const link of cr.frameworkDocumentLinks) { + if (link.frameworkId === frameworkId) { + rel.documentTypes.add(link.formType); + } + } + } } const fallbackPolicyIds = controlRelationsLive.flatMap((cr) => - cr.policyTemplates.map((p) => p.id), + cr.frameworkPolicyLinks.map((p) => p.policyTemplateId), ); if (fallbackPolicyIds.length > 0) { const livePolicies = await tx.frameworkEditorPolicyTemplate.findMany({ @@ -256,7 +300,7 @@ export async function loadFrameworkSources({ } const fallbackTaskIds = controlRelationsLive.flatMap((cr) => - cr.taskTemplates.map((t) => t.id), + cr.frameworkTaskLinks.map((t) => t.taskTemplateId), ); if (fallbackTaskIds.length > 0) { const liveTasks = await tx.frameworkEditorTaskTemplate.findMany({ @@ -278,10 +322,12 @@ export async function loadFrameworkSources({ } const groupedRelations = Array.from(relationsByControl.values()).map((rel) => ({ + frameworkId: rel.frameworkId, controlTemplateId: rel.controlTemplateId, requirementTemplateIds: Array.from(rel.requirementTemplateIds), policyTemplateIds: Array.from(rel.policyTemplateIds), taskTemplateIds: Array.from(rel.taskTemplateIds), + documentTypes: Array.from(rel.documentTypes), })); return { diff --git a/apps/api/src/frameworks/frameworks-upsert.helper.ts b/apps/api/src/frameworks/frameworks-upsert.helper.ts index 7a62e9e73b..51b7035755 100644 --- a/apps/api/src/frameworks/frameworks-upsert.helper.ts +++ b/apps/api/src/frameworks/frameworks-upsert.helper.ts @@ -282,28 +282,26 @@ export async function upsertOrgFrameworkStructure({ const requirementMapEntries: Prisma.RequirementMapCreateManyInput[] = []; const controlDocumentTypeEntries: Prisma.ControlDocumentTypeCreateManyInput[] = []; + const frameworkControlPolicyEntries: Prisma.FrameworkControlPolicyLinkCreateManyInput[] = []; + const frameworkControlTaskEntries: Prisma.FrameworkControlTaskLinkCreateManyInput[] = []; + const frameworkControlDocumentTypeEntries: Prisma.FrameworkControlDocumentTypeLinkCreateManyInput[] = []; const controlTemplateById = new Map(controlTemplates.map((c) => [c.id, c])); for (const relation of groupedRelations) { const controlId = controlMap.get(relation.controlTemplateId); if (!controlId) continue; + const frameworkInstanceId = editorToInstanceMap.get(relation.frameworkId); + if (!frameworkInstanceId) continue; const updateData: Prisma.ControlUpdateInput = {}; let needsUpdate = false; for (const reqTemplateId of relation.requirementTemplateIds) { - const frameworkEditorId = requirementToFrameworkId.get(reqTemplateId); - const frameworkInstanceId = frameworkEditorId - ? editorToInstanceMap.get(frameworkEditorId) - : undefined; - - if (frameworkInstanceId) { - requirementMapEntries.push({ - controlId, - requirementId: reqTemplateId, - frameworkInstanceId, - }); - } + requirementMapEntries.push({ + controlId, + requirementId: reqTemplateId, + frameworkInstanceId, + }); } const policiesToConnect = relation.policyTemplateIds @@ -315,6 +313,13 @@ export async function upsertOrgFrameworkStructure({ updateData.policies = { connect: policiesToConnect }; needsUpdate = true; } + for (const policy of policiesToConnect) { + frameworkControlPolicyEntries.push({ + frameworkInstanceId, + controlId, + policyId: policy.id, + }); + } const tasksToConnect = relation.taskTemplateIds .map((ttId) => taskMap.get(ttId)) @@ -325,6 +330,13 @@ export async function upsertOrgFrameworkStructure({ updateData.tasks = { connect: tasksToConnect }; needsUpdate = true; } + for (const task of tasksToConnect) { + frameworkControlTaskEntries.push({ + frameworkInstanceId, + controlId, + taskId: task.id, + }); + } if (needsUpdate) { await tx.control.update({ @@ -337,9 +349,16 @@ export async function upsertOrgFrameworkStructure({ // documentTypes so the new org starts with the same evidence form types // the published version specified. Skip duplicates against existing rows // via the unique constraint at create time. - const ct = controlTemplateById.get(relation.controlTemplateId); - for (const formType of ct?.documentTypes ?? []) { + const documentTypes = relation.documentTypes.length > 0 + ? relation.documentTypes + : (controlTemplateById.get(relation.controlTemplateId)?.documentTypes ?? []); + for (const formType of documentTypes) { controlDocumentTypeEntries.push({ controlId, formType }); + frameworkControlDocumentTypeEntries.push({ + frameworkInstanceId, + controlId, + formType, + }); } } @@ -357,6 +376,27 @@ export async function upsertOrgFrameworkStructure({ }); } + if (frameworkControlPolicyEntries.length > 0) { + await tx.frameworkControlPolicyLink.createMany({ + data: frameworkControlPolicyEntries, + skipDuplicates: true, + }); + } + + if (frameworkControlTaskEntries.length > 0) { + await tx.frameworkControlTaskLink.createMany({ + data: frameworkControlTaskEntries, + skipDuplicates: true, + }); + } + + if (frameworkControlDocumentTypeEntries.length > 0) { + await tx.frameworkControlDocumentTypeLink.createMany({ + data: frameworkControlDocumentTypeEntries, + 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 1a3debca66..39b4a7cd6f 100644 --- a/apps/api/src/frameworks/frameworks.service.ts +++ b/apps/api/src/frameworks/frameworks.service.ts @@ -156,11 +156,14 @@ export class FrameworksService { include: { control: { include: { - policies: { - where: { archivedAt: null }, - select: { id: true, name: true, status: true }, + frameworkPolicyLinks: { + include: { + policy: { + select: { id: true, name: true, status: true }, + }, + }, }, - controlDocumentTypes: true, + frameworkDocumentLinks: true, requirementsMapped: { where: { archivedAt: null } }, }, }, @@ -182,10 +185,21 @@ export class FrameworksService { for (const rm of fi.requirementsMapped || []) { if (rm.control && !controlsMap.has(rm.control.id)) { const { requirementsMapped: _, ...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: rm.control.policies || [], - controlDocumentTypes: (rm.control.controlDocumentTypes || []).map( + policies: policyLinks.map( + (link: { policy: { id: string; name: string; status: string } }) => + link.policy, + ), + controlDocumentTypes: documentLinks.map( (documentType: { formType: EvidenceFormType }) => ({ ...documentType, isNotRelevant: notRelevantFormTypes.has(documentType.formType), @@ -208,9 +222,16 @@ export class FrameworksService { where: { organizationId, archivedAt: null, - controls: { some: { organizationId, archivedAt: null } }, + frameworkControlLinks: { + some: { frameworkInstance: { organizationId } }, + }, + }, + include: { + frameworkControlLinks: { + where: { frameworkInstance: { organizationId } }, + include: { control: true }, + }, }, - include: { controls: { where: { archivedAt: null } } }, }), db.evidenceSubmission.findMany({ where: { organizationId }, @@ -222,7 +243,12 @@ export class FrameworksService { ...fw, complianceScore: computeFrameworkComplianceScore( fw, - tasks, + tasks.map(({ frameworkControlLinks, ...task }) => ({ + ...task, + controls: frameworkControlLinks + .filter((link) => link.frameworkInstanceId === fw.id) + .map((link) => link.control), + })), evidenceSubmissions, ), })); @@ -239,12 +265,18 @@ export class FrameworksService { include: { control: { include: { - policies: { - where: { archivedAt: null }, - select: { id: true, name: true, status: true }, + frameworkPolicyLinks: { + where: { frameworkInstanceId }, + include: { + policy: { + select: { id: true, name: true, status: true }, + }, + }, }, requirementsMapped: { where: { archivedAt: null } }, - controlDocumentTypes: true, + frameworkDocumentLinks: { + where: { frameworkInstanceId }, + }, }, }, }, @@ -265,9 +297,10 @@ export class FrameworksService { const { requirementsMapped: _, ...controlData } = rm.control; controlsMap.set(rm.control.id, { ...controlData, - policies: rm.control.policies || [], + policies: + rm.control.frameworkPolicyLinks?.map((link) => link.policy) || [], requirementsMapped: rm.control.requirementsMapped || [], - controlDocumentTypes: (rm.control.controlDocumentTypes || []).map( + controlDocumentTypes: (rm.control.frameworkDocumentLinks || []).map( (documentType) => ({ ...documentType, isNotRelevant: notRelevantFormTypes.has(documentType.formType), @@ -297,9 +330,14 @@ export class FrameworksService { where: { organizationId, archivedAt: null, - controls: { some: { organizationId, archivedAt: null } }, + frameworkControlLinks: { some: { frameworkInstanceId } }, + }, + include: { + frameworkControlLinks: { + where: { frameworkInstanceId }, + include: { control: true }, + }, }, - include: { controls: { where: { archivedAt: null } } }, }), db.requirementMap.findMany({ where: { frameworkInstanceId, archivedAt: null }, @@ -321,7 +359,10 @@ export class FrameworksService { ...rest, controls: Array.from(controlsMap.values()), requirementDefinitions, - tasks, + tasks: tasks.map(({ frameworkControlLinks, ...task }) => ({ + ...task, + controls: frameworkControlLinks.map((link) => link.control), + })), requirementMaps, evidenceSubmissions, }; @@ -728,25 +769,40 @@ export class FrameworksService { include: { control: { include: { - policies: { - where: { archivedAt: null }, - select: { id: true, name: true, status: true }, + frameworkPolicyLinks: { + where: { frameworkInstanceId }, + include: { + policy: { + select: { id: true, name: true, status: true }, + }, + }, + }, + frameworkDocumentLinks: { + where: { frameworkInstanceId }, }, - controlDocumentTypes: true, }, }, }, }), db.task.findMany({ - where: { organizationId, archivedAt: null }, - include: { controls: { where: { archivedAt: null } } }, + where: { + organizationId, + archivedAt: null, + frameworkControlLinks: { some: { frameworkInstanceId } }, + }, + include: { + frameworkControlLinks: { + where: { frameworkInstanceId }, + include: { control: true }, + }, + }, }), this.getNotRelevantFormTypes(organizationId), ]); const formTypes = new Set(); for (const rc of relatedControls) { - for (const dt of rc.control.controlDocumentTypes || []) { + for (const dt of rc.control.frameworkDocumentLinks || []) { if (notRelevantFormTypes.has(dt.formType)) continue; formTypes.add(dt.formType); } @@ -774,7 +830,10 @@ export class FrameworksService { ...relatedControl, control: { ...relatedControl.control, - controlDocumentTypes: relatedControl.control.controlDocumentTypes.map( + policies: relatedControl.control.frameworkPolicyLinks.map( + (link) => link.policy, + ), + controlDocumentTypes: relatedControl.control.frameworkDocumentLinks.map( (documentType) => ({ ...documentType, isNotRelevant: notRelevantFormTypes.has(documentType.formType), @@ -782,7 +841,10 @@ export class FrameworksService { ), }, })), - tasks, + tasks: tasks.map(({ frameworkControlLinks, ...task }) => ({ + ...task, + controls: frameworkControlLinks.map((link) => link.control), + })), evidenceSubmissions, siblingRequirements, }; diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/controls/[controlId]/components/DocumentsTable.tsx b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/controls/[controlId]/components/DocumentsTable.tsx index 1e165df59d..0126facd5f 100644 --- a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/controls/[controlId]/components/DocumentsTable.tsx +++ b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/controls/[controlId]/components/DocumentsTable.tsx @@ -27,10 +27,12 @@ interface DocumentTypeRow { export function DocumentsTable({ controlId, + frameworkInstanceId, orgId, rows, }: { controlId: string; + frameworkInstanceId: string; orgId: string; rows: DocumentTypeRow[]; }) { @@ -45,7 +47,7 @@ export function DocumentsTable({ setPending(formType); try { const response = await apiClient.delete( - `/v1/controls/${controlId}/document-types/${formType}`, + `/v1/controls/${controlId}/document-types/${formType}?frameworkInstanceId=${frameworkInstanceId}`, ); if (response.error) throw new Error(response.error); toast.success('Document unlinked'); diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/controls/[controlId]/components/FrameworkControlShell.tsx b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/controls/[controlId]/components/FrameworkControlShell.tsx index b4b584642c..cf541a5d43 100644 --- a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/controls/[controlId]/components/FrameworkControlShell.tsx +++ b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/controls/[controlId]/components/FrameworkControlShell.tsx @@ -53,12 +53,13 @@ interface Breadcrumb { interface Props { orgId: string; + frameworkInstanceId: string; control: ControlDetail; breadcrumbs: Breadcrumb[]; documentRows: DocumentRow[]; } -export function FrameworkControlShell({ orgId, control, breadcrumbs, documentRows }: Props) { +export function FrameworkControlShell({ orgId, frameworkInstanceId, control, breadcrumbs, documentRows }: Props) { const [activeTab, setActiveTab] = useState('policies'); const linkedPolicyIds = control.policies.map((p) => p.id); @@ -67,11 +68,23 @@ export function FrameworkControlShell({ orgId, control, breadcrumbs, documentRow const actions = activeTab === 'policies' ? ( - + ) : activeTab === 'tasks' ? ( - + ) : ( - + ); return ( @@ -99,7 +112,12 @@ export function FrameworkControlShell({ orgId, control, breadcrumbs, documentRow - + diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/controls/[controlId]/components/LinkDocumentTypeSheet.tsx b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/controls/[controlId]/components/LinkDocumentTypeSheet.tsx index ea853e287a..b7f1257dda 100644 --- a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/controls/[controlId]/components/LinkDocumentTypeSheet.tsx +++ b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/controls/[controlId]/components/LinkDocumentTypeSheet.tsx @@ -23,9 +23,11 @@ import { export function LinkDocumentTypeSheet({ controlId, + frameworkInstanceId, alreadyLinkedFormTypes, }: { controlId: string; + frameworkInstanceId: string; alreadyLinkedFormTypes: string[]; }) { const { hasPermission } = usePermissions(); @@ -63,7 +65,7 @@ export function LinkDocumentTypeSheet({ setIsSubmitting(true); try { const response = await apiClient.post( - `/v1/controls/${controlId}/document-types/link`, + `/v1/controls/${controlId}/document-types/link?frameworkInstanceId=${frameworkInstanceId}`, { formTypes: Array.from(selected) }, ); if (response.error) throw new Error(response.error); diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/controls/[controlId]/components/LinkPolicySheet.tsx b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/controls/[controlId]/components/LinkPolicySheet.tsx index efd395c5af..e0b2928a34 100644 --- a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/controls/[controlId]/components/LinkPolicySheet.tsx +++ b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/controls/[controlId]/components/LinkPolicySheet.tsx @@ -20,9 +20,11 @@ import { useControlOptions } from './useControlOptions'; export function LinkPolicySheet({ controlId, + frameworkInstanceId, alreadyLinkedPolicyIds, }: { controlId: string; + frameworkInstanceId: string; alreadyLinkedPolicyIds: string[]; }) { const { hasPermission } = usePermissions(); @@ -61,7 +63,7 @@ export function LinkPolicySheet({ setIsSubmitting(true); try { const response = await apiClient.post( - `/v1/controls/${controlId}/policies/link`, + `/v1/controls/${controlId}/policies/link?frameworkInstanceId=${frameworkInstanceId}`, { policyIds: Array.from(selected) }, ); if (response.error) throw new Error(response.error); diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/controls/[controlId]/components/LinkTaskSheet.tsx b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/controls/[controlId]/components/LinkTaskSheet.tsx index d496972fc4..ff096c2898 100644 --- a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/controls/[controlId]/components/LinkTaskSheet.tsx +++ b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/controls/[controlId]/components/LinkTaskSheet.tsx @@ -20,9 +20,11 @@ import { useControlOptions } from './useControlOptions'; export function LinkTaskSheet({ controlId, + frameworkInstanceId, alreadyLinkedTaskIds, }: { controlId: string; + frameworkInstanceId: string; alreadyLinkedTaskIds: string[]; }) { const { hasPermission } = usePermissions(); @@ -61,7 +63,7 @@ export function LinkTaskSheet({ setIsSubmitting(true); try { const response = await apiClient.post( - `/v1/controls/${controlId}/tasks/link`, + `/v1/controls/${controlId}/tasks/link?frameworkInstanceId=${frameworkInstanceId}`, { taskIds: Array.from(selected) }, ); if (response.error) throw new Error(response.error); diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/controls/[controlId]/page.tsx b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/controls/[controlId]/page.tsx index 8153b9fe12..71055d3526 100644 --- a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/controls/[controlId]/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/controls/[controlId]/page.tsx @@ -36,7 +36,9 @@ export default async function FrameworkControlPage({ params }: PageProps) { const { orgId, frameworkInstanceId, controlId } = await params; const [controlRes, frameworkRes] = await Promise.all([ - serverApi.get(`/v1/controls/${controlId}`), + serverApi.get( + `/v1/controls/${controlId}?frameworkInstanceId=${frameworkInstanceId}`, + ), serverApi.get(`/v1/frameworks/${frameworkInstanceId}`), ]); @@ -78,6 +80,7 @@ export default async function FrameworkControlPage({ params }: PageProps) { return ( { - await apiClient(`/control-template/${controlId}/${relation}/${itemId}`, { method: 'POST' }); + const query = frameworkId ? `?frameworkId=${frameworkId}` : ''; + await apiClient(`/control-template/${controlId}/${relation}/${itemId}${query}`, { method: 'POST' }); } async function unlinkControlRelation( controlId: string, relation: string, itemId: string, + frameworkId?: string, ): Promise { - await apiClient(`/control-template/${controlId}/${relation}/${itemId}`, { method: 'DELETE' }); + const query = frameworkId ? `?frameworkId=${frameworkId}` : ''; + await apiClient(`/control-template/${controlId}/${relation}/${itemId}${query}`, { method: 'DELETE' }); } interface ControlsClientPageProps { @@ -94,7 +98,7 @@ export function ControlsClientPage({ initialControls, emptyMessage, frameworkId }) => apiClient<{ id: string }>('/control-template', { method: 'POST', - body: JSON.stringify(data), + body: JSON.stringify({ ...data, frameworkId }), }), updateControl: ( id: string, @@ -102,14 +106,14 @@ export function ControlsClientPage({ initialControls, emptyMessage, frameworkId ) => apiClient(`/control-template/${id}`, { method: 'PATCH', - body: JSON.stringify(data), + body: JSON.stringify({ ...data, frameworkId }), }), deleteControl: (id: string) => apiClient(`/control-template/${id}`, { method: 'DELETE', }), }), - [], + [frameworkId], ); const initialGridData: ControlsPageGridData[] = useMemo( () => @@ -196,10 +200,10 @@ export function ControlsClientPage({ initialControls, emptyMessage, frameworkId isNewRow={createdIds.has(row.original.id)} getAllItems={fetchAllPolicyTemplates} onLink={(controlId: string, ptId: string) => - linkControlRelation(controlId, 'policy-templates', ptId) + linkControlRelation(controlId, 'policy-templates', ptId, frameworkId) } onUnlink={(controlId: string, ptId: string) => - unlinkControlRelation(controlId, 'policy-templates', ptId) + unlinkControlRelation(controlId, 'policy-templates', ptId, frameworkId) } onLocalUpdate={(newItems: RelationalItem[]) => updateRelational(row.original.id, 'policyTemplates', newItems) @@ -248,10 +252,10 @@ export function ControlsClientPage({ initialControls, emptyMessage, frameworkId isNewRow={createdIds.has(row.original.id)} getAllItems={fetchAllTaskTemplates} onLink={(controlId: string, ttId: string) => - linkControlRelation(controlId, 'task-templates', ttId) + linkControlRelation(controlId, 'task-templates', ttId, frameworkId) } onUnlink={(controlId: string, ttId: string) => - unlinkControlRelation(controlId, 'task-templates', ttId) + unlinkControlRelation(controlId, 'task-templates', ttId, frameworkId) } onLocalUpdate={(newItems: RelationalItem[]) => updateRelational(row.original.id, 'taskTemplates', newItems) @@ -314,7 +318,7 @@ export function ControlsClientPage({ initialControls, emptyMessage, frameworkId ), }), ], - [updateCell, updateRelational, deleteRow, createdIds, handleDocumentTypesUpdate], + [updateCell, updateRelational, deleteRow, createdIds, handleDocumentTypesUpdate, frameworkId], ); const [sorting, setSorting] = useState([]); diff --git a/apps/framework-editor/app/(pages)/documents/DocumentControlsCell.tsx b/apps/framework-editor/app/(pages)/documents/DocumentControlsCell.tsx index 38b5209445..7caf2b0dc4 100644 --- a/apps/framework-editor/app/(pages)/documents/DocumentControlsCell.tsx +++ b/apps/framework-editor/app/(pages)/documents/DocumentControlsCell.tsx @@ -14,6 +14,7 @@ interface ControlTemplate { interface DocumentControlsCellProps { documentType: string; controls: { id: string; name: string }[]; + frameworkId: string; onControlLinked: (documentType: string, control: { id: string; name: string }) => void; onControlUnlinked: (documentType: string, controlId: string) => void; } @@ -21,6 +22,7 @@ interface DocumentControlsCellProps { export function DocumentControlsCell({ documentType, controls, + frameworkId, onControlLinked, onControlUnlinked, }: DocumentControlsCellProps) { @@ -49,7 +51,7 @@ export function DocumentControlsCell({ useEffect(() => { if (isSearching && allControls.length === 0) { setIsLoading(true); - apiClient('/control-template') + apiClient(`/control-template?frameworkId=${frameworkId}`) .then((data: ControlTemplate[]) => data.map((c: ControlTemplate) => ({ id: c.id, name: c.name || 'Unnamed Control' })), ) @@ -57,7 +59,7 @@ export function DocumentControlsCell({ .catch(() => toast.error('Failed to load controls')) .finally(() => setIsLoading(false)); } - }, [isSearching, allControls.length]); + }, [isSearching, allControls.length, frameworkId]); const filteredControls = useMemo(() => { const linkedIds = new Set(controls.map((c) => c.id)); @@ -74,16 +76,10 @@ export function DocumentControlsCell({ } try { - const current = await apiClient(`/control-template/${control.id}`); - const currentTypes: string[] = Array.isArray(current.documentTypes) - ? current.documentTypes - : []; - if (!currentTypes.includes(documentType)) { - await apiClient(`/control-template/${control.id}`, { - method: 'PATCH', - body: JSON.stringify({ documentTypes: [...currentTypes, documentType] }), - }); - } + await apiClient( + `/control-template/${control.id}/document-types/${documentType}?frameworkId=${frameworkId}`, + { method: 'POST' }, + ); onControlLinked(documentType, control); toast.success(`Linked to ${control.name}`); } catch { @@ -92,30 +88,24 @@ export function DocumentControlsCell({ setSearch(''); setIsSearching(false); }, - [documentType, isSOADocument, onControlLinked], + [documentType, frameworkId, isSOADocument, onControlLinked], ); const handleUnlink = useCallback( async (controlId: string) => { const control = controls.find((c) => c.id === controlId); try { - const current = await apiClient(`/control-template/${controlId}`); - const currentTypes: string[] = Array.isArray(current.documentTypes) - ? current.documentTypes - : []; - await apiClient(`/control-template/${controlId}`, { - method: 'PATCH', - body: JSON.stringify({ - documentTypes: currentTypes.filter((t) => t !== documentType), - }), - }); + await apiClient( + `/control-template/${controlId}/document-types/${documentType}?frameworkId=${frameworkId}`, + { method: 'DELETE' }, + ); onControlUnlinked(documentType, controlId); toast.success(`Unlinked from ${control?.name ?? 'control'}`); } catch { toast.error('Failed to unlink control'); } }, - [documentType, controls, onControlUnlinked], + [documentType, frameworkId, controls, onControlUnlinked], ); if (!isExpanded) { diff --git a/apps/framework-editor/app/(pages)/documents/DocumentsClientPage.tsx b/apps/framework-editor/app/(pages)/documents/DocumentsClientPage.tsx index a14d92967e..89b04c7ed8 100644 --- a/apps/framework-editor/app/(pages)/documents/DocumentsClientPage.tsx +++ b/apps/framework-editor/app/(pages)/documents/DocumentsClientPage.tsx @@ -30,11 +30,12 @@ interface DocumentRow { interface DocumentsClientPageProps { controls: ControlWithDocumentTypes[]; + frameworkId: string; } const columnHelper = createColumnHelper(); -export function DocumentsClientPage({ controls }: DocumentsClientPageProps) { +export function DocumentsClientPage({ controls, frameworkId }: DocumentsClientPageProps) { const [controlsState, setControlsState] = useState(controls); const data: DocumentRow[] = useMemo(() => { @@ -104,6 +105,7 @@ export function DocumentsClientPage({ controls }: DocumentsClientPageProps) { @@ -129,7 +131,7 @@ export function DocumentsClientPage({ controls }: DocumentsClientPageProps) { ), }), ], - [handleControlLinked, handleControlUnlinked], + [handleControlLinked, handleControlUnlinked, frameworkId], ); const [sorting, setSorting] = useState([]); diff --git a/apps/framework-editor/app/(pages)/frameworks/[frameworkId]/documents/page.tsx b/apps/framework-editor/app/(pages)/frameworks/[frameworkId]/documents/page.tsx index 3e68af85f9..29142a1a4a 100644 --- a/apps/framework-editor/app/(pages)/frameworks/[frameworkId]/documents/page.tsx +++ b/apps/framework-editor/app/(pages)/frameworks/[frameworkId]/documents/page.tsx @@ -23,5 +23,5 @@ export default async function Page({ `/framework/${frameworkId}/documents`, ); - return ; + return ; } diff --git a/apps/framework-editor/app/(pages)/tasks/TasksClientPage.tsx b/apps/framework-editor/app/(pages)/tasks/TasksClientPage.tsx index 8ec3956061..f7df6fe592 100644 --- a/apps/framework-editor/app/(pages)/tasks/TasksClientPage.tsx +++ b/apps/framework-editor/app/(pages)/tasks/TasksClientPage.tsx @@ -103,20 +103,22 @@ export function TasksClientPage({ initialTasks, emptyMessage, frameworkId }: Tas const handleLinkControl = useCallback( async (taskId: string, controlId: string): Promise => { - await apiClient(`/task-template/${taskId}/control-templates/${controlId}`, { + const query = frameworkId ? `?frameworkId=${frameworkId}` : ''; + await apiClient(`/task-template/${taskId}/control-templates/${controlId}${query}`, { method: 'POST', }); }, - [], + [frameworkId], ); const handleUnlinkControl = useCallback( async (taskId: string, controlId: string): Promise => { - await apiClient(`/task-template/${taskId}/control-templates/${controlId}`, { + const query = frameworkId ? `?frameworkId=${frameworkId}` : ''; + await apiClient(`/task-template/${taskId}/control-templates/${controlId}${query}`, { method: 'DELETE', }); }, - [], + [frameworkId], ); const columns = useMemo( diff --git a/packages/db/prisma/migrations/20260513150611_framework_scoped_control_links/migration.sql b/packages/db/prisma/migrations/20260513150611_framework_scoped_control_links/migration.sql new file mode 100644 index 0000000000..ed07a6c9ae --- /dev/null +++ b/packages/db/prisma/migrations/20260513150611_framework_scoped_control_links/migration.sql @@ -0,0 +1,161 @@ +-- CreateTable +CREATE TABLE "FrameworkEditorControlPolicyTemplateLink" ( + "id" TEXT NOT NULL DEFAULT generate_prefixed_cuid('fcp'::text), + "frameworkId" TEXT NOT NULL, + "controlTemplateId" TEXT NOT NULL, + "policyTemplateId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "FrameworkEditorControlPolicyTemplateLink_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "FrameworkEditorControlTaskTemplateLink" ( + "id" TEXT NOT NULL DEFAULT generate_prefixed_cuid('fct'::text), + "frameworkId" TEXT NOT NULL, + "controlTemplateId" TEXT NOT NULL, + "taskTemplateId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "FrameworkEditorControlTaskTemplateLink_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "FrameworkEditorControlDocumentTypeLink" ( + "id" TEXT NOT NULL DEFAULT generate_prefixed_cuid('fcd'::text), + "frameworkId" TEXT NOT NULL, + "controlTemplateId" TEXT NOT NULL, + "formType" "EvidenceFormType" NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "FrameworkEditorControlDocumentTypeLink_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "FrameworkControlPolicyLink" ( + "id" TEXT NOT NULL DEFAULT generate_prefixed_cuid('fpl'::text), + "frameworkInstanceId" TEXT NOT NULL, + "controlId" TEXT NOT NULL, + "policyId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "FrameworkControlPolicyLink_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "FrameworkControlTaskLink" ( + "id" TEXT NOT NULL DEFAULT generate_prefixed_cuid('ftl'::text), + "frameworkInstanceId" TEXT NOT NULL, + "controlId" TEXT NOT NULL, + "taskId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "FrameworkControlTaskLink_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "FrameworkControlDocumentTypeLink" ( + "id" TEXT NOT NULL DEFAULT generate_prefixed_cuid('fdl'::text), + "frameworkInstanceId" TEXT NOT NULL, + "controlId" TEXT NOT NULL, + "formType" "EvidenceFormType" NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "FrameworkControlDocumentTypeLink_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "FrameworkEditorControlPolicyTemplateLink_controlTemplateId_idx" ON "FrameworkEditorControlPolicyTemplateLink"("controlTemplateId"); + +-- CreateIndex +CREATE INDEX "FrameworkEditorControlPolicyTemplateLink_policyTemplateId_idx" ON "FrameworkEditorControlPolicyTemplateLink"("policyTemplateId"); + +-- CreateIndex +CREATE UNIQUE INDEX "FrameworkEditorControlPolicyTemplateLink_frameworkId_contro_key" ON "FrameworkEditorControlPolicyTemplateLink"("frameworkId", "controlTemplateId", "policyTemplateId"); + +-- CreateIndex +CREATE INDEX "FrameworkEditorControlTaskTemplateLink_controlTemplateId_idx" ON "FrameworkEditorControlTaskTemplateLink"("controlTemplateId"); + +-- CreateIndex +CREATE INDEX "FrameworkEditorControlTaskTemplateLink_taskTemplateId_idx" ON "FrameworkEditorControlTaskTemplateLink"("taskTemplateId"); + +-- CreateIndex +CREATE UNIQUE INDEX "FrameworkEditorControlTaskTemplateLink_frameworkId_controlT_key" ON "FrameworkEditorControlTaskTemplateLink"("frameworkId", "controlTemplateId", "taskTemplateId"); + +-- CreateIndex +CREATE INDEX "FrameworkEditorControlDocumentTypeLink_controlTemplateId_idx" ON "FrameworkEditorControlDocumentTypeLink"("controlTemplateId"); + +-- CreateIndex +CREATE UNIQUE INDEX "FrameworkEditorControlDocumentTypeLink_frameworkId_controlT_key" ON "FrameworkEditorControlDocumentTypeLink"("frameworkId", "controlTemplateId", "formType"); + +-- CreateIndex +CREATE INDEX "FrameworkControlPolicyLink_controlId_idx" ON "FrameworkControlPolicyLink"("controlId"); + +-- CreateIndex +CREATE INDEX "FrameworkControlPolicyLink_policyId_idx" ON "FrameworkControlPolicyLink"("policyId"); + +-- CreateIndex +CREATE UNIQUE INDEX "FrameworkControlPolicyLink_frameworkInstanceId_controlId_po_key" ON "FrameworkControlPolicyLink"("frameworkInstanceId", "controlId", "policyId"); + +-- CreateIndex +CREATE INDEX "FrameworkControlTaskLink_controlId_idx" ON "FrameworkControlTaskLink"("controlId"); + +-- CreateIndex +CREATE INDEX "FrameworkControlTaskLink_taskId_idx" ON "FrameworkControlTaskLink"("taskId"); + +-- CreateIndex +CREATE UNIQUE INDEX "FrameworkControlTaskLink_frameworkInstanceId_controlId_task_key" ON "FrameworkControlTaskLink"("frameworkInstanceId", "controlId", "taskId"); + +-- CreateIndex +CREATE INDEX "FrameworkControlDocumentTypeLink_controlId_idx" ON "FrameworkControlDocumentTypeLink"("controlId"); + +-- CreateIndex +CREATE UNIQUE INDEX "FrameworkControlDocumentTypeLink_frameworkInstanceId_contro_key" ON "FrameworkControlDocumentTypeLink"("frameworkInstanceId", "controlId", "formType"); + +-- AddForeignKey +ALTER TABLE "FrameworkEditorControlPolicyTemplateLink" ADD CONSTRAINT "FrameworkEditorControlPolicyTemplateLink_frameworkId_fkey" FOREIGN KEY ("frameworkId") REFERENCES "FrameworkEditorFramework"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "FrameworkEditorControlPolicyTemplateLink" ADD CONSTRAINT "FrameworkEditorControlPolicyTemplateLink_controlTemplateId_fkey" FOREIGN KEY ("controlTemplateId") REFERENCES "FrameworkEditorControlTemplate"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "FrameworkEditorControlPolicyTemplateLink" ADD CONSTRAINT "FrameworkEditorControlPolicyTemplateLink_policyTemplateId_fkey" FOREIGN KEY ("policyTemplateId") REFERENCES "FrameworkEditorPolicyTemplate"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "FrameworkEditorControlTaskTemplateLink" ADD CONSTRAINT "FrameworkEditorControlTaskTemplateLink_frameworkId_fkey" FOREIGN KEY ("frameworkId") REFERENCES "FrameworkEditorFramework"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "FrameworkEditorControlTaskTemplateLink" ADD CONSTRAINT "FrameworkEditorControlTaskTemplateLink_controlTemplateId_fkey" FOREIGN KEY ("controlTemplateId") REFERENCES "FrameworkEditorControlTemplate"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "FrameworkEditorControlTaskTemplateLink" ADD CONSTRAINT "FrameworkEditorControlTaskTemplateLink_taskTemplateId_fkey" FOREIGN KEY ("taskTemplateId") REFERENCES "FrameworkEditorTaskTemplate"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "FrameworkEditorControlDocumentTypeLink" ADD CONSTRAINT "FrameworkEditorControlDocumentTypeLink_frameworkId_fkey" FOREIGN KEY ("frameworkId") REFERENCES "FrameworkEditorFramework"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "FrameworkEditorControlDocumentTypeLink" ADD CONSTRAINT "FrameworkEditorControlDocumentTypeLink_controlTemplateId_fkey" FOREIGN KEY ("controlTemplateId") REFERENCES "FrameworkEditorControlTemplate"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "FrameworkControlPolicyLink" ADD CONSTRAINT "FrameworkControlPolicyLink_frameworkInstanceId_fkey" FOREIGN KEY ("frameworkInstanceId") REFERENCES "FrameworkInstance"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "FrameworkControlPolicyLink" ADD CONSTRAINT "FrameworkControlPolicyLink_controlId_fkey" FOREIGN KEY ("controlId") REFERENCES "Control"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "FrameworkControlPolicyLink" ADD CONSTRAINT "FrameworkControlPolicyLink_policyId_fkey" FOREIGN KEY ("policyId") REFERENCES "Policy"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "FrameworkControlTaskLink" ADD CONSTRAINT "FrameworkControlTaskLink_frameworkInstanceId_fkey" FOREIGN KEY ("frameworkInstanceId") REFERENCES "FrameworkInstance"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "FrameworkControlTaskLink" ADD CONSTRAINT "FrameworkControlTaskLink_controlId_fkey" FOREIGN KEY ("controlId") REFERENCES "Control"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "FrameworkControlTaskLink" ADD CONSTRAINT "FrameworkControlTaskLink_taskId_fkey" FOREIGN KEY ("taskId") REFERENCES "Task"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "FrameworkControlDocumentTypeLink" ADD CONSTRAINT "FrameworkControlDocumentTypeLink_frameworkInstanceId_fkey" FOREIGN KEY ("frameworkInstanceId") REFERENCES "FrameworkInstance"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "FrameworkControlDocumentTypeLink" ADD CONSTRAINT "FrameworkControlDocumentTypeLink_controlId_fkey" FOREIGN KEY ("controlId") REFERENCES "Control"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/db/prisma/migrations/20260513153000_backfill_framework_scoped_control_links/migration.sql b/packages/db/prisma/migrations/20260513153000_backfill_framework_scoped_control_links/migration.sql new file mode 100644 index 0000000000..97352498bd --- /dev/null +++ b/packages/db/prisma/migrations/20260513153000_backfill_framework_scoped_control_links/migration.sql @@ -0,0 +1,284 @@ +-- Backfill framework-scoped control links from the best available source of truth. +-- +-- Published framework versions are authoritative for already-versioned +-- framework definitions and organization instances. Frameworks/instances +-- without a published version fall back to the previous global link tables so +-- local or not-yet-versioned data does not appear empty after deploy. + +-- Catalog/template links from each framework's latest published manifest. +WITH latest_versions AS ( + SELECT DISTINCT ON ("frameworkId") + "frameworkId", + manifest + FROM "FrameworkVersion" + ORDER BY "frameworkId", "publishedAt" DESC +) +INSERT INTO "FrameworkEditorControlPolicyTemplateLink" ( + "frameworkId", + "controlTemplateId", + "policyTemplateId" +) +SELECT + latest_versions."frameworkId", + control_template.id, + policy_template.id +FROM latest_versions +CROSS JOIN LATERAL jsonb_array_elements(COALESCE(latest_versions.manifest -> 'controls', '[]'::jsonb)) AS control_json(control_data) +CROSS JOIN LATERAL jsonb_array_elements_text(COALESCE(control_json.control_data -> 'policyIds', '[]'::jsonb)) AS policy_ids(policy_id) +JOIN "FrameworkEditorControlTemplate" control_template ON control_template.id = control_json.control_data ->> 'id' +JOIN "FrameworkEditorPolicyTemplate" policy_template ON policy_template.id = policy_ids.policy_id +ON CONFLICT ("frameworkId", "controlTemplateId", "policyTemplateId") DO NOTHING; + +WITH latest_versions AS ( + SELECT DISTINCT ON ("frameworkId") + "frameworkId", + manifest + FROM "FrameworkVersion" + ORDER BY "frameworkId", "publishedAt" DESC +) +INSERT INTO "FrameworkEditorControlTaskTemplateLink" ( + "frameworkId", + "controlTemplateId", + "taskTemplateId" +) +SELECT + latest_versions."frameworkId", + control_template.id, + task_template.id +FROM latest_versions +CROSS JOIN LATERAL jsonb_array_elements(COALESCE(latest_versions.manifest -> 'controls', '[]'::jsonb)) AS control_json(control_data) +CROSS JOIN LATERAL jsonb_array_elements_text(COALESCE(control_json.control_data -> 'taskIds', '[]'::jsonb)) AS task_ids(task_id) +JOIN "FrameworkEditorControlTemplate" control_template ON control_template.id = control_json.control_data ->> 'id' +JOIN "FrameworkEditorTaskTemplate" task_template ON task_template.id = task_ids.task_id +ON CONFLICT ("frameworkId", "controlTemplateId", "taskTemplateId") DO NOTHING; + +WITH latest_versions AS ( + SELECT DISTINCT ON ("frameworkId") + "frameworkId", + manifest + FROM "FrameworkVersion" + ORDER BY "frameworkId", "publishedAt" DESC +) +INSERT INTO "FrameworkEditorControlDocumentTypeLink" ( + "frameworkId", + "controlTemplateId", + "formType" +) +SELECT + latest_versions."frameworkId", + control_template.id, + document_type_labels.enumlabel::"EvidenceFormType" +FROM latest_versions +CROSS JOIN LATERAL jsonb_array_elements(COALESCE(latest_versions.manifest -> 'controls', '[]'::jsonb)) AS control_json(control_data) +CROSS JOIN LATERAL jsonb_array_elements_text(COALESCE(control_json.control_data -> 'documentTypes', '[]'::jsonb)) AS document_types(form_type) +JOIN pg_enum document_type_labels + ON document_type_labels.enumtypid = '"EvidenceFormType"'::regtype + AND document_type_labels.enumlabel = REPLACE(document_types.form_type, '_', '-') +JOIN "FrameworkEditorControlTemplate" control_template ON control_template.id = control_json.control_data ->> 'id' +ON CONFLICT ("frameworkId", "controlTemplateId", "formType") DO NOTHING; + +-- Catalog/template fallback for frameworks without any published versions. +WITH unpublished_frameworks AS ( + SELECT framework.id + FROM "FrameworkEditorFramework" framework + WHERE NOT EXISTS ( + SELECT 1 FROM "FrameworkVersion" version WHERE version."frameworkId" = framework.id + ) +) +INSERT INTO "FrameworkEditorControlPolicyTemplateLink" ( + "frameworkId", + "controlTemplateId", + "policyTemplateId" +) +SELECT DISTINCT + requirement."frameworkId", + control_policy."A", + control_policy."B" +FROM unpublished_frameworks +JOIN "FrameworkEditorRequirement" requirement ON requirement."frameworkId" = unpublished_frameworks.id +JOIN "_FrameworkEditorControlTemplateToFrameworkEditorRequirement" control_requirement + ON control_requirement."B" = requirement.id +JOIN "_FrameworkEditorControlTemplateToFrameworkEditorPolicyTemplate" control_policy + ON control_policy."A" = control_requirement."A" +ON CONFLICT ("frameworkId", "controlTemplateId", "policyTemplateId") DO NOTHING; + +WITH unpublished_frameworks AS ( + SELECT framework.id + FROM "FrameworkEditorFramework" framework + WHERE NOT EXISTS ( + SELECT 1 FROM "FrameworkVersion" version WHERE version."frameworkId" = framework.id + ) +) +INSERT INTO "FrameworkEditorControlTaskTemplateLink" ( + "frameworkId", + "controlTemplateId", + "taskTemplateId" +) +SELECT DISTINCT + requirement."frameworkId", + control_task."A", + control_task."B" +FROM unpublished_frameworks +JOIN "FrameworkEditorRequirement" requirement ON requirement."frameworkId" = unpublished_frameworks.id +JOIN "_FrameworkEditorControlTemplateToFrameworkEditorRequirement" control_requirement + ON control_requirement."B" = requirement.id +JOIN "_FrameworkEditorControlTemplateToFrameworkEditorTaskTemplate" control_task + ON control_task."A" = control_requirement."A" +ON CONFLICT ("frameworkId", "controlTemplateId", "taskTemplateId") DO NOTHING; + +WITH unpublished_frameworks AS ( + SELECT framework.id + FROM "FrameworkEditorFramework" framework + WHERE NOT EXISTS ( + SELECT 1 FROM "FrameworkVersion" version WHERE version."frameworkId" = framework.id + ) +) +INSERT INTO "FrameworkEditorControlDocumentTypeLink" ( + "frameworkId", + "controlTemplateId", + "formType" +) +SELECT DISTINCT + requirement."frameworkId", + control_template.id, + document_type.form_type +FROM unpublished_frameworks +JOIN "FrameworkEditorRequirement" requirement ON requirement."frameworkId" = unpublished_frameworks.id +JOIN "_FrameworkEditorControlTemplateToFrameworkEditorRequirement" control_requirement + ON control_requirement."B" = requirement.id +JOIN "FrameworkEditorControlTemplate" control_template + ON control_template.id = control_requirement."A" +CROSS JOIN LATERAL unnest(control_template."documentTypes") AS document_type(form_type) +ON CONFLICT ("frameworkId", "controlTemplateId", "formType") DO NOTHING; + +-- Organization framework-instance links from each instance's pinned version. +WITH versioned_instances AS ( + SELECT + instance.id AS "frameworkInstanceId", + instance."organizationId", + version.manifest + FROM "FrameworkInstance" instance + JOIN "FrameworkVersion" version ON version.id = instance."currentVersionId" +) +INSERT INTO "FrameworkControlPolicyLink" ( + "frameworkInstanceId", + "controlId", + "policyId" +) +SELECT + versioned_instances."frameworkInstanceId", + control.id, + policy.id +FROM versioned_instances +CROSS JOIN LATERAL jsonb_array_elements(COALESCE(versioned_instances.manifest -> 'controls', '[]'::jsonb)) AS control_json(control_data) +CROSS JOIN LATERAL jsonb_array_elements_text(COALESCE(control_json.control_data -> 'policyIds', '[]'::jsonb)) AS policy_ids(policy_template_id) +JOIN "Control" control + ON control."organizationId" = versioned_instances."organizationId" + AND control."controlTemplateId" = control_json.control_data ->> 'id' +JOIN "Policy" policy + ON policy."organizationId" = versioned_instances."organizationId" + AND policy."policyTemplateId" = policy_ids.policy_template_id +ON CONFLICT ("frameworkInstanceId", "controlId", "policyId") DO NOTHING; + +WITH versioned_instances AS ( + SELECT + instance.id AS "frameworkInstanceId", + instance."organizationId", + version.manifest + FROM "FrameworkInstance" instance + JOIN "FrameworkVersion" version ON version.id = instance."currentVersionId" +) +INSERT INTO "FrameworkControlTaskLink" ( + "frameworkInstanceId", + "controlId", + "taskId" +) +SELECT + versioned_instances."frameworkInstanceId", + control.id, + task.id +FROM versioned_instances +CROSS JOIN LATERAL jsonb_array_elements(COALESCE(versioned_instances.manifest -> 'controls', '[]'::jsonb)) AS control_json(control_data) +CROSS JOIN LATERAL jsonb_array_elements_text(COALESCE(control_json.control_data -> 'taskIds', '[]'::jsonb)) AS task_ids(task_template_id) +JOIN "Control" control + ON control."organizationId" = versioned_instances."organizationId" + AND control."controlTemplateId" = control_json.control_data ->> 'id' +JOIN "Task" task + ON task."organizationId" = versioned_instances."organizationId" + AND task."taskTemplateId" = task_ids.task_template_id +ON CONFLICT ("frameworkInstanceId", "controlId", "taskId") DO NOTHING; + +WITH versioned_instances AS ( + SELECT + instance.id AS "frameworkInstanceId", + instance."organizationId", + version.manifest + FROM "FrameworkInstance" instance + JOIN "FrameworkVersion" version ON version.id = instance."currentVersionId" +) +INSERT INTO "FrameworkControlDocumentTypeLink" ( + "frameworkInstanceId", + "controlId", + "formType" +) +SELECT + versioned_instances."frameworkInstanceId", + control.id, + document_type_labels.enumlabel::"EvidenceFormType" +FROM versioned_instances +CROSS JOIN LATERAL jsonb_array_elements(COALESCE(versioned_instances.manifest -> 'controls', '[]'::jsonb)) AS control_json(control_data) +CROSS JOIN LATERAL jsonb_array_elements_text(COALESCE(control_json.control_data -> 'documentTypes', '[]'::jsonb)) AS document_types(form_type) +JOIN pg_enum document_type_labels + ON document_type_labels.enumtypid = '"EvidenceFormType"'::regtype + AND document_type_labels.enumlabel = REPLACE(document_types.form_type, '_', '-') +JOIN "Control" control + ON control."organizationId" = versioned_instances."organizationId" + AND control."controlTemplateId" = control_json.control_data ->> 'id' +ON CONFLICT ("frameworkInstanceId", "controlId", "formType") DO NOTHING; + +-- Organization-instance fallback for instances without pinned versions. +INSERT INTO "FrameworkControlPolicyLink" ( + "frameworkInstanceId", + "controlId", + "policyId" +) +SELECT DISTINCT + requirement_map."frameworkInstanceId", + control_policy."A", + control_policy."B" +FROM "RequirementMap" requirement_map +JOIN "FrameworkInstance" instance ON instance.id = requirement_map."frameworkInstanceId" +JOIN "_ControlToPolicy" control_policy ON control_policy."A" = requirement_map."controlId" +WHERE instance."currentVersionId" IS NULL +ON CONFLICT ("frameworkInstanceId", "controlId", "policyId") DO NOTHING; + +INSERT INTO "FrameworkControlTaskLink" ( + "frameworkInstanceId", + "controlId", + "taskId" +) +SELECT DISTINCT + requirement_map."frameworkInstanceId", + control_task."A", + control_task."B" +FROM "RequirementMap" requirement_map +JOIN "FrameworkInstance" instance ON instance.id = requirement_map."frameworkInstanceId" +JOIN "_ControlToTask" control_task ON control_task."A" = requirement_map."controlId" +WHERE instance."currentVersionId" IS NULL +ON CONFLICT ("frameworkInstanceId", "controlId", "taskId") DO NOTHING; + +INSERT INTO "FrameworkControlDocumentTypeLink" ( + "frameworkInstanceId", + "controlId", + "formType" +) +SELECT DISTINCT + requirement_map."frameworkInstanceId", + control_document_type."controlId", + control_document_type."formType" +FROM "RequirementMap" requirement_map +JOIN "FrameworkInstance" instance ON instance.id = requirement_map."frameworkInstanceId" +JOIN "ControlDocumentType" control_document_type + ON control_document_type."controlId" = requirement_map."controlId" +WHERE instance."currentVersionId" IS NULL +ON CONFLICT ("frameworkInstanceId", "controlId", "formType") DO NOTHING; diff --git a/packages/db/prisma/schema/control.prisma b/packages/db/prisma/schema/control.prisma index 0a70fc5a9e..f64e69b47d 100644 --- a/packages/db/prisma/schema/control.prisma +++ b/packages/db/prisma/schema/control.prisma @@ -15,14 +15,17 @@ model Control { archivedAt DateTime? // Relationships - organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) - organizationId String - requirementsMapped RequirementMap[] - tasks Task[] - policies Policy[] - controlTemplateId String? - controlTemplate FrameworkEditorControlTemplate? @relation(fields: [controlTemplateId], references: [id]) - controlDocumentTypes ControlDocumentType[] + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + organizationId String + requirementsMapped RequirementMap[] + tasks Task[] + policies Policy[] + controlTemplateId String? + controlTemplate FrameworkEditorControlTemplate? @relation(fields: [controlTemplateId], references: [id]) + controlDocumentTypes ControlDocumentType[] + frameworkPolicyLinks FrameworkControlPolicyLink[] + frameworkTaskLinks FrameworkControlTaskLink[] + frameworkDocumentLinks FrameworkControlDocumentTypeLink[] @@index([organizationId]) @@index([organizationId, archivedAt]) diff --git a/packages/db/prisma/schema/framework-editor.prisma b/packages/db/prisma/schema/framework-editor.prisma index 6bb6e302f5..00db60d196 100644 --- a/packages/db/prisma/schema/framework-editor.prisma +++ b/packages/db/prisma/schema/framework-editor.prisma @@ -18,12 +18,15 @@ model FrameworkEditorFramework { description String visible Boolean @default(false) - requirements FrameworkEditorRequirement[] - frameworkInstances FrameworkInstance[] - soaConfigurations SOAFrameworkConfiguration[] // Multiple SOA config versions per framework - soaDocuments SOADocument[] // SOA documents from organizations - timelineTemplates TimelineTemplate[] - versions FrameworkVersion[] + requirements FrameworkEditorRequirement[] + frameworkInstances FrameworkInstance[] + soaConfigurations SOAFrameworkConfiguration[] // Multiple SOA config versions per framework + soaDocuments SOADocument[] // SOA documents from organizations + timelineTemplates TimelineTemplate[] + versions FrameworkVersion[] + controlPolicyLinks FrameworkEditorControlPolicyTemplateLink[] + controlTaskLinks FrameworkEditorControlTaskTemplateLink[] + controlDocumentLinks FrameworkEditorControlDocumentTypeLink[] // Dates createdAt DateTime @default(now()) @@ -55,7 +58,8 @@ model FrameworkEditorPolicyTemplate { department Departments // Using the enum from shared.prisma content Json - controlTemplates FrameworkEditorControlTemplate[] + controlTemplates FrameworkEditorControlTemplate[] + frameworkControlLinks FrameworkEditorControlPolicyTemplateLink[] // Dates createdAt DateTime @default(now()) @@ -73,7 +77,8 @@ model FrameworkEditorTaskTemplate { department Departments // Using the enum from shared.prisma automationStatus TaskAutomationStatus @default(AUTOMATED) - controlTemplates FrameworkEditorControlTemplate[] + controlTemplates FrameworkEditorControlTemplate[] + frameworkControlLinks FrameworkEditorControlTaskTemplateLink[] // Dates createdAt DateTime @default(now()) @@ -88,10 +93,13 @@ model FrameworkEditorControlTemplate { name String description String - policyTemplates FrameworkEditorPolicyTemplate[] - requirements FrameworkEditorRequirement[] - taskTemplates FrameworkEditorTaskTemplate[] - documentTypes EvidenceFormType[] + policyTemplates FrameworkEditorPolicyTemplate[] + requirements FrameworkEditorRequirement[] + taskTemplates FrameworkEditorTaskTemplate[] + documentTypes EvidenceFormType[] + frameworkPolicyLinks FrameworkEditorControlPolicyTemplateLink[] + frameworkTaskLinks FrameworkEditorControlTaskTemplateLink[] + frameworkDocumentLinks FrameworkEditorControlDocumentTypeLink[] // Dates createdAt DateTime @default(now()) @@ -100,3 +108,49 @@ model FrameworkEditorControlTemplate { // Instances controls Control[] } + +model FrameworkEditorControlPolicyTemplateLink { + id String @id @default(dbgenerated("generate_prefixed_cuid('fcp'::text)")) + frameworkId String + framework FrameworkEditorFramework @relation(fields: [frameworkId], references: [id], onDelete: Cascade) + controlTemplateId String + controlTemplate FrameworkEditorControlTemplate @relation(fields: [controlTemplateId], references: [id], onDelete: Cascade) + policyTemplateId String + policyTemplate FrameworkEditorPolicyTemplate @relation(fields: [policyTemplateId], references: [id], onDelete: Cascade) + + createdAt DateTime @default(now()) + + @@unique([frameworkId, controlTemplateId, policyTemplateId]) + @@index([controlTemplateId]) + @@index([policyTemplateId]) +} + +model FrameworkEditorControlTaskTemplateLink { + id String @id @default(dbgenerated("generate_prefixed_cuid('fct'::text)")) + frameworkId String + framework FrameworkEditorFramework @relation(fields: [frameworkId], references: [id], onDelete: Cascade) + controlTemplateId String + controlTemplate FrameworkEditorControlTemplate @relation(fields: [controlTemplateId], references: [id], onDelete: Cascade) + taskTemplateId String + taskTemplate FrameworkEditorTaskTemplate @relation(fields: [taskTemplateId], references: [id], onDelete: Cascade) + + createdAt DateTime @default(now()) + + @@unique([frameworkId, controlTemplateId, taskTemplateId]) + @@index([controlTemplateId]) + @@index([taskTemplateId]) +} + +model FrameworkEditorControlDocumentTypeLink { + id String @id @default(dbgenerated("generate_prefixed_cuid('fcd'::text)")) + frameworkId String + framework FrameworkEditorFramework @relation(fields: [frameworkId], references: [id], onDelete: Cascade) + controlTemplateId String + controlTemplate FrameworkEditorControlTemplate @relation(fields: [controlTemplateId], references: [id], onDelete: Cascade) + formType EvidenceFormType + + createdAt DateTime @default(now()) + + @@unique([frameworkId, controlTemplateId, formType]) + @@index([controlTemplateId]) +} diff --git a/packages/db/prisma/schema/framework.prisma b/packages/db/prisma/schema/framework.prisma index c7c5bb1962..171334e072 100644 --- a/packages/db/prisma/schema/framework.prisma +++ b/packages/db/prisma/schema/framework.prisma @@ -16,14 +16,17 @@ model FrameworkInstance { currentVersion FrameworkVersion? @relation("FrameworkInstanceCurrentVersion", fields: [currentVersionId], references: [id], onDelete: Restrict) // Relationships - organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) - requirementsMapped RequirementMap[] - timelineInstances TimelineInstance[] - syncOperations FrameworkSyncOperation[] + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + requirementsMapped RequirementMap[] + timelineInstances TimelineInstance[] + syncOperations FrameworkSyncOperation[] + controlPolicyLinks FrameworkControlPolicyLink[] + controlTaskLinks FrameworkControlTaskLink[] + controlDocumentLinks FrameworkControlDocumentTypeLink[] // Per-instance custom requirements (used when an org tacks an extra requirement // onto a platform framework instance). Custom requirements attached to a // CustomFramework hang off CustomFramework.requirements instead. - customRequirements CustomRequirement[] + customRequirements CustomRequirement[] // (id, organizationId) is the composite-FK target for CustomRequirement.frameworkInstanceId // so a per-instance custom requirement can only point at an FI in its own org. @@ -33,3 +36,49 @@ model FrameworkInstance { @@index([customFrameworkId]) @@index([currentVersionId]) } + +model FrameworkControlPolicyLink { + id String @id @default(dbgenerated("generate_prefixed_cuid('fpl'::text)")) + frameworkInstanceId String + frameworkInstance FrameworkInstance @relation(fields: [frameworkInstanceId], references: [id], onDelete: Cascade) + controlId String + control Control @relation(fields: [controlId], references: [id], onDelete: Cascade) + policyId String + policy Policy @relation(fields: [policyId], references: [id], onDelete: Cascade) + + createdAt DateTime @default(now()) + + @@unique([frameworkInstanceId, controlId, policyId]) + @@index([controlId]) + @@index([policyId]) +} + +model FrameworkControlTaskLink { + id String @id @default(dbgenerated("generate_prefixed_cuid('ftl'::text)")) + frameworkInstanceId String + frameworkInstance FrameworkInstance @relation(fields: [frameworkInstanceId], references: [id], onDelete: Cascade) + controlId String + control Control @relation(fields: [controlId], references: [id], onDelete: Cascade) + taskId String + task Task @relation(fields: [taskId], references: [id], onDelete: Cascade) + + createdAt DateTime @default(now()) + + @@unique([frameworkInstanceId, controlId, taskId]) + @@index([controlId]) + @@index([taskId]) +} + +model FrameworkControlDocumentTypeLink { + id String @id @default(dbgenerated("generate_prefixed_cuid('fdl'::text)")) + frameworkInstanceId String + frameworkInstance FrameworkInstance @relation(fields: [frameworkInstanceId], references: [id], onDelete: Cascade) + controlId String + control Control @relation(fields: [controlId], references: [id], onDelete: Cascade) + formType EvidenceFormType + + createdAt DateTime @default(now()) + + @@unique([frameworkInstanceId, controlId, formType]) + @@index([controlId]) +} diff --git a/packages/db/prisma/schema/policy.prisma b/packages/db/prisma/schema/policy.prisma index 461ec471b6..50f1c87853 100644 --- a/packages/db/prisma/schema/policy.prisma +++ b/packages/db/prisma/schema/policy.prisma @@ -41,20 +41,21 @@ model Policy { archivedAt DateTime? // Relationships - organizationId String - organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) - assigneeId String? - assignee Member? @relation("PolicyAssignee", fields: [assigneeId], references: [id], onDelete: SetNull, onUpdate: Cascade) - approverId String? - approver Member? @relation("PolicyApprover", fields: [approverId], references: [id], onDelete: SetNull, onUpdate: Cascade) - policyTemplateId String? - policyTemplate FrameworkEditorPolicyTemplate? @relation(fields: [policyTemplateId], references: [id]) - controls Control[] - currentVersionId String? @unique - currentVersion PolicyVersion? @relation("PolicyCurrentVersion", fields: [currentVersionId], references: [id]) - pendingVersionId String? - versions PolicyVersion[] @relation("PolicyVersions") - findings Finding[] + organizationId String + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + assigneeId String? + assignee Member? @relation("PolicyAssignee", fields: [assigneeId], references: [id], onDelete: SetNull, onUpdate: Cascade) + approverId String? + approver Member? @relation("PolicyApprover", fields: [approverId], references: [id], onDelete: SetNull, onUpdate: Cascade) + policyTemplateId String? + policyTemplate FrameworkEditorPolicyTemplate? @relation(fields: [policyTemplateId], references: [id]) + controls Control[] + frameworkControlLinks FrameworkControlPolicyLink[] + currentVersionId String? @unique + currentVersion PolicyVersion? @relation("PolicyCurrentVersion", fields: [currentVersionId], references: [id]) + pendingVersionId String? + versions PolicyVersion[] @relation("PolicyVersions") + findings Finding[] @@index([organizationId]) @@index([organizationId, archivedAt]) diff --git a/packages/db/prisma/schema/task.prisma b/packages/db/prisma/schema/task.prisma index ed056588ef..3b054a0597 100644 --- a/packages/db/prisma/schema/task.prisma +++ b/packages/db/prisma/schema/task.prisma @@ -18,17 +18,18 @@ model Task { reviewDate DateTime? // Relationships - assigneeId String? - assignee Member? @relation(fields: [assigneeId], references: [id]) - organizationId String - organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) - taskTemplateId String? - taskTemplate FrameworkEditorTaskTemplate? @relation(fields: [taskTemplateId], references: [id]) - controls Control[] - vendors Vendor[] - risks Risk[] - evidenceAutomations EvidenceAutomation[] - browserAutomations BrowserAutomation[] + assigneeId String? + assignee Member? @relation(fields: [assigneeId], references: [id]) + organizationId String + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + taskTemplateId String? + taskTemplate FrameworkEditorTaskTemplate? @relation(fields: [taskTemplateId], references: [id]) + controls Control[] + frameworkControlLinks FrameworkControlTaskLink[] + vendors Vendor[] + risks Risk[] + evidenceAutomations EvidenceAutomation[] + browserAutomations BrowserAutomation[] evidenceAutomationRuns EvidenceAutomationRun[] integrationCheckRuns IntegrationCheckRun[] diff --git a/packages/docs/openapi.json b/packages/docs/openapi.json index b38d01aef5..cb75696ffd 100644 --- a/packages/docs/openapi.json +++ b/packages/docs/openapi.json @@ -20414,6 +20414,14 @@ "schema": { "type": "string" } + }, + { + "name": "frameworkInstanceId", + "required": true, + "in": "query", + "schema": { + "type": "string" + } } ], "responses": { @@ -20490,6 +20498,14 @@ "schema": { "type": "string" } + }, + { + "name": "frameworkInstanceId", + "required": true, + "in": "query", + "schema": { + "type": "string" + } } ], "requestBody": { @@ -20539,6 +20555,14 @@ "schema": { "type": "string" } + }, + { + "name": "frameworkInstanceId", + "required": true, + "in": "query", + "schema": { + "type": "string" + } } ], "requestBody": { @@ -20637,6 +20661,14 @@ "schema": { "type": "string" } + }, + { + "name": "frameworkInstanceId", + "required": true, + "in": "query", + "schema": { + "type": "string" + } } ], "requestBody": { @@ -20694,6 +20726,14 @@ "schema": { "type": "string" } + }, + { + "name": "frameworkInstanceId", + "required": true, + "in": "query", + "schema": { + "type": "string" + } } ], "responses": { From a024822a36ef7ad328466d70bd4168c19c3fcc3a Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Wed, 13 May 2026 12:14:33 -0400 Subject: [PATCH 2/3] feat(control-template): enhance control template service and controller for document type linking - Updated ControlTemplateService to streamline the creation and updating of control templates, ensuring proper handling of document types. - Refactored methods to utilize transactions for creating and linking document types, improving data integrity. - Enhanced ControlTemplateController to enforce validation on formType parameters using ParseEnumPipe. - Updated tests to reflect changes in service logic and ensure robust coverage for new functionality. --- apps/api/src/controls/controls.service.ts | 19 ++- .../control-template.controller.ts | 9 +- .../control-template.service.spec.ts | 20 ++- .../control-template.service.ts | 159 ++++++++++-------- .../task-template/task-template.service.ts | 14 +- apps/api/src/frameworks/frameworks.service.ts | 54 ++++-- 6 files changed, 175 insertions(+), 100 deletions(-) diff --git a/apps/api/src/controls/controls.service.ts b/apps/api/src/controls/controls.service.ts index 57494eca9c..9476b28ba6 100644 --- a/apps/api/src/controls/controls.service.ts +++ b/apps/api/src/controls/controls.service.ts @@ -210,11 +210,17 @@ export class ControlsService { where: { id: controlId, organizationId }, include: { frameworkPolicyLinks: { - where: { frameworkInstanceId }, + where: { + frameworkInstanceId, + policy: { archivedAt: null }, + }, include: { policy: true }, }, frameworkTaskLinks: { - where: { frameworkInstanceId }, + where: { + frameworkInstanceId, + task: { archivedAt: null }, + }, include: { task: true }, }, frameworkDocumentLinks: { @@ -277,8 +283,15 @@ export class ControlsService { const completed = policyCompleted + taskCompleted; const totalItems = policies.length + tasks.length; + const { + frameworkPolicyLinks, + frameworkTaskLinks, + frameworkDocumentLinks, + ...controlData + } = control; + return { - ...control, + ...controlData, policies, tasks, controlDocumentTypes: controlDocumentTypes.map((documentType) => ({ diff --git a/apps/api/src/framework-editor/control-template/control-template.controller.ts b/apps/api/src/framework-editor/control-template/control-template.controller.ts index cd42c49363..6ae126a18b 100644 --- a/apps/api/src/framework-editor/control-template/control-template.controller.ts +++ b/apps/api/src/framework-editor/control-template/control-template.controller.ts @@ -6,13 +6,14 @@ import { Delete, Body, Param, + ParseEnumPipe, Query, UseGuards, UsePipes, ValidationPipe, } from '@nestjs/common'; import { ApiOperation, ApiTags } from '@nestjs/swagger'; -import type { EvidenceFormType } from '@db'; +import { EvidenceFormType } from '@db'; import { PlatformAdminGuard } from '../../auth/platform-admin.guard'; import { CreateControlTemplateDto } from './dto/create-control-template.dto'; import { UpdateControlTemplateDto } from './dto/update-control-template.dto'; @@ -124,7 +125,8 @@ export class ControlTemplateController { @ApiOperation({ summary: 'Link a document type to a control template' }) async linkDocumentType( @Param('id') id: string, - @Param('formType') formType: EvidenceFormType, + @Param('formType', new ParseEnumPipe(EvidenceFormType)) + formType: EvidenceFormType, @Query('frameworkId') frameworkId?: string, ) { return this.service.linkDocumentType(id, formType, frameworkId); @@ -134,7 +136,8 @@ export class ControlTemplateController { @ApiOperation({ summary: 'Unlink a document type from a control template' }) async unlinkDocumentType( @Param('id') id: string, - @Param('formType') formType: EvidenceFormType, + @Param('formType', new ParseEnumPipe(EvidenceFormType)) + formType: EvidenceFormType, @Query('frameworkId') frameworkId?: string, ) { return this.service.unlinkDocumentType(id, formType, frameworkId); diff --git a/apps/api/src/framework-editor/control-template/control-template.service.spec.ts b/apps/api/src/framework-editor/control-template/control-template.service.spec.ts index e4681ddba9..47453a0430 100644 --- a/apps/api/src/framework-editor/control-template/control-template.service.spec.ts +++ b/apps/api/src/framework-editor/control-template/control-template.service.spec.ts @@ -1,5 +1,5 @@ -jest.mock('@db', () => ({ - db: { +jest.mock('@db', () => { + const dbMock = { frameworkEditorControlTemplate: { create: jest.fn(), findUnique: jest.fn(), @@ -25,10 +25,18 @@ jest.mock('@db', () => ({ createMany: jest.fn(), deleteMany: jest.fn(), }, - $transaction: jest.fn((operations) => Promise.all(operations)), - }, - Prisma: { PrismaClientKnownRequestError: class {} }, -})); + $transaction: jest.fn((operations) => + typeof operations === 'function' + ? operations(dbMock) + : Promise.all(operations), + ), + }; + + return { + db: dbMock, + Prisma: { PrismaClientKnownRequestError: class {} }, + }; +}); import { db } from '@db'; import { ControlTemplateService } from './control-template.service'; 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 c9b2c05f61..44be100d1e 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 @@ -100,39 +100,87 @@ export class ControlTemplateService { } async create(dto: CreateControlTemplateDto) { - const ct = await db.frameworkEditorControlTemplate.create({ - data: { - name: dto.name, - description: dto.description ?? '', - }, - }); - if (dto.documentTypes !== undefined) { - await this.replaceDocumentTypeLinks({ - controlId: ct.id, - frameworkId: dto.frameworkId, - formTypes: dto.documentTypes as EvidenceFormType[], + if (dto.documentTypes === undefined) { + const ct = await db.frameworkEditorControlTemplate.create({ + data: { + name: dto.name, + description: dto.description ?? '', + }, }); + this.logger.log(`Created control template: ${ct.name} (${ct.id})`); + return ct; } + + const scopedFrameworkId = await this.ensureFramework(dto.frameworkId); + const uniqueFormTypes = Array.from( + new Set(dto.documentTypes as EvidenceFormType[]), + ); + const ct = await db.$transaction(async (tx) => { + const created = await tx.frameworkEditorControlTemplate.create({ + data: { + name: dto.name, + description: dto.description ?? '', + }, + }); + await tx.frameworkEditorControlDocumentTypeLink.createMany({ + data: uniqueFormTypes.map((formType) => ({ + frameworkId: scopedFrameworkId, + controlTemplateId: created.id, + formType, + })), + skipDuplicates: true, + }); + return created; + }); this.logger.log(`Created control template: ${ct.name} (${ct.id})`); return ct; } async update(id: string, dto: UpdateControlTemplateDto) { - await this.findById(id); - const updated = await db.frameworkEditorControlTemplate.update({ - where: { id }, - data: { - ...(dto.name !== undefined && { name: dto.name }), - ...(dto.description !== undefined && { description: dto.description }), - }, - }); - if (dto.documentTypes !== undefined) { - await this.replaceDocumentTypeLinks({ - controlId: id, - frameworkId: dto.frameworkId, - formTypes: dto.documentTypes as EvidenceFormType[], + if (dto.documentTypes === undefined) { + await this.findById(id); + const updated = await db.frameworkEditorControlTemplate.update({ + where: { id }, + data: { + ...(dto.name !== undefined && { name: dto.name }), + ...(dto.description !== undefined && { description: dto.description }), + }, }); + this.logger.log(`Updated control template: ${updated.name} (${id})`); + return updated; } + + const scopedFrameworkId = await this.ensureFrameworkScopedControl({ + controlId: id, + frameworkId: dto.frameworkId, + }); + const uniqueFormTypes = Array.from( + new Set(dto.documentTypes as EvidenceFormType[]), + ); + const updated = await db.$transaction(async (tx) => { + const control = await tx.frameworkEditorControlTemplate.update({ + where: { id }, + data: { + ...(dto.name !== undefined && { name: dto.name }), + ...(dto.description !== undefined && { description: dto.description }), + }, + }); + await tx.frameworkEditorControlDocumentTypeLink.deleteMany({ + where: { + frameworkId: scopedFrameworkId, + controlTemplateId: id, + }, + }); + await tx.frameworkEditorControlDocumentTypeLink.createMany({ + data: uniqueFormTypes.map((formType) => ({ + frameworkId: scopedFrameworkId, + controlTemplateId: id, + formType, + })), + skipDuplicates: true, + }); + return control; + }); this.logger.log(`Updated control template: ${updated.name} (${id})`); return updated; } @@ -285,59 +333,34 @@ export class ControlTemplateService { return { message: 'Document type unlinked' }; } - private async replaceDocumentTypeLinks(params: { + private async ensureFrameworkScopedControl(params: { controlId: string; frameworkId?: string; - formTypes: EvidenceFormType[]; - }) { - const scopedFrameworkId = await this.ensureFrameworkScopedControl({ - controlId: params.controlId, - frameworkId: params.frameworkId, + }): Promise { + const frameworkId = await this.ensureFramework(params.frameworkId); + const control = await db.frameworkEditorControlTemplate.findUnique({ + where: { id: params.controlId }, + select: { id: true }, }); - const uniqueFormTypes = Array.from(new Set(params.formTypes)); - await db.$transaction([ - db.frameworkEditorControlDocumentTypeLink.deleteMany({ - where: { - frameworkId: scopedFrameworkId, - controlTemplateId: params.controlId, - }, - }), - db.frameworkEditorControlDocumentTypeLink.createMany({ - data: uniqueFormTypes.map((formType) => ({ - frameworkId: scopedFrameworkId, - controlTemplateId: params.controlId, - formType, - })), - skipDuplicates: true, - }), - ]); + if (!control) { + throw new NotFoundException( + `Control template ${params.controlId} not found`, + ); + } + return frameworkId; } - private async ensureFrameworkScopedControl(params: { - controlId: string; - frameworkId?: string; - }): Promise { - if (!params.frameworkId) { + private async ensureFramework(frameworkId?: string): Promise { + if (!frameworkId) { throw new BadRequestException( 'frameworkId is required for policy, task, and document links', ); } - const [framework, control] = await Promise.all([ - db.frameworkEditorFramework.findUnique({ - where: { id: params.frameworkId }, - select: { id: true }, - }), - db.frameworkEditorControlTemplate.findUnique({ - where: { id: params.controlId }, - select: { id: true }, - }), - ]); + const framework = await db.frameworkEditorFramework.findUnique({ + where: { id: frameworkId }, + select: { id: true }, + }); if (!framework) throw new NotFoundException('Framework not found'); - if (!control) { - throw new NotFoundException( - `Control template ${params.controlId} not found`, - ); - } - return params.frameworkId; + return frameworkId; } } diff --git a/apps/api/src/framework-editor/task-template/task-template.service.ts b/apps/api/src/framework-editor/task-template/task-template.service.ts index 0c660fcc0e..a43aebdafa 100644 --- a/apps/api/src/framework-editor/task-template/task-template.service.ts +++ b/apps/api/src/framework-editor/task-template/task-template.service.ts @@ -102,17 +102,23 @@ export class TaskTemplateService { if (!frameworkId) { throw new NotFoundException('Framework not found'); } - await Promise.all([ - this.findById(taskTemplateId), - db.frameworkEditorControlTemplate.findUniqueOrThrow({ + await this.findById(taskTemplateId); + const [controlTemplate, framework] = await Promise.all([ + db.frameworkEditorControlTemplate.findUnique({ where: { id: controlTemplateId }, select: { id: true }, }), - db.frameworkEditorFramework.findUniqueOrThrow({ + db.frameworkEditorFramework.findUnique({ where: { id: frameworkId }, select: { id: true }, }), ]); + if (!controlTemplate) { + throw new NotFoundException('Control template not found'); + } + if (!framework) { + throw new NotFoundException('Framework not found'); + } await db.frameworkEditorControlTaskTemplateLink.createMany({ data: [{ frameworkId, controlTemplateId, taskTemplateId }], skipDuplicates: true, diff --git a/apps/api/src/frameworks/frameworks.service.ts b/apps/api/src/frameworks/frameworks.service.ts index 39b4a7cd6f..99b58d839a 100644 --- a/apps/api/src/frameworks/frameworks.service.ts +++ b/apps/api/src/frameworks/frameworks.service.ts @@ -157,6 +157,7 @@ export class FrameworksService { control: { include: { frameworkPolicyLinks: { + where: { policy: { archivedAt: null } }, include: { policy: { select: { id: true, name: true, status: true }, @@ -184,7 +185,12 @@ export class FrameworksService { const controlsMap = new Map(); for (const rm of fi.requirementsMapped || []) { if (rm.control && !controlsMap.has(rm.control.id)) { - const { requirementsMapped: _, ...controlData } = rm.control; + const { + requirementsMapped: _, + frameworkPolicyLinks, + frameworkDocumentLinks, + ...controlData + } = rm.control; const policyLinks = rm.control.frameworkPolicyLinks.filter( (link: { frameworkInstanceId: string }) => link.frameworkInstanceId === fi.id, @@ -266,7 +272,10 @@ export class FrameworksService { control: { include: { frameworkPolicyLinks: { - where: { frameworkInstanceId }, + where: { + frameworkInstanceId, + policy: { archivedAt: null }, + }, include: { policy: { select: { id: true, name: true, status: true }, @@ -294,7 +303,12 @@ export class FrameworksService { const controlsMap = new Map(); for (const rm of fi.requirementsMapped) { if (rm.control && !controlsMap.has(rm.control.id)) { - const { requirementsMapped: _, ...controlData } = rm.control; + const { + requirementsMapped: _, + frameworkPolicyLinks, + frameworkDocumentLinks, + ...controlData + } = rm.control; controlsMap.set(rm.control.id, { ...controlData, policies: @@ -770,7 +784,10 @@ export class FrameworksService { control: { include: { frameworkPolicyLinks: { - where: { frameworkInstanceId }, + where: { + frameworkInstanceId, + policy: { archivedAt: null }, + }, include: { policy: { select: { id: true, name: true, status: true }, @@ -828,18 +845,23 @@ export class FrameworksService { requirement, relatedControls: relatedControls.map((relatedControl) => ({ ...relatedControl, - control: { - ...relatedControl.control, - policies: relatedControl.control.frameworkPolicyLinks.map( - (link) => link.policy, - ), - controlDocumentTypes: relatedControl.control.frameworkDocumentLinks.map( - (documentType) => ({ - ...documentType, - isNotRelevant: notRelevantFormTypes.has(documentType.formType), - }), - ), - }, + 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 }) => ({ ...task, From d47cd5db15a4d198cfc0c67b549bda138b83ac4e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 13 May 2026 13:11:50 -0400 Subject: [PATCH 3/3] fix(rbac): enforce app:read for app access, use permissions for member invites, gate portal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(rbac): enforce app:read for app access, use permissions for member invites, gate portal Remove the APP_IMPLYING_RESOURCES fallback that let custom roles bypass the App Access toggle. Replace hardcoded role string checks in the member invite flow with RBAC permission checks (member:create/update), and add privilege escalation prevention for non-admin callers. Add portal:read / compliance-obligation check to the portal so unapproved roles are redirected. Co-Authored-By: Claude Opus 4.6 (1M context) * fix(rbac): resolve caller member permissions instead of hardcoded role checks Replace role string matching (isAdmin/isAuditor) with actual RBAC permission resolution — resolves the caller's member actions from both built-in and custom roles via BUILT_IN_ROLE_PERMISSIONS + DB lookup. Uses member:delete as the signal for full control (can assign any role) vs restricted (can only assign employee/contractor/custom roles). Co-Authored-By: Claude Opus 4.6 (1M context) * fix(rbac): let the permission guard handle member invite authorization Remove redundant validateAssignableRoles — the @RequirePermission guard on the controller already checks member:create. If the admin gave a custom role Members: Write, that role can invite. No second layer of role-string checks needed. Co-Authored-By: Claude Opus 4.6 (1M context) * fix(rbac): add server-side role assignment validation based on member permission level Resolve the caller's member actions from RBAC (built-in + custom roles). Write-level access (all CRUD) can assign any role. Partial access (e.g. auditor with create+read only) can only assign restricted roles (employee/contractor) and custom roles — cannot assign privileged built-in roles. Co-Authored-By: Claude Opus 4.6 (1M context) * fix(rbac): use permission checks for allowedBuiltInRoles on people page Replace hardcoded isAdminOrOwner/isAuditor role string checks with Write-level member permission check (all CRUD actions). Mirrors the backend validation logic. Co-Authored-By: Claude Opus 4.6 (1M context) * refactor(auth): add parseRolePermissions/parseRoleObligations helpers Extract the repeated typeof/JSON.parse pattern for OrganizationRole fields into typed helpers in the auth package. Replaces verbose defensive checks with one-liner calls that return typed objects. Co-Authored-By: Claude Opus 4.6 (1M context) * refactor: clean up parse helpers and role checks Add try/catch to JSON parse helpers, extract generic parseJsonField, add isRestrictedRole() to eliminate verbose readonly casts, and make portal-access checks consistent. Co-Authored-By: Claude Opus 4.6 (1M context) * chore: add cleanup skill for mandatory post-implementation code review Committed to the repo so all Claude agents working in this codebase will have it available and are required to run it after writing code. Checks for verbose patterns, inconsistent idioms, missing error handling, and readability issues. Co-Authored-By: Claude Opus 4.6 (1M context) * chore: update PostToolUse hook to remind about cleanup skill The hook now fires for all TS files in apps/ and packages/ and reminds agents to run the cleanup skill before committing. Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Mariano Fuentes Co-authored-by: Claude Opus 4.6 (1M context) --- .claude/settings.json | 2 +- .claude/skills/cleanup/SKILL.md | 102 +++++++++++++++++ apps/api/src/people/people-invite.service.ts | 107 ++++++++++++------ .../people/components/PeoplePageTabs.tsx | 7 +- .../app/src/app/(app)/[orgId]/people/page.tsx | 16 ++- apps/app/src/lib/permissions.ts | 28 +---- .../src/app/(app)/(home)/[orgId]/page.tsx | 10 +- apps/portal/src/utils/portal-access.ts | 45 ++++++++ packages/auth/src/index.ts | 4 + packages/auth/src/permissions.ts | 33 +++++- 10 files changed, 285 insertions(+), 69 deletions(-) create mode 100644 .claude/skills/cleanup/SKILL.md create mode 100644 apps/portal/src/utils/portal-access.ts diff --git a/.claude/settings.json b/.claude/settings.json index 8c97fa017d..fe2bfe99db 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -6,7 +6,7 @@ "hooks": [ { "type": "command", - "command": "file=\"$CLAUDE_FILE_PATH\"; if echo \"$file\" | grep -qE '\\.(ts|tsx)$' && echo \"$file\" | grep -qE '^(apps/api|apps/app)/'; then echo 'TypeScript file modified — remember to run typecheck before committing'; fi" + "command": "file=\"$CLAUDE_FILE_PATH\"; if echo \"$file\" | grep -qE '\\.(ts|tsx)$' && echo \"$file\" | grep -qE '^(apps/|packages/)'; then echo 'TypeScript file modified — run the cleanup skill and typecheck before committing'; fi" } ] }, diff --git a/.claude/skills/cleanup/SKILL.md b/.claude/skills/cleanup/SKILL.md new file mode 100644 index 0000000000..a7ced440e1 --- /dev/null +++ b/.claude/skills/cleanup/SKILL.md @@ -0,0 +1,102 @@ +--- +name: cleanup +description: "MUST run after writing or modifying code — reviews changed files for verbose patterns, inconsistencies, and readability issues before considering work done" +--- + +# Post-Implementation Cleanup + +**This skill is mandatory.** After writing or modifying code, you MUST review all changed files before reporting the task as complete. Code must be readable at a glance. + +## When to Run + +- After completing any implementation work +- After fixing bugs +- After refactoring +- Before committing + +## Checklist + +For every file you changed, verify: + +### 1. No Verbose Defensive Checks + +Extract repeated patterns into typed helpers. + +```tsx +// ❌ Verbose and repeated +const perms = typeof role.permissions === 'string' + ? JSON.parse(role.permissions) : role.permissions; +if (perms && typeof perms === 'object' && Array.isArray(perms.portal) && perms.portal.length > 0) { + +// ✅ Typed helper +const perms = parseRolePermissions(role.permissions); +if (perms?.portal?.length) { +``` + +### 2. Consistent Idioms Across Files + +The same check must use the same pattern everywhere. + +```tsx +// ❌ Inconsistent +file1: perms?.portal?.length > 0 +file2: perms?.portal?.length + +// ✅ Pick one +perms?.portal?.length +``` + +### 3. No Redundant Type Casts + +If you need a cast to satisfy TypeScript, extract a helper function instead. + +```tsx +// ❌ Verbose cast repeated in every file +const restrictedRoles: readonly string[] = RESTRICTED_ROLES; +restrictedRoles.includes(role); + +// ✅ Helper in shared package +export function isRestrictedRole(role: string): boolean { + return (RESTRICTED_ROLES as readonly string[]).includes(role); +} +``` + +### 4. Error Handling on Boundaries + +`JSON.parse`, external API calls, and DB queries at system boundaries need error handling. + +```tsx +// ❌ Unguarded parse +const parsed = JSON.parse(value); + +// ✅ Safe parse +try { + return JSON.parse(value); +} catch { + return null; +} +``` + +### 5. Shared Patterns Belong in Shared Packages + +If the same logic appears in 2+ apps (api, app, portal), extract it to a shared package (`packages/auth`, `packages/db`, etc.). + +### 6. No Dead Code + +- Remove unused imports +- Remove unused variables +- Remove unused function parameters +- Remove props that are always null/false + +### 7. Readable at a Glance + +- Function and variable names should convey intent without reading the implementation +- One-liner expressions over multi-line when equally clear +- No nested ternaries + +## How to Run + +1. List all files you modified: `git diff --name-only` +2. Read each file and check against this checklist +3. Fix any issues found +4. Typecheck after fixes: `npx tsc --noEmit` diff --git a/apps/api/src/people/people-invite.service.ts b/apps/api/src/people/people-invite.service.ts index 22a9df5eac..6cd24fd5c7 100644 --- a/apps/api/src/people/people-invite.service.ts +++ b/apps/api/src/people/people-invite.service.ts @@ -2,7 +2,6 @@ import { Injectable, Logger, BadRequestException, - ForbiddenException, } from '@nestjs/common'; import { db } from '@db'; import { triggerEmail } from '../email/trigger-email'; @@ -10,7 +9,10 @@ import { InviteEmail } from '../email/templates/invite-member'; import { InvitePortalEmail } from '@trycompai/email'; import { BUILT_IN_ROLE_OBLIGATIONS, - RESTRICTED_ROLES, + BUILT_IN_ROLE_PERMISSIONS, + isRestrictedRole, + parseRoleObligations, + parseRolePermissions, } from '@trycompai/auth'; import type { InviteItemDto } from './dto/invite-people.dto'; import { checkAutoCompletePhases } from '../frameworks/frameworks-timeline.helper'; @@ -37,38 +39,26 @@ export class PeopleInviteService { }): Promise { const { organizationId, invites, callerUserId, callerRole } = params; - const isAdmin = - callerRole.includes('admin') || callerRole.includes('owner'); - const isAuditor = callerRole.includes('auditor'); - - if (!isAdmin && !isAuditor) { - throw new ForbiddenException( - "You don't have permission to invite members.", - ); - } + const callerMemberActions = await this.resolveCallerMemberActions( + callerRole, + organizationId, + ); const results: InviteResult[] = []; for (const invite of invites) { try { - // Auditors can only invite auditors - if (isAuditor && !isAdmin) { - const onlyAuditor = - invite.roles.length === 1 && invite.roles[0] === 'auditor'; - if (!onlyAuditor) { - results.push({ - email: invite.email, - success: false, - error: "Auditors can only invite users with the 'auditor' role.", - }); - continue; - } + const roleError = this.validateAssignableRoles( + invite.roles, + callerMemberActions, + ); + if (roleError) { + results.push({ email: invite.email, success: false, error: roleError }); + continue; } const email = invite.email.toLowerCase(); - const restrictedRoles: readonly string[] = RESTRICTED_ROLES; - const isStrictlyEmployee = - invite.roles.every((role) => restrictedRoles.includes(role)); + const isStrictlyEmployee = invite.roles.every(isRestrictedRole); const hasCompliance = await this.rolesHaveComplianceObligation( invite.roles, @@ -436,13 +426,64 @@ export class PeopleInviteService { select: { obligations: true }, }); - return customRoles.some((role) => { - const obligations = - typeof role.obligations === 'string' - ? JSON.parse(role.obligations) - : role.obligations || {}; - return !!obligations.compliance; - }); + return customRoles.some((role) => + parseRoleObligations(role.obligations).compliance, + ); + } + + /** + * Write-level = all CRUD actions. Callers with Write can assign any role. + * Partial access (e.g. auditor with create+read) can only assign + * restricted roles (employee/contractor) and custom roles. + */ + private validateAssignableRoles( + targetRoles: string[], + callerMemberActions: Set, + ): string | null { + const hasWriteAccess = ['create', 'read', 'update', 'delete'].every((a) => + callerMemberActions.has(a), + ); + if (hasWriteAccess) return null; + + const disallowed = targetRoles.filter( + (r) => !isRestrictedRole(r) && Object.hasOwn(BUILT_IN_ROLE_PERMISSIONS, r), + ); + if (disallowed.length > 0) { + return `You cannot assign privileged roles: ${disallowed.join(', ')}.`; + } + return null; + } + + private async resolveCallerMemberActions( + callerRole: string, + organizationId: string, + ): Promise> { + const roles = callerRole.split(',').map((r) => r.trim()); + const actions = new Set(); + const customRoleNames: string[] = []; + + for (const role of roles) { + const builtIn = BUILT_IN_ROLE_PERMISSIONS[role]; + if (builtIn?.member) { + for (const a of builtIn.member) actions.add(a); + } + if (!builtIn) customRoleNames.push(role); + } + + if (customRoleNames.length > 0) { + const customRoles = await db.organizationRole.findMany({ + where: { organizationId, name: { in: customRoleNames } }, + select: { permissions: true }, + }); + for (const role of customRoles) { + const perms = parseRolePermissions(role.permissions); + if (perms?.member) { + for (const a of perms.member) actions.add(a); + } + } + } + + return actions; } private buildPortalUrl(organizationId: string): string { diff --git a/apps/app/src/app/(app)/[orgId]/people/components/PeoplePageTabs.tsx b/apps/app/src/app/(app)/[orgId]/people/components/PeoplePageTabs.tsx index 82523307d8..95584cb613 100644 --- a/apps/app/src/app/(app)/[orgId]/people/components/PeoplePageTabs.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/components/PeoplePageTabs.tsx @@ -10,6 +10,7 @@ import { TabsTrigger, } from '@trycompai/design-system'; import { Add } from '@trycompai/design-system/icons'; +import type { Role } from '@db'; import { usePathname, useRouter, useSearchParams } from 'next/navigation'; import type { ReactNode } from 'react'; import { useCallback, useState } from 'react'; @@ -28,6 +29,7 @@ interface PeoplePageTabsProps { showSettings: boolean; canInviteUsers: boolean; canManageMembers: boolean; + allowedBuiltInRoles: Role[]; organizationId: string; } @@ -89,6 +91,7 @@ export function PeoplePageTabs({ showSettings, canInviteUsers, canManageMembers, + allowedBuiltInRoles, organizationId, }: PeoplePageTabsProps) { const pathname = usePathname(); @@ -164,9 +167,7 @@ export function PeoplePageTabs({ open={isInviteModalOpen} onOpenChange={setIsInviteModalOpen} organizationId={organizationId} - allowedBuiltInRoles={ - canManageMembers ? ['admin', 'auditor', 'employee', 'contractor'] : ['auditor'] - } + allowedBuiltInRoles={allowedBuiltInRoles} /> ); diff --git a/apps/app/src/app/(app)/[orgId]/people/page.tsx b/apps/app/src/app/(app)/[orgId]/people/page.tsx index 0e9dd0da77..01d7c8e918 100644 --- a/apps/app/src/app/(app)/[orgId]/people/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/page.tsx @@ -3,6 +3,7 @@ import { resolveUserPermissions } from '@/lib/permissions.server'; import { auth } from '@/utils/auth'; import { db } from '@db/server'; import type { Metadata } from 'next'; +import type { Role } from '@db'; import { headers } from 'next/headers'; import { redirect } from 'next/navigation'; import { Suspense } from 'react'; @@ -32,15 +33,23 @@ export default async function PeoplePage({ params }: { params: Promise<{ orgId: where: { organizationId: orgId, userId: session.user.id }, }); const currentUserRoles = currentUserMember?.role?.split(',').map((r) => r.trim()) ?? []; - const canManageMembers = currentUserRoles.some((role) => ['owner', 'admin'].includes(role)); - const isAuditor = currentUserRoles.includes('auditor'); - const canInviteUsers = canManageMembers || isAuditor; const isCurrentUserOwner = currentUserRoles.includes('owner'); const userPermissions = await resolveUserPermissions( currentUserMember?.role ?? null, orgId, ); + const canManageMembers = hasPermission(userPermissions, 'member', 'update'); + const canInviteUsers = hasPermission(userPermissions, 'member', 'create'); + + const hasWriteMemberAccess = + canInviteUsers && + hasPermission(userPermissions, 'member', 'read') && + canManageMembers && + hasPermission(userPermissions, 'member', 'delete'); + const allowedBuiltInRoles: Role[] = hasWriteMemberAccess + ? ['admin', 'auditor', 'employee', 'contractor'] + : ['employee', 'contractor']; const canManageOrgSettings = hasPermission( userPermissions, 'organization', @@ -85,6 +94,7 @@ export default async function PeoplePage({ params }: { params: Promise<{ orgId: showEmployeeTasks canInviteUsers={canInviteUsers} canManageMembers={canManageMembers} + allowedBuiltInRoles={allowedBuiltInRoles} organizationId={orgId} /> ); diff --git a/apps/app/src/lib/permissions.ts b/apps/app/src/lib/permissions.ts index 60c442c1c3..761764fe41 100644 --- a/apps/app/src/lib/permissions.ts +++ b/apps/app/src/lib/permissions.ts @@ -153,17 +153,6 @@ export function getDefaultRoute(permissions: UserPermissions, orgId: string): st return null; } -/** - * Resources that imply the user should have access to the main app. - * Portal-only resources (policy, compliance) are excluded — employees/contractors - * have those but should NOT enter the app. - */ -const APP_IMPLYING_RESOURCES = new Set([ - 'organization', 'member', 'control', 'evidence', 'risk', 'vendor', - 'task', 'framework', 'audit', 'finding', 'questionnaire', 'integration', - 'apiKey', 'trust', 'pentest', -]); - /** Compliance route segments — used to determine if the Compliance rail icon should show. */ const COMPLIANCE_ROUTE_SEGMENTS = [ 'overview', 'frameworks', 'controls', 'policies', 'tasks', 'documents', 'people', @@ -181,22 +170,11 @@ export function canAccessCompliance(permissions: UserPermissions): boolean { /** * Check if user can access the main app (as opposed to portal-only). * - * Returns true if the user has explicit `app:read` (built-in roles like owner/admin/auditor), - * OR if they have any permission on a resource that implies app access (e.g. a custom role - * with only `pentest:read`). - * - * Employees and contractors are portal-only — they only have `policy:read` - * and compliance obligations, neither of which is in APP_IMPLYING_RESOURCES. + * Requires explicit `app:read` — controlled by the "App Access" toggle + * on custom roles, and included by default in owner/admin/auditor. */ export function canAccessApp(permissions: UserPermissions): boolean { - if (hasPermission(permissions, 'app', 'read')) return true; - - for (const resource of Object.keys(permissions)) { - if (APP_IMPLYING_RESOURCES.has(resource) && permissions[resource]?.length > 0) { - return true; - } - } - return false; + return hasPermission(permissions, 'app', 'read'); } /** diff --git a/apps/portal/src/app/(app)/(home)/[orgId]/page.tsx b/apps/portal/src/app/(app)/(home)/[orgId]/page.tsx index 967d7cdd41..f3aefcada3 100644 --- a/apps/portal/src/app/(app)/(home)/[orgId]/page.tsx +++ b/apps/portal/src/app/(app)/(home)/[orgId]/page.tsx @@ -7,6 +7,7 @@ import { db } from '@db/server'; import { PageHeader, PageLayout } from '@trycompai/design-system'; import { headers } from 'next/headers'; import { redirect } from 'next/navigation'; +import { hasPortalAccess } from '@/utils/portal-access'; import { OrganizationDashboard } from './components/OrganizationDashboard'; import type { FleetPolicy, Host } from './types'; @@ -49,11 +50,18 @@ export default async function OrganizationPage({ params }: { params: Promise<{ o redirect('/'); } - // Member check - redirect happens outside try-catch if (!member) { redirect('/'); } + const canAccessPortal = await hasPortalAccess({ + roleString: member.role, + organizationId: orgId, + }); + if (!canAccessPortal) { + redirect('/'); + } + // Fleet policies - only fetch if member has a fleet device label const fleetData = await getFleetPolicies(member); diff --git a/apps/portal/src/utils/portal-access.ts b/apps/portal/src/utils/portal-access.ts new file mode 100644 index 0000000000..76aff2d8ae --- /dev/null +++ b/apps/portal/src/utils/portal-access.ts @@ -0,0 +1,45 @@ +import { + BUILT_IN_ROLE_OBLIGATIONS, + BUILT_IN_ROLE_PERMISSIONS, + parseRoleObligations, + parseRolePermissions, +} from '@trycompai/auth'; +import { db } from '@db/server'; + +export async function hasPortalAccess({ + roleString, + organizationId, +}: { + roleString: string; + organizationId: string; +}): Promise { + const roles = roleString.split(',').map((r) => r.trim()); + const builtInNames = new Set(Object.keys(BUILT_IN_ROLE_PERMISSIONS)); + const customRoleNames: string[] = []; + + for (const role of roles) { + if (builtInNames.has(role)) { + if (BUILT_IN_ROLE_PERMISSIONS[role]?.portal?.length) return true; + if (BUILT_IN_ROLE_OBLIGATIONS[role]?.compliance) return true; + } else { + customRoleNames.push(role); + } + } + + if (customRoleNames.length === 0) return false; + + const customRoles = await db.organizationRole.findMany({ + where: { organizationId, name: { in: customRoleNames } }, + select: { permissions: true, obligations: true }, + }); + + for (const role of customRoles) { + const perms = parseRolePermissions(role.permissions); + if (perms?.portal?.length) return true; + + const obligations = parseRoleObligations(role.obligations); + if (obligations.compliance) return true; + } + + return false; +} diff --git a/packages/auth/src/index.ts b/packages/auth/src/index.ts index cda7a86a63..489deab2ae 100644 --- a/packages/auth/src/index.ts +++ b/packages/auth/src/index.ts @@ -9,11 +9,15 @@ export { allRoles, ROLE_HIERARCHY, RESTRICTED_ROLES, + isRestrictedRole, PRIVILEGED_ROLES, BUILT_IN_ROLE_PERMISSIONS, BUILT_IN_ROLE_OBLIGATIONS, type RoleName, type RoleObligations, + type RolePermissions, + parseRolePermissions, + parseRoleObligations, } from './permissions'; export { createAuthServer, type CreateAuthServerOptions, type AuthServer } from './server'; diff --git a/packages/auth/src/permissions.ts b/packages/auth/src/permissions.ts index 05205260fb..7651292538 100644 --- a/packages/auth/src/permissions.ts +++ b/packages/auth/src/permissions.ts @@ -208,9 +208,10 @@ export const ROLE_HIERARCHY = [ */ export const RESTRICTED_ROLES = ['employee', 'contractor'] as const; -/** - * Roles that have full access without assignment filtering - */ +export function isRestrictedRole(role: string): boolean { + return (RESTRICTED_ROLES as readonly string[]).includes(role); +} + export const PRIVILEGED_ROLES = ['owner', 'admin', 'auditor'] as const; /** @@ -257,3 +258,29 @@ export const BUILT_IN_ROLE_OBLIGATIONS: Record = { employee: { compliance: true }, contractor: { compliance: true }, }; + +// ─── JSON field parsers ───────────────────────────────────────────── +// OrganizationRole stores permissions/obligations as JSON text in the DB. + +export type RolePermissions = Record; + +function parseJsonField(value: unknown): T | null { + try { + if (!value) return null; + const parsed = typeof value === 'string' ? JSON.parse(value) : value; + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + return parsed as T; + } + return null; + } catch { + return null; + } +} + +export function parseRolePermissions(value: unknown): RolePermissions | null { + return parseJsonField(value); +} + +export function parseRoleObligations(value: unknown): RoleObligations { + return parseJsonField(value) ?? {}; +}