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