From a46af6e745c07387d0c375158ea3bfd46b52409a Mon Sep 17 00:00:00 2001 From: YangGwangSeong Date: Sun, 24 Nov 2024 22:07:42 +0900 Subject: [PATCH] =?UTF-8?q?:sparkles:=20feat:=20=EB=AA=A8=EA=B0=81?= =?UTF-8?q?=EB=B0=A5=20=EB=AA=A8=EC=A7=91=20=EB=A6=AC=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?cursor=20base=20pagination=20(#47)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/controllers/articles.controller.ts | 14 ++++ src/dtos/create-article.dto.ts | 1 + src/entities/article.entity.ts | 26 ++++-- src/entities/member.entity.ts | 26 ++++-- src/services/articles.service.ts | 105 +++++++++++++++++++++++++ 5 files changed, 156 insertions(+), 16 deletions(-) diff --git a/src/controllers/articles.controller.ts b/src/controllers/articles.controller.ts index 32cdbb3..ac80ccd 100644 --- a/src/controllers/articles.controller.ts +++ b/src/controllers/articles.controller.ts @@ -8,6 +8,7 @@ import { Patch, Post, Put, + Query, UploadedFile, UseInterceptors, } from "@nestjs/common"; @@ -22,6 +23,19 @@ import { ArticlesService } from "@APP/services/articles.service"; export class ArticlesController { constructor(private readonly articlesService: ArticlesService) {} + @Get() + async getArticles( + @Query("cursor", new ParseIntPipe()) cursor: number, + @Query("limit", new ParseIntPipe()) limit: number, + @CurrentMemberDecorator("id") currentMemberId: number, + ) { + return await this.articlesService.findAll( + cursor, + limit, + currentMemberId, + ); + } + @Get(":articleId") async getArticle( @Param("articleId", new ParseIntPipe()) articleId: number, diff --git a/src/dtos/create-article.dto.ts b/src/dtos/create-article.dto.ts index 6e462d3..38ccb10 100644 --- a/src/dtos/create-article.dto.ts +++ b/src/dtos/create-article.dto.ts @@ -10,4 +10,5 @@ export class CreateArticleDto extends PickType(ArticleEntity, [ "categoryId", "districtId", "regionId", + "articleImage", ]) {} diff --git a/src/entities/article.entity.ts b/src/entities/article.entity.ts index 1781b6f..d5d4ff2 100644 --- a/src/entities/article.entity.ts +++ b/src/entities/article.entity.ts @@ -1,5 +1,11 @@ import { Transform, Type } from "class-transformer"; -import { IsDate, IsNotEmpty, IsNumber, IsString } from "class-validator"; +import { + IsDate, + IsNotEmpty, + IsNumber, + IsOptional, + IsString, +} from "class-validator"; import { Column, CreateDateColumn, @@ -45,14 +51,18 @@ export class ArticleEntity { @Column({ type: "datetime" }) endTime!: Date; - @Transform(({ value }) => { - if (!value) return null; + @Transform( + ({ value }) => { + if (!value) return null; - return new URL( - `/public/articles/${value}`, - process.env["API_BASE_URL"], - ).toString(); - }) + return new URL( + `/public/articles/${value}`, + process.env["API_BASE_URL"], + ).toString(); + }, + { toPlainOnly: true }, + ) + @IsOptional() @Column({ type: "varchar", length: 2048, nullable: true }) articleImage?: string; diff --git a/src/entities/member.entity.ts b/src/entities/member.entity.ts index 8fbd930..820bac8 100644 --- a/src/entities/member.entity.ts +++ b/src/entities/member.entity.ts @@ -1,5 +1,11 @@ import { Exclude, Transform } from "class-transformer"; -import { IsEmail, IsNotEmpty, IsString, Length } from "class-validator"; +import { + IsEmail, + IsNotEmpty, + IsOptional, + IsString, + Length, +} from "class-validator"; import { Column, CreateDateColumn, @@ -43,14 +49,18 @@ export class MemberEntity { @Column({ type: "varchar", length: 100, nullable: false, unique: true }) email!: string; - @Transform(({ value }) => { - if (!value) return null; + @Transform( + ({ value }) => { + if (!value) return null; - return new URL( - `/public/members/${value}`, - process.env["API_BASE_URL"], - ).toString(); - }) + return new URL( + `/public/members/${value}`, + process.env["API_BASE_URL"], + ).toString(); + }, + { toPlainOnly: true }, + ) + @IsOptional() @Column({ type: "varchar", length: 2048, nullable: true }) profileImage?: string; diff --git a/src/services/articles.service.ts b/src/services/articles.service.ts index e089f3f..c89612b 100644 --- a/src/services/articles.service.ts +++ b/src/services/articles.service.ts @@ -3,7 +3,9 @@ import { Injectable, NotFoundException, } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { ENV_API_BASE_URL } from "@APP/common/constants/env-keys.const"; import { CreateArticleDto } from "@APP/dtos/create-article.dto"; import { UpdateArticleDto } from "@APP/dtos/update-article.dto"; import { ArticleLikesRepository } from "@APP/repositories/article-likes.repository"; @@ -20,8 +22,111 @@ export class ArticlesService { private readonly districtsRepository: DistrictsRepository, private readonly regionsRepository: RegionsRepository, private readonly articleLikesRepository: ArticleLikesRepository, + private readonly configService: ConfigService, ) {} + async findAll(cursor: number, limit: number = 10, currentMemberId: number) { + const query = this.articlesRepository + .createQueryBuilder("article") + .select([ + 'article.id AS "articleId"', + 'article.title AS "title"', + 'article.content AS "content"', + 'article.startTime AS "startTime"', + 'article.endTime AS "endTime"', + 'article.articleImage AS "articleImage"', + 'article.createdAt AS "createdAt"', + 'article.updatedAt AS "updatedAt"', + 'member.id AS "memberId"', + 'member.name AS "memberName"', + 'member.nickname AS "memberNickname"', + 'member.profileImage AS "memberProfileImage"', + 'category.id AS "categoryId"', + 'category.name AS "categoryName"', + 'region.id AS "regionId"', + 'region.name AS "regionName"', + 'district.id AS "districtId"', + 'district.name AS "districtName"', + ]) + .addSelect((qb) => { + return qb + .select("COUNT(*)") + .from("article_likes", "al") + .where("al.articleId = article.id"); + }, "likeCount") + .addSelect((qb) => { + return qb + .select("COUNT(*)") + .from("participation", "p") + .where("p.articleId = article.id") + .andWhere("p.status = :status", { status: "ACTIVE" }); + }, "participantCount") + .addSelect((qb) => { + return qb + .select("CASE WHEN COUNT(*) > 0 THEN TRUE ELSE FALSE END") + .from("article_likes", "al") + .where("al.articleId = article.id") + .andWhere("al.memberId = :currentMemberId", { + currentMemberId, + }); + }, "isLiked") + .innerJoin("article.member", "member") + .innerJoin("article.category", "category") + .innerJoin("article.region", "region") + .innerJoin("article.district", "district") + .orderBy("article.id", "DESC") + .take(limit + 1); + + if (cursor) { + query.where("article.id < :cursor", { cursor }); + } + + const articles = await query.getRawMany(); + + const transformImageUrl = ( + filename: string | null, + type: "articles" | "members", + ) => { + if (!filename) return null; + return new URL( + `/public/${type}/${filename}`, + this.configService.get(ENV_API_BASE_URL), + ).toString(); + }; + + const transformedArticles = articles.map((article) => ({ + ...article, + articleImage: transformImageUrl(article.articleImage, "articles"), + memberProfileImage: transformImageUrl( + article.memberProfileImage, + "members", + ), + })); + + const hasNextPage = transformedArticles.length > limit; + const results = hasNextPage + ? transformedArticles.slice(0, -1) + : transformedArticles; + + const lastItem = results[results.length - 1]; + + const nextUrl = + lastItem && hasNextPage + ? new URL( + `${this.configService.get(ENV_API_BASE_URL)}/articles?cursor=${lastItem.id}&limit=${limit}`, + ) + : null; + + return { + data: results, + cursor: { + after: lastItem?.id, + }, + count: results.length, + next: nextUrl?.toString(), + }; + } + async findById(articleId: number) { const article = await this.articlesRepository.findOne({ where: { id: articleId },