diff --git a/src/controllers/leaderboard.controller.ts b/src/controllers/leaderboard.controller.ts new file mode 100644 index 0000000..3ca73df --- /dev/null +++ b/src/controllers/leaderboard.controller.ts @@ -0,0 +1,49 @@ +import logger from '@/configs/logger.config'; +import { NextFunction, RequestHandler, Request, Response } from 'express'; +import { LeaderboardService } from '@/services/leaderboard.service'; +import { + GetUserLeaderboardQuery, + GetPostLeaderboardQuery, + UserLeaderboardResponseDto, + PostLeaderboardResponseDto, +} from '@/types/index'; + +export class LeaderboardController { + constructor(private leaderboardService: LeaderboardService) {} + + getUserLeaderboard: RequestHandler = async ( + req: Request, + res: Response, + next: NextFunction, + ) => { + try { + const { sort, dateRange, limit } = req.query; + + const users = await this.leaderboardService.getUserLeaderboard(sort, dateRange, limit); + const response = new UserLeaderboardResponseDto(true, '사용자 리더보드 조회에 성공하였습니다.', users, null); + + res.status(200).json(response); + } catch (error) { + logger.error('사용자 리더보드 조회 실패:', error); + next(error); + } + }; + + getPostLeaderboard: RequestHandler = async ( + req: Request, + res: Response, + next: NextFunction, + ) => { + try { + const { sort, dateRange, limit } = req.query; + + const posts = await this.leaderboardService.getPostLeaderboard(sort, dateRange, limit); + const response = new PostLeaderboardResponseDto(true, '게시물 리더보드 조회에 성공하였습니다.', posts, null); + + res.status(200).json(response); + } catch (error) { + logger.error('게시물 리더보드 조회 실패:', error); + next(error); + } + }; +} diff --git a/src/repositories/__test__/leaderboard.repo.test.ts b/src/repositories/__test__/leaderboard.repo.test.ts new file mode 100644 index 0000000..7955f51 --- /dev/null +++ b/src/repositories/__test__/leaderboard.repo.test.ts @@ -0,0 +1,196 @@ +import { Pool, QueryResult } from 'pg'; +import { DBError } from '@/exception'; +import { LeaderboardRepository } from '@/repositories/leaderboard.repository'; + +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; + + beforeEach(() => { + repo = new LeaderboardRepository(mockPool as unknown as Pool); + }); + + describe('getUserLeaderboard', () => { + it('사용자 통계 배열로 이루어진 리더보드를 반환해야 한다', async () => { + const mockResult = [ + { + id: '1', + email: 'test@test.com', + total_views: 100, + total_likes: 50, + total_posts: 1, + view_diff: 20, + like_diff: 10, + post_diff: 1, + }, + { + id: '2', + email: 'test2@test.com', + total_views: 200, + total_likes: 100, + total_posts: 2, + view_diff: 10, + like_diff: 5, + post_diff: 1, + }, + ]; + mockPool.query.mockResolvedValue(createMockQueryResult(mockResult)); + + const result = await repo.getUserLeaderboard('viewCount', 30, 10); + + expect(mockPool.query).toHaveBeenCalledWith(expect.stringContaining('FROM users_user u'), expect.anything()); + expect(result).toEqual(mockResult); + }); + + it('sort가 viewCount인 경우 view_diff 필드를 기준으로 내림차순 정렬해야 한다', async () => { + await repo.getUserLeaderboard('viewCount', 30, 10); + + expect(mockPool.query).toHaveBeenCalledWith( + expect.stringContaining('ORDER BY view_diff DESC'), + expect.anything(), + ); + }); + + it('sort가 likeCount인 경우 like_diff 필드를 기준으로 내림차순 정렬해야 한다', async () => { + await repo.getUserLeaderboard('likeCount', 30, 10); + + expect(mockPool.query).toHaveBeenCalledWith( + expect.stringContaining('ORDER BY like_diff DESC'), + expect.anything(), + ); + }); + + it('sort가 postCount인 경우 post_diff 필드를 기준으로 내림차순 정렬해야 한다', async () => { + await repo.getUserLeaderboard('postCount', 30, 10); + + expect(mockPool.query).toHaveBeenCalledWith( + expect.stringContaining('ORDER BY post_diff DESC'), + expect.anything(), + ); + }); + + it('limit 파라미터가 쿼리에 올바르게 적용되어야 한다', async () => { + const mockLimit = 5; + + await repo.getUserLeaderboard('viewCount', 30, mockLimit); + + expect(mockPool.query).toHaveBeenCalledWith( + expect.stringContaining('LIMIT $2'), + expect.arrayContaining([30, mockLimit]), + ); + }); + + it('dateRange 파라미터가 쿼리에 올바르게 적용되어야 한다', async () => { + const mockDateRange = 30; + + await repo.getUserLeaderboard('viewCount', mockDateRange, 10); + + expect(mockPool.query).toHaveBeenCalledWith( + expect.stringContaining('make_interval(days := $1::int)'), + expect.arrayContaining([mockDateRange, expect.anything()]), + ); + }); + + it('에러 발생 시 DBError를 던져야 한다', async () => { + mockPool.query.mockRejectedValue(new Error('DB connection failed')); + await expect(repo.getUserLeaderboard('viewCount', 30, 10)).rejects.toThrow(DBError); + }); + }); + + describe('getPostLeaderboard', () => { + it('게시물 통계 배열로 이루어진 리더보드를 반환해야 한다', async () => { + const mockResult = [ + { + id: '2', + title: 'test2', + slug: 'test2', + total_views: 200, + total_likes: 100, + view_diff: 20, + like_diff: 10, + released_at: '2025-01-02', + }, + { + id: '1', + title: 'test', + slug: 'test', + total_views: 100, + total_likes: 50, + view_diff: 10, + like_diff: 5, + released_at: '2025-01-01', + }, + ]; + + mockPool.query.mockResolvedValue(createMockQueryResult(mockResult)); + + const result = await repo.getPostLeaderboard('viewCount', 30, 10); + + expect(result).toEqual(mockResult); + expect(mockPool.query).toHaveBeenCalledWith(expect.stringContaining('FROM posts_post p'), expect.anything()); + }); + + it('sort가 viewCount인 경우 view_diff 필드를 기준으로 내림차순 정렬해야 한다', async () => { + await repo.getPostLeaderboard('viewCount', 30, 10); + + expect(mockPool.query).toHaveBeenCalledWith( + expect.stringContaining('ORDER BY view_diff DESC'), + expect.anything(), + ); + }); + + it('sort가 likeCount인 경우 like_diff 필드를 기준으로 내림차순 정렬해야 한다', async () => { + await repo.getPostLeaderboard('likeCount', 30, 10); + + expect(mockPool.query).toHaveBeenCalledWith( + expect.stringContaining('ORDER BY like_diff DESC'), + expect.anything(), + ); + }); + + it('limit 파라미터가 쿼리에 올바르게 적용되어야 한다', async () => { + const mockLimit = 5; + + await repo.getPostLeaderboard('viewCount', 30, mockLimit); + + expect(mockPool.query).toHaveBeenCalledWith( + expect.stringContaining('LIMIT $2'), + expect.arrayContaining([30, mockLimit]), + ); + }); + + it('dateRange 파라미터가 쿼리에 올바르게 적용되어야 한다', async () => { + const mockDateRange = 30; + + await repo.getPostLeaderboard('viewCount', mockDateRange, 10); + + expect(mockPool.query).toHaveBeenCalledWith( + expect.stringContaining('make_interval(days := $1::int)'), + expect.arrayContaining([mockDateRange, expect.anything()]), + ); + }); + + it('에러 발생 시 DBError를 던져야 한다', async () => { + mockPool.query.mockRejectedValue(new Error('DB connection failed')); + await expect(repo.getPostLeaderboard('viewCount', 30, 10)).rejects.toThrow(DBError); + }); + }); +}); diff --git a/src/repositories/leaderboard.repository.ts b/src/repositories/leaderboard.repository.ts new file mode 100644 index 0000000..e051fd5 --- /dev/null +++ b/src/repositories/leaderboard.repository.ts @@ -0,0 +1,103 @@ +import logger from '@/configs/logger.config'; +import { Pool } from 'pg'; +import { DBError } from '@/exception'; +import { UserLeaderboardSortType, PostLeaderboardSortType } from '@/types/index'; + +export class LeaderboardRepository { + constructor(private pool: Pool) {} + + async getUserLeaderboard(sort: UserLeaderboardSortType, dateRange: number, limit: number) { + try { + const cteQuery = this.buildLeaderboardCteQuery(); + + const query = ` + ${cteQuery} + SELECT + u.id AS id, + u.email AS email, + COALESCE(SUM(ts.today_view), 0) AS total_views, + COALESCE(SUM(ts.today_like), 0) AS total_likes, + COUNT(DISTINCT CASE WHEN p.is_active = true THEN p.id END) AS total_posts, + SUM(COALESCE(ts.today_view, 0) - COALESCE(ss.start_view, COALESCE(ts.today_view, 0))) AS view_diff, + SUM(COALESCE(ts.today_like, 0) - COALESCE(ss.start_like, COALESCE(ts.today_like, 0))) AS like_diff, + COUNT(DISTINCT CASE WHEN p.released_at >= CURRENT_DATE - make_interval(days := $1::int) AND p.is_active = true THEN p.id END) AS post_diff + FROM users_user u + LEFT JOIN posts_post p ON p.user_id = u.id + LEFT JOIN today_stats ts ON ts.post_id = p.id + LEFT JOIN start_stats ss ON ss.post_id = p.id + WHERE u.email IS NOT NULL + GROUP BY u.id, u.email + ORDER BY ${this.SORT_COL_MAPPING[sort]} DESC + LIMIT $2; + `; + const result = await this.pool.query(query, [dateRange, limit]); + + return result.rows; + } catch (error) { + logger.error(`Leaderboard Repo getUserLeaderboard error:`, error); + throw new DBError(`사용자 리더보드 조회 중 문제가 발생했습니다.`); + } + } + + async getPostLeaderboard(sort: PostLeaderboardSortType, dateRange: number, limit: number) { + try { + const cteQuery = this.buildLeaderboardCteQuery(); + + const query = ` + ${cteQuery} + SELECT + p.id AS id, + p.title, + p.slug, + p.released_at, + COALESCE(ts.today_view, 0) AS total_views, + COALESCE(ts.today_like, 0) AS total_likes, + COALESCE(ts.today_view, 0) - COALESCE(ss.start_view, COALESCE(ts.today_view, 0)) AS view_diff, + COALESCE(ts.today_like, 0) - COALESCE(ss.start_like, COALESCE(ts.today_like, 0)) AS like_diff + FROM posts_post p + LEFT JOIN today_stats ts ON ts.post_id = p.id + LEFT JOIN start_stats ss ON ss.post_id = p.id + WHERE p.is_active = true + ORDER BY ${this.SORT_COL_MAPPING[sort]} DESC + LIMIT $2; + `; + const result = await this.pool.query(query, [dateRange, limit]); + + return result.rows; + } catch (error) { + logger.error(`Leaderboard Repo getPostLeaderboard error:`, error); + throw new DBError(`게시물 리더보드 조회 중 문제가 발생했습니다.`); + } + } + + // 오늘 날짜와 기준 날짜의 통계를 가져오는 CTE(임시 결과 집합) 쿼리 빌드 + private buildLeaderboardCteQuery() { + return ` + WITH + today_stats AS ( + SELECT DISTINCT ON (post_id) + post_id, + daily_view_count AS today_view, + daily_like_count AS today_like + FROM posts_postdailystatistics + WHERE (date AT TIME ZONE 'Asia/Seoul' AT TIME ZONE 'UTC')::date <= (NOW() AT TIME ZONE 'UTC')::date + ORDER BY post_id, date DESC + ), + start_stats AS ( + SELECT DISTINCT ON (post_id) + post_id, + daily_view_count AS start_view, + daily_like_count AS start_like + FROM posts_postdailystatistics + WHERE (date AT TIME ZONE 'Asia/Seoul' AT TIME ZONE 'UTC')::date >= ((NOW() AT TIME ZONE 'UTC')::date - make_interval(days := $1::int)) + ORDER BY post_id, date ASC + ) + `; + } + + private readonly SORT_COL_MAPPING = { + viewCount: 'view_diff', + likeCount: 'like_diff', + postCount: 'post_diff', + } as const; +} diff --git a/src/routes/index.ts b/src/routes/index.ts index 351fecc..a2c1a7a 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -2,6 +2,7 @@ import express, { Router } from 'express'; import UserRouter from './user.router'; import PostRouter from './post.router'; import NotiRouter from './noti.router'; +import LeaderboardRouter from './leaderboard.router'; const router: Router = express.Router(); @@ -12,4 +13,5 @@ router.use('/ping', (req, res) => { router.use('/', UserRouter); router.use('/', PostRouter); router.use('/', NotiRouter); +router.use('/', LeaderboardRouter); export default router; diff --git a/src/routes/leaderboard.router.ts b/src/routes/leaderboard.router.ts new file mode 100644 index 0000000..64a6cb4 --- /dev/null +++ b/src/routes/leaderboard.router.ts @@ -0,0 +1,87 @@ +import pool from '@/configs/db.config'; +import express, { Router } from 'express'; +import { LeaderboardRepository } from '@/repositories/leaderboard.repository'; +import { LeaderboardService } from '@/services/leaderboard.service'; +import { LeaderboardController } from '@/controllers/leaderboard.controller'; +import { validateRequestDto } from '@/middlewares/validation.middleware'; +import { GetUserLeaderboardQueryDto, GetPostLeaderboardQueryDto } from '@/types/dto/requests/getLeaderboardQuery.type'; + +const router: Router = express.Router(); + +const leaderboardRepository = new LeaderboardRepository(pool); +const leaderboardService = new LeaderboardService(leaderboardRepository); +const leaderboardController = new LeaderboardController(leaderboardService); + +/** + * @swagger + * /leaderboard/user: + * get: + * summary: 사용자 리더보드 조회 + * tags: + * - Leaderboard + * parameters: + * - in: query + * name: sort + * schema: + * $ref: '#/components/schemas/UserLeaderboardSortType' + * - in: query + * name: dateRange + * schema: + * $ref: '#/components/schemas/GetLeaderboardQueryDto/properties/dateRange' + * - in: query + * name: limit + * schema: + * $ref: '#/components/schemas/GetLeaderboardQueryDto/properties/limit' + * responses: + * '200': + * description: 사용자 리더보드 조회 성공 + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/UserLeaderboardResponseDto' + * '500': + * description: 서버 오류 / 데이터 베이스 조회 오류 + */ +router.get( + '/leaderboard/user', + validateRequestDto(GetUserLeaderboardQueryDto, 'query'), + leaderboardController.getUserLeaderboard, +); + +/** + * @swagger + * /leaderboard/post: + * get: + * summary: 게시물 리더보드 조회 + * tags: + * - Leaderboard + * parameters: + * - in: query + * name: sort + * schema: + * $ref: '#/components/schemas/PostLeaderboardSortType' + * - in: query + * name: dateRange + * schema: + * $ref: '#/components/schemas/GetLeaderboardQueryDto/properties/dateRange' + * - in: query + * name: limit + * schema: + * $ref: '#/components/schemas/GetLeaderboardQueryDto/properties/limit' + * responses: + * '200': + * description: 게시물 리더보드 조회 성공 + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/PostLeaderboardResponseDto' + * '500': + * description: 서버 오류 / 데이터 베이스 조회 오류 + */ +router.get( + '/leaderboard/post', + validateRequestDto(GetPostLeaderboardQueryDto, 'query'), + leaderboardController.getPostLeaderboard, +); + +export default router; diff --git a/src/services/__test__/leaderboard.service.test.ts b/src/services/__test__/leaderboard.service.test.ts new file mode 100644 index 0000000..b99338e --- /dev/null +++ b/src/services/__test__/leaderboard.service.test.ts @@ -0,0 +1,205 @@ +import { Pool } from 'pg'; +import { DBError } from '@/exception'; +import { LeaderboardRepository } from '@/repositories/leaderboard.repository'; +import { LeaderboardService } from '@/services/leaderboard.service'; + +jest.mock('@/repositories/leaderboard.repository'); + +describe('LeaderboardService', () => { + let service: LeaderboardService; + let repo: jest.Mocked; + let mockPool: jest.Mocked; + + beforeEach(() => { + const mockPoolObj = {}; + mockPool = mockPoolObj as jest.Mocked; + + const repoInstance = new LeaderboardRepository(mockPool); + repo = repoInstance as jest.Mocked; + + service = new LeaderboardService(repo); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('getUserLeaderboard', () => { + it('응답 형식에 맞게 변환된 사용자 리더보드 데이터를 반환해야 한다', async () => { + const mockRawResult = [ + { + id: '1', + email: 'test@test.com', + total_views: 100, + total_likes: 50, + total_posts: 1, + view_diff: 20, + like_diff: 10, + post_diff: 1, + }, + { + id: '2', + email: 'test2@test.com', + total_views: 200, + total_likes: 100, + total_posts: 2, + view_diff: 10, + like_diff: 5, + post_diff: 1, + }, + ]; + + const mockResult = { + users: [ + { + id: '1', + email: 'test@test.com', + totalViews: 100, + totalLikes: 50, + totalPosts: 1, + viewDiff: 20, + likeDiff: 10, + postDiff: 1, + }, + { + id: '2', + email: 'test2@test.com', + totalViews: 200, + totalLikes: 100, + totalPosts: 2, + viewDiff: 10, + likeDiff: 5, + postDiff: 1, + }, + ], + }; + + repo.getUserLeaderboard.mockResolvedValue(mockRawResult); + const result = await service.getUserLeaderboard('viewCount', 30, 10); + + expect(result.users).toEqual(mockResult.users); + }); + + it('쿼리 파라미터가 올바르게 적용되어야 한다', async () => { + repo.getUserLeaderboard.mockResolvedValue([]); + + await service.getUserLeaderboard('postCount', 30, 10); + + expect(repo.getUserLeaderboard).toHaveBeenCalledWith('postCount', 30, 10); + }); + + it('쿼리 파라미터가 입력되지 않은 경우 기본값으로 처리되어야 한다', async () => { + repo.getUserLeaderboard.mockResolvedValue([]); + + await service.getUserLeaderboard(); + + expect(repo.getUserLeaderboard).toHaveBeenCalledWith('viewCount', 30, 10); + }); + + it('데이터가 없는 경우 빈 배열을 반환해야 한다', async () => { + repo.getUserLeaderboard.mockResolvedValue([]); + + const result = await service.getUserLeaderboard(); + + expect(result).toEqual({ users: [] }); + }); + + it('쿼리 오류 발생 시 예외를 그대로 전파한다', async () => { + const errorMessage = '사용자 리더보드 조회 중 문제가 발생했습니다.'; + const dbError = new DBError(errorMessage); + repo.getUserLeaderboard.mockRejectedValue(dbError); + + await expect(service.getUserLeaderboard()).rejects.toThrow(errorMessage); + expect(repo.getUserLeaderboard).toHaveBeenCalledTimes(1); + }); + }); + + describe('getPostLeaderboard', () => { + it('응답 형식에 맞게 변환된 게시물 리더보드 데이터를 반환해야 한다', async () => { + const mockRawResult = [ + { + id: '1', + title: 'test', + slug: 'test-slug', + total_views: 100, + total_likes: 50, + view_diff: 20, + like_diff: 10, + released_at: '2025-01-01', + }, + { + id: '2', + title: 'test2', + slug: 'test2-slug', + total_views: 200, + total_likes: 100, + view_diff: 10, + like_diff: 5, + released_at: '2025-01-02', + }, + ]; + + const mockResult = { + posts: [ + { + id: '1', + title: 'test', + slug: 'test-slug', + totalViews: 100, + totalLikes: 50, + viewDiff: 20, + likeDiff: 10, + releasedAt: '2025-01-01', + }, + { + id: '2', + title: 'test2', + slug: 'test2-slug', + totalViews: 200, + totalLikes: 100, + viewDiff: 10, + likeDiff: 5, + releasedAt: '2025-01-02', + }, + ], + }; + + repo.getPostLeaderboard.mockResolvedValue(mockRawResult); + const result = await service.getPostLeaderboard('viewCount', 30, 10); + + expect(result.posts).toEqual(mockResult.posts); + }); + + it('쿼리 파라미터가 올바르게 적용되어야 한다', async () => { + repo.getPostLeaderboard.mockResolvedValue([]); + + await service.getPostLeaderboard('likeCount', 30, 10); + + expect(repo.getPostLeaderboard).toHaveBeenCalledWith('likeCount', 30, 10); + }); + + it('쿼리 파라미터가 입력되지 않은 경우 기본값으로 처리되어야 한다', async () => { + repo.getPostLeaderboard.mockResolvedValue([]); + + await service.getPostLeaderboard(); + + expect(repo.getPostLeaderboard).toHaveBeenCalledWith('viewCount', 30, 10); + }); + + it('데이터가 없는 경우 빈 배열을 반환해야 한다', async () => { + repo.getPostLeaderboard.mockResolvedValue([]); + const result = await service.getPostLeaderboard(); + + expect(result).toEqual({ posts: [] }); + }); + + it('쿼리 오류 발생 시 예외를 그대로 전파한다', async () => { + const errorMessage = '게시물 리더보드 조회 중 문제가 발생했습니다.'; + const dbError = new DBError(errorMessage); + repo.getPostLeaderboard.mockRejectedValue(dbError); + + await expect(service.getPostLeaderboard()).rejects.toThrow(errorMessage); + expect(repo.getPostLeaderboard).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/src/services/leaderboard.service.ts b/src/services/leaderboard.service.ts new file mode 100644 index 0000000..f0b1e50 --- /dev/null +++ b/src/services/leaderboard.service.ts @@ -0,0 +1,92 @@ +import logger from '@/configs/logger.config'; +import { LeaderboardRepository } from '@/repositories/leaderboard.repository'; +import { + UserLeaderboardSortType, + PostLeaderboardSortType, + UserLeaderboardData, + PostLeaderboardData, +} from '@/types/index'; + +export class LeaderboardService { + constructor(private leaderboardRepo: LeaderboardRepository) {} + + async getUserLeaderboard( + sort: UserLeaderboardSortType = 'viewCount', + dateRange: number = 30, + limit: number = 10, + ): Promise { + try { + const rawResult = await this.leaderboardRepo.getUserLeaderboard(sort, dateRange, limit); + return this.mapRawUserResult(rawResult); + } catch (error) { + logger.error('LeaderboardService getUserLeaderboard error : ', error); + throw error; + } + } + + async getPostLeaderboard( + sort: PostLeaderboardSortType = 'viewCount', + dateRange: number = 30, + limit: number = 10, + ): Promise { + try { + const rawResult = await this.leaderboardRepo.getPostLeaderboard(sort, dateRange, limit); + return this.mapRawPostResult(rawResult); + } catch (error) { + logger.error('LeaderboardService getPostLeaderboard error : ', error); + throw error; + } + } + + private mapRawUserResult(rawResult: RawUserResult[]): UserLeaderboardData { + const users = rawResult.map((user) => ({ + id: user.id, + email: user.email, + totalViews: user.total_views, + totalLikes: user.total_likes, + totalPosts: user.total_posts, + viewDiff: user.view_diff, + likeDiff: user.like_diff, + postDiff: user.post_diff, + })); + + return { users }; + } + + private mapRawPostResult(rawResult: RawPostResult[]): PostLeaderboardData { + const posts = rawResult.map((post) => ({ + id: post.id, + title: post.title, + slug: post.slug, + totalViews: post.total_views, + totalLikes: post.total_likes, + viewDiff: post.view_diff, + likeDiff: post.like_diff, + releasedAt: post.released_at, + })); + + return { posts }; + } +} + +interface RawPostResult { + id: string; + title: string; + slug: string; + total_views: number; + total_likes: number; + view_diff: number; + like_diff: number; + released_at: string; +} + +interface RawUserResult { + id: string; + email: string; + total_views: number; + total_likes: number; + total_posts: number; + view_diff: number; + like_diff: number; + post_diff: number; +} diff --git a/src/types/dto/requests/getLeaderboardQuery.type.ts b/src/types/dto/requests/getLeaderboardQuery.type.ts new file mode 100644 index 0000000..1e5f8b9 --- /dev/null +++ b/src/types/dto/requests/getLeaderboardQuery.type.ts @@ -0,0 +1,136 @@ +import { Transform } from 'class-transformer'; +import { IsEnum, IsNumber, IsOptional, Max, Min } from 'class-validator'; + +/** + * @swagger + * components: + * schemas: + * UserLeaderboardSortType: + * type: string + * description: 사용자 리더보드 정렬 기준 + * nullable: true + * enum: ['viewCount', 'likeCount', 'postCount'] + * default: 'viewCount' + */ +export type UserLeaderboardSortType = 'viewCount' | 'likeCount' | 'postCount'; + +/** + * @swagger + * components: + * schemas: + * PostLeaderboardSortType: + * type: string + * description: 게시물 리더보드 정렬 기준 + * nullable: true + * enum: ['viewCount', 'likeCount'] + * default: 'viewCount' + */ +export type PostLeaderboardSortType = 'viewCount' | 'likeCount'; + +interface GetLeaderboardQuery { + dateRange?: number; + limit?: number; +} + +export interface GetUserLeaderboardQuery extends GetLeaderboardQuery { + sort?: UserLeaderboardSortType; +} + +export interface GetPostLeaderboardQuery extends GetLeaderboardQuery { + sort?: PostLeaderboardSortType; +} + +/** + * @swagger + * components: + * schemas: + * GetLeaderboardQueryDto: + * type: object + * properties: + * dateRange: + * type: number + * description: 리더보드 조회 기간 (일수) + * nullable: true + * default: 30 + * minimum: 1 + * maximum: 30 + * limit: + * type: number + * description: 리더보드 조회 제한 수 + * nullable: true + * default: 10 + * minimum: 1 + * maximum: 30 + */ +class GetLeaderboardQueryDto { + @IsOptional() + @IsNumber() + @Transform(({ value }) => (value === '' ? 30 : Number(value))) + @Min(1) + @Max(30) + dateRange?: number; + + @IsOptional() + @IsNumber() + @Transform(({ value }) => (value === '' ? 10 : Number(value))) + @Min(1) + @Max(30) + limit?: number; + + constructor(dateRange?: number, limit?: number) { + this.dateRange = dateRange; + this.limit = limit; + } +} + +/** + * @swagger + * components: + * schemas: + * GetUserLeaderboardQueryDto: + * type: object + * properties: + * sort: + * type: string + * description: 사용자 리더보드 정렬 기준 + * nullable: true + * enum: ['viewCount', 'likeCount', 'postCount'] + * default: 'viewCount' + */ +export class GetUserLeaderboardQueryDto extends GetLeaderboardQueryDto { + @IsOptional() + @IsEnum(['viewCount', 'likeCount', 'postCount']) + @Transform(({ value }) => (value === '' ? 'viewCount' : value)) + sort?: UserLeaderboardSortType; + + constructor(sort?: UserLeaderboardSortType) { + super(); + this.sort = sort; + } +} + +/** + * @swagger + * components: + * schemas: + * GetPostLeaderboardQueryDto: + * type: object + * properties: + * sort: + * type: string + * description: 게시물 리더보드 정렬 기준 + * nullable: true + * enum: ['viewCount', 'likeCount'] + * default: 'viewCount' + */ +export class GetPostLeaderboardQueryDto extends GetLeaderboardQueryDto { + @IsOptional() + @IsEnum(['viewCount', 'likeCount']) + @Transform(({ value }) => (value === '' ? 'viewCount' : value)) + sort?: PostLeaderboardSortType; + + constructor(sort?: PostLeaderboardSortType) { + super(); + this.sort = sort; + } +} diff --git a/src/types/dto/responses/leaderboardResponse.type.ts b/src/types/dto/responses/leaderboardResponse.type.ts new file mode 100644 index 0000000..0497be1 --- /dev/null +++ b/src/types/dto/responses/leaderboardResponse.type.ts @@ -0,0 +1,148 @@ +import { BaseResponseDto } from '@/types/dto/responses/baseResponse.type'; + +/** + * @swagger + * components: + * schemas: + * LeaderboardUser: + * type: object + * properties: + * id: + * type: string + * description: 사용자 PK + * email: + * type: string + * description: 사용자 이메일 + * totalViews: + * type: integer + * description: 누적 조회수 + * totalLikes: + * type: integer + * description: 누적 좋아요 수 + * totalPosts: + * type: integer + * description: 누적 게시물 수 + * viewDiff: + * type: integer + * description: 구간 조회수 상승값 + * likeDiff: + * type: integer + * description: 구간 좋아요 수 상승값 + * postDiff: + * type: integer + * description: 구간 게시물 수 상승값 + */ +interface LeaderboardUser { + id: string; + email: string; + totalViews: number; + totalLikes: number; + totalPosts: number; + viewDiff: number; + likeDiff: number; + postDiff: number; +} + +/** + * @swagger + * components: + * schemas: + * UserLeaderboardData: + * type: object + * properties: + * users: + * type: array + * items: + * $ref: '#/components/schemas/LeaderboardUser' + */ +export interface UserLeaderboardData { + users: LeaderboardUser[]; +} + +/** + * @swagger + * components: + * schemas: + * UserLeaderboardResponseDto: + * allOf: + * - $ref: '#/components/schemas/BaseResponseDto' + * - type: object + * properties: + * data: + * $ref: '#/components/schemas/UserLeaderboardData' + */ +export class UserLeaderboardResponseDto extends BaseResponseDto {} + +/** + * @swagger + * components: + * schemas: + * LeaderboardPost: + * type: object + * properties: + * id: + * type: string + * description: 게시물 PK + * title: + * type: string + * description: 게시물 제목 + * slug: + * type: string + * description: 게시물 url slug 값 + * totalViews: + * type: integer + * description: 누적 조회수 + * totalLikes: + * type: integer + * description: 누적 좋아요 수 + * viewDiff: + * type: integer + * description: 구간 조회수 상승값 + * likeDiff: + * type: integer + * description: 구간 좋아요 수 상승값 + * releasedAt: + * type: string + * format: date-time + * description: 게시물 업로드 일시 + */ +interface LeaderboardPost { + id: string; + title: string; + slug: string; + totalViews: number; + totalLikes: number; + viewDiff: number; + likeDiff: number; + releasedAt: string; +} + +/** + * @swagger + * components: + * schemas: + * PostLeaderboardData: + * type: object + * properties: + * posts: + * type: array + * items: + * $ref: '#/components/schemas/LeaderboardPost' + */ +export interface PostLeaderboardData { + posts: LeaderboardPost[]; +} + +/** + * @swagger + * components: + * schemas: + * PostLeaderboardResponseDto: + * allOf: + * - $ref: '#/components/schemas/BaseResponseDto' + * - type: object + * properties: + * data: + * $ref: '#/components/schemas/PostLeaderboardData' + */ +export class PostLeaderboardResponseDto extends BaseResponseDto {} diff --git a/src/types/index.ts b/src/types/index.ts index c2b2f49..d399334 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -8,6 +8,14 @@ export type { GetPostQuery, PostParam } from '@/types/dto/requests/getPostQuery. export { GetAllPostsQueryDto } from '@/types/dto/requests/getAllPostsQuery.type'; export { GetPostQueryDto } from '@/types/dto/requests/getPostQuery.type'; +export { + GetUserLeaderboardQueryDto, + GetPostLeaderboardQueryDto, + 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 { @@ -16,5 +24,11 @@ export { 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';