Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
ALTER TABLE "content_reports"
ADD COLUMN "admin_note" VARCHAR(500),
ADD COLUMN "resolved_at" TIMESTAMP(3);
8 changes: 5 additions & 3 deletions fishing/apps/api/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -208,9 +208,11 @@ model ContentReport {
targetId String @map("target_id")
reporterId String @map("reporter_id")
reason String @db.VarChar(100)
detail String? @db.VarChar(500)
status String @default("open") // open | dismissed | resolved
createdAt DateTime @default(now()) @map("created_at")
detail String? @db.VarChar(500)
status String @default("open") // open | dismissed | resolved
adminNote String? @map("admin_note") @db.VarChar(500)
resolvedAt DateTime? @map("resolved_at")
createdAt DateTime @default(now()) @map("created_at")
reporter User @relation(fields: [reporterId], references: [id], onDelete: Cascade)
Expand Down
46 changes: 46 additions & 0 deletions fishing/apps/api/src/admin/admin-report-resolve.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { AdminService } from './admin.service';

describe('AdminService.resolveFlaggedReport', () => {
const prisma = {
catch: { findUnique: jest.fn(), update: jest.fn() },
contentReport: { updateMany: jest.fn() },
};
const service = new AdminService(prisma as never);

beforeEach(() => {
jest.clearAllMocks();
prisma.catch.findUnique.mockResolvedValue({ id: 'c1' });
prisma.catch.update.mockResolvedValue({});
prisma.contentReport.updateMany.mockResolvedValue({ count: 1 });
});

it('한 건 신고를 삭제 처리할 때 메모와 처리 시각을 남긴다', async () => {
await service.resolveFlaggedReport('catch', 'c1', 'delete', ' 사진이 같음 ');

expect(prisma.contentReport.updateMany).toHaveBeenCalledWith({
where: { targetType: 'catch', targetId: 'c1', status: 'open' },
data: {
status: 'resolved',
adminNote: '사진이 같음',
resolvedAt: expect.any(Date),
},
});
expect(prisma.catch.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ imageHash: null, perceptualHash: null }),
}),
);
});

it('무시할 때도 처리 시각을 남긴다', async () => {
await service.resolveFlaggedReport('catch', 'c1', 'dismiss');
expect(prisma.contentReport.updateMany).toHaveBeenCalledWith({
where: { targetType: 'catch', targetId: 'c1', status: 'open' },
data: {
status: 'dismissed',
adminNote: null,
resolvedAt: expect.any(Date),
},
});
});
});
11 changes: 6 additions & 5 deletions fishing/apps/api/src/admin/admin.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { CurrentUser } from '../common/decorators/current-user.decorator';
import { AdminService } from './admin.service';
import { ReviewCatchDto } from './dto/review-catch.dto';
import { ReviewDayCodeDto } from './dto/review-day-code.dto';
import { ResolveReportDto } from './dto/resolve-report.dto';
import { UpsertAnnouncementDto } from './dto/upsert-announcement.dto';
import { UpdateFeedbackDto } from './dto/update-feedback.dto';
import { UpdateUserDto } from './dto/update-user.dto';
Expand Down Expand Up @@ -97,10 +98,10 @@ export class AdminController {
}

@Get('reports/flagged')
@ApiOperation({ summary: '신고 3건 이상 콘텐츠 (신고 수 내림차순)' })
@ApiOperation({ summary: '열린 신고 콘텐츠 (기본 1건부터, 신고 수 내림차순)' })
@ApiQuery({ name: 'minCount', required: false })
async listFlaggedReports(@Query('minCount') minCount = '3') {
const data = await this.admin.listFlaggedReports(Number(minCount) || 3);
async listFlaggedReports(@Query('minCount') minCount = '1') {
const data = await this.admin.listFlaggedReports(Number(minCount) || 1);
return { success: true, data };
}

Expand All @@ -109,9 +110,9 @@ export class AdminController {
async resolveFlaggedReport(
@Param('targetType') targetType: 'catch' | 'post',
@Param('targetId') targetId: string,
@Body() body: { action: 'dismiss' | 'delete' },
@Body() body: ResolveReportDto,
) {
const data = await this.admin.resolveFlaggedReport(targetType, targetId, body.action);
const data = await this.admin.resolveFlaggedReport(targetType, targetId, body.action, body.adminNote);
return { success: true, data };
}

Expand Down
17 changes: 11 additions & 6 deletions fishing/apps/api/src/admin/admin.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export class AdminService {
totalUsers,
] = await Promise.all([
this.prisma.catch.count({ where: { deletedAt: null, status: 'pending', recordType: 'certified' } }),
this.countFlaggedReports(3),
this.countFlaggedReports(1),
this.prisma.userFeedback.count({ where: { status: 'open' } }),
this.prisma.catch.count({ where: { deletedAt: null } }),
this.prisma.post.count({ where: { deletedAt: null } }),
Expand All @@ -46,7 +46,7 @@ export class AdminService {
take: 5,
});

const recentFlagged = await this.listFlaggedReports(3, 5);
const recentFlagged = await this.listFlaggedReports(1, 5);

const recentPosts = await this.prisma.post.findMany({
where: { deletedAt: null },
Expand Down Expand Up @@ -79,7 +79,7 @@ export class AdminService {
};
}

async countFlaggedReports(minCount = 3) {
async countFlaggedReports(minCount = 1) {
const rows = await this.prisma.$queryRaw<Array<{ count: bigint }>>`
SELECT COUNT(*)::bigint AS count
FROM (
Expand All @@ -93,7 +93,7 @@ export class AdminService {
return Number(rows[0]?.count ?? 0);
}

async listFlaggedReports(minCount = 3, limit = 50) {
async listFlaggedReports(minCount = 1, limit = 50) {
const rows = await this.prisma.$queryRaw<
Array<{ target_type: string; target_id: string; report_count: number }>
>`
Expand Down Expand Up @@ -155,7 +155,12 @@ export class AdminService {
targetType: 'catch' | 'post',
targetId: string,
action: 'dismiss' | 'delete',
adminNote?: string,
) {
const resolution = {
adminNote: adminNote?.trim() || null,
resolvedAt: new Date(),
};
if (action === 'delete') {
if (targetType === 'catch') {
const catch_ = await this.prisma.catch.findUnique({ where: { id: targetId, deletedAt: null } });
Expand All @@ -174,14 +179,14 @@ export class AdminService {
}
await this.prisma.contentReport.updateMany({
where: { targetType, targetId, status: 'open' },
data: { status: 'resolved' },
data: { status: 'resolved', ...resolution },
});
return { action, targetType, targetId };
}

await this.prisma.contentReport.updateMany({
where: { targetType, targetId, status: 'open' },
data: { status: 'dismissed' },
data: { status: 'dismissed', ...resolution },
});
return { action, targetType, targetId };
}
Expand Down
11 changes: 11 additions & 0 deletions fishing/apps/api/src/admin/dto/resolve-report.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { IsIn, IsOptional, IsString, MaxLength } from 'class-validator';

export class ResolveReportDto {
@IsIn(['dismiss', 'delete'])
action: 'dismiss' | 'delete';

@IsOptional()
@IsString()
@MaxLength(500)
adminNote?: string;
}
4 changes: 2 additions & 2 deletions fishing/apps/mobile/app/admin/reports.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { colors } from '@/theme/colors';
export default function AdminReportsScreen() {
const { data, isLoading } = useQuery({
queryKey: ['admin', 'reports'],
queryFn: async () => (await api.get('/admin/reports/flagged')).data.data.items,
queryFn: async () => (await api.get('/admin/reports/flagged?minCount=1')).data.data,
});

return (
Expand All @@ -21,7 +21,7 @@ export default function AdminReportsScreen() {
) : (
<FlatList
data={data ?? []}
keyExtractor={(item: { id: string }) => item.id}
keyExtractor={(item: { targetType: string; targetId: string }) => `${item.targetType}-${item.targetId}`}
ListEmptyComponent={<EmptyState title="신고가 없습니다" />}
renderItem={({ item }: { item: { targetType: string; reportCount: number } }) => (
<Text style={styles.row}>
Expand Down
34 changes: 34 additions & 0 deletions fishing/apps/mobile/app/catch/[id].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,14 @@ import { RecordStatusBadge, GradeBadge } from '@/components/ui/StatusBadge';
import { getImageUrl } from '@/lib/images';
import { formatLength, formatTimeAgo } from '@/lib/format';
import { colors } from '@/theme/colors';
import { useAuthStore } from '@/store/auth.store';
import { text } from '@/theme/text';
import { radius, spacing } from '@/theme/layout';

export default function CatchDetailScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const router = useRouter();
const me = useAuthStore((s) => s.user);

const { data, isPending, isError, refetch } = useQuery({
queryKey: ['catch', id],
Expand Down Expand Up @@ -90,6 +92,21 @@ export default function CatchDetailScreen() {
<InfoRow label="장소" value={data.locationName ?? '미입력'} />
{data.memo ? <InfoRow label="메모" value={data.memo} /> : null}

{me && data.user?.id && me.id !== data.user.id ? (
<Pressable
style={styles.reportBtn}
onPress={() => {
Alert.alert('기록 신고', '사유를 선택하세요', [
{ text: '취소', style: 'cancel' },
{ text: '허위 기록', onPress: () => submitReport('허위 기록') },
{ text: '사진 도용', onPress: () => submitReport('사진 도용') },
]);
}}
>
<Text style={styles.reportBtnText}>신고</Text>
</Pressable>
) : null}

{data.recordType === 'personal' ? (
<Pressable
style={styles.voteBtn}
Expand All @@ -109,6 +126,15 @@ export default function CatchDetailScreen() {
</Screen>
</>
);

async function submitReport(reason: string) {
try {
await api.post('/reports', { targetType: 'catch', targetId: id, reason });
Alert.alert('신고 접수', '관리자가 확인합니다.');
} catch {
Alert.alert('신고 실패', '이미 신고했거나 로그인 상태를 확인해 주세요.');
}
}
}

function InfoRow({ label, value }: { label: string; value: string }) {
Expand Down Expand Up @@ -176,4 +202,12 @@ const styles = StyleSheet.create({
justifyContent: 'center',
},
voteBtnText: { ...text.bold(15), color: '#fff' },
reportBtn: {
marginTop: spacing.md,
alignSelf: 'flex-start',
minHeight: 40,
justifyContent: 'center',
paddingHorizontal: 4,
},
reportBtnText: { ...text.bold(13), color: colors.textMuted },
});
19 changes: 16 additions & 3 deletions fishing/apps/web/src/app/admin/reports/page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
'use client';

import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import Link from 'next/link';
import api from '@/lib/api';
Expand All @@ -8,22 +9,25 @@ import { formatLength, formatTimeAgo } from '@/lib/utils';

export default function AdminReportsPage() {
const queryClient = useQueryClient();
const [note, setNote] = useState('');

const { data, isLoading } = useQuery({
queryKey: ['admin-flagged-reports'],
queryFn: async () => (await api.get('/admin/reports/flagged?minCount=3')).data.data,
queryFn: async () => (await api.get('/admin/reports/flagged?minCount=1')).data.data,
});

const resolveMutation = useMutation({
mutationFn: ({
targetType,
targetId,
action,
adminNote,
}: {
targetType: 'catch' | 'post';
targetId: string;
action: 'dismiss' | 'delete';
}) => api.patch(`/admin/reports/${targetType}/${targetId}`, { action }),
adminNote?: string;
}) => api.patch(`/admin/reports/${targetType}/${targetId}`, { action, adminNote }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-flagged-reports'] });
queryClient.invalidateQueries({ queryKey: ['admin-stats'] });
Expand All @@ -34,7 +38,7 @@ export default function AdminReportsPage() {
<div className="admin-page">
<header className="admin-page-head">
<h1>신고 관리</h1>
<p>신고가 3건 이상 접수된 콘텐츠를 신고 수가 많은 순으로 표시합니다.</p>
<p>신고 1건부터 검토합니다. 처리 메모는 기록에 남고, 랭킹은 콘텐츠를 삭제할 때만 빠집니다.</p>
</header>

{isLoading ? (
Expand Down Expand Up @@ -104,6 +108,13 @@ export default function AdminReportsPage() {
))}
</ul>

<textarea
className="admin-input"
rows={2}
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder="처리 메모 (선택)"
/>
<div className="admin-row-actions">
<button
type="button"
Expand All @@ -115,6 +126,7 @@ export default function AdminReportsPage() {
targetType: item.targetType,
targetId: item.targetId,
action: 'delete',
adminNote: note,
});
}}
>
Expand All @@ -130,6 +142,7 @@ export default function AdminReportsPage() {
targetType: item.targetType,
targetId: item.targetId,
action: 'dismiss',
adminNote: note,
});
}}
>
Expand Down
Loading