Skip to content

Commit 9f1a8cc

Browse files
carhartlewisclaude
andcommitted
refactor(frameworks): split custom frameworks into dedicated per-org tables
Previously the branch added a nullable organizationId column to the platform FrameworkEditorFramework / FrameworkEditorRequirement tables so a single row was either platform (null) or org-custom (set). That hybrid shape mismatched the template/instance pattern used everywhere else in the codebase and caused two cross-tenant reads (frameworks.service.findOne, findRequirement) to leak one org's custom requirements to another org on the same framework. Move the org-custom data into dedicated CustomFramework / CustomRequirement tables. FrameworkInstance.frameworkId and RequirementMap.requirementId become nullable and gain customFrameworkId / customRequirementId siblings with DB CHECK constraints enforcing exactly one of the two is set. The editor tables are pure platform definitions again, so the leaks vanish structurally (no shared table to filter) rather than relying on filter discipline in each read. - Schema: revert organizationId from FrameworkEditor tables; add CustomFramework + CustomRequirement; relax/branch FrameworkInstance and RequirementMap FKs with CHECK constraints - Migration: move existing per-org rows into the new tables, repoint FKs, drop the old columns - API: rewrite FrameworksService findOne / findRequirement / createCustom / createRequirement / linkRequirements / linkControlsToRequirement / findAvailable to branch on platform vs custom. Update ControlsService create + linkRequirements + DTOs to accept customRequirementId (with exactly-one validation) and persist documentTypes in the same transaction - Frontend: plumb isCustom through the requirement/control sheets, widen FrameworkInstanceWithControls / FrameworkInstanceForTasks to surface customFramework, and fix all fw.framework.* null-unsafe reads - Tests: frameworks.service.spec regression coverage that a custom FI never reads from frameworkEditorRequirement and a platform FI never reads from customRequirement Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent f40226a commit 9f1a8cc

40 files changed

Lines changed: 1412 additions & 274 deletions

apps/api/src/admin-organizations/admin-policies.controller.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,11 @@ export class AdminPoliciesController {
134134
});
135135

136136
const uniqueFrameworks = Array.from(
137-
new Map(instances.map((fi) => [fi.framework.id, fi.framework])).values(),
137+
new Map(
138+
instances
139+
.filter((fi) => fi.framework)
140+
.map((fi) => [fi.framework!.id, fi.framework!]),
141+
).values(),
138142
).map((f) => ({
139143
id: f.id,
140144
name: f.name,

apps/api/src/controls/controls.controller.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ import { CreateControlDto } from './dto/create-control.dto';
2323
import { LinkPoliciesDto } from './dto/link-policies.dto';
2424
import { LinkTasksDto } from './dto/link-tasks.dto';
2525
import { LinkRequirementsToControlDto } from './dto/link-requirements.dto';
26+
import { LinkDocumentTypesDto } from './dto/link-document-types.dto';
27+
import { EvidenceFormType } from '@db';
2628

2729
@ApiTags('Controls')
2830
@ApiBearerAuth()
@@ -136,6 +138,36 @@ export class ControlsController {
136138
);
137139
}
138140

141+
@Post(':id/document-types/link')
142+
@RequirePermission('control', 'update')
143+
@ApiOperation({ summary: 'Link required document types to a control' })
144+
async linkDocumentTypes(
145+
@OrganizationId() organizationId: string,
146+
@Param('id') id: string,
147+
@Body() dto: LinkDocumentTypesDto,
148+
) {
149+
return this.controlsService.linkDocumentTypes(
150+
id,
151+
organizationId,
152+
dto.formTypes,
153+
);
154+
}
155+
156+
@Delete(':id/document-types/:formType')
157+
@RequirePermission('control', 'update')
158+
@ApiOperation({ summary: 'Remove a required document type from a control' })
159+
async unlinkDocumentType(
160+
@OrganizationId() organizationId: string,
161+
@Param('id') id: string,
162+
@Param('formType') formType: EvidenceFormType,
163+
) {
164+
return this.controlsService.unlinkDocumentType(
165+
id,
166+
organizationId,
167+
formType,
168+
);
169+
}
170+
139171
@Delete(':id')
140172
@RequirePermission('control', 'delete')
141173
@ApiOperation({ summary: 'Delete a control' })

0 commit comments

Comments
 (0)