diff --git a/.gitignore b/.gitignore index 185d5c4c1..52ef48ea5 100644 --- a/.gitignore +++ b/.gitignore @@ -386,7 +386,6 @@ Network Trash Folder Temporary Items .apdisk -======= # Local dist /.env @@ -401,10 +400,13 @@ dist *.dev *.prod - # User specific ignores todo.txt .vscode/ +data/ +docker-compose.yml +package-lock.json +.dev.env package-lock.json @@ -412,3 +414,4 @@ docker-compose.yml data/ .dev.env + 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..0185af653 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,21 @@ export class JobsController { return this.jobService.getJob(id); } + @Patch('/:id') + @UseGuards(AuthGuard) + @UseGuards(SuperAdminGuard, 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..02560008b 100644 --- a/src/modules/jobs/jobs.module.ts +++ b/src/modules/jobs/jobs.module.ts @@ -10,13 +10,16 @@ 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'; +import { SuperAdminGuard } from '@guards/super-admin.guard'; @Module({ imports: [ TypeOrmModule.forFeature([Job, User, JobApplication, Organisation, OrganisationUserRole, Profile, Role]), UserModule, ], - providers: [JobsService], + providers: [JobsService, JobOwnerGuard, AuthGuard, SuperAdminGuard], 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, + }) + ); + }); + }); });