diff --git a/CLAUDE.md b/CLAUDE.md index c9be23a..6790fc1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -82,7 +82,10 @@ pnpm --filter @blog-study/bot rss-collect # 수동 RSS 수집 (봇 없이) | `packages/bot/src/bot.ts` | Discord 클라이언트 초기화 (이벤트 핸들러만) | | `packages/bot/src/job-queue.ts` | pg-boss 싱글톤 (시작/종료/조회) | | `packages/bot/src/scheduler-registry.ts` | 잡 등록 + RSS→Post→Notification 파이프라인 | -| `packages/bot/src/services/score.service.ts` | 활동 점수 계산/부여 | +| `packages/bot/src/services/score.service.ts` | 활동 점수 계산/부여 (봇: blog_post만) | +| `packages/web/src/lib/score.ts` | 웹 활동 점수 부여 (board_post, post_comment, board_comment, post_view) | +| `packages/web/src/lib/score-config.ts` | 활동 점수 타입별 메타데이터 (Single Source of Truth: 라벨, 이모지, 배점, 뱃지 컬러) | +| `packages/web/src/app/(user)/profile/activity/page.tsx` | 활동 내역 페이지 (타입별 필터, 무한 로드) | | `packages/web/src/lib/board-auth.ts` | 게시판 인증 헬퍼 (`getBoardAuth`) | | `packages/web/src/lib/board-config.ts` | 게시판 카테고리/뱃지 설정 | | `packages/web/src/lib/api-error.ts` | API 표준 응답/에러 헬퍼 (`successResponse`, `Errors`, `withCache`) | diff --git a/docs/26-03-06-schema-summary.md b/docs/26-03-06-schema-summary.md index 99e46cb..93755d9 100644 --- a/docs/26-03-06-schema-summary.md +++ b/docs/26-03-06-schema-summary.md @@ -11,7 +11,7 @@ | `FineType` | `late`, `absent` | 벌금 사유 | | `FineStatus` | `unpaid`, `paid`, `waived` | 벌금 납부 | | `CurationCategory` | `conference`, `article` | 큐레이션 분류 | -| `ActivityScoreType` | `blog_post`, `discord_message`, `discord_thread`, `discord_reaction`, `admin_manual`, `post_view` | 활동 점수 | +| `ActivityScoreType` | `blog_post`, `board_post`, `post_comment`, `board_comment`, `admin_manual`, `post_view` | 활동 점수 | | `BoardCategory` | `notice`, `suggestion`, `review`, `knowledge`, `daily`, `etc` | 게시판 카테고리 (const object) | ## 테이블 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 3b4535f..2603b1e 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,6 +1,6 @@ # Blog Study Admin - 시스템 아키텍처 -> 최종 업데이트: 2026-03-13 (v7) +> 최종 업데이트: 2026-03-16 (v8) 블로그 글쓰기 스터디 운영 자동화 플랫폼. 웹 대시보드에서 모든 관리/유저 기능을 제공하고, Discord 봇은 스케줄러(RSS 수집/출석/벌금/큐레이션)와 이벤트 핸들러만 담당한다. @@ -205,7 +205,7 @@ flowchart TD C -->|No| A C -->|Yes| D["posts 테이블 저장"] D --> E["출석 상태 업데이트"] - E --> F["활동 점수 부여"] + E --> F["활동 점수 부여 (봇: blog_post)"] F --> G["Discord 채널 알림"] ``` diff --git a/docs/ONBOARDING.md b/docs/ONBOARDING.md index f66a4a1..d2640bd 100644 --- a/docs/ONBOARDING.md +++ b/docs/ONBOARDING.md @@ -225,17 +225,18 @@ pnpm --filter @blog-study/shared build 봇이 Discord 서버에서 실시간으로 감지하는 이벤트들입니다. -**Activity Handler** (`handlers/activity-handler.ts`): -Discord 활동을 감지해서 자동으로 점수를 부여합니다. +**웹 활동 점수** (`web/src/lib/score.ts`): +웹에서 활동 시 자동으로 점수를 부여합니다. (v2: 디스코드 → 웹 활동으로 전환, 2026-03-16) -| 이벤트 | 조건 | 점수 | -|--------|------|------| -| `MessageCreate` (채널) | 10자 이상, 봇 아님, 스터디원 | +2 (DISCORD_MESSAGE) | -| `MessageCreate` (스레드) | 10자 이상, 봇 아님, 스터디원 | +3 (DISCORD_THREAD) | -| `MessageReactionAdd` | 봇 아님, 자기 글 아님, 스터디원 | +1 (DISCORD_REACTION) | +| 활동 | 조건 | 점수 | 일일 상한 | +|------|------|------|----------| +| 게시판 글 작성 | 인증된 사용자 | +10 (BOARD_POST) | 20 | +| 블로그 글 댓글 | 인증된 사용자 | +5 (POST_COMMENT) | 20 | +| 게시판 댓글 | 인증된 사용자 | +2 (BOARD_COMMENT) | 10 | +| 글 조회 (타인 글) | 중복 불가, 본인 글 제외 | +3 (POST_VIEW) | 15 | -- Discord ID → `members.discord_id` 매칭으로 스터디원 여부 확인 - 일일 상한 초과 시 점수 미부여 (원자적 CTE 쿼리로 race condition 방지) +- 점수 부여 실패해도 본 기능(글/댓글 작성)에는 영향 없음 (fire-and-forget) **DM Handler** (`handlers/dm-handler.ts`): 벌금 납부 확인을 위한 DM 대화 처리입니다. diff --git a/docs/plans/26-02-26-activity-scoring-design.md b/docs/plans/26-02-26-activity-scoring-design.md index 49c8fec..2345f62 100644 --- a/docs/plans/26-02-26-activity-scoring-design.md +++ b/docs/plans/26-02-26-activity-scoring-design.md @@ -1,38 +1,55 @@ -# 디스코드 활동 점수 시스템 디자인 +# 활동 점수 시스템 디자인 ## 목표 -스터디원 간 경쟁심리 자극 + 스터디 종료 시 Top 3 보상 기반. 블로그 포스팅이 핵심, 디스코드 활동이 보조. +스터디원 간 경쟁심리 자극 + 스터디 종료 시 Top 3 보상 기반. 블로그 포스팅이 핵심, 웹 활동이 보조. ## 점수 체계 -| 카테고리 | 활동 | 점수 | 일일 상한 | -|---------|------|------|----------| -| 블로그 | 포스트 발행 | +30점 | 60점 (2편) | -| 디스코드 | 메시지 작성 (10자+) | +2점 | 10점 (5개) | -| 디스코드 | 스레드 댓글 | +3점 | 9점 (3개) | -| 디스코드 | 리액션 달기 | +1점 | 5점 (5개) | -| 관리자 | 수동 부여/차감 | 자유 | 없음 | +| 카테고리 | 활동 | 점수 | 일일 상한 | 부여 위치 | +|---------|------|------|----------|----------| +| 블로그 | 포스트 발행 (RSS 수집) | +30점 | 60점 (2편) | 봇 | +| 웹 | 게시판 글 작성 | +10점 | 20점 (2편) | 웹 API | +| 웹 | 블로그 글 댓글 작성 | +5점 | 20점 (4개) | 웹 API | +| 웹 | 게시판 댓글 작성 | +2점 | 10점 (5개) | 웹 API | +| 웹 | 글 조회 (타인 글) | +3점 | 15점 (5개) | 웹 API | +| 관리자 | 수동 부여/차감 | 자유 | 없음 | 웹 API | -디스코드 일일 최대: 24점. 블로그 1편(30점) > 디스코드 1일분(24점). +웹 일일 최대: 65점. 블로그 1편(30점)이 여전히 핵심. -## 어뷰징 방지 -- 봇 메시지/자기 리액션 무시 -- 메시지 최소 10자 -- 일일 상한으로 도배 방지 +## 변경 이력 + +### v2 (2026-03-16): 디스코드 → 웹 활동으로 전환 +- **제거**: `discord_message` (+2), `discord_thread` (+3), `discord_reaction` (+1) — 봇 이벤트 핸들러 기반 +- **추가**: `board_post` (+10), `post_comment` (+5), `board_comment` (+2) — 웹 API에서 직접 DB write +- **변경**: `post_view` 2→3점, 상한 10→15점 +- **이유**: 디스코드 활동 추적은 MessageContent Intent 필요 + DB 반영 지연. 웹에서 직접 점수 부여하면 즉시 반영 + 구현 단순화 + +### v1 (2026-02-26): 최초 설계 +- 디스코드 활동(메시지/스레드/리액션) 기반 점수 체계 ## DB 스키마: activity_scores 테이블 -- id, memberId, type (blog_post/discord_message/discord_thread/discord_reaction/admin_manual) +- id, memberId, type (`blog_post`/`board_post`/`post_comment`/`board_comment`/`post_view`/`admin_manual`) - points, description, date, createdAt - memberId+type+date에 대한 일일 상한 체크용 인덱스 -## 기능 -1. **봇**: 디스코드 이벤트 감지 → 점수 자동 적립 (일일 상한 체크) -2. **봇**: 블로그 포스트 RSS 수집 시 → 점수 자동 적립 -3. **웹 관리자**: 수동 점수 부여/차감 + 사유 입력 -4. **웹 사용자/관리자**: 점수 내역 조회 -5. **랭킹**: 기존 랭킹에 총점 반영 - -## 구현 범위 -- shared: DB 스키마 + 마이그레이션 -- bot: intent 추가 + 이벤트 핸들러 + RSS 콜백 연동 -- web: 관리자 점수 부여 UI + API, 점수 내역 조회 UI + API, 랭킹 총점 반영 +## 어뷰징 방지 +- 본인 글 조회 점수 미부여 +- 같은 글 중복 조회 불가 (post_views UNIQUE) +- 일일 상한으로 도배 방지 + +## 점수 부여 위치 +1. **봇**: 블로그 포스트 RSS 수집 시 → `blog_post` 점수 자동 적립 +2. **웹 API**: 게시판 글 작성 시 → `board_post` 점수 부여 (`/api/board` POST) +3. **웹 API**: 블로그 글 댓글 작성 시 → `post_comment` 점수 부여 (`/api/posts/[id]/comments` POST) +4. **웹 API**: 게시판 댓글 작성 시 → `board_comment` 점수 부여 (`/api/board/[id]/comments` POST) +5. **웹 API**: 글 조회 시 → `post_view` 점수 부여 (`/api/posts/[id]/view` POST) +6. **웹 관리자**: 수동 점수 부여/차감 + 사유 입력 + +## 구현 파일 +- `packages/shared/src/db/schema.ts` — ActivityScoreType enum +- `packages/bot/src/services/score.service.ts` — SCORE_CONFIG (봇용) +- `packages/web/src/lib/score.ts` — grantWebScore (웹용) +- `packages/web/src/app/api/board/route.ts` — 게시판 글 점수 +- `packages/web/src/app/api/posts/[id]/comments/route.ts` — 블로그 댓글 점수 +- `packages/web/src/app/api/board/[id]/comments/route.ts` — 게시판 댓글 점수 +- `packages/web/src/app/api/posts/[id]/view/route.ts` — 글 조회 점수 diff --git a/packages/bot/src/bot.ts b/packages/bot/src/bot.ts index 78d51c5..15d4b1c 100644 --- a/packages/bot/src/bot.ts +++ b/packages/bot/src/bot.ts @@ -11,10 +11,6 @@ export function createBotClient(): Client { GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages, GatewayIntentBits.DirectMessages, - // MessageContent Intent 없이 버튼/인터랙션 방식으로 동작 - // 봇이 100개 미만 서버라 Intent 활성화 불가능 - // GatewayIntentBits.MessageContent, - GatewayIntentBits.GuildMessageReactions, ], }); diff --git a/packages/bot/src/handlers/activity-handler.ts b/packages/bot/src/handlers/activity-handler.ts deleted file mode 100644 index 1655d48..0000000 --- a/packages/bot/src/handlers/activity-handler.ts +++ /dev/null @@ -1,72 +0,0 @@ -/** - * Discord Activity Score Handler - * 메시지, 스레드 댓글, 리액션 이벤트를 감지하여 점수를 부여 - */ - -import type { Client, Message } from 'discord.js'; -import { Events } from 'discord.js'; -import { ActivityScoreType } from '@blog-study/shared/db'; -import { getScoreService } from '../services/score.service'; -import logger, { serializeError } from '../lib/logger'; - -const MIN_MESSAGE_LENGTH = 10; - -/** - * 디스코드 활동 점수 이벤트 핸들러 등록 - */ -export function setupActivityHandler(client: Client): void { - const scoreService = getScoreService(); - - // 메시지 작성 (채널 메시지 + 스레드 댓글 구분) - client.on(Events.MessageCreate, async (message: Message) => { - try { - // 봇 메시지 무시 - if (message.author.bot) return; - - // DM 무시 - if (!message.guild) return; - - // 최소 글자수 미달 - if (message.content.length < MIN_MESSAGE_LENGTH) return; - - const memberId = await scoreService.getMemberIdByDiscordId(message.author.id); - if (!memberId) return; // 스터디원이 아닌 경우 - - // 스레드 안에서의 메시지 = 스레드 댓글 (+3점) - // 일반 채널 메시지 = 메시지 (+2점) - const isThread = message.channel.isThread(); - const type = isThread - ? ActivityScoreType.DISCORD_THREAD - : ActivityScoreType.DISCORD_MESSAGE; - - await scoreService.grantScore(memberId, type); - } catch (error) { - logger.error({ error: serializeError(error) }, 'Activity score error (message)'); - } - }); - - // 리액션 달기 - client.on(Events.MessageReactionAdd, async (reaction, user) => { - try { - // 봇 리액션 무시 - if (user.bot) return; - - // partial인 경우 fetch - if (reaction.partial) { - await reaction.fetch(); - } - - // 자기 글에 자기가 리액션 → 무시 - if (reaction.message.author?.id === user.id) return; - - const memberId = await scoreService.getMemberIdByDiscordId(user.id); - if (!memberId) return; - - await scoreService.grantScore(memberId, ActivityScoreType.DISCORD_REACTION); - } catch (error) { - logger.error({ error: serializeError(error) }, 'Activity score error (reaction)'); - } - }); - - logger.info('Activity score handler registered'); -} diff --git a/packages/bot/src/handlers/handlers.test.ts b/packages/bot/src/handlers/handlers.test.ts index c022d8a..9c8dc51 100644 --- a/packages/bot/src/handlers/handlers.test.ts +++ b/packages/bot/src/handlers/handlers.test.ts @@ -5,7 +5,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { Client, Events } from 'discord.js'; -import { setupActivityHandler } from './activity-handler'; import { setupDMHandler } from './dm-handler'; // Mock dependencies @@ -14,13 +13,6 @@ vi.mock('@blog-study/shared/db', () => ({ fines: {}, })); -vi.mock('../services/score.service', () => ({ - getScoreService: vi.fn(() => ({ - getMemberIdByDiscordId: vi.fn(() => Promise.resolve('member-123')), - grantScore: vi.fn(() => Promise.resolve()), - })), -})); - vi.mock('../services', () => ({ getFineService: vi.fn(() => ({ markPaid: vi.fn(() => Promise.resolve()), @@ -29,45 +21,6 @@ vi.mock('../services', () => ({ })); describe('Handlers Integration Tests', () => { - describe('setupActivityHandler', () => { - let mockClient: Client; - let onSpy: ReturnType; - - beforeEach(() => { - onSpy = vi.fn(); - mockClient = { - on: onSpy, - } as unknown as Client; - }); - - afterEach(() => { - vi.clearAllMocks(); - }); - - describe('Property 1: 이벤트 리스너 등록', () => { - it('should register MessageCreate event listener', () => { - setupActivityHandler(mockClient); - expect(onSpy).toHaveBeenCalledWith(Events.MessageCreate, expect.any(Function)); - }); - - it('should register MessageReactionAdd event listener', () => { - setupActivityHandler(mockClient); - expect(onSpy).toHaveBeenCalledWith(Events.MessageReactionAdd, expect.any(Function)); - }); - - it('should register exactly 2 event listeners', () => { - setupActivityHandler(mockClient); - expect(onSpy).toHaveBeenCalledTimes(2); - }); - }); - - describe('Property 2: 핸들러 동작 확인', () => { - it('should call handler function without throwing', () => { - expect(() => setupActivityHandler(mockClient)).not.toThrow(); - }); - }); - }); - describe('setupDMHandler', () => { let mockClient: Client; let onSpy: ReturnType; diff --git a/packages/bot/src/index.ts b/packages/bot/src/index.ts index dbf6909..2b83c4e 100644 --- a/packages/bot/src/index.ts +++ b/packages/bot/src/index.ts @@ -8,7 +8,7 @@ import { loadBotEnv } from '@blog-study/shared'; import { createBotClient, setupEventHandlers, setupGracefulShutdown, startBot } from './bot'; import { startJobQueue, stopJobQueue } from './job-queue'; import { registerAllJobs } from './scheduler-registry'; -import { setupActivityHandler } from './handlers/activity-handler'; + import { setupDMHandler } from './handlers/dm-handler'; import { initNotificationService } from './services/notification.service'; import { startBotApiServer } from './api-server'; @@ -28,7 +28,6 @@ async function main(): Promise { // Setup event handlers setupEventHandlers(client); - setupActivityHandler(client); setupDMHandler(client); logger.debug('Event handlers configured'); diff --git a/packages/bot/src/schedulers/weekly-ranking.ts b/packages/bot/src/schedulers/weekly-ranking.ts index 1058ed2..1772360 100644 --- a/packages/bot/src/schedulers/weekly-ranking.ts +++ b/packages/bot/src/schedulers/weekly-ranking.ts @@ -90,20 +90,18 @@ async function getMemberRankings(): Promise { .groupBy(members.id); // Get activity scores for each member - // P0 #5: 이번 주 기간 점수만 필터링 + // 이번 주 기간 점수만 필터링 (date 컬럼 사용 — KST 날짜 문자열) const weekDates = getWeekDates(); - const weekStartDate = weekDates.startDate + 'T00:00:00.000+09:00'; - const weekEndDate = weekDates.endDate + 'T23:59:59.999+09:00'; const scoreStats = await db .select({ memberId: activityScores.memberId, totalScore: sql`COALESCE(SUM(${activityScores.points}), 0)`, - discordScore: sql`COALESCE(SUM(CASE WHEN ${activityScores.type} IN (${ActivityScoreType.DISCORD_MESSAGE}, ${ActivityScoreType.DISCORD_THREAD}, ${ActivityScoreType.DISCORD_REACTION}) THEN ${activityScores.points} ELSE 0 END), 0)`, + discordScore: sql`COALESCE(SUM(CASE WHEN ${activityScores.type} IN (${ActivityScoreType.BOARD_POST}, ${ActivityScoreType.POST_COMMENT}, ${ActivityScoreType.BOARD_COMMENT}, ${ActivityScoreType.POST_VIEW}) THEN ${activityScores.points} ELSE 0 END), 0)`, }) .from(activityScores) .where( - sql`${activityScores.createdAt} >= ${weekStartDate} AND ${activityScores.createdAt} <= ${weekEndDate}` + sql`${activityScores.date} >= ${weekDates.startDate} AND ${activityScores.date} <= ${weekDates.endDate}` ) .groupBy(activityScores.memberId); @@ -174,8 +172,8 @@ function createRankingEmbed(rankings: MemberRanking[]): EmbedBuilder { const r = top3[i]!; const medal = medals[i] ?? '🏅'; const name = r.nickname || r.name; - const discordScore = r.discordScore > 0 ? ` | 디스코드 ${r.discordScore}점` : ''; - podiumText += `${medal} ${bold(name)} - 총 ${r.totalScore}점 (포스트 ${r.postCount}개${discordScore})\n`; + const webActivity = r.discordScore > 0 ? ` | 활동 ${r.discordScore}점` : ''; + podiumText += `${medal} ${bold(name)} - 총 ${r.totalScore}점 (포스트 ${r.postCount}개${webActivity})\n`; } embed.addFields({ @@ -192,8 +190,8 @@ function createRankingEmbed(rankings: MemberRanking[]): EmbedBuilder { for (const r of displayRankings) { const name = r.nickname || r.name; const rankDisplay = r.rank <= 3 ? ['🥇', '🥈', '🥉'][r.rank - 1] ?? `${r.rank}위` : `${r.rank}위`; - const discordScore = r.discordScore > 0 ? ` | 디스코드 ${r.discordScore}점` : ''; - rankingText += `${rankDisplay} ${bold(name)} - 총 ${r.totalScore}점 (포스트 ${r.postCount}개${discordScore})\n`; + const webActivity = r.discordScore > 0 ? ` | 활동 ${r.discordScore}점` : ''; + rankingText += `${rankDisplay} ${bold(name)} - 총 ${r.totalScore}점 (포스트 ${r.postCount}개${webActivity})\n`; } if (rankings.length > 15) { diff --git a/packages/bot/src/services/notification.service.ts b/packages/bot/src/services/notification.service.ts index 50eb097..1679d17 100644 --- a/packages/bot/src/services/notification.service.ts +++ b/packages/bot/src/services/notification.service.ts @@ -251,7 +251,7 @@ export function buildRoundStartEmbed(round: Round, activeCount: number): EmbedBu .setTitle(`🚀 ${round.roundNumber}회차 시작!`) .setDescription([ `이번 회차에 **${activeCount}명**이 함께합니다.`, - '이번 회차도 화이팅! 좋은 글 기대하고 있을게요 🔥', + '이번 회차도 화이팅! 짧은 글이라도 괜찮아요 😌', ].join('\n')) .addFields( { diff --git a/packages/bot/src/services/score.service.ts b/packages/bot/src/services/score.service.ts index 66a303c..5cdfde2 100644 --- a/packages/bot/src/services/score.service.ts +++ b/packages/bot/src/services/score.service.ts @@ -13,11 +13,11 @@ export const SCORE_CONFIG: Record< { points: number; dailyCap: number } > = { [ActivityScoreType.BLOG_POST]: { points: 30, dailyCap: 60 }, - [ActivityScoreType.DISCORD_MESSAGE]: { points: 2, dailyCap: 10 }, - [ActivityScoreType.DISCORD_THREAD]: { points: 3, dailyCap: 9 }, - [ActivityScoreType.DISCORD_REACTION]: { points: 1, dailyCap: 5 }, - [ActivityScoreType.ADMIN_MANUAL]: { points: 0, dailyCap: Infinity }, - [ActivityScoreType.POST_VIEW]: { points: 2, dailyCap: 10 }, + [ActivityScoreType.BOARD_POST]: { points: 10, dailyCap: 20 }, + [ActivityScoreType.POST_COMMENT]: { points: 5, dailyCap: 20 }, + [ActivityScoreType.BOARD_COMMENT]: { points: 2, dailyCap: 10 }, + [ActivityScoreType.ADMIN_MANUAL]: { points: 0, dailyCap: Infinity }, // points는 무시됨 — 실제 값은 호출자가 지정 + [ActivityScoreType.POST_VIEW]: { points: 3, dailyCap: 15 }, }; function getTodayDateString(): string { diff --git a/packages/shared/src/db/schema.ts b/packages/shared/src/db/schema.ts index 2586d6b..94b70cf 100644 --- a/packages/shared/src/db/schema.ts +++ b/packages/shared/src/db/schema.ts @@ -64,9 +64,9 @@ export type CurationCategoryType = (typeof CurationCategory)[keyof typeof Curati export const ActivityScoreType = { BLOG_POST: 'blog_post', - DISCORD_MESSAGE: 'discord_message', - DISCORD_THREAD: 'discord_thread', - DISCORD_REACTION: 'discord_reaction', + BOARD_POST: 'board_post', + POST_COMMENT: 'post_comment', + BOARD_COMMENT: 'board_comment', ADMIN_MANUAL: 'admin_manual', POST_VIEW: 'post_view', } as const; @@ -296,8 +296,8 @@ export const activityScores = pgTable( ); /** - * 글 조회 기록 (Post Views) - * 글 조회 점수 중복 방지용 + * 포스트 조회 기록 (Post Views) + * 포스트 조회 점수 중복 방지용 */ export const postViews = pgTable( 'post_views', diff --git a/packages/web/src/app/(admin)/admin/scores/page.tsx b/packages/web/src/app/(admin)/admin/scores/page.tsx index 08ca77b..cdec894 100644 --- a/packages/web/src/app/(admin)/admin/scores/page.tsx +++ b/packages/web/src/app/(admin)/admin/scores/page.tsx @@ -63,43 +63,18 @@ interface MemberScoreSummary { // ─── Constants ──────────────────────────────────────────────────────────────── -const ACTIVITY_TYPE_LABELS: Record = { - blog_post: '블로그 포스트', - discord_message: '디스코드 메시지', - discord_thread: '스레드 댓글', - discord_reaction: '리액션', - admin_manual: '관리자 부여', - post_view: '글 조회', -}; +import { getScoreTypeLabel, getScoreTypeBadgeClass } from '@/lib/score-config'; const TOP_MEMBERS_COUNT = 5; // ─── Helpers ────────────────────────────────────────────────────────────────── function getActivityTypeLabel(type: string): string { - return ACTIVITY_TYPE_LABELS[type] ?? type; + return getScoreTypeLabel(type); } function getActivityTypeBadgeClass(type: string): string { - if (type === 'admin_manual') { - return 'bg-sky-100 text-sky-700 border-sky-200'; - } - if (type === 'blog_post') { - return 'bg-violet-100 text-violet-700 border-violet-200'; - } - if (type === 'discord_message') { - return 'bg-indigo-100 text-indigo-700 border-indigo-200'; - } - if (type === 'discord_thread') { - return 'bg-blue-100 text-blue-700 border-blue-200'; - } - if (type === 'discord_reaction') { - return 'bg-cyan-100 text-cyan-700 border-cyan-200'; - } - if (type === 'post_view') { - return 'bg-teal-100 text-teal-700 border-teal-200'; - } - return 'bg-muted text-muted-foreground border-border'; + return getScoreTypeBadgeClass(type); } function formatDate(dateStr: string): string { diff --git a/packages/web/src/app/(user)/dashboard/page.tsx b/packages/web/src/app/(user)/dashboard/page.tsx index 4f897d2..80349bf 100644 --- a/packages/web/src/app/(user)/dashboard/page.tsx +++ b/packages/web/src/app/(user)/dashboard/page.tsx @@ -1,14 +1,15 @@ 'use client'; -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import Link from 'next/link'; -import { ArrowUpRight, Clock, FileText, Inbox, TrendingUp } from 'lucide-react'; +import { ArrowUpRight, Check, Clock, FileText, Inbox, TrendingUp, Zap } from 'lucide-react'; import { Card, CardContent, CardHeader } from '@/components/ui/card'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { DashboardSkeleton, PageError } from '@/components/ui/page-state'; -import { getDefaultAvatar } from '@/lib/utils'; +import { getDefaultAvatar, getTimeAgo } from '@/lib/utils'; +import { SCORE_TYPE_MAP } from '@/lib/score-config'; interface RoundInfo { roundNumber: number; @@ -40,6 +41,30 @@ interface DashboardData { totalPosts: number; } +interface ScoreProgress { + type: string; + label: string; + emoji: string; + points: number; + earned: number; + dailyCap: number; +} + +interface RecentScore { + id: string; + type: string; + points: number; + description: string | null; + createdAt: string; +} + +interface MyScoreData { + totalScore: number; + todayScore: number; + todayProgress: ScoreProgress[]; + recentActivity: RecentScore[]; +} + function getGreeting(): { emoji: string; text: string } { const hour = new Date().getHours(); if (hour < 6) return { emoji: '🌙', text: '새벽까지 글쓰기, 대단해요.' }; @@ -104,18 +129,25 @@ function getDdayLabel(days: number, isGrace: boolean): string { export default function DashboardPage() { const [data, setData] = useState(null); + const [scoreData, setScoreData] = 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 [dashRes, scoreRes] = await Promise.all([ + fetch('/api/dashboard'), + fetch('/api/scores/my'), + ]); + if (!dashRes.ok) throw new Error('Failed to fetch dashboard data'); + const dashResult = await dashRes.json(); + setData(dashResult.data ?? dashResult); + + if (scoreRes.ok) { + const scoreResult = await scoreRes.json(); + if (scoreResult.success) setScoreData(scoreResult.data); } - const result = await response.json(); - setData(result.data ?? result); } catch (err) { setError('대시보드 데이터를 불러오는데 실패했습니다.'); console.error(err); @@ -127,9 +159,9 @@ export default function DashboardPage() { fetchDashboard(); }, []); - const trackPostView = (postId: string) => { + const trackPostView = useCallback((postId: string) => { fetch(`/api/posts/${postId}/view`, { method: 'POST' }).catch(() => {}); - }; + }, []); if (loading) { return ; @@ -287,6 +319,132 @@ export default function DashboardPage() { + {/* My Activity Score */} + {scoreData && ( + + +
+
+
+ +
+
+

내 활동 점수

+

+ 오늘 +{scoreData.todayScore}pt 획득 +

+
+
+
+

+ {scoreData.totalScore.toLocaleString()} + pt +

+
+
+
+ + {/* Today Progress */} +
+ {scoreData.todayProgress.map((p) => { + const isFull = p.earned >= p.dailyCap; + const pct = Math.min((p.earned / p.dailyCap) * 100, 100); + return ( +
+ {p.emoji} +
+
+ {p.label} + + +{p.points}pt + +
+
+
+
+
+ + {p.earned}/{p.dailyCap} + + {isFull && } +
+
+
+ ); + })} +
+ + {/* Recent Activity */} + {scoreData.recentActivity.length > 0 && ( +
+
+

+ 최근 활동 +

+ +
+
+ {scoreData.recentActivity.map((activity) => { + const typeInfo = SCORE_TYPE_MAP.get(activity.type) ?? { + label: activity.type, + emoji: '⭐', + }; + const timeAgo = getTimeAgo(activity.createdAt); + return ( +
+ {typeInfo.emoji} +
+

+ {activity.description || typeInfo.label} +

+

{timeAgo}

+
+ = 0 ? 'text-emerald-600' : 'text-rose-500' + }`} + > + {activity.points >= 0 ? '+' : ''} + {activity.points} + +
+ ); + })} +
+
+ )} + + + )} + {/* Recent Posts */} diff --git a/packages/web/src/app/(user)/posts/page.tsx b/packages/web/src/app/(user)/posts/page.tsx index 65af8f1..f0e677d 100644 --- a/packages/web/src/app/(user)/posts/page.tsx +++ b/packages/web/src/app/(user)/posts/page.tsx @@ -1075,7 +1075,7 @@ function PostsContent() { } if (!response.ok) { - setSubmitError(result.message || '등록에 실패했습니다.'); + setSubmitError(result.error?.message || result.message || '등록에 실패했습니다.'); return; } diff --git a/packages/web/src/app/(user)/profile/activity/page.tsx b/packages/web/src/app/(user)/profile/activity/page.tsx new file mode 100644 index 0000000..99b4f21 --- /dev/null +++ b/packages/web/src/app/(user)/profile/activity/page.tsx @@ -0,0 +1,226 @@ +'use client'; + +import { useCallback, useEffect, useState } from 'react'; +import Link from 'next/link'; +import { ArrowLeft, Zap } from 'lucide-react'; +import { Card, CardContent, CardHeader } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { PageError } from '@/components/ui/page-state'; +import { cn, getTimeAgo } from '@/lib/utils'; +import { SCORE_TYPE_MAP, SCORE_TYPE_META } from '@/lib/score-config'; + +interface ScoreRecord { + id: string; + type: string; + points: number; + description: string | null; + date: string; + createdAt: string; +} + +function formatDate(dateStr: string): string { + return new Date(dateStr).toLocaleDateString('ko-KR', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + }); +} + +export default function ActivityPage() { + const [records, setRecords] = useState([]); + const [totalScore, setTotalScore] = useState(0); + const [total, setTotal] = useState(0); + const [offset, setOffset] = useState(0); + const [loading, setLoading] = useState(true); + const [loadingMore, setLoadingMore] = useState(false); + const [error, setError] = useState(null); + const [filterType, setFilterType] = useState(null); + + const fetchRecords = useCallback(async ( + offsetVal: number, + append: boolean, + type: string | null, + signal?: AbortSignal, + ) => { + try { + if (append) { + setLoadingMore(true); + } else { + setLoading(true); + } + const typeParam = type ? `&type=${encodeURIComponent(type)}` : ''; + const res = await fetch(`/api/scores?limit=20&offset=${offsetVal}${typeParam}`, { signal }); + if (!res.ok) throw new Error('Failed to fetch'); + const result = await res.json(); + if (result.success) { + setRecords((prev) => append ? [...prev, ...result.data.records] : result.data.records); + setTotalScore(result.data.totalScore ?? 0); + setTotal(result.data.total ?? 0); + setOffset(offsetVal + (result.data.records?.length ?? 0)); + } + } catch (err) { + if (err instanceof DOMException && err.name === 'AbortError') return; + if (!append) setError('활동 내역을 불러오는데 실패했습니다.'); + } finally { + setLoading(false); + setLoadingMore(false); + } + }, []); + + useEffect(() => { + const controller = new AbortController(); + setLoading(true); + setRecords([]); + fetchRecords(0, false, filterType, controller.signal); + return () => controller.abort(); + }, [fetchRecords, filterType]); + + const handleFilterChange = (type: string | null) => { + setFilterType(type); + }; + + if (error) return ; + + const filterTypes = [ + { key: null, label: '전체' }, + ...SCORE_TYPE_META.map((m) => ({ key: m.type, label: `${m.emoji} ${m.label}` })), + ]; + + return ( +
+ {/* Header */} +
+ + + 프로필 + +
+
+

+ Activity +

+

활동 내역

+
+
+

+ {totalScore.toLocaleString()} + pt +

+

누적 점수

+
+
+
+ + {/* Filter */} +
+ {filterTypes.map((f) => ( + + ))} +
+ + {/* Records */} + + +
+
+ +

+ {filterType ? (SCORE_TYPE_MAP.get(filterType)?.label ?? filterType) : '전체'} 내역 +

+
+ {total}건 +
+
+ + {loading ? ( +
+ {Array.from({ length: 8 }).map((_, i) => ( +
+
+
+
+
+ ))} +
+ ) : records.length === 0 ? ( +
+ +

아직 활동 내역이 없습니다.

+
+ ) : ( +
+
+ {records.map((record) => { + const config = SCORE_TYPE_MAP.get(record.type) ?? { + label: record.type, + emoji: '⭐', + badgeClass: 'bg-muted text-muted-foreground border-border', + }; + return ( +
+ + {config.emoji} {config.label} + +
+

+ {record.description || config.label} +

+

+ {getTimeAgo(record.createdAt)} · {formatDate(record.date)} +

+
+ = 0 ? 'text-emerald-600' : 'text-rose-500' + )} + > + {record.points >= 0 ? '+' : ''} + {record.points}pt + +
+ ); + })} +
+ {records.length < total && ( + + )} +
+ )} + + +
+ ); +} diff --git a/packages/web/src/app/(user)/profile/page.tsx b/packages/web/src/app/(user)/profile/page.tsx index 019934a..26376d8 100644 --- a/packages/web/src/app/(user)/profile/page.tsx +++ b/packages/web/src/app/(user)/profile/page.tsx @@ -2,7 +2,9 @@ import { useCallback, useEffect, useState } from 'react'; import { useRouter } from 'next/navigation'; +import Link from 'next/link'; import { + ArrowUpRight, Calendar, CheckCircle, ExternalLink, @@ -15,6 +17,7 @@ import { LogOut, User, Wallet, + Zap, } from 'lucide-react'; import { Card, CardContent, CardHeader } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; @@ -399,6 +402,27 @@ export default function ProfilePage() {
)} + {/* Activity History Link */} + + + +
+
+ +
+

활동 내역

+
+ + 전체보기 + + + +
+
+ {/* Edit Profile & Withdraw Buttons */} {data.member.onboardingCompleted && (
diff --git a/packages/web/src/app/(user)/ranking/page.tsx b/packages/web/src/app/(user)/ranking/page.tsx index a75944e..7e7d239 100644 --- a/packages/web/src/app/(user)/ranking/page.tsx +++ b/packages/web/src/app/(user)/ranking/page.tsx @@ -38,11 +38,11 @@ interface AttendanceRecord { status: string; } -interface DiscordScore { +interface WebActivityScore { total: number; - message: number; - thread: number; - reaction: number; + message: number; // boardPost + thread: number; // postComment + reaction: number; // boardComment } interface RankingMember { @@ -60,7 +60,7 @@ interface RankingMember { attendanceHistory: AttendanceRecord[]; rankDelta: number; totalScore: number; - discordScore: DiscordScore; + discordScore: WebActivityScore; } interface CurrentRound { @@ -108,7 +108,7 @@ function getSecondaryText(member: RankingMember, sort: SortKey): string | null { case 'score': return `${member.postCount}개 · ${member.discordScore.total > 0 ? `활동 ${member.discordScore.total}pt` : ''}`; case 'activity': - return `메시지 ${member.discordScore.message} · 스레드 ${member.discordScore.thread} · 리액션 ${member.discordScore.reaction}`; + return `게시글 ${member.discordScore.message} · 댓글 ${member.discordScore.thread} · 게시판댓글 ${member.discordScore.reaction}`; case 'posts': default: return member.totalScore > 0 ? `${member.totalScore}pt` : null; @@ -392,11 +392,11 @@ function RankingRow({ member, rank, isMe, myRankGapText, sortBy }: RankingRowPro {sortBy === 'activity' ? (
- 메시지 {member.discordScore.message} + 게시글 {member.discordScore.message} · - 스레드 {member.discordScore.thread} + 댓글 {member.discordScore.thread} · - 리액션 {member.discordScore.reaction} + 게시판댓글 {member.discordScore.reaction}
) : (
diff --git a/packages/web/src/app/api/board/[id]/comments/route.ts b/packages/web/src/app/api/board/[id]/comments/route.ts index 20ffc7c..94227c5 100644 --- a/packages/web/src/app/api/board/[id]/comments/route.ts +++ b/packages/web/src/app/api/board/[id]/comments/route.ts @@ -1,16 +1,15 @@ import { NextRequest } from 'next/server'; -import { eq, and, isNull, sql } from 'drizzle-orm'; +import { and, eq, isNull, sql } from 'drizzle-orm'; import { getDb } from '@/lib/db'; import { db as sharedDb } from '@blog-study/shared'; import { getBoardAuth } from '@/lib/board-auth'; -import { successResponse, errorResponse, Errors } from '@/lib/api-error'; +import { errorResponse, Errors, successResponse } from '@/lib/api-error'; +import { grantWebScore } from '@/lib/score'; +import { sanitizeDescription } from '@/lib/sanitize'; -const { boardPosts, boardComments } = sharedDb; +const { boardPosts, boardComments, ActivityScoreType } = sharedDb; -export async function POST( - request: NextRequest, - { params }: { params: Promise<{ id: string }> } -) { +export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { try { const auth = await getBoardAuth(); if (!auth) return Errors.unauthorized().toResponse(); @@ -44,10 +43,7 @@ export async function POST( isSecret: boardComments.isSecret, }) .from(boardComments) - .where(and( - eq(boardComments.id, parentId), - eq(boardComments.postId, postId), - )) + .where(and(eq(boardComments.id, parentId), eq(boardComments.postId, postId))) .limit(1); if (!parent) return Errors.badRequest('상위 댓글을 찾을 수 없습니다.').toResponse(); @@ -57,7 +53,9 @@ export async function POST( const isCommentOwner = parent.memberId === auth.memberId; const isPostAuthor = post.memberId === auth.memberId; if (!isCommentOwner && !isPostAuthor && !auth.isAdmin) { - return Errors.forbidden('비밀 댓글에는 작성자, 글 작성자, 관리자만 답글을 달 수 있습니다.').toResponse(); + return Errors.forbidden( + '비밀 댓글에는 작성자, 글 작성자, 관리자만 답글을 달 수 있습니다.' + ).toResponse(); } // 비밀댓글 대댓글은 강제 비밀 isSecret = true; @@ -81,6 +79,15 @@ export async function POST( .set({ commentCount: sql`${boardPosts.commentCount} + 1` }) .where(eq(boardPosts.id, postId)); + // 게시판 댓글 활동 점수 (+2, 일일 상한 10) — 본인 글 제외 + if (post.memberId !== auth.memberId) { + grantWebScore( + auth.memberId, + ActivityScoreType.BOARD_COMMENT, + sanitizeDescription(content.trim().slice(0, 50)) + ).catch((err) => console.error('[score] grantWebScore failed:', err)); + } + return successResponse(newComment, '댓글이 작성되었습니다.', 201); } catch (error) { return errorResponse(error); diff --git a/packages/web/src/app/api/board/route.ts b/packages/web/src/app/api/board/route.ts index d1c90a4..a00e4d3 100644 --- a/packages/web/src/app/api/board/route.ts +++ b/packages/web/src/app/api/board/route.ts @@ -12,9 +12,10 @@ import { } from '@/lib/api-error'; import { getAdminDiscordIds } from '@/lib/admin'; import { isValidCategory } from '@/lib/board-config'; -import { sanitizeTiptapContent } from '@/lib/sanitize'; +import { sanitizeDescription, sanitizeTiptapContent } from '@/lib/sanitize'; +import { grantWebScore } from '@/lib/score'; -const { boardPosts, members, boardPolls, boardPollOptions } = sharedDb; +const { boardPosts, members, boardPolls, boardPollOptions, ActivityScoreType } = sharedDb; export async function GET(request: NextRequest) { try { @@ -249,6 +250,13 @@ export async function POST(request: NextRequest) { return post; }); + // 게시판 글 작성 활동 점수 (+10, 일일 상한 20) + grantWebScore( + auth.memberId, + ActivityScoreType.BOARD_POST, + sanitizeDescription(title.trim().slice(0, 50)) + ).catch((err) => console.error('[score] grantWebScore failed:', err)); + return successResponse(result, '게시글이 작성되었습니다.', 201); } catch (error) { return errorResponse(error); diff --git a/packages/web/src/app/api/posts/[id]/comments/route.ts b/packages/web/src/app/api/posts/[id]/comments/route.ts index 4997d53..fa5d1d4 100644 --- a/packages/web/src/app/api/posts/[id]/comments/route.ts +++ b/packages/web/src/app/api/posts/[id]/comments/route.ts @@ -5,8 +5,10 @@ import { db as sharedDb } from '@blog-study/shared'; import { getBoardAuth } from '@/lib/board-auth'; import { getAdminDiscordIds } from '@/lib/admin'; import { errorResponse, Errors, successResponse } from '@/lib/api-error'; +import { grantWebScore } from '@/lib/score'; +import { sanitizeDescription } from '@/lib/sanitize'; -const { posts, postComments, members } = sharedDb; +const { posts, postComments, members, ActivityScoreType } = sharedDb; /** * GET /api/posts/[id]/comments @@ -91,7 +93,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ const database = getDb(); const [post] = await database - .select({ id: posts.id }) + .select({ id: posts.id, memberId: posts.memberId }) .from(posts) .where(eq(posts.id, postId)) .limit(1); @@ -110,7 +112,13 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ const [parent] = await database .select({ id: postComments.id }) .from(postComments) - .where(and(eq(postComments.id, parentId), eq(postComments.postId, postId), isNull(postComments.deletedAt))) + .where( + and( + eq(postComments.id, parentId), + eq(postComments.postId, postId), + isNull(postComments.deletedAt) + ) + ) .limit(1); if (!parent) return Errors.badRequest('상위 댓글을 찾을 수 없습니다.').toResponse(); } @@ -130,6 +138,31 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ .set({ commentCount: sql`${posts.commentCount} + 1` }) .where(eq(posts.id, postId)); + // 포스트 댓글 활동 점수 (+5, 일일 상한 20) + // — 본인 글 제외, 같은 포스트에 이미 댓글 달았으면 제외 (포스트당 1회) + if (post.memberId !== auth.memberId) { + const priorComments = await database + .select({ id: postComments.id }) + .from(postComments) + .where( + and( + eq(postComments.postId, postId), + eq(postComments.memberId, auth.memberId), + isNull(postComments.deletedAt) + ) + ) + .limit(2); + + // 방금 작성한 댓글 포함해서 1개뿐이면 = 첫 댓글 → 점수 부여 + if (priorComments.length <= 1) { + grantWebScore( + auth.memberId, + ActivityScoreType.POST_COMMENT, + sanitizeDescription(content.trim().slice(0, 50)) + ).catch((err) => console.error('[score] grantWebScore failed:', err)); + } + } + return successResponse(newComment, '댓글이 작성되었습니다.', 201); } catch (error) { return errorResponse(error); diff --git a/packages/web/src/app/api/posts/[id]/view/route.ts b/packages/web/src/app/api/posts/[id]/view/route.ts index b01f0ff..538d09a 100644 --- a/packages/web/src/app/api/posts/[id]/view/route.ts +++ b/packages/web/src/app/api/posts/[id]/view/route.ts @@ -1,49 +1,39 @@ import { NextRequest, NextResponse } from 'next/server'; import { eq, sql } from 'drizzle-orm'; -import { db } from '@/lib/db'; +import { getDb } from '@/lib/db'; import { db as sharedDb } from '@blog-study/shared'; import { createClient } from '@/lib/supabase/server'; +import { getTodayDateString, SCORE_CONFIG } from '@/lib/score'; const { posts, members, activityScores, ActivityScoreType } = sharedDb; -const POST_VIEW_POINTS = 2; -const POST_VIEW_DAILY_CAP = 10; - -function getTodayDateString(): string { - const now = new Date(); - const kst = new Date(now.getTime() + 9 * 60 * 60 * 1000); - return kst.toISOString().split('T')[0]!; -} - /** * POST /api/posts/[id]/view - * 글 조회 시 활동 점수 부여 + * 포스트 조회 시 활동 점수 부여 * - 본인 글 제외 * - 같은 글 중복 조회 불가 (post_views UNIQUE) - * - 하루 최대 5회 (10점) + * - 단일 CTE로 post_views insert + 점수 부여를 원자적으로 처리 */ -export async function POST( - _request: NextRequest, - { params }: { params: Promise<{ id: string }> } -) { +export async function POST(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) { try { const { id: postId } = await params; const supabase = await createClient(); - const { data: { user }, error: authError } = await supabase.auth.getUser(); + const { + data: { user }, + error: authError, + } = await supabase.auth.getUser(); if (authError || !user) { return NextResponse.json({ scored: false }, { status: 401 }); } - const discordIdentity = user.identities?.find( - (identity) => identity.provider === 'discord' - ); + const discordIdentity = user.identities?.find((identity) => identity.provider === 'discord'); const discordId = discordIdentity?.id; if (!discordId) { return NextResponse.json({ scored: false }, { status: 400 }); } - const database = db(); + const database = getDb(); const [member] = await database .select({ id: members.id }) @@ -71,23 +61,20 @@ export async function POST( return NextResponse.json({ scored: false, reason: 'own_post' }); } - // 중복 조회 체크 + 삽입 (INSERT ON CONFLICT DO NOTHING) - const insertResult = await database.execute(sql` - INSERT INTO post_views (id, member_id, post_id) - VALUES (gen_random_uuid(), ${member.id}, ${postId}) - ON CONFLICT (member_id, post_id) DO NOTHING - RETURNING id - `); - - if (insertResult.length === 0) { - return NextResponse.json({ scored: false, reason: 'already_viewed' }); - } - - // 일일 상한 체크 후 점수 부여 (원자적 CTE) - const today = getTodayDateString(); + const config = SCORE_CONFIG[ActivityScoreType.POST_VIEW]; const safeTitle = post.title.replace(/[<>"'&]/g, '').slice(0, 200); - const scoreResult = await database.execute(sql` - WITH daily AS ( + const desc = safeTitle; + const today = getTodayDateString(); + + // 단일 CTE: post_views insert + 일일 상한 체크 + 점수 부여 (원자적) + const result = await database.execute(sql` + WITH view_insert AS ( + INSERT INTO post_views (id, member_id, post_id) + VALUES (gen_random_uuid(), ${member.id}, ${postId}) + ON CONFLICT (member_id, post_id) DO NOTHING + RETURNING id + ), + daily AS ( SELECT COALESCE(SUM(${activityScores.points}), 0) AS total FROM ${activityScores} WHERE ${activityScores.memberId} = ${member.id} @@ -95,19 +82,19 @@ export async function POST( AND ${activityScores.date} = ${today} ) INSERT INTO activity_scores (id, member_id, type, points, description, date) - SELECT gen_random_uuid(), ${member.id}, ${ActivityScoreType.POST_VIEW}, ${POST_VIEW_POINTS}, - ${`글 조회: ${safeTitle}`}, ${today} - FROM daily - WHERE daily.total < ${POST_VIEW_DAILY_CAP} + SELECT gen_random_uuid(), ${member.id}, ${ActivityScoreType.POST_VIEW}, ${config.points}, + ${desc}, ${today} + FROM view_insert, daily + WHERE daily.total < ${config.dailyCap} RETURNING points `); - const scored = scoreResult.length > 0; + const scored = result.length > 0; return NextResponse.json({ scored, - points: scored ? POST_VIEW_POINTS : 0, - reason: scored ? 'success' : 'daily_cap', + points: scored ? config.points : 0, + reason: scored ? 'success' : 'already_viewed_or_daily_cap', }); } catch (error) { console.error('Post view API error:', error); diff --git a/packages/web/src/app/api/ranking/route.ts b/packages/web/src/app/api/ranking/route.ts index cac656f..34b297f 100644 --- a/packages/web/src/app/api/ranking/route.ts +++ b/packages/web/src/app/api/ranking/route.ts @@ -229,8 +229,9 @@ export async function GET(request: NextRequest) { // Calculate streaks const streakMap = await calculateStreaks(database); - // Get activity scores per member: total + discord-only (graceful if table doesn't exist yet) + // Get activity scores per member: total + web activity breakdown (graceful if table doesn't exist yet) let scoreMap = new Map(); + // Keys: total=웹활동합계, message=게시판글, thread=포스트댓글, reaction=게시판댓글 (레거시 키명, 클라이언트 호환) let discordScoreMap = new Map< string, { total: number; message: number; thread: number; reaction: number } @@ -243,10 +244,11 @@ export async function GET(request: NextRequest) { .select({ memberId: activityScores.memberId, totalScore: sql`COALESCE(SUM(${activityScores.points}), 0)`, - discordScore: sql`COALESCE(SUM(CASE WHEN ${activityScores.type} IN ('discord_message','discord_thread','discord_reaction') THEN ${activityScores.points} ELSE 0 END), 0)`, - messageScore: sql`COALESCE(SUM(CASE WHEN ${activityScores.type} = 'discord_message' THEN ${activityScores.points} ELSE 0 END), 0)`, - threadScore: sql`COALESCE(SUM(CASE WHEN ${activityScores.type} = 'discord_thread' THEN ${activityScores.points} ELSE 0 END), 0)`, - reactionScore: sql`COALESCE(SUM(CASE WHEN ${activityScores.type} = 'discord_reaction' THEN ${activityScores.points} ELSE 0 END), 0)`, + webActivityScore: sql`COALESCE(SUM(CASE WHEN ${activityScores.type} IN ('board_post','post_comment','board_comment','post_view') THEN ${activityScores.points} ELSE 0 END), 0)`, + boardPostScore: sql`COALESCE(SUM(CASE WHEN ${activityScores.type} = 'board_post' THEN ${activityScores.points} ELSE 0 END), 0)`, + postCommentScore: sql`COALESCE(SUM(CASE WHEN ${activityScores.type} = 'post_comment' THEN ${activityScores.points} ELSE 0 END), 0)`, + boardCommentScore: sql`COALESCE(SUM(CASE WHEN ${activityScores.type} = 'board_comment' THEN ${activityScores.points} ELSE 0 END), 0)`, + postViewScore: sql`COALESCE(SUM(CASE WHEN ${activityScores.type} = 'post_view' THEN ${activityScores.points} ELSE 0 END), 0)`, }) .from(activityScores) .groupBy(activityScores.memberId); @@ -256,10 +258,10 @@ export async function GET(request: NextRequest) { scoreStats.map((s) => [ s.memberId, { - total: Number(s.discordScore), - message: Number(s.messageScore), - thread: Number(s.threadScore), - reaction: Number(s.reactionScore), + total: Number(s.webActivityScore), + message: Number(s.boardPostScore), + thread: Number(s.postCommentScore), + reaction: Number(s.boardCommentScore), }, ]) ); @@ -270,7 +272,7 @@ export async function GET(request: NextRequest) { .select({ memberId: activityScores.memberId, roundScore: sql`COALESCE(SUM(${activityScores.points}), 0)`, - roundDiscordScore: sql`COALESCE(SUM(CASE WHEN ${activityScores.type} IN ('discord_message','discord_thread','discord_reaction') THEN ${activityScores.points} ELSE 0 END), 0)`, + roundDiscordScore: sql`COALESCE(SUM(CASE WHEN ${activityScores.type} IN ('board_post','post_comment','board_comment','post_view') THEN ${activityScores.points} ELSE 0 END), 0)`, }) .from(activityScores) .where( diff --git a/packages/web/src/app/api/scores/my/route.ts b/packages/web/src/app/api/scores/my/route.ts new file mode 100644 index 0000000..7924f98 --- /dev/null +++ b/packages/web/src/app/api/scores/my/route.ts @@ -0,0 +1,101 @@ +import { desc, eq, sql } from 'drizzle-orm'; +import { getDb } from '@/lib/db'; +import { db as sharedDb } from '@blog-study/shared'; +import { errorResponse, Errors, successResponse } from '@/lib/api-error'; +import { createClient } from '@/lib/supabase/server'; +import { getTodayDateString } from '@/lib/score'; +import { SCORE_TYPE_META } from '@/lib/score-config'; + +const { activityScores, members } = sharedDb; + +/** 대시보드 프로그레스에 표시할 타입 (관리자 수동 제외) */ +const SCORE_TYPES = SCORE_TYPE_META.filter((m) => m.type !== 'admin_manual'); + +/** + * GET /api/scores/my + * 대시보드용: 내 총점 + 오늘 활동별 진행 상황 + 최근 활동 5개 + */ +export async function GET() { + try { + const supabase = await createClient(); + const { data: { user }, error: authError } = await supabase.auth.getUser(); + if (authError || !user) { + return Errors.unauthorized().toResponse(); + } + + const discordIdentity = user.identities?.find((i) => i.provider === 'discord'); + const discordId = discordIdentity?.id; + if (!discordId) { + return Errors.badRequest('Discord 연결이 필요합니다.').toResponse(); + } + + const database = getDb(); + + const [member] = await database + .select({ id: members.id }) + .from(members) + .where(eq(members.discordId, discordId)) + .limit(1); + + if (!member) { + return successResponse({ totalScore: 0, todayScore: 0, todayProgress: [], recentActivity: [] }); + } + + const today = getTodayDateString(); + + // 총점 + 오늘 타입별 합산을 단일 쿼리로 조회 + 최근 활동 5개 병렬 실행 + const [scoreStats, recentActivity] = await Promise.all([ + database.execute(sql` + SELECT + COALESCE(SUM(${activityScores.points}), 0)::int AS total_score, + COALESCE(SUM(CASE WHEN ${activityScores.date} = ${today} THEN ${activityScores.points} ELSE 0 END), 0)::int AS today_total, + ${activityScores.type} AS type, + COALESCE(SUM(CASE WHEN ${activityScores.date} = ${today} THEN ${activityScores.points} ELSE 0 END), 0)::int AS today_earned + FROM ${activityScores} + WHERE ${activityScores.memberId} = ${member.id} + GROUP BY ${activityScores.type} + `), + database + .select({ + id: activityScores.id, + type: activityScores.type, + points: activityScores.points, + description: activityScores.description, + createdAt: activityScores.createdAt, + }) + .from(activityScores) + .where(eq(activityScores.memberId, member.id)) + .orderBy(desc(activityScores.createdAt)) + .limit(5), + ]); + + // 결과 파싱 + let totalScore = 0; + const todayMap = new Map(); + for (const row of scoreStats as unknown as Array<{ total_score: number; type: string; today_earned: number }>) { + totalScore += Number(row.total_score); + todayMap.set(row.type, Number(row.today_earned)); + } + + const todayProgress = SCORE_TYPES.map((config) => ({ + type: config.type, + label: config.label, + emoji: config.emoji, + points: config.points, + earned: todayMap.get(config.type) ?? 0, + dailyCap: config.dailyCap, + })); + + const todayScore = todayProgress.reduce((sum, p) => sum + p.earned, 0); + + return successResponse({ + totalScore, + todayScore, + todayProgress, + recentActivity, + }); + } catch (error) { + console.error('My scores API error:', error); + return errorResponse(error); + } +} diff --git a/packages/web/src/app/api/scores/route.ts b/packages/web/src/app/api/scores/route.ts index 9a5d637..6d26c45 100644 --- a/packages/web/src/app/api/scores/route.ts +++ b/packages/web/src/app/api/scores/route.ts @@ -1,5 +1,5 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { desc, eq, sql } from 'drizzle-orm'; +import { NextRequest } from 'next/server'; +import { and, desc, eq, sql } from 'drizzle-orm'; import { db } from '@/lib/db'; import { db as sharedDb } from '@blog-study/shared'; import { errorResponse, Errors, successResponse } from '@/lib/api-error'; @@ -53,7 +53,7 @@ export async function GET(request: NextRequest) { // 타인의 점수 → 관리자만 허용 const isAdmin = discordId ? await isAdminDiscordId(discordId) : false; if (!isAdmin) { - return NextResponse.json({ message: '자신의 점수만 조회할 수 있습니다.' }, { status: 403 }); + return Errors.forbidden('자신의 점수만 조회할 수 있습니다.').toResponse(); } } @@ -61,11 +61,19 @@ export async function GET(request: NextRequest) { return successResponse({ records: [], total: 0, totalScore: 0 }); } + // 타입 필터 (optional, allowlist 검증) + const typeFilter = searchParams.get('type'); + const validTypes = Object.values(sharedDb.ActivityScoreType) as string[]; + const safeTypeFilter = typeFilter && validTypes.includes(typeFilter) ? typeFilter : null; + const baseConditions = safeTypeFilter + ? and(eq(activityScores.memberId, memberId), eq(activityScores.type, safeTypeFilter)) + : eq(activityScores.memberId, memberId); + // 점수 내역 const records = await database .select() .from(activityScores) - .where(eq(activityScores.memberId, memberId)) + .where(baseConditions) .orderBy(desc(activityScores.createdAt)) .limit(limit) .offset(offset); @@ -74,9 +82,9 @@ export async function GET(request: NextRequest) { const countResult = await database .select({ count: sql`COUNT(*)` }) .from(activityScores) - .where(eq(activityScores.memberId, memberId)); + .where(baseConditions); - // 총점 + // 총점 (필터 무관하게 전체 총점) const scoreResult = await database .select({ total: sql`COALESCE(SUM(${activityScores.points}), 0)` }) .from(activityScores) diff --git a/packages/web/src/lib/score-config.ts b/packages/web/src/lib/score-config.ts new file mode 100644 index 0000000..ecffbea --- /dev/null +++ b/packages/web/src/lib/score-config.ts @@ -0,0 +1,43 @@ +/** + * 활동 점수 타입별 메타데이터 (Single Source of Truth) + * 대시보드, 프로필, 관리자 페이지, API에서 공통 사용 + * + * NOTE: 클라이언트 컴포넌트에서도 import하므로 서버 전용 모듈(@blog-study/shared/db)에 의존하지 않음 + */ + +export interface ScoreTypeMeta { + type: string; + label: string; + emoji: string; + points: number; + dailyCap: number; + badgeClass: string; +} + +/** 점수 타입별 메타데이터 (순서 = UI 표시 순서) */ +export const SCORE_TYPE_META: ScoreTypeMeta[] = [ + { type: 'blog_post', label: '블로그 포스트', emoji: '📝', points: 30, dailyCap: 60, badgeClass: 'bg-violet-100 text-violet-700 border-violet-200 dark:bg-violet-900/30 dark:text-violet-400 dark:border-violet-800' }, + { type: 'board_post', label: '게시판 글', emoji: '✏️', points: 10, dailyCap: 20, badgeClass: 'bg-indigo-100 text-indigo-700 border-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400 dark:border-indigo-800' }, + { type: 'post_comment', label: '포스트 댓글', emoji: '💬', points: 5, dailyCap: 20, badgeClass: 'bg-blue-100 text-blue-700 border-blue-200 dark:bg-blue-900/30 dark:text-blue-400 dark:border-blue-800' }, + { type: 'board_comment', label: '게시판 댓글', emoji: '💭', points: 2, dailyCap: 10, badgeClass: 'bg-cyan-100 text-cyan-700 border-cyan-200 dark:bg-cyan-900/30 dark:text-cyan-400 dark:border-cyan-800' }, + { type: 'post_view', label: '포스트 조회', emoji: '👀', points: 3, dailyCap: 15, badgeClass: 'bg-teal-100 text-teal-700 border-teal-200 dark:bg-teal-900/30 dark:text-teal-400 dark:border-teal-800' }, + { type: 'admin_manual', label: '관리자 부여', emoji: '🎁', points: 0, dailyCap: Infinity, badgeClass: 'bg-sky-100 text-sky-700 border-sky-200 dark:bg-sky-900/30 dark:text-sky-400 dark:border-sky-800' }, +]; + +/** 타입 → 메타데이터 맵 (빠른 조회용) */ +export const SCORE_TYPE_MAP = new Map(SCORE_TYPE_META.map((m) => [m.type, m])); + +/** 타입 → 라벨 */ +export function getScoreTypeLabel(type: string): string { + return SCORE_TYPE_MAP.get(type)?.label ?? type; +} + +/** 타입 → emoji */ +export function getScoreTypeEmoji(type: string): string { + return SCORE_TYPE_MAP.get(type)?.emoji ?? '⭐'; +} + +/** 타입 → 뱃지 클래스 */ +export function getScoreTypeBadgeClass(type: string): string { + return SCORE_TYPE_MAP.get(type)?.badgeClass ?? 'bg-muted text-muted-foreground border-border'; +} diff --git a/packages/web/src/lib/score.ts b/packages/web/src/lib/score.ts new file mode 100644 index 0000000..1b8181e --- /dev/null +++ b/packages/web/src/lib/score.ts @@ -0,0 +1,74 @@ +/** + * Web Activity Score Service + * 웹 활동(게시판 글, 댓글 등)에 대한 점수 부여 유틸리티 + */ + +import { eq, sql } from 'drizzle-orm'; +import { getDb } from '@/lib/db'; +import { db as sharedDb } from '@blog-study/shared'; + +const { activityScores, members, ActivityScoreType, MemberStatus } = sharedDb; + +export type WebScoreType = + | typeof ActivityScoreType.BOARD_POST + | typeof ActivityScoreType.POST_COMMENT + | typeof ActivityScoreType.BOARD_COMMENT + | typeof ActivityScoreType.POST_VIEW; + +/** 점수 배점 및 일일 상한 */ +export const SCORE_CONFIG: Record = { + [ActivityScoreType.BOARD_POST]: { points: 10, dailyCap: 20 }, + [ActivityScoreType.POST_COMMENT]: { points: 5, dailyCap: 20 }, + [ActivityScoreType.BOARD_COMMENT]: { points: 2, dailyCap: 10 }, + [ActivityScoreType.POST_VIEW]: { points: 3, dailyCap: 15 }, +}; + +export function getTodayDateString(): string { + const now = new Date(); + const kst = new Date(now.getTime() + 9 * 60 * 60 * 1000); + return kst.toISOString().split('T')[0]!; +} + +/** + * 웹 활동 점수 부여 (일일 상한 체크 포함, 원자적 CTE) + * @returns 실제 부여된 점수 (상한 초과 시 0) + */ +export async function grantWebScore( + memberId: string, + type: WebScoreType, + description?: string, +): Promise { + const config = SCORE_CONFIG[type]; + if (!config) return 0; + + const database = getDb(); + + // active 멤버만 점수 부여 + const [member] = await database + .select({ status: members.status }) + .from(members) + .where(eq(members.id, memberId)) + .limit(1); + if (!member || member.status !== MemberStatus.ACTIVE) return 0; + + const today = getTodayDateString(); + const points = config.points; + const desc = description ?? null; + + const result = await database.execute(sql` + WITH daily AS ( + SELECT COALESCE(SUM(${activityScores.points}), 0) AS total + FROM ${activityScores} + WHERE ${activityScores.memberId} = ${memberId} + AND ${activityScores.type} = ${type} + AND ${activityScores.date} = ${today} + ) + INSERT INTO activity_scores (id, member_id, type, points, description, date) + SELECT gen_random_uuid(), ${memberId}, ${type}, ${points}, ${desc}, ${today} + FROM daily + WHERE daily.total < ${config.dailyCap} + RETURNING points + `); + + return result.length > 0 ? points : 0; +} diff --git a/packages/web/src/lib/utils.ts b/packages/web/src/lib/utils.ts index d4fdac7..8bfea83 100644 --- a/packages/web/src/lib/utils.ts +++ b/packages/web/src/lib/utils.ts @@ -12,3 +12,17 @@ export function cn(...inputs: ClassValue[]) { export function getDefaultAvatar(seed: string): string { return `https://api.dicebear.com/9.x/fun-emoji/svg?seed=${encodeURIComponent(seed)}`; } + +/** + * 상대 시간 문자열 (예: "5분 전", "3시간 전") + */ +export function getTimeAgo(dateStr: string): string { + const now = Date.now(); + const then = new Date(dateStr).getTime(); + const diff = Math.floor((now - then) / 1000); + if (diff < 60) return '방금 전'; + if (diff < 3600) return `${Math.floor(diff / 60)}분 전`; + if (diff < 86400) return `${Math.floor(diff / 3600)}시간 전`; + if (diff < 604800) return `${Math.floor(diff / 86400)}일 전`; + return new Date(dateStr).toLocaleDateString('ko-KR'); +}