From 2c4521ff609cf923dec95aaa55647f56f1033481 Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Tue, 24 Mar 2026 16:56:02 -0400 Subject: [PATCH 01/25] feat: add FrameworkInstanceRequirement schema for custom org requirements Co-Authored-By: Claude Opus 4.6 (1M context) --- .../framework-instance-requirement.prisma | 17 +++++++++++++++++ packages/db/prisma/schema/framework.prisma | 5 +++-- packages/db/prisma/schema/requirement.prisma | 9 +++++++-- 3 files changed, 27 insertions(+), 4 deletions(-) create mode 100644 packages/db/prisma/schema/framework-instance-requirement.prisma diff --git a/packages/db/prisma/schema/framework-instance-requirement.prisma b/packages/db/prisma/schema/framework-instance-requirement.prisma new file mode 100644 index 0000000000..b7b2df3598 --- /dev/null +++ b/packages/db/prisma/schema/framework-instance-requirement.prisma @@ -0,0 +1,17 @@ +model FrameworkInstanceRequirement { + id String @id @default(dbgenerated("generate_prefixed_cuid('fir'::text)")) + + frameworkInstanceId String + frameworkInstance FrameworkInstance @relation(fields: [frameworkInstanceId], references: [id], onDelete: Cascade) + + name String + identifier String @default("") + description String + + requirementMaps RequirementMap[] + + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) @updatedAt + + @@index([frameworkInstanceId]) +} diff --git a/packages/db/prisma/schema/framework.prisma b/packages/db/prisma/schema/framework.prisma index 5faca9cb35..f6c34e11b7 100644 --- a/packages/db/prisma/schema/framework.prisma +++ b/packages/db/prisma/schema/framework.prisma @@ -7,8 +7,9 @@ model FrameworkInstance { framework FrameworkEditorFramework @relation(fields: [frameworkId], references: [id], onDelete: Cascade) // Relationships - organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) - requirementsMapped RequirementMap[] + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + requirementsMapped RequirementMap[] + frameworkInstanceRequirements FrameworkInstanceRequirement[] @@unique([organizationId, frameworkId]) } diff --git a/packages/db/prisma/schema/requirement.prisma b/packages/db/prisma/schema/requirement.prisma index ea69db5032..efb5666b4d 100644 --- a/packages/db/prisma/schema/requirement.prisma +++ b/packages/db/prisma/schema/requirement.prisma @@ -1,8 +1,11 @@ model RequirementMap { id String @id @default(dbgenerated("generate_prefixed_cuid('req'::text)")) - requirementId String - requirement FrameworkEditorRequirement @relation(fields: [requirementId], references: [id], onDelete: Cascade) + requirementId String? + requirement FrameworkEditorRequirement? @relation(fields: [requirementId], references: [id], onDelete: Cascade) + + frameworkInstanceRequirementId String? + frameworkInstanceRequirement FrameworkInstanceRequirement? @relation(fields: [frameworkInstanceRequirementId], references: [id], onDelete: Cascade) controlId String control Control @relation(fields: [controlId], references: [id], onDelete: Cascade) @@ -11,5 +14,7 @@ model RequirementMap { frameworkInstance FrameworkInstance @relation(fields: [frameworkInstanceId], references: [id], onDelete: Cascade) @@unique([controlId, frameworkInstanceId, requirementId]) + @@unique([controlId, frameworkInstanceId, frameworkInstanceRequirementId]) @@index([requirementId, frameworkInstanceId]) + @@index([frameworkInstanceRequirementId, frameworkInstanceId]) } From 6aba767526ee43ca57de90665c223d2e59eea99d Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Tue, 24 Mar 2026 17:01:29 -0400 Subject: [PATCH 02/25] feat: add customer-facing framework instance requirements CRUD API Create NestJS module for managing custom framework instance requirements scoped to the customer's organization. Includes full CRUD with org-level authorization via HybridAuthGuard + PermissionGuard. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/api/src/app.module.ts | 2 + ...eate-framework-instance-requirement.dto.ts | 33 ++++ ...date-framework-instance-requirement.dto.ts | 22 +++ ...mework-instance-requirements.controller.ts | 96 +++++++++++ .../framework-instance-requirements.module.ts | 12 ++ ...framework-instance-requirements.service.ts | 158 ++++++++++++++++++ 6 files changed, 323 insertions(+) create mode 100644 apps/api/src/framework-instance-requirements/dto/create-framework-instance-requirement.dto.ts create mode 100644 apps/api/src/framework-instance-requirements/dto/update-framework-instance-requirement.dto.ts create mode 100644 apps/api/src/framework-instance-requirements/framework-instance-requirements.controller.ts create mode 100644 apps/api/src/framework-instance-requirements/framework-instance-requirements.module.ts create mode 100644 apps/api/src/framework-instance-requirements/framework-instance-requirements.service.ts diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 3bc246e5ac..45756132cf 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -49,6 +49,7 @@ import { SecretsModule } from './secrets/secrets.module'; import { SecurityPenetrationTestsModule } from './security-penetration-tests/security-penetration-tests.module'; import { StripeModule } from './stripe/stripe.module'; import { AdminOrganizationsModule } from './admin-organizations/admin-organizations.module'; +import { FrameworkInstanceRequirementsModule } from './framework-instance-requirements/framework-instance-requirements.module'; @Module({ imports: [ @@ -110,6 +111,7 @@ import { AdminOrganizationsModule } from './admin-organizations/admin-organizati SecurityPenetrationTestsModule, StripeModule, AdminOrganizationsModule, + FrameworkInstanceRequirementsModule, ], controllers: [AppController], providers: [ diff --git a/apps/api/src/framework-instance-requirements/dto/create-framework-instance-requirement.dto.ts b/apps/api/src/framework-instance-requirements/dto/create-framework-instance-requirement.dto.ts new file mode 100644 index 0000000000..beb7caa463 --- /dev/null +++ b/apps/api/src/framework-instance-requirements/dto/create-framework-instance-requirement.dto.ts @@ -0,0 +1,33 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + IsString, + IsNotEmpty, + IsOptional, + MaxLength, +} from 'class-validator'; + +export class CreateFrameworkInstanceRequirementDto { + @ApiProperty({ example: 'frm_abc123' }) + @IsString() + @IsNotEmpty() + @MaxLength(255) + frameworkInstanceId: string; + + @ApiProperty({ example: 'Custom Access Control' }) + @IsString() + @IsNotEmpty() + @MaxLength(255) + name: string; + + @ApiPropertyOptional({ example: 'CUSTOM-1' }) + @IsString() + @IsOptional() + @MaxLength(255) + identifier?: string; + + @ApiProperty({ example: 'Custom requirement for access control policies' }) + @IsString() + @IsNotEmpty() + @MaxLength(5000) + description: string; +} diff --git a/apps/api/src/framework-instance-requirements/dto/update-framework-instance-requirement.dto.ts b/apps/api/src/framework-instance-requirements/dto/update-framework-instance-requirement.dto.ts new file mode 100644 index 0000000000..04e960416c --- /dev/null +++ b/apps/api/src/framework-instance-requirements/dto/update-framework-instance-requirement.dto.ts @@ -0,0 +1,22 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsString, IsOptional, MaxLength } from 'class-validator'; + +export class UpdateFrameworkInstanceRequirementDto { + @ApiPropertyOptional() + @IsString() + @IsOptional() + @MaxLength(255) + name?: string; + + @ApiPropertyOptional() + @IsString() + @IsOptional() + @MaxLength(255) + identifier?: string; + + @ApiPropertyOptional() + @IsString() + @IsOptional() + @MaxLength(5000) + description?: string; +} diff --git a/apps/api/src/framework-instance-requirements/framework-instance-requirements.controller.ts b/apps/api/src/framework-instance-requirements/framework-instance-requirements.controller.ts new file mode 100644 index 0000000000..7613b9e8a7 --- /dev/null +++ b/apps/api/src/framework-instance-requirements/framework-instance-requirements.controller.ts @@ -0,0 +1,96 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Patch, + Post, + Query, + UseGuards, + UsePipes, + ValidationPipe, +} from '@nestjs/common'; +import { + ApiTags, + ApiBearerAuth, + ApiOperation, + ApiQuery, +} from '@nestjs/swagger'; +import { HybridAuthGuard } from '../auth/hybrid-auth.guard'; +import { PermissionGuard } from '../auth/permission.guard'; +import { RequirePermission } from '../auth/require-permission.decorator'; +import { OrganizationId } from '../auth/auth-context.decorator'; +import { FrameworkInstanceRequirementsService } from './framework-instance-requirements.service'; +import { CreateFrameworkInstanceRequirementDto } from './dto/create-framework-instance-requirement.dto'; +import { UpdateFrameworkInstanceRequirementDto } from './dto/update-framework-instance-requirement.dto'; + +@ApiTags('Framework Instance Requirements') +@ApiBearerAuth() +@UseGuards(HybridAuthGuard, PermissionGuard) +@Controller({ path: 'framework-instance-requirements', version: '1' }) +export class FrameworkInstanceRequirementsController { + constructor( + private readonly service: FrameworkInstanceRequirementsService, + ) {} + + @Get() + @RequirePermission('framework', 'read') + @ApiOperation({ + summary: 'List custom requirements for a framework instance', + }) + @ApiQuery({ name: 'frameworkInstanceId', required: true, type: String }) + async findAll( + @OrganizationId() organizationId: string, + @Query('frameworkInstanceId') frameworkInstanceId: string, + ) { + const data = await this.service.findAll( + frameworkInstanceId, + organizationId, + ); + return { data, count: data.length }; + } + + @Get(':id') + @RequirePermission('framework', 'read') + @ApiOperation({ summary: 'Get a single framework instance requirement' }) + async findOne( + @OrganizationId() organizationId: string, + @Param('id') id: string, + ) { + return this.service.findOne(id, organizationId); + } + + @Post() + @RequirePermission('framework', 'create') + @UsePipes(new ValidationPipe({ whitelist: true, transform: true })) + @ApiOperation({ summary: 'Create a framework instance requirement' }) + async create( + @OrganizationId() organizationId: string, + @Body() dto: CreateFrameworkInstanceRequirementDto, + ) { + return this.service.create(dto, organizationId); + } + + @Patch(':id') + @RequirePermission('framework', 'update') + @UsePipes(new ValidationPipe({ whitelist: true, transform: true })) + @ApiOperation({ summary: 'Update a framework instance requirement' }) + async update( + @OrganizationId() organizationId: string, + @Param('id') id: string, + @Body() dto: UpdateFrameworkInstanceRequirementDto, + ) { + return this.service.update(id, dto, organizationId); + } + + @Delete(':id') + @RequirePermission('framework', 'delete') + @ApiOperation({ summary: 'Delete a framework instance requirement' }) + async delete( + @OrganizationId() organizationId: string, + @Param('id') id: string, + ) { + return this.service.delete(id, organizationId); + } +} diff --git a/apps/api/src/framework-instance-requirements/framework-instance-requirements.module.ts b/apps/api/src/framework-instance-requirements/framework-instance-requirements.module.ts new file mode 100644 index 0000000000..6624971e3d --- /dev/null +++ b/apps/api/src/framework-instance-requirements/framework-instance-requirements.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { AuthModule } from '../auth/auth.module'; +import { FrameworkInstanceRequirementsController } from './framework-instance-requirements.controller'; +import { FrameworkInstanceRequirementsService } from './framework-instance-requirements.service'; + +@Module({ + imports: [AuthModule], + controllers: [FrameworkInstanceRequirementsController], + providers: [FrameworkInstanceRequirementsService], + exports: [FrameworkInstanceRequirementsService], +}) +export class FrameworkInstanceRequirementsModule {} diff --git a/apps/api/src/framework-instance-requirements/framework-instance-requirements.service.ts b/apps/api/src/framework-instance-requirements/framework-instance-requirements.service.ts new file mode 100644 index 0000000000..824f0d3966 --- /dev/null +++ b/apps/api/src/framework-instance-requirements/framework-instance-requirements.service.ts @@ -0,0 +1,158 @@ +import { Injectable, NotFoundException, Logger } from '@nestjs/common'; +import { db } from '@trycompai/db'; +import { CreateFrameworkInstanceRequirementDto } from './dto/create-framework-instance-requirement.dto'; +import { UpdateFrameworkInstanceRequirementDto } from './dto/update-framework-instance-requirement.dto'; + +@Injectable() +export class FrameworkInstanceRequirementsService { + private readonly logger = new Logger( + FrameworkInstanceRequirementsService.name, + ); + + async findAll(frameworkInstanceId: string, organizationId: string) { + const frameworkInstance = await db.frameworkInstance.findUnique({ + where: { id: frameworkInstanceId, organizationId }, + }); + + if (!frameworkInstance) { + throw new NotFoundException( + `Framework instance ${frameworkInstanceId} not found`, + ); + } + + return db.frameworkInstanceRequirement.findMany({ + where: { frameworkInstanceId }, + orderBy: { name: 'asc' }, + include: { + requirementMaps: { + include: { + control: { + include: { + tasks: true, + policies: true, + }, + }, + }, + }, + }, + }); + } + + async findOne(id: string, organizationId: string) { + const requirement = await db.frameworkInstanceRequirement.findUnique({ + where: { id }, + include: { + frameworkInstance: true, + requirementMaps: { + include: { + control: { + include: { + tasks: true, + policies: true, + }, + }, + }, + }, + }, + }); + + if (!requirement) { + throw new NotFoundException( + `Framework instance requirement ${id} not found`, + ); + } + + if (requirement.frameworkInstance.organizationId !== organizationId) { + throw new NotFoundException( + `Framework instance requirement ${id} not found`, + ); + } + + return requirement; + } + + async create( + dto: CreateFrameworkInstanceRequirementDto, + organizationId: string, + ) { + const frameworkInstance = await db.frameworkInstance.findUnique({ + where: { id: dto.frameworkInstanceId, organizationId }, + }); + + if (!frameworkInstance) { + throw new NotFoundException( + `Framework instance ${dto.frameworkInstanceId} not found`, + ); + } + + const requirement = await db.frameworkInstanceRequirement.create({ + data: { + frameworkInstanceId: dto.frameworkInstanceId, + name: dto.name, + identifier: dto.identifier ?? '', + description: dto.description, + }, + }); + + this.logger.log( + `Created framework instance requirement: ${requirement.name} (${requirement.id})`, + ); + return requirement; + } + + async update( + id: string, + dto: UpdateFrameworkInstanceRequirementDto, + organizationId: string, + ) { + const existing = await db.frameworkInstanceRequirement.findUnique({ + where: { id }, + include: { frameworkInstance: true }, + }); + + if (!existing) { + throw new NotFoundException( + `Framework instance requirement ${id} not found`, + ); + } + + if (existing.frameworkInstance.organizationId !== organizationId) { + throw new NotFoundException( + `Framework instance requirement ${id} not found`, + ); + } + + const updated = await db.frameworkInstanceRequirement.update({ + where: { id }, + data: dto, + }); + + this.logger.log( + `Updated framework instance requirement: ${updated.name} (${id})`, + ); + return updated; + } + + async delete(id: string, organizationId: string) { + const existing = await db.frameworkInstanceRequirement.findUnique({ + where: { id }, + include: { frameworkInstance: true }, + }); + + if (!existing) { + throw new NotFoundException( + `Framework instance requirement ${id} not found`, + ); + } + + if (existing.frameworkInstance.organizationId !== organizationId) { + throw new NotFoundException( + `Framework instance requirement ${id} not found`, + ); + } + + await db.frameworkInstanceRequirement.delete({ where: { id } }); + this.logger.log(`Deleted framework instance requirement ${id}`); + return { message: 'Framework instance requirement deleted successfully' }; + } +} From 51957479eb142dc8a74eb04fba55361d9d0fa321 Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Tue, 24 Mar 2026 17:06:06 -0400 Subject: [PATCH 03/25] fix: remove redundant per-method ValidationPipe, rely on global pipe --- .../framework-instance-requirements.controller.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/apps/api/src/framework-instance-requirements/framework-instance-requirements.controller.ts b/apps/api/src/framework-instance-requirements/framework-instance-requirements.controller.ts index 7613b9e8a7..9e0ec9d162 100644 --- a/apps/api/src/framework-instance-requirements/framework-instance-requirements.controller.ts +++ b/apps/api/src/framework-instance-requirements/framework-instance-requirements.controller.ts @@ -8,8 +8,6 @@ import { Post, Query, UseGuards, - UsePipes, - ValidationPipe, } from '@nestjs/common'; import { ApiTags, @@ -63,7 +61,6 @@ export class FrameworkInstanceRequirementsController { @Post() @RequirePermission('framework', 'create') - @UsePipes(new ValidationPipe({ whitelist: true, transform: true })) @ApiOperation({ summary: 'Create a framework instance requirement' }) async create( @OrganizationId() organizationId: string, @@ -74,7 +71,6 @@ export class FrameworkInstanceRequirementsController { @Patch(':id') @RequirePermission('framework', 'update') - @UsePipes(new ValidationPipe({ whitelist: true, transform: true })) @ApiOperation({ summary: 'Update a framework instance requirement' }) async update( @OrganizationId() organizationId: string, From 63292949511561cf0f769877398fc6241f2a90af Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Tue, 24 Mar 2026 17:06:52 -0400 Subject: [PATCH 04/25] feat: include instance requirements in framework detail response --- apps/api/src/frameworks/frameworks.service.ts | 51 +++++++++++++------ 1 file changed, 36 insertions(+), 15 deletions(-) diff --git a/apps/api/src/frameworks/frameworks.service.ts b/apps/api/src/frameworks/frameworks.service.ts index 4b69e33ec9..1aee111b6b 100644 --- a/apps/api/src/frameworks/frameworks.service.ts +++ b/apps/api/src/frameworks/frameworks.service.ts @@ -120,26 +120,47 @@ export class FrameworksService { const { requirementsMapped: _, ...rest } = fi; // Fetch additional data - const [requirementDefinitions, tasks, requirementMaps] = - await Promise.all([ - db.frameworkEditorRequirement.findMany({ - where: { frameworkId: fi.frameworkId }, - orderBy: { name: 'asc' }, - }), - db.task.findMany({ - where: { organizationId, controls: { some: { organizationId } } }, - include: { controls: true }, - }), - db.requirementMap.findMany({ - where: { frameworkInstanceId }, - include: { control: true }, - }), - ]); + const [ + requirementDefinitions, + frameworkInstanceRequirements, + tasks, + requirementMaps, + ] = await Promise.all([ + db.frameworkEditorRequirement.findMany({ + where: { frameworkId: fi.frameworkId }, + orderBy: { name: 'asc' }, + }), + db.frameworkInstanceRequirement.findMany({ + where: { frameworkInstanceId }, + include: { + requirementMaps: { + include: { + control: { + include: { + tasks: true, + policies: true, + }, + }, + }, + }, + }, + orderBy: { createdAt: 'asc' }, + }), + db.task.findMany({ + where: { organizationId, controls: { some: { organizationId } } }, + include: { controls: true }, + }), + db.requirementMap.findMany({ + where: { frameworkInstanceId }, + include: { control: true }, + }), + ]); return { ...rest, controls: Array.from(controlsMap.values()), requirementDefinitions, + frameworkInstanceRequirements, tasks, requirementMaps, }; From d839159625a7a2324c78cb3a3edde1247f8f21aa Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Tue, 24 Mar 2026 17:14:35 -0400 Subject: [PATCH 05/25] feat: make framework rows clickable in overview when advanced mode is enabled Adds advancedModeEnabled prop through page -> Overview -> FrameworksOverview chain. When enabled, framework rows link to their detail page. Otherwise, they remain static. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../components/FrameworksOverview.tsx | 100 ++++++++++-------- .../frameworks/components/Overview.tsx | 3 + .../src/app/(app)/[orgId]/frameworks/page.tsx | 6 +- 3 files changed, 66 insertions(+), 43 deletions(-) diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/components/FrameworksOverview.tsx b/apps/app/src/app/(app)/[orgId]/frameworks/components/FrameworksOverview.tsx index e787d3d9eb..e101ae478c 100644 --- a/apps/app/src/app/(app)/[orgId]/frameworks/components/FrameworksOverview.tsx +++ b/apps/app/src/app/(app)/[orgId]/frameworks/components/FrameworksOverview.tsx @@ -7,6 +7,7 @@ import { ScrollArea } from '@trycompai/ui/scroll-area'; import type { FrameworkEditorFramework } from '@db'; import { PlusIcon } from 'lucide-react'; import Image from 'next/image'; +import Link from 'next/link'; import { useState } from 'react'; import { usePermissions } from '@/hooks/use-permissions'; import type { FrameworkInstanceWithControls } from '../types'; @@ -19,6 +20,7 @@ export interface FrameworksOverviewProps { frameworksWithCompliance?: FrameworkInstanceWithComplianceScore[]; organizationId?: string; overallComplianceScore: number; + advancedModeEnabled: boolean; } export function mapFrameworkToBadge(framework: FrameworkInstanceWithControls) { @@ -63,6 +65,7 @@ export function FrameworksOverview({ overallComplianceScore, allFrameworks, organizationId, + advancedModeEnabled, }: FrameworksOverviewProps) { const [isAddFrameworkModalOpen, setIsAddFrameworkModalOpen] = useState(false); const { hasPermission } = usePermissions(); @@ -101,52 +104,65 @@ export function FrameworksOverview({ const complianceScore = complianceMap.get(framework.id) ?? 0; const badgeSrc = mapFrameworkToBadge(framework); - return ( -
-
-
-
- {badgeSrc ? ( - {framework.framework.name} - ) : ( -
- - {framework.framework.name.charAt(0)} - -
- )} -
-
- - {framework.framework.name} - - {framework.framework.description?.trim()} - + const rowContent = ( +
+
+ {badgeSrc ? ( + {framework.framework.name} + ) : ( +
+ + {framework.framework.name.charAt(0)} - -
-
-
-
- - {Math.round(complianceScore)}% compliant - -
+ )} +
+
+ + {framework.framework.name} + + {framework.framework.description?.trim()} + + + +
+
+
+
+ + {Math.round(complianceScore)}% compliant +
+
+ ); + + return ( +
+ {advancedModeEnabled && organizationId ? ( + + {rowContent} + + ) : ( +
+ {rowContent} +
+ )} {index < frameworksWithControls.length - 1 && (
)} diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/components/Overview.tsx b/apps/app/src/app/(app)/[orgId]/frameworks/components/Overview.tsx index 47bf84f5d5..d95b454c9a 100644 --- a/apps/app/src/app/(app)/[orgId]/frameworks/components/Overview.tsx +++ b/apps/app/src/app/(app)/[orgId]/frameworks/components/Overview.tsx @@ -49,6 +49,7 @@ export interface OverviewProps { frameworksWithCompliance: FrameworkInstanceWithComplianceScore[]; allFrameworks: FrameworkEditorFramework[]; organizationId: string; + advancedModeEnabled: boolean; publishedPoliciesScore: PublishedPoliciesScore; doneTasksScore: DoneTasksScore; documentsScore: DocumentsScore; @@ -63,6 +64,7 @@ export const Overview = ({ frameworksWithCompliance, allFrameworks, organizationId, + advancedModeEnabled, publishedPoliciesScore, doneTasksScore, documentsScore, @@ -102,6 +104,7 @@ export const Overview = ({ overallComplianceScore={overallComplianceScore} allFrameworks={allFrameworks} organizationId={organizationId} + advancedModeEnabled={advancedModeEnabled} /> }) { const { orgId: organizationId } = await params; - const [scoresRes, frameworksRes, availableRes] = await Promise.all([ + const [scoresRes, frameworksRes, availableRes, orgRes] = await Promise.all([ serverApi.get('/v1/frameworks/scores'), serverApi.get<{ data: FrameworkWithScore[] }>('/v1/frameworks?includeControls=true&includeScores=true'), serverApi.get<{ data: FrameworkEditorFramework[] }>('/v1/frameworks/available'), + serverApi.get<{ advancedModeEnabled: boolean }>('/v1/organization'), ]); + const advancedModeEnabled = orgRes.data?.advancedModeEnabled ?? false; + const scores = scoresRes.data; const frameworksData = frameworksRes.data?.data ?? []; const allFrameworks = availableRes.data?.data ?? []; @@ -56,6 +59,7 @@ export default async function DashboardPage({ params }: { params: Promise<{ orgI frameworksWithCompliance={frameworksWithCompliance} allFrameworks={allFrameworks} organizationId={organizationId} + advancedModeEnabled={advancedModeEnabled} publishedPoliciesScore={{ totalPolicies: scores?.policies?.total ?? 0, publishedPolicies: scores?.policies?.published ?? 0, From b05b17b971a509846b6f1618f6840615d1964e41 Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Tue, 24 Mar 2026 17:16:22 -0400 Subject: [PATCH 06/25] feat: add framework instance requirement types, SWR hook, and update detail page Adds RequirementMapWithControl, InstanceRequirementWithMaps, TemplateRequirement, and FrameworkInstanceDetail types. Creates useFrameworkInstance SWR hook for client-side data fetching. Updates detail page to pass instance requirement data to new FrameworkRequirementsList component and adds generateMetadata. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../hooks/useFrameworkInstance.ts | 18 +++++++++ .../frameworks/[frameworkInstanceId]/page.tsx | 20 +++++++++- .../src/app/(app)/[orgId]/frameworks/types.ts | 40 +++++++++++++++++++ 3 files changed, 77 insertions(+), 1 deletion(-) create mode 100644 apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/hooks/useFrameworkInstance.ts diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/hooks/useFrameworkInstance.ts b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/hooks/useFrameworkInstance.ts new file mode 100644 index 0000000000..aa9f0624ee --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/hooks/useFrameworkInstance.ts @@ -0,0 +1,18 @@ +'use client'; + +import useSWR from 'swr'; +import { apiClient } from '@/lib/api-client'; +import type { FrameworkInstanceDetail } from '../../types'; + +export function useFrameworkInstance(frameworkInstanceId: string) { + const { data, error, isLoading, mutate } = useSWR( + `/v1/frameworks/${frameworkInstanceId}`, + async (url: string) => { + const res = await apiClient.get(url); + if (res.error) throw new Error(res.error); + return res.data!; + }, + ); + + return { data, error, isLoading, mutate }; +} diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/page.tsx b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/page.tsx index 1cdc95454b..125656729a 100644 --- a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/page.tsx @@ -1,8 +1,10 @@ import { serverApi } from '@/lib/api-server'; import { redirect } from 'next/navigation'; import PageWithBreadcrumb from '../../../../../components/pages/PageWithBreadcrumb'; +import type { FrameworkInstanceDetail } from '../types'; import { FrameworkOverview } from './components/FrameworkOverview'; import { FrameworkRequirements } from './components/FrameworkRequirements'; +import { FrameworkRequirementsList } from './components/FrameworkRequirementsList'; interface PageProps { params: Promise<{ @@ -11,10 +13,19 @@ interface PageProps { }>; } +export async function generateMetadata({ params }: PageProps) { + const { frameworkInstanceId } = await params; + const res = await serverApi.get( + `/v1/frameworks/${frameworkInstanceId}`, + ); + const name = res.data?.framework?.name ?? 'Framework'; + return { title: `${name} Requirements` }; +} + export default async function FrameworkPage({ params }: PageProps) { const { orgId: organizationId, frameworkInstanceId } = await params; - const frameworkRes = await serverApi.get( + const frameworkRes = await serverApi.get( `/v1/frameworks/${frameworkInstanceId}`, ); @@ -45,6 +56,13 @@ export default async function FrameworkPage({ params }: PageProps) { requirementDefinitions={framework.requirementDefinitions || []} frameworkInstanceWithControls={frameworkInstanceWithControls} /> +
); diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/types.ts b/apps/app/src/app/(app)/[orgId]/frameworks/types.ts index a50e40e388..6ab9026a17 100644 --- a/apps/app/src/app/(app)/[orgId]/frameworks/types.ts +++ b/apps/app/src/app/(app)/[orgId]/frameworks/types.ts @@ -17,3 +17,43 @@ export type FrameworkInstanceWithControls = FrameworkInstance & { requirementsMapped: RequirementMap[]; })[]; }; + +export type RequirementMapWithControl = { + id: string; + requirementId: string | null; + frameworkInstanceRequirementId: string | null; + controlId: string; + frameworkInstanceId: string; + control: { + id: string; + name: string; + description: string; + tasks: { id: string; title: string; status: string }[]; + policies: { id: string; name: string; status: string }[]; + }; +}; + +export type InstanceRequirementWithMaps = { + id: string; + frameworkInstanceId: string; + name: string; + identifier: string; + description: string; + createdAt: string; + updatedAt: string; + requirementMaps: RequirementMapWithControl[]; +}; + +export type TemplateRequirement = { + id: string; + frameworkId: string; + name: string; + identifier: string; + description: string; +}; + +export type FrameworkInstanceDetail = FrameworkInstanceWithControls & { + requirementDefinitions: TemplateRequirement[]; + frameworkInstanceRequirements: InstanceRequirementWithMaps[]; + requirementMaps: RequirementMapWithControl[]; +}; From f34aa4df50537e02cf000503c169286e7c1be6e4 Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Tue, 24 Mar 2026 17:24:28 -0400 Subject: [PATCH 07/25] feat: add custom requirements list and create sheet components --- .../components/CreateRequirementSheet.tsx | 191 +++++++++++++++++ .../components/FrameworkRequirementsList.tsx | 195 ++++++++++++++++++ 2 files changed, 386 insertions(+) create mode 100644 apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/CreateRequirementSheet.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/FrameworkRequirementsList.tsx diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/CreateRequirementSheet.tsx b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/CreateRequirementSheet.tsx new file mode 100644 index 0000000000..d3dbb1168b --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/CreateRequirementSheet.tsx @@ -0,0 +1,191 @@ +'use client'; + +import { Button } from '@trycompai/ui/button'; +import { Drawer, DrawerContent, DrawerTitle } from '@trycompai/ui/drawer'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@trycompai/ui/form'; +import { useMediaQuery } from '@trycompai/ui/hooks'; +import { Input } from '@trycompai/ui/input'; +import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@trycompai/ui/sheet'; +import { Textarea } from '@trycompai/ui/textarea'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { ArrowRightIcon, X } from 'lucide-react'; +import { useCallback, useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { toast } from 'sonner'; +import { z } from 'zod'; +import { apiClient } from '@/lib/api-client'; + +const createRequirementSchema = z.object({ + name: z.string().min(1, { message: 'Name is required' }), + identifier: z.string().optional(), + description: z.string().min(1, { message: 'Description is required' }), +}); + +interface CreateRequirementSheetProps { + open: boolean; + onOpenChange: (open: boolean) => void; + frameworkInstanceId: string; + onCreated: () => void; +} + +export function CreateRequirementSheet({ + open, + onOpenChange, + frameworkInstanceId, + onCreated, +}: CreateRequirementSheetProps) { + const isDesktop = useMediaQuery('(min-width: 768px)'); + const [isSubmitting, setIsSubmitting] = useState(false); + + const form = useForm>({ + resolver: zodResolver(createRequirementSchema), + defaultValues: { + name: '', + identifier: '', + description: '', + }, + }); + + const onSubmit = useCallback( + async (data: z.infer) => { + setIsSubmitting(true); + try { + await apiClient.post('/v1/framework-instance-requirements', { + ...data, + frameworkInstanceId, + }); + toast.success('Requirement created'); + onOpenChange(false); + form.reset(); + onCreated(); + } catch { + toast.error('Failed to create requirement'); + } finally { + setIsSubmitting(false); + } + }, + [frameworkInstanceId, form, onOpenChange, onCreated], + ); + + const requirementForm = ( +
+ + ( + + Requirement Name + + + + + + )} + /> + + ( + + Identifier (Optional) + + + + + + )} + /> + + ( + + Description + +