Skip to content

Commit

Permalink
Merge pull request #1320 from LivingHopeDev/feat/s3-resume-upload
Browse files Browse the repository at this point in the history
feat: implement S3 resume upload for job applications
  • Loading branch information
incredible-phoenix246 authored Mar 1, 2025
2 parents bec34f1 + 7bdc2db commit 06283ce
Show file tree
Hide file tree
Showing 11 changed files with 146 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 || '',
}));
6 changes: 6 additions & 0 deletions local repo
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
gerge branch 'dev' into feat/s3-resume-upload
# Please enter a commit message to explain why this merge is necessary,
# especially if it merges an updated upstream into a topic branch.
#
# Lines starting with '#' will be ignored, and an empty message aborts
# the commit.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
"@types/nodemailer": "^6.4.17",
"@types/speakeasy": "^2.0.10",
"@vitalets/google-translate-api": "^9.2.1",
"aws-sdk": "^2.1692.0",
"bcrypt": "^5.1.1",
"bcryptjs": "^3.0.2",
"bull": "^4.16.5",
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
1 change: 1 addition & 0 deletions src/modules/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 { 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: Express.Multer.File;

@IsNotEmpty()
@IsString()
Expand Down
18 changes: 16 additions & 2 deletions src/modules/jobs/jobs.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,12 @@ import {
ValidationPipe,
ParseUUIDPipe,
Patch,
UseInterceptors,
UploadedFile,
BadRequestException,
} from '@nestjs/common';
import {
ApiConsumes,
ApiBadRequestResponse,
ApiBearerAuth,
ApiBody,
Expand All @@ -25,6 +29,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 +60,20 @@ 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
) {
if (!resume) {
throw new BadRequestException('Resume file is required');
}
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.uploadFile(resume, 'resumes');

const createJobApplication = this.jobApplicationRepository.create({
...others,
Expand Down
63 changes: 46 additions & 17 deletions src/modules/jobs/tests/jobs.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,25 +13,42 @@ 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: {
is_deleted: false,
deadline: new Date(new Date().getTime() + 1000 * 60 * 60 * 24).toISOString(),
},
};

const mockFile: Express.Multer.File = {
fieldname: 'resume',
originalname: 'resume.pdf',
encoding: '7bit',
mimetype: 'application/pdf',
buffer: Buffer.from('mock file content'),
size: 1024,
destination: '',
filename: '',
path: '',
stream: null,
};
const mockJobApplicationDto: JobApplicationDto = {
applicant_name: 'John Doe',
email: '[email protected]',
resume: 'resume content',
resume: mockFile,
cover_letter: 'Cover letter text',
};

Expand Down Expand Up @@ -72,6 +89,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 +122,19 @@ describe('JobsService', () => {
update: jest.fn(),
},
},
{
provide: S3Service,
useValue: {
uploadFile: 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 +186,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 +195,34 @@ 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 with resume uploaded to S3', async () => {
const resume = { buffer: Buffer.from('test file'), originalname: 'resume.pdf' } as Express.Multer.File;

it('should successfully create a job application', async () => {
jest.spyOn(service, 'getJob').mockResolvedValue(mockJob as any);
const createMock = jest.fn().mockReturnValue(mockJobApplicationDto);
const saveMock = jest.fn().mockResolvedValue(mockJobApplicationResponse);
jest.spyOn(s3Service, 'uploadFile').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.uploadFile).toHaveBeenCalledWith(resume, 'resumes'); // ✅ Updated expectation
expect(service['jobApplicationRepository'].create).toHaveBeenCalled();
expect(service['jobApplicationRepository'].save).toHaveBeenCalled();
});
});

Expand Down
49 changes: 49 additions & 0 deletions src/modules/s3/s3.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
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');
}

/**
* Uploads a file to S3
* @param file - The file to upload
* @param folder - The folder in S3 where the file should be stored (e.g., 'resumes', 'images')
* @returns The file URL
*/
async uploadFile(file: Express.Multer.File, folder: string): Promise<string> {
if (!file) {
throw new CustomHttpException('File is required', HttpStatus.BAD_REQUEST);
}

const fileExtension = file.originalname.split('.').pop();
const fileKey = `${folder}/${Date.now()}_${file.originalname}`;

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 file', HttpStatus.INTERNAL_SERVER_ERROR);
}
}
}

0 comments on commit 06283ce

Please sign in to comment.