diff --git a/package.json b/package.json index 15388bc5..d536ac12 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,8 @@ "uuid": "^9.0.0", "moment": "^2.29.4", "firebase-admin": "^11.10.1", + "@aws-sdk/client-s3": "^3.450.0", + "@aws-sdk/s3-request-presigner": "^3.450.0", "reflect-metadata": "^0.1.13", "rxjs": "^7.8.1" }, diff --git a/src/aws/aws-s3.service.ts b/src/aws/aws-s3.service.ts new file mode 100644 index 00000000..e036e342 --- /dev/null +++ b/src/aws/aws-s3.service.ts @@ -0,0 +1,161 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { S3Client, PutObjectCommand, DeleteObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3'; +import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; +import { v4 as uuidv4 } from 'uuid'; + +@Injectable() +export class AwsS3Service { + private readonly logger = new Logger(AwsS3Service.name); + private readonly s3Client: S3Client; + private readonly bucketName: string; + private readonly region: string; + + constructor(private readonly configService: ConfigService) { + this.region = this.configService.get('AWS_REGION') || 'us-east-1'; + this.bucketName = this.configService.get('AWS_S3_BUCKET_NAME'); + + if (!this.bucketName) { + throw new Error('AWS_S3_BUCKET_NAME environment variable is required'); + } + + this.s3Client = new S3Client({ + region: this.region, + credentials: { + accessKeyId: this.configService.get('AWS_ACCESS_KEY_ID'), + secretAccessKey: this.configService.get('AWS_SECRET_ACCESS_KEY'), + }, + }); + } + + async uploadFile( + file: Express.Multer.File, + folder: string = 'uploads', + organizationId?: string, + ): Promise<{ key: string; url: string }> { + try { + const fileExtension = file.originalname.split('.').pop(); + const fileName = `${uuidv4()}.${fileExtension}`; + const key = organizationId + ? `${folder}/${organizationId}/${fileName}` + : `${folder}/${fileName}`; + + const command = new PutObjectCommand({ + Bucket: this.bucketName, + Key: key, + Body: file.buffer, + ContentType: file.mimetype, + Metadata: { + originalName: file.originalname, + uploadedAt: new Date().toISOString(), + ...(organizationId && { organizationId }), + }, + }); + + await this.s3Client.send(command); + + // Generate presigned URL for the uploaded file + const presignedUrl = await this.getPresignedUrl(key); + + this.logger.log(`File uploaded successfully: ${key}`); + return { key, url: presignedUrl }; + } catch (error) { + this.logger.error(`Failed to upload file: ${error.message}`); + throw new Error(`Failed to upload file: ${error.message}`); + } + } + + async getPresignedUrl(key: string, expiresIn: number = 3600): Promise { + try { + const command = new GetObjectCommand({ + Bucket: this.bucketName, + Key: key, + }); + + const presignedUrl = await getSignedUrl(this.s3Client, command, { + expiresIn, + }); + + return presignedUrl; + } catch (error) { + this.logger.error(`Failed to generate presigned URL: ${error.message}`); + throw new Error(`Failed to generate presigned URL: ${error.message}`); + } + } + + async deleteFile(key: string): Promise { + try { + const command = new DeleteObjectCommand({ + Bucket: this.bucketName, + Key: key, + }); + + await this.s3Client.send(command); + this.logger.log(`File deleted successfully: ${key}`); + } catch (error) { + this.logger.error(`Failed to delete file: ${error.message}`); + throw new Error(`Failed to delete file: ${error.message}`); + } + } + + async uploadAvatar( + file: Express.Multer.File, + userId: string, + organizationId?: string, + ): Promise<{ key: string; url: string }> { + const folder = organizationId ? `avatars/${organizationId}` : 'avatars'; + const key = `${folder}/${userId}-${uuidv4()}.${file.originalname.split('.').pop()}`; + + try { + const command = new PutObjectCommand({ + Bucket: this.bucketName, + Key: key, + Body: file.buffer, + ContentType: file.mimetype, + Metadata: { + userId, + type: 'avatar', + uploadedAt: new Date().toISOString(), + ...(organizationId && { organizationId }), + }, + }); + + await this.s3Client.send(command); + + const presignedUrl = await this.getPresignedUrl(key); + + this.logger.log(`Avatar uploaded successfully: ${key}`); + return { key, url: presignedUrl }; + } catch (error) { + this.logger.error(`Failed to upload avatar: ${error.message}`); + throw new Error(`Failed to upload avatar: ${error.message}`); + } + } + + async uploadChatMedia( + file: Express.Multer.File, + chatRoomId: string, + organizationId: string, + ): Promise<{ key: string; url: string; type: string }> { + const mediaType = this.getMediaType(file.mimetype); + const folder = `chat-media/${organizationId}/${chatRoomId}/${mediaType}`; + + return this.uploadFile(file, folder, organizationId); + } + + private getMediaType(mimetype: string): string { + if (mimetype.startsWith('image/')) return 'images'; + if (mimetype.startsWith('video/')) return 'videos'; + if (mimetype.startsWith('audio/')) return 'audio'; + if (mimetype.includes('pdf') || mimetype.includes('document')) return 'documents'; + return 'files'; + } + + getBucketName(): string { + return this.bucketName; + } + + getRegion(): string { + return this.region; + } +} \ No newline at end of file diff --git a/src/aws/aws.module.ts b/src/aws/aws.module.ts new file mode 100644 index 00000000..4185e6e3 --- /dev/null +++ b/src/aws/aws.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { AwsS3Service } from './aws-s3.service'; + +@Module({ + imports: [ConfigModule], + providers: [AwsS3Service], + exports: [AwsS3Service], +}) +export class AwsModule {} \ No newline at end of file