From a81af3efb7e0b69cfbec9c4c2160dee198e6e01c Mon Sep 17 00:00:00 2001 From: Lewis Carhart Date: Fri, 17 Apr 2026 21:16:33 +0100 Subject: [PATCH 01/10] 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. --- .../dto/create-custom-framework.dto.ts | 21 ++ .../dto/create-custom-requirement.dto.ts | 20 ++ .../src/frameworks/dto/link-controls.dto.ts | 14 ++ .../frameworks/dto/link-requirements.dto.ts | 14 ++ .../src/frameworks/frameworks.controller.ts | 66 ++++- apps/api/src/frameworks/frameworks.service.ts | 141 ++++++++++- .../api/src/trigger/policies/update-policy.ts | 1 + .../components/AddCustomRequirementSheet.tsx | 154 ++++++++++++ .../components/FrameworkOverview.tsx | 52 ++-- .../components/FrameworkRequirements.tsx | 33 ++- .../components/LinkRequirementSheet.tsx | 177 +++++++++++++ .../components/AddCustomControlSheet.tsx | 143 +++++++++++ .../components/LinkExistingControlSheet.tsx | 138 +++++++++++ .../requirements/[requirementKey]/page.tsx | 48 +++- .../components/CreateCustomFrameworkSheet.tsx | 153 ++++++++++++ .../components/FrameworksPageActions.tsx | 6 +- .../[orgId]/overview/components/Overview.tsx | 2 +- apps/app/src/hooks/use-frameworks.ts | 15 ++ bun.lock | 52 ++-- package.json | 16 +- .../migration.sql | 20 ++ .../db/prisma/schema/framework-editor.prisma | 13 + packages/db/prisma/schema/organization.prisma | 4 + packages/db/prisma/src/.gitignore | 2 + packages/docs/openapi.json | 232 ++++++++++++++++++ 25 files changed, 1450 insertions(+), 87 deletions(-) create mode 100644 apps/api/src/frameworks/dto/create-custom-framework.dto.ts create mode 100644 apps/api/src/frameworks/dto/create-custom-requirement.dto.ts create mode 100644 apps/api/src/frameworks/dto/link-controls.dto.ts create mode 100644 apps/api/src/frameworks/dto/link-requirements.dto.ts create mode 100644 apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/AddCustomRequirementSheet.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/LinkRequirementSheet.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/requirements/[requirementKey]/components/AddCustomControlSheet.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/requirements/[requirementKey]/components/LinkExistingControlSheet.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/frameworks/components/CreateCustomFrameworkSheet.tsx create mode 100644 packages/db/prisma/migrations/20260417200000_custom_frameworks_requirements/migration.sql create mode 100644 packages/db/prisma/src/.gitignore diff --git a/apps/api/src/frameworks/dto/create-custom-framework.dto.ts b/apps/api/src/frameworks/dto/create-custom-framework.dto.ts new file mode 100644 index 0000000000..a703007927 --- /dev/null +++ b/apps/api/src/frameworks/dto/create-custom-framework.dto.ts @@ -0,0 +1,21 @@ +import { IsOptional, IsString, MaxLength, MinLength } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class CreateCustomFrameworkDto { + @ApiProperty({ description: 'Framework name', example: 'Internal Controls' }) + @IsString() + @MinLength(1) + @MaxLength(120) + name: string; + + @ApiProperty({ description: 'Framework description' }) + @IsString() + @MaxLength(2000) + description: string; + + @ApiProperty({ description: 'Version', required: false, example: '1.0' }) + @IsOptional() + @IsString() + @MaxLength(40) + version?: string; +} diff --git a/apps/api/src/frameworks/dto/create-custom-requirement.dto.ts b/apps/api/src/frameworks/dto/create-custom-requirement.dto.ts new file mode 100644 index 0000000000..5f2993c021 --- /dev/null +++ b/apps/api/src/frameworks/dto/create-custom-requirement.dto.ts @@ -0,0 +1,20 @@ +import { IsString, MaxLength, MinLength } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class CreateCustomRequirementDto { + @ApiProperty({ description: 'Requirement name', example: 'Access Review' }) + @IsString() + @MinLength(1) + @MaxLength(200) + name: string; + + @ApiProperty({ description: 'Identifier', example: '10.3' }) + @IsString() + @MaxLength(80) + identifier: string; + + @ApiProperty({ description: 'Description' }) + @IsString() + @MaxLength(4000) + description: string; +} diff --git a/apps/api/src/frameworks/dto/link-controls.dto.ts b/apps/api/src/frameworks/dto/link-controls.dto.ts new file mode 100644 index 0000000000..0b6a2fa71a --- /dev/null +++ b/apps/api/src/frameworks/dto/link-controls.dto.ts @@ -0,0 +1,14 @@ +import { ArrayMinSize, IsArray, IsString } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class LinkControlsDto { + @ApiProperty({ + description: + 'Existing org Control IDs to map to the requirement on this framework instance', + type: [String], + }) + @IsArray() + @ArrayMinSize(1) + @IsString({ each: true }) + controlIds: string[]; +} diff --git a/apps/api/src/frameworks/dto/link-requirements.dto.ts b/apps/api/src/frameworks/dto/link-requirements.dto.ts new file mode 100644 index 0000000000..d735e9c93e --- /dev/null +++ b/apps/api/src/frameworks/dto/link-requirements.dto.ts @@ -0,0 +1,14 @@ +import { ArrayMinSize, IsArray, IsString } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class LinkRequirementsDto { + @ApiProperty({ + description: + 'IDs of existing FrameworkEditorRequirement rows to clone into this framework', + type: [String], + }) + @IsArray() + @ArrayMinSize(1) + @IsString({ each: true }) + requirementIds: string[]; +} diff --git a/apps/api/src/frameworks/frameworks.controller.ts b/apps/api/src/frameworks/frameworks.controller.ts index b8e5c3002d..cee958679f 100644 --- a/apps/api/src/frameworks/frameworks.controller.ts +++ b/apps/api/src/frameworks/frameworks.controller.ts @@ -22,6 +22,10 @@ import { AuthContext, OrganizationId } from '../auth/auth-context.decorator'; import type { AuthContext as AuthContextType } from '../auth/types'; import { FrameworksService } from './frameworks.service'; import { AddFrameworksDto } from './dto/add-frameworks.dto'; +import { CreateCustomFrameworkDto } from './dto/create-custom-framework.dto'; +import { CreateCustomRequirementDto } from './dto/create-custom-requirement.dto'; +import { LinkRequirementsDto } from './dto/link-requirements.dto'; +import { LinkControlsDto } from './dto/link-controls.dto'; @ApiTags('Frameworks') @ApiBearerAuth() @@ -53,8 +57,8 @@ export class FrameworksController { summary: 'List available frameworks (requires session, no active org needed — used during onboarding)', }) - async findAvailable() { - const data = await this.frameworksService.findAvailable(); + async findAvailable(@OrganizationId() organizationId?: string) { + const data = await this.frameworksService.findAvailable(organizationId); return { data, count: data.length }; } @@ -106,6 +110,64 @@ export class FrameworksController { ); } + @Post('custom') + @RequirePermission('framework', 'create') + @ApiOperation({ summary: 'Create a custom framework for this organization' }) + async createCustom( + @OrganizationId() organizationId: string, + @Body() dto: CreateCustomFrameworkDto, + ) { + return this.frameworksService.createCustom(organizationId, dto); + } + + @Post(':id/requirements') + @RequirePermission('framework', 'update') + @ApiOperation({ summary: 'Add a custom requirement to a framework instance' }) + async createRequirement( + @OrganizationId() organizationId: string, + @Param('id') id: string, + @Body() dto: CreateCustomRequirementDto, + ) { + return this.frameworksService.createRequirement(id, organizationId, dto); + } + + @Post(':id/requirements/link') + @RequirePermission('framework', 'update') + @ApiOperation({ + summary: + 'Link (clone) existing requirements from another framework into this one', + }) + async linkRequirements( + @OrganizationId() organizationId: string, + @Param('id') id: string, + @Body() dto: LinkRequirementsDto, + ) { + return this.frameworksService.linkRequirements( + id, + organizationId, + dto.requirementIds, + ); + } + + @Post(':id/requirements/:requirementKey/controls/link') + @RequirePermission('framework', 'update') + @ApiOperation({ + summary: 'Link existing org controls to a requirement', + }) + async linkControls( + @OrganizationId() organizationId: string, + @Param('id') id: string, + @Param('requirementKey') requirementKey: string, + @Body() dto: LinkControlsDto, + ) { + return this.frameworksService.linkControlsToRequirement( + id, + requirementKey, + organizationId, + dto.controlIds, + ); + } + @Delete(':id') @RequirePermission('framework', 'delete') @ApiOperation({ summary: 'Delete a framework instance' }) diff --git a/apps/api/src/frameworks/frameworks.service.ts b/apps/api/src/frameworks/frameworks.service.ts index 311783ec2c..7711d2dde7 100644 --- a/apps/api/src/frameworks/frameworks.service.ts +++ b/apps/api/src/frameworks/frameworks.service.ts @@ -169,14 +169,151 @@ export class FrameworksService { }; } - async findAvailable() { + async findAvailable(organizationId?: string) { const frameworks = await db.frameworkEditorFramework.findMany({ - where: { visible: true }, + where: { + OR: [ + { visible: true, organizationId: null }, + ...(organizationId ? [{ organizationId }] : []), + ], + }, include: { requirements: true }, }); return frameworks; } + async createCustom( + organizationId: string, + input: { name: string; description: string; version?: string }, + ) { + return db.$transaction(async (tx) => { + const framework = await tx.frameworkEditorFramework.create({ + data: { + name: input.name, + description: input.description, + version: input.version ?? '1.0', + visible: false, + organizationId, + }, + }); + + const instance = await tx.frameworkInstance.create({ + data: { + organizationId, + frameworkId: framework.id, + }, + include: { framework: true }, + }); + + return instance; + }); + } + + async createRequirement( + frameworkInstanceId: string, + organizationId: string, + input: { name: string; identifier: string; description: string }, + ) { + const fi = await db.frameworkInstance.findUnique({ + where: { id: frameworkInstanceId, organizationId }, + select: { frameworkId: true }, + }); + if (!fi) { + throw new NotFoundException('Framework instance not found'); + } + + return db.frameworkEditorRequirement.create({ + data: { + name: input.name, + identifier: input.identifier, + description: input.description, + frameworkId: fi.frameworkId, + organizationId, + }, + }); + } + + async linkRequirements( + frameworkInstanceId: string, + organizationId: string, + requirementIds: string[], + ) { + const fi = await db.frameworkInstance.findUnique({ + where: { id: frameworkInstanceId, organizationId }, + select: { frameworkId: true }, + }); + if (!fi) { + throw new NotFoundException('Framework instance not found'); + } + + const sources = await db.frameworkEditorRequirement.findMany({ + where: { + id: { in: requirementIds }, + OR: [{ organizationId: null }, { organizationId }], + }, + }); + + if (sources.length === 0) { + throw new BadRequestException('No valid requirements to link'); + } + + const created = await db.frameworkEditorRequirement.createManyAndReturn({ + data: sources.map((r) => ({ + name: r.name, + identifier: r.identifier, + description: r.description, + frameworkId: fi.frameworkId, + organizationId, + })), + }); + + return { count: created.length, requirements: created }; + } + + async linkControlsToRequirement( + frameworkInstanceId: string, + requirementKey: string, + organizationId: string, + controlIds: string[], + ) { + const fi = await db.frameworkInstance.findUnique({ + where: { id: frameworkInstanceId, organizationId }, + select: { id: true, frameworkId: true }, + }); + if (!fi) { + throw new NotFoundException('Framework instance not found'); + } + + const requirement = await db.frameworkEditorRequirement.findFirst({ + where: { + id: requirementKey, + frameworkId: fi.frameworkId, + }, + }); + if (!requirement) { + throw new NotFoundException('Requirement not found'); + } + + const controls = await db.control.findMany({ + where: { id: { in: controlIds }, organizationId }, + select: { id: true }, + }); + if (controls.length === 0) { + throw new BadRequestException('No valid controls to link'); + } + + await db.requirementMap.createMany({ + data: controls.map((c) => ({ + controlId: c.id, + requirementId: requirementKey, + frameworkInstanceId, + })), + skipDuplicates: true, + }); + + return { count: controls.length }; + } + async getScores(organizationId: string, userId?: string) { const [scores, currentMember] = await Promise.all([ getOverviewScores(organizationId), diff --git a/apps/api/src/trigger/policies/update-policy.ts b/apps/api/src/trigger/policies/update-policy.ts index ae10c57284..93caa20eb7 100644 --- a/apps/api/src/trigger/policies/update-policy.ts +++ b/apps/api/src/trigger/policies/update-policy.ts @@ -25,6 +25,7 @@ export const updatePolicy = schemaTask({ version: z.string(), description: z.string(), visible: z.boolean(), + organizationId: z.string().nullable().default(null), createdAt: z.date(), updatedAt: z.date(), }), diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/AddCustomRequirementSheet.tsx b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/AddCustomRequirementSheet.tsx new file mode 100644 index 0000000000..ab2126d744 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/AddCustomRequirementSheet.tsx @@ -0,0 +1,154 @@ +'use client'; + +import { apiClient } from '@/lib/api-client'; +import { usePermissions } from '@/hooks/use-permissions'; +import { + Button, + Sheet, + SheetBody, + SheetContent, + SheetHeader, + SheetTitle, +} from '@trycompai/design-system'; +import { Add } from '@trycompai/design-system/icons'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@trycompai/ui/form'; +import { Input } from '@trycompai/ui/input'; +import { Textarea } from '@trycompai/ui/textarea'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useRouter } from 'next/navigation'; +import { useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { toast } from 'sonner'; +import { z } from 'zod'; + +const schema = z.object({ + identifier: z.string().min(1, 'Identifier is required').max(80), + name: z.string().min(1, 'Name is required').max(200), + description: z.string().max(4000), +}); + +type FormValues = z.infer; + +export function AddCustomRequirementSheet({ + frameworkInstanceId, +}: { + frameworkInstanceId: string; +}) { + const { hasPermission } = usePermissions(); + const router = useRouter(); + const [isOpen, setIsOpen] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + + const form = useForm({ + resolver: zodResolver(schema), + defaultValues: { identifier: '', name: '', description: '' }, + mode: 'onChange', + }); + + if (!hasPermission('framework', 'update')) return null; + + const handleSubmit = async (values: FormValues) => { + if (isSubmitting) return; + setIsSubmitting(true); + try { + const response = await apiClient.post( + `/v1/frameworks/${frameworkInstanceId}/requirements`, + values, + ); + if (response.error) throw new Error(response.error); + toast.success('Requirement added'); + setIsOpen(false); + form.reset(); + router.refresh(); + } catch (error) { + toast.error( + error instanceof Error ? error.message : 'Failed to add requirement', + ); + } finally { + setIsSubmitting(false); + } + }; + + return ( + <> + + + + + Add Custom Requirement + + +
+ + ( + + Identifier + + + + + + )} + /> + ( + + Name + + + + + + )} + /> + ( + + Description + +