Skip to content
5 changes: 3 additions & 2 deletions src/modules/slack/slack.notifier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ dotenv.config();
if (!process.env.SLACK_WEBHOOK_URL) {
throw new Error('SLACK_WEBHOOK_URL is not defined in environment variables.');
}

// eslint-disable-next-line @typescript-eslint/naming-convention
const SLACK_WEBHOOK_URL: string = process.env.SLACK_WEBHOOK_URL;

interface SlackPayload {
Expand All @@ -20,8 +22,7 @@ interface SlackPayload {
*/
export async function sendSlackMessage(message: string): Promise<void> {
const payload: SlackPayload = { text: message };
const response = await axios.post(SLACK_WEBHOOK_URL, payload, {
await axios.post(SLACK_WEBHOOK_URL, payload, {
headers: { 'Content-Type': 'application/json' },
});
console.log(response);
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
/**
* 주의: 이 통합 테스트는 현재 시간에 의존적입니다.
* getCurrentKSTDateString과 getKSTDateStringWithOffset 함수는 실제 시간을 기준으로
* 날짜 문자열을 생성하므로, 테스트 실행 시간에 따라 결과가 달라질 수 있습니다.
*/

import logger from '@/configs/logger.config';
import dotenv from 'dotenv';
import pg from 'pg';
Expand Down
16 changes: 8 additions & 8 deletions src/repositories/__test__/leaderboard.repo.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,8 @@ describe('LeaderboardRepository', () => {
await repo.getUserLeaderboard('viewCount', 30, mockLimit);

expect(mockPool.query).toHaveBeenCalledWith(
expect.stringContaining('LIMIT $2'),
expect.arrayContaining([30, mockLimit]),
expect.stringContaining('LIMIT $1'),
expect.arrayContaining([mockLimit]),
);
});

Expand All @@ -93,8 +93,8 @@ describe('LeaderboardRepository', () => {
await repo.getUserLeaderboard('viewCount', mockDateRange, 10);

expect(mockPool.query).toHaveBeenCalledWith(
expect.stringContaining('make_interval(days := $1::int)'),
expect.arrayContaining([mockDateRange, expect.anything()]),
expect.stringContaining('WHERE date >='), // pastDateKST를 사용하는 부분 확인
[expect.any(Number)] // limit
);
});

Expand Down Expand Up @@ -157,8 +157,8 @@ describe('LeaderboardRepository', () => {
await repo.getPostLeaderboard('viewCount', 30, mockLimit);

expect(mockPool.query).toHaveBeenCalledWith(
expect.stringContaining('LIMIT $2'),
expect.arrayContaining([30, mockLimit]),
expect.stringContaining('LIMIT $1'),
expect.arrayContaining([mockLimit]),
);
});

Expand All @@ -168,8 +168,8 @@ describe('LeaderboardRepository', () => {
await repo.getPostLeaderboard('viewCount', mockDateRange, 10);

expect(mockPool.query).toHaveBeenCalledWith(
expect.stringContaining('make_interval(days := $1::int)'),
expect.arrayContaining([mockDateRange, expect.anything()]),
expect.stringContaining('WHERE date >='), // pastDateKST를 사용하는 부분 확인
[expect.any(Number)] // limit
);
});

Expand Down
26 changes: 16 additions & 10 deletions src/repositories/leaderboard.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@ import logger from '@/configs/logger.config';
import { Pool } from 'pg';
import { DBError } from '@/exception';
import { UserLeaderboardSortType, PostLeaderboardSortType } from '@/types/index';
import { getCurrentKSTDateString, getKSTDateStringWithOffset } from '@/utils/date.util';

export class LeaderboardRepository {
constructor(private pool: Pool) {}

async getUserLeaderboard(sort: UserLeaderboardSortType, dateRange: number, limit: number) {
try {
const cteQuery = this.buildLeaderboardCteQuery();
const pastDateKST = getKSTDateStringWithOffset(-dateRange * 24 * 60);
const cteQuery = this.buildLeaderboardCteQuery(dateRange);

const query = `
${cteQuery}
Expand All @@ -20,17 +22,17 @@ export class LeaderboardRepository {
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
COUNT(DISTINCT CASE WHEN p.released_at >= '${pastDateKST}' 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, u.id
LIMIT $2;
LIMIT $1;
`;
const result = await this.pool.query(query, [dateRange, limit]);
const result = await this.pool.query(query, [limit]);

return result.rows;
} catch (error) {
Expand All @@ -41,7 +43,7 @@ export class LeaderboardRepository {

async getPostLeaderboard(sort: PostLeaderboardSortType, dateRange: number, limit: number) {
try {
const cteQuery = this.buildLeaderboardCteQuery();
const cteQuery = this.buildLeaderboardCteQuery(dateRange);

const query = `
${cteQuery}
Expand All @@ -59,9 +61,9 @@ export class LeaderboardRepository {
LEFT JOIN start_stats ss ON ss.post_id = p.id
WHERE p.is_active = true
ORDER BY ${this.SORT_COL_MAPPING[sort]} DESC, p.id
LIMIT $2;
LIMIT $1;
`;
const result = await this.pool.query(query, [dateRange, limit]);
const result = await this.pool.query(query, [limit]);

return result.rows;
} catch (error) {
Expand All @@ -71,7 +73,11 @@ export class LeaderboardRepository {
}

// 오늘 날짜와 기준 날짜의 통계를 가져오는 CTE(임시 결과 집합) 쿼리 빌드
private buildLeaderboardCteQuery() {
private buildLeaderboardCteQuery(dateRange: number) {
const nowDateKST = getCurrentKSTDateString();
// 과거 날짜 계산 (dateRange일 전)
const pastDateKST = getKSTDateStringWithOffset(-dateRange * 24 * 60);

return `
WITH
today_stats AS (
Expand All @@ -80,7 +86,7 @@ export class LeaderboardRepository {
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
WHERE date <= '${nowDateKST}'
ORDER BY post_id, date DESC
),
start_stats AS (
Expand All @@ -89,7 +95,7 @@ export class LeaderboardRepository {
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))
WHERE date >= '${pastDateKST}'
ORDER BY post_id, date ASC
)
`;
Expand Down
90 changes: 51 additions & 39 deletions src/repositories/post.repository.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Pool } from 'pg';
import logger from '@/configs/logger.config';
import { DBError } from '@/exception';
import { getCurrentKSTDateString, getKSTDateStringWithOffset } from '@/utils/date.util';

export class PostRepository {
constructor(private pool: Pool) { }
Expand All @@ -12,6 +13,10 @@ export class PostRepository {
isAsc: boolean = false,
limit: number = 15
) {
const nowDateKST = getCurrentKSTDateString();
const tomorrowDateKST = getKSTDateStringWithOffset(24 * 60);
const yesterDateKST = getKSTDateStringWithOffset(-24 * 60);

try {
// 1) 정렬 컬럼 매핑
let sortCol = 'p.released_at';
Expand Down Expand Up @@ -70,12 +75,12 @@ export class PostRepository {
LEFT JOIN (
SELECT post_id, daily_view_count, daily_like_count, date
FROM posts_postdailystatistics
WHERE (date AT TIME ZONE 'Asia/Seoul' AT TIME ZONE 'UTC')::date = (NOW() AT TIME ZONE 'UTC')::date
WHERE date >= '${nowDateKST}' AND date < '${tomorrowDateKST}'
) pds ON p.id = pds.post_id
LEFT JOIN (
SELECT post_id, daily_view_count, daily_like_count, date
FROM posts_postdailystatistics
WHERE (date AT TIME ZONE 'Asia/Seoul' AT TIME ZONE 'UTC')::date = (NOW() AT TIME ZONE 'UTC' - INTERVAL '1 day')::date
WHERE date >= '${yesterDateKST}' AND date < '${nowDateKST}'
) yesterday_stats ON p.id = yesterday_stats.post_id
WHERE p.user_id = $1
AND p.is_active = TRUE
Expand Down Expand Up @@ -128,6 +133,10 @@ export class PostRepository {
isAsc: boolean = false,
limit: number = 15
) {
const nowDateKST = getCurrentKSTDateString();
const tomorrowDateKST = getKSTDateStringWithOffset(24 * 60);
const yesterDateKST = getKSTDateStringWithOffset(-24 * 60);

try {
const selectFields = `
p.id,
Expand Down Expand Up @@ -170,25 +179,25 @@ export class PostRepository {
}

const query = `
SELECT ${selectFields}
FROM posts_post p
LEFT JOIN (
SELECT post_id, daily_view_count, daily_like_count, date
FROM posts_postdailystatistics
WHERE (date AT TIME ZONE 'Asia/Seoul' AT TIME ZONE 'UTC')::date = (NOW() AT TIME ZONE 'UTC')::date
) pds ON p.id = pds.post_id
LEFT JOIN (
SELECT post_id, daily_view_count, daily_like_count, date
FROM posts_postdailystatistics
WHERE (date AT TIME ZONE 'Asia/Seoul' AT TIME ZONE 'UTC')::date = (NOW() AT TIME ZONE 'UTC' - INTERVAL '1 day')::date
) yesterday_stats ON p.id = yesterday_stats.post_id
WHERE p.user_id = $1
AND p.is_active = TRUE
AND (pds.post_id IS NOT NULL OR yesterday_stats.post_id IS NOT NULL)
${cursorCondition}
ORDER BY ${orderByExpression}
LIMIT ${cursor ? '$4' : '$2'}
`;
SELECT ${selectFields}
FROM posts_post p
LEFT JOIN (
SELECT post_id, daily_view_count, daily_like_count, date
FROM posts_postdailystatistics
WHERE date >= '${nowDateKST}' AND date < '${tomorrowDateKST}'
) pds ON p.id = pds.post_id
LEFT JOIN (
SELECT post_id, daily_view_count, daily_like_count, date
FROM posts_postdailystatistics
WHERE date >= '${yesterDateKST}' AND date < '${nowDateKST}'
) yesterday_stats ON p.id = yesterday_stats.post_id
WHERE p.user_id = $1
AND p.is_active = TRUE
AND (pds.post_id IS NOT NULL OR yesterday_stats.post_id IS NOT NULL)
${cursorCondition}
ORDER BY ${orderByExpression}
LIMIT ${cursor ? '$4' : '$2'}
`;

const posts = await this.pool.query(query, params);

Expand Down Expand Up @@ -225,25 +234,28 @@ export class PostRepository {
}

async getYesterdayAndTodayViewLikeStats(userId: number) {
// ! pds.updated_at 은 FE 화면을 위해 억지로 9h 시간 더한 값임 주의
const nowDateKST = getCurrentKSTDateString();
const tomorrowDateKST = getKSTDateStringWithOffset(24 * 60);
const yesterDateKST = getKSTDateStringWithOffset(-24 * 60);

try {
const query = `
SELECT
COALESCE(SUM(pds.daily_view_count), 0) AS daily_view_count,
COALESCE(SUM(pds.daily_like_count), 0) AS daily_like_count,
COALESCE(SUM(yesterday_stats.daily_view_count), 0) AS yesterday_views,
COALESCE(SUM(yesterday_stats.daily_like_count), 0) AS yesterday_likes,
(MAX(pds.updated_at AT TIME ZONE 'Asia/Seoul') AT TIME ZONE 'UTC') AS last_updated_date
MAX(pds.updated_at) AS last_updated_date
FROM posts_post p
LEFT JOIN (
SELECT post_id, daily_view_count, daily_like_count, updated_at
FROM posts_postdailystatistics
WHERE (date AT TIME ZONE 'Asia/Seoul' AT TIME ZONE 'UTC')::date = (NOW() AT TIME ZONE 'UTC')::date
WHERE date >= '${nowDateKST}' AND date < '${tomorrowDateKST}'
) pds ON p.id = pds.post_id
LEFT JOIN (
SELECT post_id, daily_view_count, daily_like_count
FROM posts_postdailystatistics
WHERE (date AT TIME ZONE 'Asia/Seoul' AT TIME ZONE 'UTC')::date = (NOW() AT TIME ZONE 'UTC' - INTERVAL '1 day')::date
WHERE date >= '${yesterDateKST}' AND date < '${nowDateKST}'
) yesterday_stats ON p.id = yesterday_stats.post_id
WHERE p.user_id = $1
AND p.is_active = TRUE
Expand All @@ -263,31 +275,31 @@ export class PostRepository {
// 기본 쿼리 부분
const baseQuery = `
SELECT
(pds.date AT TIME ZONE 'Asia/Seoul') AT TIME ZONE 'UTC' AS date,
pds.date,
pds.daily_view_count,
pds.daily_like_count
FROM posts_postdailystatistics pds
WHERE pds.post_id = $1
`;

// 날짜 필터링 조건 구성
const dateFilterQuery = (start && end)
? `
AND (pds.date AT TIME ZONE 'Asia/Seoul' AT TIME ZONE 'UTC')::date >= ($2 AT TIME ZONE 'Asia/Seoul' AT TIME ZONE 'UTC')::date
AND (pds.date AT TIME ZONE 'Asia/Seoul' AT TIME ZONE 'UTC')::date <= ($3 AT TIME ZONE 'Asia/Seoul' AT TIME ZONE 'UTC')::date
`
: '';
let dateFilterQuery = '';
const queryParams: Array<number | string> = [postId];

if (start && end) {
dateFilterQuery = `
AND pds.date >= $2
AND pds.date <= $3
`;
queryParams.push(start, end);
}

// 정렬 조건 추가
const orderByQuery = `ORDER BY pds.date ASC`;

// 최종 쿼리 조합
const fullQuery = [baseQuery, dateFilterQuery, orderByQuery].join(' ');

// 파라미터 배열 구성
const queryParams: Array<number | string> = [postId];
if (start && end) queryParams.push(start, end);

// 쿼리 실행
const result = await this.pool.query(fullQuery, queryParams);
return result.rows;
Expand All @@ -301,15 +313,15 @@ export class PostRepository {
try {
const query = `
SELECT
(pds.date AT TIME ZONE 'Asia/Seoul') AT TIME ZONE 'UTC' AS date,
pds.date,
pds.daily_view_count,
pds.daily_like_count
FROM posts_post p
JOIN posts_postdailystatistics pds ON p.id = pds.post_id
WHERE p.post_uuid = $1
AND p.is_active = TRUE
AND (pds.date AT TIME ZONE 'Asia/Seoul' AT TIME ZONE 'UTC')::date >= ($2 AT TIME ZONE 'Asia/Seoul' AT TIME ZONE 'UTC')::date
AND (pds.date AT TIME ZONE 'Asia/Seoul' AT TIME ZONE 'UTC')::date <= ($3 AT TIME ZONE 'Asia/Seoul' AT TIME ZONE 'UTC')::date
AND pds.date >= $2
AND pds.date <= $3
ORDER BY pds.date ASC
`;

Expand Down
9 changes: 7 additions & 2 deletions src/routes/post.router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { PostRepository } from '@/repositories/post.repository';
import { PostService } from '@/services/post.service';
import { PostController } from '@/controllers/post.controller';
import { validateRequestDto } from '@/middlewares/validation.middleware';
import { GetAllPostsQueryDto } from '@/types/dto/requests/getAllPostsQuery.type';
import { GetAllPostsQueryDto, GetPostQueryDto } from '@/types';

const router: Router = express.Router();
dotenv.config();
Expand Down Expand Up @@ -133,6 +133,11 @@ router.get('/post/velog/:postId', authMiddleware.verify, postController.getPostB
* '500':
* description: 서버 오류 / 데이터 베이스 조회 오류
*/
router.get('/post/:postId', authMiddleware.verify, postController.getPostByPostId);
router.get(
'/post/:postId',
authMiddleware.verify,
validateRequestDto(GetPostQueryDto, 'query'),
postController.getPostByPostId
);

export default router;
Loading
Loading