diff --git a/.gitignore b/.gitignore index 58a7acf..f8d0997 100644 --- a/.gitignore +++ b/.gitignore @@ -44,3 +44,6 @@ firebase-service-account.json packages/shared/drizzle/*.sql .vercel scripts + +# Superpowers brainstorming artifacts (local only) +.superpowers diff --git a/CLAUDE.md b/CLAUDE.md index 2d69f1d..4620425 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -69,9 +69,10 @@ pnpm --filter @blog-study/bot rss-collect # 수동 RSS 수집 (봇 없이) - **새 글 푸시 알림**: 수동 등록 + RSS 수집 모두 지원. 대상: active/OB/dormant (작성자 본인 제외), 알림 타입 `new_post`. 봇→웹 내부 API(`/api/internal/new-post-push`, Bearer 인증) 경유 - **포스트 수정**: 본인 또는 관리자만 제목/설명 수정 가능 (`PATCH /api/posts/[id]`) - **공지 알림**: 게시판 공지 작성 시 FCM 푸시 + Discord 공지채널(`notice_channel_id`) `@everyone` + embed(제목+본문 미리보기 500자) + 웹 딥링크 버튼 -- **벌금 DM**: 계좌 정보 포함 (3333333114501 카카오뱅크), 납부완료 시 관리자 채널 알림 +- **벌금 납부**: 웹 `/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`로 유저가 끌 수 없음 - **D-Day 계산**: KST 캘린더 날짜 기준 (midnight 비교, 당일=D-Day=0), 제출률은 active 유저만 카운트 -- **Discord 알림 로그**: `discord_notification_logs` 테이블에 봇/웹 모든 채널+DM 알림 성공/실패 기록, `logNotification()` 헬퍼 (봇: `notification-logger.ts`, 웹: `notification-log.ts`), 관리자 페이지 "알림 로그" 탭에서 조회 (타입/소스/대상/상태 필터 + 무한 스크롤) +- **알림 로그**: `discord_notification_logs` 테이블에 봇/웹 모든 채널+DM+푸시 알림 성공/실패 기록 (target: `channel`/`dm`/`push`), `logNotification()` 헬퍼 (봇: `notification-logger.ts`, 웹: `notification-log.ts`), 관리자 페이지 "알림 로그" 탭에서 조회 (타입/소스/대상/상태 필터 + 무한 스크롤, 푸시 로그에 수신자 닉네임 표시) - **비밀답글 가시성**: 비밀 답글은 작성자/포스트작성자/부모댓글작성자/관리자가 열람 가능 - **랭킹**: active + OB + dormant 전원 표시, 웹 페이지 4위부터 (포디움과 분리), 주간랭킹 전원 나열 - **RSS 수집**: active + OB (rssConsent=true만), 포스트 점수는 active만 부여 @@ -145,6 +146,11 @@ pnpm --filter @blog-study/bot rss-collect # 수동 RSS 수집 (봇 없이) | `packages/web/src/app/api/push/test/route.ts` | 테스트 푸시 알림 API (레이트 리밋 5/min) | | `packages/web/src/app/api/notification-preferences/route.ts` | 알림 타입별 설정 CRUD API | | `packages/web/src/app/api/internal/new-post-push/route.ts` | 새 글 푸시 알림 내부 API (봇→웹, Bearer 인증, rate limit 20/min) | +| `packages/web/src/app/api/internal/reminder-push/route.ts` | 범용 리마인더 푸시 내부 API (봇→웹, 5종 FORCE_SEND_TYPES) | +| `packages/bot/src/lib/push-client.ts` | 봇→웹 내부 API 호출 래퍼 (reminder-push 등) | +| `packages/web/src/app/(user)/profile/fines/page.tsx` | 벌금 상세 페이지 (내 벌금 내역 + 납부 완료) | +| `packages/web/src/app/api/profile/fines/route.ts` | 내 벌금 목록 API | +| `packages/web/src/app/api/fines/[id]/pay/route.ts` | 벌금 납부 완료 API (atomic update) | | `packages/web/src/app/api/firebase-sw/route.ts` | FCM 서비스 워커 동적 서빙 (rewrite: `/firebase-messaging-sw.js` → `/api/firebase-sw`) | | `packages/bot/src/scripts/rss-collect.ts` | 수동 RSS 수집 스크립트 (봇 없이 독립 실행) | | `packages/bot/src/scripts/setup-channels.ts` | 디스코드 채널 일괄 생성 스크립트 | @@ -215,6 +221,7 @@ pnpm --filter @blog-study/bot rss-collect # 수동 RSS 수집 (봇 없이) - **토스트**: sonner (`` in root layout, `position="bottom-center"`, `richColors`) - **에러 바운더리**: `(user)/error.tsx`, `(admin)/error.tsx` — Sentry 전송 + 리셋 버튼, `global-error.tsx` — 전역 폴백 (다크모드 인라인 스타일) - **404 페이지**: `not-found.tsx` — 대시보드 링크 포함 +- **벌금 상세**: `/profile/fines` — 내 벌금 내역 카드 + "납부 완료" 버튼, 프로필 미납 스탯 카드 클릭 시 이동, 푸시 딥링크 대상 - **CSP**: `next.config.ts`에 Content-Security-Policy 헤더 설정 - **상세 스펙**: `docs/26-03-06-ui-design-system.md` 참조 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 77a26be..948f079 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,6 +1,6 @@ # Blog Study Admin - 시스템 아키텍처 -> 최종 업데이트: 2026-03-23 (v12) +> 최종 업데이트: 2026-04-15 (v13) 블로그 글쓰기 스터디 운영 자동화 플랫폼. 웹 대시보드에서 모든 관리/유저 기능을 제공하고, Discord 봇은 스케줄러(RSS 수집/출석/벌금/큐레이션)와 이벤트 핸들러만 담당한다. @@ -71,6 +71,7 @@ graph TB API -->|POST /api/trigger/*| BOT_API BOT_API --> SCH BOT_API -->|POST /api/internal/new-post-push| API + BOT_API -->|POST /api/internal/reminder-push| API API -->|Discord REST API| CH_ADMIN Web -->|HTTPS| DB @@ -252,6 +253,7 @@ flowchart TD | User | `/members` | 멤버 목록 | 로그인 필수 | | User | `/members/[id]` | 멤버 상세 | 로그인 필수 | | User | `/profile` | 프로필 | 로그인 필수 | +| User | `/profile/fines` | 벌금 상세 (내 벌금 내역 + 납부 완료) | 로그인 필수 | | User | `/profile/notifications` | 알림 설정 (푸시 토글 + 타입별 설정 + 테스트) | 로그인 필수 | | Admin | `/admin` | 관리자 대시보드 | 관리자 전용 | | Admin | `/admin/members` | 멤버 관리 | 관리자 전용 | @@ -461,7 +463,7 @@ erDiagram |------|------|------| | RSS Poller | 5분 | active 멤버 RSS 피드 수집 | | Attendance Checker | 매주 화 00:00 | 지각/결석 판정 | -| Fine Reminder | 매일 10:00 | 미납 벌금 DM 리마인드 (1일 간격) | +| Fine Reminder | 매일 10:00 | 미납 벌금 FCM 푸시 리마인드 (봇→웹 내부 API, 1일 간격) | | Curation Crawler | 매일 09:00 | 외부 컨텐츠 크롤링 | | Daily Content | 매일 10:00 | 큐레이션 컨텐츠 공유 | | Round Reporter | 회차 종료 시 | 회차 리포트 자동 생성 → #공지사항 | @@ -481,7 +483,7 @@ erDiagram | **SQL Injection** | Drizzle ORM 파라미터화 쿼리 (raw SQL 사용 안 함) | 전체 API Routes | | **CSRF** | Supabase Auth 쿠키 `SameSite=Lax` | Supabase 기본 설정 | | **입력 검증** | description 새니타이즈 (제어 문자/제로 너비 유니코드 제거, 300자 제한) | `lib/sanitize.ts` | -| **내부 API 인증** | Bearer 토큰 (`INTERNAL_API_KEY`, timing-safe 비교) + rate limit 20/min + UUID/길이 검증 | `api/internal/new-post-push/` | +| **내부 API 인증** | Bearer 토큰 (`INTERNAL_API_KEY`, timing-safe 비교) + rate limit 20/min + UUID/길이 검증 | `api/internal/new-post-push/`, `api/internal/reminder-push/` | ### 에러 처리 diff --git a/docs/superpowers/plans/26-04-15-dm-to-push-and-fines-page.md b/docs/superpowers/plans/26-04-15-dm-to-push-and-fines-page.md new file mode 100644 index 0000000..fdcb39e --- /dev/null +++ b/docs/superpowers/plans/26-04-15-dm-to-push-and-fines-page.md @@ -0,0 +1,1172 @@ +# Discord DM → FCM 푸시 전환 + 벌금 상세 페이지 구현 플랜 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Discord DM 개인 알림을 FCM 푸시로 전환하고, 벌금 상세/납부 페이지를 웹에 구축한다. + +**Architecture:** 봇 스케줄러가 DM 대신 웹 내부 API(`/api/internal/reminder-push`)를 호출 → 웹에서 FCM 발송. 벌금 납부는 `/profile/fines` 페이지에서 처리. 알림 로그에 `push` 대상 타입 추가. + +**Tech Stack:** Next.js 16 App Router, discord.js v14, Drizzle ORM, FCM (Firebase Admin), shadcn/ui, sonner + +**Spec:** `docs/superpowers/specs/26-04-15-dm-to-push-and-fines-page-design.md` + +--- + +### Task 1: NotificationType 확장 + 알림 로그 config 업데이트 + +**Files:** +- Modify: `packages/shared/src/db/schema.ts:579-586` (NotificationType) +- Modify: `packages/web/src/lib/notification-log-config.ts` (isDM → target, 푸시 타입 추가) +- Modify: `packages/web/src/components/settings/push-notification-settings.tsx:17-42` (NOTIFICATION_LABELS) + +- [ ] **Step 1: NotificationType에 5종 추가** + +`packages/shared/src/db/schema.ts` — `NotificationType` 객체에 추가: + +```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; +``` + +- [ ] **Step 2: notification-log-config.ts — isDM → target 전환** + +`packages/web/src/lib/notification-log-config.ts`: + +인터페이스 변경: +```ts +export interface NotificationLogTypeMeta { + label: string; + color: string; + target: 'channel' | 'dm' | 'push'; +} +``` + +기존 모든 항목에서 `isDM: false` → `target: 'channel'`, `isDM: true` → `target: 'push'` (새 푸시 알림으로 전환되므로). + +기존 DM 타입 5종(`deadline_reminder`, `fine_notification`, `fine_reminder`, `grace_nudge`, `poll_reminder`)은 `target: 'push'`로 변경. + +`getLogTypeMeta` 함수의 `fallbackMeta`도 `isDM` → `target: 'channel'`. + +- [ ] **Step 3: 알림 로그 관리자 UI 필터 — 푸시 옵션 추가** + +`packages/web/src/app/(admin)/admin/bot-operations/notification-logs.tsx`: + +대상 필터의 ``에 `푸시` 추가. + +로그 표시 부분에서 `isDM` 변수 대신 `getLogTypeMeta(log.type).target` 사용: +```tsx +const target = meta.target; +// 대상 표시: +// target === 'push' → '푸시' +// target === 'dm' → `DM → ${log.targetDiscordId}` +// target === 'channel' → `#${log.channelName || log.channelId || '—'}` +``` + +- [ ] **Step 4: 알림 로그 API — push 필터 지원** + +`packages/web/src/app/api/admin/bot-logs/route.ts`: + +기존 `target` 파라미터 처리에 `push` 분기 추가: +```ts +if (target === 'push') { + // push 알림은 targetDiscordId도 channelId도 없는 로그 + // notification-log-config의 target이 'push'인 type들로 필터 + const pushTypes = Object.entries(notificationLogTypeConfig) + .filter(([, meta]) => meta.target === 'push') + .map(([type]) => type); + conditions.push(inArray(discordNotificationLogs.type, pushTypes)); +} +``` + +`notification-log-config`에서 `notificationLogTypeConfig` import 추가. + +- [ ] **Step 5: 알림 설정 UI — 새 타입 추가** + +`packages/web/src/components/settings/push-notification-settings.tsx`: + +`NOTIFICATION_LABELS`에 추가 (import에 `Wallet`, `Clock`, `AlertTriangle`, `Vote` from lucide-react): +```ts +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: '투표 마감 전 참여 요청', +}, +``` + +- [ ] **Step 6: shared 패키지 빌드 + typecheck** + +```bash +pnpm --filter @blog-study/shared build +pnpm typecheck +``` + +- [ ] **Step 7: 커밋** + +```bash +git add packages/shared/src/db/schema.ts packages/web/src/lib/notification-log-config.ts packages/web/src/components/settings/push-notification-settings.tsx packages/web/src/app/api/admin/bot-logs/route.ts packages/web/src/app/(admin)/admin/bot-operations/notification-logs.tsx +git commit -m "feat: NotificationType 5종 확장 + 알림 로그 push 대상 추가" +``` + +--- + +### Task 2: 벌금 상세 페이지 API + +**Files:** +- Create: `packages/web/src/app/api/profile/fines/route.ts` +- Create: `packages/web/src/app/api/fines/[id]/pay/route.ts` + +- [ ] **Step 1: GET /api/profile/fines — 내 벌금 목록 조회 API** + +`packages/web/src/app/api/profile/fines/route.ts`: + +```ts +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 } 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 successResponse({ fines: fineList, summary }); + } catch (error) { + return errorResponse(error); + } +} +``` + +- [ ] **Step 2: PATCH /api/fines/[id]/pay — 납부 완료 처리 API** + +`packages/web/src/app/api/fines/[id]/pay/route.ts`: + +```ts +import { NextRequest } from 'next/server'; +import { after } from 'next/server'; +import { eq } 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 } from '@/lib/api-error'; +import { sendDiscordMessage } from '@/lib/discord-notify'; +import { logNotification } from '@/lib/notification-log'; + +const { members, fines, rounds, FineStatus } = sharedDb; + +export async function PATCH( + _request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id: fineId } = await params; + 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(eq(fines.id, fineId)) + .returning(); + + // 관리자 Discord 채널 알림 (fire-and-forget) + 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 reason = fine.type === 'late' ? '지각' : '결석'; + const roundText = round ? `${round.roundNumber}회차` : ''; + + const adminChannelId = process.env.DISCORD_ADMIN_CHANNEL_ID; + if (adminChannelId) { + await sendDiscordMessage(adminChannelId, { + content: `💰 **${displayName}**님이 ${roundText} ${reason} 벌금 ${fine.amount.toLocaleString()}원 납부를 완료했습니다. (웹)`, + }); + await logNotification({ + source: 'web', + type: 'fine_payment', + channelId: adminChannelId, + summary: `${displayName}님 ${roundText} ${reason} 벌금 납부 확인 (웹)`, + status: 'sent', + }); + } + } catch (err) { + console.error('[fines/pay] Admin notification failed:', err); + } + }); + + return successResponse({ fine: updated }, '납부가 확인되었습니다.'); + } catch (error) { + return errorResponse(error); + } +} +``` + +**참고:** 관리자 채널 ID는 기존 `ConfigKeys.BOT_LOG_CHANNEL_ID`와 동일. 봇은 DB config에서 읽지만, 웹은 환경변수(`DISCORD_ADMIN_CHANNEL_ID`)로 주입. 이미 다른 웹 알림(`discord-notify.ts`)에서 같은 패턴 사용 중인지 확인 후, 기존 패턴을 따른다. + +- [ ] **Step 3: 관리자 채널 ID 확인** + +`sendDiscordMessage` 함수 시그니처와 기존 웹→Discord 알림 패턴을 확인: + +```bash +grep -n "sendDiscordMessage\|admin.*channel\|BOT_LOG" packages/web/src/lib/discord-notify.ts packages/web/src/app/api/ -r | head -20 +``` + +기존 패턴에 맞춰 채널 ID 소스를 결정한다 (환경변수 or DB config). 기존 `discord-notify.ts`가 채널 ID를 인자로 받는 구조이므로 호출처에서 결정. + +- [ ] **Step 4: typecheck** + +```bash +pnpm typecheck +``` + +- [ ] **Step 5: 커밋** + +```bash +git add packages/web/src/app/api/profile/fines/route.ts packages/web/src/app/api/fines/ +git commit -m "feat: 벌금 목록 조회 + 납부 완료 API" +``` + +--- + +### Task 3: 벌금 상세 페이지 UI + +**Files:** +- Create: `packages/web/src/app/(user)/profile/fines/page.tsx` +- Modify: `packages/web/src/app/(user)/profile/page.tsx:435-455` (스탯 카드에 Link 래핑) + +- [ ] **Step 1: 벌금 상세 페이지 컴포넌트 생성** + +`packages/web/src/app/(user)/profile/fines/page.tsx`: + +```tsx +'use client'; + +import { useCallback, useEffect, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { ArrowLeft, Wallet } from 'lucide-react'; +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, +} from '@/components/ui/alert-dialog'; +import { PageError } from '@/components/ui/page-state'; +import { toast } from 'sonner'; +import { Loader2 } from 'lucide-react'; + +interface Fine { + id: string; + roundNumber: number; + type: string; + amount: number; + status: string; + createdAt: string; + paidAt: string | null; +} + +interface FinesData { + fines: Fine[]; + summary: { unpaid: number; paid: number; total: number }; +} + +function formatFineType(type: string): string { + return type === 'late' ? '지각' : '결석'; +} + +function formatDate(dateStr: string): string { + return new Date(dateStr).toLocaleDateString('ko-KR'); +} + +export default function FinesPage() { + const router = useRouter(); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [payingFineId, setPayingFineId] = useState(null); + const [confirmFineId, setConfirmFineId] = useState(null); + + const fetchFines = useCallback(async () => { + try { + const res = await fetch('/api/profile/fines'); + if (!res.ok) throw new Error('Failed to fetch fines'); + const result = await res.json(); + setData(result.data); + } catch { + setError('벌금 내역을 불러오는데 실패했습니다.'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchFines(); + }, [fetchFines]); + + const handlePay = async (fineId: string) => { + setPayingFineId(fineId); + try { + const res = await fetch(`/api/fines/${fineId}/pay`, { method: 'PATCH' }); + const result = await res.json(); + if (!res.ok) throw new Error(result.message || '납부 처리에 실패했습니다.'); + toast.success('납부가 확인되었습니다.'); + await fetchFines(); + } catch (err) { + toast.error(err instanceof Error ? err.message : '납부 처리에 실패했습니다.'); + } finally { + setPayingFineId(null); + setConfirmFineId(null); + } + }; + + if (loading) { + return ( +
+ +
+ ); + } + + if (error) return ; + if (!data) return null; + + const unpaidFines = data.fines.filter((f) => f.status === 'PENDING'); + const completedFines = data.fines.filter((f) => f.status !== 'PENDING'); + + return ( +
+ {/* Header */} +
+ +

+ Profile / Fines +

+

벌금 내역

+
+ + {/* Summary Cards */} +
+ + +

미납

+

+ {data.summary.unpaid.toLocaleString()}원 +

+
+
+ + +

납부완료

+

+ {data.summary.paid.toLocaleString()}원 +

+
+
+ + +

총 벌금

+

+ {data.summary.total.toLocaleString()}원 +

+
+
+
+ + {/* Unpaid Section */} + {unpaidFines.length > 0 && ( +
+

미납 벌금

+ {unpaidFines.map((fine) => ( + + +
+
+

+ {fine.roundNumber}회차 · {formatFineType(fine.type)} +

+

+ {formatDate(fine.createdAt)} +

+
+
+

+ {fine.amount.toLocaleString()}원 +

+ +
+
+
+
+ ))} +
+ )} + + {/* Completed Section */} + {completedFines.length > 0 && ( +
+

납부 / 면제

+ {completedFines.map((fine) => ( + + +
+
+

+ {fine.roundNumber}회차 · {formatFineType(fine.type)} +

+

+ {formatDate(fine.createdAt)} +

+
+
+

+ {fine.amount.toLocaleString()}원 +

+ {fine.status === 'PAID' ? ( + + 납부완료 + + ) : ( + + 면제 + + )} +
+
+
+
+ ))} +
+ )} + + {/* Empty State */} + {data.fines.length === 0 && ( +
+ +

벌금 내역이 없습니다.

+
+ )} + + {/* Account Info */} + {unpaidFines.length > 0 && ( + + +

입금 계좌

+

3333333114501 카카오뱅크

+
+
+ )} + + {/* Payment Confirmation Dialog */} + !open && setConfirmFineId(null)}> + + + 납부 완료 처리 + + 입금을 완료하셨나요? 확인 후 납부 완료 처리됩니다. + + + + 취소 + confirmFineId && handlePay(confirmFineId)} + > + {payingFineId ? '처리 중...' : '납부 완료'} + + + + +
+ ); +} +``` + +- [ ] **Step 2: 프로필 스탯 카드에 Link 추가** + +`packages/web/src/app/(user)/profile/page.tsx`: + +미납 벌금 스탯 카드(`data.stats.unpaidFines` 표시 부분, line ~435-455)를 `` 로 래핑. + +기존: +```tsx + + +
+
+

미납 벌금

+ ... +``` + +변경: +```tsx + + + +
+
+

미납 벌금

+ ... +``` + +`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;