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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,11 @@ pnpm --filter @blog-study/bot rss-collect # 수동 RSS 수집 (봇 없이)
- **토스트**: `sonner` 라이브러리 사용 (`toast.success()`, `toast.error()`) — inline 상태 관리 토스트 금지
- **API 응답**: 모든 API 라우트는 `Errors.*()` + `successResponse()` + `errorResponse()` 패턴 사용 (직접 `NextResponse.json` 금지)
- **캐시**: 읽기 전용 API에 `withCache(response, maxAge)` 적용 (members: 60s, ranking: 30s)
- **보안**: Tiptap content는 저장 전 `sanitizeTiptapContent()` 적용, 외부 URL fetch 시 `isSafeUrl()` SSRF 체크
- **보안**: Tiptap content는 저장 전 `sanitizeTiptapContent()` 적용, 댓글 content는 `sanitizeDescription()` 적용, 외부 URL fetch 시 `isSafeUrl()` SSRF 체크
- **댓글 길이**: 최대 5000자 제한 (API에서 검증)
- **백그라운드 작업**: API route에서 푸시 알림/점수 부여 등 fire-and-forget 작업은 `after()` from `next/server` 사용 (Vercel 서버리스 종료 방지)
- **비밀댓글 알림**: 비밀댓글(`isSecret`)의 푸시 알림은 내용 마스킹 (`'비밀 댓글이 달렸습니다.'`)
- **비밀댓글 알림**: 비밀댓글(`isSecret`)의 푸시 알림은 내용 마스킹 (`'비밀 댓글이 달렸습니다.'`), 포스트/게시판 댓글 모두 적용
- **비밀댓글 isSecret 토글**: PATCH 시 본인만 변경 가능 (관리자도 타인 비밀 상태 변경 불가)

## 핵심 파일 위치

Expand Down
134 changes: 94 additions & 40 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,62 +1,116 @@
# Study Admin
# 큐스팅 4th · 블로그 글쓰기 스터디

2주에 한 편, 블로그 글을 쓰며 함께 성장하는 스터디 플랫폼.
웹 대시보드 + Discord 봇으로 스터디 운영을 자동화합니다.

## 아키텍처

```mermaid
graph TB
subgraph Client["사용자"]
BROWSER["브라우저 / PWA"]
end

subgraph Web["Web · Vercel"]
NEXT["Next.js 16<br/>App Router"]
API["API Routes"]
FCM_WEB["FCM Push"]
end

subgraph Bot["Bot · AWS EC2"]
DISCORD_BOT["discord.js v14"]
SCHEDULER["pg-boss<br/>스케줄러"]
BOT_API["Express API<br/>수동 트리거"]
end

subgraph Infra["인프라"]
SUPABASE["Supabase<br/>PostgreSQL + Auth"]
FIREBASE["Firebase<br/>Cloud Messaging"]
SENTRY["Sentry<br/>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
```

블로그 스터디 운영 자동화 플랫폼. Discord 봇 + 웹 대시보드.
## 기술 스택

## 구조
| 영역 | 기술 |
|------|------|
| 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/
bot/ Discord 봇 (discord.js, pg-boss, Railway 배포)
web/ Next.js 대시보드 (Vercel 배포)
shared/ DB 스키마, 타입, 유틸리티 (Drizzle ORM)
├── shared/ # DB 스키마, 타입, 유틸 (Drizzle ORM)
├── bot/ # Discord 봇 (스케줄러 + 이벤트 핸들러)
└── web/ # Next.js 대시보드 (사용자 + 관리자)
```

## 기술 스택

| 영역 | 기술 |
|------|------|
| Frontend | Next.js 16, React 19, Tailwind CSS v4, shadcn/ui |
| Backend | Next.js API Routes, Supabase |
| Bot | discord.js v14, pg-boss |
| DB | PostgreSQL (Supabase), Drizzle ORM |
| Deploy | Vercel (Web), AWS EC2 (Bot), Supabase (DB) |
**모노레포**: pnpm workspace

## 주요 기능

- **RSS 자동 수집** — 블로그 글 발행 감지 및 수집
### 사용자
- **대시보드** — 현재 회차, 출석 상태, 활동 점수, 최근 포스트
- **포스트 피드** — 스터디원 블로그 글 모아보기, 댓글/비밀댓글, 조회수
- **랭킹** — 활동 기반 실시간 랭킹
- **커뮤니티 게시판** — 공지/건의/후기/지식공유/일상, 댓글/비밀댓글/투표
- **프로필** — 활동 내역, 알림 설정, 소셜 링크
- **PWA 푸시 알림** — 댓글/답글/공지 알림

### 자동화 (봇)
- **RSS 자동 수집** — 블로그 글 발행 시 자동 감지 및 수집
- **출석 자동화** — 2주 1회차, 지각/결석 자동 판정
- **벌금 관리** — 자동 부과, DM 알림, 납부 확인
- **활동 점수 & 랭킹** — 포스트 수, 출석률, 활동 기반 실시간 랭킹
- **큐레이션** — 태그 + 관심 키워드 기반 컨퍼런스/아티클 추천
- **커뮤니티 게시판** — 공지/일반/자유 게시글 + 댓글
- **벌금 관리** — 자동 부과, DM 리마인드
- **주간 랭킹** — 매주 활동 점수 랭킹 디스코드 공유

## 시작하기

```bash
# 의존성 설치
pnpm install

# 웹 개발 서버
pnpm dev:web

# 봇 개발 서버
pnpm dev:bot

# 전체 빌드
pnpm build

# 타입 체크
pnpm typecheck
pnpm install # 의존성 설치
pnpm dev:web # 웹 개발 서버 (localhost:3300)
pnpm dev:bot # 봇 개발 서버
pnpm build # 전체 빌드
pnpm typecheck # 타입 체크
pnpm lint # 린트
```

## 환경 변수

`.env.example` 참조.
`.env.example` 참조. 두 곳에 설정 필요:
- `.env.local` — 루트 (shared/bot용)
- `packages/web/.env.local` — Next.js용

## 문서

- [아키텍처](docs/ARCHITECTURE.md)
- [기술 결정](docs/26-03-06-tech-decisions.md)
- [UI 디자인 시스템](docs/26-03-06-ui-design-system.md)
- [개발 환경](docs/26-03-06-development.md)
- [구현 체크리스트](docs/26-03-06-checklist.md)
| 문서 | 설명 |
|------|------|
| [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) | 팀 온보딩 가이드 |
15 changes: 15 additions & 0 deletions docs/26-03-06-schema-summary.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,19 @@
| `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` — 설정 키-값 저장소

Expand Down Expand Up @@ -172,7 +185,9 @@ 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)
Expand Down
13 changes: 12 additions & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ graph TB

subgraph DB["Supabase · PostgreSQL"]
AUTH["Supabase Auth<br/>Discord OAuth"]
TABLES["members · posts · rounds<br/>attendance · fines · config<br/>keywords · curation · activity_scores<br/>post_views · board_posts · board_comments<br/>fcm_tokens · notification_preferences"]
TABLES["members · posts · rounds<br/>attendance · fines · config<br/>keywords · curation · activity_scores<br/>post_views · post_comments · board_posts<br/>board_comments · fcm_tokens · notification_preferences"]
PGBOSS["pg-boss<br/>Job Queue"]
end

Expand Down Expand Up @@ -297,13 +297,15 @@ erDiagram
members ||--o{ fines : "벌금"
members ||--o{ activity_scores : "활동점수"
members ||--o{ post_views : "조회"
members ||--o{ post_comments : "포스트댓글"
members ||--o{ board_posts : "게시글"
members ||--o{ board_comments : "댓글"
members ||--o{ fcm_tokens : "FCM토큰"
members ||--o{ notification_preferences : "알림설정"
rounds ||--o{ posts : "회차"
rounds ||--o{ attendance : "회차"
rounds ||--o{ fines : "회차"
posts ||--o{ post_comments : "포스트댓글"
posts ||--o{ post_views : "조회기록"
curation_sources ||--o{ curation_items : "수집"
board_posts ||--o{ board_comments : "댓글"
Expand Down Expand Up @@ -369,6 +371,15 @@ erDiagram
uuid post_id FK
}

post_comments {
uuid id PK
uuid post_id FK
uuid member_id FK
uuid parent_id FK
text content
boolean is_secret
}

board_posts {
uuid id PK
uuid member_id FK
Expand Down
11 changes: 3 additions & 8 deletions packages/shared/src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,7 @@ export const postComments = pgTable(
.references(() => members.id),
parentId: uuid('parent_id'),
content: text('content').notNull(),
isSecret: boolean('is_secret').default(false),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow(),
deletedAt: timestamp('deleted_at', { withTimezone: true }),
Expand Down Expand Up @@ -430,10 +431,7 @@ export const PollType = {

export type PollTypeType = (typeof PollType)[keyof typeof PollType];

export const pollTypeEnum = pgEnum('poll_type', [
'text',
'date',
]);
export const pollTypeEnum = pgEnum('poll_type', ['text', 'date']);

export const boardPolls = pgTable(
'board_polls',
Expand Down Expand Up @@ -698,10 +696,7 @@ export const boardPollsRelations = relations(boardPolls, ({ one, many }) => ({
votes: many(boardPollVotes),
}));

export const boardPollOptionsRelations = relations(boardPollOptions, ({
one,
many,
}) => ({
export const boardPollOptionsRelations = relations(boardPollOptions, ({ one, many }) => ({
poll: one(boardPolls, {
fields: [boardPollOptions.pollId],
references: [boardPolls.id],
Expand Down
16 changes: 4 additions & 12 deletions packages/web/src/app/(admin)/admin/scores/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,9 @@ import {
TableHeader,
TableRow,
} from '@/components/ui/table';
import { PageError, AdminScoresSkeleton } from '@/components/ui/page-state';
import { AdminScoresSkeleton, PageError } from '@/components/ui/page-state';
import { cn } from '@/lib/utils';
import { getScoreTypeBadgeClass, getScoreTypeLabel } from '@/lib/score-config';

// ─── Types ────────────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -63,8 +64,6 @@ interface MemberScoreSummary {

// ─── Constants ────────────────────────────────────────────────────────────────

import { getScoreTypeLabel, getScoreTypeBadgeClass } from '@/lib/score-config';

const TOP_MEMBERS_COUNT = 5;

// ─── Helpers ──────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -181,7 +180,6 @@ function MemberSelector({
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-3.5 w-3.5 text-muted-foreground" />
<Input
autoFocus
placeholder="이름, 닉네임, 파트 검색..."
className="pl-8 h-8 text-sm"
value={searchQuery}
Expand Down Expand Up @@ -903,9 +901,7 @@ export default function AdminScoresPage() {
</div>
<div>
<h2 className="text-lg font-semibold">점수 내역 삭제</h2>
<p className="text-sm text-muted-foreground">
이 작업은 되돌릴 수 없습니다.
</p>
<p className="text-sm text-muted-foreground">이 작업은 되돌릴 수 없습니다.</p>
</div>
</div>

Expand Down Expand Up @@ -943,11 +939,7 @@ export default function AdminScoresPage() {
<Button variant="outline" onClick={handleDeleteClose}>
취소
</Button>
<Button
variant="destructive"
onClick={handleDeleteConfirm}
disabled={deleteLoading}
>
<Button variant="destructive" onClick={handleDeleteConfirm} disabled={deleteLoading}>
{deleteLoading ? '삭제 중...' : '삭제'}
</Button>
</div>
Expand Down
Loading
Loading