From fccf989e42726ea78c926cbcadfdaa1e2cf4a5a9 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 21 Mar 2026 16:17:06 +0000 Subject: [PATCH 1/2] [dev] [carhartlewis] lewis/comp-framework-editor (#2355) * feat(framework-editor): add control, policy, and requirement templates with CRUD operations - Implemented ControlTemplate, PolicyTemplate, and Requirement modules, controllers, and services. - Added DTOs for creating and updating control and policy templates. - Integrated permission checks for all CRUD operations using @RequirePermission decorator. - Enhanced the framework editor with new endpoints for managing templates and requirements. * fix(control): drop documentTypes column from FrameworkEditorControlTemplate table - Added migration to remove the documentTypes column, which will result in data loss. - Updated Control model to include controlDocumentTypes relation. * feat(framework-editor): add documentTypes field to FrameworkEditorControlTemplate model - Introduced documentTypes field to the FrameworkEditorControlTemplate model to support evidence form types. - This addition enhances the framework editor's capability to manage document types associated with control templates. * feat(framework-editor): add Prisma schema and initial migration for Framework Editor - Introduced a new Prisma schema for the Framework Editor, defining models such as User, Attachment, and Session. - Added a migration to create the necessary database structure for the new schema. - This setup enhances the framework editor's capabilities for managing user sessions and attachments. * feat(framework-editor): enhance services with conflict handling and new validation - Added ConflictException handling in delete methods for ControlTemplateService, FrameworkEditorFrameworkService, and PolicyTemplateService to prevent deletion of referenced templates. - Updated data update logic in FrameworkEditorFrameworkService and PolicyTemplateService to conditionally include fields. - Introduced MaxJsonSize validator for policy content to enforce size limits on JSON input. - Enhanced UpdateTaskTemplateDto to include automationStatus field with validation. - Removed deprecated NuqsWrapper component and cleaned up unused search query parameters in DataTable component. * refactor(framework-editor): update controllers to use PlatformAdminGuard and remove RequirePermission decorators - Replaced HybridAuthGuard and PermissionGuard with PlatformAdminGuard in ControlTemplate, Framework, PolicyTemplate, Requirement, and TaskTemplate controllers. - Removed @RequirePermission decorators from various endpoints to streamline permission handling. - Updated session validation logic in layout and auth pages to check for admin role. - Adjusted authorization utility to enforce admin role requirement alongside internal user check. * feat(framework-editor): enhance templates with frameworkId support in CRUD operations - Updated ControlTemplate, PolicyTemplate, and TaskTemplate controllers and services to accept frameworkId as a query parameter for create and findAll methods. - Modified service logic to conditionally include requirements and control templates based on the provided frameworkId. - Enhanced CreateTaskTemplateDto to allow optional fields for better flexibility. - Updated API documentation to reflect the new frameworkId parameter in relevant endpoints. * feat(framework-editor): add linking functionality for controls, tasks, and policies - Implemented new endpoints in FrameworkEditorFrameworkController to link controls, tasks, and policies to frameworks. - Added corresponding service methods in FrameworkEditorFrameworkService for linking operations. - Introduced a new AddExistingItemDialog component for selecting and linking existing items in the UI. - Updated ControlTemplate, TaskTemplate, and PolicyTemplate services to include linked items in their data fetching logic. - Enhanced API documentation to reflect the new linking endpoints and their parameters. --------- Co-authored-by: Lewis Carhart --- apps/api/src/app.module.ts | 8 + apps/api/src/auth/auth.server.ts | 5 +- .../control-template.controller.ts | 112 + .../control-template.module.ts | 12 + .../control-template.service.ts | 160 + .../dto/create-control-template.dto.ts | 27 + .../dto/update-control-template.dto.ts | 6 + .../framework/dto/create-framework.dto.ts | 33 + .../framework/dto/update-framework.dto.ts | 4 + .../framework/framework.controller.ts | 103 + .../framework/framework.module.ts | 12 + .../framework/framework.service.ts | 246 ++ .../dto/create-policy-template.dto.ts | 29 + .../dto/update-policy-content.dto.ts | 11 + .../dto/update-policy-template.dto.ts | 6 + .../policy-template.controller.ts | 74 + .../policy-template/policy-template.module.ts | 12 + .../policy-template.service.ts | 115 + .../requirement/dto/create-requirement.dto.ts | 32 + .../requirement/dto/update-requirement.dto.ts | 22 + .../requirement/requirement.controller.ts | 55 + .../requirement/requirement.module.ts | 12 + .../requirement/requirement.service.ts | 81 + .../dto/create-task-template.dto.ts | 28 +- .../dto/update-task-template.dto.ts | 15 +- .../schemas/task-template-bodies.ts | 5 + .../schemas/task-template-operations.ts | 4 + .../task-template/task-template.controller.ts | 86 +- .../task-template/task-template.service.ts | 53 +- .../validators/max-json-size.validator.ts | 41 + .../lib/initialize-organization.ts | 44 +- .../[controlId]/components/DocumentsTable.tsx | 116 + .../components/CreateControlSheet.tsx | 12 +- .../app/(pages)/auth/Unauthorized.tsx | 36 + .../app/(pages)/auth/button-icon.tsx | 38 + .../app/(pages)/auth/google-sign-in.tsx | 40 + .../app/(pages)/auth/page.tsx | 61 + .../(pages)/controls/ControlsClientPage.tsx | 481 +++ .../(pages)/controls/document-type-options.ts | 20 + .../controls/hooks/useChangeTracking.ts | 280 ++ .../app/(pages)/controls/schemas.ts | 6 + .../app/(pages)/controls/types.ts | 72 + .../documents/DocumentControlsCell.tsx | 223 ++ .../(pages)/documents/DocumentsClientPage.tsx | 204 ++ .../frameworks/FrameworksClientPage.tsx | 46 + .../FrameworkRequirementsClientPage.tsx | 348 +++ .../[frameworkId]/FrameworkTabs.tsx | 37 + .../components/DeleteFrameworkDialog.tsx | 84 + .../components/EditFrameworkDialog.tsx | 199 ++ .../[frameworkId]/controls/page.tsx | 23 + .../[frameworkId]/documents/page.tsx | 27 + .../hooks/useRequirementChangeTracking.ts | 245 ++ .../frameworks/[frameworkId]/layout.tsx | 56 + .../(pages)/frameworks/[frameworkId]/page.tsx | 49 + .../[frameworkId]/policies/page.tsx | 30 + .../frameworks/[frameworkId]/tasks/page.tsx | 25 + .../components/CreateFrameworkDialog.tsx | 157 + .../(pages)/frameworks/components/columns.tsx | 77 + .../app/(pages)/frameworks/page.tsx | 13 + .../app/(pages)/frameworks/schemas.ts | 14 + apps/framework-editor/app/(pages)/layout.tsx | 3 + .../(pages)/policies/PoliciesClientPage.tsx | 90 + .../[policyId]/PolicyDetailsClientPage.tsx | 107 + .../[policyId]/PolicyEditorClient.tsx | 38 + .../components/DeletePolicyDialog.tsx | 73 + .../components/EditPolicyDialog.tsx | 200 ++ .../app/(pages)/policies/[policyId]/page.tsx | 52 + .../components/CreatePolicyDialog.tsx | 205 ++ .../(pages)/policies/components/columns.tsx | 49 + .../app/(pages)/policies/schemas.ts | 11 + .../app/(pages)/tasks/TasksClientPage.tsx | 441 +++ .../tasks/hooks/useTaskChangeTracking.ts | 318 ++ .../app/components/AddExistingItemDialog.tsx | 232 ++ .../app/components/DataTable.tsx | 171 ++ .../app/components/HeaderFrameworks.tsx | 17 + .../app/components/PageLayout.tsx | 74 + .../app/components/SearchAndLinkList.tsx | 210 ++ .../app/components/SearchableItemList.tsx | 77 + .../app/components/TableToolbar.tsx | 119 + .../app/components/editor/AdvancedEditor.tsx | 37 + .../app/components/editor/PolicyEditor.tsx | 62 + .../app/components/sign-out.tsx | 33 + .../app/components/table/DateCell.tsx | 18 + .../app/components/table/EditableCell.tsx | 75 + .../app/components/table/MarkdownCell.tsx | 165 ++ .../app/components/table/MultiSelectCell.tsx | 191 ++ .../app/components/table/RelationalCell.tsx | 245 ++ .../app/components/table/SelectCell.tsx | 94 + .../app/components/table/index.ts | 6 + .../app/components/user-menu-client.tsx | 63 + .../app/components/user-menu.tsx | 11 + .../app/hooks/useTableSearchSort.ts | 101 + apps/framework-editor/app/layout.tsx | 37 + apps/framework-editor/app/lib/api-client.ts | 18 + apps/framework-editor/app/lib/api-server.ts | 29 + apps/framework-editor/app/lib/auth-client.ts | 15 + apps/framework-editor/app/lib/auth.ts | 81 + apps/framework-editor/app/lib/utils.ts | 27 + apps/framework-editor/app/loading.tsx | 5 + apps/framework-editor/app/page.tsx | 12 + apps/framework-editor/app/types/common.ts | 15 + apps/framework-editor/next.config.mjs | 22 + apps/framework-editor/package.json | 52 + apps/framework-editor/postcss.config.mjs | 7 + apps/framework-editor/prisma/client.ts | 3 + apps/framework-editor/prisma/index.ts | 2 + apps/framework-editor/prisma/schema.prisma | 2607 +++++++++++++++++ apps/framework-editor/styles/editor.css | 2 + apps/framework-editor/styles/globals.css | 58 + apps/framework-editor/tailwind.config.ts | 11 + apps/framework-editor/tsconfig.json | 45 + bun.lock | 263 +- package.json | 20 +- .../migration.sql | 17 + .../migration.sql | 2 + .../migration.sql | 8 + .../migration.sql | 2 + .../schema/control-document-type.prisma | 9 + packages/db/prisma/schema/control.prisma | 1 + .../db/prisma/schema/framework-editor.prisma | 1 + packages/docs/openapi.json | 1264 +++++++- 121 files changed, 12525 insertions(+), 170 deletions(-) create mode 100644 apps/api/src/framework-editor/control-template/control-template.controller.ts create mode 100644 apps/api/src/framework-editor/control-template/control-template.module.ts create mode 100644 apps/api/src/framework-editor/control-template/control-template.service.ts create mode 100644 apps/api/src/framework-editor/control-template/dto/create-control-template.dto.ts create mode 100644 apps/api/src/framework-editor/control-template/dto/update-control-template.dto.ts create mode 100644 apps/api/src/framework-editor/framework/dto/create-framework.dto.ts create mode 100644 apps/api/src/framework-editor/framework/dto/update-framework.dto.ts create mode 100644 apps/api/src/framework-editor/framework/framework.controller.ts create mode 100644 apps/api/src/framework-editor/framework/framework.module.ts create mode 100644 apps/api/src/framework-editor/framework/framework.service.ts create mode 100644 apps/api/src/framework-editor/policy-template/dto/create-policy-template.dto.ts create mode 100644 apps/api/src/framework-editor/policy-template/dto/update-policy-content.dto.ts create mode 100644 apps/api/src/framework-editor/policy-template/dto/update-policy-template.dto.ts create mode 100644 apps/api/src/framework-editor/policy-template/policy-template.controller.ts create mode 100644 apps/api/src/framework-editor/policy-template/policy-template.module.ts create mode 100644 apps/api/src/framework-editor/policy-template/policy-template.service.ts create mode 100644 apps/api/src/framework-editor/requirement/dto/create-requirement.dto.ts create mode 100644 apps/api/src/framework-editor/requirement/dto/update-requirement.dto.ts create mode 100644 apps/api/src/framework-editor/requirement/requirement.controller.ts create mode 100644 apps/api/src/framework-editor/requirement/requirement.module.ts create mode 100644 apps/api/src/framework-editor/requirement/requirement.service.ts create mode 100644 apps/api/src/framework-editor/validators/max-json-size.validator.ts create mode 100644 apps/app/src/app/(app)/[orgId]/controls/[controlId]/components/DocumentsTable.tsx create mode 100644 apps/framework-editor/app/(pages)/auth/Unauthorized.tsx create mode 100644 apps/framework-editor/app/(pages)/auth/button-icon.tsx create mode 100644 apps/framework-editor/app/(pages)/auth/google-sign-in.tsx create mode 100644 apps/framework-editor/app/(pages)/auth/page.tsx create mode 100644 apps/framework-editor/app/(pages)/controls/ControlsClientPage.tsx create mode 100644 apps/framework-editor/app/(pages)/controls/document-type-options.ts create mode 100644 apps/framework-editor/app/(pages)/controls/hooks/useChangeTracking.ts create mode 100644 apps/framework-editor/app/(pages)/controls/schemas.ts create mode 100644 apps/framework-editor/app/(pages)/controls/types.ts create mode 100644 apps/framework-editor/app/(pages)/documents/DocumentControlsCell.tsx create mode 100644 apps/framework-editor/app/(pages)/documents/DocumentsClientPage.tsx create mode 100644 apps/framework-editor/app/(pages)/frameworks/FrameworksClientPage.tsx create mode 100644 apps/framework-editor/app/(pages)/frameworks/[frameworkId]/FrameworkRequirementsClientPage.tsx create mode 100644 apps/framework-editor/app/(pages)/frameworks/[frameworkId]/FrameworkTabs.tsx create mode 100644 apps/framework-editor/app/(pages)/frameworks/[frameworkId]/components/DeleteFrameworkDialog.tsx create mode 100644 apps/framework-editor/app/(pages)/frameworks/[frameworkId]/components/EditFrameworkDialog.tsx create mode 100644 apps/framework-editor/app/(pages)/frameworks/[frameworkId]/controls/page.tsx create mode 100644 apps/framework-editor/app/(pages)/frameworks/[frameworkId]/documents/page.tsx create mode 100644 apps/framework-editor/app/(pages)/frameworks/[frameworkId]/hooks/useRequirementChangeTracking.ts create mode 100644 apps/framework-editor/app/(pages)/frameworks/[frameworkId]/layout.tsx create mode 100644 apps/framework-editor/app/(pages)/frameworks/[frameworkId]/page.tsx create mode 100644 apps/framework-editor/app/(pages)/frameworks/[frameworkId]/policies/page.tsx create mode 100644 apps/framework-editor/app/(pages)/frameworks/[frameworkId]/tasks/page.tsx create mode 100644 apps/framework-editor/app/(pages)/frameworks/components/CreateFrameworkDialog.tsx create mode 100644 apps/framework-editor/app/(pages)/frameworks/components/columns.tsx create mode 100644 apps/framework-editor/app/(pages)/frameworks/page.tsx create mode 100644 apps/framework-editor/app/(pages)/frameworks/schemas.ts create mode 100644 apps/framework-editor/app/(pages)/layout.tsx create mode 100644 apps/framework-editor/app/(pages)/policies/PoliciesClientPage.tsx create mode 100644 apps/framework-editor/app/(pages)/policies/[policyId]/PolicyDetailsClientPage.tsx create mode 100644 apps/framework-editor/app/(pages)/policies/[policyId]/PolicyEditorClient.tsx create mode 100644 apps/framework-editor/app/(pages)/policies/[policyId]/components/DeletePolicyDialog.tsx create mode 100644 apps/framework-editor/app/(pages)/policies/[policyId]/components/EditPolicyDialog.tsx create mode 100644 apps/framework-editor/app/(pages)/policies/[policyId]/page.tsx create mode 100644 apps/framework-editor/app/(pages)/policies/components/CreatePolicyDialog.tsx create mode 100644 apps/framework-editor/app/(pages)/policies/components/columns.tsx create mode 100644 apps/framework-editor/app/(pages)/policies/schemas.ts create mode 100644 apps/framework-editor/app/(pages)/tasks/TasksClientPage.tsx create mode 100644 apps/framework-editor/app/(pages)/tasks/hooks/useTaskChangeTracking.ts create mode 100644 apps/framework-editor/app/components/AddExistingItemDialog.tsx create mode 100644 apps/framework-editor/app/components/DataTable.tsx create mode 100644 apps/framework-editor/app/components/HeaderFrameworks.tsx create mode 100644 apps/framework-editor/app/components/PageLayout.tsx create mode 100644 apps/framework-editor/app/components/SearchAndLinkList.tsx create mode 100644 apps/framework-editor/app/components/SearchableItemList.tsx create mode 100644 apps/framework-editor/app/components/TableToolbar.tsx create mode 100644 apps/framework-editor/app/components/editor/AdvancedEditor.tsx create mode 100644 apps/framework-editor/app/components/editor/PolicyEditor.tsx create mode 100644 apps/framework-editor/app/components/sign-out.tsx create mode 100644 apps/framework-editor/app/components/table/DateCell.tsx create mode 100644 apps/framework-editor/app/components/table/EditableCell.tsx create mode 100644 apps/framework-editor/app/components/table/MarkdownCell.tsx create mode 100644 apps/framework-editor/app/components/table/MultiSelectCell.tsx create mode 100644 apps/framework-editor/app/components/table/RelationalCell.tsx create mode 100644 apps/framework-editor/app/components/table/SelectCell.tsx create mode 100644 apps/framework-editor/app/components/table/index.ts create mode 100644 apps/framework-editor/app/components/user-menu-client.tsx create mode 100644 apps/framework-editor/app/components/user-menu.tsx create mode 100644 apps/framework-editor/app/hooks/useTableSearchSort.ts create mode 100644 apps/framework-editor/app/layout.tsx create mode 100644 apps/framework-editor/app/lib/api-client.ts create mode 100644 apps/framework-editor/app/lib/api-server.ts create mode 100644 apps/framework-editor/app/lib/auth-client.ts create mode 100644 apps/framework-editor/app/lib/auth.ts create mode 100644 apps/framework-editor/app/lib/utils.ts create mode 100644 apps/framework-editor/app/loading.tsx create mode 100644 apps/framework-editor/app/page.tsx create mode 100644 apps/framework-editor/app/types/common.ts create mode 100644 apps/framework-editor/next.config.mjs create mode 100644 apps/framework-editor/package.json create mode 100644 apps/framework-editor/postcss.config.mjs create mode 100644 apps/framework-editor/prisma/client.ts create mode 100644 apps/framework-editor/prisma/index.ts create mode 100644 apps/framework-editor/prisma/schema.prisma create mode 100644 apps/framework-editor/styles/editor.css create mode 100644 apps/framework-editor/styles/globals.css create mode 100644 apps/framework-editor/tailwind.config.ts create mode 100644 apps/framework-editor/tsconfig.json create mode 100644 packages/db/prisma/migrations/20260320194531_add_control_document_type/migration.sql create mode 100644 packages/db/prisma/migrations/20260320200656_add_document_types_to_control_template/migration.sql create mode 100644 packages/db/prisma/migrations/20260321151529_control_types/migration.sql create mode 100644 packages/db/prisma/migrations/20260321151753_framework_editor/migration.sql create mode 100644 packages/db/prisma/schema/control-document-type.prisma diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index aa2050fa47..3bc246e5ac 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -21,6 +21,10 @@ import { EvidenceExportModule } from './tasks/evidence-export/evidence-export.mo import { VendorsModule } from './vendors/vendors.module'; import { ContextModule } from './context/context.module'; import { TrustPortalModule } from './trust-portal/trust-portal.module'; +import { ControlTemplateModule } from './framework-editor/control-template/control-template.module'; +import { FrameworkEditorFrameworkModule } from './framework-editor/framework/framework.module'; +import { PolicyTemplateModule } from './framework-editor/policy-template/policy-template.module'; +import { RequirementModule } from './framework-editor/requirement/requirement.module'; import { TaskTemplateModule } from './framework-editor/task-template/task-template.module'; import { FindingTemplateModule } from './finding-template/finding-template.module'; import { FindingsModule } from './findings/findings.module'; @@ -78,6 +82,10 @@ import { AdminOrganizationsModule } from './admin-organizations/admin-organizati CommentsModule, HealthModule, TrustPortalModule, + ControlTemplateModule, + FrameworkEditorFrameworkModule, + PolicyTemplateModule, + RequirementModule, TaskTemplateModule, FindingTemplateModule, FindingsModule, diff --git a/apps/api/src/auth/auth.server.ts b/apps/api/src/auth/auth.server.ts index 66dd8cf567..a1205a852d 100644 --- a/apps/api/src/auth/auth.server.ts +++ b/apps/api/src/auth/auth.server.ts @@ -16,6 +16,7 @@ import { import { ac, allRoles } from '@trycompai/auth'; import { createAuthMiddleware } from 'better-auth/api'; import { Redis } from '@upstash/redis'; +import type { AccessControl } from 'better-auth/plugins/access'; const MAGIC_LINK_EXPIRES_IN_SECONDS = 60 * 60; // 1 hour @@ -47,6 +48,7 @@ export function getTrustedOrigins(): string[] { 'http://localhost:3000', 'http://localhost:3002', 'http://localhost:3333', + 'http://localhost:3004', 'https://app.trycomp.ai', 'https://portal.trycomp.ai', 'https://api.trycomp.ai', @@ -54,6 +56,7 @@ export function getTrustedOrigins(): string[] { 'https://portal.staging.trycomp.ai', 'https://api.staging.trycomp.ai', 'https://dev.trycomp.ai', + 'https://framework-editor.trycomp.ai', ]; } @@ -414,7 +417,7 @@ export const auth = betterAuth({ }), }); }, - ac, + ac: ac as AccessControl, roles: allRoles, // Enable dynamic access control for custom roles // This allows organizations to create custom roles at runtime diff --git a/apps/api/src/framework-editor/control-template/control-template.controller.ts b/apps/api/src/framework-editor/control-template/control-template.controller.ts new file mode 100644 index 0000000000..08f5506618 --- /dev/null +++ b/apps/api/src/framework-editor/control-template/control-template.controller.ts @@ -0,0 +1,112 @@ +import { + Controller, + Get, + Post, + Patch, + Delete, + Body, + Param, + Query, + UseGuards, + UsePipes, + ValidationPipe, +} from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { PlatformAdminGuard } from '../../auth/platform-admin.guard'; +import { CreateControlTemplateDto } from './dto/create-control-template.dto'; +import { UpdateControlTemplateDto } from './dto/update-control-template.dto'; +import { ControlTemplateService } from './control-template.service'; + +@ApiTags('Framework Editor Control Templates') +@Controller({ path: 'framework-editor/control-template', version: '1' }) +@UseGuards(PlatformAdminGuard) +export class ControlTemplateController { + constructor(private readonly service: ControlTemplateService) {} + + @Get() + async findAll( + @Query('take') take?: string, + @Query('skip') skip?: string, + @Query('frameworkId') frameworkId?: string, + ) { + const limit = Math.min(Number(take) || 500, 500); + const offset = Number(skip) || 0; + return this.service.findAll(limit, offset, frameworkId); + } + + @Get(':id') + async findOne(@Param('id') id: string) { + return this.service.findById(id); + } + + @Post() + @UsePipes(new ValidationPipe({ whitelist: true, transform: true })) + async create( + @Body() dto: CreateControlTemplateDto, + @Query('frameworkId') frameworkId?: string, + ) { + return this.service.create(dto, frameworkId); + } + + @Patch(':id') + @UsePipes(new ValidationPipe({ whitelist: true, transform: true })) + async update( + @Param('id') id: string, + @Body() dto: UpdateControlTemplateDto, + ) { + return this.service.update(id, dto); + } + + @Delete(':id') + async delete(@Param('id') id: string) { + return this.service.delete(id); + } + + @Post(':id/requirements/:reqId') + async linkRequirement( + @Param('id') id: string, + @Param('reqId') reqId: string, + ) { + return this.service.linkRequirement(id, reqId); + } + + @Delete(':id/requirements/:reqId') + async unlinkRequirement( + @Param('id') id: string, + @Param('reqId') reqId: string, + ) { + return this.service.unlinkRequirement(id, reqId); + } + + @Post(':id/policy-templates/:ptId') + async linkPolicyTemplate( + @Param('id') id: string, + @Param('ptId') ptId: string, + ) { + return this.service.linkPolicyTemplate(id, ptId); + } + + @Delete(':id/policy-templates/:ptId') + async unlinkPolicyTemplate( + @Param('id') id: string, + @Param('ptId') ptId: string, + ) { + return this.service.unlinkPolicyTemplate(id, ptId); + } + + @Post(':id/task-templates/:ttId') + async linkTaskTemplate( + @Param('id') id: string, + @Param('ttId') ttId: string, + ) { + return this.service.linkTaskTemplate(id, ttId); + } + + @Delete(':id/task-templates/:ttId') + async unlinkTaskTemplate( + @Param('id') id: string, + @Param('ttId') ttId: string, + ) { + return this.service.unlinkTaskTemplate(id, ttId); + } +} diff --git a/apps/api/src/framework-editor/control-template/control-template.module.ts b/apps/api/src/framework-editor/control-template/control-template.module.ts new file mode 100644 index 0000000000..22828277da --- /dev/null +++ b/apps/api/src/framework-editor/control-template/control-template.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { AuthModule } from '../../auth/auth.module'; +import { ControlTemplateController } from './control-template.controller'; +import { ControlTemplateService } from './control-template.service'; + +@Module({ + imports: [AuthModule], + controllers: [ControlTemplateController], + providers: [ControlTemplateService], + exports: [ControlTemplateService], +}) +export class ControlTemplateModule {} diff --git a/apps/api/src/framework-editor/control-template/control-template.service.ts b/apps/api/src/framework-editor/control-template/control-template.service.ts new file mode 100644 index 0000000000..12c8ef0253 --- /dev/null +++ b/apps/api/src/framework-editor/control-template/control-template.service.ts @@ -0,0 +1,160 @@ +import { Injectable, NotFoundException, ConflictException, Logger } from '@nestjs/common'; +import { db, Prisma } from '@trycompai/db'; +import type { EvidenceFormType } from '@trycompai/db'; +import { CreateControlTemplateDto } from './dto/create-control-template.dto'; +import { UpdateControlTemplateDto } from './dto/update-control-template.dto'; + +@Injectable() +export class ControlTemplateService { + private readonly logger = new Logger(ControlTemplateService.name); + + async findAll(take = 500, skip = 0, frameworkId?: string) { + return db.frameworkEditorControlTemplate.findMany({ + take, + skip, + orderBy: { createdAt: 'asc' }, + where: frameworkId + ? { requirements: { some: { frameworkId } } } + : undefined, + include: { + policyTemplates: { select: { id: true, name: true } }, + requirements: { + select: { + id: true, + name: true, + framework: { select: { name: true } }, + }, + }, + taskTemplates: { select: { id: true, name: true } }, + }, + }); + } + + async findById(id: string) { + const ct = await db.frameworkEditorControlTemplate.findUnique({ + where: { id }, + include: { + policyTemplates: { select: { id: true, name: true } }, + requirements: { + select: { + id: true, + name: true, + framework: { select: { name: true } }, + }, + }, + taskTemplates: { select: { id: true, name: true } }, + }, + }); + if (!ct) throw new NotFoundException(`Control template ${id} not found`); + return ct; + } + + async create(dto: CreateControlTemplateDto, frameworkId?: string) { + const requirementIds = frameworkId + ? await db.frameworkEditorRequirement + .findMany({ + where: { frameworkId }, + select: { id: true }, + }) + .then((reqs) => reqs.map((r) => ({ id: r.id }))) + : []; + + const ct = await db.frameworkEditorControlTemplate.create({ + data: { + name: dto.name, + description: dto.description ?? '', + ...(dto.documentTypes && { + documentTypes: dto.documentTypes as EvidenceFormType[], + }), + ...(requirementIds.length > 0 && { + requirements: { connect: requirementIds }, + }), + }, + }); + this.logger.log(`Created control template: ${ct.name} (${ct.id})`); + return ct; + } + + async update(id: string, dto: UpdateControlTemplateDto) { + await this.findById(id); + const updated = await db.frameworkEditorControlTemplate.update({ + where: { id }, + data: { + ...(dto.name !== undefined && { name: dto.name }), + ...(dto.description !== undefined && { description: dto.description }), + ...(dto.documentTypes !== undefined && { + documentTypes: dto.documentTypes as EvidenceFormType[], + }), + }, + }); + this.logger.log(`Updated control template: ${updated.name} (${id})`); + return updated; + } + + async delete(id: string) { + await this.findById(id); + try { + await db.frameworkEditorControlTemplate.delete({ where: { id } }); + } catch (error) { + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === 'P2003' + ) { + throw new ConflictException( + 'Cannot delete control template: it is referenced by existing controls', + ); + } + throw error; + } + this.logger.log(`Deleted control template ${id}`); + return { message: 'Control template deleted successfully' }; + } + + async linkRequirement(controlId: string, requirementId: string) { + await db.frameworkEditorControlTemplate.update({ + where: { id: controlId }, + data: { requirements: { connect: { id: requirementId } } }, + }); + return { message: 'Requirement linked' }; + } + + async unlinkRequirement(controlId: string, requirementId: string) { + await db.frameworkEditorControlTemplate.update({ + where: { id: controlId }, + data: { requirements: { disconnect: { id: requirementId } } }, + }); + return { message: 'Requirement unlinked' }; + } + + async linkPolicyTemplate(controlId: string, policyTemplateId: string) { + await db.frameworkEditorControlTemplate.update({ + where: { id: controlId }, + data: { policyTemplates: { connect: { id: policyTemplateId } } }, + }); + return { message: 'Policy template linked' }; + } + + async unlinkPolicyTemplate(controlId: string, policyTemplateId: string) { + await db.frameworkEditorControlTemplate.update({ + where: { id: controlId }, + data: { policyTemplates: { disconnect: { id: policyTemplateId } } }, + }); + return { message: 'Policy template unlinked' }; + } + + async linkTaskTemplate(controlId: string, taskTemplateId: string) { + await db.frameworkEditorControlTemplate.update({ + where: { id: controlId }, + data: { taskTemplates: { connect: { id: taskTemplateId } } }, + }); + return { message: 'Task template linked' }; + } + + async unlinkTaskTemplate(controlId: string, taskTemplateId: string) { + await db.frameworkEditorControlTemplate.update({ + where: { id: controlId }, + data: { taskTemplates: { disconnect: { id: taskTemplateId } } }, + }); + return { message: 'Task template unlinked' }; + } +} diff --git a/apps/api/src/framework-editor/control-template/dto/create-control-template.dto.ts b/apps/api/src/framework-editor/control-template/dto/create-control-template.dto.ts new file mode 100644 index 0000000000..d83d20c310 --- /dev/null +++ b/apps/api/src/framework-editor/control-template/dto/create-control-template.dto.ts @@ -0,0 +1,27 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + IsString, + IsNotEmpty, + IsArray, + IsOptional, + MaxLength, +} from 'class-validator'; + +export class CreateControlTemplateDto { + @ApiProperty({ example: 'Access Control Policy' }) + @IsString() + @IsNotEmpty() + @MaxLength(255) + name: string; + + @ApiProperty({ example: 'Ensures access controls are properly managed' }) + @IsString() + @MaxLength(5000) + description: string; + + @ApiPropertyOptional({ example: ['penetration-test', 'rbac-matrix'] }) + @IsArray() + @IsString({ each: true }) + @IsOptional() + documentTypes?: string[]; +} diff --git a/apps/api/src/framework-editor/control-template/dto/update-control-template.dto.ts b/apps/api/src/framework-editor/control-template/dto/update-control-template.dto.ts new file mode 100644 index 0000000000..0c6eebce3e --- /dev/null +++ b/apps/api/src/framework-editor/control-template/dto/update-control-template.dto.ts @@ -0,0 +1,6 @@ +import { PartialType } from '@nestjs/swagger'; +import { CreateControlTemplateDto } from './create-control-template.dto'; + +export class UpdateControlTemplateDto extends PartialType( + CreateControlTemplateDto, +) {} diff --git a/apps/api/src/framework-editor/framework/dto/create-framework.dto.ts b/apps/api/src/framework-editor/framework/dto/create-framework.dto.ts new file mode 100644 index 0000000000..23d5263624 --- /dev/null +++ b/apps/api/src/framework-editor/framework/dto/create-framework.dto.ts @@ -0,0 +1,33 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + IsString, + IsNotEmpty, + IsBoolean, + IsOptional, + MaxLength, +} from 'class-validator'; + +export class CreateFrameworkDto { + @ApiProperty({ example: 'SOC 2' }) + @IsString() + @IsNotEmpty() + @MaxLength(255) + name: string; + + @ApiProperty({ example: '2024' }) + @IsString() + @IsNotEmpty() + @MaxLength(50) + version: string; + + @ApiProperty({ example: 'SOC 2 Type II compliance framework' }) + @IsString() + @IsNotEmpty() + @MaxLength(2000) + description: string; + + @ApiPropertyOptional({ example: false }) + @IsBoolean() + @IsOptional() + visible?: boolean; +} diff --git a/apps/api/src/framework-editor/framework/dto/update-framework.dto.ts b/apps/api/src/framework-editor/framework/dto/update-framework.dto.ts new file mode 100644 index 0000000000..9751485984 --- /dev/null +++ b/apps/api/src/framework-editor/framework/dto/update-framework.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/swagger'; +import { CreateFrameworkDto } from './create-framework.dto'; + +export class UpdateFrameworkDto extends PartialType(CreateFrameworkDto) {} diff --git a/apps/api/src/framework-editor/framework/framework.controller.ts b/apps/api/src/framework-editor/framework/framework.controller.ts new file mode 100644 index 0000000000..0805a5b040 --- /dev/null +++ b/apps/api/src/framework-editor/framework/framework.controller.ts @@ -0,0 +1,103 @@ +import { + Controller, + Get, + Post, + Patch, + Delete, + Body, + Param, + Query, + UseGuards, + UsePipes, + ValidationPipe, +} from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { PlatformAdminGuard } from '../../auth/platform-admin.guard'; +import { CreateFrameworkDto } from './dto/create-framework.dto'; +import { UpdateFrameworkDto } from './dto/update-framework.dto'; +import { FrameworkEditorFrameworkService } from './framework.service'; + +@ApiTags('Framework Editor Frameworks') +@Controller({ path: 'framework-editor/framework', version: '1' }) +@UseGuards(PlatformAdminGuard) +export class FrameworkEditorFrameworkController { + constructor( + private readonly frameworkService: FrameworkEditorFrameworkService, + ) {} + + @Get() + async findAll( + @Query('take') take?: string, + @Query('skip') skip?: string, + ) { + const limit = Math.min(Number(take) || 500, 500); + const offset = Number(skip) || 0; + return this.frameworkService.findAll(limit, offset); + } + + @Get(':id') + async findById(@Param('id') id: string) { + return this.frameworkService.findById(id); + } + + @Post() + @UsePipes(new ValidationPipe({ whitelist: true, transform: true })) + async create(@Body() dto: CreateFrameworkDto) { + return this.frameworkService.create(dto); + } + + @Patch(':id') + @UsePipes(new ValidationPipe({ whitelist: true, transform: true })) + async update(@Param('id') id: string, @Body() dto: UpdateFrameworkDto) { + return this.frameworkService.update(id, dto); + } + + @Delete(':id') + async delete(@Param('id') id: string) { + return this.frameworkService.delete(id); + } + + @Get(':id/controls') + async getControls(@Param('id') id: string) { + return this.frameworkService.getControls(id); + } + + @Get(':id/policies') + async getPolicies(@Param('id') id: string) { + return this.frameworkService.getPolicies(id); + } + + @Get(':id/tasks') + async getTasks(@Param('id') id: string) { + return this.frameworkService.getTasks(id); + } + + @Get(':id/documents') + async getDocuments(@Param('id') id: string) { + return this.frameworkService.getDocuments(id); + } + + @Post(':id/link-control/:controlId') + async linkControl( + @Param('id') id: string, + @Param('controlId') controlId: string, + ) { + return this.frameworkService.linkControl(id, controlId); + } + + @Post(':id/link-task/:taskId') + async linkTask( + @Param('id') id: string, + @Param('taskId') taskId: string, + ) { + return this.frameworkService.linkTask(id, taskId); + } + + @Post(':id/link-policy/:policyId') + async linkPolicy( + @Param('id') id: string, + @Param('policyId') policyId: string, + ) { + return this.frameworkService.linkPolicy(id, policyId); + } +} diff --git a/apps/api/src/framework-editor/framework/framework.module.ts b/apps/api/src/framework-editor/framework/framework.module.ts new file mode 100644 index 0000000000..45b39638a1 --- /dev/null +++ b/apps/api/src/framework-editor/framework/framework.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { AuthModule } from '../../auth/auth.module'; +import { FrameworkEditorFrameworkController } from './framework.controller'; +import { FrameworkEditorFrameworkService } from './framework.service'; + +@Module({ + imports: [AuthModule], + controllers: [FrameworkEditorFrameworkController], + providers: [FrameworkEditorFrameworkService], + exports: [FrameworkEditorFrameworkService], +}) +export class FrameworkEditorFrameworkModule {} diff --git a/apps/api/src/framework-editor/framework/framework.service.ts b/apps/api/src/framework-editor/framework/framework.service.ts new file mode 100644 index 0000000000..bfa2388a1e --- /dev/null +++ b/apps/api/src/framework-editor/framework/framework.service.ts @@ -0,0 +1,246 @@ +import { Injectable, NotFoundException, ConflictException, Logger } from '@nestjs/common'; +import { db, Prisma } from '@trycompai/db'; +import { CreateFrameworkDto } from './dto/create-framework.dto'; +import { UpdateFrameworkDto } from './dto/update-framework.dto'; + +@Injectable() +export class FrameworkEditorFrameworkService { + private readonly logger = new Logger(FrameworkEditorFrameworkService.name); + + async findAll(take = 500, skip = 0) { + const frameworks = await db.frameworkEditorFramework.findMany({ + take, + skip, + orderBy: { name: 'asc' }, + include: { + _count: { select: { requirements: true } }, + requirements: { + select: { _count: { select: { controlTemplates: true } } }, + }, + }, + }); + + return frameworks.map((fw) => ({ + ...fw, + requirementsCount: fw._count.requirements, + controlsCount: fw.requirements.reduce( + (sum, r) => sum + r._count.controlTemplates, + 0, + ), + _count: undefined, + requirements: undefined, + })); + } + + async findById(id: string) { + const framework = await db.frameworkEditorFramework.findUnique({ + where: { id }, + include: { + requirements: { + orderBy: { name: 'asc' }, + include: { + controlTemplates: { select: { id: true, name: true } }, + }, + }, + }, + }); + + if (!framework) { + throw new NotFoundException(`Framework ${id} not found`); + } + + return framework; + } + + async create(dto: CreateFrameworkDto) { + const framework = await db.frameworkEditorFramework.create({ + data: { + name: dto.name, + version: dto.version, + description: dto.description, + visible: dto.visible ?? false, + }, + }); + + this.logger.log(`Created framework: ${framework.name} (${framework.id})`); + return framework; + } + + async update(id: string, dto: UpdateFrameworkDto) { + await this.findById(id); + + const updated = await db.frameworkEditorFramework.update({ + where: { id }, + data: { + ...(dto.name !== undefined && { name: dto.name }), + ...(dto.version !== undefined && { version: dto.version }), + ...(dto.description !== undefined && { description: dto.description }), + ...(dto.visible !== undefined && { visible: dto.visible }), + }, + }); + + this.logger.log(`Updated framework: ${updated.name} (${id})`); + return updated; + } + + async delete(id: string) { + await this.findById(id); + + try { + await db.$transaction([ + db.frameworkEditorRequirement.deleteMany({ + where: { frameworkId: id }, + }), + db.frameworkEditorFramework.delete({ where: { id } }), + ]); + } catch (error) { + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === 'P2003' + ) { + throw new ConflictException( + 'Cannot delete framework: it is referenced by existing framework instances', + ); + } + throw error; + } + + this.logger.log(`Deleted framework ${id}`); + return { message: 'Framework deleted successfully' }; + } + + async getControls(frameworkId: string) { + await this.findById(frameworkId); + + return db.frameworkEditorControlTemplate.findMany({ + where: { requirements: { some: { frameworkId } } }, + include: { + policyTemplates: { select: { id: true, name: true } }, + requirements: { + select: { + id: true, + name: true, + framework: { select: { name: true } }, + }, + }, + taskTemplates: { select: { id: true, name: true } }, + }, + orderBy: { createdAt: 'asc' }, + }); + } + + async getPolicies(frameworkId: string) { + await this.findById(frameworkId); + + return db.frameworkEditorPolicyTemplate.findMany({ + where: { + controlTemplates: { + some: { requirements: { some: { frameworkId } } }, + }, + }, + orderBy: { name: 'asc' }, + }); + } + + async getTasks(frameworkId: string) { + await this.findById(frameworkId); + + return db.frameworkEditorTaskTemplate.findMany({ + where: { + controlTemplates: { + some: { requirements: { some: { frameworkId } } }, + }, + }, + include: { + controlTemplates: { select: { id: true, name: true } }, + }, + orderBy: { name: 'asc' }, + }); + } + + async getDocuments(frameworkId: string) { + await this.findById(frameworkId); + + return db.frameworkEditorControlTemplate.findMany({ + where: { requirements: { some: { frameworkId } } }, + select: { id: true, name: true, documentTypes: true }, + orderBy: { name: 'asc' }, + }); + } + + async linkControl(frameworkId: string, controlId: string) { + await this.findById(frameworkId); + + const requirementIds = await db.frameworkEditorRequirement + .findMany({ where: { frameworkId }, select: { id: true } }) + .then((reqs) => reqs.map((r) => ({ id: r.id }))); + + if (requirementIds.length === 0) { + throw new ConflictException( + 'Framework has no requirements to link the control to', + ); + } + + await db.frameworkEditorControlTemplate.update({ + where: { id: controlId }, + data: { requirements: { connect: requirementIds } }, + }); + + this.logger.log( + `Linked control ${controlId} to framework ${frameworkId}`, + ); + return { message: 'Control linked to framework' }; + } + + async linkTask(frameworkId: string, taskId: string) { + await this.findById(frameworkId); + + const controlIds = await db.frameworkEditorControlTemplate + .findMany({ + where: { requirements: { some: { frameworkId } } }, + select: { id: true }, + }) + .then((cts) => cts.map((ct) => ({ id: ct.id }))); + + if (controlIds.length === 0) { + throw new ConflictException( + 'Framework has no controls to link the task to', + ); + } + + await db.frameworkEditorTaskTemplate.update({ + where: { id: taskId }, + data: { controlTemplates: { connect: controlIds } }, + }); + + this.logger.log(`Linked task ${taskId} to framework ${frameworkId}`); + return { message: 'Task linked to framework' }; + } + + async linkPolicy(frameworkId: string, policyId: string) { + await this.findById(frameworkId); + + const controlIds = await db.frameworkEditorControlTemplate + .findMany({ + where: { requirements: { some: { frameworkId } } }, + select: { id: true }, + }) + .then((cts) => cts.map((ct) => ({ id: ct.id }))); + + if (controlIds.length === 0) { + throw new ConflictException( + 'Framework has no controls to link the policy to', + ); + } + + await db.frameworkEditorPolicyTemplate.update({ + where: { id: policyId }, + data: { controlTemplates: { connect: controlIds } }, + }); + + this.logger.log( + `Linked policy ${policyId} to framework ${frameworkId}`, + ); + return { message: 'Policy linked to framework' }; + } +} diff --git a/apps/api/src/framework-editor/policy-template/dto/create-policy-template.dto.ts b/apps/api/src/framework-editor/policy-template/dto/create-policy-template.dto.ts new file mode 100644 index 0000000000..a7d87c6c7f --- /dev/null +++ b/apps/api/src/framework-editor/policy-template/dto/create-policy-template.dto.ts @@ -0,0 +1,29 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { + IsString, + IsNotEmpty, + IsEnum, + MaxLength, +} from 'class-validator'; +import { Frequency, Departments } from '@trycompai/db'; + +export class CreatePolicyTemplateDto { + @ApiProperty({ example: 'Information Security Policy' }) + @IsString() + @IsNotEmpty() + @MaxLength(255) + name: string; + + @ApiProperty({ example: 'Establishes information security practices' }) + @IsString() + @MaxLength(5000) + description: string; + + @ApiProperty({ enum: Frequency, example: 'annually' }) + @IsEnum(Frequency) + frequency: Frequency; + + @ApiProperty({ enum: Departments, example: 'it' }) + @IsEnum(Departments) + department: Departments; +} diff --git a/apps/api/src/framework-editor/policy-template/dto/update-policy-content.dto.ts b/apps/api/src/framework-editor/policy-template/dto/update-policy-content.dto.ts new file mode 100644 index 0000000000..cfbd6a6514 --- /dev/null +++ b/apps/api/src/framework-editor/policy-template/dto/update-policy-content.dto.ts @@ -0,0 +1,11 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsObject } from 'class-validator'; +import { MaxJsonSize } from '../../validators/max-json-size.validator'; + +export class UpdatePolicyContentDto { + @ApiProperty({ description: 'TipTap JSON content for the policy document' }) + @IsNotEmpty() + @IsObject() + @MaxJsonSize() + content: Record; +} diff --git a/apps/api/src/framework-editor/policy-template/dto/update-policy-template.dto.ts b/apps/api/src/framework-editor/policy-template/dto/update-policy-template.dto.ts new file mode 100644 index 0000000000..1b0eeb692c --- /dev/null +++ b/apps/api/src/framework-editor/policy-template/dto/update-policy-template.dto.ts @@ -0,0 +1,6 @@ +import { PartialType } from '@nestjs/swagger'; +import { CreatePolicyTemplateDto } from './create-policy-template.dto'; + +export class UpdatePolicyTemplateDto extends PartialType( + CreatePolicyTemplateDto, +) {} diff --git a/apps/api/src/framework-editor/policy-template/policy-template.controller.ts b/apps/api/src/framework-editor/policy-template/policy-template.controller.ts new file mode 100644 index 0000000000..6898361f76 --- /dev/null +++ b/apps/api/src/framework-editor/policy-template/policy-template.controller.ts @@ -0,0 +1,74 @@ +import { + Controller, + Get, + Post, + Patch, + Delete, + Body, + Param, + Query, + UseGuards, + UsePipes, + ValidationPipe, +} from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { PlatformAdminGuard } from '../../auth/platform-admin.guard'; +import { CreatePolicyTemplateDto } from './dto/create-policy-template.dto'; +import { UpdatePolicyContentDto } from './dto/update-policy-content.dto'; +import { UpdatePolicyTemplateDto } from './dto/update-policy-template.dto'; +import { PolicyTemplateService } from './policy-template.service'; + +@ApiTags('Framework Editor Policy Templates') +@Controller({ path: 'framework-editor/policy-template', version: '1' }) +@UseGuards(PlatformAdminGuard) +export class PolicyTemplateController { + constructor(private readonly service: PolicyTemplateService) {} + + @Get() + async findAll( + @Query('take') take?: string, + @Query('skip') skip?: string, + @Query('frameworkId') frameworkId?: string, + ) { + const limit = Math.min(Number(take) || 500, 500); + const offset = Number(skip) || 0; + return this.service.findAll(limit, offset, frameworkId); + } + + @Get(':id') + async findById(@Param('id') id: string) { + return this.service.findById(id); + } + + @Post() + @UsePipes(new ValidationPipe({ whitelist: true, transform: true })) + async create( + @Body() dto: CreatePolicyTemplateDto, + @Query('frameworkId') frameworkId?: string, + ) { + return this.service.create(dto, frameworkId); + } + + @Patch(':id') + @UsePipes(new ValidationPipe({ whitelist: true, transform: true })) + async update( + @Param('id') id: string, + @Body() dto: UpdatePolicyTemplateDto, + ) { + return this.service.update(id, dto); + } + + @Patch(':id/content') + @UsePipes(new ValidationPipe({ whitelist: true, transform: true })) + async updateContent( + @Param('id') id: string, + @Body() dto: UpdatePolicyContentDto, + ) { + return this.service.updateContent(id, dto.content); + } + + @Delete(':id') + async delete(@Param('id') id: string) { + return this.service.delete(id); + } +} diff --git a/apps/api/src/framework-editor/policy-template/policy-template.module.ts b/apps/api/src/framework-editor/policy-template/policy-template.module.ts new file mode 100644 index 0000000000..066fc14b51 --- /dev/null +++ b/apps/api/src/framework-editor/policy-template/policy-template.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { AuthModule } from '../../auth/auth.module'; +import { PolicyTemplateController } from './policy-template.controller'; +import { PolicyTemplateService } from './policy-template.service'; + +@Module({ + imports: [AuthModule], + controllers: [PolicyTemplateController], + providers: [PolicyTemplateService], + exports: [PolicyTemplateService], +}) +export class PolicyTemplateModule {} diff --git a/apps/api/src/framework-editor/policy-template/policy-template.service.ts b/apps/api/src/framework-editor/policy-template/policy-template.service.ts new file mode 100644 index 0000000000..ec7fce47c9 --- /dev/null +++ b/apps/api/src/framework-editor/policy-template/policy-template.service.ts @@ -0,0 +1,115 @@ +import { Injectable, NotFoundException, ConflictException, Logger } from '@nestjs/common'; +import { db, Prisma } from '@trycompai/db'; +import { CreatePolicyTemplateDto } from './dto/create-policy-template.dto'; +import { UpdatePolicyTemplateDto } from './dto/update-policy-template.dto'; + +@Injectable() +export class PolicyTemplateService { + private readonly logger = new Logger(PolicyTemplateService.name); + + async findAll(take = 500, skip = 0, frameworkId?: string) { + return db.frameworkEditorPolicyTemplate.findMany({ + take, + skip, + orderBy: { name: 'asc' }, + where: frameworkId + ? { + controlTemplates: { + some: { requirements: { some: { frameworkId } } }, + }, + } + : undefined, + include: { + controlTemplates: { + select: { + id: true, + name: true, + requirements: { + select: { + framework: { select: { id: true, name: true } }, + }, + }, + }, + }, + }, + }); + } + + async findById(id: string) { + const pt = await db.frameworkEditorPolicyTemplate.findUnique({ + where: { id }, + }); + if (!pt) throw new NotFoundException(`Policy template ${id} not found`); + return pt; + } + + async create(dto: CreatePolicyTemplateDto, frameworkId?: string) { + const controlIds = frameworkId + ? await db.frameworkEditorControlTemplate + .findMany({ + where: { requirements: { some: { frameworkId } } }, + select: { id: true }, + }) + .then((cts) => cts.map((ct) => ({ id: ct.id }))) + : []; + + const pt = await db.frameworkEditorPolicyTemplate.create({ + data: { + name: dto.name, + description: dto.description ?? '', + frequency: dto.frequency, + department: dto.department, + content: {}, + ...(controlIds.length > 0 && { + controlTemplates: { connect: controlIds }, + }), + }, + }); + this.logger.log(`Created policy template: ${pt.name} (${pt.id})`); + return pt; + } + + async update(id: string, dto: UpdatePolicyTemplateDto) { + await this.findById(id); + const updated = await db.frameworkEditorPolicyTemplate.update({ + where: { id }, + data: { + ...(dto.name !== undefined && { name: dto.name }), + ...(dto.description !== undefined && { description: dto.description }), + ...(dto.frequency !== undefined && { frequency: dto.frequency }), + ...(dto.department !== undefined && { department: dto.department }), + }, + }); + this.logger.log(`Updated policy template: ${updated.name} (${id})`); + return updated; + } + + async updateContent(id: string, content: Record) { + await this.findById(id); + const updated = await db.frameworkEditorPolicyTemplate.update({ + where: { id }, + data: { content: content as Prisma.InputJsonValue }, + }); + this.logger.log(`Updated policy content for ${id}`); + return updated; + } + + async delete(id: string) { + await this.findById(id); + try { + await db.frameworkEditorPolicyTemplate.delete({ where: { id } }); + } catch (error) { + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === 'P2003' + ) { + throw new ConflictException( + 'Cannot delete policy template: it is referenced by existing policies', + ); + } + throw error; + } + this.logger.log(`Deleted policy template ${id}`); + return { message: 'Policy template deleted successfully' }; + } +} diff --git a/apps/api/src/framework-editor/requirement/dto/create-requirement.dto.ts b/apps/api/src/framework-editor/requirement/dto/create-requirement.dto.ts new file mode 100644 index 0000000000..4b5d627ca1 --- /dev/null +++ b/apps/api/src/framework-editor/requirement/dto/create-requirement.dto.ts @@ -0,0 +1,32 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + IsString, + IsNotEmpty, + IsOptional, + MaxLength, +} from 'class-validator'; + +export class CreateRequirementDto { + @ApiProperty({ example: 'frk_abc123' }) + @IsString() + @IsNotEmpty() + @MaxLength(255) + frameworkId: string; + + @ApiProperty({ example: 'CC1.1' }) + @IsString() + @IsNotEmpty() + @MaxLength(255) + name: string; + + @ApiPropertyOptional({ example: 'cc1-1' }) + @IsString() + @IsOptional() + @MaxLength(255) + identifier?: string; + + @ApiProperty({ example: 'Control environment requirements' }) + @IsString() + @MaxLength(5000) + description: string; +} diff --git a/apps/api/src/framework-editor/requirement/dto/update-requirement.dto.ts b/apps/api/src/framework-editor/requirement/dto/update-requirement.dto.ts new file mode 100644 index 0000000000..bbac097ac3 --- /dev/null +++ b/apps/api/src/framework-editor/requirement/dto/update-requirement.dto.ts @@ -0,0 +1,22 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsString, IsOptional, MaxLength } from 'class-validator'; + +export class UpdateRequirementDto { + @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-editor/requirement/requirement.controller.ts b/apps/api/src/framework-editor/requirement/requirement.controller.ts new file mode 100644 index 0000000000..8ed9159b47 --- /dev/null +++ b/apps/api/src/framework-editor/requirement/requirement.controller.ts @@ -0,0 +1,55 @@ +import { + Controller, + Get, + Post, + Patch, + Delete, + Body, + Param, + Query, + UseGuards, + UsePipes, + ValidationPipe, +} from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { PlatformAdminGuard } from '../../auth/platform-admin.guard'; +import { CreateRequirementDto } from './dto/create-requirement.dto'; +import { UpdateRequirementDto } from './dto/update-requirement.dto'; +import { RequirementService } from './requirement.service'; + +@ApiTags('Framework Editor Requirements') +@Controller({ path: 'framework-editor/requirement', version: '1' }) +@UseGuards(PlatformAdminGuard) +export class RequirementController { + constructor(private readonly service: RequirementService) {} + + @Get() + async findAll( + @Query('take') take?: string, + @Query('skip') skip?: string, + ) { + const limit = Math.min(Number(take) || 500, 500); + const offset = Number(skip) || 0; + return this.service.findAll(limit, offset); + } + + @Post() + @UsePipes(new ValidationPipe({ whitelist: true, transform: true })) + async create(@Body() dto: CreateRequirementDto) { + return this.service.create(dto); + } + + @Patch(':id') + @UsePipes(new ValidationPipe({ whitelist: true, transform: true })) + async update( + @Param('id') id: string, + @Body() dto: UpdateRequirementDto, + ) { + return this.service.update(id, dto); + } + + @Delete(':id') + async delete(@Param('id') id: string) { + return this.service.delete(id); + } +} diff --git a/apps/api/src/framework-editor/requirement/requirement.module.ts b/apps/api/src/framework-editor/requirement/requirement.module.ts new file mode 100644 index 0000000000..7dc889ca02 --- /dev/null +++ b/apps/api/src/framework-editor/requirement/requirement.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { AuthModule } from '../../auth/auth.module'; +import { RequirementController } from './requirement.controller'; +import { RequirementService } from './requirement.service'; + +@Module({ + imports: [AuthModule], + controllers: [RequirementController], + providers: [RequirementService], + exports: [RequirementService], +}) +export class RequirementModule {} diff --git a/apps/api/src/framework-editor/requirement/requirement.service.ts b/apps/api/src/framework-editor/requirement/requirement.service.ts new file mode 100644 index 0000000000..6816bda72c --- /dev/null +++ b/apps/api/src/framework-editor/requirement/requirement.service.ts @@ -0,0 +1,81 @@ +import { Injectable, NotFoundException, Logger } from '@nestjs/common'; +import { db } from '@trycompai/db'; +import { CreateRequirementDto } from './dto/create-requirement.dto'; +import { UpdateRequirementDto } from './dto/update-requirement.dto'; + +@Injectable() +export class RequirementService { + private readonly logger = new Logger(RequirementService.name); + + async findAll(take = 500, skip = 0) { + return db.frameworkEditorRequirement.findMany({ + take, + skip, + orderBy: { name: 'asc' }, + include: { + framework: { select: { id: true, name: true } }, + }, + }); + } + + async findAllForFramework(frameworkId: string) { + return db.frameworkEditorRequirement.findMany({ + where: { frameworkId }, + orderBy: { name: 'asc' }, + include: { + controlTemplates: { select: { id: true, name: true } }, + }, + }); + } + + async create(dto: CreateRequirementDto) { + const framework = await db.frameworkEditorFramework.findUnique({ + where: { id: dto.frameworkId }, + }); + if (!framework) { + throw new NotFoundException( + `Framework ${dto.frameworkId} not found`, + ); + } + + const req = await db.frameworkEditorRequirement.create({ + data: { + frameworkId: dto.frameworkId, + name: dto.name, + identifier: dto.identifier ?? '', + description: dto.description ?? '', + }, + }); + this.logger.log(`Created requirement: ${req.name} (${req.id})`); + return req; + } + + async update(id: string, dto: UpdateRequirementDto) { + const existing = await db.frameworkEditorRequirement.findUnique({ + where: { id }, + }); + if (!existing) { + throw new NotFoundException(`Requirement ${id} not found`); + } + + const updated = await db.frameworkEditorRequirement.update({ + where: { id }, + data: dto, + }); + this.logger.log(`Updated requirement: ${updated.name} (${id})`); + return updated; + } + + async delete(id: string) { + const existing = await db.frameworkEditorRequirement.findUnique({ + where: { id }, + }); + if (!existing) { + throw new NotFoundException(`Requirement ${id} not found`); + } + + await db.frameworkEditorRequirement.delete({ where: { id } }); + this.logger.log(`Deleted requirement ${id}`); + return { message: 'Requirement deleted successfully' }; + } +} diff --git a/apps/api/src/framework-editor/task-template/dto/create-task-template.dto.ts b/apps/api/src/framework-editor/task-template/dto/create-task-template.dto.ts index f415dca299..27288b52dc 100644 --- a/apps/api/src/framework-editor/task-template/dto/create-task-template.dto.ts +++ b/apps/api/src/framework-editor/task-template/dto/create-task-template.dto.ts @@ -1,5 +1,11 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { IsString, IsNotEmpty, IsEnum } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + IsString, + IsNotEmpty, + IsEnum, + IsOptional, + MaxLength, +} from 'class-validator'; import { Frequency, Departments } from '@trycompai/db'; export class CreateTaskTemplateDto { @@ -9,29 +15,33 @@ export class CreateTaskTemplateDto { }) @IsString() @IsNotEmpty() + @MaxLength(255) name: string; - @ApiProperty({ + @ApiPropertyOptional({ description: 'Detailed description of the task template', example: 'Review and update security policies on a monthly basis', }) + @IsOptional() @IsString() - @IsNotEmpty() - description: string; + @MaxLength(5000) + description?: string; - @ApiProperty({ + @ApiPropertyOptional({ description: 'Frequency of the task', enum: Frequency, example: Frequency.monthly, }) + @IsOptional() @IsEnum(Frequency) - frequency: Frequency; + frequency?: Frequency; - @ApiProperty({ + @ApiPropertyOptional({ description: 'Department responsible for the task', enum: Departments, example: Departments.it, }) + @IsOptional() @IsEnum(Departments) - department: Departments; + department?: Departments; } diff --git a/apps/api/src/framework-editor/task-template/dto/update-task-template.dto.ts b/apps/api/src/framework-editor/task-template/dto/update-task-template.dto.ts index 2c654f15db..112800df89 100644 --- a/apps/api/src/framework-editor/task-template/dto/update-task-template.dto.ts +++ b/apps/api/src/framework-editor/task-template/dto/update-task-template.dto.ts @@ -1,4 +1,15 @@ -import { PartialType } from '@nestjs/swagger'; +import { ApiPropertyOptional, PartialType } from '@nestjs/swagger'; +import { IsEnum, IsOptional } from 'class-validator'; +import { TaskAutomationStatus } from '@trycompai/db'; import { CreateTaskTemplateDto } from './create-task-template.dto'; -export class UpdateTaskTemplateDto extends PartialType(CreateTaskTemplateDto) {} +export class UpdateTaskTemplateDto extends PartialType(CreateTaskTemplateDto) { + @ApiPropertyOptional({ + description: 'Automation status of the task', + enum: TaskAutomationStatus, + example: TaskAutomationStatus.AUTOMATED, + }) + @IsOptional() + @IsEnum(TaskAutomationStatus) + automationStatus?: TaskAutomationStatus; +} diff --git a/apps/api/src/framework-editor/task-template/schemas/task-template-bodies.ts b/apps/api/src/framework-editor/task-template/schemas/task-template-bodies.ts index 232ac08dfd..8c2341bd50 100644 --- a/apps/api/src/framework-editor/task-template/schemas/task-template-bodies.ts +++ b/apps/api/src/framework-editor/task-template/schemas/task-template-bodies.ts @@ -1,6 +1,11 @@ +import { CreateTaskTemplateDto } from '../dto/create-task-template.dto'; import { UpdateTaskTemplateDto } from '../dto/update-task-template.dto'; export const TASK_TEMPLATE_BODIES = { + createTaskTemplate: { + type: CreateTaskTemplateDto, + description: 'Create a new framework editor task template', + }, updateTaskTemplate: { type: UpdateTaskTemplateDto, description: 'Update framework editor task template data', diff --git a/apps/api/src/framework-editor/task-template/schemas/task-template-operations.ts b/apps/api/src/framework-editor/task-template/schemas/task-template-operations.ts index 864d7793c5..0e8d2fc083 100644 --- a/apps/api/src/framework-editor/task-template/schemas/task-template-operations.ts +++ b/apps/api/src/framework-editor/task-template/schemas/task-template-operations.ts @@ -1,4 +1,8 @@ export const TASK_TEMPLATE_OPERATIONS = { + createTaskTemplate: { + summary: 'Create a framework editor task template', + description: 'Create a new framework editor task template', + }, getAllTaskTemplates: { summary: 'Get all framework editor task templates', description: 'Retrieve all framework editor task templates', diff --git a/apps/api/src/framework-editor/task-template/task-template.controller.ts b/apps/api/src/framework-editor/task-template/task-template.controller.ts index 6c784dcf9f..fae00cc706 100644 --- a/apps/api/src/framework-editor/task-template/task-template.controller.ts +++ b/apps/api/src/framework-editor/task-template/task-template.controller.ts @@ -1,10 +1,12 @@ import { Controller, Get, + Post, Patch, Delete, Body, Param, + Query, UseGuards, UsePipes, ValidationPipe, @@ -14,14 +16,10 @@ import { ApiOperation, ApiParam, ApiResponse, - ApiSecurity, ApiTags, } from '@nestjs/swagger'; -import { AuthContext } from '../../auth/auth-context.decorator'; -import { HybridAuthGuard } from '../../auth/hybrid-auth.guard'; -import { PermissionGuard } from '../../auth/permission.guard'; -import { RequirePermission } from '../../auth/require-permission.decorator'; -import type { AuthContext as AuthContextType } from '../../auth/types'; +import { PlatformAdminGuard } from '../../auth/platform-admin.guard'; +import { CreateTaskTemplateDto } from './dto/create-task-template.dto'; import { UpdateTaskTemplateDto } from './dto/update-task-template.dto'; import { TaskTemplateService } from './task-template.service'; import { ValidateIdPipe } from './pipes/validate-id.pipe'; @@ -35,23 +33,39 @@ import { DELETE_TASK_TEMPLATE_RESPONSES } from './schemas/delete-task-template.r @ApiTags('Framework Editor Task Templates') @Controller({ path: 'framework-editor/task-template', version: '1' }) -@UseGuards(HybridAuthGuard, PermissionGuard) -@ApiSecurity('apikey') +@UseGuards(PlatformAdminGuard) export class TaskTemplateController { constructor(private readonly taskTemplateService: TaskTemplateService) {} + @Post() + @ApiOperation(TASK_TEMPLATE_OPERATIONS.createTaskTemplate) + @ApiBody(TASK_TEMPLATE_BODIES.createTaskTemplate) + @UsePipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + }), + ) + async createTaskTemplate( + @Body() dto: CreateTaskTemplateDto, + @Query('frameworkId') frameworkId?: string, + ) { + return this.taskTemplateService.create(dto, frameworkId); + } + @Get() - @RequirePermission('framework', 'read') @ApiOperation(TASK_TEMPLATE_OPERATIONS.getAllTaskTemplates) @ApiResponse(GET_ALL_TASK_TEMPLATES_RESPONSES[200]) @ApiResponse(GET_ALL_TASK_TEMPLATES_RESPONSES[401]) @ApiResponse(GET_ALL_TASK_TEMPLATES_RESPONSES[500]) - async getAllTaskTemplates() { - return await this.taskTemplateService.findAll(); + async getAllTaskTemplates( + @Query('frameworkId') frameworkId?: string, + ) { + return await this.taskTemplateService.findAll(frameworkId); } @Get(':id') - @RequirePermission('framework', 'read') @ApiOperation(TASK_TEMPLATE_OPERATIONS.getTaskTemplateById) @ApiParam(TASK_TEMPLATE_PARAMS.taskTemplateId) @ApiResponse(GET_TASK_TEMPLATE_BY_ID_RESPONSES[200]) @@ -60,26 +74,11 @@ export class TaskTemplateController { @ApiResponse(GET_TASK_TEMPLATE_BY_ID_RESPONSES[500]) async getTaskTemplateById( @Param('id', ValidateIdPipe) taskTemplateId: string, - @AuthContext() authContext: AuthContextType, ) { - const taskTemplate = - await this.taskTemplateService.findById(taskTemplateId); - - return { - ...taskTemplate, - authType: authContext.authType, - ...(authContext.userId && - authContext.userEmail && { - authenticatedUser: { - id: authContext.userId, - email: authContext.userEmail, - }, - }), - }; + return await this.taskTemplateService.findById(taskTemplateId); } @Patch(':id') - @RequirePermission('framework', 'update') @ApiOperation(TASK_TEMPLATE_OPERATIONS.updateTaskTemplate) @ApiParam(TASK_TEMPLATE_PARAMS.taskTemplateId) @ApiBody(TASK_TEMPLATE_BODIES.updateTaskTemplate) @@ -98,28 +97,14 @@ export class TaskTemplateController { async updateTaskTemplate( @Param('id', ValidateIdPipe) taskTemplateId: string, @Body() updateTaskTemplateDto: UpdateTaskTemplateDto, - @AuthContext() authContext: AuthContextType, ) { - const updatedTaskTemplate = await this.taskTemplateService.updateById( + return await this.taskTemplateService.updateById( taskTemplateId, updateTaskTemplateDto, ); - - return { - ...updatedTaskTemplate, - authType: authContext.authType, - ...(authContext.userId && - authContext.userEmail && { - authenticatedUser: { - id: authContext.userId, - email: authContext.userEmail, - }, - }), - }; } @Delete(':id') - @RequirePermission('framework', 'delete') @ApiOperation(TASK_TEMPLATE_OPERATIONS.deleteTaskTemplate) @ApiParam(TASK_TEMPLATE_PARAMS.taskTemplateId) @ApiResponse(DELETE_TASK_TEMPLATE_RESPONSES[200]) @@ -128,20 +113,7 @@ export class TaskTemplateController { @ApiResponse(DELETE_TASK_TEMPLATE_RESPONSES[500]) async deleteTaskTemplate( @Param('id', ValidateIdPipe) taskTemplateId: string, - @AuthContext() authContext: AuthContextType, ) { - const result = await this.taskTemplateService.deleteById(taskTemplateId); - - return { - ...result, - authType: authContext.authType, - ...(authContext.userId && - authContext.userEmail && { - authenticatedUser: { - id: authContext.userId, - email: authContext.userEmail, - }, - }), - }; + return await this.taskTemplateService.deleteById(taskTemplateId); } } diff --git a/apps/api/src/framework-editor/task-template/task-template.service.ts b/apps/api/src/framework-editor/task-template/task-template.service.ts index 8d34ede1b0..f72c3af524 100644 --- a/apps/api/src/framework-editor/task-template/task-template.service.ts +++ b/apps/api/src/framework-editor/task-template/task-template.service.ts @@ -1,15 +1,64 @@ import { Injectable, NotFoundException, Logger } from '@nestjs/common'; -import { db } from '@trycompai/db'; +import { db, Frequency, Departments } from '@trycompai/db'; +import { CreateTaskTemplateDto } from './dto/create-task-template.dto'; import { UpdateTaskTemplateDto } from './dto/update-task-template.dto'; @Injectable() export class TaskTemplateService { private readonly logger = new Logger(TaskTemplateService.name); - async findAll() { + async create(dto: CreateTaskTemplateDto, frameworkId?: string) { + const controlIds = frameworkId + ? await db.frameworkEditorControlTemplate + .findMany({ + where: { requirements: { some: { frameworkId } } }, + select: { id: true }, + }) + .then((cts) => cts.map((ct) => ({ id: ct.id }))) + : []; + + const taskTemplate = await db.frameworkEditorTaskTemplate.create({ + data: { + name: dto.name, + description: dto.description ?? '', + frequency: dto.frequency ?? Frequency.monthly, + department: dto.department ?? Departments.none, + ...(controlIds.length > 0 && { + controlTemplates: { connect: controlIds }, + }), + }, + }); + + this.logger.log( + `Created framework editor task template: ${taskTemplate.name} (${taskTemplate.id})`, + ); + return taskTemplate; + } + + async findAll(frameworkId?: string) { try { const taskTemplates = await db.frameworkEditorTaskTemplate.findMany({ orderBy: { name: 'asc' }, + where: frameworkId + ? { + controlTemplates: { + some: { requirements: { some: { frameworkId } } }, + }, + } + : undefined, + include: { + controlTemplates: { + select: { + id: true, + name: true, + requirements: { + select: { + framework: { select: { id: true, name: true } }, + }, + }, + }, + }, + }, }); this.logger.log( diff --git a/apps/api/src/framework-editor/validators/max-json-size.validator.ts b/apps/api/src/framework-editor/validators/max-json-size.validator.ts new file mode 100644 index 0000000000..9a2255fce7 --- /dev/null +++ b/apps/api/src/framework-editor/validators/max-json-size.validator.ts @@ -0,0 +1,41 @@ +import { + registerDecorator, + ValidationOptions, + ValidatorConstraint, + ValidatorConstraintInterface, + ValidationArguments, +} from 'class-validator'; + +const DEFAULT_MAX_BYTES = 512_000; // 500 KB + +@ValidatorConstraint({ name: 'maxJsonSize', async: false }) +export class MaxJsonSizeConstraint implements ValidatorConstraintInterface { + validate(value: unknown, args: ValidationArguments): boolean { + if (value === null || value === undefined) return true; + try { + const serialized = JSON.stringify(value); + const maxBytes = (args.constraints[0] as number) ?? DEFAULT_MAX_BYTES; + return serialized.length <= maxBytes; + } catch { + return false; + } + } + + defaultMessage(args: ValidationArguments): string { + const maxBytes = (args.constraints[0] as number) ?? DEFAULT_MAX_BYTES; + const maxKb = Math.round(maxBytes / 1000); + return `JSON content exceeds maximum allowed size of ${maxKb}KB`; + } +} + +export function MaxJsonSize(maxBytes?: number, validationOptions?: ValidationOptions) { + return function (object: object, propertyName: string) { + registerDecorator({ + target: object.constructor, + propertyName, + options: validationOptions, + constraints: [maxBytes ?? DEFAULT_MAX_BYTES], + validator: MaxJsonSizeConstraint, + }); + }; +} diff --git a/apps/app/src/actions/organization/lib/initialize-organization.ts b/apps/app/src/actions/organization/lib/initialize-organization.ts index 37bf57df41..7d897b771e 100644 --- a/apps/app/src/actions/organization/lib/initialize-organization.ts +++ b/apps/app/src/actions/organization/lib/initialize-organization.ts @@ -1,5 +1,25 @@ import { db, Prisma } from '@db'; +/** + * Policy.content is Json[] (the inner nodes of a TipTap document), + * but FrameworkEditorPolicyTemplate.content is Json (the full TipTap doc). + * This extracts the inner content array from either format. + */ +function extractTipTapContentArray(content: unknown): Prisma.InputJsonValue[] { + if (Array.isArray(content)) return content as Prisma.InputJsonValue[]; + if ( + content && + typeof content === 'object' && + 'type' in content && + (content as Record).type === 'doc' && + 'content' in content && + Array.isArray((content as Record).content) + ) { + return (content as Record).content as Prisma.InputJsonValue[]; + } + return []; +} + // Define a type for FrameworkEditorFramework with requirements included // This assumes FrameworkEditorFramework and FrameworkEditorRequirement are valid Prisma types. // Adjust if your Prisma client exposes these differently (e.g., via Prisma.FrameworkEditorFrameworkGetPayload). @@ -197,15 +217,19 @@ export const _upsertOrgFrameworkStructureCore = async ({ if (policyTemplatesForCreation.length > 0) { await tx.policy.createMany({ - data: policyTemplatesForCreation.map((policyTemplate) => ({ - name: policyTemplate.name, - description: policyTemplate.description, - department: policyTemplate.department, - frequency: policyTemplate.frequency, - content: policyTemplate.content as Prisma.PolicyCreateInput['content'], - organizationId: organizationId, - policyTemplateId: policyTemplate.id, - })), + data: policyTemplatesForCreation.map((policyTemplate) => { + const templateContent = policyTemplate.content; + const contentArray = extractTipTapContentArray(templateContent); + return { + name: policyTemplate.name, + description: policyTemplate.description, + department: policyTemplate.department, + frequency: policyTemplate.frequency, + content: { set: contentArray }, + organizationId: organizationId, + policyTemplateId: policyTemplate.id, + }; + }), }); // Fetch newly created policies to create versions for them @@ -225,7 +249,7 @@ export const _upsertOrgFrameworkStructureCore = async ({ data: newlyCreatedPolicies.map((policy) => ({ policyId: policy.id, version: 1, - content: policy.content as Prisma.InputJsonValue[], + content: { set: policy.content as Prisma.InputJsonValue[] }, changelog: 'Initial version from template', })), }); diff --git a/apps/app/src/app/(app)/[orgId]/controls/[controlId]/components/DocumentsTable.tsx b/apps/app/src/app/(app)/[orgId]/controls/[controlId]/components/DocumentsTable.tsx new file mode 100644 index 0000000000..b607e6483c --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/controls/[controlId]/components/DocumentsTable.tsx @@ -0,0 +1,116 @@ +'use client'; + +import { StatusIndicator } from '@/components/status-indicator'; +import { InputGroup, InputGroupAddon, InputGroupInput } from '@trycompai/design-system'; +import { Search } from '@trycompai/design-system/icons'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, + Text, +} from '@trycompai/design-system'; +import { useRouter } from 'next/navigation'; +import { useMemo, useState } from 'react'; + +export interface DocumentTypeWithStatus { + formType: string; + title: string; + category: string; + lastSubmittedAt: string | null; + isCurrent: boolean; +} + +interface DocumentsTableProps { + documents: DocumentTypeWithStatus[]; + orgId: string; +} + +export function DocumentsTable({ documents, orgId }: DocumentsTableProps) { + const router = useRouter(); + const [searchTerm, setSearchTerm] = useState(''); + + const filteredDocuments = useMemo(() => { + if (!searchTerm.trim()) return documents; + + const searchLower = searchTerm.toLowerCase(); + return documents.filter( + (doc) => + doc.title.toLowerCase().includes(searchLower) || + doc.category.toLowerCase().includes(searchLower), + ); + }, [documents, searchTerm]); + + const handleRowClick = (formType: string) => { + router.push(`/${orgId}/documents/${formType}`); + }; + + return ( +
+
+ + + + + setSearchTerm(e.target.value)} + /> + +
+ + + + + Document + Category + Last Submitted + Status + + + + {filteredDocuments.length === 0 ? ( + + + + No documents linked. + + + + ) : ( + filteredDocuments.map((doc) => ( + handleRowClick(doc.formType)} + onKeyDown={(event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + handleRowClick(doc.formType); + } + }} + > + {doc.title} + {doc.category} + + {doc.lastSubmittedAt + ? new Date(doc.lastSubmittedAt).toLocaleDateString() + : 'Never'} + + + + + + )) + )} + +
+
+ ); +} diff --git a/apps/app/src/app/(app)/[orgId]/controls/components/CreateControlSheet.tsx b/apps/app/src/app/(app)/[orgId]/controls/components/CreateControlSheet.tsx index f8997f548e..afdf4858e2 100644 --- a/apps/app/src/app/(app)/[orgId]/controls/components/CreateControlSheet.tsx +++ b/apps/app/src/app/(app)/[orgId]/controls/components/CreateControlSheet.tsx @@ -234,7 +234,7 @@ export function CreateControlSheet({ No policies found.

} - className="[&_[cmdk-list]]:!z-[9999] [&_[cmdk-list]]:!fixed" + className="**:[[cmdk-list]]:z-9999! **:[[cmdk-list]]:fixed!" commandProps={{ filter: policyFilterFunction, }} @@ -273,7 +273,7 @@ export function CreateControlSheet({ No tasks found.

} - className="[&_[cmdk-list]]:!z-[9999] [&_[cmdk-list]]:!fixed" + className="**:[[cmdk-list]]:z-9999! **:[[cmdk-list]]:fixed!" commandProps={{ filter: taskFilterFunction, }} @@ -327,7 +327,7 @@ export function CreateControlSheet({ No requirements found.

} - className="[&_[cmdk-list]]:!z-[9999] [&_[cmdk-list]]:!fixed" + className="**:[[cmdk-list]]:z-9999! **:[[cmdk-list]]:fixed!" commandProps={{ filter: requirementFilterFunction, }} @@ -348,7 +348,7 @@ export function CreateControlSheet({ <> - + Create New Control + + + ); +}; diff --git a/apps/framework-editor/app/(pages)/auth/button-icon.tsx b/apps/framework-editor/app/(pages)/auth/button-icon.tsx new file mode 100644 index 0000000000..3f211a370f --- /dev/null +++ b/apps/framework-editor/app/(pages)/auth/button-icon.tsx @@ -0,0 +1,38 @@ +// Temporarily removed cn import +import { motion } from 'framer-motion'; + +export const ButtonIcon = ({ + className, + children, + loading, + isLoading, +}: { + className?: string; + children: React.ReactNode; + loading?: React.ReactNode; + isLoading: boolean; +}) => { + return ( +
+ + {children} + + + {loading} + +
+ ); +}; diff --git a/apps/framework-editor/app/(pages)/auth/google-sign-in.tsx b/apps/framework-editor/app/(pages)/auth/google-sign-in.tsx new file mode 100644 index 0000000000..b4b3f1755c --- /dev/null +++ b/apps/framework-editor/app/(pages)/auth/google-sign-in.tsx @@ -0,0 +1,40 @@ +'use client'; + +import { authClient } from '@/app/lib/auth-client'; +import { Button, Icons } from '@trycompai/ui'; +import { Loader2 } from 'lucide-react'; +import { useState } from 'react'; +import { ButtonIcon } from './button-icon'; + +export function GoogleSignIn({ inviteCode }: { inviteCode?: string }) { + const [isLoading, setLoading] = useState(false); + + const handleSignIn = async () => { + setLoading(true); + const origin = typeof window !== 'undefined' ? window.location.origin : ''; + const redirectTo = inviteCode ? `${origin}/invite/${inviteCode}` : `${origin}/`; + + await authClient.signIn.social({ + provider: 'google', + callbackURL: redirectTo, + }); + }; + + return ( + + ); +} diff --git a/apps/framework-editor/app/(pages)/auth/page.tsx b/apps/framework-editor/app/(pages)/auth/page.tsx new file mode 100644 index 0000000000..322989fafd --- /dev/null +++ b/apps/framework-editor/app/(pages)/auth/page.tsx @@ -0,0 +1,61 @@ +import { auth } from '@/app/lib/auth'; +import { isInternalUser } from '@/app/lib/utils'; +import type { Metadata } from 'next'; +import { headers } from 'next/headers'; +import { redirect } from 'next/navigation'; +import Balancer from 'react-wrap-balancer'; +import { GoogleSignIn } from './google-sign-in'; +import { Unauthorized } from './Unauthorized'; + +export const metadata: Metadata = { + title: 'Login | Comp AI', +}; + +export default async function Page() { + const session = await auth.api.getSession({ + headers: await headers(), + }); + + const hasSession = !!session?.user; + const isAllowed = + hasSession && + session.user.role === 'admin' && + isInternalUser(session.user.email); + + if (hasSession && !isAllowed) { + return ; + } + + if (hasSession && isAllowed) { + redirect('/frameworks'); + } + + return ( +
+
+
+ +

Get Started with Comp AI

+

Sign in to continue

+
+ +
+ +
+ +

+ By clicking continue, you acknowledge that you have read and agree to the{' '} + + Terms and Conditions + {' '} + and{' '} + + Privacy Policy + + . +

+
+
+
+ ); +} diff --git a/apps/framework-editor/app/(pages)/controls/ControlsClientPage.tsx b/apps/framework-editor/app/(pages)/controls/ControlsClientPage.tsx new file mode 100644 index 0000000000..3db100cf0c --- /dev/null +++ b/apps/framework-editor/app/(pages)/controls/ControlsClientPage.tsx @@ -0,0 +1,481 @@ +'use client'; + +import { apiClient } from '@/app/lib/api-client'; +import { + createColumnHelper, + flexRender, + getCoreRowModel, + getSortedRowModel, + useReactTable, + type SortingState, +} from '@tanstack/react-table'; +import { Button } from '@trycompai/ui'; +import { ArrowDown, ArrowUp, ArrowUpDown, Link, Plus, Trash2 } from 'lucide-react'; +import { useCallback, useMemo, useState } from 'react'; +import { + AddExistingItemDialog, + type ExistingItemRaw, +} from '../../components/AddExistingItemDialog'; +import { + DateCell, + EditableCell, + MultiSelectCell, + type MultiSelectOption, + RelationalCell, + type RelationalItem, +} from '../../components/table'; +import { DOCUMENT_TYPE_OPTIONS } from './document-type-options'; +import { simpleUUID, useChangeTracking, type ControlMutations } from './hooks/useChangeTracking'; +import type { ControlsPageGridData, FrameworkEditorControlTemplateWithRelatedData } from './types'; + +interface RequirementApiItem { + id: string; + name: string; + identifier: string; + framework?: { name: string }; +} + +async function fetchAllPolicyTemplates(): Promise { + return apiClient('/policy-template'); +} + +async function fetchAllRequirements(): Promise { + const reqs = await apiClient('/requirement'); + return reqs.map((r) => { + let displayName = r.identifier; + if (r.identifier && r.name) { + displayName = `${r.identifier} - ${r.name}`; + } else if (r.name) { + displayName = r.name; + } + return { + id: r.id, + name: displayName || 'Unnamed Requirement', + sublabel: r.framework?.name, + }; + }); +} + +async function fetchAllTaskTemplates(): Promise { + return apiClient('/task-template'); +} + +async function linkControlRelation( + controlId: string, + relation: string, + itemId: string, +): Promise { + await apiClient(`/control-template/${controlId}/${relation}/${itemId}`, { method: 'POST' }); +} + +async function unlinkControlRelation( + controlId: string, + relation: string, + itemId: string, +): Promise { + await apiClient(`/control-template/${controlId}/${relation}/${itemId}`, { method: 'DELETE' }); +} + +interface ControlsClientPageProps { + initialControls: FrameworkEditorControlTemplateWithRelatedData[]; + emptyMessage?: string; + frameworkId?: string; +} + +const columnHelper = createColumnHelper(); + +export function ControlsClientPage({ initialControls, emptyMessage, frameworkId }: ControlsClientPageProps) { + const mutations: ControlMutations = useMemo( + () => ({ + createControl: (data: { + name: string | null; + description: string | null; + documentTypes: string[]; + }) => { + const queryParam = frameworkId ? `?frameworkId=${frameworkId}` : ''; + return apiClient<{ id: string }>(`/control-template${queryParam}`, { + method: 'POST', + body: JSON.stringify(data), + }); + }, + updateControl: ( + id: string, + data: { name: string; description: string; documentTypes: string[] }, + ) => + apiClient(`/control-template/${id}`, { + method: 'PATCH', + body: JSON.stringify(data), + }), + deleteControl: (id: string) => + apiClient(`/control-template/${id}`, { + method: 'DELETE', + }), + }), + [frameworkId], + ); + const initialGridData: ControlsPageGridData[] = useMemo( + () => + initialControls.map((control) => ({ + id: control.id || simpleUUID(), + name: control.name ?? null, + description: control.description ?? null, + policyTemplates: control.policyTemplates?.map((pt) => ({ id: pt.id, name: pt.name })) ?? [], + requirements: + control.requirements?.map((r) => ({ + id: r.id, + name: r.name, + sublabel: r.framework?.name, + })) ?? [], + taskTemplates: control.taskTemplates?.map((tt) => ({ id: tt.id, name: tt.name })) ?? [], + documentTypes: (control.documentTypes as string[]) ?? [], + policyTemplatesLength: control.policyTemplates?.length ?? 0, + requirementsLength: control.requirements?.length ?? 0, + taskTemplatesLength: control.taskTemplates?.length ?? 0, + documentTypesLength: control.documentTypes?.length ?? 0, + createdAt: control.createdAt ? new Date(control.createdAt) : null, + updatedAt: control.updatedAt ? new Date(control.updatedAt) : null, + })), + [initialControls], + ); + + const { + data, + updateCell, + updateRelational, + addRow, + deleteRow, + getRowClassName, + handleCommit, + handleCancel, + isDirty, + createdIds, + changesSummary, + } = useChangeTracking(initialGridData, mutations); + + const handleDocumentTypesUpdate = useCallback( + (rowId: string, values: string[]) => { + updateCell(rowId, 'documentTypes', values); + }, + [updateCell], + ); + + const columns = useMemo( + () => [ + columnHelper.accessor('name', { + header: 'Name', + size: 250, + cell: ({ row, getValue }) => ( + + ), + }), + columnHelper.accessor('description', { + header: 'Description', + size: 300, + maxSize: 300, + cell: ({ row, getValue }) => ( + + ), + }), + columnHelper.accessor('policyTemplates', { + header: 'Linked Policies', + size: 220, + enableSorting: false, + cell: ({ row, getValue }) => ( +
+ + linkControlRelation(controlId, 'policy-templates', ptId) + } + onUnlink={(controlId: string, ptId: string) => + unlinkControlRelation(controlId, 'policy-templates', ptId) + } + onLocalUpdate={(newItems: RelationalItem[]) => + updateRelational(row.original.id, 'policyTemplates', newItems) + } + label="Policy" + labelPlural="Policies" + /> +
+ ), + }), + columnHelper.accessor('requirements', { + header: 'Linked Requirements', + size: 220, + enableSorting: false, + cell: ({ row, getValue }) => ( +
+ + linkControlRelation(controlId, 'requirements', reqId) + } + onUnlink={(controlId: string, reqId: string) => + unlinkControlRelation(controlId, 'requirements', reqId) + } + onLocalUpdate={(newItems: RelationalItem[]) => + updateRelational(row.original.id, 'requirements', newItems) + } + label="Requirement" + labelPlural="Requirements" + /> +
+ ), + }), + columnHelper.accessor('taskTemplates', { + header: 'Linked Tasks', + size: 220, + enableSorting: false, + cell: ({ row, getValue }) => ( +
+ + linkControlRelation(controlId, 'task-templates', ttId) + } + onUnlink={(controlId: string, ttId: string) => + unlinkControlRelation(controlId, 'task-templates', ttId) + } + onLocalUpdate={(newItems: RelationalItem[]) => + updateRelational(row.original.id, 'taskTemplates', newItems) + } + label="Task" + labelPlural="Tasks" + /> +
+ ), + }), + columnHelper.accessor('documentTypes', { + header: 'Linked Documents', + size: 220, + enableSorting: false, + cell: ({ row, getValue }) => ( +
+ +
+ ), + }), + columnHelper.accessor('createdAt', { + header: 'Created At', + size: 180, + cell: ({ getValue }) => , + }), + columnHelper.accessor('updatedAt', { + header: 'Updated At', + size: 180, + cell: ({ getValue }) => , + }), + columnHelper.accessor('id', { + header: 'ID', + size: 280, + cell: ({ getValue }) => ( + + {getValue()} + + ), + }), + columnHelper.display({ + id: 'actions', + header: '', + size: 50, + cell: ({ row }) => ( + + ), + }), + ], + [updateCell, updateRelational, deleteRow, createdIds, handleDocumentTypesUpdate], + ); + + const [sorting, setSorting] = useState([]); + + const table = useReactTable({ + data, + columns, + state: { sorting }, + onSortingChange: setSorting, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getRowId: (row) => row.id, + }); + + const [isAddExistingOpen, setIsAddExistingOpen] = useState(false); + + const existingControlIds = useMemo( + () => new Set(initialControls.map((c) => c.id)), + [initialControls], + ); + + const fetchAllControlsForDialog = useCallback( + () => apiClient('/control-template'), + [], + ); + + const handleAddRow = useCallback(() => { + addRow({ + id: simpleUUID(), + name: 'New Control', + description: '', + policyTemplates: [], + requirements: [], + taskTemplates: [], + documentTypes: [], + policyTemplatesLength: 0, + requirementsLength: 0, + taskTemplatesLength: 0, + documentTypesLength: 0, + createdAt: new Date(), + updatedAt: new Date(), + }); + }, [addRow]); + + return ( +
+
+
+ {isDirty && ( + <> + {changesSummary} + + + + )} +
+
+ {frameworkId && ( + + )} + +
+
+ + {frameworkId && ( + + )} + +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + ))} + + ))} + + + {table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + ))} + + ))} + {table.getRowModel().rows.length === 0 && ( + + + + )} + +
+ {header.isPlaceholder ? null : header.column.getCanSort() ? ( + + ) : ( + flexRender(header.column.columnDef.header, header.getContext()) + )} +
+ {flexRender(cell.column.columnDef.cell, cell.getContext())} +
+ {emptyMessage ?? 'No controls yet. Click "Add Control" to create one.'} +
+
+
+ ); +} diff --git a/apps/framework-editor/app/(pages)/controls/document-type-options.ts b/apps/framework-editor/app/(pages)/controls/document-type-options.ts new file mode 100644 index 0000000000..332eb364b6 --- /dev/null +++ b/apps/framework-editor/app/(pages)/controls/document-type-options.ts @@ -0,0 +1,20 @@ +import type { MultiSelectOption } from '../../components/table'; + +export const DOCUMENT_TYPE_OPTIONS: MultiSelectOption[] = [ + { value: 'board_meeting', label: 'Board Meeting', category: 'Governance' }, + { value: 'it_leadership_meeting', label: 'IT Leadership Meeting', category: 'Governance' }, + { value: 'risk_committee_meeting', label: 'Risk Committee Meeting', category: 'Governance' }, + { value: 'meeting', label: 'Meeting Minutes', category: 'Governance' }, + { value: 'access_request', label: 'Access Request', category: 'Security' }, + { value: 'penetration_test', label: 'Penetration Test', category: 'Security' }, + { value: 'rbac_matrix', label: 'RBAC Matrix', category: 'Security' }, + { value: 'infrastructure_inventory', label: 'Infrastructure Inventory', category: 'Security' }, + { value: 'network_diagram', label: 'Network Diagram', category: 'Security' }, + { value: 'tabletop_exercise', label: 'Incident Response Tabletop Exercise', category: 'Security' }, + { value: 'whistleblower_report', label: 'Whistleblower Report', category: 'People' }, + { value: 'employee_performance_evaluation', label: 'Employee Performance Evaluation', category: 'People' }, +]; + +export function getDocumentTypeLabel(value: string): string { + return DOCUMENT_TYPE_OPTIONS.find((opt) => opt.value === value)?.label ?? value; +} diff --git a/apps/framework-editor/app/(pages)/controls/hooks/useChangeTracking.ts b/apps/framework-editor/app/(pages)/controls/hooks/useChangeTracking.ts new file mode 100644 index 0000000000..23f5bdff22 --- /dev/null +++ b/apps/framework-editor/app/(pages)/controls/hooks/useChangeTracking.ts @@ -0,0 +1,280 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { toast } from 'sonner'; +import type { ControlsPageGridData } from '../types'; + +export const simpleUUID = () => crypto.randomUUID(); + +export interface ControlMutations { + createControl: (data: { + name: string | null; + description: string | null; + documentTypes: string[]; + }) => Promise<{ id: string }>; + updateControl: ( + id: string, + data: { name: string; description: string; documentTypes: string[] }, + ) => Promise; + deleteControl: (id: string) => Promise; +} + +export const useChangeTracking = ( + initialData: ControlsPageGridData[], + mutations: ControlMutations, +) => { + const [data, setData] = useState(() => initialData); + const [prevData, setPrevData] = useState(() => initialData); + + const [createdIds, setCreatedIds] = useState>(() => new Set()); + const [updatedIds, setUpdatedIds] = useState>(() => new Set()); + const [deletedIds, setDeletedIds] = useState>(() => new Set()); + const [isCommitting, setIsCommitting] = useState(false); + + useEffect(() => { + setData(initialData); + setPrevData(initialData); + setCreatedIds(new Set()); + setUpdatedIds(new Set()); + setDeletedIds(new Set()); + }, [initialData]); + + const updateCell = useCallback( + (rowId: string, columnId: string, value: string | string[]) => { + if (isCommitting) return; + setData((prev) => + prev.map((row) => { + if (row.id !== rowId) return row; + const updated: Record = { [columnId]: value, updatedAt: new Date() }; + if (Array.isArray(value)) { + updated[`${columnId}Length`] = value.length; + } + return { ...row, ...updated }; + }), + ); + + setUpdatedIds((prev) => { + if (createdIds.has(rowId)) return prev; + const next = new Set(prev); + next.add(rowId); + return next; + }); + }, + [createdIds, isCommitting], + ); + + const updateRelational = useCallback( + ( + rowId: string, + field: 'policyTemplates' | 'requirements' | 'taskTemplates', + items: { id: string; name: string; sublabel?: string }[], + ) => { + setData((prev) => + prev.map((row) => { + if (row.id !== rowId) return row; + return { + ...row, + [field]: items, + [`${field}Length`]: items.length, + updatedAt: new Date(), + }; + }), + ); + }, + [], + ); + + const addRow = useCallback( + (newRow: ControlsPageGridData) => { + if (isCommitting) return; + setData((prev) => [...prev, newRow]); + setCreatedIds((prev) => { + const next = new Set(prev); + next.add(newRow.id); + return next; + }); + }, + [isCommitting], + ); + + const deleteRow = useCallback( + (rowId: string) => { + if (isCommitting) return; + if (createdIds.has(rowId)) { + setCreatedIds((prev) => { + const next = new Set(prev); + next.delete(rowId); + return next; + }); + setData((prev) => prev.filter((row) => row.id !== rowId)); + } else { + setDeletedIds((prev) => { + const next = new Set(prev); + next.add(rowId); + return next; + }); + setUpdatedIds((prev) => { + const next = new Set(prev); + next.delete(rowId); + return next; + }); + } + }, + [createdIds, isCommitting], + ); + + const getRowClassName = useCallback( + (rowId: string) => { + if (deletedIds.has(rowId)) return 'opacity-50 line-through'; + if (createdIds.has(rowId)) return 'bg-green-50 dark:bg-green-950/20'; + if (updatedIds.has(rowId)) return 'bg-blue-50 dark:bg-blue-950/20'; + return ''; + }, + [deletedIds, createdIds, updatedIds], + ); + + const handleCommit = useCallback(async () => { + if (isCommitting) return; + setIsCommitting(true); + + try { + const results = { successes: [] as string[], errors: [] as string[] }; + const currentData = data; + + const successfullyCreatedIds = new Set(); + const successfullyUpdatedIds = new Set(); + const successfullyDeletedIds = new Set(); + + for (const tempId of createdIds) { + const row = currentData.find((r) => r.id === tempId); + if (!row?.name) continue; + + try { + const newControl = await mutations.createControl({ + name: row.name, + description: row.description, + documentTypes: row.documentTypes, + }); + results.successes.push(`Created: ${row.name}`); + successfullyCreatedIds.add(tempId); + + setData((prev) => + prev.map((r) => + r.id === tempId + ? { + ...r, + id: newControl.id, + policyTemplates: [], + requirements: [], + taskTemplates: [], + } + : r, + ), + ); + } catch (error) { + results.errors.push( + `Failed to create ${row.name}: ${error instanceof Error ? error.message : 'Unknown error'}`, + ); + } + } + + for (const id of updatedIds) { + if (createdIds.has(id) || deletedIds.has(id)) continue; + + const row = currentData.find((r) => r.id === id); + if (!row?.name) continue; + + try { + await mutations.updateControl(id, { + name: row.name, + description: row.description || '', + documentTypes: row.documentTypes, + }); + results.successes.push(`Updated: ${row.name}`); + successfullyUpdatedIds.add(id); + } catch (error) { + results.errors.push( + `Failed to update ${row.name}: ${error instanceof Error ? error.message : 'Unknown error'}`, + ); + } + } + + for (const id of deletedIds) { + try { + await mutations.deleteControl(id); + results.successes.push(`Deleted: ${id}`); + successfullyDeletedIds.add(id); + } catch (error) { + results.errors.push( + `Failed to delete ${id}: ${error instanceof Error ? error.message : 'Unknown error'}`, + ); + } + } + + setData((prev) => { + const finalData = prev.filter((row) => !successfullyDeletedIds.has(row.id)); + setPrevData(finalData); + return finalData; + }); + + setCreatedIds((prev) => { + const remaining = new Set(prev); + for (const id of successfullyCreatedIds) remaining.delete(id); + return remaining; + }); + setUpdatedIds((prev) => { + const remaining = new Set(prev); + for (const id of successfullyUpdatedIds) remaining.delete(id); + return remaining; + }); + setDeletedIds((prev) => { + const remaining = new Set(prev); + for (const id of successfullyDeletedIds) remaining.delete(id); + return remaining; + }); + + if (results.errors.length > 0) { + toast.error('Some operations failed', { + description: results.errors.join('\n'), + }); + } else if (results.successes.length > 0) { + toast.success('Changes saved', { + description: `${results.successes.length} operation(s) completed`, + }); + } + } finally { + setIsCommitting(false); + } + }, [data, createdIds, updatedIds, deletedIds, mutations, isCommitting]); + + const handleCancel = useCallback(() => { + if (isCommitting) return; + setData(prevData); + setCreatedIds(new Set()); + setUpdatedIds(new Set()); + setDeletedIds(new Set()); + }, [prevData, isCommitting]); + + const isDirty = useMemo(() => { + return createdIds.size > 0 || updatedIds.size > 0 || deletedIds.size > 0; + }, [createdIds, updatedIds, deletedIds]); + + const changesSummary = useMemo(() => { + const total = createdIds.size + updatedIds.size + deletedIds.size; + if (total === 0) return ''; + return `(${total} ${total === 1 ? 'change' : 'changes'})`; + }, [createdIds, updatedIds, deletedIds]); + + return { + data, + updateCell, + updateRelational, + addRow, + deleteRow, + getRowClassName, + handleCommit, + handleCancel, + isDirty, + isCommitting, + createdIds, + changesSummary, + }; +}; diff --git a/apps/framework-editor/app/(pages)/controls/schemas.ts b/apps/framework-editor/app/(pages)/controls/schemas.ts new file mode 100644 index 0000000000..0b74e3609e --- /dev/null +++ b/apps/framework-editor/app/(pages)/controls/schemas.ts @@ -0,0 +1,6 @@ +import { z } from 'zod'; + +export const createControlTemplateSchema = z.object({ + name: z.string().min(1, { message: 'Name is required' }), + description: z.string().optional(), +}); diff --git a/apps/framework-editor/app/(pages)/controls/types.ts b/apps/framework-editor/app/(pages)/controls/types.ts new file mode 100644 index 0000000000..762b1ab5f4 --- /dev/null +++ b/apps/framework-editor/app/(pages)/controls/types.ts @@ -0,0 +1,72 @@ +import type { FrameworkEditorControlTemplate } from '@/db'; +// Import shared types from the common location +import type { SortDirection, SortableColumnOption } from '../../types/common'; + +// Basic item with id and name +export interface ItemWithName { + id: string; + name: string; +} + +// Define a more specific type for requirement items that can include framework info +export interface RequirementItemWithFramework extends ItemWithName { + framework?: { + name: string; + }; +} + +export interface RequirementGridItem extends ItemWithName { + frameworkName?: string; + sublabel?: string; +} + +export interface FrameworkEditorControlTemplateWithRelatedData extends FrameworkEditorControlTemplate { + policyTemplates?: ItemWithName[]; + requirements?: RequirementItemWithFramework[]; + taskTemplates?: ItemWithName[]; +} + +export type ControlsPageGridData = { + id: string; + name: string | null; + description: string | null; + policyTemplates: ItemWithName[]; + requirements: RequirementGridItem[]; + taskTemplates: ItemWithName[]; + documentTypes: string[]; + policyTemplatesLength: number; + requirementsLength: number; + taskTemplatesLength: number; + documentTypesLength: number; + createdAt: Date | null; + updatedAt: Date | null; +}; + +// react-datasheet-grid operation type +export type DSGOperation = + | { type: 'CREATE'; fromRowIndex: number; toRowIndex: number } + | { type: 'UPDATE'; fromRowIndex: number; toRowIndex: number } + | { type: 'DELETE'; fromRowIndex: number; toRowIndex: number }; + +// General types for sorting and toolbar options are now imported +// export type SortDirection = 'asc' | 'desc'; // Moved to common.ts + +// Specific sortable column keys for the Controls page table +export type ControlsPageSortableColumnKey = + | 'name' + | 'description' + // Update to use length fields for sorting + | 'policyTemplatesLength' + | 'requirementsLength' + | 'taskTemplatesLength' + | 'createdAt' + | 'updatedAt'; + +// Generic type for options in the sort dropdown for the toolbar is now imported +// export interface SortableColumnOption { // Moved to common.ts +// value: string; +// label: string; +// } + +// Re-export for convenience if ControlsClientPage needs them directly from this file +export type { SortDirection, SortableColumnOption }; diff --git a/apps/framework-editor/app/(pages)/documents/DocumentControlsCell.tsx b/apps/framework-editor/app/(pages)/documents/DocumentControlsCell.tsx new file mode 100644 index 0000000000..e52c35ee11 --- /dev/null +++ b/apps/framework-editor/app/(pages)/documents/DocumentControlsCell.tsx @@ -0,0 +1,223 @@ +'use client'; + +import { apiClient } from '@/app/lib/api-client'; +import { Plus, X } from 'lucide-react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { toast } from 'sonner'; + +interface ControlTemplate { + id: string; + name: string; + documentTypes: string[]; +} + +interface DocumentControlsCellProps { + documentType: string; + controls: { id: string; name: string }[]; + onControlLinked: (documentType: string, control: { id: string; name: string }) => void; + onControlUnlinked: (documentType: string, controlId: string) => void; +} + +export function DocumentControlsCell({ + documentType, + controls, + onControlLinked, + onControlUnlinked, +}: DocumentControlsCellProps) { + const [isExpanded, setIsExpanded] = useState(false); + const [isSearching, setIsSearching] = useState(false); + const [search, setSearch] = useState(''); + const [allControls, setAllControls] = useState<{ id: string; name: string }[]>([]); + const [isLoading, setIsLoading] = useState(false); + const containerRef = useRef(null); + + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + setIsExpanded(false); + setIsSearching(false); + setSearch(''); + } + }; + if (isExpanded) { + document.addEventListener('mousedown', handleClickOutside); + } + return () => document.removeEventListener('mousedown', handleClickOutside); + }, [isExpanded]); + + useEffect(() => { + if (isSearching && allControls.length === 0) { + setIsLoading(true); + apiClient('/control-template') + .then((data: ControlTemplate[]) => + data.map((c: ControlTemplate) => ({ id: c.id, name: c.name || 'Unnamed Control' })), + ) + .then(setAllControls) + .catch(() => toast.error('Failed to load controls')) + .finally(() => setIsLoading(false)); + } + }, [isSearching, allControls.length]); + + const filteredControls = useMemo(() => { + const linkedIds = new Set(controls.map((c) => c.id)); + return allControls + .filter((c) => !linkedIds.has(c.id)) + .filter((c) => c.name.toLowerCase().includes(search.toLowerCase())); + }, [allControls, controls, search]); + + const handleLink = useCallback( + async (control: { id: string; name: string }) => { + try { + const current = await apiClient(`/control-template/${control.id}`); + const currentTypes: string[] = Array.isArray(current.documentTypes) + ? current.documentTypes + : []; + if (!currentTypes.includes(documentType)) { + await apiClient(`/control-template/${control.id}`, { + method: 'PATCH', + body: JSON.stringify({ documentTypes: [...currentTypes, documentType] }), + }); + } + onControlLinked(documentType, control); + toast.success(`Linked to ${control.name}`); + } catch { + toast.error('Failed to link control'); + } + setSearch(''); + setIsSearching(false); + }, + [documentType, onControlLinked], + ); + + const handleUnlink = useCallback( + async (controlId: string) => { + const control = controls.find((c) => c.id === controlId); + try { + const current = await apiClient(`/control-template/${controlId}`); + const currentTypes: string[] = Array.isArray(current.documentTypes) + ? current.documentTypes + : []; + await apiClient(`/control-template/${controlId}`, { + method: 'PATCH', + body: JSON.stringify({ + documentTypes: currentTypes.filter((t) => t !== documentType), + }), + }); + onControlUnlinked(documentType, controlId); + toast.success(`Unlinked from ${control?.name ?? 'control'}`); + } catch { + toast.error('Failed to unlink control'); + } + }, + [documentType, controls, onControlUnlinked], + ); + + if (!isExpanded) { + return ( +
setIsExpanded(true)} + > + {controls.length === 0 ? ( + None + ) : ( + + {controls.length} {controls.length === 1 ? 'control' : 'controls'} + + )} +
+ ); + } + + return ( +
+
+ Linked Controls + +
+ +
+ {controls.length === 0 ? ( +
+ No controls linked +
+ ) : ( +
+ {controls.map((control) => ( +
+ {control.name} + +
+ ))} +
+ )} +
+ +
+ {isSearching ? ( + <> + setSearch(e.target.value)} + className="border-border bg-background mb-2 w-full rounded-xs border px-2 py-1.5 text-sm outline-none focus:border-primary" + autoFocus + /> +
+ {isLoading ? ( +
Loading...
+ ) : filteredControls.length === 0 ? ( +
+ {search ? 'No matches' : 'All linked'} +
+ ) : ( + filteredControls.slice(0, 10).map((control) => ( + + )) + )} +
+ + ) : ( + + )} +
+
+ ); +} diff --git a/apps/framework-editor/app/(pages)/documents/DocumentsClientPage.tsx b/apps/framework-editor/app/(pages)/documents/DocumentsClientPage.tsx new file mode 100644 index 0000000000..a14d92967e --- /dev/null +++ b/apps/framework-editor/app/(pages)/documents/DocumentsClientPage.tsx @@ -0,0 +1,204 @@ +'use client'; + +import { + createColumnHelper, + flexRender, + getCoreRowModel, + getSortedRowModel, + useReactTable, + type SortingState, +} from '@tanstack/react-table'; +import { Badge } from '@trycompai/ui'; +import { ArrowDown, ArrowUp, ArrowUpDown } from 'lucide-react'; +import { useMemo, useState } from 'react'; +import { DOCUMENT_TYPE_OPTIONS } from '../controls/document-type-options'; +import { DocumentControlsCell } from './DocumentControlsCell'; + +interface ControlWithDocumentTypes { + id: string; + name: string; + documentTypes: string[]; +} + +interface DocumentRow { + value: string; + label: string; + category: string; + controls: { id: string; name: string }[]; + controlCount: number; +} + +interface DocumentsClientPageProps { + controls: ControlWithDocumentTypes[]; +} + +const columnHelper = createColumnHelper(); + +export function DocumentsClientPage({ controls }: DocumentsClientPageProps) { + const [controlsState, setControlsState] = useState(controls); + + const data: DocumentRow[] = useMemo(() => { + return DOCUMENT_TYPE_OPTIONS.map((opt: { value: string; label: string; category?: string }) => { + const linkedControls = controlsState + .filter((c) => (c.documentTypes as string[]).includes(opt.value)) + .map((c) => ({ id: c.id, name: c.name })); + + return { + value: opt.value, + label: opt.label, + category: opt.category ?? 'Uncategorized', + controls: linkedControls, + controlCount: linkedControls.length, + }; + }); + }, [controlsState]); + + const handleControlLinked = (documentType: string, control: { id: string; name: string }) => { + setControlsState((prev) => { + const exists = prev.some((c) => c.id === control.id); + if (exists) { + return prev.map((c) => + c.id === control.id + ? { ...c, documentTypes: [...(c.documentTypes as string[]), documentType] } + : c, + ); + } + return [...prev, { id: control.id, name: control.name, documentTypes: [documentType] }]; + }); + }; + + const handleControlUnlinked = (documentType: string, controlId: string) => { + setControlsState((prev) => + prev.map((c) => + c.id === controlId + ? { ...c, documentTypes: (c.documentTypes as string[]).filter((t) => t !== documentType) } + : c, + ), + ); + }; + + const columns = useMemo( + () => [ + columnHelper.accessor('label', { + header: 'Document Type', + size: 280, + cell: ({ getValue }) => ( + {getValue()} + ), + }), + columnHelper.accessor('category', { + header: 'Category', + size: 160, + cell: ({ getValue }) => ( +
+ {getValue()} +
+ ), + }), + columnHelper.accessor('controls', { + header: 'Linked Controls', + size: 300, + enableSorting: false, + cell: ({ row }) => ( +
+ +
+ ), + }), + columnHelper.accessor('controlCount', { + header: 'Count', + size: 80, + cell: ({ getValue }) => ( + + {getValue()} + + ), + }), + columnHelper.accessor('value', { + header: 'Key', + size: 240, + cell: ({ getValue }) => ( + + {getValue()} + + ), + }), + ], + [handleControlLinked, handleControlUnlinked], + ); + + const [sorting, setSorting] = useState([]); + + const table = useReactTable({ + data, + columns, + state: { sorting }, + onSortingChange: setSorting, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getRowId: (row) => row.value, + }); + + return ( +
+
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + ))} + + ))} + + + {table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + ))} + + ))} + +
+ {header.isPlaceholder ? null : header.column.getCanSort() ? ( + + ) : ( + flexRender(header.column.columnDef.header, header.getContext()) + )} +
+ {flexRender(cell.column.columnDef.cell, cell.getContext())} +
+
+
+ ); +} diff --git a/apps/framework-editor/app/(pages)/frameworks/FrameworksClientPage.tsx b/apps/framework-editor/app/(pages)/frameworks/FrameworksClientPage.tsx new file mode 100644 index 0000000000..05a2dbd1b2 --- /dev/null +++ b/apps/framework-editor/app/(pages)/frameworks/FrameworksClientPage.tsx @@ -0,0 +1,46 @@ +'use client'; + +import { useRouter } from 'next/navigation'; +import { useState } from 'react'; +// import { db } from "@trycompai/db"; +import { DataTable } from '@/app/components/DataTable'; +import PageLayout from '@/app/components/PageLayout'; +import type { FrameworkEditorFramework } from '@/db'; +import { columns } from './components/columns'; +import { CreateFrameworkDialog } from './components/CreateFrameworkDialog'; + +export interface FrameworkWithCounts extends Omit { + requirementsCount: number; + controlsCount: number; +} + +interface FrameworksClientPageProps { + initialFrameworks: FrameworkWithCounts[]; +} + +export function FrameworksClientPage({ initialFrameworks }: FrameworksClientPageProps) { + const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); + const router = useRouter(); + + const handleRowClick = (framework: FrameworkWithCounts) => { + router.push(`/frameworks/${framework.id}`); + }; + + return ( + + setIsCreateDialogOpen(true)} + createButtonLabel="Create New Framework" + onRowClick={handleRowClick} + /> + setIsCreateDialogOpen(false)} + /> + + ); +} diff --git a/apps/framework-editor/app/(pages)/frameworks/[frameworkId]/FrameworkRequirementsClientPage.tsx b/apps/framework-editor/app/(pages)/frameworks/[frameworkId]/FrameworkRequirementsClientPage.tsx new file mode 100644 index 0000000000..41a0d8f922 --- /dev/null +++ b/apps/framework-editor/app/(pages)/frameworks/[frameworkId]/FrameworkRequirementsClientPage.tsx @@ -0,0 +1,348 @@ +'use client'; + +import { apiClient } from '@/app/lib/api-client'; +import { + createColumnHelper, + flexRender, + getCoreRowModel, + getSortedRowModel, + useReactTable, + type SortingState, +} from '@tanstack/react-table'; +import { Button } from '@trycompai/ui'; +import { ArrowDown, ArrowUp, ArrowUpDown, PencilIcon, Plus, Trash2 } from 'lucide-react'; +import { useRouter } from 'next/navigation'; +import { useCallback, useMemo, useState } from 'react'; +import { DateCell, EditableCell, RelationalCell } from '../../../components/table'; +import { EditFrameworkDialog } from './components/EditFrameworkDialog'; +import { DeleteFrameworkDialog } from './components/DeleteFrameworkDialog'; +import { + simpleUUID, + useRequirementChangeTracking, + type RequirementGridRow, +} from './hooks/useRequirementChangeTracking'; + +interface FrameworkDetails { + id: string; + name: string; + version: string; + description: string; + visible: boolean; +} + +interface RequirementInput { + id: string; + name: string; + identifier: string; + description: string; + frameworkId: string; + createdAt: string | Date; + updatedAt: string | Date; + controlTemplates?: Array<{ id: string; name: string }>; +} + +interface FrameworkRequirementsClientPageProps { + frameworkDetails: FrameworkDetails; + initialRequirements: RequirementInput[]; +} + +async function fetchAllControlTemplates() { + return apiClient>('/control-template'); +} + +async function linkControlToRequirement(requirementId: string, controlId: string) { + await apiClient(`/control-template/${controlId}/requirements/${requirementId}`, { + method: 'POST', + }); +} + +async function unlinkControlFromRequirement(requirementId: string, controlId: string) { + await apiClient(`/control-template/${controlId}/requirements/${requirementId}`, { + method: 'DELETE', + }); +} + +const columnHelper = createColumnHelper(); + +export function FrameworkRequirementsClientPage({ + frameworkDetails, + initialRequirements, +}: FrameworkRequirementsClientPageProps) { + const router = useRouter(); + const [isEditDialogOpen, setIsEditDialogOpen] = useState(false); + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + + const initialGridData: RequirementGridRow[] = useMemo( + () => + initialRequirements.map((r) => ({ + id: r.id, + name: r.name ?? null, + identifier: r.identifier ?? null, + description: r.description ?? null, + controlTemplates: r.controlTemplates ?? [], + controlTemplatesLength: r.controlTemplates?.length ?? 0, + createdAt: r.createdAt ? new Date(r.createdAt) : null, + updatedAt: r.updatedAt ? new Date(r.updatedAt) : null, + })), + [initialRequirements], + ); + + const { + data, + updateCell, + updateRelational, + addRow, + deleteRow, + getRowClassName, + handleCommit, + handleCancel, + isDirty, + createdIds, + changesSummary, + } = useRequirementChangeTracking(initialGridData, frameworkDetails.id); + + const columns = useMemo( + () => [ + columnHelper.accessor('identifier', { + header: 'Identifier', + size: 140, + cell: ({ row, getValue }) => ( + + ), + }), + columnHelper.accessor('name', { + header: 'Name', + size: 250, + cell: ({ row, getValue }) => ( + + ), + }), + columnHelper.accessor('description', { + header: 'Description', + size: 300, + maxSize: 300, + cell: ({ row, getValue }) => ( + + ), + }), + columnHelper.accessor('controlTemplates', { + header: 'Linked Controls', + size: 220, + enableSorting: false, + cell: ({ row, getValue }) => ( +
+ + updateRelational(row.original.id, newItems) + } + label="Control" + labelPlural="Controls" + /> +
+ ), + }), + columnHelper.accessor('createdAt', { + header: 'Created', + size: 160, + cell: ({ getValue }) => , + }), + columnHelper.accessor('updatedAt', { + header: 'Updated', + size: 160, + cell: ({ getValue }) => , + }), + columnHelper.display({ + id: 'actions', + header: '', + size: 50, + cell: ({ row }) => ( + + ), + }), + ], + [updateCell, updateRelational, deleteRow, createdIds], + ); + + const [sorting, setSorting] = useState([]); + + const table = useReactTable({ + data, + columns, + state: { sorting }, + onSortingChange: setSorting, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getRowId: (row) => row.id, + }); + + const handleAddRow = useCallback(() => { + addRow({ + id: simpleUUID(), + name: 'New Requirement', + identifier: '', + description: '', + controlTemplates: [], + controlTemplatesLength: 0, + createdAt: new Date(), + updatedAt: new Date(), + }); + }, [addRow]); + + const handleFrameworkUpdated = () => { + setIsEditDialogOpen(false); + router.refresh(); + }; + + return ( +
+
+
+ {isDirty && ( + <> + {changesSummary} + + + + )} +
+
+ + + +
+
+ +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + ))} + + ))} + + + {table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + ))} + + ))} + {table.getRowModel().rows.length === 0 && ( + + + + )} + +
+ {header.isPlaceholder ? null : header.column.getCanSort() ? ( + + ) : ( + flexRender(header.column.columnDef.header, header.getContext()) + )} +
+ {flexRender(cell.column.columnDef.cell, cell.getContext())} +
+ No requirements yet. Click "Add Requirement" to create one. +
+
+ + {isEditDialogOpen && ( + + )} + {isDeleteDialogOpen && ( + + )} +
+ ); +} diff --git a/apps/framework-editor/app/(pages)/frameworks/[frameworkId]/FrameworkTabs.tsx b/apps/framework-editor/app/(pages)/frameworks/[frameworkId]/FrameworkTabs.tsx new file mode 100644 index 0000000000..cbf0e528b6 --- /dev/null +++ b/apps/framework-editor/app/(pages)/frameworks/[frameworkId]/FrameworkTabs.tsx @@ -0,0 +1,37 @@ +'use client'; + +import { Tabs, TabsList, TabsTrigger } from '@trycompai/ui'; +import Link from 'next/link'; +import { useParams, useSelectedLayoutSegment } from 'next/navigation'; + +export function FrameworkTabs() { + const { frameworkId } = useParams<{ frameworkId: string }>(); + const segment = useSelectedLayoutSegment(); + + const tabs = [ + { name: 'Requirements', href: `/frameworks/${frameworkId}`, segment: null }, + { name: 'Controls', href: `/frameworks/${frameworkId}/controls`, segment: 'controls' }, + { name: 'Policies', href: `/frameworks/${frameworkId}/policies`, segment: 'policies' }, + { name: 'Tasks', href: `/frameworks/${frameworkId}/tasks`, segment: 'tasks' }, + { name: 'Documents', href: `/frameworks/${frameworkId}/documents`, segment: 'documents' }, + ]; + + const activeValue = segment ?? 'requirements'; + + return ( + + + {tabs.map((tab) => ( + + {tab.name} + + ))} + + + ); +} diff --git a/apps/framework-editor/app/(pages)/frameworks/[frameworkId]/components/DeleteFrameworkDialog.tsx b/apps/framework-editor/app/(pages)/frameworks/[frameworkId]/components/DeleteFrameworkDialog.tsx new file mode 100644 index 0000000000..99288902aa --- /dev/null +++ b/apps/framework-editor/app/(pages)/frameworks/[frameworkId]/components/DeleteFrameworkDialog.tsx @@ -0,0 +1,84 @@ +'use client'; + +import { apiClient } from '@/app/lib/api-client'; +import { + AlertDialog, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + Button, +} from '@trycompai/ui'; +import { useRouter } from 'next/navigation'; +import { useState } from 'react'; +import { toast } from 'sonner'; + +interface DeleteFrameworkDialogProps { + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; + frameworkId: string; + frameworkName: string; +} + +export function DeleteFrameworkDialog({ + isOpen, + onOpenChange, + frameworkId, + frameworkName, +}: DeleteFrameworkDialogProps) { + const router = useRouter(); + const [isPending, setIsPending] = useState(false); + const [error, setError] = useState(undefined); + + const handleDelete = async () => { + setError(undefined); + setIsPending(true); + try { + await apiClient(`/framework/${frameworkId}`, { method: 'DELETE' }); + toast.success('Framework deleted successfully.'); + onOpenChange(false); + router.push('/frameworks'); + router.refresh(); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to delete framework.'; + setError(message); + toast.error(message); + } finally { + setIsPending(false); + } + }; + + return ( + { + if (isPending && !open) return; + if (!open) setError(undefined); + onOpenChange(open); + }} + > + + + + Are you sure you want to delete {`"${frameworkName}"`}? + + + This action cannot be undone. This will permanently delete the framework and all of its + associated requirements. + {error &&

Error: {error}

} +
+
+ + onOpenChange(false)}> + Cancel + + + +
+
+ ); +} diff --git a/apps/framework-editor/app/(pages)/frameworks/[frameworkId]/components/EditFrameworkDialog.tsx b/apps/framework-editor/app/(pages)/frameworks/[frameworkId]/components/EditFrameworkDialog.tsx new file mode 100644 index 0000000000..2543e9ffad --- /dev/null +++ b/apps/framework-editor/app/(pages)/frameworks/[frameworkId]/components/EditFrameworkDialog.tsx @@ -0,0 +1,199 @@ +'use client'; + +import { apiClient } from '@/app/lib/api-client'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { + Button, + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, + Input, + Switch, + Textarea, +} from '@trycompai/ui'; +import { useRouter } from 'next/navigation'; +import { useEffect } from 'react'; +import { useForm } from 'react-hook-form'; +import { toast } from 'sonner'; +import { z } from 'zod'; +import { FrameworkBaseSchema } from '../../schemas'; + +interface FrameworkData { + id: string; + name: string; + description: string; + version: string; + visible: boolean; +} + +interface EditFrameworkDialogProps { + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; + framework: FrameworkData; + onFrameworkUpdated?: (updatedData: FrameworkData) => void; +} + +type FrameworkFormValues = z.infer; + +export function EditFrameworkDialog({ + isOpen, + onOpenChange, + framework, + onFrameworkUpdated, +}: EditFrameworkDialogProps) { + const router = useRouter(); + + const form = useForm({ + resolver: zodResolver(FrameworkBaseSchema), + defaultValues: { + name: framework.name, + description: framework.description, + version: framework.version, + visible: framework.visible, + }, + mode: 'onChange', + }); + + useEffect(() => { + form.reset({ + name: framework.name, + description: framework.description, + version: framework.version, + visible: framework.visible, + }); + }, [framework, form]); + + async function onSubmit(values: FrameworkFormValues) { + try { + const updatedFramework = await apiClient(`/framework/${framework.id}`, { + method: 'PATCH', + body: JSON.stringify({ + name: values.name, + description: values.description, + version: values.version, + visible: values.visible, + }), + }); + toast.success('Framework updated successfully!'); + onOpenChange(false); + onFrameworkUpdated?.(updatedFramework); + router.refresh(); + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to update framework.'; + toast.error(message); + } + } + + return ( + { + if (!open) { + form.reset({ + name: framework.name, + description: framework.description, + version: framework.version, + visible: framework.visible, + }); + } + onOpenChange(open); + }} + > + + + Edit Framework + + Update the details for the framework. Click save when you're done. + + +
+ + ( + + Name + + + +
+ +
+
+ )} + /> + ( + + Description + +