diff --git a/Dockerfile b/Dockerfile index ea6de2198..b22294546 100644 --- a/Dockerfile +++ b/Dockerfile @@ -62,6 +62,10 @@ COPY apps/app ./apps/app # Bring in node_modules for build and prisma prebuild COPY --from=deps /app/node_modules ./node_modules +# Pre-combine schemas for app build +RUN cd packages/db && node scripts/combine-schemas.js +RUN cp packages/db/dist/schema.prisma apps/app/prisma/schema.prisma + # Ensure Next build has required public env at build-time ARG NEXT_PUBLIC_BETTER_AUTH_URL ARG NEXT_PUBLIC_PORTAL_URL @@ -87,8 +91,8 @@ ENV NEXT_PUBLIC_BETTER_AUTH_URL=$NEXT_PUBLIC_BETTER_AUTH_URL \ NEXT_OUTPUT_STANDALONE=true \ NODE_OPTIONS=--max_old_space_size=6144 -# Build the app -RUN cd apps/app && SKIP_ENV_VALIDATION=true bun run build +# Build the app (schema already combined above) +RUN cd apps/app && SKIP_ENV_VALIDATION=true bun run build:docker # ============================================================================= # STAGE 4: App Production @@ -120,6 +124,10 @@ COPY apps/portal ./apps/portal # Bring in node_modules for build and prisma prebuild COPY --from=deps /app/node_modules ./node_modules +# Pre-combine schemas for portal build +RUN cd packages/db && node scripts/combine-schemas.js +RUN cp packages/db/dist/schema.prisma apps/portal/prisma/schema.prisma + # Ensure Next build has required public env at build-time ARG NEXT_PUBLIC_BETTER_AUTH_URL ENV NEXT_PUBLIC_BETTER_AUTH_URL=$NEXT_PUBLIC_BETTER_AUTH_URL \ @@ -127,8 +135,8 @@ ENV NEXT_PUBLIC_BETTER_AUTH_URL=$NEXT_PUBLIC_BETTER_AUTH_URL \ NEXT_OUTPUT_STANDALONE=true \ NODE_OPTIONS=--max_old_space_size=6144 -# Build the portal -RUN cd apps/portal && SKIP_ENV_VALIDATION=true bun run build +# Build the portal (schema already combined above) +RUN cd apps/portal && SKIP_ENV_VALIDATION=true bun run build:docker # ============================================================================= # STAGE 6: Portal Production diff --git a/apps/api/package.json b/apps/api/package.json index 9ec7f55e1..2a7dc2148 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -12,6 +12,7 @@ "@nestjs/platform-express": "^11.1.5", "@nestjs/swagger": "^11.2.0", "@trycompai/db": "^1.3.4", + "archiver": "^7.0.1", "class-transformer": "^0.5.1", "class-validator": "^0.14.2", "jose": "^6.0.12", @@ -26,6 +27,7 @@ "@nestjs/cli": "^11.0.0", "@nestjs/schematics": "^11.0.0", "@nestjs/testing": "^11.0.1", + "@types/archiver": "^6.0.3", "@types/express": "^5.0.0", "@types/jest": "^30.0.0", "@types/node": "^24.0.3", @@ -67,8 +69,9 @@ "private": true, "scripts": { "build": "nest build", + "build:docker": "prisma generate && nest build", "db:generate": "bun run db:getschema && prisma generate", - "db:getschema": "cp ../../node_modules/@trycompai/db/dist/schema.prisma prisma/schema.prisma", + "db:getschema": "node ../../packages/db/scripts/combine-schemas.js && cp ../../packages/db/dist/schema.prisma prisma/schema.prisma", "dev": "nest start --watch", "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index c2fa5bca8..2b0017eba 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -6,10 +6,16 @@ import { AttachmentsModule } from './attachments/attachments.module'; import { AuthModule } from './auth/auth.module'; import { CommentsModule } from './comments/comments.module'; import { DevicesModule } from './devices/devices.module'; +import { DeviceAgentModule } from './device-agent/device-agent.module'; import { awsConfig } from './config/aws.config'; import { HealthModule } from './health/health.module'; import { OrganizationModule } from './organization/organization.module'; +import { PoliciesModule } from './policies/policies.module'; +import { RisksModule } from './risks/risks.module'; import { TasksModule } from './tasks/tasks.module'; +import { VendorsModule } from './vendors/vendors.module'; +import { ContextModule } from './context/context.module'; + @Module({ imports: [ @@ -23,6 +29,12 @@ import { TasksModule } from './tasks/tasks.module'; }), AuthModule, OrganizationModule, + RisksModule, + VendorsModule, + ContextModule, + DevicesModule, + PoliciesModule, + DeviceAgentModule, DevicesModule, AttachmentsModule, TasksModule, diff --git a/apps/api/src/context/context.controller.ts b/apps/api/src/context/context.controller.ts new file mode 100644 index 000000000..96786c80e --- /dev/null +++ b/apps/api/src/context/context.controller.ts @@ -0,0 +1,187 @@ +import { + Controller, + Get, + Post, + Patch, + Delete, + Body, + Param, + UseGuards +} from '@nestjs/common'; +import { + ApiBody, + ApiHeader, + ApiOperation, + ApiParam, + ApiResponse, + ApiSecurity, + ApiTags, +} from '@nestjs/swagger'; +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'; +import { UpdateContextDto } from './dto/update-context.dto'; +import { ContextService } from './context.service'; +import { CONTEXT_OPERATIONS } from './schemas/context-operations'; +import { CONTEXT_PARAMS } from './schemas/context-params'; +import { CONTEXT_BODIES } from './schemas/context-bodies'; +import { GET_ALL_CONTEXT_RESPONSES } from './schemas/get-all-context.responses'; +import { GET_CONTEXT_BY_ID_RESPONSES } from './schemas/get-context-by-id.responses'; +import { CREATE_CONTEXT_RESPONSES } from './schemas/create-context.responses'; +import { UPDATE_CONTEXT_RESPONSES } from './schemas/update-context.responses'; +import { DELETE_CONTEXT_RESPONSES } from './schemas/delete-context.responses'; + +@ApiTags('Context') +@Controller({ path: 'context', version: '1' }) +@UseGuards(HybridAuthGuard) +@ApiSecurity('apikey') +@ApiHeader({ + name: 'X-Organization-Id', + description: + 'Organization ID (required for session auth, optional for API key auth)', + required: false, +}) +export class ContextController { + constructor(private readonly contextService: ContextService) {} + + @Get() + @ApiOperation(CONTEXT_OPERATIONS.getAllContext) + @ApiResponse(GET_ALL_CONTEXT_RESPONSES[200]) + @ApiResponse(GET_ALL_CONTEXT_RESPONSES[401]) + @ApiResponse(GET_ALL_CONTEXT_RESPONSES[404]) + @ApiResponse(GET_ALL_CONTEXT_RESPONSES[500]) + async getAllContext( + @OrganizationId() organizationId: string, + @AuthContext() authContext: AuthContextType, + ) { + 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, + }, + }), + }; + } + + @Get(':id') + @ApiOperation(CONTEXT_OPERATIONS.getContextById) + @ApiParam(CONTEXT_PARAMS.contextId) + @ApiResponse(GET_CONTEXT_BY_ID_RESPONSES[200]) + @ApiResponse(GET_CONTEXT_BY_ID_RESPONSES[401]) + @ApiResponse(GET_CONTEXT_BY_ID_RESPONSES[404]) + @ApiResponse(GET_CONTEXT_BY_ID_RESPONSES[500]) + async getContextById( + @Param('id') contextId: string, + @OrganizationId() organizationId: string, + @AuthContext() authContext: AuthContextType, + ) { + const contextEntry = await this.contextService.findById(contextId, organizationId); + + return { + ...contextEntry, + authType: authContext.authType, + ...(authContext.userId && authContext.userEmail && { + authenticatedUser: { + id: authContext.userId, + email: authContext.userEmail, + }, + }), + }; + } + + @Post() + @ApiOperation(CONTEXT_OPERATIONS.createContext) + @ApiBody(CONTEXT_BODIES.createContext) + @ApiResponse(CREATE_CONTEXT_RESPONSES[201]) + @ApiResponse(CREATE_CONTEXT_RESPONSES[400]) + @ApiResponse(CREATE_CONTEXT_RESPONSES[401]) + @ApiResponse(CREATE_CONTEXT_RESPONSES[404]) + @ApiResponse(CREATE_CONTEXT_RESPONSES[500]) + async createContext( + @Body() createContextDto: CreateContextDto, + @OrganizationId() organizationId: string, + @AuthContext() authContext: AuthContextType, + ) { + const contextEntry = await this.contextService.create(organizationId, createContextDto); + + return { + ...contextEntry, + authType: authContext.authType, + ...(authContext.userId && authContext.userEmail && { + authenticatedUser: { + id: authContext.userId, + email: authContext.userEmail, + }, + }), + }; + } + + @Patch(':id') + @ApiOperation(CONTEXT_OPERATIONS.updateContext) + @ApiParam(CONTEXT_PARAMS.contextId) + @ApiBody(CONTEXT_BODIES.updateContext) + @ApiResponse(UPDATE_CONTEXT_RESPONSES[200]) + @ApiResponse(UPDATE_CONTEXT_RESPONSES[400]) + @ApiResponse(UPDATE_CONTEXT_RESPONSES[401]) + @ApiResponse(UPDATE_CONTEXT_RESPONSES[404]) + @ApiResponse(UPDATE_CONTEXT_RESPONSES[500]) + async updateContext( + @Param('id') contextId: string, + @Body() updateContextDto: UpdateContextDto, + @OrganizationId() organizationId: string, + @AuthContext() authContext: AuthContextType, + ) { + const updatedContextEntry = await this.contextService.updateById( + contextId, + organizationId, + updateContextDto, + ); + + return { + ...updatedContextEntry, + authType: authContext.authType, + ...(authContext.userId && authContext.userEmail && { + authenticatedUser: { + id: authContext.userId, + email: authContext.userEmail, + }, + }), + }; + } + + @Delete(':id') + @ApiOperation(CONTEXT_OPERATIONS.deleteContext) + @ApiParam(CONTEXT_PARAMS.contextId) + @ApiResponse(DELETE_CONTEXT_RESPONSES[200]) + @ApiResponse(DELETE_CONTEXT_RESPONSES[401]) + @ApiResponse(DELETE_CONTEXT_RESPONSES[404]) + @ApiResponse(DELETE_CONTEXT_RESPONSES[500]) + async deleteContext( + @Param('id') contextId: string, + @OrganizationId() organizationId: string, + @AuthContext() authContext: AuthContextType, + ) { + const result = await this.contextService.deleteById(contextId, organizationId); + + return { + ...result, + authType: authContext.authType, + ...(authContext.userId && authContext.userEmail && { + authenticatedUser: { + id: authContext.userId, + email: authContext.userEmail, + }, + }), + }; + } +} diff --git a/apps/api/src/context/context.module.ts b/apps/api/src/context/context.module.ts new file mode 100644 index 000000000..dacefe30a --- /dev/null +++ b/apps/api/src/context/context.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { AuthModule } from '../auth/auth.module'; +import { ContextController } from './context.controller'; +import { ContextService } from './context.service'; + +@Module({ + imports: [AuthModule], + controllers: [ContextController], + providers: [ContextService], + exports: [ContextService], +}) +export class ContextModule {} diff --git a/apps/api/src/context/context.service.ts b/apps/api/src/context/context.service.ts new file mode 100644 index 000000000..6317b554d --- /dev/null +++ b/apps/api/src/context/context.service.ts @@ -0,0 +1,112 @@ +import { Injectable, NotFoundException, Logger } from '@nestjs/common'; +import { db } from '@trycompai/db'; +import { CreateContextDto } from './dto/create-context.dto'; +import { UpdateContextDto } from './dto/update-context.dto'; + +@Injectable() +export class ContextService { + private readonly logger = new Logger(ContextService.name); + + async findAllByOrganization(organizationId: string) { + try { + const contextEntries = await db.context.findMany({ + where: { organizationId }, + orderBy: { createdAt: 'desc' }, + }); + + 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); + throw error; + } + } + + async findById(id: string, organizationId: string) { + try { + const contextEntry = await db.context.findFirst({ + where: { + id, + organizationId + }, + }); + + if (!contextEntry) { + 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})`); + return contextEntry; + } catch (error) { + if (error instanceof NotFoundException) { + throw error; + } + this.logger.error(`Failed to retrieve context entry ${id}:`, error); + throw error; + } + } + + async create(organizationId: string, createContextDto: CreateContextDto) { + try { + const contextEntry = await db.context.create({ + data: { + ...createContextDto, + 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); + throw error; + } + } + + async updateById(id: string, organizationId: string, updateContextDto: UpdateContextDto) { + try { + // First check if the context entry exists in the organization + await this.findById(id, organizationId); + + const updatedContextEntry = await db.context.update({ + where: { id }, + data: updateContextDto, + }); + + this.logger.log(`Updated context entry: ${updatedContextEntry.question.substring(0, 50)}... (${id})`); + return updatedContextEntry; + } catch (error) { + if (error instanceof NotFoundException) { + throw error; + } + this.logger.error(`Failed to update context entry ${id}:`, error); + throw error; + } + } + + async deleteById(id: string, organizationId: string) { + try { + // First check if the context entry exists in the organization + const existingContextEntry = await this.findById(id, organizationId); + + await db.context.delete({ + where: { id }, + }); + + 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) { + throw error; + } + this.logger.error(`Failed to delete context entry ${id}:`, error); + throw error; + } + } +} diff --git a/apps/api/src/context/dto/context-response.dto.ts b/apps/api/src/context/dto/context-response.dto.ts new file mode 100644 index 000000000..511f8cd44 --- /dev/null +++ b/apps/api/src/context/dto/context-response.dto.ts @@ -0,0 +1,46 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class ContextResponseDto { + @ApiProperty({ + description: 'Unique identifier for the context entry', + example: 'ctx_abc123def456', + }) + id: string; + + @ApiProperty({ + description: 'Organization ID this context entry belongs to', + example: 'org_xyz789uvw012', + }) + organizationId: string; + + @ApiProperty({ + description: 'The question or topic this context entry addresses', + example: 'How do we handle user authentication in our application?', + }) + question: string; + + @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.', + }) + answer: string; + + @ApiProperty({ + description: 'Tags to categorize and help search this context entry', + example: ['authentication', 'security', 'api', 'sessions'], + type: [String], + }) + tags: string[]; + + @ApiProperty({ + description: 'Timestamp when the context entry was created', + example: '2024-01-15T10:30:00.000Z', + }) + createdAt: Date; + + @ApiProperty({ + description: 'Timestamp when the context entry was last updated', + example: '2024-01-15T14:20:00.000Z', + }) + updatedAt: Date; +} diff --git a/apps/api/src/context/dto/create-context.dto.ts b/apps/api/src/context/dto/create-context.dto.ts new file mode 100644 index 000000000..882f4bf69 --- /dev/null +++ b/apps/api/src/context/dto/create-context.dto.ts @@ -0,0 +1,31 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsString, IsNotEmpty, IsOptional, IsArray } from 'class-validator'; + +export class CreateContextDto { + @ApiProperty({ + description: 'The question or topic this context entry addresses', + example: 'How do we handle user authentication in our application?', + }) + @IsString() + @IsNotEmpty() + question: string; + + @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.', + }) + @IsString() + @IsNotEmpty() + answer: string; + + @ApiProperty({ + description: 'Tags to categorize and help search this context entry', + example: ['authentication', 'security', 'api', 'sessions'], + required: false, + type: [String], + }) + @IsOptional() + @IsArray() + @IsString({ each: true }) + tags?: string[]; +} diff --git a/apps/api/src/context/dto/update-context.dto.ts b/apps/api/src/context/dto/update-context.dto.ts new file mode 100644 index 000000000..f8a6d9f7f --- /dev/null +++ b/apps/api/src/context/dto/update-context.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/swagger'; +import { CreateContextDto } from './create-context.dto'; + +export class UpdateContextDto extends PartialType(CreateContextDto) {} diff --git a/apps/api/src/context/schemas/context-bodies.ts b/apps/api/src/context/schemas/context-bodies.ts new file mode 100644 index 000000000..6232249e1 --- /dev/null +++ b/apps/api/src/context/schemas/context-bodies.ts @@ -0,0 +1,42 @@ +import type { ApiBodyOptions } from '@nestjs/swagger'; +import { CreateContextDto } from '../dto/create-context.dto'; +import { UpdateContextDto } from '../dto/update-context.dto'; + +export const CONTEXT_BODIES: Record = { + createContext: { + description: 'Context entry data', + type: CreateContextDto, + examples: { + '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.', + 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.', + tags: ['database', 'postgresql', 'prisma', 'architecture'], + }, + }, + }, + }, + updateContext: { + description: 'Partial context entry data to update', + type: UpdateContextDto, + examples: { + 'Update Tags': { + value: { + tags: ['authentication', 'security', 'api', 'sessions', 'updated'], + }, + }, + '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.', + }, + }, + }, + }, +}; diff --git a/apps/api/src/context/schemas/context-operations.ts b/apps/api/src/context/schemas/context-operations.ts new file mode 100644 index 000000000..b2e488353 --- /dev/null +++ b/apps/api/src/context/schemas/context-operations.ts @@ -0,0 +1,29 @@ +import type { ApiOperationOptions } from '@nestjs/swagger'; + +export const CONTEXT_OPERATIONS: Record = { + getAllContext: { + summary: 'Get all context entries', + description: + 'Returns all context entries for the authenticated organization. Supports both API key authentication (X-API-Key header) and session authentication (cookies + X-Organization-Id header).', + }, + getContextById: { + summary: 'Get context entry by ID', + description: + 'Returns a specific context entry by ID for the authenticated organization. Supports both API key authentication (X-API-Key header) and session authentication (cookies + X-Organization-Id header).', + }, + createContext: { + summary: 'Create a new context entry', + description: + 'Creates a new context entry for the authenticated organization. All required fields must be provided. Supports both API key authentication (X-API-Key header) and session authentication (cookies + X-Organization-Id header).', + }, + updateContext: { + summary: 'Update context entry', + description: + 'Partially updates a context entry. Only provided fields will be updated. Supports both API key authentication (X-API-Key header) and session authentication (cookies + X-Organization-Id header).', + }, + deleteContext: { + summary: 'Delete context entry', + description: + 'Permanently removes a context entry from the organization. This action cannot be undone. Supports both API key authentication (X-API-Key header) and session authentication (cookies + X-Organization-Id header).', + }, +}; diff --git a/apps/api/src/context/schemas/context-params.ts b/apps/api/src/context/schemas/context-params.ts new file mode 100644 index 000000000..2a7312b8c --- /dev/null +++ b/apps/api/src/context/schemas/context-params.ts @@ -0,0 +1,10 @@ +import type { ApiParamOptions } from '@nestjs/swagger'; + +export const CONTEXT_PARAMS: Record = { + contextId: { + name: 'id', + description: 'Context entry ID', + example: 'ctx_abc123def456', + required: true, + }, +}; diff --git a/apps/api/src/context/schemas/create-context.responses.ts b/apps/api/src/context/schemas/create-context.responses.ts new file mode 100644 index 000000000..cae23afb1 --- /dev/null +++ b/apps/api/src/context/schemas/create-context.responses.ts @@ -0,0 +1,66 @@ +import type { ApiResponseOptions } from '@nestjs/swagger'; + +export const CREATE_CONTEXT_RESPONSES: Record = { + 201: { + description: 'Context entry created successfully', + content: { + 'application/json': { + example: { + id: 'ctx_abc123def456', + organizationId: 'org_xyz789uvw012', + 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.', + tags: ['authentication', 'security', 'api', 'sessions'], + createdAt: '2024-01-15T10:30:00.000Z', + updatedAt: '2024-01-15T10:30:00.000Z', + authType: 'apikey', + }, + }, + }, + }, + 400: { + description: 'Bad request - Invalid input data', + content: { + 'application/json': { + example: { + message: ['question should not be empty', 'answer should not be empty'], + error: 'Bad Request', + statusCode: 400, + }, + }, + }, + }, + 401: { + description: 'Unauthorized - Invalid or missing authentication', + content: { + 'application/json': { + example: { + message: 'Unauthorized', + statusCode: 401, + }, + }, + }, + }, + 404: { + description: 'Organization not found', + content: { + 'application/json': { + example: { + message: 'Organization not found', + statusCode: 404, + }, + }, + }, + }, + 500: { + description: 'Internal server error', + content: { + 'application/json': { + example: { + message: 'Internal server error', + statusCode: 500, + }, + }, + }, + }, +}; diff --git a/apps/api/src/context/schemas/delete-context.responses.ts b/apps/api/src/context/schemas/delete-context.responses.ts new file mode 100644 index 000000000..f637a7542 --- /dev/null +++ b/apps/api/src/context/schemas/delete-context.responses.ts @@ -0,0 +1,52 @@ +import type { ApiResponseOptions } from '@nestjs/swagger'; + +export const DELETE_CONTEXT_RESPONSES: Record = { + 200: { + description: 'Context entry deleted successfully', + content: { + 'application/json': { + example: { + message: 'Context entry deleted successfully', + deletedContext: { + id: 'ctx_abc123def456', + question: 'How do we handle user authentication in our application?', + }, + authType: 'apikey', + }, + }, + }, + }, + 401: { + description: 'Unauthorized - Invalid or missing authentication', + content: { + 'application/json': { + example: { + message: 'Unauthorized', + statusCode: 401, + }, + }, + }, + }, + 404: { + description: 'Context entry not found', + content: { + 'application/json': { + example: { + message: 'Context entry with ID ctx_abc123def456 not found in organization org_xyz789uvw012', + statusCode: 404, + }, + }, + }, + }, + 500: { + description: 'Internal server error', + content: { + 'application/json': { + example: { + message: 'Internal server error', + statusCode: 500, + }, + }, + }, + }, +}; diff --git a/apps/api/src/context/schemas/get-all-context.responses.ts b/apps/api/src/context/schemas/get-all-context.responses.ts new file mode 100644 index 000000000..f19a600a1 --- /dev/null +++ b/apps/api/src/context/schemas/get-all-context.responses.ts @@ -0,0 +1,68 @@ +import type { ApiResponseOptions } from '@nestjs/swagger'; + +export const GET_ALL_CONTEXT_RESPONSES: Record = { + 200: { + description: 'Context entries retrieved successfully', + content: { + 'application/json': { + example: { + data: [ + { + id: 'ctx_abc123def456', + organizationId: 'org_xyz789uvw012', + 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.', + tags: ['authentication', 'security', 'api', 'sessions'], + createdAt: '2024-01-15T10:30:00.000Z', + updatedAt: '2024-01-15T14:20:00.000Z', + }, + { + id: 'ctx_ghi789jkl012', + organizationId: 'org_xyz789uvw012', + question: 'What database do we use and why?', + answer: 'We use PostgreSQL as our primary database with Prisma as the ORM.', + tags: ['database', 'postgresql', 'prisma', 'architecture'], + createdAt: '2024-01-14T09:15:00.000Z', + updatedAt: '2024-01-14T09:15:00.000Z', + }, + ], + count: 2, + authType: 'apikey', + }, + }, + }, + }, + 401: { + description: 'Unauthorized - Invalid or missing authentication', + content: { + 'application/json': { + example: { + message: 'Unauthorized', + statusCode: 401, + }, + }, + }, + }, + 404: { + description: 'Organization not found', + content: { + 'application/json': { + example: { + message: 'Organization not found', + statusCode: 404, + }, + }, + }, + }, + 500: { + description: 'Internal server error', + content: { + 'application/json': { + example: { + message: 'Internal server error', + statusCode: 500, + }, + }, + }, + }, +}; diff --git a/apps/api/src/context/schemas/get-context-by-id.responses.ts b/apps/api/src/context/schemas/get-context-by-id.responses.ts new file mode 100644 index 000000000..f1a8fff03 --- /dev/null +++ b/apps/api/src/context/schemas/get-context-by-id.responses.ts @@ -0,0 +1,54 @@ +import type { ApiResponseOptions } from '@nestjs/swagger'; + +export const GET_CONTEXT_BY_ID_RESPONSES: Record = { + 200: { + description: 'Context entry retrieved successfully', + content: { + 'application/json': { + example: { + id: 'ctx_abc123def456', + organizationId: 'org_xyz789uvw012', + 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.', + tags: ['authentication', 'security', 'api', 'sessions'], + createdAt: '2024-01-15T10:30:00.000Z', + updatedAt: '2024-01-15T14:20:00.000Z', + authType: 'apikey', + }, + }, + }, + }, + 401: { + description: 'Unauthorized - Invalid or missing authentication', + content: { + 'application/json': { + example: { + message: 'Unauthorized', + statusCode: 401, + }, + }, + }, + }, + 404: { + description: 'Context entry not found', + content: { + 'application/json': { + example: { + message: 'Context entry with ID ctx_abc123def456 not found in organization org_xyz789uvw012', + statusCode: 404, + }, + }, + }, + }, + 500: { + description: 'Internal server error', + content: { + 'application/json': { + example: { + message: 'Internal server error', + statusCode: 500, + }, + }, + }, + }, +}; diff --git a/apps/api/src/context/schemas/update-context.responses.ts b/apps/api/src/context/schemas/update-context.responses.ts new file mode 100644 index 000000000..c2de92eb3 --- /dev/null +++ b/apps/api/src/context/schemas/update-context.responses.ts @@ -0,0 +1,66 @@ +import type { ApiResponseOptions } from '@nestjs/swagger'; + +export const UPDATE_CONTEXT_RESPONSES: Record = { + 200: { + description: 'Context entry updated successfully', + content: { + 'application/json': { + example: { + id: 'ctx_abc123def456', + organizationId: 'org_xyz789uvw012', + question: 'How do we handle user authentication in our application?', + answer: 'Updated: We use a hybrid authentication system supporting both API keys and session-based authentication with OAuth2 support.', + tags: ['authentication', 'security', 'api', 'sessions', 'oauth2'], + createdAt: '2024-01-15T10:30:00.000Z', + updatedAt: '2024-01-15T15:45:00.000Z', + authType: 'apikey', + }, + }, + }, + }, + 400: { + description: 'Bad request - Invalid input data', + content: { + 'application/json': { + example: { + message: ['tags must be an array of strings'], + error: 'Bad Request', + statusCode: 400, + }, + }, + }, + }, + 401: { + description: 'Unauthorized - Invalid or missing authentication', + content: { + 'application/json': { + example: { + message: 'Unauthorized', + statusCode: 401, + }, + }, + }, + }, + 404: { + description: 'Context entry not found', + content: { + 'application/json': { + example: { + message: 'Context entry with ID ctx_abc123def456 not found in organization org_xyz789uvw012', + statusCode: 404, + }, + }, + }, + }, + 500: { + description: 'Internal server error', + content: { + 'application/json': { + example: { + message: 'Internal server error', + statusCode: 500, + }, + }, + }, + }, +}; diff --git a/apps/api/src/device-agent/device-agent.controller.ts b/apps/api/src/device-agent/device-agent.controller.ts new file mode 100644 index 000000000..719c30a5d --- /dev/null +++ b/apps/api/src/device-agent/device-agent.controller.ts @@ -0,0 +1,95 @@ +import { + Controller, + Get, + UseGuards, + StreamableFile, + Response +} from '@nestjs/common'; +import { + ApiHeader, + ApiOperation, + ApiResponse, + ApiSecurity, + ApiTags, +} from '@nestjs/swagger'; +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'; +import { DEVICE_AGENT_OPERATIONS } from './schemas/device-agent-operations'; +import { DOWNLOAD_MAC_AGENT_RESPONSES } from './schemas/download-mac-agent.responses'; +import { DOWNLOAD_WINDOWS_AGENT_RESPONSES } from './schemas/download-windows-agent.responses'; +import type { Response as ExpressResponse } from 'express'; + +@ApiTags('Device Agent') +@Controller({ path: 'device-agent', version: '1' }) +@UseGuards(HybridAuthGuard) +@ApiSecurity('apikey') +@ApiHeader({ + name: 'X-Organization-Id', + description: + 'Organization ID (required for session auth, optional for API key auth)', + required: false, +}) +export class DeviceAgentController { + constructor(private readonly deviceAgentService: DeviceAgentService) {} + + @Get('mac') + @ApiOperation(DEVICE_AGENT_OPERATIONS.downloadMacAgent) + @ApiResponse(DOWNLOAD_MAC_AGENT_RESPONSES[200]) + @ApiResponse(DOWNLOAD_MAC_AGENT_RESPONSES[401]) + @ApiResponse(DOWNLOAD_MAC_AGENT_RESPONSES[404]) + @ApiResponse(DOWNLOAD_MAC_AGENT_RESPONSES[500]) + async downloadMacAgent( + @OrganizationId() organizationId: string, + @AuthContext() authContext: AuthContextType, + @Response({ passthrough: true }) res: ExpressResponse, + ) { + 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', + }); + + return new StreamableFile(stream); + } + + @Get('windows') + @ApiOperation(DEVICE_AGENT_OPERATIONS.downloadWindowsAgent) + @ApiResponse(DOWNLOAD_WINDOWS_AGENT_RESPONSES[200]) + @ApiResponse(DOWNLOAD_WINDOWS_AGENT_RESPONSES[401]) + @ApiResponse(DOWNLOAD_WINDOWS_AGENT_RESPONSES[404]) + @ApiResponse(DOWNLOAD_WINDOWS_AGENT_RESPONSES[500]) + async downloadWindowsAgent( + @OrganizationId() organizationId: string, + @AuthContext() authContext: AuthContextType, + @Response({ passthrough: true }) res: ExpressResponse, + ) { + // 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, + ); + + // 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', + }); + + return new StreamableFile(stream); + } +} diff --git a/apps/api/src/device-agent/device-agent.module.ts b/apps/api/src/device-agent/device-agent.module.ts new file mode 100644 index 000000000..77b4ef4fe --- /dev/null +++ b/apps/api/src/device-agent/device-agent.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { AuthModule } from '../auth/auth.module'; +import { DeviceAgentController } from './device-agent.controller'; +import { DeviceAgentService } from './device-agent.service'; + +@Module({ + imports: [AuthModule], + controllers: [DeviceAgentController], + providers: [DeviceAgentService], + exports: [DeviceAgentService], +}) +export class DeviceAgentModule {} diff --git a/apps/api/src/device-agent/device-agent.service.ts b/apps/api/src/device-agent/device-agent.service.ts new file mode 100644 index 000000000..891b15d6b --- /dev/null +++ b/apps/api/src/device-agent/device-agent.service.ts @@ -0,0 +1,144 @@ +import { Injectable, NotFoundException, Logger } from '@nestjs/common'; +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'; + +@Injectable() +export class DeviceAgentService { + private readonly logger = new Logger(DeviceAgentService.name); + private s3Client: S3Client; + private fleetBucketName: string; + + constructor() { + // 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.s3Client = new S3Client({ + region: process.env.APP_AWS_REGION || 'us-east-1', + credentials: { + accessKeyId: process.env.APP_AWS_ACCESS_KEY_ID!, + secretAccessKey: process.env.APP_AWS_SECRET_ACCESS_KEY!, + }, + }); + } + + async downloadMacAgent(): Promise<{ stream: Readable; filename: string; contentType: string }> { + try { + const macosPackageFilename = 'Comp AI Agent-1.0.0-arm64.dmg'; + const packageKey = `macos/${macosPackageFilename}`; + + this.logger.log(`Downloading macOS agent from S3: ${packageKey}`); + + const getObjectCommand = new GetObjectCommand({ + Bucket: this.fleetBucketName, + Key: packageKey, + }); + + const s3Response = await this.s3Client.send(getObjectCommand); + + if (!s3Response.Body) { + throw new NotFoundException('macOS agent DMG file not found in S3'); + } + + // Use S3 stream directly as Node.js Readable + const s3Stream = s3Response.Body as Readable; + + this.logger.log(`Successfully retrieved macOS agent: ${macosPackageFilename}`); + + return { + stream: s3Stream, + filename: macosPackageFilename, + contentType: 'application/x-apple-diskimage', + }; + } catch (error) { + if (error instanceof NotFoundException) { + throw error; + } + this.logger.error('Failed to download macOS agent from S3:', error); + throw error; + } + } + + 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}`); + + // 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, + employeeId: employeeId, + fleetDevicePath: fleetDevicePathWindows, + }); + + // Create a passthrough stream for the response + const passThrough = new PassThrough(); + const archive = archiver('zip', { zlib: { level: 9 } }); + + // Pipe archive to passthrough + archive.pipe(passThrough); + + // Error handling for the archive + archive.on('error', (err) => { + this.logger.error('Archive error:', err); + passThrough.destroy(err); + }); + + archive.on('warning', (warn) => { + this.logger.warn('Archive warning:', warn); + }); + + // Add script file + const scriptFilename = getScriptFilename('windows'); + archive.append(script, { name: scriptFilename, mode: 0o755 }); + + // Add README + const readmeContent = getReadmeContent('windows'); + archive.append(readmeContent, { name: 'README.txt' }); + + // Get MSI package from S3 and stream it into the zip + const windowsPackageFilename = 'fleet-osquery.msi'; + const packageKey = `windows/${windowsPackageFilename}`; + const packageFilename = getPackageFilename('windows'); + + this.logger.log(`Downloading Windows MSI from S3: ${packageKey}`); + + const getObjectCommand = new GetObjectCommand({ + Bucket: this.fleetBucketName, + Key: packageKey, + }); + + const s3Response = await this.s3Client.send(getObjectCommand); + + if (s3Response.Body) { + const s3Stream = s3Response.Body as Readable; + s3Stream.on('error', (err) => { + this.logger.error('S3 stream error:', err); + passThrough.destroy(err); + }); + archive.append(s3Stream, { name: packageFilename, store: true }); + } else { + this.logger.warn('Windows MSI file not found in S3, creating zip without MSI'); + } + + // Finalize the archive + archive.finalize(); + + this.logger.log('Successfully created Windows agent zip'); + + return { + stream: passThrough, + filename: `compai-device-agent-windows.zip`, + contentType: 'application/zip', + }; + } catch (error) { + this.logger.error('Failed to create Windows agent zip:', error); + throw error; + } + } +} diff --git a/apps/api/src/device-agent/schemas/device-agent-operations.ts b/apps/api/src/device-agent/schemas/device-agent-operations.ts new file mode 100644 index 000000000..778efcbfc --- /dev/null +++ b/apps/api/src/device-agent/schemas/device-agent-operations.ts @@ -0,0 +1,14 @@ +import type { ApiOperationOptions } from '@nestjs/swagger'; + +export const DEVICE_AGENT_OPERATIONS: Record = { + downloadMacAgent: { + summary: 'Download macOS Device Agent', + description: + 'Downloads the Comp AI Device Agent installer for macOS as a DMG file. The agent helps monitor device compliance and security policies. Supports both API key authentication (X-API-Key header) and session authentication (cookies + X-Organization-Id header).', + }, + downloadWindowsAgent: { + summary: 'Download Windows Device Agent ZIP', + description: + 'Downloads a ZIP package containing the Comp AI Device Agent installer for Windows, along with setup scripts and instructions. The package includes an MSI installer, setup batch script customized for the organization and user, and a README with installation instructions. Supports both API key authentication (X-API-Key header) and session authentication (cookies + X-Organization-Id header).', + }, +}; 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 new file mode 100644 index 000000000..721f53e5d --- /dev/null +++ b/apps/api/src/device-agent/schemas/download-mac-agent.responses.ts @@ -0,0 +1,65 @@ +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', + }, + 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"', + }, + }, + '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, + }, + }, + }, + }, + 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, + }, + }, + }, + }, +}; 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 new file mode 100644 index 000000000..7d055b349 --- /dev/null +++ b/apps/api/src/device-agent/schemas/download-windows-agent.responses.ts @@ -0,0 +1,65 @@ +import type { ApiResponseOptions } from '@nestjs/swagger'; + +export const DOWNLOAD_WINDOWS_AGENT_RESPONSES: Record = { + 200: { + description: 'Windows agent ZIP file download containing MSI installer and setup scripts', + content: { + 'application/zip': { + schema: { + type: 'string', + format: 'binary', + }, + example: 'Binary ZIP file content', + }, + }, + headers: { + 'Content-Disposition': { + description: 'Indicates file should be downloaded with specific filename', + schema: { + type: 'string', + example: 'attachment; filename="compai-device-agent-windows.zip"', + }, + }, + 'Content-Type': { + description: 'MIME type for ZIP archive', + schema: { + type: 'string', + example: 'application/zip', + }, + }, + }, + }, + 401: { + description: 'Unauthorized - Invalid or missing authentication', + content: { + 'application/json': { + example: { + message: 'Unauthorized', + statusCode: 401, + }, + }, + }, + }, + 404: { + description: 'Windows agent file not found in S3', + content: { + 'application/json': { + example: { + message: 'Failed to create Windows agent zip', + statusCode: 404, + }, + }, + }, + }, + 500: { + description: 'Internal server error', + content: { + 'application/json': { + example: { + message: 'Internal server error', + statusCode: 500, + }, + }, + }, + }, +}; diff --git a/apps/api/src/device-agent/scripts/common.ts b/apps/api/src/device-agent/scripts/common.ts new file mode 100644 index 000000000..a243f69fb --- /dev/null +++ b/apps/api/src/device-agent/scripts/common.ts @@ -0,0 +1,44 @@ +import type { SupportedOS } from './types'; + +export function getScriptFilename(os: SupportedOS): string { + return os === 'macos' ? 'run_me_first.command' : 'run_me_first.bat'; +} + +export function getPackageFilename(os: SupportedOS): string { + return os === 'macos' ? 'compai-device-agent.pkg' : 'compai-device-agent.msi'; +} + +export function getReadmeContent(os: SupportedOS): string { + if (os === 'macos') { + return `Installation Instructions for macOS: + +1. First, run the setup script by double-clicking "run_me_first.command" + - This will create the necessary organization markers for device management + - You may need to allow the script to run in System Preferences > Security & Privacy + +2. Then, install the agent by double-clicking "compai-device-agent.pkg" + - Follow the installation wizard + - You may need to allow the installer in System Preferences > Security & Privacy + +3. The agent will start automatically after installation +`; + } + + return `Installation Instructions for Windows: + +1. First, run the setup script: + - Right-click on "run_me_first.bat" and select "Run as administrator" (required) + - This writes organization markers to the device and registry + - If prompted by SmartScreen, click "More info" -> "Run anyway" + +2. Then, install the agent: + - Double-click "compai-device-agent.msi" and follow the wizard + +3. Troubleshooting: + - If setup fails, open the log at: %ProgramData%\\CompAI\\Fleet or %Public%\\CompAI\\Fleet -> setup.log + - Ensure your antivirus or endpoint protection allows running local .bat files + - If you cannot run as administrator, ask IT to assist or install both files and registry keys manually + +4. After installation, the agent will start automatically. +`; +} diff --git a/apps/api/src/device-agent/scripts/types.ts b/apps/api/src/device-agent/scripts/types.ts new file mode 100644 index 000000000..089435f5b --- /dev/null +++ b/apps/api/src/device-agent/scripts/types.ts @@ -0,0 +1,7 @@ +export type SupportedOS = 'macos' | 'windows'; + +export interface ScriptConfig { + orgId: string; + employeeId: string; + fleetDevicePath: string; +} diff --git a/apps/api/src/device-agent/scripts/windows.ts b/apps/api/src/device-agent/scripts/windows.ts new file mode 100644 index 000000000..f0985c0c2 --- /dev/null +++ b/apps/api/src/device-agent/scripts/windows.ts @@ -0,0 +1,222 @@ +import type { ScriptConfig } from './types'; + +export function generateWindowsScript(config: ScriptConfig): string { + const { orgId, employeeId, fleetDevicePath } = config; + + const script = `@echo off +title CompAI Device Setup +setlocal EnableExtensions EnableDelayedExpansion +color 0A + +REM ========================= +REM Variables +REM ========================= +set "ORG_ID=${orgId}" +set "EMPLOYEE_ID=${employeeId}" +set "PRIMARY_DIR=${fleetDevicePath}" +set "FALLBACK_DIR=C:\\Users\\Public\\CompAI\\Fleet" +set "CHOSEN_DIR=" +set "LOG_FILE=" +set "HAS_ERROR=0" +set "ERRORS=" +set "EXIT_CODE=0" +REM newline token (exactly this 2-line shape) +set "nl=^ +" + +REM --- bootstrap log (updated once CHOSEN_DIR is known) --- +set "LOG_FILE=%~dp0setup.log" + +goto :main + +REM ======================================================= +REM Subroutines (placed AFTER main to avoid early execution) +REM ======================================================= +:log_msg +setlocal EnableDelayedExpansion +set "msg=%~1" +echo [%date% %time%] !msg! +>>"%LOG_FILE%" echo [%date% %time%] !msg! +endlocal & exit /b 0 + +:log_run +setlocal EnableDelayedExpansion +set "cmdline=%*" +echo [%date% %time%] CMD: !cmdline! +>>"%LOG_FILE%" echo [%date% %time%] CMD: !cmdline! +%* +set "rc=!errorlevel!" +if not "!rc!"=="0" ( + echo [%date% %time%] ERR !rc!: !cmdline! + >>"%LOG_FILE%" echo [%date% %time%] ERR !rc!: !cmdline! +) +endlocal & set "LAST_RC=%rc%" +exit /b %LAST_RC% + +REM ========================= +REM Main +REM ========================= +:main +call :log_msg "Script starting" + +REM Admin check +whoami /groups | find "S-1-16-12288" >nul 2>&1 +if errorlevel 1 ( + color 0E + echo This script must be run as Administrator. + echo Please right-click the file and select "Run as administrator". + echo. + echo Press any key to exit, then try again with Administrator privileges. + pause + exit /b 5 +) + +REM Relaunch persistent window +if not "%PERSIST%"=="1" ( + set "PERSIST=1" + call :log_msg "Re-launching in a persistent window" + start "CompAI Device Setup" cmd /k "%~f0 %*" + exit /b +) + +call :log_msg "Running with administrator privileges" +call :log_msg "Current directory: %cd%" +call :log_msg "Script path: %~f0" +call :log_msg "Switching working directory to script folder" +cd /d "%~dp0" +call :log_msg "New current directory: %cd%" +echo. + +REM Choose writable directory +call :log_msg "Choosing destination directory; primary=%PRIMARY_DIR% fallback=%FALLBACK_DIR%" +if exist "%PRIMARY_DIR%\\*" set "CHOSEN_DIR=%PRIMARY_DIR%" +if not defined CHOSEN_DIR call :log_run mkdir "%PRIMARY_DIR%" +if not defined CHOSEN_DIR if exist "%PRIMARY_DIR%\\*" set "CHOSEN_DIR=%PRIMARY_DIR%" + +if not defined CHOSEN_DIR call :log_msg "Primary not available; trying fallback" +if not defined CHOSEN_DIR if exist "%FALLBACK_DIR%\\*" set "CHOSEN_DIR=%FALLBACK_DIR%" +if not defined CHOSEN_DIR call :log_run mkdir "%FALLBACK_DIR%" +if not defined CHOSEN_DIR if exist "%FALLBACK_DIR%\\*" set "CHOSEN_DIR=%FALLBACK_DIR%" + +if not defined CHOSEN_DIR ( + color 0E + call :log_msg "WARNING: No writable directory found" + echo Primary attempted: "%PRIMARY_DIR%" + echo Fallback attempted: "%FALLBACK_DIR%" + echo [%date% %time%] No writable directory found. Primary: %PRIMARY_DIR%, Fallback: %FALLBACK_DIR% >> "%~dp0setup.log" + set "LOG_FILE=%~dp0setup.log" + set "HAS_ERROR=1" + set "ERRORS=!ERRORS!- No writable directory found (Primary: %PRIMARY_DIR%, Fallback: %FALLBACK_DIR%).!nl!" + set "EXIT_CODE=1" +) else ( + set "MARKER_DIR=%CHOSEN_DIR%" + if not "!MARKER_DIR:~-1!"=="\\" set "MARKER_DIR=!MARKER_DIR!\\" + + REM switch the log file to the chosen directory, carry over bootstrap logs + set "FINAL_LOG=!MARKER_DIR!setup.log" + if /i not "%LOG_FILE%"=="%FINAL_LOG%" ( + call :log_msg "Switching log to !FINAL_LOG!" + if exist "%LOG_FILE%" type "%LOG_FILE%" >> "!FINAL_LOG!" & del "%LOG_FILE%" + set "LOG_FILE=!FINAL_LOG!" + ) + call :log_msg "Using directory: !MARKER_DIR!" +) +echo Logs will be written to: !LOG_FILE! +echo. + +REM Write marker files +if defined CHOSEN_DIR ( + call :log_msg "Writing organization marker file" + call :log_msg "Preparing to write org marker to !MARKER_DIR!!ORG_ID!" + call :log_run cmd /c "(echo %ORG_ID%) > \"!MARKER_DIR!!ORG_ID!\"" + if errorlevel 1 ( + color 0E + call :log_msg "WARNING: Failed writing organization marker file to !MARKER_DIR!" + echo [%date% %time%] Failed writing org marker file >> "%LOG_FILE%" + set "HAS_ERROR=1" + set "ERRORS=!ERRORS!- Failed writing organization marker file.!nl!" + set "EXIT_CODE=1" + ) else ( + call :log_msg "[OK] Organization marker file: !MARKER_DIR!!ORG_ID!" + ) + + call :log_msg "Writing employee marker file" + call :log_msg "Preparing to write employee marker to !MARKER_DIR!!EMPLOYEE_ID!" + call :log_run cmd /c "(echo %EMPLOYEE_ID%) > \"!MARKER_DIR!!EMPLOYEE_ID!\"" + if errorlevel 1 ( + color 0E + call :log_msg "WARNING: Failed writing employee marker file to !MARKER_DIR!" + echo [%date% %time%] Failed writing employee marker file >> "%LOG_FILE%" + set "HAS_ERROR=1" + set "ERRORS=!ERRORS!- Failed writing employee marker file.!nl!" + set "EXIT_CODE=1" + ) else ( + call :log_msg "[OK] Employee marker file: !MARKER_DIR!!EMPLOYEE_ID!" + ) +) + +REM Permissions +if defined CHOSEN_DIR ( + call :log_msg "Setting permissions on marker directory" + call :log_run icacls "!MARKER_DIR!" /inheritance:e + + call :log_msg "Granting read to SYSTEM and Administrators on org marker" + call :log_run icacls "!MARKER_DIR!!ORG_ID!" /grant *S-1-5-18:R *S-1-5-32-544:R + + call :log_msg "Granting read to SYSTEM and Administrators on employee marker" + call :log_run icacls "!MARKER_DIR!!EMPLOYEE_ID!" /grant *S-1-5-18:R *S-1-5-32-544:R +) + +REM Verify +echo. +echo Verifying markers... +if defined CHOSEN_DIR ( + call :log_msg "Verifying marker exists: !MARKER_DIR!!EMPLOYEE_ID!" + if not exist "!MARKER_DIR!!EMPLOYEE_ID!" ( + color 0E + call :log_msg "WARNING: Employee marker file missing at !MARKER_DIR!!EMPLOYEE_ID!" + echo [%date% %time%] Verification failed: employee marker file missing >> "!LOG_FILE!" + set "HAS_ERROR=1" + set "ERRORS=!ERRORS!- Employee marker file missing at !MARKER_DIR!!EMPLOYEE_ID!!.!nl!" + set "EXIT_CODE=2" + ) else ( + call :log_msg "[OK] Employee marker file present: !MARKER_DIR!!EMPLOYEE_ID!" + ) +) +rem Skipping registry checks per request + +REM Result / Exit +echo. +echo ------------------------------------------------------------ +if "%HAS_ERROR%"=="0" ( + color 0A + echo RESULT: SUCCESS + echo Setup completed successfully for %EMPLOYEE_ID%. + if defined CHOSEN_DIR echo Files created in: !CHOSEN_DIR! + echo Log file: !LOG_FILE! + call :log_msg "RESULT: SUCCESS" +) else ( + color 0C + echo RESULT: COMPLETED WITH ISSUES + echo One or more steps did not complete successfully. Details: + echo. + echo !ERRORS! + echo. + echo Next steps: + echo - Take a screenshot of this window. + echo - Attach the log file from: !LOG_FILE! + echo - Share both with your CompAI support contact. + call :log_msg "RESULT: COMPLETED WITH ISSUES (exit=%EXIT_CODE%)" +) +echo ------------------------------------------------------------ +echo. +echo Press any key to close this window. This will not affect installation. +pause +if "%HAS_ERROR%"=="0" (exit /b 0) else (exit /b %EXIT_CODE%) + +REM End of main +goto :eof +`; + + return script.replace(/\n/g, '\r\n'); +} diff --git a/apps/api/src/policies/dto/create-policy.dto.ts b/apps/api/src/policies/dto/create-policy.dto.ts new file mode 100644 index 000000000..5fb72b68a --- /dev/null +++ b/apps/api/src/policies/dto/create-policy.dto.ts @@ -0,0 +1,138 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsString, IsOptional, IsEnum, IsBoolean, IsArray, IsDateString } from 'class-validator'; + +export enum PolicyStatus { + DRAFT = 'draft', + PUBLISHED = 'published', + NEEDS_REVIEW = 'needs_review', +} + +export enum Frequency { + MONTHLY = 'monthly', + QUARTERLY = 'quarterly', + YEARLY = 'yearly', +} + +export enum Departments { + NONE = 'none', + ADMIN = 'admin', + GOV = 'gov', + HR = 'hr', + IT = 'it', + ITSM = 'itsm', + QMS = 'qms', +} + +export class CreatePolicyDto { + @ApiProperty({ + description: 'Name of the policy', + example: 'Data Privacy Policy', + }) + @IsString() + name: string; + + @ApiProperty({ + description: 'Description of the policy', + example: 'This policy outlines how we handle and protect personal data', + required: false, + }) + @IsOptional() + @IsString() + description?: string; + + @ApiProperty({ + description: 'Status of the policy', + enum: PolicyStatus, + example: PolicyStatus.DRAFT, + required: false, + }) + @IsOptional() + @IsEnum(PolicyStatus) + status?: PolicyStatus; + + @ApiProperty({ + description: 'Content of the policy in JSON format', + example: [{ type: 'paragraph', content: 'Policy content here' }], + type: 'array', + items: { type: 'object' }, + }) + @IsArray() + content: any[]; + + @ApiProperty({ + description: 'Review frequency of the policy', + enum: Frequency, + example: Frequency.YEARLY, + required: false, + }) + @IsOptional() + @IsEnum(Frequency) + frequency?: Frequency; + + @ApiProperty({ + description: 'Department this policy applies to', + enum: Departments, + example: Departments.IT, + required: false, + }) + @IsOptional() + @IsEnum(Departments) + department?: Departments; + + @ApiProperty({ + description: 'Whether this policy requires a signature', + example: true, + required: false, + }) + @IsOptional() + @IsBoolean() + isRequiredToSign?: boolean; + + @ApiProperty({ + description: 'Review date for the policy', + example: '2024-12-31T00:00:00.000Z', + required: false, + }) + @IsOptional() + @IsDateString() + reviewDate?: string; + + @ApiProperty({ + description: 'ID of the user assigned to this policy', + example: 'usr_abc123def456', + required: false, + }) + @IsOptional() + @IsString() + assigneeId?: string; + + @ApiProperty({ + description: 'ID of the user who approved this policy', + example: 'usr_xyz789abc123', + required: false, + }) + @IsOptional() + @IsString() + approverId?: string; + + @ApiProperty({ + description: 'ID of the policy template this policy is based on', + example: 'plt_template123', + required: false, + }) + @IsOptional() + @IsString() + policyTemplateId?: string; + + @ApiProperty({ + description: 'List of user IDs who have signed this policy', + example: ['usr_123', 'usr_456'], + type: 'array', + items: { type: 'string' }, + required: false, + }) + @IsOptional() + @IsArray() + @IsString({ each: true }) + signedBy?: string[]; +} diff --git a/apps/api/src/policies/dto/policy-responses.dto.ts b/apps/api/src/policies/dto/policy-responses.dto.ts new file mode 100644 index 000000000..a24ec72d5 --- /dev/null +++ b/apps/api/src/policies/dto/policy-responses.dto.ts @@ -0,0 +1,134 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { PolicyStatus, Frequency, Departments } from './create-policy.dto'; + +export class PolicyResponseDto { + @ApiProperty({ + description: 'The policy ID', + example: 'pol_abc123def456', + }) + id: string; + + @ApiProperty({ + description: 'Name of the policy', + example: 'Data Privacy Policy', + }) + name: string; + + @ApiProperty({ + description: 'Description of the policy', + example: 'This policy outlines how we handle and protect personal data', + nullable: true, + }) + description?: string; + + @ApiProperty({ + description: 'Status of the policy', + enum: PolicyStatus, + example: PolicyStatus.DRAFT, + }) + status: PolicyStatus; + + @ApiProperty({ + description: 'Content of the policy in JSON format', + example: [{ type: 'paragraph', content: 'Policy content here' }], + type: 'array', + items: { type: 'object' }, + }) + content: any[]; + + @ApiProperty({ + description: 'Review frequency of the policy', + enum: Frequency, + example: Frequency.YEARLY, + nullable: true, + }) + frequency?: Frequency; + + @ApiProperty({ + description: 'Department this policy applies to', + enum: Departments, + example: Departments.IT, + nullable: true, + }) + department?: Departments; + + @ApiProperty({ + description: 'Whether this policy requires a signature', + example: true, + }) + isRequiredToSign: boolean; + + @ApiProperty({ + description: 'List of user IDs who have signed this policy', + example: ['usr_123', 'usr_456'], + type: 'array', + items: { type: 'string' }, + }) + signedBy: string[]; + + @ApiProperty({ + description: 'Review date for the policy', + example: '2024-12-31T00:00:00.000Z', + nullable: true, + }) + reviewDate?: Date; + + @ApiProperty({ + description: 'Whether this policy is archived', + example: false, + }) + isArchived: boolean; + + @ApiProperty({ + description: 'When the policy was created', + example: '2024-01-01T00:00:00.000Z', + }) + createdAt: Date; + + @ApiProperty({ + description: 'When the policy was last updated', + example: '2024-01-15T00:00:00.000Z', + }) + updatedAt: Date; + + @ApiProperty({ + description: 'When the policy was last archived', + example: '2024-02-01T00:00:00.000Z', + nullable: true, + }) + lastArchivedAt?: Date; + + @ApiProperty({ + description: 'When the policy was last published', + example: '2024-01-10T00:00:00.000Z', + nullable: true, + }) + lastPublishedAt?: Date; + + @ApiProperty({ + description: 'Organization ID this policy belongs to', + example: 'org_abc123def456', + }) + organizationId: string; + + @ApiProperty({ + description: 'ID of the user assigned to this policy', + example: 'usr_abc123def456', + nullable: true, + }) + assigneeId?: string; + + @ApiProperty({ + description: 'ID of the user who approved this policy', + example: 'usr_xyz789abc123', + nullable: true, + }) + approverId?: string; + + @ApiProperty({ + description: 'ID of the policy template this policy is based on', + example: 'plt_template123', + nullable: true, + }) + policyTemplateId?: string; +} diff --git a/apps/api/src/policies/dto/update-policy.dto.ts b/apps/api/src/policies/dto/update-policy.dto.ts new file mode 100644 index 000000000..0bb03bfb0 --- /dev/null +++ b/apps/api/src/policies/dto/update-policy.dto.ts @@ -0,0 +1,14 @@ +import { ApiProperty, PartialType } from '@nestjs/swagger'; +import { IsOptional, IsBoolean } from 'class-validator'; +import { CreatePolicyDto } from './create-policy.dto'; + +export class UpdatePolicyDto extends PartialType(CreatePolicyDto) { + @ApiProperty({ + description: 'Whether to archive this policy', + example: false, + required: false, + }) + @IsOptional() + @IsBoolean() + isArchived?: boolean; +} diff --git a/apps/api/src/policies/policies.controller.ts b/apps/api/src/policies/policies.controller.ts new file mode 100644 index 000000000..c5242a4a3 --- /dev/null +++ b/apps/api/src/policies/policies.controller.ts @@ -0,0 +1,179 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Patch, + Post, + UseGuards +} from '@nestjs/common'; +import { + ApiBody, + ApiHeader, + ApiOperation, + ApiParam, + ApiResponse, + ApiSecurity, + ApiTags, +} from '@nestjs/swagger'; +import { + AuthContext, + OrganizationId, +} from '../auth/auth-context.decorator'; +import { HybridAuthGuard } from '../auth/hybrid-auth.guard'; +import type { AuthContext as AuthContextType } from '../auth/types'; +import { CreatePolicyDto } from './dto/create-policy.dto'; +import { UpdatePolicyDto } from './dto/update-policy.dto'; +import { PoliciesService } from './policies.service'; +import { GET_ALL_POLICIES_RESPONSES } from './schemas/get-all-policies.responses'; +import { GET_POLICY_BY_ID_RESPONSES } from './schemas/get-policy-by-id.responses'; +import { CREATE_POLICY_RESPONSES } from './schemas/create-policy.responses'; +import { UPDATE_POLICY_RESPONSES } from './schemas/update-policy.responses'; +import { DELETE_POLICY_RESPONSES } from './schemas/delete-policy.responses'; +import { POLICY_OPERATIONS } from './schemas/policy-operations'; +import { POLICY_PARAMS } from './schemas/policy-params'; +import { POLICY_BODIES } from './schemas/policy-bodies'; + +@ApiTags('Policies') +@Controller({ path: 'policies', version: '1' }) +@UseGuards(HybridAuthGuard) +@ApiSecurity('apikey') +@ApiHeader({ + name: 'X-Organization-Id', + description: + 'Organization ID (required for session auth, optional for API key auth)', + required: false, +}) +export class PoliciesController { + constructor(private readonly policiesService: PoliciesService) {} + + @Get() + @ApiOperation(POLICY_OPERATIONS.getAllPolicies) + @ApiResponse(GET_ALL_POLICIES_RESPONSES[200]) + @ApiResponse(GET_ALL_POLICIES_RESPONSES[401]) + async getAllPolicies( + @OrganizationId() organizationId: string, + @AuthContext() authContext: AuthContextType, + ) { + const policies = await this.policiesService.findAll(organizationId); + + return { + data: policies, + authType: authContext.authType, + ...(authContext.userId && { + authenticatedUser: { + id: authContext.userId, + email: authContext.userEmail, + }, + }), + }; + } + + @Get(':id') + @ApiOperation(POLICY_OPERATIONS.getPolicyById) + @ApiParam(POLICY_PARAMS.policyId) + @ApiResponse(GET_POLICY_BY_ID_RESPONSES[200]) + @ApiResponse(GET_POLICY_BY_ID_RESPONSES[401]) + @ApiResponse(GET_POLICY_BY_ID_RESPONSES[404]) + async getPolicy( + @Param('id') id: string, + @OrganizationId() organizationId: string, + @AuthContext() authContext: AuthContextType, + ) { + const policy = await this.policiesService.findById(id, organizationId); + + return { + ...policy, + authType: authContext.authType, + ...(authContext.userId && { + authenticatedUser: { + id: authContext.userId, + email: authContext.userEmail, + }, + }), + }; + } + + @Post() + @ApiOperation(POLICY_OPERATIONS.createPolicy) + @ApiBody(POLICY_BODIES.createPolicy) + @ApiResponse(CREATE_POLICY_RESPONSES[201]) + @ApiResponse(CREATE_POLICY_RESPONSES[400]) + @ApiResponse(CREATE_POLICY_RESPONSES[401]) + async createPolicy( + @Body() createData: CreatePolicyDto, + @OrganizationId() organizationId: string, + @AuthContext() authContext: AuthContextType, + ) { + const policy = await this.policiesService.create(organizationId, createData); + + return { + ...policy, + authType: authContext.authType, + ...(authContext.userId && { + authenticatedUser: { + id: authContext.userId, + email: authContext.userEmail, + }, + }), + }; + } + + @Patch(':id') + @ApiOperation(POLICY_OPERATIONS.updatePolicy) + @ApiParam(POLICY_PARAMS.policyId) + @ApiBody(POLICY_BODIES.updatePolicy) + @ApiResponse(UPDATE_POLICY_RESPONSES[200]) + @ApiResponse(UPDATE_POLICY_RESPONSES[400]) + @ApiResponse(UPDATE_POLICY_RESPONSES[401]) + @ApiResponse(UPDATE_POLICY_RESPONSES[404]) + async updatePolicy( + @Param('id') id: string, + @Body() updateData: UpdatePolicyDto, + @OrganizationId() organizationId: string, + @AuthContext() authContext: AuthContextType, + ) { + const updatedPolicy = await this.policiesService.updateById( + id, + organizationId, + updateData, + ); + + return { + ...updatedPolicy, + authType: authContext.authType, + ...(authContext.userId && { + authenticatedUser: { + id: authContext.userId, + email: authContext.userEmail, + }, + }), + }; + } + + @Delete(':id') + @ApiOperation(POLICY_OPERATIONS.deletePolicy) + @ApiParam(POLICY_PARAMS.policyId) + @ApiResponse(DELETE_POLICY_RESPONSES[200]) + @ApiResponse(DELETE_POLICY_RESPONSES[401]) + @ApiResponse(DELETE_POLICY_RESPONSES[404]) + async deletePolicy( + @Param('id') id: string, + @OrganizationId() organizationId: string, + @AuthContext() authContext: AuthContextType, + ) { + const result = await this.policiesService.deleteById(id, organizationId); + + return { + ...result, + authType: authContext.authType, + ...(authContext.userId && { + authenticatedUser: { + id: authContext.userId, + email: authContext.userEmail, + }, + }), + }; + } +} diff --git a/apps/api/src/policies/policies.module.ts b/apps/api/src/policies/policies.module.ts new file mode 100644 index 000000000..ab2cc2e19 --- /dev/null +++ b/apps/api/src/policies/policies.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { AuthModule } from '../auth/auth.module'; +import { PoliciesController } from './policies.controller'; +import { PoliciesService } from './policies.service'; + +@Module({ + imports: [AuthModule], + controllers: [PoliciesController], + providers: [PoliciesService], + exports: [PoliciesService], +}) +export class PoliciesModule {} diff --git a/apps/api/src/policies/policies.service.ts b/apps/api/src/policies/policies.service.ts new file mode 100644 index 000000000..1128a2e5e --- /dev/null +++ b/apps/api/src/policies/policies.service.ts @@ -0,0 +1,230 @@ +import { Injectable, NotFoundException, Logger } from '@nestjs/common'; +import { db } from '@trycompai/db'; +import type { CreatePolicyDto } from './dto/create-policy.dto'; +import type { UpdatePolicyDto } from './dto/update-policy.dto'; + +@Injectable() +export class PoliciesService { + private readonly logger = new Logger(PoliciesService.name); + + async findAll(organizationId: string) { + try { + const policies = await db.policy.findMany({ + where: { organizationId }, + select: { + id: true, + name: true, + description: true, + status: true, + content: true, + frequency: true, + department: true, + isRequiredToSign: true, + signedBy: true, + reviewDate: true, + isArchived: true, + createdAt: true, + updatedAt: true, + lastArchivedAt: true, + lastPublishedAt: true, + organizationId: true, + assigneeId: true, + approverId: true, + policyTemplateId: true, + }, + orderBy: { createdAt: 'desc' }, + }); + + this.logger.log(`Retrieved ${policies.length} policies for organization ${organizationId}`); + return policies; + } catch (error) { + this.logger.error(`Failed to retrieve policies for organization ${organizationId}:`, error); + throw error; + } + } + + async findById(id: string, organizationId: string) { + try { + const policy = await db.policy.findFirst({ + where: { + id, + organizationId, + }, + select: { + id: true, + name: true, + description: true, + status: true, + content: true, + frequency: true, + department: true, + isRequiredToSign: true, + signedBy: true, + reviewDate: true, + isArchived: true, + createdAt: true, + updatedAt: true, + lastArchivedAt: true, + lastPublishedAt: true, + organizationId: true, + assigneeId: true, + approverId: true, + policyTemplateId: true, + }, + }); + + if (!policy) { + throw new NotFoundException(`Policy with ID ${id} not found`); + } + + this.logger.log(`Retrieved policy: ${policy.name} (${id})`); + return policy; + } catch (error) { + if (error instanceof NotFoundException) { + throw error; + } + this.logger.error(`Failed to retrieve policy ${id}:`, error); + throw error; + } + } + + async create(organizationId: string, createData: CreatePolicyDto) { + try { + const policy = await db.policy.create({ + data: { + ...createData, + organizationId, + status: createData.status || 'draft', + isRequiredToSign: createData.isRequiredToSign ?? true, + }, + select: { + id: true, + name: true, + description: true, + status: true, + content: true, + frequency: true, + department: true, + isRequiredToSign: true, + signedBy: true, + reviewDate: true, + isArchived: true, + createdAt: true, + updatedAt: true, + lastArchivedAt: true, + lastPublishedAt: true, + organizationId: true, + assigneeId: true, + approverId: true, + policyTemplateId: true, + }, + }); + + this.logger.log(`Created policy: ${policy.name} (${policy.id})`); + return policy; + } catch (error) { + this.logger.error(`Failed to create policy for organization ${organizationId}:`, error); + throw error; + } + } + + async updateById(id: string, organizationId: string, updateData: UpdatePolicyDto) { + try { + // First check if the policy exists and belongs to the organization + const existingPolicy = await db.policy.findFirst({ + where: { + id, + organizationId, + }, + select: { id: true, name: true }, + }); + + if (!existingPolicy) { + throw new NotFoundException(`Policy with ID ${id} not found`); + } + + // Prepare update data with special handling for status changes + const updatePayload: any = { ...updateData }; + + // If status is being changed to published, update lastPublishedAt + if (updateData.status === 'published') { + updatePayload.lastPublishedAt = new Date(); + } + + // If isArchived is being set to true, update lastArchivedAt + if (updateData.isArchived === true) { + updatePayload.lastArchivedAt = new Date(); + } + + // Update the policy + const updatedPolicy = await db.policy.update({ + where: { id }, + data: updatePayload, + select: { + id: true, + name: true, + description: true, + status: true, + content: true, + frequency: true, + department: true, + isRequiredToSign: true, + signedBy: true, + reviewDate: true, + isArchived: true, + createdAt: true, + updatedAt: true, + lastArchivedAt: true, + lastPublishedAt: true, + organizationId: true, + assigneeId: true, + approverId: true, + policyTemplateId: true, + }, + }); + + this.logger.log(`Updated policy: ${updatedPolicy.name} (${id})`); + return updatedPolicy; + } catch (error) { + if (error instanceof NotFoundException) { + throw error; + } + this.logger.error(`Failed to update policy ${id}:`, error); + throw error; + } + } + + async deleteById(id: string, organizationId: string) { + try { + // First check if the policy exists and belongs to the organization + const policy = await db.policy.findFirst({ + where: { + id, + organizationId, + }, + select: { + id: true, + name: true, + }, + }); + + if (!policy) { + throw new NotFoundException(`Policy with ID ${id} not found`); + } + + // Delete the policy + await db.policy.delete({ + where: { id }, + }); + + this.logger.log(`Deleted policy: ${policy.name} (${id})`); + return { success: true, deletedPolicy: policy }; + } catch (error) { + if (error instanceof NotFoundException) { + throw error; + } + this.logger.error(`Failed to delete policy ${id}:`, error); + throw error; + } + } +} diff --git a/apps/api/src/policies/schemas/create-policy.responses.ts b/apps/api/src/policies/schemas/create-policy.responses.ts new file mode 100644 index 000000000..fc04fdfc6 --- /dev/null +++ b/apps/api/src/policies/schemas/create-policy.responses.ts @@ -0,0 +1,31 @@ +import { ApiResponseOptions } from '@nestjs/swagger'; + +export const CREATE_POLICY_RESPONSES: Record = { + 201: { + status: 201, + description: 'Policy created successfully', + type: 'PolicyResponseDto', + }, + 400: { + status: 400, + description: 'Bad Request - Invalid policy data', + schema: { + type: 'object', + properties: { + message: { + type: 'string', + examples: [ + 'Validation failed', + 'Invalid policy content format', + 'Policy name already exists', + ], + }, + }, + }, + }, + 401: { + status: 401, + description: + 'Unauthorized - Invalid authentication or insufficient permissions', + }, +}; diff --git a/apps/api/src/policies/schemas/delete-policy.responses.ts b/apps/api/src/policies/schemas/delete-policy.responses.ts new file mode 100644 index 000000000..6a0ffc2dc --- /dev/null +++ b/apps/api/src/policies/schemas/delete-policy.responses.ts @@ -0,0 +1,47 @@ +import { ApiResponseOptions } from '@nestjs/swagger'; + +export const DELETE_POLICY_RESPONSES: Record = { + 200: { + status: 200, + description: 'Policy deleted successfully', + schema: { + type: 'object', + properties: { + success: { + type: 'boolean', + description: 'Indicates successful deletion', + example: true, + }, + deletedPolicy: { + type: 'object', + properties: { + id: { + type: 'string', + description: 'The deleted policy ID', + example: 'pol_abc123def456', + }, + name: { + type: 'string', + description: 'The deleted policy name', + example: 'Data Privacy Policy', + }, + }, + }, + authType: { + type: 'string', + enum: ['api-key', 'session'], + description: 'How the request was authenticated', + }, + }, + }, + }, + 401: { + status: 401, + description: + 'Unauthorized - Invalid authentication or insufficient permissions', + }, + 404: { + status: 404, + description: 'Policy not found', + }, +}; diff --git a/apps/api/src/policies/schemas/get-all-policies.responses.ts b/apps/api/src/policies/schemas/get-all-policies.responses.ts new file mode 100644 index 000000000..e1ab68248 --- /dev/null +++ b/apps/api/src/policies/schemas/get-all-policies.responses.ts @@ -0,0 +1,29 @@ +import { ApiResponseOptions } from '@nestjs/swagger'; + +export const GET_ALL_POLICIES_RESPONSES: Record = { + 200: { + status: 200, + description: 'Policies retrieved successfully', + type: 'PolicyResponseDto', + isArray: true, + }, + 401: { + status: 401, + description: + 'Unauthorized - Invalid authentication or insufficient permissions', + schema: { + type: 'object', + properties: { + message: { + type: 'string', + examples: [ + 'Invalid or expired API key', + 'Invalid or expired session', + 'User does not have access to organization', + 'Organization context required', + ], + }, + }, + }, + }, +}; diff --git a/apps/api/src/policies/schemas/get-policy-by-id.responses.ts b/apps/api/src/policies/schemas/get-policy-by-id.responses.ts new file mode 100644 index 000000000..85c4daeb4 --- /dev/null +++ b/apps/api/src/policies/schemas/get-policy-by-id.responses.ts @@ -0,0 +1,27 @@ +import { ApiResponseOptions } from '@nestjs/swagger'; + +export const GET_POLICY_BY_ID_RESPONSES: Record = { + 200: { + status: 200, + description: 'Policy retrieved successfully', + type: 'PolicyResponseDto', + }, + 401: { + status: 401, + description: + 'Unauthorized - Invalid authentication or insufficient permissions', + }, + 404: { + status: 404, + description: 'Policy not found', + schema: { + type: 'object', + properties: { + message: { + type: 'string', + example: 'Policy with ID pol_abc123def456 not found', + }, + }, + }, + }, +}; diff --git a/apps/api/src/policies/schemas/policy-bodies.ts b/apps/api/src/policies/schemas/policy-bodies.ts new file mode 100644 index 000000000..64ab3b175 --- /dev/null +++ b/apps/api/src/policies/schemas/policy-bodies.ts @@ -0,0 +1,14 @@ +import type { ApiBodyOptions } from '@nestjs/swagger'; +import { CreatePolicyDto } from '../dto/create-policy.dto'; +import { UpdatePolicyDto } from '../dto/update-policy.dto'; + +export const POLICY_BODIES: Record = { + createPolicy: { + description: 'Policy creation data', + type: CreatePolicyDto, + }, + updatePolicy: { + description: 'Policy update data', + type: UpdatePolicyDto, + }, +}; diff --git a/apps/api/src/policies/schemas/policy-operations.ts b/apps/api/src/policies/schemas/policy-operations.ts new file mode 100644 index 000000000..4c7076551 --- /dev/null +++ b/apps/api/src/policies/schemas/policy-operations.ts @@ -0,0 +1,29 @@ +import type { ApiOperationOptions } from '@nestjs/swagger'; + +export const POLICY_OPERATIONS: Record = { + getAllPolicies: { + summary: 'Get all policies', + description: + 'Returns all policies for the authenticated organization. Supports both API key authentication (X-API-Key header) and session authentication (cookies + X-Organization-Id header).', + }, + getPolicyById: { + summary: 'Get policy by ID', + description: + 'Returns a specific policy by ID for the authenticated organization. Supports both API key authentication (X-API-Key header) and session authentication (cookies + X-Organization-Id header).', + }, + createPolicy: { + summary: 'Create a new policy', + description: + 'Creates a new policy for the authenticated organization. Supports both API key authentication (X-API-Key header) and session authentication (cookies + X-Organization-Id header).', + }, + updatePolicy: { + summary: 'Update policy', + description: + 'Partially updates a policy. Only provided fields will be updated. Supports both API key authentication (X-API-Key header) and session authentication (cookies + X-Organization-Id header).', + }, + deletePolicy: { + summary: 'Delete policy', + description: + 'Permanently deletes a policy. This action cannot be undone. Supports both API key authentication (X-API-Key header) and session authentication (cookies + X-Organization-Id header).', + }, +}; diff --git a/apps/api/src/policies/schemas/policy-params.ts b/apps/api/src/policies/schemas/policy-params.ts new file mode 100644 index 000000000..d911fa730 --- /dev/null +++ b/apps/api/src/policies/schemas/policy-params.ts @@ -0,0 +1,9 @@ +import type { ApiParamOptions } from '@nestjs/swagger'; + +export const POLICY_PARAMS: Record = { + policyId: { + name: 'id', + description: 'Policy ID', + example: 'pol_abc123def456', + }, +}; diff --git a/apps/api/src/policies/schemas/update-policy.responses.ts b/apps/api/src/policies/schemas/update-policy.responses.ts new file mode 100644 index 000000000..b35bc4ba5 --- /dev/null +++ b/apps/api/src/policies/schemas/update-policy.responses.ts @@ -0,0 +1,22 @@ +import { ApiResponseOptions } from '@nestjs/swagger'; + +export const UPDATE_POLICY_RESPONSES: Record = { + 200: { + status: 200, + description: 'Policy updated successfully', + type: 'PolicyResponseDto', + }, + 400: { + status: 400, + description: 'Bad Request - Invalid update data', + }, + 401: { + status: 401, + description: + 'Unauthorized - Invalid authentication or insufficient permissions', + }, + 404: { + status: 404, + description: 'Policy not found', + }, +}; diff --git a/apps/api/src/risks/dto/create-risk.dto.ts b/apps/api/src/risks/dto/create-risk.dto.ts new file mode 100644 index 000000000..2c68b362d --- /dev/null +++ b/apps/api/src/risks/dto/create-risk.dto.ts @@ -0,0 +1,124 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsString, IsNotEmpty, IsOptional, IsEnum } from 'class-validator'; +import { + RiskCategory, + Departments, + RiskStatus, + Likelihood, + Impact, + RiskTreatmentType +} from '@trycompai/db'; + +export class CreateRiskDto { + @ApiProperty({ + description: 'Risk title', + example: 'Data breach vulnerability in user authentication system', + }) + @IsString() + @IsNotEmpty() + title: string; + + @ApiProperty({ + description: 'Detailed description of the risk', + example: 'Weak password requirements could lead to unauthorized access to user accounts', + }) + @IsString() + @IsNotEmpty() + description: string; + + @ApiProperty({ + description: 'Risk category', + enum: RiskCategory, + example: RiskCategory.technology, + }) + @IsEnum(RiskCategory) + category: RiskCategory; + + @ApiProperty({ + description: 'Department responsible for the risk', + enum: Departments, + required: false, + example: Departments.it, + }) + @IsOptional() + @IsEnum(Departments) + department?: Departments; + + @ApiProperty({ + description: 'Current status of the risk', + enum: RiskStatus, + default: RiskStatus.open, + example: RiskStatus.open, + }) + @IsOptional() + @IsEnum(RiskStatus) + status?: RiskStatus; + + @ApiProperty({ + description: 'Likelihood of the risk occurring', + enum: Likelihood, + default: Likelihood.very_unlikely, + example: Likelihood.possible, + }) + @IsOptional() + @IsEnum(Likelihood) + likelihood?: Likelihood; + + @ApiProperty({ + description: 'Impact if the risk materializes', + enum: Impact, + default: Impact.insignificant, + example: Impact.major, + }) + @IsOptional() + @IsEnum(Impact) + impact?: Impact; + + @ApiProperty({ + description: 'Residual likelihood after treatment', + enum: Likelihood, + default: Likelihood.very_unlikely, + example: Likelihood.unlikely, + }) + @IsOptional() + @IsEnum(Likelihood) + residualLikelihood?: Likelihood; + + @ApiProperty({ + description: 'Residual impact after treatment', + enum: Impact, + default: Impact.insignificant, + example: Impact.minor, + }) + @IsOptional() + @IsEnum(Impact) + residualImpact?: Impact; + + @ApiProperty({ + description: 'Description of the treatment strategy', + required: false, + example: 'Implement multi-factor authentication and strengthen password requirements', + }) + @IsOptional() + @IsString() + treatmentStrategyDescription?: string; + + @ApiProperty({ + description: 'Risk treatment strategy', + enum: RiskTreatmentType, + default: RiskTreatmentType.accept, + example: RiskTreatmentType.mitigate, + }) + @IsOptional() + @IsEnum(RiskTreatmentType) + treatmentStrategy?: RiskTreatmentType; + + @ApiProperty({ + description: 'ID of the user assigned to this risk', + required: false, + example: 'mem_abc123def456', + }) + @IsOptional() + @IsString() + assigneeId?: string; +} diff --git a/apps/api/src/risks/dto/risk-response.dto.ts b/apps/api/src/risks/dto/risk-response.dto.ts new file mode 100644 index 000000000..20681471d --- /dev/null +++ b/apps/api/src/risks/dto/risk-response.dto.ts @@ -0,0 +1,122 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { + RiskCategory, + Departments, + RiskStatus, + Likelihood, + Impact, + RiskTreatmentType +} from '@trycompai/db'; + +export class RiskResponseDto { + @ApiProperty({ + description: 'Risk ID', + example: 'rsk_abc123def456', + }) + id: string; + + @ApiProperty({ + description: 'Risk title', + example: 'Data breach vulnerability in user authentication system', + }) + title: string; + + @ApiProperty({ + description: 'Detailed description of the risk', + example: 'Weak password requirements could lead to unauthorized access to user accounts', + }) + description: string; + + @ApiProperty({ + description: 'Risk category', + enum: RiskCategory, + example: RiskCategory.technology, + }) + category: RiskCategory; + + @ApiProperty({ + description: 'Department responsible for the risk', + enum: Departments, + nullable: true, + example: Departments.it, + }) + department: Departments | null; + + @ApiProperty({ + description: 'Current status of the risk', + enum: RiskStatus, + example: RiskStatus.open, + }) + status: RiskStatus; + + @ApiProperty({ + description: 'Likelihood of the risk occurring', + enum: Likelihood, + example: Likelihood.possible, + }) + likelihood: Likelihood; + + @ApiProperty({ + description: 'Impact if the risk materializes', + enum: Impact, + example: Impact.major, + }) + impact: Impact; + + @ApiProperty({ + description: 'Residual likelihood after treatment', + enum: Likelihood, + example: Likelihood.unlikely, + }) + residualLikelihood: Likelihood; + + @ApiProperty({ + description: 'Residual impact after treatment', + enum: Impact, + example: Impact.minor, + }) + residualImpact: Impact; + + @ApiProperty({ + description: 'Description of the treatment strategy', + nullable: true, + example: 'Implement multi-factor authentication and strengthen password requirements', + }) + treatmentStrategyDescription: string | null; + + @ApiProperty({ + description: 'Risk treatment strategy', + enum: RiskTreatmentType, + example: RiskTreatmentType.mitigate, + }) + treatmentStrategy: RiskTreatmentType; + + @ApiProperty({ + description: 'Organization ID', + example: 'org_abc123def456', + }) + organizationId: string; + + @ApiProperty({ + description: 'When the risk was created', + type: String, + format: 'date-time', + example: '2024-01-15T10:30:00Z', + }) + createdAt: Date; + + @ApiProperty({ + description: 'When the risk was last updated', + type: String, + format: 'date-time', + example: '2024-01-16T14:45:00Z', + }) + updatedAt: Date; + + @ApiProperty({ + description: 'ID of the user assigned to this risk', + example: 'usr_123abc456def', + nullable: true, + }) + assigneeId: string | null; +} diff --git a/apps/api/src/risks/dto/update-risk.dto.ts b/apps/api/src/risks/dto/update-risk.dto.ts new file mode 100644 index 000000000..f818000b0 --- /dev/null +++ b/apps/api/src/risks/dto/update-risk.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/swagger'; +import { CreateRiskDto } from './create-risk.dto'; + +export class UpdateRiskDto extends PartialType(CreateRiskDto) {} diff --git a/apps/api/src/risks/risks.controller.ts b/apps/api/src/risks/risks.controller.ts new file mode 100644 index 000000000..eb8bcf92f --- /dev/null +++ b/apps/api/src/risks/risks.controller.ts @@ -0,0 +1,187 @@ +import { + Controller, + Get, + Post, + Patch, + Delete, + Body, + Param, + UseGuards +} from '@nestjs/common'; +import { + ApiBody, + ApiHeader, + ApiOperation, + ApiParam, + ApiResponse, + ApiSecurity, + ApiTags, +} from '@nestjs/swagger'; +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'; +import { UpdateRiskDto } from './dto/update-risk.dto'; +import { RisksService } from './risks.service'; +import { RISK_OPERATIONS } from './schemas/risk-operations'; +import { RISK_PARAMS } from './schemas/risk-params'; +import { RISK_BODIES } from './schemas/risk-bodies'; +import { GET_ALL_RISKS_RESPONSES } from './schemas/get-all-risks.responses'; +import { GET_RISK_BY_ID_RESPONSES } from './schemas/get-risk-by-id.responses'; +import { CREATE_RISK_RESPONSES } from './schemas/create-risk.responses'; +import { UPDATE_RISK_RESPONSES } from './schemas/update-risk.responses'; +import { DELETE_RISK_RESPONSES } from './schemas/delete-risk.responses'; + +@ApiTags('Risks') +@Controller({ path: 'risks', version: '1' }) +@UseGuards(HybridAuthGuard) +@ApiSecurity('apikey') +@ApiHeader({ + name: 'X-Organization-Id', + description: + 'Organization ID (required for session auth, optional for API key auth)', + required: false, +}) +export class RisksController { + constructor(private readonly risksService: RisksService) {} + + @Get() + @ApiOperation(RISK_OPERATIONS.getAllRisks) + @ApiResponse(GET_ALL_RISKS_RESPONSES[200]) + @ApiResponse(GET_ALL_RISKS_RESPONSES[401]) + @ApiResponse(GET_ALL_RISKS_RESPONSES[404]) + @ApiResponse(GET_ALL_RISKS_RESPONSES[500]) + async getAllRisks( + @OrganizationId() organizationId: string, + @AuthContext() authContext: AuthContextType, + ) { + const risks = await this.risksService.findAllByOrganization(organizationId); + + return { + data: risks, + count: risks.length, + authType: authContext.authType, + ...(authContext.userId && authContext.userEmail && { + authenticatedUser: { + id: authContext.userId, + email: authContext.userEmail, + }, + }), + }; + } + + @Get(':id') + @ApiOperation(RISK_OPERATIONS.getRiskById) + @ApiParam(RISK_PARAMS.riskId) + @ApiResponse(GET_RISK_BY_ID_RESPONSES[200]) + @ApiResponse(GET_RISK_BY_ID_RESPONSES[401]) + @ApiResponse(GET_RISK_BY_ID_RESPONSES[404]) + @ApiResponse(GET_RISK_BY_ID_RESPONSES[500]) + async getRiskById( + @Param('id') riskId: string, + @OrganizationId() organizationId: string, + @AuthContext() authContext: AuthContextType, + ) { + const risk = await this.risksService.findById(riskId, organizationId); + + return { + ...risk, + authType: authContext.authType, + ...(authContext.userId && authContext.userEmail && { + authenticatedUser: { + id: authContext.userId, + email: authContext.userEmail, + }, + }), + }; + } + + @Post() + @ApiOperation(RISK_OPERATIONS.createRisk) + @ApiBody(RISK_BODIES.createRisk) + @ApiResponse(CREATE_RISK_RESPONSES[201]) + @ApiResponse(CREATE_RISK_RESPONSES[400]) + @ApiResponse(CREATE_RISK_RESPONSES[401]) + @ApiResponse(CREATE_RISK_RESPONSES[404]) + @ApiResponse(CREATE_RISK_RESPONSES[500]) + async createRisk( + @Body() createRiskDto: CreateRiskDto, + @OrganizationId() organizationId: string, + @AuthContext() authContext: AuthContextType, + ) { + const risk = await this.risksService.create(organizationId, createRiskDto); + + return { + ...risk, + authType: authContext.authType, + ...(authContext.userId && authContext.userEmail && { + authenticatedUser: { + id: authContext.userId, + email: authContext.userEmail, + }, + }), + }; + } + + @Patch(':id') + @ApiOperation(RISK_OPERATIONS.updateRisk) + @ApiParam(RISK_PARAMS.riskId) + @ApiBody(RISK_BODIES.updateRisk) + @ApiResponse(UPDATE_RISK_RESPONSES[200]) + @ApiResponse(UPDATE_RISK_RESPONSES[400]) + @ApiResponse(UPDATE_RISK_RESPONSES[401]) + @ApiResponse(UPDATE_RISK_RESPONSES[404]) + @ApiResponse(UPDATE_RISK_RESPONSES[500]) + async updateRisk( + @Param('id') riskId: string, + @Body() updateRiskDto: UpdateRiskDto, + @OrganizationId() organizationId: string, + @AuthContext() authContext: AuthContextType, + ) { + const updatedRisk = await this.risksService.updateById( + riskId, + organizationId, + updateRiskDto, + ); + + return { + ...updatedRisk, + authType: authContext.authType, + ...(authContext.userId && authContext.userEmail && { + authenticatedUser: { + id: authContext.userId, + email: authContext.userEmail, + }, + }), + }; + } + + @Delete(':id') + @ApiOperation(RISK_OPERATIONS.deleteRisk) + @ApiParam(RISK_PARAMS.riskId) + @ApiResponse(DELETE_RISK_RESPONSES[200]) + @ApiResponse(DELETE_RISK_RESPONSES[401]) + @ApiResponse(DELETE_RISK_RESPONSES[404]) + @ApiResponse(DELETE_RISK_RESPONSES[500]) + async deleteRisk( + @Param('id') riskId: string, + @OrganizationId() organizationId: string, + @AuthContext() authContext: AuthContextType, + ) { + const result = await this.risksService.deleteById(riskId, organizationId); + + return { + ...result, + authType: authContext.authType, + ...(authContext.userId && authContext.userEmail && { + authenticatedUser: { + id: authContext.userId, + email: authContext.userEmail, + }, + }), + }; + } +} diff --git a/apps/api/src/risks/risks.module.ts b/apps/api/src/risks/risks.module.ts new file mode 100644 index 000000000..e94beadcc --- /dev/null +++ b/apps/api/src/risks/risks.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { AuthModule } from '../auth/auth.module'; +import { RisksController } from './risks.controller'; +import { RisksService } from './risks.service'; + +@Module({ + imports: [AuthModule], + controllers: [RisksController], + providers: [RisksService], + exports: [RisksService], +}) +export class RisksModule {} diff --git a/apps/api/src/risks/risks.service.ts b/apps/api/src/risks/risks.service.ts new file mode 100644 index 000000000..97fbf1656 --- /dev/null +++ b/apps/api/src/risks/risks.service.ts @@ -0,0 +1,112 @@ +import { Injectable, NotFoundException, Logger } from '@nestjs/common'; +import { db } from '@trycompai/db'; +import { CreateRiskDto } from './dto/create-risk.dto'; +import { UpdateRiskDto } from './dto/update-risk.dto'; + +@Injectable() +export class RisksService { + private readonly logger = new Logger(RisksService.name); + + async findAllByOrganization(organizationId: string) { + try { + const risks = await db.risk.findMany({ + where: { organizationId }, + orderBy: { createdAt: 'desc' }, + }); + + 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); + throw error; + } + } + + async findById(id: string, organizationId: string) { + try { + const risk = await db.risk.findFirst({ + where: { + id, + organizationId + }, + }); + + if (!risk) { + throw new NotFoundException(`Risk with ID ${id} not found in organization ${organizationId}`); + } + + this.logger.log(`Retrieved risk: ${risk.title} (${id})`); + return risk; + } catch (error) { + if (error instanceof NotFoundException) { + throw error; + } + this.logger.error(`Failed to retrieve risk ${id}:`, error); + throw error; + } + } + + async create(organizationId: string, createRiskDto: CreateRiskDto) { + try { + const risk = await db.risk.create({ + data: { + ...createRiskDto, + 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); + throw error; + } + } + + async updateById(id: string, organizationId: string, updateRiskDto: UpdateRiskDto) { + try { + // First check if the risk exists in the organization + await this.findById(id, organizationId); + + const updatedRisk = await db.risk.update({ + where: { id }, + data: updateRiskDto, + }); + + this.logger.log(`Updated risk: ${updatedRisk.title} (${id})`); + return updatedRisk; + } catch (error) { + if (error instanceof NotFoundException) { + throw error; + } + this.logger.error(`Failed to update risk ${id}:`, error); + throw error; + } + } + + async deleteById(id: string, organizationId: string) { + try { + // First check if the risk exists in the organization + const existingRisk = await this.findById(id, organizationId); + + await db.risk.delete({ + where: { id }, + }); + + this.logger.log(`Deleted risk: ${existingRisk.title} (${id})`); + return { + message: 'Risk deleted successfully', + deletedRisk: { + id: existingRisk.id, + title: existingRisk.title, + } + }; + } catch (error) { + if (error instanceof NotFoundException) { + throw error; + } + this.logger.error(`Failed to delete risk ${id}:`, error); + throw error; + } + } +} diff --git a/apps/api/src/risks/schemas/create-risk.responses.ts b/apps/api/src/risks/schemas/create-risk.responses.ts new file mode 100644 index 000000000..aeebd53a9 --- /dev/null +++ b/apps/api/src/risks/schemas/create-risk.responses.ts @@ -0,0 +1,171 @@ +import type { ApiResponseOptions } from '@nestjs/swagger'; + +export const CREATE_RISK_RESPONSES: Record = { + 201: { + status: 201, + description: 'Risk created successfully', + schema: { + type: 'object', + properties: { + id: { + type: 'string', + description: 'Risk ID', + example: 'rsk_abc123def456', + }, + title: { + type: 'string', + description: 'Risk title', + example: 'Data breach vulnerability in user authentication system', + }, + description: { + type: 'string', + description: 'Risk description', + example: 'Weak password requirements could lead to unauthorized access to user accounts', + }, + category: { + type: 'string', + enum: ['customer', 'governance', 'operations', 'other', 'people', 'regulatory', 'reporting', 'resilience', 'technology', 'vendor_management'], + example: 'technology', + }, + department: { + type: 'string', + enum: ['none', 'admin', 'gov', 'hr', 'it', 'itsm', 'qms'], + nullable: true, + example: 'it', + }, + status: { + type: 'string', + enum: ['open', 'pending', 'closed', 'archived'], + example: 'open', + }, + likelihood: { + type: 'string', + enum: ['very_unlikely', 'unlikely', 'possible', 'likely', 'very_likely'], + example: 'possible', + }, + impact: { + type: 'string', + enum: ['insignificant', 'minor', 'moderate', 'major', 'severe'], + example: 'major', + }, + residualLikelihood: { + type: 'string', + enum: ['very_unlikely', 'unlikely', 'possible', 'likely', 'very_likely'], + example: 'unlikely', + }, + residualImpact: { + type: 'string', + enum: ['insignificant', 'minor', 'moderate', 'major', 'severe'], + example: 'minor', + }, + treatmentStrategyDescription: { + type: 'string', + nullable: true, + example: 'Implement multi-factor authentication and strengthen password requirements', + }, + treatmentStrategy: { + type: 'string', + enum: ['accept', 'avoid', 'mitigate', 'transfer'], + example: 'mitigate', + }, + organizationId: { + type: 'string', + example: 'org_abc123def456', + }, + assigneeId: { + type: 'string', + nullable: true, + description: 'ID of the user assigned to this risk', + example: 'mem_abc123def456', + }, + createdAt: { + type: 'string', + format: 'date-time', + description: 'When the risk was created', + }, + updatedAt: { + type: 'string', + format: 'date-time', + description: 'When the risk was last updated', + }, + authType: { + type: 'string', + enum: ['api-key', 'session'], + description: 'How the request was authenticated', + }, + authenticatedUser: { + type: 'object', + description: 'User information (only for session auth)', + properties: { + id: { type: 'string', example: 'usr_def456ghi789' }, + email: { type: 'string', example: 'user@example.com' }, + }, + }, + }, + }, + }, + 400: { + status: 400, + description: 'Bad request - Invalid input data', + schema: { + type: 'object', + properties: { + message: { + type: 'array', + items: { type: 'string' }, + example: [ + 'title should not be empty', + 'description should not be empty', + 'category must be a valid enum value', + ], + }, + error: { type: 'string', example: 'Bad Request' }, + statusCode: { type: 'number', example: 400 }, + }, + }, + }, + 401: { + status: 401, + description: 'Unauthorized - Invalid authentication or insufficient permissions', + schema: { + type: 'object', + properties: { + message: { + type: 'string', + examples: [ + 'Invalid or expired API key', + 'Invalid or expired session', + 'User does not have access to organization', + 'Organization context required', + ], + }, + }, + }, + }, + 404: { + status: 404, + description: 'Organization not found', + schema: { + type: 'object', + properties: { + message: { + type: 'string', + example: 'Organization with ID org_abc123def456 not found', + }, + }, + }, + }, + 500: { + status: 500, + description: 'Internal server error', + schema: { + type: 'object', + properties: { + message: { + type: 'string', + example: 'Internal server error', + }, + }, + }, + }, +}; diff --git a/apps/api/src/risks/schemas/delete-risk.responses.ts b/apps/api/src/risks/schemas/delete-risk.responses.ts new file mode 100644 index 000000000..c8c9593ce --- /dev/null +++ b/apps/api/src/risks/schemas/delete-risk.responses.ts @@ -0,0 +1,89 @@ +import type { ApiResponseOptions } from '@nestjs/swagger'; + +export const DELETE_RISK_RESPONSES: Record = { + 200: { + status: 200, + description: 'Risk deleted successfully', + schema: { + type: 'object', + properties: { + message: { + type: 'string', + example: 'Risk deleted successfully', + }, + deletedRisk: { + type: 'object', + properties: { + id: { + type: 'string', + description: 'Deleted risk ID', + example: 'rsk_abc123def456', + }, + title: { + type: 'string', + description: 'Deleted risk title', + example: 'Data breach vulnerability in user authentication system', + }, + }, + }, + authType: { + type: 'string', + enum: ['api-key', 'session'], + description: 'How the request was authenticated', + }, + authenticatedUser: { + type: 'object', + description: 'User information (only for session auth)', + properties: { + id: { type: 'string', example: 'usr_def456ghi789' }, + email: { type: 'string', example: 'user@example.com' }, + }, + }, + }, + }, + }, + 401: { + status: 401, + description: 'Unauthorized - Invalid authentication or insufficient permissions', + schema: { + type: 'object', + properties: { + message: { + type: 'string', + examples: [ + 'Invalid or expired API key', + 'Invalid or expired session', + 'User does not have access to organization', + 'Organization context required', + ], + }, + }, + }, + }, + 404: { + status: 404, + description: 'Risk not found', + schema: { + type: 'object', + properties: { + message: { + type: 'string', + example: 'Risk with ID rsk_abc123def456 not found in organization org_abc123def456', + }, + }, + }, + }, + 500: { + status: 500, + description: 'Internal server error', + schema: { + type: 'object', + properties: { + message: { + type: 'string', + example: 'Internal server error', + }, + }, + }, + }, +}; diff --git a/apps/api/src/risks/schemas/get-all-risks.responses.ts b/apps/api/src/risks/schemas/get-all-risks.responses.ts new file mode 100644 index 000000000..fdbca3dde --- /dev/null +++ b/apps/api/src/risks/schemas/get-all-risks.responses.ts @@ -0,0 +1,139 @@ +import type { ApiResponseOptions } from '@nestjs/swagger'; + +export const GET_ALL_RISKS_RESPONSES: Record = { + 200: { + status: 200, + description: 'Risks retrieved successfully', + schema: { + type: 'object', + properties: { + data: { + type: 'array', + items: { + type: 'object', + properties: { + id: { + type: 'string', + description: 'Risk ID', + example: 'rsk_abc123def456', + }, + title: { + type: 'string', + description: 'Risk title', + example: 'Data breach vulnerability in user authentication system', + }, + description: { + type: 'string', + description: 'Risk description', + example: 'Weak password requirements could lead to unauthorized access to user accounts', + }, + category: { + type: 'string', + enum: ['customer', 'governance', 'operations', 'other', 'people', 'regulatory', 'reporting', 'resilience', 'technology', 'vendor_management'], + example: 'technology', + }, + status: { + type: 'string', + enum: ['open', 'pending', 'closed', 'archived'], + example: 'open', + }, + likelihood: { + type: 'string', + enum: ['very_unlikely', 'unlikely', 'possible', 'likely', 'very_likely'], + example: 'possible', + }, + impact: { + type: 'string', + enum: ['insignificant', 'minor', 'moderate', 'major', 'severe'], + example: 'major', + }, + treatmentStrategy: { + type: 'string', + enum: ['accept', 'avoid', 'mitigate', 'transfer'], + example: 'mitigate', + }, + assigneeId: { + type: 'string', + nullable: true, + description: 'ID of the user assigned to this risk', + example: 'mem_abc123def456', + }, + createdAt: { + type: 'string', + format: 'date-time', + description: 'When the risk was created', + }, + updatedAt: { + type: 'string', + format: 'date-time', + description: 'When the risk was last updated', + }, + }, + }, + }, + count: { + type: 'number', + description: 'Total number of risks', + example: 15, + }, + authType: { + type: 'string', + enum: ['api-key', 'session'], + description: 'How the request was authenticated', + }, + authenticatedUser: { + type: 'object', + description: 'User information (only for session auth)', + properties: { + id: { type: 'string', example: 'usr_def456ghi789' }, + email: { type: 'string', example: 'user@example.com' }, + }, + }, + }, + }, + }, + 401: { + status: 401, + description: 'Unauthorized - Invalid authentication or insufficient permissions', + schema: { + type: 'object', + properties: { + message: { + type: 'string', + examples: [ + 'Invalid or expired API key', + 'Invalid or expired session', + 'User does not have access to organization', + 'Organization context required', + ], + }, + }, + }, + }, + 404: { + status: 404, + description: 'Organization not found', + schema: { + type: 'object', + properties: { + message: { + type: 'string', + example: 'Organization with ID org_abc123def456 not found', + }, + }, + }, + }, + 500: { + status: 500, + description: 'Internal server error', + schema: { + type: 'object', + properties: { + message: { + type: 'string', + example: 'Internal server error', + }, + }, + }, + }, +}; diff --git a/apps/api/src/risks/schemas/get-risk-by-id.responses.ts b/apps/api/src/risks/schemas/get-risk-by-id.responses.ts new file mode 100644 index 000000000..89ca6dcb4 --- /dev/null +++ b/apps/api/src/risks/schemas/get-risk-by-id.responses.ts @@ -0,0 +1,151 @@ +import type { ApiResponseOptions } from '@nestjs/swagger'; + +export const GET_RISK_BY_ID_RESPONSES: Record = { + 200: { + status: 200, + description: 'Risk retrieved successfully', + schema: { + type: 'object', + properties: { + id: { + type: 'string', + description: 'Risk ID', + example: 'rsk_abc123def456', + }, + title: { + type: 'string', + description: 'Risk title', + example: 'Data breach vulnerability in user authentication system', + }, + description: { + type: 'string', + description: 'Risk description', + example: 'Weak password requirements could lead to unauthorized access to user accounts', + }, + category: { + type: 'string', + enum: ['customer', 'governance', 'operations', 'other', 'people', 'regulatory', 'reporting', 'resilience', 'technology', 'vendor_management'], + example: 'technology', + }, + department: { + type: 'string', + enum: ['none', 'admin', 'gov', 'hr', 'it', 'itsm', 'qms'], + nullable: true, + example: 'it', + }, + status: { + type: 'string', + enum: ['open', 'pending', 'closed', 'archived'], + example: 'open', + }, + likelihood: { + type: 'string', + enum: ['very_unlikely', 'unlikely', 'possible', 'likely', 'very_likely'], + example: 'possible', + }, + impact: { + type: 'string', + enum: ['insignificant', 'minor', 'moderate', 'major', 'severe'], + example: 'major', + }, + residualLikelihood: { + type: 'string', + enum: ['very_unlikely', 'unlikely', 'possible', 'likely', 'very_likely'], + example: 'unlikely', + }, + residualImpact: { + type: 'string', + enum: ['insignificant', 'minor', 'moderate', 'major', 'severe'], + example: 'minor', + }, + treatmentStrategyDescription: { + type: 'string', + nullable: true, + example: 'Implement multi-factor authentication and strengthen password requirements', + }, + treatmentStrategy: { + type: 'string', + enum: ['accept', 'avoid', 'mitigate', 'transfer'], + example: 'mitigate', + }, + organizationId: { + type: 'string', + example: 'org_abc123def456', + }, + assigneeId: { + type: 'string', + nullable: true, + description: 'ID of the user assigned to this risk', + example: 'mem_abc123def456', + }, + createdAt: { + type: 'string', + format: 'date-time', + description: 'When the risk was created', + }, + updatedAt: { + type: 'string', + format: 'date-time', + description: 'When the risk was last updated', + }, + authType: { + type: 'string', + enum: ['api-key', 'session'], + description: 'How the request was authenticated', + }, + authenticatedUser: { + type: 'object', + description: 'User information (only for session auth)', + properties: { + id: { type: 'string', example: 'usr_def456ghi789' }, + email: { type: 'string', example: 'user@example.com' }, + }, + }, + }, + }, + }, + 401: { + status: 401, + description: 'Unauthorized - Invalid authentication or insufficient permissions', + schema: { + type: 'object', + properties: { + message: { + type: 'string', + examples: [ + 'Invalid or expired API key', + 'Invalid or expired session', + 'User does not have access to organization', + 'Organization context required', + ], + }, + }, + }, + }, + 404: { + status: 404, + description: 'Risk not found', + schema: { + type: 'object', + properties: { + message: { + type: 'string', + example: 'Risk with ID rsk_abc123def456 not found in organization org_abc123def456', + }, + }, + }, + }, + 500: { + status: 500, + description: 'Internal server error', + schema: { + type: 'object', + properties: { + message: { + type: 'string', + example: 'Internal server error', + }, + }, + }, + }, +}; diff --git a/apps/api/src/risks/schemas/risk-bodies.ts b/apps/api/src/risks/schemas/risk-bodies.ts new file mode 100644 index 000000000..791c9cfcc --- /dev/null +++ b/apps/api/src/risks/schemas/risk-bodies.ts @@ -0,0 +1,14 @@ +import type { ApiBodyOptions } from '@nestjs/swagger'; +import { CreateRiskDto } from '../dto/create-risk.dto'; +import { UpdateRiskDto } from '../dto/update-risk.dto'; + +export const RISK_BODIES: Record = { + createRisk: { + description: 'Risk creation data', + type: CreateRiskDto, + }, + updateRisk: { + description: 'Risk update data', + type: UpdateRiskDto, + }, +}; diff --git a/apps/api/src/risks/schemas/risk-operations.ts b/apps/api/src/risks/schemas/risk-operations.ts new file mode 100644 index 000000000..ff05b1d7d --- /dev/null +++ b/apps/api/src/risks/schemas/risk-operations.ts @@ -0,0 +1,29 @@ +import type { ApiOperationOptions } from '@nestjs/swagger'; + +export const RISK_OPERATIONS: Record = { + getAllRisks: { + summary: 'Get all risks', + description: + 'Returns all risks for the authenticated organization. Supports both API key authentication (X-API-Key header) and session authentication (cookies + X-Organization-Id header).', + }, + getRiskById: { + summary: 'Get risk by ID', + description: + 'Returns a specific risk by ID for the authenticated organization. Supports both API key authentication (X-API-Key header) and session authentication (cookies + X-Organization-Id header).', + }, + createRisk: { + summary: 'Create a new risk', + description: + 'Creates a new risk for the authenticated organization. All required fields must be provided. Supports both API key authentication (X-API-Key header) and session authentication (cookies + X-Organization-Id header).', + }, + updateRisk: { + summary: 'Update risk', + description: + 'Partially updates a risk. Only provided fields will be updated. Supports both API key authentication (X-API-Key header) and session authentication (cookies + X-Organization-Id header).', + }, + deleteRisk: { + summary: 'Delete risk', + description: + 'Permanently removes a risk from the organization. This action cannot be undone. Supports both API key authentication (X-API-Key header) and session authentication (cookies + X-Organization-Id header).', + }, +}; diff --git a/apps/api/src/risks/schemas/risk-params.ts b/apps/api/src/risks/schemas/risk-params.ts new file mode 100644 index 000000000..80f572e36 --- /dev/null +++ b/apps/api/src/risks/schemas/risk-params.ts @@ -0,0 +1,9 @@ +import type { ApiParamOptions } from '@nestjs/swagger'; + +export const RISK_PARAMS: Record = { + riskId: { + name: 'id', + description: 'Risk ID', + example: 'rsk_abc123def456', + }, +}; diff --git a/apps/api/src/risks/schemas/update-risk.responses.ts b/apps/api/src/risks/schemas/update-risk.responses.ts new file mode 100644 index 000000000..0f25c1478 --- /dev/null +++ b/apps/api/src/risks/schemas/update-risk.responses.ts @@ -0,0 +1,171 @@ +import type { ApiResponseOptions } from '@nestjs/swagger'; + +export const UPDATE_RISK_RESPONSES: Record = { + 200: { + status: 200, + description: 'Risk updated successfully', + schema: { + type: 'object', + properties: { + id: { + type: 'string', + description: 'Risk ID', + example: 'rsk_abc123def456', + }, + title: { + type: 'string', + description: 'Risk title', + example: 'Data breach vulnerability in user authentication system', + }, + description: { + type: 'string', + description: 'Risk description', + example: 'Weak password requirements could lead to unauthorized access to user accounts', + }, + category: { + type: 'string', + enum: ['customer', 'governance', 'operations', 'other', 'people', 'regulatory', 'reporting', 'resilience', 'technology', 'vendor_management'], + example: 'technology', + }, + department: { + type: 'string', + enum: ['none', 'admin', 'gov', 'hr', 'it', 'itsm', 'qms'], + nullable: true, + example: 'it', + }, + status: { + type: 'string', + enum: ['open', 'pending', 'closed', 'archived'], + example: 'open', + }, + likelihood: { + type: 'string', + enum: ['very_unlikely', 'unlikely', 'possible', 'likely', 'very_likely'], + example: 'possible', + }, + impact: { + type: 'string', + enum: ['insignificant', 'minor', 'moderate', 'major', 'severe'], + example: 'major', + }, + residualLikelihood: { + type: 'string', + enum: ['very_unlikely', 'unlikely', 'possible', 'likely', 'very_likely'], + example: 'unlikely', + }, + residualImpact: { + type: 'string', + enum: ['insignificant', 'minor', 'moderate', 'major', 'severe'], + example: 'minor', + }, + treatmentStrategyDescription: { + type: 'string', + nullable: true, + example: 'Implement multi-factor authentication and strengthen password requirements', + }, + treatmentStrategy: { + type: 'string', + enum: ['accept', 'avoid', 'mitigate', 'transfer'], + example: 'mitigate', + }, + organizationId: { + type: 'string', + example: 'org_abc123def456', + }, + assigneeId: { + type: 'string', + nullable: true, + description: 'ID of the user assigned to this risk', + example: 'mem_abc123def456', + }, + createdAt: { + type: 'string', + format: 'date-time', + description: 'When the risk was created', + }, + updatedAt: { + type: 'string', + format: 'date-time', + description: 'When the risk was last updated', + }, + authType: { + type: 'string', + enum: ['api-key', 'session'], + description: 'How the request was authenticated', + }, + authenticatedUser: { + type: 'object', + description: 'User information (only for session auth)', + properties: { + id: { type: 'string', example: 'usr_def456ghi789' }, + email: { type: 'string', example: 'user@example.com' }, + }, + }, + }, + }, + }, + 400: { + status: 400, + description: 'Bad request - Invalid input data', + schema: { + type: 'object', + properties: { + message: { + type: 'array', + items: { type: 'string' }, + example: [ + 'title should not be empty', + 'category must be a valid enum value', + 'status must be a valid enum value', + ], + }, + error: { type: 'string', example: 'Bad Request' }, + statusCode: { type: 'number', example: 400 }, + }, + }, + }, + 401: { + status: 401, + description: 'Unauthorized - Invalid authentication or insufficient permissions', + schema: { + type: 'object', + properties: { + message: { + type: 'string', + examples: [ + 'Invalid or expired API key', + 'Invalid or expired session', + 'User does not have access to organization', + 'Organization context required', + ], + }, + }, + }, + }, + 404: { + status: 404, + description: 'Risk not found', + schema: { + type: 'object', + properties: { + message: { + type: 'string', + example: 'Risk with ID rsk_abc123def456 not found in organization org_abc123def456', + }, + }, + }, + }, + 500: { + status: 500, + description: 'Internal server error', + schema: { + type: 'object', + properties: { + message: { + type: 'string', + example: 'Internal server error', + }, + }, + }, + }, +}; diff --git a/apps/api/src/vendors/dto/create-vendor.dto.ts b/apps/api/src/vendors/dto/create-vendor.dto.ts new file mode 100644 index 000000000..83d76d338 --- /dev/null +++ b/apps/api/src/vendors/dto/create-vendor.dto.ts @@ -0,0 +1,104 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsString, IsNotEmpty, IsOptional, IsEnum, IsUrl } from 'class-validator'; +import { + VendorCategory, + VendorStatus, + Likelihood, + Impact +} from '@trycompai/db'; + +export class CreateVendorDto { + @ApiProperty({ + description: 'Vendor name', + example: 'CloudTech Solutions Inc.', + }) + @IsString() + @IsNotEmpty() + name: string; + + @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.', + }) + @IsString() + @IsNotEmpty() + description: string; + + @ApiProperty({ + description: 'Vendor category', + enum: VendorCategory, + default: VendorCategory.other, + example: VendorCategory.cloud, + }) + @IsOptional() + @IsEnum(VendorCategory) + category?: VendorCategory; + + @ApiProperty({ + description: 'Assessment status of the vendor', + enum: VendorStatus, + default: VendorStatus.not_assessed, + example: VendorStatus.not_assessed, + }) + @IsOptional() + @IsEnum(VendorStatus) + status?: VendorStatus; + + @ApiProperty({ + description: 'Inherent probability of risk before controls', + enum: Likelihood, + default: Likelihood.very_unlikely, + example: Likelihood.possible, + }) + @IsOptional() + @IsEnum(Likelihood) + inherentProbability?: Likelihood; + + @ApiProperty({ + description: 'Inherent impact of risk before controls', + enum: Impact, + default: Impact.insignificant, + example: Impact.moderate, + }) + @IsOptional() + @IsEnum(Impact) + inherentImpact?: Impact; + + @ApiProperty({ + description: 'Residual probability after controls are applied', + enum: Likelihood, + default: Likelihood.very_unlikely, + example: Likelihood.unlikely, + }) + @IsOptional() + @IsEnum(Likelihood) + residualProbability?: Likelihood; + + @ApiProperty({ + description: 'Residual impact after controls are applied', + enum: Impact, + default: Impact.insignificant, + example: Impact.minor, + }) + @IsOptional() + @IsEnum(Impact) + residualImpact?: Impact; + + @ApiProperty({ + description: 'Vendor website URL', + required: false, + example: 'https://www.cloudtechsolutions.com', + }) + @IsOptional() + @IsUrl() + website?: string; + + @ApiProperty({ + description: 'ID of the user assigned to manage this vendor', + required: false, + example: 'mem_abc123def456', + }) + @IsOptional() + @IsString() + assigneeId?: string; +} diff --git a/apps/api/src/vendors/dto/update-vendor.dto.ts b/apps/api/src/vendors/dto/update-vendor.dto.ts new file mode 100644 index 000000000..66b0101e6 --- /dev/null +++ b/apps/api/src/vendors/dto/update-vendor.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/swagger'; +import { CreateVendorDto } from './create-vendor.dto'; + +export class UpdateVendorDto extends PartialType(CreateVendorDto) {} diff --git a/apps/api/src/vendors/dto/vendor-response.dto.ts b/apps/api/src/vendors/dto/vendor-response.dto.ts new file mode 100644 index 000000000..209c0109f --- /dev/null +++ b/apps/api/src/vendors/dto/vendor-response.dto.ts @@ -0,0 +1,105 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { + VendorCategory, + VendorStatus, + Likelihood, + Impact +} from '@trycompai/db'; + +export class VendorResponseDto { + @ApiProperty({ + description: 'Vendor ID', + example: 'vnd_abc123def456', + }) + id: string; + + @ApiProperty({ + description: 'Vendor name', + example: 'CloudTech Solutions Inc.', + }) + name: string; + + @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.', + }) + description: string; + + @ApiProperty({ + description: 'Vendor category', + enum: VendorCategory, + example: VendorCategory.cloud, + }) + category: VendorCategory; + + @ApiProperty({ + description: 'Assessment status of the vendor', + enum: VendorStatus, + example: VendorStatus.not_assessed, + }) + status: VendorStatus; + + @ApiProperty({ + description: 'Inherent probability of risk before controls', + enum: Likelihood, + example: Likelihood.possible, + }) + inherentProbability: Likelihood; + + @ApiProperty({ + description: 'Inherent impact of risk before controls', + enum: Impact, + example: Impact.moderate, + }) + inherentImpact: Impact; + + @ApiProperty({ + description: 'Residual probability after controls are applied', + enum: Likelihood, + example: Likelihood.unlikely, + }) + residualProbability: Likelihood; + + @ApiProperty({ + description: 'Residual impact after controls are applied', + enum: Impact, + example: Impact.minor, + }) + residualImpact: Impact; + + @ApiProperty({ + description: 'Vendor website URL', + nullable: true, + example: 'https://www.cloudtechsolutions.com', + }) + website: string | null; + + @ApiProperty({ + description: 'Organization ID', + example: 'org_abc123def456', + }) + organizationId: string; + + @ApiProperty({ + description: 'ID of the user assigned to manage this vendor', + nullable: true, + example: 'mem_abc123def456', + }) + assigneeId: string | null; + + @ApiProperty({ + description: 'When the vendor was created', + type: String, + format: 'date-time', + example: '2024-01-15T10:30:00Z', + }) + createdAt: Date; + + @ApiProperty({ + description: 'When the vendor was last updated', + type: String, + format: 'date-time', + example: '2024-01-16T14:45:00Z', + }) + updatedAt: Date; +} diff --git a/apps/api/src/vendors/schemas/create-vendor.responses.ts b/apps/api/src/vendors/schemas/create-vendor.responses.ts new file mode 100644 index 000000000..39107607e --- /dev/null +++ b/apps/api/src/vendors/schemas/create-vendor.responses.ts @@ -0,0 +1,161 @@ +import type { ApiResponseOptions } from '@nestjs/swagger'; + +export const CREATE_VENDOR_RESPONSES: Record = { + 201: { + status: 201, + description: 'Vendor created successfully', + schema: { + type: 'object', + properties: { + id: { + type: 'string', + description: 'Vendor ID', + example: 'vnd_abc123def456', + }, + name: { + type: 'string', + description: 'Vendor name', + example: 'CloudTech Solutions Inc.', + }, + description: { + type: 'string', + description: 'Vendor description', + example: 'Cloud infrastructure provider offering AWS-like services including compute, storage, and networking solutions for enterprise customers.', + }, + category: { + type: 'string', + enum: ['cloud', 'infrastructure', 'software_as_a_service', 'finance', 'marketing', 'sales', 'hr', 'other'], + example: 'cloud', + }, + status: { + type: 'string', + enum: ['not_assessed', 'in_progress', 'assessed'], + example: 'not_assessed', + }, + inherentProbability: { + type: 'string', + enum: ['very_unlikely', 'unlikely', 'possible', 'likely', 'very_likely'], + example: 'possible', + }, + inherentImpact: { + type: 'string', + enum: ['insignificant', 'minor', 'moderate', 'major', 'severe'], + example: 'moderate', + }, + residualProbability: { + type: 'string', + enum: ['very_unlikely', 'unlikely', 'possible', 'likely', 'very_likely'], + example: 'unlikely', + }, + residualImpact: { + type: 'string', + enum: ['insignificant', 'minor', 'moderate', 'major', 'severe'], + example: 'minor', + }, + website: { + type: 'string', + nullable: true, + example: 'https://www.cloudtechsolutions.com', + }, + organizationId: { + type: 'string', + example: 'org_abc123def456', + }, + assigneeId: { + type: 'string', + nullable: true, + description: 'ID of the user assigned to manage this vendor', + example: 'mem_abc123def456', + }, + createdAt: { + type: 'string', + format: 'date-time', + description: 'When the vendor was created', + }, + updatedAt: { + type: 'string', + format: 'date-time', + description: 'When the vendor was last updated', + }, + authType: { + type: 'string', + enum: ['api-key', 'session'], + description: 'How the request was authenticated', + }, + authenticatedUser: { + type: 'object', + description: 'User information (only for session auth)', + properties: { + id: { type: 'string', example: 'usr_def456ghi789' }, + email: { type: 'string', example: 'user@example.com' }, + }, + }, + }, + }, + }, + 400: { + status: 400, + description: 'Bad request - Invalid input data', + schema: { + type: 'object', + properties: { + message: { + type: 'array', + items: { type: 'string' }, + example: [ + 'name should not be empty', + 'description should not be empty', + 'category must be a valid enum value', + 'website must be a URL address', + ], + }, + error: { type: 'string', example: 'Bad Request' }, + statusCode: { type: 'number', example: 400 }, + }, + }, + }, + 401: { + status: 401, + description: 'Unauthorized - Invalid authentication or insufficient permissions', + schema: { + type: 'object', + properties: { + message: { + type: 'string', + examples: [ + 'Invalid or expired API key', + 'Invalid or expired session', + 'User does not have access to organization', + 'Organization context required', + ], + }, + }, + }, + }, + 404: { + status: 404, + description: 'Organization not found', + schema: { + type: 'object', + properties: { + message: { + type: 'string', + example: 'Organization with ID org_abc123def456 not found', + }, + }, + }, + }, + 500: { + status: 500, + description: 'Internal server error', + schema: { + type: 'object', + properties: { + message: { + type: 'string', + example: 'Internal server error', + }, + }, + }, + }, +}; diff --git a/apps/api/src/vendors/schemas/delete-vendor.responses.ts b/apps/api/src/vendors/schemas/delete-vendor.responses.ts new file mode 100644 index 000000000..32a8ac876 --- /dev/null +++ b/apps/api/src/vendors/schemas/delete-vendor.responses.ts @@ -0,0 +1,89 @@ +import type { ApiResponseOptions } from '@nestjs/swagger'; + +export const DELETE_VENDOR_RESPONSES: Record = { + 200: { + status: 200, + description: 'Vendor deleted successfully', + schema: { + type: 'object', + properties: { + message: { + type: 'string', + example: 'Vendor deleted successfully', + }, + deletedVendor: { + type: 'object', + properties: { + id: { + type: 'string', + description: 'Deleted vendor ID', + example: 'vnd_abc123def456', + }, + name: { + type: 'string', + description: 'Deleted vendor name', + example: 'CloudTech Solutions Inc.', + }, + }, + }, + authType: { + type: 'string', + enum: ['api-key', 'session'], + description: 'How the request was authenticated', + }, + authenticatedUser: { + type: 'object', + description: 'User information (only for session auth)', + properties: { + id: { type: 'string', example: 'usr_def456ghi789' }, + email: { type: 'string', example: 'user@example.com' }, + }, + }, + }, + }, + }, + 401: { + status: 401, + description: 'Unauthorized - Invalid authentication or insufficient permissions', + schema: { + type: 'object', + properties: { + message: { + type: 'string', + examples: [ + 'Invalid or expired API key', + 'Invalid or expired session', + 'User does not have access to organization', + 'Organization context required', + ], + }, + }, + }, + }, + 404: { + status: 404, + description: 'Vendor not found', + schema: { + type: 'object', + properties: { + message: { + type: 'string', + example: 'Vendor with ID vnd_abc123def456 not found in organization org_abc123def456', + }, + }, + }, + }, + 500: { + status: 500, + description: 'Internal server error', + schema: { + type: 'object', + properties: { + message: { + type: 'string', + example: 'Internal server error', + }, + }, + }, + }, +}; diff --git a/apps/api/src/vendors/schemas/get-all-vendors.responses.ts b/apps/api/src/vendors/schemas/get-all-vendors.responses.ts new file mode 100644 index 000000000..81fe3a60d --- /dev/null +++ b/apps/api/src/vendors/schemas/get-all-vendors.responses.ts @@ -0,0 +1,149 @@ +import type { ApiResponseOptions } from '@nestjs/swagger'; + +export const GET_ALL_VENDORS_RESPONSES: Record = { + 200: { + status: 200, + description: 'Vendors retrieved successfully', + schema: { + type: 'object', + properties: { + data: { + type: 'array', + items: { + type: 'object', + properties: { + id: { + type: 'string', + description: 'Vendor ID', + example: 'vnd_abc123def456', + }, + name: { + type: 'string', + description: 'Vendor name', + example: 'CloudTech Solutions Inc.', + }, + description: { + type: 'string', + description: 'Vendor description', + example: 'Cloud infrastructure provider offering AWS-like services', + }, + category: { + type: 'string', + enum: ['cloud', 'infrastructure', 'software_as_a_service', 'finance', 'marketing', 'sales', 'hr', 'other'], + example: 'cloud', + }, + status: { + type: 'string', + enum: ['not_assessed', 'in_progress', 'assessed'], + example: 'not_assessed', + }, + inherentProbability: { + type: 'string', + enum: ['very_unlikely', 'unlikely', 'possible', 'likely', 'very_likely'], + example: 'possible', + }, + inherentImpact: { + type: 'string', + enum: ['insignificant', 'minor', 'moderate', 'major', 'severe'], + example: 'moderate', + }, + residualProbability: { + type: 'string', + enum: ['very_unlikely', 'unlikely', 'possible', 'likely', 'very_likely'], + example: 'unlikely', + }, + residualImpact: { + type: 'string', + enum: ['insignificant', 'minor', 'moderate', 'major', 'severe'], + example: 'minor', + }, + website: { + type: 'string', + nullable: true, + example: 'https://www.cloudtechsolutions.com', + }, + assigneeId: { + type: 'string', + nullable: true, + description: 'ID of the user assigned to manage this vendor', + example: 'mem_abc123def456', + }, + createdAt: { + type: 'string', + format: 'date-time', + description: 'When the vendor was created', + }, + updatedAt: { + type: 'string', + format: 'date-time', + description: 'When the vendor was last updated', + }, + }, + }, + }, + count: { + type: 'number', + description: 'Total number of vendors', + example: 12, + }, + authType: { + type: 'string', + enum: ['api-key', 'session'], + description: 'How the request was authenticated', + }, + authenticatedUser: { + type: 'object', + description: 'User information (only for session auth)', + properties: { + id: { type: 'string', example: 'usr_def456ghi789' }, + email: { type: 'string', example: 'user@example.com' }, + }, + }, + }, + }, + }, + 401: { + status: 401, + description: 'Unauthorized - Invalid authentication or insufficient permissions', + schema: { + type: 'object', + properties: { + message: { + type: 'string', + examples: [ + 'Invalid or expired API key', + 'Invalid or expired session', + 'User does not have access to organization', + 'Organization context required', + ], + }, + }, + }, + }, + 404: { + status: 404, + description: 'Organization not found', + schema: { + type: 'object', + properties: { + message: { + type: 'string', + example: 'Organization with ID org_abc123def456 not found', + }, + }, + }, + }, + 500: { + status: 500, + description: 'Internal server error', + schema: { + type: 'object', + properties: { + message: { + type: 'string', + example: 'Internal server error', + }, + }, + }, + }, +}; diff --git a/apps/api/src/vendors/schemas/get-vendor-by-id.responses.ts b/apps/api/src/vendors/schemas/get-vendor-by-id.responses.ts new file mode 100644 index 000000000..6a0a135a5 --- /dev/null +++ b/apps/api/src/vendors/schemas/get-vendor-by-id.responses.ts @@ -0,0 +1,140 @@ +import type { ApiResponseOptions } from '@nestjs/swagger'; + +export const GET_VENDOR_BY_ID_RESPONSES: Record = { + 200: { + status: 200, + description: 'Vendor retrieved successfully', + schema: { + type: 'object', + properties: { + id: { + type: 'string', + description: 'Vendor ID', + example: 'vnd_abc123def456', + }, + name: { + type: 'string', + description: 'Vendor name', + example: 'CloudTech Solutions Inc.', + }, + description: { + type: 'string', + description: 'Vendor description', + example: 'Cloud infrastructure provider offering AWS-like services including compute, storage, and networking solutions for enterprise customers.', + }, + category: { + type: 'string', + enum: ['cloud', 'infrastructure', 'software_as_a_service', 'finance', 'marketing', 'sales', 'hr', 'other'], + example: 'cloud', + }, + status: { + type: 'string', + enum: ['not_assessed', 'in_progress', 'assessed'], + example: 'not_assessed', + }, + inherentProbability: { + type: 'string', + enum: ['very_unlikely', 'unlikely', 'possible', 'likely', 'very_likely'], + example: 'possible', + }, + inherentImpact: { + type: 'string', + enum: ['insignificant', 'minor', 'moderate', 'major', 'severe'], + example: 'moderate', + }, + residualProbability: { + type: 'string', + enum: ['very_unlikely', 'unlikely', 'possible', 'likely', 'very_likely'], + example: 'unlikely', + }, + residualImpact: { + type: 'string', + enum: ['insignificant', 'minor', 'moderate', 'major', 'severe'], + example: 'minor', + }, + website: { + type: 'string', + nullable: true, + example: 'https://www.cloudtechsolutions.com', + }, + organizationId: { + type: 'string', + example: 'org_abc123def456', + }, + assigneeId: { + type: 'string', + nullable: true, + description: 'ID of the user assigned to manage this vendor', + example: 'mem_abc123def456', + }, + createdAt: { + type: 'string', + format: 'date-time', + description: 'When the vendor was created', + }, + updatedAt: { + type: 'string', + format: 'date-time', + description: 'When the vendor was last updated', + }, + authType: { + type: 'string', + enum: ['api-key', 'session'], + description: 'How the request was authenticated', + }, + authenticatedUser: { + type: 'object', + description: 'User information (only for session auth)', + properties: { + id: { type: 'string', example: 'usr_def456ghi789' }, + email: { type: 'string', example: 'user@example.com' }, + }, + }, + }, + }, + }, + 401: { + status: 401, + description: 'Unauthorized - Invalid authentication or insufficient permissions', + schema: { + type: 'object', + properties: { + message: { + type: 'string', + examples: [ + 'Invalid or expired API key', + 'Invalid or expired session', + 'User does not have access to organization', + 'Organization context required', + ], + }, + }, + }, + }, + 404: { + status: 404, + description: 'Vendor not found', + schema: { + type: 'object', + properties: { + message: { + type: 'string', + example: 'Vendor with ID vnd_abc123def456 not found in organization org_abc123def456', + }, + }, + }, + }, + 500: { + status: 500, + description: 'Internal server error', + schema: { + type: 'object', + properties: { + message: { + type: 'string', + example: 'Internal server error', + }, + }, + }, + }, +}; diff --git a/apps/api/src/vendors/schemas/update-vendor.responses.ts b/apps/api/src/vendors/schemas/update-vendor.responses.ts new file mode 100644 index 000000000..20a358a4a --- /dev/null +++ b/apps/api/src/vendors/schemas/update-vendor.responses.ts @@ -0,0 +1,161 @@ +import type { ApiResponseOptions } from '@nestjs/swagger'; + +export const UPDATE_VENDOR_RESPONSES: Record = { + 200: { + status: 200, + description: 'Vendor updated successfully', + schema: { + type: 'object', + properties: { + id: { + type: 'string', + description: 'Vendor ID', + example: 'vnd_abc123def456', + }, + name: { + type: 'string', + description: 'Vendor name', + example: 'CloudTech Solutions Inc.', + }, + description: { + type: 'string', + description: 'Vendor description', + example: 'Cloud infrastructure provider offering AWS-like services including compute, storage, and networking solutions for enterprise customers.', + }, + category: { + type: 'string', + enum: ['cloud', 'infrastructure', 'software_as_a_service', 'finance', 'marketing', 'sales', 'hr', 'other'], + example: 'cloud', + }, + status: { + type: 'string', + enum: ['not_assessed', 'in_progress', 'assessed'], + example: 'assessed', + }, + inherentProbability: { + type: 'string', + enum: ['very_unlikely', 'unlikely', 'possible', 'likely', 'very_likely'], + example: 'possible', + }, + inherentImpact: { + type: 'string', + enum: ['insignificant', 'minor', 'moderate', 'major', 'severe'], + example: 'moderate', + }, + residualProbability: { + type: 'string', + enum: ['very_unlikely', 'unlikely', 'possible', 'likely', 'very_likely'], + example: 'unlikely', + }, + residualImpact: { + type: 'string', + enum: ['insignificant', 'minor', 'moderate', 'major', 'severe'], + example: 'minor', + }, + website: { + type: 'string', + nullable: true, + example: 'https://www.cloudtechsolutions.com', + }, + organizationId: { + type: 'string', + example: 'org_abc123def456', + }, + assigneeId: { + type: 'string', + nullable: true, + description: 'ID of the user assigned to manage this vendor', + example: 'mem_abc123def456', + }, + createdAt: { + type: 'string', + format: 'date-time', + description: 'When the vendor was created', + }, + updatedAt: { + type: 'string', + format: 'date-time', + description: 'When the vendor was last updated', + }, + authType: { + type: 'string', + enum: ['api-key', 'session'], + description: 'How the request was authenticated', + }, + authenticatedUser: { + type: 'object', + description: 'User information (only for session auth)', + properties: { + id: { type: 'string', example: 'usr_def456ghi789' }, + email: { type: 'string', example: 'user@example.com' }, + }, + }, + }, + }, + }, + 400: { + status: 400, + description: 'Bad request - Invalid input data', + schema: { + type: 'object', + properties: { + message: { + type: 'array', + items: { type: 'string' }, + example: [ + 'name should not be empty', + 'category must be a valid enum value', + 'status must be a valid enum value', + 'website must be a URL address', + ], + }, + error: { type: 'string', example: 'Bad Request' }, + statusCode: { type: 'number', example: 400 }, + }, + }, + }, + 401: { + status: 401, + description: 'Unauthorized - Invalid authentication or insufficient permissions', + schema: { + type: 'object', + properties: { + message: { + type: 'string', + examples: [ + 'Invalid or expired API key', + 'Invalid or expired session', + 'User does not have access to organization', + 'Organization context required', + ], + }, + }, + }, + }, + 404: { + status: 404, + description: 'Vendor not found', + schema: { + type: 'object', + properties: { + message: { + type: 'string', + example: 'Vendor with ID vnd_abc123def456 not found in organization org_abc123def456', + }, + }, + }, + }, + 500: { + status: 500, + description: 'Internal server error', + schema: { + type: 'object', + properties: { + message: { + type: 'string', + example: 'Internal server error', + }, + }, + }, + }, +}; diff --git a/apps/api/src/vendors/schemas/vendor-bodies.ts b/apps/api/src/vendors/schemas/vendor-bodies.ts new file mode 100644 index 000000000..6e3a6f890 --- /dev/null +++ b/apps/api/src/vendors/schemas/vendor-bodies.ts @@ -0,0 +1,14 @@ +import type { ApiBodyOptions } from '@nestjs/swagger'; +import { CreateVendorDto } from '../dto/create-vendor.dto'; +import { UpdateVendorDto } from '../dto/update-vendor.dto'; + +export const VENDOR_BODIES: Record = { + createVendor: { + description: 'Vendor creation data', + type: CreateVendorDto, + }, + updateVendor: { + description: 'Vendor update data', + type: UpdateVendorDto, + }, +}; diff --git a/apps/api/src/vendors/schemas/vendor-operations.ts b/apps/api/src/vendors/schemas/vendor-operations.ts new file mode 100644 index 000000000..d3d473eed --- /dev/null +++ b/apps/api/src/vendors/schemas/vendor-operations.ts @@ -0,0 +1,29 @@ +import type { ApiOperationOptions } from '@nestjs/swagger'; + +export const VENDOR_OPERATIONS: Record = { + getAllVendors: { + summary: 'Get all vendors', + description: + 'Returns all vendors for the authenticated organization. Supports both API key authentication (X-API-Key header) and session authentication (cookies + X-Organization-Id header).', + }, + getVendorById: { + summary: 'Get vendor by ID', + description: + 'Returns a specific vendor by ID for the authenticated organization. Supports both API key authentication (X-API-Key header) and session authentication (cookies + X-Organization-Id header).', + }, + createVendor: { + summary: 'Create a new vendor', + description: + 'Creates a new vendor for the authenticated organization. All required fields must be provided. Supports both API key authentication (X-API-Key header) and session authentication (cookies + X-Organization-Id header).', + }, + updateVendor: { + summary: 'Update vendor', + description: + 'Partially updates a vendor. Only provided fields will be updated. Supports both API key authentication (X-API-Key header) and session authentication (cookies + X-Organization-Id header).', + }, + deleteVendor: { + summary: 'Delete vendor', + description: + 'Permanently removes a vendor from the organization. This action cannot be undone. Supports both API key authentication (X-API-Key header) and session authentication (cookies + X-Organization-Id header).', + }, +}; diff --git a/apps/api/src/vendors/schemas/vendor-params.ts b/apps/api/src/vendors/schemas/vendor-params.ts new file mode 100644 index 000000000..8c3f5eabd --- /dev/null +++ b/apps/api/src/vendors/schemas/vendor-params.ts @@ -0,0 +1,9 @@ +import type { ApiParamOptions } from '@nestjs/swagger'; + +export const VENDOR_PARAMS: Record = { + vendorId: { + name: 'id', + description: 'Vendor ID', + example: 'vnd_abc123def456', + }, +}; diff --git a/apps/api/src/vendors/vendors.controller.ts b/apps/api/src/vendors/vendors.controller.ts new file mode 100644 index 000000000..9b54e4b85 --- /dev/null +++ b/apps/api/src/vendors/vendors.controller.ts @@ -0,0 +1,187 @@ +import { + Controller, + Get, + Post, + Patch, + Delete, + Body, + Param, + UseGuards +} from '@nestjs/common'; +import { + ApiBody, + ApiHeader, + ApiOperation, + ApiParam, + ApiResponse, + ApiSecurity, + ApiTags, +} from '@nestjs/swagger'; +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'; +import { UpdateVendorDto } from './dto/update-vendor.dto'; +import { VendorsService } from './vendors.service'; +import { VENDOR_OPERATIONS } from './schemas/vendor-operations'; +import { VENDOR_PARAMS } from './schemas/vendor-params'; +import { VENDOR_BODIES } from './schemas/vendor-bodies'; +import { GET_ALL_VENDORS_RESPONSES } from './schemas/get-all-vendors.responses'; +import { GET_VENDOR_BY_ID_RESPONSES } from './schemas/get-vendor-by-id.responses'; +import { CREATE_VENDOR_RESPONSES } from './schemas/create-vendor.responses'; +import { UPDATE_VENDOR_RESPONSES } from './schemas/update-vendor.responses'; +import { DELETE_VENDOR_RESPONSES } from './schemas/delete-vendor.responses'; + +@ApiTags('Vendors') +@Controller({ path: 'vendors', version: '1' }) +@UseGuards(HybridAuthGuard) +@ApiSecurity('apikey') +@ApiHeader({ + name: 'X-Organization-Id', + description: + 'Organization ID (required for session auth, optional for API key auth)', + required: false, +}) +export class VendorsController { + constructor(private readonly vendorsService: VendorsService) {} + + @Get() + @ApiOperation(VENDOR_OPERATIONS.getAllVendors) + @ApiResponse(GET_ALL_VENDORS_RESPONSES[200]) + @ApiResponse(GET_ALL_VENDORS_RESPONSES[401]) + @ApiResponse(GET_ALL_VENDORS_RESPONSES[404]) + @ApiResponse(GET_ALL_VENDORS_RESPONSES[500]) + async getAllVendors( + @OrganizationId() organizationId: string, + @AuthContext() authContext: AuthContextType, + ) { + 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, + }, + }), + }; + } + + @Get(':id') + @ApiOperation(VENDOR_OPERATIONS.getVendorById) + @ApiParam(VENDOR_PARAMS.vendorId) + @ApiResponse(GET_VENDOR_BY_ID_RESPONSES[200]) + @ApiResponse(GET_VENDOR_BY_ID_RESPONSES[401]) + @ApiResponse(GET_VENDOR_BY_ID_RESPONSES[404]) + @ApiResponse(GET_VENDOR_BY_ID_RESPONSES[500]) + async getVendorById( + @Param('id') vendorId: string, + @OrganizationId() organizationId: string, + @AuthContext() authContext: AuthContextType, + ) { + const vendor = await this.vendorsService.findById(vendorId, organizationId); + + return { + ...vendor, + authType: authContext.authType, + ...(authContext.userId && authContext.userEmail && { + authenticatedUser: { + id: authContext.userId, + email: authContext.userEmail, + }, + }), + }; + } + + @Post() + @ApiOperation(VENDOR_OPERATIONS.createVendor) + @ApiBody(VENDOR_BODIES.createVendor) + @ApiResponse(CREATE_VENDOR_RESPONSES[201]) + @ApiResponse(CREATE_VENDOR_RESPONSES[400]) + @ApiResponse(CREATE_VENDOR_RESPONSES[401]) + @ApiResponse(CREATE_VENDOR_RESPONSES[404]) + @ApiResponse(CREATE_VENDOR_RESPONSES[500]) + async createVendor( + @Body() createVendorDto: CreateVendorDto, + @OrganizationId() organizationId: string, + @AuthContext() authContext: AuthContextType, + ) { + const vendor = await this.vendorsService.create(organizationId, createVendorDto); + + return { + ...vendor, + authType: authContext.authType, + ...(authContext.userId && authContext.userEmail && { + authenticatedUser: { + id: authContext.userId, + email: authContext.userEmail, + }, + }), + }; + } + + @Patch(':id') + @ApiOperation(VENDOR_OPERATIONS.updateVendor) + @ApiParam(VENDOR_PARAMS.vendorId) + @ApiBody(VENDOR_BODIES.updateVendor) + @ApiResponse(UPDATE_VENDOR_RESPONSES[200]) + @ApiResponse(UPDATE_VENDOR_RESPONSES[400]) + @ApiResponse(UPDATE_VENDOR_RESPONSES[401]) + @ApiResponse(UPDATE_VENDOR_RESPONSES[404]) + @ApiResponse(UPDATE_VENDOR_RESPONSES[500]) + async updateVendor( + @Param('id') vendorId: string, + @Body() updateVendorDto: UpdateVendorDto, + @OrganizationId() organizationId: string, + @AuthContext() authContext: AuthContextType, + ) { + const updatedVendor = await this.vendorsService.updateById( + vendorId, + organizationId, + updateVendorDto, + ); + + return { + ...updatedVendor, + authType: authContext.authType, + ...(authContext.userId && authContext.userEmail && { + authenticatedUser: { + id: authContext.userId, + email: authContext.userEmail, + }, + }), + }; + } + + @Delete(':id') + @ApiOperation(VENDOR_OPERATIONS.deleteVendor) + @ApiParam(VENDOR_PARAMS.vendorId) + @ApiResponse(DELETE_VENDOR_RESPONSES[200]) + @ApiResponse(DELETE_VENDOR_RESPONSES[401]) + @ApiResponse(DELETE_VENDOR_RESPONSES[404]) + @ApiResponse(DELETE_VENDOR_RESPONSES[500]) + async deleteVendor( + @Param('id') vendorId: string, + @OrganizationId() organizationId: string, + @AuthContext() authContext: AuthContextType, + ) { + const result = await this.vendorsService.deleteById(vendorId, organizationId); + + return { + ...result, + authType: authContext.authType, + ...(authContext.userId && authContext.userEmail && { + authenticatedUser: { + id: authContext.userId, + email: authContext.userEmail, + }, + }), + }; + } +} diff --git a/apps/api/src/vendors/vendors.module.ts b/apps/api/src/vendors/vendors.module.ts new file mode 100644 index 000000000..306b56239 --- /dev/null +++ b/apps/api/src/vendors/vendors.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { AuthModule } from '../auth/auth.module'; +import { VendorsController } from './vendors.controller'; +import { VendorsService } from './vendors.service'; + +@Module({ + imports: [AuthModule], + controllers: [VendorsController], + providers: [VendorsService], + exports: [VendorsService], +}) +export class VendorsModule {} diff --git a/apps/api/src/vendors/vendors.service.ts b/apps/api/src/vendors/vendors.service.ts new file mode 100644 index 000000000..e60be6eab --- /dev/null +++ b/apps/api/src/vendors/vendors.service.ts @@ -0,0 +1,112 @@ +import { Injectable, NotFoundException, Logger } from '@nestjs/common'; +import { db } from '@trycompai/db'; +import { CreateVendorDto } from './dto/create-vendor.dto'; +import { UpdateVendorDto } from './dto/update-vendor.dto'; + +@Injectable() +export class VendorsService { + private readonly logger = new Logger(VendorsService.name); + + async findAllByOrganization(organizationId: string) { + try { + const vendors = await db.vendor.findMany({ + where: { organizationId }, + orderBy: { createdAt: 'desc' }, + }); + + 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); + throw error; + } + } + + async findById(id: string, organizationId: string) { + try { + const vendor = await db.vendor.findFirst({ + where: { + id, + organizationId + }, + }); + + if (!vendor) { + throw new NotFoundException(`Vendor with ID ${id} not found in organization ${organizationId}`); + } + + this.logger.log(`Retrieved vendor: ${vendor.name} (${id})`); + return vendor; + } catch (error) { + if (error instanceof NotFoundException) { + throw error; + } + this.logger.error(`Failed to retrieve vendor ${id}:`, error); + throw error; + } + } + + async create(organizationId: string, createVendorDto: CreateVendorDto) { + try { + const vendor = await db.vendor.create({ + data: { + ...createVendorDto, + 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); + throw error; + } + } + + async updateById(id: string, organizationId: string, updateVendorDto: UpdateVendorDto) { + try { + // First check if the vendor exists in the organization + await this.findById(id, organizationId); + + const updatedVendor = await db.vendor.update({ + where: { id }, + data: updateVendorDto, + }); + + this.logger.log(`Updated vendor: ${updatedVendor.name} (${id})`); + return updatedVendor; + } catch (error) { + if (error instanceof NotFoundException) { + throw error; + } + this.logger.error(`Failed to update vendor ${id}:`, error); + throw error; + } + } + + async deleteById(id: string, organizationId: string) { + try { + // First check if the vendor exists in the organization + const existingVendor = await this.findById(id, organizationId); + + await db.vendor.delete({ + where: { id }, + }); + + this.logger.log(`Deleted vendor: ${existingVendor.name} (${id})`); + return { + message: 'Vendor deleted successfully', + deletedVendor: { + id: existingVendor.id, + name: existingVendor.name, + } + }; + } catch (error) { + if (error instanceof NotFoundException) { + throw error; + } + this.logger.error(`Failed to delete vendor ${id}:`, error); + throw error; + } + } +} diff --git a/apps/app/package.json b/apps/app/package.json index a5982317f..a4083b23d 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -134,8 +134,9 @@ "scripts": { "analyze-locale-usage": "bunx tsx src/locales/analyze-locale-usage.ts", "build": "next build", + "build:docker": "prisma generate && next build", "db:generate": "bun run db:getschema && prisma generate", - "db:getschema": "cp ../../node_modules/@trycompai/db/dist/schema.prisma prisma/schema.prisma", + "db:getschema": "node ../../packages/db/scripts/combine-schemas.js && cp ../../packages/db/dist/schema.prisma prisma/schema.prisma", "deploy:trigger-prod": "npx trigger.dev@4.0.0 deploy", "dev": "bun i && bunx concurrently --kill-others --names \"next,trigger\" --prefix-colors \"yellow,blue\" \"next dev --turbo -p 3000\" \"bun run trigger:dev\"", "lint": "next lint && prettier --check .", diff --git a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/actions/get-policy-pdf-url.ts b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/actions/get-policy-pdf-url.ts new file mode 100644 index 000000000..953472548 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/actions/get-policy-pdf-url.ts @@ -0,0 +1,56 @@ +'use server'; + +import { authActionClient } from '@/actions/safe-action'; +import { BUCKET_NAME, s3Client } from '@/app/s3'; +import { GetObjectCommand } from '@aws-sdk/client-s3'; +import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; +import { db } from '@db'; +import { z } from 'zod'; + +export const getPolicyPdfUrlAction = authActionClient + .inputSchema(z.object({ policyId: z.string() })) + .metadata({ + name: 'get-policy-pdf-url', + track: { + event: 'get-policy-pdf-url-s3', + channel: 'server', + }, + }) + .action(async ({ parsedInput, ctx }) => { + const { policyId } = parsedInput; + const { session } = ctx; + const organizationId = session.activeOrganizationId; + + if (!organizationId) { + return { success: false, error: 'Not authorized' }; + } + + if (!s3Client || !BUCKET_NAME) { + return { success: false, error: 'File storage is not configured.' }; + } + + try { + const policy = await db.policy.findUnique({ + where: { id: policyId, organizationId }, + select: { pdfUrl: true }, + }); + + if (!policy?.pdfUrl) { + return { success: false, error: 'No PDF found for this policy.' }; + } + + // Generate a temporary, secure URL for the client to render the PDF from the private bucket. + const command = new GetObjectCommand({ + Bucket: BUCKET_NAME, + Key: policy.pdfUrl, + ResponseContentDisposition: 'inline', + ResponseContentType: 'application/pdf', + }); + const signedUrl = await getSignedUrl(s3Client, command, { expiresIn: 900 }); // URL is valid for 15 minutes + + return { success: true, data: signedUrl }; + } catch (error) { + console.error('Error generating signed URL for policy PDF:', error); + return { success: false, error: 'Could not retrieve PDF.' }; + } + }); diff --git a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/actions/switch-policy-display-format.ts b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/actions/switch-policy-display-format.ts new file mode 100644 index 000000000..09f479e9b --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/actions/switch-policy-display-format.ts @@ -0,0 +1,49 @@ +'use server'; + +import { authActionClient } from '@/actions/safe-action'; +import { db } from '@db'; +import { revalidatePath } from 'next/cache'; +import { headers } from 'next/headers'; +import { z } from 'zod'; + +const switchDisplayFormatSchema = z.object({ + policyId: z.string(), + format: z.enum(['EDITOR', 'PDF']), +}); + +export const switchPolicyDisplayFormatAction = authActionClient + .inputSchema(switchDisplayFormatSchema) + .metadata({ + name: 'switch-policy-display-format', + track: { + event: 'switch-policy-display-format', + channel: 'server', + }, + }) + .action(async ({ parsedInput, ctx }) => { + const { policyId, format } = parsedInput; + const { session } = ctx; + + if (!session.activeOrganizationId) { + return { success: false, error: 'Not authorized' }; + } + + try { + await db.policy.update({ + where: { id: policyId, organizationId: session.activeOrganizationId }, + data: { + displayFormat: format, + }, + }); + + const headersList = await headers(); + let path = headersList.get('x-pathname') || headersList.get('referer') || ''; + path = path.replace(/\/[a-z]{2}\//, '/'); + revalidatePath(path); + + return { success: true }; + } catch (error) { + console.error('Error switching policy display format:', error); + return { success: false, error: 'Failed to switch view.' }; + } + }); diff --git a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/actions/upload-policy-pdf.ts b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/actions/upload-policy-pdf.ts new file mode 100644 index 000000000..eb9c1c6bf --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/actions/upload-policy-pdf.ts @@ -0,0 +1,73 @@ +'use server'; + +import { authActionClient } from '@/actions/safe-action'; +import { BUCKET_NAME, s3Client } from '@/app/s3'; +import { PutObjectCommand } from '@aws-sdk/client-s3'; +import { db, PolicyDisplayFormat } from '@db'; +import { revalidatePath } from 'next/cache'; +import { headers } from 'next/headers'; +import { z } from 'zod'; + +const uploadPolicyPdfSchema = z.object({ + policyId: z.string(), + fileName: z.string(), + fileType: z.string(), + fileData: z.string(), // Base64 encoded file content +}); + +export const uploadPolicyPdfAction = authActionClient + .inputSchema(uploadPolicyPdfSchema) + .metadata({ + name: 'upload-policy-pdf', + track: { + event: 'upload-policy-pdf-s3', + channel: 'server', + }, + }) + .action(async ({ parsedInput, ctx }) => { + const { policyId, fileName, fileType, fileData } = parsedInput; + const { session } = ctx; + const organizationId = session.activeOrganizationId; + + if (!organizationId) { + return { success: false, error: 'Not authorized' }; + } + + if (!s3Client || !BUCKET_NAME) { + return { success: false, error: 'File storage is not configured.' }; + } + + const sanitizedFileName = fileName.replace(/[^a-zA-Z0-9.-]/g, '_'); + const s3Key = `${organizationId}/policies/${policyId}/${Date.now()}-${sanitizedFileName}`; + + try { + const fileBuffer = Buffer.from(fileData, 'base64'); + const command = new PutObjectCommand({ + Bucket: BUCKET_NAME, + Key: s3Key, + Body: fileBuffer, + ContentType: fileType, + }); + + await s3Client.send(command); + + // After a successful upload, update the policy to store the S3 Key + await db.policy.update({ + where: { id: policyId, organizationId }, + data: { + pdfUrl: s3Key, + displayFormat: PolicyDisplayFormat.PDF, + }, + }); + + const headersList = await headers(); + let path = headersList.get('x-pathname') || headersList.get('referer') || ''; + path = path.replace(/\/[a-z]{2}\//, '/'); + revalidatePath(path); + + return { success: true, data: { s3Key } }; + } catch (error) { + console.error('Error uploading policy PDF to S3:', error); + return { success: false, error: 'Failed to upload PDF.' }; + } + }); diff --git a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PdfViewer.tsx b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PdfViewer.tsx new file mode 100644 index 000000000..36105e878 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PdfViewer.tsx @@ -0,0 +1,234 @@ +'use client'; + +import { Card, CardContent, CardHeader, CardTitle } from '@comp/ui/card'; +import { cn } from '@comp/ui/cn'; +import { ExternalLink, FileText, Loader2 } from 'lucide-react'; +import { useAction } from 'next-safe-action/hooks'; +import { useRouter } from 'next/navigation'; +import { useEffect, useState } from 'react'; +import Dropzone from 'react-dropzone'; +import { toast } from 'sonner'; +import { getPolicyPdfUrlAction } from '../actions/get-policy-pdf-url'; +import { uploadPolicyPdfAction } from '../actions/upload-policy-pdf'; + +interface PdfViewerProps { + policyId: string; + pdfUrl?: string | null; // This prop contains the S3 Key + isPendingApproval: boolean; +} + +export function PdfViewer({ policyId, pdfUrl, isPendingApproval }: PdfViewerProps) { + const router = useRouter(); + const [files, setFiles] = useState([]); + const [signedUrl, setSignedUrl] = useState(null); + const [isUrlLoading, setUrlLoading] = useState(true); + + const { execute: getUrl } = useAction(getPolicyPdfUrlAction, { + onSuccess: (result) => { + const url = result?.data?.data ?? null; + if (result?.data?.success && url) { + setSignedUrl(url); + } else { + setSignedUrl(null); + } + }, + onError: () => toast.error('Could not load the policy document.'), + onSettled: () => setUrlLoading(false), + }); + + // Fetch the secure, temporary URL when the component loads with an S3 key. + useEffect(() => { + if (pdfUrl) { + getUrl({ policyId }); + } else { + setUrlLoading(false); + } + }, [pdfUrl, policyId, getUrl]); + + const { execute: upload, status: uploadStatus } = useAction(uploadPolicyPdfAction, { + onSuccess: () => { + toast.success('PDF uploaded successfully.'); + setFiles([]); + router.refresh(); + }, + onError: (error) => toast.error(error.error.serverError || 'Failed to upload PDF.'), + }); + + // Handle file upload from FileUploader component + const handleUpload = async (uploadFiles: File[]) => { + if (!uploadFiles.length) return; + const file = uploadFiles[0]; // Only handle first file since we accept single files + + const reader = new FileReader(); + reader.readAsDataURL(file); + reader.onload = () => { + const base64Data = (reader.result as string).split(',')[1]; + upload({ + policyId, + fileName: file.name, + fileType: file.type, + fileData: base64Data, + }); + }; + reader.onerror = () => toast.error('Failed to read the file for uploading.'); + }; + + // Handle direct drop on main card area + const handleMainCardDrop = (acceptedFiles: File[]) => { + if (acceptedFiles.length === 0) { + toast.error('No valid PDF file selected'); + return; + } + + if (acceptedFiles.length > 1) { + toast.error('Please upload only one PDF file at a time'); + return; + } + + const file = acceptedFiles[0]; + if (file.size > 100 * 1024 * 1024) { + toast.error('File size must be less than 100MB'); + return; + } + + handleUpload(acceptedFiles); + }; + + const isUploading = uploadStatus === 'executing'; + + return ( + + + + {signedUrl ? ( + + {pdfUrl?.split('/').pop()} + + + ) : ( + pdfUrl?.split('/').pop() + )} + + + + {pdfUrl ? ( +
+ {isUrlLoading ? ( +
+ +
+ ) : signedUrl ? ( +
+