From 6624b35e59db2c69428facc1aaae70597487b9d1 Mon Sep 17 00:00:00 2001 From: pradeepkumar Date: Sun, 1 Feb 2026 00:50:46 +0530 Subject: [PATCH] feat: Implement user profile management and avatar upload functionality for regular users. --- src/upload/upload.controller.ts | 24 +++++++- src/upload/upload.service.ts | 43 +++++++++++++- src/users/users.controller.ts | 56 +++++++++++++++++++ src/users/users.service.ts | 99 +++++++++++++++++++++++++++++++++ 4 files changed, 218 insertions(+), 4 deletions(-) diff --git a/src/upload/upload.controller.ts b/src/upload/upload.controller.ts index 76b2023..4b72dcb 100644 --- a/src/upload/upload.controller.ts +++ b/src/upload/upload.controller.ts @@ -57,7 +57,7 @@ export class UploadController { @UseGuards(JwtAuthGuard, RolesGuard) @Roles(UserRole.AGENT) @ApiBearerAuth() - @ApiOperation({ summary: 'Get a pre-signed URL for avatar upload (uses agent ID as filename)' }) + @ApiOperation({ summary: 'Get a pre-signed URL for agent avatar upload (S3 path: agents/{agentId}/avatar)' }) @ApiResponse({ status: 200, description: 'Pre-signed URL generated successfully', @@ -86,6 +86,28 @@ export class UploadController { ); } + @Post('user-avatar-presigned-url') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.USER) + @ApiBearerAuth() + @ApiOperation({ summary: 'Get a pre-signed URL for user avatar upload (S3 path: users/{userId}/avatar)' }) + @ApiResponse({ + status: 200, + description: 'Pre-signed URL generated successfully', + type: PresignedUrlResponseDto, + }) + @ApiResponse({ status: 400, description: 'Invalid content type' }) + async getUserAvatarPresignedUrl( + @CurrentUser('id') userId: string, + @Body() dto: AvatarPresignedUrlDto, + ): Promise { + return this.uploadService.getUserAvatarPresignedUrl( + userId, + dto.filename, + dto.contentType, + ); + } + @Delete(':key') @UseGuards(JwtAuthGuard, RolesGuard) @Roles(UserRole.AGENT, UserRole.ADMIN) diff --git a/src/upload/upload.service.ts b/src/upload/upload.service.ts index 0043a70..95d18eb 100644 --- a/src/upload/upload.service.ts +++ b/src/upload/upload.service.ts @@ -179,6 +179,7 @@ export class UploadService { /** * Get presigned URL for avatar upload using agent profile ID as filename + * S3 path: {env}/agents/{agentProfileId}/avatar.{ext} * This ensures the same agent's avatar is always overwritten (no orphan files) */ async getAvatarPresignedUrl( @@ -189,9 +190,45 @@ export class UploadService { 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); + // S3 path: {env}/agents/{agentProfileId}/avatar.{ext} + const avatarFilename = `avatar${extension ? `.${extension}` : ''}`; + const key = `${this.folderPrefix}/agents/${agentProfileId}/${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, + }; + } + + /** + * Get presigned URL for user avatar upload using user ID as folder + * S3 path: {env}/users/{userId}/avatar.{ext} + * This ensures the same user's avatar is always overwritten (no orphan files) + */ + async getUserAvatarPresignedUrl( + userId: string, + filename: string, + contentType: string, + ): Promise { + this.validateContentType(contentType, UploadFolder.AVATARS); + + const extension = this.getFileExtension(filename); + // S3 path: {env}/users/{userId}/avatar.{ext} + const avatarFilename = `avatar${extension ? `.${extension}` : ''}`; + const key = `${this.folderPrefix}/users/${userId}/${avatarFilename}`; const command = new PutObjectCommand({ Bucket: this.bucket, diff --git a/src/users/users.controller.ts b/src/users/users.controller.ts index 6294e43..80e079b 100644 --- a/src/users/users.controller.ts +++ b/src/users/users.controller.ts @@ -27,6 +27,62 @@ import { UserRole, UserStatus, VerificationStatus } from '@prisma/client'; export class UsersController { constructor(private readonly usersService: UsersService) {} + // ============================================= + // USER PROFILE ENDPOINTS (for regular users) + // ============================================= + + @Get('profile/me') + @Roles(UserRole.USER) + @ApiOperation({ summary: 'Get current user profile (Regular users only)' }) + @ApiResponse({ + status: 200, + description: 'User profile retrieved', + schema: { + example: { + id: 'uuid', + userId: 'uuid', + email: 'user@example.com', + firstName: 'John', + lastName: 'Doe', + phone: '+1234567890', + avatar: 'users/uuid/avatar/image.jpg', + city: 'New York', + state: 'NY', + country: 'USA', + }, + }, + }) + async getMyProfile(@CurrentUser() user: { id: string }) { + return this.usersService.getMyUserProfile(user.id); + } + + @Patch('profile/me') + @Roles(UserRole.USER) + @ApiOperation({ summary: 'Update current user profile (Regular users only)' }) + @ApiResponse({ + status: 200, + description: 'User profile updated', + }) + async updateMyProfile( + @CurrentUser() user: { id: string }, + @Body() + data: { + firstName?: string; + lastName?: string; + phone?: string; + avatar?: string | null; + city?: string; + state?: string; + country?: string; + }, + ) { + return this.usersService.updateMyUserProfile(user.id, data); + } + + // ============================================= + // ADMIN ENDPOINTS + // ============================================= + @Get() @Roles(UserRole.ADMIN) @ApiOperation({ summary: 'Get all users (Admin only)' }) diff --git a/src/users/users.service.ts b/src/users/users.service.ts index c9ae66d..15e5490 100644 --- a/src/users/users.service.ts +++ b/src/users/users.service.ts @@ -10,6 +10,105 @@ type UserWithProfiles = Prisma.UserGetPayload<{ export class UsersService { constructor(private readonly prisma: PrismaService) {} + // ============================================= + // USER PROFILE METHODS (for regular users) + // ============================================= + + async getMyUserProfile(userId: string) { + const user = await this.prisma.user.findUnique({ + where: { id: userId }, + include: { userProfile: true }, + }); + + if (!user) { + throw new NotFoundException('User not found'); + } + + if (user.role !== UserRole.USER) { + throw new BadRequestException('This endpoint is for regular users only'); + } + + // Create user profile if it doesn't exist + let profile = user.userProfile; + if (!profile) { + profile = await this.prisma.userProfile.create({ + data: { + userId: user.id, + }, + }); + } + + return { + id: profile.id, + userId: user.id, + email: user.email, + firstName: profile.firstName, + lastName: profile.lastName, + phone: profile.phone, + avatar: profile.avatar, + city: profile.city, + state: profile.state, + country: profile.country, + createdAt: profile.createdAt, + updatedAt: profile.updatedAt, + }; + } + + async updateMyUserProfile( + userId: string, + data: { + firstName?: string; + lastName?: string; + phone?: string; + avatar?: string | null; + city?: string; + state?: string; + country?: string; + }, + ) { + const user = await this.prisma.user.findUnique({ + where: { id: userId }, + include: { userProfile: true }, + }); + + if (!user) { + throw new NotFoundException('User not found'); + } + + if (user.role !== UserRole.USER) { + throw new BadRequestException('This endpoint is for regular users only'); + } + + // Create or update user profile + const profile = await this.prisma.userProfile.upsert({ + where: { userId }, + create: { + userId, + ...data, + }, + update: data, + }); + + return { + id: profile.id, + userId: user.id, + email: user.email, + firstName: profile.firstName, + lastName: profile.lastName, + phone: profile.phone, + avatar: profile.avatar, + city: profile.city, + state: profile.state, + country: profile.country, + createdAt: profile.createdAt, + updatedAt: profile.updatedAt, + }; + } + + // ============================================= + // ADMIN METHODS + // ============================================= + async findAll(page = 1, limit = 10, role?: UserRole) { const skip = (page - 1) * limit;