From e1e3152e56356d253b03c483eeffc9b93da34179 Mon Sep 17 00:00:00 2001 From: Claudio Fuentes Date: Mon, 4 Aug 2025 19:34:41 -0400 Subject: [PATCH 01/36] chore: enhance buildspec and next.config for improved build verification and caching - Updated buildspec.yml to clear Next.js cache before builds, verify S3 uploads, and invalidate CloudFront cache, ensuring fresh deployments and robust error handling. - Modified next.config.ts to prevent caching of POST requests and improve server action stability, enhancing performance and reliability. --- apps/portal/buildspec.yml | 46 +++++++++++++++++++++++++++++++++----- apps/portal/next.config.ts | 38 ++++++++++++++++++++++++++++++- 2 files changed, 77 insertions(+), 7 deletions(-) diff --git a/apps/portal/buildspec.yml b/apps/portal/buildspec.yml index 458eeaec8..1417b2612 100644 --- a/apps/portal/buildspec.yml +++ b/apps/portal/buildspec.yml @@ -43,6 +43,7 @@ phases: # Build Next.js portal app - echo "Building Next.js portal application..." - cd apps/$APP_NAME + - rm -rf .next/cache/ - NODE_TLS_REJECT_UNAUTHORIZED=0 bun run build # Upload Next.js chunks and CSS to S3 for CDN performance @@ -50,10 +51,26 @@ phases: - | if [ -d ".next/static" ]; then echo "๐Ÿ“ฆ Uploading .next/static/ files to CDN..." + LOCAL_CHUNK_COUNT=$(find .next/static -name "*.js" | wc -l) + echo "Local chunks: $LOCAL_CHUNK_COUNT" aws s3 sync .next/static/ s3://$STATIC_ASSETS_BUCKET/portal/_next/static/ \ --cache-control "public, max-age=31536000, immutable" \ --exclude "*.map" echo "โœ… Uploaded Next.js static assets to S3" + + # Verify upload + S3_CHUNK_COUNT=$(aws s3 ls s3://$STATIC_ASSETS_BUCKET/portal/_next/static/ --recursive | grep ".js$" | wc -l) + echo "S3 chunks: $S3_CHUNK_COUNT" + if [ "$S3_CHUNK_COUNT" -lt "$LOCAL_CHUNK_COUNT" ]; then + echo "โŒ S3 upload verification failed: Missing chunks" + exit 1 + fi + + # Invalidate CloudFront cache for static assets + if [ -n "$CLOUDFRONT_DISTRIBUTION_ID" ]; then + echo "Invalidating CloudFront cache..." + aws cloudfront create-invalidation --distribution-id $CLOUDFRONT_DISTRIBUTION_ID --paths "/portal/_next/static/*" + fi else echo "โš ๏ธ No .next/static directory found" fi @@ -88,14 +105,14 @@ phases: # Use the standalone build properly: copy from .next/standalone + app's own build - echo "DEBUG - Building container from standalone + app build..." - # Copy the app's own built files (server.js, etc.) + # Copy standalone build first (includes server actions) + - echo "Copying standalone build..." + - cp -r .next/standalone/* container-build/ || echo "Standalone copy failed" + + # Then copy app's own .next build to ensure server actions are included - echo "Copying app's own .next build..." - cp -r .next container-build/ || echo "App .next copy failed" - # Copy shared node_modules from standalone (minimal runtime deps) - - echo "Copying standalone node_modules (runtime dependencies)..." - - cp -r .next/standalone/node_modules container-build/ || echo "Standalone node_modules copy failed" - # Copy or create server.js for standalone - | if [ -f ".next/standalone/apps/$APP_NAME/server.js" ]; then @@ -134,6 +151,19 @@ phases: - test -f container-build/server.js && echo "โœ… Server.js exists" || echo "โŒ 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" + + # Critical: Verify static files and server files exist + - | + if [ ! -d "container-build/.next/static" ]; then + echo "โŒ Missing .next/static directory in container build" + exit 1 + fi + - | + if [ ! -d "container-build/.next/server" ]; then + echo "โŒ Missing .next/server directory in container build" + exit 1 + fi + - echo "โœ… Container build verification passed" # Add Dockerfile to the standalone build - cp Dockerfile container-build/ || echo "No Dockerfile" @@ -173,8 +203,13 @@ phases: # Build Docker image (static assets now served from S3) - echo "Building Docker image..." + - sleep 5 - 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 startup + - echo "Testing container startup..." + - timeout 30 docker run --rm -e NODE_ENV=production $ECR_REPOSITORY_URI:$IMAGE_TAG node --version || echo "Container test completed" post_build: commands: @@ -190,7 +225,6 @@ cache: - 'node_modules/**/*' - 'packages/db/node_modules/**/*' - '/root/.bun/install/cache/**/*' - - '.next/cache/**/*' - 'bun.lock' artifacts: diff --git a/apps/portal/next.config.ts b/apps/portal/next.config.ts index 124f81de0..90de1d0bc 100644 --- a/apps/portal/next.config.ts +++ b/apps/portal/next.config.ts @@ -9,7 +9,16 @@ const config: NextConfig = { transpilePackages: ['@trycompai/db'], outputFileTracingRoot: path.join(__dirname, '../../'), outputFileTracingIncludes: { - '/api/**/*': ['./prisma/**/*'], + '/**/*': ['./src/actions/**/*', './node_modules/.prisma/client/**/*'], + }, + generateEtags: false, + experimental: { + serverActions: { + allowedOrigins: process.env.NODE_ENV === 'production' + ? [process.env.NEXT_PUBLIC_BETTER_AUTH_URL].filter(Boolean) as string[] + : undefined, + }, + optimizePackageImports: ['@trycompai/db'], }, images: { remotePatterns: [ @@ -62,6 +71,33 @@ const config: NextConfig = { }, ]; }, + async headers() { + return [ + { + source: '/:path*', + has: [ + { + type: 'header', + key: 'Next-Action', + }, + ], + headers: [ + { + key: 'Cache-Control', + value: 'no-cache, no-store, must-revalidate', + }, + { + key: 'Pragma', + value: 'no-cache', + }, + { + key: 'Expires', + value: '0', + }, + ], + }, + ]; + }, skipTrailingSlashRedirect: true, }; From 1a9ef99cba3e206d0493a82dea7027a6eff234b6 Mon Sep 17 00:00:00 2001 From: Claudio Fuentes Date: Tue, 5 Aug 2025 03:23:55 -0400 Subject: [PATCH 02/36] chore: clean up buildspec and next.config for improved readability - Removed unnecessary blank lines in buildspec.yml to enhance clarity and maintainability. - Reformatted allowedOrigins in next.config.ts for better readability while preserving functionality. --- apps/portal/buildspec.yml | 7 +++---- apps/portal/next.config.ts | 7 ++++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/apps/portal/buildspec.yml b/apps/portal/buildspec.yml index 1417b2612..c8aa83a9a 100644 --- a/apps/portal/buildspec.yml +++ b/apps/portal/buildspec.yml @@ -108,7 +108,7 @@ phases: # Copy standalone build first (includes server actions) - echo "Copying standalone build..." - cp -r .next/standalone/* container-build/ || echo "Standalone copy failed" - + # Then copy app's own .next build to ensure server actions are included - echo "Copying app's own .next build..." - cp -r .next container-build/ || echo "App .next copy failed" @@ -151,7 +151,7 @@ phases: - test -f container-build/server.js && echo "โœ… Server.js exists" || echo "โŒ 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" - + # Critical: Verify static files and server files exist - | if [ ! -d "container-build/.next/static" ]; then @@ -164,7 +164,6 @@ phases: exit 1 fi - echo "โœ… Container build verification passed" - # Add Dockerfile to the standalone build - cp Dockerfile container-build/ || echo "No Dockerfile" @@ -206,7 +205,7 @@ phases: - sleep 5 - 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 startup - echo "Testing container startup..." - timeout 30 docker run --rm -e NODE_ENV=production $ECR_REPOSITORY_URI:$IMAGE_TAG node --version || echo "Container test completed" diff --git a/apps/portal/next.config.ts b/apps/portal/next.config.ts index 90de1d0bc..e073b1efe 100644 --- a/apps/portal/next.config.ts +++ b/apps/portal/next.config.ts @@ -14,9 +14,10 @@ const config: NextConfig = { generateEtags: false, experimental: { serverActions: { - allowedOrigins: process.env.NODE_ENV === 'production' - ? [process.env.NEXT_PUBLIC_BETTER_AUTH_URL].filter(Boolean) as string[] - : undefined, + allowedOrigins: + process.env.NODE_ENV === 'production' + ? ([process.env.NEXT_PUBLIC_BETTER_AUTH_URL].filter(Boolean) as string[]) + : undefined, }, optimizePackageImports: ['@trycompai/db'], }, From 51162ac03463ea66415e7a7c33e2e1421f04d64a Mon Sep 17 00:00:00 2001 From: Claudio Fuentes Date: Tue, 5 Aug 2025 06:01:27 -0400 Subject: [PATCH 03/36] feat: implement comments and attachments functionality in API - Added CommentsModule and CommentsService to handle comment-related operations. - Introduced AttachmentsModule and AttachmentsService for managing file attachments. - Enhanced API with new endpoints for creating, updating, and deleting comments, including support for attachments. - Integrated AWS S3 for file storage and retrieval, ensuring secure and efficient handling of attachments. - Updated app.module.ts to include new modules and configured global settings for environment variables. - Refactored existing services and controllers to accommodate new comment and attachment features, improving overall functionality and user experience. --- apps/api/eslint.config.mjs | 9 +- apps/api/package.json | 4 +- apps/api/src/app.module.ts | 21 +- .../api/src/attachments/attachments.module.ts | 8 + .../src/attachments/attachments.service.ts | 269 +++++++++++++ .../src/attachments/upload-attachment.dto.ts | 66 ++++ apps/api/src/auth/auth-context.decorator.ts | 78 ++++ apps/api/src/auth/auth.module.ts | 5 +- apps/api/src/auth/hybrid-auth.guard.ts | 156 ++++++++ apps/api/src/auth/types.ts | 40 ++ apps/api/src/comments/comments.controller.ts | 169 ++++++++ apps/api/src/comments/comments.module.ts | 13 + apps/api/src/comments/comments.service.ts | 364 ++++++++++++++++++ .../src/comments/dto/comment-responses.dto.ts | 91 +++++ .../src/comments/dto/create-comment.dto.ts | 52 +++ .../src/comments/dto/update-comment.dto.ts | 14 + apps/api/src/config/aws.config.ts | 33 ++ apps/api/src/health/health.controller.ts | 2 +- apps/api/src/health/health.module.ts | 2 +- apps/api/src/main.ts | 28 +- .../organization/organization.controller.ts | 58 ++- .../src/organization/organization.service.ts | 2 +- apps/api/src/tasks/attachments.service.ts | 269 +++++++++++++ apps/api/src/tasks/dto/task-responses.dto.ts | 81 ++++ .../src/tasks/dto/upload-attachment.dto.ts | 66 ++++ apps/api/src/tasks/tasks.controller.ts | 302 ++++++++------- apps/api/src/tasks/tasks.module.ts | 9 +- apps/api/src/tasks/tasks.service.ts | 237 +++--------- apps/api/test/app.e2e-spec.ts | 4 +- apps/app/package.json | 1 + .../app/src/actions/comments/createComment.ts | 221 +++-------- .../app/src/actions/comments/deleteComment.ts | 147 ++----- .../app/src/actions/comments/updateComment.ts | 194 +++------- apps/app/src/actions/files/upload-file.ts | 258 ++++--------- apps/app/src/actions/schema.ts | 31 ++ .../[orgId]/policies/[policyId]/data/index.ts | 17 +- .../app/(app)/[orgId]/risk/[riskId]/page.tsx | 17 +- .../tasks/[taskId]/components/SingleTask.tsx | 9 +- .../tasks/[taskId]/components/TaskBody.tsx | 278 ++++++------- .../[taskId]/components/TaskMainContent.tsx | 38 +- .../[taskId]/components/commentUtils.tsx | 7 +- .../app/(app)/[orgId]/tasks/[taskId]/page.tsx | 75 +--- .../(app)/[orgId]/vendors/[vendorId]/page.tsx | 17 +- .../src/components/comments/CommentForm.tsx | 262 ++++++------- .../src/components/comments/CommentItem.tsx | 319 +++------------ .../src/components/comments/CommentList.tsx | 10 +- apps/app/src/components/comments/Comments.tsx | 23 +- .../examples/TaskDetailApiExample.tsx | 339 ++++++++++++++++ apps/app/src/hooks/use-api-swr.ts | 138 +++++++ apps/app/src/hooks/use-api.ts | 141 +++++++ apps/app/src/hooks/use-comments-api.ts | 228 +++++++++++ apps/app/src/hooks/use-tasks-api.ts | 200 ++++++++++ apps/app/src/lib/api-client.ts | 126 ++++++ apps/app/src/utils/format.ts | 5 +- bun.lock | 16 +- packages/email/tsconfig.json | 2 +- 56 files changed, 3955 insertions(+), 1616 deletions(-) create mode 100644 apps/api/src/attachments/attachments.module.ts create mode 100644 apps/api/src/attachments/attachments.service.ts create mode 100644 apps/api/src/attachments/upload-attachment.dto.ts create mode 100644 apps/api/src/auth/auth-context.decorator.ts create mode 100644 apps/api/src/auth/hybrid-auth.guard.ts create mode 100644 apps/api/src/auth/types.ts create mode 100644 apps/api/src/comments/comments.controller.ts create mode 100644 apps/api/src/comments/comments.module.ts create mode 100644 apps/api/src/comments/comments.service.ts create mode 100644 apps/api/src/comments/dto/comment-responses.dto.ts create mode 100644 apps/api/src/comments/dto/create-comment.dto.ts create mode 100644 apps/api/src/comments/dto/update-comment.dto.ts create mode 100644 apps/api/src/config/aws.config.ts create mode 100644 apps/api/src/tasks/attachments.service.ts create mode 100644 apps/api/src/tasks/dto/task-responses.dto.ts create mode 100644 apps/api/src/tasks/dto/upload-attachment.dto.ts create mode 100644 apps/app/src/components/examples/TaskDetailApiExample.tsx create mode 100644 apps/app/src/hooks/use-api-swr.ts create mode 100644 apps/app/src/hooks/use-api.ts create mode 100644 apps/app/src/hooks/use-comments-api.ts create mode 100644 apps/app/src/hooks/use-tasks-api.ts create mode 100644 apps/app/src/lib/api-client.ts 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..d0f229234 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -18,10 +18,12 @@ "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" }, "dependencies": { "@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", 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..5fa22c467 --- /dev/null +++ b/apps/api/src/attachments/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 { + BadRequestException, + Injectable, + InternalServerErrorException, +} from '@nestjs/common'; +import { AttachmentEntityType, AttachmentType } from '@prisma/client'; +import { db } from '@trycompai/db'; +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..9755c8724 --- /dev/null +++ b/apps/api/src/auth/hybrid-auth.guard.ts @@ -0,0 +1,156 @@ +import { + CanActivate, + ExecutionContext, + Injectable, + UnauthorizedException, +} from '@nestjs/common'; +import { db } from '@trycompai/db'; +import { ApiKeyService } from './api-key.service'; +import { AuthenticatedRequest, BetterAuthSessionResponse } 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 Better Auth Session authentication (for internal frontend) + const cookies = request.headers['cookie'] as string | undefined; + if (cookies) { + return this.handleSessionAuth(request, cookies); + } + + throw new UnauthorizedException( + 'Authentication required: Provide either X-API-Key or valid session', + ); + } + + 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 handleSessionAuth( + request: AuthenticatedRequest, + cookies: string, + ): Promise { + // Validate Better Auth session + const session = await this.validateBetterAuthSession(cookies); + if (!session?.user) { + throw new UnauthorizedException('Invalid or expired session'); + } + + // Get organization ID from explicit header OR session fallback + const explicitOrgId = request.headers['x-organization-id'] as string; + const sessionOrgId = session.session.activeOrganizationId; + + const organizationId = explicitOrgId || sessionOrgId; + + if (!organizationId) { + throw new UnauthorizedException( + 'Organization context required: Provide X-Organization-Id header or ensure session has active organization', + ); + } + + // Critical: Verify user has access to the requested organization + const hasAccess = await this.verifyUserOrgAccess( + session.user.id, + organizationId, + ); + if (!hasAccess) { + throw new UnauthorizedException( + `User does not have access to organization: ${organizationId}`, + ); + } + + // Set request context for session auth + request.userId = session.user.id; + request.userEmail = session.user.email; + request.organizationId = organizationId; + request.authType = 'session'; + request.isApiKey = false; + + return true; + } + + /** + * Validate Better Auth session by calling the auth API + */ + private async validateBetterAuthSession( + cookies: string, + ): Promise { + try { + // Call Better Auth session endpoint + const response = await fetch( + `${process.env.BETTER_AUTH_URL}/api/auth/get-session`, + { + headers: { + Cookie: cookies, + }, + }, + ); + + if (!response.ok) { + return null; + } + + const sessionData = (await response.json()) as BetterAuthSessionResponse; + return sessionData; + } catch (error: unknown) { + console.error('Error validating Better Auth session:', error); + return null; + } + } + + /** + * 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..22371ee19 --- /dev/null +++ b/apps/api/src/auth/types.ts @@ -0,0 +1,40 @@ +export interface BetterAuthUser { + id: string; + email: string; + name: string; + emailVerified: boolean; + image?: string; + createdAt: Date; + updatedAt: Date; +} + +export interface BetterAuthSession { + id: string; + userId: string; + activeOrganizationId?: string; + expiresAt: Date; + token: string; + ipAddress?: string; + userAgent?: string; +} + +export interface BetterAuthSessionResponse { + user: BetterAuthUser; + session: BetterAuthSession; +} + +export interface AuthenticatedRequest extends Request { + organizationId: string; + authType: 'api-key' | 'session'; + isApiKey: boolean; + userId?: string; + userEmail?: string; +} + +export interface AuthContext { + organizationId: string; + authType: 'api-key' | 'session'; + isApiKey: boolean; + userId?: string; // Only available for session auth + userEmail?: string; // Only available for session 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..835d5dcc7 --- /dev/null +++ b/apps/api/src/comments/comments.controller.ts @@ -0,0 +1,169 @@ +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 { CommentEntityType } from '@prisma/client'; +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..3068c5a54 --- /dev/null +++ b/apps/api/src/comments/comments.service.ts @@ -0,0 +1,364 @@ +import { + BadRequestException, + Injectable, + InternalServerErrorException, +} from '@nestjs/common'; +import { AttachmentEntityType, CommentEntityType } from '@prisma/client'; +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..f0573b96a --- /dev/null +++ b/apps/api/src/comments/dto/create-comment.dto.ts @@ -0,0 +1,52 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { CommentEntityType } from '@prisma/client'; +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[]; +} \ No newline at end of file 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..269edb1a0 --- /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 { + BadRequestException, + Injectable, + InternalServerErrorException, +} from '@nestjs/common'; +import { AttachmentEntityType, AttachmentType } from '@prisma/client'; +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..0d832bdd3 100644 --- a/apps/api/src/tasks/tasks.controller.ts +++ b/apps/api/src/tasks/tasks.controller.ts @@ -1,4 +1,5 @@ import { + BadRequestException, Body, Controller, Delete, @@ -6,223 +7,252 @@ 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 { AttachmentEntityType } from '@prisma/client'; +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/app/package.json b/apps/app/package.json index 4f6a83534..3a9c53556 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -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/createComment.ts b/apps/app/src/actions/comments/createComment.ts index d4d7d415b..e1c4065cb 100644 --- a/apps/app/src/actions/comments/createComment.ts +++ b/apps/app/src/actions/comments/createComment.ts @@ -1,188 +1,59 @@ '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'], +import { authActionClient } from '@/actions/safe-action'; +import { createCommentSchema } from '@/actions/schema'; +import { env } from '@/env.mjs'; + +const API_BASE_URL = env.NEXT_PUBLIC_API_URL || 'http://localhost:3333'; + +export const createComment = authActionClient + .inputSchema(createCommentSchema) + .metadata({ + name: 'create-comment', + track: { + event: 'create-comment', + channel: 'server', + description: 'User created a comment', }, - ); - -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 }, - }); + }) + .action(async ({ parsedInput, ctx }) => { + const { content, entityId, entityType, attachments } = parsedInput; + const { session } = ctx; - if (!member) { - return { - success: false, - error: 'Not authorized - member not found in organization.', - data: null, - }; + if (!session.activeOrganizationId) { + throw new Error('No active organization'); } - // 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 ?? '', + try { + // Call the new generic comments API + const response = await fetch(`${API_BASE_URL}/v1/comments`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Organization-Id': session.activeOrganizationId, + Cookie: `better-auth.session_token=${session.token}`, + }, + body: JSON.stringify({ + content, entityId, entityType, - authorId: member.id, - organizationId: orgId, - }, + attachments, + }), }); - // 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, - }, - }); - } + if (!response.ok) { + const error = await response.text(); + throw new Error(`Failed to create comment: ${error}`); } - return comment; - }); + const newComment = await response.json(); - 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, - }; - } -}; + return { + success: true, + data: newComment, + }; + } catch (error) { + console.error('Failed to create comment:', error); + throw error; + } + }); diff --git a/apps/app/src/actions/comments/deleteComment.ts b/apps/app/src/actions/comments/deleteComment.ts index b6ce0b348..e26edf1a8 100644 --- a/apps/app/src/actions/comments/deleteComment.ts +++ b/apps/app/src/actions/comments/deleteComment.ts @@ -1,114 +1,51 @@ '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', - }; - } +import { authActionClient } from '@/actions/safe-action'; +import { deleteCommentSchema } from '@/actions/schema'; +import { env } from '@/env.mjs'; + +const API_BASE_URL = env.NEXT_PUBLIC_API_URL || 'http://localhost:3333'; + +export const deleteComment = authActionClient + .inputSchema(deleteCommentSchema) + .metadata({ + name: 'delete-comment', + track: { + event: 'delete-comment', + channel: 'server', + description: 'User deleted a comment', + }, + }) + .action(async ({ parsedInput, ctx }) => { + const { commentId } = parsedInput; + const { session } = ctx; + + if (!session.activeOrganizationId) { + throw new Error('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 }, + try { + // Call the new generic comments API + const response = await fetch(`${API_BASE_URL}/v1/comments/${commentId}`, { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + 'X-Organization-Id': session.activeOrganizationId, + Cookie: `better-auth.session_token=${session.token}`, }, - }, - }); + }); - if (!comment) { - return { - success: false, - error: 'Comment not found or access denied', - }; - } + if (!response.ok) { + const error = await response.text(); + throw new Error(`Failed to delete comment: ${error}`); + } - // 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', + success: true, + data: { deletedCommentId: commentId }, }; + } catch (error) { + console.error('Failed to delete comment:', error); + throw error; } - - 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/updateComment.ts b/apps/app/src/actions/comments/updateComment.ts index 1abe3b3b1..a1c528be0 100644 --- a/apps/app/src/actions/comments/updateComment.ts +++ b/apps/app/src/actions/comments/updateComment.ts @@ -1,160 +1,54 @@ '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(), +import { authActionClient } from '@/actions/safe-action'; +import { updateCommentSchema } from '@/actions/schema'; +import { env } from '@/env.mjs'; + +const API_BASE_URL = env.NEXT_PUBLIC_API_URL || 'http://localhost:3333'; + +export const updateComment = authActionClient + .inputSchema(updateCommentSchema) + .metadata({ + name: 'update-comment', + track: { + event: 'update-comment', + channel: 'server', + description: 'User updated a comment', + }, }) - .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; + .action(async ({ parsedInput, ctx }) => { + const { commentId, content } = parsedInput; + const { session } = ctx; - if (!organizationId) { - return { - success: false, - error: 'Not authorized', - data: null, - }; - } + if (!session.activeOrganizationId) { + throw new Error('No active organization'); + } - 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 }, - }); + try { + // Call the new generic comments API + const response = await fetch(`${API_BASE_URL}/v1/comments/${commentId}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'X-Organization-Id': session.activeOrganizationId, + Cookie: `better-auth.session_token=${session.token}`, + }, + body: JSON.stringify({ content }), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Failed to update comment: ${error}`); + } - if (!comment) { - return { - success: false, - error: 'Comment not found', - data: null, - }; - } + const updatedComment = await response.json(); - // 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, + success: true, + data: updatedComment, }; + } catch (error) { + console.error('Failed to update comment:', error); + throw error; } - - // --- 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]/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]/risk/[riskId]/page.tsx b/apps/app/src/app/(app)/[orgId]/risk/[riskId]/page.tsx index da975de93..4a4e6780b 100644 --- a/apps/app/src/app/(app)/[orgId]/risk/[riskId]/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/risk/[riskId]/page.tsx @@ -115,8 +115,21 @@ const getComments = async (riskId: string): Promise => { }, }); return { - ...comment, - attachments, + id: comment.id, + content: comment.content, + author: { + id: comment.author.user.id, + name: comment.author.user.name, + email: comment.author.user.email, + }, + attachments: attachments.map((att) => ({ + 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]/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 [isUploading, setIsUploading] = useState(false); const [busyAttachmentId, setBusyAttachmentId] = useState(null); - const router = useRouter(); + + // Use SWR to fetch attachments with real-time updates + const { + data: attachmentsData, + error: attachmentsError, + isLoading: attachmentsLoading, + mutate: refreshAttachments, + } = useTaskAttachments(taskId); + + // Use API hooks for mutations + const { uploadAttachment, getDownloadUrl, deleteAttachment } = useTaskAttachmentActions(taskId); + + // Extract attachments from SWR response + const attachments = attachmentsData?.data || []; const resetState = () => { setIsUploading(false); @@ -63,64 +61,7 @@ export function TaskBody({ } }; - 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; - } - - 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; - } - - const result = await uploadFile({ - fileName: file.name, - fileType: file.type, - fileData: base64Data, - entityId: taskId, - entityType: AttachmentEntityType.task, - }); - - if (result.success) { - toast.success('File uploaded successfully.'); - onAttachmentsChange?.(); - router.refresh(); - } else { - console.error('File upload failed:', result.error, result.code); - - // 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], - ); - - // 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 +69,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 +112,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 +125,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 ( @@ -231,6 +155,15 @@ export function TaskBody({ placeholder="Add description..." className="text-muted-foreground text-md min-h-[80px] resize-none border-none p-0 shadow-none focus-visible:ring-0" disabled={disabled || isUploading || !!busyAttachmentId} + style={{ + height: 'auto', + minHeight: '80px', + }} + onInput={(e) => { + const target = e.target as HTMLTextAreaElement; + target.style.height = 'auto'; + target.style.height = `${Math.max(80, target.scrollHeight)}px`; + }} />
- {attachments.length === 0 && ( - + {/* Show loading state while fetching attachments */} + {attachmentsLoading ? ( + + ) : ( + attachments.length === 0 && ( + + ) )}
- {attachments.length > 0 ? ( + {/* Show error state if attachments failed to load */} + {attachmentsError && ( +

Failed to load attachments. Please try again.

+ )} + + {attachmentsLoading && ( +
+ {/* Loading skeleton for attachments */} + {[1, 2, 3].map((i) => ( +
+
+
+
+
+
+
+ ))} +
+ )} + + {!attachmentsLoading && attachments.length > 0 ? (
{attachments.map((attachment) => { const isBusy = busyAttachmentId === attachment.id; + // Use attachment directly since it already has the correct structure + const attachmentForItem = { + ...attachment, + // Ensure proper date objects and types + createdAt: new Date(attachment.createdAt), + updatedAt: new Date(attachment.updatedAt), + entityType: attachment.entityType as AttachmentEntityType, + }; return ( ) : ( - !isUploading && ( + !attachmentsLoading && + !attachmentsError && + !isUploading && + attachmentsData && (

No attachments yet. Click the icon above to add one. @@ -286,11 +258,11 @@ export function TaskBody({ ) )} - {attachments.length > 0 && ( + {!attachmentsLoading && attachments.length > 0 && (

); diff --git a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/commentUtils.tsx b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/commentUtils.tsx index c3f238b6b..e8ac84613 100644 --- a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/commentUtils.tsx +++ b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/commentUtils.tsx @@ -1,10 +1,11 @@ import { AttachmentType } from '@db'; import { FileAudio, FileQuestion, FileText, FileVideo } from 'lucide-react'; -// Formats a date object into relative time string (e.g., "5m ago") -export function formatRelativeTime(date: Date): string { +// Formats a date (string or Date object) into relative time string (e.g., "5m ago") +export function formatRelativeTime(date: Date | string): string { const now = new Date(); - const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000); + const dateObj = typeof date === 'string' ? new Date(date) : date; + const diffInSeconds = Math.floor((now.getTime() - dateObj.getTime()) / 1000); const diffInMinutes = Math.floor(diffInSeconds / 60); const diffInHours = Math.floor(diffInMinutes / 60); const diffInDays = Math.floor(diffInHours / 24); diff --git a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/page.tsx b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/page.tsx index 2eb334a71..d91eaa2e6 100644 --- a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/page.tsx @@ -1,9 +1,7 @@ import { auth } from '@/utils/auth'; -import type { Attachment } from '@db'; -import { AttachmentEntityType, CommentEntityType, db } from '@db'; +import { db } from '@db'; import { headers } from 'next/headers'; import { redirect } from 'next/navigation'; -import { CommentWithAuthor } from '../../../../../components/comments/Comments'; import { SingleTask } from './components/SingleTask'; type Session = Awaited>; @@ -18,22 +16,20 @@ export default async function TaskPage({ console.log('[TaskPage] Params extracted:', { taskId, orgId }); console.log('[TaskPage] Getting session'); const session = await auth.api.getSession({ - headers: headers(), + headers: await headers(), }); console.log('[TaskPage] Session obtained, fetching data'); - const [task, members, comments, attachments] = await Promise.all([ + const [task, members] = await Promise.all([ getTask(taskId, session), getMembers(orgId, session), - getComments(taskId, session), - getAttachments(taskId, session), ]); if (!task) { redirect(`/${orgId}/tasks`); } - return ; + return ; } const getTask = async (taskId: string, session: Session) => { @@ -62,70 +58,7 @@ const getTask = async (taskId: string, session: Session) => { } }; -const getComments = async (taskId: string, session: Session): Promise => { - 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: taskId, - entityType: CommentEntityType.task, - }, - 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 getAttachments = async (taskId: string, session: Session): Promise => { - const activeOrgId = session?.session.activeOrganizationId; - - if (!activeOrgId) { - console.warn('Could not determine active organization ID in getAttachments'); - return []; - } - const attachments = await db.attachment.findMany({ - where: { - organizationId: activeOrgId, - entityId: taskId, - entityType: AttachmentEntityType.task, - }, - orderBy: { - createdAt: 'asc', - }, - }); - return attachments; -}; const getMembers = async (orgId: string, session: Session) => { const activeOrgId = orgId ?? session?.session.activeOrganizationId; diff --git a/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/page.tsx b/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/page.tsx index f13a29856..b2d2d8809 100644 --- a/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/page.tsx @@ -131,8 +131,21 @@ const getComments = async (vendorId: string): Promise => { }, }); return { - ...comment, - attachments, + id: comment.id, + content: comment.content, + author: { + id: comment.author.user.id, + name: comment.author.user.name, + email: comment.author.user.email, + }, + attachments: attachments.map((att) => ({ + 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/components/comments/CommentForm.tsx b/apps/app/src/components/comments/CommentForm.tsx index f1ce2b680..51b8f5ca7 100644 --- a/apps/app/src/components/comments/CommentForm.tsx +++ b/apps/app/src/components/comments/CommentForm.tsx @@ -1,15 +1,15 @@ 'use client'; import { createComment } from '@/actions/comments/createComment'; +import { useComments } from '@/hooks/use-comments-api'; import { authClient } from '@/utils/auth-client'; import { Button } from '@comp/ui/button'; -import { Input } from '@comp/ui/input'; import { Label } from '@comp/ui/label'; import { Textarea } from '@comp/ui/textarea'; import type { CommentEntityType } from '@db'; import clsx from 'clsx'; import { ArrowUp, Loader2, Paperclip } from 'lucide-react'; -import { useParams, useRouter } from 'next/navigation'; +import { useParams } from 'next/navigation'; import type React from 'react'; import { useCallback, useEffect, useRef, useState } from 'react'; import { toast } from 'sonner'; @@ -29,35 +29,21 @@ interface PendingAttachment { export function CommentForm({ entityId, entityType }: CommentFormProps) { const session = authClient.useSession(); - const router = useRouter(); const params = useParams(); const [newComment, setNewComment] = useState(''); - const [pendingAttachments, setPendingAttachments] = useState([]); - const [isUploading, setIsUploading] = useState(false); - const [isLoading, setIsLoading] = useState(false); + const [pendingFiles, setPendingFiles] = useState([]); + const [isSubmitting, setIsSubmitting] = useState(false); const fileInputRef = useRef(null); const [hasMounted, setHasMounted] = useState(false); + // Use SWR hooks for generic comments + const { mutate: refreshComments } = useComments(entityId, entityType); + // Removed useCommentWithAttachments - now using server actions + useEffect(() => { setHasMounted(true); }, []); - let pathToRevalidate = ''; - switch (entityType) { - case 'policy': - pathToRevalidate = `/${params.orgId}/policies/${entityId}`; - break; - case 'task': - pathToRevalidate = `/${params.orgId}/tasks/${entityId}`; - break; - case 'vendor': - pathToRevalidate = `/${params.orgId}/vendors/${entityId}`; - break; - case 'risk': - pathToRevalidate = `/${params.orgId}/risks/${entityId}`; - break; - } - const triggerFileInput = () => { fileInputRef.current?.click(); }; @@ -66,81 +52,43 @@ export function CommentForm({ entityId, entityType }: CommentFormProps) { const files = event.target.files; if (!files || files.length === 0) return; - setIsUploading(true); - setIsLoading(true); - - // Helper to process a single file - const processFile = (file: File) => { - return new Promise((resolve) => { - // Add file size check here - const MAX_FILE_SIZE_MB = 5; - 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(); // Skip processing this file - } - - const reader = new FileReader(); - reader.onloadend = () => { - const dataUrlResult = reader.result as string; - const base64Data = dataUrlResult?.split(',')[1]; - if (!base64Data) { - toast.error(`Failed to read file data for ${file.name}`); - return resolve(); - } - - // Store file in memory instead of uploading - setPendingAttachments((prev) => [ - ...prev, - { - id: `temp-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, // Generate temporary ID - name: file.name, - fileType: file.type, - fileData: base64Data, - }, - ]); - toast.success(`File "${file.name}" ready for attachment.`); - setIsLoading(false); - resolve(); - }; - reader.onerror = () => { - toast.error(`Error reading file: ${file.name}`); - setIsLoading(false); - resolve(); - }; - reader.readAsDataURL(file); - }); - }; + const newFiles = Array.from(files); - // Process all files sequentially - (async () => { - for (const file of Array.from(files)) { - await processFile(file); + // Validate file sizes + const MAX_FILE_SIZE_MB = 10; + const MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024; + + for (const file of newFiles) { + if (file.size > MAX_FILE_SIZE_BYTES) { + toast.error(`File "${file.name}" exceeds the ${MAX_FILE_SIZE_MB}MB limit.`); + return; } - setIsUploading(false); - if (fileInputRef.current) fileInputRef.current.value = ''; - })(); + } + + // Add files to pending list + setPendingFiles((prev) => [...prev, ...newFiles]); + if (fileInputRef.current) fileInputRef.current.value = ''; + + newFiles.forEach((file) => { + toast.success(`File "${file.name}" ready for attachment.`); + }); }, []); - const handleRemovePendingAttachment = (attachmentIdToRemove: string) => { - setPendingAttachments((prev) => prev.filter((att) => att.id !== attachmentIdToRemove)); - toast.info('Attachment removed from comment draft.'); + const handleRemovePendingFile = (fileIndexToRemove: number) => { + setPendingFiles((prev) => prev.filter((_, index) => index !== fileIndexToRemove)); + toast.info('File removed from comment draft.'); }; - const handlePendingAttachmentClick = (attachmentId: string) => { - const pendingAttachment = pendingAttachments.find((att) => att.id === attachmentId); - if (!pendingAttachment) { - console.error('Could not find pending attachment for ID:', attachmentId); - toast.error('Could not find attachment data.'); + const handlePendingFileClick = (fileIndex: number) => { + const file = pendingFiles[fileIndex]; + if (!file) { + console.error('Could not find pending file for index:', fileIndex); + toast.error('Could not find file data.'); return; } - // Convert base64 back to blob for preview - const blob = new Blob( - [Uint8Array.from(atob(pendingAttachment.fileData), (c) => c.charCodeAt(0))], - { type: pendingAttachment.fileType }, - ); - const url = URL.createObjectURL(blob); + // Create object URL for preview + const url = URL.createObjectURL(file); // Open in new tab window.open(url, '_blank', 'noopener,noreferrer'); @@ -150,50 +98,70 @@ export function CommentForm({ entityId, entityType }: CommentFormProps) { }; const handleCommentSubmit = async () => { - setIsLoading(true); - if (!newComment.trim() && pendingAttachments.length === 0) return; - - const { success, data, error } = await createComment({ - content: newComment, - entityId, - entityType, - attachments: pendingAttachments, - pathToRevalidate, - }); + if (!newComment.trim() && pendingFiles.length === 0) return; - if (success && data) { - toast.success('Comment added!'); - setNewComment(''); - setPendingAttachments([]); - } + setIsSubmitting(true); - if (error) { - toast.error(error); + try { + // Convert files to base64 for server action + const attachments = await Promise.all( + pendingFiles.map(async (file) => { + const base64 = await new Promise((resolve) => { + const reader = new FileReader(); + reader.onload = () => { + const result = reader.result as string; + // Remove data:type;base64, prefix + const base64Data = result.split(',')[1]; + resolve(base64Data); + }; + reader.readAsDataURL(file); + }); + + return { + fileName: file.name, + fileType: file.type, + fileData: base64, + }; + }), + ); + + // Use server action for comment creation + const result = await createComment({ + content: newComment, + entityId, + entityType, + attachments: attachments.length > 0 ? attachments : undefined, + }); + + if (result?.data?.success) { + toast.success('Comment added!'); + + // Refresh comments via SWR + refreshComments(); + + // Reset form + setNewComment(''); + setPendingFiles([]); + } else { + throw new Error('Failed to create comment'); + } + } catch (error) { + console.error('Error creating comment:', error); + toast.error(error instanceof Error ? error.message : 'Failed to add comment'); + } finally { + setIsSubmitting(false); } - setIsLoading(false); }; - if (!hasMounted || session.isPending) { - return ( -
-
-
-
-
-
-
-
-
-
- ); - } + // Always show the actual form - no loading gate + // Users can start typing immediately, authentication is checked on submit const handleKeyDown = (event: React.KeyboardEvent) => { if ( (event.metaKey || event.ctrlKey) && event.key === 'Enter' && - !isLoading && - (newComment.trim() || pendingAttachments.length > 0) + !isSubmitting && + (newComment.trim() || pendingFiles.length > 0) ) { event.preventDefault(); // Prevent default newline behavior handleCommentSubmit(); @@ -203,13 +171,13 @@ export function CommentForm({ entityId, entityType }: CommentFormProps) { return (
-