From afad7cd6afebdd5f38e5b988c67e0670c8825f64 Mon Sep 17 00:00:00 2001 From: Producdevity Date: Tue, 26 May 2026 18:03:05 +0200 Subject: [PATCH 01/35] refactor: simplify pagination logic and improve type safety --- src/server/api/routers/socs.ts | 1 + src/server/utils/pagination.ts | 54 ++++++---------------------------- 2 files changed, 10 insertions(+), 45 deletions(-) diff --git a/src/server/api/routers/socs.ts b/src/server/api/routers/socs.ts index 749f840a6..c88df5642 100644 --- a/src/server/api/routers/socs.ts +++ b/src/server/api/routers/socs.ts @@ -18,6 +18,7 @@ import { paginate } from '@/server/utils/pagination' export const socsRouter = createTRPCRouter({ get: publicProcedure.input(GetSoCsSchema).query(async ({ ctx, input }) => { + // TODO: use paginate helpers const repository = new SoCsRepository(ctx.prisma) const { limit = 20, offset = 0, page } = input ?? {} diff --git a/src/server/utils/pagination.ts b/src/server/utils/pagination.ts index c57d9a483..d75aa666d 100644 --- a/src/server/utils/pagination.ts +++ b/src/server/utils/pagination.ts @@ -1,4 +1,5 @@ import { toArray } from '@/utils/array' +import type { SortDirection } from '@/types/api' export interface PaginationInput { limit?: number @@ -46,18 +47,17 @@ interface PaginateParams { * @returns Complete pagination metadata with calculated offset */ export function paginate(params: PaginateParams): PaginationResult { - const { total, page, limit } = params - const offset = (page - 1) * limit - const pages = Math.ceil(total / limit) + const offset = (params.page - 1) * params.limit + const pages = Math.ceil(params.total / params.limit) return { - total, - page, - limit, + total: params.total, + page: params.page, + limit: params.limit, pages, offset, - hasNextPage: page < pages, - hasPreviousPage: page > 1, + hasNextPage: params.page < pages, + hasPreviousPage: params.page > 1, } } @@ -115,8 +115,7 @@ export async function paginatedQuery( }), ]) - const { page } = paginationInput - const actualPage = page ?? Math.floor(actualOffset / limit) + 1 + const actualPage = paginationInput.page ?? Math.floor(actualOffset / limit) + 1 const pagination = paginate({ total, page: actualPage, limit }) return { @@ -125,41 +124,6 @@ export async function paginatedQuery( } } -/** - * Type-safe wrapper for paginated queries with Prisma - * Ensures proper typing for the model and return type - */ -export function createPaginatedQueryWrapper< - Model extends { - count: (args?: { where?: unknown }) => Promise - findMany: (args?: unknown) => Promise - }, ->() { - return async function ( - model: Model, - args: Parameters[0], - paginationInput: PaginationInput, - defaultLimit = 20, - ): Promise> { - return paginatedQuery( - model as { - count: (args?: { where?: unknown }) => Promise - findMany: (args?: unknown) => Promise - }, - args as { - where?: unknown - orderBy?: unknown - include?: unknown - select?: unknown - }, - paginationInput, - defaultLimit, - ) - } -} - -export type SortDirection = 'asc' | 'desc' - /** * Build orderBy clause from sort field and direction * The generic type T represents the shape of orderBy objects From 49393ea96e230fb9289fa4a06708618f2572dffc Mon Sep 17 00:00:00 2001 From: Producdevity Date: Tue, 26 May 2026 21:51:26 +0200 Subject: [PATCH 02/35] refactor: rename helper function for clarity and ensure unique element selection --- prisma/seeders/listingsSeeder.ts | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/prisma/seeders/listingsSeeder.ts b/prisma/seeders/listingsSeeder.ts index 64309a5ec..f8079c7e8 100644 --- a/prisma/seeders/listingsSeeder.ts +++ b/prisma/seeders/listingsSeeder.ts @@ -1,17 +1,14 @@ import { Role, ApprovalStatus, type PrismaClient } from '@orm/client' -// Helper function to get random element from array function getRandomElement(array: T[]): T { return array[Math.floor(Math.random() * array.length)] } -// Helper function to get random elements from array (without duplicates) -function getRandomElements(array: T[], count: number): T[] { +function getRandomUniqueElements(array: T[], count: number): T[] { const shuffled = [...array].sort(() => 0.5 - Math.random()) return shuffled.slice(0, Math.min(count, array.length)) } -// Sample notes for different performance levels const sampleNotes = { Perfect: [ 'Runs flawlessly at full speed with no issues.', @@ -71,7 +68,6 @@ const sampleNotes = { ], } -// Sample comment content const sampleComments = [ 'Thanks for this report! Very helpful.', 'I can confirm this works on my device too.', @@ -97,7 +93,7 @@ async function createVotesAndComments( ) { // Add some random votes (60% upvotes, 40% downvotes) const votersCount = Math.floor(Math.random() * 8) + 2 // 2-9 voters - const voters = getRandomElements(users, votersCount) + const voters = getRandomUniqueElements(users, votersCount) for (const voter of voters) { const isUpvote = Math.random() > 0.4 // 60% chance of upvote @@ -114,7 +110,7 @@ async function createVotesAndComments( // Add some random comments (30% chance per listing) if (Math.random() < 0.3) { const commenterCount = Math.floor(Math.random() * 3) + 1 // 1-3 comments - const commenters = getRandomElements(users, commenterCount) + const commenters = getRandomUniqueElements(users, commenterCount) for (const commenter of commenters) { const content = getRandomElement(sampleComments) @@ -184,7 +180,7 @@ async function listingsSeeder(prisma: PrismaClient) { } // Pick 2 different games for this device - const selectedGames = getRandomElements(compatibleGames, 2) + const selectedGames = getRandomUniqueElements(compatibleGames, 2) for (let i = 0; i < selectedGames.length; i++) { const game = selectedGames[i] From cc0200945d54fedfed7f3147690505130010ad65 Mon Sep 17 00:00:00 2001 From: Producdevity Date: Tue, 26 May 2026 21:52:07 +0200 Subject: [PATCH 03/35] refactor: update cookie consent logic to use storage keys and improve readability --- .github/workflows/e2e-tests.yml | 2 +- tests/helpers/cookie-consent.ts | 27 +++++++++++---------------- 2 files changed, 12 insertions(+), 17 deletions(-) diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index d5c4e949b..9d170853b 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -59,7 +59,7 @@ jobs: NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: ${{ secrets.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY }} NEXT_PUBLIC_GA_ID: ${{ secrets.NEXT_PUBLIC_GA_ID }} NEXT_PUBLIC_IGDB_CLIENT_ID: ${{ secrets.NEXT_PUBLIC_IGDB_CLIENT_ID }} - NEXT_PUBLIC_LOCAL_STORAGE_PREFIX: ${{ secrets.NEXT_PUBLIC_LOCAL_STORAGE_PREFIX }} + NEXT_PUBLIC_LOCAL_STORAGE_PREFIX: "@TestEmuReady_" RAWG_API_KEY: ${{ secrets.RAWG_API_KEY }} THE_GAMES_DB_API_KEY: ${{ secrets.THE_GAMES_DB_API_KEY }} # Test configuration diff --git a/tests/helpers/cookie-consent.ts b/tests/helpers/cookie-consent.ts index f3d86ffe8..b04ab6532 100644 --- a/tests/helpers/cookie-consent.ts +++ b/tests/helpers/cookie-consent.ts @@ -1,22 +1,15 @@ +import storageKeys from '@/data/storageKeys' import type { BrowserContext, Page } from '@playwright/test' -const STORAGE_PREFIX = process.env.NEXT_PUBLIC_LOCAL_STORAGE_PREFIX -if (!STORAGE_PREFIX) { - throw new Error( - 'NEXT_PUBLIC_LOCAL_STORAGE_PREFIX is not set in the test environment. ' + - 'playwright.config.ts loads .env.local and .env.test.local — ensure one of them defines it.', - ) -} - -function applyConsent(prefix: string) { - localStorage.setItem(`${prefix}cookie_consent`, JSON.stringify(true)) +function applyConsent(keys: typeof storageKeys.cookies) { + localStorage.setItem(keys.consent, JSON.stringify(true)) localStorage.setItem( - `${prefix}cookie_preferences`, + keys.preferences, JSON.stringify({ necessary: true, analytics: false, performance: false }), ) - localStorage.setItem(`${prefix}cookie_consent_date`, JSON.stringify(new Date().toISOString())) - localStorage.setItem(`${prefix}analytics_enabled`, JSON.stringify(false)) - localStorage.setItem(`${prefix}performance_enabled`, JSON.stringify(false)) + localStorage.setItem(keys.consentDate, JSON.stringify(new Date().toISOString())) + localStorage.setItem(keys.analyticsEnabled, JSON.stringify(false)) + localStorage.setItem(keys.performanceEnabled, JSON.stringify(false)) const styleId = '__e2e_cookie_consent_hidden__' const css = '[data-testid="cookie-consent"]{display:none !important;}' @@ -39,10 +32,12 @@ function applyConsent(prefix: string) { document.addEventListener('readystatechange', injectStyle, { once: true }) document.addEventListener('DOMContentLoaded', injectStyle, { once: true }) observer = new MutationObserver(injectStyle) - observer.observe(document.documentElement, { childList: true, subtree: true }) + if (document.documentElement) { + observer.observe(document.documentElement, { childList: true, subtree: true }) + } } } export async function registerCookieConsent(target: BrowserContext | Page) { - await target.addInitScript(applyConsent, STORAGE_PREFIX) + await target.addInitScript(applyConsent, storageKeys.cookies) } From 17d15b3345c0598fc63a75ef47c6cf3b023c34c9 Mon Sep 17 00:00:00 2001 From: Producdevity Date: Tue, 26 May 2026 21:52:29 +0200 Subject: [PATCH 04/35] refactor: add native thin scrollbar styles for improved aesthetics --- src/app/globals.css | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/app/globals.css b/src/app/globals.css index 1dee1278a..4fd2437c9 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -266,6 +266,11 @@ display: none; } + .scrollbar-native-thin { + scrollbar-width: thin; + scrollbar-color: auto; + } + .animate-fade-in-up { animation: var(--animate-fade-in-up); opacity: 0; From 2fbc2ea2ea4d979d9b965d7344cc7521312b3a3a Mon Sep 17 00:00:00 2001 From: Producdevity Date: Tue, 26 May 2026 21:52:49 +0200 Subject: [PATCH 05/35] refactor: mock external service scripts --- tests/helpers/external-services.ts | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/tests/helpers/external-services.ts b/tests/helpers/external-services.ts index ec42861e2..a373396c7 100644 --- a/tests/helpers/external-services.ts +++ b/tests/helpers/external-services.ts @@ -6,6 +6,33 @@ const transparentPng = Buffer.from( ) export async function registerExternalServiceMocks(page: Page) { + await page.route('**/_vercel/speed-insights/script.js', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/javascript', + body: '', + }) + }) + + await page.route( + 'https://storage.ko-fi.com/cdn/scripts/floating-chat-wrapper.css', + async (route) => { + await route.fulfill({ + status: 200, + contentType: 'text/css', + body: '', + }) + }, + ) + + await page.route('https://storage.ko-fi.com/cdn/scripts/overlay-widget.js', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/javascript', + body: 'window.kofiWidgetOverlay={draw:function(){}};', + }) + }) + await page.route(/\/api\/proxy-image(?:\?.*)?$/u, async (route) => { await route.fulfill({ status: 200, From dc4e0a4e296aee439575b4c02fa78c916b710bbd Mon Sep 17 00:00:00 2001 From: Producdevity Date: Wed, 27 May 2026 18:51:56 +0200 Subject: [PATCH 06/35] refactor: remove beta feature flag and related components --- .env.docker.example | 1 - .env.example | 1 - .env.test.example | 1 - src/app/listings/shared/components/MobileFiltersFab.tsx | 4 +--- src/components/footer/Footer.tsx | 3 --- src/components/footer/components/FooterBetaBadge.tsx | 9 --------- src/lib/env.ts | 2 -- 7 files changed, 1 insertion(+), 20 deletions(-) delete mode 100644 src/components/footer/components/FooterBetaBadge.tsx diff --git a/.env.docker.example b/.env.docker.example index 9223417d4..7a7486e7f 100644 --- a/.env.docker.example +++ b/.env.docker.example @@ -72,7 +72,6 @@ NEXT_PUBLIC_GITHUB_URL="https://github.com/Producdevity/EmuReady" NEXT_PUBLIC_EMUREADY_LITE_GITHUB_URL="https://github.com/Producdevity/EmuReadyLite/releases" NEXT_PUBLIC_APP_URL="https://dev.emuready.com" NEXT_PUBLIC_ENABLE_SW=false -NEXT_PUBLIC_IS_BETA=false NEXT_PUBLIC_ENABLE_ASYNC_LISTINGS_FILTERS=false NEXT_PUBLIC_ENABLE_V2_LISTINGS=false NEXT_TELEMETRY_DISABLED=1 diff --git a/.env.example b/.env.example index 592c1934d..d52138dd4 100644 --- a/.env.example +++ b/.env.example @@ -46,7 +46,6 @@ NEXT_PUBLIC_GITHUB_URL="https://github.com/Producdevity/EmuReady" NEXT_PUBLIC_EMUREADY_LITE_GITHUB_URL="https://github.com/Producdevity/EmuReadyLite/releases" NEXT_PUBLIC_APP_URL="http://localhost:3000" # Make sure to change this if you are using a tunnel NEXT_PUBLIC_ENABLE_SW=false -NEXT_PUBLIC_IS_BETA=false NEXT_PUBLIC_ENABLE_PATREON_VERIFICATION=true NEXT_PUBLIC_ENABLE_ASYNC_LISTINGS_FILTERS=false NEXT_PUBLIC_ENABLE_V2_LISTINGS=false diff --git a/.env.test.example b/.env.test.example index 8f23094b8..b627bb17c 100644 --- a/.env.test.example +++ b/.env.test.example @@ -36,7 +36,6 @@ NEXT_PUBLIC_KOFI_LINK="https://ko-fi.com/producdevity" NEXT_PUBLIC_APP_URL="https://dev.emuready.com" NEXT_PUBLIC_ENABLE_PATREON_VERIFICATION=true NEXT_PUBLIC_ENABLE_SW=false -NEXT_PUBLIC_IS_BETA=false NEXT_PUBLIC_DISABLE_COOKIE_BANNER=true NEXT_PUBLIC_ENABLE_ASYNC_LISTINGS_FILTERS=false NEXT_PUBLIC_ENABLE_V2_LISTINGS=false diff --git a/src/app/listings/shared/components/MobileFiltersFab.tsx b/src/app/listings/shared/components/MobileFiltersFab.tsx index 2deecf745..fc1f39650 100644 --- a/src/app/listings/shared/components/MobileFiltersFab.tsx +++ b/src/app/listings/shared/components/MobileFiltersFab.tsx @@ -1,8 +1,6 @@ 'use client' import { FunnelIcon } from 'lucide-react' -import { env } from '@/lib/env' -import { cn } from '@/lib/utils' interface Props { ariaLabel: string @@ -12,7 +10,7 @@ interface Props { export function MobileFiltersFab(props: Props) { return ( -
+
- - {env.IS_BETA && } ) } diff --git a/src/components/footer/components/FooterBetaBadge.tsx b/src/components/footer/components/FooterBetaBadge.tsx deleted file mode 100644 index 023e0a30b..000000000 --- a/src/components/footer/components/FooterBetaBadge.tsx +++ /dev/null @@ -1,9 +0,0 @@ -export function FooterBetaBadge() { - return ( -
-
- BETA -
-
- ) -} diff --git a/src/lib/env.ts b/src/lib/env.ts index fe2185fa3..bc2fa8439 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -17,7 +17,6 @@ interface Env { ENABLE_SW: boolean VERCEL_ANALYTICS_ENABLED: boolean DISABLE_COOKIE_BANNER: boolean - IS_BETA: boolean IS_PROD: boolean IS_DEV: boolean IS_TEST: boolean @@ -67,7 +66,6 @@ export const env = { DISABLE_COOKIE_BANNER: process.env.NEXT_PUBLIC_DISABLE_COOKIE_BANNER === 'true', - IS_BETA: process.env.NEXT_PUBLIC_IS_BETA === 'true', IS_PROD: process.env.NODE_ENV === 'production', IS_DEV: process.env.NODE_ENV === 'development', IS_TEST: process.env.NODE_ENV === 'test', From c075f789a30cbe1eaba3937a4f54b713ddd7d746 Mon Sep 17 00:00:00 2001 From: Producdevity Date: Wed, 27 May 2026 19:14:40 +0200 Subject: [PATCH 07/35] refactor: remove AnimatePresence wrapper from ActiveFiltersSummary component --- .../components/ActiveFiltersSummary.tsx | 26 +++++++++---------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/src/app/listings/shared/components/ActiveFiltersSummary.tsx b/src/app/listings/shared/components/ActiveFiltersSummary.tsx index 1ad64ba38..eedd26a20 100644 --- a/src/app/listings/shared/components/ActiveFiltersSummary.tsx +++ b/src/app/listings/shared/components/ActiveFiltersSummary.tsx @@ -1,12 +1,12 @@ 'use client' -import { AnimatePresence, motion } from 'framer-motion' +import { motion } from 'framer-motion' import { motionPresets } from '@/lib/motionPresets' interface SummaryItem { key: string content: string - colorClass: string // e.g., 'bg-yellow-500' + colorClass: string delay?: number } @@ -41,18 +41,16 @@ export function ActiveFiltersSummary(props: Props) { )}
- - {props.items.map((item, index) => ( - - - {item.content} - - ))} - + {props.items.map((item, index) => ( + + + {item.content} + + ))}
) From 6b8e661d884e14239fa68a13dd125c772bfa5777 Mon Sep 17 00:00:00 2001 From: Producdevity Date: Thu, 28 May 2026 00:38:31 +0200 Subject: [PATCH 08/35] refactor: replace individual Async*MultiSelect components with consolidated Async*FilterSelect components and adjust query logic for filters --- .env.docker.example | 5 + .env.example | 5 + .env.test.example | 4 + next.config.ts | 13 +- playwright.config.ts | 9 +- .../migration.sql | 32 ++ prisma/schema.prisma | 11 + prisma/seed.ts | 17 + prisma/seeders/azaharCustomFieldsSeeder.ts | 77 +++- prisma/seeders/customFieldCategoryUtils.ts | 41 ++ prisma/seeders/customFieldValueSeederUtils.ts | 199 ++++++++++ prisma/seeders/edenCustomFieldsSeeder.ts | 107 +++++- .../seeders/gamenativeCustomFieldsSeeder.ts | 86 ++++- prisma/seeders/listingsSeeder.ts | 127 ++++++- prisma/seeders/pcListingsSeeder.ts | 180 +++++++++ sentry.edge.config.ts | 24 +- sentry.server.config.ts | 24 +- src/app/admin/brands/page.tsx | 15 +- src/app/admin/cpus/page.tsx | 12 +- .../admin/devices/components/DeviceModal.tsx | 3 +- src/app/admin/devices/page.tsx | 13 +- src/app/admin/gpus/page.tsx | 13 +- .../[id]/edit/components/ListingEditForm.tsx | 2 +- src/app/admin/socs/page.tsx | 13 +- src/app/api/proxy-image/route.ts | 4 +- .../home/components/HomeTrendingDevices.tsx | 13 +- src/app/layout.tsx | 26 +- src/app/listings/ListingsPage.tsx | 26 +- .../components/ListingsFiltersContent.tsx | 40 +- .../components/ListingsFiltersSidebar.tsx | 35 +- .../filters/AsyncDeviceFilterSelect.test.tsx | 191 ++++++++++ .../filters/AsyncDeviceFilterSelect.tsx | 88 +++++ .../filters/AsyncSocFilterSelect.test.tsx | 85 +++++ .../filters/AsyncSocFilterSelect.tsx | 88 +++++ src/app/listings/new/NewListingPage.tsx | 13 +- src/app/pc-listings/PcListingsPage.tsx | 41 +- .../components/PcFiltersContent.tsx | 10 +- .../filters/AsyncCpuFilterSelect.test.tsx | 87 +++++ .../filters/AsyncCpuFilterSelect.tsx | 88 +++++ .../filters/AsyncGpuFilterSelect.test.tsx | 85 +++++ .../filters/AsyncGpuFilterSelect.tsx | 88 +++++ src/app/pc-listings/new/NewPcListingPage.tsx | 22 +- src/app/profile/components/DeviceSelector.tsx | 17 +- src/app/profile/components/PcPresetModal.tsx | 67 +++- src/app/profile/components/SocSelector.tsx | 9 +- src/app/v2/listings/V2ListingsPage.tsx | 30 +- .../v2/listings/components/ListingFilters.tsx | 41 +- src/components/popups/BetaWarningPopup.tsx | 6 +- .../popups/StopKillingGamesPopup.tsx | 7 +- src/components/ui/KofiWidget.tsx | 5 +- .../ui/form/AsyncCpuMultiSelect.tsx | 70 ---- .../ui/form/AsyncDeviceMultiSelect.tsx | 70 ---- .../ui/form/AsyncGpuMultiSelect.tsx | 70 ---- src/components/ui/form/AsyncMultiSelect.tsx | 231 ------------ .../ui/form/AsyncSocMultiSelect.tsx | 70 ---- src/components/ui/form/MultiSelect.test.tsx | 59 ++- src/components/ui/form/MultiSelect.tsx | 111 ++++-- .../AsyncMultiSelect.test.tsx | 354 ++++++++++++++++++ .../async-multi-select/AsyncMultiSelect.tsx | 128 +++++-- src/components/ui/form/index.ts | 1 - src/instrumentation-client.test.ts | 55 +++ src/instrumentation-client.ts | 5 +- .../utils/sendAnalyticsEvent.test.ts | 89 +++++ src/lib/analytics/utils/sendAnalyticsEvent.ts | 17 +- src/lib/cors.test.ts | 80 +++- src/lib/cors.ts | 23 +- src/lib/env.test.ts | 77 ++++ src/lib/env.ts | 34 +- src/lib/trust/service.test.ts | 29 ++ src/lib/trust/service.ts | 9 +- src/proxy.ts | 10 +- src/schemas/cpu.ts | 10 + src/schemas/device.ts | 11 + src/schemas/gpu.ts | 10 + src/schemas/soc.ts | 9 + src/sentry-config.test.ts | 52 +++ src/server/api/routers/cpus.ts | 6 + src/server/api/routers/devices.ts | 19 +- src/server/api/routers/gpus.ts | 6 + src/server/api/routers/socs.ts | 8 +- src/server/repositories/cpus.repository.ts | 40 +- src/server/repositories/devices.repository.ts | 143 +++++-- src/server/repositories/gpus.repository.ts | 37 +- .../repositories/listings.repository.ts | 2 +- .../repositories/pc-listings.repository.ts | 2 +- src/server/repositories/socs.repository.ts | 38 +- tests/filtering.spec.ts | 71 +++- tests/pages/ListingsPage.ts | 8 +- tests/third-party-services.spec.ts | 30 ++ 89 files changed, 3445 insertions(+), 893 deletions(-) create mode 100644 prisma/migrations/20260526215009_add_listing_query_indexes/migration.sql create mode 100644 prisma/seeders/customFieldCategoryUtils.ts create mode 100644 prisma/seeders/customFieldValueSeederUtils.ts create mode 100644 prisma/seeders/pcListingsSeeder.ts create mode 100644 src/app/listings/components/filters/AsyncDeviceFilterSelect.test.tsx create mode 100644 src/app/listings/components/filters/AsyncDeviceFilterSelect.tsx create mode 100644 src/app/listings/components/filters/AsyncSocFilterSelect.test.tsx create mode 100644 src/app/listings/components/filters/AsyncSocFilterSelect.tsx create mode 100644 src/app/pc-listings/components/filters/AsyncCpuFilterSelect.test.tsx create mode 100644 src/app/pc-listings/components/filters/AsyncCpuFilterSelect.tsx create mode 100644 src/app/pc-listings/components/filters/AsyncGpuFilterSelect.test.tsx create mode 100644 src/app/pc-listings/components/filters/AsyncGpuFilterSelect.tsx delete mode 100644 src/components/ui/form/AsyncCpuMultiSelect.tsx delete mode 100644 src/components/ui/form/AsyncDeviceMultiSelect.tsx delete mode 100644 src/components/ui/form/AsyncGpuMultiSelect.tsx delete mode 100644 src/components/ui/form/AsyncMultiSelect.tsx delete mode 100644 src/components/ui/form/AsyncSocMultiSelect.tsx create mode 100644 src/components/ui/form/async-multi-select/AsyncMultiSelect.test.tsx create mode 100644 src/instrumentation-client.test.ts create mode 100644 src/lib/analytics/utils/sendAnalyticsEvent.test.ts create mode 100644 src/lib/env.test.ts create mode 100644 src/sentry-config.test.ts create mode 100644 tests/third-party-services.spec.ts diff --git a/.env.docker.example b/.env.docker.example index 7a7486e7f..79fea89fb 100644 --- a/.env.docker.example +++ b/.env.docker.example @@ -61,9 +61,14 @@ NEXT_PUBLIC_GA_ID="your_google_analytics_id_here" # APPLICATION CONFIGURATION # =========================================== # Public app configuration +# local | test | preview | staging | production +NEXT_PUBLIC_APP_ENV=local NEXT_PUBLIC_LOCAL_STORAGE_PREFIX="@DockerEmuReady_" NEXT_PUBLIC_EMUREADY_BETA_URL="https://play.google.com/store/apps/details?id=com.producdevity.emureadyapp" NEXT_PUBLIC_VERCEL_ANALYTICS_ENABLED=false +NEXT_PUBLIC_ENABLE_ANALYTICS=false +NEXT_PUBLIC_ENABLE_KOFI_WIDGET=false +NEXT_PUBLIC_ENABLE_SENTRY=false NEXT_PUBLIC_DISCORD_LINK="https://discord.gg/CYhCzApXav" NEXT_PUBLIC_PATREON_LINK="https://www.patreon.com/Producdevity" NEXT_PUBLIC_KOFI_LINK="https://ko-fi.com/producdevity" diff --git a/.env.example b/.env.example index d52138dd4..7fe59b0d7 100644 --- a/.env.example +++ b/.env.example @@ -34,10 +34,15 @@ EMAIL_FROM_NAME="EmuReady Team" # EMAIL_API_KEY="your-mailersend-api-key-here" # Public Variables +# local | test | preview | staging | production +NEXT_PUBLIC_APP_ENV=local NEXT_PUBLIC_GA_ID="Google-Analytics-ID" NEXT_PUBLIC_LOCAL_STORAGE_PREFIX="@LocalEmuReady_" NEXT_PUBLIC_EMUREADY_BETA_URL="https://play.google.com/store/apps/details?id=com.producdevity.emureadyapp" NEXT_PUBLIC_VERCEL_ANALYTICS_ENABLED=false +NEXT_PUBLIC_ENABLE_ANALYTICS=false +NEXT_PUBLIC_ENABLE_KOFI_WIDGET=false +NEXT_PUBLIC_ENABLE_SENTRY=false NEXT_PUBLIC_DISCORD_LINK="https://discord.gg/CYhCzApXav" NEXT_PUBLIC_PATREON_LINK="https://www.patreon.com/Producdevity" NEXT_PUBLIC_KOFI_LINK="https://ko-fi.com/producdevity" diff --git a/.env.test.example b/.env.test.example index b627bb17c..093105bf0 100644 --- a/.env.test.example +++ b/.env.test.example @@ -26,10 +26,14 @@ EMAIL_FROM_NAME="EmuReady Test Team" # Services RAWG_API_KEY="" +NEXT_PUBLIC_APP_ENV=test NEXT_PUBLIC_GA_ID="" NEXT_PUBLIC_LOCAL_STORAGE_PREFIX="@TestEmuReady_" NEXT_PUBLIC_EMUREADY_BETA_URL="https://play.google.com/store/apps/details?id=com.producdevity.emureadyapp" NEXT_PUBLIC_VERCEL_ANALYTICS_ENABLED=false +NEXT_PUBLIC_ENABLE_ANALYTICS=false +NEXT_PUBLIC_ENABLE_KOFI_WIDGET=false +NEXT_PUBLIC_ENABLE_SENTRY=false NEXT_PUBLIC_DISCORD_LINK="https://discord.gg/CYhCzApXav" NEXT_PUBLIC_PATREON_LINK="https://www.patreon.com/Producdevity" NEXT_PUBLIC_KOFI_LINK="https://ko-fi.com/producdevity" diff --git a/next.config.ts b/next.config.ts index 714a57299..bb0d6ef1a 100644 --- a/next.config.ts +++ b/next.config.ts @@ -6,6 +6,7 @@ import type { Configuration as WebpackConfiguration } from 'webpack' type Header = Awaited>>[number] const isVercelBuild = process.env.VERCEL === '1' +const isSentryEnabled = process.env.NEXT_PUBLIC_ENABLE_SENTRY === 'true' const contentSecurityPolicyDirectives = [ { @@ -359,7 +360,9 @@ const withBundleAnalyzer = NextBundleAnalyzer({ enabled: process.env.ANALYZE === 'true', }) -export default withSentryConfig(withBundleAnalyzer(nextConfig), { +const analyzedConfig = withBundleAnalyzer(nextConfig) + +const sentryBuildOptions = { // For all available options, see: // https://docs.sentry.io/platforms/javascript/guides/nextjs/configuration/build/ @@ -385,4 +388,10 @@ export default withSentryConfig(withBundleAnalyzer(nextConfig), { bundleSizeOptimizations: { excludeDebugStatements: true, }, -}) + + telemetry: false, +} + +export default isSentryEnabled + ? withSentryConfig(analyzedConfig, sentryBuildOptions) + : analyzedConfig diff --git a/playwright.config.ts b/playwright.config.ts index 710a5e4e1..99c81291c 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -16,7 +16,14 @@ function createWebServerEnv(): { [key: string]: string } { for (const [key, value] of Object.entries(process.env)) { if (typeof value === 'string') env[key] = value } - env.NODE_ENV ||= 'test' + env.NODE_ENV = 'test' + env.NEXT_PUBLIC_APP_ENV = 'test' + env.NEXT_PUBLIC_ENABLE_ANALYTICS = 'false' + env.NEXT_PUBLIC_ENABLE_KOFI_WIDGET = 'false' + env.NEXT_PUBLIC_ENABLE_SENTRY = 'false' + env.NEXT_PUBLIC_VERCEL_ANALYTICS_ENABLED = 'false' + env.NEXT_PUBLIC_DISABLE_COOKIE_BANNER = 'true' + env.PLAYWRIGHT_TEST = 'true' return env } diff --git a/prisma/migrations/20260526215009_add_listing_query_indexes/migration.sql b/prisma/migrations/20260526215009_add_listing_query_indexes/migration.sql new file mode 100644 index 000000000..9a46ee142 --- /dev/null +++ b/prisma/migrations/20260526215009_add_listing_query_indexes/migration.sql @@ -0,0 +1,32 @@ +-- CreateIndex +CREATE INDEX "Listing_status_createdAt_deviceId_idx" ON "Listing"("status", "createdAt", "deviceId"); + +-- CreateIndex +CREATE INDEX "Listing_deviceId_status_createdAt_idx" ON "Listing"("deviceId", "status", "createdAt"); + +-- CreateIndex +CREATE INDEX "Listing_gameId_status_createdAt_idx" ON "Listing"("gameId", "status", "createdAt"); + +-- CreateIndex +CREATE INDEX "Listing_emulatorId_status_createdAt_idx" ON "Listing"("emulatorId", "status", "createdAt"); + +-- CreateIndex +CREATE INDEX "Listing_authorId_status_createdAt_idx" ON "Listing"("authorId", "status", "createdAt"); + +-- CreateIndex +CREATE INDEX "pc_listings_status_createdAt_cpuId_idx" ON "pc_listings"("status", "createdAt", "cpuId"); + +-- CreateIndex +CREATE INDEX "pc_listings_cpuId_status_createdAt_idx" ON "pc_listings"("cpuId", "status", "createdAt"); + +-- CreateIndex +CREATE INDEX "pc_listings_gpuId_status_createdAt_idx" ON "pc_listings"("gpuId", "status", "createdAt"); + +-- CreateIndex +CREATE INDEX "pc_listings_gameId_status_createdAt_idx" ON "pc_listings"("gameId", "status", "createdAt"); + +-- CreateIndex +CREATE INDEX "pc_listings_emulatorId_status_createdAt_idx" ON "pc_listings"("emulatorId", "status", "createdAt"); + +-- CreateIndex +CREATE INDEX "pc_listings_authorId_status_createdAt_idx" ON "pc_listings"("authorId", "status", "createdAt"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 7eef08ba8..e7c9bf4f1 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -483,6 +483,11 @@ model Listing { pinnedByUser User? @relation("ListingPinnedBy", fields: [pinnedByUserId], references: [id], onDelete: SetNull) @@index([processedByUserId]) + @@index([status, createdAt, deviceId]) + @@index([deviceId, status, createdAt]) + @@index([gameId, status, createdAt]) + @@index([emulatorId, status, createdAt]) + @@index([authorId, status, createdAt]) @@index([successRate, voteCount]) @@index([voteCount]) @@index([pinnedCommentId]) @@ -1022,6 +1027,12 @@ model PcListing { @@index([processedByUserId]) @@index([status]) + @@index([status, createdAt, cpuId]) + @@index([cpuId, status, createdAt]) + @@index([gpuId, status, createdAt]) + @@index([gameId, status, createdAt]) + @@index([emulatorId, status, createdAt]) + @@index([authorId, status, createdAt]) @@index([gameId]) @@index([emulatorId]) @@index([cpuId]) diff --git a/prisma/seed.ts b/prisma/seed.ts index f0b89bd9e..c6febfb04 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -10,6 +10,7 @@ import gamenativeCustomFieldsSeeder from './seeders/gamenativeCustomFieldsSeeder import gamesSeeder from './seeders/gamesSeeder' import gpuSeeder from './seeders/gpuSeeder' import listingsSeeder from './seeders/listingsSeeder' +import pcListingsSeeder from './seeders/pcListingsSeeder' import performanceScalesSeeder from './seeders/performanceScalesSeeder' import permissionsSeeder from './seeders/permissionsSeeder' import socSeeder from './seeders/socSeeder' @@ -66,6 +67,7 @@ async function main() { const seedDevicesOnly = args.includes('--devices-only') const seedEmulatorsOnly = args.includes('--emulators-only') const seedCustomFieldsOnly = args.includes('--custom-fields-only') + const seedPcListingsOnly = args.includes('--pc-listings-only') const seedCpusOnly = args.includes('--cpus-only') const seedGpusOnly = args.includes('--gpus-only') @@ -156,6 +158,20 @@ async function main() { return } + if (seedPcListingsOnly) { + console.info('🌱 Seeding PC listings only...') + try { + await pcListingsSeeder(prisma) + console.info('✅ PC listings seeded successfully!') + } catch (error) { + console.error('❌ Error seeding PC listings:', error) + throw error + } finally { + await prisma.$disconnect() + } + return + } + if (seedGpusOnly) { console.info('🌱 Seeding GPUs only...') try { @@ -200,6 +216,7 @@ async function main() { await devicesSeeder(prisma) await gamesSeeder(prisma) await listingsSeeder(prisma) + await pcListingsSeeder(prisma) console.info('✅ Database seeded successfully!') } catch (error) { diff --git a/prisma/seeders/azaharCustomFieldsSeeder.ts b/prisma/seeders/azaharCustomFieldsSeeder.ts index 253f34d60..4576ce8e4 100644 --- a/prisma/seeders/azaharCustomFieldsSeeder.ts +++ b/prisma/seeders/azaharCustomFieldsSeeder.ts @@ -1,4 +1,5 @@ import { CustomFieldType, Prisma, type PrismaClient } from '@orm/client' +import { syncCustomFieldCategories, type CustomFieldCategorySeed } from './customFieldCategoryUtils' interface SelectOption { value: string @@ -26,6 +27,41 @@ interface AzaharCustomFieldSeed { const AZAHAR_EMULATOR_NAME = 'Azahar' +const AZAHAR_CUSTOM_FIELD_CATEGORIES: CustomFieldCategorySeed[] = [ + { name: 'General', displayOrder: 0 }, + { name: 'Graphics', displayOrder: 1 }, + { name: 'CPU', displayOrder: 2 }, + { name: 'Advanced', displayOrder: 3 }, + { name: 'Media', displayOrder: 4 }, +] + +const AZAHAR_FIELD_CATEGORY_CONFIG: Record< + string, + { categoryName: string; categoryOrder: number } +> = { + emulator_version: { categoryName: 'General', categoryOrder: 0 }, + game_version: { categoryName: 'General', categoryOrder: 1 }, + average_fps: { categoryName: 'General', categoryOrder: 2 }, + dynamic_driver_version: { categoryName: 'Graphics', categoryOrder: 0 }, + graphics_api: { categoryName: 'Graphics', categoryOrder: 1 }, + enable_spiri_v_shader_generation: { categoryName: 'Graphics', categoryOrder: 2 }, + disable_spir_v_optimizer: { categoryName: 'Graphics', categoryOrder: 3 }, + enable_async_shader_complication: { categoryName: 'Graphics', categoryOrder: 4 }, + internal_resolution: { categoryName: 'Graphics', categoryOrder: 5 }, + linear_filtering: { categoryName: 'Graphics', categoryOrder: 6 }, + accurate_multiplication: { categoryName: 'Graphics', categoryOrder: 7 }, + disk_shader_cache: { categoryName: 'Graphics', categoryOrder: 8 }, + texture_filter: { categoryName: 'Graphics', categoryOrder: 9 }, + stereoscopic_3d_mode: { categoryName: 'Graphics', categoryOrder: 10 }, + enable_hardware_shader: { categoryName: 'Graphics', categoryOrder: 11 }, + enable_vsync: { categoryName: 'Graphics', categoryOrder: 12 }, + cpu_jit: { categoryName: 'CPU', categoryOrder: 0 }, + delay_game_render_thread: { categoryName: 'Advanced', categoryOrder: 0 }, + delay_start_with_lle_modules: { categoryName: 'Advanced', categoryOrder: 1 }, + media_url: { categoryName: 'Media', categoryOrder: 0 }, + youtube: { categoryName: 'Media', categoryOrder: 1 }, +} + const AZAHAR_CUSTOM_FIELDS: AzaharCustomFieldSeed[] = [ { name: 'emulator_version', @@ -252,6 +288,11 @@ export default async function azaharCustomFieldsSeeder(prisma: PrismaClient) { } const fieldNames = AZAHAR_CUSTOM_FIELDS.map((field) => field.name) + const categoryIdByName = await syncCustomFieldCategories( + prisma, + azahar.id, + AZAHAR_CUSTOM_FIELD_CATEGORIES, + ) for (const field of AZAHAR_CUSTOM_FIELDS) { await prisma.customFieldDefinition.upsert({ @@ -261,8 +302,8 @@ export default async function azaharCustomFieldsSeeder(prisma: PrismaClient) { name: field.name, }, }, - create: buildDefinitionCreate(azahar.id, field), - update: buildDefinitionUpdate(field), + create: buildDefinitionCreate(azahar.id, field, categoryIdByName), + update: buildDefinitionUpdate(field, categoryIdByName), }) } @@ -278,11 +319,17 @@ export default async function azaharCustomFieldsSeeder(prisma: PrismaClient) { ) } -function buildDefinitionCreate(emulatorId: string, field: AzaharCustomFieldSeed) { +function buildDefinitionCreate( + emulatorId: string, + field: AzaharCustomFieldSeed, + categoryIdByName: ReadonlyMap, +) { const { options, range, defaultValue, placeholder, ...base } = field + const categoryConfig = AZAHAR_FIELD_CATEGORY_CONFIG[field.name] return { emulatorId, + categoryId: resolveCategoryId(field.name, categoryConfig?.categoryName, categoryIdByName), name: base.name, label: base.label, type: base.type, @@ -295,13 +342,19 @@ function buildDefinitionCreate(emulatorId: string, field: AzaharCustomFieldSeed) rangeUnit: range?.unit ?? null, isRequired: base.required, displayOrder: base.displayOrder, + categoryOrder: categoryConfig?.categoryOrder ?? 0, } } -function buildDefinitionUpdate(field: AzaharCustomFieldSeed) { +function buildDefinitionUpdate( + field: AzaharCustomFieldSeed, + categoryIdByName: ReadonlyMap, +) { const { options, range, defaultValue, placeholder, ...base } = field + const categoryConfig = AZAHAR_FIELD_CATEGORY_CONFIG[field.name] return { + categoryId: resolveCategoryId(field.name, categoryConfig?.categoryName, categoryIdByName), label: base.label, type: base.type, options: normalizeJsonInput(options), @@ -313,9 +366,25 @@ function buildDefinitionUpdate(field: AzaharCustomFieldSeed) { rangeUnit: range?.unit ?? null, isRequired: base.required, displayOrder: base.displayOrder, + categoryOrder: categoryConfig?.categoryOrder ?? 0, } } +function resolveCategoryId( + fieldName: string, + categoryName: string | undefined, + categoryIdByName: ReadonlyMap, +) { + if (!categoryName) return null + + const categoryId = categoryIdByName.get(categoryName) + if (!categoryId) { + throw new Error(`Missing Azahar custom field category "${categoryName}" for ${fieldName}`) + } + + return categoryId +} + function normalizeJsonInput( value: unknown, ): Prisma.NullableJsonNullValueInput | Prisma.InputJsonValue | undefined { diff --git a/prisma/seeders/customFieldCategoryUtils.ts b/prisma/seeders/customFieldCategoryUtils.ts new file mode 100644 index 000000000..9b111c79f --- /dev/null +++ b/prisma/seeders/customFieldCategoryUtils.ts @@ -0,0 +1,41 @@ +import type { PrismaClient } from '@orm/client' + +export interface CustomFieldCategorySeed { + name: string + displayOrder: number +} + +export async function syncCustomFieldCategories( + prisma: PrismaClient, + emulatorId: string, + categories: readonly CustomFieldCategorySeed[], +) { + const categoryIdByName = new Map() + + for (const category of categories) { + const syncedCategory = await prisma.customFieldCategory.upsert({ + where: { + emulatorId_name: { + emulatorId, + name: category.name, + }, + }, + create: { + emulatorId, + name: category.name, + displayOrder: category.displayOrder, + }, + update: { + displayOrder: category.displayOrder, + }, + select: { + id: true, + name: true, + }, + }) + + categoryIdByName.set(syncedCategory.name, syncedCategory.id) + } + + return categoryIdByName +} diff --git a/prisma/seeders/customFieldValueSeederUtils.ts b/prisma/seeders/customFieldValueSeederUtils.ts new file mode 100644 index 000000000..a6a1e4ae4 --- /dev/null +++ b/prisma/seeders/customFieldValueSeederUtils.ts @@ -0,0 +1,199 @@ +import { CustomFieldType, type Prisma, type PrismaClient } from '@orm/client' + +type CustomFieldDefinitionForSeed = { + id: string + name: string + type: CustomFieldType + options: Prisma.JsonValue | null + defaultValue: Prisma.JsonValue | null + rangeMin: number | null + rangeMax: number | null +} + +export type CustomFieldDefinitionCache = Map + +const CUSTOM_FIELD_VALUE_OVERRIDES: Record = { + emulator_version: ['2123.2', '0.0.2-pre-alpha', '1.0.0'], + dynamic_driver_version: ['Turnip v24.3.0 R10', 'Mesa Turnip Adreno 24.2', 'System default'], + graphics_driver: ['Turnip (Adreno)', 'Vortek (Universal)', 'VirGL (Universal)'], + graphics_api: ['Vulkan', 'OpenGLES'], + gpu_api: ['Vulkan', 'OpenGL'], + resolution: ['1280x720 (16:9)', '2x', 'Native'], + internal_resolution: ['native', '2', '3'], + average_fps: ['58', '45', '30'], + game_version: ['1.0.0', '1.1.0', 'Latest'], + youtube: ['https://www.youtube.com/watch?v=dQw4w9WgXcQ'], + media_url: ['https://example.com/emuready-seed-screenshot'], + dx_wrapper_config: ['DXVK async enabled, 60 FPS cap, 4096 MB max memory'], + env_variables: ['MESA_GL_VERSION_OVERRIDE=4.6\nBOX64_DYNAREC_SAFEFLAGS=1'], + exec_arguments: ['-skipinitialbootstrap -nocrashmonitor'], +} + +export function createCustomFieldDefinitionCache(): CustomFieldDefinitionCache { + return new Map() +} + +export async function seedListingCustomFieldValues( + prisma: PrismaClient, + definitionCache: CustomFieldDefinitionCache, + listingId: string, + emulatorId: string, + seedIndex: number, +): Promise { + const definitions = await getCustomFieldDefinitions(prisma, definitionCache, emulatorId) + let createdOrUpdated = 0 + + for (const definition of definitions) { + const value = getSeedValueForCustomField(definition, seedIndex) + if (value === undefined) continue + + await prisma.listingCustomFieldValue.upsert({ + where: { + listingId_customFieldDefinitionId: { + listingId, + customFieldDefinitionId: definition.id, + }, + }, + create: { + listingId, + customFieldDefinitionId: definition.id, + value, + }, + update: { value }, + }) + createdOrUpdated += 1 + } + + return createdOrUpdated +} + +export async function seedPcListingCustomFieldValues( + prisma: PrismaClient, + definitionCache: CustomFieldDefinitionCache, + pcListingId: string, + emulatorId: string, + seedIndex: number, +): Promise { + const definitions = await getCustomFieldDefinitions(prisma, definitionCache, emulatorId) + let createdOrUpdated = 0 + + for (const definition of definitions) { + const value = getSeedValueForCustomField(definition, seedIndex) + if (value === undefined) continue + + await prisma.pcListingCustomFieldValue.upsert({ + where: { + pcListingId_customFieldDefinitionId: { + pcListingId, + customFieldDefinitionId: definition.id, + }, + }, + create: { + pcListingId, + customFieldDefinitionId: definition.id, + value, + }, + update: { value }, + }) + createdOrUpdated += 1 + } + + return createdOrUpdated +} + +async function getCustomFieldDefinitions( + prisma: PrismaClient, + definitionCache: CustomFieldDefinitionCache, + emulatorId: string, +) { + const cachedDefinitions = definitionCache.get(emulatorId) + if (cachedDefinitions) return cachedDefinitions + + const definitions = await prisma.customFieldDefinition.findMany({ + where: { emulatorId }, + select: { + id: true, + name: true, + type: true, + options: true, + defaultValue: true, + rangeMin: true, + rangeMax: true, + }, + orderBy: [{ categoryOrder: 'asc' }, { displayOrder: 'asc' }], + }) + + definitionCache.set(emulatorId, definitions) + return definitions +} + +function getSeedValueForCustomField( + definition: CustomFieldDefinitionForSeed, + seedIndex: number, +): Prisma.InputJsonValue | undefined { + const override = getOverrideValue(definition, seedIndex) + if (override !== undefined) return override + + if (isJsonPrimitive(definition.defaultValue)) { + return definition.defaultValue + } + + switch (definition.type) { + case CustomFieldType.BOOLEAN: + return seedIndex % 2 === 0 + case CustomFieldType.RANGE: + return getRangeValue(definition) + case CustomFieldType.SELECT: + return getFirstSelectOptionValue(definition.options) + case CustomFieldType.URL: + return `https://example.com/${definition.name}` + case CustomFieldType.TEXTAREA: + return `Seeded ${definition.name.replaceAll('_', ' ')} configuration.` + case CustomFieldType.TEXT: + return `Seeded ${definition.name.replaceAll('_', ' ')}` + } +} + +function getOverrideValue( + definition: CustomFieldDefinitionForSeed, + seedIndex: number, +): Prisma.InputJsonValue | undefined { + const values = CUSTOM_FIELD_VALUE_OVERRIDES[definition.name] + if (!values || values.length === 0) return undefined + + const value = values[seedIndex % values.length] + if (definition.type !== CustomFieldType.SELECT) return value + + const optionValues = getSelectOptionValues(definition.options) + if (optionValues.length === 0) return value + return typeof value === 'string' && optionValues.includes(value) ? value : optionValues[0] +} + +function getRangeValue(definition: CustomFieldDefinitionForSeed): number { + if (typeof definition.defaultValue === 'number') return definition.defaultValue + + const min = definition.rangeMin ?? 0 + const max = definition.rangeMax ?? min + return Math.round((min + max) / 2) +} + +function getFirstSelectOptionValue(options: Prisma.JsonValue | null): string | undefined { + return getSelectOptionValues(options)[0] +} + +function getSelectOptionValues(options: Prisma.JsonValue | null): string[] { + if (!Array.isArray(options)) return [] + + return options.flatMap((option) => { + if (!isJsonObject(option)) return [] + return typeof option.value === 'string' ? [option.value] : [] + }) +} + +function isJsonPrimitive(value: Prisma.JsonValue | null): value is string | number | boolean { + return typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean' +} + +function isJsonObject(value: Prisma.JsonValue): value is Prisma.JsonObject { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} diff --git a/prisma/seeders/edenCustomFieldsSeeder.ts b/prisma/seeders/edenCustomFieldsSeeder.ts index befa8ef71..a9aded71b 100644 --- a/prisma/seeders/edenCustomFieldsSeeder.ts +++ b/prisma/seeders/edenCustomFieldsSeeder.ts @@ -1,4 +1,5 @@ import { CustomFieldType, type Prisma, type PrismaClient } from '@orm/client' +import { syncCustomFieldCategories, type CustomFieldCategorySeed } from './customFieldCategoryUtils' interface SelectOption { value: string @@ -18,6 +19,8 @@ interface EdenCustomFieldSeed { type: CustomFieldType required: boolean displayOrder: number + categoryName?: string | null + categoryOrder?: number defaultValue?: string | number | boolean | null placeholder?: string | null options?: SelectOption[] @@ -26,6 +29,14 @@ interface EdenCustomFieldSeed { const EDEN_EMULATOR_NAME = 'Eden' +const EDEN_CUSTOM_FIELD_CATEGORIES: CustomFieldCategorySeed[] = [ + { name: 'General', displayOrder: 0 }, + { name: 'Graphics', displayOrder: 1 }, + { name: 'Debug', displayOrder: 2 }, + { name: 'CPU', displayOrder: 3 }, + { name: 'Eden Veil', displayOrder: 4 }, +] + const EDEN_CUSTOM_FIELDS: EdenCustomFieldSeed[] = [ { name: 'emulator_version', @@ -35,6 +46,8 @@ const EDEN_CUSTOM_FIELDS: EdenCustomFieldSeed[] = [ defaultValue: null, placeholder: '0.0.2-pre-alpha', displayOrder: 0, + categoryName: 'Graphics', + categoryOrder: 4, }, { name: 'dynamic_driver_version', @@ -44,6 +57,8 @@ const EDEN_CUSTOM_FIELDS: EdenCustomFieldSeed[] = [ defaultValue: null, placeholder: 'Select first options for non-Android devices', displayOrder: 1, + categoryName: 'Eden Veil', + categoryOrder: 5, }, { name: 'accuracy_level', @@ -57,6 +72,8 @@ const EDEN_CUSTOM_FIELDS: EdenCustomFieldSeed[] = [ { value: 'Extreme (Slow)', label: 'Extreme (Slow)' }, ], displayOrder: 2, + categoryName: 'CPU', + categoryOrder: 2, }, { name: 'resolution', @@ -65,6 +82,8 @@ const EDEN_CUSTOM_FIELDS: EdenCustomFieldSeed[] = [ required: true, defaultValue: null, displayOrder: 3, + categoryName: 'General', + categoryOrder: 0, }, { name: 'vsync_mode', @@ -80,6 +99,8 @@ const EDEN_CUSTOM_FIELDS: EdenCustomFieldSeed[] = [ { value: 'N/A', label: 'N/A' }, ], displayOrder: 4, + categoryName: 'Graphics', + categoryOrder: 5, }, { name: 'window_adapting_filter', @@ -98,6 +119,8 @@ const EDEN_CUSTOM_FIELDS: EdenCustomFieldSeed[] = [ { value: 'Other', label: 'Other' }, ], displayOrder: 5, + categoryName: 'Graphics', + categoryOrder: 3, }, { name: 'anti_aliasing_method', @@ -112,6 +135,8 @@ const EDEN_CUSTOM_FIELDS: EdenCustomFieldSeed[] = [ { value: 'Other', label: 'Other' }, ], displayOrder: 6, + categoryName: 'General', + categoryOrder: 0, }, { name: 'anisotropic_filtering', @@ -128,6 +153,8 @@ const EDEN_CUSTOM_FIELDS: EdenCustomFieldSeed[] = [ { value: '16x', label: '16x' }, ], displayOrder: 7, + categoryName: 'Graphics', + categoryOrder: 8, }, { name: 'disk_shader_cache', @@ -136,6 +163,8 @@ const EDEN_CUSTOM_FIELDS: EdenCustomFieldSeed[] = [ required: true, defaultValue: null, displayOrder: 8, + categoryName: 'Eden Veil', + categoryOrder: 3, }, { name: 'use_async_shaders', @@ -144,6 +173,8 @@ const EDEN_CUSTOM_FIELDS: EdenCustomFieldSeed[] = [ required: true, defaultValue: null, displayOrder: 9, + categoryName: 'Eden Veil', + categoryOrder: 4, }, { name: 'use_reactive_flushing', @@ -152,6 +183,8 @@ const EDEN_CUSTOM_FIELDS: EdenCustomFieldSeed[] = [ required: true, defaultValue: null, displayOrder: 10, + categoryName: 'Graphics', + categoryOrder: 6, }, { name: 'docked_mode', @@ -160,6 +193,8 @@ const EDEN_CUSTOM_FIELDS: EdenCustomFieldSeed[] = [ required: true, defaultValue: null, displayOrder: 11, + categoryName: 'Eden Veil', + categoryOrder: 6, }, { name: 'audio_output_engine', @@ -174,6 +209,8 @@ const EDEN_CUSTOM_FIELDS: EdenCustomFieldSeed[] = [ { value: 'Null', label: 'Null' }, ], displayOrder: 12, + categoryName: 'Graphics', + categoryOrder: 7, }, { name: 'gpu_api', @@ -187,6 +224,8 @@ const EDEN_CUSTOM_FIELDS: EdenCustomFieldSeed[] = [ { value: 'Other', label: 'Other' }, ], displayOrder: 13, + categoryName: 'General', + categoryOrder: 1, }, { name: 'cpu_accuracy', @@ -201,6 +240,8 @@ const EDEN_CUSTOM_FIELDS: EdenCustomFieldSeed[] = [ { value: 'Paranoid (Slow)', label: 'Paranoid (Slow)' }, ], displayOrder: 14, + categoryName: 'General', + categoryOrder: 2, }, { name: 'cpu_backend', @@ -213,6 +254,8 @@ const EDEN_CUSTOM_FIELDS: EdenCustomFieldSeed[] = [ { value: 'Native code execution (NCE)', label: 'Native code execution (NCE)' }, ], displayOrder: 15, + categoryName: 'CPU', + categoryOrder: 0, }, { name: 'extended_dynamic_state', @@ -222,6 +265,8 @@ const EDEN_CUSTOM_FIELDS: EdenCustomFieldSeed[] = [ defaultValue: 0, range: { min: 0, max: 3, unit: '', decimals: 0 }, displayOrder: 16, + categoryName: 'CPU', + categoryOrder: 1, }, { name: 'provoking_vertex', @@ -230,6 +275,8 @@ const EDEN_CUSTOM_FIELDS: EdenCustomFieldSeed[] = [ required: true, defaultValue: false, displayOrder: 17, + categoryName: 'Eden Veil', + categoryOrder: 7, }, { name: 'descriptor_indexing', @@ -238,6 +285,8 @@ const EDEN_CUSTOM_FIELDS: EdenCustomFieldSeed[] = [ required: true, defaultValue: false, displayOrder: 18, + categoryName: 'Eden Veil', + categoryOrder: 8, }, { name: 'enhanced_frame_pacing', @@ -246,6 +295,8 @@ const EDEN_CUSTOM_FIELDS: EdenCustomFieldSeed[] = [ required: true, defaultValue: true, displayOrder: 19, + categoryName: 'Eden Veil', + categoryOrder: 9, }, { name: 'use_fast_gpu_time', @@ -254,6 +305,8 @@ const EDEN_CUSTOM_FIELDS: EdenCustomFieldSeed[] = [ required: true, defaultValue: false, displayOrder: 20, + categoryName: 'General', + categoryOrder: 2, }, { name: 'nvdec_emulation', @@ -267,6 +320,8 @@ const EDEN_CUSTOM_FIELDS: EdenCustomFieldSeed[] = [ { value: 'GPU', label: 'GPU' }, ], displayOrder: 21, + categoryName: 'Debug', + categoryOrder: 0, }, { name: 'astc_recompression_method', @@ -280,6 +335,8 @@ const EDEN_CUSTOM_FIELDS: EdenCustomFieldSeed[] = [ { value: 'BC3 (Medium Quality)', label: 'BC3 (Medium Quality)' }, ], displayOrder: 22, + categoryName: 'CPU', + categoryOrder: 3, }, { name: 'vram_usage_mode', @@ -292,6 +349,8 @@ const EDEN_CUSTOM_FIELDS: EdenCustomFieldSeed[] = [ { value: 'Aggressive', label: 'Aggressive' }, ], displayOrder: 23, + categoryName: 'General', + categoryOrder: 3, }, { name: 'optimize_spirv_output', @@ -305,6 +364,8 @@ const EDEN_CUSTOM_FIELDS: EdenCustomFieldSeed[] = [ { value: 'Always', label: 'Always' }, ], displayOrder: 24, + categoryName: 'Eden Veil', + categoryOrder: 10, }, { name: 'fast_cpu_time', @@ -313,6 +374,8 @@ const EDEN_CUSTOM_FIELDS: EdenCustomFieldSeed[] = [ required: true, defaultValue: false, displayOrder: 25, + categoryName: 'CPU', + categoryOrder: 4, }, { name: 'enable_lru_cache', @@ -321,6 +384,8 @@ const EDEN_CUSTOM_FIELDS: EdenCustomFieldSeed[] = [ required: true, defaultValue: false, displayOrder: 26, + categoryName: 'Debug', + categoryOrder: 1, }, { name: 'synchronize_core_speed', @@ -378,6 +443,11 @@ export default async function edenCustomFieldsSeeder(prisma: PrismaClient) { } const fieldNames = EDEN_CUSTOM_FIELDS.map((field) => field.name) + const categoryIdByName = await syncCustomFieldCategories( + prisma, + eden.id, + EDEN_CUSTOM_FIELD_CATEGORIES, + ) for (const field of EDEN_CUSTOM_FIELDS) { await prisma.customFieldDefinition.upsert({ @@ -387,8 +457,8 @@ export default async function edenCustomFieldsSeeder(prisma: PrismaClient) { name: field.name, }, }, - create: buildDefinitionCreate(eden.id, field), - update: buildDefinitionUpdate(field), + create: buildDefinitionCreate(eden.id, field, categoryIdByName), + update: buildDefinitionUpdate(field, categoryIdByName), }) } @@ -404,11 +474,16 @@ export default async function edenCustomFieldsSeeder(prisma: PrismaClient) { ) } -function buildDefinitionCreate(emulatorId: string, field: EdenCustomFieldSeed) { - const { options, range, defaultValue, placeholder, ...base } = field +function buildDefinitionCreate( + emulatorId: string, + field: EdenCustomFieldSeed, + categoryIdByName: ReadonlyMap, +) { + const { options, range, defaultValue, placeholder, categoryOrder, ...base } = field return { emulatorId, + categoryId: resolveCategoryId(field, categoryIdByName), name: base.name, label: base.label, type: base.type, @@ -421,13 +496,18 @@ function buildDefinitionCreate(emulatorId: string, field: EdenCustomFieldSeed) { rangeUnit: range?.unit ?? null, isRequired: base.required, displayOrder: base.displayOrder, + categoryOrder: categoryOrder ?? 0, } } -function buildDefinitionUpdate(field: EdenCustomFieldSeed) { - const { options, range, defaultValue, placeholder, ...base } = field +function buildDefinitionUpdate( + field: EdenCustomFieldSeed, + categoryIdByName: ReadonlyMap, +) { + const { options, range, defaultValue, placeholder, categoryOrder, ...base } = field return { + categoryId: resolveCategoryId(field, categoryIdByName), label: base.label, type: base.type, options: normalizeJsonInput(options), @@ -439,7 +519,22 @@ function buildDefinitionUpdate(field: EdenCustomFieldSeed) { rangeUnit: range?.unit ?? null, isRequired: base.required, displayOrder: base.displayOrder, + categoryOrder: categoryOrder ?? 0, + } +} + +function resolveCategoryId( + field: EdenCustomFieldSeed, + categoryIdByName: ReadonlyMap, +) { + if (!field.categoryName) return null + + const categoryId = categoryIdByName.get(field.categoryName) + if (!categoryId) { + throw new Error(`Missing Eden custom field category "${field.categoryName}" for ${field.name}`) } + + return categoryId } function normalizeJsonInput( diff --git a/prisma/seeders/gamenativeCustomFieldsSeeder.ts b/prisma/seeders/gamenativeCustomFieldsSeeder.ts index 495edc455..8ca275a4a 100644 --- a/prisma/seeders/gamenativeCustomFieldsSeeder.ts +++ b/prisma/seeders/gamenativeCustomFieldsSeeder.ts @@ -1,4 +1,5 @@ import { CustomFieldType, Prisma, type PrismaClient } from '@orm/client' +import { syncCustomFieldCategories, type CustomFieldCategorySeed } from './customFieldCategoryUtils' interface SelectOption { value: string @@ -18,6 +19,50 @@ interface GameNativeCustomFieldSeed { const GAMENATIVE_EMULATOR_NAME = 'GameNative' +const GAMENATIVE_CUSTOM_FIELD_CATEGORIES: CustomFieldCategorySeed[] = [ + { name: 'General', displayOrder: 0 }, + { name: 'Graphics', displayOrder: 1 }, + { name: 'Runtime', displayOrder: 2 }, + { name: 'Input', displayOrder: 3 }, + { name: 'Media', displayOrder: 4 }, +] + +const GAMENATIVE_FIELD_CATEGORY_CONFIG: Record< + string, + { categoryName: string; categoryOrder: number } +> = { + emulator_version: { categoryName: 'General', categoryOrder: 0 }, + game_version: { categoryName: 'General', categoryOrder: 1 }, + average_fps: { categoryName: 'General', categoryOrder: 2 }, + resolution: { categoryName: 'General', categoryOrder: 3 }, + audio_driver: { categoryName: 'General', categoryOrder: 4 }, + graphics_driver: { categoryName: 'Graphics', categoryOrder: 0 }, + dynamic_driver_version: { categoryName: 'Graphics', categoryOrder: 1 }, + dx_wrapper: { categoryName: 'Graphics', categoryOrder: 2 }, + dxvk_version: { categoryName: 'Graphics', categoryOrder: 3 }, + dx_wrapper_config: { categoryName: 'Graphics', categoryOrder: 4 }, + max_device_memory: { categoryName: 'Graphics', categoryOrder: 5 }, + use_adrenotools_turnip: { categoryName: 'Graphics', categoryOrder: 6 }, + env_variables: { categoryName: 'Runtime', categoryOrder: 0 }, + box64_version: { categoryName: 'Runtime', categoryOrder: 1 }, + box64_preset: { categoryName: 'Runtime', categoryOrder: 2 }, + startup_selection: { categoryName: 'Runtime', categoryOrder: 3 }, + container_variant: { categoryName: 'Runtime', categoryOrder: 4 }, + wine_version: { categoryName: 'Runtime', categoryOrder: 5 }, + steam_type: { categoryName: 'Runtime', categoryOrder: 6 }, + fex_core_version: { categoryName: 'Runtime', categoryOrder: 7 }, + '32_bit_emulator': { categoryName: 'Runtime', categoryOrder: 8 }, + '64_bit_emulator': { categoryName: 'Runtime', categoryOrder: 9 }, + fex_core_preset: { categoryName: 'Runtime', categoryOrder: 10 }, + exec_arguments: { categoryName: 'Runtime', categoryOrder: 11 }, + use_steam_input: { categoryName: 'Input', categoryOrder: 0 }, + enable_x_input_api: { categoryName: 'Input', categoryOrder: 1 }, + enable_direct_input_api: { categoryName: 'Input', categoryOrder: 2 }, + direct_input_mapper_type: { categoryName: 'Input', categoryOrder: 3 }, + youtube: { categoryName: 'Media', categoryOrder: 0 }, + media_url: { categoryName: 'Media', categoryOrder: 1 }, +} + const GAMENATIVE_CUSTOM_FIELDS: GameNativeCustomFieldSeed[] = [ { name: 'emulator_version', @@ -391,6 +436,11 @@ export default async function gamenativeCustomFieldsSeeder(prisma: PrismaClient) } const fieldNames = GAMENATIVE_CUSTOM_FIELDS.map((field) => field.name) + const categoryIdByName = await syncCustomFieldCategories( + prisma, + gamenative.id, + GAMENATIVE_CUSTOM_FIELD_CATEGORIES, + ) for (const field of GAMENATIVE_CUSTOM_FIELDS) { await prisma.customFieldDefinition.upsert({ @@ -400,8 +450,8 @@ export default async function gamenativeCustomFieldsSeeder(prisma: PrismaClient) name: field.name, }, }, - create: buildDefinitionCreate(gamenative.id, field), - update: buildDefinitionUpdate(field), + create: buildDefinitionCreate(gamenative.id, field, categoryIdByName), + update: buildDefinitionUpdate(field, categoryIdByName), }) } @@ -441,11 +491,17 @@ export default async function gamenativeCustomFieldsSeeder(prisma: PrismaClient) ) } -function buildDefinitionCreate(emulatorId: string, field: GameNativeCustomFieldSeed) { +function buildDefinitionCreate( + emulatorId: string, + field: GameNativeCustomFieldSeed, + categoryIdByName: ReadonlyMap, +) { const { options, defaultValue, placeholder, ...base } = field + const categoryConfig = GAMENATIVE_FIELD_CATEGORY_CONFIG[field.name] return { emulatorId, + categoryId: resolveCategoryId(field.name, categoryConfig?.categoryName, categoryIdByName), name: base.name, label: base.label, type: base.type, @@ -458,13 +514,19 @@ function buildDefinitionCreate(emulatorId: string, field: GameNativeCustomFieldS rangeUnit: null, isRequired: base.required, displayOrder: base.displayOrder, + categoryOrder: categoryConfig?.categoryOrder ?? 0, } } -function buildDefinitionUpdate(field: GameNativeCustomFieldSeed) { +function buildDefinitionUpdate( + field: GameNativeCustomFieldSeed, + categoryIdByName: ReadonlyMap, +) { const { options, defaultValue, placeholder, ...base } = field + const categoryConfig = GAMENATIVE_FIELD_CATEGORY_CONFIG[field.name] return { + categoryId: resolveCategoryId(field.name, categoryConfig?.categoryName, categoryIdByName), label: base.label, type: base.type, options: normalizeJsonInput(options), @@ -476,9 +538,25 @@ function buildDefinitionUpdate(field: GameNativeCustomFieldSeed) { rangeUnit: null, isRequired: base.required, displayOrder: base.displayOrder, + categoryOrder: categoryConfig?.categoryOrder ?? 0, } } +function resolveCategoryId( + fieldName: string, + categoryName: string | undefined, + categoryIdByName: ReadonlyMap, +) { + if (!categoryName) return null + + const categoryId = categoryIdByName.get(categoryName) + if (!categoryId) { + throw new Error(`Missing GameNative custom field category "${categoryName}" for ${fieldName}`) + } + + return categoryId +} + function normalizeJsonInput( value: unknown, ): Prisma.NullableJsonNullValueInput | Prisma.InputJsonValue | undefined { diff --git a/prisma/seeders/listingsSeeder.ts b/prisma/seeders/listingsSeeder.ts index f8079c7e8..63356c811 100644 --- a/prisma/seeders/listingsSeeder.ts +++ b/prisma/seeders/listingsSeeder.ts @@ -1,4 +1,8 @@ import { Role, ApprovalStatus, type PrismaClient } from '@orm/client' +import { + createCustomFieldDefinitionCache, + seedListingCustomFieldValues, +} from './customFieldValueSeederUtils' function getRandomElement(array: T[]): T { return array[Math.floor(Math.random() * array.length)] @@ -86,11 +90,36 @@ const sampleComments = [ 'Anyone tried this with different emulator versions?', ] -async function createVotesAndComments( - prisma: PrismaClient, - listingId: string, - users: { id: string; role: Role }[], -) { +const customFieldListingFixtures = [ + { + emulatorName: 'Azahar', + systemName: 'Nintendo 3DS', + notes: 'Azahar fixture with custom graphics and CPU field values.', + }, + { + emulatorName: 'Eden', + systemName: 'Nintendo Switch', + notes: 'Eden fixture with categorized graphics, CPU, and Eden Veil values.', + }, + { + emulatorName: 'GameNative', + systemName: 'Microsoft Windows', + notes: 'GameNative fixture with runtime and graphics compatibility values.', + }, +] as const + +type SeedUser = { id: string; role: Role } +type SeedDevice = { id: string; brand: { name: string }; modelName: string } +type SeedGame = { + id: string + title: string + systemId: string + system: { id: string; name: string } +} +type SeedEmulator = { id: string; name: string; systems: { id: string }[] } +type SeedPerformanceScale = { id: number; label: string } + +async function createVotesAndComments(prisma: PrismaClient, listingId: string, users: SeedUser[]) { // Add some random votes (60% upvotes, 40% downvotes) const votersCount = Math.floor(Math.random() * 8) + 2 // 2-9 voters const voters = getRandomUniqueElements(users, votersCount) @@ -154,6 +183,7 @@ async function listingsSeeder(prisma: PrismaClient) { }) const performanceScales = await prisma.performanceScale.findMany() + const customFieldDefinitionCache = createCustomFieldDefinitionCache() console.info( `📊 Found ${devices.length} devices, ${games.length} games, ${emulators.length} emulators`, @@ -162,6 +192,7 @@ async function listingsSeeder(prisma: PrismaClient) { let totalListingsCreated = 0 let totalVotesCreated = 0 let totalCommentsCreated = 0 + let totalCustomFieldValuesSynced = 0 // Create 2 listings per device for (const device of devices) { @@ -236,6 +267,13 @@ async function listingsSeeder(prisma: PrismaClient) { }) totalListingsCreated++ + totalCustomFieldValuesSynced += await seedListingCustomFieldValues( + prisma, + customFieldDefinitionCache, + listing.id, + selectedEmulator.id, + totalListingsCreated, + ) // Add votes and comments to this listing const votesBefore = await prisma.vote.count() @@ -261,12 +299,91 @@ async function listingsSeeder(prisma: PrismaClient) { } } + const fixtureResult = await createCustomFieldListingFixtures({ + prisma, + users, + adminUsers, + devices, + games, + emulators, + performanceScales, + customFieldDefinitionCache, + seedIndexStart: totalListingsCreated, + }) + totalListingsCreated += fixtureResult.listingsCreated + totalCustomFieldValuesSynced += fixtureResult.customFieldValuesSynced + console.info('✅ Listings seeding completed!') console.info(`📈 Statistics:`) console.info(` 📝 ${totalListingsCreated} listings created`) + console.info(` ⚙️ ${totalCustomFieldValuesSynced} custom field values synced`) console.info(` 👍 ${totalVotesCreated} votes added`) console.info(` 💬 ${totalCommentsCreated} comments added`) console.info(` ✅ ~50% of listings are auto-approved for testing`) } +interface CustomFieldListingFixtureInput { + prisma: PrismaClient + users: SeedUser[] + adminUsers: SeedUser[] + devices: SeedDevice[] + games: SeedGame[] + emulators: SeedEmulator[] + performanceScales: SeedPerformanceScale[] + customFieldDefinitionCache: ReturnType + seedIndexStart: number +} + +async function createCustomFieldListingFixtures(input: CustomFieldListingFixtureInput) { + let listingsCreated = 0 + let customFieldValuesSynced = 0 + + const approvedPerformance = + input.performanceScales.find((scale) => scale.label === 'Great') ?? input.performanceScales[0] + const processor = input.adminUsers[0] + const author = input.users[0] + const device = input.devices[0] + + if (!approvedPerformance || !processor || !author || !device) { + console.warn('Missing fixture dependencies, skipping custom field listing fixtures') + return { listingsCreated, customFieldValuesSynced } + } + + for (const fixture of customFieldListingFixtures) { + const emulator = input.emulators.find((candidate) => candidate.name === fixture.emulatorName) + const game = input.games.find((candidate) => candidate.system.name === fixture.systemName) + + if (!emulator || !game) { + console.warn(`Skipping ${fixture.emulatorName} fixture, missing emulator or game data`) + continue + } + + const listing = await input.prisma.listing.create({ + data: { + gameId: game.id, + deviceId: device.id, + emulatorId: emulator.id, + performanceId: approvedPerformance.id, + notes: fixture.notes, + authorId: author.id, + status: ApprovalStatus.APPROVED, + processedAt: new Date(), + processedNotes: 'Seeded custom field fixture.', + processedByUserId: processor.id, + }, + }) + + listingsCreated += 1 + customFieldValuesSynced += await seedListingCustomFieldValues( + input.prisma, + input.customFieldDefinitionCache, + listing.id, + emulator.id, + input.seedIndexStart + listingsCreated, + ) + } + + return { listingsCreated, customFieldValuesSynced } +} + export default listingsSeeder diff --git a/prisma/seeders/pcListingsSeeder.ts b/prisma/seeders/pcListingsSeeder.ts new file mode 100644 index 000000000..00c1b6cc1 --- /dev/null +++ b/prisma/seeders/pcListingsSeeder.ts @@ -0,0 +1,180 @@ +import { calculateWilsonScore } from '@/utils/wilson-score' +import { ApprovalStatus, PcOs, Role, type PrismaClient } from '@orm/client' +import { + createCustomFieldDefinitionCache, + seedPcListingCustomFieldValues, +} from './customFieldValueSeederUtils' + +type SeedUser = { id: string; role: Role } + +const pcListingFixtures = [ + { + emulatorName: 'Eden', + gameTitle: 'Hades', + systemName: 'Nintendo Switch', + cpuModelName: 'Core i7-12700K', + gpuModelName: 'GeForce RTX 4070', + performanceLabel: 'Great', + memorySize: 16, + os: PcOs.LINUX, + osVersion: 'SteamOS 3.6', + notes: 'Seeded PC report for Eden with categorized graphics, CPU, and Eden Veil values.', + }, + { + emulatorName: 'Azahar', + gameTitle: 'The Legend of Zelda: Ocarina of Time 3D', + systemName: 'Nintendo 3DS', + cpuModelName: 'Core i5-12400', + gpuModelName: 'Radeon RX 7800 XT', + performanceLabel: 'Perfect', + memorySize: 32, + os: PcOs.WINDOWS, + osVersion: 'Windows 11 24H2', + notes: 'Seeded PC report for Azahar with categorized graphics and CPU values.', + }, +] as const + +export default async function pcListingsSeeder(prisma: PrismaClient) { + console.info('🌱 Seeding PC listings...') + + const users = await prisma.user.findMany({ select: { id: true, role: true } }) + const adminUsers = users.filter( + (user) => user.role === Role.ADMIN || user.role === Role.SUPER_ADMIN, + ) + const author = users[0] + const processor = adminUsers[0] + + if (!author || !processor) { + console.warn('No seed users or admin users found, skipping PC listings seeding') + return + } + + const customFieldDefinitionCache = createCustomFieldDefinitionCache() + let pcListingsCreated = 0 + let customFieldValuesSynced = 0 + let votesCreated = 0 + let commentsCreated = 0 + + for (const fixture of pcListingFixtures) { + const [game, emulator, cpu, gpu, performance] = await Promise.all([ + prisma.game.findFirst({ + where: { title: fixture.gameTitle, system: { name: fixture.systemName } }, + select: { id: true }, + }), + prisma.emulator.findUnique({ + where: { name: fixture.emulatorName }, + select: { id: true }, + }), + prisma.cpu.findFirst({ + where: { modelName: fixture.cpuModelName }, + select: { id: true }, + }), + prisma.gpu.findFirst({ + where: { modelName: fixture.gpuModelName }, + select: { id: true }, + }), + prisma.performanceScale.findUnique({ + where: { label: fixture.performanceLabel }, + select: { id: true }, + }), + ]) + + if (!game || !emulator || !cpu || !gpu || !performance) { + console.warn(`Skipping PC listing fixture for ${fixture.gameTitle}; missing dependency`) + continue + } + + const pcListing = await prisma.pcListing.create({ + data: { + gameId: game.id, + emulatorId: emulator.id, + cpuId: cpu.id, + gpuId: gpu.id, + performanceId: performance.id, + memorySize: fixture.memorySize, + os: fixture.os, + osVersion: fixture.osVersion, + notes: fixture.notes, + authorId: author.id, + status: ApprovalStatus.APPROVED, + processedAt: new Date(), + processedNotes: 'Seeded PC report fixture.', + processedByUserId: processor.id, + }, + }) + + pcListingsCreated += 1 + customFieldValuesSynced += await seedPcListingCustomFieldValues( + prisma, + customFieldDefinitionCache, + pcListing.id, + emulator.id, + pcListingsCreated, + ) + + const engagement = await createPcListingEngagement(prisma, pcListing.id, users) + votesCreated += engagement.votesCreated + commentsCreated += engagement.commentsCreated + } + + console.info('✅ PC listings seeding completed!') + console.info(`📈 PC listing statistics:`) + console.info(` 🖥️ ${pcListingsCreated} PC listings created`) + console.info(` ⚙️ ${customFieldValuesSynced} custom field values synced`) + console.info(` 👍 ${votesCreated} votes added`) + console.info(` 💬 ${commentsCreated} comments added`) +} + +async function createPcListingEngagement( + prisma: PrismaClient, + pcListingId: string, + users: SeedUser[], +) { + let votesCreated = 0 + let commentsCreated = 0 + let upvotesCreated = 0 + let downvotesCreated = 0 + const voters = users.slice(0, 4) + + for (const [index, voter] of voters.entries()) { + const isUpvote = index !== voters.length - 1 + await prisma.pcListingVote.create({ + data: { + pcListingId, + userId: voter.id, + value: isUpvote, + }, + }) + votesCreated += 1 + if (isUpvote) { + upvotesCreated += 1 + } else { + downvotesCreated += 1 + } + } + + await prisma.pcListing.update({ + where: { id: pcListingId }, + data: { + upvoteCount: upvotesCreated, + downvoteCount: downvotesCreated, + voteCount: votesCreated, + successRate: calculateWilsonScore(upvotesCreated, downvotesCreated), + }, + }) + + const commenter = users[1] ?? users[0] + if (commenter) { + await prisma.pcListingComment.create({ + data: { + pcListingId, + userId: commenter.id, + content: 'Seeded PC report comment for local testing.', + score: 2, + }, + }) + commentsCreated += 1 + } + + return { votesCreated, commentsCreated } +} diff --git a/sentry.edge.config.ts b/sentry.edge.config.ts index fd56d752e..f24441da4 100644 --- a/sentry.edge.config.ts +++ b/sentry.edge.config.ts @@ -5,18 +5,18 @@ import * as Sentry from '@sentry/nextjs' -Sentry.init({ - dsn: 'https://85ca585e45005d8786e361c3456518bf@o74828.ingest.us.sentry.io/4509717207318529', +// Only enable Sentry when explicitly configured for this deployment. +if (process.env.NEXT_PUBLIC_ENABLE_SENTRY === 'true') { + Sentry.init({ + dsn: 'https://85ca585e45005d8786e361c3456518bf@o74828.ingest.us.sentry.io/4509717207318529', - // Only enable Sentry in production - enabled: process.env.NODE_ENV === 'production', + // Define how likely traces are sampled. Adjust this value in production, or use tracesSampler for greater control. + tracesSampleRate: 1, - // 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, - // Enable logs to be sent to Sentry - enableLogs: true, - - // Setting this option to true will print useful information to the console while you're setting up Sentry. - debug: false, -}) + // Setting this option to true will print useful information to the console while you're setting up Sentry. + debug: false, + }) +} diff --git a/sentry.server.config.ts b/sentry.server.config.ts index a2304c1cc..15f1fcbb8 100644 --- a/sentry.server.config.ts +++ b/sentry.server.config.ts @@ -4,18 +4,18 @@ import * as Sentry from '@sentry/nextjs' -Sentry.init({ - dsn: 'https://85ca585e45005d8786e361c3456518bf@o74828.ingest.us.sentry.io/4509717207318529', +// Only enable Sentry when explicitly configured for this deployment. +if (process.env.NEXT_PUBLIC_ENABLE_SENTRY === 'true') { + Sentry.init({ + dsn: 'https://85ca585e45005d8786e361c3456518bf@o74828.ingest.us.sentry.io/4509717207318529', - // Only enable Sentry in production - enabled: process.env.NODE_ENV === 'production', + // Define how likely traces are sampled. Adjust this value in production, or use tracesSampler for greater control. + tracesSampleRate: 1, - // 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, - // Enable logs to be sent to Sentry - enableLogs: true, - - // Setting this option to true will print useful information to the console while you're setting up Sentry. - debug: false, -}) + // Setting this option to true will print useful information to the console while you're setting up Sentry. + debug: false, + }) +} diff --git a/src/app/admin/brands/page.tsx b/src/app/admin/brands/page.tsx index 34b451081..ee3428ecc 100644 --- a/src/app/admin/brands/page.tsx +++ b/src/app/admin/brands/page.tsx @@ -67,9 +67,19 @@ function AdminBrandsPage() { const utils = api.useUtils() - const handleModalSuccess = () => { + const invalidateBrandQueries = () => { utils.deviceBrands.get.invalidate().catch(console.error) utils.deviceBrands.stats.invalidate().catch(console.error) + utils.devices.get.invalidate().catch(console.error) + utils.devices.options.invalidate().catch(console.error) + utils.cpus.get.invalidate().catch(console.error) + utils.cpus.options.invalidate().catch(console.error) + utils.gpus.get.invalidate().catch(console.error) + utils.gpus.options.invalidate().catch(console.error) + } + + const handleModalSuccess = () => { + invalidateBrandQueries() closeModal() } @@ -85,8 +95,7 @@ function AdminBrandsPage() { await deleteBrand.mutateAsync({ id, } satisfies RouterInput['deviceBrands']['delete']) - utils.deviceBrands.get.invalidate().catch(console.error) - utils.deviceBrands.stats.invalidate().catch(console.error) + invalidateBrandQueries() } catch (err) { toast.error(`Failed to delete brand: ${getErrorMessage(err)}`) } diff --git a/src/app/admin/cpus/page.tsx b/src/app/admin/cpus/page.tsx index 8a8d8cdd3..2c5b7f61c 100644 --- a/src/app/admin/cpus/page.tsx +++ b/src/app/admin/cpus/page.tsx @@ -83,6 +83,12 @@ function AdminCpusPage() { ['intel', 'amd', 'apple'].includes(brand.name.toLowerCase()), ) + const invalidateCpuQueries = () => { + utils.cpus.get.invalidate().catch(console.error) + utils.cpus.options.invalidate().catch(console.error) + utils.cpus.stats.invalidate().catch(console.error) + } + const openModal = (cpu?: CpuData) => { setEditId(cpu?.id ?? null) setCpuData(cpu ?? null) @@ -106,8 +112,7 @@ function AdminCpusPage() { } const handleModalSuccess = () => { - utils.cpus.get.invalidate().catch(console.error) - utils.cpus.stats.invalidate().catch(console.error) + invalidateCpuQueries() closeModal() } @@ -123,8 +128,7 @@ function AdminCpusPage() { await deleteCpu.mutateAsync({ id, } satisfies RouterInput['cpus']['delete']) - utils.cpus.get.invalidate().catch(console.error) - utils.cpus.stats.invalidate().catch(console.error) + invalidateCpuQueries() toast.success('CPU deleted successfully!') } catch (err) { toast.error(`Failed to delete CPU: ${getErrorMessage(err)}`) diff --git a/src/app/admin/devices/components/DeviceModal.tsx b/src/app/admin/devices/components/DeviceModal.tsx index df2122231..d697457a4 100644 --- a/src/app/admin/devices/components/DeviceModal.tsx +++ b/src/app/admin/devices/components/DeviceModal.tsx @@ -20,7 +20,8 @@ function DeviceModal(props: Props) { const createDevice = api.devices.create.useMutation() const updateDevice = api.devices.update.useMutation() const deviceBrandsQuery = api.deviceBrands.get.useQuery({ limit: 100 }) - const socsQuery = api.socs.get.useQuery({ limit: 1000 }) + // TODO: Make this selector async instead of preloading 1000 options. + const socsQuery = api.socs.options.useQuery({ limit: 1000 }) const [brandId, setBrandId] = useState('') const [modelName, setModelName] = useState('') diff --git a/src/app/admin/devices/page.tsx b/src/app/admin/devices/page.tsx index f4c835e3a..dd3834741 100644 --- a/src/app/admin/devices/page.tsx +++ b/src/app/admin/devices/page.tsx @@ -76,6 +76,12 @@ function AdminDevicesPage() { const userQuery = api.users.me.useQuery() const canManageDevices = hasPermission(userQuery.data?.permissions, PERMISSIONS.MANAGE_DEVICES) + const invalidateDeviceQueries = () => { + utils.devices.get.invalidate().catch(console.error) + utils.devices.options.invalidate().catch(console.error) + utils.devices.stats.invalidate().catch(console.error) + } + const openModal = (device?: DeviceData) => { setEditId(device?.id ?? null) setDeviceData(device ?? null) @@ -99,9 +105,7 @@ function AdminDevicesPage() { } const handleModalSuccess = () => { - // Invalidate queries to refetch fresh data - utils.devices.get.invalidate().catch(console.error) - utils.devices.stats.invalidate().catch(console.error) + invalidateDeviceQueries() closeModal() } @@ -117,8 +121,7 @@ function AdminDevicesPage() { await deleteDevice.mutateAsync({ id, } satisfies RouterInput['devices']['delete']) - utils.devices.get.invalidate().catch(console.error) - utils.devices.stats.invalidate().catch(console.error) + invalidateDeviceQueries() toast.success('Device deleted successfully!') } catch (err) { toast.error(`Failed to delete device: ${getErrorMessage(err)}`) diff --git a/src/app/admin/gpus/page.tsx b/src/app/admin/gpus/page.tsx index b30f7e2c6..6fb4bc8f2 100644 --- a/src/app/admin/gpus/page.tsx +++ b/src/app/admin/gpus/page.tsx @@ -84,6 +84,12 @@ function AdminGpusPage() { ['intel', 'amd', 'nvidia'].includes(brand.name.toLowerCase()), ) + const invalidateGpuQueries = () => { + utils.gpus.get.invalidate().catch(console.error) + utils.gpus.options.invalidate().catch(console.error) + utils.gpus.stats.invalidate().catch(console.error) + } + const openModal = (gpu?: GpuData) => { setEditId(gpu?.id ?? null) setGpuData(gpu ?? null) @@ -107,9 +113,7 @@ function AdminGpusPage() { } const handleModalSuccess = () => { - // Invalidate queries to refetch fresh data - utils.gpus.get.invalidate().catch(console.error) - utils.gpus.stats.invalidate().catch(console.error) + invalidateGpuQueries() closeModal() } @@ -125,8 +129,7 @@ function AdminGpusPage() { await deleteGpu.mutateAsync({ id, } satisfies RouterInput['gpus']['delete']) - utils.gpus.get.invalidate().catch(console.error) - utils.gpus.stats.invalidate().catch(console.error) + invalidateGpuQueries() toast.success('GPU deleted successfully!') } catch (err) { toast.error(`Failed to delete GPU: ${getErrorMessage(err)}`) diff --git a/src/app/admin/listings/[id]/edit/components/ListingEditForm.tsx b/src/app/admin/listings/[id]/edit/components/ListingEditForm.tsx index cc569c44f..17dc95210 100644 --- a/src/app/admin/listings/[id]/edit/components/ListingEditForm.tsx +++ b/src/app/admin/listings/[id]/edit/components/ListingEditForm.tsx @@ -109,7 +109,7 @@ function ListingEditForm(props: Props) { const loadDeviceItems = useCallback( async (query: string): Promise => { try { - const result = await utils.client.devices.get.query({ + const result = await utils.client.devices.options.query({ search: query || undefined, // Pass undefined instead of empty string limit: 50, }) diff --git a/src/app/admin/socs/page.tsx b/src/app/admin/socs/page.tsx index e60ca786d..20a72b5a1 100644 --- a/src/app/admin/socs/page.tsx +++ b/src/app/admin/socs/page.tsx @@ -75,6 +75,12 @@ function AdminSoCsPage() { const socs = socsQuery.data?.socs ?? [] const pagination = socsQuery.data?.pagination + const invalidateSocQueries = () => { + utils.socs.get.invalidate().catch(console.error) + utils.socs.options.invalidate().catch(console.error) + utils.socs.stats.invalidate().catch(console.error) + } + const openModal = (soc?: SocData) => { setEditId(soc?.id ?? null) setSocData(soc ?? null) @@ -98,9 +104,7 @@ function AdminSoCsPage() { } const handleModalSuccess = () => { - // Invalidate queries to refetch fresh data - utils.socs.get.invalidate().catch(console.error) - utils.socs.stats.invalidate().catch(console.error) + invalidateSocQueries() closeModal() } @@ -116,8 +120,7 @@ function AdminSoCsPage() { await deleteSoc.mutateAsync({ id, } satisfies RouterInput['socs']['delete']) - utils.socs.get.invalidate().catch(console.error) - utils.socs.stats.invalidate().catch(console.error) + invalidateSocQueries() toast.success('SoC deleted successfully!') } catch (err) { toast.error(`Failed to delete SoC: ${getErrorMessage(err)}`) diff --git a/src/app/api/proxy-image/route.ts b/src/app/api/proxy-image/route.ts index 64fa7e902..91bf13c41 100644 --- a/src/app/api/proxy-image/route.ts +++ b/src/app/api/proxy-image/route.ts @@ -25,7 +25,7 @@ export async function GET(req: NextRequest) { try { const upstream = await fetch(url.toString(), { - cache: env.IS_PROD ? 'force-cache' : 'no-store', + cache: env.IS_PRODUCTION_BUILD ? 'force-cache' : 'no-store', redirect: 'follow', headers: { 'User-Agent': 'EmuReadyImageProxy/1.0 (+https://www.emuready.com)', @@ -37,7 +37,7 @@ export async function GET(req: NextRequest) { } const contentType = upstream.headers.get('content-type') || 'application/octet-stream' - const cacheControl = env.IS_PROD + const cacheControl = env.IS_PRODUCTION_BUILD ? 'public, max-age=3600, s-maxage=3600, stale-while-revalidate=600' : 'no-store, no-cache, must-revalidate' diff --git a/src/app/home/components/HomeTrendingDevices.tsx b/src/app/home/components/HomeTrendingDevices.tsx index 79713ef11..f98c67fb8 100644 --- a/src/app/home/components/HomeTrendingDevices.tsx +++ b/src/app/home/components/HomeTrendingDevices.tsx @@ -9,6 +9,7 @@ import { HOME_PAGE_LIMITS } from '@/data/constants' import analytics from '@/lib/analytics' import { api } from '@/lib/api' import { cn } from '@/lib/utils' +import { ms } from '@/utils/time' import { TimeRangeTabs, type TimeRangeId } from './TimeRangeTabs' const TIME_RANGE_LABELS: Record = { @@ -18,9 +19,15 @@ const TIME_RANGE_LABELS: Record = { } export function HomeTrendingDevices() { - const trendingDevicesQuery = api.devices.trendingSummary.useQuery({ - limit: HOME_PAGE_LIMITS.TRENDING_DEVICES, - }) + const trendingDevicesQuery = api.devices.trendingSummary.useQuery( + { + limit: HOME_PAGE_LIMITS.TRENDING_DEVICES, + }, + { + staleTime: ms.hours(6), + gcTime: ms.hours(12), + }, + ) const [activeTimeRange, setActiveTimeRange] = useState('thisMonth') diff --git a/src/app/layout.tsx b/src/app/layout.tsx index e0527f2eb..abbaa4ef6 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -36,8 +36,8 @@ export const metadata: Metadata = defaultMetadata export default function RootLayout(props: PropsWithChildren) { return ( - - {env.IS_PROD && env.GA_ID && ( + + {env.ENABLE_ANALYTICS && env.GA_ID && ( )} - - - {env.IS_PROD && !env.DISABLE_COOKIE_BANNER && } + {env.ENABLE_ANALYTICS && !env.DISABLE_COOKIE_BANNER && }
@@ -61,16 +59,20 @@ export default function RootLayout(props: PropsWithChildren) {
- {env.IS_PROD && ( + {(env.ENABLE_ANALYTICS || env.ENABLE_KOFI_WIDGET) && ( - - - - - + {env.ENABLE_ANALYTICS && ( + <> + + + + {env.GA_ID && } + + )} + {env.ENABLE_KOFI_WIDGET && } )} - {env.VERCEL_ANALYTICS_ENABLED && ( + {env.ENABLE_ANALYTICS && env.VERCEL_ANALYTICS_ENABLED && ( diff --git a/src/app/listings/ListingsPage.tsx b/src/app/listings/ListingsPage.tsx index fe49b51e1..46639deac 100644 --- a/src/app/listings/ListingsPage.tsx +++ b/src/app/listings/ListingsPage.tsx @@ -66,8 +66,9 @@ const LISTINGS_COLUMNS: ColumnDefinition[] = [ { key: 'actions', label: 'Actions', alwaysVisible: true }, ] -const LOOKUP_DATA_STALE_TIME = ms.minutes(30) -const LOOKUP_DATA_GC_TIME = ms.hours(1) +const LOOKUP_DATA_STALE_TIME = ms.hours(6) +const LOOKUP_DATA_GC_TIME = ms.hours(12) +const USE_ASYNC_LISTING_FILTERS = process.env.NEXT_PUBLIC_ENABLE_ASYNC_LISTINGS_FILTERS === 'true' function ListingsPage() { const { isSignedIn } = useUser() @@ -115,18 +116,20 @@ function ListingsPage() { staleTime: LOOKUP_DATA_STALE_TIME, gcTime: LOOKUP_DATA_GC_TIME, }) - // TODO: find a better alternative to hardcoding 10000 for devices (AsyncMultiselect) - const devicesQuery = api.devices.get.useQuery( + // TODO: Remove this legacy fallback once async filters no longer need an opt-out. + const devicesQuery = api.devices.options.useQuery( { limit: 10000 }, { + enabled: !USE_ASYNC_LISTING_FILTERS, staleTime: LOOKUP_DATA_STALE_TIME, gcTime: LOOKUP_DATA_GC_TIME, }, ) - // TODO: find a better alternative to hardcoding 10000 for SoCs (AsyncMultiselect) - const socsQuery = api.socs.get.useQuery( + // TODO: Remove this legacy fallback once async filters no longer need an opt-out. + const socsQuery = api.socs.options.useQuery( { limit: 10000 }, { + enabled: !USE_ASYNC_LISTING_FILTERS, staleTime: LOOKUP_DATA_STALE_TIME, gcTime: LOOKUP_DATA_GC_TIME, }, @@ -251,6 +254,9 @@ function ListingsPage() { return
Failed to load listings.
} + const devicesForFilters = devicesQuery.data?.devices ?? [] + const socsForFilters = socsQuery.data?.socs ?? [] + return (
@@ -264,8 +270,8 @@ function ListingsPage() { performanceIds={listingsState.performanceIds} searchTerm={listingsState.searchInput} systems={systemsQuery.data ?? []} - devices={devicesQuery.data?.devices ?? []} - socs={socsQuery.data?.socs ?? []} + devices={devicesForFilters} + socs={socsForFilters} emulators={emulatorsQuery.data?.emulators ?? []} performanceScales={performanceScalesQuery.data ?? []} onSystemChange={handleSystemChange} @@ -300,8 +306,8 @@ function ListingsPage() { performanceIds={listingsState.performanceIds} searchTerm={listingsState.searchInput} systems={systemsQuery.data ?? []} - devices={devicesQuery.data?.devices ?? []} - socs={socsQuery.data?.socs ?? []} + devices={devicesForFilters} + socs={socsForFilters} emulators={emulatorsQuery.data?.emulators ?? []} performanceScales={performanceScalesQuery.data ?? []} onSystemChange={handleSystemChange} diff --git a/src/app/listings/components/ListingsFiltersContent.tsx b/src/app/listings/components/ListingsFiltersContent.tsx index f783776bd..627f87b29 100644 --- a/src/app/listings/components/ListingsFiltersContent.tsx +++ b/src/app/listings/components/ListingsFiltersContent.tsx @@ -1,12 +1,10 @@ 'use client' -import { AnimatePresence, motion } from 'framer-motion' +import { motion } from 'framer-motion' import { Joystick, MonitorSmartphone, Cpu, Gamepad, Rocket } from 'lucide-react' import { ActiveFiltersSummary, ListingsSearchBar } from '@/app/listings/shared/components' import { buildActiveFilterItems } from '@/app/listings/shared/utils/buildActiveFilterItems' import { MultiSelect } from '@/components/ui' -import AsyncDeviceMultiSelect from '@/components/ui/form/AsyncDeviceMultiSelect' -import AsyncSocMultiSelect from '@/components/ui/form/AsyncSocMultiSelect' import { performanceOptions, deviceOptions, @@ -14,6 +12,8 @@ import { systemOptions, emulatorOptions, } from '@/utils/options' +import AsyncDeviceFilterSelect from './filters/AsyncDeviceFilterSelect' +import AsyncSocFilterSelect from './filters/AsyncSocFilterSelect' interface Props { systemIds: string[] @@ -77,7 +77,7 @@ export default function ListingsFiltersContent(props: Props) { /> {ENABLE_ASYNC_LISTINGS ? ( - } value={props.deviceIds} @@ -99,7 +99,7 @@ export default function ListingsFiltersContent(props: Props) { )} {ENABLE_ASYNC_LISTINGS ? ( - } value={props.socIds} @@ -142,23 +142,19 @@ export default function ListingsFiltersContent(props: Props) { maxDisplayed={1} /> - {props.showActiveFilters && props.onClearAll && ( - - {hasActiveFilters && ( - - )} - + {props.showActiveFilters && props.onClearAll && hasActiveFilters && ( + )} ) diff --git a/src/app/listings/components/ListingsFiltersSidebar.tsx b/src/app/listings/components/ListingsFiltersSidebar.tsx index 2ec61c09f..57ecaaa92 100644 --- a/src/app/listings/components/ListingsFiltersSidebar.tsx +++ b/src/app/listings/components/ListingsFiltersSidebar.tsx @@ -1,7 +1,7 @@ 'use client' import { type inferRouterOutputs } from '@trpc/server' -import { motion, AnimatePresence } from 'framer-motion' +import { motion } from 'framer-motion' import { Joystick, MonitorSmartphone, @@ -46,9 +46,8 @@ interface FiltersProps { systems: { id: string; name: string }[] devices: { id: string - brandId: string modelName: string - brand: { id: string; name: string; createdAt: Date } + brand: { id: string; name: string } }[] socs: { id: string; name: string; manufacturer: string }[] emulators: { id: string; name: string }[] @@ -388,22 +387,20 @@ function ListingsFiltersSidebar(props: FiltersProps) { {/* Fields rendered above in ListingsFiltersContent */} - - {hasActiveFilters && ( - - )} - + {hasActiveFilters && ( + + )} ) diff --git a/src/app/listings/components/filters/AsyncDeviceFilterSelect.test.tsx b/src/app/listings/components/filters/AsyncDeviceFilterSelect.test.tsx new file mode 100644 index 000000000..35fb6395f --- /dev/null +++ b/src/app/listings/components/filters/AsyncDeviceFilterSelect.test.tsx @@ -0,0 +1,191 @@ +import { render, screen, fireEvent, act } from '@testing-library/react' +import { describe, it, expect, vi, beforeAll, beforeEach } from 'vitest' +import type AsyncDeviceFilterSelectComponent from './AsyncDeviceFilterSelect' + +const apiMocks = vi.hoisted(() => ({ + useQueries: vi.fn(), + devicesGetByIdsUseQuery: vi.fn(), +})) + +vi.mock('@/lib/api', () => ({ + api: { + useQueries: apiMocks.useQueries, + devices: { + getByIds: { useQuery: apiMocks.devicesGetByIdsUseQuery }, + }, + }, +})) + +let AsyncDeviceFilterSelect: typeof AsyncDeviceFilterSelectComponent + +interface OptionsInput { + search?: string + limit: number + offset: number +} + +interface IdsInput { + ids: string[] +} + +interface OptionsQueryDescriptor { + input: OptionsInput + queryOptions: unknown +} + +interface QueryProxy { + devices: { + options: (input: OptionsInput, queryOptions: unknown) => OptionsQueryDescriptor + } +} + +let requestedOptionInputs: OptionsInput[] = [] +let requestedQueryOptions: unknown[] = [] + +const queryOptions = expect.objectContaining({ + staleTime: expect.any(Number), + gcTime: expect.any(Number), +}) + +const firstPageData = { + devices: [ + { + id: 'device-1', + modelName: 'Pocket 5', + brand: { id: 'brand-1', name: 'Retroid' }, + soc: null, + }, + ], + hasMore: true, +} + +const secondPageData = { + devices: [ + { + id: 'device-2', + modelName: 'Odin 2', + brand: { id: 'brand-2', name: 'AYN' }, + soc: null, + }, + ], + hasMore: false, +} + +function setupApiMocks() { + requestedOptionInputs = [] + requestedQueryOptions = [] + apiMocks.useQueries.mockImplementation( + (queryCallback: (proxy: QueryProxy) => OptionsQueryDescriptor[]) => { + const descriptors = queryCallback({ + devices: { + options: (input, queryOptionsInput) => ({ + input, + queryOptions: queryOptionsInput, + }), + }, + }) + + requestedOptionInputs = descriptors.map((descriptor) => descriptor.input) + requestedQueryOptions = descriptors.map((descriptor) => descriptor.queryOptions) + + return descriptors.map((descriptor) => ({ + data: descriptor.input.offset === 0 ? firstPageData : secondPageData, + isFetching: false, + })) + }, + ) + apiMocks.devicesGetByIdsUseQuery.mockImplementation((input: IdsInput) => ({ + data: input.ids.map((id) => ({ + id, + modelName: 'Deck OLED', + brand: { id: 'brand-selected', name: 'Steam' }, + soc: null, + })), + })) +} + +describe('AsyncDeviceFilterSelect', () => { + beforeAll(async () => { + ;({ default: AsyncDeviceFilterSelect } = await import('./AsyncDeviceFilterSelect')) + }) + + beforeEach(() => { + vi.clearAllMocks() + setupApiMocks() + }) + + it('loads devices through the slim options endpoint and selected labels through getByIds', () => { + render( + , + ) + + expect(screen.getByText('Steam Deck OLED')).toBeInTheDocument() + expect(apiMocks.devicesGetByIdsUseQuery).toHaveBeenCalledWith( + { ids: ['device-selected'] }, + expect.objectContaining({ enabled: true }), + ) + expect(requestedOptionInputs).toEqual([{ search: undefined, limit: 50, offset: 0 }]) + expect(requestedQueryOptions[0]).toEqual(queryOptions) + + fireEvent.click(screen.getByRole('button', { name: 'Devices multi-select' })) + expect(screen.getByText('Retroid Pocket 5')).toBeInTheDocument() + }) + + it('keeps initially loaded options after the initial debounce window', () => { + vi.useFakeTimers() + + try { + render() + + fireEvent.click(screen.getByRole('button', { name: 'Devices multi-select' })) + expect(screen.getByText('Retroid Pocket 5')).toBeInTheDocument() + + act(() => { + vi.advanceTimersByTime(300) + }) + + expect(screen.getByText('Retroid Pocket 5')).toBeInTheDocument() + } finally { + vi.useRealTimers() + } + }) + + it('updates the search query and resets pagination', () => { + vi.useFakeTimers() + + try { + render() + + fireEvent.click(screen.getByRole('button', { name: 'Devices multi-select' })) + fireEvent.change(screen.getByPlaceholderText('Search devices...'), { + target: { value: 'odin' }, + }) + + act(() => { + vi.advanceTimersByTime(300) + }) + + expect(requestedOptionInputs).toEqual([{ search: 'odin', limit: 50, offset: 0 }]) + expect(requestedQueryOptions[0]).toEqual(queryOptions) + } finally { + vi.useRealTimers() + } + }) + + it('requests the next page when scrolled near the bottom', () => { + render() + + fireEvent.click(screen.getByRole('button', { name: 'Devices multi-select' })) + const scrollContainer = screen.getByTestId('async-multi-select-options') + Object.defineProperty(scrollContainer, 'scrollTop', { value: 260, configurable: true }) + Object.defineProperty(scrollContainer, 'clientHeight', { value: 80, configurable: true }) + Object.defineProperty(scrollContainer, 'scrollHeight', { value: 340, configurable: true }) + fireEvent.scroll(scrollContainer) + + expect(requestedOptionInputs).toEqual([ + { search: undefined, limit: 50, offset: 0 }, + { search: undefined, limit: 50, offset: 50 }, + ]) + expect(requestedQueryOptions[1]).toEqual(queryOptions) + }) +}) diff --git a/src/app/listings/components/filters/AsyncDeviceFilterSelect.tsx b/src/app/listings/components/filters/AsyncDeviceFilterSelect.tsx new file mode 100644 index 000000000..fb3a17a75 --- /dev/null +++ b/src/app/listings/components/filters/AsyncDeviceFilterSelect.tsx @@ -0,0 +1,88 @@ +'use client' + +import { type ReactNode, useCallback, useMemo, useState } from 'react' +import AsyncMultiSelect from '@/components/ui/form/async-multi-select/AsyncMultiSelect' +import { api } from '@/lib/api' +import { ms } from '@/utils/time' + +interface Props { + label: string + leftIcon?: ReactNode + value: string[] + onChange: (values: string[]) => void + placeholder?: string + className?: string + maxDisplayed?: number +} + +const PAGE_SIZE = 50 +const LOOKUP_DATA_QUERY_OPTIONS = { + staleTime: ms.hours(6), + gcTime: ms.hours(12), +} + +export default function AsyncDeviceFilterSelect(props: Props) { + const [query, setQuery] = useState('') + const [pageOffsets, setPageOffsets] = useState([0]) + + const byIdsQuery = api.devices.getByIds.useQuery( + { ids: props.value }, + { ...LOOKUP_DATA_QUERY_OPTIONS, enabled: props.value.length > 0 }, + ) + + const pageQueries = api.useQueries((t) => + pageOffsets.map((offset) => + t.devices.options( + { search: query || undefined, limit: PAGE_SIZE, offset }, + LOOKUP_DATA_QUERY_OPTIONS, + ), + ), + ) + + const options = useMemo( + () => + pageQueries.flatMap((pageQuery) => + (pageQuery.data?.devices ?? []).map((d) => ({ + id: d.id, + name: `${d.brand.name} ${d.modelName}`, + badgeName: d.modelName, + })), + ), + [pageQueries], + ) + + const selectedByIds = useMemo( + () => + (byIdsQuery.data ?? []).map((d) => ({ + id: d.id, + name: `${d.brand.name} ${d.modelName}`, + badgeName: d.modelName, + })), + [byIdsQuery.data], + ) + + const lastPageQuery = pageQueries[pageQueries.length - 1] + const hasMore = lastPageQuery?.data?.hasMore ?? false + const isFetching = pageQueries.some((pageQuery) => pageQuery.isFetching) + + const handleLoadMore = useCallback(() => { + setPageOffsets((offsets) => [...offsets, offsets[offsets.length - 1] + PAGE_SIZE]) + }, []) + + const handleQueryChange = useCallback((q: string) => { + setQuery(q) + setPageOffsets([0]) + }, []) + + return ( + + ) +} diff --git a/src/app/listings/components/filters/AsyncSocFilterSelect.test.tsx b/src/app/listings/components/filters/AsyncSocFilterSelect.test.tsx new file mode 100644 index 000000000..dc2d90412 --- /dev/null +++ b/src/app/listings/components/filters/AsyncSocFilterSelect.test.tsx @@ -0,0 +1,85 @@ +import { render, screen, fireEvent } from '@testing-library/react' +import { describe, it, expect, vi, beforeAll, beforeEach } from 'vitest' +import type AsyncSocFilterSelectComponent from './AsyncSocFilterSelect' + +const apiMocks = vi.hoisted(() => ({ + useQueries: vi.fn(), + socsGetByIdsUseQuery: vi.fn(), +})) + +vi.mock('@/lib/api', () => ({ + api: { + useQueries: apiMocks.useQueries, + socs: { + getByIds: { useQuery: apiMocks.socsGetByIdsUseQuery }, + }, + }, +})) + +let AsyncSocFilterSelect: typeof AsyncSocFilterSelectComponent + +interface IdsInput { + ids: string[] +} + +interface OptionsInput { + search?: string + limit: number + offset: number +} + +interface OptionsQueryDescriptor { + input: OptionsInput +} + +interface QueryProxy { + socs: { + options: (input: OptionsInput) => OptionsQueryDescriptor + } +} + +function setupApiMocks() { + apiMocks.useQueries.mockImplementation( + (queryCallback: (proxy: QueryProxy) => OptionsQueryDescriptor[]) => { + const descriptors = queryCallback({ + socs: { + options: (input) => ({ input }), + }, + }) + + return descriptors.map(() => ({ + data: { + socs: [{ id: 'soc-1', name: 'Snapdragon 8 Gen 2', manufacturer: 'Qualcomm' }], + hasMore: false, + }, + isFetching: false, + })) + }, + ) + apiMocks.socsGetByIdsUseQuery.mockImplementation((input: IdsInput) => ({ + data: input.ids.map((id) => ({ + id, + name: 'Dimensity 1100', + manufacturer: 'MediaTek', + })), + })) +} + +describe('AsyncSocFilterSelect', () => { + beforeAll(async () => { + ;({ default: AsyncSocFilterSelect } = await import('./AsyncSocFilterSelect')) + }) + + beforeEach(() => { + vi.clearAllMocks() + setupApiMocks() + }) + + it('maps SoC option and selected labels', () => { + render() + + expect(screen.getByText('MediaTek Dimensity 1100')).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'SoCs multi-select' })) + expect(screen.getByText('Qualcomm Snapdragon 8 Gen 2')).toBeInTheDocument() + }) +}) diff --git a/src/app/listings/components/filters/AsyncSocFilterSelect.tsx b/src/app/listings/components/filters/AsyncSocFilterSelect.tsx new file mode 100644 index 000000000..ac568ed44 --- /dev/null +++ b/src/app/listings/components/filters/AsyncSocFilterSelect.tsx @@ -0,0 +1,88 @@ +'use client' + +import { type ReactNode, useCallback, useMemo, useState } from 'react' +import AsyncMultiSelect from '@/components/ui/form/async-multi-select/AsyncMultiSelect' +import { api } from '@/lib/api' +import { ms } from '@/utils/time' + +interface Props { + label: string + leftIcon?: ReactNode + value: string[] + onChange: (values: string[]) => void + placeholder?: string + className?: string + maxDisplayed?: number +} + +const PAGE_SIZE = 50 +const LOOKUP_DATA_QUERY_OPTIONS = { + staleTime: ms.hours(6), + gcTime: ms.hours(12), +} + +export default function AsyncSocFilterSelect(props: Props) { + const [query, setQuery] = useState('') + const [pageOffsets, setPageOffsets] = useState([0]) + + const byIdsQuery = api.socs.getByIds.useQuery( + { ids: props.value }, + { ...LOOKUP_DATA_QUERY_OPTIONS, enabled: props.value.length > 0 }, + ) + + const pageQueries = api.useQueries((t) => + pageOffsets.map((offset) => + t.socs.options( + { search: query || undefined, limit: PAGE_SIZE, offset }, + LOOKUP_DATA_QUERY_OPTIONS, + ), + ), + ) + + const options = useMemo( + () => + pageQueries.flatMap((pageQuery) => + (pageQuery.data?.socs ?? []).map((s) => ({ + id: s.id, + name: `${s.manufacturer} ${s.name}`, + badgeName: s.name, + })), + ), + [pageQueries], + ) + + const selectedByIds = useMemo( + () => + (byIdsQuery.data ?? []).map((s) => ({ + id: s.id, + name: `${s.manufacturer} ${s.name}`, + badgeName: s.name, + })), + [byIdsQuery.data], + ) + + const lastPageQuery = pageQueries[pageQueries.length - 1] + const hasMore = lastPageQuery?.data?.hasMore ?? false + const isFetching = pageQueries.some((pageQuery) => pageQuery.isFetching) + + const handleLoadMore = useCallback(() => { + setPageOffsets((offsets) => [...offsets, offsets[offsets.length - 1] + PAGE_SIZE]) + }, []) + + const handleQueryChange = useCallback((q: string) => { + setQuery(q) + setPageOffsets([0]) + }, []) + + return ( + + ) +} diff --git a/src/app/listings/new/NewListingPage.tsx b/src/app/listings/new/NewListingPage.tsx index 48f5efb75..9dee18a70 100644 --- a/src/app/listings/new/NewListingPage.tsx +++ b/src/app/listings/new/NewListingPage.tsx @@ -51,6 +51,10 @@ import { reconcileDriverValue } from '../components/shared/custom-fields/driverV export type ListingFormValues = RouterInput['listings']['create'] const HIGHLIGHT_DURATION_MS = 1800 +const LOOKUP_DATA_QUERY_OPTIONS = { + staleTime: ms.hours(6), + gcTime: ms.hours(12), +} function AddListingPage() { const router = useRouter() @@ -186,7 +190,10 @@ function AddListingPage() { [form, parsedCustomFields, driverVersionsQuery.data?.releases], ) - const performanceScalesQuery = api.listings.performanceScales.useQuery() + const performanceScalesQuery = api.listings.performanceScales.useQuery( + undefined, + LOOKUP_DATA_QUERY_OPTIONS, + ) const customFieldDefinitionsQuery = api.customFieldDefinitions.getByEmulator.useQuery( { emulatorId: selectedEmulatorId }, { @@ -266,7 +273,7 @@ function AddListingPage() { setDeviceSearchTerm(query) if (query.length < 2) return Promise.resolve([]) try { - const result = await utils.devices.get.fetch({ + const result = await utils.devices.options.fetch({ search: query, limit: 50, }) @@ -291,7 +298,7 @@ function AddListingPage() { return [] } }, - [utils.devices.get], + [utils.devices.options], ) useEffect(() => { diff --git a/src/app/pc-listings/PcListingsPage.tsx b/src/app/pc-listings/PcListingsPage.tsx index d1fbaf329..357a1313a 100644 --- a/src/app/pc-listings/PcListingsPage.tsx +++ b/src/app/pc-listings/PcListingsPage.tsx @@ -49,6 +49,7 @@ import { } from '@/utils/navigation-events' import { roleIncludesRole } from '@/utils/permission-system' import { hasRolePermission } from '@/utils/permissions' +import { ms } from '@/utils/time' import { Role, ApprovalStatus } from '@orm' import PcFiltersContent from './components/PcFiltersContent' import PcFiltersSidebar from './components/PcFiltersSidebar' @@ -69,6 +70,12 @@ const PC_LISTINGS_COLUMNS: ColumnDefinition[] = [ { key: 'actions', label: 'Actions', alwaysVisible: true }, ] +const LOOKUP_DATA_QUERY_OPTIONS = { + staleTime: ms.hours(6), + gcTime: ms.hours(12), +} +const USE_ASYNC_LISTING_FILTERS = process.env.NEXT_PUBLIC_ENABLE_ASYNC_LISTINGS_FILTERS === 'true' + function PcListingsPage() { const router = useRouter() const listingsState = usePcListingsState() @@ -96,13 +103,22 @@ function PcListingsPage() { const isAdmin = userRole ? hasRolePermission(userRole, Role.ADMIN) : false const isModerator = userRole ? roleIncludesRole(userRole, Role.MODERATOR) : false - // TODO: handle MultiSelect async instead of fetching 1000 items - const cpusQuery = api.cpus.get.useQuery({ limit: 1000 }) - // TODO: handle MultiSelect async instead of fetching 1000 items - const gpusQuery = api.gpus.get.useQuery({ limit: 1000 }) - const emulatorsQuery = api.emulators.get.useQuery({ limit: 100 }) - const performanceScalesQuery = api.listings.performanceScales.useQuery() - const systemsQuery = api.systems.get.useQuery() + // TODO: Remove this legacy fallback once async PC filters no longer need an opt-out. + const cpusQuery = api.cpus.options.useQuery( + { limit: 1000 }, + { ...LOOKUP_DATA_QUERY_OPTIONS, enabled: !USE_ASYNC_LISTING_FILTERS }, + ) + // TODO: Remove this legacy fallback once async PC filters no longer need an opt-out. + const gpusQuery = api.gpus.options.useQuery( + { limit: 1000 }, + { ...LOOKUP_DATA_QUERY_OPTIONS, enabled: !USE_ASYNC_LISTING_FILTERS }, + ) + const emulatorsQuery = api.emulators.get.useQuery({ limit: 100 }, LOOKUP_DATA_QUERY_OPTIONS) + const performanceScalesQuery = api.listings.performanceScales.useQuery( + undefined, + LOOKUP_DATA_QUERY_OPTIONS, + ) + const systemsQuery = api.systems.get.useQuery(undefined, LOOKUP_DATA_QUERY_OPTIONS) const filterParams: RouterInput['pcListings']['get'] = { page: listingsState.page, @@ -167,6 +183,9 @@ function PcListingsPage() { return
Failed to load PC listings.
} + const cpusForFilters = cpusQuery.data?.cpus ?? [] + const gpusForFilters = gpusQuery.data?.gpus ?? [] + return (
@@ -184,8 +203,8 @@ function PcListingsPage() { minMemory={listingsState.minMemory} maxMemory={listingsState.maxMemory} searchTerm={listingsState.searchInput} - cpus={cpusQuery.data?.cpus ?? []} - gpus={gpusQuery.data?.gpus ?? []} + cpus={cpusForFilters} + gpus={gpusForFilters} systems={systemsQuery.data ?? []} emulators={emulatorsQuery.data?.emulators ?? []} performanceScales={performanceScalesQuery.data ?? []} @@ -212,8 +231,8 @@ function PcListingsPage() { minMemory={listingsState.minMemory} maxMemory={listingsState.maxMemory} searchTerm={listingsState.searchInput} - cpus={cpusQuery.data?.cpus ?? []} - gpus={gpusQuery.data?.gpus ?? []} + cpus={cpusForFilters} + gpus={gpusForFilters} systems={systemsQuery.data ?? []} emulators={emulatorsQuery.data?.emulators ?? []} performanceScales={performanceScalesQuery.data ?? []} diff --git a/src/app/pc-listings/components/PcFiltersContent.tsx b/src/app/pc-listings/components/PcFiltersContent.tsx index 3a9d3fbf3..c8894bc17 100644 --- a/src/app/pc-listings/components/PcFiltersContent.tsx +++ b/src/app/pc-listings/components/PcFiltersContent.tsx @@ -6,8 +6,6 @@ import { type ChangeEvent } from 'react' import { ListingsSearchBar, ActiveFiltersSummary } from '@/app/listings/shared/components' import { buildPcActiveFilterItems } from '@/app/pc-listings/utils/buildPcActiveFilterItems' import { MultiSelect, Input } from '@/components/ui' -import AsyncCpuMultiSelect from '@/components/ui/form/AsyncCpuMultiSelect' -import AsyncGpuMultiSelect from '@/components/ui/form/AsyncGpuMultiSelect' import { cpuOptions, emulatorOptions, @@ -16,6 +14,8 @@ import { systemOptions, } from '@/utils/options' import { type System, type PerformanceScale, type Emulator } from '@orm' +import AsyncCpuFilterSelect from './filters/AsyncCpuFilterSelect' +import AsyncGpuFilterSelect from './filters/AsyncGpuFilterSelect' type CpuWithBrand = { id: string; modelName: string; brand: { name: string } } type GpuWithBrand = { id: string; modelName: string; brand: { name: string } } @@ -80,7 +80,7 @@ export default function PcFiltersContent(props: Props) { props.onPerformanceChange(values) } - const ENABLE_ASYNC = process.env.NEXT_PUBLIC_ENABLE_ASYNC_PC_FILTERS === 'true' + const ENABLE_ASYNC = process.env.NEXT_PUBLIC_ENABLE_ASYNC_LISTINGS_FILTERS === 'true' const hasActiveFilters = props.searchTerm || @@ -117,7 +117,7 @@ export default function PcFiltersContent(props: Props) { {/* CPUs */} {ENABLE_ASYNC ? ( - } value={props.cpuIds} @@ -139,7 +139,7 @@ export default function PcFiltersContent(props: Props) { {/* GPUs */} {ENABLE_ASYNC ? ( - } value={props.gpuIds} diff --git a/src/app/pc-listings/components/filters/AsyncCpuFilterSelect.test.tsx b/src/app/pc-listings/components/filters/AsyncCpuFilterSelect.test.tsx new file mode 100644 index 000000000..fb501b8cc --- /dev/null +++ b/src/app/pc-listings/components/filters/AsyncCpuFilterSelect.test.tsx @@ -0,0 +1,87 @@ +import { render, screen, fireEvent } from '@testing-library/react' +import { describe, it, expect, vi, beforeAll, beforeEach } from 'vitest' +import type AsyncCpuFilterSelectComponent from './AsyncCpuFilterSelect' + +const apiMocks = vi.hoisted(() => ({ + useQueries: vi.fn(), + cpusGetByIdsUseQuery: vi.fn(), +})) + +vi.mock('@/lib/api', () => ({ + api: { + useQueries: apiMocks.useQueries, + cpus: { + getByIds: { useQuery: apiMocks.cpusGetByIdsUseQuery }, + }, + }, +})) + +let AsyncCpuFilterSelect: typeof AsyncCpuFilterSelectComponent + +interface IdsInput { + ids: string[] +} + +interface OptionsInput { + search?: string + limit: number + offset: number +} + +interface OptionsQueryDescriptor { + input: OptionsInput +} + +interface QueryProxy { + cpus: { + options: (input: OptionsInput) => OptionsQueryDescriptor + } +} + +function setupApiMocks() { + apiMocks.useQueries.mockImplementation( + (queryCallback: (proxy: QueryProxy) => OptionsQueryDescriptor[]) => { + const descriptors = queryCallback({ + cpus: { + options: (input) => ({ input }), + }, + }) + + return descriptors.map(() => ({ + data: { + cpus: [ + { id: 'cpu-1', modelName: 'Core i7-12700K', brand: { id: 'intel', name: 'Intel' } }, + ], + hasMore: false, + }, + isFetching: false, + })) + }, + ) + apiMocks.cpusGetByIdsUseQuery.mockImplementation((input: IdsInput) => ({ + data: input.ids.map((id) => ({ + id, + modelName: 'Ryzen 7 7800X3D', + brand: { id: 'amd', name: 'AMD' }, + })), + })) +} + +describe('AsyncCpuFilterSelect', () => { + beforeAll(async () => { + ;({ default: AsyncCpuFilterSelect } = await import('./AsyncCpuFilterSelect')) + }) + + beforeEach(() => { + vi.clearAllMocks() + setupApiMocks() + }) + + it('maps CPU option and selected labels', () => { + render() + + expect(screen.getByText('AMD Ryzen 7 7800X3D')).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'CPUs multi-select' })) + expect(screen.getByText('Intel Core i7-12700K')).toBeInTheDocument() + }) +}) diff --git a/src/app/pc-listings/components/filters/AsyncCpuFilterSelect.tsx b/src/app/pc-listings/components/filters/AsyncCpuFilterSelect.tsx new file mode 100644 index 000000000..474daf663 --- /dev/null +++ b/src/app/pc-listings/components/filters/AsyncCpuFilterSelect.tsx @@ -0,0 +1,88 @@ +'use client' + +import { type ReactNode, useCallback, useMemo, useState } from 'react' +import AsyncMultiSelect from '@/components/ui/form/async-multi-select/AsyncMultiSelect' +import { api } from '@/lib/api' +import { ms } from '@/utils/time' + +interface Props { + label: string + leftIcon?: ReactNode + value: string[] + onChange: (values: string[]) => void + placeholder?: string + className?: string + maxDisplayed?: number +} + +const PAGE_SIZE = 50 +const LOOKUP_DATA_QUERY_OPTIONS = { + staleTime: ms.hours(6), + gcTime: ms.hours(12), +} + +export default function AsyncCpuFilterSelect(props: Props) { + const [query, setQuery] = useState('') + const [pageOffsets, setPageOffsets] = useState([0]) + + const byIdsQuery = api.cpus.getByIds.useQuery( + { ids: props.value }, + { ...LOOKUP_DATA_QUERY_OPTIONS, enabled: props.value.length > 0 }, + ) + + const pageQueries = api.useQueries((t) => + pageOffsets.map((offset) => + t.cpus.options( + { search: query || undefined, limit: PAGE_SIZE, offset }, + LOOKUP_DATA_QUERY_OPTIONS, + ), + ), + ) + + const options = useMemo( + () => + pageQueries.flatMap((pageQuery) => + (pageQuery.data?.cpus ?? []).map((c) => ({ + id: c.id, + name: `${c.brand.name} ${c.modelName}`, + badgeName: c.modelName, + })), + ), + [pageQueries], + ) + + const selectedByIds = useMemo( + () => + (byIdsQuery.data ?? []).map((c) => ({ + id: c.id, + name: `${c.brand.name} ${c.modelName}`, + badgeName: c.modelName, + })), + [byIdsQuery.data], + ) + + const lastPageQuery = pageQueries[pageQueries.length - 1] + const hasMore = lastPageQuery?.data?.hasMore ?? false + const isFetching = pageQueries.some((pageQuery) => pageQuery.isFetching) + + const handleLoadMore = useCallback(() => { + setPageOffsets((offsets) => [...offsets, offsets[offsets.length - 1] + PAGE_SIZE]) + }, []) + + const handleQueryChange = useCallback((q: string) => { + setQuery(q) + setPageOffsets([0]) + }, []) + + return ( + + ) +} diff --git a/src/app/pc-listings/components/filters/AsyncGpuFilterSelect.test.tsx b/src/app/pc-listings/components/filters/AsyncGpuFilterSelect.test.tsx new file mode 100644 index 000000000..97bb9e9a5 --- /dev/null +++ b/src/app/pc-listings/components/filters/AsyncGpuFilterSelect.test.tsx @@ -0,0 +1,85 @@ +import { render, screen, fireEvent } from '@testing-library/react' +import { describe, it, expect, vi, beforeAll, beforeEach } from 'vitest' +import type AsyncGpuFilterSelectComponent from './AsyncGpuFilterSelect' + +const apiMocks = vi.hoisted(() => ({ + useQueries: vi.fn(), + gpusGetByIdsUseQuery: vi.fn(), +})) + +vi.mock('@/lib/api', () => ({ + api: { + useQueries: apiMocks.useQueries, + gpus: { + getByIds: { useQuery: apiMocks.gpusGetByIdsUseQuery }, + }, + }, +})) + +let AsyncGpuFilterSelect: typeof AsyncGpuFilterSelectComponent + +interface IdsInput { + ids: string[] +} + +interface OptionsInput { + search?: string + limit: number + offset: number +} + +interface OptionsQueryDescriptor { + input: OptionsInput +} + +interface QueryProxy { + gpus: { + options: (input: OptionsInput) => OptionsQueryDescriptor + } +} + +function setupApiMocks() { + apiMocks.useQueries.mockImplementation( + (queryCallback: (proxy: QueryProxy) => OptionsQueryDescriptor[]) => { + const descriptors = queryCallback({ + gpus: { + options: (input) => ({ input }), + }, + }) + + return descriptors.map(() => ({ + data: { + gpus: [{ id: 'gpu-1', modelName: 'RTX 4070', brand: { id: 'nvidia', name: 'NVIDIA' } }], + hasMore: false, + }, + isFetching: false, + })) + }, + ) + apiMocks.gpusGetByIdsUseQuery.mockImplementation((input: IdsInput) => ({ + data: input.ids.map((id) => ({ + id, + modelName: 'Radeon RX 7800 XT', + brand: { id: 'amd', name: 'AMD' }, + })), + })) +} + +describe('AsyncGpuFilterSelect', () => { + beforeAll(async () => { + ;({ default: AsyncGpuFilterSelect } = await import('./AsyncGpuFilterSelect')) + }) + + beforeEach(() => { + vi.clearAllMocks() + setupApiMocks() + }) + + it('maps GPU option and selected labels', () => { + render() + + expect(screen.getByText('AMD Radeon RX 7800 XT')).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'GPUs multi-select' })) + expect(screen.getByText('NVIDIA RTX 4070')).toBeInTheDocument() + }) +}) diff --git a/src/app/pc-listings/components/filters/AsyncGpuFilterSelect.tsx b/src/app/pc-listings/components/filters/AsyncGpuFilterSelect.tsx new file mode 100644 index 000000000..679749d77 --- /dev/null +++ b/src/app/pc-listings/components/filters/AsyncGpuFilterSelect.tsx @@ -0,0 +1,88 @@ +'use client' + +import { type ReactNode, useCallback, useMemo, useState } from 'react' +import AsyncMultiSelect from '@/components/ui/form/async-multi-select/AsyncMultiSelect' +import { api } from '@/lib/api' +import { ms } from '@/utils/time' + +interface Props { + label: string + leftIcon?: ReactNode + value: string[] + onChange: (values: string[]) => void + placeholder?: string + className?: string + maxDisplayed?: number +} + +const PAGE_SIZE = 50 +const LOOKUP_DATA_QUERY_OPTIONS = { + staleTime: ms.hours(6), + gcTime: ms.hours(12), +} + +export default function AsyncGpuFilterSelect(props: Props) { + const [query, setQuery] = useState('') + const [pageOffsets, setPageOffsets] = useState([0]) + + const byIdsQuery = api.gpus.getByIds.useQuery( + { ids: props.value }, + { ...LOOKUP_DATA_QUERY_OPTIONS, enabled: props.value.length > 0 }, + ) + + const pageQueries = api.useQueries((t) => + pageOffsets.map((offset) => + t.gpus.options( + { search: query || undefined, limit: PAGE_SIZE, offset }, + LOOKUP_DATA_QUERY_OPTIONS, + ), + ), + ) + + const options = useMemo( + () => + pageQueries.flatMap((pageQuery) => + (pageQuery.data?.gpus ?? []).map((g) => ({ + id: g.id, + name: `${g.brand.name} ${g.modelName}`, + badgeName: g.modelName, + })), + ), + [pageQueries], + ) + + const selectedByIds = useMemo( + () => + (byIdsQuery.data ?? []).map((g) => ({ + id: g.id, + name: `${g.brand.name} ${g.modelName}`, + badgeName: g.modelName, + })), + [byIdsQuery.data], + ) + + const lastPageQuery = pageQueries[pageQueries.length - 1] + const hasMore = lastPageQuery?.data?.hasMore ?? false + const isFetching = pageQueries.some((pageQuery) => pageQuery.isFetching) + + const handleLoadMore = useCallback(() => { + setPageOffsets((offsets) => [...offsets, offsets[offsets.length - 1] + PAGE_SIZE]) + }, []) + + const handleQueryChange = useCallback((q: string) => { + setQuery(q) + setPageOffsets([0]) + }, []) + + return ( + + ) +} diff --git a/src/app/pc-listings/new/NewPcListingPage.tsx b/src/app/pc-listings/new/NewPcListingPage.tsx index 55772811d..ca019771f 100644 --- a/src/app/pc-listings/new/NewPcListingPage.tsx +++ b/src/app/pc-listings/new/NewPcListingPage.tsx @@ -31,16 +31,21 @@ import { type RouterInput, type RouterOutput } from '@/types/trpc' import { type CustomFieldDefinitionWithOptions } from '@/utils/custom-field-validation' import { parseCustomFieldOptions, getCustomFieldDefaultValue } from '@/utils/custom-fields' import getErrorMessage from '@/utils/getErrorMessage' +import { ms } from '@/utils/time' import { PcOs } from '@orm' import createDynamicPcListingSchema from './form-schemas/createDynamicPcListingSchema' export type PcListingFormValues = RouterInput['pcListings']['create'] -type CpuOption = RouterOutput['cpus']['get']['cpus'][number] -type GpuOption = RouterOutput['gpus']['get']['gpus'][number] +type CpuOption = RouterOutput['cpus']['options']['cpus'][number] +type GpuOption = RouterOutput['gpus']['options']['gpus'][number] type PcPresetOption = RouterOutput['pcListings']['presets']['get'][number] const OS_OPTIONS = PC_OS_OPTIONS +const LOOKUP_DATA_QUERY_OPTIONS = { + staleTime: ms.hours(6), + gcTime: ms.hours(12), +} function AddPcListingPage() { const router = useRouter() @@ -63,7 +68,10 @@ function AddPcListingPage() { const utils = api.useUtils() const createPcListing = api.pcListings.create.useMutation() - const performanceScalesQuery = api.performanceScales.get.useQuery() + const performanceScalesQuery = api.performanceScales.get.useQuery( + undefined, + LOOKUP_DATA_QUERY_OPTIONS, + ) const presetsQuery = api.pcListings.presets.get.useQuery({}) const { handleKeyDown } = useFormKeyDown() @@ -75,28 +83,28 @@ function AddPcListingPage() { async (query: string): Promise => { if (query.length < 2) return Promise.resolve([]) try { - const result = await utils.cpus.get.fetch({ search: query, limit: 20 }) + const result = await utils.cpus.options.fetch({ search: query, limit: 20 }) return result.cpus ?? [] } catch (error) { console.error('Error fetching CPUs:', error) return [] } }, - [utils.cpus.get], + [utils.cpus.options], ) const loadGpuItems = useCallback( async (query: string): Promise => { if (query.length < 2) return Promise.resolve([]) try { - const result = await utils.gpus.get.fetch({ search: query, limit: 20 }) + const result = await utils.gpus.options.fetch({ search: query, limit: 20 }) return result.gpus ?? [] } catch (error) { console.error('Error fetching GPUs:', error) return [] } }, - [utils.gpus.get], + [utils.gpus.options], ) const form = useForm({ diff --git a/src/app/profile/components/DeviceSelector.tsx b/src/app/profile/components/DeviceSelector.tsx index ff36d166c..8404d1baa 100644 --- a/src/app/profile/components/DeviceSelector.tsx +++ b/src/app/profile/components/DeviceSelector.tsx @@ -8,6 +8,7 @@ import { api } from '@/lib/api' import { cn } from '@/lib/utils' import getErrorMessage from '@/utils/getErrorMessage' import { searchItems, getDeviceSearchText } from '@/utils/simpleSearch' +import { ms } from '@/utils/time' interface Device { id: string @@ -29,16 +30,24 @@ interface Props { className?: string } +const LOOKUP_DATA_QUERY_OPTIONS = { + staleTime: ms.hours(6), + gcTime: ms.hours(12), +} + +const EMPTY_DEVICES: Device[] = [] + function DeviceSelector(props: Props) { const [searchQuery, setSearchQuery] = useState('') const [expandedBrands, setExpandedBrands] = useState>(new Set()) - const devicesQuery = api.devices.get.useQuery({ limit: 1000 }) + // TODO: Make this selector async instead of preloading 1000 options. + const devicesQuery = api.devices.options.useQuery({ limit: 1000 }, LOOKUP_DATA_QUERY_OPTIONS) + const devices = devicesQuery.data?.devices ?? EMPTY_DEVICES const filteredDevices = useMemo(() => { - if (!devicesQuery.data?.devices) return [] - return searchItems(devicesQuery.data.devices, searchQuery, getDeviceSearchText) - }, [devicesQuery.data?.devices, searchQuery]) + return searchItems(devices, searchQuery, getDeviceSearchText) + }, [devices, searchQuery]) // Group devices by brand for better organization const devicesByBrand = useMemo(() => { diff --git a/src/app/profile/components/PcPresetModal.tsx b/src/app/profile/components/PcPresetModal.tsx index 4abc52499..e7c330c57 100644 --- a/src/app/profile/components/PcPresetModal.tsx +++ b/src/app/profile/components/PcPresetModal.tsx @@ -1,17 +1,20 @@ 'use client' -import { useState, useEffect, type FormEvent } from 'react' +import { useCallback, useState, useEffect, type FormEvent } from 'react' import { Button, Input, Modal, Autocomplete, SelectInput } from '@/components/ui' import { PC_OS_OPTIONS } from '@/data/pc-os' import { api } from '@/lib/api' import { type RouterInput, type RouterOutput } from '@/types/trpc' import getErrorMessage from '@/utils/getErrorMessage' +import { ms } from '@/utils/time' import { PcOs } from '@orm' type PcPreset = RouterOutput['pcListings']['presets']['get'][number] type PcPresetMutationResult = | RouterOutput['pcListings']['presets']['create'] | RouterOutput['pcListings']['presets']['update'] +type CpuOption = RouterOutput['cpus']['options']['cpus'][number] +type GpuOption = RouterOutput['gpus']['options']['gpus'][number] interface Props { isOpen: boolean @@ -21,12 +24,15 @@ interface Props { } const OS_OPTIONS = PC_OS_OPTIONS +const LOOKUP_DATA_QUERY_OPTIONS = { + staleTime: ms.hours(6), + gcTime: ms.hours(12), +} function PcPresetModal(props: Props) { + const utils = api.useUtils() const createPreset = api.pcListings.presets.create.useMutation() const updatePreset = api.pcListings.presets.update.useMutation() - const cpusQuery = api.cpus.get.useQuery({ limit: 500 }) // TODO: make this async - const gpusQuery = api.gpus.get.useQuery({ limit: 500 }) // TODO: make this async const [name, setName] = useState('') const [cpuId, setCpuId] = useState('') @@ -37,6 +43,43 @@ function PcPresetModal(props: Props) { const [error, setError] = useState('') const [success, setSuccess] = useState('') + const selectedCpuQuery = api.cpus.getByIds.useQuery( + { ids: cpuId ? [cpuId] : [] }, + { ...LOOKUP_DATA_QUERY_OPTIONS, enabled: cpuId !== '' }, + ) + const selectedGpuQuery = api.gpus.getByIds.useQuery( + { ids: gpuId ? [gpuId] : [] }, + { ...LOOKUP_DATA_QUERY_OPTIONS, enabled: gpuId !== '' }, + ) + + const loadCpuItems = useCallback( + async (query: string): Promise => { + if (query.length < 2) return [] + try { + const result = await utils.cpus.options.fetch({ search: query, limit: 20 }) + return result.cpus + } catch (err) { + console.error('Error fetching CPUs:', err) + return [] + } + }, + [utils.cpus.options], + ) + + const loadGpuItems = useCallback( + async (query: string): Promise => { + if (query.length < 2) return [] + try { + const result = await utils.gpus.options.fetch({ search: query, limit: 20 }) + return result.gpus + } catch (err) { + console.error('Error fetching GPUs:', err) + return [] + } + }, + [utils.gpus.options], + ) + // Update form fields when preset changes useEffect(() => { if (props.preset) { @@ -106,9 +149,9 @@ function PcPresetModal(props: Props) { } } - const formatCpuLabel = (cpu: RouterOutput['cpus']['get']['cpus'][number]) => + const formatCpuLabel = (cpu: { brand: { name: string }; modelName: string }) => `${cpu.brand.name} ${cpu.modelName}` - const formatGpuLabel = (gpu: RouterOutput['gpus']['get']['gpus'][number]) => + const formatGpuLabel = (gpu: { brand: { name: string }; modelName: string }) => `${gpu.brand.name} ${gpu.modelName}` return ( @@ -141,14 +184,15 @@ function PcPresetModal(props: Props) { CPU setCpuId(value ?? '')} - items={cpusQuery.data?.cpus ?? []} + items={selectedCpuQuery.data ?? []} + loadItems={loadCpuItems} optionToValue={(cpu) => cpu.id} optionToLabel={formatCpuLabel} placeholder="Select a CPU..." className="w-full" - filterKeys={['modelName']} + minCharsToTrigger={2} />
@@ -157,14 +201,15 @@ function PcPresetModal(props: Props) { GPU setGpuId(value ?? '')} - items={gpusQuery.data?.gpus ?? []} + items={selectedGpuQuery.data ?? []} + loadItems={loadGpuItems} optionToValue={(gpu) => gpu.id} optionToLabel={formatGpuLabel} placeholder="Select a GPU..." className="w-full" - filterKeys={['modelName']} + minCharsToTrigger={2} />
diff --git a/src/app/profile/components/SocSelector.tsx b/src/app/profile/components/SocSelector.tsx index fb95964be..b2c9f601a 100644 --- a/src/app/profile/components/SocSelector.tsx +++ b/src/app/profile/components/SocSelector.tsx @@ -7,6 +7,7 @@ import { Input } from '@/components/ui' import { api } from '@/lib/api' import { cn } from '@/lib/utils' import getErrorMessage from '@/utils/getErrorMessage' +import { ms } from '@/utils/time' interface Soc { id: string @@ -19,10 +20,16 @@ interface Props { onSocsChange: (socs: Soc[]) => void } +const LOOKUP_DATA_QUERY_OPTIONS = { + staleTime: ms.hours(6), + gcTime: ms.hours(12), +} + function SocSelector(props: Props) { const [searchTerm, setSearchTerm] = useState('') const [expandedManufacturers, setExpandedManufacturers] = useState>(new Set()) - const socsQuery = api.socs.get.useQuery({ limit: 1000 }) // TODO: let's maybe not do this + // TODO: Make this selector async instead of preloading 1000 options. + const socsQuery = api.socs.options.useQuery({ limit: 1000 }, LOOKUP_DATA_QUERY_OPTIONS) const filteredSocs = useMemo(() => { const allSocs: Soc[] = diff --git a/src/app/v2/listings/V2ListingsPage.tsx b/src/app/v2/listings/V2ListingsPage.tsx index 8136c3295..c6bab30b8 100644 --- a/src/app/v2/listings/V2ListingsPage.tsx +++ b/src/app/v2/listings/V2ListingsPage.tsx @@ -24,6 +24,12 @@ type SortField = NonNullable type ListingType = RouterOutput['listings']['get']['listings'][number] +const LOOKUP_DATA_QUERY_OPTIONS = { + staleTime: ms.hours(6), + gcTime: ms.hours(12), +} +const USE_ASYNC_LISTING_FILTERS = process.env.NEXT_PUBLIC_ENABLE_ASYNC_LISTINGS_FILTERS === 'true' + function V2ListingsPage() { const listingsState = useListingsState() @@ -39,7 +45,10 @@ function V2ListingsPage() { const [allListings, setAllListings] = useState([]) const [myListingsOnly, setMyListingsOnly] = useState(false) - const performanceScalesQuery = api.listings.performanceScales.useQuery() + const performanceScalesQuery = api.listings.performanceScales.useQuery( + undefined, + LOOKUP_DATA_QUERY_OPTIONS, + ) // User preferences and device filtering const userQuery = api.users.me.useQuery() @@ -287,11 +296,19 @@ function V2ListingsPage() { listingsState.setSortDirection(direction || null) } - // Preload filter data with 500 item limits - const systemsQuery = api.systems.get.useQuery() - const devicesQuery = api.devices.get.useQuery({ limit: 500, offset: 0 }) - const emulatorsQuery = api.emulators.get.useQuery({ limit: 500, offset: 0 }) - const socsQuery = api.socs.get.useQuery({ limit: 500, offset: 0 }) + const systemsQuery = api.systems.get.useQuery(undefined, LOOKUP_DATA_QUERY_OPTIONS) + const devicesQuery = api.devices.options.useQuery( + { limit: 500, offset: 0 }, + { ...LOOKUP_DATA_QUERY_OPTIONS, enabled: !USE_ASYNC_LISTING_FILTERS }, + ) + const emulatorsQuery = api.emulators.get.useQuery( + { limit: 500, offset: 0 }, + LOOKUP_DATA_QUERY_OPTIONS, + ) + const socsQuery = api.socs.options.useQuery( + { limit: 500, offset: 0 }, + { ...LOOKUP_DATA_QUERY_OPTIONS, enabled: !USE_ASYNC_LISTING_FILTERS }, + ) // Transform preloaded data into options format const systemOpts = useMemo( @@ -404,6 +421,7 @@ function V2ListingsPage() { performanceIds={listingsState.performanceIds.map(String)} handlePerformanceChange={(values) => handlePerformanceChange(values.map(Number))} performanceScales={performanceScalesQuery.data} + useAsyncHardwareFilters={USE_ASYNC_LISTING_FILTERS} deviceIds={listingsState.deviceIds} handleDeviceChange={handleDeviceChange} deviceOptions={deviceOpts} diff --git a/src/app/v2/listings/components/ListingFilters.tsx b/src/app/v2/listings/components/ListingFilters.tsx index fc5312b9d..97a95de76 100644 --- a/src/app/v2/listings/components/ListingFilters.tsx +++ b/src/app/v2/listings/components/ListingFilters.tsx @@ -13,6 +13,8 @@ import { Search, } from 'lucide-react' import { useState, useEffect, type ReactNode } from 'react' +import AsyncDeviceFilterSelect from '@/app/listings/components/filters/AsyncDeviceFilterSelect' +import AsyncSocFilterSelect from '@/app/listings/components/filters/AsyncSocFilterSelect' import { MultiSelect, Button, Input, Badge } from '@/components/ui' import analytics from '@/lib/analytics' import { cn } from '@/lib/utils' @@ -45,6 +47,7 @@ interface Props { handlePerformanceChange: (values: string[]) => void performanceScales: PerformanceScale[] | undefined // Device filters + useAsyncHardwareFilters: boolean deviceIds: string[] handleDeviceChange: (values: string[]) => void deviceOptions: { id: string; name: string }[] | undefined @@ -345,18 +348,31 @@ export function ListingFilters(props: Props) { /> )} - {section.id === 'devices' && props.deviceOptions && ( - )} + {section.id === 'devices' && + !props.useAsyncHardwareFilters && + props.deviceOptions && ( + + )} + {section.id === 'emulators' && props.emulatorOptions && ( )} - {section.id === 'socs' && props.socOptions && ( - )} + + {section.id === 'socs' && + !props.useAsyncHardwareFilters && + props.socOptions && ( + + )} )} diff --git a/src/components/popups/BetaWarningPopup.tsx b/src/components/popups/BetaWarningPopup.tsx index 262351279..8b1721d84 100644 --- a/src/components/popups/BetaWarningPopup.tsx +++ b/src/components/popups/BetaWarningPopup.tsx @@ -4,6 +4,7 @@ import { AlertTriangle, X } from 'lucide-react' import { useState, useEffect } from 'react' import { Modal } from '@/components/ui' import storageKeys from '@/data/storageKeys' +import { env } from '@/lib/env' const discordUrl = process.env.NEXT_PUBLIC_DISCORD_LINK @@ -11,8 +12,7 @@ export function BetaWarningPopup() { const [isOpen, setIsOpen] = useState(false) useEffect(() => { - // Only show in production - if (process.env.NODE_ENV !== 'production') return + if (!env.IS_PUBLIC_PRODUCTION) return // Don't show on admin pages if (window.location.pathname.startsWith('/admin')) return @@ -34,7 +34,7 @@ export function BetaWarningPopup() { setIsOpen(false) } - if (process.env.NODE_ENV !== 'production' || !isOpen) return null + if (!env.IS_PUBLIC_PRODUCTION || !isOpen) return null return ( diff --git a/src/components/popups/StopKillingGamesPopup.tsx b/src/components/popups/StopKillingGamesPopup.tsx index 4409362cd..176f871f6 100644 --- a/src/components/popups/StopKillingGamesPopup.tsx +++ b/src/components/popups/StopKillingGamesPopup.tsx @@ -5,9 +5,11 @@ import { useState, useEffect, useRef } from 'react' import { Modal } from '@/components/ui' import storageKeys from '@/data/storageKeys' import analytics from '@/lib/analytics' +import { env } from '@/lib/env' const signPetitionUrl = 'https://eci.ec.europa.eu/045/public/#/screen/home' +// TODO: check if we still need this for something export function StopKillingGamesPopup() { const [isOpen, setIsOpen] = useState(false) @@ -20,8 +22,7 @@ export function StopKillingGamesPopup() { } useEffect(() => { - // Only show in production - if (process.env.NODE_ENV !== 'production') return + if (!env.IS_PUBLIC_PRODUCTION) return // Don't show on admin pages if (window.location.pathname.startsWith('/admin')) return @@ -52,7 +53,7 @@ export function StopKillingGamesPopup() { window.open(signPetitionUrl, '_blank', 'noopener,noreferrer') } - if (process.env.NODE_ENV !== 'production' || !isOpen) return null + if (!env.IS_PUBLIC_PRODUCTION || !isOpen) return null return ( diff --git a/src/components/ui/KofiWidget.tsx b/src/components/ui/KofiWidget.tsx index 59a945dc5..8b35ac859 100644 --- a/src/components/ui/KofiWidget.tsx +++ b/src/components/ui/KofiWidget.tsx @@ -6,17 +6,18 @@ import useMounted from '@/hooks/useMounted' function KofiWidget() { const mounted = useMounted() - if (!mounted) return + if (!mounted) return null const isMobile = window.innerWidth <= 768 || /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent) - if (isMobile) return + if (isMobile) return null return ( - )} From f6aec3ef707948a8a685659bed4154279ffb7199 Mon Sep 17 00:00:00 2001 From: Producdevity Date: Mon, 1 Jun 2026 19:27:44 +0200 Subject: [PATCH 27/35] fix: update Turnstile keys in environment example files --- .env.docker.example | 4 ++-- .env.example | 4 ++-- .env.test.example | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.env.docker.example b/.env.docker.example index 79fea89fb..8cfd56ce2 100644 --- a/.env.docker.example +++ b/.env.docker.example @@ -38,8 +38,8 @@ TUNNEL_TOKEN="your_cloudflare_tunnel_token_here" # CLOUDFLARE TURNSTILE (Optional) # =========================================== # Human verification -NEXT_PUBLIC_TURNSTILE_SITE_KEY="your_turnstile_site_key_here" -TURNSTILE_SECRET_KEY="your_turnstile_secret_key_here" +NEXT_PUBLIC_TURNSTILE_SITE_KEY=1x00000000000000000000AA +TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA # =========================================== # EMAIL SERVICES (Optional) diff --git a/.env.example b/.env.example index 7fe59b0d7..5e80fcf9f 100644 --- a/.env.example +++ b/.env.example @@ -17,8 +17,8 @@ NEXT_PUBLIC_IGDB_CLIENT_ID="IGDB-Client-ID" IGDB_CLIENT_KEY="IGDB-Client-Secret" # Cloudflare Turnstile -NEXT_PUBLIC_TURNSTILE_SITE_KEY=0x4XXXXXXXXXXXXXXXXXXXXX -TURNSTILE_SECRET_KEY=0x4XXXXXXXXXXXXXXXXXXXXXXXX-XXXXXXX +NEXT_PUBLIC_TURNSTILE_SITE_KEY=1x00000000000000000000AA +TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA # Email Providers (For the future) EMAIL_ENABLED=false diff --git a/.env.test.example b/.env.test.example index 093105bf0..9dc7a9b4a 100644 --- a/.env.test.example +++ b/.env.test.example @@ -7,8 +7,8 @@ NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY="pk_test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx CLERK_SECRET_KEY="sk_test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" # Cloudflare Turnstile -NEXT_PUBLIC_TURNSTILE_SITE_KEY= -TURNSTILE_SECRET_KEY= +NEXT_PUBLIC_TURNSTILE_SITE_KEY=1x00000000000000000000AA +TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA # Email (For the future) EMAIL_ENABLED=false From ea06e2b8b095606997f1e7d4338baa402684fb9d Mon Sep 17 00:00:00 2001 From: Producdevity Date: Mon, 1 Jun 2026 19:38:15 +0200 Subject: [PATCH 28/35] fix: implement rate limits and duplicate detection for game submissions --- src/app/games/new/NewGamePage.tsx | 17 ++- src/app/games/new/search/GameSearchPage.tsx | 33 +++-- .../games/new/search/v2/IGDBSearchPage.tsx | 37 +++-- src/schemas/game.ts | 2 + src/server/api/routers/games.ts | 36 ++++- src/server/utils/spam-check.ts | 5 +- src/server/utils/spamDetection.test.ts | 86 ++++++++++- src/server/utils/spamDetection.ts | 137 ++++++++++++++++-- 8 files changed, 294 insertions(+), 59 deletions(-) diff --git a/src/app/games/new/NewGamePage.tsx b/src/app/games/new/NewGamePage.tsx index 176b584ff..96c834032 100644 --- a/src/app/games/new/NewGamePage.tsx +++ b/src/app/games/new/NewGamePage.tsx @@ -13,6 +13,7 @@ import { Autocomplete, type AutocompleteOptionBase, } from '@/components/ui' +import { useSubmitWithHumanVerification } from '@/features/human-verification/client' import { api } from '@/lib/api' import { cn } from '@/lib/utils' import getErrorMessage from '@/utils/getErrorMessage' @@ -39,6 +40,7 @@ function AddGamePage() { const utils = api.useUtils() const systemsQuery = api.systems.get.useQuery() const createGame = api.games.create.useMutation() + const submitWithHumanVerification = useSubmitWithHumanVerification() const userQuery = api.users.me.useQuery(undefined, { enabled: !!user }) @@ -81,12 +83,15 @@ function AddGamePage() { if (!title || !systemId) return setError('Please fill in all required fields.') try { - const result = await createGame.mutateAsync({ - title, - systemId, - imageUrl: imageUrl || undefined, - isErotic, - }) + const result = await submitWithHumanVerification((humanVerificationToken) => + createGame.mutateAsync({ + title, + systemId, + imageUrl: imageUrl || undefined, + isErotic, + humanVerificationToken, + }), + ) await utils.games.get.invalidate() await utils.games.checkExistingByTgdbIds.invalidate() diff --git a/src/app/games/new/search/GameSearchPage.tsx b/src/app/games/new/search/GameSearchPage.tsx index 45154c50d..367b06d39 100644 --- a/src/app/games/new/search/GameSearchPage.tsx +++ b/src/app/games/new/search/GameSearchPage.tsx @@ -11,7 +11,9 @@ import { type BaseGameResult, } from '@/components/game-search' import { LoadingSpinner } from '@/components/ui' +import { useSubmitWithHumanVerification } from '@/features/human-verification/client' import { api } from '@/lib/api' +import { logger } from '@/lib/logger' import getErrorMessage from '@/utils/getErrorMessage' import { hasRolePermission } from '@/utils/permissions' import { Role } from '@orm' @@ -22,7 +24,6 @@ import { handleGameCreationError } from './utils/gameCreationErrors' import NotSignedInMessage from '../components/NotSignedInMessage' import type { TGDBGame, TGDBGamesByNameResponse } from '@/types/tgdb' -// Extended TGDB game result that extends BaseGameResult interface TGDBGameResult extends BaseGameResult { id: string overview?: string @@ -64,7 +65,6 @@ function TGDBSearchContent() { const searchParams = useSearchParams() const { user, isLoaded } = useUser() - // Get values directly from URL const urlQuery = searchParams.get('q') ?? '' const urlSystemId = searchParams.get('system') ?? '' @@ -83,6 +83,7 @@ function TGDBSearchContent() { enabled: !!user, }) const createGame = api.games.create.useMutation() + const submitWithHumanVerification = useSubmitWithHumanVerification() const systemsQuery = api.systems.get.useQuery() const { existingGames, updateSearchParams } = useGameSearch({ @@ -94,10 +95,8 @@ function TGDBSearchContent() { async (query: string, _platformId: number | null, systemId: string | null) => { setIsSearching(true) - // Update URL with search parameters updateSearchParams(query, systemId) - // Get system key from systemId const selectedSystem = systemId ? systemsQuery.data?.find((s) => s.id === systemId) : null try { @@ -111,7 +110,6 @@ function TGDBSearchContent() { // Map TGDB games to our unified format const gameData = results.data?.games ?? [] const mappedGames = gameData.map((game) => { - // Extract boxart URL from the search response const boxartUrl = extractBoxartUrl(game, results) const enrichedGame: TGDBGameWithBoxart = { @@ -132,7 +130,7 @@ function TGDBSearchContent() { count: results.data?.count ?? 0, }) } catch (error) { - console.error('Search error:', error) + logger.error('Search error:', error) toast.error(getErrorMessage(error, 'Failed to search games')) } finally { setIsSearching(false) @@ -184,15 +182,18 @@ function TGDBSearchContent() { setIsSelecting(true) try { - const newGame = await createGame.mutateAsync({ - title: game.game_title, - systemId, - imageUrl: game.boxart ?? null, - boxartUrl: game.boxart ?? null, - bannerUrl: null, - isErotic: false, - tgdbGameId: game.id, - }) + const newGame = await submitWithHumanVerification((humanVerificationToken) => + createGame.mutateAsync({ + title: game.game_title, + systemId, + imageUrl: game.boxart ?? null, + boxartUrl: game.boxart ?? null, + bannerUrl: null, + isErotic: false, + tgdbGameId: game.id, + humanVerificationToken, + }), + ) // Invalidate the existing games cache to ensure the new game is reflected await utils.games.checkExistingByNamesAndSystems.invalidate() @@ -208,7 +209,7 @@ function TGDBSearchContent() { setIsSelecting(false) } }, - [user, createGame, router, utils], + [user, submitWithHumanVerification, createGame, router, utils], ) const isModeratorOrHigher = useMemo(() => { diff --git a/src/app/games/new/search/v2/IGDBSearchPage.tsx b/src/app/games/new/search/v2/IGDBSearchPage.tsx index 7b49f10bd..7f5336536 100644 --- a/src/app/games/new/search/v2/IGDBSearchPage.tsx +++ b/src/app/games/new/search/v2/IGDBSearchPage.tsx @@ -12,7 +12,9 @@ import { type BaseGameResult, } from '@/components/game-search' import { LoadingSpinner, useConfirmDialog } from '@/components/ui' +import { useSubmitWithHumanVerification } from '@/features/human-verification/client' import { api } from '@/lib/api' +import { logger } from '@/lib/logger' import getErrorMessage from '@/utils/getErrorMessage' import { hasRolePermission } from '@/utils/permissions' import { getIGDBPlatformId } from '@/utils/system-platform-mapping' @@ -56,6 +58,7 @@ function IGDBSearchContent() { enabled: !!user, }) const createGame = api.games.create.useMutation() + const submitWithHumanVerification = useSubmitWithHumanVerification() const systemsQuery = api.systems.get.useQuery() const confirm = useConfirmDialog() @@ -66,7 +69,6 @@ function IGDBSearchContent() { const handleSearch = useCallback( async (query: string, platformId: number | null, systemId: string | null) => { - // Require system selection for game creation if (!systemId) { toast.warning('Please select a system before searching for games') return @@ -82,7 +84,6 @@ function IGDBSearchContent() { platformId, limit: 20, }) - // Map the results to ensure consistent types setSearchResults({ games: results.games.map((game) => ({ ...game, @@ -91,7 +92,7 @@ function IGDBSearchContent() { count: results.count, }) } catch (error) { - console.error('Search error:', error) + logger.error('Search error:', error) toast.error(getErrorMessage(error, 'Failed to search games')) } finally { setIsSearching(false) @@ -144,15 +145,18 @@ function IGDBSearchContent() { setIsSelecting(true) try { - const newGame = await createGame.mutateAsync({ - title: selectedGame.name, - systemId, - imageUrl: selectedGame.imageUrl ?? null, - boxartUrl: selectedGame.boxartUrl ?? null, - bannerUrl: selectedGame.bannerUrl ?? null, - isErotic: selectedGame.isErotic ?? false, - igdbGameId: Number(selectedGame.id), - }) + const newGame = await submitWithHumanVerification((humanVerificationToken) => + createGame.mutateAsync({ + title: selectedGame.name, + systemId, + imageUrl: selectedGame.imageUrl ?? null, + boxartUrl: selectedGame.boxartUrl ?? null, + bannerUrl: selectedGame.bannerUrl ?? null, + isErotic: selectedGame.isErotic ?? false, + igdbGameId: Number(selectedGame.id), + humanVerificationToken, + }), + ) await utils.games.checkExistingByNamesAndSystems.invalidate() @@ -167,7 +171,14 @@ function IGDBSearchContent() { setIsSelecting(false) } }, - [selectedGame, user, createGame, utils, showGameCreatedConfirmation], + [ + selectedGame, + user, + submitWithHumanVerification, + createGame, + utils, + showGameCreatedConfirmation, + ], ) const isModeratorOrHigher = hasRolePermission(userQuery?.data?.role, Role.MODERATOR) diff --git a/src/schemas/game.ts b/src/schemas/game.ts index 212c7d9a5..74e62059d 100644 --- a/src/schemas/game.ts +++ b/src/schemas/game.ts @@ -1,4 +1,5 @@ import { z } from 'zod' +import { HumanVerificationTokenSchema } from '@/features/human-verification/shared/schema' import { ApprovalStatus } from '@orm' export const GameSortField = z.enum([ @@ -72,6 +73,7 @@ export const CheckExistingByNamesAndSystemsSchema = z.object({ export const CreateGameSchema = z.object({ title: z.string().min(1), systemId: z.string().uuid(), + humanVerificationToken: HumanVerificationTokenSchema.optional(), imageUrl: z.string().nullable().optional(), boxartUrl: z.string().nullable().optional(), bannerUrl: z.string().nullable().optional(), diff --git a/src/server/api/routers/games.ts b/src/server/api/routers/games.ts index 2b16e4d7f..41617942a 100644 --- a/src/server/api/routers/games.ts +++ b/src/server/api/routers/games.ts @@ -50,6 +50,7 @@ import { sanitizeInput, validateNonEmptyArray, } from '@/server/utils/security-validation' +import { checkSpamContent } from '@/server/utils/spam-check' import { getSubmissionBalance } from '@/server/utils/submission-balance' import { findTitleIdForGameName, @@ -292,8 +293,19 @@ export const gamesRouter = createTRPCRouter({ }), create: protectedProcedure.input(CreateGameSchema).mutation(async ({ ctx, input }) => { + const gameInput = { + title: input.title, + systemId: input.systemId, + imageUrl: input.imageUrl, + boxartUrl: input.boxartUrl, + bannerUrl: input.bannerUrl, + tgdbGameId: input.tgdbGameId, + igdbGameId: input.igdbGameId, + isErotic: input.isErotic, + } + const system = await ctx.prisma.system.findUnique({ - where: { id: input.systemId }, + where: { id: gameInput.systemId }, }) if (!system) return ResourceError.system.notFound() @@ -314,15 +326,27 @@ export const gamesRouter = createTRPCRouter({ // Check if game with same title already exists for this system const existingGame = await ctx.prisma.game.findFirst({ - where: { title: input.title, systemId: input.systemId }, + where: { title: gameInput.title, systemId: gameInput.systemId }, }) if (existingGame) { - return ResourceError.game.alreadyExists(input.title, system.name, existingGame.id) + return ResourceError.game.alreadyExists(gameInput.title, system.name, existingGame.id) } + await checkSpamContent({ + prisma: ctx.prisma, + userId: ctx.session.user.id, + content: gameInput.title, + entityType: 'game', + challengeMode: 'challenge', + enableRateLimiting: false, + enableDuplicateDetection: false, + humanVerificationToken: input.humanVerificationToken, + headers: ctx.headers, + }) + try { - const { igdbGameId, ...gameData } = input + const { igdbGameId, ...gameData } = gameInput // Build metadata object for external provider IDs const metadata = createGameMetadata({ @@ -357,7 +381,7 @@ export const gamesRouter = createTRPCRouter({ invalidateListPages(), invalidateSitemap(), revalidateByTag('games'), - revalidateByTag(`system-${input.systemId}`), + revalidateByTag(`system-${gameInput.systemId}`), ]).catch((error) => { logger.error('[Games Router] Cache invalidation failed:', error) }) @@ -366,7 +390,7 @@ export const gamesRouter = createTRPCRouter({ return result } catch (error) { if (isPrismaError(error, PRISMA_ERROR_CODES.UNIQUE_CONSTRAINT_VIOLATION)) { - return ResourceError.game.alreadyExists(input.title, system.name) + return ResourceError.game.alreadyExists(gameInput.title, system.name) } throw error } diff --git a/src/server/utils/spam-check.ts b/src/server/utils/spam-check.ts index ab2254c97..6ae5a1ad0 100644 --- a/src/server/utils/spam-check.ts +++ b/src/server/utils/spam-check.ts @@ -17,13 +17,16 @@ interface CheckSpamContentParams { content: string entityType: SpamEntityType challengeMode?: 'block' | 'challenge' + enableRateLimiting?: boolean + enableDuplicateDetection?: boolean humanVerificationToken?: string | null headers?: Headers } export async function checkSpamContent(params: CheckSpamContentParams): Promise { const detector = new SpamDetectionService(params.prisma, { - enableRateLimiting: process.env.DISABLE_RATE_LIMIT !== 'true', + enableRateLimiting: params.enableRateLimiting ?? process.env.DISABLE_RATE_LIMIT !== 'true', + enableDuplicateDetection: params.enableDuplicateDetection ?? true, }) const result = await detector.detectSpam({ userId: params.userId, diff --git a/src/server/utils/spamDetection.test.ts b/src/server/utils/spamDetection.test.ts index 6b5a00803..96967c4aa 100644 --- a/src/server/utils/spamDetection.test.ts +++ b/src/server/utils/spamDetection.test.ts @@ -1,8 +1,11 @@ import { describe, it, expect, beforeEach, vi } from 'vitest' -import { type PrismaClient } from '@orm/client' +import { Role, type PrismaClient } from '@orm/client' import { SpamDetectionService } from './spamDetection' const mockPrisma = { + user: { + findUnique: vi.fn(), + }, listing: { count: vi.fn(), findMany: vi.fn(), @@ -19,17 +22,28 @@ const mockPrisma = { count: vi.fn(), findMany: vi.fn(), }, + game: { + count: vi.fn(), + findMany: vi.fn(), + }, } function mockCleanRecentActivity() { + mockPrisma.user.findUnique.mockResolvedValue({ + createdAt: new Date(), + role: Role.USER, + trustScore: 0, + }) mockPrisma.listing.count.mockResolvedValue(0) mockPrisma.pcListing.count.mockResolvedValue(0) mockPrisma.comment.count.mockResolvedValue(0) mockPrisma.pcListingComment.count.mockResolvedValue(0) + mockPrisma.game.count.mockResolvedValue(0) mockPrisma.listing.findMany.mockResolvedValue([]) mockPrisma.pcListing.findMany.mockResolvedValue([]) mockPrisma.comment.findMany.mockResolvedValue([]) mockPrisma.pcListingComment.findMany.mockResolvedValue([]) + mockPrisma.game.findMany.mockResolvedValue([]) } describe('SpamDetectionService', () => { @@ -99,6 +113,57 @@ describe('SpamDetectionService', () => { expect(result.isSpam).toBe(true) expect(result.method).toBe('rate_limiting') }) + + it('raises the report limit for established accounts', async () => { + vi.mocked(mockPrisma.user.findUnique).mockResolvedValue({ + createdAt: new Date(Date.now() - 31 * 24 * 60 * 60 * 1000), + role: Role.USER, + trustScore: 0, + }) + vi.mocked(mockPrisma.listing.count).mockResolvedValue(5) + + const result = await service.detectSpam({ + userId: 'user-123', + content: 'Normal content', + entityType: 'listing', + }) + + expect(result.isSpam).toBe(false) + }) + + it('raises the report limit for trusted accounts while keeping a cap', async () => { + vi.mocked(mockPrisma.user.findUnique).mockResolvedValue({ + createdAt: new Date(), + role: Role.USER, + trustScore: 250, + }) + vi.mocked(mockPrisma.listing.count).mockResolvedValue(40) + + const result = await service.detectSpam({ + userId: 'user-123', + content: 'Normal content', + entityType: 'listing', + }) + + expect(result.isSpam).toBe(true) + expect(result.method).toBe('rate_limiting') + }) + + it('uses a dedicated rate limit bucket for games', async () => { + vi.mocked(mockPrisma.game.count).mockResolvedValue(10) + + const result = await service.detectSpam({ + userId: 'user-123', + content: 'Normal game title', + entityType: 'game', + }) + + expect(result.isSpam).toBe(true) + expect(result.method).toBe('rate_limiting') + expect(result.reason).toContain('Too many recent games') + expect(mockPrisma.comment.count).not.toHaveBeenCalled() + expect(mockPrisma.pcListingComment.count).not.toHaveBeenCalled() + }) }) describe('Duplicate Content Detection', () => { @@ -173,6 +238,25 @@ describe('SpamDetectionService', () => { expect(result.method).toBe('duplicate_detection') }) + it('uses game titles for duplicate game detection', async () => { + vi.mocked(mockPrisma.game.findMany).mockResolvedValue([ + { title: 'Duplicate Game' }, + { title: 'Duplicate Game' }, + { title: 'Duplicate Game' }, + ]) + + const result = await service.detectSpam({ + userId: 'user-123', + content: 'Duplicate Game', + entityType: 'game', + }) + + expect(result.isSpam).toBe(true) + expect(result.method).toBe('duplicate_detection') + expect(mockPrisma.comment.findMany).not.toHaveBeenCalled() + expect(mockPrisma.pcListingComment.findMany).not.toHaveBeenCalled() + }) + it('should NOT detect spam for slightly different content', async () => { vi.mocked(mockPrisma.listing.findMany).mockResolvedValue([ { notes: 'Game runs great on my device' }, diff --git a/src/server/utils/spamDetection.ts b/src/server/utils/spamDetection.ts index 4e9938ecc..1a217ad45 100644 --- a/src/server/utils/spamDetection.ts +++ b/src/server/utils/spamDetection.ts @@ -1,4 +1,4 @@ -import { type PrismaClient } from '@orm/client' +import { Role, type PrismaClient } from '@orm/client' export interface SpamDetectionResult { isSpam: boolean @@ -7,15 +7,21 @@ export interface SpamDetectionResult { reason?: string } -export type SpamEntityType = 'listing' | 'pcListing' | 'comment' | 'pcComment' +export type SpamEntityType = 'listing' | 'pcListing' | 'comment' | 'pcComment' | 'game' -type SpamEntityCategory = 'report' | 'comment' +type SpamEntityCategory = 'report' | 'comment' | 'game' interface RateLimitRule { windowMinutes: number maxItems: number } +interface UserRateLimitProfile { + createdAt: Date + role: Role + trustScore: number +} + export interface SpamDetectionConfig { enableRateLimiting?: boolean enableContentAnalysis?: boolean @@ -52,8 +58,23 @@ const DEFAULT_RATE_LIMITS: Record = { windowMinutes: 5, maxItems: 10, }, + game: { + windowMinutes: 15, + maxItems: 10, + }, } +const REPORT_RATE_LIMIT_TIERS = { + ESTABLISHED_ACCOUNT_DAYS: 30, + CONTRIBUTOR_TRUST_SCORE: 100, + TRUSTED_TRUST_SCORE: 250, + ESTABLISHED_MAX_ITEMS: 20, + TRUSTED_MAX_ITEMS: 40, + STAFF_MAX_ITEMS: 100, +} as const + +const STAFF_ROLES = new Set([Role.MODERATOR, Role.ADMIN, Role.SUPER_ADMIN]) + interface NormalizedSpamDetectionConfig { enableRateLimiting: boolean enableContentAnalysis: boolean @@ -73,17 +94,40 @@ const DEFAULT_CONFIG: NormalizedSpamDetectionConfig = { } function getEntityCategory(entityType: SpamEntityType): SpamEntityCategory { - return entityType === 'listing' || entityType === 'pcListing' ? 'report' : 'comment' + if (entityType === 'listing' || entityType === 'pcListing') return 'report' + if (entityType === 'game') return 'game' + return 'comment' } function formatEntityCategory(category: SpamEntityCategory): string { - return category === 'report' ? 'reports' : 'comments' + if (category === 'report') return 'reports' + if (category === 'game') return 'games' + return 'comments' } function isNonEmptyString(value: string | null | undefined): value is string { return typeof value === 'string' && value.trim().length > 0 } +function getAccountAgeDays(createdAt: Date): number { + return (Date.now() - createdAt.getTime()) / (24 * 60 * 60 * 1000) +} + +function getReportRateLimitMax(profile: UserRateLimitProfile): number { + if (STAFF_ROLES.has(profile.role)) return REPORT_RATE_LIMIT_TIERS.STAFF_MAX_ITEMS + if (profile.trustScore >= REPORT_RATE_LIMIT_TIERS.TRUSTED_TRUST_SCORE) { + return REPORT_RATE_LIMIT_TIERS.TRUSTED_MAX_ITEMS + } + if ( + profile.trustScore >= REPORT_RATE_LIMIT_TIERS.CONTRIBUTOR_TRUST_SCORE || + getAccountAgeDays(profile.createdAt) >= REPORT_RATE_LIMIT_TIERS.ESTABLISHED_ACCOUNT_DAYS + ) { + return REPORT_RATE_LIMIT_TIERS.ESTABLISHED_MAX_ITEMS + } + + return DEFAULT_RATE_LIMITS.report.maxItems +} + export class SpamDetectionService { private readonly config: NormalizedSpamDetectionConfig @@ -103,6 +147,10 @@ export class SpamDetectionService { ...DEFAULT_CONFIG.rateLimits.comment, ...config.rateLimits?.comment, }, + game: { + ...DEFAULT_CONFIG.rateLimits.game, + ...config.rateLimits?.game, + }, }, } } @@ -163,13 +211,10 @@ export class SpamDetectionService { entityType: SpamEntityType, ): Promise { const category = getEntityCategory(entityType) - const rule = this.getRateLimitRule(category) + const rule = await this.getRateLimitRule(category, userId) const windowStart = new Date(Date.now() - rule.windowMinutes * 60 * 1000) - const count = - category === 'report' - ? await this.countRecentReports(userId, windowStart) - : await this.countRecentComments(userId, windowStart) + const count = await this.countRecentContent(category, userId, windowStart) if (count >= rule.maxItems) { return { @@ -183,12 +228,42 @@ export class SpamDetectionService { return { isSpam: false, confidence: 0, method: 'rate_limiting' } } - private getRateLimitRule(category: SpamEntityCategory): RateLimitRule { + private async getRateLimitRule( + category: SpamEntityCategory, + userId: string, + ): Promise { const configured = this.config.rateLimits[category] - return { + const baseRule = { windowMinutes: this.config.rateLimitWindow ?? configured.windowMinutes, maxItems: this.config.rateLimitMax ?? configured.maxItems, } + + if (category !== 'report' || this.config.rateLimitMax !== undefined) return baseRule + + const profile = await this.getUserRateLimitProfile(userId) + if (!profile) return baseRule + + return { + ...baseRule, + maxItems: Math.max(baseRule.maxItems, getReportRateLimitMax(profile)), + } + } + + private async getUserRateLimitProfile(userId: string): Promise { + return await this.prisma.user.findUnique({ + where: { id: userId }, + select: { createdAt: true, role: true, trustScore: true }, + }) + } + + private async countRecentContent( + category: SpamEntityCategory, + userId: string, + windowStart: Date, + ): Promise { + if (category === 'report') return await this.countRecentReports(userId, windowStart) + if (category === 'game') return await this.countRecentGames(userId, windowStart) + return await this.countRecentComments(userId, windowStart) } private async countRecentReports(userId: string, windowStart: Date): Promise { @@ -210,6 +285,15 @@ export class SpamDetectionService { return handheldCount + pcCount } + private async countRecentGames(userId: string, windowStart: Date): Promise { + return await this.prisma.game.count({ + where: { + submittedBy: userId, + submittedAt: { gte: windowStart }, + }, + }) + } + private async countRecentComments(userId: string, windowStart: Date): Promise { const [handheldCount, pcCount] = await Promise.all([ this.prisma.comment.count({ @@ -241,10 +325,7 @@ export class SpamDetectionService { if (!normalizedContent) return { isSpam: false, confidence: 0, method: 'duplicate_detection' } const category = getEntityCategory(entityType) - const recentContent = - category === 'report' - ? await this.getRecentReportContent(userId, recentTimeWindow) - : await this.getRecentCommentContent(userId, recentTimeWindow) + const recentContent = await this.getRecentContent(category, userId, recentTimeWindow) const duplicates = recentContent.filter((recent) => { const normalized = this.normalizeContent(recent) @@ -266,6 +347,16 @@ export class SpamDetectionService { return { isSpam: false, confidence: 0, method: 'duplicate_detection' } } + private async getRecentContent( + category: SpamEntityCategory, + userId: string, + recentTimeWindow: Date, + ): Promise { + if (category === 'report') return await this.getRecentReportContent(userId, recentTimeWindow) + if (category === 'game') return await this.getRecentGameContent(userId, recentTimeWindow) + return await this.getRecentCommentContent(userId, recentTimeWindow) + } + private async getRecentReportContent(userId: string, recentTimeWindow: Date): Promise { const [recentListings, recentPcListings] = await Promise.all([ this.prisma.listing.findMany({ @@ -295,6 +386,20 @@ export class SpamDetectionService { .filter(isNonEmptyString) } + private async getRecentGameContent(userId: string, recentTimeWindow: Date): Promise { + const recentGames = await this.prisma.game.findMany({ + where: { + submittedBy: userId, + submittedAt: { gte: recentTimeWindow }, + }, + select: { title: true }, + take: SPAM_DETECTION_THRESHOLDS.MAX_RECENT_ITEMS_TO_CHECK, + orderBy: { submittedAt: 'desc' }, + }) + + return recentGames.map((entry) => entry.title).filter(isNonEmptyString) + } + private async getRecentCommentContent(userId: string, recentTimeWindow: Date): Promise { const [recentComments, recentPcComments] = await Promise.all([ this.prisma.comment.findMany({ From 01c294c63f4a187322578380e5a7cea5cc143c62 Mon Sep 17 00:00:00 2001 From: Producdevity Date: Mon, 1 Jun 2026 19:40:43 +0200 Subject: [PATCH 29/35] fix: update game placeholder SVG with new design and layout adjustments --- public/placeholder/game.svg | 49 +++++++++++++++++++++++++++++++++---- 1 file changed, 44 insertions(+), 5 deletions(-) diff --git a/public/placeholder/game.svg b/public/placeholder/game.svg index c69a573a3..6d2627eb2 100644 --- a/public/placeholder/game.svg +++ b/public/placeholder/game.svg @@ -1,8 +1,50 @@ + + + + + + + Game Cover - + From 8eba5f7e2edbcb2efeae67cfa29089089ddff640 Mon Sep 17 00:00:00 2001 From: Producdevity Date: Mon, 1 Jun 2026 19:41:13 +0200 Subject: [PATCH 30/35] fix: improve Turnstile script handling and widget rendering reliability, add support for structured error callbacks and flexible UI options --- .../HumanVerificationProvider.test.tsx | 107 +++++++++++++++--- .../components/HumanVerificationProvider.tsx | 87 +++++++++----- 2 files changed, 151 insertions(+), 43 deletions(-) diff --git a/src/features/human-verification/client/components/HumanVerificationProvider.test.tsx b/src/features/human-verification/client/components/HumanVerificationProvider.test.tsx index c34312115..e6cf0a857 100644 --- a/src/features/human-verification/client/components/HumanVerificationProvider.test.tsx +++ b/src/features/human-verification/client/components/HumanVerificationProvider.test.tsx @@ -1,8 +1,16 @@ import { act, fireEvent, render, screen } from '@testing-library/react' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { HumanVerificationProvider, useHumanVerification } from './HumanVerificationProvider' import { HUMAN_VERIFICATION_ACTION } from '../../shared/constants' +interface ScriptProps { + src: string + onReady?: () => void + onError?: () => void +} + +const scriptProps: ScriptProps[] = [] + vi.mock('@/lib/env', () => ({ env: { TURNSTILE_SITE_KEY: 'site-key', @@ -10,7 +18,8 @@ vi.mock('@/lib/env', () => ({ })) vi.mock('next/script', () => ({ - default: function Script() { + default: function Script(props: ScriptProps) { + scriptProps.push(props) return null }, })) @@ -35,9 +44,9 @@ function CaptureButton(props: CaptureButtonProps) { } function createTurnstileStub() { - const renderWidget = vi.fn<(container: HTMLElement, options: Record) => string>( - () => 'widget-id', - ) + const renderWidget = vi.fn< + (container: HTMLElement, options: Record) => string | undefined + >(() => 'widget-id') const ready = vi.fn(() => { throw new Error('turnstile.ready() must not be called when api.js is loaded async/defer') }) @@ -55,9 +64,12 @@ function createTurnstileStub() { } describe('HumanVerificationProvider', () => { + beforeEach(() => { + scriptProps.length = 0 + }) + afterEach(() => { delete window.turnstile - delete window.onloadTurnstileCallback vi.unstubAllEnvs() }) @@ -66,7 +78,12 @@ describe('HumanVerificationProvider', () => { render( - requests.push(promise)} /> + { + requests.push(promise) + void promise.catch(() => {}) + }} + /> , ) @@ -78,20 +95,26 @@ describe('HumanVerificationProvider', () => { await expect(requests[1]).rejects.toThrow(/already in progress/i) }) - it('becomes ready via onloadTurnstileCallback, renders the widget directly, then resolves with the token', async () => { + it('becomes ready from the script onReady callback, renders the widget, then resolves with the token', async () => { const stub = createTurnstileStub() const requests: Promise[] = [] render( - requests.push(promise)} /> + { + requests.push(promise) + void promise.catch(() => {}) + }} + /> , ) - expect(typeof window.onloadTurnstileCallback).toBe('function') - window.turnstile = stub.api - act(() => window.onloadTurnstileCallback?.()) + expect(scriptProps[0].src).toBe( + 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit', + ) + act(() => scriptProps[0].onReady?.()) fireEvent.click(screen.getByRole('button', { name: /request verification/i })) @@ -101,6 +124,8 @@ describe('HumanVerificationProvider', () => { const options = stub.renderWidget.mock.calls[0][1] expect(options.sitekey).toBe('site-key') expect(options.action).toBe(HUMAN_VERIFICATION_ACTION) + expect(options.size).toBe('flexible') + expect(options.appearance).toBe('always') const onToken = options.callback if (typeof onToken !== 'function') throw new Error('callback option is not a function') @@ -119,11 +144,65 @@ describe('HumanVerificationProvider', () => { , ) - expect(window.onloadTurnstileCallback).toBeUndefined() - fireEvent.click(screen.getByRole('button', { name: /request verification/i })) expect(stub.renderWidget).toHaveBeenCalledTimes(1) expect(stub.ready).not.toHaveBeenCalled() }) + + it('rejects when Turnstile cannot render a widget id', async () => { + const stub = createTurnstileStub() + stub.renderWidget.mockReturnValueOnce(undefined) + const requests: Promise[] = [] + + render( + + { + requests.push(promise) + void promise.catch(() => {}) + }} + /> + , + ) + + window.turnstile = stub.api + act(() => scriptProps[0].onReady?.()) + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /request verification/i })) + await Promise.resolve() + }) + + await expect(requests[0]).rejects.toThrow(/failed to render/i) + }) + + it('rejects when Turnstile render throws', async () => { + const stub = createTurnstileStub() + stub.renderWidget.mockImplementationOnce(() => { + throw new Error('render failed') + }) + const requests: Promise[] = [] + + render( + + { + requests.push(promise) + void promise.catch(() => {}) + }} + /> + , + ) + + window.turnstile = stub.api + act(() => scriptProps[0].onReady?.()) + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /request verification/i })) + await Promise.resolve() + }) + + await expect(requests[0]).rejects.toThrow(/failed to render/i) + }) }) diff --git a/src/features/human-verification/client/components/HumanVerificationProvider.tsx b/src/features/human-verification/client/components/HumanVerificationProvider.tsx index 7e09cebd3..8f2589692 100644 --- a/src/features/human-verification/client/components/HumanVerificationProvider.tsx +++ b/src/features/human-verification/client/components/HumanVerificationProvider.tsx @@ -34,7 +34,6 @@ interface TurnstileApi { declare global { interface Window { turnstile?: TurnstileApi - onloadTurnstileCallback?: () => void } } @@ -45,6 +44,8 @@ interface VerificationRequest { type RequestVerification = (request: VerificationRequest) => Promise type ScriptStatus = 'idle' | 'ready' | 'failed' +const VERIFICATION_SCRIPT_LOAD_TIMEOUT_MS = 20_000 + const HumanVerificationContext = createContext(null) interface PendingRequest { @@ -92,37 +93,64 @@ export function HumanVerificationProvider(props: PropsWithChildren) { }, []) useEffect(() => { - if (window.turnstile) return - window.onloadTurnstileCallback = () => setScriptStatus('ready') - return () => { - delete window.onloadTurnstileCallback - } - }, []) + if (!pendingRequest || scriptStatus !== 'idle') return + + const timeoutId = window.setTimeout(() => { + setScriptStatus('failed') + pendingRequest.reject(new Error('Human verification failed to load. Please try again.')) + closeDialog() + }, VERIFICATION_SCRIPT_LOAD_TIMEOUT_MS) + + return () => window.clearTimeout(timeoutId) + }, [closeDialog, pendingRequest, scriptStatus]) useEffect(() => { const turnstile = window.turnstile if (!pendingRequest || scriptStatus !== 'ready' || !widgetContainer || !turnstile) return - const widgetId = turnstile.render(widgetContainer, { - sitekey: turnstileSiteKey, - action: pendingRequest.action, - theme: 'auto', - callback: (token: string) => { - pendingRequest.resolve(token) - closeDialog() - }, - 'error-callback': () => { - pendingRequest.reject(new Error('Human verification failed. Please try again.')) - closeDialog() - }, - 'expired-callback': () => { - if (widgetIdRef.current && window.turnstile) { - window.turnstile.reset(widgetIdRef.current) - } - }, - }) - - widgetIdRef.current = widgetId ?? null + let widgetId: TurnstileWidgetId | undefined + try { + widgetId = turnstile.render(widgetContainer, { + sitekey: turnstileSiteKey, + action: pendingRequest.action, + theme: 'auto', + size: 'flexible', + appearance: 'always', + callback: (token: string) => { + pendingRequest.resolve(token) + closeDialog() + }, + 'error-callback': () => { + pendingRequest.reject(new Error('Human verification failed. Please try again.')) + closeDialog() + }, + 'expired-callback': () => { + if (widgetIdRef.current && window.turnstile) { + window.turnstile.reset(widgetIdRef.current) + } + }, + 'timeout-callback': () => { + pendingRequest.reject(new Error('Human verification timed out. Please try again.')) + closeDialog() + }, + 'unsupported-callback': () => { + pendingRequest.reject(new Error('Human verification is not supported in this browser.')) + closeDialog() + }, + }) + } catch { + pendingRequest.reject(new Error('Human verification failed to render. Please try again.')) + queueMicrotask(closeDialog) + return + } + + if (!widgetId) { + pendingRequest.reject(new Error('Human verification failed to render. Please try again.')) + queueMicrotask(closeDialog) + return + } + + widgetIdRef.current = widgetId return () => { if (widgetIdRef.current && window.turnstile) { @@ -142,8 +170,9 @@ export function HumanVerificationProvider(props: PropsWithChildren) { {turnstileSiteKey && (

Loading verification...

)} -
+
From b1c811237e43b8b9319ce526310c69c4ce1ce179 Mon Sep 17 00:00:00 2001 From: Producdevity Date: Mon, 1 Jun 2026 19:43:06 +0200 Subject: [PATCH 31/35] fix: validate Turnstile responses with stricter hostname and action checks, add development hostname support, and fix test coverage --- .../server/providers/turnstile.test.ts | 74 +++++++++++++++++- .../server/providers/turnstile.ts | 76 ++++++++++++++++++- 2 files changed, 146 insertions(+), 4 deletions(-) diff --git a/src/features/human-verification/server/providers/turnstile.test.ts b/src/features/human-verification/server/providers/turnstile.test.ts index a26e2cc36..3ccba67c5 100644 --- a/src/features/human-verification/server/providers/turnstile.test.ts +++ b/src/features/human-verification/server/providers/turnstile.test.ts @@ -38,9 +38,16 @@ describe('turnstile provider', () => { it('validates a Turnstile token with Siteverify', async () => { vi.stubEnv('TURNSTILE_SECRET_KEY', 'secret') + vi.stubEnv('NEXT_PUBLIC_APP_URL', 'https://www.emuready.com') global.fetch = vi.fn( async () => - new Response(JSON.stringify({ success: true, action: HUMAN_VERIFICATION_ACTION })), + new Response( + JSON.stringify({ + success: true, + action: HUMAN_VERIFICATION_ACTION, + hostname: 'www.emuready.com', + }), + ), ) await expect( @@ -62,8 +69,12 @@ describe('turnstile provider', () => { it('rejects tokens issued for a different action', async () => { vi.stubEnv('TURNSTILE_SECRET_KEY', 'secret') + vi.stubEnv('NEXT_PUBLIC_APP_URL', 'https://www.emuready.com') global.fetch = vi.fn( - async () => new Response(JSON.stringify({ success: true, action: 'login' })), + async () => + new Response( + JSON.stringify({ success: true, action: 'login', hostname: 'www.emuready.com' }), + ), ) await expect(verifyTurnstileToken({ token: 'token' })).resolves.toEqual({ @@ -72,6 +83,65 @@ describe('turnstile provider', () => { }) }) + it('rejects successful Siteverify responses without an action field', async () => { + vi.stubEnv('TURNSTILE_SECRET_KEY', 'secret') + vi.stubEnv('NEXT_PUBLIC_APP_URL', 'https://www.emuready.com') + global.fetch = vi.fn( + async () => new Response(JSON.stringify({ success: true, hostname: 'www.emuready.com' })), + ) + + await expect(verifyTurnstileToken({ token: 'token' })).resolves.toEqual({ + success: false, + errorCodes: ['action-mismatch'], + }) + }) + + it('rejects successful Siteverify responses without a hostname field', async () => { + vi.stubEnv('TURNSTILE_SECRET_KEY', 'secret') + vi.stubEnv('NEXT_PUBLIC_APP_URL', 'https://www.emuready.com') + global.fetch = vi.fn( + async () => + new Response(JSON.stringify({ success: true, action: HUMAN_VERIFICATION_ACTION })), + ) + + await expect(verifyTurnstileToken({ token: 'token' })).resolves.toEqual({ + success: false, + errorCodes: ['hostname-missing'], + }) + }) + + it('rejects successful Siteverify responses for an unexpected hostname', async () => { + vi.stubEnv('TURNSTILE_SECRET_KEY', 'secret') + vi.stubEnv('NEXT_PUBLIC_APP_URL', 'https://www.emuready.com') + global.fetch = vi.fn( + async () => + new Response( + JSON.stringify({ + success: true, + action: HUMAN_VERIFICATION_ACTION, + hostname: 'evil.example', + }), + ), + ) + + await expect(verifyTurnstileToken({ token: 'token' })).resolves.toEqual({ + success: false, + errorCodes: ['hostname-mismatch'], + }) + }) + + it('allows Cloudflare test-key responses to use their documented test action', async () => { + vi.stubEnv('TURNSTILE_SECRET_KEY', '1x0000000000000000000000000000000AA') + global.fetch = vi.fn( + async () => new Response(JSON.stringify({ success: true, action: 'test' })), + ) + + await expect(verifyTurnstileToken({ token: 'XXXX.DUMMY.TOKEN.XXXX' })).resolves.toEqual({ + success: true, + errorCodes: [], + }) + }) + it('returns provider error codes for invalid tokens', async () => { vi.stubEnv('TURNSTILE_SECRET_KEY', 'secret') global.fetch = vi.fn( diff --git a/src/features/human-verification/server/providers/turnstile.ts b/src/features/human-verification/server/providers/turnstile.ts index 941fc647f..874faf6df 100644 --- a/src/features/human-verification/server/providers/turnstile.ts +++ b/src/features/human-verification/server/providers/turnstile.ts @@ -3,6 +3,12 @@ import { HUMAN_VERIFICATION_ACTION } from '../../shared/constants' const TURNSTILE_SITEVERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify' const TURNSTILE_SITEVERIFY_TIMEOUT_MS = 5000 +const TURNSTILE_TEST_SECRET_KEYS = new Set([ + '1x0000000000000000000000000000000AA', + '2x0000000000000000000000000000000AA', + '3x0000000000000000000000000000000AA', +]) +const LOCAL_DEVELOPMENT_HOSTNAMES = ['localhost', '127.0.0.1', '::1'] const TurnstileResponseSchema = z .object({ @@ -68,8 +74,15 @@ export async function verifyTurnstileToken(params: { const parsed = TurnstileResponseSchema.safeParse(await response.json()) if (!parsed.success) return { success: false, errorCodes: ['invalid-siteverify-response'] } - if (parsed.data.success && parsed.data.action !== HUMAN_VERIFICATION_ACTION) { - return { success: false, errorCodes: ['action-mismatch'] } + if (parsed.data.success && !TURNSTILE_TEST_SECRET_KEYS.has(secret)) { + const validationErrors = validateSuccessfulResponse({ + action: parsed.data.action, + hostname: parsed.data.hostname, + }) + + if (validationErrors.length > 0) { + return { success: false, errorCodes: validationErrors } + } } return { @@ -78,6 +91,65 @@ export async function verifyTurnstileToken(params: { } } +function validateSuccessfulResponse(params: { action?: string; hostname?: string }): string[] { + const errors: string[] = [] + + if (params.action !== HUMAN_VERIFICATION_ACTION) errors.push('action-mismatch') + + const hostname = normalizeHostname(params.hostname) + if (!hostname) { + errors.push('hostname-missing') + } else if (!getAllowedHostnames().has(hostname)) { + errors.push('hostname-mismatch') + } + + return errors +} + +function getAllowedHostnames(): Set { + const hostnames = new Set() + + addCommaSeparatedHostnames(hostnames, process.env.TURNSTILE_ALLOWED_HOSTNAMES) + addHostnameFromUrl(hostnames, process.env.NEXT_PUBLIC_APP_URL) + addHostnameFromUrl(hostnames, process.env.VERCEL_URL) + addHostnameFromUrl(hostnames, process.env.VERCEL_BRANCH_URL) + addHostnameFromUrl(hostnames, process.env.VERCEL_PROJECT_PRODUCTION_URL) + + if (process.env.NODE_ENV !== 'production' || process.env.NEXT_PUBLIC_APP_ENV === 'test') { + for (const hostname of LOCAL_DEVELOPMENT_HOSTNAMES) hostnames.add(hostname) + } + + return hostnames +} + +function addCommaSeparatedHostnames(hostnames: Set, value: string | undefined): void { + if (!value) return + for (const hostname of value.split(',')) addHostname(hostnames, hostname) +} + +function addHostnameFromUrl(hostnames: Set, value: string | undefined): void { + if (!value) return + + try { + const url = value.includes('://') ? new URL(value) : new URL(`https://${value}`) + addHostname(hostnames, url.hostname) + } catch { + addHostname(hostnames, value) + } +} + +function addHostname(hostnames: Set, value: string | undefined): void { + const hostname = normalizeHostname(value) + if (hostname) hostnames.add(hostname) +} + +function normalizeHostname(value: string | undefined): string | null { + const trimmed = value?.trim().toLowerCase() + if (!trimmed) return null + + return trimmed.replace(/^\[/, '').replace(/\]$/, '').replace(/\.$/, '') +} + function isAbortError(error: unknown): boolean { if (typeof error !== 'object' || error === null || !('name' in error)) return false const { name } = error From 68a30e9ab97b4b3f425fb447e0d8b054c8b1e808 Mon Sep 17 00:00:00 2001 From: Producdevity Date: Mon, 1 Jun 2026 19:49:17 +0200 Subject: [PATCH 32/35] tests: fix tests, remove tests that are not actually testing anything --- src/schemas/listing.test.ts | 445 +++++------------- src/server/api/routers/games.test.ts | 134 ++++++ .../api/routers/listings/comments.test.ts | 21 + src/server/api/routers/listings/core.test.ts | 29 ++ src/server/api/routers/pcListings.test.ts | 55 +++ src/server/utils/spam-check.test.ts | 35 +- 6 files changed, 384 insertions(+), 335 deletions(-) create mode 100644 src/server/api/routers/games.test.ts diff --git a/src/schemas/listing.test.ts b/src/schemas/listing.test.ts index 2ef1fb2e8..0dffdcc1a 100644 --- a/src/schemas/listing.test.ts +++ b/src/schemas/listing.test.ts @@ -1,360 +1,137 @@ -import { describe, it, expect } from 'vitest' -import { HUMAN_VERIFICATION_TOKEN_MAX_LENGTH } from '@/features/human-verification/shared/constants' +import { describe, expect, it } from 'vitest' import { ApprovalStatus } from '@orm' import { + CreateCommentSchema, CreateListingSchema, - GetListingsSchema, GetAllListingsAdminSchema, - UpdateListingAdminSchema, - RejectListingSchema, - CreateCommentSchema, + GetListingsSchema, OverrideApprovalStatusSchema, + RejectListingSchema, + UpdateListingAdminSchema, } from './listing' -describe('Listing Schemas - Null Handling', () => { - describe('CreateListingSchema', () => { - it('should accept null for optional fields', () => { - const validInput = { - gameId: '123e4567-e89b-12d3-a456-426614174000', - deviceId: '123e4567-e89b-12d3-a456-426614174001', - emulatorId: '123e4567-e89b-12d3-a456-426614174002', - performanceId: 5, - notes: null, - customFieldValues: null, - } - - const result = CreateListingSchema.safeParse(validInput) - expect(result.success).toBe(true) - if (result.success) { - expect(result.data.notes).toBeNull() - expect(result.data.customFieldValues).toBeNull() - } - }) - - it('should accept undefined for optional fields', () => { - const validInput = { - gameId: '123e4567-e89b-12d3-a456-426614174000', - deviceId: '123e4567-e89b-12d3-a456-426614174001', - emulatorId: '123e4567-e89b-12d3-a456-426614174002', - performanceId: 5, - } - - const result = CreateListingSchema.safeParse(validInput) - expect(result.success).toBe(true) - if (result.success) { - expect(result.data.notes).toBeUndefined() - expect(result.data.customFieldValues).toBeUndefined() - } - }) - - it('should reject invalid data types', () => { - const invalidInput = { - gameId: 'not-a-uuid', - deviceId: '123e4567-e89b-12d3-a456-426614174001', - emulatorId: '123e4567-e89b-12d3-a456-426614174002', - performanceId: 5, - } - - const result = CreateListingSchema.safeParse(invalidInput) - expect(result.success).toBe(false) - }) - - it('should enforce max length on notes', () => { - const invalidInput = { - gameId: '123e4567-e89b-12d3-a456-426614174000', - deviceId: '123e4567-e89b-12d3-a456-426614174001', - emulatorId: '123e4567-e89b-12d3-a456-426614174002', - performanceId: 5, - notes: 'a'.repeat(5001), // Exceeds 5000 character limit - } - - const result = CreateListingSchema.safeParse(invalidInput) - expect(result.success).toBe(false) - }) - - it('should enforce max length on human verification tokens', () => { - const validInput = { - gameId: '123e4567-e89b-12d3-a456-426614174000', - deviceId: '123e4567-e89b-12d3-a456-426614174001', - emulatorId: '123e4567-e89b-12d3-a456-426614174002', - performanceId: 5, - humanVerificationToken: 'a'.repeat(HUMAN_VERIFICATION_TOKEN_MAX_LENGTH), - } - - expect(CreateListingSchema.safeParse(validInput).success).toBe(true) - - const invalidInput = { - ...validInput, - humanVerificationToken: 'a'.repeat(HUMAN_VERIFICATION_TOKEN_MAX_LENGTH + 1), - } - - expect(CreateListingSchema.safeParse(invalidInput).success).toBe(false) - }) +describe('Listing schemas - null compatibility contracts', () => { + it('keeps null and undefined optional fields distinct for create input', () => { + const nullResult = CreateListingSchema.safeParse({ + gameId: '123e4567-e89b-12d3-a456-426614174000', + deviceId: '123e4567-e89b-12d3-a456-426614174001', + emulatorId: '123e4567-e89b-12d3-a456-426614174002', + performanceId: 5, + notes: null, + customFieldValues: null, + }) + + expect(nullResult.success).toBe(true) + if (nullResult.success) { + expect(nullResult.data.notes).toBeNull() + expect(nullResult.data.customFieldValues).toBeNull() + } + + const undefinedResult = CreateListingSchema.safeParse({ + gameId: '123e4567-e89b-12d3-a456-426614174000', + deviceId: '123e4567-e89b-12d3-a456-426614174001', + emulatorId: '123e4567-e89b-12d3-a456-426614174002', + performanceId: 5, + }) + + expect(undefinedResult.success).toBe(true) + if (undefinedResult.success) { + expect(undefinedResult.data.notes).toBeUndefined() + expect(undefinedResult.data.customFieldValues).toBeUndefined() + } }) - describe('GetListingsSchema', () => { - it('should accept null for all optional filter fields', () => { - const validInput = { - systemIds: null, - deviceIds: null, - socIds: null, - emulatorIds: null, - performanceIds: null, - searchTerm: null, - sortField: null, - sortDirection: null, - approvalStatus: null, - myListings: null, - page: 1, - limit: 10, - } - - const result = GetListingsSchema.safeParse(validInput) - expect(result.success).toBe(true) - if (result.success) { - expect(result.data.systemIds).toBeNull() - expect(result.data.searchTerm).toBeNull() - expect(result.data.sortDirection).toBeNull() - } - }) - - it('should accept valid enum values', () => { - const validInput = { - page: 1, - limit: 10, - sortField: 'game.title', - sortDirection: 'asc', - approvalStatus: ApprovalStatus.APPROVED, - } - - const result = GetListingsSchema.safeParse(validInput) - expect(result.success).toBe(true) - if (result.success) { - expect(result.data.sortField).toBe('game.title') - expect(result.data.sortDirection).toBe('asc') - expect(result.data.approvalStatus).toBe(ApprovalStatus.APPROVED) - } - }) + it('accepts null filter values from listing URLs', () => { + const result = GetListingsSchema.safeParse({ + systemIds: null, + deviceIds: null, + socIds: null, + emulatorIds: null, + performanceIds: null, + searchTerm: null, + sortField: null, + sortDirection: null, + approvalStatus: null, + myListings: null, + page: 1, + limit: 10, + }) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.systemIds).toBeNull() + expect(result.data.searchTerm).toBeNull() + expect(result.data.sortDirection).toBeNull() + } }) - describe('GetAllListingsAdminSchema', () => { - it('should accept null for optional admin filter fields', () => { - const validInput = { - page: 1, - limit: 20, - sortField: null, - sortDirection: null, - search: null, - statusFilter: null, - systemFilter: null, - emulatorFilter: null, - } - - const result = GetAllListingsAdminSchema.safeParse(validInput) - expect(result.success).toBe(true) - if (result.success) { - expect(result.data.sortField).toBeNull() - expect(result.data.sortDirection).toBeNull() - expect(result.data.search).toBeNull() - expect(result.data.statusFilter).toBeNull() - } - }) - - it('should enforce minimum search string length when provided', () => { - const invalidInput = { - page: 1, - limit: 20, - search: '', // Empty string should fail min(1) validation - } - - const result = GetAllListingsAdminSchema.safeParse(invalidInput) - expect(result.success).toBe(false) - }) - - it('should accept valid status filter', () => { - const validInput = { - page: 1, - limit: 20, - statusFilter: ApprovalStatus.PENDING, - } - - const result = GetAllListingsAdminSchema.safeParse(validInput) - expect(result.success).toBe(true) - if (result.success) { - expect(result.data.statusFilter).toBe(ApprovalStatus.PENDING) - } - }) + it('accepts null filter values from admin listing URLs', () => { + const result = GetAllListingsAdminSchema.safeParse({ + page: 1, + limit: 20, + sortField: null, + sortDirection: null, + search: null, + statusFilter: null, + systemFilter: null, + emulatorFilter: null, + }) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.sortField).toBeNull() + expect(result.data.sortDirection).toBeNull() + expect(result.data.search).toBeNull() + expect(result.data.statusFilter).toBeNull() + } }) - describe('UpdateListingAdminSchema', () => { - it('should accept null for notes and customFieldValues', () => { - const validInput = { - id: '123e4567-e89b-12d3-a456-426614174000', - gameId: '123e4567-e89b-12d3-a456-426614174001', - deviceId: '123e4567-e89b-12d3-a456-426614174002', - emulatorId: '123e4567-e89b-12d3-a456-426614174003', - performanceId: 5, - status: ApprovalStatus.APPROVED, - notes: null, - customFieldValues: null, - } - - const result = UpdateListingAdminSchema.safeParse(validInput) - expect(result.success).toBe(true) - if (result.success) { - expect(result.data.notes).toBeNull() - expect(result.data.customFieldValues).toBeNull() - } - }) - - it('should validate custom field values structure', () => { - const validInput = { - id: '123e4567-e89b-12d3-a456-426614174000', - gameId: '123e4567-e89b-12d3-a456-426614174001', - deviceId: '123e4567-e89b-12d3-a456-426614174002', - emulatorId: '123e4567-e89b-12d3-a456-426614174003', - performanceId: 5, - status: ApprovalStatus.APPROVED, - customFieldValues: [ - { - customFieldDefinitionId: '123e4567-e89b-12d3-a456-426614174004', - value: 'test value', - }, - { - customFieldDefinitionId: '123e4567-e89b-12d3-a456-426614174005', - value: true, - }, - { - customFieldDefinitionId: '123e4567-e89b-12d3-a456-426614174006', - value: 42, - }, - ], - } - - const result = UpdateListingAdminSchema.safeParse(validInput) - expect(result.success).toBe(true) - }) + it('accepts null editable fields for admin updates', () => { + const result = UpdateListingAdminSchema.safeParse({ + id: '123e4567-e89b-12d3-a456-426614174000', + gameId: '123e4567-e89b-12d3-a456-426614174001', + deviceId: '123e4567-e89b-12d3-a456-426614174002', + emulatorId: '123e4567-e89b-12d3-a456-426614174003', + performanceId: 5, + status: ApprovalStatus.APPROVED, + notes: null, + customFieldValues: null, + }) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.notes).toBeNull() + expect(result.data.customFieldValues).toBeNull() + } }) - describe('RejectListingSchema', () => { - it('should accept null for notes', () => { - const validInput = { - listingId: '123e4567-e89b-12d3-a456-426614174000', - notes: null, - } - - const result = RejectListingSchema.safeParse(validInput) - expect(result.success).toBe(true) - if (result.success) { - expect(result.data.notes).toBeNull() - } - }) - - it('should accept string for notes', () => { - const validInput = { - listingId: '123e4567-e89b-12d3-a456-426614174000', - notes: 'Rejection reason here', - } - - const result = RejectListingSchema.safeParse(validInput) - expect(result.success).toBe(true) - if (result.success) { - expect(result.data.notes).toBe('Rejection reason here') - } + it('accepts null moderator notes when rejecting or overriding a report', () => { + const rejectResult = RejectListingSchema.safeParse({ + listingId: '123e4567-e89b-12d3-a456-426614174000', + notes: null, }) - }) - describe('CreateCommentSchema', () => { - it('should accept null for parentId', () => { - const validInput = { - listingId: '123e4567-e89b-12d3-a456-426614174000', - content: 'This is a comment', - parentId: null, - } + expect(rejectResult.success).toBe(true) + if (rejectResult.success) expect(rejectResult.data.notes).toBeNull() - const result = CreateCommentSchema.safeParse(validInput) - expect(result.success).toBe(true) - if (result.success) { - expect(result.data.parentId).toBeNull() - } + const overrideResult = OverrideApprovalStatusSchema.safeParse({ + listingId: '123e4567-e89b-12d3-a456-426614174000', + newStatus: ApprovalStatus.APPROVED, + overrideNotes: null, }) - it('should enforce content length limits', () => { - const invalidInput = { - listingId: '123e4567-e89b-12d3-a456-426614174000', - content: '', // Too short - } - - const result = CreateCommentSchema.safeParse(invalidInput) - expect(result.success).toBe(false) - - const tooLongInput = { - listingId: '123e4567-e89b-12d3-a456-426614174000', - content: 'a'.repeat(1001), // Too long - } - - const result2 = CreateCommentSchema.safeParse(tooLongInput) - expect(result2.success).toBe(false) - }) - - it('should enforce max length on human verification tokens', () => { - const validInput = { - listingId: '123e4567-e89b-12d3-a456-426614174000', - content: 'This is a comment', - humanVerificationToken: 'a'.repeat(HUMAN_VERIFICATION_TOKEN_MAX_LENGTH), - } - - expect(CreateCommentSchema.safeParse(validInput).success).toBe(true) - - const invalidInput = { - ...validInput, - humanVerificationToken: 'a'.repeat(HUMAN_VERIFICATION_TOKEN_MAX_LENGTH + 1), - } - - expect(CreateCommentSchema.safeParse(invalidInput).success).toBe(false) - }) + expect(overrideResult.success).toBe(true) + if (overrideResult.success) expect(overrideResult.data.overrideNotes).toBeNull() }) - describe('OverrideApprovalStatusSchema', () => { - it('should accept null for overrideNotes', () => { - const validInput = { - listingId: '123e4567-e89b-12d3-a456-426614174000', - newStatus: ApprovalStatus.APPROVED, - overrideNotes: null, - } - - const result = OverrideApprovalStatusSchema.safeParse(validInput) - expect(result.success).toBe(true) - if (result.success) { - expect(result.data.overrideNotes).toBeNull() - } - }) - - it('should require valid ApprovalStatus enum value', () => { - const invalidInput = { - listingId: '123e4567-e89b-12d3-a456-426614174000', - newStatus: 'INVALID_STATUS', - } - - const result = OverrideApprovalStatusSchema.safeParse(invalidInput) - expect(result.success).toBe(false) + it('accepts null for top-level comment parentId', () => { + const result = CreateCommentSchema.safeParse({ + listingId: '123e4567-e89b-12d3-a456-426614174000', + content: 'This is a comment', + parentId: null, }) - it('should accept all valid ApprovalStatus values', () => { - const statuses = [ApprovalStatus.PENDING, ApprovalStatus.APPROVED, ApprovalStatus.REJECTED] - - statuses.forEach((status) => { - const input = { - listingId: '123e4567-e89b-12d3-a456-426614174000', - newStatus: status, - } - - const result = OverrideApprovalStatusSchema.safeParse(input) - expect(result.success).toBe(true) - if (result.success) { - expect(result.data.newStatus).toBe(status) - } - }) - }) + expect(result.success).toBe(true) + if (result.success) expect(result.data.parentId).toBeNull() }) }) diff --git a/src/server/api/routers/games.test.ts b/src/server/api/routers/games.test.ts new file mode 100644 index 000000000..85a4d2672 --- /dev/null +++ b/src/server/api/routers/games.test.ts @@ -0,0 +1,134 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ApprovalStatus, Role } from '@orm/client' + +vi.unmock('@/server/api/trpc') +vi.unmock('@/server/api/root') + +const mockCheckSpamContent = vi.fn().mockResolvedValue(undefined) +const mockGetSubmissionBalance = vi.fn().mockResolvedValue({ + gamesCount: 0, + listingsCount: 1, + pcListingsCount: 0, + totalListingsCount: 1, + buffer: 2, + canSubmitGame: true, + reportsNeeded: 0, +}) +const mockGameStatsCacheDelete = vi.fn() + +vi.mock('@/server/utils/spam-check', () => ({ + checkSpamContent: (...args: unknown[]) => mockCheckSpamContent(...args), +})) + +vi.mock('@/server/utils/submission-balance', () => ({ + getSubmissionBalance: (...args: unknown[]) => mockGetSubmissionBalance(...args), +})) + +vi.mock('@/server/utils/cache', () => ({ + gameStatsCache: { delete: mockGameStatsCacheDelete, get: vi.fn(), set: vi.fn() }, +})) + +vi.mock('@/server/cache/invalidation', () => ({ + invalidateGame: vi.fn().mockResolvedValue(undefined), + invalidateGameRelatedContent: vi.fn().mockResolvedValue(undefined), + invalidateListPages: vi.fn().mockResolvedValue(undefined), + invalidateSitemap: vi.fn().mockResolvedValue(undefined), + revalidateByTag: vi.fn().mockResolvedValue(undefined), +})) + +vi.mock('@/server/notifications/eventEmitter', () => ({ + notificationEventEmitter: { emitNotificationEvent: vi.fn() }, + NOTIFICATION_EVENTS: { + GAME_STATUS_APPROVED: 'GAME_STATUS_APPROVED', + GAME_STATUS_REJECTED: 'GAME_STATUS_REJECTED', + GAME_STATUS_OVERRIDDEN: 'GAME_STATUS_OVERRIDDEN', + }, +})) + +vi.mock('@/lib/analytics', () => ({ + default: { performance: { errorOccurred: vi.fn() } }, +})) + +const { gamesRouter } = await import('./games') + +const USER_ID = '00000000-0000-4000-a000-000000000001' +const SYSTEM_ID = '00000000-0000-4000-a000-000000000010' +const GAME_ID = '00000000-0000-4000-a000-000000000020' + +function createMockPrisma() { + return { + system: { + findUnique: vi.fn().mockResolvedValue({ id: SYSTEM_ID, name: 'Nintendo Switch' }), + }, + game: { + findFirst: vi.fn().mockResolvedValue(null), + create: vi.fn().mockResolvedValue({ + id: GAME_ID, + title: 'Amazing!! This runs perfectly!!', + systemId: SYSTEM_ID, + status: ApprovalStatus.PENDING, + system: { id: SYSTEM_ID, name: 'Nintendo Switch' }, + submitter: { id: USER_ID, name: 'Test User' }, + }), + }, + } +} + +type MockPrisma = ReturnType + +function createCaller(overrides: { role?: Role; prisma?: MockPrisma } = {}) { + const prisma = overrides.prisma ?? createMockPrisma() + return { + caller: gamesRouter.createCaller({ + session: { + user: { + id: USER_ID, + email: 'test@test.com', + name: 'Test User', + role: overrides.role ?? Role.USER, + permissions: [], + showNsfw: false, + }, + }, + prisma: prisma as never, + headers: new Headers(), + }), + prisma, + } +} + +describe('games router', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('create', () => { + it('passes a human verification token to the spam check when retrying game creation', async () => { + const { caller, prisma } = createCaller() + + await caller.create({ + title: 'Amazing!! This runs perfectly!!', + systemId: SYSTEM_ID, + humanVerificationToken: 'verification-token', + }) + + expect(mockCheckSpamContent).toHaveBeenCalledWith({ + prisma, + userId: USER_ID, + content: 'Amazing!! This runs perfectly!!', + entityType: 'game', + challengeMode: 'challenge', + enableRateLimiting: false, + enableDuplicateDetection: false, + humanVerificationToken: 'verification-token', + headers: expect.any(Headers), + }) + expect(prisma.game.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.not.objectContaining({ humanVerificationToken: expect.anything() }), + }), + ) + expect(mockGameStatsCacheDelete).toHaveBeenCalled() + }) + }) +}) diff --git a/src/server/api/routers/listings/comments.test.ts b/src/server/api/routers/listings/comments.test.ts index 50ff89197..37060c3fd 100644 --- a/src/server/api/routers/listings/comments.test.ts +++ b/src/server/api/routers/listings/comments.test.ts @@ -243,4 +243,25 @@ describe('handheld comments router — create', () => { }) expect(prisma.comment.create).toHaveBeenCalled() }) + + it('passes a human verification token to the spam check when retrying creation', async () => { + const { caller, prisma } = createCaller() + + await caller.create({ + listingId: LISTING_ID, + content: 'Amazing!! This runs perfectly!!', + humanVerificationToken: 'verification-token', + }) + + expect(mockCheckSpamContent).toHaveBeenCalledWith({ + prisma, + userId: USER_ID, + content: 'Amazing!! This runs perfectly!!', + entityType: 'comment', + challengeMode: 'challenge', + humanVerificationToken: 'verification-token', + headers: expect.any(Headers), + }) + expect(prisma.comment.create).toHaveBeenCalled() + }) }) diff --git a/src/server/api/routers/listings/core.test.ts b/src/server/api/routers/listings/core.test.ts index ec5f971ee..1d4717c3e 100644 --- a/src/server/api/routers/listings/core.test.ts +++ b/src/server/api/routers/listings/core.test.ts @@ -193,6 +193,35 @@ describe('handheld listings trust integration (core.ts)', () => { }) expect(mockRepositoryCreate).toHaveBeenCalled() }) + + it('passes a human verification token to the spam check when retrying creation', async () => { + mockRepositoryCreate.mockResolvedValue({ + id: LISTING_ID, + status: ApprovalStatus.PENDING, + }) + + const { caller } = createCaller({ permissions: [PERMISSIONS.CREATE_LISTING] }) + + await caller.create({ + gameId: '00000000-0000-4000-a000-000000000030', + deviceId: '00000000-0000-4000-a000-000000000031', + emulatorId: '00000000-0000-4000-a000-000000000032', + performanceId: 1, + notes: 'Amazing!! This runs perfectly!!', + humanVerificationToken: 'verification-token', + }) + + expect(mockCheckSpamContent).toHaveBeenCalledWith({ + prisma: expect.anything(), + userId: USER_ID, + content: 'Amazing!! This runs perfectly!!', + entityType: 'listing', + challengeMode: 'challenge', + humanVerificationToken: 'verification-token', + headers: expect.any(Headers), + }) + expect(mockRepositoryCreate).toHaveBeenCalled() + }) }) describe('byId', () => { diff --git a/src/server/api/routers/pcListings.test.ts b/src/server/api/routers/pcListings.test.ts index 769d9a47f..eda2acc58 100644 --- a/src/server/api/routers/pcListings.test.ts +++ b/src/server/api/routers/pcListings.test.ts @@ -443,6 +443,28 @@ describe('pcListings trust integration', () => { }) expect(prisma.pcListingComment.create).toHaveBeenCalled() }) + + it('passes a human verification token to the spam check when retrying comment creation', async () => { + const { caller, prisma } = createCaller() + prisma.pcListing.findUnique.mockResolvedValue({ id: LISTING_ID, authorId: AUTHOR_ID }) + + await caller.createComment({ + pcListingId: LISTING_ID, + content: 'Amazing!! This runs perfectly!!', + humanVerificationToken: 'verification-token', + }) + + expect(mockCheckSpamContent).toHaveBeenCalledWith({ + prisma, + userId: USER_ID, + content: 'Amazing!! This runs perfectly!!', + entityType: 'pcComment', + challengeMode: 'challenge', + humanVerificationToken: 'verification-token', + headers: expect.any(Headers), + }) + expect(prisma.pcListingComment.create).toHaveBeenCalled() + }) }) describe('create', () => { @@ -502,6 +524,39 @@ describe('pcListings trust integration', () => { }) expect(mockRepositoryCreate).toHaveBeenCalled() }) + + it('passes a human verification token to the spam check when retrying creation', async () => { + const newListingId = '00000000-0000-4000-a000-000000000099' + mockRepositoryCreate.mockResolvedValue({ + id: newListingId, + status: ApprovalStatus.PENDING, + }) + + const { caller } = createCaller({ permissions: [PERMISSIONS.CREATE_LISTING] }) + + await caller.create({ + gameId: '00000000-0000-4000-a000-000000000030', + cpuId: '00000000-0000-4000-a000-000000000031', + emulatorId: '00000000-0000-4000-a000-000000000032', + performanceId: 1, + memorySize: 16, + os: PcOs.WINDOWS, + osVersion: '11', + notes: 'Amazing!! This runs perfectly!!', + humanVerificationToken: 'verification-token', + }) + + expect(mockCheckSpamContent).toHaveBeenCalledWith({ + prisma: expect.anything(), + userId: USER_ID, + content: 'Amazing!! This runs perfectly!!', + entityType: 'pcListing', + challengeMode: 'challenge', + humanVerificationToken: 'verification-token', + headers: expect.any(Headers), + }) + expect(mockRepositoryCreate).toHaveBeenCalled() + }) }) describe('byId', () => { diff --git a/src/server/utils/spam-check.test.ts b/src/server/utils/spam-check.test.ts index f57457ad6..05f5f61dd 100644 --- a/src/server/utils/spam-check.test.ts +++ b/src/server/utils/spam-check.test.ts @@ -4,7 +4,10 @@ import { isTurnstileConfigured, verifyTurnstileToken, } from '@/features/human-verification/server/providers/turnstile' -import { HUMAN_VERIFICATION_ERROR_CODES } from '@/features/human-verification/shared/constants' +import { + HUMAN_VERIFICATION_ERROR_CODES, + HUMAN_VERIFICATION_TOKEN_MAX_LENGTH, +} from '@/features/human-verification/shared/constants' import analytics from '@/lib/analytics' import { type PrismaClient } from '@orm/client' import { checkSpamContent } from './spam-check' @@ -241,6 +244,36 @@ describe('checkSpamContent', () => { }) }) + it('rejects oversized human verification tokens before calling the provider', async () => { + vi.spyOn(SpamDetectionService.prototype, 'detectSpam').mockResolvedValue({ + isSpam: true, + confidence: 0.85, + method: 'pattern_matching', + reason: 'Spam pattern matched', + }) + + try { + await checkSpamContent({ + prisma: mockPrisma, + userId: USER_ID, + content: 'Flagged content', + entityType: 'comment', + challengeMode: 'challenge', + humanVerificationToken: 'a'.repeat(HUMAN_VERIFICATION_TOKEN_MAX_LENGTH + 1), + }) + throw new Error('Expected checkSpamContent to throw') + } catch (error) { + expect(error).toBeInstanceOf(TRPCError) + expect((error as TRPCError).cause).toEqual( + expect.objectContaining({ + code: HUMAN_VERIFICATION_ERROR_CODES.FAILED, + }), + ) + } + + expect(verifyTurnstileToken).not.toHaveBeenCalled() + }) + it('does not allow high-confidence spam with a human verification token', async () => { vi.spyOn(SpamDetectionService.prototype, 'detectSpam').mockResolvedValue({ isSpam: true, From 91cd3dce98bf9168f9ad3fe16ec5176444a0945f Mon Sep 17 00:00:00 2001 From: Producdevity Date: Mon, 1 Jun 2026 19:52:10 +0200 Subject: [PATCH 33/35] refactor: remove redundant comments from CommunitySupportBanner component --- src/components/banners/CommunitySupportBanner.tsx | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/components/banners/CommunitySupportBanner.tsx b/src/components/banners/CommunitySupportBanner.tsx index 7c1fa9313..c5672b4bf 100644 --- a/src/components/banners/CommunitySupportBanner.tsx +++ b/src/components/banners/CommunitySupportBanner.tsx @@ -61,7 +61,6 @@ function CommunitySupportBanner(props: Props) { isHome ? 'md:gap-x-4' : '', )} > - {/* Icon */}
- {/* Text — min-width ensures icon + text + dismiss fill the first row, forcing CTA to wrap */}

- {/* CTA — wraps to second line on mobile via order-1, inline on desktop */} - {/* Dismiss — stays in first row (default order 0, same as icon + text) */}