Skip to content

Commit 0380ff7

Browse files
committed
feat(controls): add endpoints to link policies, tasks, and requirements to controls
- Implemented new API endpoints in ControlsController for linking existing policies, tasks, and requirements to a control. - Added corresponding DTOs: LinkPoliciesDto, LinkTasksDto, and LinkRequirementsToControlDto. - Enhanced ControlsService with methods to handle linking logic and validation. - Updated UI components to support linking actions, including LinkPolicySheet, LinkTaskSheet, and LinkRequirementForControlSheet. - Improved data fetching with useControlOptions hook for better management of linked items. This update enhances the control management capabilities, allowing users to effectively link existing resources to controls.
1 parent c3a50db commit 0380ff7

19 files changed

Lines changed: 1128 additions & 96 deletions

File tree

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

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ import { RequirePermission } from '../auth/require-permission.decorator';
2020
import { OrganizationId } from '../auth/auth-context.decorator';
2121
import { ControlsService } from './controls.service';
2222
import { CreateControlDto } from './dto/create-control.dto';
23+
import { LinkPoliciesDto } from './dto/link-policies.dto';
24+
import { LinkTasksDto } from './dto/link-tasks.dto';
25+
import { LinkRequirementsToControlDto } from './dto/link-requirements.dto';
2326

2427
@ApiTags('Controls')
2528
@ApiBearerAuth()
@@ -92,6 +95,47 @@ export class ControlsController {
9295
return this.controlsService.create(organizationId, dto);
9396
}
9497

98+
@Post(':id/policies/link')
99+
@RequirePermission('control', 'update')
100+
@ApiOperation({ summary: 'Link existing policies to a control' })
101+
async linkPolicies(
102+
@OrganizationId() organizationId: string,
103+
@Param('id') id: string,
104+
@Body() dto: LinkPoliciesDto,
105+
) {
106+
return this.controlsService.linkPolicies(
107+
id,
108+
organizationId,
109+
dto.policyIds,
110+
);
111+
}
112+
113+
@Post(':id/tasks/link')
114+
@RequirePermission('control', 'update')
115+
@ApiOperation({ summary: 'Link existing tasks to a control' })
116+
async linkTasks(
117+
@OrganizationId() organizationId: string,
118+
@Param('id') id: string,
119+
@Body() dto: LinkTasksDto,
120+
) {
121+
return this.controlsService.linkTasks(id, organizationId, dto.taskIds);
122+
}
123+
124+
@Post(':id/requirements/link')
125+
@RequirePermission('control', 'update')
126+
@ApiOperation({ summary: 'Link existing requirements to a control' })
127+
async linkRequirements(
128+
@OrganizationId() organizationId: string,
129+
@Param('id') id: string,
130+
@Body() dto: LinkRequirementsToControlDto,
131+
) {
132+
return this.controlsService.linkRequirements(
133+
id,
134+
organizationId,
135+
dto.requirements,
136+
);
137+
}
138+
95139
@Delete(':id')
96140
@RequirePermission('control', 'delete')
97141
@ApiOperation({ summary: 'Delete a control' })

apps/api/src/controls/controls.service.ts

Lines changed: 114 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1-
import { Injectable, NotFoundException } from '@nestjs/common';
1+
import {
2+
BadRequestException,
3+
Injectable,
4+
NotFoundException,
5+
} from '@nestjs/common';
26
import { db, Prisma } from '@db';
37
import { CreateControlDto } from './dto/create-control.dto';
48

@@ -193,6 +197,115 @@ export class ControlsService {
193197
return control;
194198
}
195199

200+
private async ensureControl(controlId: string, organizationId: string) {
201+
const control = await db.control.findUnique({
202+
where: { id: controlId, organizationId },
203+
select: { id: true },
204+
});
205+
if (!control) {
206+
throw new NotFoundException('Control not found');
207+
}
208+
return control;
209+
}
210+
211+
async linkPolicies(
212+
controlId: string,
213+
organizationId: string,
214+
policyIds: string[],
215+
) {
216+
await this.ensureControl(controlId, organizationId);
217+
218+
const policies = await db.policy.findMany({
219+
where: { id: { in: policyIds }, organizationId },
220+
select: { id: true },
221+
});
222+
if (policies.length === 0) {
223+
throw new BadRequestException('No valid policies to link');
224+
}
225+
226+
await db.control.update({
227+
where: { id: controlId },
228+
data: { policies: { connect: policies.map((p) => ({ id: p.id })) } },
229+
});
230+
231+
return { count: policies.length };
232+
}
233+
234+
async linkTasks(
235+
controlId: string,
236+
organizationId: string,
237+
taskIds: string[],
238+
) {
239+
await this.ensureControl(controlId, organizationId);
240+
241+
const tasks = await db.task.findMany({
242+
where: { id: { in: taskIds }, organizationId },
243+
select: { id: true },
244+
});
245+
if (tasks.length === 0) {
246+
throw new BadRequestException('No valid tasks to link');
247+
}
248+
249+
await db.control.update({
250+
where: { id: controlId },
251+
data: { tasks: { connect: tasks.map((t) => ({ id: t.id })) } },
252+
});
253+
254+
return { count: tasks.length };
255+
}
256+
257+
async linkRequirements(
258+
controlId: string,
259+
organizationId: string,
260+
mappings: { requirementId: string; frameworkInstanceId: string }[],
261+
) {
262+
await this.ensureControl(controlId, organizationId);
263+
264+
const frameworkInstanceIds = Array.from(
265+
new Set(mappings.map((m) => m.frameworkInstanceId)),
266+
);
267+
const instances = await db.frameworkInstance.findMany({
268+
where: { id: { in: frameworkInstanceIds }, organizationId },
269+
select: { id: true, frameworkId: true },
270+
});
271+
const instanceByfId = new Map(instances.map((i) => [i.id, i.frameworkId]));
272+
273+
const requirementIds = Array.from(
274+
new Set(mappings.map((m) => m.requirementId)),
275+
);
276+
const requirements = await db.frameworkEditorRequirement.findMany({
277+
where: {
278+
id: { in: requirementIds },
279+
OR: [{ organizationId: null }, { organizationId }],
280+
},
281+
select: { id: true, frameworkId: true },
282+
});
283+
const reqFrameworkById = new Map(
284+
requirements.map((r) => [r.id, r.frameworkId]),
285+
);
286+
287+
const validMappings = mappings.filter((m) => {
288+
const instanceFwId = instanceByfId.get(m.frameworkInstanceId);
289+
const reqFwId = reqFrameworkById.get(m.requirementId);
290+
return Boolean(instanceFwId) && instanceFwId === reqFwId;
291+
});
292+
293+
if (validMappings.length === 0) {
294+
throw new BadRequestException('No valid requirements to link');
295+
}
296+
297+
await db.requirementMap.createMany({
298+
data: validMappings.map((m) => ({
299+
controlId,
300+
requirementId: m.requirementId,
301+
frameworkInstanceId: m.frameworkInstanceId,
302+
})),
303+
skipDuplicates: true,
304+
});
305+
306+
return { count: validMappings.length };
307+
}
308+
196309
async delete(controlId: string, organizationId: string) {
197310
const control = await db.control.findUnique({
198311
where: {
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { ArrayMinSize, IsArray, IsString } from 'class-validator';
2+
import { ApiProperty } from '@nestjs/swagger';
3+
4+
export class LinkPoliciesDto {
5+
@ApiProperty({ description: 'Policy IDs to link to the control', type: [String] })
6+
@IsArray()
7+
@ArrayMinSize(1)
8+
@IsString({ each: true })
9+
policyIds: string[];
10+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import {
2+
ArrayMinSize,
3+
IsArray,
4+
IsString,
5+
ValidateNested,
6+
} from 'class-validator';
7+
import { Type } from 'class-transformer';
8+
import { ApiProperty } from '@nestjs/swagger';
9+
10+
export class LinkRequirementMappingDto {
11+
@ApiProperty({ description: 'Requirement ID' })
12+
@IsString()
13+
requirementId: string;
14+
15+
@ApiProperty({ description: 'Framework instance ID' })
16+
@IsString()
17+
frameworkInstanceId: string;
18+
}
19+
20+
export class LinkRequirementsToControlDto {
21+
@ApiProperty({
22+
description: 'Requirement + framework instance pairs to link',
23+
type: [LinkRequirementMappingDto],
24+
})
25+
@IsArray()
26+
@ArrayMinSize(1)
27+
@ValidateNested({ each: true })
28+
@Type(() => LinkRequirementMappingDto)
29+
requirements: LinkRequirementMappingDto[];
30+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { ArrayMinSize, IsArray, IsString } from 'class-validator';
2+
import { ApiProperty } from '@nestjs/swagger';
3+
4+
export class LinkTasksDto {
5+
@ApiProperty({ description: 'Task IDs to link to the control', type: [String] })
6+
@IsArray()
7+
@ArrayMinSize(1)
8+
@IsString({ each: true })
9+
taskIds: string[];
10+
}

apps/app/src/app/(app)/[orgId]/controls/[controlId]/components/PoliciesTable.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,14 @@ export function PoliciesTable({ policies, orgId }: PoliciesTableProps) {
8787
}
8888
}}
8989
>
90-
<TableCell>{policy.name}</TableCell>
90+
<TableCell>
91+
<span
92+
className="block max-w-[420px] truncate text-sm"
93+
title={policy.name}
94+
>
95+
{policy.name}
96+
</span>
97+
</TableCell>
9198
<TableCell>{new Date(policy.createdAt).toLocaleDateString()}</TableCell>
9299
<TableCell>
93100
<StatusIndicator status={policy.status} />

apps/app/src/app/(app)/[orgId]/controls/[controlId]/components/RequirementsTable.tsx

Lines changed: 42 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -74,46 +74,61 @@ export function RequirementsTable({ requirements, orgId }: RequirementsTableProp
7474
<Table variant="bordered">
7575
<TableHeader>
7676
<TableRow>
77+
<TableHead>Identifier</TableHead>
7778
<TableHead>Name</TableHead>
7879
<TableHead>Description</TableHead>
7980
</TableRow>
8081
</TableHeader>
8182
<TableBody>
8283
{filteredRequirements.length === 0 ? (
8384
<TableRow>
84-
<TableCell colSpan={2}>
85+
<TableCell colSpan={3}>
8586
<Text size="sm" variant="muted">
8687
No requirements found.
8788
</Text>
8889
</TableCell>
8990
</TableRow>
9091
) : (
91-
filteredRequirements.map((requirement) => (
92-
<TableRow
93-
key={requirement.id}
94-
role="button"
95-
tabIndex={0}
96-
style={{ cursor: 'pointer' }}
97-
onClick={() => handleRowClick(requirement)}
98-
onKeyDown={(event) => {
99-
if (event.key === 'Enter' || event.key === ' ') {
100-
event.preventDefault();
101-
handleRowClick(requirement);
102-
}
103-
}}
104-
>
105-
<TableCell>
106-
<span className="line-clamp-2 h-10 max-w-[600px] truncate text-wrap">
107-
{requirement.requirement.name}
108-
</span>
109-
</TableCell>
110-
<TableCell>
111-
<span className="line-clamp-2 h-10 max-w-[600px] truncate text-wrap">
112-
{requirement.requirement.description}
113-
</span>
114-
</TableCell>
115-
</TableRow>
116-
))
92+
filteredRequirements.map((requirement) => {
93+
const identifier = requirement.requirement.identifier?.trim();
94+
const name = requirement.requirement.name;
95+
const description = requirement.requirement.description;
96+
return (
97+
<TableRow
98+
key={requirement.id}
99+
role="button"
100+
tabIndex={0}
101+
style={{ cursor: 'pointer' }}
102+
onClick={() => handleRowClick(requirement)}
103+
onKeyDown={(event) => {
104+
if (event.key === 'Enter' || event.key === ' ') {
105+
event.preventDefault();
106+
handleRowClick(requirement);
107+
}
108+
}}
109+
>
110+
<TableCell>
111+
<span className="text-sm">{identifier || '—'}</span>
112+
</TableCell>
113+
<TableCell>
114+
<span
115+
className="block max-w-[280px] truncate text-sm"
116+
title={name}
117+
>
118+
{name}
119+
</span>
120+
</TableCell>
121+
<TableCell>
122+
<span
123+
className="block max-w-[420px] truncate text-sm"
124+
title={description}
125+
>
126+
{description}
127+
</span>
128+
</TableCell>
129+
</TableRow>
130+
);
131+
})
117132
)}
118133
</TableBody>
119134
</Table>

apps/app/src/app/(app)/[orgId]/controls/[controlId]/components/TasksTable.tsx

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,9 +89,21 @@ export function TasksTable({ tasks, orgId }: TasksTableProps) {
8989
}
9090
}}
9191
>
92-
<TableCell>{task.title}</TableCell>
9392
<TableCell>
94-
<span className="line-clamp-1 capitalize">{task.description}</span>
93+
<span
94+
className="block max-w-[280px] truncate text-sm"
95+
title={task.title}
96+
>
97+
{task.title}
98+
</span>
99+
</TableCell>
100+
<TableCell>
101+
<span
102+
className="block max-w-[420px] truncate text-sm"
103+
title={task.description}
104+
>
105+
{task.description}
106+
</span>
95107
</TableCell>
96108
<TableCell>
97109
<StatusIndicator status={task.status} />

0 commit comments

Comments
 (0)