diff --git a/config/s3.config.ts b/config/s3.config.ts new file mode 100644 index 000000000..8dc0544b3 --- /dev/null +++ b/config/s3.config.ts @@ -0,0 +1,8 @@ +import { registerAs } from '@nestjs/config'; + +export default registerAs('s3', () => ({ + accessKey: process.env.AWS_ACCESS_KEY || '', + secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || '', + region: process.env.AWS_REGION || 'us-east-2', + bucketName: process.env.AWS_S3_BUCKET_NAME || '', +})); diff --git a/package.json b/package.json index 847f7b500..e0fc0b5d0 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,7 @@ "@types/nodemailer": "^6.4.17", "@types/speakeasy": "^2.0.10", "@vitalets/google-translate-api": "^9.2.1", - "bcrypt": "^5.1.1", + "aws-sdk": "^2.1692.0", "bcryptjs": "^3.0.2", "bull": "^4.16.5", "class-transformer": "^0.5.1", diff --git a/src/app.module.ts b/src/app.module.ts index 276af8f52..d9cc362d8 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -48,7 +48,7 @@ import { ServeStaticModule } from '@nestjs/serve-static'; import { join } from 'path'; import { LanguageGuard } from '@guards/language.guard'; import { ApiStatusModule } from '@modules/api-status/api-status.module'; - +import s3Config from '@config/s3.config'; @Module({ providers: [ { @@ -82,7 +82,7 @@ import { ApiStatusModule } from '@modules/api-status/api-status.module'; */ envFilePath: ['.env.development.local', `.env.${process.env.PROFILE}`], isGlobal: true, - load: [serverConfig, authConfig], + load: [serverConfig, authConfig, s3Config], validationSchema: Joi.object({ NODE_ENV: Joi.string().valid('development', 'production', 'test', 'provision').required(), PROFILE: Joi.string().valid('local', 'development', 'production', 'ci', 'testing', 'staging').required(), diff --git a/src/modules/auth/auth.service.ts b/src/modules/auth/auth.service.ts index 05f153123..2ac927623 100644 --- a/src/modules/auth/auth.service.ts +++ b/src/modules/auth/auth.service.ts @@ -1,5 +1,5 @@ import { HttpStatus, Injectable, InternalServerErrorException } from '@nestjs/common'; -import * as bcrypt from 'bcrypt'; +import * as bcrypt from 'bcryptjs'; import * as speakeasy from 'speakeasy'; import * as SYS_MSG from '@shared/constants/SystemMessages'; import { JwtService } from '@nestjs/jwt'; @@ -22,6 +22,7 @@ import { RequestSigninTokenDto } from './dto/request-signin-token.dto'; import { OtpDto } from '@modules/otp/dto/otp.dto'; import { DataSource, EntityManager } from 'typeorm'; import { CreateOrganisationRecordOptions } from '@modules/organisations/dto/create-organisation-options'; + @Injectable() export default class AuthenticationService { constructor( diff --git a/src/modules/jobs/dto/job-application.dto.ts b/src/modules/jobs/dto/job-application.dto.ts index 4d1413be7..abec5f494 100644 --- a/src/modules/jobs/dto/job-application.dto.ts +++ b/src/modules/jobs/dto/job-application.dto.ts @@ -1,6 +1,6 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsEmail, IsNotEmpty, IsString } from 'class-validator'; - +import { IsEmail, IsNotEmpty, IsString, IsOptional } from 'class-validator'; +import * as Express from 'express'; export class JobApplicationDto { @IsNotEmpty() @IsString() @@ -16,8 +16,9 @@ export class JobApplicationDto { }) email: string; - @IsNotEmpty() - resume: string; + @IsOptional() + @ApiProperty({ type: 'string', format: 'binary', required: false }) + resume?: any; @IsNotEmpty() @IsString() diff --git a/src/modules/jobs/jobs.controller.ts b/src/modules/jobs/jobs.controller.ts index 0185af653..2a4279216 100644 --- a/src/modules/jobs/jobs.controller.ts +++ b/src/modules/jobs/jobs.controller.ts @@ -11,8 +11,11 @@ import { ValidationPipe, ParseUUIDPipe, Patch, + UseInterceptors, + UploadedFile, } from '@nestjs/common'; import { + ApiConsumes, ApiBadRequestResponse, ApiBearerAuth, ApiBody, @@ -25,6 +28,7 @@ import { ApiUnprocessableEntityResponse, ApiParam, } from '@nestjs/swagger'; +import { FileInterceptor } from '@nestjs/platform-express'; import { skipAuth } from '@shared/helpers/skipAuth'; import { JobApplicationErrorDto } from './dto/job-application-error.dto'; import { JobApplicationResponseDto } from './dto/job-application-response.dto'; @@ -55,11 +59,17 @@ export class JobsController { @ApiUnprocessableEntityResponse({ description: 'Job application deadline passed', }) + @UseInterceptors(FileInterceptor('resume')) // Handles file uploads + @ApiConsumes('multipart/form-data') @ApiBadRequestResponse({ description: 'Invalid request body', type: JobApplicationErrorDto }) @ApiInternalServerErrorResponse({ description: 'Internal server error', type: JobApplicationErrorDto }) @Post('/:id/applications') - async applyForJob(@Param('id') id: string, @Body() jobApplicationDto: JobApplicationDto) { - return this.jobService.applyForJob(id, jobApplicationDto); + async applyForJob( + @Param('id') id: string, + @Body() jobApplicationDto: JobApplicationDto, + @UploadedFile() resume: Express.Multer.File + ) { + return this.jobService.applyForJob(id, jobApplicationDto, resume); } @UseGuards(AuthGuard) diff --git a/src/modules/jobs/jobs.module.ts b/src/modules/jobs/jobs.module.ts index 02560008b..9f09f3072 100644 --- a/src/modules/jobs/jobs.module.ts +++ b/src/modules/jobs/jobs.module.ts @@ -13,13 +13,13 @@ import { UserModule } from '@modules/user/user.module'; import { JobOwnerGuard } from '@guards/job-owner.guard'; import { AuthGuard } from '@guards/auth.guard'; import { SuperAdminGuard } from '@guards/super-admin.guard'; - +import { S3Service } from '@modules/s3/s3.service'; @Module({ imports: [ TypeOrmModule.forFeature([Job, User, JobApplication, Organisation, OrganisationUserRole, Profile, Role]), UserModule, ], - providers: [JobsService, JobOwnerGuard, AuthGuard, SuperAdminGuard], + providers: [JobsService, JobOwnerGuard, AuthGuard, SuperAdminGuard, S3Service], controllers: [JobsController], }) export class JobsModule {} diff --git a/src/modules/jobs/jobs.service.ts b/src/modules/jobs/jobs.service.ts index 50c93025b..76471b0ba 100644 --- a/src/modules/jobs/jobs.service.ts +++ b/src/modules/jobs/jobs.service.ts @@ -14,7 +14,7 @@ import { User } from '@modules/user/entities/user.entity'; import { CustomHttpException } from '@shared/helpers/custom-http-filter'; import { pick } from '@shared/helpers/pick'; import { UpdateJobDto } from './dto/update-job.dto'; - +import { S3Service } from '@modules/s3/s3.service'; @Injectable() export class JobsService { constructor( @@ -23,10 +23,15 @@ export class JobsService { @InjectRepository(Job) private readonly jobRepository: Repository, @InjectRepository(JobApplication) - private readonly jobApplicationRepository: Repository + private readonly jobApplicationRepository: Repository, + private readonly s3Service: S3Service ) {} - async applyForJob(jobId: string, jobApplicationDto: JobApplicationDto): Promise { + async applyForJob( + jobId: string, + jobApplicationDto: JobApplicationDto, + resume: Express.Multer.File + ): Promise { const job: FindJobResponseDto = await this.getJob(jobId); const { is_deleted, deadline } = job.data; @@ -39,11 +44,9 @@ export class JobsService { throw new CustomHttpException(SYS_MSG.DEADLINE_PASSED, HttpStatus.UNPROCESSABLE_ENTITY); } - const { resume, applicant_name, ...others } = jobApplicationDto; - - // TODO: Upload resume to the cloud and grab URL + const { applicant_name, ...others } = jobApplicationDto; - const resumeUrl = `https://example.com/${applicant_name.split(' ').join('_')}.pdf`; + const resumeUrl = await this.s3Service.uploadResume(resume, applicant_name); const createJobApplication = this.jobApplicationRepository.create({ ...others, diff --git a/src/modules/jobs/tests/jobs.service.spec.ts b/src/modules/jobs/tests/jobs.service.spec.ts index 542da41a0..1488a82c6 100644 --- a/src/modules/jobs/tests/jobs.service.spec.ts +++ b/src/modules/jobs/tests/jobs.service.spec.ts @@ -13,13 +13,19 @@ import { JobsService } from '../jobs.service'; import { jobsMock } from './mocks/jobs.mock'; import { JobSearchDto } from '../dto/jobSearch.dto'; import { UpdateJobDto } from '../dto/update-job.dto'; +import { S3Service } from '@modules/s3/s3.service'; +import { isPassed } from '../utils/helpers'; +jest.mock('../utils/helpers', () => ({ + isPassed: jest.fn(() => false), +})); describe('JobsService', () => { let service: JobsService; let jobRepository: Repository; let userRepository: Repository; let userDto: UserResponseDTO; let createJobDto: JobDto; + let s3Service: S3Service; const mockJob = { data: { @@ -40,6 +46,8 @@ describe('JobsService', () => { message: 'Application submitted successfully', status_code: HttpStatus.CREATED, }; + console.log('isPassed:', isPassed); + console.log('isPassed is a function:', typeof isPassed === 'function'); beforeEach(async () => { userDto = { @@ -72,6 +80,7 @@ describe('JobsService', () => { save: jest.fn(), findOneBy: jest.fn(), update: jest.fn(), + createQueryBuilder: jest.fn().mockReturnValue({ where: jest.fn().mockReturnThis(), andWhere: jest.fn().mockReturnThis(), @@ -104,12 +113,19 @@ describe('JobsService', () => { update: jest.fn(), }, }, + { + provide: S3Service, + useValue: { + uploadResume: jest.fn().mockResolvedValue('https://s3-bucket-url/resume.pdf'), + }, + }, ], }).compile(); service = module.get(JobsService); userRepository = module.get(getRepositoryToken(User)); jobRepository = module.get(getRepositoryToken(Job)); + s3Service = module.get(S3Service); jest.spyOn(userRepository, 'findOne').mockResolvedValue(userDto as User); jest.spyOn(jobRepository, 'create').mockReturnValue({ ...createJobDto, user: userDto } as Job); @@ -161,7 +177,7 @@ describe('JobsService', () => { data: { is_deleted: true, deadline: new Date().toISOString() }, } as any); - await expect(service.applyForJob('jobId', mockJobApplicationDto)).rejects.toThrow( + await expect(service.applyForJob('jobId', mockJobApplicationDto, {} as Express.Multer.File)).rejects.toThrow( new CustomHttpException('Job deleted', HttpStatus.NOT_FOUND) ); }); @@ -170,30 +186,38 @@ describe('JobsService', () => { jest.spyOn(service, 'getJob').mockResolvedValue({ data: { is_deleted: false, deadline: new Date(new Date().getTime() - 1000 * 60 * 60 * 24).toISOString() }, } as any); + (isPassed as jest.Mock).mockReturnValue(true); - await expect(service.applyForJob('jobId', mockJobApplicationDto)).rejects.toThrow( + await expect( + service.applyForJob('jobId', mockJobApplicationDto, {} as Express.Multer.File) + ).rejects.toMatchObject( new CustomHttpException('Job application deadline passed', HttpStatus.UNPROCESSABLE_ENTITY) ); }); - it('should successfully create a job application', async () => { + it('should successfully create a job application with resume uploaded to S3', async () => { + const resume = { buffer: Buffer.from('test file'), originalname: 'resume.pdf' } as Express.Multer.File; + jest.spyOn(service, 'getJob').mockResolvedValue({ + data: { is_deleted: false, deadline: new Date(Date.now() - 1000 * 60 * 60 * 24).toISOString() }, // Expired deadline + } as any); + jest.spyOn(service, 'getJob').mockResolvedValue(mockJob as any); - const createMock = jest.fn().mockReturnValue(mockJobApplicationDto); - const saveMock = jest.fn().mockResolvedValue(mockJobApplicationResponse); + jest.spyOn(s3Service, 'uploadResume').mockResolvedValue('https://s3-bucket-url/resume.pdf'); + (isPassed as jest.Mock).mockReturnValue(false); - jest.spyOn(service['jobApplicationRepository'], 'create').mockImplementation(createMock); - jest.spyOn(service['jobApplicationRepository'], 'save').mockImplementation(saveMock); + jest + .spyOn(service['jobApplicationRepository'], 'create') + .mockReturnValue({ id: '1', ...mockJobApplicationDto } as any); + jest + .spyOn(service['jobApplicationRepository'], 'save') + .mockResolvedValue({ id: '1', ...mockJobApplicationDto } as any); - const result = await service.applyForJob('jobId', mockJobApplicationDto); + const result = await service.applyForJob('jobId', mockJobApplicationDto, resume); expect(result).toEqual(mockJobApplicationResponse); - expect(createMock).toHaveBeenCalledWith({ - ...mockJobApplicationDto, - applicant_name: 'John Doe', - resume: `https://example.com/John_Doe.pdf`, - ...mockJob, - }); - expect(saveMock).toHaveBeenCalled(); + expect(s3Service.uploadResume).toHaveBeenCalledWith(resume, mockJobApplicationDto.applicant_name); + expect(service['jobApplicationRepository'].create).toHaveBeenCalled(); + expect(service['jobApplicationRepository'].save).toHaveBeenCalled(); }); }); diff --git a/src/modules/s3/s3.service.ts b/src/modules/s3/s3.service.ts new file mode 100644 index 000000000..d67eb71f2 --- /dev/null +++ b/src/modules/s3/s3.service.ts @@ -0,0 +1,38 @@ +import { S3 } from 'aws-sdk'; +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { CustomHttpException } from '@shared/helpers/custom-http-filter'; +import { HttpStatus } from '@nestjs/common'; + +@Injectable() +export class S3Service { + private s3: S3; + private bucketName: string; + + constructor(private readonly configService: ConfigService) { + this.s3 = new S3({ + accessKeyId: this.configService.get('AWS_ACCESS_KEY'), + secretAccessKey: this.configService.get('AWS_SECRET_ACCESS_KEY'), + region: this.configService.get('AWS_REGION'), + }); + this.bucketName = this.configService.get('AWS_S3_BUCKET_NAME'); + } + + async uploadResume(file: Express.Multer.File, applicantName: string): Promise { + const fileKey = `resumes/${applicantName.split(' ').join('_')}_${Date.now()}.pdf`; + + const uploadParams = { + Bucket: this.bucketName, + Key: fileKey, + Body: file.buffer, + ContentType: file.mimetype, + }; + + try { + const { Location } = await this.s3.upload(uploadParams).promise(); + return Location; + } catch (error) { + throw new CustomHttpException('Failed to upload resume', HttpStatus.INTERNAL_SERVER_ERROR); + } + } +}