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
Expand Up @@ -11,8 +11,8 @@ interface Props {
export function ReportActivityItem(props: Props) {
const href =
props.report.type === 'listing'
? `/admin/reports?listing=${props.report.targetId}`
: `/admin/reports?pcListing=${props.report.targetId}`
? `/listings/${props.report.targetId}`
: `/pc-listings/${props.report.targetId}`

return (
<div className="flex items-center justify-between py-2">
Expand Down
6 changes: 6 additions & 0 deletions src/lib/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -486,6 +486,12 @@ export class ResourceError {
cannotReportOwnListing: () => AppError.forbidden('You cannot report your own listing'),
}

static pcListingReport = {
notFound: () => AppError.notFound('PC listing report'),
alreadyExists: () => AppError.conflict('You have already reported this listing'),
cannotReportOwnListing: () => AppError.forbidden('You cannot report your own listing'),
}

static userBan = {
notFound: () => AppError.notFound('User ban'),
alreadyBanned: () => AppError.conflict('User already has an active ban'),
Expand Down
121 changes: 121 additions & 0 deletions src/server/api/routers/listingReports.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ReportReason, Role } from '@orm/client'

vi.unmock('@/server/api/trpc')
vi.unmock('@/server/api/root')

const mockEmitNotificationEvent = vi.fn()
vi.mock('@/server/notifications/eventEmitter', () => ({
notificationEventEmitter: { emitNotificationEvent: mockEmitNotificationEvent },
NOTIFICATION_EVENTS: {
REPORT_CREATED: 'report.created',
},
}))

vi.mock('@/server/utils/security-validation', () => ({
validateEnum: vi.fn(),
sanitizeInput: vi.fn((value: string) => value.trim()),
validatePagination: vi.fn((page, limit, max) => ({ page: page ?? 1, limit: limit ?? max ?? 20 })),
}))

vi.mock('@/lib/trust/service', () => ({
TrustService: vi.fn().mockImplementation(function MockTrustService() {
return { logAction: vi.fn(), reverseLogAction: vi.fn() }
}),
}))

const { listingReportsRouter } = await import('./listingReports')

const USER_ID = '00000000-0000-4000-a000-000000000001'
const AUTHOR_ID = '00000000-0000-4000-a000-000000000002'
const LISTING_ID = '00000000-0000-4000-a000-000000000010'
const REPORT_ID = '00000000-0000-4000-a000-000000000020'

function createMockPrisma() {
return {
listing: {
findUnique: vi.fn().mockResolvedValue({
id: LISTING_ID,
authorId: AUTHOR_ID,
author: { id: AUTHOR_ID },
}),
},
listingReport: {
findUnique: vi.fn().mockResolvedValue(null),
create: vi.fn().mockResolvedValue({
id: REPORT_ID,
listingId: LISTING_ID,
reportedById: USER_ID,
reason: ReportReason.SPAM,
description: 'needs review',
listing: {
game: { title: 'Test Game' },
author: { name: 'Report Author' },
},
}),
},
}
}

type MockPrisma = ReturnType<typeof createMockPrisma>

function createCaller(prisma: MockPrisma = createMockPrisma()) {
return {
caller: listingReportsRouter.createCaller({
session: {
user: {
id: USER_ID,
email: 'test@test.com',
name: 'Test User',
role: Role.USER,
permissions: [],
showNsfw: false,
},
},
prisma: prisma as never,
headers: new Headers(),
}),
prisma,
}
}

describe('listingReportsRouter create', () => {
beforeEach(() => {
vi.clearAllMocks()
})

it('creates a report and emits a moderator notification event', async () => {
const { caller, prisma } = createCaller()

const report = await caller.create({
listingId: LISTING_ID,
reason: ReportReason.SPAM,
description: ' needs review ',
})

expect(report.id).toBe(REPORT_ID)
expect(prisma.listingReport.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
listingId: LISTING_ID,
reportedById: USER_ID,
description: 'needs review',
}),
}),
)
expect(mockEmitNotificationEvent).toHaveBeenCalledWith({
eventType: 'report.created',
entityType: 'listingReport',
entityId: REPORT_ID,
triggeredBy: USER_ID,
includeTriggeredBy: true,
payload: {
reportId: REPORT_ID,
contentId: LISTING_ID,
contentType: 'Compatibility Report',
actionUrl: `/listings/${LISTING_ID}`,
listingId: LISTING_ID,
},
})
})
})
52 changes: 7 additions & 45 deletions src/server/api/routers/listingReports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
publicProcedure,
} from '@/server/api/trpc'
import { getAuthorReportCounts } from '@/server/services/report-stats.service'
import { ReportSubmissionService } from '@/server/services/report-submission.service'
import { paginate } from '@/server/utils/pagination'
import { validateEnum, sanitizeInput, validatePagination } from '@/server/utils/security-validation'
import { PERMISSIONS } from '@/utils/permission-system'
Expand Down Expand Up @@ -131,54 +132,15 @@ export const listingReportsRouter = createTRPCRouter({
const { listingId, reason, description } = input
const userId = ctx.session.user.id

// Validate reason enum
validateEnum(reason, Object.values(ReportReason), 'reason')

// Sanitize description if provided (plain text, not markdown)
const sanitizedDescription = description ? sanitizeInput(description) : description
const reportSubmissionService = new ReportSubmissionService(ctx.prisma)

// Check if listing exists
const listing = await ctx.prisma.listing.findUnique({
where: { id: listingId },
include: { author: true },
})

if (!listing) return ResourceError.listing.notFound()

// Prevent users from reporting their own listings
if (listing.authorId === userId) {
return ResourceError.listingReport.cannotReportOwnListing()
}

// Check if user already reported this listing
const existingReport = await ctx.prisma.listingReport.findUnique({
where: {
listingId_reportedById: {
listingId,
reportedById: userId,
},
},
})

if (existingReport) return ResourceError.listingReport.alreadyExists()

// TODO: Send notification to SUPER_ADMIN users

return await ctx.prisma.listingReport.create({
data: {
listingId,
reportedById: userId,
reason,
description: sanitizedDescription,
},
include: {
listing: {
include: {
game: { select: { title: true } },
author: { select: { name: true } },
},
},
},
return await reportSubmissionService.createListingReport({
listingId,
reportedById: userId,
reason,
description,
})
}),

Expand Down
125 changes: 125 additions & 0 deletions src/server/api/routers/mobile/listingReports.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ReportReason, Role } from '@orm/client'

vi.unmock('@/server/api/mobileContext')

vi.mock('@/schemas/apiAccess', () => ({
GetApiKeyUsageSchema: {},
CreateApiKeySchema: {},
UpdateApiKeySchema: {},
RevokeApiKeySchema: {},
ListApiKeysSchema: {},
}))

vi.mock('@/server/repositories/api-keys.repository', () => ({
ApiKeysRepository: vi.fn().mockImplementation(function MockApiKeysRepository() {
return {}
}),
}))

const mockEmitNotificationEvent = vi.fn()
vi.mock('@/server/notifications/eventEmitter', () => ({
notificationEventEmitter: { emitNotificationEvent: mockEmitNotificationEvent },
NOTIFICATION_EVENTS: {
REPORT_CREATED: 'report.created',
},
}))

vi.mock('@/server/utils/security-validation', () => ({
sanitizeInput: vi.fn((value: string) => value.trim()),
}))

const { mobileListingReportsRouter } = await import('./listingReports')

const USER_ID = '00000000-0000-4000-a000-000000000001'
const AUTHOR_ID = '00000000-0000-4000-a000-000000000002'
const LISTING_ID = '00000000-0000-4000-a000-000000000010'
const REPORT_ID = '00000000-0000-4000-a000-000000000020'

function createMockPrisma() {
return {
listing: {
findUnique: vi.fn().mockResolvedValue({
id: LISTING_ID,
authorId: AUTHOR_ID,
author: { id: AUTHOR_ID },
}),
},
listingReport: {
findUnique: vi.fn().mockResolvedValue(null),
create: vi.fn().mockResolvedValue({
id: REPORT_ID,
listingId: LISTING_ID,
reportedById: USER_ID,
}),
},
}
}

type MockPrisma = ReturnType<typeof createMockPrisma>

function createCaller(prisma: MockPrisma = createMockPrisma()) {
return {
caller: mobileListingReportsRouter.createCaller({
session: {
user: {
id: USER_ID,
email: 'test@test.com',
name: 'Test User',
role: Role.USER,
permissions: [],
showNsfw: false,
},
},
prisma: prisma as never,
headers: new Headers(),
apiKey: null,
}),
prisma,
}
}

describe('mobileListingReportsRouter create', () => {
beforeEach(() => {
vi.clearAllMocks()
})

it('creates a report and emits the same moderator notification event as web', async () => {
const { caller, prisma } = createCaller()

const result = await caller.create({
listingId: LISTING_ID,
reason: ReportReason.SPAM,
description: ' needs review ',
})

expect(result).toEqual({
id: REPORT_ID,
success: true,
message: 'Report submitted successfully',
})
expect(prisma.listingReport.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
listingId: LISTING_ID,
reportedById: USER_ID,
description: 'needs review',
}),
}),
)
expect(mockEmitNotificationEvent).toHaveBeenCalledWith({
eventType: 'report.created',
entityType: 'listingReport',
entityId: REPORT_ID,
triggeredBy: USER_ID,
includeTriggeredBy: true,
payload: {
reportId: REPORT_ID,
contentId: LISTING_ID,
contentType: 'Compatibility Report',
actionUrl: `/listings/${LISTING_ID}`,
listingId: LISTING_ID,
},
})
})
})
Loading
Loading