diff --git a/.serena/.gitignore b/.serena/.gitignore new file mode 100644 index 0000000..14d86ad --- /dev/null +++ b/.serena/.gitignore @@ -0,0 +1 @@ +/cache diff --git a/.serena/project.yml b/.serena/project.yml new file mode 100644 index 0000000..943a1c4 --- /dev/null +++ b/.serena/project.yml @@ -0,0 +1,37 @@ +project_name: "study-admin" + +languages: +- typescript + +encoding: "utf-8" + +ignore_all_files_in_gitignore: true + +ignored_paths: +- "**/node_modules/**" +- "**/dist/**" +- "**/.next/**" +- "**/packages/shared/drizzle/**" + +read_only: false + +excluded_tools: [] + +included_optional_tools: [] + +fixed_tools: [] + +base_modes: + +default_modes: + +initial_prompt: | + This is a blog study automation platform (monorepo with pnpm workspace). + - packages/bot: Discord bot (discord.js v14) + - packages/web: Next.js 14 dashboard (App Router, shadcn/ui) + - packages/shared: Shared DB schema (Drizzle ORM), types, utilities + Tech: TypeScript strict, Supabase PostgreSQL, Supabase Auth (Discord OAuth) + +symbol_info_budget: + +language_backend: diff --git a/CLAUDE.md b/CLAUDE.md index 3f4c720..8b39e2b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -56,6 +56,7 @@ pnpm --filter @blog-study/bot init-rounds # 회차 초기화 - **커밋**: 기존 git log 스타일 따름, Co-Authored-By 포함 - **한글 커맨드**: Discord 슬래시 명령어는 한글 (예: `/참가`, `/현황`) - **Drizzle SQL**: `packages/shared/drizzle/*.sql` 마이그레이션 파일은 로컬 전용 (`.gitignore`에 등록됨, 커밋 금지) +- **다이얼로그**: `window.confirm()`, `window.alert()` 사용 금지 → 커스텀 다이얼로그 컴포넌트 사용 (기존 `DeleteMemberDialog` 패턴 참고) ## 핵심 파일 위치 diff --git a/packages/bot/src/schedulers/rss-poller.ts b/packages/bot/src/schedulers/rss-poller.ts index 1057f23..b6244ca 100644 --- a/packages/bot/src/schedulers/rss-poller.ts +++ b/packages/bot/src/schedulers/rss-poller.ts @@ -56,8 +56,8 @@ export class RssPoller { const memberService = getMemberService(); const activeMembers = await memberService.getAllByStatus(MemberStatus.ACTIVE); - // Filter to only members with RSS URLs - return activeMembers.filter(member => member.rssUrl); + // Filter to only members with RSS URLs and RSS consent + return activeMembers.filter(member => member.rssUrl && member.rssConsent !== false); } /** diff --git a/packages/bot/src/services/score.service.ts b/packages/bot/src/services/score.service.ts index 4514916..66a303c 100644 --- a/packages/bot/src/services/score.service.ts +++ b/packages/bot/src/services/score.service.ts @@ -17,6 +17,7 @@ export const SCORE_CONFIG: Record< [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 }, }; function getTodayDateString(): string { diff --git a/packages/shared/src/db/schema.ts b/packages/shared/src/db/schema.ts index 41dd6af..96701f3 100644 --- a/packages/shared/src/db/schema.ts +++ b/packages/shared/src/db/schema.ts @@ -63,6 +63,7 @@ export const ActivityScoreType = { DISCORD_THREAD: 'discord_thread', DISCORD_REACTION: 'discord_reaction', ADMIN_MANUAL: 'admin_manual', + POST_VIEW: 'post_view', } as const; export type ActivityScoreTypeValue = (typeof ActivityScoreType)[keyof typeof ActivityScoreType]; @@ -92,6 +93,7 @@ export const members = pgTable( 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 }), @@ -279,6 +281,31 @@ export const activityScores = pgTable( }) ); +/** + * 글 조회 기록 (Post Views) + * 글 조회 점수 중복 방지용 + */ +export const postViews = pgTable( + 'post_views', + { + id: uuid('id').primaryKey().defaultRandom(), + memberId: uuid('member_id') + .notNull() + .references(() => members.id), + postId: uuid('post_id') + .notNull() + .references(() => posts.id), + viewedAt: timestamp('viewed_at', { withTimezone: true }).defaultNow(), + }, + (table) => ({ + memberPostUnique: uniqueIndex('post_views_member_post_unique').on( + table.memberId, + table.postId + ), + memberIdIdx: index('idx_post_views_member_id').on(table.memberId), + }) +); + /** * 설정 (Config) * 스터디 설정 키-값 저장소 @@ -298,6 +325,7 @@ export const membersRelations = relations(members, ({ many }) => ({ attendance: many(attendance), fines: many(fines), activityScores: many(activityScores), + postViews: many(postViews), })); export const roundsRelations = relations(rounds, ({ many }) => ({ @@ -346,6 +374,17 @@ export const activityScoresRelations = relations(activityScores, ({ one }) => ({ }), })); +export const postViewsRelations = relations(postViews, ({ one }) => ({ + member: one(members, { + fields: [postViews.memberId], + references: [members.id], + }), + post: one(posts, { + fields: [postViews.postId], + references: [posts.id], + }), +})); + export const curationSourcesRelations = relations(curationSources, ({ many }) => ({ items: many(curationItems), })); @@ -388,5 +427,8 @@ export type NewCurationItem = typeof curationItems.$inferInsert; export type ActivityScore = typeof activityScores.$inferSelect; export type NewActivityScore = typeof activityScores.$inferInsert; +export type PostView = typeof postViews.$inferSelect; +export type NewPostView = typeof postViews.$inferInsert; + export type Config = typeof config.$inferSelect; export type NewConfig = typeof config.$inferInsert; diff --git a/packages/web/package.json b/packages/web/package.json index ecbaca6..3146154 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -23,6 +23,7 @@ "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-tabs": "^1.1.13", "@supabase/ssr": "^0.8.0", "@supabase/supabase-js": "^2.97.0", diff --git a/packages/web/src/app/(admin)/admin/members/page.tsx b/packages/web/src/app/(admin)/admin/members/page.tsx index 2350224..54263d0 100644 --- a/packages/web/src/app/(admin)/admin/members/page.tsx +++ b/packages/web/src/app/(admin)/admin/members/page.tsx @@ -44,6 +44,7 @@ interface Member { part: string; blogUrl: string; rssUrl: string | null; + rssConsent: boolean; profileImageUrl: string | null; bio: string | null; status: string; @@ -263,6 +264,9 @@ export default function AdminMembersPage() {

{member.discordUsername}

+ + RSS {member.rssConsent ? 'ON' : 'OFF'} + {MEMBER_STATUS_CONFIG[member.status]?.label || member.status} @@ -348,9 +352,14 @@ export default function AdminMembersPage() { - - {MEMBER_STATUS_CONFIG[member.status]?.label || member.status} - +
+ + RSS {member.rssConsent ? 'ON' : 'OFF'} + + + {MEMBER_STATUS_CONFIG[member.status]?.label || member.status} + +
{member.postCount} {member.attendanceRate}% diff --git a/packages/web/src/app/(admin)/admin/scores/page.tsx b/packages/web/src/app/(admin)/admin/scores/page.tsx index 7c23410..54008de 100644 --- a/packages/web/src/app/(admin)/admin/scores/page.tsx +++ b/packages/web/src/app/(admin)/admin/scores/page.tsx @@ -2,12 +2,15 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { + AlertTriangle, ChevronDown, + Loader2, Minus, Plus, Search, Send, Star, + Trash2, TrendingDown, TrendingUp, Trophy, @@ -66,6 +69,7 @@ const ACTIVITY_TYPE_LABELS: Record = { discord_thread: '스레드 댓글', discord_reaction: '리액션', admin_manual: '관리자 부여', + post_view: '글 조회', }; const TOP_MEMBERS_COUNT = 5; @@ -92,6 +96,9 @@ function getActivityTypeBadgeClass(type: string): string { 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'; } @@ -264,6 +271,11 @@ export default function AdminScoresPage() { const [submitError, setSubmitError] = useState(null); const [submitSuccess, setSubmitSuccess] = useState(false); + // ── Delete state ── + const [deletingRecord, setDeletingRecord] = useState(null); + const [deleteLoading, setDeleteLoading] = useState(false); + const [deleteError, setDeleteError] = useState(null); + // ── Top members summary state ── const [topMembers, setTopMembers] = useState([]); const [topMembersLoading, setTopMembersLoading] = useState(true); @@ -455,6 +467,39 @@ export default function AdminScoresPage() { } }; + const handleDeleteConfirm = async () => { + if (!deletingRecord) return; + + try { + setDeleteLoading(true); + setDeleteError(null); + const response = await fetch('/api/admin/scores', { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ scoreId: deletingRecord.id }), + }); + + if (!response.ok) { + const result = await response.json(); + setDeleteError(result.error?.message ?? '삭제에 실패했습니다.'); + return; + } + + setDeletingRecord(null); + await fetchScoreHistory(selectedMemberId); + await fetchTopMembers(members); + } catch { + setDeleteError('서버 오류가 발생했습니다.'); + } finally { + setDeleteLoading(false); + } + }; + + const handleDeleteClose = () => { + setDeletingRecord(null); + setDeleteError(null); + }; + // ─── Derived values ──────────────────────────────────────────────────────── const selectedMember = members.find((m) => m.id === selectedMemberId); @@ -731,7 +776,7 @@ export default function AdminScoresPage() {

- 왼쪽에서 멤버를 선택하면 + 멤버를 선택하면
점수 내역이 표시됩니다.

@@ -771,7 +816,22 @@ export default function AdminScoresPage() {

{record.description}

-

{formatDate(record.date)}

+
+

{formatDate(record.date)}

+ +
))} @@ -785,6 +845,7 @@ export default function AdminScoresPage() { 점수 설명 날짜 + @@ -817,6 +878,21 @@ export default function AdminScoresPage() { {formatDate(record.date)} + + + ))} @@ -828,6 +904,72 @@ export default function AdminScoresPage() { + + {/* Delete Confirmation Dialog */} + {deletingRecord && ( +
+
+
+
+
+ +
+
+

점수 내역 삭제

+

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

+
+
+ +
+
+ + {getActivityTypeLabel(deletingRecord.type)} + + = 0 ? 'text-emerald-600' : 'text-rose-500' + )} + > + {deletingRecord.points >= 0 ? '+' : ''} + {deletingRecord.points.toLocaleString()}점 + +
+

{deletingRecord.description}

+

{formatDate(deletingRecord.date)}

+
+ + {deleteError && ( +
+ {deleteError} +
+ )} + +
+ + +
+
+
+ )}
); } diff --git a/packages/web/src/app/(user)/dashboard/page.tsx b/packages/web/src/app/(user)/dashboard/page.tsx index df636c7..50cf619 100644 --- a/packages/web/src/app/(user)/dashboard/page.tsx +++ b/packages/web/src/app/(user)/dashboard/page.tsx @@ -66,6 +66,10 @@ export default function DashboardPage() { fetchDashboard(); }, []); + const trackPostView = (postId: string) => { + fetch(`/api/posts/${postId}/view`, { method: 'POST' }).catch(() => {}); + }; + if (loading) { return ; } @@ -213,6 +217,7 @@ export default function DashboardPage() { target="_blank" rel="noopener noreferrer" className="block truncate text-sm font-medium leading-snug hover:text-primary transition-colors" + onClick={() => trackPostView(post.id)} > {post.title} diff --git a/packages/web/src/app/(user)/posts/page.tsx b/packages/web/src/app/(user)/posts/page.tsx index a41ee4f..5c75c2f 100644 --- a/packages/web/src/app/(user)/posts/page.tsx +++ b/packages/web/src/app/(user)/posts/page.tsx @@ -2,11 +2,22 @@ import { Suspense, useEffect, useState } from 'react'; import { useSearchParams, useRouter } from 'next/navigation'; -import { FileText, ExternalLink, ChevronLeft, ChevronRight } from 'lucide-react'; +import { FileText, ExternalLink, ChevronLeft, ChevronRight, Plus, Loader2 } 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 { PageLoading, PageError } from '@/components/ui/page-state'; import { PartBadge } from '@/components/ui/part-badge'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog'; import { Table, TableBody, @@ -46,6 +57,14 @@ function PostsContent() { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + // 수동 글 등록 모달 state + const [dialogOpen, setDialogOpen] = useState(false); + const [postUrl, setPostUrl] = useState(''); + const [postTitle, setPostTitle] = useState(''); + const [needsTitle, setNeedsTitle] = useState(false); + const [submitting, setSubmitting] = useState(false); + const [submitError, setSubmitError] = useState(null); + useEffect(() => { const fetchPosts = async () => { setLoading(true); @@ -67,10 +86,80 @@ function PostsContent() { fetchPosts(); }, [currentPage]); + const refetchPosts = () => { + 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.data); + } catch (err) { + setError('포스트 목록을 불러오는데 실패했습니다.'); + console.error(err); + } finally { + setLoading(false); + } + }; + fetchPosts(); + }; + const handlePageChange = (page: number) => { router.push(`/posts?page=${page}`); }; + const trackPostView = (postId: string) => { + fetch(`/api/posts/${postId}/view`, { method: 'POST' }).catch(() => {}); + }; + + const resetDialog = () => { + setPostUrl(''); + setPostTitle(''); + setNeedsTitle(false); + setSubmitError(null); + }; + + const handleManualSubmit = async () => { + if (!postUrl.trim()) return; + setSubmitting(true); + setSubmitError(null); + + try { + const body: Record = { url: postUrl.trim() }; + if (needsTitle && postTitle.trim()) { + body.title = postTitle.trim(); + } + + const response = await fetch('/api/posts/manual', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + + const result = await response.json(); + + if (response.status === 422 && result.needsTitle) { + setNeedsTitle(true); + setSubmitError(null); + return; + } + + if (!response.ok) { + setSubmitError(result.message || '등록에 실패했습니다.'); + return; + } + + // 성공 → 모달 닫기 + 목록 새로고침 + setDialogOpen(false); + resetDialog(); + refetchPosts(); + } catch { + setSubmitError('서버 오류가 발생했습니다.'); + } finally { + setSubmitting(false); + } + }; + if (loading) { return ; } @@ -87,9 +176,72 @@ function PostsContent() { 전체 포스트 - - 총 {data?.pagination.totalCount ?? 0}개 - +
+ + 총 {data?.pagination.totalCount ?? 0}개 + + { setDialogOpen(open); if (!open) resetDialog(); }}> + + + + + + 글 등록 + + 블로그 글 URL을 입력하면 제목이 자동으로 추출됩니다. + + +
+
+ + setPostUrl(e.target.value)} + /> +
+ {needsTitle && ( +
+ + setPostTitle(e.target.value)} + /> +

+ 제목을 자동으로 가져올 수 없습니다. 직접 입력해주세요. +

+
+ )} + {submitError && ( +

{submitError}

+ )} +
+ + + +
+
+
@@ -104,6 +256,7 @@ function PostsContent() { target="_blank" rel="noopener noreferrer" className="flex items-start gap-3 py-3 group" + onClick={() => trackPostView(post.id)} >

@@ -148,6 +301,7 @@ function PostsContent() { target="_blank" rel="noopener noreferrer" className="text-sm text-primary hover:underline underline-offset-4 line-clamp-1 font-medium" + onClick={() => trackPostView(post.id)} > {post.title} @@ -180,6 +334,7 @@ function PostsContent() { href={post.url} target="_blank" rel="noopener noreferrer" + onClick={() => trackPostView(post.id)} > diff --git a/packages/web/src/app/(user)/profile/edit/page.tsx b/packages/web/src/app/(user)/profile/edit/page.tsx index 0dd6385..9744b03 100644 --- a/packages/web/src/app/(user)/profile/edit/page.tsx +++ b/packages/web/src/app/(user)/profile/edit/page.tsx @@ -9,6 +9,7 @@ import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Badge } from '@/components/ui/badge'; import { AvatarUpload } from '@/components/avatar-upload'; +import { Switch } from '@/components/ui/switch'; import { PART_OPTIONS } from '@/lib/part-config'; import { PageLoading } from '@/components/ui/page-state'; @@ -39,6 +40,7 @@ interface ProfileData { bio: string | null; interests: string[] | null; resolution: string | null; + rssConsent: boolean; onboardingCompleted: boolean; githubUrl: string | null; linkedinUrl: string | null; @@ -66,6 +68,7 @@ export default function ProfileEditPage() { const [githubUrl, setGithubUrl] = useState(''); const [linkedinUrl, setLinkedinUrl] = useState(''); const [instagramUrl, setInstagramUrl] = useState(''); + const [rssConsent, setRssConsent] = useState(true); useEffect(() => { const fetchProfile = async () => { @@ -102,6 +105,7 @@ export default function ProfileEditPage() { if (data.member.githubUrl) setGithubUrl(data.member.githubUrl); if (data.member.linkedinUrl) setLinkedinUrl(data.member.linkedinUrl); if (data.member.instagramUrl) setInstagramUrl(data.member.instagramUrl); + if (data.member.rssConsent !== undefined && data.member.rssConsent !== null) setRssConsent(data.member.rssConsent); } catch (err) { console.error(err); router.push('/profile'); @@ -145,6 +149,7 @@ export default function ProfileEditPage() { githubUrl: githubUrl || null, linkedinUrl: linkedinUrl || null, instagramUrl: instagramUrl || null, + rssConsent, }), }); @@ -370,6 +375,38 @@ export default function ProfileEditPage() { + {/* RSS 설정 */} + + +

+ + RSS 설정 +
+ + 블로그 글 자동 수집 설정을 관리하세요. + + + +
+
+ + {!rssConsent && ( +

+ RSS 수집을 비활성화하면 글을 직접 등록해야 합니다. +

+ )} +
+ +
+
+ + {/* Social Links */} diff --git a/packages/web/src/app/(user)/profile/onboarding/page.tsx b/packages/web/src/app/(user)/profile/onboarding/page.tsx index 132a5e4..ae43f72 100644 --- a/packages/web/src/app/(user)/profile/onboarding/page.tsx +++ b/packages/web/src/app/(user)/profile/onboarding/page.tsx @@ -10,6 +10,7 @@ import { Label } from '@/components/ui/label'; import { Badge } from '@/components/ui/badge'; import { AvatarUpload } from '@/components/avatar-upload'; import { Separator } from '@/components/ui/separator'; +import { Switch } from '@/components/ui/switch'; import { PART_OPTIONS } from '@/lib/part-config'; import { INTEREST_OPTIONS } from '@blog-study/shared/config'; import { PageLoading } from '@/components/ui/page-state'; @@ -37,6 +38,7 @@ export default function OnboardingPage() { const [githubUrl, setGithubUrl] = useState(''); const [linkedinUrl, setLinkedinUrl] = useState(''); const [instagramUrl, setInstagramUrl] = useState(''); + const [rssConsent, setRssConsent] = useState(true); useEffect(() => { const fetchUserInfo = async () => { @@ -115,6 +117,7 @@ export default function OnboardingPage() { githubUrl: githubUrl.trim() || null, linkedinUrl: linkedinUrl.trim() || null, instagramUrl: instagramUrl.trim() || null, + rssConsent, }), }); @@ -260,6 +263,24 @@ export default function OnboardingPage() {

+
+
+ + {!rssConsent && ( +

+ RSS 수집을 비활성화하면 글을 직접 등록해야 합니다. +

+ )} +
+ +
+
diff --git a/packages/web/src/app/api/admin/members/route.ts b/packages/web/src/app/api/admin/members/route.ts index 2405ebb..cf5345f 100644 --- a/packages/web/src/app/api/admin/members/route.ts +++ b/packages/web/src/app/api/admin/members/route.ts @@ -84,6 +84,7 @@ export const GET = withAdminAuth(async (request: NextRequest, _adminAuth) => { rssUrl: member.rssUrl, profileImageUrl: member.profileImageUrl, bio: member.bio, + rssConsent: member.rssConsent ?? true, status: member.status, dormantUsed: member.dormantUsed, dormantStartRound: member.dormantStartRound, diff --git a/packages/web/src/app/api/admin/scores/route.ts b/packages/web/src/app/api/admin/scores/route.ts index c4f6915..5516151 100644 --- a/packages/web/src/app/api/admin/scores/route.ts +++ b/packages/web/src/app/api/admin/scores/route.ts @@ -78,3 +78,40 @@ export const POST = withAdminAuth(async (request: NextRequest) => { return errorResponse(error); } }); + +/** + * DELETE /api/admin/scores + * 점수 내역 삭제 (관리자 전용) + */ +export const DELETE = withAdminAuth(async (request: NextRequest) => { + try { + const body = await request.json(); + const { scoreId } = body; + + if (!scoreId || typeof scoreId !== 'string' || !UUID_REGEX.test(scoreId)) { + throw Errors.badRequest('유효하지 않은 scoreId입니다.'); + } + + const database = db(); + + // 점수 레코드 조회 + const [record] = await database + .select() + .from(activityScores) + .where(eq(activityScores.id, scoreId)) + .limit(1); + + if (!record) { + throw Errors.notFound('점수 내역을 찾을 수 없습니다.'); + } + + // 점수 레코드 삭제 + await database + .delete(activityScores) + .where(eq(activityScores.id, scoreId)); + + return successResponse({ deleted: scoreId }, '점수 내역이 삭제되었습니다.'); + } 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 new file mode 100644 index 0000000..b01f0ff --- /dev/null +++ b/packages/web/src/app/api/posts/[id]/view/route.ts @@ -0,0 +1,116 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { eq, sql } from 'drizzle-orm'; +import { db } from '@/lib/db'; +import { db as sharedDb } from '@blog-study/shared'; +import { createClient } from '@/lib/supabase/server'; + +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점) + */ +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(); + + if (authError || !user) { + return NextResponse.json({ scored: false }, { status: 401 }); + } + + 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 [member] = await database + .select({ id: members.id }) + .from(members) + .where(eq(members.discordId, discordId)) + .limit(1); + + if (!member) { + return NextResponse.json({ scored: false }, { status: 404 }); + } + + // 포스트 존재 확인 + 작성자 체크 + const [post] = await database + .select({ id: posts.id, memberId: posts.memberId, title: posts.title }) + .from(posts) + .where(eq(posts.id, postId)) + .limit(1); + + if (!post) { + return NextResponse.json({ scored: false }, { status: 404 }); + } + + // 본인 글 제외 + if (post.memberId === member.id) { + 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 safeTitle = post.title.replace(/[<>"'&]/g, '').slice(0, 200); + const scoreResult = await database.execute(sql` + WITH daily AS ( + SELECT COALESCE(SUM(${activityScores.points}), 0) AS total + FROM ${activityScores} + WHERE ${activityScores.memberId} = ${member.id} + AND ${activityScores.type} = ${ActivityScoreType.POST_VIEW} + 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} + RETURNING points + `); + + const scored = scoreResult.length > 0; + + return NextResponse.json({ + scored, + points: scored ? POST_VIEW_POINTS : 0, + reason: scored ? 'success' : 'daily_cap', + }); + } catch (error) { + console.error('Post view API error:', error); + return NextResponse.json({ scored: false }, { status: 500 }); + } +} diff --git a/packages/web/src/app/api/posts/manual/route.ts b/packages/web/src/app/api/posts/manual/route.ts new file mode 100644 index 0000000..dd398b2 --- /dev/null +++ b/packages/web/src/app/api/posts/manual/route.ts @@ -0,0 +1,179 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { eq, sql } from 'drizzle-orm'; +import { db } from '@/lib/db'; +import { db as sharedDb } from '@blog-study/shared'; +import { createClient } from '@/lib/supabase/server'; + +const { posts, members, rounds, ActivityScoreType } = sharedDb; + +const BLOG_POST_POINTS = 30; +const BLOG_POST_DAILY_CAP = 60; + +function getTodayDateString(): string { + const now = new Date(); + const kst = new Date(now.getTime() + 9 * 60 * 60 * 1000); + return kst.toISOString().split('T')[0]!; +} + +/** + * OG 태그에서 제목과 발행일 추출 + */ +async function fetchOgData(url: string): Promise<{ title: string | null; publishedAt: string | null }> { + try { + const response = await fetch(url, { + headers: { 'User-Agent': 'BlogStudyBot/1.0' }, + signal: AbortSignal.timeout(10000), + }); + + if (!response.ok) return { title: null, publishedAt: null }; + + const html = await response.text(); + + // title: og:title > + const ogTitleMatch = html.match(/<meta[^>]*property=["']og:title["'][^>]*content=["']([^"']+)["']/i) + || html.match(/<meta[^>]*content=["']([^"']+)["'][^>]*property=["']og:title["']/i); + const titleMatch = html.match(/<title[^>]*>([^<]+)<\/title>/i); + const title = ogTitleMatch?.[1] || titleMatch?.[1] || null; + + // publishedAt: article:published_time + const pubMatch = html.match(/<meta[^>]*property=["']article:published_time["'][^>]*content=["']([^"']+)["']/i) + || html.match(/<meta[^>]*content=["']([^"']+)["'][^>]*property=["']article:published_time["']/i); + const publishedAt = pubMatch?.[1] || null; + + return { title: title?.trim() || null, publishedAt }; + } catch { + return { title: null, publishedAt: null }; + } +} + +/** + * POST /api/posts/manual + * 수동 글 등록 (OG 크롤링 → 실패 시 제목 직접 입력) + */ +export async function POST(request: NextRequest) { + try { + const supabase = await createClient(); + const { data: { user }, error: authError } = await supabase.auth.getUser(); + + if (authError || !user) { + return NextResponse.json({ message: '인증이 필요합니다.' }, { status: 401 }); + } + + const discordIdentity = user.identities?.find( + (identity) => identity.provider === 'discord' + ); + const discordId = discordIdentity?.id; + if (!discordId) { + return NextResponse.json({ message: 'Discord 계정이 필요합니다.' }, { status: 400 }); + } + + const body = await request.json(); + const { url, title: manualTitle } = body; + + if (!url || typeof url !== 'string') { + return NextResponse.json({ message: 'URL은 필수입니다.' }, { status: 400 }); + } + + // URL 형식 검증 + try { + const parsed = new URL(url); + if (!['http:', 'https:'].includes(parsed.protocol)) { + return NextResponse.json({ message: 'http 또는 https URL만 허용됩니다.' }, { status: 400 }); + } + } catch { + return NextResponse.json({ message: '유효하지 않은 URL입니다.' }, { status: 400 }); + } + + const database = db(); + + // 멤버 조회 + const [member] = await database + .select({ id: members.id }) + .from(members) + .where(eq(members.discordId, discordId)) + .limit(1); + + if (!member) { + return NextResponse.json({ message: '멤버 정보를 찾을 수 없습니다.' }, { status: 404 }); + } + + // 중복 URL 체크 + const [existing] = await database + .select({ id: posts.id }) + .from(posts) + .where(eq(posts.url, url)) + .limit(1); + + if (existing) { + return NextResponse.json({ message: '이미 등록된 URL입니다.' }, { status: 409 }); + } + + // OG 크롤링 시도 + let title = manualTitle as string | null; + let publishedAt: Date = new Date(); + + if (!title) { + const ogData = await fetchOgData(url); + if (ogData.title) { + title = ogData.title; + if (ogData.publishedAt) { + publishedAt = new Date(ogData.publishedAt); + } + } + } + + // 크롤링 실패 + 수동 제목 없음 + if (!title) { + return NextResponse.json( + { message: '제목을 자동으로 가져올 수 없습니다. 직접 입력해주세요.', needsTitle: true }, + { status: 422 } + ); + } + + // 현재 회차 조회 + const [currentRound] = await database + .select({ id: rounds.id }) + .from(rounds) + .where(eq(rounds.isCurrent, true)) + .limit(1); + + // 포스트 등록 + const [newPost] = await database + .insert(posts) + .values({ + memberId: member.id, + roundId: currentRound?.id ?? null, + title, + url, + publishedAt, + }) + .returning(); + + // 블로그 포스트 점수 부여 (30점, 일일 60점 상한) + const today = getTodayDateString(); + const safeTitle = title.replace(/[<>"'&]/g, '').slice(0, 200); + await database.execute(sql` + WITH daily AS ( + SELECT COALESCE(SUM(points), 0) AS total + FROM activity_scores + WHERE member_id = ${member.id} + AND type = ${ActivityScoreType.BLOG_POST} + AND date = ${today} + ) + INSERT INTO activity_scores (id, member_id, type, points, description, date) + SELECT gen_random_uuid(), ${member.id}, ${ActivityScoreType.BLOG_POST}, ${BLOG_POST_POINTS}, + ${`블로그 포스트: ${safeTitle}`}, ${today} + FROM daily + WHERE daily.total < ${BLOG_POST_DAILY_CAP} + RETURNING points + `); + + return NextResponse.json({ + message: '글이 등록되었습니다.', + post: newPost, + }); + } catch (error) { + console.error('Manual post API error:', error); + return NextResponse.json({ message: '서버 오류가 발생했습니다.' }, { status: 500 }); + } +} diff --git a/packages/web/src/app/api/profile/edit/route.ts b/packages/web/src/app/api/profile/edit/route.ts index 06f1cbb..a9e5c9a 100644 --- a/packages/web/src/app/api/profile/edit/route.ts +++ b/packages/web/src/app/api/profile/edit/route.ts @@ -48,7 +48,7 @@ export async function PUT(request: NextRequest) { } const body = await request.json(); - const { name, nickname, part, profileImageUrl, bio, interests, resolution, githubUrl, linkedinUrl, instagramUrl } = body; + const { name, nickname, part, profileImageUrl, bio, interests, resolution, githubUrl, linkedinUrl, instagramUrl, rssConsent } = body; if (part && (typeof part !== 'string' || part.length > 50)) { return NextResponse.json( @@ -123,6 +123,7 @@ export async function PUT(request: NextRequest) { githubUrl: githubUrl || null, linkedinUrl: linkedinUrl || null, instagramUrl: instagramUrl || null, + ...(typeof rssConsent === 'boolean' ? { rssConsent } : {}), updatedAt: new Date(), }) .where(eq(members.id, memberData.id)); diff --git a/packages/web/src/app/api/profile/onboarding/route.ts b/packages/web/src/app/api/profile/onboarding/route.ts index 19009db..576ec96 100644 --- a/packages/web/src/app/api/profile/onboarding/route.ts +++ b/packages/web/src/app/api/profile/onboarding/route.ts @@ -35,7 +35,7 @@ export async function POST(request: NextRequest) { } const body = await request.json(); - const { name, nickname, part, blogUrl, profileImageUrl, bio, interests, resolution, githubUrl, linkedinUrl, instagramUrl } = body; + const { name, nickname, part, blogUrl, profileImageUrl, bio, interests, resolution, githubUrl, linkedinUrl, instagramUrl, rssConsent } = body; // 필수 필드 검증 if (!name || typeof name !== 'string' || name.trim().length === 0) { @@ -163,6 +163,7 @@ export async function POST(request: NextRequest) { githubUrl: githubUrl || null, linkedinUrl: linkedinUrl || null, instagramUrl: instagramUrl || null, + rssConsent: rssConsent !== false, onboardingCompleted: true, updatedAt: new Date(), }) @@ -185,6 +186,7 @@ export async function POST(request: NextRequest) { githubUrl: githubUrl || null, linkedinUrl: linkedinUrl || null, instagramUrl: instagramUrl || null, + rssConsent: rssConsent !== false, onboardingCompleted: true, status: 'active', }); diff --git a/packages/web/src/app/api/profile/route.ts b/packages/web/src/app/api/profile/route.ts index a442e5b..fa12659 100644 --- a/packages/web/src/app/api/profile/route.ts +++ b/packages/web/src/app/api/profile/route.ts @@ -102,6 +102,7 @@ export async function GET() { bio: memberData.bio, interests: memberData.interests, resolution: memberData.resolution, + rssConsent: memberData.rssConsent ?? true, onboardingCompleted: memberData.onboardingCompleted, status: memberData.status, dormantUsed: memberData.dormantUsed, diff --git a/packages/web/src/components/ui/switch.tsx b/packages/web/src/components/ui/switch.tsx new file mode 100644 index 0000000..5f4117f --- /dev/null +++ b/packages/web/src/components/ui/switch.tsx @@ -0,0 +1,29 @@ +"use client" + +import * as React from "react" +import * as SwitchPrimitives from "@radix-ui/react-switch" + +import { cn } from "@/lib/utils" + +const Switch = React.forwardRef< + React.ElementRef<typeof SwitchPrimitives.Root>, + React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root> +>(({ className, ...props }, ref) => ( + <SwitchPrimitives.Root + className={cn( + "peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input", + className + )} + {...props} + ref={ref} + > + <SwitchPrimitives.Thumb + className={cn( + "pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0" + )} + /> + </SwitchPrimitives.Root> +)) +Switch.displayName = SwitchPrimitives.Root.displayName + +export { Switch } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5b65e2d..aefc0cf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -146,6 +146,9 @@ importers: '@radix-ui/react-slot': specifier: ^1.2.4 version: 1.2.4(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-switch': + specifier: ^1.2.6 + version: 1.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@radix-ui/react-tabs': specifier: ^1.1.13 version: 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -1562,6 +1565,19 @@ packages: '@types/react': optional: true + '@radix-ui/react-switch@1.2.6': + resolution: {integrity: sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==} + peerDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3 + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-tabs@1.1.13': resolution: {integrity: sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==} peerDependencies: @@ -5589,6 +5605,21 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 + '@radix-ui/react-switch@1.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@radix-ui/react-tabs@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: '@radix-ui/primitive': 1.1.3