Skip to content

Commit

Permalink
Merge pull request #771 from hngprojects/dev
Browse files Browse the repository at this point in the history
Merge branch dev into staging
  • Loading branch information
Homoakin619 authored Aug 12, 2024
2 parents 2c77813 + 66e464c commit 7af707c
Show file tree
Hide file tree
Showing 11 changed files with 113 additions and 44 deletions.
6 changes: 6 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@
"class-validator": "^0.14.1",
"csv-writer": "^1.6.0",
"date-fns": "^3.6.0",
"file-type-mime": "^0.4.3",
"google-auth-library": "^9.13.0",
"handlebars": "^4.7.8",
"html-validator": "^6.0.1",
Expand Down
9 changes: 9 additions & 0 deletions src/helpers/SystemMessages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,3 +96,12 @@ export const PROFILE_PIC_NOT_FOUND = 'Previous profile picture pic not found';
export const ERROR_DIRECTORY = 'Error creating uploads directory:';
export const DIRECTORY_CREATED = 'Uploads directory created at:';
export const PICTURE_UPDATED = 'Profile picture updated successfully';
export const FILE_SAVE_ERROR = 'Error saving file to disk';
export const FILE_EXCEEDS_SIZE = resource => {
return `File size exceeds ${resource} MB limit`

};
export const INVALID_FILE_TYPE = resource => {
return `Invalid file type. Allowed types: ${resource}`;

};
6 changes: 6 additions & 0 deletions src/helpers/app-constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import * as path from 'path';

export const MAX_PROFILE_PICTURE_SIZE = 2 * 1024 * 1024;
export const VALID_UPLOADS_MIME_TYPES = ['image/jpeg', 'image/png'];
export const BASE_URL = "https://staging.api-nestjs.boilerplate.hng.tech";
export const PROFILE_PHOTO_UPLOADS = path.join(__dirname, '..', 'uploads')
40 changes: 30 additions & 10 deletions src/modules/profile/dto/file.validator.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,49 @@
import { PipeTransform, Injectable, ArgumentMetadata, HttpStatus } from '@nestjs/common';
import { CustomHttpException } from '../../../helpers/custom-http-filter';
import * as fileType from 'file-type-mime';
import { FILE_EXCEEDS_SIZE, INVALID_FILE_TYPE } from '../../../helpers/SystemMessages';


export interface CustomUploadTypeValidatorOptions {
fileType: string[];
}

@Injectable()
export class FileValidator implements PipeTransform {
constructor(private readonly options: { maxSize: number; mimeTypes: string[] }) {}
constructor(private readonly options: { maxSize: number; mimeTypes: string[] }) {

}

transform(value: Express.Multer.File, metadata: ArgumentMetadata) {
async transform(value: Express.Multer.File, metadata: ArgumentMetadata) {
if (!value) {
throw new CustomHttpException('No file provided', HttpStatus.BAD_REQUEST);
}

if (value.size > this.options.maxSize) {
throw new CustomHttpException(
`File size exceeds ${this.options.maxSize / (1024 * 1024)}MB limit`,
this.validateFileSize(value.size)
await this.validateFileType(value.buffer)

return value;
}

private validateFileSize(size: number) {
if (size > this.options.maxSize) {
throw new CustomHttpException(
FILE_EXCEEDS_SIZE(this.options.maxSize / (1024 * 1024)),
HttpStatus.BAD_REQUEST
);
}
}

if (!this.options.mimeTypes.includes(value.mimetype)) {
throw new CustomHttpException(
`Invalid file type. Allowed types: ${this.options.mimeTypes.join(', ')}`,

private async validateFileType(buffer: Buffer) {
const response = await fileType.parse(buffer);
if (!response || !this.options.mimeTypes.includes(response.mime)) {
throw new CustomHttpException(
INVALID_FILE_TYPE(this.options.mimeTypes.join(', ')),
HttpStatus.BAD_REQUEST
);
}

return value;
}


}
2 changes: 1 addition & 1 deletion src/modules/profile/dto/upload-profile-pic.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export class UploadProfilePicDto {
description: 'Profile picture file',
maxLength: 2 * 1024 * 1024,
})
@HasMimeType(['image/jpeg', 'image/png', 'image/gif'])
@HasMimeType(['image/jpeg', 'image/png'])
@MaxFileSize(2 * 1024 * 1024)
file: Express.Multer.File;
}
30 changes: 19 additions & 11 deletions src/modules/profile/profile.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,19 @@ import {
UsePipes,
} from '@nestjs/common';
import { ProfileService } from './profile.service';
import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { UpdateProfileDto } from './dto/update-profile.dto';
import { ApiBearerAuth, ApiBody, ApiConsumes, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { ResponseInterceptor } from '../../shared/inteceptors/response.interceptor';
import { UploadProfilePicDto } from './dto/upload-profile-pic.dto';
import { FileInterceptor } from '@nestjs/platform-express';
import { FileValidator } from './dto/file.validator';
import * as dotenv from 'dotenv';
import { UpdateProfileDto } from './dto/update-profile.dto';
import {
BASE_URL,
MAX_PROFILE_PICTURE_SIZE,
VALID_UPLOADS_MIME_TYPES,
} from '../../helpers/app-constants';


dotenv.config();
@ApiBearerAuth()
@ApiTags('Profile')
@Controller('profile')
Expand Down Expand Up @@ -68,26 +72,30 @@ export class ProfileController {
description: 'Profile picture uploaded successfully',
})
@Post('upload-image')
@UseInterceptors(FileInterceptor('file'))
@UseInterceptors(FileInterceptor('avatar'))
@ApiConsumes('multipart/form-data')
@ApiBody({
type: UploadProfilePicDto,
description: 'Profile picture file',
})

@UsePipes(new ValidationPipe({ transform: true }))
async uploadProfilePicture(
@Req() req: any,
@UploadedFile(
new FileValidator({
maxSize: 2 * 1024 * 1024,
mimeTypes: ['image/jpeg', 'image/png'],
maxSize: MAX_PROFILE_PICTURE_SIZE,
mimeTypes: VALID_UPLOADS_MIME_TYPES,
})
)
file: Express.Multer.File
): Promise<{
status: number;
message: string;
}> {
const isDevelopment = process.env.NODE_ENV === 'development';
const baseUrl = isDevelopment ? `${req.protocol}://${req.get('host')}` : process.env.BASE_URL;
const userId = req.user.id;
const uploadProfilePicDto = new UploadProfilePicDto();
const uploadProfilePicDto = new UploadProfilePicDto()
uploadProfilePicDto.file = file;
return await this.profileService.uploadProfilePicture(userId, uploadProfilePicDto, baseUrl);
return await this.profileService.uploadProfilePicture(userId, uploadProfilePicDto, BASE_URL);
}
}
47 changes: 31 additions & 16 deletions src/modules/profile/profile.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
HttpStatus,
Injectable,
InternalServerErrorException,
Logger,
NotFoundException,
} from '@nestjs/common';
import { Profile } from './entities/profile.entity';
Expand All @@ -11,11 +12,13 @@ import { InjectRepository } from '@nestjs/typeorm';
import { User } from '../user/entities/user.entity';
import { UpdateProfileDto } from './dto/update-profile.dto';
import * as sharp from 'sharp';
import * as fs from 'fs/promises';
import * as fs from 'fs';
import * as path from 'path';
import { CustomHttpException } from '../../helpers/custom-http-filter';
import * as SYS_MSG from '../../helpers/SystemMessages';
import { UploadProfilePicDto } from './dto/upload-profile-pic.dto';
import { PROFILE_PHOTO_UPLOADS } from '../../helpers/app-constants';
import { pipeline, Readable } from 'stream';

@Injectable()
export class ProfileService {
Expand All @@ -25,7 +28,7 @@ export class ProfileService {
@InjectRepository(Profile) private profileRepository: Repository<Profile>,
@InjectRepository(User) private userRepository: Repository<User>
) {
this.uploadsDir = path.join(__dirname, '..', '..', 'uploads');
this.uploadsDir = PROFILE_PHOTO_UPLOADS;
this.createUploadsDirectory().catch(error => {
console.error('Failed to create uploads directory:', error);
});
Expand All @@ -40,7 +43,7 @@ export class ProfileService {

const userProfile = await this.userRepository.findOne({
where: { id: userId },
relations: ['profile']
relations: ['profile'],
});

const profile = userProfile.profile;
Expand Down Expand Up @@ -129,7 +132,7 @@ export class ProfileService {
throw new CustomHttpException(SYS_MSG.USER_NOT_FOUND, HttpStatus.NOT_FOUND);
}

const profile = user.profile
const profile = user.profile;
if (!profile) {
throw new CustomHttpException(SYS_MSG.PROFILE_NOT_FOUND, HttpStatus.NOT_FOUND);
}
Expand All @@ -138,8 +141,8 @@ export class ProfileService {
const previousFilePath = path.join(this.uploadsDir, path.basename(profile.profile_pic_url));

try {
await fs.access(previousFilePath);
await fs.unlink(previousFilePath);
await fs.promises.access(previousFilePath);
await fs.promises.unlink(previousFilePath);
} catch (error) {
if (error.code === 'ENOENT') {
console.error(SYS_MSG.PROFILE_PIC_NOT_FOUND, previousFilePath);
Expand All @@ -153,23 +156,35 @@ export class ProfileService {
const fileName = `${userId}${fileExtension}`;
const filePath = path.join(this.uploadsDir, fileName);

await sharp(uploadProfilePicDto.file.buffer).resize({ width: 200, height: 200 }).toFile(filePath);
const fileStream = Readable.from(uploadProfilePicDto.file.buffer);
const writeStream = fs.createWriteStream(filePath);

profile.profile_pic_url = `${baseUrl}/uploads/${fileName}`;
return new Promise((resolve, reject) => {
pipeline(fileStream, writeStream, async err => {
if (err) {
Logger.error(SYS_MSG.FILE_SAVE_ERROR, err.stack);
reject(new CustomHttpException(SYS_MSG.FILE_SAVE_ERROR, HttpStatus.INTERNAL_SERVER_ERROR));
} else {
await sharp(uploadProfilePicDto.file.buffer).resize({ width: 200, height: 200 }).toFile(filePath);

await this.profileRepository.update(profile.id, profile);
const updatedProfile = await this.profileRepository.findOne({ where: { id: profile.id } });
profile.profile_pic_url = `${baseUrl}/uploads/${fileName}`;

await this.profileRepository.update(profile.id, profile);
const updatedProfile = await this.profileRepository.findOne({ where: { id: profile.id } });
resolve({
status: HttpStatus.OK,
message: SYS_MSG.PICTURE_UPDATED,
data: { profile_picture_url: updatedProfile.profile_pic_url },
});
}
});
});

return {
status: HttpStatus.OK,
message: SYS_MSG.PICTURE_UPDATED,
data: { profile_picture_url: updatedProfile.profile_pic_url },
};
}

private async createUploadsDirectory() {
try {
await fs.mkdir(this.uploadsDir, { recursive: true });
await fs.promises.mkdir(this.uploadsDir, { recursive: true });
} catch (error) {
console.error(SYS_MSG.ERROR_DIRECTORY, error);
}
Expand Down
14 changes: 8 additions & 6 deletions src/modules/profile/tests/profile.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,12 @@ import { Profile } from '../entities/profile.entity';
import { NotFoundException, InternalServerErrorException, HttpStatus } from '@nestjs/common';
import { User } from '../../user/entities/user.entity';
import { UpdateProfileDto } from '../dto/update-profile.dto';
import * as path from 'path';
import * as fs from 'fs/promises';
import * as fs from 'fs';
import * as sharp from 'sharp';
import { CustomHttpException } from '../../../helpers/custom-http-filter';
import { PICTURE_UPDATED } from '../../../helpers/SystemMessages';
import { mockUser } from '../../../modules/invite/mocks/mockUser';
import { mockUserWithProfile } from '../mocks/mockUser';
jest.mock('fs/promises');
jest.mock('sharp');
describe('ProfileService', () => {
let service: ProfileService;
Expand Down Expand Up @@ -193,6 +191,7 @@ describe('ProfileService', () => {
};
const mockUploadProfilePicDto = { file: mockFile as any };


it('should throw an exception if no file is provided', async () => {
await expect(service.uploadProfilePicture(userId, { file: null }, baseUrl)).rejects.toThrow(CustomHttpException);
});
Expand All @@ -217,6 +216,7 @@ describe('ProfileService', () => {
});

it('should delete previous profile picture if it exists', async () => {

jest.spyOn(userRepository, 'findOne').mockResolvedValue(mockUser);

jest.spyOn(userRepository, 'findOne').mockResolvedValue(mockUser);
Expand All @@ -228,8 +228,8 @@ describe('ProfileService', () => {
toFile: jest.fn().mockResolvedValue(undefined),
} as any);

const mockUnlink = jest.spyOn(fs, 'unlink').mockResolvedValue(undefined);
const mockAccess = jest.spyOn(fs, 'access').mockResolvedValue(undefined);
const mockUnlink = jest.spyOn(fs.promises, 'unlink').mockResolvedValue(undefined);
const mockAccess = jest.spyOn(fs.promises, 'access').mockResolvedValue(undefined);

await service.uploadProfilePicture(userId, mockUploadProfilePicDto, baseUrl);

Expand All @@ -238,6 +238,7 @@ describe('ProfileService', () => {
});

it('should handle non-existent previous profile picture', async () => {

const mockResult: UpdateResult = {
generatedMaps: [],
raw: [],
Expand All @@ -247,7 +248,7 @@ describe('ProfileService', () => {
jest.spyOn(profileRepository, 'update').mockResolvedValue(mockResult);
jest.spyOn(profileRepository, 'findOne').mockResolvedValue(mockUser.profile);

(fs.access as jest.Mock).mockRejectedValue({ code: 'ENOENT' });
(fs.promises.access as jest.Mock).mockRejectedValue({ code: 'ENOENT' });

(sharp as jest.MockedFunction<typeof sharp>).mockReturnValue({
resize: jest.fn().mockReturnThis(),
Expand All @@ -258,6 +259,7 @@ describe('ProfileService', () => {
});

it('should save new profile picture and update profile', async () => {

jest.spyOn(userRepository, 'findOne').mockResolvedValue(mockUser);

(sharp as jest.MockedFunction<typeof sharp>).mockReturnValue({
Expand Down
1 change: 1 addition & 0 deletions src/uploads/testUserId.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions uploads/testUserId.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.

0 comments on commit 7af707c

Please sign in to comment.