diff --git a/apps/admin-api/src/api/article/comment/comment.controller.ts b/apps/admin-api/src/api/article/comment/comment.controller.ts index 722fee6..29731fd 100644 --- a/apps/admin-api/src/api/article/comment/comment.controller.ts +++ b/apps/admin-api/src/api/article/comment/comment.controller.ts @@ -1,6 +1,19 @@ -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') @@ -8,17 +21,38 @@ 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 { + 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 { 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); } } diff --git a/apps/admin-api/src/api/article/comment/comment.module.ts b/apps/admin-api/src/api/article/comment/comment.module.ts index 2eeed0a..7eb8807 100644 --- a/apps/admin-api/src/api/article/comment/comment.module.ts +++ b/apps/admin-api/src/api/article/comment/comment.module.ts @@ -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], }) diff --git a/apps/admin-api/src/api/article/comment/comment.service.spec.ts b/apps/admin-api/src/api/article/comment/comment.service.spec.ts index 0f57aec..42b508e 100644 --- a/apps/admin-api/src/api/article/comment/comment.service.spec.ts +++ b/apps/admin-api/src/api/article/comment/comment.service.spec.ts @@ -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, jest.Mock> + >; + let userRepositoryValue: Partial< + Record, jest.Mock> + >; + let commentRepositoryValue: Partial< + Record, 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); diff --git a/apps/admin-api/src/api/article/comment/comment.service.ts b/apps/admin-api/src/api/article/comment/comment.service.ts index 156ee38..e4b1295 100644 --- a/apps/admin-api/src/api/article/comment/comment.service.ts +++ b/apps/admin-api/src/api/article/comment/comment.service.ts @@ -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, + @InjectRepository(UserEntity) + private readonly userRepository: Repository, + @InjectRepository(CommentEntity) + private readonly commentRepository: Repository, + ) {} + + async create( + slug: string, + commentData: CreateCommentReqDto, + userId: number, + ): Promise { + 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 { + 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); } } diff --git a/apps/admin-api/src/api/article/comment/dto/comment-list.dto.ts b/apps/admin-api/src/api/article/comment/dto/comment-list.dto.ts new file mode 100644 index 0000000..0f269e1 --- /dev/null +++ b/apps/admin-api/src/api/article/comment/dto/comment-list.dto.ts @@ -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[]; +} diff --git a/apps/admin-api/src/api/article/comment/dto/comment.dto.ts b/apps/admin-api/src/api/article/comment/dto/comment.dto.ts new file mode 100644 index 0000000..35c491b --- /dev/null +++ b/apps/admin-api/src/api/article/comment/dto/comment.dto.ts @@ -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; +} diff --git a/apps/admin-api/src/api/article/comment/dto/create-comment.dto.ts b/apps/admin-api/src/api/article/comment/dto/create-comment.dto.ts new file mode 100644 index 0000000..2227545 --- /dev/null +++ b/apps/admin-api/src/api/article/comment/dto/create-comment.dto.ts @@ -0,0 +1,6 @@ +import { StringField } from '@repo/api'; + +export class CreateCommentReqDto { + @StringField({ minLength: 1, maxLength: 1000 }) + readonly body: string; +} diff --git a/apps/admin-api/src/constants/error-code.constant.ts b/apps/admin-api/src/constants/error-code.constant.ts index 36fc26a..c4d175f 100644 --- a/apps/admin-api/src/constants/error-code.constant.ts +++ b/apps/admin-api/src/constants/error-code.constant.ts @@ -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', } diff --git a/apps/admin-api/src/generated/i18n.generated.ts b/apps/admin-api/src/generated/i18n.generated.ts index 6900fcb..93d137b 100644 --- a/apps/admin-api/src/generated/i18n.generated.ts +++ b/apps/admin-api/src/generated/i18n.generated.ts @@ -36,6 +36,10 @@ export type I18nTranslations = { "article": { "not_found": string; }; + "comment": { + "not_found": string; + "not_authorized_to_delete": string; + }; }; }; /* prettier-ignore */ diff --git a/apps/admin-api/src/i18n/en/app.json b/apps/admin-api/src/i18n/en/app.json index 5847f8a..172140e 100644 --- a/apps/admin-api/src/i18n/en/app.json +++ b/apps/admin-api/src/i18n/en/app.json @@ -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" } } diff --git a/apps/admin-api/src/i18n/vi/app.json b/apps/admin-api/src/i18n/vi/app.json index 8c4719c..0e51d6d 100644 --- a/apps/admin-api/src/i18n/vi/app.json +++ b/apps/admin-api/src/i18n/vi/app.json @@ -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" } } diff --git a/packages/database-typeorm/src/entities/comment.entity.ts b/packages/database-typeorm/src/entities/comment.entity.ts index 543a47f..cfe8d78 100644 --- a/packages/database-typeorm/src/entities/comment.entity.ts +++ b/packages/database-typeorm/src/entities/comment.entity.ts @@ -4,7 +4,6 @@ import { Entity, JoinColumn, ManyToOne, - OneToOne, PrimaryGeneratedColumn, UpdateDateColumn, } from 'typeorm'; @@ -14,6 +13,11 @@ import { UserEntity } from './user.entity'; @Entity('comment') export class CommentEntity extends AbstractEntity { + constructor(data?: Partial) { + super(); + Object.assign(this, data); + } + @PrimaryGeneratedColumn({ primaryKeyConstraintName: 'PK_comment_id' }) id!: number; @@ -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', diff --git a/packages/database-typeorm/src/entities/user.entity.ts b/packages/database-typeorm/src/entities/user.entity.ts index 9826aae..e4597e3 100644 --- a/packages/database-typeorm/src/entities/user.entity.ts +++ b/packages/database-typeorm/src/entities/user.entity.ts @@ -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') @@ -48,6 +49,9 @@ export class UserEntity extends AbstractEntity { @OneToMany(() => ArticleEntity, (article) => article.author) articles: Relation; + @OneToMany(() => CommentEntity, (comment) => comment.author) + comments: Relation; + @ManyToMany(() => ArticleEntity, (article) => article.favoritedBy) @JoinTable({ name: 'user_favorites', diff --git a/packages/database-typeorm/src/migrations/1730196400597-create-user-favorites-table.ts b/packages/database-typeorm/src/migrations/1730196400597-create-user-favorites-table.ts index 3c5d595..1daeefc 100644 --- a/packages/database-typeorm/src/migrations/1730196400597-create-user-favorites-table.ts +++ b/packages/database-typeorm/src/migrations/1730196400597-create-user-favorites-table.ts @@ -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 `); } diff --git a/packages/database-typeorm/src/migrations/1730198033817-create-comment-table.ts b/packages/database-typeorm/src/migrations/1730198033817-create-comment-table.ts index 1eb664f..1bac268 100644 --- a/packages/database-typeorm/src/migrations/1730198033817-create-comment-table.ts +++ b/packages/database-typeorm/src/migrations/1730198033817-create-comment-table.ts @@ -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") ) `);