diff --git a/backend/package-lock.json b/backend/package-lock.json index 9c25102..4a48a51 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -35,6 +35,7 @@ "@types/aws-serverless-express": "^3.3.10", "@types/cookie-parser": "^1.4.10", "@types/jest": "^30.0.0", + "@types/multer": "^2.0.0", "@types/node": "^25.0.9", "aws-sdk": "^2.1693.0", "esbuild": "^0.27.2", @@ -205,6 +206,7 @@ "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", @@ -2304,6 +2306,7 @@ "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.1.12.tgz", "integrity": "sha512-v6U3O01YohHO+IE3EIFXuRuu3VJILWzyMmSYZXpyBbnp0hk0mFyHxK2w3dF4I5WnbwiRbWlEXdeXFvPQ7qaZzw==", "license": "MIT", + "peer": true, "dependencies": { "file-type": "21.3.0", "iterare": "1.2.1", @@ -2336,6 +2339,7 @@ "integrity": "sha512-97DzTYMf5RtGAVvX1cjwpKRiCUpkeQ9CCzSAenqkAhOmNVVFaApbhuw+xrDt13rsCa2hHVOYPrV4dBgOYMJjsA==", "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "@nuxt/opencollective": "0.4.1", "fast-safe-stringify": "2.1.1", @@ -2419,6 +2423,7 @@ "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.12.tgz", "integrity": "sha512-GYK/vHI0SGz5m8mxr7v3Urx8b9t78Cf/dj5aJMZlGd9/1D9OI1hAl00BaphjEXINUJ/BQLxIlF2zUjrYsd6enQ==", "license": "MIT", + "peer": true, "dependencies": { "cors": "2.8.5", "express": "5.2.1", @@ -3192,6 +3197,7 @@ "integrity": "sha512-UZUw8vjpWFXuDnjFTh7/5c2TWDlQqeXHi6hcN7F2XSVT5P+WmUnnbFS3KA6Jnc6IsEqI2qCVu2bK0R0J4A8ZQQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^5.0.0", @@ -3286,6 +3292,16 @@ "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", "license": "MIT" }, + "node_modules/@types/multer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.0.0.tgz", + "integrity": "sha512-C3Z9v9Evij2yST3RSBktxP9STm6OdMc5uR1xF1SGr98uv8dUlAL2hqwrZ3GVB3uyMyiegnscEK6PGtYvNrjTjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, "node_modules/@types/node": { "version": "25.0.9", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.9.tgz", @@ -4000,6 +4016,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4026,6 +4043,7 @@ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -4492,6 +4510,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -4712,6 +4731,7 @@ "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "readdirp": "^4.0.1" }, @@ -4759,13 +4779,15 @@ "version": "0.5.1", "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz", "integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/class-validator": { "version": "0.14.3", "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.3.tgz", "integrity": "sha512-rXXekcjofVN1LTOSw+u4u9WXVEUvNBVjORW154q/IdmYWy1nMbOU9aNtZB0t8m+FJQ9q91jlr2f9CwwUFdFMRA==", "license": "MIT", + "peer": true, "dependencies": { "@types/validator": "^13.15.3", "libphonenumber-js": "^1.11.1", @@ -6549,6 +6571,7 @@ "integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "30.2.0", "@jest/types": "30.2.0", @@ -8131,6 +8154,7 @@ "resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz", "integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==", "license": "MIT", + "peer": true, "dependencies": { "passport-strategy": "1.x.x", "pause": "0.0.1", @@ -8522,7 +8546,8 @@ "version": "0.2.2", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", - "license": "Apache-2.0" + "license": "Apache-2.0", + "peer": true }, "node_modules/require-directory": { "version": "2.1.1", @@ -8727,6 +8752,7 @@ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -9759,6 +9785,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -9995,6 +10022,7 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -10218,6 +10246,7 @@ "integrity": "sha512-Qphch25abbMNtekmEGJmeRUhLDbe+QfiWTiqpKYkpCOWY64v9eyl+KRRLmqOFA2AvKPpc9DC6+u2n76tQLBoaA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", diff --git a/backend/package.json b/backend/package.json index c33d7fc..2e99e47 100644 --- a/backend/package.json +++ b/backend/package.json @@ -39,6 +39,7 @@ "@types/aws-serverless-express": "^3.3.10", "@types/cookie-parser": "^1.4.10", "@types/jest": "^30.0.0", + "@types/multer": "^2.0.0", "@types/node": "^25.0.9", "aws-sdk": "^2.1693.0", "esbuild": "^0.27.2", diff --git a/backend/src/user/__test__/user.service.spec.ts b/backend/src/user/__test__/user.service.spec.ts index 1d713e5..47596d9 100644 --- a/backend/src/user/__test__/user.service.spec.ts +++ b/backend/src/user/__test__/user.service.spec.ts @@ -25,6 +25,9 @@ const mockAdminDeleteUser = vi.fn(); // Mock SES functions const mockSendEmail = vi.fn(); +// Mock S3 functions +const mockS3Upload = vi.fn(); + // Mock AWS SDK ONCE with proper structure for import * as AWS vi.mock('aws-sdk', () => { return { @@ -51,6 +54,11 @@ vi.mock('aws-sdk', () => { return { sendEmail: mockSendEmail, }; + }), + S3: vi.fn(function() { + return { + upload: mockS3Upload, + }; }) }, CognitoIdentityServiceProvider: vi.fn(function() { @@ -75,6 +83,11 @@ vi.mock('aws-sdk', () => { return { sendEmail: mockSendEmail, }; + }), + S3: vi.fn(function() { + return { + upload: mockS3Upload, + }; }) }; }); @@ -142,6 +155,7 @@ describe('UserController', () => { // Set up environment variables process.env.DYNAMODB_USER_TABLE_NAME = 'test-users-table'; process.env.COGNITO_USER_POOL_ID = 'test-pool-id'; + process.env.PROFILE_PICTURE_BUCKET = 'test-profile-pics-bucket'; }); beforeEach(async () => { @@ -163,6 +177,9 @@ describe('UserController', () => { // Setup SES mocks to return chainable objects with .promise() mockSendEmail.mockReturnValue({ promise: mockPromise }); + // Setup S3 mocks to return chainable objects with .promise() + mockS3Upload.mockReturnValue({ promise: mockPromise }); + // Reset promise mocks to default resolved state mockPromise.mockResolvedValue({}); @@ -175,6 +192,270 @@ describe('UserController', () => { userService = module.get(UserService); }); + // ======================================== + // Tests for uploadProfilePic + // ======================================== + + describe('uploadProfilePic', () => { + const createMockFile = (overrides?: Partial): Express.Multer.File => ({ + fieldname: 'profilePic', + originalname: 'test-image.jpg', + encoding: '7bit', + mimetype: 'image/jpeg', + size: 1024 * 1024, // 1MB + buffer: Buffer.from('fake-image-data'), + destination: '', + filename: '', + path: '', + stream: null as any, + ...overrides, + }); + + it('should successfully upload profile picture', async () => { + const user = mockDatabase.users.find(u => u.userId === 'emp1')!; + const mockFile = createMockFile(); + + // Mock S3 upload success + mockPromise.mockResolvedValueOnce({ + Location: 'https://test-profile-pics-bucket.s3.amazonaws.com/emp1-profilepic.jpg', + Key: 'emp1-profilepic.jpg', + Bucket: 'test-profile-pics-bucket', + }); + + // Mock DynamoDB update success + mockPromise.mockResolvedValueOnce({ + Attributes: { + ...user, + profilePictureUrl: 'https://test-profile-pics-bucket.s3.amazonaws.com/emp1-profilepic.jpg', + }, + }); + + const result = await userService.uploadProfilePic(user, mockFile); + + // ✅ Result is now just the URL string, not the User object + expect(result).toBe('https://test-profile-pics-bucket.s3.amazonaws.com/emp1-profilepic.jpg'); + expect(mockS3Upload).toHaveBeenCalledWith({ + Bucket: 'test-profile-pics-bucket', + Key: 'emp1-profilepic.jpg', + Body: mockFile.buffer, + ContentType: 'image/jpeg', + }); + expect(mockUpdate).toHaveBeenCalledWith({ + TableName: 'test-users-table', + Key: { userId: 'emp1' }, + UpdateExpression: 'SET profilePictureUrl = :url', + ExpressionAttributeValues: { + ':url': 'https://test-profile-pics-bucket.s3.amazonaws.com/emp1-profilepic.jpg', + }, + ReturnValues: 'ALL_NEW', + }); + }); + + it('should generate correct filename with different extensions', async () => { + const user = mockDatabase.users.find(u => u.userId === 'emp1')!; + const mockFile = createMockFile({ originalname: 'test.png', mimetype: 'image/png' }); + + mockPromise.mockResolvedValueOnce({ + Location: 'https://test-profile-pics-bucket.s3.amazonaws.com/emp1-profilepic.png', + Key: 'emp1-profilepic.png', + }); + mockPromise.mockResolvedValueOnce({ + Attributes: { ...user, profilePictureUrl: 'https://test-profile-pics-bucket.s3.amazonaws.com/emp1-profilepic.png' }, + }); + + await userService.uploadProfilePic(user, mockFile); + + expect(mockS3Upload).toHaveBeenCalledWith( + expect.objectContaining({ + Key: 'emp1-profilepic.png', + }) + ); + }); + + it('should throw BadRequestException when user object is invalid', async () => { + const mockFile = createMockFile(); + + await expect( + userService.uploadProfilePic(null as any, mockFile) + ).rejects.toThrow('Valid user object is required'); + + await expect( + userService.uploadProfilePic({ userId: '' } as any, mockFile) + ).rejects.toThrow('Valid user object is required'); + }); + + it('should throw BadRequestException when file is invalid', async () => { + const user = mockDatabase.users.find(u => u.userId === 'emp1')!; + + await expect( + userService.uploadProfilePic(user, null as any) + ).rejects.toThrow('Valid image file is required'); + + await expect( + userService.uploadProfilePic(user, { buffer: null } as any) + ).rejects.toThrow('Valid image file is required'); + }); + + it('should throw BadRequestException for invalid file type', async () => { + const user = mockDatabase.users.find(u => u.userId === 'emp1')!; + const mockFile = createMockFile({ mimetype: 'application/pdf' }); + + await expect( + userService.uploadProfilePic(user, mockFile) + ).rejects.toThrow('Invalid file type'); + }); + + it('should throw BadRequestException for file too large', async () => { + const user = mockDatabase.users.find(u => u.userId === 'emp1')!; + const mockFile = createMockFile({ size: 10 * 1024 * 1024 }); // 10MB + + await expect( + userService.uploadProfilePic(user, mockFile) + ).rejects.toThrow('File too large'); + }); + + it('should accept all allowed image types', async () => { + const user = mockDatabase.users.find(u => u.userId === 'emp1')!; + const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp']; + + for (const mimetype of allowedTypes) { + // Clear all mocks + vi.clearAllMocks(); + + // Reset the mock implementations + mockS3Upload.mockReturnValue({ promise: mockPromise }); + mockUpdate.mockReturnValue({ promise: mockPromise }); + + // Mock S3 upload success + mockPromise + .mockResolvedValueOnce({ + Location: 'https://test.com/image.jpg', + Key: 'key', + Bucket: 'test-profile-pics-bucket' + }) + // Mock DynamoDB update success + .mockResolvedValueOnce({ + Attributes: { + ...user, + profilePictureUrl: 'https://test.com/image.jpg' + } + }); + + const mockFile = createMockFile({ mimetype }); + const result = await userService.uploadProfilePic(user, mockFile); + + // ✅ Result is now just the URL string + expect(result).toBeDefined(); + expect(result).toBe('https://test.com/image.jpg'); + } + }); + + it('should handle S3 NoSuchBucket error', async () => { + const user = mockDatabase.users.find(u => u.userId === 'emp1')!; + const mockFile = createMockFile(); + + const s3Error = { code: 'NoSuchBucket', message: 'Bucket does not exist' }; + mockPromise.mockRejectedValueOnce(s3Error); + + await expect( + userService.uploadProfilePic(user, mockFile) + ).rejects.toThrow('Storage bucket not found'); + }); + + it('should handle S3 AccessDenied error', async () => { + const user = mockDatabase.users.find(u => u.userId === 'emp1')!; + const mockFile = createMockFile(); + + const s3Error = { code: 'AccessDenied', message: 'Access denied' }; + mockPromise.mockRejectedValueOnce(s3Error); + + await expect( + userService.uploadProfilePic(user, mockFile) + ).rejects.toThrow('Insufficient permissions to upload file'); + }); + + it('should handle DynamoDB update failure', async () => { + const user = mockDatabase.users.find(u => u.userId === 'emp1')!; + const mockFile = createMockFile(); + + // S3 upload succeeds + mockPromise.mockResolvedValueOnce({ + Location: 'https://test-profile-pics-bucket.s3.amazonaws.com/emp1-profilepic.jpg', + Key: 'emp1-profilepic.jpg', + }); + + // DynamoDB update fails + const dynamoError = { code: 'ResourceNotFoundException', message: 'Table not found' }; + mockPromise.mockRejectedValueOnce(dynamoError); + + await expect( + userService.uploadProfilePic(user, mockFile) + ).rejects.toThrow('Database table not found'); + }); + + it('should handle DynamoDB ValidationException', async () => { + const user = mockDatabase.users.find(u => u.userId === 'emp1')!; + const mockFile = createMockFile(); + + // S3 upload succeeds + mockPromise.mockResolvedValueOnce({ + Location: 'https://test-profile-pics-bucket.s3.amazonaws.com/emp1-profilepic.jpg', + Key: 'emp1-profilepic.jpg', + }); + + // DynamoDB update fails with ValidationException + const dynamoError = { code: 'ValidationException', message: 'Invalid parameters' }; + mockPromise.mockRejectedValueOnce(dynamoError); + + await expect( + userService.uploadProfilePic(user, mockFile) + ).rejects.toThrow('Invalid update parameters'); + }); + + it('should throw InternalServerErrorException when DynamoDB does not return attributes', async () => { + const user = mockDatabase.users.find(u => u.userId === 'emp1')!; + const mockFile = createMockFile(); + + // S3 upload succeeds + mockPromise.mockResolvedValueOnce({ + Location: 'https://test-profile-pics-bucket.s3.amazonaws.com/emp1-profilepic.jpg', + Key: 'emp1-profilepic.jpg', + }); + + // DynamoDB update succeeds but doesn't return Attributes + mockPromise.mockResolvedValueOnce({}); + + await expect( + userService.uploadProfilePic(user, mockFile) + ).rejects.toThrow('Failed to retrieve updated user data'); + }); + + it('should throw InternalServerErrorException when bucket env var is not set', async () => { + const originalBucket = process.env.PROFILE_PICTURE_BUCKET; + delete process.env.PROFILE_PICTURE_BUCKET; + + // Create a new service instance to pick up the env change + const module: TestingModule = await Test.createTestingModule({ + providers: [UserService], + }).compile(); + const testService = module.get(UserService); + + const user = mockDatabase.users.find(u => u.userId === 'emp1')!; + const mockFile = createMockFile(); + + await expect( + testService.uploadProfilePic(user, mockFile) + ).rejects.toThrow('Server configuration error'); + + // Restore env var + process.env.PROFILE_PICTURE_BUCKET = originalBucket; + }); + }); + + // ======================================== + // Existing tests... + // ======================================== + it('should get all users from mock database', async () => { // Setup the mock response using our mock database mockPromise.mockResolvedValueOnce(mockDatabase.scan({ TableName: 'test-users-table' })); diff --git a/backend/src/user/types/user.types.ts b/backend/src/user/types/user.types.ts index 8cd99c2..b067502 100644 --- a/backend/src/user/types/user.types.ts +++ b/backend/src/user/types/user.types.ts @@ -1,11 +1,13 @@ import { ApiProperty } from '@nestjs/swagger'; import { UserStatus } from '../../../../middle-layer/types/UserStatus'; +import { User } from '../../types/User'; export class ChangeRoleBody { - user!: { - userId: string, - position: UserStatus, - email: string - }; + user!: User; groupName!: UserStatus; +} + +export class UploadProfilePicBody{ + user! : User ; + file! : Express.Multer.File; } \ No newline at end of file diff --git a/backend/src/user/user.controller.ts b/backend/src/user/user.controller.ts index f32729a..5137f31 100644 --- a/backend/src/user/user.controller.ts +++ b/backend/src/user/user.controller.ts @@ -1,10 +1,11 @@ -import { Controller, Get, Patch, Delete, Body, Param, UseGuards, Req } from "@nestjs/common"; +import { Controller, Get, Patch, Delete, Body, Param, UseGuards, Req, Post, UseInterceptors, UploadedFile, BadRequestException } from "@nestjs/common"; import { UserService } from "./user.service"; import { User } from "../../../middle-layer/types/User"; import { UserStatus } from "../../../middle-layer/types/UserStatus"; import { VerifyAdminRoleGuard, VerifyUserGuard, VerifyAdminOrEmployeeRoleGuard } from "../guards/auth.guard"; -import { ApiResponse, ApiParam , ApiBearerAuth} from "@nestjs/swagger"; -import { ChangeRoleBody } from "./types/user.types"; +import { ApiResponse, ApiParam , ApiBearerAuth, ApiOperation, ApiConsumes, ApiBody} from "@nestjs/swagger"; +import { ChangeRoleBody, UploadProfilePicBody } from "./types/user.types"; +import { FileInterceptor } from "@nestjs/platform-express"; @Controller("user") export class UserController { @@ -231,4 +232,93 @@ export class UserController { async getUserById(@Param('id') userId: string): Promise { return await this.userService.getUserById(userId); } + +@Post('upload-pfp') +@ApiOperation({ + summary: 'Upload profile picture', + description: 'Uploads a profile picture for a user to S3 and updates the user record in DynamoDB with the image URL. Returns the S3 URL of the uploaded image.' +}) +@ApiConsumes('multipart/form-data') +@ApiBody({ + description: 'Profile picture upload with user information', + schema: { + type: 'object', + required: ['profilePic', 'user'], + properties: { + profilePic: { + type: 'string', + format: 'binary', + description: 'Image file (jpg, jpeg, png, gif, webp). Max size: 5MB' + }, + user: { + type: 'string', + description: 'User object as JSON string containing userId, position, and email', + example: '{"userId":"user-123","position":"Employee","email":"john@example.com"}' + } + } + } +}) +@ApiResponse({ + status: 200, + description: 'Profile picture uploaded successfully. Returns the S3 URL of the uploaded image.', + schema: { + type: 'string', + example: 'https://bcan-pics.s3.amazonaws.com/user-123-profilepic.jpg', + description: 'Full S3 URL where the profile picture is stored' + } +}) +@ApiResponse({ + status: 400, + description: 'Bad Request - Invalid file type, file too large, invalid user data format, missing required fields, or JSON parse error', + schema: { + type: 'object', + properties: { + statusCode: { type: 'number', example: 400 }, + message: { + type: 'string', + example: 'Invalid file type. Allowed types: image/jpeg, image/jpg, image/png, image/gif, image/webp' + }, + error: { type: 'string', example: 'Bad Request' } + } + } +}) +@ApiResponse({ + status: 401, + description: 'Unauthorized - Missing or invalid authentication token' +}) +@ApiResponse({ + status: 403, + description: 'Forbidden - User does not have permission to upload profile pictures' +}) +@ApiResponse({ + status: 500, + description: 'Internal Server Error - S3 upload failed, DynamoDB update failed, or server configuration error', + schema: { + type: 'object', + properties: { + statusCode: { type: 'number', example: 500 }, + message: { + type: 'string', + example: 'Failed to upload profile picture' + }, + error: { type: 'string', example: 'Internal Server Error' } + } + } +}) +@UseGuards(VerifyAdminOrEmployeeRoleGuard) +@ApiBearerAuth() +@UseInterceptors(FileInterceptor('profilePic')) +async uploadProfilePic( + @UploadedFile() file: Express.Multer.File, + @Body('user') userJson: string, +): Promise { + try { + // Parse the JSON string to User object + const user: User = JSON.parse(userJson); + + return await this.userService.uploadProfilePic(user, file); + } catch (error) { + throw new BadRequestException('Invalid user data format'); + } +} } diff --git a/backend/src/user/user.service.ts b/backend/src/user/user.service.ts index 34105d8..78380fd 100644 --- a/backend/src/user/user.service.ts +++ b/backend/src/user/user.service.ts @@ -22,7 +22,126 @@ export class UserService { private readonly logger = new Logger(UserService.name); private dynamoDb = new AWS.DynamoDB.DocumentClient(); private ses = new AWS.SES({ region: process.env.AWS_REGION }); + private s3 = new AWS.S3(); + private profilePicBucket : string = process.env.PROFILE_PICTURE_BUCKET!; + async uploadProfilePic(user: User, pic: Express.Multer.File): Promise { + const tableName = process.env.DYNAMODB_USER_TABLE_NAME; + + // 1. Validate all inputs + this.validateUploadInputs(user, pic, tableName); + + // 2. Generate filename: userId-profilepic.ext + const fileExtension = pic.originalname.split('.').pop()?.toLowerCase() || 'jpg'; + const key = `${user.userId}-profilepic.${fileExtension}`; + + this.logger.log(`Uploading profile picture for user ${user.userId} with key: ${key}`); + + try { + // 3. Upload to S3 + const uploadParams: AWS.S3.PutObjectRequest = { + Bucket: this.profilePicBucket, + Key: key, + Body: pic.buffer, + ContentType: pic.mimetype, + }; + + const uploadResult = await this.s3.upload(uploadParams).promise(); + this.logger.log(`✓ Profile picture uploaded to S3: ${uploadResult.Location}`); + + // 4. Update user's profile picture URL in DynamoDB + const updateParams = { + TableName: tableName!, + Key: { userId: user.userId }, + UpdateExpression: "SET profilePictureUrl = :url", + ExpressionAttributeValues: { + ":url": uploadResult.Location, + }, + ReturnValues: "ALL_NEW" as const, + }; + + const updateResult = await this.dynamoDb.update(updateParams).promise(); + + if (!updateResult.Attributes) { + this.logger.error(`DynamoDB update did not return updated attributes for ${user.userId}`); + throw new InternalServerErrorException("Failed to retrieve updated user data"); + } + + this.logger.log(`✅ Profile picture uploaded successfully for user ${user.userId}`); + return updateResult.Attributes.profilePictureUrl; + + } catch (error: any) { + this.logger.error(`Failed to upload profile picture for ${user.userId}:`, error); + + // Handle S3 errors + if (error.code === 'NoSuchBucket') { + this.logger.error(`S3 bucket does not exist: ${this.profilePicBucket}`); + throw new InternalServerErrorException('Storage bucket not found'); + } else if (error.code === 'AccessDenied') { + this.logger.error('Access denied to S3 bucket'); + throw new InternalServerErrorException('Insufficient permissions to upload file'); + } + + // Handle DynamoDB errors + if (error.code === 'ResourceNotFoundException') { + this.logger.error('DynamoDB table does not exist'); + throw new InternalServerErrorException('Database table not found'); + } else if (error.code === 'ValidationException') { + this.logger.error(`Invalid DynamoDB update parameters`); + throw new BadRequestException(`Invalid update parameters`); + } + + if (error instanceof HttpException) { + throw error; + } + + throw new InternalServerErrorException('Failed to upload profile picture'); + } +} + +// Validation helper method for profile picture uploads +private validateUploadInputs(user: User, pic: Express.Multer.File, tableName: string | undefined): void { + // Validate environment variables + if (!this.profilePicBucket) { + this.logger.error("Profile Picture Bucket is not defined in environment variables"); + throw new InternalServerErrorException("Server configuration error"); + } + + if (!tableName) { + this.logger.error("DynamoDB User Table Name is not defined in environment variables"); + throw new InternalServerErrorException("Server configuration error"); + } + + // Validate user object + if (!user || !user.userId) { + this.logger.error("Invalid user object provided for profile picture upload"); + throw new BadRequestException("Valid user object is required"); + } + + // Validate file exists + if (!pic || !pic.buffer) { + this.logger.error("Invalid file provided for upload"); + throw new BadRequestException("Valid image file is required"); + } + + // Validate file type + const allowedMimeTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp']; + if (!allowedMimeTypes.includes(pic.mimetype)) { + this.logger.error(`Invalid file type: ${pic.mimetype}`); + throw new BadRequestException( + `Invalid file type. Allowed types: ${allowedMimeTypes.join(', ')}` + ); + } + + // Validate file size (5MB max) + const maxSizeInBytes = 5 * 1024 * 1024; + if (pic.size > maxSizeInBytes) { + this.logger.error(`File too large: ${pic.size} bytes`); + throw new BadRequestException( + `File too large. Maximum size: ${maxSizeInBytes / (1024 * 1024)}MB` + ); + } +} // purpose statement: deletes user from database; only admin can delete users // use case: employee is no longer with BCAN async deleteUser(user: User, requestedBy: User): Promise {