Skip to content

Commit

Permalink
Merge branch 'hngprojects:dev' into feat/dislike_comment
Browse files Browse the repository at this point in the history
  • Loading branch information
victormayowa authored Mar 2, 2025
2 parents ec6a4a0 + e734e8d commit e771d42
Show file tree
Hide file tree
Showing 16 changed files with 172 additions and 46 deletions.
10 changes: 3 additions & 7 deletions src/database/migrations/1740788110830-UpdatePhoneNumberType.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,10 @@ import { MigrationInterface, QueryRunner } from 'typeorm';

export class UpdatePhoneNumberType1740788110830 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE contact ALTER COLUMN phone TYPE VARCHAR(20) USING phone::text`
);
await queryRunner.query(`ALTER TABLE contact ALTER COLUMN phone TYPE VARCHAR(20) USING phone::text`);
}

public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE contact ALTER COLUMN phone TYPE INTEGER USING phone::integer`
);
await queryRunner.query(`ALTER TABLE contact ALTER COLUMN phone TYPE INTEGER USING phone::integer`);
}
}
}
37 changes: 37 additions & 0 deletions src/guards/job-access.guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Job } from '../modules/jobs/entities/job.entity';
import { User } from '@modules/user/entities/user.entity';

@Injectable()
export class JobAccessGuard implements CanActivate {
constructor(
@InjectRepository(Job)
private readonly jobRepository: Repository<Job>,
@InjectRepository(User)
private readonly userRepository: Repository<User>
) {}

async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const jobId = request.params.id;
const userId = request.user.sub;

const user = await this.userRepository.findOne({
where: { id: userId },
});

if (user?.is_superadmin) {
return true;
}

const job = await this.jobRepository.findOne({
where: { id: jobId },
relations: ['user'],
});

if (!job) return false;
return job.user.id === userId;
}
}
2 changes: 1 addition & 1 deletion src/modules/contact-us/contact-us.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,4 +39,4 @@ export class ContactUsService {
},
});
}
}
}
32 changes: 16 additions & 16 deletions src/modules/contact-us/dto/create-contact-us.dto.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,22 @@
import { IsEmail, IsNotEmpty, IsOptional, IsString, Matches } from 'class-validator';

export class CreateContactDto {
@IsNotEmpty({ message: 'Name should not be empty' })
@IsString({ message: 'Name must be a string' })
name: string;
@IsNotEmpty({ message: 'Name should not be empty' })
@IsString({ message: 'Name must be a string' })
name: string;

@IsNotEmpty({ message: 'Email should not be empty' })
@IsEmail({}, { message: 'Email must be valid' })
email: string;
@IsNotEmpty({ message: 'Email should not be empty' })
@IsEmail({}, { message: 'Email must be valid' })
email: string;

@IsOptional()
@IsString({ message: 'Phone must be a string' })
@Matches(/^\+?[0-9\-\s()]{8,20}$/, {
message: 'Invalid phone number format'
})
phone: string;
@IsOptional()
@IsString({ message: 'Phone must be a string' })
@Matches(/^\+?[0-9\-\s()]{8,20}$/, {
message: 'Invalid phone number format',
})
phone: string;

@IsNotEmpty({ message: 'Message should not be empty' })
@IsString({ message: 'Message must be a string' })
message: string;
}
@IsNotEmpty({ message: 'Message should not be empty' })
@IsString({ message: 'Message must be a string' })
message: string;
}
22 changes: 11 additions & 11 deletions src/modules/contact-us/entities/contact-us.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,18 @@ import { Entity, Column } from 'typeorm';

@Entity()
export class ContactUs extends AbstractBaseEntity {
@Column('varchar', { length: 20, nullable: true })
phone: string;
@Column('varchar', { length: 20, nullable: true })
phone: string;

@Column('varchar', { nullable: false })
name: string;
@Column('varchar', { nullable: false })
name: string;

@Column('varchar', { nullable: false })
email: string;
@Column('varchar', { nullable: false })
email: string;

@Column('text', { nullable: false })
message: string;
@Column('text', { nullable: false })
message: string;

@Column({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' })
createdAt: Date;
}
@Column({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' })
createdAt: Date;
}
1 change: 1 addition & 0 deletions src/modules/invite/mocks/mockUser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export const mockUser: User = {
first_name: 'John',
last_name: 'Doe',
is_active: true,
is_superadmin: false,
phone: '+1234567890',
status: 'Hello from the children of planet Earth',
id: 'some-uuid-value-here',
Expand Down
4 changes: 2 additions & 2 deletions src/modules/jobs/jobs.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ 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';
import { JobAccessGuard } from '@guards/job-access.guard';

@ApiTags('Jobs')
@Controller('jobs')
Expand Down Expand Up @@ -124,8 +125,7 @@ export class JobsController {
}

@Patch('/:id')
@UseGuards(AuthGuard)
@UseGuards(SuperAdminGuard, JobOwnerGuard)
@UseGuards(AuthGuard, JobAccessGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Update a job posting' })
@ApiResponse({ status: 200, description: 'Job updated successfully' })
Expand Down
6 changes: 3 additions & 3 deletions src/modules/jobs/jobs.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +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';
import { JobAccessGuard } from '@guards/job-access.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, S3Service],
providers: [JobsService, JobAccessGuard, AuthGuard, S3Service],
controllers: [JobsController],
})
export class JobsModule {}
26 changes: 20 additions & 6 deletions src/modules/jobs/jobs.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,13 +46,22 @@ export class JobsService {

const { applicant_name, ...others } = jobApplicationDto;

const existingApplication = await this.jobApplicationRepository.findOne({
where: { job: { id: jobId }, applicant_name: jobApplicationDto.applicant_name },
relations: ['job'],
});

if (existingApplication) {
throw new CustomHttpException('Duplicate application', HttpStatus.BAD_REQUEST);
}

const resumeUrl = await this.s3Service.uploadFile(resume, 'resumes');

const createJobApplication = this.jobApplicationRepository.create({
...others,
applicant_name,
resume: resumeUrl,
...job,
job: job.data,
});

await this.jobApplicationRepository.save(createJobApplication);
Expand Down Expand Up @@ -172,16 +181,21 @@ export class JobsService {
this.validateUserId(userId);
this.validateUpdateData(updateJobDto);

const job = await this.jobRepository.findOne({
where: { id },
relations: ['user'],
});
const [job, user] = await Promise.all([
this.jobRepository.findOne({
where: { id },
relations: ['user'],
}),
this.userRepository.findOne({
where: { id: userId },
}),
]);

if (!job) {
throw new CustomHttpException('Job not found', HttpStatus.NOT_FOUND);
}

if (job.user.id !== userId) {
if (job.user.id !== userId && !user?.is_superadmin) {
throw new CustomHttpException('Unauthorized to update this job', HttpStatus.FORBIDDEN);
}

Expand Down
70 changes: 70 additions & 0 deletions src/modules/jobs/tests/jobs.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,19 @@ describe('JobsService', () => {
expect(service['jobApplicationRepository'].create).toHaveBeenCalled();
expect(service['jobApplicationRepository'].save).toHaveBeenCalled();
});

it('should throw error if duplicate application is found', async () => {
const resume = { buffer: Buffer.from('test file'), originalname: 'resume.pdf' } as Express.Multer.File;

jest.spyOn(service, 'getJob').mockResolvedValue(mockJob as any);
jest
.spyOn(service['jobApplicationRepository'], 'findOne')
.mockResolvedValue({ ...mockJobApplicationDto, resumeUrl: 'https://s3-bucket-url/resume.pdf' } as any);

await expect(service.applyForJob('jobId', mockJobApplicationDto, resume)).rejects.toThrow(
new CustomHttpException('Duplicate application', HttpStatus.CONFLICT)
);
});
});

describe('searchJobs', () => {
Expand Down Expand Up @@ -372,7 +385,12 @@ describe('JobsService', () => {
user: { ...mockUser, id: 'different_user_id' } as User,
} as Job;

// Mock both repository calls
jest.spyOn(jobRepository, 'findOne').mockResolvedValue(mockJob);
jest.spyOn(userRepository, 'findOne').mockResolvedValue({
...mockUser,
is_superadmin: false,
} as User);

await expect(service.update('job-id', updateDto, 'user_id')).rejects.toThrow(
new CustomHttpException('Unauthorized to update this job', HttpStatus.FORBIDDEN)
Expand Down Expand Up @@ -421,7 +439,12 @@ describe('JobsService', () => {
user: { ...mockUser, id: 'different_user_id' } as User,
} as Job;

// Mock both repository calls
jest.spyOn(jobRepository, 'findOne').mockResolvedValue(mockJob);
jest.spyOn(userRepository, 'findOne').mockResolvedValue({
...mockUser,
is_superadmin: false,
} as User);

await expect(service.update('job-id', updateDto, 'user_id')).rejects.toThrow(
new CustomHttpException('Unauthorized to update this job', HttpStatus.FORBIDDEN)
Expand Down Expand Up @@ -484,5 +507,52 @@ describe('JobsService', () => {
})
);
});

it('should allow super admin to update any job', async () => {
const superAdminUser = {
...mockUser,
is_superadmin: true,
id: 'super_admin_id',
};

const jobOwnedByOtherUser = {
...jobsMock[0],
user: { ...mockUser, id: 'other_user_id' } as User,
} as Job;

jest.spyOn(jobRepository, 'findOne').mockResolvedValue(jobOwnedByOtherUser);
jest.spyOn(userRepository, 'findOne').mockResolvedValue(superAdminUser as User);
jest.spyOn(jobRepository, 'save').mockResolvedValue({
...jobOwnedByOtherUser,
...updateDto,
} as Job);

const result = await service.update('job-id', updateDto, 'super_admin_id');

expect(result.status).toBe('success');
expect(result.status_code).toBe(200);
expect((result.data as Job).title).toBe(updateDto.title);
});

it('should not allow non-owner non-admin user to update job', async () => {
const regularUser = {
...mockUser,
is_superadmin: false,
id: 'regular_user_id',
};

const jobOwnedByOtherUser = {
...jobsMock[0],
user: { ...mockUser, id: 'other_user_id' } as User,
} as Job;

// Mock both repository calls
jest.spyOn(jobRepository, 'findOne').mockResolvedValue(jobOwnedByOtherUser);
jest.spyOn(userRepository, 'findOne').mockResolvedValue(regularUser as User);

await expect(service.update('job-id', updateDto, 'regular_user_id')).rejects.toThrow(
new CustomHttpException('Unauthorized to update this job', HttpStatus.FORBIDDEN)
);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export const mockUser: User = {
status: 'Hello from the children of planet Earth',
hashPassword: async () => {},
is_active: true,
is_superadmin: false,
attempts_left: 3,
time_left: 3600,
owned_organisations: [],
Expand Down
1 change: 1 addition & 0 deletions src/modules/organisations/tests/mocks/organisation.mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export const createMockOrganisation = (): Organisation => {
phone: '+1234567890',
hashPassword: async () => {},
is_active: true,
is_superadmin: false,
attempts_left: 3,
time_left: 3600,
owned_organisations: [],
Expand Down
1 change: 1 addition & 0 deletions src/modules/organisations/tests/mocks/user.mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,4 +33,5 @@ export const mockUser = {
comments: null,
cart: [],
organisations: null,
is_superadmin: false,
};
1 change: 1 addition & 0 deletions src/modules/profile/mocks/mockUser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export const mockUserWithProfile: User = {
first_name: 'John',
last_name: 'Doe',
is_active: true,
is_superadmin: false,
phone: '+1234567891',
id: 'some-uuid-value-here',
attempts_left: 2,
Expand Down
3 changes: 3 additions & 0 deletions src/modules/user/entities/user.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,9 @@ export class User extends AbstractBaseEntity {
@Column({ default: false })
is_2fa_enabled: boolean;

@Column({ default: false })
is_superadmin: boolean;

@DeleteDateColumn({ nullable: true })
deletedAt?: Date;

Expand Down
1 change: 1 addition & 0 deletions src/modules/user/tests/mocks/user.mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,5 @@ export const mockUser: User = {
hashPassword: () => null,
cart: [],
organisations: null,
is_superadmin: false,
};

0 comments on commit e771d42

Please sign in to comment.