From 83c36285ef0eb16ae554c81f4c1199f8d950fac3 Mon Sep 17 00:00:00 2001 From: Producdevity Date: Sat, 6 Jun 2026 10:30:46 +0200 Subject: [PATCH 1/3] Notify moderators about submitted reports --- src/server/api/routers/listingReports.test.ts | 120 +++++++++++++++++ src/server/api/routers/listingReports.ts | 19 +-- .../api/routers/mobile/listingReports.test.ts | 124 ++++++++++++++++++ .../api/routers/mobile/listingReports.ts | 18 ++- src/server/api/routers/pcListings.test.ts | 59 ++++++++- src/server/api/routers/pcListings.ts | 20 ++- src/server/notifications/eventEmitter.ts | 2 + src/server/notifications/reportEvents.ts | 40 ++++++ src/server/notifications/service.test.ts | 28 ++++ src/server/notifications/service.ts | 8 +- 10 files changed, 414 insertions(+), 24 deletions(-) create mode 100644 src/server/api/routers/listingReports.test.ts create mode 100644 src/server/api/routers/mobile/listingReports.test.ts create mode 100644 src/server/notifications/reportEvents.ts diff --git a/src/server/api/routers/listingReports.test.ts b/src/server/api/routers/listingReports.test.ts new file mode 100644 index 000000000..59849ec62 --- /dev/null +++ b/src/server/api/routers/listingReports.test.ts @@ -0,0 +1,120 @@ +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, + payload: { + reportId: REPORT_ID, + contentId: LISTING_ID, + contentType: 'Compatibility Report', + actionUrl: `/admin/reports?listing=${LISTING_ID}`, + listingId: LISTING_ID, + }, + }) + }) +}) diff --git a/src/server/api/routers/listingReports.ts b/src/server/api/routers/listingReports.ts index 04a079e5b..5c999f48b 100644 --- a/src/server/api/routers/listingReports.ts +++ b/src/server/api/routers/listingReports.ts @@ -15,6 +15,7 @@ import { protectedProcedure, publicProcedure, } from '@/server/api/trpc' +import { emitReportCreatedNotification } from '@/server/notifications/reportEvents' import { getAuthorReportCounts } from '@/server/services/report-stats.service' import { paginate } from '@/server/utils/pagination' import { validateEnum, sanitizeInput, validatePagination } from '@/server/utils/security-validation' @@ -131,13 +132,10 @@ 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 - // Check if listing exists const listing = await ctx.prisma.listing.findUnique({ where: { id: listingId }, include: { author: true }, @@ -145,12 +143,10 @@ export const listingReportsRouter = createTRPCRouter({ 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: { @@ -162,9 +158,7 @@ export const listingReportsRouter = createTRPCRouter({ if (existingReport) return ResourceError.listingReport.alreadyExists() - // TODO: Send notification to SUPER_ADMIN users - - return await ctx.prisma.listingReport.create({ + const report = await ctx.prisma.listingReport.create({ data: { listingId, reportedById: userId, @@ -180,6 +174,15 @@ export const listingReportsRouter = createTRPCRouter({ }, }, }) + + emitReportCreatedNotification({ + type: 'listing', + reportId: report.id, + listingId, + reportedById: userId, + }) + + return report }), updateStatus: permissionProcedure(PERMISSIONS.MANAGE_USER_BANS) 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..ac7a1deec --- /dev/null +++ b/src/server/api/routers/mobile/listingReports.test.ts @@ -0,0 +1,124 @@ +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, + payload: { + reportId: REPORT_ID, + contentId: LISTING_ID, + contentType: 'Compatibility Report', + actionUrl: `/admin/reports?listing=${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..8ef725d05 100644 --- a/src/server/api/routers/mobile/listingReports.ts +++ b/src/server/api/routers/mobile/listingReports.ts @@ -5,19 +5,18 @@ import { mobileProtectedProcedure, mobilePublicProcedure, } from '@/server/api/mobileContext' +import { emitReportCreatedNotification } from '@/server/notifications/reportEvents' import { getAuthorReportCounts } from '@/server/services/report-stats.service' +import { sanitizeInput } from '@/server/utils/security-validation' 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 + const sanitizedDescription = description ? sanitizeInput(description) : description - // Check if listing exists const listing = await ctx.prisma.listing.findUnique({ where: { id: listingId }, include: { author: true }, @@ -25,12 +24,10 @@ export const mobileListingReportsRouter = createMobileTRPCRouter({ 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 } }, }) @@ -40,7 +37,7 @@ export const mobileListingReportsRouter = createMobileTRPCRouter({ } const report = await ctx.prisma.listingReport.create({ - data: { listingId, reportedById: userId, reason, description }, + data: { listingId, reportedById: userId, reason, description: sanitizedDescription }, include: { listing: { include: { @@ -51,6 +48,13 @@ export const mobileListingReportsRouter = createMobileTRPCRouter({ }, }) + emitReportCreatedNotification({ + type: 'listing', + reportId: report.id, + listingId, + reportedById: userId, + }) + return { id: report.id, success: true, diff --git a/src/server/api/routers/pcListings.test.ts b/src/server/api/routers/pcListings.test.ts index b6895311e..66aa1f125 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,47 @@ 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, + payload: { + reportId: '00000000-0000-4000-a000-000000000030', + contentId: LISTING_ID, + contentType: 'PC Compatibility Report', + actionUrl: `/admin/reports?pcListing=${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..4e64b05c9 100644 --- a/src/server/api/routers/pcListings.ts +++ b/src/server/api/routers/pcListings.ts @@ -61,6 +61,7 @@ import { invalidatePcListingsSeo, } from '@/server/cache/invalidation' import { NOTIFICATION_EVENTS, notificationEventEmitter } from '@/server/notifications/eventEmitter' +import { emitReportCreatedNotification } from '@/server/notifications/reportEvents' import { PcListingsRepository } from '@/server/repositories/pc-listings.repository' import { UserPcPresetsRepository } from '@/server/repositories/user-pc-presets.repository' import { logAudit } from '@/server/services/audit.service' @@ -76,7 +77,7 @@ import { listingStatsCache } from '@/server/utils/cache' import { normalizeCustomFieldValues } from '@/server/utils/custom-field-values' import { paginate } from '@/server/utils/pagination' import { isUserBanned } from '@/server/utils/query-builders' -import { validatePagination } from '@/server/utils/security-validation' +import { sanitizeInput, validatePagination } from '@/server/utils/security-validation' import { checkSpamContent } from '@/server/utils/spam-check' import { updatePcListingVoteCounts } from '@/server/utils/vote-counts' import { @@ -1687,8 +1688,8 @@ export const pcListingsRouter = createTRPCRouter({ .mutation(async ({ ctx, input }) => { const { pcListingId, reason, description } = input const userId = ctx.session.user.id + const sanitizedDescription = description ? sanitizeInput(description) : description - // Check if PC listing exists const pcListing = await ctx.prisma.pcListing.findUnique({ where: { id: pcListingId }, include: { author: true }, @@ -1698,12 +1699,10 @@ export const pcListingsRouter = createTRPCRouter({ 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: { @@ -1717,12 +1716,12 @@ export const pcListingsRouter = createTRPCRouter({ return AppError.badRequest('You have already reported this listing') } - return await ctx.prisma.pcListingReport.create({ + const report = await ctx.prisma.pcListingReport.create({ data: { pcListingId, reportedById: userId, reason, - description, + description: sanitizedDescription, }, include: { pcListing: { @@ -1733,6 +1732,15 @@ export const pcListingsRouter = createTRPCRouter({ }, }, }) + + emitReportCreatedNotification({ + type: 'pcListing', + reportId: report.id, + pcListingId, + reportedById: userId, + }) + + return report }), getReports: permissionProcedure(PERMISSIONS.VIEW_USER_BANS) diff --git a/src/server/notifications/eventEmitter.ts b/src/server/notifications/eventEmitter.ts index e2bd4e9ed..1cee9b6d7 100644 --- a/src/server/notifications/eventEmitter.ts +++ b/src/server/notifications/eventEmitter.ts @@ -60,6 +60,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..5e1676600 --- /dev/null +++ b/src/server/notifications/reportEvents.ts @@ -0,0 +1,40 @@ +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, + payload: { + reportId: input.reportId, + contentId, + contentType: isPcListing ? 'PC Compatibility Report' : 'Compatibility Report', + actionUrl: isPcListing + ? `/admin/reports?pcListing=${contentId}` + : `/admin/reports?listing=${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..c37b2bf2f 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,31 @@ 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', + 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 +544,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..b500018de 100644 --- a/src/server/notifications/service.ts +++ b/src/server/notifications/service.ts @@ -17,7 +17,11 @@ import { Role, } from '@orm/client' import { createEmailService } from './emailService' -import { type NotificationEventData, notificationEventEmitter } from './eventEmitter' +import { + NOTIFICATION_EVENTS, + type NotificationEventData, + notificationEventEmitter, +} from './eventEmitter' import { notificationRateLimitService } from './rateLimitService' import { notificationTemplateEngine, type TemplateContext } from './templates' import type { @@ -673,7 +677,7 @@ export class NotificationService { break } - if (eventData.triggeredBy) { + if (eventData.triggeredBy && eventData.eventType !== NOTIFICATION_EVENTS.REPORT_CREATED) { const actorId = eventData.triggeredBy for (let i = userIds.length - 1; i >= 0; i--) { if (userIds[i] === actorId) userIds.splice(i, 1) From c596f6446ffcb402ed9d365d08334e979aa247af Mon Sep 17 00:00:00 2001 From: Producdevity Date: Sat, 6 Jun 2026 16:59:51 +0200 Subject: [PATCH 2/3] Refine report notification handling --- .../ActivityCard/ReportActivityItem.tsx | 4 +- src/server/api/routers/listingReports.test.ts | 3 +- src/server/api/routers/listingReports.ts | 51 +------ .../api/routers/mobile/listingReports.test.ts | 3 +- .../api/routers/mobile/listingReports.ts | 43 +----- src/server/api/routers/pcListings.test.ts | 3 +- src/server/api/routers/pcListings.ts | 57 +------- src/server/notifications/eventEmitter.ts | 1 + src/server/notifications/reportEvents.ts | 5 +- src/server/notifications/service.test.ts | 1 + src/server/notifications/service.ts | 3 +- .../services/report-submission.service.ts | 130 ++++++++++++++++++ 12 files changed, 160 insertions(+), 144 deletions(-) create mode 100644 src/server/services/report-submission.service.ts 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/server/api/routers/listingReports.test.ts b/src/server/api/routers/listingReports.test.ts index 59849ec62..4bf5115bf 100644 --- a/src/server/api/routers/listingReports.test.ts +++ b/src/server/api/routers/listingReports.test.ts @@ -108,11 +108,12 @@ describe('listingReportsRouter create', () => { entityType: 'listingReport', entityId: REPORT_ID, triggeredBy: USER_ID, + includeTriggeredBy: true, payload: { reportId: REPORT_ID, contentId: LISTING_ID, contentType: 'Compatibility Report', - actionUrl: `/admin/reports?listing=${LISTING_ID}`, + actionUrl: `/listings/${LISTING_ID}`, listingId: LISTING_ID, }, }) diff --git a/src/server/api/routers/listingReports.ts b/src/server/api/routers/listingReports.ts index 5c999f48b..d780346c7 100644 --- a/src/server/api/routers/listingReports.ts +++ b/src/server/api/routers/listingReports.ts @@ -15,8 +15,8 @@ import { protectedProcedure, publicProcedure, } from '@/server/api/trpc' -import { emitReportCreatedNotification } from '@/server/notifications/reportEvents' 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' @@ -134,55 +134,14 @@ export const listingReportsRouter = createTRPCRouter({ validateEnum(reason, Object.values(ReportReason), 'reason') - const sanitizedDescription = description ? sanitizeInput(description) : description + const reportSubmissionService = new ReportSubmissionService(ctx.prisma) - const listing = await ctx.prisma.listing.findUnique({ - where: { id: listingId }, - include: { author: true }, - }) - - if (!listing) return ResourceError.listing.notFound() - - if (listing.authorId === userId) { - return ResourceError.listingReport.cannotReportOwnListing() - } - - const existingReport = await ctx.prisma.listingReport.findUnique({ - where: { - listingId_reportedById: { - listingId, - reportedById: userId, - }, - }, - }) - - if (existingReport) return ResourceError.listingReport.alreadyExists() - - const report = await ctx.prisma.listingReport.create({ - data: { - listingId, - reportedById: userId, - reason, - description: sanitizedDescription, - }, - include: { - listing: { - include: { - game: { select: { title: true } }, - author: { select: { name: true } }, - }, - }, - }, - }) - - emitReportCreatedNotification({ - type: 'listing', - reportId: report.id, + return await reportSubmissionService.createListingReport({ listingId, reportedById: userId, + reason, + description, }) - - return report }), updateStatus: permissionProcedure(PERMISSIONS.MANAGE_USER_BANS) diff --git a/src/server/api/routers/mobile/listingReports.test.ts b/src/server/api/routers/mobile/listingReports.test.ts index ac7a1deec..d1671b9c8 100644 --- a/src/server/api/routers/mobile/listingReports.test.ts +++ b/src/server/api/routers/mobile/listingReports.test.ts @@ -112,11 +112,12 @@ describe('mobileListingReportsRouter create', () => { entityType: 'listingReport', entityId: REPORT_ID, triggeredBy: USER_ID, + includeTriggeredBy: true, payload: { reportId: REPORT_ID, contentId: LISTING_ID, contentType: 'Compatibility Report', - actionUrl: `/admin/reports?listing=${LISTING_ID}`, + 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 8ef725d05..e316538ee 100644 --- a/src/server/api/routers/mobile/listingReports.ts +++ b/src/server/api/routers/mobile/listingReports.ts @@ -1,13 +1,11 @@ -import { AppError, ResourceError } from '@/lib/errors' import { CreateListingReportSchema, GetUserReportStatsSchema } from '@/schemas/listingReport' import { createMobileTRPCRouter, mobileProtectedProcedure, mobilePublicProcedure, } from '@/server/api/mobileContext' -import { emitReportCreatedNotification } from '@/server/notifications/reportEvents' import { getAuthorReportCounts } from '@/server/services/report-stats.service' -import { sanitizeInput } from '@/server/utils/security-validation' +import { ReportSubmissionService } from '@/server/services/report-submission.service' export const mobileListingReportsRouter = createMobileTRPCRouter({ create: mobileProtectedProcedure @@ -15,44 +13,13 @@ export const mobileListingReportsRouter = createMobileTRPCRouter({ .mutation(async ({ ctx, input }) => { const { listingId, reason, description } = input const userId = ctx.session.user.id - const sanitizedDescription = description ? sanitizeInput(description) : description - const listing = await ctx.prisma.listing.findUnique({ - where: { id: listingId }, - include: { author: true }, - }) - - if (!listing) return ResourceError.listing.notFound() - - if (listing.authorId === userId) { - return AppError.badRequest('You cannot report your own 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: sanitizedDescription }, - include: { - listing: { - include: { - game: { select: { title: true } }, - author: { select: { name: true } }, - }, - }, - }, - }) - - emitReportCreatedNotification({ - type: 'listing', - reportId: report.id, + 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 66aa1f125..d60abece5 100644 --- a/src/server/api/routers/pcListings.test.ts +++ b/src/server/api/routers/pcListings.test.ts @@ -610,11 +610,12 @@ describe('pcListings trust integration', () => { 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: `/admin/reports?pcListing=${LISTING_ID}`, + actionUrl: `/pc-listings/${LISTING_ID}`, pcListingId: LISTING_ID, }, }) diff --git a/src/server/api/routers/pcListings.ts b/src/server/api/routers/pcListings.ts index 4e64b05c9..b82a3bfb1 100644 --- a/src/server/api/routers/pcListings.ts +++ b/src/server/api/routers/pcListings.ts @@ -61,10 +61,10 @@ import { invalidatePcListingsSeo, } from '@/server/cache/invalidation' import { NOTIFICATION_EVENTS, notificationEventEmitter } from '@/server/notifications/eventEmitter' -import { emitReportCreatedNotification } from '@/server/notifications/reportEvents' 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, @@ -77,7 +77,7 @@ import { listingStatsCache } from '@/server/utils/cache' import { normalizeCustomFieldValues } from '@/server/utils/custom-field-values' import { paginate } from '@/server/utils/pagination' import { isUserBanned } from '@/server/utils/query-builders' -import { sanitizeInput, validatePagination } from '@/server/utils/security-validation' +import { validatePagination } from '@/server/utils/security-validation' import { checkSpamContent } from '@/server/utils/spam-check' import { updatePcListingVoteCounts } from '@/server/utils/vote-counts' import { @@ -1688,59 +1688,14 @@ export const pcListingsRouter = createTRPCRouter({ .mutation(async ({ ctx, input }) => { const { pcListingId, reason, description } = input const userId = ctx.session.user.id - const sanitizedDescription = description ? sanitizeInput(description) : description + const reportSubmissionService = new ReportSubmissionService(ctx.prisma) - const pcListing = await ctx.prisma.pcListing.findUnique({ - where: { id: pcListingId }, - include: { author: true }, - }) - - if (!pcListing) { - return ResourceError.pcListing.notFound() - } - - if (pcListing.authorId === userId) { - return AppError.badRequest('You cannot report your own 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') - } - - const report = await ctx.prisma.pcListingReport.create({ - data: { - pcListingId, - reportedById: userId, - reason, - description: sanitizedDescription, - }, - include: { - pcListing: { - include: { - game: { select: { title: true } }, - author: { select: { name: true } }, - }, - }, - }, - }) - - emitReportCreatedNotification({ - type: 'pcListing', - reportId: report.id, + return await reportSubmissionService.createPcListingReport({ pcListingId, reportedById: userId, + reason, + description, }) - - return report }), getReports: permissionProcedure(PERMISSIONS.VIEW_USER_BANS) diff --git a/src/server/notifications/eventEmitter.ts b/src/server/notifications/eventEmitter.ts index 1cee9b6d7..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 } diff --git a/src/server/notifications/reportEvents.ts b/src/server/notifications/reportEvents.ts index 5e1676600..0ddcf222d 100644 --- a/src/server/notifications/reportEvents.ts +++ b/src/server/notifications/reportEvents.ts @@ -27,13 +27,14 @@ export function emitReportCreatedNotification(input: ReportNotificationInput): v 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 - ? `/admin/reports?pcListing=${contentId}` - : `/admin/reports?listing=${contentId}`, + ? `/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 c37b2bf2f..6ba9a68f9 100644 --- a/src/server/notifications/service.test.ts +++ b/src/server/notifications/service.test.ts @@ -278,6 +278,7 @@ describe('NotificationService', () => { entityType: 'listingReport', entityId: 'report-1', triggeredBy: 'reporter-1', + includeTriggeredBy: true, payload: { reportId: 'report-1', listingId: 'listing-1' }, }), ) diff --git a/src/server/notifications/service.ts b/src/server/notifications/service.ts index b500018de..3085c1156 100644 --- a/src/server/notifications/service.ts +++ b/src/server/notifications/service.ts @@ -18,7 +18,6 @@ import { } from '@orm/client' import { createEmailService } from './emailService' import { - NOTIFICATION_EVENTS, type NotificationEventData, notificationEventEmitter, } from './eventEmitter' @@ -677,7 +676,7 @@ export class NotificationService { break } - if (eventData.triggeredBy && eventData.eventType !== NOTIFICATION_EVENTS.REPORT_CREATED) { + 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..da6632bf8 --- /dev/null +++ b/src/server/services/report-submission.service.ts @@ -0,0 +1,130 @@ +import { AppError, 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 AppError.forbidden('You cannot report your own listing') + } + + const existingReport = await this.prisma.pcListingReport.findUnique({ + where: { + pcListingId_reportedById: { + pcListingId: input.pcListingId, + reportedById: input.reportedById, + }, + }, + }) + + if (existingReport) return AppError.conflict('You have already reported this listing') + + 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 + } +} From 7142dd18cd9e2f2780805e3fdb48926f4f5c7772 Mon Sep 17 00:00:00 2001 From: Producdevity Date: Sat, 6 Jun 2026 17:34:27 +0200 Subject: [PATCH 3/3] Align report submission errors --- src/lib/errors.ts | 6 ++++++ src/server/services/report-submission.service.ts | 6 +++--- 2 files changed, 9 insertions(+), 3 deletions(-) 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/services/report-submission.service.ts b/src/server/services/report-submission.service.ts index da6632bf8..f8b735181 100644 --- a/src/server/services/report-submission.service.ts +++ b/src/server/services/report-submission.service.ts @@ -1,4 +1,4 @@ -import { AppError, ResourceError } from '@/lib/errors' +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' @@ -87,7 +87,7 @@ export class ReportSubmissionService { if (!pcListing) return ResourceError.pcListing.notFound() if (pcListing.authorId === input.reportedById) { - return AppError.forbidden('You cannot report your own listing') + return ResourceError.pcListingReport.cannotReportOwnListing() } const existingReport = await this.prisma.pcListingReport.findUnique({ @@ -99,7 +99,7 @@ export class ReportSubmissionService { }, }) - if (existingReport) return AppError.conflict('You have already reported this listing') + if (existingReport) return ResourceError.pcListingReport.alreadyExists() const report = await this.prisma.pcListingReport.create({ data: {