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 + } +}