Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions apps/api/src/framework-editor/framework/dto/link-control.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { ArrayNotEmpty, IsArray, IsOptional, IsString } from 'class-validator';

export class LinkControlDto {
@ApiPropertyOptional({
description:
'Requirement ids (of this framework) to link the control to. ' +
'Omit to link the control to every requirement in the framework ' +
'(legacy bulk behavior used by the CLI).',
type: [String],
example: ['frk_rq_abc123'],
})
@IsOptional()
@IsArray()
@ArrayNotEmpty()
@IsString({ each: true })
requirementIds?: string[];
}
14 changes: 11 additions & 3 deletions apps/api/src/framework-editor/framework/framework.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,11 @@ import {
UsePipes,
ValidationPipe,
} from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { ApiBody, ApiOperation, ApiTags } from '@nestjs/swagger';
import { PlatformAdminGuard } from '../../auth/platform-admin.guard';
import { CreateFrameworkDto } from './dto/create-framework.dto';
import { ImportFrameworkDto } from './dto/import-framework.dto';
import { LinkControlDto } from './dto/link-control.dto';
import { UpdateFrameworkDto } from './dto/update-framework.dto';
import { FrameworkExportService } from './framework-export.service';
import { FrameworkEditorFrameworkService } from './framework.service';
Expand Down Expand Up @@ -100,12 +101,19 @@ export class FrameworkEditorFrameworkController {
}

@Post(':id/link-control/:controlId')
@ApiOperation({ summary: 'Link a control to a framework' })
@ApiOperation({
summary: 'Link a control to a framework',
description:
'Links a control to the framework. Pass requirementIds to link only ' +
'the selected requirements; omit it to link every requirement (legacy).',
})
@ApiBody({ type: LinkControlDto, required: false })
async linkControl(
@Param('id') id: string,
@Param('controlId') controlId: string,
@Body() body: LinkControlDto = {},
) {
return this.frameworkService.linkControl(id, controlId);
return this.frameworkService.linkControl(id, controlId, body.requirementIds);
}

@Post(':id/link-task/:taskId')
Expand Down
89 changes: 89 additions & 0 deletions apps/api/src/framework-editor/framework/framework.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
jest.mock('@db', () => {
const dbMock = {
frameworkEditorFramework: {
findUnique: jest.fn(),
},
frameworkEditorRequirement: {
findMany: jest.fn(),
},
frameworkEditorControlTemplate: {
update: jest.fn(),
},
};
return { db: dbMock, Prisma: { PrismaClientKnownRequestError: class {} } };
});

import { BadRequestException, ConflictException } from '@nestjs/common';
import { db } from '@db';
import { FrameworkEditorFrameworkService } from './framework.service';

const mockDb = db as jest.Mocked<typeof db>;

describe('FrameworkEditorFrameworkService.linkControl', () => {
let service: FrameworkEditorFrameworkService;

beforeEach(() => {
service = new FrameworkEditorFrameworkService();
jest.clearAllMocks();
(mockDb.frameworkEditorFramework.findUnique as jest.Mock).mockResolvedValue({
id: 'frk_1',
requirements: [],
});
(mockDb.frameworkEditorRequirement.findMany as jest.Mock).mockResolvedValue([
{ id: 'req_1' },
{ id: 'req_2' },
{ id: 'req_3' },
]);
(mockDb.frameworkEditorControlTemplate.update as jest.Mock).mockResolvedValue({
id: 'ct_1',
});
});

it('links only the selected requirements when requirementIds is provided', async () => {
await service.linkControl('frk_1', 'ct_1', ['req_2']);

expect(mockDb.frameworkEditorControlTemplate.update).toHaveBeenCalledWith({
where: { id: 'ct_1' },
data: { requirements: { connect: [{ id: 'req_2' }] } },
});
});

it('links every framework requirement when requirementIds is omitted (legacy CLI path)', async () => {
await service.linkControl('frk_1', 'ct_1');

expect(mockDb.frameworkEditorControlTemplate.update).toHaveBeenCalledWith({
where: { id: 'ct_1' },
data: {
requirements: { connect: [{ id: 'req_1' }, { id: 'req_2' }, { id: 'req_3' }] },
},
});
});

it('treats a null requirementIds (JSON null past @IsOptional) as link-all, not a crash', async () => {
await expect(service.linkControl('frk_1', 'ct_1', null)).resolves.toEqual({
message: 'Control linked to framework',
});
expect(mockDb.frameworkEditorControlTemplate.update).toHaveBeenCalledWith({
where: { id: 'ct_1' },
data: {
requirements: { connect: [{ id: 'req_1' }, { id: 'req_2' }, { id: 'req_3' }] },
},
});
});

it('rejects requirement ids that do not belong to the framework', async () => {
await expect(
service.linkControl('frk_1', 'ct_1', ['req_2', 'req_outsider']),
).rejects.toBeInstanceOf(BadRequestException);
expect(mockDb.frameworkEditorControlTemplate.update).not.toHaveBeenCalled();
});

it('throws when the framework has no requirements at all', async () => {
(mockDb.frameworkEditorRequirement.findMany as jest.Mock).mockResolvedValue([]);

await expect(service.linkControl('frk_1', 'ct_1')).rejects.toBeInstanceOf(
ConflictException,
);
expect(mockDb.frameworkEditorControlTemplate.update).not.toHaveBeenCalled();
});
});
45 changes: 38 additions & 7 deletions apps/api/src/framework-editor/framework/framework.service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
BadRequestException,
Injectable,
NotFoundException,
ConflictException,
Expand Down Expand Up @@ -231,25 +232,55 @@ export class FrameworkEditorFrameworkService {
}));
}

async linkControl(frameworkId: string, controlId: string) {
async linkControl(
frameworkId: string,
controlId: string,
requirementIds?: string[] | null,
) {
await this.findById(frameworkId);

const requirementIds = await db.frameworkEditorRequirement
.findMany({ where: { frameworkId }, select: { id: true } })
.then((reqs) => reqs.map((r) => ({ id: r.id })));
const frameworkRequirements = await db.frameworkEditorRequirement.findMany({
where: { frameworkId },
select: { id: true },
});

if (requirementIds.length === 0) {
if (frameworkRequirements.length === 0) {
throw new ConflictException(
'Framework has no requirements to link the control to',
);
}

// A control belongs to a framework only through its requirement links. When
// the caller passes requirementIds, link just those — this is the UI path,
// so adding an existing control no longer fans out to every requirement.
// When omitted (undefined/null), link all: the documented legacy bulk
// behavior the CLI uses. (@IsOptional lets a JSON null through, so guard it.)
let targetIds: { id: string }[];
if (requirementIds === undefined || requirementIds === null) {
targetIds = frameworkRequirements.map((r) => ({ id: r.id }));
} else {
const frameworkRequirementIds = new Set(
frameworkRequirements.map((r) => r.id),
);
const invalid = requirementIds.filter(
(id) => !frameworkRequirementIds.has(id),
);
if (invalid.length > 0) {
throw new BadRequestException(
`Requirement(s) not in this framework: ${invalid.join(', ')}`,
);
}
targetIds = requirementIds.map((id) => ({ id }));
}

await db.frameworkEditorControlTemplate.update({
where: { id: controlId },
data: { requirements: { connect: requirementIds } },
data: { requirements: { connect: targetIds } },
});

this.logger.log(`Linked control ${controlId} to framework ${frameworkId}`);
this.logger.log(
`Linked control ${controlId} to framework ${frameworkId} (${targetIds.length} requirement(s))`,
);
return { message: 'Control linked to framework' };
}

Expand Down
20 changes: 20 additions & 0 deletions apps/framework-editor/app/(pages)/controls/ControlsClientPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
AddExistingItemDialog,
type ExistingItemRaw,
} from '../../components/AddExistingItemDialog';
import type { RequirementOption } from '../../components/ControlRequirementSelect';
import { ManageFamiliesDialog } from './ManageFamiliesDialog';
import {
ComboboxCell,
Expand Down Expand Up @@ -74,6 +75,24 @@ async function fetchRequirementsForFramework(
);
}

// Requirement options for the "Add Existing Control" picker, oldest-first so
// the just-created requirement sits at the bottom of the list.
async function fetchFrameworkRequirementOptions(
frameworkId: string,
): Promise<RequirementOption[]> {
const framework = await apiClient<{
requirements: Array<{
id: string;
name: string;
identifier: string | null;
createdAt?: string;
}>;
}>(`/framework/${frameworkId}`);
return [...framework.requirements]
.sort((a, b) => (a.createdAt ?? '').localeCompare(b.createdAt ?? ''))
.map((r) => ({ id: r.id, name: r.name, identifier: r.identifier }));
}

async function fetchAllTaskTemplates(): Promise<RelationalItem[]> {
return apiClient<RelationalItem[]>('/task-template');
}
Expand Down Expand Up @@ -484,6 +503,7 @@ export function ControlsClientPage({ initialControls, emptyMessage, frameworkId
itemType="control"
existingItemIds={existingControlIds}
fetchAllItems={fetchAllControlsForDialog}
fetchRequirements={() => fetchFrameworkRequirementOptions(frameworkId)}
/>
)}

Expand Down
Loading
Loading