From 469b61839de5009b924be6315a7626aec5eec249 Mon Sep 17 00:00:00 2001 From: Lewis Carhart Date: Thu, 12 Feb 2026 18:27:00 +0000 Subject: [PATCH 01/14] feat(company): add company package and integrate into various apps --- apps/api/package.json | 259 ++++--- apps/api/src/app.module.ts | 2 + .../evidence-forms.controller.ts | 206 ++++++ .../evidence-forms.definitions.ts | 11 + .../evidence-forms/evidence-forms.module.ts | 13 + .../evidence-forms/evidence-forms.service.ts | 509 ++++++++++++++ apps/app/next.config.ts | 1 + apps/app/package.json | 377 +++++----- .../[orgId]/company/[formType]/new/page.tsx | 57 ++ .../(app)/[orgId]/company/[formType]/page.tsx | 36 + .../submissions/[submissionId]/page.tsx | 47 ++ .../components/CompanyFormPageClient.tsx | 320 +++++++++ .../components/CompanyOverviewCards.tsx | 112 +++ .../company/components/CompanySidebar.tsx | 56 ++ .../CompanySubmissionDetailPageClient.tsx | 458 ++++++++++++ .../components/CompanySubmissionWizard.tsx | 650 ++++++++++++++++++ .../src/app/(app)/[orgId]/company/forms.ts | 12 + .../src/app/(app)/[orgId]/company/page.tsx | 13 + .../[orgId]/components/AppShellWrapper.tsx | 25 +- .../components/app-shell-search-groups.tsx | 8 + .../src/components/comments/CommentForm.tsx | 79 +-- .../comments/CommentRichTextField.tsx | 20 +- apps/app/tsconfig.json | 1 + apps/portal/next.config.ts | 7 +- apps/portal/package.json | 122 ++-- .../company/[formType]/PortalFormClient.tsx | 202 ++++++ .../[orgId]/company/[formType]/page.tsx | 174 +++++ .../submissions/PortalSubmissionsClient.tsx | 133 ++++ .../company/[formType]/submissions/page.tsx | 114 +++ .../app/(app)/(home)/[orgId]/company/forms.ts | 12 + .../[orgId]/components/EmployeeTasksList.tsx | 57 +- .../components/OrganizationDashboard.tsx | 2 +- .../tasks/DeviceAgentAccordionItem.tsx | 9 +- .../tasks/GeneralTrainingAccordionItem.tsx | 6 +- .../tasks/PoliciesAccordionItem.tsx | 4 +- apps/portal/src/app/layout.tsx | 2 +- apps/portal/src/env.mjs | 2 + apps/portal/src/utils/s3.ts | 12 +- apps/portal/tailwind.config.ts | 11 - apps/portal/tsconfig.json | 1 + bun.lock | 17 + packages/company/package.json | 32 + .../company/src/evidence-forms/definitions.ts | 357 ++++++++++ .../src/evidence-forms/field-builders.ts | 46 ++ .../company/src/evidence-forms/file-schema.ts | 9 + .../company/src/evidence-forms/form-types.ts | 15 + packages/company/src/evidence-forms/index.ts | 7 + .../evidence-forms/minutes-placeholders.ts | 115 ++++ .../src/evidence-forms/submission-schemas.ts | 89 +++ packages/company/src/evidence-forms/types.ts | 32 + packages/company/src/index.ts | 1 + packages/company/tsconfig.json | 5 + .../migration.sql | 25 + .../migration.sql | 11 + packages/db/prisma/schema/auth.prisma | 2 + .../prisma/schema/evidence-submission.prisma | 22 + packages/db/prisma/schema/organization.prisma | 1 + packages/docs/openapi.json | 398 +++++++++++ 58 files changed, 4859 insertions(+), 467 deletions(-) create mode 100644 apps/api/src/evidence-forms/evidence-forms.controller.ts create mode 100644 apps/api/src/evidence-forms/evidence-forms.definitions.ts create mode 100644 apps/api/src/evidence-forms/evidence-forms.module.ts create mode 100644 apps/api/src/evidence-forms/evidence-forms.service.ts create mode 100644 apps/app/src/app/(app)/[orgId]/company/[formType]/new/page.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/company/[formType]/page.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/company/[formType]/submissions/[submissionId]/page.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/company/components/CompanyFormPageClient.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/company/components/CompanyOverviewCards.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/company/components/CompanySidebar.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/company/components/CompanySubmissionDetailPageClient.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/company/components/CompanySubmissionWizard.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/company/forms.ts create mode 100644 apps/app/src/app/(app)/[orgId]/company/page.tsx create mode 100644 apps/portal/src/app/(app)/(home)/[orgId]/company/[formType]/PortalFormClient.tsx create mode 100644 apps/portal/src/app/(app)/(home)/[orgId]/company/[formType]/page.tsx create mode 100644 apps/portal/src/app/(app)/(home)/[orgId]/company/[formType]/submissions/PortalSubmissionsClient.tsx create mode 100644 apps/portal/src/app/(app)/(home)/[orgId]/company/[formType]/submissions/page.tsx create mode 100644 apps/portal/src/app/(app)/(home)/[orgId]/company/forms.ts delete mode 100644 apps/portal/tailwind.config.ts create mode 100644 packages/company/package.json create mode 100644 packages/company/src/evidence-forms/definitions.ts create mode 100644 packages/company/src/evidence-forms/field-builders.ts create mode 100644 packages/company/src/evidence-forms/file-schema.ts create mode 100644 packages/company/src/evidence-forms/form-types.ts create mode 100644 packages/company/src/evidence-forms/index.ts create mode 100644 packages/company/src/evidence-forms/minutes-placeholders.ts create mode 100644 packages/company/src/evidence-forms/submission-schemas.ts create mode 100644 packages/company/src/evidence-forms/types.ts create mode 100644 packages/company/src/index.ts create mode 100644 packages/company/tsconfig.json create mode 100644 packages/db/prisma/migrations/20260212142142_add_evidence_submissions/migration.sql create mode 100644 packages/db/prisma/migrations/20260212180647_add_evidence_review_fields/migration.sql create mode 100644 packages/db/prisma/schema/evidence-submission.prisma 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 31be83c2c..76141c6a3 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -34,6 +34,7 @@ import { BrowserbaseModule } from './browserbase/browserbase.module'; import { TaskManagementModule } from './task-management/task-management.module'; import { AssistantChatModule } from './assistant-chat/assistant-chat.module'; import { TrainingModule } from './training/training.module'; +import { EvidenceFormsModule } from './evidence-forms/evidence-forms.module'; @Module({ imports: [ @@ -80,6 +81,7 @@ import { TrainingModule } from './training/training.module'; TaskManagementModule, AssistantChatModule, TrainingModule, + 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..bde0a0759 --- /dev/null +++ b/apps/api/src/evidence-forms/evidence-forms.controller.ts @@ -0,0 +1,206 @@ +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', + }) + async 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, + userId: authContext.userId!, + 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, + userId: authContext.userId!, + }); + } + + @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, + @Param('formType') formType: string, + @Query('search') search?: string, + @Query('limit') limit?: string, + @Query('offset') offset?: string, + ) { + return this.evidenceFormsService.getFormWithSubmissions({ + organizationId, + formType, + search, + limit: limit ? parseInt(limit, 10) : undefined, + offset: offset ? parseInt(offset, 10) : undefined, + }); + } + + @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, + @Param('formType') formType: string, + @Param('submissionId') submissionId: string, + ) { + return this.evidenceFormsService.getSubmission({ + organizationId, + 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, + @Param('formType') formType: string, + @Res() res: Response, + ) { + const csv = await this.evidenceFormsService.exportCsv({ + organizationId, + 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..a50acb2ea --- /dev/null +++ b/apps/api/src/evidence-forms/evidence-forms.service.ts @@ -0,0 +1,509 @@ +import { AttachmentsService } from '@/attachments/attachments.service'; +import type { AuthContext } from '@/auth/types'; +import { db } from '@trycompai/db'; +import { + BadRequestException, + Injectable, + NotFoundException, +} 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(), +}); + +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); + } + + return String(value); +} + +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) {} + + 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; + formType: string; + search?: string; + limit?: number; + offset?: number; + }) { + const { organizationId, formType } = params; + const parsedType = evidenceFormTypeSchema.safeParse(formType); + if (!parsedType.success) { + throw new BadRequestException('Unsupported form type'); + } + + const query = listQuerySchema.parse({ + search: params.search, + limit: params.limit, + offset: params.offset, + }); + + 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; + formType: string; + submissionId: string; + }) { + 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()); + } + + const fileBuffer = Buffer.from(parsed.data.fileData, 'base64'); + 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 }) { + 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'); + } + + if (!params.authContext.userId) { + throw new BadRequestException( + 'Authenticated user session is required to review submissions', + ); + } + + 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'); + } + + return await db.evidenceSubmission.update({ + where: { id: params.submissionId }, + data: { + status: parsed.data.action, + reviewedById: params.authContext.userId, + 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; + userId: string; + formType?: string; + }) { + const where: Record = { + organizationId: params.organizationId, + submittedById: params.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; + userId: string; + }) { + const count = await db.evidenceSubmission.count({ + where: { + organizationId: params.organizationId, + submittedById: params.userId, + status: 'pending', + }, + }); + + return { count }; + } +} 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 7855ec0a3..79dab7f37 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -1,190 +1,191 @@ { - "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", - "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", + "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/app/(app)/[orgId]/company/[formType]/new/page.tsx b/apps/app/src/app/(app)/[orgId]/company/[formType]/new/page.tsx new file mode 100644 index 000000000..e370f4502 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/company/[formType]/new/page.tsx @@ -0,0 +1,57 @@ +import { CompanySubmissionWizard } from '@/app/(app)/[orgId]/company/components/CompanySubmissionWizard'; +import { Breadcrumb, PageHeader, PageLayout, Text } from '@trycompai/design-system'; +import Link from 'next/link'; +import { notFound } from 'next/navigation'; +import { evidenceFormDefinitions, evidenceFormTypeSchema } from '../../forms'; + +const conciseFormDescriptions: Record = { + 'board-meeting': 'Hold a board meeting and capture minutes.', + 'it-leadership-meeting': 'Run an IT leadership meeting and document outcomes.', + 'risk-committee-meeting': 'Conduct a risk committee meeting and record decisions.', + 'access-request': 'Track and retain user access requests.', + 'whistleblower-report': 'Submit a confidential whistleblower report.', + 'penetration-test': 'Upload a third-party penetration test report.', +}; + +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}/company/${parsedFormType}`, + props: { render: }, + }, + { label: 'New Submission', isCurrent: true }, + ]} + /> + +
+ + {conciseFormDescriptions[parsedFormType] ?? formDefinition.description} + + +
+
+ ); +} diff --git a/apps/app/src/app/(app)/[orgId]/company/[formType]/page.tsx b/apps/app/src/app/(app)/[orgId]/company/[formType]/page.tsx new file mode 100644 index 000000000..6d6872326 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/company/[formType]/page.tsx @@ -0,0 +1,36 @@ +import { CompanyFormPageClient } from '@/app/(app)/[orgId]/company/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]/company/[formType]/submissions/[submissionId]/page.tsx b/apps/app/src/app/(app)/[orgId]/company/[formType]/submissions/[submissionId]/page.tsx new file mode 100644 index 000000000..1e1619a62 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/company/[formType]/submissions/[submissionId]/page.tsx @@ -0,0 +1,47 @@ +import { CompanySubmissionDetailPageClient } from '@/app/(app)/[orgId]/company/components/CompanySubmissionDetailPageClient'; +import { Breadcrumb, PageHeader, PageLayout } from '@trycompai/design-system'; +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]; + + return ( + + }, + }, + { + label: formDefinition.title, + href: `/${orgId}/company/${parsedFormType}`, + props: { render: }, + }, + { label: 'Submission Details', isCurrent: true }, + ]} + /> + + + + ); +} diff --git a/apps/app/src/app/(app)/[orgId]/company/components/CompanyFormPageClient.tsx b/apps/app/src/app/(app)/[orgId]/company/components/CompanyFormPageClient.tsx new file mode 100644 index 000000000..30debb87d --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/company/components/CompanyFormPageClient.tsx @@ -0,0 +1,320 @@ +'use client'; + +import { evidenceFormDefinitions, type EvidenceFormType } from '@/app/(app)/[orgId]/company/forms'; +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 { Document, 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'; + +const conciseFormDescriptions: Record = { + 'board-meeting': 'Hold a board meeting and capture minutes.', + 'it-leadership-meeting': 'Run an IT leadership meeting and document outcomes.', + 'risk-committee-meeting': 'Conduct a risk committee meeting and record decisions.', + 'access-request': 'Track and retain user access requests. Employees can request access to systems through the employee portal.', + 'whistleblower-report': 'Submit a confidential whistleblower report.', + 'penetration-test': 'Upload a third-party penetration test report.', + 'rbac-matrix': 'Document role-based access by system, role, and approval.', + 'infrastructure-inventory': 'Track infrastructure assets, ownership, and review dates.', + 'employee-performance-evaluation': 'Capture structured employee review outcomes and sign-off.', +}; + +// ─── Types ─────────────────────────────────────────────────── + +type EvidenceSubmissionRow = { + id: string; + submittedAt: string; + status: string; + data: Record; + submittedBy?: { + name: string | null; + email: string; + } | null; +}; + +function StatusBadge({ status }: { status: string }) { + switch (status) { + case 'approved': + return ( + + Approved + + ); + case 'rejected': + return ( + + Rejected + + ); + default: + return ( + + Pending + + ); + } +} + +type EvidenceFormResponse = { + form: (typeof evidenceFormDefinitions)[EvidenceFormType]; + submissions: EvidenceSubmissionRow[]; + total: number; +}; + +// ─── Helpers ───────────────────────────────────────────────── + +function truncate(str: string, max: number) { + if (str.length <= max) return str; + return `${str.slice(0, max)}…`; +} + +const submissionDateFormatter = new Intl.DateTimeFormat('en-US', { + month: '2-digit', + day: '2-digit', + year: 'numeric', +}); + +function formatSubmissionDate(submissionDate: unknown, submittedAt?: string | null): string { + const candidates: unknown[] = [submissionDate, submittedAt]; + + for (const candidate of candidates) { + if (typeof candidate !== 'string') continue; + const value = candidate.trim(); + if (!value) continue; + + // Preserve the date exactly for YYYY-MM-DD and ISO date-time inputs. + const ymdMatch = value.match(/^(\d{4})-(\d{2})-(\d{2})(?:$|T)/); + if (ymdMatch) { + return `${ymdMatch[2]}/${ymdMatch[3]}/${ymdMatch[1]}`; + } + + const parsed = new Date(value); + if (!Number.isNaN(parsed.getTime())) { + return submissionDateFormatter.format(parsed); + } + } + + return '—'; +} + +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 ? ( + Loading submissions... + ) : !data || data.submissions.length === 0 ? ( + + + + + + No submissions yet + + Click New Submission to create your first {formDefinition.title.toLowerCase()}{' '} + record. + + + + ) : ( + + + + 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}/company/${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]/company/components/CompanyOverviewCards.tsx b/apps/app/src/app/(app)/[orgId]/company/components/CompanyOverviewCards.tsx new file mode 100644 index 000000000..09431182f --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/company/components/CompanyOverviewCards.tsx @@ -0,0 +1,112 @@ +'use client'; + +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'; + +const conciseFormDescriptions: Record = { + 'board-meeting': 'Hold a board meeting and capture minutes.', + 'it-leadership-meeting': 'Run an IT leadership meeting and document outcomes.', + 'risk-committee-meeting': 'Conduct a risk committee meeting and record decisions.', + 'access-request': 'Track and retain user access requests.', + 'whistleblower-report': 'Submit a confidential whistleblower report.', + 'penetration-test': 'Upload a third-party penetration test report.', + 'rbac-matrix': 'Document role-based access by system, role, and approval.', + 'infrastructure-inventory': 'Track infrastructure assets, ownership, and review dates.', + 'employee-performance-evaluation': 'Capture structured employee review outcomes and sign-off.', +}; + +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]/company/components/CompanySidebar.tsx b/apps/app/src/app/(app)/[orgId]/company/components/CompanySidebar.tsx new file mode 100644 index 000000000..9400b7ee3 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/company/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}/company`) { + 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}/company/${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]/company/components/CompanySubmissionDetailPageClient.tsx b/apps/app/src/app/(app)/[orgId]/company/components/CompanySubmissionDetailPageClient.tsx new file mode 100644 index 000000000..28890a33b --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/company/components/CompanySubmissionDetailPageClient.tsx @@ -0,0 +1,458 @@ +'use client'; + +import { + evidenceFormDefinitions, + type EvidenceFormFieldDefinition, + type EvidenceFormFile, + type EvidenceFormType, +} from '@/app/(app)/[orgId]/company/forms'; +import { api } from '@/lib/api-client'; +import { + Badge, + Button, + Empty, + EmptyDescription, + EmptyHeader, + EmptyTitle, + Field, + FieldLabel, + Section, + Text, + Textarea, +} from '@trycompai/design-system'; +import { useState } from 'react'; +import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; +import { toast } from 'sonner'; +import useSWR from 'swr'; + +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 StatusBadge({ status }: { status: string }) { + switch (status) { + case 'approved': + return ( + + Approved + + ); + case 'rejected': + return ( + + Rejected + + ); + default: + return ( + + Pending + + ); + } +} + +const submissionDateFormatter = new Intl.DateTimeFormat('en-US', { + month: '2-digit', + day: '2-digit', + year: 'numeric', +}); + +function formatSubmissionDate(submissionDate: unknown, submittedAt?: string | null): string { + const candidates: unknown[] = [submissionDate, submittedAt]; + + for (const candidate of candidates) { + if (typeof candidate !== 'string') continue; + const value = candidate.trim(); + if (!value) continue; + + const ymdMatch = value.match(/^(\d{4})-(\d{2})-(\d{2})(?:$|T)/); + if (ymdMatch) { + return `${ymdMatch[2]}/${ymdMatch[3]}/${ymdMatch[1]}`; + } + + const parsed = new Date(value); + if (!Number.isNaN(parsed.getTime())) { + return submissionDateFormatter.format(parsed); + } + } + + return '—'; +} + +function renderSubmissionValue(value: unknown, field?: EvidenceFormFieldDefinition) { + if (!value) return '—'; + + if ( + typeof value === 'object' && + 'downloadUrl' in value && + typeof value.downloadUrl === 'string' + ) { + const fileValue = value as EvidenceFormFile; + return ( + + {fileValue.fileName} + + ); + } + + if (field?.type === 'select' && field.options && typeof value === 'string') { + const matched = field.options.find((opt) => opt.value === value); + if (matched) return matched.label; + } + + if (field?.type === 'date') { + return formatSubmissionDate(value); + } + + return String(value); +} + +function MarkdownPreview({ content }: { content: string }) { + return ( +
+ {content} +
+ ); +} + +type MatrixRowValue = Record; +type MatrixColumnDefinition = { + key: string; + label: string; + required?: boolean; + placeholder?: string; + description?: string; +}; + +function isMatrixField(field: EvidenceFormFieldDefinition): field is EvidenceFormFieldDefinition & { + type: 'matrix'; + columns: ReadonlyArray; +} { + return field.type === 'matrix' && Array.isArray(field.columns) && field.columns.length > 0; +} + +function normalizeMatrixRows(value: unknown): MatrixRowValue[] { + if (!Array.isArray(value)) return []; + return value.map((row) => { + if (!row || typeof row !== 'object') return {}; + return Object.fromEntries( + Object.entries(row).map(([key, rawValue]) => [key, typeof rawValue === 'string' ? rawValue : '']), + ); + }); +} + +export function CompanySubmissionDetailPageClient({ + organizationId, + formType, + submissionId, +}: { + organizationId: string; + formType: EvidenceFormType; + submissionId: string; +}) { + 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 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...; + } + + 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) => ( + + ))} + + + + {rows.map((row, rowIndex) => ( + + {field.columns.map((column) => ( + + ))} + + ))} + +
+ {column.label} +
+ {row[column.key] || '—'} +
+
+ )} +
+
+ ); + })} +
+
+ + {/* Review action area — only for access requests */} + {formType === 'access-request' && submission.status === 'pending' && ( +
+
+
+ + Review this submission + +
+
+ + + Reason (required for rejection) + +