diff --git a/CLAUDE.md b/CLAUDE.md index 5d9e108..1ed9771 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -70,7 +70,9 @@ pnpm --filter @blog-study/bot rss-collect # 수동 RSS 수집 (봇 없이) - **포스트 수정**: 본인 또는 관리자만 제목/설명 수정 가능 (`PATCH /api/posts/[id]`) - **공지 알림**: 게시판 공지 작성 시 FCM 푸시 + Discord 공지채널(`notice_channel_id`) `@everyone` + 웹 딥링크 버튼 - **벌금 DM**: 계좌 정보 포함 (3333333114501 카카오뱅크), 납부완료 시 관리자 채널 알림 -- **D-Day 계산**: KST 기준 (`+09:00` 명시), 제출률은 active 유저만 카운트 +- **D-Day 계산**: KST 캘린더 날짜 기준 (midnight 비교, 당일=D-Day=0), 제출률은 active 유저만 카운트 +- **Discord 알림 로그**: `discord_notification_logs` 테이블에 봇/웹 모든 채널+DM 알림 성공/실패 기록, `logNotification()` 헬퍼 (봇: `notification-logger.ts`, 웹: `notification-log.ts`), 관리자 페이지 "알림 로그" 탭에서 조회 (타입/소스/대상/상태 필터 + 무한 스크롤) +- **비밀답글 가시성**: 비밀 답글은 작성자/포스트작성자/부모댓글작성자/관리자가 열람 가능 - **랭킹**: active + OB + dormant 전원 표시, 웹 페이지 4위부터 (포디움과 분리), 주간랭킹 전원 나열 - **RSS 수집**: active + OB (rssConsent=true만), 포스트 점수는 active만 부여 - **Discord 버튼**: `discord-notify.ts`에 `components` (Link Button) + `allowEveryone` 옵션 지원 @@ -107,6 +109,11 @@ pnpm --filter @blog-study/bot rss-collect # 수동 RSS 수집 (봇 없이) | `packages/web/src/lib/score-config.ts` | 활동 점수 타입별 메타데이터 (Single Source of Truth: 라벨, 이모지, 배점, 뱃지 컬러) | | `packages/web/src/app/(user)/profile/activity/page.tsx` | 활동 내역 페이지 (타입별 필터, 무한 로드) | | `packages/web/src/components/board/reaction-bar.tsx` | 이모지 리액션 바 공용 컴포넌트 (`apiPath` prop) | +| `packages/bot/src/lib/notification-logger.ts` | 봇 알림 로그 DB 헬퍼 (`logNotification`) | +| `packages/web/src/lib/notification-log.ts` | 웹 알림 로그 DB 헬퍼 (`logNotification`) | +| `packages/web/src/lib/notification-log-config.ts` | 알림 로그 타입별 메타데이터 (라벨, 색상, DM 여부) | +| `packages/web/src/app/api/admin/bot-logs/route.ts` | 관리자 알림 로그 조회 API | +| `packages/web/src/app/(admin)/admin/bot-operations/notification-logs.tsx` | 관리자 알림 로그 UI 컴포넌트 | | `packages/web/src/lib/board-auth.ts` | 게시판 인증 헬퍼 (`getBoardAuth`) | | `packages/web/src/lib/board-config.ts` | 게시판 카테고리/뱃지 설정 | | `packages/web/src/lib/api-error.ts` | API 표준 응답/에러 헬퍼 (`successResponse`, `Errors`, `withCache`) | diff --git a/docs/superpowers/plans/26-04-03-discord-notification-logs.md b/docs/superpowers/plans/26-04-03-discord-notification-logs.md new file mode 100644 index 0000000..b30f875 --- /dev/null +++ b/docs/superpowers/plans/26-04-03-discord-notification-logs.md @@ -0,0 +1,1235 @@ +# Discord 알림 로그 시스템 구현 플랜 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 봇/웹에서 Discord 채널 및 DM으로 보내는 모든 알림의 성공/실패를 DB에 기록하고, 관리자 페이지에서 조회 + +**Architecture:** shared에 스키마 추가 → bot에 공통 로깅 헬퍼 생성 후 각 서비스에 삽입 → web의 discord-notify.ts 확장 → 관리자 API + UI + +**Tech Stack:** Drizzle ORM, Next.js API Routes, React (shadcn/ui), discord.js + +--- + +## File Structure + +### 신규 파일 +- `packages/shared/src/db/schema.ts` — 테이블 추가 (기존 파일 수정) +- `packages/bot/src/lib/notification-logger.ts` — 봇 알림 로그 헬퍼 +- `packages/web/src/app/api/admin/bot-logs/route.ts` — 로그 조회 API +- `packages/web/src/lib/notification-log.ts` — 웹 알림 로그 헬퍼 +- `packages/web/src/lib/notification-log-config.ts` — 타입 메타데이터 (라벨/색상) +- `packages/web/src/app/(admin)/admin/bot-operations/notification-logs.tsx` — 로그 탭 UI + +### 수정 파일 +- `packages/shared/src/db/schema.ts` — 테이블+타입 추가 +- `packages/web/src/lib/discord-notify.ts` — 반환타입 확장 (messageId 포함) +- `packages/web/src/app/(admin)/admin/bot-operations/page.tsx` — 탭 추가 +- `packages/bot/src/services/notification.service.ts` — 채널 알림 로깅 삽입 +- `packages/bot/src/schedulers/weekly-ranking.ts` — 랭킹 로깅 삽입 +- `packages/bot/src/schedulers/curation-crawler.ts` — 큐레이션 로깅 삽입 +- `packages/bot/src/handlers/dm-handler.ts` — DM 로깅 삽입 +- `packages/bot/src/schedulers/deadline-reminder.ts` — DM 로깅 삽입 +- `packages/bot/src/schedulers/fine-reminder.ts` — DM 로깅 삽입 +- `packages/web/src/app/api/board/route.ts` — 공지 알림 로깅 삽입 +- `packages/web/src/app/api/posts/manual/route.ts` — 수동등록 알림 로깅 삽입 + +--- + +### Task 1: DB 스키마 추가 + +**Files:** +- Modify: `packages/shared/src/db/schema.ts` (끝부분, ~line 866 이후) + +- [ ] **Step 1: 스키마에 discord_notification_logs 테이블 추가** + +`packages/shared/src/db/schema.ts`의 마지막 type export 블록 직전에 추가: + +```typescript +// ── Discord Notification Logs ───────────────────────────────────────── + +export const discordNotificationLogs = pgTable( + 'discord_notification_logs', + { + id: uuid('id').primaryKey().defaultRandom(), + source: varchar('source', { length: 10 }).notNull(), // 'bot' | 'web' + type: varchar('type', { length: 50 }).notNull(), + channelId: varchar('channel_id', { length: 20 }), + channelName: varchar('channel_name', { length: 100 }), + targetDiscordId: varchar('target_discord_id', { length: 20 }), + messageId: varchar('message_id', { length: 20 }), + summary: varchar('summary', { length: 500 }), + metadata: jsonb('metadata').default({}), + status: varchar('status', { length: 10 }).default('sent').notNull(), + errorMessage: text('error_message'), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + }, + (table) => ({ + createdAtIdx: index('idx_discord_notification_logs_created_at').on(table.createdAt), + typeIdx: index('idx_discord_notification_logs_type').on(table.type), + statusIdx: index('idx_discord_notification_logs_status').on(table.status), + targetIdx: index('idx_discord_notification_logs_target').on(table.targetDiscordId), + }) +); +``` + +타입 export 블록에 추가: + +```typescript +export type DiscordNotificationLog = typeof discordNotificationLogs.$inferSelect; +export type NewDiscordNotificationLog = typeof discordNotificationLogs.$inferInsert; +``` + +- [ ] **Step 2: shared 리빌드** + +Run: `pnpm --filter @blog-study/shared build` + +- [ ] **Step 3: Drizzle push** + +```bash +cd packages/shared +export $(grep DATABASE_URL ../../.env.local | head -1 | xargs) +npx drizzle-kit push --force +``` + +- [ ] **Step 4: 타입체크** + +Run: `pnpm typecheck` +Expected: 성공 + +- [ ] **Step 5: 커밋** + +```bash +git add packages/shared/src/db/schema.ts +git commit -m "feat(shared): add discord_notification_logs table schema" +``` + +--- + +### Task 2: 알림 타입 메타데이터 설정 (웹) + +**Files:** +- Create: `packages/web/src/lib/notification-log-config.ts` + +- [ ] **Step 1: 타입별 라벨/색상 설정 파일 생성** + +```typescript +export const NotificationLogType = { + // Bot - Channel + ROUND_REPORT: 'round_report', + ROUND_START: 'round_start', + WEEKLY_RANKING: 'weekly_ranking', + CURATION: 'curation', + NEW_POST: 'new_post', + FINE_PAYMENT: 'fine_payment', + // Bot - DM + DEADLINE_REMINDER: 'deadline_reminder', + FINE_NOTIFICATION: 'fine_notification', + FINE_REMINDER: 'fine_reminder', + GRACE_NUDGE: 'grace_nudge', + POLL_REMINDER: 'poll_reminder', + // Web - Channel + BOARD_NOTICE: 'board_notice', + POST_REGISTER: 'post_register', + MEMBER_APPROVAL: 'member_approval', + ANNOUNCEMENT: 'announcement', +} as const; + +export type NotificationLogTypeValue = + (typeof NotificationLogType)[keyof typeof NotificationLogType]; + +export interface NotificationLogTypeMeta { + label: string; + color: string; // Tailwind badge class + isDM: boolean; +} + +export const notificationLogTypeConfig: Record = { + // Bot - Channel + [NotificationLogType.ROUND_REPORT]: { + label: '회차 리포트', + color: 'bg-indigo-100 text-indigo-800 dark:bg-indigo-900/30 dark:text-indigo-400', + isDM: false, + }, + [NotificationLogType.ROUND_START]: { + label: '회차 시작', + color: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400', + isDM: false, + }, + [NotificationLogType.WEEKLY_RANKING]: { + label: '주간 랭킹', + color: 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400', + isDM: false, + }, + [NotificationLogType.CURATION]: { + label: '큐레이션', + color: 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400', + isDM: false, + }, + [NotificationLogType.NEW_POST]: { + label: '새 글 알림', + color: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400', + isDM: false, + }, + [NotificationLogType.FINE_PAYMENT]: { + label: '벌금 확인', + color: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400', + isDM: false, + }, + // Bot - DM + [NotificationLogType.DEADLINE_REMINDER]: { + label: '마감 리마인더', + color: 'bg-rose-100 text-rose-800 dark:bg-rose-900/30 dark:text-rose-400', + isDM: true, + }, + [NotificationLogType.FINE_NOTIFICATION]: { + label: '벌금 알림', + color: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400', + isDM: true, + }, + [NotificationLogType.FINE_REMINDER]: { + label: '벌금 독촉', + color: 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-400', + isDM: true, + }, + [NotificationLogType.GRACE_NUDGE]: { + label: '지각 독촉', + color: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400', + isDM: true, + }, + [NotificationLogType.POLL_REMINDER]: { + label: '투표 리마인더', + color: 'bg-cyan-100 text-cyan-800 dark:bg-cyan-900/30 dark:text-cyan-400', + isDM: true, + }, + // Web - Channel + [NotificationLogType.BOARD_NOTICE]: { + label: '게시판 공지', + color: 'bg-sky-100 text-sky-800 dark:bg-sky-900/30 dark:text-sky-400', + isDM: false, + }, + [NotificationLogType.POST_REGISTER]: { + label: '수동 등록', + color: 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-400', + isDM: false, + }, + [NotificationLogType.MEMBER_APPROVAL]: { + label: '가입 승인', + color: 'bg-teal-100 text-teal-800 dark:bg-teal-900/30 dark:text-teal-400', + isDM: false, + }, + [NotificationLogType.ANNOUNCEMENT]: { + label: '공지 알림', + color: 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-400', + isDM: false, + }, +}; + +export function getLogTypeMeta(type: string): NotificationLogTypeMeta { + return notificationLogTypeConfig[type] ?? { + label: type, + color: 'bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-400', + isDM: false, + }; +} +``` + +- [ ] **Step 2: 커밋** + +```bash +git add packages/web/src/lib/notification-log-config.ts +git commit -m "feat(web): add notification log type config with labels and colors" +``` + +--- + +### Task 3: 봇 알림 로그 헬퍼 + +**Files:** +- Create: `packages/bot/src/lib/notification-logger.ts` + +- [ ] **Step 1: 로깅 헬퍼 생성** + +```typescript +import { getDb, discordNotificationLogs } from '@blog-study/shared/db'; +import { logger } from './logger'; + +interface LogNotificationParams { + source: 'bot' | 'web'; + type: string; + channelId?: string; + channelName?: string; + targetDiscordId?: string; + messageId?: string; + summary: string; + metadata?: Record; + status: 'sent' | 'failed'; + errorMessage?: string; +} + +export async function logNotification(params: LogNotificationParams): Promise { + try { + const db = getDb(); + await db.insert(discordNotificationLogs).values({ + source: params.source, + type: params.type, + channelId: params.channelId ?? null, + channelName: params.channelName ?? null, + targetDiscordId: params.targetDiscordId ?? null, + messageId: params.messageId ?? null, + summary: params.summary.slice(0, 500), + metadata: params.metadata ?? {}, + status: params.status, + errorMessage: params.errorMessage ?? null, + }); + } catch (err) { + logger.warn({ err, type: params.type }, '⚠️ [알림 로그] DB 저장 실패 (무시)'); + } +} +``` + +- [ ] **Step 2: 타입체크** + +Run: `pnpm typecheck` + +- [ ] **Step 3: 커밋** + +```bash +git add packages/bot/src/lib/notification-logger.ts +git commit -m "feat(bot): add notification logger helper" +``` + +--- + +### Task 4: 봇 채널 알림에 로깅 삽입 + +**Files:** +- Modify: `packages/bot/src/services/notification.service.ts` +- Modify: `packages/bot/src/schedulers/weekly-ranking.ts` +- Modify: `packages/bot/src/schedulers/curation-crawler.ts` +- Modify: `packages/bot/src/handlers/dm-handler.ts` (fine_payment 확인만) + +- [ ] **Step 1: notification.service.ts — sendPostNotification (line ~422)** + +import 추가: `import { logNotification } from '../lib/notification-logger';` + +`await channel.send(message);` 호출을 수정하여 메시지 결과를 캡처하고 로그 삽입: + +```typescript +// sendPostNotification 내부 try 블록 +const sent = await channel.send(message); +await logNotification({ + source: 'bot', + type: 'new_post', + channelId: channel.id, + channelName: channel.name ?? undefined, + messageId: sent.id, + summary: `새 글: ${input.post.title}`, + metadata: { postTitle: input.post.title, memberDiscordId: input.member.discordId }, + status: 'sent', +}); +``` + +catch 블록에도 실패 로그 추가: + +```typescript +} catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + logger.error({ error }, '📢 [알림] 디스코드 알림 발송 실패'); + await logNotification({ + source: 'bot', + type: 'new_post', + channelId: channel?.id, + channelName: channel?.name ?? undefined, + summary: `새 글: ${input.post.title}`, + status: 'failed', + errorMessage: errorMsg, + }); + return false; +} +``` + +- [ ] **Step 2: notification.service.ts — sendRoundReport (line ~445)** + +같은 패턴. try 블록: + +```typescript +const sent = await channel.send(message); +await logNotification({ + source: 'bot', + type: 'round_report', + channelId: channel.id, + channelName: channel.name ?? undefined, + messageId: sent.id, + summary: `${data.round.roundNumber}회차 리포트`, + status: 'sent', +}); +``` + +catch 블록에도 failed 로그 추가. + +- [ ] **Step 3: notification.service.ts — sendRoundStartAnnouncement (line ~475)** + +```typescript +const sent = await channel.send(message); +await logNotification({ + source: 'bot', + type: 'round_start', + channelId: channel.id, + channelName: channel.name ?? undefined, + messageId: sent.id, + summary: `${round.roundNumber}회차 시작 공지`, + metadata: { activeMemberCount: activeMembers.length }, + status: 'sent', +}); +``` + +catch 블록에도 failed 로그 추가. + +- [ ] **Step 4: weekly-ranking.ts — sendWeeklyRanking (line ~254)** + +import 추가: `import { logNotification } from '../lib/notification-logger';` + +`await channel.send({ embeds: [embed] });` 수정: + +```typescript +const sent = await channel.send({ embeds: [embed] }); +await logNotification({ + source: 'bot', + type: 'weekly_ranking', + channelId: channel.id, + channelName: 'name' in channel ? (channel as { name: string }).name : undefined, + messageId: sent.id, + summary: `주간 랭킹 발표 (${rankings.length}명)`, + status: 'sent', +}); +``` + +catch 블록에도 failed 로그 추가 (channelId를 사용 가능한 범위에서). + +- [ ] **Step 5: curation-crawler.ts — shareDailyContent (line ~300)** + +import 추가: `import { logNotification } from '../lib/notification-logger';` + +`await channel.send(message);` 수정: + +```typescript +const sent = await channel.send(message); +await logNotification({ + source: 'bot', + type: 'curation', + channelId: channel.id, + channelName: 'name' in channel ? (channel as { name: string }).name : undefined, + messageId: sent.id, + summary: `큐레이션: ${item.title}`, + status: 'sent', +}); +``` + +catch 블록에도 failed 로그 추가. + +- [ ] **Step 6: dm-handler.ts — fine_payment 확인 (line ~172)** + +import 추가: `import { logNotification } from '../lib/notification-logger';` + +`await (channel as TextChannel).send(...)` 후: + +```typescript +await logNotification({ + source: 'bot', + type: 'fine_payment', + channelId: logChannelId, + channelName: 'name' in channel ? (channel as { name: string }).name : undefined, + summary: `${displayName}님 ${roundText} ${reason} 벌금 납부 확인`, + status: 'sent', +}); +``` + +- [ ] **Step 7: 타입체크** + +Run: `pnpm typecheck` + +- [ ] **Step 8: 커밋** + +```bash +git add packages/bot/src/services/notification.service.ts packages/bot/src/schedulers/weekly-ranking.ts packages/bot/src/schedulers/curation-crawler.ts packages/bot/src/handlers/dm-handler.ts +git commit -m "feat(bot): add notification logging to channel messages" +``` + +--- + +### Task 5: 봇 DM 발송에 로깅 삽입 + +**Files:** +- Modify: `packages/bot/src/handlers/dm-handler.ts` +- Modify: `packages/bot/src/schedulers/deadline-reminder.ts` +- Modify: `packages/bot/src/schedulers/fine-reminder.ts` + +- [ ] **Step 1: dm-handler.ts — sendFineNotification (line ~236)** + +`await user.send({ content: message, components: [row] });` 후 (try 내): + +```typescript +await logNotification({ + source: 'bot', + type: 'fine_notification', + targetDiscordId: discordId, + summary: `${roundNumber}회차 벌금 알림 (${amount.toLocaleString()}원)`, + status: 'sent', +}); +``` + +catch 블록: + +```typescript +} catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + logger.error({ discordId, error: serializeError(error) }, '💬 [DM] 벌금 알림 발송 실패'); + await logNotification({ + source: 'bot', + type: 'fine_notification', + targetDiscordId: discordId, + summary: `${roundNumber}회차 벌금 알림 (${amount.toLocaleString()}원)`, + status: 'failed', + errorMessage: errorMsg, + }); + return false; +} +``` + +- [ ] **Step 2: dm-handler.ts — sendFineReminder (line ~295)** + +같은 패턴. try 블록 `await user.send(...)` 후: + +```typescript +await logNotification({ + source: 'bot', + type: 'fine_reminder', + targetDiscordId: discordId, + summary: `${roundNumber}회차 벌금 리마인더 (${daysSinceCreation}일 경과)`, + status: 'sent', +}); +``` + +catch에 failed 로그. + +- [ ] **Step 3: dm-handler.ts — sendPollReminderDM (line ~355)** + +try 블록 `await user.send(...)` 후: + +```typescript +await logNotification({ + source: 'bot', + type: 'poll_reminder', + targetDiscordId: discordId, + summary: `투표 리마인더: ${pollQuestion}`, + status: 'sent', +}); +``` + +catch에 failed 로그. + +- [ ] **Step 4: deadline-reminder.ts — sendForDDay (line ~250)** + +import 추가: `import { logNotification } from '../lib/notification-logger';` + +user.send 루프 내부의 try 블록 `await user.send(dmContent);` 후: + +```typescript +await logNotification({ + source: 'bot', + type: 'deadline_reminder', + targetDiscordId: member.discordId, + summary: `D-${dDay} 마감 리마인더`, + metadata: { dDay }, + status: 'sent', +}); +sentCount++; +``` + +catch 블록: + +```typescript +} catch (err) { + const errorMsg = err instanceof Error ? err.message : String(err); + logger.error( + { discordId: member.discordId, err: serializeError(err) }, + '📅 [마감 리마인더] DM 발송 실패' + ); + await logNotification({ + source: 'bot', + type: 'deadline_reminder', + targetDiscordId: member.discordId, + summary: `D-${dDay} 마감 리마인더`, + metadata: { dDay }, + status: 'failed', + errorMessage: errorMsg, + }); + failedCount++; +} +``` + +- [ ] **Step 5: fine-reminder.ts — sendGracePeriodNudge (line ~89)** + +import 추가: `import { logNotification } from '../lib/notification-logger';` + +user.send 루프 내부 `await user.send(...)` 후: + +```typescript +await logNotification({ + source: 'bot', + type: 'grace_nudge', + targetDiscordId: member.discordId, + summary: `${currentRound.roundNumber}회차 지각 독촉`, + status: 'sent', +}); +``` + +catch에 failed 로그. + +- [ ] **Step 6: 타입체크** + +Run: `pnpm typecheck` + +- [ ] **Step 7: 커밋** + +```bash +git add packages/bot/src/handlers/dm-handler.ts packages/bot/src/schedulers/deadline-reminder.ts packages/bot/src/schedulers/fine-reminder.ts +git commit -m "feat(bot): add notification logging to DM sends" +``` + +--- + +### Task 6: 웹 알림 로그 헬퍼 + discord-notify 확장 + +**Files:** +- Create: `packages/web/src/lib/notification-log.ts` +- Modify: `packages/web/src/lib/discord-notify.ts` + +- [ ] **Step 1: 웹 알림 로그 헬퍼 생성** + +`packages/web/src/lib/notification-log.ts`: + +```typescript +import { db as sharedDb } from '@blog-study/shared'; +import { db } from '@/lib/db'; + +const { discordNotificationLogs } = sharedDb; + +interface LogNotificationParams { + source: 'bot' | 'web'; + type: string; + channelId?: string; + channelName?: string; + targetDiscordId?: string; + messageId?: string; + summary: string; + metadata?: Record; + status: 'sent' | 'failed'; + errorMessage?: string; +} + +export async function logNotification(params: LogNotificationParams): Promise { + try { + const database = db(); + await database.insert(discordNotificationLogs).values({ + source: params.source, + type: params.type, + channelId: params.channelId ?? null, + channelName: params.channelName ?? null, + targetDiscordId: params.targetDiscordId ?? null, + messageId: params.messageId ?? null, + summary: params.summary.slice(0, 500), + metadata: params.metadata ?? {}, + status: params.status, + errorMessage: params.errorMessage ?? null, + }); + } catch (err) { + console.error('[notification-log] DB 저장 실패:', err); + } +} +``` + +- [ ] **Step 2: discord-notify.ts — sendDiscordChannelMessage 반환타입 확장** + +현재 반환: `Promise` +변경: `Promise<{ success: boolean; messageId?: string; error?: string }>` + +```typescript +export async function sendDiscordChannelMessage( + options: SendChannelMessageOptions +): Promise<{ success: boolean; messageId?: string; error?: string }> { + const token = process.env.DISCORD_TOKEN; + if (!token) { + console.error('[discord-notify] DISCORD_TOKEN이 설정되지 않았습니다.'); + return { success: false, error: 'DISCORD_TOKEN 미설정' }; + } + + if (!SNOWFLAKE_RE.test(options.channelId)) { + console.error('[discord-notify] 유효하지 않은 channelId:', options.channelId); + return { success: false, error: `유효하지 않은 channelId: ${options.channelId}` }; + } + + try { + const response = await fetch( + `${DISCORD_API_BASE}/channels/${options.channelId}/messages`, + { + method: 'POST', + headers: { + Authorization: `Bot ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + content: options.content, + embeds: options.embeds, + components: options.components, + allowed_mentions: { parse: options.allowEveryone ? ['everyone'] : [] }, + }), + } + ); + + if (!response.ok) { + const errorText = await response.text().catch(() => ''); + console.error(`[discord-notify] 메시지 전송 실패 (${response.status})`); + return { success: false, error: `HTTP ${response.status}: ${errorText.slice(0, 200)}` }; + } + + const data = await response.json().catch(() => null); + return { success: true, messageId: data?.id }; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + console.error('[discord-notify] 메시지 전송 중 오류:', error); + return { success: false, error: errorMsg }; + } +} +``` + +**주의**: 기존 호출자들이 `if (await sendDiscordChannelMessage(...))` 패턴을 사용하는지 확인. 객체는 항상 truthy이므로, 기존 호출자들이 `const result = await sendDiscordChannelMessage(...); if (result)` 패턴이라면 `result.success`로 변경해야 함. 현재 코드 확인 결과 호출자들은 반환값을 사용하지 않으므로 (fire-and-forget), 문제 없음. + +- [ ] **Step 3: 타입체크** + +Run: `pnpm typecheck` + +- [ ] **Step 4: 커밋** + +```bash +git add packages/web/src/lib/notification-log.ts packages/web/src/lib/discord-notify.ts +git commit -m "feat(web): add notification log helper and extend discord-notify return type" +``` + +--- + +### Task 7: 웹 호출자에 로깅 삽입 + +**Files:** +- Modify: `packages/web/src/app/api/board/route.ts` (~line 300) +- Modify: `packages/web/src/app/api/posts/manual/route.ts` (~line 315) + +- [ ] **Step 1: board/route.ts — 공지 알림 로깅** + +import 추가: `import { logNotification } from '@/lib/notification-log';` + +기존 `await sendDiscordChannelMessage({...})` 를 결과 캡처로 변경: + +```typescript +const result = await sendDiscordChannelMessage({ + channelId, + allowEveryone: true, + content: `@everyone\n\n📢 **새로운 공지사항이 등록되었습니다!**\n\n## ${title.trim().slice(0, 100)}`, + components: [...], +}); + +await logNotification({ + source: 'web', + type: 'announcement', + channelId, + summary: `공지: ${title.trim().slice(0, 100)}`, + messageId: result.messageId, + status: result.success ? 'sent' : 'failed', + errorMessage: result.error, +}); +``` + +- [ ] **Step 2: posts/manual/route.ts — 수동등록 알림 로깅** + +import 추가: `import { logNotification } from '@/lib/notification-log';` + +after() 내 `await sendDiscordChannelMessage({...})` 를 결과 캡처로 변경: + +```typescript +const result = await sendDiscordChannelMessage({ + channelId, + content: `<@${member.discordId}>님이 새 글을 발행했습니다! 🎉`, + embeds: [...], + components: [...], +}); + +await logNotification({ + source: 'web', + type: 'post_register', + channelId, + summary: `수동등록: ${title!.slice(0, 100)}`, + messageId: result.messageId, + status: result.success ? 'sent' : 'failed', + errorMessage: result.error, +}); +``` + +- [ ] **Step 3: 다른 sendDiscordChannelMessage 호출자 확인 후 동일 패턴 적용** + +`member_approval` 등 다른 호출 지점도 grep으로 찾아서 동일하게 로깅 삽입. + +- [ ] **Step 4: 타입체크** + +Run: `pnpm typecheck` + +- [ ] **Step 5: 커밋** + +```bash +git add packages/web/src/app/api/board/route.ts packages/web/src/app/api/posts/manual/route.ts +git commit -m "feat(web): add notification logging to discord channel messages" +``` + +--- + +### Task 8: 관리자 로그 조회 API + +**Files:** +- Create: `packages/web/src/app/api/admin/bot-logs/route.ts` + +- [ ] **Step 1: API 라우트 생성** + +```typescript +import { NextRequest } from 'next/server'; +import { desc, eq, and, lt, type SQL } from 'drizzle-orm'; +import { db as sharedDb } from '@blog-study/shared'; +import { db } from '@/lib/db'; +import { withAdminAuth } from '@/lib/admin'; +import { successResponse, Errors, withCache } from '@/lib/api-error'; + +const { discordNotificationLogs } = sharedDb; + +export const GET = withAdminAuth(async (request: NextRequest) => { + try { + const { searchParams } = new URL(request.url); + const type = searchParams.get('type'); + const source = searchParams.get('source'); + const status = searchParams.get('status'); + const target = searchParams.get('target'); // 'channel' | 'dm' + const cursor = searchParams.get('cursor'); // ISO timestamp + const limit = Math.min(Number(searchParams.get('limit') || 20), 50); + + const database = db(); + const conditions: SQL[] = []; + + if (type) conditions.push(eq(discordNotificationLogs.type, type)); + if (source) conditions.push(eq(discordNotificationLogs.source, source)); + if (status) conditions.push(eq(discordNotificationLogs.status, status)); + if (cursor) { + conditions.push(lt(discordNotificationLogs.createdAt, new Date(cursor))); + } + + // target 필터: channel = targetDiscordId is null, dm = targetDiscordId is not null + if (target === 'channel') { + const { isNull } = await import('drizzle-orm'); + conditions.push(isNull(discordNotificationLogs.targetDiscordId)); + } else if (target === 'dm') { + const { isNotNull } = await import('drizzle-orm'); + conditions.push(isNotNull(discordNotificationLogs.targetDiscordId)); + } + + const logs = await database + .select() + .from(discordNotificationLogs) + .where(conditions.length > 0 ? and(...conditions) : undefined) + .orderBy(desc(discordNotificationLogs.createdAt)) + .limit(limit + 1); // +1 for next cursor + + const hasMore = logs.length > limit; + const items = hasMore ? logs.slice(0, limit) : logs; + const nextCursor = hasMore ? items[items.length - 1]!.createdAt.toISOString() : null; + + return withCache( + successResponse({ logs: items, nextCursor, hasMore }), + 10 + ); + } catch (error) { + console.error('Bot logs API error:', error); + return Errors.internalError().toResponse(); + } +}); +``` + +- [ ] **Step 2: 타입체크** + +Run: `pnpm typecheck` + +- [ ] **Step 3: 커밋** + +```bash +git add packages/web/src/app/api/admin/bot-logs/route.ts +git commit -m "feat(web): add admin bot-logs API route" +``` + +--- + +### Task 9: 관리자 로그 UI + +**Files:** +- Create: `packages/web/src/app/(admin)/admin/bot-operations/notification-logs.tsx` +- Modify: `packages/web/src/app/(admin)/admin/bot-operations/page.tsx` + +- [ ] **Step 1: 로그 리스트 컴포넌트 생성** + +`packages/web/src/app/(admin)/admin/bot-operations/notification-logs.tsx`: + +```typescript +'use client'; + +import { useCallback, useEffect, useRef, useState } from 'react'; +import { Badge } from '@/components/ui/badge'; +import { Card, CardContent } from '@/components/ui/card'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Loader2 } from 'lucide-react'; +import { toast } from 'sonner'; +import { cn } from '@/lib/utils'; +import { + getLogTypeMeta, + notificationLogTypeConfig, +} from '@/lib/notification-log-config'; + +interface LogEntry { + id: string; + source: string; + type: string; + channelId: string | null; + channelName: string | null; + targetDiscordId: string | null; + messageId: string | null; + summary: string | null; + metadata: Record; + status: string; + errorMessage: string | null; + createdAt: string; +} + +function formatRelativeTime(dateStr: string): string { + const diff = Date.now() - new Date(dateStr).getTime(); + const minutes = Math.floor(diff / 60000); + if (minutes < 1) return '방금 전'; + if (minutes < 60) return `${minutes}분 전`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}시간 전`; + const days = Math.floor(hours / 24); + return `${days}일 전`; +} + +export default function NotificationLogs() { + const [logs, setLogs] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [hasMore, setHasMore] = useState(false); + const [cursor, setCursor] = useState(null); + const [typeFilter, setTypeFilter] = useState('all'); + const [sourceFilter, setSourceFilter] = useState('all'); + const [targetFilter, setTargetFilter] = useState('all'); + const [statusFilter, setStatusFilter] = useState('all'); + const observerRef = useRef(null); + + const fetchLogs = useCallback( + async (nextCursor?: string | null) => { + try { + const params = new URLSearchParams(); + if (typeFilter !== 'all') params.set('type', typeFilter); + if (sourceFilter !== 'all') params.set('source', sourceFilter); + if (targetFilter !== 'all') params.set('target', targetFilter); + if (statusFilter !== 'all') params.set('status', statusFilter); + if (nextCursor) params.set('cursor', nextCursor); + params.set('limit', '20'); + + const res = await fetch(`/api/admin/bot-logs?${params}`); + if (!res.ok) throw new Error('로그 조회 실패'); + const json = await res.json(); + const data = json.data; + + if (nextCursor) { + setLogs((prev) => [...prev, ...data.logs]); + } else { + setLogs(data.logs); + } + setCursor(data.nextCursor); + setHasMore(data.hasMore); + } catch { + toast.error('알림 로그를 불러오는데 실패했습니다'); + } finally { + setIsLoading(false); + } + }, + [typeFilter, sourceFilter, targetFilter, statusFilter] + ); + + // 필터 변경 시 리셋 + useEffect(() => { + setIsLoading(true); + setLogs([]); + setCursor(null); + fetchLogs(); + }, [fetchLogs]); + + // 무한 스크롤 + useEffect(() => { + if (!observerRef.current || !hasMore) return; + const observer = new IntersectionObserver( + (entries) => { + if (entries[0]?.isIntersecting && cursor) { + fetchLogs(cursor); + } + }, + { threshold: 0.1 } + ); + observer.observe(observerRef.current); + return () => observer.disconnect(); + }, [cursor, hasMore, fetchLogs]); + + const typeOptions = Object.entries(notificationLogTypeConfig).map( + ([value, meta]) => ({ value, label: meta.label }) + ); + + return ( +
+ {/* Filters */} +
+ + + + + + + +
+ + {/* Log List */} + {isLoading ? ( +
+ +
+ ) : logs.length === 0 ? ( +
+

알림 로그가 없습니다

+
+ ) : ( +
+ {logs.map((log) => { + const meta = getLogTypeMeta(log.type); + return ( + + +
+ {/* Status dot */} +
+ +
+ {/* Badges row */} +
+ + {meta.label} + + + {log.source} + + {log.channelName && ( + + #{log.channelName} + + )} + {log.targetDiscordId && ( + + DM:{log.targetDiscordId} + + )} +
+ + {/* Summary */} + {log.summary && ( +

{log.summary}

+ )} + + {/* Error message */} + {log.errorMessage && ( +

+ {log.errorMessage} +

+ )} +
+ + {/* Time */} + + {formatRelativeTime(log.createdAt)} + +
+ + + ); + })} + + {/* Infinite scroll sentinel */} +
+ {hasMore && ( +
+ +
+ )} +
+ )} +
+ ); +} +``` + +- [ ] **Step 2: page.tsx에 탭 추가** + +기존 `bot-operations/page.tsx`를 탭 형태로 리팩토링: + +```typescript +'use client'; + +import { useEffect, useMemo, useState } from 'react'; +import type { BotOperation } from '@/components/bot-operation-card'; +import { BotOperationRow, categoryConfig } from '@/components/bot-operation-card'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { toast } from 'sonner'; +import { Loader2 } from 'lucide-react'; +import NotificationLogs from './notification-logs'; + +const CATEGORY_ORDER = ['polling', 'attendance', 'fine', 'round', 'ranking', 'poll', 'curation']; + +export default function BotOperationsPage() { + // ... (기존 state, fetchOperations, handleTrigger, grouped useMemo, useEffect 그대로 유지) + + return ( +
+
+

봇 관리

+

봇 작업 실행 및 알림 로그 확인

+
+ + + + 수동 실행 + 알림 로그 + + + + {isLoading ? ( + // ... 기존 로딩 UI + ) : ( +
+ {/* 기존 grouped.map 카드 그리드 */} +
+ )} +
+ + + + +
+
+ ); +} +``` + +- [ ] **Step 3: Tabs 컴포넌트 존재 확인** + +Run: `ls packages/web/src/components/ui/tabs.tsx` + +없으면: `cd packages/web && npx shadcn@latest add tabs` + +- [ ] **Step 4: 타입체크** + +Run: `pnpm typecheck` + +- [ ] **Step 5: 커밋** + +```bash +git add packages/web/src/app/\(admin\)/admin/bot-operations/notification-logs.tsx packages/web/src/app/\(admin\)/admin/bot-operations/page.tsx +git commit -m "feat(web): add notification logs tab to admin bot operations page" +``` + +--- + +### Task 10: 통합 검증 + +- [ ] **Step 1: shared 리빌드 + 전체 타입체크** + +```bash +pnpm --filter @blog-study/shared build && pnpm typecheck +``` + +- [ ] **Step 2: 린트** + +```bash +pnpm lint +``` + +- [ ] **Step 3: 빌드** + +```bash +pnpm build +``` + +- [ ] **Step 4: 최종 커밋 (린트 수정 등)** + +필요 시 린트/타입 에러 수정 후 커밋. diff --git a/docs/superpowers/specs/26-04-03-discord-notification-logs-design.md b/docs/superpowers/specs/26-04-03-discord-notification-logs-design.md new file mode 100644 index 0000000..728aeed --- /dev/null +++ b/docs/superpowers/specs/26-04-03-discord-notification-logs-design.md @@ -0,0 +1,148 @@ +# Discord 알림 로그 시스템 설계 + +## 목적 + +봇/웹에서 Discord 채널 및 DM으로 보내는 알림의 성공/실패를 DB에 기록하고, 관리자 페이지에서 조회할 수 있게 한다. RSS 수집은 제외. + +## DB 스키마 + +### 테이블: `discord_notification_logs` + +| 컬럼 | 타입 | 설명 | +|------|------|------| +| `id` | UUID (PK, default gen) | | +| `source` | VARCHAR(10) NOT NULL | `'bot'` / `'web'` | +| `type` | VARCHAR(50) NOT NULL | 알림 타입 (아래 참조) | +| `channel_id` | VARCHAR(20) | Discord 채널 ID (DM은 null) | +| `channel_name` | VARCHAR(100) | 채널 이름 (DM은 대상 닉네임) | +| `target_discord_id` | VARCHAR(20) | DM 대상 Discord ID (채널 알림은 null) | +| `message_id` | VARCHAR(20) | Discord 메시지 ID (성공 시) | +| `summary` | VARCHAR(500) | 메시지 요약 (한줄) | +| `metadata` | JSONB DEFAULT '{}' | 타입별 추가 데이터 | +| `status` | VARCHAR(10) DEFAULT 'sent' | `'sent'` / `'failed'` | +| `error_message` | TEXT | 실패 시 에러 메시지 | +| `created_at` | TIMESTAMPTZ DEFAULT NOW() | | + +인덱스: `(created_at DESC)`, `(type)`, `(status)`, `(target_discord_id)` + +### 알림 타입 + +| source | type | 한글 라벨 | 칩 색상 | 대상 | 설명 | +|--------|------|-----------|---------|------|------| +| `bot` | `round_report` | 회차 리포트 | indigo | 채널 | 회차 종료 리포트 | +| `bot` | `round_start` | 회차 시작 | blue | 채널 | 새 회차 시작 공지 | +| `bot` | `weekly_ranking` | 주간 랭킹 | amber | 채널 | 주간 랭킹 발표 | +| `bot` | `curation` | 큐레이션 | purple | 채널 | 일일 큐레이션 공유 | +| `bot` | `new_post` | 새 글 알림 | green | 채널 | 새 블로그 글 알림 | +| `bot` | `fine_payment` | 벌금 확인 | red | 채널 | 벌금 납부 확인 | +| `bot` | `deadline_reminder` | 마감 리마인더 | rose | DM | D-2/D-1/D-Day 마감 알림 | +| `bot` | `fine_notification` | 벌금 알림 | red | DM | 벌금 부과 DM | +| `bot` | `fine_reminder` | 벌금 독촉 | orange | DM | 미납 벌금 리마인더 DM | +| `bot` | `grace_nudge` | 지각 독촉 | yellow | DM | 지각 기간 제출 독촉 DM | +| `bot` | `poll_reminder` | 투표 리마인더 | cyan | DM | 투표 미참여 리마인더 DM | +| `web` | `board_notice` | 게시판 공지 | sky | 채널 | 게시판 공지 → Discord | +| `web` | `post_register` | 수동 등록 | emerald | 채널 | 포스트 수동등록 알림 | +| `web` | `member_approval` | 가입 승인 | teal | 채널 | 가입 승인대기 알림 | +| `web` | `announcement` | 공지 알림 | orange | 채널 | 공지사항 Discord 알림 | + +## 봇 측 구현 + +### 공통 로깅 헬퍼 + +`packages/bot/src/lib/notification-logger.ts` 신규 파일: + +```typescript +async function logNotification(params: { + source: 'bot'; + type: string; + channelId: string; + channelName?: string; + messageId?: string; + summary: string; + metadata?: Record; + status: 'sent' | 'failed'; + errorMessage?: string; +}): Promise +``` + +- DB에 직접 insert (봇은 이미 DB 접근 가능) +- 로깅 실패 시 logger.warn만 남기고 본 로직에 영향 없음 + +### 삽입 지점 + +**채널 알림:** + +| 파일 | 함수 | 삽입 위치 | +|------|------|-----------| +| `notification.service.ts` | `sendPostNotification` | channel.send() 후 | +| `notification.service.ts` | `sendRoundReport` | channel.send() 후 | +| `notification.service.ts` | `sendRoundStartAnnouncement` | channel.send() 후 | +| `weekly-ranking.ts` | `sendWeeklyRanking` | channel.send() 후 | +| `curation-crawler.ts` | `shareDailyContent` | channel.send() 후 | +| `dm-handler.ts` | fine_payment 확인 로그 | channel.send() 후 | + +**DM 발송:** + +| 파일 | 함수 | type | +|------|------|------| +| `dm-handler.ts` | `sendFineNotification` | `fine_notification` | +| `dm-handler.ts` | `sendFineReminder` | `fine_reminder` | +| `dm-handler.ts` | `sendPollReminderDM` | `poll_reminder` | +| `deadline-reminder.ts` | `sendForDDay` (user.send 루프) | `deadline_reminder` | +| `fine-reminder.ts` | `sendGracePeriodNudge` | `grace_nudge` | + +## 웹 측 구현 + +### `discord-notify.ts` 수정 + +`sendDiscordChannelMessage` 함수에서: +1. 성공 시 response body에서 `message.id` 추출 +2. 호출자에게 결과 반환 (현재 boolean → `{ success, messageId?, error? }`) +3. 호출 측에서 로그 insert (after() 사용) + +**또는** 더 간단하게: 각 호출 지점에서 직접 로그 insert. + +### 삽입 지점 (웹) + +| 파일 | 상황 | type | +|------|------|------| +| `api/board/[id]/notices` 등 공지 관련 | 게시판 공지 | `board_notice` | +| `api/posts/register` | 포스트 수동등록 | `post_register` | +| 가입 승인 관련 API | 가입 승인대기 | `member_approval` | +| 공지 작성 관련 API | 공지사항 알림 | `announcement` | + +## 관리자 UI + +### 위치 + +기존 `bot-operations` 페이지에 **탭 추가**: `[수동 실행] [알림 로그]` + +### 로그 리스트 + +- 최신순 정렬 +- 무한 스크롤 (20개씩) +- 각 항목: 상태 도트(sent=green, failed=red) + 타입 한글 칩(색상별) + source 뱃지(bot/web) + 채널명 + summary + 상대시간 +- 실패 시: 에러 메시지 빨간 텍스트 표시 + +### 필터 + +- **타입**: 전체 / 각 타입별 (한글 라벨로 표시) +- **소스**: 전체 / 봇 / 웹 +- **대상**: 전체 / 채널 / DM +- **상태**: 전체 / 성공 / 실패 + +### API + +`GET /api/admin/bot-logs` +- Query: `?type=&source=&status=&cursor=&limit=20` +- 관리자 인증 필수 (`withAdminAuth`) +- `withCache(response, 10)` (10초) + +## 보존 정책 + +무제한 보존 (삭제 없음). + +## 범위 외 + +- RSS 수집 로그 (제외) +- 메시지 전문 저장 (요약만 저장) diff --git a/packages/bot/src/handlers/dm-handler.ts b/packages/bot/src/handlers/dm-handler.ts index 1ad0d75..dba3935 100644 --- a/packages/bot/src/handlers/dm-handler.ts +++ b/packages/bot/src/handlers/dm-handler.ts @@ -21,6 +21,7 @@ import { eq } from 'drizzle-orm'; import { formatFineReason, getFineService, } from '../services'; import { ConfigKeys, getConfigValue } from '../services/round.service'; import logger, { serializeError } from '../lib/logger'; +import { logNotification } from '../lib/notification-logger'; /** * Add a pending fine confirmation for a user @@ -172,6 +173,12 @@ async function handleButtonInteraction(interaction: Interaction): Promise await (channel as TextChannel).send( `💰 **${displayName}**님이 ${roundText} ${reason} 벌금 ${paidFine.amount.toLocaleString()}원 납부를 완료했습니다.` ); + await logNotification({ + source: 'bot', type: 'fine_payment', + channelId: logChannelId, + summary: `${displayName}님 ${roundText} ${reason} 벌금 납부 확인`, + status: 'sent', + }); } } } @@ -237,6 +244,12 @@ export async function sendFineNotification( content: message, components: [row], }); + await logNotification({ + source: 'bot', type: 'fine_notification', + targetDiscordId: discordId, + summary: `${roundNumber}회차 벌금 알림 (${amount.toLocaleString()}원)`, + status: 'sent', + }); // Track pending confirmation in DB await addPendingConfirmation(discordId, fineId); @@ -244,6 +257,12 @@ export async function sendFineNotification( logger.info({ discordId, fineId }, '💬 [DM] 벌금 알림 발송 완료'); return true; } catch (error) { + await logNotification({ + source: 'bot', type: 'fine_notification', + targetDiscordId: discordId, + summary: `${roundNumber}회차 벌금 알림 (${amount.toLocaleString()}원)`, + status: 'failed', errorMessage: error instanceof Error ? error.message : String(error), + }); logger.error({ discordId, error: serializeError(error) }, '💬 [DM] 벌금 알림 발송 실패'); return false; } @@ -296,6 +315,12 @@ export async function sendFineReminder( content: message, components: [row], }); + await logNotification({ + source: 'bot', type: 'fine_reminder', + targetDiscordId: discordId, + summary: `${roundNumber}회차 벌금 리마인더 (${daysSinceCreation}일 경과)`, + status: 'sent', + }); // Ensure pending confirmation is tracked in DB await addPendingConfirmation(discordId, fineId); @@ -303,6 +328,12 @@ export async function sendFineReminder( logger.info({ discordId, fineId }, '💬 [DM] 벌금 리마인더 발송 완료'); return true; } catch (error) { + await logNotification({ + source: 'bot', type: 'fine_reminder', + targetDiscordId: discordId, + summary: `${roundNumber}회차 벌금 리마인더 (${daysSinceCreation}일 경과)`, + status: 'failed', errorMessage: error instanceof Error ? error.message : String(error), + }); logger.error({ discordId, error: serializeError(error) }, '💬 [DM] 벌금 리마인더 발송 실패'); return false; } @@ -340,7 +371,7 @@ export async function sendPollReminderDM( const message = [ `📊 **투표 참여 요청**`, ``, - `"${pollQuestion}" 투표가 내일 ${expiresHour}에 마감됩니다!`, + `"${pollQuestion}" 투표가 ${expiresHour}에 마감됩니다!`, `아직 참여하지 않으셨으니 투표해주세요 🙏`, ].join('\n'); @@ -356,10 +387,22 @@ export async function sendPollReminderDM( content: message, components: [row], }); + await logNotification({ + source: 'bot', type: 'poll_reminder', + targetDiscordId: discordId, + summary: `투표 리마인더: ${pollQuestion}`.slice(0, 200), + status: 'sent', + }); logger.info({ discordId, pollQuestion }, '💬 [DM] 투표 리마인더 발송 완료'); return true; } catch (error) { + await logNotification({ + source: 'bot', type: 'poll_reminder', + targetDiscordId: discordId, + summary: `투표 리마인더: ${pollQuestion}`.slice(0, 200), + status: 'failed', errorMessage: error instanceof Error ? error.message : String(error), + }); logger.error({ discordId, error: serializeError(error) }, '💬 [DM] 투표 리마인더 발송 실패'); return false; } diff --git a/packages/bot/src/lib/notification-logger.ts b/packages/bot/src/lib/notification-logger.ts new file mode 100644 index 0000000..fc02647 --- /dev/null +++ b/packages/bot/src/lib/notification-logger.ts @@ -0,0 +1,35 @@ +import { getDb, discordNotificationLogs } from '@blog-study/shared/db'; +import logger from './logger'; + +interface LogNotificationParams { + source: 'bot' | 'web'; + type: string; + channelId?: string; + channelName?: string; + targetDiscordId?: string; + messageId?: string; + summary: string; + metadata?: Record; + status: 'sent' | 'failed'; + errorMessage?: string; +} + +export async function logNotification(params: LogNotificationParams): Promise { + try { + const db = getDb(); + await db.insert(discordNotificationLogs).values({ + source: params.source, + type: params.type, + channelId: params.channelId ?? null, + channelName: params.channelName ?? null, + targetDiscordId: params.targetDiscordId ?? null, + messageId: params.messageId ?? null, + summary: params.summary.slice(0, 500), + metadata: params.metadata ?? {}, + status: params.status, + errorMessage: params.errorMessage ?? null, + }); + } catch (err) { + logger.warn({ err, type: params.type }, '⚠️ [알림 로그] DB 저장 실패 (무시)'); + } +} diff --git a/packages/bot/src/schedulers/curation-crawler.ts b/packages/bot/src/schedulers/curation-crawler.ts index 01c4b81..8c449c6 100644 --- a/packages/bot/src/schedulers/curation-crawler.ts +++ b/packages/bot/src/schedulers/curation-crawler.ts @@ -10,6 +10,7 @@ import { ConfigKeys, getConfigValue } from '../services/round.service'; import { getKeywordService, type KeywordStat } from '../services/keyword.service'; import type { CurationItem, CurationSource } from '@blog-study/shared/db'; import logger from '../lib/logger'; +import { logNotification } from '../lib/notification-logger'; /** * Result of a curation cycle @@ -297,7 +298,15 @@ export class CurationCrawler { // Build and send message const message = buildCurationMessage(item, source, keywordStrings); - await channel.send(message); + const sent = await channel.send(message); + await logNotification({ + source: 'bot', type: 'curation', + channelId: channel.id, + channelName: 'name' in channel ? String((channel as any).name) : undefined, + messageId: sent.id, + summary: `큐레이션: ${item.title}`.slice(0, 200), + status: 'sent', + }); // Mark as shared await curationService.markAsShared(item.id); @@ -310,6 +319,11 @@ export class CurationCrawler { }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); + await logNotification({ + source: 'bot', type: 'curation', + summary: '큐레이션 공유', + status: 'failed', errorMessage, + }); logger.error(`📰 [큐레이션] 공유 에러: ${errorMessage}`); return { diff --git a/packages/bot/src/schedulers/deadline-reminder.ts b/packages/bot/src/schedulers/deadline-reminder.ts index d2591bd..abbb269 100644 --- a/packages/bot/src/schedulers/deadline-reminder.ts +++ b/packages/bot/src/schedulers/deadline-reminder.ts @@ -8,6 +8,7 @@ import { and, eq } from 'drizzle-orm'; import { attendance, AttendanceStatus, getDb, members, MemberStatus } from '@blog-study/shared/db'; import { getCurrentRound } from '../services/round.service'; import logger, { serializeError } from '../lib/logger'; +import { logNotification } from '../lib/notification-logger'; export interface DeadlineReminderResult { timestamp: Date; @@ -248,8 +249,23 @@ export class DeadlineReminder { ].join('\n'); await user.send(dmContent); + await logNotification({ + source: 'bot', type: 'deadline_reminder', + targetDiscordId: member.discordId, + summary: `D-${dDay} 마감 리마인더`, + metadata: { dDay }, + status: 'sent', + }); sentCount++; } catch (err) { + await logNotification({ + source: 'bot', type: 'deadline_reminder', + targetDiscordId: member.discordId, + summary: `D-${dDay} 마감 리마인더`, + metadata: { dDay }, + status: 'failed', + errorMessage: err instanceof Error ? err.message : String(err), + }); logger.error( { discordId: member.discordId, err: serializeError(err) }, '📅 [마감 리마인더] DM 발송 실패' diff --git a/packages/bot/src/schedulers/fine-reminder.ts b/packages/bot/src/schedulers/fine-reminder.ts index ecb270f..0bd34be 100644 --- a/packages/bot/src/schedulers/fine-reminder.ts +++ b/packages/bot/src/schedulers/fine-reminder.ts @@ -11,6 +11,7 @@ import { getFineService } from '../services/fine.service'; import { sendFineReminder } from '../handlers/dm-handler'; import { getCurrentRound } from '../services/round.service'; import logger from '../lib/logger'; +import { logNotification } from '../lib/notification-logger'; /** * Result of a fine reminder cycle @@ -92,7 +93,19 @@ export class FineReminder { `${currentRound.roundNumber}회차 마감은 지났지만, 오늘 안에 제출하면 결석은 피할 수 있어요.`, `짧은 글이라도 괜찮아요. 지금 시작해보는 건 어때요?`, ].join('\n')); + await logNotification({ + source: 'bot', type: 'grace_nudge', + targetDiscordId: member.discordId, + summary: `${currentRound.roundNumber}회차 지각 독촉`, + status: 'sent', + }); } catch (err) { + await logNotification({ + source: 'bot', type: 'grace_nudge', + targetDiscordId: member.discordId, + summary: `${currentRound.roundNumber}회차 지각 독촉`, + status: 'failed', errorMessage: err instanceof Error ? err.message : String(err), + }); logger.error({ discordId: member.discordId, err }, '✍️ [지각 독촉] DM 발송 실패'); } } diff --git a/packages/bot/src/schedulers/weekly-ranking.ts b/packages/bot/src/schedulers/weekly-ranking.ts index 5e5acf6..0f5c6d3 100644 --- a/packages/bot/src/schedulers/weekly-ranking.ts +++ b/packages/bot/src/schedulers/weekly-ranking.ts @@ -6,6 +6,7 @@ import { bold, Client, EmbedBuilder } from 'discord.js'; import { count, eq, inArray, sql } from 'drizzle-orm'; import logger from '../lib/logger'; +import { logNotification } from '../lib/notification-logger'; import { activityScores, ActivityScoreType, getDb, members, MemberStatus, posts } from '@blog-study/shared/db'; import { ConfigKeys, getConfigValue } from '../services/round.service'; @@ -251,7 +252,15 @@ export class WeeklyRanking { throw new Error(`유효하지 않은 채널: ${channelId}`); } - await channel.send({ embeds: [embed] }); + const sent = await channel.send({ embeds: [embed] }); + await logNotification({ + source: 'bot', type: 'weekly_ranking', + channelId: channel.id, + channelName: 'name' in channel ? String((channel as any).name) : undefined, + messageId: sent.id, + summary: `주간 랭킹 발표 (${rankings.length}명)`, + status: 'sent', + }); logger.info(`🏆 [주간 랭킹] 발송 완료 ✅ (${rankings.length}명)`); @@ -264,6 +273,11 @@ export class WeeklyRanking { }; } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error); + await logNotification({ + source: 'bot', type: 'weekly_ranking', + summary: '주간 랭킹 발표', + status: 'failed', errorMessage: errorMsg, + }); logger.error(`🏆 [주간 랭킹] 에러: ${errorMsg}`); errors.push(errorMsg); diff --git a/packages/bot/src/services/notification.service.ts b/packages/bot/src/services/notification.service.ts index 481b295..85b0f01 100644 --- a/packages/bot/src/services/notification.service.ts +++ b/packages/bot/src/services/notification.service.ts @@ -20,6 +20,7 @@ import { AttendanceStatus, getDb, members, MemberStatus } from '@blog-study/shar import { eq } from 'drizzle-orm'; import { ConfigKeys, getConfigValue } from './round.service'; import logger from '../lib/logger'; +import { logNotification } from '../lib/notification-logger'; /** * Error codes for notification operations @@ -419,10 +420,24 @@ export class NotificationService { try { const message = buildPostNotificationMessage(input); - await channel.send(message); + const sent = await channel.send(message); + await logNotification({ + source: 'bot', type: 'new_post', + channelId: channel.id, channelName: channel.name ?? undefined, + messageId: sent.id, + summary: `새 글: ${input.post.title}`.slice(0, 200), + metadata: { memberDiscordId: input.member.discordId }, + status: 'sent', + }); logger.info({ postTitle: input.post.title }, '📢 [알림] 디스코드 알림 발송 완료'); return true; } catch (error) { + await logNotification({ + source: 'bot', type: 'new_post', + channelId: channel?.id, channelName: channel?.name ?? undefined, + summary: `새 글: ${input.post.title}`.slice(0, 200), + status: 'failed', errorMessage: error instanceof Error ? error.message : String(error), + }); logger.error({ error }, '📢 [알림] 디스코드 알림 발송 실패'); return false; } @@ -442,10 +457,23 @@ export class NotificationService { try { const message = buildRoundReportMessage(data); - await channel.send(message); + const sent = await channel.send(message); + await logNotification({ + source: 'bot', type: 'round_report', + channelId: channel.id, channelName: channel.name ?? undefined, + messageId: sent.id, + summary: `${data.round.roundNumber}회차 리포트`, + status: 'sent', + }); logger.info({ roundNumber: data.round.roundNumber }, '📢 [알림] 회차 리포트 발송 완료'); return true; } catch (error) { + await logNotification({ + source: 'bot', type: 'round_report', + channelId: channel?.id, channelName: channel?.name ?? undefined, + summary: `${data.round.roundNumber}회차 리포트`, + status: 'failed', errorMessage: error instanceof Error ? error.message : String(error), + }); logger.error({ error }, '📢 [알림] 회차 리포트 발송 실패'); return false; } @@ -472,13 +500,27 @@ export class NotificationService { .where(eq(members.status, MemberStatus.ACTIVE)); const message = buildRoundStartMessage(round, activeMembers); - await channel.send(message); + const sent = await channel.send(message); + await logNotification({ + source: 'bot', type: 'round_start', + channelId: channel.id, channelName: channel.name ?? undefined, + messageId: sent.id, + summary: `${round.roundNumber}회차 시작 공지`, + metadata: { activeMemberCount: activeMembers.length }, + status: 'sent', + }); logger.info({ roundNumber: round.roundNumber, activeMemberCount: activeMembers.length }, '📢 [알림] 회차 시작 공지 발송 완료'); return true; } catch (error) { + await logNotification({ + source: 'bot', type: 'round_start', + channelId: channel?.id, channelName: channel?.name ?? undefined, + summary: `${round.roundNumber}회차 시작 공지`, + status: 'failed', errorMessage: error instanceof Error ? error.message : String(error), + }); logger.error({ error }, '📢 [알림] 회차 시작 공지 발송 실패'); return false; } diff --git a/packages/shared/src/db/schema.ts b/packages/shared/src/db/schema.ts index 92bc3f4..0c564f6 100644 --- a/packages/shared/src/db/schema.ts +++ b/packages/shared/src/db/schema.ts @@ -605,6 +605,32 @@ export const notificationPreferences = pgTable( }) ); +// ── Discord Notification Logs ───────────────────────────────────────────────── + +export const discordNotificationLogs = pgTable( + 'discord_notification_logs', + { + id: uuid('id').primaryKey().defaultRandom(), + source: varchar('source', { length: 10 }).notNull(), + type: varchar('type', { length: 50 }).notNull(), + channelId: varchar('channel_id', { length: 20 }), + channelName: varchar('channel_name', { length: 100 }), + targetDiscordId: varchar('target_discord_id', { length: 20 }), + messageId: varchar('message_id', { length: 20 }), + summary: varchar('summary', { length: 500 }), + metadata: jsonb('metadata').default({}), + status: varchar('status', { length: 10 }).default('sent').notNull(), + errorMessage: text('error_message'), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + }, + (table) => ({ + createdAtIdx: index('idx_dnl_created_at').on(table.createdAt), + typeIdx: index('idx_dnl_type').on(table.type), + statusIdx: index('idx_dnl_status').on(table.status), + targetIdx: index('idx_dnl_target').on(table.targetDiscordId), + }) +); + // ============================================ // Relations // ============================================ @@ -864,3 +890,6 @@ export type NewFcmToken = typeof fcmTokens.$inferInsert; export type NotificationPreference = typeof notificationPreferences.$inferSelect; export type NewNotificationPreference = typeof notificationPreferences.$inferInsert; + +export type DiscordNotificationLog = typeof discordNotificationLogs.$inferSelect; +export type NewDiscordNotificationLog = typeof discordNotificationLogs.$inferInsert; diff --git a/packages/shared/src/utils/date-utils.ts b/packages/shared/src/utils/date-utils.ts index 8fb3269..3b56386 100644 --- a/packages/shared/src/utils/date-utils.ts +++ b/packages/shared/src/utils/date-utils.ts @@ -190,11 +190,14 @@ export function determineAttendanceStatus( * 마감까지 남은 일수 계산 */ export function getDaysUntilDeadline(roundDates: RoundDates, currentDate: Date = new Date()): number { - const diffMs = roundDates.endDate.getTime() - currentDate.getTime(); - if (diffMs <= 0) { - return 0; - } - return Math.ceil(diffMs / MS_PER_DAY); + // KST 캘린더 날짜 기준 (당일 = 0) + const kstNow = new Date(currentDate.getTime() + 9 * 60 * 60 * 1000); + const todayStr = kstNow.toISOString().split('T')[0]!; + const todayMs = new Date(`${todayStr}T00:00:00+09:00`).getTime(); + const endStr = roundDates.endDate.toISOString().split('T')[0]!; + const endMs = new Date(`${endStr}T00:00:00+09:00`).getTime(); + const diff = Math.round((endMs - todayMs) / MS_PER_DAY); + return Math.max(0, diff); } /** diff --git a/packages/web/src/app/(admin)/admin/bot-operations/notification-logs.tsx b/packages/web/src/app/(admin)/admin/bot-operations/notification-logs.tsx new file mode 100644 index 0000000..896dc67 --- /dev/null +++ b/packages/web/src/app/(admin)/admin/bot-operations/notification-logs.tsx @@ -0,0 +1,272 @@ +'use client'; + +import { useCallback, useEffect, useRef, useState } from 'react'; +import { getLogTypeMeta, notificationLogTypeConfig } from '@/lib/notification-log-config'; +import { Badge } from '@/components/ui/badge'; +import { Card, CardContent } from '@/components/ui/card'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Loader2 } from 'lucide-react'; +import { toast } from 'sonner'; +import { cn } from '@/lib/utils'; + +interface LogEntry { + id: string; + source: string; + type: string; + channelId: string | null; + channelName: string | null; + targetDiscordId: string | null; + messageId: string | null; + summary: string | null; + metadata: Record; + status: string; + errorMessage: string | null; + createdAt: string; +} + +function formatRelativeTime(dateStr: string): string { + const diff = Date.now() - new Date(dateStr).getTime(); + const minutes = Math.floor(diff / 60000); + if (minutes < 1) return '방금 전'; + if (minutes < 60) return `${minutes}분 전`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}시간 전`; + const days = Math.floor(hours / 24); + return `${days}일 전`; +} + +const ALL_TYPES = Object.entries(notificationLogTypeConfig).map(([value, meta]) => ({ + value, + label: meta.label, +})); + +export default function NotificationLogs() { + const [logs, setLogs] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [isLoadingMore, setIsLoadingMore] = useState(false); + const [nextCursor, setNextCursor] = useState(null); + const [hasMore, setHasMore] = useState(false); + + const [filterType, setFilterType] = useState(''); + const [filterSource, setFilterSource] = useState(''); + const [filterTarget, setFilterTarget] = useState(''); + const [filterStatus, setFilterStatus] = useState(''); + + const bottomRef = useRef(null); + const observerRef = useRef(null); + + const fetchLogs = useCallback( + async (cursor?: string) => { + const params = new URLSearchParams(); + if (filterType) params.set('type', filterType); + if (filterSource) params.set('source', filterSource); + if (filterTarget) params.set('target', filterTarget); + if (filterStatus) params.set('status', filterStatus); + if (cursor) params.set('cursor', cursor); + + const res = await fetch(`/api/admin/bot-logs?${params.toString()}`); + if (!res.ok) throw new Error('로그를 불러오는데 실패했습니다'); + const json = await res.json(); + return json.data as { logs: LogEntry[]; nextCursor: string | null; hasMore: boolean }; + }, + [filterType, filterSource, filterTarget, filterStatus] + ); + + const loadInitial = useCallback(async () => { + setIsLoading(true); + try { + const data = await fetchLogs(); + setLogs(data.logs); + setNextCursor(data.nextCursor); + setHasMore(data.hasMore); + } catch (err) { + console.error(err); + toast.error('알림 로그를 불러오는데 실패했습니다'); + } finally { + setIsLoading(false); + } + }, [fetchLogs]); + + const isLoadingMoreRef = useRef(false); + const loadMore = useCallback(async () => { + if (!hasMore || !nextCursor || isLoadingMoreRef.current) return; + isLoadingMoreRef.current = true; + setIsLoadingMore(true); + try { + const data = await fetchLogs(nextCursor); + setLogs((prev) => [...prev, ...data.logs]); + setNextCursor(data.nextCursor); + setHasMore(data.hasMore); + } catch (err) { + console.error(err); + toast.error('추가 로그를 불러오는데 실패했습니다'); + } finally { + isLoadingMoreRef.current = false; + setIsLoadingMore(false); + } + }, [fetchLogs, hasMore, nextCursor]); + + // Reset when filters change + useEffect(() => { + loadInitial(); + }, [loadInitial]); + + // Infinite scroll via IntersectionObserver + useEffect(() => { + if (observerRef.current) observerRef.current.disconnect(); + observerRef.current = new IntersectionObserver( + (entries) => { + if (entries[0]?.isIntersecting) { + loadMore(); + } + }, + { rootMargin: '100px' } + ); + if (bottomRef.current) { + observerRef.current.observe(bottomRef.current); + } + return () => observerRef.current?.disconnect(); + }, [loadMore]); + + return ( +
+ {/* Filters */} +
+ + + + + + + +
+ + {/* Log list */} + {isLoading ? ( +
+ +
+ ) : logs.length === 0 ? ( +
+ 표시할 로그가 없습니다 +
+ ) : ( +
+ {logs.map((log) => { + const meta = getLogTypeMeta(log.type); + const isSent = log.status === 'sent'; + const isDM = log.targetDiscordId != null; + + return ( + + +
+ {/* Status dot */} + +
+ {/* Top row: badges + destination + time */} +
+ + {meta.label} + + + {log.source === 'bot' ? '봇' : '웹'} + + + {isDM + ? `DM → ${log.targetDiscordId}` + : log.channelName + ? `#${log.channelName}` + : log.channelId + ? `#${log.channelId}` + : '—'} + + {formatRelativeTime(log.createdAt)} +
+ + {/* Summary */} + {log.summary && ( +

{log.summary}

+ )} + + {/* Error message */} + {!isSent && log.errorMessage && ( +

{log.errorMessage}

+ )} +
+
+
+
+ ); + })} + + {/* Infinite scroll sentinel */} +
+ + {isLoadingMore && ( +
+ +
+ )} +
+ )} +
+ ); +} diff --git a/packages/web/src/app/(admin)/admin/bot-operations/page.tsx b/packages/web/src/app/(admin)/admin/bot-operations/page.tsx index f3e6f3b..95e7c70 100644 --- a/packages/web/src/app/(admin)/admin/bot-operations/page.tsx +++ b/packages/web/src/app/(admin)/admin/bot-operations/page.tsx @@ -5,8 +5,10 @@ import type { BotOperation } from '@/components/bot-operation-card'; import { BotOperationRow, categoryConfig } from '@/components/bot-operation-card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { toast } from 'sonner'; import { Loader2 } from 'lucide-react'; +import NotificationLogs from './notification-logs'; // 카테고리 표시 순서 const CATEGORY_ORDER = ['polling', 'attendance', 'fine', 'round', 'ranking', 'poll', 'curation']; @@ -96,45 +98,58 @@ export default function BotOperationsPage() { return (
-

봇 동작 제어

-

스케줄된 작업을 수동으로 실행합니다

+

봇 관리

+

봇 작업 실행 및 알림 로그 확인

-
- {grouped.map(([category, ops]) => { - const config = categoryConfig[category] || { label: category, color: '' }; - return ( - - -
- - {config.label} - - - {ops.length}개 - -
-
- - {ops.map((op) => ( - - ))} - -
- ); - })} -
+ + + 수동 실행 + 알림 로그 + - {operations.length === 0 && ( -
-

사용 가능한 작업이 없습니다

-
- )} + +
+ {grouped.map(([category, ops]) => { + const config = categoryConfig[category] || { label: category, color: '' }; + return ( + + +
+ + {config.label} + + + {ops.length}개 + +
+
+ + {ops.map((op) => ( + + ))} + +
+ ); + })} +
+ + {operations.length === 0 && ( +
+

사용 가능한 작업이 없습니다

+
+ )} +
+ + + + +
); } diff --git a/packages/web/src/app/(user)/ranking/page.tsx b/packages/web/src/app/(user)/ranking/page.tsx index b1e457d..393177c 100644 --- a/packages/web/src/app/(user)/ranking/page.tsx +++ b/packages/web/src/app/(user)/ranking/page.tsx @@ -181,7 +181,7 @@ function PodiumCard({ member, rank, isCurrentUser }: PodiumCardProps) { {displayName}

{member.resolution && ( -

+

“{member.resolution}”

)} diff --git a/packages/web/src/app/api/admin/bot-logs/route.ts b/packages/web/src/app/api/admin/bot-logs/route.ts new file mode 100644 index 0000000..17b7051 --- /dev/null +++ b/packages/web/src/app/api/admin/bot-logs/route.ts @@ -0,0 +1,60 @@ +import { NextRequest } from 'next/server'; +import { desc, eq, and, lt, isNull, isNotNull, type SQL } from 'drizzle-orm'; +import { db as sharedDb } from '@blog-study/shared'; +import { db } from '@/lib/db'; +import { withAdminAuth } from '@/lib/admin'; +import { successResponse, Errors } from '@/lib/api-error'; + +const { discordNotificationLogs } = sharedDb; + +/** + * GET /api/admin/bot-logs + * 봇 Discord 알림 로그 조회 (페이지네이션 + 필터링) + */ +export const GET = withAdminAuth(async (request: NextRequest) => { + try { + const { searchParams } = new URL(request.url); + const type = searchParams.get('type'); + const source = searchParams.get('source'); + const status = searchParams.get('status'); + const target = searchParams.get('target'); // 'channel' | 'dm' + const cursor = searchParams.get('cursor'); // ISO timestamp + const limit = Math.min(Number(searchParams.get('limit') || 20), 50); + + const database = db(); + const conditions: SQL[] = []; + + if (type) conditions.push(eq(discordNotificationLogs.type, type)); + if (source) conditions.push(eq(discordNotificationLogs.source, source)); + if (status) conditions.push(eq(discordNotificationLogs.status, status)); + if (cursor) { + const cursorDate = new Date(cursor); + if (isNaN(cursorDate.getTime())) return Errors.badRequest('유효하지 않은 cursor').toResponse(); + conditions.push(lt(discordNotificationLogs.createdAt, cursorDate)); + } + if (target === 'channel') { + conditions.push(isNull(discordNotificationLogs.targetDiscordId)); + } else if (target === 'dm') { + conditions.push(isNotNull(discordNotificationLogs.targetDiscordId)); + } + + const logs = await database + .select() + .from(discordNotificationLogs) + .where(conditions.length > 0 ? and(...conditions) : undefined) + .orderBy(desc(discordNotificationLogs.createdAt)) + .limit(limit + 1); + + const hasMore = logs.length > limit; + const items = hasMore ? logs.slice(0, limit) : logs; + const nextCursor = + hasMore && items.length > 0 + ? items[items.length - 1]!.createdAt.toISOString() + : null; + + return successResponse({ logs: items, nextCursor, hasMore }); + } catch (error) { + console.error('Bot logs API error:', error); + return Errors.internalError().toResponse(); + } +}); diff --git a/packages/web/src/app/api/admin/dashboard/route.ts b/packages/web/src/app/api/admin/dashboard/route.ts index b3b09aa..90b44d9 100644 --- a/packages/web/src/app/api/admin/dashboard/route.ts +++ b/packages/web/src/app/api/admin/dashboard/route.ts @@ -28,16 +28,17 @@ export const GET = withAdminAuth(async (_request, _adminAuth) => { if (currentRoundData) { const now = new Date(); - const endDate = new Date(currentRoundData.endDate); - const endOfDeadline = new Date(endDate); - endOfDeadline.setHours(23, 59, 59, 999); - const graceEndDate = new Date(currentRoundData.graceEndDate); - const endOfGrace = new Date(graceEndDate); - endOfGrace.setHours(23, 59, 59, 999); - - // Calculate days remaining (마감일 당일 23:59:59 기준) - const timeDiff = endOfDeadline.getTime() - now.getTime(); - const daysRemaining = Math.ceil(timeDiff / (1000 * 60 * 60 * 24)); + const endOfDeadline = new Date(`${currentRoundData.endDate}T23:59:59.999+09:00`); + const endOfGrace = new Date(`${currentRoundData.graceEndDate}T23:59:59.999+09:00`); + + // KST 캘린더 날짜 기준 D-Day 계산 (당일 = D-Day = 0) + const kstNow = new Date(now.getTime() + 9 * 60 * 60 * 1000); + const todayStr = kstNow.toISOString().split('T')[0]!; + const todayMidnight = new Date(`${todayStr}T00:00:00+09:00`); + const endMidnight = new Date(`${currentRoundData.endDate}T00:00:00+09:00`); + const daysRemaining = Math.round( + (endMidnight.getTime() - todayMidnight.getTime()) / (1000 * 60 * 60 * 24) + ); // 지각: 마감일 다음 날부터 ~ 지각 마감일 23:59:59까지 const isGracePeriod = now > endOfDeadline && now <= endOfGrace; diff --git a/packages/web/src/app/api/board/[id]/route.ts b/packages/web/src/app/api/board/[id]/route.ts index e9d204c..9b0a7d8 100644 --- a/packages/web/src/app/api/board/[id]/route.ts +++ b/packages/web/src/app/api/board/[id]/route.ts @@ -86,10 +86,15 @@ export async function GET(_request: NextRequest, { params }: { params: Promise<{ memberIsAdmin: false, }; } + // 부모 댓글 작성자도 비밀 답글을 볼 수 있도록 + const parentComment = comment.parentId + ? comments.find((c) => c.id === comment.parentId) + : null; if ( comment.isSecret && comment.memberId !== auth.memberId && post.memberId !== auth.memberId && + parentComment?.memberId !== auth.memberId && !auth.isAdmin ) { return { diff --git a/packages/web/src/app/api/board/route.ts b/packages/web/src/app/api/board/route.ts index 97b60c2..82fd43f 100644 --- a/packages/web/src/app/api/board/route.ts +++ b/packages/web/src/app/api/board/route.ts @@ -16,6 +16,7 @@ import { sanitizeDescription, sanitizeTiptapContent } from '@/lib/sanitize'; import { grantWebScore } from '@/lib/score'; import { sendPushToMembers } from '@/lib/push'; import { sendDiscordChannelMessage } from '@/lib/discord-notify'; +import { logNotification } from '@/lib/notification-log'; const { boardPosts, @@ -297,7 +298,7 @@ export async function POST(request: NextRequest) { const channelId = channelRow?.value; if (channelId) { const postUrl = `https://kusting-web.vercel.app/board/${result.id}`; - await sendDiscordChannelMessage({ + const discordResult = await sendDiscordChannelMessage({ channelId, allowEveryone: true, content: `@everyone\n\n📢 **새로운 공지사항이 등록되었습니다!**\n\n## ${title.trim().slice(0, 100)}`, @@ -316,6 +317,15 @@ export async function POST(request: NextRequest) { }, ], }); + await logNotification({ + source: 'web', + type: 'announcement', + channelId, + summary: `공지: ${title.trim().slice(0, 100)}`, + messageId: discordResult.messageId, + status: discordResult.success ? 'sent' : 'failed', + errorMessage: discordResult.error, + }); } } catch (err) { console.error('[discord] Notice notification failed:', err); diff --git a/packages/web/src/app/api/dashboard/route.ts b/packages/web/src/app/api/dashboard/route.ts index e440479..5d4d098 100644 --- a/packages/web/src/app/api/dashboard/route.ts +++ b/packages/web/src/app/api/dashboard/route.ts @@ -99,8 +99,14 @@ export async function GET() { const endOfDeadline = new Date(`${currentRoundData.endDate}T23:59:59.999+09:00`); const endOfGrace = new Date(`${currentRoundData.graceEndDate}T23:59:59.999+09:00`); - const timeDiff = endOfDeadline.getTime() - now.getTime(); - const daysRemaining = Math.ceil(timeDiff / (1000 * 60 * 60 * 24)); + // KST 캘린더 날짜 기준 D-Day 계산 (당일 = D-Day = 0) + const kstNow = new Date(now.getTime() + 9 * 60 * 60 * 1000); + const todayStr = kstNow.toISOString().split('T')[0]!; + const todayMidnight = new Date(`${todayStr}T00:00:00+09:00`); + const endMidnight = new Date(`${currentRoundData.endDate}T00:00:00+09:00`); + const daysRemaining = Math.round( + (endMidnight.getTime() - todayMidnight.getTime()) / (1000 * 60 * 60 * 24) + ); const isGracePeriod = now > endOfDeadline && now <= endOfGrace; // Parallelize attendance stats + my attendance query diff --git a/packages/web/src/app/api/posts/[id]/comments/route.ts b/packages/web/src/app/api/posts/[id]/comments/route.ts index 399268c..15eb9cd 100644 --- a/packages/web/src/app/api/posts/[id]/comments/route.ts +++ b/packages/web/src/app/api/posts/[id]/comments/route.ts @@ -84,12 +84,14 @@ export async function GET(_request: NextRequest, { params }: { params: Promise<{ }; } - // 비밀댓글 마스킹: 작성자/포스트작성자/관리자가 아니면 내용 숨김 + // 비밀댓글 마스킹: 작성자/포스트작성자/부모댓글작성자/관리자가 아니면 내용 숨김 const isSecretComment = row.isSecret ?? false; + const parentComment = row.parentId ? rows.find((r) => r.id === row.parentId) : null; const shouldMask = isSecretComment && row.memberId !== auth.memberId && post?.memberId !== auth.memberId && + parentComment?.memberId !== auth.memberId && !auth.isAdmin; if (shouldMask) { diff --git a/packages/web/src/app/api/posts/manual/route.ts b/packages/web/src/app/api/posts/manual/route.ts index 7536584..7ab7c31 100644 --- a/packages/web/src/app/api/posts/manual/route.ts +++ b/packages/web/src/app/api/posts/manual/route.ts @@ -6,6 +6,7 @@ import { createClient } from '@/lib/supabase/server'; import { errorResponse, Errors, successResponse } from '@/lib/api-error'; import { isSafeUrl } from '@/lib/rss-detect'; import { sendDiscordChannelMessage } from '@/lib/discord-notify'; +import { logNotification } from '@/lib/notification-log'; import { sendPushToMembers } from '@/lib/push'; import { decodeHtmlEntities } from '@/lib/sanitize'; @@ -312,7 +313,7 @@ export async function POST(request: NextRequest) { const postUrl = `https://kusting-web.vercel.app/posts/${newPost!.id}`; - await sendDiscordChannelMessage({ + const discordResult = await sendDiscordChannelMessage({ channelId, content: `<@${member.discordId}>님이 새 글을 발행했습니다! 🎉`, embeds: [ @@ -349,6 +350,15 @@ export async function POST(request: NextRequest) { }, ], }); + await logNotification({ + source: 'web', + type: 'post_register', + channelId, + summary: `수동등록: ${title!.slice(0, 100)}`, + messageId: discordResult.messageId, + status: discordResult.success ? 'sent' : 'failed', + errorMessage: discordResult.error, + }); } catch (e) { console.error('[manual-post] Discord 알림 전송 실패:', e); } diff --git a/packages/web/src/app/api/rounds/route.ts b/packages/web/src/app/api/rounds/route.ts index 01532db..dbea96d 100644 --- a/packages/web/src/app/api/rounds/route.ts +++ b/packages/web/src/app/api/rounds/route.ts @@ -45,16 +45,17 @@ export async function GET(request: NextRequest) { } const now = new Date(); - const endDate = new Date(currentRound.endDate); - const endOfDeadline = new Date(endDate); - endOfDeadline.setHours(23, 59, 59, 999); - const graceEndDate = new Date(currentRound.graceEndDate); - const endOfGrace = new Date(graceEndDate); - endOfGrace.setHours(23, 59, 59, 999); + const endOfDeadline = new Date(`${currentRound.endDate}T23:59:59.999+09:00`); + const endOfGrace = new Date(`${currentRound.graceEndDate}T23:59:59.999+09:00`); - // Calculate days remaining (마감일 당일 23:59:59 기준) - const timeDiff = endOfDeadline.getTime() - now.getTime(); - const daysRemaining = Math.ceil(timeDiff / (1000 * 60 * 60 * 24)); + // KST 캘린더 날짜 기준 D-Day 계산 (당일 = D-Day = 0) + const kstNow = new Date(now.getTime() + 9 * 60 * 60 * 1000); + const todayStr = kstNow.toISOString().split('T')[0]!; + const todayMidnight = new Date(`${todayStr}T00:00:00+09:00`); + const endMidnight = new Date(`${currentRound.endDate}T00:00:00+09:00`); + const daysRemaining = Math.round( + (endMidnight.getTime() - todayMidnight.getTime()) / (1000 * 60 * 60 * 24) + ); // 지각: 마감일 다음 날부터 ~ 지각 마감일 23:59:59까지 const isGracePeriod = now > endOfDeadline && now <= endOfGrace; diff --git a/packages/web/src/lib/discord-notify.ts b/packages/web/src/lib/discord-notify.ts index d1f9195..bdf26a4 100644 --- a/packages/web/src/lib/discord-notify.ts +++ b/packages/web/src/lib/discord-notify.ts @@ -60,16 +60,18 @@ function escapeDiscordMarkdown(text: string): string { */ export async function sendDiscordChannelMessage( options: SendChannelMessageOptions -): Promise { +): Promise<{ success: boolean; messageId?: string; error?: string }> { const token = process.env.DISCORD_TOKEN; if (!token) { - console.error('[discord-notify] DISCORD_TOKEN이 설정되지 않았습니다.'); - return false; + const error = 'DISCORD_TOKEN이 설정되지 않았습니다.'; + console.error('[discord-notify]', error); + return { success: false, error }; } if (!SNOWFLAKE_RE.test(options.channelId)) { - console.error('[discord-notify] 유효하지 않은 channelId:', options.channelId); - return false; + const error = `유효하지 않은 channelId: ${options.channelId}`; + console.error('[discord-notify]', error); + return { success: false, error }; } try { @@ -91,14 +93,17 @@ export async function sendDiscordChannelMessage( ); if (!response.ok) { - console.error(`[discord-notify] 메시지 전송 실패 (${response.status})`); - return false; + const error = `메시지 전송 실패 (${response.status})`; + console.error('[discord-notify]', error); + return { success: false, error }; } - return true; + const data = await response.json() as { id?: string }; + return { success: true, messageId: data.id }; } catch (error) { + const message = error instanceof Error ? error.message : String(error); console.error('[discord-notify] 메시지 전송 중 오류:', error); - return false; + return { success: false, error: message }; } } @@ -143,7 +148,7 @@ export async function notifyNewMemberPendingApproval(opts: { }); } - return sendDiscordChannelMessage({ + const result = await sendDiscordChannelMessage({ channelId, embeds: [ { @@ -156,4 +161,5 @@ export async function notifyNewMemberPendingApproval(opts: { }, ], }); + return result.success; } diff --git a/packages/web/src/lib/notification-log-config.ts b/packages/web/src/lib/notification-log-config.ts new file mode 100644 index 0000000..4ed331e --- /dev/null +++ b/packages/web/src/lib/notification-log-config.ts @@ -0,0 +1,116 @@ +export const NotificationLogType = { + // Bot Channel + ROUND_REPORT: 'round_report', + ROUND_START: 'round_start', + WEEKLY_RANKING: 'weekly_ranking', + CURATION: 'curation', + NEW_POST: 'new_post', + FINE_PAYMENT: 'fine_payment', + // Bot DM + DEADLINE_REMINDER: 'deadline_reminder', + FINE_NOTIFICATION: 'fine_notification', + FINE_REMINDER: 'fine_reminder', + GRACE_NUDGE: 'grace_nudge', + POLL_REMINDER: 'poll_reminder', + // Web Channel + BOARD_NOTICE: 'board_notice', + POST_REGISTER: 'post_register', + MEMBER_APPROVAL: 'member_approval', + ANNOUNCEMENT: 'announcement', +} as const; + +export type NotificationLogTypeValue = (typeof NotificationLogType)[keyof typeof NotificationLogType]; + +export interface NotificationLogTypeMeta { + label: string; + color: string; + isDM: boolean; +} + +export const notificationLogTypeConfig: Record = { + round_report: { + label: '회차 리포트', + color: 'bg-indigo-100 text-indigo-800 dark:bg-indigo-900/30 dark:text-indigo-400', + isDM: false, + }, + round_start: { + label: '회차 시작', + color: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400', + isDM: false, + }, + weekly_ranking: { + label: '주간 랭킹', + color: 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400', + isDM: false, + }, + curation: { + label: '큐레이션', + color: 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400', + isDM: false, + }, + new_post: { + label: '새 글 알림', + color: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400', + isDM: false, + }, + fine_payment: { + label: '벌금 확인', + color: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400', + isDM: false, + }, + deadline_reminder: { + label: '마감 리마인더', + color: 'bg-rose-100 text-rose-800 dark:bg-rose-900/30 dark:text-rose-400', + isDM: true, + }, + fine_notification: { + label: '벌금 알림', + color: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400', + isDM: true, + }, + fine_reminder: { + label: '벌금 독촉', + color: 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-400', + isDM: true, + }, + grace_nudge: { + label: '지각 독촉', + color: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400', + isDM: true, + }, + poll_reminder: { + label: '투표 리마인더', + color: 'bg-cyan-100 text-cyan-800 dark:bg-cyan-900/30 dark:text-cyan-400', + isDM: true, + }, + board_notice: { + label: '게시판 공지', + color: 'bg-sky-100 text-sky-800 dark:bg-sky-900/30 dark:text-sky-400', + isDM: false, + }, + post_register: { + label: '수동 등록', + color: 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-400', + isDM: false, + }, + member_approval: { + label: '가입 승인', + color: 'bg-teal-100 text-teal-800 dark:bg-teal-900/30 dark:text-teal-400', + isDM: false, + }, + announcement: { + label: '공지 알림', + color: 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-400', + isDM: false, + }, +}; + +const fallbackMeta: NotificationLogTypeMeta = { + label: '알 수 없음', + color: 'bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-400', + isDM: false, +}; + +export function getLogTypeMeta(type: string): NotificationLogTypeMeta { + return notificationLogTypeConfig[type as NotificationLogTypeValue] ?? fallbackMeta; +} diff --git a/packages/web/src/lib/notification-log.ts b/packages/web/src/lib/notification-log.ts new file mode 100644 index 0000000..0abc492 --- /dev/null +++ b/packages/web/src/lib/notification-log.ts @@ -0,0 +1,37 @@ +import { db as sharedDb } from '@blog-study/shared'; +import { db } from '@/lib/db'; + +const { discordNotificationLogs } = sharedDb; + +interface LogNotificationParams { + source: 'bot' | 'web'; + type: string; + channelId?: string; + channelName?: string; + targetDiscordId?: string; + messageId?: string; + summary: string; + metadata?: Record; + status: 'sent' | 'failed'; + errorMessage?: string; +} + +export async function logNotification(params: LogNotificationParams): Promise { + try { + const database = db(); + await database.insert(discordNotificationLogs).values({ + source: params.source, + type: params.type, + channelId: params.channelId ?? null, + channelName: params.channelName ?? null, + targetDiscordId: params.targetDiscordId ?? null, + messageId: params.messageId ?? null, + summary: params.summary.slice(0, 500), + metadata: params.metadata ?? {}, + status: params.status, + errorMessage: params.errorMessage ?? null, + }); + } catch (err) { + console.error('[notification-log] DB 저장 실패:', err); + } +}