From 03348146201c6f9abe4015f60b697dcfda379e7c Mon Sep 17 00:00:00 2001 From: Nuung Date: Tue, 3 Jun 2025 13:21:03 +0900 Subject: [PATCH 01/10] =?UTF-8?q?feature:=20=EC=A0=84=EC=B2=B4=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=EC=88=98/=EC=A2=8B=EC=95=84=EC=9A=94/=EA=B2=8C?= =?UTF-8?q?=EC=8B=9C=EA=B8=80=20=EB=94=94=ED=85=8C=EC=9D=BC=20API=20?= =?UTF-8?q?=EA=B8=B0=EB=B3=B8=20=EB=B9=84=EC=A6=88=EB=8B=88=EC=8A=A4=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EC=99=84=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 250 ++++++++++++++++-- src/controllers/leaderboard.controller.ts | 2 +- src/controllers/totalStats.controller.ts | 39 +++ src/repositories/totalStats.repository.ts | 115 ++++++++ src/routes/totalStats.router.ts | 73 +++++ src/services/totalStats.service.ts | 31 +++ .../dto/requests/getTotalStatsQuery.type.ts | 44 +++ .../dto/responses/totalStatsResponse.type.ts | 44 +++ src/types/index.ts | 52 ++-- 9 files changed, 616 insertions(+), 34 deletions(-) create mode 100644 src/controllers/totalStats.controller.ts create mode 100644 src/repositories/totalStats.repository.ts create mode 100644 src/routes/totalStats.router.ts create mode 100644 src/services/totalStats.service.ts create mode 100644 src/types/dto/requests/getTotalStatsQuery.type.ts create mode 100644 src/types/dto/responses/totalStatsResponse.type.ts diff --git a/.gitignore b/.gitignore index f675f50..d9e4cc7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,3 @@ -# Node.js 기본 -node_modules/ -pnpm-debug.log* - # 환경 변수 및 인증 정보 .env .env.local @@ -17,22 +13,246 @@ dist/ *.swp *.swo -# OS별 시스템 파일 -.DS_Store -Thumbs.db - # 로그 파일 logs/* *.log *.gz *.out -# 빌드 및 캐시 파일 -.cache/ -build/ -temp/ -tmp/ +# Created by https://www.toptal.com/developers/gitignore/api/macos,windows,visualstudiocode,node,git +# Edit at https://www.toptal.com/developers/gitignore?templates=macos,windows,visualstudiocode,node,git + +### Git ### +# Created by git for backups. To disable backups in Git: +# $ git config --global mergetool.keepBackup false +*.orig + +# Created by git when using merge tools for conflicts +*.BACKUP.* +*.BASE.* +*.LOCAL.* +*.REMOTE.* +*_BACKUP_*.txt +*_BASE_*.txt +*_LOCAL_*.txt +*_REMOTE_*.txt + +### macOS ### +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +### macOS Patch ### +# iCloud generated files +*.icloud + +### Node ### +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* -# 테스트 출력 -coverage/ +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage *.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) +web_modules/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional stylelint cache +.stylelintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next +out + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# vuepress v2.x temp and cache directory +.temp + +# Docusaurus cache and generated files +.docusaurus + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# yarn v2 +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* + +### Node Patch ### +# Serverless Webpack directories +.webpack/ + +# Optional stylelint cache + +# SvelteKit build / generate output +.svelte-kit + +### VisualStudioCode ### +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +!.vscode/*.code-snippets + +# Local History for Visual Studio Code +.history/ + +# Built Visual Studio Code Extensions +*.vsix + +### VisualStudioCode Patch ### +# Ignore all local history of files +.history +.ionide + +### Windows ### +# Windows thumbnail cache files +Thumbs.db +Thumbs.db:encryptable +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +# End of https://www.toptal.com/developers/gitignore/api/macos,windows,visualstudiocode,node,git \ No newline at end of file diff --git a/src/controllers/leaderboard.controller.ts b/src/controllers/leaderboard.controller.ts index 3ca73df..b94f12c 100644 --- a/src/controllers/leaderboard.controller.ts +++ b/src/controllers/leaderboard.controller.ts @@ -6,7 +6,7 @@ import { GetPostLeaderboardQuery, UserLeaderboardResponseDto, PostLeaderboardResponseDto, -} from '@/types/index'; +} from '@/types'; export class LeaderboardController { constructor(private leaderboardService: LeaderboardService) {} diff --git a/src/controllers/totalStats.controller.ts b/src/controllers/totalStats.controller.ts new file mode 100644 index 0000000..e71f11b --- /dev/null +++ b/src/controllers/totalStats.controller.ts @@ -0,0 +1,39 @@ +import logger from '@/configs/logger.config'; +import { NextFunction, Request, RequestHandler, Response } from 'express'; +import { TotalStatsService } from '@/services/totalStats.service'; +import { GetTotalStatsQuery, TotalStatsResponseDto } from '@/types'; +import { BadRequestError } from '@/exception'; + + +export class TotalStatsController { + constructor(private totalStatsService: TotalStatsService) { } + + getTotalStats: RequestHandler = async ( + req: Request, + res: Response, + next: NextFunction, + ) => { + try { + const { id } = req.user; + const { period, type } = req.query; + + // 미들웨어에서 GetTotalStatsQueryDto 에 의해 걸리는데 런타임과 IDE 에서 구분을 못함, 이를 위해 추가 + if (!type) throw new BadRequestError('type 파라미터가 필요합니다.'); + + const stats = await this.totalStatsService.getTotalStats(id, period, type); + const message = this.totalStatsService.getSuccessMessage(type); + + const response = new TotalStatsResponseDto( + true, + message, + stats, + null, + ); + + res.status(200).json(response); + } catch (error) { + logger.error('전체 통계 조회 실패:', error); + next(error); + } + }; +} \ No newline at end of file diff --git a/src/repositories/totalStats.repository.ts b/src/repositories/totalStats.repository.ts new file mode 100644 index 0000000..1380bb0 --- /dev/null +++ b/src/repositories/totalStats.repository.ts @@ -0,0 +1,115 @@ +import { Pool } from 'pg'; +import logger from '@/configs/logger.config'; +import { DBError } from '@/exception'; +import { getKSTDateStringWithOffset } from '@/utils/date.util'; +import { TotalStatsType } from '@/types'; + +interface RawStatsResult { + date: string; + total_value: string | number; +} + +export class TotalStatsRepository { + constructor(private pool: Pool) { } + + private async getTotalViewStats(userId: number, period: number): Promise { + try { + const startDateKST = getKSTDateStringWithOffset(-period * 24 * 60); + + const query = ` + SELECT + pds.date, + COALESCE(SUM(pds.daily_view_count), 0) AS total_value + FROM posts_postdailystatistics pds + JOIN posts_post p ON p.id = pds.post_id + WHERE p.user_id = $1 + AND p.is_active = true + AND pds.date >= $2 + GROUP BY pds.date + ORDER BY pds.date ASC; + `; + + const result = await this.pool.query(query, [userId, startDateKST]); + return result.rows; + } catch (error) { + logger.error('TotalStats Repo getTotalViewStats error:', error); + throw new DBError('조회수 통계 조회 중 문제가 발생했습니다.'); + } + } + + private async getTotalLikeStats(userId: number, period: number): Promise { + try { + const startDateKST = getKSTDateStringWithOffset(-period * 24 * 60); + + const query = ` + SELECT + pds.date, + COALESCE(SUM(pds.daily_like_count), 0) AS total_value + FROM posts_postdailystatistics pds + JOIN posts_post p ON p.id = pds.post_id + WHERE p.user_id = $1 + AND p.is_active = true + AND pds.date >= $2 + GROUP BY pds.date + ORDER BY pds.date ASC; + `; + + const result = await this.pool.query(query, [userId, startDateKST]); + return result.rows; + } catch (error) { + logger.error('TotalStats Repo getTotalLikeStats error:', error); + throw new DBError('좋아요 통계 조회 중 문제가 발생했습니다.'); + } + } + + private async getTotalPostStats(userId: number, period: number): Promise { + try { + const startDateKST = getKSTDateStringWithOffset(-period * 24 * 60); + + const query = ` + WITH date_series AS ( + SELECT generate_series( + DATE($2), + CURRENT_DATE, + '1 day'::interval + )::date AS date + ), + daily_posts AS ( + SELECT + DATE(p.released_at) AS date, + COUNT(*) AS post_count + FROM posts_post p + WHERE p.user_id = $1 + AND p.is_active = true + AND DATE(p.released_at) >= DATE($2) + GROUP BY DATE(p.released_at) + ) + SELECT + ds.date, + COALESCE(SUM(dp.post_count) OVER (ORDER BY ds.date), 0) AS total_value + FROM date_series ds + LEFT JOIN daily_posts dp ON ds.date = dp.date + ORDER BY ds.date ASC; + `; + + const result = await this.pool.query(query, [userId, startDateKST]); + return result.rows; + } catch (error) { + logger.error('TotalStats Repo getTotalPostStats error:', error); + throw new DBError('게시글 통계 조회 중 문제가 발생했습니다.'); + } + } + + async getTotalStats(userId: number, period: number, type: TotalStatsType): Promise { + switch (type) { + case 'view': + return this.getTotalViewStats(userId, period); + case 'like': + return this.getTotalLikeStats(userId, period); + case 'post': + return this.getTotalPostStats(userId, period); + default: + throw new DBError('지원되지 않는 통계 타입입니다.'); + } + } +} \ No newline at end of file diff --git a/src/routes/totalStats.router.ts b/src/routes/totalStats.router.ts new file mode 100644 index 0000000..daa925b --- /dev/null +++ b/src/routes/totalStats.router.ts @@ -0,0 +1,73 @@ +import express, { Router } from 'express'; +import pool from '@/configs/db.config'; +import { authMiddleware } from '@/middlewares/auth.middleware'; +import { validateRequestDto } from '@/middlewares/validation.middleware'; +import { TotalStatsRepository } from '@/repositories/totalStats.repository'; +import { TotalStatsService } from '@/services/totalStats.service'; +import { TotalStatsController } from '@/controllers/totalStats.controller'; +import { GetTotalStatsQueryDto } from '@/types'; + +const router: Router = express.Router(); + +const totalStatsRepository = new TotalStatsRepository(pool); +const totalStatsService = new TotalStatsService(totalStatsRepository); +const totalStatsController = new TotalStatsController(totalStatsService); + +/** + * @swagger + * /total-stats: + * get: + * summary: 전체 통계 조회 + * description: 사용자의 전체 조회수/좋아요/게시글 수 변동 통계를 기간별로 조회합니다. + * tags: + * - TotalStats + * parameters: + * - in: query + * name: period + * schema: + * type: number + * enum: [7, 30] + * default: 7 + * description: 조회 기간 (일수) + * example: 7 + * - in: query + * name: type + * required: true + * schema: + * type: string + * enum: ['view', 'like', 'post'] + * description: 통계 타입 (view=조회수, like=좋아요, post=게시글수) + * example: "view" + * responses: + * '200': + * description: 전체 통계 조회 성공 + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/TotalStatsResponseDto' + * example: + * success: true + * message: "전체 조회수 변동 조회에 성공하였습니다." + * data: + * - date: "2025-05-23T15:00:00.000Z" + * value: 619 + * - date: "2025-05-24T15:00:00.000Z" + * value: 919 + * - date: "2025-05-30T15:00:00.000Z" + * value: 1919 + * error: null + * '400': + * description: 잘못된 요청 (필수 파라미터 누락 또는 잘못된 값) + * '401': + * description: 인증되지 않은 사용자 + * '500': + * description: 서버 오류 / 데이터베이스 조회 오류 + */ +router.get( + '/total-stats', + authMiddleware.verify, + validateRequestDto(GetTotalStatsQueryDto, 'query'), + totalStatsController.getTotalStats, +); + +export default router; \ No newline at end of file diff --git a/src/services/totalStats.service.ts b/src/services/totalStats.service.ts new file mode 100644 index 0000000..c9adcc1 --- /dev/null +++ b/src/services/totalStats.service.ts @@ -0,0 +1,31 @@ +import logger from '@/configs/logger.config'; +import { TotalStatsRepository } from '@/repositories/totalStats.repository'; +import { TotalStatsPeriod, TotalStatsType } from '@/types'; +import { TotalStatsItem } from '@/types'; + +export class TotalStatsService { + constructor(private totalStatsRepo: TotalStatsRepository) { } + + async getTotalStats(userId: number, period: TotalStatsPeriod = 7, type: TotalStatsType): Promise { + try { + const rawStats = await this.totalStatsRepo.getTotalStats(userId, period, type); + + return rawStats.map((stat) => ({ + date: stat.date, + value: Number(stat.total_value), + })); + } catch (error) { + logger.error('TotalStatsService getTotalStats error:', error); + throw error; + } + } + + getSuccessMessage(type: TotalStatsType): string { + const messages = { + view: '전체 조회수 변동 조회에 성공하였습니다.', + like: '전체 좋아요 변동 조회에 성공하였습니다.', + post: '전체 게시글 변동 조회에 성공하였습니다.', + }; + return messages[type]; + } +} \ No newline at end of file diff --git a/src/types/dto/requests/getTotalStatsQuery.type.ts b/src/types/dto/requests/getTotalStatsQuery.type.ts new file mode 100644 index 0000000..0334380 --- /dev/null +++ b/src/types/dto/requests/getTotalStatsQuery.type.ts @@ -0,0 +1,44 @@ +import { Transform } from 'class-transformer'; +import { IsEnum, IsOptional } from 'class-validator'; + +export type TotalStatsPeriod = 7 | 30; +export type TotalStatsType = 'view' | 'like' | 'post'; + +export interface GetTotalStatsQuery { + period?: TotalStatsPeriod; + type?: TotalStatsType; +} + +/** + * @swagger + * components: + * schemas: + * GetTotalStatsQueryDto: + * type: object + * required: + * - type + * properties: + * period: + * type: number + * description: 통계 조회 기간 (일수) + * enum: [7, 30] + * default: 7 + * type: + * type: string + * description: 통계 타입 + * enum: ['view', 'like', 'post'] + */ +export class GetTotalStatsQueryDto { + @IsOptional() + @IsEnum([7, 30]) + @Transform(({ value }) => (value === '' ? 7 : Number(value))) + period?: TotalStatsPeriod; + + @IsEnum(['view', 'like', 'post']) + type: TotalStatsType; + + constructor(period?: TotalStatsPeriod, type?: TotalStatsType) { + this.period = period || 7; + this.type = type || 'view'; + } +} \ No newline at end of file diff --git a/src/types/dto/responses/totalStatsResponse.type.ts b/src/types/dto/responses/totalStatsResponse.type.ts new file mode 100644 index 0000000..d14daa5 --- /dev/null +++ b/src/types/dto/responses/totalStatsResponse.type.ts @@ -0,0 +1,44 @@ +import { BaseResponseDto } from '@/types/dto/responses/baseResponse.type'; + +/** + * @swagger + * components: + * schemas: + * TotalStatsItem: + * type: object + * required: + * - date + * - value + * properties: + * date: + * type: string + * format: date-time + * description: 통계 날짜 (ISO 8601 형식, UTC 기준) + * example: "2025-05-23T15:00:00.000Z" + * value: + * type: integer + * description: 통계 값 (조회수/좋아요수/게시글수) + * minimum: 0 + * example: 619 + */ +export interface TotalStatsItem { + date: string; + value: number; +} + +/** + * @swagger + * components: + * schemas: + * TotalStatsResponseDto: + * allOf: + * - $ref: '#/components/schemas/BaseResponseDto' + * - type: object + * properties: + * data: + * type: array + * items: + * $ref: '#/components/schemas/TotalStatsItem' + * description: 기간별 통계 데이터 배열 + */ +export class TotalStatsResponseDto extends BaseResponseDto { } \ No newline at end of file diff --git a/src/types/index.ts b/src/types/index.ts index b10ca69..9bfaada 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,35 +1,51 @@ +// User, Login 관련 export type { User, SampleUser } from '@/types/models/User.type'; +export type { VelogJWTPayload, VelogUserCurrentResponse } from '@/modules/velog/velog.type'; +export { LoginRequestDto } from '@/types/dto/requests/loginRequest.type'; +export { LoginResponseDto } from '@/types/dto/responses/loginResponse.type'; +export { UserWithTokenDto } from '@/types/dto/userWithToken.type'; +export { VelogUserLoginDto } from '@/types/dto/velogUser.type'; + +// Post 관련 export type { Post } from '@/types/models/Post.type'; -export type { PostDailyStatistics } from '@/types/models/PostDailyStatistics.type'; +export type { GetPostQuery, PostParam } from '@/types/dto/requests/getPostQuery.type'; export type { PostStatistics } from '@/types/models/PostStatistics.type'; -export type { VelogJWTPayload, VelogUserCurrentResponse } from '@/modules/velog/velog.type'; +export type { PostDailyStatistics } from '@/types/models/PostDailyStatistics.type'; export type { GetAllPostsQuery } from '@/types/dto/requests/getAllPostsQuery.type'; -export type { GetPostQuery, PostParam } from '@/types/dto/requests/getPostQuery.type'; - -export { LoginRequestDto } from '@/types/dto/requests/loginRequest.type'; +export type { RawPostType } from '@/types/dto/responses/postResponse.type'; export { GetAllPostsQueryDto } from '@/types/dto/requests/getAllPostsQuery.type'; export { GetPostQueryDto } from '@/types/dto/requests/getPostQuery.type'; export { - GetUserLeaderboardQueryDto, - GetPostLeaderboardQueryDto, + PostsResponseDto, + PostResponseDto, + PostStatisticsResponseDto, +} from '@/types/dto/responses/postResponse.type'; + +// Leaderboard 관련 +export type { GetUserLeaderboardQuery, GetPostLeaderboardQuery, UserLeaderboardSortType, PostLeaderboardSortType, } from '@/types/dto/requests/getLeaderboardQuery.type'; -export { LoginResponseDto } from '@/types/dto/responses/loginResponse.type'; -export { EmptyResponseDto } from '@/types/dto/responses/emptyReponse.type'; +export type { + UserLeaderboardData, + PostLeaderboardData, +} from '@/types/dto/responses/leaderboardResponse.type'; export { - PostsResponseDto, - PostResponseDto, - PostStatisticsResponseDto, - RawPostType, -} from '@/types/dto/responses/postResponse.type'; + GetUserLeaderboardQueryDto, + GetPostLeaderboardQueryDto, +} from '@/types/dto/requests/getLeaderboardQuery.type'; export { UserLeaderboardResponseDto, PostLeaderboardResponseDto, - UserLeaderboardData, - PostLeaderboardData, } from '@/types/dto/responses/leaderboardResponse.type'; -export { UserWithTokenDto } from '@/types/dto/userWithToken.type'; -export { VelogUserLoginDto } from '@/types/dto/velogUser.type'; + +// Total Stats 관련 +export type { TotalStatsPeriod, TotalStatsType, GetTotalStatsQuery } from '@/types/dto/requests/getTotalStatsQuery.type'; +export type { TotalStatsItem } from '@/types/dto/responses/totalStatsResponse.type'; +export { GetTotalStatsQueryDto } from '@/types/dto/requests/getTotalStatsQuery.type'; +export { TotalStatsResponseDto } from '@/types/dto/responses/totalStatsResponse.type'; + +// Common +export { EmptyResponseDto } from '@/types/dto/responses/emptyReponse.type'; \ No newline at end of file From 229396eaa78b27f77e0460891ce730d12b32d5a0 Mon Sep 17 00:00:00 2001 From: Nuung Date: Tue, 3 Jun 2025 13:28:34 +0900 Subject: [PATCH 02/10] =?UTF-8?q?feature:=20repo=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=EC=97=90=20=ED=95=84=EC=88=98=EC=9D=B8=20=EB=AA=A8?= =?UTF-8?q?=ED=82=B9=20=EA=B0=9D=EC=B2=B4=EB=93=A4=20fixture=20=EB=A1=9C?= =?UTF-8?q?=20=EB=B6=84=EB=A6=AC,=20=EA=B3=B5=ED=86=B5=20fixture=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/repositories/__test__/fixture.ts | 22 ++ .../leaderboard.repo.integration.test.ts | 0 .../post.repo.integration.test.ts | 0 .../qr.repo.integration.test.ts | 0 .../__test__/leaderboard.repo.test.ts | 19 +- src/repositories/__test__/post.repo.test.ts | 19 +- .../__test__/totalStats.repo.test.ts | 229 ++++++++++++++++++ 7 files changed, 255 insertions(+), 34 deletions(-) create mode 100644 src/repositories/__test__/fixture.ts rename src/repositories/__test__/{ => integration}/leaderboard.repo.integration.test.ts (100%) rename src/repositories/__test__/{ => integration}/post.repo.integration.test.ts (100%) rename src/repositories/__test__/{ => integration}/qr.repo.integration.test.ts (100%) create mode 100644 src/repositories/__test__/totalStats.repo.test.ts diff --git a/src/repositories/__test__/fixture.ts b/src/repositories/__test__/fixture.ts new file mode 100644 index 0000000..994a5d1 --- /dev/null +++ b/src/repositories/__test__/fixture.ts @@ -0,0 +1,22 @@ +import { QueryResult } from "pg"; + +export const mockPool: { + query: jest.Mock>>, unknown[]>; +} = { + query: jest.fn(), +}; + +/** + * pg의 QueryResult 타입을 만족하는 mock 객체를 생성하기 위한 헬퍼 함수 + * @param rows + * @returns + */ +export function createMockQueryResult>(rows: T[]): QueryResult { + return { + rows, + rowCount: rows.length, + command: '', + oid: 0, + fields: [], + } satisfies QueryResult; +} \ No newline at end of file diff --git a/src/repositories/__test__/leaderboard.repo.integration.test.ts b/src/repositories/__test__/integration/leaderboard.repo.integration.test.ts similarity index 100% rename from src/repositories/__test__/leaderboard.repo.integration.test.ts rename to src/repositories/__test__/integration/leaderboard.repo.integration.test.ts diff --git a/src/repositories/__test__/post.repo.integration.test.ts b/src/repositories/__test__/integration/post.repo.integration.test.ts similarity index 100% rename from src/repositories/__test__/post.repo.integration.test.ts rename to src/repositories/__test__/integration/post.repo.integration.test.ts diff --git a/src/repositories/__test__/qr.repo.integration.test.ts b/src/repositories/__test__/integration/qr.repo.integration.test.ts similarity index 100% rename from src/repositories/__test__/qr.repo.integration.test.ts rename to src/repositories/__test__/integration/qr.repo.integration.test.ts diff --git a/src/repositories/__test__/leaderboard.repo.test.ts b/src/repositories/__test__/leaderboard.repo.test.ts index 35fc4f9..b51ad72 100644 --- a/src/repositories/__test__/leaderboard.repo.test.ts +++ b/src/repositories/__test__/leaderboard.repo.test.ts @@ -1,26 +1,11 @@ -import { Pool, QueryResult } from 'pg'; +import { Pool } from 'pg'; import { DBError } from '@/exception'; import { LeaderboardRepository } from '@/repositories/leaderboard.repository'; import { UserLeaderboardSortType, PostLeaderboardSortType } from '@/types'; +import { mockPool, createMockQueryResult } from './fixture'; jest.mock('pg'); -// pg의 QueryResult 타입을 만족하는 mock 객체를 생성하기 위한 헬퍼 함수 생성 -function createMockQueryResult>(rows: T[]): QueryResult { - return { - rows, - rowCount: rows.length, - command: '', - oid: 0, - fields: [], - } satisfies QueryResult; -} - -const mockPool: { - query: jest.Mock>>, unknown[]>; -} = { - query: jest.fn(), -}; describe('LeaderboardRepository', () => { let repo: LeaderboardRepository; diff --git a/src/repositories/__test__/post.repo.test.ts b/src/repositories/__test__/post.repo.test.ts index 5d13ead..503ba33 100644 --- a/src/repositories/__test__/post.repo.test.ts +++ b/src/repositories/__test__/post.repo.test.ts @@ -1,25 +1,10 @@ -import { Pool, QueryResult } from 'pg'; +import { Pool } from 'pg'; import { PostRepository } from '@/repositories/post.repository'; import { DBError } from '@/exception'; +import { mockPool, createMockQueryResult } from './fixture'; jest.mock('pg'); -// pg의 QueryResult 타입을 만족하는 mock 객체를 생성하기 위한 헬퍼 함수 생성 -function createMockQueryResult>(rows: T[]): QueryResult { - return { - rows, - rowCount: rows.length, - command: '', - oid: 0, - fields: [], - } satisfies QueryResult; -} - -const mockPool: { - query: jest.Mock>>, unknown[]>; -} = { - query: jest.fn(), -}; describe('PostRepository', () => { let repo: PostRepository; diff --git a/src/repositories/__test__/totalStats.repo.test.ts b/src/repositories/__test__/totalStats.repo.test.ts new file mode 100644 index 0000000..7e63755 --- /dev/null +++ b/src/repositories/__test__/totalStats.repo.test.ts @@ -0,0 +1,229 @@ +import { Pool} from 'pg'; +import { TotalStatsRepository } from '@/repositories/totalStats.repository'; +import { DBError } from '@/exception'; +import { getKSTDateStringWithOffset } from '@/utils/date.util'; +import { createMockQueryResult } from './fixture'; + +// Mock dependencies +jest.mock('@/configs/logger.config', () => ({ + error: jest.fn(), +})); + +jest.mock('@/utils/date.util', () => ({ + getKSTDateStringWithOffset: jest.fn(), +})); + +describe('TotalStatsRepository', () => { + let repository: TotalStatsRepository; + let mockPool: jest.Mocked; + let mockGetKSTDateStringWithOffset: jest.MockedFunction; + + beforeEach(() => { + mockPool = { + query: jest.fn(), + } as unknown as jest.Mocked; + + mockGetKSTDateStringWithOffset = getKSTDateStringWithOffset as jest.MockedFunction; + + repository = new TotalStatsRepository(mockPool); + jest.clearAllMocks(); + }); + + describe('getTotalStats', () => { + const userId = 1; + const period = 7; + const mockStartDate = '2025-05-27'; + + beforeEach(() => { + mockGetKSTDateStringWithOffset.mockReturnValue(mockStartDate); + }); + + describe('view 타입 통계 조회', () => { + it('조회수 통계를 성공적으로 조회해야 한다', async () => { + // Given + const mockViewStats = [ + { date: '2025-05-27', total_value: '100' }, + { date: '2025-05-28', total_value: '150' }, + { date: '2025-05-29', total_value: '200' }, + ]; + + mockPool.query.mockResolvedValue(createMockQueryResult(mockViewStats)); + + // When + const result = await repository.getTotalStats(userId, period, 'view'); + + // Then + expect(result).toEqual(mockViewStats); + expect(mockGetKSTDateStringWithOffset).toHaveBeenCalledWith(-period * 24 * 60); + expect(mockPool.query).toHaveBeenCalledWith( + expect.stringContaining('SUM(pds.daily_view_count)'), + [userId, mockStartDate] + ); + }); + + it('조회수 통계 조회 시 DB 에러가 발생하면 DBError를 던져야 한다', async () => { + // Given + mockPool.query.mockRejectedValue(new Error('Database connection failed')); + + // When & Then + await expect(repository.getTotalStats(userId, period, 'view')).rejects.toThrow( + new DBError('조회수 통계 조회 중 문제가 발생했습니다.') + ); + }); + }); + + describe('like 타입 통계 조회', () => { + it('좋아요 통계를 성공적으로 조회해야 한다', async () => { + // Given + const mockLikeStats = [ + { date: '2025-05-27', total_value: '50' }, + { date: '2025-05-28', total_value: '75' }, + { date: '2025-05-29', total_value: '100' }, + ]; + + mockPool.query.mockResolvedValue(createMockQueryResult(mockLikeStats)); + + // When + const result = await repository.getTotalStats(userId, period, 'like'); + + // Then + expect(result).toEqual(mockLikeStats); + expect(mockGetKSTDateStringWithOffset).toHaveBeenCalledWith(-period * 24 * 60); + expect(mockPool.query).toHaveBeenCalledWith( + expect.stringContaining('SUM(pds.daily_like_count)'), + [userId, mockStartDate] + ); + }); + + it('좋아요 통계 조회 시 DB 에러가 발생하면 DBError를 던져야 한다', async () => { + // Given + mockPool.query.mockRejectedValue(new Error('Database connection failed')); + + // When & Then + await expect(repository.getTotalStats(userId, period, 'like')).rejects.toThrow( + new DBError('좋아요 통계 조회 중 문제가 발생했습니다.') + ); + }); + }); + + describe('post 타입 통계 조회', () => { + it('게시글 통계를 성공적으로 조회해야 한다', async () => { + // Given + const mockPostStats = [ + { date: '2025-05-27', total_value: 5 }, + { date: '2025-05-28', total_value: 7 }, + { date: '2025-05-29', total_value: 10 }, + ]; + + mockPool.query.mockResolvedValue(createMockQueryResult(mockPostStats)); + + // When + const result = await repository.getTotalStats(userId, period, 'post'); + + // Then + expect(result).toEqual(mockPostStats); + expect(mockGetKSTDateStringWithOffset).toHaveBeenCalledWith(-period * 24 * 60); + expect(mockPool.query).toHaveBeenCalledWith( + expect.stringContaining('WITH date_series AS'), + [userId, mockStartDate] + ); + expect(mockPool.query).toHaveBeenCalledWith( + expect.stringContaining('SUM(dp.post_count) OVER'), + [userId, mockStartDate] + ); + }); + + it('게시글 통계 조회 시 DB 에러가 발생하면 DBError를 던져야 한다', async () => { + // Given + mockPool.query.mockRejectedValue(new Error('Database connection failed')); + + // When & Then + await expect(repository.getTotalStats(userId, period, 'post')).rejects.toThrow( + new DBError('게시글 통계 조회 중 문제가 발생했습니다.') + ); + }); + }); + + describe('잘못된 타입 처리', () => { + it('지원되지 않는 통계 타입이 전달되면 DBError를 던져야 한다', async () => { + // When & Then + await expect( + repository.getTotalStats(userId, period, 'invalid' as any) + ).rejects.toThrow(new DBError('지원되지 않는 통계 타입입니다.')); + + expect(mockPool.query).not.toHaveBeenCalled(); + }); + }); + + describe('다양한 기간 테스트', () => { + it('30일 기간으로 통계를 조회할 수 있어야 한다', async () => { + // Given + const period30 = 30; + const mockStats = [{ date: '2025-04-27', total_value: '1000' }]; + + mockPool.query.mockResolvedValue(createMockQueryResult(mockStats)); + + // When + await repository.getTotalStats(userId, period30, 'view'); + + // Then + expect(mockGetKSTDateStringWithOffset).toHaveBeenCalledWith(-period30 * 24 * 60); + }); + }); + + describe('빈 결과 처리', () => { + it('데이터가 없을 때 빈 배열을 반환해야 한다', async () => { + // Given + mockPool.query.mockResolvedValue(createMockQueryResult([])); + + // When + const result = await repository.getTotalStats(userId, period, 'view'); + + // Then + expect(result).toEqual([]); + expect(result).toHaveLength(0); + }); + }); + + describe('SQL 쿼리 검증', () => { + beforeEach(() => { + mockPool.query.mockResolvedValue(createMockQueryResult([])); + }); + + it('view 통계 쿼리가 올바른 테이블과 조건을 포함해야 한다', async () => { + // When + await repository.getTotalStats(userId, period, 'view'); + + // Then + const calledQuery = mockPool.query.mock.calls[0][0] as string; + expect(calledQuery).toContain('posts_postdailystatistics pds'); + expect(calledQuery).toContain('JOIN posts_post p ON p.id = pds.post_id'); + expect(calledQuery).toContain('p.user_id = $1'); + expect(calledQuery).toContain('p.is_active = true'); + expect(calledQuery).toContain('pds.date >= $2'); + expect(calledQuery).toContain('SUM(pds.daily_view_count)'); + }); + + it('like 통계 쿼리가 올바른 컬럼을 조회해야 한다', async () => { + // When + await repository.getTotalStats(userId, period, 'like'); + + // Then + const calledQuery = mockPool.query.mock.calls[0][0] as string; + expect(calledQuery).toContain('SUM(pds.daily_like_count)'); + }); + + it('post 통계 쿼리가 CTE와 윈도우 함수를 사용해야 한다', async () => { + // When + await repository.getTotalStats(userId, period, 'post'); + + // Then + const calledQuery = mockPool.query.mock.calls[0][0] as string; + expect(calledQuery).toContain('WITH date_series AS'); + expect(calledQuery).toContain('generate_series'); + expect(calledQuery).toContain('SUM(dp.post_count) OVER'); + expect(calledQuery).toContain('DATE(p.released_at)'); + }); + }); + }); +}); \ No newline at end of file From a56ce925ccd4b66e55a1b36eb6af43ce0fd6bf8d Mon Sep 17 00:00:00 2001 From: Nuung Date: Tue, 3 Jun 2025 13:38:59 +0900 Subject: [PATCH 03/10] =?UTF-8?q?feature:=20total=20Stats=20repo=20?= =?UTF-8?q?=EA=B3=84=EC=B8=B5=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/controllers/totalStats.controller.ts | 6 +++--- .../leaderboard.repo.integration.test.ts | 3 +-- .../integration/post.repo.integration.test.ts | 5 ++--- .../integration/qr.repo.integration.test.ts | 5 ++--- src/repositories/__test__/qr.repo.test.ts | 7 +++---- .../__test__/totalStats.repo.test.ts | 16 ++++++---------- src/repositories/totalStats.repository.ts | 2 +- src/services/totalStats.service.ts | 3 +-- 8 files changed, 19 insertions(+), 28 deletions(-) diff --git a/src/controllers/totalStats.controller.ts b/src/controllers/totalStats.controller.ts index e71f11b..cdf4c6b 100644 --- a/src/controllers/totalStats.controller.ts +++ b/src/controllers/totalStats.controller.ts @@ -1,8 +1,8 @@ -import logger from '@/configs/logger.config'; import { NextFunction, Request, RequestHandler, Response } from 'express'; -import { TotalStatsService } from '@/services/totalStats.service'; -import { GetTotalStatsQuery, TotalStatsResponseDto } from '@/types'; +import logger from '@/configs/logger.config'; import { BadRequestError } from '@/exception'; +import { GetTotalStatsQuery, TotalStatsResponseDto } from '@/types'; +import { TotalStatsService } from '@/services/totalStats.service'; export class TotalStatsController { diff --git a/src/repositories/__test__/integration/leaderboard.repo.integration.test.ts b/src/repositories/__test__/integration/leaderboard.repo.integration.test.ts index 50e9b7d..e9cfeb8 100644 --- a/src/repositories/__test__/integration/leaderboard.repo.integration.test.ts +++ b/src/repositories/__test__/integration/leaderboard.repo.integration.test.ts @@ -6,8 +6,7 @@ import logger from '@/configs/logger.config'; import dotenv from 'dotenv'; -import pg from 'pg'; -import { Pool } from 'pg'; +import pg, { Pool } from 'pg'; import { LeaderboardRepository } from '@/repositories/leaderboard.repository'; import { PostLeaderboardSortType, UserLeaderboardSortType } from '@/types'; diff --git a/src/repositories/__test__/integration/post.repo.integration.test.ts b/src/repositories/__test__/integration/post.repo.integration.test.ts index 3c643ce..6fcefe6 100644 --- a/src/repositories/__test__/integration/post.repo.integration.test.ts +++ b/src/repositories/__test__/integration/post.repo.integration.test.ts @@ -1,8 +1,7 @@ import dotenv from 'dotenv'; -import { Pool } from 'pg'; -import pg from 'pg'; -import { PostRepository } from '../post.repository'; +import pg, { Pool } from 'pg'; import logger from '@/configs/logger.config'; +import { PostRepository } from '@/repositories/post.repository'; import { getCurrentKSTDateString, getKSTDateStringWithOffset } from '@/utils/date.util'; diff --git a/src/repositories/__test__/integration/qr.repo.integration.test.ts b/src/repositories/__test__/integration/qr.repo.integration.test.ts index 733f460..227adbb 100644 --- a/src/repositories/__test__/integration/qr.repo.integration.test.ts +++ b/src/repositories/__test__/integration/qr.repo.integration.test.ts @@ -1,9 +1,8 @@ import dotenv from 'dotenv'; -import { Pool } from 'pg'; -import pg from 'pg'; +import pg, { Pool } from 'pg'; +import logger from '@/configs/logger.config'; import { UserRepository } from '@/repositories/user.repository'; import { generateRandomToken } from '@/utils/generateRandomToken.util'; -import logger from '@/configs/logger.config'; dotenv.config(); jest.setTimeout(5000); diff --git a/src/repositories/__test__/qr.repo.test.ts b/src/repositories/__test__/qr.repo.test.ts index dedb5c9..a3b5922 100644 --- a/src/repositories/__test__/qr.repo.test.ts +++ b/src/repositories/__test__/qr.repo.test.ts @@ -2,16 +2,15 @@ import { UserRepository } from '@/repositories/user.repository'; import { DBError } from '@/exception'; import { Pool } from 'pg'; import { QRLoginToken } from "@/types/models/QRLoginToken.type"; +import { mockPool } from './fixture'; -const mockPool: Partial = { - query: jest.fn(), -}; +jest.mock('pg'); describe('UserRepository - QR Login Token', () => { let repo: UserRepository; beforeEach(() => { - repo = new UserRepository(mockPool as Pool); + repo = new UserRepository(mockPool as unknown as Pool); }); afterEach(() => { diff --git a/src/repositories/__test__/totalStats.repo.test.ts b/src/repositories/__test__/totalStats.repo.test.ts index 7e63755..93ea8a5 100644 --- a/src/repositories/__test__/totalStats.repo.test.ts +++ b/src/repositories/__test__/totalStats.repo.test.ts @@ -1,8 +1,9 @@ -import { Pool} from 'pg'; +import { Pool } from 'pg'; import { TotalStatsRepository } from '@/repositories/totalStats.repository'; import { DBError } from '@/exception'; import { getKSTDateStringWithOffset } from '@/utils/date.util'; -import { createMockQueryResult } from './fixture'; +import { mockPool, createMockQueryResult } from './fixture'; +import { TotalStatsType } from '@/types'; // Mock dependencies jest.mock('@/configs/logger.config', () => ({ @@ -15,17 +16,12 @@ jest.mock('@/utils/date.util', () => ({ describe('TotalStatsRepository', () => { let repository: TotalStatsRepository; - let mockPool: jest.Mocked; let mockGetKSTDateStringWithOffset: jest.MockedFunction; beforeEach(() => { - mockPool = { - query: jest.fn(), - } as unknown as jest.Mocked; - mockGetKSTDateStringWithOffset = getKSTDateStringWithOffset as jest.MockedFunction; - - repository = new TotalStatsRepository(mockPool); + + repository = new TotalStatsRepository(mockPool as unknown as Pool); jest.clearAllMocks(); }); @@ -148,7 +144,7 @@ describe('TotalStatsRepository', () => { it('지원되지 않는 통계 타입이 전달되면 DBError를 던져야 한다', async () => { // When & Then await expect( - repository.getTotalStats(userId, period, 'invalid' as any) + repository.getTotalStats(userId, period, 'invalid' as unknown as TotalStatsType) ).rejects.toThrow(new DBError('지원되지 않는 통계 타입입니다.')); expect(mockPool.query).not.toHaveBeenCalled(); diff --git a/src/repositories/totalStats.repository.ts b/src/repositories/totalStats.repository.ts index 1380bb0..529b5ea 100644 --- a/src/repositories/totalStats.repository.ts +++ b/src/repositories/totalStats.repository.ts @@ -1,8 +1,8 @@ import { Pool } from 'pg'; import logger from '@/configs/logger.config'; import { DBError } from '@/exception'; -import { getKSTDateStringWithOffset } from '@/utils/date.util'; import { TotalStatsType } from '@/types'; +import { getKSTDateStringWithOffset } from '@/utils/date.util'; interface RawStatsResult { date: string; diff --git a/src/services/totalStats.service.ts b/src/services/totalStats.service.ts index c9adcc1..f48998e 100644 --- a/src/services/totalStats.service.ts +++ b/src/services/totalStats.service.ts @@ -1,7 +1,6 @@ import logger from '@/configs/logger.config'; +import { TotalStatsPeriod, TotalStatsType, TotalStatsItem } from '@/types'; import { TotalStatsRepository } from '@/repositories/totalStats.repository'; -import { TotalStatsPeriod, TotalStatsType } from '@/types'; -import { TotalStatsItem } from '@/types'; export class TotalStatsService { constructor(private totalStatsRepo: TotalStatsRepository) { } From b25705cdfd0ed9a174d0b5c32df299d84dd13370 Mon Sep 17 00:00:00 2001 From: Nuung Date: Tue, 3 Jun 2025 13:41:47 +0900 Subject: [PATCH 04/10] =?UTF-8?q?feature:=20fixture=20jsdocs=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/repositories/__test__/fixture.ts | 43 +++++++++++++++++++++++++--- 1 file changed, 39 insertions(+), 4 deletions(-) diff --git a/src/repositories/__test__/fixture.ts b/src/repositories/__test__/fixture.ts index 994a5d1..ddc3dd6 100644 --- a/src/repositories/__test__/fixture.ts +++ b/src/repositories/__test__/fixture.ts @@ -1,5 +1,18 @@ import { QueryResult } from "pg"; +/** +* PostgreSQL 쿼리를 모킹하기 위한 mock Pool 객체 +* +* @description Jest 테스트에서 pg.Pool의 query 메서드를 모킹하는 데 사용됩니다. +* @example +* ```typescript +* // 성공적인 쿼리 결과 모킹 +* mockPool.query.mockResolvedValue(createMockQueryResult([{ id: 1, name: 'test' }])); +* +* // 에러 발생 모킹 +* mockPool.query.mockRejectedValue(new Error('Database error')); +* ``` +*/ export const mockPool: { query: jest.Mock>>, unknown[]>; } = { @@ -7,10 +20,32 @@ export const mockPool: { }; /** - * pg의 QueryResult 타입을 만족하는 mock 객체를 생성하기 위한 헬퍼 함수 - * @param rows - * @returns - */ +* pg의 QueryResult 타입을 만족하는 mock 객체를 생성하기 위한 헬퍼 함수 +* +* @template T - 쿼리 결과 row의 타입 (Record를 확장해야 함) +* @param rows - 모킹할 데이터베이스 행들의 배열 +* @returns PostgreSQL QueryResult 형태의 mock 객체 +* +* @description +* PostgreSQL의 실제 쿼리 결과와 동일한 구조를 가진 mock 객체를 생성합니다. +* Jest 테스트에서 데이터베이스 쿼리 결과를 모킹할 때 사용됩니다. +* +* @example +* ```typescript +* // 사용자 데이터 모킹 +* const mockUsers = [ +* { id: 1, name: 'John', email: 'john@example.com' }, +* { id: 2, name: 'Jane', email: 'jane@example.com' } +* ]; +* const result = createMockQueryResult(mockUsers); +* +* // 빈 결과 모킹 +* const emptyResult = createMockQueryResult([]); +* +* // Jest mock에서 사용 +* mockPool.query.mockResolvedValue(createMockQueryResult(mockUsers)); +* ``` +*/ export function createMockQueryResult>(rows: T[]): QueryResult { return { rows, From 6a63e007eec9cbbbd0fd9bd245989774021a3ca3 Mon Sep 17 00:00:00 2001 From: Nuung Date: Tue, 3 Jun 2025 13:46:48 +0900 Subject: [PATCH 05/10] =?UTF-8?q?feature:=20prettier=20=EC=A0=81=EC=9A=A9,?= =?UTF-8?q?=20=EC=99=80=20=EC=9A=B0=EB=A6=AC=20=EB=A7=8E=EC=9D=B4=20?= =?UTF-8?q?=EB=86=93=EC=B3=A4=EB=8B=A4=20=E3=85=8B=E3=85=8B=E3=85=8B?= =?UTF-8?q?=E3=85=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/controllers/noti.controller.ts | 21 ++++------- src/controllers/post.controller.ts | 2 +- src/controllers/totalStats.controller.ts | 12 ++----- src/controllers/user.controller.ts | 35 +++++++++---------- src/exception/index.ts | 8 ++++- src/middlewares/auth.middleware.ts | 6 ++-- .../__test__/{fixture.ts => fixtures.ts} | 0 .../__test__/leaderboard.repo.test.ts | 2 +- src/repositories/__test__/post.repo.test.ts | 2 +- src/repositories/__test__/qr.repo.test.ts | 2 +- .../__test__/totalStats.repo.test.ts | 2 +- src/repositories/leaderboard.repository.ts | 2 +- src/repositories/noti.repository.ts | 4 +-- src/repositories/post.repository.ts | 12 ++----- src/repositories/totalStats.repository.ts | 4 +-- src/repositories/user.repository.ts | 2 +- src/routes/post.router.ts | 2 +- src/routes/totalStats.router.ts | 2 +- src/services/noti.service.ts | 14 ++++---- src/services/post.service.ts | 7 ++-- src/services/totalStats.service.ts | 4 +-- src/services/user.service.ts | 10 ++++-- src/types/index.ts | 31 ++++++---------- src/utils/date.util.ts | 22 ++++++------ src/utils/generateRandomToken.util.ts | 2 +- 25 files changed, 94 insertions(+), 116 deletions(-) rename src/repositories/__test__/{fixture.ts => fixtures.ts} (100%) diff --git a/src/controllers/noti.controller.ts b/src/controllers/noti.controller.ts index 29ddb9c..7afc015 100644 --- a/src/controllers/noti.controller.ts +++ b/src/controllers/noti.controller.ts @@ -1,28 +1,19 @@ import { NextFunction, Request, RequestHandler, Response } from 'express'; import logger from '@/configs/logger.config'; -import { NotiService } from "@/services/noti.service"; -import { NotiPostsResponseDto } from "@/types/dto/responses/notiResponse.type"; +import { NotiService } from '@/services/noti.service'; +import { NotiPostsResponseDto } from '@/types/dto/responses/notiResponse.type'; export class NotiController { - constructor(private notiService: NotiService) { } + constructor(private notiService: NotiService) {} - getAllNotiPosts: RequestHandler = async ( - req: Request, - res: Response, - next: NextFunction, - ) => { + getAllNotiPosts: RequestHandler = async (req: Request, res: Response, next: NextFunction) => { try { const result = await this.notiService.getAllNotiPosts(); - const response = new NotiPostsResponseDto( - true, - '전체 noti post 조회에 성공하였습니다.', - { posts: result }, - null, - ); + const response = new NotiPostsResponseDto(true, '전체 noti post 조회에 성공하였습니다.', { posts: result }, null); res.status(200).json(response); } catch (error) { logger.error('전체 조회 실패:', error); next(error); } }; -} \ No newline at end of file +} diff --git a/src/controllers/post.controller.ts b/src/controllers/post.controller.ts index 8d69280..a9a85cd 100644 --- a/src/controllers/post.controller.ts +++ b/src/controllers/post.controller.ts @@ -11,7 +11,7 @@ import { } from '@/types'; export class PostController { - constructor(private postService: PostService) { } + constructor(private postService: PostService) {} getAllPosts: RequestHandler = async ( req: Request, diff --git a/src/controllers/totalStats.controller.ts b/src/controllers/totalStats.controller.ts index cdf4c6b..a332bbf 100644 --- a/src/controllers/totalStats.controller.ts +++ b/src/controllers/totalStats.controller.ts @@ -4,9 +4,8 @@ import { BadRequestError } from '@/exception'; import { GetTotalStatsQuery, TotalStatsResponseDto } from '@/types'; import { TotalStatsService } from '@/services/totalStats.service'; - export class TotalStatsController { - constructor(private totalStatsService: TotalStatsService) { } + constructor(private totalStatsService: TotalStatsService) {} getTotalStats: RequestHandler = async ( req: Request, @@ -23,12 +22,7 @@ export class TotalStatsController { const stats = await this.totalStatsService.getTotalStats(id, period, type); const message = this.totalStatsService.getSuccessMessage(type); - const response = new TotalStatsResponseDto( - true, - message, - stats, - null, - ); + const response = new TotalStatsResponseDto(true, message, stats, null); res.status(200).json(response); } catch (error) { @@ -36,4 +30,4 @@ export class TotalStatsController { next(error); } }; -} \ No newline at end of file +} diff --git a/src/controllers/user.controller.ts b/src/controllers/user.controller.ts index 71eb3e2..36418fa 100644 --- a/src/controllers/user.controller.ts +++ b/src/controllers/user.controller.ts @@ -9,7 +9,7 @@ import { fetchVelogApi } from '@/modules/velog/velog.api'; type Token10 = string & { __lengthBrand: 10 }; export class UserController { - constructor(private userService: UserService) { } + constructor(private userService: UserService) {} private cookieOption(): CookieOptions { const isProd = process.env.NODE_ENV === 'production'; @@ -21,7 +21,7 @@ export class UserController { if (isProd) { baseOptions.sameSite = 'lax'; - baseOptions.domain = "velog-dashboard.kro.kr"; + baseOptions.domain = 'velog-dashboard.kro.kr'; } else { baseOptions.domain = 'localhost'; } @@ -31,7 +31,6 @@ export class UserController { login: RequestHandler = async (req: Request, res: Response, next: NextFunction): Promise => { try { - // 1. 외부 API (velog) 호출로 실존 하는 토큰 & 사용자 인지 검증 const { accessToken, refreshToken } = req.body; const velogUser = await fetchVelogApi(accessToken, refreshToken); @@ -60,7 +59,11 @@ export class UserController { } }; - sampleLogin: RequestHandler = async (req: Request, res: Response, next: NextFunction): Promise => { + sampleLogin: RequestHandler = async ( + req: Request, + res: Response, + next: NextFunction, + ): Promise => { try { const sampleUser = await this.userService.findSampleUser(); @@ -77,8 +80,8 @@ export class UserController { '로그인에 성공하였습니다.', { id: sampleUser.user.id, - username: "테스트 유저", - profile: { "thumbnail": "https://velog.io/favicon.ico" } + username: '테스트 유저', + profile: { thumbnail: 'https://velog.io/favicon.ico' }, }, null, ); @@ -88,7 +91,7 @@ export class UserController { logger.error('로그인 실패 : ', error); next(error); } - } + }; logout: RequestHandler = async (req: Request, res: Response) => { res.clearCookie('access_token', this.cookieOption()); @@ -114,25 +117,19 @@ export class UserController { res.status(200).json(response); }; - createToken: RequestHandler = async ( - req: Request, - res: Response, - next: NextFunction, - ) => { + createToken: RequestHandler = async (req: Request, res: Response, next: NextFunction) => { try { const user = req.user; - const ip = typeof req.headers['x-forwarded-for'] === 'string' ? req.headers['x-forwarded-for'].split(',')[0].trim() : req.ip ?? ''; + const ip = + typeof req.headers['x-forwarded-for'] === 'string' + ? req.headers['x-forwarded-for'].split(',')[0].trim() + : (req.ip ?? ''); const userAgent = req.headers['user-agent'] || ''; const token = await this.userService.createUserQRToken(user.id, ip, userAgent); const typedToken = token as Token10; - const response = new QRLoginTokenResponseDto( - true, - 'QR 토큰 생성 완료', - { token: typedToken }, - null - ); + const response = new QRLoginTokenResponseDto(true, 'QR 토큰 생성 완료', { token: typedToken }, null); res.status(200).json(response); } catch (error) { logger.error(`QR 토큰 생성 실패: [userId: ${req.user?.id || 'anonymous'}]`, error); diff --git a/src/exception/index.ts b/src/exception/index.ts index 82f83f2..c7c88c2 100644 --- a/src/exception/index.ts +++ b/src/exception/index.ts @@ -1,6 +1,12 @@ export { CustomError } from './custom.exception'; export { DBError } from './db.exception'; -export { TokenError, TokenExpiredError, InvalidTokenError, QRTokenExpiredError, QRTokenInvalidError } from './token.exception'; +export { + TokenError, + TokenExpiredError, + InvalidTokenError, + QRTokenExpiredError, + QRTokenInvalidError, +} from './token.exception'; export { UnauthorizedError } from './unauthorized.exception'; export { BadRequestError } from './badRequest.exception'; export { NotFoundError } from './notFound.exception'; diff --git a/src/middlewares/auth.middleware.ts b/src/middlewares/auth.middleware.ts index 53c2ad1..34f536d 100644 --- a/src/middlewares/auth.middleware.ts +++ b/src/middlewares/auth.middleware.ts @@ -32,7 +32,8 @@ const extractTokens = (req: Request): { accessToken: string; refreshToken: strin * const payload = extractPayload(token); * // 반환값: { sub: "1234567890" } */ -const extractPayload = (token: string): VelogJWTPayload => JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString()); +const extractPayload = (token: string): VelogJWTPayload => + JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString()); /** * Bearer 토큰을 검증한뒤 user정보를 Request 객체에 담는 인가 함수 @@ -51,7 +52,8 @@ const verifyBearerTokens = () => { throw new InvalidTokenError('유효하지 않은 토큰 페이로드 입니다.'); } - const user = (await pool.query('SELECT * FROM "users_user" WHERE velog_uuid = $1', [payload.user_id])).rows[0] as User; + const user = (await pool.query('SELECT * FROM "users_user" WHERE velog_uuid = $1', [payload.user_id])) + .rows[0] as User; if (!user) throw new DBError('사용자를 찾을 수 없습니다.'); req.user = user; diff --git a/src/repositories/__test__/fixture.ts b/src/repositories/__test__/fixtures.ts similarity index 100% rename from src/repositories/__test__/fixture.ts rename to src/repositories/__test__/fixtures.ts diff --git a/src/repositories/__test__/leaderboard.repo.test.ts b/src/repositories/__test__/leaderboard.repo.test.ts index b51ad72..cda0cbb 100644 --- a/src/repositories/__test__/leaderboard.repo.test.ts +++ b/src/repositories/__test__/leaderboard.repo.test.ts @@ -2,7 +2,7 @@ import { Pool } from 'pg'; import { DBError } from '@/exception'; import { LeaderboardRepository } from '@/repositories/leaderboard.repository'; import { UserLeaderboardSortType, PostLeaderboardSortType } from '@/types'; -import { mockPool, createMockQueryResult } from './fixture'; +import { mockPool, createMockQueryResult } from './fixtures'; jest.mock('pg'); diff --git a/src/repositories/__test__/post.repo.test.ts b/src/repositories/__test__/post.repo.test.ts index 503ba33..ba8df20 100644 --- a/src/repositories/__test__/post.repo.test.ts +++ b/src/repositories/__test__/post.repo.test.ts @@ -1,7 +1,7 @@ import { Pool } from 'pg'; import { PostRepository } from '@/repositories/post.repository'; import { DBError } from '@/exception'; -import { mockPool, createMockQueryResult } from './fixture'; +import { mockPool, createMockQueryResult } from './fixtures'; jest.mock('pg'); diff --git a/src/repositories/__test__/qr.repo.test.ts b/src/repositories/__test__/qr.repo.test.ts index a3b5922..a628955 100644 --- a/src/repositories/__test__/qr.repo.test.ts +++ b/src/repositories/__test__/qr.repo.test.ts @@ -2,7 +2,7 @@ import { UserRepository } from '@/repositories/user.repository'; import { DBError } from '@/exception'; import { Pool } from 'pg'; import { QRLoginToken } from "@/types/models/QRLoginToken.type"; -import { mockPool } from './fixture'; +import { mockPool } from './fixtures'; jest.mock('pg'); diff --git a/src/repositories/__test__/totalStats.repo.test.ts b/src/repositories/__test__/totalStats.repo.test.ts index 93ea8a5..1fb27cb 100644 --- a/src/repositories/__test__/totalStats.repo.test.ts +++ b/src/repositories/__test__/totalStats.repo.test.ts @@ -2,7 +2,7 @@ import { Pool } from 'pg'; import { TotalStatsRepository } from '@/repositories/totalStats.repository'; import { DBError } from '@/exception'; import { getKSTDateStringWithOffset } from '@/utils/date.util'; -import { mockPool, createMockQueryResult } from './fixture'; +import { mockPool, createMockQueryResult } from './fixtures'; import { TotalStatsType } from '@/types'; // Mock dependencies diff --git a/src/repositories/leaderboard.repository.ts b/src/repositories/leaderboard.repository.ts index 210f521..6786aa9 100644 --- a/src/repositories/leaderboard.repository.ts +++ b/src/repositories/leaderboard.repository.ts @@ -10,7 +10,7 @@ export class LeaderboardRepository { async getUserLeaderboard(sort: UserLeaderboardSortType, dateRange: number, limit: number) { try { const pastDateKST = getKSTDateStringWithOffset(-dateRange * 24 * 60); - const cteQuery = this.buildLeaderboardCteQuery(dateRange); + const cteQuery = this.buildLeaderboardCteQuery(dateRange); const query = ` ${cteQuery} diff --git a/src/repositories/noti.repository.ts b/src/repositories/noti.repository.ts index 1bcb501..a482fb4 100644 --- a/src/repositories/noti.repository.ts +++ b/src/repositories/noti.repository.ts @@ -3,7 +3,7 @@ import logger from '@/configs/logger.config'; import { DBError } from '@/exception'; export class NotiRepository { - constructor(private pool: Pool) { } + constructor(private pool: Pool) {} async getAllNotiPosts(limit: number = 5) { try { @@ -26,4 +26,4 @@ export class NotiRepository { throw new DBError('알림 조회 중 문제가 발생했습니다.'); } } -} \ No newline at end of file +} diff --git a/src/repositories/post.repository.ts b/src/repositories/post.repository.ts index 2e02504..4731401 100644 --- a/src/repositories/post.repository.ts +++ b/src/repositories/post.repository.ts @@ -4,15 +4,9 @@ import { DBError } from '@/exception'; import { getCurrentKSTDateString, getKSTDateStringWithOffset } from '@/utils/date.util'; export class PostRepository { - constructor(private pool: Pool) { } + constructor(private pool: Pool) {} - async findPostsByUserId( - userId: number, - cursor?: string, - sort?: string, - isAsc: boolean = false, - limit: number = 15 - ) { + async findPostsByUserId(userId: number, cursor?: string, sort?: string, isAsc: boolean = false, limit: number = 15) { const nowDateKST = getCurrentKSTDateString(); const tomorrowDateKST = getKSTDateStringWithOffset(24 * 60); const yesterDateKST = getKSTDateStringWithOffset(-24 * 60); @@ -131,7 +125,7 @@ export class PostRepository { userId: number, cursor?: string, isAsc: boolean = false, - limit: number = 15 + limit: number = 15, ) { const nowDateKST = getCurrentKSTDateString(); const tomorrowDateKST = getKSTDateStringWithOffset(24 * 60); diff --git a/src/repositories/totalStats.repository.ts b/src/repositories/totalStats.repository.ts index 529b5ea..60afd7b 100644 --- a/src/repositories/totalStats.repository.ts +++ b/src/repositories/totalStats.repository.ts @@ -10,7 +10,7 @@ interface RawStatsResult { } export class TotalStatsRepository { - constructor(private pool: Pool) { } + constructor(private pool: Pool) {} private async getTotalViewStats(userId: number, period: number): Promise { try { @@ -112,4 +112,4 @@ export class TotalStatsRepository { throw new DBError('지원되지 않는 통계 타입입니다.'); } } -} \ No newline at end of file +} diff --git a/src/repositories/user.repository.ts b/src/repositories/user.repository.ts index c3b3ebe..7737217 100644 --- a/src/repositories/user.repository.ts +++ b/src/repositories/user.repository.ts @@ -1,7 +1,7 @@ import { Pool } from 'pg'; import logger from '@/configs/logger.config'; import { User } from '@/types'; -import { QRLoginToken } from "@/types/models/QRLoginToken.type"; +import { QRLoginToken } from '@/types/models/QRLoginToken.type'; import { DBError } from '@/exception'; export class UserRepository { diff --git a/src/routes/post.router.ts b/src/routes/post.router.ts index 39a0e67..6698744 100644 --- a/src/routes/post.router.ts +++ b/src/routes/post.router.ts @@ -137,7 +137,7 @@ router.get( '/post/:postId', authMiddleware.verify, validateRequestDto(GetPostQueryDto, 'query'), - postController.getPostByPostId + postController.getPostByPostId, ); export default router; diff --git a/src/routes/totalStats.router.ts b/src/routes/totalStats.router.ts index daa925b..2c78650 100644 --- a/src/routes/totalStats.router.ts +++ b/src/routes/totalStats.router.ts @@ -70,4 +70,4 @@ router.get( totalStatsController.getTotalStats, ); -export default router; \ No newline at end of file +export default router; diff --git a/src/services/noti.service.ts b/src/services/noti.service.ts index 059cfff..662e66c 100644 --- a/src/services/noti.service.ts +++ b/src/services/noti.service.ts @@ -1,10 +1,10 @@ -import { NotiRepository } from "@/repositories/noti.repository"; -import { NotiPost } from "@/types/models/NotiPost.type"; +import { NotiRepository } from '@/repositories/noti.repository'; +import { NotiPost } from '@/types/models/NotiPost.type'; export class NotiService { - constructor(private notiRepo: NotiRepository) {} + constructor(private notiRepo: NotiRepository) {} - async getAllNotiPosts(): Promise { - return await this.notiRepo.getAllNotiPosts(); - } -} \ No newline at end of file + async getAllNotiPosts(): Promise { + return await this.notiRepo.getAllNotiPosts(); + } +} diff --git a/src/services/post.service.ts b/src/services/post.service.ts index 2e8fb91..8b7350d 100644 --- a/src/services/post.service.ts +++ b/src/services/post.service.ts @@ -4,15 +4,14 @@ import { RawPostType } from '@/types'; import { getCurrentKSTDateString, getKSTDateStringWithOffset } from '@/utils/date.util'; export class PostService { - constructor(private postRepo: PostRepository) { } + constructor(private postRepo: PostRepository) {} async getAllposts(userId: number, cursor?: string, sort: string = '', isAsc?: boolean, limit: number = 15) { try { let result = null; - if (sort === "viewGrowth") { + if (sort === 'viewGrowth') { result = await this.postRepo.findPostsByUserIdWithGrowthMetrics(userId, cursor, isAsc, limit); - } - else { + } else { result = await this.postRepo.findPostsByUserId(userId, cursor, sort, isAsc, limit); } diff --git a/src/services/totalStats.service.ts b/src/services/totalStats.service.ts index f48998e..f27791d 100644 --- a/src/services/totalStats.service.ts +++ b/src/services/totalStats.service.ts @@ -3,7 +3,7 @@ import { TotalStatsPeriod, TotalStatsType, TotalStatsItem } from '@/types'; import { TotalStatsRepository } from '@/repositories/totalStats.repository'; export class TotalStatsService { - constructor(private totalStatsRepo: TotalStatsRepository) { } + constructor(private totalStatsRepo: TotalStatsRepository) {} async getTotalStats(userId: number, period: TotalStatsPeriod = 7, type: TotalStatsType): Promise { try { @@ -27,4 +27,4 @@ export class TotalStatsService { }; return messages[type]; } -} \ No newline at end of file +} diff --git a/src/services/user.service.ts b/src/services/user.service.ts index dd208d2..93a8191 100644 --- a/src/services/user.service.ts +++ b/src/services/user.service.ts @@ -10,7 +10,7 @@ import { generateRandomToken } from '@/utils/generateRandomToken.util'; import { VelogUserCurrentResponse } from '@/modules/velog/velog.type'; export class UserService { - constructor(private userRepo: UserRepository) { } + constructor(private userRepo: UserRepository) {} private encryptTokens(groupId: number, accessToken: string, refreshToken: string) { const key = getKeyByGroup(groupId); @@ -50,7 +50,11 @@ export class UserService { } } - async handleUserTokensByVelogUUID(userData: VelogUserCurrentResponse, accessToken: string, refreshToken: string): Promise { + async handleUserTokensByVelogUUID( + userData: VelogUserCurrentResponse, + accessToken: string, + refreshToken: string, + ): Promise { // velog response 에서 주는 응답 혼용 방지를 위한 변경 id -> uuid const { id: uuid, email = null } = userData; try { @@ -95,7 +99,7 @@ export class UserService { const { decryptedAccessToken, decryptedRefreshToken } = this.decryptTokens( user.group_id, user.access_token, - user.refresh_token + user.refresh_token, ); logger.info('샘플 유저 로그인'); return { user, decryptedAccessToken, decryptedRefreshToken }; diff --git a/src/types/index.ts b/src/types/index.ts index 9bfaada..7a247d8 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -15,11 +15,7 @@ export type { GetAllPostsQuery } from '@/types/dto/requests/getAllPostsQuery.typ export type { RawPostType } from '@/types/dto/responses/postResponse.type'; export { GetAllPostsQueryDto } from '@/types/dto/requests/getAllPostsQuery.type'; export { GetPostQueryDto } from '@/types/dto/requests/getPostQuery.type'; -export { - PostsResponseDto, - PostResponseDto, - PostStatisticsResponseDto, -} from '@/types/dto/responses/postResponse.type'; +export { PostsResponseDto, PostResponseDto, PostStatisticsResponseDto } from '@/types/dto/responses/postResponse.type'; // Leaderboard 관련 export type { @@ -28,24 +24,19 @@ export type { UserLeaderboardSortType, PostLeaderboardSortType, } from '@/types/dto/requests/getLeaderboardQuery.type'; -export type { - UserLeaderboardData, - PostLeaderboardData, -} from '@/types/dto/responses/leaderboardResponse.type'; -export { - GetUserLeaderboardQueryDto, - GetPostLeaderboardQueryDto, -} from '@/types/dto/requests/getLeaderboardQuery.type'; -export { - UserLeaderboardResponseDto, - PostLeaderboardResponseDto, -} from '@/types/dto/responses/leaderboardResponse.type'; +export type { UserLeaderboardData, PostLeaderboardData } from '@/types/dto/responses/leaderboardResponse.type'; +export { GetUserLeaderboardQueryDto, GetPostLeaderboardQueryDto } from '@/types/dto/requests/getLeaderboardQuery.type'; +export { UserLeaderboardResponseDto, PostLeaderboardResponseDto } from '@/types/dto/responses/leaderboardResponse.type'; // Total Stats 관련 -export type { TotalStatsPeriod, TotalStatsType, GetTotalStatsQuery } from '@/types/dto/requests/getTotalStatsQuery.type'; +export type { + TotalStatsPeriod, + TotalStatsType, + GetTotalStatsQuery, +} from '@/types/dto/requests/getTotalStatsQuery.type'; export type { TotalStatsItem } from '@/types/dto/responses/totalStatsResponse.type'; export { GetTotalStatsQueryDto } from '@/types/dto/requests/getTotalStatsQuery.type'; export { TotalStatsResponseDto } from '@/types/dto/responses/totalStatsResponse.type'; -// Common -export { EmptyResponseDto } from '@/types/dto/responses/emptyReponse.type'; \ No newline at end of file +// Common +export { EmptyResponseDto } from '@/types/dto/responses/emptyReponse.type'; diff --git a/src/utils/date.util.ts b/src/utils/date.util.ts index 75b5345..e3391d0 100644 --- a/src/utils/date.util.ts +++ b/src/utils/date.util.ts @@ -1,6 +1,6 @@ /** * 현재 날짜의 시작 시간(00:00:00)을 한국 표준시(KST, UTC+9)의 포맷팅된 문자열로 반환합니다. - * + * * @returns {string} 'YYYY-MM-DD 00:00:00+09' 형식의 한국 시간 문자열 * @example * // 현재 시간이 2025-05-10 15:30:25 KST일 경우 @@ -11,32 +11,32 @@ export function getCurrentKSTDateString(): string { const now = new Date(); // KST = UTC + 9시간 const kstDate = new Date(now.getTime() + 9 * 60 * 60 * 1000); - + const year = kstDate.getUTCFullYear(); const month = String(kstDate.getUTCMonth() + 1).padStart(2, '0'); const day = String(kstDate.getUTCDate()).padStart(2, '0'); - + // 시간은 항상 00:00:00으로 고정 return `${year}-${month}-${day} 00:00:00+09`; } /** - * 현재 시간으로부터 지정된 분(minutes) 후의 날짜에 대한 시작 시간(00:00:00)을 + * 현재 시간으로부터 지정된 분(minutes) 후의 날짜에 대한 시작 시간(00:00:00)을 * 한국 표준시(KST, UTC+9)로 반환합니다. - * + * * @param {number} minutes - 현재 시간에 더할 분(minutes) * @returns {string} 'YYYY-MM-DD 00:00:00+09' 형식의 지정된 날짜의 시작 시간 문자열 * @example * // 현재 시간이 2025-05-10 15:30:25 KST일 경우 - * + * * // 5분 후 날짜의 시작 시간 (같은 날이므로 동일) * // 반환 예시: '2025-05-10 00:00:00+09' * const sameDay = getKSTDateStringWithOffset(5); - * + * * // 하루 후(1440분)의 날짜 시작 시간 * // 반환 예시: '2025-05-11 00:00:00+09' * const nextDay = getKSTDateStringWithOffset(1440); - * + * * // 하루 전(-1440분)의 날짜 시작 시간 * // 반환 예시: '2025-05-09 00:00:00+09' * const previousDay = getKSTDateStringWithOffset(-1440); @@ -47,11 +47,11 @@ export function getKSTDateStringWithOffset(minutes: number): string { const futureTime = new Date(now.getTime() + minutes * 60 * 1000); // KST = UTC + 9시간 const kstDate = new Date(futureTime.getTime() + 9 * 60 * 60 * 1000); - + const year = kstDate.getUTCFullYear(); const month = String(kstDate.getUTCMonth() + 1).padStart(2, '0'); const day = String(kstDate.getUTCDate()).padStart(2, '0'); - + // 시간은 항상 00:00:00으로 고정 return `${year}-${month}-${day} 00:00:00+09`; -} \ No newline at end of file +} diff --git a/src/utils/generateRandomToken.util.ts b/src/utils/generateRandomToken.util.ts index 67bb3b1..acc4df2 100644 --- a/src/utils/generateRandomToken.util.ts +++ b/src/utils/generateRandomToken.util.ts @@ -12,4 +12,4 @@ export function generateRandomToken(length: number = 10): string { } return result.join(''); -} \ No newline at end of file +} From 3a8a67e15677ad6f1cd2b08d6c162062c94c4694 Mon Sep 17 00:00:00 2001 From: Nuung Date: Tue, 3 Jun 2025 13:55:42 +0900 Subject: [PATCH 06/10] =?UTF-8?q?feature:=20router=20=EB=A7=A4=ED=95=91,?= =?UTF-8?q?=20=EC=A0=84=EC=B2=B4=20=EB=81=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/routes/index.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/routes/index.ts b/src/routes/index.ts index bcd21bc..d06357d 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -3,6 +3,7 @@ import UserRouter from './user.router'; import PostRouter from './post.router'; import NotiRouter from './noti.router'; import LeaderboardRouter from './leaderboard.router'; +import TootalStatsRouter from './totalStats.router'; const router: Router = express.Router(); @@ -14,5 +15,6 @@ router.use('/', UserRouter); router.use('/', PostRouter); router.use('/', NotiRouter); router.use('/', LeaderboardRouter); +router.use('/', TootalStatsRouter); export default router; From 1e73b84479046e0f2684837de651da7d3844c624 Mon Sep 17 00:00:00 2001 From: Nuung Date: Tue, 3 Jun 2025 14:03:22 +0900 Subject: [PATCH 07/10] =?UTF-8?q?modify:=20=EC=98=A4=ED=83=88=EC=9E=90=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/routes/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/routes/index.ts b/src/routes/index.ts index d06357d..0406af2 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -3,7 +3,7 @@ import UserRouter from './user.router'; import PostRouter from './post.router'; import NotiRouter from './noti.router'; import LeaderboardRouter from './leaderboard.router'; -import TootalStatsRouter from './totalStats.router'; +import TotalStatsRouter from './totalStats.router'; const router: Router = express.Router(); @@ -15,6 +15,6 @@ router.use('/', UserRouter); router.use('/', PostRouter); router.use('/', NotiRouter); router.use('/', LeaderboardRouter); -router.use('/', TootalStatsRouter); +router.use('/', TotalStatsRouter); export default router; From eb05ca882d2a31545e2408af87922df7f3c8e0e0 Mon Sep 17 00:00:00 2001 From: Nuung Date: Tue, 3 Jun 2025 16:06:10 +0900 Subject: [PATCH 08/10] =?UTF-8?q?modify:=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20?= =?UTF-8?q?=ED=86=B5=EA=B3=84=EB=A5=BC=20=EC=A6=9D=EA=B0=80=EB=9F=89=20?= =?UTF-8?q?=EB=8C=80=EC=8B=A0=20=EA=B7=B8=EB=82=A0=20=EB=88=84=EC=A0=81=20?= =?UTF-8?q?=EA=B2=8C=EC=8B=9C=EA=B8=80=20=EC=88=98=20=ED=86=B5=EA=B3=84?= =?UTF-8?q?=EB=A1=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../__test__/totalStats.repo.test.ts | 4 +--- src/repositories/totalStats.repository.ts | 18 ++++++------------ 2 files changed, 7 insertions(+), 15 deletions(-) diff --git a/src/repositories/__test__/totalStats.repo.test.ts b/src/repositories/__test__/totalStats.repo.test.ts index 1fb27cb..ba9f5a1 100644 --- a/src/repositories/__test__/totalStats.repo.test.ts +++ b/src/repositories/__test__/totalStats.repo.test.ts @@ -124,7 +124,7 @@ describe('TotalStatsRepository', () => { [userId, mockStartDate] ); expect(mockPool.query).toHaveBeenCalledWith( - expect.stringContaining('SUM(dp.post_count) OVER'), + expect.stringContaining('SELECT COUNT(id)'), [userId, mockStartDate] ); }); @@ -217,8 +217,6 @@ describe('TotalStatsRepository', () => { const calledQuery = mockPool.query.mock.calls[0][0] as string; expect(calledQuery).toContain('WITH date_series AS'); expect(calledQuery).toContain('generate_series'); - expect(calledQuery).toContain('SUM(dp.post_count) OVER'); - expect(calledQuery).toContain('DATE(p.released_at)'); }); }); }); diff --git a/src/repositories/totalStats.repository.ts b/src/repositories/totalStats.repository.ts index 60afd7b..9d070ed 100644 --- a/src/repositories/totalStats.repository.ts +++ b/src/repositories/totalStats.repository.ts @@ -73,22 +73,16 @@ export class TotalStatsRepository { CURRENT_DATE, '1 day'::interval )::date AS date - ), - daily_posts AS ( - SELECT - DATE(p.released_at) AS date, - COUNT(*) AS post_count - FROM posts_post p - WHERE p.user_id = $1 - AND p.is_active = true - AND DATE(p.released_at) >= DATE($2) - GROUP BY DATE(p.released_at) ) SELECT ds.date, - COALESCE(SUM(dp.post_count) OVER (ORDER BY ds.date), 0) AS total_value + (SELECT COUNT(id) + FROM posts_post p + WHERE p.user_id = $1 + AND p.is_active = true + AND DATE(p.released_at) <= ds.date + ) AS total_value FROM date_series ds - LEFT JOIN daily_posts dp ON ds.date = dp.date ORDER BY ds.date ASC; `; From 30481cf5e1b83ff63b2e0ad921c484ba71d6482f Mon Sep 17 00:00:00 2001 From: Nuung Date: Tue, 3 Jun 2025 18:11:44 +0900 Subject: [PATCH 09/10] =?UTF-8?q?modify:=20totla=20stats=20=EC=9D=98=20?= =?UTF-8?q?=EC=84=9C=EB=B9=84=EC=8A=A4=20arguments=20=EC=97=90=20=EB=AA=A8?= =?UTF-8?q?=EB=91=90=20default=20=EA=B0=92=20=EB=84=A3=EB=8A=94=20?= =?UTF-8?q?=EB=B0=A9=ED=96=A5=EC=9C=BC=EB=A1=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/controllers/totalStats.controller.ts | 4 ---- src/services/totalStats.service.ts | 4 ++-- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/src/controllers/totalStats.controller.ts b/src/controllers/totalStats.controller.ts index a332bbf..84d6045 100644 --- a/src/controllers/totalStats.controller.ts +++ b/src/controllers/totalStats.controller.ts @@ -1,6 +1,5 @@ import { NextFunction, Request, RequestHandler, Response } from 'express'; import logger from '@/configs/logger.config'; -import { BadRequestError } from '@/exception'; import { GetTotalStatsQuery, TotalStatsResponseDto } from '@/types'; import { TotalStatsService } from '@/services/totalStats.service'; @@ -16,9 +15,6 @@ export class TotalStatsController { const { id } = req.user; const { period, type } = req.query; - // 미들웨어에서 GetTotalStatsQueryDto 에 의해 걸리는데 런타임과 IDE 에서 구분을 못함, 이를 위해 추가 - if (!type) throw new BadRequestError('type 파라미터가 필요합니다.'); - const stats = await this.totalStatsService.getTotalStats(id, period, type); const message = this.totalStatsService.getSuccessMessage(type); diff --git a/src/services/totalStats.service.ts b/src/services/totalStats.service.ts index f27791d..6ca788b 100644 --- a/src/services/totalStats.service.ts +++ b/src/services/totalStats.service.ts @@ -5,7 +5,7 @@ import { TotalStatsRepository } from '@/repositories/totalStats.repository'; export class TotalStatsService { constructor(private totalStatsRepo: TotalStatsRepository) {} - async getTotalStats(userId: number, period: TotalStatsPeriod = 7, type: TotalStatsType): Promise { + async getTotalStats(userId: number, period: TotalStatsPeriod = 7, type: TotalStatsType = 'view'): Promise { try { const rawStats = await this.totalStatsRepo.getTotalStats(userId, period, type); @@ -19,7 +19,7 @@ export class TotalStatsService { } } - getSuccessMessage(type: TotalStatsType): string { + getSuccessMessage(type: TotalStatsType = 'view'): string { const messages = { view: '전체 조회수 변동 조회에 성공하였습니다.', like: '전체 좋아요 변동 조회에 성공하였습니다.', From 19d9e7ced4259b62083081afba7fa79bdcf85766 Mon Sep 17 00:00:00 2001 From: Nuung Date: Tue, 3 Jun 2025 18:11:58 +0900 Subject: [PATCH 10/10] =?UTF-8?q?modify:=20=ED=8F=AC=EB=A9=94=ED=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/services/totalStats.service.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/services/totalStats.service.ts b/src/services/totalStats.service.ts index 6ca788b..2b084e0 100644 --- a/src/services/totalStats.service.ts +++ b/src/services/totalStats.service.ts @@ -5,7 +5,11 @@ import { TotalStatsRepository } from '@/repositories/totalStats.repository'; export class TotalStatsService { constructor(private totalStatsRepo: TotalStatsRepository) {} - async getTotalStats(userId: number, period: TotalStatsPeriod = 7, type: TotalStatsType = 'view'): Promise { + async getTotalStats( + userId: number, + period: TotalStatsPeriod = 7, + type: TotalStatsType = 'view', + ): Promise { try { const rawStats = await this.totalStatsRepo.getTotalStats(userId, period, type);