diff --git a/src/app/pc-listings/[id]/components/PcReportListingModal.tsx b/src/app/pc-listings/[id]/components/PcReportListingModal.tsx index e1e71795..d99a1722 100644 --- a/src/app/pc-listings/[id]/components/PcReportListingModal.tsx +++ b/src/app/pc-listings/[id]/components/PcReportListingModal.tsx @@ -39,7 +39,7 @@ function PcReportListingModal(props: Props) { const [description, setDescription] = useState('') const [error, setError] = useState('') - const createReport = api.pcListings.createReport.useMutation() + const createReport = api.pcListingReports.create.useMutation() const { user } = useUser() // Reset form when modal opens/closes @@ -64,7 +64,7 @@ function PcReportListingModal(props: Props) { pcListingId: props.pcListingId, reason, description: description.trim() || undefined, - } satisfies RouterInput['pcListings']['createReport']) + } satisfies RouterInput['pcListingReports']['create']) // Track content flagging in analytics if (user?.id) { diff --git a/src/schemas/pcListing.ts b/src/schemas/pcListing.ts index 85bbcd29..40e1428f 100644 --- a/src/schemas/pcListing.ts +++ b/src/schemas/pcListing.ts @@ -1,7 +1,7 @@ import { z } from 'zod' import { PAGINATION, CHAR_LIMITS } from '@/data/constants' import { HumanVerificationTokenSchema } from '@/features/human-verification/shared/schema' -import { JsonValueSchema } from '@/schemas/common' +import { JsonValueSchema, SortDirectionSchema } from '@/schemas/common' import { CreatePcListingBaseSchema } from '@/schemas/listingCreate' import { REVIEW_RISK_FILTERS, ReviewRiskFilterSchema } from '@/schemas/submissionRisk' import { ApprovalStatus, PcOs, ReportReason, ReportStatus } from '@orm' @@ -274,6 +274,8 @@ export const UnpinPcListingCommentSchema = z.object({ }) // PC Listing Report schemas +export const PcListingReportSortField = z.enum(['createdAt', 'updatedAt', 'status', 'reason']) + export const CreatePcListingReportSchema = z.object({ pcListingId: z.string().uuid(), reason: z.nativeEnum(ReportReason), @@ -286,11 +288,17 @@ export const UpdatePcListingReportSchema = z.object({ reviewNotes: z.string().max(1000).optional(), }) -export const GetPcListingReportsSchema = z.object({ - status: z.nativeEnum(ReportStatus).optional(), - page: z.number().min(1).default(1), - limit: z.number().min(1).max(100).default(20), -}) +export const GetPcListingReportsSchema = z + .object({ + search: z.string().optional(), + status: z.nativeEnum(ReportStatus).optional(), + reason: z.nativeEnum(ReportReason).optional(), + sortField: PcListingReportSortField.optional(), + sortDirection: SortDirectionSchema.optional(), + page: z.number().min(1).default(1), + limit: z.number().min(1).max(100).default(20), + }) + .optional() // PC Listing Verification schemas export const VerifyPcListingSchema = z.object({ diff --git a/src/server/api/root.ts b/src/server/api/root.ts index a9949f3e..506b1962 100644 --- a/src/server/api/root.ts +++ b/src/server/api/root.ts @@ -25,6 +25,7 @@ import { listingsRouter } from './routers/listings' import { listingVerificationsRouter } from './routers/listingVerifications' import { mobileRouter } from './routers/mobile' import { notificationsRouter } from './routers/notifications' +import { pcListingReportsRouter } from './routers/pcListingReports' import { pcListingsRouter } from './routers/pcListings' import { performanceScalesRouter } from './routers/performanceScales' import { permissionLogsRouter } from './routers/permissionLogs' @@ -47,6 +48,7 @@ export const appRouter = createTRPCRouter({ activity: activityRouter, listings: listingsRouter, pcListings: pcListingsRouter, + pcListingReports: pcListingReportsRouter, apiKeys: apiKeysRouter, devices: devicesRouter, cpus: cpusRouter, diff --git a/src/server/api/routers/listings/core.ts b/src/server/api/routers/listings/core.ts index 405fd22a..02bd0801 100644 --- a/src/server/api/routers/listings/core.ts +++ b/src/server/api/routers/listings/core.ts @@ -32,12 +32,12 @@ import { isUserBanned } from '@/server/utils/query-builders' import { sanitizeInput, validatePagination } from '@/server/utils/security-validation' import { checkSpamContent } from '@/server/utils/spam-check' import { withSavepoint } from '@/server/utils/transactions' +import { validateCustomFields } from '@/server/utils/validate-custom-fields' import { updateListingVoteCounts } from '@/server/utils/vote-counts' import { handleListingVoteTrustEffects } from '@/server/utils/vote-trust-effects' import { roleIncludesRole } from '@/utils/permission-system' import { ms } from '@/utils/time' import { ApprovalStatus, Prisma, Role, TrustAction } from '@orm/client' -import { validateCustomFields } from './validation' const EDIT_TIME_LIMIT_MINUTES = 60 const EDIT_TIME_LIMIT = ms.minutes(EDIT_TIME_LIMIT_MINUTES) diff --git a/src/server/api/routers/listings/index.ts b/src/server/api/routers/listings/index.ts index f02fc174..eeaf3e74 100644 --- a/src/server/api/routers/listings/index.ts +++ b/src/server/api/routers/listings/index.ts @@ -1,4 +1,3 @@ export { coreRouter } from './core' export { commentsRouter } from './comments' export { adminRouter } from './admin' -export { validateCustomFields } from './validation' diff --git a/src/server/api/routers/pcListingReports.ts b/src/server/api/routers/pcListingReports.ts new file mode 100644 index 00000000..6f0b83a4 --- /dev/null +++ b/src/server/api/routers/pcListingReports.ts @@ -0,0 +1,228 @@ +import { ResourceError } from '@/lib/errors' +import { TrustService } from '@/lib/trust/service' +import { DeleteReportSchema, GetReportByIdSchema } from '@/schemas/listingReport' +import { + CreatePcListingReportSchema, + GetPcListingReportsSchema, + UpdatePcListingReportSchema, +} from '@/schemas/pcListing' +import { createTRPCRouter, permissionProcedure, protectedProcedure } from '@/server/api/trpc' +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' +import { ApprovalStatus, ReportReason, ReportStatus, TrustAction } from '@orm' +import { type Prisma } from '@orm/client' + +export const pcListingReportsRouter = createTRPCRouter({ + stats: permissionProcedure(PERMISSIONS.VIEW_STATISTICS).query(async ({ ctx }) => { + const [pending, underReview, resolved, dismissed] = await Promise.all([ + ctx.prisma.pcListingReport.count({ where: { status: ReportStatus.PENDING } }), + ctx.prisma.pcListingReport.count({ where: { status: ReportStatus.UNDER_REVIEW } }), + ctx.prisma.pcListingReport.count({ where: { status: ReportStatus.RESOLVED } }), + ctx.prisma.pcListingReport.count({ where: { status: ReportStatus.DISMISSED } }), + ]) + + return { + total: pending + underReview + resolved + dismissed, + pending, + underReview, + resolved, + dismissed, + } + }), + + get: permissionProcedure(PERMISSIONS.VIEW_USER_BANS) + .input(GetPcListingReportsSchema) + .query(async ({ ctx, input }) => { + const { + search, + status, + reason, + sortField = 'createdAt', + sortDirection = 'desc', + } = input ?? {} + + const { page, limit } = validatePagination(input?.page, input?.limit, 50) + const sanitizedSearch = search ? sanitizeInput(search) : undefined + const offset = (page - 1) * limit + + const where: Prisma.PcListingReportWhereInput = {} + + if (sanitizedSearch) { + where.OR = [ + { pcListing: { game: { title: { contains: sanitizedSearch, mode: 'insensitive' } } } }, + { reportedBy: { name: { contains: sanitizedSearch, mode: 'insensitive' } } }, + { description: { contains: sanitizedSearch, mode: 'insensitive' } }, + ] + } + + if (status) where.status = status + if (reason) where.reason = reason + + const orderBy: Prisma.PcListingReportOrderByWithRelationInput = {} + if (sortField && sortDirection) orderBy[sortField] = sortDirection + + const [reports, total] = await Promise.all([ + ctx.prisma.pcListingReport.findMany({ + where, + orderBy, + skip: offset, + take: limit, + include: { + pcListing: { + include: { + game: { select: { id: true, title: true } }, + author: { select: { id: true, name: true } }, + cpu: true, + gpu: true, + emulator: { select: { id: true, name: true } }, + }, + }, + reportedBy: { select: { id: true, name: true, email: true } }, + reviewedBy: { select: { id: true, name: true } }, + }, + }), + ctx.prisma.pcListingReport.count({ where }), + ]) + + return { + reports, + pagination: paginate({ total: total, page, limit: limit }), + } + }), + + byId: permissionProcedure(PERMISSIONS.VIEW_USER_BANS) + .input(GetReportByIdSchema) + .query(async ({ ctx, input }) => { + const report = await ctx.prisma.pcListingReport.findUnique({ + where: { id: input.id }, + include: { + pcListing: { + include: { + game: true, + author: { select: { id: true, name: true, email: true } }, + cpu: true, + gpu: true, + emulator: true, + performance: true, + }, + }, + reportedBy: { select: { id: true, name: true, email: true } }, + reviewedBy: { select: { id: true, name: true } }, + }, + }) + + return report || ResourceError.listingReport.notFound() + }), + + create: protectedProcedure.input(CreatePcListingReportSchema).mutation(async ({ ctx, input }) => { + const { pcListingId, reason, description } = input + const userId = ctx.session.user.id + + validateEnum(reason, Object.values(ReportReason), 'reason') + + const reportSubmissionService = new ReportSubmissionService(ctx.prisma) + + return await reportSubmissionService.createPcListingReport({ + pcListingId, + reportedById: userId, + reason, + description, + }) + }), + + updateStatus: permissionProcedure(PERMISSIONS.MANAGE_USER_BANS) + .input(UpdatePcListingReportSchema) + .mutation(async ({ ctx, input }) => { + const { reportId, status, reviewNotes } = input + const reviewerId = ctx.session.user.id + + validateEnum(status, Object.values(ReportStatus), 'status') + + const report = await ctx.prisma.pcListingReport.findUnique({ + where: { id: reportId }, + include: { pcListing: true }, + }) + + if (!report) { + return ResourceError.listingReport.notFound() + } + + if ( + status === ReportStatus.RESOLVED && + report.pcListing?.status === ApprovalStatus.APPROVED + ) { + await ctx.prisma.pcListing.update({ + where: { id: report.pcListingId }, + data: { + status: ApprovalStatus.REJECTED, + processedAt: new Date(), + processedByUserId: reviewerId, + processedNotes: `Rejected due to report: ${reviewNotes || 'No additional notes'}`, + }, + }) + } + + const trustService = new TrustService(ctx.prisma) + + if (status === ReportStatus.RESOLVED) { + await trustService.logAction({ + userId: report.reportedById, + action: TrustAction.REPORT_CONFIRMED, + metadata: { + reportId, + pcListingId: report.pcListingId, + reviewedBy: reviewerId, + reason: report.reason, + }, + }) + } else if (status === ReportStatus.DISMISSED) { + await trustService.logAction({ + userId: report.reportedById, + action: TrustAction.FALSE_REPORT, + metadata: { + reportId, + pcListingId: report.pcListingId, + reviewedBy: reviewerId, + reason: report.reason, + reviewNotes, + }, + }) + } + + return ctx.prisma.pcListingReport.update({ + where: { id: reportId }, + data: { + status, + reviewNotes, + reviewedById: reviewerId, + reviewedAt: new Date(), + }, + include: { + pcListing: { + include: { + game: { select: { title: true } }, + author: { select: { name: true } }, + }, + }, + reportedBy: { select: { name: true } }, + reviewedBy: { select: { name: true } }, + }, + }) + }), + + delete: permissionProcedure(PERMISSIONS.MANAGE_USER_BANS) + .input(DeleteReportSchema) + .mutation(async ({ ctx, input }) => { + const report = await ctx.prisma.pcListingReport.findUnique({ + where: { id: input.id }, + }) + + if (!report) return ResourceError.listingReport.notFound() + + return ctx.prisma.pcListingReport.delete({ + where: { id: input.id }, + }) + }), +}) diff --git a/src/server/api/routers/pcListings.test.ts b/src/server/api/routers/pcListings.test.ts index 3144e2e6..355a86f7 100644 --- a/src/server/api/routers/pcListings.test.ts +++ b/src/server/api/routers/pcListings.test.ts @@ -581,48 +581,6 @@ 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 7e4fb11a..7cfc1937 100644 --- a/src/server/api/routers/pcListings.ts +++ b/src/server/api/routers/pcListings.ts @@ -1,2043 +1,34 @@ -import analytics from '@/lib/analytics' -import { AppError, ResourceError } from '@/lib/errors' -import { applyTrustAction, TrustService } from '@/lib/trust/service' -import { - ApprovePcListingSchema, - BulkApprovePcListingsSchema, - BulkRejectPcListingsSchema, - CreatePcListingCommentSchema, - CreatePcListingReportSchema, - CreatePcListingSchema, - CreatePcPresetSchema, - ResetPcListingToPendingSchema, - DeletePcListingCommentSchema, - DeletePcListingSchema, - DeletePcPresetSchema, - GetAllPcListingsAdminSchema, - GetPcListingByIdSchema, - GetPcListingCommentsSchema, - GetPcListingForAdminEditSchema, - GetPcListingForUserEditSchema, - GetPcListingReportsSchema, - GetPcListingsSchema, - GetPcListingUserVoteSchema, - GetPcListingVerificationsSchema, - GetPcPresetsSchema, - GetPendingPcListingsSchema, - GetProcessedPcSchema, - OverridePcApprovalStatusSchema, - PinPcListingCommentSchema, - RejectPcListingSchema, - RemovePcListingVerificationSchema, - UnpinPcListingCommentSchema, - UpdatePcListingAdminSchema, - UpdatePcListingCommentSchema, - UpdatePcListingReportSchema, - UpdatePcListingUserSchema, - UpdatePcPresetSchema, - VerifyPcListingAdminSchema, - VotePcListingCommentSchema, - VotePcListingSchema, -} from '@/schemas/pcListing' -import { - createListingProcedure, - createTRPCRouter, - adminProcedure, - moderatorProcedure, - permissionProcedure, - protectedProcedure, - publicProcedure, - superAdminProcedure, - viewStatisticsProcedure, -} from '@/server/api/trpc' -import { buildCommentTree, findCommentWithParent } from '@/server/api/utils/commentTree' -import { - buildPcListingOrderBy, - buildPcListingWhere, - buildProcessedPcListingOrderBy, - pcListingAdminInclude, - pcListingDetailInclude, -} from '@/server/api/utils/pcListingHelpers' -import { canManageCommentPins } from '@/server/api/utils/pinPermissions' -import { getProcessedStatusTrustAction } from '@/server/api/utils/processedStatusTrust' -import { - invalidatePcListingSeo, - invalidatePcListingSeoForUpdate, - invalidatePcListingsSeo, -} from '@/server/cache/invalidation' -import { NOTIFICATION_EVENTS, notificationEventEmitter } from '@/server/notifications/eventEmitter' -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, - attachReviewRiskProfileForViewer, - computeReviewRiskProfiles, - getAutoRejectableReviewRiskPreviewForCandidates, - getRiskOnlyReviewPage, -} from '@/server/services/review-risk.service' -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 { checkSpamContent } from '@/server/utils/spam-check' -import { updatePcListingVoteCounts } from '@/server/utils/vote-counts' -import { - handleCommentVoteTrustEffects, - handleListingVoteTrustEffects, -} from '@/server/utils/vote-trust-effects' -import { PERMISSIONS, roleIncludesRole } from '@/utils/permission-system' -import { - canDeleteComment, - canEditComment, - hasRolePermission, - isModerator, -} from '@/utils/permissions' -import { ApprovalStatus, AuditAction, AuditEntityType, ReportStatus, Role, TrustAction } from '@orm' -import { Prisma } from '@orm/client' - -function isJsonRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value) -} - -function toPrismaNestedJsonValue(value: unknown): Prisma.InputJsonValue | null { - if (value === null) return null - if (typeof value === 'string') return value - if (typeof value === 'number') return value - if (typeof value === 'boolean') return value - if (Array.isArray(value)) return value.map(toPrismaNestedJsonValue) - if (isJsonRecord(value)) { - const result: Record = {} - for (const [key, entryValue] of Object.entries(value)) { - result[key] = toPrismaNestedJsonValue(entryValue) - } - - return result - } - - return AppError.invalidInput('customFieldValues') -} - -function toPrismaCustomFieldValue(value: unknown): Prisma.InputJsonValue | typeof Prisma.JsonNull { - if (value === undefined) return Prisma.JsonNull - - const normalizedValue = toPrismaNestedJsonValue(value) - if (normalizedValue === null) return Prisma.JsonNull - - return normalizedValue -} +import { createTRPCRouter } from '@/server/api/trpc' +import { adminRouter } from './pcListings/admin' +import { commentsRouter } from './pcListings/comments' +import { coreRouter } from './pcListings/core' export const pcListingsRouter = createTRPCRouter({ - // PC Listing procedures - get: publicProcedure.input(GetPcListingsSchema).query(async ({ ctx, input }) => { - const repository = new PcListingsRepository(ctx.prisma) - const canSeeBannedUsers = ctx.session?.user ? isModerator(ctx.session.user.role) : false - - // Validate and sanitize pagination parameters - const { page, limit } = validatePagination(input.page, input.limit, 50) - - const result = await repository.list({ - ...input, - sortDirection: input.sortDirection ?? undefined, - userId: ctx.session?.user?.id, - userRole: ctx.session?.user?.role, - showNsfw: ctx.session?.user?.showNsfw, - canSeeBannedUsers, - approvalStatus: input.approvalStatus || ApprovalStatus.APPROVED, - page, - limit, - }) - - return { - pcListings: result.pcListings, - pagination: result.pagination, - } - }), - - byId: publicProcedure.input(GetPcListingByIdSchema).query(async ({ ctx, input }) => { - const repository = new PcListingsRepository(ctx.prisma) - const userRole = ctx.session?.user?.role - const canSeeBannedUsers = userRole ? isModerator(userRole) : false - - const pcListing = await repository.getByIdWithDetails( - input.id, - canSeeBannedUsers, - ctx.session?.user?.id, - ) - - if (!pcListing) return ResourceError.pcListing.notFound() - - return await attachReviewRiskProfileForViewer({ - prisma: ctx.prisma, - listing: pcListing, - userRole, - }) - }), - - canEdit: protectedProcedure.input(GetPcListingForUserEditSchema).query(async ({ ctx, input }) => { - const EDIT_TIME_LIMIT_MINUTES = 60 - - const pcListing = await ctx.prisma.pcListing.findUnique({ - where: { id: input.id }, - select: { authorId: true, status: true, processedAt: true }, - }) - - if (!pcListing) { - return { - canEdit: false, - isOwner: false, - reason: 'PC listing not found', - } - } - - // Check ownership - const isOwner = pcListing.authorId === ctx.session.user.id - - // Moderators and higher can always edit any PC listing (but still reflect true ownership) - if (hasRolePermission(ctx.session.user.role, Role.MODERATOR)) { - return { - canEdit: true, - isOwner, - reason: 'Moderator can edit any PC listing', - } - } - - if (!isOwner) { - return { canEdit: false, isOwner: false, reason: 'Not your PC listing' } - } - - // PENDING PC listings can always be edited by the author - if (pcListing.status === ApprovalStatus.PENDING) { - return { - canEdit: true, - isOwner: true, - reason: 'Pending PC listings can always be edited', - isPending: true, - } - } - - // REJECTED PC listings cannot be edited - if (pcListing.status === ApprovalStatus.REJECTED) { - return { - canEdit: false, - isOwner: true, - reason: 'Rejected PC listings cannot be edited. Please create a new listing.', - } - } - - // APPROVED PC listings can be edited for 1 hour after approval - if (pcListing.status === ApprovalStatus.APPROVED) { - if (!pcListing.processedAt) { - return { - canEdit: false, - isOwner: true, - reason: 'No approval time found', - } - } - - const now = new Date() - const timeSinceApproval = now.getTime() - pcListing.processedAt.getTime() - const timeLimit = EDIT_TIME_LIMIT_MINUTES * 60 * 1000 - - const remainingTime = timeLimit - timeSinceApproval - const remainingMinutes = Math.floor(remainingTime / (60 * 1000)) - - if (timeSinceApproval > timeLimit) { - return { - canEdit: false, - isOwner: true, - reason: `Edit time expired (${EDIT_TIME_LIMIT_MINUTES} minutes after approval)`, - timeExpired: true, - } - } - - return { - canEdit: true, - isOwner: true, - remainingMinutes: Math.max(0, remainingMinutes), - remainingTime: Math.max(0, remainingTime), - isApproved: true, - } - } - - return { - canEdit: false, - isOwner: true, - reason: 'Invalid PC listing status', - } - }), - - getForUserEdit: protectedProcedure - .input(GetPcListingForUserEditSchema) - .query(async ({ ctx, input }) => { - const pcListing = await ctx.prisma.pcListing.findUnique({ - where: { id: input.id }, - include: { - ...pcListingDetailInclude, - emulator: { - include: { - customFieldDefinitions: { - orderBy: [{ categoryId: 'asc' }, { categoryOrder: 'asc' }, { displayOrder: 'asc' }], - }, - }, - }, - }, - }) - - if (!pcListing) return ResourceError.pcListing.notFound() - - // Only allow owners or moderators to fetch for editing - if ( - pcListing.authorId !== ctx.session.user.id && - !roleIncludesRole(ctx.session.user.role, Role.MODERATOR) - ) { - return ResourceError.pcListing.canOnlyEditOwn() - } - - return pcListing - }), - - create: createListingProcedure.input(CreatePcListingSchema).mutation(async ({ ctx, input }) => { - const { humanVerificationToken, ...payload } = input - const authorId = ctx.session.user.id - - await checkSpamContent({ - prisma: ctx.prisma, - userId: authorId, - content: payload.notes ?? '', - entityType: 'pcListing', - challengeMode: 'challenge', - humanVerificationToken, - headers: ctx.headers, - }) - - const repository = new PcListingsRepository(ctx.prisma) - const newListing = await repository.create({ - authorId, - userRole: ctx.session.user.role, - gameId: payload.gameId, - cpuId: payload.cpuId, - gpuId: payload.gpuId ?? null, - emulatorId: payload.emulatorId, - performanceId: payload.performanceId, - memorySize: payload.memorySize, - os: payload.os, - osVersion: payload.osVersion, - notes: payload.notes ?? null, - customFieldValues: normalizeCustomFieldValues(payload.customFieldValues), - }) - - await applyTrustAction({ - userId: authorId, - action: TrustAction.LISTING_CREATED, - context: { pcListingId: newListing.id }, - }) - - // Invalidate stats cache when PC listing is created - listingStatsCache.delete('pc-listing-stats') - - if (newListing.status === ApprovalStatus.APPROVED) { - await invalidatePcListingSeo({ - id: newListing.id, - gameId: payload.gameId, - cpuId: payload.cpuId, - gpuId: payload.gpuId ?? null, - }) - } - - return newListing - }), - - delete: protectedProcedure.input(DeletePcListingSchema).mutation(async ({ ctx, input }) => { - const pcListing = await ctx.prisma.pcListing.findUnique({ - where: { id: input.id }, - }) - - if (!pcListing) return ResourceError.pcListing.notFound() - - // Only author can delete their own PC listing - if (pcListing.authorId !== ctx.session.user.id) { - return ResourceError.pcListing.canOnlyDeleteOwn() - } - - const deletedListing = await ctx.prisma.pcListing.delete({ - where: { id: input.id }, - }) - - listingStatsCache.delete('pc-listing-stats') - - if (pcListing.status === ApprovalStatus.APPROVED) { - await invalidatePcListingSeo(pcListing) - } - - return deletedListing - }), - - update: protectedProcedure.input(UpdatePcListingUserSchema).mutation(async ({ ctx, input }) => { - const EDIT_TIME_LIMIT_MINUTES = 60 - - const pcListing = await ctx.prisma.pcListing.findUnique({ - where: { id: input.id }, - select: { - authorId: true, - status: true, - processedAt: true, - gameId: true, - cpuId: true, - gpuId: true, - }, - }) - - if (!pcListing) return ResourceError.pcListing.notFound() - - // Only allow owners or moderators to edit - if ( - pcListing.authorId !== ctx.session.user.id && - !hasRolePermission(ctx.session.user.role, Role.MODERATOR) - ) { - return ResourceError.pcListing.canOnlyEditOwn() - } - - // Check edit permissions based on PC listing status - switch (pcListing.status) { - case ApprovalStatus.REJECTED: - // Moderators can edit rejected listings - if (!hasRolePermission(ctx.session.user.role, Role.MODERATOR)) { - return ResourceError.pcListing.cannotEditRejected() - } - break - - case ApprovalStatus.APPROVED: { - // Moderators can always edit approved listings - if (hasRolePermission(ctx.session.user.role, Role.MODERATOR)) break - - // Regular users have a time limit for editing approved listings - if (!pcListing.processedAt) return ResourceError.pcListing.approvalTimeNotFound() - - const now = new Date() - const timeSinceApproval = now.getTime() - pcListing.processedAt.getTime() - const timeLimit = EDIT_TIME_LIMIT_MINUTES * 60 * 1000 - - if (timeSinceApproval > timeLimit) { - return ResourceError.pcListing.editTimeExpired(EDIT_TIME_LIMIT_MINUTES) - } - break - } - - case ApprovalStatus.PENDING: - // Pending listings can always be edited by their author - break - - default: - return AppError.badRequest('Invalid PC listing status') - } - - // Validate referenced entities exist - const [performance] = await Promise.all([ - ctx.prisma.performanceScale.findUnique({ where: { id: input.performanceId } }), - ]) - - if (!performance) return ResourceError.performanceScale.notFound() - - // Update PC listing and handle custom field values - const { id, customFieldValues, ...updateData } = input - - const updatedPcListing = await ctx.prisma.pcListing.update({ - where: { id }, - data: { ...updateData, updatedAt: new Date() }, - include: { - game: { include: { system: true } }, - cpu: { include: { brand: true } }, - gpu: { include: { brand: true } }, - emulator: true, - performance: true, - author: true, - customFieldValues: { - include: { customFieldDefinition: { include: { category: true } } }, - }, - }, - }) - - // Handle custom field values if provided - if (customFieldValues) { - // Delete existing custom field values - await ctx.prisma.pcListingCustomFieldValue.deleteMany({ where: { pcListingId: id } }) - - // Create new custom field values - if (customFieldValues.length > 0) { - await ctx.prisma.pcListingCustomFieldValue.createMany({ - data: customFieldValues.map((cfv) => ({ - pcListingId: id, - customFieldDefinitionId: cfv.customFieldDefinitionId, - value: toPrismaCustomFieldValue(cfv.value), - })), - }) - } - } - - if (pcListing.status === ApprovalStatus.APPROVED) { - await invalidatePcListingSeoForUpdate( - { - id, - gameId: pcListing.gameId, - cpuId: pcListing.cpuId, - gpuId: pcListing.gpuId, - }, - { - id, - gameId: updatedPcListing.gameId, - cpuId: updatedPcListing.cpuId, - gpuId: updatedPcListing.gpuId, - }, - ) - } - - return updatedPcListing - }), - - // Admin procedures - pending: protectedProcedure.input(GetPendingPcListingsSchema).query(async ({ ctx, input }) => { - // Check if user has permission to view pending listings - const isModerator = hasRolePermission(ctx.session.user.role, Role.MODERATOR) - const isDeveloper = hasRolePermission(ctx.session.user.role, Role.DEVELOPER) - - if (!isModerator && !isDeveloper) { - return ResourceError.pcListing.requiresDeveloperToView() - } - - const repository = new PcListingsRepository(ctx.prisma) - const { - search, - page = 1, - limit = 20, - sortField, - sortDirection = 'asc', - riskFilter = 'all', - } = input ?? {} - const filterRiskyListings = riskFilter === 'risky' - - // For developers, filter by their assigned emulators - let emulatorIds: string[] | undefined - if (!isModerator && isDeveloper) { - emulatorIds = await repository.getVerifiedEmulatorIds(ctx.session.user.id) - - if (emulatorIds.length === 0) { - // Developer has no assigned emulators, return empty results - return { - pcListings: [], - pagination: paginate({ total: 0, page, limit }), - } - } - } - - if (filterRiskyListings) { - const riskPage = await getRiskOnlyReviewPage({ - prisma: ctx.prisma, - page, - limit, - loadCandidates: () => - repository.getPendingListingRiskCandidates({ - emulatorIds, - search, - sortField, - sortDirection: sortDirection ?? 'asc', - }), - loadItemsByIds: (pcListingIds) => - repository.getPendingListingsByIds(pcListingIds, { - emulatorIds, - search, - }), - }) - - return { - pcListings: riskPage.items, - pagination: paginate({ total: riskPage.total, page, limit }), - } - } - - const result = await repository.getPendingListings({ - emulatorIds, - search, - page, - limit, - sortField, - sortDirection: sortDirection ?? 'asc', - }) - - const riskProfiles = await computeReviewRiskProfiles(ctx.prisma, result.pcListings) - const paginatedPcListings = attachReviewRiskProfiles(result.pcListings, riskProfiles) - - return { - pcListings: paginatedPcListings, - pagination: result.pagination, - } - }), - - approve: protectedProcedure.input(ApprovePcListingSchema).mutation(async ({ ctx, input }) => { - // Check if user has permission to approve listings - // Either through MODERATOR role or being a verified developer - const isModerator = hasRolePermission(ctx.session.user.role, Role.MODERATOR) - const isDeveloper = hasRolePermission(ctx.session.user.role, Role.DEVELOPER) - - if (!isModerator && !isDeveloper) { - return ResourceError.pcListing.requiresDeveloperToApprove() - } - - const repository = new PcListingsRepository(ctx.prisma) - const pcListing = await repository.getById(input.pcListingId) - - if (!pcListing) return ResourceError.pcListing.notFound() - - if (pcListing.status !== ApprovalStatus.PENDING) { - return ResourceError.pcListing.notPending() - } - - // For developers, verify they can approve this emulator's listings - if (!isModerator && isDeveloper) { - const isVerified = await repository.isDeveloperVerifiedForEmulator( - ctx.session.user.id, - pcListing.emulatorId, - ) - - if (!isVerified) { - return ResourceError.pcListing.mustBeVerifiedToApprove() - } - } - - const approvedListing = await repository.approve(input.pcListingId, ctx.session.user.id) - - if (pcListing.authorId) { - await applyTrustAction({ - userId: pcListing.authorId, - action: TrustAction.LISTING_APPROVED, - context: { - pcListingId: input.pcListingId, - adminUserId: ctx.session.user.id, - reason: 'listing_approved', - }, - }) - } - - listingStatsCache.delete('pc-listing-stats') - - await invalidatePcListingSeo({ - id: input.pcListingId, - gameId: pcListing.gameId, - cpuId: pcListing.cpuId, - gpuId: pcListing.gpuId, - }) - - notificationEventEmitter.emitNotificationEvent({ - eventType: NOTIFICATION_EVENTS.PC_LISTING_APPROVED, - entityType: 'pcListing', - entityId: input.pcListingId, - triggeredBy: ctx.session.user.id, - payload: { - pcListingId: input.pcListingId, - gameId: pcListing.gameId, - approvedBy: ctx.session.user.id, - approvedAt: approvedListing.processedAt, - }, - }) - - return approvedListing - }), - - reject: protectedProcedure.input(RejectPcListingSchema).mutation(async ({ ctx, input }) => { - // Check if user has permission to reject listings - // Either through MODERATOR role or being a verified developer - const isModerator = hasRolePermission(ctx.session.user.role, Role.MODERATOR) - const isDeveloper = hasRolePermission(ctx.session.user.role, Role.DEVELOPER) - - if (!isModerator && !isDeveloper) { - return ResourceError.pcListing.requiresDeveloperToReject() - } - - const repository = new PcListingsRepository(ctx.prisma) - const pcListing = await repository.getById(input.pcListingId) - - if (!pcListing) return ResourceError.pcListing.notFound() - - if (pcListing.status !== ApprovalStatus.PENDING) { - return ResourceError.pcListing.notPending() - } - - // For developers, verify they can reject this emulator's listings - if (!isModerator && isDeveloper) { - const isVerified = await repository.isDeveloperVerifiedForEmulator( - ctx.session.user.id, - pcListing.emulatorId, - ) - - if (!isVerified) { - return ResourceError.pcListing.mustBeVerifiedToReject() - } - } - - const rejectedListing = await repository.reject( - input.pcListingId, - ctx.session.user.id, - input.notes, - ) - - if (pcListing.authorId) { - await applyTrustAction({ - userId: pcListing.authorId, - action: TrustAction.LISTING_REJECTED, - context: { - pcListingId: input.pcListingId, - adminUserId: ctx.session.user.id, - reason: input.notes || 'listing_rejected', - }, - }) - } - - // Invalidate stats cache when PC listing is rejected - listingStatsCache.delete('pc-listing-stats') - - notificationEventEmitter.emitNotificationEvent({ - eventType: NOTIFICATION_EVENTS.PC_LISTING_REJECTED, - entityType: 'pcListing', - entityId: input.pcListingId, - triggeredBy: ctx.session.user.id, - payload: { - pcListingId: input.pcListingId, - rejectedBy: ctx.session.user.id, - rejectedAt: rejectedListing.processedAt, - rejectionReason: input.notes, - }, - }) - - return rejectedListing - }), - - resetToPending: moderatorProcedure - .input(ResetPcListingToPendingSchema) - .mutation(async ({ ctx, input }) => { - const repository = new PcListingsRepository(ctx.prisma) - const pcListing = await repository.getById(input.pcListingId) - - if (!pcListing) return ResourceError.pcListing.notFound() - - if (pcListing.status === ApprovalStatus.PENDING) { - return ResourceError.pcListing.alreadyPending() - } - - const updatedListing = await ctx.prisma.pcListing.update({ - where: { id: input.pcListingId }, - data: { - status: ApprovalStatus.PENDING, - processedByUserId: null, - processedAt: null, - processedNotes: null, - }, - }) - - listingStatsCache.delete('pc-listing-stats') - - if (pcListing.status === ApprovalStatus.APPROVED) { - await invalidatePcListingSeo({ - id: input.pcListingId, - gameId: pcListing.gameId, - cpuId: pcListing.cpuId, - gpuId: pcListing.gpuId, - }) - } - - return updatedListing - }), - - getProcessed: superAdminProcedure.input(GetProcessedPcSchema).query(async ({ ctx, input }) => { - const { page, limit, filterStatus, search, sortField, sortDirection } = input - const skip = (page - 1) * limit - - const baseWhere: Prisma.PcListingWhereInput = { - NOT: { status: ApprovalStatus.PENDING }, - ...(filterStatus ? { status: filterStatus } : {}), - } - - const searchWhere: Prisma.PcListingWhereInput = search - ? { - OR: [ - { game: { title: { contains: search, mode: 'insensitive' } } }, - { game: { system: { name: { contains: search, mode: 'insensitive' } } } }, - { cpu: { modelName: { contains: search, mode: 'insensitive' } } }, - { cpu: { brand: { name: { contains: search, mode: 'insensitive' } } } }, - { gpu: { modelName: { contains: search, mode: 'insensitive' } } }, - { gpu: { brand: { name: { contains: search, mode: 'insensitive' } } } }, - { emulator: { name: { contains: search, mode: 'insensitive' } } }, - { author: { name: { contains: search, mode: 'insensitive' } } }, - { processedNotes: { contains: search, mode: 'insensitive' } }, - { notes: { contains: search, mode: 'insensitive' } }, - ], - } - : {} - - const where = buildPcListingWhere({ ...baseWhere, ...searchWhere }, true) - const orderBy = buildProcessedPcListingOrderBy(sortField, sortDirection) - - const [pcListings, total] = await Promise.all([ - ctx.prisma.pcListing.findMany({ - where, - include: pcListingAdminInclude, - orderBy, - skip, - take: limit, - }), - ctx.prisma.pcListing.count({ where }), - ]) - - return { - pcListings, - pagination: paginate({ total, page, limit }), - } - }), - - overrideStatus: superAdminProcedure - .input(OverridePcApprovalStatusSchema) - .mutation(async ({ ctx, input }) => { - const { pcListingId, newStatus, overrideNotes } = input - const superAdminUserId = ctx.session.user.id - - const pcListing = await ctx.prisma.pcListing.findUnique({ - where: { id: pcListingId }, - select: { - id: true, - status: true, - gameId: true, - cpuId: true, - gpuId: true, - authorId: true, - processedNotes: true, - }, - }) - - if (!pcListing) return ResourceError.pcListing.notFound() - - const updatedPcListing = await ctx.prisma.pcListing.update({ - where: { id: pcListingId }, - data: - newStatus === ApprovalStatus.PENDING - ? { - status: newStatus, - processedByUserId: null, - processedAt: null, - processedNotes: null, - } - : { - status: newStatus, - processedByUserId: superAdminUserId, - processedAt: new Date(), - processedNotes: overrideNotes ?? pcListing.processedNotes, - }, - }) - - listingStatsCache.delete('pc-listing-stats') - - if (pcListing.status === ApprovalStatus.APPROVED || newStatus === ApprovalStatus.APPROVED) { - await invalidatePcListingSeo({ - id: pcListingId, - gameId: pcListing.gameId, - cpuId: pcListing.cpuId, - gpuId: pcListing.gpuId, - }) - } - - const trustAction = getProcessedStatusTrustAction({ - previousStatus: pcListing.status, - newStatus, - authorId: pcListing.authorId, - }) - if (trustAction) { - await applyTrustAction({ - userId: trustAction.userId, - action: trustAction.action, - context: { - pcListingId, - adminUserId: superAdminUserId, - reason: overrideNotes || 'pc_listing_status_override', - }, - }) - } - - if (newStatus === ApprovalStatus.APPROVED || newStatus === ApprovalStatus.REJECTED) { - notificationEventEmitter.emitNotificationEvent({ - eventType: - newStatus === ApprovalStatus.APPROVED - ? NOTIFICATION_EVENTS.PC_LISTING_APPROVED - : NOTIFICATION_EVENTS.PC_LISTING_REJECTED, - entityType: 'pcListing', - entityId: pcListingId, - triggeredBy: superAdminUserId, - payload: - newStatus === ApprovalStatus.APPROVED - ? { - pcListingId, - gameId: pcListing.gameId, - approvedBy: superAdminUserId, - approvedAt: updatedPcListing.processedAt, - } - : { - pcListingId, - rejectedBy: superAdminUserId, - rejectedAt: updatedPcListing.processedAt, - rejectionReason: overrideNotes, - }, - }) - } - - return updatedPcListing - }), - - bulkApprove: protectedProcedure - .input(BulkApprovePcListingsSchema) - .mutation(async ({ ctx, input }) => { - // Check if user has permission to approve listings - // Either through MODERATOR role or being a verified developer - const isModerator = hasRolePermission(ctx.session.user.role, Role.MODERATOR) - const isDeveloper = hasRolePermission(ctx.session.user.role, Role.DEVELOPER) - - if (!isModerator && !isDeveloper) { - return ResourceError.pcListing.requiresDeveloperToApprove() - } - - const pendingListings = await ctx.prisma.pcListing.findMany({ - where: { id: { in: input.pcListingIds }, status: ApprovalStatus.PENDING }, - select: { id: true, gameId: true, cpuId: true, gpuId: true, authorId: true }, - }) - const approvedAt = new Date() - - const result = await ctx.prisma.pcListing.updateMany({ - where: { id: { in: pendingListings.map((l) => l.id) } }, - data: { - status: ApprovalStatus.APPROVED, - processedAt: approvedAt, - processedByUserId: ctx.session.user.id, - }, - }) - - const listingsWithAuthor = pendingListings.filter( - (l): l is typeof l & { authorId: string } => l.authorId !== null, - ) - await Promise.all( - listingsWithAuthor.map((listing) => - applyTrustAction({ - userId: listing.authorId, - action: TrustAction.LISTING_APPROVED, - context: { - pcListingId: listing.id, - adminUserId: ctx.session.user.id, - reason: 'bulk_listing_approved', - }, - }), - ), - ) - - listingStatsCache.delete('pc-listing-stats') - - await invalidatePcListingsSeo(pendingListings) - - for (const listing of pendingListings) { - notificationEventEmitter.emitNotificationEvent({ - eventType: NOTIFICATION_EVENTS.PC_LISTING_APPROVED, - entityType: 'pcListing', - entityId: listing.id, - triggeredBy: ctx.session.user.id, - payload: { - pcListingId: listing.id, - gameId: listing.gameId, - approvedBy: ctx.session.user.id, - approvedAt, - bulk: true, - }, - }) - } - - return { count: result.count } - }), - - bulkReject: protectedProcedure - .input(BulkRejectPcListingsSchema) - .mutation(async ({ ctx, input }) => { - // Check if user has permission to reject listings - // Either through MODERATOR role or being a verified developer - const isModerator = hasRolePermission(ctx.session.user.role, Role.MODERATOR) - const isDeveloper = hasRolePermission(ctx.session.user.role, Role.DEVELOPER) - - if (!isModerator && !isDeveloper) { - return ResourceError.pcListing.requiresDeveloperToReject() - } - - const pendingListings = await ctx.prisma.pcListing.findMany({ - where: { id: { in: input.pcListingIds }, status: ApprovalStatus.PENDING }, - select: { id: true, authorId: true }, - }) - - const result = await ctx.prisma.pcListing.updateMany({ - where: { - id: { in: pendingListings.map((l) => l.id) }, - }, - data: { - status: ApprovalStatus.REJECTED, - processedAt: new Date(), - processedByUserId: ctx.session.user.id, - processedNotes: input.notes, - }, - }) - - // Apply trust actions in parallel — distinct user adjustments, independent. - const listingsWithAuthor = pendingListings.filter( - (l): l is typeof l & { authorId: string } => l.authorId !== null, - ) - await Promise.all( - listingsWithAuthor.map((listing) => - applyTrustAction({ - userId: listing.authorId, - action: TrustAction.LISTING_REJECTED, - context: { - pcListingId: listing.id, - adminUserId: ctx.session.user.id, - reason: input.notes || 'bulk_listing_rejected', - }, - }), - ), - ) - - // Invalidate stats cache when PC listings are bulk rejected - listingStatsCache.delete('pc-listing-stats') - - for (const listing of pendingListings) { - notificationEventEmitter.emitNotificationEvent({ - eventType: NOTIFICATION_EVENTS.PC_LISTING_REJECTED, - entityType: 'pcListing', - entityId: listing.id, - triggeredBy: ctx.session.user.id, - payload: { - pcListingId: listing.id, - rejectedBy: ctx.session.user.id, - rejectedAt: new Date(), - rejectionReason: input.notes, - }, - }) - } - - return { count: result.count } - }), - - autoRejectRiskyPreview: adminProcedure.query(async ({ ctx }) => { - const repository = new PcListingsRepository(ctx.prisma) - - return getAutoRejectableReviewRiskPreviewForCandidates({ - prisma: ctx.prisma, - loadCandidates: () => repository.getPendingListingRiskCandidates({}), - }) - }), - - autoRejectRisky: adminProcedure.mutation(async ({ ctx }) => { - const adminUserId = ctx.session.user.id - - const adminUserExists = await ctx.prisma.user.findUnique({ - where: { id: adminUserId }, - select: { id: true }, - }) - if (!adminUserExists) return ResourceError.user.notInDatabase(adminUserId) - - return autoRejectRiskyPcReports({ - prisma: ctx.prisma, - adminUserId, - }) - }), - - getAll: permissionProcedure(PERMISSIONS.APPROVE_LISTINGS) - .input(GetAllPcListingsAdminSchema) - .query(async ({ ctx, input }) => { - const { - page = 1, - limit = 20, - sortField, - sortDirection, - search, - statusFilter, - systemFilter, - emulatorFilter, - osFilter, - } = input - - const offset = (page - 1) * limit - - const baseWhere: Prisma.PcListingWhereInput = { - ...(statusFilter ? { status: statusFilter } : {}), - ...(systemFilter ? { game: { systemId: systemFilter } } : {}), - ...(emulatorFilter ? { emulatorId: emulatorFilter } : {}), - ...(osFilter ? { os: osFilter } : {}), - ...(search - ? { - OR: [ - { game: { title: { contains: search, mode: 'insensitive' } } }, - { - cpu: { modelName: { contains: search, mode: 'insensitive' } }, - }, - { - gpu: { modelName: { contains: search, mode: 'insensitive' } }, - }, - { - emulator: { name: { contains: search, mode: 'insensitive' } }, - }, - { author: { name: { contains: search, mode: 'insensitive' } } }, - ], - } - : {}), - } - - // Moderators can see listings from banned users - const where = buildPcListingWhere(baseWhere, true) - const orderBy = buildPcListingOrderBy(sortField, sortDirection ?? undefined) - - const [pcListings, total] = await Promise.all([ - ctx.prisma.pcListing.findMany({ - where, - include: pcListingAdminInclude, - orderBy, - skip: offset, - take: limit, - }), - ctx.prisma.pcListing.count({ where }), - ]) - - return { - pcListings, - pagination: paginate({ total: total, page, limit: limit }), - } - }), - - getForEdit: permissionProcedure(PERMISSIONS.APPROVE_LISTINGS) - .input(GetPcListingForAdminEditSchema) - .query(async ({ ctx, input }) => { - const pcListing = await ctx.prisma.pcListing.findUnique({ - where: { id: input.id }, - include: pcListingDetailInclude, - }) - - return pcListing ?? ResourceError.pcListing.notFound() - }), - - updateAdmin: permissionProcedure(PERMISSIONS.APPROVE_LISTINGS) - .input(UpdatePcListingAdminSchema) - .mutation(async ({ ctx, input }) => { - const { id, customFieldValues, ...data } = input - - const pcListing = await ctx.prisma.pcListing.findUnique({ - where: { id }, - include: { customFieldValues: true }, - }) - - if (!pcListing) return ResourceError.pcListing.notFound() - - const updatedPcListing = await ctx.prisma.pcListing.update({ - where: { id }, - data: { ...data, updatedAt: new Date() }, - include: pcListingDetailInclude, - }) - - if (customFieldValues) { - await ctx.prisma.pcListingCustomFieldValue.deleteMany({ - where: { pcListingId: id }, - }) - - if (customFieldValues.length > 0) { - await ctx.prisma.pcListingCustomFieldValue.createMany({ - data: customFieldValues.map((cfv) => ({ - pcListingId: id, - customFieldDefinitionId: cfv.customFieldDefinitionId, - value: toPrismaCustomFieldValue(cfv.value), - })), - }) - } - } - - const previousSeoTarget = { - id, - gameId: pcListing.gameId, - cpuId: pcListing.cpuId, - gpuId: pcListing.gpuId, - } - const nextSeoTarget = { - id, - gameId: updatedPcListing.gameId, - cpuId: updatedPcListing.cpuId, - gpuId: updatedPcListing.gpuId, - } - const wasApproved = pcListing.status === ApprovalStatus.APPROVED - const isApproved = updatedPcListing.status === ApprovalStatus.APPROVED - - if (wasApproved && isApproved) { - await invalidatePcListingSeoForUpdate(previousSeoTarget, nextSeoTarget) - } else if (wasApproved) { - await invalidatePcListingSeo(previousSeoTarget) - } else if (isApproved) { - await invalidatePcListingSeo(nextSeoTarget) - } - - return updatedPcListing - }), - - stats: viewStatisticsProcedure.query(async ({ ctx }) => { - const STATS_CACHE_KEY = 'pc-listing-stats' - const cached = listingStatsCache.get(STATS_CACHE_KEY) - if (cached) return cached - - const repository = new PcListingsRepository(ctx.prisma) - const stats = await repository.stats() - - listingStatsCache.set(STATS_CACHE_KEY, stats) - return stats - }), - - // PC Preset procedures - presets: { - get: protectedProcedure.input(GetPcPresetsSchema).query(async ({ ctx, input }) => { - const repository = new UserPcPresetsRepository(ctx.prisma) - const userId = input.userId ?? ctx.session.user.id - - return await repository.listByUserId(userId, { - requestingUserId: ctx.session.user.id, - userRole: ctx.session.user.role, - }) - }), - - create: protectedProcedure.input(CreatePcPresetSchema).mutation(async ({ ctx, input }) => { - const repository = new UserPcPresetsRepository(ctx.prisma) - - return await repository.create({ - userId: ctx.session.user.id, - name: input.name, - cpuId: input.cpuId, - gpuId: input.gpuId, - memorySize: input.memorySize, - os: input.os, - osVersion: input.osVersion, - }) - }), - - update: protectedProcedure.input(UpdatePcPresetSchema).mutation(async ({ ctx, input }) => { - const { id, ...data } = input - const repository = new UserPcPresetsRepository(ctx.prisma) - - return await repository.update(id, ctx.session.user.id, data, { - requestingUserRole: ctx.session.user.role, - }) - }), - - delete: protectedProcedure.input(DeletePcPresetSchema).mutation(async ({ ctx, input }) => { - const repository = new UserPcPresetsRepository(ctx.prisma) - await repository.delete(input.id, ctx.session.user.id, { - requestingUserRole: ctx.session.user.role, - }) - return { success: true } - }), - }, - - // Voting endpoints - vote: protectedProcedure.input(VotePcListingSchema).mutation(async ({ ctx, input }) => { - const { pcListingId, value } = input - const userId = ctx.session.user.id - - if (await isUserBanned(ctx.prisma, userId)) { - return AppError.shadowBanned() - } - - const pcListing = await ctx.prisma.pcListing.findUnique({ - where: { id: pcListingId }, - }) - - if (!pcListing) return ResourceError.pcListing.notFound() - - // Fetch existingVote INSIDE the transaction to avoid race conditions between - // concurrent votes on the same (user, pcListing) pair. - const voteResult = await ctx.prisma.$transaction(async (tx) => { - const existingVote = await tx.pcListingVote.findUnique({ - where: { userId_pcListingId: { userId, pcListingId } }, - }) - - let result: { - vote: { userId: string; pcListingId: string; value: boolean } | null - action: 'created' | 'updated' | 'deleted' - previousValue: boolean | null - } - - if (!existingVote) { - const vote = await tx.pcListingVote.create({ - data: { userId, pcListingId, value }, - }) - await updatePcListingVoteCounts(tx, pcListingId, 'create', value) - result = { vote, action: 'created', previousValue: null } - } else if (existingVote.value === value) { - await tx.pcListingVote.delete({ - where: { userId_pcListingId: { userId, pcListingId } }, - }) - await updatePcListingVoteCounts(tx, pcListingId, 'delete', undefined, existingVote.value) - result = { vote: null, action: 'deleted', previousValue: existingVote.value } - } else { - const vote = await tx.pcListingVote.update({ - where: { userId_pcListingId: { userId, pcListingId } }, - data: { value }, - }) - await updatePcListingVoteCounts(tx, pcListingId, 'update', value, existingVote.value) - result = { vote, action: 'updated', previousValue: existingVote.value } - } - - await handleListingVoteTrustEffects({ - tx, - action: result.action, - currentValue: value, - previousValue: result.previousValue, - userId, - listingId: pcListingId, - listingType: 'pc', - authorId: pcListing.authorId, - }) - - return result - }) - - // Only notify the author when a vote was created or updated — toggle-off should not fire. - if (voteResult.action === 'created' || voteResult.action === 'updated') { - if (voteResult.vote) { - notificationEventEmitter.emitNotificationEvent({ - eventType: NOTIFICATION_EVENTS.LISTING_VOTED, - entityType: 'pcListing', - entityId: pcListingId, - triggeredBy: userId, - payload: { - pcListingId, - voteValue: value, - }, - }) - } - } - - const finalVoteValue = voteResult.action === 'deleted' ? null : value - analytics.engagement.vote({ - listingId: pcListingId, - voteValue: finalVoteValue, - previousVote: voteResult.previousValue, - }) - - return voteResult.vote - }), - - getUserVote: protectedProcedure - .input(GetPcListingUserVoteSchema) - .query(async ({ ctx, input }) => { - const repository = new PcListingsRepository(ctx.prisma) - const vote = await repository.getUserVote(ctx.session.user.id, input.pcListingId) - return { vote } - }), - - // Comments endpoints - getComments: publicProcedure.input(GetPcListingCommentsSchema).query(async ({ ctx, input }) => { - const { pcListingId, sortBy = 'newest', limit = 50, offset = 0 } = input - - const pcListing = await ctx.prisma.pcListing.findUnique({ - where: { id: pcListingId }, - select: { - id: true, - emulatorId: true, - pinnedCommentId: true, - pinnedAt: true, - pinnedByUser: { select: { id: true, name: true, profileImage: true, role: true } }, - }, - }) - - if (!pcListing) return ResourceError.pcListing.notFound() - - const allComments = await ctx.prisma.pcListingComment.findMany({ - where: { - pcListingId, - deletedAt: null, - }, - include: { - user: { - select: { id: true, name: true, profileImage: true, role: true }, - }, - }, - }) - - let userCommentVotes: Record = {} - if (ctx.session?.user) { - const votes = await ctx.prisma.pcListingCommentVote.findMany({ - where: { - userId: ctx.session.user.id, - comment: { pcListingId }, - }, - select: { commentId: true, value: true }, - }) - - userCommentVotes = votes.reduce( - (acc, vote) => ({ - ...acc, - [vote.commentId]: vote.value, - }), - {} as Record, - ) - } - - const commentsWithVotes = allComments.map((comment) => ({ - ...comment, - userVote: userCommentVotes[comment.id] ?? null, - })) - - let commentsTree = buildCommentTree(commentsWithVotes, { replySort: 'asc' }) - - commentsTree.sort((a, b) => { - switch (sortBy) { - case 'oldest': - return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() - case 'score': - return (b.score ?? 0) - (a.score ?? 0) - case 'newest': - default: - return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() - } - }) - - let pinnedCommentPayload: { - comment: (typeof commentsTree)[number] - parentId: string | null - isReply: boolean - } | null = null - - if (pcListing.pinnedCommentId) { - const located = findCommentWithParent(commentsTree, pcListing.pinnedCommentId) - - if (located) { - pinnedCommentPayload = { - comment: located.comment, - parentId: located.parent?.id ?? null, - isReply: Boolean(located.parent), - } - - if (!located.parent) { - commentsTree = commentsTree.filter((comment) => comment.id !== located.comment.id) - } - } - } - - const paginatedComments = commentsTree.slice(offset, offset + limit) - - return { - comments: paginatedComments, - pinnedComment: pinnedCommentPayload - ? { - comment: pinnedCommentPayload.comment, - isReply: pinnedCommentPayload.isReply, - parentId: pinnedCommentPayload.parentId, - pinnedBy: pcListing.pinnedByUser, - pinnedAt: pcListing.pinnedAt, - } - : null, - } - }), - - createComment: protectedProcedure - .input(CreatePcListingCommentSchema) - .mutation(async ({ ctx, input }) => { - const { pcListingId, content, parentId, humanVerificationToken } = input - const userId = ctx.session.user.id - - // Check if PC listing exists - const pcListing = await ctx.prisma.pcListing.findUnique({ - where: { id: pcListingId }, - }) - - if (!pcListing) return ResourceError.pcListing.notFound() - - // If parentId is provided, check if parent comment exists - if (parentId) { - const parentComment = await ctx.prisma.pcListingComment.findUnique({ - where: { id: parentId }, - }) - - if (!parentComment) return ResourceError.comment.parentNotFound() - } - - await checkSpamContent({ - prisma: ctx.prisma, - userId, - content, - entityType: 'pcComment', - challengeMode: 'challenge', - humanVerificationToken, - headers: ctx.headers, - }) - - const comment = await ctx.prisma.pcListingComment.create({ - data: { content, userId, pcListingId, parentId }, - include: { - user: { - select: { id: true, name: true, profileImage: true, role: true }, - }, - }, - }) - - notificationEventEmitter.emitNotificationEvent({ - eventType: parentId - ? NOTIFICATION_EVENTS.COMMENT_REPLIED - : NOTIFICATION_EVENTS.LISTING_COMMENTED, - entityType: 'pcListing', - entityId: pcListingId, - triggeredBy: userId, - payload: { - pcListingId, - commentId: comment.id, - parentId, - commentText: content, - }, - }) - - analytics.engagement.comment({ - action: parentId ? 'reply' : 'created', - commentId: comment.id, - listingId: pcListingId, - isReply: !!parentId, - contentLength: content.length, - }) - - return comment - }), - - updateComment: protectedProcedure - .input(UpdatePcListingCommentSchema) - .mutation(async ({ ctx, input }) => { - const comment = await ctx.prisma.pcListingComment.findUnique({ - where: { id: input.commentId }, - include: { user: { select: { id: true } } }, - }) - - if (!comment) return ResourceError.comment.notFound() - if (comment.deletedAt) return ResourceError.comment.cannotEditDeleted() - - const canEdit = canEditComment(ctx.session.user.role, comment.user.id, ctx.session.user.id) - - if (!canEdit) { - return ResourceError.comment.noPermission('edit') - } - - return await ctx.prisma.pcListingComment.update({ - where: { id: input.commentId }, - data: { - content: input.content, - isEdited: true, - updatedAt: new Date(), - }, - include: { - user: { - select: { id: true, name: true, profileImage: true, role: true }, - }, - }, - }) - }), - - deleteComment: protectedProcedure - .input(DeletePcListingCommentSchema) - .mutation(async ({ ctx, input }) => { - const comment = await ctx.prisma.pcListingComment.findUnique({ - where: { id: input.commentId }, - include: { - user: { select: { id: true } }, - pcListing: { - select: { - id: true, - pinnedCommentId: true, - }, - }, - }, - }) - - if (!comment) return ResourceError.comment.notFound() - if (comment.deletedAt) return ResourceError.comment.alreadyDeleted() - - const canDelete = canDeleteComment( - ctx.session.user.role, - comment.user.id, - ctx.session.user.id, - ) - - if (!canDelete) { - return ResourceError.comment.noPermission('delete') - } - - const wasPinned = comment.pcListing?.pinnedCommentId === comment.id - - const updatedComment = await ctx.prisma.pcListingComment.update({ - where: { id: input.commentId }, - data: { deletedAt: new Date() }, - }) - - if (wasPinned && comment.pcListing) { - await ctx.prisma.pcListing.update({ - where: { id: comment.pcListing.id }, - data: { - pinnedCommentId: null, - pinnedByUserId: null, - pinnedAt: null, - }, - }) - - void logAudit(ctx.prisma, { - actorId: ctx.session.user.id, - action: AuditAction.UNPIN, - entityType: AuditEntityType.COMMENT, - entityId: comment.id, - metadata: { - pcListingId: comment.pcListing.id, - reason: 'comment_deleted', - }, - }) - } - - return updatedComment - }), - - voteComment: protectedProcedure - .input(VotePcListingCommentSchema) - .mutation(async ({ ctx, input }) => { - const { commentId, value } = input - const userId = ctx.session.user.id - - // Block banned users from voting (vague error preserves shadow ban) - if (await isUserBanned(ctx.prisma, userId)) { - return AppError.shadowBanned() - } - - const comment = await ctx.prisma.pcListingComment.findUnique({ - where: { id: commentId }, - }) - - if (!comment) { - return ResourceError.comment.notFound() - } - - // Fetch `existingVote` inside the transaction: two concurrent votes - // from the same user could both read null and both attempt to insert, - // producing a Prisma P2002 on the second. Keeping the read and write - // under the same isolation avoids the race. - return await ctx.prisma.$transaction(async (tx) => { - const existingVote = await tx.pcListingCommentVote.findUnique({ - where: { userId_commentId: { userId, commentId } }, - }) - - let voteResult - let scoreChange: number - let trustAction: 'upvote' | 'downvote' | 'change' | 'remove' | null - - if (existingVote) { - if (existingVote.value === value) { - await tx.pcListingCommentVote.delete({ - where: { userId_commentId: { userId, commentId } }, - }) - scoreChange = existingVote.value ? -1 : 1 - voteResult = { message: 'Vote removed' } - trustAction = 'remove' - } else { - voteResult = await tx.pcListingCommentVote.update({ - where: { userId_commentId: { userId, commentId } }, - data: { value }, - }) - scoreChange = value ? 2 : -2 - trustAction = 'change' - } - } else { - voteResult = await tx.pcListingCommentVote.create({ - data: { userId, commentId, value }, - }) - scoreChange = value ? 1 : -1 - trustAction = value ? 'upvote' : 'downvote' - } - - const updatedComment = await tx.pcListingComment.update({ - where: { id: commentId }, - data: { score: { increment: scoreChange } }, - }) - - if (trustAction) { - await handleCommentVoteTrustEffects({ - tx, - trustAction, - newValue: value, - previousValue: existingVote?.value ?? null, - commentAuthorId: comment.userId, - voterId: userId, - commentId, - parentEntityId: comment.pcListingId, - listingType: 'pc', - updatedScore: updatedComment.score, - scoreChange, - }) - } - - // Notify comment author on new votes / direction changes; skip on toggle-off. - if (trustAction !== null && trustAction !== 'remove') { - notificationEventEmitter.emitNotificationEvent({ - eventType: NOTIFICATION_EVENTS.COMMENT_VOTED, - entityType: 'comment', - entityId: comment.id, - triggeredBy: userId, - payload: { - pcListingId: comment.pcListingId, - commentId: comment.id, - voteValue: value, - }, - }) - } - - return voteResult - }) - }), - - pinComment: protectedProcedure - .input(PinPcListingCommentSchema) - .mutation(async ({ ctx, input }) => { - const { commentId, pcListingId, replaceExisting } = input - const userId = ctx.session.user.id - const userRole = ctx.session.user.role - - const comment = await ctx.prisma.pcListingComment.findUnique({ - where: { id: commentId }, - include: { - pcListing: { - select: { - id: true, - emulatorId: true, - pinnedCommentId: true, - pinnedByUserId: true, - }, - }, - }, - }) - - if (!comment) return ResourceError.comment.notFound() - if (comment.deletedAt) return ResourceError.comment.alreadyDeleted() - if (comment.pcListingId !== pcListingId) { - return AppError.badRequest('Comment does not belong to this PC listing') - } - if (!comment.pcListing) return ResourceError.pcListing.notFound() - - const pcListing = comment.pcListing - - const canPin = await canManageCommentPins({ - prisma: ctx.prisma, - userRole, - userId, - emulatorId: pcListing.emulatorId, - }) - - if (!canPin) return ResourceError.comment.noPermission('pin') - - if ( - pcListing.pinnedCommentId && - pcListing.pinnedCommentId !== comment.id && - !replaceExisting - ) { - return ResourceError.comment.alreadyPinned() - } - - const previousPinnedId = pcListing.pinnedCommentId - - const updatedPcListing = await ctx.prisma.pcListing.update({ - where: { id: pcListing.id }, - data: { - pinnedCommentId: comment.id, - pinnedByUserId: userId, - pinnedAt: new Date(), - }, - select: { - id: true, - pinnedCommentId: true, - pinnedAt: true, - }, - }) - - void logAudit(ctx.prisma, { - actorId: userId, - action: AuditAction.PIN, - entityType: AuditEntityType.COMMENT, - entityId: comment.id, - metadata: { - pcListingId: pcListing.id, - previousPinnedCommentId: previousPinnedId, - }, - }) - - return updatedPcListing - }), - - unpinComment: protectedProcedure - .input(UnpinPcListingCommentSchema) - .mutation(async ({ ctx, input }) => { - const { pcListingId } = input - const userId = ctx.session.user.id - const userRole = ctx.session.user.role - - const pcListing = await ctx.prisma.pcListing.findUnique({ - where: { id: pcListingId }, - select: { - id: true, - emulatorId: true, - pinnedCommentId: true, - }, - }) - - if (!pcListing) return ResourceError.pcListing.notFound() - if (!pcListing.pinnedCommentId) return ResourceError.comment.notPinned() - - const canUnpin = await canManageCommentPins({ - prisma: ctx.prisma, - userRole, - userId, - emulatorId: pcListing.emulatorId, - }) - - if (!canUnpin) return ResourceError.comment.noPermission('unpin') - - const previousPinnedId = pcListing.pinnedCommentId - - await ctx.prisma.pcListing.update({ - where: { id: pcListing.id }, - data: { - pinnedCommentId: null, - pinnedByUserId: null, - pinnedAt: null, - }, - }) - - void logAudit(ctx.prisma, { - actorId: userId, - action: AuditAction.UNPIN, - entityType: AuditEntityType.COMMENT, - entityId: previousPinnedId, - metadata: { - pcListingId: pcListing.id, - }, - }) - - return { success: true } - }), - - // Reporting endpoints - createReport: protectedProcedure - .input(CreatePcListingReportSchema) - .mutation(async ({ ctx, input }) => { - const { pcListingId, reason, description } = input - const userId = ctx.session.user.id - const reportSubmissionService = new ReportSubmissionService(ctx.prisma) - - return await reportSubmissionService.createPcListingReport({ - pcListingId, - reportedById: userId, - reason, - description, - }) - }), - - getReports: permissionProcedure(PERMISSIONS.VIEW_USER_BANS) - .input(GetPcListingReportsSchema) - .query(async ({ ctx, input }) => { - const { status, page = 1, limit = 20 } = input - const offset = (page - 1) * limit - - const where: Prisma.PcListingReportWhereInput = {} - if (status) { - where.status = status - } - - const [reports, total] = await Promise.all([ - ctx.prisma.pcListingReport.findMany({ - where, - orderBy: { createdAt: 'desc' }, - skip: offset, - take: limit, - include: { - pcListing: { - include: { - game: { select: { id: true, title: true } }, - author: { select: { id: true, name: true } }, - cpu: true, - gpu: true, - emulator: { select: { id: true, name: true } }, - }, - }, - reportedBy: { select: { id: true, name: true, email: true } }, - reviewedBy: { select: { id: true, name: true } }, - }, - }), - ctx.prisma.pcListingReport.count({ where }), - ]) - - return { - reports, - pagination: paginate({ total: total, page, limit: limit }), - } - }), - - updateReport: permissionProcedure(PERMISSIONS.MANAGE_USER_BANS) - .input(UpdatePcListingReportSchema) - .mutation(async ({ ctx, input }) => { - const { reportId, status, reviewNotes } = input - const reviewerId = ctx.session.user.id - - const report = await ctx.prisma.pcListingReport.findUnique({ - where: { id: reportId }, - include: { pcListing: true }, - }) - - if (!report) { - return ResourceError.listingReport.notFound() - } - - // If resolving the report and marking listing as rejected - if ( - status === ReportStatus.RESOLVED && - report.pcListing?.status === ApprovalStatus.APPROVED - ) { - // Update the listing status to rejected - await ctx.prisma.pcListing.update({ - where: { id: report.pcListingId }, - data: { - status: ApprovalStatus.REJECTED, - processedAt: new Date(), - processedByUserId: reviewerId, - processedNotes: `Rejected due to report: ${reviewNotes || 'No additional notes'}`, - }, - }) - } - - // Award trust points based on report outcome - const trustService = new TrustService(ctx.prisma) - - if (status === ReportStatus.RESOLVED) { - // Report was confirmed - reward the reporter - await trustService.logAction({ - userId: report.reportedById, - action: TrustAction.REPORT_CONFIRMED, - metadata: { - reportId, - pcListingId: report.pcListingId, - reviewedBy: reviewerId, - reason: report.reason, - }, - }) - } else if (status === ReportStatus.DISMISSED) { - // Report was false/malicious - penalize the reporter - await trustService.logAction({ - userId: report.reportedById, - action: TrustAction.FALSE_REPORT, - metadata: { - reportId, - pcListingId: report.pcListingId, - reviewedBy: reviewerId, - reason: report.reason, - reviewNotes, - }, - }) - } - - return await ctx.prisma.pcListingReport.update({ - where: { id: reportId }, - data: { - status, - reviewNotes, - reviewedById: reviewerId, - reviewedAt: new Date(), - }, - include: { - pcListing: { - include: { - game: { select: { title: true } }, - author: { select: { name: true } }, - }, - }, - reportedBy: { select: { name: true } }, - reviewedBy: { select: { name: true } }, - }, - }) - }), - - // Verification endpoints - verify: permissionProcedure(PERMISSIONS.APPROVE_LISTINGS) - .input(VerifyPcListingAdminSchema) - .mutation(async ({ ctx, input }) => { - const { pcListingId, notes } = input - const verifierId = ctx.session.user.id - - // Check if PC listing exists - const pcListing = await ctx.prisma.pcListing.findUnique({ - where: { id: pcListingId }, - }) - - if (!pcListing) { - return ResourceError.pcListing.notFound() - } - - // Check if user already verified this listing - const existingVerification = await ctx.prisma.pcListingDeveloperVerification.findUnique({ - where: { - pcListingId_verifiedBy: { - pcListingId, - verifiedBy: verifierId, - }, - }, - }) - - if (existingVerification) { - return AppError.badRequest('You have already verified this listing') - } - - return await ctx.prisma.pcListingDeveloperVerification.create({ - data: { - pcListingId, - verifiedBy: verifierId, - notes, - }, - include: { - developer: { select: { id: true, name: true } }, - }, - }) - }), - - removeVerification: permissionProcedure(PERMISSIONS.APPROVE_LISTINGS) - .input(RemovePcListingVerificationSchema) - .mutation(async ({ ctx, input }) => { - const verification = await ctx.prisma.pcListingDeveloperVerification.findUnique({ - where: { id: input.verificationId }, - }) - - if (!verification) { - return ResourceError.verification.notFound() - } - - // Only allow the verifier or admin to remove verification - if (verification.verifiedBy !== ctx.session.user.id && !isModerator(ctx.session.user.role)) { - return ResourceError.verification.canOnlyRemoveOwn() - } - - return await ctx.prisma.pcListingDeveloperVerification.delete({ - where: { id: input.verificationId }, - }) - }), - - getVerifications: publicProcedure - .input(GetPcListingVerificationsSchema) - .query(async ({ ctx, input }) => { - return await ctx.prisma.pcListingDeveloperVerification.findMany({ - where: { pcListingId: input.pcListingId }, - include: { - developer: { select: { id: true, name: true } }, - }, - orderBy: { verifiedAt: 'desc' }, - }) - }), + // Core listing operations (CRUD, voting, verification, presets) + ...coreRouter._def.procedures, + + // Admin operations + pending: adminRouter.getPending, + approve: adminRouter.approve, + reject: adminRouter.reject, + resetToPending: adminRouter.resetToPending, + getProcessed: adminRouter.getProcessed, + overrideStatus: adminRouter.overrideStatus, + bulkApprove: adminRouter.bulkApprove, + bulkReject: adminRouter.bulkReject, + autoRejectRiskyPreview: adminRouter.autoRejectRiskyPreview, + autoRejectRisky: adminRouter.autoRejectRisky, + getAll: adminRouter.get, + getForEdit: adminRouter.getForEdit, + updateAdmin: adminRouter.updateListing, + stats: adminRouter.stats, + + // Comment operations + getComments: commentsRouter.get, + createComment: commentsRouter.create, + updateComment: commentsRouter.edit, + deleteComment: commentsRouter.delete, + voteComment: commentsRouter.vote, + pinComment: commentsRouter.pinComment, + unpinComment: commentsRouter.unpinComment, }) diff --git a/src/server/api/routers/pcListings/admin.ts b/src/server/api/routers/pcListings/admin.ts new file mode 100644 index 00000000..a3c4e4a3 --- /dev/null +++ b/src/server/api/routers/pcListings/admin.ts @@ -0,0 +1,732 @@ +import { ResourceError } from '@/lib/errors' +import { applyTrustAction } from '@/lib/trust/service' +import { + ApprovePcListingSchema, + BulkApprovePcListingsSchema, + BulkRejectPcListingsSchema, + GetAllPcListingsAdminSchema, + RejectPcListingSchema, + GetPcListingForAdminEditSchema, + GetPendingPcListingsSchema, + GetProcessedPcSchema, + OverridePcApprovalStatusSchema, + ResetPcListingToPendingSchema, + UpdatePcListingAdminSchema, +} from '@/schemas/pcListing' +import { + adminProcedure, + createTRPCRouter, + moderatorProcedure, + permissionProcedure, + protectedProcedure, + superAdminProcedure, + viewStatisticsProcedure, +} from '@/server/api/trpc' +import { + buildPcListingOrderBy, + buildPcListingWhere, + buildProcessedPcListingOrderBy, + pcListingAdminInclude, + pcListingDetailInclude, +} from '@/server/api/utils/pcListingHelpers' +import { getProcessedStatusTrustAction } from '@/server/api/utils/processedStatusTrust' +import { + invalidatePcListingSeo, + invalidatePcListingSeoForUpdate, + invalidatePcListingsSeo, +} from '@/server/cache/invalidation' +import { NOTIFICATION_EVENTS, notificationEventEmitter } from '@/server/notifications/eventEmitter' +import { PcListingsRepository } from '@/server/repositories/pc-listings.repository' +import { autoRejectRiskyPcReports } from '@/server/services/review-risk-auto-reject.service' +import { + attachReviewRiskProfiles, + computeReviewRiskProfiles, + getAutoRejectableReviewRiskPreviewForCandidates, + getRiskOnlyReviewPage, +} from '@/server/services/review-risk.service' +import { listingStatsCache } from '@/server/utils/cache' +import { paginate } from '@/server/utils/pagination' +import { PERMISSIONS } from '@/utils/permission-system' +import { hasRolePermission } from '@/utils/permissions' +import { ApprovalStatus, Role, TrustAction } from '@orm' +import { type Prisma } from '@orm/client' +import { + invalidatePcListingStatsCache, + PC_LISTING_STATS_CACHE_KEY, + toPrismaCustomFieldValue, +} from './utils' + +export const adminRouter = createTRPCRouter({ + getPending: protectedProcedure.input(GetPendingPcListingsSchema).query(async ({ ctx, input }) => { + const isModerator = hasRolePermission(ctx.session.user.role, Role.MODERATOR) + const isDeveloper = hasRolePermission(ctx.session.user.role, Role.DEVELOPER) + + if (!isModerator && !isDeveloper) { + return ResourceError.pcListing.requiresDeveloperToView() + } + + const repository = new PcListingsRepository(ctx.prisma) + const { + search, + page = 1, + limit = 20, + sortField, + sortDirection = 'asc', + riskFilter = 'all', + } = input ?? {} + const filterRiskyListings = riskFilter === 'risky' + + let emulatorIds: string[] | undefined + if (!isModerator && isDeveloper) { + emulatorIds = await repository.getVerifiedEmulatorIds(ctx.session.user.id) + + if (emulatorIds.length === 0) { + return { + pcListings: [], + pagination: paginate({ total: 0, page, limit }), + } + } + } + + if (filterRiskyListings) { + const riskPage = await getRiskOnlyReviewPage({ + prisma: ctx.prisma, + page, + limit, + loadCandidates: () => + repository.getPendingListingRiskCandidates({ + emulatorIds, + search, + sortField, + sortDirection: sortDirection ?? 'asc', + }), + loadItemsByIds: (pcListingIds) => + repository.getPendingListingsByIds(pcListingIds, { + emulatorIds, + search, + }), + }) + + return { + pcListings: riskPage.items, + pagination: paginate({ total: riskPage.total, page, limit }), + } + } + + const result = await repository.getPendingListings({ + emulatorIds, + search, + page, + limit, + sortField, + sortDirection: sortDirection ?? 'asc', + }) + + const riskProfiles = await computeReviewRiskProfiles(ctx.prisma, result.pcListings) + const paginatedPcListings = attachReviewRiskProfiles(result.pcListings, riskProfiles) + + return { + pcListings: paginatedPcListings, + pagination: result.pagination, + } + }), + + approve: protectedProcedure.input(ApprovePcListingSchema).mutation(async ({ ctx, input }) => { + const isModerator = hasRolePermission(ctx.session.user.role, Role.MODERATOR) + const isDeveloper = hasRolePermission(ctx.session.user.role, Role.DEVELOPER) + + if (!isModerator && !isDeveloper) { + return ResourceError.pcListing.requiresDeveloperToApprove() + } + + const repository = new PcListingsRepository(ctx.prisma) + const pcListing = await repository.getById(input.pcListingId) + + if (!pcListing) return ResourceError.pcListing.notFound() + + if (pcListing.status !== ApprovalStatus.PENDING) { + return ResourceError.pcListing.notPending() + } + + if (!isModerator && isDeveloper) { + const isVerified = await repository.isDeveloperVerifiedForEmulator( + ctx.session.user.id, + pcListing.emulatorId, + ) + + if (!isVerified) { + return ResourceError.pcListing.mustBeVerifiedToApprove() + } + } + + const approvedListing = await repository.approve(input.pcListingId, ctx.session.user.id) + + if (pcListing.authorId) { + await applyTrustAction({ + userId: pcListing.authorId, + action: TrustAction.LISTING_APPROVED, + context: { + pcListingId: input.pcListingId, + adminUserId: ctx.session.user.id, + reason: 'listing_approved', + }, + }) + } + + invalidatePcListingStatsCache() + + await invalidatePcListingSeo({ + id: input.pcListingId, + gameId: pcListing.gameId, + cpuId: pcListing.cpuId, + gpuId: pcListing.gpuId, + }) + + notificationEventEmitter.emitNotificationEvent({ + eventType: NOTIFICATION_EVENTS.PC_LISTING_APPROVED, + entityType: 'pcListing', + entityId: input.pcListingId, + triggeredBy: ctx.session.user.id, + payload: { + pcListingId: input.pcListingId, + gameId: pcListing.gameId, + approvedBy: ctx.session.user.id, + approvedAt: approvedListing.processedAt, + }, + }) + + return approvedListing + }), + + reject: protectedProcedure.input(RejectPcListingSchema).mutation(async ({ ctx, input }) => { + const isModerator = hasRolePermission(ctx.session.user.role, Role.MODERATOR) + const isDeveloper = hasRolePermission(ctx.session.user.role, Role.DEVELOPER) + + if (!isModerator && !isDeveloper) { + return ResourceError.pcListing.requiresDeveloperToReject() + } + + const repository = new PcListingsRepository(ctx.prisma) + const pcListing = await repository.getById(input.pcListingId) + + if (!pcListing) return ResourceError.pcListing.notFound() + + if (pcListing.status !== ApprovalStatus.PENDING) { + return ResourceError.pcListing.notPending() + } + + if (!isModerator && isDeveloper) { + const isVerified = await repository.isDeveloperVerifiedForEmulator( + ctx.session.user.id, + pcListing.emulatorId, + ) + + if (!isVerified) { + return ResourceError.pcListing.mustBeVerifiedToReject() + } + } + + const rejectedListing = await repository.reject( + input.pcListingId, + ctx.session.user.id, + input.notes, + ) + + if (pcListing.authorId) { + await applyTrustAction({ + userId: pcListing.authorId, + action: TrustAction.LISTING_REJECTED, + context: { + pcListingId: input.pcListingId, + adminUserId: ctx.session.user.id, + reason: input.notes || 'listing_rejected', + }, + }) + } + + invalidatePcListingStatsCache() + + notificationEventEmitter.emitNotificationEvent({ + eventType: NOTIFICATION_EVENTS.PC_LISTING_REJECTED, + entityType: 'pcListing', + entityId: input.pcListingId, + triggeredBy: ctx.session.user.id, + payload: { + pcListingId: input.pcListingId, + rejectedBy: ctx.session.user.id, + rejectedAt: rejectedListing.processedAt, + rejectionReason: input.notes, + }, + }) + + return rejectedListing + }), + + resetToPending: moderatorProcedure + .input(ResetPcListingToPendingSchema) + .mutation(async ({ ctx, input }) => { + const repository = new PcListingsRepository(ctx.prisma) + const pcListing = await repository.getById(input.pcListingId) + + if (!pcListing) return ResourceError.pcListing.notFound() + + if (pcListing.status === ApprovalStatus.PENDING) { + return ResourceError.pcListing.alreadyPending() + } + + const updatedListing = await ctx.prisma.pcListing.update({ + where: { id: input.pcListingId }, + data: { + status: ApprovalStatus.PENDING, + processedByUserId: null, + processedAt: null, + processedNotes: null, + }, + }) + + invalidatePcListingStatsCache() + + if (pcListing.status === ApprovalStatus.APPROVED) { + await invalidatePcListingSeo({ + id: input.pcListingId, + gameId: pcListing.gameId, + cpuId: pcListing.cpuId, + gpuId: pcListing.gpuId, + }) + } + + return updatedListing + }), + + getProcessed: superAdminProcedure.input(GetProcessedPcSchema).query(async ({ ctx, input }) => { + const { page, limit, filterStatus, search, sortField, sortDirection } = input + const skip = (page - 1) * limit + + const baseWhere: Prisma.PcListingWhereInput = { + NOT: { status: ApprovalStatus.PENDING }, + ...(filterStatus ? { status: filterStatus } : {}), + } + + const searchWhere: Prisma.PcListingWhereInput = search + ? { + OR: [ + { game: { title: { contains: search, mode: 'insensitive' } } }, + { game: { system: { name: { contains: search, mode: 'insensitive' } } } }, + { cpu: { modelName: { contains: search, mode: 'insensitive' } } }, + { cpu: { brand: { name: { contains: search, mode: 'insensitive' } } } }, + { gpu: { modelName: { contains: search, mode: 'insensitive' } } }, + { gpu: { brand: { name: { contains: search, mode: 'insensitive' } } } }, + { emulator: { name: { contains: search, mode: 'insensitive' } } }, + { author: { name: { contains: search, mode: 'insensitive' } } }, + { processedNotes: { contains: search, mode: 'insensitive' } }, + { notes: { contains: search, mode: 'insensitive' } }, + ], + } + : {} + + const where = buildPcListingWhere({ ...baseWhere, ...searchWhere }, true) + const orderBy = buildProcessedPcListingOrderBy(sortField, sortDirection) + + const [pcListings, total] = await Promise.all([ + ctx.prisma.pcListing.findMany({ + where, + include: pcListingAdminInclude, + orderBy, + skip, + take: limit, + }), + ctx.prisma.pcListing.count({ where }), + ]) + + return { + pcListings, + pagination: paginate({ total, page, limit }), + } + }), + + overrideStatus: superAdminProcedure + .input(OverridePcApprovalStatusSchema) + .mutation(async ({ ctx, input }) => { + const { pcListingId, newStatus, overrideNotes } = input + const superAdminUserId = ctx.session.user.id + + const pcListing = await ctx.prisma.pcListing.findUnique({ + where: { id: pcListingId }, + select: { + id: true, + status: true, + gameId: true, + cpuId: true, + gpuId: true, + authorId: true, + processedNotes: true, + }, + }) + + if (!pcListing) return ResourceError.pcListing.notFound() + + const updatedPcListing = await ctx.prisma.pcListing.update({ + where: { id: pcListingId }, + data: + newStatus === ApprovalStatus.PENDING + ? { + status: newStatus, + processedByUserId: null, + processedAt: null, + processedNotes: null, + } + : { + status: newStatus, + processedByUserId: superAdminUserId, + processedAt: new Date(), + processedNotes: overrideNotes ?? pcListing.processedNotes, + }, + }) + + invalidatePcListingStatsCache() + + if (pcListing.status === ApprovalStatus.APPROVED || newStatus === ApprovalStatus.APPROVED) { + await invalidatePcListingSeo({ + id: pcListingId, + gameId: pcListing.gameId, + cpuId: pcListing.cpuId, + gpuId: pcListing.gpuId, + }) + } + + const trustAction = getProcessedStatusTrustAction({ + previousStatus: pcListing.status, + newStatus, + authorId: pcListing.authorId, + }) + if (trustAction) { + await applyTrustAction({ + userId: trustAction.userId, + action: trustAction.action, + context: { + pcListingId, + adminUserId: superAdminUserId, + reason: overrideNotes || 'pc_listing_status_override', + }, + }) + } + + if (newStatus === ApprovalStatus.APPROVED || newStatus === ApprovalStatus.REJECTED) { + notificationEventEmitter.emitNotificationEvent({ + eventType: + newStatus === ApprovalStatus.APPROVED + ? NOTIFICATION_EVENTS.PC_LISTING_APPROVED + : NOTIFICATION_EVENTS.PC_LISTING_REJECTED, + entityType: 'pcListing', + entityId: pcListingId, + triggeredBy: superAdminUserId, + payload: + newStatus === ApprovalStatus.APPROVED + ? { + pcListingId, + gameId: pcListing.gameId, + approvedBy: superAdminUserId, + approvedAt: updatedPcListing.processedAt, + } + : { + pcListingId, + rejectedBy: superAdminUserId, + rejectedAt: updatedPcListing.processedAt, + rejectionReason: overrideNotes, + }, + }) + } + + return updatedPcListing + }), + + bulkApprove: protectedProcedure + .input(BulkApprovePcListingsSchema) + .mutation(async ({ ctx, input }) => { + const isModerator = hasRolePermission(ctx.session.user.role, Role.MODERATOR) + const isDeveloper = hasRolePermission(ctx.session.user.role, Role.DEVELOPER) + + if (!isModerator && !isDeveloper) { + return ResourceError.pcListing.requiresDeveloperToApprove() + } + + const pendingListings = await ctx.prisma.pcListing.findMany({ + where: { id: { in: input.pcListingIds }, status: ApprovalStatus.PENDING }, + select: { id: true, gameId: true, cpuId: true, gpuId: true, authorId: true }, + }) + const approvedAt = new Date() + + const result = await ctx.prisma.pcListing.updateMany({ + where: { id: { in: pendingListings.map((l) => l.id) } }, + data: { + status: ApprovalStatus.APPROVED, + processedAt: approvedAt, + processedByUserId: ctx.session.user.id, + }, + }) + + const listingsWithAuthor = pendingListings.filter( + (l): l is typeof l & { authorId: string } => l.authorId !== null, + ) + await Promise.all( + listingsWithAuthor.map((listing) => + applyTrustAction({ + userId: listing.authorId, + action: TrustAction.LISTING_APPROVED, + context: { + pcListingId: listing.id, + adminUserId: ctx.session.user.id, + reason: 'bulk_listing_approved', + }, + }), + ), + ) + + invalidatePcListingStatsCache() + + await invalidatePcListingsSeo(pendingListings) + + for (const listing of pendingListings) { + notificationEventEmitter.emitNotificationEvent({ + eventType: NOTIFICATION_EVENTS.PC_LISTING_APPROVED, + entityType: 'pcListing', + entityId: listing.id, + triggeredBy: ctx.session.user.id, + payload: { + pcListingId: listing.id, + gameId: listing.gameId, + approvedBy: ctx.session.user.id, + approvedAt, + bulk: true, + }, + }) + } + + return { count: result.count } + }), + + bulkReject: protectedProcedure + .input(BulkRejectPcListingsSchema) + .mutation(async ({ ctx, input }) => { + const isModerator = hasRolePermission(ctx.session.user.role, Role.MODERATOR) + const isDeveloper = hasRolePermission(ctx.session.user.role, Role.DEVELOPER) + + if (!isModerator && !isDeveloper) { + return ResourceError.pcListing.requiresDeveloperToReject() + } + + const pendingListings = await ctx.prisma.pcListing.findMany({ + where: { id: { in: input.pcListingIds }, status: ApprovalStatus.PENDING }, + select: { id: true, authorId: true }, + }) + + const result = await ctx.prisma.pcListing.updateMany({ + where: { + id: { in: pendingListings.map((l) => l.id) }, + }, + data: { + status: ApprovalStatus.REJECTED, + processedAt: new Date(), + processedByUserId: ctx.session.user.id, + processedNotes: input.notes, + }, + }) + + const listingsWithAuthor = pendingListings.filter( + (l): l is typeof l & { authorId: string } => l.authorId !== null, + ) + await Promise.all( + listingsWithAuthor.map((listing) => + applyTrustAction({ + userId: listing.authorId, + action: TrustAction.LISTING_REJECTED, + context: { + pcListingId: listing.id, + adminUserId: ctx.session.user.id, + reason: input.notes || 'bulk_listing_rejected', + }, + }), + ), + ) + + invalidatePcListingStatsCache() + + for (const listing of pendingListings) { + notificationEventEmitter.emitNotificationEvent({ + eventType: NOTIFICATION_EVENTS.PC_LISTING_REJECTED, + entityType: 'pcListing', + entityId: listing.id, + triggeredBy: ctx.session.user.id, + payload: { + pcListingId: listing.id, + rejectedBy: ctx.session.user.id, + rejectedAt: new Date(), + rejectionReason: input.notes, + }, + }) + } + + return { count: result.count } + }), + + autoRejectRiskyPreview: adminProcedure.query(async ({ ctx }) => { + const repository = new PcListingsRepository(ctx.prisma) + + return getAutoRejectableReviewRiskPreviewForCandidates({ + prisma: ctx.prisma, + loadCandidates: () => repository.getPendingListingRiskCandidates({}), + }) + }), + + autoRejectRisky: adminProcedure.mutation(async ({ ctx }) => { + const adminUserId = ctx.session.user.id + + const adminUserExists = await ctx.prisma.user.findUnique({ + where: { id: adminUserId }, + select: { id: true }, + }) + if (!adminUserExists) return ResourceError.user.notInDatabase(adminUserId) + + return autoRejectRiskyPcReports({ + prisma: ctx.prisma, + adminUserId, + }) + }), + + get: permissionProcedure(PERMISSIONS.APPROVE_LISTINGS) + .input(GetAllPcListingsAdminSchema) + .query(async ({ ctx, input }) => { + const { + page = 1, + limit = 20, + sortField, + sortDirection, + search, + statusFilter, + systemFilter, + emulatorFilter, + osFilter, + } = input + + const offset = (page - 1) * limit + + const baseWhere: Prisma.PcListingWhereInput = { + ...(statusFilter ? { status: statusFilter } : {}), + ...(systemFilter ? { game: { systemId: systemFilter } } : {}), + ...(emulatorFilter ? { emulatorId: emulatorFilter } : {}), + ...(osFilter ? { os: osFilter } : {}), + ...(search + ? { + OR: [ + { game: { title: { contains: search, mode: 'insensitive' } } }, + { cpu: { modelName: { contains: search, mode: 'insensitive' } } }, + { gpu: { modelName: { contains: search, mode: 'insensitive' } } }, + { emulator: { name: { contains: search, mode: 'insensitive' } } }, + { author: { name: { contains: search, mode: 'insensitive' } } }, + ], + } + : {}), + } + + const where = buildPcListingWhere(baseWhere, true) + const orderBy = buildPcListingOrderBy(sortField, sortDirection ?? undefined) + + const [pcListings, total] = await Promise.all([ + ctx.prisma.pcListing.findMany({ + where, + include: pcListingAdminInclude, + orderBy, + skip: offset, + take: limit, + }), + ctx.prisma.pcListing.count({ where }), + ]) + + return { + pcListings, + pagination: paginate({ total: total, page, limit: limit }), + } + }), + + getForEdit: permissionProcedure(PERMISSIONS.APPROVE_LISTINGS) + .input(GetPcListingForAdminEditSchema) + .query(async ({ ctx, input }) => { + const pcListing = await ctx.prisma.pcListing.findUnique({ + where: { id: input.id }, + include: pcListingDetailInclude, + }) + + return pcListing ?? ResourceError.pcListing.notFound() + }), + + updateListing: permissionProcedure(PERMISSIONS.APPROVE_LISTINGS) + .input(UpdatePcListingAdminSchema) + .mutation(async ({ ctx, input }) => { + const { id, customFieldValues, ...data } = input + + const pcListing = await ctx.prisma.pcListing.findUnique({ + where: { id }, + include: { customFieldValues: true }, + }) + + if (!pcListing) return ResourceError.pcListing.notFound() + + const updatedPcListing = await ctx.prisma.pcListing.update({ + where: { id }, + data: { ...data, updatedAt: new Date() }, + include: pcListingDetailInclude, + }) + + if (customFieldValues) { + await ctx.prisma.pcListingCustomFieldValue.deleteMany({ + where: { pcListingId: id }, + }) + + if (customFieldValues.length > 0) { + await ctx.prisma.pcListingCustomFieldValue.createMany({ + data: customFieldValues.map((cfv) => ({ + pcListingId: id, + customFieldDefinitionId: cfv.customFieldDefinitionId, + value: toPrismaCustomFieldValue(cfv.value), + })), + }) + } + } + + const previousSeoTarget = { + id, + gameId: pcListing.gameId, + cpuId: pcListing.cpuId, + gpuId: pcListing.gpuId, + } + const nextSeoTarget = { + id, + gameId: updatedPcListing.gameId, + cpuId: updatedPcListing.cpuId, + gpuId: updatedPcListing.gpuId, + } + const wasApproved = pcListing.status === ApprovalStatus.APPROVED + const isApproved = updatedPcListing.status === ApprovalStatus.APPROVED + + if (wasApproved && isApproved) { + await invalidatePcListingSeoForUpdate(previousSeoTarget, nextSeoTarget) + } else if (wasApproved) { + await invalidatePcListingSeo(previousSeoTarget) + } else if (isApproved) { + await invalidatePcListingSeo(nextSeoTarget) + } + + return updatedPcListing + }), + + stats: viewStatisticsProcedure.query(async ({ ctx }) => { + const cached = listingStatsCache.get(PC_LISTING_STATS_CACHE_KEY) + if (cached) return cached + + const repository = new PcListingsRepository(ctx.prisma) + const stats = await repository.stats() + + listingStatsCache.set(PC_LISTING_STATS_CACHE_KEY, stats) + return stats + }), +}) diff --git a/src/server/api/routers/pcListings/comments.ts b/src/server/api/routers/pcListings/comments.ts new file mode 100644 index 00000000..3dac592b --- /dev/null +++ b/src/server/api/routers/pcListings/comments.ts @@ -0,0 +1,500 @@ +import analytics from '@/lib/analytics' +import { AppError, ResourceError } from '@/lib/errors' +import { + CreatePcListingCommentSchema, + DeletePcListingCommentSchema, + GetPcListingCommentsSchema, + PinPcListingCommentSchema, + UnpinPcListingCommentSchema, + UpdatePcListingCommentSchema, + VotePcListingCommentSchema, +} from '@/schemas/pcListing' +import { createTRPCRouter, protectedProcedure, publicProcedure } from '@/server/api/trpc' +import { buildCommentTree, findCommentWithParent } from '@/server/api/utils/commentTree' +import { canManageCommentPins } from '@/server/api/utils/pinPermissions' +import { NOTIFICATION_EVENTS, notificationEventEmitter } from '@/server/notifications/eventEmitter' +import { logAudit } from '@/server/services/audit.service' +import { isUserBanned } from '@/server/utils/query-builders' +import { checkSpamContent } from '@/server/utils/spam-check' +import { handleCommentVoteTrustEffects } from '@/server/utils/vote-trust-effects' +import { canDeleteComment, canEditComment } from '@/utils/permissions' +import { AuditAction, AuditEntityType } from '@orm' + +export const commentsRouter = createTRPCRouter({ + get: publicProcedure.input(GetPcListingCommentsSchema).query(async ({ ctx, input }) => { + const { pcListingId, sortBy = 'newest', limit = 50, offset = 0 } = input + + const pcListing = await ctx.prisma.pcListing.findUnique({ + where: { id: pcListingId }, + select: { + id: true, + emulatorId: true, + pinnedCommentId: true, + pinnedAt: true, + pinnedByUser: { select: { id: true, name: true, profileImage: true, role: true } }, + }, + }) + + if (!pcListing) return ResourceError.pcListing.notFound() + + const allComments = await ctx.prisma.pcListingComment.findMany({ + where: { + pcListingId, + deletedAt: null, + }, + include: { + user: { + select: { id: true, name: true, profileImage: true, role: true }, + }, + }, + }) + + let userCommentVotes: Record = {} + if (ctx.session?.user) { + const votes = await ctx.prisma.pcListingCommentVote.findMany({ + where: { + userId: ctx.session.user.id, + comment: { pcListingId }, + }, + select: { commentId: true, value: true }, + }) + + userCommentVotes = votes.reduce( + (acc, vote) => ({ + ...acc, + [vote.commentId]: vote.value, + }), + {} as Record, + ) + } + + const commentsWithVotes = allComments.map((comment) => ({ + ...comment, + userVote: userCommentVotes[comment.id] ?? null, + })) + + let commentsTree = buildCommentTree(commentsWithVotes, { replySort: 'asc' }) + + commentsTree.sort((a, b) => { + switch (sortBy) { + case 'oldest': + return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() + case 'score': + return (b.score ?? 0) - (a.score ?? 0) + case 'newest': + default: + return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() + } + }) + + let pinnedCommentPayload: { + comment: (typeof commentsTree)[number] + parentId: string | null + isReply: boolean + } | null = null + + if (pcListing.pinnedCommentId) { + const located = findCommentWithParent(commentsTree, pcListing.pinnedCommentId) + + if (located) { + pinnedCommentPayload = { + comment: located.comment, + parentId: located.parent?.id ?? null, + isReply: Boolean(located.parent), + } + + if (!located.parent) { + commentsTree = commentsTree.filter((comment) => comment.id !== located.comment.id) + } + } + } + + const paginatedComments = commentsTree.slice(offset, offset + limit) + + return { + comments: paginatedComments, + pinnedComment: pinnedCommentPayload + ? { + comment: pinnedCommentPayload.comment, + isReply: pinnedCommentPayload.isReply, + parentId: pinnedCommentPayload.parentId, + pinnedBy: pcListing.pinnedByUser, + pinnedAt: pcListing.pinnedAt, + } + : null, + } + }), + + create: protectedProcedure + .input(CreatePcListingCommentSchema) + .mutation(async ({ ctx, input }) => { + const { pcListingId, content, parentId, humanVerificationToken } = input + const userId = ctx.session.user.id + + const pcListing = await ctx.prisma.pcListing.findUnique({ + where: { id: pcListingId }, + }) + + if (!pcListing) return ResourceError.pcListing.notFound() + + if (parentId) { + const parentComment = await ctx.prisma.pcListingComment.findUnique({ + where: { id: parentId }, + }) + + if (!parentComment) return ResourceError.comment.parentNotFound() + } + + await checkSpamContent({ + prisma: ctx.prisma, + userId, + content, + entityType: 'pcComment', + challengeMode: 'challenge', + humanVerificationToken, + headers: ctx.headers, + }) + + const comment = await ctx.prisma.pcListingComment.create({ + data: { content, userId, pcListingId, parentId }, + include: { + user: { + select: { id: true, name: true, profileImage: true, role: true }, + }, + }, + }) + + notificationEventEmitter.emitNotificationEvent({ + eventType: parentId + ? NOTIFICATION_EVENTS.COMMENT_REPLIED + : NOTIFICATION_EVENTS.LISTING_COMMENTED, + entityType: 'pcListing', + entityId: pcListingId, + triggeredBy: userId, + payload: { + pcListingId, + commentId: comment.id, + parentId, + commentText: content, + }, + }) + + analytics.engagement.comment({ + action: parentId ? 'reply' : 'created', + commentId: comment.id, + listingId: pcListingId, + isReply: !!parentId, + contentLength: content.length, + }) + + return comment + }), + + edit: protectedProcedure.input(UpdatePcListingCommentSchema).mutation(async ({ ctx, input }) => { + const comment = await ctx.prisma.pcListingComment.findUnique({ + where: { id: input.commentId }, + include: { user: { select: { id: true } } }, + }) + + if (!comment) return ResourceError.comment.notFound() + if (comment.deletedAt) return ResourceError.comment.cannotEditDeleted() + + const canEdit = canEditComment(ctx.session.user.role, comment.user.id, ctx.session.user.id) + + if (!canEdit) { + return ResourceError.comment.noPermission('edit') + } + + return ctx.prisma.pcListingComment.update({ + where: { id: input.commentId }, + data: { + content: input.content, + isEdited: true, + updatedAt: new Date(), + }, + include: { + user: { + select: { id: true, name: true, profileImage: true, role: true }, + }, + }, + }) + }), + + delete: protectedProcedure + .input(DeletePcListingCommentSchema) + .mutation(async ({ ctx, input }) => { + const comment = await ctx.prisma.pcListingComment.findUnique({ + where: { id: input.commentId }, + include: { + user: { select: { id: true } }, + pcListing: { + select: { + id: true, + pinnedCommentId: true, + }, + }, + }, + }) + + if (!comment) return ResourceError.comment.notFound() + if (comment.deletedAt) return ResourceError.comment.alreadyDeleted() + + const canDelete = canDeleteComment( + ctx.session.user.role, + comment.user.id, + ctx.session.user.id, + ) + + if (!canDelete) { + return ResourceError.comment.noPermission('delete') + } + + const wasPinned = comment.pcListing?.pinnedCommentId === comment.id + + const updatedComment = await ctx.prisma.pcListingComment.update({ + where: { id: input.commentId }, + data: { deletedAt: new Date() }, + }) + + if (wasPinned && comment.pcListing) { + await ctx.prisma.pcListing.update({ + where: { id: comment.pcListing.id }, + data: { + pinnedCommentId: null, + pinnedByUserId: null, + pinnedAt: null, + }, + }) + + void logAudit(ctx.prisma, { + actorId: ctx.session.user.id, + action: AuditAction.UNPIN, + entityType: AuditEntityType.COMMENT, + entityId: comment.id, + metadata: { + pcListingId: comment.pcListing.id, + reason: 'comment_deleted', + }, + }) + } + + return updatedComment + }), + + vote: protectedProcedure.input(VotePcListingCommentSchema).mutation(async ({ ctx, input }) => { + const { commentId, value } = input + const userId = ctx.session.user.id + + if (await isUserBanned(ctx.prisma, userId)) { + return AppError.shadowBanned() + } + + const comment = await ctx.prisma.pcListingComment.findUnique({ + where: { id: commentId }, + }) + + if (!comment) { + return ResourceError.comment.notFound() + } + + return await ctx.prisma.$transaction(async (tx) => { + const existingVote = await tx.pcListingCommentVote.findUnique({ + where: { userId_commentId: { userId, commentId } }, + }) + + let voteResult + let scoreChange: number + let trustAction: 'upvote' | 'downvote' | 'change' | 'remove' | null + + if (existingVote) { + if (existingVote.value === value) { + await tx.pcListingCommentVote.delete({ + where: { userId_commentId: { userId, commentId } }, + }) + scoreChange = existingVote.value ? -1 : 1 + voteResult = { message: 'Vote removed' } + trustAction = 'remove' + } else { + voteResult = await tx.pcListingCommentVote.update({ + where: { userId_commentId: { userId, commentId } }, + data: { value }, + }) + scoreChange = value ? 2 : -2 + trustAction = 'change' + } + } else { + voteResult = await tx.pcListingCommentVote.create({ + data: { userId, commentId, value }, + }) + scoreChange = value ? 1 : -1 + trustAction = value ? 'upvote' : 'downvote' + } + + const updatedComment = await tx.pcListingComment.update({ + where: { id: commentId }, + data: { score: { increment: scoreChange } }, + }) + + if (trustAction) { + await handleCommentVoteTrustEffects({ + tx, + trustAction, + newValue: value, + previousValue: existingVote?.value ?? null, + commentAuthorId: comment.userId, + voterId: userId, + commentId, + parentEntityId: comment.pcListingId, + listingType: 'pc', + updatedScore: updatedComment.score, + scoreChange, + }) + } + + if (trustAction !== null && trustAction !== 'remove') { + notificationEventEmitter.emitNotificationEvent({ + eventType: NOTIFICATION_EVENTS.COMMENT_VOTED, + entityType: 'comment', + entityId: comment.id, + triggeredBy: userId, + payload: { + pcListingId: comment.pcListingId, + commentId: comment.id, + voteValue: value, + }, + }) + } + + return voteResult + }) + }), + + pinComment: protectedProcedure + .input(PinPcListingCommentSchema) + .mutation(async ({ ctx, input }) => { + const { commentId, pcListingId, replaceExisting } = input + const userId = ctx.session.user.id + const userRole = ctx.session.user.role + + const comment = await ctx.prisma.pcListingComment.findUnique({ + where: { id: commentId }, + include: { + pcListing: { + select: { + id: true, + emulatorId: true, + pinnedCommentId: true, + pinnedByUserId: true, + }, + }, + }, + }) + + if (!comment) return ResourceError.comment.notFound() + if (comment.deletedAt) return ResourceError.comment.alreadyDeleted() + if (comment.pcListingId !== pcListingId) { + return AppError.badRequest('Comment does not belong to this PC listing') + } + if (!comment.pcListing) return ResourceError.pcListing.notFound() + + const pcListing = comment.pcListing + + const canPin = await canManageCommentPins({ + prisma: ctx.prisma, + userRole, + userId, + emulatorId: pcListing.emulatorId, + }) + + if (!canPin) return ResourceError.comment.noPermission('pin') + + if ( + pcListing.pinnedCommentId && + pcListing.pinnedCommentId !== comment.id && + !replaceExisting + ) { + return ResourceError.comment.alreadyPinned() + } + + const previousPinnedId = pcListing.pinnedCommentId + + const updatedPcListing = await ctx.prisma.pcListing.update({ + where: { id: pcListing.id }, + data: { + pinnedCommentId: comment.id, + pinnedByUserId: userId, + pinnedAt: new Date(), + }, + select: { + id: true, + pinnedCommentId: true, + pinnedAt: true, + }, + }) + + void logAudit(ctx.prisma, { + actorId: userId, + action: AuditAction.PIN, + entityType: AuditEntityType.COMMENT, + entityId: comment.id, + metadata: { + pcListingId: pcListing.id, + previousPinnedCommentId: previousPinnedId, + }, + }) + + return updatedPcListing + }), + + unpinComment: protectedProcedure + .input(UnpinPcListingCommentSchema) + .mutation(async ({ ctx, input }) => { + const { pcListingId } = input + const userId = ctx.session.user.id + const userRole = ctx.session.user.role + + const pcListing = await ctx.prisma.pcListing.findUnique({ + where: { id: pcListingId }, + select: { + id: true, + emulatorId: true, + pinnedCommentId: true, + }, + }) + + if (!pcListing) return ResourceError.pcListing.notFound() + if (!pcListing.pinnedCommentId) return ResourceError.comment.notPinned() + + const canUnpin = await canManageCommentPins({ + prisma: ctx.prisma, + userRole, + userId, + emulatorId: pcListing.emulatorId, + }) + + if (!canUnpin) return ResourceError.comment.noPermission('unpin') + + const previousPinnedId = pcListing.pinnedCommentId + + await ctx.prisma.pcListing.update({ + where: { id: pcListing.id }, + data: { + pinnedCommentId: null, + pinnedByUserId: null, + pinnedAt: null, + }, + }) + + void logAudit(ctx.prisma, { + actorId: userId, + action: AuditAction.UNPIN, + entityType: AuditEntityType.COMMENT, + entityId: previousPinnedId, + metadata: { + pcListingId: pcListing.id, + }, + }) + + return { success: true } + }), +}) diff --git a/src/server/api/routers/pcListings/core.ts b/src/server/api/routers/pcListings/core.ts new file mode 100644 index 00000000..66f219b7 --- /dev/null +++ b/src/server/api/routers/pcListings/core.ts @@ -0,0 +1,602 @@ +import analytics from '@/lib/analytics' +import { AppError, ResourceError } from '@/lib/errors' +import { applyTrustAction } from '@/lib/trust/service' +import { + CreatePcListingSchema, + CreatePcPresetSchema, + DeletePcListingSchema, + DeletePcPresetSchema, + GetPcListingByIdSchema, + GetPcListingForUserEditSchema, + GetPcListingUserVoteSchema, + GetPcListingVerificationsSchema, + GetPcListingsSchema, + GetPcPresetsSchema, + RemovePcListingVerificationSchema, + UpdatePcListingUserSchema, + UpdatePcPresetSchema, + VerifyPcListingAdminSchema, + VotePcListingSchema, +} from '@/schemas/pcListing' +import { + createListingProcedure, + createTRPCRouter, + permissionProcedure, + protectedProcedure, + publicProcedure, +} from '@/server/api/trpc' +import { pcListingDetailInclude } from '@/server/api/utils/pcListingHelpers' +import { + invalidatePcListingSeo, + invalidatePcListingSeoForUpdate, +} from '@/server/cache/invalidation' +import { NOTIFICATION_EVENTS, notificationEventEmitter } from '@/server/notifications/eventEmitter' +import { PcListingsRepository } from '@/server/repositories/pc-listings.repository' +import { UserPcPresetsRepository } from '@/server/repositories/user-pc-presets.repository' +import { attachReviewRiskProfileForViewer } from '@/server/services/review-risk.service' +import { normalizeCustomFieldValues } from '@/server/utils/custom-field-values' +import { isUserBanned } from '@/server/utils/query-builders' +import { validatePagination } from '@/server/utils/security-validation' +import { checkSpamContent } from '@/server/utils/spam-check' +import { updatePcListingVoteCounts } from '@/server/utils/vote-counts' +import { handleListingVoteTrustEffects } from '@/server/utils/vote-trust-effects' +import { PERMISSIONS, roleIncludesRole } from '@/utils/permission-system' +import { hasRolePermission, isModerator } from '@/utils/permissions' +import { ApprovalStatus, Role, TrustAction } from '@orm' +import { invalidatePcListingStatsCache, toPrismaCustomFieldValue } from './utils' + +export const coreRouter = createTRPCRouter({ + get: publicProcedure.input(GetPcListingsSchema).query(async ({ ctx, input }) => { + const repository = new PcListingsRepository(ctx.prisma) + const canSeeBannedUsers = ctx.session?.user ? isModerator(ctx.session.user.role) : false + + const { page, limit } = validatePagination(input.page, input.limit, 50) + + const result = await repository.list({ + ...input, + sortDirection: input.sortDirection ?? undefined, + userId: ctx.session?.user?.id, + userRole: ctx.session?.user?.role, + showNsfw: ctx.session?.user?.showNsfw, + canSeeBannedUsers, + approvalStatus: input.approvalStatus || ApprovalStatus.APPROVED, + page, + limit, + }) + + return { + pcListings: result.pcListings, + pagination: result.pagination, + } + }), + + byId: publicProcedure.input(GetPcListingByIdSchema).query(async ({ ctx, input }) => { + const repository = new PcListingsRepository(ctx.prisma) + const userRole = ctx.session?.user?.role + const canSeeBannedUsers = userRole ? isModerator(userRole) : false + + const pcListing = await repository.getByIdWithDetails( + input.id, + canSeeBannedUsers, + ctx.session?.user?.id, + ) + + if (!pcListing) return ResourceError.pcListing.notFound() + + return await attachReviewRiskProfileForViewer({ + prisma: ctx.prisma, + listing: pcListing, + userRole, + }) + }), + + canEdit: protectedProcedure.input(GetPcListingForUserEditSchema).query(async ({ ctx, input }) => { + const EDIT_TIME_LIMIT_MINUTES = 60 + + const pcListing = await ctx.prisma.pcListing.findUnique({ + where: { id: input.id }, + select: { authorId: true, status: true, processedAt: true }, + }) + + if (!pcListing) { + return { + canEdit: false, + isOwner: false, + reason: 'PC listing not found', + } + } + + const isOwner = pcListing.authorId === ctx.session.user.id + + if (hasRolePermission(ctx.session.user.role, Role.MODERATOR)) { + return { + canEdit: true, + isOwner, + reason: 'Moderator can edit any PC listing', + } + } + + if (!isOwner) { + return { canEdit: false, isOwner: false, reason: 'Not your PC listing' } + } + + if (pcListing.status === ApprovalStatus.PENDING) { + return { + canEdit: true, + isOwner: true, + reason: 'Pending PC listings can always be edited', + isPending: true, + } + } + + if (pcListing.status === ApprovalStatus.REJECTED) { + return { + canEdit: false, + isOwner: true, + reason: 'Rejected PC listings cannot be edited. Please create a new listing.', + } + } + + if (pcListing.status === ApprovalStatus.APPROVED) { + if (!pcListing.processedAt) { + return { + canEdit: false, + isOwner: true, + reason: 'No approval time found', + } + } + + const now = new Date() + const timeSinceApproval = now.getTime() - pcListing.processedAt.getTime() + const timeLimit = EDIT_TIME_LIMIT_MINUTES * 60 * 1000 + + const remainingTime = timeLimit - timeSinceApproval + const remainingMinutes = Math.floor(remainingTime / (60 * 1000)) + + if (timeSinceApproval > timeLimit) { + return { + canEdit: false, + isOwner: true, + reason: `Edit time expired (${EDIT_TIME_LIMIT_MINUTES} minutes after approval)`, + timeExpired: true, + } + } + + return { + canEdit: true, + isOwner: true, + remainingMinutes: Math.max(0, remainingMinutes), + remainingTime: Math.max(0, remainingTime), + isApproved: true, + } + } + + return { + canEdit: false, + isOwner: true, + reason: 'Invalid PC listing status', + } + }), + + getForUserEdit: protectedProcedure + .input(GetPcListingForUserEditSchema) + .query(async ({ ctx, input }) => { + const pcListing = await ctx.prisma.pcListing.findUnique({ + where: { id: input.id }, + include: { + ...pcListingDetailInclude, + emulator: { + include: { + customFieldDefinitions: { + orderBy: [{ categoryId: 'asc' }, { categoryOrder: 'asc' }, { displayOrder: 'asc' }], + }, + }, + }, + }, + }) + + if (!pcListing) return ResourceError.pcListing.notFound() + + if ( + pcListing.authorId !== ctx.session.user.id && + !roleIncludesRole(ctx.session.user.role, Role.MODERATOR) + ) { + return ResourceError.pcListing.canOnlyEditOwn() + } + + return pcListing + }), + + create: createListingProcedure.input(CreatePcListingSchema).mutation(async ({ ctx, input }) => { + const { humanVerificationToken, ...payload } = input + const authorId = ctx.session.user.id + + await checkSpamContent({ + prisma: ctx.prisma, + userId: authorId, + content: payload.notes ?? '', + entityType: 'pcListing', + challengeMode: 'challenge', + humanVerificationToken, + headers: ctx.headers, + }) + + const repository = new PcListingsRepository(ctx.prisma) + const newListing = await repository.create({ + authorId, + userRole: ctx.session.user.role, + gameId: payload.gameId, + cpuId: payload.cpuId, + gpuId: payload.gpuId ?? null, + emulatorId: payload.emulatorId, + performanceId: payload.performanceId, + memorySize: payload.memorySize, + os: payload.os, + osVersion: payload.osVersion, + notes: payload.notes ?? null, + customFieldValues: normalizeCustomFieldValues(payload.customFieldValues), + }) + + await applyTrustAction({ + userId: authorId, + action: TrustAction.LISTING_CREATED, + context: { pcListingId: newListing.id }, + }) + + invalidatePcListingStatsCache() + + if (newListing.status === ApprovalStatus.APPROVED) { + await invalidatePcListingSeo({ + id: newListing.id, + gameId: payload.gameId, + cpuId: payload.cpuId, + gpuId: payload.gpuId ?? null, + }) + } + + return newListing + }), + + delete: protectedProcedure.input(DeletePcListingSchema).mutation(async ({ ctx, input }) => { + const pcListing = await ctx.prisma.pcListing.findUnique({ + where: { id: input.id }, + }) + + if (!pcListing) return ResourceError.pcListing.notFound() + + if (pcListing.authorId !== ctx.session.user.id) { + return ResourceError.pcListing.canOnlyDeleteOwn() + } + + const deletedListing = await ctx.prisma.pcListing.delete({ + where: { id: input.id }, + }) + + invalidatePcListingStatsCache() + + if (pcListing.status === ApprovalStatus.APPROVED) { + await invalidatePcListingSeo(pcListing) + } + + return deletedListing + }), + + update: protectedProcedure.input(UpdatePcListingUserSchema).mutation(async ({ ctx, input }) => { + const EDIT_TIME_LIMIT_MINUTES = 60 + + const pcListing = await ctx.prisma.pcListing.findUnique({ + where: { id: input.id }, + select: { + authorId: true, + status: true, + processedAt: true, + gameId: true, + cpuId: true, + gpuId: true, + }, + }) + + if (!pcListing) return ResourceError.pcListing.notFound() + + if ( + pcListing.authorId !== ctx.session.user.id && + !hasRolePermission(ctx.session.user.role, Role.MODERATOR) + ) { + return ResourceError.pcListing.canOnlyEditOwn() + } + + switch (pcListing.status) { + case ApprovalStatus.REJECTED: + if (!hasRolePermission(ctx.session.user.role, Role.MODERATOR)) { + return ResourceError.pcListing.cannotEditRejected() + } + break + + case ApprovalStatus.APPROVED: { + if (hasRolePermission(ctx.session.user.role, Role.MODERATOR)) break + + if (!pcListing.processedAt) return ResourceError.pcListing.approvalTimeNotFound() + + const now = new Date() + const timeSinceApproval = now.getTime() - pcListing.processedAt.getTime() + const timeLimit = EDIT_TIME_LIMIT_MINUTES * 60 * 1000 + + if (timeSinceApproval > timeLimit) { + return ResourceError.pcListing.editTimeExpired(EDIT_TIME_LIMIT_MINUTES) + } + break + } + + case ApprovalStatus.PENDING: + break + + default: + return AppError.badRequest('Invalid PC listing status') + } + + const [performance] = await Promise.all([ + ctx.prisma.performanceScale.findUnique({ where: { id: input.performanceId } }), + ]) + + if (!performance) return ResourceError.performanceScale.notFound() + + const { id, customFieldValues, ...updateData } = input + + const updatedPcListing = await ctx.prisma.pcListing.update({ + where: { id }, + data: { ...updateData, updatedAt: new Date() }, + include: { + game: { include: { system: true } }, + cpu: { include: { brand: true } }, + gpu: { include: { brand: true } }, + emulator: true, + performance: true, + author: true, + customFieldValues: { + include: { customFieldDefinition: { include: { category: true } } }, + }, + }, + }) + + if (customFieldValues) { + await ctx.prisma.pcListingCustomFieldValue.deleteMany({ where: { pcListingId: id } }) + + if (customFieldValues.length > 0) { + await ctx.prisma.pcListingCustomFieldValue.createMany({ + data: customFieldValues.map((cfv) => ({ + pcListingId: id, + customFieldDefinitionId: cfv.customFieldDefinitionId, + value: toPrismaCustomFieldValue(cfv.value), + })), + }) + } + } + + if (pcListing.status === ApprovalStatus.APPROVED) { + await invalidatePcListingSeoForUpdate( + { + id, + gameId: pcListing.gameId, + cpuId: pcListing.cpuId, + gpuId: pcListing.gpuId, + }, + { + id, + gameId: updatedPcListing.gameId, + cpuId: updatedPcListing.cpuId, + gpuId: updatedPcListing.gpuId, + }, + ) + } + + return updatedPcListing + }), + + vote: protectedProcedure.input(VotePcListingSchema).mutation(async ({ ctx, input }) => { + const { pcListingId, value } = input + const userId = ctx.session.user.id + + if (await isUserBanned(ctx.prisma, userId)) { + return AppError.shadowBanned() + } + + const pcListing = await ctx.prisma.pcListing.findUnique({ + where: { id: pcListingId }, + }) + + if (!pcListing) return ResourceError.pcListing.notFound() + + const voteResult = await ctx.prisma.$transaction(async (tx) => { + const existingVote = await tx.pcListingVote.findUnique({ + where: { userId_pcListingId: { userId, pcListingId } }, + }) + + let result: { + vote: { userId: string; pcListingId: string; value: boolean } | null + action: 'created' | 'updated' | 'deleted' + previousValue: boolean | null + } + + if (!existingVote) { + const vote = await tx.pcListingVote.create({ + data: { userId, pcListingId, value }, + }) + await updatePcListingVoteCounts(tx, pcListingId, 'create', value) + result = { vote, action: 'created', previousValue: null } + } else if (existingVote.value === value) { + await tx.pcListingVote.delete({ + where: { userId_pcListingId: { userId, pcListingId } }, + }) + await updatePcListingVoteCounts(tx, pcListingId, 'delete', undefined, existingVote.value) + result = { vote: null, action: 'deleted', previousValue: existingVote.value } + } else { + const vote = await tx.pcListingVote.update({ + where: { userId_pcListingId: { userId, pcListingId } }, + data: { value }, + }) + await updatePcListingVoteCounts(tx, pcListingId, 'update', value, existingVote.value) + result = { vote, action: 'updated', previousValue: existingVote.value } + } + + await handleListingVoteTrustEffects({ + tx, + action: result.action, + currentValue: value, + previousValue: result.previousValue, + userId, + listingId: pcListingId, + listingType: 'pc', + authorId: pcListing.authorId, + }) + + return result + }) + + if (voteResult.action === 'created' || voteResult.action === 'updated') { + if (voteResult.vote) { + notificationEventEmitter.emitNotificationEvent({ + eventType: NOTIFICATION_EVENTS.LISTING_VOTED, + entityType: 'pcListing', + entityId: pcListingId, + triggeredBy: userId, + payload: { + pcListingId, + voteValue: value, + }, + }) + } + } + + const finalVoteValue = voteResult.action === 'deleted' ? null : value + analytics.engagement.vote({ + listingId: pcListingId, + voteValue: finalVoteValue, + previousVote: voteResult.previousValue, + }) + + return voteResult.vote + }), + + getUserVote: protectedProcedure + .input(GetPcListingUserVoteSchema) + .query(async ({ ctx, input }) => { + const repository = new PcListingsRepository(ctx.prisma) + const vote = await repository.getUserVote(ctx.session.user.id, input.pcListingId) + return { vote } + }), + + presets: { + get: protectedProcedure.input(GetPcPresetsSchema).query(async ({ ctx, input }) => { + const repository = new UserPcPresetsRepository(ctx.prisma) + const userId = input.userId ?? ctx.session.user.id + + return await repository.listByUserId(userId, { + requestingUserId: ctx.session.user.id, + userRole: ctx.session.user.role, + }) + }), + + create: protectedProcedure.input(CreatePcPresetSchema).mutation(async ({ ctx, input }) => { + const repository = new UserPcPresetsRepository(ctx.prisma) + + return await repository.create({ + userId: ctx.session.user.id, + name: input.name, + cpuId: input.cpuId, + gpuId: input.gpuId, + memorySize: input.memorySize, + os: input.os, + osVersion: input.osVersion, + }) + }), + + update: protectedProcedure.input(UpdatePcPresetSchema).mutation(async ({ ctx, input }) => { + const { id, ...data } = input + const repository = new UserPcPresetsRepository(ctx.prisma) + + return await repository.update(id, ctx.session.user.id, data, { + requestingUserRole: ctx.session.user.role, + }) + }), + + delete: protectedProcedure.input(DeletePcPresetSchema).mutation(async ({ ctx, input }) => { + const repository = new UserPcPresetsRepository(ctx.prisma) + await repository.delete(input.id, ctx.session.user.id, { + requestingUserRole: ctx.session.user.role, + }) + return { success: true } + }), + }, + + // Verification + verify: permissionProcedure(PERMISSIONS.APPROVE_LISTINGS) + .input(VerifyPcListingAdminSchema) + .mutation(async ({ ctx, input }) => { + const { pcListingId, notes } = input + const verifierId = ctx.session.user.id + + const pcListing = await ctx.prisma.pcListing.findUnique({ + where: { id: pcListingId }, + }) + + if (!pcListing) { + return ResourceError.pcListing.notFound() + } + + const existingVerification = await ctx.prisma.pcListingDeveloperVerification.findUnique({ + where: { + pcListingId_verifiedBy: { + pcListingId, + verifiedBy: verifierId, + }, + }, + }) + + if (existingVerification) { + return AppError.badRequest('You have already verified this listing') + } + + return ctx.prisma.pcListingDeveloperVerification.create({ + data: { + pcListingId, + verifiedBy: verifierId, + notes, + }, + include: { + developer: { select: { id: true, name: true } }, + }, + }) + }), + + removeVerification: permissionProcedure(PERMISSIONS.APPROVE_LISTINGS) + .input(RemovePcListingVerificationSchema) + .mutation(async ({ ctx, input }) => { + const verification = await ctx.prisma.pcListingDeveloperVerification.findUnique({ + where: { id: input.verificationId }, + }) + + if (!verification) { + return ResourceError.verification.notFound() + } + + if (verification.verifiedBy !== ctx.session.user.id && !isModerator(ctx.session.user.role)) { + return ResourceError.verification.canOnlyRemoveOwn() + } + + return ctx.prisma.pcListingDeveloperVerification.delete({ + where: { id: input.verificationId }, + }) + }), + + getVerifications: publicProcedure + .input(GetPcListingVerificationsSchema) + .query(async ({ ctx, input }) => { + return ctx.prisma.pcListingDeveloperVerification.findMany({ + where: { pcListingId: input.pcListingId }, + include: { + developer: { select: { id: true, name: true } }, + }, + orderBy: { verifiedAt: 'desc' }, + }) + }), +}) diff --git a/src/server/api/routers/pcListings/index.ts b/src/server/api/routers/pcListings/index.ts new file mode 100644 index 00000000..e8e8cd01 --- /dev/null +++ b/src/server/api/routers/pcListings/index.ts @@ -0,0 +1,4 @@ +export { coreRouter } from './core' +export { adminRouter } from './admin' +export { commentsRouter } from './comments' +export { invalidatePcListingStatsCache, toPrismaCustomFieldValue } from './utils' diff --git a/src/server/api/routers/pcListings/utils.ts b/src/server/api/routers/pcListings/utils.ts new file mode 100644 index 00000000..4c4d4fae --- /dev/null +++ b/src/server/api/routers/pcListings/utils.ts @@ -0,0 +1,42 @@ +import { AppError } from '@/lib/errors' +import { listingStatsCache } from '@/server/utils/cache' +import { Prisma } from '@orm/client' + +export const PC_LISTING_STATS_CACHE_KEY = 'pc-listing-stats' + +export function invalidatePcListingStatsCache(): void { + listingStatsCache.delete(PC_LISTING_STATS_CACHE_KEY) +} + +function isJsonRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function toPrismaNestedJsonValue(value: unknown): Prisma.InputJsonValue | null { + if (value === null) return null + if (typeof value === 'string') return value + if (typeof value === 'number') return value + if (typeof value === 'boolean') return value + if (Array.isArray(value)) return value.map(toPrismaNestedJsonValue) + if (isJsonRecord(value)) { + const result: Record = {} + for (const [key, entryValue] of Object.entries(value)) { + result[key] = toPrismaNestedJsonValue(entryValue) + } + + return result + } + + return AppError.invalidInput('customFieldValues') +} + +export function toPrismaCustomFieldValue( + value: unknown, +): Prisma.InputJsonValue | typeof Prisma.JsonNull { + if (value === undefined) return Prisma.JsonNull + + const normalizedValue = toPrismaNestedJsonValue(value) + if (normalizedValue === null) return Prisma.JsonNull + + return normalizedValue +} diff --git a/src/server/api/trpc.ts b/src/server/api/trpc.ts index 1f0c7dd5..4448b7e4 100644 --- a/src/server/api/trpc.ts +++ b/src/server/api/trpc.ts @@ -6,7 +6,7 @@ import superjson from 'superjson' import { ZodError } from 'zod' import analytics from '@/lib/analytics' import { getSerializableAppError } from '@/lib/app-error-cause' -import { AppError } from '@/lib/errors' +import { AppError, ERROR_CODES } from '@/lib/errors' import { prisma } from '@/server/db' import { hasDeveloperAccessToEmulator } from '@/server/utils/permissions' import { type Nullable } from '@/types/utils' @@ -165,7 +165,7 @@ const t = initTRPC.context().create({ transformer: superjson, errorFormatter(ctx) { // Track errors for analytics - if (ctx.error.code !== 'UNAUTHORIZED' && ctx.error.code !== 'FORBIDDEN') { + if (ctx.error.code !== ERROR_CODES.UNAUTHORIZED && ctx.error.code !== ERROR_CODES.FORBIDDEN) { analytics.performance.errorOccurred({ errorType: ctx.error.code || 'UNKNOWN', errorMessage: ctx.error.message, @@ -230,9 +230,7 @@ export const authorProcedure = t.procedure.use(performanceMiddleware).use(({ ctx if (!ctx.session?.user) return AppError.unauthorized() // For now, we consider User as Author - if (!hasRolePermission(ctx.session.user.role, Role.USER)) { - return AppError.forbidden() - } + if (!hasRolePermission(ctx.session.user.role, Role.USER)) return AppError.forbidden() return next({ ctx: { @@ -249,7 +247,7 @@ export const moderatorProcedure = t.procedure.use(performanceMiddleware).use(({ if (!ctx.session?.user) AppError.unauthorized() if (!hasRolePermission(ctx.session.user.role, Role.MODERATOR)) { - AppError.insufficientRole(Role.MODERATOR) + return AppError.insufficientRole(Role.MODERATOR) } return next({ ctx: { session: { ...ctx.session, user: ctx.session.user } } }) @@ -262,7 +260,7 @@ export const developerProcedure = t.procedure.use(performanceMiddleware).use(({ if (!ctx.session?.user) AppError.unauthorized() if (!hasRolePermission(ctx.session.user.role, Role.DEVELOPER)) { - AppError.insufficientRole(Role.DEVELOPER) + return AppError.insufficientRole(Role.DEVELOPER) } return next({ ctx: { session: { ...ctx.session, user: ctx.session.user } } }) @@ -275,7 +273,7 @@ export const adminProcedure = t.procedure.use(performanceMiddleware).use(({ ctx, if (!ctx.session?.user) AppError.unauthorized() if (!hasRolePermission(ctx.session.user.role, Role.ADMIN)) { - AppError.insufficientRole(Role.ADMIN) + return AppError.insufficientRole(Role.ADMIN) } return next({ ctx: { session: { ...ctx.session, user: ctx.session.user } } }) @@ -288,7 +286,7 @@ export const superAdminProcedure = t.procedure.use(performanceMiddleware).use(({ if (!ctx.session?.user) return AppError.unauthorized() if (!hasRolePermission(ctx.session.user.role, Role.SUPER_ADMIN)) { - AppError.insufficientRole(Role.SUPER_ADMIN) + return AppError.insufficientRole(Role.SUPER_ADMIN) } return next({ ctx: { session: { ...ctx.session, user: ctx.session.user } } }) @@ -343,7 +341,7 @@ export function multiPermissionProcedure(requiredPermissions: string[]) { (permission) => !hasPermissionInContext(ctx, permission), ) - if (missingPermissions.length > 0) AppError.insufficientPermissions(missingPermissions) + if (missingPermissions.length > 0) return AppError.insufficientPermissions(missingPermissions) return next({ ctx: { ...ctx, session: { ...ctx.session, user: ctx.session.user } } }) }) @@ -360,7 +358,7 @@ export function anyPermissionProcedure(requiredPermissions: string[]) { hasPermissionInContext(ctx, permission), ) - if (!hasAnyPermission) AppError.insufficientRoles(requiredPermissions) + if (!hasAnyPermission) return AppError.insufficientRoles(requiredPermissions) return next({ ctx: { ...ctx, session: { ...ctx.session, user: ctx.session.user } } }) }) diff --git a/src/server/repositories/listings.repository.ts b/src/server/repositories/listings.repository.ts index 467978e4..8b9bfdd6 100644 --- a/src/server/repositories/listings.repository.ts +++ b/src/server/repositories/listings.repository.ts @@ -2,7 +2,6 @@ import { PAGINATION } from '@/data/constants' import { AppError, ResourceError } from '@/lib/errors' import { canUserAutoApprove } from '@/lib/trust/service' import { EMULATOR_VERSION_FIELD_NAME } from '@/schemas/submissionRisk' -import { validateCustomFields } from '@/server/api/routers/listings/validation' import { computeVoteCounts } from '@/server/utils/moderator-info' import { paginate, calculateOffset } from '@/server/utils/pagination' import { @@ -11,6 +10,7 @@ import { buildShadowBanFilter, buildApprovalStatusFilter, } from '@/server/utils/query-builders' +import { validateCustomFields } from '@/server/utils/validate-custom-fields' import { roleIncludesRole } from '@/utils/permission-system' import { calculateWilsonScore } from '@/utils/wilson-score' import { Prisma, ApprovalStatus, Role } from '@orm/client' diff --git a/src/server/utils/security-validation.ts b/src/server/utils/security-validation.ts index 61b5ea83..df8dbf14 100644 --- a/src/server/utils/security-validation.ts +++ b/src/server/utils/security-validation.ts @@ -1,4 +1,5 @@ import { AppError } from '@/lib/errors' +// TODO: carefully consider wtf this file is. seems like none of this is how it should be done. /** * Security validation utilities for critical runtime parameters @@ -74,6 +75,7 @@ export function validateEnum( /** * Validates pagination parameters * Prevents excessive data retrieval + * TODO: this needs to get the fuck out of here. zod validates, this is bs. */ export function validatePagination( page?: number, @@ -89,6 +91,7 @@ export function validatePagination( /** * Sanitizes user input to prevent XSS and injection * Removes potentially dangerous characters + * TODO: this is like insufficient or not the proper way of doing it. */ export function sanitizeInput(input: string): string { return input diff --git a/src/server/api/routers/listings/validation.ts b/src/server/utils/validate-custom-fields.ts similarity index 100% rename from src/server/api/routers/listings/validation.ts rename to src/server/utils/validate-custom-fields.ts