Skip to content

Commit

Permalink
Merge branch 'dev' into rate-limiting
Browse files Browse the repository at this point in the history
  • Loading branch information
NnatuanyaFrankOguguo authored Mar 2, 2025
2 parents 5e6abd0 + 3acd2f5 commit 7484368
Show file tree
Hide file tree
Showing 38 changed files with 714 additions and 70 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: 2 additions & 0 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { initializeDataSource } from '@database/data-source';
import { SeedingService } from '@database/seeding/seeding.service';
import { ResponseInterceptor } from '@shared/inteceptors/response.interceptor';
import { Request, Response } from 'express';
import { HttpExceptionFilter } from '@shared/helpers/http-exception-filter';
async function bootstrap() {
const app = await NestFactory.create<NestExpressApplication>(AppModule, { bufferLogs: true });

Expand All @@ -34,6 +35,7 @@ async function bootstrap() {
app.enableCors();
app.setGlobalPrefix('api/v1', { exclude: ['/', 'health', 'api', 'api/v1', 'api/docs', 'probe'] });
app.useGlobalInterceptors(new ResponseInterceptor());
app.useGlobalFilters(new HttpExceptionFilter());

const options = new DocumentBuilder()
.setTitle('HNG Boilerplate')
Expand Down
23 changes: 21 additions & 2 deletions src/modules/comments/comments.controller.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { Controller, Body, Post, Request, Get, Param } from '@nestjs/common';
import { UserPayload } from './../user/interfaces/user-payload.interface';
import { User } from './../user/entities/user.entity';
import { Controller, Body, Post, Request, Get, Param, Delete } from '@nestjs/common';
import { CommentsService } from './comments.service';
import { CreateCommentDto } from './dtos/create-comment.dto';
import { CommentResponseDto } from './dtos/comment-response.dto';
Expand All @@ -15,7 +17,7 @@ export class CommentsController {
@ApiResponse({ status: 400, description: 'Bad Request.' })
@ApiResponse({ status: 500, description: 'Internal Server Error.' })
async addComment(@Body() createCommentDto: CreateCommentDto, @Request() req): Promise<CommentResponseDto> {
const { userId } = req.user;
const userId = req.user.id;
return await this.commentsService.addComment(createCommentDto, userId);
}

Expand All @@ -25,4 +27,21 @@ export class CommentsController {
async getAComment(@Param('id') id: string): Promise<any> {
return await this.commentsService.getAComment(id);
}

@ApiOperation({ summary: 'Dislike a comment' })
@ApiResponse({ status: 200, description: 'Dislike updated successfully' })
@ApiResponse({ status: 404, description: 'Comment not found' })
@Post(':id/dislike')
async dislikeComment(@Param('id') id: string, @Request() req) {
const userId = req.user.id;
// console.log('User ID:', userId); debug
return await this.commentsService.dislikeComment(id, userId);
}

@ApiOperation({ summary: 'Delete a comment' })
@ApiResponse({ status: 200, description: 'The comment has been deleted successfully.' })
@Delete(':id/delete')
async deleteAComment(@Param('id') id: string, @Request() req): Promise<any> {
return await this.commentsService.deleteAComment(id, req.user.id);
}
}
52 changes: 52 additions & 0 deletions src/modules/comments/comments.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,4 +53,56 @@ export class CommentsService {
data: { comment },
};
}

async deleteAComment(commentId: string, userId: string) {
const comment = await this.commentRepository.findOne({ where: { id: commentId }, relations: ['user'] });
if (!comment) {
throw new CustomHttpException('Comment not found', HttpStatus.NOT_FOUND);
}

const isOwner = comment.user.id === userId;

if (!isOwner) {
throw new CustomHttpException('You are not authorized to delete this comment', HttpStatus.FORBIDDEN);
}

await this.commentRepository.delete(comment.id);

return {
message: 'Comment deleted successfully!',
status: HttpStatus.OK,
data: { comment },
};
}

async dislikeComment(commentId: string, userId: string): Promise<{ message: string; dislikeCount: number }> {
const comment = await this.commentRepository
.createQueryBuilder('comment')
.where('comment.id = :id', { id: commentId })
.getOne();

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

if (!comment.dislikedBy) {
comment.dislikedBy = [];
}

// Check if the user has already disliked the comment
if (comment.dislikedBy.includes(userId)) {
throw new CustomHttpException('You have already disliked this comment', HttpStatus.BAD_REQUEST);
}

// Add the user to the dislikedBy array and increment dislikes
comment.dislikedBy.push(userId);
comment.dislikes = comment.dislikedBy.length;

await this.commentRepository.save(comment);

return {
message: 'Dislike updated successfully',
dislikeCount: comment.dislikes,
};
}
}
9 changes: 9 additions & 0 deletions src/modules/comments/dtos/dislike-comment.dto
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsUUID } from 'class-validator';

export class DislikeCommentDto {
@ApiProperty({ description: 'Comment ID to dislike' })
@IsNotEmpty()
@IsUUID()
commentId: string;
}
6 changes: 6 additions & 0 deletions src/modules/comments/entities/comments.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,10 @@ export class Comment extends AbstractBaseEntity {

@Column({ nullable: true })
model_type: string;

@Column({ type: 'int', default: 0 }) // Add dislikes column
dislikes: number;

@Column('simple-array', { nullable: true }) // Store user IDs as an array
dislikedBy: string[];
}
96 changes: 96 additions & 0 deletions src/modules/comments/tests/comments.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,17 @@ import { User } from '../../user/entities/user.entity';
import { CustomHttpException } from '@shared/helpers/custom-http-filter';
import { HttpStatus } from '@nestjs/common';

const mockQueryBuilder = {
where: jest.fn().mockReturnThis(),
getOne: jest.fn(),
};

const mockCommentRepository = () => ({
create: jest.fn(),
save: jest.fn(),
findOne: jest.fn(),
delete: jest.fn(),
createQueryBuilder: jest.fn(() => mockQueryBuilder),
});

const mockUserRepository = () => ({
Expand Down Expand Up @@ -75,5 +83,93 @@ describe('CommentsService', () => {
commentedBy: 'John Doe',
});
});

describe('deleteComment', () => {
it('should throw CustomHttpException if comment is not found', async () => {
commentRepository.findOne.mockResolvedValue(null);

await expect(service.deleteAComment('comment-id', 'user-id')).rejects.toThrow(CustomHttpException);
await expect(service.deleteAComment('comment-id', 'user-id')).rejects.toMatchObject({
message: 'Comment not found',
status: HttpStatus.NOT_FOUND,
});
});

it('should throw CustomHttpException if user is not the owner of the comment', async () => {
const mockOwner = { id: 'owner-id' };
const mockComment = { id: 'comment-id', user: mockOwner };

commentRepository.findOne.mockResolvedValue(mockComment);

await expect(service.deleteAComment('comment-id', 'another-user-id')).rejects.toThrow(CustomHttpException);
await expect(service.deleteAComment('comment-id', 'another-user-id')).rejects.toMatchObject({
message: 'You are not authorized to delete this comment',
status: HttpStatus.FORBIDDEN,
});
});

it('should delete a comment successfully', async () => {
const commentId = 'comment-id';
const userId = 'user-id';
const mockUser = { id: userId };
const mockComment = {
id: commentId,
model_id: '1',
model_type: 'post',
comment: 'A valid comment',
user: mockUser,
};

commentRepository.findOne.mockResolvedValue(mockComment);
commentRepository.delete.mockResolvedValue({ affected: 1 });

console.log(await commentRepository.findOne({ where: { id: commentId }, relations: ['user'] })); // Debugging

const result = await service.deleteAComment(commentId, userId);

expect(commentRepository.findOne).toHaveBeenCalledWith({ where: { id: commentId }, relations: ['user'] });
expect(commentRepository.delete).toHaveBeenCalledWith(commentId);
expect(result).toEqual({
message: 'Comment deleted successfully!',
status: HttpStatus.OK,
data: { comment: mockComment },
});
});
});
});

describe('CommentsService - dislikeComment', () => {
it('should throw CustomHttpException if comment is not found', async () => {
commentRepository.findOne.mockResolvedValue(null);

await expect(service.dislikeComment('comment-id', 'user-id')).rejects.toThrow(CustomHttpException);
await expect(service.dislikeComment('comment-id', 'user-id')).rejects.toMatchObject({
message: 'Comment not found',
status: HttpStatus.NOT_FOUND,
});
});

it('should increase the dislike count successfully', async () => {
const mockComment = {
id: 'comment-id',
dislikes: 2,
dislikedBy: ['user1', 'user2'], // Ensure this is initialized
};

// Mock `getOne()` from `createQueryBuilder`
mockQueryBuilder.getOne.mockResolvedValue(mockComment);
commentRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder);

commentRepository.save.mockResolvedValue({ ...mockComment, dislikes: 3 });

const result = await service.dislikeComment('comment-id', 'user-id');

expect(result).toEqual({
message: 'Dislike updated successfully',
dislikeCount: 3,
});

expect(commentRepository.save).toHaveBeenCalledWith({ ...mockComment, dislikes: 3 });
});
});
});
12 changes: 9 additions & 3 deletions src/modules/contact-us/contact-us.controller.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
import { Controller, Post, Body, HttpCode, HttpStatus } from '@nestjs/common';
import { Controller, Post, Body, HttpCode, HttpStatus, Get, Query } from '@nestjs/common';
import { ContactUsService } from './contact-us.service';
import { CreateContactDto } from '../contact-us/dto/create-contact-us.dto';
import { ApiTags } from '@nestjs/swagger';
import { createContactDocs } from './docs/contact-us-swagger.docs';
import { createContactDocs, getAllContactDocs } from './docs/contact-us-swagger.docs';
import { skipAuth } from '@shared/helpers/skipAuth';

@ApiTags('Contact Us')
@skipAuth()
@Controller({ path: 'contact', version: '1' })
export class ContactUsController {
constructor(private readonly contactUsService: ContactUsService) {}
Expand All @@ -18,4 +17,11 @@ export class ContactUsController {
async createContact(@Body() createContactDto: CreateContactDto) {
return this.contactUsService.createContactMessage(createContactDto);
}

@Get()
@HttpCode(HttpStatus.OK)
@getAllContactDocs()
async getAllContact(@Query('page') page: number = 1, @Query('limit') limit: number = 10) {
return this.contactUsService.getAllContactMessages(page, limit);
}
}
21 changes: 20 additions & 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,23 @@ export class ContactUsService {
},
});
}
}

async getAllContactMessages(page: number, limit: number) {
const [messages, total] = await this.contactRepository.findAndCount({
order: { created_at: 'DESC' },
skip: (page - 1) * limit,
take: limit,
});
const totalPages = Math.ceil(total / limit);
return {
status: 'success',
message: 'Retrieved messages successfully',
data: {
currentPage: page,
totalPages,
totalResults: total,
messages,
},
};
}
}
15 changes: 14 additions & 1 deletion src/modules/contact-us/docs/contact-us-swagger.docs.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { applyDecorators, HttpStatus } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiProperty, ApiResponse } from '@nestjs/swagger';
import { ApiBearerAuth, ApiOperation, ApiProperty, ApiQuery, ApiResponse } from '@nestjs/swagger';
import { CreateContactResponseDto } from '../dto/create-contact-response.dto';
import { CreateContactErrorDto } from '../dto/create-contact-error.dto';

Expand All @@ -19,3 +19,16 @@ export function createContactDocs() {
})
);
}

export function getAllContactDocs() {
return applyDecorators(
ApiBearerAuth(),
ApiOperation({ summary: 'Get all contact messages' }),
ApiQuery({ name: 'page', required: false, type: Number, description: 'Page number' }),
ApiQuery({ name: 'limit', required: false, type: Number, description: 'Number of contact messages per page' }),
ApiResponse({
status: HttpStatus.OK,
description: 'Successfully retrieved messages',
})
);
}
Loading

0 comments on commit 7484368

Please sign in to comment.