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/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 new file mode 100644 index 0000000..84d6045 --- /dev/null +++ b/src/controllers/totalStats.controller.ts @@ -0,0 +1,29 @@ +import { NextFunction, Request, RequestHandler, Response } from 'express'; +import logger from '@/configs/logger.config'; +import { GetTotalStatsQuery, TotalStatsResponseDto } from '@/types'; +import { TotalStatsService } from '@/services/totalStats.service'; + +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; + + 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); + } + }; +} 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__/fixtures.ts b/src/repositories/__test__/fixtures.ts new file mode 100644 index 0000000..ddc3dd6 --- /dev/null +++ b/src/repositories/__test__/fixtures.ts @@ -0,0 +1,57 @@ +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[]>; +} = { + query: jest.fn(), +}; + +/** +* 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, + 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 99% rename from src/repositories/__test__/leaderboard.repo.integration.test.ts rename to src/repositories/__test__/integration/leaderboard.repo.integration.test.ts index 50e9b7d..e9cfeb8 100644 --- a/src/repositories/__test__/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__/post.repo.integration.test.ts b/src/repositories/__test__/integration/post.repo.integration.test.ts similarity index 99% rename from src/repositories/__test__/post.repo.integration.test.ts rename to src/repositories/__test__/integration/post.repo.integration.test.ts index 3c643ce..6fcefe6 100644 --- a/src/repositories/__test__/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__/qr.repo.integration.test.ts b/src/repositories/__test__/integration/qr.repo.integration.test.ts similarity index 99% rename from src/repositories/__test__/qr.repo.integration.test.ts rename to src/repositories/__test__/integration/qr.repo.integration.test.ts index 733f460..227adbb 100644 --- a/src/repositories/__test__/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__/leaderboard.repo.test.ts b/src/repositories/__test__/leaderboard.repo.test.ts index 35fc4f9..cda0cbb 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 './fixtures'; 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..ba8df20 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 './fixtures'; 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__/qr.repo.test.ts b/src/repositories/__test__/qr.repo.test.ts index dedb5c9..a628955 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 './fixtures'; -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 new file mode 100644 index 0000000..ba9f5a1 --- /dev/null +++ b/src/repositories/__test__/totalStats.repo.test.ts @@ -0,0 +1,223 @@ +import { Pool } from 'pg'; +import { TotalStatsRepository } from '@/repositories/totalStats.repository'; +import { DBError } from '@/exception'; +import { getKSTDateStringWithOffset } from '@/utils/date.util'; +import { mockPool, createMockQueryResult } from './fixtures'; +import { TotalStatsType } from '@/types'; + +// Mock dependencies +jest.mock('@/configs/logger.config', () => ({ + error: jest.fn(), +})); + +jest.mock('@/utils/date.util', () => ({ + getKSTDateStringWithOffset: jest.fn(), +})); + +describe('TotalStatsRepository', () => { + let repository: TotalStatsRepository; + let mockGetKSTDateStringWithOffset: jest.MockedFunction; + + beforeEach(() => { + mockGetKSTDateStringWithOffset = getKSTDateStringWithOffset as jest.MockedFunction; + + repository = new TotalStatsRepository(mockPool as unknown as Pool); + 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('SELECT COUNT(id)'), + [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 unknown as TotalStatsType) + ).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'); + }); + }); + }); +}); \ No newline at end of file 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 new file mode 100644 index 0000000..9d070ed --- /dev/null +++ b/src/repositories/totalStats.repository.ts @@ -0,0 +1,109 @@ +import { Pool } from 'pg'; +import logger from '@/configs/logger.config'; +import { DBError } from '@/exception'; +import { TotalStatsType } from '@/types'; +import { getKSTDateStringWithOffset } from '@/utils/date.util'; + +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 + ) + SELECT + ds.date, + (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 + 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('지원되지 않는 통계 타입입니다.'); + } + } +} 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/index.ts b/src/routes/index.ts index bcd21bc..0406af2 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 TotalStatsRouter 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('/', TotalStatsRouter); export default router; 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 new file mode 100644 index 0000000..2c78650 --- /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; 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 new file mode 100644 index 0000000..2b084e0 --- /dev/null +++ b/src/services/totalStats.service.ts @@ -0,0 +1,34 @@ +import logger from '@/configs/logger.config'; +import { TotalStatsPeriod, TotalStatsType, TotalStatsItem } from '@/types'; +import { TotalStatsRepository } from '@/repositories/totalStats.repository'; + +export class TotalStatsService { + constructor(private totalStatsRepo: TotalStatsRepository) {} + + async getTotalStats( + userId: number, + period: TotalStatsPeriod = 7, + type: TotalStatsType = 'view', + ): 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 = 'view'): string { + const messages = { + view: '전체 조회수 변동 조회에 성공하였습니다.', + like: '전체 좋아요 변동 조회에 성공하였습니다.', + post: '전체 게시글 변동 조회에 성공하였습니다.', + }; + return messages[type]; + } +} 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/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..7a247d8 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,35 +1,42 @@ +// 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, +export { 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 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 { 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'; -export { - PostsResponseDto, - PostResponseDto, - PostStatisticsResponseDto, - RawPostType, -} from '@/types/dto/responses/postResponse.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'; 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 +}