diff --git a/CLAUDE.md b/CLAUDE.md index 2390180..e7d9ec6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -70,7 +70,7 @@ pnpm --filter @blog-study/bot rss-collect # 수동 RSS 수집 (봇 없이) - **포스트 수정**: 본인 또는 관리자만 제목/설명 수정 가능 (`PATCH /api/posts/[id]`) - **공지 알림**: 게시판 공지 작성 시 FCM 푸시 + Discord 공지채널(`notice_channel_id`) `@everyone` + embed(제목+본문 미리보기 500자) + 웹 딥링크 버튼 - **벌금 납부**: 웹 `/profile/fines`에서 본인 납부 처리 (atomic update), 납부 시 관리자 Discord 채널 알림. 계좌 정보: 3333333114501 카카오뱅크 -- **리마인더 푸시**: 벌금 알림(`fine_notification`)/벌금 리마인더(`fine_reminder`)/마감 리마인더(`deadline_reminder`)/지각 독촉(`grace_nudge`)/투표 리마인더(`poll_reminder`) 5종은 Discord DM 대신 FCM 푸시로 발송. 봇→웹 내부 API(`/api/internal/reminder-push`) 경유. `FORCE_SEND_TYPES`로 유저가 끌 수 없음. **FCM 푸시 body는 plain text** (`**bold**` 등 마크다운 렌더 안 됨 — 강조 시 따옴표 `'X'` 사용) +- **리마인더 푸시**: 벌금 알림(`fine_notification`)/벌금 리마인더(`fine_reminder`)/마감 리마인더(`deadline_reminder`)/지각 독촉(`grace_nudge`)/투표 리마인더(`poll_reminder`) 5종은 Discord DM 대신 FCM 푸시로 발송. 봇→웹 내부 API(`/api/internal/reminder-push`) 경유. `FORCE_SEND_TYPES`로 유저가 끌 수 없음. **FCM 푸시 body는 plain text** (`**bold**` 등 마크다운 렌더 안 됨 — 강조 시 따옴표 `'X'` 사용). `PushResult`(`push.ts`)는 `success`/`failed`/`skipped`/`noToken` 카운트 반환. 벌금 리마인더에서 `noToken`(FCM 토큰 미등록) 케이스는 `sent=false`로 처리하되 `lastReminderAt`을 갱신해 매일 무한 재시도 차단. `addPendingConfirmation`은 `sent===true`일 때만 호출 - **D-Day 계산**: KST 캘린더 날짜 기준 (midnight 비교, 당일=D-Day=0), 제출률은 active 유저만 카운트 - **알림 로그**: `discord_notification_logs` 테이블에 봇/웹 모든 채널+DM+푸시 알림 성공/실패 기록 (target: `channel`/`dm`/`push`), `logNotification()` 헬퍼 (봇: `notification-logger.ts`, 웹: `notification-log.ts`), 관리자 페이지 "알림 로그" 탭에서 조회 (타입/소스/대상/상태 필터 + 무한 스크롤, 푸시 로그에 수신자 닉네임 표시) - **비밀답글 가시성**: 비밀 답글은 작성자/포스트작성자/부모댓글작성자/관리자가 열람 가능 @@ -80,7 +80,7 @@ pnpm --filter @blog-study/bot rss-collect # 수동 RSS 수집 (봇 없이) - **백그라운드 작업**: API route에서 푸시 알림/점수 부여 등 fire-and-forget 작업은 `after()` from `next/server` 사용 (Vercel 서버리스 종료 방지) - **비밀댓글 알림**: 비밀댓글(`isSecret`)의 푸시 알림은 내용 마스킹 (`'비밀 댓글이 달렸습니다.'`), 포스트/게시판 댓글 모두 적용 - **비밀댓글 isSecret 토글**: PATCH 시 본인만 변경 가능 (관리자도 타인 비밀 상태 변경 불가) -- **포스트 삭제**: 본인 또는 관리자만 가능, 트랜잭션으로 댓글/조회기록/활동점수(blog_post) 일괄 삭제 +- **포스트 삭제**: 본인 또는 관리자만 가능. 포스트는 soft delete (`deletedAt` 설정, URL unique constraint 유지 → RSS 재수집 차단). 트랜잭션 내 댓글/조회기록/리액션은 hard delete (복원 시 이전 데이터 잔존 방지), 활동점수(blog_post)도 hard delete. 본인이 soft delete한 URL 재등록 시 복원 흐름(`deletedAt=null`, TOCTOU race 차단), 타인의 삭제된 URL은 conflict 반환 - **이모지 리액션**: 게시판 글 + 포스트에 고정 6종 이모지 (👍👀🔥💡😂✅) 토글, `ReactionBar` 공용 컴포넌트 (`apiPath` prop으로 board/posts 구분), 호버(PC)/클릭(모바일) 시 닉네임 팝오버, 복수 선택 가능, 활동 점수/알림 없음 - **인기글 점수**: `댓글×3 + 조회수×2 + 리액션×1`, 인기순 상위 5개 메달 테두리 (금/은/동/스카이블루/라벤더) - **인기 포스트 알림**: 화 08:05 KST 자동 + 수동 트리거, 이전 회차 TOP 5 Discord Embed (이모지별 카운트, 썸네일, 링크 버튼), `popular_posts_channel_id` 설정 필요, grace period 종료 후 4일 이내만 자동 발송 (중복 방지) @@ -136,7 +136,7 @@ pnpm --filter @blog-study/bot rss-collect # 수동 RSS 수집 (봇 없이) | `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/posts/[id]/route.ts` | 포스트 삭제 API (본인/관리자, 댓글+조회+점수 일괄 삭제) | +| `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()`) | | `packages/web/src/lib/firebase/client.ts` | Firebase 클라이언트 (FCM 토큰 요청, 포그라운드 메시지) | diff --git a/docs/26-03-06-schema-summary.md b/docs/26-03-06-schema-summary.md index 94c2d00..6dd17bf 100644 --- a/docs/26-03-06-schema-summary.md +++ b/docs/26-03-06-schema-summary.md @@ -61,6 +61,7 @@ | `thumbnail_url` | varchar(1000) | nullable, OG 이미지 | | `comment_count` | integer | default 0 | | `collected_at` | timestamptz | defaultNow | +| `deleted_at` | timestamptz | nullable (soft delete, URL unique constraint 유지 → RSS 재수집 차단) | ### attendance | 컬럼 | 타입 | 비고 | diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 948f079..bdbe512 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -345,6 +345,7 @@ erDiagram text description varchar thumbnail_url integer comment_count + timestamp deleted_at } attendance { diff --git a/packages/bot/src/handlers/dm-handler.ts b/packages/bot/src/handlers/dm-handler.ts index cba3678..35a7d15 100644 --- a/packages/bot/src/handlers/dm-handler.ts +++ b/packages/bot/src/handlers/dm-handler.ts @@ -6,13 +6,7 @@ * P0 #9 해결: 인메모리 Map → DB 영속화로 변경 */ -import { - ChannelType, - Client, - Events, - Interaction, - TextChannel -} from 'discord.js'; +import { ChannelType, Client, Events, Interaction, TextChannel } from 'discord.js'; import { fines, getDb, members, rounds } from '@blog-study/shared/db'; import { eq } from 'drizzle-orm'; import { formatFineReason, getFineService, } from '../services'; @@ -229,6 +223,15 @@ export async function sendFineNotification( * Send fine reminder push to a user * Requirements: 8.4 - Send reminder for unpaid fines */ +export interface FineReminderResult { + /** 1건 이상 실제로 푸시 발송된 경우 true */ + sent: boolean; + /** 수신자에게 FCM 토큰이 1개도 없는 경우 true */ + noToken: boolean; + /** 처리 중 예외가 발생한 경우 true */ + errored: boolean; +} + export async function sendFineReminder( memberId: string, fineId: string, @@ -236,7 +239,7 @@ export async function sendFineReminder( type: 'late' | 'absent', roundNumber: number, daysSinceCreation: number -): Promise { +): Promise { try { const reason = formatFineReason(type); const result = await sendReminderPush({ @@ -246,12 +249,28 @@ export async function sendFineReminder( body: `${roundNumber}회차 ${reason} 벌금 ${amount.toLocaleString()}원이 아직 미납 상태입니다. (${daysSinceCreation}일 경과)`, clickUrl: '/profile/fines', }); - await addPendingConfirmation(memberId, fineId); - logger.info({ memberId, fineId }, '📱 [Push] 벌금 리마인더 발송 완료'); - return result.success > 0; + const sent = result.success > 0; + const noToken = !sent && (result.noToken ?? 0) > 0; + if (sent) { + // 실제 발송된 경우에만 confirmation pending 등록 (noToken/실패 시 리마인더 사이클이 다시 처리) + await addPendingConfirmation(memberId, fineId); + } + logger.info( + { + memberId, + fineId, + success: result.success, + failed: result.failed, + noToken: result.noToken ?? 0, + }, + noToken + ? '📱 [Push] 벌금 리마인더 — FCM 토큰 미등록 (수신자가 푸시 권한을 켠 적 없음)' + : '📱 [Push] 벌금 리마인더 발송 완료' + ); + return { sent, noToken, errored: false }; } catch (error) { logger.error({ memberId, error: serializeError(error) }, '📱 [Push] 벌금 리마인더 발송 실패'); - return false; + return { sent: false, noToken: false, errored: true }; } } diff --git a/packages/bot/src/lib/push-client.ts b/packages/bot/src/lib/push-client.ts index de96aa8..9ab3aff 100644 --- a/packages/bot/src/lib/push-client.ts +++ b/packages/bot/src/lib/push-client.ts @@ -11,6 +11,10 @@ interface ReminderPushPayload { interface PushResult { success: number; failed: number; + /** 알림 설정으로 수신 거부한 멤버 수 */ + skipped?: number; + /** FCM 토큰이 0개인 멤버 수 (등록 안 했거나 invalid로 삭제됨) */ + noToken?: number; } export async function sendReminderPush(payload: ReminderPushPayload): Promise { @@ -41,7 +45,7 @@ export async function sendReminderPush(payload: ReminderPushPayload): Promise }) .from(posts) .leftJoin(members, eq(posts.memberId, members.id)) - .where(eq(posts.roundId, roundId)) + .where(and(eq(posts.roundId, roundId), isNull(posts.deletedAt))) .orderBy(desc(popularScore), desc(posts.commentCount), desc(posts.publishedAt)) .limit(5); diff --git a/packages/bot/src/services/ranking.service.ts b/packages/bot/src/services/ranking.service.ts index 16bbf9a..24f3cb7 100644 --- a/packages/bot/src/services/ranking.service.ts +++ b/packages/bot/src/services/ranking.service.ts @@ -4,7 +4,7 @@ * 주간/월간 랭킹, 포디움 추출 */ -import { and, count, inArray, sql } from 'drizzle-orm'; +import { and, count, inArray, isNull, sql } from 'drizzle-orm'; import { activityScores, getDb, members, MemberStatus, posts, } from '@blog-study/shared/db'; /** @@ -63,9 +63,9 @@ export class RankingService { const memberIds = activeMembers.map((m) => m.id); - // 포스트 수 집계 + // 포스트 수 집계 (soft deleted 제외) let postCounts = new Map(); - const postConditions = [inArray(posts.memberId, memberIds)]; + const postConditions = [inArray(posts.memberId, memberIds), isNull(posts.deletedAt)]; // 날짜 범위 필터링 if (startDate && endDate) { diff --git a/packages/shared/src/db/schema.ts b/packages/shared/src/db/schema.ts index d7219de..d820d9d 100644 --- a/packages/shared/src/db/schema.ts +++ b/packages/shared/src/db/schema.ts @@ -150,6 +150,7 @@ export const posts = pgTable( thumbnailUrl: varchar('thumbnail_url', { length: 2000 }), commentCount: integer('comment_count').default(0), collectedAt: timestamp('collected_at', { withTimezone: true }).defaultNow(), + deletedAt: timestamp('deleted_at', { withTimezone: true }), }, (table) => ({ memberIdIdx: index('idx_posts_member_id').on(table.memberId), @@ -546,11 +547,7 @@ export const postReactions = pgTable( (table) => ({ postIdIdx: index('idx_post_reactions_post_id').on(table.postId), memberIdIdx: index('idx_post_reactions_member_id').on(table.memberId), - uniqueReaction: unique('unique_post_reaction').on( - table.postId, - table.memberId, - table.emoji - ), + uniqueReaction: unique('unique_post_reaction').on(table.postId, table.memberId, table.emoji), }) ); diff --git a/packages/shared/src/utils/serialization.ts b/packages/shared/src/utils/serialization.ts index b5450ec..138a17e 100644 --- a/packages/shared/src/utils/serialization.ts +++ b/packages/shared/src/utils/serialization.ts @@ -15,6 +15,7 @@ export interface SerializedPost { thumbnailUrl: string | null; commentCount: number | null; collectedAt: string | null; // ISO 8601 string + deletedAt: string | null; // ISO 8601 string } /** @@ -33,6 +34,7 @@ export function serializePost(post: Post): SerializedPost { thumbnailUrl: post.thumbnailUrl, commentCount: post.commentCount, collectedAt: post.collectedAt?.toISOString() ?? null, + deletedAt: post.deletedAt?.toISOString() ?? null, }; } @@ -52,6 +54,7 @@ export function deserializePost(serialized: SerializedPost): Post { thumbnailUrl: serialized.thumbnailUrl, commentCount: serialized.commentCount ?? 0, collectedAt: serialized.collectedAt ? new Date(serialized.collectedAt) : null, + deletedAt: serialized.deletedAt ? new Date(serialized.deletedAt) : null, }; } diff --git a/packages/web/src/app/api/admin/dashboard/route.ts b/packages/web/src/app/api/admin/dashboard/route.ts index 90b44d9..ffc028e 100644 --- a/packages/web/src/app/api/admin/dashboard/route.ts +++ b/packages/web/src/app/api/admin/dashboard/route.ts @@ -1,5 +1,5 @@ import { NextResponse } from 'next/server'; -import { count, desc, eq, sql } from 'drizzle-orm'; +import { count, desc, eq, isNull, sql } from 'drizzle-orm'; import { db } from '@/lib/db'; import { db as sharedDb } from '@blog-study/shared'; import { withAdminAuth } from '@/lib/admin'; @@ -98,8 +98,11 @@ export const GET = withAdminAuth(async (_request, _adminAuth) => { const memberCountMap = new Map(memberCounts.map((m) => [m.status, m.count])); - // Get total posts count - const [totalPostsResult] = await database.select({ count: count() }).from(posts); + // Get total posts count (soft deleted 제외) + const [totalPostsResult] = await database + .select({ count: count() }) + .from(posts) + .where(isNull(posts.deletedAt)); // Get unpaid fines summary const [unpaidFinesResult] = await database @@ -122,6 +125,7 @@ export const GET = withAdminAuth(async (_request, _adminAuth) => { }) .from(posts) .leftJoin(members, eq(posts.memberId, members.id)) + .where(isNull(posts.deletedAt)) .orderBy(desc(posts.publishedAt)) .limit(5); diff --git a/packages/web/src/app/api/admin/members/route.ts b/packages/web/src/app/api/admin/members/route.ts index ded1e00..c8d5cff 100644 --- a/packages/web/src/app/api/admin/members/route.ts +++ b/packages/web/src/app/api/admin/members/route.ts @@ -1,9 +1,8 @@ import { NextRequest, NextResponse } from 'next/server'; -import { eq, count, sql, asc } from 'drizzle-orm'; +import { asc, count, eq, isNull, sql } from 'drizzle-orm'; import { db } from '@/lib/db'; -import { db as sharedDb } from '@blog-study/shared'; +import { db as sharedDb, utils } from '@blog-study/shared'; import { withAdminAuth } from '@/lib/admin'; -import { utils } from '@blog-study/shared'; import { detectRssUrl } from '@/lib/rss-detect'; const { isValidBlogUrl } = utils; @@ -24,7 +23,7 @@ export const GET = withAdminAuth(async (request: NextRequest, _adminAuth) => { // Build query let query = database.select().from(members); - + if (status && status !== 'all') { query = query.where(eq(members.status, status)) as typeof query; } @@ -38,6 +37,7 @@ export const GET = withAdminAuth(async (request: NextRequest, _adminAuth) => { count: count(), }) .from(posts) + .where(isNull(posts.deletedAt)) .groupBy(posts.memberId); // Get attendance stats for all members @@ -73,7 +73,12 @@ export const GET = withAdminAuth(async (request: NextRequest, _adminAuth) => { const result = membersList.map((member) => { const postCount = postCountMap.get(member.id) || 0; - const attStats = attendanceMap.get(member.id) || { total: 0, submitted: 0, late: 0, absent: 0 }; + const attStats = attendanceMap.get(member.id) || { + total: 0, + submitted: 0, + late: 0, + absent: 0, + }; const attendanceRate = attStats.total > 0 ? Math.round((attStats.submitted / attStats.total) * 100) : 0; @@ -174,10 +179,7 @@ export const POST = withAdminAuth(async (request: NextRequest, _adminAuth) => { .limit(1); if (existingMember) { - return NextResponse.json( - { message: '이미 등록된 Discord ID입니다.' }, - { status: 409 } - ); + return NextResponse.json({ message: '이미 등록된 Discord ID입니다.' }, { status: 409 }); } // RSS URL 자동 감지 (비어있으면 blogUrl로부터 감지 시도) diff --git a/packages/web/src/app/api/dashboard/route.ts b/packages/web/src/app/api/dashboard/route.ts index 5d4d098..d3d34d0 100644 --- a/packages/web/src/app/api/dashboard/route.ts +++ b/packages/web/src/app/api/dashboard/route.ts @@ -1,4 +1,4 @@ -import { and, count, desc, eq } from 'drizzle-orm'; +import { and, count, desc, eq, isNull } from 'drizzle-orm'; import { db } from '@/lib/db'; import { db as sharedDb } from '@blog-study/shared'; import { createClient } from '@/lib/supabase/server'; @@ -71,6 +71,7 @@ export async function GET() { }) .from(posts) .leftJoin(members, eq(posts.memberId, members.id)) + .where(isNull(posts.deletedAt)) .orderBy(desc(posts.publishedAt)) .limit(5), @@ -79,7 +80,7 @@ export async function GET() { .from(members) .where(eq(members.status, MemberStatus.ACTIVE)), - database.select({ count: count() }).from(posts), + database.select({ count: count() }).from(posts).where(isNull(posts.deletedAt)), database.select({ count: count() }).from(members).where(eq(members.status, MemberStatus.OB)), diff --git a/packages/web/src/app/api/internal/reminder-push/route.ts b/packages/web/src/app/api/internal/reminder-push/route.ts index 82e62c5..dd9d058 100644 --- a/packages/web/src/app/api/internal/reminder-push/route.ts +++ b/packages/web/src/app/api/internal/reminder-push/route.ts @@ -104,6 +104,17 @@ export async function POST(request: NextRequest) { const recipientNicknames = recipients.map((r) => r.nickname); // 알림 로그 기록 + const noToken = result.noToken ?? 0; + const skipped = result.skipped ?? 0; + let errorMessage: string | undefined; + if (result.success === 0) { + const reasons: string[] = []; + if (noToken > 0) reasons.push(`FCM 토큰 미등록 ${noToken}명`); + if (result.failed > 0) reasons.push(`전송 실패 ${result.failed}건`); + if (skipped > 0) reasons.push(`수신 거부 ${skipped}명`); + errorMessage = reasons.length > 0 ? reasons.join(', ') : undefined; + } + await logNotification({ source: 'web', type, @@ -114,10 +125,7 @@ export async function POST(request: NextRequest) { ...result, }, status: result.success > 0 ? 'sent' : 'failed', - errorMessage: - result.success === 0 && result.failed > 0 - ? `${result.failed}건 전송 실패` - : undefined, + errorMessage, }); return successResponse(result); diff --git a/packages/web/src/app/api/members/[id]/route.ts b/packages/web/src/app/api/members/[id]/route.ts index d573656..3fa7aa7 100644 --- a/packages/web/src/app/api/members/[id]/route.ts +++ b/packages/web/src/app/api/members/[id]/route.ts @@ -1,5 +1,5 @@ import { NextRequest } from 'next/server'; -import { count, desc, eq, sql } from 'drizzle-orm'; +import { and, 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'; @@ -38,11 +38,11 @@ export async function GET(_request: NextRequest, { params }: { params: Promise<{ return Errors.notFound('멤버를 찾을 수 없습니다.').toResponse(); } - // Get post count + // Get post count (soft deleted 제외) const [postCount] = await database .select({ count: count() }) .from(posts) - .where(eq(posts.memberId, member.id)); + .where(and(eq(posts.memberId, member.id), isNull(posts.deletedAt))); // Get attendance stats const attendanceStats = await database @@ -57,7 +57,7 @@ export async function GET(_request: NextRequest, { params }: { params: Promise<{ const attStats = attendanceStats[0] || { total: 0, submitted: 0, late: 0, absent: 0 }; - // Get recent posts + // Get recent posts (soft deleted 제외) const recentPosts = await database .select({ id: posts.id, @@ -66,7 +66,7 @@ export async function GET(_request: NextRequest, { params }: { params: Promise<{ publishedAt: posts.publishedAt, }) .from(posts) - .where(eq(posts.memberId, member.id)) + .where(and(eq(posts.memberId, member.id), isNull(posts.deletedAt))) .orderBy(desc(posts.publishedAt)) .limit(5); diff --git a/packages/web/src/app/api/members/route.ts b/packages/web/src/app/api/members/route.ts index a2c1d5b..4986f05 100644 --- a/packages/web/src/app/api/members/route.ts +++ b/packages/web/src/app/api/members/route.ts @@ -1,5 +1,5 @@ import { NextRequest } from 'next/server'; -import { count, eq, inArray, sql } from 'drizzle-orm'; +import { 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'; @@ -47,13 +47,14 @@ export async function GET(request: NextRequest) { statuses.length === 1 ? eq(members.status, statuses[0]!) : inArray(members.status, statuses) ); - // Get post counts for all members + // Get post counts for all members (soft deleted 제외) const postCounts = await database .select({ memberId: posts.memberId, count: count(), }) .from(posts) + .where(isNull(posts.deletedAt)) .groupBy(posts.memberId); // Get attendance stats for all members diff --git a/packages/web/src/app/api/posts/[id]/comments/route.ts b/packages/web/src/app/api/posts/[id]/comments/route.ts index 15eb9cd..e35c466 100644 --- a/packages/web/src/app/api/posts/[id]/comments/route.ts +++ b/packages/web/src/app/api/posts/[id]/comments/route.ts @@ -30,7 +30,7 @@ export async function GET(_request: NextRequest, { params }: { params: Promise<{ const [post] = await database .select({ memberId: posts.memberId }) .from(posts) - .where(eq(posts.id, postId)) + .where(and(eq(posts.id, postId), isNull(posts.deletedAt))) .limit(1); const rows = await database @@ -165,7 +165,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ const [post] = await database .select({ id: posts.id, memberId: posts.memberId }) .from(posts) - .where(eq(posts.id, postId)) + .where(and(eq(posts.id, postId), isNull(posts.deletedAt))) .limit(1); if (!post) return Errors.notFound('글을 찾을 수 없습니다.').toResponse(); diff --git a/packages/web/src/app/api/posts/[id]/reactions/route.ts b/packages/web/src/app/api/posts/[id]/reactions/route.ts index 85ef285..2c3193f 100644 --- a/packages/web/src/app/api/posts/[id]/reactions/route.ts +++ b/packages/web/src/app/api/posts/[id]/reactions/route.ts @@ -1,5 +1,5 @@ import { NextRequest } from 'next/server'; -import { and, eq } from 'drizzle-orm'; +import { and, eq, isNull } from 'drizzle-orm'; import { getDb } from '@/lib/db'; import { db as sharedDb } from '@blog-study/shared'; import { getBoardAuth } from '@/lib/board-auth'; @@ -14,10 +14,7 @@ const { postReactions, posts, members } = sharedDb; * GET /api/posts/[id]/reactions * 포스트 리액션 조회 */ -export async function GET( - _request: NextRequest, - { params }: { params: Promise<{ id: string }> } -) { +export async function GET(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) { try { const auth = await getBoardAuth(); if (!auth) return Errors.unauthorized().toResponse(); @@ -61,10 +58,7 @@ export async function GET( * POST /api/posts/[id]/reactions * 포스트 리액션 토글 */ -export async function POST( - request: NextRequest, - { params }: { params: Promise<{ id: string }> } -) { +export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { try { const auth = await getBoardAuth(); if (!auth) return Errors.unauthorized().toResponse(); @@ -82,7 +76,7 @@ export async function POST( const [post] = await database .select({ id: posts.id }) .from(posts) - .where(eq(posts.id, postId)) + .where(and(eq(posts.id, postId), isNull(posts.deletedAt))) .limit(1); if (!post) return Errors.notFound('포스트를 찾을 수 없습니다.').toResponse(); @@ -101,23 +95,15 @@ export async function POST( .limit(1); if (existing) { - await tx - .delete(postReactions) - .where(eq(postReactions.id, existing.id)); + await tx.delete(postReactions).where(eq(postReactions.id, existing.id)); return 'removed' as const; } else { - await tx - .insert(postReactions) - .values({ postId, memberId: auth.memberId, emoji }); + await tx.insert(postReactions).values({ postId, memberId: auth.memberId, emoji }); return 'added' as const; } }); - return successResponse( - { action, emoji }, - undefined, - action === 'added' ? 201 : 200 - ); + return successResponse({ action, emoji }, undefined, action === 'added' ? 201 : 200); } catch (error) { return errorResponse(error); } diff --git a/packages/web/src/app/api/posts/[id]/route.ts b/packages/web/src/app/api/posts/[id]/route.ts index e718543..70e4995 100644 --- a/packages/web/src/app/api/posts/[id]/route.ts +++ b/packages/web/src/app/api/posts/[id]/route.ts @@ -1,11 +1,11 @@ -import { and, eq, like } from 'drizzle-orm'; +import { and, eq, isNull, like } 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 { isAdminDiscordId } from '@/lib/admin'; -const { posts, members, postComments, postViews, activityScores, ActivityScoreType } = sharedDb; +const { posts, members, postComments, postViews, postReactions, activityScores, ActivityScoreType } = sharedDb; /** * PATCH /api/posts/[id] @@ -31,11 +31,11 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id const database = db(); - // 포스트 조회 + // 포스트 조회 (soft deleted 제외) const [post] = await database .select({ id: posts.id, memberId: posts.memberId }) .from(posts) - .where(eq(posts.id, postId)) + .where(and(eq(posts.id, postId), isNull(posts.deletedAt))) .limit(1); if (!post) { @@ -110,7 +110,7 @@ export async function DELETE(_request: Request, { params }: { params: Promise<{ const database = db(); - // 포스트 조회 + // 포스트 조회 (soft deleted 제외) const [post] = await database .select({ id: posts.id, @@ -119,7 +119,7 @@ export async function DELETE(_request: Request, { params }: { params: Promise<{ url: posts.url, }) .from(posts) - .where(eq(posts.id, postId)) + .where(and(eq(posts.id, postId), isNull(posts.deletedAt))) .limit(1); if (!post) { @@ -140,14 +140,18 @@ export async function DELETE(_request: Request, { params }: { params: Promise<{ return Errors.forbidden('삭제 권한이 없습니다.').toResponse(); } - // 트랜잭션으로 일괄 삭제 + // 포스트는 soft delete (URL 보존 → RSS 재수집 방지) + // 댓글/조회/리액션/점수는 hard delete (복원 시 이전 데이터가 새 포스트에 살아남는 것 방지) await database.transaction(async (tx) => { - // 1. 댓글 삭제 + // 1. 댓글 hard delete await tx.delete(postComments).where(eq(postComments.postId, postId)); - // 2. 조회 기록 삭제 + // 2. 조회 기록 hard delete await tx.delete(postViews).where(eq(postViews.postId, postId)); + // 2b. 리액션 hard delete (복원 시 인기점수 부풀림 차단) + await tx.delete(postReactions).where(eq(postReactions.postId, postId)); + // 3. blog_post 점수 회수 // 봇: "블로그 포스트: {title(특수문자 제거, 200자)}", 수동: "블로그 포스트: {title(200자)}" // LIKE로 제목 앞 50자 prefix 매칭 (봇/수동 포맷 차이 대응) @@ -162,8 +166,8 @@ export async function DELETE(_request: Request, { params }: { params: Promise<{ ) ); - // 4. 포스트 삭제 - await tx.delete(posts).where(eq(posts.id, postId)); + // 4. 포스트 soft delete (URL은 unique constraint로 남아 RSS 재수집 차단) + await tx.update(posts).set({ deletedAt: new Date() }).where(eq(posts.id, postId)); }); return successResponse({ deleted: true }); diff --git a/packages/web/src/app/api/posts/[id]/view/route.ts b/packages/web/src/app/api/posts/[id]/view/route.ts index 538d09a..c7cf35f 100644 --- a/packages/web/src/app/api/posts/[id]/view/route.ts +++ b/packages/web/src/app/api/posts/[id]/view/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from 'next/server'; -import { eq, sql } from 'drizzle-orm'; +import { and, eq, isNull, sql } from 'drizzle-orm'; import { getDb } from '@/lib/db'; import { db as sharedDb } from '@blog-study/shared'; import { createClient } from '@/lib/supabase/server'; @@ -45,11 +45,11 @@ export async function POST(_request: NextRequest, { params }: { params: Promise< return NextResponse.json({ scored: false }, { status: 404 }); } - // 포스트 존재 확인 + 작성자 체크 + // 포스트 존재 확인 + 작성자 체크 (soft deleted 제외) const [post] = await database .select({ id: posts.id, memberId: posts.memberId, title: posts.title }) .from(posts) - .where(eq(posts.id, postId)) + .where(and(eq(posts.id, postId), isNull(posts.deletedAt))) .limit(1); if (!post) { diff --git a/packages/web/src/app/api/posts/manual/route.ts b/packages/web/src/app/api/posts/manual/route.ts index 7ab7c31..05d037d 100644 --- a/packages/web/src/app/api/posts/manual/route.ts +++ b/packages/web/src/app/api/posts/manual/route.ts @@ -150,15 +150,23 @@ export async function POST(request: NextRequest) { return Errors.notFound('멤버 정보를 찾을 수 없습니다.').toResponse(); } - // 중복 URL 체크 + // 중복 URL 체크 (soft deleted 포함 — 본인 글이면 복원 처리) const [existing] = await database - .select({ id: posts.id }) + .select({ id: posts.id, memberId: posts.memberId, deletedAt: posts.deletedAt }) .from(posts) .where(eq(posts.url, url)) .limit(1); if (existing) { - return Errors.conflict('이미 등록된 URL입니다.').toResponse(); + // 활성 포스트면 conflict + if (!existing.deletedAt) { + return Errors.conflict('이미 등록된 URL입니다.').toResponse(); + } + // 다른 사람의 삭제된 포스트는 등록 불가 (URL 도용 방지) + if (existing.memberId !== member.id) { + return Errors.conflict('이미 등록된 URL입니다.').toResponse(); + } + // 본인이 이전에 삭제한 글 → 복원 흐름으로 진행 } // 클라이언트에서 미리보기 후 편집된 값 우선 사용, 없으면 OG 크롤링 @@ -201,19 +209,49 @@ export async function POST(request: NextRequest) { .where(eq(rounds.isCurrent, true)) .limit(1); - // 포스트 등록 - const [newPost] = await database - .insert(posts) - .values({ - memberId: member.id, - roundId: currentRound?.id ?? null, - title, - url, - publishedAt, - thumbnailUrl, - description, - }) - .returning(); + // 포스트 등록 (또는 본인 soft deleted 글 복원) + // 복원 흐름은 deletedAt IS NOT NULL 조건을 update predicate에 포함시켜 race 차단 + // (동시 요청이 둘 다 select에서 deleted 상태를 읽어도, 먼저 update한 쪽만 returning row 반환) + let newPost; + if (existing?.deletedAt && existing.memberId === member.id) { + [newPost] = await database + .update(posts) + .set({ + roundId: currentRound?.id ?? null, + title, + publishedAt, + thumbnailUrl, + description, + commentCount: 0, + deletedAt: null, + collectedAt: new Date(), + }) + .where( + and( + eq(posts.id, existing.id), + eq(posts.memberId, member.id), + sql`${posts.deletedAt} IS NOT NULL` + ) + ) + .returning(); + // race 패배 → 다른 요청이 이미 복원 완료. 중복 부수효과 차단 + if (!newPost) { + return Errors.conflict('이미 처리된 요청입니다.').toResponse(); + } + } else { + [newPost] = await database + .insert(posts) + .values({ + memberId: member.id, + roundId: currentRound?.id ?? null, + title, + url, + publishedAt, + thumbnailUrl, + description, + }) + .returning(); + } // 출석 상태 업데이트 (현재 회차 + active 유저만) if (currentRound && member.status === 'active') { diff --git a/packages/web/src/app/api/posts/route.ts b/packages/web/src/app/api/posts/route.ts index d1e2339..7293bb3 100644 --- a/packages/web/src/app/api/posts/route.ts +++ b/packages/web/src/app/api/posts/route.ts @@ -1,5 +1,5 @@ import { NextRequest } from 'next/server'; -import { and, count, desc, eq, ilike, inArray, or, sql } from 'drizzle-orm'; +import { and, count, desc, eq, ilike, inArray, isNull, or, sql } from 'drizzle-orm'; import { db } from '@/lib/db'; import { db as sharedDb } from '@blog-study/shared'; import { @@ -66,30 +66,29 @@ export async function GET(request: NextRequest) { return Errors.badRequest('유효하지 않은 회차 ID입니다.').toResponse(); } - // WHERE 조건 조합 - const conditions = []; + // WHERE 조건 조합 (soft deleted 제외) + const conditions = [isNull(posts.deletedAt)]; if (roundIdNum !== null) conditions.push(eq(posts.roundId, roundIdNum)); if (search) { - conditions.push( - or( - ilike(posts.title, `%${search}%`), - ilike(members.name, `%${search}%`), - ilike(members.nickname, `%${search}%`), - ilike(members.discordUsername, `%${search}%`) - ) + const searchCondition = or( + ilike(posts.title, `%${search}%`), + ilike(members.name, `%${search}%`), + ilike(members.nickname, `%${search}%`), + ilike(members.discordUsername, `%${search}%`) ); + if (searchCondition) conditions.push(searchCondition); } if (partsFilter && partsFilter.length > 0) { conditions.push(inArray(members.part, partsFilter)); } - const whereCondition = conditions.length > 0 ? and(...conditions) : undefined; + const whereCondition = and(...conditions); // Get total count (join members for search/parts filter) const totalCountQuery = database .select({ count: count() }) .from(posts) - .leftJoin(members, eq(posts.memberId, members.id)); - if (whereCondition) totalCountQuery.where(whereCondition); + .leftJoin(members, eq(posts.memberId, members.id)) + .where(whereCondition); const totalCountResult = await totalCountQuery; const totalCount = totalCountResult[0]?.count ?? 0; @@ -118,6 +117,7 @@ export async function GET(request: NextRequest) { .from(posts) .leftJoin(members, eq(posts.memberId, members.id)) .leftJoin(rounds, eq(posts.roundId, rounds.id)) + .where(whereCondition) .orderBy( ...(sort === 'popular' ? [desc(popularScore), desc(posts.commentCount), desc(posts.publishedAt)] @@ -126,7 +126,6 @@ export async function GET(request: NextRequest) { .limit(pageSize) .offset(offset); - if (whereCondition) postsQuery.where(whereCondition); const postsResult = await postsQuery; // Get viewer info for these posts (max 4 per post for display) diff --git a/packages/web/src/app/api/profile/route.ts b/packages/web/src/app/api/profile/route.ts index e75abb7..39213f5 100644 --- a/packages/web/src/app/api/profile/route.ts +++ b/packages/web/src/app/api/profile/route.ts @@ -1,5 +1,5 @@ import { NextResponse } from 'next/server'; -import { count, eq, sql } from 'drizzle-orm'; +import { and, count, 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'; @@ -42,7 +42,7 @@ export async function GET() { const [postCount] = await database .select({ count: count() }) .from(posts) - .where(eq(posts.memberId, member.id)); + .where(and(eq(posts.memberId, member.id), isNull(posts.deletedAt))); const attendanceStats = await database .select({ diff --git a/packages/web/src/app/api/ranking/route.ts b/packages/web/src/app/api/ranking/route.ts index 11c453c..0120f02 100644 --- a/packages/web/src/app/api/ranking/route.ts +++ b/packages/web/src/app/api/ranking/route.ts @@ -1,13 +1,21 @@ import { NextRequest } from 'next/server'; -import { count, desc, eq, inArray, sql } from 'drizzle-orm'; +import { and, count, desc, eq, inArray, isNull, sql } from 'drizzle-orm'; import { db } from '@/lib/db'; import { db as sharedDb } from '@blog-study/shared'; import { errorResponse, Errors, successResponse, withCache } from '@/lib/api-error'; import { createClient } from '@/lib/supabase/server'; import { getAdminDiscordIds } from '@/lib/admin'; -const { members, posts, attendance, rounds, activityScores, config, MemberStatus, AttendanceStatus } = - sharedDb; +const { + members, + posts, + attendance, + rounds, + activityScores, + config, + MemberStatus, + AttendanceStatus, +} = sharedDb; const VALID_SORT_KEYS = ['score', 'posts', 'activity'] as const; type SortKey = (typeof VALID_SORT_KEYS)[number]; @@ -160,7 +168,10 @@ export async function GET(request: NextRequest) { .where(eq(config.key, 'ranking_excluded_ids')) .limit(1); const configExcludedIds = excludedRow - ? excludedRow.value.split(',').map((id) => id.trim()).filter(Boolean) + ? excludedRow.value + .split(',') + .map((id) => id.trim()) + .filter(Boolean) : []; const excludedDiscordIds = new Set([...adminDiscordIds, ...configExcludedIds]); const filteredMembersWithPosts = membersWithPosts.filter( @@ -176,7 +187,7 @@ export async function GET(request: NextRequest) { count: count(posts.id), }) .from(posts) - .where(eq(posts.roundId, currentRoundId)) + .where(and(eq(posts.roundId, currentRoundId), isNull(posts.deletedAt))) .groupBy(posts.memberId); for (const row of crPosts) { diff --git a/packages/web/src/app/api/rounds/[id]/route.ts b/packages/web/src/app/api/rounds/[id]/route.ts index af76ae9..44da937 100644 --- a/packages/web/src/app/api/rounds/[id]/route.ts +++ b/packages/web/src/app/api/rounds/[id]/route.ts @@ -1,5 +1,5 @@ import { NextRequest } from 'next/server'; -import { count, eq } from 'drizzle-orm'; +import { and, count, eq, isNull } from 'drizzle-orm'; import { db } from '@/lib/db'; import { db as sharedDb } from '@blog-study/shared'; import { errorResponse, Errors, successResponse } from '@/lib/api-error'; @@ -81,7 +81,7 @@ export async function GET(_request: NextRequest, { params }: RouteParams) { const [postCount] = await database .select({ count: count() }) .from(posts) - .where(eq(posts.roundId, roundData.id)); + .where(and(eq(posts.roundId, roundData.id), isNull(posts.deletedAt))); // Calculate time-related info const now = new Date(); diff --git a/packages/web/src/app/page.tsx b/packages/web/src/app/page.tsx index 80a6eed..b3673cb 100644 --- a/packages/web/src/app/page.tsx +++ b/packages/web/src/app/page.tsx @@ -1,5 +1,5 @@ import { redirect } from 'next/navigation'; -import { eq, count } from 'drizzle-orm'; +import { count, eq, isNull } from 'drizzle-orm'; import { createClient } from '@/lib/supabase/server'; import { db } from '@/lib/db'; import { db as sharedDb } from '@blog-study/shared'; @@ -26,7 +26,7 @@ export default async function Home() { const [memberResult, postResult, roundResult] = await Promise.all([ database.select({ value: count() }).from(members).where(eq(members.status, 'active')), - database.select({ value: count() }).from(posts), + database.select({ value: count() }).from(posts).where(isNull(posts.deletedAt)), database .select({ roundNumber: rounds.roundNumber }) .from(rounds) diff --git a/packages/web/src/lib/push.ts b/packages/web/src/lib/push.ts index 96c257e..969ef35 100644 --- a/packages/web/src/lib/push.ts +++ b/packages/web/src/lib/push.ts @@ -44,18 +44,27 @@ async function isNotificationEnabled(memberId: string, type: string): Promise { +): Promise { const notificationType = payload.data?.type; if (notificationType && !FORCE_SEND_TYPES.has(notificationType)) { const enabled = await isNotificationEnabled(memberId, notificationType); if (!enabled) { - return { success: 0, failed: 0 }; + return { success: 0, failed: 0, skipped: 1 }; } } @@ -67,7 +76,7 @@ export async function sendPushToMember( .where(eq(fcmTokens.memberId, memberId)); if (tokens.length === 0) { - return { success: 0, failed: 0 }; + return { success: 0, failed: 0, noToken: 1 }; } const message: MulticastMessage = { @@ -140,12 +149,13 @@ export async function sendPushToMember( export async function sendPushToMembers( memberIds: string[], payload: PushPayload -): Promise<{ success: number; failed: number }> { +): Promise { const database = getDb(); // 알림 설정으로 수신 거부한 멤버 필터링 (강제 전송 타입은 스킵) const notificationType = payload.data?.type; let filteredMemberIds = memberIds; + let skipped = 0; if (notificationType && !FORCE_SEND_TYPES.has(notificationType)) { const disabledPrefs = await database .select({ memberId: notificationPreferences.memberId }) @@ -159,10 +169,11 @@ export async function sendPushToMembers( ); const disabledSet = new Set(disabledPrefs.map((p) => p.memberId)); filteredMemberIds = memberIds.filter((id) => !disabledSet.has(id)); + skipped = memberIds.length - filteredMemberIds.length; } if (filteredMemberIds.length === 0) { - return { success: 0, failed: 0 }; + return { success: 0, failed: 0, skipped, noToken: 0 }; } const tokens = await database @@ -170,8 +181,11 @@ export async function sendPushToMembers( .from(fcmTokens) .where(inArray(fcmTokens.memberId, filteredMemberIds)); + const memberIdsWithToken = new Set(tokens.map((t) => t.memberId)); + const noToken = filteredMemberIds.filter((id) => !memberIdsWithToken.has(id)).length; + if (tokens.length === 0) { - return { success: 0, failed: 0 }; + return { success: 0, failed: 0, skipped, noToken }; } // 멤버별로 그룹화하여 전송 (FCM quota 최적화) @@ -249,5 +263,5 @@ export async function sendPushToMembers( } } - return { success: totalSuccess, failed: totalFailed }; + return { success: totalSuccess, failed: totalFailed, skipped, noToken }; }