diff --git a/apps/api/package.json b/apps/api/package.json index 3f92db055..ada1af49e 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -1,134 +1,131 @@ { - "name": "@comp/api", - "description": "", - "version": "0.0.1", - "author": "", - "dependencies": { - "@ai-sdk/anthropic": "^2.0.53", - "@ai-sdk/groq": "^2.0.32", - "@ai-sdk/openai": "^2.0.65", - "@aws-sdk/client-s3": "^3.859.0", - "@aws-sdk/client-securityhub": "^3.948.0", - "@aws-sdk/client-sts": "^3.948.0", - "@aws-sdk/s3-request-presigner": "^3.859.0", - "@browserbasehq/sdk": "^2.6.0", - "@browserbasehq/stagehand": "^3.0.5", - "@comp/integration-platform": "workspace:*", - "@mendable/firecrawl-js": "^4.9.3", - "@nestjs/common": "^11.0.1", - "@nestjs/config": "^4.0.2", - "@nestjs/core": "^11.0.1", - "@nestjs/platform-express": "^11.1.5", - "@nestjs/swagger": "^11.2.0", - "@nestjs/throttler": "^6.5.0", - "@prisma/client": "6.18.0", - "@prisma/instrumentation": "^6.13.0", - "@react-email/components": "^0.0.41", - "@trigger.dev/build": "4.0.6", - "@trigger.dev/sdk": "4.0.6", - "@trycompai/db": "1.3.22", - "@trycompai/email": "workspace:*", - "@upstash/redis": "^1.34.2", - "@upstash/vector": "^1.2.2", - "adm-zip": "^0.5.16", - "ai": "^5.0.60", - "archiver": "^7.0.1", - "axios": "^1.12.2", - "better-auth": "^1.3.27", - "class-transformer": "^0.5.1", - "class-validator": "^0.14.2", - "dotenv": "^17.2.3", - "esbuild": "^0.27.1", - "exceljs": "^4.4.0", - "express": "^4.21.2", - "helmet": "^8.1.0", - "jose": "^6.0.12", - "jspdf": "^3.0.3", - "mammoth": "^1.8.0", - "nanoid": "^5.1.6", - "pdf-lib": "^1.17.1", - "playwright-core": "^1.57.0", - "prisma": "6.18.0", - "react": "^19.1.1", - "react-dom": "^19.1.0", - "reflect-metadata": "^0.2.2", - "resend": "^6.4.2", - "rxjs": "^7.8.1", - "safe-stable-stringify": "^2.5.0", - "swagger-ui-express": "^5.0.1", - "xlsx": "^0.18.5", - "zod": "^4.0.14" - }, - "devDependencies": { - "@eslint/eslintrc": "^3.2.0", - "@eslint/js": "^9.18.0", - "@nestjs/cli": "^11.0.0", - "@nestjs/schematics": "^11.0.0", - "@nestjs/testing": "^11.0.1", - "@types/adm-zip": "^0.5.7", - "@types/archiver": "^6.0.3", - "@types/express": "^5.0.0", - "@types/jest": "^30.0.0", - "@types/multer": "^1.4.12", - "@types/node": "^24.0.3", - "@types/supertest": "^6.0.2", - "@types/swagger-ui-express": "^4.1.8", - "eslint": "^9.18.0", - "eslint-config-prettier": "^10.0.1", - "eslint-plugin-prettier": "^5.2.2", - "globals": "^16.0.0", - "jest": "^30.0.0", - "prettier": "^3.5.3", - "source-map-support": "^0.5.21", - "supertest": "^7.0.0", - "ts-jest": "^29.2.5", - "ts-loader": "^9.5.2", - "ts-node": "^10.9.2", - "tsconfig-paths": "^4.2.0", - "typescript": "^5.8.3", - "typescript-eslint": "^8.20.0" - }, - "jest": { - "moduleFileExtensions": [ - "js", - "json", - "ts" - ], - "rootDir": "src", - "testRegex": ".*\\.spec\\.ts$", - "transform": { - "^.+\\.(t|j)s$": "ts-jest" + "name": "@comp/api", + "description": "", + "version": "0.0.1", + "author": "", + "dependencies": { + "@ai-sdk/anthropic": "^2.0.53", + "@ai-sdk/groq": "^2.0.32", + "@ai-sdk/openai": "^2.0.65", + "@aws-sdk/client-s3": "^3.859.0", + "@aws-sdk/client-securityhub": "^3.948.0", + "@aws-sdk/client-sts": "^3.948.0", + "@aws-sdk/s3-request-presigner": "^3.859.0", + "@browserbasehq/sdk": "^2.6.0", + "@browserbasehq/stagehand": "^3.0.5", + "@comp/company": "workspace:*", + "@comp/integration-platform": "workspace:*", + "@mendable/firecrawl-js": "^4.9.3", + "@nestjs/common": "^11.0.1", + "@nestjs/config": "^4.0.2", + "@nestjs/core": "^11.0.1", + "@nestjs/platform-express": "^11.1.5", + "@nestjs/swagger": "^11.2.0", + "@nestjs/throttler": "^6.5.0", + "@prisma/client": "6.18.0", + "@prisma/instrumentation": "^6.13.0", + "@react-email/components": "^0.0.41", + "@trigger.dev/build": "4.0.6", + "@trigger.dev/sdk": "4.0.6", + "@trycompai/db": "1.3.22", + "@trycompai/email": "workspace:*", + "@upstash/redis": "^1.34.2", + "@upstash/vector": "^1.2.2", + "adm-zip": "^0.5.16", + "ai": "^5.0.60", + "archiver": "^7.0.1", + "axios": "^1.12.2", + "better-auth": "^1.3.27", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.2", + "dotenv": "^17.2.3", + "esbuild": "^0.27.1", + "exceljs": "^4.4.0", + "express": "^4.21.2", + "helmet": "^8.1.0", + "jose": "^6.0.12", + "jspdf": "^3.0.3", + "mammoth": "^1.8.0", + "nanoid": "^5.1.6", + "pdf-lib": "^1.17.1", + "playwright-core": "^1.57.0", + "prisma": "6.18.0", + "react": "^19.1.1", + "react-dom": "^19.1.0", + "reflect-metadata": "^0.2.2", + "resend": "^6.4.2", + "rxjs": "^7.8.1", + "safe-stable-stringify": "^2.5.0", + "swagger-ui-express": "^5.0.1", + "xlsx": "^0.18.5", + "zod": "^4.0.14" }, - "collectCoverageFrom": [ - "**/*.(t|j)s" - ], - "coverageDirectory": "../coverage", - "testEnvironment": "node" - }, - "license": "UNLICENSED", - "private": true, - "scripts": { - "build": "nest build", - "build:docker": "bunx prisma generate && nest build", - "db:generate": "bun run db:getschema && bunx prisma generate", - "db:getschema": "node ../../packages/db/scripts/combine-schemas.js && cp ../../packages/db/dist/schema.prisma prisma/schema.prisma", - "db:migrate": "cd ../../packages/db && bunx prisma migrate dev && cd ../../apps/api", - "deploy:trigger-prod": "npx trigger.dev@4.0.6 deploy", - "dev": "bunx concurrently --kill-others --names \"nest,trigger\" --prefix-colors \"green,blue\" \"nest start --watch\" \"bunx trigger.dev@4.0.6 dev\"", - "dev:nest": "nest start --watch", - "dev:trigger": "bunx trigger.dev@4.0.6 dev", - "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", - "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", - "prebuild": "bun run db:generate", - "start": "nest start", - "start:debug": "nest start --debug --watch", - "start:dev": "nest start --watch", - "start:prod": "node dist/main", - "test": "jest", - "test:cov": "jest --coverage", - "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", - "test:e2e": "jest --config ./test/jest-e2e.json", - "test:watch": "jest --watch", - "typecheck": "tsc --noEmit" - } + "devDependencies": { + "@eslint/eslintrc": "^3.2.0", + "@eslint/js": "^9.18.0", + "@nestjs/cli": "^11.0.0", + "@nestjs/schematics": "^11.0.0", + "@nestjs/testing": "^11.0.1", + "@types/adm-zip": "^0.5.7", + "@types/archiver": "^6.0.3", + "@types/express": "^5.0.0", + "@types/jest": "^30.0.0", + "@types/multer": "^1.4.12", + "@types/node": "^24.0.3", + "@types/supertest": "^6.0.2", + "@types/swagger-ui-express": "^4.1.8", + "eslint": "^9.18.0", + "eslint-config-prettier": "^10.0.1", + "eslint-plugin-prettier": "^5.2.2", + "globals": "^16.0.0", + "jest": "^30.0.0", + "prettier": "^3.5.3", + "source-map-support": "^0.5.21", + "supertest": "^7.0.0", + "ts-jest": "^29.2.5", + "ts-loader": "^9.5.2", + "ts-node": "^10.9.2", + "tsconfig-paths": "^4.2.0", + "typescript": "^5.8.3", + "typescript-eslint": "^8.20.0" + }, + "jest": { + "moduleFileExtensions": [ + "js", "json", "ts" + ], + "rootDir": "src", + "testRegex": ".*\\.spec\\.ts$", + "transform": { + "^.+\\.(t|j)s$": "ts-jest" + }, + "collectCoverageFrom": ["**/*.(t|j)s"], + "coverageDirectory": "../coverage", + "testEnvironment": "node" + }, + "license": "UNLICENSED", + "private": true, + "scripts": { + "build": "nest build", + "build:docker": "bunx prisma generate && nest build", + "db:generate": "bun run db:getschema && bunx prisma generate", + "db:getschema": "node ../../packages/db/scripts/combine-schemas.js && cp ../../packages/db/dist/schema.prisma prisma/schema.prisma", + "db:migrate": "cd ../../packages/db && bunx prisma migrate dev && cd ../../apps/api", + "deploy:trigger-prod": "npx trigger.dev@4.0.6 deploy", + "dev": "bunx concurrently --kill-others --names \"nest,trigger\" --prefix-colors \"green,blue\" \"nest start --watch\" \"bunx trigger.dev@4.0.6 dev\"", + "dev:nest": "nest start --watch", + "dev:trigger": "bunx trigger.dev@4.0.6 dev", + "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", + "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", + "prebuild": "bun run db:generate", + "start": "nest start", + "start:debug": "nest start --debug --watch", + "start:dev": "nest start --watch", + "start:prod": "node dist/main", + "test": "jest", + "test:cov": "jest --coverage", + "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", + "test:e2e": "jest --config ./test/jest-e2e.json", + "test:watch": "jest --watch", + "typecheck": "tsc --noEmit" + } } diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 2645cb341..012a3c550 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -35,6 +35,7 @@ import { TaskManagementModule } from './task-management/task-management.module'; import { AssistantChatModule } from './assistant-chat/assistant-chat.module'; import { OrgChartModule } from './org-chart/org-chart.module'; import { TrainingModule } from './training/training.module'; +import { EvidenceFormsModule } from './evidence-forms/evidence-forms.module'; @Module({ imports: [ @@ -82,6 +83,7 @@ import { TrainingModule } from './training/training.module'; AssistantChatModule, TrainingModule, OrgChartModule, + EvidenceFormsModule, ], controllers: [AppController], providers: [ diff --git a/apps/api/src/evidence-forms/evidence-forms.controller.ts b/apps/api/src/evidence-forms/evidence-forms.controller.ts new file mode 100644 index 000000000..c478dfb58 --- /dev/null +++ b/apps/api/src/evidence-forms/evidence-forms.controller.ts @@ -0,0 +1,212 @@ +import { AuthContext, OrganizationId } from '@/auth/auth-context.decorator'; +import { HybridAuthGuard } from '@/auth/hybrid-auth.guard'; +import type { AuthContext as AuthContextType } from '@/auth/types'; +import { + Body, + Controller, + Get, + Header, + Param, + Patch, + Post, + Query, + Res, + UseGuards, +} from '@nestjs/common'; +import { ApiHeader, ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger'; +import type { Response } from 'express'; +import { EvidenceFormsService } from './evidence-forms.service'; + +@ApiTags('Evidence Forms') +@Controller({ path: 'evidence-forms', version: '1' }) +@UseGuards(HybridAuthGuard) +@ApiSecurity('apikey') +@ApiHeader({ + name: 'X-Organization-Id', + description: + 'Organization ID (required for session auth, optional for API key auth)', + required: false, +}) +export class EvidenceFormsController { + constructor(private readonly evidenceFormsService: EvidenceFormsService) {} + + @Get() + @ApiOperation({ + summary: 'List evidence forms', + description: 'List all available pre-built evidence forms', + }) + listForms() { + return this.evidenceFormsService.listForms(); + } + + @Get('statuses') + @ApiOperation({ + summary: 'Get submission statuses for all forms', + description: + 'Returns the latest submission date per form type for the active organization', + }) + async getFormStatuses(@OrganizationId() organizationId: string) { + return this.evidenceFormsService.getFormStatuses(organizationId); + } + + @Get('my-submissions') + @ApiOperation({ + summary: 'Get current user submissions', + description: + 'Returns all evidence form submissions by the authenticated user for the active organization', + }) + async getMySubmissions( + @OrganizationId() organizationId: string, + @AuthContext() authContext: AuthContextType, + @Query('formType') formType?: string, + ) { + return this.evidenceFormsService.getMySubmissions({ + organizationId, + authContext, + formType, + }); + } + + @Get('my-submissions/pending-count') + @ApiOperation({ + summary: 'Get pending submission count for current user', + description: + 'Returns the count of pending evidence submissions for the authenticated user', + }) + async getPendingSubmissionCount( + @OrganizationId() organizationId: string, + @AuthContext() authContext: AuthContextType, + ) { + return this.evidenceFormsService.getPendingSubmissionCount({ + organizationId, + authContext, + }); + } + + @Get(':formType') + @ApiOperation({ + summary: 'Get form definition and submissions', + description: + 'Fetch a specific form definition with submissions for the active organization', + }) + async getFormWithSubmissions( + @OrganizationId() organizationId: string, + @AuthContext() authContext: AuthContextType, + @Param('formType') formType: string, + @Query('search') search?: string, + @Query('limit') limit?: string, + @Query('offset') offset?: string, + ) { + return this.evidenceFormsService.getFormWithSubmissions({ + organizationId, + authContext, + formType, + search, + limit, + offset, + }); + } + + @Get(':formType/submissions/:submissionId') + @ApiOperation({ + summary: 'Get a single submission', + description: + 'Fetch one evidence form submission for the active organization', + }) + async getSubmission( + @OrganizationId() organizationId: string, + @AuthContext() authContext: AuthContextType, + @Param('formType') formType: string, + @Param('submissionId') submissionId: string, + ) { + return this.evidenceFormsService.getSubmission({ + organizationId, + authContext, + formType, + submissionId, + }); + } + + @Post(':formType/submissions') + @ApiOperation({ + summary: 'Submit evidence form entry', + description: + 'Create a new organization-scoped evidence form submission using Zod-validated payloads', + }) + async submitForm( + @OrganizationId() organizationId: string, + @AuthContext() authContext: AuthContextType, + @Param('formType') formType: string, + @Body() body: unknown, + ) { + return this.evidenceFormsService.submitForm({ + organizationId, + formType, + payload: body, + authContext, + }); + } + + @Patch(':formType/submissions/:submissionId/review') + @ApiOperation({ + summary: 'Review a submission', + description: + 'Approve or reject an evidence form submission with an optional reason', + }) + async reviewSubmission( + @OrganizationId() organizationId: string, + @AuthContext() authContext: AuthContextType, + @Param('formType') formType: string, + @Param('submissionId') submissionId: string, + @Body() body: unknown, + ) { + return this.evidenceFormsService.reviewSubmission({ + organizationId, + formType, + submissionId, + payload: body, + authContext, + }); + } + + @Post('uploads') + @ApiOperation({ + summary: 'Upload evidence form file', + description: + 'Upload a file for evidence form fields and return file metadata for submission payload', + }) + async uploadFile( + @OrganizationId() organizationId: string, + @AuthContext() authContext: AuthContextType, + @Body() body: unknown, + ) { + return this.evidenceFormsService.uploadFile({ + organizationId, + authContext, + payload: body, + }); + } + + @Get(':formType/export.csv') + @ApiOperation({ + summary: 'Export form submissions to CSV', + description: 'Export all form submissions for an organization as CSV', + }) + @Header('Content-Type', 'text/csv') + async exportCsv( + @OrganizationId() organizationId: string, + @AuthContext() authContext: AuthContextType, + @Param('formType') formType: string, + @Res() res: Response, + ) { + const csv = await this.evidenceFormsService.exportCsv({ + organizationId, + authContext, + formType, + }); + + const filename = `${formType}-submissions-${new Date().toISOString().slice(0, 10)}.csv`; + res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); + res.send(csv); + } +} diff --git a/apps/api/src/evidence-forms/evidence-forms.definitions.ts b/apps/api/src/evidence-forms/evidence-forms.definitions.ts new file mode 100644 index 000000000..2b94ea7b2 --- /dev/null +++ b/apps/api/src/evidence-forms/evidence-forms.definitions.ts @@ -0,0 +1,11 @@ +// Single source of truth: re-export from shared @comp/company package +export { + evidenceFormTypeSchema, + evidenceFormFileSchema, + evidenceFormSubmissionSchemaMap, + evidenceFormDefinitions, + evidenceFormDefinitionList, + type EvidenceFormType, + type EvidenceFormFieldDefinition, + type EvidenceFormDefinition, +} from '@comp/company'; diff --git a/apps/api/src/evidence-forms/evidence-forms.module.ts b/apps/api/src/evidence-forms/evidence-forms.module.ts new file mode 100644 index 000000000..333ffaa5d --- /dev/null +++ b/apps/api/src/evidence-forms/evidence-forms.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { AttachmentsModule } from '@/attachments/attachments.module'; +import { AuthModule } from '@/auth/auth.module'; +import { EvidenceFormsController } from './evidence-forms.controller'; +import { EvidenceFormsService } from './evidence-forms.service'; + +@Module({ + imports: [AuthModule, AttachmentsModule], + controllers: [EvidenceFormsController], + providers: [EvidenceFormsService], + exports: [EvidenceFormsService], +}) +export class EvidenceFormsModule {} diff --git a/apps/api/src/evidence-forms/evidence-forms.service.ts b/apps/api/src/evidence-forms/evidence-forms.service.ts new file mode 100644 index 000000000..0e63d7467 --- /dev/null +++ b/apps/api/src/evidence-forms/evidence-forms.service.ts @@ -0,0 +1,619 @@ +import { AttachmentsService } from '@/attachments/attachments.service'; +import type { AuthContext } from '@/auth/types'; +import { db } from '@trycompai/db'; +import { + BadRequestException, + Injectable, + NotFoundException, + UnauthorizedException, +} from '@nestjs/common'; +import { z } from 'zod'; +import { + evidenceFormDefinitionList, + evidenceFormDefinitions, + evidenceFormSubmissionSchemaMap, + evidenceFormTypeSchema, + type EvidenceFormFieldDefinition, + type EvidenceFormType, +} from './evidence-forms.definitions'; + +const listQuerySchema = z.object({ + search: z.string().trim().optional(), + limit: z.coerce.number().int().min(1).max(200).optional().default(50), + offset: z.coerce.number().int().min(0).optional().default(0), +}); + +const uploadSchema = z.object({ + formType: evidenceFormTypeSchema, + fileName: z.string().min(1), + fileType: z.string().min(1), + fileData: z.string().min(1), +}); + +const reviewSchema = z.object({ + action: z.enum(['approved', 'rejected']), + reason: z.string().trim().optional(), +}); + +const EVIDENCE_FORM_REVIEWER_ROLES = ['owner', 'admin', 'auditor'] as const; +const MAX_UPLOAD_FILE_SIZE_BYTES = 100 * 1024 * 1024; +const MAX_UPLOAD_BASE64_LENGTH = Math.ceil(MAX_UPLOAD_FILE_SIZE_BYTES / 3) * 4; + +function toCsvRow(values: string[]): string { + return values.map((value) => `"${value.replace(/"/g, '""')}"`).join(','); +} + +function flattenValue(value: unknown): string { + if (value === null || value === undefined) { + return ''; + } + + if (typeof value === 'object') { + if ( + 'fileName' in value && + typeof value.fileName === 'string' && + 'downloadUrl' in value && + typeof value.downloadUrl === 'string' + ) { + return value.downloadUrl; + } + return JSON.stringify(value); + } + + if (typeof value === 'string') { + return value; + } + if ( + typeof value === 'number' || + typeof value === 'boolean' || + typeof value === 'bigint' + ) { + return value.toString(); + } + if (typeof value === 'symbol') { + return value.description ?? ''; + } + + return ''; +} + +function flattenMatrixRows( + value: unknown, + field: EvidenceFormFieldDefinition, +): string { + if (!Array.isArray(value)) { + return ''; + } + + const columns = Array.isArray(field.columns) ? field.columns : []; + if (columns.length === 0) { + return JSON.stringify(value); + } + + return value + .filter((row) => row && typeof row === 'object') + .map((row) => { + const rowRecord = row as Record; + return columns + .map((column) => { + const cellValue = rowRecord[column.key]; + const normalizedValue = + typeof cellValue === 'string' ? cellValue : ''; + return `${column.label}: ${normalizedValue}`; + }) + .join(' | '); + }) + .join(' || '); +} + +@Injectable() +export class EvidenceFormsService { + constructor(private readonly attachmentsService: AttachmentsService) {} + + private requireJwtUser(authContext: AuthContext): string { + if (authContext.isApiKey || authContext.authType === 'api-key') { + throw new UnauthorizedException( + 'This endpoint requires JWT authentication and does not support API key authentication', + ); + } + + if (!authContext.userId) { + throw new UnauthorizedException('Authenticated user session is required'); + } + + return authContext.userId; + } + + private requirePrivilegedEvidenceAccess(authContext: AuthContext): string { + const userId = this.requireJwtUser(authContext); + const roles = authContext.userRoles ?? []; + const hasRequiredRole = EVIDENCE_FORM_REVIEWER_ROLES.some((role) => + roles.includes(role), + ); + + if (!hasRequiredRole) { + throw new UnauthorizedException( + `Access denied. Required one of roles: ${EVIDENCE_FORM_REVIEWER_ROLES.join(', ')}`, + ); + } + + return userId; + } + + private decodeBase64File(fileData: string): Buffer { + const normalized = fileData.trim(); + if (normalized.length === 0 || normalized.length % 4 !== 0) { + throw new BadRequestException( + 'Invalid file data. Expected base64 string.', + ); + } + + const base64Pattern = /^[A-Za-z0-9+/]+={0,2}$/; + if (!base64Pattern.test(normalized)) { + throw new BadRequestException( + 'Invalid file data. Expected base64 string.', + ); + } + + const fileBuffer = Buffer.from(normalized, 'base64'); + if (!fileBuffer.length) { + throw new BadRequestException('File cannot be empty'); + } + + return fileBuffer; + } + + listForms() { + return evidenceFormDefinitionList; + } + + async getFormStatuses(organizationId: string) { + const results = await db.evidenceSubmission.groupBy({ + by: ['formType'], + where: { organizationId }, + _max: { submittedAt: true }, + }); + + const statuses: Record = {}; + + for (const form of evidenceFormDefinitionList) { + const match = results.find((r) => r.formType === form.type); + statuses[form.type] = { + lastSubmittedAt: match?._max.submittedAt?.toISOString() ?? null, + }; + } + + return statuses; + } + + async getFormWithSubmissions(params: { + organizationId: string; + authContext: AuthContext; + formType: string; + search?: string; + limit?: string; + offset?: string; + }) { + const { organizationId, formType } = params; + this.requirePrivilegedEvidenceAccess(params.authContext); + + const parsedType = evidenceFormTypeSchema.safeParse(formType); + if (!parsedType.success) { + throw new BadRequestException('Unsupported form type'); + } + + const parsedQuery = listQuerySchema.safeParse({ + search: params.search, + limit: params.limit, + offset: params.offset, + }); + if (!parsedQuery.success) { + throw new BadRequestException(parsedQuery.error.flatten()); + } + const query = parsedQuery.data; + + const submissions = await db.evidenceSubmission.findMany({ + where: { + organizationId, + formType: parsedType.data, + }, + include: { + submittedBy: { + select: { + id: true, + name: true, + email: true, + }, + }, + }, + orderBy: { + submittedAt: 'desc', + }, + }); + + const filtered = query.search + ? submissions.filter((submission) => { + const searchTarget = JSON.stringify(submission.data).toLowerCase(); + return searchTarget.includes(query.search!.toLowerCase()); + }) + : submissions; + + const paginated = filtered.slice(query.offset, query.offset + query.limit); + + return { + form: evidenceFormDefinitions[parsedType.data], + submissions: paginated, + total: filtered.length, + }; + } + + async getSubmission(params: { + organizationId: string; + authContext: AuthContext; + formType: string; + submissionId: string; + }) { + this.requirePrivilegedEvidenceAccess(params.authContext); + + const parsedType = evidenceFormTypeSchema.safeParse(params.formType); + if (!parsedType.success) { + throw new BadRequestException('Unsupported form type'); + } + + const submission = await db.evidenceSubmission.findFirst({ + where: { + id: params.submissionId, + organizationId: params.organizationId, + formType: parsedType.data, + }, + include: { + submittedBy: { + select: { + id: true, + name: true, + email: true, + }, + }, + reviewedBy: { + select: { + id: true, + name: true, + email: true, + }, + }, + }, + }); + + if (!submission) { + throw new NotFoundException('Submission not found'); + } + + return { + form: evidenceFormDefinitions[parsedType.data], + submission, + }; + } + + async submitForm(params: { + organizationId: string; + formType: string; + payload: unknown; + authContext: AuthContext; + }) { + const parsedType = evidenceFormTypeSchema.safeParse(params.formType); + if (!parsedType.success) { + throw new BadRequestException('Unsupported form type'); + } + + if (!params.authContext.userId) { + throw new BadRequestException( + 'Authenticated user session is required to submit evidence forms', + ); + } + + const formDefinition = evidenceFormDefinitions[parsedType.data]; + const nowIso = new Date().toISOString(); + + if (!params.payload || typeof params.payload !== 'object') { + throw new BadRequestException('Submission payload must be an object'); + } + + const payloadObject: Record = { + ...(params.payload as Record), + }; + + if (formDefinition.submissionDateMode === 'auto') { + payloadObject.submissionDate = nowIso; + } + + const schema = evidenceFormSubmissionSchemaMap[parsedType.data]; + const parsedPayload = schema.safeParse(payloadObject); + if (!parsedPayload.success) { + throw new BadRequestException(parsedPayload.error.flatten()); + } + + return await db.evidenceSubmission.create({ + data: { + organizationId: params.organizationId, + formType: parsedType.data, + submittedById: params.authContext.userId, + data: parsedPayload.data, + }, + include: { + submittedBy: { + select: { + id: true, + name: true, + email: true, + }, + }, + }, + }); + } + + async uploadFile(params: { + organizationId: string; + authContext: AuthContext; + payload: unknown; + }) { + if (!params.authContext.userId) { + throw new BadRequestException( + 'Authenticated user session is required to upload evidence files', + ); + } + + const parsed = uploadSchema.safeParse(params.payload); + if (!parsed.success) { + throw new BadRequestException(parsed.error.flatten()); + } + + if (parsed.data.fileData.length > MAX_UPLOAD_BASE64_LENGTH) { + throw new BadRequestException( + `File exceeds the ${MAX_UPLOAD_FILE_SIZE_BYTES / (1024 * 1024)}MB limit`, + ); + } + + const fileBuffer = this.decodeBase64File(parsed.data.fileData); + if (fileBuffer.length > MAX_UPLOAD_FILE_SIZE_BYTES) { + throw new BadRequestException( + `File exceeds the ${MAX_UPLOAD_FILE_SIZE_BYTES / (1024 * 1024)}MB limit`, + ); + } + + const fileKey = await this.attachmentsService.uploadToS3( + fileBuffer, + parsed.data.fileName, + parsed.data.fileType, + params.organizationId, + 'evidence-forms', + parsed.data.formType, + ); + + const downloadUrl = + await this.attachmentsService.getPresignedDownloadUrl(fileKey); + + return { + fileName: parsed.data.fileName, + fileKey, + downloadUrl, + }; + } + + async exportCsv(params: { + organizationId: string; + formType: string; + authContext: AuthContext; + }) { + this.requirePrivilegedEvidenceAccess(params.authContext); + + const parsedType = evidenceFormTypeSchema.safeParse(params.formType); + if (!parsedType.success) { + throw new BadRequestException('Unsupported form type'); + } + + const formType: EvidenceFormType = parsedType.data; + const form = evidenceFormDefinitions[formType]; + + const submissions = await db.evidenceSubmission.findMany({ + where: { + organizationId: params.organizationId, + formType, + }, + include: { + submittedBy: { + select: { + name: true, + email: true, + }, + }, + }, + orderBy: { + submittedAt: 'desc', + }, + }); + + if (submissions.length === 0) { + throw new BadRequestException( + 'No submissions available for export for this form', + ); + } + + const headers = [ + 'submissionId', + 'submissionDate', + 'submittedByName', + 'submittedByEmail', + ...form.fields + .filter((field) => field.key !== 'submissionDate') + .map((field) => field.key), + ]; + + const rows = await Promise.all( + submissions.map(async (submission) => { + const data = submission.data as Record; + const fieldValues = await Promise.all( + form.fields + .filter((field) => field.key !== 'submissionDate') + .map(async (field) => { + const rawValue = data[field.key]; + if ( + rawValue && + typeof rawValue === 'object' && + 'fileKey' in rawValue && + typeof rawValue.fileKey === 'string' + ) { + const signedUrl = + await this.attachmentsService.getPresignedDownloadUrl( + rawValue.fileKey, + ); + return signedUrl; + } + if (field.type === 'matrix') { + return flattenMatrixRows(rawValue, field); + } + return flattenValue(rawValue); + }), + ); + + return [ + submission.id, + typeof data.submissionDate === 'string' + ? data.submissionDate + : submission.submittedAt.toISOString(), + submission.submittedBy?.name ?? '', + submission.submittedBy?.email ?? '', + ...fieldValues, + ]; + }), + ); + + const csvLines = [toCsvRow(headers), ...rows.map((row) => toCsvRow(row))]; + return csvLines.join('\n'); + } + + async reviewSubmission(params: { + organizationId: string; + formType: string; + submissionId: string; + payload: unknown; + authContext: AuthContext; + }) { + const parsedType = evidenceFormTypeSchema.safeParse(params.formType); + if (!parsedType.success) { + throw new BadRequestException('Unsupported form type'); + } + + const reviewerUserId = this.requirePrivilegedEvidenceAccess( + params.authContext, + ); + + const parsed = reviewSchema.safeParse(params.payload); + if (!parsed.success) { + throw new BadRequestException(parsed.error.flatten()); + } + + if (parsed.data.action === 'rejected' && !parsed.data.reason) { + throw new BadRequestException( + 'A reason is required when rejecting a submission', + ); + } + + const submission = await db.evidenceSubmission.findFirst({ + where: { + id: params.submissionId, + organizationId: params.organizationId, + formType: parsedType.data, + }, + }); + + if (!submission) { + throw new NotFoundException('Submission not found'); + } + + if (submission.status !== 'pending') { + throw new BadRequestException( + 'Submission must be pending to be reviewed', + ); + } + + return await db.evidenceSubmission.update({ + where: { id: params.submissionId }, + data: { + status: parsed.data.action, + reviewedById: reviewerUserId, + reviewedAt: new Date(), + reviewReason: parsed.data.reason ?? null, + }, + include: { + submittedBy: { + select: { + id: true, + name: true, + email: true, + }, + }, + reviewedBy: { + select: { + id: true, + name: true, + email: true, + }, + }, + }, + }); + } + + async getMySubmissions(params: { + organizationId: string; + authContext: AuthContext; + formType?: string; + }) { + const userId = this.requireJwtUser(params.authContext); + + const where: Record = { + organizationId: params.organizationId, + submittedById: userId, + }; + + if (params.formType) { + const parsedType = evidenceFormTypeSchema.safeParse(params.formType); + if (!parsedType.success) { + throw new BadRequestException('Unsupported form type'); + } + where.formType = parsedType.data; + } + + return await db.evidenceSubmission.findMany({ + where, + include: { + reviewedBy: { + select: { + id: true, + name: true, + email: true, + }, + }, + }, + orderBy: { + submittedAt: 'desc', + }, + }); + } + + async getPendingSubmissionCount(params: { + organizationId: string; + authContext: AuthContext; + }) { + const userId = this.requireJwtUser(params.authContext); + + const count = await db.evidenceSubmission.count({ + where: { + organizationId: params.organizationId, + submittedById: userId, + status: 'pending', + }, + }); + + return { count }; + } +} diff --git a/apps/api/src/findings/dto/create-finding.dto.ts b/apps/api/src/findings/dto/create-finding.dto.ts index ada5d1ee9..c759a68a8 100644 --- a/apps/api/src/findings/dto/create-finding.dto.ts +++ b/apps/api/src/findings/dto/create-finding.dto.ts @@ -12,10 +12,20 @@ export class CreateFindingDto { @ApiProperty({ description: 'Task ID this finding is associated with', example: 'tsk_abc123', + required: false, }) @IsString() - @IsNotEmpty() - taskId: string; + @IsOptional() + taskId?: string; + + @ApiProperty({ + description: 'Evidence submission ID this finding is associated with', + example: 'evs_abc123', + required: false, + }) + @IsString() + @IsOptional() + evidenceSubmissionId?: string; @ApiProperty({ description: 'Type of finding (SOC 2 or ISO 27001)', diff --git a/apps/api/src/findings/finding-audit.service.ts b/apps/api/src/findings/finding-audit.service.ts index d7698cead..19aa45966 100644 --- a/apps/api/src/findings/finding-audit.service.ts +++ b/apps/api/src/findings/finding-audit.service.ts @@ -17,8 +17,10 @@ export class FindingAuditService { */ async logFindingCreated( params: FindingAuditParams & { - taskId: string; - taskTitle: string; + taskId?: string; + taskTitle?: string; + evidenceSubmissionId?: string; + evidenceSubmissionFormType?: string; content: string; type: FindingType; }, @@ -37,6 +39,8 @@ export class FindingAuditService { findingId: params.findingId, taskId: params.taskId, taskTitle: params.taskTitle, + evidenceSubmissionId: params.evidenceSubmissionId, + evidenceSubmissionFormType: params.evidenceSubmissionFormType, content: params.content, type: params.type, status: FindingStatus.open, @@ -147,8 +151,10 @@ export class FindingAuditService { */ async logFindingDeleted( params: FindingAuditParams & { - taskId: string; - taskTitle: string; + taskId?: string; + taskTitle?: string; + evidenceSubmissionId?: string; + evidenceSubmissionFormType?: string; content: string; }, ): Promise { @@ -166,6 +172,8 @@ export class FindingAuditService { findingId: params.findingId, taskId: params.taskId, taskTitle: params.taskTitle, + evidenceSubmissionId: params.evidenceSubmissionId, + evidenceSubmissionFormType: params.evidenceSubmissionFormType, content: params.content, }, }, diff --git a/apps/api/src/findings/finding-notifier.service.ts b/apps/api/src/findings/finding-notifier.service.ts index d4ea71b8e..b22ca0175 100644 --- a/apps/api/src/findings/finding-notifier.service.ts +++ b/apps/api/src/findings/finding-notifier.service.ts @@ -32,8 +32,11 @@ interface Recipient { interface NotificationParams { organizationId: string; findingId: string; - taskId: string; - taskTitle: string; + taskId?: string; + taskTitle?: string; + evidenceSubmissionId?: string; + evidenceSubmissionFormType?: string; + evidenceSubmissionSubmittedById?: string | null; findingContent: string; findingType: FindingType; actorUserId: string; @@ -84,6 +87,19 @@ function getAppUrl(): string { ); } +function getDocumentContextTitle( + formType?: string, + evidenceSubmissionId?: string, +): string { + if (formType) { + return `Document submission (${formType})`; + } + if (evidenceSubmissionId) { + return `Document submission (${evidenceSubmissionId})`; + } + return 'Document submission'; +} + // ============================================================================ // Service // ============================================================================ @@ -107,29 +123,40 @@ export class FindingNotifierService { organizationId, taskId, taskTitle, + evidenceSubmissionId, + evidenceSubmissionFormType, + evidenceSubmissionSubmittedById, findingType, actorUserId, actorName, } = params; - const recipients = await this.getTaskAssigneeAndAdmins( - organizationId, - taskId, - actorUserId, - ); + const recipients = taskId + ? await this.getTaskAssigneeAndAdmins(organizationId, taskId, actorUserId) + : await this.getSubmissionSubmitterAndAdmins( + organizationId, + evidenceSubmissionId, + evidenceSubmissionSubmittedById, + actorUserId, + ); if (recipients.length === 0) { this.logger.log('No recipients for finding created notification'); return; } + const contextTitle = + taskTitle ?? + getDocumentContextTitle(evidenceSubmissionFormType, evidenceSubmissionId); + const contextLabel = taskId ? 'task' : 'document submission'; + await this.sendNotifications({ ...params, action: 'created', recipients, - subject: `New finding on task: ${taskTitle}`, + subject: `New finding on ${contextLabel}: ${contextTitle}`, heading: 'New Finding Created', - message: `${actorName} created a new ${TYPE_LABELS[findingType]} finding on the task "${taskTitle}".`, + message: `${actorName} created a new ${TYPE_LABELS[findingType]} finding on the ${contextLabel} "${contextTitle}".`, }); } @@ -143,6 +170,8 @@ export class FindingNotifierService { const { findingId, taskTitle, + evidenceSubmissionId, + evidenceSubmissionFormType, actorUserId, actorName, findingCreatorMemberId, @@ -168,13 +197,17 @@ export class FindingNotifierService { `[notifyReadyForReview] Finding ${findingId}: Sending to ${recipients.length} recipient(s): ${recipients.map((r) => r.email).join(', ')}`, ); + const contextTitle = + taskTitle ?? + getDocumentContextTitle(evidenceSubmissionFormType, evidenceSubmissionId); + await this.sendNotifications({ ...params, action: 'ready_for_review', recipients, - subject: `Finding ready for review: ${taskTitle}`, + subject: `Finding ready for review: ${contextTitle}`, heading: 'Finding Ready for Review', - message: `${actorName} marked a finding on "${taskTitle}" as ready for your review.`, + message: `${actorName} marked a finding on "${contextTitle}" as ready for your review.`, newStatus: FindingStatus.ready_for_review, }); } @@ -184,27 +217,42 @@ export class FindingNotifierService { * Recipients: Task assignee + Organization admins/owners */ async notifyNeedsRevision(params: NotificationParams): Promise { - const { organizationId, taskId, taskTitle, actorUserId, actorName } = - params; - - const recipients = await this.getTaskAssigneeAndAdmins( + const { organizationId, taskId, + taskTitle, + evidenceSubmissionId, + evidenceSubmissionFormType, + evidenceSubmissionSubmittedById, actorUserId, - ); + actorName, + } = params; + + const recipients = taskId + ? await this.getTaskAssigneeAndAdmins(organizationId, taskId, actorUserId) + : await this.getSubmissionSubmitterAndAdmins( + organizationId, + evidenceSubmissionId, + evidenceSubmissionSubmittedById, + actorUserId, + ); if (recipients.length === 0) { this.logger.log('No recipients for needs revision notification'); return; } + const contextTitle = + taskTitle ?? + getDocumentContextTitle(evidenceSubmissionFormType, evidenceSubmissionId); + await this.sendNotifications({ ...params, action: 'needs_revision', recipients, - subject: `Finding needs revision: ${taskTitle}`, + subject: `Finding needs revision: ${contextTitle}`, heading: 'Finding Needs Revision', - message: `${actorName} reviewed a finding on "${taskTitle}" and marked it as needing revision.`, + message: `${actorName} reviewed a finding on "${contextTitle}" and marked it as needing revision.`, newStatus: FindingStatus.needs_revision, }); } @@ -214,27 +262,42 @@ export class FindingNotifierService { * Recipients: Task assignee + Organization admins/owners */ async notifyFindingClosed(params: NotificationParams): Promise { - const { organizationId, taskId, taskTitle, actorUserId, actorName } = - params; - - const recipients = await this.getTaskAssigneeAndAdmins( + const { organizationId, taskId, + taskTitle, + evidenceSubmissionId, + evidenceSubmissionFormType, + evidenceSubmissionSubmittedById, actorUserId, - ); + actorName, + } = params; + + const recipients = taskId + ? await this.getTaskAssigneeAndAdmins(organizationId, taskId, actorUserId) + : await this.getSubmissionSubmitterAndAdmins( + organizationId, + evidenceSubmissionId, + evidenceSubmissionSubmittedById, + actorUserId, + ); if (recipients.length === 0) { this.logger.log('No recipients for finding closed notification'); return; } + const contextTitle = + taskTitle ?? + getDocumentContextTitle(evidenceSubmissionFormType, evidenceSubmissionId); + await this.sendNotifications({ ...params, action: 'closed', recipients, - subject: `Finding closed: ${taskTitle}`, + subject: `Finding closed: ${contextTitle}`, heading: 'Finding Closed', - message: `${actorName} closed a finding on "${taskTitle}". The issue has been resolved.`, + message: `${actorName} closed a finding on "${contextTitle}". The issue has been resolved.`, newStatus: FindingStatus.closed, }); } @@ -255,6 +318,8 @@ export class FindingNotifierService { findingId, taskId, taskTitle, + evidenceSubmissionId, + evidenceSubmissionFormType, findingContent, findingType, action, @@ -272,7 +337,13 @@ export class FindingNotifierService { }); const organizationName = organization?.name ?? 'your organization'; - const findingUrl = `${getAppUrl()}/${organizationId}/tasks/${taskId}`; + const contextTitle = + taskTitle ?? + getDocumentContextTitle(evidenceSubmissionFormType, evidenceSubmissionId); + const findingUrl = + evidenceSubmissionId && evidenceSubmissionFormType + ? `${getAppUrl()}/${organizationId}/documents/${evidenceSubmissionFormType}/submissions/${evidenceSubmissionId}` + : `${getAppUrl()}/${organizationId}/tasks/${taskId}`; const typeLabel = TYPE_LABELS[findingType]; const statusLabel = newStatus ? STATUS_LABELS[newStatus] : undefined; @@ -285,7 +356,7 @@ export class FindingNotifierService { organizationName, findingId, taskId, - taskTitle, + taskTitle: contextTitle, findingContent, findingType: typeLabel, action, @@ -307,7 +378,7 @@ export class FindingNotifierService { organizationId: string; organizationName: string; findingId: string; - taskId: string; + taskId?: string; taskTitle: string; findingContent: string; findingType: string; @@ -460,7 +531,7 @@ export class FindingNotifierService { organizationId: string; organizationName: string; findingId: string; - taskId: string; + taskId?: string; taskTitle: string; findingType: string; findingContent: string; @@ -605,6 +676,103 @@ export class FindingNotifierService { } } + private async getSubmissionSubmitterAndAdmins( + organizationId: string, + evidenceSubmissionId: string | undefined, + submitterUserId: string | null | undefined, + excludeUserId: string, + ): Promise { + try { + const allMembers = await db.member.findMany({ + where: { + organizationId, + deactivated: false, + }, + select: { + role: true, + user: { select: { id: true, email: true, name: true } }, + }, + }); + + const adminMembers = allMembers.filter( + (member) => + member.role.includes('admin') || member.role.includes('owner'), + ); + + const recipients: Recipient[] = []; + const addedUserIds = new Set(); + + if (submitterUserId) { + const submitter = await db.user.findUnique({ + where: { id: submitterUserId }, + select: { id: true, email: true, name: true }, + }); + + if ( + submitter && + submitter.id !== excludeUserId && + submitter.email && + !addedUserIds.has(submitter.id) + ) { + recipients.push({ + userId: submitter.id, + email: submitter.email, + name: submitter.name || submitter.email, + }); + addedUserIds.add(submitter.id); + } + } else if (evidenceSubmissionId) { + const submission = await db.evidenceSubmission.findUnique({ + where: { id: evidenceSubmissionId }, + select: { + submittedBy: { + select: { id: true, email: true, name: true }, + }, + }, + }); + + const submitter = submission?.submittedBy; + if ( + submitter && + submitter.id !== excludeUserId && + submitter.email && + !addedUserIds.has(submitter.id) + ) { + recipients.push({ + userId: submitter.id, + email: submitter.email, + name: submitter.name || submitter.email, + }); + addedUserIds.add(submitter.id); + } + } + + for (const member of adminMembers) { + const user = member.user; + if ( + user.id !== excludeUserId && + user.email && + !addedUserIds.has(user.id) + ) { + recipients.push({ + userId: user.id, + email: user.email, + name: user.name || user.email, + }); + addedUserIds.add(user.id); + } + } + + return recipients; + } catch (error) { + this.logger.error( + 'Failed to get submission recipients:', + error instanceof Error ? error.message : 'Unknown error', + ); + return []; + } + } + /** * Get the finding creator as recipient (for Ready for Review notifications). * Excludes the actor (person who triggered the action). diff --git a/apps/api/src/findings/findings.controller.ts b/apps/api/src/findings/findings.controller.ts index a4006a2dd..937716f50 100644 --- a/apps/api/src/findings/findings.controller.ts +++ b/apps/api/src/findings/findings.controller.ts @@ -54,10 +54,16 @@ export class FindingsController { }) @ApiQuery({ name: 'taskId', - required: true, + required: false, description: 'Task ID to get findings for', example: 'tsk_abc123', }) + @ApiQuery({ + name: 'evidenceSubmissionId', + required: false, + description: 'Evidence submission ID to get findings for', + example: 'evs_abc123', + }) @ApiResponse({ status: 200, description: 'List of findings for the task', @@ -72,14 +78,31 @@ export class FindingsController { }) async getFindingsByTask( @Query('taskId') taskId: string, + @Query('evidenceSubmissionId') evidenceSubmissionId: string, @AuthContext() authContext: AuthContextType, ) { - if (!taskId) { - throw new BadRequestException('taskId query parameter is required'); + if (!taskId && !evidenceSubmissionId) { + throw new BadRequestException( + 'Either taskId or evidenceSubmissionId query parameter is required', + ); + } + + if (taskId && evidenceSubmissionId) { + throw new BadRequestException( + 'Provide only one target: taskId or evidenceSubmissionId', + ); } - return await this.findingsService.findByTaskId( + + if (taskId) { + return await this.findingsService.findByTaskId( + authContext.organizationId, + taskId, + ); + } + + return await this.findingsService.findByEvidenceSubmissionId( authContext.organizationId, - taskId, + evidenceSubmissionId, ); } diff --git a/apps/api/src/findings/findings.service.ts b/apps/api/src/findings/findings.service.ts index 57e6b8f26..8c6f11549 100644 --- a/apps/api/src/findings/findings.service.ts +++ b/apps/api/src/findings/findings.service.ts @@ -14,6 +14,41 @@ import { FindingNotifierService } from './finding-notifier.service'; @Injectable() export class FindingsService { private readonly logger = new Logger(FindingsService.name); + private readonly findingInclude = { + createdBy: { + include: { + user: { + select: { + id: true, + name: true, + email: true, + image: true, + }, + }, + }, + }, + template: { + select: { + id: true, + category: true, + title: true, + }, + }, + task: { + select: { + id: true, + title: true, + }, + }, + evidenceSubmission: { + select: { + id: true, + formType: true, + submittedAt: true, + submittedById: true, + }, + }, + }; constructor( private readonly findingAuditService: FindingAuditService, @@ -37,33 +72,7 @@ export class FindingsService { const findings = await db.finding.findMany({ where: { taskId, organizationId }, - include: { - createdBy: { - include: { - user: { - select: { - id: true, - name: true, - email: true, - image: true, - }, - }, - }, - }, - template: { - select: { - id: true, - category: true, - title: true, - }, - }, - task: { - select: { - id: true, - title: true, - }, - }, - }, + include: this.findingInclude, orderBy: [ // Sort by status: open first, then ready_for_review, needs_revision, closed { status: 'asc' }, @@ -75,6 +84,35 @@ export class FindingsService { return findings; } + /** + * Get all findings for a specific evidence submission + */ + async findByEvidenceSubmissionId( + organizationId: string, + evidenceSubmissionId: string, + ) { + const submission = await db.evidenceSubmission.findFirst({ + where: { id: evidenceSubmissionId, organizationId }, + }); + + if (!submission) { + throw new NotFoundException( + `Evidence submission with ID ${evidenceSubmissionId} not found in organization`, + ); + } + + const findings = await db.finding.findMany({ + where: { evidenceSubmissionId, organizationId }, + include: this.findingInclude, + orderBy: [{ status: 'asc' }, { createdAt: 'desc' }], + }); + + this.logger.log( + `Retrieved ${findings.length} findings for evidence submission ${evidenceSubmissionId}`, + ); + return findings; + } + /** * Get all findings for an organization */ @@ -84,33 +122,7 @@ export class FindingsService { organizationId, ...(status && { status }), }, - include: { - createdBy: { - include: { - user: { - select: { - id: true, - name: true, - email: true, - image: true, - }, - }, - }, - }, - template: { - select: { - id: true, - category: true, - title: true, - }, - }, - task: { - select: { - id: true, - title: true, - }, - }, - }, + include: this.findingInclude, orderBy: [{ status: 'asc' }, { createdAt: 'desc' }], }); @@ -126,33 +138,7 @@ export class FindingsService { async findById(organizationId: string, findingId: string) { const finding = await db.finding.findFirst({ where: { id: findingId, organizationId }, - include: { - createdBy: { - include: { - user: { - select: { - id: true, - name: true, - email: true, - image: true, - }, - }, - }, - }, - template: { - select: { - id: true, - category: true, - title: true, - }, - }, - task: { - select: { - id: true, - title: true, - }, - }, - }, + include: this.findingInclude, }); if (!finding) { @@ -173,16 +159,64 @@ export class FindingsService { userId: string, createDto: CreateFindingDto, ) { - // Verify task belongs to organization - const task = await db.task.findFirst({ - where: { id: createDto.taskId, organizationId }, - }); - - if (!task) { - throw new NotFoundException( - `Task with ID ${createDto.taskId} not found in organization`, + const hasTaskTarget = Boolean(createDto.taskId); + const hasSubmissionTarget = Boolean(createDto.evidenceSubmissionId); + if (!hasTaskTarget && !hasSubmissionTarget) { + throw new BadRequestException( + 'Either taskId or evidenceSubmissionId is required', ); } + if (hasTaskTarget && hasSubmissionTarget) { + throw new BadRequestException( + 'Provide only one target: taskId or evidenceSubmissionId', + ); + } + + let task: + | { + id: string; + title: string; + } + | null = null; + let evidenceSubmission: + | { + id: string; + formType: string; + submittedAt: Date; + submittedById: string | null; + } + | null = null; + + if (createDto.taskId) { + task = await db.task.findFirst({ + where: { id: createDto.taskId, organizationId }, + select: { id: true, title: true }, + }); + + if (!task) { + throw new NotFoundException( + `Task with ID ${createDto.taskId} not found in organization`, + ); + } + } + + if (createDto.evidenceSubmissionId) { + evidenceSubmission = await db.evidenceSubmission.findFirst({ + where: { id: createDto.evidenceSubmissionId, organizationId }, + select: { + id: true, + formType: true, + submittedAt: true, + submittedById: true, + }, + }); + + if (!evidenceSubmission) { + throw new NotFoundException( + `Evidence submission with ID ${createDto.evidenceSubmissionId} not found in organization`, + ); + } + } // Verify template exists if provided if (createDto.templateId) { @@ -199,7 +233,8 @@ export class FindingsService { const finding = await db.finding.create({ data: { - taskId: createDto.taskId, + taskId: createDto.taskId ?? null, + evidenceSubmissionId: createDto.evidenceSubmissionId ?? null, type: createDto.type, content: createDto.content, templateId: createDto.templateId, @@ -207,33 +242,7 @@ export class FindingsService { organizationId, status: FindingStatus.open, }, - include: { - createdBy: { - include: { - user: { - select: { - id: true, - name: true, - email: true, - image: true, - }, - }, - }, - }, - template: { - select: { - id: true, - category: true, - title: true, - }, - }, - task: { - select: { - id: true, - title: true, - }, - }, - }, + include: this.findingInclude, }); // Log to audit trail @@ -242,8 +251,10 @@ export class FindingsService { organizationId, userId, memberId, - taskId: createDto.taskId, - taskTitle: task.title, + taskId: task?.id, + taskTitle: task?.title, + evidenceSubmissionId: evidenceSubmission?.id, + evidenceSubmissionFormType: evidenceSubmission?.formType, content: createDto.content, type: createDto.type ?? FindingType.soc2, }); @@ -256,17 +267,21 @@ export class FindingsService { void this.findingNotifierService.notifyFindingCreated({ organizationId, findingId: finding.id, - taskId: createDto.taskId, - taskTitle: task.title, + taskId: task?.id, + taskTitle: task?.title, + evidenceSubmissionId: evidenceSubmission?.id, + evidenceSubmissionFormType: evidenceSubmission?.formType, + evidenceSubmissionSubmittedById: evidenceSubmission?.submittedById, findingContent: createDto.content, findingType: createDto.type ?? FindingType.soc2, actorUserId: userId, actorName, }); - this.logger.log( - `Created finding ${finding.id} for task ${createDto.taskId}`, - ); + const target = task + ? `task ${task.id}` + : `evidence submission ${evidenceSubmission?.id}`; + this.logger.log(`Created finding ${finding.id} for ${target}`); return finding; } @@ -418,8 +433,12 @@ export class FindingsService { const notificationParams = { organizationId, findingId, - taskId: finding.taskId, - taskTitle: finding.task.title, + taskId: finding.task?.id, + taskTitle: finding.task?.title, + evidenceSubmissionId: finding.evidenceSubmission?.id, + evidenceSubmissionFormType: finding.evidenceSubmission?.formType, + evidenceSubmissionSubmittedById: + finding.evidenceSubmission?.submittedById, findingContent: updatedFinding.content, findingType: updatedFinding.type, actorUserId: userId, @@ -496,17 +515,20 @@ export class FindingsService { organizationId, userId, memberId, - taskId: finding.taskId, - taskTitle: finding.task.title, + taskId: finding.task?.id, + taskTitle: finding.task?.title, + evidenceSubmissionId: finding.evidenceSubmission?.id, + evidenceSubmissionFormType: finding.evidenceSubmission?.formType, content: finding.content, }); - this.logger.log(`Deleted finding ${findingId} from task ${finding.taskId}`); + this.logger.log(`Deleted finding ${findingId}`); return { message: 'Finding deleted successfully', deletedFinding: { id: finding.id, taskId: finding.taskId, + evidenceSubmissionId: finding.evidenceSubmissionId, }, }; } diff --git a/apps/api/src/tasks/task-notifier.service.ts b/apps/api/src/tasks/task-notifier.service.ts index 180b6733c..b8fae1b90 100644 --- a/apps/api/src/tasks/task-notifier.service.ts +++ b/apps/api/src/tasks/task-notifier.service.ts @@ -624,45 +624,49 @@ export class TaskNotifierService { } = params; try { - const [organization, changedByUser, oldAssigneeMember, newAssigneeMember] = - await Promise.all([ - db.organization.findUnique({ - where: { id: organizationId }, - select: { name: true }, - }), - db.user.findUnique({ - where: { id: changedByUserId }, - select: { name: true, email: true }, - }), - oldAssigneeId - ? db.member.findUnique({ - where: { id: oldAssigneeId }, - select: { - user: { - select: { - id: true, - name: true, - email: true, - }, + const [ + organization, + changedByUser, + oldAssigneeMember, + newAssigneeMember, + ] = await Promise.all([ + db.organization.findUnique({ + where: { id: organizationId }, + select: { name: true }, + }), + db.user.findUnique({ + where: { id: changedByUserId }, + select: { name: true, email: true }, + }), + oldAssigneeId + ? db.member.findUnique({ + where: { id: oldAssigneeId }, + select: { + user: { + select: { + id: true, + name: true, + email: true, }, }, - }) - : Promise.resolve(null), - newAssigneeId - ? db.member.findUnique({ - where: { id: newAssigneeId }, - select: { - user: { - select: { - id: true, - name: true, - email: true, - }, + }, + }) + : Promise.resolve(null), + newAssigneeId + ? db.member.findUnique({ + where: { id: newAssigneeId }, + select: { + user: { + select: { + id: true, + name: true, + email: true, }, }, - }) - : Promise.resolve(null), - ]); + }, + }) + : Promise.resolve(null), + ]); const organizationName = organization?.name ?? 'your organization'; const changedByName = @@ -792,31 +796,39 @@ export class TaskNotifierService { submittedByUserId: string; approverMemberId: string; }): Promise { - const { organizationId, taskId, taskTitle, submittedByUserId, approverMemberId } = params; + const { + organizationId, + taskId, + taskTitle, + submittedByUserId, + approverMemberId, + } = params; try { - const [organization, submittedByUser, approverMember] = await Promise.all([ - db.organization.findUnique({ - where: { id: organizationId }, - select: { name: true }, - }), - db.user.findUnique({ - where: { id: submittedByUserId }, - select: { name: true, email: true }, - }), - db.member.findUnique({ - where: { id: approverMemberId }, - select: { - user: { - select: { - id: true, - name: true, - email: true, + const [organization, submittedByUser, approverMember] = await Promise.all( + [ + db.organization.findUnique({ + where: { id: organizationId }, + select: { name: true }, + }), + db.user.findUnique({ + where: { id: submittedByUserId }, + select: { name: true, email: true }, + }), + db.member.findUnique({ + where: { id: approverMemberId }, + select: { + user: { + select: { + id: true, + name: true, + email: true, + }, }, }, - }, - }), - ]); + }), + ], + ); const organizationName = organization?.name ?? 'your organization'; const submittedByName = @@ -825,7 +837,9 @@ export class TaskNotifierService { 'Someone'; if (!approverMember?.user?.id || !approverMember.user.email) { - this.logger.warn('Approver not found, skipping review request notification'); + this.logger.warn( + 'Approver not found, skipping review request notification', + ); return; } @@ -836,7 +850,10 @@ export class TaskNotifierService { const recipient = { id: approverMember.user.id, - name: approverMember.user.name?.trim() || approverMember.user.email?.trim() || 'User', + name: + approverMember.user.name?.trim() || + approverMember.user.email?.trim() || + 'User', email: approverMember.user.email, }; @@ -925,41 +942,48 @@ export class TaskNotifierService { submittedByUserId: string; approverMemberId: string; }): Promise { - const { organizationId, taskIds, taskCount, submittedByUserId, approverMemberId } = params; + const { + organizationId, + taskIds, + taskCount, + submittedByUserId, + approverMemberId, + } = params; try { - const [organization, submittedByUser, approverMember, tasks] = await Promise.all([ - db.organization.findUnique({ - where: { id: organizationId }, - select: { name: true }, - }), - db.user.findUnique({ - where: { id: submittedByUserId }, - select: { name: true, email: true }, - }), - db.member.findUnique({ - where: { id: approverMemberId }, - select: { - user: { - select: { - id: true, - name: true, - email: true, + const [organization, submittedByUser, approverMember, tasks] = + await Promise.all([ + db.organization.findUnique({ + where: { id: organizationId }, + select: { name: true }, + }), + db.user.findUnique({ + where: { id: submittedByUserId }, + select: { name: true, email: true }, + }), + db.member.findUnique({ + where: { id: approverMemberId }, + select: { + user: { + select: { + id: true, + name: true, + email: true, + }, }, }, - }, - }), - db.task.findMany({ - where: { - id: { in: taskIds }, - organizationId, - }, - select: { - id: true, - title: true, - }, - }), - ]); + }), + db.task.findMany({ + where: { + id: { in: taskIds }, + organizationId, + }, + select: { + id: true, + title: true, + }, + }), + ]); const organizationName = organization?.name ?? 'your organization'; const submittedByName = @@ -968,7 +992,9 @@ export class TaskNotifierService { 'Someone'; if (!approverMember?.user?.id || !approverMember.user.email) { - this.logger.warn('Approver not found, skipping bulk review notification'); + this.logger.warn( + 'Approver not found, skipping bulk review notification', + ); return; } @@ -979,7 +1005,10 @@ export class TaskNotifierService { const recipient = { id: approverMember.user.id, - name: approverMember.user.name?.trim() || approverMember.user.email?.trim() || 'User', + name: + approverMember.user.name?.trim() || + approverMember.user.email?.trim() || + 'User', email: approverMember.user.email, }; diff --git a/apps/api/src/tasks/tasks.controller.ts b/apps/api/src/tasks/tasks.controller.ts index 92bfa929a..e14eb0e0b 100644 --- a/apps/api/src/tasks/tasks.controller.ts +++ b/apps/api/src/tasks/tasks.controller.ts @@ -408,7 +408,8 @@ export class TasksController { @Get(':taskId/activity') @ApiOperation({ summary: 'Get task activity', - description: 'Retrieve audit log activity for a specific task with pagination', + description: + 'Retrieve audit log activity for a specific task with pagination', }) @ApiParam({ name: 'taskId', @@ -424,8 +425,15 @@ export class TasksController { @Query('take') take?: string, ) { const parsedSkip = skip ? Math.max(0, parseInt(skip, 10) || 0) : 0; - const parsedTake = take ? Math.min(50, Math.max(1, parseInt(take, 10) || 10)) : 10; - return await this.tasksService.getTaskActivity(organizationId, taskId, parsedSkip, parsedTake); + const parsedTake = take + ? Math.min(50, Math.max(1, parseInt(take, 10) || 10)) + : 10; + return await this.tasksService.getTaskActivity( + organizationId, + taskId, + parsedSkip, + parsedTake, + ); } @Patch(':taskId') @@ -552,8 +560,7 @@ export class TasksController { @Post(':taskId/submit-for-review') @ApiOperation({ summary: 'Submit task for review', - description: - 'Move task status to in_review and assign an approver.', + description: 'Move task status to in_review and assign an approver.', }) @ApiParam({ name: 'taskId', diff --git a/apps/api/src/tasks/tasks.service.ts b/apps/api/src/tasks/tasks.service.ts index ee5403c07..88d799c41 100644 --- a/apps/api/src/tasks/tasks.service.ts +++ b/apps/api/src/tasks/tasks.service.ts @@ -571,7 +571,10 @@ export class TasksService { approverMemberId: approverId, }) .catch((error) => { - console.error('Failed to send evidence review request notifications:', error); + console.error( + 'Failed to send evidence review request notifications:', + error, + ); }); return updatedTask; @@ -652,7 +655,10 @@ export class TasksService { approverMemberId: approverId, }) .catch((error) => { - console.error('Failed to send bulk evidence review request notifications:', error); + console.error( + 'Failed to send bulk evidence review request notifications:', + error, + ); }); return { submittedCount: tasks.length }; @@ -772,9 +778,8 @@ export class TasksService { throw new ForbiddenException('User is not a member of this organization'); } - const memberRoles = currentMember.role - ?.split(',') - .map((r: string) => r.trim()) ?? []; + const memberRoles = + currentMember.role?.split(',').map((r: string) => r.trim()) ?? []; const isAdminOrOwner = memberRoles.includes('admin') || memberRoles.includes('owner'); const isApprover = task.approverId === currentMember.id; @@ -801,7 +806,7 @@ export class TasksService { }); const assigneeName = task.assignee - ? (task.assignee.user.name || task.assignee.user.email) + ? task.assignee.user.name || task.assignee.user.email : 'Unknown'; await tx.auditLog.create({ diff --git a/apps/api/src/trust-portal/badge-svgs-new.ts b/apps/api/src/trust-portal/badge-svgs-new.ts index 7907c50df..e94a41f48 100644 --- a/apps/api/src/trust-portal/badge-svgs-new.ts +++ b/apps/api/src/trust-portal/badge-svgs-new.ts @@ -31,4 +31,4 @@ export const BADGE_ICON_MAP: Record = { icon: 'data:image/svg+xml;base64,PHN2ZwogICAgd2lkdGg9IjE2OCIKICAgIGhlaWdodD0iMjEwIgogICAgdmlld0JveD0iMCAwIDE2OCAyMTAiCiAgICBmaWxsPSJub25lIgogICAgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIgogID4KICAgIDxwYXRoCiAgICAgIGQ9Ik0xNTcuMjI3IDIuODAzNzFIMTAuNDc4QzYuMzQ4IDIuODAzNzEgMyA2LjE1MTYxIDMgMTAuMjgxNFYxNjAuNDM2QzMgMTYzLjIyNiA0LjU1MyAxNjUuNzgzIDcuMDI4IDE2Ny4wN0w4MS4yOTcgMjA1LjY5QzgzLjQ4MiAyMDYuODI3IDg2LjA4NiAyMDYuODE0IDg4LjI2IDIwNS42NTdMMTYwLjc0IDE2Ny4wODZDMTYzLjE4IDE2NS43ODcgMTY0LjcwNSAxNjMuMjQ5IDE2NC43MDUgMTYwLjQ4NVYxMC4yODE0QzE2NC43MDUgNi4xNTE2MSAxNjEuMzU3IDIuODAzNzEgMTU3LjIyNyAyLjgwMzcxWiIKICAgICAgZmlsbD0id2hpdGUiCiAgICAvPgogICAgPHBhdGgKICAgICAgZD0iTTE2MS43MDUgMTAuMjgxOEMxNjEuNzA1IDcuNzAwNyAxNTkuNjEzIDUuNjA4MyAxNTcuMDMyIDUuNjA4M0gxMC4yODJDNy43MDEgNS42MDgzIDUuNjA4IDcuNzAwNyA1LjYwOCAxMC4yODE4VjE2MC40MzZMNS42MTkgMTYwLjc2MkM1LjczMiAxNjIuMzc3IDYuNjc2IDE2My44MjkgOC4xMjYgMTY0LjU4M0w4Mi4zOTUgMjAzLjIwM0w4Mi42NTQgMjAzLjMyOEM4My45NjMgMjAzLjkxIDg1LjQ3MyAyMDMuODYgODYuNzQ3IDIwMy4xODNMMTU5LjIyNyAxNjQuNjExTDE1OS41MDYgMTY0LjQ1QzE2MC44NjcgMTYzLjYgMTYxLjcwNSAxNjIuMTA1IDE2MS43MDUgMTYwLjQ4NVYxMC4yODE4Wk0xNjcuMzEzIDE2MC40ODVDMTY3LjMxMyAxNjQuMTY3IDE2NS4zNDYgMTY3LjU1OCAxNjIuMTcyIDE2OS4zOUwxNjEuODYyIDE2OS41NjFMODkuMzgyIDIwOC4xMzNDODYuNDg2IDIwOS42NzQgODMuMDM1IDIwOS43MzggODAuMDkxIDIwOC4zMjFMNzkuODA4IDIwOC4xNzhMNS41MzggMTY5LjU1OUMyLjI0MSAxNjcuODQ0IDAuMTM1IDE2NC40OSAwLjAwNyAxNjAuNzk1TDAgMTYwLjQzNlYxMC4yODE4QzAgNC42MDMzIDQuNjA0IDAgMTAuMjgyIDBIMTU3LjAzMkMxNjIuNzEgMCAxNjcuMzEzIDQuNjAzMyAxNjcuMzEzIDEwLjI4MThWMTYwLjQ4NVoiCiAgICAgIGZpbGw9IiMxNjE3MUIiCiAgICAvPgogICAgPHBhdGgKICAgICAgZD0iTTEwMy40ODkgNS42MDg0SDYzLjI5NjlWMzYuMjQ5TDgzLjYyNDkgNDYuNzM1N0wxMDMuNDg5IDM2LjI0OVY1LjYwODRaIgogICAgICBmaWxsPSIjMTYxNzFCIgogICAgLz4KICAgIDxwYXRoCiAgICAgIGQ9Ik04OS4yMzQgMTcuMDgzTDg4LjUwMSAxNi41ODAzTDgzLjM5NCAxMy4wODVMNzEuNzA4IDIxLjA4MVYyNS42NTEyTDgzLjM5MyAzMy42NDg3TDk1LjA3NiAyNS42NTEyVjIxLjA4MUw4OS4yMzMgMTcuMDgzSDg5LjIzNFpNODMuMzk0IDE1LjA5ODhMODcuMDMxIDE3LjU4ODdMODUuMTYzIDE4Ljg2NjJMODQuOTk3IDE4Ljk4TDgzLjM5NiAxNy44ODQ0TDc4LjcyMSAyMS4wODI1TDgwLjMyMyAyMi4xNzk2TDgxLjc5MyAyMy4xODY1TDgzLjM5NCAyNC4yODM2TDg4LjA2NyAyMS4wODRMODYuNDY3IDE5Ljk4ODRMODYuNjMzIDE5Ljg3NDVMODguNTAxIDE4LjU5NzFMOTIuMTM2IDIxLjA4NTVMOTAuMjY4IDIyLjM2NDRMODMuMzk0IDI3LjA2OTFMNzkuNzU3IDI0LjU3OTNMNzguMjg3IDIzLjU3MzlMNzYuNTE4IDIyLjM2NDRMNzQuNjUxIDIxLjA4NTVMODMuMzk0IDE1LjA5ODhaIgogICAgICBmaWxsPSJ3aGl0ZSIKICAgIC8+CiAgICA8cGF0aAogICAgICBkPSJNNTEuODYwNCA4NS44ODVWNTcuNjIzMkg1OC43ODY0Vjg1Ljg4NUg1MS44NjA0Wk03Mi43OTY0IDg2LjUyMThDNjkuMDI4NCA4Ni41MjE4IDY2LjA4MzQgODUuNjE5NiA2My45NjA0IDgzLjgxNTFDNjEuODM3NCA4MS45ODQgNjAuNjY5NCA3OS41NDI2IDYwLjQ1NzQgNzYuNDkwOUw2Ny4zODM0IDc2LjE3MjRDNjcuNTk1NCA3Ny43MTE2IDY4LjEzOTQgNzguODkyNSA2OS4wMTU0IDc5LjcxNTFDNjkuOTE3NCA4MC41Mzc4IDcxLjIwNDQgODAuOTQ5MSA3Mi44NzY0IDgwLjk0OTFDNzQuMjI5NCA4MC45NDkxIDc1LjI2NDQgODAuNzEwMyA3NS45ODE0IDgwLjIzMjZDNzYuNzI0NCA3OS43NTQ5IDc3LjA5NTQgNzkuMDM4NCA3Ny4wOTU0IDc4LjA4MzFDNzcuMDk1NCA3Ny40OTkzIDc2Ljk0OTQgNzYuOTk1MSA3Ni42NTg0IDc2LjU3MDVDNzYuMzkyNCA3Ni4xMTk0IDc1Ljg0ODQgNzUuNzIxMyA3NS4wMjU0IDc1LjM3NjNDNzQuMjI5NCA3NS4wMDQ4IDczLjAyMjQgNzQuNjQ2NiA3MS40MDM0IDc0LjMwMTZDNjguOTA5NCA3My43NzA5IDY2Ljg5MjQgNzMuMTYwNSA2NS4zNTM0IDcyLjQ3MDVDNjMuODQwNCA3MS43NTQgNjIuNzI2NCA3MC44NjUxIDYyLjAwOTQgNjkuODAzNkM2MS4zMTk0IDY4LjcxNTYgNjAuOTc0NCA2Ny4zMzU3IDYwLjk3NDQgNjUuNjYzOEM2MC45NzQ0IDYzLjkzODkgNjEuNDEyNCA2Mi40MjYzIDYyLjI4ODQgNjEuMTI2QzYzLjE2MzQgNTkuODI1NyA2NC40MjQ0IDU4LjgxNzMgNjYuMDY5NCA1OC4xMDA4QzY3LjcxNTQgNTcuMzU3OCA2OS42Nzg0IDU2Ljk4NjMgNzEuOTYwNCA1Ni45ODYzQzc0LjQwMjQgNTYuOTg2MyA3Ni40NDU0IDU3LjQxMDkgNzguMDkwNCA1OC4yNkM3OS43NjI0IDU5LjA4MjcgODEuMDQ5NCA2MC4yMjM4IDgxLjk1MjQgNjEuNjgzM0M4Mi44ODA0IDYzLjE0MjggODMuNDUxNCA2NC44Mjc5IDgzLjY2MzQgNjYuNzM4Nkw3Ni44MTc0IDY3LjA1N0M3Ni42ODQ0IDY1LjY3NzEgNzYuMTkzNCA2NC41ODkxIDc1LjM0NDQgNjMuNzkzQzc0LjQ5NTQgNjIuOTcwMyA3My4zNDA0IDYyLjU1OSA3MS44ODE0IDYyLjU1OUM3MC42NjA0IDYyLjU1OSA2OS42OTI0IDYyLjgyNDQgNjguOTc1NCA2My4zNTUxQzY4LjI4NTQgNjMuODg1OSA2Ny45NDA0IDY0LjU3NTggNjcuOTQwNCA2NS40MjVDNjcuOTQwNCA2Ni40MDY5IDY4LjMxMjQgNjcuMTQ5OSA2OS4wNTU0IDY3LjY1NDFDNjkuODI0NCA2OC4xNTgzIDcxLjI0NDQgNjguNjA5NCA3My4zMTQ0IDY5LjAwNzVDNzYuMDIxNCA2OS41MTE3IDc4LjE1NzQgNzAuMTc1MSA3OS43MjM0IDcwLjk5NzdDODEuMjg4NCA3MS43OTM5IDgyLjQwMzQgNzIuNzYyNSA4My4wNjY0IDczLjkwMzVDODMuNzMwNCA3NS4wMTgxIDg0LjA2MTQgNzYuMzA1MSA4NC4wNjE0IDc3Ljc2NDdDODQuMDYxNCA4MC40NzE0IDgzLjA1MzQgODIuNjA3NiA4MS4wMzY0IDg0LjE3MzNDNzkuMDQ2NCA4NS43MzkgNzYuMjk5NCA4Ni41MjE4IDcyLjc5NjQgODYuNTIxOFpNOTkuMzk5NCA4Ni41MjE4Qzk2LjQ4MDQgODYuNTIxOCA5My45NzI0IDg1LjkyNDggOTEuODc2NCA4NC43MzA2Qzg5Ljc3OTQgODMuNTM2NCA4OC4xNjA0IDgxLjgzODEgODcuMDE5NCA3OS42MzU1Qzg1LjkwNTQgNzcuNDMyOSA4NS4zNDg0IDc0LjgxOTEgODUuMzQ4NCA3MS43OTM5Qzg1LjM0ODQgNjguNzY4NyA4NS45MDU0IDY2LjE1NDggODcuMDE5NCA2My45NTIyQzg4LjE2MDQgNjEuNzIzMSA4OS43Nzk0IDYwLjAxMTUgOTEuODc2NCA1OC44MTczQzkzLjk3MjQgNTcuNTk2NiA5Ni40ODA0IDU2Ljk4NjMgOTkuMzk5NCA1Ni45ODYzQzEwMi4zMTggNTYuOTg2MyAxMDQuODI2IDU3LjU5NjYgMTA2LjkyMiA1OC44MTczQzEwOS4wMTggNjAuMDExNSAxMTAuNjI0IDYxLjcyMzEgMTExLjczOCA2My45NTIyQzExMi44OCA2Ni4xNTQ4IDExMy40NSA2OC43Njg3IDExMy40NSA3MS43OTM5QzExMy40NSA3NC44MTkxIDExMi44OCA3Ny40MzI5IDExMS43MzggNzkuNjM1NUMxMTAuNjI0IDgxLjgzODEgMTA5LjAxOCA4My41MzY0IDEwNi45MjIgODQuNzMwNkMxMDQuODI2IDg1LjkyNDggMTAyLjMxOCA4Ni41MjE4IDk5LjM5OTQgODYuNTIxOFpNOTkuMzk5NCA4MC44Njk1QzEwMS41NzUgODAuODY5NSAxMDMuMjczIDgwLjA4NjYgMTA0LjQ5NCA3OC41MjFDMTA1LjcxNSA3Ni45Mjg3IDEwNi4zMjUgNzQuNjg2NCAxMDYuMzI1IDcxLjc5MzlDMTA2LjMyNSA2OC45MDEzIDEwNS43MTUgNjYuNjU5IDEwNC40OTQgNjUuMDY2OEMxMDMuMjczIDYzLjQ0OCAxMDEuNTc1IDYyLjYzODYgOTkuMzk5NCA2Mi42Mzg2Qzk3LjIyMzQgNjIuNjM4NiA5NS41MjQ0IDYzLjQ0OCA5NC4zMDQ0IDY1LjA2NjhDOTMuMDgzNCA2Ni42NTkgOTIuNDczNCA2OC45MDEzIDkyLjQ3MzQgNzEuNzkzOUM5Mi40NzM0IDc0LjY4NjQgOTMuMDgzNCA3Ni45Mjg3IDk0LjMwNDQgNzguNTIxQzk1LjUyNDQgODAuMDg2NiA5Ny4yMjM0IDgwLjg2OTUgOTkuMzk5NCA4MC44Njk1WiIKICAgICAgZmlsbD0iIzE2MTcxQiIKICAgIC8+CiAgICA8cGF0aAogICAgICBkPSJNNDUuNzU0NyA5NC4xMTU3QzQ3LjM2NSA5NC4xMTU3IDQ4Ljg4OTkgOTQuMzcyMSA1MC4zMjkzIDk0Ljg4NDlDNTEuNzY4NyA5NS4zODg2IDUzLjA0MTYgOTYuMTg0OCA1NC4xNDgyIDk3LjI3MzNDNTUuMjYzNyA5OC4zNjE5IDU2LjEzNjMgOTkuNzgzMyA1Ni43NjYxIDEwMS41MzhDNTcuNDA0OCAxMDMuMjgzIDU3LjcyODcgMTA1LjM5NyA1Ny43Mzc3IDEwNy44OEM1Ny43NDY3IDExMC4xNDcgNTcuNDYzMyAxMTIuMTg1IDU2Ljg4NzUgMTEzLjk5M0M1Ni4zMjA4IDExNS43OTIgNTUuNTA2NiAxMTcuMzI2IDU0LjQ0NSAxMTguNTk0QzUzLjM5MjUgMTE5Ljg2MyA1Mi4xMjQgMTIwLjgzNCA1MC42Mzk2IDEyMS41MDlDNDkuMTY0MyAxMjIuMTc1IDQ3LjUxOCAxMjIuNTA4IDQ1LjcwMDcgMTIyLjUwOEM0My42NTg2IDEyMi41MDggNDEuODYzOCAxMjIuMTE2IDQwLjMxNjUgMTIxLjMzNEMzOC43NjkxIDEyMC41NTEgMzcuNTM2NyAxMTkuNDk4IDM2LjYxOTEgMTE4LjE3NkMzNS43MDE0IDExNi44NTQgMzUuMTY2MiAxMTUuMzgzIDM1LjAxMzIgMTEzLjc2M0g0Mi40MDgxQzQyLjU4OCAxMTQuNiA0Mi45ODM5IDExNS4yMDcgNDMuNTk1NiAxMTUuNTg1QzQ0LjIwNzQgMTE1Ljk1NCA0NC45MDkxIDExNi4xMzggNDUuNzAwNyAxMTYuMTM4QzQ3LjI4NDEgMTE2LjEzOCA0OC40NDAxIDExNS40NTUgNDkuMTY4OCAxMTQuMDg3QzQ5LjkwNjUgMTEyLjcxMSA1MC4yNzk4IDExMC44NzYgNTAuMjg4OCAxMDguNTgySDUwLjEyNjlDNDkuNzc2IDEwOS40MjcgNDkuMjQ1MiAxMTAuMTUxIDQ4LjUzNDUgMTEwLjc1NEM0Ny44MjM4IDExMS4zNTcgNDYuOTk2MiAxMTEuODIgNDYuMDUxNiAxMTIuMTQ0QzQ1LjEwNyAxMTIuNDY4IDQ0LjEwODQgMTEyLjYzIDQzLjA1NTggMTEyLjYzQzQxLjM5MTUgMTEyLjYzIDM5LjkzNDIgMTEyLjI1NyAzOC42ODM3IDExMS41MUMzNy40MzMyIDExMC43NTQgMzYuNDU3MSAxMDkuNzI0IDM1Ljc1NTQgMTA4LjQyQzM1LjA1MzcgMTA3LjEwNiAzNC42OTg0IDEwNS42MTMgMzQuNjg5NCAxMDMuOTRDMzQuNjgwNCAxMDEuOTc4IDM1LjE0MzcgMTAwLjI2IDM2LjA3OTMgOTguNzg0N0MzNy4wMTQ5IDk3LjMwOTMgMzguMzE0OCA5Ni4xNjIzIDM5Ljk3OTEgOTUuMzQzN0M0MS42NDM0IDk0LjUyNSA0My41Njg2IDk0LjExNTcgNDUuNzU0NyA5NC4xMTU3Wk00NS44MDg3IDk5Ljc4MzNDNDUuMDYyIDk5Ljc4MzMgNDQuMzk2MyA5OS45NTQyIDQzLjgxMTUgMTAwLjI5NkM0My4yMzU4IDEwMC42MzggNDIuNzgxNSAxMDEuMTAxIDQyLjQ0ODYgMTAxLjY4NkM0Mi4xMjQ3IDEwMi4yNzEgNDEuOTY3MyAxMDIuOTMyIDQxLjk3NjMgMTAzLjY3QzQxLjk4NTMgMTA0LjQwNyA0Mi4xNTE3IDEwNS4wNjkgNDIuNDc1NiAxMDUuNjUzQzQyLjgwODQgMTA2LjIzOCA0My4yNTgzIDEwNi43MDEgNDMuODI1IDEwNy4wNDNDNDQuNDAwOCAxMDcuMzg1IDQ1LjA2MiAxMDcuNTU2IDQ1LjgwODcgMTA3LjU1NkM0Ni4zNTc1IDEwNy41NTYgNDYuODY1NyAxMDcuNDYyIDQ3LjMzMzUgMTA3LjI3M0M0Ny44MDEzIDEwNy4wNzUgNDguMjA2MiAxMDYuOCA0OC41NDggMTA2LjQ0OUM0OC44OTg5IDEwNi4wOSA0OS4xNjg4IDEwNS42NzYgNDkuMzU3NyAxMDUuMjA4QzQ5LjU1NTYgMTA0LjczMSA0OS42NTAxIDEwNC4yMTggNDkuNjQxMSAxMDMuNjdDNDkuNjMyMSAxMDIuOTMyIDQ5LjQ2MTEgMTAyLjI3MSA0OS4xMjgzIDEwMS42ODZDNDguNzk1NCAxMDEuMTAxIDQ4LjM0MTEgMTAwLjYzOCA0Ny43NjU0IDEwMC4yOTZDNDcuMTg5NiA5OS45NTQyIDQ2LjUzNzQgOTkuNzgzMyA0NS44MDg3IDk5Ljc4MzNaTTczLjAwOTkgMTIyLjg4NkM3MC40OTk5IDEyMi44NzcgNjguMzMxOCAxMjIuMjk2IDY2LjUwNTYgMTIxLjE0NUM2NC42Nzk0IDExOS45OTMgNjMuMjcxNSAxMTguMzMzIDYyLjI4MTkgMTE2LjE2NUM2MS4yOTIzIDExMy45OTcgNjAuODAyIDExMS4zOTcgNjAuODExIDEwOC4zNjZDNjAuODIgMTA1LjMyNSA2MS4zMTQ4IDEwMi43NDMgNjIuMjk1NCAxMDAuNjJDNjMuMjg0OSA5OC40OTY4IDY0LjY4ODQgOTYuODgyIDY2LjUwNTYgOTUuNzc1NUM2OC4zMzE4IDk0LjY2ODkgNzAuNDk5OSA5NC4xMTU3IDczLjAwOTkgOTQuMTE1N0M3NS41MTk4IDk0LjExNTcgNzcuNjg3OSA5NC42NzM0IDc5LjUxNDEgOTUuNzg5QzgxLjM0MDMgOTYuODk1NSA4Mi43NDgzIDk4LjUxMDMgODMuNzM3OCAxMDAuNjMzQzg0LjcyNzQgMTAyLjc1NyA4NS4yMTc3IDEwNS4zMzQgODUuMjA4NyAxMDguMzY2Qzg1LjIwODcgMTExLjQxNSA4NC43MTM5IDExNC4wMjQgODMuNzI0MyAxMTYuMTkyQzgyLjczNDggMTE4LjM2IDgxLjMyNjkgMTIwLjAyIDc5LjUwMDYgMTIxLjE3MkM3Ny42ODM0IDEyMi4zMTQgNzUuNTE5OCAxMjIuODg2IDczLjAwOTkgMTIyLjg4NlpNNzMuMDA5OSAxMTYuNzg2Qzc0LjMwNTMgMTE2Ljc4NiA3NS4zNjY5IDExNi4xMTEgNzYuMTk0NSAxMTQuNzYyQzc3LjAzMTIgMTEzLjQwNCA3Ny40NDUgMTExLjI3MSA3Ny40MzYgMTA4LjM2NkM3Ny40MzYgMTA2LjQ2NyA3Ny4yNDcxIDEwNC45MTYgNzYuODY5MiAxMDMuNzFDNzYuNDkxNCAxMDIuNTA1IDc1Ljk2OTYgMTAxLjYxNCA3NS4zMDM5IDEwMS4wMzhDNzQuNjM4MiAxMDAuNDU0IDczLjg3MzUgMTAwLjE2MSA3My4wMDk5IDEwMC4xNjFDNzEuNzE0NCAxMDAuMTYxIDcwLjY1NzMgMTAwLjgxOCA2OS44Mzg3IDEwMi4xMzFDNjkuMDIgMTAzLjQ0NSA2OC42MDE3IDEwNS41MjMgNjguNTgzNyAxMDguMzY2QzY4LjU3NDcgMTEwLjMgNjguNzU5MSAxMTEuODkyIDY5LjEzNyAxMTMuMTQzQzY5LjUxNDggMTE0LjM4NCA3MC4wMzY2IDExNS4zMDIgNzAuNzAyMyAxMTUuODk2QzcxLjM3NyAxMTYuNDg5IDcyLjE0NjIgMTE2Ljc4NiA3My4wMDk5IDExNi43ODZaTTEwMC43NjggMTIyLjg4NkM5OC4yNTc3IDEyMi44NzcgOTYuMDg5NiAxMjIuMjk2IDk0LjI2MzQgMTIxLjE0NUM5Mi40MzcyIDExOS45OTMgOTEuMDI5MyAxMTguMzMzIDkwLjAzOTcgMTE2LjE2NUM4OS4wNTAxIDExMy45OTcgODguNTU5OCAxMTEuMzk3IDg4LjU2ODggMTA4LjM2NkM4OC41Nzc4IDEwNS4zMjUgODkuMDcyNiAxMDIuNzQzIDkwLjA1MzIgMTAwLjYyQzkxLjA0MjggOTguNDk2OCA5Mi40NDYyIDk2Ljg4MiA5NC4yNjM0IDk1Ljc3NTVDOTYuMDg5NiA5NC42Njg5IDk4LjI1NzcgOTQuMTE1NyAxMDAuNzY4IDk0LjExNTdDMTAzLjI3OCA5NC4xMTU3IDEwNS40NDYgOTQuNjczNCAxMDcuMjcyIDk1Ljc4OUMxMDkuMDk4IDk2Ljg5NTUgMTEwLjUwNiA5OC41MTAzIDExMS40OTYgMTAwLjYzM0MxMTIuNDg1IDEwMi43NTcgMTEyLjk3NiAxMDUuMzM0IDExMi45NjcgMTA4LjM2NkMxMTIuOTY3IDExMS40MTUgMTEyLjQ3MiAxMTQuMDI0IDExMS40ODIgMTE2LjE5MkMxMTAuNDkzIDExOC4zNiAxMDkuMDg1IDEyMC4wMiAxMDcuMjU4IDEyMS4xNzJDMTA1LjQ0MSAxMjIuMzE0IDEwMy4yNzggMTIyLjg4NiAxMDAuNzY4IDEyMi44ODZaTTEwMC43NjggMTE2Ljc4NkMxMDIuMDYzIDExNi43ODYgMTAzLjEyNSAxMTYuMTExIDEwMy45NTIgMTE0Ljc2MkMxMDQuNzg5IDExMy40MDQgMTA1LjIwMyAxMTEuMjcxIDEwNS4xOTQgMTA4LjM2NkMxMDUuMTk0IDEwNi40NjcgMTA1LjAwNSAxMDQuOTE2IDEwNC42MjcgMTAzLjcxQzEwNC4yNDkgMTAyLjUwNSAxMDMuNzI3IDEwMS42MTQgMTAzLjA2MiAxMDEuMDM4QzEwMi4zOTYgMTAwLjQ1NCAxMDEuNjMxIDEwMC4xNjEgMTAwLjc2OCAxMDAuMTYxQzk5LjQ3MjIgMTAwLjE2MSA5OC40MTUyIDEwMC44MTggOTcuNTk2NSAxMDIuMTMxQzk2Ljc3NzggMTAzLjQ0NSA5Ni4zNTk1IDEwNS41MjMgOTYuMzQxNSAxMDguMzY2Qzk2LjMzMjUgMTEwLjMgOTYuNTE3IDExMS44OTIgOTYuODk0OCAxMTMuMTQzQzk3LjI3MjYgMTE0LjM4NCA5Ny43OTQ0IDExNS4zMDIgOTguNDYwMSAxMTUuODk2Qzk5LjEzNDkgMTE2LjQ4OSA5OS45MDQgMTE2Ljc4NiAxMDAuNzY4IDExNi43ODZaTTEzMC4wMzcgOTQuNDkzNVYxMjIuMTNIMTIyLjUzNFYxMDEuNDAzSDEyMi4zNzJMMTE2LjMyNyAxMDUuMDE5Vjk4LjY0OThMMTIzLjEyOCA5NC40OTM1SDEzMC4wMzdaIgogICAgICBmaWxsPSIjMTYxNzFCIgogICAgLz4KICAgIDxwYXRoCiAgICAgIGQ9Ik00NS41MTY3IDE0MS4xMUM0NC43MzA3IDE0MS4xMSA0NC4wMzM3IDE0MC45MzIgNDMuNDI2NyAxNDAuNTc2QzQyLjgxOTcgMTQwLjIxOSA0Mi4zNDE3IDEzOS43MDUgNDEuOTkzNyAxMzkuMDMzQzQxLjY0NDcgMTM4LjM2IDQxLjQ3MDcgMTM3LjU1NSA0MS40NzA3IDEzNi42MTVDNDEuNDcwNyAxMzUuNyA0MS42MzY3IDEzNC45MDYgNDEuOTY4NyAxMzQuMjM0QzQyLjMwODcgMTMzLjU1NCA0Mi43ODI3IDEzMy4wMjcgNDMuMzkwNyAxMzIuNjU1QzQ0LjAwNTcgMTMyLjI4MiA0NC43MjY3IDEzMi4wOTYgNDUuNTUyNyAxMzIuMDk2QzQ2LjY3ODcgMTMyLjA5NiA0Ny41NTM3IDEzMi4zNzUgNDguMTc2NyAxMzIuOTM0QzQ4LjgwMDcgMTMzLjQ4NSA0OS4yMDU3IDEzNC4yNzQgNDkuMzkxNyAxMzUuMzAzTDQ3LjQ3MjcgMTM1LjM3NkM0Ny4zNzQ3IDEzNC44MzMgNDcuMTY0NyAxMzQuNDEyIDQ2Ljg0MDcgMTM0LjExMkM0Ni41MTY3IDEzMy44MDUgNDYuMDg3NyAxMzMuNjUxIDQ1LjU1MjcgMTMzLjY1MUM0NC44NjQ3IDEzMy42NTEgNDQuMzI5NyAxMzMuOTIyIDQzLjk0ODcgMTM0LjQ2NUM0My41Njg3IDEzNC45OTkgNDMuMzc3NyAxMzUuNzE2IDQzLjM3NzcgMTM2LjYxNUM0My4zNzc3IDEzNy41MjIgNDMuNTcyNyAxMzguMjM5IDQzLjk2MTcgMTM4Ljc2NUM0NC4zNDk3IDEzOS4yOTIgNDQuODc2NyAxMzkuNTU1IDQ1LjU0MDcgMTM5LjU1NUM0Ni4xMTU3IDEzOS41NTUgNDYuNTY0NyAxMzkuMzkzIDQ2Ljg4ODcgMTM5LjA2OUM0Ny4yMTI3IDEzOC43MzcgNDcuNDE1NyAxMzguMjc5IDQ3LjQ5NjcgMTM3LjY5Nkw0OS40Mjc3IDEzNy43NjlDNDkuMjQ5NyAxMzguODIyIDQ4LjgzMjcgMTM5LjY0NCA0OC4xNzY3IDE0MC4yMzVDNDcuNTI4NyAxNDAuODE4IDQ2LjY0MTcgMTQxLjExIDQ1LjUxNjcgMTQxLjExWk01My4wODA3IDE0MC45MTZWMTMyLjI5SDU5LjA4MTdWMTMzLjg0NUg1NC45MjY3VjEzNS44MjVINTguOTM1N1YxMzcuMzU2SDU0LjkyNjdWMTM5LjM2MUg1OS4xNzg3VjE0MC45MTZINTMuMDgwN1pNNjMuMDMxNyAxNDAuOTE2VjEzMi4yOUg2Ni44MTA3QzY3LjQ0MTcgMTMyLjI5IDY3Ljk4ODcgMTMyLjM5NSA2OC40NTA3IDEzMi42MDZDNjguOTExNyAxMzIuODE3IDY5LjI2ODcgMTMzLjExNiA2OS41MTk3IDEzMy41MDVDNjkuNzc4NyAxMzMuODg2IDY5LjkwNzcgMTM0LjMzOSA2OS45MDc3IDEzNC44NjZDNjkuOTA3NyAxMzUuMzkyIDY5Ljc2NjcgMTM1LjgzOCA2OS40ODI3IDEzNi4yMDJDNjkuMjA3NyAxMzYuNTY2IDY4Ljg0MjcgMTM2LjgxOCA2OC4zODk3IDEzNi45NTVDNjkuMjM5NyAxMzcuMDc3IDY5LjcwMTcgMTM3LjU3MSA2OS43NzQ3IDEzOC40MzdMNjkuOTkyNyAxNDAuOTE2SDY4LjEwOTdMNjcuOTUxNyAxMzguNzI5QzY3LjkyNzcgMTM4LjM5NyA2Ny44MjI3IDEzOC4xNTQgNjcuNjM2NyAxMzhDNjcuNDU3NyAxMzcuODM4IDY3LjE1MDcgMTM3Ljc1NyA2Ni43MTI3IDEzNy43NTdINjQuODc4N1YxNDAuOTE2SDYzLjAzMTdaTTY0Ljg3ODcgMTM2LjIxNEg2Ni42MDM3QzY3LjA0ODcgMTM2LjIxNCA2Ny4zOTM3IDEzNi4xMTMgNjcuNjM2NyAxMzUuOTFDNjcuODg3NyAxMzUuNyA2OC4wMTI3IDEzNS40MDQgNjguMDEyNyAxMzUuMDI0QzY4LjAxMjcgMTM0LjYzNSA2Ny44ODc3IDEzNC4zNDMgNjcuNjM2NyAxMzQuMTQ5QzY3LjM4NTcgMTMzLjk0NiA2Ny4wMjA3IDEzMy44NDUgNjYuNTQyNyAxMzMuODQ1SDY0Ljg3ODdWMTM2LjIxNFpNNzUuNTI4NyAxNDAuOTE2VjEzMy44NDVINzIuOTUzN1YxMzIuMjlINzkuOTc1N1YxMzMuODQ1SDc3LjM4NzdWMTQwLjkxNkg3NS41Mjg3Wk04My4yMDc3IDE0MC45MTZWMTMyLjI5SDg1LjA1NDdWMTQwLjkxNkg4My4yMDc3Wk04OS4xMTM3IDE0MC45MTZWMTMyLjI5SDk1LjA2NjdWMTMzLjg0NUg5MC45NjA3VjEzNS45MzVIOTQuODQ3N1YxMzcuNDY1SDkwLjk2MDdWMTQwLjkxNkg4OS4xMTM3Wk05OC43MDk3IDE0MC45MTZWMTMyLjI5SDEwMC41NTZWMTQwLjkxNkg5OC43MDk3Wk0xMDQuNjE1IDE0MC45MTZWMTMyLjI5SDExMC42MTdWMTMzLjg0NUgxMDYuNDYyVjEzNS44MjVIMTEwLjQ3MVYxMzcuMzU2SDEwNi40NjJWMTM5LjM2MUgxMTAuNzE0VjE0MC45MTZIMTA0LjYxNVpNMTE0LjU2NyAxNDAuOTE2VjEzMi4yOUgxMTcuNTQzQzExOC45MzYgMTMyLjI5IDEyMC4wMDkgMTMyLjY2NyAxMjAuNzYzIDEzMy40MkMxMjEuNTI0IDEzNC4xNjUgMTIxLjkwNSAxMzUuMjMgMTIxLjkwNSAxMzYuNjE1QzEyMS45MDUgMTM3Ljk5MiAxMjEuNTMyIDEzOS4wNTMgMTIwLjc4NyAxMzkuNzk4QzEyMC4wNDIgMTQwLjU0MyAxMTguOTg1IDE0MC45MTYgMTE3LjYxNiAxNDAuOTE2SDExNC41NjdaTTExNi40MTMgMTM5LjM2MUgxMTcuNTQzQzExOC4zNzggMTM5LjM2MSAxMTguOTkzIDEzOS4xMzggMTE5LjM5IDEzOC42OTJDMTE5Ljc5NSAxMzguMjM5IDExOS45OTcgMTM3LjU0MiAxMTkuOTk3IDEzNi42MDNDMTE5Ljk5NyAxMzUuNjcxIDExOS43OTUgMTM0Ljk3OSAxMTkuMzkgMTM0LjUyNUMxMTguOTkzIDEzNC4wNzIgMTE4LjM3OCAxMzMuODQ1IDExNy41NDMgMTMzLjg0NUgxMTYuNDEzVjEzOS4zNjFaIgogICAgICBmaWxsPSIjMTYxNzFCIgogICAgLz4KICAgIDxwYXRoCiAgICAgIGQ9Ik01OS41NTc1IDUuMTQwMjhMNTYuNTE5NSA0LjkxNTg4VjMuNzM4MjhINjYuODAxNVYxMS45ODE0SDY0LjA5NjVDNjQuMDk2NSAxMi4zNzM5IDYzLjc2MzUgMTIuMTc2NCA2My43NjM1IDguODc5MThDNjMuNzYzNSA1LjU4MTg4IDYxLjE4MTUgNS4xNDAyOCA1OS41NTc1IDUuMTQwMjhaIgogICAgICBmaWxsPSIjMTYxNzFCIgogICAgLz4KICAgIDxwYXRoCiAgICAgIGQ9Ik01Ni41MTg4IDQuOTE1NTFINTYuMDUxOFY1LjM0OTYxTDU2LjQ4NDggNS4zODE2MUw1Ni41MTg4IDQuOTE1NTFaTTU5LjU1NjggNS4xMzk5MUw1OS41MjI4IDUuNjA2MDFMNTkuNTM5OCA1LjYwNzMxSDU5LjU1NjhWNS4xMzk5MVpNNjQuMDk1OCAxMS45ODFWMTEuNTEzNkg2My42Mjc4VjExLjk4MUg2NC4wOTU4Wk02Ni44MDA4IDExLjk4MVYxMi40NDg0SDY3LjI2ODhWMTEuOTgxSDY2LjgwMDhaTTY2LjgwMDggMy43Mzc5MUg2Ny4yNjg4VjMuMjcwNTFINjYuODAwOFYzLjczNzkxWk01Ni41MTg4IDMuNzM3OTFWMy4yNzA1MUg1Ni4wNTE4VjMuNzM3OTFINTYuNTE4OFpNNTYuNTE4OCA0LjkxNTUxTDU2LjQ4NDggNS4zODE2MUw1OS41MjI4IDUuNjA2MDFMNTkuNTU2OCA1LjEzOTkxTDU5LjU5MTggNC42NzM5MUw1Ni41NTM4IDQuNDQ5NDFMNTYuNTE4OCA0LjkxNTUxWk01OS41NTY4IDUuMTM5OTFWNS42MDczMUM2MC4zNDE4IDUuNjA3MzEgNjEuMjg2OCA1LjcxNzIxIDYyLjAyMzggNi4xNzA5MUM2Mi43MjA4IDYuNjAwMTEgNjMuMjk1OCA3LjM3NjExIDYzLjI5NTggOC44Nzg4MUg2My43NjI4SDY0LjIzMDhDNjQuMjMwOCA3LjA4NDIxIDYzLjUxMzggNS45OTA4MSA2Mi41MTM4IDUuMzc0OTFDNjEuNTUzOCA0Ljc4MzUxIDYwLjM5NTggNC42NzI2MSA1OS41NTY4IDQuNjcyNjFWNS4xMzk5MVpNNjMuNzYyOCA4Ljg3ODgxSDYzLjI5NThDNjMuMjk1OCAxMC41MzM1IDYzLjM3ODggMTEuNDQ1NiA2My40NzA4IDExLjkwNThDNjMuNDk0OCAxMi4wMjE2IDYzLjUyMTggMTIuMTI0NiA2My41NTQ4IDEyLjIwOTdDNjMuNTcwOCAxMi4yNTE3IDYzLjU5MjggMTIuMzAyNyA2My42MjU4IDEyLjM1MjhDNjMuNjUwOCAxMi4zOTE4IDYzLjcxNjggMTIuNDg3OCA2My44NDI4IDEyLjU0NzVDNjMuOTEzOCAxMi41ODE0IDY0LjAwNjggMTIuNjA0NyA2NC4xMTI4IDEyLjU5MTNDNjQuMjE4OCAxMi41NzggNjQuMzAxOCAxMi41MzI3IDY0LjM2MDggMTIuNDg0M0M2NC40NjU4IDEyLjM5ODcgNjQuNTA2OCAxMi4yOTM1IDY0LjUyMTggMTIuMjUxMkM2NC41NTY4IDEyLjE1MjUgNjQuNTYyOCAxMi4wNTE0IDY0LjU2MjggMTEuOTgxSDY0LjA5NThINjMuNjI3OEM2My42Mjc4IDEyLjAwODcgNjMuNjIzOCAxMS45ODczIDYzLjY0MDggMTEuOTQwMUM2My42NDU4IDExLjkyMzUgNjMuNjc2OCAxMS44MzcgNjMuNzY3OCAxMS43NjE4QzYzLjgyMDggMTEuNzE4NSA2My44OTc4IDExLjY3NjMgNjMuOTk1OCAxMS42NjM5QzY0LjA5NDggMTEuNjUxNSA2NC4xODA4IDExLjY3MzUgNjQuMjQ0OCAxMS43MDM2QzY0LjM1MzggMTEuNzU1OSA2NC40MDM4IDExLjgzNSA2NC40MTA4IDExLjg0NjNDNjQuNDI1OCAxMS44Njg3IDY0LjQyOTggMTEuODgxMyA2NC40MjU4IDExLjg3MjlDNjQuNDE5OCAxMS44NTczIDY0LjQwNTggMTEuODExNyA2NC4zODc4IDExLjcyMTNDNjQuMzEzOCAxMS4zNTY1IDY0LjIzMDggMTAuNTIxNCA2NC4yMzA4IDguODc4ODFINjMuNzYyOFpNNjQuMDk1OCAxMS45ODFWMTIuNDQ4NEg2Ni44MDA4VjExLjk4MVYxMS41MTM2SDY0LjA5NThWMTEuOTgxWk02Ni44MDA4IDExLjk4MUg2Ny4yNjg4VjMuNzM3OTFINjYuODAwOEg2Ni4zMzM4VjExLjk4MUg2Ni44MDA4Wk02Ni44MDA4IDMuNzM3OTFWMy4yNzA1MUg1Ni41MTg4VjMuNzM3OTFWNC4yMDUyMUg2Ni44MDA4VjMuNzM3OTFaTTU2LjUxODggMy43Mzc5MUg1Ni4wNTE4VjQuOTE1NTFINTYuNTE4OEg1Ni45ODY4VjMuNzM3OTFINTYuNTE4OFoiCiAgICAgIGZpbGw9IiMxNjE3MUIiCiAgICAvPgogICAgPHBhdGgKICAgICAgZD0iTTEwNy45MjggNS4xNDAyOEwxMTAuOTY2IDQuOTE1ODhWMy43MzgyOEgxMDAuNjg0VjExLjk4MTRMMTAyLjU1MyAxMi4xNTA3QzEwMi41NTMgMTIuNTQzMiAxMDMuMDIxIDExLjY4MzMgMTAzLjAyMSA4Ljg3OTE4QzEwMy4wMjEgNS41ODE4OCAxMDYuMzA1IDUuMTQwMjggMTA3LjkyOCA1LjE0MDI4WiIKICAgICAgZmlsbD0iIzE2MTcxQiIKICAgIC8+CiAgICA8cGF0aAogICAgICBkPSJNMTEwLjk2NSA0LjkxNTUxSDExMS40MzJWNS4zNDk2MUwxMTAuOTk5IDUuMzgxNjFMMTEwLjk2NSA0LjkxNTUxWk0xMDcuOTI3IDUuMTM5OTFMMTA3Ljk2MSA1LjYwNjAxTDEwNy45NDQgNS42MDczMUgxMDcuOTI3VjUuMTM5OTFaTTEwMi41NTIgMTIuMTUwM0wxMDIuNTk1IDExLjY4NDhMMTAzLjAyIDExLjcyMzNWMTIuMTUwM0gxMDIuNTUyWk0xMDAuNjgzIDExLjk4MUwxMDAuNjQxIDEyLjQ0NjRMMTAwLjIxNiAxMi40MDc5VjExLjk4MUgxMDAuNjgzWk0xMDAuNjgzIDMuNzM3OTFIMTAwLjIxNlYzLjI3MDUxSDEwMC42ODNWMy43Mzc5MVpNMTEwLjk2NSAzLjczNzkxVjMuMjcwNTFIMTExLjQzMlYzLjczNzkxSDExMC45NjVaTTExMC45NjUgNC45MTU1MUwxMTAuOTk5IDUuMzgxNjFMMTA3Ljk2MSA1LjYwNjAxTDEwNy45MjcgNS4xMzk5MUwxMDcuODkzIDQuNjczOTFMMTEwLjkzIDQuNDQ5NDFMMTEwLjk2NSA0LjkxNTUxWk0xMDcuOTI3IDUuMTM5OTFWNS42MDczMUMxMDcuMTQyIDUuNjA3MzEgMTA2LjAwMyA1LjcxNjQxIDEwNS4wNzcgNi4xODkyMUMxMDQuNjIgNi40MjIzMSAxMDQuMjI4IDYuNzM3NDEgMTAzLjk0OCA3LjE2MDkxQzEwMy42NzEgNy41ODE2MSAxMDMuNDg3IDguMTM1OTEgMTAzLjQ4NyA4Ljg3ODgxSDEwMy4wMkgxMDIuNTUyQzEwMi41NTIgNy45NzMxMSAxMDIuNzc5IDcuMjM1NzEgMTAzLjE2OCA2LjY0NjIxQzEwMy41NTUgNi4wNTk2MSAxMDQuMDg1IDUuNjQ2MTEgMTA0LjY1MiA1LjM1NjYxQzEwNS43NzQgNC43ODQzMSAxMDcuMDg4IDQuNjcyNjEgMTA3LjkyNyA0LjY3MjYxVjUuMTM5OTFaTTEwMy4wMiA4Ljg3ODgxSDEwMy40ODdDMTAzLjQ4NyAxMC4zMDMzIDEwMy4zNjkgMTEuMjU2IDEwMy4yNDIgMTEuODE2NEMxMDMuMTgzIDEyLjA3ODYgMTAzLjExMSAxMi4zMDYzIDEwMy4wMiAxMi40NDlDMTAyLjk5OCAxMi40ODM4IDEwMi45NDkgMTIuNTU2MiAxMDIuODY3IDEyLjYxNTFDMTAyLjgyMyAxMi42NDY1IDEwMi43NDYgMTIuNjkwMiAxMDIuNjQyIDEyLjcwMzVDMTAyLjUyNyAxMi43MTgzIDEwMi40MSAxMi42OTA5IDEwMi4zMTIgMTIuNjI1NUMxMDIuMTUgMTIuNTE2OSAxMDIuMTExIDEyLjM1MzIgMTAyLjEwMyAxMi4zMTU4QzEwMi4wODggMTIuMjUyNSAxMDIuMDg1IDEyLjE5MzIgMTAyLjA4NSAxMi4xNTAzSDEwMi41NTJIMTAzLjAyQzEwMy4wMiAxMi4xNTY0IDEwMy4wMjEgMTIuMTM2NCAxMDMuMDEzIDEyLjEwMjJDMTAzLjAxMSAxMi4wOTQgMTAyLjk4MiAxMS45NDk0IDEwMi44MzMgMTEuODQ5NEMxMDIuNzQyIDExLjc4ODIgMTAyLjYzMiAxMS43NjI1IDEwMi41MjQgMTEuNzc2M0MxMDIuNDI3IDExLjc4ODYgMTAyLjM2IDExLjgyODcgMTAyLjMyNCAxMS44NTM4QzEwMi4yNTkgMTEuOSAxMDIuMjMxIDExLjk0ODkgMTAyLjIzIDExLjk0OTFDMTAyLjIyOCAxMS45NTMzIDEwMi4yNzMgMTEuODY0MyAxMDIuMzMgMTEuNjEwMUMxMDIuNDM3IDExLjEzNzcgMTAyLjU1MiAxMC4yNTg0IDEwMi41NTIgOC44Nzg4MUgxMDMuMDJaTTEwMi41NTIgMTIuMTUwM0wxMDIuNTEgMTIuNjE1N0wxMDAuNjQxIDEyLjQ0NjRMMTAwLjY4MyAxMS45ODFMMTAwLjcyNSAxMS41MTU1TDEwMi41OTUgMTEuNjg0OEwxMDIuNTUyIDEyLjE1MDNaTTEwMC42ODMgMTEuOTgxSDEwMC4yMTZWMy43Mzc5MUgxMDAuNjgzSDEwMS4xNVYxMS45ODFIMTAwLjY4M1pNMTAwLjY4MyAzLjczNzkxVjMuMjcwNTFIMTEwLjk2NVYzLjczNzkxVjQuMjA1MjFIMTAwLjY4M1YzLjczNzkxWk0xMTAuOTY1IDMuNzM3OTFIMTExLjQzMlY0LjkxNTUxSDExMC45NjVIMTEwLjQ5N1YzLjczNzkxSDExMC45NjVaIgogICAgICBmaWxsPSIjMTYxNzFCIgogICAgLz4KICAgIDxwYXRoCiAgICAgIGQ9Ik04Mi40MDYxIDE2Mi42MjdDODMuMTc3MSAxNjIuNzMxIDgzLjk1ODEgMTYyLjczMSA4NC43MjkxIDE2Mi42MjhMMTUzLjM3NSAxNTMuNDQzQzE1NS4yODggMTUzLjE4NyAxNTYuOTYyIDE1NC43MzMgMTU2Ljg1OSAxNTYuNjYxQzE1Ni44MDEgMTU3Ljc0NyAxNTYuMTc0IDE1OC43MjIgMTU1LjIxIDE1OS4yMjZMODUuNTkxMSAxOTUuNTk0Qzg0LjMyMzEgMTk2LjI1NiA4Mi44MTIxIDE5Ni4yNTYgODEuNTQ0MSAxOTUuNTk0TDEyLjA0NjEgMTU5LjI4OUMxMS4wMTUxIDE1OC43NTEgMTAuMzY5MSAxNTcuNjg0IDEwLjM2OTEgMTU2LjUyMUMxMC4zNjkxIDE1NC42MzEgMTIuMDM1MSAxNTMuMTc0IDEzLjkwODEgMTUzLjQyNkw4Mi40MDYxIDE2Mi42MjdaIgogICAgICBmaWxsPSIjMDA0RjNCIgogICAgLz4KICA8L3N2Zz4=', label: 'ISO 9001', }, -}; \ No newline at end of file +}; diff --git a/apps/api/src/trust-portal/dto/trust-vendor.dto.ts b/apps/api/src/trust-portal/dto/trust-vendor.dto.ts index a790c4fb7..47396e54d 100644 --- a/apps/api/src/trust-portal/dto/trust-vendor.dto.ts +++ b/apps/api/src/trust-portal/dto/trust-vendor.dto.ts @@ -1,7 +1,16 @@ import { z } from 'zod'; const ComplianceBadgeSchema = z.object({ - type: z.enum(['soc2', 'iso27001', 'iso42001', 'gdpr', 'hipaa', 'pci_dss', 'nen7510', 'iso9001']), + type: z.enum([ + 'soc2', + 'iso27001', + 'iso42001', + 'gdpr', + 'hipaa', + 'pci_dss', + 'nen7510', + 'iso9001', + ]), verified: z.boolean(), }); @@ -12,6 +21,8 @@ export const UpdateVendorTrustSettingsSchema = z.object({ complianceBadges: z.array(ComplianceBadgeSchema).optional().nullable(), }); -export type UpdateVendorTrustSettingsDto = z.infer; +export type UpdateVendorTrustSettingsDto = z.infer< + typeof UpdateVendorTrustSettingsSchema +>; export type ComplianceBadge = z.infer; diff --git a/apps/api/src/trust-portal/trust-access.service.ts b/apps/api/src/trust-portal/trust-access.service.ts index 6427cbf2c..003b9acef 100644 --- a/apps/api/src/trust-portal/trust-access.service.ts +++ b/apps/api/src/trust-portal/trust-access.service.ts @@ -25,7 +25,6 @@ import archiver from 'archiver'; import { PassThrough, Readable } from 'stream'; import { PDFDocument, rgb, StandardFonts } from 'pdf-lib'; - @Injectable() export class TrustAccessService { /** @@ -2522,7 +2521,9 @@ export class TrustAccessService { } return { ...vendor, - complianceBadges: this.formatComplianceBadgeLabels(vendor.complianceBadges), + complianceBadges: this.formatComplianceBadgeLabels( + vendor.complianceBadges, + ), trustPortalUrl, }; }); @@ -2532,7 +2533,9 @@ export class TrustAccessService { * Format compliance badges as simple type + label pairs for external rendering. * Does NOT include branded icons to avoid implying vendors were certified through us. */ - private formatComplianceBadgeLabels(badges: unknown): { type: string; label: string }[] { + private formatComplianceBadgeLabels( + badges: unknown, + ): { type: string; label: string }[] { if (!badges || !Array.isArray(badges)) { return []; } diff --git a/apps/api/src/trust-portal/trust-portal.controller.ts b/apps/api/src/trust-portal/trust-portal.controller.ts index 4cc5e5a18..1117467ab 100644 --- a/apps/api/src/trust-portal/trust-portal.controller.ts +++ b/apps/api/src/trust-portal/trust-portal.controller.ts @@ -325,7 +325,11 @@ export class TrustPortalController { @AuthContext() authContext: AuthContextType, ) { const dto = UpdateCustomLinkSchema.parse(body); - return this.trustPortalService.updateCustomLink(linkId, dto, authContext.organizationId); + return this.trustPortalService.updateCustomLink( + linkId, + dto, + authContext.organizationId, + ); } @Post('custom-links/:linkId/delete') @@ -341,7 +345,10 @@ export class TrustPortalController { @Param('linkId') linkId: string, @AuthContext() authContext: AuthContextType, ) { - return this.trustPortalService.deleteCustomLink(linkId, authContext.organizationId); + return this.trustPortalService.deleteCustomLink( + linkId, + authContext.organizationId, + ); } @Post('custom-links/reorder') @@ -394,7 +401,11 @@ export class TrustPortalController { @AuthContext() authContext: AuthContextType, ) { const dto = UpdateVendorTrustSettingsSchema.parse(body); - return this.trustPortalService.updateVendorTrustSettings(vendorId, dto, authContext.organizationId); + return this.trustPortalService.updateVendorTrustSettings( + vendorId, + dto, + authContext.organizationId, + ); } @Get('vendors') diff --git a/apps/api/src/trust-portal/trust-portal.service.ts b/apps/api/src/trust-portal/trust-portal.service.ts index ea21950a6..cae381605 100644 --- a/apps/api/src/trust-portal/trust-portal.service.ts +++ b/apps/api/src/trust-portal/trust-portal.service.ts @@ -644,11 +644,14 @@ export class TrustPortalService { } } - async updateOverview(organizationId: string, data: { - overviewTitle?: string | null; - overviewContent?: string | null; - showOverview?: boolean; - }) { + async updateOverview( + organizationId: string, + data: { + overviewTitle?: string | null; + overviewContent?: string | null; + showOverview?: boolean; + }, + ) { const trust = await db.trust.findUnique({ where: { organizationId }, }); @@ -684,11 +687,14 @@ export class TrustPortalService { return trust; } - async createCustomLink(organizationId: string, data: { - title: string; - description?: string | null; - url: string; - }) { + async createCustomLink( + organizationId: string, + data: { + title: string; + description?: string | null; + url: string; + }, + ) { const maxOrder = await db.trustCustomLink.findFirst({ where: { organizationId }, orderBy: { order: 'desc' }, @@ -708,12 +714,16 @@ export class TrustPortalService { }); } - async updateCustomLink(linkId: string, data: { - title?: string; - description?: string | null; - url?: string; - isActive?: boolean; - }, organizationId: string) { + async updateCustomLink( + linkId: string, + data: { + title?: string; + description?: string | null; + url?: string; + isActive?: boolean; + }, + organizationId: string, + ) { const link = await db.trustCustomLink.findUnique({ where: { id: linkId }, }); @@ -723,7 +733,9 @@ export class TrustPortalService { } if (link.organizationId !== organizationId) { - throw new BadRequestException('You can only modify custom links belonging to your organization'); + throw new BadRequestException( + 'You can only modify custom links belonging to your organization', + ); } return db.trustCustomLink.update({ @@ -742,7 +754,9 @@ export class TrustPortalService { } if (link.organizationId !== organizationId) { - throw new BadRequestException('You can only delete custom links belonging to your organization'); + throw new BadRequestException( + 'You can only delete custom links belonging to your organization', + ); } await db.trustCustomLink.delete({ @@ -761,7 +775,9 @@ export class TrustPortalService { const invalidIds = linkIds.filter((id) => !linkIdSet.has(id)); if (invalidIds.length > 0) { - throw new BadRequestException('Some link IDs do not belong to this organization'); + throw new BadRequestException( + 'Some link IDs do not belong to this organization', + ); } await db.$transaction( @@ -790,10 +806,7 @@ export class TrustPortalService { isSubProcessor: true, showOnTrustPortal: true, }, - orderBy: [ - { trustPortalOrder: 'asc' }, - { name: 'asc' }, - ], + orderBy: [{ trustPortalOrder: 'asc' }, { name: 'asc' }], select: { id: true, name: true, @@ -805,12 +818,16 @@ export class TrustPortalService { }); } - async updateVendorTrustSettings(vendorId: string, data: { - logoUrl?: string | null; - showOnTrustPortal?: boolean; - trustPortalOrder?: number | null; - complianceBadges?: any; - }, organizationId: string) { + async updateVendorTrustSettings( + vendorId: string, + data: { + logoUrl?: string | null; + showOnTrustPortal?: boolean; + trustPortalOrder?: number | null; + complianceBadges?: any; + }, + organizationId: string, + ) { const vendor = await db.vendor.findUnique({ where: { id: vendorId }, }); @@ -820,7 +837,9 @@ export class TrustPortalService { } if (vendor.organizationId !== organizationId) { - throw new BadRequestException('You can only modify vendors belonging to your organization'); + throw new BadRequestException( + 'You can only modify vendors belonging to your organization', + ); } return db.vendor.update({ diff --git a/apps/app/next.config.ts b/apps/app/next.config.ts index ab6dc8956..d5fffca64 100644 --- a/apps/app/next.config.ts +++ b/apps/app/next.config.ts @@ -48,6 +48,7 @@ const config: NextConfig = { '@prisma/client', '@trycompai/design-system', '@carbon/icons-react', + '@comp/company', ], images: { remotePatterns: [ diff --git a/apps/app/package.json b/apps/app/package.json index 19c7b1f2b..5ab0118ae 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -1,191 +1,192 @@ { - "name": "@comp/app", - "version": "0.1.0", - "type": "module", - "dependencies": { - "@ai-sdk/anthropic": "^2.0.0", - "@ai-sdk/groq": "^2.0.0", - "@ai-sdk/openai": "^2.0.80", - "@ai-sdk/provider": "^2.0.0", - "@ai-sdk/react": "^2.0.60", - "@ai-sdk/rsc": "^1.0.0", - "@aws-sdk/client-ec2": "^3.911.0", - "@aws-sdk/client-lambda": "^3.891.0", - "@aws-sdk/client-s3": "^3.859.0", - "@aws-sdk/client-sts": "^3.808.0", - "@aws-sdk/s3-request-presigner": "^3.859.0", - "@azure/core-rest-pipeline": "^1.21.0", - "@browserbasehq/sdk": "^2.5.0", - "@browserbasehq/stagehand": "^3.0.5", - "@calcom/atoms": "^1.0.102-framer", - "@calcom/embed-react": "^1.5.3", - "@comp/integration-platform": "workspace:*", - "@date-fns/tz": "^1.2.0", - "@dnd-kit/core": "^6.3.1", - "@dnd-kit/modifiers": "^9.0.0", - "@dnd-kit/sortable": "^10.0.0", - "@dnd-kit/utilities": "^3.2.2", - "@dub/analytics": "^0.0.27", - "@dub/better-auth": "^0.0.6", - "@dub/embed-react": "^0.0.16", - "@hookform/resolvers": "^5.1.1", - "@mendable/firecrawl-js": "^1.24.0", - "@monaco-editor/react": "^4.7.0", - "@nangohq/frontend": "^0.53.2", - "@next/third-parties": "^15.3.1", - "@novu/api": "^1.6.0", - "@novu/nextjs": "^3.10.1", - "@number-flow/react": "^0.5.9", - "@prisma/client": "6.18.0", - "@prisma/instrumentation": "6.18.0", - "@prisma/nextjs-monorepo-workaround-plugin": "6.18.0", - "@radix-ui/react-slot": "^1.2.3", - "@react-email/components": "^0.0.41", - "@react-email/render": "^1.1.2", - "@react-three/drei": "^10.3.0", - "@react-three/fiber": "^9.1.2", - "@react-three/postprocessing": "^3.0.4", - "@t3-oss/env-nextjs": "^0.13.8", - "@tanstack/react-form": "^1.23.8", - "@tanstack/react-query": "^5.90.7", - "@tanstack/react-table": "^8.21.3", - "@tiptap/extension-mention": "3.16.0", - "@tiptap/extension-table": "3.16.0", - "@tiptap/react": "3.16.0", - "@trigger.dev/react-hooks": "4.0.6", - "@trigger.dev/sdk": "4.0.6", - "@trycompai/db": "1.3.22", - "@trycompai/design-system": "^1.0.32", - "@trycompai/email": "workspace:*", - "@types/canvas-confetti": "^1.9.0", - "@types/react-syntax-highlighter": "^15.5.13", - "@types/three": "^0.180.0", - "@uiw/react-json-view": "^2.0.0-alpha.40", - "@uploadthing/react": "^7.3.0", - "@upstash/ratelimit": "^2.0.5", - "@vercel/analytics": "^1.5.0", - "@vercel/sandbox": "^0.0.21", - "@vercel/sdk": "^1.7.1", - "@xyflow/react": "^12.10.0", - "ai": "^5.0.108", - "ai-elements": "^1.6.1", - "axios": "^1.9.0", - "better-auth": "^1.3.27", - "botid": "^1.5.5", - "canvas-confetti": "^1.9.3", - "d3": "^7.9.0", - "date-fns": "^4.1.0", - "diff": "^8.0.2", - "dub": "^0.66.1", - "framer-motion": "^12.18.1", - "geist": "^1.3.1", - "jspdf": "^3.0.2", - "lucide-react": "^0.544.0", - "mammoth": "^1.11.0", - "motion": "^12.9.2", - "next": "^16.0.10", - "next-safe-action": "^8.0.3", - "next-themes": "^0.4.4", - "nuqs": "^2.4.3", - "pdf-parse": "^2.4.5", - "playwright-core": "^1.52.0", - "posthog-js": "^1.236.6", - "posthog-node": "^5.8.2", - "prisma": "6.18.0", - "puppeteer-core": "^24.7.2", - "react": "^19.2.3", - "react-dom": "^19.2.3", - "react-email": "^4.0.15", - "react-hook-form": "^7.61.1", - "react-hotkeys-hook": "^5.1.0", - "react-intersection-observer": "^9.16.0", - "react-markdown": "10.1.0", - "react-spinners": "^0.17.0", - "react-syntax-highlighter": "^15.6.6", - "react-textarea-autosize": "^8.5.9", - "react-use-draggable-scroll": "^0.4.7", - "react-wrap-balancer": "^1.1.1", - "rehype-raw": "^7.0.0", - "remark-gfm": "^4.0.1", - "remark-parse": "^11.0.0", - "resend": "^4.4.1", - "sonner": "^2.0.5", - "stripe": "^20.0.0", - "swr": "^2.3.4", - "three": "^0.182.0", - "ts-pattern": "^5.7.0", - "use-debounce": "^10.0.4", - "use-long-press": "^3.3.0", - "use-stick-to-bottom": "^1.1.1", - "xlsx": "^0.18.5", - "xml2js": "^0.6.2", - "zaraz-ts": "^1.2.0", - "zod": "^4.0.0", - "zustand": "^5.0.3" - }, - "devDependencies": { - "@playwright/experimental-ct-react": "^1.53.1", - "@playwright/test": "^1.53.1", - "@tailwindcss/postcss": "^4.1.10", - "@testing-library/dom": "^10.4.0", - "@testing-library/jest-dom": "^6.6.3", - "@testing-library/react": "^16.3.0", - "@trigger.dev/build": "4.0.6", - "@types/d3": "^7.4.3", - "@types/jspdf": "^2.0.0", - "@types/node": "^24.0.3", - "@vitejs/plugin-react": "^4.6.0", - "@vitest/ui": "^3.2.4", - "eslint": "^9.18.0", - "eslint-config-next": "15.5.2", - "fleetctl": "^4.68.1", - "glob": "^11.0.3", - "jsdom": "^26.1.0", - "postcss": "^8.5.4", - "raw-loader": "^4.0.2", - "tailwindcss": "^4.1.8", - "typescript": "^5.8.3", - "vite-tsconfig-paths": "^5.1.4", - "vitest": "^3.2.4" - }, - "exports": { - "./src/lib/encryption": "./src/lib/encryption.ts" - }, - "peerDependencies": { - "react": "^19.1.1", - "react-dom": "^19.1.0" - }, - "pnpm": { - "overrides": { - "tiptap-extension-global-drag-handle": "^0.1.18" + "name": "@comp/app", + "version": "0.1.0", + "type": "module", + "dependencies": { + "@ai-sdk/anthropic": "^2.0.0", + "@ai-sdk/groq": "^2.0.0", + "@ai-sdk/openai": "^2.0.80", + "@ai-sdk/provider": "^2.0.0", + "@ai-sdk/react": "^2.0.60", + "@ai-sdk/rsc": "^1.0.0", + "@aws-sdk/client-ec2": "^3.911.0", + "@aws-sdk/client-lambda": "^3.891.0", + "@aws-sdk/client-s3": "^3.859.0", + "@aws-sdk/client-sts": "^3.808.0", + "@aws-sdk/s3-request-presigner": "^3.859.0", + "@azure/core-rest-pipeline": "^1.21.0", + "@browserbasehq/sdk": "^2.5.0", + "@browserbasehq/stagehand": "^3.0.5", + "@calcom/atoms": "^1.0.102-framer", + "@calcom/embed-react": "^1.5.3", + "@comp/company": "workspace:*", + "@comp/integration-platform": "workspace:*", + "@date-fns/tz": "^1.2.0", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/modifiers": "^9.0.0", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", + "@dub/analytics": "^0.0.27", + "@dub/better-auth": "^0.0.6", + "@dub/embed-react": "^0.0.16", + "@hookform/resolvers": "^5.1.1", + "@mendable/firecrawl-js": "^1.24.0", + "@monaco-editor/react": "^4.7.0", + "@nangohq/frontend": "^0.53.2", + "@next/third-parties": "^15.3.1", + "@novu/api": "^1.6.0", + "@novu/nextjs": "^3.10.1", + "@number-flow/react": "^0.5.9", + "@prisma/client": "6.18.0", + "@prisma/instrumentation": "6.18.0", + "@prisma/nextjs-monorepo-workaround-plugin": "6.18.0", + "@radix-ui/react-slot": "^1.2.3", + "@react-email/components": "^0.0.41", + "@react-email/render": "^1.1.2", + "@react-three/drei": "^10.3.0", + "@react-three/fiber": "^9.1.2", + "@react-three/postprocessing": "^3.0.4", + "@t3-oss/env-nextjs": "^0.13.8", + "@tanstack/react-form": "^1.23.8", + "@tanstack/react-query": "^5.90.7", + "@tanstack/react-table": "^8.21.3", + "@tiptap/extension-mention": "3.16.0", + "@tiptap/extension-table": "3.16.0", + "@tiptap/react": "3.16.0", + "@trigger.dev/react-hooks": "4.0.6", + "@trigger.dev/sdk": "4.0.6", + "@trycompai/db": "1.3.22", + "@trycompai/design-system": "^1.0.32", + "@trycompai/email": "workspace:*", + "@types/canvas-confetti": "^1.9.0", + "@types/react-syntax-highlighter": "^15.5.13", + "@types/three": "^0.180.0", + "@uiw/react-json-view": "^2.0.0-alpha.40", + "@uploadthing/react": "^7.3.0", + "@upstash/ratelimit": "^2.0.5", + "@vercel/analytics": "^1.5.0", + "@vercel/sandbox": "^0.0.21", + "@vercel/sdk": "^1.7.1", + "@xyflow/react": "^12.10.0", + "ai": "^5.0.108", + "ai-elements": "^1.6.1", + "axios": "^1.9.0", + "better-auth": "^1.3.27", + "botid": "^1.5.5", + "canvas-confetti": "^1.9.3", + "d3": "^7.9.0", + "date-fns": "^4.1.0", + "diff": "^8.0.2", + "dub": "^0.66.1", + "framer-motion": "^12.18.1", + "geist": "^1.3.1", + "jspdf": "^3.0.2", + "lucide-react": "^0.544.0", + "mammoth": "^1.11.0", + "motion": "^12.9.2", + "next": "^16.0.10", + "next-safe-action": "^8.0.3", + "next-themes": "^0.4.4", + "nuqs": "^2.4.3", + "pdf-parse": "^2.4.5", + "playwright-core": "^1.52.0", + "posthog-js": "^1.236.6", + "posthog-node": "^5.8.2", + "prisma": "6.18.0", + "puppeteer-core": "^24.7.2", + "react": "^19.2.3", + "react-dom": "^19.2.3", + "react-email": "^4.0.15", + "react-hook-form": "^7.61.1", + "react-hotkeys-hook": "^5.1.0", + "react-intersection-observer": "^9.16.0", + "react-markdown": "10.1.0", + "react-spinners": "^0.17.0", + "react-syntax-highlighter": "^15.6.6", + "react-textarea-autosize": "^8.5.9", + "react-use-draggable-scroll": "^0.4.7", + "react-wrap-balancer": "^1.1.1", + "rehype-raw": "^7.0.0", + "remark-gfm": "^4.0.1", + "remark-parse": "^11.0.0", + "resend": "^4.4.1", + "sonner": "^2.0.5", + "stripe": "^20.0.0", + "swr": "^2.3.4", + "three": "^0.182.0", + "ts-pattern": "^5.7.0", + "use-debounce": "^10.0.4", + "use-long-press": "^3.3.0", + "use-stick-to-bottom": "^1.1.1", + "xlsx": "^0.18.5", + "xml2js": "^0.6.2", + "zaraz-ts": "^1.2.0", + "zod": "^4.0.0", + "zustand": "^5.0.3" + }, + "devDependencies": { + "@playwright/experimental-ct-react": "^1.53.1", + "@playwright/test": "^1.53.1", + "@tailwindcss/postcss": "^4.1.10", + "@testing-library/dom": "^10.4.0", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.3.0", + "@trigger.dev/build": "4.0.6", + "@types/d3": "^7.4.3", + "@types/jspdf": "^2.0.0", + "@types/node": "^24.0.3", + "@vitejs/plugin-react": "^4.6.0", + "@vitest/ui": "^3.2.4", + "eslint": "^9.18.0", + "eslint-config-next": "15.5.2", + "fleetctl": "^4.68.1", + "glob": "^11.0.3", + "jsdom": "^26.1.0", + "postcss": "^8.5.4", + "raw-loader": "^4.0.2", + "tailwindcss": "^4.1.8", + "typescript": "^5.8.3", + "vite-tsconfig-paths": "^5.1.4", + "vitest": "^3.2.4" + }, + "exports": { + "./src/lib/encryption": "./src/lib/encryption.ts" + }, + "peerDependencies": { + "react": "^19.1.1", + "react-dom": "^19.1.0" + }, + "pnpm": { + "overrides": { + "tiptap-extension-global-drag-handle": "^0.1.18" + } + }, + "private": true, + "scripts": { + "analyze-locale-usage": "bunx tsx src/locales/analyze-locale-usage.ts", + "build": "next build", + "build:docker": "prisma generate && next build", + "db:generate": "bun run db:getschema && prisma generate", + "db:getschema": "node ../../packages/db/scripts/combine-schemas.js && cp ../../packages/db/dist/schema.prisma prisma/schema.prisma", + "db:migrate": "cd ../../packages/db && bunx prisma migrate dev && cd ../../apps/app", + "deploy:trigger-prod": "npx trigger.dev@4.0.6 deploy", + "dev": "bun i && bunx concurrently --kill-others --names \"next,trigger\" --prefix-colors \"yellow,blue\" \"next dev --turbo -p 3000\" \"bunx trigger.dev@4.0.6 dev\"", + "lint": "eslint . && prettier --check .", + "prebuild": "bun run db:generate", + "postinstall": "prisma generate --schema=./prisma/schema.prisma || exit 0", + "start": "next start", + "test": "vitest", + "test:all": "./scripts/test-all.sh", + "test:coverage": "vitest run --coverage", + "test:e2e": "playwright test", + "test:e2e:debug": "playwright test --debug", + "test:e2e:headed": "playwright test --headed", + "test:e2e:install": "playwright install --with-deps", + "test:e2e:report": "playwright show-report", + "test:e2e:setup": "./scripts/setup-e2e.sh", + "test:e2e:ui": "playwright test --ui", + "test:ui": "vitest --ui", + "test:watch": "vitest --watch", + "typecheck": "tsc --noEmit" } - }, - "private": true, - "scripts": { - "analyze-locale-usage": "bunx tsx src/locales/analyze-locale-usage.ts", - "build": "next build", - "build:docker": "prisma generate && next build", - "db:generate": "bun run db:getschema && prisma generate", - "db:getschema": "node ../../packages/db/scripts/combine-schemas.js && cp ../../packages/db/dist/schema.prisma prisma/schema.prisma", - "db:migrate": "cd ../../packages/db && bunx prisma migrate dev && cd ../../apps/app", - "deploy:trigger-prod": "npx trigger.dev@4.0.6 deploy", - "dev": "bun i && bunx concurrently --kill-others --names \"next,trigger\" --prefix-colors \"yellow,blue\" \"next dev --turbo -p 3000\" \"bunx trigger.dev@4.0.6 dev\"", - "lint": "eslint . && prettier --check .", - "prebuild": "bun run db:generate", - "postinstall": "prisma generate --schema=./prisma/schema.prisma || exit 0", - "start": "next start", - "test": "vitest", - "test:all": "./scripts/test-all.sh", - "test:coverage": "vitest run --coverage", - "test:e2e": "playwright test", - "test:e2e:debug": "playwright test --debug", - "test:e2e:headed": "playwright test --headed", - "test:e2e:install": "playwright install --with-deps", - "test:e2e:report": "playwright show-report", - "test:e2e:setup": "./scripts/setup-e2e.sh", - "test:e2e:ui": "playwright test --ui", - "test:ui": "vitest --ui", - "test:watch": "vitest --watch", - "typecheck": "tsc --noEmit" - } -} \ No newline at end of file +} diff --git a/apps/app/src/actions/organization/update-organization-access-request-form-action.ts b/apps/app/src/actions/organization/update-organization-access-request-form-action.ts new file mode 100644 index 000000000..adfd12e7a --- /dev/null +++ b/apps/app/src/actions/organization/update-organization-access-request-form-action.ts @@ -0,0 +1,46 @@ +'use server'; + +import { db } from '@db'; +import { revalidatePath, revalidateTag } from 'next/cache'; +import { headers } from 'next/headers'; +import { authActionClient } from '../safe-action'; +import { organizationAccessRequestFormSchema } from '../schema'; + +export const updateOrganizationAccessRequestFormAction = authActionClient + .inputSchema(organizationAccessRequestFormSchema) + .metadata({ + name: 'update-organization-access-request-form', + track: { + event: 'update-organization-access-request-form', + channel: 'server', + }, + }) + .action(async ({ parsedInput, ctx }) => { + const { accessRequestFormEnabled } = parsedInput; + const { activeOrganizationId } = ctx.session; + + if (!activeOrganizationId) { + throw new Error('No active organization'); + } + + try { + await db.organization.update({ + where: { id: activeOrganizationId }, + data: { accessRequestFormEnabled }, + }); + + const headersList = await headers(); + let path = headersList.get('x-pathname') || headersList.get('referer') || ''; + path = path.replace(/\/[a-z]{2}\//, '/'); + + revalidatePath(path); + revalidateTag(`organization_${activeOrganizationId}`, 'max'); + + return { + success: true, + }; + } catch (error) { + console.error(error); + throw new Error('Failed to update access request form setting'); + } + }); diff --git a/apps/app/src/actions/organization/update-organization-whistleblower-report-action.ts b/apps/app/src/actions/organization/update-organization-whistleblower-report-action.ts new file mode 100644 index 000000000..e01cceb2c --- /dev/null +++ b/apps/app/src/actions/organization/update-organization-whistleblower-report-action.ts @@ -0,0 +1,46 @@ +'use server'; + +import { db } from '@db'; +import { revalidatePath, revalidateTag } from 'next/cache'; +import { headers } from 'next/headers'; +import { authActionClient } from '../safe-action'; +import { organizationWhistleblowerReportSchema } from '../schema'; + +export const updateOrganizationWhistleblowerReportAction = authActionClient + .inputSchema(organizationWhistleblowerReportSchema) + .metadata({ + name: 'update-organization-whistleblower-report', + track: { + event: 'update-organization-whistleblower-report', + channel: 'server', + }, + }) + .action(async ({ parsedInput, ctx }) => { + const { whistleblowerReportEnabled } = parsedInput; + const { activeOrganizationId } = ctx.session; + + if (!activeOrganizationId) { + throw new Error('No active organization'); + } + + try { + await db.organization.update({ + where: { id: activeOrganizationId }, + data: { whistleblowerReportEnabled }, + }); + + const headersList = await headers(); + let path = headersList.get('x-pathname') || headersList.get('referer') || ''; + path = path.replace(/\/[a-z]{2}\//, '/'); + + revalidatePath(path); + revalidateTag(`organization_${activeOrganizationId}`, 'max'); + + return { + success: true, + }; + } catch (error) { + console.error(error); + throw new Error('Failed to update whistleblower report setting'); + } + }); diff --git a/apps/app/src/actions/schema.ts b/apps/app/src/actions/schema.ts index 3f7034ce6..9a91c755f 100644 --- a/apps/app/src/actions/schema.ts +++ b/apps/app/src/actions/schema.ts @@ -77,6 +77,14 @@ export const organizationSecurityTrainingStepSchema = z.object({ securityTrainingStepEnabled: z.boolean(), }); +export const organizationWhistleblowerReportSchema = z.object({ + whistleblowerReportEnabled: z.boolean(), +}); + +export const organizationAccessRequestFormSchema = z.object({ + accessRequestFormEnabled: z.boolean(), +}); + // Risks export const createRiskSchema = z.object({ title: z diff --git a/apps/app/src/app/(app)/[orgId]/components/AppShellWrapper.tsx b/apps/app/src/app/(app)/[orgId]/components/AppShellWrapper.tsx index 3b57abb8c..2833f0fc7 100644 --- a/apps/app/src/app/(app)/[orgId]/components/AppShellWrapper.tsx +++ b/apps/app/src/app/(app)/[orgId]/components/AppShellWrapper.tsx @@ -243,7 +243,13 @@ function AppShellWrapperContent({ {isSettingsActive ? ( diff --git a/apps/app/src/app/(app)/[orgId]/components/AppSidebar.tsx b/apps/app/src/app/(app)/[orgId]/components/AppSidebar.tsx index d5b58307f..f92cfeb30 100644 --- a/apps/app/src/app/(app)/[orgId]/components/AppSidebar.tsx +++ b/apps/app/src/app/(app)/[orgId]/components/AppSidebar.tsx @@ -1,6 +1,7 @@ 'use client'; import { + Catalog, Chemistry, Dashboard, Document, @@ -74,6 +75,12 @@ export function AppSidebar({ name: 'Evidence', icon: , }, + { + id: 'documents', + path: `/${organization.id}/documents`, + name: 'Documents', + icon: , + }, { id: 'people', path: `/${organization.id}/people/all`, diff --git a/apps/app/src/app/(app)/[orgId]/components/app-shell-search-groups.tsx b/apps/app/src/app/(app)/[orgId]/components/app-shell-search-groups.tsx index 5d260829b..ad944cf2d 100644 --- a/apps/app/src/app/(app)/[orgId]/components/app-shell-search-groups.tsx +++ b/apps/app/src/app/(app)/[orgId]/components/app-shell-search-groups.tsx @@ -1,4 +1,5 @@ import { + Catalog, Chemistry, Dashboard, Document, @@ -124,6 +125,14 @@ export const getAppShellSearchGroups = ({ }), ] : []), + createNavItem({ + id: 'documents', + label: 'Documents', + icon: , + path: `/${organizationId}/documents`, + keywords: ['company', 'tasks', 'forms', 'evidence submissions', 'documents'], + router, + }), createNavItem({ id: 'people', label: 'People', diff --git a/apps/app/src/app/(app)/[orgId]/documents/[formType]/new/page.tsx b/apps/app/src/app/(app)/[orgId]/documents/[formType]/new/page.tsx new file mode 100644 index 000000000..eec663891 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/documents/[formType]/new/page.tsx @@ -0,0 +1,49 @@ +import { CompanySubmissionWizard } from '@/app/(app)/[orgId]/documents/components/CompanySubmissionWizard'; +import { conciseFormDescriptions } from '@/app/(app)/[orgId]/documents/form-descriptions'; +import { Breadcrumb, PageHeader, PageLayout, Text } from '@trycompai/design-system'; +import Link from 'next/link'; +import { notFound } from 'next/navigation'; +import { evidenceFormDefinitions, evidenceFormTypeSchema } from '../../forms'; + +export default async function NewCompanySubmissionPage({ + params, +}: { + params: Promise<{ orgId: string; formType: string }>; +}) { + const { orgId, formType } = await params; + const parsedType = evidenceFormTypeSchema.safeParse(formType); + + if (!parsedType.success) { + notFound(); + } + + const parsedFormType = parsedType.data; + const formDefinition = evidenceFormDefinitions[parsedFormType]; + + return ( + + }, + }, + { + label: formDefinition.title, + href: `/${orgId}/documents/${parsedFormType}`, + props: { render: }, + }, + { label: 'New Submission', isCurrent: true }, + ]} + /> + +
+ + {conciseFormDescriptions[parsedFormType] ?? formDefinition.description} + + +
+
+ ); +} diff --git a/apps/app/src/app/(app)/[orgId]/documents/[formType]/page.tsx b/apps/app/src/app/(app)/[orgId]/documents/[formType]/page.tsx new file mode 100644 index 000000000..f1651d3c6 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/documents/[formType]/page.tsx @@ -0,0 +1,36 @@ +import { CompanyFormPageClient } from '@/app/(app)/[orgId]/documents/components/CompanyFormPageClient'; +import { Breadcrumb, PageLayout } from '@trycompai/design-system'; +import Link from 'next/link'; +import { notFound } from 'next/navigation'; +import { evidenceFormDefinitions, evidenceFormTypeSchema } from '../forms'; + +export default async function CompanyFormDetailPage({ + params, +}: { + params: Promise<{ orgId: string; formType: string }>; +}) { + const { orgId, formType } = await params; + const parsedType = evidenceFormTypeSchema.safeParse(formType); + + if (!parsedType.success) { + notFound(); + } + + const formDefinition = evidenceFormDefinitions[parsedType.data]; + + return ( + + }, + }, + { label: formDefinition.title, isCurrent: true }, + ]} + /> + + + ); +} diff --git a/apps/app/src/app/(app)/[orgId]/documents/[formType]/submissions/[submissionId]/page.tsx b/apps/app/src/app/(app)/[orgId]/documents/[formType]/submissions/[submissionId]/page.tsx new file mode 100644 index 000000000..60c26ddf1 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/documents/[formType]/submissions/[submissionId]/page.tsx @@ -0,0 +1,63 @@ +import { CompanySubmissionDetailPageClient } from '@/app/(app)/[orgId]/documents/components/CompanySubmissionDetailPageClient'; +import { auth } from '@/utils/auth'; +import { db } from '@db'; +import { Breadcrumb, PageHeader, PageLayout } from '@trycompai/design-system'; +import { headers } from 'next/headers'; +import Link from 'next/link'; +import { notFound } from 'next/navigation'; +import { evidenceFormDefinitions, evidenceFormTypeSchema } from '../../../forms'; + +export default async function CompanySubmissionDetailPage({ + params, +}: { + params: Promise<{ orgId: string; formType: string; submissionId: string }>; +}) { + const { orgId, formType, submissionId } = await params; + const parsedType = evidenceFormTypeSchema.safeParse(formType); + + if (!parsedType.success) { + notFound(); + } + + const parsedFormType = parsedType.data; + const formDefinition = evidenceFormDefinitions[parsedFormType]; + const session = await auth.api.getSession({ + headers: await headers(), + }); + + let isPlatformAdmin = false; + if (session?.user?.id) { + const user = await db.user.findUnique({ + where: { id: session.user.id }, + select: { isPlatformAdmin: true }, + }); + isPlatformAdmin = user?.isPlatformAdmin ?? false; + } + + return ( + + }, + }, + { + label: formDefinition.title, + href: `/${orgId}/documents/${parsedFormType}`, + props: { render: }, + }, + { label: 'Submission Details', isCurrent: true }, + ]} + /> + + + + ); +} diff --git a/apps/app/src/app/(app)/[orgId]/documents/components/CompanyFormPageClient.tsx b/apps/app/src/app/(app)/[orgId]/documents/components/CompanyFormPageClient.tsx new file mode 100644 index 000000000..dafe1db6e --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/documents/components/CompanyFormPageClient.tsx @@ -0,0 +1,300 @@ +'use client'; + +import { + evidenceFormDefinitions, + type EvidenceFormType, +} from '@/app/(app)/[orgId]/documents/forms'; +import { conciseFormDescriptions } from '@/app/(app)/[orgId]/documents/form-descriptions'; +import { api } from '@/lib/api-client'; +import { jwtManager } from '@/utils/jwt-manager'; +import { + Button, + Empty, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, + InputGroup, + InputGroupAddon, + InputGroupInput, + PageHeader, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, + Text, +} from '@trycompai/design-system'; +import { Add, Catalog, Download, Search } from '@trycompai/design-system/icons'; +import Link from 'next/link'; +import { useRouter } from 'next/navigation'; +import { useState } from 'react'; +import { toast } from 'sonner'; +import useSWR from 'swr'; +import { StatusBadge, formatSubmissionDate } from './submission-utils'; + +// ─── Types ─────────────────────────────────────────────────── + +type EvidenceSubmissionRow = { + id: string; + submittedAt: string; + status: string; + data: Record; + submittedBy?: { + name: string | null; + email: string; + } | null; +}; + +type EvidenceFormResponse = { + form: (typeof evidenceFormDefinitions)[EvidenceFormType]; + submissions: EvidenceSubmissionRow[]; + total: number; +}; + +const submissionDateColumnWidth = 128; +const submittedByColumnWidth = 128; +const statusColumnWidth = 176; +const summaryColumnWidth = 280; + +// ─── Helpers ───────────────────────────────────────────────── + +function truncate(str: string, max: number) { + if (str.length <= max) return str; + return `${str.slice(0, max)}…`; +} + +function getMatrixRowCount(value: unknown): number { + if (!Array.isArray(value)) return 0; + return value.filter((row) => row && typeof row === 'object').length; +} + +// ─── Main Component ────────────────────────────────────────── + +export function CompanyFormPageClient({ + organizationId, + formType, +}: { + organizationId: string; + formType: EvidenceFormType; +}) { + const router = useRouter(); + const [search, setSearch] = useState(''); + const [isExporting, setIsExporting] = useState(false); + + const formDefinition = evidenceFormDefinitions[formType]; + const summaryField = formDefinition.fields.find((field) => field.type === 'textarea'); + const matrixSummaryField = formDefinition.fields.find((field) => field.type === 'matrix'); + const hasMatrixSummary = Boolean(matrixSummaryField); + const showSummaryColumn = Boolean(summaryField) || hasMatrixSummary; + + const query = search.trim() ? `?search=${encodeURIComponent(search.trim())}` : ''; + const swrKey: readonly [string, string] = [ + `/v1/evidence-forms/${formType}${query}`, + organizationId, + ]; + + const { data, isLoading } = useSWR( + swrKey, + async ([endpoint, orgId]: readonly [string, string]) => { + const response = await api.get(endpoint, orgId); + if (response.error || !response.data) { + throw new Error(response.error ?? 'Failed to load evidence form submissions'); + } + return response.data; + }, + ); + + const handleExportCsv = async () => { + if (!data || data.total === 0) { + toast.error('No submissions available to export'); + return; + } + + setIsExporting(true); + try { + const token = await jwtManager.getValidToken(); + if (!token) { + throw new Error('Authentication failed'); + } + + const response = await fetch( + `${process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3333'}/v1/evidence-forms/${formType}/export.csv`, + { + method: 'GET', + headers: { + Authorization: `Bearer ${token}`, + 'X-Organization-Id': organizationId, + }, + credentials: 'include', + }, + ); + + if (!response.ok) { + throw new Error(await response.text()); + } + + const blob = await response.blob(); + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = `${formType}-submissions.csv`; + document.body.appendChild(anchor); + anchor.click(); + document.body.removeChild(anchor); + URL.revokeObjectURL(url); + } catch (error) { + toast.error(error instanceof Error ? error.message : 'CSV export failed'); + } finally { + setIsExporting(false); + } + }; + + return ( +
+ + + + + +
+ } + /> +
+ + {conciseFormDescriptions[formType] ?? formDefinition.description} + +
+ + {/* ─── Submissions List ─── */} +
+
+ + + + + setSearch(event.target.value)} + /> + +
+ + {isLoading ? ( + + + + + + No submissions yet. + + Start by creating a new submission, click the New Submission button above. + + + + ) : !data || data.submissions.length === 0 ? ( + + + + + + No submissions yet + + Start by creating a new submission, click the New Submission button above. + + + + ) : ( + + + + + {formType === 'access-request' && } + {showSummaryColumn && } + + + + +
Submission Date
+
+ +
Submitted By
+
+ {formType === 'access-request' && ( + +
Status
+
+ )} + {showSummaryColumn && Summary} +
+
+ + {data.submissions.map((submission) => { + const summaryValue = summaryField + ? String(submission.data[summaryField.key] ?? '') + : ''; + const matrixSummary = matrixSummaryField + ? `${getMatrixRowCount(submission.data[matrixSummaryField.key])} row(s)` + : ''; + const rowSummary = summaryField ? truncate(summaryValue, 80) : matrixSummary; + + return ( + + router.push( + `/${organizationId}/documents/${formType}/submissions/${submission.id}`, + ) + } + style={{ cursor: 'pointer' }} + > + +
+ {formatSubmissionDate( + submission.data.submissionDate, + submission.submittedAt, + )} +
+
+ + + {submission.submittedBy?.name ?? submission.submittedBy?.email ?? 'Unknown'} + + + {formType === 'access-request' && ( + +
+ +
+
+ )} + {showSummaryColumn && ( + + + {rowSummary || '—'} + + + )} +
+ ); + })} +
+
+ )} +
+ + ); +} diff --git a/apps/app/src/app/(app)/[orgId]/documents/components/CompanyOverviewCards.tsx b/apps/app/src/app/(app)/[orgId]/documents/components/CompanyOverviewCards.tsx new file mode 100644 index 000000000..50e791e14 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/documents/components/CompanyOverviewCards.tsx @@ -0,0 +1,101 @@ +'use client'; + +import { conciseFormDescriptions } from '@/app/(app)/[orgId]/documents/form-descriptions'; +import { api } from '@/lib/api-client'; +import { + Badge, + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, + Stack, + Text, +} from '@trycompai/design-system'; +import Link from 'next/link'; +import useSWR from 'swr'; +import { evidenceFormDefinitionList } from '../forms'; + +type FormStatuses = Record; + +const SIX_MONTHS_MS = 6 * 30 * 24 * 60 * 60 * 1000; + +function isTodo(lastSubmittedAt: string | null): boolean { + if (!lastSubmittedAt) return true; + const elapsed = Date.now() - new Date(lastSubmittedAt).getTime(); + return elapsed > SIX_MONTHS_MS; +} + +export function CompanyOverviewCards({ organizationId }: { organizationId: string }) { + const swrKey: readonly [string, string] = ['/v1/evidence-forms/statuses', organizationId]; + + const { data: statuses } = useSWR( + swrKey, + async ([endpoint, orgId]: readonly [string, string]) => { + const response = await api.get(endpoint, orgId); + if (response.error || !response.data) { + throw new Error(response.error ?? 'Failed to load form statuses'); + } + return response.data; + }, + ); + + // Group forms by category + const categories = new Map(); + for (const form of evidenceFormDefinitionList) { + const cat = form.category; + if (!categories.has(cat)) { + categories.set(cat, []); + } + categories.get(cat)!.push(form); + } + + return ( + + {Array.from(categories.entries()).map(([category, forms]) => ( +
+
+ + {category} + + {forms.length} +
+
+ {forms.map((form) => { + const status = statuses?.[form.type]; + const showTodo = statuses ? isTodo(status?.lastSubmittedAt ?? null) : false; + + return ( + + + + {form.title} +
+ + {conciseFormDescriptions[form.type] ?? form.description} + +
+
+ + {statuses ? ( + showTodo ? ( + TODO + ) : ( + Complete + ) + ) : ( + + {form.fields.length} fields + + )} + +
+ + ); + })} +
+
+ ))} +
+ ); +} diff --git a/apps/app/src/app/(app)/[orgId]/documents/components/CompanySidebar.tsx b/apps/app/src/app/(app)/[orgId]/documents/components/CompanySidebar.tsx new file mode 100644 index 000000000..1ff6f77f0 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/documents/components/CompanySidebar.tsx @@ -0,0 +1,56 @@ +'use client'; + +import { AppShellNav, AppShellNavItem } from '@trycompai/design-system'; +import Link from 'next/link'; +import { usePathname } from 'next/navigation'; +import { evidenceFormDefinitionList } from '../forms'; + +interface CompanySidebarProps { + orgId: string; +} + +export function CompanySidebar({ orgId }: CompanySidebarProps) { + const pathname = usePathname() ?? ''; + + const isPathActive = (path: string) => { + if (path === `/${orgId}/documents`) { + return pathname === path; + } + return pathname.startsWith(path); + }; + + // Group forms by category preserving insertion order + const categories = new Map>(); + for (const form of evidenceFormDefinitionList) { + const cat = form.category; + if (!categories.has(cat)) { + categories.set(cat, []); + } + categories.get(cat)!.push({ + id: form.type, + label: form.title, + path: `/${orgId}/documents/${form.type}`, + }); + } + + return ( + + + Overview + + + {Array.from(categories.entries()).map(([category, items]) => ( +
+
+ {category} +
+ {items.map((item) => ( + + {item.label} + + ))} +
+ ))} +
+ ); +} diff --git a/apps/app/src/app/(app)/[orgId]/documents/components/CompanySubmissionDetailPageClient.tsx b/apps/app/src/app/(app)/[orgId]/documents/components/CompanySubmissionDetailPageClient.tsx new file mode 100644 index 000000000..88e964bc5 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/documents/components/CompanySubmissionDetailPageClient.tsx @@ -0,0 +1,370 @@ +'use client'; + +import { + evidenceFormDefinitions, + type EvidenceFormType, +} from '@/app/(app)/[orgId]/documents/forms'; +import { api } from '@/lib/api-client'; +import { useActiveMember } from '@/utils/auth-client'; +import { + Button, + Empty, + EmptyMedia, + EmptyDescription, + EmptyHeader, + EmptyTitle, + Field, + FieldLabel, + Section, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, + Text, + Textarea, +} from '@trycompai/design-system'; +import { Document } from '@trycompai/design-system/icons'; +import { useState } from 'react'; +import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; +import { toast } from 'sonner'; +import useSWR from 'swr'; +import { + StatusBadge, + formatSubmissionDate, + isMatrixField, + normalizeMatrixRows, + renderSubmissionValue, +} from './submission-utils'; +import { DocumentFindingsSection } from './DocumentFindingsSection'; + +type EvidenceSubmissionRow = { + id: string; + submittedAt: string; + status: string; + reviewedAt?: string | null; + reviewReason?: string | null; + data: Record; + submittedBy?: { + name: string | null; + email: string; + } | null; + reviewedBy?: { + name: string | null; + email: string; + } | null; +}; + +type EvidenceSubmissionResponse = { + form: (typeof evidenceFormDefinitions)[EvidenceFormType]; + submission: EvidenceSubmissionRow; +}; + +function MarkdownPreview({ content }: { content: string }) { + return ( +
+ {content} +
+ ); +} + +export function CompanySubmissionDetailPageClient({ + organizationId, + formType, + submissionId, + isPlatformAdmin, +}: { + organizationId: string; + formType: EvidenceFormType; + submissionId: string; + isPlatformAdmin: boolean; +}) { + const endpoint = `/v1/evidence-forms/${formType}/submissions/${submissionId}`; + const swrKey: readonly [string, string] = [endpoint, organizationId]; + + const { data, isLoading, error, mutate } = useSWR( + swrKey, + async ([path, orgId]: readonly [string, string]) => { + const response = await api.get(path, orgId); + if (response.error || !response.data) { + throw new Error(response.error ?? 'Failed to load submission'); + } + return response.data; + }, + ); + + const [reviewReason, setReviewReason] = useState(''); + const [isSubmittingReview, setIsSubmittingReview] = useState(false); + const { data: activeMember } = useActiveMember(); + const memberRoles = activeMember?.role?.split(',').map((role: string) => role.trim()) || []; + const isAuditor = memberRoles.includes('auditor'); + const isAdminOrOwner = memberRoles.includes('admin') || memberRoles.includes('owner'); + + const handleReview = async (action: 'approved' | 'rejected') => { + if (action === 'rejected' && !reviewReason.trim()) { + toast.error('A reason is required when rejecting a submission'); + return; + } + + setIsSubmittingReview(true); + try { + const response = await api.patch( + `/v1/evidence-forms/${formType}/submissions/${submissionId}/review`, + { action, reason: reviewReason.trim() || undefined }, + organizationId, + ); + + if (response.error) { + toast.error(response.error); + return; + } + + toast.success(action === 'approved' ? 'Submission approved' : 'Submission rejected'); + setReviewReason(''); + mutate(); + } catch { + toast.error('Failed to submit review'); + } finally { + setIsSubmittingReview(false); + } + }; + + if (isLoading) { + return ( + + + + + + Loading submission... + Fetching the selected document details. + + + ); + } + + if (error || !data?.submission) { + return ( + + + Submission not found + + This submission may have been removed or you may not have access. + + + + ); + } + + const submission = data.submission; + const fields = data.form.fields.filter((field) => field.key !== 'submissionDate'); + const compactFields = fields.filter((f) => f.type === 'text' || f.type === 'date'); + const selectFields = fields.filter((f) => f.type === 'select'); + const textareaFields = fields.filter((f) => f.type === 'textarea'); + const fileFields = fields.filter((f) => f.type === 'file'); + const matrixFields = fields.filter(isMatrixField); + + return ( +
+
+
+
+ {formType === 'access-request' && ( +
+
+ Status +
+
+ +
+
+ )} +
+
+ Submission Date +
+
+ {formatSubmissionDate(submission.data.submissionDate, submission.submittedAt)} +
+
+
+
+ Submitted By +
+
+ {submission.submittedBy?.name ?? submission.submittedBy?.email ?? 'Unknown'} +
+
+ {formType === 'access-request' && submission.status !== 'pending' && ( + <> +
+
+ Reviewed By +
+
+ {submission.reviewedBy?.name ?? submission.reviewedBy?.email ?? '—'} +
+
+ {submission.reviewReason && ( +
+
+ Review Reason +
+
+ {submission.reviewReason} +
+
+ )} + + )} + {compactFields.map((field) => ( +
+
+ {field.label} +
+
+ {renderSubmissionValue(submission.data[field.key], field)} +
+
+ ))} + {selectFields.map((field) => ( +
+
+ {field.label} +
+
+ {renderSubmissionValue(submission.data[field.key], field)} +
+
+ ))} + {textareaFields.map((field) => { + const value = submission.data[field.key]; + const content = typeof value === 'string' ? value : ''; + return ( +
+
+ {field.label} +
+
+ {content ? ( + + ) : ( + + — + + )} +
+
+ ); + })} + {fileFields.map((field) => ( +
+
+ {field.label} +
+
+ {renderSubmissionValue(submission.data[field.key], field)} +
+
+ ))} + {matrixFields.map((field) => { + const rows = normalizeMatrixRows(submission.data[field.key]); + return ( +
+
+ {field.label} +
+
+ {rows.length === 0 ? ( + + — + + ) : ( + + + + {field.columns.map((column) => ( + + {column.label} + + ))} + + + + {rows.map((row, rowIndex) => ( + + {field.columns.map((column) => ( + +
{row[column.key] || '—'}
+
+ ))} +
+ ))} +
+
+ )} +
+
+ ); + })} +
+
+ + {/* Review action area — only for access requests */} + {formType === 'access-request' && submission.status === 'pending' && ( +
+
+
+ + Review this submission + +
+
+ + Reason (required for rejection) +