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