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
16 changes: 6 additions & 10 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,27 +1,23 @@
# 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
SUPABASE_URL=https://your-project.supabase.co
SUPABASE_ANON_KEY=your_supabase_anon_key
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:5432/database

# Web Authentication
JWT_SECRET=your_jwt_secret_key_min_32_chars
SESSION_EXPIRY=7d

# Email (Resend)
RESEND_API_KEY=your_resend_api_key
EMAIL_FROM=noreply@yourdomain.com

# Application
APP_URL=http://localhost:3000
NODE_ENV=development

# OpenAI (AI 기능용)
OPENAI_API_KEY=your_openai_api_key

# Study Configuration (optional, can be set via admin commands)
STUDY_START_DATE=2024-01-01
TOTAL_ROUNDS=10
Expand Down
58 changes: 38 additions & 20 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@

```
packages/
├── bot/ # Discord 봇 (discord.js v14) → Railway 배포
├── web/ # Next.js 15 대시보드 → Vercel 배포
├── bot/ # Discord 봇 (discord.js v14) → AWS EC2 배포
├── web/ # Next.js 14 대시보드 → Vercel 배포
└── shared/ # 공유 코드 (DB 스키마, 타입, 유틸)
```

Expand All @@ -17,13 +17,13 @@ packages/

| 영역 | 기술 |
|------|------|
| Runtime | Node.js 20 LTS, TypeScript 5.x |
| Bot | discord.js v14, feedsmith (RSS), pg-boss (job queue) |
| Web | Next.js 15 App Router, shadcn/ui, Tailwind CSS v4 |
| DB | Supabase PostgreSQL + Drizzle ORM + pgvector |
| Auth | Supabase Auth (Discord OAuth) |
| AI | OpenAI GPT-4o-mini (요약/키워드), text-embedding-3-small (벡터) |
| 배포 | Railway (bot), Vercel (web), Supabase (DB) |
| Runtime | Node.js 22, TypeScript 5.x |
| Bot | discord.js v14, rss-parser (→ feedsmith 예정), node-cron (→ pg-boss 예정) |
| Web | Next.js 14 App Router, React 18, shadcn/ui, Tailwind CSS v3 |
| DB | Supabase PostgreSQL + Drizzle ORM (Transaction Pooler, `prepare: false`) |
| Auth | Supabase Auth (Discord OAuth) + `@supabase/ssr` |
| AI | OpenAI GPT-4o-mini + text-embedding-3-small (예정) |
| 배포 | AWS EC2 (bot), Vercel (web), Supabase (DB + Auth) |

## 개발 명령어

Expand All @@ -33,11 +33,14 @@ pnpm dev:bot # 봇 로컬 실행
pnpm dev:web # 웹 로컬 실행 (localhost:3000)

# 빌드/테스트
pnpm build # 전체 빌드
pnpm build # 전체 빌드 (shared → bot/web)
pnpm test # 전체 테스트
pnpm lint # 전체 린트
pnpm typecheck # 타입 체크

# shared 패키지 변경 시
pnpm --filter @blog-study/shared build # 반드시 리빌드

# 봇 전용
pnpm --filter @blog-study/bot deploy-commands # 슬래시 커맨드 등록
pnpm --filter @blog-study/bot init-rounds # 회차 초기화
Expand All @@ -58,13 +61,24 @@ pnpm --filter @blog-study/bot init-rounds # 회차 초기화
| 파일 | 설명 |
|------|------|
| `packages/shared/src/db/schema.ts` | 전체 DB 스키마 (Drizzle) |
| `packages/shared/src/types/index.ts` | 공유 enum/타입 |
| `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/middleware.ts` | 라우트 보호 (protected/admin/auth) |
| `packages/web/src/app/auth/callback/route.ts` | OAuth 콜백 |
| `packages/web/src/lib/admin.ts` | 관리자 권한 체크 |
| `packages/web/src/app/` | Next.js 페이지/라우트 |
| `packages/bot/src/bot.ts` | Discord 클라이언트 초기화 |
| `packages/bot/src/commands/index.ts` | 커맨드 레지스트리 |
| `packages/bot/src/services/` | 비즈니스 로직 서비스 |
| `packages/bot/src/schedulers/` | 크론 작업 (RSS, 출석, 벌금) |
| `packages/web/src/app/` | Next.js 페이지/라우트 |
| `packages/web/middleware.ts` | 인증 미들웨어 |

## 인증 구조

- **웹**: Supabase Auth → Discord OAuth → `user.identities[].id` (Discord ID) → `members.discord_id` 매칭
- **봇**: `service_role` key로 직접 DB 접근, `interaction.user.id`로 Discord ID 획득
- **미들웨어**: `@supabase/ssr`의 `updateSession()`으로 세션 자동 갱신
- **관리자**: `ADMIN_DISCORD_IDS` 환경변수로 Discord ID 기반 권한 체크
- **API Route**: `createClient()` → `getUser()` → `identities` 배열에서 Discord ID 추출

## UI 디자인 시스템

Expand Down Expand Up @@ -108,16 +122,20 @@ pnpm --filter @blog-study/bot init-rounds # 회차 초기화
## 환경 변수

`.env.example` 참조. 필수:
- `DISCORD_TOKEN`, `DISCORD_CLIENT_ID`, `DISCORD_GUILD_ID`
- `SUPABASE_URL`, `SUPABASE_SERVICE_KEY`, `DATABASE_URL`
- `OPENAI_API_KEY` (AI 기능용)
- `NEXT_PUBLIC_SUPABASE_URL`, `NEXT_PUBLIC_SUPABASE_ANON_KEY` (Supabase)
- `SUPABASE_SERVICE_KEY`, `DATABASE_URL` (DB)
- `DISCORD_TOKEN`, `DISCORD_CLIENT_ID`, `DISCORD_CLIENT_SECRET`, `DISCORD_GUILD_ID`
- `ADMIN_DISCORD_IDS` (관리자 Discord ID, 쉼표 구분)
- `OPENAI_API_KEY` (AI 기능용, 예정)

**주의**: `packages/web/.env.local`에도 동일 환경변수 필요 (Next.js는 패키지 디렉토리 기준)

## 문서

| 문서 | 설명 |
|------|------|
| `docs/ARCHITECTURE.md` | 시스템 아키텍처 |
| `docs/TECH-DECISIONS.md` | 기술 선택 근거 |
| `docs/ARCHITECTURE.md` | 시스템 아키텍처 (Mermaid 다이어그램) |
| `docs/TECH-DECISIONS.md` | 기술 선택 근거 (ADR) |
| `docs/UI-DESIGN-SYSTEM.md` | UI 디자인 시스템 스펙 |
| `docs/DEVELOPMENT.md` | 개발 환경 설정 |
| `docs/CHECKLIST.md` | 구현 체크리스트 |
16 changes: 0 additions & 16 deletions packages/shared/src/config/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,18 +24,6 @@ const supabaseEnvSchema = z.object({
DATABASE_URL: z.string().min(1, 'DATABASE_URL is required'),
});

// Web authentication schema
const authEnvSchema = z.object({
JWT_SECRET: z.string().min(32, 'JWT_SECRET must be at least 32 characters'),
SESSION_EXPIRY: z.string().default('7d'),
});

// Email configuration schema
const emailEnvSchema = z.object({
RESEND_API_KEY: z.string().min(1, 'RESEND_API_KEY is required'),
EMAIL_FROM: z.string().email('EMAIL_FROM must be a valid email'),
});

// Application configuration schema
const appEnvSchema = z.object({
APP_URL: z.string().url('APP_URL must be a valid URL').default('http://localhost:3000'),
Expand All @@ -55,8 +43,6 @@ const studyEnvSchema = z.object({
const envSchema = z.object({
...discordEnvSchema.shape,
...supabaseEnvSchema.shape,
...authEnvSchema.shape,
...emailEnvSchema.shape,
...appEnvSchema.shape,
...studyEnvSchema.shape,
});
Expand All @@ -72,8 +58,6 @@ const botEnvSchema = z.object({
// Partial schema for web-only usage
const webEnvSchema = z.object({
...supabaseEnvSchema.shape,
...authEnvSchema.shape,
...emailEnvSchema.shape,
...appEnvSchema.shape,
});

Expand Down
1 change: 1 addition & 0 deletions packages/shared/src/db/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export function getDb(connectionString?: string) {
max: 10, // Maximum number of connections
idle_timeout: 20, // Close idle connections after 20 seconds
connect_timeout: 10, // Connection timeout in seconds
prepare: false, // Transaction pooler (Supavisor)는 PREPARE 미지원
});

db = drizzle(client, { schema });
Expand Down
57 changes: 2 additions & 55 deletions packages/shared/src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,35 +94,7 @@ export const members = pgTable(
})
);

/**
* 웹 사용자 (Users)
* 이메일로 웹사이트에 가입한 사용자
*/
export const users = pgTable('users', {
id: uuid('id').primaryKey().defaultRandom(),
email: varchar('email', { length: 255 }).notNull().unique(),
passwordHash: varchar('password_hash', { length: 255 }).notNull(),
emailVerified: boolean('email_verified').default(false),
emailVerifyToken: varchar('email_verify_token', { length: 255 }),
emailVerifyExpires: timestamp('email_verify_expires', { withTimezone: true }),
memberId: uuid('member_id').references(() => members.id),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow(),
});

/**
* 세션 토큰 (Sessions)
* 웹 로그인 세션 관리
*/
export const sessions = pgTable('sessions', {
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
token: varchar('token', { length: 255 }).notNull().unique(),
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(),
});
// users, sessions 테이블 제거됨 — Supabase Auth가 대체

/**
* 스터디 회차 (Rounds)
Expand Down Expand Up @@ -276,29 +248,10 @@ export const config = pgTable('config', {
// Relations
// ============================================

export const membersRelations = relations(members, ({ many, one }) => ({
export const membersRelations = relations(members, ({ many }) => ({
posts: many(posts),
attendance: many(attendance),
fines: many(fines),
user: one(users, {
fields: [members.id],
references: [users.memberId],
}),
}));

export const usersRelations = relations(users, ({ one, many }) => ({
member: one(members, {
fields: [users.memberId],
references: [members.id],
}),
sessions: many(sessions),
}));

export const sessionsRelations = relations(sessions, ({ one }) => ({
user: one(users, {
fields: [sessions.userId],
references: [users.id],
}),
}));

export const roundsRelations = relations(rounds, ({ many }) => ({
Expand Down Expand Up @@ -359,12 +312,6 @@ export const curationItemsRelations = relations(curationItems, ({ one }) => ({
export type Member = typeof members.$inferSelect;
export type NewMember = typeof members.$inferInsert;

export type User = typeof users.$inferSelect;
export type NewUser = typeof users.$inferInsert;

export type Session = typeof sessions.$inferSelect;
export type NewSession = typeof sessions.$inferInsert;

export type Round = typeof rounds.$inferSelect;
export type NewRound = typeof rounds.$inferInsert;

Expand Down
Loading