+
+
미납 벌금
+ ...
+```
+
+`Link`는 이미 import 되어 있음 (`import Link from 'next/link'`, line 3).
+
+- [ ] **Step 3: dev 서버 확인 + typecheck**
+
+```bash
+pnpm typecheck
+```
+
+dev 서버에서 `/profile` → 미납 벌금 카드 클릭 → `/profile/fines` 이동 확인.
+
+- [ ] **Step 4: 커밋**
+
+```bash
+git add packages/web/src/app/\(user\)/profile/fines/page.tsx packages/web/src/app/\(user\)/profile/page.tsx
+git commit -m "feat: 벌금 상세 페이지 + 프로필 스탯 카드 링크"
+```
+
+---
+
+### Task 4: 웹 내부 API — reminder-push
+
+**Files:**
+- Create: `packages/web/src/app/api/internal/reminder-push/route.ts`
+
+- [ ] **Step 1: POST /api/internal/reminder-push 구현**
+
+`packages/web/src/app/api/internal/reminder-push/route.ts`:
+
+```ts
+import { timingSafeEqual } from 'crypto';
+import { NextRequest } from 'next/server';
+import { sendPushToMembers } from '@/lib/push';
+import { logNotification } from '@/lib/notification-log';
+import { errorResponse, Errors, successResponse } from '@/lib/api-error';
+
+const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
+
+const rateLimitMap = new Map
();
+const RATE_LIMIT_WINDOW = 60_000;
+const RATE_LIMIT_MAX = 30;
+
+function checkRateLimit(): boolean {
+ const key = 'reminder-push';
+ const now = Date.now();
+ const timestamps = rateLimitMap.get(key) ?? [];
+ const recent = timestamps.filter((t) => now - t < RATE_LIMIT_WINDOW);
+ if (recent.length >= RATE_LIMIT_MAX) return false;
+ recent.push(now);
+ rateLimitMap.set(key, recent);
+ return true;
+}
+
+function safeCompare(a: string, b: string): boolean {
+ if (a.length !== b.length) return false;
+ return timingSafeEqual(Buffer.from(a), Buffer.from(b));
+}
+
+export async function POST(request: NextRequest) {
+ try {
+ if (!checkRateLimit()) {
+ return Errors.badRequest('Rate limit exceeded').toResponse();
+ }
+
+ const authHeader = request.headers.get('authorization');
+ const expectedKey = process.env.INTERNAL_API_KEY;
+ const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : '';
+ if (!expectedKey || !token || !safeCompare(token, expectedKey)) {
+ return Errors.unauthorized('Invalid API key').toResponse();
+ }
+
+ const body = await request.json();
+ const { type, memberIds, title, body: pushBody, clickUrl } = body;
+
+ // Validation
+ if (typeof type !== 'string' || typeof title !== 'string' || typeof pushBody !== 'string' || typeof clickUrl !== 'string') {
+ return Errors.badRequest('type, memberIds, title, body, clickUrl are required').toResponse();
+ }
+ if (!Array.isArray(memberIds) || memberIds.length === 0) {
+ return Errors.badRequest('memberIds must be a non-empty array').toResponse();
+ }
+ if (!memberIds.every((id: unknown) => typeof id === 'string' && UUID_RE.test(id))) {
+ return Errors.badRequest('Invalid memberIds format').toResponse();
+ }
+ if (title.length > 200) return Errors.badRequest('title too long (max 200)').toResponse();
+ if (pushBody.length > 1000) return Errors.badRequest('body too long (max 1000)').toResponse();
+ if (!clickUrl.startsWith('/')) return Errors.badRequest('clickUrl must start with /').toResponse();
+
+ const result = await sendPushToMembers(memberIds, {
+ title,
+ body: pushBody,
+ clickUrl,
+ data: { type },
+ });
+
+ // 알림 로그 기록
+ await logNotification({
+ source: 'web',
+ type,
+ summary: `[푸시] ${title}: ${pushBody}`.slice(0, 500),
+ metadata: { memberCount: memberIds.length, ...result },
+ status: result.success > 0 ? 'sent' : 'failed',
+ errorMessage: result.success === 0 && result.failed > 0 ? `${result.failed}건 전송 실패` : undefined,
+ });
+
+ return successResponse(result);
+ } catch (error) {
+ console.error('[internal/reminder-push] Error:', error);
+ return errorResponse(error);
+ }
+}
+```
+
+- [ ] **Step 2: typecheck**
+
+```bash
+pnpm typecheck
+```
+
+- [ ] **Step 3: 커밋**
+
+```bash
+git add packages/web/src/app/api/internal/reminder-push/route.ts
+git commit -m "feat: 범용 리마인더 푸시 내부 API"
+```
+
+---
+
+### Task 5: 봇 push-client + DM→푸시 전환
+
+**Files:**
+- Create: `packages/bot/src/lib/push-client.ts`
+- Modify: `packages/bot/src/handlers/dm-handler.ts:206-409` (send 함수들 내부 교체)
+- Modify: `packages/bot/src/schedulers/fine-reminder.ts:52-115` (grace nudge)
+- Modify: `packages/bot/src/schedulers/deadline-reminder.ts:198-289` (sendForDDay)
+- Modify: `packages/bot/src/schedulers/poll-reminder.ts:142-217` (sendDMsToNonVoters)
+
+- [ ] **Step 1: push-client.ts — 웹 내부 API 호출 래퍼**
+
+`packages/bot/src/lib/push-client.ts`:
+
+```ts
+import logger from './logger';
+
+interface ReminderPushPayload {
+ type: string;
+ memberIds: string[];
+ title: string;
+ body: string;
+ clickUrl: string;
+}
+
+interface PushResult {
+ success: number;
+ failed: number;
+}
+
+export async function sendReminderPush(payload: ReminderPushPayload): Promise {
+ const webUrl = process.env.WEB_URL;
+ const apiKey = process.env.INTERNAL_API_KEY;
+
+ if (!webUrl || !apiKey) {
+ logger.warn('📱 [Push] WEB_URL 또는 INTERNAL_API_KEY 미설정, 푸시 스킵');
+ return { success: 0, failed: payload.memberIds.length };
+ }
+
+ try {
+ const res = await fetch(`${webUrl}/api/internal/reminder-push`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${apiKey}`,
+ },
+ body: JSON.stringify(payload),
+ signal: AbortSignal.timeout(15_000),
+ });
+
+ if (!res.ok) {
+ const text = await res.text().catch(() => '');
+ logger.error({ status: res.status, body: text }, '📱 [Push] 내부 API 호출 실패');
+ return { success: 0, failed: payload.memberIds.length };
+ }
+
+ const json = await res.json();
+ const result: PushResult = json.data ?? { success: 0, failed: 0 };
+ logger.info({ type: payload.type, ...result }, '📱 [Push] 발송 완료');
+ return result;
+ } catch (error) {
+ logger.error({ error, type: payload.type }, '📱 [Push] 내부 API 호출 에러');
+ return { success: 0, failed: payload.memberIds.length };
+ }
+}
+```
+
+- [ ] **Step 2: dm-handler.ts — sendFineNotification을 푸시로 전환**
+
+`sendFineNotification()` 함수 시그니처 변경: `client` 파라미터 제거, `memberId` 추가.
+
+```ts
+export async function sendFineNotification(
+ memberId: string,
+ fineId: string,
+ amount: number,
+ type: 'late' | 'absent',
+ roundNumber: number
+): Promise {
+ try {
+ const reason = formatFineReason(type);
+
+ const result = await sendReminderPush({
+ type: 'fine_notification',
+ memberIds: [memberId],
+ title: '벌금이 부과되었어요',
+ body: `${roundNumber}회차 ${reason} 벌금 ${amount.toLocaleString()}원이 부과되었습니다.`,
+ clickUrl: '/profile/fines',
+ });
+
+ await addPendingConfirmation(memberId, fineId);
+
+ logger.info({ memberId, fineId }, '📱 [Push] 벌금 알림 발송 완료');
+ return result.success > 0;
+ } catch (error) {
+ logger.error({ memberId, error: serializeError(error) }, '📱 [Push] 벌금 알림 발송 실패');
+ return false;
+ }
+}
+```
+
+import 추가: `import { sendReminderPush } from '../lib/push-client';`
+import 제거: `Client` 관련 import에서 `Client` 사용하는 부분 정리 (setupDMHandler에서는 여전히 필요).
+
+- [ ] **Step 3: dm-handler.ts — sendFineReminder을 푸시로 전환**
+
+```ts
+export async function sendFineReminder(
+ memberId: string,
+ fineId: string,
+ amount: number,
+ type: 'late' | 'absent',
+ roundNumber: number,
+ daysSinceCreation: number
+): Promise {
+ try {
+ const reason = formatFineReason(type);
+
+ const result = await sendReminderPush({
+ type: 'fine_reminder',
+ memberIds: [memberId],
+ title: '미납 벌금 리마인더',
+ body: `${roundNumber}회차 ${reason} 벌금이 ${daysSinceCreation}일째 미납 상태입니다.`,
+ clickUrl: '/profile/fines',
+ });
+
+ await addPendingConfirmation(memberId, fineId);
+
+ logger.info({ memberId, fineId }, '📱 [Push] 벌금 리마인더 발송 완료');
+ return result.success > 0;
+ } catch (error) {
+ logger.error({ memberId, error: serializeError(error) }, '📱 [Push] 벌금 리마인더 발송 실패');
+ return false;
+ }
+}
+```
+
+- [ ] **Step 4: dm-handler.ts — sendPollReminderDM을 푸시로 전환**
+
+함수명을 `sendPollReminderPush`로 변경하고, 시그니처 변경:
+
+```ts
+export async function sendPollReminderPush(
+ memberId: string,
+ pollQuestion: string,
+ expiresAt: Date,
+ postId: string,
+): Promise {
+ try {
+ const expiresHour = expiresAt.toLocaleString('ko-KR', {
+ timeZone: 'Asia/Seoul',
+ month: 'long',
+ day: 'numeric',
+ hour: 'numeric',
+ minute: '2-digit',
+ });
+
+ const result = await sendReminderPush({
+ type: 'poll_reminder',
+ memberIds: [memberId],
+ title: '투표에 참여해주세요',
+ body: `"${pollQuestion}" 투표가 ${expiresHour}에 마감됩니다!`,
+ clickUrl: `/board/${postId}`,
+ });
+
+ logger.info({ memberId, pollQuestion }, '📱 [Push] 투표 리마인더 발송 완료');
+ return result.success > 0;
+ } catch (error) {
+ logger.error({ memberId, error: serializeError(error) }, '📱 [Push] 투표 리마인더 발송 실패');
+ return false;
+ }
+}
+```
+
+dm-handler.ts에서 기존 `logNotification` import와 DM 발송 함수 내의 `logNotification` 호출을 모두 제거 (웹 API가 로그를 담당).
+
+- [ ] **Step 5: fine-reminder.ts — sendGracePeriodNudge를 푸시로 전환**
+
+`packages/bot/src/schedulers/fine-reminder.ts`의 `sendGracePeriodNudge()` 수정.
+
+`Client` 대신 `sendReminderPush` 사용. 멤버 쿼리에 `members.id` 추가:
+
+```ts
+private async sendGracePeriodNudge(): Promise {
+ try {
+ const currentRound = await getCurrentRound().catch(() => null);
+ if (!currentRound) return;
+
+ const now = new Date();
+ const submissionDeadline = new Date(`${currentRound.graceEndDate}T00:00:00.000+09:00`);
+ const graceEnd = new Date(`${currentRound.graceEndDate}T23:59:59.999+09:00`);
+ if (now <= submissionDeadline || now > graceEnd) return;
+
+ const db = getDb();
+ const pendingMembers = await db
+ .select({
+ id: members.id,
+ nickname: members.nickname,
+ })
+ .from(attendance)
+ .innerJoin(members, eq(attendance.memberId, members.id))
+ .where(
+ and(
+ eq(attendance.roundId, currentRound.id),
+ eq(attendance.status, AttendanceStatus.PENDING),
+ eq(members.status, MemberStatus.ACTIVE),
+ )
+ );
+
+ if (pendingMembers.length === 0) return;
+
+ logger.info(`✍️ [지각 독촉] 미제출 멤버 ${pendingMembers.length}명에게 푸시 발송`);
+
+ const memberIds = pendingMembers.map((m) => m.id);
+ await sendReminderPush({
+ type: 'grace_nudge',
+ memberIds,
+ title: '아직 시간이 있어요!',
+ body: `${currentRound.roundNumber}회차 마감은 지났지만, 오늘 안에 제출하면 결석은 피할 수 있어요.`,
+ clickUrl: '/dashboard',
+ });
+ } catch (error) {
+ logger.error({ error }, '✍️ [지각 독촉] 에러');
+ }
+}
+```
+
+import 추가: `import { sendReminderPush } from '../lib/push-client';`
+import 제거: `logNotification` (grace_nudge 로그는 웹 API가 담당)
+
+- [ ] **Step 6: deadline-reminder.ts — sendForDDay를 푸시로 전환**
+
+`packages/bot/src/schedulers/deadline-reminder.ts`의 `sendForDDay()` 수정.
+
+쿼리에 `members.id` 추가, `this.client` 대신 `sendReminderPush` 사용:
+
+```ts
+private async sendForDDay(
+ dDay: number,
+ currentRound: { id: number; roundNumber: number; endDate: string },
+): Promise {
+ // ... 기존 emptyResult, message 생성 유지 ...
+
+ const db = getDb();
+ const pendingMembers = await db
+ .select({
+ id: members.id,
+ discordId: members.discordId,
+ nickname: members.nickname,
+ })
+ .from(attendance)
+ .innerJoin(members, eq(attendance.memberId, members.id))
+ .where(
+ and(
+ eq(attendance.roundId, currentRound.id),
+ eq(attendance.status, AttendanceStatus.PENDING),
+ eq(members.status, MemberStatus.ACTIVE),
+ )
+ );
+
+ if (pendingMembers.length === 0) {
+ logger.info({ dDay }, '📅 [마감 리마인더] 미제출 멤버 없음');
+ return emptyResult();
+ }
+
+ logger.info(
+ { dDay, count: pendingMembers.length },
+ `📅 [마감 리마인더] D-${dDay} 미제출 멤버 ${pendingMembers.length}명에게 푸시 발송`
+ );
+
+ const memberIds = pendingMembers.map((m) => m.id);
+ const pushTitle = message.title.replace(/^"|"$/g, '');
+ const pushBody = message.body.join(' ').slice(0, 200);
+
+ const result = await sendReminderPush({
+ type: 'deadline_reminder',
+ memberIds,
+ title: pushTitle,
+ body: pushBody,
+ clickUrl: '/dashboard',
+ });
+
+ return {
+ timestamp: new Date(),
+ dDay,
+ targetCount: pendingMembers.length,
+ sentCount: result.success,
+ failedCount: result.failed,
+ };
+}
+```
+
+import 추가: `import { sendReminderPush } from '../lib/push-client';`
+import/사용 제거: `logNotification`, `Client` 관련 (클래스에서 `client` 프로퍼티는 레거시 호환용으로 유지하되 미사용)
+
+- [ ] **Step 7: poll-reminder.ts — sendDMsToNonVoters를 푸시로 전환**
+
+`packages/bot/src/schedulers/poll-reminder.ts`의 `sendDMsToNonVoters()` 수정.
+
+쿼리에 `members.id` 추가, `sendPollReminderDM` → `sendPollReminderPush` 변경:
+
+```ts
+import { sendPollReminderPush } from '../handlers/dm-handler';
+
+// sendDMsToNonVoters 내부:
+// 개별 발송 (targetDiscordId 지정)
+if (targetDiscordId) {
+ const [target] = await db
+ .select({ id: members.id, discordId: members.discordId, name: members.name })
+ .from(members)
+ .where(eq(members.discordId, targetDiscordId))
+ .limit(1);
+
+ if (!target) { /* ... 기존 에러 처리 ... */ }
+
+ const success = await sendPollReminderPush(target.id, question, expiresAt, postId);
+ return success ? { sent: 1, failed: 0 } : { sent: 0, failed: 1 };
+}
+
+// 전체 발송:
+const nonVoterMembers = await db
+ .select({ id: members.id, discordId: members.discordId, name: members.name })
+ .from(members)
+ .where(/* 기존 조건 유지 */);
+
+let sent = 0;
+let failed = 0;
+for (const member of nonVoterMembers) {
+ const success = await sendPollReminderPush(member.id, question, expiresAt, postId);
+ if (success) sent++; else failed++;
+}
+return { sent, failed };
+```
+
+- [ ] **Step 8: fine-reminder.ts — sendFineReminder 호출 수정**
+
+`packages/bot/src/schedulers/fine-reminder.ts`:
+
+`sendFineReminder` 호출에서 `client` 제거, `discordId` → `memberId`로 변경.
+
+`fineService.getFinesWithMemberInfo()`가 memberId를 반환하는지 확인. 반환하지 않으면 fine 레코드의 `memberId`를 직접 사용:
+
+```ts
+// 기존:
+const success = await sendFineReminder(this.client, discordId, fine.id, fine.amount, fine.type as 'late' | 'absent', roundNumber, daysSinceCreation);
+
+// 변경:
+const success = await sendFineReminder(fine.memberId, fine.id, fine.amount, fine.type as 'late' | 'absent', roundNumber, daysSinceCreation);
+```
+
+`getFinesWithMemberInfo()` 반환값에서 `fine.memberId`를 사용. `discordId` 파라미터는 더 이상 불필요.
+
+`this.client` 사용 부분도 grace nudge에서 제거되므로, `setClient()`와 `client` 프로퍼티는 유지하되 미사용 상태가 됨.
+
+- [ ] **Step 9: handlers/index.ts export 정리**
+
+`packages/bot/src/handlers/index.ts`에서 export 확인. `sendPollReminderDM`이 `sendPollReminderPush`로 변경되었으므로 export명도 업데이트.
+
+- [ ] **Step 10: typecheck + 빌드**
+
+```bash
+pnpm --filter @blog-study/shared build
+pnpm typecheck
+pnpm --filter @blog-study/bot build
+```
+
+- [ ] **Step 11: 커밋**
+
+```bash
+git add packages/bot/src/lib/push-client.ts packages/bot/src/handlers/dm-handler.ts packages/bot/src/handlers/index.ts packages/bot/src/schedulers/fine-reminder.ts packages/bot/src/schedulers/deadline-reminder.ts packages/bot/src/schedulers/poll-reminder.ts
+git commit -m "feat: Discord DM → FCM 푸시 전환 (5종)"
+```
+
+---
+
+### Task 6: 최종 검증 + 정리
+
+**Files:**
+- Modify: `packages/bot/src/handlers/handlers.test.ts` (export 테스트 업데이트)
+
+- [ ] **Step 1: 기존 테스트 업데이트**
+
+`packages/bot/src/handlers/handlers.test.ts`:
+
+`sendFineNotification`, `sendFineReminder` export 테스트의 시그니처가 변경되었으므로 (client 파라미터 제거), 테스트를 함수 존재 여부만 확인하는 방식으로 유지 (기존과 동일).
+
+`sendPollReminderDM` → `sendPollReminderPush`로 변경된 경우 테스트도 import명 업데이트.
+
+- [ ] **Step 2: 전체 lint + typecheck + test**
+
+```bash
+pnpm lint
+pnpm typecheck
+pnpm test
+```
+
+- [ ] **Step 3: 커밋**
+
+```bash
+git add packages/bot/src/handlers/handlers.test.ts
+git commit -m "test: DM→푸시 전환에 따른 테스트 업데이트"
+```
diff --git a/docs/superpowers/specs/26-04-15-dm-to-push-and-fines-page-design.md b/docs/superpowers/specs/26-04-15-dm-to-push-and-fines-page-design.md
new file mode 100644
index 0000000..d3d050f
--- /dev/null
+++ b/docs/superpowers/specs/26-04-15-dm-to-push-and-fines-page-design.md
@@ -0,0 +1,218 @@
+# Discord DM → FCM 푸시 전환 + 벌금 상세 페이지
+
+## 개요
+
+Discord DM으로 보내던 개인 알림(벌금, 마감, 투표 리마인더 등)을 FCM 푸시 알림으로 전환한다.
+이에 따라 벌금 "납부 완료" 버튼을 웹으로 옮기기 위해 벌금 상세 페이지를 신규 개발한다.
+
+## 변경 범위
+
+1. **벌금 상세 페이지** — `/profile/fines` 신규
+2. **내부 API** — `POST /api/internal/reminder-push` 신규
+3. **봇 DM → 푸시 전환** — 5종 DM 발송 로직을 내부 API 호출로 교체
+4. **NotificationType 확장** — 5종 추가 + 알림 설정 UI 반영
+5. **알림 로그 대상 확장** — `push` 대상 추가
+
+---
+
+## 1. 벌금 상세 페이지
+
+### 경로
+
+`/profile/fines` (`packages/web/src/app/(user)/profile/fines/page.tsx`)
+
+### 진입점
+
+- 프로필 "미납 벌금" 스탯 카드 클릭
+- 푸시 알림 딥링크 (`clickUrl: '/profile/fines'`)
+
+### 레이아웃 (단일 리스트)
+
+```
+┌─────────────────────────────────────────┐
+│ Profile / Fines │
+│ 벌금 내역 ← 뒤로 │
+├─────────────────────────────────────────┤
+│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
+│ │ 미납 │ │ 납부완료 │ │ 총 벌금 │ │
+│ │ 3,000원 │ │ 6,000원 │ │ 9,000원 │ │
+│ │ (빨강) │ │ (초록) │ │ │ │
+│ └──────────┘ └──────────┘ └──────────┘ │
+├─────────────────────────────────────────┤
+│ 미납 벌금 │
+│ ┌─────────────────────────────────────┐ │
+│ │ 5회차 · 지각 3,000원 (빨강) │ │
+│ │ 2026.04.14 [납부 완료] │ │
+│ └─────────────────────────────────────┘ │
+├─────────────────────────────────────────┤
+│ 납부 / 면제 │
+│ ┌─────────────────────────────────────┐ │
+│ │ 4회차 · 결석 (취소선) 3,000원 │ │
+│ │ 2026.04.07 납부완료 뱃지 │ │
+│ ├─────────────────────────────────────┤ │
+│ │ 3회차 · 지각 (취소선) 3,000원 │ │
+│ │ 2026.03.31 면제 뱃지 │ │
+│ └─────────────────────────────────────┘ │
+├─────────────────────────────────────────┤
+│ 입금 계좌: 3333333114501 │
+│ 카카오뱅크 │
+└─────────────────────────────────────────┘
+```
+
+### 납부 완료 플로우
+
+1. "납부 완료" 버튼 클릭
+2. 확인 다이얼로그 표시 ("정말 납부 완료 처리하시겠습니까?")
+3. `PATCH /api/fines/[id]/pay` 호출
+4. 서버: `status = PAID`, `paidAt = now()`, `pendingConfirmation = false`
+5. 서버: 관리자 Discord 채널에 납부 완료 알림 (`discord-notify.ts`)
+6. 클라이언트: 토스트 ("납부가 확인되었습니다") + 목록 갱신
+
+### API
+
+**`GET /api/profile/fines`** — 내 벌금 목록 조회
+
+- 인증: Supabase Auth → Discord ID → member
+- 응답: `{ fines: Fine[], summary: { unpaid, paid, total } }`
+- Fine: `{ id, roundNumber, type, amount, status, createdAt, paidAt }`
+- 정렬: 미납 먼저 (createdAt DESC), 그 다음 납부/면제 (createdAt DESC)
+
+**`PATCH /api/fines/[id]/pay`** — 납부 완료 처리
+
+- 인증: Supabase Auth → 본인 벌금인지 검증
+- 검증: status가 PENDING(UNPAID)인 경우만 허용
+- 처리: status=PAID, paidAt=now(), pendingConfirmation=false
+- 사이드이펙트: `after()`로 관리자 Discord 채널 알림 발송
+- 응답: `{ fine: Fine }`
+
+---
+
+## 2. 내부 API (`POST /api/internal/reminder-push`)
+
+기존 `POST /api/internal/new-post-push` 패턴을 범용화한다.
+
+### 스펙
+
+- **인증**: `Authorization: Bearer {INTERNAL_API_KEY}` (timing-safe comparison)
+- **Rate limit**: 30 requests/minute
+- **Body**:
+ ```ts
+ {
+ type: string; // NotificationType (fine_notification, deadline_reminder, ...)
+ memberIds: string[]; // 대상 멤버 ID 배열
+ title: string; // 푸시 제목
+ body: string; // 푸시 본문
+ clickUrl: string; // 클릭 시 이동 URL
+ }
+ ```
+- **처리**: `sendPushToMembers(memberIds, { title, body, clickUrl, data: { type } })`
+- **응답**: `{ success: number, failed: number }`
+
+### 검증
+
+- `type`: NotificationType enum에 포함된 값인지
+- `memberIds`: 1개 이상, 각각 UUID 형식
+- `title`, `body`: string, 최대 200자 / 1000자
+- `clickUrl`: `/`로 시작하는 상대 경로
+
+---
+
+## 3. 봇 DM → 푸시 전환
+
+### 전환 대상
+
+| # | 기존 DM 함수 | 푸시 type | title | body 템플릿 | clickUrl |
+|---|-------------|-----------|-------|------------|----------|
+| 1 | `sendFineNotification()` | `fine_notification` | "벌금이 부과되었어요" | "{round}회차 {reason} 벌금 {amount}원이 부과되었습니다." | `/profile/fines` |
+| 2 | `sendFineReminder()` | `fine_reminder` | "미납 벌금 리마인더" | "{round}회차 {reason} 벌금이 {days}일째 미납 상태입니다." | `/profile/fines` |
+| 3 | `sendGracePeriodNudge()` | `grace_nudge` | "아직 시간이 있어요!" | "오늘 안에 제출하면 결석은 피할 수 있어요." | `/dashboard` |
+| 4 | `DeadlineReminder.sendForDDay()` | `deadline_reminder` | D-day별 메시지 타이틀 | D-day별 메시지 본문 (기존 문구 유지) | `/dashboard` |
+| 5 | `sendPollReminderDM()` | `poll_reminder` | "투표에 참여해주세요" | '"{question}" 투표가 곧 마감됩니다!' | `/board/{postId}` |
+
+### 봇 변경사항
+
+**신규**: `packages/bot/src/lib/push-client.ts`
+- `sendReminderPush(payload)` — 웹 내부 API 호출 래퍼
+- `WEB_URL + '/api/internal/reminder-push'` + Bearer 인증
+- 실패 시 로그만 남기고 에러 throw하지 않음
+
+**수정**: 각 스케줄러/핸들러
+- `dm-handler.ts`: `sendFineNotification()`, `sendFineReminder()`, `sendPollReminderDM()` → 내부에서 `user.send()` 대신 `sendReminderPush()` 호출
+- `fine-reminder.ts`: `sendGracePeriodNudge()` → `sendReminderPush()` 호출
+- `deadline-reminder.ts`: `sendForDDay()` → `sendReminderPush()` 호출
+
+**유지**: `setupDMHandler()` + 버튼 인터랙션 핸들러
+- 이미 발송된 Discord DM의 "납부 완료" 버튼 클릭은 계속 처리
+- 신규 벌금 납부는 웹 `/profile/fines`에서만 가능
+
+### 알림 로그
+
+- 봇의 기존 `logNotification()` 호출은 **제거** (DM을 더 이상 보내지 않으므로)
+- 웹 내부 API에서 푸시 발송 후 로그 기록 (source: `'web'`, target: `'push'`)
+- 이중 로깅 방지: 알림 하나당 로그 한 건 (웹 API가 담당)
+
+---
+
+## 4. NotificationType 확장
+
+### schema.ts 변경
+
+```ts
+export const NotificationType = {
+ BOARD_COMMENT: 'board_comment',
+ BOARD_REPLY: 'board_reply',
+ POST_COMMENT: 'post_comment',
+ POST_REPLY: 'post_reply',
+ BOARD_NOTICE: 'board_notice',
+ NEW_POST: 'new_post',
+ // 신규 추가
+ FINE_NOTIFICATION: 'fine_notification',
+ FINE_REMINDER: 'fine_reminder',
+ DEADLINE_REMINDER: 'deadline_reminder',
+ GRACE_NUDGE: 'grace_nudge',
+ POLL_REMINDER: 'poll_reminder',
+} as const;
+```
+
+### 알림 설정 UI 확장
+
+`push-notification-settings.tsx`의 `NOTIFICATION_LABELS`에 추가:
+
+| type | label | description |
+|------|-------|-------------|
+| `fine_notification` | 벌금 알림 | 벌금이 부과될 때 |
+| `fine_reminder` | 벌금 리마인더 | 미납 벌금 독촉 |
+| `deadline_reminder` | 마감 리마인더 | 제출 마감 D-2/D-1/D-day |
+| `grace_nudge` | 지각 독촉 | 지각 기간 제출 독려 |
+| `poll_reminder` | 투표 리마인더 | 투표 마감 전 참여 요청 |
+
+---
+
+## 5. 알림 로그 대상 확장
+
+### notification-log-config.ts 변경
+
+`isDM: boolean` → `target: 'channel' | 'dm' | 'push'`로 확장.
+
+```ts
+export interface NotificationLogTypeMeta {
+ label: string;
+ color: string;
+ target: 'channel' | 'dm' | 'push';
+}
+```
+
+기존 `isDM: true` → `target: 'dm'`, `isDM: false` → `target: 'channel'`.
+새 푸시 알림 타입은 `target: 'push'`.
+
+### 관리자 UI 필터
+
+대상 필터 옵션: `전체` / `채널` / `DM` / `푸시`
+
+---
+
+## 스코프 외
+
+- 기존 Discord 채널 알림(새 글, 인기 포스트, 회차 리포트 등)은 변경하지 않음
+- `setupDMHandler()`의 버튼 인터랙션은 레거시 호환용으로 유지
+- Vercel Cron으로의 스케줄러 이전은 이번 스코프에 포함하지 않음
diff --git a/packages/bot/src/handlers/dm-handler.ts b/packages/bot/src/handlers/dm-handler.ts
index 31d8e8b..cba3678 100644
--- a/packages/bot/src/handlers/dm-handler.ts
+++ b/packages/bot/src/handlers/dm-handler.ts
@@ -7,9 +7,6 @@
*/
import {
- ActionRowBuilder,
- ButtonBuilder,
- ButtonStyle,
ChannelType,
Client,
Events,
@@ -22,6 +19,7 @@ import { formatFineReason, getFineService, } from '../services';
import { ConfigKeys, getConfigValue } from '../services/round.service';
import logger, { serializeError } from '../lib/logger';
import { logNotification } from '../lib/notification-logger';
+import { sendReminderPush } from '../lib/push-client';
/**
* Add a pending fine confirmation for a user
@@ -199,83 +197,40 @@ async function handleButtonInteraction(interaction: Interaction): Promise
}
/**
- * Send fine notification DM to a user with payment confirmation button
- * Requirements: 8.1 - Send DM with fine amount, reason, and payment instructions
- * MessageContent Intent 없이 동작 - 버튼 사용
+ * Send fine notification push to a user
+ * Requirements: 8.1 - Send push with fine amount, reason, and payment instructions
*/
export async function sendFineNotification(
- client: Client,
- discordId: string,
+ memberId: string,
fineId: string,
amount: number,
type: 'late' | 'absent',
roundNumber: number
): Promise {
try {
- const user = await client.users.fetch(discordId);
- if (!user) {
- logger.error({ discordId }, '💬 [DM] 유저를 찾을 수 없음');
- return false;
- }
-
const reason = formatFineReason(type);
- const message = [
- `📢 **벌금 알림**`,
- ``,
- `${roundNumber}회차 ${reason}으로 인해 벌금이 부과되었습니다.`,
- ``,
- `💰 **금액**: ${amount.toLocaleString()}원`,
- `📝 **사유**: ${reason}`,
- `🏦 **계좌**: 3333333114501 (카카오뱅크)`,
- ``,
- `계좌에 금액 입금 후 아래 완료 버튼을 클릭해주세요.`,
- ].join('\n');
-
- // Create payment confirmation button
- const row = new ActionRowBuilder()
- .addComponents(
- new ButtonBuilder()
- .setCustomId(`confirm_payment_${fineId}`)
- .setLabel('✅ 납부 완료')
- .setStyle(ButtonStyle.Success)
- );
-
- await user.send({
- content: message,
- components: [row],
- });
- await logNotification({
- source: 'bot', type: 'fine_notification',
- targetDiscordId: discordId,
- summary: `${roundNumber}회차 벌금 알림 (${amount.toLocaleString()}원)`,
- status: 'sent',
+ const result = await sendReminderPush({
+ type: 'fine_notification',
+ memberIds: [memberId],
+ title: '벌금이 부과되었어요',
+ body: `${roundNumber}회차 ${reason} 벌금 ${amount.toLocaleString()}원이 부과되었습니다.`,
+ clickUrl: '/profile/fines',
});
-
- // Track pending confirmation in DB
- await addPendingConfirmation(discordId, fineId);
-
- logger.info({ discordId, fineId }, '💬 [DM] 벌금 알림 발송 완료');
- return true;
+ await addPendingConfirmation(memberId, fineId);
+ logger.info({ memberId, fineId }, '📱 [Push] 벌금 알림 발송 완료');
+ return result.success > 0;
} catch (error) {
- await logNotification({
- source: 'bot', type: 'fine_notification',
- targetDiscordId: discordId,
- summary: `${roundNumber}회차 벌금 알림 (${amount.toLocaleString()}원)`,
- status: 'failed', errorMessage: error instanceof Error ? error.message : String(error),
- });
- logger.error({ discordId, error: serializeError(error) }, '💬 [DM] 벌금 알림 발송 실패');
+ logger.error({ memberId, error: serializeError(error) }, '📱 [Push] 벌금 알림 발송 실패');
return false;
}
}
/**
- * Send fine reminder DM to a user with payment confirmation button
+ * Send fine reminder push to a user
* Requirements: 8.4 - Send reminder for unpaid fines
- * MessageContent Intent 없이 동작 - 버튼 사용
*/
export async function sendFineReminder(
- client: Client,
- discordId: string,
+ memberId: string,
fineId: string,
amount: number,
type: 'late' | 'absent',
@@ -283,80 +238,34 @@ export async function sendFineReminder(
daysSinceCreation: number
): Promise {
try {
- const user = await client.users.fetch(discordId);
- if (!user) {
- logger.error({ discordId }, '💬 [DM] 유저를 찾을 수 없음');
- return false;
- }
-
const reason = formatFineReason(type);
- const message = [
- `⏰ **벌금 리마인더**`,
- ``,
- `${roundNumber}회차 ${reason} 벌금이 아직 미납 상태입니다.`,
- `(${daysSinceCreation}일 경과)`,
- ``,
- `💰 **금액**: ${amount.toLocaleString()}원`,
- `🏦 **계좌**: 3333333114501 (카카오뱅크)`,
- ``,
- `계좌에 금액 입금 후 아래 완료 버튼을 클릭해주세요.`,
- ].join('\n');
-
- // Create payment confirmation button
- const row = new ActionRowBuilder()
- .addComponents(
- new ButtonBuilder()
- .setCustomId(`confirm_payment_${fineId}`)
- .setLabel('✅ 납부 완료')
- .setStyle(ButtonStyle.Success)
- );
-
- await user.send({
- content: message,
- components: [row],
+ const result = await sendReminderPush({
+ type: 'fine_reminder',
+ memberIds: [memberId],
+ title: '미납 벌금 리마인더',
+ body: `${roundNumber}회차 ${reason} 벌금 ${amount.toLocaleString()}원이 아직 미납 상태입니다. (${daysSinceCreation}일 경과)`,
+ clickUrl: '/profile/fines',
});
- await logNotification({
- source: 'bot', type: 'fine_reminder',
- targetDiscordId: discordId,
- summary: `${roundNumber}회차 벌금 리마인더 (${daysSinceCreation}일 경과)`,
- status: 'sent',
- });
-
- // Ensure pending confirmation is tracked in DB
- await addPendingConfirmation(discordId, fineId);
-
- logger.info({ discordId, fineId }, '💬 [DM] 벌금 리마인더 발송 완료');
- return true;
+ await addPendingConfirmation(memberId, fineId);
+ logger.info({ memberId, fineId }, '📱 [Push] 벌금 리마인더 발송 완료');
+ return result.success > 0;
} catch (error) {
- await logNotification({
- source: 'bot', type: 'fine_reminder',
- targetDiscordId: discordId,
- summary: `${roundNumber}회차 벌금 리마인더 (${daysSinceCreation}일 경과)`,
- status: 'failed', errorMessage: error instanceof Error ? error.message : String(error),
- });
- logger.error({ discordId, error: serializeError(error) }, '💬 [DM] 벌금 리마인더 발송 실패');
+ logger.error({ memberId, error: serializeError(error) }, '📱 [Push] 벌금 리마인더 발송 실패');
return false;
}
}
/**
- * Send poll reminder DM to a user
- * 투표 마감 전 미참여자에게 리마인더 발송
+ * Send poll reminder push to a user
+ * 투표 마감 전 미참여자에게 푸시 리마인더 발송
*/
-export async function sendPollReminderDM(
- client: Client,
- discordId: string,
+export async function sendPollReminderPush(
+ memberId: string,
pollQuestion: string,
expiresAt: Date,
postId: string,
): Promise {
try {
- const user = await client.users.fetch(discordId);
- if (!user) {
- logger.error({ discordId }, '💬 [DM] 유저를 찾을 수 없음');
- return false;
- }
-
const expiresHour = expiresAt.toLocaleString('ko-KR', {
timeZone: 'Asia/Seoul',
month: 'long',
@@ -365,45 +274,17 @@ export async function sendPollReminderDM(
minute: '2-digit',
});
- const webUrl = process.env.WEB_URL || 'https://kusting-web.vercel.app';
- const postUrl = `${webUrl}/board/${postId}`;
-
- const message = [
- `📊 **투표 참여 요청**`,
- ``,
- `"${pollQuestion}" 투표가 ${expiresHour}에 마감됩니다!`,
- `아직 참여하지 않으셨으니 투표해주세요 🙏`,
- ].join('\n');
-
- const row = new ActionRowBuilder()
- .addComponents(
- new ButtonBuilder()
- .setLabel('📊 투표하러 가기')
- .setStyle(ButtonStyle.Link)
- .setURL(postUrl),
- );
-
- await user.send({
- content: message,
- components: [row],
+ const result = await sendReminderPush({
+ type: 'poll_reminder',
+ memberIds: [memberId],
+ title: '투표 참여 요청',
+ body: `"${pollQuestion}" 투표가 ${expiresHour}에 마감됩니다! 아직 참여하지 않으셨으니 투표해주세요.`,
+ clickUrl: `/board/${postId}`,
});
- await logNotification({
- source: 'bot', type: 'poll_reminder',
- targetDiscordId: discordId,
- summary: `투표 리마인더: ${pollQuestion}`.slice(0, 200),
- status: 'sent',
- });
-
- logger.info({ discordId, pollQuestion }, '💬 [DM] 투표 리마인더 발송 완료');
- return true;
+ logger.info({ memberId, pollQuestion }, '📱 [Push] 투표 리마인더 발송 완료');
+ return result.success > 0;
} catch (error) {
- await logNotification({
- source: 'bot', type: 'poll_reminder',
- targetDiscordId: discordId,
- summary: `투표 리마인더: ${pollQuestion}`.slice(0, 200),
- status: 'failed', errorMessage: error instanceof Error ? error.message : String(error),
- });
- logger.error({ discordId, error: serializeError(error) }, '💬 [DM] 투표 리마인더 발송 실패');
+ logger.error({ memberId, error: serializeError(error) }, '📱 [Push] 투표 리마인더 발송 실패');
return false;
}
}
diff --git a/packages/bot/src/lib/push-client.ts b/packages/bot/src/lib/push-client.ts
new file mode 100644
index 0000000..de96aa8
--- /dev/null
+++ b/packages/bot/src/lib/push-client.ts
@@ -0,0 +1,50 @@
+import logger from './logger';
+
+interface ReminderPushPayload {
+ type: string;
+ memberIds: string[];
+ title: string;
+ body: string;
+ clickUrl: string;
+}
+
+interface PushResult {
+ success: number;
+ failed: number;
+}
+
+export async function sendReminderPush(payload: ReminderPushPayload): Promise {
+ const webUrl = process.env.WEB_URL;
+ const apiKey = process.env.INTERNAL_API_KEY;
+
+ if (!webUrl || !apiKey) {
+ logger.warn('📱 [Push] WEB_URL 또는 INTERNAL_API_KEY 미설정, 푸시 스킵');
+ return { success: 0, failed: payload.memberIds.length };
+ }
+
+ try {
+ const res = await fetch(`${webUrl}/api/internal/reminder-push`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${apiKey}`,
+ },
+ body: JSON.stringify(payload),
+ signal: AbortSignal.timeout(15_000),
+ });
+
+ if (!res.ok) {
+ const text = await res.text().catch(() => '');
+ logger.error({ status: res.status, body: text }, '📱 [Push] 내부 API 호출 실패');
+ return { success: 0, failed: payload.memberIds.length };
+ }
+
+ const json = (await res.json()) as { data?: PushResult };
+ const result: PushResult = json.data ?? { success: 0, failed: 0 };
+ logger.info({ type: payload.type, ...result }, '📱 [Push] 발송 완료');
+ return result;
+ } catch (error) {
+ logger.error({ error, type: payload.type }, '📱 [Push] 내부 API 호출 에러');
+ return { success: 0, failed: payload.memberIds.length };
+ }
+}
diff --git a/packages/bot/src/schedulers/deadline-reminder.ts b/packages/bot/src/schedulers/deadline-reminder.ts
index abbb269..090ad42 100644
--- a/packages/bot/src/schedulers/deadline-reminder.ts
+++ b/packages/bot/src/schedulers/deadline-reminder.ts
@@ -8,7 +8,7 @@ import { and, eq } from 'drizzle-orm';
import { attendance, AttendanceStatus, getDb, members, MemberStatus } from '@blog-study/shared/db';
import { getCurrentRound } from '../services/round.service';
import logger, { serializeError } from '../lib/logger';
-import { logNotification } from '../lib/notification-logger';
+import { sendReminderPush } from '../lib/push-client';
export interface DeadlineReminderResult {
timestamp: Date;
@@ -80,6 +80,7 @@ function formatKSTDate(dateStr: string): string {
export class DeadlineReminder {
private isRunning = false;
+ // @ts-expect-error -- scheduler-registry 호환 유지, push 전환 후 미사용
private client: Client | null = null;
setClient(client: Client): void {
@@ -107,11 +108,6 @@ export class DeadlineReminder {
return emptyResult();
}
- if (!this.client) {
- logger.error('📅 [마감 리마인더] Discord 클라이언트 미설정');
- return emptyResult();
- }
-
this.isRunning = true;
try {
@@ -164,12 +160,6 @@ export class DeadlineReminder {
this.isRunning = true;
- if (!this.client) {
- this.isRunning = false;
- logger.error('📅 [마감 리마인더] Discord 클라이언트 미설정');
- return emptyResult();
- }
-
if (dDay < 0 || dDay > 2) {
this.isRunning = false;
logger.info({ dDay }, '📅 [마감 리마인더] 유효하지 않은 D-day (0~2만 가능)');
@@ -193,7 +183,7 @@ export class DeadlineReminder {
}
/**
- * 실제 DM 발송 로직 (자동/수동 공용)
+ * 실제 푸시 발송 로직 (자동/수동 공용)
*/
private async sendForDDay(
dDay: number,
@@ -213,7 +203,7 @@ export class DeadlineReminder {
const db = getDb();
const pendingMembers = await db
.select({
- discordId: members.discordId,
+ id: members.id,
nickname: members.nickname,
})
.from(attendance)
@@ -233,58 +223,27 @@ export class DeadlineReminder {
logger.info(
{ dDay, count: pendingMembers.length },
- `📅 [마감 리마인더] D-${dDay} 미제출 멤버 ${pendingMembers.length}명에게 DM 발송`
+ `📅 [마감 리마인더] D-${dDay} 미제출 멤버 ${pendingMembers.length}명에게 푸시 발송`
);
- let sentCount = 0;
- let failedCount = 0;
+ const memberIds = pendingMembers.map((m) => m.id);
+ const pushTitle = message.title.replace(/^"|"$/g, '');
+ const pushBody = message.body.join(' ').slice(0, 200);
- for (const member of pendingMembers) {
- try {
- const user = await this.client!.users.fetch(member.discordId);
- const dmContent = [
- `${message.title}`,
- ``,
- ...message.body,
- ].join('\n');
-
- await user.send(dmContent);
- await logNotification({
- source: 'bot', type: 'deadline_reminder',
- targetDiscordId: member.discordId,
- summary: `D-${dDay} 마감 리마인더`,
- metadata: { dDay },
- status: 'sent',
- });
- sentCount++;
- } catch (err) {
- await logNotification({
- source: 'bot', type: 'deadline_reminder',
- targetDiscordId: member.discordId,
- summary: `D-${dDay} 마감 리마인더`,
- metadata: { dDay },
- status: 'failed',
- errorMessage: err instanceof Error ? err.message : String(err),
- });
- logger.error(
- { discordId: member.discordId, err: serializeError(err) },
- '📅 [마감 리마인더] DM 발송 실패'
- );
- failedCount++;
- }
- }
-
- logger.info(
- { dDay, sentCount, failedCount },
- `📅 [마감 리마인더] 완료 — 발송 ${sentCount}건, 실패 ${failedCount}건`
- );
+ const result = await sendReminderPush({
+ type: 'deadline_reminder',
+ memberIds,
+ title: pushTitle,
+ body: pushBody,
+ clickUrl: '/dashboard',
+ });
return {
timestamp: new Date(),
dDay,
targetCount: pendingMembers.length,
- sentCount,
- failedCount,
+ sentCount: result.success,
+ failedCount: result.failed,
};
}
}
diff --git a/packages/bot/src/schedulers/fine-reminder.ts b/packages/bot/src/schedulers/fine-reminder.ts
index 0bd34be..ddf14fa 100644
--- a/packages/bot/src/schedulers/fine-reminder.ts
+++ b/packages/bot/src/schedulers/fine-reminder.ts
@@ -11,7 +11,7 @@ import { getFineService } from '../services/fine.service';
import { sendFineReminder } from '../handlers/dm-handler';
import { getCurrentRound } from '../services/round.service';
import logger from '../lib/logger';
-import { logNotification } from '../lib/notification-logger';
+import { sendReminderPush } from '../lib/push-client';
/**
* Result of a fine reminder cycle
@@ -29,6 +29,7 @@ export interface FineReminderResult {
*/
export class FineReminder {
private isRunning = false;
+ // @ts-expect-error -- scheduler-registry 호환 유지, push 전환 후 미사용
private client: Client | null = null;
/**
@@ -46,12 +47,10 @@ export class FineReminder {
}
/**
- * 지각 기간(월요일)에 아직 PENDING인 멤버에게 독촉 DM 발송
+ * 지각 기간(월요일)에 아직 PENDING인 멤버에게 푸시 발송
* "오늘 안에 제출하면 결석은 피할 수 있어요!"
*/
private async sendGracePeriodNudge(): Promise {
- if (!this.client) return;
-
try {
const currentRound = await getCurrentRound().catch(() => null);
if (!currentRound) return;
@@ -67,7 +66,7 @@ export class FineReminder {
const db = getDb();
const pendingMembers = await db
.select({
- discordId: members.discordId,
+ id: members.id,
nickname: members.nickname,
})
.from(attendance)
@@ -82,33 +81,16 @@ export class FineReminder {
if (pendingMembers.length === 0) return;
- logger.info(`✍️ [지각 독촉] 미제출 멤버 ${pendingMembers.length}명에게 DM 발송`);
+ logger.info(`✍️ [지각 독촉] 미제출 멤버 ${pendingMembers.length}명에게 푸시 발송`);
- for (const member of pendingMembers) {
- try {
- const user = await this.client.users.fetch(member.discordId);
- await user.send([
- `✍️ **${member.nickname}님, 아직 시간이 있어요!**`,
- ``,
- `${currentRound.roundNumber}회차 마감은 지났지만, 오늘 안에 제출하면 결석은 피할 수 있어요.`,
- `짧은 글이라도 괜찮아요. 지금 시작해보는 건 어때요?`,
- ].join('\n'));
- await logNotification({
- source: 'bot', type: 'grace_nudge',
- targetDiscordId: member.discordId,
- summary: `${currentRound.roundNumber}회차 지각 독촉`,
- status: 'sent',
- });
- } catch (err) {
- await logNotification({
- source: 'bot', type: 'grace_nudge',
- targetDiscordId: member.discordId,
- summary: `${currentRound.roundNumber}회차 지각 독촉`,
- status: 'failed', errorMessage: err instanceof Error ? err.message : String(err),
- });
- logger.error({ discordId: member.discordId, err }, '✍️ [지각 독촉] DM 발송 실패');
- }
- }
+ const memberIds = pendingMembers.map((m) => m.id);
+ await sendReminderPush({
+ type: 'grace_nudge',
+ memberIds,
+ title: '아직 시간이 있어요!',
+ body: `${currentRound.roundNumber}회차 마감은 지났지만, 오늘 안에 제출하면 결석은 피할 수 있어요.`,
+ clickUrl: '/dashboard',
+ });
} catch (error) {
logger.error({ error }, '✍️ [지각 독촉] 에러');
}
@@ -130,17 +112,6 @@ export class FineReminder {
};
}
- if (!this.client) {
- logger.error('⏰ [벌금 리마인더] Discord 클라이언트 미설정');
- return {
- timestamp: new Date(),
- processedCount: 0,
- sentCount: 0,
- failedCount: 0,
- errors: ['Discord 클라이언트 미설정'],
- };
- }
-
this.isRunning = true;
const startTime = new Date();
const errors: string[] = [];
@@ -180,7 +151,7 @@ export class FineReminder {
);
// Send reminders
- for (const { fine, discordId, roundNumber } of finesNeedingReminder) {
+ for (const { fine, roundNumber } of finesNeedingReminder) {
const createdAt = fine.createdAt ? new Date(fine.createdAt) : new Date();
const daysSinceCreation = Math.floor(
(now.getTime() - createdAt.getTime()) / (1000 * 60 * 60 * 24)
@@ -188,8 +159,7 @@ export class FineReminder {
try {
const success = await sendFineReminder(
- this.client,
- discordId,
+ fine.memberId,
fine.id,
fine.amount,
fine.type as 'late' | 'absent',
@@ -257,17 +227,6 @@ export class FineReminder {
};
}
- if (!this.client) {
- logger.error('[FineReminder] Discord client not set');
- return {
- timestamp: new Date(),
- processedCount: 0,
- sentCount: 0,
- failedCount: 0,
- errors: ['Discord client not set'],
- };
- }
-
this.isRunning = true;
const startTime = new Date();
const errors: string[] = [];
@@ -284,7 +243,7 @@ export class FineReminder {
const now = new Date();
- for (const { fine, discordId, roundNumber } of finesWithInfo) {
+ for (const { fine, roundNumber } of finesWithInfo) {
const createdAt = fine.createdAt ? new Date(fine.createdAt) : new Date();
const daysSinceCreation = Math.floor(
(now.getTime() - createdAt.getTime()) / (1000 * 60 * 60 * 24)
@@ -292,8 +251,7 @@ export class FineReminder {
try {
const success = await sendFineReminder(
- this.client,
- discordId,
+ fine.memberId,
fine.id,
fine.amount,
fine.type as 'late' | 'absent',
diff --git a/packages/bot/src/schedulers/poll-reminder.ts b/packages/bot/src/schedulers/poll-reminder.ts
index 5378a3a..1a6ced4 100644
--- a/packages/bot/src/schedulers/poll-reminder.ts
+++ b/packages/bot/src/schedulers/poll-reminder.ts
@@ -6,7 +6,7 @@
import { Client } from 'discord.js';
import { and, eq, inArray, isNull, notInArray } from 'drizzle-orm';
import { boardPolls, boardPollVotes, getDb, members, MemberStatus } from '@blog-study/shared/db';
-import { sendPollReminderDM } from '../handlers/dm-handler';
+import { sendPollReminderPush } from '../handlers/dm-handler';
import logger from '../lib/logger';
export interface PollReminderResult {
@@ -153,7 +153,7 @@ export class PollReminder {
// 특정 멤버 지정 시 해당 멤버에게만 발송
if (targetDiscordId) {
const [target] = await db
- .select({ discordId: members.discordId, name: members.name })
+ .select({ id: members.id, discordId: members.discordId, name: members.name })
.from(members)
.where(eq(members.discordId, targetDiscordId))
.limit(1);
@@ -166,7 +166,7 @@ export class PollReminder {
logger.info(`📊 [투표 리마인더] "${question}" — ${target.name}에게 개별 발송`);
try {
- const success = await sendPollReminderDM(this.client!, target.discordId, question, expiresAt, postId);
+ const success = await sendPollReminderPush(target.id, question, expiresAt, postId);
return success ? { sent: 1, failed: 0 } : { sent: 0, failed: 1 };
} catch {
return { sent: 0, failed: 1 };
@@ -186,7 +186,7 @@ export class PollReminder {
// eligible 멤버 중 미투표자 조회 (단일 쿼리)
const statusFilter = inArray(members.status, [MemberStatus.ACTIVE, MemberStatus.OB, MemberStatus.DORMANT]);
const nonVoterMembers = await db
- .select({ discordId: members.discordId, name: members.name })
+ .select({ id: members.id, name: members.name })
.from(members)
.where(
voterMemberIds.length > 0
@@ -198,9 +198,8 @@ export class PollReminder {
for (const member of nonVoterMembers) {
try {
- const success = await sendPollReminderDM(
- this.client!,
- member.discordId,
+ const success = await sendPollReminderPush(
+ member.id,
question,
expiresAt,
postId,
diff --git a/packages/shared/src/db/schema.ts b/packages/shared/src/db/schema.ts
index 0c564f6..d7219de 100644
--- a/packages/shared/src/db/schema.ts
+++ b/packages/shared/src/db/schema.ts
@@ -583,6 +583,11 @@ export const NotificationType = {
POST_REPLY: 'post_reply',
BOARD_NOTICE: 'board_notice',
NEW_POST: 'new_post',
+ FINE_NOTIFICATION: 'fine_notification',
+ FINE_REMINDER: 'fine_reminder',
+ DEADLINE_REMINDER: 'deadline_reminder',
+ GRACE_NUDGE: 'grace_nudge',
+ POLL_REMINDER: 'poll_reminder',
} as const;
export type NotificationTypeType = (typeof NotificationType)[keyof typeof NotificationType];
diff --git a/packages/web/src/app/(admin)/admin/bot-operations/notification-logs.tsx b/packages/web/src/app/(admin)/admin/bot-operations/notification-logs.tsx
index 896dc67..b786813 100644
--- a/packages/web/src/app/(admin)/admin/bot-operations/notification-logs.tsx
+++ b/packages/web/src/app/(admin)/admin/bot-operations/notification-logs.tsx
@@ -171,6 +171,7 @@ export default function NotificationLogs() {
전체
채널
DM
+ 푸시
@@ -200,7 +201,7 @@ export default function NotificationLogs() {
{logs.map((log) => {
const meta = getLogTypeMeta(log.type);
const isSent = log.status === 'sent';
- const isDM = log.targetDiscordId != null;
+ const target = meta.target;
return (
@@ -230,13 +231,18 @@ export default function NotificationLogs() {
{log.source === 'bot' ? '봇' : '웹'}
- {isDM
- ? `DM → ${log.targetDiscordId}`
- : log.channelName
- ? `#${log.channelName}`
- : log.channelId
- ? `#${log.channelId}`
- : '—'}
+ {(() => {
+ if (target === 'push') {
+ const recipients = (log.metadata?.recipients as string[] | undefined) ?? [];
+ const count = (log.metadata?.memberCount as number | undefined) ?? recipients.length;
+ if (recipients.length > 0) {
+ return `푸시 → ${recipients.join(', ')}`;
+ }
+ return count > 0 ? `푸시 → ${count}명` : '푸시';
+ }
+ if (target === 'dm') return `DM → ${log.targetDiscordId}`;
+ return `#${log.channelName || log.channelId || '—'}`;
+ })()}
{formatRelativeTime(log.createdAt)}
diff --git a/packages/web/src/app/(user)/profile/fines/page.tsx b/packages/web/src/app/(user)/profile/fines/page.tsx
new file mode 100644
index 0000000..9164132
--- /dev/null
+++ b/packages/web/src/app/(user)/profile/fines/page.tsx
@@ -0,0 +1,309 @@
+'use client';
+
+import { useCallback, useEffect, useState } from 'react';
+import Link from 'next/link';
+import { ArrowLeft, Copy, Loader2, Wallet } from 'lucide-react';
+import { toast } from 'sonner';
+import { Card, CardContent } from '@/components/ui/card';
+import { Button } from '@/components/ui/button';
+import { Badge } from '@/components/ui/badge';
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ AlertDialogTrigger,
+} from '@/components/ui/alert-dialog';
+import { PageError } from '@/components/ui/page-state';
+
+interface Fine {
+ id: string;
+ roundNumber: number;
+ type: string;
+ amount: number;
+ status: 'PENDING' | 'PAID' | 'WAIVED';
+ createdAt: string;
+ paidAt: string | null;
+}
+
+interface Summary {
+ unpaid: number;
+ paid: number;
+ total: number;
+}
+
+interface FinesData {
+ fines: Fine[];
+ summary: Summary;
+}
+
+export default function FinesPage() {
+ const [data, setData] = useState
(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [payingId, setPayingId] = useState(null);
+
+ const fetchFines = useCallback(async () => {
+ try {
+ const response = await fetch('/api/profile/fines');
+ if (!response.ok) {
+ throw new Error('Failed to fetch fines');
+ }
+ const result = await response.json();
+ setData(result.data);
+ } catch (err) {
+ setError('벌금 내역을 불러오는데 실패했습니다.');
+ console.error(err);
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ fetchFines();
+ }, [fetchFines]);
+
+ const handlePay = async (fineId: string) => {
+ setPayingId(fineId);
+ try {
+ const res = await fetch(`/api/fines/${fineId}/pay`, { method: 'PATCH' });
+ const result = await res.json();
+
+ if (!res.ok) {
+ toast.error(result.message || '납부 처리에 실패했습니다.');
+ setPayingId(null);
+ return;
+ }
+
+ toast.success('납부가 확인되었습니다.');
+ setPayingId(null);
+ await fetchFines();
+ } catch {
+ toast.error('서버 오류가 발생했습니다. 다시 시도해주세요.');
+ setPayingId(null);
+ }
+ };
+
+ const getTypeLabel = (type: string) => (type === 'late' ? '지각' : '결석');
+
+ const unpaidFines = data?.fines.filter((f) => f.status === 'PENDING') ?? [];
+ const completedFines = data?.fines.filter((f) => f.status !== 'PENDING') ?? [];
+
+ return (
+
+ {/* Page Header */}
+
+
+ Profile / Fines
+
+
+
+
+ {loading ? (
+
+
+
+ ) : error ? (
+
+ ) : (<>
+
+ {/* Summary Cards */}
+ {data && (
+
+
+
+ 미납
+
+ {data.summary.unpaid.toLocaleString()}원
+
+
+
+
+
+ 납부완료
+
+ {data.summary.paid.toLocaleString()}원
+
+
+
+
+
+ 총 벌금
+
+ {data.summary.total.toLocaleString()}원
+
+
+
+
+ )}
+
+ {/* Empty State */}
+ {data && data.fines.length === 0 && (
+
+
+
+
+
+
벌금 내역이 없습니다
+
지각이나 결석 없이 잘 하고 계세요!
+
+
+ )}
+
+ {/* Unpaid Fines */}
+ {unpaidFines.length > 0 && (
+
+
미납 벌금
+
+ {unpaidFines.map((fine) => (
+
+
+
+
+
+ {fine.roundNumber}회차 · {getTypeLabel(fine.type)}
+
+
+ {new Date(fine.createdAt).toLocaleDateString('ko-KR')}
+
+
+
+
+ {fine.amount.toLocaleString()}원
+
+
+
+
+
+
+
+
+ 입금을 완료하셨나요?
+
+
+
+ {fine.roundNumber}회차 {getTypeLabel(fine.type)} 벌금{' '}
+ {fine.amount.toLocaleString()}원의 납부를 확인합니다.
+
+
+ 카카오뱅크 3333333114501
+
+
+
+
+ 취소
+ handlePay(fine.id)}
+ className="h-9 text-sm"
+ >
+ 확인
+
+
+
+
+
+
+
+
+ ))}
+
+
+ )}
+
+ {/* Completed Fines */}
+ {completedFines.length > 0 && (
+
+
처리 완료
+
+ {completedFines.map((fine) => (
+
+
+
+
+
+ {fine.roundNumber}회차 · {getTypeLabel(fine.type)}
+
+
+ {new Date(fine.createdAt).toLocaleDateString('ko-KR')}
+
+
+
+
+ {fine.amount.toLocaleString()}원
+
+ {fine.status === 'PAID' ? (
+
납부완료
+ ) : (
+
+ 면제
+
+ )}
+
+
+
+
+ ))}
+
+
+ )}
+
+ {/* Account Info */}
+ {unpaidFines.length > 0 && (
+
+
+
+
+
+
+
+
입금 계좌
+
+
+ 카카오뱅크 3333333114501
+
+
+
+
+
+
+
+ )}
+
+ >)}
+
+ );
+}
diff --git a/packages/web/src/app/(user)/profile/page.tsx b/packages/web/src/app/(user)/profile/page.tsx
index 0602638..8db0d42 100644
--- a/packages/web/src/app/(user)/profile/page.tsx
+++ b/packages/web/src/app/(user)/profile/page.tsx
@@ -434,24 +434,26 @@ export default function ProfilePage() {
-
-
-
-
-
미납 벌금
-
- {data.stats.unpaidFines.toLocaleString()}원
-
-
- 총 {data.stats.totalFines.toLocaleString()}원
-
-
-
-
+
+
+
+
+
+
미납 벌금
+
+ {data.stats.unpaidFines.toLocaleString()}원
+
+
+ 총 {data.stats.totalFines.toLocaleString()}원
+
+
+
+
+
-
-
-
+
+
+
)}
diff --git a/packages/web/src/app/api/admin/bot-logs/route.ts b/packages/web/src/app/api/admin/bot-logs/route.ts
index 17b7051..116b0fa 100644
--- a/packages/web/src/app/api/admin/bot-logs/route.ts
+++ b/packages/web/src/app/api/admin/bot-logs/route.ts
@@ -1,9 +1,10 @@
import { NextRequest } from 'next/server';
-import { desc, eq, and, lt, isNull, isNotNull, type SQL } from 'drizzle-orm';
+import { desc, eq, and, lt, isNull, isNotNull, inArray, type SQL } from 'drizzle-orm';
import { db as sharedDb } from '@blog-study/shared';
import { db } from '@/lib/db';
import { withAdminAuth } from '@/lib/admin';
import { successResponse, Errors } from '@/lib/api-error';
+import { notificationLogTypeConfig } from '@/lib/notification-log-config';
const { discordNotificationLogs } = sharedDb;
@@ -17,7 +18,7 @@ export const GET = withAdminAuth(async (request: NextRequest) => {
const type = searchParams.get('type');
const source = searchParams.get('source');
const status = searchParams.get('status');
- const target = searchParams.get('target'); // 'channel' | 'dm'
+ const target = searchParams.get('target'); // 'channel' | 'dm' | 'push'
const cursor = searchParams.get('cursor'); // ISO timestamp
const limit = Math.min(Number(searchParams.get('limit') || 20), 50);
@@ -36,6 +37,11 @@ export const GET = withAdminAuth(async (request: NextRequest) => {
conditions.push(isNull(discordNotificationLogs.targetDiscordId));
} else if (target === 'dm') {
conditions.push(isNotNull(discordNotificationLogs.targetDiscordId));
+ } else if (target === 'push') {
+ const pushTypes = Object.entries(notificationLogTypeConfig)
+ .filter(([, meta]) => meta.target === 'push')
+ .map(([type]) => type);
+ conditions.push(inArray(discordNotificationLogs.type, pushTypes));
}
const logs = await database
diff --git a/packages/web/src/app/api/fines/[id]/pay/route.ts b/packages/web/src/app/api/fines/[id]/pay/route.ts
new file mode 100644
index 0000000..5b3dbf2
--- /dev/null
+++ b/packages/web/src/app/api/fines/[id]/pay/route.ts
@@ -0,0 +1,108 @@
+import { NextRequest, after } from 'next/server';
+import { and, eq } from 'drizzle-orm';
+import { db as sharedDb } from '@blog-study/shared';
+import { config } from '@blog-study/shared/db';
+import { createClient } from '@/lib/supabase/server';
+import { getDb } from '@/lib/db';
+import { errorResponse, Errors, successResponse } from '@/lib/api-error';
+import { escapeDiscordMarkdown, sendDiscordChannelMessage } from '@/lib/discord-notify';
+import { logNotification } from '@/lib/notification-log';
+
+const { members, fines, rounds, FineStatus } = sharedDb;
+
+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 async function PATCH(
+ _request: NextRequest,
+ { params }: { params: Promise<{ id: string }> }
+) {
+ try {
+ const { id: fineId } = await params;
+ if (!UUID_RE.test(fineId)) {
+ return Errors.badRequest('Invalid fine ID format').toResponse();
+ }
+ const supabase = await createClient();
+ const {
+ data: { user },
+ error,
+ } = await supabase.auth.getUser();
+ if (error || !user) return Errors.unauthorized().toResponse();
+
+ const discordId = user.identities?.find((i) => i.provider === 'discord')?.id;
+ if (!discordId) return Errors.unauthorized().toResponse();
+
+ const database = getDb();
+
+ const [member] = await database
+ .select({ id: members.id, name: members.name, nickname: members.nickname })
+ .from(members)
+ .where(eq(members.discordId, discordId))
+ .limit(1);
+ if (!member) return Errors.notFound('멤버를 찾을 수 없습니다.').toResponse();
+
+ const [fine] = await database.select().from(fines).where(eq(fines.id, fineId)).limit(1);
+ if (!fine) return Errors.notFound('벌금을 찾을 수 없습니다.').toResponse();
+ if (fine.memberId !== member.id)
+ return Errors.forbidden('본인의 벌금만 처리할 수 있습니다.').toResponse();
+ if (fine.status !== FineStatus.UNPAID)
+ return Errors.badRequest('이미 처리된 벌금입니다.').toResponse();
+
+ const [updated] = await database
+ .update(fines)
+ .set({
+ status: FineStatus.PAID,
+ paidAt: new Date(),
+ pendingConfirmation: false,
+ })
+ .where(and(eq(fines.id, fineId), eq(fines.status, FineStatus.UNPAID)))
+ .returning();
+
+ if (!updated) {
+ return Errors.badRequest('이미 처리된 벌금입니다.').toResponse();
+ }
+
+ after(async () => {
+ try {
+ const [round] = await database
+ .select({ roundNumber: rounds.roundNumber })
+ .from(rounds)
+ .where(eq(rounds.id, fine.roundId))
+ .limit(1);
+
+ const displayName = member.name || member.nickname;
+ const safeName = escapeDiscordMarkdown(displayName);
+ const reason = fine.type === 'late' ? '지각' : '결석';
+ const roundText = round ? `${round.roundNumber}회차` : '';
+
+ const [channelRow] = await database
+ .select({ value: config.value })
+ .from(config)
+ .where(eq(config.key, 'admin_notification_channel_id'))
+ .limit(1);
+
+ const adminChannelId = channelRow?.value;
+ if (adminChannelId) {
+ const discordResult = await sendDiscordChannelMessage({
+ channelId: adminChannelId,
+ content: `💰 **${safeName}**님이 ${roundText} ${reason} 벌금 ${fine.amount.toLocaleString()}원 납부를 완료했습니다. (웹)`,
+ });
+ await logNotification({
+ source: 'web',
+ type: 'fine_payment',
+ channelId: adminChannelId,
+ summary: `${displayName}님 ${roundText} ${reason} 벌금 납부 확인 (웹)`,
+ messageId: discordResult.messageId,
+ status: discordResult.success ? 'sent' : 'failed',
+ errorMessage: discordResult.error,
+ });
+ }
+ } catch (err) {
+ console.error('[fines/pay] Admin notification failed:', err);
+ }
+ });
+
+ return successResponse({ fine: updated }, '납부가 확인되었습니다.');
+ } catch (error) {
+ return errorResponse(error);
+ }
+}
diff --git a/packages/web/src/app/api/internal/reminder-push/route.ts b/packages/web/src/app/api/internal/reminder-push/route.ts
new file mode 100644
index 0000000..82e62c5
--- /dev/null
+++ b/packages/web/src/app/api/internal/reminder-push/route.ts
@@ -0,0 +1,128 @@
+import { timingSafeEqual } from 'crypto';
+import { NextRequest } from 'next/server';
+import { inArray } from 'drizzle-orm';
+import { db as sharedDb } from '@blog-study/shared';
+import { getDb } from '@/lib/db';
+import { sendPushToMembers } from '@/lib/push';
+import { logNotification } from '@/lib/notification-log';
+import { errorResponse, Errors, successResponse } from '@/lib/api-error';
+
+const { members } = sharedDb;
+
+const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
+
+const ALLOWED_TYPES = new Set([
+ 'fine_notification',
+ 'fine_reminder',
+ 'deadline_reminder',
+ 'grace_nudge',
+ 'poll_reminder',
+]);
+
+// Rate limit: 30 requests/minute
+const rateLimitMap = new Map();
+const RATE_LIMIT_WINDOW = 60_000;
+const RATE_LIMIT_MAX = 30;
+
+function checkRateLimit(): boolean {
+ const key = 'reminder-push';
+ const now = Date.now();
+ const timestamps = rateLimitMap.get(key) ?? [];
+ const recent = timestamps.filter((t) => now - t < RATE_LIMIT_WINDOW);
+ if (recent.length >= RATE_LIMIT_MAX) return false;
+ recent.push(now);
+ rateLimitMap.set(key, recent);
+ return true;
+}
+
+function safeCompare(a: string, b: string): boolean {
+ if (a.length !== b.length) return false;
+ return timingSafeEqual(Buffer.from(a), Buffer.from(b));
+}
+
+export async function POST(request: NextRequest) {
+ try {
+ // Rate limit
+ if (!checkRateLimit()) {
+ return Errors.badRequest('Rate limit exceeded').toResponse();
+ }
+
+ // 내부 API 인증 (timing-safe comparison)
+ const authHeader = request.headers.get('authorization');
+ const expectedKey = process.env.INTERNAL_API_KEY;
+ const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : '';
+ if (!expectedKey || !token || !safeCompare(token, expectedKey)) {
+ return Errors.unauthorized('Invalid API key').toResponse();
+ }
+
+ const body = await request.json();
+ const { type, memberIds, title, body: pushBody, clickUrl } = body;
+
+ // 타입 검증
+ if (
+ typeof type !== 'string' ||
+ typeof title !== 'string' ||
+ typeof pushBody !== 'string' ||
+ typeof clickUrl !== 'string'
+ ) {
+ return Errors.badRequest('type, memberIds, title, body, clickUrl are required').toResponse();
+ }
+ if (!Array.isArray(memberIds) || memberIds.length === 0) {
+ return Errors.badRequest('memberIds must be a non-empty array').toResponse();
+ }
+ if (!memberIds.every((id: unknown) => typeof id === 'string' && UUID_RE.test(id))) {
+ return Errors.badRequest('Invalid memberIds format').toResponse();
+ }
+ if (memberIds.length > 200) {
+ return Errors.badRequest('memberIds too large (max 200)').toResponse();
+ }
+ if (!ALLOWED_TYPES.has(type)) {
+ return Errors.badRequest('Invalid notification type').toResponse();
+ }
+ if (type.length > 50) {
+ return Errors.badRequest('type too long').toResponse();
+ }
+ if (title.length > 200) return Errors.badRequest('title too long (max 200)').toResponse();
+ if (pushBody.length > 1000) return Errors.badRequest('body too long (max 1000)').toResponse();
+ if (!clickUrl.startsWith('/') || clickUrl.startsWith('//')) {
+ return Errors.badRequest('clickUrl must be a relative path starting with /').toResponse();
+ }
+
+ const result = await sendPushToMembers(memberIds, {
+ title,
+ body: pushBody,
+ clickUrl,
+ data: { type },
+ });
+
+ // 수신자 닉네임 조회 (로그 표시용)
+ const database = getDb();
+ const recipients = await database
+ .select({ id: members.id, nickname: members.nickname })
+ .from(members)
+ .where(inArray(members.id, memberIds));
+ const recipientNicknames = recipients.map((r) => r.nickname);
+
+ // 알림 로그 기록
+ await logNotification({
+ source: 'web',
+ type,
+ summary: `[푸시] ${title}: ${pushBody}`.slice(0, 500),
+ metadata: {
+ memberCount: memberIds.length,
+ recipients: recipientNicknames,
+ ...result,
+ },
+ status: result.success > 0 ? 'sent' : 'failed',
+ errorMessage:
+ result.success === 0 && result.failed > 0
+ ? `${result.failed}건 전송 실패`
+ : undefined,
+ });
+
+ return successResponse(result);
+ } catch (error) {
+ console.error('[internal/reminder-push] Error:', error);
+ return errorResponse(error);
+ }
+}
diff --git a/packages/web/src/app/api/notification-preferences/route.ts b/packages/web/src/app/api/notification-preferences/route.ts
index ce2d86a..73e24b8 100644
--- a/packages/web/src/app/api/notification-preferences/route.ts
+++ b/packages/web/src/app/api/notification-preferences/route.ts
@@ -4,6 +4,7 @@ import { getDb } from '@/lib/db';
import { db as sharedDb } from '@blog-study/shared';
import { getBoardAuth } from '@/lib/board-auth';
import { errorResponse, Errors, successResponse } from '@/lib/api-error';
+import { FORCE_SEND_TYPES } from '@/lib/push';
const { notificationPreferences, NotificationType } = sharedDb;
@@ -18,10 +19,8 @@ export async function GET(_request: NextRequest) {
const database = getDb();
- // 모든 알림 타입에 대한 설정 조회 (board_notice는 강제 전송이므로 제외)
- const allTypes = Object.values(NotificationType).filter(
- (t) => t !== NotificationType.BOARD_NOTICE
- );
+ // 모든 알림 타입에 대한 설정 조회 (강제 전송 타입은 제외 - 끌 수 없음)
+ const allTypes = Object.values(NotificationType).filter((t) => !FORCE_SEND_TYPES.has(t));
const existing = await database
.select()
.from(notificationPreferences)
diff --git a/packages/web/src/app/api/profile/fines/route.ts b/packages/web/src/app/api/profile/fines/route.ts
new file mode 100644
index 0000000..359307d
--- /dev/null
+++ b/packages/web/src/app/api/profile/fines/route.ts
@@ -0,0 +1,62 @@
+import { eq, sql } from 'drizzle-orm';
+import { db as sharedDb } from '@blog-study/shared';
+import { createClient } from '@/lib/supabase/server';
+import { getDb } from '@/lib/db';
+import { errorResponse, Errors, successResponse, withCache } from '@/lib/api-error';
+
+const { members, fines, rounds, FineStatus } = sharedDb;
+
+export async function GET() {
+ try {
+ const supabase = await createClient();
+ const {
+ data: { user },
+ error,
+ } = await supabase.auth.getUser();
+ if (error || !user) return Errors.unauthorized().toResponse();
+
+ const discordId = user.identities?.find((i) => i.provider === 'discord')?.id;
+ if (!discordId) return Errors.unauthorized().toResponse();
+
+ const database = getDb();
+ const [member] = await database
+ .select({ id: members.id })
+ .from(members)
+ .where(eq(members.discordId, discordId))
+ .limit(1);
+
+ if (!member) return Errors.notFound('멤버를 찾을 수 없습니다.').toResponse();
+
+ const fineList = await database
+ .select({
+ id: fines.id,
+ roundNumber: rounds.roundNumber,
+ type: fines.type,
+ amount: fines.amount,
+ status: fines.status,
+ createdAt: fines.createdAt,
+ paidAt: fines.paidAt,
+ })
+ .from(fines)
+ .innerJoin(rounds, eq(fines.roundId, rounds.id))
+ .where(eq(fines.memberId, member.id))
+ .orderBy(
+ sql`CASE WHEN ${fines.status} = ${FineStatus.UNPAID} THEN 0 ELSE 1 END`,
+ sql`${fines.createdAt} DESC`
+ );
+
+ const summary = {
+ unpaid: fineList
+ .filter((f) => f.status === FineStatus.UNPAID)
+ .reduce((sum, f) => sum + f.amount, 0),
+ paid: fineList
+ .filter((f) => f.status === FineStatus.PAID)
+ .reduce((sum, f) => sum + f.amount, 0),
+ total: fineList.reduce((sum, f) => sum + f.amount, 0),
+ };
+
+ return withCache(successResponse({ fines: fineList, summary }), 30);
+ } catch (error) {
+ return errorResponse(error);
+ }
+}
diff --git a/packages/web/src/components/settings/push-notification-settings.tsx b/packages/web/src/components/settings/push-notification-settings.tsx
index 4214f17..3db5dcb 100644
--- a/packages/web/src/components/settings/push-notification-settings.tsx
+++ b/packages/web/src/components/settings/push-notification-settings.tsx
@@ -1,7 +1,17 @@
'use client';
import { useEffect, useState } from 'react';
-import { Bell, FileText, MessageCircle, MessageSquare, SendHorizonal } from 'lucide-react';
+import {
+ AlertTriangle,
+ Bell,
+ Clock,
+ FileText,
+ MessageCircle,
+ MessageSquare,
+ SendHorizonal,
+ Vote,
+ Wallet,
+} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
import { toast } from 'sonner';
@@ -39,6 +49,31 @@ const NOTIFICATION_LABELS: Record<
description: '내 댓글에 답글이 달릴 때',
},
new_post: { label: '새 글 알림', icon: FileText, description: '스터디원이 새 글을 등록할 때' },
+ fine_notification: {
+ label: '벌금 알림',
+ icon: Wallet,
+ description: '벌금이 부과될 때',
+ },
+ fine_reminder: {
+ label: '벌금 리마인더',
+ icon: Wallet,
+ description: '미납 벌금 독촉',
+ },
+ deadline_reminder: {
+ label: '마감 리마인더',
+ icon: Clock,
+ description: '제출 마감 D-2/D-1/D-day',
+ },
+ grace_nudge: {
+ label: '지각 독촉',
+ icon: AlertTriangle,
+ description: '지각 기간 제출 독려',
+ },
+ poll_reminder: {
+ label: '투표 리마인더',
+ icon: Vote,
+ description: '투표 마감 전 참여 요청',
+ },
};
export function PushNotificationSettings() {
diff --git a/packages/web/src/lib/notification-log-config.ts b/packages/web/src/lib/notification-log-config.ts
index 7530986..fad9bf8 100644
--- a/packages/web/src/lib/notification-log-config.ts
+++ b/packages/web/src/lib/notification-log-config.ts
@@ -25,96 +25,96 @@ export type NotificationLogTypeValue = (typeof NotificationLogType)[keyof typeof
export interface NotificationLogTypeMeta {
label: string;
color: string;
- isDM: boolean;
+ target: 'channel' | 'dm' | 'push';
}
export const notificationLogTypeConfig: Record = {
round_report: {
label: '회차 리포트',
color: 'bg-indigo-100 text-indigo-800 dark:bg-indigo-900/30 dark:text-indigo-400',
- isDM: false,
+ target: 'channel',
},
round_start: {
label: '회차 시작',
color: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400',
- isDM: false,
+ target: 'channel',
},
weekly_ranking: {
label: '주간 랭킹',
color: 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400',
- isDM: false,
+ target: 'channel',
},
curation: {
label: '큐레이션',
color: 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400',
- isDM: false,
+ target: 'channel',
},
new_post: {
label: '새 글 알림',
color: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400',
- isDM: false,
+ target: 'channel',
},
popular_posts: {
label: '인기 포스트',
color: 'bg-pink-100 text-pink-800 dark:bg-pink-900/30 dark:text-pink-400',
- isDM: false,
+ target: 'channel',
},
fine_payment: {
label: '벌금 확인',
color: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400',
- isDM: false,
+ target: 'channel',
},
deadline_reminder: {
label: '마감 리마인더',
color: 'bg-rose-100 text-rose-800 dark:bg-rose-900/30 dark:text-rose-400',
- isDM: true,
+ target: 'push',
},
fine_notification: {
label: '벌금 알림',
color: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400',
- isDM: true,
+ target: 'push',
},
fine_reminder: {
label: '벌금 독촉',
color: 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-400',
- isDM: true,
+ target: 'push',
},
grace_nudge: {
label: '지각 독촉',
color: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400',
- isDM: true,
+ target: 'push',
},
poll_reminder: {
label: '투표 리마인더',
color: 'bg-cyan-100 text-cyan-800 dark:bg-cyan-900/30 dark:text-cyan-400',
- isDM: true,
+ target: 'push',
},
board_notice: {
label: '게시판 공지',
color: 'bg-sky-100 text-sky-800 dark:bg-sky-900/30 dark:text-sky-400',
- isDM: false,
+ target: 'channel',
},
post_register: {
label: '수동 등록',
color: 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-400',
- isDM: false,
+ target: 'channel',
},
member_approval: {
label: '가입 승인',
color: 'bg-teal-100 text-teal-800 dark:bg-teal-900/30 dark:text-teal-400',
- isDM: false,
+ target: 'channel',
},
announcement: {
label: '공지 알림',
color: 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-400',
- isDM: false,
+ target: 'channel',
},
};
const fallbackMeta: NotificationLogTypeMeta = {
label: '알 수 없음',
color: 'bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-400',
- isDM: false,
+ target: 'channel',
};
export function getLogTypeMeta(type: string): NotificationLogTypeMeta {
diff --git a/packages/web/src/lib/push.ts b/packages/web/src/lib/push.ts
index 144cf60..96c257e 100644
--- a/packages/web/src/lib/push.ts
+++ b/packages/web/src/lib/push.ts
@@ -7,7 +7,14 @@ import type { MulticastMessage } from 'firebase-admin/messaging';
const { fcmTokens, notificationPreferences } = sharedDb;
/** preference 무시하고 항상 전송하는 알림 타입 */
-const FORCE_SEND_TYPES = new Set(['board_notice']);
+export const FORCE_SEND_TYPES = new Set([
+ 'board_notice',
+ 'fine_notification',
+ 'fine_reminder',
+ 'deadline_reminder',
+ 'grace_nudge',
+ 'poll_reminder',
+]);
export interface PushPayload {
title: string;