diff --git a/CLAUDE.md b/CLAUDE.md
index 1ed9771..24b040e 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -81,7 +81,9 @@ pnpm --filter @blog-study/bot rss-collect # 수동 RSS 수집 (봇 없이)
- **비밀댓글 isSecret 토글**: PATCH 시 본인만 변경 가능 (관리자도 타인 비밀 상태 변경 불가)
- **포스트 삭제**: 본인 또는 관리자만 가능, 트랜잭션으로 댓글/조회기록/활동점수(blog_post) 일괄 삭제
- **이모지 리액션**: 게시판 글 + 포스트에 고정 6종 이모지 (👍👀🔥💡😂✅) 토글, `ReactionBar` 공용 컴포넌트 (`apiPath` prop으로 board/posts 구분), 호버(PC)/클릭(모바일) 시 닉네임 팝오버, 복수 선택 가능, 활동 점수/알림 없음
-- **인기글 점수**: `댓글×3 + 조회수×2 + 리액션×1`
+- **인기글 점수**: `댓글×3 + 조회수×2 + 리액션×1`, 인기순 상위 5개 메달 테두리 (금/은/동/스카이블루/라벤더)
+- **인기 포스트 알림**: 화 08:05 KST 자동 + 수동 트리거, 이전 회차 TOP 5 Discord Embed (이모지별 카운트, 썸네일, 링크 버튼), `popular_posts_channel_id` 설정 필요
+- **포스트 회차 필터**: 전체/회차별 셀렉트 드롭다운, `/api/rounds`에서 동적 조회, 인기순도 선택 회차 기준
- **스터디원 목록**: active + dormant + ob 모두 표시, 상태 칩으로 구분 (OB: 황금 파스텔, 휴면: secondary)
## 핵심 파일 위치
@@ -104,6 +106,7 @@ pnpm --filter @blog-study/bot rss-collect # 수동 RSS 수집 (봇 없이)
| `packages/bot/src/bot.ts` | Discord 클라이언트 초기화 (이벤트 핸들러만) |
| `packages/bot/src/job-queue.ts` | pg-boss 싱글톤 (시작/종료/조회) |
| `packages/bot/src/scheduler-registry.ts` | 잡 등록 + RSS→Post→Notification→Push 파이프라인 |
+| `packages/bot/src/schedulers/popular-posts.ts` | 인기 포스트 TOP 5 Discord 알림 (화 08:01 KST + 수동) |
| `packages/bot/src/services/score.service.ts` | 활동 점수 계산/부여 (봇: blog_post만) |
| `packages/web/src/lib/score.ts` | 웹 활동 점수 부여 (board_post, post_comment, board_comment, post_view) |
| `packages/web/src/lib/score-config.ts` | 활동 점수 타입별 메타데이터 (Single Source of Truth: 라벨, 이모지, 배점, 뱃지 컬러) |
diff --git a/packages/bot/src/api-server.ts b/packages/bot/src/api-server.ts
index 70fa06c..d385de9 100644
--- a/packages/bot/src/api-server.ts
+++ b/packages/bot/src/api-server.ts
@@ -12,6 +12,7 @@ import {
getDeadlineReminder,
getFineReminder,
getPollReminder,
+ getPopularPosts,
getRoundReporter,
getRssPoller,
getWeeklyRanking,
@@ -259,6 +260,36 @@ export function createBotApiServer(): Express {
}
});
+ app.post('/api/trigger/popular-posts', authMiddleware, triggerLimiter, async (req, res) => {
+ try {
+ const popularPosts = getPopularPosts();
+
+ if (popularPosts.isSending()) {
+ return res.status(409).json({ error: '인기 포스트 알림이 이미 실행 중입니다' });
+ }
+
+ const { roundNumber } = req.body || {};
+
+ const result = await popularPosts.sendPopularPosts(
+ true,
+ typeof roundNumber === 'number' ? roundNumber : undefined
+ );
+
+ const serializedResult = {
+ ...result,
+ timestamp: result.timestamp instanceof Date
+ ? result.timestamp.toISOString()
+ : result.timestamp,
+ };
+
+ res.json({ success: true, result: serializedResult });
+ } catch (error) {
+ Sentry.captureException(error);
+ logger.error({ error }, '🌐 [API] 인기 포스트 에러');
+ res.status(500).json({ error: '내부 오류가 발생했습니다' });
+ }
+ });
+
app.post('/api/trigger/deadline-reminder', authMiddleware, triggerLimiter, async (req, res) => {
try {
const deadlineReminder = getDeadlineReminder();
diff --git a/packages/bot/src/scheduler-registry.ts b/packages/bot/src/scheduler-registry.ts
index e4d5bfa..e9eaf50 100644
--- a/packages/bot/src/scheduler-registry.ts
+++ b/packages/bot/src/scheduler-registry.ts
@@ -13,6 +13,7 @@ import { getRoundReporter } from './schedulers/round-reporter';
import { getCurationCrawler } from './schedulers/curation-crawler';
import { getWeeklyRanking } from './schedulers/weekly-ranking';
import { getDeadlineReminder } from './schedulers/deadline-reminder';
+import { getPopularPosts } from './schedulers/popular-posts';
import type { CrawledContent } from './services/curation.service';
import { getPostService } from './services/post.service';
import { getNotificationService } from './services/notification.service';
@@ -41,6 +42,7 @@ const JOB_DEFINITIONS = [
{ name: 'curation-share', cron: '5 10 * * *' }, // 4기 미사용
{ name: 'weekly-ranking', cron: '0 1 * * 0' }, // KST 일 10:00 (UTC 일 01:00)
{ name: 'deadline-reminder', cron: '0 23 * * *' }, // KST 매일 08:00 (UTC 23:00)
+ { name: 'popular-posts', cron: '5 23 * * 1' }, // KST 화 08:05 (UTC 월 23:05)
] as const;
/**
@@ -56,12 +58,14 @@ export async function registerAllJobs(boss: PgBoss, client: Client): Promise {
+ await popularPosts.sendPopularPosts();
+ });
+
// Wait for queues to be created in the database
await new Promise(resolve => setTimeout(resolve, 500));
diff --git a/packages/bot/src/schedulers/index.ts b/packages/bot/src/schedulers/index.ts
index 52d189f..2ae43e1 100644
--- a/packages/bot/src/schedulers/index.ts
+++ b/packages/bot/src/schedulers/index.ts
@@ -8,3 +8,4 @@ export * from './curation-crawler';
export * from './weekly-ranking';
export * from './poll-reminder';
export * from './deadline-reminder';
+export * from './popular-posts';
diff --git a/packages/bot/src/schedulers/popular-posts.ts b/packages/bot/src/schedulers/popular-posts.ts
new file mode 100644
index 0000000..ce7cd55
--- /dev/null
+++ b/packages/bot/src/schedulers/popular-posts.ts
@@ -0,0 +1,332 @@
+/**
+ * Popular Posts Scheduler
+ * 회차 종료 후 인기 포스트 TOP 5 발송 (화요일 08:01 KST + 수동 트리거)
+ */
+
+import {
+ ActionRowBuilder,
+ bold,
+ ButtonBuilder,
+ ButtonStyle,
+ Client,
+ EmbedBuilder,
+ type MessageCreateOptions,
+} from 'discord.js';
+import { desc, eq, sql } from 'drizzle-orm';
+import logger from '../lib/logger';
+import { logNotification } from '../lib/notification-logger';
+import { getDb, members, posts } from '@blog-study/shared/db';
+import {
+ ConfigKeys,
+ getConfigValue,
+ getCurrentRound,
+ getRoundByNumber,
+ isGracePeriodEnded,
+} from '../services/round.service';
+
+const KUSTING_WEB_URL = 'https://kusting-web.vercel.app';
+
+export interface PopularPostsResult {
+ timestamp: Date;
+ sent: boolean;
+ roundNumber: number | null;
+ postCount: number;
+ errors: string[];
+}
+
+interface PopularPost {
+ id: string;
+ title: string;
+ url: string;
+ thumbnailUrl: string | null;
+ popularScore: number;
+ memberName: string;
+ memberNickname: string;
+ memberDiscordId: string;
+ memberProfileImageUrl: string | null;
+}
+
+/**
+ * 특정 회차의 인기 포스트 TOP 5 조회
+ */
+async function getPopularPostsForRound(roundId: number): Promise {
+ const db = getDb();
+
+ // 인기 점수 = 댓글×3 + 조회수×2 + 리액션×1
+ const popularScore = sql`
+ COALESCE(${posts.commentCount}, 0) * 3
+ + (SELECT COUNT(*) FROM post_views pv WHERE pv.post_id = ${posts.id}) * 2
+ + (SELECT COUNT(*) FROM post_reactions pr WHERE pr.post_id = ${posts.id})
+ `;
+
+ const topPosts = await db
+ .select({
+ id: posts.id,
+ title: posts.title,
+ url: posts.url,
+ thumbnailUrl: posts.thumbnailUrl,
+ commentCount: posts.commentCount,
+ memberId: members.id,
+ memberName: members.name,
+ memberNickname: members.nickname,
+ memberDiscordId: members.discordId,
+ memberDiscordUsername: members.discordUsername,
+ memberProfileImageUrl: members.profileImageUrl,
+ score: popularScore,
+ })
+ .from(posts)
+ .leftJoin(members, eq(posts.memberId, members.id))
+ .where(eq(posts.roundId, roundId))
+ .orderBy(desc(popularScore), desc(posts.commentCount), desc(posts.publishedAt))
+ .limit(5);
+
+ return topPosts.map((p) => ({
+ id: p.id,
+ title: p.title,
+ url: p.url,
+ thumbnailUrl: p.thumbnailUrl,
+ popularScore: Number(p.score),
+ memberName: p.memberName!,
+ memberNickname: p.memberNickname!,
+ memberDiscordId: p.memberDiscordId!,
+ memberProfileImageUrl: p.memberProfileImageUrl,
+ }));
+}
+
+const RANK_EMOJIS = ['🥇', '🥈', '🥉', '4️⃣', '5️⃣'];
+const RANK_COLORS = [0xFFD700, 0xC0C0C0, 0xCD7F32, 0x87CEEB, 0xB19CD9];
+
+/**
+ * 개별 포스트 Embed 생성
+ */
+function buildPostEmbed(post: PopularPost, rank: number): EmbedBuilder {
+ const emoji = RANK_EMOJIS[rank - 1] ?? `${rank}.`;
+ const color = RANK_COLORS[rank - 1] ?? 0x5865F2;
+
+ const embed = new EmbedBuilder()
+ .setColor(color)
+ .setTitle(`${emoji} ${rank}위 — ${post.title}`)
+ .setURL(post.url)
+ .setAuthor({
+ name: post.memberNickname || post.memberName,
+ iconURL: post.memberProfileImageUrl || undefined,
+ })
+ .setFooter({
+ text: `인기 점수: ${post.popularScore}점`,
+ });
+
+ if (post.thumbnailUrl) {
+ embed.setThumbnail(post.thumbnailUrl);
+ }
+
+ return embed;
+}
+
+/**
+ * 개별 포스트 버튼 생성
+ */
+function buildPostButtons(post: PopularPost): ActionRowBuilder {
+ return new ActionRowBuilder().addComponents(
+ new ButtonBuilder()
+ .setLabel('📖 블로그 원문 보기')
+ .setStyle(ButtonStyle.Link)
+ .setURL(post.url),
+ new ButtonBuilder()
+ .setLabel('🔗 큐스팅 웹에서 보기')
+ .setStyle(ButtonStyle.Link)
+ .setURL(`${KUSTING_WEB_URL}/posts/${post.id}`),
+ );
+}
+
+/**
+ * Popular Posts Scheduler class
+ */
+export class PopularPosts {
+ private isRunning = false;
+ private client: Client | null = null;
+
+ setClient(client: Client): void {
+ this.client = client;
+ }
+
+ getClient(): Client | null {
+ return this.client;
+ }
+
+ isSending(): boolean {
+ return this.isRunning;
+ }
+
+ /**
+ * 인기 포스트 알림 발송
+ * @param force - true면 grace period 체크 건너뜀 (수동 트리거용)
+ * @param forceRoundNumber - 특정 회차 번호 지정 (미지정 시 이전 회차)
+ */
+ async sendPopularPosts(force = false, forceRoundNumber?: number): Promise {
+ if (this.isRunning) {
+ logger.info('🏆 [인기 포스트] 이미 실행 중, 건너뜀');
+ return {
+ timestamp: new Date(),
+ sent: false,
+ roundNumber: null,
+ postCount: 0,
+ errors: ['이미 실행 중'],
+ };
+ }
+
+ this.isRunning = true;
+ const startTime = new Date();
+ const errors: string[] = [];
+
+ try {
+ if (!this.client) {
+ throw new Error('Discord client 미설정');
+ }
+
+ // 대상 회차 결정
+ let targetRound;
+ if (forceRoundNumber !== undefined) {
+ targetRound = await getRoundByNumber(forceRoundNumber);
+ if (!targetRound) throw new Error(`${forceRoundNumber}회차를 찾을 수 없습니다`);
+ } else {
+ const currentRound = await getCurrentRound();
+ targetRound = await getRoundByNumber(currentRound.roundNumber - 1);
+ if (!targetRound) throw new Error('이전 회차를 찾을 수 없습니다');
+ }
+
+ // grace period 체크 (수동/특정 회차 지정 시 건너뜀)
+ if (!force && !forceRoundNumber && !isGracePeriodEnded(targetRound)) {
+ logger.info(`🏆 [인기 포스트] ${targetRound.roundNumber}회차 유예 기간 미종료, 건너뜀`);
+ return {
+ timestamp: startTime,
+ sent: false,
+ roundNumber: targetRound.roundNumber,
+ postCount: 0,
+ errors: ['유예 기간 미종료'],
+ };
+ }
+
+ logger.info(`🏆 [인기 포스트] ${targetRound.roundNumber}회차 인기 포스트 조회 중...`);
+
+ const popularPosts = await getPopularPostsForRound(targetRound.id);
+
+ if (popularPosts.length === 0) {
+ logger.info('🏆 [인기 포스트] 해당 회차에 포스트 없음');
+ return {
+ timestamp: startTime,
+ sent: false,
+ roundNumber: targetRound.roundNumber,
+ postCount: 0,
+ errors: ['포스트 없음'],
+ };
+ }
+
+ // 채널 조회
+ const channelId = await getConfigValue(ConfigKeys.POPULAR_POSTS_CHANNEL_ID);
+ if (!channelId) throw new Error('popular_posts_channel_id 미설정');
+
+ const channel = await this.client.channels.fetch(channelId);
+ if (!channel || !channel.isTextBased() || channel.isDMBased()) {
+ throw new Error(`유효하지 않은 채널: ${channelId}`);
+ }
+
+ // 1. 헤더 메시지 발송
+ const headerEmbed = new EmbedBuilder()
+ .setColor(0xFF6B9D)
+ .setTitle(`🏆 ${targetRound.roundNumber}회차 인기 포스트 TOP ${popularPosts.length}`)
+ .setDescription(
+ `${targetRound.startDate} ~ ${targetRound.endDate} 기간 동안 가장 인기 있었던 포스트입니다!\n` +
+ `점수 산정 기준: ${bold('댓글 × 3 + 조회수 × 2 + 리액션 × 1')}`
+ )
+ .setTimestamp();
+
+ const headerSent = await channel.send({
+ content: '@everyone',
+ embeds: [headerEmbed],
+ allowedMentions: { parse: ['everyone'] },
+ });
+ await logNotification({
+ source: 'bot',
+ type: 'popular_posts',
+ channelId: channel.id,
+ channelName: 'name' in channel ? String((channel as any).name) : undefined,
+ messageId: headerSent.id,
+ summary: `${targetRound.roundNumber}회차 인기 포스트 헤더`,
+ status: 'sent',
+ });
+
+ // 2. 개별 포스트 순차 발송 (1위 → 5위)
+ for (let i = 0; i < popularPosts.length; i++) {
+ const post = popularPosts[i]!;
+ const rank = i + 1;
+
+ const embed = buildPostEmbed(post, rank);
+ const buttons = buildPostButtons(post);
+
+ const message: MessageCreateOptions = {
+ embeds: [embed],
+ components: [buttons],
+ };
+
+ const sent = await channel.send(message);
+ await logNotification({
+ source: 'bot',
+ type: 'popular_posts',
+ channelId: channel.id,
+ channelName: 'name' in channel ? String((channel as any).name) : undefined,
+ messageId: sent.id,
+ summary: `${targetRound.roundNumber}회차 인기 ${rank}위: ${post.title}`.slice(0, 200),
+ metadata: { memberDiscordId: post.memberDiscordId, rank },
+ status: 'sent',
+ });
+ }
+
+ logger.info(
+ `🏆 [인기 포스트] ${targetRound.roundNumber}회차 TOP ${popularPosts.length} 발송 완료 ✅`
+ );
+
+ return {
+ timestamp: startTime,
+ sent: true,
+ roundNumber: targetRound.roundNumber,
+ postCount: popularPosts.length,
+ errors,
+ };
+ } catch (error) {
+ const errorMsg = error instanceof Error ? error.message : String(error);
+ await logNotification({
+ source: 'bot',
+ type: 'popular_posts',
+ summary: '인기 포스트 발표',
+ status: 'failed',
+ errorMessage: errorMsg,
+ });
+ logger.error(`🏆 [인기 포스트] 에러: ${errorMsg}`);
+ errors.push(errorMsg);
+
+ return {
+ timestamp: startTime,
+ sent: false,
+ roundNumber: null,
+ postCount: 0,
+ errors,
+ };
+ } finally {
+ this.isRunning = false;
+ }
+ }
+}
+
+// Singleton instance
+let popularPostsInstance: PopularPosts | null = null;
+
+export function getPopularPosts(): PopularPosts {
+ if (!popularPostsInstance) {
+ popularPostsInstance = new PopularPosts();
+ }
+ return popularPostsInstance;
+}
+
+export function resetPopularPosts(): void {
+ popularPostsInstance = null;
+}
diff --git a/packages/bot/src/services/round.service.ts b/packages/bot/src/services/round.service.ts
index 0ea5d64..ba29a20 100644
--- a/packages/bot/src/services/round.service.ts
+++ b/packages/bot/src/services/round.service.ts
@@ -52,6 +52,7 @@ export const ConfigKeys = {
CURATION_CHANNEL_ID: 'curation_channel_id',
RANKING_CHANNEL_ID: 'ranking_channel_id',
BOT_LOG_CHANNEL_ID: 'bot_log_channel_id',
+ POPULAR_POSTS_CHANNEL_ID: 'popular_posts_channel_id',
} as const;
/**
diff --git a/packages/web/src/app/(admin)/admin/settings/page.tsx b/packages/web/src/app/(admin)/admin/settings/page.tsx
index 2cbd88b..4f6e9e5 100644
--- a/packages/web/src/app/(admin)/admin/settings/page.tsx
+++ b/packages/web/src/app/(admin)/admin/settings/page.tsx
@@ -25,6 +25,7 @@ interface StudySettings {
announcementChannelId: string | null;
noticeChannelId: string | null;
rankingChannelId: string | null;
+ popularPostsChannelId: string | null;
botLogChannelId: string | null;
adminNotificationChannelId: string | null;
adminDiscordIds: string;
@@ -72,6 +73,7 @@ export default function AdminSettingsPage() {
announcementChannelId: null,
noticeChannelId: null,
rankingChannelId: null,
+ popularPostsChannelId: null,
botLogChannelId: null,
adminNotificationChannelId: null,
adminDiscordIds: '',
@@ -334,6 +336,18 @@ export default function AdminSettingsPage() {
주간 랭킹이 발송되는 채널 (#주간-랭킹)
+
+
+
handleInputChange('popularPostsChannelId', e.target.value)}
+ placeholder="예: 1234567890123456789"
+ />
+
+ 인기 포스트 TOP 5가 발송되는 채널 (#인기-포스트)
+
+
{emoji}
@@ -926,34 +947,39 @@ function PostCard({
if (!res.ok) return;
const result = await res.json();
setReactions(result.data.reactions || {});
- } catch { /* non-critical */ }
+ } catch {
+ /* non-critical */
+ }
}, [post.id]);
useEffect(() => {
fetchReactions();
}, [fetchReactions]);
- const toggleReaction = useCallback(async (emoji: string) => {
- if (reactionLoading) return;
- setReactionLoading(emoji);
- setReactionPickerOpen(false);
- try {
- const res = await fetch(`/api/posts/${post.id}/reactions`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ emoji }),
- });
- if (!res.ok) {
+ const toggleReaction = useCallback(
+ async (emoji: string) => {
+ if (reactionLoading) return;
+ setReactionLoading(emoji);
+ setReactionPickerOpen(false);
+ try {
+ const res = await fetch(`/api/posts/${post.id}/reactions`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ emoji }),
+ });
+ if (!res.ok) {
+ toast.error('리액션 처리에 실패했습니다.');
+ return;
+ }
+ fetchReactions();
+ } catch {
toast.error('리액션 처리에 실패했습니다.');
- return;
+ } finally {
+ setReactionLoading(null);
}
- fetchReactions();
- } catch {
- toast.error('리액션 처리에 실패했습니다.');
- } finally {
- setReactionLoading(null);
- }
- }, [post.id, reactionLoading, fetchReactions]);
+ },
+ [post.id, reactionLoading, fetchReactions]
+ );
const activeEmojis = REACTION_EMOJIS.filter((e) => (reactions[e]?.count ?? 0) > 0);
@@ -1052,12 +1078,12 @@ function PostCard({
{/* 메달 뱃지 */}
- {rank && rank <= 3 && (
+ {rank && rank <= 5 && (
{post.commentCount}
-
{/* Reactions — 이모지별 카운트, 호버/클릭 시 닉네임 */}
{activeEmojis.map((emoji) => {
const r = reactions[emoji]!;
@@ -1214,7 +1239,7 @@ function PostCard({
className={cn(
'h-8 w-8 rounded-md text-base flex items-center justify-center hover:bg-muted/80 transition-colors',
reactions[emoji]?.reacted && 'bg-sky-50 dark:bg-sky-950/40',
- reactionLoading === emoji && 'opacity-50',
+ reactionLoading === emoji && 'opacity-50'
)}
>
{emoji}
@@ -1455,6 +1480,8 @@ function PostsContent() {
const [selectedParts, setSelectedParts] = useState
([]);
const [filterOpen, setFilterOpen] = useState(false);
const filterRef = useRef(null);
+ const [rounds, setRounds] = useState([]);
+ const [selectedRoundId, setSelectedRoundId] = useState('all');
// Refs for IntersectionObserver (avoid stale closures)
const sentinelRef = useRef(null);
@@ -1477,7 +1504,22 @@ function PostsContent() {
const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState(null);
- const PAGE_SIZE = 12;
+ // 회차 목록 로드 (한 번만)
+ useEffect(() => {
+ fetch('/api/rounds?sort=asc')
+ .then((res) => res.json())
+ .then((result) => {
+ if (result.data?.rounds) {
+ setRounds(
+ result.data.rounds.map((r: { id: number; roundNumber: number }) => ({
+ id: r.id,
+ roundNumber: r.roundNumber,
+ }))
+ );
+ }
+ })
+ .catch(() => {});
+ }, []);
const fetchPosts = useCallback(
async (pageNum: number, append: boolean) => {
@@ -1491,6 +1533,7 @@ function PostsContent() {
if (tab === 'popular') params.set('sort', 'popular');
if (searchQuery) params.set('search', searchQuery);
if (selectedParts.length > 0) params.set('parts', selectedParts.join(','));
+ if (selectedRoundId !== 'all') params.set('roundId', selectedRoundId);
const response = await fetch(`/api/posts?${params}`);
if (!response.ok) throw new Error('Failed to fetch posts');
@@ -1520,7 +1563,7 @@ function PostsContent() {
setLoadingMore(false);
}
},
- [tab, searchQuery, selectedParts]
+ [tab, searchQuery, selectedParts, selectedRoundId]
);
// Initial load + tab change
@@ -1660,31 +1703,53 @@ function PostsContent() {
{/* Tabs + Header */}
-
-
-
+
+
+
+
+
+
+ {rounds.length > 0 && (
+
+ )}
@@ -1942,7 +2007,7 @@ function PostsContent() {
{tab === 'popular' && (
- 조회수 + 댓글 기반
+ 댓글 × 3 + 조회 × 2 + 리액션 × 1
)}
diff --git a/packages/web/src/app/api/admin/bot-operations/[operationId]/route.ts b/packages/web/src/app/api/admin/bot-operations/[operationId]/route.ts
index 60115d0..0215f8a 100644
--- a/packages/web/src/app/api/admin/bot-operations/[operationId]/route.ts
+++ b/packages/web/src/app/api/admin/bot-operations/[operationId]/route.ts
@@ -17,6 +17,7 @@ const OPERATION_ENDPOINT_MAP: Record
= {
'curation-crawl': '/api/trigger/curation-crawl',
'curation-share': '/api/trigger/curation-share',
'weekly-ranking': '/api/trigger/weekly-ranking',
+ 'popular-posts': '/api/trigger/popular-posts',
'deadline-reminder-d2': '/api/trigger/deadline-reminder',
'deadline-reminder-d1': '/api/trigger/deadline-reminder',
'deadline-reminder-d0': '/api/trigger/deadline-reminder',
diff --git a/packages/web/src/app/api/admin/bot-operations/route.ts b/packages/web/src/app/api/admin/bot-operations/route.ts
index f8cc3ec..57fd6b5 100644
--- a/packages/web/src/app/api/admin/bot-operations/route.ts
+++ b/packages/web/src/app/api/admin/bot-operations/route.ts
@@ -55,6 +55,14 @@ export const GET = withAdminAuth(async () => {
schedule: '일요일 10:00',
running: false,
},
+ {
+ id: 'popular-posts',
+ name: '인기 포스트',
+ description: '이전 회차의 인기 포스트 TOP 5를 디스코드 채널에 발송합니다',
+ category: 'ranking',
+ schedule: '화요일 08:01',
+ running: false,
+ },
{
id: 'deadline-reminder-d2',
name: '마감 리마인더 (D-2)',
diff --git a/packages/web/src/app/api/admin/settings/route.ts b/packages/web/src/app/api/admin/settings/route.ts
index ddb9c0c..f154a4f 100644
--- a/packages/web/src/app/api/admin/settings/route.ts
+++ b/packages/web/src/app/api/admin/settings/route.ts
@@ -73,6 +73,7 @@ export const GET = withAdminAuth(async (_request: NextRequest, adminAuth) => {
announcementChannelId: settings['announcement_channel_id'] || null,
noticeChannelId: settings['notice_channel_id'] || null,
rankingChannelId: settings['ranking_channel_id'] || null,
+ popularPostsChannelId: settings['popular_posts_channel_id'] || null,
botLogChannelId: settings['bot_log_channel_id'] || null,
adminNotificationChannelId: settings['admin_notification_channel_id'] || null,
adminDiscordIds: settings['admin_discord_ids'] || '',
@@ -107,6 +108,7 @@ export const PATCH = withAdminAuth(async (request: NextRequest, _adminAuth) => {
announcementChannelId: 'announcement_channel_id',
noticeChannelId: 'notice_channel_id',
rankingChannelId: 'ranking_channel_id',
+ popularPostsChannelId: 'popular_posts_channel_id',
botLogChannelId: 'bot_log_channel_id',
adminNotificationChannelId: 'admin_notification_channel_id',
adminDiscordIds: 'admin_discord_ids',
@@ -119,6 +121,7 @@ export const PATCH = withAdminAuth(async (request: NextRequest, _adminAuth) => {
'announcement_channel_id',
'notice_channel_id',
'ranking_channel_id',
+ 'popular_posts_channel_id',
'bot_log_channel_id',
'admin_notification_channel_id',
'study_role_id',
diff --git a/packages/web/src/app/api/posts/route.ts b/packages/web/src/app/api/posts/route.ts
index d4afc36..d1e2339 100644
--- a/packages/web/src/app/api/posts/route.ts
+++ b/packages/web/src/app/api/posts/route.ts
@@ -68,7 +68,7 @@ export async function GET(request: NextRequest) {
// WHERE 조건 조합
const conditions = [];
- if (roundIdNum) conditions.push(eq(posts.roundId, roundIdNum));
+ if (roundIdNum !== null) conditions.push(eq(posts.roundId, roundIdNum));
if (search) {
conditions.push(
or(
diff --git a/packages/web/src/lib/notification-log-config.ts b/packages/web/src/lib/notification-log-config.ts
index 4ed331e..7530986 100644
--- a/packages/web/src/lib/notification-log-config.ts
+++ b/packages/web/src/lib/notification-log-config.ts
@@ -5,6 +5,7 @@ export const NotificationLogType = {
WEEKLY_RANKING: 'weekly_ranking',
CURATION: 'curation',
NEW_POST: 'new_post',
+ POPULAR_POSTS: 'popular_posts',
FINE_PAYMENT: 'fine_payment',
// Bot DM
DEADLINE_REMINDER: 'deadline_reminder',
@@ -53,6 +54,11 @@ export const notificationLogTypeConfig: Record