This commit is contained in:
pradeepkumar
2026-01-24 22:19:26 +05:30
parent 3b76a5aa50
commit 4397075715
8 changed files with 208 additions and 6 deletions

View File

@@ -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

View File

@@ -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' },
],
},
],
},

View File

@@ -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;
}

View File

@@ -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<NestExpressApplication>(AppModule);
const configService = app.get(ConfigService);
const port = configService.get<number>('app.port') || 3001;

View File

@@ -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;
}

View File

@@ -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<PresignedUrlResponseDto> {
// 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' })

View File

@@ -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],

View File

@@ -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<string>('AWS_REGION') || 'us-east-1';
this.bucket = this.configService.get<string>('AWS_S3_BUCKET') || '';
this.endpoint = this.configService.get<string>('S3_ENDPOINT') || '';
// Use S3_FOLDER_PREFIX from env, defaults to 'development' for dev environment
this.folderPrefix = this.configService.get<string>('S3_FOLDER_PREFIX') || 'development';
this.s3Client = new S3Client({
// Build S3 client config
const s3Config: any = {
region: this.region,
credentials: {
accessKeyId: this.configService.get<string>('AWS_ACCESS_KEY_ID') || '',
secretAccessKey:
this.configService.get<string>('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<PresignedUrlResponseDto> {
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,
};
}
}