diff --git a/.env.example b/.env.example index 57a20c7a6..c4da7a6f2 100644 --- a/.env.example +++ b/.env.example @@ -17,6 +17,7 @@ APP_AWS_ACCESS_KEY_ID="" # AWS Access Key ID APP_AWS_SECRET_ACCESS_KEY="" # AWS Secret Access Key APP_AWS_REGION="" # AWS Region APP_AWS_BUCKET_NAME="" # AWS Bucket Name +APP_AWS_QUESTIONNAIRE_UPLOAD_BUCKET="" # AWS, Required for Security Questionnaire feature TRIGGER_SECRET_KEY="" # For background jobs. Self-host or use cloud-version @ https://trigger.dev # TRIGGER_API_URL="" # Only set if you are self-hosting @@ -26,4 +27,6 @@ TRIGGER_SECRET_KEY="" # Secret key from Trigger.dev OPENAI_API_KEY="" # AI Chat + Auto Generated Policies, Risks + Vendors FIRECRAWL_API_KEY="" # For research, self-host or use cloud-version @ https://firecrawl.dev +TRUST_APP_URL="http://localhost:3008" # Trust portal public site for NDA signing and access requests + AUTH_TRUSTED_ORIGINS=http://localhost:3000,https://*.trycomp.ai,http://localhost:3002 diff --git a/.github/workflows/auto-pr-to-main.yml b/.github/workflows/auto-pr-to-main.yml index c93e9edb4..d4de05c30 100644 --- a/.github/workflows/auto-pr-to-main.yml +++ b/.github/workflows/auto-pr-to-main.yml @@ -13,10 +13,12 @@ on: - claudio/* - mariano/* - lewis/* + - daniel/* - COMP-* - cursor/* - codex/* - chas/* + - tofik/* jobs: create-pull-request: runs-on: warp-ubuntu-latest-arm64-4x @@ -35,9 +37,9 @@ jobs: uses: repo-sync/pull-request@v2 with: github_token: ${{ secrets.GITHUB_TOKEN }} - destination_branch: 'main' - pr_title: '[dev] [${{ github.actor }}] ${{ github.ref_name }}' - pr_label: 'automated-pr' + destination_branch: "main" + pr_title: "[dev] [${{ github.actor }}] ${{ github.ref_name }}" + pr_label: "automated-pr" pr_body: | This is an automated pull request to merge ${{ github.ref_name }} into dev. It was created by the [Auto Pull Request] action. diff --git a/.gitignore b/.gitignore index 42824128e..14601ac9f 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ node_modules .pnp .pnp.js +.idea/ # testing coverage @@ -83,4 +84,5 @@ packages/*/dist **/src/db/generated/ # Release script -scripts/sync-release-branch.sh \ No newline at end of file +scripts/sync-release-branch.sh +/.vscode diff --git a/.vscode/settings.json b/.vscode/settings.json index 6e34cc286..4450f981d 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -23,5 +23,6 @@ }, "[typescriptreact]": { "editor.defaultFormatter": "esbenp.prettier-vscode" - } + }, + "typescript.tsserver.experimental.enableProjectDiagnostics": true } diff --git a/SELF_HOSTING.md b/SELF_HOSTING.md index 62990d706..c225133e9 100644 --- a/SELF_HOSTING.md +++ b/SELF_HOSTING.md @@ -43,6 +43,8 @@ Portal (`apps/portal`): App (`apps/app`): +- **APP_AWS_REGION**, **APP_AWS_ACCESS_KEY_ID**, **APP_AWS_SECRET_ACCESS_KEY**, **APP_AWS_BUCKET_NAME**: AWS S3 credentials for file storage (attachments, general uploads). +- **APP_AWS_QUESTIONNAIRE_UPLOAD_BUCKET**: AWS S3 bucket name specifically for questionnaire file uploads. Required for the Security Questionnaire feature. If not set, users will see an error when trying to parse questionnaires. - **OPENAI_API_KEY**: Enables AI features that call OpenAI models. - **UPSTASH_REDIS_REST_URL**, **UPSTASH_REDIS_REST_TOKEN**: Optional Redis (Upstash) used for rate limiting/queues/caching. - **NEXT_PUBLIC_POSTHOG_KEY**, **NEXT_PUBLIC_POSTHOG_HOST**: Client analytics via PostHog; leave unset to disable. @@ -143,6 +145,12 @@ BETTER_AUTH_URL_PORTAL=http://localhost:3002 NEXT_PUBLIC_BETTER_AUTH_URL_PORTAL=http://localhost:3002 # Optional +# AWS S3 (for file storage) +# APP_AWS_REGION= +# APP_AWS_ACCESS_KEY_ID= +# APP_AWS_SECRET_ACCESS_KEY= +# APP_AWS_BUCKET_NAME= +# APP_AWS_QUESTIONNAIRE_UPLOAD_BUCKET= # OPENAI_API_KEY= # UPSTASH_REDIS_REST_URL= # UPSTASH_REDIS_REST_TOKEN= diff --git a/apps/.cursor/rules/trigger.basic.mdc b/apps/.cursor/rules/trigger.basic.mdc new file mode 100644 index 000000000..3d96e6657 --- /dev/null +++ b/apps/.cursor/rules/trigger.basic.mdc @@ -0,0 +1,190 @@ +--- +description: Only the most important rules for writing basic Trigger.dev tasks +globs: **/trigger/**/*.ts +alwaysApply: false +--- +# Trigger.dev Basic Tasks (v4) + +**MUST use `@trigger.dev/sdk` (v4), NEVER `client.defineJob`** + +## Basic Task + +```ts +import { task } from "@trigger.dev/sdk"; + +export const processData = task({ + id: "process-data", + retry: { + maxAttempts: 10, + factor: 1.8, + minTimeoutInMs: 500, + maxTimeoutInMs: 30_000, + randomize: false, + }, + run: async (payload: { userId: string; data: any[] }) => { + // Task logic - runs for long time, no timeouts + console.log(`Processing ${payload.data.length} items for user ${payload.userId}`); + return { processed: payload.data.length }; + }, +}); +``` + +## Schema Task (with validation) + +```ts +import { schemaTask } from "@trigger.dev/sdk"; +import { z } from "zod"; + +export const validatedTask = schemaTask({ + id: "validated-task", + schema: z.object({ + name: z.string(), + age: z.number(), + email: z.string().email(), + }), + run: async (payload) => { + // Payload is automatically validated and typed + return { message: `Hello ${payload.name}, age ${payload.age}` }; + }, +}); +``` + +## Scheduled Task + +```ts +import { schedules } from "@trigger.dev/sdk"; + +const dailyReport = schedules.task({ + id: "daily-report", + cron: "0 9 * * *", // Daily at 9:00 AM UTC + // or with timezone: cron: { pattern: "0 9 * * *", timezone: "America/New_York" }, + run: async (payload) => { + console.log("Scheduled run at:", payload.timestamp); + console.log("Last run was:", payload.lastTimestamp); + console.log("Next 5 runs:", payload.upcoming); + + // Generate daily report logic + return { reportGenerated: true, date: payload.timestamp }; + }, +}); +``` + +## Triggering Tasks + +### From Backend Code + +```ts +import { tasks } from "@trigger.dev/sdk"; +import type { processData } from "./trigger/tasks"; + +// Single trigger +const handle = await tasks.trigger("process-data", { + userId: "123", + data: [{ id: 1 }, { id: 2 }], +}); + +// Batch trigger +const batchHandle = await tasks.batchTrigger("process-data", [ + { payload: { userId: "123", data: [{ id: 1 }] } }, + { payload: { userId: "456", data: [{ id: 2 }] } }, +]); +``` + +### From Inside Tasks (with Result handling) + +```ts +export const parentTask = task({ + id: "parent-task", + run: async (payload) => { + // Trigger and continue + const handle = await childTask.trigger({ data: "value" }); + + // Trigger and wait - returns Result object, NOT task output + const result = await childTask.triggerAndWait({ data: "value" }); + if (result.ok) { + console.log("Task output:", result.output); // Actual task return value + } else { + console.error("Task failed:", result.error); + } + + // Quick unwrap (throws on error) + const output = await childTask.triggerAndWait({ data: "value" }).unwrap(); + + // Batch trigger and wait + const results = await childTask.batchTriggerAndWait([ + { payload: { data: "item1" } }, + { payload: { data: "item2" } }, + ]); + + for (const run of results) { + if (run.ok) { + console.log("Success:", run.output); + } else { + console.log("Failed:", run.error); + } + } + }, +}); + +export const childTask = task({ + id: "child-task", + run: async (payload: { data: string }) => { + return { processed: payload.data }; + }, +}); +``` + +> Never wrap triggerAndWait or batchTriggerAndWait calls in a Promise.all or Promise.allSettled as this is not supported in Trigger.dev tasks. + +## Waits + +```ts +import { task, wait } from "@trigger.dev/sdk"; + +export const taskWithWaits = task({ + id: "task-with-waits", + run: async (payload) => { + console.log("Starting task"); + + // Wait for specific duration + await wait.for({ seconds: 30 }); + await wait.for({ minutes: 5 }); + await wait.for({ hours: 1 }); + await wait.for({ days: 1 }); + + // Wait until specific date + await wait.until({ date: new Date("2024-12-25") }); + + // Wait for token (from external system) + await wait.forToken({ + token: "user-approval-token", + timeoutInSeconds: 3600, // 1 hour timeout + }); + + console.log("All waits completed"); + return { status: "completed" }; + }, +}); +``` + +> Never wrap wait calls in a Promise.all or Promise.allSettled as this is not supported in Trigger.dev tasks. + +## Key Points + +- **Result vs Output**: `triggerAndWait()` returns a `Result` object with `ok`, `output`, `error` properties - NOT the direct task output +- **Type safety**: Use `import type` for task references when triggering from backend +- **Waits > 5 seconds**: Automatically checkpointed, don't count toward compute usage + +## NEVER Use (v2 deprecated) + +```ts +// BREAKS APPLICATION +client.defineJob({ + id: "job-id", + run: async (payload, io) => { + /* ... */ + }, +}); +``` + +Use v4 SDK (`@trigger.dev/sdk`), check `result.ok` before accessing `result.output` diff --git a/apps/api/buildspec.yml b/apps/api/buildspec.yml index d6809fe8a..1efc81436 100644 --- a/apps/api/buildspec.yml +++ b/apps/api/buildspec.yml @@ -26,6 +26,7 @@ phases: - '[ -n "$DATABASE_URL" ] || { echo "❌ DATABASE_URL is not set"; exit 1; }' - '[ -n "$BASE_URL" ] || { echo "❌ BASE_URL is not set"; exit 1; }' - '[ -n "$BETTER_AUTH_URL" ] || { echo "❌ BETTER_AUTH_URL is not set"; exit 1; }' + - '[ -n "$TRUST_APP_URL" ] || { echo "❌ TRUST_APP_URL is not set"; exit 1; }' # Install only API workspace dependencies - echo "Installing API dependencies only..." diff --git a/apps/api/package.json b/apps/api/package.json index 48a9de0b2..4da62dd09 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -20,8 +20,12 @@ "class-validator": "^0.14.2", "dotenv": "^17.2.3", "jose": "^6.0.12", + "jspdf": "^3.0.3", + "nanoid": "^5.1.6", + "pdf-lib": "^1.17.1", "prisma": "^6.13.0", "reflect-metadata": "^0.2.2", + "resend": "^6.4.2", "rxjs": "^7.8.1", "swagger-ui-express": "^5.0.1", "zod": "^4.0.14" diff --git a/apps/api/src/attachments/attachments.service.ts b/apps/api/src/attachments/attachments.service.ts index 599497590..a63c72038 100644 --- a/apps/api/src/attachments/attachments.service.ts +++ b/apps/api/src/attachments/attachments.service.ts @@ -267,9 +267,60 @@ export class AttachmentsService { }); } - /** - * Sanitize filename for S3 storage - */ + async uploadToS3( + fileBuffer: Buffer, + fileName: string, + contentType: string, + organizationId: string, + entityType: string, + entityId: string, + ): Promise { + const fileId = randomBytes(16).toString('hex'); + const sanitizedFileName = this.sanitizeFileName(fileName); + const timestamp = Date.now(); + const s3Key = `${organizationId}/attachments/${entityType}/${entityId}/${timestamp}-${fileId}-${sanitizedFileName}`; + + const putCommand = new PutObjectCommand({ + Bucket: this.bucketName, + Key: s3Key, + Body: fileBuffer, + ContentType: contentType, + Metadata: { + originalFileName: this.sanitizeHeaderValue(fileName), + organizationId, + entityId, + entityType, + }, + }); + + await this.s3Client.send(putCommand); + return s3Key; + } + + async getPresignedDownloadUrl(s3Key: string): Promise { + return this.generateSignedUrl(s3Key); + } + + async getObjectBuffer(s3Key: string): Promise { + const getCommand = new GetObjectCommand({ + Bucket: this.bucketName, + Key: s3Key, + }); + + const response = await this.s3Client.send(getCommand); + const chunks: Uint8Array[] = []; + + if (!response.Body) { + throw new InternalServerErrorException('No file data received from S3'); + } + + for await (const chunk of response.Body as any) { + chunks.push(chunk); + } + + return Buffer.concat(chunks); + } + private sanitizeFileName(fileName: string): string { return fileName.replace(/[^a-zA-Z0-9.-]/g, '_'); } @@ -281,6 +332,7 @@ export class AttachmentsService { * - Trim whitespace */ private sanitizeHeaderValue(value: string): string { + // eslint-disable-next-line no-control-regex const withoutControls = value.replace(/[\x00-\x1F\x7F]/g, ''); const asciiOnly = withoutControls.replace(/[^\x20-\x7E]/g, '_'); return asciiOnly.trim(); diff --git a/apps/api/src/comments/dto/update-comment.dto.ts b/apps/api/src/comments/dto/update-comment.dto.ts index 7687c6f69..64fb93226 100644 --- a/apps/api/src/comments/dto/update-comment.dto.ts +++ b/apps/api/src/comments/dto/update-comment.dto.ts @@ -11,4 +11,4 @@ export class UpdateCommentDto { @IsNotEmpty() @MaxLength(2000) content: string; -} \ No newline at end of file +} diff --git a/apps/api/src/context/context.controller.ts b/apps/api/src/context/context.controller.ts index 96786c80e..c423fb780 100644 --- a/apps/api/src/context/context.controller.ts +++ b/apps/api/src/context/context.controller.ts @@ -1,12 +1,12 @@ -import { - Controller, - Get, +import { + Controller, + Get, Post, Patch, Delete, Body, Param, - UseGuards + UseGuards, } from '@nestjs/common'; import { ApiBody, @@ -17,10 +17,7 @@ import { ApiSecurity, ApiTags, } from '@nestjs/swagger'; -import { - AuthContext, - OrganizationId, -} from '../auth/auth-context.decorator'; +import { AuthContext, OrganizationId } from '../auth/auth-context.decorator'; import { HybridAuthGuard } from '../auth/hybrid-auth.guard'; import type { AuthContext as AuthContextType } from '../auth/types'; import { CreateContextDto } from './dto/create-context.dto'; @@ -58,18 +55,20 @@ export class ContextController { @OrganizationId() organizationId: string, @AuthContext() authContext: AuthContextType, ) { - const contextEntries = await this.contextService.findAllByOrganization(organizationId); + const contextEntries = + await this.contextService.findAllByOrganization(organizationId); return { data: contextEntries, count: contextEntries.length, authType: authContext.authType, - ...(authContext.userId && authContext.userEmail && { - authenticatedUser: { - id: authContext.userId, - email: authContext.userEmail, - }, - }), + ...(authContext.userId && + authContext.userEmail && { + authenticatedUser: { + id: authContext.userId, + email: authContext.userEmail, + }, + }), }; } @@ -85,17 +84,21 @@ export class ContextController { @OrganizationId() organizationId: string, @AuthContext() authContext: AuthContextType, ) { - const contextEntry = await this.contextService.findById(contextId, organizationId); + const contextEntry = await this.contextService.findById( + contextId, + organizationId, + ); return { ...contextEntry, authType: authContext.authType, - ...(authContext.userId && authContext.userEmail && { - authenticatedUser: { - id: authContext.userId, - email: authContext.userEmail, - }, - }), + ...(authContext.userId && + authContext.userEmail && { + authenticatedUser: { + id: authContext.userId, + email: authContext.userEmail, + }, + }), }; } @@ -112,17 +115,21 @@ export class ContextController { @OrganizationId() organizationId: string, @AuthContext() authContext: AuthContextType, ) { - const contextEntry = await this.contextService.create(organizationId, createContextDto); + const contextEntry = await this.contextService.create( + organizationId, + createContextDto, + ); return { ...contextEntry, authType: authContext.authType, - ...(authContext.userId && authContext.userEmail && { - authenticatedUser: { - id: authContext.userId, - email: authContext.userEmail, - }, - }), + ...(authContext.userId && + authContext.userEmail && { + authenticatedUser: { + id: authContext.userId, + email: authContext.userEmail, + }, + }), }; } @@ -150,12 +157,13 @@ export class ContextController { return { ...updatedContextEntry, authType: authContext.authType, - ...(authContext.userId && authContext.userEmail && { - authenticatedUser: { - id: authContext.userId, - email: authContext.userEmail, - }, - }), + ...(authContext.userId && + authContext.userEmail && { + authenticatedUser: { + id: authContext.userId, + email: authContext.userEmail, + }, + }), }; } @@ -171,17 +179,21 @@ export class ContextController { @OrganizationId() organizationId: string, @AuthContext() authContext: AuthContextType, ) { - const result = await this.contextService.deleteById(contextId, organizationId); + const result = await this.contextService.deleteById( + contextId, + organizationId, + ); return { ...result, authType: authContext.authType, - ...(authContext.userId && authContext.userEmail && { - authenticatedUser: { - id: authContext.userId, - email: authContext.userEmail, - }, - }), + ...(authContext.userId && + authContext.userEmail && { + authenticatedUser: { + id: authContext.userId, + email: authContext.userEmail, + }, + }), }; } } diff --git a/apps/api/src/context/context.service.ts b/apps/api/src/context/context.service.ts index 6317b554d..74f519b39 100644 --- a/apps/api/src/context/context.service.ts +++ b/apps/api/src/context/context.service.ts @@ -14,10 +14,15 @@ export class ContextService { orderBy: { createdAt: 'desc' }, }); - this.logger.log(`Retrieved ${contextEntries.length} context entries for organization ${organizationId}`); + this.logger.log( + `Retrieved ${contextEntries.length} context entries for organization ${organizationId}`, + ); return contextEntries; } catch (error) { - this.logger.error(`Failed to retrieve context entries for organization ${organizationId}:`, error); + this.logger.error( + `Failed to retrieve context entries for organization ${organizationId}:`, + error, + ); throw error; } } @@ -25,17 +30,21 @@ export class ContextService { async findById(id: string, organizationId: string) { try { const contextEntry = await db.context.findFirst({ - where: { + where: { id, - organizationId + organizationId, }, }); if (!contextEntry) { - throw new NotFoundException(`Context entry with ID ${id} not found in organization ${organizationId}`); + throw new NotFoundException( + `Context entry with ID ${id} not found in organization ${organizationId}`, + ); } - this.logger.log(`Retrieved context entry: ${contextEntry.question.substring(0, 50)}... (${id})`); + this.logger.log( + `Retrieved context entry: ${contextEntry.question.substring(0, 50)}... (${id})`, + ); return contextEntry; } catch (error) { if (error instanceof NotFoundException) { @@ -55,15 +64,24 @@ export class ContextService { }, }); - this.logger.log(`Created new context entry: ${contextEntry.question.substring(0, 50)}... (${contextEntry.id}) for organization ${organizationId}`); + this.logger.log( + `Created new context entry: ${contextEntry.question.substring(0, 50)}... (${contextEntry.id}) for organization ${organizationId}`, + ); return contextEntry; } catch (error) { - this.logger.error(`Failed to create context entry for organization ${organizationId}:`, error); + this.logger.error( + `Failed to create context entry for organization ${organizationId}:`, + error, + ); throw error; } } - async updateById(id: string, organizationId: string, updateContextDto: UpdateContextDto) { + async updateById( + id: string, + organizationId: string, + updateContextDto: UpdateContextDto, + ) { try { // First check if the context entry exists in the organization await this.findById(id, organizationId); @@ -73,7 +91,9 @@ export class ContextService { data: updateContextDto, }); - this.logger.log(`Updated context entry: ${updatedContextEntry.question.substring(0, 50)}... (${id})`); + this.logger.log( + `Updated context entry: ${updatedContextEntry.question.substring(0, 50)}... (${id})`, + ); return updatedContextEntry; } catch (error) { if (error instanceof NotFoundException) { @@ -93,13 +113,15 @@ export class ContextService { where: { id }, }); - this.logger.log(`Deleted context entry: ${existingContextEntry.question.substring(0, 50)}... (${id})`); - return { + this.logger.log( + `Deleted context entry: ${existingContextEntry.question.substring(0, 50)}... (${id})`, + ); + return { message: 'Context entry deleted successfully', deletedContext: { id: existingContextEntry.id, question: existingContextEntry.question, - } + }, }; } catch (error) { if (error instanceof NotFoundException) { diff --git a/apps/api/src/context/dto/context-response.dto.ts b/apps/api/src/context/dto/context-response.dto.ts index 511f8cd44..a7d2c7a3f 100644 --- a/apps/api/src/context/dto/context-response.dto.ts +++ b/apps/api/src/context/dto/context-response.dto.ts @@ -21,7 +21,8 @@ export class ContextResponseDto { @ApiProperty({ description: 'The answer or detailed explanation for the question', - example: 'We use a hybrid authentication system supporting both API keys and session-based authentication.', + example: + 'We use a hybrid authentication system supporting both API keys and session-based authentication.', }) answer: string; diff --git a/apps/api/src/context/dto/create-context.dto.ts b/apps/api/src/context/dto/create-context.dto.ts index 882f4bf69..62d26c059 100644 --- a/apps/api/src/context/dto/create-context.dto.ts +++ b/apps/api/src/context/dto/create-context.dto.ts @@ -12,7 +12,8 @@ export class CreateContextDto { @ApiProperty({ description: 'The answer or detailed explanation for the question', - example: 'We use a hybrid authentication system supporting both API keys and session-based authentication. API keys are used for programmatic access while sessions are used for web interface interactions.', + example: + 'We use a hybrid authentication system supporting both API keys and session-based authentication. API keys are used for programmatic access while sessions are used for web interface interactions.', }) @IsString() @IsNotEmpty() diff --git a/apps/api/src/context/schemas/context-bodies.ts b/apps/api/src/context/schemas/context-bodies.ts index 6232249e1..f9776c32d 100644 --- a/apps/api/src/context/schemas/context-bodies.ts +++ b/apps/api/src/context/schemas/context-bodies.ts @@ -10,14 +10,16 @@ export const CONTEXT_BODIES: Record = { 'Authentication Context': { value: { question: 'How do we handle user authentication in our application?', - answer: 'We use a hybrid authentication system supporting both API keys and session-based authentication. API keys are used for programmatic access while sessions are used for web interface interactions.', + answer: + 'We use a hybrid authentication system supporting both API keys and session-based authentication. API keys are used for programmatic access while sessions are used for web interface interactions.', tags: ['authentication', 'security', 'api', 'sessions'], }, }, 'Database Context': { value: { question: 'What database do we use and why?', - answer: 'We use PostgreSQL as our primary database with Prisma as the ORM. PostgreSQL provides excellent performance, ACID compliance, and supports advanced features like JSON columns and full-text search.', + answer: + 'We use PostgreSQL as our primary database with Prisma as the ORM. PostgreSQL provides excellent performance, ACID compliance, and supports advanced features like JSON columns and full-text search.', tags: ['database', 'postgresql', 'prisma', 'architecture'], }, }, @@ -34,7 +36,8 @@ export const CONTEXT_BODIES: Record = { }, 'Update Answer': { value: { - answer: 'Updated: We use a hybrid authentication system supporting both API keys and session-based authentication. Recent updates include support for OAuth2 providers.', + answer: + 'Updated: We use a hybrid authentication system supporting both API keys and session-based authentication. Recent updates include support for OAuth2 providers.', }, }, }, diff --git a/apps/api/src/device-agent/device-agent.controller.ts b/apps/api/src/device-agent/device-agent.controller.ts index 719c30a5d..3323b03cf 100644 --- a/apps/api/src/device-agent/device-agent.controller.ts +++ b/apps/api/src/device-agent/device-agent.controller.ts @@ -1,9 +1,9 @@ -import { - Controller, - Get, +import { + Controller, + Get, UseGuards, StreamableFile, - Response + Response, } from '@nestjs/common'; import { ApiHeader, @@ -12,10 +12,7 @@ import { ApiSecurity, ApiTags, } from '@nestjs/swagger'; -import { - AuthContext, - OrganizationId, -} from '../auth/auth-context.decorator'; +import { AuthContext, OrganizationId } from '../auth/auth-context.decorator'; import { HybridAuthGuard } from '../auth/hybrid-auth.guard'; import type { AuthContext as AuthContextType } from '../auth/types'; import { DeviceAgentService } from './device-agent.service'; @@ -48,15 +45,16 @@ export class DeviceAgentController { @AuthContext() authContext: AuthContextType, @Response({ passthrough: true }) res: ExpressResponse, ) { - const { stream, filename, contentType } = await this.deviceAgentService.downloadMacAgent(); + const { stream, filename, contentType } = + await this.deviceAgentService.downloadMacAgent(); // Set headers for file download res.set({ 'Content-Type': contentType, 'Content-Disposition': `attachment; filename="${filename}"; filename*=UTF-8''${encodeURIComponent(filename)}`, 'Cache-Control': 'no-cache, no-store, must-revalidate', - 'Pragma': 'no-cache', - 'Expires': '0', + Pragma: 'no-cache', + Expires: '0', }); return new StreamableFile(stream); @@ -75,19 +73,20 @@ export class DeviceAgentController { ) { // Use the authenticated user's ID as the employee ID const employeeId = authContext.userId || 'unknown-user'; - - const { stream, filename, contentType } = await this.deviceAgentService.downloadWindowsAgent( - organizationId, - employeeId, - ); + + const { stream, filename, contentType } = + await this.deviceAgentService.downloadWindowsAgent( + organizationId, + employeeId, + ); // Set headers for file download res.set({ 'Content-Type': contentType, 'Content-Disposition': `attachment; filename="${filename}"; filename*=UTF-8''${encodeURIComponent(filename)}`, 'Cache-Control': 'no-cache, no-store, must-revalidate', - 'Pragma': 'no-cache', - 'Expires': '0', + Pragma: 'no-cache', + Expires: '0', }); return new StreamableFile(stream); diff --git a/apps/api/src/device-agent/device-agent.service.ts b/apps/api/src/device-agent/device-agent.service.ts index 891b15d6b..cfb1e411a 100644 --- a/apps/api/src/device-agent/device-agent.service.ts +++ b/apps/api/src/device-agent/device-agent.service.ts @@ -3,7 +3,11 @@ import { S3Client, GetObjectCommand } from '@aws-sdk/client-s3'; import { Readable, PassThrough } from 'stream'; import archiver from 'archiver'; import { generateWindowsScript } from './scripts/windows'; -import { getPackageFilename, getReadmeContent, getScriptFilename } from './scripts/common'; +import { + getPackageFilename, + getReadmeContent, + getScriptFilename, +} from './scripts/common'; @Injectable() export class DeviceAgentService { @@ -15,7 +19,8 @@ export class DeviceAgentService { // AWS configuration is validated at startup via ConfigModule // For device agents, we use the FLEET_AGENT_BUCKET_NAME if available, // otherwise fall back to the main bucket - this.fleetBucketName = process.env.FLEET_AGENT_BUCKET_NAME || process.env.APP_AWS_BUCKET_NAME!; + this.fleetBucketName = + process.env.FLEET_AGENT_BUCKET_NAME || process.env.APP_AWS_BUCKET_NAME!; this.s3Client = new S3Client({ region: process.env.APP_AWS_REGION || 'us-east-1', credentials: { @@ -25,7 +30,11 @@ export class DeviceAgentService { }); } - async downloadMacAgent(): Promise<{ stream: Readable; filename: string; contentType: string }> { + async downloadMacAgent(): Promise<{ + stream: Readable; + filename: string; + contentType: string; + }> { try { const macosPackageFilename = 'Comp AI Agent-1.0.0-arm64.dmg'; const packageKey = `macos/${macosPackageFilename}`; @@ -46,7 +55,9 @@ export class DeviceAgentService { // Use S3 stream directly as Node.js Readable const s3Stream = s3Response.Body as Readable; - this.logger.log(`Successfully retrieved macOS agent: ${macosPackageFilename}`); + this.logger.log( + `Successfully retrieved macOS agent: ${macosPackageFilename}`, + ); return { stream: s3Stream, @@ -62,13 +73,18 @@ export class DeviceAgentService { } } - async downloadWindowsAgent(organizationId: string, employeeId: string): Promise<{ stream: Readable; filename: string; contentType: string }> { + async downloadWindowsAgent( + organizationId: string, + employeeId: string, + ): Promise<{ stream: Readable; filename: string; contentType: string }> { try { - this.logger.log(`Creating Windows agent zip for org ${organizationId}, employee ${employeeId}`); + this.logger.log( + `Creating Windows agent zip for org ${organizationId}, employee ${employeeId}`, + ); // Hardcoded device marker paths used by the setup scripts const fleetDevicePathWindows = 'C:\\ProgramData\\CompAI\\Fleet'; - + // Generate the Windows setup script const script = generateWindowsScript({ orgId: organizationId, @@ -123,7 +139,9 @@ export class DeviceAgentService { }); archive.append(s3Stream, { name: packageFilename, store: true }); } else { - this.logger.warn('Windows MSI file not found in S3, creating zip without MSI'); + this.logger.warn( + 'Windows MSI file not found in S3, creating zip without MSI', + ); } // Finalize the archive diff --git a/apps/api/src/device-agent/schemas/download-mac-agent.responses.ts b/apps/api/src/device-agent/schemas/download-mac-agent.responses.ts index 721f53e5d..9486d7449 100644 --- a/apps/api/src/device-agent/schemas/download-mac-agent.responses.ts +++ b/apps/api/src/device-agent/schemas/download-mac-agent.responses.ts @@ -1,65 +1,67 @@ import type { ApiResponseOptions } from '@nestjs/swagger'; -export const DOWNLOAD_MAC_AGENT_RESPONSES: Record = { - 200: { - description: 'macOS agent DMG file download', - content: { - 'application/x-apple-diskimage': { - schema: { - type: 'string', - format: 'binary', +export const DOWNLOAD_MAC_AGENT_RESPONSES: Record = + { + 200: { + description: 'macOS agent DMG file download', + content: { + 'application/x-apple-diskimage': { + schema: { + type: 'string', + format: 'binary', + }, + example: 'Binary DMG file content', }, - example: 'Binary DMG file content', }, - }, - headers: { - 'Content-Disposition': { - description: 'Indicates file should be downloaded with specific filename', - schema: { - type: 'string', - example: 'attachment; filename="Comp AI Agent-1.0.0-arm64.dmg"', + headers: { + 'Content-Disposition': { + description: + 'Indicates file should be downloaded with specific filename', + schema: { + type: 'string', + example: 'attachment; filename="Comp AI Agent-1.0.0-arm64.dmg"', + }, }, - }, - 'Content-Type': { - description: 'MIME type for macOS disk image', - schema: { - type: 'string', - example: 'application/x-apple-diskimage', + 'Content-Type': { + description: 'MIME type for macOS disk image', + schema: { + type: 'string', + example: 'application/x-apple-diskimage', + }, }, }, }, - }, - 401: { - description: 'Unauthorized - Invalid or missing authentication', - content: { - 'application/json': { - example: { - message: 'Unauthorized', - statusCode: 401, + 401: { + description: 'Unauthorized - Invalid or missing authentication', + content: { + 'application/json': { + example: { + message: 'Unauthorized', + statusCode: 401, + }, }, }, }, - }, - 404: { - description: 'macOS agent file not found in S3', - content: { - 'application/json': { - example: { - message: 'macOS agent DMG file not found in S3', - statusCode: 404, + 404: { + description: 'macOS agent file not found in S3', + content: { + 'application/json': { + example: { + message: 'macOS agent DMG file not found in S3', + statusCode: 404, + }, }, }, }, - }, - 500: { - description: 'Internal server error', - content: { - 'application/json': { - example: { - message: 'Internal server error', - statusCode: 500, + 500: { + description: 'Internal server error', + content: { + 'application/json': { + example: { + message: 'Internal server error', + statusCode: 500, + }, }, }, }, - }, -}; + }; diff --git a/apps/api/src/device-agent/schemas/download-windows-agent.responses.ts b/apps/api/src/device-agent/schemas/download-windows-agent.responses.ts index 7d055b349..e6f888602 100644 --- a/apps/api/src/device-agent/schemas/download-windows-agent.responses.ts +++ b/apps/api/src/device-agent/schemas/download-windows-agent.responses.ts @@ -1,8 +1,12 @@ import type { ApiResponseOptions } from '@nestjs/swagger'; -export const DOWNLOAD_WINDOWS_AGENT_RESPONSES: Record = { +export const DOWNLOAD_WINDOWS_AGENT_RESPONSES: Record< + number, + ApiResponseOptions +> = { 200: { - description: 'Windows agent ZIP file download containing MSI installer and setup scripts', + description: + 'Windows agent ZIP file download containing MSI installer and setup scripts', content: { 'application/zip': { schema: { @@ -14,7 +18,8 @@ export const DOWNLOAD_WINDOWS_AGENT_RESPONSES: Record { + async findAllByOrganization( + organizationId: string, + ): Promise { try { // Get organization and its FleetDM label ID const organization = await db.organization.findUnique({ where: { id: organizationId }, - select: { - id: true, - name: true, - fleetDmLabelId: true + select: { + id: true, + name: true, + fleetDmLabelId: true, }, }); if (!organization) { - throw new NotFoundException(`Organization with ID ${organizationId} not found`); + throw new NotFoundException( + `Organization with ID ${organizationId} not found`, + ); } if (!organization.fleetDmLabelId) { - this.logger.warn(`Organization ${organizationId} does not have FleetDM label configured`); + this.logger.warn( + `Organization ${organizationId} does not have FleetDM label configured`, + ); return []; } // Get all hosts for the organization's label - const labelHosts = await this.fleetService.getHostsByLabel(organization.fleetDmLabelId); - + const labelHosts = await this.fleetService.getHostsByLabel( + organization.fleetDmLabelId, + ); + if (!labelHosts.hosts || labelHosts.hosts.length === 0) { this.logger.log(`No devices found for organization ${organizationId}`); return []; @@ -41,45 +49,57 @@ export class DevicesService { // Extract host IDs const hostIds = labelHosts.hosts.map((host: { id: number }) => host.id); - this.logger.log(`Found ${hostIds.length} devices for organization ${organizationId}`); + this.logger.log( + `Found ${hostIds.length} devices for organization ${organizationId}`, + ); // Get detailed information for each host const devices = await this.fleetService.getMultipleHosts(hostIds); - this.logger.log(`Retrieved ${devices.length} device details for organization ${organizationId}`); + this.logger.log( + `Retrieved ${devices.length} device details for organization ${organizationId}`, + ); return devices; } catch (error) { if (error instanceof NotFoundException) { throw error; } - this.logger.error(`Failed to retrieve devices for organization ${organizationId}:`, error); + this.logger.error( + `Failed to retrieve devices for organization ${organizationId}:`, + error, + ); throw new Error(`Failed to retrieve devices: ${error.message}`); } } - async findAllByMember(organizationId: string, memberId: string): Promise { + async findAllByMember( + organizationId: string, + memberId: string, + ): Promise { try { // First verify the organization exists const organization = await db.organization.findUnique({ where: { id: organizationId }, - select: { - id: true, - name: true + select: { + id: true, + name: true, }, }); if (!organization) { - throw new NotFoundException(`Organization with ID ${organizationId} not found`); + throw new NotFoundException( + `Organization with ID ${organizationId} not found`, + ); } // Verify the member exists and belongs to the organization const member = await db.member.findFirst({ - where: { + where: { id: memberId, organizationId: organizationId, }, - select: { - id: true, + select: { + id: true, userId: true, role: true, department: true, @@ -91,17 +111,23 @@ export class DevicesService { }); if (!member) { - throw new NotFoundException(`Member with ID ${memberId} not found in organization ${organizationId}`); + throw new NotFoundException( + `Member with ID ${memberId} not found in organization ${organizationId}`, + ); } if (!member.fleetDmLabelId) { - this.logger.warn(`Member ${memberId} does not have FleetDM label configured`); + this.logger.warn( + `Member ${memberId} does not have FleetDM label configured`, + ); return []; } // Get devices for the member's specific FleetDM label - const labelHosts = await this.fleetService.getHostsByLabel(member.fleetDmLabelId); - + const labelHosts = await this.fleetService.getHostsByLabel( + member.fleetDmLabelId, + ); + if (!labelHosts.hosts || labelHosts.hosts.length === 0) { this.logger.log(`No devices found for member ${memberId}`); return []; @@ -114,26 +140,34 @@ export class DevicesService { // Get detailed information for each host const devices = await this.fleetService.getMultipleHosts(hostIds); - this.logger.log(`Retrieved ${devices.length} device details for member ${memberId} in organization ${organizationId}`); + this.logger.log( + `Retrieved ${devices.length} device details for member ${memberId} in organization ${organizationId}`, + ); return devices; } catch (error) { if (error instanceof NotFoundException) { throw error; } - this.logger.error(`Failed to retrieve devices for member ${memberId} in organization ${organizationId}:`, error); + this.logger.error( + `Failed to retrieve devices for member ${memberId} in organization ${organizationId}:`, + error, + ); throw new Error(`Failed to retrieve member devices: ${error.message}`); } } - async getMemberById(organizationId: string, memberId: string): Promise { + async getMemberById( + organizationId: string, + memberId: string, + ): Promise { try { const member = await db.member.findFirst({ - where: { + where: { id: memberId, organizationId: organizationId, }, - select: { - id: true, + select: { + id: true, userId: true, role: true, department: true, @@ -145,7 +179,9 @@ export class DevicesService { }); if (!member) { - throw new NotFoundException(`Member with ID ${memberId} not found in organization ${organizationId}`); + throw new NotFoundException( + `Member with ID ${memberId} not found in organization ${organizationId}`, + ); } return member; @@ -153,7 +189,10 @@ export class DevicesService { if (error instanceof NotFoundException) { throw error; } - this.logger.error(`Failed to retrieve member ${memberId} in organization ${organizationId}:`, error); + this.logger.error( + `Failed to retrieve member ${memberId} in organization ${organizationId}:`, + error, + ); throw new Error(`Failed to retrieve member: ${error.message}`); } } diff --git a/apps/api/src/devices/dto/device-responses.dto.ts b/apps/api/src/devices/dto/device-responses.dto.ts index c6609e248..70bc4c072 100644 --- a/apps/api/src/devices/dto/device-responses.dto.ts +++ b/apps/api/src/devices/dto/device-responses.dto.ts @@ -13,7 +13,10 @@ export class FleetPolicyDto { @ApiProperty({ description: 'Whether policy is critical', example: true }) critical: boolean; - @ApiProperty({ description: 'Policy description', example: 'Ensures strong passwords' }) + @ApiProperty({ + description: 'Policy description', + example: 'Ensures strong passwords', + }) description: string; @ApiProperty({ description: 'Author ID', example: 456 }) @@ -28,7 +31,10 @@ export class FleetPolicyDto { @ApiProperty({ description: 'Team ID', example: 789, nullable: true }) team_id: number | null; - @ApiProperty({ description: 'Policy resolution', example: 'Update password settings' }) + @ApiProperty({ + description: 'Policy resolution', + example: 'Update password settings', + }) resolution: string; @ApiProperty({ description: 'Platform', example: 'darwin' }) @@ -48,34 +54,62 @@ export class FleetPolicyDto { } export class DeviceResponseDto { - @ApiProperty({ description: 'Device created at', example: '2024-01-01T00:00:00Z' }) + @ApiProperty({ + description: 'Device created at', + example: '2024-01-01T00:00:00Z', + }) created_at: string; - @ApiProperty({ description: 'Device updated at', example: '2024-01-15T00:00:00Z' }) + @ApiProperty({ + description: 'Device updated at', + example: '2024-01-15T00:00:00Z', + }) updated_at: string; - @ApiProperty({ description: 'Software list', type: 'array', items: { type: 'object' } }) + @ApiProperty({ + description: 'Software list', + type: 'array', + items: { type: 'object' }, + }) software: object[]; - @ApiProperty({ description: 'Software updated at', example: '2024-01-10T00:00:00Z' }) + @ApiProperty({ + description: 'Software updated at', + example: '2024-01-10T00:00:00Z', + }) software_updated_at: string; @ApiProperty({ description: 'Device ID', example: 123 }) id: number; - @ApiProperty({ description: 'Detail updated at', example: '2024-01-10T00:00:00Z' }) + @ApiProperty({ + description: 'Detail updated at', + example: '2024-01-10T00:00:00Z', + }) detail_updated_at: string; - @ApiProperty({ description: 'Label updated at', example: '2024-01-10T00:00:00Z' }) + @ApiProperty({ + description: 'Label updated at', + example: '2024-01-10T00:00:00Z', + }) label_updated_at: string; - @ApiProperty({ description: 'Policy updated at', example: '2024-01-10T00:00:00Z' }) + @ApiProperty({ + description: 'Policy updated at', + example: '2024-01-10T00:00:00Z', + }) policy_updated_at: string; - @ApiProperty({ description: 'Last enrolled at', example: '2024-01-01T00:00:00Z' }) + @ApiProperty({ + description: 'Last enrolled at', + example: '2024-01-01T00:00:00Z', + }) last_enrolled_at: string; - @ApiProperty({ description: 'Last seen time', example: '2024-01-15T12:00:00Z' }) + @ApiProperty({ + description: 'Last seen time', + example: '2024-01-15T12:00:00Z', + }) seen_time: string; @ApiProperty({ description: 'Refetch requested', example: false }) @@ -126,7 +160,10 @@ export class DeviceResponseDto { @ApiProperty({ description: 'CPU subtype', example: 'x86_64h' }) cpu_subtype: string; - @ApiProperty({ description: 'CPU brand', example: 'Intel(R) Core(TM) i7-9750H' }) + @ApiProperty({ + description: 'CPU brand', + example: 'Intel(R) Core(TM) i7-9750H', + }) cpu_brand: string; @ApiProperty({ description: 'CPU physical cores', example: 6 }) @@ -171,13 +208,25 @@ export class DeviceResponseDto { @ApiProperty({ description: 'Team ID', example: 1, nullable: true }) team_id: number | null; - @ApiProperty({ description: 'Pack stats', type: 'array', items: { type: 'object' } }) + @ApiProperty({ + description: 'Pack stats', + type: 'array', + items: { type: 'object' }, + }) pack_stats: object[]; - @ApiProperty({ description: 'Team name', example: 'Engineering', nullable: true }) + @ApiProperty({ + description: 'Team name', + example: 'Engineering', + nullable: true, + }) team_name: string | null; - @ApiProperty({ description: 'Users', type: 'array', items: { type: 'object' } }) + @ApiProperty({ + description: 'Users', + type: 'array', + items: { type: 'object' }, + }) users: object[]; @ApiProperty({ description: 'Disk space available in GB', example: 250.5 }) @@ -206,31 +255,60 @@ export class DeviceResponseDto { }) mdm: Record; - @ApiProperty({ description: 'Refetch critical queries until', example: '2024-01-20T00:00:00Z', nullable: true }) + @ApiProperty({ + description: 'Refetch critical queries until', + example: '2024-01-20T00:00:00Z', + nullable: true, + }) refetch_critical_queries_until: string | null; - @ApiProperty({ description: 'Last restarted at', example: '2024-01-10T08:00:00Z' }) + @ApiProperty({ + description: 'Last restarted at', + example: '2024-01-10T08:00:00Z', + }) last_restarted_at: string; @ApiProperty({ description: 'Policies', type: [FleetPolicyDto] }) policies: FleetPolicyDto[]; - @ApiProperty({ description: 'Labels', type: 'array', items: { type: 'object' } }) + @ApiProperty({ + description: 'Labels', + type: 'array', + items: { type: 'object' }, + }) labels: object[]; - @ApiProperty({ description: 'Packs', type: 'array', items: { type: 'object' } }) + @ApiProperty({ + description: 'Packs', + type: 'array', + items: { type: 'object' }, + }) packs: object[]; - @ApiProperty({ description: 'Batteries', type: 'array', items: { type: 'object' } }) + @ApiProperty({ + description: 'Batteries', + type: 'array', + items: { type: 'object' }, + }) batteries: object[]; - @ApiProperty({ description: 'End users', type: 'array', items: { type: 'object' } }) + @ApiProperty({ + description: 'End users', + type: 'array', + items: { type: 'object' }, + }) end_users: object[]; - @ApiProperty({ description: 'Last MDM enrolled at', example: '2024-01-01T00:00:00Z' }) + @ApiProperty({ + description: 'Last MDM enrolled at', + example: '2024-01-01T00:00:00Z', + }) last_mdm_enrolled_at: string; - @ApiProperty({ description: 'Last MDM checked in at', example: '2024-01-15T12:00:00Z' }) + @ApiProperty({ + description: 'Last MDM checked in at', + example: '2024-01-15T12:00:00Z', + }) last_mdm_checked_in_at: string; @ApiProperty({ description: 'Device status', example: 'online' }) diff --git a/apps/api/src/framework-editor/task-template/dto/create-task-template.dto.ts b/apps/api/src/framework-editor/task-template/dto/create-task-template.dto.ts index 9c1f50bf6..f415dca29 100644 --- a/apps/api/src/framework-editor/task-template/dto/create-task-template.dto.ts +++ b/apps/api/src/framework-editor/task-template/dto/create-task-template.dto.ts @@ -35,4 +35,3 @@ export class CreateTaskTemplateDto { @IsEnum(Departments) department: Departments; } - diff --git a/apps/api/src/framework-editor/task-template/dto/task-template-response.dto.ts b/apps/api/src/framework-editor/task-template/dto/task-template-response.dto.ts index 74fdfcf76..e6b567e57 100644 --- a/apps/api/src/framework-editor/task-template/dto/task-template-response.dto.ts +++ b/apps/api/src/framework-editor/task-template/dto/task-template-response.dto.ts @@ -46,4 +46,3 @@ export class TaskTemplateResponseDto { }) updatedAt: Date; } - diff --git a/apps/api/src/framework-editor/task-template/dto/update-task-template.dto.ts b/apps/api/src/framework-editor/task-template/dto/update-task-template.dto.ts index 8eca6085f..2c654f15d 100644 --- a/apps/api/src/framework-editor/task-template/dto/update-task-template.dto.ts +++ b/apps/api/src/framework-editor/task-template/dto/update-task-template.dto.ts @@ -2,4 +2,3 @@ import { PartialType } from '@nestjs/swagger'; import { CreateTaskTemplateDto } from './create-task-template.dto'; export class UpdateTaskTemplateDto extends PartialType(CreateTaskTemplateDto) {} - diff --git a/apps/api/src/framework-editor/task-template/pipes/validate-id.pipe.ts b/apps/api/src/framework-editor/task-template/pipes/validate-id.pipe.ts index ffde496f1..c84355e91 100644 --- a/apps/api/src/framework-editor/task-template/pipes/validate-id.pipe.ts +++ b/apps/api/src/framework-editor/task-template/pipes/validate-id.pipe.ts @@ -1,8 +1,4 @@ -import { - PipeTransform, - Injectable, - BadRequestException, -} from '@nestjs/common'; +import { PipeTransform, Injectable, BadRequestException } from '@nestjs/common'; @Injectable() export class ValidateIdPipe implements PipeTransform { @@ -23,4 +19,3 @@ export class ValidateIdPipe implements PipeTransform { return value; } } - diff --git a/apps/api/src/framework-editor/task-template/schemas/delete-task-template.responses.ts b/apps/api/src/framework-editor/task-template/schemas/delete-task-template.responses.ts index ed320a4f4..ad60d56a5 100644 --- a/apps/api/src/framework-editor/task-template/schemas/delete-task-template.responses.ts +++ b/apps/api/src/framework-editor/task-template/schemas/delete-task-template.responses.ts @@ -33,7 +33,8 @@ export const DELETE_TASK_TEMPLATE_RESPONSES = { schema: { example: { statusCode: 404, - message: 'Framework editor task template with ID frk_tt_abc123def456 not found', + message: + 'Framework editor task template with ID frk_tt_abc123def456 not found', }, }, }, @@ -48,4 +49,3 @@ export const DELETE_TASK_TEMPLATE_RESPONSES = { }, }, }; - diff --git a/apps/api/src/framework-editor/task-template/schemas/get-all-task-templates.responses.ts b/apps/api/src/framework-editor/task-template/schemas/get-all-task-templates.responses.ts index f1cac2b04..ae388155d 100644 --- a/apps/api/src/framework-editor/task-template/schemas/get-all-task-templates.responses.ts +++ b/apps/api/src/framework-editor/task-template/schemas/get-all-task-templates.responses.ts @@ -8,7 +8,8 @@ export const GET_ALL_TASK_TEMPLATES_RESPONSES = { { id: 'frk_tt_abc123def456', name: 'Monthly Security Review', - description: 'Review and update security policies on a monthly basis', + description: + 'Review and update security policies on a monthly basis', frequency: 'monthly', department: 'it', createdAt: '2025-01-01T00:00:00.000Z', @@ -45,4 +46,3 @@ export const GET_ALL_TASK_TEMPLATES_RESPONSES = { }, }, }; - diff --git a/apps/api/src/framework-editor/task-template/schemas/get-task-template-by-id.responses.ts b/apps/api/src/framework-editor/task-template/schemas/get-task-template-by-id.responses.ts index 1c0ed2514..1f7b956a8 100644 --- a/apps/api/src/framework-editor/task-template/schemas/get-task-template-by-id.responses.ts +++ b/apps/api/src/framework-editor/task-template/schemas/get-task-template-by-id.responses.ts @@ -35,7 +35,8 @@ export const GET_TASK_TEMPLATE_BY_ID_RESPONSES = { schema: { example: { statusCode: 404, - message: 'Framework editor task template with ID frk_tt_abc123def456 not found', + message: + 'Framework editor task template with ID frk_tt_abc123def456 not found', }, }, }, @@ -50,4 +51,3 @@ export const GET_TASK_TEMPLATE_BY_ID_RESPONSES = { }, }, }; - diff --git a/apps/api/src/framework-editor/task-template/schemas/task-template-bodies.ts b/apps/api/src/framework-editor/task-template/schemas/task-template-bodies.ts index dd838bdc7..232ac08df 100644 --- a/apps/api/src/framework-editor/task-template/schemas/task-template-bodies.ts +++ b/apps/api/src/framework-editor/task-template/schemas/task-template-bodies.ts @@ -6,4 +6,3 @@ export const TASK_TEMPLATE_BODIES = { description: 'Update framework editor task template data', }, }; - diff --git a/apps/api/src/framework-editor/task-template/schemas/task-template-operations.ts b/apps/api/src/framework-editor/task-template/schemas/task-template-operations.ts index 25fdd00d3..864d7793c 100644 --- a/apps/api/src/framework-editor/task-template/schemas/task-template-operations.ts +++ b/apps/api/src/framework-editor/task-template/schemas/task-template-operations.ts @@ -16,4 +16,3 @@ export const TASK_TEMPLATE_OPERATIONS = { description: 'Delete a framework editor task template by ID', }, }; - diff --git a/apps/api/src/framework-editor/task-template/schemas/task-template-params.ts b/apps/api/src/framework-editor/task-template/schemas/task-template-params.ts index 11f2d3f33..2ed665fe9 100644 --- a/apps/api/src/framework-editor/task-template/schemas/task-template-params.ts +++ b/apps/api/src/framework-editor/task-template/schemas/task-template-params.ts @@ -5,4 +5,3 @@ export const TASK_TEMPLATE_PARAMS = { example: 'frk_tt_abc123def456', }, }; - diff --git a/apps/api/src/framework-editor/task-template/schemas/update-task-template.responses.ts b/apps/api/src/framework-editor/task-template/schemas/update-task-template.responses.ts index 5c7db2acb..ab9c08268 100644 --- a/apps/api/src/framework-editor/task-template/schemas/update-task-template.responses.ts +++ b/apps/api/src/framework-editor/task-template/schemas/update-task-template.responses.ts @@ -45,7 +45,8 @@ export const UPDATE_TASK_TEMPLATE_RESPONSES = { schema: { example: { statusCode: 404, - message: 'Framework editor task template with ID frk_tt_abc123def456 not found', + message: + 'Framework editor task template with ID frk_tt_abc123def456 not found', }, }, }, @@ -60,4 +61,3 @@ export const UPDATE_TASK_TEMPLATE_RESPONSES = { }, }, }; - diff --git a/apps/api/src/framework-editor/task-template/task-template.controller.ts b/apps/api/src/framework-editor/task-template/task-template.controller.ts index 4f6128fbe..c0d9b4339 100644 --- a/apps/api/src/framework-editor/task-template/task-template.controller.ts +++ b/apps/api/src/framework-editor/task-template/task-template.controller.ts @@ -1,6 +1,6 @@ -import { - Controller, - Get, +import { + Controller, + Get, Patch, Delete, Body, @@ -18,9 +18,7 @@ import { ApiSecurity, ApiTags, } from '@nestjs/swagger'; -import { - AuthContext, -} from '../../auth/auth-context.decorator'; +import { AuthContext } from '../../auth/auth-context.decorator'; import { HybridAuthGuard } from '../../auth/hybrid-auth.guard'; import type { AuthContext as AuthContextType } from '../../auth/types'; import { UpdateTaskTemplateDto } from './dto/update-task-template.dto'; @@ -67,17 +65,19 @@ export class TaskTemplateController { @Param('id', ValidateIdPipe) taskTemplateId: string, @AuthContext() authContext: AuthContextType, ) { - const taskTemplate = await this.taskTemplateService.findById(taskTemplateId); + const taskTemplate = + await this.taskTemplateService.findById(taskTemplateId); return { ...taskTemplate, authType: authContext.authType, - ...(authContext.userId && authContext.userEmail && { - authenticatedUser: { - id: authContext.userId, - email: authContext.userEmail, - }, - }), + ...(authContext.userId && + authContext.userEmail && { + authenticatedUser: { + id: authContext.userId, + email: authContext.userEmail, + }, + }), }; } @@ -90,7 +90,13 @@ export class TaskTemplateController { @ApiResponse(UPDATE_TASK_TEMPLATE_RESPONSES[401]) @ApiResponse(UPDATE_TASK_TEMPLATE_RESPONSES[404]) @ApiResponse(UPDATE_TASK_TEMPLATE_RESPONSES[500]) - @UsePipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true })) + @UsePipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + }), + ) async updateTaskTemplate( @Param('id', ValidateIdPipe) taskTemplateId: string, @Body() updateTaskTemplateDto: UpdateTaskTemplateDto, @@ -104,12 +110,13 @@ export class TaskTemplateController { return { ...updatedTaskTemplate, authType: authContext.authType, - ...(authContext.userId && authContext.userEmail && { - authenticatedUser: { - id: authContext.userId, - email: authContext.userEmail, - }, - }), + ...(authContext.userId && + authContext.userEmail && { + authenticatedUser: { + id: authContext.userId, + email: authContext.userEmail, + }, + }), }; } @@ -129,13 +136,13 @@ export class TaskTemplateController { return { ...result, authType: authContext.authType, - ...(authContext.userId && authContext.userEmail && { - authenticatedUser: { - id: authContext.userId, - email: authContext.userEmail, - }, - }), + ...(authContext.userId && + authContext.userEmail && { + authenticatedUser: { + id: authContext.userId, + email: authContext.userEmail, + }, + }), }; } } - diff --git a/apps/api/src/framework-editor/task-template/task-template.module.ts b/apps/api/src/framework-editor/task-template/task-template.module.ts index fd665ca6a..5c9608dc0 100644 --- a/apps/api/src/framework-editor/task-template/task-template.module.ts +++ b/apps/api/src/framework-editor/task-template/task-template.module.ts @@ -10,4 +10,3 @@ import { TaskTemplateService } from './task-template.service'; exports: [TaskTemplateService], }) export class TaskTemplateModule {} - diff --git a/apps/api/src/framework-editor/task-template/task-template.service.ts b/apps/api/src/framework-editor/task-template/task-template.service.ts index ecbe3f404..8d34ede1b 100644 --- a/apps/api/src/framework-editor/task-template/task-template.service.ts +++ b/apps/api/src/framework-editor/task-template/task-template.service.ts @@ -12,10 +12,15 @@ export class TaskTemplateService { orderBy: { name: 'asc' }, }); - this.logger.log(`Retrieved ${taskTemplates.length} framework editor task templates`); + this.logger.log( + `Retrieved ${taskTemplates.length} framework editor task templates`, + ); return taskTemplates; } catch (error) { - this.logger.error('Failed to retrieve framework editor task templates:', error); + this.logger.error( + 'Failed to retrieve framework editor task templates:', + error, + ); throw error; } } @@ -27,16 +32,23 @@ export class TaskTemplateService { }); if (!taskTemplate) { - throw new NotFoundException(`Framework editor task template with ID ${id} not found`); + throw new NotFoundException( + `Framework editor task template with ID ${id} not found`, + ); } - this.logger.log(`Retrieved framework editor task template: ${taskTemplate.name} (${id})`); + this.logger.log( + `Retrieved framework editor task template: ${taskTemplate.name} (${id})`, + ); return taskTemplate; } catch (error) { if (error instanceof NotFoundException) { throw error; } - this.logger.error(`Failed to retrieve framework editor task template ${id}:`, error); + this.logger.error( + `Failed to retrieve framework editor task template ${id}:`, + error, + ); throw error; } } @@ -51,13 +63,18 @@ export class TaskTemplateService { data: updateDto, }); - this.logger.log(`Updated framework editor task template: ${updatedTaskTemplate.name} (${id})`); + this.logger.log( + `Updated framework editor task template: ${updatedTaskTemplate.name} (${id})`, + ); return updatedTaskTemplate; } catch (error) { if (error instanceof NotFoundException) { throw error; } - this.logger.error(`Failed to update framework editor task template ${id}:`, error); + this.logger.error( + `Failed to update framework editor task template ${id}:`, + error, + ); throw error; } } @@ -71,21 +88,25 @@ export class TaskTemplateService { where: { id }, }); - this.logger.log(`Deleted framework editor task template: ${existingTaskTemplate.name} (${id})`); - return { + this.logger.log( + `Deleted framework editor task template: ${existingTaskTemplate.name} (${id})`, + ); + return { message: 'Framework editor task template deleted successfully', deletedTaskTemplate: { id: existingTaskTemplate.id, name: existingTaskTemplate.name, - } + }, }; } catch (error) { if (error instanceof NotFoundException) { throw error; } - this.logger.error(`Failed to delete framework editor task template ${id}:`, error); + this.logger.error( + `Failed to delete framework editor task template ${id}:`, + error, + ); throw error; } } } - diff --git a/apps/api/src/lib/fleet.service.ts b/apps/api/src/lib/fleet.service.ts index e3d336454..7df5f599a 100644 --- a/apps/api/src/lib/fleet.service.ts +++ b/apps/api/src/lib/fleet.service.ts @@ -9,7 +9,7 @@ export class FleetService { constructor() { this.fleetInstance = axios.create({ baseURL: `${process.env.FLEET_URL}/api/v1/fleet`, - headers: { + headers: { Authorization: `Bearer ${process.env.FLEET_TOKEN}`, 'Content-Type': 'application/json', }, @@ -19,24 +19,31 @@ export class FleetService { // Add request/response interceptors for logging this.fleetInstance.interceptors.request.use( (config) => { - this.logger.debug(`FleetDM Request: ${config.method?.toUpperCase()} ${config.url}`); + this.logger.debug( + `FleetDM Request: ${config.method?.toUpperCase()} ${config.url}`, + ); return config; }, (error) => { this.logger.error('FleetDM Request Error:', error); return Promise.reject(error); - } + }, ); this.fleetInstance.interceptors.response.use( (response) => { - this.logger.debug(`FleetDM Response: ${response.status} ${response.config.url}`); + this.logger.debug( + `FleetDM Response: ${response.status} ${response.config.url}`, + ); return response; }, (error) => { - this.logger.error(`FleetDM Response Error: ${error.response?.status} ${error.config?.url}`, error.response?.data); + this.logger.error( + `FleetDM Response Error: ${error.response?.status} ${error.config?.url}`, + error.response?.data, + ); return Promise.reject(error); - } + }, ); } @@ -62,9 +69,9 @@ export class FleetService { async getMultipleHosts(hostIds: number[]) { try { - const requests = hostIds.map(id => this.getHostById(id)); + const requests = hostIds.map((id) => this.getHostById(id)); const responses = await Promise.all(requests); - return responses.map(response => response.host); + return responses.map((response) => response.host); } catch (error) { this.logger.error('Failed to get multiple hosts:', error); throw new Error('Failed to fetch multiple hosts'); diff --git a/apps/api/src/organization/organization.controller.ts b/apps/api/src/organization/organization.controller.ts index 41ff3b711..f2484a13f 100644 --- a/apps/api/src/organization/organization.controller.ts +++ b/apps/api/src/organization/organization.controller.ts @@ -1,4 +1,11 @@ -import { Body, Controller, Delete, Get, Patch, UseGuards } from '@nestjs/common'; +import { + Body, + Controller, + Delete, + Get, + Patch, + UseGuards, +} from '@nestjs/common'; import { ApiBody, ApiHeader, diff --git a/apps/api/src/organization/organization.service.ts b/apps/api/src/organization/organization.service.ts index 89bd4436d..6e4db316c 100644 --- a/apps/api/src/organization/organization.service.ts +++ b/apps/api/src/organization/organization.service.ts @@ -83,7 +83,9 @@ export class OrganizationService { }, }); - this.logger.log(`Updated organization: ${updatedOrganization.name} (${id})`); + this.logger.log( + `Updated organization: ${updatedOrganization.name} (${id})`, + ); return updatedOrganization; } catch (error) { if (error instanceof NotFoundException) { diff --git a/apps/api/src/people/dto/bulk-create-people.dto.ts b/apps/api/src/people/dto/bulk-create-people.dto.ts index fa610997b..9b41f4f97 100644 --- a/apps/api/src/people/dto/bulk-create-people.dto.ts +++ b/apps/api/src/people/dto/bulk-create-people.dto.ts @@ -1,6 +1,11 @@ import { ApiProperty } from '@nestjs/swagger'; import { Type } from 'class-transformer'; -import { IsArray, ValidateNested, ArrayMinSize, ArrayMaxSize } from 'class-validator'; +import { + IsArray, + ValidateNested, + ArrayMinSize, + ArrayMaxSize, +} from 'class-validator'; import { CreatePeopleDto } from './create-people.dto'; export class BulkCreatePeopleDto { @@ -25,7 +30,9 @@ export class BulkCreatePeopleDto { }) @IsArray() @ArrayMinSize(1, { message: 'Members array cannot be empty' }) - @ArrayMaxSize(1000, { message: 'Maximum 1000 members allowed per bulk request' }) + @ArrayMaxSize(1000, { + message: 'Maximum 1000 members allowed per bulk request', + }) @ValidateNested({ each: true }) @Type(() => CreatePeopleDto) members: CreatePeopleDto[]; diff --git a/apps/api/src/people/dto/create-people.dto.ts b/apps/api/src/people/dto/create-people.dto.ts index 81aff80ed..4c36b7a9a 100644 --- a/apps/api/src/people/dto/create-people.dto.ts +++ b/apps/api/src/people/dto/create-people.dto.ts @@ -1,5 +1,11 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsString, IsOptional, IsEnum, IsBoolean, IsNumber } from 'class-validator'; +import { + IsString, + IsOptional, + IsEnum, + IsBoolean, + IsNumber, +} from 'class-validator'; import { Departments } from '@trycompai/db'; export class CreatePeopleDto { diff --git a/apps/api/src/people/people.service.ts b/apps/api/src/people/people.service.ts index ad02d02a9..fef0fc15f 100644 --- a/apps/api/src/people/people.service.ts +++ b/apps/api/src/people/people.service.ts @@ -1,4 +1,9 @@ -import { Injectable, NotFoundException, Logger, BadRequestException } from '@nestjs/common'; +import { + Injectable, + NotFoundException, + Logger, + BadRequestException, +} from '@nestjs/common'; import type { PeopleResponseDto } from './dto/people-responses.dto'; import type { CreatePeopleDto } from './dto/create-people.dto'; import type { UpdatePeopleDto } from './dto/update-people.dto'; @@ -10,29 +15,44 @@ import { MemberQueries } from './utils/member-queries'; export class PeopleService { private readonly logger = new Logger(PeopleService.name); - async findAllByOrganization(organizationId: string): Promise { + async findAllByOrganization( + organizationId: string, + ): Promise { try { await MemberValidator.validateOrganization(organizationId); const members = await MemberQueries.findAllByOrganization(organizationId); - this.logger.log(`Retrieved ${members.length} members for organization ${organizationId}`); + this.logger.log( + `Retrieved ${members.length} members for organization ${organizationId}`, + ); return members; } catch (error) { if (error instanceof NotFoundException) { throw error; } - this.logger.error(`Failed to retrieve members for organization ${organizationId}:`, error); + this.logger.error( + `Failed to retrieve members for organization ${organizationId}:`, + error, + ); throw new Error(`Failed to retrieve members: ${error.message}`); } } - async findById(memberId: string, organizationId: string): Promise { + async findById( + memberId: string, + organizationId: string, + ): Promise { try { await MemberValidator.validateOrganization(organizationId); - const member = await MemberQueries.findByIdInOrganization(memberId, organizationId); + const member = await MemberQueries.findByIdInOrganization( + memberId, + organizationId, + ); if (!member) { - throw new NotFoundException(`Member with ID ${memberId} not found in organization ${organizationId}`); + throw new NotFoundException( + `Member with ID ${memberId} not found in organization ${organizationId}`, + ); } this.logger.log(`Retrieved member: ${member.user.name} (${memberId})`); @@ -41,31 +61,54 @@ export class PeopleService { if (error instanceof NotFoundException) { throw error; } - this.logger.error(`Failed to retrieve member ${memberId} in organization ${organizationId}:`, error); + this.logger.error( + `Failed to retrieve member ${memberId} in organization ${organizationId}:`, + error, + ); throw new Error(`Failed to retrieve member: ${error.message}`); } } - async create(organizationId: string, createData: CreatePeopleDto): Promise { + async create( + organizationId: string, + createData: CreatePeopleDto, + ): Promise { try { await MemberValidator.validateOrganization(organizationId); await MemberValidator.validateUser(createData.userId); - await MemberValidator.validateUserNotMember(createData.userId, organizationId); + await MemberValidator.validateUserNotMember( + createData.userId, + organizationId, + ); - const member = await MemberQueries.createMember(organizationId, createData); + const member = await MemberQueries.createMember( + organizationId, + createData, + ); - this.logger.log(`Created member: ${member.user.name} (${member.id}) for organization ${organizationId}`); + this.logger.log( + `Created member: ${member.user.name} (${member.id}) for organization ${organizationId}`, + ); return member; } catch (error) { - if (error instanceof NotFoundException || error instanceof BadRequestException) { + if ( + error instanceof NotFoundException || + error instanceof BadRequestException + ) { throw error; } - this.logger.error(`Failed to create member for organization ${organizationId}:`, error); + this.logger.error( + `Failed to create member for organization ${organizationId}:`, + error, + ); throw new Error(`Failed to create member: ${error.message}`); } } - async bulkCreate(organizationId: string, bulkCreateData: BulkCreatePeopleDto): Promise<{ + async bulkCreate( + organizationId: string, + bulkCreateData: BulkCreatePeopleDto, + ): Promise<{ created: PeopleResponseDto[]; errors: Array<{ index: number; userId: string; error: string }>; summary: { total: number; successful: number; failed: number }; @@ -74,7 +117,8 @@ export class PeopleService { await MemberValidator.validateOrganization(organizationId); const created: PeopleResponseDto[] = []; - const errors: Array<{ index: number; userId: string; error: string }> = []; + const errors: Array<{ index: number; userId: string; error: string }> = + []; // Process each member in the bulk request // Validate all users and membership status first, collecting errors @@ -83,7 +127,10 @@ export class PeopleService { const memberData = bulkCreateData.members[i]; try { await MemberValidator.validateUser(memberData.userId); - await MemberValidator.validateUserNotMember(memberData.userId, organizationId); + await MemberValidator.validateUserNotMember( + memberData.userId, + organizationId, + ); validMembers.push(memberData); } catch (error) { errors.push({ @@ -91,17 +138,25 @@ export class PeopleService { userId: memberData.userId, error: error.message || 'Unknown error occurred', }); - this.logger.error(`Failed to validate member at index ${i} (userId: ${memberData.userId}):`, error); + this.logger.error( + `Failed to validate member at index ${i} (userId: ${memberData.userId}):`, + error, + ); } } // Bulk insert valid members using createMany if (validMembers.length > 0) { - const createdMembers = await MemberQueries.bulkCreateMembers(organizationId, validMembers); + const createdMembers = await MemberQueries.bulkCreateMembers( + organizationId, + validMembers, + ); created.push(...createdMembers); - createdMembers.forEach(member => { - this.logger.log(`Created member: ${member.user.name} (${member.id}) for organization ${organizationId}`); + createdMembers.forEach((member) => { + this.logger.log( + `Created member: ${member.user.name} (${member.id}) for organization ${organizationId}`, + ); }); } @@ -111,67 +166,110 @@ export class PeopleService { failed: errors.length, }; - this.logger.log(`Bulk create completed for organization ${organizationId}: ${summary.successful}/${summary.total} successful`); - + this.logger.log( + `Bulk create completed for organization ${organizationId}: ${summary.successful}/${summary.total} successful`, + ); + return { created, errors, summary }; } catch (error) { if (error instanceof NotFoundException) { throw error; } - this.logger.error(`Failed to bulk create members for organization ${organizationId}:`, error); + this.logger.error( + `Failed to bulk create members for organization ${organizationId}:`, + error, + ); throw new Error(`Failed to bulk create members: ${error.message}`); } } - async updateById(memberId: string, organizationId: string, updateData: UpdatePeopleDto): Promise { + async updateById( + memberId: string, + organizationId: string, + updateData: UpdatePeopleDto, + ): Promise { try { await MemberValidator.validateOrganization(organizationId); - const existingMember = await MemberValidator.validateMemberExists(memberId, organizationId); + const existingMember = await MemberValidator.validateMemberExists( + memberId, + organizationId, + ); // If userId is being updated, validate the new user if (updateData.userId && updateData.userId !== existingMember.userId) { await MemberValidator.validateUser(updateData.userId); - await MemberValidator.validateUserNotMember(updateData.userId, organizationId, memberId); + await MemberValidator.validateUserNotMember( + updateData.userId, + organizationId, + memberId, + ); } - const updatedMember = await MemberQueries.updateMember(memberId, updateData); + const updatedMember = await MemberQueries.updateMember( + memberId, + updateData, + ); - this.logger.log(`Updated member: ${updatedMember.user.name} (${memberId})`); + this.logger.log( + `Updated member: ${updatedMember.user.name} (${memberId})`, + ); return updatedMember; } catch (error) { - if (error instanceof NotFoundException || error instanceof BadRequestException) { + if ( + error instanceof NotFoundException || + error instanceof BadRequestException + ) { throw error; } - this.logger.error(`Failed to update member ${memberId} in organization ${organizationId}:`, error); + this.logger.error( + `Failed to update member ${memberId} in organization ${organizationId}:`, + error, + ); throw new Error(`Failed to update member: ${error.message}`); } } - async deleteById(memberId: string, organizationId: string): Promise<{ success: boolean; deletedMember: { id: string; name: string; email: string } }> { + async deleteById( + memberId: string, + organizationId: string, + ): Promise<{ + success: boolean; + deletedMember: { id: string; name: string; email: string }; + }> { try { await MemberValidator.validateOrganization(organizationId); - const member = await MemberQueries.findMemberForDeletion(memberId, organizationId); + const member = await MemberQueries.findMemberForDeletion( + memberId, + organizationId, + ); if (!member) { - throw new NotFoundException(`Member with ID ${memberId} not found in organization ${organizationId}`); + throw new NotFoundException( + `Member with ID ${memberId} not found in organization ${organizationId}`, + ); } await MemberQueries.deleteMember(memberId); - this.logger.log(`Deleted member: ${member.user.name} (${memberId}) from organization ${organizationId}`); - return { - success: true, + this.logger.log( + `Deleted member: ${member.user.name} (${memberId}) from organization ${organizationId}`, + ); + return { + success: true, deletedMember: { id: member.id, name: member.user.name, email: member.user.email, - } + }, }; } catch (error) { if (error instanceof NotFoundException) { throw error; } - this.logger.error(`Failed to delete member ${memberId} from organization ${organizationId}:`, error); + this.logger.error( + `Failed to delete member ${memberId} from organization ${organizationId}:`, + error, + ); throw new Error(`Failed to delete member: ${error.message}`); } } diff --git a/apps/api/src/people/utils/member-queries.ts b/apps/api/src/people/utils/member-queries.ts index 8cb9e6fb3..0985195c5 100644 --- a/apps/api/src/people/utils/member-queries.ts +++ b/apps/api/src/people/utils/member-queries.ts @@ -36,7 +36,9 @@ export class MemberQueries { /** * Get all members for an organization */ - static async findAllByOrganization(organizationId: string): Promise { + static async findAllByOrganization( + organizationId: string, + ): Promise { return db.member.findMany({ where: { organizationId }, select: this.MEMBER_SELECT, @@ -47,9 +49,12 @@ export class MemberQueries { /** * Find a member by ID within an organization */ - static async findByIdInOrganization(memberId: string, organizationId: string): Promise { + static async findByIdInOrganization( + memberId: string, + organizationId: string, + ): Promise { return db.member.findFirst({ - where: { + where: { id: memberId, organizationId, }, @@ -60,7 +65,10 @@ export class MemberQueries { /** * Create a new member */ - static async createMember(organizationId: string, createData: CreatePeopleDto): Promise { + static async createMember( + organizationId: string, + createData: CreatePeopleDto, + ): Promise { return db.member.create({ data: { organizationId, @@ -77,12 +85,18 @@ export class MemberQueries { /** * Update a member by ID */ - static async updateMember(memberId: string, updateData: UpdatePeopleDto): Promise { + static async updateMember( + memberId: string, + updateData: UpdatePeopleDto, + ): Promise { // Prepare update data with defaults for optional fields const updatePayload: any = { ...updateData }; - + // Handle fleetDmLabelId: convert undefined to null for database - if (updateData.fleetDmLabelId === undefined && 'fleetDmLabelId' in updateData) { + if ( + updateData.fleetDmLabelId === undefined && + 'fleetDmLabelId' in updateData + ) { updatePayload.fleetDmLabelId = null; } @@ -96,12 +110,15 @@ export class MemberQueries { /** * Get member for deletion (with minimal user info) */ - static async findMemberForDeletion(memberId: string, organizationId: string): Promise<{ + static async findMemberForDeletion( + memberId: string, + organizationId: string, + ): Promise<{ id: string; user: { id: string; name: string; email: string }; } | null> { return db.member.findFirst({ - where: { + where: { id: memberId, organizationId, }, @@ -130,9 +147,12 @@ export class MemberQueries { /** * Bulk create members for an organization */ - static async bulkCreateMembers(organizationId: string, memberData: CreatePeopleDto[]): Promise { + static async bulkCreateMembers( + organizationId: string, + memberData: CreatePeopleDto[], + ): Promise { // Prepare data for createMany - const data = memberData.map(member => ({ + const data = memberData.map((member) => ({ organizationId, userId: member.userId, role: member.role, @@ -151,7 +171,7 @@ export class MemberQueries { return db.member.findMany({ where: { organizationId, - userId: { in: memberData.map(m => m.userId) }, + userId: { in: memberData.map((m) => m.userId) }, }, select: this.MEMBER_SELECT, orderBy: { createdAt: 'desc' }, diff --git a/apps/api/src/people/utils/member-validator.ts b/apps/api/src/people/utils/member-validator.ts index bbb4a5402..1c678c63f 100644 --- a/apps/api/src/people/utils/member-validator.ts +++ b/apps/api/src/people/utils/member-validator.ts @@ -12,14 +12,18 @@ export class MemberValidator { }); if (!organization) { - throw new NotFoundException(`Organization with ID ${organizationId} not found`); + throw new NotFoundException( + `Organization with ID ${organizationId} not found`, + ); } } /** * Validates that a user exists and returns user data */ - static async validateUser(userId: string): Promise<{ id: string; name: string; email: string }> { + static async validateUser( + userId: string, + ): Promise<{ id: string; name: string; email: string }> { const user = await db.user.findUnique({ where: { id: userId }, select: { id: true, name: true, email: true }, @@ -35,9 +39,12 @@ export class MemberValidator { /** * Validates that a member exists in an organization */ - static async validateMemberExists(memberId: string, organizationId: string): Promise<{ id: string; userId: string }> { + static async validateMemberExists( + memberId: string, + organizationId: string, + ): Promise<{ id: string; userId: string }> { const member = await db.member.findFirst({ - where: { + where: { id: memberId, organizationId, }, @@ -45,7 +52,9 @@ export class MemberValidator { }); if (!member) { - throw new NotFoundException(`Member with ID ${memberId} not found in organization ${organizationId}`); + throw new NotFoundException( + `Member with ID ${memberId} not found in organization ${organizationId}`, + ); } return member; @@ -55,9 +64,9 @@ export class MemberValidator { * Validates that a user is not already a member of an organization */ static async validateUserNotMember( - userId: string, - organizationId: string, - excludeMemberId?: string + userId: string, + organizationId: string, + excludeMemberId?: string, ): Promise { const whereClause: any = { userId, @@ -74,7 +83,9 @@ export class MemberValidator { if (existingMember) { const user = await this.validateUser(userId); - throw new BadRequestException(`User ${user.email} is already a member of this organization`); + throw new BadRequestException( + `User ${user.email} is already a member of this organization`, + ); } } } diff --git a/apps/api/src/risks/dto/create-risk.dto.ts b/apps/api/src/risks/dto/create-risk.dto.ts index 2c68b362d..0875bc2cc 100644 --- a/apps/api/src/risks/dto/create-risk.dto.ts +++ b/apps/api/src/risks/dto/create-risk.dto.ts @@ -1,12 +1,12 @@ import { ApiProperty } from '@nestjs/swagger'; import { IsString, IsNotEmpty, IsOptional, IsEnum } from 'class-validator'; -import { - RiskCategory, - Departments, - RiskStatus, - Likelihood, - Impact, - RiskTreatmentType +import { + RiskCategory, + Departments, + RiskStatus, + Likelihood, + Impact, + RiskTreatmentType, } from '@trycompai/db'; export class CreateRiskDto { @@ -20,7 +20,8 @@ export class CreateRiskDto { @ApiProperty({ description: 'Detailed description of the risk', - example: 'Weak password requirements could lead to unauthorized access to user accounts', + example: + 'Weak password requirements could lead to unauthorized access to user accounts', }) @IsString() @IsNotEmpty() @@ -97,7 +98,8 @@ export class CreateRiskDto { @ApiProperty({ description: 'Description of the treatment strategy', required: false, - example: 'Implement multi-factor authentication and strengthen password requirements', + example: + 'Implement multi-factor authentication and strengthen password requirements', }) @IsOptional() @IsString() diff --git a/apps/api/src/risks/dto/risk-response.dto.ts b/apps/api/src/risks/dto/risk-response.dto.ts index 20681471d..36d1bd23e 100644 --- a/apps/api/src/risks/dto/risk-response.dto.ts +++ b/apps/api/src/risks/dto/risk-response.dto.ts @@ -1,11 +1,11 @@ import { ApiProperty } from '@nestjs/swagger'; -import { - RiskCategory, - Departments, - RiskStatus, - Likelihood, - Impact, - RiskTreatmentType +import { + RiskCategory, + Departments, + RiskStatus, + Likelihood, + Impact, + RiskTreatmentType, } from '@trycompai/db'; export class RiskResponseDto { @@ -23,7 +23,8 @@ export class RiskResponseDto { @ApiProperty({ description: 'Detailed description of the risk', - example: 'Weak password requirements could lead to unauthorized access to user accounts', + example: + 'Weak password requirements could lead to unauthorized access to user accounts', }) description: string; @@ -80,7 +81,8 @@ export class RiskResponseDto { @ApiProperty({ description: 'Description of the treatment strategy', nullable: true, - example: 'Implement multi-factor authentication and strengthen password requirements', + example: + 'Implement multi-factor authentication and strengthen password requirements', }) treatmentStrategyDescription: string | null; diff --git a/apps/api/src/risks/risks.controller.ts b/apps/api/src/risks/risks.controller.ts index eb8bcf92f..28afa46c0 100644 --- a/apps/api/src/risks/risks.controller.ts +++ b/apps/api/src/risks/risks.controller.ts @@ -1,12 +1,12 @@ -import { - Controller, - Get, +import { + Controller, + Get, Post, Patch, Delete, Body, Param, - UseGuards + UseGuards, } from '@nestjs/common'; import { ApiBody, @@ -17,10 +17,7 @@ import { ApiSecurity, ApiTags, } from '@nestjs/swagger'; -import { - AuthContext, - OrganizationId, -} from '../auth/auth-context.decorator'; +import { AuthContext, OrganizationId } from '../auth/auth-context.decorator'; import { HybridAuthGuard } from '../auth/hybrid-auth.guard'; import type { AuthContext as AuthContextType } from '../auth/types'; import { CreateRiskDto } from './dto/create-risk.dto'; @@ -64,12 +61,13 @@ export class RisksController { data: risks, count: risks.length, authType: authContext.authType, - ...(authContext.userId && authContext.userEmail && { - authenticatedUser: { - id: authContext.userId, - email: authContext.userEmail, - }, - }), + ...(authContext.userId && + authContext.userEmail && { + authenticatedUser: { + id: authContext.userId, + email: authContext.userEmail, + }, + }), }; } @@ -90,12 +88,13 @@ export class RisksController { return { ...risk, authType: authContext.authType, - ...(authContext.userId && authContext.userEmail && { - authenticatedUser: { - id: authContext.userId, - email: authContext.userEmail, - }, - }), + ...(authContext.userId && + authContext.userEmail && { + authenticatedUser: { + id: authContext.userId, + email: authContext.userEmail, + }, + }), }; } @@ -117,12 +116,13 @@ export class RisksController { return { ...risk, authType: authContext.authType, - ...(authContext.userId && authContext.userEmail && { - authenticatedUser: { - id: authContext.userId, - email: authContext.userEmail, - }, - }), + ...(authContext.userId && + authContext.userEmail && { + authenticatedUser: { + id: authContext.userId, + email: authContext.userEmail, + }, + }), }; } @@ -150,12 +150,13 @@ export class RisksController { return { ...updatedRisk, authType: authContext.authType, - ...(authContext.userId && authContext.userEmail && { - authenticatedUser: { - id: authContext.userId, - email: authContext.userEmail, - }, - }), + ...(authContext.userId && + authContext.userEmail && { + authenticatedUser: { + id: authContext.userId, + email: authContext.userEmail, + }, + }), }; } @@ -176,12 +177,13 @@ export class RisksController { return { ...result, authType: authContext.authType, - ...(authContext.userId && authContext.userEmail && { - authenticatedUser: { - id: authContext.userId, - email: authContext.userEmail, - }, - }), + ...(authContext.userId && + authContext.userEmail && { + authenticatedUser: { + id: authContext.userId, + email: authContext.userEmail, + }, + }), }; } } diff --git a/apps/api/src/risks/risks.service.ts b/apps/api/src/risks/risks.service.ts index 97fbf1656..3d425ccc8 100644 --- a/apps/api/src/risks/risks.service.ts +++ b/apps/api/src/risks/risks.service.ts @@ -14,10 +14,15 @@ export class RisksService { orderBy: { createdAt: 'desc' }, }); - this.logger.log(`Retrieved ${risks.length} risks for organization ${organizationId}`); + this.logger.log( + `Retrieved ${risks.length} risks for organization ${organizationId}`, + ); return risks; } catch (error) { - this.logger.error(`Failed to retrieve risks for organization ${organizationId}:`, error); + this.logger.error( + `Failed to retrieve risks for organization ${organizationId}:`, + error, + ); throw error; } } @@ -25,14 +30,16 @@ export class RisksService { async findById(id: string, organizationId: string) { try { const risk = await db.risk.findFirst({ - where: { + where: { id, - organizationId + organizationId, }, }); if (!risk) { - throw new NotFoundException(`Risk with ID ${id} not found in organization ${organizationId}`); + throw new NotFoundException( + `Risk with ID ${id} not found in organization ${organizationId}`, + ); } this.logger.log(`Retrieved risk: ${risk.title} (${id})`); @@ -55,15 +62,24 @@ export class RisksService { }, }); - this.logger.log(`Created new risk: ${risk.title} (${risk.id}) for organization ${organizationId}`); + this.logger.log( + `Created new risk: ${risk.title} (${risk.id}) for organization ${organizationId}`, + ); return risk; } catch (error) { - this.logger.error(`Failed to create risk for organization ${organizationId}:`, error); + this.logger.error( + `Failed to create risk for organization ${organizationId}:`, + error, + ); throw error; } } - async updateById(id: string, organizationId: string, updateRiskDto: UpdateRiskDto) { + async updateById( + id: string, + organizationId: string, + updateRiskDto: UpdateRiskDto, + ) { try { // First check if the risk exists in the organization await this.findById(id, organizationId); @@ -94,12 +110,12 @@ export class RisksService { }); this.logger.log(`Deleted risk: ${existingRisk.title} (${id})`); - return { + return { message: 'Risk deleted successfully', deletedRisk: { id: existingRisk.id, title: existingRisk.title, - } + }, }; } catch (error) { if (error instanceof NotFoundException) { diff --git a/apps/api/src/trust-portal/dto/domain-status.dto.ts b/apps/api/src/trust-portal/dto/domain-status.dto.ts index 7f06dc66c..977a13977 100644 --- a/apps/api/src/trust-portal/dto/domain-status.dto.ts +++ b/apps/api/src/trust-portal/dto/domain-status.dto.ts @@ -8,7 +8,7 @@ export class GetDomainStatusDto { }) @IsString() @IsNotEmpty({ message: 'domain cannot be empty' }) - @Matches(/^(?!-)[A-Za-z0-9-]+([-\.]{1}[a-z0-9]+)*\.[A-Za-z]{2,6}$/, { + @Matches(/^(?!-)[A-Za-z0-9-]+([-.][a-z0-9]+)*\.[A-Za-z]{2,6}$/u, { message: 'domain must be a valid domain format', }) domain: string; diff --git a/apps/api/src/trust-portal/dto/nda.dto.ts b/apps/api/src/trust-portal/dto/nda.dto.ts new file mode 100644 index 000000000..c7fffdc2c --- /dev/null +++ b/apps/api/src/trust-portal/dto/nda.dto.ts @@ -0,0 +1,17 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsBoolean, IsEmail, IsString, MinLength } from 'class-validator'; + +export class SignNdaDto { + @ApiProperty() + @IsString() + @MinLength(2) + name: string; + + @ApiProperty() + @IsEmail() + email: string; + + @ApiProperty() + @IsBoolean() + accept: boolean; +} diff --git a/apps/api/src/trust-portal/dto/trust-access.dto.ts b/apps/api/src/trust-portal/dto/trust-access.dto.ts new file mode 100644 index 000000000..563e1abdf --- /dev/null +++ b/apps/api/src/trust-portal/dto/trust-access.dto.ts @@ -0,0 +1,88 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + ArrayNotEmpty, + IsArray, + IsEmail, + IsEnum, + IsInt, + IsNotEmpty, + IsOptional, + IsString, + Min, +} from 'class-validator'; + +export class CreateAccessRequestDto { + @ApiProperty() + @IsString() + @IsNotEmpty() + name: string; + + @ApiProperty() + @IsEmail() + @IsNotEmpty() + email: string; + + @ApiPropertyOptional() + @IsString() + @IsOptional() + company?: string; + + @ApiPropertyOptional() + @IsString() + @IsOptional() + jobTitle?: string; + + @ApiPropertyOptional() + @IsString() + @IsOptional() + purpose?: string; + + @ApiPropertyOptional({ minimum: 1 }) + @IsInt() + @Min(1) + @IsOptional() + requestedDurationDays?: number; +} + +export class ApproveAccessRequestDto { + @ApiPropertyOptional({ minimum: 1 }) + @IsInt() + @Min(1) + @IsOptional() + durationDays?: number; +} + +export class DenyAccessRequestDto { + @ApiProperty() + @IsString() + @IsNotEmpty() + reason: string; +} + +export class RevokeGrantDto { + @ApiProperty() + @IsString() + @IsNotEmpty() + reason: string; +} + +export enum AccessRequestStatusFilter { + UNDER_REVIEW = 'under_review', + APPROVED = 'approved', + DENIED = 'denied', + CANCELED = 'canceled', +} + +export class ListAccessRequestsDto { + @ApiPropertyOptional({ enum: AccessRequestStatusFilter }) + @IsEnum(AccessRequestStatusFilter) + @IsOptional() + status?: AccessRequestStatusFilter; +} + +export class ReclaimAccessDto { + @ApiProperty() + @IsEmail() + @IsNotEmpty() + email: string; +} diff --git a/apps/api/src/trust-portal/email.service.ts b/apps/api/src/trust-portal/email.service.ts new file mode 100644 index 000000000..f38a96401 --- /dev/null +++ b/apps/api/src/trust-portal/email.service.ts @@ -0,0 +1,82 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { + sendEmail, + NdaSigningEmail, + AccessGrantedEmail, + AccessReclaimEmail, +} from '@trycompai/email'; + +@Injectable() +export class TrustEmailService { + private readonly logger = new Logger(TrustEmailService.name); + + async sendNdaSigningEmail(params: { + toEmail: string; + toName: string; + organizationName: string; + ndaSigningLink: string; + }): Promise { + const { toEmail, toName, organizationName, ndaSigningLink } = params; + + const { id } = await sendEmail({ + to: toEmail, + subject: `NDA Signature Required - ${organizationName}`, + react: NdaSigningEmail({ + toName, + organizationName, + ndaSigningLink, + }), + system: true, + }); + + this.logger.log(`NDA signing email sent to ${toEmail} (ID: ${id})`); + } + + async sendAccessGrantedEmail(params: { + toEmail: string; + toName: string; + organizationName: string; + expiresAt: Date; + portalUrl?: string | null; + }): Promise { + const { toEmail, toName, organizationName, expiresAt, portalUrl } = params; + + const { id } = await sendEmail({ + to: toEmail, + subject: `Access Granted - ${organizationName}`, + react: AccessGrantedEmail({ + toName, + organizationName, + expiresAt, + portalUrl, + }), + system: true, + }); + + this.logger.log(`Access granted email sent to ${toEmail} (ID: ${id})`); + } + + async sendAccessReclaimEmail(params: { + toEmail: string; + toName: string; + organizationName: string; + accessLink: string; + expiresAt: Date; + }): Promise { + const { toEmail, toName, organizationName, accessLink, expiresAt } = params; + + const { id } = await sendEmail({ + to: toEmail, + subject: `Access Your Compliance Data - ${organizationName}`, + react: AccessReclaimEmail({ + toName, + organizationName, + accessLink, + expiresAt, + }), + system: true, + }); + + this.logger.log(`Access reclaim email sent to ${toEmail} (ID: ${id})`); + } +} diff --git a/apps/api/src/trust-portal/nda-pdf.service.ts b/apps/api/src/trust-portal/nda-pdf.service.ts new file mode 100644 index 000000000..4b5de879c --- /dev/null +++ b/apps/api/src/trust-portal/nda-pdf.service.ts @@ -0,0 +1,246 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { PDFDocument, rgb, StandardFonts, degrees } from 'pdf-lib'; +import { AttachmentsService } from '../attachments/attachments.service'; + +@Injectable() +export class NdaPdfService { + private readonly logger = new Logger(NdaPdfService.name); + + constructor(private readonly attachmentsService: AttachmentsService) {} + + async generateNdaPdf(params: { + organizationName: string; + signerName: string; + signerEmail: string; + agreementId: string; + }): Promise { + const { organizationName, signerName, signerEmail, agreementId } = params; + + const pdfDoc = await PDFDocument.create(); + const helveticaBold = await pdfDoc.embedFont(StandardFonts.HelveticaBold); + const helvetica = await pdfDoc.embedFont(StandardFonts.Helvetica); + + const page = pdfDoc.addPage([595, 842]); + const { width, height } = page.getSize(); + + const margin = 50; + let yPosition = height - margin; + + page.drawText('NON-DISCLOSURE AGREEMENT', { + x: margin, + y: yPosition, + size: 18, + font: helveticaBold, + color: rgb(0, 0, 0), + }); + + yPosition -= 40; + + page.drawText(`Organization: ${organizationName}`, { + x: margin, + y: yPosition, + size: 12, + font: helvetica, + }); + + yPosition -= 20; + + const now = new Date(); + page.drawText(`Date: ${now.toLocaleDateString('en-US')}`, { + x: margin, + y: yPosition, + size: 12, + font: helvetica, + }); + + yPosition -= 40; + + const ndaText = `This Non-Disclosure Agreement ("Agreement") is entered into on ${now.toLocaleDateString('en-US')} between ${organizationName} ("Disclosing Party") and ${signerName} ("Receiving Party"). + +1. CONFIDENTIAL INFORMATION +The Receiving Party acknowledges that they will receive access to confidential compliance documentation and policy materials. + +2. OBLIGATIONS +The Receiving Party agrees to: + a) Maintain all confidential information in strict confidence + b) Not disclose confidential information to any third party without prior written consent + c) Use confidential information solely for evaluation purposes + d) Return or destroy all confidential materials upon request + +3. TERM +This Agreement shall remain in effect for a period of two (2) years from the date of execution. + +4. REMEDIES +The Receiving Party acknowledges that unauthorized disclosure may cause irreparable harm. + +By signing below, the Receiving Party agrees to be bound by the terms of this Agreement.`; + + const lines = this.wrapText(ndaText, width - 2 * margin, helvetica, 11); + for (const line of lines) { + if (yPosition < margin + 100) { + const newPage = pdfDoc.addPage([595, 842]); + yPosition = newPage.getSize().height - margin; + } + page.drawText(line, { + x: margin, + y: yPosition, + size: 11, + font: helvetica, + }); + yPosition -= 15; + } + + yPosition -= 40; + + page.drawText('RECEIVING PARTY:', { + x: margin, + y: yPosition, + size: 12, + font: helveticaBold, + }); + + yPosition -= 30; + + page.drawText(`Name: ${signerName}`, { + x: margin, + y: yPosition, + size: 11, + font: helvetica, + }); + + yPosition -= 20; + + page.drawText(`Email: ${signerEmail}`, { + x: margin, + y: yPosition, + size: 11, + font: helvetica, + }); + + yPosition -= 20; + + page.drawText(`Signed: ${now.toLocaleString('en-US')}`, { + x: margin, + y: yPosition, + size: 11, + font: helvetica, + }); + + await this.addWatermark(pdfDoc, signerName, signerEmail, agreementId); + + const pdfBytes = await pdfDoc.save(); + return Buffer.from(pdfBytes); + } + + private async addWatermark( + pdfDoc: PDFDocument, + name: string, + email: string, + agreementId: string, + ) { + const font = await pdfDoc.embedFont(StandardFonts.HelveticaBold); + const pages = pdfDoc.getPages(); + + const timestamp = new Date().toISOString(); + const watermarkText = `For: ${name} <${email}> | ${timestamp} | ID: ${agreementId}`; + + for (const page of pages) { + const { width, height } = page.getSize(); + const textWidth = font.widthOfTextAtSize(watermarkText, 10); + + page.drawText(watermarkText, { + x: width / 2 - textWidth / 2, + y: height / 2, + size: 10, + font, + color: rgb(0.8, 0.8, 0.8), + opacity: 0.3, + rotate: degrees(-45), + }); + + page.drawText(`Document ID: ${agreementId}`, { + x: 50, + y: 20, + size: 8, + font, + color: rgb(0.5, 0.5, 0.5), + }); + } + } + + private wrapText( + text: string, + maxWidth: number, + font: any, + fontSize: number, + ): string[] { + const paragraphs = text.split('\n'); + const lines: string[] = []; + + for (const paragraph of paragraphs) { + if (!paragraph.trim()) { + lines.push(''); + continue; + } + + const words = paragraph.split(' '); + let currentLine = ''; + + for (const word of words) { + const testLine = currentLine ? `${currentLine} ${word}` : word; + const testWidth = font.widthOfTextAtSize(testLine, fontSize); + + if (testWidth > maxWidth && currentLine) { + lines.push(currentLine); + currentLine = word; + } else { + currentLine = testLine; + } + } + + if (currentLine) { + lines.push(currentLine); + } + } + + return lines; + } + + async uploadNdaPdf( + organizationId: string, + agreementId: string, + pdfBuffer: Buffer, + ): Promise { + const fileName = `nda-${agreementId}-${Date.now()}.pdf`; + + const s3Key = await this.attachmentsService.uploadToS3( + pdfBuffer, + fileName, + 'application/pdf', + organizationId, + 'trust_nda', + agreementId, + ); + + return s3Key; + } + + async getSignedUrl(s3Key: string): Promise { + return this.attachmentsService.getPresignedDownloadUrl(s3Key); + } + + async watermarkExistingPdf( + pdfBuffer: Buffer, + params: { + name: string; + email: string; + docId: string; + }, + ): Promise { + const { name, email, docId } = params; + const pdfDoc = await PDFDocument.load(pdfBuffer); + await this.addWatermark(pdfDoc, name, email, docId); + const pdfBytes = await pdfDoc.save(); + return Buffer.from(pdfBytes); + } +} diff --git a/apps/api/src/trust-portal/policy-pdf-renderer.service.ts b/apps/api/src/trust-portal/policy-pdf-renderer.service.ts new file mode 100644 index 000000000..66bd04b56 --- /dev/null +++ b/apps/api/src/trust-portal/policy-pdf-renderer.service.ts @@ -0,0 +1,429 @@ +import { Injectable } from '@nestjs/common'; +import { jsPDF } from 'jspdf'; + +interface JSONContent { + type: string; + attrs?: Record; + content?: JSONContent[]; + text?: string; + marks?: Array<{ type: string }>; +} + +interface PDFConfig { + doc: jsPDF; + pageWidth: number; + pageHeight: number; + margin: number; + contentWidth: number; + lineHeight: number; + defaultFontSize: number; + yPosition: number; +} + +interface PolicyForPDF { + name: string; + content: any; +} + +@Injectable() +export class PolicyPdfRendererService { + private cleanTextForPDF(text: string): string { + const replacements: { [key: string]: string } = { + '\u2018': "'", + '\u2019': "'", + '\u201C': '"', + '\u201D': '"', + '\u2013': '-', + '\u2014': '-', + '\u2026': '...', + '\u2265': '>=', + '\u2264': '<=', + '\u00B0': 'deg', + '\u00A9': '(c)', + '\u00AE': '(R)', + '\u2122': 'TM', + '\u00A0': ' ', + '\u2022': '•', + '\u00B1': '+/-', + '\u00D7': 'x', + '\u00F7': '/', + '\u2192': '->', + '\u2190': '<-', + '\u2194': '<->', + }; + + let cleanedText = text; + for (const [unicode, replacement] of Object.entries(replacements)) { + cleanedText = cleanedText.replace(new RegExp(unicode, 'g'), replacement); + } + + return cleanedText.replace(/[^\x00-\x7F]/g, (char) => { + const safeChars = + /[àáâãäåæçèéêëìíîïðñòóôõöøùúûüýþÿÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞß]/; + if (safeChars.test(char)) { + return char; + } + const fallbacks: { [key: string]: string } = { + à: 'a', + á: 'a', + â: 'a', + ã: 'a', + ä: 'a', + å: 'a', + æ: 'ae', + è: 'e', + é: 'e', + ê: 'e', + ë: 'e', + ì: 'i', + í: 'i', + î: 'i', + ï: 'i', + ò: 'o', + ó: 'o', + ô: 'o', + õ: 'o', + ö: 'o', + ø: 'o', + ù: 'u', + ú: 'u', + û: 'u', + ü: 'u', + ñ: 'n', + ç: 'c', + ß: 'ss', + ÿ: 'y', + À: 'A', + Á: 'A', + Â: 'A', + Ã: 'A', + Ä: 'A', + Å: 'A', + Æ: 'AE', + È: 'E', + É: 'E', + Ê: 'E', + Ë: 'E', + Ì: 'I', + Í: 'I', + Î: 'I', + Ï: 'I', + Ò: 'O', + Ó: 'O', + Ô: 'O', + Õ: 'O', + Ö: 'O', + Ø: 'O', + Ù: 'U', + Ú: 'U', + Û: 'U', + Ü: 'U', + Ñ: 'N', + Ç: 'C', + Ý: 'Y', + }; + return fallbacks[char] || '?'; + }); + } + + private convertToInternalFormat(content: any[]): JSONContent[] { + return content.map((item) => ({ + type: item.type || 'paragraph', + attrs: item.attrs, + content: item.content + ? this.convertToInternalFormat(item.content) + : undefined, + text: item.text, + marks: item.marks, + })); + } + + private extractTextFromContent(content: JSONContent[]): string { + let text = ''; + for (const node of content) { + if (node.text) { + text += node.text; + } + if (node.content) { + text += this.extractTextFromContent(node.content); + } + } + return text; + } + + private checkPageBreak(config: PDFConfig, requiredSpace: number = 20): void { + if (config.yPosition + requiredSpace > config.pageHeight - config.margin) { + config.doc.addPage(); + config.yPosition = config.margin; + } + } + + private renderFormattedContent( + config: PDFConfig, + text: string, + marks?: Array<{ type: string }>, + ): void { + const cleanText = this.cleanTextForPDF(text); + const isBold = marks?.some((mark) => mark.type === 'bold'); + const isItalic = marks?.some((mark) => mark.type === 'italic'); + + let fontStyle = 'normal'; + if (isBold && isItalic) fontStyle = 'bolditalic'; + else if (isBold) fontStyle = 'bold'; + else if (isItalic) fontStyle = 'italic'; + + config.doc.setFont('helvetica', fontStyle); + const lines = config.doc.splitTextToSize(cleanText, config.contentWidth); + + for (const line of lines) { + this.checkPageBreak(config); + config.doc.text(line, config.margin, config.yPosition); + config.yPosition += config.lineHeight; + } + + config.doc.setFont('helvetica', 'normal'); + } + + private processContent(config: PDFConfig, content: JSONContent[]): void { + for (const node of content) { + switch (node.type) { + case 'heading': + this.checkPageBreak(config, 30); + const level = node.attrs?.level || 1; + const headingSizes: { [key: number]: number } = { + 1: 16, + 2: 14, + 3: 12, + 4: 11, + 5: 10, + 6: 10, + }; + config.doc.setFontSize(headingSizes[level]); + config.doc.setFont('helvetica', 'bold'); + config.doc.setTextColor(0, 0, 0); + + if (node.content) { + const headingText = this.cleanTextForPDF( + this.extractTextFromContent(node.content), + ); + const lines = config.doc.splitTextToSize( + headingText, + config.contentWidth, + ); + for (const line of lines) { + this.checkPageBreak(config); + config.doc.text(line, config.margin, config.yPosition); + config.yPosition += config.lineHeight * 1.2; + } + } + + config.doc.setFontSize(config.defaultFontSize); + config.doc.setFont('helvetica', 'normal'); + config.yPosition += config.lineHeight; + break; + + case 'paragraph': + this.checkPageBreak(config); + if (node.content) { + for (const inline of node.content) { + if (inline.type === 'text' && inline.text) { + this.renderFormattedContent(config, inline.text, inline.marks); + } else if (inline.content) { + this.processContent(config, [inline]); + } + } + } + config.yPosition += config.lineHeight * 0.5; + break; + + case 'bulletList': + case 'orderedList': + if (node.content) { + node.content.forEach((item, index) => { + if (item.type === 'listItem' && item.content) { + this.checkPageBreak(config); + const bullet = + node.type === 'bulletList' ? '•' : `${index + 1}.`; + const itemText = this.cleanTextForPDF( + this.extractTextFromContent(item.content), + ); + const lines = config.doc.splitTextToSize( + itemText, + config.contentWidth - 10, + ); + + config.doc.text(bullet, config.margin, config.yPosition); + + for (let i = 0; i < lines.length; i++) { + this.checkPageBreak(config); + config.doc.text( + lines[i], + config.margin + 10, + config.yPosition, + ); + config.yPosition += config.lineHeight; + } + + if ( + item.content.some( + (n) => n.type === 'bulletList' || n.type === 'orderedList', + ) + ) { + const nestedLists = item.content.filter( + (n) => n.type === 'bulletList' || n.type === 'orderedList', + ); + this.processContent(config, nestedLists); + } + } + }); + } + config.yPosition += config.lineHeight; + break; + + case 'codeBlock': + this.checkPageBreak(config, 30); + config.doc.setFont('courier', 'normal'); + config.doc.setFontSize(9); + + if (node.content) { + const codeText = this.cleanTextForPDF( + this.extractTextFromContent(node.content), + ); + const lines = config.doc.splitTextToSize( + codeText, + config.contentWidth - 10, + ); + + for (const line of lines) { + this.checkPageBreak(config); + config.doc.text(line, config.margin + 5, config.yPosition); + config.yPosition += config.lineHeight; + } + } + + config.doc.setFont('helvetica', 'normal'); + config.doc.setFontSize(config.defaultFontSize); + config.yPosition += config.lineHeight; + break; + + case 'blockquote': + this.checkPageBreak(config, 20); + config.doc.setTextColor(100, 100, 100); + + if (node.content) { + for (const quoteNode of node.content) { + if (quoteNode.content) { + const quoteText = this.cleanTextForPDF( + this.extractTextFromContent(quoteNode.content), + ); + const lines = config.doc.splitTextToSize( + quoteText, + config.contentWidth - 15, + ); + + for (const line of lines) { + this.checkPageBreak(config); + config.doc.text(line, config.margin + 10, config.yPosition); + config.yPosition += config.lineHeight; + } + } + } + } + + config.doc.setTextColor(0, 0, 0); + config.yPosition += config.lineHeight; + break; + + case 'hardBreak': + config.yPosition += config.lineHeight; + break; + + default: + if (node.content) { + this.processContent(config, node.content); + } + } + } + } + + renderPoliciesPdfBuffer( + policies: PolicyForPDF[], + organizationName?: string, + ): Buffer { + const doc = new jsPDF(); + const config: PDFConfig = { + doc, + pageWidth: doc.internal.pageSize.getWidth(), + pageHeight: doc.internal.pageSize.getHeight(), + margin: 20, + contentWidth: doc.internal.pageSize.getWidth() - 40, + lineHeight: 6, + defaultFontSize: 10, + yPosition: 20, + }; + + const documentTitle = organizationName + ? `${organizationName} - All Policies` + : 'All Policies'; + const cleanTitle = this.cleanTextForPDF(documentTitle); + + config.doc.setFontSize(18); + config.doc.setFont('helvetica', 'bold'); + config.doc.text(cleanTitle, config.margin, config.yPosition); + config.yPosition += config.lineHeight * 3; + + policies.forEach((policy, index) => { + config.doc.setTextColor(0, 0, 0); + + if (index > 0) { + config.doc.addPage(); + config.yPosition = config.margin; + } + + if (policy.name) { + const cleanPolicyTitle = this.cleanTextForPDF(policy.name); + config.doc.setFontSize(16); + config.doc.setFont('helvetica', 'bold'); + config.doc.setTextColor(0, 0, 0); + config.doc.text(cleanPolicyTitle, config.margin, config.yPosition); + config.yPosition += config.lineHeight * 2; + } + + if (policy.content) { + let policyContent: JSONContent[]; + if (Array.isArray(policy.content)) { + policyContent = this.convertToInternalFormat(policy.content); + } else if ( + typeof policy.content === 'object' && + policy.content.content + ) { + policyContent = this.convertToInternalFormat(policy.content.content); + } else { + policyContent = []; + } + + config.doc.setFontSize(config.defaultFontSize); + config.doc.setFont('helvetica', 'normal'); + this.processContent(config, policyContent); + } + + config.yPosition += config.lineHeight * 2; + }); + + const totalPages = doc.getNumberOfPages(); + for (let i = 1; i <= totalPages; i++) { + doc.setPage(i); + doc.setFontSize(8); + doc.setTextColor(128, 128, 128); + doc.text( + `Page ${i} of ${totalPages}`, + config.pageWidth / 2, + config.pageHeight - 10, + { align: 'center' }, + ); + } + + const arrayBuffer = doc.output('arraybuffer'); + return Buffer.from(arrayBuffer); + } +} diff --git a/apps/api/src/trust-portal/trust-access.controller.ts b/apps/api/src/trust-portal/trust-access.controller.ts new file mode 100644 index 000000000..323456f55 --- /dev/null +++ b/apps/api/src/trust-portal/trust-access.controller.ts @@ -0,0 +1,421 @@ +import { + Body, + Controller, + Get, + HttpCode, + HttpStatus, + Param, + Post, + Query, + Req, + UnauthorizedException, + UseGuards, +} from '@nestjs/common'; +import { + ApiHeader, + ApiOperation, + ApiResponse, + ApiSecurity, + ApiTags, +} from '@nestjs/swagger'; +import { HybridAuthGuard } from '../auth/hybrid-auth.guard'; +import { OrganizationId } from '../auth/auth-context.decorator'; +import { AuthenticatedRequest } from '../auth/types'; +import { + ApproveAccessRequestDto, + CreateAccessRequestDto, + DenyAccessRequestDto, + ListAccessRequestsDto, + ReclaimAccessDto, + RevokeGrantDto, +} from './dto/trust-access.dto'; +import { SignNdaDto } from './dto/nda.dto'; +import { TrustAccessService } from './trust-access.service'; + +@ApiTags('Trust Access') +@Controller({ path: 'trust-access', version: '1' }) +export class TrustAccessController { + constructor(private readonly trustAccessService: TrustAccessService) {} + + @Post(':friendlyUrl/requests') + @HttpCode(HttpStatus.CREATED) + @ApiOperation({ + summary: 'Submit data access request', + description: + 'External users submit request for data access from trust site', + }) + @ApiResponse({ + status: HttpStatus.CREATED, + description: 'Access request created and sent for review', + }) + async createAccessRequest( + @Param('friendlyUrl') friendlyUrl: string, + @Body() dto: CreateAccessRequestDto, + @Req() req: Request, + ) { + const ipAddress = + (req as any).ip ?? (req as any).socket.remoteAddress ?? undefined; + const userAgent = + typeof req.headers['user-agent'] === 'string' + ? req.headers['user-agent'] + : undefined; + + return this.trustAccessService.createAccessRequest( + friendlyUrl, + dto, + ipAddress, + userAgent, + ); + } + + @Get('admin/requests') + @UseGuards(HybridAuthGuard) + @ApiSecurity('apikey') + @ApiHeader({ + name: 'X-Organization-Id', + description: 'Organization ID', + required: true, + }) + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: 'List access requests', + description: 'Get all access requests for organization', + }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Access requests retrieved', + }) + async listAccessRequests( + @OrganizationId() organizationId: string, + @Query() dto: ListAccessRequestsDto, + ) { + return this.trustAccessService.listAccessRequests(organizationId, dto); + } + + @Get('admin/requests/:id') + @UseGuards(HybridAuthGuard) + @ApiSecurity('apikey') + @ApiHeader({ + name: 'X-Organization-Id', + description: 'Organization ID', + required: true, + }) + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: 'Get access request details', + description: 'Get detailed information about a specific access request', + }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Request details returned', + }) + async getAccessRequest( + @OrganizationId() organizationId: string, + @Param('id') requestId: string, + ) { + return this.trustAccessService.getAccessRequest(organizationId, requestId); + } + + @Post('admin/requests/:id/approve') + @UseGuards(HybridAuthGuard) + @ApiSecurity('apikey') + @ApiHeader({ + name: 'X-Organization-Id', + description: 'Organization ID', + required: true, + }) + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: 'Approve access request', + description: 'Approve request and create time-limited grant', + }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Request approved successfully', + }) + async approveRequest( + @OrganizationId() organizationId: string, + @Param('id') requestId: string, + @Body() dto: ApproveAccessRequestDto, + @Req() req: Request, + ) { + const userId = (req as any).userId; + if (!userId) { + throw new UnauthorizedException('User ID is required'); + } + const memberId = await this.trustAccessService.getMemberIdFromUserId( + userId, + organizationId, + ); + return this.trustAccessService.approveRequest( + organizationId, + requestId, + dto, + memberId, + ); + } + + @Post('admin/requests/:id/deny') + @UseGuards(HybridAuthGuard) + @ApiSecurity('apikey') + @ApiHeader({ + name: 'X-Organization-Id', + description: 'Organization ID', + required: true, + }) + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: 'Deny access request', + description: 'Reject access request with reason', + }) + @ApiResponse({ status: HttpStatus.OK, description: 'Request denied' }) + async denyRequest( + @OrganizationId() organizationId: string, + @Param('id') requestId: string, + @Body() dto: DenyAccessRequestDto, + @Req() req: Request, + ) { + const userId = (req as any).userId; + if (!userId) { + throw new UnauthorizedException('User ID is required'); + } + const memberId = await this.trustAccessService.getMemberIdFromUserId( + userId, + organizationId, + ); + return this.trustAccessService.denyRequest( + organizationId, + requestId, + dto, + memberId, + ); + } + + @Get('admin/grants') + @UseGuards(HybridAuthGuard) + @ApiSecurity('apikey') + @ApiHeader({ + name: 'X-Organization-Id', + description: 'Organization ID', + required: true, + }) + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: 'List access grants', + description: 'Get all active and expired grants', + }) + @ApiResponse({ status: HttpStatus.OK, description: 'Grants retrieved' }) + async listGrants(@OrganizationId() organizationId: string) { + return this.trustAccessService.listGrants(organizationId); + } + + @Post('admin/grants/:id/revoke') + @UseGuards(HybridAuthGuard) + @ApiSecurity('apikey') + @ApiHeader({ + name: 'X-Organization-Id', + description: 'Organization ID', + required: true, + }) + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: 'Revoke access grant', + description: 'Immediately revoke active grant', + }) + @ApiResponse({ status: HttpStatus.OK, description: 'Grant revoked' }) + async revokeGrant( + @OrganizationId() organizationId: string, + @Param('id') grantId: string, + @Body() dto: RevokeGrantDto, + @Req() req: Request, + ) { + const userId = (req as any).userId; + if (!userId) { + throw new UnauthorizedException('User ID is required'); + } + const memberId = await this.trustAccessService.getMemberIdFromUserId( + userId, + organizationId, + ); + return this.trustAccessService.revokeGrant( + organizationId, + grantId, + dto, + memberId, + ); + } + + @Get('nda/:token') + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: 'Get NDA details by token', + description: 'Fetch NDA agreement details for signing', + }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'NDA details returned', + }) + async getNda(@Param('token') token: string) { + return this.trustAccessService.getNdaByToken(token); + } + + @Post('nda/:token/preview-nda') + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: 'Preview NDA by token', + description: 'Generate preview NDA PDF for external user before signing', + }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Preview NDA generated', + }) + async previewNdaByToken(@Param('token') token: string) { + return this.trustAccessService.previewNdaByToken(token); + } + + @Post('nda/:token/sign') + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: 'Sign NDA', + description: + 'Sign NDA agreement, generate watermarked PDF, and create access grant', + }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'NDA signed successfully', + }) + async signNda( + @Param('token') token: string, + @Body() dto: SignNdaDto, + @Req() req: Request, + ) { + if (!dto.accept) { + throw new Error('You must accept the NDA to proceed'); + } + + const ipAddress = + (req as any).ip ?? (req as any).socket.remoteAddress ?? undefined; + const userAgent = + typeof req.headers['user-agent'] === 'string' + ? req.headers['user-agent'] + : undefined; + + return this.trustAccessService.signNda( + token, + dto.name, + dto.email, + ipAddress, + userAgent, + ); + } + + @Post('admin/requests/:id/resend-nda') + @UseGuards(HybridAuthGuard) + @ApiSecurity('apikey') + @ApiHeader({ + name: 'X-Organization-Id', + description: 'Organization ID', + required: true, + }) + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: 'Resend NDA email', + description: 'Resend NDA signing email to requester', + }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'NDA email resent', + }) + async resendNda( + @OrganizationId() organizationId: string, + @Param('id') requestId: string, + ) { + return this.trustAccessService.resendNda(organizationId, requestId); + } + + @Post('admin/requests/:id/preview-nda') + @UseGuards(HybridAuthGuard) + @ApiSecurity('apikey') + @ApiHeader({ + name: 'X-Organization-Id', + description: 'Organization ID', + required: true, + }) + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: 'Preview NDA PDF', + description: + 'Generate preview NDA with watermark and save to S3 with preview-* prefix', + }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Preview NDA generated', + }) + async previewNda( + @OrganizationId() organizationId: string, + @Param('id') requestId: string, + ) { + return this.trustAccessService.previewNda(organizationId, requestId); + } + + @Post(':friendlyUrl/reclaim') + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: 'Reclaim access', + description: + 'Generate access link for users with existing grants to redownload data', + }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Access link sent to email', + }) + async reclaimAccess( + @Param('friendlyUrl') friendlyUrl: string, + @Body() dto: ReclaimAccessDto, + ) { + return this.trustAccessService.reclaimAccess(friendlyUrl, dto.email); + } + + @Get('access/:token') + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: 'Get grant data by access token', + description: 'Retrieve compliance data using access token', + }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Grant data returned', + }) + async getGrantByAccessToken(@Param('token') token: string) { + return this.trustAccessService.getGrantByAccessToken(token); + } + + @Get('access/:token/policies') + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: 'List policies by access token', + description: 'Get list of published policies available for download', + }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Policies list returned', + }) + async getPoliciesByAccessToken(@Param('token') token: string) { + return this.trustAccessService.getPoliciesByAccessToken(token); + } + + @Get('access/:token/policies/download-all') + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: 'Download all policies as watermarked PDF', + description: + 'Generate combined PDF from all published policy content with watermark', + }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Download URL for watermarked PDF returned', + }) + async downloadAllPolicies(@Param('token') token: string) { + return this.trustAccessService.downloadAllPoliciesByAccessToken(token); + } +} diff --git a/apps/api/src/trust-portal/trust-access.service.ts b/apps/api/src/trust-portal/trust-access.service.ts new file mode 100644 index 000000000..f95b883d9 --- /dev/null +++ b/apps/api/src/trust-portal/trust-access.service.ts @@ -0,0 +1,1050 @@ +import { + BadRequestException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { db } from '@trycompai/db'; +import { randomBytes } from 'crypto'; +import { + ApproveAccessRequestDto, + CreateAccessRequestDto, + DenyAccessRequestDto, + ListAccessRequestsDto, + RevokeGrantDto, +} from './dto/trust-access.dto'; +import { TrustEmailService } from './email.service'; +import { NdaPdfService } from './nda-pdf.service'; +import { AttachmentsService } from '../attachments/attachments.service'; +import { PolicyPdfRendererService } from './policy-pdf-renderer.service'; + +@Injectable() +export class TrustAccessService { + private readonly TRUST_APP_URL = + process.env.TRUST_APP_URL || + process.env.BASE_URL || + 'http://localhost:3008'; + + private generateToken(length: number): string { + return randomBytes(length).toString('base64url').slice(0, length); + } + + constructor( + private readonly ndaPdfService: NdaPdfService, + private readonly emailService: TrustEmailService, + private readonly attachmentsService: AttachmentsService, + private readonly pdfRendererService: PolicyPdfRendererService, + ) { + if ( + !process.env.TRUST_APP_URL && + !process.env.BASE_URL && + process.env.NODE_ENV === 'production' + ) { + throw new Error('TRUST_APP_URL or BASE_URL must be set in production'); + } + } + + async getMemberIdFromUserId( + userId: string, + organizationId: string, + ): Promise { + const member = await db.member.findFirst({ + where: { + userId, + organizationId, + }, + select: { + id: true, + }, + }); + return member?.id; + } + + async createAccessRequest( + friendlyUrl: string, + dto: CreateAccessRequestDto, + ipAddress: string | undefined, + userAgent: string | undefined, + ) { + const trust = await db.trust.findUnique({ + where: { friendlyUrl }, + include: { organization: true }, + }); + + if (!trust || trust.status !== 'published') { + throw new NotFoundException('Trust site not found or not published'); + } + + // Check if the email already has an active grant + const existingGrant = await db.trustAccessGrant.findFirst({ + where: { + subjectEmail: dto.email, + status: 'active', + expiresAt: { + gt: new Date(), + }, + accessRequest: { + organizationId: trust.organizationId, + }, + }, + include: { + accessRequest: true, + }, + }); + + if (existingGrant) { + return { + id: existingGrant.id, + status: 'already_approved', + message: 'You already have active access', + grant: { + expiresAt: existingGrant.expiresAt, + }, + }; + } + + const existingRequest = await db.trustAccessRequest.findFirst({ + where: { + organizationId: trust.organizationId, + email: dto.email, + status: 'under_review', + }, + }); + + if (existingRequest) { + throw new BadRequestException( + 'You already have a pending request for this organization', + ); + } + + const request = await db.trustAccessRequest.create({ + data: { + organizationId: trust.organizationId, + name: dto.name, + email: dto.email, + company: dto.company, + jobTitle: dto.jobTitle, + purpose: dto.purpose, + requestedDurationDays: dto.requestedDurationDays, + status: 'under_review', + ipAddress, + userAgent, + }, + }); + + return { + id: request.id, + status: request.status, + message: 'Access request submitted for review', + }; + } + + async listAccessRequests(organizationId: string, dto: ListAccessRequestsDto) { + const where = { + organizationId, + ...(dto.status && { status: dto.status }), + }; + + const requests = await db.trustAccessRequest.findMany({ + where, + include: { + reviewer: { + select: { + id: true, + user: { select: { name: true, email: true } }, + }, + }, + grant: { + select: { + id: true, + status: true, + expiresAt: true, + }, + }, + }, + orderBy: { createdAt: 'desc' }, + }); + + return requests; + } + + async getAccessRequest(organizationId: string, requestId: string) { + const request = await db.trustAccessRequest.findFirst({ + where: { + id: requestId, + organizationId, + }, + include: { + reviewer: { + select: { + id: true, + user: { select: { name: true, email: true } }, + }, + }, + grant: true, + }, + }); + + if (!request) { + throw new NotFoundException('Access request not found'); + } + + return request; + } + + async approveRequest( + organizationId: string, + requestId: string, + dto: ApproveAccessRequestDto, + memberId?: string, + ) { + const request = await db.trustAccessRequest.findFirst({ + where: { + id: requestId, + organizationId, + }, + include: { + organization: true, + }, + }); + + if (!request) { + throw new NotFoundException('Access request not found'); + } + + if (request.status !== 'under_review') { + throw new BadRequestException( + `Request is already ${request.status}, cannot approve`, + ); + } + + const durationDays = + dto.durationDays || request.requestedDurationDays || 30; + + const member = memberId + ? await db.member.findFirst({ + where: { id: memberId, organizationId }, + select: { id: true, userId: true }, + }) + : null; + + if (!member) { + throw new BadRequestException('Invalid member ID'); + } + + const signToken = this.generateToken(32); + const signTokenExpiresAt = new Date(); + signTokenExpiresAt.setDate(signTokenExpiresAt.getDate() + 7); + + const result = await db.$transaction(async (tx) => { + const ndaAgreement = await tx.trustNDAAgreement.create({ + data: { + organizationId, + accessRequestId: requestId, + signToken, + signTokenExpiresAt, + status: 'pending', + }, + }); + + const updatedRequest = await tx.trustAccessRequest.update({ + where: { id: requestId }, + data: { + status: 'approved', + reviewerMemberId: member.id, + reviewedAt: new Date(), + requestedDurationDays: durationDays, + }, + }); + + await tx.auditLog.create({ + data: { + organizationId, + userId: member.userId, + memberId: member.id, + entityType: 'trust', + entityId: requestId, + description: `Access request approved for ${request.email}, NDA signature required`, + data: { + requestId, + ndaAgreementId: ndaAgreement.id, + durationDays, + }, + }, + }); + + return { request: updatedRequest, ndaAgreement, durationDays }; + }); + + const ndaSigningLink = `${this.TRUST_APP_URL}/nda/${result.ndaAgreement.signToken}`; + + await this.emailService.sendNdaSigningEmail({ + toEmail: request.email, + toName: request.name, + organizationName: request.organization.name, + ndaSigningLink, + }); + + return { + request: result.request, + ndaAgreement: result.ndaAgreement, + message: 'NDA signing email sent', + }; + } + + async denyRequest( + organizationId: string, + requestId: string, + dto: DenyAccessRequestDto, + memberId?: string, + ) { + const request = await db.trustAccessRequest.findFirst({ + where: { + id: requestId, + organizationId, + }, + }); + + if (!request) { + throw new NotFoundException('Access request not found'); + } + + if (request.status !== 'under_review') { + throw new BadRequestException( + `Request is already ${request.status}, cannot deny`, + ); + } + + const member = memberId + ? await db.member.findFirst({ + where: { id: memberId, organizationId }, + select: { id: true, userId: true }, + }) + : null; + + if (!member) { + throw new BadRequestException('Invalid member ID'); + } + + const updatedRequest = await db.trustAccessRequest.update({ + where: { id: requestId }, + data: { + status: 'denied', + reviewerMemberId: member.id, + reviewedAt: new Date(), + decisionReason: dto.reason, + }, + }); + + await db.auditLog.create({ + data: { + organizationId, + userId: member.userId, + memberId: member.id, + entityType: 'trust', + entityId: requestId, + description: `Access request denied for ${request.email}`, + data: { + requestId, + reason: dto.reason, + }, + }, + }); + + return updatedRequest; + } + + async listGrants(organizationId: string) { + const grants = await db.trustAccessGrant.findMany({ + where: { + accessRequest: { + organizationId, + }, + }, + include: { + accessRequest: { + select: { + name: true, + email: true, + company: true, + purpose: true, + }, + }, + issuedBy: { + select: { + user: { select: { name: true, email: true } }, + }, + }, + revokedBy: { + select: { + user: { select: { name: true, email: true } }, + }, + }, + }, + orderBy: { createdAt: 'desc' }, + }); + + return grants; + } + + async revokeGrant( + organizationId: string, + grantId: string, + dto: RevokeGrantDto, + memberId?: string, + ) { + const grant = await db.trustAccessGrant.findFirst({ + where: { + id: grantId, + accessRequest: { + organizationId, + }, + }, + include: { + accessRequest: { + select: { + organizationId: true, + }, + }, + }, + }); + + if (!grant) { + throw new NotFoundException('Grant not found'); + } + + if (grant.status !== 'active') { + throw new BadRequestException(`Grant is already ${grant.status}`); + } + + const member = memberId + ? await db.member.findFirst({ + where: { id: memberId, organizationId }, + select: { id: true, userId: true }, + }) + : null; + + if (!member) { + throw new BadRequestException('Invalid member ID'); + } + + const updatedGrant = await db.trustAccessGrant.update({ + where: { id: grantId }, + data: { + status: 'revoked', + revokedAt: new Date(), + revokedByMemberId: member.id, + revokeReason: dto.reason, + }, + }); + + // Void the associated NDA agreement if it exists + await db.trustNDAAgreement.updateMany({ + where: { grantId }, + data: { status: 'void' }, + }); + + await db.auditLog.create({ + data: { + organizationId: grant.accessRequest.organizationId, + userId: member.userId, + memberId: member.id, + entityType: 'trust', + entityId: grantId, + description: `Access grant revoked for ${grant.subjectEmail}`, + data: { + grantId, + reason: dto.reason, + }, + }, + }); + + return updatedGrant; + } + + async getNdaByToken(token: string) { + const nda = await db.trustNDAAgreement.findUnique({ + where: { signToken: token }, + include: { + accessRequest: { + include: { + organization: true, + }, + }, + }, + }); + + if (!nda) { + throw new NotFoundException('NDA agreement not found'); + } + + if (nda.signTokenExpiresAt < new Date()) { + throw new BadRequestException('NDA signing link has expired'); + } + + if (nda.status === 'void') { + throw new BadRequestException( + 'This NDA has been revoked and is no longer valid', + ); + } + + if (nda.status !== 'pending') { + throw new BadRequestException('NDA has already been signed'); + } + + return { + id: nda.id, + organizationName: nda.accessRequest.organization.name, + requesterName: nda.accessRequest.name, + requesterEmail: nda.accessRequest.email, + expiresAt: nda.signTokenExpiresAt, + }; + } + + async signNda( + token: string, + signerName: string, + signerEmail: string, + ipAddress: string | undefined, + userAgent: string | undefined, + ) { + const nda = await db.trustNDAAgreement.findUnique({ + where: { signToken: token }, + include: { + accessRequest: { + include: { + organization: true, + }, + }, + grant: true, + }, + }); + + if (!nda) { + throw new NotFoundException('NDA agreement not found'); + } + + if (nda.signTokenExpiresAt < new Date()) { + throw new BadRequestException('NDA signing link has expired'); + } + + if (nda.status === 'void') { + throw new BadRequestException( + 'This NDA has been revoked and is no longer valid', + ); + } + + if (nda.status === 'signed' && nda.grant) { + const pdfUrl = nda.pdfSignedKey + ? await this.ndaPdfService.getSignedUrl(nda.pdfSignedKey) + : null; + + const accessToken = nda.grant.accessToken || this.generateToken(32); + const accessTokenExpiresAt = + nda.grant.accessTokenExpiresAt || + new Date(Date.now() + 24 * 60 * 60 * 1000); + + if (!nda.grant.accessToken) { + await db.trustAccessGrant.update({ + where: { id: nda.grant.id }, + data: { accessToken, accessTokenExpiresAt }, + }); + } + + const trust = await db.trust.findUnique({ + where: { organizationId: nda.organizationId }, + select: { friendlyUrl: true }, + }); + + const portalUrl = trust?.friendlyUrl + ? `${this.TRUST_APP_URL}/${trust.friendlyUrl}/access/${accessToken}` + : null; + + return { + message: 'NDA already signed', + grant: nda.grant, + pdfDownloadUrl: pdfUrl, + portalUrl, + expiresAt: nda.grant.expiresAt, + }; + } + + if (nda.status !== 'pending') { + throw new BadRequestException('NDA has already been signed'); + } + + const pdfBuffer = await this.ndaPdfService.generateNdaPdf({ + organizationName: nda.accessRequest.organization.name, + signerName, + signerEmail, + agreementId: nda.id, + }); + + const pdfKey = await this.ndaPdfService.uploadNdaPdf( + nda.organizationId, + nda.id, + pdfBuffer, + ); + + const durationDays = nda.accessRequest.requestedDurationDays || 30; + const expiresAt = new Date(); + expiresAt.setDate(expiresAt.getDate() + durationDays); + + const accessToken = this.generateToken(32); + const accessTokenExpiresAt = new Date(); + accessTokenExpiresAt.setHours(accessTokenExpiresAt.getHours() + 24); + + const result = await db.$transaction(async (tx) => { + const grant = await tx.trustAccessGrant.create({ + data: { + accessRequestId: nda.accessRequestId, + subjectEmail: signerEmail, + expiresAt, + accessToken, + accessTokenExpiresAt, + }, + }); + + const updatedNda = await tx.trustNDAAgreement.update({ + where: { id: nda.id }, + data: { + status: 'signed', + signerName, + signerEmail, + signedAt: new Date(), + pdfSignedKey: pdfKey, + grantId: grant.id, + ipAddress, + userAgent, + }, + }); + + return { grant, updatedNda }; + }); + + const trust = await db.trust.findUnique({ + where: { organizationId: nda.organizationId }, + select: { friendlyUrl: true }, + }); + + const portalUrl = trust?.friendlyUrl + ? `${this.TRUST_APP_URL}/${trust.friendlyUrl}/access/${accessToken}` + : null; + + await this.emailService.sendAccessGrantedEmail({ + toEmail: signerEmail, + toName: signerName, + organizationName: nda.accessRequest.organization.name, + expiresAt: result.grant.expiresAt, + portalUrl, + }); + + const pdfUrl = await this.ndaPdfService.getSignedUrl(pdfKey); + + return { + message: 'NDA signed successfully', + grant: result.grant, + pdfDownloadUrl: pdfUrl, + portalUrl, + expiresAt: result.grant.expiresAt, + }; + } + + async resendNda(organizationId: string, requestId: string) { + const request = await db.trustAccessRequest.findFirst({ + where: { + id: requestId, + organizationId, + }, + include: { + organization: true, + ndaAgreements: { + where: { status: 'pending' }, + orderBy: { createdAt: 'desc' }, + take: 1, + }, + }, + }); + + if (!request) { + throw new NotFoundException('Access request not found'); + } + + if (request.status !== 'approved') { + throw new BadRequestException('Request must be approved first'); + } + + const pendingNda = request.ndaAgreements[0]; + if (!pendingNda) { + throw new BadRequestException('No pending NDA agreement found'); + } + + const newExpiresAt = new Date(); + newExpiresAt.setDate(newExpiresAt.getDate() + 7); + + await db.trustNDAAgreement.update({ + where: { id: pendingNda.id }, + data: { signTokenExpiresAt: newExpiresAt }, + }); + + const ndaSigningLink = `${this.TRUST_APP_URL}/nda/${pendingNda.signToken}`; + + await this.emailService.sendNdaSigningEmail({ + toEmail: request.email, + toName: request.name, + organizationName: request.organization.name, + ndaSigningLink, + }); + + return { + message: 'NDA signing email resent', + }; + } + + async previewNda(organizationId: string, requestId: string) { + const request = await db.trustAccessRequest.findFirst({ + where: { + id: requestId, + organizationId, + }, + include: { + organization: true, + }, + }); + + if (!request) { + throw new NotFoundException('Access request not found'); + } + + const previewId = this.generateToken(16); + const pdfBuffer = await this.ndaPdfService.generateNdaPdf({ + organizationName: request.organization.name, + signerName: request.name, + signerEmail: request.email, + agreementId: `preview-${previewId}`, + }); + + const fileName = `preview-nda-${requestId}-${Date.now()}.pdf`; + const s3Key = await this.attachmentsService.uploadToS3( + pdfBuffer, + fileName, + 'application/pdf', + organizationId, + 'trust_nda', + `preview-${previewId}`, + ); + + const pdfUrl = await this.ndaPdfService.getSignedUrl(s3Key); + + return { + message: 'Preview NDA generated', + previewId, + s3Key, + pdfDownloadUrl: pdfUrl, + }; + } + + async previewNdaByToken(token: string) { + const nda = await db.trustNDAAgreement.findUnique({ + where: { signToken: token }, + include: { + accessRequest: { + include: { + organization: true, + }, + }, + }, + }); + + if (!nda) { + throw new NotFoundException('NDA not found or token expired'); + } + + if (nda.signTokenExpiresAt < new Date()) { + throw new BadRequestException('NDA signing link has expired'); + } + + const previewId = this.generateToken(16); + const pdfBuffer = await this.ndaPdfService.generateNdaPdf({ + organizationName: nda.accessRequest.organization.name, + signerName: nda.accessRequest.name, + signerEmail: nda.accessRequest.email, + agreementId: `preview-${previewId}`, + }); + + const fileName = `preview-nda-${nda.id}-${Date.now()}.pdf`; + const s3Key = await this.attachmentsService.uploadToS3( + pdfBuffer, + fileName, + 'application/pdf', + nda.organizationId, + 'trust_nda', + `preview-${previewId}`, + ); + + const pdfUrl = await this.ndaPdfService.getSignedUrl(s3Key); + + return { + message: 'Preview NDA generated', + previewId, + s3Key, + pdfDownloadUrl: pdfUrl, + }; + } + + async reclaimAccess(friendlyUrl: string, email: string) { + const trust = await db.trust.findUnique({ + where: { friendlyUrl }, + include: { organization: true }, + }); + + if (!trust || trust.status !== 'published') { + throw new NotFoundException('Trust site not found or not published'); + } + + const grant = await db.trustAccessGrant.findFirst({ + where: { + subjectEmail: email, + status: 'active', + expiresAt: { + gt: new Date(), + }, + accessRequest: { + organizationId: trust.organizationId, + }, + }, + include: { + accessRequest: { + include: { + organization: true, + }, + }, + ndaAgreement: true, + }, + }); + + if (!grant) { + throw new NotFoundException( + 'No active access grant found for this email', + ); + } + + let accessToken = grant.accessToken; + let accessTokenExpiresAt = grant.accessTokenExpiresAt; + + if ( + !accessToken || + !accessTokenExpiresAt || + accessTokenExpiresAt < new Date() + ) { + accessToken = this.generateToken(32); + accessTokenExpiresAt = new Date(); + accessTokenExpiresAt.setHours(accessTokenExpiresAt.getHours() + 24); + + await db.trustAccessGrant.update({ + where: { id: grant.id }, + data: { + accessToken, + accessTokenExpiresAt, + }, + }); + } + + const accessLink = `${this.TRUST_APP_URL}/${friendlyUrl}/access/${accessToken}`; + + await this.emailService.sendAccessReclaimEmail({ + toEmail: email, + toName: grant.accessRequest.name, + organizationName: grant.accessRequest.organization.name, + accessLink, + expiresAt: grant.expiresAt, + }); + + return { + message: 'Access link sent to your email', + accessLink, + expiresAt: accessTokenExpiresAt, + }; + } + + async getGrantByAccessToken(token: string) { + const grant = await db.trustAccessGrant.findUnique({ + where: { accessToken: token }, + include: { + accessRequest: { + include: { + organization: true, + }, + }, + ndaAgreement: true, + }, + }); + + if (!grant) { + throw new NotFoundException('Invalid access token'); + } + + if (grant.status !== 'active') { + throw new BadRequestException('Access grant is not active'); + } + + if (grant.expiresAt < new Date()) { + throw new BadRequestException('Access grant has expired'); + } + + if ( + !grant.accessTokenExpiresAt || + grant.accessTokenExpiresAt < new Date() + ) { + throw new BadRequestException('Access token has expired'); + } + + const ndaPdfUrl = grant.ndaAgreement?.pdfSignedKey + ? await this.ndaPdfService.getSignedUrl(grant.ndaAgreement.pdfSignedKey) + : null; + + return { + organizationName: grant.accessRequest.organization.name, + expiresAt: grant.expiresAt, + subjectEmail: grant.subjectEmail, + ndaPdfUrl, + }; + } + + private async validateAccessToken(token: string) { + const grant = await db.trustAccessGrant.findUnique({ + where: { accessToken: token }, + include: { + accessRequest: { + include: { + organization: true, + }, + }, + }, + }); + + if (!grant) { + throw new NotFoundException('Invalid access token'); + } + + if (grant.status !== 'active') { + throw new BadRequestException('Access grant is not active'); + } + + if (grant.expiresAt < new Date()) { + throw new BadRequestException('Access grant has expired'); + } + + if ( + !grant.accessTokenExpiresAt || + grant.accessTokenExpiresAt < new Date() + ) { + throw new BadRequestException('Access token has expired'); + } + + return grant; + } + + async getPoliciesByAccessToken(token: string) { + const grant = await db.trustAccessGrant.findUnique({ + where: { accessToken: token }, + include: { + accessRequest: { + include: { + organization: true, + }, + }, + }, + }); + + if (!grant) { + throw new NotFoundException('Invalid access token'); + } + + if (grant.status !== 'active') { + throw new BadRequestException('Access grant is not active'); + } + + if (grant.expiresAt < new Date()) { + throw new BadRequestException('Access grant has expired'); + } + + if ( + !grant.accessTokenExpiresAt || + grant.accessTokenExpiresAt < new Date() + ) { + throw new BadRequestException('Access token has expired'); + } + + const policies = await db.policy.findMany({ + where: { + organizationId: grant.accessRequest.organizationId, + status: 'published', + isArchived: false, + }, + select: { + id: true, + name: true, + description: true, + lastPublishedAt: true, + updatedAt: true, + }, + orderBy: [{ lastPublishedAt: 'desc' }, { updatedAt: 'desc' }], + }); + + return policies; + } + + async downloadAllPoliciesByAccessToken(token: string) { + const grant = await this.validateAccessToken(token); + + const policies = await db.policy.findMany({ + where: { + organizationId: grant.accessRequest.organizationId, + status: 'published', + isArchived: false, + }, + select: { + id: true, + name: true, + content: true, + }, + orderBy: [{ lastPublishedAt: 'desc' }, { updatedAt: 'desc' }], + }); + + if (policies.length === 0) { + throw new NotFoundException('No published policies available'); + } + + const pdfBuffer = this.pdfRendererService.renderPoliciesPdfBuffer( + policies.map((p) => ({ + name: p.name, + content: p.content, + })), + grant.accessRequest.organization.name, + ); + + const bundleDocId = `bundle-${grant.id}-${Date.now()}`; + const watermarked = await this.ndaPdfService.watermarkExistingPdf( + pdfBuffer, + { + name: grant.accessRequest.name, + email: grant.subjectEmail, + docId: bundleDocId, + }, + ); + + const key = await this.attachmentsService.uploadToS3( + watermarked, + `policies-bundle-grant-${grant.id}-${Date.now()}.pdf`, + 'application/pdf', + grant.accessRequest.organizationId, + 'trust_policy_downloads', + `${grant.id}`, + ); + + const downloadUrl = + await this.attachmentsService.getPresignedDownloadUrl(key); + + return { name: 'All Policies', downloadUrl }; + } +} diff --git a/apps/api/src/trust-portal/trust-portal.module.ts b/apps/api/src/trust-portal/trust-portal.module.ts index a5d011f76..a9764f9bc 100644 --- a/apps/api/src/trust-portal/trust-portal.module.ts +++ b/apps/api/src/trust-portal/trust-portal.module.ts @@ -1,12 +1,24 @@ import { Module } from '@nestjs/common'; +import { AttachmentsModule } from '../attachments/attachments.module'; import { AuthModule } from '../auth/auth.module'; +import { TrustEmailService } from './email.service'; +import { NdaPdfService } from './nda-pdf.service'; +import { PolicyPdfRendererService } from './policy-pdf-renderer.service'; +import { TrustAccessController } from './trust-access.controller'; +import { TrustAccessService } from './trust-access.service'; import { TrustPortalController } from './trust-portal.controller'; import { TrustPortalService } from './trust-portal.service'; @Module({ - imports: [AuthModule], - controllers: [TrustPortalController], - providers: [TrustPortalService], - exports: [TrustPortalService], + imports: [AuthModule, AttachmentsModule], + controllers: [TrustPortalController, TrustAccessController], + providers: [ + TrustPortalService, + TrustAccessService, + NdaPdfService, + TrustEmailService, + PolicyPdfRendererService, + ], + exports: [TrustPortalService, TrustAccessService], }) export class TrustPortalModule {} diff --git a/apps/api/src/vendors/dto/create-vendor.dto.ts b/apps/api/src/vendors/dto/create-vendor.dto.ts index 83d76d338..c3b4c6169 100644 --- a/apps/api/src/vendors/dto/create-vendor.dto.ts +++ b/apps/api/src/vendors/dto/create-vendor.dto.ts @@ -1,10 +1,16 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsString, IsNotEmpty, IsOptional, IsEnum, IsUrl } from 'class-validator'; -import { - VendorCategory, - VendorStatus, - Likelihood, - Impact +import { + IsString, + IsNotEmpty, + IsOptional, + IsEnum, + IsUrl, +} from 'class-validator'; +import { + VendorCategory, + VendorStatus, + Likelihood, + Impact, } from '@trycompai/db'; export class CreateVendorDto { @@ -18,7 +24,8 @@ export class CreateVendorDto { @ApiProperty({ description: 'Detailed description of the vendor and services provided', - example: 'Cloud infrastructure provider offering AWS-like services including compute, storage, and networking solutions for enterprise customers.', + example: + 'Cloud infrastructure provider offering AWS-like services including compute, storage, and networking solutions for enterprise customers.', }) @IsString() @IsNotEmpty() diff --git a/apps/api/src/vendors/dto/vendor-response.dto.ts b/apps/api/src/vendors/dto/vendor-response.dto.ts index 209c0109f..8350592c5 100644 --- a/apps/api/src/vendors/dto/vendor-response.dto.ts +++ b/apps/api/src/vendors/dto/vendor-response.dto.ts @@ -1,9 +1,9 @@ import { ApiProperty } from '@nestjs/swagger'; -import { - VendorCategory, - VendorStatus, - Likelihood, - Impact +import { + VendorCategory, + VendorStatus, + Likelihood, + Impact, } from '@trycompai/db'; export class VendorResponseDto { @@ -21,7 +21,8 @@ export class VendorResponseDto { @ApiProperty({ description: 'Detailed description of the vendor and services provided', - example: 'Cloud infrastructure provider offering AWS-like services including compute, storage, and networking solutions for enterprise customers.', + example: + 'Cloud infrastructure provider offering AWS-like services including compute, storage, and networking solutions for enterprise customers.', }) description: string; diff --git a/apps/api/src/vendors/vendors.controller.ts b/apps/api/src/vendors/vendors.controller.ts index 9b54e4b85..1bcd8f1e8 100644 --- a/apps/api/src/vendors/vendors.controller.ts +++ b/apps/api/src/vendors/vendors.controller.ts @@ -1,12 +1,12 @@ -import { - Controller, - Get, +import { + Controller, + Get, Post, Patch, Delete, Body, Param, - UseGuards + UseGuards, } from '@nestjs/common'; import { ApiBody, @@ -17,10 +17,7 @@ import { ApiSecurity, ApiTags, } from '@nestjs/swagger'; -import { - AuthContext, - OrganizationId, -} from '../auth/auth-context.decorator'; +import { AuthContext, OrganizationId } from '../auth/auth-context.decorator'; import { HybridAuthGuard } from '../auth/hybrid-auth.guard'; import type { AuthContext as AuthContextType } from '../auth/types'; import { CreateVendorDto } from './dto/create-vendor.dto'; @@ -58,18 +55,20 @@ export class VendorsController { @OrganizationId() organizationId: string, @AuthContext() authContext: AuthContextType, ) { - const vendors = await this.vendorsService.findAllByOrganization(organizationId); + const vendors = + await this.vendorsService.findAllByOrganization(organizationId); return { data: vendors, count: vendors.length, authType: authContext.authType, - ...(authContext.userId && authContext.userEmail && { - authenticatedUser: { - id: authContext.userId, - email: authContext.userEmail, - }, - }), + ...(authContext.userId && + authContext.userEmail && { + authenticatedUser: { + id: authContext.userId, + email: authContext.userEmail, + }, + }), }; } @@ -90,12 +89,13 @@ export class VendorsController { return { ...vendor, authType: authContext.authType, - ...(authContext.userId && authContext.userEmail && { - authenticatedUser: { - id: authContext.userId, - email: authContext.userEmail, - }, - }), + ...(authContext.userId && + authContext.userEmail && { + authenticatedUser: { + id: authContext.userId, + email: authContext.userEmail, + }, + }), }; } @@ -112,17 +112,21 @@ export class VendorsController { @OrganizationId() organizationId: string, @AuthContext() authContext: AuthContextType, ) { - const vendor = await this.vendorsService.create(organizationId, createVendorDto); + const vendor = await this.vendorsService.create( + organizationId, + createVendorDto, + ); return { ...vendor, authType: authContext.authType, - ...(authContext.userId && authContext.userEmail && { - authenticatedUser: { - id: authContext.userId, - email: authContext.userEmail, - }, - }), + ...(authContext.userId && + authContext.userEmail && { + authenticatedUser: { + id: authContext.userId, + email: authContext.userEmail, + }, + }), }; } @@ -150,12 +154,13 @@ export class VendorsController { return { ...updatedVendor, authType: authContext.authType, - ...(authContext.userId && authContext.userEmail && { - authenticatedUser: { - id: authContext.userId, - email: authContext.userEmail, - }, - }), + ...(authContext.userId && + authContext.userEmail && { + authenticatedUser: { + id: authContext.userId, + email: authContext.userEmail, + }, + }), }; } @@ -171,17 +176,21 @@ export class VendorsController { @OrganizationId() organizationId: string, @AuthContext() authContext: AuthContextType, ) { - const result = await this.vendorsService.deleteById(vendorId, organizationId); + const result = await this.vendorsService.deleteById( + vendorId, + organizationId, + ); return { ...result, authType: authContext.authType, - ...(authContext.userId && authContext.userEmail && { - authenticatedUser: { - id: authContext.userId, - email: authContext.userEmail, - }, - }), + ...(authContext.userId && + authContext.userEmail && { + authenticatedUser: { + id: authContext.userId, + email: authContext.userEmail, + }, + }), }; } } diff --git a/apps/api/src/vendors/vendors.service.ts b/apps/api/src/vendors/vendors.service.ts index e60be6eab..159848356 100644 --- a/apps/api/src/vendors/vendors.service.ts +++ b/apps/api/src/vendors/vendors.service.ts @@ -14,10 +14,15 @@ export class VendorsService { orderBy: { createdAt: 'desc' }, }); - this.logger.log(`Retrieved ${vendors.length} vendors for organization ${organizationId}`); + this.logger.log( + `Retrieved ${vendors.length} vendors for organization ${organizationId}`, + ); return vendors; } catch (error) { - this.logger.error(`Failed to retrieve vendors for organization ${organizationId}:`, error); + this.logger.error( + `Failed to retrieve vendors for organization ${organizationId}:`, + error, + ); throw error; } } @@ -25,14 +30,16 @@ export class VendorsService { async findById(id: string, organizationId: string) { try { const vendor = await db.vendor.findFirst({ - where: { + where: { id, - organizationId + organizationId, }, }); if (!vendor) { - throw new NotFoundException(`Vendor with ID ${id} not found in organization ${organizationId}`); + throw new NotFoundException( + `Vendor with ID ${id} not found in organization ${organizationId}`, + ); } this.logger.log(`Retrieved vendor: ${vendor.name} (${id})`); @@ -55,15 +62,24 @@ export class VendorsService { }, }); - this.logger.log(`Created new vendor: ${vendor.name} (${vendor.id}) for organization ${organizationId}`); + this.logger.log( + `Created new vendor: ${vendor.name} (${vendor.id}) for organization ${organizationId}`, + ); return vendor; } catch (error) { - this.logger.error(`Failed to create vendor for organization ${organizationId}:`, error); + this.logger.error( + `Failed to create vendor for organization ${organizationId}:`, + error, + ); throw error; } } - async updateById(id: string, organizationId: string, updateVendorDto: UpdateVendorDto) { + async updateById( + id: string, + organizationId: string, + updateVendorDto: UpdateVendorDto, + ) { try { // First check if the vendor exists in the organization await this.findById(id, organizationId); @@ -94,12 +110,12 @@ export class VendorsService { }); this.logger.log(`Deleted vendor: ${existingVendor.name} (${id})`); - return { + return { message: 'Vendor deleted successfully', deletedVendor: { id: existingVendor.id, name: existingVendor.name, - } + }, }; } catch (error) { if (error instanceof NotFoundException) { diff --git a/apps/app/package.json b/apps/app/package.json index e2476b603..6de9953a8 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -43,7 +43,8 @@ "@react-three/fiber": "^9.1.2", "@react-three/postprocessing": "^3.0.4", "@t3-oss/env-nextjs": "^0.13.8", - "@tanstack/react-query": "^5.74.4", + "@tanstack/react-form": "^1.23.8", + "@tanstack/react-query": "^5.90.7", "@tanstack/react-table": "^8.21.3", "@tiptap/extension-table": "^3.4.4", "@tiptap/extension-table-cell": "^3.4.4", @@ -79,6 +80,7 @@ "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", @@ -106,6 +108,7 @@ "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": "^3.25.76", diff --git a/apps/app/public/questionaire/tmp-questionaire-empty-state.png b/apps/app/public/questionaire/tmp-questionaire-empty-state.png new file mode 100644 index 000000000..723fb8d90 Binary files /dev/null and b/apps/app/public/questionaire/tmp-questionaire-empty-state.png differ diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/components/FrameworksOverview.tsx b/apps/app/src/app/(app)/[orgId]/frameworks/components/FrameworksOverview.tsx index d792e8b51..db848d64e 100644 --- a/apps/app/src/app/(app)/[orgId]/frameworks/components/FrameworksOverview.tsx +++ b/apps/app/src/app/(app)/[orgId]/frameworks/components/FrameworksOverview.tsx @@ -98,19 +98,28 @@ export function FrameworksOverview({
{frameworksWithControls.map((framework, index) => { const complianceScore = complianceMap.get(framework.id) ?? 0; + const badgeSrc = mapFrameworkToBadge(framework); return (
- {framework.framework.name} + {badgeSrc ? ( + {framework.framework.name} + ) : ( +
+ + {framework.framework.name.charAt(0)} + +
+ )}
diff --git a/apps/app/src/app/(app)/[orgId]/security-questionnaire/actions/create-trigger-token.ts b/apps/app/src/app/(app)/[orgId]/security-questionnaire/actions/create-trigger-token.ts new file mode 100644 index 000000000..3b6565c13 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/security-questionnaire/actions/create-trigger-token.ts @@ -0,0 +1,82 @@ +'use server'; + +import { auth as betterAuth } from '@/utils/auth'; +import { auth } from '@trigger.dev/sdk'; +import { headers } from 'next/headers'; + +// Create trigger token for auto-answer (can trigger and read) +export const createTriggerToken = async (taskId: 'parse-questionnaire' | 'vendor-questionnaire-orchestrator' | 'answer-question') => { + const session = await betterAuth.api.getSession({ + headers: await headers(), + }); + + if (!session) { + return { + success: false, + error: 'Unauthorized', + }; + } + + const orgId = session.session?.activeOrganizationId; + if (!orgId) { + return { + success: false, + error: 'No active organization', + }; + } + + try { + const token = await auth.createTriggerPublicToken(taskId, { + multipleUse: true, + expirationTime: '1hr', + }); + + return { + success: true, + token, + }; + } catch (error) { + console.error('Error creating trigger token:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to create trigger token', + }; + } +}; + +// Create public token with read permissions for a specific run +export const createRunReadToken = async (runId: string) => { + const session = await betterAuth.api.getSession({ + headers: await headers(), + }); + + if (!session) { + return { + success: false, + error: 'Unauthorized', + }; + } + + try { + const token = await auth.createPublicToken({ + scopes: { + read: { + runs: [runId], + }, + }, + expirationTime: '1hr', + }); + + return { + success: true, + token, + }; + } catch (error) { + console.error('Error creating run read token:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to create run read token', + }; + } +}; + diff --git a/apps/app/src/app/(app)/[orgId]/security-questionnaire/actions/export-questionnaire.ts b/apps/app/src/app/(app)/[orgId]/security-questionnaire/actions/export-questionnaire.ts new file mode 100644 index 000000000..e949ee2d8 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/security-questionnaire/actions/export-questionnaire.ts @@ -0,0 +1,202 @@ +'use server'; + +import { authActionClient } from '@/actions/safe-action'; +import { db } from '@db'; +import * as XLSX from 'xlsx'; +import { jsPDF } from 'jspdf'; +import { z } from 'zod'; + +const inputSchema = z.object({ + questionsAndAnswers: z.array( + z.object({ + question: z.string(), + answer: z.string().nullable(), + }), + ), + format: z.enum(['xlsx', 'csv', 'pdf']), +}); + +interface QuestionAnswer { + question: string; + answer: string | null; +} + +/** + * Generates XLSX file from questions and answers + */ +function generateXLSX(questionsAndAnswers: QuestionAnswer[]): Buffer { + const workbook = XLSX.utils.book_new(); + + // Create worksheet data + const worksheetData = [ + ['#', 'Question', 'Answer'], // Header row + ...questionsAndAnswers.map((qa, index) => [ + index + 1, + qa.question, + qa.answer || '', + ]), + ]; + + const worksheet = XLSX.utils.aoa_to_sheet(worksheetData); + + // Set column widths + worksheet['!cols'] = [ + { wch: 5 }, // # + { wch: 60 }, // Question + { wch: 60 }, // Answer + ]; + + XLSX.utils.book_append_sheet(workbook, worksheet, 'Questionnaire'); + + // Convert to buffer + return Buffer.from(XLSX.write(workbook, { type: 'buffer', bookType: 'xlsx' })); +} + +/** + * Generates CSV file from questions and answers + */ +function generateCSV(questionsAndAnswers: QuestionAnswer[]): string { + const rows = [ + ['#', 'Question', 'Answer'], // Header row + ...questionsAndAnswers.map((qa, index) => [ + String(index + 1), + qa.question.replace(/"/g, '""'), // Escape quotes + (qa.answer || '').replace(/"/g, '""'), // Escape quotes + ]), + ]; + + return rows.map((row) => row.map((cell) => `"${cell}"`).join(',')).join('\n'); +} + +/** + * Generates PDF file from questions and answers + */ +function generatePDF(questionsAndAnswers: QuestionAnswer[], vendorName?: string): Buffer { + const doc = new jsPDF(); + const pageWidth = doc.internal.pageSize.getWidth(); + const pageHeight = doc.internal.pageSize.getHeight(); + const margin = 20; + const contentWidth = pageWidth - 2 * margin; + let yPosition = margin; + const lineHeight = 7; + + // Add title + doc.setFontSize(16); + doc.setFont('helvetica', 'bold'); + const title = vendorName ? `Questionnaire: ${vendorName}` : 'Questionnaire'; + doc.text(title, margin, yPosition); + yPosition += lineHeight * 2; + + // Add date + doc.setFontSize(10); + doc.setFont('helvetica', 'normal'); + doc.text(`Generated: ${new Date().toLocaleDateString()}`, margin, yPosition); + yPosition += lineHeight * 2; + + // Process each question-answer pair + doc.setFontSize(11); + + questionsAndAnswers.forEach((qa, index) => { + // Check if we need a new page + if (yPosition > pageHeight - 40) { + doc.addPage(); + yPosition = margin; + } + + // Question number and question + doc.setFont('helvetica', 'bold'); + const questionText = `Q${index + 1}: ${qa.question}`; + const questionLines = doc.splitTextToSize(questionText, contentWidth); + doc.text(questionLines, margin, yPosition); + yPosition += questionLines.length * lineHeight + 2; + + // Answer + doc.setFont('helvetica', 'normal'); + const answerText = qa.answer || 'No answer provided'; + const answerLines = doc.splitTextToSize(`A${index + 1}: ${answerText}`, contentWidth); + doc.text(answerLines, margin, yPosition); + yPosition += answerLines.length * lineHeight + 4; + }); + + // Convert to buffer + return Buffer.from(doc.output('arraybuffer')); +} + +export const exportQuestionnaire = authActionClient + .inputSchema(inputSchema) + .metadata({ + name: 'export-questionnaire', + track: { + event: 'export-questionnaire', + channel: 'server', + }, + }) + .action(async ({ parsedInput, ctx }) => { + const { questionsAndAnswers, format } = parsedInput; + const { session } = ctx; + + if (!session?.activeOrganizationId) { + throw new Error('No active organization'); + } + + const organizationId = session.activeOrganizationId; + + try { + const vendorName = 'security-questionnaire'; + const sanitizedVendorName = vendorName.toLowerCase().replace(/[^a-z0-9]/g, '-'); + const timestamp = new Date().toISOString().split('T')[0]; + + let fileBuffer: Buffer; + let mimeType: string; + let fileExtension: string; + let filename: string; + + // Generate file based on format + switch (format) { + case 'xlsx': { + fileBuffer = generateXLSX(questionsAndAnswers); + mimeType = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'; + fileExtension = 'xlsx'; + filename = `questionnaire-${sanitizedVendorName}-${timestamp}.xlsx`; + break; + } + + case 'csv': { + const csvContent = generateCSV(questionsAndAnswers); + fileBuffer = Buffer.from(csvContent, 'utf-8'); + mimeType = 'text/csv'; + fileExtension = 'csv'; + filename = `questionnaire-${sanitizedVendorName}-${timestamp}.csv`; + break; + } + + case 'pdf': { + fileBuffer = generatePDF(questionsAndAnswers, vendorName); + mimeType = 'application/pdf'; + fileExtension = 'pdf'; + filename = `questionnaire-${sanitizedVendorName}-${timestamp}.pdf`; + break; + } + + default: + throw new Error(`Unsupported format: ${format}`); + } + + // Convert buffer to base64 data URL for direct download + const base64Data = fileBuffer.toString('base64'); + const dataUrl = `data:${mimeType};base64,${base64Data}`; + + return { + success: true, + data: { + downloadUrl: dataUrl, + filename, + }, + }; + } catch (error) { + throw error instanceof Error + ? error + : new Error('Failed to export questionnaire'); + } + }); + diff --git a/apps/app/src/app/(app)/[orgId]/security-questionnaire/actions/parse-questionnaire-ai.ts b/apps/app/src/app/(app)/[orgId]/security-questionnaire/actions/parse-questionnaire-ai.ts new file mode 100644 index 000000000..af8d55b20 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/security-questionnaire/actions/parse-questionnaire-ai.ts @@ -0,0 +1,96 @@ +'use server'; + +import { authActionClient } from '@/actions/safe-action'; +import { parseQuestionnaireTask } from '@/jobs/tasks/vendors/parse-questionnaire'; +import { tasks } from '@trigger.dev/sdk'; +import { z } from 'zod'; +import { APP_AWS_QUESTIONNAIRE_UPLOAD_BUCKET } from '@/app/s3'; + +const inputSchema = z.object({ + inputType: z.enum(['file', 'url', 'attachment', 's3']), + // For file uploads + fileData: z.string().optional(), // base64 encoded + fileName: z.string().optional(), + fileType: z.string().optional(), // MIME type + // For URLs + url: z.string().url().optional(), + // For attachments + attachmentId: z.string().optional(), + // For S3 keys (temporary questionnaire files) + s3Key: z.string().optional(), +}); + +export const parseQuestionnaireAI = authActionClient + .inputSchema(inputSchema) + .metadata({ + name: 'parse-questionnaire-ai', + track: { + event: 'parse-questionnaire-ai', + channel: 'server', + }, + }) + .action(async ({ parsedInput, ctx }) => { + const { inputType } = parsedInput; + const { session } = ctx; + + if (!session?.activeOrganizationId) { + throw new Error('No active organization'); + } + + // Validate questionnaire upload bucket is configured + if (!APP_AWS_QUESTIONNAIRE_UPLOAD_BUCKET) { + throw new Error('Questionnaire upload service is not configured. Please set APP_AWS_QUESTIONNAIRE_UPLOAD_BUCKET environment variable to use this feature.'); + } + + const organizationId = session.activeOrganizationId; + + try { + // Trigger the parse questionnaire task in Trigger.dev + // Only include fileData if inputType is 'file' (for backward compatibility) + // Otherwise use attachmentId or url + const payload: { + inputType: 'file' | 'url' | 'attachment' | 's3'; + organizationId: string; + fileData?: string; + fileName?: string; + fileType?: string; + url?: string; + attachmentId?: string; + s3Key?: string; + } = { + inputType, + organizationId, + }; + + if (inputType === 'file' && parsedInput.fileData) { + payload.fileData = parsedInput.fileData; + payload.fileName = parsedInput.fileName; + payload.fileType = parsedInput.fileType; + } else if (inputType === 'url' && parsedInput.url) { + payload.url = parsedInput.url; + } else if (inputType === 'attachment' && parsedInput.attachmentId) { + payload.attachmentId = parsedInput.attachmentId; + } else if (inputType === 's3' && parsedInput.s3Key) { + payload.s3Key = parsedInput.s3Key; + payload.fileName = parsedInput.fileName; + payload.fileType = parsedInput.fileType; + } + + const handle = await tasks.trigger( + 'parse-questionnaire', + payload, + ); + + return { + success: true, + data: { + taskId: handle.id, // Return task ID for polling + }, + }; + } catch (error) { + throw error instanceof Error + ? error + : new Error('Failed to trigger parse questionnaire task'); + } + }); + diff --git a/apps/app/src/app/(app)/[orgId]/security-questionnaire/actions/upload-questionnaire-file.ts b/apps/app/src/app/(app)/[orgId]/security-questionnaire/actions/upload-questionnaire-file.ts new file mode 100644 index 000000000..6e1945c60 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/security-questionnaire/actions/upload-questionnaire-file.ts @@ -0,0 +1,134 @@ +'use server'; + +import { APP_AWS_QUESTIONNAIRE_UPLOAD_BUCKET, s3Client } from '@/app/s3'; +import { authActionClient } from '@/actions/safe-action'; +import { PutObjectCommand } from '@aws-sdk/client-s3'; +import { db } from '@db'; +import { AttachmentEntityType, AttachmentType } from '@db'; +import { z } from 'zod'; +import { randomBytes } from 'crypto'; + +const uploadSchema = z.object({ + fileName: z.string(), + fileType: z.string(), + fileData: z.string(), // base64 encoded + organizationId: z.string(), +}); + +function mapFileTypeToAttachmentType(fileType: string): AttachmentType { + const type = fileType.split('/')[0]; + switch (type) { + case 'image': + return AttachmentType.image; + case 'video': + return AttachmentType.video; + case 'audio': + return AttachmentType.audio; + case 'application': + return AttachmentType.document; + default: + return AttachmentType.other; + } +} + +export const uploadQuestionnaireFile = authActionClient + .inputSchema(uploadSchema) + .metadata({ + name: 'upload-questionnaire-file', + track: { + event: 'upload-questionnaire-file', + channel: 'server', + }, + }) + .action(async ({ parsedInput, ctx }) => { + const { fileName, fileType, fileData, organizationId } = parsedInput; + const { session } = ctx; + + if (!session?.activeOrganizationId || session.activeOrganizationId !== organizationId) { + throw new Error('Unauthorized'); + } + + if (!s3Client) { + throw new Error('S3 client not configured'); + } + + if (!APP_AWS_QUESTIONNAIRE_UPLOAD_BUCKET) { + throw new Error('Questionnaire upload bucket is not configured. Please set APP_AWS_QUESTIONNAIRE_UPLOAD_BUCKET environment variable.'); + } + + try { + // Convert base64 to buffer + const fileBuffer = Buffer.from(fileData, 'base64'); + + // Validate file size (10MB limit) + const MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024; + if (fileBuffer.length > MAX_FILE_SIZE_BYTES) { + throw new Error(`File exceeds the ${MAX_FILE_SIZE_BYTES / (1024 * 1024)}MB limit`); + } + + // Generate unique file key + const fileId = randomBytes(16).toString('hex'); + const sanitizedFileName = fileName.replace(/[^a-zA-Z0-9.-]/g, '_'); + const timestamp = Date.now(); + const s3Key = `${organizationId}/questionnaire-uploads/${timestamp}-${fileId}-${sanitizedFileName}`; + + // Upload to S3 + const putCommand = new PutObjectCommand({ + Bucket: APP_AWS_QUESTIONNAIRE_UPLOAD_BUCKET, + Key: s3Key, + Body: fileBuffer, + ContentType: fileType, + Metadata: { + originalFileName: fileName, + organizationId, + }, + }); + + await s3Client.send(putCommand); + + // Return S3 key directly instead of creating attachment record + // Questionnaire files are temporary processing files, not permanent attachments + return { + success: true, + data: { + s3Key, + fileName, + fileType, + }, + }; + } catch (error) { + // Provide more helpful error messages for common S3 errors + if (error && typeof error === 'object' && 'Code' in error) { + const awsError = error as { Code: string; message?: string }; + + if (awsError.Code === 'AccessDenied') { + throw new Error( + `Access denied to S3 bucket "${APP_AWS_QUESTIONNAIRE_UPLOAD_BUCKET}". ` + + `Please verify that:\n` + + `1. The bucket "${APP_AWS_QUESTIONNAIRE_UPLOAD_BUCKET}" exists\n` + + `2. Your AWS credentials have s3:PutObject permission for this bucket\n` + + `3. The bucket is in the correct region (${process.env.APP_AWS_REGION || 'not set'})\n` + + `4. The bucket name is correct` + ); + } + + if (awsError.Code === 'NoSuchBucket') { + throw new Error( + `S3 bucket "${APP_AWS_QUESTIONNAIRE_UPLOAD_BUCKET}" does not exist. ` + + `Please create the bucket or update APP_AWS_QUESTIONNAIRE_UPLOAD_BUCKET environment variable.` + ); + } + + if (awsError.Code === 'InvalidAccessKeyId' || awsError.Code === 'SignatureDoesNotMatch') { + throw new Error( + `Invalid AWS credentials. Please check APP_AWS_ACCESS_KEY_ID and APP_AWS_SECRET_ACCESS_KEY environment variables.` + ); + } + } + + throw error instanceof Error + ? error + : new Error('Failed to upload questionnaire file'); + } + }); + diff --git a/apps/app/src/app/(app)/[orgId]/security-questionnaire/actions/vendor-questionnaire-orchestrator.ts b/apps/app/src/app/(app)/[orgId]/security-questionnaire/actions/vendor-questionnaire-orchestrator.ts new file mode 100644 index 000000000..1bf54ed56 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/security-questionnaire/actions/vendor-questionnaire-orchestrator.ts @@ -0,0 +1,59 @@ +'use server'; + +import { authActionClient } from '@/actions/safe-action'; +import { vendorQuestionnaireOrchestratorTask } from '@/jobs/tasks/vendors/vendor-questionnaire-orchestrator'; +import { tasks } from '@trigger.dev/sdk'; +import { z } from 'zod'; + +const inputSchema = z.object({ + questionsAndAnswers: z.array( + z.object({ + question: z.string(), + answer: z.string().nullable(), + }), + ), +}); + +export const vendorQuestionnaireOrchestrator = authActionClient + .inputSchema(inputSchema) + .metadata({ + name: 'vendor-questionnaire-orchestrator', + track: { + event: 'vendor-questionnaire-orchestrator', + channel: 'server', + }, + }) + .action(async ({ parsedInput, ctx }) => { + const { questionsAndAnswers } = parsedInput; + const { session } = ctx; + + if (!session?.activeOrganizationId) { + throw new Error('No active organization'); + } + + const organizationId = session.activeOrganizationId; + + try { + // Trigger the root orchestrator task - it will handle batching internally + const handle = await tasks.trigger( + 'vendor-questionnaire-orchestrator', + { + vendorId: `org_${organizationId}`, + organizationId, + questionsAndAnswers, + }, + ); + + return { + success: true, + data: { + taskId: handle.id, // Return orchestrator task ID for polling + }, + }; + } catch (error) { + throw error instanceof Error + ? error + : new Error('Failed to trigger vendor questionnaire orchestrator'); + } + }); + diff --git a/apps/app/src/app/(app)/[orgId]/security-questionnaire/components/QuestionnaireParser.tsx b/apps/app/src/app/(app)/[orgId]/security-questionnaire/components/QuestionnaireParser.tsx new file mode 100644 index 000000000..831cf1b90 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/security-questionnaire/components/QuestionnaireParser.tsx @@ -0,0 +1,115 @@ +'use client'; + +import { useQuestionnaireParser } from '../hooks/useQuestionnaireParser'; +import { QuestionnaireResults } from './QuestionnaireResults'; +import { QuestionnaireSidebar } from './QuestionnaireSidebar'; +import { QuestionnaireUpload } from './QuestionnaireUpload'; + +export function QuestionnaireParser() { + const { + orgId, + selectedFile, + setSelectedFile, + showExitDialog, + setShowExitDialog, + results, + searchQuery, + setSearchQuery, + editingIndex, + editingAnswer, + setEditingAnswer, + expandedSources, + questionStatuses, + answeringQuestionIndex, + hasClickedAutoAnswer, + isLoading, + parseStatus, + isAutoAnswering, + isExporting, + filteredResults, + answeredCount, + totalCount, + progressPercentage, + handleFileSelect, + handleParse, + confirmReset, + handleAutoAnswer, + handleAnswerSingleQuestion, + handleEditAnswer, + handleSaveAnswer, + handleCancelEdit, + handleExport, + handleToggleSource, + } = useQuestionnaireParser(); + + const hasResults = results && results.length > 0; + + if (!hasResults) { + return ( +
+
+

+ Security Questionnaire +

+

+ Automatically analyze and answer questionnaires using AI. Upload questionnaires from + vendors, and our system will extract questions and generate answers based on your + organization's policies and documentation. +

+
+
+
+ setSelectedFile(null)} + onParse={handleParse} + isLoading={isLoading} + parseStatus={parseStatus} + orgId={orgId} + /> +
+
+ +
+
+
+ ); + } + + return ( +
+

Security Questionnaire

+ +
+ ); +} diff --git a/apps/app/src/app/(app)/[orgId]/security-questionnaire/components/QuestionnaireResults.tsx b/apps/app/src/app/(app)/[orgId]/security-questionnaire/components/QuestionnaireResults.tsx new file mode 100644 index 000000000..d35854f8c --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/security-questionnaire/components/QuestionnaireResults.tsx @@ -0,0 +1,154 @@ +'use client'; + +import { ScrollArea } from '@comp/ui/scroll-area'; +import { Search } from 'lucide-react'; +import type { QuestionAnswer } from './types'; +import { QuestionnaireResultsCards } from './QuestionnaireResultsCards'; +import { QuestionnaireResultsHeader } from './QuestionnaireResultsHeader'; +import { QuestionnaireResultsTable } from './QuestionnaireResultsTable'; + +interface QuestionnaireResultsProps { + orgId: string; + results: QuestionAnswer[]; + filteredResults: QuestionAnswer[] | null; + searchQuery: string; + onSearchChange: (query: string) => void; + editingIndex: number | null; + editingAnswer: string; + onEditingAnswerChange: (answer: string) => void; + expandedSources: Set; + questionStatuses: Map; + answeringQuestionIndex: number | null; + hasClickedAutoAnswer: boolean; + isLoading: boolean; + isAutoAnswering: boolean; + isExporting: boolean; + showExitDialog: boolean; + onShowExitDialogChange: (show: boolean) => void; + onExit: () => void; + onAutoAnswer: () => void; + onAnswerSingleQuestion: (index: number) => void; + onEditAnswer: (index: number) => void; + onSaveAnswer: (index: number) => void; + onCancelEdit: () => void; + onExport: (format: 'xlsx' | 'csv' | 'pdf') => void; + onToggleSource: (index: number) => void; + totalCount: number; + answeredCount: number; + progressPercentage: number; +} + +export function QuestionnaireResults({ + orgId, + results, + filteredResults, + searchQuery, + onSearchChange, + editingIndex, + editingAnswer, + onEditingAnswerChange, + expandedSources, + questionStatuses, + answeringQuestionIndex, + hasClickedAutoAnswer, + isLoading, + isAutoAnswering, + isExporting, + showExitDialog, + onShowExitDialogChange, + onExit, + onAutoAnswer, + onAnswerSingleQuestion, + onEditAnswer, + onSaveAnswer, + onCancelEdit, + onExport, + onToggleSource, + totalCount, + answeredCount, + progressPercentage, +}: QuestionnaireResultsProps) { + return ( +
+ + +
+ {results && results.length > 0 ? ( + <> + +
+ {filteredResults && filteredResults.length > 0 ? ( + <> +
+ +
+ + + + ) : ( +
+ +

No matches found

+

Try a different search term

+
+ )} +
+
+ + ) : null} +
+
+ ); +} + diff --git a/apps/app/src/app/(app)/[orgId]/security-questionnaire/components/QuestionnaireResultsCards.tsx b/apps/app/src/app/(app)/[orgId]/security-questionnaire/components/QuestionnaireResultsCards.tsx new file mode 100644 index 000000000..dbf26c2a9 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/security-questionnaire/components/QuestionnaireResultsCards.tsx @@ -0,0 +1,195 @@ +'use client'; + +import { Button } from '@comp/ui/button'; +import { Textarea } from '@comp/ui/textarea'; +import { BookOpen, ChevronDown, ChevronUp, Link as LinkIcon, Loader2 } from 'lucide-react'; +import Link from 'next/link'; +import type { QuestionAnswer } from './types'; + +interface QuestionnaireResultsCardsProps { + orgId: string; + results: QuestionAnswer[]; + filteredResults: QuestionAnswer[]; + editingIndex: number | null; + editingAnswer: string; + onEditingAnswerChange: (answer: string) => void; + expandedSources: Set; + questionStatuses: Map; + answeringQuestionIndex: number | null; + isAutoAnswering: boolean; + hasClickedAutoAnswer: boolean; + onEditAnswer: (index: number) => void; + onSaveAnswer: (index: number) => void; + onCancelEdit: () => void; + onAnswerSingleQuestion: (index: number) => void; + onToggleSource: (index: number) => void; +} + +export function QuestionnaireResultsCards({ + orgId, + results, + filteredResults, + editingIndex, + editingAnswer, + onEditingAnswerChange, + expandedSources, + questionStatuses, + answeringQuestionIndex, + isAutoAnswering, + hasClickedAutoAnswer, + onEditAnswer, + onSaveAnswer, + onCancelEdit, + onAnswerSingleQuestion, + onToggleSource, +}: QuestionnaireResultsCardsProps) { + return ( +
+ {filteredResults.map((qa, index) => { + const originalIndex = results.findIndex((r) => r === qa); + const isEditing = editingIndex === originalIndex; + const questionStatus = questionStatuses.get(originalIndex); + const isProcessing = questionStatus === 'processing'; + + return ( +
+
+ + Question {originalIndex + 1} + +

{qa.question}

+
+ +
+ Answer + {isEditing ? ( +
+