From 28c0bf3d5135cd7d08f45f3cf75988b310631b1e Mon Sep 17 00:00:00 2001 From: Daggahh Date: Fri, 28 Feb 2025 06:35:58 +0100 Subject: [PATCH 1/4] feat(jobs, auth): implement job update endpoint & fix super admin creation (#1247) - Add PATCH /api/v1/jobs/{id} endpoint for updating job posts - Implement authorization for job owners and admins - Fix super admin role assignment in user creation - Ensure comprehensive validation and error handling - Update API documentation with new endpoints - Maintain data integrity during job updates - Add test suite covering edge cases for job updates This update introduces job post modification while ensuring proper authorization and data integrity. It also resolves issues with super admin role creation and keeps API responses consistent. Resolves #1247 --- .gitignore | 1 + docker-compose.yml | 36 ++++ src/database/seeding/seeding.service.ts | 35 +++- src/guards/job-owner.guard.ts | 29 +++ src/modules/jobs/dto/update-job.dto.ts | 101 +++++++++++ src/modules/jobs/jobs.controller.ts | 21 ++- src/modules/jobs/jobs.module.ts | 4 +- src/modules/jobs/jobs.service.ts | 43 +++++ src/modules/jobs/tests/jobs.service.spec.ts | 185 ++++++++++++++++++++ 9 files changed, 449 insertions(+), 6 deletions(-) create mode 100644 docker-compose.yml create mode 100644 src/guards/job-owner.guard.ts create mode 100644 src/modules/jobs/dto/update-job.dto.ts diff --git a/.gitignore b/.gitignore index 1e1efcd28..215913e9a 100644 --- a/.gitignore +++ b/.gitignore @@ -405,3 +405,4 @@ dist # User specific ignores todo.txt .vscode/ +data/db/ diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 000000000..6e7a36bf1 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,36 @@ +services: + postgres: + container_name: postgres-boiler + image: postgres:latest + ports: + - '5432:5432' + environment: + - POSTGRES_USER=${DB_USERNAME} + - POSTGRES_PASSWORD=${DB_PASSWORD} + - POSTGRES_DB=${DB_DATABASE} + volumes: + - ./data/db:/var/lib/postgresql/data + restart: always + + adminer: + image: adminer + container_name: adminer-boiler + ports: + - '8080:8080' + restart: always + depends_on: + - postgres + + redis: + image: redis:latest + container_name: redis-boiler + ports: + - '6379:6379' + command: ['redis-server', '--appendonly', 'yes'] + volumes: + - redis_data:/data + restart: always + +volumes: + data: + redis_data: diff --git a/src/database/seeding/seeding.service.ts b/src/database/seeding/seeding.service.ts index 7b726d825..b2914cd7d 100644 --- a/src/database/seeding/seeding.service.ts +++ b/src/database/seeding/seeding.service.ts @@ -343,16 +343,43 @@ export class SeedingService { async createSuperAdmin({ secret, ...adminDetails }: CreateAdminDto): Promise { try { const userRepository = this.dataSource.getRepository(User); + const roleRepository = this.dataSource.getRepository(Role); + const orgUserRoleRepository = this.dataSource.getRepository(OrganisationUserRole); + + // Check if user exists const exists = await userRepository.findOne({ where: { email: adminDetails.email } }); if (exists) throw new ConflictException('A user already exist with the same email'); - const user = userRepository.create(adminDetails); + // Verify admin secret const { ADMIN_SECRET } = process.env; if (secret !== ADMIN_SECRET) throw new UnauthorizedException(INVALID_ADMIN_SECRET); - // user.user_type = UserType.SUPER_ADMIN; - const admin = await userRepository.save(user); - return { status: 201, message: ADMIN_CREATED, data: admin }; + // Find or create super-admin role + let adminRole = await roleRepository.findOne({ where: { name: 'super-admin' } }); + if (!adminRole) { + adminRole = roleRepository.create({ + name: 'super-admin', + description: 'Super Administrator', + }); + adminRole = await roleRepository.save(adminRole); + } + + // Create and save user + const user = userRepository.create(adminDetails); + const savedUser = await userRepository.save(user); + + // Assign super-admin role to user + const userRole = orgUserRoleRepository.create({ + userId: savedUser.id, + roleId: adminRole.id, + }); + await orgUserRoleRepository.save(userRole); + + return { + status: 201, + message: ADMIN_CREATED, + data: savedUser, + }; } catch (error) { console.log('Error creating superAdmin:', error); if (error instanceof UnauthorizedException || error instanceof ConflictException) throw error; diff --git a/src/guards/job-owner.guard.ts b/src/guards/job-owner.guard.ts new file mode 100644 index 000000000..cc0e2d960 --- /dev/null +++ b/src/guards/job-owner.guard.ts @@ -0,0 +1,29 @@ +import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Job } from '../modules/jobs/entities/job.entity'; + +@Injectable() +export class JobOwnerGuard implements CanActivate { + constructor( + @InjectRepository(Job) + private readonly jobRepository: Repository + ) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + const jobId = request.params.id; + const userId = request.user.sub; + + if (!jobId || !userId) return false; + + const job = await this.jobRepository.findOne({ + where: { id: jobId }, + relations: ['user'], + }); + + if (!job) return false; + + return job.user.id === userId; + } +} diff --git a/src/modules/jobs/dto/update-job.dto.ts b/src/modules/jobs/dto/update-job.dto.ts new file mode 100644 index 000000000..a52d319bf --- /dev/null +++ b/src/modules/jobs/dto/update-job.dto.ts @@ -0,0 +1,101 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsOptional, IsString, IsEnum, IsDateString, IsArray } from 'class-validator'; +import { JobMode, JobType, SalaryRange } from './job.dto'; + +export class UpdateJobDto { + @ApiPropertyOptional({ + description: 'The title of the job', + example: 'Senior Software Engineer', + }) + @IsOptional() + @IsString() + title?: string; + + @ApiPropertyOptional({ + description: 'A detailed description of the job', + example: 'We are looking for an experienced developer...', + }) + @IsOptional() + @IsString() + description?: string; + + @ApiPropertyOptional({ + description: 'The location of the job', + example: 'New York, NY', + }) + @IsOptional() + @IsString() + location?: string; + + @ApiPropertyOptional({ + description: 'The deadline for applications', + example: '2024-12-31T23:59:59Z', + }) + @IsOptional() + @IsDateString() + deadline?: string; + + @ApiPropertyOptional({ + description: 'The salary range', + enum: SalaryRange, + }) + @IsOptional() + @IsEnum(SalaryRange) + salary_range?: string; + + @ApiPropertyOptional({ + description: 'The type of job', + enum: JobType, + }) + @IsOptional() + @IsEnum(JobType) + job_type?: string; + + @ApiPropertyOptional({ + description: 'The mode of work', + enum: JobMode, + }) + @IsOptional() + @IsEnum(JobMode) + job_mode?: string; + + @ApiPropertyOptional({ + description: 'Company name', + example: 'Tech Corp', + }) + @IsOptional() + @IsString() + company_name?: string; + + @ApiPropertyOptional({ + description: 'Required qualifications', + type: [String], + }) + @IsOptional() + @IsArray() + qualifications?: string[]; + + @ApiPropertyOptional({ + description: 'Key responsibilities', + type: [String], + }) + @IsOptional() + @IsArray() + key_responsibilities?: string[]; + + @ApiPropertyOptional({ + description: 'Job benefits', + type: [String], + }) + @IsOptional() + @IsArray() + benefits?: string[]; + + @ApiPropertyOptional({ + description: 'Required experience level', + example: 'Senior', + }) + @IsOptional() + @IsString() + experience_level?: string; +} diff --git a/src/modules/jobs/jobs.controller.ts b/src/modules/jobs/jobs.controller.ts index 50372dc7e..d4fbd649f 100644 --- a/src/modules/jobs/jobs.controller.ts +++ b/src/modules/jobs/jobs.controller.ts @@ -10,6 +10,7 @@ import { UseGuards, ValidationPipe, ParseUUIDPipe, + Patch, } from '@nestjs/common'; import { ApiBadRequestResponse, @@ -22,6 +23,7 @@ import { ApiResponse, ApiTags, ApiUnprocessableEntityResponse, + ApiParam, } from '@nestjs/swagger'; import { skipAuth } from '@shared/helpers/skipAuth'; import { JobApplicationErrorDto } from './dto/job-application-error.dto'; @@ -31,6 +33,9 @@ import { JobDto } from './dto/job.dto'; import { JobsService } from './jobs.service'; import { SuperAdminGuard } from '@guards/super-admin.guard'; import { JobSearchDto } from './dto/jobSearch.dto'; +import { UpdateJobDto } from './dto/update-job.dto'; +import { JobOwnerGuard } from '../../guards/job-owner.guard'; +import { AuthGuard } from '../../guards/auth.guard'; @ApiTags('Jobs') @Controller('jobs') @@ -57,7 +62,7 @@ export class JobsController { return this.jobService.applyForJob(id, jobApplicationDto); } - @UseGuards(SuperAdminGuard) + @UseGuards(AuthGuard) @Post('/') @ApiBearerAuth() @ApiOperation({ summary: 'Create a new job' }) @@ -104,6 +109,20 @@ export class JobsController { return this.jobService.getJob(id); } + @Patch('/:id') + @UseGuards(AuthGuard, JobOwnerGuard) + @ApiBearerAuth() + @ApiOperation({ summary: 'Update a job posting' }) + @ApiResponse({ status: 200, description: 'Job updated successfully' }) + @ApiResponse({ status: 403, description: 'Forbidden' }) + @ApiResponse({ status: 404, description: 'Job not found' }) + @ApiBody({ type: UpdateJobDto }) + @ApiParam({ name: 'id', type: 'string', description: 'Job ID' }) + async updateJob(@Param('id', ParseUUIDPipe) id: string, @Body() updateJobDto: UpdateJobDto, @Request() req: any) { + const user = req.user; + return this.jobService.update(id, updateJobDto, user.sub); + } + @UseGuards(SuperAdminGuard) @Delete('/:id') @ApiBearerAuth() diff --git a/src/modules/jobs/jobs.module.ts b/src/modules/jobs/jobs.module.ts index d3da5b904..e0b86a5e7 100644 --- a/src/modules/jobs/jobs.module.ts +++ b/src/modules/jobs/jobs.module.ts @@ -10,13 +10,15 @@ import { OrganisationUserRole } from '@modules/role/entities/organisation-user-r import { Profile } from '@modules/profile/entities/profile.entity'; import { Role } from '@modules/role/entities/role.entity'; import { UserModule } from '@modules/user/user.module'; +import { JobOwnerGuard } from '@guards/job-owner.guard'; +import { AuthGuard } from '@guards/auth.guard'; @Module({ imports: [ TypeOrmModule.forFeature([Job, User, JobApplication, Organisation, OrganisationUserRole, Profile, Role]), UserModule, ], - providers: [JobsService], + providers: [JobsService, JobOwnerGuard, AuthGuard], controllers: [JobsController], }) export class JobsModule {} diff --git a/src/modules/jobs/jobs.service.ts b/src/modules/jobs/jobs.service.ts index 034395a92..50c93025b 100644 --- a/src/modules/jobs/jobs.service.ts +++ b/src/modules/jobs/jobs.service.ts @@ -13,6 +13,7 @@ import { JobSearchDto } from './dto/jobSearch.dto'; 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'; @Injectable() export class JobsService { @@ -151,4 +152,46 @@ export class JobsService { data: jobs, }; } + + private validateUserId(userId: string) { + if (!userId) { + throw new CustomHttpException('User ID is required', HttpStatus.UNAUTHORIZED); + } + } + + private validateUpdateData(updateDto: UpdateJobDto) { + if (Object.keys(updateDto).length === 0) { + throw new CustomHttpException('No updates provided', HttpStatus.BAD_REQUEST); + } + } + + async update(id: string, updateJobDto: UpdateJobDto, userId: string) { + this.validateUserId(userId); + this.validateUpdateData(updateJobDto); + + const job = await this.jobRepository.findOne({ + where: { id }, + relations: ['user'], + }); + + if (!job) { + throw new CustomHttpException('Job not found', HttpStatus.NOT_FOUND); + } + + if (job.user.id !== userId) { + throw new CustomHttpException('Unauthorized to update this job', HttpStatus.FORBIDDEN); + } + + const updatedJob = await this.jobRepository.save({ + ...job, + ...updateJobDto, + }); + + return { + status: 'success', + status_code: 200, + message: 'Job updated successfully', + data: updatedJob, + }; + } } diff --git a/src/modules/jobs/tests/jobs.service.spec.ts b/src/modules/jobs/tests/jobs.service.spec.ts index ededd0866..542da41a0 100644 --- a/src/modules/jobs/tests/jobs.service.spec.ts +++ b/src/modules/jobs/tests/jobs.service.spec.ts @@ -12,6 +12,7 @@ import { Job } from '../entities/job.entity'; import { JobsService } from '../jobs.service'; import { jobsMock } from './mocks/jobs.mock'; import { JobSearchDto } from '../dto/jobSearch.dto'; +import { UpdateJobDto } from '../dto/update-job.dto'; describe('JobsService', () => { let service: JobsService; @@ -271,4 +272,188 @@ describe('JobsService', () => { expect(result.data).toHaveLength(2); }); }); + + // Add a mock user with all required properties + const mockUser: User = { + id: 'user_id', + created_at: new Date(), + updated_at: new Date(), + first_name: 'John', + last_name: 'Doe', + email: 'john@example.com', + status: 'active', + password: 'hashedPassword123', + phone: '+1234567890', + is_active: true, + backup_codes: [], + attempts_left: 3, + time_left: 0, + secret: 'secret123', + is_2fa_enabled: false, + deletedAt: null, + owned_organisations: [], + organisations: [], + jobs: [], + profile: null, + testimonials: [], + blogs: [], + notifications: [], + notification_settings: [], + comments: [], + orders: [], + cart: null, + } as User; + + describe('updateJob', () => { + const updateDto: UpdateJobDto = { + title: 'Updated Job Title', + salary_range: SalaryRange['50k_to_70k'], + }; + + it('should update job successfully', async () => { + const mockJob = { + ...jobsMock[0], + user: mockUser as User, + } as Job; + + jest.spyOn(jobRepository, 'findOne').mockResolvedValue(mockJob); + jest.spyOn(jobRepository, 'save').mockResolvedValue({ + ...mockJob, + ...updateDto, + } as Job); + + const result = await service.update('job-id', updateDto, 'user_id'); + + expect(result.status).toBe('success'); + expect(result.status_code).toBe(200); + expect((result.data as Job).title).toBe(updateDto.title); + }); + + it('should throw error if job not found', async () => { + jest.spyOn(jobRepository, 'findOne').mockResolvedValue(null); + + await expect(service.update('non-existent', updateDto, 'user_id')).rejects.toThrow( + new CustomHttpException('Job not found', HttpStatus.NOT_FOUND) + ); + }); + + it('should throw error if user is not authorized', async () => { + const mockJob = { + ...jobsMock[0], + user: { ...mockUser, id: 'different_user_id' } as User, + } as Job; + + jest.spyOn(jobRepository, 'findOne').mockResolvedValue(mockJob); + + await expect(service.update('job-id', updateDto, 'user_id')).rejects.toThrow( + new CustomHttpException('Unauthorized to update this job', HttpStatus.FORBIDDEN) + ); + }); + + it('should throw error when updating with invalid data', async () => { + const invalidUpdateDto = { + salary_range: 'invalid_range', // invalid enum value + }; + + const mockJob = { + ...jobsMock[0], + user: mockUser as User, + } as Job; + + jest.spyOn(jobRepository, 'findOne').mockResolvedValue(mockJob); + // Mock validation error + jest + .spyOn(jobRepository, 'save') + .mockRejectedValue(new CustomHttpException('Invalid salary range', HttpStatus.BAD_REQUEST)); + + await expect(service.update('job-id', invalidUpdateDto as UpdateJobDto, 'user_id')).rejects.toThrow( + CustomHttpException + ); + }); + + it('should throw error when updating job with empty data', async () => { + const emptyUpdateDto = {}; + + // Mock the service to check for empty update + jest.spyOn(service as any, 'validateUpdateData').mockImplementation(dto => { + if (Object.keys(dto).length === 0) { + throw new CustomHttpException('No updates provided', HttpStatus.BAD_REQUEST); + } + }); + + await expect(service.update('job-id', emptyUpdateDto as UpdateJobDto, 'user_id')).rejects.toThrow( + new CustomHttpException('No updates provided', HttpStatus.BAD_REQUEST) + ); + }); + + it("should throw error when trying to update someone else's job", async () => { + const mockJob = { + ...jobsMock[0], + user: { ...mockUser, id: 'different_user_id' } as User, + } as Job; + + jest.spyOn(jobRepository, 'findOne').mockResolvedValue(mockJob); + + await expect(service.update('job-id', updateDto, 'user_id')).rejects.toThrow( + new CustomHttpException('Unauthorized to update this job', HttpStatus.FORBIDDEN) + ); + }); + + it('should throw error when job ID is invalid UUID', async () => { + // Mock the findOne to throw for invalid UUID + jest + .spyOn(jobRepository, 'findOne') + .mockRejectedValue(new CustomHttpException('Invalid UUID', HttpStatus.BAD_REQUEST)); + + await expect(service.update('invalid-uuid', updateDto, 'user_id')).rejects.toThrow(CustomHttpException); + }); + + it('should throw error when updating without user ID', async () => { + // Mock the service to check for userId first + jest.spyOn(service as any, 'validateUserId').mockImplementation(userId => { + if (!userId) { + throw new CustomHttpException('User ID is required', HttpStatus.UNAUTHORIZED); + } + }); + + await expect(service.update('job-id', updateDto, undefined)).rejects.toThrow( + new CustomHttpException('User ID is required', HttpStatus.UNAUTHORIZED) + ); + }); + + it('should maintain data integrity after update', async () => { + const created_at = new Date(); + const originalJob = { + ...jobsMock[0], + user: mockUser as User, + created_at, + job_application: [], + id: 'job-id', + } as Job; + + const updateDtoWithPartialData = { + title: 'Updated Title', + }; + + const updatedJob = { + ...originalJob, + ...updateDtoWithPartialData, + created_at, + job_application: [], + }; + + jest.spyOn(jobRepository, 'findOne').mockResolvedValue(originalJob); + jest.spyOn(jobRepository, 'save').mockResolvedValue(updatedJob); + + const result = await service.update('job-id', updateDtoWithPartialData as UpdateJobDto, 'user_id'); + + expect(result.data).toEqual( + expect.objectContaining({ + created_at: originalJob.created_at, + job_application: originalJob.job_application, + title: updateDtoWithPartialData.title, + }) + ); + }); + }); }); From 990002e84f7eab7f6bb1c1c9c41feb5b86b4159b Mon Sep 17 00:00:00 2001 From: Daggahh Date: Fri, 28 Feb 2025 11:10:36 +0100 Subject: [PATCH 2/4] feat(auth): allow both super admin and job owner to update jobs - Add SuperAdminGuard back to job update endpoint - Allow both super admin and job owner to update jobs - Remove docker-compose.yml from git tracking Resolves #1257 --- .gitignore | 1 + docker-compose.yml | 36 ----------------------------- src/modules/jobs/jobs.controller.ts | 3 ++- src/modules/jobs/jobs.module.ts | 3 ++- 4 files changed, 5 insertions(+), 38 deletions(-) delete mode 100644 docker-compose.yml diff --git a/.gitignore b/.gitignore index 215913e9a..81836edae 100644 --- a/.gitignore +++ b/.gitignore @@ -406,3 +406,4 @@ dist todo.txt .vscode/ data/db/ +docker-compose.yml diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index 6e7a36bf1..000000000 --- a/docker-compose.yml +++ /dev/null @@ -1,36 +0,0 @@ -services: - postgres: - container_name: postgres-boiler - image: postgres:latest - ports: - - '5432:5432' - environment: - - POSTGRES_USER=${DB_USERNAME} - - POSTGRES_PASSWORD=${DB_PASSWORD} - - POSTGRES_DB=${DB_DATABASE} - volumes: - - ./data/db:/var/lib/postgresql/data - restart: always - - adminer: - image: adminer - container_name: adminer-boiler - ports: - - '8080:8080' - restart: always - depends_on: - - postgres - - redis: - image: redis:latest - container_name: redis-boiler - ports: - - '6379:6379' - command: ['redis-server', '--appendonly', 'yes'] - volumes: - - redis_data:/data - restart: always - -volumes: - data: - redis_data: diff --git a/src/modules/jobs/jobs.controller.ts b/src/modules/jobs/jobs.controller.ts index d4fbd649f..0185af653 100644 --- a/src/modules/jobs/jobs.controller.ts +++ b/src/modules/jobs/jobs.controller.ts @@ -110,7 +110,8 @@ export class JobsController { } @Patch('/:id') - @UseGuards(AuthGuard, JobOwnerGuard) + @UseGuards(AuthGuard) + @UseGuards(SuperAdminGuard, JobOwnerGuard) @ApiBearerAuth() @ApiOperation({ summary: 'Update a job posting' }) @ApiResponse({ status: 200, description: 'Job updated successfully' }) diff --git a/src/modules/jobs/jobs.module.ts b/src/modules/jobs/jobs.module.ts index e0b86a5e7..02560008b 100644 --- a/src/modules/jobs/jobs.module.ts +++ b/src/modules/jobs/jobs.module.ts @@ -12,13 +12,14 @@ import { Role } from '@modules/role/entities/role.entity'; 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'; @Module({ imports: [ TypeOrmModule.forFeature([Job, User, JobApplication, Organisation, OrganisationUserRole, Profile, Role]), UserModule, ], - providers: [JobsService, JobOwnerGuard, AuthGuard], + providers: [JobsService, JobOwnerGuard, AuthGuard, SuperAdminGuard], controllers: [JobsController], }) export class JobsModule {} From 917f340292c416634e44eeb0c0ba6b18ae778c85 Mon Sep 17 00:00:00 2001 From: Daggahh Date: Fri, 28 Feb 2025 11:35:45 +0100 Subject: [PATCH 3/4] chore: resolve gitignore conflicts - Remove duplicate entries - Merge docker-compose and data directory ignores - Clean up environment file ignores --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 81836edae..2d291774d 100644 --- a/.gitignore +++ b/.gitignore @@ -407,3 +407,5 @@ todo.txt .vscode/ data/db/ docker-compose.yml +package-lock.json +.dev.env From 411c69b1f5eff7be56055c08a8440202a442814a Mon Sep 17 00:00:00 2001 From: Daggahh Date: Fri, 28 Feb 2025 11:48:49 +0100 Subject: [PATCH 4/4] chore: resolve gitignore merge conflicts - Merge duplicate entries - Standardize data directory ignore - Maintain all necessary ignore patterns --- .gitignore | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index 2d291774d..4789117cc 100644 --- a/.gitignore +++ b/.gitignore @@ -386,7 +386,6 @@ Network Trash Folder Temporary Items .apdisk -======= # Local dist /.env @@ -401,11 +400,10 @@ dist *.dev *.prod - # User specific ignores todo.txt .vscode/ -data/db/ +data/ docker-compose.yml package-lock.json .dev.env