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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`), 관리자 페이지 "알림 로그" 탭에서 조회 (타입/소스/대상/상태 필터 + 무한 스크롤, 푸시 로그에 수신자 닉네임 표시)
- **비밀답글 가시성**: 비밀 답글은 작성자/포스트작성자/부모댓글작성자/관리자가 열람 가능
Expand All @@ -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일 이내만 자동 발송 (중복 방지)
Expand Down Expand Up @@ -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 토큰 요청, 포그라운드 메시지) |
Expand Down
1 change: 1 addition & 0 deletions docs/26-03-06-schema-summary.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
| 컬럼 | 타입 | 비고 |
Expand Down
1 change: 1 addition & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,7 @@ erDiagram
text description
varchar thumbnail_url
integer comment_count
timestamp deleted_at
}

attendance {
Expand Down
43 changes: 31 additions & 12 deletions packages/bot/src/handlers/dm-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -229,14 +223,23 @@ 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,
amount: number,
type: 'late' | 'absent',
roundNumber: number,
daysSinceCreation: number
): Promise<boolean> {
): Promise<FineReminderResult> {
try {
const reason = formatFineReason(type);
const result = await sendReminderPush({
Expand All @@ -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 };
}
}

Expand Down
6 changes: 5 additions & 1 deletion packages/bot/src/lib/push-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<PushResult> {
Expand Down Expand Up @@ -41,7 +45,7 @@ export async function sendReminderPush(payload: ReminderPushPayload): Promise<Pu

const json = (await res.json()) as { data?: PushResult };
const result: PushResult = json.data ?? { success: 0, failed: 0 };
logger.info({ type: payload.type, ...result }, '📱 [Push] 발송 완료');
logger.info({ type: payload.type, webUrl, raw: json, ...result }, '📱 [Push] 발송 완료');
return result;
} catch (error) {
logger.error({ error, type: payload.type }, '📱 [Push] 내부 API 호출 에러');
Expand Down
33 changes: 27 additions & 6 deletions packages/bot/src/schedulers/fine-reminder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export interface FineReminderResult {
processedCount: number;
sentCount: number;
failedCount: number;
noTokenCount?: number;
errors: string[];
}

Expand Down Expand Up @@ -117,6 +118,7 @@ export class FineReminder {
const errors: string[] = [];
let sentCount = 0;
let failedCount = 0;
let noTokenCount = 0;

try {
// 지각 기간이면 PENDING 멤버에게 독촉 DM 발송
Expand Down Expand Up @@ -158,7 +160,7 @@ export class FineReminder {
);

try {
const success = await sendFineReminder(
const result = await sendFineReminder(
fine.memberId,
fine.id,
fine.amount,
Expand All @@ -167,12 +169,21 @@ export class FineReminder {
daysSinceCreation
);

if (success) {
if (result.sent) {
sentCount++;
// P1 #10: 리마인드 발송 후 lastReminderAt 업데이트
await fineService.updateLastReminderAt(fine.id);
} else if (result.noToken) {
// FCM 토큰 미등록 — 매일 무한 재시도되지 않도록 lastReminderAt 갱신
noTokenCount++;
await fineService.updateLastReminderAt(fine.id);
errors.push(`벌금 ${fine.id} 수신자(${fine.memberId}) FCM 토큰 미등록`);
} else {
failedCount++;
logger.warn(
{ fineId: fine.id, memberId: fine.memberId, result },
'⏰ [벌금 리마인더] 분류 실패 (sent=false, noToken=false) — webUrl이 옛 reminder-push API를 호출 중일 가능성'
);
}
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
Expand All @@ -183,14 +194,15 @@ export class FineReminder {
}

logger.info(
`⏰ [벌금 리마인더] 완료 — 발송 ${sentCount}건, 실패 ${failedCount}건`
`⏰ [벌금 리마인더] 완료 — 발송 ${sentCount}건, 실패 ${failedCount}건, 토큰 미등록 ${noTokenCount}건`
);

return {
timestamp: startTime,
processedCount: finesNeedingReminder.length,
sentCount,
failedCount,
noTokenCount,
errors,
};
} catch (error) {
Expand Down Expand Up @@ -232,6 +244,7 @@ export class FineReminder {
const errors: string[] = [];
let sentCount = 0;
let failedCount = 0;
let noTokenCount = 0;

try {
const fineService = getFineService();
Expand All @@ -250,7 +263,7 @@ export class FineReminder {
);

try {
const success = await sendFineReminder(
const result = await sendFineReminder(
fine.memberId,
fine.id,
fine.amount,
Expand All @@ -259,11 +272,18 @@ export class FineReminder {
daysSinceCreation
);

if (success) {
if (result.sent) {
sentCount++;
// 수동 실행이므로 lastReminderAt 업데이트 안 함
} else if (result.noToken) {
noTokenCount++;
errors.push(`벌금 ${fine.id} 수신자(${fine.memberId}) FCM 토큰 미등록`);
} else {
failedCount++;
logger.warn(
{ fineId: fine.id, memberId: fine.memberId, result },
'⏰ [벌금 리마인더] 분류 실패 (sent=false, noToken=false) — webUrl이 옛 reminder-push API를 호출 중일 가능성'
);
}
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
Expand All @@ -273,14 +293,15 @@ export class FineReminder {
}

logger.info(
`⏰ [벌금 리마인더] 수동 실행 완료 — 발송 ${sentCount}건, 실패 ${failedCount}건`
`⏰ [벌금 리마인더] 수동 실행 완료 — 발송 ${sentCount}건, 실패 ${failedCount}건, 토큰 미등록 ${noTokenCount}건`
);

return {
timestamp: startTime,
processedCount: finesWithInfo.length,
sentCount,
failedCount,
noTokenCount,
errors,
};
} catch (error) {
Expand Down
4 changes: 2 additions & 2 deletions packages/bot/src/schedulers/popular-posts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
EmbedBuilder,
type MessageCreateOptions,
} from 'discord.js';
import { desc, eq, sql } from 'drizzle-orm';
import { and, desc, eq, isNull, sql } from 'drizzle-orm';
import logger from '../lib/logger';
import { logNotification } from '../lib/notification-logger';
import { getDb, members, posts } from '@blog-study/shared/db';
Expand Down Expand Up @@ -76,7 +76,7 @@ async function getPopularPostsForRound(roundId: number): Promise<PopularPost[]>
})
.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);

Expand Down
6 changes: 3 additions & 3 deletions packages/bot/src/services/ranking.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand Down Expand Up @@ -63,9 +63,9 @@ export class RankingService {

const memberIds = activeMembers.map((m) => m.id);

// 포스트 수 집계
// 포스트 수 집계 (soft deleted 제외)
let postCounts = new Map<string, number>();
const postConditions = [inArray(posts.memberId, memberIds)];
const postConditions = [inArray(posts.memberId, memberIds), isNull(posts.deletedAt)];

// 날짜 범위 필터링
if (startDate && endDate) {
Expand Down
7 changes: 2 additions & 5 deletions packages/shared/src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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),
})
);

Expand Down
3 changes: 3 additions & 0 deletions packages/shared/src/utils/serialization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

/**
Expand All @@ -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,
};
}

Expand All @@ -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,
};
}

Expand Down
10 changes: 7 additions & 3 deletions packages/web/src/app/api/admin/dashboard/route.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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
Expand All @@ -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);

Expand Down
Loading
Loading