diff --git a/src/app/admin/dashboard/components/ActivityCard/ReportActivityItem.tsx b/src/app/admin/dashboard/components/ActivityCard/ReportActivityItem.tsx
index 4460bd752..fc6398b3b 100644
--- a/src/app/admin/dashboard/components/ActivityCard/ReportActivityItem.tsx
+++ b/src/app/admin/dashboard/components/ActivityCard/ReportActivityItem.tsx
@@ -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 (
diff --git a/src/lib/errors.ts b/src/lib/errors.ts
index 0c6cc8e55..83abf9040 100644
--- a/src/lib/errors.ts
+++ b/src/lib/errors.ts
@@ -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'),
diff --git a/src/server/api/routers/listingReports.test.ts b/src/server/api/routers/listingReports.test.ts
new file mode 100644
index 000000000..4bf5115bf
--- /dev/null
+++ b/src/server/api/routers/listingReports.test.ts
@@ -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
+
+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,
+ },
+ })
+ })
+})
diff --git a/src/server/api/routers/listingReports.ts b/src/server/api/routers/listingReports.ts
index 04a079e5b..d780346c7 100644
--- a/src/server/api/routers/listingReports.ts
+++ b/src/server/api/routers/listingReports.ts
@@ -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'
@@ -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,
})
}),
diff --git a/src/server/api/routers/mobile/listingReports.test.ts b/src/server/api/routers/mobile/listingReports.test.ts
new file mode 100644
index 000000000..d1671b9c8
--- /dev/null
+++ b/src/server/api/routers/mobile/listingReports.test.ts
@@ -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
+
+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,
+ },
+ })
+ })
+})
diff --git a/src/server/api/routers/mobile/listingReports.ts b/src/server/api/routers/mobile/listingReports.ts
index e952910cf..e316538ee 100644
--- a/src/server/api/routers/mobile/listingReports.ts
+++ b/src/server/api/routers/mobile/listingReports.ts
@@ -1,4 +1,3 @@
-import { AppError, ResourceError } from '@/lib/errors'
import { CreateListingReportSchema, GetUserReportStatsSchema } from '@/schemas/listingReport'
import {
createMobileTRPCRouter,
@@ -6,49 +5,21 @@ import {
mobilePublicProcedure,
} from '@/server/api/mobileContext'
import { getAuthorReportCounts } from '@/server/services/report-stats.service'
+import { ReportSubmissionService } from '@/server/services/report-submission.service'
export const mobileListingReportsRouter = createMobileTRPCRouter({
- /**
- * Create a new listing report (user-facing)
- */
create: mobileProtectedProcedure
.input(CreateListingReportSchema)
.mutation(async ({ ctx, input }) => {
const { listingId, reason, description } = input
const userId = ctx.session.user.id
- // 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 AppError.badRequest('You cannot report your own listing')
- }
-
- // Check if user already reported this listing
- const existingReport = await ctx.prisma.listingReport.findUnique({
- where: { listingId_reportedById: { listingId, reportedById: userId } },
- })
-
- if (existingReport) {
- return AppError.badRequest('You have already reported this listing')
- }
-
- const report = await ctx.prisma.listingReport.create({
- data: { listingId, reportedById: userId, reason, description },
- include: {
- listing: {
- include: {
- game: { select: { title: true } },
- author: { select: { name: true } },
- },
- },
- },
+ const reportSubmissionService = new ReportSubmissionService(ctx.prisma)
+ const report = await reportSubmissionService.createListingReport({
+ listingId,
+ reportedById: userId,
+ reason,
+ description,
})
return {
diff --git a/src/server/api/routers/pcListings.test.ts b/src/server/api/routers/pcListings.test.ts
index b6895311e..d60abece5 100644
--- a/src/server/api/routers/pcListings.test.ts
+++ b/src/server/api/routers/pcListings.test.ts
@@ -7,7 +7,7 @@ import {
invalidatePcListingsSeo,
} from '@/server/cache/invalidation'
import { PERMISSIONS } from '@/utils/permission-system'
-import { ApprovalStatus, PcOs, Role, TrustAction } from '@orm/client'
+import { ApprovalStatus, PcOs, ReportReason, Role, TrustAction } from '@orm/client'
vi.unmock('@/server/api/trpc')
vi.unmock('@/server/api/root')
@@ -46,6 +46,7 @@ vi.mock('@/server/notifications/eventEmitter', () => ({
COMMENT_REPLIED: 'COMMENT_REPLIED',
PC_LISTING_APPROVED: 'PC_LISTING_APPROVED',
PC_LISTING_REJECTED: 'PC_LISTING_REJECTED',
+ REPORT_CREATED: 'report.created',
},
}))
@@ -111,6 +112,7 @@ vi.mock('@/server/api/utils/pinPermissions', () => ({
vi.mock('@/server/utils/security-validation', () => ({
validatePagination: vi.fn((page, limit, max) => ({ page: page ?? 1, limit: limit ?? max ?? 20 })),
+ sanitizeInput: vi.fn((value: string) => value.trim()),
}))
const mockRepositoryCreate = vi.fn()
@@ -196,6 +198,20 @@ function createMockPrisma() {
update: vi.fn(),
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
},
+ pcListingReport: {
+ findUnique: vi.fn().mockResolvedValue(null),
+ create: vi.fn().mockResolvedValue({
+ id: '00000000-0000-4000-a000-000000000030',
+ pcListingId: LISTING_ID,
+ reportedById: USER_ID,
+ reason: ReportReason.SPAM,
+ description: 'needs review',
+ pcListing: {
+ game: { title: 'PC Test Game' },
+ author: { name: 'PC Report Author' },
+ },
+ }),
+ },
user: {
findUnique: vi.fn().mockResolvedValue({ id: ADMIN_ID }),
},
@@ -564,6 +580,48 @@ describe('pcListings trust integration', () => {
})
})
+ describe('createReport', () => {
+ it('creates a PC report and emits a moderator notification event', async () => {
+ const { caller, prisma } = createCaller()
+ prisma.pcListing.findUnique.mockResolvedValue({
+ id: LISTING_ID,
+ authorId: AUTHOR_ID,
+ author: { id: AUTHOR_ID },
+ })
+
+ const report = await caller.createReport({
+ pcListingId: LISTING_ID,
+ reason: ReportReason.SPAM,
+ description: ' needs review ',
+ })
+
+ expect(report.id).toBe('00000000-0000-4000-a000-000000000030')
+ expect(prisma.pcListingReport.create).toHaveBeenCalledWith(
+ expect.objectContaining({
+ data: expect.objectContaining({
+ pcListingId: LISTING_ID,
+ reportedById: USER_ID,
+ description: 'needs review',
+ }),
+ }),
+ )
+ expect(mockEmitNotificationEvent).toHaveBeenCalledWith({
+ eventType: 'report.created',
+ entityType: 'pcListingReport',
+ entityId: '00000000-0000-4000-a000-000000000030',
+ triggeredBy: USER_ID,
+ includeTriggeredBy: true,
+ payload: {
+ reportId: '00000000-0000-4000-a000-000000000030',
+ contentId: LISTING_ID,
+ contentType: 'PC Compatibility Report',
+ actionUrl: `/pc-listings/${LISTING_ID}`,
+ pcListingId: LISTING_ID,
+ },
+ })
+ })
+ })
+
describe('byId', () => {
it('hides review risk profiles for non-reviewers', async () => {
mockRepositoryGetByIdWithDetails.mockResolvedValueOnce({
diff --git a/src/server/api/routers/pcListings.ts b/src/server/api/routers/pcListings.ts
index 00ff91ac6..b82a3bfb1 100644
--- a/src/server/api/routers/pcListings.ts
+++ b/src/server/api/routers/pcListings.ts
@@ -64,6 +64,7 @@ import { NOTIFICATION_EVENTS, notificationEventEmitter } from '@/server/notifica
import { PcListingsRepository } from '@/server/repositories/pc-listings.repository'
import { UserPcPresetsRepository } from '@/server/repositories/user-pc-presets.repository'
import { logAudit } from '@/server/services/audit.service'
+import { ReportSubmissionService } from '@/server/services/report-submission.service'
import { autoRejectRiskyPcReports } from '@/server/services/review-risk-auto-reject.service'
import {
attachReviewRiskProfiles,
@@ -1687,51 +1688,13 @@ export const pcListingsRouter = createTRPCRouter({
.mutation(async ({ ctx, input }) => {
const { pcListingId, reason, description } = input
const userId = ctx.session.user.id
+ const reportSubmissionService = new ReportSubmissionService(ctx.prisma)
- // Check if PC listing exists
- const pcListing = await ctx.prisma.pcListing.findUnique({
- where: { id: pcListingId },
- include: { author: true },
- })
-
- if (!pcListing) {
- return ResourceError.pcListing.notFound()
- }
-
- // Prevent users from reporting their own listings
- if (pcListing.authorId === userId) {
- return AppError.badRequest('You cannot report your own listing')
- }
-
- // Check if user already reported this listing
- const existingReport = await ctx.prisma.pcListingReport.findUnique({
- where: {
- pcListingId_reportedById: {
- pcListingId,
- reportedById: userId,
- },
- },
- })
-
- if (existingReport) {
- return AppError.badRequest('You have already reported this listing')
- }
-
- return await ctx.prisma.pcListingReport.create({
- data: {
- pcListingId,
- reportedById: userId,
- reason,
- description,
- },
- include: {
- pcListing: {
- include: {
- game: { select: { title: true } },
- author: { select: { name: true } },
- },
- },
- },
+ return await reportSubmissionService.createPcListingReport({
+ pcListingId,
+ reportedById: userId,
+ reason,
+ description,
})
}),
diff --git a/src/server/notifications/eventEmitter.ts b/src/server/notifications/eventEmitter.ts
index e2bd4e9ed..3398fe035 100644
--- a/src/server/notifications/eventEmitter.ts
+++ b/src/server/notifications/eventEmitter.ts
@@ -6,6 +6,7 @@ export interface NotificationEventData {
entityType: string
entityId: string
triggeredBy?: string
+ includeTriggeredBy?: boolean
payload?: NotificationEventPayload
}
@@ -60,6 +61,8 @@ export const NOTIFICATION_EVENTS = {
MAINTENANCE_SCHEDULED: 'maintenance.scheduled',
FEATURE_ANNOUNCED: 'feature.announced',
USER_ROLE_CHANGED: 'user.role_changed',
+ REPORT_CREATED: 'report.created',
+ REPORT_STATUS_CHANGED: 'report.status_changed',
GAME_STATUS_OVERRIDDEN: 'game.status_overridden',
PC_LISTING_APPROVED: 'pcListing.approved',
PC_LISTING_REJECTED: 'pcListing.rejected',
diff --git a/src/server/notifications/reportEvents.ts b/src/server/notifications/reportEvents.ts
new file mode 100644
index 000000000..0ddcf222d
--- /dev/null
+++ b/src/server/notifications/reportEvents.ts
@@ -0,0 +1,41 @@
+import { NOTIFICATION_EVENTS, notificationEventEmitter } from '@/server/notifications/eventEmitter'
+
+type ListingReportNotificationInput = {
+ type: 'listing'
+ reportId: string
+ listingId: string
+ reportedById: string
+}
+
+type PcListingReportNotificationInput = {
+ type: 'pcListing'
+ reportId: string
+ pcListingId: string
+ reportedById: string
+}
+
+type ReportNotificationInput =
+ | ListingReportNotificationInput
+ | PcListingReportNotificationInput
+
+export function emitReportCreatedNotification(input: ReportNotificationInput): void {
+ const isPcListing = input.type === 'pcListing'
+ const contentId = isPcListing ? input.pcListingId : input.listingId
+
+ notificationEventEmitter.emitNotificationEvent({
+ eventType: NOTIFICATION_EVENTS.REPORT_CREATED,
+ entityType: isPcListing ? 'pcListingReport' : 'listingReport',
+ entityId: input.reportId,
+ triggeredBy: input.reportedById,
+ includeTriggeredBy: true,
+ payload: {
+ reportId: input.reportId,
+ contentId,
+ contentType: isPcListing ? 'PC Compatibility Report' : 'Compatibility Report',
+ actionUrl: isPcListing
+ ? `/pc-listings/${contentId}`
+ : `/listings/${contentId}`,
+ ...(isPcListing ? { pcListingId: input.pcListingId } : { listingId: input.listingId }),
+ },
+ })
+}
diff --git a/src/server/notifications/service.test.ts b/src/server/notifications/service.test.ts
index 235bb19f3..6ba9a68f9 100644
--- a/src/server/notifications/service.test.ts
+++ b/src/server/notifications/service.test.ts
@@ -4,6 +4,7 @@ import {
NotificationCategory,
NotificationDeliveryStatus,
NotificationType,
+ Role,
} from '@orm/client'
import { NOTIFICATION_EVENTS } from './eventEmitter'
import type { NotificationEventData } from './eventEmitter'
@@ -263,6 +264,32 @@ describe('NotificationService', () => {
expect(users).toContain('pc-author-1')
})
+ it('report.created returns moderator and higher users', async () => {
+ mockPrisma.user.findMany.mockResolvedValue([
+ { id: 'moderator-1' },
+ { id: 'admin-1' },
+ { id: 'super-admin-1' },
+ { id: 'reporter-1' },
+ ])
+
+ const users = await serviceInternals.getUsersForEvent(
+ makeEvent({
+ eventType: NOTIFICATION_EVENTS.REPORT_CREATED,
+ entityType: 'listingReport',
+ entityId: 'report-1',
+ triggeredBy: 'reporter-1',
+ includeTriggeredBy: true,
+ payload: { reportId: 'report-1', listingId: 'listing-1' },
+ }),
+ )
+
+ expect(mockPrisma.user.findMany).toHaveBeenCalledWith({
+ where: { role: { in: [Role.MODERATOR, Role.ADMIN, Role.SUPER_ADMIN] } },
+ select: { id: true },
+ })
+ expect(users).toEqual(['moderator-1', 'admin-1', 'super-admin-1', 'reporter-1'])
+ })
+
it('excludes the actor from recipients', async () => {
mockPrisma.listing.findUnique.mockResolvedValue(makeListingRecord({ authorId: 'admin-1' }))
@@ -518,6 +545,8 @@ describe('NotificationService', () => {
['pcListing.rejected', NotificationType.LISTING_REJECTED],
['game_follow.new_listing', NotificationType.FOLLOWED_GAME_NEW_LISTING],
['game_follow.new_pc_listing', NotificationType.FOLLOWED_GAME_NEW_PC_LISTING],
+ [NOTIFICATION_EVENTS.REPORT_CREATED, NotificationType.REPORT_CREATED],
+ [NOTIFICATION_EVENTS.REPORT_STATUS_CHANGED, NotificationType.REPORT_STATUS_CHANGED],
['listing.commented', NotificationType.COMMENT_ON_LISTING],
]
diff --git a/src/server/notifications/service.ts b/src/server/notifications/service.ts
index 80b6d7854..3085c1156 100644
--- a/src/server/notifications/service.ts
+++ b/src/server/notifications/service.ts
@@ -17,7 +17,10 @@ import {
Role,
} from '@orm/client'
import { createEmailService } from './emailService'
-import { type NotificationEventData, notificationEventEmitter } from './eventEmitter'
+import {
+ type NotificationEventData,
+ notificationEventEmitter,
+} from './eventEmitter'
import { notificationRateLimitService } from './rateLimitService'
import { notificationTemplateEngine, type TemplateContext } from './templates'
import type {
@@ -673,7 +676,7 @@ export class NotificationService {
break
}
- if (eventData.triggeredBy) {
+ if (eventData.triggeredBy && !eventData.includeTriggeredBy) {
const actorId = eventData.triggeredBy
for (let i = userIds.length - 1; i >= 0; i--) {
if (userIds[i] === actorId) userIds.splice(i, 1)
diff --git a/src/server/services/report-submission.service.ts b/src/server/services/report-submission.service.ts
new file mode 100644
index 000000000..f8b735181
--- /dev/null
+++ b/src/server/services/report-submission.service.ts
@@ -0,0 +1,130 @@
+import { ResourceError } from '@/lib/errors'
+import { emitReportCreatedNotification } from '@/server/notifications/reportEvents'
+import { sanitizeInput } from '@/server/utils/security-validation'
+import { type PrismaClient, type ReportReason } from '@orm/client'
+
+type CreateListingReportInput = {
+ listingId: string
+ reason: ReportReason
+ description?: string
+ reportedById: string
+}
+
+type CreatePcListingReportInput = {
+ pcListingId: string
+ reason: ReportReason
+ description?: string
+ reportedById: string
+}
+
+function sanitizeOptionalDescription(description: string | undefined): string | undefined {
+ return description ? sanitizeInput(description) : description
+}
+
+export class ReportSubmissionService {
+ constructor(private readonly prisma: PrismaClient) {}
+
+ async createListingReport(input: CreateListingReportInput) {
+ const sanitizedDescription = sanitizeOptionalDescription(input.description)
+
+ const listing = await this.prisma.listing.findUnique({
+ where: { id: input.listingId },
+ select: { authorId: true },
+ })
+
+ if (!listing) return ResourceError.listing.notFound()
+
+ if (listing.authorId === input.reportedById) {
+ return ResourceError.listingReport.cannotReportOwnListing()
+ }
+
+ const existingReport = await this.prisma.listingReport.findUnique({
+ where: {
+ listingId_reportedById: {
+ listingId: input.listingId,
+ reportedById: input.reportedById,
+ },
+ },
+ })
+
+ if (existingReport) return ResourceError.listingReport.alreadyExists()
+
+ const report = await this.prisma.listingReport.create({
+ data: {
+ listingId: input.listingId,
+ reportedById: input.reportedById,
+ reason: input.reason,
+ description: sanitizedDescription,
+ },
+ include: {
+ listing: {
+ include: {
+ game: { select: { title: true } },
+ author: { select: { name: true } },
+ },
+ },
+ },
+ })
+
+ emitReportCreatedNotification({
+ type: 'listing',
+ reportId: report.id,
+ listingId: input.listingId,
+ reportedById: input.reportedById,
+ })
+
+ return report
+ }
+
+ async createPcListingReport(input: CreatePcListingReportInput) {
+ const sanitizedDescription = sanitizeOptionalDescription(input.description)
+
+ const pcListing = await this.prisma.pcListing.findUnique({
+ where: { id: input.pcListingId },
+ select: { authorId: true },
+ })
+
+ if (!pcListing) return ResourceError.pcListing.notFound()
+
+ if (pcListing.authorId === input.reportedById) {
+ return ResourceError.pcListingReport.cannotReportOwnListing()
+ }
+
+ const existingReport = await this.prisma.pcListingReport.findUnique({
+ where: {
+ pcListingId_reportedById: {
+ pcListingId: input.pcListingId,
+ reportedById: input.reportedById,
+ },
+ },
+ })
+
+ if (existingReport) return ResourceError.pcListingReport.alreadyExists()
+
+ const report = await this.prisma.pcListingReport.create({
+ data: {
+ pcListingId: input.pcListingId,
+ reportedById: input.reportedById,
+ reason: input.reason,
+ description: sanitizedDescription,
+ },
+ include: {
+ pcListing: {
+ include: {
+ game: { select: { title: true } },
+ author: { select: { name: true } },
+ },
+ },
+ },
+ })
+
+ emitReportCreatedNotification({
+ type: 'pcListing',
+ reportId: report.id,
+ pcListingId: input.pcListingId,
+ reportedById: input.reportedById,
+ })
+
+ return report
+ }
+}