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);
+ }
+}