Skip to content

Commit

Permalink
feat: upload resume to S3 in job application process
Browse files Browse the repository at this point in the history
  • Loading branch information
LivingHopeDev committed Mar 1, 2025
1 parent 3b77e04 commit 5e8305f
Show file tree
Hide file tree
Showing 10 changed files with 119 additions and 34 deletions.
8 changes: 8 additions & 0 deletions config/s3.config.ts
Original file line number Diff line number Diff line change
@@ -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 || '',
}));
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 2 additions & 2 deletions src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
{
Expand Down Expand Up @@ -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(),
Expand Down
3 changes: 2 additions & 1 deletion src/modules/auth/auth.service.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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(
Expand Down
9 changes: 5 additions & 4 deletions src/modules/jobs/dto/job-application.dto.ts
Original file line number Diff line number Diff line change
@@ -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()
Expand All @@ -16,8 +16,9 @@ export class JobApplicationDto {
})
email: string;

@IsNotEmpty()
resume: string;
@IsOptional()
@ApiProperty({ type: 'string', format: 'binary', required: false })
resume?: any;

@IsNotEmpty()
@IsString()
Expand Down
14 changes: 12 additions & 2 deletions src/modules/jobs/jobs.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,11 @@ import {
ValidationPipe,
ParseUUIDPipe,
Patch,
UseInterceptors,
UploadedFile,
} from '@nestjs/common';
import {
ApiConsumes,
ApiBadRequestResponse,
ApiBearerAuth,
ApiBody,
Expand All @@ -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';
Expand Down Expand Up @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions src/modules/jobs/jobs.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}
17 changes: 10 additions & 7 deletions src/modules/jobs/jobs.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -23,10 +23,15 @@ export class JobsService {
@InjectRepository(Job)
private readonly jobRepository: Repository<Job>,
@InjectRepository(JobApplication)
private readonly jobApplicationRepository: Repository<JobApplication>
private readonly jobApplicationRepository: Repository<JobApplication>,
private readonly s3Service: S3Service
) {}

async applyForJob(jobId: string, jobApplicationDto: JobApplicationDto): Promise<JobApplicationResponseDto> {
async applyForJob(
jobId: string,
jobApplicationDto: JobApplicationDto,
resume: Express.Multer.File
): Promise<JobApplicationResponseDto> {
const job: FindJobResponseDto = await this.getJob(jobId);

const { is_deleted, deadline } = job.data;
Expand All @@ -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,
Expand Down
54 changes: 39 additions & 15 deletions src/modules/jobs/tests/jobs.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Job>;
let userRepository: Repository<User>;
let userDto: UserResponseDTO;
let createJobDto: JobDto;
let s3Service: S3Service;

const mockJob = {
data: {
Expand All @@ -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 = {
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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>(JobsService);
userRepository = module.get(getRepositoryToken(User));
jobRepository = module.get(getRepositoryToken(Job));
s3Service = module.get<S3Service>(S3Service);

jest.spyOn(userRepository, 'findOne').mockResolvedValue(userDto as User);
jest.spyOn(jobRepository, 'create').mockReturnValue({ ...createJobDto, user: userDto } as Job);
Expand Down Expand Up @@ -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)
);
});
Expand All @@ -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();
});
});

Expand Down
38 changes: 38 additions & 0 deletions src/modules/s3/s3.service.ts
Original file line number Diff line number Diff line change
@@ -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<string>('AWS_ACCESS_KEY'),
secretAccessKey: this.configService.get<string>('AWS_SECRET_ACCESS_KEY'),
region: this.configService.get<string>('AWS_REGION'),
});
this.bucketName = this.configService.get<string>('AWS_S3_BUCKET_NAME');
}

async uploadResume(file: Express.Multer.File, applicantName: string): Promise<string> {
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);
}
}
}

0 comments on commit 5e8305f

Please sign in to comment.