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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 16 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ deploy/
|------|------|
| Runtime | Node.js 22, TypeScript 5.x |
| Bot | discord.js v14, feedsmith (RSS 파서), pg-boss (PostgreSQL 잡 큐), Sentry (에러 모니터링) |
| Web | Next.js 16 App Router, React 19, shadcn/ui, Tailwind CSS v4, Tiptap (리치 에디터), sonner (토스트), Framer Motion (랜딩 애니메이션), Firebase (FCM 푸시 알림), Sentry (에러 모니터링) |
| Web | Next.js 16 App Router, React 19, shadcn/ui, Tailwind CSS v4, Tiptap (리치 에디터), sonner (토스트), Framer Motion (랜딩 애니메이션), Firebase (FCM 푸시 알림), Sentry (에러 모니터링), Cloudflare R2 (이미지 스토리지) |
| DB | Supabase PostgreSQL + Drizzle ORM (Transaction Pooler, `prepare: false`) |
| Auth | Supabase Auth (Discord OAuth) + `@supabase/ssr` |
| 배포 | AWS EC2 Docker (bot), Vercel (web), Supabase (DB + Auth) |
Expand Down Expand Up @@ -64,6 +64,15 @@ pnpm --filter @blog-study/bot rss-collect # 수동 RSS 수집 (봇 없이)
- **블로그 URL 수정**: 프로필 수정 시 blogUrl 변경 가능, 변경 시 rssUrl을 null 초기화 후 `after()`로 RSS 비동기 재감지
- **Discord 알림**: 웹에서 직접 Discord REST API 호출 시 `discord-notify.ts` 유틸 사용, 사용자 입력은 `escapeDiscordMarkdown()` 적용, `allowed_mentions: { parse: [] }` 필수
- **댓글 길이**: 최대 5000자 제한 (API에서 검증)
- **이미지 업로드**: Cloudflare R2 (`board-images/{userId}/{uuid}.{ext}`), 5MB 제한, rate limit 20회/분/유저
- **포스트 수동등록**: 2단계 UX (URL→미리보기→편집→등록), OG HTML 엔티티 자동 디코딩, Discord 알림 토글
- **포스트 수정**: 본인 또는 관리자만 제목/설명 수정 가능 (`PATCH /api/posts/[id]`)
- **공지 알림**: 게시판 공지 작성 시 FCM 푸시 + Discord 공지채널(`notice_channel_id`) `@everyone` + 웹 딥링크 버튼
- **벌금 DM**: 계좌 정보 포함 (3333333114501 카카오뱅크), 납부완료 시 관리자 채널 알림
- **D-Day 계산**: KST 기준 (`+09:00` 명시), 제출률은 active 유저만 카운트
- **랭킹**: active + OB + dormant 전원 표시, 웹 페이지 4위부터 (포디움과 분리), 주간랭킹 전원 나열
- **RSS 수집**: active + OB (rssConsent=true만), 포스트 점수는 active만 부여
- **Discord 버튼**: `discord-notify.ts`에 `components` (Link Button) + `allowEveryone` 옵션 지원
- **백그라운드 작업**: API route에서 푸시 알림/점수 부여 등 fire-and-forget 작업은 `after()` from `next/server` 사용 (Vercel 서버리스 종료 방지)
- **비밀댓글 알림**: 비밀댓글(`isSecret`)의 푸시 알림은 내용 마스킹 (`'비밀 댓글이 달렸습니다.'`), 포스트/게시판 댓글 모두 적용
- **비밀댓글 isSecret 토글**: PATCH 시 본인만 변경 가능 (관리자도 타인 비밀 상태 변경 불가)
Expand Down Expand Up @@ -126,7 +135,12 @@ pnpm --filter @blog-study/bot rss-collect # 수동 RSS 수집 (봇 없이)
| `packages/bot/src/scripts/setup-channels.ts` | 디스코드 채널 일괄 생성 스크립트 |
| `packages/bot/src/scripts/list-channels.ts` | 서버 채널 구조 조회 스크립트 |
| `packages/bot/src/api-server.ts` | 봇 HTTP API 서버 (Express, 수동 트리거 엔드포인트, rate limit 10/min) |
| `packages/web/src/lib/discord-notify.ts` | Discord REST API 채널 메시지 전송 유틸 (웹→Discord 직접 알림) |
| `packages/web/src/lib/discord-notify.ts` | Discord REST API 채널 메시지 전송 유틸 (웹→Discord 직접 알림, 버튼/embed/allowEveryone 지원) |
| `packages/web/src/lib/r2.ts` | Cloudflare R2 업로드/삭제 유틸 (`uploadToR2`, `deleteFromR2`) |
| `packages/web/src/app/api/board/image/route.ts` | 게시판 이미지 업로드 API (R2, 5MB, JPEG/PNG/GIF/WebP) |
| `packages/web/src/app/api/posts/preview/route.ts` | 포스트 URL OG 미리보기 API (제목/설명/썸네일 추출) |
| `packages/web/src/components/board/image-block.tsx` | Tiptap ImageBlock 커스텀 노드 (리사이즈, 삭제, 캡션) |
| `packages/web/src/components/board/image-drop-plugin.ts` | Tiptap 이미지 드래그앤드롭 + 붙여넣기 플러그인 |
| `packages/bot/src/services/round.service.ts` | 회차 관리 + ConfigKeys (announcement/notice/curation/admin_notification 채널) |
| `packages/web/src/components/landing/landing-client.tsx` | 랜딩 페이지 클라이언트 (7섹션: Hero, Stats, Bento, HowItWorks, Marquee, CTA, Footer) |
| `packages/web/src/components/landing/motion.tsx` | 랜딩 애니메이션 컴포넌트 (FadeUp, StaggerContainer, CountUp, DrawLine) |
Expand Down
6 changes: 4 additions & 2 deletions packages/bot/src/handlers/dm-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@ async function handleButtonInteraction(interaction: Interaction): Promise<void>
.where(eq(rounds.id, paidFine.roundId))
.limit(1);

const displayName = member.nickname || member.name;
const displayName = member.name || member.nickname;
const reason = formatFineReason(paidFine.type as 'late' | 'absent');
const roundText = round ? `${round.roundNumber}회차` : '';

Expand All @@ -170,7 +170,7 @@ async function handleButtonInteraction(interaction: Interaction): Promise<void>
const channel = await interaction.client.channels.fetch(logChannelId).catch(() => null);
if (channel && channel.isTextBased() && !channel.isDMBased()) {
await (channel as TextChannel).send(
`💰 **${displayName}**님이 ${roundText} ${reason} 벌금 ${paidFine.amount.toLocaleString()}원 납부를 완료했습니다. 확인해주세요.`
`💰 **${displayName}**님이 ${roundText} ${reason} 벌금 ${paidFine.amount.toLocaleString()}원 납부를 완료했습니다.`
);
}
}
Expand Down Expand Up @@ -219,6 +219,7 @@ export async function sendFineNotification(
``,
`💰 **금액**: ${amount.toLocaleString()}원`,
`📝 **사유**: ${reason}`,
`🏦 **계좌**: 3333333114501 (카카오뱅크)`,
``,
`납부 완료 후 아래 버튼을 클릭해주세요.`,
].join('\n');
Expand Down Expand Up @@ -277,6 +278,7 @@ export async function sendFineReminder(
`(${daysSinceCreation}일 경과)`,
``,
`💰 **금액**: ${amount.toLocaleString()}원`,
`🏦 **계좌**: 3333333114501 (카카오뱅크)`,
``,
`납부 완료 후 아래 버튼을 클릭해주세요.`,
].join('\n');
Expand Down
16 changes: 9 additions & 7 deletions packages/bot/src/scheduler-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,13 +123,15 @@ export async function registerAllJobs(boss: PgBoss, client: Client): Promise<voi
}
}

// 블로그 포스트 점수 부여 (+30점, 일일 2편 상한)
const safeTitle = item.title.replace(/[<>"'&]/g, '').slice(0, 200);
await scoreService.grantScore(
member.id,
ActivityScoreType.BLOG_POST,
`블로그 포스트: ${safeTitle}`,
);
// 블로그 포스트 점수 부여 (+30점, 일일 2편 상한) — active 유저만
if (member.status === 'active') {
const safeTitle = item.title.replace(/[<>"'&]/g, '').slice(0, 200);
await scoreService.grantScore(
member.id,
ActivityScoreType.BLOG_POST,
`블로그 포스트: ${safeTitle}`,
);
}

await notificationService.sendPostNotification({
member,
Expand Down
25 changes: 18 additions & 7 deletions packages/bot/src/schedulers/rss-poller.property.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,25 +101,33 @@ describe('RSS Poller Property Tests', () => {
)
);

// Filter to only active members (what the service should return)
// Filter to active and OB members (what the service should return)
const activeMembers = allMembers.filter(
m => m.status === MemberStatus.ACTIVE
);
const obMembers = allMembers.filter(
m => m.status === MemberStatus.OB
);

// Mock the service to return only active members
mockGetAllByStatus.mockResolvedValue(activeMembers);
// Mock the service to return active and OB members separately
mockGetAllByStatus.mockImplementation(async (status: string) => {
if (status === MemberStatus.ACTIVE) return activeMembers;
if (status === MemberStatus.OB) return obMembers;
return [];
});

// Get members to poll
const membersToPoll = await poller.getMembersToPoll();

// Verify: all returned members should be active AND have RSS URL
// Verify: all returned members should be active or OB AND have RSS URL
for (const member of membersToPoll) {
expect(member.status).toBe(MemberStatus.ACTIVE);
expect([MemberStatus.ACTIVE, MemberStatus.OB]).toContain(member.status);
expect(member.rssUrl).not.toBeNull();
}

// Verify: the service was called with ACTIVE status
// Verify: the service was called with both ACTIVE and OB
expect(mockGetAllByStatus).toHaveBeenCalledWith(MemberStatus.ACTIVE);
expect(mockGetAllByStatus).toHaveBeenCalledWith(MemberStatus.OB);
}
),
{ numRuns: 100 }
Expand Down Expand Up @@ -148,7 +156,10 @@ describe('RSS Poller Property Tests', () => {
)
);

mockGetAllByStatus.mockResolvedValue(activeMembers);
mockGetAllByStatus.mockImplementation(async (status: string) => {
if (status === MemberStatus.ACTIVE) return activeMembers;
return []; // OB returns empty for this test
});

const membersToPoll = await poller.getMembersToPoll();

Expand Down
11 changes: 7 additions & 4 deletions packages/bot/src/schedulers/rss-poller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,14 +51,17 @@ export class RssPoller {

/**
* Get members that should be polled
* Requirements: 6.2 - Only active members should be polled
* Active + OB members with RSS consent
*/
async getMembersToPoll(): Promise<Member[]> {
const memberService = getMemberService();
const activeMembers = await memberService.getAllByStatus(MemberStatus.ACTIVE);

const [activeMembers, obMembers] = await Promise.all([
memberService.getAllByStatus(MemberStatus.ACTIVE),
memberService.getAllByStatus(MemberStatus.OB),
]);

// Filter to only members with RSS URLs and RSS consent
return activeMembers.filter(member => member.rssUrl && member.rssConsent !== false);
return [...activeMembers, ...obMembers].filter(member => member.rssUrl && member.rssConsent !== false);
}

/**
Expand Down
53 changes: 35 additions & 18 deletions packages/bot/src/schedulers/weekly-ranking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
*/

import { bold, Client, EmbedBuilder } from 'discord.js';
import { count, eq, sql } from 'drizzle-orm';
import { count, eq, inArray, sql } from 'drizzle-orm';
import logger from '../lib/logger';
import { activityScores, ActivityScoreType, getDb, members, MemberStatus, posts } from '@blog-study/shared/db';
import { ConfigKeys, getConfigValue } from '../services/round.service';
Expand Down Expand Up @@ -53,7 +53,7 @@ async function getMemberRankings(): Promise<MemberRanking[]> {
})
.from(members)
.leftJoin(posts, eq(members.id, posts.memberId))
.where(eq(members.status, MemberStatus.ACTIVE))
.where(inArray(members.status, [MemberStatus.ACTIVE, MemberStatus.OB, MemberStatus.DORMANT]))
.groupBy(members.id);

// 전체 누적 점수 (주간 필터 없음 — 웹 랭킹과 동일)
Expand Down Expand Up @@ -127,28 +127,45 @@ function createRankingEmbed(rankings: MemberRanking[]): EmbedBuilder {
});
}

// 전체 랭킹 (Top 15)
const displayRankings = rankings.slice(0, 15);
let rankingText = '';

for (const r of displayRankings) {
// 전체 랭킹 (전원 표시, 1024자 제한 시 여러 field로 분할)
const rankingLines: string[] = [];
for (const r of rankings) {
const rankDisplay = r.rank <= 3 ? ['🥇', '🥈', '🥉'][r.rank - 1] ?? `${r.rank}.` : `${r.rank}.`;
const activity = r.webActivityScore > 0 ? ` | 활동 ${r.webActivityScore}pt` : '';
rankingText += `${rankDisplay} <@${r.discordId}> — ${r.totalScore}pt (포스트 ${r.postCount}개${activity})\n`;
rankingLines.push(`${rankDisplay} <@${r.discordId}> — ${r.totalScore}pt (포스트 ${r.postCount}개${activity})`);
}

if (rankings.length > 15) {
rankingText += `\n_외 ${rankings.length - 15}명_`;
if (rankingLines.length === 0) {
embed.addFields({
name: `# 📊 전체 랭킹`,
value: '등록된 멤버가 없습니다.',
inline: false,
});
} else {
// 1024자 제한 대응: 여러 field로 분할
let chunk = '';
let isFirst = true;
for (const line of rankingLines) {
if (chunk.length + line.length + 1 > 1000) {
embed.addFields({
name: isFirst ? `# 📊 전체 랭킹 (${rankings.length}명)` : '\u200b',
value: chunk,
inline: false,
});
chunk = '';
isFirst = false;
}
chunk += (chunk ? '\n' : '') + line;
}
if (chunk) {
embed.addFields({
name: isFirst ? `# 📊 전체 랭킹 (${rankings.length}명)` : '\u200b',
value: chunk,
inline: false,
});
}
}

embed.addFields({
name: `# 📊 전체 랭킹 (${rankings.length}명)`,
value: (rankingText || '등록된 멤버가 없습니다.').length > 1024
? rankingText.substring(0, 1021) + '...'
: rankingText || '등록된 멤버가 없습니다.',
inline: false,
});

return embed;
}

Expand Down
12 changes: 3 additions & 9 deletions packages/bot/src/services/ranking.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,8 @@
* 주간/월간 랭킹, 포디움 추출
*/

import { and, count, eq, inArray, sql } from 'drizzle-orm';
import {
getDb,
members,
posts,
activityScores,
MemberStatus,
} from '@blog-study/shared/db';
import { and, count, inArray, sql } from 'drizzle-orm';
import { activityScores, getDb, members, MemberStatus, posts, } from '@blog-study/shared/db';

/**
* 블로그 포스트 점수 (포스트 1개당 부여되는 점수)
Expand Down Expand Up @@ -61,7 +55,7 @@ export class RankingService {
part: members.part,
})
.from(members)
.where(eq(members.status, MemberStatus.ACTIVE));
.where(inArray(members.status, [MemberStatus.ACTIVE, MemberStatus.OB, MemberStatus.DORMANT]));

if (activeMembers.length === 0) {
return [];
Expand Down
28 changes: 14 additions & 14 deletions packages/shared/src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,19 +91,19 @@ export const members = pgTable(
name: varchar('name', { length: 50 }).notNull(),
nickname: varchar('nickname', { length: 100 }).notNull(),
part: varchar('part', { length: 50 }).notNull(),
blogUrl: varchar('blog_url', { length: 500 }).notNull(),
rssUrl: varchar('rss_url', { length: 500 }),
blogUrl: varchar('blog_url', { length: 2000 }).notNull(),
rssUrl: varchar('rss_url', { length: 2000 }),
// 프로필 정보 (온보딩)
profileImageUrl: varchar('profile_image_url', { length: 500 }),
profileImageUrl: varchar('profile_image_url', { length: 2000 }),
bio: varchar('bio', { length: 200 }),
interests: text('interests').array(),
resolution: varchar('resolution', { length: 300 }),
onboardingCompleted: boolean('onboarding_completed').default(false),
rssConsent: boolean('rss_consent').default(true),
// 소셜 링크
githubUrl: varchar('github_url', { length: 500 }),
linkedinUrl: varchar('linkedin_url', { length: 500 }),
instagramUrl: varchar('instagram_url', { length: 500 }),
githubUrl: varchar('github_url', { length: 2000 }),
linkedinUrl: varchar('linkedin_url', { length: 2000 }),
instagramUrl: varchar('instagram_url', { length: 2000 }),
// 상태 관리
status: varchar('status', { length: 20 }).notNull().default(MemberStatus.ACTIVE),
dormantStartRound: integer('dormant_start_round'),
Expand Down Expand Up @@ -143,11 +143,11 @@ export const posts = pgTable(
.notNull()
.references(() => members.id),
roundId: integer('round_id').references(() => rounds.id),
title: varchar('title', { length: 500 }).notNull(),
url: varchar('url', { length: 1000 }).notNull().unique(),
title: varchar('title', { length: 2000 }).notNull(),
url: varchar('url', { length: 2000 }).notNull().unique(),
publishedAt: timestamp('published_at', { withTimezone: true }).notNull(),
description: text('description'),
thumbnailUrl: varchar('thumbnail_url', { length: 1000 }),
thumbnailUrl: varchar('thumbnail_url', { length: 2000 }),
commentCount: integer('comment_count').default(0),
collectedAt: timestamp('collected_at', { withTimezone: true }).defaultNow(),
},
Expand Down Expand Up @@ -232,10 +232,10 @@ export const keywords = pgTable('keywords', {
*/
export const curationSources = pgTable('curation_sources', {
id: uuid('id').primaryKey().defaultRandom(),
url: varchar('url', { length: 500 }).notNull().unique(),
url: varchar('url', { length: 2000 }).notNull().unique(),
name: varchar('name', { length: 200 }).notNull(),
category: varchar('category', { length: 50 }).notNull(),
rssUrl: varchar('rss_url', { length: 500 }),
rssUrl: varchar('rss_url', { length: 2000 }),
tags: text('tags').array(),
isActive: boolean('is_active').default(true),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(),
Expand All @@ -250,10 +250,10 @@ export const curationItems = pgTable(
{
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(),
title: varchar('title', { length: 2000 }).notNull(),
url: varchar('url', { length: 2000 }).notNull().unique(),
description: text('description'),
thumbnailUrl: varchar('thumbnail_url', { length: 1000 }),
thumbnailUrl: varchar('thumbnail_url', { length: 2000 }),
publishedAt: timestamp('published_at', { withTimezone: true }),
category: varchar('category', { length: 50 }).notNull(),
tags: text('tags').array(),
Expand Down
1 change: 1 addition & 0 deletions packages/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"test:watch": "vitest"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.1013.0",
"@blog-study/shared": "workspace:*",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-avatar": "^1.1.11",
Expand Down
Loading
Loading