Skip to content

Commit a81af3e

Browse files
Lewis CarhartLewis Carhart
authored andcommitted
feat(frameworks): add custom framework and requirement management endpoints
- Implemented new API endpoints for creating custom frameworks and requirements in the FrameworksController. - Added DTOs for CreateCustomFrameworkDto, CreateCustomRequirementDto, LinkRequirementsDto, and LinkControlsDto. - Enhanced findAvailable method to filter frameworks by organization ID. - Introduced client components for adding and linking requirements, including AddCustomRequirementSheet and LinkRequirementSheet. - Updated FrameworkOverview to include new actions for managing custom requirements. - Added UI components for creating custom frameworks and linking existing controls. This update enhances the framework management capabilities, allowing users to create and manage custom frameworks and their associated requirements effectively.
1 parent 3a2825d commit a81af3e

25 files changed

Lines changed: 1450 additions & 87 deletions

File tree

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
2+
import { ApiProperty } from '@nestjs/swagger';
3+
4+
export class CreateCustomFrameworkDto {
5+
@ApiProperty({ description: 'Framework name', example: 'Internal Controls' })
6+
@IsString()
7+
@MinLength(1)
8+
@MaxLength(120)
9+
name: string;
10+
11+
@ApiProperty({ description: 'Framework description' })
12+
@IsString()
13+
@MaxLength(2000)
14+
description: string;
15+
16+
@ApiProperty({ description: 'Version', required: false, example: '1.0' })
17+
@IsOptional()
18+
@IsString()
19+
@MaxLength(40)
20+
version?: string;
21+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { IsString, MaxLength, MinLength } from 'class-validator';
2+
import { ApiProperty } from '@nestjs/swagger';
3+
4+
export class CreateCustomRequirementDto {
5+
@ApiProperty({ description: 'Requirement name', example: 'Access Review' })
6+
@IsString()
7+
@MinLength(1)
8+
@MaxLength(200)
9+
name: string;
10+
11+
@ApiProperty({ description: 'Identifier', example: '10.3' })
12+
@IsString()
13+
@MaxLength(80)
14+
identifier: string;
15+
16+
@ApiProperty({ description: 'Description' })
17+
@IsString()
18+
@MaxLength(4000)
19+
description: string;
20+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { ArrayMinSize, IsArray, IsString } from 'class-validator';
2+
import { ApiProperty } from '@nestjs/swagger';
3+
4+
export class LinkControlsDto {
5+
@ApiProperty({
6+
description:
7+
'Existing org Control IDs to map to the requirement on this framework instance',
8+
type: [String],
9+
})
10+
@IsArray()
11+
@ArrayMinSize(1)
12+
@IsString({ each: true })
13+
controlIds: string[];
14+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { ArrayMinSize, IsArray, IsString } from 'class-validator';
2+
import { ApiProperty } from '@nestjs/swagger';
3+
4+
export class LinkRequirementsDto {
5+
@ApiProperty({
6+
description:
7+
'IDs of existing FrameworkEditorRequirement rows to clone into this framework',
8+
type: [String],
9+
})
10+
@IsArray()
11+
@ArrayMinSize(1)
12+
@IsString({ each: true })
13+
requirementIds: string[];
14+
}

apps/api/src/frameworks/frameworks.controller.ts

Lines changed: 64 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@ import { AuthContext, OrganizationId } from '../auth/auth-context.decorator';
2222
import type { AuthContext as AuthContextType } from '../auth/types';
2323
import { FrameworksService } from './frameworks.service';
2424
import { AddFrameworksDto } from './dto/add-frameworks.dto';
25+
import { CreateCustomFrameworkDto } from './dto/create-custom-framework.dto';
26+
import { CreateCustomRequirementDto } from './dto/create-custom-requirement.dto';
27+
import { LinkRequirementsDto } from './dto/link-requirements.dto';
28+
import { LinkControlsDto } from './dto/link-controls.dto';
2529

2630
@ApiTags('Frameworks')
2731
@ApiBearerAuth()
@@ -53,8 +57,8 @@ export class FrameworksController {
5357
summary:
5458
'List available frameworks (requires session, no active org needed — used during onboarding)',
5559
})
56-
async findAvailable() {
57-
const data = await this.frameworksService.findAvailable();
60+
async findAvailable(@OrganizationId() organizationId?: string) {
61+
const data = await this.frameworksService.findAvailable(organizationId);
5862
return { data, count: data.length };
5963
}
6064

@@ -106,6 +110,64 @@ export class FrameworksController {
106110
);
107111
}
108112

113+
@Post('custom')
114+
@RequirePermission('framework', 'create')
115+
@ApiOperation({ summary: 'Create a custom framework for this organization' })
116+
async createCustom(
117+
@OrganizationId() organizationId: string,
118+
@Body() dto: CreateCustomFrameworkDto,
119+
) {
120+
return this.frameworksService.createCustom(organizationId, dto);
121+
}
122+
123+
@Post(':id/requirements')
124+
@RequirePermission('framework', 'update')
125+
@ApiOperation({ summary: 'Add a custom requirement to a framework instance' })
126+
async createRequirement(
127+
@OrganizationId() organizationId: string,
128+
@Param('id') id: string,
129+
@Body() dto: CreateCustomRequirementDto,
130+
) {
131+
return this.frameworksService.createRequirement(id, organizationId, dto);
132+
}
133+
134+
@Post(':id/requirements/link')
135+
@RequirePermission('framework', 'update')
136+
@ApiOperation({
137+
summary:
138+
'Link (clone) existing requirements from another framework into this one',
139+
})
140+
async linkRequirements(
141+
@OrganizationId() organizationId: string,
142+
@Param('id') id: string,
143+
@Body() dto: LinkRequirementsDto,
144+
) {
145+
return this.frameworksService.linkRequirements(
146+
id,
147+
organizationId,
148+
dto.requirementIds,
149+
);
150+
}
151+
152+
@Post(':id/requirements/:requirementKey/controls/link')
153+
@RequirePermission('framework', 'update')
154+
@ApiOperation({
155+
summary: 'Link existing org controls to a requirement',
156+
})
157+
async linkControls(
158+
@OrganizationId() organizationId: string,
159+
@Param('id') id: string,
160+
@Param('requirementKey') requirementKey: string,
161+
@Body() dto: LinkControlsDto,
162+
) {
163+
return this.frameworksService.linkControlsToRequirement(
164+
id,
165+
requirementKey,
166+
organizationId,
167+
dto.controlIds,
168+
);
169+
}
170+
109171
@Delete(':id')
110172
@RequirePermission('framework', 'delete')
111173
@ApiOperation({ summary: 'Delete a framework instance' })

apps/api/src/frameworks/frameworks.service.ts

Lines changed: 139 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -169,14 +169,151 @@ export class FrameworksService {
169169
};
170170
}
171171

172-
async findAvailable() {
172+
async findAvailable(organizationId?: string) {
173173
const frameworks = await db.frameworkEditorFramework.findMany({
174-
where: { visible: true },
174+
where: {
175+
OR: [
176+
{ visible: true, organizationId: null },
177+
...(organizationId ? [{ organizationId }] : []),
178+
],
179+
},
175180
include: { requirements: true },
176181
});
177182
return frameworks;
178183
}
179184

185+
async createCustom(
186+
organizationId: string,
187+
input: { name: string; description: string; version?: string },
188+
) {
189+
return db.$transaction(async (tx) => {
190+
const framework = await tx.frameworkEditorFramework.create({
191+
data: {
192+
name: input.name,
193+
description: input.description,
194+
version: input.version ?? '1.0',
195+
visible: false,
196+
organizationId,
197+
},
198+
});
199+
200+
const instance = await tx.frameworkInstance.create({
201+
data: {
202+
organizationId,
203+
frameworkId: framework.id,
204+
},
205+
include: { framework: true },
206+
});
207+
208+
return instance;
209+
});
210+
}
211+
212+
async createRequirement(
213+
frameworkInstanceId: string,
214+
organizationId: string,
215+
input: { name: string; identifier: string; description: string },
216+
) {
217+
const fi = await db.frameworkInstance.findUnique({
218+
where: { id: frameworkInstanceId, organizationId },
219+
select: { frameworkId: true },
220+
});
221+
if (!fi) {
222+
throw new NotFoundException('Framework instance not found');
223+
}
224+
225+
return db.frameworkEditorRequirement.create({
226+
data: {
227+
name: input.name,
228+
identifier: input.identifier,
229+
description: input.description,
230+
frameworkId: fi.frameworkId,
231+
organizationId,
232+
},
233+
});
234+
}
235+
236+
async linkRequirements(
237+
frameworkInstanceId: string,
238+
organizationId: string,
239+
requirementIds: string[],
240+
) {
241+
const fi = await db.frameworkInstance.findUnique({
242+
where: { id: frameworkInstanceId, organizationId },
243+
select: { frameworkId: true },
244+
});
245+
if (!fi) {
246+
throw new NotFoundException('Framework instance not found');
247+
}
248+
249+
const sources = await db.frameworkEditorRequirement.findMany({
250+
where: {
251+
id: { in: requirementIds },
252+
OR: [{ organizationId: null }, { organizationId }],
253+
},
254+
});
255+
256+
if (sources.length === 0) {
257+
throw new BadRequestException('No valid requirements to link');
258+
}
259+
260+
const created = await db.frameworkEditorRequirement.createManyAndReturn({
261+
data: sources.map((r) => ({
262+
name: r.name,
263+
identifier: r.identifier,
264+
description: r.description,
265+
frameworkId: fi.frameworkId,
266+
organizationId,
267+
})),
268+
});
269+
270+
return { count: created.length, requirements: created };
271+
}
272+
273+
async linkControlsToRequirement(
274+
frameworkInstanceId: string,
275+
requirementKey: string,
276+
organizationId: string,
277+
controlIds: string[],
278+
) {
279+
const fi = await db.frameworkInstance.findUnique({
280+
where: { id: frameworkInstanceId, organizationId },
281+
select: { id: true, frameworkId: true },
282+
});
283+
if (!fi) {
284+
throw new NotFoundException('Framework instance not found');
285+
}
286+
287+
const requirement = await db.frameworkEditorRequirement.findFirst({
288+
where: {
289+
id: requirementKey,
290+
frameworkId: fi.frameworkId,
291+
},
292+
});
293+
if (!requirement) {
294+
throw new NotFoundException('Requirement not found');
295+
}
296+
297+
const controls = await db.control.findMany({
298+
where: { id: { in: controlIds }, organizationId },
299+
select: { id: true },
300+
});
301+
if (controls.length === 0) {
302+
throw new BadRequestException('No valid controls to link');
303+
}
304+
305+
await db.requirementMap.createMany({
306+
data: controls.map((c) => ({
307+
controlId: c.id,
308+
requirementId: requirementKey,
309+
frameworkInstanceId,
310+
})),
311+
skipDuplicates: true,
312+
});
313+
314+
return { count: controls.length };
315+
}
316+
180317
async getScores(organizationId: string, userId?: string) {
181318
const [scores, currentMember] = await Promise.all([
182319
getOverviewScores(organizationId),

apps/api/src/trigger/policies/update-policy.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ export const updatePolicy = schemaTask({
2525
version: z.string(),
2626
description: z.string(),
2727
visible: z.boolean(),
28+
organizationId: z.string().nullable().default(null),
2829
createdAt: z.date(),
2930
updatedAt: z.date(),
3031
}),

0 commit comments

Comments
 (0)