From ff048f52b3865a401c659325bfe43248e4ee3e20 Mon Sep 17 00:00:00 2001 From: Producdevity Date: Thu, 14 May 2026 17:18:44 +0200 Subject: [PATCH 01/17] refactor: update GlobalError component to use props for error handling --- src/app/global-error.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/app/global-error.tsx b/src/app/global-error.tsx index 520717034..801510b24 100644 --- a/src/app/global-error.tsx +++ b/src/app/global-error.tsx @@ -4,10 +4,14 @@ import * as Sentry from '@sentry/nextjs' import NextError from 'next/error' import { useEffect } from 'react' -export default function GlobalError({ error }: { error: Error & { digest?: string } }) { +interface Props { + error: Error & { digest?: string } +} + +export default function GlobalError(props: Props) { useEffect(() => { - Sentry.captureException(error) - }, [error]) + Sentry.captureException(props.error) + }, [props.error]) return ( From 0054b463159b6333e7c26822ec829ed47098b8af Mon Sep 17 00:00:00 2001 From: Producdevity Date: Thu, 14 May 2026 17:42:58 +0200 Subject: [PATCH 02/17] fix: out of scope hotfix; set default value for showNsfw so users that aren't signed in default to false --- src/server/utils/query-builders.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/server/utils/query-builders.ts b/src/server/utils/query-builders.ts index 8fbe770e4..8b4f69d42 100644 --- a/src/server/utils/query-builders.ts +++ b/src/server/utils/query-builders.ts @@ -130,7 +130,7 @@ function createNestedContains(field: string, value: string): SearchCondition { * Build filter for NSFW content based on user preferences */ export function buildNsfwFilter( - showNsfw?: boolean | null, + showNsfw: boolean | null = false, fieldName = 'isErotic', ): Record | undefined { return showNsfw === false ? { [fieldName]: false } : undefined From 1f5a7c5b86e7dbdedf4a45309b63a70616931cd1 Mon Sep 17 00:00:00 2001 From: Producdevity Date: Fri, 15 May 2026 01:26:33 +0200 Subject: [PATCH 03/17] fix: development configuration for improved caching and local testing --- next.config.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/next.config.ts b/next.config.ts index 3b8d265a5..5735541e4 100644 --- a/next.config.ts +++ b/next.config.ts @@ -28,7 +28,7 @@ const nextConfig: NextConfig = { ], }, - allowedDevOrigins: ['dev.emuready.com'], + allowedDevOrigins: ['dev.emuready.com', '127.0.0.1'], turbopack: { rules: { @@ -115,6 +115,7 @@ const nextConfig: NextConfig = { }, async headers() { + const isProduction = process.env.NODE_ENV === 'production' const headers = [ { source: '/service-worker.js', @@ -124,10 +125,15 @@ const nextConfig: NextConfig = { source: '/sw-register.js', headers: [{ key: 'Cache-Control', value: 'no-store, no-cache, must-revalidate' }], }, - // Static assets with hash - cache immutable + // Static assets are immutable in production and uncached in dev. { source: '/_next/static/:path*', - headers: [{ key: 'Cache-Control', value: 'public, max-age=31536000, immutable' }], + headers: isProduction + ? [{ key: 'Cache-Control', value: 'public, max-age=31536000, immutable' }] + : [ + { key: 'Cache-Control', value: 'no-store, no-cache, must-revalidate' }, + { key: 'Pragma', value: 'no-cache' }, + ], }, // Images and other assets - cache with revalidation { @@ -182,7 +188,7 @@ const nextConfig: NextConfig = { ] // In dev disable HTML caching to avoid stale content via proxies - if (process.env.NODE_ENV !== 'production') { + if (!isProduction) { headers.push({ // All routes except static assets and API source: '/((?!_next|api|favicon|service-worker\\.js|sw-register\\.js).*)', From a0390024f4deb1a6b1a77e572f550df8387fec27 Mon Sep 17 00:00:00 2001 From: Producdevity Date: Fri, 15 May 2026 01:27:51 +0200 Subject: [PATCH 04/17] refactor: add separate script for Turbo in dev configuration --- package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index c7d40c71c..150bf9c90 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,8 @@ "db:seed": "./scripts/db-cmd.sh pnpm exec tsx prisma/seed.ts", "db:seed:permissions": "./scripts/db-cmd.sh pnpm exec tsx prisma/seed-permissions.ts", "db:studio": "./scripts/db-cmd.sh pnpm exec prisma studio", - "dev": "next dev --turbopack", + "dev": "next dev", + "dev:turbo": "next dev --turbopack", "dev:tracing": "NEXT_TURBOPACK_TRACING=1 next dev --turbopack", "dev:profile": "NEXT_CPU_PROF=1 NEXT_TURBOPACK_TRACING=1 next dev --turbopack", "dev:debug": "DEBUG=next:* next dev --turbopack", From 7eebded2e111d43ddea1c5171adb30fe34acc5ac Mon Sep 17 00:00:00 2001 From: Producdevity Date: Fri, 15 May 2026 01:31:10 +0200 Subject: [PATCH 05/17] feat: introduce risk management utilities for author and submission risk detection, with associated tests and UI components --- .../approvals/components/ApprovalModal.tsx | 11 +- src/app/admin/approvals/page.tsx | 29 ++- src/app/admin/hooks/index.ts | 1 + src/app/admin/hooks/useReviewRiskFilter.ts | 29 +++ .../components/ApprovalModal.tsx | 11 +- src/app/admin/pc-listing-approvals/page.tsx | 29 ++- src/app/testing/page.tsx | 2 +- .../admin/ReviewRiskFilterButton.tsx | 21 ++ src/components/admin/index.ts | 1 + .../ui/ReviewRiskIndicator.test.tsx | 68 +++++ src/components/ui/ReviewRiskIndicator.tsx | 149 +++++++++++ .../ui/ReviewRiskWarningBanner.test.tsx | 55 ++++ src/components/ui/ReviewRiskWarningBanner.tsx | 112 ++++++++ src/components/ui/index.ts | 2 + src/schemas/listing.ts | 2 + src/schemas/pcListing.ts | 2 + src/schemas/submissionRisk.ts | 38 +++ src/server/api/routers/listings/admin.test.ts | 218 ++++++++++++++++ src/server/api/routers/listings/admin.ts | 178 ++++--------- src/server/api/routers/pcListings.test.ts | 141 ++++++++++- src/server/api/routers/pcListings.ts | 74 +++--- .../repositories/listings.repository.ts | 188 ++++++++++++++ .../repositories/pc-listings.repository.ts | 113 +++++++-- src/server/services/author-risk.service.ts | 36 ++- .../services/review-risk.service.test.ts | 78 ++++++ src/server/services/review-risk.service.ts | 84 ++++++ .../services/submission-risk.service.test.ts | 112 ++++++++ .../services/submission-risk.service.ts | 239 ++++++++++++++++++ 28 files changed, 1815 insertions(+), 208 deletions(-) create mode 100644 src/app/admin/hooks/useReviewRiskFilter.ts create mode 100644 src/components/admin/ReviewRiskFilterButton.tsx create mode 100644 src/components/ui/ReviewRiskIndicator.test.tsx create mode 100644 src/components/ui/ReviewRiskIndicator.tsx create mode 100644 src/components/ui/ReviewRiskWarningBanner.test.tsx create mode 100644 src/components/ui/ReviewRiskWarningBanner.tsx create mode 100644 src/schemas/submissionRisk.ts create mode 100644 src/server/api/routers/listings/admin.test.ts create mode 100644 src/server/services/review-risk.service.test.ts create mode 100644 src/server/services/review-risk.service.ts create mode 100644 src/server/services/submission-risk.service.test.ts create mode 100644 src/server/services/submission-risk.service.ts diff --git a/src/app/admin/approvals/components/ApprovalModal.tsx b/src/app/admin/approvals/components/ApprovalModal.tsx index f8ac3ce71..40f23ddf9 100644 --- a/src/app/admin/approvals/components/ApprovalModal.tsx +++ b/src/app/admin/approvals/components/ApprovalModal.tsx @@ -6,7 +6,7 @@ import { RejectionNotesInput, CustomFieldsApprovalSection, } from '@/app/listings/components/shared/approval/ApprovalModalSharedComponents' -import { AuthorRiskWarningBanner, Modal, Button } from '@/components/ui' +import { ReviewRiskWarningBanner, Modal, Button } from '@/components/ui' import { type RouterOutput } from '@/types/trpc' import { ApprovalStatus } from '@orm' @@ -25,7 +25,9 @@ interface Props { } function ApprovalModal(props: Props) { - const hasRisk = props.selectedListingForApproval.authorRiskProfile?.highestSeverity !== null + const hasRisk = + Boolean(props.selectedListingForApproval.authorRiskProfile?.highestSeverity) || + Boolean(props.selectedListingForApproval.submissionRiskProfile?.highestSeverity) const actionText = props.approvalDecision === ApprovalStatus.APPROVED ? 'Approve' : 'Reject' const modalTitle = `${actionText} Listing: ${props.selectedListingForApproval.game.title}` @@ -51,7 +53,10 @@ function ApprovalModal(props: Props) { size="lg" >
- + {/* Listing Details Grid */}
diff --git a/src/app/admin/approvals/page.tsx b/src/app/admin/approvals/page.tsx index c22683f69..8ba9e25cb 100644 --- a/src/app/admin/approvals/page.tsx +++ b/src/app/admin/approvals/page.tsx @@ -5,7 +5,7 @@ import Link from 'next/link' import { useRouter } from 'next/navigation' import { useState } from 'react' import { isEmpty } from 'remeda' -import { useAdminTable } from '@/app/admin/hooks' +import { useAdminTable, useReviewRiskFilter } from '@/app/admin/hooks' import { confirmBulkApproval } from '@/app/admin/utils' import { AdminPageLayout, @@ -14,11 +14,11 @@ import { AdminStatsDisplay, AdminSearchFilters, AdminTableNoResults, + ReviewRiskFilterButton, } from '@/components/admin' import { EmulatorIcon, SystemIcon } from '@/components/icons' import { ApproveButton, - AuthorRiskIndicator, BulkActions, Button, ColumnVisibilityControl, @@ -27,6 +27,7 @@ import { LocalizedDate, Pagination, RejectButton, + ReviewRiskIndicator, SortableHeader, ViewButton, Tooltip, @@ -90,6 +91,11 @@ function AdminApprovalsPage() { ) const emulatorLogos = useEmulatorLogos() + const [selectedListingIds, setSelectedListingIds] = useState([]) + const reviewRiskFilter = useReviewRiskFilter({ + clearSelection: () => setSelectedListingIds([]), + resetPage: () => table.setPage(1), + }) const currentUserQuery = api.users.me.useQuery() const pendingListingsQuery = api.listings.getPending.useQuery({ @@ -98,6 +104,7 @@ function AdminApprovalsPage() { sortField: table.sortField ?? null, sortDirection: table.sortDirection ?? null, search: isEmpty(table.search) ? null : table.search, + riskFilter: reviewRiskFilter.riskFilter, }) const gameStatsQuery = api.games.stats.useQuery() @@ -108,7 +115,6 @@ function AdminApprovalsPage() { useState(null) const [approvalNotes, setApprovalNotes] = useState('') const [approvalDecision, setApprovalDecision] = useState(null) - const [selectedListingIds, setSelectedListingIds] = useState([]) const confirm = useConfirmDialog() const utils = api.useUtils() @@ -338,7 +344,12 @@ function AdminApprovalsPage() { /> )} - table={table} searchPlaceholder="Search listings..." /> + table={table} searchPlaceholder="Search listings..."> + + {/* Bulk Actions */} {listings.length > 0 && ( @@ -372,7 +383,10 @@ function AdminApprovalsPage() { {pendingListingsQuery.isPending ? ( ) : listings.length === 0 ? ( - + ) : (
@@ -506,8 +520,9 @@ function AdminApprovalsPage() { 'N/A' )} - router.push(`/admin/users?userId=${authorId}&tab=reports`) diff --git a/src/app/admin/hooks/index.ts b/src/app/admin/hooks/index.ts index 51240f807..f2f30bc19 100644 --- a/src/app/admin/hooks/index.ts +++ b/src/app/admin/hooks/index.ts @@ -1 +1,2 @@ export * from './useAdminTable' +export * from './useReviewRiskFilter' diff --git a/src/app/admin/hooks/useReviewRiskFilter.ts b/src/app/admin/hooks/useReviewRiskFilter.ts new file mode 100644 index 000000000..bad8eaafc --- /dev/null +++ b/src/app/admin/hooks/useReviewRiskFilter.ts @@ -0,0 +1,29 @@ +import { useState } from 'react' +import { REVIEW_RISK_FILTERS, type ReviewRiskFilter } from '@/schemas/submissionRisk' + +interface Options { + clearSelection: () => void + resetPage: () => void +} + +interface ReviewRiskFilterState { + isRiskOnly: boolean + riskFilter: ReviewRiskFilter + toggleRiskFilter: () => void +} + +export function useReviewRiskFilter(options: Options): ReviewRiskFilterState { + const [isRiskOnly, setIsRiskOnly] = useState(false) + + const toggleRiskFilter = () => { + setIsRiskOnly((current) => !current) + options.clearSelection() + options.resetPage() + } + + return { + isRiskOnly, + riskFilter: isRiskOnly ? REVIEW_RISK_FILTERS.RISKY : REVIEW_RISK_FILTERS.ALL, + toggleRiskFilter, + } +} diff --git a/src/app/admin/pc-listing-approvals/components/ApprovalModal.tsx b/src/app/admin/pc-listing-approvals/components/ApprovalModal.tsx index 5e83b5676..48f8a8432 100644 --- a/src/app/admin/pc-listing-approvals/components/ApprovalModal.tsx +++ b/src/app/admin/pc-listing-approvals/components/ApprovalModal.tsx @@ -9,7 +9,7 @@ import { RejectionNotesInput, CustomFieldsApprovalSection, } from '@/app/listings/components/shared/approval/ApprovalModalSharedComponents' -import { AuthorRiskWarningBanner, Button, Modal } from '@/components/ui' +import { ReviewRiskWarningBanner, Button, Modal } from '@/components/ui' import { useEmulatorLogos } from '@/hooks' import { type RouterOutput } from '@/types/trpc' import { ApprovalStatus } from '@orm' @@ -31,7 +31,9 @@ interface Props { function ApprovalModal(props: Props) { const emulatorLogos = useEmulatorLogos() - const hasRisk = props.selectedPcListingForApproval.authorRiskProfile?.highestSeverity !== null + const hasRisk = + Boolean(props.selectedPcListingForApproval.authorRiskProfile?.highestSeverity) || + Boolean(props.selectedPcListingForApproval.submissionRiskProfile?.highestSeverity) const getModalTitle = () => { const actionText = props.approvalDecision === ApprovalStatus.APPROVED ? 'Approve' : 'Reject' @@ -56,8 +58,9 @@ function ApprovalModal(props: Props) { size="lg" >
-
diff --git a/src/app/admin/pc-listing-approvals/page.tsx b/src/app/admin/pc-listing-approvals/page.tsx index 39668fce3..876bc1203 100644 --- a/src/app/admin/pc-listing-approvals/page.tsx +++ b/src/app/admin/pc-listing-approvals/page.tsx @@ -6,7 +6,7 @@ import Link from 'next/link' import { useRouter } from 'next/navigation' import { useState } from 'react' import { isEmpty } from 'remeda' -import { useAdminTable } from '@/app/admin/hooks' +import { useAdminTable, useReviewRiskFilter } from '@/app/admin/hooks' import { confirmBulkApproval } from '@/app/admin/utils' import { AdminPageLayout, @@ -15,11 +15,11 @@ import { AdminSearchFilters, AdminStatsDisplay, AdminTableNoResults, + ReviewRiskFilterButton, } from '@/components/admin' import { EmulatorIcon, SystemIcon } from '@/components/icons' import { ApproveButton, - AuthorRiskIndicator, BulkActions, Button, ColumnVisibilityControl, @@ -28,6 +28,7 @@ import { LocalizedDate, Pagination, RejectButton, + ReviewRiskIndicator, SortableHeader, ViewButton, Tooltip, @@ -95,6 +96,11 @@ function PcListingApprovalsPage() { ) const emulatorLogos = useEmulatorLogos() + const [selectedListingIds, setSelectedListingIds] = useState([]) + const reviewRiskFilter = useReviewRiskFilter({ + clearSelection: () => setSelectedListingIds([]), + resetPage: () => table.setPage(1), + }) const currentUserQuery = api.users.me.useQuery() const pendingPcListingsQuery = api.pcListings.pending.useQuery({ @@ -103,6 +109,7 @@ function PcListingApprovalsPage() { sortField: table.sortField ?? undefined, sortDirection: table.sortDirection ?? undefined, search: isEmpty(table.search) ? undefined : table.search, + riskFilter: reviewRiskFilter.riskFilter, }) const gameStatsQuery = api.games.stats.useQuery() @@ -115,7 +122,6 @@ function PcListingApprovalsPage() { useState(null) const [approvalNotes, setApprovalNotes] = useState('') const [approvalDecision, setApprovalDecision] = useState(null) - const [selectedListingIds, setSelectedListingIds] = useState([]) const confirm = useConfirmDialog() const utils = api.useUtils() @@ -343,7 +349,12 @@ function PcListingApprovalsPage() { table={table} searchPlaceholder="Search PC listings..." - /> + > + + {pcListings.length > 0 && ( ) : pcListings.length === 0 ? ( - + ) : (
@@ -560,8 +574,9 @@ function PcListingApprovalsPage() { 'N/A' )} - router.push(`/admin/users?userId=${authorId}&tab=reports`) diff --git a/src/app/testing/page.tsx b/src/app/testing/page.tsx index e4c6c9cb5..701b3a4d6 100644 --- a/src/app/testing/page.tsx +++ b/src/app/testing/page.tsx @@ -51,7 +51,7 @@ export default function TestingPage() { useEffect(() => { if (typeof window !== 'undefined') { const hostname = window.location.hostname - const allowedHosts = ['localhost', 'staging.emuready.com', 'dev.emuready.com'] + const allowedHosts = ['localhost', '127.0.0.1', 'staging.emuready.com', 'dev.emuready.com'] setIsAllowed(allowedHosts.some((host) => hostname.includes(host))) } }, []) diff --git a/src/components/admin/ReviewRiskFilterButton.tsx b/src/components/admin/ReviewRiskFilterButton.tsx new file mode 100644 index 000000000..458f59b18 --- /dev/null +++ b/src/components/admin/ReviewRiskFilterButton.tsx @@ -0,0 +1,21 @@ +import { ShieldAlert } from 'lucide-react' +import { Button } from '@/components/ui' + +interface Props { + isActive: boolean + onToggle: () => void +} + +// TODO: refactor to use a toggle instead of a button. if we have one, we should use that or create a reusable button or steal one from chadcn. (see chadcn command in package.json_ +export function ReviewRiskFilterButton(props: Props) { + return ( + + ) +} diff --git a/src/components/admin/index.ts b/src/components/admin/index.ts index d2e2dba0e..8426784f0 100644 --- a/src/components/admin/index.ts +++ b/src/components/admin/index.ts @@ -4,3 +4,4 @@ export * from './AdminSearchFilters' export * from './AdminStatsDisplay' export * from './AdminTableContainer' export * from './AdminTableNoResults' +export * from './ReviewRiskFilterButton' diff --git a/src/components/ui/ReviewRiskIndicator.test.tsx b/src/components/ui/ReviewRiskIndicator.test.tsx new file mode 100644 index 000000000..f23c8b778 --- /dev/null +++ b/src/components/ui/ReviewRiskIndicator.test.tsx @@ -0,0 +1,68 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import { RISK_SIGNAL_TYPES, type AuthorRiskProfile } from '@/schemas/authorRisk' +import { SUBMISSION_RISK_SIGNAL_TYPES, type SubmissionRiskProfile } from '@/schemas/submissionRisk' +import { ReviewRiskIndicator } from './ReviewRiskIndicator' + +const authorRiskProfile: AuthorRiskProfile = { + authorId: 'author-1', + signals: [ + { + type: RISK_SIGNAL_TYPES.NEW_AUTHOR, + severity: 'low', + label: 'New Author', + description: 'No previously approved listings', + }, + ], + highestSeverity: 'low', +} + +const submissionRiskProfile: SubmissionRiskProfile = { + listingId: 'listing-1', + signals: [ + { + type: SUBMISSION_RISK_SIGNAL_TYPES.PLACEHOLDER_EMULATOR_VERSION, + severity: 'high', + label: 'Placeholder Emulator Version', + description: 'Submitted emulator version resembles placeholder text.', + }, + ], + highestSeverity: 'high', +} + +describe('ReviewRiskIndicator', () => { + it('renders nothing when there are no review risk signals', () => { + const { container } = render( + , + ) + + expect(container.innerHTML).toBe('') + }) + + it('uses the highest severity across author and submission risk', () => { + render( + , + ) + + const status = screen.getByRole('status') + expect(status).toHaveAttribute('aria-label', 'Review risk: high severity, 2 signals') + expect(status.querySelector('svg')).toHaveClass('text-red-500') + }) + + it('renders for submission risk without author risk', () => { + render( + , + ) + + expect(screen.getByRole('status')).toHaveAttribute( + 'aria-label', + 'Review risk: high severity, 1 signal', + ) + }) +}) diff --git a/src/components/ui/ReviewRiskIndicator.tsx b/src/components/ui/ReviewRiskIndicator.tsx new file mode 100644 index 000000000..e8493b9f0 --- /dev/null +++ b/src/components/ui/ReviewRiskIndicator.tsx @@ -0,0 +1,149 @@ +'use client' + +import { cn } from '@/lib/utils' +import { type AuthorRiskProfile } from '@/schemas/authorRisk' +import { type Severity } from '@/schemas/common' +import { type SubmissionRiskProfile } from '@/schemas/submissionRisk' +import { severityBadgeVariant, severityIconConfig } from './AuthorRiskIndicator' +import { Badge } from './Badge' +import { Tooltip, TooltipContent, TooltipTrigger } from './Tooltip' + +interface RiskSignalForDisplay { + severity: Severity + label: string + description: string +} + +interface RiskGroupForDisplay { + title: string + signals: RiskSignalForDisplay[] +} + +interface Props { + authorRiskProfile: AuthorRiskProfile | null | undefined + submissionRiskProfile: SubmissionRiskProfile | null | undefined + size?: 'sm' | 'md' + className?: string + onInvestigate?: (authorId: string) => void +} + +const SEVERITY_ORDER: Record = { + low: 1, + medium: 2, + high: 3, +} + +function getRiskGroups(props: Props): RiskGroupForDisplay[] { + const groups: RiskGroupForDisplay[] = [] + + if (props.submissionRiskProfile && props.submissionRiskProfile.signals.length > 0) { + groups.push({ title: 'Submission Risk', signals: props.submissionRiskProfile.signals }) + } + + if (props.authorRiskProfile && props.authorRiskProfile.signals.length > 0) { + groups.push({ title: 'Author Risk', signals: props.authorRiskProfile.signals }) + } + + return groups +} + +function getHighestSeverity(groups: RiskGroupForDisplay[]): Severity | null { + let max: Severity | null = null + + for (const group of groups) { + for (const signal of group.signals) { + if (!max || SEVERITY_ORDER[signal.severity] > SEVERITY_ORDER[max]) { + max = signal.severity + } + } + } + + return max +} + +function getSignalCount(groups: RiskGroupForDisplay[]): number { + return groups.reduce((total, group) => total + group.signals.length, 0) +} + +export function ReviewRiskIndicator(props: Props) { + const groups = getRiskGroups(props) + const severity = getHighestSeverity(groups) + if (!severity) return null + + const config = severityIconConfig[severity] + const Icon = config.icon + const size = props.size ?? 'sm' + const iconSize = size === 'sm' ? 'h-4 w-4' : 'h-5 w-5' + const isClickable = !!props.onInvestigate && !!props.authorRiskProfile + const signalCount = getSignalCount(groups) + + const handleClick = () => { + if (props.onInvestigate && props.authorRiskProfile) { + props.onInvestigate(props.authorRiskProfile.authorId) + } + } + + return ( + + +
1 ? 's' : ''}`} + onClick={isClickable ? handleClick : undefined} + onKeyDown={ + isClickable + ? (ev) => { + if (ev.key === 'Enter' || ev.key === ' ') { + ev.preventDefault() + handleClick() + } + } + : undefined + } + tabIndex={isClickable ? 0 : undefined} + > + +
+
+ +
+
Review Risk Signals
+ {groups.map((group) => ( +
+
+ {group.title} +
+ {group.signals.map((signal, index) => ( +
+ + {signal.severity} + +
+ {signal.label} +

+ {signal.description} +

+
+
+ ))} +
+ ))} + {isClickable && ( +

+ Click to investigate author +

+ )} +
+
+
+ ) +} diff --git a/src/components/ui/ReviewRiskWarningBanner.test.tsx b/src/components/ui/ReviewRiskWarningBanner.test.tsx new file mode 100644 index 000000000..191b1d2ed --- /dev/null +++ b/src/components/ui/ReviewRiskWarningBanner.test.tsx @@ -0,0 +1,55 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import { RISK_SIGNAL_TYPES, type AuthorRiskProfile } from '@/schemas/authorRisk' +import { SUBMISSION_RISK_SIGNAL_TYPES, type SubmissionRiskProfile } from '@/schemas/submissionRisk' +import { ReviewRiskWarningBanner } from './ReviewRiskWarningBanner' + +const authorRiskProfile: AuthorRiskProfile = { + authorId: 'author-1', + signals: [ + { + type: RISK_SIGNAL_TYPES.ACTIVE_REPORTS, + severity: 'medium', + label: 'Active Reports', + description: '4 active reports across listings', + }, + ], + highestSeverity: 'medium', +} + +const submissionRiskProfile: SubmissionRiskProfile = { + listingId: 'listing-1', + signals: [ + { + type: SUBMISSION_RISK_SIGNAL_TYPES.PLACEHOLDER_EMULATOR_VERSION, + severity: 'high', + label: 'Placeholder Emulator Version', + description: 'Submitted emulator version resembles placeholder text.', + }, + ], + highestSeverity: 'high', +} + +describe('ReviewRiskWarningBanner', () => { + it('renders nothing when there are no review risk signals', () => { + const { container } = render( + , + ) + + expect(container.innerHTML).toBe('') + }) + + it('separates submission risk from author risk', () => { + render( + , + ) + + expect(screen.getByText('Submission Risk')).toBeInTheDocument() + expect(screen.getByText('Author Risk')).toBeInTheDocument() + expect(screen.getByText('Placeholder Emulator Version')).toBeInTheDocument() + expect(screen.getByText('Active Reports')).toBeInTheDocument() + }) +}) diff --git a/src/components/ui/ReviewRiskWarningBanner.tsx b/src/components/ui/ReviewRiskWarningBanner.tsx new file mode 100644 index 000000000..31e527bcf --- /dev/null +++ b/src/components/ui/ReviewRiskWarningBanner.tsx @@ -0,0 +1,112 @@ +'use client' + +import { cn } from '@/lib/utils' +import { type AuthorRiskProfile } from '@/schemas/authorRisk' +import { type Severity } from '@/schemas/common' +import { type SubmissionRiskProfile } from '@/schemas/submissionRisk' +import { severityBadgeVariant, severityIconConfig } from './AuthorRiskIndicator' +import { Badge } from './Badge' + +interface RiskSignalForDisplay { + severity: Severity + label: string + description: string +} + +interface RiskGroupForDisplay { + title: string + signals: RiskSignalForDisplay[] +} + +interface Props { + authorRiskProfile: AuthorRiskProfile | null | undefined + submissionRiskProfile: SubmissionRiskProfile | null | undefined + className?: string +} + +const severityBorderConfig: Record = { + high: 'border-red-200 dark:border-red-800 bg-red-50 dark:bg-red-900/20', + medium: 'border-orange-200 dark:border-orange-800 bg-orange-50 dark:bg-orange-900/20', + low: 'border-yellow-200 dark:border-yellow-800 bg-yellow-50 dark:bg-yellow-900/20', +} + +// TODO: consider abstracting these 30~ lines of duplicated lines across multiple files +const SEVERITY_ORDER: Record = { + low: 1, + medium: 2, + high: 3, +} + +function getRiskGroups(props: Props): RiskGroupForDisplay[] { + const groups: RiskGroupForDisplay[] = [] + + if (props.submissionRiskProfile && props.submissionRiskProfile.signals.length > 0) { + groups.push({ title: 'Submission Risk', signals: props.submissionRiskProfile.signals }) + } + + if (props.authorRiskProfile && props.authorRiskProfile.signals.length > 0) { + groups.push({ title: 'Author Risk', signals: props.authorRiskProfile.signals }) + } + + return groups +} + +function getHighestSeverity(groups: RiskGroupForDisplay[]): Severity | null { + let max: Severity | null = null + + for (const group of groups) { + for (const signal of group.signals) { + if (!max || SEVERITY_ORDER[signal.severity] > SEVERITY_ORDER[max]) max = signal.severity + } + } + + return max +} + +export function ReviewRiskWarningBanner(props: Props) { + const groups = getRiskGroups(props) + const severity = getHighestSeverity(groups) + if (!severity) return null + + const config = severityIconConfig[severity] + const Icon = config.icon + + return ( +
+
+ +
+

Review Risk Warning

+ {groups.map((group) => ( +
+
+ {group.title} +
+ {group.signals.map((signal, index) => ( +
+ + {signal.severity} + + {signal.label} + + {signal.description} + +
+ ))} +
+ ))} +

+ Review this listing carefully before making a decision. +

+
+
+
+ ) +} diff --git a/src/components/ui/index.ts b/src/components/ui/index.ts index 02088ad15..c5e707498 100644 --- a/src/components/ui/index.ts +++ b/src/components/ui/index.ts @@ -31,6 +31,8 @@ export * from './Popover' export * from './ProgressiveImage' export * from './PullToRefresh' export * from './RoleBadge' +export * from './ReviewRiskIndicator' +export * from './ReviewRiskWarningBanner' export * from './SegmentedTabs' export * from './Skeleton' export * from './SortableHeader' diff --git a/src/schemas/listing.ts b/src/schemas/listing.ts index 03173cd69..f7cd9b09b 100644 --- a/src/schemas/listing.ts +++ b/src/schemas/listing.ts @@ -1,6 +1,7 @@ import { z } from 'zod' import { PAGINATION } from '@/data/constants' import { JsonValueSchema, ListingType } from '@/schemas/common' +import { REVIEW_RISK_FILTERS, ReviewRiskFilterSchema } from '@/schemas/submissionRisk' import { ApprovalStatus } from '@orm' export const CreateListingSchema = z.object({ @@ -72,6 +73,7 @@ export const GetPendingListingsSchema = z .nullable() .optional(), sortDirection: z.enum(['asc', 'desc']).nullable().optional(), + riskFilter: ReviewRiskFilterSchema.default(REVIEW_RISK_FILTERS.ALL), }) .optional() diff --git a/src/schemas/pcListing.ts b/src/schemas/pcListing.ts index 441102ff7..78c57f846 100644 --- a/src/schemas/pcListing.ts +++ b/src/schemas/pcListing.ts @@ -1,5 +1,6 @@ import { z } from 'zod' import { PAGINATION, CHAR_LIMITS } from '@/data/constants' +import { REVIEW_RISK_FILTERS, ReviewRiskFilterSchema } from '@/schemas/submissionRisk' import { ApprovalStatus, PcOs, ReportReason, ReportStatus } from '@orm' export const CreatePcListingSchema = z.object({ @@ -73,6 +74,7 @@ export const GetPendingPcListingsSchema = z ]) .optional(), sortDirection: z.enum(['asc', 'desc']).optional(), + riskFilter: ReviewRiskFilterSchema.default(REVIEW_RISK_FILTERS.ALL), }) .optional() diff --git a/src/schemas/submissionRisk.ts b/src/schemas/submissionRisk.ts new file mode 100644 index 000000000..04b8aa26c --- /dev/null +++ b/src/schemas/submissionRisk.ts @@ -0,0 +1,38 @@ +import { z } from 'zod' +import { Severity } from './common' + +export const SUBMISSION_RISK_SIGNAL_TYPES = { + PLACEHOLDER_EMULATOR_VERSION: 'PLACEHOLDER_EMULATOR_VERSION', +} as const + +export const EMULATOR_VERSION_FIELD_NAME = 'emulator_version' + +export const REVIEW_RISK_FILTERS = { + ALL: 'all', + RISKY: 'risky', +} as const + +export type SubmissionRiskSignalType = + (typeof SUBMISSION_RISK_SIGNAL_TYPES)[keyof typeof SUBMISSION_RISK_SIGNAL_TYPES] +export type ReviewRiskFilter = (typeof REVIEW_RISK_FILTERS)[keyof typeof REVIEW_RISK_FILTERS] + +const SubmissionRiskSignalTypeSchema = z.enum([ + SUBMISSION_RISK_SIGNAL_TYPES.PLACEHOLDER_EMULATOR_VERSION, +]) + +export const ReviewRiskFilterSchema = z.enum([REVIEW_RISK_FILTERS.ALL, REVIEW_RISK_FILTERS.RISKY]) + +export const SubmissionRiskSignalSchema = z.object({ + type: SubmissionRiskSignalTypeSchema, + severity: Severity, + label: z.string(), + description: z.string(), +}) +export type SubmissionRiskSignal = z.infer + +export const SubmissionRiskProfileSchema = z.object({ + listingId: z.string().uuid(), + signals: z.array(SubmissionRiskSignalSchema), + highestSeverity: Severity.nullable(), +}) +export type SubmissionRiskProfile = z.infer diff --git a/src/server/api/routers/listings/admin.test.ts b/src/server/api/routers/listings/admin.test.ts new file mode 100644 index 000000000..6885fdd57 --- /dev/null +++ b/src/server/api/routers/listings/admin.test.ts @@ -0,0 +1,218 @@ +import { describe, expect, it, beforeEach, vi } from 'vitest' +import { RISK_SIGNAL_TYPES } from '@/schemas/authorRisk' +import { SUBMISSION_RISK_SIGNAL_TYPES } from '@/schemas/submissionRisk' +import { Role } from '@orm' + +vi.unmock('@/server/api/trpc') + +vi.mock('@/server/db', () => ({ + prisma: {}, +})) + +const mockComputeAuthorRiskProfiles = vi.fn().mockResolvedValue(new Map()) +const mockComputeSubmissionRiskProfiles = vi.fn().mockResolvedValue(new Map()) +const mockListVerifiedEmulatorIdsByUserId = vi.fn() +const mockGetPendingListingRiskCandidates = vi.fn() +const mockGetPendingListingsByIds = vi.fn() +const mockGetPendingListings = vi.fn() + +vi.mock('@/server/services/author-risk.service', () => ({ + computeAuthorRiskProfiles: (...args: unknown[]) => mockComputeAuthorRiskProfiles(...args), + createExistingAuthorBansMap: ( + listings: readonly { + authorId: string + author?: { userBans?: readonly { reason: string }[] } | null + }[], + ) => { + const existingBansMap = new Map() + + for (const listing of listings) { + if ( + listing.author?.userBans && + listing.author.userBans.length > 0 && + !existingBansMap.has(listing.authorId) + ) { + existingBansMap.set( + listing.authorId, + listing.author.userBans.map((ban) => ({ reason: ban.reason })), + ) + } + } + + return existingBansMap + }, +})) + +vi.mock('@/server/services/submission-risk.service', () => ({ + computeSubmissionRiskProfiles: (...args: unknown[]) => mockComputeSubmissionRiskProfiles(...args), +})) + +vi.mock('@/lib/trust/service', () => ({ + applyTrustAction: vi.fn().mockResolvedValue(undefined), +})) + +vi.mock('@/server/notifications/eventEmitter', () => ({ + notificationEventEmitter: { emitNotificationEvent: vi.fn() }, + NOTIFICATION_EVENTS: { + LISTING_APPROVED: 'LISTING_APPROVED', + LISTING_REJECTED: 'LISTING_REJECTED', + }, +})) + +vi.mock('@/server/cache/invalidation', () => ({ + invalidateListing: vi.fn().mockResolvedValue(undefined), + invalidateListPages: vi.fn().mockResolvedValue(undefined), + invalidateSitemap: vi.fn().mockResolvedValue(undefined), + revalidateByTag: vi.fn().mockResolvedValue(undefined), +})) + +vi.mock('@/server/utils/cache/instances', () => ({ + listingStatsCache: { delete: vi.fn(), get: vi.fn(), set: vi.fn() }, +})) + +vi.mock('@/server/utils/emulator-config/emulator-detector', () => ({ + generateEmulatorConfig: vi.fn(), +})) + +vi.mock('@/server/repositories/listings.repository', () => ({ + ListingsRepository: vi.fn().mockImplementation(function MockListingsRepository() { + return { + getModeratorInfo: vi.fn(), + listVerifiedEmulatorIdsByUserId: mockListVerifiedEmulatorIdsByUserId, + getPendingListingRiskCandidates: mockGetPendingListingRiskCandidates, + getPendingListingsByIds: mockGetPendingListingsByIds, + getPendingListings: mockGetPendingListings, + } + }), +})) + +vi.mock('@/server/repositories/pc-listings.repository', () => ({ + PcListingsRepository: vi.fn().mockImplementation(function MockPcListingsRepository() { + return { getModeratorInfo: vi.fn() } + }), +})) + +const { prisma } = await import('@/server/db') +const { adminRouter } = await import('./admin') + +const ADMIN_ID = '00000000-0000-4000-a000-000000000001' +const AUTHOR_ID = '00000000-0000-4000-a000-000000000002' +const CLEAN_AUTHOR_ID = '00000000-0000-4000-a000-000000000003' +const LISTING_ID = '00000000-0000-4000-a000-000000000010' +const LISTING_ID_B = '00000000-0000-4000-a000-000000000011' +const LISTING_ID_C = '00000000-0000-4000-a000-000000000012' + +function createCaller() { + return { + caller: adminRouter.createCaller({ + session: { + user: { + id: ADMIN_ID, + email: 'admin@test.com', + name: 'Admin User', + role: Role.MODERATOR, + permissions: [], + showNsfw: false, + }, + }, + prisma, + headers: new Headers(), + }), + } +} + +describe('listing admin pending approvals', () => { + beforeEach(() => { + vi.clearAllMocks() + mockComputeAuthorRiskProfiles.mockResolvedValue(new Map()) + mockComputeSubmissionRiskProfiles.mockResolvedValue(new Map()) + }) + + it('filters risk-only listings using lightweight candidates before fetching full page rows', async () => { + const submissionRiskListing = { + id: LISTING_ID, + authorId: AUTHOR_ID, + author: { userBans: [] }, + customFieldValues: [], + } + const authorRiskListing = { + id: LISTING_ID_B, + authorId: ADMIN_ID, + author: { userBans: [] }, + customFieldValues: [], + } + const cleanListing = { + id: LISTING_ID_C, + authorId: CLEAN_AUTHOR_ID, + author: { userBans: [] }, + customFieldValues: [], + } + mockGetPendingListingRiskCandidates.mockResolvedValueOnce([ + submissionRiskListing, + authorRiskListing, + cleanListing, + ]) + mockGetPendingListingsByIds.mockResolvedValueOnce([submissionRiskListing, authorRiskListing]) + mockComputeAuthorRiskProfiles.mockResolvedValue( + new Map([ + [AUTHOR_ID, { authorId: AUTHOR_ID, signals: [], highestSeverity: null }], + [ + ADMIN_ID, + { + authorId: ADMIN_ID, + signals: [ + { + type: RISK_SIGNAL_TYPES.NEW_AUTHOR, + severity: 'low', + label: 'New Author', + description: 'No previously approved listings', + }, + ], + highestSeverity: 'low', + }, + ], + [CLEAN_AUTHOR_ID, { authorId: CLEAN_AUTHOR_ID, signals: [], highestSeverity: null }], + ]), + ) + mockComputeSubmissionRiskProfiles.mockResolvedValue( + new Map([ + [ + LISTING_ID, + { + listingId: LISTING_ID, + signals: [ + { + type: SUBMISSION_RISK_SIGNAL_TYPES.PLACEHOLDER_EMULATOR_VERSION, + severity: 'high', + label: 'Placeholder Emulator Version', + description: 'Submitted emulator version resembles placeholder text.', + }, + ], + highestSeverity: 'high', + }, + ], + [LISTING_ID_B, { listingId: LISTING_ID_B, signals: [], highestSeverity: null }], + [LISTING_ID_C, { listingId: LISTING_ID_C, signals: [], highestSeverity: null }], + ]), + ) + + const { caller } = createCaller() + + const result = await caller.getPending({ riskFilter: 'risky', page: 1, limit: 20 }) + + expect(mockGetPendingListingRiskCandidates).toHaveBeenCalledWith({ + emulatorIds: undefined, + search: undefined, + sortField: undefined, + sortDirection: undefined, + }) + expect(mockGetPendingListingsByIds).toHaveBeenCalledWith([LISTING_ID, LISTING_ID_B]) + expect(mockGetPendingListings).not.toHaveBeenCalled() + expect(result.listings).toHaveLength(2) + expect(result.listings[0].id).toBe(LISTING_ID) + expect(result.listings[0].submissionRiskProfile.highestSeverity).toBe('high') + expect(result.listings[1].id).toBe(LISTING_ID_B) + expect(result.listings[1].authorRiskProfile.highestSeverity).toBe('low') + expect(result.pagination.total).toBe(2) + }) +}) diff --git a/src/server/api/routers/listings/admin.ts b/src/server/api/routers/listings/admin.ts index ac06029da..468c6cda9 100644 --- a/src/server/api/routers/listings/admin.ts +++ b/src/server/api/routers/listings/admin.ts @@ -36,7 +36,11 @@ import { import { notificationEventEmitter, NOTIFICATION_EVENTS } from '@/server/notifications/eventEmitter' import { ListingsRepository } from '@/server/repositories/listings.repository' import { PcListingsRepository } from '@/server/repositories/pc-listings.repository' -import { computeAuthorRiskProfiles } from '@/server/services/author-risk.service' +import { + attachReviewRiskProfiles, + computeReviewRiskProfiles, + getRiskyReviewItemIds, +} from '@/server/services/review-risk.service' import { listingStatsCache } from '@/server/utils/cache/instances' import { generateEmulatorConfig } from '@/server/utils/emulator-config/emulator-detector' import { paginate } from '@/server/utils/pagination' @@ -56,157 +60,71 @@ export const adminRouter = createTRPCRouter({ : new PcListingsRepository(ctx.prisma).getModeratorInfo(input.id) }), - // TODO: abstract to service or repository getPending: developerProcedure.input(GetPendingListingsSchema).query(async ({ ctx, input }) => { - const { search, page = 1, limit = 20, sortField, sortDirection } = input ?? {} + const repository = new ListingsRepository(ctx.prisma) + const { + search, + page = 1, + limit = 20, + sortField, + sortDirection, + riskFilter = 'all', + } = input ?? {} const skip = (page - 1) * limit - - // Build where clause for search - let where: Prisma.ListingWhereInput = { status: ApprovalStatus.PENDING } + const filterRiskyListings = riskFilter === 'risky' + let emulatorIds: string[] | undefined // For developers, only show listings for their verified emulators if (!hasRolePermission(ctx.session.user.role, Role.MODERATOR)) { - // Get user's verified emulators - const verifiedEmulators = await ctx.prisma.verifiedDeveloper.findMany({ - where: { userId: ctx.session.user.id }, - select: { emulatorId: true }, - }) - - const emulatorIds = verifiedEmulators.map((ve) => ve.emulatorId) + emulatorIds = await repository.listVerifiedEmulatorIdsByUserId(ctx.session.user.id) if (emulatorIds.length === 0) { - // Developer has no verified emulators, return empty result return { listings: [], pagination: paginate({ total: 0, page, limit }), } } - - where.emulatorId = { in: emulatorIds } } - if (search && search.trim() !== '') { - where = { - ...where, - OR: [ - { game: { title: { contains: search, mode } } }, - { game: { system: { name: { contains: search, mode } } } }, - { device: { modelName: { contains: search, mode } } }, - { device: { brand: { name: { contains: search, mode } } } }, - { emulator: { name: { contains: search, mode } } }, - { author: { name: { contains: search, mode } } }, - ], - } - } - - // Build orderBy clause - let orderBy: Prisma.ListingOrderByWithRelationInput = { - createdAt: 'asc', // Default sorting - } + if (filterRiskyListings) { + const riskCandidates = await repository.getPendingListingRiskCandidates({ + emulatorIds, + search, + sortField, + sortDirection, + }) + const riskProfiles = await computeReviewRiskProfiles(ctx.prisma, riskCandidates) + const riskyListingIds = getRiskyReviewItemIds(riskCandidates, riskProfiles) + const paginatedRiskyListingIds = riskyListingIds.slice(skip, skip + limit) + const pageListings = await repository.getPendingListingsByIds(paginatedRiskyListingIds) + const pageListingMap = new Map(pageListings.map((listing) => [listing.id, listing])) + const sortedPageListings = paginatedRiskyListingIds.flatMap((listingId) => { + const listing = pageListingMap.get(listingId) + return listing ? [listing] : [] + }) + const paginatedListings = attachReviewRiskProfiles(sortedPageListings, riskProfiles) - if (sortField && sortDirection) { - switch (sortField) { - case 'game.title': - orderBy = { game: { title: sortDirection } } - break - case 'game.system.name': - orderBy = { game: { system: { name: sortDirection } } } - break - case 'device': - orderBy = { device: { modelName: sortDirection } } - break - case 'emulator.name': - orderBy = { emulator: { name: sortDirection } } - break - case 'author.name': - orderBy = { author: { name: sortDirection } } - break - case 'createdAt': - orderBy = { createdAt: sortDirection } - break + return { + listings: paginatedListings, + pagination: paginate({ total: riskyListingIds.length, page, limit }), } } - const listings = await ctx.prisma.listing.findMany({ - where, - include: { - game: { include: { system: true } }, - device: { include: { brand: true } }, - emulator: true, - author: { - select: { - id: true, - name: true, - userBans: { - where: { - isActive: true, - OR: [{ expiresAt: null }, { expiresAt: { gt: new Date() } }], - }, - select: { id: true, reason: true, bannedAt: true, expiresAt: true }, - }, - }, - }, - performance: true, - customFieldValues: { - include: { - customFieldDefinition: { - select: { - id: true, - type: true, - label: true, - name: true, - options: true, - defaultValue: true, - rangeDecimals: true, - rangeUnit: true, - categoryId: true, - categoryOrder: true, - category: { select: { id: true, name: true, displayOrder: true } }, - }, - }, - }, - }, - }, - orderBy, - skip, - take: limit, + const result = await repository.getPendingListings({ + emulatorIds, + search, + page, + limit, + sortField, + sortDirection, }) - // Compute author risk profiles - const uniqueAuthorIds = [...new Set(listings.map((l) => l.authorId))] - const existingBansMap = new Map() - for (const listing of listings) { - if ( - listing.author?.userBans && - listing.author.userBans.length > 0 && - !existingBansMap.has(listing.authorId) - ) { - existingBansMap.set( - listing.authorId, - listing.author.userBans.map((b) => ({ reason: b.reason })), - ) - } - } - const riskProfiles = await computeAuthorRiskProfiles( - ctx.prisma, - uniqueAuthorIds, - existingBansMap, - ) - - const listingsWithRiskProfiles = listings.map((listing) => ({ - ...listing, - authorRiskProfile: riskProfiles.get(listing.authorId) ?? { - authorId: listing.authorId, - signals: [], - highestSeverity: null, - }, - })) - - const totalListings = await ctx.prisma.listing.count({ where }) + const riskProfiles = await computeReviewRiskProfiles(ctx.prisma, result.listings) + const paginatedListings = attachReviewRiskProfiles(result.listings, riskProfiles) return { - listings: listingsWithRiskProfiles, - pagination: paginate({ total: totalListings, page, limit: limit }), + listings: paginatedListings, + pagination: result.pagination, } }), diff --git a/src/server/api/routers/pcListings.test.ts b/src/server/api/routers/pcListings.test.ts index e13578f58..83f548071 100644 --- a/src/server/api/routers/pcListings.test.ts +++ b/src/server/api/routers/pcListings.test.ts @@ -1,4 +1,6 @@ import { describe, expect, it, beforeEach, vi } from 'vitest' +import { RISK_SIGNAL_TYPES } from '@/schemas/authorRisk' +import { SUBMISSION_RISK_SIGNAL_TYPES } from '@/schemas/submissionRisk' import { ApprovalStatus, PcOs, Role, TrustAction } from '@orm' vi.unmock('@/server/api/trpc') @@ -8,6 +10,8 @@ const mockApplyTrustAction = vi.fn().mockResolvedValue(undefined) const mockHandleListingVoteTrustEffects = vi.fn().mockResolvedValue(undefined) const mockHandleCommentVoteTrustEffects = vi.fn().mockResolvedValue(undefined) const mockLogAction = vi.fn().mockResolvedValue(undefined) +const mockComputeAuthorRiskProfiles = vi.fn().mockResolvedValue(new Map()) +const mockComputeSubmissionRiskProfiles = vi.fn().mockResolvedValue(new Map()) vi.mock('@/lib/trust/service', () => ({ applyTrustAction: (...args: unknown[]) => mockApplyTrustAction(...args), @@ -73,7 +77,34 @@ vi.mock('@/server/services/audit.service', () => ({ })) vi.mock('@/server/services/author-risk.service', () => ({ - computeAuthorRiskProfiles: vi.fn().mockResolvedValue(new Map()), + computeAuthorRiskProfiles: (...args: unknown[]) => mockComputeAuthorRiskProfiles(...args), + createExistingAuthorBansMap: ( + listings: readonly { + authorId: string + author?: { userBans?: readonly { reason: string }[] } | null + }[], + ) => { + const existingBansMap = new Map() + + for (const listing of listings) { + if ( + listing.author?.userBans && + listing.author.userBans.length > 0 && + !existingBansMap.has(listing.authorId) + ) { + existingBansMap.set( + listing.authorId, + listing.author.userBans.map((ban) => ({ reason: ban.reason })), + ) + } + } + + return existingBansMap + }, +})) + +vi.mock('@/server/services/submission-risk.service', () => ({ + computeSubmissionRiskProfiles: (...args: unknown[]) => mockComputeSubmissionRiskProfiles(...args), })) vi.mock('@/server/api/utils/pinPermissions', () => ({ @@ -90,6 +121,10 @@ const mockRepositoryApprove = vi.fn() const mockRepositoryReject = vi.fn() const mockRepositoryGetExistingVote = vi.fn() const mockIsDeveloperVerified = vi.fn() +const mockRepositoryGetPendingListings = vi.fn() +const mockRepositoryGetPendingListingRiskCandidates = vi.fn() +const mockRepositoryGetPendingListingsByIds = vi.fn() +const mockRepositoryGetVerifiedEmulatorIds = vi.fn() vi.mock('@/server/repositories/pc-listings.repository', () => ({ PcListingsRepository: vi.fn().mockImplementation(function MockPcListingsRepository() { @@ -100,6 +135,10 @@ vi.mock('@/server/repositories/pc-listings.repository', () => ({ reject: mockRepositoryReject, getExistingVote: mockRepositoryGetExistingVote, isDeveloperVerifiedForEmulator: mockIsDeveloperVerified, + getPendingListings: mockRepositoryGetPendingListings, + getPendingListingRiskCandidates: mockRepositoryGetPendingListingRiskCandidates, + getPendingListingsByIds: mockRepositoryGetPendingListingsByIds, + getVerifiedEmulatorIds: mockRepositoryGetVerifiedEmulatorIds, list: vi.fn().mockResolvedValue({ pcListings: [], pagination: {} }), getUserVote: vi.fn().mockResolvedValue(null), } @@ -117,7 +156,10 @@ const { pcListingsRouter } = await import('./pcListings') const USER_ID = '00000000-0000-4000-a000-000000000001' const AUTHOR_ID = '00000000-0000-4000-a000-000000000002' const ADMIN_ID = '00000000-0000-4000-a000-000000000003' +const CLEAN_AUTHOR_ID = '00000000-0000-4000-a000-000000000004' const LISTING_ID = '00000000-0000-4000-a000-000000000010' +const LISTING_ID_B = '00000000-0000-4000-a000-000000000011' +const LISTING_ID_C = '00000000-0000-4000-a000-000000000012' const COMMENT_ID = '00000000-0000-4000-a000-000000000020' function createMockPrisma() { @@ -178,6 +220,15 @@ describe('pcListings trust integration', () => { beforeEach(() => { vi.clearAllMocks() mockRepositoryGetExistingVote.mockResolvedValue(null) + mockRepositoryGetVerifiedEmulatorIds.mockResolvedValue([]) + mockRepositoryGetPendingListings.mockResolvedValue({ + pcListings: [], + pagination: { total: 0, pages: 0, page: 1, offset: 0, limit: 20 }, + }) + mockRepositoryGetPendingListingRiskCandidates.mockResolvedValue([]) + mockRepositoryGetPendingListingsByIds.mockResolvedValue([]) + mockComputeAuthorRiskProfiles.mockResolvedValue(new Map()) + mockComputeSubmissionRiskProfiles.mockResolvedValue(new Map()) }) describe('vote', () => { @@ -422,6 +473,94 @@ describe('pcListings trust integration', () => { }) }) + describe('pending', () => { + it('filters to review-risk PC listings when riskFilter is risky', async () => { + const submissionRiskListing = { + id: LISTING_ID, + authorId: AUTHOR_ID, + author: { id: AUTHOR_ID, name: 'Submission Risk Author', userBans: [] }, + } + const authorRiskListing = { + id: LISTING_ID_B, + authorId: USER_ID, + author: { id: USER_ID, name: 'Author Risk Author', userBans: [] }, + } + const cleanListing = { + id: LISTING_ID_C, + authorId: CLEAN_AUTHOR_ID, + author: { id: CLEAN_AUTHOR_ID, name: 'Clean Author', userBans: [] }, + } + + mockRepositoryGetPendingListingRiskCandidates.mockResolvedValue([ + submissionRiskListing, + authorRiskListing, + cleanListing, + ]) + mockRepositoryGetPendingListingsByIds.mockResolvedValue([ + submissionRiskListing, + authorRiskListing, + ]) + mockComputeAuthorRiskProfiles.mockResolvedValue( + new Map([ + [AUTHOR_ID, { authorId: AUTHOR_ID, signals: [], highestSeverity: null }], + [ + USER_ID, + { + authorId: USER_ID, + signals: [ + { + type: RISK_SIGNAL_TYPES.NEW_AUTHOR, + severity: 'low', + label: 'New Author', + description: 'No previously approved listings', + }, + ], + highestSeverity: 'low', + }, + ], + [CLEAN_AUTHOR_ID, { authorId: CLEAN_AUTHOR_ID, signals: [], highestSeverity: null }], + ]), + ) + mockComputeSubmissionRiskProfiles.mockResolvedValue( + new Map([ + [ + LISTING_ID, + { + listingId: LISTING_ID, + signals: [ + { + type: SUBMISSION_RISK_SIGNAL_TYPES.PLACEHOLDER_EMULATOR_VERSION, + severity: 'high', + label: 'Placeholder Emulator Version', + description: 'Submitted emulator version resembles placeholder text.', + }, + ], + highestSeverity: 'high', + }, + ], + [LISTING_ID_B, { listingId: LISTING_ID_B, signals: [], highestSeverity: null }], + [LISTING_ID_C, { listingId: LISTING_ID_C, signals: [], highestSeverity: null }], + ]), + ) + + const { caller } = createCaller({ userId: ADMIN_ID, role: Role.MODERATOR }) + + const result = await caller.pending({ riskFilter: 'risky', page: 1, limit: 20 }) + + expect(mockRepositoryGetPendingListingRiskCandidates).toHaveBeenCalledWith( + expect.objectContaining({ canSeeBannedUsers: true }), + ) + expect(mockRepositoryGetPendingListingsByIds).toHaveBeenCalledWith([LISTING_ID, LISTING_ID_B]) + expect(mockRepositoryGetPendingListings).not.toHaveBeenCalled() + expect(result.pcListings).toHaveLength(2) + expect(result.pcListings[0].id).toBe(LISTING_ID) + expect(result.pcListings[0].submissionRiskProfile.highestSeverity).toBe('high') + expect(result.pcListings[1].id).toBe(LISTING_ID_B) + expect(result.pcListings[1].authorRiskProfile.highestSeverity).toBe('low') + expect(result.pagination.total).toBe(2) + }) + }) + describe('approve', () => { it('calls applyTrustAction with LISTING_APPROVED for author', async () => { mockRepositoryGetById.mockResolvedValue({ diff --git a/src/server/api/routers/pcListings.ts b/src/server/api/routers/pcListings.ts index c6dd3d2b0..953bfb46a 100644 --- a/src/server/api/routers/pcListings.ts +++ b/src/server/api/routers/pcListings.ts @@ -64,7 +64,11 @@ import { NOTIFICATION_EVENTS, notificationEventEmitter } from '@/server/notifica import { PcListingsRepository } from '@/server/repositories/pc-listings.repository' import { UserPcPresetsRepository } from '@/server/repositories/user-pc-presets.repository' import { logAudit } from '@/server/services/audit.service' -import { computeAuthorRiskProfiles } from '@/server/services/author-risk.service' +import { + attachReviewRiskProfiles, + computeReviewRiskProfiles, + getRiskyReviewItemIds, +} from '@/server/services/review-risk.service' import { listingStatsCache } from '@/server/utils/cache' import { paginate } from '@/server/utils/pagination' import { isUserBanned } from '@/server/utils/query-builders' @@ -436,7 +440,15 @@ export const pcListingsRouter = createTRPCRouter({ } const repository = new PcListingsRepository(ctx.prisma) - const { search, page = 1, limit = 20, sortField, sortDirection = 'asc' } = input ?? {} + 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 @@ -452,6 +464,31 @@ export const pcListingsRouter = createTRPCRouter({ } } + if (filterRiskyListings) { + const riskCandidates = await repository.getPendingListingRiskCandidates({ + emulatorIds, + search, + sortField, + sortDirection: sortDirection ?? 'asc', + canSeeBannedUsers: true, + }) + const riskProfiles = await computeReviewRiskProfiles(ctx.prisma, riskCandidates) + const riskyPcListingIds = getRiskyReviewItemIds(riskCandidates, riskProfiles) + const paginatedRiskyPcListingIds = riskyPcListingIds.slice((page - 1) * limit, page * limit) + const pagePcListings = await repository.getPendingListingsByIds(paginatedRiskyPcListingIds) + const pagePcListingMap = new Map(pagePcListings.map((listing) => [listing.id, listing])) + const sortedPagePcListings = paginatedRiskyPcListingIds.flatMap((pcListingId) => { + const listing = pagePcListingMap.get(pcListingId) + return listing ? [listing] : [] + }) + const paginatedPcListings = attachReviewRiskProfiles(sortedPagePcListings, riskProfiles) + + return { + pcListings: paginatedPcListings, + pagination: paginate({ total: riskyPcListingIds.length, page, limit }), + } + } + const result = await repository.getPendingListings({ emulatorIds, search, @@ -459,39 +496,14 @@ export const pcListingsRouter = createTRPCRouter({ limit, sortField, sortDirection: sortDirection ?? 'asc', - canSeeBannedUsers: true, // Moderators can see listings from banned users + canSeeBannedUsers: true, }) - // Compute author risk profiles - const uniqueAuthorIds = [...new Set(result.pcListings.map((l) => l.authorId))] - const existingBansMap = new Map() - for (const listing of result.pcListings) { - if ( - listing.author?.userBans && - listing.author.userBans.length > 0 && - !existingBansMap.has(listing.authorId) - ) { - existingBansMap.set( - listing.authorId, - listing.author.userBans.map((b) => ({ reason: b.reason })), - ) - } - } - const riskProfiles = await computeAuthorRiskProfiles( - ctx.prisma, - uniqueAuthorIds, - existingBansMap, - ) + const riskProfiles = await computeReviewRiskProfiles(ctx.prisma, result.pcListings) + const paginatedPcListings = attachReviewRiskProfiles(result.pcListings, riskProfiles) return { - pcListings: result.pcListings.map((listing) => ({ - ...listing, - authorRiskProfile: riskProfiles.get(listing.authorId) ?? { - authorId: listing.authorId, - signals: [], - highestSeverity: null, - }, - })), + pcListings: paginatedPcListings, pagination: result.pagination, } }), diff --git a/src/server/repositories/listings.repository.ts b/src/server/repositories/listings.repository.ts index 073da543e..f3200ee9b 100644 --- a/src/server/repositories/listings.repository.ts +++ b/src/server/repositories/listings.repository.ts @@ -1,6 +1,7 @@ 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' @@ -45,6 +46,28 @@ export interface ListingFilters { showNsfw?: boolean } +export interface PendingListingsFilters { + emulatorIds?: string[] + search?: string | null + page?: number + limit?: number + sortField?: string | null + sortDirection?: 'asc' | 'desc' | null +} + +export interface ListingRiskCandidate { + id: string + authorId: string + author: { userBans: { reason: string }[] } | null + customFieldValues: { + value: unknown + customFieldDefinition: { + name: string + label: string + } + }[] +} + /** * Repository for Listing data access operations. * Manages game compatibility reports with: @@ -448,6 +471,171 @@ export class ListingsRepository extends BaseRepository { } } + async listVerifiedEmulatorIdsByUserId(userId: string): Promise { + const verifiedEmulators = await this.prisma.verifiedDeveloper.findMany({ + where: { userId }, + select: { emulatorId: true }, + }) + + return verifiedEmulators.map((verifiedEmulator) => verifiedEmulator.emulatorId) + } + + async getPendingListings(filters: PendingListingsFilters) { + const page = filters.page ?? 1 + const limit = filters.limit ?? 20 + const where = this.buildPendingListingsWhere(filters) + const orderBy = this.buildPendingListingsOrderBy(filters.sortField, filters.sortDirection) + const offset = calculateOffset({ page }, limit) + + const [total, listings] = await Promise.all([ + this.prisma.listing.count({ where }), + this.prisma.listing.findMany({ + where, + include: this.getPendingListingInclude(), + orderBy, + skip: offset, + take: limit, + }), + ]) + + return { + listings, + pagination: paginate({ total, page, limit }), + } + } + + async getPendingListingRiskCandidates( + filters: PendingListingsFilters, + ): Promise { + return this.prisma.listing.findMany({ + where: this.buildPendingListingsWhere(filters), + select: this.getPendingListingRiskCandidateSelect(), + orderBy: this.buildPendingListingsOrderBy(filters.sortField, filters.sortDirection), + }) + } + + async getPendingListingsByIds(listingIds: string[]) { + if (listingIds.length === 0) return [] + + return this.prisma.listing.findMany({ + where: { id: { in: listingIds }, status: ApprovalStatus.PENDING }, + include: this.getPendingListingInclude(), + }) + } + + private buildPendingListingsWhere(filters: PendingListingsFilters): Prisma.ListingWhereInput { + const where: Prisma.ListingWhereInput = { status: ApprovalStatus.PENDING } + + if (filters.emulatorIds?.length) { + where.emulatorId = { in: filters.emulatorIds } + } + + const search = filters.search?.trim() + if (search) { + where.OR = [ + { game: { title: { contains: search, mode: this.mode } } }, + { game: { system: { name: { contains: search, mode: this.mode } } } }, + { device: { modelName: { contains: search, mode: this.mode } } }, + { device: { brand: { name: { contains: search, mode: this.mode } } } }, + { emulator: { name: { contains: search, mode: this.mode } } }, + { author: { name: { contains: search, mode: this.mode } } }, + ] + } + + return where + } + + private buildPendingListingsOrderBy( + sortField?: string | null, + sortDirection?: 'asc' | 'desc' | null, + ): Prisma.ListingOrderByWithRelationInput { + const direction = sortDirection ?? Prisma.SortOrder.asc + + switch (sortField) { + case 'game.title': + return { game: { title: direction } } + case 'game.system.name': + return { game: { system: { name: direction } } } + case 'device': + return { device: { modelName: direction } } + case 'emulator.name': + return { emulator: { name: direction } } + case 'author.name': + return { author: { name: direction } } + case 'createdAt': + return { createdAt: direction } + default: + return { createdAt: Prisma.SortOrder.asc } + } + } + + private getActiveBanWhere(): Prisma.UserBanWhereInput { + return { + isActive: true, + OR: [{ expiresAt: null }, { expiresAt: { gt: new Date() } }], + } + } + + private getPendingListingInclude() { + return { + game: { include: { system: true } }, + device: { include: { brand: true } }, + emulator: true, + author: { + select: { + id: true, + name: true, + userBans: { + where: this.getActiveBanWhere(), + select: { id: true, reason: true, bannedAt: true, expiresAt: true }, + }, + }, + }, + performance: true, + customFieldValues: { + include: { + customFieldDefinition: { + select: { + id: true, + type: true, + label: true, + name: true, + options: true, + defaultValue: true, + rangeDecimals: true, + rangeUnit: true, + categoryId: true, + categoryOrder: true, + category: { select: { id: true, name: true, displayOrder: true } }, + }, + }, + }, + }, + } satisfies Prisma.ListingInclude + } + + private getPendingListingRiskCandidateSelect() { + return { + id: true, + authorId: true, + author: { + select: { + userBans: { + where: this.getActiveBanWhere(), + select: { reason: true }, + }, + }, + }, + customFieldValues: { + where: { customFieldDefinition: { name: EMULATOR_VERSION_FIELD_NAME } }, + select: { + value: true, + customFieldDefinition: { select: { name: true, label: true } }, + }, + }, + } satisfies Prisma.ListingSelect + } + /** * Build order by clause based on sort field */ diff --git a/src/server/repositories/pc-listings.repository.ts b/src/server/repositories/pc-listings.repository.ts index a6790136f..73ecc5fb5 100644 --- a/src/server/repositories/pc-listings.repository.ts +++ b/src/server/repositories/pc-listings.repository.ts @@ -6,6 +6,7 @@ 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 { computeVoteCounts } from '@/server/utils/moderator-info' import { paginate, calculateOffset } from '@/server/utils/pagination' import { sanitizeInput } from '@/server/utils/security-validation' @@ -38,6 +39,19 @@ export interface PcListingFilters { canSeeBannedUsers?: boolean } +export interface PcListingRiskCandidate { + id: string + authorId: string + author: { userBans: { reason: string }[] } | null + customFieldValues: { + value: unknown + customFieldDefinition: { + name: string + label: string + } + }[] +} + export class PcListingsRepository extends BaseRepository { // Static query shapes for this repository static readonly includes = { @@ -790,6 +804,60 @@ export class PcListingsRepository extends BaseRepository { canSeeBannedUsers = false, } = filters + const where = this.buildPendingListingsWhere({ emulatorIds, search, canSeeBannedUsers }) + const actualOffset = calculateOffset({ page }, limit) + const orderBy = this.buildOrderBy(sortField, sortDirection) + + const [total, pcListings] = await Promise.all([ + this.prisma.pcListing.count({ where }), + this.prisma.pcListing.findMany({ + where, + include: PcListingsRepository.includes.forList, + orderBy, + skip: actualOffset, + take: limit, + }), + ]) + + return { + pcListings, + pagination: paginate({ total: total, page, limit: limit }), + } + } + + async getPendingListingRiskCandidates(filters: { + emulatorIds?: string[] + search?: string + sortField?: string + sortDirection?: 'asc' | 'desc' + canSeeBannedUsers?: boolean + }): Promise { + return this.prisma.pcListing.findMany({ + where: this.buildPendingListingsWhere(filters), + select: this.getPendingListingRiskCandidateSelect(), + orderBy: this.buildOrderBy(filters.sortField, filters.sortDirection ?? 'asc'), + }) + } + + async getPendingListingsByIds(pcListingIds: string[]): Promise< + Prisma.PcListingGetPayload<{ + include: typeof PcListingsRepository.includes.forList + }>[] + > { + if (pcListingIds.length === 0) return [] + + return this.prisma.pcListing.findMany({ + where: { id: { in: pcListingIds }, status: ApprovalStatus.PENDING }, + include: PcListingsRepository.includes.forList, + }) + } + + private buildPendingListingsWhere(filters: { + emulatorIds?: string[] + search?: string + canSeeBannedUsers?: boolean + }): Prisma.PcListingWhereInput { + const { emulatorIds, search, canSeeBannedUsers = false } = filters const baseWhere: Prisma.PcListingWhereInput = { status: ApprovalStatus.PENDING, ...(emulatorIds?.length ? { emulatorId: { in: emulatorIds } } : {}), @@ -806,33 +874,36 @@ export class PcListingsRepository extends BaseRepository { : {}), } - // Apply banned user filtering const shadowBanFilter = canSeeBannedUsers ? undefined : buildShadowBanFilter(null) - const where = { + return { ...baseWhere, ...(shadowBanFilter && { author: shadowBanFilter }), } + } - const actualOffset = calculateOffset({ page }, limit) - const orderBy = this.buildOrderBy(sortField, sortDirection) - - const [total, pcListings] = await Promise.all([ - this.prisma.pcListing.count({ where }), - this.prisma.pcListing.findMany({ - where, - include: PcListingsRepository.includes.forList, - orderBy, - skip: actualOffset, - take: limit, - }), - ]) - - const pagination = paginate({ total: total, page, limit: limit }) - + private getPendingListingRiskCandidateSelect() { return { - pcListings, - pagination, - } + id: true, + authorId: true, + author: { + select: { + userBans: { + where: { + isActive: true, + OR: [{ expiresAt: null }, { expiresAt: { gt: new Date() } }], + }, + select: { reason: true }, + }, + }, + }, + customFieldValues: { + where: { customFieldDefinition: { name: EMULATOR_VERSION_FIELD_NAME } }, + select: { + value: true, + customFieldDefinition: { select: { name: true, label: true } }, + }, + }, + } satisfies Prisma.PcListingSelect } /** diff --git a/src/server/services/author-risk.service.ts b/src/server/services/author-risk.service.ts index 0b56f7e51..1cb625481 100644 --- a/src/server/services/author-risk.service.ts +++ b/src/server/services/author-risk.service.ts @@ -9,10 +9,17 @@ import { TIME_CONSTANTS } from '@/utils/time' import { ApprovalStatus, type PrismaClient } from '@orm' import { getAuthorReportStats, getAuthorVoteStats, getAuthorsWithApprovedListings } from '@orm/sql' -interface ExistingBan { +export interface ExistingAuthorBan { reason: string } +export interface AuthorBanRiskCandidate { + authorId: string + author?: { + userBans?: readonly ExistingAuthorBan[] + } | null +} + const SEVERITY_ORDER: Record = { low: 1, medium: 2, @@ -152,7 +159,7 @@ async function batchGetRejectionCounts( export async function computeAuthorRiskProfiles( prisma: PrismaClient, authorIds: string[], - existingBans: Map, + existingBans: Map, ): Promise> { const profileMap = new Map() @@ -212,7 +219,7 @@ export async function computeAuthorRiskProfiles( RISK_SIGNAL_TYPES.ACTIVE_REPORTS, 'low', 'Active Reports', - `${reportCount} active report${reportCount > 1 ? 's' : ''} across listings`, + `${reportCount} active reports across listings`, ), ) } @@ -277,6 +284,7 @@ export async function computeAuthorRiskProfiles( // NEGATIVE_TRUST_SCORE const trustScore = trustScores.get(authorId) ?? 0 + // TODO: Replace fixed trust-score thresholds with an account-history calibrated risk model. if (trustScore < -50) { signals.push( createSignal( @@ -308,6 +316,7 @@ export async function computeAuthorRiskProfiles( // PREVIOUSLY_REJECTED const rejectedCount = rejectionCounts.get(authorId) ?? 0 + // TODO: Weight rejection history against author reputation and approved contribution history. if (rejectedCount >= 6) { signals.push( createSignal( @@ -346,3 +355,24 @@ export async function computeAuthorRiskProfiles( return profileMap } + +export function createExistingAuthorBansMap( + listings: readonly AuthorBanRiskCandidate[], +): Map { + const existingBansMap = new Map() + + for (const listing of listings) { + if ( + listing.author?.userBans && + listing.author.userBans.length > 0 && + !existingBansMap.has(listing.authorId) + ) { + existingBansMap.set( + listing.authorId, + listing.author.userBans.map((ban) => ({ reason: ban.reason })), + ) + } + } + + return existingBansMap +} diff --git a/src/server/services/review-risk.service.test.ts b/src/server/services/review-risk.service.test.ts new file mode 100644 index 000000000..8f74f7506 --- /dev/null +++ b/src/server/services/review-risk.service.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from 'vitest' +import { RISK_SIGNAL_TYPES, type AuthorRiskProfile } from '@/schemas/authorRisk' +import { SUBMISSION_RISK_SIGNAL_TYPES, type SubmissionRiskProfile } from '@/schemas/submissionRisk' +import { attachReviewRiskProfiles, getRiskyReviewItemIds } from './review-risk.service' + +const AUTHOR_ID = '00000000-0000-4000-a000-000000000001' +const CLEAN_AUTHOR_ID = '00000000-0000-4000-a000-000000000002' +const LISTING_ID = '00000000-0000-4000-a000-000000000010' +const CLEAN_LISTING_ID = '00000000-0000-4000-a000-000000000011' + +const authorRiskProfile: AuthorRiskProfile = { + authorId: AUTHOR_ID, + highestSeverity: 'high', + signals: [ + { + type: RISK_SIGNAL_TYPES.ACTIVE_BAN, + severity: 'high', + label: 'Active Ban', + description: 'Banned for spam', + }, + ], +} + +const submissionRiskProfile: SubmissionRiskProfile = { + listingId: LISTING_ID, + highestSeverity: 'high', + signals: [ + { + type: SUBMISSION_RISK_SIGNAL_TYPES.PLACEHOLDER_EMULATOR_VERSION, + severity: 'high', + label: 'Placeholder Emulator Version', + description: 'Submitted emulator version resembles placeholder text.', + }, + ], +} + +describe('review risk helpers', () => { + it('returns only candidates with author or submission risk signals', () => { + const riskCandidateIds = getRiskyReviewItemIds( + [ + { id: LISTING_ID, authorId: AUTHOR_ID, customFieldValues: [] }, + { id: CLEAN_LISTING_ID, authorId: CLEAN_AUTHOR_ID, customFieldValues: [] }, + ], + { + authorRiskProfiles: new Map([[AUTHOR_ID, authorRiskProfile]]), + submissionRiskProfiles: new Map([[LISTING_ID, submissionRiskProfile]]), + }, + ) + + expect(riskCandidateIds).toEqual([LISTING_ID]) + }) + + it('attaches empty profiles when no risk profile exists for a listing', () => { + const enrichedListings = attachReviewRiskProfiles( + [{ id: CLEAN_LISTING_ID, authorId: CLEAN_AUTHOR_ID, title: 'Clean listing' }], + { + authorRiskProfiles: new Map(), + submissionRiskProfiles: new Map(), + }, + ) + + expect(enrichedListings[0]).toMatchObject({ + id: CLEAN_LISTING_ID, + authorId: CLEAN_AUTHOR_ID, + title: 'Clean listing', + authorRiskProfile: { + authorId: CLEAN_AUTHOR_ID, + signals: [], + highestSeverity: null, + }, + submissionRiskProfile: { + listingId: CLEAN_LISTING_ID, + signals: [], + highestSeverity: null, + }, + }) + }) +}) diff --git a/src/server/services/review-risk.service.ts b/src/server/services/review-risk.service.ts new file mode 100644 index 000000000..2d9331f28 --- /dev/null +++ b/src/server/services/review-risk.service.ts @@ -0,0 +1,84 @@ +import { type AuthorRiskProfile } from '@/schemas/authorRisk' +import { type SubmissionRiskProfile } from '@/schemas/submissionRisk' +import { + type AuthorBanRiskCandidate, + computeAuthorRiskProfiles, + createExistingAuthorBansMap, +} from '@/server/services/author-risk.service' +import { + computeSubmissionRiskProfiles, + type SubmissionForRisk, +} from '@/server/services/submission-risk.service' + +type RiskPrismaClient = Parameters[0] + +type ReviewRiskCandidate = AuthorBanRiskCandidate & SubmissionForRisk + +interface ReviewRiskProfiles { + authorRiskProfiles: Map + submissionRiskProfiles: Map +} + +export type ReviewRiskEnriched = TListing & { + authorRiskProfile: AuthorRiskProfile + submissionRiskProfile: SubmissionRiskProfile +} + +function createEmptyAuthorRiskProfile(authorId: string): AuthorRiskProfile { + return { authorId, signals: [], highestSeverity: null } +} + +function createEmptySubmissionRiskProfile(listingId: string): SubmissionRiskProfile { + return { listingId, signals: [], highestSeverity: null } +} + +function hasRiskSignals(candidate: ReviewRiskCandidate, profiles: ReviewRiskProfiles): boolean { + const authorRiskProfile = profiles.authorRiskProfiles.get(candidate.authorId) + if (authorRiskProfile && authorRiskProfile.signals.length > 0) return true + + const submissionRiskProfile = profiles.submissionRiskProfiles.get(candidate.id) + return Boolean(submissionRiskProfile && submissionRiskProfile.signals.length > 0) +} + +export async function computeReviewRiskProfiles( + prisma: RiskPrismaClient, + candidates: readonly TCandidate[], +): Promise { + const authorIds = [...new Set(candidates.map((candidate) => candidate.authorId))] + const authorRiskProfiles = await computeAuthorRiskProfiles( + prisma, + authorIds, + createExistingAuthorBansMap(candidates), + ) + const submissionRiskProfiles = await computeSubmissionRiskProfiles( + prisma, + candidates, + authorRiskProfiles, + ) + + return { authorRiskProfiles, submissionRiskProfiles } +} + +export function getRiskyReviewItemIds( + candidates: readonly TCandidate[], + profiles: ReviewRiskProfiles, +): string[] { + return candidates + .filter((candidate) => hasRiskSignals(candidate, profiles)) + .map((candidate) => candidate.id) +} + +export function attachReviewRiskProfiles( + listings: readonly TListing[], + profiles: ReviewRiskProfiles, +): ReviewRiskEnriched[] { + return listings.map((listing) => ({ + ...listing, + authorRiskProfile: + profiles.authorRiskProfiles.get(listing.authorId) ?? + createEmptyAuthorRiskProfile(listing.authorId), + submissionRiskProfile: + profiles.submissionRiskProfiles.get(listing.id) ?? + createEmptySubmissionRiskProfile(listing.id), + })) +} diff --git a/src/server/services/submission-risk.service.test.ts b/src/server/services/submission-risk.service.test.ts new file mode 100644 index 000000000..22e2a5c44 --- /dev/null +++ b/src/server/services/submission-risk.service.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from 'vitest' +import { RISK_SIGNAL_TYPES, type AuthorRiskProfile } from '@/schemas/authorRisk' +import { SUBMISSION_RISK_SIGNAL_TYPES } from '@/schemas/submissionRisk' +import { + computeSubmissionRiskProfile, + isPlaceholderLikeEmulatorVersion, +} from './submission-risk.service' + +const AUTHOR_ID = 'author-1' +const LISTING_ID = 'listing-1' + +function createSubmission(value: unknown) { + return { + id: LISTING_ID, + authorId: AUTHOR_ID, + customFieldValues: [ + { + value, + customFieldDefinition: { + name: 'emulator_version', + label: 'Emulator Version', + }, + }, + ], + } +} + +function createAuthorRiskProfile(signals: AuthorRiskProfile['signals']): AuthorRiskProfile { + return { + authorId: AUTHOR_ID, + signals, + highestSeverity: signals[0]?.severity ?? null, + } +} + +describe('isPlaceholderLikeEmulatorVersion', () => { + it.each(['v0.1.4', 'v0.14', '014', 'o14', 'V 0 1 4'])( + 'detects placeholder-like value %s', + (value) => { + expect(isPlaceholderLikeEmulatorVersion(value)).toBe(true) + }, + ) + + it.each(['1.1.0', '2.0.0', '2123.2', '0.0.2-pre-alpha'])( + 'allows realistic version value %s', + (value) => { + expect(isPlaceholderLikeEmulatorVersion(value)).toBe(false) + }, + ) +}) + +describe('computeSubmissionRiskProfiles', () => { + it('returns no signals when emulator_version is not placeholder-like', async () => { + const profile = computeSubmissionRiskProfile(createSubmission('1.1.0'), undefined, undefined) + + expect(profile.signals).toHaveLength(0) + }) + + it('flags placeholder-like emulator_version as high risk for a new author', async () => { + const profile = computeSubmissionRiskProfile( + createSubmission('v0.1.4'), + createAuthorRiskProfile([ + { + type: RISK_SIGNAL_TYPES.NEW_AUTHOR, + severity: 'low', + label: 'New Author', + description: 'No previously approved listings', + }, + ]), + undefined, + ) + + const signal = profile.signals[0] + expect(signal?.type).toBe(SUBMISSION_RISK_SIGNAL_TYPES.PLACEHOLDER_EMULATOR_VERSION) + expect(signal?.severity).toBe('high') + }) + + it('lowers placeholder severity for authors with multiple approved listings', async () => { + const profile = computeSubmissionRiskProfile(createSubmission('v0.14'), undefined, { + trustScore: 0, + approvedListings: 3, + }) + + expect(profile.signals[0]?.severity).toBe('low') + }) + + it('lowers placeholder severity for contributor-level trust', async () => { + const profile = computeSubmissionRiskProfile(createSubmission('014'), undefined, { + trustScore: 100, + approvedListings: 0, + }) + + expect(profile.signals[0]?.severity).toBe('low') + }) + + it('keeps placeholder severity medium for limited authors with prior low risk', async () => { + const profile = computeSubmissionRiskProfile( + createSubmission('o14'), + createAuthorRiskProfile([ + { + type: RISK_SIGNAL_TYPES.PREVIOUSLY_REJECTED, + severity: 'low', + label: 'Previously Rejected', + description: '1 rejected listing', + }, + ]), + { trustScore: 0, approvedListings: 1 }, + ) + + expect(profile.signals[0]?.severity).toBe('medium') + }) +}) diff --git a/src/server/services/submission-risk.service.ts b/src/server/services/submission-risk.service.ts new file mode 100644 index 000000000..381c07018 --- /dev/null +++ b/src/server/services/submission-risk.service.ts @@ -0,0 +1,239 @@ +import { TRUST_LEVELS, hasTrustLevel } from '@/lib/trust/config' +import { RISK_SIGNAL_TYPES, type AuthorRiskProfile } from '@/schemas/authorRisk' +import { type Severity } from '@/schemas/common' +import { + EMULATOR_VERSION_FIELD_NAME, + SUBMISSION_RISK_SIGNAL_TYPES, + type SubmissionRiskProfile, + type SubmissionRiskSignal, + type SubmissionRiskSignalType, +} from '@/schemas/submissionRisk' +import { ApprovalStatus, type PrismaClient } from '@orm' + +interface CustomFieldDefinitionForRisk { + name: string + label: string +} + +interface CustomFieldValueForRisk { + value: unknown + customFieldDefinition: CustomFieldDefinitionForRisk +} + +export interface SubmissionForRisk { + id: string + authorId: string + customFieldValues?: readonly CustomFieldValueForRisk[] | null +} + +export interface AuthorCredibility { + trustScore: number + approvedListings: number +} + +const PLACEHOLDER_VERSION_DIGITS = '014' +const MIN_APPROVED_LISTINGS_FOR_CREDIBILITY = 3 +const TRUST_LEVEL_FOR_CREDIBILITY = TRUST_LEVELS[1].name + +const SEVERITY_ORDER: Record = { + low: 1, + medium: 2, + high: 3, +} + +function createSignal( + type: SubmissionRiskSignalType, + severity: Severity, + label: string, + description: string, +): SubmissionRiskSignal { + return { type, severity, label, description } +} + +function highestSeverity(signals: SubmissionRiskSignal[]): Severity | null { + if (signals.length === 0) return null + let max = signals[0].severity + for (const signal of signals) { + if (SEVERITY_ORDER[signal.severity] > SEVERITY_ORDER[max]) max = signal.severity + } + return max +} + +function createEmptyProfile(listingId: string): SubmissionRiskProfile { + return { listingId, signals: [], highestSeverity: null } +} + +function compactVersionValue(value: string): string { + return value + .normalize('NFKC') + .trim() + .toLowerCase() + .replaceAll('o', '0') + .replaceAll('i', '1') + .replaceAll('l', '1') + .replace(/[^a-z0-9]/g, '') +} + +export function isPlaceholderLikeEmulatorVersion(value: unknown): value is string { + if (typeof value !== 'string') return false + + const compactValue = compactVersionValue(value) + if (compactValue.length === 0) return false + + const digitSignature = compactValue.replace(/\D/g, '') + return digitSignature === PLACEHOLDER_VERSION_DIGITS +} + +function getEmulatorVersionValue(submission: SubmissionForRisk): string | null { + const fieldValue = submission.customFieldValues?.find( + (customFieldValue) => + customFieldValue.customFieldDefinition.name === EMULATOR_VERSION_FIELD_NAME, + ) + + return isPlaceholderLikeEmulatorVersion(fieldValue?.value) ? fieldValue.value : null +} + +async function batchGetAuthorCredibility( + prisma: PrismaClient, + authorIds: string[], +): Promise> { + const uniqueAuthorIds = [...new Set(authorIds)] + const [users, listingApprovals, pcListingApprovals] = await Promise.all([ + prisma.user.findMany({ + where: { id: { in: uniqueAuthorIds } }, + select: { id: true, trustScore: true }, + }), + prisma.listing.groupBy({ + by: ['authorId'], + where: { authorId: { in: uniqueAuthorIds }, status: ApprovalStatus.APPROVED }, + _count: true, + }), + prisma.pcListing.groupBy({ + by: ['authorId'], + where: { authorId: { in: uniqueAuthorIds }, status: ApprovalStatus.APPROVED }, + _count: true, + }), + ]) + + const credibilityMap = new Map() + + for (const authorId of uniqueAuthorIds) { + credibilityMap.set(authorId, { trustScore: 0, approvedListings: 0 }) + } + + for (const user of users) { + const credibility = credibilityMap.get(user.id) ?? { trustScore: 0, approvedListings: 0 } + credibilityMap.set(user.id, { ...credibility, trustScore: user.trustScore }) + } + + for (const row of listingApprovals) { + const credibility = credibilityMap.get(row.authorId) ?? { trustScore: 0, approvedListings: 0 } + credibilityMap.set(row.authorId, { + ...credibility, + approvedListings: credibility.approvedListings + row._count, + }) + } + + for (const row of pcListingApprovals) { + const credibility = credibilityMap.get(row.authorId) ?? { trustScore: 0, approvedListings: 0 } + credibilityMap.set(row.authorId, { + ...credibility, + approvedListings: credibility.approvedListings + row._count, + }) + } + + return credibilityMap +} + +function hasOnlyNewAuthorRisk(authorRiskProfile: AuthorRiskProfile | undefined): boolean { + if (!authorRiskProfile || authorRiskProfile.signals.length === 0) return false + return authorRiskProfile.signals.every((signal) => signal.type === RISK_SIGNAL_TYPES.NEW_AUTHOR) +} + +function getPlaceholderVersionSeverity(params: { + authorRiskProfile: AuthorRiskProfile | undefined + credibility: AuthorCredibility | undefined +}): Severity { + const credibility = params.credibility ?? { trustScore: 0, approvedListings: 0 } + const isCredibleAuthor = + credibility.approvedListings >= MIN_APPROVED_LISTINGS_FOR_CREDIBILITY || + hasTrustLevel(credibility.trustScore, TRUST_LEVEL_FOR_CREDIBILITY) + + if (isCredibleAuthor) return 'low' + + if (params.authorRiskProfile?.highestSeverity === 'high') return 'high' + if (!params.authorRiskProfile || hasOnlyNewAuthorRisk(params.authorRiskProfile)) return 'high' + if (params.authorRiskProfile.highestSeverity === 'medium') return 'high' + + return 'medium' +} + +function getPlaceholderVersionDescription(params: { + value: string + severity: Severity + credibility: AuthorCredibility | undefined +}): string { + const approvedListings = params.credibility?.approvedListings ?? 0 + const trustScore = params.credibility?.trustScore ?? 0 + const context = + params.severity === 'low' + ? 'The author has established contribution history, so this may be a mistake.' + : approvedListings === 0 + ? 'The author has no approved listings, so this needs close review.' + : 'The author has limited established contribution history, so this needs close review.' + + return `Submitted emulator version "${params.value}" resembles placeholder text. ${context} Approved listings: ${approvedListings}. Trust score: ${trustScore}.` +} + +export async function computeSubmissionRiskProfiles( + prisma: PrismaClient, + submissions: readonly SubmissionForRisk[], + authorRiskProfiles: ReadonlyMap, +): Promise> { + const profileMap = new Map() + + if (submissions.length === 0) return profileMap + + const credibilityMap = await batchGetAuthorCredibility( + prisma, + submissions.map((submission) => submission.authorId), + ) + + for (const submission of submissions) { + profileMap.set( + submission.id, + computeSubmissionRiskProfile( + submission, + authorRiskProfiles.get(submission.authorId), + credibilityMap.get(submission.authorId), + ), + ) + } + + return profileMap +} + +export function computeSubmissionRiskProfile( + submission: SubmissionForRisk, + authorRiskProfile: AuthorRiskProfile | undefined, + credibility: AuthorCredibility | undefined, +): SubmissionRiskProfile { + const emulatorVersionValue = getEmulatorVersionValue(submission) + if (!emulatorVersionValue) return createEmptyProfile(submission.id) + + const severity = getPlaceholderVersionSeverity({ authorRiskProfile, credibility }) + const signals = [ + createSignal( + SUBMISSION_RISK_SIGNAL_TYPES.PLACEHOLDER_EMULATOR_VERSION, + severity, + 'Placeholder Emulator Version', + getPlaceholderVersionDescription({ value: emulatorVersionValue, severity, credibility }), + ), + ] + + return { + listingId: submission.id, + signals, + highestSeverity: highestSeverity(signals), + } +} From fb426ca18776592e1b5472723900a3c60a5d87cb Mon Sep 17 00:00:00 2001 From: Producdevity Date: Fri, 15 May 2026 01:46:04 +0200 Subject: [PATCH 06/17] fix: address review risk follow-ups --- src/app/admin/approvals/page.tsx | 19 ++---- src/app/admin/pc-listing-approvals/page.tsx | 18 ++---- src/components/admin/AdminErrorState.tsx | 20 ++++++ .../admin/ReviewRiskFilterButton.tsx | 12 +--- src/components/admin/index.ts | 1 + .../ui/ReviewRiskIndicator.test.tsx | 47 +++++++++++++- src/components/ui/ReviewRiskIndicator.tsx | 61 +++--------------- src/components/ui/ReviewRiskWarningBanner.tsx | 49 +------------- src/components/ui/ToggleButton.tsx | 37 +++++++++++ src/components/ui/index.ts | 1 + src/components/ui/reviewRiskDisplay.ts | 61 ++++++++++++++++++ src/server/api/routers/listings/admin.test.ts | 5 +- src/server/api/routers/listings/admin.ts | 5 +- src/server/api/routers/pcListings.test.ts | 9 ++- src/server/api/routers/pcListings.ts | 6 +- .../repositories/listings.repository.ts | 6 +- .../repositories/pc-listings.repository.ts | 64 +++++++++---------- 17 files changed, 250 insertions(+), 171 deletions(-) create mode 100644 src/components/admin/AdminErrorState.tsx create mode 100644 src/components/ui/ToggleButton.tsx create mode 100644 src/components/ui/reviewRiskDisplay.ts diff --git a/src/app/admin/approvals/page.tsx b/src/app/admin/approvals/page.tsx index 8ba9e25cb..170991cc0 100644 --- a/src/app/admin/approvals/page.tsx +++ b/src/app/admin/approvals/page.tsx @@ -8,6 +8,7 @@ import { isEmpty } from 'remeda' import { useAdminTable, useReviewRiskFilter } from '@/app/admin/hooks' import { confirmBulkApproval } from '@/app/admin/utils' import { + AdminErrorState, AdminPageLayout, AdminTableContainer, AdminNotificationBanner, @@ -20,7 +21,6 @@ import { EmulatorIcon, SystemIcon } from '@/components/icons' import { ApproveButton, BulkActions, - Button, ColumnVisibilityControl, DisplayToggleButton, LoadingSpinner, @@ -262,19 +262,14 @@ function AdminApprovalsPage() { } } - // TODO: extract this to a generic Admin error component if (pendingListingsQuery.error) { return ( -
-
-

- Error loading pending listings: {pendingListingsQuery.error.message} -

- -
-
+ { + void pendingListingsQuery.refetch() + }} + /> ) } diff --git a/src/app/admin/pc-listing-approvals/page.tsx b/src/app/admin/pc-listing-approvals/page.tsx index 876bc1203..5bc5aa99c 100644 --- a/src/app/admin/pc-listing-approvals/page.tsx +++ b/src/app/admin/pc-listing-approvals/page.tsx @@ -9,6 +9,7 @@ import { isEmpty } from 'remeda' import { useAdminTable, useReviewRiskFilter } from '@/app/admin/hooks' import { confirmBulkApproval } from '@/app/admin/utils' import { + AdminErrorState, AdminPageLayout, AdminTableContainer, AdminNotificationBanner, @@ -21,7 +22,6 @@ import { EmulatorIcon, SystemIcon } from '@/components/icons' import { ApproveButton, BulkActions, - Button, ColumnVisibilityControl, DisplayToggleButton, LoadingSpinner, @@ -267,16 +267,12 @@ function PcListingApprovalsPage() { if (pendingPcListingsQuery.error) { return ( -
-
-

- Error loading pending PC listings: {pendingPcListingsQuery.error.message} -

- -
-
+ { + void pendingPcListingsQuery.refetch() + }} + /> ) } diff --git a/src/components/admin/AdminErrorState.tsx b/src/components/admin/AdminErrorState.tsx new file mode 100644 index 000000000..1bae0b692 --- /dev/null +++ b/src/components/admin/AdminErrorState.tsx @@ -0,0 +1,20 @@ +import { Button } from '@/components/ui' + +interface Props { + message: string + onRetry: () => void + retryLabel?: string +} + +export function AdminErrorState(props: Props) { + return ( +
+
+

{props.message}

+ +
+
+ ) +} diff --git a/src/components/admin/ReviewRiskFilterButton.tsx b/src/components/admin/ReviewRiskFilterButton.tsx index 458f59b18..a4af0eb71 100644 --- a/src/components/admin/ReviewRiskFilterButton.tsx +++ b/src/components/admin/ReviewRiskFilterButton.tsx @@ -1,21 +1,15 @@ import { ShieldAlert } from 'lucide-react' -import { Button } from '@/components/ui' +import { ToggleButton } from '@/components/ui' interface Props { isActive: boolean onToggle: () => void } -// TODO: refactor to use a toggle instead of a button. if we have one, we should use that or create a reusable button or steal one from chadcn. (see chadcn command in package.json_ export function ReviewRiskFilterButton(props: Props) { return ( - + ) } diff --git a/src/components/admin/index.ts b/src/components/admin/index.ts index 8426784f0..28ad4d25c 100644 --- a/src/components/admin/index.ts +++ b/src/components/admin/index.ts @@ -1,4 +1,5 @@ export * from './AdminNotificationBanner' +export * from './AdminErrorState' export * from './AdminPageLayout' export * from './AdminSearchFilters' export * from './AdminStatsDisplay' diff --git a/src/components/ui/ReviewRiskIndicator.test.tsx b/src/components/ui/ReviewRiskIndicator.test.tsx index f23c8b778..0f63a2307 100644 --- a/src/components/ui/ReviewRiskIndicator.test.tsx +++ b/src/components/ui/ReviewRiskIndicator.test.tsx @@ -1,5 +1,6 @@ import { render, screen } from '@testing-library/react' -import { describe, expect, it } from 'vitest' +import userEvent from '@testing-library/user-event' +import { describe, expect, it, vi } from 'vitest' import { RISK_SIGNAL_TYPES, type AuthorRiskProfile } from '@/schemas/authorRisk' import { SUBMISSION_RISK_SIGNAL_TYPES, type SubmissionRiskProfile } from '@/schemas/submissionRisk' import { ReviewRiskIndicator } from './ReviewRiskIndicator' @@ -65,4 +66,48 @@ describe('ReviewRiskIndicator', () => { 'Review risk: high severity, 1 signal', ) }) + + it('calls investigate from pointer and keyboard activation when author risk exists', async () => { + const user = userEvent.setup() + const onInvestigate = vi.fn() + + render( + , + ) + + const button = screen.getByRole('button', { + name: 'Review risk: high severity, 2 signals', + }) + await user.click(button) + button.focus() + await user.keyboard('{Enter}') + await user.keyboard(' ') + + expect(onInvestigate).toHaveBeenCalledTimes(3) + expect(onInvestigate).toHaveBeenNthCalledWith(1, authorRiskProfile.authorId) + expect(onInvestigate).toHaveBeenNthCalledWith(2, authorRiskProfile.authorId) + expect(onInvestigate).toHaveBeenNthCalledWith(3, authorRiskProfile.authorId) + }) + + it('does not expose investigate action for submission-only risk', () => { + const onInvestigate = vi.fn() + + render( + , + ) + + expect(screen.getByRole('status')).toHaveAttribute( + 'aria-label', + 'Review risk: high severity, 1 signal', + ) + expect(screen.queryByRole('button')).not.toBeInTheDocument() + }) }) diff --git a/src/components/ui/ReviewRiskIndicator.tsx b/src/components/ui/ReviewRiskIndicator.tsx index e8493b9f0..d497a0539 100644 --- a/src/components/ui/ReviewRiskIndicator.tsx +++ b/src/components/ui/ReviewRiskIndicator.tsx @@ -2,23 +2,16 @@ import { cn } from '@/lib/utils' import { type AuthorRiskProfile } from '@/schemas/authorRisk' -import { type Severity } from '@/schemas/common' import { type SubmissionRiskProfile } from '@/schemas/submissionRisk' import { severityBadgeVariant, severityIconConfig } from './AuthorRiskIndicator' import { Badge } from './Badge' +import { + getHighestReviewRiskSeverity, + getReviewRiskGroups, + getReviewRiskSignalCount, +} from './reviewRiskDisplay' import { Tooltip, TooltipContent, TooltipTrigger } from './Tooltip' -interface RiskSignalForDisplay { - severity: Severity - label: string - description: string -} - -interface RiskGroupForDisplay { - title: string - signals: RiskSignalForDisplay[] -} - interface Props { authorRiskProfile: AuthorRiskProfile | null | undefined submissionRiskProfile: SubmissionRiskProfile | null | undefined @@ -27,47 +20,9 @@ interface Props { onInvestigate?: (authorId: string) => void } -const SEVERITY_ORDER: Record = { - low: 1, - medium: 2, - high: 3, -} - -function getRiskGroups(props: Props): RiskGroupForDisplay[] { - const groups: RiskGroupForDisplay[] = [] - - if (props.submissionRiskProfile && props.submissionRiskProfile.signals.length > 0) { - groups.push({ title: 'Submission Risk', signals: props.submissionRiskProfile.signals }) - } - - if (props.authorRiskProfile && props.authorRiskProfile.signals.length > 0) { - groups.push({ title: 'Author Risk', signals: props.authorRiskProfile.signals }) - } - - return groups -} - -function getHighestSeverity(groups: RiskGroupForDisplay[]): Severity | null { - let max: Severity | null = null - - for (const group of groups) { - for (const signal of group.signals) { - if (!max || SEVERITY_ORDER[signal.severity] > SEVERITY_ORDER[max]) { - max = signal.severity - } - } - } - - return max -} - -function getSignalCount(groups: RiskGroupForDisplay[]): number { - return groups.reduce((total, group) => total + group.signals.length, 0) -} - export function ReviewRiskIndicator(props: Props) { - const groups = getRiskGroups(props) - const severity = getHighestSeverity(groups) + const groups = getReviewRiskGroups(props) + const severity = getHighestReviewRiskSeverity(groups) if (!severity) return null const config = severityIconConfig[severity] @@ -75,7 +30,7 @@ export function ReviewRiskIndicator(props: Props) { const size = props.size ?? 'sm' const iconSize = size === 'sm' ? 'h-4 w-4' : 'h-5 w-5' const isClickable = !!props.onInvestigate && !!props.authorRiskProfile - const signalCount = getSignalCount(groups) + const signalCount = getReviewRiskSignalCount(groups) const handleClick = () => { if (props.onInvestigate && props.authorRiskProfile) { diff --git a/src/components/ui/ReviewRiskWarningBanner.tsx b/src/components/ui/ReviewRiskWarningBanner.tsx index 31e527bcf..a92272c28 100644 --- a/src/components/ui/ReviewRiskWarningBanner.tsx +++ b/src/components/ui/ReviewRiskWarningBanner.tsx @@ -6,17 +6,7 @@ import { type Severity } from '@/schemas/common' import { type SubmissionRiskProfile } from '@/schemas/submissionRisk' import { severityBadgeVariant, severityIconConfig } from './AuthorRiskIndicator' import { Badge } from './Badge' - -interface RiskSignalForDisplay { - severity: Severity - label: string - description: string -} - -interface RiskGroupForDisplay { - title: string - signals: RiskSignalForDisplay[] -} +import { getHighestReviewRiskSeverity, getReviewRiskGroups } from './reviewRiskDisplay' interface Props { authorRiskProfile: AuthorRiskProfile | null | undefined @@ -30,42 +20,9 @@ const severityBorderConfig: Record = { low: 'border-yellow-200 dark:border-yellow-800 bg-yellow-50 dark:bg-yellow-900/20', } -// TODO: consider abstracting these 30~ lines of duplicated lines across multiple files -const SEVERITY_ORDER: Record = { - low: 1, - medium: 2, - high: 3, -} - -function getRiskGroups(props: Props): RiskGroupForDisplay[] { - const groups: RiskGroupForDisplay[] = [] - - if (props.submissionRiskProfile && props.submissionRiskProfile.signals.length > 0) { - groups.push({ title: 'Submission Risk', signals: props.submissionRiskProfile.signals }) - } - - if (props.authorRiskProfile && props.authorRiskProfile.signals.length > 0) { - groups.push({ title: 'Author Risk', signals: props.authorRiskProfile.signals }) - } - - return groups -} - -function getHighestSeverity(groups: RiskGroupForDisplay[]): Severity | null { - let max: Severity | null = null - - for (const group of groups) { - for (const signal of group.signals) { - if (!max || SEVERITY_ORDER[signal.severity] > SEVERITY_ORDER[max]) max = signal.severity - } - } - - return max -} - export function ReviewRiskWarningBanner(props: Props) { - const groups = getRiskGroups(props) - const severity = getHighestSeverity(groups) + const groups = getReviewRiskGroups(props) + const severity = getHighestReviewRiskSeverity(groups) if (!severity) return null const config = severityIconConfig[severity] diff --git a/src/components/ui/ToggleButton.tsx b/src/components/ui/ToggleButton.tsx new file mode 100644 index 000000000..494805f55 --- /dev/null +++ b/src/components/ui/ToggleButton.tsx @@ -0,0 +1,37 @@ +'use client' + +import { type LucideIcon } from 'lucide-react' +import { type ReactNode } from 'react' +import { Button, type ButtonSize, type ButtonVariant } from '@/components/ui/Button' + +interface Props { + isPressed: boolean + onToggle: () => void + children: ReactNode + icon?: LucideIcon + size?: ButtonSize + className?: string + disabled?: boolean + pressedVariant?: ButtonVariant + unpressedVariant?: ButtonVariant +} + +export function ToggleButton(props: Props) { + return ( + + ) +} diff --git a/src/components/ui/index.ts b/src/components/ui/index.ts index c5e707498..ed46589e5 100644 --- a/src/components/ui/index.ts +++ b/src/components/ui/index.ts @@ -41,6 +41,7 @@ export * from './SwipeableCard' export * from './ThemeSelect' export * from './ThemeToggle' export * from './ThreeWayToggle' +export * from './ToggleButton' export * from './Tooltip' export * from './UnderlineTabBar' export * from './TrustLevelBadge' diff --git a/src/components/ui/reviewRiskDisplay.ts b/src/components/ui/reviewRiskDisplay.ts new file mode 100644 index 000000000..2e841b400 --- /dev/null +++ b/src/components/ui/reviewRiskDisplay.ts @@ -0,0 +1,61 @@ +import { type AuthorRiskProfile } from '@/schemas/authorRisk' +import { type Severity } from '@/schemas/common' +import { type SubmissionRiskProfile } from '@/schemas/submissionRisk' + +export interface RiskSignalForDisplay { + severity: Severity + label: string + description: string +} + +export interface ReviewRiskGroupForDisplay { + title: string + signals: RiskSignalForDisplay[] +} + +interface ReviewRiskProfilesForDisplay { + authorRiskProfile: AuthorRiskProfile | null | undefined + submissionRiskProfile: SubmissionRiskProfile | null | undefined +} + +const SEVERITY_ORDER: Record = { + low: 1, + medium: 2, + high: 3, +} + +export function getReviewRiskGroups( + profiles: ReviewRiskProfilesForDisplay, +): ReviewRiskGroupForDisplay[] { + const groups: ReviewRiskGroupForDisplay[] = [] + + if (profiles.submissionRiskProfile && profiles.submissionRiskProfile.signals.length > 0) { + groups.push({ title: 'Submission Risk', signals: profiles.submissionRiskProfile.signals }) + } + + if (profiles.authorRiskProfile && profiles.authorRiskProfile.signals.length > 0) { + groups.push({ title: 'Author Risk', signals: profiles.authorRiskProfile.signals }) + } + + return groups +} + +export function getHighestReviewRiskSeverity( + groups: readonly ReviewRiskGroupForDisplay[], +): Severity | null { + let max: Severity | null = null + + for (const group of groups) { + for (const signal of group.signals) { + if (!max || SEVERITY_ORDER[signal.severity] > SEVERITY_ORDER[max]) { + max = signal.severity + } + } + } + + return max +} + +export function getReviewRiskSignalCount(groups: readonly ReviewRiskGroupForDisplay[]): number { + return groups.reduce((total, group) => total + group.signals.length, 0) +} diff --git a/src/server/api/routers/listings/admin.test.ts b/src/server/api/routers/listings/admin.test.ts index 6885fdd57..a32803e35 100644 --- a/src/server/api/routers/listings/admin.test.ts +++ b/src/server/api/routers/listings/admin.test.ts @@ -206,7 +206,10 @@ describe('listing admin pending approvals', () => { sortField: undefined, sortDirection: undefined, }) - expect(mockGetPendingListingsByIds).toHaveBeenCalledWith([LISTING_ID, LISTING_ID_B]) + expect(mockGetPendingListingsByIds).toHaveBeenCalledWith([LISTING_ID, LISTING_ID_B], { + emulatorIds: undefined, + search: undefined, + }) expect(mockGetPendingListings).not.toHaveBeenCalled() expect(result.listings).toHaveLength(2) expect(result.listings[0].id).toBe(LISTING_ID) diff --git a/src/server/api/routers/listings/admin.ts b/src/server/api/routers/listings/admin.ts index 468c6cda9..cf89cb169 100644 --- a/src/server/api/routers/listings/admin.ts +++ b/src/server/api/routers/listings/admin.ts @@ -96,7 +96,10 @@ export const adminRouter = createTRPCRouter({ const riskProfiles = await computeReviewRiskProfiles(ctx.prisma, riskCandidates) const riskyListingIds = getRiskyReviewItemIds(riskCandidates, riskProfiles) const paginatedRiskyListingIds = riskyListingIds.slice(skip, skip + limit) - const pageListings = await repository.getPendingListingsByIds(paginatedRiskyListingIds) + const pageListings = await repository.getPendingListingsByIds(paginatedRiskyListingIds, { + emulatorIds, + search, + }) const pageListingMap = new Map(pageListings.map((listing) => [listing.id, listing])) const sortedPageListings = paginatedRiskyListingIds.flatMap((listingId) => { const listing = pageListingMap.get(listingId) diff --git a/src/server/api/routers/pcListings.test.ts b/src/server/api/routers/pcListings.test.ts index 83f548071..2355462ee 100644 --- a/src/server/api/routers/pcListings.test.ts +++ b/src/server/api/routers/pcListings.test.ts @@ -550,7 +550,14 @@ describe('pcListings trust integration', () => { expect(mockRepositoryGetPendingListingRiskCandidates).toHaveBeenCalledWith( expect.objectContaining({ canSeeBannedUsers: true }), ) - expect(mockRepositoryGetPendingListingsByIds).toHaveBeenCalledWith([LISTING_ID, LISTING_ID_B]) + expect(mockRepositoryGetPendingListingsByIds).toHaveBeenCalledWith( + [LISTING_ID, LISTING_ID_B], + { + emulatorIds: undefined, + search: undefined, + canSeeBannedUsers: true, + }, + ) expect(mockRepositoryGetPendingListings).not.toHaveBeenCalled() expect(result.pcListings).toHaveLength(2) expect(result.pcListings[0].id).toBe(LISTING_ID) diff --git a/src/server/api/routers/pcListings.ts b/src/server/api/routers/pcListings.ts index 953bfb46a..521ac9dea 100644 --- a/src/server/api/routers/pcListings.ts +++ b/src/server/api/routers/pcListings.ts @@ -475,7 +475,11 @@ export const pcListingsRouter = createTRPCRouter({ const riskProfiles = await computeReviewRiskProfiles(ctx.prisma, riskCandidates) const riskyPcListingIds = getRiskyReviewItemIds(riskCandidates, riskProfiles) const paginatedRiskyPcListingIds = riskyPcListingIds.slice((page - 1) * limit, page * limit) - const pagePcListings = await repository.getPendingListingsByIds(paginatedRiskyPcListingIds) + const pagePcListings = await repository.getPendingListingsByIds(paginatedRiskyPcListingIds, { + emulatorIds, + search, + canSeeBannedUsers: true, + }) const pagePcListingMap = new Map(pagePcListings.map((listing) => [listing.id, listing])) const sortedPagePcListings = paginatedRiskyPcListingIds.flatMap((pcListingId) => { const listing = pagePcListingMap.get(pcListingId) diff --git a/src/server/repositories/listings.repository.ts b/src/server/repositories/listings.repository.ts index f3200ee9b..4c68ab87d 100644 --- a/src/server/repositories/listings.repository.ts +++ b/src/server/repositories/listings.repository.ts @@ -514,11 +514,13 @@ export class ListingsRepository extends BaseRepository { }) } - async getPendingListingsByIds(listingIds: string[]) { + async getPendingListingsByIds(listingIds: string[], filters: PendingListingsFilters = {}) { if (listingIds.length === 0) return [] return this.prisma.listing.findMany({ - where: { id: { in: listingIds }, status: ApprovalStatus.PENDING }, + where: { + AND: [{ id: { in: listingIds } }, this.buildPendingListingsWhere(filters)], + }, include: this.getPendingListingInclude(), }) } diff --git a/src/server/repositories/pc-listings.repository.ts b/src/server/repositories/pc-listings.repository.ts index 73ecc5fb5..aab3a220a 100644 --- a/src/server/repositories/pc-listings.repository.ts +++ b/src/server/repositories/pc-listings.repository.ts @@ -52,6 +52,23 @@ export interface PcListingRiskCandidate { }[] } +export interface PendingPcListingsFilters { + emulatorIds?: string[] + search?: string + page?: number + limit?: number + sortField?: string + sortDirection?: 'asc' | 'desc' + canSeeBannedUsers?: boolean +} + +function getActiveUserBanWhere(): Prisma.UserBanWhereInput { + return { + isActive: true, + OR: [{ expiresAt: null }, { expiresAt: { gt: new Date() } }], + } +} + export class PcListingsRepository extends BaseRepository { // Static query shapes for this repository static readonly includes = { @@ -82,10 +99,7 @@ export class PcListingsRepository extends BaseRepository { author: { include: { userBans: { - where: { - isActive: true, - OR: [{ expiresAt: null }, { expiresAt: { gt: new Date() } }], - }, + where: getActiveUserBanWhere(), select: { id: true, reason: true, bannedAt: true, expiresAt: true }, }, }, @@ -772,17 +786,7 @@ export class PcListingsRepository extends BaseRepository { /** * Get pending PC listings with optional filtering */ - async getPendingListings( - filters: { - emulatorIds?: string[] - search?: string - page?: number - limit?: number - sortField?: string - sortDirection?: 'asc' | 'desc' - canSeeBannedUsers?: boolean - } = {}, - ): Promise<{ + async getPendingListings(filters: PendingPcListingsFilters = {}): Promise<{ pcListings: Prisma.PcListingGetPayload<{ include: typeof PcListingsRepository.includes.forList }>[] @@ -825,13 +829,9 @@ export class PcListingsRepository extends BaseRepository { } } - async getPendingListingRiskCandidates(filters: { - emulatorIds?: string[] - search?: string - sortField?: string - sortDirection?: 'asc' | 'desc' - canSeeBannedUsers?: boolean - }): Promise { + async getPendingListingRiskCandidates( + filters: PendingPcListingsFilters, + ): Promise { return this.prisma.pcListing.findMany({ where: this.buildPendingListingsWhere(filters), select: this.getPendingListingRiskCandidateSelect(), @@ -839,7 +839,10 @@ export class PcListingsRepository extends BaseRepository { }) } - async getPendingListingsByIds(pcListingIds: string[]): Promise< + async getPendingListingsByIds( + pcListingIds: string[], + filters: PendingPcListingsFilters = {}, + ): Promise< Prisma.PcListingGetPayload<{ include: typeof PcListingsRepository.includes.forList }>[] @@ -847,16 +850,14 @@ export class PcListingsRepository extends BaseRepository { if (pcListingIds.length === 0) return [] return this.prisma.pcListing.findMany({ - where: { id: { in: pcListingIds }, status: ApprovalStatus.PENDING }, + where: { + AND: [{ id: { in: pcListingIds } }, this.buildPendingListingsWhere(filters)], + }, include: PcListingsRepository.includes.forList, }) } - private buildPendingListingsWhere(filters: { - emulatorIds?: string[] - search?: string - canSeeBannedUsers?: boolean - }): Prisma.PcListingWhereInput { + private buildPendingListingsWhere(filters: PendingPcListingsFilters): Prisma.PcListingWhereInput { const { emulatorIds, search, canSeeBannedUsers = false } = filters const baseWhere: Prisma.PcListingWhereInput = { status: ApprovalStatus.PENDING, @@ -888,10 +889,7 @@ export class PcListingsRepository extends BaseRepository { author: { select: { userBans: { - where: { - isActive: true, - OR: [{ expiresAt: null }, { expiresAt: { gt: new Date() } }], - }, + where: getActiveUserBanWhere(), select: { reason: true }, }, }, From bc057630fa793731687ffdd2deecd35eda600485 Mon Sep 17 00:00:00 2001 From: Producdevity Date: Fri, 15 May 2026 01:58:59 +0200 Subject: [PATCH 07/17] fix: resolve review risk toggle import --- src/components/admin/ReviewRiskFilterButton.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/admin/ReviewRiskFilterButton.tsx b/src/components/admin/ReviewRiskFilterButton.tsx index a4af0eb71..85c7b86b2 100644 --- a/src/components/admin/ReviewRiskFilterButton.tsx +++ b/src/components/admin/ReviewRiskFilterButton.tsx @@ -1,5 +1,5 @@ import { ShieldAlert } from 'lucide-react' -import { ToggleButton } from '@/components/ui' +import { ToggleButton } from '@/components/ui/ToggleButton' interface Props { isActive: boolean From a02bd24c4b87cce0eb4e853e5003ad4a42036eb2 Mon Sep 17 00:00:00 2001 From: Producdevity Date: Fri, 15 May 2026 02:09:37 +0200 Subject: [PATCH 08/17] test: cover review risk pending paths --- src/server/api/routers/listings/admin.test.ts | 90 ++++++++++----- src/server/api/routers/listings/admin.ts | 11 +- src/server/api/routers/pcListings.test.ts | 103 +++++++++++++----- src/server/api/routers/pcListings.ts | 13 ++- 4 files changed, 158 insertions(+), 59 deletions(-) diff --git a/src/server/api/routers/listings/admin.test.ts b/src/server/api/routers/listings/admin.test.ts index a32803e35..527c50ae4 100644 --- a/src/server/api/routers/listings/admin.test.ts +++ b/src/server/api/routers/listings/admin.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, beforeEach, vi } from 'vitest' import { RISK_SIGNAL_TYPES } from '@/schemas/authorRisk' import { SUBMISSION_RISK_SIGNAL_TYPES } from '@/schemas/submissionRisk' import { Role } from '@orm' +import type * as AuthorRiskService from '@/server/services/author-risk.service' vi.unmock('@/server/api/trpc') @@ -16,32 +17,14 @@ const mockGetPendingListingRiskCandidates = vi.fn() const mockGetPendingListingsByIds = vi.fn() const mockGetPendingListings = vi.fn() -vi.mock('@/server/services/author-risk.service', () => ({ - computeAuthorRiskProfiles: (...args: unknown[]) => mockComputeAuthorRiskProfiles(...args), - createExistingAuthorBansMap: ( - listings: readonly { - authorId: string - author?: { userBans?: readonly { reason: string }[] } | null - }[], - ) => { - const existingBansMap = new Map() - - for (const listing of listings) { - if ( - listing.author?.userBans && - listing.author.userBans.length > 0 && - !existingBansMap.has(listing.authorId) - ) { - existingBansMap.set( - listing.authorId, - listing.author.userBans.map((ban) => ({ reason: ban.reason })), - ) - } - } +vi.mock('@/server/services/author-risk.service', async (importOriginal) => { + const actual = await importOriginal() - return existingBansMap - }, -})) + return { + computeAuthorRiskProfiles: (...args: unknown[]) => mockComputeAuthorRiskProfiles(...args), + createExistingAuthorBansMap: actual.createExistingAuthorBansMap, + } +}) vi.mock('@/server/services/submission-risk.service', () => ({ computeSubmissionRiskProfiles: (...args: unknown[]) => mockComputeSubmissionRiskProfiles(...args), @@ -218,4 +201,61 @@ describe('listing admin pending approvals', () => { expect(result.listings[1].authorRiskProfile.highestSeverity).toBe('low') expect(result.pagination.total).toBe(2) }) + + it('loads pending listings directly when risk filter is all', async () => { + const listing = { + id: LISTING_ID, + authorId: AUTHOR_ID, + author: { userBans: [] }, + customFieldValues: [], + } + mockGetPendingListings.mockResolvedValueOnce({ + listings: [listing], + pagination: { total: 1, pages: 1, page: 1, offset: 0, limit: 20 }, + }) + + const { caller } = createCaller() + + const result = await caller.getPending({ riskFilter: 'all', page: 1, limit: 20 }) + + expect(mockGetPendingListings).toHaveBeenCalledWith({ + emulatorIds: undefined, + search: undefined, + page: 1, + limit: 20, + sortField: undefined, + sortDirection: undefined, + }) + expect(mockGetPendingListingRiskCandidates).not.toHaveBeenCalled() + expect(mockGetPendingListingsByIds).not.toHaveBeenCalled() + expect(result.listings).toHaveLength(1) + expect(result.listings[0].id).toBe(LISTING_ID) + expect(result.pagination.total).toBe(1) + }) + + it('returns an empty page when no risk-only candidates are risky', async () => { + const cleanListing = { + id: LISTING_ID_C, + authorId: CLEAN_AUTHOR_ID, + author: { userBans: [] }, + customFieldValues: [], + } + mockGetPendingListingRiskCandidates.mockResolvedValueOnce([cleanListing]) + mockComputeAuthorRiskProfiles.mockResolvedValue( + new Map([ + [CLEAN_AUTHOR_ID, { authorId: CLEAN_AUTHOR_ID, signals: [], highestSeverity: null }], + ]), + ) + mockComputeSubmissionRiskProfiles.mockResolvedValue( + new Map([[LISTING_ID_C, { listingId: LISTING_ID_C, signals: [], highestSeverity: null }]]), + ) + + const { caller } = createCaller() + + const result = await caller.getPending({ riskFilter: 'risky', page: 1, limit: 20 }) + + expect(mockGetPendingListingsByIds).not.toHaveBeenCalled() + expect(result.listings).toHaveLength(0) + expect(result.pagination.total).toBe(0) + }) }) diff --git a/src/server/api/routers/listings/admin.ts b/src/server/api/routers/listings/admin.ts index cf89cb169..7fadf7b8c 100644 --- a/src/server/api/routers/listings/admin.ts +++ b/src/server/api/routers/listings/admin.ts @@ -96,10 +96,13 @@ export const adminRouter = createTRPCRouter({ const riskProfiles = await computeReviewRiskProfiles(ctx.prisma, riskCandidates) const riskyListingIds = getRiskyReviewItemIds(riskCandidates, riskProfiles) const paginatedRiskyListingIds = riskyListingIds.slice(skip, skip + limit) - const pageListings = await repository.getPendingListingsByIds(paginatedRiskyListingIds, { - emulatorIds, - search, - }) + const pageListings = + paginatedRiskyListingIds.length > 0 + ? await repository.getPendingListingsByIds(paginatedRiskyListingIds, { + emulatorIds, + search, + }) + : [] const pageListingMap = new Map(pageListings.map((listing) => [listing.id, listing])) const sortedPageListings = paginatedRiskyListingIds.flatMap((listingId) => { const listing = pageListingMap.get(listingId) diff --git a/src/server/api/routers/pcListings.test.ts b/src/server/api/routers/pcListings.test.ts index 2355462ee..5bc83301e 100644 --- a/src/server/api/routers/pcListings.test.ts +++ b/src/server/api/routers/pcListings.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, beforeEach, vi } from 'vitest' import { RISK_SIGNAL_TYPES } from '@/schemas/authorRisk' import { SUBMISSION_RISK_SIGNAL_TYPES } from '@/schemas/submissionRisk' import { ApprovalStatus, PcOs, Role, TrustAction } from '@orm' +import type * as AuthorRiskService from '@/server/services/author-risk.service' vi.unmock('@/server/api/trpc') vi.unmock('@/server/api/root') @@ -76,32 +77,14 @@ vi.mock('@/server/services/audit.service', () => ({ logAudit: vi.fn().mockResolvedValue(undefined), })) -vi.mock('@/server/services/author-risk.service', () => ({ - computeAuthorRiskProfiles: (...args: unknown[]) => mockComputeAuthorRiskProfiles(...args), - createExistingAuthorBansMap: ( - listings: readonly { - authorId: string - author?: { userBans?: readonly { reason: string }[] } | null - }[], - ) => { - const existingBansMap = new Map() - - for (const listing of listings) { - if ( - listing.author?.userBans && - listing.author.userBans.length > 0 && - !existingBansMap.has(listing.authorId) - ) { - existingBansMap.set( - listing.authorId, - listing.author.userBans.map((ban) => ({ reason: ban.reason })), - ) - } - } +vi.mock('@/server/services/author-risk.service', async (importOriginal) => { + const actual = await importOriginal() - return existingBansMap - }, -})) + return { + computeAuthorRiskProfiles: (...args: unknown[]) => mockComputeAuthorRiskProfiles(...args), + createExistingAuthorBansMap: actual.createExistingAuthorBansMap, + } +}) vi.mock('@/server/services/submission-risk.service', () => ({ computeSubmissionRiskProfiles: (...args: unknown[]) => mockComputeSubmissionRiskProfiles(...args), @@ -474,6 +457,51 @@ describe('pcListings trust integration', () => { }) describe('pending', () => { + it('loads pending PC listings directly when risk filter is all', async () => { + const listing = { + id: LISTING_ID, + authorId: AUTHOR_ID, + author: { id: AUTHOR_ID, name: 'Pending Author', userBans: [] }, + } + mockRepositoryGetPendingListings.mockResolvedValueOnce({ + pcListings: [listing], + pagination: { total: 1, pages: 1, page: 1, offset: 0, limit: 20 }, + }) + + const { caller } = createCaller({ userId: ADMIN_ID, role: Role.MODERATOR }) + + const result = await caller.pending({ riskFilter: 'all', page: 1, limit: 20 }) + + expect(mockRepositoryGetPendingListings).toHaveBeenCalledWith({ + emulatorIds: undefined, + search: undefined, + page: 1, + limit: 20, + sortField: undefined, + sortDirection: 'asc', + canSeeBannedUsers: true, + }) + expect(mockRepositoryGetPendingListingRiskCandidates).not.toHaveBeenCalled() + expect(mockRepositoryGetPendingListingsByIds).not.toHaveBeenCalled() + expect(result.pcListings).toHaveLength(1) + expect(result.pcListings[0].id).toBe(LISTING_ID) + expect(result.pagination.total).toBe(1) + }) + + it('uses the direct pending path when risk filter is omitted', async () => { + const { caller } = createCaller({ userId: ADMIN_ID, role: Role.MODERATOR }) + + const result = await caller.pending({ page: 1, limit: 20 }) + + expect(mockRepositoryGetPendingListings).toHaveBeenCalledWith( + expect.objectContaining({ canSeeBannedUsers: true }), + ) + expect(mockRepositoryGetPendingListingRiskCandidates).not.toHaveBeenCalled() + expect(mockRepositoryGetPendingListingsByIds).not.toHaveBeenCalled() + expect(result.pcListings).toHaveLength(0) + expect(result.pagination.total).toBe(0) + }) + it('filters to review-risk PC listings when riskFilter is risky', async () => { const submissionRiskListing = { id: LISTING_ID, @@ -566,6 +594,31 @@ describe('pcListings trust integration', () => { expect(result.pcListings[1].authorRiskProfile.highestSeverity).toBe('low') expect(result.pagination.total).toBe(2) }) + + it('returns an empty page when no risk-only PC listing candidates are risky', async () => { + const cleanListing = { + id: LISTING_ID_C, + authorId: CLEAN_AUTHOR_ID, + author: { id: CLEAN_AUTHOR_ID, name: 'Clean Author', userBans: [] }, + } + mockRepositoryGetPendingListingRiskCandidates.mockResolvedValueOnce([cleanListing]) + mockComputeAuthorRiskProfiles.mockResolvedValue( + new Map([ + [CLEAN_AUTHOR_ID, { authorId: CLEAN_AUTHOR_ID, signals: [], highestSeverity: null }], + ]), + ) + mockComputeSubmissionRiskProfiles.mockResolvedValue( + new Map([[LISTING_ID_C, { listingId: LISTING_ID_C, signals: [], highestSeverity: null }]]), + ) + + const { caller } = createCaller({ userId: ADMIN_ID, role: Role.MODERATOR }) + + const result = await caller.pending({ riskFilter: 'risky', page: 1, limit: 20 }) + + expect(mockRepositoryGetPendingListingsByIds).not.toHaveBeenCalled() + expect(result.pcListings).toHaveLength(0) + expect(result.pagination.total).toBe(0) + }) }) describe('approve', () => { diff --git a/src/server/api/routers/pcListings.ts b/src/server/api/routers/pcListings.ts index 521ac9dea..b5fc33bac 100644 --- a/src/server/api/routers/pcListings.ts +++ b/src/server/api/routers/pcListings.ts @@ -475,11 +475,14 @@ export const pcListingsRouter = createTRPCRouter({ const riskProfiles = await computeReviewRiskProfiles(ctx.prisma, riskCandidates) const riskyPcListingIds = getRiskyReviewItemIds(riskCandidates, riskProfiles) const paginatedRiskyPcListingIds = riskyPcListingIds.slice((page - 1) * limit, page * limit) - const pagePcListings = await repository.getPendingListingsByIds(paginatedRiskyPcListingIds, { - emulatorIds, - search, - canSeeBannedUsers: true, - }) + const pagePcListings = + paginatedRiskyPcListingIds.length > 0 + ? await repository.getPendingListingsByIds(paginatedRiskyPcListingIds, { + emulatorIds, + search, + canSeeBannedUsers: true, + }) + : [] const pagePcListingMap = new Map(pagePcListings.map((listing) => [listing.id, listing])) const sortedPagePcListings = paginatedRiskyPcListingIds.flatMap((pcListingId) => { const listing = pagePcListingMap.get(pcListingId) From df63733fa54f457655f4826ee4adcd51456b969e Mon Sep 17 00:00:00 2001 From: Producdevity Date: Fri, 15 May 2026 11:03:13 +0200 Subject: [PATCH 09/17] fix: allow localhost origins during ci e2e --- src/lib/cors.test.ts | 18 ++++++++++++++++-- src/lib/cors.ts | 34 ++++++++++++++++++++-------------- 2 files changed, 36 insertions(+), 16 deletions(-) diff --git a/src/lib/cors.test.ts b/src/lib/cors.test.ts index 879831674..d96bcf00f 100644 --- a/src/lib/cors.test.ts +++ b/src/lib/cors.test.ts @@ -1,5 +1,5 @@ -import { describe, expect, it } from 'vitest' -import { getOriginFromUrl, isAllowedRequestOrigin } from './cors' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { getAllowedOrigins, getOriginFromUrl, isAllowedRequestOrigin } from './cors' const allowedOrigins = ['https://emuready.com', 'capacitor://localhost'] @@ -13,6 +13,20 @@ describe('getOriginFromUrl', () => { }) }) +describe('getAllowedOrigins', () => { + afterEach(() => { + vi.unstubAllEnvs() + }) + + it('allows localhost during CI production-mode E2E runs', () => { + vi.stubEnv('CI', 'true') + vi.stubEnv('NEXT_PUBLIC_ALLOWED_ORIGINS', '') + vi.stubEnv('ALLOWED_ORIGINS', '') + + expect(getAllowedOrigins()).toEqual(expect.arrayContaining(['http://localhost:3000'])) + }) +}) + describe('isAllowedRequestOrigin', () => { it('allows configured origins', () => { expect( diff --git a/src/lib/cors.ts b/src/lib/cors.ts index a27b09ee3..ea3416730 100644 --- a/src/lib/cors.ts +++ b/src/lib/cors.ts @@ -21,6 +21,18 @@ const PARTNER_ORIGINS = [ 'https://steamuready.com', ] +const LOCAL_TEST_ORIGINS = [ + 'http://localhost:3000', + 'http://localhost:3001', + 'http://127.0.0.1:3000', +] + +function addMissingOrigins(origins: string[], additionalOrigins: string[]) { + for (const origin of additionalOrigins) { + if (!origins.includes(origin)) origins.push(origin) + } +} + /** * Get allowed CORS origins from environment variables * This is the single source of truth for allowed origins @@ -35,33 +47,27 @@ export function getAllowedOrigins(): string[] { origins = envOrigins.split(',').map((origin) => origin.trim()) } else if (process.env.NODE_ENV === 'development') { // Default to localhost for development - origins = ['http://localhost:3000', 'http://localhost:3001', 'http://127.0.0.1:3000'] + origins = [...LOCAL_TEST_ORIGINS] } else { // In production without env vars, use hardcoded production origins origins = [...PRODUCTION_ORIGINS] } + if (process.env.CI === 'true') { + addMissingOrigins(origins, LOCAL_TEST_ORIGINS) + } + // Always include production origins if we're in production if (process.env.NODE_ENV === 'production') { - for (const prodOrigin of PRODUCTION_ORIGINS) { - if (!origins.includes(prodOrigin)) { - origins.push(prodOrigin) - } - } + addMissingOrigins(origins, PRODUCTION_ORIGINS) } // Always include partner origins - for (const partnerOrigin of PARTNER_ORIGINS) { - if (!origins.includes(partnerOrigin)) { - origins.push(partnerOrigin) - } - } + addMissingOrigins(origins, PARTNER_ORIGINS) // Always include mobile app origins const mobileOrigins = ['capacitor://localhost', 'ionic://localhost'] - for (const mobileOrigin of mobileOrigins) { - if (!origins.includes(mobileOrigin)) origins.push(mobileOrigin) - } + addMissingOrigins(origins, mobileOrigins) // Include app URL if set const appUrl = process.env.NEXT_PUBLIC_APP_URL From 7d8b7de77a0d6304d5f11af05d6a267c1c4b47e2 Mon Sep 17 00:00:00 2001 From: Producdevity Date: Fri, 15 May 2026 11:25:35 +0200 Subject: [PATCH 10/17] fix: scope ci localhost cors origins --- src/lib/cors.test.ts | 3 ++- src/lib/cors.ts | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/lib/cors.test.ts b/src/lib/cors.test.ts index d96bcf00f..fd09607de 100644 --- a/src/lib/cors.test.ts +++ b/src/lib/cors.test.ts @@ -18,8 +18,9 @@ describe('getAllowedOrigins', () => { vi.unstubAllEnvs() }) - it('allows localhost during CI production-mode E2E runs', () => { + it('allows localhost during CI E2E runs', () => { vi.stubEnv('CI', 'true') + vi.stubEnv('NODE_ENV', 'test') vi.stubEnv('NEXT_PUBLIC_ALLOWED_ORIGINS', '') vi.stubEnv('ALLOWED_ORIGINS', '') diff --git a/src/lib/cors.ts b/src/lib/cors.ts index ea3416730..bfd8c1412 100644 --- a/src/lib/cors.ts +++ b/src/lib/cors.ts @@ -53,7 +53,7 @@ export function getAllowedOrigins(): string[] { origins = [...PRODUCTION_ORIGINS] } - if (process.env.CI === 'true') { + if (process.env.CI === 'true' && process.env.NODE_ENV !== 'production') { addMissingOrigins(origins, LOCAL_TEST_ORIGINS) } From 33052d81642629320e129cce8cf52d59a74fb079 Mon Sep 17 00:00:00 2001 From: Producdevity Date: Fri, 15 May 2026 11:52:32 +0200 Subject: [PATCH 11/17] test: stabilize trust user details e2e --- tests/helpers/data-factory.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/helpers/data-factory.ts b/tests/helpers/data-factory.ts index 2d9f70f9a..1bb9f2c0d 100644 --- a/tests/helpers/data-factory.ts +++ b/tests/helpers/data-factory.ts @@ -600,7 +600,7 @@ export async function resetUserTrustScore(page: Page, targetUserEmail: string): const userRow = page.locator('table tbody tr').filter({ hasText: targetUserEmail }).first() await expect(userRow).toBeVisible() - await userRow.locator('button').first().click() + await userRow.getByRole('button', { name: 'View User Details' }).click() const dialog = page.locator('[role="dialog"]') await expect(dialog).toBeVisible() From c26b54d305fd7fc6bdc0a8d4ea1755e9fe38652b Mon Sep 17 00:00:00 2001 From: Producdevity Date: Fri, 15 May 2026 12:31:16 +0200 Subject: [PATCH 12/17] fix: align pc listing shadow-ban visibility --- src/server/api/routers/mobile/pcListings.ts | 7 +- src/server/api/routers/pcListings.test.ts | 22 +- src/server/api/routers/pcListings.ts | 3 - src/server/api/utils/pcListingHelpers.ts | 18 +- .../repositories/listings.repository.ts | 13 +- .../pc-listings.repository.test.ts | 115 ++++++++++ .../repositories/pc-listings.repository.ts | 206 ++++++++++-------- src/server/utils/query-builders.test.ts | 17 ++ src/server/utils/query-builders.ts | 16 +- 9 files changed, 284 insertions(+), 133 deletions(-) create mode 100644 src/server/repositories/pc-listings.repository.test.ts diff --git a/src/server/api/routers/mobile/pcListings.ts b/src/server/api/routers/mobile/pcListings.ts index afa8496a4..0233d28ff 100644 --- a/src/server/api/routers/mobile/pcListings.ts +++ b/src/server/api/routers/mobile/pcListings.ts @@ -106,7 +106,12 @@ export const mobilePcListingsRouter = createMobileTRPCRouter({ } // Apply banned user filtering - const where = buildPcListingWhere(baseWhere, canSeeBannedUsers) + const where = buildPcListingWhere( + baseWhere, + canSeeBannedUsers, + ctx.session?.user?.role, + ctx.session?.user?.id, + ) const [pcListings, total] = await Promise.all([ ctx.prisma.pcListing.findMany({ diff --git a/src/server/api/routers/pcListings.test.ts b/src/server/api/routers/pcListings.test.ts index 5bc83301e..ec10baf42 100644 --- a/src/server/api/routers/pcListings.test.ts +++ b/src/server/api/routers/pcListings.test.ts @@ -479,7 +479,6 @@ describe('pcListings trust integration', () => { limit: 20, sortField: undefined, sortDirection: 'asc', - canSeeBannedUsers: true, }) expect(mockRepositoryGetPendingListingRiskCandidates).not.toHaveBeenCalled() expect(mockRepositoryGetPendingListingsByIds).not.toHaveBeenCalled() @@ -493,9 +492,14 @@ describe('pcListings trust integration', () => { const result = await caller.pending({ page: 1, limit: 20 }) - expect(mockRepositoryGetPendingListings).toHaveBeenCalledWith( - expect.objectContaining({ canSeeBannedUsers: true }), - ) + expect(mockRepositoryGetPendingListings).toHaveBeenCalledWith({ + emulatorIds: undefined, + search: undefined, + page: 1, + limit: 20, + sortField: undefined, + sortDirection: 'asc', + }) expect(mockRepositoryGetPendingListingRiskCandidates).not.toHaveBeenCalled() expect(mockRepositoryGetPendingListingsByIds).not.toHaveBeenCalled() expect(result.pcListings).toHaveLength(0) @@ -575,15 +579,17 @@ describe('pcListings trust integration', () => { const result = await caller.pending({ riskFilter: 'risky', page: 1, limit: 20 }) - expect(mockRepositoryGetPendingListingRiskCandidates).toHaveBeenCalledWith( - expect.objectContaining({ canSeeBannedUsers: true }), - ) + expect(mockRepositoryGetPendingListingRiskCandidates).toHaveBeenCalledWith({ + emulatorIds: undefined, + search: undefined, + sortField: undefined, + sortDirection: 'asc', + }) expect(mockRepositoryGetPendingListingsByIds).toHaveBeenCalledWith( [LISTING_ID, LISTING_ID_B], { emulatorIds: undefined, search: undefined, - canSeeBannedUsers: true, }, ) expect(mockRepositoryGetPendingListings).not.toHaveBeenCalled() diff --git a/src/server/api/routers/pcListings.ts b/src/server/api/routers/pcListings.ts index b5fc33bac..e02ed2bc2 100644 --- a/src/server/api/routers/pcListings.ts +++ b/src/server/api/routers/pcListings.ts @@ -470,7 +470,6 @@ export const pcListingsRouter = createTRPCRouter({ search, sortField, sortDirection: sortDirection ?? 'asc', - canSeeBannedUsers: true, }) const riskProfiles = await computeReviewRiskProfiles(ctx.prisma, riskCandidates) const riskyPcListingIds = getRiskyReviewItemIds(riskCandidates, riskProfiles) @@ -480,7 +479,6 @@ export const pcListingsRouter = createTRPCRouter({ ? await repository.getPendingListingsByIds(paginatedRiskyPcListingIds, { emulatorIds, search, - canSeeBannedUsers: true, }) : [] const pagePcListingMap = new Map(pagePcListings.map((listing) => [listing.id, listing])) @@ -503,7 +501,6 @@ export const pcListingsRouter = createTRPCRouter({ limit, sortField, sortDirection: sortDirection ?? 'asc', - canSeeBannedUsers: true, }) const riskProfiles = await computeReviewRiskProfiles(ctx.prisma, result.pcListings) diff --git a/src/server/api/utils/pcListingHelpers.ts b/src/server/api/utils/pcListingHelpers.ts index ae868af38..24604f169 100644 --- a/src/server/api/utils/pcListingHelpers.ts +++ b/src/server/api/utils/pcListingHelpers.ts @@ -1,4 +1,5 @@ -import type { Prisma } from '@orm' +import { buildShadowBanFilter } from '@/server/utils/query-builders' +import type { Prisma, Role } from '@orm' /** * Common include for PC listings queries @@ -100,20 +101,13 @@ export function buildPcListingOrderBy( export function buildPcListingWhere( baseWhere: Prisma.PcListingWhereInput, canSeeBannedUsers: boolean = false, + userRole?: Role | null, + userId?: string | null, ): Prisma.PcListingWhereInput { const where = { ...baseWhere } - // Filter out listings from banned users (shadow ban) - if (!canSeeBannedUsers) { - where.author = { - userBans: { - none: { - isActive: true, - OR: [{ expiresAt: null }, { expiresAt: { gt: new Date() } }], - }, - }, - } - } + const shadowBanFilter = canSeeBannedUsers ? undefined : buildShadowBanFilter(userRole, userId) + if (shadowBanFilter) where.author = shadowBanFilter return where } diff --git a/src/server/repositories/listings.repository.ts b/src/server/repositories/listings.repository.ts index 4c68ab87d..8ff2e4039 100644 --- a/src/server/repositories/listings.repository.ts +++ b/src/server/repositories/listings.repository.ts @@ -415,24 +415,19 @@ export class ListingsRepository extends BaseRepository { /** * Get a single listing by ID with full details and optional access control. - * When canSeeBannedUsers is false, hides listings from banned authors and REJECTED listings. + * When canSeeBannedUsers is false, hides listings from banned authors except the requester, + * and hides REJECTED listings. */ async byIdWithAccess(id: string, userId?: string, canSeeBannedUsers: boolean = false) { + const shadowBanFilter = canSeeBannedUsers ? undefined : buildShadowBanFilter(null, userId) const where: Prisma.ListingWhereInput = { id, ...(canSeeBannedUsers ? {} : { - author: { - userBans: { - none: { - isActive: true, - OR: [{ expiresAt: null }, { expiresAt: { gt: new Date() } }], - }, - }, - }, NOT: { status: ApprovalStatus.REJECTED }, }), + ...(shadowBanFilter && { author: shadowBanFilter }), } const listing = await this.prisma.listing.findFirst({ diff --git a/src/server/repositories/pc-listings.repository.test.ts b/src/server/repositories/pc-listings.repository.test.ts new file mode 100644 index 000000000..4dacd256b --- /dev/null +++ b/src/server/repositories/pc-listings.repository.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from 'vitest' +import { ApprovalStatus, Prisma, Role } from '@orm' +import { buildPcListingListWhere, buildPendingPcListingsWhere } from './pc-listings.repository' + +const USER_ID = 'user-123' + +describe('PC listing repository query builders', () => { + describe('buildPcListingListWhere', () => { + it('includes approved and own pending PC listings for authenticated users', () => { + const where = buildPcListingListWhere({ + userId: USER_ID, + userRole: Role.USER, + myListings: true, + }) + + expect(where).toMatchObject({ + authorId: USER_ID, + OR: [ + { status: ApprovalStatus.APPROVED }, + { status: ApprovalStatus.PENDING, authorId: USER_ID }, + ], + author: { + OR: [ + { id: USER_ID }, + { + userBans: { + none: { + isActive: true, + OR: [{ expiresAt: null }, { expiresAt: { gt: expect.any(Date) } }], + }, + }, + }, + ], + }, + }) + }) + + it('limits requested pending PC listings to the requester for regular users', () => { + const where = buildPcListingListWhere({ + userId: USER_ID, + userRole: Role.USER, + approvalStatus: ApprovalStatus.PENDING, + }) + + expect(where).toMatchObject({ + status: ApprovalStatus.PENDING, + authorId: USER_ID, + }) + }) + + it('keeps search and authenticated visibility filters conjunctive', () => { + const where = buildPcListingListWhere({ + userId: USER_ID, + userRole: Role.USER, + searchTerm: 'zelda', + }) + + expect(where).toMatchObject({ + AND: [ + { + OR: expect.arrayContaining([ + { + game: { + title: { contains: 'zelda', mode: Prisma.QueryMode.insensitive }, + system: { key: { not: 'microsoft_windows' } }, + }, + }, + ]), + }, + { + OR: [ + { status: ApprovalStatus.APPROVED }, + { status: ApprovalStatus.PENDING, authorId: USER_ID }, + ], + }, + ], + }) + expect(where).not.toHaveProperty('OR') + }) + + it('does not apply shadow-ban author filtering for moderators', () => { + const where = buildPcListingListWhere({ + userId: USER_ID, + userRole: Role.MODERATOR, + approvalStatus: ApprovalStatus.PENDING, + canSeeBannedUsers: true, + }) + + expect(where).toMatchObject({ status: ApprovalStatus.PENDING }) + expect(where).not.toHaveProperty('author') + }) + }) + + describe('buildPendingPcListingsWhere', () => { + it('does not apply shadow-ban author filtering to the approval queue query', () => { + const where = buildPendingPcListingsWhere({ + emulatorIds: ['emulator-1'], + search: 'zelda', + }) + + expect(where).toMatchObject({ + status: ApprovalStatus.PENDING, + emulatorId: { in: ['emulator-1'] }, + OR: [ + { game: { title: { contains: 'zelda', mode: Prisma.QueryMode.insensitive } } }, + { cpu: { modelName: { contains: 'zelda', mode: Prisma.QueryMode.insensitive } } }, + { gpu: { modelName: { contains: 'zelda', mode: Prisma.QueryMode.insensitive } } }, + { emulator: { name: { contains: 'zelda', mode: Prisma.QueryMode.insensitive } } }, + { author: { name: { contains: 'zelda', mode: Prisma.QueryMode.insensitive } } }, + ], + }) + expect(where).not.toHaveProperty('author') + }) + }) +}) diff --git a/src/server/repositories/pc-listings.repository.ts b/src/server/repositories/pc-listings.repository.ts index aab3a220a..2de812e1e 100644 --- a/src/server/repositories/pc-listings.repository.ts +++ b/src/server/repositories/pc-listings.repository.ts @@ -14,7 +14,7 @@ import { roleIncludesRole } from '@/utils/permission-system' import { calculateWilsonScore } from '@/utils/wilson-score' import { Prisma, ApprovalStatus, type PcOs, Role } from '@orm' import { BaseRepository } from './base.repository' -import { buildShadowBanFilter } from '../utils/query-builders' +import { buildApprovalStatusFilter, buildShadowBanFilter } from '../utils/query-builders' export interface PcListingFilters { gameId?: string @@ -33,7 +33,7 @@ export interface PcListingFilters { userId?: string userRole?: Role showNsfw?: boolean - osFilter?: string[] + osFilter?: PcOs[] memoryMin?: number memoryMax?: number canSeeBannedUsers?: boolean @@ -59,7 +59,6 @@ export interface PendingPcListingsFilters { limit?: number sortField?: string sortDirection?: 'asc' | 'desc' - canSeeBannedUsers?: boolean } function getActiveUserBanWhere(): Prisma.UserBanWhereInput { @@ -69,6 +68,113 @@ function getActiveUserBanWhere(): Prisma.UserBanWhereInput { } } +function appendAndCondition( + where: Prisma.PcListingWhereInput, + condition: Prisma.PcListingWhereInput, +) { + where.AND = [...(Array.isArray(where.AND) ? where.AND : where.AND ? [where.AND] : []), condition] +} + +export function buildPcListingListWhere( + filters: PcListingFilters, + mode: Prisma.QueryMode = Prisma.QueryMode.insensitive, +): Prisma.PcListingWhereInput { + const { + gameId, + systemIds, + cpuIds, + gpuIds, + emulatorIds, + performanceIds, + searchTerm, + approvalStatus, + myListings, + userId, + userRole, + showNsfw, + osFilter, + memoryMin, + memoryMax, + canSeeBannedUsers = false, + } = filters + + const where: Prisma.PcListingWhereInput = { + ...(gameId ? { gameId } : {}), + ...(myListings && userId ? { authorId: userId } : {}), + game: { + system: { key: { not: 'microsoft_windows' } }, + ...(showNsfw === false ? { isErotic: false } : {}), + ...(systemIds?.length ? { systemId: { in: systemIds } } : {}), + }, + ...(cpuIds?.length ? { cpuId: { in: cpuIds } } : {}), + ...(gpuIds?.length ? { gpuId: { in: gpuIds } } : {}), + ...(emulatorIds?.length ? { emulatorId: { in: emulatorIds } } : {}), + ...(performanceIds?.length ? { performanceId: { in: performanceIds } } : {}), + ...(osFilter?.length ? { os: { in: osFilter } } : {}), + ...(memoryMin ? { memorySize: { gte: memoryMin } } : {}), + ...(memoryMax ? { memorySize: { lte: memoryMax } } : {}), + ...(searchTerm + ? { + OR: [ + { + game: { + title: { contains: searchTerm, mode }, + system: { key: { not: 'microsoft_windows' } }, + }, + }, + { cpu: { modelName: { contains: searchTerm, mode } } }, + { gpu: { modelName: { contains: searchTerm, mode } } }, + { emulator: { name: { contains: searchTerm, mode } } }, + { notes: { contains: searchTerm, mode } }, + ], + } + : {}), + } + + const statusFilter = buildApprovalStatusFilter(userRole, userId, approvalStatus, 'authorId') + if (statusFilter) { + if (Array.isArray(statusFilter)) { + if (where.OR) { + appendAndCondition(where, { OR: Array.isArray(where.OR) ? where.OR : [where.OR] }) + appendAndCondition(where, { OR: statusFilter }) + delete where.OR + } else { + where.OR = statusFilter + } + } else { + Object.assign(where, statusFilter) + } + } + + const shadowBanFilter = canSeeBannedUsers ? undefined : buildShadowBanFilter(userRole, userId) + if (shadowBanFilter) where.author = shadowBanFilter + + return where +} + +export function buildPendingPcListingsWhere( + filters: PendingPcListingsFilters, + mode: Prisma.QueryMode = Prisma.QueryMode.insensitive, +): Prisma.PcListingWhereInput { + const { emulatorIds, search } = filters + + return { + status: ApprovalStatus.PENDING, + ...(emulatorIds?.length ? { emulatorId: { in: emulatorIds } } : {}), + ...(search + ? { + OR: [ + { game: { title: { contains: search, mode } } }, + { cpu: { modelName: { contains: search, mode } } }, + { gpu: { modelName: { contains: search, mode } } }, + { emulator: { name: { contains: search, mode } } }, + { author: { name: { contains: search, mode } } }, + ], + } + : {}), + } +} + export class PcListingsRepository extends BaseRepository { // Static query shapes for this repository static readonly includes = { @@ -216,70 +322,9 @@ export class PcListingsRepository extends BaseRepository { limit: number } }> { - const { - gameId, - systemIds, - cpuIds, - gpuIds, - emulatorIds, - performanceIds, - searchTerm, - page = 1, - limit = PAGINATION.DEFAULT_LIMIT, - sortField, - sortDirection, - approvalStatus = ApprovalStatus.APPROVED, - myListings, - userId, - showNsfw, - osFilter, - memoryMin, - memoryMax, - canSeeBannedUsers = false, - } = filters + const { page = 1, limit = PAGINATION.DEFAULT_LIMIT, sortField, sortDirection } = filters - // Build base where clause - const baseWhere: Prisma.PcListingWhereInput = { - status: approvalStatus, - ...(gameId ? { gameId } : {}), - ...(myListings && userId ? { authorId: userId } : {}), - // Exclude Microsoft Windows games since PC listings are for emulation - game: { - system: { key: { not: 'microsoft_windows' } }, - ...(showNsfw === false ? { isErotic: false } : {}), - ...(systemIds?.length ? { systemId: { in: systemIds } } : {}), - }, - ...(cpuIds?.length ? { cpuId: { in: cpuIds } } : {}), - ...(gpuIds?.length ? { gpuId: { in: gpuIds } } : {}), - ...(emulatorIds?.length ? { emulatorId: { in: emulatorIds } } : {}), - ...(performanceIds?.length ? { performanceId: { in: performanceIds } } : {}), - ...(osFilter?.length ? { os: { in: osFilter as PcOs[] } } : {}), - ...(memoryMin ? { memorySize: { gte: memoryMin } } : {}), - ...(memoryMax ? { memorySize: { lte: memoryMax } } : {}), - ...(searchTerm - ? { - OR: [ - { - game: { - title: { contains: searchTerm, mode: this.mode }, - system: { key: { not: 'microsoft_windows' } }, - }, - }, - { cpu: { modelName: { contains: searchTerm, mode: this.mode } } }, - { gpu: { modelName: { contains: searchTerm, mode: this.mode } } }, - { emulator: { name: { contains: searchTerm, mode: this.mode } } }, - { notes: { contains: searchTerm, mode: this.mode } }, - ], - } - : {}), - } - - // Apply banned user filtering - const shadowBanFilter = canSeeBannedUsers ? undefined : buildShadowBanFilter(null) - const where = { - ...baseWhere, - ...(shadowBanFilter && { author: shadowBanFilter }), - } + const where = buildPcListingListWhere(filters, this.mode) // Build orderBy based on sort field const orderBy: Prisma.PcListingOrderByWithRelationInput[] = [] @@ -394,8 +439,7 @@ export class PcListingsRepository extends BaseRepository { }) | null > { - // Build where with banned user filtering - const shadowBanFilter = canSeeBannedUsers ? undefined : buildShadowBanFilter(null) + const shadowBanFilter = canSeeBannedUsers ? undefined : buildShadowBanFilter(null, userId) const where: Prisma.PcListingWhereInput = { id, ...(shadowBanFilter && { author: shadowBanFilter }), @@ -805,10 +849,9 @@ export class PcListingsRepository extends BaseRepository { limit = PAGINATION.DEFAULT_LIMIT, sortField, sortDirection = 'asc', - canSeeBannedUsers = false, } = filters - const where = this.buildPendingListingsWhere({ emulatorIds, search, canSeeBannedUsers }) + const where = this.buildPendingListingsWhere({ emulatorIds, search }) const actualOffset = calculateOffset({ page }, limit) const orderBy = this.buildOrderBy(sortField, sortDirection) @@ -858,28 +901,7 @@ export class PcListingsRepository extends BaseRepository { } private buildPendingListingsWhere(filters: PendingPcListingsFilters): Prisma.PcListingWhereInput { - const { emulatorIds, search, canSeeBannedUsers = false } = filters - const baseWhere: Prisma.PcListingWhereInput = { - status: ApprovalStatus.PENDING, - ...(emulatorIds?.length ? { emulatorId: { in: emulatorIds } } : {}), - ...(search - ? { - OR: [ - { game: { title: { contains: search, mode: this.mode } } }, - { cpu: { modelName: { contains: search, mode: this.mode } } }, - { gpu: { modelName: { contains: search, mode: this.mode } } }, - { emulator: { name: { contains: search, mode: this.mode } } }, - { author: { name: { contains: search, mode: this.mode } } }, - ], - } - : {}), - } - - const shadowBanFilter = canSeeBannedUsers ? undefined : buildShadowBanFilter(null) - return { - ...baseWhere, - ...(shadowBanFilter && { author: shadowBanFilter }), - } + return buildPendingPcListingsWhere(filters, this.mode) } private getPendingListingRiskCandidateSelect() { diff --git a/src/server/utils/query-builders.test.ts b/src/server/utils/query-builders.test.ts index 7532b3ecc..3c241f85c 100644 --- a/src/server/utils/query-builders.test.ts +++ b/src/server/utils/query-builders.test.ts @@ -32,6 +32,23 @@ describe('query-builders', () => { }) }) + it('should allow regular users to see their own content', () => { + const filter = buildShadowBanFilter(Role.USER, 'user123') + expect(filter).toEqual({ + OR: [ + { id: 'user123' }, + { + userBans: { + none: { + isActive: true, + OR: [{ expiresAt: null }, { expiresAt: { gt: expect.any(Date) } }], + }, + }, + }, + ], + }) + }) + it('should return shadow ban filter for unauthenticated users', () => { const filter = buildShadowBanFilter(null) expect(filter).toBeDefined() diff --git a/src/server/utils/query-builders.ts b/src/server/utils/query-builders.ts index 8b4f69d42..5116c82c0 100644 --- a/src/server/utils/query-builders.ts +++ b/src/server/utils/query-builders.ts @@ -1,21 +1,15 @@ import { hasRolePermission } from '@/utils/permissions' import { type Prisma, type PrismaClient, ApprovalStatus, Role } from '@orm' -/** - * Build where clause for shadow ban filtering - * Excludes content from banned users for non-moderators - */ export function buildShadowBanFilter( userRole?: Role | null, - _userId?: string | null, + userId?: string | null, ): Prisma.UserWhereInput | undefined { - // Moderators and above can see all content if (userRole && hasRolePermission(userRole, Role.MODERATOR)) { return undefined } - // Regular users don't see content from banned users - return { + const visibleAuthorFilter: Prisma.UserWhereInput = { userBans: { none: { isActive: true, @@ -23,6 +17,12 @@ export function buildShadowBanFilter( }, }, } + + if (!userId) return visibleAuthorFilter + + return { + OR: [{ id: userId }, visibleAuthorFilter], + } } /** From c0427402c0913592d2d743bfae96fa734b872f30 Mon Sep 17 00:00:00 2001 From: Producdevity Date: Fri, 15 May 2026 16:14:06 +0200 Subject: [PATCH 13/17] fix: simplify rejected listings message formatting --- src/server/services/author-risk.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/server/services/author-risk.service.ts b/src/server/services/author-risk.service.ts index 1cb625481..997d4d3ca 100644 --- a/src/server/services/author-risk.service.ts +++ b/src/server/services/author-risk.service.ts @@ -341,7 +341,7 @@ export async function computeAuthorRiskProfiles( RISK_SIGNAL_TYPES.PREVIOUSLY_REJECTED, 'low', 'Previously Rejected', - `${rejectedCount} rejected listing${rejectedCount > 1 ? 's' : ''}`, + `${rejectedCount} rejected listings`, ), ) } From 619373be3e9a34613be18b489382577494596b5f Mon Sep 17 00:00:00 2001 From: Producdevity Date: Fri, 15 May 2026 16:18:10 +0200 Subject: [PATCH 14/17] chore: remove outdated duplication audit documentation --- duplication-audit.md | 505 ------------------------------------------- 1 file changed, 505 deletions(-) delete mode 100644 duplication-audit.md diff --git a/duplication-audit.md b/duplication-audit.md deleted file mode 100644 index 19b824a60..000000000 --- a/duplication-audit.md +++ /dev/null @@ -1,505 +0,0 @@ -**Duplication Audit (Listings, PC Listings, and Related UI)** - -This document catalogs notable duplication across the codebase, with concrete file references and targeted abstraction proposals to improve maintainability, testability, and adherence to SOLID/DRY principles. Scope focuses on Listings, PC Listings, and adjacent UI/state patterns that repeat in multiple places. - ---- - -**Summary Hotspots** - -- Listings filter UI and logic are implemented in parallel variants: classic `/listings` and v2, plus `/pc-listings`. -- URL-driven filter state and query param assembly are recreated with similar patterns across pages. -- Repeated analytics tracking calls for filter changes. -- Repeated table headers, column visibility, sorting, and “actions” cells. -- Repeated mobile filter sheet overlay behavior. -- Option-mapping for MultiSelects repeated across systems, devices, emulators, SoCs, performance. - ---- - -**1) URL Filter State + Query Param Assembly** - -- Files: - - `src/app/listings/hooks/useListingsState.ts` - - `src/app/pc-listings/hooks/usePcListingsState.ts` - - `src/hooks/useUrlState.ts` (+ `useUrlSearch`) - - Pages assembling filter params: - - `src/app/listings/ListingsPage.tsx` (filterParams) - - `src/app/pc-listings/PcListingsPage.tsx` (filterParams) - - `src/app/v2/listings/V2ListingsPage.tsx` (filterParams) -- Duplication: - - Converging patterns to parse URL params, debounce search, and update URL via replace/push. - - Similar `filterNullAndEmpty` usage with per-page manual assembly. -- Proposal: - - Abstraction: `useFilterParams()` + page-specific mappers. - - Responsibilities: centralize building the tRPC input for listings/pc-listings based on URL state, with pluggable mappers for page-specific fields. - - Inputs: page key (e.g., 'listings' | 'pcListings'), current URL-state hook values. - - Output: stable `filterParams` object with memoization. - - Abstraction: `useUrlFilters` presets - - Provide preconfigured helpers: `useListingsUrlFilters()`, `usePcListingsUrlFilters()` returning common setters + debounced search. - - Wins: reduces three near-identical code paths and ensures consistent debounce/reset-page behavior. - ---- - -**3) Filter Panels and Option Mapping** - -- Files: - - Classic: `src/app/listings/components/ListingFilters.tsx` - - V2: `src/app/v2/listings/components/ListingFilters.tsx`, `SearchBar.tsx` - - PC: `src/app/pc-listings/components/PcListingsFilters.tsx` -- Duplication: - - The same filter categories (systems, performance, devices, emulators, SoCs) appear across classic and v2, implemented with different layouts but identical value plumbing. - - Option mapping (e.g., devices => `{ id, name }`) repeated in several components. -- Proposal: - - Abstraction: `FilterCategory` primitives and mappers - - `mapDeviceOptions`, `mapSocOptions`, `mapPerformanceOptions`, `mapSystemOptions`, `mapEmulatorOptions` in a shared `filters/options.ts`. - - `FilterSection` component primitives (label, icon, MultiSelect wiring) that can be composed into classic or v2 layouts. - - Abstraction: unify search input to a shared `ListingsSearchInput` (or reuse v2 `SearchBar` across pages) with consistent analytics and debounce. - - Wins: reduce per-page boilerplate; consistent UX and analytics across pages. - -**5) Table Structure, Headers, and Sorting** - -- Files: - - `src/app/listings/ListingsPage.tsx` - - `src/app/pc-listings/PcListingsPage.tsx` -- Duplication: - - Nearly identical table scaffolding: SortableHeader usage per column, an “Actions” column, and row click handlers. - - Repeated badges and tooltip patterns for status/verification. -- Proposal: - - Abstraction: `ListingsTable` primitive - - Props: `columns`, `rows`, `renderers` per column, `onRowClick`, `actionsRenderer`, `sortState`/`onSort`. - - Optionally integrate `ColumnVisibilityControl` directly. - - Wins: consolidates duplicated table chrome and sorting wiring; isolates per-column rendering differences. - ---- - -**6) Header Toolbars (My Listings, Add, Toggles, Column Visibility)** - -- Files: - - `src/app/listings/ListingsPage.tsx` (desktop header + mobile header variants) - - `src/app/pc-listings/PcListingsPage.tsx` (same pattern) -- Duplication: - - “My Listings” toggle button logic and labels. - - “Add Listing”/“Add PC Listing” actions. - - Display toggles (`DisplayToggleButton`) and `ColumnVisibilityControl` blocks. -- Proposal: - - Abstraction: `ListingsHeader` - - Props to toggle “my listings”, inject “add” link target/label, and add display/visibility controls. - - Wins: consistent layout; easier to adjust styling/behavior across both listings. - ---- - -**7) Analytics Filter Tracking** - -- Files: - - Classic filters: `src/app/listings/components/ListingFilters.tsx` - - v2 filters: `src/app/v2/listings/components/ListingFilters.tsx` - - v2 quick filters: `src/app/v2/listings/components/QuickFilters.tsx` - - URL-state hooks: `src/app/listings/hooks/useListingsState.ts`, `src/app/pc-listings/hooks/usePcListingsState.ts` -- Duplication: - - Repeated `analytics.filter.*` calls spread across components and hooks. -- Proposal: - - Abstraction: `filterAnalytics` adapter - - Provides typed helpers: `trackApply(filters)`, `trackClear()`, `trackSearch(term)`, `trackSort(field)`, etc. - - Centralizes name-resolution for options (avoid re-deriving labels in each component). - - Wins: consistent analytics and fewer edge-case divergences. - ---- - -**8) MultiSelect Configuration and Behavior** - -- Files: - - Used in all filter components listed above; frequent repetition of `label`, `leftIcon`, `maxDisplayed`, placeholder strings. -- Duplication: - - Many MultiSelect instances differ only in `options` and `onChange` handlers. -- Proposal: - - Abstraction: `MultiSelectField` presets - - Factory/utility to render a MultiSelect with standard props for common entities (`DeviceSelect`, `SystemSelect`, etc.). - - Wins: consistent appearance and reduces repeated configuration wiring. - ---- - -**10) Pagination Wiring** - -- Files: - - `src/app/listings/ListingsPage.tsx` and `src/app/pc-listings/PcListingsPage.tsx` use `` with similar `onPageChange` (to URL state) flows. -- Duplication: - - Repeated logic to call `listingsState.setPage(newPage)` where page is managed within the same URL filter hook. -- Proposal: - - Abstraction: pair `Pagination` with a small adapter hook `useUrlPagination()` that exposes `{ page, setPage, totalPages, itemsPerPage }` for list pages. - - Wins: fewer places to remember to reset other filters when page changes; consistent push/replace behavior. - ---- - -**11) Row Rendering (Badges, Tooltips, Status, Verification)** - -- Files: - - Both listings tables render `ApprovalStatus.PENDING` with a clock + tooltip, show verification badges, and author ban badges. -- Duplication: - - Repeated row-cell conditionals and tooltip patterns. -- Proposal: - - Abstraction: `ListingRowMeta` component - - Responsible for rendering the meta badges/tooltip cluster consistently; receives a listing-like shape. - - Wins: consistent semantics and styling; easier maintenance. - ---- - -**Implementation Sketch (Non‑code)** - -- New shared modules (suggested locations): - - `src/app/listings/shared/hooks/useFilterParams.ts` - - `src/app/listings/shared/components/ListingsTable.tsx` - - `src/app/listings/shared/components/ListingsHeader.tsx` - - `src/lib/analytics/filterAnalytics.ts` (adapter) - -Each abstraction is small and composable (SOLID): single-responsibility primitives that pages compose, keeping page components thin and declarative. - ---- - -**Expected Benefits** - -- Reduced churn: styling or behavior changes apply in one place. -- Lower cognitive load: fewer bespoke implementations of the same patterns. -- Easier testability: focus tests on shared primitives and page-specific glue. -- More consistent UX across classic, v2, and PC listings pages. - ---- - -**Next Steps (Suggested Order)** - -1. Extract `ListingsTable` and `ListingsHeader`; refactor both listings pages. -2. Centralize analytics calls via `filterAnalytics` adapter (align v2 components). -3. Optional: create `useFilterParams` and `useUrlPagination` adapters to reduce per-page param assembly. - ---- - -## Implementation TODOs (Per Item) - -Below are concrete, low-risk TODOs for items we plan to implement, with acceptance checks and rollout notes. Items already completed are noted as such. - -### 3) Filter Panels and Option Mapping — Status: PARTIALLY COMPLETED - -- Done: - - Shared mappers in `src/utils/options.ts` and adopted in classic Listings, PC Listings, and v2 page (systems/devices/emulators/SoCs). - - V2 performance kept custom label-with-description mapping intentionally. -- TODO: - - Consider a `performanceOptionsWithDesc` if we want a shared variant for v2, then swap in v2 only. - - Acceptance: Display strings for all filters unchanged; no runtime type errors. - -### 3a) FilterField primitive (new) — Status: PENDING - -- Goal: Wrap label + icon + `MultiSelect` to remove repeated markup in classic + PC filters (not v2). -- Steps: - - Create `src/app/listings/shared/components/FilterField.tsx` with props: `label`, `icon`, `value`, `onChange(values)`, `options`, `placeholder`, `maxDisplayed`, `leftIcon?`. - - Replace Systems block in classic Listings, then in PC Listings. - - Replace Devices/Emulators/SoCs in both pages in small PRs. -- Acceptance: - - Visual parity; unchanged analytics calls; identical option counts and selections. -- Rollout: Systems first, then others; verify on mobile and desktop. - -### 1) URL Filter State + Param Assembly — `useFilterParams` — Status: PENDING - -- Goal: Centralize building tRPC filter input from URL/state for classic and PC listings. -- Steps: - - Create `useFilterParams(pageKey, state, overrides?)` returning memoized `{ filterParams }`. - - Provide mappers for classic listings and pc-listings (page-specific fields like limits, sort defaults). - - Swap into one page (classic) and validate; then adopt in PC. -- Acceptance: - - No shape changes in API requests; identical query behavior (diff check on inputs). -- Rollout: Behind a local flag in code or in small PRs per page. - -### 11) Pagination Wiring — `useUrlPagination` — Status: PENDING - -- Goal: Standardize pagination wiring to URL and list state. -- Steps: - - Create `useUrlPagination()` exposing `{ page, setPage, limit, totalPages? }` backed by existing URL sync. - - Replace ad-hoc `setPage` calls in classic + PC after validating the behavior. -- Acceptance: - - Page changes persist via URL; refresh and back/forward keep the same page. - -### 6) Centralized Analytics Adapter — `filterAnalytics` — Status: PENDING - -- Goal: Normalize analytics calls for filter interactions to avoid drift. -- Steps: - - Add `src/lib/analytics/filterAnalytics.ts` translating normalized calls to existing `analytics.filter.*`. - - Replace calls in classic + PC filters first; keep payloads identical. -- Acceptance: - - Analytics dashboards show no breaks or duplicates; event names unchanged. - -### 4) Mobile Filter Sheet Primitive — Status: PENDING - -- Goal: Extract bottom-sheet overlay used by mobile filter UIs into `MobileFilterSheet`. -- Steps: - - Create `src/app/listings/shared/components/MobileFilterSheet.tsx` (props: `isOpen`, `onClose`, `title`, `children`). - - Adopt in one page (classic) and verify; then apply to PC. -- Acceptance: - - Identical open/close behavior, animations, and focus interactions on mobile. -- Risk: Low–medium (UI/animation). Roll out gradually. - -### 12) Row Rendering Meta — `ListingRowMeta` — Status: PENDING - -- Goal: Unify status/verification/ban badges and tooltips across tables. -- Steps: - - Create `ListingRowMeta` with props for status, verification, and author ban flags. - - Replace row fragments in classic + PC listings tables. -- Acceptance: - - Visual parity and identical tooltip content; no regressions in a11y. - -### 5) Table Structure, Headers, and Sorting — `ListingsTable` — Status: PENDING - -- Goal: Extract table scaffolding and header sorting. -- Steps: - - Create `ListingsTable` and `ListingsHeader` primitives. - - Migrate classic listings first; keep existing `SortableHeader` wiring. -- Acceptance: - - Sorting works identically; column visibility unaffected. -- Risk: Medium–high (table interactions). Do last after smaller wins. - -### 14) Truncated Text Primitive — Status: PENDING - -- Goal: Replace ad-hoc title truncation + tooltip logic. -- Steps: - - Create `TruncatedText` (props: `text`, `max`, `tooltipSide?`, `href?`). - - Adopt in listings tables where long titles are truncated. -- Acceptance: - - Same truncation width and tooltip behavior. - -### 15) Error State Component — Status: PENDING - -- Goal: Replace repeated “Failed to load …” blocks with `ErrorBanner`. -- Steps: - - Create `ErrorBanner` (props: `title`, `error`, `onRetry?`). - - Adopt in listings and PC listings first; expand later. -- Acceptance: - - Copy and retry actions preserved; visuals consistent. - -### 13) Server-side Query Builders — Status: PENDING (High Risk) - -- Goal: Unify where-building for approvals/NSFW/search ORs across repositories. -- Steps: - - Add utilities: `buildSearchWhere`, `buildApprovalWhere`, `buildArrayWhere`, `composeWhere` under `src/server/repositories/utils/`. - - Add focused tests that lock current behavior (approvals, NSFW, shadow-ban, myListings). - - Migrate listings repository only; validate outputs; then roll out to others. -- Acceptance: - - Identical query results on representative datasets; no behavior regressions. -- Risk: High. Gate behind tests and migrate incrementally. - -This sequencing minimizes risk (start with hooks and leaf components) and yields early DRY wins without large cross-cutting refactors. - ---- - -**Additional Cases (Extended Coverage)** - -13. Server-side Filter Builders and Search OR Conditions - -- Files: - - `src/server/repositories/listings.repository.ts` (search across game.title, notes, device brand/model, emulator; device/SoC OR logic; approval/nsfw/shadow-ban filters) - - `src/server/repositories/pc-listings.repository.ts` (similar where-building, approval, myListings, success-rate sorting, excludes Windows) - - `src/server/repositories/games.repository.ts` (buildWhereClause for games; similar patterns) -- Duplication: - - Rebuilding where clauses with recurring patterns: approval status handling, user-context (myListings), NSFW, search ORs, ID array filters. - - Repeating large `include` maps for “forList”, “default”, etc., with similar shapes across repos. -- Proposal: - - Abstraction: query-builder utilities per concern - - `buildSearchWhere(filters, fields)` to produce OR clusters (shared across repos). - - `buildApprovalWhere(userRole, userId, approvalStatus, authorField)` unified. - - `buildArrayWhere(ids, field)` returns `{ [field]: { in: ids } } | undefined` (already partly exists) extended for PC entities. - - `composeWhere(...clauses)` to merge AND/OR safely and predictably. - - Extract shared `includes` presets for list/detail views where feasible to avoid drift. - -14. Truncated Titles + Tooltip Pattern - -- Files: - - `src/app/listings/ListingsPage.tsx` and `src/app/pc-listings/PcListingsPage.tsx` render `{title.substring(0, 30)}` with a Tooltip for full title. - - Admin games lists repeat the same pattern. -- Proposal: - - Abstraction: `TruncatedLink`/`TruncatedText` component with props: `text`, `max`, `href?`, `tooltipSide='top'`. - - Ensures consistent truncation rules, ellipsis, and tooltip behavior. - -15. “Failed to load …” Error UI - -- Files (examples): - - Listings/PC Listings pages; several admin pages; profile selectors. -- Duplication: - - Repeated red text blocks or light wrappers for “Failed to load X”. -- Proposal: - - Abstraction: `ErrorState`/`ErrorBanner` component taking `title`, `error`, and optional retry callback. - - Standardize getErrorMessage usage and styling. - -16. Pagination Wiring Across Many Pages - -- Files: - - Numerous admin pages and listings pages invoke `` similarly, with `onPageChange` leading back to a URL/hook update. -- Proposal: - - Abstraction: `useUrlPagination()` hook returning `{ page, setPage }` and a light `PaginationBar` that binds handlers, reducing per-page glue and aligning push/replace semantics. - -17. Active Filters Count + Badges - -- Files: - - Count chips appear in classic filters, v2 filters, and mobile FABs in both listings and pc-listings. -- Proposal: - - Abstraction: `useActiveFilterCount(filters)` and a `FilterCountBadge` component. - - Eliminates counting logic duplication, normalizes what “active” means across pages. - -Status: Partially Implemented - -- Collapsed sidebar badges unified via `src/app/listings/shared/components/CollapsedBadges.tsx`. -- Adopted in: `ListingsFiltersSidebar` and `PcFiltersSidebar`. -- Mobile FAB badge count remains page-specific and is not changed. - - Note: The FAB is shared; each page computes its count to preserve semantics (PC includes memory). - -18. “My Listings” Toggle - -- Files: - - Classic listings, PC listings, and v2 quick filters each implement a “My Listings” toggle. -- Proposal: - - Abstraction: `MyListingsToggle` component + unified state plumbing via hook (`useListingsOwnershipFilter`) that hides the user-check details and analytics. - -19. Display Toggles (Icons vs Names, Logos vs Names) - -- Files: - - Classic/PC listings headers, admin approvals/games. -- Duplication: - - Repeated `DisplayToggleButton` usage patterns with near-identical wiring and labels. -- Proposal: - - Abstraction: `DisplayToggles` group component that receives an array of toggles with keys and labels, stores preferences in localStorage consistently, and exposes a single onChange callback. - -20. Magic Numbers and Limit Constants - -- Files: - - Devices/SoCs fetched with `limit: 10000`; CPUs/GPUs with `limit: 1000`; per-page limits vary across pages (10 vs 15). -- Proposal: - - Centralize in `src/data/constants.ts` (e.g., `OPTION_FETCH_LIMITS`), or move to Async data-selectors with server-side search + pagination to drop these limits. - -21. Performance Scales Fetch + Mapping - -- Files: - - Classic/v2 listings and PC listings fetch `performanceScales` and map to options. -- Proposal: - - Abstraction: `usePerformanceScales()` hook returning memoized option lists and a map by id/rank, reducing duplicated mapping. - -23. Performance Scales Fetch + Mapping - -- Files: - - Classic/v2 listings and PC listings fetch `performanceScales` and map to options. -- Proposal: - - Abstraction: `usePerformanceScales()` hook returning memoized option lists and a map by id/rank, reducing duplicated mapping. - -24. Sort Handling Logic - -- Files: - - `useListingsState.ts` and PC state hook implement `handleSort` with similar tri-state logic. -- Proposal: - - Abstraction: `useTriStateSort()` returning `{ sortField, sortDirection, handleSort }`, parameterized by default field/direction; share across pages. - -25. URL Param Keys as Constants - -- Files: - - Inline strings like `'systemIds'`, `'deviceIds'`, `'search'`, `'page'`, etc., repeated across pages and hooks. -- Proposal: - - Abstraction: `URL_PARAMS` constants module to avoid typos and keep naming consistent. - -26. Empty State Components - -- Files: - - Classic uses `NoListingsFound`; v2 uses `EmptyState` with CTA to clear filters. -- Proposal: - - Abstraction: a unified `EmptyState` that supports both “no data” and “no results with active filters” and customizable CTAs. - -27. Derived Labels for Options - -- Files: - - Building device label as `brand + modelName`; SoC as `manufacturer + name`; repeated across multiple components. -- Proposal: - - Abstraction: `formatDeviceName(device)`, `formatSocName(soc)`, `formatCpuName(cpu)`, etc., in `src/utils/formatters.ts` used by option mappers and tables. - -28. Row Click Navigation + Stop Propagation for Action Cells - -- Files: - - Both listings pages attach `onClick` at row-level and stop propagation in actions. -- Proposal: - - Abstraction: Table row wrapper `ClickableRow` and an `ActionsCell` helper to standardize propagation and accessibility (role/button mapping). - -29. Error/Loading State Strategy - -- Files: - - Pages vary in handling `isPending`, showing spinners, and conditionally rendering content. -- Proposal: - - Abstraction: `DataState` wrapper component that takes `{isLoading, error, hasData}` and slots for `loading`, `error`, `empty`, `content`. - -30. Time Constants and Query Caching Policies - -- Files: - - Mixed use of `ms` utility vs raw numbers for `staleTime`/`gcTime` across components. -- Proposal: - - Abstraction: `queryCachePolicies` constants and a small helper `useCachePolicy('short'|'medium'|'long')` to standardize cache durations. - -31. Verified Developer and Verification Badges - -- Files: - - Badges appear in list rows and details views with similar conditions and tooltip usage. -- Proposal: - - Abstraction: `DeveloperVerificationCluster` that renders the right badges and tooltips based on a standard listing shape. - -32. Reusable Headers Across Admin Tables - -- Files: - - Many admin tables repeat header toolbars with search, add buttons, column visibility, and display toggles. -- Proposal: - - Abstraction: `AdminTableHeader` combined with `useAdminTable` to reduce boilerplate in each admin page while preserving flexibility via render props. - -33. Tooltip Side/Styling Consistency - -- Files: - - Tooltips frequently use `side="top"` with similar styles. -- Proposal: - - Abstraction: Tooltip preset wrapper exporting `TopTooltip`, `RightTooltip`, etc., or a `withTooltip` helper for common patterns. - -34. Row Styling and Hover Patterns - -- Files: - - Repeated classes: `hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer transition-colors`. -- Proposal: - - Abstraction: table row style constants or a `TableRow` component to ensure consistent hover states and accessibility roles. - -35. Limits and Page Size Defaults (UX Consistency) - -- Files: - - Listings use limit 10; v2 uses 15; admin varies. -- Proposal: - - Establish a default page-size policy per viewport (desktop/mobile) and centralize in constants to drive consistent UX and predictable pagination. - -36. Clear All Filters CTA and Messaging - -- Files: - - Implemented multiple times with similar analytics calls. -- Proposal: - - Abstraction: `ClearFiltersButton` that clears via central hook and tracks analytics in one place. - -37. Preferences Banners (Devices/SoCs) - -- Files: - - Classic listings shows banners for active/available preference filtering with similar text and CTAs. -- Proposal: - - Abstraction: `PreferencesBanner` component that adapts copy for Devices vs SoCs and exposes `onEnable`/`onDisable` hooks. - -38. Search Bars Across Domains (Games, Listings, Admin) - -- Files: - - Games search, listings search, admin tables (via `useAdminTable`) all have search inputs with debounce. -- Proposal: - - Abstraction: `SearchInput` preset that integrates with `useUrlSearch` or `useDebouncedValue` based on context, with consistent a11y and analytics. - -39. Success Rate Bar Usage - -- Files: - - Listings and PC listings use `SuccessRateBar` similarly; admin pages may also reuse. -- Proposal: - - Provide a compact variant preset (``) and consolidated usage guidelines to avoid inline tweaks. - -40. Repeated “Add” Buttons with Similar Styling - -- Files: - - “Add Listing”, “Add PC Listing”, “Add” in mobile headers. -- Proposal: - - Abstraction: `AddEntityButton` that receives target URL and label; integrates with routing and analytics. - -These additional items broaden the DRY opportunities and can be tackled incrementally. Each suggested abstraction preserves single responsibility and composes cleanly, enabling gradual adoption without large rewrites. From 4423cd4cd0e28e758156ff899ccecc2ae395bbe9 Mon Sep 17 00:00:00 2001 From: Producdevity Date: Fri, 15 May 2026 16:47:27 +0200 Subject: [PATCH 15/17] fix: conditionally initialize Sentry in production environment --- src/instrumentation-client.ts | 97 +++++++++++++++-------------------- 1 file changed, 41 insertions(+), 56 deletions(-) diff --git a/src/instrumentation-client.ts b/src/instrumentation-client.ts index 842ad970e..1fe7ae9dd 100644 --- a/src/instrumentation-client.ts +++ b/src/instrumentation-client.ts @@ -4,61 +4,46 @@ import * as Sentry from '@sentry/nextjs' -Sentry.init({ - dsn: 'https://85ca585e45005d8786e361c3456518bf@o74828.ingest.us.sentry.io/4509717207318529', - - // Only enable Sentry in production - enabled: process.env.NODE_ENV === 'production', - - // Add optional integrations for additional features - integrations: [Sentry.replayIntegration()], - - // Define how likely traces are sampled. Adjust this value in production, or use tracesSampler for greater control. - tracesSampleRate: 1, - // Enable logs to be sent to Sentry - enableLogs: true, - - // Define how likely Replay events are sampled. - // This sets the sample rate to be 10%. You may want this to be 100% while - // in development and sample at a lower rate in production - replaysSessionSampleRate: 0.1, - - // Define how likely Replay events are sampled when an error occurs. - replaysOnErrorSampleRate: 1.0, - - // Setting this option to true will print useful information to the console while you're setting up Sentry. - debug: false, - - // Filter out localhost and third-party errors - beforeSend(event, hint) { - // Don't send events from localhost - if (typeof window !== 'undefined' && window.location.hostname === 'localhost') return null - - // Filter out third-party script errors - const error = hint?.originalException - const errorMessage = error?.toString() || event.exception?.values?.[0]?.value || '' - const errorUrl = event.request?.url || '' - - // List of third-party domains and patterns to ignore - const ignoredPatterns = [ - 'productfruits', - 'my.productfruits.com', - 'pf - starting script', - 'chrome-extension://', - 'moz-extension://', - 'safari-extension://', - 'ResizeObserver loop', - ] - - // Check if error matches any ignored pattern - const shouldIgnore = ignoredPatterns.some( - (pattern) => - errorMessage.toLowerCase().includes(pattern.toLowerCase()) || - errorUrl.toLowerCase().includes(pattern.toLowerCase()), - ) - - return shouldIgnore ? null : event - }, -}) +if (process.env.NODE_ENV === 'production') { + Sentry.init({ + dsn: 'https://85ca585e45005d8786e361c3456518bf@o74828.ingest.us.sentry.io/4509717207318529', + + integrations: [Sentry.replayIntegration()], + + tracesSampleRate: 1, + enableLogs: true, + + replaysSessionSampleRate: 0.1, + replaysOnErrorSampleRate: 1.0, + + debug: false, + + beforeSend(event, hint) { + if (typeof window !== 'undefined' && window.location.hostname === 'localhost') return null + + const error = hint?.originalException + const errorMessage = error?.toString() || event.exception?.values?.[0]?.value || '' + const errorUrl = event.request?.url || '' + + const ignoredPatterns = [ + 'productfruits', + 'my.productfruits.com', + 'pf - starting script', + 'chrome-extension://', + 'moz-extension://', + 'safari-extension://', + 'ResizeObserver loop', + ] + + const shouldIgnore = ignoredPatterns.some( + (pattern) => + errorMessage.toLowerCase().includes(pattern.toLowerCase()) || + errorUrl.toLowerCase().includes(pattern.toLowerCase()), + ) + + return shouldIgnore ? null : event + }, + }) +} export const onRouterTransitionStart = Sentry.captureRouterTransitionStart From 2ef566bb010447fc98940b719cdc4b1eeeced498 Mon Sep 17 00:00:00 2001 From: Producdevity Date: Fri, 15 May 2026 16:52:18 +0200 Subject: [PATCH 16/17] test: enhance placeholder-like version detection in tests --- src/server/services/submission-risk.service.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/server/services/submission-risk.service.test.ts b/src/server/services/submission-risk.service.test.ts index 22e2a5c44..1b3310c29 100644 --- a/src/server/services/submission-risk.service.test.ts +++ b/src/server/services/submission-risk.service.test.ts @@ -34,7 +34,7 @@ function createAuthorRiskProfile(signals: AuthorRiskProfile['signals']): AuthorR } describe('isPlaceholderLikeEmulatorVersion', () => { - it.each(['v0.1.4', 'v0.14', '014', 'o14', 'V 0 1 4'])( + it.each(['v0.1.4', 'v0.14', '014', 'o14', 'V 0 1 4', '0.1.4', '0.14', '01.4'])( 'detects placeholder-like value %s', (value) => { expect(isPlaceholderLikeEmulatorVersion(value)).toBe(true) From 54762b024b76de212a335ccc0c816e309ce10016 Mon Sep 17 00:00:00 2001 From: Producdevity Date: Sat, 16 May 2026 11:05:07 +0200 Subject: [PATCH 17/17] fix: use switch for risk filter and stabilize e2e --- package.json | 1 + pnpm-lock.yaml | 46 +++++++++++++++++++ .../admin/ReviewRiskFilterButton.test.tsx | 26 +++++++++++ .../admin/ReviewRiskFilterButton.tsx | 27 +++++++++-- src/components/ui/Switch.tsx | 29 ++++++++++++ src/components/ui/index.ts | 1 + tests/filtering.spec.ts | 8 ++-- tests/helpers/data-factory.ts | 21 +++++++-- tests/pages/ListingsPage.ts | 9 ++++ 9 files changed, 157 insertions(+), 11 deletions(-) create mode 100644 src/components/admin/ReviewRiskFilterButton.test.tsx create mode 100644 src/components/ui/Switch.tsx diff --git a/package.json b/package.json index 150bf9c90..96426ae11 100644 --- a/package.json +++ b/package.json @@ -66,6 +66,7 @@ "@radix-ui/react-dialog": "^1.1.14", "@radix-ui/react-popover": "^1.1.14", "@radix-ui/react-slot": "^1.2.3", + "@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-tooltip": "^1.2.7", "@scalar/api-reference-react": "^0.7.30", "@sendgrid/mail": "^8.1.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f857f97ee..fe4481ef2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -53,6 +53,9 @@ importers: '@radix-ui/react-slot': specifier: ^1.2.3 version: 1.2.3(@types/react@19.1.8)(react@19.1.4) + '@radix-ui/react-switch': + specifier: ^1.2.6 + version: 1.2.6(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.4(react@19.1.4))(react@19.1.4) '@radix-ui/react-tooltip': specifier: ^1.2.7 version: 1.2.7(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.4(react@19.1.4))(react@19.1.4) @@ -2407,6 +2410,19 @@ packages: '@types/react': optional: true + '@radix-ui/react-switch@1.2.6': + resolution: {integrity: sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-tooltip@1.2.7': resolution: {integrity: sha512-Ap+fNYwKTYJ9pzqW+Xe2HtMRbQ/EeWkj2qykZ6SuEV4iS/o1bZI5ssJbk4D2r8XuDuOBVz/tIx2JObtuqU+5Zw==} peerDependencies: @@ -2465,6 +2481,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-use-previous@1.1.1': + resolution: {integrity: sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-use-rect@1.1.1': resolution: {integrity: sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==} peerDependencies: @@ -10049,6 +10074,21 @@ snapshots: optionalDependencies: '@types/react': 19.1.8 + '@radix-ui/react-switch@1.2.6(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.4(react@19.1.4))(react@19.1.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.8)(react@19.1.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.1.8)(react@19.1.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.4(react@19.1.4))(react@19.1.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.8)(react@19.1.4) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.1.8)(react@19.1.4) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.1.8)(react@19.1.4) + react: 19.1.4 + react-dom: 19.1.4(react@19.1.4) + optionalDependencies: + '@types/react': 19.1.8 + '@types/react-dom': 19.1.6(@types/react@19.1.8) + '@radix-ui/react-tooltip@1.2.7(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.4(react@19.1.4))(react@19.1.4)': dependencies: '@radix-ui/primitive': 1.1.2 @@ -10103,6 +10143,12 @@ snapshots: optionalDependencies: '@types/react': 19.1.8 + '@radix-ui/react-use-previous@1.1.1(@types/react@19.1.8)(react@19.1.4)': + dependencies: + react: 19.1.4 + optionalDependencies: + '@types/react': 19.1.8 + '@radix-ui/react-use-rect@1.1.1(@types/react@19.1.8)(react@19.1.4)': dependencies: '@radix-ui/rect': 1.1.1 diff --git a/src/components/admin/ReviewRiskFilterButton.test.tsx b/src/components/admin/ReviewRiskFilterButton.test.tsx new file mode 100644 index 000000000..47b53d5ca --- /dev/null +++ b/src/components/admin/ReviewRiskFilterButton.test.tsx @@ -0,0 +1,26 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, expect, it, vi } from 'vitest' +import { ReviewRiskFilterButton } from './ReviewRiskFilterButton' + +describe('ReviewRiskFilterButton', () => { + it('renders the risk filter as a switch', () => { + render() + + expect(screen.getByRole('switch', { name: 'Risk only' })).toHaveAttribute( + 'aria-checked', + 'false', + ) + }) + + it('calls onToggle when the switch changes', async () => { + const user = userEvent.setup() + const onToggle = vi.fn() + + render() + + await user.click(screen.getByRole('switch', { name: 'Risk only' })) + + expect(onToggle).toHaveBeenCalledOnce() + }) +}) diff --git a/src/components/admin/ReviewRiskFilterButton.tsx b/src/components/admin/ReviewRiskFilterButton.tsx index 85c7b86b2..a2e8b1661 100644 --- a/src/components/admin/ReviewRiskFilterButton.tsx +++ b/src/components/admin/ReviewRiskFilterButton.tsx @@ -1,5 +1,9 @@ +'use client' + import { ShieldAlert } from 'lucide-react' -import { ToggleButton } from '@/components/ui/ToggleButton' +import { useId } from 'react' +import { Switch } from '@/components/ui/Switch' +import { cn } from '@/lib/utils' interface Props { isActive: boolean @@ -7,9 +11,24 @@ interface Props { } export function ReviewRiskFilterButton(props: Props) { + const switchId = useId() + return ( - - Risk only - +
+ props.onToggle()} /> + +
) } diff --git a/src/components/ui/Switch.tsx b/src/components/ui/Switch.tsx new file mode 100644 index 000000000..8358b66b0 --- /dev/null +++ b/src/components/ui/Switch.tsx @@ -0,0 +1,29 @@ +'use client' + +import * as SwitchPrimitive from '@radix-ui/react-switch' +import { type ComponentProps } from 'react' +import { cn } from '@/lib/utils' + +interface Props extends ComponentProps { + className?: string +} + +export function Switch(props: Props) { + return ( + + + + ) +} diff --git a/src/components/ui/index.ts b/src/components/ui/index.ts index ed46589e5..19600e66c 100644 --- a/src/components/ui/index.ts +++ b/src/components/ui/index.ts @@ -38,6 +38,7 @@ export * from './Skeleton' export * from './SortableHeader' export * from './SuccessRateBar' export * from './SwipeableCard' +export * from './Switch' export * from './ThemeSelect' export * from './ThemeToggle' export * from './ThreeWayToggle' diff --git a/tests/filtering.spec.ts b/tests/filtering.spec.ts index 2361efef4..1e8a32fd1 100644 --- a/tests/filtering.spec.ts +++ b/tests/filtering.spec.ts @@ -2,9 +2,10 @@ import { test, expect } from './fixtures' import { ListingsPage } from './pages/ListingsPage' import type { Locator, Page } from '@playwright/test' -async function selectFirstFilterOption(page: Page, filterButton: Locator) { +async function selectFirstFilterOption(page: Page, filterButton: Locator, optionText?: string) { await filterButton.click() - const firstOption = page.locator('label:has(input[type="checkbox"])').first() + const options = page.locator('label:has(input[type="checkbox"])') + const firstOption = optionText ? options.filter({ hasText: optionText }).first() : options.first() await expect(firstOption).toBeVisible() await firstOption.click() await filterButton.click() @@ -119,7 +120,8 @@ test.describe('Filtering Tests', () => { await listingsPage.verifyPageLoaded() await expect(listingsPage.listingItems.first()).toBeVisible() - await selectFirstFilterOption(page, listingsPage.deviceFilter) + const deviceName = await listingsPage.getFirstListingDeviceName() + await selectFirstFilterOption(page, listingsPage.deviceFilter, deviceName) await expect(page).toHaveURL(/[?&]deviceIds=/) await listingsPage.clickFirstListing() diff --git a/tests/helpers/data-factory.ts b/tests/helpers/data-factory.ts index 1bb9f2c0d..055ad0e61 100644 --- a/tests/helpers/data-factory.ts +++ b/tests/helpers/data-factory.ts @@ -592,6 +592,22 @@ export async function withContext( } } +async function openUserDetailsDialog(page: Page, userRow: Locator): Promise { + const viewDetailsButton = userRow.getByRole('button', { name: 'View User Details' }) + await expect(viewDetailsButton).toBeVisible() + + const dialog = page.locator('[role="dialog"]') + await expect(async () => { + if (!(await dialog.isVisible())) { + await viewDetailsButton.click() + } + + await expect(dialog).toBeVisible({ timeout: 2000 }) + }).toPass({ timeout: 10000 }) + + return dialog +} + export async function resetUserTrustScore(page: Page, targetUserEmail: string): Promise { await page.goto('/admin/users', { waitUntil: 'domcontentloaded' }) await expect(page.getByText(/loading/i)).toBeHidden() @@ -600,10 +616,7 @@ export async function resetUserTrustScore(page: Page, targetUserEmail: string): const userRow = page.locator('table tbody tr').filter({ hasText: targetUserEmail }).first() await expect(userRow).toBeVisible() - await userRow.getByRole('button', { name: 'View User Details' }).click() - - const dialog = page.locator('[role="dialog"]') - await expect(dialog).toBeVisible() + const dialog = await openUserDetailsDialog(page, userRow) const scoreElement = dialog.getByLabel('Trust score value') await expect(scoreElement).toBeVisible() diff --git a/tests/pages/ListingsPage.ts b/tests/pages/ListingsPage.ts index deb7ead00..4d570183c 100644 --- a/tests/pages/ListingsPage.ts +++ b/tests/pages/ListingsPage.ts @@ -27,6 +27,10 @@ export class ListingsPage extends BasePage { return this.page.locator('table tbody tr') } + get firstListingDeviceCell() { + return this.listingItems.first().locator('td').nth(2) + } + get noListingsMessage() { return this.page.getByText(/no listings found|no results|empty|nothing found/i) } @@ -70,6 +74,11 @@ export class ListingsPage extends BasePage { await expect(this.page).toHaveURL(/\/listings\/[^/]+/) } + async getFirstListingDeviceName() { + await expect(this.firstListingDeviceCell).toBeVisible() + return (await this.firstListingDeviceCell.innerText()).trim() + } + async verifyPageLoaded() { await expect(this.pageHeading).toBeVisible() }