diff --git a/apps/web/src/app/admin/reports/AdminReportItem.tsx b/apps/web/src/app/admin/reports/AdminReportItem.tsx new file mode 100644 index 0000000..c8d9535 --- /dev/null +++ b/apps/web/src/app/admin/reports/AdminReportItem.tsx @@ -0,0 +1,88 @@ +'use client'; + +import { Check, X } from 'lucide-react'; + +import ReportFieldCard from '@/components/ReportFieldCard'; +import { Button } from '@/components/ui/button'; +import { + AdminReport, + AdminReportAction, + REPORT_CATEGORY_LABEL, + REPORT_CATEGORY_TO_FIELD, + REPORT_NO_DATA_LABEL, + REPORT_STATUS_LABEL, +} from '@/types/report'; +import { cn } from '@/utils/cn'; +import { + REPORT_CATEGORY_BADGE_CLASS, + REPORT_STATUS_BADGE_CLASS, + formatReportDate, +} from '@/utils/reportDisplay'; + +interface AdminReportItemProps { + report: AdminReport; + onAction: (report: AdminReport, action: AdminReportAction) => void; + isDisabled?: boolean; +} + +export default function AdminReportItem({ report, onAction, isDisabled }: AdminReportItemProps) { + const activeField = REPORT_CATEGORY_TO_FIELD[report.category]; + const newValue = report.suggested_value ?? REPORT_NO_DATA_LABEL; + const isPending = report.status === 'pending'; + + return ( +
+
+ + {REPORT_STATUS_LABEL[report.status]} + + + {REPORT_CATEGORY_LABEL[report.category]} + + {report.nickname} + + {formatReportDate(report.created_at)} + +
+ + + + {isPending && ( +
+ + +
+ )} +
+ ); +} 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/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)}