diff --git a/CLAUDE.md b/CLAUDE.md index e7d9ec6..f1938b5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -61,7 +61,7 @@ pnpm --filter @blog-study/bot rss-collect # 수동 RSS 수집 (봇 없이) - **API 응답**: 모든 API 라우트는 `Errors.*()` + `successResponse()` + `errorResponse()` 패턴 사용 (직접 `NextResponse.json` 금지) - **캐시**: 읽기 전용 API에 `withCache(response, maxAge)` 적용 (members: 60s, ranking: 30s) - **보안**: Tiptap content는 저장 전 `sanitizeTiptapContent()` 적용, 댓글 content는 `sanitizeDescription()` 적용, 외부 URL fetch/저장 시 `isSafeUrl()` SSRF 체크 (blogUrl, profileImageUrl, 소셜 URL 등 사용자 입력 URL 포함) -- **블로그 URL 수정**: 프로필 수정 시 blogUrl 변경 가능, 변경 시 rssUrl을 null 초기화 후 `after()`로 RSS 비동기 재감지 +- **블로그 관리**: 프로필/온보딩/관리자 멤버 편집에서 `blogs[]` 배열로 입/출력 (최대 `MAX_BLOGS_PER_MEMBER=3`개). 각 블로그에 label(선택), blogUrl, rssConsent 포함. `syncMemberBlogs()`로 트랜잭션 내 upsert 처리 후 `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회/분/유저 @@ -75,7 +75,7 @@ pnpm --filter @blog-study/bot rss-collect # 수동 RSS 수집 (봇 없이) - **알림 로그**: `discord_notification_logs` 테이블에 봇/웹 모든 채널+DM+푸시 알림 성공/실패 기록 (target: `channel`/`dm`/`push`), `logNotification()` 헬퍼 (봇: `notification-logger.ts`, 웹: `notification-log.ts`), 관리자 페이지 "알림 로그" 탭에서 조회 (타입/소스/대상/상태 필터 + 무한 스크롤, 푸시 로그에 수신자 닉네임 표시) - **비밀답글 가시성**: 비밀 답글은 작성자/포스트작성자/부모댓글작성자/관리자가 열람 가능 - **랭킹**: active + OB + dormant 전원 표시, 웹 페이지 4위부터 (포디움과 분리), 주간랭킹 전원 나열 -- **RSS 수집**: active + OB (rssConsent=true만), 포스트 점수는 active만 부여 +- **RSS 수집**: active + OB 멤버의 `member_blogs` 중 `rss_consent=true`이고 `rss_url`이 존재하는 블로그만 수집 (per-blog RSS consent). 포스트 점수는 active만 부여 - **Discord 버튼**: `discord-notify.ts`에 `components` (Link Button) + `allowEveryone` 옵션 지원 - **백그라운드 작업**: API route에서 푸시 알림/점수 부여 등 fire-and-forget 작업은 `after()` from `next/server` 사용 (Vercel 서버리스 종료 방지) - **비밀댓글 알림**: 비밀댓글(`isSecret`)의 푸시 알림은 내용 마스킹 (`'비밀 댓글이 달렸습니다.'`), 포스트/게시판 댓글 모두 적용 @@ -135,7 +135,7 @@ pnpm --filter @blog-study/bot rss-collect # 수동 RSS 수집 (봇 없이) | `packages/web/src/app/(admin)/admin/bot-operations/page.tsx` | 봇 수동 실행 대시보드 (관리자 전용) | | `packages/web/src/app/api/admin/bot-operations/[operationId]/route.ts` | 봇 작업 트리거 프록시 (web → bot HTTP API, 30s 타임아웃) | | `packages/web/src/app/(admin)/admin/rounds/page.tsx` | 회차 관리 페이지 (CRUD + 현재 회차 설정) | -| `packages/web/src/app/api/profile/edit/route.ts` | 프로필 수정 API (blogUrl 변경 시 RSS 재감지, 소셜 URL SSRF 체크) | +| `packages/web/src/app/api/profile/edit/route.ts` | 프로필 수정 API (blogs[] 동기화 시 RSS 재감지, 소셜 URL SSRF 체크) | | `packages/web/src/app/api/posts/[id]/route.ts` | 포스트 PATCH/DELETE API (본인/관리자, soft delete + 댓글/조회/리액션/점수 hard delete) | | `packages/web/src/app/api/profile/withdraw/route.ts` | 유저 자체 탈퇴 API | | `packages/web/src/lib/firebase/admin.ts` | Firebase Admin SDK (lazy 초기화, `getAdminMessaging()`) | @@ -148,6 +148,10 @@ pnpm --filter @blog-study/bot rss-collect # 수동 RSS 수집 (봇 없이) | `packages/web/src/app/api/internal/new-post-push/route.ts` | 새 글 푸시 알림 내부 API (봇→웹, Bearer 인증, rate limit 20/min) | | `packages/web/src/app/api/internal/reminder-push/route.ts` | 범용 리마인더 푸시 내부 API (봇→웹, 5종 FORCE_SEND_TYPES) | | `packages/bot/src/lib/push-client.ts` | 봇→웹 내부 API 호출 래퍼 (reminder-push 등) | +| `packages/bot/src/services/member-blog.service.ts` | RSS 폴링 대상 조회 (`getPollableBlogs`) + 멤버별 블로그 목록 (봇 전용) | +| `packages/web/src/lib/member-blogs.ts` | 멀티 블로그 웹 헬퍼 (`validateBlogInputs`, `fetchMemberBlogs`, `createMemberBlogs`, `syncMemberBlogs`) | +| `packages/shared/src/db/migrate-member-blogs-expand.ts` | 멤버 블로그 마이그레이션 Phase1 (비파괴: member_blogs 생성+백필, 구컬럼 유지) — `migrate:member-blogs:expand` | +| `packages/shared/src/db/migrate-member-blogs-contract.ts` | 멤버 블로그 마이그레이션 Phase2 (파괴적: 재백필 보정 후 members 구컬럼 DROP) — `migrate:member-blogs:contract`, 신코드 배포·검증 후 실행 | | `packages/web/src/app/(user)/profile/fines/page.tsx` | 벌금 상세 페이지 (내 벌금 내역 + 납부 완료) | | `packages/web/src/app/api/profile/fines/route.ts` | 내 벌금 목록 API | | `packages/web/src/app/api/fines/[id]/pay/route.ts` | 벌금 납부 완료 API (atomic update) | diff --git a/docs/26-03-06-schema-summary.md b/docs/26-03-06-schema-summary.md index 6dd17bf..853631b 100644 --- a/docs/26-03-06-schema-summary.md +++ b/docs/26-03-06-schema-summary.md @@ -26,20 +26,35 @@ | `name` | varchar(50) | not null | | `nickname` | varchar(100) | not null | | `part` | varchar(50) | not null | -| `blog_url` | varchar(500) | not null | -| `rss_url` | varchar(500) | nullable | | `profile_image_url` | varchar(500) | nullable | | `bio` | varchar(200) | nullable | | `interests` | text[] | nullable | | `resolution` | varchar(300) | nullable | | `onboarding_completed` | boolean | default false | -| `rss_consent` | boolean | default true | | `github_url`, `linkedin_url`, `instagram_url` | varchar(500) | 소셜 링크 | | `status` | varchar(20) | default 'active' | | `dormant_start_round` | integer | nullable | | `dormant_used` | boolean | default false | | `joined_at`, `updated_at` | timestamptz | defaultNow | +> `blog_url` / `rss_url` / `rss_consent` 컬럼 제거됨 — `member_blogs` 테이블로 분리 (feat/multi-blog-urls) + +### member_blogs +| 컬럼 | 타입 | 비고 | +|------|------|------| +| `id` | uuid PK | defaultRandom | +| `member_id` | uuid FK → members | not null, ON DELETE cascade | +| `label` | varchar(100) | nullable (사용자 지정 이름) | +| `blog_url` | varchar(2000) | not null | +| `rss_url` | varchar(2000) | nullable | +| `rss_consent` | boolean | not null, default true | +| `sort_order` | integer | not null, default 0 | +| `created_at`, `updated_at` | timestamptz | defaultNow | +| unique constraint: `(member_id, blog_url)` | | | +| 인덱스: `member_id` | | | + +상수: `MAX_BLOGS_PER_MEMBER = 3` (멤버당 최대 등록 블로그 수) + ### rounds | 컬럼 | 타입 | 비고 | |------|------|------| @@ -178,6 +193,7 @@ ## FK 관계 요약 ``` +members ──< member_blogs (member_id, cascade delete) members ──< posts (member_id) members ──< attendance (member_id) members ──< fines (member_id) @@ -200,4 +216,4 @@ members ──< notification_preferences (member_id) ## 타입 Export -모든 테이블에 `Type`/`NewType` export 있음 (예: `Member`/`NewMember`, `Post`/`NewPost`, `BoardPost`/`NewBoardPost`, `BoardComment`/`NewBoardComment`, `FcmToken`/`NewFcmToken`, `NotificationPreference`/`NewNotificationPreference`) +모든 테이블에 `Type`/`NewType` export 있음 (예: `Member`/`NewMember`, `MemberBlog`/`NewMemberBlog`, `Post`/`NewPost`, `BoardPost`/`NewBoardPost`, `BoardComment`/`NewBoardComment`, `FcmToken`/`NewFcmToken`, `NotificationPreference`/`NewNotificationPreference`) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index bdbe512..16149be 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,6 +1,6 @@ # Blog Study Admin - 시스템 아키텍처 -> 최종 업데이트: 2026-04-15 (v13) +> 최종 업데이트: 2026-05-17 (v14) 블로그 글쓰기 스터디 운영 자동화 플랫폼. 웹 대시보드에서 모든 관리/유저 기능을 제공하고, Discord 봇은 스케줄러(RSS 수집/출석/벌금/큐레이션)와 이벤트 핸들러만 담당한다. @@ -42,7 +42,7 @@ graph TB subgraph DB["Supabase · PostgreSQL"] AUTH["Supabase Auth
Discord OAuth"] - TABLES["members · posts · rounds
attendance · fines · config
keywords · curation · activity_scores
post_views · post_comments · board_posts
board_comments · fcm_tokens · notification_preferences"] + TABLES["members · member_blogs · posts · rounds
attendance · fines · config
keywords · curation · activity_scores
post_views · post_comments · board_posts
board_comments · fcm_tokens · notification_preferences"] PGBOSS["pg-boss
Job Queue"] end @@ -298,6 +298,7 @@ flowchart TD ```mermaid erDiagram + members ||--o{ member_blogs : "블로그" members ||--o{ posts : "작성" members ||--o{ attendance : "출석" members ||--o{ fines : "벌금" @@ -321,12 +322,21 @@ erDiagram varchar discord_id UK varchar name varchar part - varchar blog_url varchar status text[] interests boolean onboarding_completed } + member_blogs { + uuid id PK + uuid member_id FK + varchar label + varchar blog_url + varchar rss_url + boolean rss_consent + integer sort_order + } + rounds { serial id PK integer round_number UK @@ -462,7 +472,7 @@ erDiagram | 작업 | 주기 | 설명 | |------|------|------| -| RSS Poller | 5분 | active 멤버 RSS 피드 수집 | +| RSS Poller | 5분 | active/OB 멤버의 블로그(member_blogs) RSS 피드 수집 | | Attendance Checker | 매주 화 00:00 | 지각/결석 판정 | | Fine Reminder | 매일 10:00 | 미납 벌금 FCM 푸시 리마인드 (봇→웹 내부 API, 1일 간격) | | Curation Crawler | 매일 09:00 | 외부 컨텐츠 크롤링 | diff --git a/packages/bot/src/schedulers/rss-poller.property.test.ts b/packages/bot/src/schedulers/rss-poller.property.test.ts index 61ebbb1..2dde3c4 100644 --- a/packages/bot/src/schedulers/rss-poller.property.test.ts +++ b/packages/bot/src/schedulers/rss-poller.property.test.ts @@ -3,15 +3,16 @@ * **Feature: blog-study-discord-bot** */ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import * as fc from 'fast-check'; -import { MemberStatus, type Member } from '@blog-study/shared/db'; +import { type Member, type MemberBlog, MemberStatus } from '@blog-study/shared/db'; +import { RssPoller } from './rss-poller'; -// Mock the member service -const mockGetAllByStatus = vi.fn(); -vi.mock('../services/member.service', () => ({ - getMemberService: () => ({ - getAllByStatus: mockGetAllByStatus, +// Mock the member-blog service (RSS 폴링 대상 조회를 담당) +const mockGetPollableBlogs = vi.fn(); +vi.mock('../services/member-blog.service', () => ({ + getMemberBlogService: () => ({ + getPollableBlogs: mockGetPollableBlogs, }), })); @@ -22,36 +23,65 @@ vi.mock('../services/rss.service', () => ({ }), })); -import { RssPoller } from './rss-poller'; - /** * Generate a mock member with specified status */ -function generateMember( - id: string, - discordId: string, - status: string, - hasRssUrl: boolean -): Member { +function generateMember(id: string, discordId: string, status: string): Member { return { id, discordId, discordUsername: `user_${discordId}`, name: `Name ${discordId}`, + nickname: `Nick ${discordId}`, part: 'frontend', - blogUrl: `https://blog.example.com/${discordId}`, - rssUrl: hasRssUrl ? `https://blog.example.com/${discordId}/rss` : null, profileImageUrl: null, bio: null, interests: null, resolution: null, onboardingCompleted: false, + githubUrl: null, + linkedinUrl: null, + instagramUrl: null, status, dormantStartRound: null, dormantUsed: false, joinedAt: new Date(), updatedAt: new Date(), - }; + } as Member; +} + +/** + * Generate a mock member blog + */ +function generateBlog( + id: string, + memberId: string, + hasRssUrl: boolean, + rssConsent: boolean +): MemberBlog { + return { + id, + memberId, + label: null, + blogUrl: `https://blog.example.com/${memberId}`, + rssUrl: hasRssUrl ? `https://blog.example.com/${memberId}/rss` : null, + rssConsent, + sortOrder: 0, + createdAt: new Date(), + updatedAt: new Date(), + } as MemberBlog; +} + +/** + * The (member, blog) pollable filter as enforced by MemberBlogService.getPollableBlogs(): + * member status ∈ {active, ob} AND blog.rssConsent === true AND blog.rssUrl !== null + */ +function pollableFilter(member: Member, blog: MemberBlog): boolean { + return ( + (member.status === MemberStatus.ACTIVE || member.status === MemberStatus.OB) && + blog.rssConsent === true && + blog.rssUrl !== null + ); } describe('RSS Poller Property Tests', () => { @@ -68,73 +98,61 @@ describe('RSS Poller Property Tests', () => { /** * **Feature: blog-study-discord-bot, Property 15: Active Member RSS Polling** - * *For any* RSS polling operation, only Members with `active` status - * SHALL have their feeds processed. + * *For any* RSS polling operation, only blogs belonging to `active`/`ob` + * Members, with RSS consent and an RSS URL, SHALL have their feeds processed. * **Validates: Requirements 6.2** */ describe('Property 15: Active Member RSS Polling', () => { - it('should only return active members with RSS URLs for polling', async () => { + it('should only return active/OB members blogs with RSS URL + consent', async () => { await fc.assert( fc.asyncProperty( - // Generate a list of members with various statuses fc.array( fc.record({ id: fc.uuid(), discordId: fc.stringMatching(/^\d{17,19}$/), status: fc.constantFrom( MemberStatus.ACTIVE, + MemberStatus.OB, MemberStatus.DORMANT, MemberStatus.WITHDRAWN ), hasRssUrl: fc.boolean(), + rssConsent: fc.boolean(), }), { minLength: 0, maxLength: 20 } ), - async (memberSpecs) => { - // Create mock members - const allMembers = memberSpecs.map((spec, index) => - generateMember( - spec.id, - spec.discordId + index, // Ensure unique - spec.status, - spec.hasRssUrl - ) - ); - - // Filter to 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 active and OB members separately - mockGetAllByStatus.mockImplementation(async (status: string) => { - if (status === MemberStatus.ACTIVE) return activeMembers; - if (status === MemberStatus.OB) return obMembers; - return []; + async (specs) => { + const pairs = specs.map((spec, index) => { + const member = generateMember(spec.id, spec.discordId + index, spec.status); + const blog = generateBlog( + `blog-${index}`, + member.id, + spec.hasRssUrl, + spec.rssConsent + ); + return { member, blog }; }); - // Get members to poll - const membersToPoll = await poller.getMembersToPoll(); + // Service applies the SQL-level filter + const pollable = pairs.filter((p) => pollableFilter(p.member, p.blog)); + mockGetPollableBlogs.mockResolvedValue(pollable); - // Verify: all returned members should be active or OB AND have RSS URL - for (const member of membersToPoll) { + const blogsToPoll = await poller.getBlogsToPoll(); + + for (const { member, blog } of blogsToPoll) { expect([MemberStatus.ACTIVE, MemberStatus.OB]).toContain(member.status); - expect(member.rssUrl).not.toBeNull(); + expect(blog.rssConsent).toBe(true); + expect(blog.rssUrl).not.toBeNull(); } - - // Verify: the service was called with both ACTIVE and OB - expect(mockGetAllByStatus).toHaveBeenCalledWith(MemberStatus.ACTIVE); - expect(mockGetAllByStatus).toHaveBeenCalledWith(MemberStatus.OB); + expect(blogsToPoll.length).toBe(pollable.length); + expect(mockGetPollableBlogs).toHaveBeenCalled(); } ), { numRuns: 100 } ); }); - it('should filter out members without RSS URLs', async () => { + it('should filter out blogs without RSS URLs or without consent', async () => { await fc.assert( fc.asyncProperty( fc.array( @@ -142,43 +160,47 @@ describe('RSS Poller Property Tests', () => { id: fc.uuid(), discordId: fc.stringMatching(/^\d{17,19}$/), hasRssUrl: fc.boolean(), + rssConsent: fc.boolean(), }), { minLength: 1, maxLength: 20 } ), - async (memberSpecs) => { - // Create all active members, some with and some without RSS URLs - const activeMembers = memberSpecs.map((spec, index) => - generateMember( + async (specs) => { + const pairs = specs.map((spec, index) => { + const member = generateMember( spec.id, spec.discordId + index, - MemberStatus.ACTIVE, - spec.hasRssUrl - ) - ); - - mockGetAllByStatus.mockImplementation(async (status: string) => { - if (status === MemberStatus.ACTIVE) return activeMembers; - return []; // OB returns empty for this test + MemberStatus.ACTIVE + ); + const blog = generateBlog( + `blog-${index}`, + member.id, + spec.hasRssUrl, + spec.rssConsent + ); + return { member, blog }; }); - const membersToPoll = await poller.getMembersToPoll(); + const pollable = pairs.filter((p) => pollableFilter(p.member, p.blog)); + mockGetPollableBlogs.mockResolvedValue(pollable); - // All returned members should have RSS URLs - for (const member of membersToPoll) { - expect(member.rssUrl).not.toBeNull(); - expect(member.rssUrl).toBeDefined(); - } + const blogsToPoll = await poller.getBlogsToPoll(); - // Count should match members with RSS URLs - const expectedCount = activeMembers.filter(m => m.rssUrl).length; - expect(membersToPoll.length).toBe(expectedCount); + for (const { blog } of blogsToPoll) { + expect(blog.rssUrl).not.toBeNull(); + expect(blog.rssUrl).toBeDefined(); + expect(blog.rssConsent).toBe(true); + } + const expectedCount = pairs.filter( + (p) => p.blog.rssUrl && p.blog.rssConsent + ).length; + expect(blogsToPoll.length).toBe(expectedCount); } ), { numRuns: 100 } ); }); - it('should not poll dormant or withdrawn members', async () => { + it('should not poll dormant or withdrawn members blogs', async () => { await fc.assert( fc.asyncProperty( fc.array( @@ -189,25 +211,20 @@ describe('RSS Poller Property Tests', () => { }), { minLength: 1, maxLength: 10 } ), - async (memberSpecs) => { - // Create non-active members - memberSpecs.map((spec, index) => - generateMember( - spec.id, - spec.discordId + index, - spec.status, - true // All have RSS URLs - ) - ); + async (specs) => { + const pairs = specs.map((spec, index) => { + const member = generateMember(spec.id, spec.discordId + index, spec.status); + const blog = generateBlog(`blog-${index}`, member.id, true, true); + return { member, blog }; + }); - // Service returns empty for active (since we're testing non-active) - mockGetAllByStatus.mockResolvedValue([]); + // Service excludes non-active/OB members → empty + const pollable = pairs.filter((p) => pollableFilter(p.member, p.blog)); + mockGetPollableBlogs.mockResolvedValue(pollable); - const membersToPoll = await poller.getMembersToPoll(); + const blogsToPoll = await poller.getBlogsToPoll(); - // Should return empty since we only query for active - expect(membersToPoll.length).toBe(0); - expect(mockGetAllByStatus).toHaveBeenCalledWith(MemberStatus.ACTIVE); + expect(blogsToPoll.length).toBe(0); } ), { numRuns: 100 } diff --git a/packages/bot/src/schedulers/rss-poller.ts b/packages/bot/src/schedulers/rss-poller.ts index b0afe1e..59841f2 100644 --- a/packages/bot/src/schedulers/rss-poller.ts +++ b/packages/bot/src/schedulers/rss-poller.ts @@ -1,13 +1,13 @@ /** * RSS Poller Scheduler - * 5분마다 모든 active 멤버의 RSS 폴링 + * 5분마다 active/OB 멤버의 모든 블로그 RSS 폴링 * Requirements: 6.1, 6.2 */ -import { getMemberService } from '../services/member.service'; +import { getMemberBlogService, type PollableBlog } from '../services/member-blog.service'; import { getPostService } from '../services/post.service'; import { getRssService, type PollResult, type RssFeedItem } from '../services/rss.service'; -import { type Member, MemberStatus } from '@blog-study/shared/db'; +import { type Member } from '@blog-study/shared/db'; import logger from '../lib/logger'; /** @@ -51,27 +51,22 @@ export class RssPoller { } /** - * Get members that should be polled - * Active + OB members with RSS consent + * Get blogs that should be polled + * Active + OB members' blogs with RSS consent and an RSS URL */ - async getMembersToPoll(): Promise { - const memberService = getMemberService(); - 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, ...obMembers].filter(member => member.rssUrl && member.rssConsent !== false); + async getBlogsToPoll(): Promise { + const memberBlogService = getMemberBlogService(); + return memberBlogService.getPollableBlogs(); } /** - * Poll a single member's RSS feed + * Poll a single blog's RSS feed */ - async pollMember(member: Member): Promise { - if (!member.rssUrl) { + async pollBlog({ member, blog }: PollableBlog): Promise { + if (!blog.rssUrl) { return { memberId: member.id, + blogId: blog.id, success: false, newItems: [], error: 'No RSS URL configured', @@ -81,10 +76,10 @@ export class RssPoller { try { const rssService = getRssService(); const postService = getPostService(); - const items = await rssService.fetchFeed(member.rssUrl); + const items = await rssService.fetchFeed(blog.rssUrl); if (items.length === 0) { - return { memberId: member.id, success: true, newItems: [] }; + return { memberId: member.id, blogId: blog.id, success: true, newItems: [] }; } // Batch duplicate check: 1 IN query instead of N individual SELECTs @@ -95,6 +90,7 @@ export class RssPoller { return { memberId: member.id, + blogId: blog.id, success: true, newItems, }; @@ -102,11 +98,14 @@ export class RssPoller { // Requirements: 6.5 - Log error and continue processing other feeds logger.error({ member: member.discordUsername, + blogId: blog.id, + rssUrl: blog.rssUrl, error, - }, '📡 [RSS] 멤버 피드 폴링 에러'); + }, '📡 [RSS] 블로그 피드 폴링 에러'); return { memberId: member.id, + blogId: blog.id, success: false, newItems: [], error: error instanceof Error ? error.message : String(error), @@ -115,7 +114,7 @@ export class RssPoller { } /** - * Run a polling cycle for all active members + * Run a polling cycle for all active/OB members' blogs * Requirements: 6.1, 6.2 */ async poll(): Promise { @@ -136,15 +135,16 @@ export class RssPoller { const errors: string[] = []; try { - const members = await this.getMembersToPoll(); - logger.info({ memberCount: members.length }, '📡 [RSS] 활성 멤버 폴링 시작'); + const blogs = await this.getBlogsToPoll(); + logger.info({ blogCount: blogs.length }, '📡 [RSS] 블로그 폴링 시작'); - for (const member of members) { - const result = await this.pollMember(member); + for (const pollable of blogs) { + const { member } = pollable; + const result = await this.pollBlog(pollable); results.push(result); if (!result.success && result.error) { - errors.push(`${member.discordUsername}: ${result.error}`); + errors.push(`${member.discordUsername} (${pollable.blog.rssUrl}): ${result.error}`); } // Call the callback if there are new items @@ -165,11 +165,13 @@ export class RssPoller { } const totalNewItems = results.reduce((sum, r) => sum + r.newItems.length, 0); + // 한 멤버가 블로그를 여러 개 가질 수 있으므로 distinct 멤버 수로 집계 + const membersPolled = new Set(blogs.map((b) => b.member.id)).size; logger.info({ totalNewItems }, '📡 [RSS] 폴링 완료'); return { timestamp: startTime, - membersPolled: members.length, + membersPolled, totalNewItems, results, errors, diff --git a/packages/bot/src/scripts/seed-test-data.ts b/packages/bot/src/scripts/seed-test-data.ts index d32a3a8..e3d8c34 100644 --- a/packages/bot/src/scripts/seed-test-data.ts +++ b/packages/bot/src/scripts/seed-test-data.ts @@ -14,7 +14,17 @@ */ import { loadBotEnv } from '@blog-study/shared'; -import { getDb, members, rounds, attendance, posts, fines, activityScores, ActivityScoreType } from '@blog-study/shared/db'; +import { + activityScores, + ActivityScoreType, + attendance, + fines, + getDb, + memberBlogs, + members, + posts, + rounds, +} from '@blog-study/shared/db'; import { eq } from 'drizzle-orm'; async function main() { @@ -36,7 +46,9 @@ async function main() { if (existingTestRound) { console.log(' 테스트 회차가 이미 존재합니다. (999회차)'); - const deleteConfirm = await prompt(' 기존 테스트 데이터를 삭제하고 다시 생성하시겠습니까? (y/N): '); + const deleteConfirm = await prompt( + ' 기존 테스트 데이터를 삭제하고 다시 생성하시겠습니까? (y/N): ' + ); if (deleteConfirm.toLowerCase() === 'y') { console.log(' 기존 테스트 데이터 삭제 중...'); @@ -85,11 +97,41 @@ async function main() { console.log('--- Step 3: 테스트 멤버 확인 ---'); const testMembers = [ - { name: '황동준', nickname: '동준이', discordUsername: 'hwangdongjun', discordId: '111111111111111111', part: 'frontend' }, - { name: '테스터2', nickname: '투스', discordUsername: 'tester2', discordId: '222222222222222222', part: 'backend' }, - { name: '테스터3', nickname: '쓰리스', discordUsername: 'tester3', discordId: '333333333333333333', part: 'devops' }, - { name: '테스터4', nickname: '포스', discordUsername: 'tester4', discordId: '444444444444444444', part: 'design' }, - { name: '테스터5', nickname: '파이브', discordUsername: 'tester5', discordId: '555555555555555555', part: 'frontend' }, + { + name: '황동준', + nickname: '동준이', + discordUsername: 'hwangdongjun', + discordId: '111111111111111111', + part: 'frontend', + }, + { + name: '테스터2', + nickname: '투스', + discordUsername: 'tester2', + discordId: '222222222222222222', + part: 'backend', + }, + { + name: '테스터3', + nickname: '쓰리스', + discordUsername: 'tester3', + discordId: '333333333333333333', + part: 'devops', + }, + { + name: '테스터4', + nickname: '포스', + discordUsername: 'tester4', + discordId: '444444444444444444', + part: 'design', + }, + { + name: '테스터5', + nickname: '파이브', + discordUsername: 'tester5', + discordId: '555555555555555555', + part: 'frontend', + }, ]; const memberIds: string[] = []; @@ -112,14 +154,19 @@ async function main() { .values({ ...testMember, status: 'active', - blogUrl: `https://${testMember.discordUsername}.blog.com`, - rssUrl: `https://${testMember.discordUsername}.blog.com/rss`, - rssConsent: true, onboardingCompleted: true, interests: ['React', 'TypeScript', 'Node.js'], }) .returning(); + await db.insert(memberBlogs).values({ + memberId: created.id, + blogUrl: `https://${testMember.discordUsername}.blog.com`, + rssUrl: `https://${testMember.discordUsername}.blog.com/rss`, + rssConsent: true, + sortOrder: 0, + }); + memberIds.push(created.id); console.log(` ✅ 신규 멤버: ${testMember.name} (@${testMember.discordUsername})`); } @@ -131,10 +178,10 @@ async function main() { console.log('--- Step 4: 출석 기록 생성 ---'); const attendanceData = [ - { memberId: memberIds[0], status: 'LATE' }, // 황동준: 지각 + { memberId: memberIds[0], status: 'LATE' }, // 황동준: 지각 { memberId: memberIds[1], status: 'SUBMITTED' }, // 제출완료 - { memberId: memberIds[2], status: 'PENDING' }, // 대기중 - { memberId: memberIds[3], status: 'ABSENT' }, // 결석 + { memberId: memberIds[2], status: 'PENDING' }, // 대기중 + { memberId: memberIds[3], status: 'ABSENT' }, // 결석 { memberId: memberIds[4], status: 'SUBMITTED' }, // 제출완료 ]; @@ -227,9 +274,9 @@ async function main() { const scoreData = [ { memberId: memberIds[0], points: 100 }, // 1번: 100점 (3개 포스트 + 활동) - { memberId: memberIds[1], points: 70 }, // 2번: 70점 (2개 포스트 + 활동) - { memberId: memberIds[2], points: 20 }, // 3번: 20점 (활동만) - { memberId: memberIds[3], points: 10 }, // 4번: 10점 (활동만) + { memberId: memberIds[1], points: 70 }, // 2번: 70점 (2개 포스트 + 활동) + { memberId: memberIds[2], points: 20 }, // 3번: 20점 (활동만) + { memberId: memberIds[3], points: 10 }, // 4번: 10점 (활동만) { memberId: memberIds[4], points: 140 }, // 5번: 140점 (4개 포스트 + 활동) ]; @@ -260,7 +307,6 @@ async function main() { console.log(' pnpm --filter @blog-study/bot test-attendance'); console.log(' pnpm --filter @blog-study/bot test-round-report'); console.log(' pnpm --filter @blog-study/bot test-fine-reminder'); - } catch (error) { console.error('❌ 테스트 데이터 생성 실패:', error); process.exit(1); diff --git a/packages/bot/src/scripts/test-rss-poll.ts b/packages/bot/src/scripts/test-rss-poll.ts index 46479e1..5256ad0 100644 --- a/packages/bot/src/scripts/test-rss-poll.ts +++ b/packages/bot/src/scripts/test-rss-poll.ts @@ -7,8 +7,8 @@ */ import { loadBotEnv } from '@blog-study/shared'; -import { getDb, members } from '@blog-study/shared/db'; -import { eq } from 'drizzle-orm'; +import { getDb, memberBlogs, members } from '@blog-study/shared/db'; +import { and, eq } from 'drizzle-orm'; import { getRssPoller } from '../schedulers/rss-poller'; import { getRssService } from '../services/rss.service'; import { getPostService } from '../services/post.service'; @@ -52,10 +52,26 @@ async function main() { process.exit(1); } - await db - .update(members) - .set({ rssUrl: RSS_URL }) - .where(eq(members.id, member.id)); + const [existingBlog] = await db + .select() + .from(memberBlogs) + .where(and(eq(memberBlogs.memberId, member.id), eq(memberBlogs.blogUrl, RSS_URL))) + .limit(1); + + if (existingBlog) { + await db + .update(memberBlogs) + .set({ rssUrl: RSS_URL, rssConsent: true }) + .where(eq(memberBlogs.id, existingBlog.id)); + } else { + await db.insert(memberBlogs).values({ + memberId: member.id, + blogUrl: RSS_URL, + rssUrl: RSS_URL, + rssConsent: true, + sortOrder: 0, + }); + } console.log(`✅ ${member.discordUsername}에 RSS URL 설정: ${RSS_URL}\n`); // 4. 현재 라운드 확인 diff --git a/packages/bot/src/services/index.ts b/packages/bot/src/services/index.ts index 73a6e40..5fde562 100644 --- a/packages/bot/src/services/index.ts +++ b/packages/bot/src/services/index.ts @@ -2,6 +2,7 @@ // 모든 서비스를 내보냅니다. export * from './member.service'; +export * from './member-blog.service'; export * from './rss.service'; export * from './round.service'; export * from './post.service'; diff --git a/packages/bot/src/services/member-blog.service.ts b/packages/bot/src/services/member-blog.service.ts new file mode 100644 index 0000000..6002505 --- /dev/null +++ b/packages/bot/src/services/member-blog.service.ts @@ -0,0 +1,75 @@ +/** + * Member Blog Service + * 멤버당 여러 블로그(member_blogs) 관리 서비스 + */ + +import { and, asc, eq, inArray, isNotNull } from 'drizzle-orm'; +import { getDb, type Member, type MemberBlog, memberBlogs, members, MemberStatus, } from '@blog-study/shared/db'; + +/** + * RSS 폴링 대상 (멤버 + 블로그 페어) + */ +export interface PollableBlog { + member: Member; + blog: MemberBlog; +} + +/** + * Member blog service + */ +export class MemberBlogService { + private db = getDb(); + + /** + * RSS 폴링 대상 블로그 조회 + * - 멤버 상태: active 또는 ob + * - 블로그: rss_consent = true 이고 rss_url 존재 + */ + async getPollableBlogs(): Promise { + const rows = await this.db + .select({ member: members, blog: memberBlogs }) + .from(memberBlogs) + .innerJoin(members, eq(memberBlogs.memberId, members.id)) + .where( + and( + inArray(members.status, [MemberStatus.ACTIVE, MemberStatus.OB]), + eq(memberBlogs.rssConsent, true), + isNotNull(memberBlogs.rssUrl) + ) + ) + .orderBy(asc(memberBlogs.memberId), asc(memberBlogs.sortOrder)); + + return rows.map((r) => ({ member: r.member, blog: r.blog })); + } + + /** + * 멤버의 모든 블로그 조회 (sort_order 순) + */ + async getByMember(memberId: string): Promise { + return this.db + .select() + .from(memberBlogs) + .where(eq(memberBlogs.memberId, memberId)) + .orderBy(asc(memberBlogs.sortOrder)); + } +} + +// Singleton instance +let memberBlogServiceInstance: MemberBlogService | null = null; + +/** + * Get the MemberBlogService singleton instance + */ +export function getMemberBlogService(): MemberBlogService { + if (!memberBlogServiceInstance) { + memberBlogServiceInstance = new MemberBlogService(); + } + return memberBlogServiceInstance; +} + +/** + * Reset the singleton (useful for testing) + */ +export function resetMemberBlogService(): void { + memberBlogServiceInstance = null; +} diff --git a/packages/bot/src/services/member.service.ts b/packages/bot/src/services/member.service.ts index fe83352..1aad6d2 100644 --- a/packages/bot/src/services/member.service.ts +++ b/packages/bot/src/services/member.service.ts @@ -4,14 +4,15 @@ * Requirements: 1.1, 2.1, 3.2, 3.6 */ -import { eq } from 'drizzle-orm'; +import { asc, eq } from 'drizzle-orm'; import { getDb, + type Member, + memberBlogs, members, MemberStatus, - type Member, - type NewMember, type MemberStatusType, + type NewMember, } from '@blog-study/shared/db'; import { validateBlogUrl } from '@blog-study/shared/utils'; @@ -81,7 +82,7 @@ export class MemberService { if (existing) { throw new MemberError( MemberErrorCodes.ALREADY_REGISTERED, - `이미 등록된 사용자입니다. 현재 블로그: ${existing.blogUrl}`, + '이미 등록된 사용자입니다.', `User ${input.discordId} is already registered` ); } @@ -93,14 +94,21 @@ export class MemberService { name: input.name, nickname: input.name, part: input.part, - blogUrl: urlValidation.normalizedUrl || input.blogUrl, - rssUrl: input.rssUrl || null, status: MemberStatus.ACTIVE, dormantUsed: false, onboardingCompleted: false, }; const [created] = await this.db.insert(members).values(newMember).returning(); + + // Register the member's first blog + await this.db.insert(memberBlogs).values({ + memberId: created!.id, + blogUrl: urlValidation.normalizedUrl || input.blogUrl, + rssUrl: input.rssUrl || null, + sortOrder: 0, + }); + return created!; } @@ -267,7 +275,7 @@ export class MemberService { } /** - * Update member's RSS URL + * Update the RSS URL of the member's primary (lowest sortOrder) blog */ async updateRssUrl(discordId: string, rssUrl: string): Promise { const member = await this.getByDiscordId(discordId); @@ -279,16 +287,27 @@ export class MemberService { ); } - const [updated] = await this.db - .update(members) - .set({ - rssUrl, - updatedAt: new Date(), - }) - .where(eq(members.discordId, discordId)) - .returning(); + const [primaryBlog] = await this.db + .select() + .from(memberBlogs) + .where(eq(memberBlogs.memberId, member.id)) + .orderBy(asc(memberBlogs.sortOrder)) + .limit(1); - return updated!; + if (!primaryBlog) { + throw new MemberError( + MemberErrorCodes.USER_NOT_FOUND, + '등록된 블로그가 없습니다.', + `Member ${member.id} has no blog` + ); + } + + await this.db + .update(memberBlogs) + .set({ rssUrl, updatedAt: new Date() }) + .where(eq(memberBlogs.id, primaryBlog.id)); + + return member; } /** diff --git a/packages/bot/src/services/rss.service.ts b/packages/bot/src/services/rss.service.ts index 231134b..70c0b66 100644 --- a/packages/bot/src/services/rss.service.ts +++ b/packages/bot/src/services/rss.service.ts @@ -7,7 +7,7 @@ import axios from 'axios'; import * as cheerio from 'cheerio'; import { parseFeed } from 'feedsmith'; -import { detectBlogPlatform, type BlogPlatform } from '@blog-study/shared/utils'; +import { type BlogPlatform, detectBlogPlatform } from '@blog-study/shared/utils'; /** * Error codes for RSS operations @@ -57,6 +57,7 @@ export interface RssDetectionResult { */ export interface PollResult { memberId: string; + blogId?: string; success: boolean; newItems: RssFeedItem[]; error?: string; diff --git a/packages/shared/package.json b/packages/shared/package.json index 4bc5a42..ef2817f 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -38,6 +38,8 @@ "db:generate": "drizzle-kit generate", "db:migrate": "drizzle-kit migrate", "db:migrate:run": "tsx src/db/migrate.ts", + "migrate:member-blogs:expand": "tsx src/db/migrate-member-blogs-expand.ts", + "migrate:member-blogs:contract": "tsx src/db/migrate-member-blogs-contract.ts", "db:push": "drizzle-kit push", "db:studio": "drizzle-kit studio" }, diff --git a/packages/shared/src/db/migrate-member-blogs-contract.ts b/packages/shared/src/db/migrate-member-blogs-contract.ts new file mode 100644 index 0000000..46cae9c --- /dev/null +++ b/packages/shared/src/db/migrate-member-blogs-contract.ts @@ -0,0 +1,93 @@ +/** + * member_blogs 마이그레이션 — Phase 2 (CONTRACT, 파괴적) + * + * ❗ 신코드(member_blogs 사용)가 운영에 완전히 배포·검증된 뒤에만 실행. + * 구버전 코드가 살아있는 상태에서 실행하면 members.blog_url 참조 쿼리가 + * 전부 깨진다. + * + * 1. (안전 가드) member_blogs 테이블이 없으면 중단 — expand 먼저 실행 요구 + * 2. 멱등 재백필 — expand 이후 구코드가 추가한 멤버를 누락 없이 보정 + * 3. members.blog_url/rss_url/rss_consent 컬럼 제거 + * + * 재실행 안전 (구컬럼 이미 없으면 스킵). + * + * Usage: pnpm --filter @blog-study/shared migrate:member-blogs:contract + */ + +import postgres from 'postgres'; +import { config } from 'dotenv'; +import { resolve } from 'path'; + +config({ path: resolve(__dirname, '../../../../.env.local') }); +config({ path: resolve(__dirname, '../../../../.env') }); + +async function main() { + const connectionString = process.env.DATABASE_URL; + if (!connectionString) { + console.error('❌ DATABASE_URL 환경변수가 설정되지 않았습니다.'); + process.exit(1); + } + + const sql = postgres(connectionString, { max: 1, prepare: false }); + + try { + await sql.begin(async (tx) => { + // 1. member_blogs 존재 확인 (expand 선행 강제) + const tblCheck = (await tx.unsafe(` + SELECT EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_name = 'member_blogs' + ) AS exists + `)) as unknown as Array<{ exists: boolean }>; + if (!tblCheck[0]?.exists) { + throw new Error('member_blogs 테이블이 없습니다. 먼저 expand 를 실행하세요.'); + } + + // 2. 구컬럼이 아직 있으면: 멱등 재백필 후 컬럼 제거 + const colCheck = (await tx.unsafe(` + SELECT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'members' AND column_name = 'blog_url' + ) AS exists + `)) as unknown as Array<{ exists: boolean }>; + const hasBlogUrl = colCheck[0]?.exists ?? false; + + if (!hasBlogUrl) { + console.log('ℹ️ members.blog_url 컬럼 없음 — 이미 contract 완료. 스킵'); + return; + } + + // expand~contract 사이 구코드가 추가한 멤버 보정 (멱등) + const inserted = (await tx.unsafe(` + INSERT INTO "member_blogs" ("member_id", "blog_url", "rss_url", "rss_consent", "sort_order") + SELECT m."id", m."blog_url", m."rss_url", COALESCE(m."rss_consent", true), 0 + FROM "members" m + WHERE m."blog_url" IS NOT NULL + AND m."blog_url" <> '' + AND NOT EXISTS ( + SELECT 1 FROM "member_blogs" mb WHERE mb."member_id" = m."id" + ) + `)) as unknown as { count: number }; + console.log(`✅ 재백필 보정: ${inserted.count}개 신규 멤버 블로그 행 생성`); + + // 3. 구컬럼 제거 (파괴적) + await tx.unsafe(`ALTER TABLE "members" DROP COLUMN IF EXISTS "blog_url"`); + await tx.unsafe(`ALTER TABLE "members" DROP COLUMN IF EXISTS "rss_url"`); + await tx.unsafe(`ALTER TABLE "members" DROP COLUMN IF EXISTS "rss_consent"`); + console.log('✅ members.blog_url/rss_url/rss_consent 컬럼 제거 완료'); + }); + + const countRows = (await sql.unsafe( + `SELECT COUNT(*) AS count FROM "member_blogs"` + )) as unknown as Array<{ count: string }>; + console.log(`🎉 CONTRACT 완료 — member_blogs 총 ${countRows[0]?.count ?? '0'}행`); + process.exit(0); + } catch (error) { + console.error('❌ CONTRACT 실패 (롤백됨):', error); + process.exit(1); + } finally { + await sql.end(); + } +} + +main(); diff --git a/packages/shared/src/db/migrate-member-blogs-expand.ts b/packages/shared/src/db/migrate-member-blogs-expand.ts new file mode 100644 index 0000000..12abc7a --- /dev/null +++ b/packages/shared/src/db/migrate-member-blogs-expand.ts @@ -0,0 +1,126 @@ +/** + * member_blogs 마이그레이션 — Phase 1 (EXPAND, 비파괴) + * + * 1. member_blogs 테이블 생성 (FK / index / UNIQUE 포함, idempotent) + * 2. 기존 members.blog_url/rss_url/rss_consent → member_blogs 1행 백필 + * + * ❗ members 의 구컬럼은 그대로 둔다 — 구버전 코드가 계속 동작하므로 + * 이 단계는 운영 중에 안전하게 실행 가능. 컬럼 제거는 Phase 2(contract). + * + * 재실행 안전 (CREATE TABLE IF NOT EXISTS, NOT EXISTS 백필 가드). + * SQL은 전부 정적(사용자 입력 없음) → .unsafe() 사용 (postgres tagged-template + * 제네릭이 환경별로 다르게 추론되는 문제 회피). + * + * Usage: pnpm --filter @blog-study/shared migrate:member-blogs:expand + */ + +import postgres from 'postgres'; +import { config } from 'dotenv'; +import { resolve } from 'path'; + +config({ path: resolve(__dirname, '../../../../.env.local') }); +config({ path: resolve(__dirname, '../../../../.env') }); + +async function main() { + const connectionString = process.env.DATABASE_URL; + if (!connectionString) { + console.error('❌ DATABASE_URL 환경변수가 설정되지 않았습니다.'); + process.exit(1); + } + + const sql = postgres(connectionString, { max: 1, prepare: false }); + + try { + await sql.begin(async (tx) => { + // 1. member_blogs 테이블 생성 + await tx.unsafe(` + CREATE TABLE IF NOT EXISTS "member_blogs" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "member_id" uuid NOT NULL, + "label" varchar(100), + "blog_url" varchar(2000) NOT NULL, + "rss_url" varchar(2000), + "rss_consent" boolean DEFAULT true NOT NULL, + "sort_order" integer DEFAULT 0 NOT NULL, + "created_at" timestamp with time zone DEFAULT now(), + "updated_at" timestamp with time zone DEFAULT now() + ) + `); + + // FK (없을 때만 추가) + await tx.unsafe(` + DO $$ BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'member_blogs_member_id_members_id_fk' + ) THEN + ALTER TABLE "member_blogs" + ADD CONSTRAINT "member_blogs_member_id_members_id_fk" + FOREIGN KEY ("member_id") REFERENCES "public"."members"("id") + ON DELETE cascade ON UPDATE no action; + END IF; + END $$ + `); + + await tx.unsafe(` + CREATE INDEX IF NOT EXISTS "idx_member_blogs_member_id" + ON "member_blogs" ("member_id") + `); + + // (member_id, blog_url) 유니크 — 동시 요청 중복 삽입 방지 + await tx.unsafe(` + DO $$ BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'uq_member_blogs_member_id_blog_url' + ) THEN + ALTER TABLE "member_blogs" + ADD CONSTRAINT "uq_member_blogs_member_id_blog_url" + UNIQUE ("member_id", "blog_url"); + END IF; + END $$ + `); + + // members.blog_url 컬럼이 존재할 때만 백필 (구컬럼 유지 — 비파괴) + const colCheck = (await tx.unsafe(` + SELECT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'members' AND column_name = 'blog_url' + ) AS exists + `)) as unknown as Array<{ exists: boolean }>; + const hasBlogUrl = colCheck[0]?.exists ?? false; + + if (hasBlogUrl) { + const inserted = (await tx.unsafe(` + INSERT INTO "member_blogs" ("member_id", "blog_url", "rss_url", "rss_consent", "sort_order") + SELECT m."id", m."blog_url", m."rss_url", COALESCE(m."rss_consent", true), 0 + FROM "members" m + WHERE m."blog_url" IS NOT NULL + AND m."blog_url" <> '' + AND NOT EXISTS ( + SELECT 1 FROM "member_blogs" mb WHERE mb."member_id" = m."id" + ) + `)) as unknown as { count: number }; + console.log(`✅ 백필 완료: ${inserted.count}개 멤버 블로그 행 생성 (구컬럼 유지)`); + } else { + console.log('ℹ️ members.blog_url 컬럼 없음 — 이미 contract 완료된 상태로 보임. 백필 스킵'); + } + }); + + const countRows = (await sql.unsafe( + `SELECT COUNT(*) AS count FROM "member_blogs"` + )) as unknown as Array<{ count: string }>; + console.log( + `🎉 EXPAND 완료 — member_blogs 총 ${countRows[0]?.count ?? '0'}행. ` + + `구컬럼은 유지됨 (운영 안전). 신코드 배포 검증 후 contract 실행.` + ); + process.exit(0); + } catch (error) { + console.error('❌ EXPAND 실패 (롤백됨):', error); + process.exit(1); + } finally { + await sql.end(); + } +} + +main(); diff --git a/packages/shared/src/db/schema.ts b/packages/shared/src/db/schema.ts index d820d9d..96adb77 100644 --- a/packages/shared/src/db/schema.ts +++ b/packages/shared/src/db/schema.ts @@ -91,15 +91,13 @@ 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: 2000 }).notNull(), - rssUrl: varchar('rss_url', { length: 2000 }), + // 블로그/RSS 정보는 member_blogs 테이블로 분리 (멤버당 여러 블로그 지원) // 프로필 정보 (온보딩) 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: 2000 }), linkedinUrl: varchar('linkedin_url', { length: 2000 }), @@ -116,6 +114,39 @@ export const members = pgTable( }) ); +/** + * 멤버당 등록 가능한 블로그 최대 개수 + */ +export const MAX_BLOGS_PER_MEMBER = 3; + +/** + * 멤버 블로그 (Member Blogs) + * 멤버당 여러 블로그 URL을 등록 — 각 블로그별로 RSS 수집/동의 관리 + */ +export const memberBlogs = pgTable( + 'member_blogs', + { + id: uuid('id').primaryKey().defaultRandom(), + memberId: uuid('member_id') + .notNull() + .references(() => members.id, { onDelete: 'cascade' }), + label: varchar('label', { length: 100 }), + blogUrl: varchar('blog_url', { length: 2000 }).notNull(), + rssUrl: varchar('rss_url', { length: 2000 }), + rssConsent: boolean('rss_consent').notNull().default(true), + sortOrder: integer('sort_order').notNull().default(0), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow(), + }, + (table) => ({ + memberIdIdx: index('idx_member_blogs_member_id').on(table.memberId), + memberBlogUrlUnique: unique('uq_member_blogs_member_id_blog_url').on( + table.memberId, + table.blogUrl + ), + }) +); + // users, sessions 테이블 제거됨 — Supabase Auth가 대체 /** @@ -638,6 +669,7 @@ export const discordNotificationLogs = pgTable( // ============================================ export const membersRelations = relations(members, ({ many }) => ({ + blogs: many(memberBlogs), posts: many(posts), attendance: many(attendance), fines: many(fines), @@ -652,6 +684,13 @@ export const membersRelations = relations(members, ({ many }) => ({ postReactions: many(postReactions), })); +export const memberBlogsRelations = relations(memberBlogs, ({ one }) => ({ + member: one(members, { + fields: [memberBlogs.memberId], + references: [members.id], + }), +})); + export const fcmTokensRelations = relations(fcmTokens, ({ one }) => ({ member: one(members, { fields: [fcmTokens.memberId], @@ -836,6 +875,9 @@ export const postReactionsRelations = relations(postReactions, ({ one }) => ({ export type Member = typeof members.$inferSelect; export type NewMember = typeof members.$inferInsert; +export type MemberBlog = typeof memberBlogs.$inferSelect; +export type NewMemberBlog = typeof memberBlogs.$inferInsert; + export type Round = typeof rounds.$inferSelect; export type NewRound = typeof rounds.$inferInsert; diff --git a/packages/web/src/app/(admin)/admin/members/member-form-dialog.tsx b/packages/web/src/app/(admin)/admin/members/member-form-dialog.tsx index 5882226..edc1be0 100644 --- a/packages/web/src/app/(admin)/admin/members/member-form-dialog.tsx +++ b/packages/web/src/app/(admin)/admin/members/member-form-dialog.tsx @@ -1,22 +1,41 @@ 'use client'; -import { useState, useEffect } from 'react'; +import { useEffect, useState } from 'react'; +import { Plus, Trash2 } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; +import { Switch } from '@/components/ui/switch'; import { MEMBER_STATUS_CONFIG } from '@/lib/member-config'; +// 멤버당 블로그 최대 개수 (서버 MAX_BLOGS_PER_MEMBER와 동일, 서버가 최종 검증) +const MAX_BLOGS = 3; + +interface MemberBlog { + id: string; + label: string | null; + blogUrl: string; + rssConsent: boolean; +} + interface Member { id: string; discordId: string; discordUsername: string; name: string; part: string; - blogUrl: string; - rssUrl: string | null; + blogs: MemberBlog[]; status: string; } +interface BlogItem { + key: string; + id?: string; + label: string; + blogUrl: string; + rssConsent: boolean; +} + interface MemberFormDialogProps { open: boolean; onClose: () => void; @@ -24,26 +43,29 @@ interface MemberFormDialogProps { member: Member | null; } +let blogKeySeq = 0; +const newBlogKey = () => `blog-${Date.now()}-${blogKeySeq++}`; +const emptyBlog = (): BlogItem => ({ + key: newBlogKey(), + label: '', + blogUrl: '', + rssConsent: true, +}); + /** * Member Form Dialog * For creating and editing members * Requirements: 19.1, 19.2, 19.3, 19.4 */ -export function MemberFormDialog({ - open, - onClose, - onSuccess, - member, -}: MemberFormDialogProps) { +export function MemberFormDialog({ open, onClose, onSuccess, member }: MemberFormDialogProps) { const [formData, setFormData] = useState({ name: '', part: '', discordId: '', discordUsername: '', - blogUrl: '', - rssUrl: '', status: 'active', }); + const [blogs, setBlogs] = useState([emptyBlog()]); const [errors, setErrors] = useState([]); const [loading, setLoading] = useState(false); @@ -56,20 +78,28 @@ export function MemberFormDialog({ part: member.part, discordId: member.discordId, discordUsername: member.discordUsername, - blogUrl: member.blogUrl, - rssUrl: member.rssUrl || '', status: member.status, }); + setBlogs( + member.blogs.length > 0 + ? member.blogs.map((b) => ({ + key: newBlogKey(), + id: b.id, + label: b.label ?? '', + blogUrl: b.blogUrl, + rssConsent: b.rssConsent, + })) + : [emptyBlog()] + ); } else { setFormData({ name: '', part: '', discordId: '', discordUsername: '', - blogUrl: '', - rssUrl: '', status: 'active', }); + setBlogs([emptyBlog()]); } setErrors([]); }, [member, open]); @@ -79,10 +109,21 @@ export function MemberFormDialog({ setFormData((prev) => ({ ...prev, [name]: value })); }; + const addBlog = () => { + setBlogs((prev) => (prev.length >= MAX_BLOGS ? prev : [...prev, emptyBlog()])); + }; + + const removeBlog = (key: string) => { + setBlogs((prev) => (prev.length <= 1 ? prev : prev.filter((b) => b.key !== key))); + }; + + const updateBlog = (key: string, patch: Partial>) => { + setBlogs((prev) => prev.map((b) => (b.key === key ? { ...b, ...patch } : b))); + }; + const validateForm = (): boolean => { const newErrors: string[] = []; - // Required fields validation (Requirement: 19.2) if (!formData.name.trim()) { newErrors.push('이름은 필수입니다.'); } @@ -92,24 +133,26 @@ export function MemberFormDialog({ if (!formData.discordId.trim()) { newErrors.push('Discord ID는 필수입니다.'); } - if (!formData.blogUrl.trim()) { - newErrors.push('블로그 URL은 필수입니다.'); - } - // URL format validation - if (formData.blogUrl.trim()) { - try { - new URL(formData.blogUrl.trim()); - } catch { - newErrors.push('유효하지 않은 블로그 URL 형식입니다.'); + if (blogs.length === 0) { + newErrors.push('블로그를 1개 이상 등록해주세요.'); + } + if (blogs.length > MAX_BLOGS) { + newErrors.push(`블로그는 최대 ${MAX_BLOGS}개까지 등록할 수 있습니다.`); + } + for (const b of blogs) { + if (!b.blogUrl.trim()) { + newErrors.push('블로그 URL은 필수입니다.'); + break; } } - - if (formData.rssUrl.trim()) { - try { - new URL(formData.rssUrl.trim()); - } catch { - newErrors.push('유효하지 않은 RSS URL 형식입니다.'); + for (const b of blogs) { + if (b.blogUrl.trim()) { + try { + new URL(b.blogUrl.trim()); + } catch { + newErrors.push(`유효하지 않은 블로그 URL 형식입니다: ${b.blogUrl.trim()}`); + } } } @@ -141,8 +184,12 @@ export function MemberFormDialog({ part: formData.part.trim(), discordId: formData.discordId.trim(), discordUsername: formData.discordUsername.trim() || formData.discordId.trim(), - blogUrl: formData.blogUrl.trim(), - rssUrl: formData.rssUrl.trim() || null, + blogs: blogs.map((b) => ({ + id: b.id, + label: b.label.trim() || null, + blogUrl: b.blogUrl.trim(), + rssConsent: b.rssConsent, + })), ...(isEditing && { status: formData.status }), }), }); @@ -170,16 +217,11 @@ export function MemberFormDialog({ return (
{/* Backdrop */} -
+
{/* Dialog */} -
-

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

+
+

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

{/* Error messages */} @@ -257,34 +299,70 @@ export function MemberFormDialog({ />
- {/* Blog URL field */} -
-
@@ -169,7 +183,9 @@ export default function MemberProfilePage() {

관심 분야

{member.interests.map((interest, idx) => ( - {interest} + + {interest} + ))}
@@ -187,20 +203,18 @@ export default function MemberProfilePage() {

가입일

-

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

+

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

{/* Social Links */} {socialLinks.length > 0 && (
- {socialLinks.map((link) => { + {socialLinks.map((link, i) => { const Icon = link.icon; return ( !filterPart || m.part === filterPart) .map((member) => { const socialLinks = [ - { url: member.blogUrl, icon: ExternalLink, label: '블로그' }, + ...member.blogs.map((b) => ({ + url: b.blogUrl, + icon: ExternalLink, + label: b.label || '블로그', + })), { url: member.githubUrl, icon: Github, label: 'GitHub' }, { url: member.linkedinUrl, icon: Linkedin, label: 'LinkedIn' }, { url: member.instagramUrl, icon: Instagram, label: 'Instagram' }, @@ -199,11 +203,11 @@ export default function MembersPage() { {/* Social link chips */} {socialLinks.length > 0 && (
- {socialLinks.map((link) => { + {socialLinks.map((link, i) => { const Icon = link.icon; return ( `blog-${Date.now()}-${blogKeySeq++}`; + export default function ProfileEditPage() { const router = useRouter(); const [loading, setLoading] = useState(true); @@ -90,7 +121,7 @@ export default function ProfileEditPage() { const [nickname, setNickname] = useState(''); const [selectedPart, setSelectedPart] = useState(''); const [customPart, setCustomPart] = useState(''); - const [blogUrl, setBlogUrl] = useState(''); + const [blogs, setBlogs] = useState([]); const [profileImageUrl, setProfileImageUrl] = useState(''); const [bio, setBio] = useState(''); const [interests, setInterests] = useState([]); @@ -98,7 +129,6 @@ 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 () => { @@ -128,7 +158,17 @@ export default function ProfileEditPage() { setCustomPart(data.member.part); } } - if (data.member.blogUrl) setBlogUrl(data.member.blogUrl); + if (Array.isArray(data.member.blogs)) { + setBlogs( + data.member.blogs.map((b) => ({ + key: newBlogKey(), + id: b.id, + label: b.label ?? '', + blogUrl: b.blogUrl, + rssConsent: b.rssConsent, + })) + ); + } if (data.member.profileImageUrl) setProfileImageUrl(data.member.profileImageUrl); if (data.member.bio) setBio(data.member.bio); if (data.member.interests) setInterests(data.member.interests); @@ -136,8 +176,6 @@ 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'); @@ -159,6 +197,27 @@ export default function ProfileEditPage() { }); }; + const addBlog = () => { + setBlogs((prev) => + prev.length >= MAX_BLOGS + ? prev + : [...prev, { key: newBlogKey(), label: '', blogUrl: '', rssConsent: true }] + ); + }; + + const removeBlog = (key: string) => { + setBlogs((prev) => (prev.length <= 1 ? prev : prev.filter((b) => b.key !== key))); + }; + + const updateBlog = (key: string, patch: Partial>) => { + setBlogs((prev) => prev.map((b) => (b.key === key ? { ...b, ...patch } : b))); + }; + + const blogsValid = + blogs.length >= 1 && + blogs.length <= MAX_BLOGS && + blogs.every((b) => b.blogUrl.trim().length > 0); + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setSaving(true); @@ -173,7 +232,12 @@ export default function ProfileEditPage() { name: name.trim() || null, nickname: nickname.trim() || null, part: part || null, - blogUrl: blogUrl.trim() || null, + blogs: blogs.map((b) => ({ + id: b.id, + label: b.label.trim() || null, + blogUrl: b.blogUrl.trim(), + rssConsent: b.rssConsent, + })), profileImageUrl: profileImageUrl || null, bio: bio || null, interests: interests.length > 0 ? interests : null, @@ -181,7 +245,6 @@ export default function ProfileEditPage() { githubUrl: githubUrl || null, linkedinUrl: linkedinUrl || null, instagramUrl: instagramUrl || null, - rssConsent, }), }); @@ -401,7 +464,7 @@ export default function ProfileEditPage() { - {/* Blog URL */} + {/* Blogs */}
@@ -409,46 +472,75 @@ export default function ProfileEditPage() { 블로그
- 블로그 주소를 변경하면 RSS URL이 자동으로 재감지됩니다. + 블로그를 최대 {MAX_BLOGS}개까지 등록할 수 있습니다. 각 블로그별로 이름과 RSS 자동 수집 + 여부를 설정하세요. 주소를 변경하면 RSS가 자동으로 재감지됩니다.
- - - setBlogUrl(e.target.value)} - maxLength={500} - /> - -
- - {/* RSS 설정 */} - - -
- - RSS 설정 -
- 블로그 글 자동 수집 설정을 관리하세요. -
- -
-
- - {!rssConsent && ( -

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

- )} + + {blogs.map((blog, idx) => ( +
+
+

블로그 {idx + 1}

+ {blogs.length > 1 && ( + + )} +
+
+ + updateBlog(blog.key, { label: e.target.value })} + maxLength={100} + /> +
+
+ + updateBlog(blog.key, { blogUrl: e.target.value })} + maxLength={500} + /> +
+
+
+ + {!blog.rssConsent && ( +

+ 끄면 이 블로그 글을 직접 등록해야 합니다. +

+ )} +
+ updateBlog(blog.key, { rssConsent: checked })} + /> +
- -
+ ))} + {blogs.length < MAX_BLOGS && ( + + )} @@ -511,7 +603,7 @@ export default function ProfileEditPage() { saving || !name.trim() || !nickname.trim() || - !blogUrl.trim() || + !blogsValid || interests.length < 3 || bio.trim().length < 100 } diff --git a/packages/web/src/app/(user)/profile/onboarding/page.tsx b/packages/web/src/app/(user)/profile/onboarding/page.tsx index 2fe4b27..9d2713c 100644 --- a/packages/web/src/app/(user)/profile/onboarding/page.tsx +++ b/packages/web/src/app/(user)/profile/onboarding/page.tsx @@ -2,7 +2,17 @@ import { useEffect, useState } from 'react'; import { useRouter } from 'next/navigation'; -import { ArrowLeft, ArrowRight, Check, Heart, Loader2, Target, User } from 'lucide-react'; +import { + ArrowLeft, + ArrowRight, + Check, + Heart, + Loader2, + Plus, + Target, + Trash2, + User, +} from 'lucide-react'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; @@ -18,6 +28,19 @@ import { FormPageSkeleton } from '@/components/ui/page-state'; const TOTAL_STEPS = 3; +// 멤버당 블로그 최대 개수 (서버 MAX_BLOGS_PER_MEMBER와 동일, 서버가 최종 검증) +const MAX_BLOGS = 3; + +interface BlogItem { + key: string; + label: string; + blogUrl: string; + rssConsent: boolean; +} + +let blogKeySeq = 0; +const newBlogKey = () => `blog-${Date.now()}-${blogKeySeq++}`; + export default function OnboardingPage() { const router = useRouter(); const [loading, setLoading] = useState(true); @@ -30,7 +53,9 @@ export default function OnboardingPage() { const [nickname, setNickname] = useState(''); const [selectedPart, setSelectedPart] = useState(''); const [customPart, setCustomPart] = useState(''); - const [blogUrl, setBlogUrl] = useState(''); + const [blogs, setBlogs] = useState([ + { key: newBlogKey(), label: '', blogUrl: '', rssConsent: true }, + ]); const [interests, setInterests] = useState([]); const [bio, setBio] = useState(''); const [profileImageUrl, setProfileImageUrl] = useState(''); @@ -39,7 +64,6 @@ 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 () => { @@ -82,12 +106,29 @@ export default function OnboardingPage() { }); }; + const addBlog = () => { + setBlogs((prev) => + prev.length >= MAX_BLOGS + ? prev + : [...prev, { key: newBlogKey(), label: '', blogUrl: '', rssConsent: true }] + ); + }; + + const removeBlog = (key: string) => { + setBlogs((prev) => (prev.length <= 1 ? prev : prev.filter((b) => b.key !== key))); + }; + + const updateBlog = (key: string, patch: Partial>) => { + setBlogs((prev) => prev.map((b) => (b.key === key ? { ...b, ...patch } : b))); + }; + const part = selectedPart === 'other' ? customPart.trim() : selectedPart; + const blogsValid = + blogs.length >= 1 && + blogs.length <= MAX_BLOGS && + blogs.every((b) => b.blogUrl.trim().length > 0); const isStep1Valid = - name.trim().length > 0 && - nickname.trim().length > 0 && - part.length > 0 && - blogUrl.trim().length > 0; + name.trim().length > 0 && nickname.trim().length > 0 && part.length > 0 && blogsValid; const isStep2Valid = interests.length >= 3 && interests.length <= 6 && bio.trim().length >= 100; const isStep3Valid = resolution.trim().length > 0; @@ -111,7 +152,7 @@ export default function OnboardingPage() { if (!name.trim()) missing.push('이름'); if (!nickname.trim()) missing.push('닉네임'); if (!part) missing.push('파트'); - if (!blogUrl.trim()) missing.push('블로그 URL'); + if (!blogsValid) missing.push('블로그 URL'); if (missing.length > 0) toast.error(`${missing.join(', ')}을(를) 입력해주세요.`); break; } @@ -140,7 +181,11 @@ export default function OnboardingPage() { name: name.trim(), nickname: nickname.trim(), part, - blogUrl: blogUrl.trim(), + blogs: blogs.map((b) => ({ + label: b.label.trim() || null, + blogUrl: b.blogUrl.trim(), + rssConsent: b.rssConsent, + })), profileImageUrl: profileImageUrl || null, bio: bio.trim(), interests, @@ -148,7 +193,6 @@ export default function OnboardingPage() { githubUrl: githubUrl.trim() || null, linkedinUrl: linkedinUrl.trim() || null, instagramUrl: instagramUrl.trim() || null, - rssConsent, }), }); @@ -274,36 +318,70 @@ export default function OnboardingPage() { )}
-
-
+

가입일

@@ -515,9 +530,7 @@ export default function ProfilePage() { - - 정말 탈퇴하시겠습니까? - + 정말 탈퇴하시겠습니까? 탈퇴하면 스터디 활동이 중단되며, 다시 참가하려면 관리자 승인이 필요합니다. diff --git a/packages/web/src/app/api/admin/members/[id]/route.ts b/packages/web/src/app/api/admin/members/[id]/route.ts index ea8dfba..b14929c 100644 --- a/packages/web/src/app/api/admin/members/[id]/route.ts +++ b/packages/web/src/app/api/admin/members/[id]/route.ts @@ -1,13 +1,11 @@ import { NextRequest, NextResponse } from 'next/server'; -import { eq } from 'drizzle-orm'; +import { asc, eq } from 'drizzle-orm'; import { db } from '@/lib/db'; -import { db as sharedDb, utils } from '@blog-study/shared'; +import { db as sharedDb } from '@blog-study/shared'; import { withAdminAuth } from '@/lib/admin'; -import { detectRssUrl } from '@/lib/rss-detect'; +import { syncMemberBlogs, validateBlogInputs } from '@/lib/member-blogs'; -const { isValidBlogUrl } = utils; - -const { members, MemberStatus, rounds } = sharedDb; +const { members, memberBlogs, MemberStatus, rounds } = sharedDb; /** * GET /api/admin/members/[id] @@ -27,7 +25,26 @@ export const GET = withAdminAuth(async (request: NextRequest, _adminAuth) => { return NextResponse.json({ message: '멤버를 찾을 수 없습니다.' }, { status: 404 }); } - return NextResponse.json({ message: 'success', member }); + const blogs = await database + .select() + .from(memberBlogs) + .where(eq(memberBlogs.memberId, member.id)) + .orderBy(asc(memberBlogs.sortOrder)); + + return NextResponse.json({ + message: 'success', + member: { + ...member, + blogs: blogs.map((b) => ({ + id: b.id, + label: b.label, + blogUrl: b.blogUrl, + rssUrl: b.rssUrl, + rssConsent: b.rssConsent, + sortOrder: b.sortOrder, + })), + }, + }); } catch (error) { console.error('Admin get member error:', error); return NextResponse.json({ message: '서버 오류가 발생했습니다.' }, { status: 500 }); @@ -47,7 +64,7 @@ export const PUT = withAdminAuth(async (request: NextRequest, _adminAuth) => { } const body = await request.json(); - const { name, part, discordId, discordUsername, blogUrl, rssUrl, status } = body; + const { name, part, discordId, discordUsername, blogs, status } = body; const database = db(); @@ -76,13 +93,9 @@ export const PUT = withAdminAuth(async (request: NextRequest, _adminAuth) => { ) { errors.push('Discord ID는 필수입니다.'); } - if (blogUrl !== undefined && (typeof blogUrl !== 'string' || blogUrl.trim().length === 0)) { - errors.push('블로그 URL은 필수입니다.'); - } - - // Validate blog URL format - if (blogUrl && !isValidBlogUrl(blogUrl)) { - errors.push('유효하지 않은 블로그 URL 형식입니다.'); + const blogValidation = blogs !== undefined ? validateBlogInputs(blogs, true) : null; + if (blogValidation && !blogValidation.ok) { + errors.push(blogValidation.message); } // Validate status @@ -124,8 +137,6 @@ export const PUT = withAdminAuth(async (request: NextRequest, _adminAuth) => { if (part !== undefined) updateData.part = part.trim(); if (discordId !== undefined) updateData.discordId = discordId.trim(); if (discordUsername !== undefined) updateData.discordUsername = discordUsername.trim(); - if (blogUrl !== undefined) updateData.blogUrl = blogUrl.trim(); - if (rssUrl !== undefined) updateData.rssUrl = rssUrl?.trim() || null; if (status !== undefined) { // 휴면 전환 시 전용 로직 (1회 제한 해제됨 — 관리자가 필요에 따라 반복 전환 가능) if (status === MemberStatus.DORMANT) { @@ -152,12 +163,6 @@ export const PUT = withAdminAuth(async (request: NextRequest, _adminAuth) => { updateData.status = status; } - // RSS URL 자동 감지: rssUrl이 비어있고 blogUrl이 있으면 감지 시도 - const targetBlogUrl = updateData.blogUrl ?? existingMember.blogUrl; - if (!updateData.rssUrl && targetBlogUrl) { - updateData.rssUrl = await detectRssUrl(targetBlogUrl); - } - // Update member const [updatedMember] = await database .update(members) @@ -165,6 +170,11 @@ export const PUT = withAdminAuth(async (request: NextRequest, _adminAuth) => { .where(eq(members.id, id)) .returning(); + // 블로그 동기화 (제공된 경우만) + if (blogValidation && blogValidation.ok) { + await syncMemberBlogs(id, blogValidation.value); + } + return NextResponse.json({ message: '멤버 정보가 수정되었습니다.', member: updatedMember, diff --git a/packages/web/src/app/api/admin/members/route.ts b/packages/web/src/app/api/admin/members/route.ts index c8d5cff..7ee38f8 100644 --- a/packages/web/src/app/api/admin/members/route.ts +++ b/packages/web/src/app/api/admin/members/route.ts @@ -1,13 +1,11 @@ import { NextRequest, NextResponse } from 'next/server'; -import { asc, count, eq, isNull, sql } from 'drizzle-orm'; +import { asc, count, eq, inArray, isNull, sql } from 'drizzle-orm'; import { db } from '@/lib/db'; -import { db as sharedDb, utils } from '@blog-study/shared'; +import { db as sharedDb } from '@blog-study/shared'; import { withAdminAuth } from '@/lib/admin'; -import { detectRssUrl } from '@/lib/rss-detect'; +import { createMemberBlogs, validateBlogInputs } from '@/lib/member-blogs'; -const { isValidBlogUrl } = utils; - -const { members, posts, attendance, AttendanceStatus, MemberStatus } = sharedDb; +const { members, memberBlogs, posts, attendance, AttendanceStatus, MemberStatus } = sharedDb; /** * GET /api/admin/members @@ -30,6 +28,23 @@ export const GET = withAdminAuth(async (request: NextRequest, _adminAuth) => { const membersList = await query.orderBy(asc(members.name)); + // Get blogs for all listed members + const memberIds = membersList.map((m) => m.id); + const blogRows = + memberIds.length > 0 + ? await database + .select() + .from(memberBlogs) + .where(inArray(memberBlogs.memberId, memberIds)) + .orderBy(asc(memberBlogs.sortOrder)) + : []; + const blogMap = new Map(); + for (const b of blogRows) { + const list = blogMap.get(b.memberId) ?? []; + list.push(b); + blogMap.set(b.memberId, list); + } + // Get post counts for all members const postCounts = await database .select({ @@ -89,13 +104,18 @@ export const GET = withAdminAuth(async (request: NextRequest, _adminAuth) => { name: member.name, nickname: member.nickname, part: member.part, - blogUrl: member.blogUrl, - rssUrl: member.rssUrl, + blogs: (blogMap.get(member.id) ?? []).map((b) => ({ + id: b.id, + label: b.label, + blogUrl: b.blogUrl, + rssUrl: b.rssUrl, + rssConsent: b.rssConsent, + sortOrder: b.sortOrder, + })), profileImageUrl: member.profileImageUrl, bio: member.bio, interests: member.interests, resolution: member.resolution, - rssConsent: member.rssConsent ?? true, status: member.status, onboardingCompleted: member.onboardingCompleted, dormantUsed: member.dormantUsed, @@ -143,7 +163,7 @@ export const GET = withAdminAuth(async (request: NextRequest, _adminAuth) => { export const POST = withAdminAuth(async (request: NextRequest, _adminAuth) => { try { const body = await request.json(); - const { name, part, discordId, discordUsername, blogUrl, rssUrl } = body; + const { name, part, discordId, discordUsername, blogs } = body; // Validate required fields (Requirement: 19.2) const errors: string[] = []; @@ -156,13 +176,10 @@ export const POST = withAdminAuth(async (request: NextRequest, _adminAuth) => { if (!discordId || typeof discordId !== 'string' || discordId.trim().length === 0) { errors.push('Discord ID는 필수입니다.'); } - if (!blogUrl || typeof blogUrl !== 'string' || blogUrl.trim().length === 0) { - errors.push('블로그 URL은 필수입니다.'); - } - // Validate blog URL format - if (blogUrl && !isValidBlogUrl(blogUrl)) { - errors.push('유효하지 않은 블로그 URL 형식입니다.'); + const blogValidation = validateBlogInputs(blogs, true); + if (!blogValidation.ok) { + errors.push(blogValidation.message); } if (errors.length > 0) { @@ -182,12 +199,6 @@ export const POST = withAdminAuth(async (request: NextRequest, _adminAuth) => { return NextResponse.json({ message: '이미 등록된 Discord ID입니다.' }, { status: 409 }); } - // RSS URL 자동 감지 (비어있으면 blogUrl로부터 감지 시도) - let resolvedRssUrl = rssUrl?.trim() || null; - if (!resolvedRssUrl && blogUrl) { - resolvedRssUrl = await detectRssUrl(blogUrl.trim()); - } - // Create member (Requirement: 19.3) const [newMember] = await database .insert(members) @@ -197,12 +208,14 @@ export const POST = withAdminAuth(async (request: NextRequest, _adminAuth) => { part: part.trim(), discordId: discordId.trim(), discordUsername: discordUsername?.trim() || discordId.trim(), - blogUrl: blogUrl.trim(), - rssUrl: resolvedRssUrl, status: MemberStatus.ACTIVE, }) .returning(); + if (newMember && blogValidation.ok) { + await createMemberBlogs(newMember.id, blogValidation.value); + } + return NextResponse.json({ message: '멤버가 등록되었습니다.', member: newMember, diff --git a/packages/web/src/app/api/members/[id]/route.ts b/packages/web/src/app/api/members/[id]/route.ts index 3fa7aa7..94a32d4 100644 --- a/packages/web/src/app/api/members/[id]/route.ts +++ b/packages/web/src/app/api/members/[id]/route.ts @@ -1,11 +1,11 @@ import { NextRequest } from 'next/server'; -import { and, count, desc, eq, isNull, sql } from 'drizzle-orm'; +import { and, asc, count, desc, eq, isNull, sql } from 'drizzle-orm'; import { db } from '@/lib/db'; import { db as sharedDb } from '@blog-study/shared'; import { createClient } from '@/lib/supabase/server'; import { errorResponse, Errors, successResponse, withCache } from '@/lib/api-error'; -const { members, posts, attendance, AttendanceStatus } = sharedDb; +const { members, memberBlogs, posts, attendance, AttendanceStatus } = sharedDb; /** * GET /api/members/[id] @@ -57,6 +57,17 @@ export async function GET(_request: NextRequest, { params }: { params: Promise<{ const attStats = attendanceStats[0] || { total: 0, submitted: 0, late: 0, absent: 0 }; + const blogs = await database + .select({ + id: memberBlogs.id, + label: memberBlogs.label, + blogUrl: memberBlogs.blogUrl, + sortOrder: memberBlogs.sortOrder, + }) + .from(memberBlogs) + .where(eq(memberBlogs.memberId, member.id)) + .orderBy(asc(memberBlogs.sortOrder)); + // Get recent posts (soft deleted 제외) const recentPosts = await database .select({ @@ -78,7 +89,7 @@ export async function GET(_request: NextRequest, { params }: { params: Promise<{ name: member.name, nickname: member.nickname, part: member.part, - blogUrl: member.blogUrl, + blogs: blogs.map((b) => ({ id: b.id, label: b.label, blogUrl: b.blogUrl })), profileImageUrl: member.profileImageUrl, bio: member.bio, interests: member.interests, diff --git a/packages/web/src/app/api/members/route.ts b/packages/web/src/app/api/members/route.ts index 4986f05..d05fd67 100644 --- a/packages/web/src/app/api/members/route.ts +++ b/packages/web/src/app/api/members/route.ts @@ -1,12 +1,12 @@ import { NextRequest } from 'next/server'; -import { count, eq, inArray, isNull, sql } from 'drizzle-orm'; +import { asc, count, eq, inArray, isNull, sql } from 'drizzle-orm'; import { db } from '@/lib/db'; import { db as sharedDb } from '@blog-study/shared'; import { createClient } from '@/lib/supabase/server'; import { errorResponse, Errors, successResponse, withCache } from '@/lib/api-error'; import { getAdminDiscordIds } from '@/lib/admin'; -const { members, posts, attendance, AttendanceStatus } = sharedDb; +const { members, memberBlogs, posts, attendance, AttendanceStatus } = sharedDb; const ALLOWED_STATUSES = ['active', 'dormant', 'ob']; @@ -47,6 +47,29 @@ export async function GET(request: NextRequest) { statuses.length === 1 ? eq(members.status, statuses[0]!) : inArray(members.status, statuses) ); + // Get blogs for listed members + const memberIds = membersList.map((m) => m.id); + const blogRows = + memberIds.length > 0 + ? await database + .select({ + memberId: memberBlogs.memberId, + id: memberBlogs.id, + label: memberBlogs.label, + blogUrl: memberBlogs.blogUrl, + sortOrder: memberBlogs.sortOrder, + }) + .from(memberBlogs) + .where(inArray(memberBlogs.memberId, memberIds)) + .orderBy(asc(memberBlogs.sortOrder)) + : []; + const blogMap = new Map(); + for (const b of blogRows) { + const list = blogMap.get(b.memberId) ?? []; + list.push({ id: b.id, label: b.label, blogUrl: b.blogUrl }); + blogMap.set(b.memberId, list); + } + // Get post counts for all members (soft deleted 제외) const postCounts = await database .select({ @@ -87,7 +110,7 @@ export async function GET(request: NextRequest) { name: member.name, nickname: member.nickname, part: member.part, - blogUrl: member.blogUrl, + blogs: blogMap.get(member.id) ?? [], profileImageUrl: member.profileImageUrl, bio: member.bio, status: member.status, diff --git a/packages/web/src/app/api/profile/edit/route.ts b/packages/web/src/app/api/profile/edit/route.ts index 3c63e6c..b70b71a 100644 --- a/packages/web/src/app/api/profile/edit/route.ts +++ b/packages/web/src/app/api/profile/edit/route.ts @@ -1,10 +1,11 @@ -import { after, NextRequest } from 'next/server'; +import { NextRequest } from 'next/server'; import { eq } from 'drizzle-orm'; import { db } from '@/lib/db'; import { db as sharedDb } from '@blog-study/shared'; import { createClient } from '@/lib/supabase/server'; import { errorResponse, Errors, successResponse } from '@/lib/api-error'; -import { detectRssUrl, isSafeUrl } from '@/lib/rss-detect'; +import { isSafeUrl } from '@/lib/rss-detect'; +import { syncMemberBlogs, validateBlogInputs } from '@/lib/member-blogs'; const { members } = sharedDb; @@ -46,7 +47,7 @@ export async function PUT(request: NextRequest) { name, nickname, part, - blogUrl, + blogs, profileImageUrl, bio, interests, @@ -54,7 +55,6 @@ export async function PUT(request: NextRequest) { githubUrl, linkedinUrl, instagramUrl, - rssConsent, } = body; // --- 검증 --- @@ -71,21 +71,10 @@ export async function PUT(request: NextRequest) { return Errors.badRequest('파트는 50자 이내의 문자열이어야 합니다.').toResponse(); } - // 블로그 URL 검증 - const trimmedBlogUrl = typeof blogUrl === 'string' ? blogUrl.trim() : null; - const blogUrlChanged = - typeof blogUrl === 'string' && - trimmedBlogUrl !== null && - trimmedBlogUrl.length > 0 && - trimmedBlogUrl !== memberData.blogUrl; - - if (blogUrlChanged) { - if (trimmedBlogUrl!.length > 500) { - return Errors.badRequest('블로그 URL은 500자 이내여야 합니다.').toResponse(); - } - if (!isSafeUrl(trimmedBlogUrl!)) { - return Errors.badRequest('유효하지 않은 블로그 URL입니다.').toResponse(); - } + // 블로그 목록 검증 (1~MAX개, 각 SSRF 체크, 이름 길이) + const blogValidation = validateBlogInputs(blogs, true); + if (!blogValidation.ok) { + return Errors.badRequest(blogValidation.message).toResponse(); } // 프로필 이미지 URL 검증 (SSRF 방지) @@ -129,13 +118,6 @@ export async function PUT(request: NextRequest) { // --- DB 업데이트 --- - // 블로그 URL 변경 시: 즉시 blogUrl 저장 + rssUrl null 초기화, RSS 감지는 after()로 비동기 처리 - const blogFields: { blogUrl?: string; rssUrl?: string | null } = {}; - if (blogUrlChanged) { - blogFields.blogUrl = trimmedBlogUrl!; - blogFields.rssUrl = null; - } - await database .update(members) .set({ @@ -146,7 +128,6 @@ export async function PUT(request: NextRequest) { ? { nickname: nickname.trim() } : {}), ...(part ? { part } : {}), - ...blogFields, profileImageUrl: profileImageUrl || null, bio: bio || null, interests: interests || null, @@ -154,27 +135,12 @@ 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)); - // RSS URL 비동기 감지 (fire-and-forget) - if (blogUrlChanged) { - after(async () => { - try { - const newRssUrl = await detectRssUrl(trimmedBlogUrl!); - if (newRssUrl) { - await db() - .update(members) - .set({ rssUrl: newRssUrl, updatedAt: new Date() }) - .where(eq(members.id, memberData.id)); - } - } catch (err) { - console.error('[profile-edit] RSS 재감지 실패:', err); - } - }); - } + // 블로그 동기화 (추가/수정/삭제 + 변경분 RSS 비동기 재감지) + await syncMemberBlogs(memberData.id, blogValidation.value); return successResponse(null, '프로필이 수정되었습니다.'); } catch (error) { diff --git a/packages/web/src/app/api/profile/onboarding/route.ts b/packages/web/src/app/api/profile/onboarding/route.ts index 991026e..00dde25 100644 --- a/packages/web/src/app/api/profile/onboarding/route.ts +++ b/packages/web/src/app/api/profile/onboarding/route.ts @@ -6,6 +6,7 @@ import { config } from '@blog-study/shared/db'; import { createClient } from '@/lib/supabase/server'; import { notifyNewMemberPendingApproval } from '@/lib/discord-notify'; import { isSafeUrl } from '@/lib/rss-detect'; +import { createMemberBlogs, syncMemberBlogs, validateBlogInputs } from '@/lib/member-blogs'; const { members } = sharedDb; @@ -40,7 +41,7 @@ export async function POST(request: NextRequest) { name, nickname, part, - blogUrl, + blogs, profileImageUrl, bio, interests, @@ -48,7 +49,6 @@ export async function POST(request: NextRequest) { githubUrl, linkedinUrl, instagramUrl, - rssConsent, } = body; // 필수 필드 검증 @@ -64,14 +64,12 @@ export async function POST(request: NextRequest) { return NextResponse.json({ message: '파트는 필수입니다.' }, { status: 400 }); } - if (!blogUrl || typeof blogUrl !== 'string') { - return NextResponse.json({ message: '블로그 URL은 필수입니다.' }, { status: 400 }); - } - - // 블로그 URL 검증 (SSRF 방지 포함) - if (!isSafeUrl(blogUrl)) { - return NextResponse.json({ message: '유효하지 않은 블로그 URL입니다.' }, { status: 400 }); + // 블로그 목록 검증 (1~MAX개, 각 SSRF 체크, 이름 길이) + const blogValidation = validateBlogInputs(blogs, true); + if (!blogValidation.ok) { + return NextResponse.json({ message: blogValidation.message }, { status: 400 }); } + const blogList = blogValidation.value; if (!bio || typeof bio !== 'string' || bio.trim().length < 100) { return NextResponse.json({ message: '자기소개는 100자 이상 작성해주세요.' }, { status: 400 }); @@ -127,7 +125,6 @@ export async function POST(request: NextRequest) { name: name.trim(), nickname: nickname.trim(), part, - blogUrl, profileImageUrl: profileImageUrl || null, bio: bio.trim(), interests, @@ -135,33 +132,41 @@ export async function POST(request: NextRequest) { githubUrl: githubUrl || null, linkedinUrl: linkedinUrl || null, instagramUrl: instagramUrl || null, - rssConsent: rssConsent !== false, onboardingCompleted: true, updatedAt: new Date(), }) .where(eq(members.id, existingMember.id)); + + // 블로그 동기화 (재온보딩 케이스) + await syncMemberBlogs(existingMember.id, blogList); } else { // 신규 유저: INSERT - await database.insert(members).values({ - discordId, - discordUsername, - name: name.trim(), - nickname: nickname.trim(), - part, - blogUrl, - profileImageUrl: profileImageUrl || null, - bio: bio.trim(), - interests, - resolution: resolution.trim(), - githubUrl: githubUrl || null, - linkedinUrl: linkedinUrl || null, - instagramUrl: instagramUrl || null, - rssConsent: rssConsent !== false, - onboardingCompleted: true, - status: 'pending_approval', - }); + const [newMember] = await database + .insert(members) + .values({ + discordId, + discordUsername, + name: name.trim(), + nickname: nickname.trim(), + part, + profileImageUrl: profileImageUrl || null, + bio: bio.trim(), + interests, + resolution: resolution.trim(), + githubUrl: githubUrl || null, + linkedinUrl: linkedinUrl || null, + instagramUrl: instagramUrl || null, + onboardingCompleted: true, + status: 'pending_approval', + }) + .returning({ id: members.id }); + + if (newMember) { + await createMemberBlogs(newMember.id, blogList); + } // 관리자 채널에 승인대기 알림 (fire-and-forget) + const primaryBlogUrl = blogList[0]?.blogUrl ?? ''; after(async () => { try { const [channelConfig] = await database @@ -178,7 +183,7 @@ export async function POST(request: NextRequest) { name: name.trim(), discordUsername, part, - blogUrl, + blogUrl: primaryBlogUrl, bio: bio.trim(), adminDashboardUrl: 'https://kusting-web.vercel.app/admin/members', }); diff --git a/packages/web/src/app/api/profile/route.ts b/packages/web/src/app/api/profile/route.ts index 39213f5..12bf53a 100644 --- a/packages/web/src/app/api/profile/route.ts +++ b/packages/web/src/app/api/profile/route.ts @@ -4,6 +4,7 @@ import { db } from '@/lib/db'; import { db as sharedDb } from '@blog-study/shared'; import { createClient } from '@/lib/supabase/server'; import { errorResponse, Errors } from '@/lib/api-error'; +import { fetchMemberBlogs } from '@/lib/member-blogs'; const { members, posts, attendance, fines, AttendanceStatus, FineStatus } = sharedDb; @@ -79,6 +80,8 @@ export async function GET() { } } + const blogs = memberData ? await fetchMemberBlogs(memberData.id) : []; + return NextResponse.json({ user: { id: user.id, @@ -95,13 +98,11 @@ export async function GET() { name: memberData.name, nickname: memberData.nickname, part: memberData.part, - blogUrl: memberData.blogUrl, - rssUrl: memberData.rssUrl, + blogs, profileImageUrl: memberData.profileImageUrl, 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/lib/member-blogs.ts b/packages/web/src/lib/member-blogs.ts new file mode 100644 index 0000000..4441dd3 --- /dev/null +++ b/packages/web/src/lib/member-blogs.ts @@ -0,0 +1,239 @@ +/** + * 멤버 블로그(member_blogs) 공용 헬퍼 + * 프로필 수정 / 온보딩 / 관리자 멤버 생성·수정에서 공통 사용 + */ + +import { after } from 'next/server'; +import { and, asc, eq, inArray } from 'drizzle-orm'; +import { db as sharedDb } from '@blog-study/shared'; +import { db } from '@/lib/db'; +import { detectRssUrl, isSafeUrl } from '@/lib/rss-detect'; + +const { memberBlogs, MAX_BLOGS_PER_MEMBER } = sharedDb; + +export const MAX_BLOGS = MAX_BLOGS_PER_MEMBER; + +const MAX_URL_LEN = 500; +const MAX_LABEL_LEN = 100; +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +export interface BlogInput { + id?: string; + label?: string | null; + blogUrl?: string; + rssConsent?: boolean; +} + +export interface NormalizedBlog { + id?: string; + label: string | null; + blogUrl: string; + rssConsent: boolean; +} + +export type ValidateResult = + | { ok: true; value: NormalizedBlog[] } + | { ok: false; message: string }; + +/** + * 클라이언트가 보낸 blogs 배열 검증 + 정규화 + * @param required 최소 1개 필요 여부 (온보딩/생성=true) + */ +export function validateBlogInputs(raw: unknown, required = true): ValidateResult { + if (!Array.isArray(raw)) { + return { ok: false, message: '블로그 목록 형식이 올바르지 않습니다.' }; + } + + if (raw.length === 0) { + if (required) return { ok: false, message: '블로그를 1개 이상 등록해주세요.' }; + return { ok: true, value: [] }; + } + + if (raw.length > MAX_BLOGS) { + return { ok: false, message: `블로그는 최대 ${MAX_BLOGS}개까지 등록할 수 있습니다.` }; + } + + const normalized: NormalizedBlog[] = []; + const seenUrls = new Set(); + + for (const item of raw as BlogInput[]) { + const blogUrl = typeof item?.blogUrl === 'string' ? item.blogUrl.trim() : ''; + if (!blogUrl) { + return { ok: false, message: '블로그 URL은 필수입니다.' }; + } + if (blogUrl.length > MAX_URL_LEN) { + return { ok: false, message: `블로그 URL은 ${MAX_URL_LEN}자 이내여야 합니다.` }; + } + if (!isSafeUrl(blogUrl)) { + return { ok: false, message: `유효하지 않은 블로그 URL입니다: ${blogUrl}` }; + } + + const dedupeKey = blogUrl.toLowerCase(); + if (seenUrls.has(dedupeKey)) { + return { ok: false, message: '중복된 블로그 URL이 있습니다.' }; + } + seenUrls.add(dedupeKey); + + let label: string | null = null; + if (typeof item?.label === 'string') { + const trimmed = item.label.trim(); + if (trimmed.length > MAX_LABEL_LEN) { + return { ok: false, message: `블로그 이름은 ${MAX_LABEL_LEN}자 이내여야 합니다.` }; + } + label = trimmed.length > 0 ? trimmed : null; + } + + const id = + typeof item?.id === 'string' && UUID_RE.test(item.id) ? item.id : undefined; + const rssConsent = item?.rssConsent !== false; + + normalized.push({ id, label, blogUrl, rssConsent }); + } + + return { ok: true, value: normalized }; +} + +/** + * 멤버 블로그 조회 (sort_order 순) + */ +export async function fetchMemberBlogs(memberId: string) { + const rows = await db() + .select() + .from(memberBlogs) + .where(eq(memberBlogs.memberId, memberId)) + .orderBy(asc(memberBlogs.sortOrder)); + + return rows.map((b) => ({ + id: b.id, + label: b.label, + blogUrl: b.blogUrl, + rssUrl: b.rssUrl, + rssConsent: b.rssConsent, + sortOrder: b.sortOrder, + })); +} + +/** + * 신규 멤버의 블로그 일괄 생성 (온보딩/관리자 생성) + * 각 블로그 RSS URL은 after()로 비동기 감지 + */ +export async function createMemberBlogs( + memberId: string, + blogs: NormalizedBlog[] +): Promise { + if (blogs.length === 0) return; + + const inserted = await db() + .insert(memberBlogs) + .values( + blogs.map((b, idx) => ({ + memberId, + label: b.label, + blogUrl: b.blogUrl, + rssUrl: null, + rssConsent: b.rssConsent, + sortOrder: idx, + })) + ) + .returning({ id: memberBlogs.id, blogUrl: memberBlogs.blogUrl }); + + scheduleRssDetection(inserted); +} + +/** + * 멤버 블로그 동기화 (프로필/관리자 수정) + * - id 매칭: label/rssConsent/sortOrder 갱신, blogUrl 변경 시 rssUrl 초기화 + 재감지 + * - id 없음: 신규 insert + 재감지 + * - 입력에 없는 기존 행: 삭제 + */ +export async function syncMemberBlogs( + memberId: string, + blogs: NormalizedBlog[] +): Promise { + const toRedetect: { id: string; blogUrl: string }[] = []; + + // 추가/수정/삭제를 단일 트랜잭션으로 처리 — 도중 실패 시 전체 롤백 + await db().transaction(async (tx) => { + const existing = await tx + .select() + .from(memberBlogs) + .where(eq(memberBlogs.memberId, memberId)); + + const existingById = new Map(existing.map((b) => [b.id, b])); + const keptIds = new Set(); + + for (let idx = 0; idx < blogs.length; idx++) { + const input = blogs[idx]!; + const current = input.id ? existingById.get(input.id) : undefined; + + if (current) { + keptIds.add(current.id); + const urlChanged = current.blogUrl !== input.blogUrl; + await tx + .update(memberBlogs) + .set({ + label: input.label, + blogUrl: input.blogUrl, + rssConsent: input.rssConsent, + sortOrder: idx, + ...(urlChanged ? { rssUrl: null } : {}), + updatedAt: new Date(), + }) + // memberId도 함께 스코프 (방어적 — current는 이미 멤버 소유 행) + .where(and(eq(memberBlogs.id, current.id), eq(memberBlogs.memberId, memberId))); + + if (urlChanged) toRedetect.push({ id: current.id, blogUrl: input.blogUrl }); + } else { + const [created] = await tx + .insert(memberBlogs) + .values({ + memberId, + label: input.label, + blogUrl: input.blogUrl, + rssUrl: null, + rssConsent: input.rssConsent, + sortOrder: idx, + }) + .returning({ id: memberBlogs.id }); + if (created) { + keptIds.add(created.id); + toRedetect.push({ id: created.id, blogUrl: input.blogUrl }); + } + } + } + + // 입력에서 빠진 기존 블로그 삭제 + const removedIds = existing.filter((b) => !keptIds.has(b.id)).map((b) => b.id); + if (removedIds.length > 0) { + await tx + .delete(memberBlogs) + .where(and(eq(memberBlogs.memberId, memberId), inArray(memberBlogs.id, removedIds))); + } + }); + + // 커밋 후 RSS 비동기 재감지 스케줄 + scheduleRssDetection(toRedetect); +} + +/** + * RSS URL 비동기 감지 (fire-and-forget, Vercel after()) + */ +function scheduleRssDetection(targets: { id: string; blogUrl: string }[]): void { + if (targets.length === 0) return; + + after(async () => { + for (const t of targets) { + try { + const rssUrl = await detectRssUrl(t.blogUrl); + if (rssUrl) { + await db() + .update(memberBlogs) + .set({ rssUrl, updatedAt: new Date() }) + .where(eq(memberBlogs.id, t.id)); + } + } catch (err) { + console.error('[member-blogs] RSS 재감지 실패:', t.blogUrl, err); + } + } + }); +}