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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions apps/web/src/app/admin/reports/AdminReportItem.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="border-border border-b py-3 last:border-0">
<div className="mb-2 flex flex-wrap items-center gap-2 pr-2">
<span
className={cn(
'rounded-full px-2 py-0.5 text-xs font-medium',
REPORT_STATUS_BADGE_CLASS[report.status],
)}
>
{REPORT_STATUS_LABEL[report.status]}
</span>
<span
className={cn(
'rounded-full px-2 py-0.5 text-xs font-medium',
REPORT_CATEGORY_BADGE_CLASS[report.category],
)}
>
{REPORT_CATEGORY_LABEL[report.category]}
</span>
<span className="text-muted-foreground text-xs">{report.nickname}</span>
<span className="text-muted-foreground ml-auto text-xs">
{formatReportDate(report.created_at)}
</span>
</div>

<ReportFieldCard
title={report.title}
artist={report.artist}
title_ko={report.title_ko}
artist_ko={report.artist_ko}
num_tj={report.num_tj}
num_ky={report.num_ky}
activeField={activeField}
newValue={newValue}
/>

{isPending && (
<div className="mt-2 flex justify-end gap-2">
<Button
variant="outline"
size="sm"
onClick={() => onAction(report, 'reject')}
disabled={isDisabled}
>
<X className="mr-1 h-4 w-4" />
거부
</Button>
<Button size="sm" onClick={() => onAction(report, 'approve')} disabled={isDisabled}>
<Check className="mr-1 h-4 w-4" />
승인
</Button>
</div>
)}
</div>
);
}
97 changes: 97 additions & 0 deletions apps/web/src/app/admin/reports/ReviewActionModal.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Dialog open={isOpen} onOpenChange={open => !open && !isPending && onClose()}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Icon className="h-5 w-5" />
{titleText}
</DialogTitle>
<DialogDescription>
{report
? `${report.title || '해당 곡'} - ${categoryLabel} 신고를 ${isApprove ? '승인' : '거부'}합니다.`
: '신고를 처리합니다.'}
</DialogDescription>
</DialogHeader>
<div className="py-2">
{isApprove ? (
<p className="text-muted-foreground text-center text-sm">
제안값{' '}
<span className="text-foreground font-semibold">&quot;{suggestedValue}&quot;</span>
으로 곡 정보가 업데이트됩니다.
</p>
) : (
<p className="text-muted-foreground text-center text-sm">
이 신고를 거부 상태로 변경합니다.
</p>
)}
</div>
<DialogFooter className="flex space-x-2">
<Button variant="outline" onClick={onClose} disabled={isPending}>
취소
</Button>
<Button
onClick={handleConfirm}
disabled={isPending}
variant={isApprove ? 'default' : 'destructive'}
>
{isPending ? '처리 중...' : isApprove ? '승인' : '거부'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
108 changes: 108 additions & 0 deletions apps/web/src/app/admin/reports/page.tsx
Original file line number Diff line number Diff line change
@@ -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<AdminReportStatusFilter>('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 (
<div className="bg-background h-full">
{isLoading && <StaticLoading />}
<div className="flex items-center">
<Button variant="ghost" size="icon" onClick={() => router.back()} className="mr-2">
<ArrowLeft className="h-5 w-5" />
</Button>
<h1 className="text-2xl font-bold">신고 관리</h1>
</div>

<div className="flex h-[48px] items-center justify-between p-2">
<p className="text-muted-foreground text-sm">총 {reports.length}건</p>
</div>

<Tabs
value={statusFilter}
onValueChange={value => setStatusFilter(value as AdminReportStatusFilter)}
className="mb-2"
>
<TabsList className="w-full">
{ADMIN_REPORT_STATUS_FILTERS.map(filter => (
<TabsTrigger key={filter} value={filter}>
{ADMIN_REPORT_STATUS_FILTER_LABEL[filter]}
</TabsTrigger>
))}
</TabsList>
</Tabs>

<Separator className="mb-2" />

<ScrollArea className="h-[calc(100vh-22rem)]">
{reports.length === 0 && !isLoading ? (
<div className="text-muted-foreground py-8 text-center text-sm">
<p>해당 상태의 신고 내역이 없습니다.</p>
</div>
) : (
reports.map(report => (
<AdminReportItem key={report.id} report={report} onAction={handleAction} />
))
)}
</ScrollArea>

<ReviewActionModal
isOpen={reviewTarget !== null}
report={reviewTarget?.report ?? null}
action={reviewTarget?.action ?? null}
onClose={handleClose}
/>
</div>
);
}
102 changes: 102 additions & 0 deletions apps/web/src/app/api/admin/reports/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -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<NextResponse<ApiResponse<void>>> {
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<ReportLookupRow>();

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);
Comment on lines +64 to +69
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

3. Approve writes null song fields 🐞 Bug ≡ Correctness

Admin approve updates songs.<column> with song_reports.suggested_value even when it is null
(e.g., “데이터 없음” reports for num_tj/num_ky), which can turn songs.num_tj/num_ky into NULL while
multiple APIs/types assume these are non-null strings.
Agent Prompt
### Issue description
Approving a report can write `NULL` into `songs.num_tj`/`songs.num_ky` (and potentially other columns) because the admin PATCH handler uses `report.suggested_value` as-is. Other parts of the codebase treat these fields as non-null `string`.

### Issue Context
- `POST /api/songs/report` allows `suggested_value: null` for `num_tj`/`num_ky` to represent “데이터 없음”.
- `Song` type currently models `num_tj`/`num_ky` as required `string`.

### Fix Focus Areas
- apps/web/src/app/api/admin/reports/[id]/route.ts[64-79]
- apps/web/src/app/api/songs/report/route.ts[123-154]
- apps/web/src/types/song.ts[1-15]
- apps/web/src/app/api/search/route.ts[178-186]

### Suggested fix options (pick one, but be consistent)
1) **Keep songs fields non-null (minimal impact):** when approving, coalesce `null` to `''` for `num_tj/num_ky`, and (optionally) also null-coalesce in APIs that read from `songs`.
2) **Make songs fields nullable (larger change):** update `Song`/`DBSong` types and all API mappers/UI renderers to handle `null` explicitly.
3) **Disallow approving null suggested_value:** return 400/409 if `action==='approve'` and `suggested_value` is null (then require reject instead).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


if (songUpdateError) throw songUpdateError;

const { error: statusUpdateError } = await supabase
.from('song_reports')
.update({ status: 'applied' })
.eq('id', reportId);

if (statusUpdateError) throw statusUpdateError;
Comment on lines +64 to +78
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. Non-atomic approve updates 📎 Requirement gap ☼ Reliability

Approving a report performs the songs update and the song_reports.status update as two separate
operations without a transaction/RPC. If the second update fails, the song can be changed while the
report remains pending, violating the atomicity requirement.
Agent Prompt
## Issue description
Approving a report updates `songs` and `song_reports` in two separate calls, which can leave the system in a partially-applied state.

## Issue Context
Compliance requires the song update and report status update to be atomic (single transaction or RPC), so failures cannot create mismatched state.

## Fix Focus Areas
- apps/web/src/app/api/admin/reports/[id]/route.ts[64-78]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

} 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 });
}
}
Loading