fix
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user