From b64eae8bfc7a368661ec8b1af10a55f1822e3a2b Mon Sep 17 00:00:00 2001 From: Sangho Han <113084292+bbbang105@users.noreply.github.com> Date: Mon, 23 Feb 2026 16:17:16 +0900 Subject: [PATCH 01/81] =?UTF-8?q?chore:=20=ED=94=84=EB=A1=9C=EC=A0=9D?= =?UTF-8?q?=ED=8A=B8=20=EC=B4=88=EA=B8=B0=20=EC=84=B8=ED=8C=85=20=EB=B0=8F?= =?UTF-8?q?=20=EC=BD=94=EB=93=9C=20=EC=A0=95=EB=A6=AC=20(#1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: 프로젝트 README 및 CLAUDE.md를 작성한다 - README.md: 프로젝트 소개, 기술 스택, 시작 가이드, 문서 링크 - CLAUDE.md: Claude Code 프로젝트 설정, 코딩 컨벤션, 개발 커맨드 Co-Authored-By: Claude * style: 웹 UI를 Vercel 스타일로 전면 리디자인한다 - globals.css: sky-blue 디자인 토큰 (light/dark) - layout.tsx: Pretendard 폰트, ThemeProvider 다크모드 - tailwind.config.ts: success, warning, sidebar 토큰 추가 - sidebar: 접기/펼치기, sky-blue 활성 인디케이터, 모바일 드로어 - header: DarkModeToggle, 페이지 타이틀 자동 매핑 - footer: 미니멀 1줄 푸터 - badge: success/warning variant를 디자인 토큰으로 변경 - landing: Vercel 스타일 히어로 + 6개 기능 카드 - login: BS 로고, 디자인 토큰 기반 폼 - dashboard: stat 카드 + 활동 피드 리디자인 - posts: 컴팩트 테이블, 미니멀 페이지네이션 - ranking: 포디움 카드, 고스트 소트 토글 - curation: 고스트 필터, 소프트 카테고리 뱃지 - profile: 플랫 카드, 아이콘 필, 토큰 기반 뱃지 - admin dashboard: 디자인 토큰 통일, 고스트 퀵 액션 Co-Authored-By: Claude * refactor: 봇 커맨드 파일명을 한글에서 영문으로 변경한다 - 핑.ts → ping.ts, 참가.ts → join.ts, 탈퇴.ts → leave.ts - 참가자목록.ts → member-list.ts, 내정보.ts → my-info.ts - 랭킹.ts → ranking.ts, 현황.ts → status.ts, 통계.ts → stats.ts - 관심분야.ts → interests.ts - admin/휴면.ts → dormant.ts, 휴면해제.ts → dormant-release.ts - admin/벌금전체.ts → fine-all.ts, 벌금면제.ts → fine-exempt.ts - admin/벌금현황.ts → fine-status.ts, 출석수정.ts → attendance-edit.ts - admin/설정.ts → settings.ts, 큐레이션소스.ts → curation-source.ts - index.ts barrel export 경로 업데이트 Co-Authored-By: Claude * fix: 코드 스멜 및 미사용 코드를 정리한다 봇 패키지: - getMembersToPolll 타이포 수정 → getMembersToPoll - isGracePeriodEnded_ 트레일링 언더스코어 제거 - JSDoc 주석 /* → /** 수정 - dm-handler type: string → 'late' | 'absent' 유니온 타입 - round-reporter _client → client 네이밍 정리 - ephemeral: false 불필요한 명시적 기본값 20건 제거 - STUDY_ROLE_NAME, getRankEmoji 중복 제거 → constants.ts 통합 - dead middleware 디렉터리 제거 웹 패키지: - register, verify-email: 하드코딩 컬러 → 디자인 토큰 - admin/attendance, settings, curation, fines, members: 디자인 토큰 통일 - profile/edit: text-green-600 → text-success shared 패키지: - types/index.ts 중복 enum 정의 제거 (schema.ts as const와 충돌) Co-Authored-By: Claude * chore: 기존 프로젝트 기반 코드를 추가한다 - 루트: pnpm workspace 설정, ESLint, Prettier, TypeScript 설정 - packages/bot: Discord 봇 (커맨드, 서비스, 스케줄러, 핸들러, 테스트) - packages/shared: DB 스키마 (Drizzle), 설정, 유틸리티, 마이그레이션 - packages/web: Next.js 대시보드 (API 라우트, UI 컴포넌트, 미들웨어) - 불필요 파일 제거: 루트 Dockerfile.bot, railway.json, debug-test.mjs, package-lock.json Co-Authored-By: Claude * ci: 깃허브 세팅 * chore: CI 워크플로우 추가 및 safe 의존성 버전 업데이트 - GitHub Actions CI 파이프라인 추가 (lint, typecheck, test, build) - pnpm + Node 22 기반 워크플로우 구성 - typescript ^5.9.3, discord.js ^14.25.1, Radix UI 등 safe 범위 버전 업데이트 Co-Authored-By: Claude * chore: pnpm lockfile 갱신 Co-Authored-By: Claude * fix: 테스트 파일 미사용 import 제거 Co-Authored-By: Claude * fix: CI 파이프라인 수정 - typecheck 전 shared 패키지 빌드 추가 (dist 의존성 해결) - test에서 web 제외 (테스트 파일 없음) - 미사용 변수 제거 (rss-poller.property.test.ts) Co-Authored-By: Claude * fix: CI 타입체크 및 테스트 오류 수정 - fine-reminder.ts: fine.type을 'late' | 'absent'로 타입 캐스팅 - rss-poller.property.test.ts: getMembersToPolll 오타를 getMembersToPoll로 수정 - CI test job에 shared 빌드 스텝 추가 (bot 테스트가 shared/db 의존) Co-Authored-By: Claude --------- Co-authored-by: Claude --- .dockerignore | 43 + .env.example | 30 + .eslintrc.json | 24 + .github/CODEOWNERS | 1 + .github/auto-assign-config.yaml | 4 + .github/pull_request_template.md | 19 + .github/workflows/auto-assign.yaml | 15 + .github/workflows/ci.yml | 117 + .github/workflows/commit-labeler.yaml | 46 + .gitignore | 37 + .prettierignore | 6 + .prettierrc | 10 + CLAUDE.md | 123 + README.md | 65 +- package.json | 39 + packages/bot/.dockerignore | 36 + packages/bot/Dockerfile | 50 + packages/bot/package.json | 37 + packages/bot/railway.json | 18 + packages/bot/src/bot.ts | 165 + .../bot/src/commands/admin/attendance-edit.ts | 184 + .../bot/src/commands/admin/curation-source.ts | 342 + .../bot/src/commands/admin/dormant-release.ts | 67 + packages/bot/src/commands/admin/dormant.ts | 74 + packages/bot/src/commands/admin/fine-all.ts | 81 + .../bot/src/commands/admin/fine-exempt.ts | 87 + .../bot/src/commands/admin/fine-status.ts | 86 + packages/bot/src/commands/admin/index.ts | 43 + packages/bot/src/commands/admin/settings.ts | 336 + packages/bot/src/commands/constants.ts | 25 + packages/bot/src/commands/index.ts | 75 + packages/bot/src/commands/interests.ts | 111 + packages/bot/src/commands/join.ts | 156 + packages/bot/src/commands/leave.ts | 81 + packages/bot/src/commands/member-list.ts | 167 + packages/bot/src/commands/my-info.ts | 115 + packages/bot/src/commands/ping.ts | 23 + packages/bot/src/commands/ranking.ts | 181 + packages/bot/src/commands/stats.ts | 248 + .../bot/src/commands/status.property.test.ts | 682 ++ packages/bot/src/commands/status.ts | 203 + packages/bot/src/deploy-commands.ts | 22 + packages/bot/src/handlers/dm-handler.ts | 218 + packages/bot/src/handlers/index.ts | 4 + packages/bot/src/index.ts | 50 + .../bot/src/schedulers/attendance-checker.ts | 257 + .../bot/src/schedulers/curation-crawler.ts | 402 + packages/bot/src/schedulers/fine-reminder.ts | 299 + packages/bot/src/schedulers/index.ts | 8 + packages/bot/src/schedulers/round-reporter.ts | 441 ++ .../schedulers/rss-poller.property.test.ts | 206 + packages/bot/src/schedulers/rss-poller.ts | 215 + packages/bot/src/scripts/init-rounds.ts | 231 + .../attendance.service.property.test.ts | 546 ++ .../bot/src/services/attendance.service.ts | 377 + .../curation.service.property.test.ts | 468 ++ packages/bot/src/services/curation.service.ts | 461 ++ .../services/fine.service.property.test.ts | 341 + packages/bot/src/services/fine.service.ts | 407 ++ packages/bot/src/services/index.ts | 12 + .../services/keyword.service.property.test.ts | 470 ++ packages/bot/src/services/keyword.service.ts | 256 + .../services/member.service.property.test.ts | 464 ++ packages/bot/src/services/member.service.ts | 344 + .../notification.service.property.test.ts | 493 ++ .../bot/src/services/notification.service.ts | 461 ++ .../services/post.service.property.test.ts | 465 ++ packages/bot/src/services/post.service.ts | 202 + packages/bot/src/services/round.service.ts | 382 + .../src/services/rss.service.property.test.ts | 242 + packages/bot/src/services/rss.service.ts | 300 + packages/bot/tsconfig.json | 16 + packages/bot/tsup.config.ts | 11 + packages/bot/vitest.config.ts | 13 + packages/shared/drizzle.config.ts | 21 + .../shared/drizzle/0000_steep_amphibian.sql | 187 + packages/shared/drizzle/0001_rls_policies.sql | 218 + .../shared/drizzle/meta/0000_snapshot.json | 987 +++ packages/shared/drizzle/meta/_journal.json | 20 + packages/shared/package.json | 59 + packages/shared/src/config/env.ts | 166 + packages/shared/src/config/index.ts | 1 + packages/shared/src/db/index.ts | 61 + packages/shared/src/db/migrate.ts | 41 + .../shared/src/db/schema.property.test.ts | 115 + packages/shared/src/db/schema.ts | 390 + packages/shared/src/index.ts | 6 + .../src/utils/date-utils.property.test.ts | 328 + packages/shared/src/utils/date-utils.ts | 247 + packages/shared/src/utils/index.ts | 6 + .../utils/keyword-extractor.property.test.ts | 417 ++ .../shared/src/utils/keyword-extractor.ts | 300 + packages/shared/src/utils/serialization.ts | 84 + .../src/utils/url-validator.property.test.ts | 261 + packages/shared/src/utils/url-validator.ts | 182 + packages/shared/tsconfig.json | 16 + packages/shared/tsup.config.ts | 19 + packages/shared/vitest.config.ts | 13 + packages/web/components.json | 17 + packages/web/middleware.ts | 120 + packages/web/next-env.d.ts | 5 + packages/web/next.config.js | 31 + packages/web/package.json | 55 + packages/web/postcss.config.js | 6 + .../src/app/(admin)/admin/attendance/page.tsx | 413 ++ .../src/app/(admin)/admin/curation/page.tsx | 445 ++ .../web/src/app/(admin)/admin/fines/page.tsx | 420 ++ .../admin/members/delete-member-dialog.tsx | 122 + .../admin/members/member-form-dialog.tsx | 285 + .../src/app/(admin)/admin/members/page.tsx | 360 + packages/web/src/app/(admin)/admin/page.tsx | 393 + .../src/app/(admin)/admin/settings/page.tsx | 327 + packages/web/src/app/(admin)/layout.tsx | 86 + packages/web/src/app/(auth)/layout.tsx | 11 + packages/web/src/app/(auth)/login/page.tsx | 168 + packages/web/src/app/(auth)/register/page.tsx | 165 + .../web/src/app/(auth)/verify-email/page.tsx | 124 + packages/web/src/app/(user)/curation/page.tsx | 216 + .../web/src/app/(user)/dashboard/page.tsx | 248 + packages/web/src/app/(user)/layout.tsx | 56 + .../web/src/app/(user)/members/[id]/page.tsx | 295 + packages/web/src/app/(user)/posts/page.tsx | 217 + .../web/src/app/(user)/profile/edit/page.tsx | 324 + .../app/(user)/profile/onboarding/page.tsx | 381 + packages/web/src/app/(user)/profile/page.tsx | 451 ++ packages/web/src/app/(user)/ranking/page.tsx | 295 + .../app/api/admin/attendance/[id]/route.ts | 214 + .../web/src/app/api/admin/attendance/route.ts | 130 + packages/web/src/app/api/admin/check/route.ts | 44 + .../src/app/api/admin/curation/[id]/route.ts | 151 + .../web/src/app/api/admin/curation/route.ts | 145 + .../web/src/app/api/admin/dashboard/route.ts | 173 + .../web/src/app/api/admin/fines/[id]/route.ts | 89 + packages/web/src/app/api/admin/fines/route.ts | 123 + .../src/app/api/admin/members/[id]/route.ts | 188 + .../web/src/app/api/admin/members/route.ts | 196 + .../src/app/api/admin/rounds/[id]/route.ts | 150 + .../web/src/app/api/admin/rounds/route.ts | 184 + .../web/src/app/api/admin/settings/route.ts | 127 + packages/web/src/app/api/auth/login/route.ts | 137 + packages/web/src/app/api/auth/logout/route.ts | 63 + packages/web/src/app/api/auth/me/route.ts | 79 + .../web/src/app/api/auth/register/route.ts | 111 + .../src/app/api/auth/verify-email/route.ts | 163 + packages/web/src/app/api/curation/route.ts | 70 + packages/web/src/app/api/dashboard/route.ts | 93 + packages/web/src/app/api/keywords/route.ts | 53 + .../web/src/app/api/members/[id]/route.ts | 113 + packages/web/src/app/api/members/route.ts | 83 + packages/web/src/app/api/posts/route.ts | 66 + .../web/src/app/api/profile/edit/route.ts | 100 + .../web/src/app/api/profile/link/route.ts | 146 + .../src/app/api/profile/onboarding/route.ts | 116 + packages/web/src/app/api/profile/route.ts | 140 + packages/web/src/app/api/ranking/route.ts | 90 + packages/web/src/app/api/rounds/[id]/route.ts | 121 + packages/web/src/app/api/rounds/route.ts | 83 + packages/web/src/app/api/stats/route.ts | 165 + packages/web/src/app/globals.css | 102 + packages/web/src/app/layout.tsx | 46 + packages/web/src/app/page.tsx | 165 + packages/web/src/components/index.ts | 5 + packages/web/src/components/layout/footer.tsx | 10 + packages/web/src/components/layout/header.tsx | 175 + packages/web/src/components/layout/index.ts | 4 + .../web/src/components/layout/main-layout.tsx | 88 + .../web/src/components/layout/sidebar.tsx | 379 + packages/web/src/components/ui/avatar.tsx | 50 + packages/web/src/components/ui/badge.tsx | 40 + packages/web/src/components/ui/button.tsx | 56 + packages/web/src/components/ui/card.tsx | 79 + packages/web/src/components/ui/index.ts | 8 + packages/web/src/components/ui/input.tsx | 25 + packages/web/src/components/ui/label.tsx | 26 + packages/web/src/components/ui/separator.tsx | 31 + packages/web/src/components/ui/table.tsx | 117 + packages/web/src/lib/admin.ts | 214 + packages/web/src/lib/api-error.ts | 213 + packages/web/src/lib/auth.ts | 122 + packages/web/src/lib/db.ts | 11 + packages/web/src/lib/email.ts | 119 + packages/web/src/lib/utils.ts | 6 + packages/web/tailwind.config.ts | 93 + packages/web/tsconfig.json | 38 + packages/web/vercel.json | 21 + pnpm-lock.yaml | 6477 +++++++++++++++++ pnpm-workspace.yaml | 2 + tsconfig.json | 20 + vercel.json | 20 + 189 files changed, 36183 insertions(+), 2 deletions(-) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .eslintrc.json create mode 100644 .github/CODEOWNERS create mode 100644 .github/auto-assign-config.yaml create mode 100644 .github/pull_request_template.md create mode 100644 .github/workflows/auto-assign.yaml create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/commit-labeler.yaml create mode 100644 .gitignore create mode 100644 .prettierignore create mode 100644 .prettierrc create mode 100644 CLAUDE.md create mode 100644 package.json create mode 100644 packages/bot/.dockerignore create mode 100644 packages/bot/Dockerfile create mode 100644 packages/bot/package.json create mode 100644 packages/bot/railway.json create mode 100644 packages/bot/src/bot.ts create mode 100644 packages/bot/src/commands/admin/attendance-edit.ts create mode 100644 packages/bot/src/commands/admin/curation-source.ts create mode 100644 packages/bot/src/commands/admin/dormant-release.ts create mode 100644 packages/bot/src/commands/admin/dormant.ts create mode 100644 packages/bot/src/commands/admin/fine-all.ts create mode 100644 packages/bot/src/commands/admin/fine-exempt.ts create mode 100644 packages/bot/src/commands/admin/fine-status.ts create mode 100644 packages/bot/src/commands/admin/index.ts create mode 100644 packages/bot/src/commands/admin/settings.ts create mode 100644 packages/bot/src/commands/constants.ts create mode 100644 packages/bot/src/commands/index.ts create mode 100644 packages/bot/src/commands/interests.ts create mode 100644 packages/bot/src/commands/join.ts create mode 100644 packages/bot/src/commands/leave.ts create mode 100644 packages/bot/src/commands/member-list.ts create mode 100644 packages/bot/src/commands/my-info.ts create mode 100644 packages/bot/src/commands/ping.ts create mode 100644 packages/bot/src/commands/ranking.ts create mode 100644 packages/bot/src/commands/stats.ts create mode 100644 packages/bot/src/commands/status.property.test.ts create mode 100644 packages/bot/src/commands/status.ts create mode 100644 packages/bot/src/deploy-commands.ts create mode 100644 packages/bot/src/handlers/dm-handler.ts create mode 100644 packages/bot/src/handlers/index.ts create mode 100644 packages/bot/src/index.ts create mode 100644 packages/bot/src/schedulers/attendance-checker.ts create mode 100644 packages/bot/src/schedulers/curation-crawler.ts create mode 100644 packages/bot/src/schedulers/fine-reminder.ts create mode 100644 packages/bot/src/schedulers/index.ts create mode 100644 packages/bot/src/schedulers/round-reporter.ts create mode 100644 packages/bot/src/schedulers/rss-poller.property.test.ts create mode 100644 packages/bot/src/schedulers/rss-poller.ts create mode 100644 packages/bot/src/scripts/init-rounds.ts create mode 100644 packages/bot/src/services/attendance.service.property.test.ts create mode 100644 packages/bot/src/services/attendance.service.ts create mode 100644 packages/bot/src/services/curation.service.property.test.ts create mode 100644 packages/bot/src/services/curation.service.ts create mode 100644 packages/bot/src/services/fine.service.property.test.ts create mode 100644 packages/bot/src/services/fine.service.ts create mode 100644 packages/bot/src/services/index.ts create mode 100644 packages/bot/src/services/keyword.service.property.test.ts create mode 100644 packages/bot/src/services/keyword.service.ts create mode 100644 packages/bot/src/services/member.service.property.test.ts create mode 100644 packages/bot/src/services/member.service.ts create mode 100644 packages/bot/src/services/notification.service.property.test.ts create mode 100644 packages/bot/src/services/notification.service.ts create mode 100644 packages/bot/src/services/post.service.property.test.ts create mode 100644 packages/bot/src/services/post.service.ts create mode 100644 packages/bot/src/services/round.service.ts create mode 100644 packages/bot/src/services/rss.service.property.test.ts create mode 100644 packages/bot/src/services/rss.service.ts create mode 100644 packages/bot/tsconfig.json create mode 100644 packages/bot/tsup.config.ts create mode 100644 packages/bot/vitest.config.ts create mode 100644 packages/shared/drizzle.config.ts create mode 100644 packages/shared/drizzle/0000_steep_amphibian.sql create mode 100644 packages/shared/drizzle/0001_rls_policies.sql create mode 100644 packages/shared/drizzle/meta/0000_snapshot.json create mode 100644 packages/shared/drizzle/meta/_journal.json create mode 100644 packages/shared/package.json create mode 100644 packages/shared/src/config/env.ts create mode 100644 packages/shared/src/config/index.ts create mode 100644 packages/shared/src/db/index.ts create mode 100644 packages/shared/src/db/migrate.ts create mode 100644 packages/shared/src/db/schema.property.test.ts create mode 100644 packages/shared/src/db/schema.ts create mode 100644 packages/shared/src/index.ts create mode 100644 packages/shared/src/utils/date-utils.property.test.ts create mode 100644 packages/shared/src/utils/date-utils.ts create mode 100644 packages/shared/src/utils/index.ts create mode 100644 packages/shared/src/utils/keyword-extractor.property.test.ts create mode 100644 packages/shared/src/utils/keyword-extractor.ts create mode 100644 packages/shared/src/utils/serialization.ts create mode 100644 packages/shared/src/utils/url-validator.property.test.ts create mode 100644 packages/shared/src/utils/url-validator.ts create mode 100644 packages/shared/tsconfig.json create mode 100644 packages/shared/tsup.config.ts create mode 100644 packages/shared/vitest.config.ts create mode 100644 packages/web/components.json create mode 100644 packages/web/middleware.ts create mode 100644 packages/web/next-env.d.ts create mode 100644 packages/web/next.config.js create mode 100644 packages/web/package.json create mode 100644 packages/web/postcss.config.js create mode 100644 packages/web/src/app/(admin)/admin/attendance/page.tsx create mode 100644 packages/web/src/app/(admin)/admin/curation/page.tsx create mode 100644 packages/web/src/app/(admin)/admin/fines/page.tsx create mode 100644 packages/web/src/app/(admin)/admin/members/delete-member-dialog.tsx create mode 100644 packages/web/src/app/(admin)/admin/members/member-form-dialog.tsx create mode 100644 packages/web/src/app/(admin)/admin/members/page.tsx create mode 100644 packages/web/src/app/(admin)/admin/page.tsx create mode 100644 packages/web/src/app/(admin)/admin/settings/page.tsx create mode 100644 packages/web/src/app/(admin)/layout.tsx create mode 100644 packages/web/src/app/(auth)/layout.tsx create mode 100644 packages/web/src/app/(auth)/login/page.tsx create mode 100644 packages/web/src/app/(auth)/register/page.tsx create mode 100644 packages/web/src/app/(auth)/verify-email/page.tsx create mode 100644 packages/web/src/app/(user)/curation/page.tsx create mode 100644 packages/web/src/app/(user)/dashboard/page.tsx create mode 100644 packages/web/src/app/(user)/layout.tsx create mode 100644 packages/web/src/app/(user)/members/[id]/page.tsx create mode 100644 packages/web/src/app/(user)/posts/page.tsx create mode 100644 packages/web/src/app/(user)/profile/edit/page.tsx create mode 100644 packages/web/src/app/(user)/profile/onboarding/page.tsx create mode 100644 packages/web/src/app/(user)/profile/page.tsx create mode 100644 packages/web/src/app/(user)/ranking/page.tsx create mode 100644 packages/web/src/app/api/admin/attendance/[id]/route.ts create mode 100644 packages/web/src/app/api/admin/attendance/route.ts create mode 100644 packages/web/src/app/api/admin/check/route.ts create mode 100644 packages/web/src/app/api/admin/curation/[id]/route.ts create mode 100644 packages/web/src/app/api/admin/curation/route.ts create mode 100644 packages/web/src/app/api/admin/dashboard/route.ts create mode 100644 packages/web/src/app/api/admin/fines/[id]/route.ts create mode 100644 packages/web/src/app/api/admin/fines/route.ts create mode 100644 packages/web/src/app/api/admin/members/[id]/route.ts create mode 100644 packages/web/src/app/api/admin/members/route.ts create mode 100644 packages/web/src/app/api/admin/rounds/[id]/route.ts create mode 100644 packages/web/src/app/api/admin/rounds/route.ts create mode 100644 packages/web/src/app/api/admin/settings/route.ts create mode 100644 packages/web/src/app/api/auth/login/route.ts create mode 100644 packages/web/src/app/api/auth/logout/route.ts create mode 100644 packages/web/src/app/api/auth/me/route.ts create mode 100644 packages/web/src/app/api/auth/register/route.ts create mode 100644 packages/web/src/app/api/auth/verify-email/route.ts create mode 100644 packages/web/src/app/api/curation/route.ts create mode 100644 packages/web/src/app/api/dashboard/route.ts create mode 100644 packages/web/src/app/api/keywords/route.ts create mode 100644 packages/web/src/app/api/members/[id]/route.ts create mode 100644 packages/web/src/app/api/members/route.ts create mode 100644 packages/web/src/app/api/posts/route.ts create mode 100644 packages/web/src/app/api/profile/edit/route.ts create mode 100644 packages/web/src/app/api/profile/link/route.ts create mode 100644 packages/web/src/app/api/profile/onboarding/route.ts create mode 100644 packages/web/src/app/api/profile/route.ts create mode 100644 packages/web/src/app/api/ranking/route.ts create mode 100644 packages/web/src/app/api/rounds/[id]/route.ts create mode 100644 packages/web/src/app/api/rounds/route.ts create mode 100644 packages/web/src/app/api/stats/route.ts create mode 100644 packages/web/src/app/globals.css create mode 100644 packages/web/src/app/layout.tsx create mode 100644 packages/web/src/app/page.tsx create mode 100644 packages/web/src/components/index.ts create mode 100644 packages/web/src/components/layout/footer.tsx create mode 100644 packages/web/src/components/layout/header.tsx create mode 100644 packages/web/src/components/layout/index.ts create mode 100644 packages/web/src/components/layout/main-layout.tsx create mode 100644 packages/web/src/components/layout/sidebar.tsx create mode 100644 packages/web/src/components/ui/avatar.tsx create mode 100644 packages/web/src/components/ui/badge.tsx create mode 100644 packages/web/src/components/ui/button.tsx create mode 100644 packages/web/src/components/ui/card.tsx create mode 100644 packages/web/src/components/ui/index.ts create mode 100644 packages/web/src/components/ui/input.tsx create mode 100644 packages/web/src/components/ui/label.tsx create mode 100644 packages/web/src/components/ui/separator.tsx create mode 100644 packages/web/src/components/ui/table.tsx create mode 100644 packages/web/src/lib/admin.ts create mode 100644 packages/web/src/lib/api-error.ts create mode 100644 packages/web/src/lib/auth.ts create mode 100644 packages/web/src/lib/db.ts create mode 100644 packages/web/src/lib/email.ts create mode 100644 packages/web/src/lib/utils.ts create mode 100644 packages/web/tailwind.config.ts create mode 100644 packages/web/tsconfig.json create mode 100644 packages/web/vercel.json create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml create mode 100644 tsconfig.json create mode 100644 vercel.json diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..63a3791 --- /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 (use Railway/Vercel environment variables) +.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..c6370f7 --- /dev/null +++ b/.env.example @@ -0,0 +1,30 @@ +# Discord Bot Configuration +DISCORD_TOKEN=your_discord_bot_token +DISCORD_CLIENT_ID=your_discord_client_id +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 +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 + +# 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..2142951 --- /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"] +} diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..ef29017 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @bbbang105 diff --git a/.github/auto-assign-config.yaml b/.github/auto-assign-config.yaml new file mode 100644 index 0000000..649c44e --- /dev/null +++ b/.github/auto-assign-config.yaml @@ -0,0 +1,4 @@ +addReviewers: true +addAssignees: author +reviewers: + - bbbang105 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/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..57d3735 --- /dev/null +++ b/.gitignore @@ -0,0 +1,37 @@ +# 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 + +### CLAUDE ### +/docs 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/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..a336b92 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,123 @@ +# Blog Study Discord Bot + +블로그 글쓰기 스터디 자동화 플랫폼. Discord 봇 + 웹 대시보드 + AI 추천. + +## 프로젝트 구조 + +``` +packages/ +├── bot/ # Discord 봇 (discord.js v14) → Railway 배포 +├── web/ # Next.js 15 대시보드 → Vercel 배포 +└── shared/ # 공유 코드 (DB 스키마, 타입, 유틸) +``` + +**모노레포**: pnpm workspace (`pnpm-workspace.yaml`) + +## 기술 스택 + +| 영역 | 기술 | +|------|------| +| 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) | + +## 개발 명령어 + +```bash +# 개발 +pnpm dev:bot # 봇 로컬 실행 +pnpm dev:web # 웹 로컬 실행 (localhost:3000) + +# 빌드/테스트 +pnpm build # 전체 빌드 +pnpm test # 전체 테스트 +pnpm lint # 전체 린트 +pnpm typecheck # 타입 체크 + +# 봇 전용 +pnpm --filter @blog-study/bot deploy-commands # 슬래시 커맨드 등록 +pnpm --filter @blog-study/bot init-rounds # 회차 초기화 +``` + +## 코딩 컨벤션 + +- **언어**: 모든 코드는 TypeScript strict 모드 +- **스타일**: Prettier + ESLint (설정 파일 참조) +- **폰트**: Pretendard (한국어 최적화) +- **네이밍**: camelCase (변수/함수), PascalCase (컴포넌트/타입), kebab-case (파일명) +- **DB 컬럼**: snake_case (Drizzle ORM이 자동 매핑) +- **커밋**: 기존 git log 스타일 따름, Co-Authored-By 포함 +- **한글 커맨드**: Discord 슬래시 명령어는 한글 (예: `/참가`, `/현황`) + +## 핵심 파일 위치 + +| 파일 | 설명 | +|------|------| +| `packages/shared/src/db/schema.ts` | 전체 DB 스키마 (Drizzle) | +| `packages/shared/src/types/index.ts` | 공유 enum/타입 | +| `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` | 인증 미들웨어 | + +## UI 디자인 시스템 + +- **스타일**: Vercel 화이트/블랙 미니멀 + 스카이블루 포인트 +- **컴포넌트**: shadcn/ui + Radix UI +- **아이콘**: lucide-react +- **다크모드**: next-themes (시스템 연동) +- **폰트**: Pretendard Variable +- **상세 스펙**: `docs/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` 참조. 필수: +- `DISCORD_TOKEN`, `DISCORD_CLIENT_ID`, `DISCORD_GUILD_ID` +- `SUPABASE_URL`, `SUPABASE_SERVICE_KEY`, `DATABASE_URL` +- `OPENAI_API_KEY` (AI 기능용) + +## 문서 + +| 문서 | 설명 | +|------|------| +| `docs/ARCHITECTURE.md` | 시스템 아키텍처 | +| `docs/TECH-DECISIONS.md` | 기술 선택 근거 | +| `docs/UI-DESIGN-SYSTEM.md` | UI 디자인 시스템 스펙 | +| `docs/DEVELOPMENT.md` | 개발 환경 설정 | +| `docs/CHECKLIST.md` | 구현 체크리스트 | diff --git a/README.md b/README.md index 582d6ca..8054e19 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,63 @@ -# study-admin -스터디 어드민 웹사이트 +# Study Admin + +블로그 스터디 운영 자동화 플랫폼. Discord 봇 + 웹 대시보드. + +## 구조 + +``` +packages/ + bot/ Discord 봇 (discord.js, pg-boss, Railway 배포) + web/ Next.js 대시보드 (Vercel 배포) + shared/ DB 스키마, 타입, 유틸리티 (Drizzle ORM) +``` + +## 기술 스택 + +| 영역 | 기술 | +|------|------| +| Frontend | Next.js 14, React 18, Tailwind CSS, shadcn/ui | +| Backend | Next.js API Routes, Supabase | +| Bot | discord.js v14, pg-boss | +| DB | PostgreSQL (Supabase), Drizzle ORM, pgvector | +| AI | OpenAI GPT-4o-mini, text-embedding-3-small | +| Deploy | Vercel (Web), Railway (Bot), Supabase (DB) | + +## 주요 기능 + +- **RSS 자동 수집** — 블로그 글 발행 감지 및 수집 +- **AI 요약 & 추천** — GPT 기반 글 요약, 키워드 추출, 유사 글 추천 +- **출석 자동화** — 2주 1회차, 지각/결석 자동 판정 +- **벌금 관리** — 자동 부과, DM 알림, 납부 확인 +- **랭킹 & 통계** — 포스트 수, 출석률 기반 실시간 랭킹 +- **큐레이션** — 관심 키워드 기반 컨퍼런스/아티클 추천 + +## 시작하기 + +```bash +# 의존성 설치 +pnpm install + +# 웹 개발 서버 +pnpm dev:web + +# 봇 개발 서버 +pnpm dev:bot + +# 전체 빌드 +pnpm build + +# 타입 체크 +pnpm typecheck +``` + +## 환경 변수 + +`.env.example` 참조. + +## 문서 + +- [아키텍처](docs/ARCHITECTURE.md) +- [기술 결정](docs/TECH-DECISIONS.md) +- [UI 디자인 시스템](docs/UI-DESIGN-SYSTEM.md) +- [개발 환경](docs/DEVELOPMENT.md) +- [구현 체크리스트](docs/CHECKLIST.md) diff --git a/package.json b/package.json new file mode 100644 index 0000000..8947d93 --- /dev/null +++ b/package.json @@ -0,0 +1,39 @@ +{ + "name": "blog-study-discord-bot", + "version": "1.0.0", + "private": true, + "description": "블로그 글쓰기 스터디 디스코드 봇 시스템", + "scripts": { + "dev:bot": "pnpm --filter @blog-study/bot dev", + "dev:web": "pnpm --filter @blog-study/web dev", + "build": "pnpm -r build", + "build:bot": "pnpm --filter @blog-study/bot build", + "build:web": "pnpm --filter @blog-study/web build", + "lint": "pnpm -r lint", + "format": "prettier --write \"packages/**/*.{ts,tsx,js,jsx,json,md}\"", + "format:check": "prettier --check \"packages/**/*.{ts,tsx,js,jsx,json,md}\"", + "test": "pnpm -r test", + "typecheck": "pnpm -r typecheck" + }, + "keywords": [ + "discord", + "bot", + "blog", + "study", + "rss" + ], + "author": "", + "license": "MIT", + "devDependencies": { + "@typescript-eslint/eslint-plugin": "^7.13.0", + "@typescript-eslint/parser": "^7.13.0", + "eslint": "^8.57.0", + "prettier": "^3.8.1", + "typescript": "^5.9.3" + }, + "engines": { + "node": ">=22.0.0", + "pnpm": ">=8.0.0" + }, + "packageManager": "pnpm@9.0.0" +} diff --git a/packages/bot/.dockerignore b/packages/bot/.dockerignore new file mode 100644 index 0000000..392f954 --- /dev/null +++ b/packages/bot/.dockerignore @@ -0,0 +1,36 @@ +# Dependencies +node_modules + +# Build output (will be built in container) +dist + +# Development files +*.test.ts +*.spec.ts +vitest.config.ts +tsconfig.tsbuildinfo + +# IDE +.vscode +.idea + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log +npm-debug.log* + +# Environment files (use Railway environment variables) +.env +.env.* +!.env.example + +# Git +.git +.gitignore + +# Documentation +*.md +!README.md diff --git a/packages/bot/Dockerfile b/packages/bot/Dockerfile new file mode 100644 index 0000000..b591372 --- /dev/null +++ b/packages/bot/Dockerfile @@ -0,0 +1,50 @@ +# Build stage +FROM node:20-alpine AS builder + +# Install pnpm +RUN corepack enable && corepack prepare pnpm@9.0.0 --activate + +WORKDIR /app + +# Copy workspace configuration +COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./ +COPY packages/shared/package.json ./packages/shared/ +COPY packages/bot/package.json ./packages/bot/ + +# Install dependencies +RUN pnpm install --frozen-lockfile + +# Copy source code +COPY tsconfig.json ./ +COPY packages/shared ./packages/shared +COPY packages/bot ./packages/bot + +# Build shared package first, then bot +RUN pnpm --filter @blog-study/shared build +RUN pnpm --filter @blog-study/bot build + +# Production stage +FROM node:20-alpine AS runner + +# Install pnpm +RUN corepack enable && corepack prepare pnpm@9.0.0 --activate + +WORKDIR /app + +# Copy workspace configuration +COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./ +COPY packages/shared/package.json ./packages/shared/ +COPY packages/bot/package.json ./packages/bot/ + +# Install production dependencies only +RUN pnpm install --frozen-lockfile --prod + +# Copy built files +COPY --from=builder /app/packages/shared/dist ./packages/shared/dist +COPY --from=builder /app/packages/bot/dist ./packages/bot/dist + +# Set environment +ENV NODE_ENV=production + +# Run the bot +CMD ["node", "packages/bot/dist/index.js"] diff --git a/packages/bot/package.json b/packages/bot/package.json new file mode 100644 index 0000000..74ef120 --- /dev/null +++ b/packages/bot/package.json @@ -0,0 +1,37 @@ +{ + "name": "@blog-study/bot", + "version": "1.0.0", + "private": true, + "description": "블로그 스터디 디스코드 봇", + "main": "./dist/index.js", + "scripts": { + "build": "tsup", + "dev": "tsx watch src/index.ts", + "start": "node dist/index.js", + "lint": "eslint src --ext .ts", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest", + "deploy-commands": "tsx src/deploy-commands.ts", + "init-rounds": "tsx src/scripts/init-rounds.ts" + }, + "dependencies": { + "@blog-study/shared": "workspace:*", + "discord.js": "^14.25.1", + "dotenv": "^16.4.5", + "drizzle-orm": "^0.33.0", + "rss-parser": "^3.13.0", + "axios": "^1.13.5", + "cheerio": "^1.2.0", + "node-cron": "^3.0.3" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "@types/node-cron": "^3.0.11", + "tsup": "^8.5.1", + "tsx": "^4.21.0", + "typescript": "^5.9.3", + "vitest": "^1.6.0", + "fast-check": "^3.19.0" + } +} diff --git a/packages/bot/railway.json b/packages/bot/railway.json new file mode 100644 index 0000000..d8af1cd --- /dev/null +++ b/packages/bot/railway.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://railway.app/railway.schema.json", + "build": { + "builder": "DOCKERFILE", + "dockerfilePath": "packages/bot/Dockerfile", + "watchPatterns": [ + "packages/bot/**", + "packages/shared/**" + ] + }, + "deploy": { + "startCommand": "node packages/bot/dist/index.js", + "healthcheckPath": null, + "healthcheckTimeout": 300, + "restartPolicyType": "ON_FAILURE", + "restartPolicyMaxRetries": 10 + } +} diff --git a/packages/bot/src/bot.ts b/packages/bot/src/bot.ts new file mode 100644 index 0000000..ac46605 --- /dev/null +++ b/packages/bot/src/bot.ts @@ -0,0 +1,165 @@ +import { + Client, + GatewayIntentBits, + Collection, + REST, + Routes, + Events, + type ChatInputCommandInteraction, + type SlashCommandBuilder, +} from 'discord.js'; +import type { BotEnv } from '@blog-study/shared'; + +/** + * Command handler interface + */ +export interface CommandHandler { + data: SlashCommandBuilder; + execute(interaction: ChatInputCommandInteraction): Promise; + adminOnly?: boolean; +} + +/** + * Extended Discord client with command collection + */ +export interface BotClient extends Client { + commands: Collection; +} + +/** + * Create and configure the Discord bot client + */ +export function createBotClient(): BotClient { + const client = new Client({ + intents: [ + GatewayIntentBits.Guilds, + GatewayIntentBits.GuildMessages, + GatewayIntentBits.DirectMessages, + GatewayIntentBits.MessageContent, + ], + }) as BotClient; + + client.commands = new Collection(); + + return client; +} + +/** + * Register slash commands with Discord API + */ +export async function registerCommands( + commands: CommandHandler[], + env: BotEnv +): Promise { + const rest = new REST({ version: '10' }).setToken(env.DISCORD_TOKEN); + + const commandData = commands.map((cmd) => cmd.data.toJSON()); + + console.log(`🔄 Registering ${commandData.length} slash commands...`); + + await rest.put( + Routes.applicationGuildCommands(env.DISCORD_CLIENT_ID, env.DISCORD_GUILD_ID), + { body: commandData } + ); + + console.log(`✅ Successfully registered ${commandData.length} slash commands`); +} + + +/** + * Setup event handlers for the bot client + */ +export function setupEventHandlers( + client: BotClient, + env: BotEnv +): void { + // Ready event + client.once(Events.ClientReady, (readyClient) => { + console.log(`✅ Bot logged in as ${readyClient.user.tag}`); + console.log(`📊 Serving ${readyClient.guilds.cache.size} guild(s)`); + }); + + // Interaction event (slash commands) + client.on(Events.InteractionCreate, async (interaction) => { + if (!interaction.isChatInputCommand()) return; + + const command = client.commands.get(interaction.commandName); + + if (!command) { + console.warn(`⚠️ Unknown command: ${interaction.commandName}`); + return; + } + + try { + // Check admin permission if required + if (command.adminOnly) { + const isAdmin = env.ADMIN_DISCORD_IDS.includes(interaction.user.id); + if (!isAdmin) { + await interaction.reply({ + content: '❌ 권한이 없습니다. 관리자만 사용할 수 있는 명령어입니다.', + ephemeral: true, + }); + return; + } + } + + await command.execute(interaction); + } catch (error) { + console.error(`❌ Error executing command ${interaction.commandName}:`, error); + + const errorMessage = '❌ 명령어 실행 중 오류가 발생했습니다.'; + + if (interaction.replied || interaction.deferred) { + await interaction.followUp({ content: errorMessage, ephemeral: true }); + } else { + await interaction.reply({ content: errorMessage, ephemeral: true }); + } + } + }); + + // Error handling + client.on(Events.Error, (error) => { + console.error('❌ Discord client error:', error); + }); + + client.on(Events.Warn, (warning) => { + console.warn('⚠️ Discord client warning:', warning); + }); +} + +/** + * Load commands into the client's command collection + */ +export function loadCommands( + client: BotClient, + commands: CommandHandler[] +): void { + for (const command of commands) { + client.commands.set(command.data.name, command); + console.log(`📝 Loaded command: /${command.data.name}`); + } +} + +/** + * Start the Discord bot + */ +export async function startBot( + client: BotClient, + token: string +): Promise { + await client.login(token); +} + +/** + * Graceful shutdown handler + */ +export function setupGracefulShutdown(client: BotClient): void { + const shutdown = async (signal: string) => { + console.log(`\n🛑 Received ${signal}. Shutting down gracefully...`); + client.destroy(); + process.exit(0); + }; + + process.on('SIGINT', () => shutdown('SIGINT')); + process.on('SIGTERM', () => shutdown('SIGTERM')); +} diff --git a/packages/bot/src/commands/admin/attendance-edit.ts b/packages/bot/src/commands/admin/attendance-edit.ts new file mode 100644 index 0000000..2e22197 --- /dev/null +++ b/packages/bot/src/commands/admin/attendance-edit.ts @@ -0,0 +1,184 @@ +/** + * /출석수정 명령어 - 출석 상태 수동 수정 (관리자 전용) + * Requirements: 15.6 + */ + +import { + SlashCommandBuilder, + type ChatInputCommandInteraction, +} from 'discord.js'; +import type { CommandHandler } from '../../bot'; +import { getMemberService } from '../../services/member.service'; +import { getAttendanceService } from '../../services/attendance.service'; +import { getRoundByNumber } from '../../services/round.service'; +import { AttendanceStatus, type AttendanceStatusType } from '@blog-study/shared/db'; + +/** + * Map Korean status names to AttendanceStatus values + */ +const statusMap: Record = { + '출석': AttendanceStatus.SUBMITTED, + '지각': AttendanceStatus.LATE, + '결석': AttendanceStatus.ABSENT, + '대기': AttendanceStatus.PENDING, +}; + +/** + * Get Korean status name from AttendanceStatus value + */ +function getStatusName(status: AttendanceStatusType): string { + switch (status) { + case AttendanceStatus.SUBMITTED: + return '출석'; + case AttendanceStatus.LATE: + return '지각'; + case AttendanceStatus.ABSENT: + return '결석'; + case AttendanceStatus.PENDING: + return '대기'; + default: + return status; + } +} + +/** + * Get status emoji + */ +function getStatusEmoji(status: AttendanceStatusType): string { + switch (status) { + case AttendanceStatus.SUBMITTED: + return '✅'; + case AttendanceStatus.LATE: + return '⏰'; + case AttendanceStatus.ABSENT: + return '❌'; + case AttendanceStatus.PENDING: + return '⏳'; + default: + return '❓'; + } +} + +export const 출석수정Command: CommandHandler = { + data: new SlashCommandBuilder() + .setName('출석수정') + .setDescription('[관리자] 특정 유저의 출석 상태를 수정합니다.') + .addUserOption((option) => + option + .setName('유저') + .setDescription('출석을 수정할 멤버') + .setRequired(true) + ) + .addIntegerOption((option) => + option + .setName('회차') + .setDescription('수정할 회차 번호') + .setRequired(true) + .setMinValue(1) + .setMaxValue(52) + ) + .addStringOption((option) => + option + .setName('상태') + .setDescription('변경할 출석 상태') + .setRequired(true) + .addChoices( + { name: '출석', value: '출석' }, + { name: '지각', value: '지각' }, + { name: '결석', value: '결석' }, + { name: '대기', value: '대기' } + ) + ) as SlashCommandBuilder, + + async execute(interaction: ChatInputCommandInteraction): Promise { + const targetUser = interaction.options.getUser('유저', true); + const roundNumber = interaction.options.getInteger('회차', true); + const statusKorean = interaction.options.getString('상태', true); + + const memberService = getMemberService(); + const attendanceService = getAttendanceService(); + + try { + // Get member info + const member = await memberService.getByDiscordId(targetUser.id); + if (!member) { + await interaction.reply({ + content: `❌ <@${targetUser.id}>님은 스터디에 등록되지 않은 사용자입니다.`, + ephemeral: true, + }); + return; + } + + // Get round info + const round = await getRoundByNumber(roundNumber); + if (!round) { + await interaction.reply({ + content: `❌ ${roundNumber}회차를 찾을 수 없습니다.`, + ephemeral: true, + }); + return; + } + + // Get the new status + const newStatus = statusMap[statusKorean]; + if (!newStatus) { + await interaction.reply({ + content: `❌ 알 수 없는 상태입니다: ${statusKorean}`, + ephemeral: true, + }); + return; + } + + // Get current attendance status + const currentAttendance = await attendanceService.getByMemberAndRound(member.id, round.id); + const previousStatus = currentAttendance?.status as AttendanceStatusType | undefined; + + // Update attendance status + await attendanceService.updateStatus( + member.id, + round.id, + newStatus + ); + + const previousStatusName = previousStatus ? getStatusName(previousStatus) : '없음'; + const newStatusName = getStatusName(newStatus); + const statusEmoji = getStatusEmoji(newStatus); + + await interaction.reply({ + content: [ + `✅ **출석 상태 수정 완료**`, + ``, + `👤 **대상:** ${member.name} (<@${targetUser.id}>)`, + `📅 **회차:** ${roundNumber}회차`, + ``, + `📝 **상태 변경:**`, + `- 이전: ${previousStatusName}`, + `- 변경: ${statusEmoji} ${newStatusName}`, + ``, + `💡 출석 상태가 수정되었습니다.`, + newStatus === AttendanceStatus.LATE || newStatus === AttendanceStatus.ABSENT + ? `⚠️ 지각/결석으로 변경 시 벌금은 자동으로 생성되지 않습니다. 필요시 별도로 처리해주세요.` + : '', + ].filter(Boolean).join('\n'), + }); + } catch (error) { + console.error('Error in /출석수정 command:', error); + + // Check if it's an attendance not found error + if (error instanceof Error && error.message.includes('출석 기록을 찾을 수 없습니다')) { + await interaction.reply({ + content: `❌ <@${targetUser.id}>님의 ${roundNumber}회차 출석 기록이 없습니다. 해당 회차가 시작되지 않았거나 멤버가 해당 회차에 참가하지 않았을 수 있습니다.`, + ephemeral: true, + }); + return; + } + + await interaction.reply({ + content: '❌ 출석 수정 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.', + ephemeral: true, + }); + } + }, + + adminOnly: true, +}; diff --git a/packages/bot/src/commands/admin/curation-source.ts b/packages/bot/src/commands/admin/curation-source.ts new file mode 100644 index 0000000..cfba016 --- /dev/null +++ b/packages/bot/src/commands/admin/curation-source.ts @@ -0,0 +1,342 @@ +/** + * /큐레이션소스 명령어 - 큐레이션 소스 관리 (관리자 전용) + * Requirements: 15.5 + */ + +import { + SlashCommandBuilder, + type ChatInputCommandInteraction, +} from 'discord.js'; +import type { CommandHandler } from '../../bot'; +import { getCurationService } from '../../services/curation.service'; +import { CurationCategory } from '@blog-study/shared/db'; + +/** + * Validate URL format + */ +function isValidUrl(url: string): boolean { + try { + new URL(url); + return true; + } catch { + return false; + } +} + +export const 큐레이션소스Command: CommandHandler = { + data: new SlashCommandBuilder() + .setName('큐레이션소스') + .setDescription('[관리자] 큐레이션 소스를 관리합니다.') + .addSubcommand((subcommand) => + subcommand + .setName('추가') + .setDescription('새 큐레이션 소스를 추가합니다.') + .addStringOption((option) => + option + .setName('url') + .setDescription('소스 URL') + .setRequired(true) + ) + .addStringOption((option) => + option + .setName('이름') + .setDescription('소스 이름') + .setRequired(true) + ) + .addStringOption((option) => + option + .setName('카테고리') + .setDescription('소스 카테고리') + .setRequired(true) + .addChoices( + { name: '컨퍼런스', value: CurationCategory.CONFERENCE }, + { name: '아티클', value: CurationCategory.ARTICLE } + ) + ) + ) + .addSubcommand((subcommand) => + subcommand + .setName('삭제') + .setDescription('큐레이션 소스를 삭제합니다.') + .addStringOption((option) => + option + .setName('url') + .setDescription('삭제할 소스 URL') + .setRequired(true) + ) + ) + .addSubcommand((subcommand) => + subcommand + .setName('목록') + .setDescription('등록된 큐레이션 소스 목록을 조회합니다.') + ) + .addSubcommand((subcommand) => + subcommand + .setName('활성화') + .setDescription('큐레이션 소스를 활성화합니다.') + .addStringOption((option) => + option + .setName('url') + .setDescription('활성화할 소스 URL') + .setRequired(true) + ) + ) + .addSubcommand((subcommand) => + subcommand + .setName('비활성화') + .setDescription('큐레이션 소스를 비활성화합니다.') + .addStringOption((option) => + option + .setName('url') + .setDescription('비활성화할 소스 URL') + .setRequired(true) + ) + ) as SlashCommandBuilder, + + async execute(interaction: ChatInputCommandInteraction): Promise { + const subcommand = interaction.options.getSubcommand(); + + try { + switch (subcommand) { + case '추가': + await handleAdd(interaction); + break; + case '삭제': + await handleRemove(interaction); + break; + case '목록': + await handleList(interaction); + break; + case '활성화': + await handleActivate(interaction); + break; + case '비활성화': + await handleDeactivate(interaction); + break; + default: + await interaction.reply({ + content: '❌ 알 수 없는 명령어입니다.', + ephemeral: true, + }); + } + } catch (error) { + console.error('Error in /큐레이션소스 command:', error); + await interaction.reply({ + content: '❌ 명령어 실행 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.', + ephemeral: true, + }); + } + }, + + adminOnly: true, +}; + +/** + * Handle /큐레이션소스 추가 [URL] [이름] [카테고리] + * Requirements: 15.5 + */ +async function handleAdd(interaction: ChatInputCommandInteraction): Promise { + const url = interaction.options.getString('url', true); + const name = interaction.options.getString('이름', true); + const category = interaction.options.getString('카테고리', true); + + // Validate URL + if (!isValidUrl(url)) { + await interaction.reply({ + content: '❌ 올바른 URL 형식이 아닙니다.', + ephemeral: true, + }); + return; + } + + const curationService = getCurationService(); + + // Check if already exists + const existing = await curationService.getSourceByUrl(url); + if (existing) { + await interaction.reply({ + content: `❌ 이미 등록된 소스입니다: **${existing.name}**`, + ephemeral: true, + }); + return; + } + + // Add source + const source = await curationService.addSource(url, name, category); + + const categoryLabel = category === CurationCategory.CONFERENCE ? '컨퍼런스' : '아티클'; + + await interaction.reply({ + content: [ + `✅ **큐레이션 소스 추가 완료**`, + ``, + `📌 **이름:** ${source.name}`, + `🔗 **URL:** ${source.url}`, + `📂 **카테고리:** ${categoryLabel}`, + ``, + `💡 매일 09:00에 자동으로 크롤링됩니다.`, + ].join('\n'), + }); +} + +/** + * Handle /큐레이션소스 삭제 [URL] + * Requirements: 15.5 + */ +async function handleRemove(interaction: ChatInputCommandInteraction): Promise { + const url = interaction.options.getString('url', true); + + const curationService = getCurationService(); + + // Find source + const source = await curationService.getSourceByUrl(url); + if (!source) { + await interaction.reply({ + content: '❌ 해당 URL의 소스를 찾을 수 없습니다.', + ephemeral: true, + }); + return; + } + + // Remove source + await curationService.removeSource(source.id); + + await interaction.reply({ + content: [ + `✅ **큐레이션 소스 삭제 완료**`, + ``, + `📌 **이름:** ${source.name}`, + `🔗 **URL:** ${source.url}`, + ``, + `⚠️ 해당 소스에서 수집된 아이템도 함께 삭제되었습니다.`, + ].join('\n'), + }); +} + +/** + * Handle /큐레이션소스 목록 + */ +async function handleList(interaction: ChatInputCommandInteraction): Promise { + const curationService = getCurationService(); + const sources = await curationService.getAllSources(); + + if (sources.length === 0) { + await interaction.reply({ + content: '📋 등록된 큐레이션 소스가 없습니다.\n\n`/큐레이션소스 추가` 명령어로 소스를 추가해주세요.', + }); + return; + } + + const activeSources = sources.filter(s => s.isActive); + const inactiveSources = sources.filter(s => !s.isActive); + + const lines: string[] = [ + `📋 **큐레이션 소스 목록** (총 ${sources.length}개)`, + ``, + ]; + + if (activeSources.length > 0) { + lines.push(`**✅ 활성 소스 (${activeSources.length}개)**`); + for (const source of activeSources) { + const categoryEmoji = source.category === CurationCategory.CONFERENCE ? '🎤' : '📰'; + lines.push(`${categoryEmoji} **${source.name}**`); + lines.push(` ${source.url}`); + } + lines.push(''); + } + + if (inactiveSources.length > 0) { + lines.push(`**⏸️ 비활성 소스 (${inactiveSources.length}개)**`); + for (const source of inactiveSources) { + const categoryEmoji = source.category === CurationCategory.CONFERENCE ? '🎤' : '📰'; + lines.push(`${categoryEmoji} ~~${source.name}~~`); + lines.push(` ${source.url}`); + } + } + + await interaction.reply({ + content: lines.join('\n'), + }); +} + +/** + * Handle /큐레이션소스 활성화 [URL] + */ +async function handleActivate(interaction: ChatInputCommandInteraction): Promise { + const url = interaction.options.getString('url', true); + + const curationService = getCurationService(); + + // Find source + const source = await curationService.getSourceByUrl(url); + if (!source) { + await interaction.reply({ + content: '❌ 해당 URL의 소스를 찾을 수 없습니다.', + ephemeral: true, + }); + return; + } + + if (source.isActive) { + await interaction.reply({ + content: `ℹ️ **${source.name}**은(는) 이미 활성화되어 있습니다.`, + ephemeral: true, + }); + return; + } + + // Activate source + await curationService.setSourceActive(source.id, true); + + await interaction.reply({ + content: [ + `✅ **큐레이션 소스 활성화 완료**`, + ``, + `📌 **이름:** ${source.name}`, + `🔗 **URL:** ${source.url}`, + ``, + `💡 다음 크롤링 시간(09:00)부터 수집이 재개됩니다.`, + ].join('\n'), + }); +} + +/** + * Handle /큐레이션소스 비활성화 [URL] + */ +async function handleDeactivate(interaction: ChatInputCommandInteraction): Promise { + const url = interaction.options.getString('url', true); + + const curationService = getCurationService(); + + // Find source + const source = await curationService.getSourceByUrl(url); + if (!source) { + await interaction.reply({ + content: '❌ 해당 URL의 소스를 찾을 수 없습니다.', + ephemeral: true, + }); + return; + } + + if (!source.isActive) { + await interaction.reply({ + content: `ℹ️ **${source.name}**은(는) 이미 비활성화되어 있습니다.`, + ephemeral: true, + }); + return; + } + + // Deactivate source + await curationService.setSourceActive(source.id, false); + + await interaction.reply({ + content: [ + `✅ **큐레이션 소스 비활성화 완료**`, + ``, + `📌 **이름:** ${source.name}`, + `🔗 **URL:** ${source.url}`, + ``, + `⏸️ 해당 소스의 크롤링이 중지되었습니다.`, + ].join('\n'), + }); +} diff --git a/packages/bot/src/commands/admin/dormant-release.ts b/packages/bot/src/commands/admin/dormant-release.ts new file mode 100644 index 0000000..c458f9d --- /dev/null +++ b/packages/bot/src/commands/admin/dormant-release.ts @@ -0,0 +1,67 @@ +/** + * /휴면해제 명령어 - 멤버 휴면 해제 (관리자 전용) + * 휴면 상태의 멤버를 활동 상태로 변경합니다. + * Requirements: 3.6, 3.8 + */ + +import { + SlashCommandBuilder, + type ChatInputCommandInteraction, +} from 'discord.js'; +import type { CommandHandler } from '../../bot'; +import { getMemberService, MemberError, MemberErrorCodes } from '../../services/member.service'; + +export const 휴면해제Command: CommandHandler = { + data: new SlashCommandBuilder() + .setName('휴면해제') + .setDescription('[관리자] 멤버의 휴면 상태를 해제합니다.') + .addUserOption((option) => + option + .setName('유저') + .setDescription('휴면 해제할 멤버') + .setRequired(true) + ) as SlashCommandBuilder, + + async execute(interaction: ChatInputCommandInteraction): Promise { + const targetUser = interaction.options.getUser('유저', true); + const memberService = getMemberService(); + + try { + const member = await memberService.unsetDormant(targetUser.id); + + await interaction.reply({ + content: [ + `✅ **휴면 해제 완료**`, + ``, + `👤 **대상:** ${member.name} (<@${targetUser.id}>)`, + `📊 **상태:** 활동중`, + ``, + `💡 이제부터 출석 체크가 적용됩니다.`, + ].join('\n'), + }); + } catch (error) { + if (error instanceof MemberError) { + let errorMessage = `❌ ${error.userMessage}`; + + if (error.code === MemberErrorCodes.USER_NOT_FOUND) { + errorMessage = `❌ <@${targetUser.id}>님은 스터디에 등록되지 않은 사용자입니다.`; + } else if (error.code === MemberErrorCodes.NOT_DORMANT) { + errorMessage = `❌ <@${targetUser.id}>님은 휴면 상태가 아닙니다.`; + } + + await interaction.reply({ + content: errorMessage, + ephemeral: true, + }); + } else { + console.error('Error in /휴면해제 command:', error); + await interaction.reply({ + content: '❌ 휴면 해제 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.', + ephemeral: true, + }); + } + } + }, + + adminOnly: true, +}; diff --git a/packages/bot/src/commands/admin/dormant.ts b/packages/bot/src/commands/admin/dormant.ts new file mode 100644 index 0000000..2606388 --- /dev/null +++ b/packages/bot/src/commands/admin/dormant.ts @@ -0,0 +1,74 @@ +/** + * /휴면 명령어 - 멤버 휴면 설정 (관리자 전용) + * 특정 멤버를 휴면 상태로 설정합니다. + * Requirements: 3.1, 3.2, 3.3, 3.8 + */ + +import { + SlashCommandBuilder, + type ChatInputCommandInteraction, +} from 'discord.js'; +import type { CommandHandler } from '../../bot'; +import { getMemberService, MemberError, MemberErrorCodes } from '../../services/member.service'; + +// TODO: Get current round from RoundService when implemented +const CURRENT_ROUND = 1; // Placeholder + +export const 휴면Command: CommandHandler = { + data: new SlashCommandBuilder() + .setName('휴면') + .setDescription('[관리자] 멤버를 휴면 상태로 설정합니다.') + .addUserOption((option) => + option + .setName('유저') + .setDescription('휴면 설정할 멤버') + .setRequired(true) + ) as SlashCommandBuilder, + + async execute(interaction: ChatInputCommandInteraction): Promise { + const targetUser = interaction.options.getUser('유저', true); + const memberService = getMemberService(); + + try { + const member = await memberService.setDormant(targetUser.id, CURRENT_ROUND); + + await interaction.reply({ + content: [ + `✅ **휴면 설정 완료**`, + ``, + `👤 **대상:** ${member.name} (<@${targetUser.id}>)`, + `📅 **시작 회차:** ${CURRENT_ROUND}회차`, + `⏰ **종료 예정:** ${CURRENT_ROUND + 4}회차 (4회차 후 자동 해제)`, + ``, + `💡 휴면 기간 동안 출석 체크가 면제됩니다.`, + `💡 조기 해제하려면 \`/휴면해제\` 명령어를 사용하세요.`, + ].join('\n'), + }); + } catch (error) { + if (error instanceof MemberError) { + let errorMessage = `❌ ${error.userMessage}`; + + if (error.code === MemberErrorCodes.USER_NOT_FOUND) { + errorMessage = `❌ <@${targetUser.id}>님은 스터디에 등록되지 않은 사용자입니다.`; + } else if (error.code === MemberErrorCodes.DORMANT_ALREADY_USED) { + errorMessage = `❌ <@${targetUser.id}>님은 이미 휴면을 사용했습니다.\n\n💡 휴면은 스터디 기간 중 1회만 사용할 수 있습니다.`; + } else if (error.code === MemberErrorCodes.ALREADY_DORMANT) { + errorMessage = `❌ <@${targetUser.id}>님은 이미 휴면 상태입니다.`; + } + + await interaction.reply({ + content: errorMessage, + ephemeral: true, + }); + } else { + console.error('Error in /휴면 command:', error); + await interaction.reply({ + content: '❌ 휴면 설정 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.', + ephemeral: true, + }); + } + } + }, + + adminOnly: true, +}; diff --git a/packages/bot/src/commands/admin/fine-all.ts b/packages/bot/src/commands/admin/fine-all.ts new file mode 100644 index 0000000..fe2d94e --- /dev/null +++ b/packages/bot/src/commands/admin/fine-all.ts @@ -0,0 +1,81 @@ +/** + * /벌금전체 명령어 - 전체 미납 벌금 조회 (관리자 전용) + * Requirements: 8.6, 8.7 + */ + +import { + SlashCommandBuilder, + type ChatInputCommandInteraction, + EmbedBuilder, +} from 'discord.js'; +import type { CommandHandler } from '../../bot'; +import { getFineService } from '../../services/fine.service'; + +export const 벌금전체Command: CommandHandler = { + data: new SlashCommandBuilder() + .setName('벌금전체') + .setDescription('[관리자] 전체 미납 벌금을 조회합니다.') as SlashCommandBuilder, + + async execute(interaction: ChatInputCommandInteraction): Promise { + const fineService = getFineService(); + + try { + // Get all unpaid fines grouped by member + const groupedFines = await fineService.getAllUnpaidGroupedByMember(); + + if (groupedFines.length === 0) { + await interaction.reply({ + content: '✅ 현재 미납 벌금이 없습니다.', + }); + return; + } + + // Calculate total + const totalAmount = groupedFines.reduce((sum, group) => sum + group.totalAmount, 0); + const totalFines = groupedFines.reduce((sum, group) => sum + group.fines.length, 0); + + // Build embed + const embed = new EmbedBuilder() + .setTitle('💰 전체 미납 벌금 현황') + .setColor(0xff6b6b) + .setDescription(`총 ${groupedFines.length}명, ${totalFines}건의 미납 벌금이 있습니다.`) + .addFields( + ...groupedFines.slice(0, 25).map((group) => ({ + name: `👤 ${group.discordUsername}`, + value: [ + `<@${group.discordId}>`, + `📝 ${group.fines.length}건`, + `💵 ${group.totalAmount.toLocaleString()}원`, + ].join('\n'), + inline: true, + })) + ) + .addFields({ + name: '📊 총 미납 금액', + value: `**${totalAmount.toLocaleString()}원**`, + inline: false, + }) + .setFooter({ text: `총 ${groupedFines.length}명` }) + .setTimestamp(); + + // If there are more than 25 members, add a note + if (groupedFines.length > 25) { + embed.setDescription( + `총 ${groupedFines.length}명, ${totalFines}건의 미납 벌금이 있습니다.\n⚠️ 상위 25명만 표시됩니다.` + ); + } + + await interaction.reply({ + embeds: [embed], + }); + } catch (error) { + console.error('Error in /벌금전체 command:', error); + await interaction.reply({ + content: '❌ 벌금 현황 조회 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.', + ephemeral: true, + }); + } + }, + + adminOnly: true, +}; diff --git a/packages/bot/src/commands/admin/fine-exempt.ts b/packages/bot/src/commands/admin/fine-exempt.ts new file mode 100644 index 0000000..74f6a89 --- /dev/null +++ b/packages/bot/src/commands/admin/fine-exempt.ts @@ -0,0 +1,87 @@ +/** + * /벌금면제 명령어 - 특정 유저의 벌금 면제 (관리자 전용) + * Requirements: 15.7 + */ + +import { + SlashCommandBuilder, + type ChatInputCommandInteraction, +} from 'discord.js'; +import type { CommandHandler } from '../../bot'; +import { getMemberService } from '../../services/member.service'; +import { getFineService, formatFineReason } from '../../services/fine.service'; + +export const 벌금면제Command: CommandHandler = { + data: new SlashCommandBuilder() + .setName('벌금면제') + .setDescription('[관리자] 특정 유저의 벌금을 면제합니다.') + .addUserOption((option) => + option + .setName('유저') + .setDescription('벌금을 면제할 멤버') + .setRequired(true) + ) + .addIntegerOption((option) => + option + .setName('회차') + .setDescription('면제할 회차 번호') + .setRequired(true) + .setMinValue(1) + .setMaxValue(20) + ) as SlashCommandBuilder, + + async execute(interaction: ChatInputCommandInteraction): Promise { + const targetUser = interaction.options.getUser('유저', true); + const roundNumber = interaction.options.getInteger('회차', true); + const memberService = getMemberService(); + const fineService = getFineService(); + + try { + // Get member info + const member = await memberService.getByDiscordId(targetUser.id); + if (!member) { + await interaction.reply({ + content: `❌ <@${targetUser.id}>님은 스터디에 등록되지 않은 사용자입니다.`, + ephemeral: true, + }); + return; + } + + // Find and waive the fine + // Note: We need to find the round ID from round number + // For now, we'll use roundNumber as roundId (assuming they match) + const fine = await fineService.waiveByMemberAndRound(member.id, roundNumber); + + if (!fine) { + await interaction.reply({ + content: `❌ <@${targetUser.id}>님의 ${roundNumber}회차 벌금을 찾을 수 없습니다.`, + ephemeral: true, + }); + return; + } + + const reason = formatFineReason(fine.type as 'late' | 'absent'); + + await interaction.reply({ + content: [ + `✅ **벌금 면제 완료**`, + ``, + `👤 **대상:** ${member.name} (<@${targetUser.id}>)`, + `📅 **회차:** ${roundNumber}회차`, + `📝 **사유:** ${reason}`, + `💵 **금액:** ${fine.amount.toLocaleString()}원`, + ``, + `💡 해당 벌금이 면제 처리되었습니다.`, + ].join('\n'), + }); + } catch (error) { + console.error('Error in /벌금면제 command:', error); + await interaction.reply({ + content: '❌ 벌금 면제 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.', + ephemeral: true, + }); + } + }, + + adminOnly: true, +}; diff --git a/packages/bot/src/commands/admin/fine-status.ts b/packages/bot/src/commands/admin/fine-status.ts new file mode 100644 index 0000000..f6da273 --- /dev/null +++ b/packages/bot/src/commands/admin/fine-status.ts @@ -0,0 +1,86 @@ +/** + * /벌금현황 명령어 - 특정 유저의 미납 벌금 조회 (관리자 전용) + * Requirements: 8.5, 8.7 + */ + +import { + SlashCommandBuilder, + type ChatInputCommandInteraction, + EmbedBuilder, +} from 'discord.js'; +import type { CommandHandler } from '../../bot'; +import { getMemberService } from '../../services/member.service'; +import { getFineService, formatFineReason } from '../../services/fine.service'; + +export const 벌금현황Command: CommandHandler = { + data: new SlashCommandBuilder() + .setName('벌금현황') + .setDescription('[관리자] 특정 유저의 미납 벌금을 조회합니다.') + .addUserOption((option) => + option + .setName('유저') + .setDescription('벌금 현황을 조회할 멤버') + .setRequired(true) + ) as SlashCommandBuilder, + + async execute(interaction: ChatInputCommandInteraction): Promise { + const targetUser = interaction.options.getUser('유저', true); + const memberService = getMemberService(); + const fineService = getFineService(); + + try { + // Get member info + const member = await memberService.getByDiscordId(targetUser.id); + if (!member) { + await interaction.reply({ + content: `❌ <@${targetUser.id}>님은 스터디에 등록되지 않은 사용자입니다.`, + ephemeral: true, + }); + return; + } + + // Get unpaid fines + const unpaidFines = await fineService.getUnpaidByMember(member.id); + const totalAmount = unpaidFines.reduce((sum, fine) => sum + fine.amount, 0); + + if (unpaidFines.length === 0) { + await interaction.reply({ + content: `✅ <@${targetUser.id}>님은 미납 벌금이 없습니다.`, + }); + return; + } + + // Build embed + const embed = new EmbedBuilder() + .setTitle(`💰 ${member.name}님의 벌금 현황`) + .setColor(0xff6b6b) + .setDescription(`총 ${unpaidFines.length}건의 미납 벌금이 있습니다.`) + .addFields( + ...unpaidFines.map((fine, index) => ({ + name: `${index + 1}. ${formatFineReason(fine.type as 'late' | 'absent')}`, + value: `💵 ${fine.amount.toLocaleString()}원\n📅 ${fine.createdAt ? new Date(fine.createdAt).toLocaleDateString('ko-KR') : '알 수 없음'}`, + inline: true, + })), + { + name: '📊 총 미납 금액', + value: `**${totalAmount.toLocaleString()}원**`, + inline: false, + } + ) + .setFooter({ text: `Discord ID: ${targetUser.id}` }) + .setTimestamp(); + + await interaction.reply({ + embeds: [embed], + }); + } catch (error) { + console.error('Error in /벌금현황 command:', error); + await interaction.reply({ + content: '❌ 벌금 현황 조회 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.', + ephemeral: true, + }); + } + }, + + adminOnly: true, +}; diff --git a/packages/bot/src/commands/admin/index.ts b/packages/bot/src/commands/admin/index.ts new file mode 100644 index 0000000..fe7f60d --- /dev/null +++ b/packages/bot/src/commands/admin/index.ts @@ -0,0 +1,43 @@ +// Admin Commands Registry +// 관리자 전용 명령어들을 등록합니다. +// Requirements: 3.8, 8.7 - 관리자 권한 체크 필요 + +import type { CommandHandler } from '../../bot'; + +// Admin commands +import { 휴면Command } from './dormant'; +import { 휴면해제Command } from './dormant-release'; +import { 벌금현황Command } from './fine-status'; +import { 벌금전체Command } from './fine-all'; +import { 벌금면제Command } from './fine-exempt'; +import { 설정Command } from './settings'; +import { 출석수정Command } from './attendance-edit'; +import { 큐레이션소스Command } from './curation-source'; + +// Admin commands array - all commands here must have adminOnly: true +const adminCommands: CommandHandler[] = [ + 휴면Command, // /휴면 [유저] - Requirements 3.1, 3.2, 3.3, 3.8 + 휴면해제Command, // /휴면해제 [유저] - Requirements 3.6, 3.8 + 벌금현황Command, // /벌금현황 [유저] - Requirements 8.5, 8.7 + 벌금전체Command, // /벌금전체 - Requirements 8.6, 8.7 + 벌금면제Command, // /벌금면제 [유저] [회차] - Requirements 15.7 + 설정Command, // /설정 [항목] [값] - Requirements 15.1, 15.2, 15.3, 15.4 + 출석수정Command, // /출석수정 [유저] [회차] [상태] - Requirements 15.6 + 큐레이션소스Command, // /큐레이션소스 [추가|삭제] [URL] - Requirements 15.5 +]; + +/** + * Get all admin commands + * All commands returned here should have adminOnly: true + */ +export function getAdminCommands(): CommandHandler[] { + return adminCommands; +} + +/** + * Validate that all admin commands have adminOnly flag set + * This is a development-time check + */ +export function validateAdminCommands(): boolean { + return adminCommands.every((cmd) => cmd.adminOnly === true); +} diff --git a/packages/bot/src/commands/admin/settings.ts b/packages/bot/src/commands/admin/settings.ts new file mode 100644 index 0000000..d6194db --- /dev/null +++ b/packages/bot/src/commands/admin/settings.ts @@ -0,0 +1,336 @@ +/** + * /설정 명령어 - 스터디 설정 관리 (관리자 전용) + * Requirements: 15.1, 15.2, 15.3, 15.4 + */ + +import { + SlashCommandBuilder, + ChannelType, + type ChatInputCommandInteraction, + type TextChannel, +} from 'discord.js'; +import type { CommandHandler } from '../../bot'; +import { + ConfigKeys, + setConfigValue, + getConfigValue, + createRounds, + deleteAllRounds, + getAllRounds, +} from '../../services/round.service'; + +/** + * Parse date string in YYYY-MM-DD format + */ +function parseDate(dateStr: string): Date | null { + const match = dateStr.match(/^(\d{4})-(\d{2})-(\d{2})$/); + if (!match) return null; + + const [, year, month, day] = match; + const date = new Date(Date.UTC( + parseInt(year!, 10), + parseInt(month!, 10) - 1, + parseInt(day!, 10) + )); + + // Validate the date is valid + if (isNaN(date.getTime())) return null; + + return date; +} + +/** + * Format date to YYYY-MM-DD string + */ +function formatDate(date: Date): string { + return date.toISOString().split('T')[0]!; +} + +export const 설정Command: CommandHandler = { + data: new SlashCommandBuilder() + .setName('설정') + .setDescription('[관리자] 스터디 설정을 관리합니다.') + .addSubcommand((subcommand) => + subcommand + .setName('시작일') + .setDescription('스터디 시작일을 설정합니다.') + .addStringOption((option) => + option + .setName('날짜') + .setDescription('시작일 (YYYY-MM-DD 형식, 월요일이어야 함)') + .setRequired(true) + ) + ) + .addSubcommand((subcommand) => + subcommand + .setName('총회차') + .setDescription('총 회차 수를 설정합니다.') + .addIntegerOption((option) => + option + .setName('숫자') + .setDescription('총 회차 수 (1-52)') + .setRequired(true) + .setMinValue(1) + .setMaxValue(52) + ) + ) + .addSubcommand((subcommand) => + subcommand + .setName('알림채널') + .setDescription('새 글 알림 채널을 설정합니다.') + .addChannelOption((option) => + option + .setName('채널') + .setDescription('알림을 보낼 채널') + .setRequired(true) + .addChannelTypes(ChannelType.GuildText) + ) + ) + .addSubcommand((subcommand) => + subcommand + .setName('큐레이션채널') + .setDescription('큐레이션 컨텐츠 공유 채널을 설정합니다.') + .addChannelOption((option) => + option + .setName('채널') + .setDescription('큐레이션을 공유할 채널') + .setRequired(true) + .addChannelTypes(ChannelType.GuildText) + ) + ) + .addSubcommand((subcommand) => + subcommand + .setName('조회') + .setDescription('현재 스터디 설정을 조회합니다.') + ) + .addSubcommand((subcommand) => + subcommand + .setName('회차초기화') + .setDescription('회차를 초기화하고 새로 생성합니다. (주의: 기존 회차 삭제)') + ) as SlashCommandBuilder, + + async execute(interaction: ChatInputCommandInteraction): Promise { + const subcommand = interaction.options.getSubcommand(); + + try { + switch (subcommand) { + case '시작일': + await handleStartDate(interaction); + break; + case '총회차': + await handleTotalRounds(interaction); + break; + case '알림채널': + await handleAnnouncementChannel(interaction); + break; + case '큐레이션채널': + await handleCurationChannel(interaction); + break; + case '조회': + await handleView(interaction); + break; + case '회차초기화': + await handleRoundInit(interaction); + break; + default: + await interaction.reply({ + content: '❌ 알 수 없는 설정 명령어입니다.', + ephemeral: true, + }); + } + } catch (error) { + console.error('Error in /설정 command:', error); + await interaction.reply({ + content: '❌ 설정 변경 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.', + ephemeral: true, + }); + } + }, + + adminOnly: true, +}; + +/** + * Handle /설정 시작일 [날짜] + * Requirements: 15.1 + */ +async function handleStartDate(interaction: ChatInputCommandInteraction): Promise { + const dateStr = interaction.options.getString('날짜', true); + const date = parseDate(dateStr); + + if (!date) { + await interaction.reply({ + content: '❌ 날짜 형식이 올바르지 않습니다. YYYY-MM-DD 형식으로 입력해주세요. (예: 2024-01-01)', + ephemeral: true, + }); + return; + } + + // Check if the date is a Monday (0 = Sunday, 1 = Monday, ...) + if (date.getUTCDay() !== 1) { + await interaction.reply({ + content: '❌ 시작일은 월요일이어야 합니다. 다른 날짜를 선택해주세요.', + ephemeral: true, + }); + return; + } + + await setConfigValue(ConfigKeys.STUDY_START_DATE, formatDate(date)); + + await interaction.reply({ + content: [ + `✅ **스터디 시작일 설정 완료**`, + ``, + `📅 **시작일:** ${formatDate(date)}`, + ``, + `💡 회차를 새로 생성하려면 \`/설정 회차초기화\` 명령어를 사용하세요.`, + ].join('\n'), + }); +} + +/** + * Handle /설정 총회차 [숫자] + * Requirements: 15.2 + */ +async function handleTotalRounds(interaction: ChatInputCommandInteraction): Promise { + const totalRounds = interaction.options.getInteger('숫자', true); + + await setConfigValue(ConfigKeys.TOTAL_ROUNDS, totalRounds.toString()); + + await interaction.reply({ + content: [ + `✅ **총 회차 수 설정 완료**`, + ``, + `🔢 **총 회차:** ${totalRounds}회`, + `📆 **총 기간:** ${totalRounds * 2}주`, + ``, + `💡 회차를 새로 생성하려면 \`/설정 회차초기화\` 명령어를 사용하세요.`, + ].join('\n'), + }); +} + +/** + * Handle /설정 알림채널 [채널] + * Requirements: 15.3 + */ +async function handleAnnouncementChannel(interaction: ChatInputCommandInteraction): Promise { + const channel = interaction.options.getChannel('채널', true) as TextChannel; + + await setConfigValue(ConfigKeys.ANNOUNCEMENT_CHANNEL, channel.id); + + await interaction.reply({ + content: [ + `✅ **알림 채널 설정 완료**`, + ``, + `📢 **알림 채널:** <#${channel.id}>`, + ``, + `💡 새 글이 등록되면 해당 채널에 알림이 발송됩니다.`, + ].join('\n'), + }); +} + +/** + * Handle /설정 큐레이션채널 [채널] + * Requirements: 15.4 + */ +async function handleCurationChannel(interaction: ChatInputCommandInteraction): Promise { + const channel = interaction.options.getChannel('채널', true) as TextChannel; + + await setConfigValue(ConfigKeys.CURATION_CHANNEL, channel.id); + + await interaction.reply({ + content: [ + `✅ **큐레이션 채널 설정 완료**`, + ``, + `📚 **큐레이션 채널:** <#${channel.id}>`, + ``, + `💡 큐레이션된 컨텐츠가 해당 채널에 공유됩니다.`, + ].join('\n'), + }); +} + +/** + * Handle /설정 조회 + */ +async function handleView(interaction: ChatInputCommandInteraction): Promise { + const startDate = await getConfigValue(ConfigKeys.STUDY_START_DATE); + const totalRounds = await getConfigValue(ConfigKeys.TOTAL_ROUNDS); + const announcementChannel = await getConfigValue(ConfigKeys.ANNOUNCEMENT_CHANNEL); + const curationChannel = await getConfigValue(ConfigKeys.CURATION_CHANNEL); + const rounds = await getAllRounds(); + + const currentRound = rounds.find((r) => r.isCurrent); + + await interaction.reply({ + content: [ + `📋 **현재 스터디 설정**`, + ``, + `📅 **시작일:** ${startDate || '미설정'}`, + `🔢 **총 회차:** ${totalRounds ? `${totalRounds}회` : '미설정'}`, + `📢 **알림 채널:** ${announcementChannel ? `<#${announcementChannel}>` : '미설정'}`, + `📚 **큐레이션 채널:** ${curationChannel ? `<#${curationChannel}>` : '미설정'}`, + ``, + `📊 **회차 정보:**`, + `- 생성된 회차: ${rounds.length}개`, + `- 현재 회차: ${currentRound ? `${currentRound.roundNumber}회차` : '없음'}`, + ].join('\n'), + }); +} + +/** + * Handle /설정 회차초기화 + */ +async function handleRoundInit(interaction: ChatInputCommandInteraction): Promise { + const startDateStr = await getConfigValue(ConfigKeys.STUDY_START_DATE); + const totalRoundsStr = await getConfigValue(ConfigKeys.TOTAL_ROUNDS); + + if (!startDateStr) { + await interaction.reply({ + content: '❌ 시작일이 설정되지 않았습니다. `/설정 시작일 [날짜]` 명령어로 먼저 설정해주세요.', + ephemeral: true, + }); + return; + } + + if (!totalRoundsStr) { + await interaction.reply({ + content: '❌ 총 회차가 설정되지 않았습니다. `/설정 총회차 [숫자]` 명령어로 먼저 설정해주세요.', + ephemeral: true, + }); + return; + } + + const startDate = parseDate(startDateStr); + const totalRounds = parseInt(totalRoundsStr, 10); + + if (!startDate) { + await interaction.reply({ + content: '❌ 저장된 시작일 형식이 올바르지 않습니다. `/설정 시작일 [날짜]` 명령어로 다시 설정해주세요.', + ephemeral: true, + }); + return; + } + + // Delete existing rounds and create new ones + await deleteAllRounds(); + const createdRounds = await createRounds(startDate, totalRounds); + + const firstRound = createdRounds[0]; + const lastRound = createdRounds[createdRounds.length - 1]; + + await interaction.reply({ + content: [ + `✅ **회차 초기화 완료**`, + ``, + `📅 **시작일:** ${startDateStr}`, + `🔢 **총 회차:** ${totalRounds}회`, + `📊 **생성된 회차:** ${createdRounds.length}개`, + ``, + `📆 **기간:**`, + `- 1회차: ${firstRound?.startDate} ~ ${firstRound?.endDate}`, + `- ${totalRounds}회차: ${lastRound?.startDate} ~ ${lastRound?.endDate}`, + ``, + `💡 현재 1회차가 진행 중으로 설정되었습니다.`, + ].join('\n'), + }); +} diff --git a/packages/bot/src/commands/constants.ts b/packages/bot/src/commands/constants.ts new file mode 100644 index 0000000..56b06f4 --- /dev/null +++ b/packages/bot/src/commands/constants.ts @@ -0,0 +1,25 @@ +/** + * Shared constants and utilities for command handlers + */ + +/** + * Discord role name for study participants. + * Used when assigning/removing the role on join and leave. + */ +export const STUDY_ROLE_NAME = '스터디원'; + +/** + * Get rank emoji for top 3 positions; returns numbered string for the rest. + */ +export function getRankEmoji(rank: number): string { + switch (rank) { + case 1: + return '🥇'; + case 2: + return '🥈'; + case 3: + return '🥉'; + default: + return `${rank}.`; + } +} diff --git a/packages/bot/src/commands/index.ts b/packages/bot/src/commands/index.ts new file mode 100644 index 0000000..f660544 --- /dev/null +++ b/packages/bot/src/commands/index.ts @@ -0,0 +1,75 @@ +// Command Registry +// 모든 슬래시 명령어를 등록하고 관리합니다. +// Requirements: 3.8, 8.7 - 관리자 권한 체크 지원 + +import type { CommandHandler } from '../bot'; + +// Import user commands +import { 핑Command } from './ping'; +import { 참가Command } from './join'; +import { 탈퇴Command } from './leave'; +import { 내정보Command } from './my-info'; +import { 참가자목록Command } from './member-list'; +import { 현황Command } from './status'; +import { 랭킹Command } from './ranking'; +import { 통계Command } from './stats'; +import { 관심분야Command } from './interests'; + +// Import admin commands from admin directory +import { getAdminCommands as getAdminCommandsFromDir } from './admin'; + +// User commands array +const userCommands: CommandHandler[] = [ + 핑Command, + 참가Command, + 탈퇴Command, + 내정보Command, + 참가자목록Command, + 현황Command, + 랭킹Command, + 통계Command, + 관심분야Command, +]; + +/** + * Get all registered commands (user + admin) + */ +export function getAllCommands(): CommandHandler[] { + return [...userCommands, ...getAdminCommandsFromDir()]; +} + +/** + * Get user commands only (non-admin) + */ +export function getUserCommands(): CommandHandler[] { + return userCommands.filter((cmd) => !cmd.adminOnly); +} + +/** + * Get admin commands only + */ +export function getAdminCommands(): CommandHandler[] { + return getAdminCommandsFromDir(); +} + +/** + * Find a command by name + */ +export function findCommand(name: string): CommandHandler | undefined { + return getAllCommands().find((cmd) => cmd.data.name === name); +} + +/** + * Check if a command exists + */ +export function hasCommand(name: string): boolean { + return findCommand(name) !== undefined; +} + +/** + * Check if a command requires admin permissions + */ +export function isAdminCommand(name: string): boolean { + const command = findCommand(name); + return command?.adminOnly === true; +} diff --git a/packages/bot/src/commands/interests.ts b/packages/bot/src/commands/interests.ts new file mode 100644 index 0000000..28ce025 --- /dev/null +++ b/packages/bot/src/commands/interests.ts @@ -0,0 +1,111 @@ +/** + * /관심분야 명령어 - 스터디원 관심 분야 조회 + * 스터디원들의 블로그 글에서 추출된 Top 10 키워드를 표시합니다. + * Requirements: 14.3 + */ + +import { + SlashCommandBuilder, + EmbedBuilder, + type ChatInputCommandInteraction, +} from 'discord.js'; +import type { CommandHandler } from '../bot'; +import { getKeywordService, type KeywordStat } from '../services/keyword.service'; +import { getRankEmoji } from './constants'; + +/** + * Format keyword entry for display + */ +function formatKeywordEntry(entry: KeywordStat, rank: number): string { + const rankEmoji = getRankEmoji(rank); + return `${rankEmoji} **${entry.keyword}** - ${entry.frequency}회`; +} + +/** + * Format top keywords for display + * Requirements: 14.3 - Display top 10 keywords with frequency counts + */ +export function formatTopKeywords(keywords: KeywordStat[]): string { + if (keywords.length === 0) { + return '아직 분석된 키워드가 없습니다.'; + } + + return keywords + .map((entry, index) => formatKeywordEntry(entry, index + 1)) + .join('\n'); +} + +export const 관심분야Command: CommandHandler = { + data: new SlashCommandBuilder() + .setName('관심분야') + .setDescription('스터디원들의 관심 분야 Top 10 키워드를 조회합니다.') + .addIntegerOption((option) => + option + .setName('개수') + .setDescription('표시할 키워드 수 (기본: 10)') + .setRequired(false) + .setMinValue(1) + .setMaxValue(50) + ) as SlashCommandBuilder, + + async execute(interaction: ChatInputCommandInteraction): Promise { + try { + const limit = interaction.options.getInteger('개수') || 10; + + const keywordService = getKeywordService(); + + // Get top keywords + const topKeywords = await keywordService.getTopKeywords(limit); + + if (topKeywords.length === 0) { + await interaction.reply({ + content: '📊 아직 분석된 키워드가 없습니다. 스터디원들의 글이 수집되면 관심 분야가 분석됩니다.', + }); + return; + } + + // Calculate total frequency + const totalFrequency = topKeywords.reduce((sum, k) => sum + k.frequency, 0); + + // Build embed + const embed = new EmbedBuilder() + .setColor(0x9b59b6) // Purple color for interests + .setTitle('🔍 스터디원 관심 분야') + .setDescription( + [ + `총 **${topKeywords.length}개** 키워드 분석`, + `총 언급 횟수: **${totalFrequency}회**`, + '', + '📊 **블로그 글 기반 키워드 분석**', + ].join('\n') + ) + .setTimestamp(); + + // Format keyword list + const keywordList = formatTopKeywords(topKeywords); + + embed.addFields({ + name: `Top ${topKeywords.length} 키워드`, + value: keywordList, + inline: false, + }); + + // Add footer with info + embed.setFooter({ + text: '💡 스터디원들의 블로그 글 제목과 설명에서 추출된 키워드입니다.', + }); + + await interaction.reply({ + embeds: [embed], + }); + } catch (error) { + console.error('Error in /관심분야 command:', error); + await interaction.reply({ + content: '❌ 관심 분야 조회 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.', + ephemeral: true, + }); + } + }, + + adminOnly: false, +}; diff --git a/packages/bot/src/commands/join.ts b/packages/bot/src/commands/join.ts new file mode 100644 index 0000000..db9a94b --- /dev/null +++ b/packages/bot/src/commands/join.ts @@ -0,0 +1,156 @@ +/** + * /참가 명령어 - 스터디 참가 등록 + * 블로그 URL로 스터디에 참가 등록합니다. + * Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 1.6 + */ + +import { + SlashCommandBuilder, + type ChatInputCommandInteraction, + type GuildMember, +} from 'discord.js'; +import type { CommandHandler } from '../bot'; +import { getMemberService, MemberError, MemberErrorCodes } from '../services/member.service'; +import { STUDY_ROLE_NAME } from './constants'; + +// Welcome message template +const WELCOME_MESSAGE = ` +🎉 **스터디 참가를 환영합니다!** + +📋 **스터디 규칙 안내** +• 총 10회차로 진행됩니다 (각 회차는 2주) +• 월요일 시작 → 2주 뒤 일요일 마감 +• 마감 다음날(월요일) 제출 시 지각 (벌금 3,000원) +• 마감 다음날 이후(화요일~) 미제출 시 결석 (벌금 5,000원) +• 휴면은 1회만 가능, 최대 4회차(8주) 동안 면제 + +📝 **명령어 안내** +• \`/내정보\` - 내 참가 정보 확인 +• \`/현황\` - 현재 회차 출석 현황 +• \`/랭킹\` - 포스트 수 랭킹 +• \`/탈퇴\` - 스터디 탈퇴 + +열심히 글 써봐요! 💪 +`; + +export const 참가Command: CommandHandler = { + data: new SlashCommandBuilder() + .setName('참가') + .setDescription('블로그 URL로 스터디에 참가 등록합니다.') + .addStringOption((option) => + option + .setName('블로그url') + .setDescription('블로그 주소 (예: https://velog.io/@username)') + .setRequired(true) + ) + .addStringOption((option) => + option + .setName('이름') + .setDescription('실명 (예: 홍길동)') + .setRequired(true) + ) + .addStringOption((option) => + option + .setName('파트') + .setDescription('담당 파트') + .setRequired(true) + .addChoices( + { name: 'Frontend', value: 'frontend' }, + { name: 'Backend', value: 'backend' }, + { name: 'Fullstack', value: 'fullstack' }, + { name: 'Design', value: 'design' }, + { name: 'PM', value: 'pm' }, + { name: 'DevOps', value: 'devops' }, + { name: 'Other', value: 'other' } + ) + ) + .addStringOption((option) => + option + .setName('rss_url') + .setDescription('RSS 피드 URL (자동 감지 실패 시 직접 입력)') + .setRequired(false) + ) as SlashCommandBuilder, + + async execute(interaction: ChatInputCommandInteraction): Promise { + const blogUrl = interaction.options.getString('블로그url', true); + const name = interaction.options.getString('이름', true); + const part = interaction.options.getString('파트', true); + const rssUrl = interaction.options.getString('rss_url') || undefined; + + const memberService = getMemberService(); + + try { + // Register the member + const member = await memberService.register({ + discordId: interaction.user.id, + discordUsername: interaction.user.username, + name, + part, + blogUrl, + rssUrl, + }); + + // Try to assign the study role + let roleAssigned = false; + if (interaction.guild && interaction.member) { + try { + const role = interaction.guild.roles.cache.find( + (r) => r.name === STUDY_ROLE_NAME + ); + if (role && interaction.member instanceof Object && 'roles' in interaction.member) { + const guildMember = interaction.member as GuildMember; + await guildMember.roles.add(role); + roleAssigned = true; + } + } catch (roleError) { + console.warn('Failed to assign study role:', roleError); + } + } + + // Build response message + let response = `✅ **스터디 참가 등록 완료!**\n\n`; + response += `📝 **등록 정보**\n`; + response += `• 이름: ${member.name}\n`; + response += `• 파트: ${member.part}\n`; + response += `• 블로그: ${member.blogUrl}\n`; + if (member.rssUrl) { + response += `• RSS: ${member.rssUrl}\n`; + } else { + response += `• RSS: 자동 감지 예정\n`; + } + if (roleAssigned) { + response += `• 역할: ${STUDY_ROLE_NAME} 부여됨\n`; + } + response += WELCOME_MESSAGE; + + await interaction.reply({ + content: response, + }); + } catch (error) { + if (error instanceof MemberError) { + let errorMessage = `❌ ${error.userMessage}`; + + if (error.code === MemberErrorCodes.INVALID_URL) { + errorMessage += '\n\n💡 올바른 블로그 URL 형식:\n'; + errorMessage += '• Velog: `https://velog.io/@username`\n'; + errorMessage += '• Tistory: `https://blogname.tistory.com`\n'; + errorMessage += '• Medium: `https://medium.com/@username`\n'; + errorMessage += '• 기타: `https://your-blog.com`'; + } + + await interaction.reply({ + content: errorMessage, + ephemeral: true, + }); + } else { + console.error('Error in /참가 command:', error); + await interaction.reply({ + content: '❌ 참가 등록 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.', + ephemeral: true, + }); + } + } + }, + + adminOnly: false, +}; diff --git a/packages/bot/src/commands/leave.ts b/packages/bot/src/commands/leave.ts new file mode 100644 index 0000000..c1b3c9d --- /dev/null +++ b/packages/bot/src/commands/leave.ts @@ -0,0 +1,81 @@ +/** + * /탈퇴 명령어 - 스터디 탈퇴 + * 스터디에서 탈퇴합니다. + * Requirements: 2.1, 2.2, 2.3 + */ + +import { + SlashCommandBuilder, + type ChatInputCommandInteraction, + type GuildMember, +} from 'discord.js'; +import type { CommandHandler } from '../bot'; +import { getMemberService, MemberError, MemberErrorCodes } from '../services/member.service'; +import { STUDY_ROLE_NAME } from './constants'; + +export const 탈퇴Command: CommandHandler = { + data: new SlashCommandBuilder() + .setName('탈퇴') + .setDescription('스터디에서 탈퇴합니다.') as SlashCommandBuilder, + + async execute(interaction: ChatInputCommandInteraction): Promise { + const memberService = getMemberService(); + + try { + // Withdraw the member + const member = await memberService.withdraw(interaction.user.id); + + // Try to remove the study role + let roleRemoved = false; + if (interaction.guild && interaction.member) { + try { + const role = interaction.guild.roles.cache.find( + (r) => r.name === STUDY_ROLE_NAME + ); + if (role && interaction.member instanceof Object && 'roles' in interaction.member) { + const guildMember = interaction.member as GuildMember; + await guildMember.roles.remove(role); + roleRemoved = true; + } + } catch (roleError) { + console.warn('Failed to remove study role:', roleError); + } + } + + // Build response message + let response = `👋 **스터디 탈퇴 완료**\n\n`; + response += `${member.name}님, 그동안 수고하셨습니다!\n`; + response += `작성하신 글과 출석 기록은 보존됩니다.\n\n`; + if (roleRemoved) { + response += `✅ ${STUDY_ROLE_NAME} 역할이 제거되었습니다.\n`; + } + response += `\n다음에 또 함께해요! 🙏`; + + await interaction.reply({ + content: response, + }); + } catch (error) { + if (error instanceof MemberError) { + if (error.code === MemberErrorCodes.USER_NOT_FOUND) { + await interaction.reply({ + content: '❌ 등록되지 않은 사용자입니다.\n\n💡 스터디에 참가하려면 `/참가` 명령어를 사용하세요.', + ephemeral: true, + }); + } else { + await interaction.reply({ + content: `❌ ${error.userMessage}`, + ephemeral: true, + }); + } + } else { + console.error('Error in /탈퇴 command:', error); + await interaction.reply({ + content: '❌ 탈퇴 처리 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.', + ephemeral: true, + }); + } + } + }, + + adminOnly: false, +}; diff --git a/packages/bot/src/commands/member-list.ts b/packages/bot/src/commands/member-list.ts new file mode 100644 index 0000000..25d15ff --- /dev/null +++ b/packages/bot/src/commands/member-list.ts @@ -0,0 +1,167 @@ +/** + * /참가자목록 명령어 - 전체 참가자 목록 조회 + * 스터디 참가자 목록을 상태별로 그룹화하여 표시합니다. + * Requirements: 4.3, 4.4 + */ + +import { + SlashCommandBuilder, + EmbedBuilder, + type ChatInputCommandInteraction, +} from 'discord.js'; +import type { CommandHandler } from '../bot'; +import { getMemberService } from '../services/member.service'; +import { MemberStatus, type Member } from '@blog-study/shared/db'; + +// Status display mapping +const STATUS_EMOJI: Record = { + [MemberStatus.ACTIVE]: '✅', + [MemberStatus.DORMANT]: '😴', + [MemberStatus.WITHDRAWN]: '👋', +}; + +const STATUS_TITLE: Record = { + [MemberStatus.ACTIVE]: '활동중', + [MemberStatus.DORMANT]: '휴면중', + [MemberStatus.WITHDRAWN]: '탈퇴', +}; + +/** + * Format member list for display + */ +function formatMemberList(members: Member[]): string { + if (members.length === 0) { + return '없음'; + } + + return members + .map((m, i) => `${i + 1}. **${m.name}** (${m.part}) - [블로그](${m.blogUrl})`) + .join('\n'); +} + +export const 참가자목록Command: CommandHandler = { + data: new SlashCommandBuilder() + .setName('참가자목록') + .setDescription('스터디 참가자 목록을 조회합니다.') + .addStringOption((option) => + option + .setName('상태') + .setDescription('특정 상태의 참가자만 조회') + .setRequired(false) + .addChoices( + { name: '전체', value: 'all' }, + { name: '활동중', value: MemberStatus.ACTIVE }, + { name: '휴면중', value: MemberStatus.DORMANT }, + { name: '탈퇴', value: MemberStatus.WITHDRAWN } + ) + ) as SlashCommandBuilder, + + async execute(interaction: ChatInputCommandInteraction): Promise { + const memberService = getMemberService(); + const statusFilter = interaction.options.getString('상태') || 'all'; + + try { + const allMembers = await memberService.getAll(); + + // Group members by status + const activeMembers: Member[] = []; + const dormantMembers: Member[] = []; + const withdrawnMembers: Member[] = []; + + for (const member of allMembers) { + if (member.status === MemberStatus.ACTIVE) { + activeMembers.push(member); + } else if (member.status === MemberStatus.DORMANT) { + dormantMembers.push(member); + } else if (member.status === MemberStatus.WITHDRAWN) { + withdrawnMembers.push(member); + } + } + + const grouped: Record = { + [MemberStatus.ACTIVE]: activeMembers, + [MemberStatus.DORMANT]: dormantMembers, + [MemberStatus.WITHDRAWN]: withdrawnMembers, + }; + + // Build embed + const embed = new EmbedBuilder() + .setColor(0x5865f2) + .setTitle('📋 스터디 참가자 목록') + .setTimestamp(); + + // Add summary + const activeCount = activeMembers.length; + const dormantCount = dormantMembers.length; + const withdrawnCount = withdrawnMembers.length; + const totalCount = allMembers.length; + + embed.setDescription( + `총 **${totalCount}명** (활동 ${activeCount} / 휴면 ${dormantCount} / 탈퇴 ${withdrawnCount})` + ); + + // Helper to add member group to embed + const addMemberGroup = (members: Member[], status: string) => { + if (members.length === 0) return; + + const emoji = STATUS_EMOJI[status] || '📌'; + const title = STATUS_TITLE[status] || status; + const memberList = formatMemberList(members); + + if (memberList.length <= 1024) { + embed.addFields({ + name: `${emoji} ${title} (${members.length}명)`, + value: memberList, + inline: false, + }); + } else { + const truncated = members.slice(0, 10); + embed.addFields({ + name: `${emoji} ${title} (${members.length}명)`, + value: formatMemberList(truncated) + `\n... 외 ${members.length - 10}명`, + inline: false, + }); + } + }; + + // Add fields based on filter + if (statusFilter === 'all') { + // Show all groups + addMemberGroup(activeMembers, MemberStatus.ACTIVE); + addMemberGroup(dormantMembers, MemberStatus.DORMANT); + addMemberGroup(withdrawnMembers, MemberStatus.WITHDRAWN); + } else { + // Show only filtered status + const members = grouped[statusFilter] || []; + const emoji = STATUS_EMOJI[statusFilter] || '📌'; + const title = STATUS_TITLE[statusFilter] || statusFilter; + + if (members.length === 0) { + embed.addFields({ + name: `${emoji} ${title}`, + value: '해당 상태의 참가자가 없습니다.', + inline: false, + }); + } else { + addMemberGroup(members, statusFilter); + } + } + + embed.setFooter({ + text: '💡 /내정보 명령어로 본인의 상세 정보를 확인하세요', + }); + + await interaction.reply({ + embeds: [embed], + }); + } catch (error) { + console.error('Error in /참가자목록 command:', error); + await interaction.reply({ + content: '❌ 참가자 목록 조회 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.', + ephemeral: true, + }); + } + }, + + adminOnly: false, +}; diff --git a/packages/bot/src/commands/my-info.ts b/packages/bot/src/commands/my-info.ts new file mode 100644 index 0000000..86a6305 --- /dev/null +++ b/packages/bot/src/commands/my-info.ts @@ -0,0 +1,115 @@ +/** + * /내정보 명령어 - 내 참가 정보 조회 + * 내 스터디 참가 정보를 조회합니다. + * Requirements: 4.1, 4.2 + */ + +import { + SlashCommandBuilder, + EmbedBuilder, + type ChatInputCommandInteraction, +} from 'discord.js'; +import type { CommandHandler } from '../bot'; +import { getMemberService, MemberError } from '../services/member.service'; +import { MemberStatus } from '@blog-study/shared/db'; + +// Status display mapping +const STATUS_DISPLAY: Record = { + [MemberStatus.ACTIVE]: '✅ 활동중', + [MemberStatus.DORMANT]: '😴 휴면중', + [MemberStatus.WITHDRAWN]: '👋 탈퇴', +}; + +export const 내정보Command: CommandHandler = { + data: new SlashCommandBuilder() + .setName('내정보') + .setDescription('내 스터디 참가 정보를 조회합니다.') as SlashCommandBuilder, + + async execute(interaction: ChatInputCommandInteraction): Promise { + const memberService = getMemberService(); + + try { + const member = await memberService.getByDiscordId(interaction.user.id); + + if (!member) { + await interaction.reply({ + content: '❌ 등록되지 않은 사용자입니다.\n\n💡 스터디에 참가하려면 `/참가` 명령어를 사용하세요.', + ephemeral: true, + }); + return; + } + + // Build embed message + const embed = new EmbedBuilder() + .setColor(member.status === MemberStatus.ACTIVE ? 0x00ff00 : 0xffaa00) + .setTitle(`📋 ${member.name}님의 스터디 정보`) + .setThumbnail(interaction.user.displayAvatarURL()) + .addFields( + { + name: '👤 기본 정보', + value: [ + `**이름:** ${member.name}`, + `**파트:** ${member.part}`, + `**상태:** ${STATUS_DISPLAY[member.status] || member.status}`, + ].join('\n'), + inline: true, + }, + { + name: '📝 블로그', + value: [ + `**URL:** ${member.blogUrl}`, + `**RSS:** ${member.rssUrl || '자동 감지 예정'}`, + ].join('\n'), + inline: true, + }, + { + name: '📊 활동 정보', + value: [ + `**가입일:** ${member.joinedAt ? new Date(member.joinedAt).toLocaleDateString('ko-KR') : '알 수 없음'}`, + `**휴면 사용:** ${member.dormantUsed ? '사용함' : '미사용'}`, + ].join('\n'), + inline: false, + } + ) + .setFooter({ + text: '💡 /현황 명령어로 현재 회차 출석 현황을 확인하세요', + }) + .setTimestamp(); + + // Add dormant info if applicable + if (member.status === MemberStatus.DORMANT && member.dormantStartRound) { + embed.addFields({ + name: '😴 휴면 정보', + value: [ + `**시작 회차:** ${member.dormantStartRound}회차`, + `**종료 예정:** ${member.dormantStartRound + 4}회차`, + ].join('\n'), + inline: false, + }); + } + + // TODO: Add post count and fine info when those services are implemented + // This will be added in later tasks + + await interaction.reply({ + embeds: [embed], + ephemeral: true, + }); + } catch (error) { + if (error instanceof MemberError) { + await interaction.reply({ + content: `❌ ${error.userMessage}`, + ephemeral: true, + }); + } else { + console.error('Error in /내정보 command:', error); + await interaction.reply({ + content: '❌ 정보 조회 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.', + ephemeral: true, + }); + } + } + }, + + adminOnly: false, +}; diff --git a/packages/bot/src/commands/ping.ts b/packages/bot/src/commands/ping.ts new file mode 100644 index 0000000..6abe3ba --- /dev/null +++ b/packages/bot/src/commands/ping.ts @@ -0,0 +1,23 @@ +// /핑 명령어 - 봇 상태 확인용 +// 봇이 정상적으로 동작하는지 확인하는 간단한 명령어입니다. + +import { SlashCommandBuilder, type ChatInputCommandInteraction } from 'discord.js'; +import type { CommandHandler } from '../bot'; + +export const 핑Command: CommandHandler = { + data: new SlashCommandBuilder() + .setName('핑') + .setDescription('봇이 정상적으로 동작하는지 확인합니다.') as SlashCommandBuilder, + + async execute(interaction: ChatInputCommandInteraction): Promise { + const latency = Date.now() - interaction.createdTimestamp; + const apiLatency = Math.round(interaction.client.ws.ping); + + await interaction.reply({ + content: `🏓 퐁!\n⏱️ 응답 시간: ${latency}ms\n📡 API 지연: ${apiLatency}ms`, + ephemeral: true, + }); + }, + + adminOnly: false, +}; diff --git a/packages/bot/src/commands/ranking.ts b/packages/bot/src/commands/ranking.ts new file mode 100644 index 0000000..590b310 --- /dev/null +++ b/packages/bot/src/commands/ranking.ts @@ -0,0 +1,181 @@ +/** + * /랭킹 명령어 - 누적 포스트 수 기준 랭킹 조회 + * 스터디원들의 누적 포스트 수와 출석률 기준 랭킹을 표시합니다. + * Requirements: 9.3, 9.5 + */ + +import { + SlashCommandBuilder, + EmbedBuilder, + type ChatInputCommandInteraction, +} from 'discord.js'; +import type { CommandHandler } from '../bot'; +import { getMemberService } from '../services/member.service'; +import { getPostService } from '../services/post.service'; +import { getAttendanceService } from '../services/attendance.service'; +import { MemberStatus, AttendanceStatus, type Member } from '@blog-study/shared/db'; +import { getRankEmoji } from './constants'; + +/** + * Ranking entry with member info and stats + */ +export interface RankingEntry { + member: Member; + postCount: number; + attendanceRate: number; + submittedRounds: number; + totalRounds: number; +} + +/** + * Calculate ranking data for all members + * Requirements: 9.3, 9.5 - Ranking by post count with attendance rate + */ +export async function calculateRankings(): Promise { + const memberService = getMemberService(); + const postService = getPostService(); + const attendanceService = getAttendanceService(); + + // Get all members (not just active, to show historical rankings) + const allMembers = await memberService.getAll(); + + // Filter to active and dormant members (exclude withdrawn for ranking) + const eligibleMembers = allMembers.filter( + (m) => m.status === MemberStatus.ACTIVE || m.status === MemberStatus.DORMANT + ); + + const rankings: RankingEntry[] = []; + + for (const member of eligibleMembers) { + // Get post count + const postCount = await postService.countByMember(member.id); + + // Get attendance records + const attendanceRecords = await attendanceService.getByMember(member.id); + + // Calculate attendance rate + const totalRounds = attendanceRecords.length; + const submittedRounds = attendanceRecords.filter( + (a) => a.status === AttendanceStatus.SUBMITTED || a.status === AttendanceStatus.LATE + ).length; + + const attendanceRate = totalRounds > 0 + ? Math.round((submittedRounds / totalRounds) * 100) + : 0; + + rankings.push({ + member, + postCount, + attendanceRate, + submittedRounds, + totalRounds, + }); + } + + // Sort by post count descending, then by attendance rate descending + rankings.sort((a, b) => { + if (b.postCount !== a.postCount) { + return b.postCount - a.postCount; + } + return b.attendanceRate - a.attendanceRate; + }); + + return rankings; +} + +/** + * Format ranking entry for display + */ +function formatRankingEntry(entry: RankingEntry, rank: number): string { + const rankEmoji = getRankEmoji(rank); + const statusEmoji = entry.member.status === MemberStatus.DORMANT ? ' 😴' : ''; + + return `${rankEmoji} **${entry.member.name}**${statusEmoji} - ${entry.postCount}개 (출석률 ${entry.attendanceRate}%)`; +} + +export const 랭킹Command: CommandHandler = { + data: new SlashCommandBuilder() + .setName('랭킹') + .setDescription('누적 포스트 수 기준 랭킹을 조회합니다.') + .addIntegerOption((option) => + option + .setName('개수') + .setDescription('표시할 랭킹 수 (기본: 10)') + .setRequired(false) + .setMinValue(1) + .setMaxValue(50) + ) as SlashCommandBuilder, + + async execute(interaction: ChatInputCommandInteraction): Promise { + try { + const limit = interaction.options.getInteger('개수') || 10; + + // Calculate rankings + const rankings = await calculateRankings(); + + if (rankings.length === 0) { + await interaction.reply({ + content: '📊 아직 랭킹 데이터가 없습니다.', + }); + return; + } + + // Get top N rankings + const topRankings = rankings.slice(0, limit); + + // Calculate total posts + const totalPosts = rankings.reduce((sum, r) => sum + r.postCount, 0); + const avgPosts = rankings.length > 0 + ? (totalPosts / rankings.length).toFixed(1) + : '0'; + + // Build embed + const embed = new EmbedBuilder() + .setColor(0xffd700) // Gold color for rankings + .setTitle('🏆 스터디 랭킹') + .setDescription( + [ + `총 **${rankings.length}명** 참가 | 총 **${totalPosts}개** 포스트`, + `평균 포스트 수: **${avgPosts}개**`, + '', + '📊 **누적 포스트 수 기준**', + ].join('\n') + ) + .setTimestamp(); + + // Format ranking list + const rankingList = topRankings + .map((entry, index) => formatRankingEntry(entry, index + 1)) + .join('\n'); + + embed.addFields({ + name: `Top ${topRankings.length}`, + value: rankingList || '데이터 없음', + inline: false, + }); + + // Add footer with info + if (rankings.length > limit) { + embed.setFooter({ + text: `💡 /랭킹 개수:${rankings.length} 으로 전체 랭킹을 확인하세요`, + }); + } else { + embed.setFooter({ + text: '💡 출석률 = 제출 회차 / 전체 회차', + }); + } + + await interaction.reply({ + embeds: [embed], + }); + } catch (error) { + console.error('Error in /랭킹 command:', error); + await interaction.reply({ + content: '❌ 랭킹 조회 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.', + ephemeral: true, + }); + } + }, + + adminOnly: false, +}; diff --git a/packages/bot/src/commands/stats.ts b/packages/bot/src/commands/stats.ts new file mode 100644 index 0000000..40b7b67 --- /dev/null +++ b/packages/bot/src/commands/stats.ts @@ -0,0 +1,248 @@ +/** + * /통계 명령어 - 회차별 통계 조회 + * 회차별 제출률, 지각률, 결석률을 표시합니다. + * Requirements: 9.4 + */ + +import { + SlashCommandBuilder, + EmbedBuilder, + type ChatInputCommandInteraction, +} from 'discord.js'; +import type { CommandHandler } from '../bot'; +import { getAttendanceService } from '../services/attendance.service'; +import { getPostService } from '../services/post.service'; +import { getMemberService } from '../services/member.service'; +import { getCurrentRound, getRoundByNumber } from '../services/round.service'; +import { AttendanceStatus, type Round } from '@blog-study/shared/db'; + +/** + * Round statistics + */ +export interface RoundStatistics { + roundNumber: number; + totalMembers: number; + submittedCount: number; + lateCount: number; + absentCount: number; + pendingCount: number; + submissionRate: number; + lateRate: number; + absentRate: number; + totalPosts: number; + topContributors: Array<{ name: string; postCount: number }>; +} + +/** + * Calculate statistics for a specific round + * Requirements: 9.4 - Round statistics calculation + */ +export async function calculateRoundStatistics( + roundId: number, + roundNumber: number +): Promise { + const attendanceService = getAttendanceService(); + const postService = getPostService(); + const memberService = getMemberService(); + + // Get attendance records for the round + const attendanceRecords = await attendanceService.getByRound(roundId); + + // Get posts for the round + const posts = await postService.getByRound(roundId); + + // Count by status + let submittedCount = 0; + let lateCount = 0; + let absentCount = 0; + let pendingCount = 0; + + for (const record of attendanceRecords) { + switch (record.status) { + case AttendanceStatus.SUBMITTED: + submittedCount++; + break; + case AttendanceStatus.LATE: + lateCount++; + break; + case AttendanceStatus.ABSENT: + absentCount++; + break; + default: + pendingCount++; + break; + } + } + + const totalMembers = attendanceRecords.length; + + // Calculate rates + const submissionRate = totalMembers > 0 + ? Math.round(((submittedCount + lateCount) / totalMembers) * 100) + : 0; + const lateRate = totalMembers > 0 + ? Math.round((lateCount / totalMembers) * 100) + : 0; + const absentRate = totalMembers > 0 + ? Math.round((absentCount / totalMembers) * 100) + : 0; + + // Calculate top contributors (members with most posts in this round) + const postCountByMember = new Map(); + for (const post of posts) { + const current = postCountByMember.get(post.memberId) || 0; + postCountByMember.set(post.memberId, current + 1); + } + + // Get member names and sort by post count + const topContributors: Array<{ name: string; postCount: number }> = []; + for (const [memberId, postCount] of postCountByMember) { + const member = await memberService.getById(memberId); + if (member) { + topContributors.push({ name: member.name, postCount }); + } + } + topContributors.sort((a, b) => b.postCount - a.postCount); + + return { + roundNumber, + totalMembers, + submittedCount, + lateCount, + absentCount, + pendingCount, + submissionRate, + lateRate, + absentRate, + totalPosts: posts.length, + topContributors: topContributors.slice(0, 3), // Top 3 + }; +} + +/** + * Format percentage with bar visualization + */ +function formatPercentageBar(rate: number, emoji: string): string { + const filled = Math.round(rate / 10); + const empty = 10 - filled; + const bar = emoji.repeat(filled) + '░'.repeat(empty); + return `${bar} ${rate}%`; +} + +export const 통계Command: CommandHandler = { + data: new SlashCommandBuilder() + .setName('통계') + .setDescription('회차별 통계를 조회합니다.') + .addIntegerOption((option) => + option + .setName('회차') + .setDescription('조회할 회차 번호 (미입력 시 현재 회차)') + .setRequired(false) + .setMinValue(1) + ) as SlashCommandBuilder, + + async execute(interaction: ChatInputCommandInteraction): Promise { + try { + const roundNumberInput = interaction.options.getInteger('회차'); + + let round: Round; + + if (roundNumberInput) { + // Get specific round + const foundRound = await getRoundByNumber(roundNumberInput); + if (!foundRound) { + await interaction.reply({ + content: `❌ ${roundNumberInput}회차를 찾을 수 없습니다.`, + ephemeral: true, + }); + return; + } + round = foundRound; + } else { + // Get current round + round = await getCurrentRound(); + } + + // Calculate statistics + const stats = await calculateRoundStatistics(round.id, round.roundNumber); + + // Build embed + const embed = new EmbedBuilder() + .setColor(0x5865f2) + .setTitle(`📈 ${stats.roundNumber}회차 통계`) + .setDescription( + [ + `📅 기간: ${round.startDate} ~ ${round.endDate}`, + `👥 참가자: **${stats.totalMembers}명** | 📝 포스트: **${stats.totalPosts}개**`, + ].join('\n') + ) + .setTimestamp(); + + // Add submission rate + embed.addFields({ + name: '✅ 제출률', + value: formatPercentageBar(stats.submissionRate, '🟩'), + inline: true, + }); + + // Add late rate + embed.addFields({ + name: '⚠️ 지각률', + value: formatPercentageBar(stats.lateRate, '🟨'), + inline: true, + }); + + // Add absent rate + embed.addFields({ + name: '❌ 결석률', + value: formatPercentageBar(stats.absentRate, '🟥'), + inline: true, + }); + + // Add detailed counts + embed.addFields({ + name: '📊 상세 현황', + value: [ + `✅ 제출: ${stats.submittedCount}명`, + `⚠️ 지각: ${stats.lateCount}명`, + `❌ 결석: ${stats.absentCount}명`, + `⏳ 미제출: ${stats.pendingCount}명`, + ].join(' | '), + inline: false, + }); + + // Add top contributors if any + if (stats.topContributors.length > 0) { + const topList = stats.topContributors + .map((c, i) => { + const emoji = i === 0 ? '🥇' : i === 1 ? '🥈' : '🥉'; + return `${emoji} ${c.name} (${c.postCount}개)`; + }) + .join('\n'); + + embed.addFields({ + name: '🏆 이번 회차 MVP', + value: topList, + inline: false, + }); + } + + // Add footer + embed.setFooter({ + text: '💡 /통계 회차:N 으로 특정 회차 통계를 확인하세요', + }); + + await interaction.reply({ + embeds: [embed], + }); + } catch (error) { + console.error('Error in /통계 command:', error); + await interaction.reply({ + content: '❌ 통계 조회 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.', + ephemeral: true, + }); + } + }, + + adminOnly: false, +}; diff --git a/packages/bot/src/commands/status.property.test.ts b/packages/bot/src/commands/status.property.test.ts new file mode 100644 index 0000000..cefb773 --- /dev/null +++ b/packages/bot/src/commands/status.property.test.ts @@ -0,0 +1,682 @@ +/** + * Property-Based Tests for Status Display Commands + * Tests correctness properties for 현황, 랭킹, 통계 commands + * + * These tests verify the business logic of status display operations using + * pure functions extracted from the commands. + */ + +import { describe, it, expect } from 'vitest'; +import * as fc from 'fast-check'; +import { + AttendanceStatus, + MemberStatus, + type Member, + type Attendance, + type AttendanceStatusType, + type MemberStatusType, +} from '@blog-study/shared/db'; +import { getDaysUntilDeadline, getDaysUntilGraceEnd, type RoundDates } from '@blog-study/shared/utils'; + +// ============================================ +// Arbitraries for generating test data +// ============================================ + +/** + * Generate valid UUIDs + */ +const uuidArb = fc.uuid(); + +/** + * Generate valid round IDs + */ +const roundIdArb = fc.integer({ min: 1, max: 52 }); + +/** + * Generate valid attendance status + */ +const attendanceStatusArb = fc.constantFrom( + AttendanceStatus.PENDING, + AttendanceStatus.SUBMITTED, + AttendanceStatus.LATE, + AttendanceStatus.ABSENT +) as fc.Arbitrary; + +/** + * Generate valid member status (active or dormant for rankings) + */ +const memberStatusArb = fc.constantFrom( + MemberStatus.ACTIVE, + MemberStatus.DORMANT +) as fc.Arbitrary; + +/** + * Generate a valid member (simplified for faster tests) + */ +const memberArb = fc.record({ + id: uuidArb, + discordId: fc.stringMatching(/^\d{17,19}$/), + discordUsername: fc.string({ minLength: 1, maxLength: 10 }), + name: fc.string({ minLength: 1, maxLength: 20 }), + part: fc.constantFrom('frontend', 'backend', 'design', 'pm', 'fullstack'), + blogUrl: fc.constant('https://example.com/blog'), + rssUrl: fc.constant(null), + profileImageUrl: fc.constant(null), + bio: fc.constant(null), + interests: fc.constant(null), + resolution: fc.constant(null), + onboardingCompleted: fc.constant(false), + status: memberStatusArb, + dormantStartRound: fc.constant(null), + dormantUsed: fc.constant(false), + joinedAt: fc.constant(new Date('2024-06-01')), + updatedAt: fc.constant(new Date('2024-06-01')), +}) as fc.Arbitrary; + +/** + * Generate attendance record + */ +const attendanceArb = fc.record({ + id: uuidArb, + memberId: uuidArb, + roundId: roundIdArb, + status: attendanceStatusArb, + submittedAt: fc.option(fc.date({ min: new Date('2024-01-01'), max: new Date('2024-12-31') }), { nil: null }), + createdAt: fc.date({ min: new Date('2024-01-01'), max: new Date('2024-12-31') }), + updatedAt: fc.date({ min: new Date('2024-01-01'), max: new Date('2024-12-31') }), +}) as fc.Arbitrary; + +// ============================================ +// Pure Business Logic Functions for Testing +// ============================================ + +/** + * Group members by attendance status + * Requirements: 9.1 - Display current round attendance status for all active members + */ +interface StatusGroups { + submitted: Member[]; + pending: Member[]; + late: Member[]; + absent: Member[]; +} + +function groupMembersByStatus( + members: Member[], + attendanceMap: Map +): StatusGroups { + const groups: StatusGroups = { + submitted: [], + pending: [], + late: [], + absent: [], + }; + + for (const member of members) { + const status = attendanceMap.get(member.id) || AttendanceStatus.PENDING; + + switch (status) { + case AttendanceStatus.SUBMITTED: + groups.submitted.push(member); + break; + case AttendanceStatus.LATE: + groups.late.push(member); + break; + case AttendanceStatus.ABSENT: + groups.absent.push(member); + break; + default: + groups.pending.push(member); + break; + } + } + + return groups; +} + +/** + * Calculate remaining days info + * Requirements: 9.2 - Show days remaining until deadline and grace period end + */ +interface RemainingDaysInfo { + daysUntilDeadline: number; + daysUntilGraceEnd: number; + isGracePeriod: boolean; + isCompleted: boolean; +} + +function calculateRemainingDays( + roundDates: RoundDates, + currentDate: Date +): RemainingDaysInfo { + const daysUntilDeadline = getDaysUntilDeadline(roundDates, currentDate); + const daysUntilGraceEnd = getDaysUntilGraceEnd(roundDates, currentDate); + + return { + daysUntilDeadline, + daysUntilGraceEnd, + isGracePeriod: daysUntilDeadline === 0 && daysUntilGraceEnd > 0, + isCompleted: daysUntilDeadline === 0 && daysUntilGraceEnd === 0, + }; +} + +/** + * Ranking entry with member info and stats + * Requirements: 9.3, 9.5 - Ranking by post count with attendance rate + */ +interface RankingEntry { + member: Member; + postCount: number; + attendanceRate: number; + submittedRounds: number; + totalRounds: number; +} + +/** + * Sort rankings by post count descending, then by attendance rate + * Requirements: 9.3 - Display cumulative post count ranking in descending order + */ +function sortRankings(rankings: RankingEntry[]): RankingEntry[] { + return [...rankings].sort((a, b) => { + if (b.postCount !== a.postCount) { + return b.postCount - a.postCount; + } + return b.attendanceRate - a.attendanceRate; + }); +} + +/** + * Calculate attendance rate + * Requirements: 9.5 - Include attendance rate for each member + */ +function calculateAttendanceRate(submittedRounds: number, totalRounds: number): number { + if (totalRounds === 0) return 0; + return Math.round((submittedRounds / totalRounds) * 100); +} + +/** + * Round statistics + * Requirements: 9.4 - Round statistics calculation + */ +interface RoundStatistics { + totalMembers: number; + submittedCount: number; + lateCount: number; + absentCount: number; + pendingCount: number; + submissionRate: number; + lateRate: number; + absentRate: number; +} + +/** + * Calculate round statistics from attendance records + * Requirements: 9.4 - Calculate submission rate, late rate, absent rate + */ +function calculateRoundStatistics(attendanceRecords: Attendance[]): RoundStatistics { + let submittedCount = 0; + let lateCount = 0; + let absentCount = 0; + let pendingCount = 0; + + for (const record of attendanceRecords) { + switch (record.status) { + case AttendanceStatus.SUBMITTED: + submittedCount++; + break; + case AttendanceStatus.LATE: + lateCount++; + break; + case AttendanceStatus.ABSENT: + absentCount++; + break; + default: + pendingCount++; + break; + } + } + + const totalMembers = attendanceRecords.length; + + // Submission rate includes both submitted and late + const submissionRate = totalMembers > 0 + ? Math.round(((submittedCount + lateCount) / totalMembers) * 100) + : 0; + const lateRate = totalMembers > 0 + ? Math.round((lateCount / totalMembers) * 100) + : 0; + const absentRate = totalMembers > 0 + ? Math.round((absentCount / totalMembers) * 100) + : 0; + + return { + totalMembers, + submittedCount, + lateCount, + absentCount, + pendingCount, + submissionRate, + lateRate, + absentRate, + }; +} + +// ============================================ +// Property Tests +// ============================================ + +describe('Status Display Property Tests', () => { + /** + * **Feature: blog-study-discord-bot, Property 24: Current Status Display** + * *For any* `/현황` command, the response SHALL show all active Members' + * attendance status for the current round with remaining days until deadline. + * **Validates: Requirements 9.1, 9.2** + */ + describe('Property 24: Current Status Display', () => { + it('should group all active members by their attendance status', () => { + fc.assert( + fc.property( + fc.array(memberArb, { minLength: 1, maxLength: 20 }), + fc.array(attendanceStatusArb, { minLength: 1, maxLength: 20 }), + (members, statuses) => { + // Filter to only active members + const activeMembers = members.filter(m => m.status === MemberStatus.ACTIVE); + fc.pre(activeMembers.length > 0); + + // Create attendance map + const attendanceMap = new Map(); + activeMembers.forEach((member, index) => { + const status = statuses[index % statuses.length]; + if (status) { + attendanceMap.set(member.id, status); + } + }); + + // Group members + const groups = groupMembersByStatus(activeMembers, attendanceMap); + + // Verify all members are accounted for + const totalGrouped = + groups.submitted.length + + groups.pending.length + + groups.late.length + + groups.absent.length; + + expect(totalGrouped).toBe(activeMembers.length); + + // Verify each member is in exactly one group + const allGroupedIds = [ + ...groups.submitted.map(m => m.id), + ...groups.pending.map(m => m.id), + ...groups.late.map(m => m.id), + ...groups.absent.map(m => m.id), + ]; + const uniqueIds = new Set(allGroupedIds); + expect(uniqueIds.size).toBe(activeMembers.length); + } + ), + { numRuns: 100 } + ); + }); + + it('should correctly assign members to status groups based on attendance', () => { + fc.assert( + fc.property( + fc.array(memberArb.filter(m => m.status === MemberStatus.ACTIVE), { minLength: 1, maxLength: 10 }), + (activeMembers) => { + // Create specific attendance statuses + const attendanceMap = new Map(); + activeMembers.forEach((member, index) => { + const statuses: AttendanceStatusType[] = [ + AttendanceStatus.SUBMITTED, + AttendanceStatus.PENDING, + AttendanceStatus.LATE, + AttendanceStatus.ABSENT, + ]; + attendanceMap.set(member.id, statuses[index % 4]!); + }); + + const groups = groupMembersByStatus(activeMembers, attendanceMap); + + // Verify each member is in the correct group + for (const member of groups.submitted) { + expect(attendanceMap.get(member.id)).toBe(AttendanceStatus.SUBMITTED); + } + for (const member of groups.late) { + expect(attendanceMap.get(member.id)).toBe(AttendanceStatus.LATE); + } + for (const member of groups.absent) { + expect(attendanceMap.get(member.id)).toBe(AttendanceStatus.ABSENT); + } + for (const member of groups.pending) { + const status = attendanceMap.get(member.id); + expect(status === AttendanceStatus.PENDING || status === undefined).toBe(true); + } + } + ), + { numRuns: 100 } + ); + }); + + it('should calculate remaining days correctly', () => { + fc.assert( + fc.property( + fc.date({ min: new Date('2024-01-01'), max: new Date('2024-06-30') }), + (startDate) => { + // Create round dates + const roundDates: RoundDates = { + roundNumber: 1, + startDate: new Date(startDate), + endDate: new Date(startDate.getTime() + 13 * 24 * 60 * 60 * 1000), // +13 days + graceEndDate: new Date(startDate.getTime() + 14 * 24 * 60 * 60 * 1000), // +14 days + }; + roundDates.endDate.setHours(23, 59, 59, 999); + roundDates.graceEndDate.setHours(23, 59, 59, 999); + + // Test before deadline + const beforeDeadline = new Date(startDate.getTime() + 5 * 24 * 60 * 60 * 1000); + const infoBefore = calculateRemainingDays(roundDates, beforeDeadline); + expect(infoBefore.daysUntilDeadline).toBeGreaterThan(0); + expect(infoBefore.isGracePeriod).toBe(false); + expect(infoBefore.isCompleted).toBe(false); + + // Test during grace period + const duringGrace = new Date(roundDates.endDate.getTime() + 12 * 60 * 60 * 1000); // +12 hours after deadline + const infoDuring = calculateRemainingDays(roundDates, duringGrace); + expect(infoDuring.daysUntilDeadline).toBe(0); + expect(infoDuring.daysUntilGraceEnd).toBeGreaterThan(0); + expect(infoDuring.isGracePeriod).toBe(true); + expect(infoDuring.isCompleted).toBe(false); + + // Test after grace period + const afterGrace = new Date(roundDates.graceEndDate.getTime() + 24 * 60 * 60 * 1000); + const infoAfter = calculateRemainingDays(roundDates, afterGrace); + expect(infoAfter.daysUntilDeadline).toBe(0); + expect(infoAfter.daysUntilGraceEnd).toBe(0); + expect(infoAfter.isCompleted).toBe(true); + } + ), + { numRuns: 50 } + ); + }); + + it('should default to pending status for members without attendance records', () => { + fc.assert( + fc.property( + fc.array(memberArb.filter(m => m.status === MemberStatus.ACTIVE), { minLength: 1, maxLength: 10 }), + (activeMembers) => { + // Empty attendance map - no records + const attendanceMap = new Map(); + + const groups = groupMembersByStatus(activeMembers, attendanceMap); + + // All members should be in pending group + expect(groups.pending.length).toBe(activeMembers.length); + expect(groups.submitted.length).toBe(0); + expect(groups.late.length).toBe(0); + expect(groups.absent.length).toBe(0); + } + ), + { numRuns: 100 } + ); + }); + }); + + /** + * **Feature: blog-study-discord-bot, Property 25: Ranking Order** + * *For any* set of Members, the ranking SHALL be sorted by cumulative post count + * in descending order, with attendance rate included. + * **Validates: Requirements 9.3, 9.5** + */ + describe('Property 25: Ranking Order', () => { + it('should sort rankings by post count in descending order', () => { + fc.assert( + fc.property( + fc.array( + fc.record({ + member: memberArb, + postCount: fc.integer({ min: 0, max: 100 }), + attendanceRate: fc.integer({ min: 0, max: 100 }), + submittedRounds: fc.integer({ min: 0, max: 52 }), + totalRounds: fc.integer({ min: 0, max: 52 }), + }), + { minLength: 2, maxLength: 20 } + ), + (rankings) => { + const sorted = sortRankings(rankings); + + // Verify descending order by post count + for (let i = 0; i < sorted.length - 1; i++) { + const current = sorted[i]!; + const next = sorted[i + 1]!; + + // Post count should be >= next + expect(current.postCount).toBeGreaterThanOrEqual(next.postCount); + + // If post counts are equal, attendance rate should be >= next + if (current.postCount === next.postCount) { + expect(current.attendanceRate).toBeGreaterThanOrEqual(next.attendanceRate); + } + } + } + ), + { numRuns: 100 } + ); + }); + + it('should preserve all ranking entries after sorting', () => { + fc.assert( + fc.property( + fc.array( + fc.record({ + member: memberArb, + postCount: fc.integer({ min: 0, max: 100 }), + attendanceRate: fc.integer({ min: 0, max: 100 }), + submittedRounds: fc.integer({ min: 0, max: 52 }), + totalRounds: fc.integer({ min: 0, max: 52 }), + }), + { minLength: 1, maxLength: 20 } + ), + (rankings) => { + const sorted = sortRankings(rankings); + + // Same length + expect(sorted.length).toBe(rankings.length); + + // All original entries should be present + const originalIds = new Set(rankings.map(r => r.member.id)); + const sortedIds = new Set(sorted.map(r => r.member.id)); + expect(sortedIds).toEqual(originalIds); + } + ), + { numRuns: 100 } + ); + }); + + it('should calculate attendance rate correctly', () => { + fc.assert( + fc.property( + fc.integer({ min: 0, max: 52 }), + fc.integer({ min: 0, max: 52 }), + (submitted, total) => { + // Ensure submitted <= total + const actualSubmitted = Math.min(submitted, total); + + const rate = calculateAttendanceRate(actualSubmitted, total); + + if (total === 0) { + expect(rate).toBe(0); + } else { + const expectedRate = Math.round((actualSubmitted / total) * 100); + expect(rate).toBe(expectedRate); + expect(rate).toBeGreaterThanOrEqual(0); + expect(rate).toBeLessThanOrEqual(100); + } + } + ), + { numRuns: 100 } + ); + }); + + it('should handle empty rankings', () => { + const sorted = sortRankings([]); + expect(sorted).toEqual([]); + }); + + it('should handle single entry rankings', () => { + fc.assert( + fc.property( + fc.record({ + member: memberArb, + postCount: fc.integer({ min: 0, max: 100 }), + attendanceRate: fc.integer({ min: 0, max: 100 }), + submittedRounds: fc.integer({ min: 0, max: 52 }), + totalRounds: fc.integer({ min: 0, max: 52 }), + }), + (entry) => { + const sorted = sortRankings([entry]); + expect(sorted.length).toBe(1); + expect(sorted[0]).toEqual(entry); + } + ), + { numRuns: 50 } + ); + }); + }); + + /** + * **Feature: blog-study-discord-bot, Property 26: Round Statistics Calculation** + * *For any* round, statistics SHALL correctly calculate: submission rate, + * late rate, absent rate based on attendance records. + * **Validates: Requirements 9.4** + */ + describe('Property 26: Round Statistics Calculation', () => { + it('should correctly count attendance by status', () => { + fc.assert( + fc.property( + fc.array(attendanceArb, { minLength: 1, maxLength: 50 }), + (records) => { + const stats = calculateRoundStatistics(records); + + // Total should equal sum of all categories + const totalCounted = + stats.submittedCount + + stats.lateCount + + stats.absentCount + + stats.pendingCount; + + expect(totalCounted).toBe(stats.totalMembers); + expect(stats.totalMembers).toBe(records.length); + } + ), + { numRuns: 100 } + ); + }); + + it('should calculate rates that sum correctly', () => { + fc.assert( + fc.property( + fc.array(attendanceArb, { minLength: 1, maxLength: 50 }), + (records) => { + const stats = calculateRoundStatistics(records); + + // Submission rate includes both submitted and late + if (stats.totalMembers > 0) { + const expectedSubmissionRate = Math.round( + ((stats.submittedCount + stats.lateCount) / stats.totalMembers) * 100 + ); + expect(stats.submissionRate).toBe(expectedSubmissionRate); + + const expectedLateRate = Math.round( + (stats.lateCount / stats.totalMembers) * 100 + ); + expect(stats.lateRate).toBe(expectedLateRate); + + const expectedAbsentRate = Math.round( + (stats.absentCount / stats.totalMembers) * 100 + ); + expect(stats.absentRate).toBe(expectedAbsentRate); + } + } + ), + { numRuns: 100 } + ); + }); + + it('should handle empty attendance records', () => { + const stats = calculateRoundStatistics([]); + + expect(stats.totalMembers).toBe(0); + expect(stats.submittedCount).toBe(0); + expect(stats.lateCount).toBe(0); + expect(stats.absentCount).toBe(0); + expect(stats.pendingCount).toBe(0); + expect(stats.submissionRate).toBe(0); + expect(stats.lateRate).toBe(0); + expect(stats.absentRate).toBe(0); + }); + + it('should correctly categorize each attendance status', () => { + fc.assert( + fc.property( + fc.integer({ min: 0, max: 10 }), + fc.integer({ min: 0, max: 10 }), + fc.integer({ min: 0, max: 10 }), + fc.integer({ min: 0, max: 10 }), + (submittedCount, lateCount, absentCount, pendingCount) => { + // Create records with specific statuses + const records: Attendance[] = []; + const baseRecord = { + id: '', + memberId: '', + roundId: 1, + submittedAt: null, + createdAt: new Date(), + updatedAt: new Date(), + }; + + for (let i = 0; i < submittedCount; i++) { + records.push({ ...baseRecord, id: `s${i}`, memberId: `ms${i}`, status: AttendanceStatus.SUBMITTED }); + } + for (let i = 0; i < lateCount; i++) { + records.push({ ...baseRecord, id: `l${i}`, memberId: `ml${i}`, status: AttendanceStatus.LATE }); + } + for (let i = 0; i < absentCount; i++) { + records.push({ ...baseRecord, id: `a${i}`, memberId: `ma${i}`, status: AttendanceStatus.ABSENT }); + } + for (let i = 0; i < pendingCount; i++) { + records.push({ ...baseRecord, id: `p${i}`, memberId: `mp${i}`, status: AttendanceStatus.PENDING }); + } + + const stats = calculateRoundStatistics(records); + + expect(stats.submittedCount).toBe(submittedCount); + expect(stats.lateCount).toBe(lateCount); + expect(stats.absentCount).toBe(absentCount); + expect(stats.pendingCount).toBe(pendingCount); + } + ), + { numRuns: 100 } + ); + }); + + it('should have rates between 0 and 100', () => { + fc.assert( + fc.property( + fc.array(attendanceArb, { minLength: 1, maxLength: 50 }), + (records) => { + const stats = calculateRoundStatistics(records); + + expect(stats.submissionRate).toBeGreaterThanOrEqual(0); + expect(stats.submissionRate).toBeLessThanOrEqual(100); + expect(stats.lateRate).toBeGreaterThanOrEqual(0); + expect(stats.lateRate).toBeLessThanOrEqual(100); + expect(stats.absentRate).toBeGreaterThanOrEqual(0); + expect(stats.absentRate).toBeLessThanOrEqual(100); + } + ), + { numRuns: 100 } + ); + }); + }); +}); diff --git a/packages/bot/src/commands/status.ts b/packages/bot/src/commands/status.ts new file mode 100644 index 0000000..0cc958e --- /dev/null +++ b/packages/bot/src/commands/status.ts @@ -0,0 +1,203 @@ +/** + * /현황 명령어 - 현재 회차 출석 현황 조회 + * 현재 회차의 출석 현황과 마감까지 남은 일수를 표시합니다. + * Requirements: 9.1, 9.2 + */ + +import { + SlashCommandBuilder, + EmbedBuilder, + type ChatInputCommandInteraction, +} from 'discord.js'; +import type { CommandHandler } from '../bot'; +import { getAttendanceService } from '../services/attendance.service'; +import { getMemberService } from '../services/member.service'; +import { getCurrentRound } from '../services/round.service'; +import { AttendanceStatus, type Member } from '@blog-study/shared/db'; +import { getDaysUntilDeadline, getDaysUntilGraceEnd, type RoundDates } from '@blog-study/shared/utils'; + +// Status display mapping +const STATUS_EMOJI: Record = { + [AttendanceStatus.SUBMITTED]: '✅', + [AttendanceStatus.PENDING]: '⏳', + [AttendanceStatus.LATE]: '⚠️', + [AttendanceStatus.ABSENT]: '❌', +}; + +const STATUS_TEXT: Record = { + [AttendanceStatus.SUBMITTED]: '제출완료', + [AttendanceStatus.PENDING]: '미제출', + [AttendanceStatus.LATE]: '지각', + [AttendanceStatus.ABSENT]: '결석', +}; + +/** + * Format member attendance for display + */ +function formatMemberStatus( + member: Member, + status: string +): string { + const emoji = STATUS_EMOJI[status] || '❓'; + const statusText = STATUS_TEXT[status] || status; + return `${emoji} **${member.name}** (${member.part}) - ${statusText}`; +} + +/** + * Calculate remaining days info + */ +function getRemainingDaysInfo(round: { startDate: string; endDate: string; graceEndDate: string; roundNumber: number }): string { + const now = new Date(); + const roundDates: RoundDates = { + roundNumber: round.roundNumber, + startDate: new Date(round.startDate + 'T00:00:00.000Z'), + endDate: new Date(round.endDate + 'T23:59:59.999Z'), + graceEndDate: new Date(round.graceEndDate + 'T23:59:59.999Z'), + }; + + const daysUntilDeadline = getDaysUntilDeadline(roundDates, now); + const daysUntilGraceEnd = getDaysUntilGraceEnd(roundDates, now); + + if (daysUntilDeadline > 0) { + return `📅 마감까지 **${daysUntilDeadline}일** 남음`; + } else if (daysUntilGraceEnd > 0) { + return `⚠️ 지각 마감까지 **${daysUntilGraceEnd}일** 남음 (지각 기간)`; + } else { + return `🔒 마감 완료`; + } +} + +export const 현황Command: CommandHandler = { + data: new SlashCommandBuilder() + .setName('현황') + .setDescription('현재 회차의 출석 현황을 조회합니다.') as SlashCommandBuilder, + + async execute(interaction: ChatInputCommandInteraction): Promise { + try { + // Get current round + const currentRound = await getCurrentRound(); + + // Get all active members + const memberService = getMemberService(); + const activeMembers = await memberService.getAllActive(); + + // Get attendance records for current round + const attendanceService = getAttendanceService(); + const attendanceRecords = await attendanceService.getByRound(currentRound.id); + + // Create a map of member ID to attendance status + const attendanceMap = new Map(); + for (const record of attendanceRecords) { + attendanceMap.set(record.memberId, record.status); + } + + // Group members by attendance status + const submitted: Member[] = []; + const pending: Member[] = []; + const late: Member[] = []; + const absent: Member[] = []; + + for (const member of activeMembers) { + const status = attendanceMap.get(member.id) || AttendanceStatus.PENDING; + + switch (status) { + case AttendanceStatus.SUBMITTED: + submitted.push(member); + break; + case AttendanceStatus.LATE: + late.push(member); + break; + case AttendanceStatus.ABSENT: + absent.push(member); + break; + default: + pending.push(member); + break; + } + } + + // Calculate statistics + const totalActive = activeMembers.length; + const submittedCount = submitted.length + late.length; // Both submitted and late count as submitted + const submissionRate = totalActive > 0 ? Math.round((submittedCount / totalActive) * 100) : 0; + + // Build embed + const embed = new EmbedBuilder() + .setColor(0x5865f2) + .setTitle(`📊 ${currentRound.roundNumber}회차 출석 현황`) + .setDescription( + [ + getRemainingDaysInfo(currentRound), + '', + `📈 제출률: **${submissionRate}%** (${submittedCount}/${totalActive}명)`, + ].join('\n') + ) + .setTimestamp(); + + // Add submitted members + if (submitted.length > 0) { + const submittedList = submitted + .map((m) => formatMemberStatus(m, AttendanceStatus.SUBMITTED)) + .join('\n'); + embed.addFields({ + name: `✅ 제출완료 (${submitted.length}명)`, + value: submittedList.length <= 1024 ? submittedList : submittedList.slice(0, 1000) + '...', + inline: false, + }); + } + + // Add pending members + if (pending.length > 0) { + const pendingList = pending + .map((m) => formatMemberStatus(m, AttendanceStatus.PENDING)) + .join('\n'); + embed.addFields({ + name: `⏳ 미제출 (${pending.length}명)`, + value: pendingList.length <= 1024 ? pendingList : pendingList.slice(0, 1000) + '...', + inline: false, + }); + } + + // Add late members + if (late.length > 0) { + const lateList = late + .map((m) => formatMemberStatus(m, AttendanceStatus.LATE)) + .join('\n'); + embed.addFields({ + name: `⚠️ 지각 (${late.length}명)`, + value: lateList.length <= 1024 ? lateList : lateList.slice(0, 1000) + '...', + inline: false, + }); + } + + // Add absent members + if (absent.length > 0) { + const absentList = absent + .map((m) => formatMemberStatus(m, AttendanceStatus.ABSENT)) + .join('\n'); + embed.addFields({ + name: `❌ 결석 (${absent.length}명)`, + value: absentList.length <= 1024 ? absentList : absentList.slice(0, 1000) + '...', + inline: false, + }); + } + + // Add round info footer + embed.setFooter({ + text: `기간: ${currentRound.startDate} ~ ${currentRound.endDate} | 지각 마감: ${currentRound.graceEndDate}`, + }); + + await interaction.reply({ + embeds: [embed], + }); + } catch (error) { + console.error('Error in /현황 command:', error); + await interaction.reply({ + content: '❌ 현황 조회 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.', + ephemeral: true, + }); + } + }, + + adminOnly: false, +}; diff --git a/packages/bot/src/deploy-commands.ts b/packages/bot/src/deploy-commands.ts new file mode 100644 index 0000000..31a7acd --- /dev/null +++ b/packages/bot/src/deploy-commands.ts @@ -0,0 +1,22 @@ +// Script to deploy slash commands to Discord +// Run with: pnpm deploy-commands + +import { loadBotEnv } from '@blog-study/shared'; +import { registerCommands } from './bot'; +import { getAllCommands } from './commands'; + +async function main(): Promise { + console.log('🚀 Deploying slash commands to Discord...'); + + const env = loadBotEnv(); + const commands = getAllCommands(); + + await registerCommands(commands, env); + + console.log('✅ Commands deployed successfully!'); +} + +main().catch((error) => { + console.error('❌ Failed to deploy commands:', error); + process.exit(1); +}); diff --git a/packages/bot/src/handlers/dm-handler.ts b/packages/bot/src/handlers/dm-handler.ts new file mode 100644 index 0000000..09f5672 --- /dev/null +++ b/packages/bot/src/handlers/dm-handler.ts @@ -0,0 +1,218 @@ +/** + * DM Handler + * 벌금 납부 확인을 위한 DM 응답 처리 + * Requirements: 8.2 + */ + +import { Message, Client, Events } from 'discord.js'; +import { + getFineService, + isPaymentConfirmation, + formatFineReason, +} from '../services'; + +/** + * Track pending fine confirmations + * Maps Discord user ID to their pending fine IDs + */ +const pendingConfirmations = new Map(); + +/** + * Add a pending fine confirmation for a user + */ +export function addPendingConfirmation(discordId: string, fineId: string): void { + const existing = pendingConfirmations.get(discordId) || []; + if (!existing.includes(fineId)) { + existing.push(fineId); + pendingConfirmations.set(discordId, existing); + } +} + +/** + * Remove a pending fine confirmation for a user + */ +export function removePendingConfirmation(discordId: string, fineId: string): void { + const existing = pendingConfirmations.get(discordId) || []; + const filtered = existing.filter(id => id !== fineId); + if (filtered.length > 0) { + pendingConfirmations.set(discordId, filtered); + } else { + pendingConfirmations.delete(discordId); + } +} + +/** + * Get pending fine confirmations for a user + */ +export function getPendingConfirmations(discordId: string): string[] { + return pendingConfirmations.get(discordId) || []; +} + +/** + * Clear all pending confirmations for a user + */ +export function clearPendingConfirmations(discordId: string): void { + pendingConfirmations.delete(discordId); +} + + +/** + * Handle DM message for fine payment confirmation + * Requirements: 8.2 - Parse confirmation words and update fine status + */ +async function handleDMMessage(message: Message): Promise { + // Ignore bot messages + if (message.author.bot) { + return; + } + + // Only process DMs + if (message.guild) { + return; + } + + const discordId = message.author.id; + const pendingFineIds = getPendingConfirmations(discordId); + + // If no pending confirmations, ignore + if (pendingFineIds.length === 0) { + return; + } + + // Check if message contains confirmation words + if (!isPaymentConfirmation(message.content)) { + return; + } + + const fineService = getFineService(); + + // Mark all pending fines as paid + const paidFines: string[] = []; + for (const fineId of pendingFineIds) { + try { + await fineService.markPaid(fineId); + paidFines.push(fineId); + removePendingConfirmation(discordId, fineId); + + console.log(`✅ Fine ${fineId} marked as paid for user ${discordId}`); + } catch (error) { + console.error(`❌ Failed to mark fine ${fineId} as paid:`, error); + } + } + + // Send confirmation message + if (paidFines.length > 0) { + try { + await message.reply({ + content: `✅ 벌금 납부가 확인되었습니다! (${paidFines.length}건)\n감사합니다. 🙏`, + }); + } catch (error) { + console.error('❌ Failed to send confirmation reply:', error); + } + } +} + +/** + * Send fine notification DM to a user + * Requirements: 8.1 - Send DM with fine amount, reason, and payment instructions + */ +export async function sendFineNotification( + client: Client, + discordId: string, + fineId: string, + amount: number, + type: 'late' | 'absent', + roundNumber: number +): Promise { + try { + const user = await client.users.fetch(discordId); + if (!user) { + console.error(`❌ User ${discordId} not found`); + return false; + } + + const reason = formatFineReason(type); + const message = [ + `📢 **벌금 알림**`, + ``, + `${roundNumber}회차 ${reason}으로 인해 벌금이 부과되었습니다.`, + ``, + `💰 **금액**: ${amount.toLocaleString()}원`, + `📝 **사유**: ${reason}`, + ``, + `납부 완료 후 이 메시지에 "납부완료" 또는 "완료"라고 답장해주세요.`, + `(영어로 "yes", "done", "paid"도 가능합니다)`, + ].join('\n'); + + await user.send(message); + + // Track pending confirmation + addPendingConfirmation(discordId, fineId); + + console.log(`📤 Fine notification sent to ${discordId} for fine ${fineId}`); + return true; + } catch (error) { + console.error(`❌ Failed to send fine notification to ${discordId}:`, error); + return false; + } +} + +/** + * Send fine reminder DM to a user + * Requirements: 8.4 - Send reminder for unpaid fines + */ +export async function sendFineReminder( + client: Client, + discordId: string, + fineId: string, + amount: number, + type: 'late' | 'absent', + roundNumber: number, + daysSinceCreation: number +): Promise { + try { + const user = await client.users.fetch(discordId); + if (!user) { + console.error(`❌ User ${discordId} not found`); + return false; + } + + const reason = formatFineReason(type); + const message = [ + `⏰ **벌금 리마인더**`, + ``, + `${roundNumber}회차 ${reason} 벌금이 아직 미납 상태입니다.`, + `(${daysSinceCreation}일 경과)`, + ``, + `💰 **금액**: ${amount.toLocaleString()}원`, + ``, + `납부 완료 후 이 메시지에 "납부완료" 또는 "완료"라고 답장해주세요.`, + ].join('\n'); + + await user.send(message); + + // Ensure pending confirmation is tracked + addPendingConfirmation(discordId, fineId); + + console.log(`📤 Fine reminder sent to ${discordId} for fine ${fineId}`); + return true; + } catch (error) { + console.error(`❌ Failed to send fine reminder to ${discordId}:`, error); + return false; + } +} + +/** + * Setup DM handler for the bot client + */ +export function setupDMHandler(client: Client): void { + client.on(Events.MessageCreate, async (message) => { + try { + await handleDMMessage(message); + } catch (error) { + console.error('❌ Error handling DM message:', error); + } + }); + + console.log('📬 DM handler setup complete'); +} diff --git a/packages/bot/src/handlers/index.ts b/packages/bot/src/handlers/index.ts new file mode 100644 index 0000000..cd46442 --- /dev/null +++ b/packages/bot/src/handlers/index.ts @@ -0,0 +1,4 @@ +// Handlers Index +// 모든 핸들러를 내보냅니다. + +export * from './dm-handler'; diff --git a/packages/bot/src/index.ts b/packages/bot/src/index.ts new file mode 100644 index 0000000..2354b79 --- /dev/null +++ b/packages/bot/src/index.ts @@ -0,0 +1,50 @@ +// @blog-study/bot +// 블로그 스터디 디스코드 봇 엔트리포인트 + +import { loadBotEnv } from '@blog-study/shared'; +import { + createBotClient, + setupEventHandlers, + loadCommands, + registerCommands, + startBot, + setupGracefulShutdown, +} from './bot'; +import { getAllCommands } from './commands'; + +async function main(): Promise { + console.log('🚀 Blog Study Discord Bot starting...'); + + // Load environment variables + const env = loadBotEnv(); + console.log('✅ Environment variables loaded'); + + // Create bot client + const client = createBotClient(); + console.log('✅ Bot client created'); + + // Get all commands + const commands = getAllCommands(); + console.log(`📦 Found ${commands.length} commands`); + + // Load commands into client + loadCommands(client, commands); + + // Setup event handlers + setupEventHandlers(client, env); + console.log('✅ Event handlers configured'); + + // Register slash commands with Discord API + await registerCommands(commands, env); + + // Setup graceful shutdown + setupGracefulShutdown(client); + + // Start the bot + await startBot(client, env.DISCORD_TOKEN); +} + +main().catch((error) => { + console.error('❌ Failed to start bot:', error); + process.exit(1); +}); diff --git a/packages/bot/src/schedulers/attendance-checker.ts b/packages/bot/src/schedulers/attendance-checker.ts new file mode 100644 index 0000000..5f31b01 --- /dev/null +++ b/packages/bot/src/schedulers/attendance-checker.ts @@ -0,0 +1,257 @@ +/** + * Attendance Checker Scheduler + * 매주 화요일 00:00에 지각/결석 처리 + * Requirements: 5.6 + */ + +import cron from 'node-cron'; +import { getAttendanceService } from '../services/attendance.service'; +import { getCurrentRound, isGracePeriodEnded } from '../services/round.service'; +import { AttendanceStatus, type Attendance, type Round } from '@blog-study/shared/db'; + +/** + * Result of an attendance check cycle + */ +export interface AttendanceCheckResult { + timestamp: Date; + roundId: number; + roundNumber: number; + processedCount: number; + absentMembers: string[]; + errors: string[]; +} + +/** + * Callback for when attendance is marked as absent + */ +export type OnAbsentCallback = ( + attendance: Attendance, + round: Round +) => Promise; + +/** + * Attendance Checker class for scheduling attendance processing + */ +export class AttendanceChecker { + private cronJob: cron.ScheduledTask | null = null; + private isRunning = false; + private onAbsent: OnAbsentCallback | null = null; + + /** + * Set callback for when a member is marked absent + * This can be used to create fines or send notifications + */ + setOnAbsentCallback(callback: OnAbsentCallback): void { + this.onAbsent = callback; + } + + /** + * Start the attendance checker scheduler + * Runs every Tuesday at 00:00 (after grace period ends) + * Requirements: 5.6 + */ + start(): void { + if (this.cronJob) { + console.log('[AttendanceChecker] Already running'); + return; + } + + // Run every Tuesday at 00:00: 0 0 * * 2 + // Tuesday is day 2 in cron (0 = Sunday, 1 = Monday, 2 = Tuesday) + this.cronJob = cron.schedule('0 0 * * 2', async () => { + await this.check(); + }); + + console.log('[AttendanceChecker] Started - checking every Tuesday at 00:00'); + } + + /** + * Stop the attendance checker scheduler + */ + stop(): void { + if (this.cronJob) { + this.cronJob.stop(); + this.cronJob = null; + console.log('[AttendanceChecker] Stopped'); + } + } + + /** + * Check if the checker is currently running + */ + isChecking(): boolean { + return this.isRunning; + } + + /** + * Run an attendance check cycle + * Marks all pending attendance records as absent for the previous round + * Requirements: 5.6 - When grace period ends, pending -> absent + */ + async check(): Promise { + if (this.isRunning) { + console.log('[AttendanceChecker] Check already in progress, skipping'); + return { + timestamp: new Date(), + roundId: 0, + roundNumber: 0, + processedCount: 0, + absentMembers: [], + errors: ['Check already in progress'], + }; + } + + this.isRunning = true; + const startTime = new Date(); + const errors: string[] = []; + const absentMembers: string[] = []; + + try { + // Get the current round + const currentRound = await getCurrentRound(); + + // Check if grace period has ended for this round + if (!isGracePeriodEnded(currentRound)) { + console.log('[AttendanceChecker] Grace period not yet ended, skipping'); + return { + timestamp: startTime, + roundId: currentRound.id, + roundNumber: currentRound.roundNumber, + processedCount: 0, + absentMembers: [], + errors: ['Grace period not yet ended'], + }; + } + + console.log(`[AttendanceChecker] Processing round ${currentRound.roundNumber}`); + + // Process grace period end - mark all pending as absent + const attendanceService = getAttendanceService(); + const updatedRecords = await attendanceService.processGracePeriodEnd(currentRound.id); + + // Filter to only those that were actually marked absent + const absentRecords = updatedRecords.filter( + (r) => r.status === AttendanceStatus.ABSENT + ); + + // Call the callback for each absent member + for (const record of absentRecords) { + absentMembers.push(record.memberId); + + if (this.onAbsent) { + try { + await this.onAbsent(record, currentRound); + } catch (callbackError) { + const errorMsg = callbackError instanceof Error + ? callbackError.message + : String(callbackError); + console.error(`[AttendanceChecker] Callback error for ${record.memberId}: ${errorMsg}`); + errors.push(`Callback error for ${record.memberId}: ${errorMsg}`); + } + } + } + + console.log( + `[AttendanceChecker] Completed - ${absentRecords.length} members marked absent` + ); + + return { + timestamp: startTime, + roundId: currentRound.id, + roundNumber: currentRound.roundNumber, + processedCount: absentRecords.length, + absentMembers, + errors, + }; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + console.error(`[AttendanceChecker] Error: ${errorMsg}`); + errors.push(errorMsg); + + return { + timestamp: startTime, + roundId: 0, + roundNumber: 0, + processedCount: 0, + absentMembers: [], + errors, + }; + } finally { + this.isRunning = false; + } + } + + /** + * Manually trigger attendance check for a specific round + * Useful for admin operations or testing + */ + async checkRound(roundId: number): Promise { + const startTime = new Date(); + const errors: string[] = []; + const absentMembers: string[] = []; + + try { + console.log(`[AttendanceChecker] Manually processing round ID ${roundId}`); + + const attendanceService = getAttendanceService(); + const updatedRecords = await attendanceService.processGracePeriodEnd(roundId); + + const absentRecords = updatedRecords.filter( + (r) => r.status === AttendanceStatus.ABSENT + ); + + for (const record of absentRecords) { + absentMembers.push(record.memberId); + } + + console.log( + `[AttendanceChecker] Manual check completed - ${absentRecords.length} members marked absent` + ); + + return { + timestamp: startTime, + roundId, + roundNumber: 0, // Unknown for manual check + processedCount: absentRecords.length, + absentMembers, + errors, + }; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + console.error(`[AttendanceChecker] Manual check error: ${errorMsg}`); + errors.push(errorMsg); + + return { + timestamp: startTime, + roundId, + roundNumber: 0, + processedCount: 0, + absentMembers: [], + errors, + }; + } + } +} + +// Singleton instance +let attendanceCheckerInstance: AttendanceChecker | null = null; + +/** + * Get the AttendanceChecker singleton instance + */ +export function getAttendanceChecker(): AttendanceChecker { + if (!attendanceCheckerInstance) { + attendanceCheckerInstance = new AttendanceChecker(); + } + return attendanceCheckerInstance; +} + +/** + * Reset the singleton (useful for testing) + */ +export function resetAttendanceChecker(): void { + if (attendanceCheckerInstance) { + attendanceCheckerInstance.stop(); + } + attendanceCheckerInstance = null; +} diff --git a/packages/bot/src/schedulers/curation-crawler.ts b/packages/bot/src/schedulers/curation-crawler.ts new file mode 100644 index 0000000..b64afbe --- /dev/null +++ b/packages/bot/src/schedulers/curation-crawler.ts @@ -0,0 +1,402 @@ +/** + * Curation Crawler Scheduler + * 매일 09:00 크롤링, 10:00 공유 + * Requirements: 13.2, 13.6 + */ + +import cron from 'node-cron'; +import { + Client, + EmbedBuilder, + TextChannel, + type MessageCreateOptions, +} from 'discord.js'; +import { getCurationService, type CrawlResult, type CrawledContent } from '../services/curation.service'; +import { getConfigValue, ConfigKeys } from '../services/round.service'; +import { getKeywordService, type KeywordStat } from '../services/keyword.service'; +import type { CurationItem, CurationSource } from '@blog-study/shared/db'; + +/** + * Result of a curation cycle + */ +export interface CurationCycleResult { + timestamp: Date; + sourcesProcessed: number; + totalNewItems: number; + results: CrawlResult[]; + errors: string[]; +} + +/** + * Result of sharing daily content + */ +export interface ShareResult { + success: boolean; + item: CurationItem | null; + error?: string; +} + +/** + * Build curation content embed + * Requirements: 13.7 - Include title, source, URL, category, and relevance reason + */ +export function buildCurationEmbed( + item: CurationItem, + source: CurationSource | null, + topKeywords: string[] +): EmbedBuilder { + const embed = new EmbedBuilder() + .setColor(0xFFA500) // Orange for curation + .setTitle(`📚 ${item.title}`) + .setURL(item.url) + .setTimestamp(); + + // Add source info + if (source) { + embed.setAuthor({ + name: source.name, + url: source.url, + }); + } + + // Add category + const categoryEmoji = item.category === 'conference' ? '🎤' : '📰'; + embed.addFields({ + name: '카테고리', + value: `${categoryEmoji} ${item.category === 'conference' ? '컨퍼런스' : '아티클'}`, + inline: true, + }); + + // Add relevance score + embed.addFields({ + name: '관련성 점수', + value: `${Math.round(item.relevanceScore ?? 0)}점`, + inline: true, + }); + + // Add tags if available + if (item.tags && item.tags.length > 0) { + embed.addFields({ + name: '태그', + value: item.tags.slice(0, 5).map(t => `\`${t}\``).join(' '), + inline: false, + }); + } + + // Add relevance reason based on matching keywords + if (topKeywords.length > 0) { + const matchingKeywords = topKeywords.filter(keyword => { + const titleLower = item.title.toLowerCase(); + const tagsLower = (item.tags || []).map(t => t.toLowerCase()); + return titleLower.includes(keyword.toLowerCase()) || + tagsLower.some(t => t.includes(keyword.toLowerCase())); + }); + + if (matchingKeywords.length > 0) { + embed.addFields({ + name: '💡 추천 이유', + value: `스터디원들의 관심 키워드와 일치: ${matchingKeywords.slice(0, 3).map(k => `\`${k}\``).join(', ')}`, + inline: false, + }); + } + } + + // Add published date if available + if (item.publishedAt) { + embed.setFooter({ + text: `발행일: ${item.publishedAt.toLocaleDateString('ko-KR')}`, + }); + } + + return embed; +} + +/** + * Build curation share message + * Requirements: 13.6 - Send one curated content item per day + */ +export function buildCurationMessage( + item: CurationItem, + source: CurationSource | null, + topKeywords: string[] +): MessageCreateOptions { + return { + content: '📢 **오늘의 추천 컨텐츠**가 도착했습니다! 🎉', + embeds: [buildCurationEmbed(item, source, topKeywords)], + }; +} + +/** + * Curation Crawler class for scheduling content curation + */ +export class CurationCrawler { + private crawlJob: cron.ScheduledTask | null = null; + private shareJob: cron.ScheduledTask | null = null; + private client: Client | null = null; + private isCrawling = false; + private isSharing = false; + private crawlFunction: ((url: string) => Promise) | null = null; + + /** + * Set the Discord client for sending messages + */ + setClient(client: Client): void { + this.client = client; + } + + /** + * Set custom crawl function for sources + * This allows injecting different crawling strategies + */ + setCrawlFunction(fn: (url: string) => Promise): void { + this.crawlFunction = fn; + } + + /** + * Start the curation scheduler + * Requirements: 13.2 - Crawl once daily at 09:00 + * Requirements: 13.6 - Send content at 10:00 + */ + start(): void { + if (this.crawlJob || this.shareJob) { + console.log('[CurationCrawler] Already running'); + return; + } + + // Crawl at 09:00 every day + this.crawlJob = cron.schedule('0 9 * * *', async () => { + await this.crawl(); + }); + + // Share at 10:00 every day + this.shareJob = cron.schedule('0 10 * * *', async () => { + await this.shareDailyContent(); + }); + + console.log('[CurationCrawler] Started - crawling at 09:00, sharing at 10:00'); + } + + /** + * Stop the curation scheduler + */ + stop(): void { + if (this.crawlJob) { + this.crawlJob.stop(); + this.crawlJob = null; + } + if (this.shareJob) { + this.shareJob.stop(); + this.shareJob = null; + } + console.log('[CurationCrawler] Stopped'); + } + + /** + * Check if currently crawling + */ + isCrawlingNow(): boolean { + return this.isCrawling; + } + + /** + * Check if currently sharing + */ + isSharingNow(): boolean { + return this.isSharing; + } + + /** + * Get the curation channel + */ + async getCurationChannel(): Promise { + if (!this.client) { + console.warn('[CurationCrawler] Discord client not set'); + return null; + } + + const channelId = await getConfigValue(ConfigKeys.CURATION_CHANNEL); + + if (!channelId) { + console.warn('[CurationCrawler] Curation channel not configured'); + return null; + } + + try { + const channel = await this.client.channels.fetch(channelId); + if (channel instanceof TextChannel) { + return channel; + } + console.warn('[CurationCrawler] Curation channel is not a text channel'); + return null; + } catch (error) { + console.error('[CurationCrawler] Failed to fetch curation channel:', error); + return null; + } + } + + /** + * Run a crawling cycle for all active sources + * Requirements: 13.2 - Crawl configured source websites once daily + */ + async crawl(): Promise { + if (this.isCrawling) { + console.log('[CurationCrawler] Crawling already in progress, skipping'); + return { + timestamp: new Date(), + sourcesProcessed: 0, + totalNewItems: 0, + results: [], + errors: ['Crawling already in progress'], + }; + } + + this.isCrawling = true; + const startTime = new Date(); + const errors: string[] = []; + + try { + const curationService = getCurationService(); + const results = await curationService.crawlAllSources(this.crawlFunction || undefined); + + const totalNewItems = results.reduce((sum, r) => sum + r.newItemsAdded, 0); + const failedResults = results.filter(r => !r.success); + + for (const failed of failedResults) { + errors.push(`${failed.sourceName}: ${failed.error}`); + } + + console.log(`[CurationCrawler] Completed - ${totalNewItems} new items from ${results.length} sources`); + + return { + timestamp: startTime, + sourcesProcessed: results.length, + totalNewItems, + results, + errors, + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + console.error(`[CurationCrawler] Crawl error: ${errorMessage}`); + errors.push(errorMessage); + + return { + timestamp: startTime, + sourcesProcessed: 0, + totalNewItems: 0, + results: [], + errors, + }; + } finally { + this.isCrawling = false; + } + } + + /** + * Share daily curated content + * Requirements: 13.6 - Send one curated content item per day at 10:00 + */ + async shareDailyContent(): Promise { + if (this.isSharing) { + console.log('[CurationCrawler] Sharing already in progress, skipping'); + return { + success: false, + item: null, + error: 'Sharing already in progress', + }; + } + + this.isSharing = true; + + try { + const channel = await this.getCurationChannel(); + + if (!channel) { + return { + success: false, + item: null, + error: 'Curation channel not configured or not found', + }; + } + + const curationService = getCurationService(); + const item = await curationService.selectDailyContent(); + + if (!item) { + console.log('[CurationCrawler] No unshared content available'); + return { + success: false, + item: null, + error: 'No unshared content available', + }; + } + + // Get source info + let source: CurationSource | null = null; + if (item.sourceId) { + source = await curationService.getSourceById(item.sourceId); + } + + // Get top keywords for relevance reason + const keywordService = getKeywordService(); + const topKeywords = await keywordService.getTopKeywords(10); + const keywordStrings = topKeywords.map((k: KeywordStat) => k.keyword); + + // Build and send message + const message = buildCurationMessage(item, source, keywordStrings); + await channel.send(message); + + // Mark as shared + await curationService.markAsShared(item.id); + + console.log(`[CurationCrawler] Shared content: ${item.title}`); + + return { + success: true, + item, + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + console.error(`[CurationCrawler] Share error: ${errorMessage}`); + + return { + success: false, + item: null, + error: errorMessage, + }; + } finally { + this.isSharing = false; + } + } +} + +// Singleton instance +let curationCrawlerInstance: CurationCrawler | null = null; + +/** + * Get the CurationCrawler singleton instance + */ +export function getCurationCrawler(): CurationCrawler { + if (!curationCrawlerInstance) { + curationCrawlerInstance = new CurationCrawler(); + } + return curationCrawlerInstance; +} + +/** + * Initialize the CurationCrawler with a Discord client + */ +export function initCurationCrawler(client: Client): CurationCrawler { + const crawler = getCurationCrawler(); + crawler.setClient(client); + return crawler; +} + +/** + * Reset the singleton (useful for testing) + */ +export function resetCurationCrawler(): void { + if (curationCrawlerInstance) { + curationCrawlerInstance.stop(); + } + curationCrawlerInstance = null; +} diff --git a/packages/bot/src/schedulers/fine-reminder.ts b/packages/bot/src/schedulers/fine-reminder.ts new file mode 100644 index 0000000..72e05b1 --- /dev/null +++ b/packages/bot/src/schedulers/fine-reminder.ts @@ -0,0 +1,299 @@ +/** + * Fine Reminder Scheduler + * 미납 벌금 3일마다 리마인드 + * Requirements: 8.4 + */ + +import cron from 'node-cron'; +import { Client } from 'discord.js'; +import { getFineService } from '../services/fine.service'; +import { sendFineReminder } from '../handlers/dm-handler'; + +/** + * Result of a fine reminder cycle + */ +export interface FineReminderResult { + timestamp: Date; + processedCount: number; + sentCount: number; + failedCount: number; + errors: string[]; +} + +/** + * Fine Reminder class for scheduling fine reminder notifications + */ +export class FineReminder { + private cronJob: cron.ScheduledTask | null = null; + private isRunning = false; + private client: Client | null = null; + + /** + * Set the Discord client for sending DMs + */ + setClient(client: Client): void { + this.client = client; + } + + /** + * Start the fine reminder scheduler + * Runs every day at 10:00 to check for unpaid fines + * Requirements: 8.4 - Send reminder every 3 days for unpaid fines + */ + start(): void { + if (this.cronJob) { + console.log('[FineReminder] Already running'); + return; + } + + // Run every day at 10:00: 0 10 * * * + this.cronJob = cron.schedule('0 10 * * *', async () => { + await this.sendReminders(); + }); + + console.log('[FineReminder] Started - checking every day at 10:00'); + } + + /** + * Stop the fine reminder scheduler + */ + stop(): void { + if (this.cronJob) { + this.cronJob.stop(); + this.cronJob = null; + console.log('[FineReminder] Stopped'); + } + } + + + /** + * Check if the reminder is currently running + */ + isReminding(): boolean { + return this.isRunning; + } + + /** + * Send reminders for all unpaid fines that are older than 3 days + * Requirements: 8.4 - Send reminder every 3 days for unpaid fines + */ + async sendReminders(): Promise { + if (this.isRunning) { + console.log('[FineReminder] Reminder already in progress, skipping'); + return { + timestamp: new Date(), + processedCount: 0, + sentCount: 0, + failedCount: 0, + errors: ['Reminder already in progress'], + }; + } + + if (!this.client) { + console.error('[FineReminder] Discord client not set'); + return { + timestamp: new Date(), + processedCount: 0, + sentCount: 0, + failedCount: 0, + errors: ['Discord client not set'], + }; + } + + this.isRunning = true; + const startTime = new Date(); + const errors: string[] = []; + let sentCount = 0; + let failedCount = 0; + + try { + const fineService = getFineService(); + + // Get all unpaid fines with member info + const finesWithInfo = await fineService.getFinesWithMemberInfo(); + + // Filter fines that need reminders (created more than 3 days ago) + // and check if today is a reminder day (every 3 days) + const now = new Date(); + const finesNeedingReminder = finesWithInfo.filter(({ fine }) => { + const createdAt = fine.createdAt ? new Date(fine.createdAt) : new Date(); + const daysSinceCreation = Math.floor( + (now.getTime() - createdAt.getTime()) / (1000 * 60 * 60 * 24) + ); + + // Send reminder if 3+ days have passed and it's a multiple of 3 days + return daysSinceCreation >= 3 && daysSinceCreation % 3 === 0; + }); + + console.log( + `[FineReminder] Found ${finesNeedingReminder.length} fines needing reminders` + ); + + // Send reminders + for (const { fine, discordId, roundNumber } of finesNeedingReminder) { + const createdAt = fine.createdAt ? new Date(fine.createdAt) : new Date(); + const daysSinceCreation = Math.floor( + (now.getTime() - createdAt.getTime()) / (1000 * 60 * 60 * 24) + ); + + try { + const success = await sendFineReminder( + this.client, + discordId, + fine.id, + fine.amount, + fine.type as 'late' | 'absent', + roundNumber, + daysSinceCreation + ); + + if (success) { + sentCount++; + } else { + failedCount++; + } + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + console.error(`[FineReminder] Error sending reminder: ${errorMsg}`); + errors.push(`Failed to send reminder for fine ${fine.id}: ${errorMsg}`); + failedCount++; + } + } + + console.log( + `[FineReminder] Completed - sent ${sentCount}, failed ${failedCount}` + ); + + return { + timestamp: startTime, + processedCount: finesNeedingReminder.length, + sentCount, + failedCount, + errors, + }; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + console.error(`[FineReminder] Error: ${errorMsg}`); + errors.push(errorMsg); + + return { + timestamp: startTime, + processedCount: 0, + sentCount: 0, + failedCount: 0, + errors, + }; + } finally { + this.isRunning = false; + } + } + + /** + * Manually trigger reminders for all unpaid fines + * Useful for admin operations or testing + */ + async sendAllReminders(): Promise { + if (!this.client) { + console.error('[FineReminder] Discord client not set'); + return { + timestamp: new Date(), + processedCount: 0, + sentCount: 0, + failedCount: 0, + errors: ['Discord client not set'], + }; + } + + const startTime = new Date(); + const errors: string[] = []; + let sentCount = 0; + let failedCount = 0; + + try { + const fineService = getFineService(); + const finesWithInfo = await fineService.getFinesWithMemberInfo(); + + console.log( + `[FineReminder] Manually sending reminders for ${finesWithInfo.length} unpaid fines` + ); + + const now = new Date(); + + for (const { fine, discordId, roundNumber } of finesWithInfo) { + const createdAt = fine.createdAt ? new Date(fine.createdAt) : new Date(); + const daysSinceCreation = Math.floor( + (now.getTime() - createdAt.getTime()) / (1000 * 60 * 60 * 24) + ); + + try { + const success = await sendFineReminder( + this.client, + discordId, + fine.id, + fine.amount, + fine.type as 'late' | 'absent', + roundNumber, + daysSinceCreation + ); + + if (success) { + sentCount++; + } else { + failedCount++; + } + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + errors.push(`Failed to send reminder for fine ${fine.id}: ${errorMsg}`); + failedCount++; + } + } + + console.log( + `[FineReminder] Manual reminders completed - sent ${sentCount}, failed ${failedCount}` + ); + + return { + timestamp: startTime, + processedCount: finesWithInfo.length, + sentCount, + failedCount, + errors, + }; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + console.error(`[FineReminder] Manual reminder error: ${errorMsg}`); + errors.push(errorMsg); + + return { + timestamp: startTime, + processedCount: 0, + sentCount: 0, + failedCount: 0, + errors, + }; + } + } +} + +// Singleton instance +let fineReminderInstance: FineReminder | null = null; + +/** + * Get the FineReminder singleton instance + */ +export function getFineReminder(): FineReminder { + if (!fineReminderInstance) { + fineReminderInstance = new FineReminder(); + } + return fineReminderInstance; +} + +/** + * Reset the singleton (useful for testing) + */ +export function resetFineReminder(): void { + if (fineReminderInstance) { + fineReminderInstance.stop(); + } + fineReminderInstance = null; +} diff --git a/packages/bot/src/schedulers/index.ts b/packages/bot/src/schedulers/index.ts new file mode 100644 index 0000000..d982743 --- /dev/null +++ b/packages/bot/src/schedulers/index.ts @@ -0,0 +1,8 @@ +// Schedulers Index +// 모든 스케줄러를 내보냅니다. + +export * from './rss-poller'; +export * from './attendance-checker'; +export * from './fine-reminder'; +export * from './round-reporter'; +export * from './curation-crawler'; diff --git a/packages/bot/src/schedulers/round-reporter.ts b/packages/bot/src/schedulers/round-reporter.ts new file mode 100644 index 0000000..8e01a49 --- /dev/null +++ b/packages/bot/src/schedulers/round-reporter.ts @@ -0,0 +1,441 @@ +/** + * Round Reporter Scheduler + * 회차 종료 시 리포트 자동 발송 및 새 회차 시작 알림 + * Requirements: 10.1, 10.2, 10.3, 10.4 + */ + +import cron from 'node-cron'; +import { Client } from 'discord.js'; +import { eq, count } from 'drizzle-orm'; +import { + getDb, + attendance, + members, + posts, + type Round, +} from '@blog-study/shared/db'; +import { + getCurrentRound, + getRoundByNumber, + setCurrentRound, + isGracePeriodEnded, +} from '../services/round.service'; +import { + getNotificationService, + calculateRoundReportData, + type AttendanceSummary, + type RoundReportData, +} from '../services/notification.service'; + +/** + * Result of a round report cycle + */ +export interface RoundReportResult { + timestamp: Date; + roundNumber: number; + reportSent: boolean; + newRoundStarted: boolean; + newRoundNumber: number | null; + errors: string[]; +} + +/** + * Get attendance summaries for a round with member info and post counts + * Requirements: 10.2 - Include submission list, late list, absent list + * Requirements: 10.3 - Include MVP (member with most posts) + */ +export async function getAttendanceSummariesForRound( + roundId: number +): Promise { + const db = getDb(); + + // Get all attendance records for the round with member info + const attendanceRecords = await db + .select({ + memberId: attendance.memberId, + status: attendance.status, + discordId: members.discordId, + discordUsername: members.discordUsername, + name: members.name, + }) + .from(attendance) + .innerJoin(members, eq(attendance.memberId, members.id)) + .where(eq(attendance.roundId, roundId)); + + // Get post counts for each member in this round + const postCounts = await db + .select({ + memberId: posts.memberId, + count: count(), + }) + .from(posts) + .where(eq(posts.roundId, roundId)) + .groupBy(posts.memberId); + + // Create a map of member ID to post count + const postCountMap = new Map(); + for (const pc of postCounts) { + postCountMap.set(pc.memberId, Number(pc.count)); + } + + // Build attendance summaries + return attendanceRecords.map((record) => ({ + memberId: record.memberId, + discordId: record.discordId, + discordUsername: record.discordUsername, + name: record.name, + status: record.status as AttendanceSummary['status'], + postCount: postCountMap.get(record.memberId) || 0, + })); +} + +/** + * Build round report data from a round + * Requirements: 10.2 - Generate round report content + */ +export async function buildRoundReportDataForRound( + round: Round +): Promise { + const summaries = await getAttendanceSummariesForRound(round.id); + return calculateRoundReportData(round, summaries); +} + +/** + * Round Reporter class for scheduling round reports and announcements + */ +export class RoundReporter { + private reportCronJob: cron.ScheduledTask | null = null; + private startCronJob: cron.ScheduledTask | null = null; + private isRunning = false; + private client: Client | null = null; + + /** + * Set the Discord client for sending notifications + */ + setClient(client: Client): void { + this.client = client; + } + + /** + * Get the Discord client + */ + getClient(): Client | null { + return this.client; + } + + /** + * Start the round reporter scheduler + * - Round report: Every Tuesday at 00:05 (after grace period ends) + * - Round start: Every other Monday at 00:00 (when new round starts) + * Requirements: 10.1, 10.4 + */ + start(): void { + if (this.reportCronJob) { + console.log('[RoundReporter] Already running'); + return; + } + + // Run round report every Tuesday at 00:05 (after attendance checker) + // Tuesday is day 2 in cron (0 = Sunday, 1 = Monday, 2 = Tuesday) + this.reportCronJob = cron.schedule('5 0 * * 2', async () => { + await this.sendRoundReport(); + }); + + // Run round start announcement every other Monday at 00:00 + // We check if it's actually a new round start day in the handler + this.startCronJob = cron.schedule('0 0 * * 1', async () => { + await this.sendRoundStartAnnouncement(); + }); + + console.log('[RoundReporter] Started - report every Tuesday at 00:05, start announcement every Monday at 00:00'); + } + + /** + * Stop the round reporter scheduler + */ + stop(): void { + if (this.reportCronJob) { + this.reportCronJob.stop(); + this.reportCronJob = null; + } + if (this.startCronJob) { + this.startCronJob.stop(); + this.startCronJob = null; + } + console.log('[RoundReporter] Stopped'); + } + + /** + * Check if the reporter is currently running + */ + isReporting(): boolean { + return this.isRunning; + } + + /** + * Send round report for the completed round + * Requirements: 10.1 - Automatically generate and send round report + * Requirements: 10.2 - Include round number, submission list, late list, absent list + * Requirements: 10.3 - Highlight MVP + */ + async sendRoundReport(): Promise { + if (this.isRunning) { + console.log('[RoundReporter] Report already in progress, skipping'); + return { + timestamp: new Date(), + roundNumber: 0, + reportSent: false, + newRoundStarted: false, + newRoundNumber: null, + errors: ['Report already in progress'], + }; + } + + this.isRunning = true; + const startTime = new Date(); + const errors: string[] = []; + + try { + // Get the current round (which just ended) + const currentRound = await getCurrentRound(); + + // Check if grace period has ended + if (!isGracePeriodEnded(currentRound)) { + console.log('[RoundReporter] Grace period not yet ended, skipping report'); + return { + timestamp: startTime, + roundNumber: currentRound.roundNumber, + reportSent: false, + newRoundStarted: false, + newRoundNumber: null, + errors: ['Grace period not yet ended'], + }; + } + + console.log(`[RoundReporter] Generating report for round ${currentRound.roundNumber}`); + + // Build report data + const reportData = await buildRoundReportDataForRound(currentRound); + + // Send the report + const notificationService = getNotificationService(); + const sent = await notificationService.sendRoundReport(reportData); + + if (!sent) { + errors.push('Failed to send round report'); + } + + console.log(`[RoundReporter] Round ${currentRound.roundNumber} report ${sent ? 'sent' : 'failed'}`); + + return { + timestamp: startTime, + roundNumber: currentRound.roundNumber, + reportSent: sent, + newRoundStarted: false, + newRoundNumber: null, + errors, + }; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + console.error(`[RoundReporter] Error: ${errorMsg}`); + errors.push(errorMsg); + + return { + timestamp: startTime, + roundNumber: 0, + reportSent: false, + newRoundStarted: false, + newRoundNumber: null, + errors, + }; + } finally { + this.isRunning = false; + } + } + + /** + * Send round start announcement for a new round + * Requirements: 10.4 - Send round start announcement with deadline info + */ + async sendRoundStartAnnouncement(): Promise { + const startTime = new Date(); + const errors: string[] = []; + + try { + // Get the current round + const currentRound = await getCurrentRound(); + + // Check if this is actually the start of a new round + // (Monday of the round's start week) + const today = new Date(); + const roundStartDate = new Date(currentRound.startDate + 'T00:00:00.000Z'); + + // Check if today is the start date of the current round + const isSameDay = + today.getUTCFullYear() === roundStartDate.getUTCFullYear() && + today.getUTCMonth() === roundStartDate.getUTCMonth() && + today.getUTCDate() === roundStartDate.getUTCDate(); + + if (!isSameDay) { + // Not a round start day, check if we need to advance to next round + const nextRound = await getRoundByNumber(currentRound.roundNumber + 1); + + if (nextRound) { + const nextRoundStartDate = new Date(nextRound.startDate + 'T00:00:00.000Z'); + const isNextRoundStartDay = + today.getUTCFullYear() === nextRoundStartDate.getUTCFullYear() && + today.getUTCMonth() === nextRoundStartDate.getUTCMonth() && + today.getUTCDate() === nextRoundStartDate.getUTCDate(); + + if (isNextRoundStartDay) { + // Advance to next round + await setCurrentRound(nextRound.roundNumber); + + console.log(`[RoundReporter] Starting round ${nextRound.roundNumber}`); + + // Send announcement + const notificationService = getNotificationService(); + const sent = await notificationService.sendRoundStartAnnouncement(nextRound); + + if (!sent) { + errors.push('Failed to send round start announcement'); + } + + return { + timestamp: startTime, + roundNumber: currentRound.roundNumber, + reportSent: false, + newRoundStarted: sent, + newRoundNumber: nextRound.roundNumber, + errors, + }; + } + } + + console.log('[RoundReporter] Not a round start day, skipping announcement'); + return { + timestamp: startTime, + roundNumber: currentRound.roundNumber, + reportSent: false, + newRoundStarted: false, + newRoundNumber: null, + errors: ['Not a round start day'], + }; + } + + // Today is the start of the current round - send announcement + console.log(`[RoundReporter] Sending start announcement for round ${currentRound.roundNumber}`); + + const notificationService = getNotificationService(); + const sent = await notificationService.sendRoundStartAnnouncement(currentRound); + + if (!sent) { + errors.push('Failed to send round start announcement'); + } + + return { + timestamp: startTime, + roundNumber: currentRound.roundNumber, + reportSent: false, + newRoundStarted: sent, + newRoundNumber: currentRound.roundNumber, + errors, + }; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + console.error(`[RoundReporter] Error: ${errorMsg}`); + errors.push(errorMsg); + + return { + timestamp: startTime, + roundNumber: 0, + reportSent: false, + newRoundStarted: false, + newRoundNumber: null, + errors, + }; + } + } + + /** + * Manually trigger round report for a specific round + * Useful for admin operations or testing + */ + async sendReportForRound(roundNumber: number): Promise { + const startTime = new Date(); + const errors: string[] = []; + + try { + const round = await getRoundByNumber(roundNumber); + + if (!round) { + return { + timestamp: startTime, + roundNumber, + reportSent: false, + newRoundStarted: false, + newRoundNumber: null, + errors: [`Round ${roundNumber} not found`], + }; + } + + console.log(`[RoundReporter] Manually generating report for round ${roundNumber}`); + + // Build report data + const reportData = await buildRoundReportDataForRound(round); + + // Send the report + const notificationService = getNotificationService(); + const sent = await notificationService.sendRoundReport(reportData); + + if (!sent) { + errors.push('Failed to send round report'); + } + + return { + timestamp: startTime, + roundNumber, + reportSent: sent, + newRoundStarted: false, + newRoundNumber: null, + errors, + }; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + console.error(`[RoundReporter] Manual report error: ${errorMsg}`); + errors.push(errorMsg); + + return { + timestamp: startTime, + roundNumber, + reportSent: false, + newRoundStarted: false, + newRoundNumber: null, + errors, + }; + } + } +} + +// Singleton instance +let roundReporterInstance: RoundReporter | null = null; + +/** + * Get the RoundReporter singleton instance + */ +export function getRoundReporter(): RoundReporter { + if (!roundReporterInstance) { + roundReporterInstance = new RoundReporter(); + } + return roundReporterInstance; +} + +/** + * Reset the singleton (useful for testing) + */ +export function resetRoundReporter(): void { + if (roundReporterInstance) { + roundReporterInstance.stop(); + } + roundReporterInstance = null; +} diff --git a/packages/bot/src/schedulers/rss-poller.property.test.ts b/packages/bot/src/schedulers/rss-poller.property.test.ts new file mode 100644 index 0000000..bae6202 --- /dev/null +++ b/packages/bot/src/schedulers/rss-poller.property.test.ts @@ -0,0 +1,206 @@ +/** + * Property-Based Tests for RSS Poller + * **Feature: blog-study-discord-bot** + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import * as fc from 'fast-check'; +import { MemberStatus, type Member } from '@blog-study/shared/db'; + +// Mock the member service +const mockGetAllByStatus = vi.fn(); +vi.mock('../services/member.service', () => ({ + getMemberService: () => ({ + getAllByStatus: mockGetAllByStatus, + }), +})); + +// Mock the RSS service +vi.mock('../services/rss.service', () => ({ + getRssService: () => ({ + fetchFeed: vi.fn().mockResolvedValue([]), + }), +})); + +import { RssPoller } from './rss-poller'; + +/** + * Generate a mock member with specified status + */ +function generateMember( + id: string, + discordId: string, + status: string, + hasRssUrl: boolean +): Member { + return { + id, + discordId, + discordUsername: `user_${discordId}`, + name: `Name ${discordId}`, + part: 'frontend', + blogUrl: `https://blog.example.com/${discordId}`, + rssUrl: hasRssUrl ? `https://blog.example.com/${discordId}/rss` : null, + profileImageUrl: null, + bio: null, + interests: null, + resolution: null, + onboardingCompleted: false, + status, + dormantStartRound: null, + dormantUsed: false, + joinedAt: new Date(), + updatedAt: new Date(), + }; +} + +describe('RSS Poller Property Tests', () => { + let poller: RssPoller; + + beforeEach(() => { + vi.clearAllMocks(); + poller = new RssPoller(); + }); + + afterEach(() => { + poller.stop(); + }); + + /** + * **Feature: blog-study-discord-bot, Property 15: Active Member RSS Polling** + * *For any* RSS polling operation, only Members with `active` status + * SHALL have their feeds processed. + * **Validates: Requirements 6.2** + */ + describe('Property 15: Active Member RSS Polling', () => { + it('should only return active members with RSS URLs for polling', async () => { + await fc.assert( + fc.asyncProperty( + // Generate a list of members with various statuses + fc.array( + fc.record({ + id: fc.uuid(), + discordId: fc.stringMatching(/^\d{17,19}$/), + status: fc.constantFrom( + MemberStatus.ACTIVE, + MemberStatus.DORMANT, + MemberStatus.WITHDRAWN + ), + hasRssUrl: fc.boolean(), + }), + { minLength: 0, maxLength: 20 } + ), + async (memberSpecs) => { + // Create mock members + const allMembers = memberSpecs.map((spec, index) => + generateMember( + spec.id, + spec.discordId + index, // Ensure unique + spec.status, + spec.hasRssUrl + ) + ); + + // Filter to only active members (what the service should return) + const activeMembers = allMembers.filter( + m => m.status === MemberStatus.ACTIVE + ); + + // Mock the service to return only active members + mockGetAllByStatus.mockResolvedValue(activeMembers); + + // Get members to poll + const membersToPoll = await poller.getMembersToPoll(); + + // Verify: all returned members should be active AND have RSS URL + for (const member of membersToPoll) { + expect(member.status).toBe(MemberStatus.ACTIVE); + expect(member.rssUrl).not.toBeNull(); + } + + // Verify: the service was called with ACTIVE status + expect(mockGetAllByStatus).toHaveBeenCalledWith(MemberStatus.ACTIVE); + } + ), + { numRuns: 100 } + ); + }); + + it('should filter out members without RSS URLs', async () => { + await fc.assert( + fc.asyncProperty( + fc.array( + fc.record({ + id: fc.uuid(), + discordId: fc.stringMatching(/^\d{17,19}$/), + hasRssUrl: fc.boolean(), + }), + { minLength: 1, maxLength: 20 } + ), + async (memberSpecs) => { + // Create all active members, some with and some without RSS URLs + const activeMembers = memberSpecs.map((spec, index) => + generateMember( + spec.id, + spec.discordId + index, + MemberStatus.ACTIVE, + spec.hasRssUrl + ) + ); + + mockGetAllByStatus.mockResolvedValue(activeMembers); + + const membersToPoll = await poller.getMembersToPoll(); + + // All returned members should have RSS URLs + for (const member of membersToPoll) { + expect(member.rssUrl).not.toBeNull(); + expect(member.rssUrl).toBeDefined(); + } + + // Count should match members with RSS URLs + const expectedCount = activeMembers.filter(m => m.rssUrl).length; + expect(membersToPoll.length).toBe(expectedCount); + } + ), + { numRuns: 100 } + ); + }); + + it('should not poll dormant or withdrawn members', async () => { + await fc.assert( + fc.asyncProperty( + fc.array( + fc.record({ + id: fc.uuid(), + discordId: fc.stringMatching(/^\d{17,19}$/), + status: fc.constantFrom(MemberStatus.DORMANT, MemberStatus.WITHDRAWN), + }), + { minLength: 1, maxLength: 10 } + ), + async (memberSpecs) => { + // Create non-active members + memberSpecs.map((spec, index) => + generateMember( + spec.id, + spec.discordId + index, + spec.status, + true // All have RSS URLs + ) + ); + + // Service returns empty for active (since we're testing non-active) + mockGetAllByStatus.mockResolvedValue([]); + + const membersToPoll = await poller.getMembersToPoll(); + + // Should return empty since we only query for active + expect(membersToPoll.length).toBe(0); + expect(mockGetAllByStatus).toHaveBeenCalledWith(MemberStatus.ACTIVE); + } + ), + { numRuns: 100 } + ); + }); + }); +}); diff --git a/packages/bot/src/schedulers/rss-poller.ts b/packages/bot/src/schedulers/rss-poller.ts new file mode 100644 index 0000000..d6a6475 --- /dev/null +++ b/packages/bot/src/schedulers/rss-poller.ts @@ -0,0 +1,215 @@ +/** + * RSS Poller Scheduler + * 5분마다 모든 active 멤버의 RSS 폴링 + * Requirements: 6.1, 6.2 + */ + +import cron from 'node-cron'; +import { getMemberService } from '../services/member.service'; +import { getRssService, type RssFeedItem, type PollResult } from '../services/rss.service'; +import { MemberStatus, type Member } from '@blog-study/shared/db'; + +/** + * Result of a polling cycle + */ +export interface PollingCycleResult { + timestamp: Date; + membersPolled: number; + totalNewItems: number; + results: PollResult[]; + errors: string[]; +} + +/** + * Callback for when new posts are found + */ +export type OnNewPostCallback = ( + member: Member, + items: RssFeedItem[] +) => Promise; + +/** + * RSS Poller class for scheduling RSS feed polling + */ +export class RssPoller { + private cronJob: cron.ScheduledTask | null = null; + private isRunning = false; + private onNewPost: OnNewPostCallback | null = null; + + /** + * Set callback for when new posts are found + */ + setOnNewPostCallback(callback: OnNewPostCallback): void { + this.onNewPost = callback; + } + + /** + * Start the RSS polling scheduler + * Runs every 5 minutes + * Requirements: 6.1 + */ + start(): void { + if (this.cronJob) { + console.log('[RssPoller] Already running'); + return; + } + + // Run every 5 minutes: */5 * * * * + this.cronJob = cron.schedule('*/5 * * * *', async () => { + await this.poll(); + }); + + console.log('[RssPoller] Started - polling every 5 minutes'); + } + + /** + * Stop the RSS polling scheduler + */ + stop(): void { + if (this.cronJob) { + this.cronJob.stop(); + this.cronJob = null; + console.log('[RssPoller] Stopped'); + } + } + + /** + * Check if the poller is currently running + */ + isPolling(): boolean { + return this.isRunning; + } + + /** + * Get members that should be polled + * Requirements: 6.2 - Only active members should be polled + */ + async getMembersToPoll(): Promise { + const memberService = getMemberService(); + const activeMembers = await memberService.getAllByStatus(MemberStatus.ACTIVE); + + // Filter to only members with RSS URLs + return activeMembers.filter(member => member.rssUrl); + } + + /** + * Poll a single member's RSS feed + */ + async pollMember(member: Member): Promise { + if (!member.rssUrl) { + return { + memberId: member.id, + success: false, + newItems: [], + error: 'No RSS URL configured', + }; + } + + try { + const rssService = getRssService(); + const items = await rssService.fetchFeed(member.rssUrl); + + return { + memberId: member.id, + success: true, + newItems: items, + }; + } catch (error) { + // Requirements: 6.5 - Log error and continue processing other feeds + const errorMessage = error instanceof Error ? error.message : String(error); + console.error(`[RssPoller] Error polling ${member.discordUsername}: ${errorMessage}`); + + return { + memberId: member.id, + success: false, + newItems: [], + error: errorMessage, + }; + } + } + + /** + * Run a polling cycle for all active members + * Requirements: 6.1, 6.2 + */ + async poll(): Promise { + if (this.isRunning) { + console.log('[RssPoller] Polling already in progress, skipping'); + return { + timestamp: new Date(), + membersPolled: 0, + totalNewItems: 0, + results: [], + errors: ['Polling already in progress'], + }; + } + + this.isRunning = true; + const startTime = new Date(); + const results: PollResult[] = []; + const errors: string[] = []; + + try { + const members = await this.getMembersToPoll(); + console.log(`[RssPoller] Polling ${members.length} active members`); + + for (const member of members) { + const result = await this.pollMember(member); + results.push(result); + + if (!result.success && result.error) { + errors.push(`${member.discordUsername}: ${result.error}`); + } + + // Call the callback if there are new items + if (result.success && result.newItems.length > 0 && this.onNewPost) { + try { + await this.onNewPost(member, result.newItems); + } catch (callbackError) { + const errorMsg = callbackError instanceof Error + ? callbackError.message + : String(callbackError); + console.error(`[RssPoller] Callback error for ${member.discordUsername}: ${errorMsg}`); + errors.push(`Callback error for ${member.discordUsername}: ${errorMsg}`); + } + } + } + + const totalNewItems = results.reduce((sum, r) => sum + r.newItems.length, 0); + console.log(`[RssPoller] Completed - ${totalNewItems} new items found`); + + return { + timestamp: startTime, + membersPolled: members.length, + totalNewItems, + results, + errors, + }; + } finally { + this.isRunning = false; + } + } +} + +// Singleton instance +let rssPollerInstance: RssPoller | null = null; + +/** + * Get the RssPoller singleton instance + */ +export function getRssPoller(): RssPoller { + if (!rssPollerInstance) { + rssPollerInstance = new RssPoller(); + } + return rssPollerInstance; +} + +/** + * Reset the singleton (useful for testing) + */ +export function resetRssPoller(): void { + if (rssPollerInstance) { + rssPollerInstance.stop(); + } + rssPollerInstance = null; +} diff --git a/packages/bot/src/scripts/init-rounds.ts b/packages/bot/src/scripts/init-rounds.ts new file mode 100644 index 0000000..afef842 --- /dev/null +++ b/packages/bot/src/scripts/init-rounds.ts @@ -0,0 +1,231 @@ +#!/usr/bin/env npx ts-node +/** + * Round Initialization Script + * 스터디 시작일과 총 회차 수로 rounds 테이블 생성 + * Requirements: 15.1, 15.2 + * + * Usage: + * npx ts-node src/scripts/init-rounds.ts --start-date 2024-01-08 --total-rounds 10 + * npx ts-node src/scripts/init-rounds.ts -s 2024-01-08 -t 10 + * npx ts-node src/scripts/init-rounds.ts --delete # Delete all rounds + * npx ts-node src/scripts/init-rounds.ts --list # List all rounds + */ + +import 'dotenv/config'; +import { closeDb } from '@blog-study/shared/db'; +import { + createRounds, + deleteAllRounds, + getAllRounds, + RoundError, + RoundErrorCodes, +} from '../services/round.service'; +import { isMonday, getPreviousMonday } from '@blog-study/shared/utils'; + +interface ParsedArgs { + startDate?: string; + totalRounds?: number; + delete?: boolean; + list?: boolean; + force?: boolean; + help?: boolean; +} + +function parseArgs(args: string[]): ParsedArgs { + const result: ParsedArgs = {}; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + + switch (arg) { + case '-s': + case '--start-date': + result.startDate = args[++i]; + break; + case '-t': + case '--total-rounds': { + const val = args[++i]; + if (val) { + result.totalRounds = parseInt(val, 10); + } + break; + } + case '-d': + case '--delete': + result.delete = true; + break; + case '-l': + case '--list': + result.list = true; + break; + case '-f': + case '--force': + result.force = true; + break; + case '-h': + case '--help': + result.help = true; + break; + } + } + + return result; +} + +function printHelp(): void { + console.log(` +회차 초기화 스크립트 (Round Initialization Script) + +사용법: + npx ts-node src/scripts/init-rounds.ts [옵션] + +옵션: + -s, --start-date <날짜> 스터디 시작일 (YYYY-MM-DD 형식, 월요일 권장) + -t, --total-rounds <숫자> 총 회차 수 (1-52) + -d, --delete 모든 회차 삭제 + -l, --list 모든 회차 목록 출력 + -f, --force 기존 회차가 있어도 삭제 후 재생성 + -h, --help 도움말 출력 + +예시: + # 10회차 스터디 생성 (2024년 1월 8일 시작) + npx ts-node src/scripts/init-rounds.ts -s 2024-01-08 -t 10 + + # 기존 회차 삭제 후 재생성 + npx ts-node src/scripts/init-rounds.ts -s 2024-01-08 -t 10 --force + + # 모든 회차 목록 확인 + npx ts-node src/scripts/init-rounds.ts --list + + # 모든 회차 삭제 + npx ts-node src/scripts/init-rounds.ts --delete + +참고: + - 시작일이 월요일이 아닌 경우, 가장 가까운 이전 월요일로 조정됩니다. + - 각 회차는 2주 (월요일 시작 ~ 다음주 일요일 마감)입니다. + - 지각 마감은 마감일 다음날 월요일 23:59입니다. +`); +} + +function formatDate(dateStr: string): string { + const date = new Date(dateStr); + const days = ['일', '월', '화', '수', '목', '금', '토']; + const dayIndex = date.getDay(); + return `${dateStr} (${days[dayIndex]})`; +} + +async function listRounds(): Promise { + const roundsList = await getAllRounds(); + + if (roundsList.length === 0) { + console.log('생성된 회차가 없습니다.'); + return; + } + + console.log('\n=== 회차 목록 ===\n'); + console.log('회차\t시작일\t\t\t종료일\t\t\t지각마감\t\t현재'); + console.log('─'.repeat(80)); + + for (const round of roundsList) { + const current = round.isCurrent ? '✓' : ''; + console.log( + `${round.roundNumber}\t${formatDate(round.startDate)}\t${formatDate(round.endDate)}\t${formatDate(round.graceEndDate)}\t${current}` + ); + } + + console.log(`\n총 ${roundsList.length}개 회차`); +} + +async function main(): Promise { + const args = parseArgs(process.argv.slice(2)); + + if (args.help) { + printHelp(); + return; + } + + try { + // List rounds + if (args.list) { + await listRounds(); + return; + } + + // Delete all rounds + if (args.delete && !args.startDate) { + console.log('모든 회차를 삭제합니다...'); + await deleteAllRounds(); + console.log('✓ 모든 회차가 삭제되었습니다.'); + return; + } + + // Create rounds + if (!args.startDate || !args.totalRounds) { + console.error('오류: 시작일(-s)과 총 회차 수(-t)를 모두 지정해야 합니다.'); + console.log('도움말: npx ts-node src/scripts/init-rounds.ts --help'); + process.exit(1); + } + + // Validate date format + const dateRegex = /^\d{4}-\d{2}-\d{2}$/; + if (!dateRegex.test(args.startDate)) { + console.error('오류: 날짜 형식이 올바르지 않습니다. YYYY-MM-DD 형식을 사용하세요.'); + process.exit(1); + } + + const startDate = new Date(args.startDate + 'T00:00:00.000Z'); + + // Check if date is valid + if (isNaN(startDate.getTime())) { + console.error('오류: 유효하지 않은 날짜입니다.'); + process.exit(1); + } + + // Warn if not Monday + if (!isMonday(startDate)) { + const adjustedDate = getPreviousMonday(startDate); + const adjustedDateStr = adjustedDate.toISOString().split('T')[0]; + console.log(`⚠ 시작일이 월요일이 아닙니다. ${adjustedDateStr} (월)로 조정됩니다.`); + } + + // Validate total rounds + if (args.totalRounds < 1 || args.totalRounds > 52) { + console.error('오류: 총 회차 수는 1~52 사이여야 합니다.'); + process.exit(1); + } + + // Force delete if requested + if (args.force) { + console.log('기존 회차를 삭제합니다...'); + await deleteAllRounds(); + } + + console.log(`\n회차 생성 중...`); + console.log(` 시작일: ${args.startDate}`); + console.log(` 총 회차: ${args.totalRounds}회`); + + const createdRounds = await createRounds(startDate, args.totalRounds); + + console.log(`\n✓ ${createdRounds.length}개 회차가 생성되었습니다.\n`); + + // Show created rounds + await listRounds(); + + } catch (error) { + if (error instanceof RoundError) { + if (error.code === RoundErrorCodes.ROUNDS_ALREADY_EXIST) { + console.error(`오류: ${error.userMessage}`); + console.log('기존 회차를 삭제하려면 --force 옵션을 사용하세요.'); + } else { + console.error(`오류: ${error.userMessage}`); + } + } else { + console.error('오류:', error); + } + process.exit(1); + } finally { + await closeDb(); + } +} + +main().catch(console.error); diff --git a/packages/bot/src/services/attendance.service.property.test.ts b/packages/bot/src/services/attendance.service.property.test.ts new file mode 100644 index 0000000..7f95610 --- /dev/null +++ b/packages/bot/src/services/attendance.service.property.test.ts @@ -0,0 +1,546 @@ +/** + * Property-Based Tests for AttendanceService + * Tests correctness properties for attendance management operations + * + * These tests verify the business logic of attendance operations using + * pure functions extracted from the service. + */ + +import { describe, it, expect } from 'vitest'; +import * as fc from 'fast-check'; +import { + AttendanceStatus, + type Attendance, + type AttendanceStatusType, +} from '@blog-study/shared/db'; + +/** + * Generate valid UUIDs + */ +const uuidArb = fc.uuid(); + +/** + * Generate valid round IDs + */ +const roundIdArb = fc.integer({ min: 1, max: 52 }); + +/** + * Generate valid attendance status + */ +const attendanceStatusArb = fc.constantFrom( + AttendanceStatus.PENDING, + AttendanceStatus.SUBMITTED, + AttendanceStatus.LATE, + AttendanceStatus.ABSENT +) as fc.Arbitrary; + + + +// ============================================ +// Pure Business Logic Functions for Testing +// ============================================ + +/** + * Simulates the attendance store + */ +interface AttendanceStore { + records: Map; +} + +function createAttendanceStore(): AttendanceStore { + return { records: new Map() }; +} + +function getKey(memberId: string, roundId: number): string { + return `${memberId}:${roundId}`; +} + +/** + * Create attendance record with pending status + * Requirements: 5.2 + */ +function createAttendance( + store: AttendanceStore, + memberId: string, + roundId: number +): Attendance { + const key = getKey(memberId, roundId); + const existing = store.records.get(key); + if (existing) { + return existing; + } + + const record: Attendance = { + id: crypto.randomUUID(), + memberId, + roundId, + status: AttendanceStatus.PENDING, + submittedAt: null, + createdAt: new Date(), + updatedAt: new Date(), + }; + + store.records.set(key, record); + return record; +} + +/** + * Mark attendance as submitted + * Requirements: 5.3 - Post collected within round period -> submitted + */ +function markSubmitted( + store: AttendanceStore, + memberId: string, + roundId: number +): Attendance { + const key = getKey(memberId, roundId); + let record = store.records.get(key); + + if (!record) { + record = { + id: crypto.randomUUID(), + memberId, + roundId, + status: AttendanceStatus.SUBMITTED, + submittedAt: new Date(), + createdAt: new Date(), + updatedAt: new Date(), + }; + store.records.set(key, record); + return record; + } + + // Only update if current status is pending + if (record.status !== AttendanceStatus.PENDING) { + return record; + } + + const updated: Attendance = { + ...record, + status: AttendanceStatus.SUBMITTED, + submittedAt: new Date(), + updatedAt: new Date(), + }; + store.records.set(key, updated); + return updated; +} + +/** + * Mark attendance as late + * Requirements: 5.5 - Post collected on grace day -> late + */ +function markLate( + store: AttendanceStore, + memberId: string, + roundId: number +): Attendance { + const key = getKey(memberId, roundId); + let record = store.records.get(key); + + if (!record) { + record = { + id: crypto.randomUUID(), + memberId, + roundId, + status: AttendanceStatus.LATE, + submittedAt: new Date(), + createdAt: new Date(), + updatedAt: new Date(), + }; + store.records.set(key, record); + return record; + } + + // Only update if current status is pending + if (record.status !== AttendanceStatus.PENDING) { + return record; + } + + const updated: Attendance = { + ...record, + status: AttendanceStatus.LATE, + submittedAt: new Date(), + updatedAt: new Date(), + }; + store.records.set(key, updated); + return updated; +} + +/** + * Mark attendance as absent + * Requirements: 5.6 - No post after grace period -> absent + */ +function markAbsent( + store: AttendanceStore, + memberId: string, + roundId: number +): Attendance { + const key = getKey(memberId, roundId); + let record = store.records.get(key); + + if (!record) { + record = { + id: crypto.randomUUID(), + memberId, + roundId, + status: AttendanceStatus.ABSENT, + submittedAt: null, + createdAt: new Date(), + updatedAt: new Date(), + }; + store.records.set(key, record); + return record; + } + + // Only update if current status is pending + if (record.status !== AttendanceStatus.PENDING) { + return record; + } + + const updated: Attendance = { + ...record, + status: AttendanceStatus.ABSENT, + updatedAt: new Date(), + }; + store.records.set(key, updated); + return updated; +} + +/** + * Determine attendance status based on submission time + * Requirements: 5.3, 5.5, 5.6 + */ +interface RoundPeriod { + startDate: Date; + endDate: Date; // Regular deadline (Sunday 23:59) + graceEndDate: Date; // Grace period end (Monday 23:59) +} + +function determineAttendanceStatus( + submissionTime: Date | null, + roundPeriod: RoundPeriod +): AttendanceStatusType { + if (!submissionTime) { + return AttendanceStatus.ABSENT; + } + + // Within regular period (before or on deadline) + if (submissionTime <= roundPeriod.endDate) { + return AttendanceStatus.SUBMITTED; + } + + // Within grace period (after deadline but before grace end) + if (submissionTime <= roundPeriod.graceEndDate) { + return AttendanceStatus.LATE; + } + + // After grace period + return AttendanceStatus.ABSENT; +} + +/** + * Check if status transition is valid + * Valid transitions: pending -> submitted, pending -> late, pending -> absent + * Invalid: any transition from non-pending status + */ +function isValidTransition( + currentStatus: AttendanceStatusType, + newStatus: AttendanceStatusType +): boolean { + // Can only transition from pending + if (currentStatus !== AttendanceStatus.PENDING) { + return false; + } + // Can transition to any final status + const validTargetStatuses: AttendanceStatusType[] = [ + AttendanceStatus.SUBMITTED, + AttendanceStatus.LATE, + AttendanceStatus.ABSENT, + ]; + return validTargetStatuses.includes(newStatus); +} + +// ============================================ +// Property Tests +// ============================================ + +describe('AttendanceService Property Tests', () => { + /** + * **Feature: blog-study-discord-bot, Property 13: Attendance Status Transitions** + * *For any* attendance record: + * - Post collected within round period → status becomes `submitted` + * - Post collected on grace day → status becomes `late` + * - No post after grace period → status becomes `absent` + * **Validates: Requirements 5.3, 5.5, 5.6** + */ + describe('Property 13: Attendance Status Transitions', () => { + it('should transition from pending to submitted when post is within deadline', () => { + fc.assert( + fc.property(uuidArb, roundIdArb, (memberId, roundId) => { + const store = createAttendanceStore(); + + // Create pending attendance + const created = createAttendance(store, memberId, roundId); + expect(created.status).toBe(AttendanceStatus.PENDING); + + // Mark as submitted + const submitted = markSubmitted(store, memberId, roundId); + expect(submitted.status).toBe(AttendanceStatus.SUBMITTED); + expect(submitted.submittedAt).not.toBeNull(); + }), + { numRuns: 100 } + ); + }); + + it('should transition from pending to late when post is in grace period', () => { + fc.assert( + fc.property(uuidArb, roundIdArb, (memberId, roundId) => { + const store = createAttendanceStore(); + + // Create pending attendance + const created = createAttendance(store, memberId, roundId); + expect(created.status).toBe(AttendanceStatus.PENDING); + + // Mark as late + const late = markLate(store, memberId, roundId); + expect(late.status).toBe(AttendanceStatus.LATE); + expect(late.submittedAt).not.toBeNull(); + }), + { numRuns: 100 } + ); + }); + + it('should transition from pending to absent when no post after grace period', () => { + fc.assert( + fc.property(uuidArb, roundIdArb, (memberId, roundId) => { + const store = createAttendanceStore(); + + // Create pending attendance + const created = createAttendance(store, memberId, roundId); + expect(created.status).toBe(AttendanceStatus.PENDING); + + // Mark as absent + const absent = markAbsent(store, memberId, roundId); + expect(absent.status).toBe(AttendanceStatus.ABSENT); + }), + { numRuns: 100 } + ); + }); + + it('should not change status once already submitted', () => { + fc.assert( + fc.property(uuidArb, roundIdArb, (memberId, roundId) => { + const store = createAttendanceStore(); + + // Create and mark as submitted + createAttendance(store, memberId, roundId); + markSubmitted(store, memberId, roundId); + + // Try to mark as late - should not change + const afterLate = markLate(store, memberId, roundId); + expect(afterLate.status).toBe(AttendanceStatus.SUBMITTED); + + // Try to mark as absent - should not change + const afterAbsent = markAbsent(store, memberId, roundId); + expect(afterAbsent.status).toBe(AttendanceStatus.SUBMITTED); + }), + { numRuns: 100 } + ); + }); + + it('should not change status once already late', () => { + fc.assert( + fc.property(uuidArb, roundIdArb, (memberId, roundId) => { + const store = createAttendanceStore(); + + // Create and mark as late + createAttendance(store, memberId, roundId); + markLate(store, memberId, roundId); + + // Try to mark as submitted - should not change + const afterSubmitted = markSubmitted(store, memberId, roundId); + expect(afterSubmitted.status).toBe(AttendanceStatus.LATE); + + // Try to mark as absent - should not change + const afterAbsent = markAbsent(store, memberId, roundId); + expect(afterAbsent.status).toBe(AttendanceStatus.LATE); + }), + { numRuns: 100 } + ); + }); + + it('should not change status once already absent', () => { + fc.assert( + fc.property(uuidArb, roundIdArb, (memberId, roundId) => { + const store = createAttendanceStore(); + + // Create and mark as absent + createAttendance(store, memberId, roundId); + markAbsent(store, memberId, roundId); + + // Try to mark as submitted - should not change + const afterSubmitted = markSubmitted(store, memberId, roundId); + expect(afterSubmitted.status).toBe(AttendanceStatus.ABSENT); + + // Try to mark as late - should not change + const afterLate = markLate(store, memberId, roundId); + expect(afterLate.status).toBe(AttendanceStatus.ABSENT); + }), + { numRuns: 100 } + ); + }); + + it('should correctly determine status based on submission time', () => { + fc.assert( + fc.property( + fc.date({ min: new Date('2024-01-01'), max: new Date('2024-12-31') }), + (baseDate) => { + // Create a round period + const startDate = new Date(baseDate); + startDate.setHours(0, 0, 0, 0); + + const endDate = new Date(startDate); + endDate.setDate(endDate.getDate() + 13); // 2 weeks - 1 day + endDate.setHours(23, 59, 59, 999); + + const graceEndDate = new Date(endDate); + graceEndDate.setDate(graceEndDate.getDate() + 1); // +1 day for grace + graceEndDate.setHours(23, 59, 59, 999); + + const roundPeriod: RoundPeriod = { startDate, endDate, graceEndDate }; + + // Test submission within deadline + const withinDeadline = new Date(endDate); + withinDeadline.setHours(12, 0, 0, 0); + expect(determineAttendanceStatus(withinDeadline, roundPeriod)).toBe( + AttendanceStatus.SUBMITTED + ); + + // Test submission in grace period + const inGracePeriod = new Date(graceEndDate); + inGracePeriod.setHours(12, 0, 0, 0); + expect(determineAttendanceStatus(inGracePeriod, roundPeriod)).toBe( + AttendanceStatus.LATE + ); + + // Test no submission + expect(determineAttendanceStatus(null, roundPeriod)).toBe( + AttendanceStatus.ABSENT + ); + + // Test submission after grace period + const afterGrace = new Date(graceEndDate); + afterGrace.setDate(afterGrace.getDate() + 1); + expect(determineAttendanceStatus(afterGrace, roundPeriod)).toBe( + AttendanceStatus.ABSENT + ); + } + ), + { numRuns: 50 } + ); + }); + + it('should validate status transitions correctly', () => { + fc.assert( + fc.property(attendanceStatusArb, attendanceStatusArb, (from, to) => { + const isValid = isValidTransition(from, to); + + if (from === AttendanceStatus.PENDING) { + // From pending, can transition to any final status + const canTransitionTo = + to === AttendanceStatus.SUBMITTED || + to === AttendanceStatus.LATE || + to === AttendanceStatus.ABSENT; + expect(isValid).toBe(canTransitionTo); + } else { + // From any other status, cannot transition + expect(isValid).toBe(false); + } + }), + { numRuns: 100 } + ); + }); + }); + + /** + * Additional property tests for attendance creation + */ + describe('Attendance Creation Properties', () => { + it('should create attendance with pending status', () => { + fc.assert( + fc.property(uuidArb, roundIdArb, (memberId, roundId) => { + const store = createAttendanceStore(); + const created = createAttendance(store, memberId, roundId); + + expect(created.memberId).toBe(memberId); + expect(created.roundId).toBe(roundId); + expect(created.status).toBe(AttendanceStatus.PENDING); + expect(created.submittedAt).toBeNull(); + }), + { numRuns: 100 } + ); + }); + + it('should not create duplicate attendance records', () => { + fc.assert( + fc.property(uuidArb, roundIdArb, (memberId, roundId) => { + const store = createAttendanceStore(); + + const first = createAttendance(store, memberId, roundId); + const second = createAttendance(store, memberId, roundId); + + // Should return the same record + expect(first.id).toBe(second.id); + expect(store.records.size).toBe(1); + }), + { numRuns: 100 } + ); + }); + + it('should create separate records for different members', () => { + fc.assert( + fc.property( + fc.array(uuidArb, { minLength: 2, maxLength: 5 }), + roundIdArb, + (memberIds, roundId) => { + // Ensure unique member IDs + const uniqueIds = [...new Set(memberIds)]; + fc.pre(uniqueIds.length >= 2); + + const store = createAttendanceStore(); + + for (const memberId of uniqueIds) { + createAttendance(store, memberId, roundId); + } + + expect(store.records.size).toBe(uniqueIds.length); + } + ), + { numRuns: 50 } + ); + }); + + it('should create separate records for different rounds', () => { + fc.assert( + fc.property( + uuidArb, + fc.array(roundIdArb, { minLength: 2, maxLength: 5 }), + (memberId, roundIds) => { + // Ensure unique round IDs + const uniqueRounds = [...new Set(roundIds)]; + fc.pre(uniqueRounds.length >= 2); + + const store = createAttendanceStore(); + + for (const roundId of uniqueRounds) { + createAttendance(store, memberId, roundId); + } + + expect(store.records.size).toBe(uniqueRounds.length); + } + ), + { numRuns: 50 } + ); + }); + }); +}); diff --git a/packages/bot/src/services/attendance.service.ts b/packages/bot/src/services/attendance.service.ts new file mode 100644 index 0000000..85e4f7b --- /dev/null +++ b/packages/bot/src/services/attendance.service.ts @@ -0,0 +1,377 @@ +/** + * Attendance Service + * 출석 관리 서비스 + * Requirements: 5.2, 5.3, 5.5, 5.6 + */ + +import { eq, and } from 'drizzle-orm'; +import { + getDb, + attendance, + members, + MemberStatus, + AttendanceStatus, + type Attendance, + type NewAttendance, + type AttendanceStatusType, +} from '@blog-study/shared/db'; + +/** + * Error codes for attendance operations + */ +export const AttendanceErrorCodes = { + ATTENDANCE_NOT_FOUND: 'E4001', + MEMBER_NOT_FOUND: 'E4002', + ROUND_NOT_FOUND: 'E4003', + INVALID_STATUS_TRANSITION: 'E4004', + ATTENDANCE_ALREADY_EXISTS: 'E4005', +} as const; + +/** + * Custom error class for attendance operations + */ +export class AttendanceError extends Error { + constructor( + public code: string, + public userMessage: string, + message?: string + ) { + super(message || userMessage); + this.name = 'AttendanceError'; + } +} + +/** + * Attendance service for managing attendance records + */ +export class AttendanceService { + private db = getDb(); + + /** + * Create attendance records for all active members for a round + * Requirements: 5.2 - Create attendance records with pending status for active members + */ + async createForRound(roundId: number): Promise { + // Get all active members + const activeMembers = await this.db + .select() + .from(members) + .where(eq(members.status, MemberStatus.ACTIVE)); + + if (activeMembers.length === 0) { + return []; + } + + // Create attendance records for each active member + const attendanceRecords: NewAttendance[] = activeMembers.map((member) => ({ + memberId: member.id, + roundId, + status: AttendanceStatus.PENDING, + })); + + // Insert all records (ignore conflicts for idempotency) + const created: Attendance[] = []; + for (const record of attendanceRecords) { + try { + const [inserted] = await this.db + .insert(attendance) + .values(record) + .onConflictDoNothing() + .returning(); + if (inserted) { + created.push(inserted); + } + } catch { + // Skip if already exists + } + } + + return created; + } + + /** + * Mark attendance as submitted (on-time submission) + * Requirements: 5.3 - Post collected within round period -> submitted + */ + async markSubmitted(memberId: string, roundId: number): Promise { + const existing = await this.getByMemberAndRound(memberId, roundId); + + if (!existing) { + // Create new attendance record if it doesn't exist + const [created] = await this.db + .insert(attendance) + .values({ + memberId, + roundId, + status: AttendanceStatus.SUBMITTED, + submittedAt: new Date(), + }) + .returning(); + return created!; + } + + // Only update if current status is pending + if (existing.status !== AttendanceStatus.PENDING) { + return existing; // Already submitted, late, or absent + } + + const [updated] = await this.db + .update(attendance) + .set({ + status: AttendanceStatus.SUBMITTED, + submittedAt: new Date(), + updatedAt: new Date(), + }) + .where( + and( + eq(attendance.memberId, memberId), + eq(attendance.roundId, roundId) + ) + ) + .returning(); + + return updated!; + } + + /** + * Mark attendance as late (grace period submission) + * Requirements: 5.5 - Post collected on grace day -> late + */ + async markLate(memberId: string, roundId: number): Promise { + const existing = await this.getByMemberAndRound(memberId, roundId); + + if (!existing) { + // Create new attendance record if it doesn't exist + const [created] = await this.db + .insert(attendance) + .values({ + memberId, + roundId, + status: AttendanceStatus.LATE, + submittedAt: new Date(), + }) + .returning(); + return created!; + } + + // Only update if current status is pending + if (existing.status !== AttendanceStatus.PENDING) { + return existing; // Already submitted, late, or absent + } + + const [updated] = await this.db + .update(attendance) + .set({ + status: AttendanceStatus.LATE, + submittedAt: new Date(), + updatedAt: new Date(), + }) + .where( + and( + eq(attendance.memberId, memberId), + eq(attendance.roundId, roundId) + ) + ) + .returning(); + + return updated!; + } + + /** + * Mark attendance as absent (no submission after grace period) + * Requirements: 5.6 - No post after grace period -> absent + */ + async markAbsent(memberId: string, roundId: number): Promise { + const existing = await this.getByMemberAndRound(memberId, roundId); + + if (!existing) { + // Create new attendance record if it doesn't exist + const [created] = await this.db + .insert(attendance) + .values({ + memberId, + roundId, + status: AttendanceStatus.ABSENT, + }) + .returning(); + return created!; + } + + // Only update if current status is pending + if (existing.status !== AttendanceStatus.PENDING) { + return existing; // Already submitted, late, or absent + } + + const [updated] = await this.db + .update(attendance) + .set({ + status: AttendanceStatus.ABSENT, + updatedAt: new Date(), + }) + .where( + and( + eq(attendance.memberId, memberId), + eq(attendance.roundId, roundId) + ) + ) + .returning(); + + return updated!; + } + + /** + * Process grace period end - mark all pending as absent + * Requirements: 5.6 - When grace period ends, pending -> absent + */ + async processGracePeriodEnd(roundId: number): Promise { + // Get all pending attendance records for the round + const pendingRecords = await this.db + .select() + .from(attendance) + .where( + and( + eq(attendance.roundId, roundId), + eq(attendance.status, AttendanceStatus.PENDING) + ) + ); + + const updated: Attendance[] = []; + for (const record of pendingRecords) { + const result = await this.markAbsent(record.memberId, roundId); + updated.push(result); + } + + return updated; + } + + /** + * Get attendance record by member and round + */ + async getByMemberAndRound(memberId: string, roundId: number): Promise { + const [record] = await this.db + .select() + .from(attendance) + .where( + and( + eq(attendance.memberId, memberId), + eq(attendance.roundId, roundId) + ) + ) + .limit(1); + + return record || null; + } + + /** + * Get all attendance records for a round + */ + async getByRound(roundId: number): Promise { + return this.db + .select() + .from(attendance) + .where(eq(attendance.roundId, roundId)); + } + + /** + * Get all attendance records for a member + */ + async getByMember(memberId: string): Promise { + return this.db + .select() + .from(attendance) + .where(eq(attendance.memberId, memberId)); + } + + /** + * Update attendance status manually (admin function) + */ + async updateStatus( + memberId: string, + roundId: number, + status: AttendanceStatusType + ): Promise { + const existing = await this.getByMemberAndRound(memberId, roundId); + + if (!existing) { + throw new AttendanceError( + AttendanceErrorCodes.ATTENDANCE_NOT_FOUND, + '해당 출석 기록을 찾을 수 없습니다.' + ); + } + + const [updated] = await this.db + .update(attendance) + .set({ + status, + updatedAt: new Date(), + submittedAt: status === AttendanceStatus.SUBMITTED || status === AttendanceStatus.LATE + ? new Date() + : existing.submittedAt, + }) + .where( + and( + eq(attendance.memberId, memberId), + eq(attendance.roundId, roundId) + ) + ) + .returning(); + + return updated!; + } + + /** + * Get current round attendance status for all active members + */ + async getCurrentRoundStatus(roundId: number): Promise> { + const records = await this.db + .select({ + memberId: attendance.memberId, + discordId: members.discordId, + discordUsername: members.discordUsername, + status: attendance.status, + submittedAt: attendance.submittedAt, + }) + .from(attendance) + .innerJoin(members, eq(attendance.memberId, members.id)) + .where( + and( + eq(attendance.roundId, roundId), + eq(members.status, MemberStatus.ACTIVE) + ) + ); + + return records.map((r) => ({ + memberId: r.memberId, + discordId: r.discordId, + discordUsername: r.discordUsername, + status: r.status as AttendanceStatusType, + submittedAt: r.submittedAt, + })); + } +} + +// Singleton instance +let attendanceServiceInstance: AttendanceService | null = null; + +/** + * Get the AttendanceService singleton instance + */ +export function getAttendanceService(): AttendanceService { + if (!attendanceServiceInstance) { + attendanceServiceInstance = new AttendanceService(); + } + return attendanceServiceInstance; +} + +/** + * Reset the singleton (useful for testing) + */ +export function resetAttendanceService(): void { + attendanceServiceInstance = null; +} diff --git a/packages/bot/src/services/curation.service.property.test.ts b/packages/bot/src/services/curation.service.property.test.ts new file mode 100644 index 0000000..a6c9c04 --- /dev/null +++ b/packages/bot/src/services/curation.service.property.test.ts @@ -0,0 +1,468 @@ +/** + * Property-Based Tests for CurationService + * Tests correctness properties for curation operations + * + * These tests verify the business logic of curation operations using + * pure functions extracted from the service. + */ + +import { describe, it, expect } from 'vitest'; +import * as fc from 'fast-check'; + +/** + * Curation item for testing + */ +interface TestCurationItem { + id: string; + title: string; + url: string; + tags: string[]; + relevanceScore: number; + isShared: boolean; +} + +/** + * Generate valid keyword strings (lowercase, no stop words, min length 2) + */ +const validKeywordArb = fc.stringMatching(/^[a-z]{3,15}$/).filter( + (s) => !['the', 'and', 'for', 'are', 'but', 'not', 'you', 'all', 'can', 'had', 'her', 'was', 'one', 'our', 'out'].includes(s) +); + +/** + * Generate a valid URL + */ +const validUrlArb = fc.webUrl(); + +/** + * Generate a curation item with random relevance score + */ +const curationItemArb = fc.record({ + id: fc.uuid(), + title: fc.array(validKeywordArb, { minLength: 2, maxLength: 8 }).map(words => words.join(' ')), + url: validUrlArb, + tags: fc.array(validKeywordArb, { minLength: 0, maxLength: 5 }), + relevanceScore: fc.float({ min: 0, max: 100, noNaN: true }), + isShared: fc.boolean(), +}); + +// ============================================ +// Pure Business Logic Functions for Testing +// ============================================ + +/** + * Select daily content from unshared items + * Prioritizes items with higher relevance scores + * Requirements: 13.5 + */ +function selectDailyContentPure(items: TestCurationItem[]): TestCurationItem | null { + // Filter to only unshared items + const unsharedItems = items.filter(item => !item.isShared); + + if (unsharedItems.length === 0) { + return null; + } + + // Sort by relevance score descending and return the first one + const sorted = [...unsharedItems].sort((a, b) => b.relevanceScore - a.relevanceScore); + return sorted[0]!; +} + +/** + * Get unshared items sorted by relevance score + * Requirements: 13.5 + */ +function getUnsharedItemsSortedByRelevance( + items: TestCurationItem[], + limit: number +): TestCurationItem[] { + return items + .filter(item => !item.isShared) + .sort((a, b) => b.relevanceScore - a.relevanceScore) + .slice(0, limit); +} + +/** + * Calculate relevance score based on keyword matches + * Requirements: 14.4 + */ +function calculateRelevanceScorePure( + contentKeywords: string[], + keywordFrequencies: Map +): number { + if (contentKeywords.length === 0 || keywordFrequencies.size === 0) { + return 0; + } + + const totalFrequency = Array.from(keywordFrequencies.values()) + .reduce((sum, freq) => sum + freq, 0); + + if (totalFrequency === 0) { + return 0; + } + + let score = 0; + for (const keyword of contentKeywords) { + const frequency = keywordFrequencies.get(keyword); + if (frequency !== undefined && frequency > 0) { + score += frequency / totalFrequency; + } + } + + return Math.min(Math.round(score * 100 * 10), 100); +} + +/** + * Extract keywords from title and tags (simplified version) + */ +function extractContentKeywords(title: string, tags: string[]): string[] { + const titleWords = title.toLowerCase().split(/\s+/).filter(w => w.length >= 2); + const tagWords = tags.map(t => t.toLowerCase()); + return [...new Set([...titleWords, ...tagWords])]; +} + +// ============================================ +// Property Tests +// ============================================ + +describe('CurationService Property Tests', () => { + /** + * **Feature: blog-study-discord-bot, Property 35: Relevance-Based Content Selection** + * *For any* set of curation items, selection SHALL prioritize items with higher + * relevance scores calculated from keyword matches. + * **Validates: Requirements 13.5** + */ + describe('Property 35: Relevance-Based Content Selection', () => { + it('should select the item with highest relevance score from unshared items', () => { + fc.assert( + fc.property( + fc.array(curationItemArb, { minLength: 1, maxLength: 50 }), + (items) => { + const selected = selectDailyContentPure(items); + const unsharedItems = items.filter(item => !item.isShared); + + if (unsharedItems.length === 0) { + expect(selected).toBeNull(); + return; + } + + expect(selected).not.toBeNull(); + + // The selected item should have the highest relevance score among unshared items + const maxRelevanceScore = Math.max(...unsharedItems.map(i => i.relevanceScore)); + expect(selected!.relevanceScore).toBe(maxRelevanceScore); + } + ), + { numRuns: 100 } + ); + }); + + it('should only select from unshared items', () => { + fc.assert( + fc.property( + fc.array(curationItemArb, { minLength: 1, maxLength: 30 }), + (items) => { + const selected = selectDailyContentPure(items); + + if (selected !== null) { + expect(selected.isShared).toBe(false); + } + } + ), + { numRuns: 100 } + ); + }); + + it('should return null when all items are shared', () => { + fc.assert( + fc.property( + fc.array(curationItemArb, { minLength: 1, maxLength: 20 }), + (items) => { + // Mark all items as shared + const allSharedItems = items.map(item => ({ ...item, isShared: true })); + const selected = selectDailyContentPure(allSharedItems); + + expect(selected).toBeNull(); + } + ), + { numRuns: 100 } + ); + }); + + it('should return null for empty item list', () => { + const selected = selectDailyContentPure([]); + expect(selected).toBeNull(); + }); + + it('should return unshared items sorted by relevance score descending', () => { + fc.assert( + fc.property( + fc.array(curationItemArb, { minLength: 1, maxLength: 50 }), + fc.integer({ min: 1, max: 20 }), + (items, limit) => { + const sortedItems = getUnsharedItemsSortedByRelevance(items, limit); + + // Verify descending order by relevance score + for (let i = 1; i < sortedItems.length; i++) { + expect(sortedItems[i - 1]!.relevanceScore).toBeGreaterThanOrEqual( + sortedItems[i]!.relevanceScore + ); + } + } + ), + { numRuns: 100 } + ); + }); + + it('should respect the limit parameter', () => { + fc.assert( + fc.property( + fc.array(curationItemArb, { minLength: 10, maxLength: 50 }), + fc.integer({ min: 1, max: 10 }), + (items) => { + // Ensure some items are unshared + const mixedItems = items.map((item, i) => ({ + ...item, + isShared: i % 3 === 0, // Every 3rd item is shared + })); + + const limit = 5; + const sortedItems = getUnsharedItemsSortedByRelevance(mixedItems, limit); + + expect(sortedItems.length).toBeLessThanOrEqual(limit); + } + ), + { numRuns: 100 } + ); + }); + + it('should include highest relevance items in the result', () => { + fc.assert( + fc.property( + fc.array(curationItemArb, { minLength: 10, maxLength: 30 }), + (items) => { + // Ensure all items are unshared for this test + const unsharedItems = items.map(item => ({ ...item, isShared: false })); + + const limit = 5; + const sortedItems = getUnsharedItemsSortedByRelevance(unsharedItems, limit); + + if (sortedItems.length === 0) return; + + // The minimum relevance in the result should be >= any item not in result + const minResultRelevance = Math.min(...sortedItems.map(i => i.relevanceScore)); + const resultIds = new Set(sortedItems.map(i => i.id)); + + for (const item of unsharedItems) { + if (!resultIds.has(item.id)) { + expect(item.relevanceScore).toBeLessThanOrEqual(minResultRelevance); + } + } + } + ), + { numRuns: 100 } + ); + }); + }); + + /** + * **Feature: blog-study-discord-bot, Property 40: Relevance Score Calculation** + * *For any* curation item, the relevance score SHALL be calculated based on + * keyword frequency matches with the item's tags and title. + * **Validates: Requirements 14.4** + */ + describe('Property 40: Relevance Score Calculation', () => { + it('should return 0 for empty content keywords', () => { + const frequencyMap = new Map([['test', 5], ['keyword', 3]]); + const score = calculateRelevanceScorePure([], frequencyMap); + expect(score).toBe(0); + }); + + it('should return 0 for empty frequency map', () => { + const score = calculateRelevanceScorePure(['test', 'keyword'], new Map()); + expect(score).toBe(0); + }); + + it('should return 0 when no keywords match', () => { + fc.assert( + fc.property( + fc.array(validKeywordArb, { minLength: 2, maxLength: 5 }), + fc.array(validKeywordArb, { minLength: 2, maxLength: 5 }), + (contentKeywords, mapKeywords) => { + // Ensure no overlap between content and map keywords + const contentSet = new Set(contentKeywords); + const nonOverlappingMapKeywords = mapKeywords.filter(k => !contentSet.has(k)); + + if (nonOverlappingMapKeywords.length === 0) return; // Skip if all overlap + + const frequencyMap = new Map(); + for (const keyword of nonOverlappingMapKeywords) { + frequencyMap.set(keyword, 10); + } + + const score = calculateRelevanceScorePure(contentKeywords, frequencyMap); + expect(score).toBe(0); + } + ), + { numRuns: 100 } + ); + }); + + it('should return higher score for more matching keywords', () => { + fc.assert( + fc.property( + fc.array(validKeywordArb, { minLength: 3, maxLength: 10 }), + (keywords) => { + // Deduplicate keywords + const uniqueKeywords = [...new Set(keywords)]; + if (uniqueKeywords.length < 2) return; + + // Create frequency map with all keywords having same frequency + const frequencyMap = new Map(); + for (const keyword of uniqueKeywords) { + frequencyMap.set(keyword, 10); + } + + // Score with 1 matching keyword + const score1 = calculateRelevanceScorePure([uniqueKeywords[0]!], frequencyMap); + + // Score with 2 matching keywords + const score2 = calculateRelevanceScorePure( + [uniqueKeywords[0]!, uniqueKeywords[1]!], + frequencyMap + ); + + // More matches should give higher or equal score + expect(score2).toBeGreaterThanOrEqual(score1); + } + ), + { numRuns: 100 } + ); + }); + + it('should weight by keyword frequency', () => { + fc.assert( + fc.property( + validKeywordArb, + validKeywordArb, + fc.integer({ min: 1, max: 50 }), + fc.integer({ min: 51, max: 100 }), + (keyword1, keyword2, lowFreq, highFreq) => { + if (keyword1 === keyword2) return; // Skip if same keyword + + const frequencyMap = new Map(); + frequencyMap.set(keyword1, lowFreq); + frequencyMap.set(keyword2, highFreq); + + // Score for low frequency keyword + const scoreLow = calculateRelevanceScorePure([keyword1], frequencyMap); + + // Score for high frequency keyword + const scoreHigh = calculateRelevanceScorePure([keyword2], frequencyMap); + + // Higher frequency keyword should give higher or equal score + expect(scoreHigh).toBeGreaterThanOrEqual(scoreLow); + } + ), + { numRuns: 100 } + ); + }); + + it('should cap score at 100', () => { + fc.assert( + fc.property( + fc.array(validKeywordArb, { minLength: 1, maxLength: 20 }), + fc.integer({ min: 1, max: 1000 }), + (keywords) => { + const uniqueKeywords = [...new Set(keywords)]; + + const frequencyMap = new Map(); + for (const keyword of uniqueKeywords) { + frequencyMap.set(keyword, 1000); // High frequency + } + + const score = calculateRelevanceScorePure(uniqueKeywords, frequencyMap); + expect(score).toBeLessThanOrEqual(100); + } + ), + { numRuns: 100 } + ); + }); + + it('should return non-negative score', () => { + fc.assert( + fc.property( + fc.array(validKeywordArb, { minLength: 1, maxLength: 10 }), + fc.array( + fc.tuple(validKeywordArb, fc.integer({ min: 0, max: 100 })), + { minLength: 1, maxLength: 10 } + ), + (contentKeywords, keywordFreqPairs) => { + const frequencyMap = new Map(); + for (const [keyword, freq] of keywordFreqPairs) { + frequencyMap.set(keyword, freq); + } + + const score = calculateRelevanceScorePure(contentKeywords, frequencyMap); + expect(score).toBeGreaterThanOrEqual(0); + } + ), + { numRuns: 100 } + ); + }); + + it('should calculate score based on title and tags combined', () => { + fc.assert( + fc.property( + fc.array(validKeywordArb, { minLength: 2, maxLength: 5 }).map(words => words.join(' ')), + fc.array(validKeywordArb, { minLength: 1, maxLength: 3 }), + (title, tags) => { + const contentKeywords = extractContentKeywords(title, tags); + + // All extracted keywords should be lowercase + for (const keyword of contentKeywords) { + expect(keyword).toBe(keyword.toLowerCase()); + } + + // Should include words from both title and tags + const titleWords = title.toLowerCase().split(/\s+/).filter(w => w.length >= 2); + const tagWords = tags.map(t => t.toLowerCase()); + + for (const word of titleWords) { + expect(contentKeywords).toContain(word); + } + for (const tag of tagWords) { + expect(contentKeywords).toContain(tag); + } + } + ), + { numRuns: 100 } + ); + }); + + it('should handle duplicate keywords in content', () => { + fc.assert( + fc.property( + validKeywordArb, + fc.integer({ min: 2, max: 10 }), + (keyword, repeatCount) => { + const frequencyMap = new Map(); + frequencyMap.set(keyword, 50); + frequencyMap.set('other', 50); + + // Create content with repeated keyword + const repeatedKeywords = Array(repeatCount).fill(keyword); + + // Score should be the same regardless of repetition + const scoreRepeated = calculateRelevanceScorePure(repeatedKeywords, frequencyMap); + const scoreSingle = calculateRelevanceScorePure([keyword], frequencyMap); + + // With the current implementation, repeated keywords will increase score + // This is expected behavior - more mentions = higher relevance + expect(scoreRepeated).toBeGreaterThanOrEqual(scoreSingle); + } + ), + { numRuns: 100 } + ); + }); + }); +}); diff --git a/packages/bot/src/services/curation.service.ts b/packages/bot/src/services/curation.service.ts new file mode 100644 index 0000000..159fcad --- /dev/null +++ b/packages/bot/src/services/curation.service.ts @@ -0,0 +1,461 @@ +/** + * Curation Service + * 외부 컨퍼런스 및 아티클 큐레이션 서비스 + * Requirements: 13.1, 13.3, 13.4, 13.5 + */ + +import { eq, desc } from 'drizzle-orm'; +import { + getDb, + curationSources, + curationItems, + type CurationSource, + type NewCurationSource, + type CurationItem, + type NewCurationItem, + CurationCategory, +} from '@blog-study/shared/db'; +import { getKeywordService } from './keyword.service'; + +/** + * Curation source with item count + */ +export interface CurationSourceWithCount extends CurationSource { + itemCount?: number; +} + +/** + * Crawled content item from external source + */ +export interface CrawledContent { + title: string; + url: string; + publishedAt?: Date; + category: string; + tags: string[]; +} + +/** + * Result of crawling a single source + */ +export interface CrawlResult { + sourceId: string; + sourceName: string; + success: boolean; + itemsFound: number; + newItemsAdded: number; + error?: string; +} + +/** + * Curation service for managing external content curation + * Requirements: 13.1, 13.3, 13.4, 13.5 + */ +export class CurationService { + private db = getDb(); + + /** + * Add a new curation source + * Requirements: 13.1 - Maintain configurable list of curated source websites + * @param url Source URL + * @param name Source name + * @param category Category (conference or article) + * @returns Created curation source + */ + async addSource( + url: string, + name: string, + category: string + ): Promise { + // Validate category + const validCategories = Object.values(CurationCategory); + if (!validCategories.includes(category as typeof CurationCategory[keyof typeof CurationCategory])) { + throw new Error(`Invalid category: ${category}. Must be one of: ${validCategories.join(', ')}`); + } + + // Check for duplicate URL + const existing = await this.getSourceByUrl(url); + if (existing) { + throw new Error(`Source with URL already exists: ${url}`); + } + + const newSource: NewCurationSource = { + url, + name, + category, + isActive: true, + }; + + const [created] = await this.db + .insert(curationSources) + .values(newSource) + .returning(); + + return created!; + } + + /** + * Remove a curation source + * @param sourceId Source ID to remove + */ + async removeSource(sourceId: string): Promise { + // First delete all items from this source + await this.db + .delete(curationItems) + .where(eq(curationItems.sourceId, sourceId)); + + // Then delete the source + await this.db + .delete(curationSources) + .where(eq(curationSources.id, sourceId)); + } + + /** + * Get a source by URL + * @param url Source URL + * @returns Source or null + */ + async getSourceByUrl(url: string): Promise { + const [source] = await this.db + .select() + .from(curationSources) + .where(eq(curationSources.url, url)) + .limit(1); + + return source || null; + } + + /** + * Get a source by ID + * @param sourceId Source ID + * @returns Source or null + */ + async getSourceById(sourceId: string): Promise { + const [source] = await this.db + .select() + .from(curationSources) + .where(eq(curationSources.id, sourceId)) + .limit(1); + + return source || null; + } + + /** + * Get all active curation sources + * @returns Array of active sources + */ + async getAllActiveSources(): Promise { + return this.db + .select() + .from(curationSources) + .where(eq(curationSources.isActive, true)); + } + + /** + * Get all curation sources + * @returns Array of all sources + */ + async getAllSources(): Promise { + return this.db.select().from(curationSources); + } + + /** + * Toggle source active status + * @param sourceId Source ID + * @param isActive New active status + */ + async setSourceActive(sourceId: string, isActive: boolean): Promise { + await this.db + .update(curationSources) + .set({ isActive }) + .where(eq(curationSources.id, sourceId)); + } + + + /** + * Add a curation item + * Requirements: 13.4 - Store content with extracted metadata + * @param item Item data + * @returns Created item + */ + async addItem(item: NewCurationItem): Promise { + // Check for duplicate URL + const existing = await this.getItemByUrl(item.url); + if (existing) { + return existing; + } + + const [created] = await this.db + .insert(curationItems) + .values(item) + .returning(); + + return created!; + } + + /** + * Get item by URL + * @param url Item URL + * @returns Item or null + */ + async getItemByUrl(url: string): Promise { + const [item] = await this.db + .select() + .from(curationItems) + .where(eq(curationItems.url, url)) + .limit(1); + + return item || null; + } + + /** + * Get item by ID + * @param itemId Item ID + * @returns Item or null + */ + async getItemById(itemId: string): Promise { + const [item] = await this.db + .select() + .from(curationItems) + .where(eq(curationItems.id, itemId)) + .limit(1); + + return item || null; + } + + /** + * Get all unshared items sorted by relevance score + * @param limit Maximum number of items to return + * @returns Array of unshared items + */ + async getUnsharedItems(limit: number = 10): Promise { + return this.db + .select() + .from(curationItems) + .where(eq(curationItems.isShared, false)) + .orderBy(desc(curationItems.relevanceScore)) + .limit(limit); + } + + /** + * Mark an item as shared + * @param itemId Item ID + */ + async markAsShared(itemId: string): Promise { + await this.db + .update(curationItems) + .set({ + isShared: true, + sharedAt: new Date(), + }) + .where(eq(curationItems.id, itemId)); + } + + /** + * Calculate relevance score for content + * Requirements: 13.5 - Prioritize content matching study group's interest keywords + * Requirements: 14.4 - Use keyword frequency data to calculate relevance scores + * @param title Content title + * @param tags Content tags + * @returns Relevance score (0-100) + */ + async calculateRelevanceScore(title: string, tags: string[]): Promise { + const keywordService = getKeywordService(); + + // Combine title and tags for analysis + const content = [title, ...tags].join(' '); + + return keywordService.calculateRelevanceScore(content); + } + + /** + * Update relevance score for an item + * @param itemId Item ID + * @param score New relevance score + */ + async updateRelevanceScore(itemId: string, score: number): Promise { + await this.db + .update(curationItems) + .set({ relevanceScore: score }) + .where(eq(curationItems.id, itemId)); + } + + /** + * Crawl all active sources and collect new content + * Requirements: 13.3 - Extract title, URL, date, category, and tags/keywords + * Note: This is a placeholder - actual crawling logic depends on source format + * @param crawlFunction Function to crawl a single source URL + * @returns Array of crawl results + */ + async crawlAllSources( + crawlFunction?: (url: string) => Promise + ): Promise { + const sources = await this.getAllActiveSources(); + const results: CrawlResult[] = []; + + for (const source of sources) { + try { + let crawledItems: CrawledContent[] = []; + + if (crawlFunction) { + crawledItems = await crawlFunction(source.url); + } + + let newItemsAdded = 0; + + for (const crawled of crawledItems) { + // Check if item already exists + const existing = await this.getItemByUrl(crawled.url); + if (existing) continue; + + // Calculate relevance score + const relevanceScore = await this.calculateRelevanceScore( + crawled.title, + crawled.tags + ); + + // Add new item + await this.addItem({ + sourceId: source.id, + title: crawled.title, + url: crawled.url, + publishedAt: crawled.publishedAt, + category: crawled.category || source.category, + tags: crawled.tags, + relevanceScore, + isShared: false, + }); + + newItemsAdded++; + } + + results.push({ + sourceId: source.id, + sourceName: source.name, + success: true, + itemsFound: crawledItems.length, + newItemsAdded, + }); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + console.error(`[CurationService] Error crawling ${source.name}: ${errorMessage}`); + + results.push({ + sourceId: source.id, + sourceName: source.name, + success: false, + itemsFound: 0, + newItemsAdded: 0, + error: errorMessage, + }); + } + } + + return results; + } + + /** + * Select daily content to share + * Requirements: 13.5 - Prioritize content matching study group's interest keywords + * @returns Selected item or null if no unshared items + */ + async selectDailyContent(): Promise { + // Get unshared items sorted by relevance score (highest first) + const items = await this.getUnsharedItems(1); + + if (items.length === 0) { + return null; + } + + return items[0]!; + } + + /** + * Get items by source + * @param sourceId Source ID + * @param limit Maximum number of items + * @returns Array of items + */ + async getItemsBySource(sourceId: string, limit: number = 50): Promise { + return this.db + .select() + .from(curationItems) + .where(eq(curationItems.sourceId, sourceId)) + .orderBy(desc(curationItems.collectedAt)) + .limit(limit); + } + + /** + * Get recently shared items + * @param limit Maximum number of items + * @returns Array of shared items + */ + async getRecentlySharedItems(limit: number = 10): Promise { + return this.db + .select() + .from(curationItems) + .where(eq(curationItems.isShared, true)) + .orderBy(desc(curationItems.sharedAt)) + .limit(limit); + } + + /** + * Delete all items (useful for testing) + */ + async deleteAllItems(): Promise { + await this.db.delete(curationItems); + } + + /** + * Delete all sources (useful for testing) + */ + async deleteAllSources(): Promise { + await this.db.delete(curationItems); + await this.db.delete(curationSources); + } + + /** + * Get statistics about curation + */ + async getStats(): Promise<{ + totalSources: number; + activeSources: number; + totalItems: number; + sharedItems: number; + unsharedItems: number; + }> { + const allSources = await this.getAllSources(); + const activeSources = allSources.filter(s => s.isActive); + + const allItems = await this.db.select().from(curationItems); + const sharedItems = allItems.filter(i => i.isShared); + + return { + totalSources: allSources.length, + activeSources: activeSources.length, + totalItems: allItems.length, + sharedItems: sharedItems.length, + unsharedItems: allItems.length - sharedItems.length, + }; + } +} + +// Singleton instance +let curationServiceInstance: CurationService | null = null; + +/** + * Get the CurationService singleton instance + */ +export function getCurationService(): CurationService { + if (!curationServiceInstance) { + curationServiceInstance = new CurationService(); + } + return curationServiceInstance; +} + +/** + * Reset the singleton (useful for testing) + */ +export function resetCurationService(): void { + curationServiceInstance = null; +} diff --git a/packages/bot/src/services/fine.service.property.test.ts b/packages/bot/src/services/fine.service.property.test.ts new file mode 100644 index 0000000..e0843e0 --- /dev/null +++ b/packages/bot/src/services/fine.service.property.test.ts @@ -0,0 +1,341 @@ +/** + * Property-Based Tests for FineService + * Tests correctness properties for fine management operations + * + * These tests verify the business logic of fine operations using + * pure functions extracted from the service. + */ + +import { describe, it, expect } from 'vitest'; +import * as fc from 'fast-check'; +import { + FineAmounts, + PaymentConfirmationWords, + isPaymentConfirmation, + getFineAmount, + formatFineReason, +} from './fine.service'; +import { + FineType, + FineStatus, + type Fine, + type FineTypeValue, +} from '@blog-study/shared/db'; + +/** + * Generate valid fine types + */ +const fineTypeArb = fc.constantFrom(FineType.LATE, FineType.ABSENT) as fc.Arbitrary; + +/** + * Generate a valid fine object + */ +const fineArb = fc.record({ + id: fc.uuid(), + memberId: fc.uuid(), + roundId: fc.integer({ min: 1, max: 20 }), + type: fineTypeArb, + amount: fc.constantFrom(FineAmounts.LATE, FineAmounts.ABSENT), + status: fc.constantFrom(FineStatus.UNPAID, FineStatus.PAID, FineStatus.WAIVED), + createdAt: fc.date(), + paidAt: fc.option(fc.date(), { nil: null }), +}) as fc.Arbitrary; + + + + +/** + * Generate messages containing payment confirmation words + */ +const confirmationMessageArb = fc.oneof( + // Direct confirmation words + fc.constantFrom(...PaymentConfirmationWords).map(word => word), + // Confirmation words with surrounding text + fc.tuple( + fc.string({ minLength: 0, maxLength: 20 }), + fc.constantFrom(...PaymentConfirmationWords), + fc.string({ minLength: 0, maxLength: 20 }) + ).map(([prefix, word, suffix]) => `${prefix} ${word} ${suffix}`), + // Confirmation words with different cases + fc.constantFrom(...PaymentConfirmationWords).map(word => word.toUpperCase()), +); + +/** + * Generate messages that do NOT contain payment confirmation words + */ +const nonConfirmationMessageArb = fc.string({ minLength: 1, maxLength: 100 }) + .filter(msg => { + const normalized = msg.toLowerCase().trim(); + return !PaymentConfirmationWords.some(word => + normalized.includes(word.toLowerCase()) + ); + }); + +// ============================================ +// Pure Business Logic Functions for Testing +// ============================================ + +/** + * Calculate total unpaid fines + */ +function calculateTotalUnpaid(fines: Fine[]): number { + return fines + .filter(f => f.status === FineStatus.UNPAID) + .reduce((sum, f) => sum + f.amount, 0); +} + +/** + * Validate fine creation + * Returns the expected amount based on type + */ +function validateFineCreation(type: FineTypeValue): { amount: number; reason: string } { + switch (type) { + case FineType.LATE: + return { amount: FineAmounts.LATE, reason: '지각' }; + case FineType.ABSENT: + return { amount: FineAmounts.ABSENT, reason: '결석' }; + default: + throw new Error('Invalid fine type'); + } +} + +/** + * Check if fine status transition is valid + */ +function isValidStatusTransition( + currentStatus: string, + newStatus: string +): boolean { + // Can only transition from UNPAID + if (currentStatus !== FineStatus.UNPAID) { + return false; + } + // Can transition to PAID or WAIVED + return newStatus === FineStatus.PAID || newStatus === FineStatus.WAIVED; +} + +// ============================================ +// Property Tests +// ============================================ + +describe('FineService Property Tests', () => { + /** + * **Feature: blog-study-discord-bot, Property 14: Fine Creation on Late/Absent** + * *For any* attendance status change to `late`, a fine of 3,000 KRW SHALL be created. + * For `absent`, a fine of 5,000 KRW SHALL be created. + * **Validates: Requirements 5.7, 5.8** + */ + describe('Property 14: Fine Creation on Late/Absent', () => { + it('should create 3000 KRW fine for late attendance', () => { + fc.assert( + fc.property(fc.uuid(), fc.integer({ min: 1, max: 20 }), (_memberId, _roundId) => { + const result = validateFineCreation(FineType.LATE); + expect(result.amount).toBe(3000); + expect(result.reason).toBe('지각'); + }), + { numRuns: 100 } + ); + }); + + it('should create 5000 KRW fine for absent attendance', () => { + fc.assert( + fc.property(fc.uuid(), fc.integer({ min: 1, max: 20 }), (_memberId, _roundId) => { + const result = validateFineCreation(FineType.ABSENT); + expect(result.amount).toBe(5000); + expect(result.reason).toBe('결석'); + }), + { numRuns: 100 } + ); + }); + + it('should return correct amount for any fine type', () => { + fc.assert( + fc.property(fineTypeArb, (type) => { + const amount = getFineAmount(type); + if (type === FineType.LATE) { + expect(amount).toBe(FineAmounts.LATE); + } else if (type === FineType.ABSENT) { + expect(amount).toBe(FineAmounts.ABSENT); + } + }), + { numRuns: 100 } + ); + }); + + it('should format fine reason correctly for any type', () => { + fc.assert( + fc.property(fineTypeArb, (type) => { + const reason = formatFineReason(type); + expect(typeof reason).toBe('string'); + expect(reason.length).toBeGreaterThan(0); + + if (type === FineType.LATE) { + expect(reason).toBe('지각'); + } else if (type === FineType.ABSENT) { + expect(reason).toBe('결석'); + } + }), + { numRuns: 100 } + ); + }); + + it('should ensure late fine is always less than absent fine', () => { + expect(FineAmounts.LATE).toBeLessThan(FineAmounts.ABSENT); + expect(getFineAmount(FineType.LATE)).toBeLessThan(getFineAmount(FineType.ABSENT)); + }); + }); + + + /** + * **Feature: blog-study-discord-bot, Property 21: Fine Payment Confirmation Parsing** + * *For any* DM reply containing confirmation words ("yes", "네", "납부완료", "완료"), + * the associated fine status SHALL be updated to `paid`. + * **Validates: Requirements 8.2** + */ + describe('Property 21: Fine Payment Confirmation Parsing', () => { + it('should recognize all defined confirmation words', () => { + fc.assert( + fc.property( + fc.constantFrom(...PaymentConfirmationWords), + (word) => { + expect(isPaymentConfirmation(word)).toBe(true); + } + ), + { numRuns: PaymentConfirmationWords.length } + ); + }); + + it('should recognize confirmation words regardless of case', () => { + fc.assert( + fc.property( + fc.constantFrom(...PaymentConfirmationWords), + fc.constantFrom('upper', 'lower', 'mixed'), + (word, caseType) => { + let testWord: string; + switch (caseType) { + case 'upper': + testWord = word.toUpperCase(); + break; + case 'lower': + testWord = word.toLowerCase(); + break; + case 'mixed': + testWord = word.split('').map((c, i) => + i % 2 === 0 ? c.toUpperCase() : c.toLowerCase() + ).join(''); + break; + default: + testWord = word; + } + expect(isPaymentConfirmation(testWord)).toBe(true); + } + ), + { numRuns: 100 } + ); + }); + + it('should recognize confirmation words with surrounding whitespace', () => { + fc.assert( + fc.property( + fc.constantFrom(...PaymentConfirmationWords), + fc.stringOf(fc.constantFrom(' ', '\t', '\n'), { minLength: 0, maxLength: 5 }), + fc.stringOf(fc.constantFrom(' ', '\t', '\n'), { minLength: 0, maxLength: 5 }), + (word, prefix, suffix) => { + const message = `${prefix}${word}${suffix}`; + expect(isPaymentConfirmation(message)).toBe(true); + } + ), + { numRuns: 100 } + ); + }); + + it('should recognize confirmation words embedded in longer messages', () => { + fc.assert( + fc.property(confirmationMessageArb, (message) => { + expect(isPaymentConfirmation(message)).toBe(true); + }), + { numRuns: 100 } + ); + }); + + it('should reject messages without confirmation words', () => { + fc.assert( + fc.property(nonConfirmationMessageArb, (message) => { + expect(isPaymentConfirmation(message)).toBe(false); + }), + { numRuns: 100 } + ); + }); + + it('should handle empty and whitespace-only messages', () => { + expect(isPaymentConfirmation('')).toBe(false); + expect(isPaymentConfirmation(' ')).toBe(false); + expect(isPaymentConfirmation('\t\n')).toBe(false); + }); + }); + + /** + * Additional property tests for fine calculations + */ + describe('Fine Calculation Properties', () => { + it('should calculate total unpaid correctly for any set of fines', () => { + fc.assert( + fc.property(fc.array(fineArb, { minLength: 0, maxLength: 20 }), (fineList) => { + const total = calculateTotalUnpaid(fineList); + + // Manual calculation for verification + const expected = fineList + .filter(f => f.status === FineStatus.UNPAID) + .reduce((sum, f) => sum + f.amount, 0); + + expect(total).toBe(expected); + }), + { numRuns: 100 } + ); + }); + + it('should return 0 for empty fine list', () => { + expect(calculateTotalUnpaid([])).toBe(0); + }); + + it('should return 0 when all fines are paid or waived', () => { + fc.assert( + fc.property( + fc.array( + fc.tuple(fineArb, fc.constantFrom(FineStatus.PAID, FineStatus.WAIVED)).map(([f, status]) => ({ + ...f, + status, + })), + { minLength: 1, maxLength: 10 } + ), + (fineList) => { + const total = calculateTotalUnpaid(fineList); + expect(total).toBe(0); + } + ), + { numRuns: 100 } + ); + }); + + it('should only allow valid status transitions', () => { + fc.assert( + fc.property( + fc.constantFrom(FineStatus.UNPAID, FineStatus.PAID, FineStatus.WAIVED), + fc.constantFrom(FineStatus.UNPAID, FineStatus.PAID, FineStatus.WAIVED), + (currentStatus, newStatus) => { + const isValid = isValidStatusTransition(currentStatus, newStatus); + + if (currentStatus === FineStatus.UNPAID) { + // From UNPAID, can go to PAID or WAIVED + expect(isValid).toBe(newStatus === FineStatus.PAID || newStatus === FineStatus.WAIVED); + } else { + // From PAID or WAIVED, cannot transition + expect(isValid).toBe(false); + } + } + ), + { numRuns: 100 } + ); + }); + }); +}); diff --git a/packages/bot/src/services/fine.service.ts b/packages/bot/src/services/fine.service.ts new file mode 100644 index 0000000..7dd0bc3 --- /dev/null +++ b/packages/bot/src/services/fine.service.ts @@ -0,0 +1,407 @@ +/** + * Fine Service + * 벌금 관리 서비스 + * Requirements: 5.7, 5.8, 8.1, 8.2, 8.3 + */ + +import { eq, and } from 'drizzle-orm'; +import { + getDb, + fines, + members, + rounds, + FineType, + FineStatus, + type Fine, + type NewFine, + type FineTypeValue, +} from '@blog-study/shared/db'; + +/** + * Fine amounts in KRW + */ +export const FineAmounts = { + LATE: 3000, + ABSENT: 5000, +} as const; + +/** + * Confirmation words for payment (Korean and English) + */ +export const PaymentConfirmationWords = [ + 'yes', + '네', + '납부완료', + '완료', + '확인', + 'paid', + 'done', +] as const; + +/** + * Error codes for fine operations + */ +export const FineErrorCodes = { + FINE_NOT_FOUND: 'E5001', + MEMBER_NOT_FOUND: 'E5002', + ROUND_NOT_FOUND: 'E5003', + FINE_ALREADY_EXISTS: 'E5004', + FINE_ALREADY_PAID: 'E5005', + FINE_ALREADY_WAIVED: 'E5006', + INVALID_FINE_TYPE: 'E5007', +} as const; + +/** + * Custom error class for fine operations + */ +export class FineError extends Error { + constructor( + public code: string, + public userMessage: string, + message?: string + ) { + super(message || userMessage); + this.name = 'FineError'; + } +} + + +/** + * Check if a message contains payment confirmation words + * Requirements: 8.2 - Parse confirmation words from DM reply + */ +export function isPaymentConfirmation(message: string): boolean { + const normalizedMessage = message.toLowerCase().trim(); + return PaymentConfirmationWords.some(word => + normalizedMessage.includes(word.toLowerCase()) + ); +} + +/** + * Get fine amount based on type + * Requirements: 5.7, 5.8 - Late = 3000, Absent = 5000 + */ +export function getFineAmount(type: FineTypeValue): number { + switch (type) { + case FineType.LATE: + return FineAmounts.LATE; + case FineType.ABSENT: + return FineAmounts.ABSENT; + default: + throw new FineError( + FineErrorCodes.INVALID_FINE_TYPE, + '유효하지 않은 벌금 유형입니다.' + ); + } +} + +/** + * Format fine reason for display + */ +export function formatFineReason(type: FineTypeValue): string { + switch (type) { + case FineType.LATE: + return '지각'; + case FineType.ABSENT: + return '결석'; + default: + return '알 수 없음'; + } +} + +/** + * Fine service for managing fines + */ +export class FineService { + private db = getDb(); + + /** + * Create a fine for a member + * Requirements: 5.7, 5.8 - Create fine on late/absent + */ + async create( + memberId: string, + roundId: number, + type: FineTypeValue + ): Promise { + // Check if fine already exists for this member and round + const existing = await this.getByMemberAndRound(memberId, roundId); + if (existing) { + // Return existing fine instead of creating duplicate + return existing; + } + + const amount = getFineAmount(type); + + const newFine: NewFine = { + memberId, + roundId, + type, + amount, + status: FineStatus.UNPAID, + }; + + const [created] = await this.db.insert(fines).values(newFine).returning(); + return created!; + } + + /** + * Mark a fine as paid + * Requirements: 8.2 - Update fine status to paid + */ + async markPaid(fineId: string): Promise { + const existing = await this.getById(fineId); + if (!existing) { + throw new FineError( + FineErrorCodes.FINE_NOT_FOUND, + '해당 벌금을 찾을 수 없습니다.' + ); + } + + if (existing.status === FineStatus.PAID) { + return existing; // Already paid + } + + const [updated] = await this.db + .update(fines) + .set({ + status: FineStatus.PAID, + paidAt: new Date(), + }) + .where(eq(fines.id, fineId)) + .returning(); + + return updated!; + } + + /** + * Mark a fine as waived (admin function) + * Requirements: 15.7 - Admin can waive fines + */ + async markWaived(fineId: string): Promise { + const existing = await this.getById(fineId); + if (!existing) { + throw new FineError( + FineErrorCodes.FINE_NOT_FOUND, + '해당 벌금을 찾을 수 없습니다.' + ); + } + + if (existing.status === FineStatus.WAIVED) { + return existing; // Already waived + } + + const [updated] = await this.db + .update(fines) + .set({ + status: FineStatus.WAIVED, + }) + .where(eq(fines.id, fineId)) + .returning(); + + return updated!; + } + + + /** + * Get all unpaid fines for a member + * Requirements: 8.5 - Get unpaid fines by member + */ + async getUnpaidByMember(memberId: string): Promise { + return this.db + .select() + .from(fines) + .where( + and( + eq(fines.memberId, memberId), + eq(fines.status, FineStatus.UNPAID) + ) + ); + } + + /** + * Get all unpaid fines + * Requirements: 8.6 - Get all unpaid fines + */ + async getAllUnpaid(): Promise { + return this.db + .select() + .from(fines) + .where(eq(fines.status, FineStatus.UNPAID)); + } + + /** + * Get all unpaid fines grouped by member with member info + */ + async getAllUnpaidGroupedByMember(): Promise> { + const unpaidFines = await this.db + .select({ + fine: fines, + discordId: members.discordId, + discordUsername: members.discordUsername, + }) + .from(fines) + .innerJoin(members, eq(fines.memberId, members.id)) + .where(eq(fines.status, FineStatus.UNPAID)); + + // Group by member + const grouped = new Map(); + + for (const row of unpaidFines) { + const existing = grouped.get(row.fine.memberId); + if (existing) { + existing.fines.push(row.fine); + existing.totalAmount += row.fine.amount; + } else { + grouped.set(row.fine.memberId, { + memberId: row.fine.memberId, + discordId: row.discordId, + discordUsername: row.discordUsername, + fines: [row.fine], + totalAmount: row.fine.amount, + }); + } + } + + return Array.from(grouped.values()); + } + + /** + * Get fine by ID + */ + async getById(fineId: string): Promise { + const [fine] = await this.db + .select() + .from(fines) + .where(eq(fines.id, fineId)) + .limit(1); + + return fine || null; + } + + /** + * Get fine by member and round + */ + async getByMemberAndRound(memberId: string, roundId: number): Promise { + const [fine] = await this.db + .select() + .from(fines) + .where( + and( + eq(fines.memberId, memberId), + eq(fines.roundId, roundId) + ) + ) + .limit(1); + + return fine || null; + } + + /** + * Get all fines for a member + */ + async getByMember(memberId: string): Promise { + return this.db + .select() + .from(fines) + .where(eq(fines.memberId, memberId)); + } + + /** + * Get total unpaid amount for a member + */ + async getTotalUnpaidByMember(memberId: string): Promise { + const unpaidFines = await this.getUnpaidByMember(memberId); + return unpaidFines.reduce((sum, fine) => sum + fine.amount, 0); + } + + /** + * Get fines that need reminders (unpaid for more than 3 days) + * Requirements: 8.4 - Send reminder every 3 days for unpaid fines + */ + async getFinesNeedingReminder(daysSinceCreation: number = 3): Promise { + const cutoffDate = new Date(); + cutoffDate.setDate(cutoffDate.getDate() - daysSinceCreation); + + const unpaidFines = await this.getAllUnpaid(); + + // Filter fines created more than daysSinceCreation days ago + return unpaidFines.filter(fine => { + const createdAt = fine.createdAt ? new Date(fine.createdAt) : new Date(); + return createdAt <= cutoffDate; + }); + } + + /** + * Get fines with member info for sending reminders + */ + async getFinesWithMemberInfo(): Promise> { + const result = await this.db + .select({ + fine: fines, + discordId: members.discordId, + discordUsername: members.discordUsername, + roundNumber: rounds.roundNumber, + }) + .from(fines) + .innerJoin(members, eq(fines.memberId, members.id)) + .innerJoin(rounds, eq(fines.roundId, rounds.id)) + .where(eq(fines.status, FineStatus.UNPAID)); + + return result.map(row => ({ + fine: row.fine, + discordId: row.discordId, + discordUsername: row.discordUsername, + roundNumber: row.roundNumber, + })); + } + + /** + * Waive fine by member and round (admin function) + * Requirements: 15.7 - Admin can waive fines by user and round + */ + async waiveByMemberAndRound(memberId: string, roundId: number): Promise { + const fine = await this.getByMemberAndRound(memberId, roundId); + if (!fine) { + return null; + } + return this.markWaived(fine.id); + } +} + +// Singleton instance +let fineServiceInstance: FineService | null = null; + +/** + * Get the FineService singleton instance + */ +export function getFineService(): FineService { + if (!fineServiceInstance) { + fineServiceInstance = new FineService(); + } + return fineServiceInstance; +} + +/** + * Reset the singleton (useful for testing) + */ +export function resetFineService(): void { + fineServiceInstance = null; +} diff --git a/packages/bot/src/services/index.ts b/packages/bot/src/services/index.ts new file mode 100644 index 0000000..25b7e8c --- /dev/null +++ b/packages/bot/src/services/index.ts @@ -0,0 +1,12 @@ +// Services Index +// 모든 서비스를 내보냅니다. + +export * from './member.service'; +export * from './rss.service'; +export * from './round.service'; +export * from './post.service'; +export * from './attendance.service'; +export * from './fine.service'; +export * from './notification.service'; +export * from './keyword.service'; +export * from './curation.service'; diff --git a/packages/bot/src/services/keyword.service.property.test.ts b/packages/bot/src/services/keyword.service.property.test.ts new file mode 100644 index 0000000..64f22d6 --- /dev/null +++ b/packages/bot/src/services/keyword.service.property.test.ts @@ -0,0 +1,470 @@ +/** + * Property-Based Tests for KeywordService + * Tests correctness properties for keyword management operations + * + * These tests verify the business logic of keyword operations using + * pure functions extracted from the service. + */ + +import { describe, it, expect } from 'vitest'; +import * as fc from 'fast-check'; +import { extractKeywordsFromPost, extractKeywords } from '@blog-study/shared/utils'; + +/** + * Generate valid keyword strings (lowercase, no stop words, min length 2) + */ +const validKeywordArb = fc.stringMatching(/^[a-z]{3,15}$/).filter( + (s) => !['the', 'and', 'for', 'are', 'but', 'not', 'you', 'all', 'can', 'had', 'her', 'was', 'one', 'our', 'out'].includes(s) +); + +/** + * Generate valid post titles + */ +const postTitleArb = fc.array(validKeywordArb, { minLength: 2, maxLength: 8 }) + .map(words => words.join(' ')); + +/** + * Generate valid post descriptions + */ +const postDescriptionArb = fc.array(validKeywordArb, { minLength: 3, maxLength: 15 }) + .map(words => words.join(' ')); + +// ============================================ +// Pure Business Logic Functions for Testing +// ============================================ + +/** + * Simulate keyword frequency aggregation + * This is a pure function that mimics the database aggregation logic + */ +function aggregateKeywordFrequencies( + keywordLists: string[][] +): Map { + const frequencyMap = new Map(); + + for (const keywords of keywordLists) { + // Deduplicate within each list (as the service does) + const uniqueKeywords = [...new Set(keywords)]; + + for (const keyword of uniqueKeywords) { + const current = frequencyMap.get(keyword) || 0; + frequencyMap.set(keyword, current + 1); + } + } + + return frequencyMap; +} + +/** + * Get top N keywords from frequency map + */ +function getTopKeywordsFromMap( + frequencyMap: Map, + limit: number +): Array<{ keyword: string; frequency: number }> { + const entries = Array.from(frequencyMap.entries()) + .map(([keyword, frequency]) => ({ keyword, frequency })) + .sort((a, b) => b.frequency - a.frequency) + .slice(0, limit); + + return entries; +} + +/** + * Calculate relevance score based on keyword matches + */ +function calculateRelevanceScorePure( + contentKeywords: string[], + keywordFrequencies: Map +): number { + if (contentKeywords.length === 0 || keywordFrequencies.size === 0) { + return 0; + } + + const totalFrequency = Array.from(keywordFrequencies.values()) + .reduce((sum, freq) => sum + freq, 0); + + if (totalFrequency === 0) { + return 0; + } + + let score = 0; + for (const keyword of contentKeywords) { + const frequency = keywordFrequencies.get(keyword); + if (frequency !== undefined && frequency > 0) { + score += frequency / totalFrequency; + } + } + + return Math.min(Math.round(score * 100 * 10), 100); +} + +// ============================================ +// Property Tests +// ============================================ + +describe('KeywordService Property Tests', () => { + /** + * **Feature: blog-study-discord-bot, Property 38: Keyword Frequency Aggregation** + * *For any* set of extracted keywords, the frequency table SHALL correctly + * aggregate counts across all posts. + * **Validates: Requirements 14.2** + */ + describe('Property 38: Keyword Frequency Aggregation', () => { + it('should correctly count keyword occurrences across multiple posts', () => { + fc.assert( + fc.property( + fc.array( + fc.array(validKeywordArb, { minLength: 1, maxLength: 10 }), + { minLength: 1, maxLength: 20 } + ), + (keywordLists) => { + const frequencyMap = aggregateKeywordFrequencies(keywordLists); + + // Verify each keyword's frequency + for (const [keyword, frequency] of frequencyMap) { + // Count how many lists contain this keyword + const expectedCount = keywordLists.filter( + list => list.includes(keyword) + ).length; + + expect(frequency).toBe(expectedCount); + } + } + ), + { numRuns: 100 } + ); + }); + + it('should deduplicate keywords within a single post', () => { + fc.assert( + fc.property( + validKeywordArb, + fc.integer({ min: 2, max: 10 }), + (keyword, repeatCount) => { + // Create a list with repeated keyword + const repeatedKeywords = Array(repeatCount).fill(keyword); + const frequencyMap = aggregateKeywordFrequencies([repeatedKeywords]); + + // Should only count once per post + expect(frequencyMap.get(keyword)).toBe(1); + } + ), + { numRuns: 100 } + ); + }); + + it('should increment frequency for each post containing the keyword', () => { + fc.assert( + fc.property( + validKeywordArb, + fc.integer({ min: 1, max: 20 }), + (keyword, postCount) => { + // Create multiple posts each containing the keyword + const keywordLists = Array(postCount).fill([keyword]); + const frequencyMap = aggregateKeywordFrequencies(keywordLists); + + expect(frequencyMap.get(keyword)).toBe(postCount); + } + ), + { numRuns: 100 } + ); + }); + + it('should handle empty keyword lists', () => { + const frequencyMap = aggregateKeywordFrequencies([]); + expect(frequencyMap.size).toBe(0); + + const frequencyMap2 = aggregateKeywordFrequencies([[], [], []]); + expect(frequencyMap2.size).toBe(0); + }); + + it('should preserve all unique keywords across posts', () => { + fc.assert( + fc.property( + fc.array( + fc.array(validKeywordArb, { minLength: 1, maxLength: 5 }), + { minLength: 1, maxLength: 10 } + ), + (keywordLists) => { + const frequencyMap = aggregateKeywordFrequencies(keywordLists); + + // All unique keywords should be in the map + const allKeywords = new Set(keywordLists.flat()); + for (const keyword of allKeywords) { + expect(frequencyMap.has(keyword)).toBe(true); + } + } + ), + { numRuns: 100 } + ); + }); + }); + + /** + * **Feature: blog-study-discord-bot, Property 39: Top Keywords Ranking** + * *For any* `/관심분야` command, the response SHALL display top 10 keywords + * sorted by frequency in descending order. + * **Validates: Requirements 14.3** + */ + describe('Property 39: Top Keywords Ranking', () => { + it('should return keywords sorted by frequency in descending order', () => { + fc.assert( + fc.property( + fc.array( + fc.tuple(validKeywordArb, fc.integer({ min: 1, max: 100 })), + { minLength: 1, maxLength: 30 } + ), + (keywordFreqPairs) => { + // Create frequency map from pairs (deduplicate keywords) + const frequencyMap = new Map(); + for (const [keyword, freq] of keywordFreqPairs) { + if (!frequencyMap.has(keyword)) { + frequencyMap.set(keyword, freq); + } + } + + const topKeywords = getTopKeywordsFromMap(frequencyMap, 10); + + // Verify descending order + for (let i = 1; i < topKeywords.length; i++) { + expect(topKeywords[i - 1]!.frequency).toBeGreaterThanOrEqual( + topKeywords[i]!.frequency + ); + } + } + ), + { numRuns: 100 } + ); + }); + + it('should return at most 10 keywords', () => { + fc.assert( + fc.property( + fc.array( + fc.tuple(validKeywordArb, fc.integer({ min: 1, max: 100 })), + { minLength: 15, maxLength: 50 } + ), + (keywordFreqPairs) => { + // Create frequency map from pairs (deduplicate keywords) + const frequencyMap = new Map(); + for (const [keyword, freq] of keywordFreqPairs) { + if (!frequencyMap.has(keyword)) { + frequencyMap.set(keyword, freq); + } + } + + const topKeywords = getTopKeywordsFromMap(frequencyMap, 10); + + expect(topKeywords.length).toBeLessThanOrEqual(10); + } + ), + { numRuns: 100 } + ); + }); + + it('should return all keywords if less than limit', () => { + fc.assert( + fc.property( + fc.array( + fc.tuple(validKeywordArb, fc.integer({ min: 1, max: 100 })), + { minLength: 1, maxLength: 5 } + ), + (keywordFreqPairs) => { + // Create frequency map from pairs (deduplicate keywords) + const frequencyMap = new Map(); + for (const [keyword, freq] of keywordFreqPairs) { + if (!frequencyMap.has(keyword)) { + frequencyMap.set(keyword, freq); + } + } + + const topKeywords = getTopKeywordsFromMap(frequencyMap, 10); + + expect(topKeywords.length).toBe(frequencyMap.size); + } + ), + { numRuns: 100 } + ); + }); + + it('should include the highest frequency keywords', () => { + fc.assert( + fc.property( + fc.array( + fc.tuple(validKeywordArb, fc.integer({ min: 1, max: 100 })), + { minLength: 15, maxLength: 30 } + ), + (keywordFreqPairs) => { + // Create frequency map from pairs (deduplicate keywords) + const frequencyMap = new Map(); + for (const [keyword, freq] of keywordFreqPairs) { + if (!frequencyMap.has(keyword)) { + frequencyMap.set(keyword, freq); + } + } + + const topKeywords = getTopKeywordsFromMap(frequencyMap, 10); + + if (topKeywords.length === 0) return; + + // The minimum frequency in top 10 should be >= any frequency not in top 10 + const minTopFreq = Math.min(...topKeywords.map(k => k.frequency)); + const topKeywordSet = new Set(topKeywords.map(k => k.keyword)); + + for (const [keyword, freq] of frequencyMap) { + if (!topKeywordSet.has(keyword)) { + expect(freq).toBeLessThanOrEqual(minTopFreq); + } + } + } + ), + { numRuns: 100 } + ); + }); + + it('should handle empty frequency map', () => { + const topKeywords = getTopKeywordsFromMap(new Map(), 10); + expect(topKeywords).toEqual([]); + }); + }); + + /** + * Additional tests for keyword extraction integration + */ + describe('Keyword Extraction Integration', () => { + it('should extract keywords from post title and description', () => { + fc.assert( + fc.property( + postTitleArb, + postDescriptionArb, + (title, description) => { + const keywords = extractKeywordsFromPost(title, description); + + // Should return an array + expect(Array.isArray(keywords)).toBe(true); + + // All keywords should be lowercase + for (const keyword of keywords) { + expect(keyword).toBe(keyword.toLowerCase()); + } + + // No duplicates + const uniqueKeywords = new Set(keywords); + expect(keywords.length).toBe(uniqueKeywords.size); + } + ), + { numRuns: 100 } + ); + }); + + it('should filter out stop words from extracted keywords', () => { + const stopWords = ['the', 'and', 'for', 'are', 'but', 'not', 'you', 'all']; + + fc.assert( + fc.property( + fc.array(fc.constantFrom(...stopWords), { minLength: 1, maxLength: 5 }), + fc.array(validKeywordArb, { minLength: 1, maxLength: 5 }), + (stops, validWords) => { + const text = [...stops, ...validWords].join(' '); + const keywords = extractKeywords(text); + + // No stop words should be in the result + for (const keyword of keywords) { + expect(stopWords).not.toContain(keyword); + } + } + ), + { numRuns: 100 } + ); + }); + }); + + /** + * Relevance Score Calculation Tests + */ + describe('Relevance Score Calculation', () => { + it('should return 0 for empty content keywords', () => { + const frequencyMap = new Map([['test', 5], ['keyword', 3]]); + const score = calculateRelevanceScorePure([], frequencyMap); + expect(score).toBe(0); + }); + + it('should return 0 for empty frequency map', () => { + const score = calculateRelevanceScorePure(['test', 'keyword'], new Map()); + expect(score).toBe(0); + }); + + it('should return higher score for more matching keywords', () => { + fc.assert( + fc.property( + fc.array(validKeywordArb, { minLength: 3, maxLength: 10 }), + (keywords) => { + // Create frequency map with all keywords having same frequency + const frequencyMap = new Map(); + for (const keyword of keywords) { + frequencyMap.set(keyword, 10); + } + + // Score with 1 matching keyword + const score1 = calculateRelevanceScorePure([keywords[0]!], frequencyMap); + + // Score with 2 matching keywords + const score2 = calculateRelevanceScorePure( + [keywords[0]!, keywords[1]!], + frequencyMap + ); + + // More matches should give higher or equal score + expect(score2).toBeGreaterThanOrEqual(score1); + } + ), + { numRuns: 100 } + ); + }); + + it('should return 0 for non-matching keywords', () => { + fc.assert( + fc.property( + fc.array(validKeywordArb, { minLength: 2, maxLength: 5 }), + fc.array(validKeywordArb, { minLength: 2, maxLength: 5 }), + (contentKeywords, mapKeywords) => { + // Ensure no overlap + const contentSet = new Set(contentKeywords); + const nonOverlappingMapKeywords = mapKeywords.filter(k => !contentSet.has(k)); + + if (nonOverlappingMapKeywords.length === 0) return; // Skip if all overlap + + const frequencyMap = new Map(); + for (const keyword of nonOverlappingMapKeywords) { + frequencyMap.set(keyword, 10); + } + + const score = calculateRelevanceScorePure(contentKeywords, frequencyMap); + expect(score).toBe(0); + } + ), + { numRuns: 100 } + ); + }); + + it('should cap score at 100', () => { + fc.assert( + fc.property( + fc.array(validKeywordArb, { minLength: 1, maxLength: 20 }), + fc.integer({ min: 1, max: 1000 }), + (keywords, frequency) => { + const frequencyMap = new Map(); + for (const keyword of keywords) { + frequencyMap.set(keyword, frequency); + } + + const score = calculateRelevanceScorePure(keywords, frequencyMap); + expect(score).toBeLessThanOrEqual(100); + } + ), + { numRuns: 100 } + ); + }); + }); +}); diff --git a/packages/bot/src/services/keyword.service.ts b/packages/bot/src/services/keyword.service.ts new file mode 100644 index 0000000..71ef6e3 --- /dev/null +++ b/packages/bot/src/services/keyword.service.ts @@ -0,0 +1,256 @@ +/** + * Keyword Service + * 스터디원 관심 분야 분석 서비스 + * Requirements: 14.1, 14.2, 14.4 + */ + +import { eq, desc, sql } from 'drizzle-orm'; +import { + getDb, + keywords, + type Keyword, + type NewKeyword, +} from '@blog-study/shared/db'; +import { extractKeywordsFromPost } from '@blog-study/shared/utils'; + +/** + * Keyword statistics entry + */ +export interface KeywordStat { + keyword: string; + frequency: number; + lastUpdated: Date | null; +} + +/** + * Keyword service for managing interest analysis + * Requirements: 14.1, 14.2, 14.4 + */ +export class KeywordService { + private db = getDb(); + + /** + * Extract keywords from post title and description + * Requirements: 14.1 - Extract keywords from post title and description + * @param title Post title + * @param description Post description (optional) + * @returns Array of extracted keywords + */ + extractKeywords(title: string, description?: string): string[] { + return extractKeywordsFromPost(title, description, { + minLength: 2, + maxKeywords: 20, // Limit keywords per post + }); + } + + /** + * Update keyword statistics in the database + * Requirements: 14.2 - Maintain keyword frequency table + * @param extractedKeywords Array of keywords to update + */ + async updateKeywordStats(extractedKeywords: string[]): Promise { + if (!extractedKeywords || extractedKeywords.length === 0) { + return; + } + + // Deduplicate keywords + const uniqueKeywords = [...new Set(extractedKeywords)]; + + for (const keyword of uniqueKeywords) { + // Try to find existing keyword + const [existing] = await this.db + .select() + .from(keywords) + .where(eq(keywords.keyword, keyword)) + .limit(1); + + if (existing) { + // Increment frequency + await this.db + .update(keywords) + .set({ + frequency: sql`${keywords.frequency} + 1`, + lastUpdated: new Date(), + }) + .where(eq(keywords.keyword, keyword)); + } else { + // Insert new keyword + const newKeyword: NewKeyword = { + keyword, + frequency: 1, + lastUpdated: new Date(), + }; + await this.db.insert(keywords).values(newKeyword); + } + } + } + + + /** + * Get top keywords by frequency + * Requirements: 14.3 - Display top 10 keywords with frequency counts + * @param limit Number of keywords to return (default: 10) + * @returns Array of keyword statistics sorted by frequency descending + */ + async getTopKeywords(limit: number = 10): Promise { + const results = await this.db + .select() + .from(keywords) + .orderBy(desc(keywords.frequency)) + .limit(limit); + + return results.map((k) => ({ + keyword: k.keyword, + frequency: k.frequency ?? 0, + lastUpdated: k.lastUpdated, + })); + } + + /** + * Calculate relevance score for content based on keyword matches + * Requirements: 14.4 - Use keyword frequency data to calculate relevance scores + * @param content Content text to analyze (title + tags) + * @returns Relevance score (higher = more relevant to study group interests) + */ + async calculateRelevanceScore(content: string): Promise { + if (!content || typeof content !== 'string') { + return 0; + } + + // Extract keywords from content + const contentKeywords = extractKeywordsFromPost(content, undefined, { + minLength: 2, + }); + + if (contentKeywords.length === 0) { + return 0; + } + + // Get all keywords from database + const allKeywords = await this.db.select().from(keywords); + + if (allKeywords.length === 0) { + return 0; + } + + // Calculate total frequency for normalization + const totalFrequency = allKeywords.reduce((sum, k) => sum + (k.frequency ?? 0), 0); + + if (totalFrequency === 0) { + return 0; + } + + // Create keyword frequency map + const keywordMap = new Map(); + for (const k of allKeywords) { + keywordMap.set(k.keyword, k.frequency ?? 0); + } + + // Calculate relevance score based on matching keywords + let score = 0; + for (const keyword of contentKeywords) { + const frequency = keywordMap.get(keyword); + if (frequency !== undefined && frequency > 0) { + // Weight by normalized frequency + score += frequency / totalFrequency; + } + } + + // Normalize score to 0-100 range + // Multiply by 100 and cap at 100 + return Math.min(Math.round(score * 100 * 10), 100); + } + + /** + * Get all keywords + * @returns Array of all keywords + */ + async getAllKeywords(): Promise { + return this.db.select().from(keywords); + } + + /** + * Get keyword by name + * @param keyword Keyword string + * @returns Keyword record or null + */ + async getByKeyword(keyword: string): Promise { + const [result] = await this.db + .select() + .from(keywords) + .where(eq(keywords.keyword, keyword)) + .limit(1); + + return result || null; + } + + /** + * Process a new post and update keyword statistics + * Combines extractKeywords and updateKeywordStats for convenience + * @param title Post title + * @param description Post description (optional) + */ + async processPost(title: string, description?: string): Promise { + const extractedKeywords = this.extractKeywords(title, description); + await this.updateKeywordStats(extractedKeywords); + return extractedKeywords; + } + + /** + * Reset all keyword statistics (useful for recalculation) + */ + async resetStats(): Promise { + await this.db.delete(keywords); + } + + /** + * Bulk update keywords with specific frequencies + * Useful for testing and recalculation + * @param keywordFrequencies Map of keyword to frequency + */ + async bulkUpdateKeywords(keywordFrequencies: Map): Promise { + for (const [keyword, frequency] of keywordFrequencies) { + const [existing] = await this.db + .select() + .from(keywords) + .where(eq(keywords.keyword, keyword)) + .limit(1); + + if (existing) { + await this.db + .update(keywords) + .set({ + frequency, + lastUpdated: new Date(), + }) + .where(eq(keywords.keyword, keyword)); + } else { + await this.db.insert(keywords).values({ + keyword, + frequency, + lastUpdated: new Date(), + }); + } + } + } +} + +// Singleton instance +let keywordServiceInstance: KeywordService | null = null; + +/** + * Get the KeywordService singleton instance + */ +export function getKeywordService(): KeywordService { + if (!keywordServiceInstance) { + keywordServiceInstance = new KeywordService(); + } + return keywordServiceInstance; +} + +/** + * Reset the singleton (useful for testing) + */ +export function resetKeywordService(): void { + keywordServiceInstance = null; +} diff --git a/packages/bot/src/services/member.service.property.test.ts b/packages/bot/src/services/member.service.property.test.ts new file mode 100644 index 0000000..707eeba --- /dev/null +++ b/packages/bot/src/services/member.service.property.test.ts @@ -0,0 +1,464 @@ +/** + * Property-Based Tests for MemberService + * Tests correctness properties for member management operations + * + * These tests verify the business logic of member operations using + * pure functions extracted from the service. + */ + +import { describe, it, expect } from 'vitest'; +import * as fc from 'fast-check'; +import { MemberErrorCodes } from './member.service'; +import { + MemberStatus, + type Member, +} from '@blog-study/shared/db'; +import { validateBlogUrl } from '@blog-study/shared/utils'; + +/** + * Generate valid Discord IDs (snowflake format - 17-19 digit numbers) + */ +const discordIdArb = fc.stringOf(fc.constantFrom('0', '1', '2', '3', '4', '5', '6', '7', '8', '9'), { + minLength: 17, + maxLength: 19, +}); + +/** + * Generate valid Discord usernames + */ +const discordUsernameArb = fc.stringMatching(/^[\w.-]{2,32}$/); + +/** + * Generate valid names (Korean or English) + */ +const nameArb = fc.stringOf( + fc.constantFrom(...'가나다라마바사아자차카타파하김이박최정강조윤장임abcdefghijklmnopqrstuvwxyz'.split('')), + { minLength: 2, maxLength: 20 } +); + +/** + * Generate valid parts + */ +const partArb = fc.constantFrom('frontend', 'backend', 'design', 'pm', 'fullstack', 'devops'); + +/** + * Generate valid blog URLs + */ +const validBlogUrlArb = fc.oneof( + // Velog URLs + fc.stringMatching(/^[\w-]{3,20}$/).map(username => `https://velog.io/@${username}`), + // Tistory URLs + fc.stringMatching(/^[\w-]{3,20}$/).map(blogName => `https://${blogName}.tistory.com`), + // Medium URLs + fc.stringMatching(/^[\w-]{3,20}$/).map(username => `https://medium.com/@${username}`), + // Generic blog URLs + fc.webUrl() +); + +/** + * Generate invalid URLs + */ +const invalidUrlArb = fc.oneof( + fc.constant(''), + fc.constant(' '), + fc.constant('not-a-url'), + fc.constant('ftp://invalid.com'), + fc.constant('javascript:alert(1)'), + fc.stringOf(fc.char(), { minLength: 1, maxLength: 50 }).filter(s => !s.startsWith('http')) +); + +/** + * Generate a valid member object + */ +const memberArb = fc.record({ + id: fc.uuid(), + discordId: discordIdArb, + discordUsername: discordUsernameArb, + name: nameArb, + part: partArb, + blogUrl: validBlogUrlArb, + rssUrl: fc.option(fc.webUrl(), { nil: null }), + profileImageUrl: fc.constant(null), + bio: fc.constant(null), + interests: fc.constant(null), + resolution: fc.constant(null), + onboardingCompleted: fc.boolean(), + status: fc.constantFrom(MemberStatus.ACTIVE, MemberStatus.DORMANT, MemberStatus.WITHDRAWN), + dormantStartRound: fc.option(fc.integer({ min: 1, max: 10 }), { nil: null }), + dormantUsed: fc.boolean(), + joinedAt: fc.date(), + updatedAt: fc.date(), +}) as fc.Arbitrary; + +/** + * Generate an active member + */ +const activeMemberArb = memberArb.map(m => ({ + ...m, + status: MemberStatus.ACTIVE, +})); + +/** + * Generate a dormant member + */ +const dormantMemberArb = memberArb.map(m => ({ + ...m, + status: MemberStatus.DORMANT, + dormantUsed: true, + dormantStartRound: m.dormantStartRound ?? 1, +})); + +// ============================================ +// Pure Business Logic Functions for Testing +// ============================================ + +/** + * Validate registration input + * Returns error if invalid, null if valid + */ +function validateRegistration( + blogUrl: string, + existingMember: Member | null +): { code: string; message: string } | null { + const urlValidation = validateBlogUrl(blogUrl); + if (!urlValidation.isValid) { + return { + code: MemberErrorCodes.INVALID_URL, + message: urlValidation.error || '올바른 URL 형식이 아닙니다.', + }; + } + + if (existingMember) { + return { + code: MemberErrorCodes.ALREADY_REGISTERED, + message: `이미 등록된 사용자입니다. 현재 블로그: ${existingMember.blogUrl}`, + }; + } + + return null; +} + +/** + * Validate dormant setting + * Returns error if invalid, null if valid + */ +function validateSetDormant(member: Member | null): { code: string; message: string } | null { + if (!member) { + return { + code: MemberErrorCodes.USER_NOT_FOUND, + message: '등록되지 않은 사용자입니다.', + }; + } + + if (member.dormantUsed) { + return { + code: MemberErrorCodes.DORMANT_ALREADY_USED, + message: '휴면은 1회만 사용할 수 있습니다.', + }; + } + + if (member.status === MemberStatus.DORMANT) { + return { + code: 'E1006', + message: '이미 휴면 상태입니다.', + }; + } + + return null; +} + +/** + * Check if dormant period has expired (4 rounds = 8 weeks) + */ +function shouldAutoActivate(member: Member, currentRoundNumber: number): boolean { + if (member.status !== MemberStatus.DORMANT) { + return false; + } + if (member.dormantStartRound === null) { + return false; + } + return currentRoundNumber >= member.dormantStartRound + 4; +} + +/** + * Create member info response + */ +function createMemberInfo(member: Member, postCount: number, totalFines: number) { + return { + blogUrl: member.blogUrl, + rssUrl: member.rssUrl, + joinDate: member.joinedAt, + postCount, + status: member.status, + dormantUsed: member.dormantUsed, + totalFines, + }; +} + +// ============================================ +// Property Tests +// ============================================ + +describe('MemberService Property Tests', () => { + /** + * **Feature: blog-study-discord-bot, Property 1: Valid URL Registration Creates Active Member** + * *For any* valid blog URL and Discord user, registering with that URL SHALL create + * a Member with `active` status and the correct blog URL stored. + * **Validates: Requirements 1.1** + */ + describe('Property 1: Valid URL Registration Creates Active Member', () => { + it('should accept all valid blog URLs for registration', () => { + fc.assert( + fc.property(validBlogUrlArb, (blogUrl) => { + const error = validateRegistration(blogUrl, null); + expect(error).toBeNull(); + }), + { numRuns: 100 } + ); + }); + + it('should reject all invalid URLs for registration', () => { + fc.assert( + fc.property(invalidUrlArb, (invalidUrl) => { + const error = validateRegistration(invalidUrl, null); + expect(error).not.toBeNull(); + expect(error!.code).toBe(MemberErrorCodes.INVALID_URL); + }), + { numRuns: 100 } + ); + }); + + it('should validate URL format correctly', () => { + fc.assert( + fc.property(fc.string(), (url) => { + const validation = validateBlogUrl(url); + const error = validateRegistration(url, null); + + // If URL is valid, registration should pass (no existing member) + // If URL is invalid, registration should fail with INVALID_URL + if (validation.isValid) { + expect(error).toBeNull(); + } else { + expect(error).not.toBeNull(); + expect(error!.code).toBe(MemberErrorCodes.INVALID_URL); + } + }), + { numRuns: 100 } + ); + }); + }); + + /** + * **Feature: blog-study-discord-bot, Property 3: Duplicate Registration Prevention** + * *For any* Discord user who is already registered, attempting to register again + * SHALL fail and the existing Member record SHALL remain unchanged. + * **Validates: Requirements 1.4** + */ + describe('Property 3: Duplicate Registration Prevention', () => { + it('should reject registration when member already exists', () => { + fc.assert( + fc.property(activeMemberArb, validBlogUrlArb, (existingMember, newBlogUrl) => { + const error = validateRegistration(newBlogUrl, existingMember); + expect(error).not.toBeNull(); + expect(error!.code).toBe(MemberErrorCodes.ALREADY_REGISTERED); + }), + { numRuns: 100 } + ); + }); + + it('should include existing blog URL in error message', () => { + fc.assert( + fc.property(activeMemberArb, validBlogUrlArb, (existingMember, newBlogUrl) => { + const error = validateRegistration(newBlogUrl, existingMember); + expect(error).not.toBeNull(); + expect(error!.message).toContain(existingMember.blogUrl); + }), + { numRuns: 100 } + ); + }); + }); + + /** + * **Feature: blog-study-discord-bot, Property 4: Withdrawal Status Change** + * *For any* registered Member, executing withdrawal SHALL change their status to + * `withdrawn` while preserving all associated posts, attendance, and fine records. + * **Validates: Requirements 2.1, 2.4** + */ + describe('Property 4: Withdrawal Status Change', () => { + it('should preserve all member data except status on withdrawal', () => { + fc.assert( + fc.property(activeMemberArb, (member) => { + // Simulate withdrawal - only status changes + const withdrawnMember: Member = { + ...member, + status: MemberStatus.WITHDRAWN, + updatedAt: new Date(), + }; + + // All data except status and updatedAt should be preserved + expect(withdrawnMember.id).toBe(member.id); + expect(withdrawnMember.discordId).toBe(member.discordId); + expect(withdrawnMember.blogUrl).toBe(member.blogUrl); + expect(withdrawnMember.name).toBe(member.name); + expect(withdrawnMember.part).toBe(member.part); + expect(withdrawnMember.rssUrl).toBe(member.rssUrl); + expect(withdrawnMember.dormantUsed).toBe(member.dormantUsed); + expect(withdrawnMember.status).toBe(MemberStatus.WITHDRAWN); + }), + { numRuns: 100 } + ); + }); + }); + + /** + * **Feature: blog-study-discord-bot, Property 5: Dormant One-Time Usage** + * *For any* Member, dormant status can only be set once. If `dormant_used` is true, + * setting dormant SHALL fail. + * **Validates: Requirements 3.1, 3.3** + */ + describe('Property 5: Dormant One-Time Usage', () => { + it('should allow dormant for members who have not used it', () => { + fc.assert( + fc.property( + activeMemberArb.map(m => ({ ...m, dormantUsed: false })), + (member) => { + const error = validateSetDormant(member); + expect(error).toBeNull(); + } + ), + { numRuns: 100 } + ); + }); + + it('should reject dormant for members who have already used it', () => { + fc.assert( + fc.property( + activeMemberArb.map(m => ({ ...m, dormantUsed: true })), + (member) => { + const error = validateSetDormant(member); + expect(error).not.toBeNull(); + expect(error!.code).toBe(MemberErrorCodes.DORMANT_ALREADY_USED); + } + ), + { numRuns: 100 } + ); + }); + + it('should reject dormant for members already in dormant status', () => { + fc.assert( + fc.property( + dormantMemberArb.map(m => ({ ...m, dormantUsed: false })), // Edge case: dormant but not marked as used + (member) => { + const error = validateSetDormant(member); + expect(error).not.toBeNull(); + // Should fail because already dormant + } + ), + { numRuns: 100 } + ); + }); + + it('should reject dormant for non-existent members', () => { + const error = validateSetDormant(null); + expect(error).not.toBeNull(); + expect(error!.code).toBe(MemberErrorCodes.USER_NOT_FOUND); + }); + }); + + /** + * **Feature: blog-study-discord-bot, Property 7: Dormant Auto-Expiration** + * *For any* dormant Member, when 4 rounds have passed since `dormant_start_round`, + * the status SHALL automatically change to `active`. + * **Validates: Requirements 3.5** + */ + describe('Property 7: Dormant Auto-Expiration', () => { + it('should auto-activate after 4 rounds', () => { + fc.assert( + fc.property( + dormantMemberArb, + fc.integer({ min: 1, max: 10 }), + (member, startRound) => { + const memberWithStart = { ...member, dormantStartRound: startRound }; + + // Before 4 rounds: should not auto-activate + expect(shouldAutoActivate(memberWithStart, startRound)).toBe(false); + expect(shouldAutoActivate(memberWithStart, startRound + 1)).toBe(false); + expect(shouldAutoActivate(memberWithStart, startRound + 2)).toBe(false); + expect(shouldAutoActivate(memberWithStart, startRound + 3)).toBe(false); + + // At 4 rounds: should auto-activate + expect(shouldAutoActivate(memberWithStart, startRound + 4)).toBe(true); + expect(shouldAutoActivate(memberWithStart, startRound + 5)).toBe(true); + } + ), + { numRuns: 100 } + ); + }); + + it('should not auto-activate non-dormant members', () => { + fc.assert( + fc.property( + activeMemberArb, + fc.integer({ min: 1, max: 20 }), + (member, currentRound) => { + expect(shouldAutoActivate(member, currentRound)).toBe(false); + } + ), + { numRuns: 100 } + ); + }); + + it('should not auto-activate dormant members without start round', () => { + fc.assert( + fc.property( + dormantMemberArb.map(m => ({ ...m, dormantStartRound: null })), + fc.integer({ min: 1, max: 20 }), + (member, currentRound) => { + expect(shouldAutoActivate(member, currentRound)).toBe(false); + } + ), + { numRuns: 100 } + ); + }); + }); + + /** + * **Feature: blog-study-discord-bot, Property 9: Member Info Completeness** + * *For any* registered Member, the `/내정보` response SHALL contain all required fields: + * blog URL, RSS URL, join date, post count, attendance status, dormant status, and total fines. + * **Validates: Requirements 4.1** + */ + describe('Property 9: Member Info Completeness', () => { + it('should include all required fields in member info', () => { + fc.assert( + fc.property( + memberArb, + fc.integer({ min: 0, max: 100 }), + fc.integer({ min: 0, max: 50000 }), + (member, postCount, totalFines) => { + const info = createMemberInfo(member, postCount, totalFines); + + // All required fields should be present + expect(info).toHaveProperty('blogUrl'); + expect(info).toHaveProperty('rssUrl'); + expect(info).toHaveProperty('joinDate'); + expect(info).toHaveProperty('postCount'); + expect(info).toHaveProperty('status'); + expect(info).toHaveProperty('dormantUsed'); + expect(info).toHaveProperty('totalFines'); + + // Values should match + expect(info.blogUrl).toBe(member.blogUrl); + expect(info.rssUrl).toBe(member.rssUrl); + expect(info.joinDate).toBe(member.joinedAt); + expect(info.postCount).toBe(postCount); + expect(info.status).toBe(member.status); + expect(info.dormantUsed).toBe(member.dormantUsed); + expect(info.totalFines).toBe(totalFines); + } + ), + { numRuns: 100 } + ); + }); + }); +}); diff --git a/packages/bot/src/services/member.service.ts b/packages/bot/src/services/member.service.ts new file mode 100644 index 0000000..7e8e1de --- /dev/null +++ b/packages/bot/src/services/member.service.ts @@ -0,0 +1,344 @@ +/** + * Member Service + * 스터디 참가자 관리 서비스 + * Requirements: 1.1, 2.1, 3.2, 3.6 + */ + +import { eq } from 'drizzle-orm'; +import { + getDb, + members, + MemberStatus, + type Member, + type NewMember, + type MemberStatusType, +} from '@blog-study/shared/db'; +import { validateBlogUrl } from '@blog-study/shared/utils'; + +/** + * Error codes for member operations + */ +export const MemberErrorCodes = { + INVALID_URL: 'E1001', + USER_NOT_FOUND: 'E1002', + ALREADY_REGISTERED: 'E1003', + DORMANT_ALREADY_USED: 'E1004', + NOT_DORMANT: 'E1005', + ALREADY_DORMANT: 'E1006', +} as const; + +/** + * Custom error class for member operations + */ +export class MemberError extends Error { + constructor( + public code: string, + public userMessage: string, + message?: string + ) { + super(message || userMessage); + this.name = 'MemberError'; + } +} + +/** + * Input for registering a new member + */ +export interface RegisterMemberInput { + discordId: string; + discordUsername: string; + name: string; + part: string; + blogUrl: string; + rssUrl?: string; +} + + +/** + * Member service for managing study participants + */ +export class MemberService { + private db = getDb(); + + /** + * Register a new member + * Requirements: 1.1 - Valid URL registration creates active member + * @throws MemberError if URL is invalid or user is already registered + */ + async register(input: RegisterMemberInput): Promise { + // Validate blog URL + const urlValidation = validateBlogUrl(input.blogUrl); + if (!urlValidation.isValid) { + throw new MemberError( + MemberErrorCodes.INVALID_URL, + urlValidation.error || '올바른 URL 형식이 아닙니다.', + `Invalid blog URL: ${input.blogUrl}` + ); + } + + // Check if user is already registered + const existing = await this.getByDiscordId(input.discordId); + if (existing) { + throw new MemberError( + MemberErrorCodes.ALREADY_REGISTERED, + `이미 등록된 사용자입니다. 현재 블로그: ${existing.blogUrl}`, + `User ${input.discordId} is already registered` + ); + } + + // Create new member with active status + const newMember: NewMember = { + discordId: input.discordId, + discordUsername: input.discordUsername, + name: input.name, + part: input.part, + blogUrl: urlValidation.normalizedUrl || input.blogUrl, + rssUrl: input.rssUrl || null, + status: MemberStatus.ACTIVE, + dormantUsed: false, + onboardingCompleted: false, + }; + + const [created] = await this.db.insert(members).values(newMember).returning(); + return created!; + } + + /** + * Withdraw a member from the study + * Requirements: 2.1 - Withdrawal changes status to withdrawn, preserves data + * @throws MemberError if user is not found + */ + async withdraw(discordId: string): Promise { + const member = await this.getByDiscordId(discordId); + if (!member) { + throw new MemberError( + MemberErrorCodes.USER_NOT_FOUND, + '등록되지 않은 사용자입니다.', + `User ${discordId} not found` + ); + } + + const [updated] = await this.db + .update(members) + .set({ + status: MemberStatus.WITHDRAWN, + updatedAt: new Date(), + }) + .where(eq(members.discordId, discordId)) + .returning(); + + return updated!; + } + + /** + * Set a member to dormant status + * Requirements: 3.2 - Set dormant status and record start round + * @throws MemberError if user not found or dormant already used + */ + async setDormant(discordId: string, currentRoundNumber: number): Promise { + const member = await this.getByDiscordId(discordId); + if (!member) { + throw new MemberError( + MemberErrorCodes.USER_NOT_FOUND, + '등록되지 않은 사용자입니다.', + `User ${discordId} not found` + ); + } + + // Check if dormant was already used + if (member.dormantUsed) { + throw new MemberError( + MemberErrorCodes.DORMANT_ALREADY_USED, + '휴면은 1회만 사용할 수 있습니다.', + `User ${discordId} has already used dormant privilege` + ); + } + + // Check if already dormant + if (member.status === MemberStatus.DORMANT) { + throw new MemberError( + MemberErrorCodes.ALREADY_DORMANT, + '이미 휴면 상태입니다.', + `User ${discordId} is already dormant` + ); + } + + const [updated] = await this.db + .update(members) + .set({ + status: MemberStatus.DORMANT, + dormantStartRound: currentRoundNumber, + dormantUsed: true, + updatedAt: new Date(), + }) + .where(eq(members.discordId, discordId)) + .returning(); + + return updated!; + } + + /** + * Unset dormant status (return to active) + * Requirements: 3.6 - Unset dormant changes status back to active + * @throws MemberError if user not found or not dormant + */ + async unsetDormant(discordId: string): Promise { + const member = await this.getByDiscordId(discordId); + if (!member) { + throw new MemberError( + MemberErrorCodes.USER_NOT_FOUND, + '등록되지 않은 사용자입니다.', + `User ${discordId} not found` + ); + } + + if (member.status !== MemberStatus.DORMANT) { + throw new MemberError( + MemberErrorCodes.NOT_DORMANT, + '휴면 상태가 아닙니다.', + `User ${discordId} is not dormant` + ); + } + + const [updated] = await this.db + .update(members) + .set({ + status: MemberStatus.ACTIVE, + updatedAt: new Date(), + }) + .where(eq(members.discordId, discordId)) + .returning(); + + return updated!; + } + + /** + * Get a member by Discord ID + */ + async getByDiscordId(discordId: string): Promise { + const [member] = await this.db + .select() + .from(members) + .where(eq(members.discordId, discordId)) + .limit(1); + + return member || null; + } + + /** + * Get all active members + */ + async getAllActive(): Promise { + return this.db + .select() + .from(members) + .where(eq(members.status, MemberStatus.ACTIVE)); + } + + /** + * Get all members by status + */ + async getAllByStatus(status: MemberStatusType): Promise { + return this.db + .select() + .from(members) + .where(eq(members.status, status)); + } + + /** + * Get all members (regardless of status) + */ + async getAll(): Promise { + return this.db.select().from(members); + } + + /** + * Get member by ID + */ + async getById(id: string): Promise { + const [member] = await this.db + .select() + .from(members) + .where(eq(members.id, id)) + .limit(1); + + return member || null; + } + + /** + * Update member's RSS URL + */ + async updateRssUrl(discordId: string, rssUrl: string): Promise { + const member = await this.getByDiscordId(discordId); + if (!member) { + throw new MemberError( + MemberErrorCodes.USER_NOT_FOUND, + '등록되지 않은 사용자입니다.', + `User ${discordId} not found` + ); + } + + const [updated] = await this.db + .update(members) + .set({ + rssUrl, + updatedAt: new Date(), + }) + .where(eq(members.discordId, discordId)) + .returning(); + + return updated!; + } + + /** + * Check if dormant period has expired (4 rounds = 8 weeks) + * Returns true if member should be auto-activated + */ + shouldAutoActivate(member: Member, currentRoundNumber: number): boolean { + if (member.status !== MemberStatus.DORMANT) { + return false; + } + if (member.dormantStartRound === null) { + return false; + } + // Dormant lasts for 4 rounds + return currentRoundNumber >= member.dormantStartRound + 4; + } + + /** + * Auto-activate dormant members whose period has expired + */ + async autoActivateExpiredDormant(currentRoundNumber: number): Promise { + const dormantMembers = await this.getAllByStatus(MemberStatus.DORMANT); + const activated: Member[] = []; + + for (const member of dormantMembers) { + if (this.shouldAutoActivate(member, currentRoundNumber)) { + const updated = await this.unsetDormant(member.discordId); + activated.push(updated); + } + } + + return activated; + } +} + +// Singleton instance +let memberServiceInstance: MemberService | null = null; + +/** + * Get the MemberService singleton instance + */ +export function getMemberService(): MemberService { + if (!memberServiceInstance) { + memberServiceInstance = new MemberService(); + } + return memberServiceInstance; +} + +/** + * Reset the singleton (useful for testing) + */ +export function resetMemberService(): void { + memberServiceInstance = null; +} diff --git a/packages/bot/src/services/notification.service.property.test.ts b/packages/bot/src/services/notification.service.property.test.ts new file mode 100644 index 0000000..95b2dcd --- /dev/null +++ b/packages/bot/src/services/notification.service.property.test.ts @@ -0,0 +1,493 @@ +/** + * Property-Based Tests for NotificationService + * Tests correctness properties for notification message formatting + * + * These tests verify the business logic of notification operations using + * pure functions extracted from the service. + * + * **Feature: blog-study-discord-bot, Property 20: Notification Message Format** + * **Validates: Requirements 7.2** + */ + +import { describe, it, expect } from 'vitest'; +import * as fc from 'fast-check'; +import type { Post, Member, Round, AttendanceStatusType } from '@blog-study/shared/db'; +import { AttendanceStatus } from '@blog-study/shared/db'; +import { + buildPostNotificationEmbed, + buildPostNotificationMessage, + buildRoundReportEmbed, + buildRoundReportMessage, + calculateRoundReportData, + type PostNotificationInput, + type AttendanceSummary, +} from './notification.service'; + +// ============================================ +// Arbitraries for generating test data +// ============================================ + +/** + * Generate valid UUIDs + */ +const uuidArb = fc.uuid(); + +/** + * Generate valid Discord IDs (snowflake format - 17-19 digit numbers) + */ +const discordIdArb = fc.stringOf(fc.constantFrom('0', '1', '2', '3', '4', '5', '6', '7', '8', '9'), { + minLength: 17, + maxLength: 19, +}); + +/** + * Generate valid Discord usernames + */ +const discordUsernameArb = fc.string({ minLength: 2, maxLength: 32 }).filter(s => s.trim().length > 0); + +/** + * Generate valid names + */ +const nameArb = fc.string({ minLength: 1, maxLength: 100 }).filter(s => s.trim().length > 0); + +/** + * Generate valid parts + */ +const partArb = fc.constantFrom('frontend', 'backend', 'design', 'pm', 'fullstack'); + +/** + * Generate valid post titles + */ +const titleArb = fc.string({ minLength: 1, maxLength: 500 }).filter(s => s.trim().length > 0); + +/** + * Generate valid post URLs + */ +const postUrlArb = fc.webUrl(); + +/** + * Generate valid blog URLs + */ +const blogUrlArb = fc.webUrl(); + +/** + * Generate valid descriptions + */ +const descriptionArb = fc.option(fc.string({ maxLength: 1000 }), { nil: null }); + +/** + * Generate valid round numbers + */ +const roundNumberArb = fc.integer({ min: 1, max: 52 }); + +/** + * Generate a valid Member object + */ +const memberArb: fc.Arbitrary = fc.record({ + id: uuidArb, + discordId: discordIdArb, + discordUsername: discordUsernameArb, + name: nameArb, + part: partArb, + blogUrl: blogUrlArb, + rssUrl: fc.option(fc.webUrl(), { nil: null }), + profileImageUrl: fc.option(fc.webUrl(), { nil: null }), + bio: fc.option(fc.string({ maxLength: 200 }), { nil: null }), + interests: fc.option(fc.array(fc.string({ minLength: 1, maxLength: 50 }), { maxLength: 5 }), { nil: null }), + resolution: fc.option(fc.string({ maxLength: 300 }), { nil: null }), + onboardingCompleted: fc.boolean(), + status: fc.constantFrom('active', 'dormant', 'withdrawn'), + dormantStartRound: fc.option(fc.integer({ min: 1, max: 52 }), { nil: null }), + dormantUsed: fc.boolean(), + joinedAt: fc.date(), + updatedAt: fc.date(), +}); + +/** + * Generate a valid Post object + */ +const postArb: fc.Arbitrary = fc.record({ + id: uuidArb, + memberId: uuidArb, + roundId: fc.option(fc.integer({ min: 1, max: 52 }), { nil: null }), + title: titleArb, + url: postUrlArb, + publishedAt: fc.date(), + description: descriptionArb, + collectedAt: fc.date(), +}); + +/** + * Generate a valid Round object + */ +const roundArb: fc.Arbitrary = fc.record({ + id: fc.integer({ min: 1, max: 100 }), + roundNumber: roundNumberArb, + startDate: fc.date().map(d => d.toISOString().split('T')[0]!), + endDate: fc.date().map(d => d.toISOString().split('T')[0]!), + graceEndDate: fc.date().map(d => d.toISOString().split('T')[0]!), + isCurrent: fc.boolean(), +}); + +/** + * Generate a valid PostNotificationInput + */ +const postNotificationInputArb: fc.Arbitrary = fc.record({ + post: postArb, + member: memberArb, + roundNumber: fc.option(roundNumberArb, { nil: null }), +}); + +/** + * Generate a valid AttendanceSummary + */ +const attendanceSummaryArb: fc.Arbitrary = fc.record({ + memberId: uuidArb, + discordId: discordIdArb, + discordUsername: discordUsernameArb, + name: nameArb, + status: fc.constantFrom( + AttendanceStatus.SUBMITTED, + AttendanceStatus.LATE, + AttendanceStatus.ABSENT, + AttendanceStatus.PENDING + ) as fc.Arbitrary, + postCount: fc.integer({ min: 0, max: 10 }), +}); + +// ============================================ +// Property Tests +// ============================================ + +describe('NotificationService Property Tests', () => { + /** + * **Feature: blog-study-discord-bot, Property 20: Notification Message Format** + * *For any* post notification, the message SHALL contain: post title, author Discord mention, + * post URL, and round number. + * **Validates: Requirements 7.2** + */ + describe('Property 20: Notification Message Format', () => { + it('should include post title in the embed', () => { + fc.assert( + fc.property(postNotificationInputArb, (input) => { + const embed = buildPostNotificationEmbed(input); + const embedData = embed.toJSON(); + + // Title should contain the post title + expect(embedData.title).toContain(input.post.title); + }), + { numRuns: 100 } + ); + }); + + it('should include post URL in the embed', () => { + fc.assert( + fc.property(postNotificationInputArb, (input) => { + const embed = buildPostNotificationEmbed(input); + const embedData = embed.toJSON(); + + // URL should be set to the post URL + expect(embedData.url).toBe(input.post.url); + }), + { numRuns: 100 } + ); + }); + + it('should include author Discord mention in the message content', () => { + fc.assert( + fc.property(postNotificationInputArb, (input) => { + const message = buildPostNotificationMessage(input); + + // Content should include Discord mention format + expect(message.content).toContain(`<@${input.member.discordId}>`); + }), + { numRuns: 100 } + ); + }); + + it('should include author username in the embed', () => { + fc.assert( + fc.property(postNotificationInputArb, (input) => { + const embed = buildPostNotificationEmbed(input); + const embedData = embed.toJSON(); + + // Author name should be set + expect(embedData.author?.name).toBe(input.member.discordUsername); + }), + { numRuns: 100 } + ); + }); + + it('should include round number when provided', () => { + fc.assert( + fc.property( + postNotificationInputArb.filter(input => input.roundNumber !== null), + (input) => { + const embed = buildPostNotificationEmbed(input); + const embedData = embed.toJSON(); + + // Should have a field with round number + const roundField = embedData.fields?.find(f => f.name.includes('회차')); + expect(roundField).toBeDefined(); + expect(roundField?.value).toContain(`${input.roundNumber}`); + } + ), + { numRuns: 100 } + ); + }); + + it('should not include round field when round number is null', () => { + fc.assert( + fc.property( + postNotificationInputArb.map(input => ({ ...input, roundNumber: null })), + (input) => { + const embed = buildPostNotificationEmbed(input); + const embedData = embed.toJSON(); + + // Should not have a round field + const roundField = embedData.fields?.find(f => f.name.includes('회차')); + expect(roundField).toBeUndefined(); + } + ), + { numRuns: 100 } + ); + }); + + it('should include member name and part in footer', () => { + fc.assert( + fc.property(postNotificationInputArb, (input) => { + const embed = buildPostNotificationEmbed(input); + const embedData = embed.toJSON(); + + // Footer should contain member name and part + expect(embedData.footer?.text).toContain(input.member.name); + expect(embedData.footer?.text).toContain(input.member.part); + }), + { numRuns: 100 } + ); + }); + + it('should include embeds and components in the message', () => { + fc.assert( + fc.property(postNotificationInputArb, (input) => { + const message = buildPostNotificationMessage(input); + + // Message should have embeds + expect(message.embeds).toBeDefined(); + expect(message.embeds?.length).toBeGreaterThan(0); + + // Message should have components (reaction buttons) + expect(message.components).toBeDefined(); + expect(message.components?.length).toBeGreaterThan(0); + }), + { numRuns: 100 } + ); + }); + + it('should truncate long descriptions', () => { + fc.assert( + fc.property( + postNotificationInputArb.map(input => ({ + ...input, + post: { + ...input.post, + description: 'x'.repeat(300), // Long description + }, + })), + (input) => { + const embed = buildPostNotificationEmbed(input); + const embedData = embed.toJSON(); + + // Description should be truncated + if (embedData.description) { + expect(embedData.description.length).toBeLessThanOrEqual(200); + } + } + ), + { numRuns: 50 } + ); + }); + }); + + /** + * **Feature: blog-study-discord-bot, Property 27: Round Report Content** + * *For any* round report, the content SHALL include: round number, submitted members list, + * late members list, absent members list, and MVP (member with most posts). + * **Validates: Requirements 10.2, 10.3** + */ + describe('Property 27: Round Report Content', () => { + it('should include round number in the report title', () => { + fc.assert( + fc.property( + roundArb, + fc.array(attendanceSummaryArb, { minLength: 0, maxLength: 10 }), + (round, summaries) => { + const reportData = calculateRoundReportData(round, summaries); + const embed = buildRoundReportEmbed(reportData); + const embedData = embed.toJSON(); + + // Title should contain round number + expect(embedData.title).toContain(`${round.roundNumber}`); + } + ), + { numRuns: 100 } + ); + }); + + it('should correctly categorize members by attendance status', () => { + fc.assert( + fc.property( + roundArb, + fc.array(attendanceSummaryArb, { minLength: 1, maxLength: 10 }), + (round, summaries) => { + const reportData = calculateRoundReportData(round, summaries); + + // Count members in each category + const expectedSubmitted = summaries.filter(s => s.status === AttendanceStatus.SUBMITTED).length; + const expectedLate = summaries.filter(s => s.status === AttendanceStatus.LATE).length; + const expectedAbsent = summaries.filter(s => s.status === AttendanceStatus.ABSENT).length; + + expect(reportData.submitted.length).toBe(expectedSubmitted); + expect(reportData.late.length).toBe(expectedLate); + expect(reportData.absent.length).toBe(expectedAbsent); + } + ), + { numRuns: 100 } + ); + }); + + it('should calculate correct statistics', () => { + fc.assert( + fc.property( + roundArb, + fc.array(attendanceSummaryArb, { minLength: 1, maxLength: 10 }), + (round, summaries) => { + const reportData = calculateRoundReportData(round, summaries); + + const total = summaries.length; + const expectedSubmissionRate = summaries.filter(s => s.status === AttendanceStatus.SUBMITTED).length / total; + const expectedLateRate = summaries.filter(s => s.status === AttendanceStatus.LATE).length / total; + const expectedAbsentRate = summaries.filter(s => s.status === AttendanceStatus.ABSENT).length / total; + + expect(reportData.submissionRate).toBeCloseTo(expectedSubmissionRate, 5); + expect(reportData.lateRate).toBeCloseTo(expectedLateRate, 5); + expect(reportData.absentRate).toBeCloseTo(expectedAbsentRate, 5); + expect(reportData.totalMembers).toBe(total); + } + ), + { numRuns: 100 } + ); + }); + + it('should identify MVP as member with most posts', () => { + fc.assert( + fc.property( + roundArb, + fc.array( + attendanceSummaryArb.filter(s => + s.status === AttendanceStatus.SUBMITTED || s.status === AttendanceStatus.LATE + ), + { minLength: 1, maxLength: 10 } + ), + (round, summaries) => { + // Ensure at least one member has posts + const summariesWithPosts = summaries.map((s, i) => ({ + ...s, + postCount: i === 0 ? Math.max(1, s.postCount) : s.postCount, + })); + + const reportData = calculateRoundReportData(round, summariesWithPosts); + + if (reportData.mvp) { + // MVP should have the highest post count among submitted/late members + const maxPostCount = Math.max( + ...summariesWithPosts + .filter(s => s.status === AttendanceStatus.SUBMITTED || s.status === AttendanceStatus.LATE) + .filter(s => s.postCount > 0) + .map(s => s.postCount) + ); + expect(reportData.mvp.postCount).toBe(maxPostCount); + } + } + ), + { numRuns: 100 } + ); + }); + + it('should have no MVP when no members have posts', () => { + fc.assert( + fc.property( + roundArb, + fc.array( + attendanceSummaryArb.map(s => ({ ...s, postCount: 0, status: AttendanceStatus.ABSENT as AttendanceStatusType })), + { minLength: 1, maxLength: 5 } + ), + (round, summaries) => { + const reportData = calculateRoundReportData(round, summaries); + + // No MVP when all members are absent or have 0 posts + expect(reportData.mvp).toBeNull(); + } + ), + { numRuns: 50 } + ); + }); + + it('should include statistics field in the embed', () => { + fc.assert( + fc.property( + roundArb, + fc.array(attendanceSummaryArb, { minLength: 1, maxLength: 10 }), + (round, summaries) => { + const reportData = calculateRoundReportData(round, summaries); + const embed = buildRoundReportEmbed(reportData); + const embedData = embed.toJSON(); + + // Should have a statistics field + const statsField = embedData.fields?.find(f => f.name.includes('통계')); + expect(statsField).toBeDefined(); + expect(statsField?.value).toContain('제출률'); + expect(statsField?.value).toContain('지각률'); + expect(statsField?.value).toContain('결석률'); + } + ), + { numRuns: 100 } + ); + }); + + it('should include round number in the message content', () => { + fc.assert( + fc.property( + roundArb, + fc.array(attendanceSummaryArb, { minLength: 0, maxLength: 5 }), + (round, summaries) => { + const reportData = calculateRoundReportData(round, summaries); + const message = buildRoundReportMessage(reportData); + + // Content should mention the round number + expect(message.content).toContain(`${round.roundNumber}`); + } + ), + { numRuns: 100 } + ); + }); + + it('should handle empty attendance list', () => { + fc.assert( + fc.property(roundArb, (round) => { + const reportData = calculateRoundReportData(round, []); + + expect(reportData.submitted).toHaveLength(0); + expect(reportData.late).toHaveLength(0); + expect(reportData.absent).toHaveLength(0); + expect(reportData.mvp).toBeNull(); + expect(reportData.totalMembers).toBe(0); + expect(reportData.submissionRate).toBe(0); + expect(reportData.lateRate).toBe(0); + expect(reportData.absentRate).toBe(0); + }), + { numRuns: 50 } + ); + }); + }); +}); diff --git a/packages/bot/src/services/notification.service.ts b/packages/bot/src/services/notification.service.ts new file mode 100644 index 0000000..89cbf1e --- /dev/null +++ b/packages/bot/src/services/notification.service.ts @@ -0,0 +1,461 @@ +/** + * Notification Service + * 새 글 알림 및 회차 리포트 서비스 + * Requirements: 7.1, 7.2, 7.3, 7.4, 10.1, 10.2, 10.3, 10.4 + */ + +import { + Client, + EmbedBuilder, + TextChannel, + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + type MessageCreateOptions, +} from 'discord.js'; +import type { Member, Post, Round, AttendanceStatusType } from '@blog-study/shared/db'; +import { AttendanceStatus } from '@blog-study/shared/db'; +import { getConfigValue, ConfigKeys } from './round.service'; + +/** + * Error codes for notification operations + */ +export const NotificationErrorCodes = { + CHANNEL_NOT_FOUND: 'E5001', + CHANNEL_NOT_CONFIGURED: 'E5002', + SEND_FAILED: 'E5003', +} as const; + +/** + * Custom error class for notification operations + */ +export class NotificationError extends Error { + constructor( + public code: string, + public userMessage: string, + message?: string + ) { + super(message || userMessage); + this.name = 'NotificationError'; + } +} + +/** + * Input for creating a new post notification + * Requirements: 7.2 - Notification message format + */ +export interface PostNotificationInput { + post: Post; + member: Member; + roundNumber: number | null; +} + +/** + * Build notification embed message for a new post + * Requirements: 7.2 - Include post title, author Discord mention, post URL, and round number + */ +export function buildPostNotificationEmbed(input: PostNotificationInput): EmbedBuilder { + const { post, member, roundNumber } = input; + + const embed = new EmbedBuilder() + .setColor(0x5865F2) // Discord blurple + .setTitle(`📝 ${post.title}`) + .setURL(post.url) + .setAuthor({ + name: member.discordUsername, + iconURL: member.profileImageUrl || undefined, + }) + .setTimestamp(post.publishedAt); + + // Add round number if available + if (roundNumber !== null) { + embed.addFields({ + name: '📅 회차', + value: `${roundNumber}회차`, + inline: true, + }); + } + + // Add description if available (truncate if too long) + if (post.description) { + const truncatedDesc = post.description.length > 200 + ? post.description.substring(0, 197) + '...' + : post.description; + embed.setDescription(truncatedDesc); + } + + embed.setFooter({ + text: `${member.name} • ${member.part}`, + }); + + return embed; +} + +/** + * Build reaction buttons for post notification + * Requirements: 7.4 - Add reaction buttons for engagement + */ +export function buildPostNotificationButtons(): ActionRowBuilder { + return new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId('post_like') + .setLabel('👍') + .setStyle(ButtonStyle.Secondary), + new ButtonBuilder() + .setCustomId('post_comment') + .setLabel('💬') + .setStyle(ButtonStyle.Secondary), + new ButtonBuilder() + .setCustomId('post_bookmark') + .setLabel('🔖') + .setStyle(ButtonStyle.Secondary) + ); +} + +/** + * Build the complete notification message + * Requirements: 7.2 - Include all required fields + */ +export function buildPostNotificationMessage(input: PostNotificationInput): MessageCreateOptions { + const { member } = input; + + return { + content: `<@${member.discordId}>님이 새 글을 발행했습니다! 🎉`, + embeds: [buildPostNotificationEmbed(input)], + components: [buildPostNotificationButtons()], + }; +} + + +/** + * Attendance summary for round report + */ +export interface AttendanceSummary { + memberId: string; + discordId: string; + discordUsername: string; + name: string; + status: AttendanceStatusType; + postCount: number; +} + +/** + * Round report data + * Requirements: 10.2 - Round report content + */ +export interface RoundReportData { + round: Round; + submitted: AttendanceSummary[]; + late: AttendanceSummary[]; + absent: AttendanceSummary[]; + mvp: AttendanceSummary | null; + totalMembers: number; + submissionRate: number; + lateRate: number; + absentRate: number; +} + +/** + * Build round report embed + * Requirements: 10.2 - Include round number, submission list, late list, absent list + * Requirements: 10.3 - Highlight MVP + */ +export function buildRoundReportEmbed(data: RoundReportData): EmbedBuilder { + const { round, submitted, late, absent, mvp, submissionRate, lateRate, absentRate } = data; + + const embed = new EmbedBuilder() + .setColor(0x57F287) // Green + .setTitle(`📊 ${round.roundNumber}회차 리포트`) + .setDescription(`${round.startDate} ~ ${round.endDate}`) + .setTimestamp(); + + // Statistics + embed.addFields({ + name: '📈 통계', + value: [ + `✅ 제출률: ${(submissionRate * 100).toFixed(1)}%`, + `⏰ 지각률: ${(lateRate * 100).toFixed(1)}%`, + `❌ 결석률: ${(absentRate * 100).toFixed(1)}%`, + ].join('\n'), + inline: false, + }); + + // MVP + if (mvp) { + embed.addFields({ + name: '🏆 MVP', + value: `<@${mvp.discordId}> (${mvp.postCount}개 작성)`, + inline: false, + }); + } + + // Submitted list + if (submitted.length > 0) { + const submittedList = submitted + .map((s) => `• <@${s.discordId}>`) + .join('\n'); + embed.addFields({ + name: `✅ 제출 (${submitted.length}명)`, + value: submittedList.length > 1024 ? submittedList.substring(0, 1021) + '...' : submittedList, + inline: true, + }); + } + + // Late list + if (late.length > 0) { + const lateList = late + .map((l) => `• <@${l.discordId}>`) + .join('\n'); + embed.addFields({ + name: `⏰ 지각 (${late.length}명)`, + value: lateList.length > 1024 ? lateList.substring(0, 1021) + '...' : lateList, + inline: true, + }); + } + + // Absent list + if (absent.length > 0) { + const absentList = absent + .map((a) => `• <@${a.discordId}>`) + .join('\n'); + embed.addFields({ + name: `❌ 결석 (${absent.length}명)`, + value: absentList.length > 1024 ? absentList.substring(0, 1021) + '...' : absentList, + inline: true, + }); + } + + return embed; +} + +/** + * Build round report message + * Requirements: 10.1 - Generate and send round report + */ +export function buildRoundReportMessage(data: RoundReportData): MessageCreateOptions { + return { + content: `📢 **${data.round.roundNumber}회차가 종료되었습니다!**`, + embeds: [buildRoundReportEmbed(data)], + }; +} + +/** + * Build round start announcement embed + * Requirements: 10.4 - Send round start announcement with deadline info + */ +export function buildRoundStartEmbed(round: Round): EmbedBuilder { + return new EmbedBuilder() + .setColor(0x5865F2) // Discord blurple + .setTitle(`🚀 ${round.roundNumber}회차 시작!`) + .setDescription('새로운 회차가 시작되었습니다. 열심히 글을 작성해주세요!') + .addFields( + { + name: '📅 기간', + value: `${round.startDate} ~ ${round.endDate}`, + inline: true, + }, + { + name: '⏰ 마감일', + value: round.endDate, + inline: true, + }, + { + name: '🕐 지각 마감', + value: `${round.graceEndDate} (벌금 3,000원)`, + inline: true, + } + ) + .setFooter({ + text: '마감일까지 블로그 글을 작성해주세요!', + }) + .setTimestamp(); +} + +/** + * Build round start announcement message + * Requirements: 10.4 - Send round start announcement + */ +export function buildRoundStartMessage(round: Round): MessageCreateOptions { + return { + content: '@everyone 새로운 회차가 시작되었습니다! 📝', + embeds: [buildRoundStartEmbed(round)], + }; +} + +/** + * Calculate round report data from attendance records + */ +export function calculateRoundReportData( + round: Round, + attendanceSummaries: AttendanceSummary[] +): RoundReportData { + const submitted = attendanceSummaries.filter( + (a) => a.status === AttendanceStatus.SUBMITTED + ); + const late = attendanceSummaries.filter( + (a) => a.status === AttendanceStatus.LATE + ); + const absent = attendanceSummaries.filter( + (a) => a.status === AttendanceStatus.ABSENT + ); + + const totalMembers = attendanceSummaries.length; + const submissionRate = totalMembers > 0 ? submitted.length / totalMembers : 0; + const lateRate = totalMembers > 0 ? late.length / totalMembers : 0; + const absentRate = totalMembers > 0 ? absent.length / totalMembers : 0; + + // Find MVP (member with most posts in this round) + const allWithPosts = [...submitted, ...late].filter((a) => a.postCount > 0); + const mvp = allWithPosts.length > 0 + ? allWithPosts.reduce((max, curr) => + curr.postCount > max.postCount ? curr : max + ) + : null; + + return { + round, + submitted, + late, + absent, + mvp, + totalMembers, + submissionRate, + lateRate, + absentRate, + }; +} + +/** + * Notification service class for sending Discord notifications + */ +export class NotificationService { + private client: Client; + + constructor(client: Client) { + this.client = client; + } + + /** + * Get the announcement channel + * Requirements: 7.3 - Check if announcement channel is configured + */ + async getAnnouncementChannel(): Promise { + const channelId = await getConfigValue(ConfigKeys.ANNOUNCEMENT_CHANNEL); + + if (!channelId) { + console.warn('[NotificationService] Announcement channel not configured'); + return null; + } + + try { + const channel = await this.client.channels.fetch(channelId); + if (channel instanceof TextChannel) { + return channel; + } + console.warn('[NotificationService] Announcement channel is not a text channel'); + return null; + } catch (error) { + console.error('[NotificationService] Failed to fetch announcement channel:', error); + return null; + } + } + + /** + * Send a new post notification + * Requirements: 7.1 - Send notification to designated channel + * Requirements: 7.3 - Log error if channel not configured + */ + async sendPostNotification(input: PostNotificationInput): Promise { + const channel = await this.getAnnouncementChannel(); + + if (!channel) { + console.error('[NotificationService] Cannot send post notification: channel not configured'); + return false; + } + + try { + const message = buildPostNotificationMessage(input); + await channel.send(message); + console.log(`[NotificationService] Sent post notification for: ${input.post.title}`); + return true; + } catch (error) { + console.error('[NotificationService] Failed to send post notification:', error); + return false; + } + } + + /** + * Send a round report + * Requirements: 10.1 - Send round report to designated channel + */ + async sendRoundReport(data: RoundReportData): Promise { + const channel = await this.getAnnouncementChannel(); + + if (!channel) { + console.error('[NotificationService] Cannot send round report: channel not configured'); + return false; + } + + try { + const message = buildRoundReportMessage(data); + await channel.send(message); + console.log(`[NotificationService] Sent round report for round ${data.round.roundNumber}`); + return true; + } catch (error) { + console.error('[NotificationService] Failed to send round report:', error); + return false; + } + } + + /** + * Send a round start announcement + * Requirements: 10.4 - Send round start announcement + */ + async sendRoundStartAnnouncement(round: Round): Promise { + const channel = await this.getAnnouncementChannel(); + + if (!channel) { + console.error('[NotificationService] Cannot send round start: channel not configured'); + return false; + } + + try { + const message = buildRoundStartMessage(round); + await channel.send(message); + console.log(`[NotificationService] Sent round start announcement for round ${round.roundNumber}`); + return true; + } catch (error) { + console.error('[NotificationService] Failed to send round start announcement:', error); + return false; + } + } +} + +// Singleton instance +let notificationServiceInstance: NotificationService | null = null; + +/** + * Initialize the NotificationService with a Discord client + */ +export function initNotificationService(client: Client): NotificationService { + notificationServiceInstance = new NotificationService(client); + return notificationServiceInstance; +} + +/** + * Get the NotificationService singleton instance + * @throws Error if not initialized + */ +export function getNotificationService(): NotificationService { + if (!notificationServiceInstance) { + throw new Error('NotificationService not initialized. Call initNotificationService first.'); + } + return notificationServiceInstance; +} + +/** + * Reset the singleton (useful for testing) + */ +export function resetNotificationService(): void { + notificationServiceInstance = null; +} diff --git a/packages/bot/src/services/post.service.property.test.ts b/packages/bot/src/services/post.service.property.test.ts new file mode 100644 index 0000000..297216a --- /dev/null +++ b/packages/bot/src/services/post.service.property.test.ts @@ -0,0 +1,465 @@ +/** + * Property-Based Tests for PostService + * Tests correctness properties for post management operations + * + * These tests verify the business logic of post operations using + * pure functions extracted from the service. + */ + +import { describe, it, expect } from 'vitest'; +import * as fc from 'fast-check'; +import type { Post } from '@blog-study/shared/db'; + +/** + * Generate valid UUIDs + */ +const uuidArb = fc.uuid(); + +/** + * Generate valid post titles + */ +const titleArb = fc.string({ minLength: 1, maxLength: 500 }); + +/** + * Generate valid post URLs + */ +const postUrlArb = fc.webUrl(); + +/** + * Generate valid descriptions + */ +const descriptionArb = fc.option(fc.string({ maxLength: 1000 }), { nil: null }); + +/** + * Generate valid round IDs + */ +const roundIdArb = fc.option(fc.integer({ min: 1, max: 52 }), { nil: null }); + +/** + * Generate a valid post input + */ +const postInputArb = fc.record({ + memberId: uuidArb, + roundId: roundIdArb, + title: titleArb, + url: postUrlArb, + publishedAt: fc.date(), + description: descriptionArb, +}); + +// ============================================ +// Pure Business Logic Functions for Testing +// ============================================ + +/** + * Simulates the post storage with duplicate detection + * This is a pure function that models the database behavior + */ +interface PostStore { + posts: Map; +} + +function createPostStore(): PostStore { + return { posts: new Map() }; +} + +function existsByUrl(store: PostStore, url: string): boolean { + for (const post of store.posts.values()) { + if (post.url === url) { + return true; + } + } + return false; +} + +function getByUrl(store: PostStore, url: string): Post | null { + for (const post of store.posts.values()) { + if (post.url === url) { + return post; + } + } + return null; +} + +interface CreatePostResult { + post: Post; + isNew: boolean; +} + +function createPost( + store: PostStore, + input: { + memberId: string; + roundId: number | null; + title: string; + url: string; + publishedAt: Date; + description?: string | null; + } +): CreatePostResult { + // Check for duplicate URL first + const existing = getByUrl(store, input.url); + if (existing) { + return { post: existing, isNew: false }; + } + + // Create new post with all required fields + const newPost: Post = { + id: crypto.randomUUID(), + memberId: input.memberId, + roundId: input.roundId, + title: input.title, + url: input.url, + publishedAt: input.publishedAt, + description: input.description ?? null, + collectedAt: new Date(), + }; + + store.posts.set(newPost.id, newPost); + return { post: newPost, isNew: true }; +} + +function getByMember(store: PostStore, memberId: string): Post[] { + const result: Post[] = []; + for (const post of store.posts.values()) { + if (post.memberId === memberId) { + result.push(post); + } + } + return result; +} + +function getByRound(store: PostStore, roundId: number): Post[] { + const result: Post[] = []; + for (const post of store.posts.values()) { + if (post.roundId === roundId) { + result.push(post); + } + } + return result; +} + +function countByMember(store: PostStore, memberId: string): number { + return getByMember(store, memberId).length; +} + +/** + * Validates that a post has all required fields + * Requirements: 6.3, 6.6 + */ +function validatePostCompleteness(post: Post): { + isComplete: boolean; + missingFields: string[]; +} { + const missingFields: string[] = []; + + if (!post.id) missingFields.push('id'); + if (!post.memberId) missingFields.push('memberId'); + if (!post.title) missingFields.push('title'); + if (!post.url) missingFields.push('url'); + if (!post.publishedAt) missingFields.push('publishedAt'); + // roundId can be null for posts outside study period + // description is optional + // collectedAt should be set automatically + + return { + isComplete: missingFields.length === 0, + missingFields, + }; +} + +// ============================================ +// Property Tests +// ============================================ + +describe('PostService Property Tests', () => { + /** + * **Feature: blog-study-discord-bot, Property 17: Duplicate Post Prevention** + * *For any* post URL that already exists in the database, attempting to store it + * again SHALL be skipped without error. + * **Validates: Requirements 6.4** + */ + describe('Property 17: Duplicate Post Prevention', () => { + it('should return existing post when URL already exists', () => { + fc.assert( + fc.property(postInputArb, (input) => { + const store = createPostStore(); + + // Create first post + const result1 = createPost(store, input); + expect(result1.isNew).toBe(true); + + // Try to create duplicate with same URL + const result2 = createPost(store, { + ...input, + title: 'Different Title', + description: 'Different Description', + }); + + // Should return existing post, not create new one + expect(result2.isNew).toBe(false); + expect(result2.post.id).toBe(result1.post.id); + expect(result2.post.url).toBe(input.url); + }), + { numRuns: 100 } + ); + }); + + it('should not increase post count when duplicate URL is added', () => { + fc.assert( + fc.property(postInputArb, (input) => { + const store = createPostStore(); + + // Create first post + createPost(store, input); + const countBefore = store.posts.size; + + // Try to create duplicate + createPost(store, input); + const countAfter = store.posts.size; + + // Count should remain the same + expect(countAfter).toBe(countBefore); + }), + { numRuns: 100 } + ); + }); + + it('should allow posts with different URLs', () => { + fc.assert( + fc.property( + postInputArb, + postInputArb.filter(p => p.url !== ''), + (input1, input2) => { + // Ensure different URLs + fc.pre(input1.url !== input2.url); + + const store = createPostStore(); + + const result1 = createPost(store, input1); + const result2 = createPost(store, input2); + + // Both should be new posts + expect(result1.isNew).toBe(true); + expect(result2.isNew).toBe(true); + expect(result1.post.id).not.toBe(result2.post.id); + expect(store.posts.size).toBe(2); + } + ), + { numRuns: 100 } + ); + }); + + it('should correctly report URL existence', () => { + fc.assert( + fc.property(postInputArb, postUrlArb, (input, otherUrl) => { + const store = createPostStore(); + + // Before creating post, URL should not exist + expect(existsByUrl(store, input.url)).toBe(false); + + // Create post + createPost(store, input); + + // After creating, URL should exist + expect(existsByUrl(store, input.url)).toBe(true); + + // Other URL should not exist (unless it's the same) + if (otherUrl !== input.url) { + expect(existsByUrl(store, otherUrl)).toBe(false); + } + }), + { numRuns: 100 } + ); + }); + }); + + /** + * **Feature: blog-study-discord-bot, Property 16: Post Storage Completeness** + * *For any* new post detected from RSS, the stored record SHALL contain: + * title, URL, published date, member_id, and calculated round number. + * **Validates: Requirements 6.3, 6.6** + */ + describe('Property 16: Post Storage Completeness', () => { + it('should store all required fields when creating a post', () => { + fc.assert( + fc.property(postInputArb, (input) => { + const store = createPostStore(); + const result = createPost(store, input); + + // Verify all required fields are present + const validation = validatePostCompleteness(result.post); + expect(validation.isComplete).toBe(true); + expect(validation.missingFields).toHaveLength(0); + }), + { numRuns: 100 } + ); + }); + + it('should preserve input values in stored post', () => { + fc.assert( + fc.property(postInputArb, (input) => { + const store = createPostStore(); + const result = createPost(store, input); + + // All input values should be preserved + expect(result.post.memberId).toBe(input.memberId); + expect(result.post.roundId).toBe(input.roundId); + expect(result.post.title).toBe(input.title); + expect(result.post.url).toBe(input.url); + expect(result.post.publishedAt).toEqual(input.publishedAt); + expect(result.post.description).toBe(input.description ?? null); + }), + { numRuns: 100 } + ); + }); + + it('should set collectedAt timestamp automatically', () => { + fc.assert( + fc.property(postInputArb, (input) => { + const store = createPostStore(); + const beforeCreate = new Date(); + const result = createPost(store, input); + const afterCreate = new Date(); + + // collectedAt should be set and within the creation window + expect(result.post.collectedAt).toBeDefined(); + expect(result.post.collectedAt!.getTime()).toBeGreaterThanOrEqual(beforeCreate.getTime()); + expect(result.post.collectedAt!.getTime()).toBeLessThanOrEqual(afterCreate.getTime()); + }), + { numRuns: 100 } + ); + }); + + it('should generate unique ID for each new post', () => { + fc.assert( + fc.property( + fc.array(postInputArb, { minLength: 2, maxLength: 10 }), + (inputs) => { + const store = createPostStore(); + const ids = new Set(); + + // Create posts with unique URLs + const uniqueInputs = inputs.map((input, i) => ({ + ...input, + url: `${input.url}/${i}`, // Ensure unique URLs + })); + + for (const input of uniqueInputs) { + const result = createPost(store, input); + if (result.isNew) { + // Each new post should have a unique ID + expect(ids.has(result.post.id)).toBe(false); + ids.add(result.post.id); + } + } + } + ), + { numRuns: 50 } + ); + }); + }); + + /** + * Additional property tests for post retrieval operations + */ + describe('Post Retrieval Properties', () => { + it('should correctly retrieve posts by member', () => { + fc.assert( + fc.property( + uuidArb, + fc.array(postInputArb, { minLength: 1, maxLength: 5 }), + (memberId, inputs) => { + const store = createPostStore(); + + // Create posts for the target member + const memberInputs = inputs.map((input, i) => ({ + ...input, + memberId, + url: `${input.url}/${i}`, // Ensure unique URLs + })); + + for (const input of memberInputs) { + createPost(store, input); + } + + // Retrieve posts by member + const memberPosts = getByMember(store, memberId); + + // All retrieved posts should belong to the member + for (const post of memberPosts) { + expect(post.memberId).toBe(memberId); + } + + // Count should match + expect(memberPosts.length).toBe(memberInputs.length); + } + ), + { numRuns: 50 } + ); + }); + + it('should correctly retrieve posts by round', () => { + fc.assert( + fc.property( + fc.integer({ min: 1, max: 10 }), + fc.array(postInputArb, { minLength: 1, maxLength: 5 }), + (roundId, inputs) => { + const store = createPostStore(); + + // Create posts for the target round + const roundInputs = inputs.map((input, i) => ({ + ...input, + roundId, + url: `${input.url}/${i}`, // Ensure unique URLs + })); + + for (const input of roundInputs) { + createPost(store, input); + } + + // Retrieve posts by round + const roundPosts = getByRound(store, roundId); + + // All retrieved posts should belong to the round + for (const post of roundPosts) { + expect(post.roundId).toBe(roundId); + } + + // Count should match + expect(roundPosts.length).toBe(roundInputs.length); + } + ), + { numRuns: 50 } + ); + }); + + it('should correctly count posts by member', () => { + fc.assert( + fc.property( + uuidArb, + fc.integer({ min: 0, max: 10 }), + (memberId, numPosts) => { + const store = createPostStore(); + + // Create specified number of posts for the member + for (let i = 0; i < numPosts; i++) { + createPost(store, { + memberId, + roundId: 1, + title: `Post ${i}`, + url: `https://example.com/post/${memberId}/${i}`, + publishedAt: new Date(), + description: null, + }); + } + + // Count should match + expect(countByMember(store, memberId)).toBe(numPosts); + } + ), + { numRuns: 50 } + ); + }); + }); +}); diff --git a/packages/bot/src/services/post.service.ts b/packages/bot/src/services/post.service.ts new file mode 100644 index 0000000..05f06ba --- /dev/null +++ b/packages/bot/src/services/post.service.ts @@ -0,0 +1,202 @@ +/** + * Post Service + * 블로그 글 관리 서비스 + * Requirements: 6.3, 6.4, 6.6 + */ + +import { eq, and, count } from 'drizzle-orm'; +import { + getDb, + posts, + type Post, + type NewPost, +} from '@blog-study/shared/db'; + +/** + * Error codes for post operations + */ +export const PostErrorCodes = { + POST_NOT_FOUND: 'E3001', + DUPLICATE_URL: 'E3002', + INVALID_POST_DATA: 'E3003', + MEMBER_NOT_FOUND: 'E3004', +} as const; + +/** + * Custom error class for post operations + */ +export class PostError extends Error { + constructor( + public code: string, + public userMessage: string, + message?: string + ) { + super(message || userMessage); + this.name = 'PostError'; + } +} + +/** + * Input for creating a new post + * Requirements: 6.3 - Post storage completeness + */ +export interface CreatePostInput { + memberId: string; + roundId: number | null; + title: string; + url: string; + publishedAt: Date; + description?: string | null; +} + +/** + * Result of creating a post + */ +export interface CreatePostResult { + post: Post; + isNew: boolean; +} + +/** + * Post service for managing blog posts + */ +export class PostService { + private db = getDb(); + + /** + * Create a new post + * Requirements: 6.3 - Store post with all required fields + * Requirements: 6.4 - Skip duplicate URLs + * Requirements: 6.6 - Include round number based on published date + * + * @returns CreatePostResult with isNew=false if duplicate + */ + async create(input: CreatePostInput): Promise { + // Check for duplicate URL first + const existing = await this.getByUrl(input.url); + if (existing) { + return { post: existing, isNew: false }; + } + + // Create new post with all required fields + const newPost: NewPost = { + memberId: input.memberId, + roundId: input.roundId, + title: input.title, + url: input.url, + publishedAt: input.publishedAt, + description: input.description ?? null, + }; + + const [created] = await this.db.insert(posts).values(newPost).returning(); + return { post: created!, isNew: true }; + } + + /** + * Check if a post with the given URL already exists + * Requirements: 6.4 - Duplicate post prevention + */ + async existsByUrl(url: string): Promise { + const result = await this.db + .select({ count: count() }) + .from(posts) + .where(eq(posts.url, url)); + + return (result[0]?.count ?? 0) > 0; + } + + /** + * Get a post by URL + */ + async getByUrl(url: string): Promise { + const [post] = await this.db + .select() + .from(posts) + .where(eq(posts.url, url)) + .limit(1); + + return post || null; + } + + /** + * Get all posts by a member + */ + async getByMember(memberId: string): Promise { + return this.db + .select() + .from(posts) + .where(eq(posts.memberId, memberId)); + } + + /** + * Get all posts for a specific round + */ + async getByRound(roundId: number): Promise { + return this.db + .select() + .from(posts) + .where(eq(posts.roundId, roundId)); + } + + /** + * Get posts by member and round + */ + async getByMemberAndRound(memberId: string, roundId: number): Promise { + return this.db + .select() + .from(posts) + .where(and(eq(posts.memberId, memberId), eq(posts.roundId, roundId))); + } + + /** + * Count posts by a member + */ + async countByMember(memberId: string): Promise { + const result = await this.db + .select({ count: count() }) + .from(posts) + .where(eq(posts.memberId, memberId)); + + return result[0]?.count ?? 0; + } + + /** + * Get post by ID + */ + async getById(id: string): Promise { + const [post] = await this.db + .select() + .from(posts) + .where(eq(posts.id, id)) + .limit(1); + + return post || null; + } + + /** + * Get all posts + */ + async getAll(): Promise { + return this.db.select().from(posts); + } +} + +// Singleton instance +let postServiceInstance: PostService | null = null; + +/** + * Get the PostService singleton instance + */ +export function getPostService(): PostService { + if (!postServiceInstance) { + postServiceInstance = new PostService(); + } + return postServiceInstance; +} + +/** + * Reset the singleton (useful for testing) + */ +export function resetPostService(): void { + postServiceInstance = null; +} diff --git a/packages/bot/src/services/round.service.ts b/packages/bot/src/services/round.service.ts new file mode 100644 index 0000000..0311bd3 --- /dev/null +++ b/packages/bot/src/services/round.service.ts @@ -0,0 +1,382 @@ +/** + * Round Service + * 회차 관리 서비스 + * Requirements: 5.1, 5.4, 15.1, 15.2 + */ + +import { eq, and, lte, gte, desc, sql } from 'drizzle-orm'; +import { + getDb, + rounds, + config, + type Round, + type NewRound, +} from '@blog-study/shared/db'; +import { + isGracePeriod as checkIsGracePeriod, + isDeadlinePassed as checkIsDeadlinePassed, + isGracePeriodEnded as checkIsGracePeriodEnded, + generateAllRoundDates, + type RoundDates, +} from '@blog-study/shared/utils'; + +/** + * Error codes for round operations + */ +export const RoundErrorCodes = { + ROUND_NOT_FOUND: 'E2001', + NO_CURRENT_ROUND: 'E2002', + INVALID_START_DATE: 'E2003', + INVALID_TOTAL_ROUNDS: 'E2004', + STUDY_NOT_STARTED: 'E2005', + CONFIG_NOT_FOUND: 'E2006', + ROUNDS_ALREADY_EXIST: 'E2007', +} as const; + +/** + * Custom error class for round operations + */ +export class RoundError extends Error { + constructor( + public code: string, + public userMessage: string, + message?: string + ) { + super(message || userMessage); + this.name = 'RoundError'; + } +} + +/** + * Config keys for study settings + */ +export const ConfigKeys = { + STUDY_START_DATE: 'study_start_date', + TOTAL_ROUNDS: 'total_rounds', + ANNOUNCEMENT_CHANNEL: 'announcement_channel', + CURATION_CHANNEL: 'curation_channel', +} as const; + +/** + * Format date to YYYY-MM-DD string for database storage + */ +function formatDateToString(date: Date): string { + const result = date.toISOString().split('T')[0]; + if (!result) { + throw new Error('Invalid date format'); + } + return result; +} + +/** + * Parse date string from database to Date object + */ +function parseDateString(dateStr: string): Date { + return new Date(dateStr + 'T00:00:00.000Z'); +} + + +/** + * Get the current round based on today's date + * Requirements: 5.1 + */ +export async function getCurrentRound(): Promise { + const db = getDb(); + + // First try to get the round marked as current + const currentRound = await db + .select() + .from(rounds) + .where(eq(rounds.isCurrent, true)) + .limit(1); + + if (currentRound.length > 0 && currentRound[0]) { + return currentRound[0]; + } + + // If no round is marked as current, find by date + const today = new Date(); + const todayStr = formatDateToString(today); + + const roundByDate = await db + .select() + .from(rounds) + .where( + and( + lte(rounds.startDate, todayStr), + gte(rounds.graceEndDate, todayStr) + ) + ) + .limit(1); + + if (roundByDate.length > 0 && roundByDate[0]) { + return roundByDate[0]; + } + + // If still no round found, get the latest round + const latestRound = await db + .select() + .from(rounds) + .orderBy(desc(rounds.roundNumber)) + .limit(1); + + if (latestRound.length === 0 || !latestRound[0]) { + throw new RoundError( + RoundErrorCodes.NO_CURRENT_ROUND, + '현재 진행 중인 회차가 없습니다. 스터디 설정을 확인해주세요.' + ); + } + + return latestRound[0]; +} + +/** + * Get round by specific date + * Requirements: 5.1 + */ +export async function getRoundByDate(date: Date): Promise { + const db = getDb(); + const dateStr = formatDateToString(date); + + const result = await db + .select() + .from(rounds) + .where( + and( + lte(rounds.startDate, dateStr), + gte(rounds.graceEndDate, dateStr) + ) + ) + .limit(1); + + return result.length > 0 && result[0] ? result[0] : null; +} + +/** + * Get round by round number + */ +export async function getRoundByNumber(roundNumber: number): Promise { + const db = getDb(); + + const result = await db + .select() + .from(rounds) + .where(eq(rounds.roundNumber, roundNumber)) + .limit(1); + + return result.length > 0 && result[0] ? result[0] : null; +} + +/** + * Get all rounds + */ +export async function getAllRounds(): Promise { + const db = getDb(); + + return db + .select() + .from(rounds) + .orderBy(rounds.roundNumber); +} + +/** + * Create rounds for the study + * Requirements: 15.1, 15.2 + * + * @param startDate - Study start date (should be a Monday) + * @param totalRounds - Total number of rounds to create + */ +export async function createRounds(startDate: Date, totalRounds: number): Promise { + if (totalRounds < 1 || totalRounds > 52) { + throw new RoundError( + RoundErrorCodes.INVALID_TOTAL_ROUNDS, + '총 회차 수는 1~52 사이여야 합니다.' + ); + } + + const db = getDb(); + + // Check if rounds already exist + const existingRounds = await db.select().from(rounds).limit(1); + if (existingRounds.length > 0) { + throw new RoundError( + RoundErrorCodes.ROUNDS_ALREADY_EXIST, + '이미 회차가 생성되어 있습니다. 기존 회차를 삭제한 후 다시 시도해주세요.' + ); + } + + // Generate all round dates using the utility function + const roundDatesList = generateAllRoundDates(startDate, totalRounds); + + // Prepare round records for insertion + const roundRecords: NewRound[] = roundDatesList.map((rd, index) => ({ + roundNumber: rd.roundNumber, + startDate: formatDateToString(rd.startDate), + endDate: formatDateToString(rd.endDate), + graceEndDate: formatDateToString(rd.graceEndDate), + isCurrent: index === 0, // First round is current by default + })); + + // Insert all rounds + const insertedRounds = await db + .insert(rounds) + .values(roundRecords) + .returning(); + + // Save config - use sql template for the value in onConflictDoUpdate + const startDateStr = formatDateToString(startDate); + const totalRoundsStr = totalRounds.toString(); + + await db + .insert(config) + .values({ key: ConfigKeys.STUDY_START_DATE, value: startDateStr }) + .onConflictDoUpdate({ + target: config.key, + set: { value: sql`excluded.value`, updatedAt: new Date() }, + }); + + await db + .insert(config) + .values({ key: ConfigKeys.TOTAL_ROUNDS, value: totalRoundsStr }) + .onConflictDoUpdate({ + target: config.key, + set: { value: sql`excluded.value`, updatedAt: new Date() }, + }); + + return insertedRounds; +} + +/** + * Delete all rounds (for re-initialization) + */ +export async function deleteAllRounds(): Promise { + const db = getDb(); + await db.delete(rounds); +} + +/** + * Update current round marker + * Sets the specified round as current and unsets all others + */ +export async function setCurrentRound(roundNumber: number): Promise { + const db = getDb(); + + // First, unset all current flags + await db + .update(rounds) + .set({ isCurrent: false }) + .where(eq(rounds.isCurrent, true)); + + // Set the specified round as current + const result = await db + .update(rounds) + .set({ isCurrent: true }) + .where(eq(rounds.roundNumber, roundNumber)) + .returning(); + + if (result.length === 0 || !result[0]) { + throw new RoundError( + RoundErrorCodes.ROUND_NOT_FOUND, + `${roundNumber}회차를 찾을 수 없습니다.` + ); + } + + return result[0]; +} + + +/** + * Check if the round deadline has passed + * Requirements: 5.4 + */ +export function isDeadlinePassed(round: Round, currentDate: Date = new Date()): boolean { + const roundDates: RoundDates = { + roundNumber: round.roundNumber, + startDate: parseDateString(round.startDate), + endDate: parseDateString(round.endDate), + graceEndDate: parseDateString(round.graceEndDate), + }; + // Set endDate to end of day for proper comparison + roundDates.endDate.setHours(23, 59, 59, 999); + + return checkIsDeadlinePassed(roundDates, currentDate); +} + +/** + * Check if currently in grace period + * Requirements: 5.4 + */ +export function isGracePeriod(round: Round, currentDate: Date = new Date()): boolean { + const roundDates: RoundDates = { + roundNumber: round.roundNumber, + startDate: parseDateString(round.startDate), + endDate: parseDateString(round.endDate), + graceEndDate: parseDateString(round.graceEndDate), + }; + // Set times for proper comparison + roundDates.endDate.setHours(23, 59, 59, 999); + roundDates.graceEndDate.setHours(23, 59, 59, 999); + + return checkIsGracePeriod(roundDates, currentDate); +} + +/** + * Check if grace period has ended + */ +export function isGracePeriodEnded(round: Round, currentDate: Date = new Date()): boolean { + const roundDates: RoundDates = { + roundNumber: round.roundNumber, + startDate: parseDateString(round.startDate), + endDate: parseDateString(round.endDate), + graceEndDate: parseDateString(round.graceEndDate), + }; + roundDates.graceEndDate.setHours(23, 59, 59, 999); + + return checkIsGracePeriodEnded(roundDates, currentDate); +} + +/** + * Get config value by key + */ +export async function getConfigValue(key: string): Promise { + const db = getDb(); + + const result = await db + .select() + .from(config) + .where(eq(config.key, key)) + .limit(1); + + return result.length > 0 && result[0] ? result[0].value : null; +} + +/** + * Set config value + */ +export async function setConfigValue(key: string, value: string): Promise { + const db = getDb(); + + await db + .insert(config) + .values({ key, value }) + .onConflictDoUpdate({ + target: config.key, + set: { value: sql`excluded.value`, updatedAt: new Date() }, + }); +} + +/** + * Get study start date from config + */ +export async function getStudyStartDate(): Promise { + const value = await getConfigValue(ConfigKeys.STUDY_START_DATE); + return value ? parseDateString(value) : null; +} + +/** + * Get total rounds from config + */ +export async function getTotalRounds(): Promise { + const value = await getConfigValue(ConfigKeys.TOTAL_ROUNDS); + return value ? parseInt(value, 10) : null; +} \ No newline at end of file diff --git a/packages/bot/src/services/rss.service.property.test.ts b/packages/bot/src/services/rss.service.property.test.ts new file mode 100644 index 0000000..2192f1f --- /dev/null +++ b/packages/bot/src/services/rss.service.property.test.ts @@ -0,0 +1,242 @@ +/** + * Property-Based Tests for RSS Service + * **Feature: blog-study-discord-bot** + */ + +import { describe, it, expect } from 'vitest'; +import * as fc from 'fast-check'; +import { + constructRssUrl, + extractRssFromHtml, + parseRssDate, +} from './rss.service'; + +describe('RSS Service Property Tests', () => { + /** + * **Feature: blog-study-discord-bot, Property 28: RSS URL Construction by Platform** + * *For any* blog URL from known platforms (Velog, Tistory, Medium), + * the RSS URL SHALL be correctly constructed using the platform-specific pattern. + * **Validates: Requirements 11.1, 11.2, 11.3** + */ + describe('Property 28: RSS URL Construction by Platform', () => { + it('should construct correct RSS URL for Velog blogs', () => { + fc.assert( + fc.property( + fc.stringMatching(/^[\w][\w-]{0,29}$/), // Valid username pattern + (username) => { + const blogUrl = `https://velog.io/@${username}`; + const rssUrl = constructRssUrl(blogUrl, 'velog'); + + expect(rssUrl).toBe(`https://v2.velog.io/rss/@${username}`); + } + ), + { numRuns: 100 } + ); + }); + + it('should construct correct RSS URL for Tistory blogs', () => { + fc.assert( + fc.property( + fc.stringMatching(/^[\w][\w-]{0,29}$/), // Valid blog name pattern + (blogName) => { + const blogUrl = `https://${blogName}.tistory.com`; + const rssUrl = constructRssUrl(blogUrl, 'tistory'); + + // URL hostnames are case-insensitive and normalized to lowercase + expect(rssUrl).toBe(`https://${blogName.toLowerCase()}.tistory.com/rss`); + } + ), + { numRuns: 100 } + ); + }); + + it('should construct correct RSS URL for Medium blogs', () => { + fc.assert( + fc.property( + fc.stringMatching(/^[\w][\w-]{0,29}$/), // Valid username pattern + (username) => { + const blogUrl = `https://medium.com/@${username}`; + const rssUrl = constructRssUrl(blogUrl, 'medium'); + + expect(rssUrl).toBe(`https://medium.com/feed/@${username}`); + } + ), + { numRuns: 100 } + ); + }); + + it('should return null for unknown platform', () => { + fc.assert( + fc.property( + fc.webUrl().filter(url => + !url.includes('velog.io') && + !url.includes('tistory.com') && + !url.includes('medium.com') + ), + (url) => { + const rssUrl = constructRssUrl(url, 'unknown'); + expect(rssUrl).toBeNull(); + } + ), + { numRuns: 100 } + ); + }); + + it('should handle trailing slashes in blog URLs', () => { + fc.assert( + fc.property( + fc.stringMatching(/^[\w][\w-]{0,29}$/), + fc.boolean(), + (username, hasTrailingSlash) => { + const blogUrl = `https://velog.io/@${username}${hasTrailingSlash ? '/' : ''}`; + const rssUrl = constructRssUrl(blogUrl, 'velog'); + + expect(rssUrl).toBe(`https://v2.velog.io/rss/@${username}`); + } + ), + { numRuns: 100 } + ); + }); + }); + + /** + * **Feature: blog-study-discord-bot, Property 30: RSS Parsing Extraction** + * *For any* valid RSS feed, parsing SHALL extract: title, link, published date, + * and description from each item. + * **Validates: Requirements 12.1** + */ + describe('Property 30: RSS Parsing Extraction', () => { + it('should extract RSS link from HTML with rss+xml type', () => { + fc.assert( + fc.property( + fc.webUrl(), + (rssUrl) => { + const html = ` + + + + + + + + `; + + const extracted = extractRssFromHtml(html); + expect(extracted).toBe(rssUrl); + } + ), + { numRuns: 100 } + ); + }); + + it('should extract Atom feed link as fallback', () => { + fc.assert( + fc.property( + fc.webUrl(), + (atomUrl) => { + const html = ` + + + + + + + + `; + + const extracted = extractRssFromHtml(html); + expect(extracted).toBe(atomUrl); + } + ), + { numRuns: 100 } + ); + }); + + it('should return null when no RSS link is present', () => { + fc.assert( + fc.property( + fc.string(), + (title) => { + const html = ` + + + ${title} + + + `; + + const extracted = extractRssFromHtml(html); + expect(extracted).toBeNull(); + } + ), + { numRuns: 100 } + ); + }); + }); + + /** + * **Feature: blog-study-discord-bot, Property 33: Date Format Parsing** + * *For any* date string in RFC 822 or ISO 8601 format, parsing SHALL produce + * a valid UTC timestamp. + * **Validates: Requirements 12.5** + */ + describe('Property 33: Date Format Parsing', () => { + it('should parse ISO 8601 date strings', () => { + fc.assert( + fc.property( + fc.date({ min: new Date('2000-01-01'), max: new Date('2030-12-31') }), + (date) => { + const isoString = date.toISOString(); + const parsed = parseRssDate(isoString); + + expect(parsed).not.toBeNull(); + expect(parsed!.getTime()).toBe(date.getTime()); + } + ), + { numRuns: 100 } + ); + }); + + it('should parse RFC 822 date strings', () => { + // RFC 822 format: "Tue, 15 Nov 2024 12:45:26 GMT" + const rfc822Dates = [ + 'Tue, 15 Nov 2024 12:45:26 GMT', + 'Mon, 01 Jan 2024 00:00:00 +0000', + 'Fri, 31 Dec 2023 23:59:59 -0500', + 'Wed, 14 Feb 2024 08:30:00 +0900', + ]; + + rfc822Dates.forEach(dateStr => { + const parsed = parseRssDate(dateStr); + expect(parsed).not.toBeNull(); + expect(parsed).toBeInstanceOf(Date); + expect(isNaN(parsed!.getTime())).toBe(false); + }); + }); + + it('should return null for invalid date strings', () => { + fc.assert( + fc.property( + fc.string().filter(s => { + // Filter out strings that might accidentally be valid dates + const d = new Date(s); + return isNaN(d.getTime()); + }), + (invalidDate) => { + const parsed = parseRssDate(invalidDate); + expect(parsed).toBeNull(); + } + ), + { numRuns: 100 } + ); + }); + + it('should return null for undefined input', () => { + expect(parseRssDate(undefined)).toBeNull(); + }); + + it('should return null for empty string', () => { + expect(parseRssDate('')).toBeNull(); + }); + }); +}); diff --git a/packages/bot/src/services/rss.service.ts b/packages/bot/src/services/rss.service.ts new file mode 100644 index 0000000..16a3488 --- /dev/null +++ b/packages/bot/src/services/rss.service.ts @@ -0,0 +1,300 @@ +/** + * RSS Service + * RSS URL 감지, 피드 파싱, 폴링 서비스 + * Requirements: 11.1, 11.2, 11.3, 11.4, 11.5, 12.1, 12.2, 12.5, 6.1, 6.2 + */ + +import axios from 'axios'; +import * as cheerio from 'cheerio'; +import Parser from 'rss-parser'; +import { detectBlogPlatform, type BlogPlatform } from '@blog-study/shared/utils'; + +/** + * Error codes for RSS operations + */ +export const RssErrorCodes = { + RSS_FETCH_FAILED: 'E3001', + RSS_PARSE_FAILED: 'E3002', + RSS_DETECTION_FAILED: 'E3003', +} as const; + +/** + * Custom error class for RSS operations + */ +export class RssError extends Error { + constructor( + public code: string, + public userMessage: string, + message?: string + ) { + super(message || userMessage); + this.name = 'RssError'; + } +} + +/** + * RSS feed item extracted from a feed + */ +export interface RssFeedItem { + title: string; + link: string; + pubDate: Date; + description: string | null; +} + +/** + * Result of RSS URL detection + */ +export interface RssDetectionResult { + success: boolean; + rssUrl: string | null; + platform: BlogPlatform; + error?: string; +} + +/** + * Result of polling a single member's RSS feed + */ +export interface PollResult { + memberId: string; + success: boolean; + newItems: RssFeedItem[]; + error?: string; +} + +/** + * Construct RSS URL for known blog platforms + * Requirements: 11.1, 11.2, 11.3 + * + * @param blogUrl - The blog URL + * @param platform - The detected platform + * @returns RSS URL or null if cannot be constructed + */ +export function constructRssUrl(blogUrl: string, platform: BlogPlatform): string | null { + try { + const url = new URL(blogUrl); + + switch (platform) { + case 'velog': { + // Velog: https://velog.io/@username -> https://v2.velog.io/rss/@username + const match = url.pathname.match(/^\/@([\w-]+)\/?$/); + if (match) { + return `https://v2.velog.io/rss/@${match[1]}`; + } + return null; + } + + case 'tistory': { + // Tistory: https://blog.tistory.com -> https://blog.tistory.com/rss + return `${url.protocol}//${url.hostname}/rss`; + } + + case 'medium': { + // Medium: https://medium.com/@username -> https://medium.com/feed/@username + const match = url.pathname.match(/^\/@([\w-]+)\/?$/); + if (match) { + return `https://medium.com/feed/@${match[1]}`; + } + return null; + } + + default: + return null; + } + } catch { + return null; + } +} + +/** + * Extract RSS URL from HTML page by looking for link tags + * Requirements: 11.4 + * + * @param html - The HTML content of the page + * @returns RSS URL or null if not found + */ +export function extractRssFromHtml(html: string): string | null { + try { + const $ = cheerio.load(html); + + // Look for RSS link tags + const rssLink = $('link[type="application/rss+xml"]').attr('href'); + if (rssLink) { + return rssLink; + } + + // Also try atom feeds + const atomLink = $('link[type="application/atom+xml"]').attr('href'); + if (atomLink) { + return atomLink; + } + + return null; + } catch { + return null; + } +} + +/** + * Parse date string from RSS feed + * Handles RFC 822 and ISO 8601 formats + * Requirements: 12.5 + * + * @param dateStr - The date string to parse + * @returns Date object or null if parsing fails + */ +export function parseRssDate(dateStr: string | undefined): Date | null { + if (!dateStr) { + return null; + } + + try { + const date = new Date(dateStr); + if (isNaN(date.getTime())) { + return null; + } + return date; + } catch { + return null; + } +} + +/** + * RSS Service class for managing RSS operations + */ +export class RssService { + private parser: Parser; + private httpTimeout = 10000; // 10 seconds + + constructor() { + this.parser = new Parser({ + timeout: this.httpTimeout, + headers: { + 'User-Agent': 'BlogStudyBot/1.0', + }, + }); + } + + /** + * Detect RSS URL from a blog URL + * Requirements: 11.1, 11.2, 11.3, 11.4, 11.5 + * + * @param blogUrl - The blog URL to detect RSS from + * @returns Detection result with RSS URL or error + */ + async detectRssUrl(blogUrl: string): Promise { + const platform = detectBlogPlatform(blogUrl); + + // Try platform-specific construction first + if (platform !== 'unknown') { + const rssUrl = constructRssUrl(blogUrl, platform); + if (rssUrl) { + // Verify the RSS URL is valid by trying to fetch it + try { + await this.parser.parseURL(rssUrl); + return { success: true, rssUrl, platform }; + } catch { + // Fall through to HTML discovery + } + } + } + + // Try to discover RSS from HTML link tags + try { + const response = await axios.get(blogUrl, { + timeout: this.httpTimeout, + headers: { + 'User-Agent': 'BlogStudyBot/1.0', + }, + }); + + const rssUrl = extractRssFromHtml(response.data); + if (rssUrl) { + // Make absolute URL if relative + const absoluteRssUrl = new URL(rssUrl, blogUrl).toString(); + + // Verify the RSS URL is valid + try { + await this.parser.parseURL(absoluteRssUrl); + return { success: true, rssUrl: absoluteRssUrl, platform }; + } catch { + return { + success: false, + rssUrl: null, + platform, + error: 'RSS 피드를 파싱할 수 없습니다.', + }; + } + } + } catch { + // Could not fetch the blog page + } + + return { + success: false, + rssUrl: null, + platform, + error: 'RSS URL을 감지할 수 없습니다. /참가 [블로그URL] [RSS_URL] 형식으로 직접 입력해주세요.', + }; + } + + /** + * Fetch and parse an RSS feed + * Requirements: 12.1, 12.2 + * + * @param rssUrl - The RSS feed URL to fetch + * @returns Array of feed items + * @throws RssError if fetch or parse fails + */ + async fetchFeed(rssUrl: string): Promise { + try { + const feed = await this.parser.parseURL(rssUrl); + const items: RssFeedItem[] = []; + + for (const item of feed.items) { + // Skip items missing required fields (title or link) + // Requirements: 12.2 + if (!item.title || !item.link) { + continue; + } + + const pubDate = parseRssDate(item.pubDate || item.isoDate); + + items.push({ + title: item.title, + link: item.link, + pubDate: pubDate || new Date(), + description: item.contentSnippet || item.content || null, + }); + } + + return items; + } catch (error) { + throw new RssError( + RssErrorCodes.RSS_FETCH_FAILED, + 'RSS 피드를 가져올 수 없습니다.', + `Failed to fetch RSS feed: ${rssUrl} - ${error}` + ); + } + } +} + +// Singleton instance +let rssServiceInstance: RssService | null = null; + +/** + * Get the RssService singleton instance + */ +export function getRssService(): RssService { + if (!rssServiceInstance) { + rssServiceInstance = new RssService(); + } + return rssServiceInstance; +} + +/** + * Reset the singleton (useful for testing) + */ +export function resetRssService(): void { + rssServiceInstance = null; +} diff --git a/packages/bot/tsconfig.json b/packages/bot/tsconfig.json new file mode 100644 index 0000000..acb268b --- /dev/null +++ b/packages/bot/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "**/*.test.ts"], + "references": [ + { "path": "../shared" } + ] +} diff --git a/packages/bot/tsup.config.ts b/packages/bot/tsup.config.ts new file mode 100644 index 0000000..94be953 --- /dev/null +++ b/packages/bot/tsup.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: ['src/index.ts'], + format: ['cjs'], + dts: false, + splitting: false, + sourcemap: true, + clean: true, + target: 'node20', +}); diff --git a/packages/bot/vitest.config.ts b/packages/bot/vitest.config.ts new file mode 100644 index 0000000..7ce0d17 --- /dev/null +++ b/packages/bot/vitest.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + include: ['src/**/*.test.ts', 'src/**/*.property.test.ts'], + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html'], + }, + }, +}); diff --git a/packages/shared/drizzle.config.ts b/packages/shared/drizzle.config.ts new file mode 100644 index 0000000..3e1b8df --- /dev/null +++ b/packages/shared/drizzle.config.ts @@ -0,0 +1,21 @@ +import { defineConfig } from 'drizzle-kit'; +import { config } from 'dotenv'; +import { resolve } from 'path'; + +// Load .env file from project root +config({ path: resolve(__dirname, '../../.env') }); + +if (!process.env.DATABASE_URL) { + throw new Error('DATABASE_URL environment variable is required for migrations'); +} + +export default defineConfig({ + schema: './src/db/schema.ts', + out: './drizzle', + dialect: 'postgresql', + dbCredentials: { + url: process.env.DATABASE_URL, + }, + verbose: true, + strict: true, +}); diff --git a/packages/shared/drizzle/0000_steep_amphibian.sql b/packages/shared/drizzle/0000_steep_amphibian.sql new file mode 100644 index 0000000..a08753a --- /dev/null +++ b/packages/shared/drizzle/0000_steep_amphibian.sql @@ -0,0 +1,187 @@ +CREATE TABLE IF NOT EXISTS "attendance" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "member_id" uuid NOT NULL, + "round_id" integer NOT NULL, + "status" varchar(20) DEFAULT 'pending' NOT NULL, + "submitted_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now(), + "updated_at" timestamp with time zone DEFAULT now() +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "config" ( + "key" varchar(100) PRIMARY KEY NOT NULL, + "value" text NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "curation_items" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "source_id" uuid, + "title" varchar(500) NOT NULL, + "url" varchar(1000) NOT NULL, + "published_at" timestamp with time zone, + "category" varchar(50) NOT NULL, + "tags" text[], + "relevance_score" real DEFAULT 0, + "is_shared" boolean DEFAULT false, + "shared_at" timestamp with time zone, + "collected_at" timestamp with time zone DEFAULT now(), + CONSTRAINT "curation_items_url_unique" UNIQUE("url") +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "curation_sources" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "url" varchar(500) NOT NULL, + "name" varchar(200) NOT NULL, + "category" varchar(50) NOT NULL, + "is_active" boolean DEFAULT true, + "created_at" timestamp with time zone DEFAULT now(), + CONSTRAINT "curation_sources_url_unique" UNIQUE("url") +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "fines" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "member_id" uuid NOT NULL, + "round_id" integer NOT NULL, + "type" varchar(20) NOT NULL, + "amount" integer NOT NULL, + "status" varchar(20) DEFAULT 'unpaid' NOT NULL, + "created_at" timestamp with time zone DEFAULT now(), + "paid_at" timestamp with time zone +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "keywords" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "keyword" varchar(100) NOT NULL, + "frequency" integer DEFAULT 1, + "last_updated" timestamp with time zone DEFAULT now(), + CONSTRAINT "keywords_keyword_unique" UNIQUE("keyword") +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "members" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "discord_id" varchar(20) NOT NULL, + "discord_username" varchar(100) NOT NULL, + "name" varchar(100) NOT NULL, + "part" varchar(50) NOT NULL, + "blog_url" varchar(500) NOT NULL, + "rss_url" varchar(500), + "profile_image_url" varchar(500), + "bio" varchar(200), + "interests" text[], + "resolution" varchar(300), + "onboarding_completed" boolean DEFAULT false, + "status" varchar(20) DEFAULT 'active' NOT NULL, + "dormant_start_round" integer, + "dormant_used" boolean DEFAULT false, + "joined_at" timestamp with time zone DEFAULT now(), + "updated_at" timestamp with time zone DEFAULT now(), + CONSTRAINT "members_discord_id_unique" UNIQUE("discord_id") +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "posts" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "member_id" uuid NOT NULL, + "round_id" integer, + "title" varchar(500) NOT NULL, + "url" varchar(1000) NOT NULL, + "published_at" timestamp with time zone NOT NULL, + "description" text, + "collected_at" timestamp with time zone DEFAULT now(), + CONSTRAINT "posts_url_unique" UNIQUE("url") +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "rounds" ( + "id" serial PRIMARY KEY NOT NULL, + "round_number" integer NOT NULL, + "start_date" date NOT NULL, + "end_date" date NOT NULL, + "grace_end_date" date NOT NULL, + "is_current" boolean DEFAULT false, + CONSTRAINT "rounds_round_number_unique" UNIQUE("round_number") +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "sessions" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" uuid NOT NULL, + "token" varchar(255) NOT NULL, + "expires_at" timestamp with time zone NOT NULL, + "created_at" timestamp with time zone DEFAULT now(), + CONSTRAINT "sessions_token_unique" UNIQUE("token") +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "users" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "email" varchar(255) NOT NULL, + "password_hash" varchar(255) NOT NULL, + "email_verified" boolean DEFAULT false, + "email_verify_token" varchar(255), + "email_verify_expires" timestamp with time zone, + "member_id" uuid, + "created_at" timestamp with time zone DEFAULT now(), + "updated_at" timestamp with time zone DEFAULT now(), + CONSTRAINT "users_email_unique" UNIQUE("email") +); +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "attendance" ADD CONSTRAINT "attendance_member_id_members_id_fk" FOREIGN KEY ("member_id") REFERENCES "public"."members"("id") ON DELETE no action ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "attendance" ADD CONSTRAINT "attendance_round_id_rounds_id_fk" FOREIGN KEY ("round_id") REFERENCES "public"."rounds"("id") ON DELETE no action ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "curation_items" ADD CONSTRAINT "curation_items_source_id_curation_sources_id_fk" FOREIGN KEY ("source_id") REFERENCES "public"."curation_sources"("id") ON DELETE no action ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "fines" ADD CONSTRAINT "fines_member_id_members_id_fk" FOREIGN KEY ("member_id") REFERENCES "public"."members"("id") ON DELETE no action ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "fines" ADD CONSTRAINT "fines_round_id_rounds_id_fk" FOREIGN KEY ("round_id") REFERENCES "public"."rounds"("id") ON DELETE no action ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "posts" ADD CONSTRAINT "posts_member_id_members_id_fk" FOREIGN KEY ("member_id") REFERENCES "public"."members"("id") ON DELETE no action ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "posts" ADD CONSTRAINT "posts_round_id_rounds_id_fk" FOREIGN KEY ("round_id") REFERENCES "public"."rounds"("id") ON DELETE no action ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "sessions" ADD CONSTRAINT "sessions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "users" ADD CONSTRAINT "users_member_id_members_id_fk" FOREIGN KEY ("member_id") REFERENCES "public"."members"("id") ON DELETE no action ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "attendance_member_round_unique" ON "attendance" USING btree ("member_id","round_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "idx_attendance_round_id" ON "attendance" USING btree ("round_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "idx_curation_items_is_shared" ON "curation_items" USING btree ("is_shared");--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "fines_member_round_unique" ON "fines" USING btree ("member_id","round_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "idx_fines_status" ON "fines" USING btree ("status");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "idx_members_status" ON "members" USING btree ("status");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "idx_posts_member_id" ON "posts" USING btree ("member_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "idx_posts_round_id" ON "posts" USING btree ("round_id"); \ No newline at end of file diff --git a/packages/shared/drizzle/0001_rls_policies.sql b/packages/shared/drizzle/0001_rls_policies.sql new file mode 100644 index 0000000..da125bf --- /dev/null +++ b/packages/shared/drizzle/0001_rls_policies.sql @@ -0,0 +1,218 @@ +-- ============================================ +-- Row Level Security (RLS) Policies +-- Supabase Production Configuration +-- ============================================ + +-- Enable RLS on all tables +ALTER TABLE members ENABLE ROW LEVEL SECURITY; +ALTER TABLE users ENABLE ROW LEVEL SECURITY; +ALTER TABLE sessions ENABLE ROW LEVEL SECURITY; +ALTER TABLE rounds ENABLE ROW LEVEL SECURITY; +ALTER TABLE posts ENABLE ROW LEVEL SECURITY; +ALTER TABLE attendance ENABLE ROW LEVEL SECURITY; +ALTER TABLE fines ENABLE ROW LEVEL SECURITY; +ALTER TABLE keywords ENABLE ROW LEVEL SECURITY; +ALTER TABLE curation_sources ENABLE ROW LEVEL SECURITY; +ALTER TABLE curation_items ENABLE ROW LEVEL SECURITY; +ALTER TABLE config ENABLE ROW LEVEL SECURITY; + +-- ============================================ +-- Service Role Policies (Full Access) +-- Bot and Server-side operations +-- ============================================ + +-- Members: Service role has full access +CREATE POLICY "service_role_members_all" ON members + FOR ALL + TO service_role + USING (true) + WITH CHECK (true); + +-- Users: Service role has full access +CREATE POLICY "service_role_users_all" ON users + FOR ALL + TO service_role + USING (true) + WITH CHECK (true); + +-- Sessions: Service role has full access +CREATE POLICY "service_role_sessions_all" ON sessions + FOR ALL + TO service_role + USING (true) + WITH CHECK (true); + +-- Rounds: Service role has full access +CREATE POLICY "service_role_rounds_all" ON rounds + FOR ALL + TO service_role + USING (true) + WITH CHECK (true); + +-- Posts: Service role has full access +CREATE POLICY "service_role_posts_all" ON posts + FOR ALL + TO service_role + USING (true) + WITH CHECK (true); + +-- Attendance: Service role has full access +CREATE POLICY "service_role_attendance_all" ON attendance + FOR ALL + TO service_role + USING (true) + WITH CHECK (true); + +-- Fines: Service role has full access +CREATE POLICY "service_role_fines_all" ON fines + FOR ALL + TO service_role + USING (true) + WITH CHECK (true); + +-- Keywords: Service role has full access +CREATE POLICY "service_role_keywords_all" ON keywords + FOR ALL + TO service_role + USING (true) + WITH CHECK (true); + +-- Curation Sources: Service role has full access +CREATE POLICY "service_role_curation_sources_all" ON curation_sources + FOR ALL + TO service_role + USING (true) + WITH CHECK (true); + +-- Curation Items: Service role has full access +CREATE POLICY "service_role_curation_items_all" ON curation_items + FOR ALL + TO service_role + USING (true) + WITH CHECK (true); + +-- Config: Service role has full access +CREATE POLICY "service_role_config_all" ON config + FOR ALL + TO service_role + USING (true) + WITH CHECK (true); + +-- ============================================ +-- Anon/Authenticated Role Policies (Read-Only Public Data) +-- Web client operations +-- ============================================ + +-- Members: Public can read active members only +CREATE POLICY "anon_members_select" ON members + FOR SELECT + TO anon, authenticated + USING (status = 'active' OR status = 'dormant'); + +-- Rounds: Public can read all rounds +CREATE POLICY "anon_rounds_select" ON rounds + FOR SELECT + TO anon, authenticated + USING (true); + +-- Posts: Public can read all posts +CREATE POLICY "anon_posts_select" ON posts + FOR SELECT + TO anon, authenticated + USING (true); + +-- Attendance: Public can read all attendance +CREATE POLICY "anon_attendance_select" ON attendance + FOR SELECT + TO anon, authenticated + USING (true); + +-- Fines: Public can read all fines (amounts are not sensitive) +CREATE POLICY "anon_fines_select" ON fines + FOR SELECT + TO anon, authenticated + USING (true); + +-- Keywords: Public can read all keywords +CREATE POLICY "anon_keywords_select" ON keywords + FOR SELECT + TO anon, authenticated + USING (true); + +-- Curation Sources: Public can read active sources +CREATE POLICY "anon_curation_sources_select" ON curation_sources + FOR SELECT + TO anon, authenticated + USING (is_active = true); + +-- Curation Items: Public can read shared items +CREATE POLICY "anon_curation_items_select" ON curation_items + FOR SELECT + TO anon, authenticated + USING (is_shared = true); + +-- Config: Public can read non-sensitive config +CREATE POLICY "anon_config_select" ON config + FOR SELECT + TO anon, authenticated + USING (key NOT LIKE '%secret%' AND key NOT LIKE '%token%' AND key NOT LIKE '%key%'); + +-- ============================================ +-- Additional Performance Indexes +-- ============================================ + +-- Members: Frequently queried fields +CREATE INDEX IF NOT EXISTS idx_members_discord_username ON members(discord_username); +CREATE INDEX IF NOT EXISTS idx_members_joined_at ON members(joined_at DESC); + +-- Users: Email lookup and member linking +CREATE INDEX IF NOT EXISTS idx_users_email_verified ON users(email_verified); +CREATE INDEX IF NOT EXISTS idx_users_member_id ON users(member_id); + +-- Sessions: Token lookup and expiry cleanup +CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id); +CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at); + +-- Rounds: Current round lookup +CREATE INDEX IF NOT EXISTS idx_rounds_is_current ON rounds(is_current) WHERE is_current = true; +CREATE INDEX IF NOT EXISTS idx_rounds_dates ON rounds(start_date, end_date); + +-- Posts: Date-based queries +CREATE INDEX IF NOT EXISTS idx_posts_published_at ON posts(published_at DESC); +CREATE INDEX IF NOT EXISTS idx_posts_collected_at ON posts(collected_at DESC); + +-- Attendance: Status filtering +CREATE INDEX IF NOT EXISTS idx_attendance_status ON attendance(status); +CREATE INDEX IF NOT EXISTS idx_attendance_member_status ON attendance(member_id, status); + +-- Fines: Member lookup for unpaid fines +CREATE INDEX IF NOT EXISTS idx_fines_member_status ON fines(member_id, status); + +-- Keywords: Frequency ranking +CREATE INDEX IF NOT EXISTS idx_keywords_frequency ON keywords(frequency DESC); + +-- Curation Items: Relevance sorting +CREATE INDEX IF NOT EXISTS idx_curation_items_relevance ON curation_items(relevance_score DESC) WHERE is_shared = false; +CREATE INDEX IF NOT EXISTS idx_curation_items_collected_at ON curation_items(collected_at DESC); + +-- ============================================ +-- Cleanup Functions +-- ============================================ + +-- Function to clean up expired sessions +CREATE OR REPLACE FUNCTION cleanup_expired_sessions() +RETURNS void AS $$ +BEGIN + DELETE FROM sessions WHERE expires_at < NOW(); +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +-- Function to clean up expired email verification tokens +CREATE OR REPLACE FUNCTION cleanup_expired_email_tokens() +RETURNS void AS $$ +BEGIN + UPDATE users + SET email_verify_token = NULL, email_verify_expires = NULL + WHERE email_verify_expires < NOW() AND email_verified = false; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; diff --git a/packages/shared/drizzle/meta/0000_snapshot.json b/packages/shared/drizzle/meta/0000_snapshot.json new file mode 100644 index 0000000..eee4bc5 --- /dev/null +++ b/packages/shared/drizzle/meta/0000_snapshot.json @@ -0,0 +1,987 @@ +{ + "id": "176fe00c-1c02-4cae-a845-4258404d5aea", + "prevId": "00000000-0000-0000-0000-000000000000", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.attendance": { + "name": "attendance", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "member_id": { + "name": "member_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "round_id": { + "name": "round_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "submitted_at": { + "name": "submitted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "attendance_member_round_unique": { + "name": "attendance_member_round_unique", + "columns": [ + { + "expression": "member_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "round_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_attendance_round_id": { + "name": "idx_attendance_round_id", + "columns": [ + { + "expression": "round_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "attendance_member_id_members_id_fk": { + "name": "attendance_member_id_members_id_fk", + "tableFrom": "attendance", + "tableTo": "members", + "columnsFrom": [ + "member_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "attendance_round_id_rounds_id_fk": { + "name": "attendance_round_id_rounds_id_fk", + "tableFrom": "attendance", + "tableTo": "rounds", + "columnsFrom": [ + "round_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.config": { + "name": "config", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "varchar(100)", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.curation_items": { + "name": "curation_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "source_id": { + "name": "source_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "varchar(1000)", + "primaryKey": false, + "notNull": true + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "category": { + "name": "category", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "tags": { + "name": "tags", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "relevance_score": { + "name": "relevance_score", + "type": "real", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "is_shared": { + "name": "is_shared", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "shared_at": { + "name": "shared_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "collected_at": { + "name": "collected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_curation_items_is_shared": { + "name": "idx_curation_items_is_shared", + "columns": [ + { + "expression": "is_shared", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "curation_items_source_id_curation_sources_id_fk": { + "name": "curation_items_source_id_curation_sources_id_fk", + "tableFrom": "curation_items", + "tableTo": "curation_sources", + "columnsFrom": [ + "source_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "curation_items_url_unique": { + "name": "curation_items_url_unique", + "nullsNotDistinct": false, + "columns": [ + "url" + ] + } + } + }, + "public.curation_sources": { + "name": "curation_sources", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "url": { + "name": "url", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "curation_sources_url_unique": { + "name": "curation_sources_url_unique", + "nullsNotDistinct": false, + "columns": [ + "url" + ] + } + } + }, + "public.fines": { + "name": "fines", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "member_id": { + "name": "member_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "round_id": { + "name": "round_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'unpaid'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "paid_at": { + "name": "paid_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "fines_member_round_unique": { + "name": "fines_member_round_unique", + "columns": [ + { + "expression": "member_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "round_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_fines_status": { + "name": "idx_fines_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "fines_member_id_members_id_fk": { + "name": "fines_member_id_members_id_fk", + "tableFrom": "fines", + "tableTo": "members", + "columnsFrom": [ + "member_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "fines_round_id_rounds_id_fk": { + "name": "fines_round_id_rounds_id_fk", + "tableFrom": "fines", + "tableTo": "rounds", + "columnsFrom": [ + "round_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.keywords": { + "name": "keywords", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "keyword": { + "name": "keyword", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "frequency": { + "name": "frequency", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 1 + }, + "last_updated": { + "name": "last_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "keywords_keyword_unique": { + "name": "keywords_keyword_unique", + "nullsNotDistinct": false, + "columns": [ + "keyword" + ] + } + } + }, + "public.members": { + "name": "members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "discord_id": { + "name": "discord_id", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "discord_username": { + "name": "discord_username", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "part": { + "name": "part", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "blog_url": { + "name": "blog_url", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true + }, + "rss_url": { + "name": "rss_url", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "profile_image_url": { + "name": "profile_image_url", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "bio": { + "name": "bio", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "interests": { + "name": "interests", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "resolution": { + "name": "resolution", + "type": "varchar(300)", + "primaryKey": false, + "notNull": false + }, + "onboarding_completed": { + "name": "onboarding_completed", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "status": { + "name": "status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "dormant_start_round": { + "name": "dormant_start_round", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "dormant_used": { + "name": "dormant_used", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_members_status": { + "name": "idx_members_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "members_discord_id_unique": { + "name": "members_discord_id_unique", + "nullsNotDistinct": false, + "columns": [ + "discord_id" + ] + } + } + }, + "public.posts": { + "name": "posts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "member_id": { + "name": "member_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "round_id": { + "name": "round_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "varchar(1000)", + "primaryKey": false, + "notNull": true + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "collected_at": { + "name": "collected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_posts_member_id": { + "name": "idx_posts_member_id", + "columns": [ + { + "expression": "member_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_posts_round_id": { + "name": "idx_posts_round_id", + "columns": [ + { + "expression": "round_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "posts_member_id_members_id_fk": { + "name": "posts_member_id_members_id_fk", + "tableFrom": "posts", + "tableTo": "members", + "columnsFrom": [ + "member_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "posts_round_id_rounds_id_fk": { + "name": "posts_round_id_rounds_id_fk", + "tableFrom": "posts", + "tableTo": "rounds", + "columnsFrom": [ + "round_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "posts_url_unique": { + "name": "posts_url_unique", + "nullsNotDistinct": false, + "columns": [ + "url" + ] + } + } + }, + "public.rounds": { + "name": "rounds", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "round_number": { + "name": "round_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "start_date": { + "name": "start_date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "end_date": { + "name": "end_date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "grace_end_date": { + "name": "grace_end_date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "is_current": { + "name": "is_current", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "rounds_round_number_unique": { + "name": "rounds_round_number_unique", + "nullsNotDistinct": false, + "columns": [ + "round_number" + ] + } + } + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_token_unique": { + "name": "sessions_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + } + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "email_verify_token": { + "name": "email_verify_token", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "email_verify_expires": { + "name": "email_verify_expires", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "member_id": { + "name": "member_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "users_member_id_members_id_fk": { + "name": "users_member_id_members_id_fk", + "tableFrom": "users", + "tableTo": "members", + "columnsFrom": [ + "member_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + } + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/shared/drizzle/meta/_journal.json b/packages/shared/drizzle/meta/_journal.json new file mode 100644 index 0000000..cfbece2 --- /dev/null +++ b/packages/shared/drizzle/meta/_journal.json @@ -0,0 +1,20 @@ +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1764216150637, + "tag": "0000_steep_amphibian", + "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1764316150637, + "tag": "0001_rls_policies", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/packages/shared/package.json b/packages/shared/package.json new file mode 100644 index 0000000..77a41ee --- /dev/null +++ b/packages/shared/package.json @@ -0,0 +1,59 @@ +{ + "name": "@blog-study/shared", + "version": "1.0.0", + "private": true, + "description": "공유 유틸리티, 타입, 데이터베이스 스키마", + "main": "./dist/index.js", + "module": "./dist/index.mjs", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.mjs", + "require": "./dist/index.js" + }, + "./db": { + "types": "./dist/db/index.d.ts", + "import": "./dist/db/index.mjs", + "require": "./dist/db/index.js" + }, + "./config": { + "types": "./dist/config/index.d.ts", + "import": "./dist/config/index.mjs", + "require": "./dist/config/index.js" + }, + "./utils": { + "types": "./dist/utils/index.d.ts", + "import": "./dist/utils/index.mjs", + "require": "./dist/utils/index.js" + } + }, + "scripts": { + "build": "tsup", + "dev": "tsup --watch", + "lint": "eslint src --ext .ts", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest", + "db:generate": "drizzle-kit generate", + "db:migrate": "drizzle-kit migrate", + "db:migrate:run": "tsx src/db/migrate.ts", + "db:push": "drizzle-kit push", + "db:studio": "drizzle-kit studio" + }, + "dependencies": { + "dotenv": "^16.4.5", + "drizzle-orm": "^0.33.0", + "postgres": "^3.4.8", + "zod": "^3.23.8" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "drizzle-kit": "^0.24.0", + "fast-check": "^3.19.0", + "tsup": "^8.5.1", + "tsx": "^4.21.0", + "typescript": "^5.9.3", + "vitest": "^1.6.0" + } +} diff --git a/packages/shared/src/config/env.ts b/packages/shared/src/config/env.ts new file mode 100644 index 0000000..dd0299f --- /dev/null +++ b/packages/shared/src/config/env.ts @@ -0,0 +1,166 @@ +import { config } from 'dotenv'; +import { z } from 'zod'; +import { resolve } from 'path'; + +// Load .env file from project root +config({ path: resolve(process.cwd(), '.env') }); + +// Discord configuration schema +const discordEnvSchema = z.object({ + DISCORD_TOKEN: z.string().min(1, 'DISCORD_TOKEN is required'), + DISCORD_CLIENT_ID: z.string().min(1, 'DISCORD_CLIENT_ID is required'), + DISCORD_GUILD_ID: z.string().min(1, 'DISCORD_GUILD_ID is required'), + ADMIN_DISCORD_IDS: z + .string() + .transform((val) => val.split(',').map((id) => id.trim())) + .default(''), +}); + +// Supabase configuration schema +const supabaseEnvSchema = z.object({ + SUPABASE_URL: z.string().url('SUPABASE_URL must be a valid URL'), + SUPABASE_ANON_KEY: z.string().min(1, 'SUPABASE_ANON_KEY is required'), + SUPABASE_SERVICE_KEY: z.string().min(1, 'SUPABASE_SERVICE_KEY is required'), + 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'), + NODE_ENV: z.enum(['development', 'production', 'test']).default('development'), +}); + +// Study configuration schema (optional) +const studyEnvSchema = z.object({ + STUDY_START_DATE: z.string().optional(), + TOTAL_ROUNDS: z.coerce.number().int().positive().default(10), + ANNOUNCEMENT_CHANNEL_ID: z.string().optional(), + CURATION_CHANNEL_ID: z.string().optional(), + STUDY_ROLE_ID: z.string().optional(), +}); + +// Combined environment schema +const envSchema = z.object({ + ...discordEnvSchema.shape, + ...supabaseEnvSchema.shape, + ...authEnvSchema.shape, + ...emailEnvSchema.shape, + ...appEnvSchema.shape, + ...studyEnvSchema.shape, +}); + +// Partial schema for bot-only usage +const botEnvSchema = z.object({ + ...discordEnvSchema.shape, + ...supabaseEnvSchema.shape, + ...appEnvSchema.shape, + ...studyEnvSchema.shape, +}); + +// Partial schema for web-only usage +const webEnvSchema = z.object({ + ...supabaseEnvSchema.shape, + ...authEnvSchema.shape, + ...emailEnvSchema.shape, + ...appEnvSchema.shape, +}); + +export type Env = z.infer; +export type BotEnv = z.infer; +export type WebEnv = z.infer; + +/** + * Load and validate all environment variables + */ +export function loadEnv(): Env { + const result = envSchema.safeParse(process.env); + + if (!result.success) { + const errors = result.error.errors.map((e) => ` - ${e.path.join('.')}: ${e.message}`); + throw new Error(`Environment validation failed:\n${errors.join('\n')}`); + } + + return result.data; +} + +/** + * Load and validate bot-specific environment variables + */ +export function loadBotEnv(): BotEnv { + const result = botEnvSchema.safeParse(process.env); + + if (!result.success) { + const errors = result.error.errors.map((e) => ` - ${e.path.join('.')}: ${e.message}`); + throw new Error(`Bot environment validation failed:\n${errors.join('\n')}`); + } + + return result.data; +} + +/** + * Load and validate web-specific environment variables + */ +export function loadWebEnv(): WebEnv { + const result = webEnvSchema.safeParse(process.env); + + if (!result.success) { + const errors = result.error.errors.map((e) => ` - ${e.path.join('.')}: ${e.message}`); + throw new Error(`Web environment validation failed:\n${errors.join('\n')}`); + } + + return result.data; +} + +/** + * Check if a Discord user ID is an admin + */ +export function isAdmin(discordId: string, adminIds: string[]): boolean { + return adminIds.includes(discordId); +} + +/** + * Get environment variable with fallback + */ +export function getEnvVar(key: string, fallback?: string): string { + const value = process.env[key]; + if (value === undefined) { + if (fallback !== undefined) { + return fallback; + } + throw new Error(`Environment variable ${key} is not set`); + } + return value; +} + +/** + * Check if running in production + */ +export function isProduction(): boolean { + return process.env.NODE_ENV === 'production'; +} + +/** + * Check if running in development + */ +export function isDevelopment(): boolean { + return process.env.NODE_ENV === 'development' || process.env.NODE_ENV === undefined; +} + +/** + * Check if running in test + */ +export function isTest(): boolean { + return process.env.NODE_ENV === 'test'; +} diff --git a/packages/shared/src/config/index.ts b/packages/shared/src/config/index.ts new file mode 100644 index 0000000..c1532d6 --- /dev/null +++ b/packages/shared/src/config/index.ts @@ -0,0 +1 @@ +export * from './env'; diff --git a/packages/shared/src/db/index.ts b/packages/shared/src/db/index.ts new file mode 100644 index 0000000..93552d5 --- /dev/null +++ b/packages/shared/src/db/index.ts @@ -0,0 +1,61 @@ +import { drizzle } from 'drizzle-orm/postgres-js'; +import postgres from 'postgres'; +import * as schema from './schema'; + +// Re-export all schema definitions +export * from './schema'; + +// Database connection singleton +let db: ReturnType> | null = null; +let client: ReturnType | null = null; + +/** + * Get or create database connection + * @param connectionString - PostgreSQL connection string (defaults to DATABASE_URL env var) + */ +export function getDb(connectionString?: string) { + if (db) { + return db; + } + + const connStr = connectionString || process.env.DATABASE_URL; + + if (!connStr) { + throw new Error('DATABASE_URL environment variable is not set'); + } + + client = postgres(connStr, { + max: 10, // Maximum number of connections + idle_timeout: 20, // Close idle connections after 20 seconds + connect_timeout: 10, // Connection timeout in seconds + }); + + db = drizzle(client, { schema }); + + return db; +} + +/** + * Close database connection + * Useful for graceful shutdown + */ +export async function closeDb() { + if (client) { + await client.end(); + client = null; + db = null; + } +} + +/** + * Get database client for raw queries + */ +export function getClient() { + if (!client) { + throw new Error('Database not initialized. Call getDb() first.'); + } + return client; +} + +// Export the schema object for use with drizzle +export { schema }; diff --git a/packages/shared/src/db/migrate.ts b/packages/shared/src/db/migrate.ts new file mode 100644 index 0000000..09179e3 --- /dev/null +++ b/packages/shared/src/db/migrate.ts @@ -0,0 +1,41 @@ +import { drizzle } from 'drizzle-orm/postgres-js'; +import { migrate } from 'drizzle-orm/postgres-js/migrator'; +import postgres from 'postgres'; +import { config } from 'dotenv'; +import { resolve } from 'path'; + +// Load .env file from project root +config({ path: resolve(__dirname, '../../../../.env') }); + +/** + * Run database migrations + * This script applies all pending migrations from the drizzle folder + */ +async function runMigrations() { + const connectionString = process.env.DATABASE_URL; + + if (!connectionString) { + console.error('❌ DATABASE_URL environment variable is not set'); + process.exit(1); + } + + console.log('🔄 Starting database migration...'); + + // Create a connection for migrations (max 1 connection) + const migrationClient = postgres(connectionString, { max: 1 }); + const db = drizzle(migrationClient); + + try { + // Run migrations from the drizzle folder + await migrate(db, { migrationsFolder: resolve(__dirname, '../../drizzle') }); + console.log('✅ Migrations completed successfully'); + } catch (error) { + console.error('❌ Migration failed:', error); + process.exit(1); + } finally { + await migrationClient.end(); + } +} + +// Run if executed directly +runMigrations(); diff --git a/packages/shared/src/db/schema.property.test.ts b/packages/shared/src/db/schema.property.test.ts new file mode 100644 index 0000000..6cefdde --- /dev/null +++ b/packages/shared/src/db/schema.property.test.ts @@ -0,0 +1,115 @@ +import { describe, it, expect } from 'vitest'; +import * as fc from 'fast-check'; +import type { Post } from './schema'; +import { + serializePost, + deserializePost, + postToJson, + postFromJson, + postsAreEqual, +} from '../utils/serialization'; + +/** + * **Feature: blog-study-discord-bot, Property 32: Post Data Round-Trip** + * + * *For any* valid Post object, serializing to JSON and deserializing back + * SHALL produce an equivalent object with all fields preserved. + * + * **Validates: Requirements 12.3, 12.4** + */ +describe('Property 32: Post Data Round-Trip', () => { + // Arbitrary for generating valid UUIDs + const uuidArb = fc.uuid(); + + // Arbitrary for generating valid Post objects + const postArb: fc.Arbitrary = fc.record({ + id: uuidArb, + memberId: uuidArb, + roundId: fc.option(fc.integer({ min: 1, max: 100 }), { nil: null }), + title: fc.string({ minLength: 1, maxLength: 500 }), + url: fc.webUrl(), + publishedAt: fc.date({ + min: new Date('2020-01-01'), + max: new Date('2030-12-31'), + }), + description: fc.option(fc.string({ maxLength: 5000 }), { nil: null }), + collectedAt: fc.option( + fc.date({ + min: new Date('2020-01-01'), + max: new Date('2030-12-31'), + }), + { nil: null } + ), + }); + + it('should preserve all fields after serialize/deserialize round-trip', () => { + fc.assert( + fc.property(postArb, (post) => { + // Serialize then deserialize + const serialized = serializePost(post); + const deserialized = deserializePost(serialized); + + // All fields should be preserved + expect(postsAreEqual(post, deserialized)).toBe(true); + }), + { numRuns: 100 } + ); + }); + + it('should preserve all fields after JSON string round-trip', () => { + fc.assert( + fc.property(postArb, (post) => { + // Convert to JSON string and back + const json = postToJson(post); + const restored = postFromJson(json); + + // All fields should be preserved + expect(postsAreEqual(post, restored)).toBe(true); + }), + { numRuns: 100 } + ); + }); + + it('should produce valid JSON that can be parsed', () => { + fc.assert( + fc.property(postArb, (post) => { + const json = postToJson(post); + + // Should be valid JSON + expect(() => JSON.parse(json)).not.toThrow(); + + // Parsed JSON should have all required fields + const parsed = JSON.parse(json); + expect(parsed).toHaveProperty('id'); + expect(parsed).toHaveProperty('memberId'); + expect(parsed).toHaveProperty('roundId'); + expect(parsed).toHaveProperty('title'); + expect(parsed).toHaveProperty('url'); + expect(parsed).toHaveProperty('publishedAt'); + expect(parsed).toHaveProperty('description'); + expect(parsed).toHaveProperty('collectedAt'); + }), + { numRuns: 100 } + ); + }); + + it('should preserve date precision in round-trip', () => { + fc.assert( + fc.property(postArb, (post) => { + const restored = postFromJson(postToJson(post)); + + // publishedAt should have same timestamp + expect(restored.publishedAt.getTime()).toBe(post.publishedAt.getTime()); + + // collectedAt should have same timestamp (if not null) + if (post.collectedAt !== null) { + expect(restored.collectedAt).not.toBeNull(); + expect(restored.collectedAt!.getTime()).toBe(post.collectedAt.getTime()); + } else { + expect(restored.collectedAt).toBeNull(); + } + }), + { numRuns: 100 } + ); + }); +}); diff --git a/packages/shared/src/db/schema.ts b/packages/shared/src/db/schema.ts new file mode 100644 index 0000000..2fe80ca --- /dev/null +++ b/packages/shared/src/db/schema.ts @@ -0,0 +1,390 @@ +import { + pgTable, + uuid, + varchar, + text, + boolean, + integer, + timestamp, + date, + real, + serial, + uniqueIndex, + index, +} from 'drizzle-orm/pg-core'; +import { relations } from 'drizzle-orm'; + +// ============================================ +// Enums (as string literals for PostgreSQL) +// ============================================ + +export const MemberStatus = { + ACTIVE: 'active', + DORMANT: 'dormant', + WITHDRAWN: 'withdrawn', +} as const; + +export type MemberStatusType = (typeof MemberStatus)[keyof typeof MemberStatus]; + +export const AttendanceStatus = { + PENDING: 'pending', + SUBMITTED: 'submitted', + LATE: 'late', + ABSENT: 'absent', +} as const; + +export type AttendanceStatusType = (typeof AttendanceStatus)[keyof typeof AttendanceStatus]; + +export const FineType = { + LATE: 'late', + ABSENT: 'absent', +} as const; + +export type FineTypeValue = (typeof FineType)[keyof typeof FineType]; + +export const FineStatus = { + UNPAID: 'unpaid', + PAID: 'paid', + WAIVED: 'waived', +} as const; + +export type FineStatusType = (typeof FineStatus)[keyof typeof FineStatus]; + +export const CurationCategory = { + CONFERENCE: 'conference', + ARTICLE: 'article', +} as const; + +export type CurationCategoryType = (typeof CurationCategory)[keyof typeof CurationCategory]; + + +// ============================================ +// Tables +// ============================================ + +/** + * 스터디 참가자 (Members) + * Discord 사용자가 스터디에 참가 등록하면 생성됨 + */ +export const members = pgTable( + 'members', + { + id: uuid('id').primaryKey().defaultRandom(), + discordId: varchar('discord_id', { length: 20 }).notNull().unique(), + discordUsername: varchar('discord_username', { length: 100 }).notNull(), + name: varchar('name', { length: 100 }).notNull(), + part: varchar('part', { length: 50 }).notNull(), + blogUrl: varchar('blog_url', { length: 500 }).notNull(), + rssUrl: varchar('rss_url', { length: 500 }), + // 프로필 정보 (온보딩) + profileImageUrl: varchar('profile_image_url', { length: 500 }), + bio: varchar('bio', { length: 200 }), + interests: text('interests').array(), + resolution: varchar('resolution', { length: 300 }), + onboardingCompleted: boolean('onboarding_completed').default(false), + // 상태 관리 + status: varchar('status', { length: 20 }).notNull().default(MemberStatus.ACTIVE), + dormantStartRound: integer('dormant_start_round'), + dormantUsed: boolean('dormant_used').default(false), + joinedAt: timestamp('joined_at', { withTimezone: true }).defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow(), + }, + (table) => ({ + statusIdx: index('idx_members_status').on(table.status), + }) +); + +/** + * 웹 사용자 (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(), +}); + +/** + * 스터디 회차 (Rounds) + * 2주 = 1회차, 월요일 시작 ~ 일요일 마감 + */ +export const rounds = pgTable('rounds', { + id: serial('id').primaryKey(), + roundNumber: integer('round_number').notNull().unique(), + startDate: date('start_date').notNull(), + endDate: date('end_date').notNull(), + graceEndDate: date('grace_end_date').notNull(), + isCurrent: boolean('is_current').default(false), +}); + + +/** + * 블로그 글 (Posts) + * RSS에서 수집된 스터디원의 블로그 글 + */ +export const posts = pgTable( + 'posts', + { + id: uuid('id').primaryKey().defaultRandom(), + memberId: uuid('member_id') + .notNull() + .references(() => members.id), + roundId: integer('round_id').references(() => rounds.id), + title: varchar('title', { length: 500 }).notNull(), + url: varchar('url', { length: 1000 }).notNull().unique(), + publishedAt: timestamp('published_at', { withTimezone: true }).notNull(), + description: text('description'), + collectedAt: timestamp('collected_at', { withTimezone: true }).defaultNow(), + }, + (table) => ({ + memberIdIdx: index('idx_posts_member_id').on(table.memberId), + roundIdIdx: index('idx_posts_round_id').on(table.roundId), + }) +); + +/** + * 출석 (Attendance) + * 회차별 멤버의 출석 상태 + */ +export const attendance = pgTable( + 'attendance', + { + id: uuid('id').primaryKey().defaultRandom(), + memberId: uuid('member_id') + .notNull() + .references(() => members.id), + roundId: integer('round_id') + .notNull() + .references(() => rounds.id), + status: varchar('status', { length: 20 }).notNull().default(AttendanceStatus.PENDING), + submittedAt: timestamp('submitted_at', { withTimezone: true }), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow(), + }, + (table) => ({ + memberRoundUnique: uniqueIndex('attendance_member_round_unique').on(table.memberId, table.roundId), + roundIdIdx: index('idx_attendance_round_id').on(table.roundId), + }) +); + +/** + * 벌금 (Fines) + * 지각/결석 시 부과되는 벌금 + */ +export const fines = pgTable( + 'fines', + { + id: uuid('id').primaryKey().defaultRandom(), + memberId: uuid('member_id') + .notNull() + .references(() => members.id), + roundId: integer('round_id') + .notNull() + .references(() => rounds.id), + type: varchar('type', { length: 20 }).notNull(), + amount: integer('amount').notNull(), + status: varchar('status', { length: 20 }).notNull().default(FineStatus.UNPAID), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(), + paidAt: timestamp('paid_at', { withTimezone: true }), + }, + (table) => ({ + memberRoundUnique: uniqueIndex('fines_member_round_unique').on(table.memberId, table.roundId), + statusIdx: index('idx_fines_status').on(table.status), + }) +); + +/** + * 키워드 통계 (Keywords) + * 스터디원 글에서 추출된 키워드 빈도 + */ +export const keywords = pgTable('keywords', { + id: uuid('id').primaryKey().defaultRandom(), + keyword: varchar('keyword', { length: 100 }).notNull().unique(), + frequency: integer('frequency').default(1), + lastUpdated: timestamp('last_updated', { withTimezone: true }).defaultNow(), +}); + +/** + * 큐레이션 소스 (Curation Sources) + * 외부 컨퍼런스/아티클 수집 소스 + */ +export const curationSources = pgTable('curation_sources', { + id: uuid('id').primaryKey().defaultRandom(), + url: varchar('url', { length: 500 }).notNull().unique(), + name: varchar('name', { length: 200 }).notNull(), + category: varchar('category', { length: 50 }).notNull(), + isActive: boolean('is_active').default(true), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(), +}); + + +/** + * 큐레이션 아이템 (Curation Items) + * 수집된 외부 컨텐츠 + */ +export const curationItems = pgTable( + 'curation_items', + { + id: uuid('id').primaryKey().defaultRandom(), + sourceId: uuid('source_id').references(() => curationSources.id), + title: varchar('title', { length: 500 }).notNull(), + url: varchar('url', { length: 1000 }).notNull().unique(), + publishedAt: timestamp('published_at', { withTimezone: true }), + category: varchar('category', { length: 50 }).notNull(), + tags: text('tags').array(), + relevanceScore: real('relevance_score').default(0), + isShared: boolean('is_shared').default(false), + sharedAt: timestamp('shared_at', { withTimezone: true }), + collectedAt: timestamp('collected_at', { withTimezone: true }).defaultNow(), + }, + (table) => ({ + isSharedIdx: index('idx_curation_items_is_shared').on(table.isShared), + }) +); + +/** + * 설정 (Config) + * 스터디 설정 키-값 저장소 + */ +export const config = pgTable('config', { + key: varchar('key', { length: 100 }).primaryKey(), + value: text('value').notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow(), +}); + +// ============================================ +// Relations +// ============================================ + +export const membersRelations = relations(members, ({ many, one }) => ({ + 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 }) => ({ + posts: many(posts), + attendance: many(attendance), + fines: many(fines), +})); + +export const postsRelations = relations(posts, ({ one }) => ({ + member: one(members, { + fields: [posts.memberId], + references: [members.id], + }), + round: one(rounds, { + fields: [posts.roundId], + references: [rounds.id], + }), +})); + +export const attendanceRelations = relations(attendance, ({ one }) => ({ + member: one(members, { + fields: [attendance.memberId], + references: [members.id], + }), + round: one(rounds, { + fields: [attendance.roundId], + references: [rounds.id], + }), +})); + +export const finesRelations = relations(fines, ({ one }) => ({ + member: one(members, { + fields: [fines.memberId], + references: [members.id], + }), + round: one(rounds, { + fields: [fines.roundId], + references: [rounds.id], + }), +})); + +export const curationSourcesRelations = relations(curationSources, ({ many }) => ({ + items: many(curationItems), +})); + +export const curationItemsRelations = relations(curationItems, ({ one }) => ({ + source: one(curationSources, { + fields: [curationItems.sourceId], + references: [curationSources.id], + }), +})); + + +// ============================================ +// Type Exports (for use in application code) +// ============================================ + +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; + +export type Post = typeof posts.$inferSelect; +export type NewPost = typeof posts.$inferInsert; + +export type Attendance = typeof attendance.$inferSelect; +export type NewAttendance = typeof attendance.$inferInsert; + +export type Fine = typeof fines.$inferSelect; +export type NewFine = typeof fines.$inferInsert; + +export type Keyword = typeof keywords.$inferSelect; +export type NewKeyword = typeof keywords.$inferInsert; + +export type CurationSource = typeof curationSources.$inferSelect; +export type NewCurationSource = typeof curationSources.$inferInsert; + +export type CurationItem = typeof curationItems.$inferSelect; +export type NewCurationItem = typeof curationItems.$inferInsert; + +export type Config = typeof config.$inferSelect; +export type NewConfig = typeof config.$inferInsert; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts new file mode 100644 index 0000000..ed471a9 --- /dev/null +++ b/packages/shared/src/index.ts @@ -0,0 +1,6 @@ +// @blog-study/shared +// 공유 유틸리티, 타입, 데이터베이스 스키마 + +export * from './config'; +export * as db from './db'; +export * as utils from './utils'; diff --git a/packages/shared/src/utils/date-utils.property.test.ts b/packages/shared/src/utils/date-utils.property.test.ts new file mode 100644 index 0000000..ede1701 --- /dev/null +++ b/packages/shared/src/utils/date-utils.property.test.ts @@ -0,0 +1,328 @@ +/** + * Property-Based Tests for Date Utilities + * **Feature: blog-study-discord-bot, Property 11: Round Date Calculation** + * **Validates: Requirements 5.1** + */ + +import { describe, it, expect } from 'vitest'; +import * as fc from 'fast-check'; +import { + calculateRoundDates, + validateRoundDuration, + validateRoundStartsOnMonday, + validateRoundEndsOnSunday, + isMonday, + isSunday, + getRoundNumberByDate, + determineAttendanceStatus, + isGracePeriod, + isDeadlinePassed, + isGracePeriodEnded, + generateAllRoundDates, +} from './date-utils'; + +// 월요일 날짜 생성 arbitrary +const mondayArbitrary = fc.date({ + min: new Date('2020-01-06'), // 월요일 + max: new Date('2030-12-30'), +}).map(date => { + // 가장 가까운 월요일로 조정 + const day = date.getDay(); + const daysToMonday = day === 0 ? 1 : (day === 1 ? 0 : 8 - day); + const monday = new Date(date); + monday.setDate(monday.getDate() + daysToMonday); + monday.setHours(0, 0, 0, 0); + return monday; +}); + +// 회차 번호 arbitrary (1-100) +const roundNumberArbitrary = fc.integer({ min: 1, max: 100 }); + +describe('Date Utils Property Tests', () => { + /** + * **Feature: blog-study-discord-bot, Property 11: Round Date Calculation** + * *For any* study start date and round number, the round SHALL span exactly 2 weeks + * from Monday 00:00 to the following Sunday 23:59, with grace period ending Monday 23:59. + * **Validates: Requirements 5.1** + */ + describe('Property 11: Round Date Calculation', () => { + it('should always start on Monday', () => { + fc.assert( + fc.property( + mondayArbitrary, + roundNumberArbitrary, + (startDate, roundNumber) => { + const roundDates = calculateRoundDates(startDate, roundNumber); + expect(validateRoundStartsOnMonday(roundDates)).toBe(true); + expect(isMonday(roundDates.startDate)).toBe(true); + } + ), + { numRuns: 100 } + ); + }); + + it('should always end on Sunday', () => { + fc.assert( + fc.property( + mondayArbitrary, + roundNumberArbitrary, + (startDate, roundNumber) => { + const roundDates = calculateRoundDates(startDate, roundNumber); + expect(validateRoundEndsOnSunday(roundDates)).toBe(true); + expect(isSunday(roundDates.endDate)).toBe(true); + } + ), + { numRuns: 100 } + ); + }); + + it('should span exactly 2 weeks (14 days)', () => { + fc.assert( + fc.property( + mondayArbitrary, + roundNumberArbitrary, + (startDate, roundNumber) => { + const roundDates = calculateRoundDates(startDate, roundNumber); + expect(validateRoundDuration(roundDates)).toBe(true); + } + ), + { numRuns: 100 } + ); + }); + + it('should have grace period end on Monday 23:59', () => { + fc.assert( + fc.property( + mondayArbitrary, + roundNumberArbitrary, + (startDate, roundNumber) => { + const roundDates = calculateRoundDates(startDate, roundNumber); + // Grace end should be Monday (day after Sunday end) + expect(isMonday(roundDates.graceEndDate)).toBe(true); + // Should be at 23:59 + expect(roundDates.graceEndDate.getHours()).toBe(23); + expect(roundDates.graceEndDate.getMinutes()).toBe(59); + } + ), + { numRuns: 100 } + ); + }); + + it('should have consecutive rounds without gaps', () => { + fc.assert( + fc.property( + mondayArbitrary, + fc.integer({ min: 1, max: 50 }), + (startDate, roundNumber) => { + const round1 = calculateRoundDates(startDate, roundNumber); + const round2 = calculateRoundDates(startDate, roundNumber + 1); + + // Round 2 should start exactly 2 weeks after Round 1 + const expectedStart = new Date(round1.startDate); + expectedStart.setDate(expectedStart.getDate() + 14); + + expect(round2.startDate.getTime()).toBe(expectedStart.getTime()); + } + ), + { numRuns: 100 } + ); + }); + + it('should correctly identify round number from any date within round', () => { + fc.assert( + fc.property( + mondayArbitrary, + roundNumberArbitrary, + fc.integer({ min: 0, max: 13 }), // day offset within round + (startDate, roundNumber, dayOffset) => { + const roundDates = calculateRoundDates(startDate, roundNumber); + const testDate = new Date(roundDates.startDate); + testDate.setDate(testDate.getDate() + dayOffset); + + const detectedRound = getRoundNumberByDate(startDate, testDate); + expect(detectedRound).toBe(roundNumber); + } + ), + { numRuns: 100 } + ); + }); + }); + + describe('Attendance Status Determination', () => { + it('should mark as submitted when post is within round period', () => { + fc.assert( + fc.property( + mondayArbitrary, + roundNumberArbitrary, + fc.integer({ min: 0, max: 13 }), // day within round + (startDate, roundNumber, dayOffset) => { + const roundDates = calculateRoundDates(startDate, roundNumber); + const submissionDate = new Date(roundDates.startDate); + submissionDate.setDate(submissionDate.getDate() + dayOffset); + submissionDate.setHours(12, 0, 0, 0); // noon + + const status = determineAttendanceStatus(roundDates, submissionDate); + expect(status).toBe('submitted'); + } + ), + { numRuns: 100 } + ); + }); + + it('should mark as late when post is in grace period', () => { + fc.assert( + fc.property( + mondayArbitrary, + roundNumberArbitrary, + (startDate, roundNumber) => { + const roundDates = calculateRoundDates(startDate, roundNumber); + // Submit on grace day (Monday after deadline) + const submissionDate = new Date(roundDates.graceEndDate); + submissionDate.setHours(12, 0, 0, 0); // noon on grace day + + const status = determineAttendanceStatus(roundDates, submissionDate); + expect(status).toBe('late'); + } + ), + { numRuns: 100 } + ); + }); + + it('should mark as absent when post is after grace period', () => { + fc.assert( + fc.property( + mondayArbitrary, + roundNumberArbitrary, + (startDate, roundNumber) => { + const roundDates = calculateRoundDates(startDate, roundNumber); + // Submit after grace period + const submissionDate = new Date(roundDates.graceEndDate); + submissionDate.setDate(submissionDate.getDate() + 1); + + const status = determineAttendanceStatus(roundDates, submissionDate); + expect(status).toBe('absent'); + } + ), + { numRuns: 100 } + ); + }); + + it('should mark as pending when no submission', () => { + fc.assert( + fc.property( + mondayArbitrary, + roundNumberArbitrary, + (startDate, roundNumber) => { + const roundDates = calculateRoundDates(startDate, roundNumber); + const status = determineAttendanceStatus(roundDates, null); + expect(status).toBe('pending'); + } + ), + { numRuns: 100 } + ); + }); + }); + + describe('Period Detection', () => { + it('should correctly identify deadline passed', () => { + fc.assert( + fc.property( + mondayArbitrary, + roundNumberArbitrary, + (startDate, roundNumber) => { + const roundDates = calculateRoundDates(startDate, roundNumber); + + // Before deadline + const beforeDeadline = new Date(roundDates.endDate); + beforeDeadline.setHours(beforeDeadline.getHours() - 1); + expect(isDeadlinePassed(roundDates, beforeDeadline)).toBe(false); + + // After deadline + const afterDeadline = new Date(roundDates.endDate); + afterDeadline.setDate(afterDeadline.getDate() + 1); + expect(isDeadlinePassed(roundDates, afterDeadline)).toBe(true); + } + ), + { numRuns: 100 } + ); + }); + + it('should correctly identify grace period', () => { + fc.assert( + fc.property( + mondayArbitrary, + roundNumberArbitrary, + (startDate, roundNumber) => { + const roundDates = calculateRoundDates(startDate, roundNumber); + + // During grace period (Monday noon) + const duringGrace = new Date(roundDates.graceEndDate); + duringGrace.setHours(12, 0, 0, 0); + expect(isGracePeriod(roundDates, duringGrace)).toBe(true); + + // Before grace period (during round) + const beforeGrace = new Date(roundDates.endDate); + beforeGrace.setHours(beforeGrace.getHours() - 1); + expect(isGracePeriod(roundDates, beforeGrace)).toBe(false); + } + ), + { numRuns: 100 } + ); + }); + + it('should correctly identify grace period ended', () => { + fc.assert( + fc.property( + mondayArbitrary, + roundNumberArbitrary, + (startDate, roundNumber) => { + const roundDates = calculateRoundDates(startDate, roundNumber); + + // After grace period + const afterGrace = new Date(roundDates.graceEndDate); + afterGrace.setDate(afterGrace.getDate() + 1); + expect(isGracePeriodEnded(roundDates, afterGrace)).toBe(true); + + // During grace period + const duringGrace = new Date(roundDates.graceEndDate); + duringGrace.setHours(12, 0, 0, 0); + expect(isGracePeriodEnded(roundDates, duringGrace)).toBe(false); + } + ), + { numRuns: 100 } + ); + }); + }); + + describe('Generate All Rounds', () => { + it('should generate correct number of rounds', () => { + fc.assert( + fc.property( + mondayArbitrary, + fc.integer({ min: 1, max: 20 }), + (startDate, totalRounds) => { + const rounds = generateAllRoundDates(startDate, totalRounds); + expect(rounds.length).toBe(totalRounds); + } + ), + { numRuns: 100 } + ); + }); + + it('should have all rounds with correct round numbers', () => { + fc.assert( + fc.property( + mondayArbitrary, + fc.integer({ min: 1, max: 20 }), + (startDate, totalRounds) => { + const rounds = generateAllRoundDates(startDate, totalRounds); + rounds.forEach((round, index) => { + expect(round.roundNumber).toBe(index + 1); + }); + } + ), + { numRuns: 100 } + ); + }); + }); +}); diff --git a/packages/shared/src/utils/date-utils.ts b/packages/shared/src/utils/date-utils.ts new file mode 100644 index 0000000..2c187e1 --- /dev/null +++ b/packages/shared/src/utils/date-utils.ts @@ -0,0 +1,247 @@ +/** + * 날짜 유틸리티 + * 회차 날짜 계산, 마감일 계산, 지각/결석 판정 + * Requirements: 5.1, 5.4, 5.5, 5.6 + */ + +/** + * 회차 정보 인터페이스 + */ +export interface RoundDates { + roundNumber: number; + startDate: Date; // 월요일 00:00 + endDate: Date; // 일요일 23:59 + graceEndDate: Date; // 월요일 23:59 (지각 마감) +} + +/** + * 출석 상태 + */ +export type AttendanceStatus = 'pending' | 'submitted' | 'late' | 'absent'; + +/** + * 밀리초 상수 + */ +const MS_PER_DAY = 24 * 60 * 60 * 1000; +const MS_PER_WEEK = 7 * MS_PER_DAY; +const ROUND_DURATION_WEEKS = 2; + +/** + * 날짜를 해당 일의 시작(00:00:00.000)으로 설정 + */ +export function startOfDay(date: Date): Date { + const result = new Date(date); + result.setHours(0, 0, 0, 0); + return result; +} + +/** + * 날짜를 해당 일의 끝(23:59:59.999)으로 설정 + */ +export function endOfDay(date: Date): Date { + const result = new Date(date); + result.setHours(23, 59, 59, 999); + return result; +} + +/** + * 주어진 날짜가 월요일인지 확인 + * @param date 확인할 날짜 + * @returns 월요일이면 true + */ +export function isMonday(date: Date): boolean { + return date.getDay() === 1; +} + +/** + * 주어진 날짜가 일요일인지 확인 + * @param date 확인할 날짜 + * @returns 일요일이면 true + */ +export function isSunday(date: Date): boolean { + return date.getDay() === 0; +} + +/** + * 주어진 날짜에서 가장 가까운 이전 월요일을 찾음 + * (해당 날짜가 월요일이면 그 날짜 반환) + */ +export function getPreviousMonday(date: Date): Date { + const result = new Date(date); + const day = result.getDay(); + // 일요일(0)은 6일 전, 월요일(1)은 0일 전, 화요일(2)은 1일 전... + const daysToSubtract = day === 0 ? 6 : day - 1; + result.setDate(result.getDate() - daysToSubtract); + return startOfDay(result); +} + +/** + * 스터디 시작일로부터 특정 회차의 날짜 정보 계산 + * 각 회차는 2주 (월요일 시작 ~ 다음주 일요일 마감) + * + * @param studyStartDate 스터디 시작일 (월요일이어야 함) + * @param roundNumber 회차 번호 (1부터 시작) + * @returns 회차 날짜 정보 + */ +export function calculateRoundDates(studyStartDate: Date, roundNumber: number): RoundDates { + if (roundNumber < 1) { + throw new Error('회차 번호는 1 이상이어야 합니다.'); + } + + // 시작일을 월요일 00:00으로 정규화 + const normalizedStart = getPreviousMonday(studyStartDate); + + // 회차 시작일 계산: (roundNumber - 1) * 2주 + const roundStartMs = normalizedStart.getTime() + (roundNumber - 1) * ROUND_DURATION_WEEKS * MS_PER_WEEK; + const startDate = new Date(roundStartMs); + + // 회차 종료일: 시작일 + 13일 (2주차 일요일) + const endDate = new Date(roundStartMs + 13 * MS_PER_DAY); + endDate.setHours(23, 59, 59, 999); + + // 지각 마감일: 종료일 다음날 (월요일) 23:59 + const graceEndDate = new Date(roundStartMs + 14 * MS_PER_DAY); + graceEndDate.setHours(23, 59, 59, 999); + + return { + roundNumber, + startDate: startOfDay(startDate), + endDate, + graceEndDate, + }; +} + +/** + * 주어진 날짜가 속한 회차 번호 계산 + * + * @param studyStartDate 스터디 시작일 + * @param targetDate 확인할 날짜 + * @returns 회차 번호 (스터디 시작 전이면 0) + */ +export function getRoundNumberByDate(studyStartDate: Date, targetDate: Date): number { + const normalizedStart = getPreviousMonday(studyStartDate); + const targetMs = targetDate.getTime(); + const startMs = normalizedStart.getTime(); + + if (targetMs < startMs) { + return 0; // 스터디 시작 전 + } + + const daysDiff = Math.floor((targetMs - startMs) / MS_PER_DAY); + const roundNumber = Math.floor(daysDiff / (ROUND_DURATION_WEEKS * 7)) + 1; + + return roundNumber; +} + +/** + * 현재 회차가 마감되었는지 확인 (일요일 23:59 이후) + */ +export function isDeadlinePassed(roundDates: RoundDates, currentDate: Date = new Date()): boolean { + return currentDate.getTime() > roundDates.endDate.getTime(); +} + +/** + * 현재가 지각 기간인지 확인 (마감 후 ~ 지각 마감 전) + */ +export function isGracePeriod(roundDates: RoundDates, currentDate: Date = new Date()): boolean { + const currentMs = currentDate.getTime(); + return currentMs > roundDates.endDate.getTime() && currentMs <= roundDates.graceEndDate.getTime(); +} + +/** + * 지각 기간이 종료되었는지 확인 (월요일 23:59 이후) + */ +export function isGracePeriodEnded(roundDates: RoundDates, currentDate: Date = new Date()): boolean { + return currentDate.getTime() > roundDates.graceEndDate.getTime(); +} + +/** + * 포스트 제출 시점에 따른 출석 상태 결정 + * + * @param roundDates 회차 날짜 정보 + * @param submissionDate 제출 시점 + * @returns 출석 상태 + */ +export function determineAttendanceStatus( + roundDates: RoundDates, + submissionDate: Date | null +): AttendanceStatus { + if (!submissionDate) { + return 'pending'; + } + + const submissionMs = submissionDate.getTime(); + + // 회차 기간 내 제출 → 출석 + if (submissionMs <= roundDates.endDate.getTime()) { + return 'submitted'; + } + + // 지각 기간 내 제출 → 지각 + if (submissionMs <= roundDates.graceEndDate.getTime()) { + return 'late'; + } + + // 지각 기간 이후 → 결석 (이미 지난 회차에 대한 제출) + return 'absent'; +} + +/** + * 마감까지 남은 일수 계산 + */ +export function getDaysUntilDeadline(roundDates: RoundDates, currentDate: Date = new Date()): number { + const diffMs = roundDates.endDate.getTime() - currentDate.getTime(); + if (diffMs <= 0) { + return 0; + } + return Math.ceil(diffMs / MS_PER_DAY); +} + +/** + * 지각 마감까지 남은 일수 계산 + */ +export function getDaysUntilGraceEnd(roundDates: RoundDates, currentDate: Date = new Date()): number { + const diffMs = roundDates.graceEndDate.getTime() - currentDate.getTime(); + if (diffMs <= 0) { + return 0; + } + return Math.ceil(diffMs / MS_PER_DAY); +} + +/** + * 총 회차 수에 따른 모든 회차 날짜 생성 + */ +export function generateAllRoundDates(studyStartDate: Date, totalRounds: number): RoundDates[] { + const rounds: RoundDates[] = []; + for (let i = 1; i <= totalRounds; i++) { + rounds.push(calculateRoundDates(studyStartDate, i)); + } + return rounds; +} + +/** + * 회차 기간이 정확히 2주(14일)인지 검증 + */ +export function validateRoundDuration(roundDates: RoundDates): boolean { + const startMs = roundDates.startDate.getTime(); + const endMs = roundDates.endDate.getTime(); + + // 시작일부터 종료일까지 13일 (0~13일, 총 14일) + // endDate는 23:59:59.999이므로 약 13.999일이 됨, floor 사용 + const durationDays = Math.floor((endMs - startMs) / MS_PER_DAY); + return durationDays === 13; // 0일차(월) ~ 13일차(일) = 14일 +} + +/** + * 회차 시작일이 월요일인지 검증 + */ +export function validateRoundStartsOnMonday(roundDates: RoundDates): boolean { + return isMonday(roundDates.startDate); +} + +/** + * 회차 종료일이 일요일인지 검증 + */ +export function validateRoundEndsOnSunday(roundDates: RoundDates): boolean { + return isSunday(roundDates.endDate); +} diff --git a/packages/shared/src/utils/index.ts b/packages/shared/src/utils/index.ts new file mode 100644 index 0000000..8568e2b --- /dev/null +++ b/packages/shared/src/utils/index.ts @@ -0,0 +1,6 @@ +// 공유 유틸리티 함수 + +export * from './serialization'; +export * from './url-validator'; +export * from './date-utils'; +export * from './keyword-extractor'; diff --git a/packages/shared/src/utils/keyword-extractor.property.test.ts b/packages/shared/src/utils/keyword-extractor.property.test.ts new file mode 100644 index 0000000..c368452 --- /dev/null +++ b/packages/shared/src/utils/keyword-extractor.property.test.ts @@ -0,0 +1,417 @@ +/** + * Property-Based Tests for Keyword Extractor + * **Feature: blog-study-discord-bot, Property 37: Keyword Extraction** + * **Validates: Requirements 14.1, 14.6** + */ + +import { describe, it, expect } from 'vitest'; +import * as fc from 'fast-check'; +import { + extractKeywords, + extractKeywordsFromPost, + extractKeywordsFromMultiple, + normalizeText, + normalizeKeyword, + isStopWord, + getKoreanStopWords, + getEnglishStopWords, +} from './keyword-extractor'; + +describe('Keyword Extractor Property Tests', () => { + /** + * **Feature: blog-study-discord-bot, Property 37: Keyword Extraction** + * *For any* post title and description, keyword extraction SHALL produce a list + * of normalized keywords with stop words filtered out. + * **Validates: Requirements 14.1, 14.6** + */ + describe('Property 37: Keyword Extraction', () => { + it('should filter out all Korean stop words from extracted keywords', () => { + const koreanStopWords = getKoreanStopWords(); + + fc.assert( + fc.property( + fc.array(fc.constantFrom(...koreanStopWords), { minLength: 1, maxLength: 10 }), + fc.array(fc.stringMatching(/^[가-힣]{2,10}$/), { minLength: 1, maxLength: 5 }), + (stopWords, validWords) => { + // Create text with mix of stop words and valid words + const text = [...stopWords, ...validWords].join(' '); + const keywords = extractKeywords(text); + + // No stop words should be in the result + for (const keyword of keywords) { + expect(koreanStopWords.has(keyword)).toBe(false); + } + } + ), + { numRuns: 100 } + ); + }); + + it('should filter out all English stop words from extracted keywords', () => { + const englishStopWords = getEnglishStopWords(); + + fc.assert( + fc.property( + fc.array(fc.constantFrom(...englishStopWords), { minLength: 1, maxLength: 10 }), + fc.array(fc.stringMatching(/^[a-z]{3,10}$/), { minLength: 1, maxLength: 5 }), + (stopWords, validWords) => { + // Create text with mix of stop words and valid words + const text = [...stopWords, ...validWords].join(' '); + const keywords = extractKeywords(text); + + // No stop words should be in the result + for (const keyword of keywords) { + expect(englishStopWords.has(keyword)).toBe(false); + } + } + ), + { numRuns: 100 } + ); + }); + + it('should normalize all extracted keywords to lowercase', () => { + fc.assert( + fc.property( + fc.array( + fc.stringMatching(/^[A-Za-z]{3,10}$/), + { minLength: 1, maxLength: 10 } + ), + (words) => { + const text = words.join(' '); + const keywords = extractKeywords(text); + + // All keywords should be lowercase + for (const keyword of keywords) { + expect(keyword).toBe(keyword.toLowerCase()); + } + } + ), + { numRuns: 100 } + ); + }); + + it('should remove duplicate keywords', () => { + fc.assert( + fc.property( + fc.stringMatching(/^[a-z]{3,10}$/), + fc.integer({ min: 2, max: 10 }), + (word, repeatCount) => { + // Create text with repeated word + const text = Array(repeatCount).fill(word).join(' '); + const keywords = extractKeywords(text); + + // Should have at most one instance of the word + const wordCount = keywords.filter(k => k === word).length; + expect(wordCount).toBeLessThanOrEqual(1); + } + ), + { numRuns: 100 } + ); + }); + + it('should filter out words shorter than minimum length', () => { + const minLength = 3; + + fc.assert( + fc.property( + fc.array( + fc.stringMatching(/^[a-z]{1,10}$/), + { minLength: 1, maxLength: 20 } + ), + (words) => { + const text = words.join(' '); + const keywords = extractKeywords(text, { minLength }); + + // All keywords should meet minimum length + for (const keyword of keywords) { + expect(keyword.length).toBeGreaterThanOrEqual(minLength); + } + } + ), + { numRuns: 100 } + ); + }); + + it('should filter out pure numeric strings by default', () => { + fc.assert( + fc.property( + fc.array(fc.stringMatching(/^\d{1,10}$/), { minLength: 1, maxLength: 5 }), + fc.array(fc.stringMatching(/^[a-z]{3,10}$/), { minLength: 1, maxLength: 5 }), + (numbers, words) => { + const text = [...numbers, ...words].join(' '); + const keywords = extractKeywords(text, { includeNumbers: false }); + + // No pure numeric keywords should be in the result + for (const keyword of keywords) { + expect(/^\d+$/.test(keyword)).toBe(false); + } + } + ), + { numRuns: 100 } + ); + }); + + it('should include numeric strings when includeNumbers is true', () => { + fc.assert( + fc.property( + fc.stringMatching(/^\d{3,10}$/), + (number) => { + const keywords = extractKeywords(number, { includeNumbers: true, minLength: 1 }); + + // The number should be included + expect(keywords).toContain(number); + } + ), + { numRuns: 100 } + ); + }); + + it('should extract keywords from post title and description', () => { + fc.assert( + fc.property( + fc.array(fc.stringMatching(/^[a-z]{3,10}$/), { minLength: 1, maxLength: 5 }), + fc.array(fc.stringMatching(/^[a-z]{3,10}$/), { minLength: 1, maxLength: 5 }), + (titleWords, descWords) => { + const title = titleWords.join(' '); + const description = descWords.join(' '); + const keywords = extractKeywordsFromPost(title, description); + + // Keywords should be from both title and description + const allWords = new Set([...titleWords, ...descWords]); + for (const keyword of keywords) { + // Each keyword should be from the original words (after filtering) + if (!isStopWord(keyword) && keyword.length >= 2) { + expect(allWords.has(keyword)).toBe(true); + } + } + } + ), + { numRuns: 100 } + ); + }); + + it('should respect maxKeywords limit', () => { + fc.assert( + fc.property( + fc.array( + fc.stringMatching(/^[a-z]{3,10}$/), + { minLength: 10, maxLength: 20 } + ), + fc.integer({ min: 1, max: 5 }), + (words, maxKeywords) => { + const text = words.join(' '); + const keywords = extractKeywords(text, { maxKeywords }); + + expect(keywords.length).toBeLessThanOrEqual(maxKeywords); + } + ), + { numRuns: 100 } + ); + }); + + it('should handle custom stop words', () => { + fc.assert( + fc.property( + fc.array(fc.stringMatching(/^[a-z]{3,10}$/), { minLength: 3, maxLength: 10 }), + (words) => { + // Use first word as custom stop word (guaranteed to exist due to minLength: 3) + const customStopWord = words[0] as string; + const text = words.join(' '); + const keywords = extractKeywords(text, { customStopWords: [customStopWord] }); + + // Custom stop word should not be in results + expect(keywords).not.toContain(customStopWord); + } + ), + { numRuns: 100 } + ); + }); + }); + + describe('Text Normalization', () => { + it('should convert all text to lowercase', () => { + fc.assert( + fc.property( + fc.string(), + (text) => { + const normalized = normalizeText(text); + expect(normalized).toBe(normalized.toLowerCase()); + } + ), + { numRuns: 100 } + ); + }); + + it('should remove HTML tags', () => { + fc.assert( + fc.property( + fc.stringMatching(/^[a-z]+$/), + fc.stringMatching(/^[a-z]+$/), + (tagName, content) => { + const html = `<${tagName}>${content}`; + const normalized = normalizeText(html); + + // Should not contain angle brackets + expect(normalized).not.toContain('<'); + expect(normalized).not.toContain('>'); + // Should contain the content + expect(normalized).toContain(content); + } + ), + { numRuns: 100 } + ); + }); + + it('should remove URLs from text', () => { + fc.assert( + fc.property( + fc.webUrl(), + fc.stringMatching(/^[a-z]{3,10}$/), + (url, word) => { + const text = `${word} ${url} ${word}`; + const normalized = normalizeText(text); + + // Should not contain http or https + expect(normalized).not.toContain('http'); + } + ), + { numRuns: 100 } + ); + }); + + it('should return empty string for null/undefined/empty input', () => { + expect(normalizeText('')).toBe(''); + expect(normalizeText(null as unknown as string)).toBe(''); + expect(normalizeText(undefined as unknown as string)).toBe(''); + }); + }); + + describe('Keyword Normalization', () => { + it('should trim whitespace from keywords', () => { + fc.assert( + fc.property( + fc.stringMatching(/^[a-z]{3,10}$/), + fc.stringOf(fc.constantFrom(' ', '\t'), { minLength: 0, maxLength: 3 }), + (word, whitespace) => { + const paddedWord = whitespace + word + whitespace; + const normalized = normalizeKeyword(paddedWord); + + expect(normalized).toBe(word); + } + ), + { numRuns: 100 } + ); + }); + + it('should convert keywords to lowercase', () => { + fc.assert( + fc.property( + fc.stringMatching(/^[A-Z]{3,10}$/), + (upperWord) => { + const normalized = normalizeKeyword(upperWord); + expect(normalized).toBe(upperWord.toLowerCase()); + } + ), + { numRuns: 100 } + ); + }); + }); + + describe('Stop Word Detection', () => { + it('should identify all Korean stop words', () => { + const koreanStopWords = getKoreanStopWords(); + + for (const stopWord of koreanStopWords) { + expect(isStopWord(stopWord)).toBe(true); + } + }); + + it('should identify all English stop words', () => { + const englishStopWords = getEnglishStopWords(); + + for (const stopWord of englishStopWords) { + expect(isStopWord(stopWord)).toBe(true); + } + }); + + it('should be case-insensitive for stop word detection', () => { + fc.assert( + fc.property( + fc.constantFrom(...getEnglishStopWords()), + (stopWord) => { + expect(isStopWord(stopWord.toUpperCase())).toBe(true); + expect(isStopWord(stopWord.toLowerCase())).toBe(true); + } + ), + { numRuns: 100 } + ); + }); + }); + + describe('Multiple Text Extraction', () => { + it('should merge keywords from multiple texts without duplicates', () => { + fc.assert( + fc.property( + fc.array( + fc.array(fc.stringMatching(/^[a-z]{3,10}$/), { minLength: 1, maxLength: 5 }), + { minLength: 2, maxLength: 5 } + ), + (textArrays) => { + const texts = textArrays.map(words => words.join(' ')); + const keywords = extractKeywordsFromMultiple(texts); + + // No duplicates + const uniqueKeywords = new Set(keywords); + expect(keywords.length).toBe(uniqueKeywords.size); + } + ), + { numRuns: 100 } + ); + }); + + it('should return empty array for empty input', () => { + expect(extractKeywordsFromMultiple([])).toEqual([]); + expect(extractKeywordsFromMultiple(['', ' ', '\n'])).toEqual([]); + }); + }); + + describe('Edge Cases', () => { + it('should handle empty strings', () => { + expect(extractKeywords('')).toEqual([]); + expect(extractKeywords(' ')).toEqual([]); + expect(extractKeywords('\n\t')).toEqual([]); + }); + + it('should handle text with only stop words', () => { + const stopWordsText = 'the and or but if then'; + const keywords = extractKeywords(stopWordsText); + expect(keywords).toEqual([]); + }); + + it('should handle text with only short words', () => { + const shortWordsText = 'a b c d e f'; + const keywords = extractKeywords(shortWordsText, { minLength: 2 }); + expect(keywords).toEqual([]); + }); + + it('should handle Korean text', () => { + const koreanText = '프로그래밍 자바스크립트 타입스크립트 개발'; + const keywords = extractKeywords(koreanText); + + // Should extract Korean keywords + expect(keywords.length).toBeGreaterThan(0); + // Should not contain Korean stop words + const koreanStopWords = getKoreanStopWords(); + for (const keyword of keywords) { + expect(koreanStopWords.has(keyword)).toBe(false); + } + }); + + it('should handle mixed Korean and English text', () => { + const mixedText = 'React 컴포넌트 TypeScript 타입 시스템'; + const keywords = extractKeywords(mixedText); + + expect(keywords.length).toBeGreaterThan(0); + }); + }); +}); diff --git a/packages/shared/src/utils/keyword-extractor.ts b/packages/shared/src/utils/keyword-extractor.ts new file mode 100644 index 0000000..073114d --- /dev/null +++ b/packages/shared/src/utils/keyword-extractor.ts @@ -0,0 +1,300 @@ +/** + * 키워드 추출 유틸리티 + * 텍스트에서 키워드 추출, 불용어 필터링 + * Requirements: 14.1, 14.6 + */ + +/** + * 한국어 불용어 목록 + * 일반적으로 의미가 없는 조사, 접속사, 대명사 등 + */ +const KOREAN_STOP_WORDS = new Set([ + // 조사 + '이', '가', '은', '는', '을', '를', '의', '에', '에서', '로', '으로', + '와', '과', '도', '만', '까지', '부터', '에게', '한테', '께', + // 접속사 + '그리고', '그러나', '그래서', '하지만', '또한', '또는', '및', '혹은', + // 대명사 + '나', '너', '우리', '저', '그', '그녀', '이것', '저것', '그것', + // 지시어 + '이런', '저런', '그런', '어떤', '무슨', '어느', + // 동사/형용사 어미 + '하다', '되다', '있다', '없다', '같다', '이다', + // 부사 + '매우', '아주', '정말', '너무', '많이', '조금', '잘', '더', '가장', + // 기타 + '것', '수', '등', '때', '중', '위', '후', '전', '내', '외', +]); + +/** + * 영어 불용어 목록 + */ +const ENGLISH_STOP_WORDS = new Set([ + // Articles + 'a', 'an', 'the', + // Prepositions + 'in', 'on', 'at', 'to', 'for', 'of', 'with', 'by', 'from', 'as', + 'into', 'through', 'during', 'before', 'after', 'above', 'below', + // Conjunctions + 'and', 'or', 'but', 'if', 'then', 'else', 'when', 'while', 'because', + // Pronouns + 'i', 'you', 'he', 'she', 'it', 'we', 'they', 'me', 'him', 'her', 'us', 'them', + 'my', 'your', 'his', 'its', 'our', 'their', 'this', 'that', 'these', 'those', + // Common verbs + 'is', 'are', 'was', 'were', 'be', 'been', 'being', 'have', 'has', 'had', + 'do', 'does', 'did', 'will', 'would', 'could', 'should', 'may', 'might', + 'can', 'must', 'shall', + // Others + 'what', 'which', 'who', 'whom', 'how', 'why', 'where', 'when', + 'all', 'each', 'every', 'both', 'few', 'more', 'most', 'other', 'some', + 'such', 'no', 'not', 'only', 'same', 'so', 'than', 'too', 'very', + 'just', 'also', 'now', 'here', 'there', 'about', 'over', 'again', +]); + +/** + * 모든 불용어 통합 + */ +const ALL_STOP_WORDS = new Set([...KOREAN_STOP_WORDS, ...ENGLISH_STOP_WORDS]); + +/** + * 키워드 추출 결과 + */ +export interface KeywordExtractionResult { + keywords: string[]; + originalText: string; +} + +/** + * 키워드 추출 옵션 + */ +export interface KeywordExtractorOptions { + minLength?: number; // 최소 키워드 길이 (기본: 2) + maxKeywords?: number; // 최대 키워드 수 (기본: 무제한) + includeNumbers?: boolean; // 숫자 포함 여부 (기본: false) + customStopWords?: string[]; // 추가 불용어 +} + +const DEFAULT_OPTIONS: Required = { + minLength: 2, + maxKeywords: Infinity, + includeNumbers: false, + customStopWords: [], +}; + +/** + * 텍스트 정규화 + * - 소문자 변환 + * - 특수문자 제거 + * - 연속 공백 제거 + */ +export function normalizeText(text: string): string { + if (!text || typeof text !== 'string') { + return ''; + } + + return text + .toLowerCase() + .trim() + // HTML 태그 제거 + .replace(/<[^>]*>/g, ' ') + // URL 제거 + .replace(/https?:\/\/[^\s]+/g, ' ') + // 이메일 제거 + .replace(/[\w.-]+@[\w.-]+\.\w+/g, ' ') + // 특수문자를 공백으로 변환 (한글, 영문, 숫자 제외) + .replace(/[^\w\sㄱ-ㅎㅏ-ㅣ가-힣]/g, ' ') + // 연속 공백 제거 + .replace(/\s+/g, ' ') + .trim(); +} + +/** + * 키워드 정규화 + * - 소문자 변환 + * - 앞뒤 공백 제거 + */ +export function normalizeKeyword(keyword: string): string { + if (!keyword || typeof keyword !== 'string') { + return ''; + } + return keyword.toLowerCase().trim(); +} + +/** + * 불용어인지 확인 + */ +export function isStopWord(word: string, customStopWords: string[] = []): boolean { + const normalized = normalizeKeyword(word); + if (!normalized) { + return true; + } + + if (ALL_STOP_WORDS.has(normalized)) { + return true; + } + + if (customStopWords.length > 0) { + const customSet = new Set(customStopWords.map(w => normalizeKeyword(w))); + if (customSet.has(normalized)) { + return true; + } + } + + return false; +} + +/** + * 유효한 키워드인지 확인 + */ +export function isValidKeyword( + word: string, + options: Required +): boolean { + const normalized = normalizeKeyword(word); + + // 빈 문자열 제외 + if (!normalized) { + return false; + } + + // 최소 길이 체크 + if (normalized.length < options.minLength) { + return false; + } + + // 불용어 체크 + if (isStopWord(normalized, options.customStopWords)) { + return false; + } + + // 숫자만으로 이루어진 단어 체크 + if (!options.includeNumbers && /^\d+$/.test(normalized)) { + return false; + } + + return true; +} + +/** + * 텍스트에서 키워드 추출 + * + * @param text 키워드를 추출할 텍스트 + * @param options 추출 옵션 + * @returns 추출된 키워드 배열 + */ +export function extractKeywords( + text: string, + options: KeywordExtractorOptions = {} +): string[] { + const mergedOptions: Required = { + ...DEFAULT_OPTIONS, + ...options, + }; + + if (!text || typeof text !== 'string') { + return []; + } + + const normalizedText = normalizeText(text); + if (!normalizedText) { + return []; + } + + // 단어 분리 + const words = normalizedText.split(/\s+/); + + // 유효한 키워드만 필터링 + const validKeywords = words + .map(normalizeKeyword) + .filter(word => isValidKeyword(word, mergedOptions)); + + // 중복 제거 + const uniqueKeywords = [...new Set(validKeywords)]; + + // 최대 개수 제한 + if (mergedOptions.maxKeywords !== Infinity && uniqueKeywords.length > mergedOptions.maxKeywords) { + return uniqueKeywords.slice(0, mergedOptions.maxKeywords); + } + + return uniqueKeywords; +} + +/** + * 여러 텍스트에서 키워드 추출 및 병합 + * + * @param texts 키워드를 추출할 텍스트 배열 + * @param options 추출 옵션 + * @returns 추출된 키워드 배열 (중복 제거됨) + */ +export function extractKeywordsFromMultiple( + texts: string[], + options: KeywordExtractorOptions = {} +): string[] { + if (!Array.isArray(texts)) { + return []; + } + + const allKeywords: string[] = []; + + for (const text of texts) { + const keywords = extractKeywords(text, options); + allKeywords.push(...keywords); + } + + // 중복 제거 + const uniqueKeywords = [...new Set(allKeywords)]; + + const mergedOptions: Required = { + ...DEFAULT_OPTIONS, + ...options, + }; + + // 최대 개수 제한 + if (mergedOptions.maxKeywords !== Infinity && uniqueKeywords.length > mergedOptions.maxKeywords) { + return uniqueKeywords.slice(0, mergedOptions.maxKeywords); + } + + return uniqueKeywords; +} + +/** + * 포스트 제목과 설명에서 키워드 추출 + * Requirements: 14.1 + * + * @param title 포스트 제목 + * @param description 포스트 설명 (선택) + * @param options 추출 옵션 + * @returns 추출된 키워드 배열 + */ +export function extractKeywordsFromPost( + title: string, + description?: string, + options: KeywordExtractorOptions = {} +): string[] { + const texts = [title]; + if (description) { + texts.push(description); + } + return extractKeywordsFromMultiple(texts, options); +} + +/** + * 불용어 목록 가져오기 (테스트용) + */ +export function getStopWords(): Set { + return new Set(ALL_STOP_WORDS); +} + +/** + * 한국어 불용어 목록 가져오기 + */ +export function getKoreanStopWords(): Set { + return new Set(KOREAN_STOP_WORDS); +} + +/** + * 영어 불용어 목록 가져오기 + */ +export function getEnglishStopWords(): Set { + return new Set(ENGLISH_STOP_WORDS); +} diff --git a/packages/shared/src/utils/serialization.ts b/packages/shared/src/utils/serialization.ts new file mode 100644 index 0000000..87e12aa --- /dev/null +++ b/packages/shared/src/utils/serialization.ts @@ -0,0 +1,84 @@ +import type { Post } from '../db/schema'; + +/** + * Serializable Post type for JSON storage/transfer + * Converts Date objects to ISO strings + */ +export interface SerializedPost { + id: string; + memberId: string; + roundId: number | null; + title: string; + url: string; + publishedAt: string; // ISO 8601 string + description: string | null; + collectedAt: string | null; // ISO 8601 string +} + +/** + * Serialize a Post object to JSON-safe format + * Converts Date objects to ISO strings for JSON storage + */ +export function serializePost(post: Post): SerializedPost { + return { + id: post.id, + memberId: post.memberId, + roundId: post.roundId, + title: post.title, + url: post.url, + publishedAt: post.publishedAt.toISOString(), + description: post.description, + collectedAt: post.collectedAt?.toISOString() ?? null, + }; +} + +/** + * Deserialize a JSON Post back to Post object + * Converts ISO strings back to Date objects + */ +export function deserializePost(serialized: SerializedPost): Post { + return { + id: serialized.id, + memberId: serialized.memberId, + roundId: serialized.roundId, + title: serialized.title, + url: serialized.url, + publishedAt: new Date(serialized.publishedAt), + description: serialized.description, + collectedAt: serialized.collectedAt ? new Date(serialized.collectedAt) : null, + }; +} + +/** + * Serialize Post to JSON string + */ +export function postToJson(post: Post): string { + return JSON.stringify(serializePost(post)); +} + +/** + * Deserialize Post from JSON string + */ +export function postFromJson(json: string): Post { + const parsed = JSON.parse(json) as SerializedPost; + return deserializePost(parsed); +} + +/** + * Check if two Post objects are equivalent + * Compares all fields including Date objects by value + */ +export function postsAreEqual(a: Post, b: Post): boolean { + return ( + a.id === b.id && + a.memberId === b.memberId && + a.roundId === b.roundId && + a.title === b.title && + a.url === b.url && + a.publishedAt.getTime() === b.publishedAt.getTime() && + a.description === b.description && + (a.collectedAt === null && b.collectedAt === null || + (a.collectedAt !== null && b.collectedAt !== null && + a.collectedAt.getTime() === b.collectedAt.getTime())) + ); +} diff --git a/packages/shared/src/utils/url-validator.property.test.ts b/packages/shared/src/utils/url-validator.property.test.ts new file mode 100644 index 0000000..9ce2fe3 --- /dev/null +++ b/packages/shared/src/utils/url-validator.property.test.ts @@ -0,0 +1,261 @@ +/** + * Property-Based Tests for URL Validator + * **Feature: blog-study-discord-bot, Property 2: Invalid URL Registration Rejection** + * **Validates: Requirements 1.3** + */ + +import { describe, it, expect } from 'vitest'; +import * as fc from 'fast-check'; +import { isValidUrl, isValidBlogUrl, validateBlogUrl, detectBlogPlatform, normalizeUrl } from './url-validator'; + +describe('URL Validator Property Tests', () => { + /** + * **Feature: blog-study-discord-bot, Property 2: Invalid URL Registration Rejection** + * *For any* string that does not match a valid URL pattern, attempting to register + * SHALL be rejected without creating a Member record. + * **Validates: Requirements 1.3** + */ + describe('Property 2: Invalid URL Registration Rejection', () => { + it('should reject all strings without http/https protocol', () => { + fc.assert( + fc.property( + fc.string().filter(s => !s.startsWith('http://') && !s.startsWith('https://')), + (invalidUrl) => { + expect(isValidUrl(invalidUrl)).toBe(false); + } + ), + { numRuns: 100 } + ); + }); + + it('should reject URLs with invalid protocols', () => { + const invalidProtocols = ['ftp://', 'file://', 'mailto:', 'javascript:', 'data:']; + + fc.assert( + fc.property( + fc.constantFrom(...invalidProtocols), + fc.webUrl().map(url => url.replace(/^https?:\/\//, '')), + (protocol, urlPart) => { + const invalidUrl = protocol + urlPart; + expect(isValidUrl(invalidUrl)).toBe(false); + } + ), + { numRuns: 100 } + ); + }); + + it('should reject empty strings and whitespace-only strings', () => { + fc.assert( + fc.property( + fc.stringOf(fc.constantFrom(' ', '\t', '\n', '\r')), + (whitespace) => { + expect(isValidUrl(whitespace)).toBe(false); + expect(isValidBlogUrl(whitespace)).toBe(false); + } + ), + { numRuns: 100 } + ); + }); + + it('should reject null-like and non-string values', () => { + const invalidValues = [null, undefined, '', ' ']; + invalidValues.forEach(value => { + expect(isValidUrl(value as string)).toBe(false); + expect(isValidBlogUrl(value as string)).toBe(false); + }); + }); + + it('should reject URLs without valid hostname', () => { + const invalidUrls = [ + 'http://', + 'https://', + 'http:///', + 'https:///', + 'http:// ', + 'https:// ', + ]; + + invalidUrls.forEach(url => { + expect(isValidUrl(url)).toBe(false); + }); + }); + + it('should reject malformed URLs', () => { + // Note: URL constructor is lenient and normalizes some malformed URLs + // e.g., 'http:example.com' becomes 'http://example.com/' + // We only test URLs that are truly invalid + const malformedUrls = [ + 'not a url', + 'http//missing-colon.com', + 'https//missing-colon.com', + '://no-protocol.com', + 'just-text', + 'www.example.com', // missing protocol + ]; + + malformedUrls.forEach(url => { + expect(isValidUrl(url)).toBe(false); + }); + }); + }); + + describe('Valid URL Acceptance', () => { + it('should accept all valid http/https URLs', () => { + fc.assert( + fc.property( + fc.webUrl(), + (validUrl) => { + expect(isValidUrl(validUrl)).toBe(true); + } + ), + { numRuns: 100 } + ); + }); + + it('should accept valid blog platform URLs', () => { + const validBlogUrls = [ + 'https://velog.io/@username', + 'https://velog.io/@user-name', + 'https://myblog.tistory.com', + 'https://my-blog.tistory.com', + 'https://medium.com/@username', + 'https://medium.com/@user-name', + ]; + + validBlogUrls.forEach(url => { + expect(isValidBlogUrl(url)).toBe(true); + const result = validateBlogUrl(url); + expect(result.isValid).toBe(true); + }); + }); + }); + + describe('Platform Detection', () => { + it('should correctly detect Velog URLs', () => { + fc.assert( + fc.property( + fc.stringMatching(/^[\w-]+$/), + (username) => { + const url = `https://velog.io/@${username}`; + if (isValidUrl(url)) { + expect(detectBlogPlatform(url)).toBe('velog'); + } + } + ), + { numRuns: 100 } + ); + }); + + it('should correctly detect Tistory URLs', () => { + fc.assert( + fc.property( + fc.stringMatching(/^[\w-]+$/), + (blogName) => { + const url = `https://${blogName}.tistory.com`; + if (isValidUrl(url)) { + expect(detectBlogPlatform(url)).toBe('tistory'); + } + } + ), + { numRuns: 100 } + ); + }); + + it('should correctly detect Medium URLs', () => { + fc.assert( + fc.property( + fc.stringMatching(/^[\w-]+$/), + (username) => { + const url = `https://medium.com/@${username}`; + if (isValidUrl(url)) { + expect(detectBlogPlatform(url)).toBe('medium'); + } + } + ), + { numRuns: 100 } + ); + }); + + it('should return unknown for other valid URLs', () => { + fc.assert( + fc.property( + fc.webUrl().filter(url => + !url.includes('velog.io') && + !url.includes('tistory.com') && + !url.includes('medium.com') + ), + (url) => { + expect(detectBlogPlatform(url)).toBe('unknown'); + } + ), + { numRuns: 100 } + ); + }); + }); + + describe('URL Normalization', () => { + it('should remove trailing slashes from valid URLs with paths', () => { + fc.assert( + fc.property( + fc.webUrl(), + fc.stringMatching(/^[\w-]+$/), + (baseUrl, pathSegment) => { + // Create a URL with a path and trailing slash + const urlWithPath = `${baseUrl}/${pathSegment}/`; + const normalized = normalizeUrl(urlWithPath); + if (normalized) { + // Normalized URL should not end with slash (except root) + expect(normalized.endsWith('/')).toBe(false); + } + } + ), + { numRuns: 100 } + ); + }); + + it('should return null for invalid URLs', () => { + fc.assert( + fc.property( + fc.string().filter(s => !s.startsWith('http://') && !s.startsWith('https://')), + (invalidUrl) => { + expect(normalizeUrl(invalidUrl)).toBeNull(); + } + ), + { numRuns: 100 } + ); + }); + }); + + describe('validateBlogUrl comprehensive result', () => { + it('should return complete validation result for valid URLs', () => { + fc.assert( + fc.property( + fc.webUrl(), + (url) => { + const result = validateBlogUrl(url); + expect(result.isValid).toBe(true); + expect(result.normalizedUrl).not.toBeNull(); + expect(['velog', 'tistory', 'medium', 'unknown']).toContain(result.platform); + expect(result.error).toBeUndefined(); + } + ), + { numRuns: 100 } + ); + }); + + it('should return error message for invalid URLs', () => { + fc.assert( + fc.property( + fc.string().filter(s => !s.startsWith('http://') && !s.startsWith('https://')), + (invalidUrl) => { + const result = validateBlogUrl(invalidUrl); + expect(result.isValid).toBe(false); + expect(result.normalizedUrl).toBeNull(); + expect(result.error).toBeDefined(); + } + ), + { numRuns: 100 } + ); + }); + }); +}); diff --git a/packages/shared/src/utils/url-validator.ts b/packages/shared/src/utils/url-validator.ts new file mode 100644 index 0000000..e169475 --- /dev/null +++ b/packages/shared/src/utils/url-validator.ts @@ -0,0 +1,182 @@ +/** + * URL 검증 유틸리티 + * 블로그 URL 형식 검증 및 플랫폼 감지 + * Requirements: 1.1, 1.3 + */ + +/** + * 지원하는 블로그 플랫폼 + */ +export type BlogPlatform = 'velog' | 'tistory' | 'medium' | 'unknown'; + +/** + * URL 검증 결과 + */ +export interface UrlValidationResult { + isValid: boolean; + platform: BlogPlatform; + normalizedUrl: string | null; + error?: string; +} + +/** + * 블로그 플랫폼별 URL 패턴 + */ +const PLATFORM_PATTERNS: Record = { + velog: /^https?:\/\/velog\.io\/@[\w-]+\/?$/, + tistory: /^https?:\/\/[\w-]+\.tistory\.com\/?$/, + medium: /^https?:\/\/medium\.com\/@[\w-]+\/?$/, + unknown: /^https?:\/\/.+/, +}; + +/** + * URL이 유효한 형식인지 검증 + * @param url 검증할 URL 문자열 + * @returns URL이 유효하면 true + */ +export function isValidUrl(url: string): boolean { + if (!url || typeof url !== 'string') { + return false; + } + + const trimmed = url.trim(); + if (trimmed.length === 0) { + return false; + } + + try { + const parsed = new URL(trimmed); + // http 또는 https 프로토콜만 허용 + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + return false; + } + // 호스트가 있어야 함 + if (!parsed.hostname || parsed.hostname.length === 0) { + return false; + } + // 호스트에 최소한 하나의 점이 있어야 함 (localhost 제외) + if (!parsed.hostname.includes('.') && parsed.hostname !== 'localhost') { + return false; + } + return true; + } catch { + return false; + } +} + +/** + * 블로그 URL이 유효한 형식인지 검증 + * 일반 URL 검증 + 블로그 플랫폼 패턴 검증 + * @param url 검증할 블로그 URL + * @returns URL이 유효하면 true + */ +export function isValidBlogUrl(url: string): boolean { + if (!isValidUrl(url)) { + return false; + } + + const trimmed = url.trim(); + + // 알려진 플랫폼 패턴 검증 + for (const platform of ['velog', 'tistory', 'medium'] as BlogPlatform[]) { + if (PLATFORM_PATTERNS[platform].test(trimmed)) { + return true; + } + } + + // 알려지지 않은 플랫폼도 유효한 URL이면 허용 + return true; +} + +/** + * URL에서 블로그 플랫폼 감지 + * @param url 블로그 URL + * @returns 감지된 플랫폼 + */ +export function detectBlogPlatform(url: string): BlogPlatform { + if (!isValidUrl(url)) { + return 'unknown'; + } + + const trimmed = url.trim(); + + if (PLATFORM_PATTERNS.velog.test(trimmed)) { + return 'velog'; + } + if (PLATFORM_PATTERNS.tistory.test(trimmed)) { + return 'tistory'; + } + if (PLATFORM_PATTERNS.medium.test(trimmed)) { + return 'medium'; + } + + return 'unknown'; +} + +/** + * URL 정규화 (trailing slash 제거, 소문자 변환 등) + * @param url 정규화할 URL + * @returns 정규화된 URL 또는 null (유효하지 않은 경우) + */ +export function normalizeUrl(url: string): string | null { + if (!isValidUrl(url)) { + return null; + } + + try { + const parsed = new URL(url.trim()); + // 프로토콜을 https로 통일 (선택적) + // pathname에서 trailing slash 제거 (루트 제외) + let pathname = parsed.pathname; + if (pathname.length > 1 && pathname.endsWith('/')) { + pathname = pathname.slice(0, -1); + } + return `${parsed.protocol}//${parsed.hostname}${pathname}`; + } catch { + return null; + } +} + +/** + * URL 검증 및 상세 결과 반환 + * @param url 검증할 URL + * @returns 검증 결과 객체 + */ +export function validateBlogUrl(url: string): UrlValidationResult { + if (!url || typeof url !== 'string') { + return { + isValid: false, + platform: 'unknown', + normalizedUrl: null, + error: 'URL이 비어있거나 문자열이 아닙니다.', + }; + } + + const trimmed = url.trim(); + if (trimmed.length === 0) { + return { + isValid: false, + platform: 'unknown', + normalizedUrl: null, + error: 'URL이 비어있습니다.', + }; + } + + if (!isValidUrl(trimmed)) { + return { + isValid: false, + platform: 'unknown', + normalizedUrl: null, + error: '올바른 URL 형식이 아닙니다.', + }; + } + + const platform = detectBlogPlatform(trimmed); + const normalizedUrl = normalizeUrl(trimmed); + + return { + isValid: true, + platform, + normalizedUrl, + }; +} diff --git a/packages/shared/tsconfig.json b/packages/shared/tsconfig.json new file mode 100644 index 0000000..a4b1b9b --- /dev/null +++ b/packages/shared/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "composite": true, + "declaration": true, + "declarationMap": true, + "outDir": "./dist", + "rootDir": "./src", + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "**/*.test.ts"] +} diff --git a/packages/shared/tsup.config.ts b/packages/shared/tsup.config.ts new file mode 100644 index 0000000..62c4b00 --- /dev/null +++ b/packages/shared/tsup.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: { + index: 'src/index.ts', + 'db/index': 'src/db/index.ts', + 'config/index': 'src/config/index.ts', + 'utils/index': 'src/utils/index.ts', + }, + format: ['cjs', 'esm'], + dts: { + compilerOptions: { + composite: false, + }, + }, + splitting: false, + sourcemap: true, + clean: true, +}); diff --git a/packages/shared/vitest.config.ts b/packages/shared/vitest.config.ts new file mode 100644 index 0000000..7ce0d17 --- /dev/null +++ b/packages/shared/vitest.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + include: ['src/**/*.test.ts', 'src/**/*.property.test.ts'], + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html'], + }, + }, +}); diff --git a/packages/web/components.json b/packages/web/components.json new file mode 100644 index 0000000..7559f63 --- /dev/null +++ b/packages/web/components.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "default", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "tailwind.config.ts", + "css": "src/app/globals.css", + "baseColor": "slate", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils" + } +} diff --git a/packages/web/middleware.ts b/packages/web/middleware.ts new file mode 100644 index 0000000..73a7ae3 --- /dev/null +++ b/packages/web/middleware.ts @@ -0,0 +1,120 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { jwtVerify } from 'jose'; + +// Routes that require authentication +const protectedRoutes = [ + '/dashboard', + '/posts', + '/curation', + '/ranking', + '/profile', +]; + +// Routes that require admin access +const adminRoutes = [ + '/admin', +]; + +// Routes that should redirect to dashboard if already logged in +const authRoutes = [ + '/login', + '/register', +]; + +/** + * Verify JWT token using jose library (Edge runtime compatible) + */ +async function verifyJWT(token: string): Promise<{ userId: string; email: string } | null> { + try { + const secret = new TextEncoder().encode( + process.env.JWT_SECRET || 'development-secret-key-min-32-chars' + ); + + const { payload } = await jwtVerify(token, secret); + + return { + userId: payload.userId as string, + email: payload.email as string, + }; + } catch { + return null; + } +} + +/** + * Middleware for authentication and route protection + * Requirement: 17.9 + */ +export async function middleware(request: NextRequest) { + const { pathname } = request.nextUrl; + + // Get auth token from cookie + const authToken = request.cookies.get('auth-token')?.value; + + // Verify token if present + let user: { userId: string; email: string } | null = null; + if (authToken) { + user = await verifyJWT(authToken); + } + + // Check if token is expired (Requirement 17.9) + const isAuthenticated = !!user; + + // Handle auth routes (login, register) + if (authRoutes.some(route => pathname.startsWith(route))) { + if (isAuthenticated) { + // Redirect to dashboard if already logged in + return NextResponse.redirect(new URL('/dashboard', request.url)); + } + return NextResponse.next(); + } + + // Handle protected routes + if (protectedRoutes.some(route => pathname.startsWith(route))) { + if (!isAuthenticated) { + // Redirect to login if not authenticated + const loginUrl = new URL('/login', request.url); + loginUrl.searchParams.set('redirect', pathname); + return NextResponse.redirect(loginUrl); + } + + // Add user info to headers for downstream use + const response = NextResponse.next(); + response.headers.set('x-user-id', user!.userId); + response.headers.set('x-user-email', user!.email); + return response; + } + + // Handle admin routes + if (adminRoutes.some(route => pathname.startsWith(route))) { + if (!isAuthenticated) { + // Redirect to login if not authenticated + const loginUrl = new URL('/login', request.url); + loginUrl.searchParams.set('redirect', pathname); + return NextResponse.redirect(loginUrl); + } + + // Note: Admin check is done at the API/page level using Discord ID + // The middleware just ensures the user is authenticated + const response = NextResponse.next(); + response.headers.set('x-user-id', user!.userId); + response.headers.set('x-user-email', user!.email); + return response; + } + + return NextResponse.next(); +} + +export const config = { + matcher: [ + /* + * Match all request paths except: + * - api routes (handled separately) + * - _next/static (static files) + * - _next/image (image optimization files) + * - favicon.ico (favicon file) + * - public files (images, etc.) + */ + '/((?!api|_next/static|_next/image|favicon.ico|.*\\..*|_next).*)', + ], +}; diff --git a/packages/web/next-env.d.ts b/packages/web/next-env.d.ts new file mode 100644 index 0000000..40c3d68 --- /dev/null +++ b/packages/web/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information. diff --git a/packages/web/next.config.js b/packages/web/next.config.js new file mode 100644 index 0000000..a7b9cdf --- /dev/null +++ b/packages/web/next.config.js @@ -0,0 +1,31 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + transpilePackages: ['@blog-study/shared'], + + // Production optimizations + poweredByHeader: false, + + // Image optimization + images: { + remotePatterns: [ + { + protocol: 'https', + hostname: '**.supabase.co', + }, + { + protocol: 'https', + hostname: 'cdn.discordapp.com', + }, + ], + }, + + // Experimental features + experimental: { + // Enable server actions + serverActions: { + bodySizeLimit: '2mb', + }, + }, +}; + +module.exports = nextConfig; diff --git a/packages/web/package.json b/packages/web/package.json new file mode 100644 index 0000000..9a0e600 --- /dev/null +++ b/packages/web/package.json @@ -0,0 +1,55 @@ +{ + "name": "@blog-study/web", + "version": "1.0.0", + "private": true, + "description": "블로그 스터디 관리자 대시보드", + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "@blog-study/shared": "workspace:*", + "@radix-ui/react-avatar": "^1.1.11", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-label": "^2.1.8", + "@radix-ui/react-navigation-menu": "^1.2.14", + "@radix-ui/react-select": "^2.2.6", + "@radix-ui/react-separator": "^1.1.8", + "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-tabs": "^1.1.13", + "bcryptjs": "^2.4.3", + "class-variance-authority": "^0.7.0", + "clsx": "^2.1.1", + "drizzle-orm": "^0.33.0", + "jose": "^5.2.0", + "jsonwebtoken": "^9.0.2", + "lucide-react": "^0.575.0", + "next": "^14.2.4", + "next-themes": "^0.4.6", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "recharts": "^2.12.7", + "resend": "^3.4.0", + "tailwind-merge": "^2.3.0", + "tailwindcss-animate": "^1.0.7" + }, + "devDependencies": { + "@types/bcryptjs": "^2.4.6", + "@types/jsonwebtoken": "^9.0.6", + "@types/node": "^20.14.0", + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "autoprefixer": "^10.4.24", + "fast-check": "^3.19.0", + "postcss": "^8.5.6", + "tailwindcss": "^3.4.4", + "typescript": "^5.9.3", + "vitest": "^1.6.0" + } +} diff --git a/packages/web/postcss.config.js b/packages/web/postcss.config.js new file mode 100644 index 0000000..12a703d --- /dev/null +++ b/packages/web/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/packages/web/src/app/(admin)/admin/attendance/page.tsx b/packages/web/src/app/(admin)/admin/attendance/page.tsx new file mode 100644 index 0000000..56f13cd --- /dev/null +++ b/packages/web/src/app/(admin)/admin/attendance/page.tsx @@ -0,0 +1,413 @@ +'use client'; + +import { useEffect, useState, useCallback } from 'react'; +import { + CheckCircle, + Clock, + XCircle, + AlertCircle, + ChevronLeft, + ChevronRight, +} from 'lucide-react'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; + +interface RoundInfo { + id: number; + roundNumber: number; + startDate: string; + endDate: string; + graceEndDate: string; + isCurrent: boolean; +} + +interface MemberInfo { + id: string; + name: string; + discordUsername: string; + part: string; + status: string; +} + +interface AttendanceRecord { + id: string | null; + status: string; + submittedAt: string | null; +} + +interface GridRow { + member: MemberInfo; + attendance: Record; +} + +interface RoundStats { + roundId: number; + roundNumber: number; + startDate: string; + endDate: string; + isCurrent: boolean; + stats: { + total: number; + submitted: number; + late: number; + absent: number; + pending: number; + submissionRate: number; + }; +} + +interface AttendanceData { + rounds: RoundInfo[]; + grid: GridRow[]; + roundStats: RoundStats[]; +} + +interface StatusConfigItem { + label: string; + icon: React.ReactNode; + className: string; + bgClassName: string; +} + +const statusConfig = { + submitted: { + label: '제출', + icon: , + className: 'text-success', + bgClassName: 'bg-success/10 dark:bg-green-900/30', + }, + pending: { + label: '대기', + icon: , + className: 'text-muted-foreground', + bgClassName: 'bg-muted dark:bg-gray-800/30', + }, + late: { + label: '지각', + icon: , + className: 'text-warning', + bgClassName: 'bg-warning/10 dark:bg-yellow-900/30', + }, + absent: { + label: '결석', + icon: , + className: 'text-destructive', + bgClassName: 'bg-destructive/10 dark:bg-red-900/30', + }, + none: { + label: '-', + icon: null, + className: 'text-muted-foreground/50', + bgClassName: '', + }, +} as const satisfies Record; + +const memberStatusConfig: Record = { + active: { label: '활성', variant: 'default' }, + dormant: { label: '휴면', variant: 'secondary' }, + withdrawn: { label: '탈퇴', variant: 'destructive' }, +}; + +function getStatusConfig(status: string): StatusConfigItem { + if (status in statusConfig) { + return statusConfig[status as keyof typeof statusConfig]; + } + return statusConfig.none; +} + +// Number of rounds to show per page +const ROUNDS_PER_PAGE = 5; + +export default function AdminAttendancePage() { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [currentPage, setCurrentPage] = useState(0); + const [statusFilter, setStatusFilter] = useState('all'); + + const fetchAttendance = useCallback(async () => { + try { + setLoading(true); + const response = await fetch('/api/admin/attendance'); + if (!response.ok) { + throw new Error('Failed to fetch attendance data'); + } + const result = await response.json(); + setData(result); + + // Set initial page to show current round + if (result.rounds.length > 0) { + const currentRoundIndex = result.rounds.findIndex((r: RoundInfo) => r.isCurrent); + if (currentRoundIndex >= 0) { + setCurrentPage(Math.floor(currentRoundIndex / ROUNDS_PER_PAGE)); + } else { + // Default to last page if no current round + setCurrentPage(Math.max(0, Math.ceil(result.rounds.length / ROUNDS_PER_PAGE) - 1)); + } + } + } catch (err) { + setError('출석 데이터를 불러오는데 실패했습니다.'); + console.error(err); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchAttendance(); + }, [fetchAttendance]); + + if (loading) { + return ( +
+
로딩 중...
+
+ ); + } + + if (error) { + return ( +
+
{error}
+
+ ); + } + + if (!data || data.rounds.length === 0) { + return ( +
+
+

출석 현황

+

회차별 출석 현황을 확인하세요.

+
+ + +
+ 등록된 회차가 없습니다. +
+
+
+
+ ); + } + + // Pagination + const totalPages = Math.ceil(data.rounds.length / ROUNDS_PER_PAGE); + const startIndex = currentPage * ROUNDS_PER_PAGE; + const visibleRounds = data.rounds.slice(startIndex, startIndex + ROUNDS_PER_PAGE); + const visibleRoundStats = data.roundStats.filter((rs) => + visibleRounds.some((r) => r.id === rs.roundId) + ); + + // Filter members by status + const filteredGrid = statusFilter === 'all' + ? data.grid + : data.grid.filter((row) => row.member.status === statusFilter); + + const handlePrevPage = () => { + setCurrentPage((prev) => Math.max(0, prev - 1)); + }; + + const handleNextPage = () => { + setCurrentPage((prev) => Math.min(totalPages - 1, prev + 1)); + }; + + return ( +
+
+

출석 현황

+

+ 회차별 출석 현황을 확인하세요. (멤버 × 회차 그리드) +

+
+ + {/* Legend */} + + + 범례 + + +
+ {Object.entries(statusConfig).filter(([key]) => key !== 'none').map(([key, config]) => ( +
+
+ {config.icon} +
+ {config.label} +
+ ))} +
+
+
+ + {/* Round Stats Summary */} +
+ {visibleRoundStats.map((rs) => ( + + +
+ + {rs.roundNumber}회차 + + {rs.isCurrent && ( + 현재 + )} +
+ + {rs.startDate} ~ {rs.endDate} + +
+ +
{rs.stats.submissionRate}%
+
+ ✓{rs.stats.submitted} + △{rs.stats.late} + ✗{rs.stats.absent} + ○{rs.stats.pending} +
+
+
+ ))} +
+ + {/* Attendance Grid */} + + +
+
+ 출석 그리드 + + {filteredGrid.length}명의 멤버 × {data.rounds.length}개 회차 + +
+
+ {/* Status Filter */} +
+ 상태: + +
+ {/* Pagination */} +
+ + + {currentPage + 1} / {totalPages} + + +
+
+
+
+ +
+ + + + + 멤버 + + + 상태 + + {visibleRounds.map((round) => ( + +
+ {round.roundNumber}회차 + + {round.endDate.slice(5)} + +
+
+ ))} +
+
+ + {filteredGrid.length > 0 ? ( + filteredGrid.map((row) => ( + + +
+
{row.member.name}
+
+ {row.member.part} +
+
+
+ + + {memberStatusConfig[row.member.status]?.label || row.member.status} + + + {visibleRounds.map((round) => { + const att = row.attendance[round.id]; + const cellConfig = getStatusConfig(att?.status || 'none'); + return ( + +
+ + {cellConfig.icon || '-'} + +
+
+ ); + })} +
+ )) + ) : ( + + + 표시할 멤버가 없습니다. + + + )} +
+
+
+
+
+
+ ); +} diff --git a/packages/web/src/app/(admin)/admin/curation/page.tsx b/packages/web/src/app/(admin)/admin/curation/page.tsx new file mode 100644 index 0000000..f427012 --- /dev/null +++ b/packages/web/src/app/(admin)/admin/curation/page.tsx @@ -0,0 +1,445 @@ +'use client'; + +import { useEffect, useState, useCallback } from 'react'; +import { + Rss, + Plus, + Trash2, + ExternalLink, + Search, + ToggleLeft, + ToggleRight, + FileText, + Calendar, +} from 'lucide-react'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; + +interface CurationSource { + id: string; + url: string; + name: string; + category: string; + isActive: boolean; + createdAt: string; + itemCount: number; +} + +interface CurationStats { + totalSources: number; + activeSources: number; + totalItems: number; + sharedItems: number; +} + +interface CurationData { + sources: CurationSource[]; + stats: CurationStats; + categories: string[]; +} + +const categoryLabels: Record = { + conference: '컨퍼런스', + article: '아티클', +}; + +export default function AdminCurationPage() { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [searchQuery, setSearchQuery] = useState(''); + const [showAddForm, setShowAddForm] = useState(false); + const [updatingId, setUpdatingId] = useState(null); + + // Add form state + const [newSource, setNewSource] = useState({ + url: '', + name: '', + category: 'article', + }); + const [addError, setAddError] = useState(null); + const [adding, setAdding] = useState(false); + + const fetchSources = useCallback(async () => { + try { + setLoading(true); + const response = await fetch('/api/admin/curation'); + if (!response.ok) { + throw new Error('Failed to fetch curation sources'); + } + const result = await response.json(); + setData(result); + } catch (err) { + setError('큐레이션 소스를 불러오는데 실패했습니다.'); + console.error(err); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchSources(); + }, [fetchSources]); + + const handleAddSource = async (e: React.FormEvent) => { + e.preventDefault(); + setAddError(null); + + if (!newSource.url || !newSource.name) { + setAddError('URL과 이름은 필수입니다.'); + return; + } + + try { + setAdding(true); + const response = await fetch('/api/admin/curation', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(newSource), + }); + + if (!response.ok) { + const data = await response.json(); + throw new Error(data.error || 'Failed to add source'); + } + + // Reset form and refresh + setNewSource({ url: '', name: '', category: 'article' }); + setShowAddForm(false); + await fetchSources(); + } catch (err) { + setAddError(err instanceof Error ? err.message : '소스 추가에 실패했습니다.'); + } finally { + setAdding(false); + } + }; + + const handleToggleActive = async (source: CurationSource) => { + try { + setUpdatingId(source.id); + const response = await fetch(`/api/admin/curation/${source.id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ isActive: !source.isActive }), + }); + + if (!response.ok) { + throw new Error('Failed to toggle source status'); + } + + await fetchSources(); + } catch (err) { + console.error('Error toggling source:', err); + alert('상태 변경에 실패했습니다.'); + } finally { + setUpdatingId(null); + } + }; + + const handleDelete = async (source: CurationSource) => { + if (!confirm(`"${source.name}" 소스를 삭제하시겠습니까?\n수집된 ${source.itemCount}개의 아이템도 함께 삭제됩니다.`)) { + return; + } + + try { + setUpdatingId(source.id); + const response = await fetch(`/api/admin/curation/${source.id}`, { + method: 'DELETE', + }); + + if (!response.ok) { + throw new Error('Failed to delete source'); + } + + await fetchSources(); + } catch (err) { + console.error('Error deleting source:', err); + alert('삭제에 실패했습니다.'); + } finally { + setUpdatingId(null); + } + }; + + if (loading) { + return ( +
+
로딩 중...
+
+ ); + } + + if (error) { + return ( +
+
{error}
+
+ ); + } + + // Filter sources + const filteredSources = data?.sources.filter((source) => { + if (!searchQuery) return true; + const query = searchQuery.toLowerCase(); + return ( + source.name.toLowerCase().includes(query) || + source.url.toLowerCase().includes(query) || + source.category.toLowerCase().includes(query) + ); + }) || []; + + return ( +
+
+
+

큐레이션 소스 관리

+

+ 외부 컨퍼런스 및 아티클 수집 소스를 관리하세요. +

+
+ +
+ + {/* Stats Cards */} +
+ + + 전체 소스 + + + +
{data?.stats.totalSources || 0}개
+

+ 활성 {data?.stats.activeSources || 0}개 +

+
+
+ + + 수집된 아이템 + + + +
{data?.stats.totalItems || 0}개
+

+ 공유됨 {data?.stats.sharedItems || 0}개 +

+
+
+ + + 컨퍼런스 + + + +
+ {data?.sources.filter((s) => s.category === 'conference').length || 0}개 +
+
+
+ + + 아티클 + + + +
+ {data?.sources.filter((s) => s.category === 'article').length || 0}개 +
+
+
+
+ + {/* Add Form */} + {showAddForm && ( + + + 새 소스 추가 + + 큐레이션할 외부 사이트를 추가합니다. + + + +
+ {addError && ( +
+ {addError} +
+ )} +
+
+ + setNewSource({ ...newSource, name: e.target.value })} + placeholder="예: GeekNews" + /> +
+
+ + setNewSource({ ...newSource, url: e.target.value })} + placeholder="https://news.hada.io" + /> +
+
+ + +
+
+
+ + +
+
+
+
+ )} + + {/* Sources Table */} + + +
+
+ 소스 목록 + + {filteredSources.length}개의 소스 + +
+
+ + setSearchQuery(e.target.value)} + /> +
+
+
+ + + + + 이름 + URL + 카테고리 + 아이템 + 상태 + 등록일 + 작업 + + + + {filteredSources.length > 0 ? ( + filteredSources.map((source) => ( + + {source.name} + + + {source.url.length > 40 + ? source.url.substring(0, 40) + '...' + : source.url} + + + + + + {categoryLabels[source.category] || source.category} + + + {source.itemCount} + + + {source.isActive ? '활성' : '비활성'} + + + + {new Date(source.createdAt).toLocaleDateString('ko-KR')} + + +
+ + +
+
+
+ )) + ) : ( + + + {searchQuery ? '검색 결과가 없습니다.' : '등록된 소스가 없습니다.'} + + + )} +
+
+
+
+
+ ); +} diff --git a/packages/web/src/app/(admin)/admin/fines/page.tsx b/packages/web/src/app/(admin)/admin/fines/page.tsx new file mode 100644 index 0000000..d20d992 --- /dev/null +++ b/packages/web/src/app/(admin)/admin/fines/page.tsx @@ -0,0 +1,420 @@ +'use client'; + +import { useEffect, useState, useCallback } from 'react'; +import { + CreditCard, + CheckCircle, + XCircle, + AlertCircle, + Search, + Ban, +} from 'lucide-react'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; + +interface Fine { + id: string; + memberId: string; + roundId: number; + type: string; + amount: number; + status: string; + createdAt: string; + paidAt: string | null; + memberName: string; + memberDiscordUsername: string; + memberPart: string; + roundNumber: number; +} + +interface FineSummary { + total: { count: number; amount: number }; + unpaid: { count: number; amount: number }; + paid: { count: number; amount: number }; + waived: { count: number; amount: number }; +} + +interface MemberFineSummary { + memberId: string; + memberName: string; + memberDiscordUsername: string; + memberPart: string; + unpaidCount: number; + unpaidAmount: number; + totalCount: number; + totalAmount: number; +} + +interface FinesData { + fines: Fine[]; + summary: FineSummary; + byMember: MemberFineSummary[]; +} + +interface StatusConfigItem { + label: string; + variant: 'default' | 'secondary' | 'destructive' | 'outline' | 'success' | 'warning'; +} + +const statusConfig: Record = { + unpaid: { label: '미납', variant: 'destructive' }, + paid: { label: '납부', variant: 'success' }, + waived: { label: '면제', variant: 'secondary' }, +}; + +const typeConfig: Record = { + late: { label: '지각', color: 'text-warning' }, + absent: { label: '결석', color: 'text-destructive' }, +}; + +export default function AdminFinesPage() { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [searchQuery, setSearchQuery] = useState(''); + const [statusFilter, setStatusFilter] = useState('all'); + const [updatingId, setUpdatingId] = useState(null); + + const fetchFines = useCallback(async () => { + try { + setLoading(true); + const response = await fetch('/api/admin/fines'); + if (!response.ok) { + throw new Error('Failed to fetch fines data'); + } + const result = await response.json(); + setData(result); + } catch (err) { + setError('벌금 데이터를 불러오는데 실패했습니다.'); + console.error(err); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchFines(); + }, [fetchFines]); + + const handleMarkPaid = async (fineId: string) => { + try { + setUpdatingId(fineId); + const response = await fetch(`/api/admin/fines/${fineId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ status: 'paid' }), + }); + + if (!response.ok) { + throw new Error('Failed to mark fine as paid'); + } + + // Refresh data + await fetchFines(); + } catch (err) { + console.error('Error marking fine as paid:', err); + alert('납부 처리에 실패했습니다.'); + } finally { + setUpdatingId(null); + } + }; + + const handleWaive = async (fineId: string) => { + if (!confirm('정말 이 벌금을 면제하시겠습니까?')) { + return; + } + + try { + setUpdatingId(fineId); + const response = await fetch(`/api/admin/fines/${fineId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ status: 'waived' }), + }); + + if (!response.ok) { + throw new Error('Failed to waive fine'); + } + + // Refresh data + await fetchFines(); + } catch (err) { + console.error('Error waiving fine:', err); + alert('면제 처리에 실패했습니다.'); + } finally { + setUpdatingId(null); + } + }; + + if (loading) { + return ( +
+
로딩 중...
+
+ ); + } + + if (error) { + return ( +
+
{error}
+
+ ); + } + + // Filter fines + const filteredFines = data?.fines.filter((fine) => { + // Status filter + if (statusFilter !== 'all' && fine.status !== statusFilter) { + return false; + } + // Search filter + if (searchQuery) { + const query = searchQuery.toLowerCase(); + return ( + fine.memberName.toLowerCase().includes(query) || + fine.memberDiscordUsername.toLowerCase().includes(query) || + fine.memberPart.toLowerCase().includes(query) + ); + } + return true; + }) || []; + + return ( +
+
+

벌금 관리

+

+ 벌금 현황을 확인하고 납부/면제 처리를 하세요. +

+
+ + {/* Summary Cards */} +
+ setStatusFilter('all')} + > + + 전체 + + + +
+ {(data?.summary.total.amount ?? 0).toLocaleString()}원 +
+

+ {data?.summary.total.count ?? 0}건 +

+
+
+ setStatusFilter('unpaid')} + > + + 미납 + + + +
+ {(data?.summary.unpaid.amount ?? 0).toLocaleString()}원 +
+

+ {data?.summary.unpaid.count ?? 0}건 +

+
+
+ setStatusFilter('paid')} + > + + 납부 + + + +
+ {(data?.summary.paid.amount ?? 0).toLocaleString()}원 +
+

+ {data?.summary.paid.count ?? 0}건 +

+
+
+ setStatusFilter('waived')} + > + + 면제 + + + +
+ {(data?.summary.waived.amount ?? 0).toLocaleString()}원 +
+

+ {data?.summary.waived.count ?? 0}건 +

+
+
+
+ + {/* Member Summary (only show if there are unpaid fines) */} + {data?.byMember && data.byMember.some((m) => m.unpaidAmount > 0) && ( + + + 멤버별 미납 현황 + 미납 벌금이 있는 멤버 목록 + + +
+ {data.byMember + .filter((m) => m.unpaidAmount > 0) + .map((member) => ( +
+
+
{member.memberName}
+
+ {member.memberPart} • {member.unpaidCount}건 미납 +
+
+
+
+ {member.unpaidAmount.toLocaleString()}원 +
+
+
+ ))} +
+
+
+ )} + + {/* Fines Table */} + + +
+
+ 벌금 목록 + + {statusFilter === 'all' ? '전체' : statusConfig[statusFilter]?.label} 벌금 {filteredFines.length}건 + +
+
+
+ + setSearchQuery(e.target.value)} + /> +
+
+
+
+ + + + + 멤버 + 회차 + 유형 + 금액 + 상태 + 생성일 + 작업 + + + + {filteredFines.length > 0 ? ( + filteredFines.map((fine) => ( + + +
+
{fine.memberName}
+
+ {fine.memberPart} +
+
+
+ {fine.roundNumber}회차 + + + {typeConfig[fine.type]?.label || fine.type} + + + + {fine.amount.toLocaleString()}원 + + + + {statusConfig[fine.status]?.label || fine.status} + + + + {new Date(fine.createdAt).toLocaleDateString('ko-KR')} + + + {fine.status === 'unpaid' && ( +
+ + +
+ )} + {fine.status === 'paid' && fine.paidAt && ( + + {new Date(fine.paidAt).toLocaleDateString('ko-KR')} 납부 + + )} + {fine.status === 'waived' && ( + 면제됨 + )} +
+
+ )) + ) : ( + + + {searchQuery ? '검색 결과가 없습니다.' : '등록된 벌금이 없습니다.'} + + + )} +
+
+
+
+
+ ); +} diff --git a/packages/web/src/app/(admin)/admin/members/delete-member-dialog.tsx b/packages/web/src/app/(admin)/admin/members/delete-member-dialog.tsx new file mode 100644 index 0000000..34f39ad --- /dev/null +++ b/packages/web/src/app/(admin)/admin/members/delete-member-dialog.tsx @@ -0,0 +1,122 @@ +'use client'; + +import { useState } from 'react'; +import { AlertTriangle } from 'lucide-react'; +import { Button } from '@/components/ui/button'; + +interface Member { + id: string; + name: string; + discordUsername: string; + status: string; +} + +interface DeleteMemberDialogProps { + open: boolean; + onClose: () => void; + onSuccess: () => void; + member: Member | null; +} + +/** + * Delete Member Confirmation Dialog + * Soft deletes member (marks as withdrawn) + * Requirements: 19.5, 19.6 + */ +export function DeleteMemberDialog({ + open, + onClose, + onSuccess, + member, +}: DeleteMemberDialogProps) { + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const handleDelete = async () => { + if (!member) return; + + setLoading(true); + setError(null); + + try { + const response = await fetch(`/api/admin/members/${member.id}`, { + method: 'DELETE', + }); + + const data = await response.json(); + + if (!response.ok) { + setError(data.message || '삭제에 실패했습니다.'); + return; + } + + onSuccess(); + } catch (err) { + setError('서버 오류가 발생했습니다.'); + console.error(err); + } finally { + setLoading(false); + } + }; + + if (!open || !member) { + return null; + } + + return ( +
+ {/* Backdrop */} +
+ + {/* Dialog */} +
+
+
+ +
+
+

멤버 탈퇴 처리

+

+ 이 작업은 되돌릴 수 없습니다. +

+
+
+ +
+

+ {member.name} ({member.discordUsername}) + 님을 탈퇴 처리하시겠습니까? +

+

+ 멤버의 데이터(포스트, 출석, 벌금 기록)는 보존되며, 상태만 '탈퇴'로 변경됩니다. +

+
+ + {/* Error message */} + {error && ( +
+ {error} +
+ )} + + {/* Actions */} +
+ + +
+
+
+ ); +} diff --git a/packages/web/src/app/(admin)/admin/members/member-form-dialog.tsx b/packages/web/src/app/(admin)/admin/members/member-form-dialog.tsx new file mode 100644 index 0000000..cbf5ea3 --- /dev/null +++ b/packages/web/src/app/(admin)/admin/members/member-form-dialog.tsx @@ -0,0 +1,285 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; + +interface Member { + id: string; + discordId: string; + discordUsername: string; + name: string; + part: string; + blogUrl: string; + rssUrl: string | null; + status: string; +} + +interface MemberFormDialogProps { + open: boolean; + onClose: () => void; + onSuccess: () => void; + member: Member | null; +} + +/** + * Member Form Dialog + * For creating and editing members + * Requirements: 19.1, 19.2, 19.3, 19.4 + */ +export function MemberFormDialog({ + open, + onClose, + onSuccess, + member, +}: MemberFormDialogProps) { + const [formData, setFormData] = useState({ + name: '', + part: '', + discordId: '', + discordUsername: '', + blogUrl: '', + rssUrl: '', + }); + const [errors, setErrors] = useState([]); + const [loading, setLoading] = useState(false); + + const isEditing = !!member; + + useEffect(() => { + if (member) { + setFormData({ + name: member.name, + part: member.part, + discordId: member.discordId, + discordUsername: member.discordUsername, + blogUrl: member.blogUrl, + rssUrl: member.rssUrl || '', + }); + } else { + setFormData({ + name: '', + part: '', + discordId: '', + discordUsername: '', + blogUrl: '', + rssUrl: '', + }); + } + setErrors([]); + }, [member, open]); + + const handleChange = (e: React.ChangeEvent) => { + const { name, value } = e.target; + setFormData((prev) => ({ ...prev, [name]: value })); + }; + + const validateForm = (): boolean => { + const newErrors: string[] = []; + + // Required fields validation (Requirement: 19.2) + if (!formData.name.trim()) { + newErrors.push('이름은 필수입니다.'); + } + if (!formData.part.trim()) { + newErrors.push('파트는 필수입니다.'); + } + if (!formData.discordId.trim()) { + newErrors.push('Discord ID는 필수입니다.'); + } + if (!formData.blogUrl.trim()) { + newErrors.push('블로그 URL은 필수입니다.'); + } + + // URL format validation + if (formData.blogUrl.trim()) { + try { + new URL(formData.blogUrl.trim()); + } catch { + newErrors.push('유효하지 않은 블로그 URL 형식입니다.'); + } + } + + if (formData.rssUrl.trim()) { + try { + new URL(formData.rssUrl.trim()); + } catch { + newErrors.push('유효하지 않은 RSS URL 형식입니다.'); + } + } + + setErrors(newErrors); + return newErrors.length === 0; + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!validateForm()) { + return; + } + + setLoading(true); + setErrors([]); + + try { + const url = isEditing ? `/api/admin/members/${member.id}` : '/api/admin/members'; + const method = isEditing ? 'PUT' : 'POST'; + + const response = await fetch(url, { + method, + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + name: formData.name.trim(), + part: formData.part.trim(), + discordId: formData.discordId.trim(), + discordUsername: formData.discordUsername.trim() || formData.discordId.trim(), + blogUrl: formData.blogUrl.trim(), + rssUrl: formData.rssUrl.trim() || null, + }), + }); + + const data = await response.json(); + + if (!response.ok) { + setErrors([data.message || '오류가 발생했습니다.']); + return; + } + + onSuccess(); + } catch (err) { + setErrors(['서버 오류가 발생했습니다.']); + console.error(err); + } finally { + setLoading(false); + } + }; + + if (!open) { + return null; + } + + return ( +
+ {/* Backdrop */} +
+ + {/* Dialog */} +
+

+ {isEditing ? '멤버 수정' : '멤버 추가'} +

+ +
+ {/* Error messages */} + {errors.length > 0 && ( +
+ {errors.map((error, index) => ( +

{error}

+ ))} +
+ )} + + {/* Name field */} +
+ + +
+ + {/* Part field */} +
+ + +
+ + {/* Discord ID field */} +
+ + +
+ + {/* Discord Username field */} +
+ + +
+ + {/* Blog URL field */} +
+ + +
+ + {/* RSS URL field */} +
+ + +

+ 비워두면 자동으로 감지합니다. +

+
+ + {/* Actions */} +
+ + +
+
+
+
+ ); +} diff --git a/packages/web/src/app/(admin)/admin/members/page.tsx b/packages/web/src/app/(admin)/admin/members/page.tsx new file mode 100644 index 0000000..57cc430 --- /dev/null +++ b/packages/web/src/app/(admin)/admin/members/page.tsx @@ -0,0 +1,360 @@ +'use client'; + +import { useEffect, useState, useCallback } from 'react'; +import { + Users, + Plus, + Search, + Edit, + Trash2, + ExternalLink, + Moon, + UserX, +} from 'lucide-react'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { MemberFormDialog } from './member-form-dialog'; +import { DeleteMemberDialog } from './delete-member-dialog'; + +interface AttendanceStats { + total: number; + submitted: number; + late: number; + absent: number; +} + +interface Member { + id: string; + discordId: string; + discordUsername: string; + name: string; + part: string; + blogUrl: string; + rssUrl: string | null; + profileImageUrl: string | null; + bio: string | null; + status: string; + dormantUsed: boolean; + dormantStartRound: number | null; + postCount: number; + attendanceRate: number; + attendanceStats: AttendanceStats; + joinedAt: string; + updatedAt: string; +} + +interface MemberCounts { + active: number; + dormant: number; + withdrawn: number; + total: number; +} + +interface MembersData { + members: Member[]; + grouped: { + active: Member[]; + dormant: Member[]; + withdrawn: Member[]; + }; + counts: MemberCounts; +} + +const statusConfig: Record = { + active: { label: '활성', variant: 'default' }, + dormant: { label: '휴면', variant: 'secondary' }, + withdrawn: { label: '탈퇴', variant: 'destructive' }, +}; + +export default function AdminMembersPage() { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [searchQuery, setSearchQuery] = useState(''); + const [statusFilter, setStatusFilter] = useState('all'); + const [isFormOpen, setIsFormOpen] = useState(false); + const [editingMember, setEditingMember] = useState(null); + const [deletingMember, setDeletingMember] = useState(null); + + const fetchMembers = useCallback(async () => { + try { + setLoading(true); + const response = await fetch('/api/admin/members'); + if (!response.ok) { + throw new Error('Failed to fetch members'); + } + const result = await response.json(); + setData(result); + } catch (err) { + setError('멤버 목록을 불러오는데 실패했습니다.'); + console.error(err); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchMembers(); + }, [fetchMembers]); + + const handleAddMember = () => { + setEditingMember(null); + setIsFormOpen(true); + }; + + const handleEditMember = (member: Member) => { + setEditingMember(member); + setIsFormOpen(true); + }; + + const handleDeleteMember = (member: Member) => { + setDeletingMember(member); + }; + + const handleFormClose = () => { + setIsFormOpen(false); + setEditingMember(null); + }; + + const handleFormSuccess = () => { + setIsFormOpen(false); + setEditingMember(null); + fetchMembers(); + }; + + const handleDeleteClose = () => { + setDeletingMember(null); + }; + + const handleDeleteSuccess = () => { + setDeletingMember(null); + fetchMembers(); + }; + + // Filter members + const filteredMembers = data?.members.filter((member) => { + // Status filter + if (statusFilter !== 'all' && member.status !== statusFilter) { + return false; + } + // Search filter + if (searchQuery) { + const query = searchQuery.toLowerCase(); + return ( + member.name.toLowerCase().includes(query) || + member.discordUsername.toLowerCase().includes(query) || + member.part.toLowerCase().includes(query) || + member.blogUrl.toLowerCase().includes(query) + ); + } + return true; + }) || []; + + if (loading) { + return ( +
+
로딩 중...
+
+ ); + } + + if (error) { + return ( +
+
{error}
+
+ ); + } + + return ( +
+
+
+

참가자 관리

+

+ 스터디 참가자를 관리하세요. +

+
+ +
+ + {/* Stats Cards */} +
+ setStatusFilter('all')} + > + + 전체 + + + +
{data?.counts.total ?? 0}명
+
+
+ setStatusFilter('active')} + > + + 활성 + + + +
{data?.counts.active ?? 0}명
+
+
+ setStatusFilter('dormant')} + > + + 휴면 + + + +
{data?.counts.dormant ?? 0}명
+
+
+ setStatusFilter('withdrawn')} + > + + 탈퇴 + + + +
{data?.counts.withdrawn ?? 0}명
+
+
+
+ + {/* Members Table */} + + +
+
+ 멤버 목록 + + {statusFilter === 'all' ? '전체' : statusConfig[statusFilter]?.label} 멤버 {filteredMembers.length}명 + +
+
+
+ + setSearchQuery(e.target.value)} + /> +
+
+
+
+ + + + + 이름 + 파트 + Discord + 블로그 + 상태 + 포스트 + 출석률 + + + + + {filteredMembers.length > 0 ? ( + filteredMembers.map((member) => ( + + {member.name} + {member.part} + + {member.discordUsername} + + + + + 블로그 + + + + + {statusConfig[member.status]?.label || member.status} + + + {member.postCount} + {member.attendanceRate}% + +
+ + +
+
+
+ )) + ) : ( + + + {searchQuery ? '검색 결과가 없습니다.' : '등록된 멤버가 없습니다.'} + + + )} +
+
+
+
+ + {/* Member Form Dialog */} + + + {/* Delete Confirmation Dialog */} + +
+ ); +} diff --git a/packages/web/src/app/(admin)/admin/page.tsx b/packages/web/src/app/(admin)/admin/page.tsx new file mode 100644 index 0000000..40cb87e --- /dev/null +++ b/packages/web/src/app/(admin)/admin/page.tsx @@ -0,0 +1,393 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import Link from 'next/link'; +import { + Calendar, + FileText, + Clock, + Users, + CreditCard, + TrendingUp, + CheckCircle, + AlertCircle, + XCircle, + ArrowRight, +} from 'lucide-react'; +import { Card, CardContent, CardHeader } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; + +interface RoundInfo { + id: number; + roundNumber: number; + startDate: string; + endDate: string; + graceEndDate: string; + daysRemaining: number; + isGracePeriod: boolean; +} + +interface SubmissionStats { + total: number; + submitted: number; + late: number; + absent: number; + pending: number; + submissionRate: number; +} + +interface MemberCounts { + active: number; + dormant: number; + withdrawn: number; + total: number; +} + +interface UnpaidFines { + count: number; + total: number; +} + +interface RecentPost { + id: string; + title: string; + url: string; + publishedAt: string; + memberName: string; + memberDiscordUsername: string; +} + +interface RecentAttendance { + id: string; + status: string; + updatedAt: string; + memberName: string; + memberDiscordUsername: string; + roundNumber: number; +} + +interface AdminDashboardData { + currentRound: RoundInfo | null; + submissionStats: SubmissionStats | null; + memberCounts: MemberCounts; + totalPosts: number; + unpaidFines: UnpaidFines; + recentActivity: { + posts: RecentPost[]; + attendance: RecentAttendance[]; + }; +} + +const statusLabels: Record< + string, + { label: string; variant: 'default' | 'secondary' | 'destructive' | 'outline' | 'success' | 'warning' } +> = { + submitted: { label: '제출', variant: 'success' }, + pending: { label: '대기', variant: 'secondary' }, + late: { label: '지각', variant: 'warning' }, + absent: { label: '결석', variant: 'destructive' }, +}; + +export default function AdminDashboardPage() { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const fetchDashboard = async () => { + try { + const response = await fetch('/api/admin/dashboard'); + if (!response.ok) { + throw new Error('Failed to fetch dashboard data'); + } + const result = await response.json(); + setData(result); + } catch (err) { + setError('대시보드 데이터를 불러오는데 실패했습니다.'); + console.error(err); + } finally { + setLoading(false); + } + }; + + fetchDashboard(); + }, []); + + if (loading) { + return ( +
+
로딩 중...
+
+ ); + } + + if (error) { + return ( +
+
{error}
+
+ ); + } + + return ( +
+ {/* Page Header */} +
+

+ Admin +

+

관리자 대시보드

+
+ + {/* Current Round Card */} + {data?.currentRound && ( + + +
+

현재 회차

+ {data.currentRound.isGracePeriod ? ( + 지각 기간 + ) : ( + 진행 중 + )} +
+
+ +
+
+

회차

+

{data.currentRound.roundNumber}회차

+
+
+

기간

+

+ {data.currentRound.startDate} ~ {data.currentRound.endDate} +

+
+
+

마감까지

+

+ {data.currentRound.isGracePeriod ? ( + 지각 마감 + ) : ( + `${data.currentRound.daysRemaining}일` + )} +

+
+
+

제출률

+

+ {data.submissionStats?.submissionRate ?? 0}% +

+
+
+
+
+ )} + + {/* Stats Cards */} +
+ + +
+
+ +
+
+
{data?.memberCounts.active ?? 0}명
+

활성 참가자

+

+ 휴면 {data?.memberCounts.dormant ?? 0}명 · 탈퇴 {data?.memberCounts.withdrawn ?? 0}명 +

+
+
+ + + +
+
+ +
+
+
{data?.totalPosts ?? 0}개
+

총 포스트

+

누적 작성 글

+
+
+ + + +
+
+ +
+
+
+ {(data?.unpaidFines.total ?? 0).toLocaleString()}원 +
+

미납 벌금

+

+ {data?.unpaidFines.count ?? 0}건 미납 +

+
+
+ + + +
+
+ +
+
+
+
+ + {data?.submissionStats?.submitted ?? 0} +
+
+ + {data?.submissionStats?.late ?? 0} +
+
+ + {data?.submissionStats?.absent ?? 0} +
+
+

이번 회차 현황

+

+ 대기 {data?.submissionStats?.pending ?? 0}명 +

+
+
+
+ + {/* Recent Activity */} +
+ {/* Recent Posts */} + + +
+

최근 포스트

+ +
+
+ + {data?.recentActivity.posts && data.recentActivity.posts.length > 0 ? ( +
+ {data.recentActivity.posts.map((post) => ( +
+
+ + {post.title} + +
+ {post.memberName || post.memberDiscordUsername} + · + {new Date(post.publishedAt).toLocaleDateString('ko-KR')} +
+
+
+ ))} +
+ ) : ( +
+ 최근 포스트가 없습니다. +
+ )} +
+
+ + {/* Recent Attendance */} + + +
+

최근 출석 변경

+ +
+
+ + {data?.recentActivity.attendance && data.recentActivity.attendance.length > 0 ? ( +
+ {data.recentActivity.attendance.map((att) => ( +
+
+

+ {att.memberName || att.memberDiscordUsername} +

+

+ {att.roundNumber}회차 · {new Date(att.updatedAt).toLocaleDateString('ko-KR')} +

+
+ + {statusLabels[att.status]?.label ?? att.status} + +
+ ))} +
+ ) : ( +
+ 최근 출석 변경 내역이 없습니다. +
+ )} +
+
+
+ + {/* Quick Actions */} +
+ + + + + +
+
+ ); +} diff --git a/packages/web/src/app/(admin)/admin/settings/page.tsx b/packages/web/src/app/(admin)/admin/settings/page.tsx new file mode 100644 index 0000000..c646c27 --- /dev/null +++ b/packages/web/src/app/(admin)/admin/settings/page.tsx @@ -0,0 +1,327 @@ +'use client'; + +import { useEffect, useState, useCallback } from 'react'; +import { Settings, Calendar, Hash, Users, Save, RefreshCw } from 'lucide-react'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; + +interface StudySettings { + studyStartDate: string | null; + totalRounds: string; + announcementChannelId: string | null; + curationChannelId: string | null; + adminDiscordIds: string; + studyRoleId: string | null; +} + +interface RoundInfo { + id: number; + roundNumber: number; + startDate: string; + endDate: string; + graceEndDate: string; + isCurrent: boolean; +} + +interface SettingsData { + settings: StudySettings; + currentRound: RoundInfo | null; + totalRoundsCreated: number; +} + +export default function AdminSettingsPage() { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [successMessage, setSuccessMessage] = useState(null); + + // Form state + const [formData, setFormData] = useState({ + studyStartDate: null, + totalRounds: '10', + announcementChannelId: null, + curationChannelId: null, + adminDiscordIds: '', + studyRoleId: null, + }); + + const fetchSettings = useCallback(async () => { + try { + setLoading(true); + const response = await fetch('/api/admin/settings'); + if (!response.ok) { + throw new Error('Failed to fetch settings'); + } + const result = await response.json(); + setData(result); + setFormData(result.settings); + } catch (err) { + setError('설정을 불러오는데 실패했습니다.'); + console.error(err); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchSettings(); + }, [fetchSettings]); + + const handleInputChange = (key: keyof StudySettings, value: string) => { + setFormData((prev) => ({ + ...prev, + [key]: value || null, + })); + // Clear messages on change + setSuccessMessage(null); + setError(null); + }; + + const handleSave = async () => { + try { + setSaving(true); + setError(null); + setSuccessMessage(null); + + const response = await fetch('/api/admin/settings', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(formData), + }); + + if (!response.ok) { + throw new Error('Failed to save settings'); + } + + setSuccessMessage('설정이 저장되었습니다.'); + // Refresh data + await fetchSettings(); + } catch (err) { + setError('설정 저장에 실패했습니다.'); + console.error(err); + } finally { + setSaving(false); + } + }; + + if (loading) { + return ( +
+
로딩 중...
+
+ ); + } + + return ( +
+
+
+

스터디 설정

+

+ 스터디 운영에 필요한 설정을 관리하세요. +

+
+ +
+ + {/* Status Messages */} + {error && ( +
+ {error} +
+ )} + {successMessage && ( +
+ {successMessage} +
+ )} + + {/* Current Round Info */} + {data?.currentRound && ( + + + + + 현재 회차 정보 + + + +
+
+

회차

+

{data.currentRound.roundNumber}회차

+
+
+

시작일

+

{data.currentRound.startDate}

+
+
+

마감일

+

{data.currentRound.endDate}

+
+
+

지각 마감

+

{data.currentRound.graceEndDate}

+
+
+
+
+ )} + +
+ {/* Study Schedule Settings */} + + + + + 스터디 일정 + + + 스터디 시작일과 총 회차를 설정합니다. + + + +
+ + handleInputChange('studyStartDate', e.target.value)} + placeholder="YYYY-MM-DD" + /> +

+ 스터디 1회차가 시작되는 월요일 날짜 +

+
+
+ + handleInputChange('totalRounds', e.target.value)} + /> +

+ 스터디 총 회차 수 (현재 {data?.totalRoundsCreated || 0}개 회차 생성됨) +

+
+
+
+ + {/* Channel Settings */} + + + + + Discord 채널 + + + 알림을 보낼 Discord 채널 ID를 설정합니다. + + + +
+ + handleInputChange('announcementChannelId', e.target.value)} + placeholder="예: 1234567890123456789" + /> +

+ 새 글 알림, 회차 리포트가 발송되는 채널 +

+
+
+ + handleInputChange('curationChannelId', e.target.value)} + placeholder="예: 1234567890123456789" + /> +

+ 큐레이션 컨텐츠가 공유되는 채널 +

+
+
+
+ + {/* Admin Settings */} + + + + + 관리자 설정 + + + 관리자 권한을 가진 Discord 사용자를 설정합니다. + + + +
+ + handleInputChange('adminDiscordIds', e.target.value)} + placeholder="예: 123456789,987654321" + /> +

+ 쉼표로 구분하여 여러 ID 입력 가능 +

+
+
+ + handleInputChange('studyRoleId', e.target.value)} + placeholder="예: 1234567890123456789" + /> +

+ 참가자에게 부여되는 Discord 역할 +

+
+
+
+ + {/* Info Card */} + + + + + 설정 안내 + + + +

+ 스터디 시작일을 변경하면 회차 날짜가 재계산됩니다. + 기존 출석 기록에 영향을 줄 수 있으니 주의하세요. +

+

+ Discord ID는 Discord 개발자 모드를 활성화한 후 + 사용자/채널/역할을 우클릭하여 복사할 수 있습니다. +

+

+ 설정 변경 후 저장 버튼을 눌러야 적용됩니다. +

+
+
+
+
+ ); +} diff --git a/packages/web/src/app/(admin)/layout.tsx b/packages/web/src/app/(admin)/layout.tsx new file mode 100644 index 0000000..c40b9b1 --- /dev/null +++ b/packages/web/src/app/(admin)/layout.tsx @@ -0,0 +1,86 @@ +'use client'; + +import { useEffect, useState, useCallback } from 'react'; +import { useRouter } from 'next/navigation'; +import { MainLayout } from '@/components/layout'; + +interface UserInfo { + name: string; + email: string; + imageUrl?: string; +} + +export default function AdminLayout({ + children, +}: { + children: React.ReactNode; +}) { + const router = useRouter(); + const [user, setUser] = useState(null); + const [isAdmin, setIsAdmin] = useState(false); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const checkAdminAccess = async () => { + try { + // Check admin status + const adminResponse = await fetch('/api/admin/check'); + const adminData = await adminResponse.json(); + + if (!adminResponse.ok || !adminData.isAdmin) { + // Not admin, redirect to dashboard + router.push('/dashboard'); + return; + } + + setIsAdmin(true); + + // Get user info + const userResponse = await fetch('/api/auth/me'); + if (userResponse.ok) { + const userData = await userResponse.json(); + setUser({ + name: userData.email.split('@')[0], + email: userData.email, + imageUrl: userData.profileImageUrl, + }); + } + } catch { + // Error checking admin, redirect to login + router.push('/login'); + } finally { + setLoading(false); + } + }; + + checkAdminAccess(); + }, [router]); + + const handleLogout = useCallback(async () => { + try { + await fetch('/api/auth/logout', { method: 'POST' }); + router.push('/login'); + router.refresh(); + } catch { + console.error('Logout failed'); + } + }, [router]); + + if (loading) { + return ( +
+
권한 확인 중...
+
+ ); + } + + if (!isAdmin) { + return null; + } + + return ( + + {children} + + ); +} diff --git a/packages/web/src/app/(auth)/layout.tsx b/packages/web/src/app/(auth)/layout.tsx new file mode 100644 index 0000000..d804069 --- /dev/null +++ b/packages/web/src/app/(auth)/layout.tsx @@ -0,0 +1,11 @@ +export default function AuthLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( +
+ {children} +
+ ); +} diff --git a/packages/web/src/app/(auth)/login/page.tsx b/packages/web/src/app/(auth)/login/page.tsx new file mode 100644 index 0000000..d29d68b --- /dev/null +++ b/packages/web/src/app/(auth)/login/page.tsx @@ -0,0 +1,168 @@ +'use client'; + +import { Suspense, useState } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import Link from 'next/link'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Card, CardContent, CardFooter, CardHeader } from '@/components/ui/card'; + +function LoginForm() { + const router = useRouter(); + const searchParams = useSearchParams(); + const redirect = searchParams.get('redirect') || '/dashboard'; + + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + const [isLoading, setIsLoading] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + setIsLoading(true); + + try { + const response = await fetch('/api/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password }), + }); + + const data = await response.json(); + + if (!response.ok) { + setError(data.message || '로그인에 실패했습니다.'); + return; + } + + // Redirect to intended page or dashboard + router.push(redirect); + router.refresh(); + } catch { + setError('서버 오류가 발생했습니다.'); + } finally { + setIsLoading(false); + } + }; + + return ( + + + {/* BS Logo */} +
+
+ + BS + +
+
+ + {/* Heading */} +
+

+ 로그인 +

+

+ 블로그 스터디에 오신 것을 환영합니다 +

+
+
+ +
+ + {error && ( +
+ {error} +
+ )} + +
+ + setEmail(e.target.value)} + required + disabled={isLoading} + className="h-9" + /> +
+ +
+ + setPassword(e.target.value)} + required + disabled={isLoading} + className="h-9" + /> +
+
+ + + + +

+ 계정이 없으신가요?{' '} + + 회원가입 + +

+
+
+
+ ); +} + +function LoginSkeleton() { + return ( + + +
+
+ + BS + +
+
+
+

+ 로그인 +

+

로딩 중...

+
+
+
+ ); +} + +export default function LoginPage() { + return ( +
+ }> + + +
+ ); +} diff --git a/packages/web/src/app/(auth)/register/page.tsx b/packages/web/src/app/(auth)/register/page.tsx new file mode 100644 index 0000000..acc4ebf --- /dev/null +++ b/packages/web/src/app/(auth)/register/page.tsx @@ -0,0 +1,165 @@ +'use client'; + +import { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import Link from 'next/link'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'; + +export default function RegisterPage() { + const router = useRouter(); + + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [error, setError] = useState(''); + const [success, setSuccess] = useState(false); + const [isLoading, setIsLoading] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + setIsLoading(true); + + // Validate password match + if (password !== confirmPassword) { + setError('비밀번호가 일치하지 않습니다.'); + setIsLoading(false); + return; + } + + // Validate password length + if (password.length < 8) { + setError('비밀번호는 최소 8자 이상이어야 합니다.'); + setIsLoading(false); + return; + } + + try { + const response = await fetch('/api/auth/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password }), + }); + + const data = await response.json(); + + if (!response.ok) { + setError(data.message || '회원가입에 실패했습니다.'); + return; + } + + setSuccess(true); + } catch { + setError('서버 오류가 발생했습니다.'); + } finally { + setIsLoading(false); + } + }; + + if (success) { + return ( +
+ + + 이메일 인증 필요 + + +
+
📧
+

+ {email}로 인증 이메일을 발송했습니다. +

+

+ 이메일을 확인하여 인증을 완료해 주세요. +
+ 인증 링크는 24시간 동안 유효합니다. +

+
+
+ + + +
+
+ ); + } + + return ( +
+ + + 회원가입 + + 블로그 스터디에 가입하세요 + + +
+ + {error && ( +
+ {error} +
+ )} +
+ + setEmail(e.target.value)} + required + disabled={isLoading} + /> +
+
+ + setPassword(e.target.value)} + required + disabled={isLoading} + /> +

최소 8자 이상 입력해 주세요

+
+
+ + setConfirmPassword(e.target.value)} + required + disabled={isLoading} + /> +
+
+ + +

+ 이미 계정이 있으신가요?{' '} + + 로그인 + +

+
+
+
+
+ ); +} diff --git a/packages/web/src/app/(auth)/verify-email/page.tsx b/packages/web/src/app/(auth)/verify-email/page.tsx new file mode 100644 index 0000000..df4f1df --- /dev/null +++ b/packages/web/src/app/(auth)/verify-email/page.tsx @@ -0,0 +1,124 @@ +'use client'; + +import { useEffect, useState, Suspense } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import Link from 'next/link'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'; + +function VerifyEmailContent() { + const router = useRouter(); + const searchParams = useSearchParams(); + const token = searchParams.get('token'); + + const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading'); + const [message, setMessage] = useState(''); + + useEffect(() => { + if (!token) { + setStatus('error'); + setMessage('인증 토큰이 없습니다.'); + return; + } + + const verifyEmail = async () => { + try { + const response = await fetch(`/api/auth/verify-email?token=${token}`); + const data = await response.json(); + + if (response.ok) { + setStatus('success'); + setMessage(data.message || '이메일 인증이 완료되었습니다.'); + } else { + setStatus('error'); + setMessage(data.message || '이메일 인증에 실패했습니다.'); + } + } catch { + setStatus('error'); + setMessage('서버 오류가 발생했습니다.'); + } + }; + + verifyEmail(); + }, [token]); + + if (status === 'loading') { + return ( +
+ + + 이메일 인증 중... + + +
+
+
+
+ ); + } + + return ( +
+ + + + {status === 'success' ? '인증 완료' : '인증 실패'} + + + +
+
+ {status === 'success' ? '✅' : '❌'} +
+

+ {message} +

+
+
+ + {status === 'success' ? ( + + ) : ( + <> + +

+ 인증 이메일을 다시 받으시려면{' '} + + 회원가입 + + 을 다시 시도해 주세요. +

+ + )} +
+
+
+ ); +} + +export default function VerifyEmailPage() { + return ( + + + + 이메일 인증 중... + + +
+
+
+
+ }> + + + ); +} diff --git a/packages/web/src/app/(user)/curation/page.tsx b/packages/web/src/app/(user)/curation/page.tsx new file mode 100644 index 0000000..82da63c --- /dev/null +++ b/packages/web/src/app/(user)/curation/page.tsx @@ -0,0 +1,216 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { Bookmark, ExternalLink, Calendar, Tag } from 'lucide-react'; +import { Card, CardContent, CardHeader } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Separator } from '@/components/ui/separator'; + +interface CurationItem { + id: string; + title: string; + url: string; + publishedAt: string | null; + category: string; + tags: string[] | null; + relevanceScore: number; + sharedAt: string | null; +} + +interface CurationData { + items: CurationItem[]; + totalCount: number; +} + +export default function CurationPage() { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [filter, setFilter] = useState<'all' | 'conference' | 'article'>('all'); + + useEffect(() => { + const fetchCuration = async () => { + setLoading(true); + try { + const response = await fetch(`/api/curation?category=${filter}`); + if (!response.ok) { + throw new Error('Failed to fetch curation data'); + } + const result = await response.json(); + setData(result); + } catch (err) { + setError('큐레이션 데이터를 불러오는데 실패했습니다.'); + console.error(err); + } finally { + setLoading(false); + } + }; + + fetchCuration(); + }, [filter]); + + const getCategoryBadge = (category: string) => { + switch (category) { + case 'conference': + return ( + + 컨퍼런스 + + ); + case 'article': + return ( + + 아티클 + + ); + default: + return ( + + {category} + + ); + } + }; + + if (loading) { + return ( +
+
로딩 중...
+
+ ); + } + + if (error) { + return ( +
+
{error}
+
+ ); + } + + const filters: { value: 'all' | 'conference' | 'article'; label: string }[] = [ + { value: 'all', label: '전체' }, + { value: 'conference', label: '컨퍼런스' }, + { value: 'article', label: '아티클' }, + ]; + + return ( +
+ {/* Page Header */} +
+

+ Curation +

+

큐레이션

+
+ + {/* Filter Buttons */} +
+ {filters.map(({ value, label }) => ( + + ))} +
+ + {/* Curation Card */} + + +
+
+ + 큐레이션 목록 +
+ + 총 {data?.totalCount ?? 0}개 + +
+
+ + {data?.items && data.items.length > 0 ? ( +
+ {data.items.map((item, index) => ( +
+
+
+ {/* Badges row */} +
+ {getCategoryBadge(item.category)} + {item.sharedAt && ( + + 공유됨 + + )} +
+ + {/* Title */} + + {item.title} + + + {/* Metadata */} +
+ {item.publishedAt && ( +
+ + + {new Date(item.publishedAt).toLocaleDateString('ko-KR')} + +
+ )} + {item.tags && item.tags.length > 0 && ( +
+ + + {item.tags.slice(0, 3).join(', ')} + {item.tags.length > 3 && ` +${item.tags.length - 3}`} + +
+ )} +
+
+ + {/* External link */} + +
+ + {index < data.items.length - 1 && ( + + )} +
+ ))} +
+ ) : ( +
+ +

아직 큐레이션된 컨텐츠가 없습니다.

+
+ )} +
+
+
+ ); +} diff --git a/packages/web/src/app/(user)/dashboard/page.tsx b/packages/web/src/app/(user)/dashboard/page.tsx new file mode 100644 index 0000000..861e348 --- /dev/null +++ b/packages/web/src/app/(user)/dashboard/page.tsx @@ -0,0 +1,248 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import Link from 'next/link'; +import { Calendar, FileText, Clock, TrendingUp, ArrowUpRight, Inbox } from 'lucide-react'; +import { Card, CardContent, CardHeader } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; + +interface RoundInfo { + roundNumber: number; + startDate: string; + endDate: string; + graceEndDate: string; + daysRemaining: number; + isGracePeriod: boolean; +} + +interface Post { + id: string; + title: string; + url: string; + publishedAt: string; + memberName: string; + memberDiscordUsername: string; +} + +interface DashboardData { + currentRound: RoundInfo | null; + recentPosts: Post[]; + totalMembers: number; + totalPosts: number; +} + +function AvatarInitial({ name }: { name: string }) { + const initial = name.trim().charAt(0).toUpperCase(); + return ( +
+ {initial} +
+ ); +} + +export default function DashboardPage() { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const fetchDashboard = async () => { + try { + const response = await fetch('/api/dashboard'); + if (!response.ok) { + throw new Error('Failed to fetch dashboard data'); + } + const result = await response.json(); + setData(result); + } catch (err) { + setError('대시보드 데이터를 불러오는데 실패했습니다.'); + console.error(err); + } finally { + setLoading(false); + } + }; + + fetchDashboard(); + }, []); + + if (loading) { + return ( +
+
로딩 중...
+
+ ); + } + + if (error) { + return ( +
+
{error}
+
+ ); + } + + return ( +
+ {/* Page Header */} +
+

+ Overview +

+

블로그 스터디 현황

+
+ + {/* Stat Cards */} +
+ {/* 현재 회차 */} + + +
+
+

현재 회차

+

+ {data?.currentRound ? `${data.currentRound.roundNumber}회차` : '-'} +

+ {data?.currentRound && ( +

+ {data.currentRound.startDate} ~ {data.currentRound.endDate} +

+ )} +
+
+ +
+
+
+
+ + {/* 마감까지 */} + + +
+
+

마감까지

+

+ {data?.currentRound ? ( + data.currentRound.isGracePeriod ? ( + 지각 기간 + ) : ( + `${data.currentRound.daysRemaining}일` + ) + ) : ( + '-' + )} +

+ {data?.currentRound?.isGracePeriod && ( +

+ 지각 마감: {data.currentRound.graceEndDate} +

+ )} +
+
+ +
+
+
+
+ + {/* 총 참가자 */} + + +
+
+

총 참가자

+

+ {data?.totalMembers ?? 0}명 +

+

활성 스터디원

+
+
+ +
+
+
+
+ + {/* 총 포스트 */} + + +
+
+

총 포스트

+

+ {data?.totalPosts ?? 0}개 +

+

누적 작성 글

+
+
+ +
+
+
+
+
+ + {/* Recent Posts */} + + +
+

+ Activity +

+

최근 포스트

+
+ +
+ + {data?.recentPosts && data.recentPosts.length > 0 ? ( +
+ {data.recentPosts.map((post) => { + const authorName = post.memberName || post.memberDiscordUsername; + return ( +
+ +
+ + {post.title} + +
+ {authorName} + · + + {new Date(post.publishedAt).toLocaleDateString('ko-KR')} + +
+
+
+ ); + })} +
+ ) : ( +
+ +

아직 등록된 포스트가 없습니다.

+
+ )} +
+
+
+ ); +} diff --git a/packages/web/src/app/(user)/layout.tsx b/packages/web/src/app/(user)/layout.tsx new file mode 100644 index 0000000..196535b --- /dev/null +++ b/packages/web/src/app/(user)/layout.tsx @@ -0,0 +1,56 @@ +'use client'; + +import { useEffect, useState, useCallback } from 'react'; +import { useRouter } from 'next/navigation'; +import { MainLayout } from '@/components/layout'; + +interface UserInfo { + name: string; + email: string; + imageUrl?: string; +} + +export default function UserLayout({ + children, +}: { + children: React.ReactNode; +}) { + const router = useRouter(); + const [user, setUser] = useState(null); + + useEffect(() => { + // Get user info from cookie or API + const fetchUser = async () => { + try { + const response = await fetch('/api/auth/me'); + if (response.ok) { + const data = await response.json(); + setUser({ + name: data.email.split('@')[0], + email: data.email, + imageUrl: data.profileImageUrl, + }); + } + } catch { + // User not authenticated, middleware will handle redirect + } + }; + fetchUser(); + }, []); + + const handleLogout = useCallback(async () => { + try { + await fetch('/api/auth/logout', { method: 'POST' }); + router.push('/login'); + router.refresh(); + } catch { + console.error('Logout failed'); + } + }, [router]); + + return ( + + {children} + + ); +} diff --git a/packages/web/src/app/(user)/members/[id]/page.tsx b/packages/web/src/app/(user)/members/[id]/page.tsx new file mode 100644 index 0000000..c5b42cf --- /dev/null +++ b/packages/web/src/app/(user)/members/[id]/page.tsx @@ -0,0 +1,295 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useParams, useRouter } from 'next/navigation'; +import { ArrowLeft, User, FileText, CheckCircle, Calendar, ExternalLink } from 'lucide-react'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { Separator } from '@/components/ui/separator'; + +interface MemberInfo { + id: string; + discordUsername: string; + name: string; + part: string; + blogUrl: string; + profileImageUrl: string | null; + bio: string | null; + interests: string[] | null; + resolution: string | null; + status: string; + joinedAt: string; +} + +interface Stats { + postCount: number; + totalRounds: number; + submittedRounds: number; + lateRounds: number; + absentRounds: number; + attendanceRate: number; +} + +interface Post { + id: string; + title: string; + url: string; + publishedAt: string; +} + +interface MemberProfileData { + member: MemberInfo; + stats: Stats; + recentPosts: Post[]; +} + +export default function MemberProfilePage() { + const params = useParams(); + const router = useRouter(); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const fetchMember = async () => { + try { + const response = await fetch(`/api/members/${params.id}`); + if (!response.ok) { + if (response.status === 404) { + setError('멤버를 찾을 수 없습니다.'); + } else { + throw new Error('Failed to fetch member'); + } + return; + } + const result = await response.json(); + setData(result); + } catch (err) { + setError('프로필 정보를 불러오는데 실패했습니다.'); + console.error(err); + } finally { + setLoading(false); + } + }; + + if (params.id) { + fetchMember(); + } + }, [params.id]); + + const getStatusBadge = (status: string) => { + switch (status) { + case 'active': + return 활동중; + case 'dormant': + return 휴면; + case 'withdrawn': + return 탈퇴; + default: + return {status}; + } + }; + + if (loading) { + return ( +
+
로딩 중...
+
+ ); + } + + if (error || !data) { + return ( +
+ +
+
{error || '데이터를 불러올 수 없습니다.'}
+
+
+ ); + } + + const { member, stats, recentPosts } = data; + + return ( +
+
+ +
+

{member.name}

+

스터디원 프로필

+
+
+ + {/* Profile Card */} + + +
+
+ + 프로필 +
+ {getStatusBadge(member.status)} +
+
+ +
+ + + + {member.name.slice(0, 2).toUpperCase()} + + +
+
+

{member.name}

+

@{member.discordUsername}

+
+ {member.part} +
+
+ + {member.bio && ( + <> + +
+

한줄 소개

+

{member.bio}

+
+ + )} + + {member.interests && member.interests.length > 0 && ( +
+

관심 분야

+
+ {member.interests.map((interest, idx) => ( + {interest} + ))} +
+
+ )} + + {member.resolution && ( +
+

다짐

+

“{member.resolution}”

+
+ )} + + + +
+
+

블로그

+ + {member.blogUrl} + + +
+
+

가입일

+

+ {new Date(member.joinedAt).toLocaleDateString('ko-KR')} +

+
+
+
+
+ + {/* Stats */} +
+ + + 작성 글 + + + +
{stats.postCount}개
+
+
+ + + + 출석률 + + + +
{stats.attendanceRate}%
+

+ {stats.submittedRounds}/{stats.totalRounds}회 +

+
+
+ + + + 지각/결석 + + + +
+ {stats.lateRounds + stats.absentRounds}회 +
+

+ 지각 {stats.lateRounds}회 / 결석 {stats.absentRounds}회 +

+
+
+
+ + {/* Recent Posts */} + + +
+ + 최근 포스트 +
+ 최근 작성한 블로그 글 +
+ + {recentPosts.length > 0 ? ( +
+ {recentPosts.map((post) => ( +
+
+ + {post.title} + + +

+ {new Date(post.publishedAt).toLocaleDateString('ko-KR')} +

+
+
+ ))} +
+ ) : ( +
+ 아직 작성한 포스트가 없습니다. +
+ )} +
+
+
+ ); +} diff --git a/packages/web/src/app/(user)/posts/page.tsx b/packages/web/src/app/(user)/posts/page.tsx new file mode 100644 index 0000000..e0620a7 --- /dev/null +++ b/packages/web/src/app/(user)/posts/page.tsx @@ -0,0 +1,217 @@ +'use client'; + +import { Suspense, useEffect, useState } from 'react'; +import { useSearchParams, useRouter } from 'next/navigation'; +import { FileText, ExternalLink, ChevronLeft, ChevronRight } from 'lucide-react'; +import { Card, CardContent, CardHeader } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; + +interface Post { + id: string; + title: string; + url: string; + publishedAt: string; + memberName: string; + memberDiscordUsername: string; + roundNumber: number | null; +} + +interface PostsData { + posts: Post[]; + pagination: { + page: number; + pageSize: number; + totalPages: number; + totalCount: number; + }; +} + +function PostsContent() { + const router = useRouter(); + const searchParams = useSearchParams(); + const currentPage = parseInt(searchParams.get('page') || '1', 10); + + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const fetchPosts = async () => { + setLoading(true); + try { + const response = await fetch(`/api/posts?page=${currentPage}&pageSize=10`); + if (!response.ok) { + throw new Error('Failed to fetch posts'); + } + const result = await response.json(); + setData(result); + } catch (err) { + setError('포스트 목록을 불러오는데 실패했습니다.'); + console.error(err); + } finally { + setLoading(false); + } + }; + + fetchPosts(); + }, [currentPage]); + + const handlePageChange = (page: number) => { + router.push(`/posts?page=${page}`); + }; + + if (loading) { + return ( +
+
로딩 중...
+
+ ); + } + + if (error) { + return ( +
+
{error}
+
+ ); + } + + return ( + + +
+
+ + 전체 포스트 +
+ + 총 {data?.pagination.totalCount ?? 0}개 + +
+
+ + {data?.posts && data.posts.length > 0 ? ( + <> + + + + 제목 + 작성자 + 회차 + 작성일 + 링크 + + + + {data.posts.map((post) => ( + + + + {post.title} + + + + {post.memberName || post.memberDiscordUsername} + + + {post.roundNumber ? ( + + {post.roundNumber}회차 + + ) : ( + — + )} + + + {new Date(post.publishedAt).toLocaleDateString('ko-KR')} + + + + + + ))} + +
+ + {/* Pagination */} + {data.pagination.totalPages > 1 && ( +
+ + + {currentPage} / {data.pagination.totalPages} + + +
+ )} + + ) : ( +
+ +

아직 등록된 포스트가 없습니다.

+
+ )} +
+
+ ); +} + +export default function PostsPage() { + return ( +
+
+

Posts

+

포스트

+

+ 스터디원들이 작성한 블로그 글 목록입니다. +

+
+ + +
로딩 중...
+
+ }> + + +
+ ); +} diff --git a/packages/web/src/app/(user)/profile/edit/page.tsx b/packages/web/src/app/(user)/profile/edit/page.tsx new file mode 100644 index 0000000..b74bc16 --- /dev/null +++ b/packages/web/src/app/(user)/profile/edit/page.tsx @@ -0,0 +1,324 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { ArrowLeft, Save, Image, FileText, Heart, Target } from 'lucide-react'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Badge } from '@/components/ui/badge'; +import { Separator } from '@/components/ui/separator'; + +const INTEREST_OPTIONS = [ + 'Frontend', + 'Backend', + 'DevOps', + 'Mobile', + 'AI/ML', + 'Data', + 'Security', + 'Cloud', + 'Design', + 'PM', + 'Startup', + 'Career', +]; + +interface ProfileData { + member: { + name: string; + profileImageUrl: string | null; + bio: string | null; + interests: string[] | null; + resolution: string | null; + onboardingCompleted: boolean; + } | null; +} + +export default function ProfileEditPage() { + const router = useRouter(); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(false); + + // Form state + const [profileImageUrl, setProfileImageUrl] = useState(''); + const [bio, setBio] = useState(''); + const [interests, setInterests] = useState([]); + const [customInterest, setCustomInterest] = useState(''); + const [resolution, setResolution] = useState(''); + + useEffect(() => { + const fetchProfile = async () => { + try { + const response = await fetch('/api/profile'); + if (!response.ok) { + router.push('/profile'); + return; + } + const data: ProfileData = await response.json(); + + if (!data.member) { + router.push('/profile'); + return; + } + + // Pre-fill existing data + if (data.member.profileImageUrl) setProfileImageUrl(data.member.profileImageUrl); + if (data.member.bio) setBio(data.member.bio); + if (data.member.interests) setInterests(data.member.interests); + if (data.member.resolution) setResolution(data.member.resolution); + } catch (err) { + console.error(err); + router.push('/profile'); + } finally { + setLoading(false); + } + }; + + fetchProfile(); + }, [router]); + + const toggleInterest = (interest: string) => { + setInterests((prev) => + prev.includes(interest) + ? prev.filter((i) => i !== interest) + : [...prev, interest] + ); + }; + + const addCustomInterest = () => { + if (customInterest.trim() && !interests.includes(customInterest.trim())) { + setInterests((prev) => [...prev, customInterest.trim()]); + setCustomInterest(''); + } + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setSaving(true); + setError(null); + setSuccess(false); + + try { + const response = await fetch('/api/profile/edit', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + profileImageUrl: profileImageUrl || null, + bio: bio || null, + interests: interests.length > 0 ? interests : null, + resolution: resolution || null, + }), + }); + + const result = await response.json(); + + if (!response.ok) { + setError(result.message || '저장에 실패했습니다.'); + return; + } + + setSuccess(true); + setTimeout(() => { + router.push('/profile'); + }, 1000); + } catch (err) { + setError('서버 오류가 발생했습니다.'); + console.error(err); + } finally { + setSaving(false); + } + }; + + if (loading) { + return ( +
+
로딩 중...
+
+ ); + } + + return ( +
+
+ +
+

프로필 수정

+

+ 프로필 정보를 수정하세요. +

+
+
+ +
+ {/* Profile Image */} + + +
+ + 프로필 이미지 +
+ + 프로필에 표시될 이미지 URL을 입력하세요. + +
+ +
+ + setProfileImageUrl(e.target.value)} + /> +
+ {profileImageUrl && ( +
+ Preview { + (e.target as HTMLImageElement).style.display = 'none'; + }} + /> +
+ )} +
+
+ + {/* Bio */} + + +
+ + 한줄 소개 +
+
+ + + setBio(e.target.value)} + maxLength={200} + /> +

+ {bio.length}/200 +

+
+
+ + {/* Interests */} + + +
+ + 관심 분야 +
+
+ +
+ {INTEREST_OPTIONS.map((interest) => ( + toggleInterest(interest)} + > + {interest} + + ))} +
+ + {/* Custom interests */} + {interests.filter((i) => !INTEREST_OPTIONS.includes(i)).length > 0 && ( + <> + +
+ {interests + .filter((i) => !INTEREST_OPTIONS.includes(i)) + .map((interest) => ( + toggleInterest(interest)} + > + {interest} × + + ))} +
+ + )} + +
+ setCustomInterest(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + addCustomInterest(); + } + }} + /> + +
+
+
+ + {/* Resolution */} + + +
+ + 다짐 +
+
+ + + setResolution(e.target.value)} + maxLength={300} + /> +

+ {resolution.length}/300 +

+
+
+ + {/* Messages */} + {error && ( +

{error}

+ )} + {success && ( +

프로필이 수정되었습니다!

+ )} + + {/* Submit Button */} +
+ + +
+
+
+ ); +} diff --git a/packages/web/src/app/(user)/profile/onboarding/page.tsx b/packages/web/src/app/(user)/profile/onboarding/page.tsx new file mode 100644 index 0000000..d4b90f0 --- /dev/null +++ b/packages/web/src/app/(user)/profile/onboarding/page.tsx @@ -0,0 +1,381 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { Image, FileText, Heart, Target, ArrowRight, Check } from 'lucide-react'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Badge } from '@/components/ui/badge'; + +const INTEREST_OPTIONS = [ + 'Frontend', + 'Backend', + 'DevOps', + 'Mobile', + 'AI/ML', + 'Data', + 'Security', + 'Cloud', + 'Design', + 'PM', + 'Startup', + 'Career', +]; + +interface ProfileData { + member: { + name: string; + profileImageUrl: string | null; + bio: string | null; + interests: string[] | null; + resolution: string | null; + onboardingCompleted: boolean; + } | null; +} + +export default function OnboardingPage() { + const router = useRouter(); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [step, setStep] = useState(1); + + // Form state + const [profileImageUrl, setProfileImageUrl] = useState(''); + const [bio, setBio] = useState(''); + const [interests, setInterests] = useState([]); + const [customInterest, setCustomInterest] = useState(''); + const [resolution, setResolution] = useState(''); + + useEffect(() => { + const fetchProfile = async () => { + try { + const response = await fetch('/api/profile'); + if (!response.ok) { + router.push('/profile'); + return; + } + const data: ProfileData = await response.json(); + + if (!data.member) { + router.push('/profile'); + return; + } + + // Pre-fill existing data + if (data.member.profileImageUrl) setProfileImageUrl(data.member.profileImageUrl); + if (data.member.bio) setBio(data.member.bio); + if (data.member.interests) setInterests(data.member.interests); + if (data.member.resolution) setResolution(data.member.resolution); + } catch (err) { + console.error(err); + router.push('/profile'); + } finally { + setLoading(false); + } + }; + + fetchProfile(); + }, [router]); + + const toggleInterest = (interest: string) => { + setInterests((prev) => + prev.includes(interest) + ? prev.filter((i) => i !== interest) + : [...prev, interest] + ); + }; + + const addCustomInterest = () => { + if (customInterest.trim() && !interests.includes(customInterest.trim())) { + setInterests((prev) => [...prev, customInterest.trim()]); + setCustomInterest(''); + } + }; + + const handleSubmit = async () => { + setSaving(true); + setError(null); + + try { + const response = await fetch('/api/profile/onboarding', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + profileImageUrl: profileImageUrl || null, + bio: bio || null, + interests: interests.length > 0 ? interests : null, + resolution: resolution || null, + }), + }); + + const result = await response.json(); + + if (!response.ok) { + setError(result.message || '저장에 실패했습니다.'); + return; + } + + router.push('/profile'); + } catch (err) { + setError('서버 오류가 발생했습니다.'); + console.error(err); + } finally { + setSaving(false); + } + }; + + const nextStep = () => setStep((prev) => Math.min(prev + 1, 4)); + const prevStep = () => setStep((prev) => Math.max(prev - 1, 1)); + + if (loading) { + return ( +
+
로딩 중...
+
+ ); + } + + return ( +
+
+

프로필 작성

+

+ 다른 스터디원들에게 나를 소개해보세요. +

+
+ + {/* Progress Steps */} +
+ {[1, 2, 3, 4].map((s) => ( +
+
+ {s < step ? : s} +
+ {s < 4 && ( +
+ )} +
+ ))} +
+ + {/* Step 1: Profile Image */} + {step === 1 && ( + + +
+ + 프로필 이미지 +
+ + 프로필에 표시될 이미지 URL을 입력하세요. (선택사항) + +
+ +
+ + setProfileImageUrl(e.target.value)} + /> +

+ JPG, PNG, WebP 형식의 이미지 URL을 입력하세요. +

+
+ {profileImageUrl && ( +
+ Preview { + (e.target as HTMLImageElement).style.display = 'none'; + }} + /> +
+ )} +
+
+ )} + + {/* Step 2: Bio */} + {step === 2 && ( + + +
+ + 한줄 소개 +
+ + 나를 한 문장으로 소개해보세요. (선택사항) + +
+ +
+ + setBio(e.target.value)} + maxLength={200} + /> +

+ {bio.length}/200 +

+
+
+
+ )} + + {/* Step 3: Interests */} + {step === 3 && ( + + +
+ + 관심 분야 +
+ + 관심 있는 분야를 선택하거나 직접 입력하세요. (선택사항) + +
+ +
+ {INTEREST_OPTIONS.map((interest) => ( + toggleInterest(interest)} + > + {interest} + + ))} +
+ + {/* Custom interests */} + {interests.filter((i) => !INTEREST_OPTIONS.includes(i)).length > 0 && ( +
+ {interests + .filter((i) => !INTEREST_OPTIONS.includes(i)) + .map((interest) => ( + toggleInterest(interest)} + > + {interest} × + + ))} +
+ )} + +
+ setCustomInterest(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + addCustomInterest(); + } + }} + /> + +
+
+
+ )} + + {/* Step 4: Resolution */} + {step === 4 && ( + + +
+ + 다짐 +
+ + 이번 스터디에서의 다짐을 적어보세요. (선택사항) + +
+ +
+ + setResolution(e.target.value)} + maxLength={300} + /> +

+ {resolution.length}/300 +

+
+
+
+ )} + + {/* Error Message */} + {error && ( +

{error}

+ )} + + {/* Navigation Buttons */} +
+ + + {step < 4 ? ( + + ) : ( + + )} +
+ + {/* Skip Button */} +
+ +
+
+ ); +} diff --git a/packages/web/src/app/(user)/profile/page.tsx b/packages/web/src/app/(user)/profile/page.tsx new file mode 100644 index 0000000..0d73436 --- /dev/null +++ b/packages/web/src/app/(user)/profile/page.tsx @@ -0,0 +1,451 @@ +'use client'; + +import { useEffect, useState, useCallback } from 'react'; +import { useRouter } from 'next/navigation'; +import { User, Link2, FileText, CheckCircle, AlertCircle, Calendar, Wallet, ExternalLink } from 'lucide-react'; +import { Card, CardContent, CardHeader } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Badge } from '@/components/ui/badge'; +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { Separator } from '@/components/ui/separator'; + +interface UserInfo { + id: string; + email: string; + emailVerified: boolean; + memberId: string | null; +} + +interface MemberInfo { + id: string; + discordId: string; + discordUsername: string; + name: string; + part: string; + blogUrl: string; + rssUrl: string | null; + profileImageUrl: string | null; + bio: string | null; + interests: string[] | null; + resolution: string | null; + onboardingCompleted: boolean; + status: string; + dormantUsed: boolean; + joinedAt: string; +} + +interface Stats { + postCount: number; + totalRounds: number; + submittedRounds: number; + lateRounds: number; + absentRounds: number; + attendanceRate: number; + totalFines: number; + unpaidFines: number; +} + +interface ProfileData { + user: UserInfo; + member: MemberInfo | null; + stats: Stats | null; +} + +export default function ProfilePage() { + const router = useRouter(); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [discordId, setDiscordId] = useState(''); + const [linking, setLinking] = useState(false); + const [linkError, setLinkError] = useState(null); + + const fetchProfile = useCallback(async () => { + try { + const response = await fetch('/api/profile'); + if (!response.ok) { + throw new Error('Failed to fetch profile'); + } + const result = await response.json(); + setData(result); + } catch (err) { + setError('프로필 정보를 불러오는데 실패했습니다.'); + console.error(err); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchProfile(); + }, [fetchProfile]); + + const handleLinkMember = async (e: React.FormEvent) => { + e.preventDefault(); + if (!discordId.trim()) return; + + setLinking(true); + setLinkError(null); + + try { + const response = await fetch('/api/profile/link', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ discordId: discordId.trim() }), + }); + + const result = await response.json(); + + if (!response.ok) { + setLinkError(result.message || '연결에 실패했습니다.'); + return; + } + + // Refresh profile data + await fetchProfile(); + setDiscordId(''); + + // Check if onboarding is needed + if (!result.member.onboardingCompleted) { + router.push('/profile/onboarding'); + } + } catch (err) { + setLinkError('서버 오류가 발생했습니다.'); + console.error(err); + } finally { + setLinking(false); + } + }; + + const handleUnlinkMember = async () => { + if (!confirm('스터디원 계정 연결을 해제하시겠습니까?')) return; + + try { + const response = await fetch('/api/profile/link', { + method: 'DELETE', + }); + + if (!response.ok) { + throw new Error('Failed to unlink'); + } + + await fetchProfile(); + } catch (err) { + console.error(err); + } + }; + + const getStatusBadge = (status: string) => { + switch (status) { + case 'active': + return 활동중; + case 'dormant': + return 휴면; + case 'withdrawn': + return 탈퇴; + default: + return {status}; + } + }; + + if (loading) { + return ( +
+
로딩 중...
+
+ ); + } + + if (error) { + return ( +
+
{error}
+
+ ); + } + + return ( +
+ {/* Page Header */} +
+

+ Profile +

+

내 프로필

+
+ + {/* Account Info */} + + +
+
+ +
+

계정 정보

+
+
+ +
+ + + + {(data?.member?.name || data?.user?.email || 'U').slice(0, 2).toUpperCase()} + + +
+

+ {data?.member?.name || data?.user?.email?.split('@')[0]} +

+

{data?.user?.email}

+ {data?.user?.emailVerified ? ( + + + 이메일 인증됨 + + ) : ( + + + 이메일 미인증 + + )} +
+
+
+
+ + {/* Member Link Section */} + {!data?.member ? ( + + +
+
+ +
+
+

스터디원 계정 연결

+

+ Discord ID를 입력하여 스터디원 계정을 연결하세요. +

+
+
+
+ +
+
+ + setDiscordId(e.target.value)} + disabled={linking} + className="border-border/60" + /> +

+ Discord 설정 > 내 계정에서 ID를 복사할 수 있습니다. +

+
+ {linkError && ( +

{linkError}

+ )} + +
+
+
+ ) : ( + <> + {/* Member Profile */} + + +
+
+
+ +
+

스터디원 정보

+
+
+ {getStatusBadge(data.member.status)} + {!data.member.onboardingCompleted && ( + + )} +
+
+
+ +
+
+

Discord

+

{data.member.discordUsername}

+
+
+

파트

+

{data.member.part}

+
+ +
+

가입일

+

+ {new Date(data.member.joinedAt).toLocaleDateString('ko-KR')} +

+
+
+ + {data.member.bio && ( + <> + +
+

한줄 소개

+

{data.member.bio}

+
+ + )} + + {data.member.interests && data.member.interests.length > 0 && ( +
+

관심 분야

+
+ {data.member.interests.map((interest, idx) => ( + + {interest} + + ))} +
+
+ )} + + {data.member.resolution && ( +
+

다짐

+

“{data.member.resolution}”

+
+ )} + + +
+ +
+
+
+ + {/* Stats */} + {data.stats && ( +
+ + +
+
+

작성 글

+

{data.stats.postCount}개

+
+
+ +
+
+
+
+ + + +
+
+

출석률

+

{data.stats.attendanceRate}%

+

+ {data.stats.submittedRounds}/{data.stats.totalRounds}회 +

+
+
+ +
+
+
+
+ + + +
+
+

지각/결석

+

+ {data.stats.lateRounds + data.stats.absentRounds}회 +

+

+ 지각 {data.stats.lateRounds}회 / 결석 {data.stats.absentRounds}회 +

+
+
+ +
+
+
+
+ + + +
+
+

미납 벌금

+

+ {data.stats.unpaidFines.toLocaleString()}원 +

+

+ 총 {data.stats.totalFines.toLocaleString()}원 +

+
+
+ +
+
+
+
+
+ )} + + {/* Edit Profile Button */} + {data.member.onboardingCompleted && ( +
+ +
+ )} + + )} +
+ ); +} diff --git a/packages/web/src/app/(user)/ranking/page.tsx b/packages/web/src/app/(user)/ranking/page.tsx new file mode 100644 index 0000000..0e52da2 --- /dev/null +++ b/packages/web/src/app/(user)/ranking/page.tsx @@ -0,0 +1,295 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import Link from 'next/link'; +import { Trophy, Medal, Award, FileText, CheckCircle } from 'lucide-react'; +import { Card, CardContent, CardHeader } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; + +interface RankingMember { + id: string; + name: string; + discordUsername: string; + profileImageUrl: string | null; + postCount: number; + attendanceRate: number; + submittedRounds: number; + totalRounds: number; +} + +interface RankingData { + rankings: RankingMember[]; + totalMembers: number; +} + +const PODIUM_CONFIG = [ + { + rank: 1, + icon: , + label: '1위', + cardClass: 'ring-2 ring-yellow-400/40 border-yellow-400/20 shadow-none', + labelClass: 'bg-yellow-50 text-yellow-700 dark:bg-yellow-950/40 dark:text-yellow-400', + }, + { + rank: 2, + icon: , + label: '2위', + cardClass: 'border-border/50 shadow-none', + labelClass: 'bg-muted text-muted-foreground', + }, + { + rank: 3, + icon: , + label: '3위', + cardClass: 'border-border/50 shadow-none', + labelClass: 'bg-muted text-muted-foreground', + }, +] as const; + +export default function RankingPage() { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [sortBy, setSortBy] = useState<'posts' | 'attendance'>('posts'); + + useEffect(() => { + const fetchRanking = async () => { + setLoading(true); + try { + const response = await fetch(`/api/ranking?sortBy=${sortBy}`); + if (!response.ok) { + throw new Error('Failed to fetch ranking data'); + } + const result = await response.json(); + setData(result); + } catch (err) { + setError('랭킹 데이터를 불러오는데 실패했습니다.'); + console.error(err); + } finally { + setLoading(false); + } + }; + + fetchRanking(); + }, [sortBy]); + + const getRankDisplay = (rank: number) => { + switch (rank) { + case 1: + return ; + case 2: + return ; + case 3: + return ; + default: + return ( + + {rank} + + ); + } + }; + + if (loading) { + return ( +
+
로딩 중...
+
+ ); + } + + if (error) { + return ( +
+
{error}
+
+ ); + } + + return ( +
+ {/* Page header */} +
+

Ranking

+

랭킹

+

+ 스터디원들의 활동 랭킹을 확인하세요. +

+
+ + {/* Top 3 podium cards */} + {data?.rankings && data.rankings.length >= 3 && ( +
+ {data.rankings.slice(0, 3).map((member, index) => { + const config = PODIUM_CONFIG[index]!; + return ( + + +
+
+ {config.icon} + + {config.label} + +
+
+
+ + + + + + {(member.name || member.discordUsername).slice(0, 2).toUpperCase()} + + +
+

+ {member.name || member.discordUsername} +

+
+ + + {member.postCount}개 + + + + {member.attendanceRate.toFixed(0)}% + +
+
+ +
+
+ ); + })} +
+ )} + + {/* Sort toggle */} +
+ + +
+ + {/* Full ranking table */} + + +
+
+ + 전체 랭킹 +
+ + 총 {data?.totalMembers ?? 0}명 + +
+
+ + {data?.rankings && data.rankings.length > 0 ? ( + + + + 순위 + 스터디원 + 포스트 + 출석률 + 출석 현황 + + + + {data.rankings.map((member, index) => ( + + +
+ {getRankDisplay(index + 1)} +
+
+ + + + + + {(member.name || member.discordUsername).slice(0, 2).toUpperCase()} + + + + {member.name || member.discordUsername} + + + + + + {member.postCount}개 + + + + = 80 + ? 'success' + : member.attendanceRate >= 50 + ? 'warning' + : 'destructive' + } + > + {member.attendanceRate.toFixed(0)}% + + + + {member.submittedRounds}/{member.totalRounds}회 + +
+ ))} +
+
+ ) : ( +
+ +

아직 랭킹 데이터가 없습니다.

+
+ )} +
+
+
+ ); +} diff --git a/packages/web/src/app/api/admin/attendance/[id]/route.ts b/packages/web/src/app/api/admin/attendance/[id]/route.ts new file mode 100644 index 0000000..d16ca58 --- /dev/null +++ b/packages/web/src/app/api/admin/attendance/[id]/route.ts @@ -0,0 +1,214 @@ +import { NextRequest } from 'next/server'; +import { eq, and } from 'drizzle-orm'; +import { db } from '@/lib/db'; +import { db as sharedDb } from '@blog-study/shared'; +import { withAdminAuth } from '@/lib/admin'; +import { Errors, successResponse, errorResponse } from '@/lib/api-error'; + +const { attendance, members, rounds, fines, AttendanceStatus, FineStatus, FineType } = sharedDb; + +/** + * GET /api/admin/attendance/[id] + * Get a single attendance record + */ +export const GET = withAdminAuth(async (request: NextRequest, _adminAuth) => { + try { + const url = new URL(request.url); + const id = url.pathname.split('/').pop(); + + if (!id) { + return Errors.badRequest('출석 ID가 필요합니다.').toResponse(); + } + + const database = db(); + const [record] = await database + .select({ + id: attendance.id, + memberId: attendance.memberId, + roundId: attendance.roundId, + status: attendance.status, + submittedAt: attendance.submittedAt, + createdAt: attendance.createdAt, + updatedAt: attendance.updatedAt, + memberName: members.name, + memberDiscordUsername: members.discordUsername, + roundNumber: rounds.roundNumber, + }) + .from(attendance) + .leftJoin(members, eq(attendance.memberId, members.id)) + .leftJoin(rounds, eq(attendance.roundId, rounds.id)) + .where(eq(attendance.id, id)) + .limit(1); + + if (!record) { + return Errors.notFound('출석 기록을 찾을 수 없습니다.').toResponse(); + } + + return successResponse({ + attendance: { + id: record.id, + memberId: record.memberId, + roundId: record.roundId, + status: record.status, + submittedAt: record.submittedAt?.toISOString(), + createdAt: record.createdAt?.toISOString(), + updatedAt: record.updatedAt?.toISOString(), + memberName: record.memberName, + memberDiscordUsername: record.memberDiscordUsername, + roundNumber: record.roundNumber, + }, + }); + } catch (error) { + console.error('Admin get attendance error:', error); + return errorResponse(error); + } +}); + +/** + * PATCH /api/admin/attendance/[id] + * Update attendance status (admin only) + * Requirement: 15.6 - /출석수정 [유저] [회차] [상태] + */ +export const PATCH = withAdminAuth(async (request: NextRequest, _adminAuth) => { + try { + const url = new URL(request.url); + const id = url.pathname.split('/').pop(); + + if (!id) { + return Errors.badRequest('출석 ID가 필요합니다.').toResponse(); + } + + const body = await request.json(); + const { status } = body; + + // Validate status + const validStatuses = [ + AttendanceStatus.PENDING, + AttendanceStatus.SUBMITTED, + AttendanceStatus.LATE, + AttendanceStatus.ABSENT, + ]; + + if (!status || !validStatuses.includes(status)) { + return Errors.badRequest( + `유효하지 않은 상태입니다. (${validStatuses.join(', ')} 중 하나여야 합니다.)` + ).toResponse(); + } + + const database = db(); + + // Check if attendance record exists + const [existingRecord] = await database + .select() + .from(attendance) + .where(eq(attendance.id, id)) + .limit(1); + + if (!existingRecord) { + return Errors.notFound('출석 기록을 찾을 수 없습니다.').toResponse(); + } + + const previousStatus = existingRecord.status; + const now = new Date(); + + // Update attendance status + const [updatedRecord] = await database + .update(attendance) + .set({ + status, + submittedAt: status === AttendanceStatus.SUBMITTED || status === AttendanceStatus.LATE + ? existingRecord.submittedAt || now + : null, + updatedAt: now, + }) + .where(eq(attendance.id, id)) + .returning(); + + // Handle fine creation/removal based on status change + // If changing TO late/absent, create fine if not exists + // If changing FROM late/absent to submitted/pending, remove fine + + if (status === AttendanceStatus.LATE || status === AttendanceStatus.ABSENT) { + // Check if fine already exists for this member and round + const [existingFine] = await database + .select() + .from(fines) + .where( + and( + eq(fines.memberId, existingRecord.memberId), + eq(fines.roundId, existingRecord.roundId) + ) + ) + .limit(1); + + if (!existingFine) { + // Create fine + const fineAmount = status === AttendanceStatus.LATE ? 3000 : 5000; + const fineType = status === AttendanceStatus.LATE ? FineType.LATE : FineType.ABSENT; + + await database.insert(fines).values({ + memberId: existingRecord.memberId, + roundId: existingRecord.roundId, + type: fineType, + amount: fineAmount, + status: FineStatus.UNPAID, + }); + } else if (existingFine.status === FineStatus.UNPAID) { + // Update existing fine type and amount if unpaid + const fineAmount = status === AttendanceStatus.LATE ? 3000 : 5000; + const fineType = status === AttendanceStatus.LATE ? FineType.LATE : FineType.ABSENT; + + await database + .update(fines) + .set({ + type: fineType, + amount: fineAmount, + }) + .where(eq(fines.id, existingFine.id)); + } + } else if ( + (previousStatus === AttendanceStatus.LATE || previousStatus === AttendanceStatus.ABSENT) && + (status === AttendanceStatus.SUBMITTED || status === AttendanceStatus.PENDING) + ) { + // Remove unpaid fine if status changed from late/absent to submitted/pending + const [existingFine] = await database + .select() + .from(fines) + .where( + and( + eq(fines.memberId, existingRecord.memberId), + eq(fines.roundId, existingRecord.roundId), + eq(fines.status, FineStatus.UNPAID) + ) + ) + .limit(1); + + if (existingFine) { + // Mark as waived instead of deleting to preserve history + await database + .update(fines) + .set({ status: FineStatus.WAIVED }) + .where(eq(fines.id, existingFine.id)); + } + } + + if (!updatedRecord) { + return Errors.internalError('출석 상태 업데이트에 실패했습니다.').toResponse(); + } + + return successResponse( + { + attendance: { + id: updatedRecord.id, + status: updatedRecord.status, + submittedAt: updatedRecord.submittedAt?.toISOString(), + updatedAt: updatedRecord.updatedAt?.toISOString(), + }, + }, + '출석 상태가 수정되었습니다.' + ); + } catch (error) { + console.error('Admin update attendance error:', error); + return errorResponse(error); + } +}); diff --git a/packages/web/src/app/api/admin/attendance/route.ts b/packages/web/src/app/api/admin/attendance/route.ts new file mode 100644 index 0000000..ae9f4d1 --- /dev/null +++ b/packages/web/src/app/api/admin/attendance/route.ts @@ -0,0 +1,130 @@ +import { NextResponse } from 'next/server'; +import { asc } from 'drizzle-orm'; +import { db } from '@/lib/db'; +import { db as sharedDb } from '@blog-study/shared'; +import { withAdminAuth } from '@/lib/admin'; + +const { members, rounds, attendance, AttendanceStatus } = sharedDb; + +/** + * GET /api/admin/attendance + * Get attendance grid data (members × rounds) + * Requirement: 16.6 + */ +export const GET = withAdminAuth(async (_request, _adminAuth) => { + try { + const database = db(); + + // Get all rounds ordered by round number + const allRounds = await database + .select() + .from(rounds) + .orderBy(asc(rounds.roundNumber)); + + // Get all members (excluding withdrawn for cleaner view, but include all for completeness) + const allMembers = await database + .select({ + id: members.id, + name: members.name, + discordUsername: members.discordUsername, + part: members.part, + status: members.status, + }) + .from(members) + .orderBy(asc(members.name)); + + // Get all attendance records + const allAttendance = await database + .select({ + id: attendance.id, + memberId: attendance.memberId, + roundId: attendance.roundId, + status: attendance.status, + submittedAt: attendance.submittedAt, + updatedAt: attendance.updatedAt, + }) + .from(attendance); + + // Create a map for quick lookup: memberId-roundId -> attendance + const attendanceMap = new Map(); + for (const att of allAttendance) { + const key = `${att.memberId}-${att.roundId}`; + attendanceMap.set(key, att); + } + + // Build the grid data + const grid = allMembers.map((member) => { + const memberAttendance: Record = {}; + + for (const round of allRounds) { + const key = `${member.id}-${round.id}`; + const att = attendanceMap.get(key); + memberAttendance[round.id] = { + id: att?.id || null, + status: att?.status || 'none', + submittedAt: att?.submittedAt?.toISOString() || null, + }; + } + + return { + member: { + id: member.id, + name: member.name, + discordUsername: member.discordUsername, + part: member.part, + status: member.status, + }, + attendance: memberAttendance, + }; + }); + + // Calculate statistics per round + const roundStats = allRounds.map((round) => { + const roundAttendance = allAttendance.filter((a) => a.roundId === round.id); + const total = roundAttendance.length; + const submitted = roundAttendance.filter((a) => a.status === AttendanceStatus.SUBMITTED).length; + const late = roundAttendance.filter((a) => a.status === AttendanceStatus.LATE).length; + const absent = roundAttendance.filter((a) => a.status === AttendanceStatus.ABSENT).length; + const pending = roundAttendance.filter((a) => a.status === AttendanceStatus.PENDING).length; + + return { + roundId: round.id, + roundNumber: round.roundNumber, + startDate: round.startDate, + endDate: round.endDate, + isCurrent: round.isCurrent, + stats: { + total, + submitted, + late, + absent, + pending, + submissionRate: total > 0 ? Math.round((submitted / total) * 100) : 0, + }, + }; + }); + + return NextResponse.json({ + rounds: allRounds.map((r) => ({ + id: r.id, + roundNumber: r.roundNumber, + startDate: r.startDate, + endDate: r.endDate, + graceEndDate: r.graceEndDate, + isCurrent: r.isCurrent, + })), + grid, + roundStats, + }); + } catch (error) { + console.error('Admin attendance API error:', error); + return NextResponse.json( + { message: '서버 오류가 발생했습니다.' }, + { status: 500 } + ); + } +}); diff --git a/packages/web/src/app/api/admin/check/route.ts b/packages/web/src/app/api/admin/check/route.ts new file mode 100644 index 0000000..87a42e2 --- /dev/null +++ b/packages/web/src/app/api/admin/check/route.ts @@ -0,0 +1,44 @@ +import { NextResponse } from 'next/server'; +import { verifyAdminAccess } from '@/lib/admin'; + +/** + * GET /api/admin/check + * Check if the current user has admin privileges + * Requirements: 16.2, 16.3 + */ +export async function GET() { + try { + const adminAuth = await verifyAdminAccess(); + + // Return 401 if not authenticated + if (!adminAuth.isAuthenticated) { + return NextResponse.json( + { + isAdmin: false, + message: adminAuth.error || '인증이 필요합니다.', + }, + { status: 401 } + ); + } + + // Return admin status (Requirement: 16.2) + // Note: We return 200 even for non-admins, just with isAdmin: false + // The 403 is returned when trying to access admin-only resources + return NextResponse.json({ + isAdmin: adminAuth.isAdmin, + userId: adminAuth.userId, + memberId: adminAuth.memberId, + discordId: adminAuth.discordId, + message: adminAuth.isAdmin ? '관리자 권한이 확인되었습니다.' : '관리자 권한이 없습니다.', + }); + } catch (error) { + console.error('Admin check error:', error); + return NextResponse.json( + { + isAdmin: false, + message: '서버 오류가 발생했습니다.', + }, + { status: 500 } + ); + } +} diff --git a/packages/web/src/app/api/admin/curation/[id]/route.ts b/packages/web/src/app/api/admin/curation/[id]/route.ts new file mode 100644 index 0000000..6d6ee28 --- /dev/null +++ b/packages/web/src/app/api/admin/curation/[id]/route.ts @@ -0,0 +1,151 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { eq } from 'drizzle-orm'; +import { getDb } from '@/lib/db'; +import { curationSources, curationItems } from '@blog-study/shared/db'; +import { verifyAdminAccess } from '@/lib/admin'; + +interface RouteParams { + params: Promise<{ id: string }>; +} + +/** + * GET /api/admin/curation/[id] + * Get a single curation source + */ +export async function GET(_request: NextRequest, { params }: RouteParams) { + try { + // Check admin access + const adminCheck = await verifyAdminAccess(); + if (!adminCheck.isAdmin) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const { id } = await params; + const db = getDb(); + + const [source] = await db + .select() + .from(curationSources) + .where(eq(curationSources.id, id)) + .limit(1); + + if (!source) { + return NextResponse.json({ error: 'Source not found' }, { status: 404 }); + } + + return NextResponse.json(source); + } catch (error) { + console.error('Error fetching curation source:', error); + return NextResponse.json( + { error: 'Failed to fetch curation source' }, + { status: 500 } + ); + } +} + +/** + * PATCH /api/admin/curation/[id] + * Update a curation source (toggle active status, update name, etc.) + */ +export async function PATCH(request: NextRequest, { params }: RouteParams) { + try { + // Check admin access + const adminCheck = await verifyAdminAccess(); + if (!adminCheck.isAdmin) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const { id } = await params; + const body = await request.json(); + const db = getDb(); + + // Check if source exists + const [existing] = await db + .select() + .from(curationSources) + .where(eq(curationSources.id, id)) + .limit(1); + + if (!existing) { + return NextResponse.json({ error: 'Source not found' }, { status: 404 }); + } + + // Build update object + const updateData: Partial = {}; + + if (body.name !== undefined) { + updateData.name = body.name; + } + if (body.url !== undefined) { + updateData.url = body.url; + } + if (body.category !== undefined) { + updateData.category = body.category; + } + if (body.isActive !== undefined) { + updateData.isActive = body.isActive; + } + + // Update source + const [updated] = await db + .update(curationSources) + .set(updateData) + .where(eq(curationSources.id, id)) + .returning(); + + return NextResponse.json(updated); + } catch (error) { + console.error('Error updating curation source:', error); + return NextResponse.json( + { error: 'Failed to update curation source' }, + { status: 500 } + ); + } +} + +/** + * DELETE /api/admin/curation/[id] + * Delete a curation source and all its items + * Requirements: 15.5 + */ +export async function DELETE(_request: NextRequest, { params }: RouteParams) { + try { + // Check admin access + const adminCheck = await verifyAdminAccess(); + if (!adminCheck.isAdmin) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const { id } = await params; + const db = getDb(); + + // Check if source exists + const [existing] = await db + .select() + .from(curationSources) + .where(eq(curationSources.id, id)) + .limit(1); + + if (!existing) { + return NextResponse.json({ error: 'Source not found' }, { status: 404 }); + } + + // Delete all items from this source first + await db + .delete(curationItems) + .where(eq(curationItems.sourceId, id)); + + // Delete the source + await db + .delete(curationSources) + .where(eq(curationSources.id, id)); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('Error deleting curation source:', error); + return NextResponse.json( + { error: 'Failed to delete curation source' }, + { status: 500 } + ); + } +} diff --git a/packages/web/src/app/api/admin/curation/route.ts b/packages/web/src/app/api/admin/curation/route.ts new file mode 100644 index 0000000..77f950f --- /dev/null +++ b/packages/web/src/app/api/admin/curation/route.ts @@ -0,0 +1,145 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { eq, desc, sql } from 'drizzle-orm'; +import { getDb } from '@/lib/db'; +import { curationSources, curationItems, CurationCategory } from '@blog-study/shared/db'; +import { verifyAdminAccess } from '@/lib/admin'; + +/** + * GET /api/admin/curation + * Get all curation sources with item counts + * Requirements: 15.5 + */ +export async function GET() { + try { + // Check admin access + const adminCheck = await verifyAdminAccess(); + if (!adminCheck.isAdmin) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const db = getDb(); + + // Get all sources + const sources = await db + .select() + .from(curationSources) + .orderBy(desc(curationSources.createdAt)); + + // Get item counts per source + const itemCounts = await db + .select({ + sourceId: curationItems.sourceId, + count: sql`count(*)::int`, + }) + .from(curationItems) + .groupBy(curationItems.sourceId); + + // Create a map of source ID to item count + const countMap = new Map(); + for (const item of itemCounts) { + if (item.sourceId) { + countMap.set(item.sourceId, item.count); + } + } + + // Combine sources with item counts + const sourcesWithCounts = sources.map((source) => ({ + ...source, + itemCount: countMap.get(source.id) || 0, + })); + + // Get summary stats + const totalItems = await db.select({ count: sql`count(*)::int` }).from(curationItems); + const sharedItems = await db + .select({ count: sql`count(*)::int` }) + .from(curationItems) + .where(eq(curationItems.isShared, true)); + + return NextResponse.json({ + sources: sourcesWithCounts, + stats: { + totalSources: sources.length, + activeSources: sources.filter((s) => s.isActive).length, + totalItems: totalItems[0]?.count || 0, + sharedItems: sharedItems[0]?.count || 0, + }, + categories: Object.values(CurationCategory), + }); + } catch (error) { + console.error('Error fetching curation sources:', error); + return NextResponse.json( + { error: 'Failed to fetch curation sources' }, + { status: 500 } + ); + } +} + +/** + * POST /api/admin/curation + * Add a new curation source + * Requirements: 15.5 + */ +export async function POST(request: NextRequest) { + try { + // Check admin access + const adminCheck = await verifyAdminAccess(); + if (!adminCheck.isAdmin) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const body = await request.json(); + const { url, name, category } = body; + + // Validate required fields + if (!url || !name || !category) { + return NextResponse.json( + { error: 'URL, 이름, 카테고리는 필수입니다.' }, + { status: 400 } + ); + } + + // Validate category + const validCategories = Object.values(CurationCategory); + if (!validCategories.includes(category)) { + return NextResponse.json( + { error: `유효하지 않은 카테고리입니다. (${validCategories.join(', ')})` }, + { status: 400 } + ); + } + + const db = getDb(); + + // Check for duplicate URL + const [existing] = await db + .select() + .from(curationSources) + .where(eq(curationSources.url, url)) + .limit(1); + + if (existing) { + return NextResponse.json( + { error: '이미 등록된 URL입니다.' }, + { status: 400 } + ); + } + + // Create new source + const [created] = await db + .insert(curationSources) + .values({ + url, + name, + category, + isActive: true, + }) + .returning(); + + return NextResponse.json(created, { status: 201 }); + } catch (error) { + console.error('Error creating curation source:', error); + return NextResponse.json( + { error: 'Failed to create curation source' }, + { status: 500 } + ); + } +} diff --git a/packages/web/src/app/api/admin/dashboard/route.ts b/packages/web/src/app/api/admin/dashboard/route.ts new file mode 100644 index 0000000..d4ea27c --- /dev/null +++ b/packages/web/src/app/api/admin/dashboard/route.ts @@ -0,0 +1,173 @@ +import { NextResponse } from 'next/server'; +import { eq, desc, count, sql } from 'drizzle-orm'; +import { db } from '@/lib/db'; +import { db as sharedDb } from '@blog-study/shared'; +import { withAdminAuth } from '@/lib/admin'; + +const { members, posts, rounds, attendance, fines, MemberStatus, AttendanceStatus, FineStatus } = sharedDb; + +/** + * GET /api/admin/dashboard + * Get admin dashboard data including current round summary, submission rate, and recent activity + * Requirement: 16.4 + */ +export const GET = withAdminAuth(async (_request, _adminAuth) => { + try { + const database = db(); + + // Get current round + const [currentRoundData] = await database + .select() + .from(rounds) + .where(eq(rounds.isCurrent, true)) + .limit(1); + + let currentRound = null; + let submissionStats = null; + + if (currentRoundData) { + const now = new Date(); + const endDate = new Date(currentRoundData.endDate); + const graceEndDate = new Date(currentRoundData.graceEndDate); + + // Calculate days remaining + const timeDiff = endDate.getTime() - now.getTime(); + const daysRemaining = Math.ceil(timeDiff / (1000 * 60 * 60 * 24)); + + // Check if in grace period + const isGracePeriod = now > endDate && now <= graceEndDate; + + currentRound = { + id: currentRoundData.id, + roundNumber: currentRoundData.roundNumber, + startDate: currentRoundData.startDate, + endDate: currentRoundData.endDate, + graceEndDate: currentRoundData.graceEndDate, + daysRemaining: Math.max(0, daysRemaining), + isGracePeriod, + }; + + // Get submission stats for current round + const attendanceStats = await database + .select({ + status: attendance.status, + count: count(), + }) + .from(attendance) + .where(eq(attendance.roundId, currentRoundData.id)) + .groupBy(attendance.status); + + const statsMap = new Map(attendanceStats.map((s) => [s.status, s.count])); + const total = attendanceStats.reduce((sum, s) => sum + s.count, 0); + const submitted = statsMap.get(AttendanceStatus.SUBMITTED) || 0; + const late = statsMap.get(AttendanceStatus.LATE) || 0; + const absent = statsMap.get(AttendanceStatus.ABSENT) || 0; + const pending = statsMap.get(AttendanceStatus.PENDING) || 0; + + submissionStats = { + total, + submitted, + late, + absent, + pending, + submissionRate: total > 0 ? Math.round((submitted / total) * 100) : 0, + }; + } + + // Get member counts by status + const memberCounts = await database + .select({ + status: members.status, + count: count(), + }) + .from(members) + .groupBy(members.status); + + const memberCountMap = new Map(memberCounts.map((m) => [m.status, m.count])); + + // Get total posts count + const [totalPostsResult] = await database + .select({ count: count() }) + .from(posts); + + // Get unpaid fines summary + const [unpaidFinesResult] = await database + .select({ + count: count(), + total: sql`COALESCE(SUM(${fines.amount}), 0)`, + }) + .from(fines) + .where(eq(fines.status, FineStatus.UNPAID)); + + // Get recent activity (recent posts) + const recentPosts = await database + .select({ + id: posts.id, + title: posts.title, + url: posts.url, + publishedAt: posts.publishedAt, + memberName: members.name, + memberDiscordUsername: members.discordUsername, + }) + .from(posts) + .leftJoin(members, eq(posts.memberId, members.id)) + .orderBy(desc(posts.collectedAt)) + .limit(5); + + // Get recent attendance changes + const recentAttendance = await database + .select({ + id: attendance.id, + status: attendance.status, + updatedAt: attendance.updatedAt, + memberName: members.name, + memberDiscordUsername: members.discordUsername, + roundNumber: rounds.roundNumber, + }) + .from(attendance) + .leftJoin(members, eq(attendance.memberId, members.id)) + .leftJoin(rounds, eq(attendance.roundId, rounds.id)) + .orderBy(desc(attendance.updatedAt)) + .limit(5); + + return NextResponse.json({ + currentRound, + submissionStats, + memberCounts: { + active: memberCountMap.get(MemberStatus.ACTIVE) || 0, + dormant: memberCountMap.get(MemberStatus.DORMANT) || 0, + withdrawn: memberCountMap.get(MemberStatus.WITHDRAWN) || 0, + total: memberCounts.reduce((sum, m) => sum + m.count, 0), + }, + totalPosts: totalPostsResult?.count ?? 0, + unpaidFines: { + count: unpaidFinesResult?.count ?? 0, + total: unpaidFinesResult?.total ?? 0, + }, + recentActivity: { + posts: recentPosts.map((post) => ({ + id: post.id, + title: post.title, + url: post.url, + publishedAt: post.publishedAt?.toISOString(), + memberName: post.memberName, + memberDiscordUsername: post.memberDiscordUsername, + })), + attendance: recentAttendance.map((att) => ({ + id: att.id, + status: att.status, + updatedAt: att.updatedAt?.toISOString(), + memberName: att.memberName, + memberDiscordUsername: att.memberDiscordUsername, + roundNumber: att.roundNumber, + })), + }, + }); + } catch (error) { + console.error('Admin dashboard API error:', error); + return NextResponse.json( + { message: '서버 오류가 발생했습니다.' }, + { status: 500 } + ); + } +}); diff --git a/packages/web/src/app/api/admin/fines/[id]/route.ts b/packages/web/src/app/api/admin/fines/[id]/route.ts new file mode 100644 index 0000000..92dfc11 --- /dev/null +++ b/packages/web/src/app/api/admin/fines/[id]/route.ts @@ -0,0 +1,89 @@ +import { NextRequest, NextResponse } 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'; + +const { fines, FineStatus } = sharedDb; + +/** + * PATCH /api/admin/fines/[id] + * Update fine status (mark as paid or waived) + * Requirements: 16.8, 16.9 + */ +export const PATCH = withAdminAuth(async (request: NextRequest, _adminAuth) => { + try { + const url = new URL(request.url); + const id = url.pathname.split('/').pop(); + + if (!id) { + return NextResponse.json( + { message: '벌금 ID가 필요합니다.' }, + { status: 400 } + ); + } + + const body = await request.json(); + const { status } = body; + + // Validate status + if (!status || ![FineStatus.PAID, FineStatus.WAIVED].includes(status)) { + return NextResponse.json( + { message: '유효하지 않은 상태입니다. (paid 또는 waived만 가능)' }, + { status: 400 } + ); + } + + const database = db(); + + // Check if fine exists + const [existingFine] = await database + .select() + .from(fines) + .where(eq(fines.id, id)) + .limit(1); + + if (!existingFine) { + return NextResponse.json( + { message: '벌금을 찾을 수 없습니다.' }, + { status: 404 } + ); + } + + // Update fine status + const updateData: { status: string; paidAt?: Date } = { status }; + + // Set paidAt timestamp if marking as paid + if (status === FineStatus.PAID) { + updateData.paidAt = new Date(); + } + + const [updatedFine] = await database + .update(fines) + .set(updateData) + .where(eq(fines.id, id)) + .returning(); + + if (!updatedFine) { + return NextResponse.json( + { message: '벌금 업데이트에 실패했습니다.' }, + { status: 500 } + ); + } + + return NextResponse.json({ + message: status === FineStatus.PAID ? '납부 처리되었습니다.' : '면제 처리되었습니다.', + fine: { + id: updatedFine.id, + status: updatedFine.status, + paidAt: updatedFine.paidAt?.toISOString(), + }, + }); + } catch (error) { + console.error('Admin fine update API error:', error); + return NextResponse.json( + { message: '서버 오류가 발생했습니다.' }, + { status: 500 } + ); + } +}); diff --git a/packages/web/src/app/api/admin/fines/route.ts b/packages/web/src/app/api/admin/fines/route.ts new file mode 100644 index 0000000..799095b --- /dev/null +++ b/packages/web/src/app/api/admin/fines/route.ts @@ -0,0 +1,123 @@ +import { NextResponse } from 'next/server'; +import { eq, desc } from 'drizzle-orm'; +import { db } from '@/lib/db'; +import { db as sharedDb } from '@blog-study/shared'; +import { withAdminAuth } from '@/lib/admin'; + +const { members, rounds, fines, FineStatus } = sharedDb; + +/** + * GET /api/admin/fines + * Get all fines with member and round information + * Requirement: 16.7 + */ +export const GET = withAdminAuth(async (_request, _adminAuth) => { + try { + const database = db(); + + // Get all fines with member and round info + const allFines = await database + .select({ + id: fines.id, + memberId: fines.memberId, + roundId: fines.roundId, + type: fines.type, + amount: fines.amount, + status: fines.status, + createdAt: fines.createdAt, + paidAt: fines.paidAt, + memberName: members.name, + memberDiscordUsername: members.discordUsername, + memberPart: members.part, + roundNumber: rounds.roundNumber, + }) + .from(fines) + .leftJoin(members, eq(fines.memberId, members.id)) + .leftJoin(rounds, eq(fines.roundId, rounds.id)) + .orderBy(desc(fines.createdAt)); + + // Calculate summary statistics + const unpaidFines = allFines.filter((f) => f.status === FineStatus.UNPAID); + const paidFines = allFines.filter((f) => f.status === FineStatus.PAID); + const waivedFines = allFines.filter((f) => f.status === FineStatus.WAIVED); + + const summary = { + total: { + count: allFines.length, + amount: allFines.reduce((sum, f) => sum + f.amount, 0), + }, + unpaid: { + count: unpaidFines.length, + amount: unpaidFines.reduce((sum, f) => sum + f.amount, 0), + }, + paid: { + count: paidFines.length, + amount: paidFines.reduce((sum, f) => sum + f.amount, 0), + }, + waived: { + count: waivedFines.length, + amount: waivedFines.reduce((sum, f) => sum + f.amount, 0), + }, + }; + + // Group by member for summary view + const byMember = new Map(); + + for (const fine of allFines) { + const existing = byMember.get(fine.memberId); + if (existing) { + existing.totalCount++; + existing.totalAmount += fine.amount; + if (fine.status === FineStatus.UNPAID) { + existing.unpaidCount++; + existing.unpaidAmount += fine.amount; + } + } else { + byMember.set(fine.memberId, { + memberId: fine.memberId, + memberName: fine.memberName || '', + memberDiscordUsername: fine.memberDiscordUsername || '', + memberPart: fine.memberPart || '', + unpaidCount: fine.status === FineStatus.UNPAID ? 1 : 0, + unpaidAmount: fine.status === FineStatus.UNPAID ? fine.amount : 0, + totalCount: 1, + totalAmount: fine.amount, + }); + } + } + + return NextResponse.json({ + fines: allFines.map((f) => ({ + id: f.id, + memberId: f.memberId, + roundId: f.roundId, + type: f.type, + amount: f.amount, + status: f.status, + createdAt: f.createdAt?.toISOString(), + paidAt: f.paidAt?.toISOString(), + memberName: f.memberName, + memberDiscordUsername: f.memberDiscordUsername, + memberPart: f.memberPart, + roundNumber: f.roundNumber, + })), + summary, + byMember: Array.from(byMember.values()).sort((a, b) => b.unpaidAmount - a.unpaidAmount), + }); + } catch (error) { + console.error('Admin fines API error:', error); + return NextResponse.json( + { message: '서버 오류가 발생했습니다.' }, + { status: 500 } + ); + } +}); diff --git a/packages/web/src/app/api/admin/members/[id]/route.ts b/packages/web/src/app/api/admin/members/[id]/route.ts new file mode 100644 index 0000000..de0a0ae --- /dev/null +++ b/packages/web/src/app/api/admin/members/[id]/route.ts @@ -0,0 +1,188 @@ +import { NextRequest, NextResponse } 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 { utils } from '@blog-study/shared'; + +const { isValidBlogUrl } = utils; + +const { members, MemberStatus } = sharedDb; + +/** + * GET /api/admin/members/[id] + * Get a single member by ID + */ +export const GET = withAdminAuth(async (request: NextRequest, _adminAuth) => { + try { + const id = request.url.split('/').pop(); + if (!id) { + return NextResponse.json({ message: '멤버 ID가 필요합니다.' }, { status: 400 }); + } + + const database = db(); + const [member] = await database + .select() + .from(members) + .where(eq(members.id, id)) + .limit(1); + + if (!member) { + return NextResponse.json({ message: '멤버를 찾을 수 없습니다.' }, { status: 404 }); + } + + return NextResponse.json({ message: 'success', member }); + } catch (error) { + console.error('Admin get member error:', error); + return NextResponse.json({ message: '서버 오류가 발생했습니다.' }, { status: 500 }); + } +}); + +/** + * PUT /api/admin/members/[id] + * Update a member (admin only) + * Requirement: 19.4 + */ +export const PUT = withAdminAuth(async (request: NextRequest, _adminAuth) => { + try { + const id = request.url.split('/').pop(); + if (!id) { + return NextResponse.json({ message: '멤버 ID가 필요합니다.' }, { status: 400 }); + } + + const body = await request.json(); + const { name, part, discordId, discordUsername, blogUrl, rssUrl, status } = body; + + const database = db(); + + // Check if member exists + const [existingMember] = await database + .select() + .from(members) + .where(eq(members.id, id)) + .limit(1); + + if (!existingMember) { + return NextResponse.json({ message: '멤버를 찾을 수 없습니다.' }, { status: 404 }); + } + + // Validate required fields + const errors: string[] = []; + if (name !== undefined && (typeof name !== 'string' || name.trim().length === 0)) { + errors.push('이름은 필수입니다.'); + } + if (part !== undefined && (typeof part !== 'string' || part.trim().length === 0)) { + errors.push('파트는 필수입니다.'); + } + if (discordId !== undefined && (typeof discordId !== 'string' || discordId.trim().length === 0)) { + errors.push('Discord ID는 필수입니다.'); + } + if (blogUrl !== undefined && (typeof blogUrl !== 'string' || blogUrl.trim().length === 0)) { + errors.push('블로그 URL은 필수입니다.'); + } + + // Validate blog URL format + if (blogUrl && !isValidBlogUrl(blogUrl)) { + errors.push('유효하지 않은 블로그 URL 형식입니다.'); + } + + // Validate status + const validStatuses = [MemberStatus.ACTIVE, MemberStatus.DORMANT, MemberStatus.WITHDRAWN]; + if (status !== undefined && !validStatuses.includes(status)) { + errors.push('유효하지 않은 상태입니다.'); + } + + if (errors.length > 0) { + return NextResponse.json({ message: errors.join(' '), errors }, { status: 400 }); + } + + // Check for duplicate Discord ID if changed + if (discordId && discordId !== existingMember.discordId) { + const [duplicateMember] = await database + .select() + .from(members) + .where(eq(members.discordId, discordId)) + .limit(1); + + if (duplicateMember) { + return NextResponse.json( + { message: '이미 등록된 Discord ID입니다.' }, + { status: 409 } + ); + } + } + + // Build update object + const updateData: Partial = { + updatedAt: new Date(), + }; + + if (name !== undefined) updateData.name = name.trim(); + if (part !== undefined) updateData.part = part.trim(); + if (discordId !== undefined) updateData.discordId = discordId.trim(); + if (discordUsername !== undefined) updateData.discordUsername = discordUsername.trim(); + if (blogUrl !== undefined) updateData.blogUrl = blogUrl.trim(); + if (rssUrl !== undefined) updateData.rssUrl = rssUrl?.trim() || null; + if (status !== undefined) updateData.status = status; + + // Update member + const [updatedMember] = await database + .update(members) + .set(updateData) + .where(eq(members.id, id)) + .returning(); + + return NextResponse.json({ + message: '멤버 정보가 수정되었습니다.', + member: updatedMember, + }); + } catch (error) { + console.error('Admin update member error:', error); + return NextResponse.json({ message: '서버 오류가 발생했습니다.' }, { status: 500 }); + } +}); + +/** + * DELETE /api/admin/members/[id] + * Soft delete a member (mark as withdrawn) + * Requirements: 19.5, 19.6 + */ +export const DELETE = withAdminAuth(async (request: NextRequest, _adminAuth) => { + try { + const id = request.url.split('/').pop(); + if (!id) { + return NextResponse.json({ message: '멤버 ID가 필요합니다.' }, { status: 400 }); + } + + const database = db(); + + // Check if member exists + const [existingMember] = await database + .select() + .from(members) + .where(eq(members.id, id)) + .limit(1); + + if (!existingMember) { + return NextResponse.json({ message: '멤버를 찾을 수 없습니다.' }, { status: 404 }); + } + + // Soft delete: mark as withdrawn (Requirement: 19.6) + const [deletedMember] = await database + .update(members) + .set({ + status: MemberStatus.WITHDRAWN, + updatedAt: new Date(), + }) + .where(eq(members.id, id)) + .returning(); + + return NextResponse.json({ + message: '멤버가 탈퇴 처리되었습니다.', + member: deletedMember, + }); + } catch (error) { + console.error('Admin delete member error:', error); + return NextResponse.json({ message: '서버 오류가 발생했습니다.' }, { status: 500 }); + } +}); diff --git a/packages/web/src/app/api/admin/members/route.ts b/packages/web/src/app/api/admin/members/route.ts new file mode 100644 index 0000000..28052b9 --- /dev/null +++ b/packages/web/src/app/api/admin/members/route.ts @@ -0,0 +1,196 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { eq, count, sql, desc } from 'drizzle-orm'; +import { db } from '@/lib/db'; +import { db as sharedDb } from '@blog-study/shared'; +import { withAdminAuth } from '@/lib/admin'; +import { utils } from '@blog-study/shared'; + +const { isValidBlogUrl } = utils; + +const { members, posts, attendance, AttendanceStatus, MemberStatus } = sharedDb; + +/** + * GET /api/admin/members + * Get all members with stats for admin management + * Requirements: 16.5, 19.7 + */ +export const GET = withAdminAuth(async (request: NextRequest, _adminAuth) => { + try { + const { searchParams } = new URL(request.url); + const status = searchParams.get('status'); + + const database = db(); + + // Build query + let query = database.select().from(members); + + if (status && status !== 'all') { + query = query.where(eq(members.status, status)) as typeof query; + } + + const membersList = await query.orderBy(desc(members.joinedAt)); + + // Get post counts for all members + const postCounts = await database + .select({ + memberId: posts.memberId, + count: count(), + }) + .from(posts) + .groupBy(posts.memberId); + + // Get attendance stats for all members + const attendanceStats = await database + .select({ + memberId: attendance.memberId, + total: count(), + submitted: sql`COUNT(CASE WHEN ${attendance.status} = ${AttendanceStatus.SUBMITTED} THEN 1 END)`, + late: sql`COUNT(CASE WHEN ${attendance.status} = ${AttendanceStatus.LATE} THEN 1 END)`, + absent: sql`COUNT(CASE WHEN ${attendance.status} = ${AttendanceStatus.ABSENT} THEN 1 END)`, + }) + .from(attendance) + .groupBy(attendance.memberId); + + // Create maps for quick lookup + const postCountMap = new Map(postCounts.map((p) => [p.memberId, p.count])); + const attendanceMap = new Map( + attendanceStats.map((a) => [ + a.memberId, + { total: a.total, submitted: a.submitted, late: a.late, absent: a.absent }, + ]) + ); + + // Group members by status + const groupedMembers = { + active: [] as typeof result, + dormant: [] as typeof result, + withdrawn: [] as typeof result, + }; + + const result = membersList.map((member) => { + const postCount = postCountMap.get(member.id) || 0; + const attStats = attendanceMap.get(member.id) || { total: 0, submitted: 0, late: 0, absent: 0 }; + const attendanceRate = + attStats.total > 0 ? Math.round((attStats.submitted / attStats.total) * 100) : 0; + + return { + id: member.id, + discordId: member.discordId, + discordUsername: member.discordUsername, + name: member.name, + part: member.part, + blogUrl: member.blogUrl, + rssUrl: member.rssUrl, + profileImageUrl: member.profileImageUrl, + bio: member.bio, + status: member.status, + dormantUsed: member.dormantUsed, + dormantStartRound: member.dormantStartRound, + postCount, + attendanceRate, + attendanceStats: attStats, + joinedAt: member.joinedAt?.toISOString(), + updatedAt: member.updatedAt?.toISOString(), + }; + }); + + // Group by status + result.forEach((member) => { + if (member.status === MemberStatus.ACTIVE) { + groupedMembers.active.push(member); + } else if (member.status === MemberStatus.DORMANT) { + groupedMembers.dormant.push(member); + } else if (member.status === MemberStatus.WITHDRAWN) { + groupedMembers.withdrawn.push(member); + } + }); + + return NextResponse.json({ + members: result, + grouped: groupedMembers, + counts: { + active: groupedMembers.active.length, + dormant: groupedMembers.dormant.length, + withdrawn: groupedMembers.withdrawn.length, + total: result.length, + }, + }); + } catch (error) { + console.error('Admin members API error:', error); + return NextResponse.json({ message: '서버 오류가 발생했습니다.' }, { status: 500 }); + } +}); + +/** + * POST /api/admin/members + * Create a new member (admin only) + * Requirements: 19.1, 19.2, 19.3 + */ +export const POST = withAdminAuth(async (request: NextRequest, _adminAuth) => { + try { + const body = await request.json(); + const { name, part, discordId, discordUsername, blogUrl, rssUrl } = body; + + // Validate required fields (Requirement: 19.2) + const errors: string[] = []; + if (!name || typeof name !== 'string' || name.trim().length === 0) { + errors.push('이름은 필수입니다.'); + } + if (!part || typeof part !== 'string' || part.trim().length === 0) { + errors.push('파트는 필수입니다.'); + } + if (!discordId || typeof discordId !== 'string' || discordId.trim().length === 0) { + errors.push('Discord ID는 필수입니다.'); + } + if (!blogUrl || typeof blogUrl !== 'string' || blogUrl.trim().length === 0) { + errors.push('블로그 URL은 필수입니다.'); + } + + // Validate blog URL format + if (blogUrl && !isValidBlogUrl(blogUrl)) { + errors.push('유효하지 않은 블로그 URL 형식입니다.'); + } + + if (errors.length > 0) { + return NextResponse.json({ message: errors.join(' '), errors }, { status: 400 }); + } + + const database = db(); + + // Check for duplicate Discord ID + const [existingMember] = await database + .select() + .from(members) + .where(eq(members.discordId, discordId)) + .limit(1); + + if (existingMember) { + return NextResponse.json( + { message: '이미 등록된 Discord ID입니다.' }, + { status: 409 } + ); + } + + // Create member (Requirement: 19.3) + const [newMember] = await database + .insert(members) + .values({ + name: name.trim(), + part: part.trim(), + discordId: discordId.trim(), + discordUsername: discordUsername?.trim() || discordId.trim(), + blogUrl: blogUrl.trim(), + rssUrl: rssUrl?.trim() || null, + status: MemberStatus.ACTIVE, + }) + .returning(); + + return NextResponse.json({ + message: '멤버가 등록되었습니다.', + member: newMember, + }); + } catch (error) { + console.error('Admin create member error:', error); + return NextResponse.json({ message: '서버 오류가 발생했습니다.' }, { status: 500 }); + } +}); diff --git a/packages/web/src/app/api/admin/rounds/[id]/route.ts b/packages/web/src/app/api/admin/rounds/[id]/route.ts new file mode 100644 index 0000000..f1a4b3d --- /dev/null +++ b/packages/web/src/app/api/admin/rounds/[id]/route.ts @@ -0,0 +1,150 @@ +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 { Errors, successResponse, errorResponse } from '@/lib/api-error'; + +const { rounds } = sharedDb; + +/** + * GET /api/admin/rounds/[id] + * Get a single round by ID + */ +export const GET = withAdminAuth(async (request: NextRequest, _adminAuth) => { + try { + const url = new URL(request.url); + const id = url.pathname.split('/').pop(); + + if (!id) { + return Errors.badRequest('회차 ID가 필요합니다.').toResponse(); + } + + const database = db(); + const [round] = await database + .select() + .from(rounds) + .where(eq(rounds.id, parseInt(id, 10))) + .limit(1); + + if (!round) { + return Errors.notFound('회차를 찾을 수 없습니다.').toResponse(); + } + + return successResponse({ round }); + } catch (error) { + console.error('Admin get round error:', error); + return errorResponse(error); + } +}); + +/** + * PATCH /api/admin/rounds/[id] + * Update a round (set as current, update dates) + */ +export const PATCH = withAdminAuth(async (request: NextRequest, _adminAuth) => { + try { + const url = new URL(request.url); + const id = url.pathname.split('/').pop(); + + if (!id) { + return Errors.badRequest('회차 ID가 필요합니다.').toResponse(); + } + + const body = await request.json(); + const { isCurrent, startDate, endDate, graceEndDate } = body; + + const database = db(); + + // Check if round exists + const [existingRound] = await database + .select() + .from(rounds) + .where(eq(rounds.id, parseInt(id, 10))) + .limit(1); + + if (!existingRound) { + return Errors.notFound('회차를 찾을 수 없습니다.').toResponse(); + } + + // Build update object + const updateData: Partial = {}; + + if (startDate !== undefined) { + updateData.startDate = startDate; + } + if (endDate !== undefined) { + updateData.endDate = endDate; + } + if (graceEndDate !== undefined) { + updateData.graceEndDate = graceEndDate; + } + + // If setting as current, unset all other rounds first + if (isCurrent === true) { + await database + .update(rounds) + .set({ isCurrent: false }) + .where(eq(rounds.isCurrent, true)); + updateData.isCurrent = true; + } else if (isCurrent === false) { + updateData.isCurrent = false; + } + + // Update round + const [updatedRound] = await database + .update(rounds) + .set(updateData) + .where(eq(rounds.id, parseInt(id, 10))) + .returning(); + + return successResponse( + { round: updatedRound }, + '회차가 수정되었습니다.' + ); + } catch (error) { + console.error('Admin update round error:', error); + return errorResponse(error); + } +}); + +/** + * DELETE /api/admin/rounds/[id] + * Delete a round (use with caution) + */ +export const DELETE = withAdminAuth(async (request: NextRequest, _adminAuth) => { + try { + const url = new URL(request.url); + const id = url.pathname.split('/').pop(); + + if (!id) { + return Errors.badRequest('회차 ID가 필요합니다.').toResponse(); + } + + const database = db(); + + // Check if round exists + const [existingRound] = await database + .select() + .from(rounds) + .where(eq(rounds.id, parseInt(id, 10))) + .limit(1); + + if (!existingRound) { + return Errors.notFound('회차를 찾을 수 없습니다.').toResponse(); + } + + // Delete round (cascade will handle related records) + await database + .delete(rounds) + .where(eq(rounds.id, parseInt(id, 10))); + + return successResponse( + { deleted: true }, + '회차가 삭제되었습니다.' + ); + } catch (error) { + console.error('Admin delete round error:', error); + return errorResponse(error); + } +}); diff --git a/packages/web/src/app/api/admin/rounds/route.ts b/packages/web/src/app/api/admin/rounds/route.ts new file mode 100644 index 0000000..5046eb0 --- /dev/null +++ b/packages/web/src/app/api/admin/rounds/route.ts @@ -0,0 +1,184 @@ +import { NextRequest } from 'next/server'; +import { eq, asc, sql } from 'drizzle-orm'; +import { db } from '@/lib/db'; +import { db as sharedDb } from '@blog-study/shared'; +import { withAdminAuth } from '@/lib/admin'; +import { Errors, successResponse, errorResponse } from '@/lib/api-error'; +import { utils } from '@blog-study/shared'; + +const { calculateRoundDates } = utils; +const { rounds, attendance, MemberStatus, members, AttendanceStatus } = sharedDb; + +/** + * GET /api/admin/rounds + * Get all rounds with detailed statistics for admin + */ +export const GET = withAdminAuth(async (_request, _adminAuth) => { + try { + const database = db(); + + // Get all rounds + const allRounds = await database + .select() + .from(rounds) + .orderBy(asc(rounds.roundNumber)); + + // Get attendance stats per round + const attendanceStats = await database + .select({ + roundId: attendance.roundId, + total: sql`count(*)::int`, + submitted: sql`COUNT(CASE WHEN ${attendance.status} = ${AttendanceStatus.SUBMITTED} THEN 1 END)::int`, + late: sql`COUNT(CASE WHEN ${attendance.status} = ${AttendanceStatus.LATE} THEN 1 END)::int`, + absent: sql`COUNT(CASE WHEN ${attendance.status} = ${AttendanceStatus.ABSENT} THEN 1 END)::int`, + pending: sql`COUNT(CASE WHEN ${attendance.status} = ${AttendanceStatus.PENDING} THEN 1 END)::int`, + }) + .from(attendance) + .groupBy(attendance.roundId); + + const statsMap = new Map( + attendanceStats.map((s) => [ + s.roundId, + { + total: s.total, + submitted: s.submitted, + late: s.late, + absent: s.absent, + pending: s.pending, + }, + ]) + ); + + const roundsWithStats = allRounds.map((round) => { + const stats = statsMap.get(round.id) || { + total: 0, + submitted: 0, + late: 0, + absent: 0, + pending: 0, + }; + + return { + id: round.id, + roundNumber: round.roundNumber, + startDate: round.startDate, + endDate: round.endDate, + graceEndDate: round.graceEndDate, + isCurrent: round.isCurrent, + stats: { + ...stats, + submissionRate: stats.total > 0 ? Math.round((stats.submitted / stats.total) * 100) : 0, + }, + }; + }); + + return successResponse({ + rounds: roundsWithStats, + total: allRounds.length, + }); + } catch (error) { + console.error('Admin rounds API error:', error); + return errorResponse(error); + } +}); + +/** + * POST /api/admin/rounds + * Create rounds based on start date and total rounds + * Requirements: 15.1, 15.2 + */ +export const POST = withAdminAuth(async (request: NextRequest, _adminAuth) => { + try { + const body = await request.json(); + const { startDate, totalRounds } = body; + + // Validate required fields + if (!startDate) { + return Errors.badRequest('시작일이 필요합니다.').toResponse(); + } + + if (!totalRounds || totalRounds < 1 || totalRounds > 52) { + return Errors.badRequest('총 회차는 1~52 사이여야 합니다.').toResponse(); + } + + const database = db(); + + // Check if rounds already exist + const existingRounds = await database.select().from(rounds); + if (existingRounds.length > 0) { + return Errors.conflict('이미 회차가 생성되어 있습니다. 기존 회차를 삭제 후 다시 시도해주세요.').toResponse(); + } + + // Parse start date + const parsedStartDate = new Date(startDate); + if (isNaN(parsedStartDate.getTime())) { + return Errors.badRequest('유효하지 않은 날짜 형식입니다.').toResponse(); + } + + // Helper function to format date as YYYY-MM-DD string + const formatDateString = (date: Date): string => { + const isoString = date.toISOString().split('T')[0]; + return isoString || ''; + }; + + // Generate rounds + const roundsToCreate = []; + const now = new Date(); + + for (let i = 1; i <= totalRounds; i++) { + const dates = calculateRoundDates(parsedStartDate, i); + + // Determine if this is the current round + const roundStart = new Date(dates.startDate); + const roundGraceEnd = new Date(dates.graceEndDate); + const isCurrent = now >= roundStart && now <= roundGraceEnd; + + roundsToCreate.push({ + roundNumber: i, + startDate: formatDateString(dates.startDate), + endDate: formatDateString(dates.endDate), + graceEndDate: formatDateString(dates.graceEndDate), + isCurrent, + }); + } + + // Insert all rounds + await database.insert(rounds).values(roundsToCreate); + + // Create attendance records for active members for the current round + const currentRound = roundsToCreate.find((r) => r.isCurrent); + if (currentRound) { + const activeMembers = await database + .select({ id: members.id }) + .from(members) + .where(eq(members.status, MemberStatus.ACTIVE)); + + const [insertedCurrentRound] = await database + .select() + .from(rounds) + .where(eq(rounds.roundNumber, currentRound.roundNumber)) + .limit(1); + + if (insertedCurrentRound && activeMembers.length > 0) { + const attendanceRecords = activeMembers.map((member) => ({ + memberId: member.id, + roundId: insertedCurrentRound.id, + status: AttendanceStatus.PENDING, + })); + + await database.insert(attendance).values(attendanceRecords); + } + } + + return successResponse( + { + created: roundsToCreate.length, + currentRound: currentRound?.roundNumber || null, + }, + `${roundsToCreate.length}개의 회차가 생성되었습니다.` + ); + } catch (error) { + console.error('Admin create rounds error:', error); + return errorResponse(error); + } +}); diff --git a/packages/web/src/app/api/admin/settings/route.ts b/packages/web/src/app/api/admin/settings/route.ts new file mode 100644 index 0000000..0601cf0 --- /dev/null +++ b/packages/web/src/app/api/admin/settings/route.ts @@ -0,0 +1,127 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { eq } from 'drizzle-orm'; +import { getDb } from '@/lib/db'; +import { config, rounds } from '@blog-study/shared/db'; +import { verifyAdminAccess } from '@/lib/admin'; + +/** + * GET /api/admin/settings + * Get all study settings + * Requirements: 16.10 + */ +export async function GET() { + try { + // Check admin access + const adminCheck = await verifyAdminAccess(); + if (!adminCheck.isAdmin) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const db = getDb(); + + // Get all config values + const configRows = await db.select().from(config); + + // Convert to object + const settings: Record = {}; + for (const row of configRows) { + settings[row.key] = row.value; + } + + // Get current round info + const [currentRound] = await db + .select() + .from(rounds) + .where(eq(rounds.isCurrent, true)) + .limit(1); + + // Get total rounds count + const allRounds = await db.select().from(rounds); + + return NextResponse.json({ + settings: { + studyStartDate: settings['study_start_date'] || null, + totalRounds: settings['total_rounds'] || '10', + announcementChannelId: settings['announcement_channel_id'] || null, + curationChannelId: settings['curation_channel_id'] || null, + adminDiscordIds: settings['admin_discord_ids'] || '', + studyRoleId: settings['study_role_id'] || null, + }, + currentRound: currentRound || null, + totalRoundsCreated: allRounds.length, + }); + } catch (error) { + console.error('Error fetching settings:', error); + return NextResponse.json( + { error: 'Failed to fetch settings' }, + { status: 500 } + ); + } +} + +/** + * PATCH /api/admin/settings + * Update study settings + * Requirements: 16.11 + */ +export async function PATCH(request: NextRequest) { + try { + // Check admin access + const adminCheck = await verifyAdminAccess(); + if (!adminCheck.isAdmin) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const body = await request.json(); + const db = getDb(); + const now = new Date(); + + // Map of frontend keys to database keys + const keyMap: Record = { + studyStartDate: 'study_start_date', + totalRounds: 'total_rounds', + announcementChannelId: 'announcement_channel_id', + curationChannelId: 'curation_channel_id', + adminDiscordIds: 'admin_discord_ids', + studyRoleId: 'study_role_id', + }; + + // Update each setting + for (const [frontendKey, value] of Object.entries(body)) { + const dbKey = keyMap[frontendKey]; + if (!dbKey || value === undefined) continue; + + const stringValue = String(value); + + // Check if key exists + const [existing] = await db + .select() + .from(config) + .where(eq(config.key, dbKey)) + .limit(1); + + if (existing) { + // Update existing + await db + .update(config) + .set({ value: stringValue, updatedAt: now }) + .where(eq(config.key, dbKey)); + } else { + // Insert new + await db.insert(config).values({ + key: dbKey, + value: stringValue, + updatedAt: now, + }); + } + } + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('Error updating settings:', error); + return NextResponse.json( + { error: 'Failed to update settings' }, + { status: 500 } + ); + } +} diff --git a/packages/web/src/app/api/auth/login/route.ts b/packages/web/src/app/api/auth/login/route.ts new file mode 100644 index 0000000..4e4b384 --- /dev/null +++ b/packages/web/src/app/api/auth/login/route.ts @@ -0,0 +1,137 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { eq } from 'drizzle-orm'; +import { db } from '@/lib/db'; +import { db as sharedDb } from '@blog-study/shared'; + +const { users, sessions } = sharedDb; +import { + isValidEmail, + comparePassword, + generateToken, + getSessionExpiry, +} from '@/lib/auth'; +import { randomUUID } from 'crypto'; + +export interface LoginRequest { + email: string; + password: string; +} + +export interface LoginResponse { + success: boolean; + message: string; + token?: string; +} + +/** + * POST /api/auth/login + * Login with email and password + * Requirements: 17.6, 17.7 + */ +export async function POST(request: NextRequest): Promise> { + try { + const body = (await request.json()) as LoginRequest; + const { email, password } = body; + + // Validate input + if (!email || !isValidEmail(email)) { + return NextResponse.json( + { success: false, message: '올바른 이메일 형식이 아닙니다.' }, + { status: 400 } + ); + } + + if (!password) { + return NextResponse.json( + { success: false, message: '비밀번호를 입력해 주세요.' }, + { status: 400 } + ); + } + + const database = db(); + + // Find user by email + const [user] = await database + .select() + .from(users) + .where(eq(users.email, email.toLowerCase())) + .limit(1); + + if (!user) { + return NextResponse.json( + { success: false, message: '이메일 또는 비밀번호가 올바르지 않습니다.' }, + { status: 401 } + ); + } + + // Verify password (Requirement 17.7) + const isPasswordValid = await comparePassword(password, user.passwordHash); + + if (!isPasswordValid) { + return NextResponse.json( + { success: false, message: '이메일 또는 비밀번호가 올바르지 않습니다.' }, + { status: 401 } + ); + } + + // Check email verification (Requirement 17.6) + if (!user.emailVerified) { + return NextResponse.json( + { success: false, message: '이메일 인증이 필요합니다. 이메일을 확인해 주세요.' }, + { status: 403 } + ); + } + + // Generate JWT token (Requirement 17.7) + const token = generateToken({ + userId: user.id, + email: user.email, + }); + + // Create session record + const sessionToken = randomUUID(); + const expiresAt = getSessionExpiry(); + + await database.insert(sessions).values({ + userId: user.id, + token: sessionToken, + expiresAt, + }); + + // Create response with httpOnly cookie + const response = NextResponse.json( + { + success: true, + message: '로그인되었습니다.', + token, + }, + { status: 200 } + ); + + // Set JWT token as httpOnly cookie + response.cookies.set('auth-token', token, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + path: '/', + maxAge: 7 * 24 * 60 * 60, // 7 days + }); + + // Set session token as httpOnly cookie + response.cookies.set('session-token', sessionToken, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + path: '/', + maxAge: 7 * 24 * 60 * 60, // 7 days + }); + + return response; + } catch (error) { + console.error('Login error:', error); + return NextResponse.json( + { success: false, message: '서버 오류가 발생했습니다.' }, + { status: 500 } + ); + } +} diff --git a/packages/web/src/app/api/auth/logout/route.ts b/packages/web/src/app/api/auth/logout/route.ts new file mode 100644 index 0000000..f77399c --- /dev/null +++ b/packages/web/src/app/api/auth/logout/route.ts @@ -0,0 +1,63 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { eq } from 'drizzle-orm'; +import { db } from '@/lib/db'; +import { db as sharedDb } from '@blog-study/shared'; + +const { sessions } = sharedDb; + +export interface LogoutResponse { + success: boolean; + message: string; +} + +/** + * POST /api/auth/logout + * Logout and invalidate session + * Requirement: 17.8 + */ +export async function POST(request: NextRequest): Promise> { + try { + const sessionToken = request.cookies.get('session-token')?.value; + + if (sessionToken) { + const database = db(); + + // Invalidate session token (Requirement 17.8) + await database + .delete(sessions) + .where(eq(sessions.token, sessionToken)); + } + + // Create response and clear cookies + const response = NextResponse.json( + { success: true, message: '로그아웃되었습니다.' }, + { status: 200 } + ); + + // Clear auth token cookie + response.cookies.set('auth-token', '', { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + path: '/', + maxAge: 0, + }); + + // Clear session token cookie + response.cookies.set('session-token', '', { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + path: '/', + maxAge: 0, + }); + + return response; + } catch (error) { + console.error('Logout error:', error); + return NextResponse.json( + { success: false, message: '서버 오류가 발생했습니다.' }, + { status: 500 } + ); + } +} diff --git a/packages/web/src/app/api/auth/me/route.ts b/packages/web/src/app/api/auth/me/route.ts new file mode 100644 index 0000000..18e842f --- /dev/null +++ b/packages/web/src/app/api/auth/me/route.ts @@ -0,0 +1,79 @@ +import { NextResponse } from 'next/server'; +import { cookies } from 'next/headers'; +import { eq } from 'drizzle-orm'; +import { db } from '@/lib/db'; +import { db as sharedDb } from '@blog-study/shared'; +import { verifyToken } from '@/lib/auth'; + +const { users, members } = sharedDb; + +/** + * GET /api/auth/me + * Get current authenticated user info + */ +export async function GET() { + try { + const cookieStore = await cookies(); + const authToken = cookieStore.get('auth-token')?.value; + + if (!authToken) { + return NextResponse.json( + { message: '인증이 필요합니다.' }, + { status: 401 } + ); + } + + const payload = verifyToken(authToken); + if (!payload) { + return NextResponse.json( + { message: '유효하지 않은 토큰입니다.' }, + { status: 401 } + ); + } + + const database = db(); + const [userData] = await database + .select() + .from(users) + .where(eq(users.id, payload.userId)) + .limit(1); + + if (!userData) { + return NextResponse.json( + { message: '사용자를 찾을 수 없습니다.' }, + { status: 404 } + ); + } + + let memberData = null; + + // If user is linked to a member, get member info + if (userData.memberId) { + const [member] = await database + .select() + .from(members) + .where(eq(members.id, userData.memberId)) + .limit(1); + + if (member) { + memberData = member; + } + } + + return NextResponse.json({ + id: userData.id, + email: userData.email, + emailVerified: userData.emailVerified, + memberId: userData.memberId, + profileImageUrl: memberData?.profileImageUrl, + name: memberData?.name, + discordUsername: memberData?.discordUsername, + }); + } catch (error) { + console.error('Get user error:', error); + return NextResponse.json( + { message: '서버 오류가 발생했습니다.' }, + { status: 500 } + ); + } +} diff --git a/packages/web/src/app/api/auth/register/route.ts b/packages/web/src/app/api/auth/register/route.ts new file mode 100644 index 0000000..6b0f143 --- /dev/null +++ b/packages/web/src/app/api/auth/register/route.ts @@ -0,0 +1,111 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { eq } from 'drizzle-orm'; +import { db } from '@/lib/db'; +import { db as sharedDb } from '@blog-study/shared'; + +const { users } = sharedDb; +import { + isValidEmail, + isValidPassword, + hashPassword, + generateVerificationToken, + getVerificationExpiry, +} from '@/lib/auth'; +import { sendVerificationEmail } from '@/lib/email'; + +export interface RegisterRequest { + email: string; + password: string; +} + +export interface RegisterResponse { + success: boolean; + message: string; + userId?: string; +} + +/** + * POST /api/auth/register + * Register a new user with email and password + * Requirements: 17.1, 17.2, 17.3 + */ +export async function POST(request: NextRequest): Promise> { + try { + const body = (await request.json()) as RegisterRequest; + const { email, password } = body; + + // Validate email format (Requirement 17.1) + if (!email || !isValidEmail(email)) { + return NextResponse.json( + { success: false, message: '올바른 이메일 형식이 아닙니다.' }, + { status: 400 } + ); + } + + // Validate password strength (Requirement 17.1) + if (!password || !isValidPassword(password)) { + return NextResponse.json( + { success: false, message: '비밀번호는 최소 8자 이상이어야 합니다.' }, + { status: 400 } + ); + } + + const database = db(); + + // Check if email already exists + const existingUser = await database + .select() + .from(users) + .where(eq(users.email, email.toLowerCase())) + .limit(1); + + if (existingUser.length > 0) { + return NextResponse.json( + { success: false, message: '이미 등록된 이메일입니다.' }, + { status: 409 } + ); + } + + // Hash password using bcrypt (Requirement 17.2) + const passwordHash = await hashPassword(password); + + // Generate verification token (Requirement 17.3) + const verificationToken = generateVerificationToken(); + const verificationExpiry = getVerificationExpiry(); + + // Create user record + const [newUser] = await database + .insert(users) + .values({ + email: email.toLowerCase(), + passwordHash, + emailVerified: false, + emailVerifyToken: verificationToken, + emailVerifyExpires: verificationExpiry, + }) + .returning({ id: users.id }); + + // Send verification email (Requirement 17.3) + const emailResult = await sendVerificationEmail(email, verificationToken); + + if (!emailResult.success) { + console.error('Failed to send verification email:', emailResult.error); + // User is created but email failed - they can request resend later + } + + return NextResponse.json( + { + success: true, + message: '회원가입이 완료되었습니다. 이메일을 확인하여 인증을 완료해 주세요.', + userId: newUser?.id, + }, + { status: 201 } + ); + } catch (error) { + console.error('Registration error:', error); + return NextResponse.json( + { success: false, message: '서버 오류가 발생했습니다.' }, + { status: 500 } + ); + } +} diff --git a/packages/web/src/app/api/auth/verify-email/route.ts b/packages/web/src/app/api/auth/verify-email/route.ts new file mode 100644 index 0000000..fd3044d --- /dev/null +++ b/packages/web/src/app/api/auth/verify-email/route.ts @@ -0,0 +1,163 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { eq, and, gt } from 'drizzle-orm'; +import { db } from '@/lib/db'; +import { db as sharedDb } from '@blog-study/shared'; + +const { users } = sharedDb; +import { generateVerificationToken, getVerificationExpiry } from '@/lib/auth'; +import { sendVerificationEmail } from '@/lib/email'; + +export interface VerifyEmailRequest { + token: string; +} + +export interface VerifyEmailResponse { + success: boolean; + message: string; +} + +export interface ResendVerificationRequest { + email: string; +} + +/** + * GET /api/auth/verify-email?token=xxx + * Verify email with token + * Requirements: 17.4, 17.5 + */ +export async function GET(request: NextRequest): Promise> { + try { + const { searchParams } = new URL(request.url); + const token = searchParams.get('token'); + + if (!token) { + return NextResponse.json( + { success: false, message: '인증 토큰이 필요합니다.' }, + { status: 400 } + ); + } + + const database = db(); + + // Find user with valid token (Requirement 17.4) + const [user] = await database + .select() + .from(users) + .where( + and( + eq(users.emailVerifyToken, token), + gt(users.emailVerifyExpires, new Date()) + ) + ) + .limit(1); + + // Token expired or invalid (Requirement 17.5) + if (!user) { + return NextResponse.json( + { success: false, message: '유효하지 않거나 만료된 인증 토큰입니다.' }, + { status: 400 } + ); + } + + // Already verified + if (user.emailVerified) { + return NextResponse.json( + { success: true, message: '이미 인증된 이메일입니다.' }, + { status: 200 } + ); + } + + // Mark email as verified (Requirement 17.4) + await database + .update(users) + .set({ + emailVerified: true, + emailVerifyToken: null, + emailVerifyExpires: null, + updatedAt: new Date(), + }) + .where(eq(users.id, user.id)); + + return NextResponse.json( + { success: true, message: '이메일 인증이 완료되었습니다.' }, + { status: 200 } + ); + } catch (error) { + console.error('Email verification error:', error); + return NextResponse.json( + { success: false, message: '서버 오류가 발생했습니다.' }, + { status: 500 } + ); + } +} + +/** + * POST /api/auth/verify-email + * Resend verification email (Requirement 17.5) + */ +export async function POST(request: NextRequest): Promise> { + try { + const body = (await request.json()) as ResendVerificationRequest; + const { email } = body; + + if (!email) { + return NextResponse.json( + { success: false, message: '이메일이 필요합니다.' }, + { status: 400 } + ); + } + + const database = db(); + + // Find user by email + const [user] = await database + .select() + .from(users) + .where(eq(users.email, email.toLowerCase())) + .limit(1); + + if (!user) { + // Don't reveal if email exists + return NextResponse.json( + { success: true, message: '인증 이메일이 발송되었습니다.' }, + { status: 200 } + ); + } + + // Already verified + if (user.emailVerified) { + return NextResponse.json( + { success: false, message: '이미 인증된 이메일입니다.' }, + { status: 400 } + ); + } + + // Generate new verification token + const verificationToken = generateVerificationToken(); + const verificationExpiry = getVerificationExpiry(); + + // Update user with new token + await database + .update(users) + .set({ + emailVerifyToken: verificationToken, + emailVerifyExpires: verificationExpiry, + updatedAt: new Date(), + }) + .where(eq(users.id, user.id)); + + // Send verification email + await sendVerificationEmail(email, verificationToken); + + return NextResponse.json( + { success: true, message: '인증 이메일이 발송되었습니다.' }, + { status: 200 } + ); + } catch (error) { + console.error('Resend verification error:', error); + return NextResponse.json( + { success: false, message: '서버 오류가 발생했습니다.' }, + { status: 500 } + ); + } +} diff --git a/packages/web/src/app/api/curation/route.ts b/packages/web/src/app/api/curation/route.ts new file mode 100644 index 0000000..cfc486d --- /dev/null +++ b/packages/web/src/app/api/curation/route.ts @@ -0,0 +1,70 @@ +import { NextRequest } from 'next/server'; +import { desc, count, eq, and } from 'drizzle-orm'; +import { db } from '@/lib/db'; +import { db as sharedDb } from '@blog-study/shared'; +import { successResponse, errorResponse } from '@/lib/api-error'; + +const { curationItems } = sharedDb; + +/** + * GET /api/curation + * Get curated articles and conferences + * Requirement: 18.3 + */ +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url); + const category = searchParams.get('category') || 'all'; + const limit = parseInt(searchParams.get('limit') || '50', 10); + + const database = db(); + + // Build query conditions + const conditions = []; + if (category !== 'all') { + conditions.push(eq(curationItems.category, category)); + } + + // Get total count + const totalCountResult = await database + .select({ count: count() }) + .from(curationItems) + .where(conditions.length > 0 ? and(...conditions) : undefined); + const totalCount = totalCountResult[0]?.count ?? 0; + + // Get curation items + const itemsResult = await database + .select({ + id: curationItems.id, + title: curationItems.title, + url: curationItems.url, + publishedAt: curationItems.publishedAt, + category: curationItems.category, + tags: curationItems.tags, + relevanceScore: curationItems.relevanceScore, + isShared: curationItems.isShared, + sharedAt: curationItems.sharedAt, + }) + .from(curationItems) + .where(conditions.length > 0 ? and(...conditions) : undefined) + .orderBy(desc(curationItems.relevanceScore), desc(curationItems.collectedAt)) + .limit(Math.min(100, limit)); + + return successResponse({ + items: itemsResult.map((item) => ({ + id: item.id, + title: item.title, + url: item.url, + publishedAt: item.publishedAt?.toISOString() ?? null, + category: item.category, + tags: item.tags, + relevanceScore: item.relevanceScore, + sharedAt: item.isShared ? item.sharedAt?.toISOString() : null, + })), + totalCount, + }); + } catch (error) { + console.error('Curation API error:', error); + return errorResponse(error); + } +} diff --git a/packages/web/src/app/api/dashboard/route.ts b/packages/web/src/app/api/dashboard/route.ts new file mode 100644 index 0000000..30bf333 --- /dev/null +++ b/packages/web/src/app/api/dashboard/route.ts @@ -0,0 +1,93 @@ +import { NextResponse } from 'next/server'; +import { eq, desc, count } from 'drizzle-orm'; +import { db } from '@/lib/db'; +import { db as sharedDb } from '@blog-study/shared'; + +const { members, posts, rounds, MemberStatus } = sharedDb; + +/** + * GET /api/dashboard + * Get dashboard data including current round info and recent posts + * Requirement: 18.1 + */ +export async function GET() { + try { + const database = db(); + + // Get current round + const [currentRoundData] = await database + .select() + .from(rounds) + .where(eq(rounds.isCurrent, true)) + .limit(1); + + let currentRound = null; + if (currentRoundData) { + const now = new Date(); + const endDate = new Date(currentRoundData.endDate); + const graceEndDate = new Date(currentRoundData.graceEndDate); + + // Calculate days remaining + const timeDiff = endDate.getTime() - now.getTime(); + const daysRemaining = Math.ceil(timeDiff / (1000 * 60 * 60 * 24)); + + // Check if in grace period + const isGracePeriod = now > endDate && now <= graceEndDate; + + currentRound = { + roundNumber: currentRoundData.roundNumber, + startDate: currentRoundData.startDate, + endDate: currentRoundData.endDate, + graceEndDate: currentRoundData.graceEndDate, + daysRemaining: Math.max(0, daysRemaining), + isGracePeriod, + }; + } + + // Get recent posts with member info + const recentPostsResult = await database + .select({ + id: posts.id, + title: posts.title, + url: posts.url, + publishedAt: posts.publishedAt, + memberName: members.name, + memberDiscordUsername: members.discordUsername, + }) + .from(posts) + .leftJoin(members, eq(posts.memberId, members.id)) + .orderBy(desc(posts.publishedAt)) + .limit(5); + + // Get total active members count + const totalMembersResult = await database + .select({ count: count() }) + .from(members) + .where(eq(members.status, MemberStatus.ACTIVE)); + + // Get total posts count + const totalPostsResult = await database + .select({ count: count() }) + .from(posts); + + return NextResponse.json({ + currentRound, + recentPosts: recentPostsResult.map((post) => ({ + id: post.id, + title: post.title, + url: post.url, + publishedAt: post.publishedAt?.toISOString(), + memberName: post.memberName, + memberDiscordUsername: post.memberDiscordUsername, + })), + totalMembers: totalMembersResult[0]?.count ?? 0, + totalPosts: totalPostsResult[0]?.count ?? 0, + }); + } catch (error) { + console.error('Dashboard API error:', error); + return NextResponse.json( + { message: '서버 오류가 발생했습니다.' }, + { status: 500 } + ); + } +} diff --git a/packages/web/src/app/api/keywords/route.ts b/packages/web/src/app/api/keywords/route.ts new file mode 100644 index 0000000..b8e7d02 --- /dev/null +++ b/packages/web/src/app/api/keywords/route.ts @@ -0,0 +1,53 @@ +import { NextRequest } from 'next/server'; +import { desc } from 'drizzle-orm'; +import { db } from '@/lib/db'; +import { db as sharedDb } from '@blog-study/shared'; +import { successResponse, errorResponse } from '@/lib/api-error'; + +const { keywords } = sharedDb; + +/** + * GET /api/keywords + * Get top keywords (interest analysis) + * Requirement: 14.3 - /관심분야 command equivalent + */ +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url); + const limit = Math.min(50, Math.max(1, parseInt(searchParams.get('limit') || '10', 10))); + + const database = db(); + + // Get top keywords by frequency + const topKeywords = await database + .select({ + id: keywords.id, + keyword: keywords.keyword, + frequency: keywords.frequency, + lastUpdated: keywords.lastUpdated, + }) + .from(keywords) + .orderBy(desc(keywords.frequency)) + .limit(limit); + + // Calculate total frequency for percentage + const totalFrequency = topKeywords.reduce((sum, k) => sum + (k.frequency ?? 0), 0); + + return successResponse({ + keywords: topKeywords.map((k, index) => ({ + rank: index + 1, + keyword: k.keyword, + frequency: k.frequency ?? 0, + percentage: totalFrequency > 0 + ? Math.round(((k.frequency ?? 0) / totalFrequency) * 100 * 10) / 10 + : 0, + lastUpdated: k.lastUpdated?.toISOString(), + })), + total: topKeywords.length, + totalFrequency, + }); + } catch (error) { + console.error('Keywords API error:', error); + return errorResponse(error); + } +} diff --git a/packages/web/src/app/api/members/[id]/route.ts b/packages/web/src/app/api/members/[id]/route.ts new file mode 100644 index 0000000..256c42e --- /dev/null +++ b/packages/web/src/app/api/members/[id]/route.ts @@ -0,0 +1,113 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { eq, count, desc, sql } from 'drizzle-orm'; +import { db } from '@/lib/db'; +import { db as sharedDb } from '@blog-study/shared'; + +const { members, posts, attendance, AttendanceStatus } = sharedDb; + +/** + * GET /api/members/[id] + * Get member profile by ID + * Requirement: 20.6 + */ +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params; + + if (!id) { + return NextResponse.json( + { message: '멤버 ID가 필요합니다.' }, + { status: 400 } + ); + } + + const database = db(); + + // Get member by ID + const [member] = await database + .select() + .from(members) + .where(eq(members.id, id)) + .limit(1); + + if (!member) { + return NextResponse.json( + { message: '멤버를 찾을 수 없습니다.' }, + { status: 404 } + ); + } + + // Get post count + const [postCount] = await database + .select({ count: count() }) + .from(posts) + .where(eq(posts.memberId, member.id)); + + // Get attendance stats + const attendanceStats = await database + .select({ + total: count(), + submitted: sql`COUNT(CASE WHEN ${attendance.status} = ${AttendanceStatus.SUBMITTED} THEN 1 END)`, + late: sql`COUNT(CASE WHEN ${attendance.status} = ${AttendanceStatus.LATE} THEN 1 END)`, + absent: sql`COUNT(CASE WHEN ${attendance.status} = ${AttendanceStatus.ABSENT} THEN 1 END)`, + }) + .from(attendance) + .where(eq(attendance.memberId, member.id)); + + const attStats = attendanceStats[0] || { total: 0, submitted: 0, late: 0, absent: 0 }; + + // Get recent posts + const recentPosts = await database + .select({ + id: posts.id, + title: posts.title, + url: posts.url, + publishedAt: posts.publishedAt, + }) + .from(posts) + .where(eq(posts.memberId, member.id)) + .orderBy(desc(posts.publishedAt)) + .limit(5); + + return NextResponse.json({ + member: { + id: member.id, + discordUsername: member.discordUsername, + name: member.name, + part: member.part, + blogUrl: member.blogUrl, + profileImageUrl: member.profileImageUrl, + bio: member.bio, + interests: member.interests, + resolution: member.resolution, + status: member.status, + joinedAt: member.joinedAt, + }, + stats: { + postCount: postCount?.count ?? 0, + totalRounds: attStats.total, + submittedRounds: attStats.submitted, + lateRounds: attStats.late, + absentRounds: attStats.absent, + attendanceRate: attStats.total > 0 + ? Math.round((attStats.submitted / attStats.total) * 100) + : 0, + }, + recentPosts: recentPosts.map((post) => ({ + id: post.id, + title: post.title, + url: post.url, + publishedAt: post.publishedAt?.toISOString(), + })), + }); + } catch (error) { + console.error('Member profile API error:', error); + return NextResponse.json( + { message: '서버 오류가 발생했습니다.' }, + { status: 500 } + ); + } +} diff --git a/packages/web/src/app/api/members/route.ts b/packages/web/src/app/api/members/route.ts new file mode 100644 index 0000000..883b451 --- /dev/null +++ b/packages/web/src/app/api/members/route.ts @@ -0,0 +1,83 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { eq, count, sql } from 'drizzle-orm'; +import { db } from '@/lib/db'; +import { db as sharedDb } from '@blog-study/shared'; + +const { members, posts, attendance, AttendanceStatus } = sharedDb; + +/** + * GET /api/members + * Get all members with basic stats + */ +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url); + const status = searchParams.get('status') || 'active'; + + const database = db(); + + // Get members by status + const membersList = await database + .select() + .from(members) + .where(eq(members.status, status)); + + // Get post counts for all members + const postCounts = await database + .select({ + memberId: posts.memberId, + count: count(), + }) + .from(posts) + .groupBy(posts.memberId); + + // Get attendance stats for all members + const attendanceStats = await database + .select({ + memberId: attendance.memberId, + total: count(), + submitted: sql`COUNT(CASE WHEN ${attendance.status} = ${AttendanceStatus.SUBMITTED} THEN 1 END)`, + }) + .from(attendance) + .groupBy(attendance.memberId); + + // Create maps for quick lookup + const postCountMap = new Map(postCounts.map((p) => [p.memberId, p.count])); + const attendanceMap = new Map( + attendanceStats.map((a) => [a.memberId, { total: a.total, submitted: a.submitted }]) + ); + + const result = membersList.map((member) => { + const postCount = postCountMap.get(member.id) || 0; + const attStats = attendanceMap.get(member.id) || { total: 0, submitted: 0 }; + const attendanceRate = attStats.total > 0 + ? Math.round((attStats.submitted / attStats.total) * 100) + : 0; + + return { + id: member.id, + discordUsername: member.discordUsername, + name: member.name, + part: member.part, + blogUrl: member.blogUrl, + profileImageUrl: member.profileImageUrl, + bio: member.bio, + status: member.status, + postCount, + attendanceRate, + joinedAt: member.joinedAt, + }; + }); + + return NextResponse.json({ + members: result, + total: result.length, + }); + } catch (error) { + console.error('Members API error:', error); + return NextResponse.json( + { message: '서버 오류가 발생했습니다.' }, + { status: 500 } + ); + } +} diff --git a/packages/web/src/app/api/posts/route.ts b/packages/web/src/app/api/posts/route.ts new file mode 100644 index 0000000..abeba96 --- /dev/null +++ b/packages/web/src/app/api/posts/route.ts @@ -0,0 +1,66 @@ +import { NextRequest } from 'next/server'; +import { desc, count, eq } from 'drizzle-orm'; +import { db } from '@/lib/db'; +import { db as sharedDb } from '@blog-study/shared'; +import { + successResponse, + errorResponse, + parsePagination, + createPaginationMeta +} from '@/lib/api-error'; + +const { posts, members, rounds } = sharedDb; + +/** + * GET /api/posts + * Get paginated list of all posts + * Requirement: 18.2 + */ +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url); + const { page, pageSize, offset } = parsePagination(searchParams); + + const database = db(); + + // Get total count + const totalCountResult = await database + .select({ count: count() }) + .from(posts); + const totalCount = totalCountResult[0]?.count ?? 0; + + // Get paginated posts with member and round info + const postsResult = await database + .select({ + id: posts.id, + title: posts.title, + url: posts.url, + publishedAt: posts.publishedAt, + memberName: members.name, + memberDiscordUsername: members.discordUsername, + roundNumber: rounds.roundNumber, + }) + .from(posts) + .leftJoin(members, eq(posts.memberId, members.id)) + .leftJoin(rounds, eq(posts.roundId, rounds.id)) + .orderBy(desc(posts.publishedAt)) + .limit(pageSize) + .offset(offset); + + return successResponse({ + posts: postsResult.map((post) => ({ + id: post.id, + title: post.title, + url: post.url, + publishedAt: post.publishedAt?.toISOString(), + memberName: post.memberName, + memberDiscordUsername: post.memberDiscordUsername, + roundNumber: post.roundNumber, + })), + pagination: createPaginationMeta(page, pageSize, totalCount), + }); + } catch (error) { + console.error('Posts API error:', error); + return errorResponse(error); + } +} diff --git a/packages/web/src/app/api/profile/edit/route.ts b/packages/web/src/app/api/profile/edit/route.ts new file mode 100644 index 0000000..6779b62 --- /dev/null +++ b/packages/web/src/app/api/profile/edit/route.ts @@ -0,0 +1,100 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { cookies } from 'next/headers'; +import { eq } from 'drizzle-orm'; +import { db } from '@/lib/db'; +import { db as sharedDb } from '@blog-study/shared'; +import { verifyToken } from '@/lib/auth'; + +const { users, members } = sharedDb; + +/** + * PUT /api/profile/edit + * Update profile information + * Requirement: 20.7 + */ +export async function PUT(request: NextRequest) { + try { + const cookieStore = await cookies(); + const authToken = cookieStore.get('auth-token')?.value; + + if (!authToken) { + return NextResponse.json( + { message: '인증이 필요합니다.' }, + { status: 401 } + ); + } + + const payload = verifyToken(authToken); + if (!payload) { + return NextResponse.json( + { message: '유효하지 않은 토큰입니다.' }, + { status: 401 } + ); + } + + const database = db(); + + // Get user and check if linked to member + const [userData] = await database + .select() + .from(users) + .where(eq(users.id, payload.userId)) + .limit(1); + + if (!userData || !userData.memberId) { + return NextResponse.json( + { message: '스터디원 계정이 연결되어 있지 않습니다.' }, + { status: 400 } + ); + } + + const body = await request.json(); + const { profileImageUrl, bio, interests, resolution } = body; + + // Validate bio length + if (bio && bio.length > 200) { + return NextResponse.json( + { message: '한줄 소개는 200자 이내로 작성해주세요.' }, + { status: 400 } + ); + } + + // Validate resolution length + if (resolution && resolution.length > 300) { + return NextResponse.json( + { message: '다짐은 300자 이내로 작성해주세요.' }, + { status: 400 } + ); + } + + // Validate interests + if (interests && !Array.isArray(interests)) { + return NextResponse.json( + { message: '관심 분야는 배열 형식이어야 합니다.' }, + { status: 400 } + ); + } + + // Update member profile + await database + .update(members) + .set({ + profileImageUrl: profileImageUrl || null, + bio: bio || null, + interests: interests || null, + resolution: resolution || null, + updatedAt: new Date(), + }) + .where(eq(members.id, userData.memberId)); + + return NextResponse.json({ + message: '프로필이 수정되었습니다.', + }); + } catch (error) { + console.error('Profile edit API error:', error); + return NextResponse.json( + { message: '서버 오류가 발생했습니다.' }, + { status: 500 } + ); + } +} diff --git a/packages/web/src/app/api/profile/link/route.ts b/packages/web/src/app/api/profile/link/route.ts new file mode 100644 index 0000000..bc00e5e --- /dev/null +++ b/packages/web/src/app/api/profile/link/route.ts @@ -0,0 +1,146 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { cookies } from 'next/headers'; +import { eq } from 'drizzle-orm'; +import { db } from '@/lib/db'; +import { db as sharedDb } from '@blog-study/shared'; +import { verifyToken } from '@/lib/auth'; + +const { users, members } = sharedDb; + +/** + * POST /api/profile/link + * Link user account to a study member by Discord ID + * Requirement: 18.5 + */ +export async function POST(request: NextRequest) { + try { + const cookieStore = await cookies(); + const authToken = cookieStore.get('auth-token')?.value; + + if (!authToken) { + return NextResponse.json( + { message: '인증이 필요합니다.' }, + { status: 401 } + ); + } + + const payload = verifyToken(authToken); + if (!payload) { + return NextResponse.json( + { message: '유효하지 않은 토큰입니다.' }, + { status: 401 } + ); + } + + const body = await request.json(); + const { discordId } = body; + + if (!discordId || typeof discordId !== 'string') { + return NextResponse.json( + { message: 'Discord ID가 필요합니다.' }, + { status: 400 } + ); + } + + const database = db(); + + // Find member by Discord ID + const [member] = await database + .select() + .from(members) + .where(eq(members.discordId, discordId)) + .limit(1); + + if (!member) { + return NextResponse.json( + { message: '해당 Discord ID로 등록된 스터디원을 찾을 수 없습니다.' }, + { status: 404 } + ); + } + + // Check if member is already linked to another user + const [existingLink] = await database + .select() + .from(users) + .where(eq(users.memberId, member.id)) + .limit(1); + + if (existingLink && existingLink.id !== payload.userId) { + return NextResponse.json( + { message: '이 스터디원은 이미 다른 계정에 연결되어 있습니다.' }, + { status: 409 } + ); + } + + // Link member to user + await database + .update(users) + .set({ + memberId: member.id, + updatedAt: new Date(), + }) + .where(eq(users.id, payload.userId)); + + return NextResponse.json({ + message: '스터디원 계정이 연결되었습니다.', + member: { + id: member.id, + discordUsername: member.discordUsername, + name: member.name, + onboardingCompleted: member.onboardingCompleted, + }, + }); + } catch (error) { + console.error('Profile link API error:', error); + return NextResponse.json( + { message: '서버 오류가 발생했습니다.' }, + { status: 500 } + ); + } +} + +/** + * DELETE /api/profile/link + * Unlink user account from study member + */ +export async function DELETE() { + try { + const cookieStore = await cookies(); + const authToken = cookieStore.get('auth-token')?.value; + + if (!authToken) { + return NextResponse.json( + { message: '인증이 필요합니다.' }, + { status: 401 } + ); + } + + const payload = verifyToken(authToken); + if (!payload) { + return NextResponse.json( + { message: '유효하지 않은 토큰입니다.' }, + { status: 401 } + ); + } + + const database = db(); + + await database + .update(users) + .set({ + memberId: null, + updatedAt: new Date(), + }) + .where(eq(users.id, payload.userId)); + + return NextResponse.json({ + message: '스터디원 계정 연결이 해제되었습니다.', + }); + } catch (error) { + console.error('Profile unlink API error:', error); + return NextResponse.json( + { message: '서버 오류가 발생했습니다.' }, + { status: 500 } + ); + } +} diff --git a/packages/web/src/app/api/profile/onboarding/route.ts b/packages/web/src/app/api/profile/onboarding/route.ts new file mode 100644 index 0000000..57032d1 --- /dev/null +++ b/packages/web/src/app/api/profile/onboarding/route.ts @@ -0,0 +1,116 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { cookies } from 'next/headers'; +import { eq } from 'drizzle-orm'; +import { db } from '@/lib/db'; +import { db as sharedDb } from '@blog-study/shared'; +import { verifyToken } from '@/lib/auth'; + +const { users, members } = sharedDb; + +// Allowed file types for profile image (for future use) +// const ALLOWED_IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/webp']; +// const MAX_IMAGE_SIZE = 2 * 1024 * 1024; // 2MB + +/** + * POST /api/profile/onboarding + * Complete onboarding with profile information + * Requirement: 20.1, 20.2, 20.3, 20.4, 20.5 + */ +export async function POST(request: NextRequest) { + try { + const cookieStore = await cookies(); + const authToken = cookieStore.get('auth-token')?.value; + + if (!authToken) { + return NextResponse.json( + { message: '인증이 필요합니다.' }, + { status: 401 } + ); + } + + const payload = verifyToken(authToken); + if (!payload) { + return NextResponse.json( + { message: '유효하지 않은 토큰입니다.' }, + { status: 401 } + ); + } + + const database = db(); + + // Get user and check if linked to member + const [userData] = await database + .select() + .from(users) + .where(eq(users.id, payload.userId)) + .limit(1); + + if (!userData || !userData.memberId) { + return NextResponse.json( + { message: '스터디원 계정이 연결되어 있지 않습니다.' }, + { status: 400 } + ); + } + + const body = await request.json(); + const { profileImageUrl, bio, interests, resolution } = body; + + // Validate bio length + if (bio && bio.length > 200) { + return NextResponse.json( + { message: '한줄 소개는 200자 이내로 작성해주세요.' }, + { status: 400 } + ); + } + + // Validate resolution length + if (resolution && resolution.length > 300) { + return NextResponse.json( + { message: '다짐은 300자 이내로 작성해주세요.' }, + { status: 400 } + ); + } + + // Validate interests + if (interests && !Array.isArray(interests)) { + return NextResponse.json( + { message: '관심 분야는 배열 형식이어야 합니다.' }, + { status: 400 } + ); + } + + // Update member profile + await database + .update(members) + .set({ + profileImageUrl: profileImageUrl || null, + bio: bio || null, + interests: interests || null, + resolution: resolution || null, + onboardingCompleted: true, + updatedAt: new Date(), + }) + .where(eq(members.id, userData.memberId)); + + return NextResponse.json({ + message: '프로필이 저장되었습니다.', + }); + } catch (error) { + console.error('Onboarding API error:', error); + return NextResponse.json( + { message: '서버 오류가 발생했습니다.' }, + { status: 500 } + ); + } +} + +// Image validation helper (commented out for future use when image upload is implemented) +// function validateImageFile(file: File): { valid: boolean; error?: string } { +// if (!ALLOWED_IMAGE_TYPES.includes(file.type)) { +// return { valid: false, error: 'JPG, PNG, WebP 형식의 이미지만 업로드 가능합니다.' }; +// } +// if (file.size > MAX_IMAGE_SIZE) { +// return { valid: false, error: '이미지 크기는 2MB 이하여야 합니다.' }; +// } +// return { valid: true }; +// } diff --git a/packages/web/src/app/api/profile/route.ts b/packages/web/src/app/api/profile/route.ts new file mode 100644 index 0000000..b6f4cd7 --- /dev/null +++ b/packages/web/src/app/api/profile/route.ts @@ -0,0 +1,140 @@ +import { NextResponse } from 'next/server'; +import { cookies } from 'next/headers'; +import { eq, count, sql } from 'drizzle-orm'; +import { db } from '@/lib/db'; +import { db as sharedDb } from '@blog-study/shared'; +import { verifyToken } from '@/lib/auth'; + +const { users, members, posts, attendance, fines, AttendanceStatus, FineStatus } = sharedDb; + +/** + * GET /api/profile + * Get current user's profile with linked member info + * Requirement: 18.5, 18.6 + */ +export async function GET() { + try { + const cookieStore = await cookies(); + const authToken = cookieStore.get('auth-token')?.value; + + if (!authToken) { + return NextResponse.json( + { message: '인증이 필요합니다.' }, + { status: 401 } + ); + } + + const payload = verifyToken(authToken); + if (!payload) { + return NextResponse.json( + { message: '유효하지 않은 토큰입니다.' }, + { status: 401 } + ); + } + + const database = db(); + const [userData] = await database + .select() + .from(users) + .where(eq(users.id, payload.userId)) + .limit(1); + + if (!userData) { + return NextResponse.json( + { message: '사용자를 찾을 수 없습니다.' }, + { status: 404 } + ); + } + + let memberData = null; + let stats = null; + + // If user is linked to a member, get member info and stats + if (userData.memberId) { + const [member] = await database + .select() + .from(members) + .where(eq(members.id, userData.memberId)) + .limit(1); + + if (member) { + memberData = member; + + // Get post count + const [postCount] = await database + .select({ count: count() }) + .from(posts) + .where(eq(posts.memberId, member.id)); + + // Get attendance stats + const attendanceStats = await database + .select({ + total: count(), + submitted: sql`COUNT(CASE WHEN ${attendance.status} = ${AttendanceStatus.SUBMITTED} THEN 1 END)`, + late: sql`COUNT(CASE WHEN ${attendance.status} = ${AttendanceStatus.LATE} THEN 1 END)`, + absent: sql`COUNT(CASE WHEN ${attendance.status} = ${AttendanceStatus.ABSENT} THEN 1 END)`, + }) + .from(attendance) + .where(eq(attendance.memberId, member.id)); + + // Get fine stats + const fineStats = await database + .select({ + totalFines: sql`COALESCE(SUM(${fines.amount}), 0)`, + unpaidFines: sql`COALESCE(SUM(CASE WHEN ${fines.status} = ${FineStatus.UNPAID} THEN ${fines.amount} ELSE 0 END), 0)`, + }) + .from(fines) + .where(eq(fines.memberId, member.id)); + + const attStats = attendanceStats[0] || { total: 0, submitted: 0, late: 0, absent: 0 }; + const fStats = fineStats[0] || { totalFines: 0, unpaidFines: 0 }; + + stats = { + postCount: postCount?.count ?? 0, + totalRounds: attStats.total, + submittedRounds: attStats.submitted, + lateRounds: attStats.late, + absentRounds: attStats.absent, + attendanceRate: attStats.total > 0 + ? Math.round((attStats.submitted / attStats.total) * 100) + : 0, + totalFines: fStats.totalFines, + unpaidFines: fStats.unpaidFines, + }; + } + } + + return NextResponse.json({ + user: { + id: userData.id, + email: userData.email, + emailVerified: userData.emailVerified, + memberId: userData.memberId, + }, + member: memberData ? { + id: memberData.id, + discordId: memberData.discordId, + discordUsername: memberData.discordUsername, + name: memberData.name, + part: memberData.part, + blogUrl: memberData.blogUrl, + rssUrl: memberData.rssUrl, + profileImageUrl: memberData.profileImageUrl, + bio: memberData.bio, + interests: memberData.interests, + resolution: memberData.resolution, + onboardingCompleted: memberData.onboardingCompleted, + status: memberData.status, + dormantUsed: memberData.dormantUsed, + joinedAt: memberData.joinedAt, + } : null, + stats, + }); + } catch (error) { + console.error('Profile API error:', error); + return NextResponse.json( + { message: '서버 오류가 발생했습니다.' }, + { status: 500 } + ); + } +} diff --git a/packages/web/src/app/api/ranking/route.ts b/packages/web/src/app/api/ranking/route.ts new file mode 100644 index 0000000..95d82ae --- /dev/null +++ b/packages/web/src/app/api/ranking/route.ts @@ -0,0 +1,90 @@ +import { NextRequest } from 'next/server'; +import { eq, count, sql } from 'drizzle-orm'; +import { db } from '@/lib/db'; +import { db as sharedDb } from '@blog-study/shared'; +import { successResponse, errorResponse } from '@/lib/api-error'; + +const { members, posts, attendance, MemberStatus, AttendanceStatus } = sharedDb; + +/** + * GET /api/ranking + * Get member rankings by post count and attendance rate + * Requirement: 18.4 + */ +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url); + const sortBy = searchParams.get('sortBy') || 'posts'; + + const database = db(); + + // Get all active members with their post counts + const membersWithPosts = await database + .select({ + id: members.id, + name: members.name, + discordUsername: members.discordUsername, + profileImageUrl: members.profileImageUrl, + postCount: count(posts.id), + }) + .from(members) + .leftJoin(posts, eq(members.id, posts.memberId)) + .where(eq(members.status, MemberStatus.ACTIVE)) + .groupBy(members.id); + + // Get attendance stats for each member + const attendanceStats = await database + .select({ + memberId: attendance.memberId, + totalRounds: count(attendance.id), + submittedRounds: sql`COUNT(CASE WHEN ${attendance.status} = ${AttendanceStatus.SUBMITTED} THEN 1 END)`.as('submitted_rounds'), + }) + .from(attendance) + .groupBy(attendance.memberId); + + // Create a map of attendance stats + const attendanceMap = new Map( + attendanceStats.map((stat) => [ + stat.memberId, + { + totalRounds: stat.totalRounds, + submittedRounds: stat.submittedRounds, + }, + ]) + ); + + // Combine data and calculate attendance rate + const rankings = membersWithPosts.map((member) => { + const stats = attendanceMap.get(member.id) || { totalRounds: 0, submittedRounds: 0 }; + const attendanceRate = stats.totalRounds > 0 + ? (stats.submittedRounds / stats.totalRounds) * 100 + : 0; + + return { + id: member.id, + name: member.name, + discordUsername: member.discordUsername, + profileImageUrl: member.profileImageUrl, + postCount: member.postCount, + attendanceRate, + submittedRounds: stats.submittedRounds, + totalRounds: stats.totalRounds, + }; + }); + + // Sort based on sortBy parameter + if (sortBy === 'attendance') { + rankings.sort((a, b) => b.attendanceRate - a.attendanceRate || b.postCount - a.postCount); + } else { + rankings.sort((a, b) => b.postCount - a.postCount || b.attendanceRate - a.attendanceRate); + } + + return successResponse({ + rankings, + totalMembers: rankings.length, + }); + } catch (error) { + console.error('Ranking API error:', error); + return errorResponse(error); + } +} diff --git a/packages/web/src/app/api/rounds/[id]/route.ts b/packages/web/src/app/api/rounds/[id]/route.ts new file mode 100644 index 0000000..873b0d3 --- /dev/null +++ b/packages/web/src/app/api/rounds/[id]/route.ts @@ -0,0 +1,121 @@ +import { NextRequest } from 'next/server'; +import { eq, count } from 'drizzle-orm'; +import { db } from '@/lib/db'; +import { db as sharedDb } from '@blog-study/shared'; +import { Errors, successResponse, errorResponse } from '@/lib/api-error'; + +const { rounds, attendance, posts, AttendanceStatus } = sharedDb; + +interface RouteParams { + params: Promise<{ id: string }>; +} + +/** + * GET /api/rounds/[id] + * Get a single round with statistics + */ +export async function GET( + _request: NextRequest, + { params }: RouteParams +) { + try { + const { id } = await params; + + if (!id) { + return Errors.badRequest('회차 ID가 필요합니다.').toResponse(); + } + + const database = db(); + + // Get round by ID (can be numeric ID or round number) + let roundData; + + // Try to parse as number for round number lookup + const roundNumber = parseInt(id, 10); + if (!isNaN(roundNumber)) { + [roundData] = await database + .select() + .from(rounds) + .where(eq(rounds.roundNumber, roundNumber)) + .limit(1); + } + + // If not found by round number, try by ID + if (!roundData) { + [roundData] = await database + .select() + .from(rounds) + .where(eq(rounds.id, parseInt(id, 10))) + .limit(1); + } + + if (!roundData) { + return Errors.notFound('회차를 찾을 수 없습니다.').toResponse(); + } + + // Get attendance statistics for this round + const attendanceStats = await database + .select({ + status: attendance.status, + count: count(), + }) + .from(attendance) + .where(eq(attendance.roundId, roundData.id)) + .groupBy(attendance.status); + + const statsMap = new Map(attendanceStats.map((s) => [s.status, s.count])); + const total = attendanceStats.reduce((sum, s) => sum + s.count, 0); + const submitted = statsMap.get(AttendanceStatus.SUBMITTED) || 0; + const late = statsMap.get(AttendanceStatus.LATE) || 0; + const absent = statsMap.get(AttendanceStatus.ABSENT) || 0; + const pending = statsMap.get(AttendanceStatus.PENDING) || 0; + + // Get post count for this round + const [postCount] = await database + .select({ count: count() }) + .from(posts) + .where(eq(posts.roundId, roundData.id)); + + // Calculate time-related info + const now = new Date(); + const endDate = new Date(roundData.endDate); + const graceEndDate = new Date(roundData.graceEndDate); + const startDate = new Date(roundData.startDate); + + const timeDiff = endDate.getTime() - now.getTime(); + const daysRemaining = Math.ceil(timeDiff / (1000 * 60 * 60 * 24)); + + const isGracePeriod = now > endDate && now <= graceEndDate; + const isCompleted = now > graceEndDate; + const isUpcoming = now < startDate; + + return successResponse({ + round: { + id: roundData.id, + roundNumber: roundData.roundNumber, + startDate: roundData.startDate, + endDate: roundData.endDate, + graceEndDate: roundData.graceEndDate, + isCurrent: roundData.isCurrent, + daysRemaining: Math.max(0, daysRemaining), + isGracePeriod, + isCompleted, + isUpcoming, + }, + stats: { + total, + submitted, + late, + absent, + pending, + submissionRate: total > 0 ? Math.round((submitted / total) * 100) : 0, + lateRate: total > 0 ? Math.round((late / total) * 100) : 0, + absentRate: total > 0 ? Math.round((absent / total) * 100) : 0, + postCount: postCount?.count ?? 0, + }, + }); + } catch (error) { + console.error('Round API error:', error); + return errorResponse(error); + } +} diff --git a/packages/web/src/app/api/rounds/route.ts b/packages/web/src/app/api/rounds/route.ts new file mode 100644 index 0000000..c5924a1 --- /dev/null +++ b/packages/web/src/app/api/rounds/route.ts @@ -0,0 +1,83 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { eq, asc, desc } from 'drizzle-orm'; +import { db } from '@/lib/db'; +import { db as sharedDb } from '@blog-study/shared'; +import { successResponse, errorResponse } from '@/lib/api-error'; + +const { rounds } = sharedDb; + +/** + * GET /api/rounds + * Get all rounds with optional filtering + */ +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url); + const current = searchParams.get('current'); + const sortOrder = searchParams.get('sort') || 'asc'; + + const database = db(); + + // If requesting current round only + if (current === 'true') { + const [currentRound] = await database + .select() + .from(rounds) + .where(eq(rounds.isCurrent, true)) + .limit(1); + + if (!currentRound) { + return NextResponse.json({ + success: true, + data: { round: null }, + message: '현재 진행 중인 회차가 없습니다.', + }); + } + + const now = new Date(); + const endDate = new Date(currentRound.endDate); + const graceEndDate = new Date(currentRound.graceEndDate); + + // Calculate days remaining + const timeDiff = endDate.getTime() - now.getTime(); + const daysRemaining = Math.ceil(timeDiff / (1000 * 60 * 60 * 24)); + + // Check if in grace period + const isGracePeriod = now > endDate && now <= graceEndDate; + + return successResponse({ + round: { + id: currentRound.id, + roundNumber: currentRound.roundNumber, + startDate: currentRound.startDate, + endDate: currentRound.endDate, + graceEndDate: currentRound.graceEndDate, + isCurrent: currentRound.isCurrent, + daysRemaining: Math.max(0, daysRemaining), + isGracePeriod, + }, + }); + } + + // Get all rounds + const allRounds = await database + .select() + .from(rounds) + .orderBy(sortOrder === 'desc' ? desc(rounds.roundNumber) : asc(rounds.roundNumber)); + + return successResponse({ + rounds: allRounds.map((round) => ({ + id: round.id, + roundNumber: round.roundNumber, + startDate: round.startDate, + endDate: round.endDate, + graceEndDate: round.graceEndDate, + isCurrent: round.isCurrent, + })), + total: allRounds.length, + }); + } catch (error) { + console.error('Rounds API error:', error); + return errorResponse(error); + } +} diff --git a/packages/web/src/app/api/stats/route.ts b/packages/web/src/app/api/stats/route.ts new file mode 100644 index 0000000..16a2fb9 --- /dev/null +++ b/packages/web/src/app/api/stats/route.ts @@ -0,0 +1,165 @@ +import { NextRequest } from 'next/server'; +import { eq, count, sql, desc } from 'drizzle-orm'; +import { db } from '@/lib/db'; +import { db as sharedDb } from '@blog-study/shared'; +import { successResponse, errorResponse } from '@/lib/api-error'; + +const { rounds, attendance, posts, members, MemberStatus, AttendanceStatus } = sharedDb; + +/** + * GET /api/stats + * Get overall study statistics + * Requirement: 9.4 - /통계 command equivalent + */ +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url); + const roundNumber = searchParams.get('round'); + + const database = db(); + + // If specific round requested + if (roundNumber) { + const roundNum = parseInt(roundNumber, 10); + if (isNaN(roundNum)) { + return successResponse({ error: '유효하지 않은 회차 번호입니다.' }); + } + + const [roundData] = await database + .select() + .from(rounds) + .where(eq(rounds.roundNumber, roundNum)) + .limit(1); + + if (!roundData) { + return successResponse({ error: '회차를 찾을 수 없습니다.' }); + } + + // Get attendance stats for this round + const attendanceStats = await database + .select({ + status: attendance.status, + count: count(), + }) + .from(attendance) + .where(eq(attendance.roundId, roundData.id)) + .groupBy(attendance.status); + + const statsMap = new Map(attendanceStats.map((s) => [s.status, s.count])); + const total = attendanceStats.reduce((sum, s) => sum + s.count, 0); + const submitted = statsMap.get(AttendanceStatus.SUBMITTED) || 0; + const late = statsMap.get(AttendanceStatus.LATE) || 0; + const absent = statsMap.get(AttendanceStatus.ABSENT) || 0; + + // Get top contributors for this round + const topContributors = await database + .select({ + memberId: posts.memberId, + memberName: members.name, + memberDiscordUsername: members.discordUsername, + postCount: count(), + }) + .from(posts) + .leftJoin(members, eq(posts.memberId, members.id)) + .where(eq(posts.roundId, roundData.id)) + .groupBy(posts.memberId, members.name, members.discordUsername) + .orderBy(desc(count())) + .limit(5); + + return successResponse({ + round: { + roundNumber: roundData.roundNumber, + startDate: roundData.startDate, + endDate: roundData.endDate, + }, + stats: { + total, + submitted, + late, + absent, + submissionRate: total > 0 ? Math.round((submitted / total) * 100) : 0, + lateRate: total > 0 ? Math.round((late / total) * 100) : 0, + absentRate: total > 0 ? Math.round((absent / total) * 100) : 0, + }, + topContributors: topContributors.map((c, index) => ({ + rank: index + 1, + name: c.memberName, + discordUsername: c.memberDiscordUsername, + postCount: c.postCount, + })), + }); + } + + // Overall statistics + const [totalMembersResult] = await database + .select({ count: count() }) + .from(members) + .where(eq(members.status, MemberStatus.ACTIVE)); + + const [totalPostsResult] = await database + .select({ count: count() }) + .from(posts); + + const [totalRoundsResult] = await database + .select({ count: count() }) + .from(rounds); + + // Get overall attendance stats + const overallAttendance = await database + .select({ + status: attendance.status, + count: count(), + }) + .from(attendance) + .groupBy(attendance.status); + + const overallStatsMap = new Map(overallAttendance.map((s) => [s.status, s.count])); + const overallTotal = overallAttendance.reduce((sum, s) => sum + s.count, 0); + const overallSubmitted = overallStatsMap.get(AttendanceStatus.SUBMITTED) || 0; + const overallLate = overallStatsMap.get(AttendanceStatus.LATE) || 0; + const overallAbsent = overallStatsMap.get(AttendanceStatus.ABSENT) || 0; + + // Get per-round statistics + const roundStats = await database + .select({ + roundId: attendance.roundId, + roundNumber: rounds.roundNumber, + total: count(), + submitted: sql`COUNT(CASE WHEN ${attendance.status} = ${AttendanceStatus.SUBMITTED} THEN 1 END)`, + late: sql`COUNT(CASE WHEN ${attendance.status} = ${AttendanceStatus.LATE} THEN 1 END)`, + absent: sql`COUNT(CASE WHEN ${attendance.status} = ${AttendanceStatus.ABSENT} THEN 1 END)`, + }) + .from(attendance) + .leftJoin(rounds, eq(attendance.roundId, rounds.id)) + .groupBy(attendance.roundId, rounds.roundNumber) + .orderBy(rounds.roundNumber); + + return successResponse({ + overview: { + totalMembers: totalMembersResult?.count ?? 0, + totalPosts: totalPostsResult?.count ?? 0, + totalRounds: totalRoundsResult?.count ?? 0, + overallSubmissionRate: overallTotal > 0 + ? Math.round((overallSubmitted / overallTotal) * 100) + : 0, + overallLateRate: overallTotal > 0 + ? Math.round((overallLate / overallTotal) * 100) + : 0, + overallAbsentRate: overallTotal > 0 + ? Math.round((overallAbsent / overallTotal) * 100) + : 0, + }, + roundStats: roundStats.map((r) => ({ + roundNumber: r.roundNumber, + total: r.total, + submitted: r.submitted, + late: r.late, + absent: r.absent, + submissionRate: r.total > 0 ? Math.round((r.submitted / r.total) * 100) : 0, + })), + }); + } catch (error) { + console.error('Stats API error:', error); + return errorResponse(error); + } +} diff --git a/packages/web/src/app/globals.css b/packages/web/src/app/globals.css new file mode 100644 index 0000000..5abe403 --- /dev/null +++ b/packages/web/src/app/globals.css @@ -0,0 +1,102 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + :root { + /* Vercel-inspired white/black + Sky Blue accent */ + --background: 0 0% 100%; + --foreground: 240 10% 3.9%; + --card: 0 0% 100%; + --card-foreground: 240 10% 3.9%; + --popover: 0 0% 100%; + --popover-foreground: 240 10% 3.9%; + --primary: 199 89% 48%; + --primary-foreground: 0 0% 100%; + --secondary: 240 4.8% 95.9%; + --secondary-foreground: 240 5.9% 10%; + --muted: 240 4.8% 95.9%; + --muted-foreground: 240 3.8% 46.1%; + --accent: 240 4.8% 95.9%; + --accent-foreground: 240 5.9% 10%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 0 0% 100%; + --success: 142 71% 45%; + --success-foreground: 0 0% 100%; + --warning: 38 92% 50%; + --warning-foreground: 0 0% 100%; + --border: 240 5.9% 90%; + --input: 240 5.9% 90%; + --ring: 199 89% 48%; + --radius: 0.75rem; + + /* Sidebar tokens */ + --sidebar: 0 0% 100%; + --sidebar-foreground: 240 3.8% 46.1%; + --sidebar-primary: 199 89% 48%; + --sidebar-accent: 240 4.8% 95.9%; + --sidebar-border: 240 5.9% 90%; + } + + .dark { + --background: 240 10% 3.9%; + --foreground: 0 0% 98%; + --card: 240 10% 3.9%; + --card-foreground: 0 0% 98%; + --popover: 240 10% 3.9%; + --popover-foreground: 0 0% 98%; + --primary: 199 89% 65%; + --primary-foreground: 240 10% 3.9%; + --secondary: 240 3.7% 15.9%; + --secondary-foreground: 0 0% 98%; + --muted: 240 3.7% 15.9%; + --muted-foreground: 240 5% 64.9%; + --accent: 240 3.7% 15.9%; + --accent-foreground: 0 0% 98%; + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 0 0% 98%; + --success: 142 71% 35%; + --success-foreground: 0 0% 98%; + --warning: 38 92% 40%; + --warning-foreground: 0 0% 98%; + --border: 240 3.7% 15.9%; + --input: 240 3.7% 15.9%; + --ring: 199 89% 65%; + + /* Sidebar dark */ + --sidebar: 240 10% 3.9%; + --sidebar-foreground: 240 5% 64.9%; + --sidebar-primary: 199 89% 65%; + --sidebar-accent: 240 3.7% 15.9%; + --sidebar-border: 240 3.7% 15.9%; + } +} + +@layer base { + * { + @apply border-border; + } + + html { + scroll-behavior: smooth; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + text-rendering: optimizeLegibility; + } + + body { + @apply bg-background text-foreground; + font-feature-settings: "ss01", "ss02", "cv01"; + letter-spacing: -0.014em; + } + + h1, h2, h3, h4, h5, h6 { + letter-spacing: -0.025em; + } +} + +@layer utilities { + .text-balance { + text-wrap: balance; + } +} diff --git a/packages/web/src/app/layout.tsx b/packages/web/src/app/layout.tsx new file mode 100644 index 0000000..9e87eab --- /dev/null +++ b/packages/web/src/app/layout.tsx @@ -0,0 +1,46 @@ +import type { Metadata } from 'next'; +import { ThemeProvider } from 'next-themes'; +import './globals.css'; + +export const metadata: Metadata = { + title: { + default: '블로그 스터디', + template: '%s | 블로그 스터디', + }, + description: '블로그 글쓰기 스터디 자동화 플랫폼', +}; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + + + + + + {children} + + + + ); +} diff --git a/packages/web/src/app/page.tsx b/packages/web/src/app/page.tsx new file mode 100644 index 0000000..4bc1981 --- /dev/null +++ b/packages/web/src/app/page.tsx @@ -0,0 +1,165 @@ +import Link from 'next/link'; +import { + Rss, + Brain, + CalendarCheck, + Banknote, + Trophy, + Newspaper, +} from 'lucide-react'; +import { Button } from '@/components/ui/button'; + +// --------------------------------------------------------------------------- +// Data +// --------------------------------------------------------------------------- + +const features = [ + { + icon: Rss, + title: 'RSS 자동 수집', + description: '블로그 글 발행 시 자동 감지 및 수집', + }, + { + icon: Brain, + title: 'AI 요약 & 추천', + description: 'GPT가 글을 요약하고 관심사를 분석', + }, + { + icon: CalendarCheck, + title: '출석 자동화', + description: '2주 1회차, 지각·결석 자동 판정', + }, + { + icon: Banknote, + title: '벌금 관리', + description: '자동 부과, DM 알림, 납부 확인', + }, + { + icon: Trophy, + title: '랭킹 & 통계', + description: '포스트 수, 출석률 기반 실시간 랭킹', + }, + { + icon: Newspaper, + title: '큐레이션', + description: '관심 키워드 기반 컨퍼런스·아티클 추천', + }, +] as const; + +// --------------------------------------------------------------------------- +// Page +// --------------------------------------------------------------------------- + +export default function Home() { + return ( +
+ {/* ------------------------------------------------------------------ */} + {/* Nav */} + {/* ------------------------------------------------------------------ */} +
+ +
+ + {/* ------------------------------------------------------------------ */} + {/* Hero */} + {/* ------------------------------------------------------------------ */} +
+ {/* Badge pill */} +
+ + Discord 봇 연동 지원 +
+ + {/* Main heading */} +

+ 블로그 스터디를 +
+ 더 스마트하게 +

+ + {/* Subtext */} +

+ AI가 글을 요약하고, 봇이 출석을 관리하고, 대시보드에서 한눈에 + 확인하세요. +

+ + {/* CTA */} +
+ +

+ 무료로 시작 · 설정 5분 +

+
+
+ + {/* ------------------------------------------------------------------ */} + {/* Features */} + {/* ------------------------------------------------------------------ */} +
+
+ {/* Section header */} +
+

+ 필요한 기능, 전부 갖췄습니다 +

+

+ 반복적인 스터디 운영 업무를 자동화하세요 +

+
+ + {/* Grid */} +
+ {features.map(({ icon: Icon, title, description }) => ( +
+ +

+ {title} +

+

+ {description} +

+
+ ))} +
+
+
+ + {/* ------------------------------------------------------------------ */} + {/* Footer */} + {/* ------------------------------------------------------------------ */} +
+
+ © 2025 블로그 스터디 + + GitHub + +
+
+
+ ); +} diff --git a/packages/web/src/components/index.ts b/packages/web/src/components/index.ts new file mode 100644 index 0000000..d5d4d52 --- /dev/null +++ b/packages/web/src/components/index.ts @@ -0,0 +1,5 @@ +// UI Components +export * from './ui'; + +// Layout Components +export * from './layout'; diff --git a/packages/web/src/components/layout/footer.tsx b/packages/web/src/components/layout/footer.tsx new file mode 100644 index 0000000..9589ecb --- /dev/null +++ b/packages/web/src/components/layout/footer.tsx @@ -0,0 +1,10 @@ +export function Footer() { + return ( +
+
+ © {new Date().getFullYear()} 블로그 스터디 + Blog Study Dashboard +
+
+ ); +} diff --git a/packages/web/src/components/layout/header.tsx b/packages/web/src/components/layout/header.tsx new file mode 100644 index 0000000..f936fef --- /dev/null +++ b/packages/web/src/components/layout/header.tsx @@ -0,0 +1,175 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { usePathname } from 'next/navigation'; +import { useTheme } from 'next-themes'; +import { Menu, LogOut, Moon, Sun } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { Button } from '@/components/ui/button'; +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; + +interface HeaderProps { + user?: { + name: string; + email: string; + imageUrl?: string; + } | null; + onMenuClick?: () => void; + onLogout?: () => void; +} + +const PATH_TITLE_MAP: Record = { + '/dashboard': '대시보드', + '/posts': '글 목록', + '/ranking': '랭킹', + '/curation': '큐레이션', + '/profile': '프로필', + '/admin': '관리자', + '/admin/members': '멤버 관리', + '/admin/attendance': '출석 관리', + '/admin/fines': '벌금 관리', + '/admin/settings': '설정', + '/admin/curation': '큐레이션 소스', +}; + +function resolvePageTitle(pathname: string): string { + // Exact match first + if (PATH_TITLE_MAP[pathname]) { + return PATH_TITLE_MAP[pathname]; + } + // Longest prefix match for nested routes (e.g. /admin/members/123) + const match = Object.keys(PATH_TITLE_MAP) + .filter((key) => pathname.startsWith(key + '/')) + .sort((a, b) => b.length - a.length)[0] as string | undefined; + return match ? PATH_TITLE_MAP[match]! : ''; +} + +function DarkModeToggle() { + const { theme, setTheme } = useTheme(); + const [mounted, setMounted] = useState(false); + + useEffect(() => { + setMounted(true); + }, []); + + if (!mounted) { + // Render a placeholder with the same dimensions to prevent layout shift + return ( + + ); + } + + const isDark = theme === 'dark'; + + return ( + + ); +} + +export function Header({ user, onMenuClick, onLogout }: HeaderProps) { + const pathname = usePathname(); + const pageTitle = resolvePageTitle(pathname); + + return ( +
+
+ + {/* Left: hamburger (mobile only) */} +
+ +
+ + {/* Left: page title (desktop only) */} + {pageTitle && ( +
+ + {pageTitle} + +
+ )} + + {/* Spacer */} +
+ + {/* Right: dark mode toggle + user info + logout */} +
+ + {/* Dark mode toggle */} + + + {user && ( + <> + {/* Thin divider */} +
+
+
+ ); +} diff --git a/packages/web/src/components/layout/index.ts b/packages/web/src/components/layout/index.ts new file mode 100644 index 0000000..a5660ed --- /dev/null +++ b/packages/web/src/components/layout/index.ts @@ -0,0 +1,4 @@ +export { Header } from './header'; +export { Sidebar } from './sidebar'; +export { Footer } from './footer'; +export { MainLayout } from './main-layout'; diff --git a/packages/web/src/components/layout/main-layout.tsx b/packages/web/src/components/layout/main-layout.tsx new file mode 100644 index 0000000..a4b8a9e --- /dev/null +++ b/packages/web/src/components/layout/main-layout.tsx @@ -0,0 +1,88 @@ +'use client'; + +import { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { Header } from './header'; +import { Sidebar, useSidebarCollapsed } from './sidebar'; +import { Footer } from './footer'; +import { cn } from '@/lib/utils'; + +interface MainLayoutProps { + children: React.ReactNode; + user?: { + name: string; + email: string; + imageUrl?: string; + } | null; + isAdmin?: boolean; + showSidebar?: boolean; + onLogout?: () => void; +} + +// Inner wrapper that reads sidebar collapsed state to apply the correct margin. +function MainContent({ + children, + showSidebar, +}: { + children: React.ReactNode; + showSidebar: boolean; +}) { + const collapsed = useSidebarCollapsed(); + + return ( +
+
{children}
+
+ ); +} + +export function MainLayout({ + children, + user, + isAdmin = false, + showSidebar = true, + onLogout, +}: MainLayoutProps) { + const router = useRouter(); + const [sidebarOpen, setSidebarOpen] = useState(false); + + const handleLogout = async () => { + if (onLogout) { + onLogout(); + } else { + try { + await fetch('/api/auth/logout', { method: 'POST' }); + router.push('/login'); + router.refresh(); + } catch { + console.error('Logout failed'); + } + } + }; + + return ( +
+
setSidebarOpen(true)} + onLogout={handleLogout} + /> +
+ {showSidebar && ( + setSidebarOpen(false)} + isAdmin={isAdmin} + /> + )} + {children} +
+
+
+ ); +} diff --git a/packages/web/src/components/layout/sidebar.tsx b/packages/web/src/components/layout/sidebar.tsx new file mode 100644 index 0000000..1871535 --- /dev/null +++ b/packages/web/src/components/layout/sidebar.tsx @@ -0,0 +1,379 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import Link from 'next/link'; +import { usePathname } from 'next/navigation'; +import { + LayoutDashboard, + FileText, + Trophy, + Newspaper, + User, + Users, + CalendarCheck, + Banknote, + Settings, + Shield, + PanelLeftClose, + PanelLeftOpen, + X, +} from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { Separator } from '@/components/ui/separator'; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +interface SidebarProps { + isOpen?: boolean; // mobile drawer open state + onClose?: () => void; // mobile close handler + isAdmin?: boolean; // show admin nav +} + +interface NavItem { + title: string; + href: string; + icon: React.ComponentType<{ className?: string }>; +} + +// ─── Navigation data ────────────────────────────────────────────────────────── + +const userNavItems: NavItem[] = [ + { title: '대시보드', href: '/dashboard', icon: LayoutDashboard }, + { title: '글 목록', href: '/posts', icon: FileText }, + { title: '랭킹', href: '/ranking', icon: Trophy }, + { title: '큐레이션', href: '/curation', icon: Newspaper }, + { title: '프로필', href: '/profile', icon: User }, +]; + +const adminNavItems: NavItem[] = [ + { title: '관리자 홈', href: '/admin', icon: LayoutDashboard }, + { title: '멤버 관리', href: '/admin/members', icon: Users }, + { title: '출석 관리', href: '/admin/attendance', icon: CalendarCheck }, + { title: '벌금 관리', href: '/admin/fines', icon: Banknote }, + { title: '큐레이션 소스', href: '/admin/curation', icon: Newspaper }, + { title: '설정', href: '/admin/settings', icon: Settings }, +]; + +// ─── Constants ──────────────────────────────────────────────────────────────── + +const STORAGE_KEY = 'study-sidebar-collapsed'; + +// ─── NavLink sub-component ──────────────────────────────────────────────────── + +interface NavLinkProps { + item: NavItem; + isActive: boolean; + collapsed: boolean; + onClick?: () => void; +} + +function NavLink({ item, isActive, collapsed, onClick }: NavLinkProps) { + const Icon = item.icon; + + return ( + + + {!collapsed && ( + {item.title} + )} + + ); +} + +// ─── Sidebar component ──────────────────────────────────────────────────────── + +export function Sidebar({ isOpen = false, onClose, isAdmin = false }: SidebarProps) { + const pathname = usePathname(); + + // Persist collapsed state in localStorage + const [collapsed, setCollapsed] = useState(() => { + if (typeof window === 'undefined') return false; + return localStorage.getItem(STORAGE_KEY) === 'true'; + }); + + // Keep localStorage in sync whenever collapsed changes + useEffect(() => { + localStorage.setItem(STORAGE_KEY, String(collapsed)); + }, [collapsed]); + + const toggleCollapsed = () => setCollapsed((prev) => !prev); + + const navItems = isAdmin ? adminNavItems : userNavItems; + + // ── Shared sidebar content (used by both desktop and mobile) ─────────────── + const SidebarContent = ({ mobile = false }: { mobile?: boolean }) => ( +
+ + {/* ── Logo ─────────────────────────────────────────────────────── */} +
+ + {collapsed && !mobile ? 'B' : 'BS'} + + {(!collapsed || mobile) && ( + + Blog Study + + )} + + {/* Mobile close button */} + {mobile && ( + + )} +
+ + {/* ── Primary navigation ───────────────────────────────────────── */} + + + {/* ── Bottom section ───────────────────────────────────────────── */} +
+ + + {/* User / Admin page toggle link */} +
+ {isAdmin ? ( + + + {(!collapsed || mobile) && ( + 사용자 페이지 + )} + + ) : ( + + + {(!collapsed || mobile) && ( + 관리자 + )} + + )} +
+ + {/* Collapse toggle — desktop only (hidden inside mobile drawer) */} + {!mobile && ( +
+ +
+ )} +
+
+ ); + + return ( + <> + {/* ── Mobile: overlay backdrop + drawer ──────────────────────── */} + {/* Backdrop */} +