From 45035e9714db9fac26cbcff3356387e8e5b00145 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 17 Feb 2026 12:15:20 +0000 Subject: [PATCH 1/3] [dev] [carhartlewis] lewis/comp-company-tasks (#2128) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(company): add company package and integrate into various apps * feat(app-shell): update company section to tasks and adjust sidebar * feat(documents): add document management features and update routing * style(policy): simplify button formatting and improve layout consistency * feat(documents): add concise form descriptions and refactor usage * docs(openapi): update org chart and evidence forms endpoints * feat(tasks): pass organizationId to PolicyImageUploadModal * feat(tasks): pass organizationId to PolicyImageUploadModal * fix(automation): clarify automation agent's data retrieval capabilities (#2129) Co-authored-by: Tofik Hasanov * fix: policy version API content bug + published version protection (#2130) * fix(api): fix policy version content stored as empty arrays via API class-transformer with enableImplicitConversion was converting TipTap node objects to empty arrays when processing content: unknown[] DTO fields. Added @Transform decorator to preserve raw values. Also: - Block content updates on published policies via PATCH /policies/:id - Align updateVersionContent guard with UI (only block current version when published) - Sync content to current version when updating via PATCH /policies/:id - Add GET /policies/:id/versions/:versionId endpoint - Add Swagger docs for new endpoint Co-Authored-By: Claude Opus 4.6 * fix(app): allow PDF upload/delete on draft policy versions and fix false success toast The upload and delete PDF guards blocked all operations on the current version regardless of policy status. Now only blocks when policy is actually published (matching the pattern used everywhere else). Also fixed PdfViewer onSuccess handlers to check result.data.success before showing the success toast — previously showed "PDF uploaded successfully" even when the server action returned { success: false }. Co-Authored-By: Claude Opus 4.6 * fix(api,app): protect current version during needs_review status and fix stale pointer Change version mutation guards from `status === 'published'` to `status !== 'draft'` so that the current version is also protected when the policy is in needs_review state. Fix stale currentVersionId in updateById by reading it inside the transaction. Co-Authored-By: Claude Opus 4.6 * fix(api): move status guard inside transaction to prevent concurrent publish bypass The draft-only content guard was reading policy status before the transaction, allowing a concurrent publish to bypass the check. Now the existence check and status guard both run inside the transaction. Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Tofik Hasanov Co-authored-by: Claude Opus 4.6 * chore(release): 1.82.3 [skip ci] ## [1.82.3](https://github.com/trycompai/comp/compare/v1.82.2...v1.82.3) (2026-02-12) ### Bug Fixes * **app:** check DNS records using Node's built-in DNS instead of using external APIs ([#2126](https://github.com/trycompai/comp/issues/2126)) ([5fab9bd](https://github.com/trycompai/comp/commit/5fab9bd703d1d42925b609631acf9e0f058cdf4f)) * **app:** enable capitalized text for role in csv when adding users ([#2123](https://github.com/trycompai/comp/issues/2123)) ([5fdb448](https://github.com/trycompai/comp/commit/5fdb4482ddd414b31aebb6278cf5d4a82a5b8bc9)) * **automation:** clarify automation agent's data retrieval capabilities ([#2129](https://github.com/trycompai/comp/issues/2129)) ([eb2957f](https://github.com/trycompai/comp/commit/eb2957fe9f52ea3c97b0b091e304bc4804bb6c95)) * policy version API content bug + published version protection ([#2130](https://github.com/trycompai/comp/issues/2130)) ([7f79351](https://github.com/trycompai/comp/commit/7f793512731cecf873b765b535d28bc1c5da4fea)) * feat(portal): add form visibility toggles and improve form layout Co-authored-by: Cursor * fix(evidence-forms): add validation for submission status before review * feat(findings): add support for evidence submissions in findings * feat(device-agent): add new device agent package with dependencies * feat(frameworks): add documents score calculation and update compliance overview --------- Co-authored-by: Lewis Carhart Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Tofik Hasanov Co-authored-by: Claude Opus 4.6 Co-authored-by: semantic-release-bot Co-authored-by: Cursor --- apps/api/package.json | 260 ++++---- apps/api/src/app.module.ts | 2 + .../evidence-forms.controller.ts | 212 ++++++ .../evidence-forms.definitions.ts | 11 + .../evidence-forms/evidence-forms.module.ts | 13 + .../evidence-forms/evidence-forms.service.ts | 619 ++++++++++++++++++ .../src/findings/dto/create-finding.dto.ts | 14 +- .../api/src/findings/finding-audit.service.ts | 16 +- .../src/findings/finding-notifier.service.ts | 226 ++++++- apps/api/src/findings/findings.controller.ts | 33 +- apps/api/src/findings/findings.service.ts | 280 ++++---- apps/api/src/tasks/task-notifier.service.ts | 211 +++--- apps/api/src/tasks/tasks.controller.ts | 17 +- apps/api/src/tasks/tasks.service.ts | 17 +- apps/api/src/trust-portal/badge-svgs-new.ts | 2 +- .../src/trust-portal/dto/trust-vendor.dto.ts | 15 +- .../src/trust-portal/trust-access.service.ts | 9 +- .../trust-portal/trust-portal.controller.ts | 17 +- .../src/trust-portal/trust-portal.service.ts | 79 ++- apps/app/next.config.ts | 1 + apps/app/package.json | 380 +++++------ ...organization-access-request-form-action.ts | 46 ++ ...rganization-whistleblower-report-action.ts | 46 ++ apps/app/src/actions/schema.ts | 8 + .../[orgId]/components/AppShellWrapper.tsx | 8 +- .../(app)/[orgId]/components/AppSidebar.tsx | 7 + .../components/app-shell-search-groups.tsx | 9 + .../[orgId]/documents/[formType]/new/page.tsx | 49 ++ .../[orgId]/documents/[formType]/page.tsx | 36 + .../submissions/[submissionId]/page.tsx | 63 ++ .../components/CompanyFormPageClient.tsx | 300 +++++++++ .../components/CompanyOverviewCards.tsx | 101 +++ .../documents/components/CompanySidebar.tsx | 56 ++ .../CompanySubmissionDetailPageClient.tsx | 370 +++++++++++ .../components/CompanySubmissionWizard.tsx | 594 +++++++++++++++++ .../components/DocumentFindingsSection.tsx | 202 ++++++ .../documents/components/submission-utils.tsx | 120 ++++ .../[orgId]/documents/form-descriptions.ts | 12 + .../src/app/(app)/[orgId]/documents/forms.ts | 12 + .../src/app/(app)/[orgId]/documents/page.tsx | 13 + .../components/ComplianceOverview.tsx | 253 +++---- .../components/FindingsOverview.tsx | 21 +- .../components/FrameworksOverview.tsx | 11 +- .../frameworks/components/Overview.tsx | 70 +- .../data/getFrameworkWithComplianceScores.ts | 13 +- .../(app)/[orgId]/frameworks/lib/compute.ts | 18 +- .../[orgId]/frameworks/lib/getDocuments.ts | 17 + .../(app)/[orgId]/frameworks/lib/getTasks.ts | 28 +- .../lib/taskEvidenceDocumentsScore.ts | 92 +++ .../src/app/(app)/[orgId]/frameworks/page.tsx | 32 + .../people/all/components/MemberRow.tsx | 93 +-- .../all/components/PendingInvitationRow.tsx | 14 +- .../all/components/TeamMembersClient.tsx | 4 +- .../components/EmployeeCompletionChart.tsx | 11 +- .../(app)/[orgId]/settings/portal/page.tsx | 4 + .../settings/portal/portal-settings.tsx | 42 ++ .../findings/CreateFindingButton.tsx | 10 +- .../findings/CreateFindingSheet.tsx | 7 +- .../src/components/comments/CommentForm.tsx | 79 +-- .../comments/CommentRichTextField.tsx | 20 +- apps/app/src/hooks/use-findings-api.ts | 31 +- apps/app/tsconfig.json | 1 + apps/portal/next.config.ts | 12 +- apps/portal/package.json | 123 ++-- .../[orgId]/components/EmployeeTasksList.tsx | 148 +++-- .../components/OrganizationDashboard.tsx | 2 + .../components/policy/AdvancedEditor.tsx | 4 +- .../[orgId]/components/policy/PolicyCard.tsx | 120 ++-- .../components/policy/PolicyCarousel.tsx | 8 +- .../components/policy/PolicyContainer.tsx | 13 +- .../components/policy/PolicyEditor.tsx | 1 + .../[orgId]/components/policy/PolicyGrid.tsx | 63 +- .../[orgId]/components/policy/PolicyList.tsx | 6 +- .../components/policy/PortalPdfViewer.tsx | 38 +- .../tasks/DeviceAgentAccordionItem.tsx | 415 +++++------- .../components/tasks/FleetPolicyItem.tsx | 34 +- .../tasks/GeneralTrainingAccordionItem.tsx | 6 +- .../tasks/PoliciesAccordionItem.tsx | 22 +- .../tasks/PolicyImagePreviewModal.tsx | 18 +- .../components/video/CarouselControls.tsx | 12 +- .../components/video/VideoCarousel.tsx | 4 - .../[orgId]/components/video/YoutubeEmbed.tsx | 27 +- .../documents/[formType]/PortalFormClient.tsx | 224 +++++++ .../[orgId]/documents/[formType]/page.tsx | 211 ++++++ .../submissions/PortalSubmissionsClient.tsx | 138 ++++ .../documents/[formType]/submissions/page.tsx | 151 +++++ .../(app)/(home)/[orgId]/documents/forms.ts | 12 + .../src/app/(app)/(home)/[orgId]/page.tsx | 18 +- .../policy/[policyId]/PolicyAcceptButton.tsx | 8 +- .../(home)/[orgId]/policy/[policyId]/page.tsx | 99 ++- .../app/(app)/(home)/components/Overview.tsx | 10 +- apps/portal/src/app/(app)/(home)/layout.tsx | 2 +- apps/portal/src/app/(app)/(home)/page.tsx | 27 +- apps/portal/src/app/(app)/layout.tsx | 9 +- .../src/app/(app)/unauthorized/page.tsx | 21 +- .../src/app/api/download-agent/constants.ts | 5 + apps/portal/src/app/components/header.tsx | 19 +- apps/portal/src/app/layout.tsx | 6 +- apps/portal/src/env.mjs | 2 + apps/portal/src/styles/editor.css | 220 +++++++ apps/portal/src/utils/s3.ts | 12 +- apps/portal/tailwind.config.ts | 11 - apps/portal/tsconfig.json | 1 + bun.lock | 24 +- 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 + .../migration.sql | 3 + .../migration.sql | 9 + packages/db/prisma/schema/auth.prisma | 2 + .../prisma/schema/evidence-submission.prisma | 23 + packages/db/prisma/schema/finding.prisma | 7 +- packages/db/prisma/schema/organization.prisma | 3 + packages/docs/openapi.json | 416 +++++++++++- 124 files changed, 7331 insertions(+), 1539 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/actions/organization/update-organization-access-request-form-action.ts create mode 100644 apps/app/src/actions/organization/update-organization-whistleblower-report-action.ts create mode 100644 apps/app/src/app/(app)/[orgId]/documents/[formType]/new/page.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/documents/[formType]/page.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/documents/[formType]/submissions/[submissionId]/page.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/documents/components/CompanyFormPageClient.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/documents/components/CompanyOverviewCards.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/documents/components/CompanySidebar.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/documents/components/CompanySubmissionDetailPageClient.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/documents/components/CompanySubmissionWizard.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/documents/components/DocumentFindingsSection.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/documents/components/submission-utils.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/documents/form-descriptions.ts create mode 100644 apps/app/src/app/(app)/[orgId]/documents/forms.ts create mode 100644 apps/app/src/app/(app)/[orgId]/documents/page.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/frameworks/lib/getDocuments.ts create mode 100644 apps/app/src/app/(app)/[orgId]/frameworks/lib/taskEvidenceDocumentsScore.ts create mode 100644 apps/portal/src/app/(app)/(home)/[orgId]/documents/[formType]/PortalFormClient.tsx create mode 100644 apps/portal/src/app/(app)/(home)/[orgId]/documents/[formType]/page.tsx create mode 100644 apps/portal/src/app/(app)/(home)/[orgId]/documents/[formType]/submissions/PortalSubmissionsClient.tsx create mode 100644 apps/portal/src/app/(app)/(home)/[orgId]/documents/[formType]/submissions/page.tsx create mode 100644 apps/portal/src/app/(app)/(home)/[orgId]/documents/forms.ts create mode 100644 apps/portal/src/styles/editor.css 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/migrations/20260213132000_add_portal_form_visibility_settings/migration.sql create mode 100644 packages/db/prisma/migrations/20260213141447_add_document_findings_target/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 cc2bf7730..ada1af49e 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -1,135 +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/utils": "1.0.0", - "@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 b73268e09..5ab0118ae 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -1,192 +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/utils": "1.0.0", - "@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) +