diff --git a/apps/api/.gitignore b/apps/api/.gitignore index 4b56acfbe..01b6aa2b3 100644 --- a/apps/api/.gitignore +++ b/apps/api/.gitignore @@ -54,3 +54,6 @@ pids # Diagnostic reports (https://nodejs.org/api/report.html) report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + + +prisma/schema.prisma \ No newline at end of file diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile index f71ebfb33..a66077e63 100644 --- a/apps/api/Dockerfile +++ b/apps/api/Dockerfile @@ -29,4 +29,4 @@ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ CMD wget --no-verbose --tries=1 --spider http://localhost:3333/v1/health || exit 1 # Start the application -CMD ["node", "main.js"] \ No newline at end of file +CMD ["node", "src/main.js"] \ No newline at end of file diff --git a/apps/api/buildspec.yml b/apps/api/buildspec.yml index 15180b37d..e6a222549 100644 --- a/apps/api/buildspec.yml +++ b/apps/api/buildspec.yml @@ -25,36 +25,52 @@ phases: - echo "Validating environment variables..." - '[ -n "$DATABASE_URL" ] || { echo "❌ DATABASE_URL is not set"; exit 1; }' - '[ -n "$BASE_URL" ] || { echo "❌ BASE_URL is not set"; exit 1; }' + - '[ -n "$BETTER_AUTH_URL" ] || { echo "❌ BETTER_AUTH_URL is not set"; exit 1; }' - # Install dependencies - - echo "Installing dependencies..." - - bun install --frozen-lockfile - - # Generate Prisma client - - echo "Generating Prisma client..." - - cd packages/db && bun x prisma generate && cd ../../ + # Install only API workspace dependencies + - echo "Installing API dependencies only..." + - bun install --filter=@comp/api --frozen-lockfile - # Build NestJS application + # Build NestJS application (prebuild automatically handles Prisma) - echo "Building NestJS application..." - - cd apps/$APP_NAME && bun run build && cd ../../ + - echo "APP_NAME is set to $APP_NAME" + - echo "Current directory $(pwd)" + - echo "Available apps $(ls -la apps/)" + - cd apps/api + - echo "Changed to $(pwd)" + - echo "Running build (includes automatic prebuild db:generate)..." + - bun run build + + # Verify build output exists + - echo "Checking build output..." + - ls -la dist/ + - ls -la dist/src/ + - '[ -f "dist/src/main.js" ] || { echo "❌ main.js not found in dist/src"; exit 1; }' - # Create self-contained bundle for Docker + # Create self-contained bundle for Docker (stay in apps/api) - echo "Creating self-contained bundle..." - - cd apps/$APP_NAME - mkdir -p ../docker-build - # Copy built application + # Copy built application (preserves NestJS structure) + - echo "Copying built application..." - cp -r dist/* ../docker-build/ - # Copy entire node_modules for runtime (simpler and comprehensive) + # Copy prisma folder (needed for runtime imports) + - echo "Copying prisma folder..." + - cp -r prisma ../docker-build/ + + # Verify files were copied correctly + - echo "Verifying copied files..." + - ls -la ../docker-build/ + - ls -la ../docker-build/src/ + - '[ -f "../docker-build/src/main.js" ] || { echo "❌ main.js not found in docker-build/src"; exit 1; }' + + # Copy entire node_modules for runtime (includes @trycompai/db from npm) - echo "Bundling all runtime dependencies..." - cp -r ../../node_modules ../docker-build/ - # Copy @trycompai/db package - - mkdir -p ../docker-build/node_modules/@trycompai - - cp -r ../../packages/db ../docker-build/node_modules/@trycompai/ - # Copy Dockerfile + - echo "Copying Dockerfile..." - cp Dockerfile ../docker-build/ # Build Docker image @@ -69,7 +85,7 @@ phases: - docker push $ECR_REPOSITORY_URI:latest - echo "Updating ECS service..." - aws ecs update-service --cluster $ECS_CLUSTER_NAME --service $ECS_SERVICE_NAME --force-new-deployment - - 'printf "[{\"name\":\"%s-container\",\"imageUri\":\"%s\"}]" $APP_NAME $ECR_REPOSITORY_URI:$IMAGE_TAG > imagedefinitions.json' + - 'printf "[{\"name\":\"%s-container\",\"imageUri\":\"%s\"}]" api $ECR_REPOSITORY_URI:$IMAGE_TAG > imagedefinitions.json' cache: paths: diff --git a/apps/api/eslint.config.mjs b/apps/api/eslint.config.mjs index caebf6e70..212fd04b3 100644 --- a/apps/api/eslint.config.mjs +++ b/apps/api/eslint.config.mjs @@ -28,7 +28,12 @@ export default tseslint.config( rules: { '@typescript-eslint/no-explicit-any': 'off', '@typescript-eslint/no-floating-promises': 'warn', - '@typescript-eslint/no-unsafe-argument': 'warn' + '@typescript-eslint/no-unsafe-argument': 'warn', + '@typescript-eslint/no-unsafe-assignment': 'off', + '@typescript-eslint/no-unsafe-call': 'off', + '@typescript-eslint/no-unsafe-member-access': 'off', + '@typescript-eslint/no-unsafe-return': 'off', + '@typescript-eslint/restrict-template-expressions': 'off', }, }, -); \ No newline at end of file +); diff --git a/apps/api/package.json b/apps/api/package.json index 907997ca3..a51c1f10d 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -18,16 +18,24 @@ "test:watch": "jest --watch", "test:cov": "jest --coverage", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", - "test:e2e": "jest --config ./test/jest-e2e.json" + "test:e2e": "jest --config ./test/jest-e2e.json", + "typecheck": "tsc --noEmit", + "db:generate": "bun run db:getschema && prisma generate", + "db:getschema": "cp ../../node_modules/@trycompai/db/dist/schema.prisma prisma/schema.prisma", + "prebuild": "bun run db:generate" }, "dependencies": { + "@aws-sdk/client-s3": "^3.859.0", + "@aws-sdk/s3-request-presigner": "^3.859.0", "@nestjs/common": "^11.0.1", + "@nestjs/config": "^4.0.2", "@nestjs/core": "^11.0.1", "@nestjs/platform-express": "^11.1.5", "@nestjs/swagger": "^11.2.0", - "@trycompai/db": "file:../../packages/db", + "@trycompai/db": "^1.3.2", "class-transformer": "^0.5.1", "class-validator": "^0.14.2", + "jose": "^6.0.12", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", "swagger-ui-express": "^5.0.1", diff --git a/apps/api/prisma/client.ts b/apps/api/prisma/client.ts new file mode 100644 index 000000000..a696328be --- /dev/null +++ b/apps/api/prisma/client.ts @@ -0,0 +1,7 @@ +import { PrismaClient } from '@prisma/client'; + +const globalForPrisma = global as unknown as { prisma: PrismaClient }; + +export const db = globalForPrisma.prisma || new PrismaClient(); + +if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = db; diff --git a/apps/api/prisma/index.ts b/apps/api/prisma/index.ts new file mode 100644 index 000000000..54d1c4b9c --- /dev/null +++ b/apps/api/prisma/index.ts @@ -0,0 +1,2 @@ +export * from '@prisma/client'; +export { db } from './client'; diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 1cd5a4582..4fd79e509 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -1,13 +1,32 @@ import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; import { AppController } from './app.controller'; import { AppService } from './app.service'; +import { AttachmentsModule } from './attachments/attachments.module'; import { AuthModule } from './auth/auth.module'; +import { CommentsModule } from './comments/comments.module'; +import { awsConfig } from './config/aws.config'; import { HealthModule } from './health/health.module'; import { OrganizationModule } from './organization/organization.module'; import { TasksModule } from './tasks/tasks.module'; @Module({ - imports: [AuthModule, OrganizationModule, TasksModule, HealthModule], + imports: [ + ConfigModule.forRoot({ + isGlobal: true, + load: [awsConfig], + validationOptions: { + allowUnknown: true, + abortEarly: true, + }, + }), + AuthModule, + OrganizationModule, + AttachmentsModule, + TasksModule, + CommentsModule, + HealthModule, + ], controllers: [AppController], providers: [AppService], }) diff --git a/apps/api/src/attachments/attachments.module.ts b/apps/api/src/attachments/attachments.module.ts new file mode 100644 index 000000000..46434a9db --- /dev/null +++ b/apps/api/src/attachments/attachments.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { AttachmentsService } from './attachments.service'; + +@Module({ + providers: [AttachmentsService], + exports: [AttachmentsService], +}) +export class AttachmentsModule {} \ No newline at end of file diff --git a/apps/api/src/attachments/attachments.service.ts b/apps/api/src/attachments/attachments.service.ts new file mode 100644 index 000000000..7cf993489 --- /dev/null +++ b/apps/api/src/attachments/attachments.service.ts @@ -0,0 +1,268 @@ +import { + DeleteObjectCommand, + GetObjectCommand, + PutObjectCommand, + S3Client, +} from '@aws-sdk/client-s3'; +import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; +import { AttachmentEntityType, AttachmentType, db } from '@db'; +import { + BadRequestException, + Injectable, + InternalServerErrorException, +} from '@nestjs/common'; +import { randomBytes } from 'crypto'; +import { AttachmentResponseDto } from '../tasks/dto/task-responses.dto'; +import { UploadAttachmentDto } from './upload-attachment.dto'; + +@Injectable() +export class AttachmentsService { + private s3Client: S3Client; + private bucketName: string; + private readonly MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024; // 10MB + private readonly SIGNED_URL_EXPIRY = 900; // 15 minutes + + constructor() { + // AWS configuration is validated at startup via ConfigModule + // Safe to access environment variables directly since they're validated + this.bucketName = 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!, + }, + }); + } + + /** + * Upload attachment to S3 and create database record + */ + async uploadAttachment( + organizationId: string, + entityId: string, + entityType: AttachmentEntityType, + uploadDto: UploadAttachmentDto, + userId?: string, + ): Promise { + try { + // Validate file size + const fileBuffer = Buffer.from(uploadDto.fileData, 'base64'); + if (fileBuffer.length > this.MAX_FILE_SIZE_BYTES) { + throw new BadRequestException( + `File size exceeds maximum allowed size of ${this.MAX_FILE_SIZE_BYTES / (1024 * 1024)}MB`, + ); + } + + // Generate unique file key + const fileId = randomBytes(16).toString('hex'); + const sanitizedFileName = this.sanitizeFileName(uploadDto.fileName); + const timestamp = Date.now(); + const s3Key = `${organizationId}/attachments/${entityType}/${entityId}/${timestamp}-${fileId}-${sanitizedFileName}`; + + // Upload to S3 + const putCommand = new PutObjectCommand({ + Bucket: this.bucketName, + Key: s3Key, + Body: fileBuffer, + ContentType: uploadDto.fileType, + Metadata: { + originalFileName: uploadDto.fileName, + organizationId, + entityId, + entityType, + ...(userId && { uploadedBy: userId }), + }, + }); + + await this.s3Client.send(putCommand); + + // Create database record + const attachment = await db.attachment.create({ + data: { + name: uploadDto.fileName, + url: s3Key, + type: this.mapFileTypeToAttachmentType(uploadDto.fileType), + entityId, + entityType, + organizationId, + }, + }); + + // Generate signed URL for immediate access + const downloadUrl = await this.generateSignedUrl(s3Key); + + return { + id: attachment.id, + name: attachment.name, + type: attachment.type, + downloadUrl, + createdAt: attachment.createdAt, + size: fileBuffer.length, + }; + } catch (error) { + console.error('Error uploading attachment:', error); + if (error instanceof BadRequestException) { + throw error; + } + throw new InternalServerErrorException('Failed to upload attachment'); + } + } + + /** + * Get all attachments for an entity + */ + async getAttachments( + organizationId: string, + entityId: string, + entityType: AttachmentEntityType, + ): Promise { + const attachments = await db.attachment.findMany({ + where: { + organizationId, + entityId, + entityType, + }, + orderBy: { + createdAt: 'asc', + }, + }); + + // Generate signed URLs for all attachments + const attachmentsWithUrls = await Promise.all( + attachments.map(async (attachment) => { + const downloadUrl = await this.generateSignedUrl(attachment.url); + return { + id: attachment.id, + name: attachment.name, + type: attachment.type, + downloadUrl, + createdAt: attachment.createdAt, + }; + }), + ); + + return attachmentsWithUrls; + } + + /** + * Get download URL for an attachment + */ + async getAttachmentDownloadUrl( + organizationId: string, + attachmentId: string, + ): Promise<{ downloadUrl: string; expiresIn: number }> { + try { + // Get attachment record + const attachment = await db.attachment.findFirst({ + where: { + id: attachmentId, + organizationId, + }, + }); + + if (!attachment) { + throw new BadRequestException('Attachment not found'); + } + + // Generate signed URL + const downloadUrl = await this.generateSignedUrl(attachment.url); + + return { + downloadUrl, + expiresIn: this.SIGNED_URL_EXPIRY, + }; + } catch (error) { + console.error('Error generating download URL:', error); + if (error instanceof BadRequestException) { + throw error; + } + throw new InternalServerErrorException('Failed to generate download URL'); + } + } + + /** + * Delete attachment from S3 and database + */ + async deleteAttachment( + organizationId: string, + attachmentId: string, + ): Promise { + try { + // Get attachment record + const attachment = await db.attachment.findFirst({ + where: { + id: attachmentId, + organizationId, + }, + }); + + if (!attachment) { + throw new BadRequestException('Attachment not found'); + } + + // Delete from S3 + const deleteCommand = new DeleteObjectCommand({ + Bucket: this.bucketName, + Key: attachment.url, + }); + + await this.s3Client.send(deleteCommand); + + // Delete from database + await db.attachment.delete({ + where: { + id: attachmentId, + organizationId, + }, + }); + } catch (error) { + console.error('Error deleting attachment:', error); + if (error instanceof BadRequestException) { + throw error; + } + throw new InternalServerErrorException('Failed to delete attachment'); + } + } + + /** + * Generate signed URL for file download + */ + private async generateSignedUrl(s3Key: string): Promise { + const getCommand = new GetObjectCommand({ + Bucket: this.bucketName, + Key: s3Key, + }); + + return getSignedUrl(this.s3Client, getCommand, { + expiresIn: this.SIGNED_URL_EXPIRY, + }); + } + + /** + * Sanitize filename for S3 storage + */ + private sanitizeFileName(fileName: string): string { + return fileName.replace(/[^a-zA-Z0-9.-]/g, '_'); + } + + /** + * Map MIME type to AttachmentType enum + */ + private mapFileTypeToAttachmentType(fileType: string): AttachmentType { + const type = fileType.split('/')[0]; + switch (type) { + case 'image': + return AttachmentType.image; + case 'video': + return AttachmentType.video; + case 'audio': + return AttachmentType.audio; + case 'application': + case 'text': + return AttachmentType.document; + default: + return AttachmentType.other; + } + } +} diff --git a/apps/api/src/attachments/upload-attachment.dto.ts b/apps/api/src/attachments/upload-attachment.dto.ts new file mode 100644 index 000000000..33f2aceb0 --- /dev/null +++ b/apps/api/src/attachments/upload-attachment.dto.ts @@ -0,0 +1,66 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; +import { + IsBase64, + IsIn, + IsNotEmpty, + IsOptional, + IsString, + MaxLength, +} from 'class-validator'; + +const ALLOWED_FILE_TYPES = [ + 'image/jpeg', + 'image/png', + 'image/gif', + 'image/webp', + 'application/pdf', + 'text/plain', + 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', +]; + +export class UploadAttachmentDto { + @ApiProperty({ + description: 'Name of the file', + example: 'document.pdf', + maxLength: 255, + }) + @IsString() + @IsNotEmpty() + @MaxLength(255) + @Transform(({ value }) => value?.trim()) + fileName: string; + + @ApiProperty({ + description: 'MIME type of the file', + example: 'application/pdf', + enum: ALLOWED_FILE_TYPES, + }) + @IsString() + @IsIn(ALLOWED_FILE_TYPES, { + message: `File type must be one of: ${ALLOWED_FILE_TYPES.join(', ')}`, + }) + fileType: string; + + @ApiProperty({ + description: 'Base64 encoded file data', + example: + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==', + }) + @IsString() + @IsNotEmpty() + @IsBase64() + fileData: string; + + @ApiProperty({ + description: 'Description of the attachment', + example: 'Meeting notes from Q4 planning session', + required: false, + maxLength: 500, + }) + @IsOptional() + @IsString() + @MaxLength(500) + description?: string; +} diff --git a/apps/api/src/auth/auth-context.decorator.ts b/apps/api/src/auth/auth-context.decorator.ts new file mode 100644 index 000000000..618c712fc --- /dev/null +++ b/apps/api/src/auth/auth-context.decorator.ts @@ -0,0 +1,78 @@ +import { createParamDecorator, ExecutionContext } from '@nestjs/common'; +import { AuthContext as AuthContextType, AuthenticatedRequest } from './types'; + +/** + * Parameter decorator to extract the full authentication context + * Works with both API key and session authentication + */ +export const AuthContext = createParamDecorator( + (data: unknown, ctx: ExecutionContext): AuthContextType => { + const request = ctx.switchToHttp().getRequest(); + + const { organizationId, authType, isApiKey, userId, userEmail } = request; + + if (!organizationId || !authType) { + throw new Error( + 'Authentication context not found. Ensure HybridAuthGuard is applied.', + ); + } + + return { + organizationId, + authType, + isApiKey, + userId, + userEmail, + }; + }, +); + +/** + * Parameter decorator to extract just the organization ID + */ +export const OrganizationId = createParamDecorator( + (data: unknown, ctx: ExecutionContext): string => { + const request = ctx.switchToHttp().getRequest(); + const { organizationId } = request; + + if (!organizationId) { + throw new Error( + 'Organization ID not found. Ensure HybridAuthGuard is applied.', + ); + } + + return organizationId; + }, +); + +/** + * Parameter decorator to extract the user ID (only available for session auth) + */ +export const UserId = createParamDecorator( + (data: unknown, ctx: ExecutionContext): string => { + const request = ctx.switchToHttp().getRequest(); + const { userId, authType } = request; + + if (authType === 'api-key') { + throw new Error('User ID is not available for API key authentication'); + } + + if (!userId) { + throw new Error( + 'User ID not found. Ensure HybridAuthGuard is applied and using session auth.', + ); + } + + return userId; + }, +); + +/** + * Parameter decorator to check if the request is authenticated via API key + */ +export const IsApiKeyAuth = createParamDecorator( + (data: unknown, ctx: ExecutionContext): boolean => { + const request = ctx.switchToHttp().getRequest(); + return request.isApiKey; + }, +); diff --git a/apps/api/src/auth/auth.module.ts b/apps/api/src/auth/auth.module.ts index 750fe6dd5..5f1d0e2e1 100644 --- a/apps/api/src/auth/auth.module.ts +++ b/apps/api/src/auth/auth.module.ts @@ -1,9 +1,10 @@ import { Module } from '@nestjs/common'; import { ApiKeyGuard } from './api-key.guard'; import { ApiKeyService } from './api-key.service'; +import { HybridAuthGuard } from './hybrid-auth.guard'; @Module({ - providers: [ApiKeyService, ApiKeyGuard], - exports: [ApiKeyService, ApiKeyGuard], + providers: [ApiKeyService, ApiKeyGuard, HybridAuthGuard], + exports: [ApiKeyService, ApiKeyGuard, HybridAuthGuard], }) export class AuthModule {} diff --git a/apps/api/src/auth/hybrid-auth.guard.ts b/apps/api/src/auth/hybrid-auth.guard.ts new file mode 100644 index 000000000..ea2bde011 --- /dev/null +++ b/apps/api/src/auth/hybrid-auth.guard.ts @@ -0,0 +1,145 @@ +import { + CanActivate, + ExecutionContext, + Injectable, + UnauthorizedException, +} from '@nestjs/common'; +import { db } from '@trycompai/db'; +import { createRemoteJWKSet, jwtVerify } from 'jose'; +import { ApiKeyService } from './api-key.service'; +import { AuthenticatedRequest } from './types'; + +@Injectable() +export class HybridAuthGuard implements CanActivate { + constructor(private readonly apiKeyService: ApiKeyService) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + + // Try API Key authentication first (for external customers) + const apiKey = request.headers['x-api-key'] as string; + if (apiKey) { + return this.handleApiKeyAuth(request, apiKey); + } + + // Try Bearer JWT token authentication (for internal frontend) + const authHeader = request.headers['authorization'] as string; + if (authHeader?.startsWith('Bearer ')) { + return this.handleJwtAuth(request, authHeader); + } + + throw new UnauthorizedException( + 'Authentication required: Provide either X-API-Key or Bearer JWT token', + ); + } + + private async handleApiKeyAuth( + request: AuthenticatedRequest, + apiKey: string, + ): Promise { + const extractedKey = this.apiKeyService.extractApiKey(apiKey); + if (!extractedKey) { + throw new UnauthorizedException('Invalid API key format'); + } + + const organizationId = + await this.apiKeyService.validateApiKey(extractedKey); + if (!organizationId) { + throw new UnauthorizedException('Invalid or expired API key'); + } + + // Set request context for API key auth + request.organizationId = organizationId; + request.authType = 'api-key'; + request.isApiKey = true; + + return true; + } + + private async handleJwtAuth( + request: AuthenticatedRequest, + authHeader: string, + ): Promise { + try { + // Extract token from "Bearer " + const token = authHeader.substring(7); + + // Create JWKS for token verification using Better Auth endpoint + const JWKS = createRemoteJWKSet( + new URL(`${process.env.BETTER_AUTH_URL}/api/auth/jwks`), + ); + + // Verify JWT token + const { payload } = await jwtVerify(token, JWKS, { + issuer: process.env.BETTER_AUTH_URL, + audience: process.env.BETTER_AUTH_URL, + }); + + // Extract user information from JWT payload (user data is directly in payload for Better Auth JWT) + const userId = payload.id as string; + const userEmail = payload.email as string; + + if (!userId) { + throw new UnauthorizedException( + 'Invalid JWT payload: missing user information', + ); + } + + // JWT authentication REQUIRES explicit X-Organization-Id header + const explicitOrgId = request.headers['x-organization-id'] as string; + + if (!explicitOrgId) { + throw new UnauthorizedException( + 'Organization context required: X-Organization-Id header is mandatory for JWT authentication', + ); + } + + // Verify user has access to the requested organization + const hasAccess = await this.verifyUserOrgAccess(userId, explicitOrgId); + if (!hasAccess) { + throw new UnauthorizedException( + `User does not have access to organization: ${explicitOrgId}`, + ); + } + + // Set request context for JWT auth + request.userId = userId; + request.userEmail = userEmail; + request.organizationId = explicitOrgId; + request.authType = 'jwt'; + request.isApiKey = false; + + return true; + } catch (error) { + console.error('JWT verification failed:', error); + throw new UnauthorizedException('Invalid or expired JWT token'); + } + } + + /** + * Verify that a user has access to a specific organization + */ + private async verifyUserOrgAccess( + userId: string, + organizationId: string, + ): Promise { + try { + const member = await db.member.findFirst({ + where: { + userId, + organizationId, + }, + select: { + id: true, + role: true, + }, + }); + + // User must be a member of the organization + return !!member; + } catch (error: unknown) { + console.error('Error verifying user organization access:', error); + return false; + } + } +} diff --git a/apps/api/src/auth/types.ts b/apps/api/src/auth/types.ts new file mode 100644 index 000000000..1c1a3a28b --- /dev/null +++ b/apps/api/src/auth/types.ts @@ -0,0 +1,17 @@ +// Types for API authentication - supports API keys and JWT tokens only + +export interface AuthenticatedRequest extends Request { + organizationId: string; + authType: 'api-key' | 'jwt'; + isApiKey: boolean; + userId?: string; + userEmail?: string; +} + +export interface AuthContext { + organizationId: string; + authType: 'api-key' | 'jwt'; + isApiKey: boolean; + userId?: string; // Only available for JWT auth + userEmail?: string; // Only available for JWT auth +} diff --git a/apps/api/src/comments/comments.controller.ts b/apps/api/src/comments/comments.controller.ts new file mode 100644 index 000000000..1769ae803 --- /dev/null +++ b/apps/api/src/comments/comments.controller.ts @@ -0,0 +1,169 @@ +import { CommentEntityType } from '@db'; +import { + BadRequestException, + Body, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + Param, + Post, + Put, + Query, + UseGuards, +} from '@nestjs/common'; +import { + ApiHeader, + ApiOperation, + ApiParam, + ApiQuery, + 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 { CommentsService } from './comments.service'; +import { CommentResponseDto } from './dto/comment-responses.dto'; +import { CreateCommentDto } from './dto/create-comment.dto'; +import { UpdateCommentDto } from './dto/update-comment.dto'; + +@ApiTags('Comments') +@Controller({ path: 'comments', 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 CommentsController { + constructor(private readonly commentsService: CommentsService) {} + + @Get() + @ApiOperation({ + summary: 'Get comments for an entity', + description: + 'Retrieve all comments for a specific entity (task, policy, vendor, etc.)', + }) + @ApiQuery({ + name: 'entityId', + description: 'ID of the entity to get comments for', + example: 'tsk_abc123def456', + }) + @ApiQuery({ + name: 'entityType', + description: 'Type of entity', + enum: CommentEntityType, + example: 'task', + }) + @ApiResponse({ + status: 200, + description: 'Comments retrieved successfully', + type: [CommentResponseDto], + }) + async getComments( + @OrganizationId() organizationId: string, + @Query('entityId') entityId: string, + @Query('entityType') entityType: CommentEntityType, + ): Promise { + return await this.commentsService.getComments( + organizationId, + entityId, + entityType, + ); + } + + @Post() + @ApiOperation({ + summary: 'Create a new comment', + description: 'Create a comment on an entity with optional file attachments', + }) + @ApiResponse({ + status: 201, + description: 'Comment created successfully', + type: CommentResponseDto, + }) + async createComment( + @OrganizationId() organizationId: string, + @AuthContext() authContext: AuthContextType, + @Body() createCommentDto: CreateCommentDto, + ): Promise { + if (!authContext.userId) { + throw new BadRequestException('User ID is required'); + } + + return await this.commentsService.createComment( + organizationId, + authContext.userId, + createCommentDto, + ); + } + + @Put(':commentId') + @ApiOperation({ + summary: 'Update a comment', + description: 'Update the content of an existing comment (author only)', + }) + @ApiParam({ + name: 'commentId', + description: 'Unique comment identifier', + example: 'cmt_abc123def456', + }) + @ApiResponse({ + status: 200, + description: 'Comment updated successfully', + type: CommentResponseDto, + }) + async updateComment( + @OrganizationId() organizationId: string, + @AuthContext() authContext: AuthContextType, + @Param('commentId') commentId: string, + @Body() updateCommentDto: UpdateCommentDto, + ): Promise { + if (!authContext.userId) { + throw new BadRequestException('User ID is required'); + } + + return await this.commentsService.updateComment( + organizationId, + commentId, + authContext.userId, + updateCommentDto.content, + ); + } + + @Delete(':commentId') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ + summary: 'Delete a comment', + description: 'Delete a comment and all its attachments (author only)', + }) + @ApiParam({ + name: 'commentId', + description: 'Unique comment identifier', + example: 'cmt_abc123def456', + }) + @ApiResponse({ + status: 204, + description: 'Comment deleted successfully', + }) + async deleteComment( + @OrganizationId() organizationId: string, + @AuthContext() authContext: AuthContextType, + @Param('commentId') commentId: string, + ): Promise { + if (!authContext.userId) { + throw new BadRequestException('User ID is required'); + } + + return await this.commentsService.deleteComment( + organizationId, + commentId, + authContext.userId, + ); + } +} diff --git a/apps/api/src/comments/comments.module.ts b/apps/api/src/comments/comments.module.ts new file mode 100644 index 000000000..47698a451 --- /dev/null +++ b/apps/api/src/comments/comments.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { AttachmentsModule } from '../attachments/attachments.module'; +import { AuthModule } from '../auth/auth.module'; +import { CommentsController } from './comments.controller'; +import { CommentsService } from './comments.service'; + +@Module({ + imports: [AuthModule, AttachmentsModule], // Import AuthModule for HybridAuthGuard dependencies + controllers: [CommentsController], + providers: [CommentsService], + exports: [CommentsService], +}) +export class CommentsModule {} diff --git a/apps/api/src/comments/comments.service.ts b/apps/api/src/comments/comments.service.ts new file mode 100644 index 000000000..c2521fe5d --- /dev/null +++ b/apps/api/src/comments/comments.service.ts @@ -0,0 +1,364 @@ +import { AttachmentEntityType, CommentEntityType } from '@db'; +import { + BadRequestException, + Injectable, + InternalServerErrorException, +} from '@nestjs/common'; +import { db } from '@trycompai/db'; +import { AttachmentsService } from '../attachments/attachments.service'; +import { + AttachmentResponseDto, + CommentResponseDto, +} from './dto/comment-responses.dto'; +import { CreateCommentDto } from './dto/create-comment.dto'; + +@Injectable() +export class CommentsService { + constructor(private readonly attachmentsService: AttachmentsService) {} + + /** + * Validate that the target entity exists and belongs to the organization + */ + private async validateEntityAccess( + organizationId: string, + entityId: string, + entityType: CommentEntityType, + ): Promise { + let entityExists = false; + + switch (entityType) { + case CommentEntityType.task: { + const task = await db.task.findFirst({ + where: { id: entityId, organizationId }, + }); + entityExists = !!task; + break; + } + + case CommentEntityType.policy: { + const policy = await db.policy.findFirst({ + where: { id: entityId, organizationId }, + }); + entityExists = !!policy; + break; + } + + case CommentEntityType.vendor: { + const vendor = await db.vendor.findFirst({ + where: { id: entityId, organizationId }, + }); + entityExists = !!vendor; + break; + } + + case CommentEntityType.risk: { + const risk = await db.risk.findFirst({ + where: { id: entityId, organizationId }, + }); + entityExists = !!risk; + break; + } + + default: + throw new BadRequestException(`Unsupported entity type: ${entityType}`); + } + + if (!entityExists) { + throw new BadRequestException(`${entityType} not found or access denied`); + } + } + + /** + * Get all comments for an entity + */ + async getComments( + organizationId: string, + entityId: string, + entityType: CommentEntityType, + ): Promise { + try { + // Validate entity access + await this.validateEntityAccess(organizationId, entityId, entityType); + + const comments = await db.comment.findMany({ + where: { + organizationId, + entityId, + entityType, + }, + include: { + author: { + include: { + user: true, + }, + }, + }, + orderBy: { + createdAt: 'desc', + }, + }); + + // Get attachments for each comment + const commentsWithAttachments = await Promise.all( + comments.map(async (comment) => { + const attachments = await this.attachmentsService.getAttachments( + organizationId, + comment.id, + AttachmentEntityType.comment, + ); + + return { + id: comment.id, + content: comment.content, + author: { + id: comment.author.user.id, + name: comment.author.user.name, + email: comment.author.user.email, + }, + attachments, + createdAt: comment.createdAt, + }; + }), + ); + + return commentsWithAttachments; + } catch (error) { + console.error('Error fetching comments:', error); + if (error instanceof BadRequestException) { + throw error; + } + throw new InternalServerErrorException('Failed to fetch comments'); + } + } + + /** + * Create a new comment with optional attachments + */ + async createComment( + organizationId: string, + userId: string, + createCommentDto: CreateCommentDto, + ): Promise { + try { + // Validate entity access + await this.validateEntityAccess( + organizationId, + createCommentDto.entityId, + createCommentDto.entityType, + ); + + // Get user and member info + const member = await db.member.findFirst({ + where: { + userId, + organizationId, + }, + include: { + user: true, + }, + }); + + if (!member) { + throw new BadRequestException( + 'User is not a member of this organization', + ); + } + + // Use transaction to ensure data consistency + const result = await db.$transaction(async (tx) => { + // Create comment + const comment = await tx.comment.create({ + data: { + content: createCommentDto.content, + entityId: createCommentDto.entityId, + entityType: createCommentDto.entityType, + organizationId, + authorId: member.id, + }, + }); + + // Upload attachments if provided + const attachments: AttachmentResponseDto[] = []; + if ( + createCommentDto.attachments && + createCommentDto.attachments.length > 0 + ) { + for (const attachmentDto of createCommentDto.attachments) { + const attachment = await this.attachmentsService.uploadAttachment( + organizationId, + comment.id, + AttachmentEntityType.comment, + attachmentDto, + userId, + ); + attachments.push(attachment); + } + } + + return { + comment, + attachments, + }; + }); + + return { + id: result.comment.id, + content: result.comment.content, + author: { + id: member.user.id, + name: member.user.name, + email: member.user.email, + }, + attachments: result.attachments, + createdAt: result.comment.createdAt, + }; + } catch (error) { + console.error('Error creating comment:', error); + if (error instanceof BadRequestException) { + throw error; + } + throw new InternalServerErrorException('Failed to create comment'); + } + } + + /** + * Update a comment + */ + async updateComment( + organizationId: string, + commentId: string, + userId: string, + content: string, + ): Promise { + try { + // Get comment and verify ownership/permissions + const existingComment = await db.comment.findFirst({ + where: { + id: commentId, + organizationId, + }, + include: { + author: { + include: { + user: true, + }, + }, + }, + }); + + if (!existingComment) { + throw new BadRequestException('Comment not found or access denied'); + } + + // Verify user is the author or has admin privileges + if (existingComment.author.userId !== userId) { + throw new BadRequestException('You can only edit your own comments'); + } + + // Update comment + const updatedComment = await db.comment.update({ + where: { + id: commentId, + organizationId, + }, + data: { + content, + }, + }); + + // Get attachments + const attachments = await this.attachmentsService.getAttachments( + organizationId, + commentId, + AttachmentEntityType.comment, + ); + + return { + id: updatedComment.id, + content: updatedComment.content, + author: { + id: existingComment.author.user.id, + name: existingComment.author.user.name, + email: existingComment.author.user.email, + }, + attachments, + createdAt: updatedComment.createdAt, + }; + } catch (error) { + console.error('Error updating comment:', error); + if (error instanceof BadRequestException) { + throw error; + } + throw new InternalServerErrorException('Failed to update comment'); + } + } + + /** + * Delete a comment and its attachments + */ + async deleteComment( + organizationId: string, + commentId: string, + userId: string, + ): Promise { + try { + // Get comment and verify ownership/permissions + const existingComment = await db.comment.findFirst({ + where: { + id: commentId, + organizationId, + }, + include: { + author: { + include: { + user: true, + }, + }, + }, + }); + + if (!existingComment) { + throw new BadRequestException('Comment not found or access denied'); + } + + // Verify user is the author or has admin privileges + if (existingComment.author.userId !== userId) { + throw new BadRequestException('You can only delete your own comments'); + } + + // Use transaction to ensure data consistency + await db.$transaction(async (tx) => { + // Get all attachments for this comment + const attachments = await tx.attachment.findMany({ + where: { + organizationId, + entityId: commentId, + entityType: AttachmentEntityType.comment, + }, + }); + + // Delete attachments from S3 and database + for (const attachment of attachments) { + await this.attachmentsService.deleteAttachment( + organizationId, + attachment.id, + ); + } + + // Delete the comment + await tx.comment.delete({ + where: { + id: commentId, + organizationId, + }, + }); + }); + } catch (error) { + console.error('Error deleting comment:', error); + if (error instanceof BadRequestException) { + throw error; + } + throw new InternalServerErrorException('Failed to delete comment'); + } + } +} diff --git a/apps/api/src/comments/dto/comment-responses.dto.ts b/apps/api/src/comments/dto/comment-responses.dto.ts new file mode 100644 index 000000000..79c864e2e --- /dev/null +++ b/apps/api/src/comments/dto/comment-responses.dto.ts @@ -0,0 +1,91 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class AttachmentResponseDto { + @ApiProperty({ + description: 'Unique identifier for the attachment', + example: 'att_abc123def456', + }) + id: string; + + @ApiProperty({ + description: 'Original filename', + example: 'document.pdf', + }) + name: string; + + @ApiProperty({ + description: 'File type/MIME type', + example: 'application/pdf', + }) + type: string; + + @ApiProperty({ + description: 'File size in bytes', + example: 1024000, + }) + size?: number; + + @ApiProperty({ + description: 'Signed URL for downloading the file (temporary)', + example: 'https://bucket.s3.amazonaws.com/path/to/file.pdf?signature=...', + }) + downloadUrl: string; + + @ApiProperty({ + description: 'Upload timestamp', + example: '2024-01-15T10:30:00Z', + }) + createdAt: Date; +} + +export class AuthorResponseDto { + @ApiProperty({ + description: 'User ID', + example: 'usr_abc123def456', + }) + id: string; + + @ApiProperty({ + description: 'User name', + example: 'John Doe', + }) + name: string; + + @ApiProperty({ + description: 'User email', + example: 'john.doe@company.com', + }) + email: string; +} + +export class CommentResponseDto { + @ApiProperty({ + description: 'Unique identifier for the comment', + example: 'cmt_abc123def456', + }) + id: string; + + @ApiProperty({ + description: 'Comment content', + example: 'This task needs to be completed by end of week', + }) + content: string; + + @ApiProperty({ + description: 'Comment author information', + type: AuthorResponseDto, + }) + author: AuthorResponseDto; + + @ApiProperty({ + description: 'Attachments associated with this comment', + type: [AttachmentResponseDto], + }) + attachments: AttachmentResponseDto[]; + + @ApiProperty({ + description: 'Comment creation timestamp', + example: '2024-01-15T10:30:00Z', + }) + createdAt: Date; +} \ No newline at end of file diff --git a/apps/api/src/comments/dto/create-comment.dto.ts b/apps/api/src/comments/dto/create-comment.dto.ts new file mode 100644 index 000000000..6775f8832 --- /dev/null +++ b/apps/api/src/comments/dto/create-comment.dto.ts @@ -0,0 +1,52 @@ +import { CommentEntityType } from '@db'; +import { ApiProperty } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { + IsArray, + IsEnum, + IsNotEmpty, + IsOptional, + IsString, + MaxLength, + ValidateNested, +} from 'class-validator'; +import { UploadAttachmentDto } from '../../attachments/upload-attachment.dto'; + +export class CreateCommentDto { + @ApiProperty({ + description: 'Content of the comment', + example: 'This task needs to be completed by end of week', + maxLength: 2000, + }) + @IsString() + @IsNotEmpty() + @MaxLength(2000) + content: string; + + @ApiProperty({ + description: 'ID of the entity to comment on', + example: 'tsk_abc123def456', + }) + @IsString() + @IsNotEmpty() + entityId: string; + + @ApiProperty({ + description: 'Type of entity being commented on', + enum: CommentEntityType, + example: 'task', + }) + @IsEnum(CommentEntityType) + entityType: CommentEntityType; + + @ApiProperty({ + description: 'Optional attachments to include with the comment', + type: [UploadAttachmentDto], + required: false, + }) + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => UploadAttachmentDto) + attachments?: UploadAttachmentDto[]; +} diff --git a/apps/api/src/comments/dto/update-comment.dto.ts b/apps/api/src/comments/dto/update-comment.dto.ts new file mode 100644 index 000000000..7687c6f69 --- /dev/null +++ b/apps/api/src/comments/dto/update-comment.dto.ts @@ -0,0 +1,14 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsString, MaxLength } from 'class-validator'; + +export class UpdateCommentDto { + @ApiProperty({ + description: 'Updated content of the comment', + example: 'This task needs to be completed by end of week (updated)', + maxLength: 2000, + }) + @IsString() + @IsNotEmpty() + @MaxLength(2000) + content: string; +} \ No newline at end of file diff --git a/apps/api/src/config/aws.config.ts b/apps/api/src/config/aws.config.ts new file mode 100644 index 000000000..40788e08c --- /dev/null +++ b/apps/api/src/config/aws.config.ts @@ -0,0 +1,33 @@ +import { registerAs } from '@nestjs/config'; +import { z } from 'zod'; + +const awsConfigSchema = z.object({ + region: z.string().default('us-east-1'), + accessKeyId: z.string().min(1, 'AWS_ACCESS_KEY_ID is required'), + secretAccessKey: z.string().min(1, 'AWS_SECRET_ACCESS_KEY is required'), + bucketName: z.string().min(1, 'AWS_BUCKET_NAME is required'), +}); + +export type AwsConfig = z.infer; + +export const awsConfig = registerAs('aws', (): AwsConfig => { + const config = { + region: process.env.APP_AWS_REGION || 'us-east-1', + accessKeyId: process.env.APP_AWS_ACCESS_KEY_ID || '', + secretAccessKey: process.env.APP_AWS_SECRET_ACCESS_KEY || '', + bucketName: process.env.APP_AWS_BUCKET_NAME || '', + }; + + // Validate configuration at startup + const result = awsConfigSchema.safeParse(config); + + if (!result.success) { + throw new Error( + `AWS configuration validation failed: ${result.error.issues + .map((e) => `${e.path.join('.')}: ${e.message}`) + .join(', ')}`, + ); + } + + return result.data; +}); diff --git a/apps/api/src/health/health.controller.ts b/apps/api/src/health/health.controller.ts index af927107c..5029f855c 100644 --- a/apps/api/src/health/health.controller.ts +++ b/apps/api/src/health/health.controller.ts @@ -42,4 +42,4 @@ export class HealthController { version: '1.0.0', }; } -} \ No newline at end of file +} diff --git a/apps/api/src/health/health.module.ts b/apps/api/src/health/health.module.ts index e1614049c..7476abedd 100644 --- a/apps/api/src/health/health.module.ts +++ b/apps/api/src/health/health.module.ts @@ -4,4 +4,4 @@ import { HealthController } from './health.controller'; @Module({ controllers: [HealthController], }) -export class HealthModule {} \ No newline at end of file +export class HealthModule {} diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 3dea240dd..c00312b31 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -3,11 +3,29 @@ import { VersioningType } from '@nestjs/common'; import { NestFactory } from '@nestjs/core'; import type { OpenAPIObject } from '@nestjs/swagger'; import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; +import * as express from 'express'; import { AppModule } from './app.module'; async function bootstrap(): Promise { const app: INestApplication = await NestFactory.create(AppModule); + // Configure body parser limits for file uploads (base64 encoded files) + app.use(express.json({ limit: '15mb' })); + app.use(express.urlencoded({ limit: '15mb', extended: true })); + + // Enable CORS for cross-origin requests + app.enableCors({ + origin: true, // Allow requests from any origin + credentials: true, // Allow cookies to be sent cross-origin (for auth) + methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'], + allowedHeaders: [ + 'Content-Type', + 'Authorization', + 'X-API-Key', + 'X-Organization-Id', + ], + }); + // Enable API versioning app.enableVersioning({ type: VersioningType.URI, @@ -43,9 +61,13 @@ async function bootstrap(): Promise { }, }); - await app.listen(port); - console.log(`Application is running on: ${baseUrl}`); - console.log(`API Documentation available at: ${baseUrl}/api/docs`); + const server = await app.listen(port); + const address = server.address(); + const actualPort = typeof address === 'string' ? port : address?.port || port; + const actualUrl = `http://localhost:${actualPort}`; + + console.log(`Application is running on: ${actualUrl}`); + console.log(`API Documentation available at: ${actualUrl}/api/docs`); } // Handle bootstrap errors properly diff --git a/apps/api/src/organization/organization.controller.ts b/apps/api/src/organization/organization.controller.ts index 74b944840..83a9fd371 100644 --- a/apps/api/src/organization/organization.controller.ts +++ b/apps/api/src/organization/organization.controller.ts @@ -1,25 +1,38 @@ import { Controller, Get, UseGuards } from '@nestjs/common'; import { + ApiHeader, ApiOperation, ApiResponse, ApiSecurity, ApiTags, } from '@nestjs/swagger'; -import { ApiKeyGuard } from '../auth/api-key.guard'; -import { Organization } from '../auth/organization.decorator'; +import { + AuthContext, + IsApiKeyAuth, + OrganizationId, +} from '../auth/auth-context.decorator'; +import { HybridAuthGuard } from '../auth/hybrid-auth.guard'; +import type { AuthContext as AuthContextType } from '../auth/types'; import { OrganizationService } from './organization.service'; @ApiTags('Organization') @Controller({ path: 'organization', version: '1' }) -@UseGuards(ApiKeyGuard) -@ApiSecurity('apikey') +@UseGuards(HybridAuthGuard) +@ApiSecurity('apikey') // Still document API key for external customers +@ApiHeader({ + name: 'X-Organization-Id', + description: + 'Organization ID (required for session auth, optional for API key auth)', + required: false, +}) export class OrganizationController { constructor(private readonly organizationService: OrganizationService) {} + @Get() @ApiOperation({ summary: 'Get organization information', description: - 'Returns detailed information about the authenticated organization', + 'Returns detailed information about the authenticated organization. Supports both API key authentication (X-API-Key header) and session authentication (cookies + X-Organization-Id header).', }) @ApiResponse({ status: 200, @@ -47,23 +60,50 @@ export class OrganizationController { format: 'date-time', description: 'When the organization was created', }, + authType: { + type: 'string', + enum: ['api-key', 'session'], + description: 'How the request was authenticated', + }, }, }, }) @ApiResponse({ status: 401, - description: 'Unauthorized - Invalid or missing API key', + description: + 'Unauthorized - Invalid authentication or insufficient permissions', schema: { type: 'object', properties: { message: { type: 'string', - example: 'Invalid or expired API key', + examples: [ + 'Invalid or expired API key', + 'Invalid or expired session', + 'User does not have access to organization', + 'Organization context required', + ], }, }, }, }) - async getOrganization(@Organization() organizationId: string) { - return this.organizationService.findById(organizationId); + async getOrganization( + @OrganizationId() organizationId: string, + @AuthContext() authContext: AuthContextType, + @IsApiKeyAuth() isApiKey: boolean, + ) { + const org = await this.organizationService.findById(organizationId); + + return { + ...org, + authType: authContext.authType, + // Include user context for session auth (helpful for debugging) + ...(authContext.userId && { + authenticatedUser: { + id: authContext.userId, + email: authContext.userEmail, + }, + }), + }; } } diff --git a/apps/api/src/organization/organization.service.ts b/apps/api/src/organization/organization.service.ts index c39e01b0f..c1a6f7788 100644 --- a/apps/api/src/organization/organization.service.ts +++ b/apps/api/src/organization/organization.service.ts @@ -31,4 +31,4 @@ export class OrganizationService { throw error; } } -} \ No newline at end of file +} diff --git a/apps/api/src/tasks/attachments.service.ts b/apps/api/src/tasks/attachments.service.ts new file mode 100644 index 000000000..876f9bce9 --- /dev/null +++ b/apps/api/src/tasks/attachments.service.ts @@ -0,0 +1,269 @@ +import { + DeleteObjectCommand, + GetObjectCommand, + PutObjectCommand, + S3Client, +} from '@aws-sdk/client-s3'; +import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; +import { AttachmentEntityType, AttachmentType } from '@db'; +import { + BadRequestException, + Injectable, + InternalServerErrorException, +} from '@nestjs/common'; +import { db } from '@trycompai/db'; +import { randomBytes } from 'crypto'; +import { AttachmentResponseDto } from './dto/task-responses.dto'; +import { UploadAttachmentDto } from './dto/upload-attachment.dto'; + +@Injectable() +export class AttachmentsService { + private s3Client: S3Client; + private bucketName: string; + private readonly MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024; // 10MB + private readonly SIGNED_URL_EXPIRY = 900; // 15 minutes + + constructor() { + // AWS configuration is validated at startup via ConfigModule + // Safe to access environment variables directly since they're validated + this.bucketName = 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!, + }, + }); + } + + /** + * Upload attachment to S3 and create database record + */ + async uploadAttachment( + organizationId: string, + entityId: string, + entityType: AttachmentEntityType, + uploadDto: UploadAttachmentDto, + userId?: string, + ): Promise { + try { + // Validate file size + const fileBuffer = Buffer.from(uploadDto.fileData, 'base64'); + if (fileBuffer.length > this.MAX_FILE_SIZE_BYTES) { + throw new BadRequestException( + `File size exceeds maximum allowed size of ${this.MAX_FILE_SIZE_BYTES / (1024 * 1024)}MB`, + ); + } + + // Generate unique file key + const fileId = randomBytes(16).toString('hex'); + const sanitizedFileName = this.sanitizeFileName(uploadDto.fileName); + const timestamp = Date.now(); + const s3Key = `${organizationId}/attachments/${entityType}/${entityId}/${timestamp}-${fileId}-${sanitizedFileName}`; + + // Upload to S3 + const putCommand = new PutObjectCommand({ + Bucket: this.bucketName, + Key: s3Key, + Body: fileBuffer, + ContentType: uploadDto.fileType, + Metadata: { + originalFileName: uploadDto.fileName, + organizationId, + entityId, + entityType, + ...(userId && { uploadedBy: userId }), + }, + }); + + await this.s3Client.send(putCommand); + + // Create database record + const attachment = await db.attachment.create({ + data: { + name: uploadDto.fileName, + url: s3Key, + type: this.mapFileTypeToAttachmentType(uploadDto.fileType), + entityId, + entityType, + organizationId, + }, + }); + + // Generate signed URL for immediate access + const downloadUrl = await this.generateSignedUrl(s3Key); + + return { + id: attachment.id, + name: attachment.name, + type: attachment.type, + downloadUrl, + createdAt: attachment.createdAt, + size: fileBuffer.length, + }; + } catch (error) { + console.error('Error uploading attachment:', error); + if (error instanceof BadRequestException) { + throw error; + } + throw new InternalServerErrorException('Failed to upload attachment'); + } + } + + /** + * Get all attachments for an entity + */ + async getAttachments( + organizationId: string, + entityId: string, + entityType: AttachmentEntityType, + ): Promise { + const attachments = await db.attachment.findMany({ + where: { + organizationId, + entityId, + entityType, + }, + orderBy: { + createdAt: 'asc', + }, + }); + + // Generate signed URLs for all attachments + const attachmentsWithUrls = await Promise.all( + attachments.map(async (attachment) => { + const downloadUrl = await this.generateSignedUrl(attachment.url); + return { + id: attachment.id, + name: attachment.name, + type: attachment.type, + downloadUrl, + createdAt: attachment.createdAt, + }; + }), + ); + + return attachmentsWithUrls; + } + + /** + * Get download URL for an attachment + */ + async getAttachmentDownloadUrl( + organizationId: string, + attachmentId: string, + ): Promise<{ downloadUrl: string; expiresIn: number }> { + try { + // Get attachment record + const attachment = await db.attachment.findFirst({ + where: { + id: attachmentId, + organizationId, + }, + }); + + if (!attachment) { + throw new BadRequestException('Attachment not found'); + } + + // Generate signed URL + const downloadUrl = await this.generateSignedUrl(attachment.url); + + return { + downloadUrl, + expiresIn: this.SIGNED_URL_EXPIRY, + }; + } catch (error) { + console.error('Error generating download URL:', error); + if (error instanceof BadRequestException) { + throw error; + } + throw new InternalServerErrorException('Failed to generate download URL'); + } + } + + /** + * Delete attachment from S3 and database + */ + async deleteAttachment( + organizationId: string, + attachmentId: string, + ): Promise { + try { + // Get attachment record + const attachment = await db.attachment.findFirst({ + where: { + id: attachmentId, + organizationId, + }, + }); + + if (!attachment) { + throw new BadRequestException('Attachment not found'); + } + + // Delete from S3 + const deleteCommand = new DeleteObjectCommand({ + Bucket: this.bucketName, + Key: attachment.url, + }); + + await this.s3Client.send(deleteCommand); + + // Delete from database + await db.attachment.delete({ + where: { + id: attachmentId, + organizationId, + }, + }); + } catch (error) { + console.error('Error deleting attachment:', error); + if (error instanceof BadRequestException) { + throw error; + } + throw new InternalServerErrorException('Failed to delete attachment'); + } + } + + /** + * Generate signed URL for file download + */ + private async generateSignedUrl(s3Key: string): Promise { + const getCommand = new GetObjectCommand({ + Bucket: this.bucketName, + Key: s3Key, + }); + + return getSignedUrl(this.s3Client, getCommand, { + expiresIn: this.SIGNED_URL_EXPIRY, + }); + } + + /** + * Sanitize filename for S3 storage + */ + private sanitizeFileName(fileName: string): string { + return fileName.replace(/[^a-zA-Z0-9.-]/g, '_'); + } + + /** + * Map MIME type to AttachmentType enum + */ + private mapFileTypeToAttachmentType(fileType: string): AttachmentType { + const type = fileType.split('/')[0]; + switch (type) { + case 'image': + return AttachmentType.image; + case 'video': + return AttachmentType.video; + case 'audio': + return AttachmentType.audio; + case 'application': + case 'text': + return AttachmentType.document; + default: + return AttachmentType.other; + } + } +} diff --git a/apps/api/src/tasks/dto/task-responses.dto.ts b/apps/api/src/tasks/dto/task-responses.dto.ts new file mode 100644 index 000000000..442ded044 --- /dev/null +++ b/apps/api/src/tasks/dto/task-responses.dto.ts @@ -0,0 +1,81 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class AttachmentResponseDto { + @ApiProperty({ + description: 'Unique identifier for the attachment', + example: 'att_abc123def456', + }) + id: string; + + @ApiProperty({ + description: 'Original filename', + example: 'document.pdf', + }) + name: string; + + @ApiProperty({ + description: 'File type/MIME type', + example: 'application/pdf', + }) + type: string; + + @ApiProperty({ + description: 'File size in bytes', + example: 1024000, + }) + size?: number; + + @ApiProperty({ + description: 'Signed URL for downloading the file (temporary)', + example: 'https://bucket.s3.amazonaws.com/path/to/file.pdf?signature=...', + }) + downloadUrl: string; + + @ApiProperty({ + description: 'Upload timestamp', + example: '2024-01-15T10:30:00Z', + }) + createdAt: Date; +} + + + +export class TaskResponseDto { + @ApiProperty({ + description: 'Unique identifier for the task', + example: 'tsk_abc123def456', + }) + id: string; + + @ApiProperty({ + description: 'Task title', + example: 'Implement user authentication', + }) + title: string; + + @ApiProperty({ + description: 'Task description', + example: 'Add OAuth 2.0 authentication to the platform', + required: false, + }) + description?: string; + + @ApiProperty({ + description: 'Task status', + example: 'in_progress', + enum: ['todo', 'in_progress', 'done', 'blocked'], + }) + status: string; + + @ApiProperty({ + description: 'Task creation timestamp', + example: '2024-01-15T10:30:00Z', + }) + createdAt: Date; + + @ApiProperty({ + description: 'Task last update timestamp', + example: '2024-01-15T10:30:00Z', + }) + updatedAt: Date; +} diff --git a/apps/api/src/tasks/dto/upload-attachment.dto.ts b/apps/api/src/tasks/dto/upload-attachment.dto.ts new file mode 100644 index 000000000..33f2aceb0 --- /dev/null +++ b/apps/api/src/tasks/dto/upload-attachment.dto.ts @@ -0,0 +1,66 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; +import { + IsBase64, + IsIn, + IsNotEmpty, + IsOptional, + IsString, + MaxLength, +} from 'class-validator'; + +const ALLOWED_FILE_TYPES = [ + 'image/jpeg', + 'image/png', + 'image/gif', + 'image/webp', + 'application/pdf', + 'text/plain', + 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', +]; + +export class UploadAttachmentDto { + @ApiProperty({ + description: 'Name of the file', + example: 'document.pdf', + maxLength: 255, + }) + @IsString() + @IsNotEmpty() + @MaxLength(255) + @Transform(({ value }) => value?.trim()) + fileName: string; + + @ApiProperty({ + description: 'MIME type of the file', + example: 'application/pdf', + enum: ALLOWED_FILE_TYPES, + }) + @IsString() + @IsIn(ALLOWED_FILE_TYPES, { + message: `File type must be one of: ${ALLOWED_FILE_TYPES.join(', ')}`, + }) + fileType: string; + + @ApiProperty({ + description: 'Base64 encoded file data', + example: + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==', + }) + @IsString() + @IsNotEmpty() + @IsBase64() + fileData: string; + + @ApiProperty({ + description: 'Description of the attachment', + example: 'Meeting notes from Q4 planning session', + required: false, + maxLength: 500, + }) + @IsOptional() + @IsString() + @MaxLength(500) + description?: string; +} diff --git a/apps/api/src/tasks/tasks.controller.ts b/apps/api/src/tasks/tasks.controller.ts index 41c2d5a6e..92a806985 100644 --- a/apps/api/src/tasks/tasks.controller.ts +++ b/apps/api/src/tasks/tasks.controller.ts @@ -1,4 +1,6 @@ +import { AttachmentEntityType } from '@db'; import { + BadRequestException, Body, Controller, Delete, @@ -6,223 +8,251 @@ import { HttpCode, HttpStatus, Param, - Patch, Post, - Query, UseGuards, } from '@nestjs/common'; import { + ApiHeader, ApiOperation, ApiParam, - ApiQuery, ApiResponse, ApiSecurity, ApiTags, } from '@nestjs/swagger'; -import { ZodValidationPipe } from '../common/pipes/zod-validation.pipe'; -import type { - CreateTaskDto, - PaginatedTasksResponseDto, - TaskQueryDto, - TaskResponseDto, - UpdateTaskDto, -} from './schemas/task.schemas'; +import { AttachmentsService } from '../attachments/attachments.service'; +import { UploadAttachmentDto } from '../attachments/upload-attachment.dto'; +import { AuthContext, OrganizationId } from '../auth/auth-context.decorator'; +import { HybridAuthGuard } from '../auth/hybrid-auth.guard'; +import type { AuthContext as AuthContextType } from '../auth/types'; import { - CreateTaskSchema, - TaskQuerySchema, - UpdateTaskSchema, -} from './schemas/task.schemas'; + AttachmentResponseDto, + TaskResponseDto, +} from './dto/task-responses.dto'; import { TasksService } from './tasks.service'; -// Import DTOs for Swagger decorators -import { ApiKeyGuard } from '../auth/api-key.guard'; -import { Organization } from '../auth/organization.decorator'; -import { - PaginatedTasksResponseDto as PaginatedTasksSwagger, - TaskQueryDto as TaskQuerySwagger, - TaskResponseDto as TaskResponseSwagger, -} from './dto/swagger.dto'; @ApiTags('Tasks') @Controller({ path: 'tasks', version: '1' }) -@UseGuards(ApiKeyGuard) +@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 TasksController { - constructor(private readonly tasksService: TasksService) {} + constructor( + private readonly tasksService: TasksService, + private readonly attachmentsService: AttachmentsService, + ) {} - @Post() - @ApiOperation({ - summary: 'Create a new task', - description: 'Creates a new task for the authenticated organization', - }) - @ApiResponse({ - status: 201, - description: 'Task created successfully', - type: TaskResponseSwagger, - }) - @ApiResponse({ - status: 400, - description: 'Invalid task data provided', - }) - @ApiResponse({ - status: 401, - description: 'Unauthorized - Invalid or missing API key', - }) - async create( - @Body(new ZodValidationPipe(CreateTaskSchema)) createTaskDto: CreateTaskDto, - @Organization() organizationId: string, - ): Promise { - return this.tasksService.create(createTaskDto, organizationId); - } + // ==================== TASKS ==================== @Get() @ApiOperation({ - summary: 'List tasks with pagination and filtering', - description: - 'Retrieves a paginated list of tasks for the authenticated organization with optional filtering', + summary: 'Get all tasks', + description: 'Retrieve all tasks for the authenticated organization', }) - @ApiQuery({ type: TaskQuerySwagger }) @ApiResponse({ status: 200, description: 'Tasks retrieved successfully', - type: PaginatedTasksSwagger, + type: [TaskResponseDto], }) @ApiResponse({ status: 401, - description: 'Unauthorized - Invalid or missing API key', + description: 'Unauthorized - Invalid authentication', }) - async findAll( - @Query(new ZodValidationPipe(TaskQuerySchema)) query: TaskQueryDto, - @Organization() organizationId: string, - ): Promise { - return this.tasksService.findAll(query, organizationId); + async getTasks( + @OrganizationId() organizationId: string, + ): Promise { + return await this.tasksService.getTasks(organizationId); } - @Get(':id') + @Get(':taskId') @ApiOperation({ - summary: 'Get a specific task', - description: - 'Retrieves a specific task by ID for the authenticated organization', + summary: 'Get task by ID', + description: 'Retrieve a specific task by its ID', }) @ApiParam({ - name: 'id', - description: 'Task ID', + name: 'taskId', + description: 'Unique task identifier', example: 'tsk_abc123def456', }) @ApiResponse({ status: 200, description: 'Task retrieved successfully', - type: TaskResponseSwagger, + type: TaskResponseDto, }) @ApiResponse({ status: 404, description: 'Task not found', }) - @ApiResponse({ - status: 401, - description: 'Unauthorized - Invalid or missing API key', - }) - async findOne( - @Param('id') id: string, - @Organization() organizationId: string, + async getTask( + @OrganizationId() organizationId: string, + @Param('taskId') taskId: string, ): Promise { - return this.tasksService.findOne(id, organizationId); + return await this.tasksService.getTask(organizationId, taskId); } - @Patch(':id') + // ==================== TASK ATTACHMENTS ==================== + + @Get(':taskId/attachments') @ApiOperation({ - summary: 'Update a task', - description: - 'Updates a specific task by ID for the authenticated organization', + summary: 'Get task attachments', + description: 'Retrieve all attachments for a specific task', }) @ApiParam({ - name: 'id', - description: 'Task ID', + name: 'taskId', + description: 'Unique task identifier', example: 'tsk_abc123def456', }) @ApiResponse({ status: 200, - description: 'Task updated successfully', - type: TaskResponseSwagger, + description: 'Attachments retrieved successfully', + type: [AttachmentResponseDto], }) - @ApiResponse({ - status: 404, - description: 'Task not found', + async getTaskAttachments( + @OrganizationId() organizationId: string, + @Param('taskId') taskId: string, + ): Promise { + // Verify task access + await this.tasksService.verifyTaskAccess(organizationId, taskId); + + return await this.attachmentsService.getAttachments( + organizationId, + taskId, + AttachmentEntityType.task, + ); + } + + @Post(':taskId/attachments') + @ApiOperation({ + summary: 'Upload attachment to task', + description: 'Upload a file attachment to a specific task', + }) + @ApiParam({ + name: 'taskId', + description: 'Unique task identifier', + example: 'tsk_abc123def456', }) @ApiResponse({ - status: 400, - description: 'Invalid task data provided', + status: 201, + description: 'Attachment uploaded successfully', + type: AttachmentResponseDto, }) @ApiResponse({ - status: 401, - description: 'Unauthorized - Invalid or missing API key', + status: 400, + description: 'Invalid file data or file too large', }) - async update( - @Param('id') id: string, - @Body(new ZodValidationPipe(UpdateTaskSchema)) updateTaskDto: UpdateTaskDto, - @Organization() organizationId: string, - ): Promise { - return this.tasksService.update(id, updateTaskDto, organizationId); + async uploadTaskAttachment( + @AuthContext() authContext: AuthContextType, + @Param('taskId') taskId: string, + @Body() uploadDto: UploadAttachmentDto, + ): Promise { + // Verify task access + await this.tasksService.verifyTaskAccess( + authContext.organizationId, + taskId, + ); + + // Ensure userId is present for attachment upload + if (!authContext.userId) { + throw new BadRequestException('User ID is required for file upload'); + } + + return await this.attachmentsService.uploadAttachment( + authContext.organizationId, + taskId, + AttachmentEntityType.task, + uploadDto, + authContext.userId, + ); } - @Delete(':id') - @HttpCode(HttpStatus.NO_CONTENT) + @Get(':taskId/attachments/:attachmentId/download') @ApiOperation({ - summary: 'Delete a task', - description: - 'Deletes a specific task by ID for the authenticated organization', + summary: 'Get attachment download URL', + description: 'Generate a signed URL for downloading a task attachment', }) @ApiParam({ - name: 'id', - description: 'Task ID', + name: 'taskId', + description: 'Unique task identifier', example: 'tsk_abc123def456', }) - @ApiResponse({ - status: 204, - description: 'Task deleted successfully', - }) - @ApiResponse({ - status: 404, - description: 'Task not found', + @ApiParam({ + name: 'attachmentId', + description: 'Unique attachment identifier', + example: 'att_abc123def456', }) @ApiResponse({ - status: 401, - description: 'Unauthorized - Invalid or missing API key', + status: 200, + description: 'Download URL generated successfully', + schema: { + type: 'object', + properties: { + downloadUrl: { + type: 'string', + description: 'Signed URL for downloading the file', + example: + 'https://bucket.s3.amazonaws.com/path/to/file.pdf?signature=...', + }, + expiresIn: { + type: 'number', + description: 'URL expiration time in seconds', + example: 900, + }, + }, + }, }) - async remove( - @Param('id') id: string, - @Organization() organizationId: string, - ): Promise { - return this.tasksService.remove(id, organizationId); + async getTaskAttachmentDownloadUrl( + @OrganizationId() organizationId: string, + @Param('taskId') taskId: string, + @Param('attachmentId') attachmentId: string, + ): Promise<{ downloadUrl: string; expiresIn: number }> { + // Verify task access + await this.tasksService.verifyTaskAccess(organizationId, taskId); + + return await this.attachmentsService.getAttachmentDownloadUrl( + organizationId, + attachmentId, + ); } - @Patch(':id/complete') + @Delete(':taskId/attachments/:attachmentId') + @HttpCode(HttpStatus.NO_CONTENT) @ApiOperation({ - summary: 'Mark a task as complete', - description: - 'Marks a specific task as done and sets the completion timestamp', + summary: 'Delete task attachment', + description: 'Delete a specific attachment from a task', }) @ApiParam({ - name: 'id', - description: 'Task ID', + name: 'taskId', + description: 'Unique task identifier', example: 'tsk_abc123def456', }) - @ApiResponse({ - status: 200, - description: 'Task marked as complete successfully', - type: TaskResponseSwagger, + @ApiParam({ + name: 'attachmentId', + description: 'Unique attachment identifier', + example: 'att_abc123def456', }) @ApiResponse({ - status: 404, - description: 'Task not found', + status: 204, + description: 'Attachment deleted successfully', }) @ApiResponse({ - status: 401, - description: 'Unauthorized - Invalid or missing API key', + status: 404, + description: 'Task or attachment not found', }) - async markComplete( - @Param('id') id: string, - @Organization() organizationId: string, - ): Promise { - return this.tasksService.markComplete(id, organizationId); + async deleteTaskAttachment( + @OrganizationId() organizationId: string, + @Param('taskId') taskId: string, + @Param('attachmentId') attachmentId: string, + ): Promise { + // Verify task access + await this.tasksService.verifyTaskAccess(organizationId, taskId); + + await this.attachmentsService.deleteAttachment( + organizationId, + attachmentId, + ); } } diff --git a/apps/api/src/tasks/tasks.module.ts b/apps/api/src/tasks/tasks.module.ts index 879bb1e2b..34a6604a8 100644 --- a/apps/api/src/tasks/tasks.module.ts +++ b/apps/api/src/tasks/tasks.module.ts @@ -1,12 +1,13 @@ import { Module } from '@nestjs/common'; -import { TasksService } from './tasks.service'; -import { TasksController } from './tasks.controller'; +import { AttachmentsModule } from '../attachments/attachments.module'; import { AuthModule } from '../auth/auth.module'; +import { TasksController } from './tasks.controller'; +import { TasksService } from './tasks.service'; @Module({ - imports: [AuthModule], + imports: [AuthModule, AttachmentsModule], controllers: [TasksController], providers: [TasksService], exports: [TasksService], }) -export class TasksModule {} \ No newline at end of file +export class TasksModule {} diff --git a/apps/api/src/tasks/tasks.service.ts b/apps/api/src/tasks/tasks.service.ts index 8e907d9b5..e64d76264 100644 --- a/apps/api/src/tasks/tasks.service.ts +++ b/apps/api/src/tasks/tasks.service.ts @@ -1,212 +1,93 @@ -import { Injectable, Logger, NotFoundException } from '@nestjs/common'; -import { Prisma } from '@prisma/client'; -import { db } from '@trycompai/db'; import { - CreateTaskDto, - PaginatedTasksResponseDto, - TaskQueryDto, - TaskResponseDto, - UpdateTaskDto, -} from './schemas/task.schemas'; + BadRequestException, + Injectable, + InternalServerErrorException, +} from '@nestjs/common'; +import { db } from '@trycompai/db'; +import { TaskResponseDto } from './dto/task-responses.dto'; @Injectable() export class TasksService { - private readonly logger = new Logger(TasksService.name); + constructor() {} - async create( - createTaskDto: CreateTaskDto, - organizationId: string, - ): Promise { + /** + * Get all tasks for an organization + */ + async getTasks(organizationId: string): Promise { try { - const task = await db.task.create({ - data: { - ...createTaskDto, + const tasks = await db.task.findMany({ + where: { organizationId, }, + orderBy: [{ status: 'asc' }, { order: 'asc' }, { createdAt: 'asc' }], }); - this.logger.log( - `Created task ${task.id} for organization ${organizationId}`, - ); - return task; + return tasks.map((task) => ({ + id: task.id, + title: task.title, + description: task.description, + status: task.status, + createdAt: task.createdAt, + updatedAt: task.updatedAt, + })); } catch (error) { - this.logger.error( - `Failed to create task for organization ${organizationId}:`, - error, - ); - throw error; + console.error('Error fetching tasks:', error); + throw new InternalServerErrorException('Failed to fetch tasks'); } } - async findAll( - query: TaskQueryDto, + /** + * Get a single task by ID + */ + async getTask( organizationId: string, - ): Promise { - const page = query.page ?? 1; - const limit = query.limit ?? 20; - const skip = (page - 1) * limit; - - // Build where clause with organization isolation - const where: Prisma.TaskWhereInput = { - organizationId, - }; - - // Add filters - Zod ensures proper types - if (query.status) { - where.status = query.status; - } - if (query.frequency) { - where.frequency = query.frequency; - } - if (query.department) { - where.department = query.department; - } - if (query.assigneeId !== undefined) { - where.assigneeId = query.assigneeId; - } - if (query.search !== undefined) { - where.OR = [ - { title: { contains: query.search, mode: 'insensitive' } }, - { description: { contains: query.search, mode: 'insensitive' } }, - ]; - } - - try { - const [tasks, total] = await Promise.all([ - db.task.findMany({ - where, - skip, - take: limit, - orderBy: [{ order: 'asc' }, { createdAt: 'desc' }], - }), - db.task.count({ where }), - ]); - - const totalPages = Math.ceil(total / limit); - - this.logger.log( - `Retrieved ${tasks.length} tasks for organization ${organizationId} (page ${page})`, - ); - - return { - tasks, - meta: { - page, - limit, - total, - totalPages, - hasNextPage: page < totalPages, - hasPrevPage: page > 1, - }, - }; - } catch (error) { - this.logger.error( - `Failed to retrieve tasks for organization ${organizationId}:`, - error, - ); - throw error; - } - } - - async findOne(id: string, organizationId: string): Promise { + taskId: string, + ): Promise { try { const task = await db.task.findFirst({ where: { - id, - organizationId, // Enforce organization isolation + id: taskId, + organizationId, }, }); if (!task) { - throw new NotFoundException(`Task with ID ${id} not found`); + throw new BadRequestException('Task not found or access denied'); } - this.logger.log( - `Retrieved task ${id} for organization ${organizationId}`, - ); - return task; + return { + id: task.id, + title: task.title, + description: task.description, + status: task.status, + createdAt: task.createdAt, + updatedAt: task.updatedAt, + }; } catch (error) { - if (error instanceof NotFoundException) { + console.error('Error fetching task:', error); + if (error instanceof BadRequestException) { throw error; } - this.logger.error( - `Failed to retrieve task ${id} for organization ${organizationId}:`, - error, - ); - throw error; - } - } - - async update( - id: string, - updateTaskDto: UpdateTaskDto, - organizationId: string, - ): Promise { - // First verify the task exists and belongs to the organization - await this.findOne(id, organizationId); - - try { - const task = await db.task.update({ - where: { id }, - data: updateTaskDto, - }); - - this.logger.log(`Updated task ${id} for organization ${organizationId}`); - return task; - } catch (error) { - this.logger.error( - `Failed to update task ${id} for organization ${organizationId}:`, - error, - ); - throw error; - } - } - - async remove(id: string, organizationId: string): Promise { - // First verify the task exists and belongs to the organization - await this.findOne(id, organizationId); - - try { - await db.task.delete({ - where: { id }, - }); - - this.logger.log(`Deleted task ${id} for organization ${organizationId}`); - } catch (error) { - this.logger.error( - `Failed to delete task ${id} for organization ${organizationId}:`, - error, - ); - throw error; + throw new InternalServerErrorException('Failed to fetch task'); } } - async markComplete( - id: string, + /** + * Verify that a task exists and user has access + */ + async verifyTaskAccess( organizationId: string, - ): Promise { - // First verify the task exists and belongs to the organization - await this.findOne(id, organizationId); - - try { - const task = await db.task.update({ - where: { id }, - data: { - status: 'done', - lastCompletedAt: new Date(), - }, - }); - - this.logger.log( - `Marked task ${id} as complete for organization ${organizationId}`, - ); - return task; - } catch (error) { - this.logger.error( - `Failed to mark task ${id} as complete for organization ${organizationId}:`, - error, - ); - throw error; + taskId: string, + ): Promise { + const task = await db.task.findFirst({ + where: { + id: taskId, + organizationId, + }, + }); + + if (!task) { + throw new BadRequestException('Task not found or access denied'); } } } diff --git a/apps/api/test/app.e2e-spec.ts b/apps/api/test/app.e2e-spec.ts index 4df6580c7..cdaa3d539 100644 --- a/apps/api/test/app.e2e-spec.ts +++ b/apps/api/test/app.e2e-spec.ts @@ -1,6 +1,6 @@ -import { Test, TestingModule } from '@nestjs/testing'; import { INestApplication } from '@nestjs/common'; -import * as request from 'supertest'; +import { Test, TestingModule } from '@nestjs/testing'; +import request from 'supertest'; import { App } from 'supertest/types'; import { AppModule } from './../src/app.module'; diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json index aba29b0e7..7bc839792 100644 --- a/apps/api/tsconfig.json +++ b/apps/api/tsconfig.json @@ -20,6 +20,10 @@ "forceConsistentCasingInFileNames": true, "noImplicitAny": false, "strictBindCallApply": false, - "noFallthroughCasesInSwitch": false + "noFallthroughCasesInSwitch": false, + "paths": { + "@/*": ["./src/*"], + "@db": ["./prisma/index"] + } } } diff --git a/apps/app/buildspec.yml b/apps/app/buildspec.yml deleted file mode 100644 index 512c106fe..000000000 --- a/apps/app/buildspec.yml +++ /dev/null @@ -1,252 +0,0 @@ -version: 0.2 - -phases: - pre_build: - commands: - - echo "Logging in to Amazon ECR..." - - aws ecr get-login-password --region $AWS_DEFAULT_REGION | docker login --username AWS --password-stdin $AWS_ACCOUNT_ID.dkr.ecr.$AWS_DEFAULT_REGION.amazonaws.com - - REPOSITORY_URI=$ECR_REPOSITORY_URI - - COMMIT_HASH=$(echo $CODEBUILD_RESOLVED_SOURCE_VERSION | cut -c 1-7) - - IMAGE_TAG=${COMMIT_HASH:=latest} - - echo "Installing dependencies..." - - curl -fsSL https://bun.sh/install | bash - - build: - commands: - # Environment setup - - export PATH="/root/.bun/bin:$PATH" - - export PGSSLMODE=require - - export NODE_ENV=production - - export NEXT_TELEMETRY_DISABLED=1 - - export UV_THREADPOOL_SIZE=36 - - export NODE_OPTIONS="--max-old-space-size=65536" - - # Navigate to app directory - - cd apps/$APP_NAME - - # Install dependencies - - echo "Installing dependencies..." - - SKIP_ENV_VALIDATION=true bun install --frozen-lockfile --concurrent 36 || SKIP_ENV_VALIDATION=true bun install --concurrent 36 - - cd ../../ - - # Generate Prisma client - - echo "Generating Prisma client..." - - cd packages/db && bun x prisma generate && cd ../../ - - # Validate environment variables - - echo "Validating environment variables..." - - '[ -n "$NEXT_PUBLIC_PORTAL_URL" ] || { echo "❌ NEXT_PUBLIC_PORTAL_URL is not set"; exit 1; }' - - '[ -n "$STATIC_ASSETS_BUCKET" ] || { echo "❌ STATIC_ASSETS_BUCKET is not set"; exit 1; }' - - # Type check - - echo "Type checking..." - - cd apps/$APP_NAME && bun run typecheck && cd ../../ - - # Clear Next.js cache to prevent stale server actions - - echo "Clearing Next.js cache to prevent stale builds..." - - cd apps/$APP_NAME - - rm -rf .next/cache/ - - # Build Next.js app - - echo "Building Next.js application..." - - NODE_TLS_REJECT_UNAUTHORIZED=0 bun run build - - # Upload Next.js chunks and CSS to S3 for CDN performance - - echo "Uploading Next.js static assets to S3..." - - | - if [ -d ".next/static" ]; then - echo "📦 Uploading .next/static/ files to CDN..." - aws s3 sync .next/static/ s3://$STATIC_ASSETS_BUCKET/app/_next/static/ \ - --cache-control "public, max-age=31536000, immutable" \ - --exclude "*.map" - echo "✅ Uploaded Next.js static assets to S3" - - # Verify upload completed successfully - echo "🔍 Verifying S3 upload completed..." - CHUNK_COUNT=$(find .next/static -name "*.js" | wc -l) - S3_COUNT=$(aws s3 ls s3://$STATIC_ASSETS_BUCKET/app/_next/static/ --recursive | grep "\.js$" | wc -l) - echo "Local chunks: $CHUNK_COUNT, S3 chunks: $S3_COUNT" - - if [ "$S3_COUNT" -ge "$CHUNK_COUNT" ]; then - echo "✅ S3 upload verification successful" - else - echo "⚠️ S3 upload may be incomplete, but continuing..." - fi - - # Invalidate CloudFront cache for new chunks - echo "🔄 Invalidating CloudFront cache for new deployment..." - aws cloudfront create-invalidation \ - --distribution-id $CLOUDFRONT_DISTRIBUTION_ID \ - --paths "/app/_next/static/*" || echo "⚠️ CloudFront invalidation failed (non-critical)" - else - echo "⚠️ No .next/static directory found" - fi - - # Upload public assets to S3 for CDN serving - - | - if [ -d "public" ]; then - echo "📦 Uploading public/ files to S3..." - aws s3 sync public/ s3://$STATIC_ASSETS_BUCKET/app/ \ - --cache-control "public, max-age=86400" \ - --exclude "*.map" \ - --exclude "_next/*" - echo "✅ Uploaded public assets to S3" - else - echo "⚠️ No public directory found" - fi - - # Prepare standalone build for container - - echo "Preparing standalone build..." - - echo "DEBUG - Checking what Next.js built..." - - ls -la .next/ - - ls -la .next/standalone/ || echo "No standalone directory" - - echo "DEBUG - Checking standalone structure..." - - find .next/standalone -name "server.js" -ls || echo "No server.js found" - - ls -la .next/standalone/apps/$APP_NAME/ || echo "No app directory" - - echo "DEBUG - Checking app's own .next build structure..." - - ls -la .next/server/ || echo "No .next/server directory" - - ls -la .next/standalone/ || echo "No .next/standalone directory" - - echo "DEBUG - Checking for server.js in various locations..." - - test -f .next/standalone/server.js && echo "✅ Standalone server.js exists" || echo "❌ No standalone server.js" - - find .next -name "server.js" | head -5 || echo "No server.js found anywhere" - - # Create container build directory - - mkdir -p container-build - - # Use the standalone build properly: copy from .next/standalone + app's own build - - echo "DEBUG - Building container from standalone + app build..." - - # Copy the COMPLETE standalone build first (includes server actions) - - echo "Copying complete standalone build..." - - cp -r .next/standalone/* container-build/ || echo "Standalone copy failed" - - # Then overlay the app's own .next directory (preserves server actions mapping) - - echo "Overlaying app's own .next build..." - - cp -r .next container-build/ || echo "App .next overlay failed" - - # CRITICAL: Verify container has all necessary files - - echo "🔍 Verifying container build completeness..." - - ls -la container-build/.next/static/ || echo "No static directory in container" - - ls -la container-build/.next/server/ || echo "No server directory in container" - - CONTAINER_CHUNKS=$(find container-build/.next/static -name "*.js" 2>/dev/null | wc -l) - - CONTAINER_SERVER_FILES=$(find container-build/.next/server -name "*.js" 2>/dev/null | wc -l) - - 'echo "Container chunks: $CONTAINER_CHUNKS, Server files: $CONTAINER_SERVER_FILES"' - - - 'if [ "$CONTAINER_CHUNKS" -eq 0 ]; then echo "❌ ERROR: No static chunks found in container build!" && exit 1; fi' - - 'if [ "$CONTAINER_SERVER_FILES" -eq 0 ]; then echo "❌ ERROR: No server files found in container build!" && exit 1; fi' - - echo "✅ Container build verification passed" - - # Copy or create server.js for standalone - - | - if [ -f ".next/standalone/apps/$APP_NAME/server.js" ]; then - echo "Using app-specific standalone server.js..." - cp .next/standalone/apps/$APP_NAME/server.js container-build/ - elif [ -f ".next/standalone/server.js" ]; then - echo "Using global standalone server.js..." - cp .next/standalone/server.js container-build/ - else - echo "Creating minimal standalone server.js..." - cat > container-build/server.js << 'EOF' - const { createServer } = require('http') - const next = require('next') - - const dev = false - const hostname = process.env.HOSTNAME || '0.0.0.0' - const port = process.env.PORT || 3000 - - // Use the current directory as the Next.js app - const app = next({ dev, hostname, port, dir: __dirname }) - const handle = app.getRequestHandler() - - app.prepare().then(() => { - createServer(async (req, res) => { - await handle(req, res) - }).listen(port, hostname, () => { - console.log(`> Ready on http://${hostname}:${port}`) - }) - }) - EOF - fi - - # Verify the app-specific standalone build - - echo "DEBUG - Verifying app-specific standalone build..." - - ls -la container-build/ - - test -f container-build/server.js && echo "✅ App server.js exists" || echo "❌ App server.js missing" - - test -d container-build/.next && echo "✅ .next directory exists" || echo "❌ .next directory missing" - - test -d container-build/node_modules && echo "✅ node_modules exists" || echo "❌ node_modules missing" - - # Add Dockerfile to the standalone build - - cp Dockerfile container-build/ || echo "No Dockerfile" - - # Ensure Prisma client is available (override standalone if needed) - - echo "Ensuring Prisma client is available..." - - mkdir -p container-build/node_modules/.prisma container-build/node_modules/@prisma - - | - if [ -d "../../node_modules/.prisma/client" ]; then - echo "Copying Prisma client from monorepo root..." - cp -r ../../node_modules/.prisma/client container-build/node_modules/.prisma/ - elif [ -d "node_modules/.prisma/client" ]; then - echo "Copying Prisma client from app directory..." - cp -r node_modules/.prisma/client container-build/node_modules/.prisma/ - else - echo "Warning: No Prisma client found" - fi - - | - if [ -d "../../node_modules/@prisma/client" ]; then - echo "Copying @prisma/client from monorepo root..." - cp -r "../../node_modules/@prisma/client" "container-build/node_modules/@prisma/" - elif [ -d "node_modules/@prisma/client" ]; then - echo "Copying @prisma/client from app directory..." - cp -r "node_modules/@prisma/client" "container-build/node_modules/@prisma/" - else - echo "Warning: No @prisma/client found" - fi - - # Debug: Verify container build contents - - echo "DEBUG - Verifying app-specific container build..." - - ls -la container-build/ - - ls -la container-build/.next/ || echo "❌ .next directory not found" - - ls -la container-build/node_modules/next/ || echo "❌ Next.js module not found" - - ls -la container-build/node_modules/.prisma/ || echo "❌ Prisma client not found" - - test -f container-build/server.js && echo "✅ App-specific server.js found" || echo "❌ App-specific server.js not found" - - head -10 container-build/server.js || echo "❌ Cannot read server.js" - - # Ensure all S3 operations are complete before building container - - echo "⏳ Waiting for S3 operations to fully complete..." - - sleep 5 - - # Final verification before Docker build - - echo "🔍 Final pre-build verification..." - - aws s3 ls s3://$STATIC_ASSETS_BUCKET/app/_next/static/ --recursive | tail -5 || echo "S3 listing failed" - - # Build Docker image (static assets now served from S3) - - echo "🐳 Building Docker image..." - - docker build --build-arg BUILDKIT_INLINE_CACHE=1 -f container-build/Dockerfile -t $ECR_REPOSITORY_URI:$IMAGE_TAG container-build/ - - docker tag $ECR_REPOSITORY_URI:$IMAGE_TAG $ECR_REPOSITORY_URI:latest - - # Test container starts correctly (catch issues early) - - echo "🧪 Testing container startup..." - - timeout 30s docker run --rm -d -p 3001:3000 $ECR_REPOSITORY_URI:$IMAGE_TAG && echo "✅ Container startup test passed" || echo "⚠️ Container startup test failed (non-critical)" - - post_build: - commands: - - echo "Pushing images to ECR..." - - docker push $ECR_REPOSITORY_URI:$IMAGE_TAG - - docker push $ECR_REPOSITORY_URI:latest - - echo "Updating ECS service..." - - aws ecs update-service --cluster $ECS_CLUSTER_NAME --service $ECS_SERVICE_NAME --force-new-deployment - - 'printf "[{\"name\":\"%s-container\",\"imageUri\":\"%s\"}]" $APP_NAME $ECR_REPOSITORY_URI:$IMAGE_TAG > imagedefinitions.json' - -cache: - paths: - - 'node_modules/**/*' - - 'packages/db/node_modules/**/*' - - '/root/.bun/install/cache/**/*' - # Exclude .next/cache to prevent stale server action mappings - # - '.next/cache/**/*' - - 'bun.lock' - -artifacts: - files: - - imagedefinitions.json - name: ${APP_NAME}-build diff --git a/apps/app/next.config.ts b/apps/app/next.config.ts index b989982b1..70247ad33 100644 --- a/apps/app/next.config.ts +++ b/apps/app/next.config.ts @@ -2,10 +2,13 @@ import type { NextConfig } from 'next'; import './src/env.mjs'; const config: NextConfig = { - poweredByHeader: false, + // Use S3 bucket for static assets with app-specific path + assetPrefix: + process.env.NODE_ENV === 'production' && process.env.STATIC_ASSETS_URL + ? `${process.env.STATIC_ASSETS_URL}/app` + : '', reactStrictMode: true, transpilePackages: ['@trycompai/db'], - images: { remotePatterns: [ { diff --git a/apps/app/package.json b/apps/app/package.json index 27f2b9b25..c87ecf70d 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -46,7 +46,7 @@ "@tiptap/extension-table-row": "^2.22.3", "@trigger.dev/react-hooks": "3.3.17", "@trigger.dev/sdk": "3.3.17", - "@trycompai/db": "^1.3.1", + "@trycompai/db": "^1.3.2", "@types/canvas-confetti": "^1.9.0", "@types/three": "^0.177.0", "@uploadthing/react": "^7.3.0", @@ -84,6 +84,7 @@ "remark-parse": "^11.0.0", "resend": "^4.4.1", "sonner": "^2.0.5", + "swr": "^2.3.4", "three": "^0.177.0", "ts-pattern": "^5.7.0", "use-debounce": "^10.0.4", diff --git a/apps/app/src/actions/comments/__tests__/createComment.test.ts b/apps/app/src/actions/comments/__tests__/createComment.test.ts deleted file mode 100644 index dda7a1e1a..000000000 --- a/apps/app/src/actions/comments/__tests__/createComment.test.ts +++ /dev/null @@ -1,349 +0,0 @@ -import { CommentEntityType } from '@db'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -// Import the mock setup utilities first -import { mockAuth, setupAuthMocks } from '@/test-utils/mocks/auth'; -import { mockDb } from '@/test-utils/mocks/db'; - -// Mock the modules with the imported mocks -vi.mock('@/utils/auth', () => ({ - auth: mockAuth, -})); - -vi.mock('@db', () => ({ - db: mockDb, -})); - -vi.mock('@/app/s3', () => ({ - BUCKET_NAME: 'test-bucket', - s3Client: { - send: vi.fn(), - }, -})); - -// Import the function to test after mocking -import { createComment } from '../createComment'; - -describe('createComment', () => { - beforeEach(() => { - vi.clearAllMocks(); - // Reset transaction mock to pass through - mockDb.$transaction.mockImplementation((fn) => fn(mockDb)); - }); - - describe('Authorization', () => { - it('should fail when no active organization is found', async () => { - setupAuthMocks({ session: { activeOrganizationId: null } as any }); - - const result = await createComment({ - content: 'Test comment', - entityId: 'task_123', - entityType: CommentEntityType.task, - }); - - expect(result).toEqual({ - success: false, - error: 'Not authorized - no active organization found.', - data: null, - }); - }); - - it('should fail when user is not a member of the organization', async () => { - setupAuthMocks(); - mockDb.member.findFirst.mockResolvedValue(null); - - const result = await createComment({ - content: 'Test comment', - entityId: 'task_123', - entityType: CommentEntityType.task, - }); - - expect(result).toEqual({ - success: false, - error: 'Not authorized - member not found in organization.', - data: null, - }); - }); - }); - - describe('Validation', () => { - it('should fail when both content and attachments are empty', async () => { - setupAuthMocks(); - mockDb.member.findFirst.mockResolvedValue({ id: 'member_123' }); - - // The actual implementation creates an empty comment, doesn't validate - // So let's update the test to match the actual behavior - const result = await createComment({ - content: '', - entityId: 'task_123', - entityType: CommentEntityType.task, - }); - - // Based on the schema validation, this should fail - expect(result.success).toBe(false); - // The error comes from Zod validation - expect(result.error).toBeDefined(); - }); - - it('should fail when entityId is missing', async () => { - setupAuthMocks(); - mockDb.member.findFirst.mockResolvedValue({ id: 'member_123' }); - - const result = await createComment({ - content: 'Test comment', - entityId: '', - entityType: CommentEntityType.task, - }); - - expect(result).toEqual({ - success: false, - error: 'Internal error: Entity ID missing.', - data: null, - }); - }); - - it('should succeed with only content (no attachments)', async () => { - const { session } = setupAuthMocks(); - const mockMember = { id: 'member_123' }; - const mockComment = { - id: 'comment_123', - content: 'Test comment', - entityId: 'task_123', - entityType: CommentEntityType.task, - authorId: mockMember.id, - organizationId: session!.activeOrganizationId, - createdAt: new Date(), - }; - - mockDb.member.findFirst.mockResolvedValue(mockMember); - mockDb.comment.create.mockResolvedValue(mockComment); - - const result = await createComment({ - content: 'Test comment', - entityId: 'task_123', - entityType: CommentEntityType.task, - }); - - expect(result).toEqual({ - success: true, - data: mockComment, - error: null, - }); - - expect(mockDb.comment.create).toHaveBeenCalledWith({ - data: { - content: 'Test comment', - entityId: 'task_123', - entityType: CommentEntityType.task, - authorId: mockMember.id, - organizationId: session!.activeOrganizationId, - }, - }); - }); - - it('should succeed with only attachments (no content)', async () => { - const { session } = setupAuthMocks(); - const mockMember = { id: 'member_123' }; - const mockComment = { - id: 'comment_123', - content: '', - entityId: 'task_123', - entityType: CommentEntityType.task, - authorId: mockMember.id, - organizationId: session!.activeOrganizationId, - createdAt: new Date(), - }; - - mockDb.member.findFirst.mockResolvedValue(mockMember); - mockDb.comment.create.mockResolvedValue(mockComment); - - const { s3Client } = await import('@/app/s3'); - - const result = await createComment({ - content: '', - entityId: 'task_123', - entityType: CommentEntityType.task, - attachments: [ - { - id: 'temp_123', - name: 'test.pdf', - fileType: 'application/pdf', - fileData: 'base64data', - }, - ], - }); - - expect(result.success).toBe(true); - expect(result.data).toEqual(mockComment); - expect(s3Client.send).toHaveBeenCalled(); - expect(mockDb.attachment.create).toHaveBeenCalled(); - }); - }); - - describe('Attachment Handling', () => { - it('should upload files to S3 and create attachment records', async () => { - const { session } = setupAuthMocks(); - const mockMember = { id: 'member_123' }; - const mockComment = { - id: 'comment_123', - content: 'Test with attachments', - entityId: 'task_123', - entityType: CommentEntityType.task, - authorId: mockMember.id, - organizationId: session!.activeOrganizationId, - createdAt: new Date(), - }; - - mockDb.member.findFirst.mockResolvedValue(mockMember); - mockDb.comment.create.mockResolvedValue(mockComment); - - const { s3Client } = await import('@/app/s3'); - - const attachments = [ - { - id: 'temp_1', - name: 'document.pdf', - fileType: 'application/pdf', - fileData: 'base64pdf', - }, - { - id: 'temp_2', - name: 'image.png', - fileType: 'image/png', - fileData: 'base64image', - }, - ]; - - const result = await createComment({ - content: 'Test with attachments', - entityId: 'task_123', - entityType: CommentEntityType.task, - attachments, - }); - - expect(result.success).toBe(true); - - // Verify S3 uploads - expect(s3Client.send).toHaveBeenCalledTimes(2); - - // Verify attachment records - expect(mockDb.attachment.create).toHaveBeenCalledTimes(2); - expect(mockDb.attachment.create).toHaveBeenCalledWith({ - data: expect.objectContaining({ - name: 'document.pdf', - type: 'document', - entityId: mockComment.id, - entityType: 'comment', - organizationId: session!.activeOrganizationId, - }), - }); - expect(mockDb.attachment.create).toHaveBeenCalledWith({ - data: expect.objectContaining({ - name: 'image.png', - type: 'image', - entityId: mockComment.id, - entityType: 'comment', - organizationId: session!.activeOrganizationId, - }), - }); - }); - - it('should handle S3 upload failures gracefully', async () => { - const { session } = setupAuthMocks(); - const mockMember = { id: 'member_123' }; - - mockDb.member.findFirst.mockResolvedValue(mockMember); - - const { s3Client } = await import('@/app/s3'); - // Make S3 upload fail - (s3Client.send as any).mockRejectedValue(new Error('S3 upload failed')); - - const result = await createComment({ - content: 'Test with failing attachment', - entityId: 'task_123', - entityType: CommentEntityType.task, - attachments: [ - { - id: 'temp_fail', - name: 'fail.pdf', - fileType: 'application/pdf', - fileData: 'base64fail', - }, - ], - }); - - expect(result.success).toBe(false); - expect(result.error).toBe('Failed to save comment and link attachments.'); - // Verify transaction was rolled back (comment not created) - expect(mockDb.comment.create).toHaveBeenCalled(); - }); - }); - - describe('Entity Type Support', () => { - const entityTypes = [ - CommentEntityType.task, - CommentEntityType.policy, - CommentEntityType.vendor, - CommentEntityType.risk, - ]; - - entityTypes.forEach((entityType) => { - it(`should create comments for ${entityType} entities`, async () => { - const { session } = setupAuthMocks(); - const mockMember = { id: 'member_123' }; - const mockComment = { - id: 'comment_123', - content: `Comment on ${entityType}`, - entityId: `${entityType}_123`, - entityType, - authorId: mockMember.id, - organizationId: session!.activeOrganizationId, - createdAt: new Date(), - }; - - mockDb.member.findFirst.mockResolvedValue(mockMember); - mockDb.comment.create.mockResolvedValue(mockComment); - - const result = await createComment({ - content: `Comment on ${entityType}`, - entityId: `${entityType}_123`, - entityType, - }); - - expect(result.success).toBe(true); - expect(result.data?.entityType).toBe(entityType); - }); - }); - }); - - describe('Transaction Behavior', () => { - it('should rollback all operations if any step fails', async () => { - const { session } = setupAuthMocks(); - const mockMember = { id: 'member_123' }; - - mockDb.member.findFirst.mockResolvedValue(mockMember); - mockDb.comment.create.mockResolvedValue({ id: 'comment_123' }); - - // Make attachment creation fail - mockDb.attachment.create.mockRejectedValue(new Error('DB error')); - - const result = await createComment({ - content: 'Test transaction rollback', - entityId: 'task_123', - entityType: CommentEntityType.task, - attachments: [ - { - id: 'temp_123', - name: 'test.pdf', - fileType: 'application/pdf', - fileData: 'base64data', - }, - ], - }); - - expect(result.success).toBe(false); - expect(result.error).toBe('Failed to save comment and link attachments.'); - expect(mockDb.$transaction).toHaveBeenCalled(); - }); - }); -}); diff --git a/apps/app/src/actions/comments/__tests__/deleteComment.test.ts b/apps/app/src/actions/comments/__tests__/deleteComment.test.ts deleted file mode 100644 index 9dcf618e9..000000000 --- a/apps/app/src/actions/comments/__tests__/deleteComment.test.ts +++ /dev/null @@ -1,323 +0,0 @@ -import { AttachmentEntityType, CommentEntityType } from '@db'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -// Import the mock setup utilities first -import { mockAuth, setupAuthMocks } from '@/test-utils/mocks/auth'; -import { mockDb } from '@/test-utils/mocks/db'; - -// Mock the modules with the imported mocks -vi.mock('@/utils/auth', () => ({ - auth: mockAuth, -})); - -vi.mock('@db', () => ({ - db: mockDb, -})); - -vi.mock('@/app/s3', () => ({ - BUCKET_NAME: 'test-bucket', - s3Client: { - send: vi.fn(), - }, - extractS3KeyFromUrl: vi.fn((url: string) => url), -})); - -// Import the function to test after mocking -import { deleteComment } from '../deleteComment'; - -describe('deleteComment', () => { - beforeEach(() => { - vi.clearAllMocks(); - mockDb.$transaction.mockImplementation((fn) => fn(mockDb)); - }); - - describe('Authorization', () => { - it('should fail when no session exists', async () => { - setupAuthMocks({ session: null, user: null }); - - const result = await deleteComment({ - commentId: 'comment_123', - }); - - expect(result).toEqual({ - success: false, - error: 'Not authorized: No active organization', - }); - }); - - it('should fail when no active organization exists', async () => { - setupAuthMocks({ session: { activeOrganizationId: null } as any }); - - const result = await deleteComment({ - commentId: 'comment_123', - }); - - expect(result).toEqual({ - success: false, - error: 'Not authorized: No active organization', - }); - }); - - it('should fail when user is not the comment author', async () => { - const { session, user } = setupAuthMocks(); - const differentMember = { - id: 'member_different', - userId: 'user_different', - organizationId: session!.activeOrganizationId, - }; - - const mockComment = { - id: 'comment_123', - authorId: differentMember.id, // Different member - organizationId: session!.activeOrganizationId, - }; - - mockDb.comment.findUnique.mockResolvedValue(mockComment); - mockDb.member.findFirst.mockResolvedValue({ - id: 'member_123', // Current user's member ID - userId: user!.id, - }); - - const result = await deleteComment({ - commentId: 'comment_123', - }); - - expect(result).toEqual({ - success: false, - error: 'Not authorized to delete this comment', - }); - }); - - it('should fail when comment belongs to different organization', async () => { - const { session } = setupAuthMocks(); - - // When querying with organizationId filter, comment from different org returns null - mockDb.comment.findUnique.mockResolvedValue(null); - - const result = await deleteComment({ - commentId: 'comment_123', - }); - - expect(result).toEqual({ - success: false, - error: 'Comment not found or access denied', - }); - }); - }); - - describe('Successful Deletion', () => { - it('should delete comment without attachments', async () => { - const { session, user } = setupAuthMocks(); - const mockMember = { - id: 'member_123', - userId: user!.id, - organizationId: session!.activeOrganizationId, - }; - - const mockComment = { - id: 'comment_123', - authorId: mockMember.id, - organizationId: session!.activeOrganizationId, - entityId: 'task_123', - entityType: CommentEntityType.task, - attachments: [], // No attachments - }; - - mockDb.comment.findUnique.mockResolvedValue(mockComment); - mockDb.member.findFirst.mockResolvedValue(mockMember); - mockDb.comment.delete.mockResolvedValue(mockComment); - - const result = await deleteComment({ - commentId: 'comment_123', - }); - - expect(result).toEqual({ - success: true, - data: { deletedCommentId: 'comment_123' }, - }); - - expect(mockDb.comment.delete).toHaveBeenCalledWith({ - where: { id: 'comment_123', organizationId: session!.activeOrganizationId }, - }); - }); - - it('should delete comment with attachments and clean up S3', async () => { - const { session, user } = setupAuthMocks(); - const mockMember = { - id: 'member_123', - userId: user!.id, - organizationId: session!.activeOrganizationId, - }; - - const mockAttachments = [ - { - id: 'att_1', - url: 's3://bucket/key1', - entityType: AttachmentEntityType.comment, - }, - { - id: 'att_2', - url: 's3://bucket/key2', - entityType: AttachmentEntityType.comment, - }, - ]; - - const mockComment = { - id: 'comment_123', - authorId: mockMember.id, - organizationId: session!.activeOrganizationId, - entityId: 'task_123', - entityType: CommentEntityType.task, - attachments: mockAttachments, - }; - - mockDb.comment.findUnique.mockResolvedValue(mockComment); - mockDb.member.findFirst.mockResolvedValue(mockMember); - mockDb.comment.delete.mockResolvedValue(mockComment); - mockDb.attachment.deleteMany.mockResolvedValue({ count: 2 }); - - const { s3Client } = await import('@/app/s3'); - - const result = await deleteComment({ - commentId: 'comment_123', - }); - - expect(result).toEqual({ - success: true, - data: { deletedCommentId: 'comment_123' }, - }); - - // Verify S3 deletions - expect(s3Client.send).toHaveBeenCalledTimes(2); - - // Verify DB deletions - expect(mockDb.attachment.deleteMany).toHaveBeenCalledWith({ - where: { - entityId: 'comment_123', - organizationId: session!.activeOrganizationId, - }, - }); - - expect(mockDb.comment.delete).toHaveBeenCalledWith({ - where: { id: 'comment_123', organizationId: session!.activeOrganizationId }, - }); - }); - - it('should continue deletion even if S3 cleanup fails', async () => { - const { session, user } = setupAuthMocks(); - const mockMember = { - id: 'member_123', - userId: user!.id, - organizationId: session!.activeOrganizationId, - }; - - const mockAttachments = [ - { - id: 'att_1', - url: 's3://bucket/key1', - entityType: AttachmentEntityType.comment, - }, - ]; - - const mockComment = { - id: 'comment_123', - authorId: mockMember.id, - organizationId: session!.activeOrganizationId, - entityId: 'task_123', - entityType: CommentEntityType.task, - attachments: mockAttachments, - }; - - mockDb.comment.findUnique.mockResolvedValue(mockComment); - mockDb.member.findFirst.mockResolvedValue(mockMember); - mockDb.comment.delete.mockResolvedValue(mockComment); - mockDb.attachment.deleteMany.mockResolvedValue({ count: 1 }); - - const { s3Client } = await import('@/app/s3'); - // Make S3 deletion fail - (s3Client.send as any).mockRejectedValue(new Error('S3 error')); - - const result = await deleteComment({ - commentId: 'comment_123', - }); - - // Should still succeed even if S3 fails - expect(result).toEqual({ - success: true, - data: { deletedCommentId: 'comment_123' }, - }); - - expect(mockDb.comment.delete).toHaveBeenCalled(); - }); - }); - - describe('Error Handling', () => { - it('should handle database errors gracefully', async () => { - const { session, user } = setupAuthMocks(); - const mockMember = { - id: 'member_123', - userId: user!.id, - organizationId: session!.activeOrganizationId, - }; - - const mockComment = { - id: 'comment_123', - authorId: mockMember.id, - organizationId: session!.activeOrganizationId, - attachments: [], - }; - - mockDb.comment.findUnique.mockResolvedValue(mockComment); - mockDb.member.findFirst.mockResolvedValue(mockMember); - mockDb.comment.delete.mockRejectedValue(new Error('DB error')); - - const result = await deleteComment({ - commentId: 'comment_123', - }); - - expect(result).toEqual({ - success: false, - error: 'DB error', - }); - }); - - it('should handle non-existent comments', async () => { - setupAuthMocks(); - mockDb.comment.findUnique.mockResolvedValue(null); - - const result = await deleteComment({ - commentId: 'comment_nonexistent', - }); - - expect(result).toEqual({ - success: false, - error: 'Comment not found or access denied', - }); - }); - }); - - describe('Member Lookup', () => { - it('should handle missing member record', async () => { - const { session } = setupAuthMocks(); - - const mockComment = { - id: 'comment_123', - authorId: 'member_123', - organizationId: session!.activeOrganizationId, - attachments: [], - }; - - mockDb.comment.findUnique.mockResolvedValue(mockComment); - mockDb.member.findFirst.mockResolvedValue(null); // No member found - - const result = await deleteComment({ - commentId: 'comment_123', - }); - - expect(result).toEqual({ - success: false, - error: 'Not authorized to delete this comment', - }); - }); - }); -}); diff --git a/apps/app/src/actions/comments/__tests__/updateComment.test.ts b/apps/app/src/actions/comments/__tests__/updateComment.test.ts deleted file mode 100644 index e77165485..000000000 --- a/apps/app/src/actions/comments/__tests__/updateComment.test.ts +++ /dev/null @@ -1,450 +0,0 @@ -import { AttachmentEntityType, CommentEntityType } from '@db'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -// Import the mock setup utilities first -import { mockAuth, setupAuthMocks } from '@/test-utils/mocks/auth'; -import { mockDb } from '@/test-utils/mocks/db'; - -// Mock the modules with the imported mocks -vi.mock('@/utils/auth', () => ({ - auth: mockAuth, -})); - -vi.mock('@db', () => ({ - db: mockDb, -})); - -vi.mock('@/app/s3', () => ({ - BUCKET_NAME: 'test-bucket', - s3Client: { - send: vi.fn(), - }, - extractS3KeyFromUrl: vi.fn((url: string) => url), -})); - -// Import the function to test after mocking -import { updateComment } from '../updateComment'; - -describe('updateComment', () => { - beforeEach(() => { - vi.clearAllMocks(); - mockDb.$transaction.mockImplementation((fn) => fn(mockDb)); - }); - - describe('Authorization', () => { - it('should fail when no active organization exists', async () => { - setupAuthMocks({ session: { activeOrganizationId: null } as any }); - - const result = await updateComment({ - commentId: 'comment_123', - content: 'Updated content', - }); - - expect(result).toEqual({ - success: false, - error: 'Not authorized', - data: null, - }); - }); - - it('should fail when user is not the comment author', async () => { - const { session, user } = setupAuthMocks(); - const differentMember = { - id: 'member_different', - userId: 'user_different', - }; - - const mockComment = { - id: 'comment_123', - authorId: differentMember.id, // Different author - organizationId: session!.activeOrganizationId, - entityId: 'task_123', - }; - - mockDb.comment.findUnique.mockResolvedValue(mockComment); - mockDb.member.findFirst.mockResolvedValue({ - id: 'member_123', // Current user's member ID - userId: user!.id, - }); - - const result = await updateComment({ - commentId: 'comment_123', - content: 'Updated content', - }); - - expect(result).toEqual({ - success: false, - error: 'Not authorized', - data: null, - }); - }); - - it('should fail when comment belongs to different organization', async () => { - const { session } = setupAuthMocks(); - - // When comment belongs to different org, findUnique with organizationId filter returns null - mockDb.comment.findUnique.mockResolvedValue(null); - - const result = await updateComment({ - commentId: 'comment_123', - content: 'Updated content', - }); - - expect(result).toEqual({ - success: false, - error: 'Comment not found', - data: null, - }); - }); - }); - - describe('Content Updates', () => { - it('should update comment content successfully', async () => { - const { session, user } = setupAuthMocks(); - const mockMember = { - id: 'member_123', - userId: user!.id, - }; - - const mockComment = { - id: 'comment_123', - content: 'Original content', - authorId: mockMember.id, - organizationId: session!.activeOrganizationId, - entityId: 'task_123', - entityType: CommentEntityType.task, - }; - - const updatedComment = { - ...mockComment, - content: 'Updated content', - }; - - mockDb.comment.findUnique.mockResolvedValue(mockComment); - mockDb.member.findFirst.mockResolvedValue(mockMember); - mockDb.comment.update.mockResolvedValue(updatedComment); - - const result = await updateComment({ - commentId: 'comment_123', - content: 'Updated content', - }); - - expect(result).toEqual({ - success: true, - error: null, - data: updatedComment, - }); - - expect(mockDb.comment.update).toHaveBeenCalledWith({ - where: { id: 'comment_123' }, - data: { content: 'Updated content' }, - }); - }); - - it('should handle empty content when attachments exist', async () => { - const { session, user } = setupAuthMocks(); - const mockMember = { - id: 'member_123', - userId: user!.id, - }; - - const mockComment = { - id: 'comment_123', - content: 'Original content', - authorId: mockMember.id, - organizationId: session!.activeOrganizationId, - entityId: 'task_123', - entityType: CommentEntityType.task, - }; - - mockDb.comment.findUnique.mockResolvedValue(mockComment); - mockDb.member.findFirst.mockResolvedValue(mockMember); - mockDb.comment.update.mockResolvedValue({ ...mockComment, content: '' }); - mockDb.attachment.findMany.mockResolvedValue([{ id: 'att_1' }]); // Has attachments - - const result = await updateComment({ - commentId: 'comment_123', - content: '', // Empty content - }); - - expect(result.success).toBe(true); - }); - - it('should fail when setting empty content without attachments', async () => { - const { session, user } = setupAuthMocks(); - const mockMember = { - id: 'member_123', - userId: user!.id, - }; - - const mockComment = { - id: 'comment_123', - content: 'Original content', - authorId: mockMember.id, - organizationId: session!.activeOrganizationId, - entityId: 'task_123', - }; - - mockDb.comment.findUnique.mockResolvedValue(mockComment); - mockDb.member.findFirst.mockResolvedValue(mockMember); - mockDb.attachment.findMany.mockResolvedValue([]); // No attachments - mockDb.comment.update.mockResolvedValue({ ...mockComment, content: '' }); - - // The updateComment action doesn't validate empty content, it just updates - const result = await updateComment({ - commentId: 'comment_123', - content: '', // Empty content - }); - - expect(result.success).toBe(true); - }); - }); - - describe('Attachment Management', () => { - it('should add new attachments to comment', async () => { - const { session, user } = setupAuthMocks(); - const mockMember = { - id: 'member_123', - userId: user!.id, - }; - - const mockComment = { - id: 'comment_123', - content: 'Original content', - authorId: mockMember.id, - organizationId: session!.activeOrganizationId, - entityId: 'task_123', - entityType: CommentEntityType.task, - }; - - mockDb.comment.findUnique.mockResolvedValue(mockComment); - mockDb.member.findFirst.mockResolvedValue(mockMember); - mockDb.attachment.updateMany.mockResolvedValue({ count: 2 }); - - const result = await updateComment({ - commentId: 'comment_123', - attachmentIdsToAdd: ['att_new_1', 'att_new_2'], - }); - - expect(result.success).toBe(true); - - expect(mockDb.attachment.updateMany).toHaveBeenCalledWith({ - where: { - id: { in: ['att_new_1', 'att_new_2'] }, - organizationId: session!.activeOrganizationId, - entityType: AttachmentEntityType.comment, - entityId: mockComment.entityId, // Parent entity ID - }, - data: { - entityId: mockComment.id, // Update to comment ID - }, - }); - }); - - it('should remove attachments and clean up S3', async () => { - const { session, user } = setupAuthMocks(); - const mockMember = { - id: 'member_123', - userId: user!.id, - }; - - const mockComment = { - id: 'comment_123', - content: 'Original content', - authorId: mockMember.id, - organizationId: session!.activeOrganizationId, - entityId: 'task_123', - entityType: CommentEntityType.task, - }; - - const attachmentsToRemove = [ - { id: 'att_1', url: 's3://bucket/key1' }, - { id: 'att_2', url: 's3://bucket/key2' }, - ]; - - mockDb.comment.findUnique.mockResolvedValue(mockComment); - mockDb.member.findFirst.mockResolvedValue(mockMember); - mockDb.attachment.findMany.mockResolvedValue(attachmentsToRemove); - mockDb.attachment.deleteMany.mockResolvedValue({ count: 2 }); - - const { s3Client } = await import('@/app/s3'); - - const result = await updateComment({ - commentId: 'comment_123', - attachmentIdsToRemove: ['att_1', 'att_2'], - }); - - expect(result.success).toBe(true); - - // Verify S3 deletions - expect(s3Client.send).toHaveBeenCalledTimes(2); - - // Verify DB deletions - expect(mockDb.attachment.deleteMany).toHaveBeenCalledWith({ - where: { - id: { in: ['att_1', 'att_2'] }, - organizationId: session!.activeOrganizationId, - entityId: mockComment.id, - }, - }); - }); - - it('should continue attachment removal even if S3 fails', async () => { - const { session, user } = setupAuthMocks(); - const mockMember = { - id: 'member_123', - userId: user!.id, - }; - - const mockComment = { - id: 'comment_123', - content: 'Original content', - authorId: mockMember.id, - organizationId: session!.activeOrganizationId, - entityId: 'task_123', - entityType: CommentEntityType.task, - }; - - mockDb.comment.findUnique.mockResolvedValue(mockComment); - mockDb.member.findFirst.mockResolvedValue(mockMember); - mockDb.attachment.findMany.mockResolvedValue([{ id: 'att_1', url: 's3://bucket/key1' }]); - mockDb.attachment.deleteMany.mockResolvedValue({ count: 1 }); - - const { s3Client } = await import('@/app/s3'); - (s3Client.send as any).mockRejectedValue(new Error('S3 error')); - - const result = await updateComment({ - commentId: 'comment_123', - attachmentIdsToRemove: ['att_1'], - }); - - // Should still succeed - expect(result.success).toBe(true); - expect(mockDb.attachment.deleteMany).toHaveBeenCalled(); - }); - }); - - describe('Combined Updates', () => { - it('should update content and manage attachments in one transaction', async () => { - const { session, user } = setupAuthMocks(); - const mockMember = { - id: 'member_123', - userId: user!.id, - }; - - const mockComment = { - id: 'comment_123', - content: 'Original content', - authorId: mockMember.id, - organizationId: session!.activeOrganizationId, - entityId: 'task_123', - entityType: CommentEntityType.task, - }; - - const updatedComment = { - ...mockComment, - content: 'Updated content', - }; - - mockDb.comment.findUnique.mockResolvedValue(mockComment); - mockDb.member.findFirst.mockResolvedValue(mockMember); - mockDb.comment.update.mockResolvedValue(updatedComment); - mockDb.attachment.findMany.mockResolvedValue([{ id: 'att_old', url: 's3://bucket/old' }]); - mockDb.attachment.deleteMany.mockResolvedValue({ count: 1 }); - mockDb.attachment.updateMany.mockResolvedValue({ count: 1 }); - - const result = await updateComment({ - commentId: 'comment_123', - content: 'Updated content', - attachmentIdsToRemove: ['att_old'], - attachmentIdsToAdd: ['att_new'], - }); - - expect(result.success).toBe(true); - expect(result.data).toBeTruthy(); - - // Verify all operations were called - expect(mockDb.comment.update).toHaveBeenCalled(); - expect(mockDb.attachment.deleteMany).toHaveBeenCalled(); - expect(mockDb.attachment.updateMany).toHaveBeenCalled(); - }); - }); - - describe('Error Handling', () => { - it('should handle non-existent comments', async () => { - setupAuthMocks(); - mockDb.comment.findUnique.mockResolvedValue(null); - - const result = await updateComment({ - commentId: 'comment_nonexistent', - content: 'Updated', - }); - - expect(result).toEqual({ - success: false, - error: 'Comment not found', - data: null, - }); - }); - - it('should handle database errors gracefully', async () => { - const { session, user } = setupAuthMocks(); - const mockMember = { - id: 'member_123', - userId: user!.id, - }; - - const mockComment = { - id: 'comment_123', - content: 'Original content', - authorId: mockMember.id, - organizationId: session!.activeOrganizationId, - entityId: 'task_123', - }; - - mockDb.comment.findUnique.mockResolvedValue(mockComment); - mockDb.member.findFirst.mockResolvedValue(mockMember); - mockDb.comment.update.mockRejectedValue(new Error('DB error')); - - const result = await updateComment({ - commentId: 'comment_123', - content: 'Updated content', - }); - - expect(result).toEqual({ - success: false, - error: 'DB error', - }); - }); - - it('should rollback transaction on failure', async () => { - const { session, user } = setupAuthMocks(); - const mockMember = { - id: 'member_123', - userId: user!.id, - }; - - const mockComment = { - id: 'comment_123', - content: 'Original content', - authorId: mockMember.id, - organizationId: session!.activeOrganizationId, - entityId: 'task_123', - }; - - mockDb.comment.findUnique.mockResolvedValue(mockComment); - mockDb.member.findFirst.mockResolvedValue(mockMember); - mockDb.comment.update.mockResolvedValue(mockComment); - mockDb.attachment.updateMany.mockRejectedValue(new Error('Attachment update failed')); - - const result = await updateComment({ - commentId: 'comment_123', - content: 'Updated content', - attachmentIdsToAdd: ['att_new'], - }); - - expect(result.success).toBe(false); - expect(mockDb.$transaction).toHaveBeenCalled(); - }); - }); -}); diff --git a/apps/app/src/actions/comments/createComment.ts b/apps/app/src/actions/comments/createComment.ts deleted file mode 100644 index d4d7d415b..000000000 --- a/apps/app/src/actions/comments/createComment.ts +++ /dev/null @@ -1,188 +0,0 @@ -'use server'; - -import { BUCKET_NAME, s3Client } from '@/app/s3'; -import { auth } from '@/utils/auth'; -import { PutObjectCommand } from '@aws-sdk/client-s3'; -import { AttachmentEntityType, CommentEntityType, db } from '@db'; -import { revalidatePath } from 'next/cache'; -import { headers } from 'next/headers'; -import { z } from 'zod'; - -// Helper to map fileType to AttachmentType -function mapFileTypeToAttachmentType(fileType: string) { - const type = fileType.split('/')[0]; - switch (type) { - case 'image': - return 'image' as const; - case 'video': - return 'video' as const; - case 'audio': - return 'audio' as const; - case 'application': - if (fileType === 'application/pdf') return 'document' as const; - return 'document' as const; - default: - return 'other' as const; - } -} - -// Define schema for attachment data -const attachmentSchema = z.object({ - id: z.string(), // temporary ID from frontend - name: z.string(), - fileType: z.string(), - fileData: z.string(), // base64 encoded -}); - -// Define the input schema -const createCommentSchema = z - .object({ - content: z.string(), - entityId: z.string(), - entityType: z.nativeEnum(CommentEntityType), - attachments: z.array(attachmentSchema).optional(), - pathToRevalidate: z.string().optional(), - }) - .refine( - (data) => - // Check if content is non-empty after trimming OR if attachments exist - (data.content && data.content.trim().length > 0) || - (data.attachments && data.attachments.length > 0), - { - message: 'Comment cannot be empty unless attachments are provided.', - path: ['content'], - }, - ); - -export const createComment = async (input: z.infer) => { - // Parse and validate the input - const parseResult = createCommentSchema.safeParse(input); - if (!parseResult.success) { - return { - success: false, - error: parseResult.error.errors[0]?.message || 'Invalid input', - data: null, - }; - } - - const { content, entityId, entityType, attachments, pathToRevalidate } = parseResult.data; - const session = await auth.api.getSession({ - headers: await headers(), - }); - const orgId = session?.session?.activeOrganizationId; - - if (!orgId) { - return { - success: false, - error: 'Not authorized - no active organization found.', - data: null, - }; - } - - if (!entityId) { - console.error('Entity ID missing after validation in createComment'); - return { - success: false, - error: 'Internal error: Entity ID missing.', - data: null, - }; - } - - try { - // Find the Member ID associated with the user and organization - const member = await db.member.findFirst({ - where: { - userId: session?.user?.id, - organizationId: orgId, - }, - select: { id: true }, - }); - - if (!member) { - return { - success: false, - error: 'Not authorized - member not found in organization.', - data: null, - }; - } - - // Wrap create and update in a transaction - const result = await db.$transaction(async (tx) => { - // 1. Create the comment within the transaction - console.log('Creating comment:', { - content, - entityId, - entityType, - memberId: member.id, - organizationId: orgId, - }); - const comment = await tx.comment.create({ - data: { - content: content ?? '', - entityId, - entityType, - authorId: member.id, - organizationId: orgId, - }, - }); - - // 2. Upload and create attachments if provided - if (attachments && attachments.length > 0) { - console.log('Uploading and creating attachments for comment:', comment.id); - - for (const attachment of attachments) { - // Convert base64 to buffer - const fileBuffer = Buffer.from(attachment.fileData, 'base64'); - - // Create S3 key - const timestamp = Date.now(); - const sanitizedFileName = attachment.name.replace(/[^a-zA-Z0-9.-]/g, '_'); - const key = `${orgId}/attachments/comment/${comment.id}/${timestamp}-${sanitizedFileName}`; - - // Upload to S3 - const putCommand = new PutObjectCommand({ - Bucket: BUCKET_NAME, - Key: key, - Body: fileBuffer, - ContentType: attachment.fileType, - }); - - await s3Client.send(putCommand); - - // Create attachment record - await tx.attachment.create({ - data: { - name: attachment.name, - url: key, - type: mapFileTypeToAttachmentType(attachment.fileType), - entityId: comment.id, - entityType: AttachmentEntityType.comment, - organizationId: orgId, - }, - }); - } - } - - return comment; - }); - - 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: result, - error: null, - }; - } catch (error) { - console.error('Failed to create comment with attachments transaction:', error); - return { - success: false, - error: 'Failed to save comment and link attachments.', // More specific error - data: null, - }; - } -}; diff --git a/apps/app/src/actions/comments/deleteComment.ts b/apps/app/src/actions/comments/deleteComment.ts deleted file mode 100644 index b6ce0b348..000000000 --- a/apps/app/src/actions/comments/deleteComment.ts +++ /dev/null @@ -1,114 +0,0 @@ -'use server'; - -import { extractS3KeyFromUrl, s3Client } from '@/app/s3'; -import { auth } from '@/utils/auth'; -import { DeleteObjectCommand } from '@aws-sdk/client-s3'; -import { db } from '@db'; -import { revalidatePath } from 'next/cache'; -import { headers } from 'next/headers'; -import { z } from 'zod'; - -const schema = z.object({ - commentId: z.string(), -}); - -export const deleteComment = async (input: z.infer) => { - const { commentId } = input; - const session = await auth.api.getSession({ - headers: await headers(), - }); - const organizationId = session?.session?.activeOrganizationId; - const userId = session?.session.userId; - - if (!organizationId) { - return { - success: false, - error: 'Not authorized: No active organization', - }; - } - - try { - // 1. Fetch the comment, its author, and its attachments - const comment = await db.comment.findUnique({ - where: { id: commentId, organizationId }, - select: { - id: true, - authorId: true, - entityId: true, // Parent task ID for revalidation - attachments: { - // Get attachments to delete from S3 - select: { id: true, url: true }, - }, - }, - }); - - if (!comment) { - return { - success: false, - error: 'Comment not found or access denied', - }; - } - - // 2. Authorization Check (Placeholder - implement proper logic) - const currentMember = await db.member.findFirst({ - where: { userId, organizationId }, - select: { id: true }, - }); - if (!currentMember || comment.authorId !== currentMember.id) { - // TODO: Add role-based check for admins - return { - success: false, - error: 'Not authorized to delete this comment', - }; - } - - const parentTaskId = comment.entityId; // Store before transaction - - // --- Start Transaction --- - await db.$transaction(async (tx) => { - // 3. Delete Attachments from S3 (best effort) - if (comment.attachments && comment.attachments.length > 0) { - for (const att of comment.attachments as { - id: string; - url: string; - }[]) { - try { - const key = extractS3KeyFromUrl(att.url); - await s3Client.send( - new DeleteObjectCommand({ - Bucket: process.env.APP_AWS_BUCKET_NAME!, - Key: key, - }), - ); - } catch (s3Error: unknown) { - console.error( - `Failed to delete attachment ${att.id} from S3 during comment deletion:`, - s3Error, - ); - // Continue even if S3 delete fails - } - } - // 4. Delete Attachment records from DB - await tx.attachment.deleteMany({ - where: { entityId: commentId, organizationId }, // Delete all linked to this comment - }); - } - - // 5. Delete the Comment itself - await tx.comment.delete({ - where: { id: commentId, organizationId }, - }); - }); // --- End Transaction --- - - // Revalidate Task path - if (parentTaskId) { - revalidatePath(`/${organizationId}/tasks/${parentTaskId}`); - } - - return { success: true, data: { deletedCommentId: commentId } }; - } catch (error: unknown) { - console.error('Failed to delete comment:', error); - const errorMessage = error instanceof Error ? error.message : 'Could not delete comment.'; - return { success: false, error: errorMessage }; - } -}; diff --git a/apps/app/src/actions/comments/deleteCommentAttachment.ts b/apps/app/src/actions/comments/deleteCommentAttachment.ts deleted file mode 100644 index 9d2d1f294..000000000 --- a/apps/app/src/actions/comments/deleteCommentAttachment.ts +++ /dev/null @@ -1,138 +0,0 @@ -'use server'; - -import { authActionClient } from '@/actions/safe-action'; -import { BUCKET_NAME, extractS3KeyFromUrl, s3Client } from '@/app/s3'; -import { DeleteObjectCommand } from '@aws-sdk/client-s3'; -import { AttachmentEntityType, db } from '@db'; -import { revalidatePath } from 'next/cache'; -import { z } from 'zod'; - -const schema = z.object({ - attachmentId: z.string(), -}); - -export const deleteCommentAttachment = authActionClient - .inputSchema(schema) - .metadata({ - name: 'deleteCommentAttachment', - track: { event: 'delete-comment-attachment', channel: 'server' }, - }) - .action(async ({ parsedInput, ctx }) => { - const { session, user } = ctx; - const { attachmentId } = parsedInput; - const organizationId = session.activeOrganizationId; - const userId = user.id; - - if (!organizationId) { - return { success: false, error: 'Not authorized' } as const; - } - - try { - // 1. Find the attachment and verify ownership/TYPE - const attachmentToDelete = await db.attachment.findUnique({ - where: { - id: attachmentId, - organizationId: organizationId, - // No include needed here - }, - }); - - if (!attachmentToDelete) { - return { - success: false, - error: 'Attachment not found or access denied', - } as const; - } - - // 1b. Verify it's a comment attachment - if (attachmentToDelete.entityType !== AttachmentEntityType.comment) { - console.error( - 'Attachment requested for deletion is not a comment attachment', - attachmentId, - ); - return { - success: false, - error: 'Invalid attachment type for deletion', - } as const; - } - - // 2. Fetch the associated Comment for authorization check and revalidation path - const comment = await db.comment.findUnique({ - where: { - id: attachmentToDelete.entityId, - organizationId: organizationId, // Ensure comment is in the same org - }, - select: { - authorId: true, // Need author to check permission - entityId: true, // Need parent task ID for revalidation - }, - }); - - if (!comment) { - console.error( - 'Comment associated with attachment not found during delete', - attachmentId, - attachmentToDelete.entityId, - ); - // Proceed with deleting the attachment record, but log the error. - // S3 deletion might still proceed if key extraction works. - } - - // 3. Authorization Check: Ensure user is the comment author - const authorMember = await db.member.findFirst({ - where: { - userId: userId, - organizationId: organizationId, - }, - select: { id: true }, - }); - - // Check if comment was found AND if the author matches - if (!authorMember || !comment || comment.authorId !== authorMember.id) { - // Add role-based check here if admins should also be able to delete - return { - success: false, - error: 'Not authorized to delete this attachment', - } as const; - } - - // 4. Attempt to delete from S3 using shared client - let key: string; - try { - key = extractS3KeyFromUrl(attachmentToDelete.url); - const deleteCommand = new DeleteObjectCommand({ - Bucket: BUCKET_NAME!, - Key: key, - }); - await s3Client.send(deleteCommand); - } catch (s3Error: any) { - // Log error but proceed to delete DB record (orphan file is better than inconsistent state) - console.error('S3 Delete Error for comment attachment:', attachmentId, s3Error); - } - - // 5. Delete Attachment record from Database - await db.attachment.delete({ - where: { - id: attachmentId, - organizationId: organizationId, - }, - }); - - // 6. Revalidate the parent task path (using comment fetched earlier) - if (comment?.entityId) { - // Check if comment was found before revalidating - revalidatePath(`/${organizationId}/tasks/${comment.entityId}`); - } - - return { - success: true, - data: { deletedAttachmentId: attachmentId }, - }; - } catch (error: any) { - console.error('Error deleting comment attachment:', attachmentId, error); - return { - success: false, - error: 'Failed to delete attachment.', - } as const; - } - }); diff --git a/apps/app/src/actions/comments/getCommentAttachmentUrl.ts b/apps/app/src/actions/comments/getCommentAttachmentUrl.ts deleted file mode 100644 index 0d2ff4431..000000000 --- a/apps/app/src/actions/comments/getCommentAttachmentUrl.ts +++ /dev/null @@ -1,120 +0,0 @@ -'use server'; - -import { BUCKET_NAME, extractS3KeyFromUrl, s3Client } from '@/app/s3'; -import { auth } from '@/utils/auth'; -import { GetObjectCommand } from '@aws-sdk/client-s3'; -import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; -import { AttachmentEntityType, db } from '@db'; -import { headers } from 'next/headers'; -import { z } from 'zod'; - -const schema = z.object({ - attachmentId: z.string(), -}); - -export const getCommentAttachmentUrl = async (input: z.infer) => { - const session = await auth.api.getSession({ - headers: await headers(), - }); - const { attachmentId } = input; - const organizationId = session?.session?.activeOrganizationId; - - if (!organizationId) { - return { - success: false, - error: 'Not authorized - no organization found', - } as const; - } - - try { - // 1. Find the attachment and verify ownership & TYPE - const attachment = await db.attachment.findUnique({ - where: { - id: attachmentId, - organizationId: organizationId, - }, - }); - - if (!attachment) { - return { - success: false, - error: 'Attachment not found or access denied', - } as const; - } - - // 1b. Check if it's actually a comment attachment - if (attachment.entityType !== AttachmentEntityType.comment) { - console.error('Attachment requested is not a comment attachment', attachmentId); - return { - success: false, - error: 'Invalid attachment type requested', - } as const; - } - - // 2. Fetch the associated Comment - const comment = await db.comment.findUnique({ - where: { - id: attachment.entityId, // Use entityId from attachment - organizationId: organizationId, // Ensure comment is in the same org - }, - }); - - if (!comment) { - console.error( - 'Comment associated with attachment not found', - attachmentId, - attachment.entityId, - ); - return { - success: false, - error: 'Attachment link error (Comment not found)', - } as const; - } - - // 3. Extract S3 key - let key: string; - try { - key = extractS3KeyFromUrl(attachment.url); - } catch (extractError) { - console.error('Error extracting S3 key for comment attachment:', attachmentId, extractError); - return { - success: false, - error: 'Could not process attachment URL', - } as const; - } - - // 4. Generate Signed URL using shared client and bucket name - try { - const command = new GetObjectCommand({ - Bucket: BUCKET_NAME!, - Key: key, - }); - - const signedUrl = await getSignedUrl(s3Client, command, { - expiresIn: 3600, - }); - - if (!signedUrl) { - console.error('getSignedUrl returned undefined for key:', key); - return { - success: false, - error: 'Failed to generate signed URL', - } as const; - } - - return { success: true, data: { signedUrl } }; - } catch (s3Error) { - console.error('S3 getSignedUrl Error:', s3Error); - return { - success: false, - error: 'Could not generate access URL for the file', - } as const; - } - } catch (dbError) { - console.error('Database Error fetching comment attachment:', dbError); - return { - success: false, - error: 'Failed to retrieve attachment details', - } as const; - } -}; diff --git a/apps/app/src/actions/comments/updateComment.ts b/apps/app/src/actions/comments/updateComment.ts deleted file mode 100644 index 1abe3b3b1..000000000 --- a/apps/app/src/actions/comments/updateComment.ts +++ /dev/null @@ -1,160 +0,0 @@ -'use server'; - -import { BUCKET_NAME, extractS3KeyFromUrl, s3Client } from '@/app/s3'; -import { auth } from '@/utils/auth'; -import { DeleteObjectCommand } from '@aws-sdk/client-s3'; -import { AttachmentEntityType, Comment, db } from '@db'; -import { revalidatePath } from 'next/cache'; -import { headers } from 'next/headers'; -import { z } from 'zod'; - -const schema = z - .object({ - commentId: z.string(), - content: z.string().optional(), // Optional: content might not change - attachmentIdsToAdd: z.array(z.string()).optional(), - attachmentIdsToRemove: z.array(z.string()).optional(), - }) - .refine( - (data) => - data.content !== undefined || - data.attachmentIdsToAdd?.length || - data.attachmentIdsToRemove?.length, - { message: 'No changes provided for update.' }, - ); - -export const updateComment = async (input: z.infer) => { - const session = await auth.api.getSession({ - headers: await headers(), - }); - const { commentId, content, attachmentIdsToAdd, attachmentIdsToRemove } = schema.parse(input); - const organizationId = session?.session?.activeOrganizationId; - const userId = session?.session.userId; - - if (!organizationId) { - return { - success: false, - error: 'Not authorized', - data: null, - }; - } - - try { - // 1. Fetch comment, include ID for return value consistency - const comment = await db.comment.findUnique({ - where: { id: commentId, organizationId }, - select: { id: true, authorId: true, entityId: true }, - }); - - if (!comment) { - return { - success: false, - error: 'Comment not found', - data: null, - }; - } - - // 2. Authorization Check (Placeholder - implement proper logic) - const currentMember = await db.member.findFirst({ - where: { userId, organizationId }, - select: { id: true }, - }); - if (!currentMember || comment.authorId !== currentMember.id) { - // TODO: Add role-based check for admins - return { - success: false, - error: 'Not authorized', - data: null, - }; - } - - // --- Start Transaction --- - const updatedCommentResult = await db.$transaction(async (tx) => { - let updatedCommentData: Comment | null = null; - if (content !== undefined) { - // update returns the full comment object including id - updatedCommentData = await tx.comment.update({ - where: { id: commentId }, - data: { content }, - }); - } - - // 4. Handle Attachments to Remove - if (attachmentIdsToRemove && attachmentIdsToRemove.length > 0) { - const attachments = await tx.attachment.findMany({ - where: { - id: { in: attachmentIdsToRemove }, - organizationId, - entityId: commentId, - }, - select: { id: true, url: true }, - }); - - // Delete from S3 (best effort) - for (const attachmentRecord of attachments) { - // Ensure type for internal usage - const att: { id: string; url: string } = attachmentRecord; - try { - const key = extractS3KeyFromUrl(att.url); - await s3Client.send( - new DeleteObjectCommand({ - Bucket: BUCKET_NAME, - Key: key, - }), - ); - } catch (s3Error: unknown) { - console.error(`Failed to delete attachment ${att.id} from S3:`, s3Error); - // Continue even if S3 delete fails - } - } - - // Delete from DB - await tx.attachment.deleteMany({ - where: { - id: { in: attachments.map((a) => a.id) }, - organizationId, - entityId: commentId, - }, - }); - } - - // 5. Handle Attachments to Add - if (attachmentIdsToAdd && attachmentIdsToAdd.length > 0) { - // Link attachments that were temporarily uploaded linked to the TASK - await tx.attachment.updateMany({ - where: { - id: { in: attachmentIdsToAdd }, - organizationId, - entityType: AttachmentEntityType.comment, // Ensure they were uploaded for a comment - // IMPORTANT: Assuming upload linked them to the *TASK* ID temporarily - entityId: comment.entityId, // Check they are linked to the parent task - }, - data: { - entityId: commentId, // Link to the actual comment ID - }, - }); - // TODO: Check update count matches length of attachmentIdsToAdd? - } - - // Return the newly updated comment data or the original fetched comment - return updatedCommentData || comment; - }); // --- End Transaction --- - - revalidatePath(`/${organizationId}/tasks/${updatedCommentResult.entityId}`); - - return { - success: true, - error: null, - data: updatedCommentResult, - }; - } catch (error) { - // Use unknown for outer catch block - console.error('Failed to update comment:', error); - // Type checking before accessing message - const errorMessage = error instanceof Error ? error.message : 'Could not update comment.'; - return { - success: false, - error: errorMessage, - }; - } -}; diff --git a/apps/app/src/actions/files/upload-file.ts b/apps/app/src/actions/files/upload-file.ts index ae78d553f..e4f90ff1e 100644 --- a/apps/app/src/actions/files/upload-file.ts +++ b/apps/app/src/actions/files/upload-file.ts @@ -1,29 +1,28 @@ 'use server'; +console.log('[uploadFile] Upload action module is being loaded...'); + +console.log('[uploadFile] Importing auth and logger...'); import { BUCKET_NAME, s3Client } from '@/app/s3'; import { auth } from '@/utils/auth'; import { logger } from '@/utils/logger'; -import { DeleteObjectCommand, GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3'; +import { GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3'; import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; import { AttachmentEntityType, AttachmentType, db } from '@db'; import { revalidatePath } from 'next/cache'; import { headers } from 'next/headers'; import { z } from 'zod'; -// Configuration constants -const MAX_FILE_SIZE_MB = 10; -const MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024; -const SIGNED_URL_EXPIRY = 900; // 15 minutes +console.log('[uploadFile] Importing S3 client...'); + +console.log('[uploadFile] Importing AWS SDK...'); + +console.log('[uploadFile] Importing database...'); -// Allowed file types for security -const ALLOWED_IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']; -const ALLOWED_DOCUMENT_TYPES = [ - 'application/pdf', - 'text/plain', - 'application/msword', - 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', -]; -const ALLOWED_FILE_TYPES = [...ALLOWED_IMAGE_TYPES, ...ALLOWED_DOCUMENT_TYPES]; +console.log('[uploadFile] All imports successful'); + +// This log will run as soon as the module is loaded. +logger.info('[uploadFile] Module loaded.'); function mapFileTypeToAttachmentType(fileType: string): AttachmentType { const type = fileType.split('/')[0]; @@ -41,179 +40,112 @@ function mapFileTypeToAttachmentType(fileType: string): AttachmentType { } } -// Enhanced validation schema const uploadAttachmentSchema = z.object({ - fileName: z - .string() - .min(1, 'File name is required') - .max(255, 'File name is too long') - .refine((name) => name.trim().length > 0, 'File name cannot be empty') - .refine((name) => !/[<>:"/\\|?*]/.test(name), 'File name contains invalid characters'), - fileType: z - .string() - .min(1, 'File type is required') - .refine( - (type) => ALLOWED_FILE_TYPES.includes(type), - `File type not allowed. Allowed types: ${ALLOWED_FILE_TYPES.join(', ')}`, - ), - fileData: z - .string() - .min(1, 'File data is required') - .refine((data) => { - try { - // Basic base64 validation - return /^[A-Za-z0-9+/]*={0,2}$/.test(data); - } catch { - return false; - } - }, 'Invalid file data format'), - entityId: z - .string() - .min(1, 'Entity ID is required') - .regex(/^[a-zA-Z0-9_-]+$/, 'Invalid entity ID format'), + fileName: z.string(), + fileType: z.string(), + fileData: z.string(), + entityId: z.string(), entityType: z.nativeEnum(AttachmentEntityType), pathToRevalidate: z.string().optional(), }); -// Custom error types for better error handling -class FileUploadError extends Error { - constructor( - message: string, - public code: string, - ) { - super(message); - this.name = 'FileUploadError'; - } -} - -// Helper function to generate secure file key -function generateFileKey( - organizationId: string, - entityType: AttachmentEntityType, - entityId: string, - fileName: string, -): string { - const timestamp = Date.now(); - const randomId = Math.random().toString(36).substring(2, 15); - const sanitizedFileName = fileName - .replace(/[^a-zA-Z0-9.-]/g, '_') - .replace(/_{2,}/g, '_') - .replace(/^_|_$/g, ''); - - return `${organizationId}/attachments/${entityType}/${entityId}/${timestamp}-${randomId}-${sanitizedFileName}`; -} - export const uploadFile = async (input: z.infer) => { - // Parse and validate input first - validation errors will be thrown automatically - const parsedInput = uploadAttachmentSchema.parse(input) as { - fileName: string; - fileType: string; - fileData: string; - entityId: string; - entityType: AttachmentEntityType; - pathToRevalidate?: string; - }; + console.log('[uploadFile] Function called - starting execution'); + logger.info(`[uploadFile] Starting upload for ${input.fileName}`); - // Pre-flight checks - if (!s3Client) { - logger.error('[uploadFile] S3 client not initialized'); - throw new FileUploadError( - 'File upload service is currently unavailable. Please contact support.', - 'S3_CLIENT_UNAVAILABLE', - ); - } + console.log('[uploadFile] Checking S3 client availability'); + try { + // Check if S3 client is available + if (!s3Client) { + logger.error('[uploadFile] S3 client not initialized - check environment variables'); + return { + success: false, + error: 'File upload service is currently unavailable. Please contact support.', + } as const; + } - if (!BUCKET_NAME) { - logger.error('[uploadFile] S3 bucket name not configured'); - throw new FileUploadError( - 'File upload service is not properly configured.', - 'S3_BUCKET_NOT_CONFIGURED', - ); - } + if (!BUCKET_NAME) { + logger.error('[uploadFile] S3 bucket name not configured'); + return { + success: false, + error: 'File upload service is not properly configured.', + } as const; + } - // Authentication check - const session = await auth.api.getSession({ headers: await headers() }); - const organizationId = session?.session.activeOrganizationId; + console.log('[uploadFile] Parsing input schema'); + const { fileName, fileType, fileData, entityId, entityType, pathToRevalidate } = + uploadAttachmentSchema.parse(input); - if (!organizationId) { - logger.error('[uploadFile] Not authorized - no organization found'); - throw new FileUploadError('Not authorized - no organization found', 'UNAUTHORIZED'); - } + console.log('[uploadFile] Getting user session'); + const session = await auth.api.getSession({ headers: await headers() }); + const organizationId = session?.session.activeOrganizationId; - logger.info(`[uploadFile] Starting upload for ${parsedInput.fileName} in org ${organizationId}`); + if (!organizationId) { + logger.error('[uploadFile] Not authorized - no organization found'); + return { + success: false, + error: 'Not authorized - no organization found', + } as const; + } - let s3Key: string | null = null; + logger.info(`[uploadFile] Starting upload for ${fileName} in org ${organizationId}`); - try { - // Convert and validate file data - const fileBuffer = Buffer.from(parsedInput.fileData, 'base64'); + console.log('[uploadFile] Converting file data to buffer'); + const fileBuffer = Buffer.from(fileData, 'base64'); + const MAX_FILE_SIZE_MB = 10; + const MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024; if (fileBuffer.length > MAX_FILE_SIZE_BYTES) { - logger.warn(`[uploadFile] File size ${fileBuffer.length} exceeds limit`); - throw new FileUploadError(`File exceeds the ${MAX_FILE_SIZE_MB}MB limit.`, 'FILE_TOO_LARGE'); - } - - // Validate actual file size vs base64 size (base64 is ~33% larger) - const actualFileSize = Math.floor(fileBuffer.length * 0.75); - if (actualFileSize > MAX_FILE_SIZE_BYTES) { - throw new FileUploadError(`File exceeds the ${MAX_FILE_SIZE_MB}MB limit.`, 'FILE_TOO_LARGE'); + logger.warn( + `[uploadFile] File size ${fileBuffer.length} exceeds the ${MAX_FILE_SIZE_MB}MB limit.`, + ); + return { + success: false, + error: `File exceeds the ${MAX_FILE_SIZE_MB}MB limit.`, + } as const; } - // Generate secure file key - s3Key = generateFileKey( - organizationId, - parsedInput.entityType, - parsedInput.entityId, - parsedInput.fileName, - ); + const timestamp = Date.now(); + const sanitizedFileName = fileName.replace(/[^a-zA-Z0-9.-]/g, '_'); + const key = `${organizationId}/attachments/${entityType}/${entityId}/${timestamp}-${sanitizedFileName}`; - // Upload to S3 - logger.info(`[uploadFile] Uploading to S3 with key: ${s3Key}`); + logger.info(`[uploadFile] Uploading to S3 with key: ${key}`); const putCommand = new PutObjectCommand({ Bucket: BUCKET_NAME, - Key: s3Key, + Key: key, Body: fileBuffer, - ContentType: parsedInput.fileType, - Metadata: { - originalFileName: parsedInput.fileName, - entityId: parsedInput.entityId, - entityType: parsedInput.entityType, - organizationId: organizationId, - }, + ContentType: fileType, }); - await s3Client.send(putCommand); - logger.info(`[uploadFile] S3 upload successful for key: ${s3Key}`); + logger.info(`[uploadFile] S3 upload successful for key: ${key}`); - // Create database record - logger.info(`[uploadFile] Creating attachment record in DB for key: ${s3Key}`); + logger.info(`[uploadFile] Creating attachment record in DB for key: ${key}`); const attachment = await db.attachment.create({ data: { - name: parsedInput.fileName, - url: s3Key, - type: mapFileTypeToAttachmentType(parsedInput.fileType), - entityId: parsedInput.entityId, - entityType: parsedInput.entityType, + name: fileName, + url: key, + type: mapFileTypeToAttachmentType(fileType), + entityId: entityId, + entityType: entityType, organizationId: organizationId, }, }); + logger.info(`[uploadFile] DB record created with id: ${attachment.id}`); - // Generate signed URL + logger.info(`[uploadFile] Generating signed URL for key: ${key}`); const getCommand = new GetObjectCommand({ Bucket: BUCKET_NAME, - Key: s3Key, + Key: key, }); const signedUrl = await getSignedUrl(s3Client, getCommand, { - expiresIn: SIGNED_URL_EXPIRY, + expiresIn: 900, }); + logger.info(`[uploadFile] Signed URL generated for key: ${key}`); - // Revalidate path if provided - if (parsedInput.pathToRevalidate) { - revalidatePath(parsedInput.pathToRevalidate); + if (pathToRevalidate) { + revalidatePath(pathToRevalidate); } - logger.info(`[uploadFile] Upload completed successfully for ${parsedInput.fileName}`); - return { success: true, data: { @@ -222,36 +154,10 @@ export const uploadFile = async (input: z.infer) }, } as const; } catch (error) { - // Cleanup: If S3 upload succeeded but DB operation failed, clean up S3 object - if (s3Key && error instanceof Error && !error.message.includes('S3')) { - try { - logger.warn(`[uploadFile] Cleaning up S3 object after DB failure: ${s3Key}`); - await s3Client.send( - new DeleteObjectCommand({ - Bucket: BUCKET_NAME, - Key: s3Key, - }), - ); - } catch (cleanupError) { - logger.error(`[uploadFile] Failed to cleanup S3 object: ${s3Key}`, cleanupError); - } - } - - // Enhanced error handling - if (error instanceof FileUploadError) { - logger.error(`[uploadFile] Upload failed: ${error.code} - ${error.message}`); - return { - success: false, - error: error.message, - code: error.code, - } as const; - } - - logger.error(`[uploadFile] Unexpected error during upload:`, error); + logger.error(`[uploadFile] Error during upload process:`, error); return { success: false, error: error instanceof Error ? error.message : 'An unknown error occurred.', - code: 'UNKNOWN_ERROR', } as const; } }; diff --git a/apps/app/src/actions/schema.ts b/apps/app/src/actions/schema.ts index ddf56f891..246717025 100644 --- a/apps/app/src/actions/schema.ts +++ b/apps/app/src/actions/schema.ts @@ -378,3 +378,34 @@ export const updateContextEntrySchema = z.object({ export const deleteContextEntrySchema = z.object({ id: z.string().min(1, 'ID is required'), }); + +// Comment schemas for the new generic comments API +export const createCommentSchema = z.object({ + content: z.string().min(1, 'Comment content is required'), + entityId: z.string(), + entityType: z.nativeEnum(CommentEntityType), + attachments: z + .array( + z.object({ + fileName: z.string(), + fileType: z.string(), + fileData: z.string(), // base64 + }), + ) + .optional(), +}); + +export type CreateCommentSchema = z.infer; + +export const updateCommentSchema = z.object({ + commentId: z.string(), + content: z.string().min(1, 'Comment content is required'), +}); + +export type UpdateCommentSchema = z.infer; + +export const deleteCommentSchema = z.object({ + commentId: z.string(), +}); + +export type DeleteCommentSchema = z.infer; diff --git a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PolicyPage.tsx b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PolicyPage.tsx index a1e98b434..341212e60 100644 --- a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PolicyPage.tsx +++ b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PolicyPage.tsx @@ -1,6 +1,6 @@ import { Control, Member, Policy, User } from '@db'; import type { JSONContent } from '@tiptap/react'; -import { Comments, CommentWithAuthor } from '../../../../../../components/comments/Comments'; +import { Comments } from '../../../../../../components/comments/Comments'; import { AuditLogWithRelations } from '../data'; import { PolicyPageEditor } from '../editor/components/PolicyDetails'; import { PolicyOverview } from './PolicyOverview'; @@ -14,7 +14,6 @@ export default function PolicyPage({ isPendingApproval, policyId, logs, - comments, }: { policy: (Policy & { approver: (Member & { user: User }) | null }) | null; assignees: (Member & { user: User })[]; @@ -23,7 +22,6 @@ export default function PolicyPage({ isPendingApproval: boolean; policyId: string; logs: AuditLogWithRelations[]; - comments: CommentWithAuthor[]; }) { return ( <> @@ -42,7 +40,7 @@ export default function PolicyPage({ - + ); } diff --git a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/data/index.ts b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/data/index.ts index 76b4eb300..1bcbb8393 100644 --- a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/data/index.ts +++ b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/data/index.ts @@ -186,8 +186,21 @@ export const getComments = async (policyId: string): Promise ({ + id: att.id, + name: att.name, + type: att.type, + downloadUrl: att.url || '', // assuming url maps to downloadUrl + createdAt: att.createdAt.toISOString(), + })), + createdAt: comment.createdAt.toISOString(), }; }), ); diff --git a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/page.tsx b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/page.tsx index d84cf93f4..ca94a8805 100644 --- a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/page.tsx @@ -1,13 +1,7 @@ import PageWithBreadcrumb from '@/components/pages/PageWithBreadcrumb'; import type { Metadata } from 'next'; import PolicyPage from './components/PolicyPage'; -import { - getAssignees, - getComments, - getLogsForPolicy, - getPolicy, - getPolicyControlMappingInfo, -} from './data'; +import { getAssignees, getLogsForPolicy, getPolicy, getPolicyControlMappingInfo } from './data'; export default async function PolicyDetails({ params, @@ -18,7 +12,6 @@ export default async function PolicyDetails({ const policy = await getPolicy(policyId); const assignees = await getAssignees(); - const comments = await getComments(policyId); const { mappedControls, allControls } = await getPolicyControlMappingInfo(policyId); const logs = await getLogsForPolicy(policyId); @@ -39,7 +32,6 @@ export default async function PolicyDetails({ allControls={allControls} isPendingApproval={isPendingApproval} logs={logs} - comments={comments} /> ); diff --git a/apps/app/src/app/(app)/[orgId]/risk/[riskId]/page.tsx b/apps/app/src/app/(app)/[orgId]/risk/[riskId]/page.tsx index da975de93..af51b6fdd 100644 --- a/apps/app/src/app/(app)/[orgId]/risk/[riskId]/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/risk/[riskId]/page.tsx @@ -3,12 +3,12 @@ import { InherentRiskChart } from '@/components/risks/charts/InherentRiskChart'; import { ResidualRiskChart } from '@/components/risks/charts/ResidualRiskChart'; import { RiskOverview } from '@/components/risks/risk-overview'; import { auth } from '@/utils/auth'; -import { AttachmentEntityType, CommentEntityType, db } from '@db'; +import { CommentEntityType, db } from '@db'; import type { Metadata } from 'next'; import { headers } from 'next/headers'; import { redirect } from 'next/navigation'; import { cache } from 'react'; -import { Comments, CommentWithAuthor } from '../../../../../components/comments/Comments'; +import { Comments } from '../../../../../components/comments/Comments'; interface PageProps { searchParams: Promise<{ @@ -24,7 +24,6 @@ interface PageProps { export default async function RiskPage({ searchParams, params }: PageProps) { const { riskId, orgId } = await params; const risk = await getRisk(riskId); - const comments = await getComments(riskId); const assignees = await getAssignees(); if (!risk) { redirect('/'); @@ -43,7 +42,7 @@ export default async function RiskPage({ searchParams, params }: PageProps) { - + ); @@ -75,54 +74,7 @@ const getRisk = cache(async (riskId: string) => { return risk; }); -const getComments = async (riskId: string): Promise => { - const session = await auth.api.getSession({ - headers: await headers(), - }); - - const activeOrgId = session?.session.activeOrganizationId; - - if (!activeOrgId) { - console.warn('Could not determine active organization ID in getComments'); - return []; - } - - const comments = await db.comment.findMany({ - where: { - organizationId: activeOrgId, - entityId: riskId, - entityType: CommentEntityType.risk, - }, - include: { - author: { - include: { - user: true, - }, - }, - }, - orderBy: { - createdAt: 'desc', - }, - }); - - const commentsWithAttachments = await Promise.all( - comments.map(async (comment) => { - const attachments = await db.attachment.findMany({ - where: { - organizationId: activeOrgId, - entityId: comment.id, - entityType: AttachmentEntityType.comment, - }, - }); - return { - ...comment, - attachments, - }; - }), - ); - return commentsWithAttachments; -}; const getAssignees = cache(async () => { const session = await auth.api.getSession({ diff --git a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/SingleTask.tsx b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/SingleTask.tsx index edbbaa264..56eef4e14 100644 --- a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/SingleTask.tsx +++ b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/SingleTask.tsx @@ -1,9 +1,8 @@ 'use client'; import { Card } from '@comp/ui/card'; -import type { Attachment, Member, Task, User } from '@db'; +import type { Member, Task, User } from '@db'; import { useMemo, useState } from 'react'; -import { CommentWithAuthor } from '../../../../../../components/comments/Comments'; import { updateTask } from '../../actions/updateTask'; import { TaskDeleteDialog } from './TaskDeleteDialog'; import { TaskMainContent } from './TaskMainContent'; @@ -12,11 +11,9 @@ import { TaskPropertiesSidebar } from './TaskPropertiesSidebar'; interface SingleTaskProps { task: Task & { fileUrls?: string[] }; members?: (Member & { user: User })[]; - comments: CommentWithAuthor[]; - attachments: Attachment[]; } -export function SingleTask({ task, members, comments, attachments }: SingleTaskProps) { +export function SingleTask({ task, members }: SingleTaskProps) { const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const assignedMember = useMemo(() => { @@ -50,7 +47,7 @@ export function SingleTask({ task, members, comments, attachments }: SingleTaskP return ( - + ) => void; onDescriptionChange: (event: React.ChangeEvent) => void; disabled?: boolean; - onAttachmentsChange?: () => void; } // Helper function to provide user-friendly error messages -function getErrorMessage(errorMessage: string, errorCode?: string): string { - switch (errorCode) { - case 'FILE_TOO_LARGE': - return 'The file is too large. Please choose a file smaller than 10MB.'; - case 'S3_CLIENT_UNAVAILABLE': - case 'S3_BUCKET_NOT_CONFIGURED': - return 'File upload service is temporarily unavailable. Please try again later.'; - case 'UNAUTHORIZED': - return 'You are not authorized to upload files. Please refresh the page and try again.'; - default: - return errorMessage || 'Failed to upload file. Please try again.'; - } +function getErrorMessage(errorMessage: string): string { + // Simplified error handling since API errors are already user-friendly + return errorMessage || 'Failed to upload file. Please try again.'; } export function TaskBody({ taskId, title, description, - attachments = [], onTitleChange, onDescriptionChange, disabled, - onAttachmentsChange, }: TaskBodyProps) { const fileInputRef = useRef(null); + const textareaRef = useRef(null); const [isUploading, setIsUploading] = useState(false); const [busyAttachmentId, setBusyAttachmentId] = useState(null); - const router = useRouter(); - const resetState = () => { - setIsUploading(false); - if (fileInputRef.current) { - fileInputRef.current.value = ''; + // Auto-resize function for textarea + const autoResizeTextarea = useCallback(() => { + const textarea = textareaRef.current; + if (textarea) { + textarea.style.height = 'auto'; + textarea.style.height = `${Math.max(80, textarea.scrollHeight)}px`; } - }; + }, []); - const handleFileSelect = useCallback( - async (event: React.ChangeEvent) => { - const files = event.target.files; - if (!files || files.length === 0) return; - setIsUploading(true); - try { - for (const file of Array.from(files)) { - const MAX_FILE_SIZE_MB = 10; - const MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024; - if (file.size > MAX_FILE_SIZE_BYTES) { - toast.error(`File "${file.name}" exceeds the ${MAX_FILE_SIZE_MB}MB limit.`); - continue; - } + // Auto-resize on mount and when description changes + useEffect(() => { + // Use requestAnimationFrame to ensure the DOM is ready + const resizeTimeout = requestAnimationFrame(() => { + autoResizeTextarea(); + }); - const reader = new FileReader(); - reader.onloadend = async () => { - const base64Data = (reader.result as string)?.split(',')[1]; - if (!base64Data) { - toast.error('Failed to read file data.'); - resetState(); - return; - } + return () => cancelAnimationFrame(resizeTimeout); + }, [description, autoResizeTextarea]); - const result = await uploadFile({ - fileName: file.name, - fileType: file.type, - fileData: base64Data, - entityId: taskId, - entityType: AttachmentEntityType.task, - }); + // Use SWR to fetch attachments with real-time updates + const { + data: attachmentsData, + error: attachmentsError, + isLoading: attachmentsLoading, + mutate: refreshAttachments, + } = useTaskAttachments(taskId); - if (result.success) { - toast.success('File uploaded successfully.'); - onAttachmentsChange?.(); - router.refresh(); - } else { - console.error('File upload failed:', result.error, result.code); + // Use API hooks for mutations + const { uploadAttachment, getDownloadUrl, deleteAttachment } = useTaskAttachmentActions(taskId); - // Provide user-friendly error messages based on error codes - const userFriendlyMessage = getErrorMessage(result.error, result.code); - toast.error(userFriendlyMessage); - } - }; - reader.onerror = () => { - toast.error('Error reading file.'); - resetState(); - }; - reader.readAsDataURL(file); - } - } finally { - // This finally block might run before all file readers are done. - // It's better to manage the loading state inside the onloadend. - } - }, - [taskId, onAttachmentsChange, router], - ); + // Extract attachments from SWR response + const attachments = attachmentsData?.data || []; + + const resetState = () => { + setIsUploading(false); + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + }; - // A better way to handle multiple file uploads + // Handle multiple file uploads using API const handleFileSelectMultiple = useCallback( async (event: React.ChangeEvent) => { const files = event.target.files; @@ -128,57 +89,40 @@ export function TaskBody({ setIsUploading(true); const uploadPromises = Array.from(files).map((file) => { - return new Promise((resolve, reject) => { + return new Promise((resolve) => { const MAX_FILE_SIZE_MB = 10; const MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024; if (file.size > MAX_FILE_SIZE_BYTES) { toast.error(`File "${file.name}" exceeds the ${MAX_FILE_SIZE_MB}MB limit.`); return resolve(null); // Resolve to skip this file } - const reader = new FileReader(); - reader.onloadend = async () => { - try { - const base64Data = (reader.result as string)?.split(',')[1]; - if (!base64Data) { - throw new Error('Failed to read file data.'); - } - const result = await uploadFile({ - fileName: file.name, - fileType: file.type, - fileData: base64Data, - entityId: taskId, - entityType: AttachmentEntityType.task, - }); - if (result.success) { - toast.success(`File "${file.name}" uploaded successfully.`); - resolve(result); - } else { - const userFriendlyMessage = getErrorMessage(result.error, result.code); - throw new Error(userFriendlyMessage); - } - } catch (error) { + + // Use the API hook's uploadAttachment method + uploadAttachment(file) + .then((result) => { + toast.success(`File "${file.name}" uploaded successfully.`); + // Refresh attachments via SWR after successful upload + refreshAttachments(); + resolve(result); + }) + .catch((error) => { console.error(`Failed to upload ${file.name}:`, error); - toast.error( - `Failed to upload ${file.name}: ${error instanceof Error ? error.message : 'Unknown error'}`, + const userFriendlyMessage = getErrorMessage( + error instanceof Error ? error.message : 'Unknown error', ); + toast.error(`Failed to upload ${file.name}: ${userFriendlyMessage}`); resolve(null); // Resolve even if there's an error to not break Promise.all - } - }; - reader.onerror = () => { - toast.error(`Error reading file "${file.name}".`); - resolve(null); - }; - reader.readAsDataURL(file); + }); }); }); await Promise.all(uploadPromises); - onAttachmentsChange?.(); - router.refresh(); + // Refresh attachments via SWR instead of manual router refresh + refreshAttachments(); resetState(); }, - [taskId, onAttachmentsChange, router], + [uploadAttachment, refreshAttachments], ); const triggerFileInput = () => { @@ -188,17 +132,11 @@ export function TaskBody({ const handleDownloadClick = async (attachmentId: string) => { setBusyAttachmentId(attachmentId); try { - const { success, data, error } = await getTaskAttachmentUrl({ - attachmentId, - }); - - if (success && data?.signedUrl) { - window.open(data.signedUrl, '_blank'); - } else { - toast.error(String(error || 'Failed to get attachment URL.')); - } - } catch (err) { - toast.error('An unexpected error occurred while fetching the attachment URL.'); + const downloadUrl = await getDownloadUrl(attachmentId); + window.open(downloadUrl, '_blank'); + } catch (error) { + console.error('Failed to get download URL:', error); + toast.error('Failed to get download URL. Please try again.'); } finally { setBusyAttachmentId(null); } @@ -207,13 +145,19 @@ export function TaskBody({ const handleDeleteAttachment = useCallback( async (attachmentId: string) => { setBusyAttachmentId(attachmentId); - await deleteTaskAttachment({ attachmentId }); - - setBusyAttachmentId(null); - onAttachmentsChange?.(); - router.refresh(); + try { + await deleteAttachment(attachmentId); + toast.success('Attachment deleted successfully.'); + // Refresh attachments via SWR instead of manual router refresh + refreshAttachments(); + } catch (error) { + console.error('Failed to delete attachment:', error); + toast.error('Failed to delete attachment. Please try again.'); + } finally { + setBusyAttachmentId(null); + } }, - [onAttachmentsChange, router], + [deleteAttachment, refreshAttachments], ); return ( @@ -226,11 +170,20 @@ export function TaskBody({ disabled={disabled || isUploading || !!busyAttachmentId} />