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_SECRET_ACCESS_KEY=your-aws-secret-key
|
||||||
AWS_REGION=us-east-1
|
AWS_REGION=us-east-1
|
||||||
AWS_S3_BUCKET=your-s3-bucket-name
|
AWS_S3_BUCKET=your-s3-bucket-name
|
||||||
|
S3_FOLDER_PREFIX=development # Root folder for all uploads (e.g., 'development', 'staging', 'production')
|
||||||
|
|
||||||
# Email (SMTP / SendGrid)
|
# Email (SMTP / SendGrid)
|
||||||
MAIL_HOST=smtp.sendgrid.net
|
MAIL_HOST=smtp.sendgrid.net
|
||||||
|
|||||||
@@ -232,6 +232,48 @@ async function main() {
|
|||||||
allowCustomOptions: true,
|
allowCustomOptions: true,
|
||||||
maxSelections: 5,
|
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()
|
@IsOptional()
|
||||||
@IsUrl()
|
@IsUrl()
|
||||||
instagramUrl?: string;
|
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 { ValidationPipe, Logger, BadRequestException } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
||||||
|
import { NestExpressApplication } from '@nestjs/platform-express';
|
||||||
import helmet from 'helmet';
|
import helmet from 'helmet';
|
||||||
import cookieParser from 'cookie-parser';
|
import cookieParser from 'cookie-parser';
|
||||||
import { AppModule } from './app.module';
|
import { AppModule } from './app.module';
|
||||||
@@ -10,7 +11,7 @@ import { TransformInterceptor } from './common/interceptors';
|
|||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const logger = new Logger('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 configService = app.get(ConfigService);
|
||||||
const port = configService.get<number>('app.port') || 3001;
|
const port = configService.get<number>('app.port') || 3001;
|
||||||
|
|||||||
@@ -51,3 +51,19 @@ export class PresignedUrlResponseDto {
|
|||||||
})
|
})
|
||||||
key: string;
|
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,
|
Body,
|
||||||
Param,
|
Param,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
ApiTags,
|
ApiTags,
|
||||||
@@ -13,11 +14,13 @@ import {
|
|||||||
ApiBearerAuth,
|
ApiBearerAuth,
|
||||||
} from '@nestjs/swagger';
|
} from '@nestjs/swagger';
|
||||||
import { UploadService } from './upload.service';
|
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 { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
import { RolesGuard } from '../auth/guards/roles.guard';
|
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||||
import { Roles } from '../auth/decorators/roles.decorator';
|
import { Roles } from '../auth/decorators/roles.decorator';
|
||||||
|
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||||
import { UserRole } from '@prisma/client';
|
import { UserRole } from '@prisma/client';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
@ApiTags('upload')
|
@ApiTags('upload')
|
||||||
@Controller('upload')
|
@Controller('upload')
|
||||||
@@ -25,7 +28,10 @@ import { UserRole } from '@prisma/client';
|
|||||||
@Roles(UserRole.AGENT, UserRole.ADMIN)
|
@Roles(UserRole.AGENT, UserRole.ADMIN)
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
export class UploadController {
|
export class UploadController {
|
||||||
constructor(private readonly uploadService: UploadService) {}
|
constructor(
|
||||||
|
private readonly uploadService: UploadService,
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
) {}
|
||||||
|
|
||||||
@Post('presigned-url')
|
@Post('presigned-url')
|
||||||
@ApiOperation({ summary: 'Get a pre-signed URL for file upload' })
|
@ApiOperation({ summary: 'Get a pre-signed URL for file upload' })
|
||||||
@@ -41,6 +47,37 @@ export class UploadController {
|
|||||||
return this.uploadService.getPresignedUrl(dto);
|
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')
|
@Delete(':key')
|
||||||
@ApiOperation({ summary: 'Delete an uploaded file' })
|
@ApiOperation({ summary: 'Delete an uploaded file' })
|
||||||
@ApiResponse({ status: 200, description: 'File deleted successfully' })
|
@ApiResponse({ status: 200, description: 'File deleted successfully' })
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { UploadController } from './upload.controller';
|
import { UploadController } from './upload.controller';
|
||||||
import { UploadService } from './upload.service';
|
import { UploadService } from './upload.service';
|
||||||
|
import { PrismaModule } from '../prisma/prisma.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
imports: [PrismaModule],
|
||||||
controllers: [UploadController],
|
controllers: [UploadController],
|
||||||
providers: [UploadService],
|
providers: [UploadService],
|
||||||
exports: [UploadService],
|
exports: [UploadService],
|
||||||
|
|||||||
@@ -15,19 +15,40 @@ export class UploadService {
|
|||||||
private s3Client: S3Client;
|
private s3Client: S3Client;
|
||||||
private bucket: string;
|
private bucket: string;
|
||||||
private region: string;
|
private region: string;
|
||||||
|
private folderPrefix: string;
|
||||||
|
private endpoint: string;
|
||||||
|
|
||||||
constructor(private configService: ConfigService) {
|
constructor(private configService: ConfigService) {
|
||||||
this.region = this.configService.get<string>('AWS_REGION') || 'us-east-1';
|
this.region = this.configService.get<string>('AWS_REGION') || 'us-east-1';
|
||||||
this.bucket = this.configService.get<string>('AWS_S3_BUCKET') || '';
|
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,
|
region: this.region,
|
||||||
credentials: {
|
credentials: {
|
||||||
accessKeyId: this.configService.get<string>('AWS_ACCESS_KEY_ID') || '',
|
accessKeyId: this.configService.get<string>('AWS_ACCESS_KEY_ID') || '',
|
||||||
secretAccessKey:
|
secretAccessKey:
|
||||||
this.configService.get<string>('AWS_SECRET_ACCESS_KEY') || '',
|
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 {
|
private getFileExtension(filename: string): string {
|
||||||
@@ -72,7 +93,8 @@ export class UploadService {
|
|||||||
|
|
||||||
const extension = this.getFileExtension(dto.filename);
|
const extension = this.getFileExtension(dto.filename);
|
||||||
const uniqueId = uuidv4();
|
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({
|
const command = new PutObjectCommand({
|
||||||
Bucket: this.bucket,
|
Bucket: this.bucket,
|
||||||
@@ -114,6 +136,79 @@ export class UploadService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getPublicUrl(key: string): string {
|
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}`;
|
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