Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add comment apis #69

Merged
merged 1 commit into from
Nov 18, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 40 additions & 6 deletions apps/admin-api/src/api/article/comment/comment.controller.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,58 @@
import { Controller, Delete, Get, Param, Post } from '@nestjs/common';
import {
Body,
Controller,
Delete,
Get,
Param,
Post,
SerializeOptions,
} from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { CurrentUser } from '@repo/api';
import { ApiAuth } from '@repo/api/decorators/http.decorators';
import { CommentService } from './comment.service';
import { CommentListResDto } from './dto/comment-list.dto';
import { CommentResDto } from './dto/comment.dto';
import { CreateCommentReqDto } from './dto/create-comment.dto';

@ApiTags('Comment')
@Controller('articles/:slug/comments')
export class CommentController {
constructor(private readonly commentService: CommentService) {}

@Post()
async create(@Param('slug') slug: string) {
return await this.commentService.create(slug);
@SerializeOptions({ type: CommentResDto })
@ApiAuth({
summary: 'Add Comments to an Article',
type: CommentResDto,
})
async create(
@Param('slug') slug: string,
@Body('comment') commentData: CreateCommentReqDto,
@CurrentUser('id') userId: number,
): Promise<CommentResDto> {
return await this.commentService.create(slug, commentData, userId);
}

@Get()
async list(@Param('slug') slug: string) {
@SerializeOptions({ type: CommentListResDto })
@ApiAuth({
summary: 'Get Comments from an Article',
type: CommentListResDto,
isAuthOptional: true,
})
async list(@Param('slug') slug: string): Promise<CommentListResDto> {
return await this.commentService.list(slug);
}

@Delete(':id')
async delete(@Param('slug') slug: string, @Param('id') id: string) {
return await this.commentService.delete(slug, id);
@ApiAuth({
summary: 'Delete Comment',
})
async delete(
@CurrentUser('id') userId: number,
@Param('id') commentId: number,
) {
return await this.commentService.delete(commentId, userId);
}
}
9 changes: 9 additions & 0 deletions apps/admin-api/src/api/article/comment/comment.module.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,17 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import {
ArticleEntity,
CommentEntity,
UserEntity,
} from '@repo/database-typeorm';
import { CommentController } from './comment.controller';
import { CommentService } from './comment.service';

@Module({
imports: [
TypeOrmModule.forFeature([ArticleEntity, UserEntity, CommentEntity]),
],
controllers: [CommentController],
providers: [CommentService],
})
Expand Down
32 changes: 31 additions & 1 deletion apps/admin-api/src/api/article/comment/comment.service.spec.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,42 @@
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import {
ArticleEntity,
CommentEntity,
UserEntity,
} from '@repo/database-typeorm';
import { Repository } from 'typeorm';
import { CommentService } from './comment.service';

describe('CommentService', () => {
let service: CommentService;
let articleRepositoryValue: Partial<
Record<keyof Repository<ArticleEntity>, jest.Mock>
>;
let userRepositoryValue: Partial<
Record<keyof Repository<UserEntity>, jest.Mock>
>;
let commentRepositoryValue: Partial<
Record<keyof Repository<CommentEntity>, jest.Mock>
>;

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [CommentService],
providers: [
CommentService,
{
provide: getRepositoryToken(ArticleEntity),
useValue: articleRepositoryValue,
},
{
provide: getRepositoryToken(UserEntity),
useValue: userRepositoryValue,
},
{
provide: getRepositoryToken(CommentEntity),
useValue: commentRepositoryValue,
},
],
}).compile();

service = module.get<CommentService>(CommentService);
Expand Down
88 changes: 82 additions & 6 deletions apps/admin-api/src/api/article/comment/comment.service.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,92 @@
import { ProfileDto } from '@/api/profile/dto/profile.dto';
import { ErrorCode } from '@/constants/error-code.constant';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { ValidationException } from '@repo/api';
import {
ArticleEntity,
CommentEntity,
UserEntity,
} from '@repo/database-typeorm';
import { Repository } from 'typeorm';
import { CommentListResDto } from './dto/comment-list.dto';
import { CommentResDto } from './dto/comment.dto';
import { CreateCommentReqDto } from './dto/create-comment.dto';

@Injectable()
export class CommentService {
async create(_slug: string) {
throw new Error('Method not implemented.');
constructor(
@InjectRepository(ArticleEntity)
private readonly articleRepository: Repository<ArticleEntity>,
@InjectRepository(UserEntity)
private readonly userRepository: Repository<UserEntity>,
@InjectRepository(CommentEntity)
private readonly commentRepository: Repository<CommentEntity>,
) {}

async create(
slug: string,
commentData: CreateCommentReqDto,
userId: number,
): Promise<CommentResDto> {
const article = await this.articleRepository.findOneBy({ slug: slug });

if (!article) {
throw new ValidationException(ErrorCode.E201);
}

const user = await this.userRepository.findOneByOrFail({ id: userId });
const comment = new CommentEntity({
body: commentData.body,
articleId: article.id,
authorId: userId,
});

const savedComment = await this.commentRepository.save(comment);

return {
comment: {
...savedComment,
author: user.toDto(ProfileDto),
},
};
}

async list(_slug: string) {
throw new Error('Method not implemented.');
async list(slug: string): Promise<CommentListResDto> {
const article = await this.articleRepository.findOneBy({ slug: slug });

if (!article) {
throw new ValidationException(ErrorCode.E201);
}

const comments = await this.commentRepository.find({
where: { articleId: article.id },
relations: ['author'],
});

return {
comments: comments.map((comment) => {
return {
...comment,
author: comment.author.toDto(ProfileDto),
};
}),
};
}

async delete(_slug: string, _id: string) {
throw new Error('Method not implemented.');
async delete(commentId: number, userId: number) {
const comment = await this.commentRepository.findOneBy({
id: commentId,
});

if (!comment) {
throw new ValidationException(ErrorCode.E301);
}

if (comment.authorId !== userId) {
throw new ValidationException(ErrorCode.E302);
}

await this.commentRepository.remove(comment);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { ClassField } from '@repo/api';
import { CommentDto } from './comment.dto';

export class CommentListResDto {
@ClassField(() => CommentDto, { isArray: true, expose: true })
comments: CommentDto[];
}
24 changes: 24 additions & 0 deletions apps/admin-api/src/api/article/comment/dto/comment.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { ProfileDto } from '@/api/profile/dto/profile.dto';
import { ClassField, DateField, NumberField, StringField } from '@repo/api';

export class CommentDto {
@NumberField({ expose: true })
id: number;

@StringField({ expose: true })
body: string;

@DateField({ expose: true })
createdAt: Date;

@DateField({ expose: true })
updatedAt: Date;

@ClassField(() => ProfileDto, { expose: true })
author: ProfileDto;
}

export class CommentResDto {
@ClassField(() => CommentDto, { expose: true })
comment: CommentDto;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { StringField } from '@repo/api';

export class CreateCommentReqDto {
@StringField({ minLength: 1, maxLength: 1000 })
readonly body: string;
}
2 changes: 2 additions & 0 deletions apps/admin-api/src/constants/error-code.constant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,6 @@ export enum ErrorCode {
E103 = 'app.profile.already_followed',
E104 = 'app.profile.not_followed',
E201 = 'app.article.not_found',
E301 = 'app.comment.not_found',
E302 = 'app.comment.not_authorized_to_delete',
}
4 changes: 4 additions & 0 deletions apps/admin-api/src/generated/i18n.generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ export type I18nTranslations = {
"article": {
"not_found": string;
};
"comment": {
"not_found": string;
"not_authorized_to_delete": string;
};
};
};
/* prettier-ignore */
Expand Down
4 changes: 4 additions & 0 deletions apps/admin-api/src/i18n/en/app.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,5 +29,9 @@
},
"article": {
"not_found": "Article not found"
},
"comment": {
"not_found": "comment not found",
"not_authorized_to_delete": "You are not authorized to delete this comment"
}
}
4 changes: 4 additions & 0 deletions apps/admin-api/src/i18n/vi/app.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,5 +29,9 @@
},
"article": {
"not_found": "Bài viết không tìm thấy"
},
"comment": {
"not_found": "Bình luận không tìm thấy",
"not_authorized_to_delete": "Bạn không được phép xóa bình luận này"
}
}
8 changes: 6 additions & 2 deletions packages/database-typeorm/src/entities/comment.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import {
Entity,
JoinColumn,
ManyToOne,
OneToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
Expand All @@ -14,6 +13,11 @@ import { UserEntity } from './user.entity';

@Entity('comment')
export class CommentEntity extends AbstractEntity {
constructor(data?: Partial<CommentEntity>) {
super();
Object.assign(this, data);
}

@PrimaryGeneratedColumn({ primaryKeyConstraintName: 'PK_comment_id' })
id!: number;

Expand All @@ -34,7 +38,7 @@ export class CommentEntity extends AbstractEntity {
@Column({ name: 'author_id' })
authorId!: number;

@OneToOne(() => UserEntity)
@ManyToOne(() => UserEntity, (user) => user.comments)
@JoinColumn({
name: 'author_id',
referencedColumnName: 'id',
Expand Down
4 changes: 4 additions & 0 deletions packages/database-typeorm/src/entities/user.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
} from 'typeorm';
import { AbstractEntity } from './abstract.entity';
import { ArticleEntity } from './article.entity';
import { CommentEntity } from './comment.entity';
import { UserFollowsEntity } from './user-follows.entity';

@Entity('user')
Expand Down Expand Up @@ -48,6 +49,9 @@ export class UserEntity extends AbstractEntity {
@OneToMany(() => ArticleEntity, (article) => article.author)
articles: Relation<ArticleEntity[]>;

@OneToMany(() => CommentEntity, (comment) => comment.author)
comments: Relation<CommentEntity[]>;

@ManyToMany(() => ArticleEntity, (article) => article.favoritedBy)
@JoinTable({
name: 'user_favorites',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export class CreateUserFavoritesTable1730196400597
`);
await queryRunner.query(`
ALTER TABLE "user_favorites"
ADD CONSTRAINT "FK_user_favorites_article" FOREIGN KEY ("article_id") REFERENCES "article"("id") ON DELETE CASCADE ON UPDATE CASCADE
ADD CONSTRAINT "FK_user_favorites_article" FOREIGN KEY ("article_id") REFERENCES "article"("id") ON DELETE NO ACTION ON UPDATE NO ACTION
`);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ export class CreateCommentTable1730198033817 implements MigrationInterface {
"author_id" integer NOT NULL,
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
"updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
CONSTRAINT "REL_3ce66469b26697baa097f8da92" UNIQUE ("author_id"),
CONSTRAINT "PK_comment_id" PRIMARY KEY ("id")
)
`);
Expand Down