diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..3247d64 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,16 @@ +{ + "mcpServers": { + "playwright": { + "command": "npx", + "args": [ + "@playwright/mcp@latest", + "--headless" + ] + } + }, + "permissions": { + "allow": [ + "Bash" + ] + } +} diff --git a/.claude/skills/events-handler/config.json b/.claude/skills/events-handler/config.json new file mode 100644 index 0000000..1247ceb --- /dev/null +++ b/.claude/skills/events-handler/config.json @@ -0,0 +1,15 @@ +{ + "name": "events-handler", + "description": "Discord.js 이벤트 처리 참고 (현재 프로젝트 패턴 기반)", + "version": "1.0.0", + "author": "Blog Study", + "tags": ["discord", "events", "handlers", "reference"], + "keywords": ["discord.js", "events", "handlers", "patterns", "examples"], + "category": "reference", + "patterns": [ + "event-handler-registration", + "error-handling", + "message-handling", + "reaction-handling" + ] +} diff --git a/.claude/skills/events-handler/prompt.md b/.claude/skills/events-handler/prompt.md new file mode 100644 index 0000000..610da10 --- /dev/null +++ b/.claude/skills/events-handler/prompt.md @@ -0,0 +1,149 @@ +# Events Handler Reference + +Discord.js v14 이벤트 처리 예시 모음 - 현재 프로젝트 패턴 기반 + +## 사용 방법 +새로운 이벤트 핸들러 구현 시 참고 + +## 기본 패턴 (현재 프로젝트 방식) + +```typescript +import type { Client, Message } from 'discord.js'; +import { Events } from 'discord.js'; + +/** + * 커스텀 이벤트 핸들러 등록 + */ +export function setupCustomHandler(client: Client): void { + // 메시지 생성 이벤트 + client.on(Events.MessageCreate, async (message: Message) => { + try { + // 봇 메시지 무시 + if (message.author.bot) return; + + // DM 무시 + if (!message.guild) return; + + // 처리 로직 + console.log(`[CustomHandler] Message from ${message.author.id}`); + } catch (error) { + console.error('[CustomHandler] Error:', error); + } + }); + + console.log('✅ Custom handler registered'); +} +``` + +## 주요 이벤트 타입 + +### 1. 봇 시작 (ClientReady) +```typescript +client.once(Events.ClientReady, (readyClient) => { + console.log(`✅ Bot logged in as ${readyClient.user.tag}`); + console.log(`📊 Serving ${readyClient.guilds.cache.size} guild(s)`); +}); +``` + +### 2. 메시지 생성 (MessageCreate) +```typescript +client.on(Events.MessageCreate, async (message: Message) => { + if (message.author.bot) return; + if (!message.guild) return; + + // 메시지 처리 +}); +``` + +### 3. 리액션 추가 (MessageReactionAdd) +```typescript +client.on(Events.MessageReactionAdd, async (reaction, user) => { + if (user.bot) return; + + // partial인 경우 fetch + if (reaction.partial) { + await reaction.fetch(); + } + + // 리액션 처리 +}); +``` + +### 4. 리액션 제거 (MessageReactionRemove) +```typescript +client.on(Events.MessageReactionRemove, async (reaction, user) => { + if (user.bot) return; + + // 리액션 제거 처리 +}); +``` + +### 5. 음성 상태 변경 (VoiceStateUpdate) +```typescript +client.on(Events.VoiceStateUpdate, (oldState, newState) => { + // 음성 채널 입장/퇴장 처리 +}); +``` + +### 6. 멤버 입장 (GuildMemberAdd) +```typescript +client.on(Events.GuildMemberAdd, (member) => { + // 새 멤버 환영 +}); +``` + +### 7. 에러 처리 (Error) +```typescript +client.on(Events.Error, (error) => { + console.error('❌ Discord client error:', error); +}); +``` + +## 등록 방법 (index.ts) + +```typescript +import { createBotClient, setupEventHandlers } from './bot'; +import { setupActivityHandler } from './handlers/activity-handler'; +import { setupCustomHandler } from './handlers/custom-handler'; + +async function main(): Promise { + const client = createBotClient(); + + // 기본 이벤트 핸들러 + setupEventHandlers(client); + + // 활동 점수 핸들러 + setupActivityHandler(client); + + // 커스텀 핸들러 + setupCustomHandler(client); + + await startBot(client, env.DISCORD_TOKEN); +} +``` + +## 에러 처리 패턴 + +모든 이벤트 핸들러는 try-catch로 감싸야 합니다: + +```typescript +client.on(Events.SomeEvent, async (...args) => { + try { + // 이벤트 처리 로직 + } catch (error) { + console.error('[HandlerName] Error:', error); + // 필요시 로깅 또는 알림 + } +}); +``` + +## 프로젝트에서 사용 중인 핸들러 + +- `setupEventHandlers()` - 기본 이벤트 (ready, error, warn) +- `setupActivityHandler()` - 활동 점수 (message, reaction) +- `setupDMHandler()` - DM 처리 (벌금 납부 확인) + +## 참고 + +- Discord.js v14.25.1 Events: https://discord.js.org/docs/packages/discord.js/14.25.1/Classes/Client +- 현재 프로젝트: `packages/bot/src/handlers/` 참고 diff --git a/.claude/skills/scheduler-reference/config.json b/.claude/skills/scheduler-reference/config.json new file mode 100644 index 0000000..b456709 --- /dev/null +++ b/.claude/skills/scheduler-reference/config.json @@ -0,0 +1,15 @@ +{ + "name": "scheduler-reference", + "description": "pg-boss 스케줄러 구현 참고 (현재 프로젝트 패턴 기반)", + "version": "1.0.0", + "author": "Blog Study", + "tags": ["scheduler", "pg-boss", "cron", "jobs", "reference"], + "keywords": ["pg-boss", "scheduler", "cron", "worker", "singleton"], + "category": "reference", + "patterns": [ + "singleton-pattern", + "isRunning-flag", + "cron-scheduling", + "worker-registration" + ] +} diff --git a/.claude/skills/scheduler-reference/prompt.md b/.claude/skills/scheduler-reference/prompt.md new file mode 100644 index 0000000..474419c --- /dev/null +++ b/.claude/skills/scheduler-reference/prompt.md @@ -0,0 +1,183 @@ +# Scheduler Reference + +pg-boss 기반 스케줄러 구현 참고 - 현재 프로젝트 패턴 기반 + +## 사용 방법 +새로운 스케줄러 구현 시 참고 + +## 기본 패턴 + +```typescript +/** + * 스케줄러 클래스 템플릿 + */ +export class MyScheduler { + private isRunning = false; + + /** + * 실행 중인지 확인 + */ + isChecking(): boolean { + return this.isRunning; + } + + /** + * 작업 실행 + */ + async run(): Promise { + if (this.isRunning) { + console.log('[MyScheduler] Already in progress, skipping'); + return { + timestamp: new Date(), + errors: ['Already in progress'], + }; + } + + this.isRunning = true; + + try { + // 작업 로직 구현 + console.log('[MyScheduler] Running...'); + + return { + timestamp: new Date(), + success: true, + }; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + console.error(`[MyScheduler] Error: ${errorMsg}`); + + return { + timestamp: new Date(), + errors: [errorMsg], + }; + } finally { + this.isRunning = false; + } + } +} + +// Singleton 인스턴스 +let instance: MyScheduler | null = null; + +export function getMyScheduler(): MyScheduler { + if (!instance) { + instance = new MyScheduler(); + } + return instance; +} + +export function resetMyScheduler(): void { + instance = null; +} +``` + +## scheduler-registry.ts에 등록 + +```typescript +import { getMyScheduler } from './schedulers/my-scheduler'; + +export async function registerAllJobs(boss: PgBoss, client: Client): Promise { + const myScheduler = getMyScheduler(); + + // 1. Cron 잡 등록 (매일 09:00) + await boss.schedule('my-job', '0 9 * * *'); + console.log(' 📅 Scheduled: my-job (0 9 * * *)'); + + // 2. 워커 등록 + await boss.work('my-job', { batchSize: 1 }, async () => { + await myScheduler.run(); + }); + + console.log(`✅ All jobs registered`); +} +``` + +## Discord 클라이언트가 필요한 경우 + +```typescript +export class MyScheduler { + private client: Client | null = null; + + setClient(client: Client): void { + this.client = client; + } + + async run(): Promise { + if (!this.client) { + console.error('[MyScheduler] Discord client not set'); + return { + timestamp: new Date(), + errors: ['Discord client not set'], + }; + } + + // client 사용 + const channel = await this.client.channels.fetch('CHANNEL_ID'); + // ... + } +} + +// 등록 시 +const myScheduler = getMyScheduler(); +myScheduler.setClient(client); +``` + +## Cron 표현식 예시 + +| 표현식 | 설명 | +|---------|------| +| `*/5 * * * *` | 5분마다 | +| `0 9 * * *` | 매일 09:00 | +| `0 0 * * 2` | 매주 화요일 00:00 | +| `0 23 * * *` | 매일 23:00 (KST 익일 08:00) | +| `0 0 1 * *` | 매월 1일 00:00 | + +## pg-boss 설정 + +```typescript +import { PgBoss } from 'pg-boss'; + +// 시작 +const boss = new PgBoss(connectionString); +boss.on('error', (error: Error) => console.error('[pg-boss] Error:', error)); +await boss.start(); + +// Cron 잡 등록 +await boss.schedule('job-name', 'cron-expression'); + +// 워커 등록 +await boss.work('job-name', { batchSize: 1 }, async () => { + // 작업 실행 +}); + +// 종료 +await boss.stop({ graceful: true, timeout: 30000 }); +``` + +## 결과 인터페이스 + +```typescript +interface Result { + timestamp: Date; + success?: boolean; + errors: string[]; + // 기타 필요한 필드 +} +``` + +## 프로젝트 예시 참고 + +- `rss-poller.ts` - RSS 폴링 (5분) +- `attendance-checker.ts` - 출석 체크 (주간) +- `fine-reminder.ts` - 벌금 리마인더 (일일) +- `round-reporter.ts` - 회차 리포트 (주간) +- `curation-crawler.ts` - 큐레이션 크롤링 (일일) + +## 주의사항 + +1. **동시 실행 방지**: `isRunning` 플래그로 중복 실행 방지 +2. **에러 처리**: 모든 에러를 catch하고 결과 객체에 포함 +3. **로그**: 진행 상황을 console.log로 출력 +4. **Singleton**: 한 인스턴스만 사용 (`getXxx()` 함수) +5. **테스트용 reset**: `resetXxx()` 함수 제공 diff --git a/.claude/skills/service-pattern/config.json b/.claude/skills/service-pattern/config.json new file mode 100644 index 0000000..db51ca9 --- /dev/null +++ b/.claude/skills/service-pattern/config.json @@ -0,0 +1,16 @@ +{ + "name": "service-pattern", + "description": "DB 서비스 계층 구현 참고 (현재 프로젝트 패턴 기반)", + "version": "1.0.0", + "author": "Blog Study", + "tags": ["service", "database", "drizzle", "orm", "reference"], + "keywords": ["drizzle-orm", "service-layer", "crud", "singleton", "repository"], + "category": "reference", + "patterns": [ + "singleton-pattern", + "error-handling", + "crud-operations", + "complex-queries", + "transactions" + ] +} diff --git a/.claude/skills/service-pattern/prompt.md b/.claude/skills/service-pattern/prompt.md new file mode 100644 index 0000000..1b580c5 --- /dev/null +++ b/.claude/skills/service-pattern/prompt.md @@ -0,0 +1,252 @@ +# Service Pattern Reference + +DB 서비스 계층 구현 참고 - 현재 프로젝트 패턴 기반 + +## 사용 방법 +새로운 서비스 구현 시 참고 + +## 기본 패턴 + +```typescript +import { eq } from 'drizzle-orm'; +import { getDb, myTable, type NewMyTable, type MyTable } from '@blog-study/shared/db'; + +/** + * 서비스 클래스 템플릿 + */ +export class MyService { + private db = getDb(); + + /** + * 생성 + */ + async create(input: CreateInput): Promise { + const newItem: NewMyTable = { + // 필드 매핑 + }; + + const [created] = await this.db.insert(myTable).values(newItem).returning(); + return created!; + } + + /** + * ID로 조회 + */ + async getById(id: string): Promise { + const [item] = await this.db + .select() + .from(myTable) + .where(eq(myTable.id, id)) + .limit(1); + + return item || null; + } + + /** + * 전체 조회 + */ + async getAll(): Promise { + return this.db.select().from(myTable); + } + + /** + * 업데이트 + */ + async update(id: string, data: Partial): Promise { + const [updated] = await this.db + .update(myTable) + .set(data) + .where(eq(myTable.id, id)) + .returning(); + + return updated!; + } + + /** + * 삭제 + */ + async delete(id: string): Promise { + await this.db.delete(myTable).where(eq(myTable.id, id)); + } +} + +// Singleton 인스턴스 +let instance: MyService | null = null; + +export function getMyService(): MyService { + if (!instance) { + instance = new MyService(); + } + return instance; +} + +export function resetMyService(): void { + instance = null; +} +``` + +## 에러 처리 패턴 + +```typescript +/** + * 에러 코드 정의 + */ +export const MyErrorCodes = { + NOT_FOUND: 'E0001', + INVALID_INPUT: 'E0002', + DUPLICATE: 'E0003', +} as const; + +/** + * 커스텀 에러 클래스 + */ +export class MyError extends Error { + constructor( + public code: string, + public userMessage: string, + message?: string + ) { + super(message || userMessage); + this.name = 'MyError'; + } +} + +// 사용 예시 +async getById(id: string): Promise { + const [item] = await this.db + .select() + .from(myTable) + .where(eq(myTable.id, id)) + .limit(1); + + if (!item) { + throw new MyError( + MyErrorCodes.NOT_FOUND, + '항목을 찾을 수 없습니다.' + ); + } + + return item; +} +``` + +## 복잡한 쿼리 패턴 + +```typescript +/** + * 조인 조회 + */ +async getWithMember(id: string): Promise { + const result = await this.db + .select({ + item: myTable, + memberName: members.name, + memberDiscordId: members.discordId, + }) + .from(myTable) + .innerJoin(members, eq(myTable.memberId, members.id)) + .where(eq(myTable.id, id)) + .limit(1); + + if (!result[0]) return null; + + return { + ...result[0].item, + memberName: result[0].memberName, + memberDiscordId: result[0].memberDiscordId, + }; +} + +/** + * 집계 쿼리 + */ +async getStats(): Promise { + const result = await this.db + .select({ + total: sql`COUNT(*)`, + sum: sql`COALESCE(SUM(${myTable.amount}), 0)`, + }) + .from(myTable); + + return { + total: Number(result[0]?.total ?? 0), + sum: Number(result[0]?.sum ?? 0), + }; +} + +/** + * 그룹화 + */ +async groupByMember(): Promise> { + return this.db + .select({ + memberId: myTable.memberId, + count: sql`COUNT(*)`, + }) + .from(myTable) + .groupBy(myTable.memberId); +} +``` + +## 트랜잭션 패턴 + +```typescript +import { db } from '@blog-study/shared/db'; + +async createWithRelated(input: CreateInput): Promise { + return db.transaction(async (tx) => { + // 1. 메인 항목 생성 + const [item] = await tx + .insert(myTable) + .values({ /* ... */ }) + .returning(); + + // 2. 연관 항목 생성 + await tx + .insert(relatedTable) + .values({ /* ... */ }); + + return item; + }); +} +``` + +## Property-Based Test 패턴 + +```typescript +import { describe, it } from 'vitest'; +import { fc } from 'fast-check'; + +describe('MyService Property Tests', () => { + it('Property 1: create generates valid ID', async () => { + await fc.assert( + fc.asyncProperty( + fc.string(), + async (name) => { + const service = getMyService(); + const result = await service.create({ name }); + + return !!result.id; + } + ), + { numRuns: 100 } + ); + }); +}); +``` + +## 프로젝트 예시 참고 + +- `score.service.ts` - 활동 점수 (일일 상한 체크, CTE) +- `post.service.ts` - 블로그 포스트 (중복 체크) +- `attendance.service.ts` - 출석 관리 (상태 전환) +- `fine.service.ts` - 벌금 관리 (납부/면제) +- `notification.service.ts` - 알림 발송 + +## 주의사항 + +1. **Singleton**: 한 인스턴스만 사용 (`getXxx()` 함수) +2. **DB 연결**: `getDb()` 사용 (Transaction Pooler) +3. **타입 안전성**: Drizzle ORM 타입 활용 +4. **에러 처리**: 커스텀 에러 클래스로 사용자 메시지 제공 +5. **테스트**: Property-Based Test 작성 (최소 100회) diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..40c18a7 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,43 @@ +# Dependencies +node_modules +**/node_modules + +# Build output (will be built in container) +**/dist + +# Development files +**/*.test.ts +**/*.spec.ts +**/vitest.config.ts +**/*.tsbuildinfo + +# IDE +.vscode +.idea +.kiro + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log +npm-debug.log* + +# Environment files +.env +.env.* +!.env.example + +# Git +.git +.gitignore + +# Documentation +docs/ + +# Web package (not needed for bot deployment) +packages/web + +# Drizzle migrations (handled separately) +**/drizzle diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..924f014 --- /dev/null +++ b/.env.example @@ -0,0 +1,37 @@ +# Discord Bot Configuration +DISCORD_TOKEN=your_discord_bot_token +DISCORD_CLIENT_ID=your_discord_client_id +DISCORD_CLIENT_SECRET=your_discord_client_secret +DISCORD_GUILD_ID=your_discord_guild_id +ADMIN_DISCORD_IDS=discord_id_1,discord_id_2 + +# 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 (에러 모니터링) +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 환경변수로만 설정) + +# Bot API Authentication (shared secret between web and bot) +BOT_API_SECRET=your_bot_api_secret_here +BOT_API_URL=http://localhost:3001 + +# Internal API (bot → web push notification) +INTERNAL_API_KEY=your_internal_api_key_here +WEB_URL=https://your-web-app.vercel.app + +# Application +APP_URL=http://localhost:3000 +NODE_ENV=development + +# Study Configuration (optional, can be set via admin commands) +STUDY_START_DATE=2024-01-01 +TOTAL_ROUNDS=10 +ANNOUNCEMENT_CHANNEL_ID=channel_id +CURATION_CHANNEL_ID=channel_id +STUDY_ROLE_ID=role_id diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..ed5fab3 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,24 @@ +{ + "root": true, + "env": { + "node": true, + "es2022": true + }, + "parser": "@typescript-eslint/parser", + "parserOptions": { + "ecmaVersion": "latest", + "sourceType": "module" + }, + "plugins": ["@typescript-eslint"], + "extends": [ + "eslint:recommended", + "plugin:@typescript-eslint/recommended" + ], + "rules": { + "@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }], + "@typescript-eslint/explicit-function-return-type": "off", + "@typescript-eslint/no-explicit-any": "warn", + "no-console": ["warn", { "allow": ["warn", "error"] }] + }, + "ignorePatterns": ["dist", "node_modules", ".next", "**/scripts/*.ts"] +} diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..82b4174 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @bbbang105 @choihooo diff --git a/.github/auto-assign-config.yaml b/.github/auto-assign-config.yaml new file mode 100644 index 0000000..340c2d2 --- /dev/null +++ b/.github/auto-assign-config.yaml @@ -0,0 +1,5 @@ +addReviewers: true +addAssignees: author +reviewers: + - bbbang105 + - choihooo diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..f10d166 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,19 @@ +## 🎯 Summary +> 이 PR의 목적을 한 줄로 요약해주세요 +- + +--- + +## 🔴 AS-IS +> 기존 상태 또는 문제점 +- + +## 🟢 TO-BE +> 변경 후 상태 또는 개선점 +- + +--- + +## 💬 참고사항 +> 리뷰어가 알아야 할 내용, 논의 포인트, 주의사항 등 +- diff --git a/.github/workflows/auto-assign.yaml b/.github/workflows/auto-assign.yaml new file mode 100644 index 0000000..d9a9ce3 --- /dev/null +++ b/.github/workflows/auto-assign.yaml @@ -0,0 +1,15 @@ +name: Auto Assign + +on: + pull_request: + types: [opened, ready_for_review] + +jobs: + assign: + runs-on: ubuntu-latest + permissions: + pull-requests: write + steps: + - uses: kentaro-m/auto-assign-action@v2.0.1 + with: + configuration-path: '.github/auto-assign-config.yaml' diff --git a/.github/workflows/bot-deploy.yml b/.github/workflows/bot-deploy.yml new file mode 100644 index 0000000..88e2b02 --- /dev/null +++ b/.github/workflows/bot-deploy.yml @@ -0,0 +1,102 @@ +name: 🤖 Bot Build & Deploy + +on: + push: + branches: [dev] + paths: + - 'packages/bot/**' + - 'packages/shared/**' + - 'packages/bot/Dockerfile' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: bot-deploy-${{ github.ref }} + cancel-in-progress: true + +env: + ECR_REPOSITORY: study-admin-bot + AWS_REGION: ap-northeast-2 + +jobs: + ci: + name: CI Gate + runs-on: ubuntu-latest + steps: + - name: ✅ 코드 체크아웃 + uses: actions/checkout@v4 + + - name: 📦 pnpm 설정 + uses: pnpm/action-setup@v4 + + - name: 🟢 Node.js 설정 + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: 'pnpm' + + - name: 📥 의존성 설치 + run: pnpm install --frozen-lockfile + + - name: 🔨 shared 빌드 + run: pnpm --filter @blog-study/shared build + + - name: 🔍 린트 + run: pnpm --filter @blog-study/bot lint + + - name: 🔎 타입 체크 + run: pnpm --filter @blog-study/bot typecheck + + - name: 🧪 테스트 + run: pnpm --filter @blog-study/bot test + + build-and-push: + name: Build and Push to ECR + needs: ci + runs-on: ubuntu-latest + steps: + - name: ✅ 코드 체크아웃 + uses: actions/checkout@v4 + + - name: 🏗️ Docker Buildx 설정 + uses: docker/setup-buildx-action@v3 + + - name: 🔐 AWS 자격 증명 설정 + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ${{ env.AWS_REGION }} + + - name: 🐳 ECR 로그인 + id: login-ecr + uses: aws-actions/amazon-ecr-login@v2 + + - name: 🐳 Docker 이미지 빌드 및 푸시 (ARM64) + uses: docker/build-push-action@v6 + with: + context: . + file: ./packages/bot/Dockerfile + push: true + platforms: linux/arm64 + tags: | + ${{ steps.login-ecr.outputs.registry }}/${{ env.ECR_REPOSITORY }}:${{ github.sha }} + ${{ steps.login-ecr.outputs.registry }}/${{ env.ECR_REPOSITORY }}:latest + cache-from: type=gha + cache-to: type=gha,mode=max + + deploy: + name: Deploy to EC2 + needs: build-and-push + runs-on: ubuntu-latest + steps: + - name: 🚀 SSH로 배포 스크립트 실행 + uses: appleboy/ssh-action@v1.0.3 + with: + host: ${{ secrets.SSH_HOST }} + username: ${{ secrets.SSH_USER }} + key: ${{ secrets.SSH_PRIVATE_KEY }} + script: | + ~/study-admin-bot/deploy.sh ${{ github.sha }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b6d860a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,117 @@ +name: CI + +on: + pull_request: + branches: + - main + - dev + push: + branches: + - main + - dev + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Run lint + run: pnpm lint + + typecheck: + name: Type Check + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build shared package + run: pnpm --filter @blog-study/shared build + + - name: Run type check + run: pnpm typecheck + + test: + name: Test + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build shared package + run: pnpm --filter @blog-study/shared build + + - name: Run tests + run: pnpm --filter @blog-study/shared test && pnpm --filter @blog-study/bot test + + build: + name: Build + runs-on: ubuntu-latest + needs: [lint, typecheck] + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build + run: pnpm build + env: + NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.NEXT_PUBLIC_SUPABASE_URL }} + NEXT_PUBLIC_SUPABASE_ANON_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }} diff --git a/.github/workflows/commit-labeler.yaml b/.github/workflows/commit-labeler.yaml new file mode 100644 index 0000000..475d16d --- /dev/null +++ b/.github/workflows/commit-labeler.yaml @@ -0,0 +1,46 @@ +name: "Commit Message Labeler" + +on: + pull_request: + types: [opened, synchronize, reopened] + +jobs: + commit-labeler: + runs-on: ubuntu-latest + permissions: + contents: read + issues: write + steps: + - uses: actions/github-script@v7 + with: + github-token: ${{ secrets.PERSONAL_TOKEN }} + script: | + const { owner, repo, number } = context.issue; + const commits = await github.rest.pulls.listCommits({ owner, repo, pull_number: number }); + const labels = new Set(); + + for (const c of commits.data) { + const msg = c.commit.message.toLowerCase(); + if (msg.includes("feat")) labels.add("🚀 feat"); + if (msg.includes("fix")) labels.add("🚨 fix"); + if (msg.includes("docs")) labels.add("📄 docs"); + if (msg.includes("style")) labels.add("🌱 style"); + if (msg.includes("refactor")) labels.add("🔄 refactor"); + if (msg.includes("test")) labels.add("✅ test"); + if (msg.includes("perf")) labels.add("⚡️ perf"); + if (msg.includes("chore")) labels.add("⚒️ chore"); + if (msg.includes("ci")) labels.add("🔧 ci"); + if (msg.includes("hotfix")) labels.add("🛟 hotfix"); + if (msg.includes("release")) labels.add("💫 release"); + if (msg.includes("rename")) labels.add("🎫 rename"); + if (msg.includes("remove")) labels.add("✂️ remove"); + } + + if (labels.size > 0) { + await github.rest.issues.addLabels({ + owner, + repo, + issue_number: number, + labels: Array.from(labels), + }); + } diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..58a7acf --- /dev/null +++ b/.gitignore @@ -0,0 +1,46 @@ +# Dependencies +node_modules +.pnpm-store + +# Build outputs +dist +.next +out + +# Environment variables +.env +.env.local +.env.*.local + +# IDE +.idea +.vscode +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log +npm-debug.log* +pnpm-debug.log* + +# Test coverage +coverage + +# Misc +*.tsbuildinfo +*.pem + +# Firebase Service Account (보안 민감 정보) +firebase-service-account.json + +### CLAUDE ### +#/docs + +# Drizzle migration SQL (로컬 전용, DB에서 직접 실행) +packages/shared/drizzle/*.sql +.vercel +scripts diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..9d348ba --- /dev/null +++ b/.prettierignore @@ -0,0 +1,6 @@ +node_modules +dist +.next +coverage +*.min.js +pnpm-lock.yaml diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..b6b0fde --- /dev/null +++ b/.prettierrc @@ -0,0 +1,10 @@ +{ + "semi": true, + "singleQuote": true, + "tabWidth": 2, + "trailingComma": "es5", + "printWidth": 100, + "bracketSpacing": true, + "arrowParens": "always", + "endOfLine": "lf" +} diff --git a/.serena/.gitignore b/.serena/.gitignore new file mode 100644 index 0000000..14d86ad --- /dev/null +++ b/.serena/.gitignore @@ -0,0 +1 @@ +/cache diff --git a/.serena/project.yml b/.serena/project.yml new file mode 100644 index 0000000..272e3c0 --- /dev/null +++ b/.serena/project.yml @@ -0,0 +1,144 @@ +# the name by which the project can be referenced within Serena +project_name: "study-admin" + + +# list of languages for which language servers are started; choose from: +# al bash clojure cpp csharp +# csharp_omnisharp dart elixir elm erlang +# fortran fsharp go groovy haskell +# java julia kotlin lua markdown +# matlab nix pascal perl php +# php_phpactor powershell python python_jedi r +# rego ruby ruby_solargraph rust scala +# swift terraform toml typescript typescript_vts +# vue yaml zig +# (This list may be outdated. For the current list, see values of Language enum here: +# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py +# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.) +# Note: +# - For C, use cpp +# - For JavaScript, use typescript +# - For Free Pascal/Lazarus, use pascal +# Special requirements: +# Some languages require additional setup/installations. +# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers +# When using multiple languages, the first language server that supports a given file will be used for that file. +# The first language is the default language and the respective language server will be used as a fallback. +# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored. +languages: +- typescript + +# the encoding used by text files in the project +# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings +encoding: "utf-8" + +# whether to use project's .gitignore files to ignore files +ignore_all_files_in_gitignore: true + +# list of additional paths to ignore in this project. +# Same syntax as gitignore, so you can use * and **. +# Note: global ignored_paths from serena_config.yml are also applied additively. +ignored_paths: +- "**/node_modules/**" +- "**/dist/**" +- "**/.next/**" +- "**/packages/shared/drizzle/**" + +# whether the project is in read-only mode +# If set to true, all editing tools will be disabled and attempts to use them will result in an error +# Added on 2025-04-18 +read_only: false + +# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details. +# Below is the complete list of tools for convenience. +# To make sure you have the latest list of tools, and to view their descriptions, +# execute `uv run scripts/print_tool_overview.py`. +# +# * `activate_project`: Activates a project by name. +# * `check_onboarding_performed`: Checks whether project onboarding was already performed. +# * `create_text_file`: Creates/overwrites a file in the project directory. +# * `delete_lines`: Deletes a range of lines within a file. +# * `delete_memory`: Deletes a memory from Serena's project-specific memory store. +# * `execute_shell_command`: Executes a shell command. +# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced. +# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type). +# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type). +# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes. +# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file. +# * `initial_instructions`: Gets the initial instructions for the current project. +# Should only be used in settings where the system prompt cannot be set, +# e.g. in clients you have no control over, like Claude Desktop. +# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol. +# * `insert_at_line`: Inserts content at a given line in a file. +# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol. +# * `list_dir`: Lists files and directories in the given directory (optionally with recursion). +# * `list_memories`: Lists memories in Serena's project-specific memory store. +# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building). +# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context). +# * `read_file`: Reads a file within the project directory. +# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store. +# * `remove_project`: Removes a project from the Serena configuration. +# * `replace_lines`: Replaces a range of lines within a file with new content. +# * `replace_symbol_body`: Replaces the full definition of a symbol. +# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen. +# * `search_for_pattern`: Performs a search for a pattern in the project. +# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase. +# * `switch_modes`: Activates modes by providing a list of their names +# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information. +# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task. +# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed. +# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store. +excluded_tools: [] + +# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default) +included_optional_tools: [] + +# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools. +# This cannot be combined with non-empty excluded_tools or included_optional_tools. +fixed_tools: [] + +# list of mode names to that are always to be included in the set of active modes +# The full set of modes to be activated is base_modes + default_modes. +# If the setting is undefined, the base_modes from the global configuration (serena_config.yml) apply. +# Otherwise, this setting overrides the global configuration. +# Set this to [] to disable base modes for this project. +# Set this to a list of mode names to always include the respective modes for this project. +base_modes: + +# list of mode names that are to be activated by default. +# The full set of modes to be activated is base_modes + default_modes. +# If the setting is undefined, the default_modes from the global configuration (serena_config.yml) apply. +# Otherwise, this overrides the setting from the global configuration (serena_config.yml). +# This setting can, in turn, be overridden by CLI parameters (--mode). +default_modes: + +# initial prompt for the project. It will always be given to the LLM upon activating the project +# (contrary to the memories, which are loaded on demand). +initial_prompt: | + This is a blog study automation platform (monorepo with pnpm workspace). + - packages/bot: Discord bot (discord.js v14) + - packages/web: Next.js 14 dashboard (App Router, shadcn/ui) + - packages/shared: Shared DB schema (Drizzle ORM), types, utilities + Tech: TypeScript strict, Supabase PostgreSQL, Supabase Auth (Discord OAuth) + +# time budget (seconds) per tool call for the retrieval of additional symbol information +# such as docstrings or parameter information. +# This overrides the corresponding setting in the global configuration; see the documentation there. +# If null or missing, use the setting from the global configuration. +symbol_info_budget: + +# The language backend to use for this project. +# If not set, the global setting from serena_config.yml is used. +# Valid values: LSP, JetBrains +# Note: the backend is fixed at startup. If a project with a different backend +# is activated post-init, an error will be returned. +language_backend: + +# line ending convention to use when writing source files. +# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default) +# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings. +line_ending: + +# list of regex patterns which, when matched, mark a memory entry as read‑only. +# Extends the list from the global configuration, merging the two lists. +read_only_memory_patterns: [] diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..1ed9771 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,309 @@ +# Blog Study Discord Bot + +블로그 글쓰기 스터디 자동화 플랫폼. 웹 대시보드(관리+유저) + Discord 봇(스케줄러+이벤트). + +## 프로젝트 구조 + +``` +packages/ +├── bot/ # Discord 봇 (스케줄러 + 이벤트 핸들러만, 슬래시 커맨드 없음) → AWS EC2 (Docker) +├── web/ # Next.js 16 대시보드 → Vercel 배포 +└── shared/ # 공유 코드 (DB 스키마, 타입, 유틸) +deploy/ +└── bot/ # EC2 배포 스크립트 (deploy.sh) — 리포에 커밋하지 않음, EC2에 직접 배치 +``` + +**모노레포**: pnpm workspace (`pnpm-workspace.yaml`) + +## 기술 스택 + +| 영역 | 기술 | +|------|------| +| Runtime | Node.js 22, TypeScript 5.x | +| 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 (랜딩 애니메이션), Firebase (FCM 푸시 알림), Sentry (에러 모니터링), Cloudflare R2 (이미지 스토리지) | +| DB | Supabase PostgreSQL + Drizzle ORM (Transaction Pooler, `prepare: false`) | +| Auth | Supabase Auth (Discord OAuth) + `@supabase/ssr` | +| 배포 | AWS EC2 Docker (bot), Vercel (web), Supabase (DB + Auth) | +| CI/CD | GitHub Actions → ECR → SSH deploy (bot), Vercel Git Integration (web) | + +## 개발 명령어 + +```bash +# 개발 +pnpm dev:bot # 봇 로컬 실행 +pnpm dev:web # 웹 로컬 실행 (localhost:3300) + +# 빌드/테스트 +pnpm build # 전체 빌드 (shared → bot/web) +pnpm test # 전체 테스트 +pnpm lint # 전체 린트 +pnpm typecheck # 타입 체크 + +# shared 패키지 변경 시 +pnpm --filter @blog-study/shared build # 반드시 리빌드 + +# 봇 전용 +pnpm --filter @blog-study/bot rss-collect # 수동 RSS 수집 (봇 없이) +``` + +## 코딩 컨벤션 + +- **언어**: 모든 코드는 TypeScript strict 모드 +- **스타일**: Prettier + ESLint (설정 파일 참조) +- **폰트**: Pretendard (한국어 최적화) +- **네이밍**: camelCase (변수/함수), PascalCase (컴포넌트/타입), kebab-case (파일명) +- **DB 컬럼**: snake_case (Drizzle ORM이 자동 매핑) +- **커밋**: 기존 git log 스타일 따름, Co-Authored-By 포함 +- **Drizzle SQL**: `packages/shared/drizzle/*.sql` 마이그레이션 파일은 로컬 전용 (`.gitignore`에 등록됨, 커밋 금지) +- **다이얼로그**: `window.confirm()`, `window.alert()`, `window.prompt()` 사용 금지 → 커스텀 다이얼로그 컴포넌트 사용 (기존 `DeletePostDialog` 패턴 참고) +- **토스트**: `sonner` 라이브러리 사용 (`toast.success()`, `toast.error()`) — inline 상태 관리 토스트 금지 +- **API 응답**: 모든 API 라우트는 `Errors.*()` + `successResponse()` + `errorResponse()` 패턴 사용 (직접 `NextResponse.json` 금지) +- **캐시**: 읽기 전용 API에 `withCache(response, maxAge)` 적용 (members: 60s, ranking: 30s) +- **보안**: Tiptap content는 저장 전 `sanitizeTiptapContent()` 적용, 댓글 content는 `sanitizeDescription()` 적용, 외부 URL fetch/저장 시 `isSafeUrl()` SSRF 체크 (blogUrl, profileImageUrl, 소셜 URL 등 사용자 입력 URL 포함) +- **블로그 URL 수정**: 프로필 수정 시 blogUrl 변경 가능, 변경 시 rssUrl을 null 초기화 후 `after()`로 RSS 비동기 재감지 +- **Discord 알림**: 웹에서 직접 Discord REST API 호출 시 `discord-notify.ts` 유틸 사용, 사용자 입력은 `escapeDiscordMarkdown()` 적용, `allowed_mentions: { parse: [] }` 필수 +- **댓글 길이**: 최대 5000자 제한 (API에서 검증) +- **이미지 업로드**: Cloudflare R2 (`board-images/{userId}/{uuid}.{ext}`), 5MB 제한, rate limit 20회/분/유저 +- **포스트 수동등록**: 2단계 UX (URL→미리보기→편집→등록), OG HTML 엔티티 자동 디코딩, Discord 알림 토글, 푸시 알림은 Discord 토글과 무관하게 항상 발송 +- **새 글 푸시 알림**: 수동 등록 + RSS 수집 모두 지원. 대상: active/OB/dormant (작성자 본인 제외), 알림 타입 `new_post`. 봇→웹 내부 API(`/api/internal/new-post-push`, Bearer 인증) 경유 +- **포스트 수정**: 본인 또는 관리자만 제목/설명 수정 가능 (`PATCH /api/posts/[id]`) +- **공지 알림**: 게시판 공지 작성 시 FCM 푸시 + Discord 공지채널(`notice_channel_id`) `@everyone` + 웹 딥링크 버튼 +- **벌금 DM**: 계좌 정보 포함 (3333333114501 카카오뱅크), 납부완료 시 관리자 채널 알림 +- **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` 옵션 지원 +- **백그라운드 작업**: API route에서 푸시 알림/점수 부여 등 fire-and-forget 작업은 `after()` from `next/server` 사용 (Vercel 서버리스 종료 방지) +- **비밀댓글 알림**: 비밀댓글(`isSecret`)의 푸시 알림은 내용 마스킹 (`'비밀 댓글이 달렸습니다.'`), 포스트/게시판 댓글 모두 적용 +- **비밀댓글 isSecret 토글**: PATCH 시 본인만 변경 가능 (관리자도 타인 비밀 상태 변경 불가) +- **포스트 삭제**: 본인 또는 관리자만 가능, 트랜잭션으로 댓글/조회기록/활동점수(blog_post) 일괄 삭제 +- **이모지 리액션**: 게시판 글 + 포스트에 고정 6종 이모지 (👍👀🔥💡😂✅) 토글, `ReactionBar` 공용 컴포넌트 (`apiPath` prop으로 board/posts 구분), 호버(PC)/클릭(모바일) 시 닉네임 팝오버, 복수 선택 가능, 활동 점수/알림 없음 +- **인기글 점수**: `댓글×3 + 조회수×2 + 리액션×1` +- **스터디원 목록**: active + dormant + ob 모두 표시, 상태 칩으로 구분 (OB: 황금 파스텔, 휴면: secondary) + +## 핵심 파일 위치 + +| 파일 | 설명 | +|------|------| +| `packages/shared/src/db/schema.ts` | 전체 DB 스키마 (Drizzle) | +| `packages/shared/src/db/index.ts` | DB 연결 (Transaction Pooler, `prepare: false`) | +| `packages/web/src/lib/supabase/client.ts` | 브라우저용 Supabase 클라이언트 | +| `packages/web/src/lib/supabase/server.ts` | 서버용 Supabase 클라이언트 (cookies) | +| `packages/web/src/lib/supabase/middleware.ts` | 미들웨어용 세션 갱신 | +| `packages/web/src/app/(user)/layout.tsx` | 사용자 레이아웃 (인증 체크 + 상태별 리다이렉트) | +| `packages/web/src/app/auth/callback/route.ts` | OAuth 콜백 + 상태별 리다이렉트 | +| `packages/web/src/lib/admin.ts` | 관리자 권한 체크 (`withAdminAuth`) | +| `packages/web/src/lib/member-config.ts` | 멤버 상태별 라벨/뱃지 설정 | +| `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→Push 파이프라인 | +| `packages/bot/src/services/score.service.ts` | 활동 점수 계산/부여 (봇: blog_post만) | +| `packages/web/src/lib/score.ts` | 웹 활동 점수 부여 (board_post, post_comment, board_comment, post_view) | +| `packages/web/src/lib/score-config.ts` | 활동 점수 타입별 메타데이터 (Single Source of Truth: 라벨, 이모지, 배점, 뱃지 컬러) | +| `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`) | +| `packages/web/src/lib/sanitize.ts` | 입력 새니타이즈 (`sanitizeDescription`, `sanitizeTiptapContent`, `decodeHtmlEntities`, `getTodayKST`) | +| `packages/web/src/app/not-found.tsx` | 커스텀 404 페이지 | +| `packages/web/src/app/(user)/error.tsx` | 사용자 에러 바운더리 | +| `packages/web/src/app/(admin)/error.tsx` | 관리자 에러 바운더리 | +| `packages/web/src/components/ui/member-avatar.tsx` | 재사용 아바타 컴포넌트 (링크+관리자뱃지) | +| `packages/web/src/components/board/tiptap-editor.tsx` | Tiptap 리치 에디터 (H1-H3, 구분선, 코드블록, 링크, 한글 IME 대응) | +| `packages/web/src/components/layout/bottom-nav.tsx` | 모바일 하단 탭 바 (사용자: 랭킹/포스트/홈/게시판/스터디원, 관리자: 멤버/회차/출석/벌금/점수/봇) | +| `packages/web/src/components/layout/notice-banner.tsx` | 글로벌 공지 배너 (제목+내용 미리보기, 접기/닫기) | +| `packages/web/src/components/layout/pull-to-refresh.tsx` | Pull-to-Refresh 컴포넌트 (PWA 터치 제스처) | +| `packages/web/src/hooks/use-pull-to-refresh.ts` | Pull-to-Refresh 훅 (`window.location.reload()` 기반) | +| `packages/web/src/app/api/notice-banner/route.ts` | 활성 공지 배너 조회 API | +| `packages/web/src/app/(admin)/admin/bot-operations/page.tsx` | 봇 수동 실행 대시보드 (관리자 전용) | +| `packages/web/src/app/api/admin/bot-operations/[operationId]/route.ts` | 봇 작업 트리거 프록시 (web → bot HTTP API, 30s 타임아웃) | +| `packages/web/src/app/(admin)/admin/rounds/page.tsx` | 회차 관리 페이지 (CRUD + 현재 회차 설정) | +| `packages/web/src/app/api/profile/edit/route.ts` | 프로필 수정 API (blogUrl 변경 시 RSS 재감지, 소셜 URL SSRF 체크) | +| `packages/web/src/app/api/posts/[id]/route.ts` | 포스트 삭제 API (본인/관리자, 댓글+조회+점수 일괄 삭제) | +| `packages/web/src/app/api/profile/withdraw/route.ts` | 유저 자체 탈퇴 API | +| `packages/web/src/lib/firebase/admin.ts` | Firebase Admin SDK (lazy 초기화, `getAdminMessaging()`) | +| `packages/web/src/lib/firebase/client.ts` | Firebase 클라이언트 (FCM 토큰 요청, 포그라운드 메시지) | +| `packages/web/src/lib/push.ts` | FCM 푸시 전송 (`sendPushToMember`, `sendPushToMembers`) | +| `packages/web/src/hooks/use-push-notification.ts` | 푸시 알림 훅 (권한 관리, 토큰 복원, 구독/해제) | +| `packages/web/src/components/settings/push-notification-settings.tsx` | 알림 설정 UI (타입별 토글 + 테스트 전송) | +| `packages/web/src/app/api/push/test/route.ts` | 테스트 푸시 알림 API (레이트 리밋 5/min) | +| `packages/web/src/app/api/notification-preferences/route.ts` | 알림 타입별 설정 CRUD API | +| `packages/web/src/app/api/internal/new-post-push/route.ts` | 새 글 푸시 알림 내부 API (봇→웹, Bearer 인증, rate limit 20/min) | +| `packages/web/src/app/api/firebase-sw/route.ts` | FCM 서비스 워커 동적 서빙 (rewrite: `/firebase-messaging-sw.js` → `/api/firebase-sw`) | +| `packages/bot/src/scripts/rss-collect.ts` | 수동 RSS 수집 스크립트 (봇 없이 독립 실행) | +| `packages/bot/src/scripts/setup-channels.ts` | 디스코드 채널 일괄 생성 스크립트 | +| `packages/bot/src/scripts/list-channels.ts` | 서버 채널 구조 조회 스크립트 | +| `packages/bot/src/api-server.ts` | 봇 HTTP API 서버 (Express, 수동 트리거 엔드포인트, rate limit 10/min) | +| `packages/web/src/lib/discord-notify.ts` | Discord REST API 채널 메시지 전송 유틸 (웹→Discord 직접 알림, 버튼/embed/allowEveryone 지원) | +| `packages/web/src/lib/r2.ts` | Cloudflare R2 업로드/삭제 유틸 (`uploadToR2`, `deleteFromR2`) | +| `packages/web/src/app/api/board/image/route.ts` | 게시판 이미지 업로드 API (R2, 5MB, JPEG/PNG/GIF/WebP) | +| `packages/web/src/app/api/posts/preview/route.ts` | 포스트 URL OG 미리보기 API (제목/설명/썸네일 추출) | +| `packages/web/src/components/board/image-block.tsx` | Tiptap ImageBlock 커스텀 노드 (리사이즈, 삭제, 캡션) | +| `packages/web/src/components/board/image-drop-plugin.ts` | Tiptap 이미지 드래그앤드롭 + 붙여넣기 플러그인 | +| `packages/bot/src/services/round.service.ts` | 회차 관리 + ConfigKeys (announcement/notice/curation/admin_notification 채널) | +| `packages/web/src/components/landing/landing-client.tsx` | 랜딩 페이지 클라이언트 (7섹션: Hero, Stats, Bento, HowItWorks, Marquee, CTA, Footer) | +| `packages/web/src/components/landing/motion.tsx` | 랜딩 애니메이션 컴포넌트 (FadeUp, StaggerContainer, CountUp, DrawLine) | +| `packages/web/src/app/opengraph-image.tsx` | OG 이미지 동적 생성 (Edge Runtime, `next/og` ImageResponse, 1200×630) | +| `packages/web/public/logo.svg` | 풀 로고 SVG (픽토그램 + 텍스트) | +| `packages/bot/Dockerfile` | 봇 Docker 이미지 (multi-stage, node:22-alpine) | +| `.github/workflows/bot-deploy.yml` | 봇 CI/CD (CI Gate → ECR 빌드/푸시 → SSH 배포) | +| `.github/workflows/ci.yml` | PR/push CI (lint, typecheck, test, build) | +| `packages/web/next.config.ts` | Next.js 설정 + Sentry `withSentryConfig` 래핑 | +| `packages/web/sentry.client.config.ts` | Sentry 클라이언트 SDK 초기화 (DSN 가드, PII 스크러빙) | +| `packages/web/sentry.server.config.ts` | Sentry 서버 SDK 초기화 | +| `packages/web/sentry.edge.config.ts` | Sentry Edge SDK 초기화 | +| `packages/web/instrumentation.ts` | Next.js instrumentation hook (Sentry 서버/엣지 등록) | +| `packages/web/src/app/global-error.tsx` | 전역 에러 바운더리 (Sentry 전송 + 다크모드 대응) | + +## 인증 구조 + +- **웹**: Supabase Auth → Discord OAuth → `user.identities[].id` (Discord ID) → `members.discord_id` 매칭 +- **봇**: `service_role` key로 직접 DB 접근 (스케줄러/이벤트 핸들러 전용, 슬래시 커맨드 없음) +- **미들웨어**: 없음 (Next.js 16 + Sentry withSentryConfig 비호환). 인증 체크는 각 layout에서 처리 +- **관리자**: `(admin)/layout.tsx`에서 `/api/admin/check` 호출 → 미인증 시 로그인, 비관리자 시 접근 거부 다이얼로그. API는 `withAdminAuth` 래퍼로 서버사이드 권한 체크 +- **API Route**: `createClient()` → `getUser()` → `identities` 배열에서 Discord ID 추출 +- **상태 리다이렉트**: `auth/callback` + `(user)/layout.tsx`에서 이중 체크 → 상태별 차단 페이지로 리다이렉트 +- **랜딩 페이지**: 서버 사이드 `getUser()` 체크 → 인증 유저는 `/dashboard`로 redirect + +## 멤버 상태 규칙 + +| 상태 | 접근 | 출석/벌금 | 비고 | +|------|------|-----------|------| +| `pending_approval` | 차단 (`/pending`) | 제외 | 온보딩 후 기본 상태 | +| `active` | 허용 | 대상 | 관리자 승인 시 전환 | +| `inactive` | 차단 (`/inactive`) | 제외 | | +| `dormant` | 허용 | 제외 | 관리자가 반복 전환 가능 (1회 제한 해제됨) | +| `ob` | 허용 | 제외 | 관리자 승인 시 선택 가능 | +| `withdrawn` | 차단 | 제외 | soft delete | + +## UI 디자인 시스템 + +- **스타일**: Vercel 화이트/블랙 미니멀 + 스카이블루 포인트 +- **컴포넌트**: shadcn/ui + Radix UI +- **아이콘**: lucide-react +- **다크모드**: next-themes (시스템 연동) +- **폰트**: Pretendard Variable +- **기본 아바타**: DiceBear `fun-emoji` 스타일 (`getDefaultAvatar()` in `utils.ts`) +- **아바타 리소스**: [DiceBear](https://www.dicebear.com/styles/) - 30+ 스타일, seed 기반 결정적 아바타 생성, API: `https://api.dicebear.com/9.x/{style}/svg?seed={seed}` +- **레이아웃**: 데스크톱 사이드바 + 모바일 하단 탭 바 (사용자/관리자 모드별 탭 자동 전환) +- **사이드바**: 모드 전환은 헤더 프로필 드롭다운에서만 가능 (사이드바에 토글 없음) +- **큐레이션**: 현재 네비게이션에서 숨김 (TODO: 나중에 활성화 예정), 페이지/로직은 유지 +- **포스트**: 최신순/인기순 탭, 무한 스크롤, 썸네일(OG 이미지)+그라디언트 폴백, 인기순 상위 3개 금/은/동 메달, 검색(제목/작성자) + 분야 필터(복수 선택) +- **공지 배너**: 글로벌 상단 배너 (제목+내용 미리보기, 접기→제목만, 닫기→숨김, 관리자 페이지 미표시) +- **다이얼로그**: Safari PWA 스크롤 대응 (flex 레이아웃, `inset-y-0 my-auto` 센터링, `overflow-y-auto`, `data-scroll-locked` 가드) +- **Pull-to-Refresh**: 커스텀 터치 제스처 → `window.location.reload()` (Safari PWA 최적화, 다이얼로그 열림 시 비활성화) +- **PWA**: 홈 화면 추가 지원 (manifest.json, 서비스 워커 없음) +- **랜딩 페이지**: Linear 스타일 다크 모드 원페이지 (큐시즘 블루 그라디언트 `#0091FF→#004DFF`, Framer Motion 풀 애니메이션, DB 스탯 ISR 60s, 인증 유저 `/dashboard` 리다이렉트) +- **로고**: 커스텀 SVG 픽토그램 (펜촉+화살표, 큐시즘 블루 그라디언트), `icon.svg`/`icon-192.png`/`icon-512.png` +- **OG 이미지**: `opengraph-image.tsx` 동적 생성 (Edge Runtime, 1200×630, 다크 테마 + Mock UI 카드), `layout.tsx`에 `openGraph`/`twitter` 메타데이터 +- **토스트**: sonner (`` in root layout, `position="bottom-center"`, `richColors`) +- **에러 바운더리**: `(user)/error.tsx`, `(admin)/error.tsx` — Sentry 전송 + 리셋 버튼, `global-error.tsx` — 전역 폴백 (다크모드 인라인 스타일) +- **404 페이지**: `not-found.tsx` — 대시보드 링크 포함 +- **CSP**: `next.config.ts`에 Content-Security-Policy 헤더 설정 +- **상세 스펙**: `docs/26-03-06-ui-design-system.md` 참조 + +## 에이전트 활용 가이드 + +### 서브에이전트 용도 +| 에이전트 | 용도 | +|---------|------| +| `code-reviewer` | PR 리뷰, 코드 품질 체크 | +| `qa-expert` | 테스트 작성, QA 전략 | +| `security-auditor` | 보안 취약점 검토 | +| `frontend-developer` | UI 컴포넌트 구현, 반응형 | +| `devops-engineer` | 배포, CI/CD, 인프라 | +| `ui-designer` | 디자인 시스템, 컴포넌트 설계 | +| `accessibility-tester` | 접근성 검증 | + +### 스킬 사용 +| 커맨드 | 설명 | +|--------|------| +| `/safe-commit` | 변경 파일만 명시적 추가 후 커밋 | +| `/pr` | PR 생성 | +| `/review` | 코드 리뷰 | +| `/test` | 테스트 작성 | +| `/security` | 보안 검토 | +| `/devops` | DevOps 작업 | + +## 테스트 전략 + +- **프레임워크**: Vitest + fast-check (Property-Based Testing) +- **구조**: `*.property.test.ts` (속성 테스트), `*.test.ts` (단위 테스트) +- **최소 100회 반복**: Property-Based Test +- **주석 형식**: `Property {N}: {설명}` → Correctness Property 매핑 + +## 환경 변수 + +`.env.example` 참조. 필수: +- `NEXT_PUBLIC_SUPABASE_URL`, `NEXT_PUBLIC_SUPABASE_ANON_KEY` (Supabase) +- `SUPABASE_SERVICE_KEY`, `DATABASE_URL`, `DATABASE_URL_DIRECT` (DB) +- `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에서만 설정) +- `FIREBASE_PROJECT_ID`, `FIREBASE_PRIVATE_KEY`, `FIREBASE_CLIENT_EMAIL` 등 (Firebase Admin, 서버용) +- `NEXT_PUBLIC_FIREBASE_*` (Firebase 클라이언트, `API_KEY`/`AUTH_DOMAIN`/`PROJECT_ID`/`MESSAGING_SENDER_ID`/`APP_ID`/`VAPID_KEY`) +- `INTERNAL_API_KEY` (봇→웹 내부 API 인증, 웹+봇 공유) +- `WEB_URL` (봇에서 웹 API 호출 시 base URL, 봇 전용) + +**env 파일 위치** (2곳): +- `.env.local` — 루트 (shared/bot용) +- `packages/web/.env.local` — Next.js용 + +**주의**: `packages/web/.env.local`에도 동일 환경변수 필요 (Next.js는 패키지 디렉토리 기준) + +## DB 마이그레이션 + +스키마 변경 시 drizzle-kit push까지 직접 실행: + +```bash +cd packages/shared +export $(grep DATABASE_URL ../../.env.local | head -1 | xargs) +npx drizzle-kit push --force +``` + +## 문서 + +| 문서 | 설명 | +|------|------| +| `docs/ARCHITECTURE.md` | 시스템 아키텍처 (Mermaid 다이어그램) | +| `docs/26-03-06-tech-decisions.md` | 기술 선택 근거 (ADR) | +| `docs/26-03-06-ui-design-system.md` | UI 디자인 시스템 스펙 | +| `docs/26-03-06-development.md` | 개발 환경 설정 | +| `docs/26-03-06-checklist.md` | 구현 체크리스트 | +| `docs/26-03-06-schema-summary.md` | DB 스키마 요약 (테이블/Enum/FK) | +| `docs/26-03-06-patterns.md` | API 패턴 & 코드 규칙 | +| `docs/ONBOARDING.md` | 팀 온보딩 가이드 | +| `docs/26-03-08-discord-channel-setup.md` | 디스코드 채널 세팅 가이드 (큐스팅) | +| `docs/plans/26-03-08-landing-page-redesign-design.md` | 랜딩 페이지 리디자인 디자인 문서 | +| `docs/plans/26-03-08-landing-page-redesign.md` | 랜딩 페이지 구현 플랜 | + +## 봇 배포 (CI/CD) + +- **파이프라인**: `dev` push → CI Gate (lint+typecheck+test) → ECR 빌드(ARM64) → SSH 배포 +- **트리거**: `packages/bot/**`, `packages/shared/**` 변경 시 + `workflow_dispatch` +- **EC2**: illdan-mgmt (t4g ARM64), `~/study-admin-bot/deploy.sh` + `.env` +- **ECR**: `699475955307.dkr.ecr.ap-northeast-2.amazonaws.com/study-admin-bot` +- **deploy.sh**: ECR 로그인 → pull → 컨테이너 교체 → health check → Discord 웹훅 알림 +- **주의**: `deploy/bot/deploy.sh`는 커밋하지 않음 (EC2에 직접 배치) + +## docs 파일명 컨벤션 + +`yy-mm-dd-{설명}.md` — 예: `26-03-03-system-architecture.md` +- 설명은 다른 문서와 구분될 정도로 구체적으로 작명 +- `docs/plans/` 하위도 동일 컨벤션 적용 +- 단, `docs/ARCHITECTURE.md`는 제외하며 업데이트 시에도 네이밍을 그대로 유지 diff --git a/README.md b/README.md index 582d6ca..0e7f1b5 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,116 @@ -# study-admin -스터디 어드민 웹사이트 +# 큐스팅 4th · 블로그 글쓰기 스터디 + +2주에 한 편, 블로그 글을 쓰며 함께 성장하는 스터디 플랫폼. +웹 대시보드 + Discord 봇으로 스터디 운영을 자동화합니다. + +## 아키텍처 + +```mermaid +graph TB + subgraph Client["사용자"] + BROWSER["브라우저 / PWA"] + end + + subgraph Web["Web · Vercel"] + NEXT["Next.js 16
App Router"] + API["API Routes"] + FCM_WEB["FCM Push"] + end + + subgraph Bot["Bot · AWS EC2"] + DISCORD_BOT["discord.js v14"] + SCHEDULER["pg-boss
스케줄러"] + BOT_API["Express API
수동 트리거"] + end + + subgraph Infra["인프라"] + SUPABASE["Supabase
PostgreSQL + Auth"] + FIREBASE["Firebase
Cloud Messaging"] + SENTRY["Sentry
Error Tracking"] + DISCORD["Discord Server"] + end + + BROWSER --> NEXT + NEXT --> API + API --> SUPABASE + FCM_WEB --> FIREBASE + FIREBASE -->|push| BROWSER + + DISCORD_BOT <-->|WebSocket| DISCORD + SCHEDULER --> SUPABASE + API -->|트리거| BOT_API + BOT_API --> SCHEDULER + + NEXT --> SENTRY + DISCORD_BOT --> SENTRY +``` + +## 기술 스택 + +| 영역 | 기술 | +|------|------| +| Frontend | Next.js 16, React 19, Tailwind CSS v4, shadcn/ui, Framer Motion | +| Backend | Next.js API Routes, Supabase Auth (Discord OAuth) | +| Bot | discord.js v14, pg-boss (Job Queue), feedsmith (RSS) | +| Editor | Tiptap (리치 에디터), sonner (토스트) | +| DB | PostgreSQL (Supabase) + Drizzle ORM | +| Push | Firebase Cloud Messaging (FCM) | +| Monitor | Sentry (에러 모니터링 + PII 스크러빙) | +| Deploy | Vercel (Web), AWS EC2 Docker (Bot), Supabase (DB) | +| CI/CD | GitHub Actions → ECR → SSH deploy (Bot), Vercel Git (Web) | + +## 프로젝트 구조 + +``` +packages/ +├── shared/ # DB 스키마, 타입, 유틸 (Drizzle ORM) +├── bot/ # Discord 봇 (스케줄러 + 이벤트 핸들러) +└── web/ # Next.js 대시보드 (사용자 + 관리자) +``` + +**모노레포**: pnpm workspace + +## 주요 기능 + +### 사용자 +- **대시보드** — 현재 회차, 출석 상태, 활동 점수, 최근 포스트 +- **포스트 피드** — 스터디원 블로그 글 모아보기, 댓글/비밀댓글, 조회수 +- **랭킹** — 활동 기반 실시간 랭킹 +- **커뮤니티 게시판** — 공지/건의/후기/지식공유/일상, 댓글/비밀댓글/투표 +- **프로필** — 활동 내역, 알림 설정, 소셜 링크 +- **PWA 푸시 알림** — 댓글/답글/공지 알림 + +### 자동화 (봇) +- **RSS 자동 수집** — 블로그 글 발행 시 자동 감지 및 수집 +- **출석 자동화** — 2주 1회차, 지각/결석 자동 판정 +- **벌금 관리** — 자동 부과, DM 리마인드 +- **주간 랭킹** — 매주 활동 점수 랭킹 디스코드 공유 + +## 시작하기 + +```bash +pnpm install # 의존성 설치 +pnpm dev:web # 웹 개발 서버 (localhost:3300) +pnpm dev:bot # 봇 개발 서버 +pnpm build # 전체 빌드 +pnpm typecheck # 타입 체크 +pnpm lint # 린트 +``` + +## 환경 변수 + +`.env.example` 참조. 두 곳에 설정 필요: +- `.env.local` — 루트 (shared/bot용) +- `packages/web/.env.local` — Next.js용 + +## 문서 + +| 문서 | 설명 | +|------|------| +| [ARCHITECTURE.md](docs/ARCHITECTURE.md) | 시스템 아키텍처 (Mermaid 다이어그램) | +| [기술 결정](docs/26-03-06-tech-decisions.md) | 기술 선택 근거 (ADR) | +| [UI 디자인 시스템](docs/26-03-06-ui-design-system.md) | UI 스펙 | +| [개발 환경](docs/26-03-06-development.md) | 개발 환경 설정 | +| [DB 스키마](docs/26-03-06-schema-summary.md) | 테이블/Enum/FK 요약 | +| [API 패턴](docs/26-03-06-patterns.md) | API 코드 규칙 | +| [온보딩](docs/ONBOARDING.md) | 팀 온보딩 가이드 | diff --git a/blog-study-bot-logrotate b/blog-study-bot-logrotate new file mode 100644 index 0000000..889b322 --- /dev/null +++ b/blog-study-bot-logrotate @@ -0,0 +1,25 @@ +# Blog Study Discord Bot - Logrotate Configuration +# +# NOTE: This configuration is for future file logging setup. +# Current implementation uses systemd journal, so logs are managed by journald. +# View logs with: journalctl -u blog-study-bot -f +# +# To enable file logging: +# 1. Configure pino to write to file (update logger.ts) +# 2. Install: sudo cp blog-study-bot-logrotate /etc/logrotate.d/blog-study-bot +# 3. Update file permissions to match your EC2 user (e.g., ubuntu, ec2-user) + +/var/log/blog-study-bot/*.log { + daily # Rotate daily + rotate 7 # Keep 7 days of logs + compress # Compress old logs with gzip + delaycompress # Compress the next day (not immediately) + missingok # No error if log file is missing + notifempty # Don't rotate if log is empty + create 0640 $USER $USER # Create new log file with current user permissions + sharedscripts # Run scripts once for all logs + postrotate + # Send SIGUSR1 to bot to reopen log files (if using file logging) + # pkill -SIGUSR1 -f "node.*bot" + endscript +} diff --git a/debug-round-report.ts b/debug-round-report.ts new file mode 100644 index 0000000..59a1ee8 --- /dev/null +++ b/debug-round-report.ts @@ -0,0 +1,33 @@ +import { loadBotEnv } from '@blog-study/shared'; +import { getDb, rounds, attendance, members } from '@blog-study/shared/db'; +import { eq } from 'drizzle-orm'; +import { getCurrentRound } from './packages/bot/src/services/round.service'; +import { getAttendanceSummariesForRound } from './packages/bot/src/schedulers/round-reporter'; + +async function main() { + loadBotEnv(); + + console.log('=== getCurrentRound() 확인 ===\n'); + const currentRound = await getCurrentRound(); + console.log(`현재 회차: ${currentRound.roundNumber} (ID: ${currentRound.id})`); + + console.log('\n=== getAttendanceSummariesForRound() 확인 ===\n'); + const summaries = await getAttendanceSummariesForRound(currentRound.id); + console.log(`attendanceSummaries: ${summaries.length}개`); + + summaries.forEach((s, index) => { + console.log(`${index + 1}. ${s.name} (${s.status}) - ${s.postCount}개 포스트`); + }); + + console.log('\n=== 상태별 개수 ==='); + const submitted = summaries.filter(s => s.status === 'SUBMITTED'); + const late = summaries.filter(s => s.status === 'LATE'); + const absent = summaries.filter(s => s.status === 'ABSENT'); + + console.log(`SUBMITTED: ${submitted.length}`); + console.log(`LATE: ${late.length}`); + console.log(`ABSENT: ${absent.length}`); + console.log(`총계: ${summaries.length}`); +} + +main(); diff --git a/docs/26-03-06-checklist.md b/docs/26-03-06-checklist.md new file mode 100644 index 0000000..4a73f7e --- /dev/null +++ b/docs/26-03-06-checklist.md @@ -0,0 +1,128 @@ +# 구현 체크리스트 + +전면 개편 작업 순서. 의존성 순서대로 정렬. + +--- + +## Phase 0: 기반 정리 ✅ + +- [x] 기존 `.kiro/`, `.vscode/`, 구 `docs/` 파일 제거 +- [x] 새 `CLAUDE.md` 및 `docs/` 문서 체계 확립 +- [x] Git 초기 커밋 (새 출발점) + +## Phase 3: 인증 전환 (Supabase Auth) ✅ + +> Phase 1보다 먼저 진행됨 (인증이 모든 기능의 기반) + +- [x] Supabase 프로젝트에 Discord OAuth 프로바이더 설정 +- [x] `@supabase/ssr` 기반 클라이언트 설정 (`packages/web/src/lib/supabase/`) +- [x] 로그인 페이지: "Discord로 로그인" 버튼으로 교체 +- [x] 회원가입 페이지 제거 (Discord OAuth로 통합) +- [x] `middleware.ts` → Supabase Auth 세션 체크로 교체 +- [x] 관리자 권한: Discord ID + `ADMIN_DISCORD_IDS` 매칭 +- [x] 기존 auth 관련 파일/라우트 제거 +- [x] `api/auth/callback` 라우트 추가 (Supabase OAuth 콜백) +- [x] 모든 API 라우트 Supabase Auth 기반으로 리팩토링 +- [x] DB 스키마에서 `users`, `sessions` 테이블 제거 +- [x] 보안 강화 (오픈 리다이렉트 방지, 입력 검증) +- [x] 불필요한 의존성 제거: `bcryptjs`, `jsonwebtoken`, `jose`, `resend` +- [x] DB 연결: Transaction Pooler 지원 (`prepare: false`) +- [x] 빌드 검증 통과 +- [ ] RLS 정책 설정 (배포 시) + +## Phase 1: 의존성 업그레이드 ✅ + +- [x] Next.js 14 → 16 업그레이드 (PR #5) +- [x] Tailwind CSS v3 → v4 업그레이드 (PR #5) +- [x] React 18 → 19 업그레이드 (PR #5) +- [x] `rss-parser` → `feedsmith` 교체 +- [x] `node-cron` → `pg-boss` 교체 +- [x] 전체 빌드 확인 (`pnpm build`) + +## Phase 4: Bot 개편 (pg-boss + feedsmith) ✅ + +- [x] `feedsmith` 기반 RSS 서비스 재구현 +- [x] `pg-boss` 기반 스케줄러 전환 (6개 잡) +- [x] RSS→PostService.create→NotificationService 파이프라인 연결 +- [x] graceful shutdown에 pg-boss 정리 추가 + +## Phase 6: 웹 UI 전면 리디자인 + +### 6-1: 디자인 시스템 기반 +- [ ] globals.css 컬러 토큰 재정의 (스카이블루 포인트) +- [x] Pretendard 폰트 설정 +- [x] Tailwind CSS v4 + 디자인 토큰 설정 +- [x] next-themes 다크모드 설정 +- [x] shadcn/ui 컴포넌트 테마 커스터마이징 + +### 6-2: 레이아웃 ✅ +- [x] AppLayout (사이드바 + 헤더 + 메인) +- [x] Sidebar (접기/펼치기, 활성 인디케이터, 아이콘 모드) +- [x] 반응형: 모바일 드로어, 태블릿/데스크톱 사이드바 + +### 6-3: Public 페이지 +- [ ] 랜딩 페이지 (/) - 스터디 소개, 로그인 유도 +- [x] 로그인 페이지 (/login) - Discord OAuth 버튼 + +### 6-4: 사용자 페이지 ✅ +- [x] 대시보드 (/dashboard) - 현재 회차, 내 출석, 최근 글 +- [x] 글 목록 (/posts) - 스터디원 글 + 수동 등록 + 페이지네이션 +- [x] 랭킹 (/ranking) - 포디움, 정렬 (총점/포스트/활동), 출석 히트맵 +- [x] 큐레이션 (/curation) - 추천/최신 정렬, 카테고리/태그 필터, 무한 스크롤 +- [x] 게시판 (/board) - 카테고리별 게시글 + 댓글 + 비밀글 + Tiptap 에디터 +- [x] 멤버 목록 (/members) - 활동 멤버 그리드 +- [x] 멤버 프로필 (/members/[id]) - 상세 프로필 + 활동 통계 +- [x] 프로필 (/profile) - 내 정보 조회/수정 +- [x] 온보딩 (/profile/onboarding) - 최초 가입 설정 + +### 6-5: 관리자 페이지 +- [x] 관리자 대시보드 (/admin) - 요약 통계, 활동 피드 +- [x] 멤버 관리 (/admin/members) - CRUD + 승인 + 상태 관리 +- [ ] 출석 관리 (/admin/attendance) - 멤버 × 회차 그리드 (검증 필요) +- [ ] 벌금 관리 (/admin/fines) - 납부/면제 처리 (검증 필요) +- [ ] 점수 관리 (/admin/scores) - 수동 부여/삭제 (검증 필요) +- [x] 큐레이션 소스 (/admin/curation) - 소스 관리 + 크롤링 +- [ ] 설정 (/admin/settings) - 스터디 설정 (검증 필요) + +### 6-6: Supabase Realtime 통합 (선택적) +- [ ] 활동 피드 컴포넌트 (실시간 업데이트) +- [ ] 대시보드 통계 실시간 반영 + +## Phase 7: 통합 테스트 및 배포 + +- [ ] 전체 빌드 성공 확인 +- [ ] 타입 체크 통과 +- [ ] 린트 통과 +- [ ] Supabase 프로덕션 설정 + - RLS 정책 + - Discord OAuth 프로바이더 +- [ ] AWS EC2 배포 (Bot) + - 환경 변수 설정 + - 빌드 확인 +- [ ] Vercel 배포 (Web) + - 환경 변수 설정 + - Supabase URL/키 설정 + - 빌드 확인 +- [ ] E2E 수동 테스트 + - Discord 봇 커맨드 전체 동작 + - 웹 로그인 플로우 + - RSS 수집 → 알림 파이프라인 + - 관리자 페이지 전체 기능 + +--- + +## 우선순위 요약 + +``` +Phase 0 (정리) ✅ + ↓ +Phase 3 (인증) ✅ + ↓ +Phase 1 (의존성) ✅ + ↓ +Phase 4 (봇) ✅ + ↓ +Phase 6 (UI) ←── 관리자 페이지 검증 + 랜딩 + 디자인 정비 남음 + ↓ +Phase 7 (배포) +``` diff --git a/docs/26-03-06-development.md b/docs/26-03-06-development.md new file mode 100644 index 0000000..410d510 --- /dev/null +++ b/docs/26-03-06-development.md @@ -0,0 +1,206 @@ +# 개발 환경 설정 + +## 사전 요구사항 + +- Node.js 22 이상 +- pnpm 8 이상 (`npm install -g pnpm`) +- Discord 개발자 계정 ([discord.com/developers](https://discord.com/developers/applications)) +- Supabase 프로젝트 ([supabase.com](https://supabase.com)) + +## 초기 설정 + +```bash +# 1. 의존성 설치 +pnpm install + +# 2. 환경 변수 설정 +cp .env.example .env.local +cp .env.example packages/web/.env.local +# 두 파일 모두 값을 채워야 합니다 (Next.js는 packages/web/ 기준으로 읽음) + +# 3. shared 패키지 빌드 (web/bot이 의존) +pnpm --filter @blog-study/shared build + +# 4. DB 스키마 푸시 +DATABASE_URL="your-connection-string" pnpm --filter @blog-study/shared db:push + +# 5. 스터디 회차 초기화 +pnpm --filter @blog-study/bot init-rounds + +# 6. Discord 슬래시 커맨드 등록 +pnpm --filter @blog-study/bot deploy-commands +``` + +## 개발 서버 + +```bash +# 봇과 웹을 각각 별도 터미널에서 실행 +pnpm dev:bot # 봇 (tsx watch) +pnpm dev:web # 웹 (localhost:3000) + +# 포트 지정 +pnpm --filter @blog-study/web dev --port 3200 +``` + +## 환경 변수 + +루트 `.env.local`과 `packages/web/.env.local` 두 곳에 동일하게 설정 필요. + +```env +# Supabase +NEXT_PUBLIC_SUPABASE_URL= # 프로젝트 URL (https://xxx.supabase.co) +NEXT_PUBLIC_SUPABASE_ANON_KEY= # 클라이언트용 공개 키 +SUPABASE_SERVICE_KEY= # 서버용 비밀 키 +DATABASE_URL= # Transaction Pooler URL + # postgresql://postgres.xxx:[password]@aws-1-ap-northeast-2.pooler.supabase.com:6543/postgres + +# Discord +DISCORD_TOKEN= # 봇 토큰 +DISCORD_CLIENT_ID= # 클라이언트 ID +DISCORD_CLIENT_SECRET= # 클라이언트 시크릿 (OAuth용) +DISCORD_GUILD_ID= # 서버(길드) ID + +# Admin +ADMIN_DISCORD_IDS=id1,id2 # 관리자 Discord User ID (쉼표 구분) + +# Application +APP_URL=http://localhost:3000 # 웹 URL +NODE_ENV=development + +# Study Config +STUDY_START_DATE=2024-01-01 # 스터디 시작일 +TOTAL_ROUNDS=10 # 총 회차 수 +``` + +### 환경변수 주의사항 + +- `NEXT_PUBLIC_` 접두사가 있는 변수만 브라우저에 노출됨 +- `packages/web/.env.local`이 없으면 Next.js가 환경변수를 읽지 못함 +- `DATABASE_URL`은 **Transaction Pooler** URL 사용 (Direct connection은 DNS 미지원 가능) + +## 테스트 + +```bash +# 전체 테스트 +pnpm test + +# 패키지별 +pnpm --filter @blog-study/bot test +pnpm --filter @blog-study/shared test + +# 워치 모드 +pnpm --filter @blog-study/bot test:watch +``` + +## 빌드 + +```bash +# 전체 빌드 (shared → bot/web 순서) +pnpm build + +# 패키지별 +pnpm build:bot # tsup으로 번들링 → dist/ +pnpm build:web # next build +``` + +## 코드 품질 + +```bash +pnpm lint # ESLint +pnpm format # Prettier (자동 수정) +pnpm format:check # Prettier (검사만) +pnpm typecheck # TypeScript 타입 체크 +``` + +## Supabase 설정 + +### Discord OAuth 프로바이더 +1. Supabase Dashboard → Authentication → Providers → Discord +2. `DISCORD_CLIENT_ID`와 `DISCORD_CLIENT_SECRET` 입력 +3. Callback URL을 Discord Developer Portal에 등록 + +### Redirect URL 설정 +1. Supabase Dashboard → Authentication → URL Configuration +2. Redirect URLs에 추가: + - `http://localhost:3000/auth/callback` (로컬) + - `http://localhost:3200/auth/callback` (로컬 대체 포트) + - `https://your-domain.vercel.app/auth/callback` (프로덕션) + +### pg-boss 설정 +pg-boss는 첫 실행 시 자동으로 필요한 테이블을 생성합니다. + +## Discord 봇 설정 + +### 1. 봇 생성 +1. [Discord Developer Portal](https://discord.com/developers/applications) 접속 +2. "New Application" → 이름 입력 +3. "Bot" 탭 → "Add Bot" +4. Token 복사 → `DISCORD_TOKEN`에 입력 +5. **Privileged Gateway Intents** 모두 활성화: + - PRESENCE INTENT + - SERVER MEMBERS INTENT + - MESSAGE CONTENT INTENT + +### 2. OAuth2 설정 +1. "OAuth2" → "General" +2. Redirects에 Supabase 콜백 URL 추가 +3. Client Secret 복사 → `DISCORD_CLIENT_SECRET`에 입력 + +### 3. 봇 초대 +1. "OAuth2" → "URL Generator" +2. Scopes: `bot`, `applications.commands` +3. Bot Permissions: `Send Messages`, `Manage Roles`, `Embed Links`, `Add Reactions`, `Use Slash Commands` +4. 생성된 URL로 서버에 초대 + +## 프로젝트 구조 + +``` +study-admin/ +├── CLAUDE.md # Claude Code 프로젝트 설정 +├── .env.example # 환경 변수 템플릿 +├── .env.local # 로컬 환경 변수 (gitignored) +├── package.json # 루트 패키지 (스크립트) +├── pnpm-workspace.yaml # 모노레포 설정 +├── tsconfig.json # 공통 TypeScript 설정 +├── vercel.json # Vercel 배포 설정 +├── docs/ # 프로젝트 문서 +│ ├── ARCHITECTURE.md # 시스템 아키텍처 +│ ├── TECH-DECISIONS.md # 기술 선택 근거 (ADR) +│ ├── UI-DESIGN-SYSTEM.md # UI 디자인 시스템 +│ ├── DEVELOPMENT.md # (이 파일) +│ └── CHECKLIST.md # 구현 체크리스트 +└── packages/ + ├── bot/ # Discord 봇 + │ ├── src/ + │ │ ├── index.ts # 엔트리포인트 + │ │ ├── bot.ts # Discord 클라이언트 + │ │ ├── commands/ # 슬래시 커맨드 + │ │ ├── services/ # 비즈니스 로직 + │ │ ├── schedulers/ # 크론/잡 큐 + │ │ ├── handlers/ # 이벤트 핸들러 + │ │ └── scripts/ # 초기화 스크립트 + │ └── package.json + ├── web/ # Next.js 대시보드 + │ ├── .env.local # 웹 전용 환경 변수 (gitignored) + │ ├── middleware.ts # 라우트 보호 미들웨어 + │ ├── src/ + │ │ ├── app/ # App Router 페이지 + │ │ │ ├── auth/callback/ # OAuth 콜백 + │ │ │ ├── (auth)/ # 로그인 페이지 + │ │ │ ├── (user)/ # 사용자 페이지 + │ │ │ └── (admin)/ # 관리자 페이지 + │ │ ├── components/ # UI 컴포넌트 (shadcn/ui) + │ │ └── lib/ # 유틸리티 + │ │ ├── supabase/ # Supabase 클라이언트 (client/server/middleware) + │ │ ├── admin.ts # 관리자 권한 + │ │ ├── db.ts # DB 연결 + │ │ └── api-error.ts # API 에러 처리 + │ └── package.json + └── shared/ # 공유 코드 + ├── src/ + │ ├── db/ # Drizzle 스키마, 연결 + │ ├── config/ # 설정 + │ └── utils/ # 유틸리티 함수 + ├── drizzle.config.ts # Drizzle Kit 설정 + └── package.json +``` diff --git a/docs/26-03-06-patterns.md b/docs/26-03-06-patterns.md new file mode 100644 index 0000000..d8ca481 --- /dev/null +++ b/docs/26-03-06-patterns.md @@ -0,0 +1,389 @@ +# API 패턴 & 코드 규칙 + +## 관리자 API 인증 패턴 + +### withAdminAuth 래퍼 (권장) + +```ts +// packages/web/src/lib/admin.ts 의 withAdminAuth 사용 +import { withAdminAuth } from '@/lib/admin'; + +export const GET = withAdminAuth(async (request: NextRequest, adminAuth) => { + // adminAuth.discordId — 관리자의 Discord ID + // adminAuth.userId — Supabase user ID + const database = db(); + // ... DB 조회 로직 + return NextResponse.json({ data }); +}); +``` + +### 인증 흐름 +``` +createClient() → getUser() → identities[].find(p => p.provider === 'discord').id +→ isAdminDiscordId(discordId) → 관리자 확인 +``` + +### 관련 파일 +| 파일 | 역할 | +|------|------| +| `packages/web/src/lib/admin.ts` | `withAdminAuth`, `verifyAdminAccess`, `isAdminDiscordId` | +| `packages/web/src/lib/supabase/server.ts` | `createClient()` — 서버용 Supabase | +| `packages/web/src/lib/supabase/client.ts` | 브라우저용 Supabase | +| `packages/web/src/lib/db.ts` | `db()` — shared DB 인스턴스 래퍼 | + +## Drizzle ORM Import 패턴 + +```ts +// shared 패키지에서 스키마 + enum import +import { db as sharedDb } from '@blog-study/shared'; +const { members, posts, attendance, MemberStatus, AttendanceStatus } = sharedDb; + +// drizzle-orm 연산자 +import { eq, count, sql, asc, desc, and, or, inArray } from 'drizzle-orm'; + +// DB 인스턴스 (web 패키지) +import { db } from '@/lib/db'; +const database = db(); +``` + +## API Route 기본 구조 (Next.js App Router) + +```ts +// packages/web/src/app/api/admin/{resource}/route.ts +import { NextRequest } from 'next/server'; +import { eq } from 'drizzle-orm'; +import { db } from '@/lib/db'; +import { db as sharedDb } from '@blog-study/shared'; +import { withAdminAuth } from '@/lib/admin'; +import { errorResponse, successResponse } from '@/lib/api-error'; + +const { members } = sharedDb; + +// GET — 목록 조회 +export const GET = withAdminAuth(async (request: NextRequest, _adminAuth) => { + try { + const database = db(); + const result = await database.select().from(members); + return successResponse({ data: result }); + } catch (error) { + return errorResponse(error); + } +}); + +// POST — 생성 +export const POST = withAdminAuth(async (request: NextRequest, _adminAuth) => { + try { + const body = await request.json(); + // validation → insert → returning + return successResponse(newItem, '생성되었습니다.', 201); + } catch (error) { + return errorResponse(error); + } +}); +``` + +### 동적 라우트 (패턴: `/api/admin/{resource}/[id]/route.ts`) + +```ts +// PATCH — 수정 +export const PATCH = withAdminAuth(async (request: NextRequest, _adminAuth) => { + const id = request.url.split('/').pop(); // 또는 params에서 추출 + // update → returning +}); + +// DELETE — 삭제 +export const DELETE = withAdminAuth(async (request: NextRequest, _adminAuth) => { + const id = request.url.split('/').pop(); + // delete → returning +}); +``` + +## 일반 사용자 API 패턴 + +```ts +// 인증만 필요, 관리자 권한 불필요 +import { createClient } from '@/lib/supabase/server'; +import { errorResponse, Errors, successResponse, withCache } from '@/lib/api-error'; + +export async function GET() { + try { + const supabase = await createClient(); + const { data: { user }, error } = await supabase.auth.getUser(); + if (error || !user) return Errors.unauthorized().toResponse(); + + const discordId = user.identities?.find(i => i.provider === 'discord')?.id; + // discordId로 members 테이블 조회 + return withCache(successResponse(data), 60); // 읽기 전용은 캐시 적용 + } catch (error) { + return errorResponse(error); + } +} +``` + +## 멤버 상태 리다이렉트 패턴 + +서버 + 클라이언트 이중 체크로 차단 상태 사용자의 접근을 제어: + +### 1. OAuth 콜백 (서버 사이드) + +```ts +// packages/web/src/app/auth/callback/route.ts +// 로그인 직후 DB에서 status 조회 → 상태별 리다이렉트 +if (!memberData || !memberData.onboardingCompleted) → /profile/onboarding +if (memberData.status === 'pending_approval') → /pending +if (memberData.status === 'inactive') → /inactive +``` + +### 2. UserLayout (클라이언트 사이드) + +```ts +// packages/web/src/app/(user)/layout.tsx +// checkedPathname 패턴: pathname 변경 시 자동으로 로딩 상태 진입 (플래시 방지) +const [checkedPathname, setCheckedPathname] = useState(null); + +// /api/auth/me 응답의 status 필드로 리다이렉트 판단 +// 차단 페이지 자체는 예외 처리: blockedPages = ['/pending', '/inactive'] + +// 로딩 가드: checkedPathname !== pathname이면 로딩 표시 +if (checkedPathname !== pathname && pathname !== '/profile/onboarding') → 로딩 +``` + +### 주의사항 +- `(admin)` 레이아웃은 별도 인증 → 멤버 상태 체크 없음 (관리자가 스스로 승인 가능) +- `pending`/`inactive` 페이지는 `(user)` 그룹 내에 있지만 리다이렉트 예외 처리됨 + +## 다이얼로그 패턴 + +`window.confirm()`/`window.alert()`/`window.prompt()` 사용 금지. 커스텀 다이얼로그 사용: + +```tsx +// AlertDialog (shadcn/ui) 패턴 — DeletePostDialog 참고 + + + + 제목 + 설명 + + + 취소 + 확인 + + + +``` + +## 게시판 API 인증 패턴 + +관리자 전용이 아닌 일반 사용자 API는 `getBoardAuth` + `successResponse`/`Errors` 조합 사용: + +```ts +// packages/web/src/lib/board-auth.ts +import { getBoardAuth } from '@/lib/board-auth'; +import { successResponse, errorResponse, Errors } from '@/lib/api-error'; + +export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { + try { + const auth = await getBoardAuth(); + if (!auth) return Errors.unauthorized().toResponse(); + + const { id } = await params; + const database = getDb(); + // ... DB 조회 + return successResponse(data); + } catch (error) { + return errorResponse(error); + } +} +``` + +### getBoardAuth vs withAdminAuth +| 함수 | 용도 | 반환 | +|------|------|------| +| `withAdminAuth` | 관리자 전용 API 래퍼 | `adminAuth.discordId`, `adminAuth.userId` | +| `getBoardAuth` | 일반 사용자 API 함수 | `{ memberId, discordId, isAdmin }` 또는 `null` | + +### API 표준 응답 (`api-error.ts`) +| 함수 | 용도 | +|------|------| +| `successResponse(data, message?)` | `{ success: true, data, message }` | +| `errorResponse(error)` | ApiError → 표준 에러 응답, unknown → 500 | +| `Errors.unauthorized()` | 401 | +| `Errors.forbidden(msg)` | 403 | +| `Errors.notFound(msg)` | 404 | +| `Errors.badRequest(msg)` | 400 | +| `withCache(response, maxAge, scope?)` | Cache-Control 헤더 설정 (`private` 기본) | + +## Cache-Control 패턴 + +```ts +import { withCache, successResponse } from '@/lib/api-error'; + +// 읽기 전용 API에 캐시 적용 +return withCache(successResponse(data), 60); // private, 60s, swr 120s +return withCache(successResponse(data), 30, 'public'); // public, 30s, swr 60s +``` + +| API | maxAge | scope | +|-----|--------|-------| +| `GET /api/members` | 60s | private | +| `GET /api/members/[id]` | 60s | private | +| `GET /api/ranking` | 30s | private | + +## 입력 새니타이즈 패턴 + +```ts +import { sanitizeDescription, sanitizeTiptapContent } from '@/lib/sanitize'; + +// description 필드: 제어 문자 + 제로 너비 유니코드 제거, 300자 제한 +const desc = sanitizeDescription(input); + +// Tiptap JSON content: javascript:/data:/vbscript: 프로토콜 링크 제거 +const safeContent = sanitizeTiptapContent(content); +``` + +**적용 위치**: `api/board/route.ts` (POST), `api/board/[id]/route.ts` (PATCH) + +## SSRF 방어 패턴 + +```ts +import { isSafeUrl } from '@/lib/rss-detect'; + +// 외부 URL fetch 전 반드시 체크 +if (!isSafeUrl(url)) { + return Errors.badRequest('허용되지 않은 URL입니다.').toResponse(); +} +``` + +**적용 위치**: `api/posts/manual/route.ts`, `api/admin/curation/crawl/route.ts` + +## 토스트 패턴 + +```tsx +import { toast } from 'sonner'; + +// 성공/에러 피드백 (inline 상태 관리 금지) +toast.success('저장되었습니다.'); +toast.error('오류가 발생했습니다.'); +``` + +`` 는 root `layout.tsx`에 설정됨 (`position="bottom-center"`, `richColors`) + +## 공지 배너 패턴 + +게시판 공지 글 중 1개를 전역 배너로 표시. 관리자만 설정 가능. + +### API: `GET /api/notice-banner` +- 인증 필수 (`getBoardAuth`) +- `isNoticeBanner: true` + `isPinned: true` + `deletedAt IS NULL`인 글 1개 반환 +- `title`, `contentText`, `memberName` 포함 +- `Cache-Control: no-store` (새 공지 즉시 반영) + +### 배너 활성화 로직 (POST/PATCH) +- `isNoticeBanner` 설정 시 기존 배너 자동 비활성화 (트랜잭션) +- 관리자 전용: `category === 'notice'` 또는 `isNoticeBanner` 설정 시 `auth.isAdmin` 필수 + +```ts +// 트랜잭션 패턴 (배너 clear + set 원자적 처리) +const [result] = await database.transaction(async (tx) => { + if (bannerEnabled) { + await tx.update(boardPosts).set({ isNoticeBanner: false }).where(eq(boardPosts.isNoticeBanner, true)); + } + return tx.insert(boardPosts).values({ ... }).returning(); +}); +``` + +### 클라이언트 (`NoticeBanner` 컴포넌트) +- localStorage로 상태 유지 (공지 ID별, 새 공지 시 자동 리셋) +- 상태: `open` (제목+내용 미리보기) → `collapsed` (제목만) → `closed` (숨김) +- 관리자 페이지에서는 미표시 (`{!isAdmin && }`) + +### Tiptap 에디터 한글 IME 대응 + +```ts +// compositionstart/end로 IME 조합 중 onUpdate 차단 +editorProps: { + handleDOMEvents: { + compositionstart: () => { composingRef.current = true; return false; }, + compositionend: (_view) => { + composingRef.current = false; + requestAnimationFrame(() => onChange(...)); + return false; + }, + }, +}, +``` + +## 카테고리 서버사이드 검증 + +```ts +import { isValidCategory } from '@/lib/board-config'; +if (!isValidCategory(category)) { + return Errors.badRequest('유효하지 않은 카테고리입니다.').toResponse(); +} +``` + +## MemberAvatar 재사용 컴포넌트 + +프로필 아바타 + 이름 + 멤버 상세 링크 + 관리자 뱃지를 통합 제공: + +```tsx +import { MemberAvatar } from '@/components/ui/member-avatar'; + +// 기본: 아바타만 (클릭 시 멤버 상세로 이동) + + +// 아바타 + 이름 + 관리자 뱃지 + + +// 링크 비활성화 (삭제된 댓글 등) + +``` + +| Prop | 타입 | 설명 | +|------|------|------| +| `size` | `'xs' \| 'sm' \| 'md' \| 'lg'` | 아바타 크기 | +| `showName` | boolean | 이름 표시 + 링크 포함 | +| `noLink` | boolean | 링크 비활성화 | +| `isAdmin` | boolean | 관리자 뱃지 표시 | + +## 봇 작업 프록시 패턴 + +웹 관리자 대시보드에서 봇 스케줄러를 수동 트리거하는 프록시 API: + +``` +[Web Admin UI] → POST /api/admin/bot-operations/{operationId} + → withAdminAuth (관리자 인증) + → fetch(BOT_API_URL + endpoint, { signal: AbortController(30s) }) + → [Bot Express API :3001] → /api/trigger/{operationId} + → rate limit (10/min) → isRunning guard → 작업 실행 +``` + +### 봇 API 서버 (`api-server.ts`) + +```ts +// Express 서버 (포트 3001), 각 스케줄러에 대한 POST 트리거 엔드포인트 +// rate limit: 10 requests/min per endpoint +// 각 핸들러는 isRunning/isSending 가드로 중복 실행 방지 +// 응답: { success, message, data? } 또는 409 (이미 실행 중) +``` + +### 웹 프록시 라우트 (`[operationId]/route.ts`) + +```ts +// OPERATION_ENDPOINT_MAP으로 operationId → bot endpoint 매핑 +// AbortController 30초 타임아웃 +// 에러 분류: AbortError(타임아웃), ECONNREFUSED(봇 미실행), 409(중복 실행), 기타 +``` + +### 지원 작업 목록 + +| operationId | 설명 | 스케줄 | +|-------------|------|--------| +| `rss-poll` | RSS 피드 수집 | 매 30분 | +| `attendance-check` | 출석 체크 | 회차 종료 후 | +| `fine-reminder` | 미납 벌금 알림 | 매일 | +| `round-report` | 회차 리포트 | 회차 종료 후 | +| `round-start` | 회차 시작 알림 | 회차 시작일 | +| `curation-crawl` | 큐레이션 크롤링 | 매일 09:00 | +| `curation-share` | 큐레이션 공유 | 매일 10:05 | +| `weekly-ranking` | 주간 랭킹 | 매주 일요일 22:00 | diff --git a/docs/26-03-06-schema-summary.md b/docs/26-03-06-schema-summary.md new file mode 100644 index 0000000..94c2d00 --- /dev/null +++ b/docs/26-03-06-schema-summary.md @@ -0,0 +1,202 @@ +# DB 스키마 요약 + +> 소스: `packages/shared/src/db/schema.ts` + +## Enum 타입 + +| Enum | 값 | 용도 | +|------|-----|------| +| `MemberStatus` | `pending_approval`, `active`, `inactive`, `dormant`, `ob`, `withdrawn` | 멤버 상태 | +| `AttendanceStatus` | `pending`, `submitted`, `late`, `absent` | 출석 상태 | +| `FineType` | `late`, `absent` | 벌금 사유 | +| `FineStatus` | `unpaid`, `paid`, `waived` | 벌금 납부 | +| `CurationCategory` | `conference`, `article` | 큐레이션 분류 | +| `ActivityScoreType` | `blog_post`, `board_post`, `post_comment`, `board_comment`, `admin_manual`, `post_view` | 활동 점수 | +| `BoardCategory` | `notice`, `suggestion`, `review`, `knowledge`, `daily`, `etc` | 게시판 카테고리 (const object) | +| `NotificationType` | `board_comment`, `board_reply`, `post_comment`, `post_reply`, `board_notice` | 알림 유형 (const object) | + +## 테이블 + +### members +| 컬럼 | 타입 | 비고 | +|------|------|------| +| `id` | uuid PK | defaultRandom | +| `discord_id` | varchar(20) | unique, not null | +| `discord_username` | varchar(100) | not null | +| `name` | varchar(50) | not null | +| `nickname` | varchar(100) | not null | +| `part` | varchar(50) | not null | +| `blog_url` | varchar(500) | not null | +| `rss_url` | varchar(500) | nullable | +| `profile_image_url` | varchar(500) | nullable | +| `bio` | varchar(200) | nullable | +| `interests` | text[] | nullable | +| `resolution` | varchar(300) | nullable | +| `onboarding_completed` | boolean | default false | +| `rss_consent` | boolean | default true | +| `github_url`, `linkedin_url`, `instagram_url` | varchar(500) | 소셜 링크 | +| `status` | varchar(20) | default 'active' | +| `dormant_start_round` | integer | nullable | +| `dormant_used` | boolean | default false | +| `joined_at`, `updated_at` | timestamptz | defaultNow | + +### rounds +| 컬럼 | 타입 | 비고 | +|------|------|------| +| `id` | serial PK | | +| `round_number` | integer | unique, not null | +| `start_date`, `end_date`, `grace_end_date` | date | not null | +| `is_current` | boolean | default false | + +### posts +| 컬럼 | 타입 | 비고 | +|------|------|------| +| `id` | uuid PK | | +| `member_id` | uuid FK → members | not null | +| `round_id` | integer FK → rounds | nullable | +| `title` | varchar(500) | not null | +| `url` | varchar(1000) | unique, not null | +| `published_at` | timestamptz | not null | +| `description` | text | nullable | +| `thumbnail_url` | varchar(1000) | nullable, OG 이미지 | +| `comment_count` | integer | default 0 | +| `collected_at` | timestamptz | defaultNow | + +### attendance +| 컬럼 | 타입 | 비고 | +|------|------|------| +| `id` | uuid PK | | +| `member_id` | uuid FK → members | not null | +| `round_id` | integer FK → rounds | not null | +| `status` | varchar(20) | default 'pending' | +| `submitted_at` | timestamptz | nullable | +| unique constraint: `(member_id, round_id)` | + +### fines +| 컬럼 | 타입 | 비고 | +|------|------|------| +| `id` | uuid PK | | +| `member_id` | uuid FK → members | not null | +| `round_id` | integer FK → rounds | not null | +| `type` | varchar(20) | late/absent | +| `amount` | integer | not null | +| `status` | varchar(20) | default 'unpaid' | +| unique constraint: `(member_id, round_id)` | + +### activity_scores +| 컬럼 | 타입 | 비고 | +|------|------|------| +| `id` | uuid PK | | +| `member_id` | uuid FK → members | not null | +| `type` | varchar(30) | ActivityScoreType | +| `points` | integer | not null | +| `description` | varchar(300) | nullable | +| `date` | date | 일일 상한 체크용 | + +### post_views +| 컬럼 | 타입 | 비고 | +|------|------|------| +| `id` | uuid PK | | +| `member_id` | uuid FK → members | not null | +| `post_id` | uuid FK → posts | not null | +| unique constraint: `(member_id, post_id)` | + +### post_comments +| 컬럼 | 타입 | 비고 | +|------|------|------| +| `id` | uuid PK | defaultRandom | +| `post_id` | uuid FK → posts | not null | +| `member_id` | uuid FK → members | not null | +| `parent_id` | uuid | nullable (대댓글) | +| `content` | text | not null | +| `is_secret` | boolean | default false | +| `created_at`, `updated_at` | timestamptz | defaultNow | +| `deleted_at` | timestamptz | nullable (soft delete) | +| 인덱스: `post_id`, `member_id`, `parent_id` | + +### config +`key` (varchar PK) / `value` (text) / `updated_at` — 설정 키-값 저장소 + +### board_posts +| 컬럼 | 타입 | 비고 | +|------|------|------| +| `id` | uuid PK | defaultRandom | +| `member_id` | uuid FK → members | not null | +| `category` | varchar(20) | BoardCategory, not null | +| `title` | varchar(200) | not null | +| `content` | jsonb | Tiptap JSON, not null | +| `content_text` | text | 검색용 평문, not null | +| `is_secret` | boolean | default false | +| `is_pinned` | boolean | default false | +| `is_notice_banner` | boolean | default false, 글로벌 배너 활성화 (1개만) | +| `comment_count` | integer | default 0 | +| `created_at`, `updated_at` | timestamptz | defaultNow | +| `deleted_at` | timestamptz | nullable (soft delete) | +| 인덱스: `member_id`, `category`, `is_pinned`, `created_at` | + +### board_comments +| 컬럼 | 타입 | 비고 | +|------|------|------| +| `id` | uuid PK | defaultRandom | +| `post_id` | uuid FK → board_posts | not null | +| `member_id` | uuid FK → members | not null | +| `parent_id` | uuid FK → board_comments | nullable (대댓글) | +| `content` | text | not null | +| `is_secret` | boolean | default false | +| `created_at`, `updated_at` | timestamptz | defaultNow | +| `deleted_at` | timestamptz | nullable (soft delete) | +| 인덱스: `post_id`, `member_id`, `parent_id` | + +### fcm_tokens +| 컬럼 | 타입 | 비고 | +|------|------|------| +| `id` | uuid PK | defaultRandom | +| `member_id` | uuid FK → members | not null | +| `token` | text | not null | +| `device_info` | text | nullable | +| `last_used_at` | timestamptz | defaultNow | +| unique constraint: `(member_id, token)` | +| 인덱스: `member_id` | + +### notification_preferences +| 컬럼 | 타입 | 비고 | +|------|------|------| +| `id` | uuid PK | defaultRandom | +| `member_id` | uuid FK → members | not null | +| `type` | varchar(30) | NotificationType, not null | +| `enabled` | boolean | default true | +| `updated_at` | timestamptz | defaultNow | +| unique constraint: `(member_id, type)` | +| 인덱스: `member_id` | + +### keywords, curation_sources, curation_items +- `keywords`: keyword(unique) + frequency + last_updated +- `curation_sources`: url(unique) + name + category + rss_url + tags[] + is_active +- `curation_items`: source_id FK → curation_sources, url(unique) + title + description + thumbnail_url + category + tags[] + relevance_score + is_shared + +## FK 관계 요약 + +``` +members ──< posts (member_id) +members ──< attendance (member_id) +members ──< fines (member_id) +members ──< activity_scores (member_id) +members ──< post_views (member_id) +rounds ──< posts (round_id) +rounds ──< attendance (round_id) +rounds ──< fines (round_id) +posts ──< post_comments (post_id) +posts ──< post_views (post_id) +members ──< post_comments (member_id) +curation_sources ──< curation_items (source_id) +members ──< board_posts (member_id) +members ──< board_comments (member_id) +board_posts ──< board_comments (post_id) +board_comments ──< board_comments (parent_id, self-ref) +members ──< fcm_tokens (member_id) +members ──< notification_preferences (member_id) +``` + +## 타입 Export + +모든 테이블에 `Type`/`NewType` export 있음 (예: `Member`/`NewMember`, `Post`/`NewPost`, `BoardPost`/`NewBoardPost`, `BoardComment`/`NewBoardComment`, `FcmToken`/`NewFcmToken`, `NotificationPreference`/`NewNotificationPreference`) diff --git a/docs/26-03-06-tech-decisions.md b/docs/26-03-06-tech-decisions.md new file mode 100644 index 0000000..6b2ccd5 --- /dev/null +++ b/docs/26-03-06-tech-decisions.md @@ -0,0 +1,121 @@ +# 기술 결정 기록 (ADR) + +## 결정 요약 + +| # | 결정 | 선택 | 대안 | 이유 | +|---|------|------|------|------| +| 1 | Next.js 버전 | **16** | 14 유지, 15 | React 19, Tailwind v4 네이티브 지원 | +| 2 | 인증 | **Supabase Auth** | 커스텀 JWT | Discord OAuth 네이티브, 유지보수 비용 제거 | +| 3 | RSS 파서 | **feedsmith** | rss-parser | rss-parser 3년 미유지보수, feedsmith 활발 | +| 4 | 작업 큐 | **pg-boss** | node-cron, Upstash Redis | 추가 인프라 불필요, PostgreSQL 기반 트랜잭션 | +| 5 | 실시간 | **Supabase Realtime** (선택적) | WebSocket 직접 구현 | 활동 피드만 적용, 20줄로 구현 가능 | + +--- + +## ADR-1: Next.js 16 업그레이드 + +**상태**: ✅ 구현 완료 (PR #5) + +**결정**: Next.js 14 → 16 업그레이드 (React 19, Tailwind CSS v4 포함) + +**근거**: +- React 19: 서버 컴포넌트/액션 안정화 +- Tailwind CSS v4: 네이티브 CSS 기반, 빌드 성능 향상 +- Turbopack 안정화: 빌드/HMR 성능 개선 + +**마이그레이션 내용**: +- `params`/`cookies()`/`headers()` async 변환 +- Tailwind v4 설정 마이그레이션 (PostCSS 기반) +- React 18 → 19 호환성 업데이트 + +--- + +## ADR-2: Supabase Auth로 전환 + +**상태**: ✅ 구현 완료 (PR #2) + +**결정**: 커스텀 이메일/JWT 인증 → Supabase Auth (Discord OAuth) + +**근거**: +- Discord OAuth2 네이티브 지원 (콜백 URL 자동 관리) +- JWT, 세션, 토큰 리프레시 코드 전부 삭제 가능 +- RLS 정책 활용 가능 +- 무료 50,000 MAU (스터디 규모에 충분) + +**아키텍처 분리**: +- 웹: Supabase Auth (Discord OAuth2 PKCE 플로우) +- 봇: `service_role` 키 + Discord ID로 직접 DB 조회/수정 +- 봇은 Auth 레이어 무관 + +**구현 내용**: +- `@supabase/ssr` 기반 SSR 클라이언트 (client/server/middleware) +- Discord ID 추출: `user.identities[].id` where `provider === 'discord'` +- 미들웨어: `updateSession()`으로 세션 자동 갱신 + 라우트 보호 +- 관리자: `ADMIN_DISCORD_IDS` 환경변수로 Discord ID 기반 권한 체크 +- DB: Transaction Pooler 사용 (`prepare: false`) + +**제거 완료**: +- `users`, `sessions` 테이블 (Supabase Auth의 `auth.users`로 대체) +- `packages/web/src/lib/auth.ts` (커스텀 JWT 로직) +- `packages/web/src/lib/email.ts` (이메일 인증) +- `bcryptjs`, `jsonwebtoken`, `jose`, `resend` 의존성 +- 회원가입/이메일인증 페이지 및 API + +--- + +## ADR-3: feedsmith 채택 + +**상태**: ✅ 구현 완료 + +**결정**: rss-parser → feedsmith + +**근거**: +- rss-parser: 마지막 배포 3년 전, 비활성 +- feedsmith: 2025 활발, RSS/Atom/RDF/JSON Feed 모두 지원 +- 피드 생성 기능도 있어 향후 RSS 피드 노출 가능 +- rss-parser보다 빠름 + +**구현 내용**: +- `RssService`: axios fetch + `parseFeed()` 분리 구조 +- `extractFeedItems()` 헬퍼로 RSS/Atom/JSON/RDF 전체 포맷 정규화 +- `detectRssUrl()`, `fetchFeed()` 모두 feedsmith 기반으로 전환 + +--- + +## ADR-4: pg-boss로 작업 큐 전환 + +**상태**: ✅ 구현 완료 + +**결정**: node-cron → pg-boss + +**근거**: +- node-cron: 프로세스 내 메모리 기반, 재시작 시 상태 유실 +- pg-boss: PostgreSQL 기반, 트랜잭션 보장, 재시도/동시성 관리 +- Upstash Redis 불필요: 추가 인프라, 커맨드당 과금, BullMQ 호환 문제 + +**구현 내용**: +- `job-queue.ts`: pg-boss 싱글톤 (start/stop/get) +- `scheduler-registry.ts`: 6개 cron 잡 등록 + 워커 연결 +- `index.ts`에서 pg-boss 시작 → 잡 등록 → graceful shutdown 통합 +- `DATABASE_URL_DIRECT` 환경변수 추가 (pg-boss LISTEN/NOTIFY용) + +**등록된 잡**: +| 잡 이름 | cron | 핸들러 | +|---------|------|--------| +| `rss-poll` | `*/5 * * * *` | `RssPoller.poll()` | +| `attendance-check` | `0 0 * * 2` | `AttendanceChecker.check()` | +| `fine-reminder` | `0 10 * * *` | `FineReminder.sendReminders()` | +| `round-report` | `5 0 * * 2` | `RoundReporter.sendRoundReport()` | +| `curation-crawl` | `0 9 * * *` | `CurationCrawler.crawl()` | +| `curation-share` | `0 10 * * *` | `CurationCrawler.shareDailyContent()` | + +--- + +## 비용 총 요약 + +| 서비스 | 월 비용 | +|--------|--------| +| AWS EC2 (Bot) | 기존 서버 활용 | +| Vercel (Web) | $0 (무료) | +| Supabase (DB + Auth) | $0 (무료) ~ $25 (Pro) | +| **합계** | **$0 ~ $25/월** | diff --git a/docs/26-03-06-ui-design-system.md b/docs/26-03-06-ui-design-system.md new file mode 100644 index 0000000..f6bb301 --- /dev/null +++ b/docs/26-03-06-ui-design-system.md @@ -0,0 +1,286 @@ +# UI 디자인 시스템 + +## 디자인 방향 + +**컨셉**: Vercel 화이트/블랙 미니멀 + 스카이블루 포인트 +- 해외 어드민 사이트 느낌: 깔끔, 세련, 여백 활용 +- 참고: hazel-admin (레이아웃/컴포넌트), obsidian-quartz-blog (타이포/색상 체계) + +--- + +## 컬러 시스템 + +### Light Mode + +| 토큰 | 색상 | 용도 | +|------|------|------| +| `--background` | `#ffffff` | 페이지 배경 | +| `--foreground` | `#18181b` (zinc-900) | 기본 텍스트 | +| `--muted` | `#f4f4f5` (zinc-100) | 비활성 배경, 카드 배경 | +| `--muted-foreground` | `#71717a` (zinc-500) | 보조 텍스트, 플레이스홀더 | +| `--border` | `#e4e4e7` (zinc-200) | 테두리, 구분선 | +| `--primary` | `#0ea5e9` (sky-500) | 주요 액션, 링크, 포인트 | +| `--primary-hover` | `#0284c7` (sky-600) | 호버 상태 | +| `--primary-foreground` | `#ffffff` | primary 위 텍스트 | +| `--primary-muted` | `#e0f2fe` (sky-100) | 포인트 배경, 뱃지 | +| `--secondary` | `#f4f4f5` (zinc-100) | 보조 버튼 배경 | +| `--secondary-foreground` | `#18181b` | 보조 버튼 텍스트 | +| `--destructive` | `#ef4444` (red-500) | 삭제, 에러 | +| `--success` | `#22c55e` (green-500) | 성공, 출석 | +| `--warning` | `#f59e0b` (amber-500) | 경고, 지각 | +| `--accent` | `#f4f4f5` | 사이드바 호버 | +| `--ring` | `#0ea5e9` | 포커스 링 | + +### Dark Mode + +| 토큰 | 색상 | +|------|------| +| `--background` | `#09090b` (zinc-950) | +| `--foreground` | `#fafafa` (zinc-50) | +| `--muted` | `#27272a` (zinc-800) | +| `--muted-foreground` | `#a1a1aa` (zinc-400) | +| `--border` | `#3f3f46` (zinc-700) | +| `--primary` | `#38bdf8` (sky-400) | +| `--primary-hover` | `#0ea5e9` (sky-500) | +| `--primary-muted` | `#0c4a6e` (sky-900) | +| `--secondary` | `#27272a` (zinc-800) | + +### 차트 컬러 + +``` +--chart-1: #0ea5e9 (sky - 출석/제출) +--chart-2: #22c55e (green - 성공) +--chart-3: #f59e0b (amber - 지각) +--chart-4: #ef4444 (red - 결석) +--chart-5: #8b5cf6 (violet - 기타) +``` + +--- + +## 타이포그래피 + +### 폰트 + +```css +--font-sans: "Pretendard Variable", "Pretendard", + -apple-system, BlinkMacSystemFont, system-ui, Roboto, + "Helvetica Neue", "Segoe UI", sans-serif; + +--font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, + Menlo, Monaco, Consolas, monospace; +``` + +**Pretendard 로딩**: CDN (`cdn.jsdelivr.net/gh/orioncactus/pretendard`) + +### 크기 스케일 + +| 용도 | 크기 | 무게 | 행간 | +|------|------|------|------| +| h1 (페이지 제목) | `clamp(1.65rem, 1.4rem + 1vw, 2rem)` | 700 | 1.2 | +| h2 (섹션 제목) | `clamp(1.35rem, 1.2rem + 0.6vw, 1.5rem)` | 600 | 1.3 | +| h3 (서브섹션) | `clamp(1.1rem, 1rem + 0.4vw, 1.25rem)` | 600 | 1.4 | +| body | `0.875rem` (14px) | 400 | 1.6 | +| small/label | `0.75rem` (12px) | 500 | 1.4 | +| code | `0.85rem` | 400 | 1.5 | + +### 자간 + +```css +body { letter-spacing: -0.014em; } +h1, h2, h3 { letter-spacing: -0.025em; } +``` + +--- + +## 디자인 토큰 + +### 간격 (Spacing) + +```css +--spacing-xs: 0.25rem; /* 4px */ +--spacing-sm: 0.5rem; /* 8px */ +--spacing-md: 1rem; /* 16px */ +--spacing-lg: 1.5rem; /* 24px */ +--spacing-xl: 2rem; /* 32px */ +--spacing-2xl: 3rem; /* 48px */ +``` + +### 라운딩 (Border Radius) + +```css +--radius: 0.75rem; /* 12px - 기본 */ +--radius-sm: 0.5rem; /* 8px - 뱃지, 인라인 코드 */ +--radius-md: 0.625rem; /* 10px */ +--radius-lg: 0.75rem; /* 12px - 카드 */ +--radius-xl: 1rem; /* 16px - 모달 */ +--radius-full: 9999px; /* 원형 - 아바타 */ +``` + +### 그림자 (Shadow) + +```css +--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05); +--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.07), + 0 2px 4px -2px rgba(0, 0, 0, 0.05); +--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.08), + 0 4px 6px -4px rgba(0, 0, 0, 0.04); +``` + +### 트랜지션 + +```css +--transition: 0.2s cubic-bezier(0.4, 0, 0.2, 1); +--transition-slow: 0.4s cubic-bezier(0.4, 0, 0.2, 1); +``` + +--- + +## 레이아웃 + +### 데스크톱 (≥1024px) + +``` +┌──────────────────────────────────────────────────────────┐ +│ Sidebar (240px) │ Main Content (max-w-7xl, mx-auto) │ +│ │ │ +│ ┌─────────────┐ │ ┌─ Header (sticky) ──────────────┐ │ +│ │ Logo │ │ │ 페이지 제목 [🔔] [🌙] [👤] │ │ +│ │ │ │ └────────────────────────────────┘ │ +│ │ ─────────── │ │ │ +│ │ 대시보드 │ │ ┌─ Content ──────────────────────┐ │ +│ │ 글 목록 │ │ │ │ │ +│ │ 랭킹 │ │ │ p-4 sm:p-6 lg:p-8 │ │ +│ │ 큐레이션 │ │ │ │ │ +│ │ │ │ └────────────────────────────────┘ │ +│ │ ─────────── │ │ │ +│ │ 관리자 │ │ │ +│ │ 멤버 │ │ │ +│ │ 출석 │ │ │ +│ │ 벌금 │ │ │ +│ │ 설정 │ │ │ +│ │ │ │ │ +│ │ ─────────── │ │ │ +│ │ [👤 유저] │ │ │ +│ └─────────────┘ │ │ +└──────────────────────────────────────────────────────────┘ +``` + +### 모바일 (<1024px) + +- 사이드바 → 햄버거 메뉴 (오버레이 드로어) +- 컨텐츠 전체 너비 +- 반응형 패딩: `p-4` + +### 사이드바 기능 + +- 접기/펼치기 (아이콘만 모드) +- localStorage로 상태 유지 +- 활성 메뉴: 좌측 스카이블루 바 인디케이터 +- 접힌 상태: 아이콘 + 툴팁 + +--- + +## 컴포넌트 패턴 + +### 버튼 Variants + +| Variant | 배경 | 텍스트 | 용도 | +|---------|------|--------|------| +| `default` | zinc-900 | white | 주요 액션 | +| `primary` | sky-500 | white | 포인트 액션 (참가, 저장) | +| `secondary` | zinc-100 | zinc-900 | 보조 액션 | +| `outline` | transparent + border | zinc-900 | 3차 액션 | +| `ghost` | transparent | zinc-900 | 사이드바, 아이콘 | +| `destructive` | red-500 | white | 삭제, 위험 액션 | +| `link` | transparent | sky-500 | 텍스트 링크 | + +**크기**: `sm` (h-8), `default` (h-9), `lg` (h-10), `icon` (h-9 w-9) + +### 카드 + +``` +┌─ Card ──────────────────────────────┐ +│ ┌─ CardHeader ──────────────────┐ │ +│ │ CardTitle CardAction │ │ +│ │ CardDescription │ │ +│ └──────────────────────────────┘ │ +│ ┌─ CardContent ─────────────────┐ │ +│ │ │ │ +│ └──────────────────────────────┘ │ +│ ┌─ CardFooter ──────────────────┐ │ +│ │ │ │ +│ └──────────────────────────────┘ │ +└────────────────────────────────────┘ +``` + +- 패딩: `px-6` +- 라운딩: `rounded-lg` (12px) +- 호버: `translateY(-2px)` + 그림자 증가 + +### 데이터 테이블 + +- shadcn/ui Table 컴포넌트 +- 정렬, 필터, 페이지네이션 +- 모바일: 수직 레이아웃 전환 + +### 상태 뱃지 + +| 상태 | 색상 | 텍스트 | +|------|------|--------| +| 출석(submitted) | green-100/green-800 | 제출 완료 | +| 미제출(pending) | zinc-100/zinc-600 | 미제출 | +| 지각(late) | amber-100/amber-800 | 지각 | +| 결석(absent) | red-100/red-800 | 결석 | +| 활성(active) | sky-100/sky-800 | 활성 | +| 휴면(dormant) | zinc-100/zinc-600 | 휴면 | +| 탈퇴(withdrawn) | zinc-50/zinc-400 | 탈퇴 | + +--- + +## 아이콘 + +**lucide-react** 사용. 주요 아이콘: + +| 메뉴 | 아이콘 | +|------|--------| +| 대시보드 | `LayoutDashboard` | +| 글 목록 | `FileText` | +| 랭킹 | `Trophy` | +| 큐레이션 | `Newspaper` | +| 멤버 관리 | `Users` | +| 출석 | `CalendarCheck` | +| 벌금 | `Banknote` | +| 설정 | `Settings` | +| 알림 | `Bell` | +| 다크모드 | `Moon` / `Sun` | +| 프로필 | `User` | + +--- + +## 다크모드 + +- `next-themes` 사용 +- `attribute="class"` (html에 `dark` 클래스 추가) +- `defaultTheme="system"` (시스템 설정 따름) +- CSS 변수 기반 전환 (트랜지션 없음 - `disableTransitionOnChange`) + +--- + +## 반응형 브레이크포인트 + +| 이름 | 너비 | 변경 | +|------|------|------| +| mobile | < 768px | 1열, 사이드바 숨김 | +| tablet | 768px - 1023px | 1열, 축소된 사이드바 | +| desktop | ≥ 1024px | 사이드바 + 메인 컨텐츠 | + +--- + +## 접근성 (a11y) + +- 시맨틱 HTML: `
`, `