diff --git a/.env.example b/.env.example index 6bb1efc..d4b08ee 100644 --- a/.env.example +++ b/.env.example @@ -37,6 +37,7 @@ AWS_ACCESS_KEY_ID=your-aws-access-key AWS_SECRET_ACCESS_KEY=your-aws-secret-key AWS_REGION=us-east-1 AWS_S3_BUCKET=your-s3-bucket-name +S3_FOLDER_PREFIX=development # Root folder for all uploads (e.g., 'development', 'staging', 'production') # Email (SMTP / SendGrid) MAIL_HOST=smtp.sendgrid.net diff --git a/prisma/seed.ts b/prisma/seed.ts index 790a2d3..feb08d5 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -232,6 +232,48 @@ async function main() { allowCustomOptions: true, maxSelections: 5, }, + options: [ + { label: 'New York', value: 'new_york' }, + { label: 'Los Angeles', value: 'los_angeles' }, + { label: 'Chicago', value: 'chicago' }, + { label: 'Houston', value: 'houston' }, + { label: 'Phoenix', value: 'phoenix' }, + { label: 'Philadelphia', value: 'philadelphia' }, + { label: 'San Antonio', value: 'san_antonio' }, + { label: 'San Diego', value: 'san_diego' }, + { label: 'Dallas', value: 'dallas' }, + { label: 'San Jose', value: 'san_jose' }, + { label: 'Austin', value: 'austin' }, + { label: 'Jacksonville', value: 'jacksonville' }, + { label: 'Fort Worth', value: 'fort_worth' }, + { label: 'Columbus', value: 'columbus' }, + { label: 'Charlotte', value: 'charlotte' }, + { label: 'San Francisco', value: 'san_francisco' }, + { label: 'Indianapolis', value: 'indianapolis' }, + { label: 'Seattle', value: 'seattle' }, + { label: 'Denver', value: 'denver' }, + { label: 'Washington DC', value: 'washington_dc' }, + { label: 'Boston', value: 'boston' }, + { label: 'Nashville', value: 'nashville' }, + { label: 'Detroit', value: 'detroit' }, + { label: 'Portland', value: 'portland' }, + { label: 'Las Vegas', value: 'las_vegas' }, + { label: 'Memphis', value: 'memphis' }, + { label: 'Louisville', value: 'louisville' }, + { label: 'Baltimore', value: 'baltimore' }, + { label: 'Milwaukee', value: 'milwaukee' }, + { label: 'Albuquerque', value: 'albuquerque' }, + { label: 'Tucson', value: 'tucson' }, + { label: 'Fresno', value: 'fresno' }, + { label: 'Sacramento', value: 'sacramento' }, + { label: 'Atlanta', value: 'atlanta' }, + { label: 'Miami', value: 'miami' }, + { label: 'Oakland', value: 'oakland' }, + { label: 'Minneapolis', value: 'minneapolis' }, + { label: 'Cleveland', value: 'cleveland' }, + { label: 'Raleigh', value: 'raleigh' }, + { label: 'Tampa', value: 'tampa' }, + ], }, ], }, diff --git a/src/agents/dto/create-agent-profile.dto.ts b/src/agents/dto/create-agent-profile.dto.ts index 0d39152..16666ca 100644 --- a/src/agents/dto/create-agent-profile.dto.ts +++ b/src/agents/dto/create-agent-profile.dto.ts @@ -123,4 +123,12 @@ export class CreateAgentProfileDto { @IsOptional() @IsUrl() instagramUrl?: string; + + @ApiPropertyOptional({ + example: 'https://sin1.contabostorage.com/request/development/avatars/abc123.jpg', + description: 'Avatar image URL', + }) + @IsOptional() + @IsString() + avatar?: string; } diff --git a/src/main.ts b/src/main.ts index 92db57a..ddeeea7 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2,6 +2,7 @@ import { NestFactory } from '@nestjs/core'; import { ValidationPipe, Logger, BadRequestException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; +import { NestExpressApplication } from '@nestjs/platform-express'; import helmet from 'helmet'; import cookieParser from 'cookie-parser'; import { AppModule } from './app.module'; @@ -10,7 +11,7 @@ import { TransformInterceptor } from './common/interceptors'; async function bootstrap() { const logger = new Logger('Bootstrap'); - const app = await NestFactory.create(AppModule); + const app = await NestFactory.create(AppModule); const configService = app.get(ConfigService); const port = configService.get('app.port') || 3001; diff --git a/src/upload/dto/upload.dto.ts b/src/upload/dto/upload.dto.ts index 7221369..0cb9f57 100644 --- a/src/upload/dto/upload.dto.ts +++ b/src/upload/dto/upload.dto.ts @@ -51,3 +51,19 @@ export class PresignedUrlResponseDto { }) key: string; } + +export class AvatarPresignedUrlDto { + @ApiProperty({ + example: 'profile.jpg', + description: 'Original filename (used to get extension)', + }) + @IsString() + filename: string; + + @ApiProperty({ + example: 'image/jpeg', + description: 'MIME type of the image (image/jpeg, image/png, image/webp)', + }) + @IsString() + contentType: string; +} diff --git a/src/upload/upload.controller.ts b/src/upload/upload.controller.ts index ec720e8..e008301 100644 --- a/src/upload/upload.controller.ts +++ b/src/upload/upload.controller.ts @@ -5,6 +5,7 @@ import { Body, Param, UseGuards, + NotFoundException, } from '@nestjs/common'; import { ApiTags, @@ -13,11 +14,13 @@ import { ApiBearerAuth, } from '@nestjs/swagger'; import { UploadService } from './upload.service'; -import { PresignedUrlDto, PresignedUrlResponseDto } from './dto'; +import { PresignedUrlDto, PresignedUrlResponseDto, AvatarPresignedUrlDto } from './dto'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { RolesGuard } from '../auth/guards/roles.guard'; import { Roles } from '../auth/decorators/roles.decorator'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; import { UserRole } from '@prisma/client'; +import { PrismaService } from '../prisma/prisma.service'; @ApiTags('upload') @Controller('upload') @@ -25,7 +28,10 @@ import { UserRole } from '@prisma/client'; @Roles(UserRole.AGENT, UserRole.ADMIN) @ApiBearerAuth() export class UploadController { - constructor(private readonly uploadService: UploadService) {} + constructor( + private readonly uploadService: UploadService, + private readonly prisma: PrismaService, + ) {} @Post('presigned-url') @ApiOperation({ summary: 'Get a pre-signed URL for file upload' }) @@ -41,6 +47,37 @@ export class UploadController { return this.uploadService.getPresignedUrl(dto); } + @Post('avatar-presigned-url') + @Roles(UserRole.AGENT) + @ApiOperation({ summary: 'Get a pre-signed URL for avatar upload (uses agent ID as filename)' }) + @ApiResponse({ + status: 200, + description: 'Pre-signed URL generated successfully', + type: PresignedUrlResponseDto, + }) + @ApiResponse({ status: 400, description: 'Invalid content type' }) + @ApiResponse({ status: 404, description: 'Agent profile not found' }) + async getAvatarPresignedUrl( + @CurrentUser('id') userId: string, + @Body() dto: AvatarPresignedUrlDto, + ): Promise { + // Get agent profile ID + const agentProfile = await this.prisma.agentProfile.findUnique({ + where: { userId }, + select: { id: true }, + }); + + if (!agentProfile) { + throw new NotFoundException('Agent profile not found'); + } + + return this.uploadService.getAvatarPresignedUrl( + agentProfile.id, + dto.filename, + dto.contentType, + ); + } + @Delete(':key') @ApiOperation({ summary: 'Delete an uploaded file' }) @ApiResponse({ status: 200, description: 'File deleted successfully' }) diff --git a/src/upload/upload.module.ts b/src/upload/upload.module.ts index e4baecb..6247543 100644 --- a/src/upload/upload.module.ts +++ b/src/upload/upload.module.ts @@ -1,8 +1,10 @@ import { Module } from '@nestjs/common'; import { UploadController } from './upload.controller'; import { UploadService } from './upload.service'; +import { PrismaModule } from '../prisma/prisma.module'; @Module({ + imports: [PrismaModule], controllers: [UploadController], providers: [UploadService], exports: [UploadService], diff --git a/src/upload/upload.service.ts b/src/upload/upload.service.ts index 591689b..9969b7a 100644 --- a/src/upload/upload.service.ts +++ b/src/upload/upload.service.ts @@ -15,19 +15,40 @@ export class UploadService { private s3Client: S3Client; private bucket: string; private region: string; + private folderPrefix: string; + private endpoint: string; constructor(private configService: ConfigService) { this.region = this.configService.get('AWS_REGION') || 'us-east-1'; this.bucket = this.configService.get('AWS_S3_BUCKET') || ''; + this.endpoint = this.configService.get('S3_ENDPOINT') || ''; + // Use S3_FOLDER_PREFIX from env, defaults to 'development' for dev environment + this.folderPrefix = this.configService.get('S3_FOLDER_PREFIX') || 'development'; - this.s3Client = new S3Client({ + // Build S3 client config + const s3Config: any = { region: this.region, credentials: { accessKeyId: this.configService.get('AWS_ACCESS_KEY_ID') || '', secretAccessKey: this.configService.get('AWS_SECRET_ACCESS_KEY') || '', }, - }); + }; + + // Add custom endpoint for S3-compatible services (Contabo, MinIO, DigitalOcean, etc.) + if (this.endpoint) { + s3Config.endpoint = this.endpoint; + s3Config.forcePathStyle = true; // Required for most S3-compatible services + } + + this.s3Client = new S3Client(s3Config); + } + + /** + * Get the full S3 key with environment prefix + */ + private getFullKey(folder: string, filename: string): string { + return `${this.folderPrefix}/${folder}/${filename}`; } private getFileExtension(filename: string): string { @@ -72,7 +93,8 @@ export class UploadService { const extension = this.getFileExtension(dto.filename); const uniqueId = uuidv4(); - const key = `${dto.folder}/${uniqueId}${extension ? `.${extension}` : ''}`; + const filename = `${uniqueId}${extension ? `.${extension}` : ''}`; + const key = this.getFullKey(dto.folder, filename); const command = new PutObjectCommand({ Bucket: this.bucket, @@ -114,6 +136,79 @@ export class UploadService { } getPublicUrl(key: string): string { + // For custom S3-compatible endpoints (Contabo, MinIO, etc.) + if (this.endpoint) { + return `${this.endpoint}/${this.bucket}/${key}`; + } + // For AWS S3 return `https://${this.bucket}.s3.${this.region}.amazonaws.com/${key}`; } + + /** + * Upload a file buffer directly to S3 (for server-side uploads) + */ + async uploadBuffer( + buffer: Buffer, + filename: string, + contentType: string, + folder: UploadFolder, + ): Promise<{ publicUrl: string; key: string }> { + this.validateContentType(contentType, folder); + + const extension = this.getFileExtension(filename); + const uniqueId = uuidv4(); + const uniqueFilename = `${uniqueId}${extension ? `.${extension}` : ''}`; + const key = this.getFullKey(folder, uniqueFilename); + + const command = new PutObjectCommand({ + Bucket: this.bucket, + Key: key, + Body: buffer, + ContentType: contentType, + }); + + await this.s3Client.send(command); + + const publicUrl = this.getPublicUrl(key); + + return { + publicUrl, + key, + }; + } + + /** + * Get presigned URL for avatar upload using agent profile ID as filename + * This ensures the same agent's avatar is always overwritten (no orphan files) + */ + async getAvatarPresignedUrl( + agentProfileId: string, + filename: string, + contentType: string, + ): Promise { + this.validateContentType(contentType, UploadFolder.AVATARS); + + const extension = this.getFileExtension(filename); + // Use agent profile ID as filename so it always overwrites + const avatarFilename = `${agentProfileId}${extension ? `.${extension}` : ''}`; + const key = this.getFullKey(UploadFolder.AVATARS, avatarFilename); + + const command = new PutObjectCommand({ + Bucket: this.bucket, + Key: key, + ContentType: contentType, + }); + + const uploadUrl = await getSignedUrl(this.s3Client, command, { + expiresIn: 3600, // 1 hour + }); + + const publicUrl = this.getPublicUrl(key); + + return { + uploadUrl, + publicUrl, + key, + }; + } }