From 4e3f9ac20b3e2ead3f9c5315bd3a0b242ccf0edd Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Wed, 1 Jul 2026 12:15:12 -0400 Subject: [PATCH 1/6] feat(frameworks): allow editing custom framework name and description Customers could create and delete custom frameworks but had no way to rename or re-describe one after creation. Adds the missing update path: - API: PATCH /v1/frameworks/:id/custom (framework:update) with UpdateCustomFrameworkDto and an updateCustom service method. Resolves the instance org-scoped (404 if missing), rejects platform frameworks (400, their metadata comes from the shared template), and updates the org's CustomFramework. Endpoint follows the MCP/OpenAPI contract (class DTO with both decorator stacks, @ApiBody, summary + description). - App: updateCustomFramework hook + EditCustomFrameworkSheet (pre-filled, framework:update-gated). "Edit Framework" item in the framework detail overflow menu, shown only for custom frameworks. Trust Portal name propagates automatically (it reads CustomFramework.name over HTTP). Tests: service (success / partial update / 404 / 400 platform) + controller delegation; frontend sheet permission gating, prefill, and submit. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_019jXBJKNd7CYdUxf44DsKba --- .../dto/update-custom-framework.dto.ts | 17 ++ .../frameworks/frameworks.controller.spec.ts | 14 ++ .../src/frameworks/frameworks.controller.ts | 19 +++ .../src/frameworks/frameworks.service.spec.ts | 68 +++++++- apps/api/src/frameworks/frameworks.service.ts | 32 ++++ .../EditCustomFrameworkSheet.test.tsx | 96 ++++++++++++ .../components/EditCustomFrameworkSheet.tsx | 148 ++++++++++++++++++ .../components/FrameworkDetailContent.tsx | 54 +++++-- apps/app/src/hooks/use-frameworks.ts | 15 ++ 9 files changed, 449 insertions(+), 14 deletions(-) create mode 100644 apps/api/src/frameworks/dto/update-custom-framework.dto.ts create mode 100644 apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/EditCustomFrameworkSheet.test.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/EditCustomFrameworkSheet.tsx diff --git a/apps/api/src/frameworks/dto/update-custom-framework.dto.ts b/apps/api/src/frameworks/dto/update-custom-framework.dto.ts new file mode 100644 index 0000000000..42a9ffc95b --- /dev/null +++ b/apps/api/src/frameworks/dto/update-custom-framework.dto.ts @@ -0,0 +1,17 @@ +import { IsOptional, IsString, MaxLength, MinLength } from 'class-validator'; +import { ApiPropertyOptional } from '@nestjs/swagger'; + +export class UpdateCustomFrameworkDto { + @ApiPropertyOptional({ description: 'Framework name', example: 'Internal Controls' }) + @IsOptional() + @IsString() + @MinLength(1) + @MaxLength(120) + name?: string; + + @ApiPropertyOptional({ description: 'Framework description' }) + @IsOptional() + @IsString() + @MaxLength(2000) + description?: string; +} diff --git a/apps/api/src/frameworks/frameworks.controller.spec.ts b/apps/api/src/frameworks/frameworks.controller.spec.ts index 970e46be5c..eb67a5b5ca 100644 --- a/apps/api/src/frameworks/frameworks.controller.spec.ts +++ b/apps/api/src/frameworks/frameworks.controller.spec.ts @@ -41,6 +41,7 @@ describe('FrameworksController', () => { const mockService = { findAll: jest.fn(), findAvailable: jest.fn(), + updateCustom: jest.fn(), delete: jest.fn(), getUpdateStatus: jest.fn(), getUpdatePreview: jest.fn(), @@ -158,6 +159,19 @@ describe('FrameworksController', () => { }); }); + describe('updateCustom', () => { + it('should delegate to service and return the updated framework', async () => { + const updated = { id: 'cfrm_A', name: 'CSC/CPRT' }; + mockService.updateCustom.mockResolvedValue(updated); + + const dto = { name: 'CSC/CPRT', description: 'Renamed' }; + const result = await controller.updateCustom('org_1', 'fi1', dto); + + expect(result).toEqual(updated); + expect(service.updateCustom).toHaveBeenCalledWith('fi1', 'org_1', dto); + }); + }); + describe('getUpdateStatus', () => { it('should return update status with { data }', async () => { const mockStatus = { diff --git a/apps/api/src/frameworks/frameworks.controller.ts b/apps/api/src/frameworks/frameworks.controller.ts index 68a347fa1a..31b309c4d2 100644 --- a/apps/api/src/frameworks/frameworks.controller.ts +++ b/apps/api/src/frameworks/frameworks.controller.ts @@ -5,6 +5,7 @@ import { Delete, Get, Param, + Patch, Post, Query, UseGuards, @@ -12,6 +13,7 @@ import { import { ApiTags, ApiBearerAuth, + ApiBody, ApiOperation, ApiQuery, } from '@nestjs/swagger'; @@ -28,6 +30,7 @@ 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 { UpdateCustomFrameworkDto } from './dto/update-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'; @@ -144,6 +147,22 @@ export class FrameworksController { return this.frameworksService.createCustom(organizationId, dto); } + @Patch(':id/custom') + @RequirePermission('framework', 'update') + @ApiOperation({ + summary: 'Update a custom framework', + description: + "Update the name and/or description of an organization's custom framework. Only custom frameworks are editable; platform frameworks return 400.", + }) + @ApiBody({ type: UpdateCustomFrameworkDto }) + async updateCustom( + @OrganizationId() organizationId: string, + @Param('id') id: string, + @Body() dto: UpdateCustomFrameworkDto, + ) { + return this.frameworksService.updateCustom(id, organizationId, dto); + } + @Post(':id/requirements') @RequirePermission('framework', 'update') @ApiOperation({ summary: 'Add a custom requirement to a framework instance' }) diff --git a/apps/api/src/frameworks/frameworks.service.spec.ts b/apps/api/src/frameworks/frameworks.service.spec.ts index dad4f7b141..1485dc275f 100644 --- a/apps/api/src/frameworks/frameworks.service.spec.ts +++ b/apps/api/src/frameworks/frameworks.service.spec.ts @@ -1,5 +1,5 @@ import { Test, TestingModule } from '@nestjs/testing'; -import { NotFoundException } from '@nestjs/common'; +import { BadRequestException, NotFoundException } from '@nestjs/common'; import { FrameworksService } from './frameworks.service'; import { TimelinesService } from '../timelines/timelines.service'; @@ -35,6 +35,7 @@ jest.mock('@db', () => ({ }, customFramework: { findMany: jest.fn(), + update: jest.fn(), }, }, // The frameworks-timeline helper imports FindingType (a Prisma enum) at module @@ -135,6 +136,71 @@ describe('FrameworksService', () => { }); }); + describe('updateCustom', () => { + it('should update the custom framework name and description', async () => { + (mockDb.frameworkInstance.findUnique as jest.Mock).mockResolvedValue({ + customFrameworkId: 'cfrm_A', + }); + const updated = { + id: 'cfrm_A', + name: 'CSC/CPRT', + description: 'Renamed', + }; + (mockDb.customFramework.update as jest.Mock).mockResolvedValue(updated); + + const result = await service.updateCustom('fi1', 'org_1', { + name: 'CSC/CPRT', + description: 'Renamed', + }); + + expect(result).toEqual(updated); + expect(mockDb.frameworkInstance.findUnique).toHaveBeenCalledWith({ + where: { id: 'fi1', organizationId: 'org_1' }, + select: { customFrameworkId: true }, + }); + expect(mockDb.customFramework.update).toHaveBeenCalledWith({ + where: { id: 'cfrm_A' }, + data: { name: 'CSC/CPRT', description: 'Renamed' }, + }); + }); + + it('should only update the fields that are provided', async () => { + (mockDb.frameworkInstance.findUnique as jest.Mock).mockResolvedValue({ + customFrameworkId: 'cfrm_A', + }); + (mockDb.customFramework.update as jest.Mock).mockResolvedValue({}); + + await service.updateCustom('fi1', 'org_1', { name: 'Just the name' }); + + expect(mockDb.customFramework.update).toHaveBeenCalledWith({ + where: { id: 'cfrm_A' }, + data: { name: 'Just the name' }, + }); + }); + + it('should throw NotFoundException when instance not found', async () => { + (mockDb.frameworkInstance.findUnique as jest.Mock).mockResolvedValue( + null, + ); + + await expect( + service.updateCustom('missing', 'org_1', { name: 'x' }), + ).rejects.toThrow(NotFoundException); + expect(mockDb.customFramework.update).not.toHaveBeenCalled(); + }); + + it('should throw BadRequestException for a platform framework', async () => { + (mockDb.frameworkInstance.findUnique as jest.Mock).mockResolvedValue({ + customFrameworkId: null, + }); + + await expect( + service.updateCustom('fi_platform', 'org_1', { name: 'x' }), + ).rejects.toThrow(BadRequestException); + expect(mockDb.customFramework.update).not.toHaveBeenCalled(); + }); + }); + describe('getScores', () => { it('should call getOverviewScores and getCurrentMember when userId is provided', async () => { const mockScores = { policies: 10, tasks: 5 }; diff --git a/apps/api/src/frameworks/frameworks.service.ts b/apps/api/src/frameworks/frameworks.service.ts index 7ee1d65b7f..6de51601bb 100644 --- a/apps/api/src/frameworks/frameworks.service.ts +++ b/apps/api/src/frameworks/frameworks.service.ts @@ -465,6 +465,38 @@ export class FrameworksService { }); } + async updateCustom( + frameworkInstanceId: string, + organizationId: string, + input: { name?: string; description?: string }, + ) { + const frameworkInstance = await db.frameworkInstance.findUnique({ + where: { id: frameworkInstanceId, organizationId }, + select: { customFrameworkId: true }, + }); + + if (!frameworkInstance) { + throw new NotFoundException('Framework instance not found'); + } + + // Only org-authored custom frameworks carry editable metadata. Platform + // frameworks derive their name/description from the shared global template. + if (!frameworkInstance.customFrameworkId) { + throw new BadRequestException('Only custom frameworks can be edited'); + } + + const data: { name?: string; description?: string } = {}; + if (input.name !== undefined) data.name = input.name; + if (input.description !== undefined) data.description = input.description; + + // Ownership is already enforced by the org-scoped instance lookup above, + // so keying the update by the custom framework id is safe. + return db.customFramework.update({ + where: { id: frameworkInstance.customFrameworkId }, + data, + }); + } + async createRequirement( frameworkInstanceId: string, organizationId: string, diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/EditCustomFrameworkSheet.test.tsx b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/EditCustomFrameworkSheet.test.tsx new file mode 100644 index 0000000000..59221c4428 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/EditCustomFrameworkSheet.test.tsx @@ -0,0 +1,96 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + setMockPermissions, + ADMIN_PERMISSIONS, + AUDITOR_PERMISSIONS, + mockHasPermission, +} from '@/test-utils/mocks/permissions'; + +vi.mock('@/hooks/use-permissions', () => ({ + usePermissions: () => ({ + permissions: {}, + hasPermission: mockHasPermission, + }), +})); + +const updateCustomFramework = vi.fn(); +vi.mock('@/hooks/use-frameworks', () => ({ + useFrameworks: () => ({ updateCustomFramework }), +})); + +vi.mock('sonner', () => ({ + toast: { success: vi.fn(), error: vi.fn() }, +})); + +import { EditCustomFrameworkSheet } from './EditCustomFrameworkSheet'; + +const frameworkInstance = { + id: 'frm_1', + organizationId: 'org_123', + customFrameworkId: 'cfrm_1', + customFramework: { + id: 'cfrm_1', + name: 'CMMC', + description: 'Original description', + }, + controls: [], +} as any; + +function renderSheet(overrides: Partial> = {}) { + return render( + , + ); +} + +describe('EditCustomFrameworkSheet', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders nothing when the user lacks framework:update', () => { + setMockPermissions(AUDITOR_PERMISSIONS); + renderSheet(); + expect(screen.queryByText('Edit Custom Framework')).not.toBeInTheDocument(); + }); + + it('pre-fills the form with the current name and description', () => { + setMockPermissions(ADMIN_PERMISSIONS); + renderSheet(); + expect(screen.getByText('Edit Custom Framework')).toBeInTheDocument(); + expect(screen.getByDisplayValue('CMMC')).toBeInTheDocument(); + expect( + screen.getByDisplayValue('Original description'), + ).toBeInTheDocument(); + }); + + it('submits the edited values via updateCustomFramework', async () => { + setMockPermissions(ADMIN_PERMISSIONS); + updateCustomFramework.mockResolvedValue({}); + const onClose = vi.fn(); + const onUpdated = vi.fn(); + renderSheet({ onClose, onUpdated }); + + const user = userEvent.setup(); + const nameInput = screen.getByDisplayValue('CMMC'); + await user.clear(nameInput); + await user.type(nameInput, 'CSC/CPRT'); + await user.click(screen.getByRole('button', { name: /save changes/i })); + + await waitFor(() => { + expect(updateCustomFramework).toHaveBeenCalledWith('frm_1', { + name: 'CSC/CPRT', + description: 'Original description', + }); + }); + expect(onUpdated).toHaveBeenCalled(); + expect(onClose).toHaveBeenCalled(); + }); +}); diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/EditCustomFrameworkSheet.tsx b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/EditCustomFrameworkSheet.tsx new file mode 100644 index 0000000000..9387f18040 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/EditCustomFrameworkSheet.tsx @@ -0,0 +1,148 @@ +'use client'; + +import { useFrameworks } from '@/hooks/use-frameworks'; +import { usePermissions } from '@/hooks/use-permissions'; +import type { FrameworkInstanceWithControls } from '@/lib/types/framework'; +import { + Button, + Sheet, + SheetBody, + SheetContent, + SheetHeader, + SheetTitle, +} from '@trycompai/design-system'; +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 { useEffect, useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { toast } from 'sonner'; +import { z } from 'zod'; + +const schema = z.object({ + name: z.string().min(1, 'Name is required').max(120), + description: z.string().max(2000), +}); + +type FormValues = z.infer; + +interface EditCustomFrameworkSheetProps { + isOpen: boolean; + onClose: () => void; + frameworkInstance: FrameworkInstanceWithControls; + onUpdated?: () => void; +} + +export function EditCustomFrameworkSheet({ + isOpen, + onClose, + frameworkInstance, + onUpdated, +}: EditCustomFrameworkSheetProps) { + const { updateCustomFramework } = useFrameworks(); + const { hasPermission } = usePermissions(); + const [isSubmitting, setIsSubmitting] = useState(false); + + const form = useForm({ + resolver: zodResolver(schema), + defaultValues: { + name: frameworkInstance.customFramework?.name ?? '', + description: frameworkInstance.customFramework?.description ?? '', + }, + mode: 'onChange', + }); + + // Refresh the form with the latest values each time the sheet opens. + useEffect(() => { + if (isOpen) { + form.reset({ + name: frameworkInstance.customFramework?.name ?? '', + description: frameworkInstance.customFramework?.description ?? '', + }); + } + }, [isOpen, frameworkInstance, form]); + + if (!hasPermission('framework', 'update')) return null; + + const handleSubmit = async (values: FormValues) => { + if (isSubmitting) return; + setIsSubmitting(true); + try { + await updateCustomFramework(frameworkInstance.id, values); + toast.success('Custom framework updated'); + onUpdated?.(); + onClose(); + } catch (error) { + toast.error( + error instanceof Error ? error.message : 'Failed to update framework', + ); + } finally { + setIsSubmitting(false); + } + }; + + return ( + !open && onClose()}> + + + Edit Custom Framework + + +
+ + ( + + Name + + + + + + )} + /> + ( + + Description + +