Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 2 additions & 4 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,16 @@ DISCORD_CLIENT_SECRET=your_discord_client_secret
DISCORD_GUILD_ID=your_discord_guild_id
ADMIN_DISCORD_IDS=discord_id_1,discord_id_2

# Error Reporting (optional)
DISCORD_ERROR_WEBHOOK_URL=https://discord.com/api/webhooks/...

# Supabase Configuration
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key
SUPABASE_SERVICE_KEY=your_supabase_service_key
DATABASE_URL=postgresql://user:password@host:6543/database
DATABASE_URL_DIRECT=postgresql://user:password@host:5432/database

# Sentry (web only — SENTRY_AUTH_TOKEN은 Vercel/CI에서만 설정, 로컬 불필요)
# Sentry (에러 모니터링)
NEXT_PUBLIC_SENTRY_DSN=https://your-dsn@o123.ingest.us.sentry.io/456
SENTRY_DSN=https://your-dsn@o123.ingest.us.sentry.io/456
# SENTRY_AUTH_TOKEN=sntrys_xxx (CI/Vercel 환경변수로만 설정)

# Application
Expand Down
4 changes: 3 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ deploy/
| 영역 | 기술 |
|------|------|
| Runtime | Node.js 22, TypeScript 5.x |
| Bot | discord.js v14, feedsmith (RSS 파서), pg-boss (PostgreSQL 잡 큐) |
| Bot | discord.js v14, feedsmith (RSS 파서), pg-boss (PostgreSQL 잡 큐), Sentry (에러 모니터링) |
| Web | Next.js 16 App Router, React 19, shadcn/ui, Tailwind CSS v4, Tiptap (리치 에디터), sonner (토스트), Framer Motion (랜딩 애니메이션), Sentry (에러 모니터링) |
| DB | Supabase PostgreSQL + Drizzle ORM (Transaction Pooler, `prepare: false`) |
| Auth | Supabase Auth (Discord OAuth) + `@supabase/ssr` |
Expand Down Expand Up @@ -78,6 +78,7 @@ pnpm --filter @blog-study/bot rss-collect # 수동 RSS 수집 (봇 없이)
| `packages/web/src/lib/rss-detect.ts` | 블로그 URL → RSS URL 자동 감지 |
| `packages/web/src/app/(user)/layout.tsx` | 사용자 레이아웃 (상태 체크 + 리다이렉트) |
| `packages/web/src/app/` | Next.js 페이지/라우트 |
| `packages/bot/src/lib/sentry.ts` | 봇 Sentry SDK 초기화 (PII 스크러빙, DB URL/토큰 마스킹) |
| `packages/bot/src/bot.ts` | Discord 클라이언트 초기화 (이벤트 핸들러만) |
| `packages/bot/src/job-queue.ts` | pg-boss 싱글톤 (시작/종료/조회) |
| `packages/bot/src/scheduler-registry.ts` | 잡 등록 + RSS→Post→Notification 파이프라인 |
Expand Down Expand Up @@ -199,6 +200,7 @@ pnpm --filter @blog-study/bot rss-collect # 수동 RSS 수집 (봇 없이)
- `DISCORD_TOKEN`, `DISCORD_CLIENT_ID`, `DISCORD_CLIENT_SECRET`, `DISCORD_GUILD_ID`
- `ADMIN_DISCORD_IDS` (관리자 Discord ID, 쉼표 구분)
- `NEXT_PUBLIC_SENTRY_DSN` (Sentry 에러 모니터링, web 전용)
- `SENTRY_DSN` (Sentry 에러 모니터링, bot 전용)
- `SENTRY_AUTH_TOKEN` (소스맵 업로드, Vercel/CI에서만 설정)

**env 파일 위치** (2곳):
Expand Down
11 changes: 8 additions & 3 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Blog Study Admin - 시스템 아키텍처

> 최종 업데이트: 2026-03-13 (v5)
> 최종 업데이트: 2026-03-13 (v6)

블로그 글쓰기 스터디 운영 자동화 플랫폼. 웹 대시보드에서 모든 관리/유저 기능을 제공하고, Discord 봇은 스케줄러(RSS 수집/출석/벌금/큐레이션)와 이벤트 핸들러만 담당한다.

Expand Down Expand Up @@ -35,6 +35,7 @@ graph TB
SCH["Schedulers<br/>pg-boss"]
EVT["Event Handlers<br/>discord.js v14"]
SVC["Service Layer<br/>RSS · Fine · Curation · Score"]
SENTRY_BOT["Sentry SDK<br/>에러 모니터링 + PII 스크러빙"]
end

subgraph DB["Supabase · PostgreSQL"]
Expand Down Expand Up @@ -71,6 +72,7 @@ graph TB
API --> TABLES
PAGES --> API
SENTRY_WEB -->|tunnel /api/_sentry-tunnel| SENTRY
SENTRY_BOT -->|HTTPS| SENTRY
SENTRY -->|Alert| DISCORD_WH
```

Expand All @@ -83,6 +85,7 @@ mindmap
discord.js v14
pg-boss Job Queue
feedsmith RSS
Sentry 에러 모니터링
Web
Next.js 16 App Router
React 19
Expand Down Expand Up @@ -437,7 +440,8 @@ erDiagram
| **API 성공** | `successResponse(data, message?, status?)` 통일 | `lib/api-error.ts` |
| **캐시** | `withCache(response, maxAge, scope?)` — 읽기 API에 Cache-Control 적용 | `lib/api-error.ts` |
| **클라이언트 에러** | Error Boundary → `Sentry.captureException()` — 사용자/관리자/전역 | `(user)/error.tsx`, `(admin)/error.tsx`, `global-error.tsx` |
| **에러 모니터링** | Sentry SDK (DSN 가드, `beforeSend` PII 스크러빙, tunnel route) | `sentry.*.config.ts`, `instrumentation.ts`, `next.config.ts` |
| **웹 에러 모니터링** | Sentry SDK (DSN 가드, `beforeSend` PII 스크러빙, tunnel route) | `sentry.*.config.ts`, `instrumentation.ts`, `next.config.ts` |
| **봇 에러 모니터링** | Sentry Node SDK (`sendDefaultPii: false`, DB URL/토큰 마스킹) | `bot/src/lib/sentry.ts` |
| **404** | 커스텀 Not Found 페이지 | `not-found.tsx` |
| **사용자 피드백** | sonner 토스트 (`toast.success()`, `toast.error()`) | `layout.tsx` (`<Toaster />`) |

Expand Down Expand Up @@ -502,4 +506,5 @@ graph LR
| Tiptap | 3.20 | Rich text editor |
| shadcn/ui | latest | UI components |
| Framer Motion | 12.x | Landing page animations |
| @sentry/nextjs | 10.43 | Error monitoring + source maps |
| @sentry/nextjs | 10.43 | Web error monitoring + source maps |
| @sentry/node | 10.43 | Bot error monitoring |
1 change: 1 addition & 0 deletions packages/bot/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
},
"dependencies": {
"@blog-study/shared": "workspace:*",
"@sentry/node": "^10.43.0",
"@types/express": "^5.0.6",
"@types/express-rate-limit": "^6.0.2",
"axios": "^1.13.5",
Expand Down
9 changes: 9 additions & 0 deletions packages/bot/src/api-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import express, { type Express } from 'express';
import rateLimit from 'express-rate-limit';
import logger from './lib/logger';
import { Sentry } from './lib/sentry';
import {
getRssPoller,
getAttendanceChecker,
Expand Down Expand Up @@ -47,6 +48,7 @@ export function createBotApiServer(): Express {
const result = await rssPoller.poll();
res.json({ success: true, result });
} catch (error) {
Sentry.captureException(error);
logger.error({ error }, '[API] RSS poll error');
res.status(500).json({
error: error instanceof Error ? error.message : '알 수 없는 오류'
Expand All @@ -65,6 +67,7 @@ export function createBotApiServer(): Express {
const result = await attendanceChecker.check();
res.json({ success: true, result });
} catch (error) {
Sentry.captureException(error);
logger.error({ error }, '[API] Attendance check error');
res.status(500).json({
error: error instanceof Error ? error.message : '알 수 없는 오류'
Expand All @@ -83,6 +86,7 @@ export function createBotApiServer(): Express {
const result = await fineReminder.sendReminders();
res.json({ success: true, result });
} catch (error) {
Sentry.captureException(error);
logger.error({ error }, '[API] Fine reminder error');
res.status(500).json({
error: error instanceof Error ? error.message : '알 수 없는 오류'
Expand All @@ -101,6 +105,7 @@ export function createBotApiServer(): Express {
const result = await roundReporter.sendRoundReport();
res.json({ success: true, result });
} catch (error) {
Sentry.captureException(error);
logger.error({ error }, '[API] Round report error');
res.status(500).json({
error: error instanceof Error ? error.message : '알 수 없는 오류'
Expand All @@ -114,6 +119,7 @@ export function createBotApiServer(): Express {
const result = await roundReporter.sendRoundStartAnnouncement();
res.json({ success: true, result });
} catch (error) {
Sentry.captureException(error);
logger.error({ error }, '[API] Round start error');
res.status(500).json({
error: error instanceof Error ? error.message : '알 수 없는 오류'
Expand All @@ -132,6 +138,7 @@ export function createBotApiServer(): Express {
const result = await curationCrawler.crawl();
res.json({ success: true, result });
} catch (error) {
Sentry.captureException(error);
logger.error({ error }, '[API] Curation crawl error');
res.status(500).json({
error: error instanceof Error ? error.message : '알 수 없는 오류'
Expand All @@ -150,6 +157,7 @@ export function createBotApiServer(): Express {
const result = await curationCrawler.shareDailyContent();
res.json({ success: true, result });
} catch (error) {
Sentry.captureException(error);
logger.error({ error }, '[API] Curation share error');
res.status(500).json({
error: error instanceof Error ? error.message : '알 수 없는 오류'
Expand Down Expand Up @@ -177,6 +185,7 @@ export function createBotApiServer(): Express {

res.json({ success: true, result: serializedResult });
} catch (error) {
Sentry.captureException(error);
logger.error({ error }, '[API] Weekly ranking error');
res.status(500).json({
error: error instanceof Error ? error.message : '알 수 없는 오류'
Expand Down
14 changes: 4 additions & 10 deletions packages/bot/src/bot.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Client, Events, GatewayIntentBits, } from 'discord.js';
import logger, { serializeError } from './lib/logger';
import { reportError } from './lib/error-webhook';
import { Sentry } from './lib/sentry';

/**
* Create and configure the Discord bot client
Expand Down Expand Up @@ -34,17 +34,10 @@ export function setupEventHandlers(client: Client): void {
});

// Error handling
client.on(Events.Error, async (error) => {
client.on(Events.Error, (error) => {
const errorObj = error instanceof Error ? error : new Error(String(error));
logger.error({ error: serializeError(errorObj) }, 'Discord client error');

// Send critical error notification to Discord webhook
await reportError(errorObj, {
location: 'Discord.Client',
event: 'error',
}).catch(() => {
// Ignore webhook errors
});
Sentry.captureException(errorObj);
});

client.on(Events.Warn, (warning) => {
Expand Down Expand Up @@ -74,6 +67,7 @@ export function setupGracefulShutdown(client: Client, onShutdown?: () => Promise
await onShutdown();
}
client.destroy();
await Sentry.flush(2000);
process.exit(0);
};

Expand Down
26 changes: 8 additions & 18 deletions packages/bot/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
// @blog-study/bot
// 큐스팅 4th 디스코드 봇 엔트리포인트

import { loadBotEnv, getErrorWebhookUrl } from '@blog-study/shared';
// Sentry must be initialized before anything else
import './lib/sentry';

import { loadBotEnv } from '@blog-study/shared';
import { createBotClient, setupEventHandlers, setupGracefulShutdown, startBot } from './bot';
import { startJobQueue, stopJobQueue } from './job-queue';
import { registerAllJobs } from './scheduler-registry';
Expand All @@ -10,7 +13,7 @@ import { setupDMHandler } from './handlers/dm-handler';
import { initNotificationService } from './services/notification.service';
import { startBotApiServer } from './api-server';
import logger, { serializeError } from './lib/logger';
import { initErrorWebhook, reportError } from './lib/error-webhook';
import { Sentry } from './lib/sentry';

async function main(): Promise<void> {
logger.info('Blog Study Discord Bot starting...');
Expand All @@ -19,13 +22,6 @@ async function main(): Promise<void> {
const env = loadBotEnv();
logger.info('Environment variables loaded');

// Initialize error webhook if configured
const errorWebhookUrl = getErrorWebhookUrl();
if (errorWebhookUrl) {
initErrorWebhook(errorWebhookUrl);
logger.info('Error webhook initialized');
}

// Create bot client
const client = createBotClient();
logger.debug('Bot client created');
Expand Down Expand Up @@ -58,18 +54,12 @@ async function main(): Promise<void> {
await startBot(client, env.DISCORD_TOKEN);
}

main().catch((error) => {
main().catch(async (error) => {
const errorObj = error instanceof Error ? error : new Error(String(error));

logger.error({ error: serializeError(errorObj) }, 'Failed to start bot');
Sentry.captureException(errorObj);

// Send error notification to Discord webhook
reportError(errorObj, {
location: 'main()',
phase: 'bot-startup',
}).catch(() => {
// Ignore webhook errors during startup
});

await Sentry.flush(2000);
process.exit(1);
});
6 changes: 5 additions & 1 deletion packages/bot/src/job-queue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

import { PgBoss } from 'pg-boss';
import logger from './lib/logger';
import { Sentry } from './lib/sentry';

let boss: PgBoss | null = null;

Expand All @@ -16,7 +17,10 @@ export async function startJobQueue(connectionString: string): Promise<PgBoss> {
// Simple connection string - use default pg-boss settings
boss = new PgBoss(connectionString);

boss.on('error', (error: Error) => logger.error({ error }, '[pg-boss] Error'));
boss.on('error', (error: Error) => {
logger.error({ error }, '[pg-boss] Error');
Sentry.captureException(error);
});
await boss.start();
logger.info('[pg-boss] Started');

Expand Down
77 changes: 0 additions & 77 deletions packages/bot/src/lib/error-webhook.ts

This file was deleted.

Loading
Loading