diff --git a/src/agents/agents.controller.ts b/src/agents/agents.controller.ts index c8c1594..8e8ef79 100644 --- a/src/agents/agents.controller.ts +++ b/src/agents/agents.controller.ts @@ -80,14 +80,15 @@ export class AgentsController { return this.agentsService.getProfile(userId); } - // Public parameterized route - must come after specific routes - @Get(':id') - @Public() - @ApiOperation({ summary: 'Get agent profile by ID' }) - @ApiResponse({ status: 200, description: 'Agent profile retrieved successfully' }) - @ApiResponse({ status: 404, description: 'Agent profile not found' }) - async getAgentById(@Param('id', ParseUUIDPipe) id: string) { - return this.agentsService.getProfileById(id); + @Get('profile/field-values') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.AGENT) + @ApiBearerAuth() + @ApiOperation({ summary: 'Get dynamic field values for agent profile' }) + @ApiResponse({ status: 200, description: 'Field values retrieved successfully' }) + @ApiResponse({ status: 404, description: 'Profile not found' }) + async getFieldValues(@CurrentUser('id') userId: string) { + return this.agentsService.getFieldValues(userId); } @Post('profile') @@ -132,15 +133,23 @@ export class AgentsController { return this.agentsService.saveFieldValues(userId, dto); } - @Get('profile/field-values') - @UseGuards(JwtAuthGuard, RolesGuard) - @Roles(UserRole.AGENT) - @ApiBearerAuth() - @ApiOperation({ summary: 'Get dynamic field values for agent profile' }) + // Public parameterized routes - MUST come after specific routes like profile/* + @Get(':id') + @Public() + @ApiOperation({ summary: 'Get agent profile by ID' }) + @ApiResponse({ status: 200, description: 'Agent profile retrieved successfully' }) + @ApiResponse({ status: 404, description: 'Agent profile not found' }) + async getAgentById(@Param('id', ParseUUIDPipe) id: string) { + return this.agentsService.getProfileById(id); + } + + @Get(':id/field-values') + @Public() + @ApiOperation({ summary: 'Get field values for an agent profile by ID (public)' }) @ApiResponse({ status: 200, description: 'Field values retrieved successfully' }) - @ApiResponse({ status: 404, description: 'Profile not found' }) - async getFieldValues(@CurrentUser('id') userId: string) { - return this.agentsService.getFieldValues(userId); + @ApiResponse({ status: 404, description: 'Agent profile not found' }) + async getFieldValuesByAgentId(@Param('id', ParseUUIDPipe) id: string) { + return this.agentsService.getFieldValuesByAgentId(id); } // Admin endpoints diff --git a/src/agents/agents.service.ts b/src/agents/agents.service.ts index b203415..7b8ceb1 100644 --- a/src/agents/agents.service.ts +++ b/src/agents/agents.service.ts @@ -498,6 +498,54 @@ export class AgentsService { }; } + async getFieldValuesByAgentId(agentId: string) { + // Get agent profile by ID + const profile = await this.prisma.agentProfile.findUnique({ + where: { id: agentId }, + }); + + if (!profile) { + throw new NotFoundException('Agent profile not found'); + } + + // Get all field values for this profile + const fieldValues = await this.prisma.agentProfileFieldValue.findMany({ + where: { agentProfileId: profile.id }, + include: { + field: { + select: { + id: true, + slug: true, + name: true, + fieldType: true, + section: { + select: { + id: true, + slug: true, + name: true, + }, + }, + }, + }, + }, + }); + + // Transform to a more usable format + const transformedValues = fieldValues.map((fv) => ({ + fieldSlug: fv.field.slug, + fieldName: fv.field.name, + fieldType: fv.field.fieldType, + sectionSlug: fv.field.section.slug, + sectionName: fv.field.section.name, + value: this.extractValue(fv), + })); + + return { + agentProfileId: profile.id, + fieldValues: transformedValues, + }; + } + private getValueData( fieldType: string, value: string | number | boolean | string[] | Record | null, diff --git a/src/upload/upload.controller.ts b/src/upload/upload.controller.ts index e008301..76b2023 100644 --- a/src/upload/upload.controller.ts +++ b/src/upload/upload.controller.ts @@ -2,17 +2,23 @@ import { Controller, Post, Delete, + Get, Body, Param, + Query, UseGuards, NotFoundException, + Res, + BadRequestException, } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, + ApiQuery, } from '@nestjs/swagger'; +import type { Response } from 'express'; import { UploadService } from './upload.service'; import { PresignedUrlDto, PresignedUrlResponseDto, AvatarPresignedUrlDto } from './dto'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; @@ -24,9 +30,6 @@ import { PrismaService } from '../prisma/prisma.service'; @ApiTags('upload') @Controller('upload') -@UseGuards(JwtAuthGuard, RolesGuard) -@Roles(UserRole.AGENT, UserRole.ADMIN) -@ApiBearerAuth() export class UploadController { constructor( private readonly uploadService: UploadService, @@ -34,6 +37,9 @@ export class UploadController { ) {} @Post('presigned-url') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.AGENT, UserRole.ADMIN) + @ApiBearerAuth() @ApiOperation({ summary: 'Get a pre-signed URL for file upload' }) @ApiResponse({ status: 200, @@ -48,7 +54,9 @@ export class UploadController { } @Post('avatar-presigned-url') + @UseGuards(JwtAuthGuard, RolesGuard) @Roles(UserRole.AGENT) + @ApiBearerAuth() @ApiOperation({ summary: 'Get a pre-signed URL for avatar upload (uses agent ID as filename)' }) @ApiResponse({ status: 200, @@ -79,10 +87,34 @@ export class UploadController { } @Delete(':key') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.AGENT, UserRole.ADMIN) + @ApiBearerAuth() @ApiOperation({ summary: 'Delete an uploaded file' }) @ApiResponse({ status: 200, description: 'File deleted successfully' }) async deleteFile(@Param('key') key: string) { await this.uploadService.deleteFile(key); return { message: 'File deleted successfully' }; } + + @Get('presigned-download-url') + @ApiOperation({ summary: 'Get a presigned download URL for an S3 key (public endpoint)' }) + @ApiQuery({ name: 'key', required: true, description: 'S3 key (e.g., avatars/uuid.png)' }) + @ApiResponse({ status: 200, description: 'Presigned URL returned successfully' }) + @ApiResponse({ status: 400, description: 'Key is required' }) + async getPresignedDownloadUrl( + @Query('key') key: string, + ) { + if (!key) { + throw new BadRequestException('Key is required'); + } + + try { + const url = await this.uploadService.getSignedDownloadUrl(key); + return { url }; + } catch (error) { + console.error('Error generating presigned URL:', error); + throw new NotFoundException('File not found'); + } + } } diff --git a/src/upload/upload.service.ts b/src/upload/upload.service.ts index 9969b7a..0043a70 100644 --- a/src/upload/upload.service.ts +++ b/src/upload/upload.service.ts @@ -211,4 +211,57 @@ export class UploadService { key, }; } + + /** + * Extract S3 key from a full S3 URL + */ + private extractKeyFromUrl(url: string): string { + try { + const urlObj = new URL(url); + // Remove leading slash from pathname + let key = urlObj.pathname.replace(/^\//, ''); + + // For Contabo/path-style URLs: endpoint/bucket/key + // The bucket name might be in the path, so we need to remove it + if (this.endpoint && key.startsWith(this.bucket + '/')) { + key = key.substring(this.bucket.length + 1); + } + + return key; + } catch { + // If URL parsing fails, assume it's already a key + return url; + } + } + + /** + * Fetch image from S3 and return as buffer + */ + async getImageFromUrl(url: string): Promise<{ + buffer: Buffer; + contentType: string; + contentLength: number; + }> { + const key = this.extractKeyFromUrl(url); + + const command = new GetObjectCommand({ + Bucket: this.bucket, + Key: key, + }); + + const response = await this.s3Client.send(command); + + // Convert readable stream to buffer + const chunks: Uint8Array[] = []; + for await (const chunk of response.Body as AsyncIterable) { + chunks.push(chunk); + } + const buffer = Buffer.concat(chunks); + + return { + buffer, + contentType: response.ContentType || 'image/jpeg', + contentLength: response.ContentLength || buffer.length, + }; + } }