From 836eeab5ed86d7eb1cb187ccdd2e7f914a2ac5cd Mon Sep 17 00:00:00 2001 From: GulSam00 Date: Mon, 4 May 2026 01:17:47 +0900 Subject: [PATCH 1/2] =?UTF-8?q?refactor=20:=20=EC=8B=A0=EA=B3=A0=20?= =?UTF-8?q?=ED=95=AD=EB=AA=A9=20=ED=91=9C=EC=8B=9C=20=EC=9C=A0=ED=8B=B8=20?= =?UTF-8?q?=EA=B3=B5=ED=86=B5=ED=99=94=20(#221)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/src/app/info/reports/ReportItem.tsx | 40 ++++---------------- apps/web/src/utils/reportDisplay.ts | 28 ++++++++++++++ 2 files changed, 36 insertions(+), 32 deletions(-) create mode 100644 apps/web/src/utils/reportDisplay.ts diff --git a/apps/web/src/app/info/reports/ReportItem.tsx b/apps/web/src/app/info/reports/ReportItem.tsx index f484300..c6ec98c 100644 --- a/apps/web/src/app/info/reports/ReportItem.tsx +++ b/apps/web/src/app/info/reports/ReportItem.tsx @@ -10,10 +10,13 @@ import { REPORT_CATEGORY_TO_FIELD, REPORT_NO_DATA_LABEL, REPORT_STATUS_LABEL, - ReportCategory, - ReportStatus, } from '@/types/report'; import { cn } from '@/utils/cn'; +import { + REPORT_CATEGORY_BADGE_CLASS, + REPORT_STATUS_BADGE_CLASS, + formatReportDate, +} from '@/utils/reportDisplay'; interface ReportItemProps { report: MyReport; @@ -21,33 +24,6 @@ interface ReportItemProps { isDisabled?: boolean; } -const STATUS_BADGE_CLASS: Record = { - pending: - 'border border-amber-300 bg-amber-50 text-amber-700 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-300', - applied: - 'border border-emerald-300 bg-emerald-50 text-emerald-700 dark:border-emerald-800 dark:bg-emerald-950 dark:text-emerald-300', - rejected: - 'border border-red-300 bg-red-50 text-red-700 dark:border-red-800 dark:bg-red-950 dark:text-red-300', -}; - -const CATEGORY_BADGE_CLASS: Record = { - title_translation: - 'border border-sky-300 bg-sky-50 text-sky-700 dark:border-sky-800 dark:bg-sky-950 dark:text-sky-300', - artist_translation: - 'border border-pink-300 bg-pink-50 text-pink-700 dark:border-pink-800 dark:bg-pink-950 dark:text-pink-300', - num_tj: 'border border-brand-tj/40 bg-brand-tj/10 text-brand-tj', - num_ky: 'border border-brand-ky/40 bg-brand-ky/10 text-brand-ky', -}; - -function formatDate(value: string) { - const date = new Date(value); - if (Number.isNaN(date.getTime())) return value; - const yyyy = date.getFullYear(); - const mm = String(date.getMonth() + 1).padStart(2, '0'); - const dd = String(date.getDate()).padStart(2, '0'); - return `${yyyy}.${mm}.${dd}`; -} - export default function ReportItem({ report, onDelete, isDisabled }: ReportItemProps) { const activeField = REPORT_CATEGORY_TO_FIELD[report.category]; const newValue = report.suggested_value ?? REPORT_NO_DATA_LABEL; @@ -58,7 +34,7 @@ export default function ReportItem({ report, onDelete, isDisabled }: ReportItemP {REPORT_STATUS_LABEL[report.status]} @@ -66,13 +42,13 @@ export default function ReportItem({ report, onDelete, isDisabled }: ReportItemP {REPORT_CATEGORY_LABEL[report.category]} - {formatDate(report.created_at)} + {formatReportDate(report.created_at)} + + + )} + + ); +} diff --git a/apps/web/src/app/admin/reports/ReviewActionModal.tsx b/apps/web/src/app/admin/reports/ReviewActionModal.tsx new file mode 100644 index 0000000..8b280e2 --- /dev/null +++ b/apps/web/src/app/admin/reports/ReviewActionModal.tsx @@ -0,0 +1,97 @@ +'use client'; + +import { Check, X } from 'lucide-react'; + +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { usePatchAdminReportMutation } from '@/queries/adminReportQuery'; +import { + AdminReport, + AdminReportAction, + REPORT_CATEGORY_LABEL, + REPORT_NO_DATA_LABEL, +} from '@/types/report'; + +interface ReviewActionModalProps { + isOpen: boolean; + report: AdminReport | null; + action: AdminReportAction | null; + onClose: () => void; +} + +export default function ReviewActionModal({ + isOpen, + report, + action, + onClose, +}: ReviewActionModalProps) { + const { mutate, isPending } = usePatchAdminReportMutation(); + + const handleConfirm = () => { + if (!report || !action) return; + mutate( + { reportId: report.id, action }, + { + onSuccess: () => { + onClose(); + }, + }, + ); + }; + + const isApprove = action === 'approve'; + const titleText = isApprove ? '신고 승인' : '신고 거부'; + const Icon = isApprove ? Check : X; + const suggestedValue = report?.suggested_value ?? REPORT_NO_DATA_LABEL; + const categoryLabel = report ? REPORT_CATEGORY_LABEL[report.category] : ''; + + return ( + !open && !isPending && onClose()}> + + + + + {titleText} + + + {report + ? `${report.title || '해당 곡'} - ${categoryLabel} 신고를 ${isApprove ? '승인' : '거부'}합니다.` + : '신고를 처리합니다.'} + + +
+ {isApprove ? ( +

+ 제안값{' '} + "{suggestedValue}" + 으로 곡 정보가 업데이트됩니다. +

+ ) : ( +

+ 이 신고를 거부 상태로 변경합니다. +

+ )} +
+ + + + +
+
+ ); +} diff --git a/apps/web/src/app/admin/reports/page.tsx b/apps/web/src/app/admin/reports/page.tsx new file mode 100644 index 0000000..dc1a4cf --- /dev/null +++ b/apps/web/src/app/admin/reports/page.tsx @@ -0,0 +1,108 @@ +'use client'; + +import { AxiosError } from 'axios'; +import { ArrowLeft } from 'lucide-react'; +import { useRouter } from 'next/navigation'; +import { useEffect, useState } from 'react'; +import { toast } from 'sonner'; + +import StaticLoading from '@/components/StaticLoading'; +import { Button } from '@/components/ui/button'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { Separator } from '@/components/ui/separator'; +import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { useAdminReportsQuery } from '@/queries/adminReportQuery'; +import useAuthStore from '@/stores/useAuthStore'; +import { + ADMIN_REPORT_STATUS_FILTERS, + ADMIN_REPORT_STATUS_FILTER_LABEL, + AdminReport, + AdminReportAction, + AdminReportStatusFilter, +} from '@/types/report'; + +import AdminReportItem from './AdminReportItem'; +import ReviewActionModal from './ReviewActionModal'; + +export default function AdminReportsPage() { + const router = useRouter(); + const { isAuthenticated } = useAuthStore(); + const [statusFilter, setStatusFilter] = useState('pending'); + const { data, isLoading, error } = useAdminReportsQuery(statusFilter, isAuthenticated); + const reports = data ?? []; + + const [reviewTarget, setReviewTarget] = useState<{ + report: AdminReport; + action: AdminReportAction; + } | null>(null); + + useEffect(() => { + if (!error) return; + if (error instanceof AxiosError) { + const status = error.response?.status; + if (status === 401 || status === 403) { + toast.error('관리자 권한이 없습니다.'); + router.replace('/'); + } + } + }, [error, router]); + + const handleAction = (report: AdminReport, action: AdminReportAction) => { + setReviewTarget({ report, action }); + }; + + const handleClose = () => { + setReviewTarget(null); + }; + + return ( +
+ {isLoading && } +
+ +

신고 관리

+
+ +
+

총 {reports.length}건

+
+ + setStatusFilter(value as AdminReportStatusFilter)} + className="mb-2" + > + + {ADMIN_REPORT_STATUS_FILTERS.map(filter => ( + + {ADMIN_REPORT_STATUS_FILTER_LABEL[filter]} + + ))} + + + + + + + {reports.length === 0 && !isLoading ? ( +
+

해당 상태의 신고 내역이 없습니다.

+
+ ) : ( + reports.map(report => ( + + )) + )} +
+ + +
+ ); +} diff --git a/apps/web/src/app/api/admin/reports/[id]/route.ts b/apps/web/src/app/api/admin/reports/[id]/route.ts new file mode 100644 index 0000000..d2acab3 --- /dev/null +++ b/apps/web/src/app/api/admin/reports/[id]/route.ts @@ -0,0 +1,102 @@ +import { NextResponse } from 'next/server'; + +import createClient from '@/lib/supabase/server'; +import { ApiResponse } from '@/types/apiRoute'; +import { AdminReportAction, ReportCategory, ReportStatus } from '@/types/report'; +import { getAdminUser } from '@/utils/getAdminUser'; + +const CATEGORY_TO_SONG_COLUMN: Record< + ReportCategory, + 'title_ko' | 'artist_ko' | 'num_tj' | 'num_ky' +> = { + title_translation: 'title_ko', + artist_translation: 'artist_ko', + num_tj: 'num_tj', + num_ky: 'num_ky', +}; + +function isAdminReportAction(value: unknown): value is AdminReportAction { + return value === 'approve' || value === 'reject'; +} + +interface ReportLookupRow { + status: ReportStatus; + song_id: string; + category: ReportCategory; + suggested_value: string | null; +} + +export async function PATCH( + request: Request, + { params }: { params: Promise<{ id: string }> }, +): Promise>> { + try { + const supabase = await createClient(); + await getAdminUser(supabase); + + const { id: reportId } = await params; + const { action } = await request.json(); + + if (!reportId) { + return NextResponse.json({ success: false, error: 'Missing reportId' }, { status: 400 }); + } + if (!isAdminReportAction(action)) { + return NextResponse.json({ success: false, error: 'Invalid action' }, { status: 400 }); + } + + const { data: report, error: lookupError } = await supabase + .from('song_reports') + .select('status, song_id, category, suggested_value') + .eq('id', reportId) + .maybeSingle(); + + if (lookupError) throw lookupError; + if (!report) { + return NextResponse.json({ success: false, error: 'Report not found' }, { status: 404 }); + } + if (report.status !== 'pending') { + return NextResponse.json( + { success: false, error: 'Report already processed' }, + { status: 409 }, + ); + } + + if (action === 'approve') { + const column = CATEGORY_TO_SONG_COLUMN[report.category]; + const { error: songUpdateError } = await supabase + .from('songs') + .update({ [column]: report.suggested_value }) + .eq('id', report.song_id); + + if (songUpdateError) throw songUpdateError; + + const { error: statusUpdateError } = await supabase + .from('song_reports') + .update({ status: 'applied' }) + .eq('id', reportId); + + if (statusUpdateError) throw statusUpdateError; + } else { + const { error: rejectError } = await supabase + .from('song_reports') + .update({ status: 'rejected' }) + .eq('id', reportId); + + if (rejectError) throw rejectError; + } + + return NextResponse.json({ success: true }); + } catch (error) { + if (error instanceof Error && error.cause === 'auth') { + return NextResponse.json( + { success: false, error: 'User not authenticated' }, + { status: 401 }, + ); + } + if (error instanceof Error && error.cause === 'forbidden') { + return NextResponse.json({ success: false, error: 'Forbidden' }, { status: 403 }); + } + console.error('Error in admin report PATCH API:', error); + return NextResponse.json({ success: false, error: 'Failed to update report' }, { status: 500 }); + } +} diff --git a/apps/web/src/app/api/admin/reports/route.ts b/apps/web/src/app/api/admin/reports/route.ts new file mode 100644 index 0000000..2202700 --- /dev/null +++ b/apps/web/src/app/api/admin/reports/route.ts @@ -0,0 +1,90 @@ +import { NextRequest, NextResponse } from 'next/server'; + +import createClient from '@/lib/supabase/server'; +import { ApiResponse } from '@/types/apiRoute'; +import { + ADMIN_REPORT_STATUS_FILTERS, + AdminReport, + AdminReportStatusFilter, + ReportCategory, + ReportStatus, +} from '@/types/report'; +import { Song } from '@/types/song'; +import { getAdminUser } from '@/utils/getAdminUser'; + +interface AdminReportRow { + id: string; + song_id: string; + user_id: string; + category: ReportCategory; + suggested_value: string | null; + status: ReportStatus; + created_at: string; + songs: Pick | null; + users: { nickname: string } | null; +} + +function isStatusFilter(value: string | null): value is AdminReportStatusFilter { + return value !== null && ADMIN_REPORT_STATUS_FILTERS.includes(value as AdminReportStatusFilter); +} + +export async function GET(request: NextRequest): Promise>> { + try { + const supabase = await createClient(); + await getAdminUser(supabase); + + const statusParam = request.nextUrl.searchParams.get('status'); + const statusFilter: AdminReportStatusFilter = isStatusFilter(statusParam) ? statusParam : 'all'; + + let query = supabase + .from('song_reports') + .select( + `id, song_id, user_id, category, suggested_value, status, created_at, + songs ( title, artist, title_ko, artist_ko, num_tj, num_ky ), + users ( nickname )`, + ) + .order('created_at', { ascending: false }); + + if (statusFilter !== 'all') { + query = query.eq('status', statusFilter); + } + + const { data, error } = await query.returns(); + + if (error) throw error; + + const reports: AdminReport[] = (data ?? []).map(row => ({ + id: row.id, + song_id: row.song_id, + user_id: row.user_id, + nickname: row.users?.nickname ?? '알 수 없음', + category: row.category, + suggested_value: row.suggested_value, + status: row.status, + created_at: row.created_at, + title: row.songs?.title ?? '', + artist: row.songs?.artist ?? '', + title_ko: row.songs?.title_ko, + artist_ko: row.songs?.artist_ko, + num_tj: row.songs?.num_tj ?? '', + num_ky: row.songs?.num_ky ?? '', + })); + + return NextResponse.json({ success: true, data: reports }); + } catch (error) { + if (error instanceof Error && error.cause === 'auth') { + return NextResponse.json( + { success: false, error: 'User not authenticated' }, + { status: 401 }, + ); + } + if (error instanceof Error && error.cause === 'forbidden') { + return NextResponse.json({ success: false, error: 'Forbidden' }, { status: 403 }); + } + console.error('Error in admin reports GET API:', error); + return NextResponse.json( + { success: false, error: 'Failed to get admin reports' }, + { status: 500 }, + ); + } +} diff --git a/apps/web/src/lib/api/adminReport.ts b/apps/web/src/lib/api/adminReport.ts new file mode 100644 index 0000000..aeea680 --- /dev/null +++ b/apps/web/src/lib/api/adminReport.ts @@ -0,0 +1,18 @@ +import { ApiResponse } from '@/types/apiRoute'; +import { AdminReport, AdminReportAction, AdminReportStatusFilter } from '@/types/report'; + +import { instance } from './client'; + +export async function getAdminReports(status: AdminReportStatusFilter) { + const response = await instance.get>('/admin/reports', { + params: { status }, + }); + return response.data; +} + +export async function patchAdminReport(reportId: string, action: AdminReportAction) { + const response = await instance.patch>(`/admin/reports/${reportId}`, { + action, + }); + return response.data; +} diff --git a/apps/web/src/queries/adminReportQuery.ts b/apps/web/src/queries/adminReportQuery.ts new file mode 100644 index 0000000..ba4e960 --- /dev/null +++ b/apps/web/src/queries/adminReportQuery.ts @@ -0,0 +1,55 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { AxiosError } from 'axios'; +import { toast } from 'sonner'; + +import { getAdminReports, patchAdminReport } from '@/lib/api/adminReport'; +import { AdminReportAction, AdminReportStatusFilter } from '@/types/report'; + +export const adminReportsQueryKey = (status: AdminReportStatusFilter) => + ['adminReports', status] as const; + +export const useAdminReportsQuery = (status: AdminReportStatusFilter, enabled: boolean = true) => { + return useQuery({ + queryKey: adminReportsQueryKey(status), + queryFn: async () => { + const response = await getAdminReports(status); + if (!response.success) { + return []; + } + return response.data || []; + }, + enabled, + }); +}; + +interface PatchAdminReportArgs { + reportId: string; + action: AdminReportAction; +} + +export const usePatchAdminReportMutation = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ reportId, action }: PatchAdminReportArgs) => patchAdminReport(reportId, action), + onSuccess: (_data, variables) => { + queryClient.invalidateQueries({ queryKey: ['adminReports'] }); + toast.success( + variables.action === 'approve' ? '신고를 승인했습니다.' : '신고를 거절했습니다.', + ); + }, + onError: error => { + console.error('Patch admin report error:', error); + if (error instanceof AxiosError && error.response?.status === 409) { + toast.error('이미 처리된 신고입니다.'); + return; + } + if (error instanceof AxiosError && error.response?.status === 403) { + toast.error('관리자 권한이 없습니다.'); + return; + } + const message = error instanceof Error ? error.message : '신고 처리 실패'; + toast.error(message); + }, + }); +}; diff --git a/apps/web/src/types/report.ts b/apps/web/src/types/report.ts index 85a31d9..c36e296 100644 --- a/apps/web/src/types/report.ts +++ b/apps/web/src/types/report.ts @@ -49,3 +49,26 @@ export interface MyReport { num_tj: string; num_ky: string; } + +export interface AdminReport extends MyReport { + user_id: string; + nickname: string; +} + +export type AdminReportStatusFilter = ReportStatus | 'all'; + +export const ADMIN_REPORT_STATUS_FILTERS: AdminReportStatusFilter[] = [ + 'all', + 'pending', + 'applied', + 'rejected', +]; + +export const ADMIN_REPORT_STATUS_FILTER_LABEL: Record = { + all: '전체', + pending: '대기중', + applied: '반영됨', + rejected: '거절됨', +}; + +export type AdminReportAction = 'approve' | 'reject'; diff --git a/apps/web/src/utils/getAdminUser.ts b/apps/web/src/utils/getAdminUser.ts new file mode 100644 index 0000000..08b1a80 --- /dev/null +++ b/apps/web/src/utils/getAdminUser.ts @@ -0,0 +1,14 @@ +import { SupabaseClient } from '@supabase/supabase-js'; + +import { getAuthenticatedUser } from './getAuthenticatedUser'; + +export async function getAdminUser(supabase: SupabaseClient): Promise { + const userId = await getAuthenticatedUser(supabase); + + const adminId = process.env.ADMIN_USER_ID; + if (!adminId || userId !== adminId) { + throw new Error('Forbidden', { cause: 'forbidden' }); + } + + return userId; +} diff --git a/turbo.json b/turbo.json index 793cb34..77b248a 100644 --- a/turbo.json +++ b/turbo.json @@ -8,6 +8,7 @@ "SUPABASE_URL", "SUPABASE_ANON_KEY", "SUPABASE_SERVICE_ROLE_KEY", + "ADMIN_USER_ID", "KAKAO_REST_API_KEY", "KAKAO_SECRET_KEY", "NODE_ENV",