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. 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).*)', diff --git a/package.json b/package.json index c7d40c71c..96426ae11 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", @@ -65,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/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..170991cc0 100644 --- a/src/app/admin/approvals/page.tsx +++ b/src/app/admin/approvals/page.tsx @@ -5,28 +5,29 @@ 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 { + AdminErrorState, AdminPageLayout, AdminTableContainer, AdminNotificationBanner, AdminStatsDisplay, AdminSearchFilters, AdminTableNoResults, + ReviewRiskFilterButton, } from '@/components/admin' import { EmulatorIcon, SystemIcon } from '@/components/icons' import { ApproveButton, - AuthorRiskIndicator, BulkActions, - Button, ColumnVisibilityControl, DisplayToggleButton, LoadingSpinner, 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() @@ -256,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() + }} + /> ) } @@ -338,7 +339,12 @@ function AdminApprovalsPage() { /> )} - table={table} searchPlaceholder="Search listings..." /> + table={table} searchPlaceholder="Search listings..."> + + {/* Bulk Actions */} {listings.length > 0 && ( @@ -372,7 +378,10 @@ function AdminApprovalsPage() { {pendingListingsQuery.isPending ? ( ) : listings.length === 0 ? ( - + ) : (
@@ -506,8 +515,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..5bc5aa99c 100644 --- a/src/app/admin/pc-listing-approvals/page.tsx +++ b/src/app/admin/pc-listing-approvals/page.tsx @@ -6,28 +6,29 @@ 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 { + AdminErrorState, AdminPageLayout, AdminTableContainer, AdminNotificationBanner, AdminSearchFilters, AdminStatsDisplay, AdminTableNoResults, + ReviewRiskFilterButton, } from '@/components/admin' import { EmulatorIcon, SystemIcon } from '@/components/icons' import { ApproveButton, - AuthorRiskIndicator, BulkActions, - Button, ColumnVisibilityControl, DisplayToggleButton, LoadingSpinner, 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() @@ -261,16 +267,12 @@ function PcListingApprovalsPage() { if (pendingPcListingsQuery.error) { return ( -
-
-

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

- -
-
+ { + void pendingPcListingsQuery.refetch() + }} + /> ) } @@ -343,7 +345,12 @@ function PcListingApprovalsPage() { table={table} searchPlaceholder="Search PC listings..." - /> + > + + {pcListings.length > 0 && ( ) : pcListings.length === 0 ? ( - + ) : (
@@ -560,8 +570,9 @@ function PcListingApprovalsPage() { 'N/A' )} - router.push(`/admin/users?userId=${authorId}&tab=reports`) 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 ( 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/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.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 new file mode 100644 index 000000000..a2e8b1661 --- /dev/null +++ b/src/components/admin/ReviewRiskFilterButton.tsx @@ -0,0 +1,34 @@ +'use client' + +import { ShieldAlert } from 'lucide-react' +import { useId } from 'react' +import { Switch } from '@/components/ui/Switch' +import { cn } from '@/lib/utils' + +interface Props { + isActive: boolean + onToggle: () => void +} + +export function ReviewRiskFilterButton(props: Props) { + const switchId = useId() + + return ( +
+ props.onToggle()} /> + +
+ ) +} diff --git a/src/components/admin/index.ts b/src/components/admin/index.ts index d2e2dba0e..28ad4d25c 100644 --- a/src/components/admin/index.ts +++ b/src/components/admin/index.ts @@ -1,6 +1,8 @@ export * from './AdminNotificationBanner' +export * from './AdminErrorState' export * from './AdminPageLayout' 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..0f63a2307 --- /dev/null +++ b/src/components/ui/ReviewRiskIndicator.test.tsx @@ -0,0 +1,113 @@ +import { render, screen } from '@testing-library/react' +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' + +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', + ) + }) + + 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 new file mode 100644 index 000000000..d497a0539 --- /dev/null +++ b/src/components/ui/ReviewRiskIndicator.tsx @@ -0,0 +1,104 @@ +'use client' + +import { cn } from '@/lib/utils' +import { type AuthorRiskProfile } from '@/schemas/authorRisk' +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 Props { + authorRiskProfile: AuthorRiskProfile | null | undefined + submissionRiskProfile: SubmissionRiskProfile | null | undefined + size?: 'sm' | 'md' + className?: string + onInvestigate?: (authorId: string) => void +} + +export function ReviewRiskIndicator(props: Props) { + const groups = getReviewRiskGroups(props) + const severity = getHighestReviewRiskSeverity(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 = getReviewRiskSignalCount(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..a92272c28 --- /dev/null +++ b/src/components/ui/ReviewRiskWarningBanner.tsx @@ -0,0 +1,69 @@ +'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 { getHighestReviewRiskSeverity, getReviewRiskGroups } from './reviewRiskDisplay' + +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', +} + +export function ReviewRiskWarningBanner(props: Props) { + const groups = getReviewRiskGroups(props) + const severity = getHighestReviewRiskSeverity(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/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/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 02088ad15..19600e66c 100644 --- a/src/components/ui/index.ts +++ b/src/components/ui/index.ts @@ -31,14 +31,18 @@ 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' export * from './SuccessRateBar' export * from './SwipeableCard' +export * from './Switch' 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/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 diff --git a/src/lib/cors.test.ts b/src/lib/cors.test.ts index 879831674..fd09607de 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,21 @@ describe('getOriginFromUrl', () => { }) }) +describe('getAllowedOrigins', () => { + afterEach(() => { + vi.unstubAllEnvs() + }) + + 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', '') + + 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..bfd8c1412 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' && process.env.NODE_ENV !== 'production') { + 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 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..527c50ae4 --- /dev/null +++ b/src/server/api/routers/listings/admin.test.ts @@ -0,0 +1,261 @@ +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') + +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', async (importOriginal) => { + const actual = await importOriginal() + + return { + computeAuthorRiskProfiles: (...args: unknown[]) => mockComputeAuthorRiskProfiles(...args), + createExistingAuthorBansMap: actual.createExistingAuthorBansMap, + } +}) + +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], { + emulatorIds: undefined, + search: undefined, + }) + 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) + }) + + 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 ac06029da..7fadf7b8c 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,77 @@ 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 = + 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) + 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/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 e13578f58..ec10baf42 100644 --- a/src/server/api/routers/pcListings.test.ts +++ b/src/server/api/routers/pcListings.test.ts @@ -1,5 +1,8 @@ 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') @@ -8,6 +11,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), @@ -72,8 +77,17 @@ vi.mock('@/server/services/audit.service', () => ({ logAudit: vi.fn().mockResolvedValue(undefined), })) -vi.mock('@/server/services/author-risk.service', () => ({ - computeAuthorRiskProfiles: vi.fn().mockResolvedValue(new Map()), +vi.mock('@/server/services/author-risk.service', async (importOriginal) => { + const actual = await importOriginal() + + return { + computeAuthorRiskProfiles: (...args: unknown[]) => mockComputeAuthorRiskProfiles(...args), + createExistingAuthorBansMap: actual.createExistingAuthorBansMap, + } +}) + +vi.mock('@/server/services/submission-risk.service', () => ({ + computeSubmissionRiskProfiles: (...args: unknown[]) => mockComputeSubmissionRiskProfiles(...args), })) vi.mock('@/server/api/utils/pinPermissions', () => ({ @@ -90,6 +104,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 +118,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 +139,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 +203,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 +456,177 @@ 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', + }) + 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({ + 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) + expect(result.pagination.total).toBe(0) + }) + + 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({ + emulatorIds: undefined, + search: undefined, + sortField: undefined, + sortDirection: 'asc', + }) + expect(mockRepositoryGetPendingListingsByIds).toHaveBeenCalledWith( + [LISTING_ID, LISTING_ID_B], + { + emulatorIds: undefined, + search: undefined, + }, + ) + 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) + }) + + 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', () => { 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..e02ed2bc2 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,36 @@ export const pcListingsRouter = createTRPCRouter({ } } + if (filterRiskyListings) { + const riskCandidates = await repository.getPendingListingRiskCandidates({ + emulatorIds, + search, + sortField, + sortDirection: sortDirection ?? 'asc', + }) + const riskProfiles = await computeReviewRiskProfiles(ctx.prisma, riskCandidates) + const riskyPcListingIds = getRiskyReviewItemIds(riskCandidates, riskProfiles) + const paginatedRiskyPcListingIds = riskyPcListingIds.slice((page - 1) * limit, page * limit) + const pagePcListings = + paginatedRiskyPcListingIds.length > 0 + ? await repository.getPendingListingsByIds(paginatedRiskyPcListingIds, { + emulatorIds, + search, + }) + : [] + 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 +501,13 @@ export const pcListingsRouter = createTRPCRouter({ limit, sortField, sortDirection: sortDirection ?? 'asc', - canSeeBannedUsers: true, // Moderators can see listings from banned users }) - // 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/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 073da543e..8ff2e4039 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: @@ -392,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({ @@ -448,6 +466,173 @@ 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[], filters: PendingListingsFilters = {}) { + if (listingIds.length === 0) return [] + + return this.prisma.listing.findMany({ + where: { + AND: [{ id: { in: listingIds } }, this.buildPendingListingsWhere(filters)], + }, + 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.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 a6790136f..2de812e1e 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' @@ -13,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 @@ -32,12 +33,148 @@ export interface PcListingFilters { userId?: string userRole?: Role showNsfw?: boolean - osFilter?: string[] + osFilter?: PcOs[] memoryMin?: number memoryMax?: number canSeeBannedUsers?: boolean } +export interface PcListingRiskCandidate { + id: string + authorId: string + author: { userBans: { reason: string }[] } | null + customFieldValues: { + value: unknown + customFieldDefinition: { + name: string + label: string + } + }[] +} + +export interface PendingPcListingsFilters { + emulatorIds?: string[] + search?: string + page?: number + limit?: number + sortField?: string + sortDirection?: 'asc' | 'desc' +} + +function getActiveUserBanWhere(): Prisma.UserBanWhereInput { + return { + isActive: true, + OR: [{ expiresAt: null }, { expiresAt: { gt: new Date() } }], + } +} + +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 = { @@ -68,10 +205,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 }, }, }, @@ -188,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[] = [] @@ -366,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 }), @@ -758,17 +830,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 }>[] @@ -787,32 +849,9 @@ export class PcListingsRepository extends BaseRepository { limit = PAGINATION.DEFAULT_LIMIT, sortField, sortDirection = 'asc', - 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 } } }, - ], - } - : {}), - } - - // Apply banned user filtering - const shadowBanFilter = canSeeBannedUsers ? undefined : buildShadowBanFilter(null) - const where = { - ...baseWhere, - ...(shadowBanFilter && { author: shadowBanFilter }), - } - + const where = this.buildPendingListingsWhere({ emulatorIds, search }) const actualOffset = calculateOffset({ page }, limit) const orderBy = this.buildOrderBy(sortField, sortDirection) @@ -827,14 +866,66 @@ export class PcListingsRepository extends BaseRepository { }), ]) - const pagination = paginate({ total: total, page, limit: limit }) - return { pcListings, - pagination, + pagination: paginate({ total: total, page, limit: limit }), } } + async getPendingListingRiskCandidates( + filters: PendingPcListingsFilters, + ): 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[], + filters: PendingPcListingsFilters = {}, + ): Promise< + Prisma.PcListingGetPayload<{ + include: typeof PcListingsRepository.includes.forList + }>[] + > { + if (pcListingIds.length === 0) return [] + + return this.prisma.pcListing.findMany({ + where: { + AND: [{ id: { in: pcListingIds } }, this.buildPendingListingsWhere(filters)], + }, + include: PcListingsRepository.includes.forList, + }) + } + + private buildPendingListingsWhere(filters: PendingPcListingsFilters): Prisma.PcListingWhereInput { + return buildPendingPcListingsWhere(filters, this.mode) + } + + private getPendingListingRiskCandidateSelect() { + return { + id: true, + authorId: true, + author: { + select: { + userBans: { + where: getActiveUserBanWhere(), + select: { reason: true }, + }, + }, + }, + customFieldValues: { + where: { customFieldDefinition: { name: EMULATOR_VERSION_FIELD_NAME } }, + select: { + value: true, + customFieldDefinition: { select: { name: true, label: true } }, + }, + }, + } satisfies Prisma.PcListingSelect + } + /** * Get emulator IDs for a verified developer */ diff --git a/src/server/services/author-risk.service.ts b/src/server/services/author-risk.service.ts index 0b56f7e51..997d4d3ca 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( @@ -332,7 +341,7 @@ export async function computeAuthorRiskProfiles( RISK_SIGNAL_TYPES.PREVIOUSLY_REJECTED, 'low', 'Previously Rejected', - `${rejectedCount} rejected listing${rejectedCount > 1 ? 's' : ''}`, + `${rejectedCount} rejected listings`, ), ) } @@ -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..1b3310c29 --- /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', '0.1.4', '0.14', '01.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), + } +} 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 8fbe770e4..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], + } } /** @@ -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 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 2d9f70f9a..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.locator('button').first().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() }