diff --git a/playwright.config.ts b/playwright.config.ts
index 02db5bd4..020df9ea 100644
--- a/playwright.config.ts
+++ b/playwright.config.ts
@@ -18,28 +18,23 @@ export default defineConfig({
forbidOnly: !!process.env.CI,
/* 1 retry catches timing-sensitive tests without hiding consistent issues */
retries: 1,
- /* Conservative approach for CI stability - 1 worker ensures each test gets full resources */
- workers: isCI ? 1 : undefined,
+ workers: 1,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: 'list',
- /* Test timeout */
- timeout: isCI ? 60 * 1000 : 30 * 1000, // 60 seconds in CI, 30 locally
+ timeout: 60 * 1000,
+
+ expect: {
+ timeout: 10 * 1000,
+ },
- /* Global setup - runs once before all tests */
globalSetup: require.resolve('./tests/global.setup.ts'),
- /* Shared settings for all the projects below. */
use: {
- /* Base URL to use in actions like `await page.goto('/')`. */
baseURL: 'http://localhost:3000',
-
- /* Collect trace when retrying the failed test. */
+ actionTimeout: 10 * 1000,
+ navigationTimeout: 30 * 1000,
trace: 'on-first-retry',
-
- /* Screenshot on failure */
screenshot: 'only-on-failure',
-
- /* Video on failure */
video: 'retain-on-failure',
},
@@ -50,11 +45,17 @@ export default defineConfig({
name: 'setup',
testMatch: /auth\.setup\.ts/,
},
+ // Data setup - creates listings, reports, etc. that other tests depend on
+ {
+ name: 'data-setup',
+ testMatch: /data-setup\.spec\.ts/,
+ dependencies: ['setup'],
+ },
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
- testIgnore: ['**/auth.setup.ts', '**/global.setup.ts'],
- dependencies: ['setup'], // Ensure auth is set up before running tests
+ testIgnore: ['**/auth.setup.ts', '**/global.setup.ts', '**/data-setup.spec.ts'],
+ dependencies: ['data-setup'],
},
],
diff --git a/prisma/migrations/20260414175740_add_vote_change_reversal_trust_action/migration.sql b/prisma/migrations/20260414175740_add_vote_change_reversal_trust_action/migration.sql
new file mode 100644
index 00000000..834b8342
--- /dev/null
+++ b/prisma/migrations/20260414175740_add_vote_change_reversal_trust_action/migration.sql
@@ -0,0 +1,14 @@
+-- AlterEnum
+ALTER TYPE "trust_action" ADD VALUE 'VOTE_CHANGE_REVERSAL';
+
+-- CreateIndex
+CREATE INDEX "CommentVote_userId_nullifiedAt_idx" ON "CommentVote"("userId", "nullifiedAt");
+
+-- CreateIndex
+CREATE INDEX "Vote_userId_nullifiedAt_idx" ON "Vote"("userId", "nullifiedAt");
+
+-- CreateIndex
+CREATE INDEX "pc_listing_comment_votes_userId_nullifiedAt_idx" ON "pc_listing_comment_votes"("userId", "nullifiedAt");
+
+-- CreateIndex
+CREATE INDEX "pc_listing_votes_userId_nullifiedAt_idx" ON "pc_listing_votes"("userId", "nullifiedAt");
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 10cb2f90..1555e5fc 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -505,6 +505,7 @@ model Vote {
@@unique([userId, listingId])
@@index([userId])
@@index([listingId])
+ @@index([userId, nullifiedAt])
}
model Comment {
@@ -541,6 +542,7 @@ model CommentVote {
@@unique([userId, commentId])
@@index([userId])
+ @@index([userId, nullifiedAt])
}
model ListingCustomFieldValue {
@@ -735,6 +737,7 @@ enum TrustAction {
ADMIN_ADJUSTMENT_POSITIVE
ADMIN_ADJUSTMENT_NEGATIVE
VOTE_NULLIFICATION_REVERSAL
+ VOTE_CHANGE_REVERSAL
@@map("trust_action")
}
@@ -1123,6 +1126,7 @@ model PcListingVote {
@@unique([userId, pcListingId])
@@index([pcListingId])
@@index([userId])
+ @@index([userId, nullifiedAt])
@@map("pc_listing_votes")
}
@@ -1166,6 +1170,7 @@ model PcListingCommentVote {
@@unique([userId, commentId])
@@index([commentId])
@@index([userId])
+ @@index([userId, nullifiedAt])
@@map("pc_listing_comment_votes")
}
diff --git a/prisma/seed.ts b/prisma/seed.ts
index e10128cd..63596a09 100644
--- a/prisma/seed.ts
+++ b/prisma/seed.ts
@@ -6,6 +6,7 @@ import customFieldTemplatesSeeder from './seeders/customFieldTemplatesSeeder'
import devicesSeeder from './seeders/devicesSeeder'
import edenCustomFieldsSeeder from './seeders/edenCustomFieldsSeeder'
import emulatorsSeeder from './seeders/emulatorsSeeder'
+import gamenativeCustomFieldsSeeder from './seeders/gamenativeCustomFieldsSeeder'
import gamesSeeder from './seeders/gamesSeeder'
import gpuSeeder from './seeders/gpuSeeder'
import listingsSeeder from './seeders/listingsSeeder'
@@ -129,6 +130,7 @@ async function main() {
await customFieldTemplatesSeeder(prisma)
await azaharCustomFieldsSeeder(prisma)
await edenCustomFieldsSeeder(prisma)
+ await gamenativeCustomFieldsSeeder(prisma)
console.info('β
Custom fields seeded successfully!')
} catch (error) {
console.error('β Error seeding custom fields:', error)
@@ -188,6 +190,7 @@ async function main() {
await emulatorsSeeder(prisma)
await azaharCustomFieldsSeeder(prisma)
await edenCustomFieldsSeeder(prisma)
+ await gamenativeCustomFieldsSeeder(prisma)
await customFieldTemplatesSeeder(prisma)
await socSeeder(prisma)
await cpuSeeder(prisma)
diff --git a/prisma/seeders/gamenativeCustomFieldsSeeder.ts b/prisma/seeders/gamenativeCustomFieldsSeeder.ts
new file mode 100644
index 00000000..4e67ce7b
--- /dev/null
+++ b/prisma/seeders/gamenativeCustomFieldsSeeder.ts
@@ -0,0 +1,486 @@
+import { CustomFieldType, Prisma, type PrismaClient } from '@orm'
+
+interface SelectOption {
+ value: string
+ label: string
+}
+
+interface GameNativeCustomFieldSeed {
+ name: string
+ label: string
+ type: CustomFieldType
+ required: boolean
+ displayOrder: number
+ defaultValue?: string | number | boolean | null
+ placeholder?: string | null
+ options?: SelectOption[]
+}
+
+const GAMENATIVE_EMULATOR_NAME = 'GameNative'
+
+const GAMENATIVE_CUSTOM_FIELDS: GameNativeCustomFieldSeed[] = [
+ {
+ name: 'emulator_version',
+ label: 'Emulator Version',
+ type: CustomFieldType.TEXT,
+ required: true,
+ displayOrder: 0,
+ defaultValue: null,
+ },
+ {
+ name: 'graphics_driver',
+ label: 'Graphics Driver',
+ type: CustomFieldType.SELECT,
+ required: true,
+ displayOrder: 1,
+ defaultValue: 'Vortek (Universal)',
+ options: [
+ { value: 'Vortek (Universal)', label: 'Vortek (Universal)' },
+ { value: 'Turnip (Adreno)', label: 'Turnip (Adreno)' },
+ { value: 'VirGL (Universal)', label: 'VirGL (Universal)' },
+ { value: 'Adreno (Adreno)', label: 'Adreno (Adreno)' },
+ { value: 'SD 8 Elite (SD 8 Elite)', label: 'SD 8 Elite (SD 8 Elite)' },
+ { value: 'Wrapper', label: 'Wrapper (Bionic)' },
+ { value: 'Wrapper-v2', label: 'Wrapper-v2 (Bionic)' },
+ { value: 'Wrapper-leegao', label: 'Wrapper-leegao (Bionic)' },
+ { value: 'Wrapper-legacy', label: 'Wrapper-legacy (Bionic)' },
+ ],
+ },
+ {
+ name: 'dx_wrapper',
+ label: 'DX Wrapper',
+ type: CustomFieldType.SELECT,
+ required: true,
+ displayOrder: 2,
+ defaultValue: 'DXVK',
+ options: [
+ { value: 'WineD3D', label: 'WineD3D' },
+ { value: 'DXVK', label: 'DXVK' },
+ { value: 'VKD3D', label: 'VKD3D' },
+ { value: 'CNC DDraw', label: 'CNC DDraw' },
+ { value: 'Other', label: 'Other' },
+ ],
+ },
+ {
+ name: 'dxvk_version',
+ label: 'DXVK Version',
+ type: CustomFieldType.SELECT,
+ required: true,
+ displayOrder: 3,
+ defaultValue: '2.6.1-gplasync',
+ options: [
+ { value: '1.10.1', label: '1.10.1' },
+ { value: '1.10.3', label: '1.10.3' },
+ { value: 'async-1.10.3', label: 'async-1.10.3' },
+ { value: '1.10.9-sarek', label: '1.10.9-sarek' },
+ { value: '1.11.1-sarek', label: '1.11.1-sarek' },
+ { value: '1.9.2', label: '1.9.2' },
+ { value: '2.3.1', label: '2.3.1' },
+ { value: '2.4-gplasync', label: '2.4-gplasync' },
+ { value: '2.4.1', label: '2.4.1' },
+ { value: '2.4.1-gplasync', label: '2.4.1-gplasync' },
+ { value: '2.6.1-gplasync', label: '2.6.1-gplasync' },
+ { value: '2.6-arm64ec', label: '2.6-arm64ec' },
+ { value: '2.7.1', label: '2.7.1' },
+ { value: 'other', label: 'Other (specify in notes)' },
+ ],
+ },
+ {
+ name: 'dx_wrapper_config',
+ label: 'DX Wrapper Config (Version, Frame Rate, Max Memory)',
+ type: CustomFieldType.TEXTAREA,
+ required: false,
+ displayOrder: 4,
+ defaultValue: null,
+ },
+ {
+ name: 'audio_driver',
+ label: 'Audio Driver',
+ type: CustomFieldType.SELECT,
+ required: true,
+ displayOrder: 5,
+ defaultValue: 'alsa',
+ options: [
+ { value: 'alsa', label: 'ALSA' },
+ { value: 'pulse', label: 'PulseAudio' },
+ { value: 'other', label: 'Other' },
+ ],
+ },
+ {
+ name: 'env_variables',
+ label: 'Environment Variables',
+ type: CustomFieldType.TEXTAREA,
+ required: false,
+ displayOrder: 6,
+ defaultValue: null,
+ },
+ {
+ name: 'box64_version',
+ label: 'Box64 Version',
+ type: CustomFieldType.SELECT,
+ required: true,
+ displayOrder: 7,
+ defaultValue: '0.3.6',
+ options: [
+ { value: '0.3.2', label: '0.3.2' },
+ { value: '0.3.4', label: '0.3.4' },
+ { value: '0.3.6', label: '0.3.6' },
+ { value: '0.3.7', label: '0.3.7' },
+ { value: '0.3.8', label: '0.3.8' },
+ { value: '0.4.0', label: '0.4.0' },
+ ],
+ },
+ {
+ name: 'box64_preset',
+ label: 'Box64 Preset',
+ type: CustomFieldType.SELECT,
+ required: true,
+ displayOrder: 8,
+ defaultValue: 'compatibility',
+ options: [
+ { value: 'stability', label: 'Stability' },
+ { value: 'compatibility', label: 'Compatibility' },
+ { value: 'intermediate', label: 'Intermediate' },
+ { value: 'performance', label: 'Performance' },
+ { value: 'unity', label: 'Unity' },
+ { value: 'unity_mono_bleeding_edge', label: 'Unity Mono Bleeding Edge' },
+ { value: 'denuvo', label: 'Denuvo' },
+ { value: 'other', label: 'Other/Custom' },
+ ],
+ },
+ {
+ name: 'startup_selection',
+ label: 'Startup Selection',
+ type: CustomFieldType.SELECT,
+ required: false,
+ displayOrder: 9,
+ defaultValue: 'Aggressive (Stop services on startup)',
+ options: [
+ { value: 'Normal (Load all services)', label: 'Normal (Load all services)' },
+ {
+ value: 'Essential (Load only essential services)',
+ label: 'Essential (Load only essential services)',
+ },
+ {
+ value: 'Aggressive (Stop services on startup)',
+ label: 'Aggressive (Stop services on startup)',
+ },
+ { value: 'Other', label: 'Other' },
+ ],
+ },
+ {
+ name: 'resolution',
+ label: 'Resolution (Screen Size)',
+ type: CustomFieldType.TEXT,
+ required: true,
+ displayOrder: 10,
+ defaultValue: '1280x720 (16:9)',
+ },
+ {
+ name: 'youtube',
+ label: 'YouTube',
+ type: CustomFieldType.URL,
+ required: false,
+ displayOrder: 11,
+ defaultValue: null,
+ },
+ {
+ name: 'media_url',
+ label: 'Screenshots, Blog Post, etc',
+ type: CustomFieldType.URL,
+ required: false,
+ displayOrder: 12,
+ defaultValue: null,
+ },
+ {
+ name: 'exec_arguments',
+ label: 'Exec Arguments',
+ type: CustomFieldType.TEXT,
+ required: false,
+ displayOrder: 13,
+ defaultValue: null,
+ placeholder:
+ '-noverifyfiles -nobootstrapupdate -skipinitialbootstrap -norepairfiles -nocrashmonitor -noshaders',
+ },
+ {
+ name: 'game_version',
+ label: 'Game Version',
+ type: CustomFieldType.TEXT,
+ required: false,
+ displayOrder: 14,
+ defaultValue: null,
+ },
+ {
+ name: 'average_fps',
+ label: 'Average FPS',
+ type: CustomFieldType.TEXT,
+ required: false,
+ displayOrder: 15,
+ defaultValue: null,
+ },
+ {
+ name: 'container_variant',
+ label: 'Container Variant',
+ type: CustomFieldType.SELECT,
+ required: true,
+ displayOrder: 16,
+ defaultValue: 'bionic',
+ options: [
+ { value: 'bionic', label: 'bionic' },
+ { value: 'glibc', label: 'glibc' },
+ ],
+ },
+ {
+ name: 'wine_version',
+ label: 'Wine Version',
+ type: CustomFieldType.SELECT,
+ required: true,
+ displayOrder: 17,
+ defaultValue: 'proton-9.0-arm64ec',
+ options: [
+ { value: 'wine-9.2-x86_64', label: 'wine-9.2-x86_64 (Glibc)' },
+ { value: 'proton-9.0-arm64ec', label: 'proton-9.0-arm64ec (Bionic)' },
+ { value: 'proton-9.0-x86_64', label: 'proton-9.0-x86_64 (Bionic)' },
+ { value: 'proton-10.0-arm64ec', label: 'proton-10.0-arm64ec (Bionic)' },
+ ],
+ },
+ {
+ name: 'steam_type',
+ label: 'Steam Type',
+ type: CustomFieldType.SELECT,
+ required: true,
+ displayOrder: 18,
+ defaultValue: 'normal',
+ options: [
+ { value: 'normal', label: 'Normal' },
+ { value: 'light', label: 'Light' },
+ { value: 'ultra_light', label: 'Ultra Light' },
+ ],
+ },
+ {
+ name: 'dynamic_driver_version',
+ label: 'Dynamic Driver Version',
+ type: CustomFieldType.TEXT,
+ required: true,
+ displayOrder: 19,
+ defaultValue: null,
+ },
+ {
+ name: 'max_device_memory',
+ label: 'Max Device Memory',
+ type: CustomFieldType.SELECT,
+ required: true,
+ displayOrder: 20,
+ defaultValue: '0',
+ options: [
+ { value: '0', label: '0 MB' },
+ { value: '512', label: '512 MB' },
+ { value: '1024', label: '1024 MB' },
+ { value: '2048', label: '2048 MB' },
+ { value: '4096', label: '4096 MB' },
+ ],
+ },
+ {
+ name: 'use_adrenotools_turnip',
+ label: 'Use Adrenotools Turnip',
+ type: CustomFieldType.BOOLEAN,
+ required: true,
+ displayOrder: 21,
+ defaultValue: true,
+ },
+ {
+ name: 'fex_core_version',
+ label: 'FEXCore Version',
+ type: CustomFieldType.TEXT,
+ required: true,
+ displayOrder: 22,
+ defaultValue: '2603',
+ },
+ {
+ name: '32_bit_emulator',
+ label: '32-bit Emulator',
+ type: CustomFieldType.SELECT,
+ required: true,
+ displayOrder: 23,
+ defaultValue: 'fex',
+ options: [
+ { value: 'fex', label: 'FEXCore' },
+ { value: 'box', label: 'Box32' },
+ ],
+ },
+ {
+ name: '64_bit_emulator',
+ label: '64-bit Emulator',
+ type: CustomFieldType.SELECT,
+ required: false,
+ displayOrder: 24,
+ defaultValue: 'fex',
+ options: [
+ { value: 'fex', label: 'FEXCore' },
+ { value: 'box', label: 'Box64' },
+ ],
+ },
+ {
+ name: 'fex_core_preset',
+ label: 'FEXCore Preset',
+ type: CustomFieldType.SELECT,
+ required: true,
+ displayOrder: 25,
+ defaultValue: 'intermediate',
+ options: [
+ { value: 'stability', label: 'Stability' },
+ { value: 'compatibility', label: 'Compatibility' },
+ { value: 'intermediate', label: 'Intermediate' },
+ { value: 'performance', label: 'Performance' },
+ { value: 'extreme', label: 'Extreme' },
+ { value: 'denuvo', label: 'Denuvo' },
+ { value: 'other', label: 'Other' },
+ ],
+ },
+ {
+ name: 'use_steam_input',
+ label: 'Use Steam Input',
+ type: CustomFieldType.BOOLEAN,
+ required: false,
+ displayOrder: 26,
+ defaultValue: false,
+ },
+ {
+ name: 'enable_x_input_api',
+ label: 'Enable XInput API',
+ type: CustomFieldType.BOOLEAN,
+ required: false,
+ displayOrder: 27,
+ defaultValue: true,
+ },
+ {
+ name: 'enable_direct_input_api',
+ label: 'Enable DirectInput API',
+ type: CustomFieldType.BOOLEAN,
+ required: false,
+ displayOrder: 28,
+ defaultValue: true,
+ },
+ {
+ name: 'direct_input_mapper_type',
+ label: 'DirectInput Mapper Type',
+ type: CustomFieldType.SELECT,
+ required: true,
+ displayOrder: 29,
+ defaultValue: 'standard',
+ options: [
+ { value: 'standard', label: 'Standard' },
+ { value: 'xinput_mapper', label: 'XInput Mapper' },
+ ],
+ },
+]
+
+export default async function gamenativeCustomFieldsSeeder(prisma: PrismaClient) {
+ console.info('π± Seeding GameNative custom fields...')
+
+ const gamenative = await prisma.emulator.findUnique({
+ where: { name: GAMENATIVE_EMULATOR_NAME },
+ select: { id: true },
+ })
+
+ if (!gamenative) {
+ console.warn(
+ `β οΈ Emulator "${GAMENATIVE_EMULATOR_NAME}" not found. Skipping custom field seeding.`,
+ )
+ return
+ }
+
+ const fieldNames = GAMENATIVE_CUSTOM_FIELDS.map((field) => field.name)
+
+ for (const field of GAMENATIVE_CUSTOM_FIELDS) {
+ await prisma.customFieldDefinition.upsert({
+ where: {
+ emulatorId_name: {
+ emulatorId: gamenative.id,
+ name: field.name,
+ },
+ },
+ create: buildDefinitionCreate(gamenative.id, field),
+ update: buildDefinitionUpdate(field),
+ })
+ }
+
+ const staleDefinitions = await prisma.customFieldDefinition.findMany({
+ where: {
+ emulatorId: gamenative.id,
+ name: { notIn: fieldNames },
+ },
+ select: { id: true, name: true },
+ })
+
+ if (staleDefinitions.length > 0) {
+ const valueCount = await prisma.listingCustomFieldValue.count({
+ where: { customFieldDefinitionId: { in: staleDefinitions.map((d) => d.id) } },
+ })
+ const pcValueCount = await prisma.pcListingCustomFieldValue.count({
+ where: { customFieldDefinitionId: { in: staleDefinitions.map((d) => d.id) } },
+ })
+
+ if (valueCount > 0 || pcValueCount > 0) {
+ console.warn(
+ `β οΈ Skipping removal of ${staleDefinitions.length} stale field(s) ` +
+ `(${staleDefinitions.map((d) => d.name).join(', ')}) β ` +
+ `${valueCount + pcValueCount} user-submitted values would be cascade-deleted. ` +
+ `Remove manually after migrating data.`,
+ )
+ } else {
+ await prisma.customFieldDefinition.deleteMany({
+ where: { id: { in: staleDefinitions.map((d) => d.id) } },
+ })
+ console.info(`β
Removed ${staleDefinitions.length} unused field definition(s).`)
+ }
+ }
+
+ console.info(
+ `β
GameNative custom fields synced. Updated ${GAMENATIVE_CUSTOM_FIELDS.length} definitions.`,
+ )
+}
+
+function buildDefinitionCreate(emulatorId: string, field: GameNativeCustomFieldSeed) {
+ const { options, defaultValue, placeholder, ...base } = field
+
+ return {
+ emulatorId,
+ name: base.name,
+ label: base.label,
+ type: base.type,
+ options: normalizeJsonInput(options),
+ defaultValue: normalizeJsonInput(defaultValue),
+ placeholder: placeholder ?? null,
+ rangeMin: null,
+ rangeMax: null,
+ rangeDecimals: null,
+ rangeUnit: null,
+ isRequired: base.required,
+ displayOrder: base.displayOrder,
+ }
+}
+
+function buildDefinitionUpdate(field: GameNativeCustomFieldSeed) {
+ const { options, defaultValue, placeholder, ...base } = field
+
+ return {
+ label: base.label,
+ type: base.type,
+ options: normalizeJsonInput(options),
+ defaultValue: normalizeJsonInput(defaultValue),
+ placeholder: placeholder ?? null,
+ rangeMin: null,
+ rangeMax: null,
+ rangeDecimals: null,
+ rangeUnit: null,
+ isRequired: base.required,
+ displayOrder: base.displayOrder,
+ }
+}
+
+function normalizeJsonInput(
+ value: unknown,
+): Prisma.NullableJsonNullValueInput | Prisma.InputJsonValue | undefined {
+ return value === null || value === undefined ? Prisma.JsonNull : (value as Prisma.InputJsonValue)
+}
diff --git a/src/app/admin/users/components/UserDetailsModal.tsx b/src/app/admin/users/components/UserDetailsModal.tsx
index 1b7eaf15..fbcc990b 100644
--- a/src/app/admin/users/components/UserDetailsModal.tsx
+++ b/src/app/admin/users/components/UserDetailsModal.tsx
@@ -368,7 +368,10 @@ function UserDetailsModal(props: Props) {
Trust Score
-
+
{userQuery.data.trustScore}
@@ -669,6 +672,7 @@ function UserDetailsModal(props: Props) {
setCustomAdjustment(e.target.value)}
disabled={isAdjusting}
@@ -698,6 +702,7 @@ function UserDetailsModal(props: Props) {
setAdjustmentReason(e.target.value)}
disabled={isAdjusting}
diff --git a/src/app/admin/users/components/UserRoleModal.tsx b/src/app/admin/users/components/UserRoleModal.tsx
index df1949d4..1a292884 100644
--- a/src/app/admin/users/components/UserRoleModal.tsx
+++ b/src/app/admin/users/components/UserRoleModal.tsx
@@ -64,7 +64,12 @@ function UserRoleModal(props: Props) {
return (
-
+
Edit User Role
diff --git a/src/app/listings/ListingsPage.tsx b/src/app/listings/ListingsPage.tsx
index 2bdafb26..fd6147aa 100644
--- a/src/app/listings/ListingsPage.tsx
+++ b/src/app/listings/ListingsPage.tsx
@@ -12,7 +12,7 @@ import {
} from '@/app/listings/shared/components'
import CommunitySupportBanner from '@/components/banners/CommunitySupportBanner'
import { EmulatorIcon, SystemIcon } from '@/components/icons'
-import { Badge } from '@/components/ui/Badge'
+import { BannedUserBadge } from '@/components/ui/BannedUserBadge'
import { Button } from '@/components/ui/Button'
import { ColumnVisibilityControl } from '@/components/ui/ColumnVisibilityControl'
import { DisplayToggleButton } from '@/components/ui/DisplayToggleButton'
@@ -516,20 +516,12 @@ function ListingsPage() {
)}
- {isModerator &&
- listing.author &&
- 'userBans' in listing.author &&
- Array.isArray(listing.author.userBans) &&
- listing.author.userBans.length > 0 && (
-
-
-
- BANNED
-
-
- This user has been banned
-
- )}
+
{listing.developerVerifications &&
listing.developerVerifications.length > 0 && (
diff --git a/src/app/listings/[id]/components/ListingDetailsClient.tsx b/src/app/listings/[id]/components/ListingDetailsClient.tsx
index 5932e947..63a0c0fb 100644
--- a/src/app/listings/[id]/components/ListingDetailsClient.tsx
+++ b/src/app/listings/[id]/components/ListingDetailsClient.tsx
@@ -11,16 +11,16 @@ import { AuthorPanel } from '@/app/listings/components/shared/details/AuthorPane
import BookmarkButton from '@/app/listings/components/shared/details/BookmarkButton'
import { DetailsHeader } from '@/app/listings/components/shared/details/DetailsHeader'
import { DetailsHeaderBadges } from '@/app/listings/components/shared/details/DetailsHeaderBadges'
+import { ModeratorInfoPanel } from '@/app/listings/components/shared/details/ModeratorInfoPanel'
+import { logHandheldVoteError } from '@/app/listings/components/shared/details/utils/logVoteError'
+import { refreshHandheldListingDetail } from '@/app/listings/components/shared/details/utils/refreshListingDetail'
import { VotingSection } from '@/app/listings/components/shared/details/VotingSection'
import { NotesSection } from '@/app/listings/components/shared/NotesSection'
import { GameImage } from '@/app/listings/shared/components'
import CommunitySupportBanner from '@/components/banners/CommunitySupportBanner'
-import { Card, Button, Badge } from '@/components/ui'
+import { BannedUserBadge, Button, Card } from '@/components/ui'
import { api } from '@/lib/api'
-import { logger } from '@/lib/logger'
-import toast from '@/lib/toast'
import { type RouterOutput } from '@/types/trpc'
-import getErrorMessage from '@/utils/getErrorMessage'
import { roleIncludesRole } from '@/utils/permission-system'
import { Role } from '@orm'
import CommentThread from './CommentThread'
@@ -46,10 +46,7 @@ function ListingDetailsClient(props: Props) {
const currentUserQuery = api.users.me.useQuery()
const canViewBannedUsers = roleIncludesRole(currentUserQuery.data?.role, Role.MODERATOR)
- const refreshData = async () => {
- await utils.listings.byId.invalidate({ id: props.listing.id })
- await utils.listings.byId.refetch({ id: props.listing.id })
- }
+ const refreshData = () => refreshHandheldListingDetail({ utils, listingId: props.listing.id })
const scrollToVoteSection = () => {
voteSectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' })
@@ -57,10 +54,7 @@ function ListingDetailsClient(props: Props) {
const voteMutation = api.listings.vote.useMutation({
onSuccess: refreshData,
- onError: (error) => {
- logger.error('[ListingDetailsClient] handleVoting:', error)
- toast.error(`Failed to vote: ${getErrorMessage(error)}`)
- },
+ onError: (error) => logHandheldVoteError({ error, listingId: props.listing.id }),
})
const handleVote = async (value: boolean | null) => {
@@ -142,15 +136,11 @@ function ListingDetailsClient(props: Props) {
authorId={props.listing?.author?.id}
postedAt={props.listing.createdAt}
bannedBadge={
- canViewBannedUsers &&
- props.listing?.author &&
- 'userBans' in props.listing.author &&
- Array.isArray(props.listing.author.userBans) &&
- props.listing.author.userBans.length > 0 ? (
-
- BANNED USER
-
- ) : undefined
+
}
/>
@@ -205,6 +195,10 @@ function ListingDetailsClient(props: Props) {
/>
+ {canViewBannedUsers && (
+
+ )}
+
+ {props.name ?? 'Unknown User'}
+
+
+ )
+}
diff --git a/src/app/listings/components/shared/details/ApprovalSection.tsx b/src/app/listings/components/shared/details/ApprovalSection.tsx
new file mode 100644
index 00000000..35d6b67a
--- /dev/null
+++ b/src/app/listings/components/shared/details/ApprovalSection.tsx
@@ -0,0 +1,43 @@
+import { ApprovalStatusBadge, LocalizedDate } from '@/components/ui'
+import { type RouterOutput } from '@/types/trpc'
+import { ApprovalStatus } from '@orm'
+import { AdminUserLink } from './AdminUserLink'
+
+type ModeratorInfo = NonNullable
+
+interface Props {
+ approval: ModeratorInfo['approval']
+}
+
+export function ApprovalSection(props: Props) {
+ return (
+
+
Approval
+
+
+ {props.approval.processedBy && (
+
+ by{' '}
+
+
+ )}
+ {props.approval.processedAt && (
+
+
+
+ )}
+
+ {props.approval.processedNotes && (
+
+ {props.approval.processedNotes}
+
+ )}
+ {props.approval.status === ApprovalStatus.PENDING && (
+
Not yet reviewed
+ )}
+
+ )
+}
diff --git a/src/app/listings/components/shared/details/ModeratorInfoPanel.tsx b/src/app/listings/components/shared/details/ModeratorInfoPanel.tsx
new file mode 100644
index 00000000..e6c0dc03
--- /dev/null
+++ b/src/app/listings/components/shared/details/ModeratorInfoPanel.tsx
@@ -0,0 +1,66 @@
+'use client'
+
+import { ChevronDown, ChevronRight } from 'lucide-react'
+import { useState } from 'react'
+import { LoadingSpinner } from '@/components/ui'
+import { api } from '@/lib/api'
+import { type ListingType } from '@/schemas/common'
+import { ApprovalSection } from './ApprovalSection'
+import { VotesSection } from './VotesSection'
+
+interface Props {
+ listingId: string
+ listingType: ListingType
+}
+
+export function ModeratorInfoPanel(props: Props) {
+ const [isExpanded, setIsExpanded] = useState(false)
+
+ const moderatorInfoQuery = api.listings.moderatorInfo.useQuery(
+ { id: props.listingId, type: props.listingType },
+ { enabled: isExpanded, refetchOnWindowFocus: false },
+ )
+
+ const ChevronIcon = isExpanded ? ChevronDown : ChevronRight
+
+ return (
+
+
+
+ {isExpanded && (
+
+ {moderatorInfoQuery.isLoading && (
+
+
+
+ )}
+
+ {moderatorInfoQuery.data && (
+ <>
+
+
+ >
+ )}
+
+ {moderatorInfoQuery.isError && (
+
+ Failed to load moderator info.
+
+ )}
+
+ )}
+
+ )
+}
diff --git a/src/app/listings/components/shared/details/VoteDirectionIcon.test.tsx b/src/app/listings/components/shared/details/VoteDirectionIcon.test.tsx
new file mode 100644
index 00000000..52ade6ac
--- /dev/null
+++ b/src/app/listings/components/shared/details/VoteDirectionIcon.test.tsx
@@ -0,0 +1,31 @@
+import { render, screen } from '@testing-library/react'
+import { describe, expect, it } from 'vitest'
+import { VoteDirectionIcon } from './VoteDirectionIcon'
+
+describe('VoteDirectionIcon', () => {
+ it('renders upvote icon with aria-label "Upvote" when value is true', () => {
+ render()
+ const icon = screen.getByLabelText('Upvote')
+ expect(icon).toBeInTheDocument()
+ expect(icon).toHaveAttribute('role', 'img')
+ })
+
+ it('renders downvote icon with aria-label "Downvote" when value is false', () => {
+ render()
+ const icon = screen.getByLabelText('Downvote')
+ expect(icon).toBeInTheDocument()
+ expect(icon).toHaveAttribute('role', 'img')
+ })
+
+ it('applies green color class for upvote', () => {
+ render()
+ const icon = screen.getByLabelText('Upvote')
+ expect(icon).toHaveClass('text-green-600')
+ })
+
+ it('applies red color class for downvote', () => {
+ render()
+ const icon = screen.getByLabelText('Downvote')
+ expect(icon).toHaveClass('text-red-600')
+ })
+})
diff --git a/src/app/listings/components/shared/details/VoteDirectionIcon.tsx b/src/app/listings/components/shared/details/VoteDirectionIcon.tsx
new file mode 100644
index 00000000..4fe1146a
--- /dev/null
+++ b/src/app/listings/components/shared/details/VoteDirectionIcon.tsx
@@ -0,0 +1,14 @@
+import { ArrowDown, ArrowUp } from 'lucide-react'
+
+interface Props {
+ value: boolean
+}
+
+export function VoteDirectionIcon(props: Props) {
+ const label = props.value ? 'Upvote' : 'Downvote'
+ return props.value ? (
+
+ ) : (
+
+ )
+}
diff --git a/src/app/listings/components/shared/details/VoteRow.test.tsx b/src/app/listings/components/shared/details/VoteRow.test.tsx
new file mode 100644
index 00000000..996de715
--- /dev/null
+++ b/src/app/listings/components/shared/details/VoteRow.test.tsx
@@ -0,0 +1,86 @@
+import { render, screen } from '@testing-library/react'
+import { describe, expect, it } from 'vitest'
+import { VoteRow } from './VoteRow'
+
+function makeVote(
+ overrides: Partial<{
+ id: string
+ value: boolean
+ nullifiedAt: Date | null
+ userName: string | null
+ }> = {},
+) {
+ return {
+ id: overrides.id ?? 'vote-1',
+ value: overrides.value ?? true,
+ nullifiedAt: overrides.nullifiedAt ?? null,
+ createdAt: new Date('2026-01-01T00:00:00Z'),
+ user: {
+ id: 'user-1',
+ name: 'userName' in overrides ? (overrides.userName ?? null) : 'Alice',
+ trustScore: 50,
+ },
+ }
+}
+
+function renderRow(vote: ReturnType) {
+ return render(
+ ,
+ )
+}
+
+describe('VoteRow', () => {
+ it('renders an active (non-nullified) upvote row without strike-through or Nullified badge', () => {
+ const vote = makeVote({ value: true, nullifiedAt: null })
+ renderRow(vote)
+
+ expect(screen.queryByText('Nullified')).not.toBeInTheDocument()
+
+ const row = screen.getByRole('row')
+ expect(row.className).not.toContain('line-through')
+ expect(row.className).not.toContain('opacity-60')
+ })
+
+ it('renders a nullified vote row with reduced opacity and a Nullified badge, but no line-through', () => {
+ const vote = makeVote({ value: true, nullifiedAt: new Date('2026-01-02T00:00:00Z') })
+ renderRow(vote)
+
+ expect(screen.getByText('Nullified')).toBeInTheDocument()
+
+ const row = screen.getByRole('row')
+ expect(row.className).toContain('opacity-60')
+ expect(row.innerHTML).not.toContain('line-through')
+ })
+
+ it('links to the voter user in admin', () => {
+ const vote = makeVote({ userName: 'Alice' })
+ renderRow(vote)
+
+ expect(screen.getByText('Alice')).toBeInTheDocument()
+ })
+
+ it('falls back to "Unknown User" when voter name is null', () => {
+ const vote = makeVote({ userName: null })
+ renderRow(vote)
+
+ expect(screen.getByText('Unknown User')).toBeInTheDocument()
+ })
+
+ it('renders upvote icon with Upvote aria-label for value=true', () => {
+ const vote = makeVote({ value: true })
+ renderRow(vote)
+
+ expect(screen.getByLabelText('Upvote')).toBeInTheDocument()
+ })
+
+ it('renders downvote icon with Downvote aria-label for value=false', () => {
+ const vote = makeVote({ value: false })
+ renderRow(vote)
+
+ expect(screen.getByLabelText('Downvote')).toBeInTheDocument()
+ })
+})
diff --git a/src/app/listings/components/shared/details/VoteRow.tsx b/src/app/listings/components/shared/details/VoteRow.tsx
new file mode 100644
index 00000000..7621b7a6
--- /dev/null
+++ b/src/app/listings/components/shared/details/VoteRow.tsx
@@ -0,0 +1,42 @@
+import { Badge, LocalizedDate, TrustLevelBadge } from '@/components/ui'
+import { cn } from '@/lib/utils'
+import { type RouterOutput } from '@/types/trpc'
+import { AdminUserLink } from './AdminUserLink'
+import { VoteDirectionIcon } from './VoteDirectionIcon'
+
+type ModeratorInfo = NonNullable
+export type VoteEntry = ModeratorInfo['votes'][number]
+
+interface Props {
+ vote: VoteEntry
+}
+
+export function VoteRow(props: Props) {
+ const isNullified = props.vote.nullifiedAt !== null
+
+ return (
+
+ |
+
+ |
+
+
+ |
+
+
+ |
+
+
+ |
+
+ {isNullified && (
+
+ Nullified
+
+ )}
+ |
+
+ )
+}
diff --git a/src/app/listings/components/shared/details/VotesSection.tsx b/src/app/listings/components/shared/details/VotesSection.tsx
new file mode 100644
index 00000000..2c275406
--- /dev/null
+++ b/src/app/listings/components/shared/details/VotesSection.tsx
@@ -0,0 +1,43 @@
+import { type RouterOutput } from '@/types/trpc'
+import { VoteRow } from './VoteRow'
+
+type ModeratorInfo = NonNullable
+
+interface Props {
+ votes: ModeratorInfo['votes']
+ voteCounts: ModeratorInfo['voteCounts']
+}
+
+export function VotesSection(props: Props) {
+ const { up, down, nullified } = props.voteCounts
+
+ return (
+
+
+ Votes ({up} up, {down} down{nullified > 0 && `, ${nullified} nullified`})
+
+ {props.votes.length === 0 ? (
+
No votes yet
+ ) : (
+
+
+
+
+ |
+ User |
+ Trust |
+ When |
+ |
+
+
+
+ {props.votes.map((vote) => (
+
+ ))}
+
+
+
+ )}
+
+ )
+}
diff --git a/src/app/listings/components/shared/details/utils/logVoteError.test.ts b/src/app/listings/components/shared/details/utils/logVoteError.test.ts
new file mode 100644
index 00000000..8722b154
--- /dev/null
+++ b/src/app/listings/components/shared/details/utils/logVoteError.test.ts
@@ -0,0 +1,77 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { logger } from '@/lib/logger'
+import toast from '@/lib/toast'
+import { logHandheldVoteError, logPcVoteError } from './logVoteError'
+
+vi.mock('@/lib/logger', () => ({
+ logger: {
+ error: vi.fn(),
+ },
+}))
+
+vi.mock('@/lib/toast', () => ({
+ default: {
+ error: vi.fn(),
+ },
+}))
+
+vi.mock('@/utils/getErrorMessage', () => ({
+ default: (error: unknown) => (error instanceof Error ? error.message : String(error)),
+}))
+
+afterEach(() => vi.clearAllMocks())
+
+describe('logHandheldVoteError', () => {
+ const LISTING_ID = '00000000-0000-4000-a000-000000000010'
+
+ it('passes listingId as extra context to logger.error', () => {
+ const error = new Error('Network down')
+
+ logHandheldVoteError({ error, listingId: LISTING_ID })
+
+ expect(logger.error).toHaveBeenCalledTimes(1)
+ expect(logger.error).toHaveBeenCalledWith('[ListingDetailsClient] handleVoting:', error, {
+ listingId: LISTING_ID,
+ })
+ })
+
+ it('calls toast.error with a user-facing message derived from the error', () => {
+ logHandheldVoteError({ error: new Error('Forbidden'), listingId: LISTING_ID })
+
+ expect(toast.error).toHaveBeenCalledTimes(1)
+ expect(toast.error).toHaveBeenCalledWith('Failed to vote: Forbidden')
+ })
+
+ it('handles non-Error thrown values without crashing', () => {
+ logHandheldVoteError({ error: 'bare string', listingId: LISTING_ID })
+
+ expect(logger.error).toHaveBeenCalledWith(
+ '[ListingDetailsClient] handleVoting:',
+ 'bare string',
+ { listingId: LISTING_ID },
+ )
+ expect(toast.error).toHaveBeenCalledWith('Failed to vote: bare string')
+ })
+})
+
+describe('logPcVoteError', () => {
+ const PC_LISTING_ID = '00000000-0000-4000-a000-000000000011'
+
+ it('passes pcListingId as extra context to logger.error', () => {
+ const error = new Error('CAPTCHA failed')
+
+ logPcVoteError({ error, pcListingId: PC_LISTING_ID })
+
+ expect(logger.error).toHaveBeenCalledTimes(1)
+ expect(logger.error).toHaveBeenCalledWith('[PcListingDetailsClient] handleVoting:', error, {
+ pcListingId: PC_LISTING_ID,
+ })
+ })
+
+ it('uses the PC-specific prefix (not the handheld one)', () => {
+ logPcVoteError({ error: new Error('err'), pcListingId: PC_LISTING_ID })
+
+ const firstArg = vi.mocked(logger.error).mock.calls[0]?.[0]
+ expect(firstArg).toBe('[PcListingDetailsClient] handleVoting:')
+ })
+})
diff --git a/src/app/listings/components/shared/details/utils/logVoteError.ts b/src/app/listings/components/shared/details/utils/logVoteError.ts
new file mode 100644
index 00000000..a4533d9a
--- /dev/null
+++ b/src/app/listings/components/shared/details/utils/logVoteError.ts
@@ -0,0 +1,27 @@
+import { logger } from '@/lib/logger'
+import toast from '@/lib/toast'
+import getErrorMessage from '@/utils/getErrorMessage'
+
+interface HandheldParams {
+ error: unknown
+ listingId: string
+}
+
+export function logHandheldVoteError(params: HandheldParams): void {
+ logger.error('[ListingDetailsClient] handleVoting:', params.error, {
+ listingId: params.listingId,
+ })
+ toast.error(`Failed to vote: ${getErrorMessage(params.error)}`)
+}
+
+interface PcParams {
+ error: unknown
+ pcListingId: string
+}
+
+export function logPcVoteError(params: PcParams): void {
+ logger.error('[PcListingDetailsClient] handleVoting:', params.error, {
+ pcListingId: params.pcListingId,
+ })
+ toast.error(`Failed to vote: ${getErrorMessage(params.error)}`)
+}
diff --git a/src/app/listings/components/shared/details/utils/refreshListingDetail.test.ts b/src/app/listings/components/shared/details/utils/refreshListingDetail.test.ts
new file mode 100644
index 00000000..20030893
--- /dev/null
+++ b/src/app/listings/components/shared/details/utils/refreshListingDetail.test.ts
@@ -0,0 +1,101 @@
+import { describe, expect, it, vi } from 'vitest'
+import { refreshHandheldListingDetail, refreshPcListingDetail } from './refreshListingDetail'
+
+function createMockUtils() {
+ return {
+ listings: {
+ byId: {
+ invalidate: vi.fn().mockResolvedValue(undefined),
+ refetch: vi.fn().mockResolvedValue(undefined),
+ },
+ moderatorInfo: {
+ invalidate: vi.fn().mockResolvedValue(undefined),
+ },
+ },
+ pcListings: {
+ byId: {
+ invalidate: vi.fn().mockResolvedValue(undefined),
+ },
+ },
+ }
+}
+
+describe('refreshHandheldListingDetail', () => {
+ const LISTING_ID = '00000000-0000-4000-a000-000000000010'
+
+ it('invalidates listings.byId and listings.moderatorInfo with type=handheld', async () => {
+ const utils = createMockUtils()
+
+ await refreshHandheldListingDetail({ utils, listingId: LISTING_ID })
+
+ expect(utils.listings.byId.invalidate).toHaveBeenCalledWith({ id: LISTING_ID })
+ expect(utils.listings.moderatorInfo.invalidate).toHaveBeenCalledWith({
+ id: LISTING_ID,
+ type: 'handheld',
+ })
+ })
+
+ it('refetches listings.byId after invalidation completes', async () => {
+ const utils = createMockUtils()
+ const refetchOrder: string[] = []
+ vi.mocked(utils.listings.byId.invalidate).mockImplementation(async () => {
+ refetchOrder.push('invalidate-byId')
+ })
+ vi.mocked(utils.listings.moderatorInfo.invalidate).mockImplementation(async () => {
+ refetchOrder.push('invalidate-moderatorInfo')
+ })
+ vi.mocked(utils.listings.byId.refetch).mockImplementation(async () => {
+ refetchOrder.push('refetch-byId')
+ })
+
+ await refreshHandheldListingDetail({ utils, listingId: LISTING_ID })
+
+ expect(refetchOrder.indexOf('refetch-byId')).toBeGreaterThan(
+ refetchOrder.indexOf('invalidate-byId'),
+ )
+ expect(refetchOrder.indexOf('refetch-byId')).toBeGreaterThan(
+ refetchOrder.indexOf('invalidate-moderatorInfo'),
+ )
+ })
+
+ it('does NOT touch the pcListings utils', async () => {
+ const utils = createMockUtils()
+
+ await refreshHandheldListingDetail({ utils, listingId: LISTING_ID })
+
+ expect(utils.pcListings.byId.invalidate).not.toHaveBeenCalled()
+ })
+})
+
+describe('refreshPcListingDetail', () => {
+ const PC_LISTING_ID = '00000000-0000-4000-a000-000000000011'
+
+ it('invalidates pcListings.byId and listings.moderatorInfo with type=pc', async () => {
+ const utils = createMockUtils()
+
+ await refreshPcListingDetail({ utils, pcListingId: PC_LISTING_ID })
+
+ expect(utils.pcListings.byId.invalidate).toHaveBeenCalledWith({ id: PC_LISTING_ID })
+ expect(utils.listings.moderatorInfo.invalidate).toHaveBeenCalledWith({
+ id: PC_LISTING_ID,
+ type: 'pc',
+ })
+ })
+
+ it('does NOT touch the handheld listings.byId utils', async () => {
+ const utils = createMockUtils()
+
+ await refreshPcListingDetail({ utils, pcListingId: PC_LISTING_ID })
+
+ expect(utils.listings.byId.invalidate).not.toHaveBeenCalled()
+ expect(utils.listings.byId.refetch).not.toHaveBeenCalled()
+ })
+
+ it('does NOT call refetch', async () => {
+ const utils = createMockUtils()
+
+ await refreshPcListingDetail({ utils, pcListingId: PC_LISTING_ID })
+
+ expect(utils.listings.byId.refetch).not.toHaveBeenCalled()
+ })
+})
diff --git a/src/app/listings/components/shared/details/utils/refreshListingDetail.ts b/src/app/listings/components/shared/details/utils/refreshListingDetail.ts
new file mode 100644
index 00000000..4503ed46
--- /dev/null
+++ b/src/app/listings/components/shared/details/utils/refreshListingDetail.ts
@@ -0,0 +1,40 @@
+import { type api } from '@/lib/api'
+
+type Utils = ReturnType
+
+interface RefreshHandheldParams {
+ utils: {
+ listings: {
+ byId: Pick
+ moderatorInfo: Pick
+ }
+ }
+ listingId: string
+}
+
+export async function refreshHandheldListingDetail(params: RefreshHandheldParams): Promise {
+ await Promise.all([
+ params.utils.listings.byId.invalidate({ id: params.listingId }),
+ params.utils.listings.moderatorInfo.invalidate({ id: params.listingId, type: 'handheld' }),
+ ])
+ await params.utils.listings.byId.refetch({ id: params.listingId })
+}
+
+interface RefreshPcParams {
+ utils: {
+ pcListings: {
+ byId: Pick
+ }
+ listings: {
+ moderatorInfo: Pick
+ }
+ }
+ pcListingId: string
+}
+
+export async function refreshPcListingDetail(params: RefreshPcParams): Promise {
+ await Promise.all([
+ params.utils.pcListings.byId.invalidate({ id: params.pcListingId }),
+ params.utils.listings.moderatorInfo.invalidate({ id: params.pcListingId, type: 'pc' }),
+ ])
+}
diff --git a/src/app/listings/new/NewListingPage.tsx b/src/app/listings/new/NewListingPage.tsx
index 42c85151..6d49ea12 100644
--- a/src/app/listings/new/NewListingPage.tsx
+++ b/src/app/listings/new/NewListingPage.tsx
@@ -16,6 +16,7 @@ import {
import { useForm, Controller } from 'react-hook-form'
import '@/shared/emulator-config/eden'
import '@/shared/emulator-config/azahar'
+import '@/shared/emulator-config/gamenative'
import { Button, LoadingSpinner } from '@/components/ui'
import analytics from '@/lib/analytics'
import { api } from '@/lib/api'
@@ -27,6 +28,7 @@ import { type RouterInput } from '@/types/trpc'
import { parseCustomFieldOptions, getCustomFieldDefaultValue } from '@/utils/custom-fields'
import getErrorMessage from '@/utils/getErrorMessage'
import { formatCountLabel } from '@/utils/text'
+import { ms } from '@/utils/time'
import {
CustomFieldsFormSection,
type DeviceOption,
@@ -49,6 +51,8 @@ import { reconcileDriverValue } from '../components/shared/custom-fields/driverV
export type ListingFormValues = RouterInput['listings']['create']
+const HIGHLIGHT_DURATION_MS = 1800
+
function AddListingPage() {
const router = useRouter()
const searchParams = useSearchParams()
@@ -99,7 +103,7 @@ function AddListingPage() {
}, [availableEmulators, selectedEmulatorId])
// Prefetch driver versions so an imported Eden driver filename can be resolved immediately
const driverVersionsQuery = api.listings.driverVersions.useQuery(undefined, {
- staleTime: 30 * 60 * 1000,
+ staleTime: ms.minutes(30),
refetchOnWindowFocus: false,
refetchOnReconnect: false,
})
@@ -165,15 +169,14 @@ function AddListingPage() {
}
importHighlightTimeoutRef.current = window.setTimeout(() => {
setHighlightedFieldIds([])
- }, 1800)
-
- if (changedCount > 0) {
- toast.success(
- `Imported Eden configuration. Filled ${formatCountLabel('field', changedCount)}.`,
- )
- } else {
- toast.success('Imported Eden configuration.')
- }
+ }, HIGHLIGHT_DURATION_MS)
+
+ const changedFieldsMessage =
+ changedCount > 0
+ ? `Filled ${formatCountLabel('field', changedCount)}.`
+ : 'No matching fields were filled.'
+
+ toast.success(`Imported configuration. ${changedFieldsMessage}`)
if (uniqueMissing.length > 0) {
toast.info(`Review manually: ${uniqueMissing.join(', ')}`)
@@ -202,9 +205,10 @@ function AddListingPage() {
.trim()
.toLowerCase()
- const importerSlugMap: Record = {
+ const importerSlugMap: Record = {
eden: 'eden',
azahar: 'azahar',
+ gamenative: 'gamenative',
}
const selectedEmulatorSlug = importerSlugMap[normalizedEmulatorName] ?? null
@@ -412,10 +416,8 @@ function AddListingPage() {
})
useEffect(() => {
- if (selectedEmulatorSlug !== 'eden') {
- setImportSummary(null)
- setHighlightedFieldIds([])
- }
+ setImportSummary(null)
+ setHighlightedFieldIds([])
}, [selectedEmulatorSlug])
const onSubmit = async (data: ListingFormValues) => {
diff --git a/src/app/pc-listings/PcListingsPage.tsx b/src/app/pc-listings/PcListingsPage.tsx
index f6e51349..f1e47f63 100644
--- a/src/app/pc-listings/PcListingsPage.tsx
+++ b/src/app/pc-listings/PcListingsPage.tsx
@@ -13,23 +13,23 @@ import {
import CommunitySupportBanner from '@/components/banners/CommunitySupportBanner'
import { EmulatorIcon, SystemIcon } from '@/components/icons'
import {
- PerformanceBadge,
- PageSizeSelector,
- Pagination,
- LoadingSpinner,
- LocalizedDate,
- SortableHeader,
+ BannedUserBadge,
Button,
ColumnVisibilityControl,
+ DisplayToggleButton,
+ EditButton,
+ LoadingSpinner,
+ LocalizedDate,
MobileColumnVisibilityControl,
+ PageSizeSelector,
+ Pagination,
+ PerformanceBadge,
+ SortableHeader,
+ SuccessRateBar,
Tooltip,
- TooltipTrigger,
TooltipContent,
- EditButton,
+ TooltipTrigger,
ViewButton,
- Badge,
- DisplayToggleButton,
- SuccessRateBar,
} from '@/components/ui'
import storageKeys from '@/data/storageKeys'
import {
@@ -485,20 +485,12 @@ function PcListingsPage() {
)}
- {isModerator &&
- listing.author &&
- 'userBans' in listing.author &&
- Array.isArray(listing.author.userBans) &&
- listing.author.userBans.length > 0 && (
-
-
-
- BANNED
-
-
- This user has been banned
-
- )}
+
)}
diff --git a/src/app/pc-listings/[id]/components/PcListingDetailsClient.tsx b/src/app/pc-listings/[id]/components/PcListingDetailsClient.tsx
index 2c5ffd24..9d58104b 100644
--- a/src/app/pc-listings/[id]/components/PcListingDetailsClient.tsx
+++ b/src/app/pc-listings/[id]/components/PcListingDetailsClient.tsx
@@ -13,17 +13,17 @@ import BookmarkButton from '@/app/listings/components/shared/details/BookmarkBut
import { DetailFieldRow } from '@/app/listings/components/shared/details/DetailFieldRow'
import { DetailsHeader } from '@/app/listings/components/shared/details/DetailsHeader'
import { DetailsHeaderBadges } from '@/app/listings/components/shared/details/DetailsHeaderBadges'
+import { ModeratorInfoPanel } from '@/app/listings/components/shared/details/ModeratorInfoPanel'
+import { logPcVoteError } from '@/app/listings/components/shared/details/utils/logVoteError'
+import { refreshPcListingDetail } from '@/app/listings/components/shared/details/utils/refreshListingDetail'
import { VotingSection } from '@/app/listings/components/shared/details/VotingSection'
import { NotesSection } from '@/app/listings/components/shared/NotesSection'
import { GameImage } from '@/app/listings/shared/components'
import CommunitySupportBanner from '@/components/banners/CommunitySupportBanner'
-import { Card, Button, Badge } from '@/components/ui'
+import { BannedUserBadge, Button, Card } from '@/components/ui'
import { PC_OS_LABELS } from '@/data/pc-os'
import { api } from '@/lib/api'
-import { logger } from '@/lib/logger'
-import toast from '@/lib/toast'
import { type RouterOutput } from '@/types/trpc'
-import getErrorMessage from '@/utils/getErrorMessage'
import { roleIncludesRole } from '@/utils/permission-system'
import { type PcOs, Role } from '@orm'
import EditPcListingButton from './EditPcListingButton'
@@ -89,9 +89,7 @@ function PcListingDetailsClient(props: Props) {
},
] as const
- const refreshData = async () => {
- await utils.pcListings.byId.invalidate({ id: props.pcListing.id })
- }
+ const refreshData = () => refreshPcListingDetail({ utils, pcListingId: props.pcListing.id })
const scrollToVoteSection = () => {
voteSectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' })
@@ -99,10 +97,7 @@ function PcListingDetailsClient(props: Props) {
const voteMutation = api.pcListings.vote.useMutation({
onSuccess: refreshData,
- onError: (error) => {
- logger.error('[PcListingDetailsClient] handleVoting:', error)
- toast.error(`Failed to vote: ${getErrorMessage(error)}`)
- },
+ onError: (error) => logPcVoteError({ error, pcListingId: props.pcListing.id }),
})
const handleVote = async (value: boolean | null) => {
@@ -205,15 +200,11 @@ function PcListingDetailsClient(props: Props) {
authorId={props.pcListing.author?.id}
postedAt={props.pcListing.createdAt}
bannedBadge={
- canViewBannedUsers &&
- props.pcListing?.author &&
- 'userBans' in props.pcListing.author &&
- Array.isArray(props.pcListing.author.userBans) &&
- props.pcListing.author.userBans.length > 0 ? (
-
- BANNED USER
-
- ) : undefined
+
}
/>
@@ -269,6 +260,10 @@ function PcListingDetailsClient(props: Props) {
/>
+ {canViewBannedUsers && (
+
+ )}
+
{/* Comments Section */}
{formatUserRole(userQuery.data.role)}
- {canViewBannedUsers &&
- 'userBans' in userQuery.data &&
- Array.isArray(userQuery.data.userBans) &&
- userQuery.data.userBans.length > 0 && (
-
- BANNED USER
-
- )}
+
diff --git a/src/components/listings/AuthorDisplay.tsx b/src/components/listings/AuthorDisplay.tsx
index 95c96b0f..663415ee 100644
--- a/src/components/listings/AuthorDisplay.tsx
+++ b/src/components/listings/AuthorDisplay.tsx
@@ -1,5 +1,5 @@
import Link from 'next/link'
-import { Badge } from '@/components/ui'
+import { BannedUserBadge } from '@/components/ui'
interface Author {
id?: string | null
@@ -25,15 +25,7 @@ export function AuthorDisplay(props: Props) {
) : (
{props.author?.name ?? 'Anonymous'}
)}
- {props.canSeeBannedUsers &&
- props.author &&
- 'userBans' in props.author &&
- Array.isArray(props.author.userBans) &&
- props.author.userBans.length > 0 && (
-
- BANNED
-
- )}
+
)
}
diff --git a/src/components/ui/Badge.tsx b/src/components/ui/Badge.tsx
index ed7d03a0..d3c0223b 100644
--- a/src/components/ui/Badge.tsx
+++ b/src/components/ui/Badge.tsx
@@ -3,7 +3,7 @@
import { type PropsWithChildren } from 'react'
import { cn } from '@/lib/utils'
-type BadgeSize = 'sm' | 'md' | 'lg'
+export type BadgeSize = 'sm' | 'md' | 'lg'
export type BadgeVariant = 'default' | 'success' | 'warning' | 'danger' | 'info' | 'primary'
const variantClasses: Record = {
diff --git a/src/components/ui/BannedUserBadge.test.tsx b/src/components/ui/BannedUserBadge.test.tsx
new file mode 100644
index 00000000..0d3a8c2d
--- /dev/null
+++ b/src/components/ui/BannedUserBadge.test.tsx
@@ -0,0 +1,54 @@
+import { render, screen } from '@testing-library/react'
+import { describe, expect, it } from 'vitest'
+import { BannedUserBadge } from './BannedUserBadge'
+
+describe('BannedUserBadge', () => {
+ const bannedAuthor = { userBans: [{ id: 'ban-1' }] }
+ const cleanAuthor = { userBans: [] }
+
+ it('renders nothing when canView is false (even if author is banned)', () => {
+ const { container } = render()
+ expect(container).toBeEmptyDOMElement()
+ })
+
+ it('renders nothing when author has no active bans', () => {
+ const { container } = render()
+ expect(container).toBeEmptyDOMElement()
+ })
+
+ it('renders nothing for null / undefined authors', () => {
+ const { container: nullContainer } = render()
+ expect(nullContainer).toBeEmptyDOMElement()
+
+ const { container: undefContainer } = render(
+ ,
+ )
+ expect(undefContainer).toBeEmptyDOMElement()
+ })
+
+ it('renders the default BANNED USER label when author is banned and viewer can see', () => {
+ render()
+ expect(screen.getByText('BANNED USER')).toBeInTheDocument()
+ })
+
+ it('renders a custom label when provided (AuthorDisplay compact variant)', () => {
+ render()
+ expect(screen.getByText('BANNED')).toBeInTheDocument()
+ expect(screen.queryByText('BANNED USER')).not.toBeInTheDocument()
+ })
+
+ it('forwards className through to the underlying Badge', () => {
+ render()
+ const badge = screen.getByText('BANNED USER')
+ expect(badge.className).toContain('mt-1')
+ expect(badge.className).toContain('custom-class')
+ })
+
+ it('respects the size prop (profile-header md variant)', () => {
+ render()
+ const badge = screen.getByText('BANNED USER')
+ // size="md" β px-2 py-1 per Badge's sizeClasses map
+ expect(badge.className).toContain('px-2')
+ expect(badge.className).toContain('py-1')
+ })
+})
diff --git a/src/components/ui/BannedUserBadge.tsx b/src/components/ui/BannedUserBadge.tsx
new file mode 100644
index 00000000..ace6426f
--- /dev/null
+++ b/src/components/ui/BannedUserBadge.tsx
@@ -0,0 +1,34 @@
+'use client'
+
+import { Badge, type BadgeSize } from '@/components/ui/Badge'
+import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/Tooltip'
+import { hasActiveBans } from '@/utils/user-bans'
+
+interface Props {
+ author: unknown
+ canView: boolean
+ label?: string
+ size?: BadgeSize
+ className?: string
+ tooltip?: string
+}
+
+export function BannedUserBadge(props: Props) {
+ if (!props.canView) return null
+ if (!hasActiveBans(props.author)) return null
+
+ const badge = (
+
+ {props.label ?? 'BANNED USER'}
+
+ )
+
+ if (!props.tooltip) return badge
+
+ return (
+
+ {badge}
+ {props.tooltip}
+
+ )
+}
diff --git a/src/components/ui/TrustLevelBadge.test.tsx b/src/components/ui/TrustLevelBadge.test.tsx
new file mode 100644
index 00000000..b4e2f96c
--- /dev/null
+++ b/src/components/ui/TrustLevelBadge.test.tsx
@@ -0,0 +1,56 @@
+import { render, screen } from '@testing-library/react'
+import { describe, expect, it } from 'vitest'
+import { TrustLevelBadge } from './TrustLevelBadge'
+
+describe('TrustLevelBadge', () => {
+ it('shows Newcomer level for score 0', () => {
+ render()
+ expect(screen.getByText('Newcomer')).toBeInTheDocument()
+ })
+
+ it('shows Newcomer level for negative score (no crash)', () => {
+ render()
+ expect(screen.getByText('Newcomer')).toBeInTheDocument()
+ })
+
+ it('shows Contributor level at 100', () => {
+ render()
+ expect(screen.getByText('Contributor')).toBeInTheDocument()
+ })
+
+ it('shows Trusted level at 250', () => {
+ render()
+ expect(screen.getByText('Trusted')).toBeInTheDocument()
+ })
+
+ describe('progress display', () => {
+ it('shows 0% for negative scores (clamped, no "-5%")', () => {
+ render()
+ expect(screen.getByText('0%')).toBeInTheDocument()
+ expect(screen.queryByText(/-\d+%/)).not.toBeInTheDocument()
+ })
+
+ it('shows 0% at the bottom of a level', () => {
+ render()
+ expect(screen.getByText('0%')).toBeInTheDocument()
+ })
+
+ it('shows 50% at the midpoint between levels', () => {
+ // Newcomer (0) β Contributor (100), midpoint is 50
+ render()
+ expect(screen.getByText('50%')).toBeInTheDocument()
+ })
+
+ it('does not render progress at the highest level (Core)', () => {
+ render()
+ expect(screen.getByText('Core')).toBeInTheDocument()
+ expect(screen.queryByText(/Progress to/)).not.toBeInTheDocument()
+ })
+
+ it('shows points needed (can be larger than typical when score is negative)', () => {
+ render()
+ // Next level Contributor at 100, so 100 - (-10) = 110 points needed
+ expect(screen.getByText('110 points needed')).toBeInTheDocument()
+ })
+ })
+})
diff --git a/src/components/ui/TrustLevelBadge.tsx b/src/components/ui/TrustLevelBadge.tsx
index 1d7ed805..4c831c95 100644
--- a/src/components/ui/TrustLevelBadge.tsx
+++ b/src/components/ui/TrustLevelBadge.tsx
@@ -75,7 +75,7 @@ export function TrustLevelBadge(props: Props): JSX.Element {
Progress to {nextLevel.name}
- {Math.round(progress * 100)}%
+ {Math.round(Math.min(Math.max(progress, 0), 1) * 100)}%
handleVote(true)}
disabled={!isAuthenticated || props.isLoading}
+ aria-pressed={optimisticVote === true}
className={`flex flex-col items-center p-3 rounded-lg transition-colors border-2 ${
optimisticVote === true
? 'bg-green-50 border-green-200 text-green-700 dark:bg-green-900/20 dark:border-green-700 dark:text-green-400'
@@ -173,6 +174,7 @@ export function VoteButtons(props: VoteButtonsProps) {
type="button"
onClick={() => handleVote(false)}
disabled={!isAuthenticated || props.isLoading}
+ aria-pressed={optimisticVote === false}
className={`flex flex-col items-center p-3 rounded-lg transition-colors border-2 ${
optimisticVote === false
? 'bg-red-50 border-red-200 text-red-700 dark:bg-red-900/20 dark:border-red-700 dark:text-red-400'
diff --git a/src/components/ui/index.ts b/src/components/ui/index.ts
index 44e27728..02088ad1 100644
--- a/src/components/ui/index.ts
+++ b/src/components/ui/index.ts
@@ -3,6 +3,7 @@ export * from './ApprovalStatusBadge'
export * from './AuthorRiskIndicator'
export * from './AuthorRiskWarningBanner'
export * from './Badge'
+export * from './BannedUserBadge'
export * from './BulkActions'
export * from './Button'
export * from './Card'
diff --git a/src/components/ui/modals/Modal.tsx b/src/components/ui/modals/Modal.tsx
index b1dd7a17..186a117e 100644
--- a/src/components/ui/modals/Modal.tsx
+++ b/src/components/ui/modals/Modal.tsx
@@ -65,6 +65,9 @@ export function Modal({ onClose, ...props }: Props) {
onClick={handleBackdropClick}
>
AppError.badRequest('Trust adjustment cannot be zero'),
+ }
}
diff --git a/src/lib/trust/config.ts b/src/lib/trust/config.ts
index 3b320df0..3ab72bbc 100644
--- a/src/lib/trust/config.ts
+++ b/src/lib/trust/config.ts
@@ -58,9 +58,13 @@ export const TRUST_ACTIONS = {
description: 'Manual trust score decrease by admin',
},
[TrustAction.VOTE_NULLIFICATION_REVERSAL]: {
- weight: 0, // Dynamic weight set during nullification
+ weight: 0,
description: 'Trust reversal due to vote nullification or restoration',
},
+ [TrustAction.VOTE_CHANGE_REVERSAL]: {
+ weight: 0,
+ description: 'Trust reversal due to vote change or removal',
+ },
} as const
export const TRUST_LEVELS = [
diff --git a/src/lib/trust/service.test.ts b/src/lib/trust/service.test.ts
new file mode 100644
index 00000000..c456fc8e
--- /dev/null
+++ b/src/lib/trust/service.test.ts
@@ -0,0 +1,595 @@
+import { describe, expect, it, beforeEach, vi } from 'vitest'
+import { TrustAction } from '@orm'
+
+vi.mock('@/lib/analytics', () => ({
+ default: {
+ trust: {
+ trustScoreChanged: vi.fn(),
+ trustLevelChanged: vi.fn(),
+ },
+ },
+}))
+
+function createMockPrismaCtx() {
+ return {
+ user: {
+ findMany: vi.fn().mockResolvedValue([]),
+ findUnique: vi.fn().mockResolvedValue(null),
+ update: vi.fn().mockResolvedValue({}),
+ },
+ trustActionLog: {
+ create: vi.fn().mockResolvedValue({}),
+ createMany: vi.fn().mockResolvedValue({ count: 0 }),
+ },
+ }
+}
+
+type MockPrismaCtx = ReturnType
+
+describe('TrustService.applyBulkManualAdjustments', () => {
+ let prismaCtx: MockPrismaCtx
+
+ beforeEach(async () => {
+ vi.clearAllMocks()
+ prismaCtx = createMockPrismaCtx()
+ })
+
+ async function createService() {
+ const { TrustService } = await import('./service')
+ return new TrustService(prismaCtx as never)
+ }
+
+ it('returns 0 and makes no DB calls for an empty map', async () => {
+ const service = await createService()
+
+ const result = await service.applyBulkManualAdjustments({
+ adjustments: new Map(),
+ reason: 'test',
+ adminUserId: 'admin-1',
+ })
+
+ expect(result).toBe(0)
+ expect(prismaCtx.user.findMany).not.toHaveBeenCalled()
+ expect(prismaCtx.trustActionLog.createMany).not.toHaveBeenCalled()
+ })
+
+ it('returns 0 and makes no DB calls when all adjustments are zero', async () => {
+ const service = await createService()
+
+ const result = await service.applyBulkManualAdjustments({
+ adjustments: new Map([
+ ['user-1', 0],
+ ['user-2', 0],
+ ]),
+ reason: 'test',
+ adminUserId: 'admin-1',
+ })
+
+ expect(result).toBe(0)
+ expect(prismaCtx.user.findMany).not.toHaveBeenCalled()
+ })
+
+ it('skips non-existent users gracefully', async () => {
+ prismaCtx.user.findMany.mockResolvedValue([{ id: 'user-1', trustScore: 50 }])
+ prismaCtx.user.findUnique.mockResolvedValue({ name: 'Admin', email: 'admin@test.com' })
+
+ const service = await createService()
+
+ const result = await service.applyBulkManualAdjustments({
+ adjustments: new Map([
+ ['user-1', -10],
+ ['deleted-user', -5],
+ ]),
+ reason: 'test',
+ adminUserId: 'admin-1',
+ })
+
+ expect(result).toBe(1)
+ expect(prismaCtx.user.update).toHaveBeenCalledTimes(1)
+ expect(prismaCtx.user.update).toHaveBeenCalledWith({
+ where: { id: 'user-1' },
+ data: { trustScore: 40, lastActiveAt: expect.any(Date) },
+ })
+ })
+
+ it('returns 0 when all users in map are non-existent', async () => {
+ prismaCtx.user.findMany.mockResolvedValue([])
+
+ const service = await createService()
+
+ const result = await service.applyBulkManualAdjustments({
+ adjustments: new Map([['deleted-user', -5]]),
+ reason: 'test',
+ adminUserId: 'admin-1',
+ })
+
+ expect(result).toBe(0)
+ expect(prismaCtx.user.findUnique).not.toHaveBeenCalled()
+ expect(prismaCtx.user.update).not.toHaveBeenCalled()
+ expect(prismaCtx.trustActionLog.createMany).not.toHaveBeenCalled()
+ })
+
+ it('applies a single adjustment correctly', async () => {
+ prismaCtx.user.findMany.mockResolvedValue([{ id: 'user-1', trustScore: 50 }])
+ prismaCtx.user.findUnique.mockResolvedValue({ name: 'Admin', email: 'admin@test.com' })
+
+ const service = await createService()
+
+ const result = await service.applyBulkManualAdjustments({
+ adjustments: new Map([['user-1', -10]]),
+ reason: 'Vote nullification: Spam',
+ adminUserId: 'admin-1',
+ })
+
+ expect(result).toBe(1)
+
+ expect(prismaCtx.user.update).toHaveBeenCalledWith({
+ where: { id: 'user-1' },
+ data: { trustScore: 40, lastActiveAt: expect.any(Date) },
+ })
+
+ expect(prismaCtx.trustActionLog.createMany).toHaveBeenCalledWith({
+ data: [
+ {
+ userId: 'user-1',
+ action: TrustAction.ADMIN_ADJUSTMENT_NEGATIVE,
+ weight: -10,
+ metadata: {
+ reason: 'Vote nullification: Spam',
+ adminUserId: 'admin-1',
+ adminName: 'Admin',
+ adjustment: -10,
+ },
+ },
+ ],
+ })
+ })
+
+ it('applies multiple adjustments with one createMany call', async () => {
+ prismaCtx.user.findMany.mockResolvedValue([
+ { id: 'user-1', trustScore: 100 },
+ { id: 'user-2', trustScore: 200 },
+ ])
+ prismaCtx.user.findUnique.mockResolvedValue({ name: 'Admin', email: 'admin@test.com' })
+
+ const service = await createService()
+
+ const result = await service.applyBulkManualAdjustments({
+ adjustments: new Map([
+ ['user-1', -5],
+ ['user-2', 10],
+ ]),
+ reason: 'Vote nullification: test',
+ adminUserId: 'admin-1',
+ })
+
+ expect(result).toBe(2)
+ expect(prismaCtx.user.update).toHaveBeenCalledTimes(2)
+ expect(prismaCtx.trustActionLog.createMany).toHaveBeenCalledTimes(1)
+
+ const logData = prismaCtx.trustActionLog.createMany.mock.calls[0][0].data as {
+ userId: string
+ action: TrustAction
+ }[]
+ expect(logData).toHaveLength(2)
+ expect(logData[0].userId).toBe('user-1')
+ expect(logData[0].action).toBe(TrustAction.ADMIN_ADJUSTMENT_NEGATIVE)
+ expect(logData[1].userId).toBe('user-2')
+ expect(logData[1].action).toBe(TrustAction.ADMIN_ADJUSTMENT_POSITIVE)
+ })
+
+ it('allows negative trust scores when adjustment exceeds current score (no clamping)', async () => {
+ prismaCtx.user.findMany.mockResolvedValue([{ id: 'user-1', trustScore: 5 }])
+ prismaCtx.user.findUnique.mockResolvedValue({ name: 'Admin', email: 'admin@test.com' })
+
+ const service = await createService()
+
+ await service.applyBulkManualAdjustments({
+ adjustments: new Map([['user-1', -100]]),
+ reason: 'test',
+ adminUserId: 'admin-1',
+ })
+
+ // 5 + (-100) = -95 β negative scores are allowed (used for author-risk signals)
+ expect(prismaCtx.user.update).toHaveBeenCalledWith({
+ where: { id: 'user-1' },
+ data: { trustScore: -95, lastActiveAt: expect.any(Date) },
+ })
+
+ const logData = prismaCtx.trustActionLog.createMany.mock.calls[0][0].data as {
+ weight: number
+ metadata: { adjustment: number }
+ }[]
+ expect(logData[0].weight).toBe(-100)
+ expect(logData[0].metadata.adjustment).toBe(-100)
+ })
+
+ it('emits trustLevelChanged analytics when level changes', async () => {
+ const analytics = (await import('@/lib/analytics')).default
+
+ prismaCtx.user.findMany.mockResolvedValue([{ id: 'user-1', trustScore: 95 }])
+ prismaCtx.user.findUnique.mockResolvedValue({ name: 'Admin', email: 'admin@test.com' })
+
+ const service = await createService()
+
+ await service.applyBulkManualAdjustments({
+ adjustments: new Map([['user-1', 10]]),
+ reason: 'test',
+ adminUserId: 'admin-1',
+ })
+
+ // 95 + 10 = 105, crosses from Newcomer (0) to Contributor (100)
+ expect(analytics.trust.trustLevelChanged).toHaveBeenCalledWith({
+ userId: 'user-1',
+ oldLevel: 'Newcomer',
+ newLevel: 'Contributor',
+ score: 105,
+ })
+ })
+
+ it('does not emit trustLevelChanged when level stays the same', async () => {
+ const analytics = (await import('@/lib/analytics')).default
+
+ prismaCtx.user.findMany.mockResolvedValue([{ id: 'user-1', trustScore: 50 }])
+ prismaCtx.user.findUnique.mockResolvedValue({ name: 'Admin', email: 'admin@test.com' })
+
+ const service = await createService()
+
+ await service.applyBulkManualAdjustments({
+ adjustments: new Map([['user-1', 10]]),
+ reason: 'test',
+ adminUserId: 'admin-1',
+ })
+
+ // 50 + 10 = 60, still Newcomer
+ expect(analytics.trust.trustLevelChanged).not.toHaveBeenCalled()
+ })
+
+ it('wraps in $transaction when prisma has it', async () => {
+ const mockTransaction = vi.fn(async (fn: (ctx: MockPrismaCtx) => Promise) =>
+ fn(prismaCtx),
+ )
+ const prismaWithTx = {
+ ...prismaCtx,
+ $transaction: mockTransaction,
+ }
+
+ prismaCtx.user.findMany.mockResolvedValue([{ id: 'user-1', trustScore: 50 }])
+ prismaCtx.user.findUnique.mockResolvedValue({ name: 'Admin', email: 'admin@test.com' })
+
+ const { TrustService } = await import('./service')
+ const service = new TrustService(prismaWithTx as never)
+
+ await service.applyBulkManualAdjustments({
+ adjustments: new Map([['user-1', 5]]),
+ reason: 'test',
+ adminUserId: 'admin-1',
+ })
+
+ expect(mockTransaction).toHaveBeenCalledTimes(1)
+ })
+
+ it('calls execute directly when prisma is a transaction client (no $transaction)', async () => {
+ prismaCtx.user.findMany.mockResolvedValue([{ id: 'user-1', trustScore: 50 }])
+ prismaCtx.user.findUnique.mockResolvedValue({ name: 'Admin', email: 'admin@test.com' })
+
+ const service = await createService()
+
+ const result = await service.applyBulkManualAdjustments({
+ adjustments: new Map([['user-1', 5]]),
+ reason: 'test',
+ adminUserId: 'admin-1',
+ })
+
+ expect(result).toBe(1)
+ expect(prismaCtx.user.update).toHaveBeenCalled()
+ })
+
+ it('uses positive action type for positive adjustments', async () => {
+ prismaCtx.user.findMany.mockResolvedValue([{ id: 'user-1', trustScore: 50 }])
+ prismaCtx.user.findUnique.mockResolvedValue({ name: 'Admin', email: 'admin@test.com' })
+
+ const service = await createService()
+
+ await service.applyBulkManualAdjustments({
+ adjustments: new Map([['user-1', 10]]),
+ reason: 'test',
+ adminUserId: 'admin-1',
+ })
+
+ const logData = prismaCtx.trustActionLog.createMany.mock.calls[0][0].data as {
+ action: TrustAction
+ }[]
+ expect(logData[0].action).toBe(TrustAction.ADMIN_ADJUSTMENT_POSITIVE)
+ })
+
+ it('falls back to email when admin has no name', async () => {
+ prismaCtx.user.findMany.mockResolvedValue([{ id: 'user-1', trustScore: 50 }])
+ prismaCtx.user.findUnique.mockResolvedValue({ name: null, email: 'admin@test.com' })
+
+ const service = await createService()
+
+ await service.applyBulkManualAdjustments({
+ adjustments: new Map([['user-1', -5]]),
+ reason: 'test',
+ adminUserId: 'admin-1',
+ })
+
+ const logData = prismaCtx.trustActionLog.createMany.mock.calls[0][0].data as {
+ metadata: { adminName: string }
+ }[]
+ expect(logData[0].metadata.adminName).toBe('admin@test.com')
+ })
+
+ it('uses "Unknown Admin" when admin not found', async () => {
+ prismaCtx.user.findMany.mockResolvedValue([{ id: 'user-1', trustScore: 50 }])
+ prismaCtx.user.findUnique.mockResolvedValue(null)
+
+ const service = await createService()
+
+ await service.applyBulkManualAdjustments({
+ adjustments: new Map([['user-1', -5]]),
+ reason: 'test',
+ adminUserId: 'admin-1',
+ })
+
+ const logData = prismaCtx.trustActionLog.createMany.mock.calls[0][0].data as {
+ metadata: { adminName: string }
+ }[]
+ expect(logData[0].metadata.adminName).toBe('Unknown Admin')
+ })
+})
+
+describe('TrustService.applyManualAdjustment', () => {
+ let prismaCtx: MockPrismaCtx
+
+ beforeEach(async () => {
+ vi.clearAllMocks()
+ prismaCtx = createMockPrismaCtx()
+ })
+
+ async function createService() {
+ const { TrustService } = await import('./service')
+ return new TrustService(prismaCtx as never)
+ }
+
+ it('throws when adjustment is zero', async () => {
+ const service = await createService()
+
+ await expect(
+ service.applyManualAdjustment({
+ userId: 'user-1',
+ adjustment: 0,
+ reason: 'test',
+ adminUserId: 'admin-1',
+ }),
+ ).rejects.toThrow()
+ })
+
+ it('throws when user not found', async () => {
+ prismaCtx.user.findUnique.mockResolvedValueOnce(null)
+ const service = await createService()
+
+ await expect(
+ service.applyManualAdjustment({
+ userId: 'user-1',
+ adjustment: 5,
+ reason: 'test',
+ adminUserId: 'admin-1',
+ }),
+ ).rejects.toThrow()
+ })
+
+ it('allows negative trust score when adjustment goes below zero (no clamping)', async () => {
+ prismaCtx.user.findUnique
+ .mockResolvedValueOnce({ trustScore: 5, name: 'User', email: 'user@test.com' })
+ .mockResolvedValueOnce({ name: 'Admin', email: 'admin@test.com' })
+ const service = await createService()
+
+ await service.applyManualAdjustment({
+ userId: 'user-1',
+ adjustment: -100,
+ reason: 'test',
+ adminUserId: 'admin-1',
+ })
+
+ // 5 + (-100) = -95, not clamped to 0
+ expect(prismaCtx.user.update).toHaveBeenCalledWith({
+ where: { id: 'user-1' },
+ data: { trustScore: -95, lastActiveAt: expect.any(Date) },
+ })
+ })
+
+ it('logs the raw adjustment weight (no clamping adjustment)', async () => {
+ prismaCtx.user.findUnique
+ .mockResolvedValueOnce({ trustScore: 10, name: 'User', email: 'user@test.com' })
+ .mockResolvedValueOnce({ name: 'Admin', email: 'admin@test.com' })
+ const service = await createService()
+
+ await service.applyManualAdjustment({
+ userId: 'user-1',
+ adjustment: -50,
+ reason: 'Vote nullification',
+ adminUserId: 'admin-1',
+ })
+
+ expect(prismaCtx.trustActionLog.create).toHaveBeenCalledWith({
+ data: {
+ userId: 'user-1',
+ action: TrustAction.ADMIN_ADJUSTMENT_NEGATIVE,
+ weight: -50,
+ metadata: {
+ reason: 'Vote nullification',
+ adminUserId: 'admin-1',
+ adminName: 'Admin',
+ adjustment: -50,
+ },
+ },
+ })
+ })
+})
+
+describe('TrustService.reverseLogAction', () => {
+ let prismaCtx: MockPrismaCtx
+
+ beforeEach(async () => {
+ vi.clearAllMocks()
+ prismaCtx = createMockPrismaCtx()
+ })
+
+ async function createService() {
+ const { TrustService } = await import('./service')
+ return new TrustService(prismaCtx as never)
+ }
+
+ it('negates positive action weight (LISTING_RECEIVED_UPVOTE: +2 β -2)', async () => {
+ prismaCtx.user.findUnique.mockResolvedValue({ trustScore: 10 })
+ const service = await createService()
+
+ await service.reverseLogAction({
+ userId: 'author-1',
+ originalAction: TrustAction.LISTING_RECEIVED_UPVOTE,
+ metadata: { listingId: 'listing-1' },
+ })
+
+ expect(prismaCtx.user.update).toHaveBeenCalledWith({
+ where: { id: 'author-1' },
+ data: { trustScore: 8, lastActiveAt: expect.any(Date) },
+ })
+ expect(prismaCtx.trustActionLog.create).toHaveBeenCalledWith({
+ data: {
+ userId: 'author-1',
+ action: TrustAction.VOTE_CHANGE_REVERSAL,
+ weight: -2,
+ metadata: {
+ listingId: 'listing-1',
+ originalAction: TrustAction.LISTING_RECEIVED_UPVOTE,
+ reversed: true,
+ },
+ },
+ })
+ })
+
+ it('negates negative action weight (COMMENT_RECEIVED_DOWNVOTE: -1 β +1)', async () => {
+ prismaCtx.user.findUnique.mockResolvedValue({ trustScore: 5 })
+ const service = await createService()
+
+ await service.reverseLogAction({
+ userId: 'author-1',
+ originalAction: TrustAction.COMMENT_RECEIVED_DOWNVOTE,
+ metadata: { commentId: 'comment-1' },
+ })
+
+ expect(prismaCtx.user.update).toHaveBeenCalledWith({
+ where: { id: 'author-1' },
+ data: { trustScore: 6, lastActiveAt: expect.any(Date) },
+ })
+ expect(prismaCtx.trustActionLog.create).toHaveBeenCalledWith({
+ data: {
+ userId: 'author-1',
+ action: TrustAction.VOTE_CHANGE_REVERSAL,
+ weight: 1,
+ metadata: {
+ commentId: 'comment-1',
+ originalAction: TrustAction.COMMENT_RECEIVED_DOWNVOTE,
+ reversed: true,
+ },
+ },
+ })
+ })
+
+ it('allows negative trust score on reversal (no clamping)', async () => {
+ prismaCtx.user.findUnique.mockResolvedValue({ trustScore: 1 })
+ const service = await createService()
+
+ await service.reverseLogAction({
+ userId: 'author-1',
+ originalAction: TrustAction.LISTING_RECEIVED_UPVOTE, // weight +2, reversal = -2
+ metadata: {},
+ })
+
+ // 1 + (-2) = -1, allowed
+ expect(prismaCtx.user.update).toHaveBeenCalledWith({
+ where: { id: 'author-1' },
+ data: { trustScore: -1, lastActiveAt: expect.any(Date) },
+ })
+ })
+
+ it('no-ops when original action has weight 0', async () => {
+ const service = await createService()
+
+ await service.reverseLogAction({
+ userId: 'user-1',
+ originalAction: TrustAction.ADMIN_ADJUSTMENT_POSITIVE, // weight 0
+ metadata: {},
+ })
+
+ expect(prismaCtx.user.update).not.toHaveBeenCalled()
+ expect(prismaCtx.trustActionLog.create).not.toHaveBeenCalled()
+ })
+
+ it('throws on invalid action', async () => {
+ const service = await createService()
+
+ await expect(
+ service.reverseLogAction({
+ userId: 'user-1',
+ originalAction: 'NONEXISTENT_ACTION' as TrustAction,
+ metadata: {},
+ }),
+ ).rejects.toThrow('Invalid trust action')
+ })
+
+ it('wraps in $transaction when prisma has it', async () => {
+ const mockTransaction = vi.fn(async (fn: (ctx: MockPrismaCtx) => Promise) =>
+ fn(prismaCtx),
+ )
+ const prismaWithTx = { ...prismaCtx, $transaction: mockTransaction }
+ prismaCtx.user.findUnique.mockResolvedValue({ trustScore: 10 })
+
+ const { TrustService } = await import('./service')
+ const service = new TrustService(prismaWithTx as never)
+
+ await service.reverseLogAction({
+ userId: 'user-1',
+ originalAction: TrustAction.LISTING_RECEIVED_UPVOTE,
+ metadata: {},
+ })
+
+ expect(mockTransaction).toHaveBeenCalledTimes(1)
+ })
+
+ it('calls execute directly when prisma is a transaction client (no $transaction)', async () => {
+ prismaCtx.user.findUnique.mockResolvedValue({ trustScore: 10 })
+ const service = await createService()
+
+ await service.reverseLogAction({
+ userId: 'user-1',
+ originalAction: TrustAction.LISTING_RECEIVED_UPVOTE,
+ metadata: {},
+ })
+
+ expect(prismaCtx.user.update).toHaveBeenCalled()
+ })
+
+ it('emits trustLevelChanged when crossing a level boundary', async () => {
+ const analytics = (await import('@/lib/analytics')).default
+
+ prismaCtx.user.findUnique.mockResolvedValue({ trustScore: 101 })
+ const service = await createService()
+
+ await service.reverseLogAction({
+ userId: 'user-1',
+ originalAction: TrustAction.LISTING_RECEIVED_UPVOTE, // -2
+ metadata: {},
+ })
+
+ // 101 + (-2) = 99, drops from Contributor (>=100) back to Newcomer
+ expect(analytics.trust.trustLevelChanged).toHaveBeenCalledWith({
+ userId: 'user-1',
+ oldLevel: 'Contributor',
+ newLevel: 'Newcomer',
+ score: 99,
+ })
+ })
+})
diff --git a/src/lib/trust/service.ts b/src/lib/trust/service.ts
index 7ebc3880..f399f562 100644
--- a/src/lib/trust/service.ts
+++ b/src/lib/trust/service.ts
@@ -1,5 +1,6 @@
import { z } from 'zod'
import analytics from '@/lib/analytics'
+import { ResourceError } from '@/lib/errors'
import { prisma } from '@/server/db'
import { validateData } from '@/server/utils/validation'
import { TrustAction, type Prisma, type PrismaClient } from '@orm'
@@ -13,6 +14,7 @@ function resolveTrustLevelName(level: ReturnType | null):
interface TrustActionContext {
listingId?: string
+ pcListingId?: string
targetUserId?: string
voteType?: 'up' | 'down'
adminUserId?: string
@@ -112,7 +114,7 @@ export async function reverseTrustAction(params: {
})
const currentTrustLevel = currentUser ? getTrustLevel(currentUser.trustScore) : null
- const newTrustScore = Math.max(0, (currentUser?.trustScore || 0) + reversalWeight)
+ const newTrustScore = (currentUser?.trustScore ?? 0) + reversalWeight
const newTrustLevel = getTrustLevel(newTrustScore)
await tx.user.update({
@@ -228,44 +230,44 @@ export async function applyMonthlyActiveBonus(): Promise<{
return { processedUsers, errors }
}
-export async function applyManualTrustAdjustment(params: {
- userId: string
- adjustment: number
- reason: string
- adminUserId: string
-}): Promise {
- const { userId, adjustment, reason, adminUserId } = params
+type PrismaTransaction = Omit<
+ PrismaClient,
+ '$connect' | '$disconnect' | '$on' | '$transaction' | '$use' | '$extends'
+>
- if (adjustment === 0) {
- // TODO: Custom Error, or not to Custom Error, that is the question
- throw new Error('Adjustment cannot be zero')
- }
+/**
+ * TrustService class for managing trust actions.
+ * Accepts either a PrismaClient (creates its own transactions) or a
+ * TransactionClient (participates in an outer transaction).
+ */
+export class TrustService {
+ constructor(private readonly prisma: PrismaClient | PrismaTransaction) {}
- try {
- // Get user's current trust score before applying adjustment
- const currentUser = await prisma.user.findUnique({
- where: { id: userId },
- select: { trustScore: true, name: true, email: true },
- })
+ async logAction(params: {
+ userId: string
+ action: TrustAction
+ targetUserId?: string
+ metadata?: Record
+ }): Promise {
+ const { userId, action, metadata } = params
- if (!currentUser) {
- // TODO: use proper error later, now i just want to sleep
- throw new Error('User not found')
+ if (!TRUST_ACTIONS[action]) {
+ throw new Error(`Invalid trust action: ${action}`)
}
- const currentTrustLevel = getTrustLevel(currentUser.trustScore)
- const newTrustScore = Math.max(0, currentUser.trustScore + adjustment) // Prevent negative trust scores
- const newTrustLevel = getTrustLevel(newTrustScore)
- const actualAdjustment = newTrustScore - currentUser.trustScore
+ const weight = TRUST_ACTIONS[action].weight
- // Determine action type based on adjustment direction
- const action =
- adjustment > 0 ? TrustAction.ADMIN_ADJUSTMENT_POSITIVE : TrustAction.ADMIN_ADJUSTMENT_NEGATIVE
+ const executeInTransaction = async (prismaCtx: PrismaTransaction) => {
+ const currentUser = await prismaCtx.user.findUnique({
+ where: { id: userId },
+ select: { trustScore: true },
+ })
+
+ const currentTrustLevel = currentUser ? getTrustLevel(currentUser.trustScore) : null
+ const newTrustScore = (currentUser?.trustScore || 0) + weight
+ const newTrustLevel = getTrustLevel(newTrustScore)
- // Use a transaction to ensure atomicity
- await prisma.$transaction(async (tx) => {
- // Update user's trust score
- await tx.user.update({
+ await prismaCtx.user.update({
where: { id: userId },
data: {
trustScore: newTrustScore,
@@ -273,77 +275,58 @@ export async function applyManualTrustAdjustment(params: {
},
})
- // Create audit log entry with admin context
- await tx.trustActionLog.create({
+ await prismaCtx.trustActionLog.create({
data: {
userId,
action,
- weight: actualAdjustment,
- metadata: {
- reason,
- adminUserId,
- adminName: await tx.user
- .findUnique({
- where: { id: adminUserId },
- select: { name: true, email: true },
- })
- .then((admin) => admin?.name || admin?.email || 'Unknown Admin'),
- originalAdjustment: adjustment,
- actualAdjustment,
- },
+ weight,
+ metadata: metadata as Prisma.InputJsonValue,
},
})
- })
-
- analytics.trust.trustScoreChanged({
- userId,
- oldScore: currentUser.trustScore,
- newScore: newTrustScore,
- action,
- weight: actualAdjustment,
- })
- // Track trust level achievement if level changed
- if ((currentTrustLevel.name ?? null) !== (newTrustLevel.name ?? null)) {
- analytics.trust.trustLevelChanged({
+ analytics.trust.trustScoreChanged({
userId,
- oldLevel: resolveTrustLevelName(currentTrustLevel),
- newLevel: resolveTrustLevelName(newTrustLevel),
- score: newTrustScore,
+ oldScore: currentUser?.trustScore || 0,
+ newScore: newTrustScore,
+ action,
+ weight,
})
- }
- } catch (error) {
- console.error('Failed to apply manual trust adjustment:', error)
- throw new Error('Failed to update trust score')
- }
-}
-type PrismaTransaction = Omit<
- PrismaClient,
- '$connect' | '$disconnect' | '$on' | '$transaction' | '$use' | '$extends'
->
+ const previousLevel = currentTrustLevel?.name ?? null
+ const nextLevel = newTrustLevel.name ?? null
-/**
- * TrustService class for managing trust actions
- */
-export class TrustService {
- constructor(private readonly prisma: PrismaClient | PrismaTransaction) {}
+ if (previousLevel !== nextLevel) {
+ analytics.trust.trustLevelChanged({
+ userId,
+ oldLevel: resolveTrustLevelName(currentTrustLevel),
+ newLevel: resolveTrustLevelName(newTrustLevel),
+ score: newTrustScore,
+ })
+ }
+ }
- async logAction(params: {
+ if ('$transaction' in this.prisma) {
+ await this.prisma.$transaction(executeInTransaction)
+ } else {
+ await executeInTransaction(this.prisma)
+ }
+ }
+
+ async reverseLogAction(params: {
userId: string
- action: TrustAction
+ originalAction: TrustAction
targetUserId?: string
metadata?: Record
}): Promise {
- const { userId, action, metadata } = params
+ const { userId, originalAction, metadata = {} } = params
- if (!TRUST_ACTIONS[action]) {
- throw new Error(`Invalid trust action: ${action}`)
+ if (!TRUST_ACTIONS[originalAction]) {
+ throw new Error(`Invalid trust action: ${originalAction}`)
}
- const weight = TRUST_ACTIONS[action].weight
+ const reversalWeight = -TRUST_ACTIONS[originalAction].weight
+ if (reversalWeight === 0) return
- // Handle both regular prisma client and transaction context
const executeInTransaction = async (prismaCtx: PrismaTransaction) => {
const currentUser = await prismaCtx.user.findUnique({
where: { id: userId },
@@ -351,7 +334,7 @@ export class TrustService {
})
const currentTrustLevel = currentUser ? getTrustLevel(currentUser.trustScore) : null
- const newTrustScore = (currentUser?.trustScore || 0) + weight
+ const newTrustScore = (currentUser?.trustScore ?? 0) + reversalWeight
const newTrustLevel = getTrustLevel(newTrustScore)
await prismaCtx.user.update({
@@ -365,19 +348,18 @@ export class TrustService {
await prismaCtx.trustActionLog.create({
data: {
userId,
- action,
- weight,
- metadata: metadata as Prisma.InputJsonValue,
+ action: TrustAction.VOTE_CHANGE_REVERSAL,
+ weight: reversalWeight,
+ metadata: { ...metadata, originalAction, reversed: true } as Prisma.InputJsonValue,
},
})
- // Track analytics
analytics.trust.trustScoreChanged({
userId,
- oldScore: currentUser?.trustScore || 0,
+ oldScore: currentUser?.trustScore ?? 0,
newScore: newTrustScore,
- action,
- weight,
+ action: TrustAction.VOTE_CHANGE_REVERSAL,
+ weight: reversalWeight,
})
const previousLevel = currentTrustLevel?.name ?? null
@@ -393,11 +375,199 @@ export class TrustService {
}
}
- // If already in a transaction, use it; otherwise create a new one
if ('$transaction' in this.prisma) {
await this.prisma.$transaction(executeInTransaction)
} else {
await executeInTransaction(this.prisma)
}
}
+
+ async applyManualAdjustment(params: {
+ userId: string
+ adjustment: number
+ reason: string
+ adminUserId: string
+ }): Promise {
+ const { userId, adjustment, reason, adminUserId } = params
+
+ if (adjustment === 0) {
+ throw ResourceError.trust.adjustmentCannotBeZero()
+ }
+
+ const executeAdjustment = async (prismaCtx: PrismaTransaction) => {
+ const currentUser = await prismaCtx.user.findUnique({
+ where: { id: userId },
+ select: { trustScore: true, name: true, email: true },
+ })
+
+ if (!currentUser) {
+ throw ResourceError.user.notFound()
+ }
+
+ const currentTrustLevel = getTrustLevel(currentUser.trustScore)
+ const newTrustScore = currentUser.trustScore + adjustment
+ const newTrustLevel = getTrustLevel(newTrustScore)
+
+ const action =
+ adjustment > 0
+ ? TrustAction.ADMIN_ADJUSTMENT_POSITIVE
+ : TrustAction.ADMIN_ADJUSTMENT_NEGATIVE
+
+ await prismaCtx.user.update({
+ where: { id: userId },
+ data: {
+ trustScore: newTrustScore,
+ lastActiveAt: new Date(),
+ },
+ })
+
+ const admin = await prismaCtx.user.findUnique({
+ where: { id: adminUserId },
+ select: { name: true, email: true },
+ })
+
+ await prismaCtx.trustActionLog.create({
+ data: {
+ userId,
+ action,
+ weight: adjustment,
+ metadata: {
+ reason,
+ adminUserId,
+ adminName: admin?.name || admin?.email || 'Unknown Admin',
+ adjustment,
+ },
+ },
+ })
+
+ analytics.trust.trustScoreChanged({
+ userId,
+ oldScore: currentUser.trustScore,
+ newScore: newTrustScore,
+ action,
+ weight: adjustment,
+ })
+
+ if ((currentTrustLevel.name ?? null) !== (newTrustLevel.name ?? null)) {
+ analytics.trust.trustLevelChanged({
+ userId,
+ oldLevel: resolveTrustLevelName(currentTrustLevel),
+ newLevel: resolveTrustLevelName(newTrustLevel),
+ score: newTrustScore,
+ })
+ }
+ }
+
+ if ('$transaction' in this.prisma) {
+ await this.prisma.$transaction(executeAdjustment)
+ } else {
+ await executeAdjustment(this.prisma)
+ }
+ }
+
+ async applyBulkManualAdjustments(params: {
+ adjustments: Map
+ reason: string
+ adminUserId: string
+ }): Promise {
+ const { adjustments, reason, adminUserId } = params
+
+ const nonZeroEntries = [...adjustments.entries()].filter(([, adj]) => adj !== 0)
+ if (nonZeroEntries.length === 0) return 0
+
+ const userIds = nonZeroEntries.map(([id]) => id)
+
+ const executeBulk = async (prismaCtx: PrismaTransaction) => {
+ const users = await prismaCtx.user.findMany({
+ where: { id: { in: userIds } },
+ select: { id: true, trustScore: true },
+ })
+
+ const userMap = new Map(users.map((u) => [u.id, u.trustScore]))
+ const foundEntries = nonZeroEntries.filter(([id]) => userMap.has(id))
+ if (foundEntries.length === 0) return 0
+
+ const admin = await prismaCtx.user.findUnique({
+ where: { id: adminUserId },
+ select: { name: true, email: true },
+ })
+ const adminName = admin?.name || admin?.email || 'Unknown Admin'
+
+ const logEntries: {
+ userId: string
+ action: TrustAction
+ weight: number
+ metadata: Prisma.InputJsonValue
+ }[] = []
+
+ const levelChanges: {
+ userId: string
+ oldLevel: string
+ newLevel: string
+ score: number
+ }[] = []
+
+ for (const [userId, adjustment] of foundEntries) {
+ const currentScore = userMap.get(userId) ?? 0
+ const newTrustScore = currentScore + adjustment
+
+ const action =
+ adjustment > 0
+ ? TrustAction.ADMIN_ADJUSTMENT_POSITIVE
+ : TrustAction.ADMIN_ADJUSTMENT_NEGATIVE
+
+ await prismaCtx.user.update({
+ where: { id: userId },
+ data: {
+ trustScore: newTrustScore,
+ lastActiveAt: new Date(),
+ },
+ })
+
+ logEntries.push({
+ userId,
+ action,
+ weight: adjustment,
+ metadata: {
+ reason,
+ adminUserId,
+ adminName,
+ adjustment,
+ },
+ })
+
+ analytics.trust.trustScoreChanged({
+ userId,
+ oldScore: currentScore,
+ newScore: newTrustScore,
+ action,
+ weight: adjustment,
+ })
+
+ const currentTrustLevel = getTrustLevel(currentScore)
+ const newTrustLevel = getTrustLevel(newTrustScore)
+ if ((currentTrustLevel.name ?? null) !== (newTrustLevel.name ?? null)) {
+ levelChanges.push({
+ userId,
+ oldLevel: resolveTrustLevelName(currentTrustLevel),
+ newLevel: resolveTrustLevelName(newTrustLevel),
+ score: newTrustScore,
+ })
+ }
+ }
+
+ await prismaCtx.trustActionLog.createMany({ data: logEntries })
+
+ for (const change of levelChanges) {
+ analytics.trust.trustLevelChanged(change)
+ }
+
+ return foundEntries.length
+ }
+
+ if ('$transaction' in this.prisma) {
+ return this.prisma.$transaction(executeBulk)
+ }
+ return executeBulk(this.prisma)
+ }
}
diff --git a/src/schemas/listing.ts b/src/schemas/listing.ts
index 9ff49aca..03173cd6 100644
--- a/src/schemas/listing.ts
+++ b/src/schemas/listing.ts
@@ -1,6 +1,6 @@
import { z } from 'zod'
import { PAGINATION } from '@/data/constants'
-import { JsonValueSchema } from '@/schemas/common'
+import { JsonValueSchema, ListingType } from '@/schemas/common'
import { ApprovalStatus } from '@orm'
export const CreateListingSchema = z.object({
@@ -50,6 +50,11 @@ export const GetListingsSchema = z.object({
export const GetListingByIdSchema = z.object({ id: z.string().uuid() })
+export const GetListingModeratorInfoSchema = z.object({
+ id: z.string().uuid(),
+ type: ListingType,
+})
+
export const GetPendingListingsSchema = z
.object({
search: z.string().nullable().optional(),
@@ -77,6 +82,20 @@ export const GetProcessedSchema = z.object({
limit: z.number().default(10),
filterStatus: z.nativeEnum(ApprovalStatus).nullable().optional(),
search: z.string().nullable().optional(),
+ sortField: z
+ .enum([
+ 'processedAt',
+ 'createdAt',
+ 'status',
+ 'game.title',
+ 'game.system.name',
+ 'device',
+ 'emulator.name',
+ 'author.name',
+ ])
+ .nullable()
+ .optional(),
+ sortDirection: z.enum(['asc', 'desc']).nullable().optional(),
})
export const OverrideApprovalStatusSchema = z.object({
diff --git a/src/schemas/pcListing.ts b/src/schemas/pcListing.ts
index 963a7290..441102ff 100644
--- a/src/schemas/pcListing.ts
+++ b/src/schemas/pcListing.ts
@@ -78,6 +78,16 @@ export const GetPendingPcListingsSchema = z
export const DeletePcListingSchema = z.object({ id: z.string().uuid() })
+// TODO: Wire up a PC admin processed-listings page + router procedure for
+// parity with handheld (`admin.getProcessed` + `src/app/admin/processed-listings/`).
+// When doing so, extend this schema with `sortField` / `sortDirection` using the
+// same shape as `GetProcessedSchema` in `./listing.ts`, and ideally share as much
+// of the admin router logic as possible (the two codebases are drifting β fixes
+// applied to handheld listings often miss their PC counterpart). Candidates for
+// shared code: `buildProcessedOrderBy`, the search `where` builder, the
+// approval-flow branches. See also: `src/server/api/utils/listingHelpers.ts`
+// (handheld) vs `pcListingHelpers.ts` (PC) β these helpers already exist and
+// should be the basis for a shared abstraction.
export const GetProcessedPcSchema = z.object({
page: z.number().default(1),
limit: z.number().default(10),
@@ -225,6 +235,7 @@ export const GetPcPresetsSchema = z.object({
export const VotePcListingSchema = z.object({
pcListingId: z.string().uuid(),
value: z.boolean(), // true = upvote, false = downvote
+ recaptchaToken: z.string().nullable().optional(),
})
export const GetPcListingUserVoteSchema = z.object({
diff --git a/src/server/api/routers/listings.ts b/src/server/api/routers/listings.ts
index 9d75104c..77922ec7 100644
--- a/src/server/api/routers/listings.ts
+++ b/src/server/api/routers/listings.ts
@@ -18,6 +18,7 @@ export const listingsRouter = createTRPCRouter({
unpinComment: commentsRouter.unpinComment,
// Admin operations
+ moderatorInfo: adminRouter.moderatorInfo,
getPending: adminRouter.getPending,
approveListing: adminRouter.approve,
rejectListing: adminRouter.reject,
diff --git a/src/server/api/routers/listings/admin.ts b/src/server/api/routers/listings/admin.ts
index 79afa327..ac06029d 100644
--- a/src/server/api/routers/listings/admin.ts
+++ b/src/server/api/routers/listings/admin.ts
@@ -1,10 +1,12 @@
import { ResourceError, AppError } from '@/lib/errors'
import { applyTrustAction } from '@/lib/trust/service'
+import { ListingType } from '@/schemas/common'
import {
ApproveListingSchema,
RejectListingSchema,
GetProcessedSchema,
GetPendingListingsSchema,
+ GetListingModeratorInfoSchema,
OverrideApprovalStatusSchema,
DeleteListingSchema,
BulkApproveListingsSchema,
@@ -24,6 +26,7 @@ import {
viewStatisticsProcedure,
protectedProcedure,
} from '@/server/api/trpc'
+import { buildProcessedOrderBy } from '@/server/api/utils/listingHelpers'
import {
invalidateListing,
invalidateListPages,
@@ -31,6 +34,8 @@ import {
revalidateByTag,
} from '@/server/cache/invalidation'
import { notificationEventEmitter, NOTIFICATION_EVENTS } from '@/server/notifications/eventEmitter'
+import { ListingsRepository } from '@/server/repositories/listings.repository'
+import { PcListingsRepository } from '@/server/repositories/pc-listings.repository'
import { computeAuthorRiskProfiles } from '@/server/services/author-risk.service'
import { listingStatsCache } from '@/server/utils/cache/instances'
import { generateEmulatorConfig } from '@/server/utils/emulator-config/emulator-detector'
@@ -43,6 +48,15 @@ const LISTING_STATS_CACHE_KEY = 'listing-stats'
const mode = Prisma.QueryMode.insensitive
export const adminRouter = createTRPCRouter({
+ moderatorInfo: moderatorProcedure
+ .input(GetListingModeratorInfoSchema)
+ .query(async ({ ctx, input }) => {
+ return input.type === ListingType.enum.handheld
+ ? new ListingsRepository(ctx.prisma).getModeratorInfo(input.id)
+ : new PcListingsRepository(ctx.prisma).getModeratorInfo(input.id)
+ }),
+
+ // TODO: abstract to service or repository
getPending: developerProcedure.input(GetPendingListingsSchema).query(async ({ ctx, input }) => {
const { search, page = 1, limit = 20, sortField, sortDirection } = input ?? {}
const skip = (page - 1) * limit
@@ -123,7 +137,6 @@ export const adminRouter = createTRPCRouter({
select: {
id: true,
name: true,
- email: true,
userBans: {
where: {
isActive: true,
@@ -267,7 +280,7 @@ export const adminRouter = createTRPCRouter({
},
})
- ResourceError.listing.cannotApproveBannedUser(banReason)
+ return ResourceError.listing.cannotApproveBannedUser(banReason)
}
// Update listing status
@@ -450,7 +463,7 @@ export const adminRouter = createTRPCRouter({
}),
getProcessed: superAdminProcedure.input(GetProcessedSchema).query(async ({ ctx, input }) => {
- const { page, limit, filterStatus, search } = input
+ const { page, limit, filterStatus, search, sortField, sortDirection } = input
const skip = (page - 1) * limit
const baseWhere: Prisma.ListingWhereInput = {
@@ -474,19 +487,19 @@ export const adminRouter = createTRPCRouter({
...searchWhere,
}
+ const orderBy = buildProcessedOrderBy(sortField, sortDirection)
+
const listings = await ctx.prisma.listing.findMany({
where: whereClause,
include: {
game: { include: { system: true } },
device: { include: { brand: true } },
emulator: true,
- author: { select: { id: true, name: true, email: true } },
+ author: { select: { id: true, name: true } },
performance: true,
- processedByUser: { select: { id: true, name: true, email: true } }, // Admin who processed
- },
- orderBy: {
- processedAt: 'desc', // Show most recently processed first
+ processedByUser: { select: { id: true, name: true } },
},
+ orderBy,
skip,
take: limit,
})
@@ -497,7 +510,7 @@ export const adminRouter = createTRPCRouter({
return {
listings,
- pagination: paginate({ total: totalListings, page, limit: limit }),
+ pagination: paginate({ total: totalListings, page, limit }),
}
}),
@@ -666,22 +679,9 @@ export const adminRouter = createTRPCRouter({
})
}
- // Apply trust actions for approved listings only
- for (const listing of validListings) {
- if (listing.authorId) {
- await applyTrustAction({
- userId: listing.authorId,
- action: TrustAction.LISTING_APPROVED,
- context: {
- listingId: listing.id,
- adminUserId,
- reason: 'bulk_listing_approved',
- },
- })
- }
- }
-
- // Return data needed for post-transaction operations
+ // Return data needed for post-transaction operations.
+ // Trust actions intentionally run AFTER the transaction commits β applyTrustAction
+ // opens its own internal transaction, so nesting would deadlock or surprise.
return {
validListings,
bannedUserListings,
@@ -689,6 +689,24 @@ export const adminRouter = createTRPCRouter({
}
})
+ // Apply trust actions in parallel for approved listings (post-commit, distinct authors).
+ const approvedListingsWithAuthor = transactionResult.validListings.filter(
+ (l): l is typeof l & { authorId: string } => l.authorId !== null,
+ )
+ await Promise.all(
+ approvedListingsWithAuthor.map((listing) =>
+ applyTrustAction({
+ userId: listing.authorId,
+ action: TrustAction.LISTING_APPROVED,
+ context: {
+ listingId: listing.id,
+ adminUserId,
+ reason: 'bulk_listing_approved',
+ },
+ }),
+ ),
+ )
+
// Emit notification events AFTER transaction completes successfully
try {
const { validListings } = transactionResult
@@ -827,28 +845,33 @@ export const adminRouter = createTRPCRouter({
},
})
- // Apply trust actions for all rejected listings
- for (const listing of listingsToReject) {
- if (listing.authorId) {
- await applyTrustAction({
- userId: listing.authorId,
- action: TrustAction.LISTING_REJECTED,
- context: {
- listingId: listing.id,
- adminUserId,
- reason: notes || 'bulk_listing_rejected',
- },
- })
- }
- }
-
- // Return data needed for post-transaction operations
+ // Return data needed for post-transaction operations.
+ // Trust actions intentionally run AFTER the transaction commits β applyTrustAction
+ // opens its own internal transaction, so nesting would deadlock or surprise.
return {
listingsToReject,
notFoundOrNotPendingIds,
}
})
+ // Apply trust actions in parallel for rejected listings (post-commit, distinct authors).
+ const rejectedListingsWithAuthor = transactionResult.listingsToReject.filter(
+ (l): l is typeof l & { authorId: string } => l.authorId !== null,
+ )
+ await Promise.all(
+ rejectedListingsWithAuthor.map((listing) =>
+ applyTrustAction({
+ userId: listing.authorId,
+ action: TrustAction.LISTING_REJECTED,
+ context: {
+ listingId: listing.id,
+ adminUserId,
+ reason: notes || 'bulk_listing_rejected',
+ },
+ }),
+ ),
+ )
+
// NOTE: Emit notification events AFTER transaction completes successfully
try {
const { listingsToReject } = transactionResult
@@ -990,7 +1013,7 @@ export const adminRouter = createTRPCRouter({
game: { include: { system: true } },
device: { include: { brand: true, soc: true } },
emulator: true,
- author: { select: { id: true, name: true, email: true } },
+ author: { select: { id: true, name: true } },
performance: true,
},
orderBy,
@@ -1027,7 +1050,7 @@ export const adminRouter = createTRPCRouter({
},
},
},
- author: { select: { id: true, name: true, email: true } },
+ author: { select: { id: true, name: true } },
performance: true,
customFieldValues: {
include: { customFieldDefinition: { include: { category: true } } },
@@ -1055,7 +1078,7 @@ export const adminRouter = createTRPCRouter({
if (!existingListing) return ResourceError.listing.notFound()
- return ctx.prisma.$transaction(async (tx) => {
+ return await ctx.prisma.$transaction(async (tx) => {
// Update the main listing fields
const updatedListing = await tx.listing.update({
where: { id },
diff --git a/src/server/api/routers/listings/comments.test.ts b/src/server/api/routers/listings/comments.test.ts
new file mode 100644
index 00000000..d25b3afe
--- /dev/null
+++ b/src/server/api/routers/listings/comments.test.ts
@@ -0,0 +1,199 @@
+import { describe, expect, it, beforeEach, vi } from 'vitest'
+import { Role } from '@orm'
+
+vi.unmock('@/server/api/trpc')
+vi.unmock('@/server/api/root')
+
+const mockHandleCommentVoteTrustEffects = vi.fn().mockResolvedValue(undefined)
+const mockEmitNotificationEvent = vi.fn()
+
+vi.mock('@/server/utils/vote-trust-effects', () => ({
+ handleCommentVoteTrustEffects: (...args: unknown[]) => mockHandleCommentVoteTrustEffects(...args),
+ handleListingVoteTrustEffects: vi.fn(),
+ handleVoteTrustEffects: vi.fn(),
+}))
+
+vi.mock('@/server/notifications/eventEmitter', () => ({
+ notificationEventEmitter: { emitNotificationEvent: mockEmitNotificationEvent },
+ NOTIFICATION_EVENTS: {
+ COMMENT_VOTED: 'COMMENT_VOTED',
+ LISTING_COMMENTED: 'LISTING_COMMENTED',
+ COMMENT_REPLIED: 'COMMENT_REPLIED',
+ },
+}))
+
+vi.mock('@/server/utils/query-builders', () => ({
+ isUserBanned: vi.fn().mockResolvedValue(false),
+}))
+
+vi.mock('@/lib/analytics', () => ({
+ default: {
+ engagement: { commentVote: vi.fn() },
+ },
+}))
+
+const { commentsRouter } = await import('./comments')
+
+const USER_ID = '00000000-0000-4000-a000-000000000001'
+const AUTHOR_ID = '00000000-0000-4000-a000-000000000002'
+const LISTING_ID = '00000000-0000-4000-a000-000000000010'
+const COMMENT_ID = '00000000-0000-4000-a000-000000000020'
+
+function createMockPrisma() {
+ const mockTx = {
+ commentVote: {
+ create: vi.fn().mockResolvedValue({ userId: USER_ID, commentId: COMMENT_ID, value: true }),
+ delete: vi.fn().mockResolvedValue(undefined),
+ update: vi.fn().mockResolvedValue({ userId: USER_ID, commentId: COMMENT_ID, value: false }),
+ findUnique: vi.fn().mockResolvedValue(null),
+ },
+ comment: {
+ findUnique: vi.fn(),
+ update: vi.fn().mockResolvedValue({ id: COMMENT_ID, score: 1 }),
+ },
+ }
+
+ return {
+ ...mockTx,
+ $transaction: vi.fn(async (cb: (tx: typeof mockTx) => Promise) => cb(mockTx)),
+ }
+}
+
+type MockPrisma = ReturnType
+
+function createCaller(overrides: { userId?: string; role?: Role; prisma?: MockPrisma } = {}) {
+ const prisma = overrides.prisma ?? createMockPrisma()
+ return {
+ caller: commentsRouter.createCaller({
+ session: {
+ user: {
+ id: overrides.userId ?? 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('handheld comments router β voteComment', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ function setupCommentMocks(prisma: MockPrisma) {
+ prisma.comment.findUnique.mockResolvedValue({
+ id: COMMENT_ID,
+ userId: AUTHOR_ID,
+ listingId: LISTING_ID,
+ })
+ prisma.commentVote.findUnique.mockResolvedValue(null)
+ prisma.comment.update.mockResolvedValue({ id: COMMENT_ID, score: 1 })
+ }
+
+ it('dispatches trust effects with listingType handheld on new upvote', async () => {
+ const { caller, prisma } = createCaller()
+ setupCommentMocks(prisma)
+
+ await caller.vote({ commentId: COMMENT_ID, value: true })
+
+ expect(mockHandleCommentVoteTrustEffects).toHaveBeenCalledWith(
+ expect.objectContaining({
+ trustAction: 'upvote',
+ commentAuthorId: AUTHOR_ID,
+ voterId: USER_ID,
+ commentId: COMMENT_ID,
+ parentEntityId: LISTING_ID,
+ listingType: 'handheld',
+ }),
+ )
+ })
+
+ it('dispatches trust effects with change action on vote flip', async () => {
+ const { caller, prisma } = createCaller()
+ setupCommentMocks(prisma)
+ prisma.commentVote.findUnique.mockResolvedValue({
+ userId: USER_ID,
+ commentId: COMMENT_ID,
+ value: true,
+ })
+
+ await caller.vote({ commentId: COMMENT_ID, value: false })
+
+ expect(mockHandleCommentVoteTrustEffects).toHaveBeenCalledWith(
+ expect.objectContaining({
+ trustAction: 'change',
+ previousValue: true,
+ newValue: false,
+ listingType: 'handheld',
+ }),
+ )
+ })
+
+ it('dispatches trust effects with remove action on toggle-off', async () => {
+ const { caller, prisma } = createCaller()
+ setupCommentMocks(prisma)
+ prisma.commentVote.findUnique.mockResolvedValue({
+ userId: USER_ID,
+ commentId: COMMENT_ID,
+ value: true,
+ })
+
+ await caller.vote({ commentId: COMMENT_ID, value: true })
+
+ expect(mockHandleCommentVoteTrustEffects).toHaveBeenCalledWith(
+ expect.objectContaining({
+ trustAction: 'remove',
+ listingType: 'handheld',
+ }),
+ )
+ })
+
+ it('emits COMMENT_VOTED on new upvote', async () => {
+ const { caller, prisma } = createCaller()
+ setupCommentMocks(prisma)
+
+ await caller.vote({ commentId: COMMENT_ID, value: true })
+
+ expect(mockEmitNotificationEvent).toHaveBeenCalledWith(
+ expect.objectContaining({
+ eventType: 'COMMENT_VOTED',
+ entityId: COMMENT_ID,
+ payload: expect.objectContaining({ voteValue: true, commentId: COMMENT_ID }),
+ }),
+ )
+ })
+
+ it('does NOT emit COMMENT_VOTED on toggle-off', async () => {
+ const { caller, prisma } = createCaller()
+ setupCommentMocks(prisma)
+ prisma.commentVote.findUnique.mockResolvedValue({
+ userId: USER_ID,
+ commentId: COMMENT_ID,
+ value: true,
+ })
+
+ await caller.vote({ commentId: COMMENT_ID, value: true })
+
+ expect(mockEmitNotificationEvent).not.toHaveBeenCalled()
+ })
+
+ it('fetches existingVote inside the $transaction callback', async () => {
+ const { caller, prisma } = createCaller()
+ setupCommentMocks(prisma)
+
+ await caller.vote({ commentId: COMMENT_ID, value: true })
+
+ const txCall = vi.mocked(prisma.$transaction).mock.invocationCallOrder[0]
+ const findUniqueCall = prisma.commentVote.findUnique.mock.invocationCallOrder[0]
+ expect(txCall).toBeDefined()
+ expect(findUniqueCall).toBeDefined()
+ expect(findUniqueCall).toBeGreaterThan(txCall as number)
+ })
+})
diff --git a/src/server/api/routers/listings/comments.ts b/src/server/api/routers/listings/comments.ts
index 878f89d7..884d2890 100644
--- a/src/server/api/routers/listings/comments.ts
+++ b/src/server/api/routers/listings/comments.ts
@@ -2,7 +2,6 @@ import analytics from '@/lib/analytics'
import { RECAPTCHA_CONFIG } from '@/lib/captcha/config'
import { getClientIP, verifyRecaptcha } from '@/lib/captcha/verify'
import { AppError, ResourceError } from '@/lib/errors'
-import { TrustService } from '@/lib/trust/service'
import {
CreateCommentSchema,
EditCommentSchema,
@@ -20,15 +19,20 @@ import { notificationEventEmitter, NOTIFICATION_EVENTS } from '@/server/notifica
import { CommentsRepository } from '@/server/repositories/comments.repository'
import { logAudit } from '@/server/services/audit.service'
import { isUserBanned } from '@/server/utils/query-builders'
+import { handleCommentVoteTrustEffects } from '@/server/utils/vote-trust-effects'
import { roleIncludesRole } from '@/utils/permission-system'
import { canDeleteComment, canEditComment } from '@/utils/permissions'
-import { AuditAction, AuditEntityType, Role, TrustAction } from '@orm'
+import { AuditAction, AuditEntityType, Role } from '@orm'
export const commentsRouter = createTRPCRouter({
create: protectedProcedure.input(CreateCommentSchema).mutation(async ({ ctx, input }) => {
const { listingId, content, parentId, recaptchaToken } = input
const userId = ctx.session.user.id
+ // TODO: Add spam detection via `checkSpamContent` from
+ // `@/server/utils/spam-check` (currently only applied in mobile routes).
+ // Block: UX/product sign-off needed since existing web users would start
+ // seeing spam-block errors. Mirror mobile: `{ userId, content, entityType: 'comment' }`.
// Verify CAPTCHA if token is provided
if (recaptchaToken) {
const clientIP = ctx.headers ? getClientIP(ctx.headers) : undefined
@@ -322,13 +326,15 @@ export const commentsRouter = createTRPCRouter({
if (!comment) return ResourceError.comment.notFound()
- // Check if user already voted on this comment
- const existingVote = await ctx.prisma.commentVote.findUnique({
- where: { userId_commentId: { userId, commentId } },
- })
+ // Fetch `existingVote` inside the transaction: two concurrent votes from
+ // the same user could both read null and both attempt to insert,
+ // producing a Prisma P2002 on the second. Keeping the read and write
+ // under the same isolation avoids the race.
+ return await ctx.prisma.$transaction(async (tx) => {
+ const existingVote = await tx.commentVote.findUnique({
+ where: { userId_commentId: { userId, commentId } },
+ })
- // Start a transaction to handle both the vote and score update
- return ctx.prisma.$transaction(async (tx) => {
let voteResult
let scoreChange: number
let trustActionNeeded: 'upvote' | 'downvote' | 'change' | 'remove' | null = null
@@ -370,126 +376,26 @@ export const commentsRouter = createTRPCRouter({
data: { score: { increment: scoreChange } },
})
- // Award trust points to the comment author (not the voter)
- if (comment && comment.userId !== userId) {
- // Don't award points for self-votes
- const trustService = new TrustService(tx)
-
- if (trustActionNeeded === 'upvote') {
- // New upvote: +2 points to comment author
- await trustService.logAction({
- userId: comment.userId,
- action: TrustAction.COMMENT_RECEIVED_UPVOTE,
- targetUserId: userId,
- metadata: {
- commentId,
- voterId: userId,
- listingId: comment.listingId,
- },
- })
- } else if (trustActionNeeded === 'downvote') {
- // New downvote: -1 point to comment author
- await trustService.logAction({
- userId: comment.userId,
- action: TrustAction.COMMENT_RECEIVED_DOWNVOTE,
- targetUserId: userId,
- metadata: {
- commentId,
- voterId: userId,
- listingId: comment.listingId,
- },
- })
- } else if (trustActionNeeded === 'change') {
- // Vote changed: reverse previous and apply new
- if (value) {
- // Changed from downvote to upvote: +1 (reverse) +2 (new) = +3 net
- await trustService.logAction({
- userId: comment.userId,
- action: TrustAction.COMMENT_RECEIVED_DOWNVOTE,
- targetUserId: userId,
- metadata: {
- commentId,
- voterId: userId,
- listingId: comment.listingId,
- reversed: true,
- },
- })
- await trustService.logAction({
- userId: comment.userId,
- action: TrustAction.COMMENT_RECEIVED_UPVOTE,
- targetUserId: userId,
- metadata: {
- commentId,
- voterId: userId,
- listingId: comment.listingId,
- },
- })
- } else {
- // Changed from upvote to downvote: -2 (reverse) -1 (new) = -3 net
- await trustService.logAction({
- userId: comment.userId,
- action: TrustAction.COMMENT_RECEIVED_UPVOTE,
- targetUserId: userId,
- metadata: {
- commentId,
- voterId: userId,
- listingId: comment.listingId,
- reversed: true,
- },
- })
- await trustService.logAction({
- userId: comment.userId,
- action: TrustAction.COMMENT_RECEIVED_DOWNVOTE,
- targetUserId: userId,
- metadata: {
- commentId,
- voterId: userId,
- listingId: comment.listingId,
- },
- })
- }
- } else if (trustActionNeeded === 'remove' && existingVote) {
- // Vote removed: reverse the previous vote's effect
- const action = existingVote.value
- ? TrustAction.COMMENT_RECEIVED_UPVOTE
- : TrustAction.COMMENT_RECEIVED_DOWNVOTE
- await trustService.logAction({
- userId: comment.userId,
- action,
- targetUserId: userId,
- metadata: {
- commentId,
- voterId: userId,
- listingId: comment.listingId,
- reversed: true,
- },
- })
- }
-
- // Check if comment has reached the helpful threshold (5+ score)
- // Award bonus only when crossing the threshold for the first time
- const HELPFUL_THRESHOLD = 5
- const previousScore = updatedComment.score - scoreChange
-
- if (previousScore < HELPFUL_THRESHOLD && updatedComment.score >= HELPFUL_THRESHOLD) {
- // Comment just crossed the threshold - award bonus
- await trustService.logAction({
- userId: comment.userId,
- action: TrustAction.HELPFUL_COMMENT,
- metadata: {
- commentId,
- listingId: comment.listingId,
- score: updatedComment.score,
- threshold: HELPFUL_THRESHOLD,
- },
- })
- }
+ if (trustActionNeeded) {
+ await handleCommentVoteTrustEffects({
+ tx,
+ trustAction: trustActionNeeded,
+ newValue: value,
+ previousValue: existingVote?.value ?? null,
+ commentAuthorId: comment.userId,
+ voterId: userId,
+ commentId,
+ parentEntityId: comment.listingId,
+ listingType: 'handheld',
+ updatedScore: updatedComment.score,
+ scoreChange,
+ })
}
- // Emit notification event
- if (comment) {
+ // Notify comment author on new votes / direction changes; skip on toggle-off.
+ if (comment && trustActionNeeded !== null && trustActionNeeded !== 'remove') {
notificationEventEmitter.emitNotificationEvent({
- eventType: value ? NOTIFICATION_EVENTS.COMMENT_VOTED : NOTIFICATION_EVENTS.COMMENT_VOTED,
+ eventType: NOTIFICATION_EVENTS.COMMENT_VOTED,
entityType: 'comment',
entityId: comment.id,
triggeredBy: ctx.session.user.id,
diff --git a/src/server/api/routers/listings/core.test.ts b/src/server/api/routers/listings/core.test.ts
new file mode 100644
index 00000000..3a8d5479
--- /dev/null
+++ b/src/server/api/routers/listings/core.test.ts
@@ -0,0 +1,233 @@
+import { describe, expect, it, beforeEach, vi } from 'vitest'
+import { Role } from '@orm'
+
+vi.unmock('@/server/api/trpc')
+vi.unmock('@/server/api/root')
+
+const mockApplyTrustAction = vi.fn().mockResolvedValue(undefined)
+const mockHandleListingVoteTrustEffects = vi.fn().mockResolvedValue(undefined)
+const mockHandleCommentVoteTrustEffects = vi.fn().mockResolvedValue(undefined)
+const mockLogAction = vi.fn().mockResolvedValue(undefined)
+const mockReverseLogAction = vi.fn().mockResolvedValue(undefined)
+
+vi.mock('@/lib/trust/service', () => ({
+ applyTrustAction: (...args: unknown[]) => mockApplyTrustAction(...args),
+ TrustService: vi.fn().mockImplementation(() => ({
+ logAction: mockLogAction,
+ reverseLogAction: mockReverseLogAction,
+ })),
+}))
+
+vi.mock('@/server/utils/vote-trust-effects', () => ({
+ handleListingVoteTrustEffects: (...args: unknown[]) => mockHandleListingVoteTrustEffects(...args),
+ handleCommentVoteTrustEffects: (...args: unknown[]) => mockHandleCommentVoteTrustEffects(...args),
+}))
+
+vi.mock('@/server/utils/vote-counts', () => ({
+ updateListingVoteCounts: vi.fn().mockResolvedValue(undefined),
+}))
+
+vi.mock('@/server/notifications/eventEmitter', () => ({
+ notificationEventEmitter: { emitNotificationEvent: vi.fn() },
+ NOTIFICATION_EVENTS: {
+ LISTING_VOTED: 'LISTING_VOTED',
+ COMMENT_VOTED: 'COMMENT_VOTED',
+ LISTING_APPROVED: 'LISTING_APPROVED',
+ LISTING_REJECTED: 'LISTING_REJECTED',
+ COMMENT_REPLIED: 'COMMENT_REPLIED',
+ LISTING_COMMENTED: 'LISTING_COMMENTED',
+ },
+}))
+
+vi.mock('@/server/utils/query-builders', () => ({
+ isUserBanned: vi.fn().mockResolvedValue(false),
+ listingWhereClause: vi.fn(() => ({})),
+}))
+
+vi.mock('@/lib/captcha/verify', () => ({
+ verifyRecaptcha: vi.fn().mockResolvedValue({ success: true }),
+ getClientIP: vi.fn().mockReturnValue('127.0.0.1'),
+}))
+
+vi.mock('@/lib/analytics', () => ({
+ default: {
+ engagement: { vote: vi.fn(), commentVote: vi.fn() },
+ userJourney: { firstTimeAction: vi.fn() },
+ listing: { created: vi.fn() },
+ },
+}))
+
+vi.mock('@/server/services/audit.service', () => ({
+ logAudit: vi.fn().mockResolvedValue(undefined),
+ buildDiff: vi.fn().mockReturnValue({}),
+}))
+
+vi.mock('@/server/utils/security-validation', () => ({
+ validatePagination: vi.fn((page, limit, max) => ({ page: page ?? 1, limit: limit ?? max ?? 20 })),
+ sanitizeInput: vi.fn((s: string) => s),
+}))
+
+vi.mock('@/server/repositories/listings.repository', () => ({
+ ListingsRepository: vi.fn().mockImplementation(() => ({
+ getExistingVote: vi.fn().mockResolvedValue(null),
+ })),
+}))
+
+const { coreRouter } = await import('./core')
+
+const USER_ID = '00000000-0000-4000-a000-000000000001'
+const AUTHOR_ID = '00000000-0000-4000-a000-000000000002'
+const LISTING_ID = '00000000-0000-4000-a000-000000000010'
+
+function createMockPrisma() {
+ const mockTx = {
+ vote: {
+ create: vi
+ .fn()
+ .mockResolvedValue({ id: 'vote-1', value: true, userId: USER_ID, listingId: LISTING_ID }),
+ delete: vi.fn().mockResolvedValue(undefined),
+ update: vi
+ .fn()
+ .mockResolvedValue({ id: 'vote-1', value: false, userId: USER_ID, listingId: LISTING_ID }),
+ findUnique: vi.fn().mockResolvedValue(null),
+ count: vi.fn().mockResolvedValue(1),
+ },
+ listing: {
+ findUnique: vi.fn(),
+ update: vi.fn().mockResolvedValue({}),
+ },
+ user: {
+ findUnique: vi.fn().mockResolvedValue({ id: USER_ID }),
+ },
+ }
+
+ return {
+ ...mockTx,
+ $transaction: vi.fn(async (cb: (tx: typeof mockTx) => Promise) => cb(mockTx)),
+ }
+}
+
+type MockPrisma = ReturnType
+
+function createCaller(overrides: { userId?: string; role?: Role; prisma?: MockPrisma } = {}) {
+ const prisma = overrides.prisma ?? createMockPrisma()
+ return {
+ caller: coreRouter.createCaller({
+ session: {
+ user: {
+ id: overrides.userId ?? 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('handheld listings trust integration (core.ts)', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ describe('vote', () => {
+ it('calls handleListingVoteTrustEffects with tx on new upvote', async () => {
+ const { caller, prisma } = createCaller()
+ prisma.listing.findUnique.mockResolvedValue({ id: LISTING_ID, authorId: AUTHOR_ID })
+
+ await caller.vote({ listingId: LISTING_ID, value: true })
+
+ expect(mockHandleListingVoteTrustEffects).toHaveBeenCalledWith(
+ expect.objectContaining({
+ action: 'created',
+ currentValue: true,
+ previousValue: null,
+ userId: USER_ID,
+ listingId: LISTING_ID,
+ listingType: 'handheld',
+ authorId: AUTHOR_ID,
+ tx: expect.any(Object),
+ }),
+ )
+ })
+
+ it('calls handleListingVoteTrustEffects with listingType handheld (not pc)', async () => {
+ const { caller, prisma } = createCaller()
+ prisma.listing.findUnique.mockResolvedValue({ id: LISTING_ID, authorId: AUTHOR_ID })
+
+ await caller.vote({ listingId: LISTING_ID, value: true })
+
+ const firstCall = mockHandleListingVoteTrustEffects.mock.calls[0][0] as {
+ listingType: string
+ }
+ expect(firstCall.listingType).toBe('handheld')
+ })
+
+ it('calls handleListingVoteTrustEffects with action deleted on vote toggle', async () => {
+ const { caller, prisma } = createCaller()
+ prisma.listing.findUnique.mockResolvedValue({ id: LISTING_ID, authorId: AUTHOR_ID })
+ prisma.vote.findUnique.mockResolvedValue({
+ id: 'vote-1',
+ value: true,
+ userId: USER_ID,
+ listingId: LISTING_ID,
+ })
+
+ await caller.vote({ listingId: LISTING_ID, value: true })
+
+ expect(mockHandleListingVoteTrustEffects).toHaveBeenCalledWith(
+ expect.objectContaining({
+ action: 'deleted',
+ previousValue: true,
+ listingType: 'handheld',
+ tx: expect.any(Object),
+ }),
+ )
+ })
+
+ it('calls handleListingVoteTrustEffects with action updated on vote change', async () => {
+ const { caller, prisma } = createCaller()
+ prisma.listing.findUnique.mockResolvedValue({ id: LISTING_ID, authorId: AUTHOR_ID })
+ prisma.vote.findUnique.mockResolvedValue({
+ id: 'vote-1',
+ value: true,
+ userId: USER_ID,
+ listingId: LISTING_ID,
+ })
+
+ await caller.vote({ listingId: LISTING_ID, value: false }) // change to downvote
+
+ expect(mockHandleListingVoteTrustEffects).toHaveBeenCalledWith(
+ expect.objectContaining({
+ action: 'updated',
+ currentValue: false,
+ previousValue: true,
+ listingType: 'handheld',
+ tx: expect.any(Object),
+ }),
+ )
+ })
+
+ it('passes the transaction client to handleListingVoteTrustEffects', async () => {
+ const { caller, prisma } = createCaller()
+ prisma.listing.findUnique.mockResolvedValue({ id: LISTING_ID, authorId: AUTHOR_ID })
+
+ await caller.vote({ listingId: LISTING_ID, value: true })
+
+ expect(prisma.$transaction).toHaveBeenCalledTimes(1)
+
+ const trustCall = mockHandleListingVoteTrustEffects.mock.calls[0][0] as {
+ tx: unknown
+ }
+ // The tx passed should be the same mockTx object passed to the transaction callback
+ expect(trustCall.tx).toBeDefined()
+ expect(trustCall.tx).toHaveProperty('vote')
+ expect(trustCall.tx).toHaveProperty('listing')
+ })
+ })
+})
diff --git a/src/server/api/routers/listings/core.ts b/src/server/api/routers/listings/core.ts
index 4200d69e..5bc6d54f 100644
--- a/src/server/api/routers/listings/core.ts
+++ b/src/server/api/routers/listings/core.ts
@@ -32,7 +32,7 @@ import { isUserBanned } from '@/server/utils/query-builders'
import { sanitizeInput, validatePagination } from '@/server/utils/security-validation'
import { withSavepoint } from '@/server/utils/transactions'
import { updateListingVoteCounts } from '@/server/utils/vote-counts'
-import { handleVoteTrustEffects } from '@/server/utils/vote-trust-effects'
+import { handleListingVoteTrustEffects } from '@/server/utils/vote-trust-effects'
import { roleIncludesRole } from '@/utils/permission-system'
import { ms } from '@/utils/time'
import { ApprovalStatus, Prisma, Role, TrustAction } from '@orm'
@@ -145,6 +145,10 @@ export const coreRouter = createTRPCRouter({
const { recaptchaToken, ...payload } = input
const authorId = ctx.session.user.id
+ // TODO: Add spam detection via `checkSpamContent` from
+ // `@/server/utils/spam-check` (currently only applied in mobile routes).
+ // Block: UX/product sign-off needed since existing web users would start
+ // seeing spam-block errors. Mirror mobile: `{ userId, content: notes, entityType: 'listing' }`.
// Verify CAPTCHA if token is provided
if (recaptchaToken) {
const clientIP = ctx.headers ? getClientIP(ctx.headers) : undefined
@@ -269,6 +273,12 @@ export const coreRouter = createTRPCRouter({
where: { userId_listingId: { userId, listingId: input.listingId } },
})
+ let result: {
+ vote: { id: string; value: boolean; userId: string; listingId: string } | null
+ action: 'created' | 'updated' | 'deleted'
+ previousValue: boolean | null
+ }
+
if (!existingVote) {
// Create new vote
const newVote = await tx.vote.create({
@@ -277,39 +287,44 @@ export const coreRouter = createTRPCRouter({
await updateListingVoteCounts(tx, input.listingId, 'create', input.value)
- return { vote: newVote, action: 'created' as const, previousValue: null }
- }
-
- // If value is the same, remove the vote (toggle)
- if (existingVote.value === input.value) {
+ result = { vote: newVote, action: 'created', previousValue: null }
+ } else if (existingVote.value === input.value) {
await tx.vote.delete({
where: { userId_listingId: { userId, listingId: input.listingId } },
})
await updateListingVoteCounts(tx, input.listingId, 'delete', undefined, existingVote.value)
- return { vote: null, action: 'deleted' as const, previousValue: existingVote.value }
- }
+ result = { vote: null, action: 'deleted', previousValue: existingVote.value }
+ } else {
+ const updatedVote = await tx.vote.update({
+ where: { userId_listingId: { userId, listingId: input.listingId } },
+ data: { value: input.value },
+ })
- // Update vote to new value
- const updatedVote = await tx.vote.update({
- where: { userId_listingId: { userId, listingId: input.listingId } },
- data: { value: input.value },
- })
+ await updateListingVoteCounts(
+ tx,
+ input.listingId,
+ 'update',
+ input.value,
+ existingVote.value,
+ )
- await updateListingVoteCounts(tx, input.listingId, 'update', input.value, existingVote.value)
+ result = { vote: updatedVote, action: 'updated', previousValue: existingVote.value }
+ }
- return { vote: updatedVote, action: 'updated' as const, previousValue: existingVote.value }
- })
+ await handleListingVoteTrustEffects({
+ tx,
+ action: result.action,
+ currentValue: input.value,
+ previousValue: result.previousValue,
+ userId,
+ listingId: input.listingId,
+ listingType: 'handheld',
+ authorId: listing.authorId,
+ })
- // Handle trust effects for all vote actions
- await handleVoteTrustEffects({
- action: voteResult.action,
- currentValue: input.value,
- previousValue: voteResult.previousValue,
- userId,
- listingId: input.listingId,
- authorId: listing.authorId,
+ return result
})
// Handle post-transaction side effects for created/updated votes (TODO: consider abstracting)
@@ -342,7 +357,7 @@ export const coreRouter = createTRPCRouter({
}
}
- return voteResult.vote || { id: '', value: false, listingId: input.listingId, userId }
+ return voteResult.vote
}),
performanceScales: publicProcedure.query(async ({ ctx }) =>
diff --git a/src/server/api/routers/mobile/listings.test.ts b/src/server/api/routers/mobile/listings.test.ts
new file mode 100644
index 00000000..d3d24cf5
--- /dev/null
+++ b/src/server/api/routers/mobile/listings.test.ts
@@ -0,0 +1,370 @@
+import { describe, expect, it, beforeEach, vi } from 'vitest'
+import { Role } from '@orm'
+
+vi.unmock('@/server/api/trpc')
+vi.unmock('@/server/api/root')
+
+// Stub the api-keys schema so it doesn't pull unmocked enums from @orm
+vi.mock('@/schemas/apiAccess', () => ({
+ GetApiKeyUsageSchema: {},
+ CreateApiKeySchema: {},
+ UpdateApiKeySchema: {},
+ RevokeApiKeySchema: {},
+ ListApiKeysSchema: {},
+}))
+
+vi.mock('@/server/repositories/api-keys.repository', () => ({
+ ApiKeysRepository: vi.fn().mockImplementation(() => ({})),
+}))
+
+const mockApplyTrustAction = vi.fn().mockResolvedValue(undefined)
+const mockHandleListingVoteTrustEffects = vi.fn().mockResolvedValue(undefined)
+const mockHandleCommentVoteTrustEffects = vi.fn().mockResolvedValue(undefined)
+const mockLogAction = vi.fn().mockResolvedValue(undefined)
+
+vi.mock('@/lib/trust/service', () => ({
+ applyTrustAction: (...args: unknown[]) => mockApplyTrustAction(...args),
+ TrustService: vi.fn().mockImplementation(() => ({ logAction: mockLogAction })),
+}))
+
+vi.mock('@/server/utils/vote-trust-effects', () => ({
+ handleListingVoteTrustEffects: (...args: unknown[]) => mockHandleListingVoteTrustEffects(...args),
+ handleCommentVoteTrustEffects: (...args: unknown[]) => mockHandleCommentVoteTrustEffects(...args),
+}))
+
+vi.mock('@/server/utils/vote-counts', () => ({
+ updateListingVoteCounts: vi.fn().mockResolvedValue(undefined),
+}))
+
+vi.mock('@/lib/analytics', () => ({
+ default: {
+ engagement: { vote: vi.fn(), commentVote: vi.fn() },
+ },
+}))
+
+vi.mock('@/server/repositories/comments.repository', () => ({
+ CommentsRepository: vi.fn().mockImplementation(() => ({
+ listByListing: vi.fn().mockResolvedValue([]),
+ })),
+}))
+
+const mockEmitNotificationEvent = vi.fn()
+vi.mock('@/server/notifications/eventEmitter', () => ({
+ notificationEventEmitter: { emitNotificationEvent: mockEmitNotificationEvent },
+ NOTIFICATION_EVENTS: {
+ LISTING_VOTED: 'LISTING_VOTED',
+ COMMENT_VOTED: 'COMMENT_VOTED',
+ },
+}))
+
+const { mobileListingsRouter } = await import('./listings')
+
+const USER_ID = '00000000-0000-4000-a000-000000000001'
+const AUTHOR_ID = '00000000-0000-4000-a000-000000000002'
+const LISTING_ID = '00000000-0000-4000-a000-000000000010'
+const COMMENT_ID = '00000000-0000-4000-a000-000000000020'
+
+function createMockPrisma() {
+ const mockTx = {
+ vote: {
+ create: vi
+ .fn()
+ .mockResolvedValue({ id: 'vote-1', value: true, userId: USER_ID, listingId: LISTING_ID }),
+ delete: vi.fn().mockResolvedValue(undefined),
+ update: vi
+ .fn()
+ .mockResolvedValue({ id: 'vote-1', value: false, userId: USER_ID, listingId: LISTING_ID }),
+ findUnique: vi.fn().mockResolvedValue(null),
+ },
+ commentVote: {
+ create: vi.fn().mockResolvedValue({ userId: USER_ID, commentId: COMMENT_ID, value: true }),
+ delete: vi.fn().mockResolvedValue(undefined),
+ update: vi.fn().mockResolvedValue({ userId: USER_ID, commentId: COMMENT_ID, value: false }),
+ findUnique: vi.fn().mockResolvedValue(null),
+ },
+ comment: {
+ findUnique: vi.fn(),
+ update: vi.fn().mockResolvedValue({ id: COMMENT_ID, score: 1 }),
+ },
+ listing: {
+ findUnique: vi.fn(),
+ },
+ }
+ return {
+ ...mockTx,
+ $transaction: vi.fn(async (cb: (tx: typeof mockTx) => Promise) => cb(mockTx)),
+ }
+}
+
+type MockPrisma = ReturnType
+
+function createCaller(overrides: { userId?: string; role?: Role; prisma?: MockPrisma } = {}) {
+ const prisma = overrides.prisma ?? createMockPrisma()
+ return {
+ caller: mobileListingsRouter.createCaller({
+ session: {
+ user: {
+ id: overrides.userId ?? USER_ID,
+ email: 'test@test.com',
+ name: 'Test User',
+ role: overrides.role ?? Role.USER,
+ permissions: [],
+ showNsfw: false,
+ },
+ },
+ prisma: prisma as never,
+ headers: new Headers(),
+ apiKey: null,
+ }),
+ prisma,
+ }
+}
+
+describe('mobile listings trust integration', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ describe('vote', () => {
+ it('calls handleListingVoteTrustEffects with tx on new upvote', async () => {
+ const { caller, prisma } = createCaller()
+ prisma.listing.findUnique.mockResolvedValue({ authorId: AUTHOR_ID })
+
+ await caller.vote({ listingId: LISTING_ID, value: true })
+
+ expect(mockHandleListingVoteTrustEffects).toHaveBeenCalledWith(
+ expect.objectContaining({
+ action: 'created',
+ currentValue: true,
+ previousValue: null,
+ userId: USER_ID,
+ listingId: LISTING_ID,
+ listingType: 'handheld',
+ authorId: AUTHOR_ID,
+ tx: expect.any(Object),
+ }),
+ )
+ })
+
+ it('calls handleListingVoteTrustEffects with action deleted on vote toggle', async () => {
+ const { caller, prisma } = createCaller()
+ prisma.listing.findUnique.mockResolvedValue({ authorId: AUTHOR_ID })
+ prisma.vote.findUnique.mockResolvedValue({
+ id: 'vote-1',
+ value: true,
+ userId: USER_ID,
+ listingId: LISTING_ID,
+ })
+
+ await caller.vote({ listingId: LISTING_ID, value: true })
+
+ expect(mockHandleListingVoteTrustEffects).toHaveBeenCalledWith(
+ expect.objectContaining({
+ action: 'deleted',
+ previousValue: true,
+ listingType: 'handheld',
+ tx: expect.any(Object),
+ }),
+ )
+ })
+
+ it('calls handleListingVoteTrustEffects with action updated on vote change', async () => {
+ const { caller, prisma } = createCaller()
+ prisma.listing.findUnique.mockResolvedValue({ authorId: AUTHOR_ID })
+ prisma.vote.findUnique.mockResolvedValue({
+ id: 'vote-1',
+ value: true,
+ userId: USER_ID,
+ listingId: LISTING_ID,
+ })
+
+ await caller.vote({ listingId: LISTING_ID, value: false })
+
+ expect(mockHandleListingVoteTrustEffects).toHaveBeenCalledWith(
+ expect.objectContaining({
+ action: 'updated',
+ currentValue: false,
+ previousValue: true,
+ tx: expect.any(Object),
+ }),
+ )
+ })
+
+ it('runs trust call inside the transaction', async () => {
+ const { caller, prisma } = createCaller()
+ prisma.listing.findUnique.mockResolvedValue({ authorId: AUTHOR_ID })
+
+ await caller.vote({ listingId: LISTING_ID, value: true })
+
+ expect(prisma.$transaction).toHaveBeenCalledTimes(1)
+ const trustCall = mockHandleListingVoteTrustEffects.mock.calls[0][0] as { tx: unknown }
+ expect(trustCall.tx).toHaveProperty('vote')
+ })
+
+ it('emits LISTING_VOTED on a new vote', async () => {
+ const { caller, prisma } = createCaller()
+ prisma.listing.findUnique.mockResolvedValue({ authorId: AUTHOR_ID })
+
+ await caller.vote({ listingId: LISTING_ID, value: true })
+
+ expect(mockEmitNotificationEvent).toHaveBeenCalledWith(
+ expect.objectContaining({
+ eventType: 'LISTING_VOTED',
+ entityType: 'listing',
+ entityId: LISTING_ID,
+ payload: expect.objectContaining({ voteValue: true, listingId: LISTING_ID }),
+ }),
+ )
+ })
+
+ it('does NOT emit LISTING_VOTED on toggle-off', async () => {
+ const { caller, prisma } = createCaller()
+ prisma.listing.findUnique.mockResolvedValue({ authorId: AUTHOR_ID })
+ prisma.vote.findUnique.mockResolvedValue({
+ id: 'vote-1',
+ value: true,
+ userId: USER_ID,
+ listingId: LISTING_ID,
+ })
+
+ await caller.vote({ listingId: LISTING_ID, value: true })
+
+ expect(mockEmitNotificationEvent).not.toHaveBeenCalled()
+ })
+ })
+
+ describe('voteComment', () => {
+ it('calls handleCommentVoteTrustEffects with listingType handheld on new upvote', async () => {
+ const { caller, prisma } = createCaller()
+ prisma.comment.findUnique.mockResolvedValue({
+ id: COMMENT_ID,
+ userId: AUTHOR_ID,
+ listingId: LISTING_ID,
+ })
+ prisma.commentVote.findUnique.mockResolvedValue(null)
+
+ await caller.voteComment({ commentId: COMMENT_ID, value: true })
+
+ expect(mockHandleCommentVoteTrustEffects).toHaveBeenCalledWith(
+ expect.objectContaining({
+ trustAction: 'upvote',
+ newValue: true,
+ commentAuthorId: AUTHOR_ID,
+ voterId: USER_ID,
+ commentId: COMMENT_ID,
+ parentEntityId: LISTING_ID,
+ listingType: 'handheld',
+ }),
+ )
+ })
+
+ it('calls handleCommentVoteTrustEffects with change action on vote flip', async () => {
+ const { caller, prisma } = createCaller()
+ prisma.comment.findUnique.mockResolvedValue({
+ id: COMMENT_ID,
+ userId: AUTHOR_ID,
+ listingId: LISTING_ID,
+ })
+ prisma.commentVote.findUnique.mockResolvedValue({
+ userId: USER_ID,
+ commentId: COMMENT_ID,
+ value: true,
+ })
+
+ await caller.voteComment({ commentId: COMMENT_ID, value: false })
+
+ expect(mockHandleCommentVoteTrustEffects).toHaveBeenCalledWith(
+ expect.objectContaining({
+ trustAction: 'change',
+ newValue: false,
+ previousValue: true,
+ listingType: 'handheld',
+ }),
+ )
+ })
+
+ it('calls handleCommentVoteTrustEffects with remove action on vote toggle', async () => {
+ const { caller, prisma } = createCaller()
+ prisma.comment.findUnique.mockResolvedValue({
+ id: COMMENT_ID,
+ userId: AUTHOR_ID,
+ listingId: LISTING_ID,
+ })
+ prisma.commentVote.findUnique.mockResolvedValue({
+ userId: USER_ID,
+ commentId: COMMENT_ID,
+ value: true,
+ })
+
+ await caller.voteComment({ commentId: COMMENT_ID, value: true })
+
+ expect(mockHandleCommentVoteTrustEffects).toHaveBeenCalledWith(
+ expect.objectContaining({
+ trustAction: 'remove',
+ listingType: 'handheld',
+ }),
+ )
+ })
+
+ it('emits COMMENT_VOTED on a new upvote', async () => {
+ const { caller, prisma } = createCaller()
+ prisma.comment.findUnique.mockResolvedValue({
+ id: COMMENT_ID,
+ userId: AUTHOR_ID,
+ listingId: LISTING_ID,
+ })
+ prisma.commentVote.findUnique.mockResolvedValue(null)
+
+ await caller.voteComment({ commentId: COMMENT_ID, value: true })
+
+ expect(mockEmitNotificationEvent).toHaveBeenCalledWith(
+ expect.objectContaining({
+ eventType: 'COMMENT_VOTED',
+ entityType: 'comment',
+ entityId: COMMENT_ID,
+ payload: expect.objectContaining({
+ commentId: COMMENT_ID,
+ listingId: LISTING_ID,
+ voteValue: true,
+ }),
+ }),
+ )
+ })
+
+ it('does NOT emit COMMENT_VOTED on toggle-off (remove)', async () => {
+ const { caller, prisma } = createCaller()
+ prisma.comment.findUnique.mockResolvedValue({
+ id: COMMENT_ID,
+ userId: AUTHOR_ID,
+ listingId: LISTING_ID,
+ })
+ prisma.commentVote.findUnique.mockResolvedValue({
+ userId: USER_ID,
+ commentId: COMMENT_ID,
+ value: true,
+ })
+
+ await caller.voteComment({ commentId: COMMENT_ID, value: true })
+
+ expect(mockEmitNotificationEvent).not.toHaveBeenCalled()
+ })
+
+ it('fetches existingVote inside the $transaction callback', async () => {
+ const { caller, prisma } = createCaller()
+ prisma.comment.findUnique.mockResolvedValue({
+ id: COMMENT_ID,
+ userId: AUTHOR_ID,
+ listingId: LISTING_ID,
+ })
+ prisma.commentVote.findUnique.mockResolvedValue(null)
+
+ await caller.voteComment({ commentId: COMMENT_ID, value: true })
+
+ const txCall = vi.mocked(prisma.$transaction).mock.invocationCallOrder[0]
+ const findUniqueCall = prisma.commentVote.findUnique.mock.invocationCallOrder[0]
+ expect(txCall).toBeDefined()
+ expect(findUniqueCall).toBeDefined()
+ expect(findUniqueCall).toBeGreaterThan(txCall as number)
+ })
+ })
+})
diff --git a/src/server/api/routers/mobile/listings.ts b/src/server/api/routers/mobile/listings.ts
index ffe38d6e..0312b56e 100644
--- a/src/server/api/routers/mobile/listings.ts
+++ b/src/server/api/routers/mobile/listings.ts
@@ -1,6 +1,5 @@
import analytics from '@/lib/analytics'
import { AppError, ResourceError } from '@/lib/errors'
-import { TrustService } from '@/lib/trust/service'
import { CreateListingSchema } from '@/schemas/listing'
import {
CreateCommentSchema,
@@ -26,6 +25,7 @@ import {
mobileProtectedProcedure,
mobilePublicProcedure,
} from '@/server/api/mobileContext'
+import { NOTIFICATION_EVENTS, notificationEventEmitter } from '@/server/notifications/eventEmitter'
import { CommentsRepository } from '@/server/repositories/comments.repository'
import { ListingsRepository } from '@/server/repositories/listings.repository'
import { getDriverVersions } from '@/server/utils/driver-versions'
@@ -34,10 +34,14 @@ import {
detectEmulatorConfigType,
generateEmulatorConfig,
} from '@/server/utils/emulator-config/emulator-detector'
-import { SpamDetectionService } from '@/server/utils/spamDetection'
+import { checkSpamContent } from '@/server/utils/spam-check'
import { updateListingVoteCounts } from '@/server/utils/vote-counts'
+import {
+ handleCommentVoteTrustEffects,
+ handleListingVoteTrustEffects,
+} from '@/server/utils/vote-trust-effects'
import { isModerator } from '@/utils/permissions'
-import { ApprovalStatus, Prisma, TrustAction, type PrismaClient, type Role } from '@orm'
+import { ApprovalStatus, Prisma, type PrismaClient, type Role } from '@orm'
// Helper for getting listings using the repository
async function getListingsHelper(
@@ -144,28 +148,13 @@ export const mobileListingsRouter = createMobileTRPCRouter({
const { ...payload } = input
const repository = new ListingsRepository(ctx.prisma)
- // Spam detection
- const spamDetector = new SpamDetectionService(ctx.prisma)
- const spamResult = await spamDetector.detectSpam({
+ await checkSpamContent({
+ prisma: ctx.prisma,
userId: ctx.session.user.id,
content: payload.notes || '',
entityType: 'listing',
})
- if (spamResult.isSpam) {
- // Track spam detection in analytics
- analytics.contentQuality.spamDetected({
- entityType: 'listing',
- entityId: ctx.session.user.id, // Use userId as entityId since listing not created yet
- confidence: spamResult.confidence,
- method: spamResult.method,
- })
-
- throw AppError.badRequest(
- `Spam detected: ${spamResult.reason || 'Your content appears to be spam. Please review our community guidelines.'}`,
- )
- }
-
return await repository.create({
authorId: ctx.session.user.id,
userRole: ctx.session.user.role,
@@ -239,68 +228,89 @@ export const mobileListingsRouter = createMobileTRPCRouter({
* Vote on a listing
*/
vote: mobileProtectedProcedure.input(VoteListingSchema).mutation(async ({ ctx, input }) => {
- return await ctx.prisma.$transaction(async (tx) => {
- // Check if user already voted
- const existingVote = await tx.vote.findUnique({
- where: {
- userId_listingId: {
- userId: ctx.session.user.id,
- listingId: input.listingId,
- },
- },
- })
+ const userId = ctx.session.user.id
- let vote
+ const listing = await ctx.prisma.listing.findUnique({
+ where: { id: input.listingId },
+ select: { authorId: true },
+ })
- if (existingVote) {
- if (existingVote.value === input.value) {
- // Same vote = toggle off (remove vote)
- await tx.vote.delete({
- where: { id: existingVote.id },
- })
+ if (!listing) return ResourceError.listing.notFound()
- // Update counts for deletion
- await updateListingVoteCounts(
- tx,
- input.listingId,
- 'delete',
- undefined,
- existingVote.value,
- )
+ const voteResult = await ctx.prisma.$transaction(async (tx) => {
+ const existingVote = await tx.vote.findUnique({
+ where: { userId_listingId: { userId, listingId: input.listingId } },
+ })
- return { id: existingVote.id, value: null, removed: true }
- } else {
- // Different vote = update
- vote = await tx.vote.update({
- where: { id: existingVote.id },
- data: { value: input.value },
- })
+ let result: {
+ vote:
+ | { id: string; value: boolean; userId: string; listingId: string }
+ | { id: string; value: null; removed: true }
+ action: 'created' | 'updated' | 'deleted'
+ previousValue: boolean | null
+ }
- // Update counts for change
- await updateListingVoteCounts(
- tx,
- input.listingId,
- 'update',
- input.value,
- existingVote.value,
- )
+ if (!existingVote) {
+ const vote = await tx.vote.create({
+ data: { value: input.value, listingId: input.listingId, userId },
+ })
+ await updateListingVoteCounts(tx, input.listingId, 'create', input.value)
+ result = { vote, action: 'created', previousValue: null }
+ } else if (existingVote.value === input.value) {
+ await tx.vote.delete({ where: { id: existingVote.id } })
+ await updateListingVoteCounts(tx, input.listingId, 'delete', undefined, existingVote.value)
+ result = {
+ vote: { id: existingVote.id, value: null, removed: true },
+ action: 'deleted',
+ previousValue: existingVote.value,
}
} else {
- // New vote
- vote = await tx.vote.create({
- data: {
- value: input.value,
+ const vote = await tx.vote.update({
+ where: { id: existingVote.id },
+ data: { value: input.value },
+ })
+ await updateListingVoteCounts(
+ tx,
+ input.listingId,
+ 'update',
+ input.value,
+ existingVote.value,
+ )
+ result = { vote, action: 'updated', previousValue: existingVote.value }
+ }
+
+ await handleListingVoteTrustEffects({
+ tx,
+ action: result.action,
+ currentValue: input.value,
+ previousValue: result.previousValue,
+ userId,
+ listingId: input.listingId,
+ listingType: 'handheld',
+ authorId: listing.authorId,
+ })
+
+ return result
+ })
+
+ // Notify listing author on new votes / direction changes; skip on toggle-off.
+ if (voteResult.action === 'created' || voteResult.action === 'updated') {
+ if (voteResult.vote && 'id' in voteResult.vote) {
+ notificationEventEmitter.emitNotificationEvent({
+ eventType: NOTIFICATION_EVENTS.LISTING_VOTED,
+ entityType: 'listing',
+ entityId: input.listingId,
+ triggeredBy: userId,
+ payload: {
listingId: input.listingId,
- userId: ctx.session.user.id,
+ voteId: voteResult.vote.id,
+ voteValue: input.value,
},
})
-
- // Update counts for new vote
- await updateListingVoteCounts(tx, input.listingId, 'create', input.value)
}
+ }
- return vote
- })
+ return voteResult.vote
}),
/**
@@ -371,28 +381,13 @@ export const mobileListingsRouter = createMobileTRPCRouter({
.mutation(async ({ ctx, input }) => {
const { listingId, content, parentId } = input
- // Spam detection
- const spamDetector = new SpamDetectionService(ctx.prisma)
- const spamResult = await spamDetector.detectSpam({
+ await checkSpamContent({
+ prisma: ctx.prisma,
userId: ctx.session.user.id,
content,
entityType: 'comment',
})
- if (spamResult.isSpam) {
- // Track spam detection in analytics
- analytics.contentQuality.spamDetected({
- entityType: 'comment',
- entityId: ctx.session.user.id, // Use userId as entityId since comment not created yet
- confidence: spamResult.confidence,
- method: spamResult.method,
- })
-
- throw AppError.badRequest(
- `Spam detected: ${spamResult.reason || 'Your content appears to be spam. Please review our community guidelines.'}`,
- )
- }
-
// If replying to a comment, validate parent existence and ownership
if (parentId) {
const parent = await ctx.prisma.comment.findUnique({
@@ -526,11 +521,15 @@ export const mobileListingsRouter = createMobileTRPCRouter({
const comment = await ctx.prisma.comment.findUnique({ where: { id: input.commentId } })
if (!comment) return ResourceError.comment.notFound()
- const existingVote = await ctx.prisma.commentVote.findUnique({
- where: { userId_commentId: { userId, commentId: input.commentId } },
- })
+ // Fetch `existingVote` inside the transaction: two concurrent votes
+ // from the same user could both read null and both attempt to insert,
+ // producing a Prisma P2002 on the second. Keeping the read and write
+ // under the same isolation avoids the race.
+ return await ctx.prisma.$transaction(async (tx) => {
+ const existingVote = await tx.commentVote.findUnique({
+ where: { userId_commentId: { userId, commentId: input.commentId } },
+ })
- return ctx.prisma.$transaction(async (tx) => {
let voteResult: unknown
let scoreChange: number
let trustActionNeeded: 'upvote' | 'downvote' | 'change' | 'remove' | null
@@ -568,110 +567,37 @@ export const mobileListingsRouter = createMobileTRPCRouter({
data: { score: { increment: scoreChange } },
})
- // Award trust points to the comment author (not the voter)
- if (comment && comment.userId !== userId) {
- const trustService = new TrustService(tx)
-
- if (trustActionNeeded === 'upvote') {
- await trustService.logAction({
- userId: comment.userId,
- action: TrustAction.COMMENT_RECEIVED_UPVOTE,
- targetUserId: userId,
- metadata: {
- commentId: input.commentId,
- voterId: userId,
- listingId: comment.listingId,
- },
- })
- } else if (trustActionNeeded === 'downvote') {
- await trustService.logAction({
- userId: comment.userId,
- action: TrustAction.COMMENT_RECEIVED_DOWNVOTE,
- targetUserId: userId,
- metadata: {
- commentId: input.commentId,
- voterId: userId,
- listingId: comment.listingId,
- },
- })
- } else if (trustActionNeeded === 'change') {
- if (input.value) {
- await trustService.logAction({
- userId: comment.userId,
- action: TrustAction.COMMENT_RECEIVED_DOWNVOTE,
- targetUserId: userId,
- metadata: {
- commentId: input.commentId,
- voterId: userId,
- listingId: comment.listingId,
- reversed: true,
- },
- })
- await trustService.logAction({
- userId: comment.userId,
- action: TrustAction.COMMENT_RECEIVED_UPVOTE,
- targetUserId: userId,
- metadata: {
- commentId: input.commentId,
- voterId: userId,
- listingId: comment.listingId,
- },
- })
- } else {
- await trustService.logAction({
- userId: comment.userId,
- action: TrustAction.COMMENT_RECEIVED_UPVOTE,
- targetUserId: userId,
- metadata: {
- commentId: input.commentId,
- voterId: userId,
- listingId: comment.listingId,
- reversed: true,
- },
- })
- await trustService.logAction({
- userId: comment.userId,
- action: TrustAction.COMMENT_RECEIVED_DOWNVOTE,
- targetUserId: userId,
- metadata: {
- commentId: input.commentId,
- voterId: userId,
- listingId: comment.listingId,
- },
- })
- }
- } else if (trustActionNeeded === 'remove' && existingVote) {
- const action = existingVote.value
- ? TrustAction.COMMENT_RECEIVED_UPVOTE
- : TrustAction.COMMENT_RECEIVED_DOWNVOTE
- await trustService.logAction({
- userId: comment.userId,
- action,
- targetUserId: userId,
- metadata: {
- commentId: input.commentId,
- voterId: userId,
- listingId: comment.listingId,
- reversed: true,
- },
- })
- }
+ if (trustActionNeeded) {
+ await handleCommentVoteTrustEffects({
+ tx,
+ trustAction: trustActionNeeded,
+ newValue: !!input.value,
+ previousValue: existingVote?.value ?? null,
+ commentAuthorId: comment.userId,
+ voterId: userId,
+ commentId: input.commentId,
+ parentEntityId: comment.listingId,
+ listingType: 'handheld',
+ updatedScore: updatedComment.score,
+ scoreChange,
+ })
+ }
- // Helpful threshold bonus
- const HELPFUL_THRESHOLD = 5
- const previousScore = updatedComment.score - scoreChange
- if (previousScore < HELPFUL_THRESHOLD && updatedComment.score >= HELPFUL_THRESHOLD) {
- await trustService.logAction({
- userId: comment.userId,
- action: TrustAction.HELPFUL_COMMENT,
- metadata: {
- commentId: input.commentId,
- listingId: comment.listingId,
- score: updatedComment.score,
- threshold: HELPFUL_THRESHOLD,
- },
- })
- }
+ // Notify comment author on new votes / direction changes; skip on toggle-off.
+ // When trustActionNeeded is upvote/downvote/change, input.value is guaranteed non-null
+ // (null input.value always maps to 'remove' in the branching above).
+ if (trustActionNeeded !== null && trustActionNeeded !== 'remove' && input.value !== null) {
+ notificationEventEmitter.emitNotificationEvent({
+ eventType: NOTIFICATION_EVENTS.COMMENT_VOTED,
+ entityType: 'comment',
+ entityId: comment.id,
+ triggeredBy: userId,
+ payload: {
+ listingId: comment.listingId,
+ commentId: comment.id,
+ voteValue: input.value,
+ },
+ })
}
// Analytics
diff --git a/src/server/api/routers/mobile/pcListings.ts b/src/server/api/routers/mobile/pcListings.ts
index c549222c..afa8496a 100644
--- a/src/server/api/routers/mobile/pcListings.ts
+++ b/src/server/api/routers/mobile/pcListings.ts
@@ -1,4 +1,5 @@
import { ResourceError } from '@/lib/errors'
+import { applyTrustAction } from '@/lib/trust/service'
import {
GetCpusSchema,
GetGpusSchema,
@@ -21,7 +22,7 @@ import { PcListingsRepository } from '@/server/repositories/pc-listings.reposito
import { listingStatsCache } from '@/server/utils/cache'
import { paginate } from '@/server/utils/pagination'
import { isModerator } from '@/utils/permissions'
-import { Prisma, ApprovalStatus } from '@orm'
+import { Prisma, ApprovalStatus, TrustAction } from '@orm'
export const mobilePcListingsRouter = createMobileTRPCRouter({
/**
@@ -166,6 +167,12 @@ export const mobilePcListingsRouter = createMobileTRPCRouter({
: null) as { customFieldDefinitionId: string; value: unknown }[] | null,
})
+ await applyTrustAction({
+ userId: ctx.session.user.id,
+ action: TrustAction.LISTING_CREATED,
+ context: { pcListingId: created.id },
+ })
+
// Invalidate stats cache
listingStatsCache.delete('pc-listing-stats')
diff --git a/src/server/api/routers/pcListings.test.ts b/src/server/api/routers/pcListings.test.ts
new file mode 100644
index 00000000..6aeb4af9
--- /dev/null
+++ b/src/server/api/routers/pcListings.test.ts
@@ -0,0 +1,627 @@
+import { describe, expect, it, beforeEach, vi } from 'vitest'
+import { ApprovalStatus, Role, TrustAction } from '@orm'
+
+vi.unmock('@/server/api/trpc')
+vi.unmock('@/server/api/root')
+
+const mockApplyTrustAction = vi.fn().mockResolvedValue(undefined)
+const mockHandleListingVoteTrustEffects = vi.fn().mockResolvedValue(undefined)
+const mockHandleCommentVoteTrustEffects = vi.fn().mockResolvedValue(undefined)
+const mockLogAction = vi.fn().mockResolvedValue(undefined)
+
+vi.mock('@/lib/trust/service', () => ({
+ applyTrustAction: (...args: unknown[]) => mockApplyTrustAction(...args),
+ TrustService: vi.fn().mockImplementation(() => ({ logAction: mockLogAction })),
+}))
+
+vi.mock('@/server/utils/vote-trust-effects', () => ({
+ handleListingVoteTrustEffects: (...args: unknown[]) => mockHandleListingVoteTrustEffects(...args),
+ handleCommentVoteTrustEffects: (...args: unknown[]) => mockHandleCommentVoteTrustEffects(...args),
+ handleVoteTrustEffects: vi.fn(),
+}))
+
+vi.mock('@/server/utils/vote-counts', () => ({
+ updatePcListingVoteCounts: vi.fn().mockResolvedValue(undefined),
+}))
+
+const mockEmitNotificationEvent = vi.fn()
+vi.mock('@/server/notifications/eventEmitter', () => ({
+ notificationEventEmitter: { emitNotificationEvent: mockEmitNotificationEvent },
+ NOTIFICATION_EVENTS: {
+ LISTING_VOTED: 'LISTING_VOTED',
+ COMMENT_VOTED: 'COMMENT_VOTED',
+ PC_LISTING_APPROVED: 'PC_LISTING_APPROVED',
+ PC_LISTING_REJECTED: 'PC_LISTING_REJECTED',
+ },
+}))
+
+const mockVerifyRecaptcha = vi.fn().mockResolvedValue({ success: true })
+vi.mock('@/lib/captcha/verify', () => ({
+ verifyRecaptcha: (...args: unknown[]) => mockVerifyRecaptcha(...args),
+ getClientIP: vi.fn().mockReturnValue('127.0.0.1'),
+}))
+
+vi.mock('@/lib/captcha/config', () => ({
+ RECAPTCHA_CONFIG: { actions: { VOTE: 'VOTE' } },
+}))
+
+vi.mock('@/server/utils/query-builders', () => ({
+ isUserBanned: vi.fn().mockResolvedValue(false),
+}))
+
+vi.mock('@/server/utils/cache', () => ({
+ listingStatsCache: { delete: vi.fn() },
+}))
+
+vi.mock('@/server/cache/invalidation', () => ({
+ invalidateListPages: vi.fn().mockResolvedValue(undefined),
+ invalidateSitemap: vi.fn().mockResolvedValue(undefined),
+ revalidateByTag: vi.fn().mockResolvedValue(undefined),
+}))
+
+vi.mock('@/lib/analytics', () => ({
+ default: {
+ engagement: { vote: vi.fn(), commentVote: vi.fn() },
+ listing: { created: vi.fn() },
+ },
+}))
+
+vi.mock('@/server/services/audit.service', () => ({
+ logAudit: vi.fn().mockResolvedValue(undefined),
+}))
+
+vi.mock('@/server/services/author-risk.service', () => ({
+ computeAuthorRiskProfiles: vi.fn().mockResolvedValue(new Map()),
+}))
+
+vi.mock('@/server/api/utils/pinPermissions', () => ({
+ canManageCommentPins: vi.fn().mockReturnValue(false),
+}))
+
+vi.mock('@/server/utils/security-validation', () => ({
+ validatePagination: vi.fn((page, limit, max) => ({ page: page ?? 1, limit: limit ?? max ?? 20 })),
+}))
+
+const mockRepositoryCreate = vi.fn()
+const mockRepositoryGetById = vi.fn()
+const mockRepositoryApprove = vi.fn()
+const mockRepositoryReject = vi.fn()
+const mockRepositoryGetExistingVote = vi.fn()
+const mockIsDeveloperVerified = vi.fn()
+
+vi.mock('@/server/repositories/pc-listings.repository', () => ({
+ PcListingsRepository: vi.fn().mockImplementation(() => ({
+ create: mockRepositoryCreate,
+ getById: mockRepositoryGetById,
+ approve: mockRepositoryApprove,
+ reject: mockRepositoryReject,
+ getExistingVote: mockRepositoryGetExistingVote,
+ isDeveloperVerifiedForEmulator: mockIsDeveloperVerified,
+ list: vi.fn().mockResolvedValue({ pcListings: [], pagination: {} }),
+ getUserVote: vi.fn().mockResolvedValue(null),
+ })),
+}))
+
+vi.mock('@/server/repositories/user-pc-presets.repository', () => ({
+ UserPcPresetsRepository: vi.fn().mockImplementation(() => ({})),
+}))
+
+const { pcListingsRouter } = await import('./pcListings')
+
+const USER_ID = '00000000-0000-4000-a000-000000000001'
+const AUTHOR_ID = '00000000-0000-4000-a000-000000000002'
+const ADMIN_ID = '00000000-0000-4000-a000-000000000003'
+const LISTING_ID = '00000000-0000-4000-a000-000000000010'
+const COMMENT_ID = '00000000-0000-4000-a000-000000000020'
+
+function createMockPrisma() {
+ const mockTx = {
+ pcListingVote: {
+ create: vi.fn().mockResolvedValue({ userId: USER_ID, pcListingId: LISTING_ID, value: true }),
+ delete: vi.fn().mockResolvedValue(undefined),
+ update: vi.fn().mockResolvedValue({ userId: USER_ID, pcListingId: LISTING_ID, value: false }),
+ findUnique: vi.fn().mockResolvedValue(null),
+ },
+ pcListingCommentVote: {
+ create: vi.fn().mockResolvedValue({ userId: USER_ID, commentId: COMMENT_ID, value: true }),
+ delete: vi.fn().mockResolvedValue(undefined),
+ update: vi.fn().mockResolvedValue({ userId: USER_ID, commentId: COMMENT_ID, value: false }),
+ findUnique: vi.fn().mockResolvedValue(null),
+ },
+ pcListingComment: {
+ findUnique: vi.fn(),
+ update: vi.fn().mockResolvedValue({ id: COMMENT_ID, score: 1 }),
+ },
+ pcListing: {
+ findUnique: vi.fn(),
+ findMany: vi.fn().mockResolvedValue([]),
+ updateMany: vi.fn().mockResolvedValue({ count: 0 }),
+ },
+ }
+
+ return {
+ ...mockTx,
+ $transaction: vi.fn(async (cb: (tx: typeof mockTx) => Promise) => cb(mockTx)),
+ }
+}
+
+type MockPrisma = ReturnType
+
+function createCaller(overrides: { userId?: string; role?: Role; prisma?: MockPrisma } = {}) {
+ const prisma = overrides.prisma ?? createMockPrisma()
+ return {
+ caller: pcListingsRouter.createCaller({
+ session: {
+ user: {
+ id: overrides.userId ?? 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('pcListings trust integration', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockRepositoryGetExistingVote.mockResolvedValue(null)
+ })
+
+ describe('vote', () => {
+ it('calls handleListingVoteTrustEffects with listingType pc on new vote', async () => {
+ const { caller, prisma } = createCaller()
+ prisma.pcListing.findUnique.mockResolvedValue({ id: LISTING_ID, authorId: AUTHOR_ID })
+ await caller.vote({ pcListingId: LISTING_ID, value: true })
+
+ expect(mockHandleListingVoteTrustEffects).toHaveBeenCalledWith(
+ expect.objectContaining({
+ action: 'created',
+ currentValue: true,
+ previousValue: null,
+ userId: USER_ID,
+ listingId: LISTING_ID,
+ listingType: 'pc',
+ authorId: AUTHOR_ID,
+ tx: expect.any(Object),
+ }),
+ )
+ })
+
+ it('calls handleListingVoteTrustEffects with action deleted on vote toggle', async () => {
+ const { caller, prisma } = createCaller()
+ prisma.pcListing.findUnique.mockResolvedValue({ id: LISTING_ID, authorId: AUTHOR_ID })
+ // Simulate an existing upvote inside the transaction.
+ prisma.pcListingVote.findUnique.mockResolvedValue({
+ userId: USER_ID,
+ pcListingId: LISTING_ID,
+ value: true,
+ })
+
+ await caller.vote({ pcListingId: LISTING_ID, value: true })
+
+ expect(mockHandleListingVoteTrustEffects).toHaveBeenCalledWith(
+ expect.objectContaining({
+ action: 'deleted',
+ previousValue: true,
+ listingType: 'pc',
+ }),
+ )
+ })
+
+ it('fetches existingVote via the transaction client, not the repository', async () => {
+ const { caller, prisma } = createCaller()
+ prisma.pcListing.findUnique.mockResolvedValue({ id: LISTING_ID, authorId: AUTHOR_ID })
+
+ await caller.vote({ pcListingId: LISTING_ID, value: true })
+
+ expect(prisma.pcListingVote.findUnique).toHaveBeenCalledWith({
+ where: { userId_pcListingId: { userId: USER_ID, pcListingId: LISTING_ID } },
+ })
+ expect(mockRepositoryGetExistingVote).not.toHaveBeenCalled()
+ })
+
+ it('emits LISTING_VOTED on a new vote', async () => {
+ const { caller, prisma } = createCaller()
+ prisma.pcListing.findUnique.mockResolvedValue({ id: LISTING_ID, authorId: AUTHOR_ID })
+
+ await caller.vote({ pcListingId: LISTING_ID, value: true })
+
+ expect(mockEmitNotificationEvent).toHaveBeenCalledWith(
+ expect.objectContaining({
+ eventType: 'LISTING_VOTED',
+ entityType: 'pcListing',
+ entityId: LISTING_ID,
+ payload: expect.objectContaining({ voteValue: true }),
+ }),
+ )
+ })
+
+ it('emits LISTING_VOTED on a vote direction change', async () => {
+ const { caller, prisma } = createCaller()
+ prisma.pcListing.findUnique.mockResolvedValue({ id: LISTING_ID, authorId: AUTHOR_ID })
+ prisma.pcListingVote.findUnique.mockResolvedValue({
+ userId: USER_ID,
+ pcListingId: LISTING_ID,
+ value: true,
+ })
+
+ await caller.vote({ pcListingId: LISTING_ID, value: false })
+
+ expect(mockEmitNotificationEvent).toHaveBeenCalledWith(
+ expect.objectContaining({
+ eventType: 'LISTING_VOTED',
+ payload: expect.objectContaining({ voteValue: false }),
+ }),
+ )
+ })
+
+ it('does NOT emit LISTING_VOTED on toggle-off (delete)', async () => {
+ const { caller, prisma } = createCaller()
+ prisma.pcListing.findUnique.mockResolvedValue({ id: LISTING_ID, authorId: AUTHOR_ID })
+ prisma.pcListingVote.findUnique.mockResolvedValue({
+ userId: USER_ID,
+ pcListingId: LISTING_ID,
+ value: true,
+ })
+
+ await caller.vote({ pcListingId: LISTING_ID, value: true })
+
+ expect(mockEmitNotificationEvent).not.toHaveBeenCalled()
+ })
+
+ it('verifies recaptcha when token is provided', async () => {
+ const { caller, prisma } = createCaller()
+ prisma.pcListing.findUnique.mockResolvedValue({ id: LISTING_ID, authorId: AUTHOR_ID })
+
+ await caller.vote({
+ pcListingId: LISTING_ID,
+ value: true,
+ recaptchaToken: 'valid-token',
+ })
+
+ expect(mockVerifyRecaptcha).toHaveBeenCalledWith({
+ token: 'valid-token',
+ expectedAction: 'VOTE',
+ userIP: expect.any(String),
+ })
+ })
+
+ it('throws CAPTCHA error when recaptcha verification fails', async () => {
+ const { caller, prisma } = createCaller()
+ prisma.pcListing.findUnique.mockResolvedValue({ id: LISTING_ID, authorId: AUTHOR_ID })
+ mockVerifyRecaptcha.mockResolvedValueOnce({ success: false, error: 'invalid-token' })
+
+ // AppError.captcha throws a TRPCError; assert the procedure rejects.
+ await expect(
+ caller.vote({
+ pcListingId: LISTING_ID,
+ value: true,
+ recaptchaToken: 'bad-token',
+ }),
+ ).rejects.toThrow(/CAPTCHA/)
+
+ // Vote write must NOT have happened
+ expect(prisma.pcListingVote.create).not.toHaveBeenCalled()
+ expect(prisma.pcListingVote.update).not.toHaveBeenCalled()
+ expect(prisma.pcListingVote.delete).not.toHaveBeenCalled()
+ })
+
+ it('proceeds without verifying recaptcha when no token provided (optional)', async () => {
+ const { caller, prisma } = createCaller()
+ prisma.pcListing.findUnique.mockResolvedValue({ id: LISTING_ID, authorId: AUTHOR_ID })
+
+ await caller.vote({ pcListingId: LISTING_ID, value: true })
+
+ expect(mockVerifyRecaptcha).not.toHaveBeenCalled()
+ expect(prisma.pcListingVote.create).toHaveBeenCalled()
+ })
+ })
+
+ describe('voteComment notifications', () => {
+ function setupComment(prisma: MockPrisma) {
+ prisma.pcListingComment.findUnique.mockResolvedValue({
+ id: COMMENT_ID,
+ userId: AUTHOR_ID,
+ pcListingId: LISTING_ID,
+ })
+ prisma.pcListingComment.update.mockResolvedValue({ id: COMMENT_ID, score: 1 })
+ }
+
+ it('emits COMMENT_VOTED on a new upvote', async () => {
+ const { caller, prisma } = createCaller()
+ setupComment(prisma)
+
+ await caller.voteComment({ commentId: COMMENT_ID, value: true })
+
+ expect(mockEmitNotificationEvent).toHaveBeenCalledWith(
+ expect.objectContaining({
+ eventType: 'COMMENT_VOTED',
+ entityType: 'comment',
+ entityId: COMMENT_ID,
+ payload: expect.objectContaining({
+ commentId: COMMENT_ID,
+ pcListingId: LISTING_ID,
+ voteValue: true,
+ }),
+ }),
+ )
+ })
+
+ it('emits COMMENT_VOTED on a vote change', async () => {
+ const { caller, prisma } = createCaller()
+ setupComment(prisma)
+ prisma.pcListingCommentVote.findUnique.mockResolvedValue({
+ userId: USER_ID,
+ commentId: COMMENT_ID,
+ value: true,
+ })
+
+ await caller.voteComment({ commentId: COMMENT_ID, value: false })
+
+ expect(mockEmitNotificationEvent).toHaveBeenCalledWith(
+ expect.objectContaining({
+ eventType: 'COMMENT_VOTED',
+ payload: expect.objectContaining({ voteValue: false }),
+ }),
+ )
+ })
+
+ it('does NOT emit COMMENT_VOTED on toggle-off (remove)', async () => {
+ const { caller, prisma } = createCaller()
+ setupComment(prisma)
+ prisma.pcListingCommentVote.findUnique.mockResolvedValue({
+ userId: USER_ID,
+ commentId: COMMENT_ID,
+ value: true,
+ })
+
+ await caller.voteComment({ commentId: COMMENT_ID, value: true })
+
+ expect(mockEmitNotificationEvent).not.toHaveBeenCalled()
+ })
+ })
+
+ describe('create', () => {
+ it('calls applyTrustAction with LISTING_CREATED after creation', async () => {
+ const newListingId = '00000000-0000-4000-a000-000000000099'
+ mockRepositoryCreate.mockResolvedValue({
+ id: newListingId,
+ status: ApprovalStatus.PENDING,
+ })
+
+ const { caller } = createCaller()
+
+ 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: 'WINDOWS' as never,
+ osVersion: '11',
+ })
+
+ expect(mockApplyTrustAction).toHaveBeenCalledWith({
+ userId: USER_ID,
+ action: TrustAction.LISTING_CREATED,
+ context: { pcListingId: newListingId },
+ })
+ })
+ })
+
+ describe('approve', () => {
+ it('calls applyTrustAction with LISTING_APPROVED for author', async () => {
+ mockRepositoryGetById.mockResolvedValue({
+ id: LISTING_ID,
+ authorId: AUTHOR_ID,
+ status: ApprovalStatus.PENDING,
+ emulatorId: '00000000-0000-4000-a000-000000000060',
+ })
+ mockRepositoryApprove.mockResolvedValue({
+ id: LISTING_ID,
+ status: ApprovalStatus.APPROVED,
+ })
+
+ const { caller } = createCaller({ userId: ADMIN_ID, role: Role.MODERATOR })
+
+ await caller.approve({ pcListingId: LISTING_ID })
+
+ expect(mockApplyTrustAction).toHaveBeenCalledWith({
+ userId: AUTHOR_ID,
+ action: TrustAction.LISTING_APPROVED,
+ context: {
+ pcListingId: LISTING_ID,
+ adminUserId: ADMIN_ID,
+ reason: 'listing_approved',
+ },
+ })
+ })
+ })
+
+ describe('reject', () => {
+ it('calls applyTrustAction with LISTING_REJECTED for author', async () => {
+ mockRepositoryGetById.mockResolvedValue({
+ id: LISTING_ID,
+ authorId: AUTHOR_ID,
+ status: ApprovalStatus.PENDING,
+ emulatorId: '00000000-0000-4000-a000-000000000060',
+ })
+ mockRepositoryReject.mockResolvedValue({
+ id: LISTING_ID,
+ status: ApprovalStatus.REJECTED,
+ })
+
+ const { caller } = createCaller({ userId: ADMIN_ID, role: Role.MODERATOR })
+
+ await caller.reject({ pcListingId: LISTING_ID, notes: 'Incomplete report' })
+
+ expect(mockApplyTrustAction).toHaveBeenCalledWith({
+ userId: AUTHOR_ID,
+ action: TrustAction.LISTING_REJECTED,
+ context: {
+ pcListingId: LISTING_ID,
+ adminUserId: ADMIN_ID,
+ reason: 'Incomplete report',
+ },
+ })
+ })
+ })
+
+ describe('bulkApprove', () => {
+ it('calls applyTrustAction with LISTING_APPROVED for each listing author', async () => {
+ const listing1 = {
+ id: LISTING_ID,
+ gameId: '00000000-0000-4000-a000-000000000040',
+ authorId: AUTHOR_ID,
+ }
+ const listing2 = {
+ id: '00000000-0000-4000-a000-000000000011',
+ gameId: '00000000-0000-4000-a000-000000000041',
+ authorId: '00000000-0000-4000-a000-000000000050',
+ }
+
+ const { caller, prisma } = createCaller({ userId: ADMIN_ID, role: Role.MODERATOR })
+ prisma.pcListing.findMany.mockResolvedValue([listing1, listing2])
+ prisma.pcListing.updateMany.mockResolvedValue({ count: 2 })
+
+ await caller.bulkApprove({ pcListingIds: [listing1.id, listing2.id] })
+
+ expect(mockApplyTrustAction).toHaveBeenCalledTimes(2)
+ expect(mockApplyTrustAction).toHaveBeenCalledWith({
+ userId: AUTHOR_ID,
+ action: TrustAction.LISTING_APPROVED,
+ context: expect.objectContaining({ pcListingId: LISTING_ID }),
+ })
+ expect(mockApplyTrustAction).toHaveBeenCalledWith({
+ userId: '00000000-0000-4000-a000-000000000050',
+ action: TrustAction.LISTING_APPROVED,
+ context: expect.objectContaining({ pcListingId: '00000000-0000-4000-a000-000000000011' }),
+ })
+ })
+ })
+
+ describe('bulkReject', () => {
+ it('calls applyTrustAction with LISTING_REJECTED for each listing author', async () => {
+ const listing1 = { id: LISTING_ID, authorId: AUTHOR_ID }
+ const listing2 = {
+ id: '00000000-0000-4000-a000-000000000011',
+ authorId: '00000000-0000-4000-a000-000000000050',
+ }
+
+ const { caller, prisma } = createCaller({ userId: ADMIN_ID, role: Role.MODERATOR })
+ prisma.pcListing.findMany.mockResolvedValue([listing1, listing2])
+ prisma.pcListing.updateMany.mockResolvedValue({ count: 2 })
+
+ await caller.bulkReject({ pcListingIds: [listing1.id, listing2.id], notes: 'Spam' })
+
+ expect(mockApplyTrustAction).toHaveBeenCalledTimes(2)
+ expect(mockApplyTrustAction).toHaveBeenCalledWith({
+ userId: AUTHOR_ID,
+ action: TrustAction.LISTING_REJECTED,
+ context: expect.objectContaining({
+ pcListingId: LISTING_ID,
+ reason: 'Spam',
+ }),
+ })
+ expect(mockApplyTrustAction).toHaveBeenCalledWith({
+ userId: '00000000-0000-4000-a000-000000000050',
+ action: TrustAction.LISTING_REJECTED,
+ context: expect.objectContaining({
+ pcListingId: '00000000-0000-4000-a000-000000000011',
+ reason: 'Spam',
+ }),
+ })
+ })
+ })
+
+ describe('voteComment', () => {
+ function setupCommentMocks(prisma: MockPrisma) {
+ prisma.pcListingComment.findUnique.mockResolvedValue({
+ id: COMMENT_ID,
+ userId: AUTHOR_ID,
+ pcListingId: LISTING_ID,
+ })
+ prisma.pcListingCommentVote.findUnique.mockResolvedValue(null)
+ prisma.pcListingComment.update.mockResolvedValue({ id: COMMENT_ID, score: 1 })
+ }
+
+ it('calls handleCommentVoteTrustEffects with listingType pc on new upvote', async () => {
+ const { caller, prisma } = createCaller()
+ setupCommentMocks(prisma)
+
+ await caller.voteComment({ commentId: COMMENT_ID, value: true })
+
+ expect(mockHandleCommentVoteTrustEffects).toHaveBeenCalledWith(
+ expect.objectContaining({
+ trustAction: 'upvote',
+ newValue: true,
+ commentAuthorId: AUTHOR_ID,
+ voterId: USER_ID,
+ commentId: COMMENT_ID,
+ parentEntityId: LISTING_ID,
+ listingType: 'pc',
+ }),
+ )
+ })
+
+ it('calls handleCommentVoteTrustEffects with change action on vote flip', async () => {
+ const { caller, prisma } = createCaller()
+ setupCommentMocks(prisma)
+ prisma.pcListingCommentVote.findUnique.mockResolvedValue({
+ userId: USER_ID,
+ commentId: COMMENT_ID,
+ value: true,
+ })
+
+ await caller.voteComment({ commentId: COMMENT_ID, value: false })
+
+ expect(mockHandleCommentVoteTrustEffects).toHaveBeenCalledWith(
+ expect.objectContaining({
+ trustAction: 'change',
+ newValue: false,
+ previousValue: true,
+ listingType: 'pc',
+ }),
+ )
+ })
+
+ it('calls handleCommentVoteTrustEffects with remove action on vote toggle', async () => {
+ const { caller, prisma } = createCaller()
+ setupCommentMocks(prisma)
+ prisma.pcListingCommentVote.findUnique.mockResolvedValue({
+ userId: USER_ID,
+ commentId: COMMENT_ID,
+ value: true,
+ })
+
+ await caller.voteComment({ commentId: COMMENT_ID, value: true })
+
+ expect(mockHandleCommentVoteTrustEffects).toHaveBeenCalledWith(
+ expect.objectContaining({
+ trustAction: 'remove',
+ listingType: 'pc',
+ }),
+ )
+ })
+
+ it('fetches existingVote inside the $transaction callback', async () => {
+ const { caller, prisma } = createCaller()
+ setupCommentMocks(prisma)
+
+ await caller.voteComment({ commentId: COMMENT_ID, value: true })
+
+ const txCall = vi.mocked(prisma.$transaction).mock.invocationCallOrder[0]
+ const findUniqueCall = prisma.pcListingCommentVote.findUnique.mock.invocationCallOrder[0]
+ expect(txCall).toBeDefined()
+ expect(findUniqueCall).toBeDefined()
+ expect(findUniqueCall).toBeGreaterThan(txCall as number)
+ })
+ })
+})
diff --git a/src/server/api/routers/pcListings.ts b/src/server/api/routers/pcListings.ts
index b588f387..c6dd3d2b 100644
--- a/src/server/api/routers/pcListings.ts
+++ b/src/server/api/routers/pcListings.ts
@@ -1,6 +1,8 @@
import analytics from '@/lib/analytics'
+import { RECAPTCHA_CONFIG } from '@/lib/captcha/config'
+import { getClientIP, verifyRecaptcha } from '@/lib/captcha/verify'
import { AppError, ResourceError } from '@/lib/errors'
-import { TrustService } from '@/lib/trust/service'
+import { applyTrustAction, TrustService } from '@/lib/trust/service'
import {
ApprovePcListingSchema,
BulkApprovePcListingsSchema,
@@ -68,6 +70,10 @@ import { paginate } from '@/server/utils/pagination'
import { isUserBanned } from '@/server/utils/query-builders'
import { validatePagination } from '@/server/utils/security-validation'
import { updatePcListingVoteCounts } from '@/server/utils/vote-counts'
+import {
+ handleCommentVoteTrustEffects,
+ handleListingVoteTrustEffects,
+} from '@/server/utils/vote-trust-effects'
import { PERMISSIONS, roleIncludesRole } from '@/utils/permission-system'
import {
canDeleteComment,
@@ -251,6 +257,10 @@ export const pcListingsRouter = createTRPCRouter({
}),
create: protectedProcedure.input(CreatePcListingSchema).mutation(async ({ ctx, input }) => {
+ // TODO: Add spam detection via `checkSpamContent` from
+ // `@/server/utils/spam-check` (currently only applied in mobile routes).
+ // Block: UX/product sign-off needed since existing web users would start
+ // seeing spam-block errors. Mirror mobile: `{ userId, content: notes, entityType: 'listing' }`.
const authorId = ctx.session.user.id
const repository = new PcListingsRepository(ctx.prisma)
const newListing = await repository.create({
@@ -270,6 +280,12 @@ export const pcListingsRouter = createTRPCRouter({
: null) as { customFieldDefinitionId: string; value: unknown }[] | null,
})
+ await applyTrustAction({
+ userId: authorId,
+ action: TrustAction.LISTING_CREATED,
+ context: { pcListingId: newListing.id },
+ })
+
// Invalidate stats cache when PC listing is created
listingStatsCache.delete('pc-listing-stats')
@@ -513,6 +529,18 @@ export const pcListingsRouter = createTRPCRouter({
const approvedListing = await repository.approve(input.pcListingId, ctx.session.user.id)
+ if (pcListing.authorId) {
+ await applyTrustAction({
+ userId: pcListing.authorId,
+ action: TrustAction.LISTING_APPROVED,
+ context: {
+ pcListingId: input.pcListingId,
+ adminUserId: ctx.session.user.id,
+ reason: 'listing_approved',
+ },
+ })
+ }
+
// Invalidate stats cache when PC listing is approved
listingStatsCache.delete('pc-listing-stats')
@@ -567,6 +595,18 @@ export const pcListingsRouter = createTRPCRouter({
input.notes,
)
+ if (pcListing.authorId) {
+ await applyTrustAction({
+ userId: pcListing.authorId,
+ action: TrustAction.LISTING_REJECTED,
+ context: {
+ pcListingId: input.pcListingId,
+ adminUserId: ctx.session.user.id,
+ reason: input.notes || 'listing_rejected',
+ },
+ })
+ }
+
// Invalidate stats cache when PC listing is rejected
listingStatsCache.delete('pc-listing-stats')
@@ -627,7 +667,7 @@ export const pcListingsRouter = createTRPCRouter({
const pendingListings = await ctx.prisma.pcListing.findMany({
where: { id: { in: input.pcListingIds }, status: ApprovalStatus.PENDING },
- select: { id: true, gameId: true },
+ select: { id: true, gameId: true, authorId: true },
})
const result = await ctx.prisma.pcListing.updateMany({
@@ -639,6 +679,24 @@ export const pcListingsRouter = createTRPCRouter({
},
})
+ // Apply trust actions in parallel β distinct user adjustments, independent.
+ const listingsWithAuthor = pendingListings.filter(
+ (l): l is typeof l & { authorId: string } => l.authorId !== null,
+ )
+ await Promise.all(
+ listingsWithAuthor.map((listing) =>
+ applyTrustAction({
+ userId: listing.authorId,
+ action: TrustAction.LISTING_APPROVED,
+ context: {
+ pcListingId: listing.id,
+ adminUserId: ctx.session.user.id,
+ reason: 'bulk_listing_approved',
+ },
+ }),
+ ),
+ )
+
listingStatsCache.delete('pc-listing-stats')
for (const listing of pendingListings) {
@@ -671,7 +729,7 @@ export const pcListingsRouter = createTRPCRouter({
const pendingListings = await ctx.prisma.pcListing.findMany({
where: { id: { in: input.pcListingIds }, status: ApprovalStatus.PENDING },
- select: { id: true },
+ select: { id: true, authorId: true },
})
const result = await ctx.prisma.pcListing.updateMany({
@@ -686,6 +744,24 @@ export const pcListingsRouter = createTRPCRouter({
},
})
+ // Apply trust actions in parallel β distinct user adjustments, independent.
+ const listingsWithAuthor = pendingListings.filter(
+ (l): l is typeof l & { authorId: string } => l.authorId !== null,
+ )
+ await Promise.all(
+ listingsWithAuthor.map((listing) =>
+ applyTrustAction({
+ userId: listing.authorId,
+ action: TrustAction.LISTING_REJECTED,
+ context: {
+ pcListingId: listing.id,
+ adminUserId: ctx.session.user.id,
+ reason: input.notes || 'bulk_listing_rejected',
+ },
+ }),
+ ),
+ )
+
// Invalidate stats cache when PC listings are bulk rejected
listingStatsCache.delete('pc-listing-stats')
@@ -886,74 +962,95 @@ export const pcListingsRouter = createTRPCRouter({
return AppError.shadowBanned()
}
+ if (input.recaptchaToken) {
+ const clientIP = ctx.headers ? getClientIP(ctx.headers) : undefined
+ const captchaResult = await verifyRecaptcha({
+ token: input.recaptchaToken,
+ expectedAction: RECAPTCHA_CONFIG.actions.VOTE,
+ userIP: clientIP,
+ })
+
+ if (!captchaResult.success) return AppError.captcha(captchaResult.error)
+ }
+
const pcListing = await ctx.prisma.pcListing.findUnique({
where: { id: pcListingId },
})
if (!pcListing) return ResourceError.pcListing.notFound()
- // Check if user already voted on this PC listing
- const repository = new PcListingsRepository(ctx.prisma)
- const existingVote = await repository.getExistingVote(userId, pcListingId)
-
- let voteResult
-
- if (existingVote) {
- // If vote is the same, remove the vote (toggle)
- if (existingVote.value === value) {
- // Delete vote and update counts in transaction
- voteResult = await ctx.prisma.$transaction(async (tx) => {
- await tx.pcListingVote.delete({
- where: { userId_pcListingId: { userId, pcListingId } },
- })
-
- await updatePcListingVoteCounts(tx, pcListingId, 'delete', undefined, existingVote.value)
-
- return { message: 'Vote removed' }
- })
- } else {
- // Update vote and counts in transaction
- voteResult = await ctx.prisma.$transaction(async (tx) => {
- const vote = await tx.pcListingVote.update({
- where: { userId_pcListingId: { userId, pcListingId } },
- data: { value },
- })
-
- await updatePcListingVoteCounts(tx, pcListingId, 'update', value, existingVote.value)
+ // Fetch existingVote INSIDE the transaction to avoid race conditions between
+ // concurrent votes on the same (user, pcListing) pair.
+ const voteResult = await ctx.prisma.$transaction(async (tx) => {
+ const existingVote = await tx.pcListingVote.findUnique({
+ where: { userId_pcListingId: { userId, pcListingId } },
+ })
- return vote
- })
+ let result: {
+ vote: { userId: string; pcListingId: string; value: boolean } | null
+ action: 'created' | 'updated' | 'deleted'
+ previousValue: boolean | null
}
- } else {
- // Create new vote and update counts in transaction
- voteResult = await ctx.prisma.$transaction(async (tx) => {
+
+ if (!existingVote) {
const vote = await tx.pcListingVote.create({
data: { userId, pcListingId, value },
})
-
await updatePcListingVoteCounts(tx, pcListingId, 'create', value)
+ result = { vote, action: 'created', previousValue: null }
+ } else if (existingVote.value === value) {
+ await tx.pcListingVote.delete({
+ where: { userId_pcListingId: { userId, pcListingId } },
+ })
+ await updatePcListingVoteCounts(tx, pcListingId, 'delete', undefined, existingVote.value)
+ result = { vote: null, action: 'deleted', previousValue: existingVote.value }
+ } else {
+ const vote = await tx.pcListingVote.update({
+ where: { userId_pcListingId: { userId, pcListingId } },
+ data: { value },
+ })
+ await updatePcListingVoteCounts(tx, pcListingId, 'update', value, existingVote.value)
+ result = { vote, action: 'updated', previousValue: existingVote.value }
+ }
- return vote
+ await handleListingVoteTrustEffects({
+ tx,
+ action: result.action,
+ currentValue: value,
+ previousValue: result.previousValue,
+ userId,
+ listingId: pcListingId,
+ listingType: 'pc',
+ authorId: pcListing.authorId,
})
- }
- // Emit notification event
- notificationEventEmitter.emitNotificationEvent({
- eventType: value ? NOTIFICATION_EVENTS.LISTING_VOTED : NOTIFICATION_EVENTS.LISTING_VOTED,
- entityType: 'pcListing',
- entityId: pcListingId,
- triggeredBy: userId,
- payload: { pcListingId, voteValue: value },
+ return result
})
- const finalVoteValue = existingVote?.value === value ? null : value
+ // Only notify the author when a vote was created or updated β toggle-off should not fire.
+ if (voteResult.action === 'created' || voteResult.action === 'updated') {
+ if (voteResult.vote) {
+ notificationEventEmitter.emitNotificationEvent({
+ eventType: NOTIFICATION_EVENTS.LISTING_VOTED,
+ entityType: 'pcListing',
+ entityId: pcListingId,
+ triggeredBy: userId,
+ payload: {
+ pcListingId,
+ voteValue: value,
+ },
+ })
+ }
+ }
+
+ const finalVoteValue = voteResult.action === 'deleted' ? null : value
analytics.engagement.vote({
listingId: pcListingId,
voteValue: finalVoteValue,
- previousVote: existingVote?.value,
+ previousVote: voteResult.previousValue,
})
- return voteResult
+ return voteResult.vote
}),
getUserVote: protectedProcedure
@@ -1072,6 +1169,10 @@ export const pcListingsRouter = createTRPCRouter({
createComment: protectedProcedure
.input(CreatePcListingCommentSchema)
.mutation(async ({ ctx, input }) => {
+ // TODO: Add spam detection via `checkSpamContent` from
+ // `@/server/utils/spam-check` (currently only applied in mobile routes).
+ // Block: UX/product sign-off needed since existing web users would start
+ // seeing spam-block errors. Mirror mobile: `{ userId, content, entityType: 'comment' }`.
const { pcListingId, content, parentId } = input
const userId = ctx.session.user.id
@@ -1143,7 +1244,7 @@ export const pcListingsRouter = createTRPCRouter({
return ResourceError.comment.noPermission('edit')
}
- return ctx.prisma.pcListingComment.update({
+ return await ctx.prisma.pcListingComment.update({
where: { id: input.commentId },
data: {
content: input.content,
@@ -1238,44 +1339,79 @@ export const pcListingsRouter = createTRPCRouter({
return ResourceError.comment.notFound()
}
- // Check if user already voted on this comment
- const existingVote = await ctx.prisma.pcListingCommentVote.findUnique({
- where: { userId_commentId: { userId, commentId } },
- })
+ // Fetch `existingVote` inside the transaction: two concurrent votes
+ // from the same user could both read null and both attempt to insert,
+ // producing a Prisma P2002 on the second. Keeping the read and write
+ // under the same isolation avoids the race.
+ return await ctx.prisma.$transaction(async (tx) => {
+ const existingVote = await tx.pcListingCommentVote.findUnique({
+ where: { userId_commentId: { userId, commentId } },
+ })
- // Start a transaction to handle both the vote and score update
- return ctx.prisma.$transaction(async (tx) => {
let voteResult
let scoreChange: number
+ let trustAction: 'upvote' | 'downvote' | 'change' | 'remove' | null = null
if (existingVote) {
- // If vote is the same, remove the vote (toggle)
if (existingVote.value === value) {
await tx.pcListingCommentVote.delete({
where: { userId_commentId: { userId, commentId } },
})
scoreChange = existingVote.value ? -1 : 1
voteResult = { message: 'Vote removed' }
+ trustAction = 'remove'
} else {
voteResult = await tx.pcListingCommentVote.update({
where: { userId_commentId: { userId, commentId } },
data: { value },
})
scoreChange = value ? 2 : -2
+ trustAction = 'change'
}
} else {
voteResult = await tx.pcListingCommentVote.create({
data: { userId, commentId, value },
})
scoreChange = value ? 1 : -1
+ trustAction = value ? 'upvote' : 'downvote'
}
- // Update the comment score
- await tx.pcListingComment.update({
+ const updatedComment = await tx.pcListingComment.update({
where: { id: commentId },
data: { score: { increment: scoreChange } },
})
+ if (trustAction) {
+ await handleCommentVoteTrustEffects({
+ tx,
+ trustAction,
+ newValue: value,
+ previousValue: existingVote?.value ?? null,
+ commentAuthorId: comment.userId,
+ voterId: userId,
+ commentId,
+ parentEntityId: comment.pcListingId,
+ listingType: 'pc',
+ updatedScore: updatedComment.score,
+ scoreChange,
+ })
+ }
+
+ // Notify comment author on new votes / direction changes; skip on toggle-off.
+ if (trustAction !== null && trustAction !== 'remove') {
+ notificationEventEmitter.emitNotificationEvent({
+ eventType: NOTIFICATION_EVENTS.COMMENT_VOTED,
+ entityType: 'comment',
+ entityId: comment.id,
+ triggeredBy: userId,
+ payload: {
+ pcListingId: comment.pcListingId,
+ commentId: comment.id,
+ voteValue: value,
+ },
+ })
+ }
+
return voteResult
})
}),
@@ -1428,7 +1564,7 @@ export const pcListingsRouter = createTRPCRouter({
// Prevent users from reporting their own listings
if (pcListing.authorId === userId) {
- AppError.badRequest('You cannot report your own listing')
+ return AppError.badRequest('You cannot report your own listing')
}
// Check if user already reported this listing
@@ -1442,10 +1578,10 @@ export const pcListingsRouter = createTRPCRouter({
})
if (existingReport) {
- AppError.badRequest('You have already reported this listing')
+ return AppError.badRequest('You have already reported this listing')
}
- return ctx.prisma.pcListingReport.create({
+ return await ctx.prisma.pcListingReport.create({
data: {
pcListingId,
reportedById: userId,
@@ -1565,7 +1701,7 @@ export const pcListingsRouter = createTRPCRouter({
})
}
- return ctx.prisma.pcListingReport.update({
+ return await ctx.prisma.pcListingReport.update({
where: { id: reportId },
data: {
status,
@@ -1599,7 +1735,7 @@ export const pcListingsRouter = createTRPCRouter({
})
if (!pcListing) {
- ResourceError.pcListing.notFound()
+ return ResourceError.pcListing.notFound()
}
// Check if user already verified this listing
@@ -1613,10 +1749,10 @@ export const pcListingsRouter = createTRPCRouter({
})
if (existingVerification) {
- AppError.badRequest('You have already verified this listing')
+ return AppError.badRequest('You have already verified this listing')
}
- return ctx.prisma.pcListingDeveloperVerification.create({
+ return await ctx.prisma.pcListingDeveloperVerification.create({
data: {
pcListingId,
verifiedBy: verifierId,
@@ -1644,7 +1780,7 @@ export const pcListingsRouter = createTRPCRouter({
return ResourceError.verification.canOnlyRemoveOwn()
}
- return ctx.prisma.pcListingDeveloperVerification.delete({
+ return await ctx.prisma.pcListingDeveloperVerification.delete({
where: { id: input.verificationId },
})
}),
@@ -1652,7 +1788,7 @@ export const pcListingsRouter = createTRPCRouter({
getVerifications: publicProcedure
.input(GetPcListingVerificationsSchema)
.query(async ({ ctx, input }) => {
- return ctx.prisma.pcListingDeveloperVerification.findMany({
+ return await ctx.prisma.pcListingDeveloperVerification.findMany({
where: { pcListingId: input.pcListingId },
include: {
developer: { select: { id: true, name: true } },
diff --git a/src/server/api/routers/trust.test.ts b/src/server/api/routers/trust.test.ts
new file mode 100644
index 00000000..5961ca72
--- /dev/null
+++ b/src/server/api/routers/trust.test.ts
@@ -0,0 +1,179 @@
+import { describe, expect, it, beforeEach, vi } from 'vitest'
+import { Role } from '@orm'
+
+vi.unmock('@/server/api/trpc')
+vi.unmock('@/server/api/root')
+
+const mockApplyMonthlyActiveBonus = vi.fn().mockResolvedValue({ processedUsers: 0, errors: [] })
+const mockApplyManualAdjustment = vi.fn().mockResolvedValue(undefined)
+
+vi.mock('@/lib/trust/service', () => ({
+ applyMonthlyActiveBonus: (...args: unknown[]) => mockApplyMonthlyActiveBonus(...args),
+ TrustService: vi.fn().mockImplementation(() => ({
+ applyManualAdjustment: (...args: unknown[]) => mockApplyManualAdjustment(...args),
+ })),
+}))
+
+const mockPrismaTrustActionLogFindMany = vi.fn().mockResolvedValue([])
+const mockPrismaTrustActionLogCount = vi.fn().mockResolvedValue(0)
+const mockPrismaUserCount = vi.fn().mockResolvedValue(0)
+const mockPrismaUserGroupBy = vi.fn().mockResolvedValue([])
+
+vi.mock('@/server/db', () => ({
+ prisma: {
+ trustActionLog: {
+ findMany: (...args: unknown[]) => mockPrismaTrustActionLogFindMany(...args),
+ count: (...args: unknown[]) => mockPrismaTrustActionLogCount(...args),
+ },
+ user: {
+ count: (...args: unknown[]) => mockPrismaUserCount(...args),
+ groupBy: (...args: unknown[]) => mockPrismaUserGroupBy(...args),
+ },
+ },
+}))
+
+const { trustRouter } = await import('./trust')
+
+const USER_ID = '00000000-0000-4000-a000-000000000001'
+const TARGET_USER_ID = '00000000-0000-4000-a000-000000000002'
+
+function createCaller(role: Role) {
+ return trustRouter.createCaller({
+ session: {
+ user: {
+ id: USER_ID,
+ email: 'test@test.com',
+ name: 'Test User',
+ role,
+ permissions: [],
+ showNsfw: false,
+ },
+ },
+ prisma: {} as never,
+ headers: new Headers(),
+ })
+}
+
+const VALID_PAGINATION = { page: 1, limit: 20 }
+
+const NON_SUPER_ADMIN_ROLES: Role[] = [
+ Role.USER,
+ Role.AUTHOR,
+ Role.DEVELOPER,
+ Role.MODERATOR,
+ Role.ADMIN,
+]
+
+describe('trust router permission checks', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ describe('getTrustLogs (SUPER_ADMIN only)', () => {
+ NON_SUPER_ADMIN_ROLES.forEach((role) => {
+ it(`rejects with FORBIDDEN for ${role} and does not query the DB`, async () => {
+ const caller = createCaller(role)
+
+ await expect(caller.getTrustLogs(VALID_PAGINATION)).rejects.toThrow(
+ /Super Admin|insufficient|forbidden/i,
+ )
+
+ expect(mockPrismaTrustActionLogFindMany).not.toHaveBeenCalled()
+ expect(mockPrismaTrustActionLogCount).not.toHaveBeenCalled()
+ })
+ })
+
+ it('proceeds for SUPER_ADMIN', async () => {
+ const caller = createCaller(Role.SUPER_ADMIN)
+
+ const result = await caller.getTrustLogs(VALID_PAGINATION)
+
+ expect(result).toHaveProperty('logs')
+ expect(result).toHaveProperty('pagination')
+ expect(mockPrismaTrustActionLogFindMany).toHaveBeenCalledTimes(1)
+ })
+ })
+
+ describe('getTrustStats (SUPER_ADMIN only)', () => {
+ NON_SUPER_ADMIN_ROLES.forEach((role) => {
+ it(`rejects with FORBIDDEN for ${role} and does not query the DB`, async () => {
+ const caller = createCaller(role)
+
+ await expect(caller.getTrustStats({})).rejects.toThrow(
+ /Super Admin|insufficient|forbidden/i,
+ )
+
+ expect(mockPrismaTrustActionLogCount).not.toHaveBeenCalled()
+ expect(mockPrismaUserCount).not.toHaveBeenCalled()
+ expect(mockPrismaUserGroupBy).not.toHaveBeenCalled()
+ })
+ })
+
+ it('proceeds for SUPER_ADMIN', async () => {
+ const caller = createCaller(Role.SUPER_ADMIN)
+
+ const result = await caller.getTrustStats({})
+
+ expect(result).toHaveProperty('totalActions')
+ expect(mockPrismaTrustActionLogCount).toHaveBeenCalledTimes(1)
+ })
+ })
+
+ describe('runMonthlyActiveBonus (SUPER_ADMIN only)', () => {
+ NON_SUPER_ADMIN_ROLES.forEach((role) => {
+ it(`rejects with FORBIDDEN for ${role} and does not invoke the bonus job`, async () => {
+ const caller = createCaller(role)
+
+ await expect(caller.runMonthlyActiveBonus({})).rejects.toThrow(
+ /Super Admin|insufficient|forbidden/i,
+ )
+
+ expect(mockApplyMonthlyActiveBonus).not.toHaveBeenCalled()
+ })
+ })
+
+ it('invokes the bonus job for SUPER_ADMIN', async () => {
+ const caller = createCaller(Role.SUPER_ADMIN)
+
+ await caller.runMonthlyActiveBonus({})
+
+ expect(mockApplyMonthlyActiveBonus).toHaveBeenCalledTimes(1)
+ })
+ })
+
+ describe('adjustTrustScore (SUPER_ADMIN only)', () => {
+ NON_SUPER_ADMIN_ROLES.forEach((role) => {
+ it(`rejects with FORBIDDEN for ${role} and does not adjust trust`, async () => {
+ const caller = createCaller(role)
+
+ await expect(
+ caller.adjustTrustScore({
+ userId: TARGET_USER_ID,
+ adjustment: 50,
+ reason: 'attempted bypass',
+ }),
+ ).rejects.toThrow(/Super Admin|insufficient|forbidden/i)
+
+ expect(mockApplyManualAdjustment).not.toHaveBeenCalled()
+ })
+ })
+
+ it('invokes the adjustment for SUPER_ADMIN', async () => {
+ const caller = createCaller(Role.SUPER_ADMIN)
+
+ await caller.adjustTrustScore({
+ userId: TARGET_USER_ID,
+ adjustment: 50,
+ reason: 'legitimate adjustment',
+ })
+
+ expect(mockApplyManualAdjustment).toHaveBeenCalledTimes(1)
+ expect(mockApplyManualAdjustment).toHaveBeenCalledWith({
+ userId: TARGET_USER_ID,
+ adjustment: 50,
+ reason: 'legitimate adjustment',
+ adminUserId: USER_ID,
+ })
+ })
+ })
+})
diff --git a/src/server/api/routers/trust.ts b/src/server/api/routers/trust.ts
index 104651de..84f066f1 100644
--- a/src/server/api/routers/trust.ts
+++ b/src/server/api/routers/trust.ts
@@ -1,6 +1,6 @@
import { AppError } from '@/lib/errors'
import { TRUST_LEVELS } from '@/lib/trust/config'
-import { applyManualTrustAdjustment, applyMonthlyActiveBonus } from '@/lib/trust/service'
+import { TrustService, applyMonthlyActiveBonus } from '@/lib/trust/service'
import {
GetTrustLogsSchema,
GetTrustStatsSchema,
@@ -17,7 +17,7 @@ export const trustRouter = createTRPCRouter({
// Get trust logs for admin dashboard (SUPER_ADMIN only)
getTrustLogs: protectedProcedure.input(GetTrustLogsSchema).query(async ({ ctx, input }) => {
if (!hasRolePermission(ctx.session.user.role, Role.SUPER_ADMIN)) {
- AppError.insufficientRole(Role.SUPER_ADMIN)
+ return AppError.insufficientRole(Role.SUPER_ADMIN)
}
const { page, limit, sortField, sortDirection, search, action } = input
@@ -79,7 +79,7 @@ export const trustRouter = createTRPCRouter({
// Get trust system statistics (SUPER_ADMIN only)
getTrustStats: protectedProcedure.input(GetTrustStatsSchema).query(async ({ ctx }) => {
if (!hasRolePermission(ctx.session.user.role, Role.SUPER_ADMIN)) {
- AppError.insufficientRole(Role.SUPER_ADMIN)
+ return AppError.insufficientRole(Role.SUPER_ADMIN)
}
const [totalActions, totalUsers, levelDistribution] = await Promise.all([
@@ -127,7 +127,7 @@ export const trustRouter = createTRPCRouter({
.input(RunMonthlyActiveBonusSchema)
.mutation(async ({ ctx }) => {
if (!hasRolePermission(ctx.session.user.role, Role.SUPER_ADMIN)) {
- AppError.insufficientRole(Role.SUPER_ADMIN)
+ return AppError.insufficientRole(Role.SUPER_ADMIN)
}
return await applyMonthlyActiveBonus()
@@ -138,10 +138,10 @@ export const trustRouter = createTRPCRouter({
.input(ManualTrustAdjustmentSchema)
.mutation(async ({ ctx, input }) => {
if (!hasRolePermission(ctx.session.user.role, Role.SUPER_ADMIN)) {
- AppError.insufficientRole(Role.SUPER_ADMIN)
+ return AppError.insufficientRole(Role.SUPER_ADMIN)
}
- await applyManualTrustAdjustment({
+ await new TrustService(ctx.prisma).applyManualAdjustment({
userId: input.userId,
adjustment: input.adjustment,
reason: input.reason,
diff --git a/src/server/api/routers/userBans.ts b/src/server/api/routers/userBans.ts
index 81ddd1df..fd27221c 100644
--- a/src/server/api/routers/userBans.ts
+++ b/src/server/api/routers/userBans.ts
@@ -19,6 +19,7 @@ import {
import { UserBansRepository } from '@/server/repositories/user-bans.repository'
import { logAudit, buildDiff } from '@/server/services/audit.service'
import { nullifyUserVotes, restoreUserVotes } from '@/server/services/vote-nullification.service'
+import { withRetryTransaction } from '@/server/utils/transactions'
import { PERMISSIONS } from '@/utils/permission-system'
import { hasRolePermission } from '@/utils/permissions'
import { AuditAction, AuditEntityType, Role } from '@orm'
@@ -85,32 +86,38 @@ export const userBansRouter = createTRPCRouter({
return AppError.badRequest('Expiration date must be in the future')
}
- const created = await repository.create({
- userId,
- bannedById,
- reason,
- notes,
- expiresAt,
- })
-
- // Nullify votes if requested
- let nullificationResult: Awaited> | null = null
- if (shouldNullifyVotes) {
- nullificationResult = await nullifyUserVotes(ctx.prisma, {
- userId,
- adminUserId: ctx.session.user.id,
- reason: `Ban: ${reason}`,
- includeCommentVotes: true,
- headers: ctx.headers,
- })
-
- await ctx.prisma.userBan.update({
- where: { id: created.id },
- data: { votesNullified: true },
- })
- }
+ const { created, nullificationResult } = await withRetryTransaction(
+ ctx.prisma,
+ async (tx) => {
+ const ban = await new UserBansRepository(tx).create({
+ userId,
+ bannedById,
+ reason,
+ notes,
+ expiresAt,
+ })
+
+ let nullResult: Awaited> | null = null
+ if (shouldNullifyVotes) {
+ nullResult = await nullifyUserVotes(tx, {
+ userId,
+ adminUserId: ctx.session.user.id,
+ reason: `Ban: ${reason}`,
+ includeCommentVotes: true,
+ headers: ctx.headers,
+ })
+
+ await tx.userBan.update({
+ where: { id: ban.id },
+ data: { votesNullified: true },
+ })
+ }
+
+ return { created: ban, nullificationResult: nullResult }
+ },
+ { timeout: 120_000 },
+ )
- // Fire-and-forget audit log
void logAudit(ctx.prisma, {
actorId: ctx.session.user.id,
action: AuditAction.BAN,
@@ -217,7 +224,26 @@ export const userBansRouter = createTRPCRouter({
isActive: ban.isActive,
}
- const lifted = await repository.lift(id, unbannedById, notes)
+ const { lifted, restorationResult } = await withRetryTransaction(
+ ctx.prisma,
+ async (tx) => {
+ const liftedBan = await new UserBansRepository(tx).lift(id, unbannedById, notes)
+
+ let restoreResult: Awaited> | null = null
+ if (ban.votesNullified) {
+ restoreResult = await restoreUserVotes(tx, {
+ userId: ban.userId,
+ adminUserId: ctx.session.user.id,
+ reason: `Ban lifted: ${ban.reason}`,
+ headers: ctx.headers,
+ })
+ }
+
+ return { lifted: liftedBan, restorationResult: restoreResult }
+ },
+ { timeout: 120_000 },
+ )
+
const next = {
reason: lifted.reason,
notes: lifted.notes ?? null,
@@ -225,17 +251,6 @@ export const userBansRouter = createTRPCRouter({
isActive: lifted.isActive,
}
- // Auto-restore votes if they were nullified during this ban
- let restorationResult: Awaited> | null = null
- if (ban.votesNullified) {
- restorationResult = await restoreUserVotes(ctx.prisma, {
- userId: ban.userId,
- adminUserId: ctx.session.user.id,
- reason: `Ban lifted: ${ban.reason}`,
- headers: ctx.headers,
- })
- }
-
void logAudit(ctx.prisma, {
actorId: ctx.session.user.id,
action: AuditAction.UNBAN,
@@ -324,8 +339,8 @@ export const userBansRouter = createTRPCRouter({
ctx.prisma.listingReport.findMany({
where: { listing: { authorId: userId } },
include: {
- reportedBy: { select: { id: true, name: true, email: true } },
- reviewedBy: { select: { id: true, name: true, email: true } },
+ reportedBy: { select: { id: true, name: true } },
+ reviewedBy: { select: { id: true, name: true } },
listing: {
select: {
id: true,
@@ -339,8 +354,8 @@ export const userBansRouter = createTRPCRouter({
ctx.prisma.pcListingReport.findMany({
where: { pcListing: { authorId: userId } },
include: {
- reportedBy: { select: { id: true, name: true, email: true } },
- reviewedBy: { select: { id: true, name: true, email: true } },
+ reportedBy: { select: { id: true, name: true } },
+ reviewedBy: { select: { id: true, name: true } },
pcListing: {
select: {
id: true,
diff --git a/src/server/api/utils/listingHelpers.test.ts b/src/server/api/utils/listingHelpers.test.ts
new file mode 100644
index 00000000..2f3abb64
--- /dev/null
+++ b/src/server/api/utils/listingHelpers.test.ts
@@ -0,0 +1,57 @@
+import { describe, expect, it } from 'vitest'
+import { buildProcessedOrderBy } from './listingHelpers'
+
+describe('buildProcessedOrderBy', () => {
+ it('defaults to processedAt desc when both args are null', () => {
+ expect(buildProcessedOrderBy(null, null)).toEqual({ processedAt: 'desc' })
+ })
+
+ it('defaults to processedAt desc when both args are undefined', () => {
+ expect(buildProcessedOrderBy(undefined, undefined)).toEqual({ processedAt: 'desc' })
+ })
+
+ it('honours explicit sortDirection with default sortField', () => {
+ expect(buildProcessedOrderBy(undefined, 'asc')).toEqual({ processedAt: 'asc' })
+ expect(buildProcessedOrderBy(null, 'asc')).toEqual({ processedAt: 'asc' })
+ })
+
+ it('maps createdAt directly', () => {
+ expect(buildProcessedOrderBy('createdAt', 'asc')).toEqual({ createdAt: 'asc' })
+ expect(buildProcessedOrderBy('createdAt', 'desc')).toEqual({ createdAt: 'desc' })
+ })
+
+ it('maps status directly', () => {
+ expect(buildProcessedOrderBy('status', 'asc')).toEqual({ status: 'asc' })
+ })
+
+ it('maps game.title into a nested Prisma order clause', () => {
+ expect(buildProcessedOrderBy('game.title', 'asc')).toEqual({ game: { title: 'asc' } })
+ })
+
+ it('maps author.name into a nested Prisma order clause', () => {
+ expect(buildProcessedOrderBy('author.name', 'desc')).toEqual({ author: { name: 'desc' } })
+ })
+
+ it('maps game.system.name into a doubly-nested clause', () => {
+ expect(buildProcessedOrderBy('game.system.name', 'asc')).toEqual({
+ game: { system: { name: 'asc' } },
+ })
+ })
+
+ it('maps emulator.name into a nested Prisma order clause', () => {
+ expect(buildProcessedOrderBy('emulator.name', 'desc')).toEqual({
+ emulator: { name: 'desc' },
+ })
+ })
+
+ it('maps device into a composite brand+model sort array', () => {
+ expect(buildProcessedOrderBy('device', 'asc')).toEqual([
+ { device: { brand: { name: 'asc' } } },
+ { device: { modelName: 'asc' } },
+ ])
+ })
+
+ it('maps processedAt explicitly (no change from default)', () => {
+ expect(buildProcessedOrderBy('processedAt', 'asc')).toEqual({ processedAt: 'asc' })
+ })
+})
diff --git a/src/server/api/utils/listingHelpers.ts b/src/server/api/utils/listingHelpers.ts
new file mode 100644
index 00000000..2ce5a232
--- /dev/null
+++ b/src/server/api/utils/listingHelpers.ts
@@ -0,0 +1,41 @@
+import type { Prisma } from '@orm'
+
+export type ProcessedSortField =
+ | 'processedAt'
+ | 'createdAt'
+ | 'status'
+ | 'game.title'
+ | 'game.system.name'
+ | 'device'
+ | 'emulator.name'
+ | 'author.name'
+
+export type ProcessedSortDirection = 'asc' | 'desc'
+
+export function buildProcessedOrderBy(
+ sortField: ProcessedSortField | null | undefined,
+ sortDirection: ProcessedSortDirection | null | undefined,
+): Prisma.ListingOrderByWithRelationInput | Prisma.ListingOrderByWithRelationInput[] {
+ const direction: Prisma.SortOrder = sortDirection ?? 'desc'
+
+ switch (sortField) {
+ case 'createdAt':
+ return { createdAt: direction }
+ case 'status':
+ return { status: direction }
+ case 'game.title':
+ return { game: { title: direction } }
+ case 'game.system.name':
+ return { game: { system: { name: direction } } }
+ case 'device':
+ return [{ device: { brand: { name: direction } } }, { device: { modelName: direction } }]
+ case 'emulator.name':
+ return { emulator: { name: direction } }
+ case 'author.name':
+ return { author: { name: direction } }
+ case 'processedAt':
+ case null:
+ case undefined:
+ return { processedAt: direction }
+ }
+}
diff --git a/src/server/repositories/listings.repository.ts b/src/server/repositories/listings.repository.ts
index 114bf378..073da543 100644
--- a/src/server/repositories/listings.repository.ts
+++ b/src/server/repositories/listings.repository.ts
@@ -2,6 +2,7 @@ import { PAGINATION } from '@/data/constants'
import { AppError, ResourceError } from '@/lib/errors'
import { canUserAutoApprove } from '@/lib/trust/service'
import { validateCustomFields } from '@/server/api/routers/listings/validation'
+import { computeVoteCounts } from '@/server/utils/moderator-info'
import { paginate, calculateOffset } from '@/server/utils/pagination'
import {
buildNsfwFilter,
@@ -737,4 +738,58 @@ export class ListingsRepository extends BaseRepository {
orderBy: { createdAt: 'desc' },
})
}
+
+ private static readonly moderatorInfoUserSelect = {
+ id: true,
+ name: true,
+ } as const
+
+ private static readonly moderatorInfoVoteSelect = {
+ id: true,
+ value: true,
+ createdAt: true,
+ nullifiedAt: true,
+ user: { select: { id: true, name: true, trustScore: true } },
+ } as const
+
+ async getModeratorInfo(listingId: string) {
+ const listing = await this.handleDatabaseOperation(
+ () =>
+ this.prisma.listing.findUnique({
+ where: { id: listingId },
+ select: {
+ status: true,
+ processedAt: true,
+ processedNotes: true,
+ processedByUser: { select: ListingsRepository.moderatorInfoUserSelect },
+ },
+ }),
+ 'Listing',
+ )
+
+ if (!listing) throw ResourceError.listing.notFound()
+
+ const votes = await this.handleDatabaseOperation(
+ () =>
+ this.prisma.vote.findMany({
+ where: { listingId },
+ orderBy: { createdAt: 'desc' },
+ select: ListingsRepository.moderatorInfoVoteSelect,
+ }),
+ 'Vote',
+ )
+
+ const voteCounts = computeVoteCounts(votes)
+
+ return {
+ approval: {
+ status: listing.status,
+ processedAt: listing.processedAt,
+ processedNotes: listing.processedNotes,
+ processedBy: listing.processedByUser,
+ },
+ votes,
+ voteCounts,
+ }
+ }
}
diff --git a/src/server/repositories/pc-listings.repository.ts b/src/server/repositories/pc-listings.repository.ts
index 9b5d8aa4..a6790136 100644
--- a/src/server/repositories/pc-listings.repository.ts
+++ b/src/server/repositories/pc-listings.repository.ts
@@ -6,6 +6,7 @@
import { PAGINATION } from '@/data/constants'
import { AppError, ResourceError } from '@/lib/errors'
import { canUserAutoApprove } from '@/lib/trust/service'
+import { computeVoteCounts } from '@/server/utils/moderator-info'
import { paginate, calculateOffset } from '@/server/utils/pagination'
import { sanitizeInput } from '@/server/utils/security-validation'
import { roleIncludesRole } from '@/utils/permission-system'
@@ -945,4 +946,58 @@ export class PcListingsRepository extends BaseRepository {
})
return !!verified
}
+
+ private static readonly moderatorInfoUserSelect = {
+ id: true,
+ name: true,
+ } as const
+
+ private static readonly moderatorInfoVoteSelect = {
+ id: true,
+ value: true,
+ createdAt: true,
+ nullifiedAt: true,
+ user: { select: { id: true, name: true, trustScore: true } },
+ } as const
+
+ async getModeratorInfo(pcListingId: string) {
+ const pcListing = await this.handleDatabaseOperation(
+ () =>
+ this.prisma.pcListing.findUnique({
+ where: { id: pcListingId },
+ select: {
+ status: true,
+ processedAt: true,
+ processedNotes: true,
+ processedByUser: { select: PcListingsRepository.moderatorInfoUserSelect },
+ },
+ }),
+ 'PcListing',
+ )
+
+ if (!pcListing) throw ResourceError.pcListing.notFound()
+
+ const votes = await this.handleDatabaseOperation(
+ () =>
+ this.prisma.pcListingVote.findMany({
+ where: { pcListingId },
+ orderBy: { createdAt: 'desc' },
+ select: PcListingsRepository.moderatorInfoVoteSelect,
+ }),
+ 'PcListingVote',
+ )
+
+ const voteCounts = computeVoteCounts(votes)
+
+ return {
+ approval: {
+ status: pcListing.status,
+ processedAt: pcListing.processedAt,
+ processedNotes: pcListing.processedNotes,
+ processedBy: pcListing.processedByUser,
+ },
+ votes,
+ voteCounts,
+ }
+ }
}
diff --git a/src/server/services/audit.service.ts b/src/server/services/audit.service.ts
index 364b4b4f..f8cd17c5 100644
--- a/src/server/services/audit.service.ts
+++ b/src/server/services/audit.service.ts
@@ -35,7 +35,9 @@ type Params = {
headers?: Headers | Record
}
-export async function logAudit(prisma: PrismaClient, params: Params): Promise {
+type PrismaLike = PrismaClient | Prisma.TransactionClient
+
+export async function logAudit(prisma: PrismaLike, params: Params): Promise {
const { actorId, action, entityType, entityId, targetUserId, metadata, headers } = params
const meta = extractRequestMeta(headers)
diff --git a/src/server/services/vote-nullification.service.test.ts b/src/server/services/vote-nullification.service.test.ts
index 9a469127..1934eb94 100644
--- a/src/server/services/vote-nullification.service.test.ts
+++ b/src/server/services/vote-nullification.service.test.ts
@@ -10,9 +10,13 @@ vi.mock('@/server/services/audit.service', () => ({
logAudit: vi.fn(),
}))
-const mockApplyManualTrustAdjustment = vi.fn()
+const mockApplyManualAdjustment = vi.fn()
+const mockApplyBulkManualAdjustments = vi.fn()
vi.mock('@/lib/trust/service', () => ({
- applyManualTrustAdjustment: (...args: unknown[]) => mockApplyManualTrustAdjustment(...args),
+ TrustService: vi.fn().mockImplementation(() => ({
+ applyManualAdjustment: (...args: unknown[]) => mockApplyManualAdjustment(...args),
+ applyBulkManualAdjustments: (...args: unknown[]) => mockApplyBulkManualAdjustments(...args),
+ })),
}))
vi.mock('@/utils/wilson-score', () => ({
@@ -28,22 +32,22 @@ function createMockPrisma() {
vote: {
findMany: vi.fn().mockResolvedValue([]),
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
- count: vi.fn().mockResolvedValue(0),
+ groupBy: vi.fn().mockResolvedValue([]),
},
pcListingVote: {
findMany: vi.fn().mockResolvedValue([]),
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
- count: vi.fn().mockResolvedValue(0),
+ groupBy: vi.fn().mockResolvedValue([]),
},
commentVote: {
findMany: vi.fn().mockResolvedValue([]),
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
- count: vi.fn().mockResolvedValue(0),
+ groupBy: vi.fn().mockResolvedValue([]),
},
pcListingCommentVote: {
findMany: vi.fn().mockResolvedValue([]),
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
- count: vi.fn().mockResolvedValue(0),
+ groupBy: vi.fn().mockResolvedValue([]),
},
listing: {
update: vi.fn().mockResolvedValue({}),
@@ -146,7 +150,8 @@ describe('vote-nullification.service', () => {
beforeEach(() => {
prisma = createMockPrisma()
- mockApplyManualTrustAdjustment.mockResolvedValue(undefined)
+ mockApplyManualAdjustment.mockResolvedValue(undefined)
+ mockApplyBulkManualAdjustments.mockResolvedValue(0)
})
describe('nullifyUserVotes', () => {
@@ -170,7 +175,7 @@ describe('vote-nullification.service', () => {
expect(prisma.vote.updateMany).not.toHaveBeenCalled()
expect(prisma.pcListingVote.updateMany).not.toHaveBeenCalled()
- expect(mockApplyManualTrustAdjustment).not.toHaveBeenCalled()
+ expect(mockApplyBulkManualAdjustments).not.toHaveBeenCalled()
})
it('nullifies handheld votes and sets nullifiedAt', async () => {
@@ -179,7 +184,6 @@ describe('vote-nullification.service', () => {
makeHandheldVote({ id: 'hv-2', value: false, authorId: AUTHOR_B }),
]
prisma.vote.findMany.mockResolvedValue(votes)
- prisma.vote.count.mockResolvedValue(0)
const result = await nullifyUserVotes(prisma as unknown as PrismaClient, {
userId: USER_ID,
@@ -198,7 +202,6 @@ describe('vote-nullification.service', () => {
it('nullifies PC votes', async () => {
const votes = [makePcVote({ id: 'pv-1' }), makePcVote({ id: 'pv-2' })]
prisma.pcListingVote.findMany.mockResolvedValue(votes)
- prisma.pcListingVote.count.mockResolvedValue(0)
const result = await nullifyUserVotes(prisma as unknown as PrismaClient, {
userId: USER_ID,
@@ -235,7 +238,6 @@ describe('vote-nullification.service', () => {
it('skips comment votes when includeCommentVotes is false', async () => {
prisma.vote.findMany.mockResolvedValue([makeHandheldVote()])
- prisma.vote.count.mockResolvedValue(0)
const result = await nullifyUserVotes(prisma as unknown as PrismaClient, {
userId: USER_ID,
@@ -255,10 +257,6 @@ describe('vote-nullification.service', () => {
makeHandheldVote({ id: 'hv-1', listingId: 'listing-1', value: true }),
makeHandheldVote({ id: 'hv-2', listingId: 'listing-1', value: false }),
])
- // After nullification: 0 active votes remain
- prisma.vote.count
- .mockResolvedValueOnce(0) // upvotes for listing-1
- .mockResolvedValueOnce(0) // downvotes for listing-1
await nullifyUserVotes(prisma as unknown as PrismaClient, {
userId: USER_ID,
@@ -282,10 +280,6 @@ describe('vote-nullification.service', () => {
prisma.pcListingVote.findMany.mockResolvedValue([
makePcVote({ id: 'pv-1', pcListingId: 'pc-listing-1', value: true }),
])
- prisma.pcListingVote.count
- .mockResolvedValueOnce(0) // upvotes
- .mockResolvedValueOnce(0) // downvotes
-
await nullifyUserVotes(prisma as unknown as PrismaClient, {
userId: USER_ID,
adminUserId: ADMIN_ID,
@@ -308,9 +302,10 @@ describe('vote-nullification.service', () => {
prisma.commentVote.findMany.mockResolvedValue([
makeCommentVote({ id: 'cv-1', commentId: 'comment-1' }),
])
- prisma.commentVote.count
- .mockResolvedValueOnce(3) // remaining upvotes
- .mockResolvedValueOnce(1) // remaining downvotes
+ prisma.commentVote.groupBy.mockResolvedValue([
+ { commentId: 'comment-1', value: true, _count: { _all: 3 } },
+ { commentId: 'comment-1', value: false, _count: { _all: 1 } },
+ ])
await nullifyUserVotes(prisma as unknown as PrismaClient, {
userId: USER_ID,
@@ -329,9 +324,10 @@ describe('vote-nullification.service', () => {
prisma.pcListingCommentVote.findMany.mockResolvedValue([
makePcCommentVote({ id: 'pcv-1', commentId: 'pc-comment-1' }),
])
- prisma.pcListingCommentVote.count
- .mockResolvedValueOnce(5) // remaining upvotes
- .mockResolvedValueOnce(2) // remaining downvotes
+ prisma.pcListingCommentVote.groupBy.mockResolvedValue([
+ { commentId: 'pc-comment-1', value: true, _count: { _all: 5 } },
+ { commentId: 'pc-comment-1', value: false, _count: { _all: 2 } },
+ ])
await nullifyUserVotes(prisma as unknown as PrismaClient, {
userId: USER_ID,
@@ -351,7 +347,6 @@ describe('vote-nullification.service', () => {
makeHandheldVote({ id: 'hv-1', value: true }),
makeHandheldVote({ id: 'hv-2', value: false, authorId: AUTHOR_B }),
])
- prisma.vote.count.mockResolvedValue(0)
await nullifyUserVotes(prisma as unknown as PrismaClient, {
userId: USER_ID,
@@ -361,11 +356,11 @@ describe('vote-nullification.service', () => {
})
// Voter should get -1 per handheld vote (total -2)
- const voterCall = mockApplyManualTrustAdjustment.mock.calls.find(
- (call: unknown[]) => (call[0] as { userId: string }).userId === USER_ID,
- )
- expect(voterCall).toBeDefined()
- expect((voterCall![0] as { adjustment: number }).adjustment).toBe(-2)
+ expect(mockApplyBulkManualAdjustments).toHaveBeenCalledTimes(1)
+ const { adjustments } = mockApplyBulkManualAdjustments.mock.calls[0][0] as {
+ adjustments: Map
+ }
+ expect(adjustments.get(USER_ID)).toBe(-2)
})
it('reverses author trust: -2 for upvote, +1 for downvote', async () => {
@@ -373,7 +368,6 @@ describe('vote-nullification.service', () => {
makeHandheldVote({ id: 'hv-1', value: true, authorId: AUTHOR_A }),
makeHandheldVote({ id: 'hv-2', value: false, authorId: AUTHOR_A }),
])
- prisma.vote.count.mockResolvedValue(0)
await nullifyUserVotes(prisma as unknown as PrismaClient, {
userId: USER_ID,
@@ -383,18 +377,17 @@ describe('vote-nullification.service', () => {
})
// Author A: upvote reversed (-2) + downvote reversed (+1) = -1
- const authorCall = mockApplyManualTrustAdjustment.mock.calls.find(
- (call: unknown[]) => (call[0] as { userId: string }).userId === AUTHOR_A,
- )
- expect(authorCall).toBeDefined()
- expect((authorCall![0] as { adjustment: number }).adjustment).toBe(-1)
+ expect(mockApplyBulkManualAdjustments).toHaveBeenCalledTimes(1)
+ const { adjustments } = mockApplyBulkManualAdjustments.mock.calls[0][0] as {
+ adjustments: Map
+ }
+ expect(adjustments.get(AUTHOR_A)).toBe(-1)
})
it('skips author trust reversal for self-votes', async () => {
prisma.vote.findMany.mockResolvedValue([
makeHandheldVote({ id: 'hv-1', value: true, authorId: USER_ID }),
])
- prisma.vote.count.mockResolvedValue(0)
await nullifyUserVotes(prisma as unknown as PrismaClient, {
userId: USER_ID,
@@ -404,18 +397,19 @@ describe('vote-nullification.service', () => {
})
// Only the voter trust should be adjusted (not author, since it's a self-vote)
- expect(mockApplyManualTrustAdjustment).toHaveBeenCalledTimes(1)
- expect(mockApplyManualTrustAdjustment).toHaveBeenCalledWith(
- expect.objectContaining({ userId: USER_ID, adjustment: -1 }),
- )
+ expect(mockApplyBulkManualAdjustments).toHaveBeenCalledTimes(1)
+ const { adjustments } = mockApplyBulkManualAdjustments.mock.calls[0][0] as {
+ adjustments: Map
+ }
+ expect(adjustments.get(USER_ID)).toBe(-1)
+ expect(adjustments.size).toBe(1)
})
- it('does NOT reverse trust for PC votes', async () => {
+ it('reverses trust for PC votes (same as handheld)', async () => {
prisma.pcListingVote.findMany.mockResolvedValue([
- makePcVote({ id: 'pv-1', value: true }),
- makePcVote({ id: 'pv-2', value: false }),
+ makePcVote({ id: 'pv-1', value: true, authorId: AUTHOR_B }),
+ makePcVote({ id: 'pv-2', value: false, authorId: AUTHOR_B }),
])
- prisma.pcListingVote.count.mockResolvedValue(0)
await nullifyUserVotes(prisma as unknown as PrismaClient, {
userId: USER_ID,
@@ -424,7 +418,14 @@ describe('vote-nullification.service', () => {
includeCommentVotes: false,
})
- expect(mockApplyManualTrustAdjustment).not.toHaveBeenCalled()
+ expect(mockApplyBulkManualAdjustments).toHaveBeenCalledTimes(1)
+ const { adjustments } = mockApplyBulkManualAdjustments.mock.calls[0][0] as {
+ adjustments: Map
+ }
+ // Voter: -1 per vote = -2 total
+ expect(adjustments.get(USER_ID)).toBe(-2)
+ // Author B: upvote reversed (-2) + downvote reversed (+1) = -1
+ expect(adjustments.get(AUTHOR_B)).toBe(-1)
})
it('reverses comment author trust for handheld comment upvotes/downvotes', async () => {
@@ -432,7 +433,6 @@ describe('vote-nullification.service', () => {
makeCommentVote({ id: 'cv-1', value: true, commentUserId: AUTHOR_A }),
makeCommentVote({ id: 'cv-2', value: false, commentUserId: AUTHOR_A }),
])
- prisma.commentVote.count.mockResolvedValue(0)
await nullifyUserVotes(prisma as unknown as PrismaClient, {
userId: USER_ID,
@@ -442,18 +442,17 @@ describe('vote-nullification.service', () => {
})
// Author A comment trust: upvote reversed (-2) + downvote reversed (+1) = -1
- const authorCall = mockApplyManualTrustAdjustment.mock.calls.find(
- (call: unknown[]) => (call[0] as { userId: string }).userId === AUTHOR_A,
- )
- expect(authorCall).toBeDefined()
- expect((authorCall![0] as { adjustment: number }).adjustment).toBe(-1)
+ expect(mockApplyBulkManualAdjustments).toHaveBeenCalledTimes(1)
+ const { adjustments } = mockApplyBulkManualAdjustments.mock.calls[0][0] as {
+ adjustments: Map
+ }
+ expect(adjustments.get(AUTHOR_A)).toBe(-1)
})
it('skips comment author trust for self-votes on own comments', async () => {
prisma.commentVote.findMany.mockResolvedValue([
makeCommentVote({ id: 'cv-1', value: true, commentUserId: USER_ID }),
])
- prisma.commentVote.count.mockResolvedValue(0)
await nullifyUserVotes(prisma as unknown as PrismaClient, {
userId: USER_ID,
@@ -462,25 +461,26 @@ describe('vote-nullification.service', () => {
includeCommentVotes: true,
})
- // No trust adjustment for comment self-votes
- expect(mockApplyManualTrustAdjustment).not.toHaveBeenCalled()
+ // No trust adjustment for comment self-votes β map is empty
+ expect(mockApplyBulkManualAdjustments).toHaveBeenCalledTimes(1)
+ const { adjustments } = mockApplyBulkManualAdjustments.mock.calls[0][0] as {
+ adjustments: Map
+ }
+ expect(adjustments.size).toBe(0)
})
- it('continues when trust adjustment fails and logs error', async () => {
+ it('propagates trust adjustment errors for transactional rollback', async () => {
prisma.vote.findMany.mockResolvedValue([makeHandheldVote({ id: 'hv-1', value: true })])
- prisma.vote.count.mockResolvedValue(0)
- mockApplyManualTrustAdjustment.mockRejectedValue(new Error('Trust service down'))
-
- const result = await nullifyUserVotes(prisma as unknown as PrismaClient, {
- userId: USER_ID,
- adminUserId: ADMIN_ID,
- reason: 'Spam',
- includeCommentVotes: false,
- })
-
- // Should still return counts despite trust failure
- expect(result.handheldVotesNullified).toBe(1)
- expect(result.trustAdjustments).toBe(0)
+ mockApplyBulkManualAdjustments.mockRejectedValue(new Error('Trust service down'))
+
+ await expect(
+ nullifyUserVotes(prisma as unknown as PrismaClient, {
+ userId: USER_ID,
+ adminUserId: ADMIN_ID,
+ reason: 'Spam',
+ includeCommentVotes: false,
+ }),
+ ).rejects.toThrow('Trust service down')
})
it('batches nullification for more than 100 votes', async () => {
@@ -488,7 +488,6 @@ describe('vote-nullification.service', () => {
makeHandheldVote({ id: `hv-${i}`, listingId: `listing-${i % 5}` }),
)
prisma.vote.findMany.mockResolvedValue(votes)
- prisma.vote.count.mockResolvedValue(0)
await nullifyUserVotes(prisma as unknown as PrismaClient, {
userId: USER_ID,
@@ -511,7 +510,6 @@ describe('vote-nullification.service', () => {
makeHandheldVote({ id: 'hv-2', listingId: 'listing-1' }),
makeHandheldVote({ id: 'hv-3', listingId: 'listing-2' }),
])
- prisma.vote.count.mockResolvedValue(0)
const result = await nullifyUserVotes(prisma as unknown as PrismaClient, {
userId: USER_ID,
@@ -528,8 +526,6 @@ describe('vote-nullification.service', () => {
const { logAudit } = await import('@/server/services/audit.service')
prisma.vote.findMany.mockResolvedValue([makeHandheldVote()])
prisma.pcListingVote.findMany.mockResolvedValue([makePcVote()])
- prisma.vote.count.mockResolvedValue(0)
- prisma.pcListingVote.count.mockResolvedValue(0)
await nullifyUserVotes(prisma as unknown as PrismaClient, {
userId: USER_ID,
@@ -559,10 +555,6 @@ describe('vote-nullification.service', () => {
prisma.pcListingCommentVote.findMany.mockResolvedValue([
makePcCommentVote({ id: 'pcv-1', value: false }),
])
- prisma.vote.count.mockResolvedValue(0)
- prisma.pcListingVote.count.mockResolvedValue(0)
- prisma.commentVote.count.mockResolvedValue(0)
- prisma.pcListingCommentVote.count.mockResolvedValue(0)
const result = await nullifyUserVotes(prisma as unknown as PrismaClient, {
userId: USER_ID,
@@ -606,7 +598,6 @@ describe('vote-nullification.service', () => {
{ ...makeHandheldVote({ id: 'hv-2' }), nullifiedAt: new Date() },
]
prisma.vote.findMany.mockResolvedValue(nullifiedVotes)
- prisma.vote.count.mockResolvedValue(0)
const result = await restoreUserVotes(prisma as unknown as PrismaClient, {
userId: USER_ID,
@@ -624,7 +615,6 @@ describe('vote-nullification.service', () => {
it('clears nullifiedAt on PC votes', async () => {
const nullifiedVotes = [{ ...makePcVote({ id: 'pv-1' }), nullifiedAt: new Date() }]
prisma.pcListingVote.findMany.mockResolvedValue(nullifiedVotes)
- prisma.pcListingVote.count.mockResolvedValue(0)
const result = await restoreUserVotes(prisma as unknown as PrismaClient, {
userId: USER_ID,
@@ -646,8 +636,6 @@ describe('vote-nullification.service', () => {
prisma.pcListingCommentVote.findMany.mockResolvedValue([
{ ...makePcCommentVote({ id: 'pcv-1' }), nullifiedAt: new Date() },
])
- prisma.commentVote.count.mockResolvedValue(0)
- prisma.pcListingCommentVote.count.mockResolvedValue(0)
const result = await restoreUserVotes(prisma as unknown as PrismaClient, {
userId: USER_ID,
@@ -666,9 +654,10 @@ describe('vote-nullification.service', () => {
nullifiedAt: new Date(),
},
])
- prisma.vote.count
- .mockResolvedValueOnce(5) // upvotes after restore
- .mockResolvedValueOnce(2) // downvotes after restore
+ prisma.vote.groupBy.mockResolvedValue([
+ { listingId: 'listing-1', value: true, _count: { _all: 5 } },
+ { listingId: 'listing-1', value: false, _count: { _all: 2 } },
+ ])
await restoreUserVotes(prisma as unknown as PrismaClient, {
userId: USER_ID,
@@ -691,9 +680,10 @@ describe('vote-nullification.service', () => {
prisma.commentVote.findMany.mockResolvedValue([
{ ...makeCommentVote({ id: 'cv-1', commentId: 'comment-1' }), nullifiedAt: new Date() },
])
- prisma.commentVote.count
- .mockResolvedValueOnce(4) // upvotes
- .mockResolvedValueOnce(1) // downvotes
+ prisma.commentVote.groupBy.mockResolvedValue([
+ { commentId: 'comment-1', value: true, _count: { _all: 4 } },
+ { commentId: 'comment-1', value: false, _count: { _all: 1 } },
+ ])
await restoreUserVotes(prisma as unknown as PrismaClient, {
userId: USER_ID,
@@ -715,7 +705,6 @@ describe('vote-nullification.service', () => {
nullifiedAt: new Date(),
},
])
- prisma.vote.count.mockResolvedValue(0)
await restoreUserVotes(prisma as unknown as PrismaClient, {
userId: USER_ID,
@@ -724,11 +713,11 @@ describe('vote-nullification.service', () => {
})
// Voter should get +1 per handheld vote (total +2)
- const voterCall = mockApplyManualTrustAdjustment.mock.calls.find(
- (call: unknown[]) => (call[0] as { userId: string }).userId === USER_ID,
- )
- expect(voterCall).toBeDefined()
- expect((voterCall![0] as { adjustment: number }).adjustment).toBe(2)
+ expect(mockApplyBulkManualAdjustments).toHaveBeenCalledTimes(1)
+ const { adjustments } = mockApplyBulkManualAdjustments.mock.calls[0][0] as {
+ adjustments: Map
+ }
+ expect(adjustments.get(USER_ID)).toBe(2)
})
it('re-applies author trust: +2 for upvotes, -1 for downvotes', async () => {
@@ -742,7 +731,6 @@ describe('vote-nullification.service', () => {
nullifiedAt: new Date(),
},
])
- prisma.vote.count.mockResolvedValue(0)
await restoreUserVotes(prisma as unknown as PrismaClient, {
userId: USER_ID,
@@ -751,11 +739,11 @@ describe('vote-nullification.service', () => {
})
// Author A: upvote re-applied (+2) + downvote re-applied (-1) = +1
- const authorCall = mockApplyManualTrustAdjustment.mock.calls.find(
- (call: unknown[]) => (call[0] as { userId: string }).userId === AUTHOR_A,
- )
- expect(authorCall).toBeDefined()
- expect((authorCall![0] as { adjustment: number }).adjustment).toBe(1)
+ expect(mockApplyBulkManualAdjustments).toHaveBeenCalledTimes(1)
+ const { adjustments } = mockApplyBulkManualAdjustments.mock.calls[0][0] as {
+ adjustments: Map
+ }
+ expect(adjustments.get(AUTHOR_A)).toBe(1)
})
it('skips author trust re-application for self-votes', async () => {
@@ -765,7 +753,6 @@ describe('vote-nullification.service', () => {
nullifiedAt: new Date(),
},
])
- prisma.vote.count.mockResolvedValue(0)
await restoreUserVotes(prisma as unknown as PrismaClient, {
userId: USER_ID,
@@ -773,17 +760,22 @@ describe('vote-nullification.service', () => {
reason: 'Wrongful ban',
})
- expect(mockApplyManualTrustAdjustment).toHaveBeenCalledTimes(1)
- expect(mockApplyManualTrustAdjustment).toHaveBeenCalledWith(
- expect.objectContaining({ userId: USER_ID, adjustment: 1 }),
- )
+ expect(mockApplyBulkManualAdjustments).toHaveBeenCalledTimes(1)
+ const { adjustments } = mockApplyBulkManualAdjustments.mock.calls[0][0] as {
+ adjustments: Map
+ }
+ expect(adjustments.get(USER_ID)).toBe(1)
+ expect(adjustments.size).toBe(1)
})
- it('does NOT re-apply trust for PC votes', async () => {
+ it('re-applies trust for PC votes (same as handheld)', async () => {
prisma.pcListingVote.findMany.mockResolvedValue([
- { ...makePcVote({ id: 'pv-1' }), nullifiedAt: new Date() },
+ { ...makePcVote({ id: 'pv-1', value: true, authorId: AUTHOR_B }), nullifiedAt: new Date() },
+ {
+ ...makePcVote({ id: 'pv-2', value: false, authorId: AUTHOR_B }),
+ nullifiedAt: new Date(),
+ },
])
- prisma.pcListingVote.count.mockResolvedValue(0)
await restoreUserVotes(prisma as unknown as PrismaClient, {
userId: USER_ID,
@@ -791,7 +783,14 @@ describe('vote-nullification.service', () => {
reason: 'Wrongful ban',
})
- expect(mockApplyManualTrustAdjustment).not.toHaveBeenCalled()
+ expect(mockApplyBulkManualAdjustments).toHaveBeenCalledTimes(1)
+ const { adjustments } = mockApplyBulkManualAdjustments.mock.calls[0][0] as {
+ adjustments: Map
+ }
+ // Voter: +1 per vote = +2 total
+ expect(adjustments.get(USER_ID)).toBe(2)
+ // Author B: upvote re-applied (+2) + downvote re-applied (-1) = +1
+ expect(adjustments.get(AUTHOR_B)).toBe(1)
})
it('batches restoration for more than 100 votes', async () => {
@@ -800,7 +799,6 @@ describe('vote-nullification.service', () => {
nullifiedAt: new Date(),
}))
prisma.vote.findMany.mockResolvedValue(votes)
- prisma.vote.count.mockResolvedValue(0)
await restoreUserVotes(prisma as unknown as PrismaClient, {
userId: USER_ID,
@@ -821,8 +819,6 @@ describe('vote-nullification.service', () => {
prisma.pcListingVote.findMany.mockResolvedValue([
{ ...makePcVote(), nullifiedAt: new Date() },
])
- prisma.vote.count.mockResolvedValue(0)
- prisma.pcListingVote.count.mockResolvedValue(0)
await restoreUserVotes(prisma as unknown as PrismaClient, {
userId: USER_ID,
@@ -855,7 +851,6 @@ describe('vote-nullification.service', () => {
nullifiedAt: new Date(),
},
])
- prisma.commentVote.count.mockResolvedValue(0)
await restoreUserVotes(prisma as unknown as PrismaClient, {
userId: USER_ID,
@@ -863,19 +858,14 @@ describe('vote-nullification.service', () => {
reason: 'Wrongful ban',
})
+ expect(mockApplyBulkManualAdjustments).toHaveBeenCalledTimes(1)
+ const { adjustments } = mockApplyBulkManualAdjustments.mock.calls[0][0] as {
+ adjustments: Map
+ }
// Author A: upvote re-applied (+2)
- const authorACall = mockApplyManualTrustAdjustment.mock.calls.find(
- (call: unknown[]) => (call[0] as { userId: string }).userId === AUTHOR_A,
- )
- expect(authorACall).toBeDefined()
- expect((authorACall![0] as { adjustment: number }).adjustment).toBe(2)
-
+ expect(adjustments.get(AUTHOR_A)).toBe(2)
// Author B: downvote re-applied (-1)
- const authorBCall = mockApplyManualTrustAdjustment.mock.calls.find(
- (call: unknown[]) => (call[0] as { userId: string }).userId === AUTHOR_B,
- )
- expect(authorBCall).toBeDefined()
- expect((authorBCall![0] as { adjustment: number }).adjustment).toBe(-1)
+ expect(adjustments.get(AUTHOR_B)).toBe(-1)
})
})
@@ -890,8 +880,6 @@ describe('vote-nullification.service', () => {
// Phase 1: Nullify
prisma.vote.findMany.mockResolvedValue(votes)
prisma.commentVote.findMany.mockResolvedValue(commentVotes)
- prisma.vote.count.mockResolvedValue(0)
- prisma.commentVote.count.mockResolvedValue(0)
const nullifyResult = await nullifyUserVotes(prisma as unknown as PrismaClient, {
userId: USER_ID,
@@ -904,14 +892,15 @@ describe('vote-nullification.service', () => {
expect(nullifyResult.commentVotesNullified).toBe(1)
// Capture nullification trust adjustments
- const nullifyAdjustments = new Map()
- for (const call of mockApplyManualTrustAdjustment.mock.calls) {
- const { userId, adjustment } = call[0] as { userId: string; adjustment: number }
- nullifyAdjustments.set(userId, (nullifyAdjustments.get(userId) ?? 0) + adjustment)
- }
+ expect(mockApplyBulkManualAdjustments).toHaveBeenCalledTimes(1)
+ const nullifyAdjustments = mockApplyBulkManualAdjustments.mock.calls[0][0].adjustments as Map<
+ string,
+ number
+ >
// Phase 2: Restore
- mockApplyManualTrustAdjustment.mockClear()
+ mockApplyBulkManualAdjustments.mockClear()
+ mockApplyBulkManualAdjustments.mockResolvedValue(0)
prisma = createMockPrisma()
const nullifiedVotes = votes.map((v) => ({ ...v, nullifiedAt: new Date() }))
@@ -919,8 +908,6 @@ describe('vote-nullification.service', () => {
prisma.vote.findMany.mockResolvedValue(nullifiedVotes)
prisma.commentVote.findMany.mockResolvedValue(nullifiedCommentVotes)
- prisma.vote.count.mockResolvedValue(0)
- prisma.commentVote.count.mockResolvedValue(0)
const restoreResult = await restoreUserVotes(prisma as unknown as PrismaClient, {
userId: USER_ID,
@@ -932,11 +919,11 @@ describe('vote-nullification.service', () => {
expect(restoreResult.commentVotesRestored).toBe(1)
// Capture restoration trust adjustments
- const restoreAdjustments = new Map()
- for (const call of mockApplyManualTrustAdjustment.mock.calls) {
- const { userId, adjustment } = call[0] as { userId: string; adjustment: number }
- restoreAdjustments.set(userId, (restoreAdjustments.get(userId) ?? 0) + adjustment)
- }
+ expect(mockApplyBulkManualAdjustments).toHaveBeenCalledTimes(1)
+ const restoreAdjustments = mockApplyBulkManualAdjustments.mock.calls[0][0].adjustments as Map<
+ string,
+ number
+ >
// Net trust adjustment for each user should be zero
for (const [uid, nullifyAdj] of nullifyAdjustments) {
@@ -950,7 +937,6 @@ describe('vote-nullification.service', () => {
// Phase 1: Nullify
prisma.vote.findMany.mockResolvedValue([vote])
- prisma.vote.count.mockResolvedValue(0)
await nullifyUserVotes(prisma as unknown as PrismaClient, {
userId: USER_ID,
@@ -968,9 +954,9 @@ describe('vote-nullification.service', () => {
// Phase 2: Restore
prisma = createMockPrisma()
prisma.vote.findMany.mockResolvedValue([{ ...vote, nullifiedAt: new Date() }])
- prisma.vote.count
- .mockResolvedValueOnce(1) // upvotes after restore
- .mockResolvedValueOnce(0) // downvotes after restore
+ prisma.vote.groupBy.mockResolvedValue([
+ { listingId: 'listing-1', value: true, _count: { _all: 1 } },
+ ])
await restoreUserVotes(prisma as unknown as PrismaClient, {
userId: USER_ID,
diff --git a/src/server/services/vote-nullification.service.ts b/src/server/services/vote-nullification.service.ts
index 2f31713c..d91c7e72 100644
--- a/src/server/services/vote-nullification.service.ts
+++ b/src/server/services/vote-nullification.service.ts
@@ -1,10 +1,11 @@
-import { logger } from '@/lib/logger'
import { TRUST_ACTIONS } from '@/lib/trust/config'
-import { applyManualTrustAdjustment } from '@/lib/trust/service'
+import { TrustService } from '@/lib/trust/service'
import { type ListingType } from '@/schemas/common'
import { logAudit } from '@/server/services/audit.service'
import { calculateWilsonScore } from '@/utils/wilson-score'
-import { AuditAction, AuditEntityType, TrustAction, type PrismaClient } from '@orm'
+import { AuditAction, AuditEntityType, TrustAction, type Prisma, type PrismaClient } from '@orm'
+
+type PrismaClientOrTransaction = PrismaClient | Prisma.TransactionClient
interface NullifyParams {
userId: string
@@ -43,86 +44,162 @@ interface RestoreResult {
const BATCH_SIZE = 100
+interface VoteIdentifier {
+ id: string
+}
+
+interface NullifiableDelegate {
+ updateMany: (args: {
+ where: { id: { in: string[] } }
+ data: { nullifiedAt: Date | null }
+ }) => Promise<{ count: number }>
+}
+
+/**
+ * Updates `nullifiedAt` on a set of votes in serial batches of `BATCH_SIZE`.
+ * Batching is serial within one delegate (avoids exhausting the connection pool);
+ * the caller can run multiple `batchUpdateNullifiedAt` invocations in parallel
+ * across different delegates (different tables) via `Promise.all`.
+ */
+async function batchUpdateNullifiedAt(
+ delegate: NullifiableDelegate,
+ votes: readonly T[],
+ nullifiedAt: Date | null,
+): Promise {
+ for (let i = 0; i < votes.length; i += BATCH_SIZE) {
+ const batch = votes.slice(i, i + BATCH_SIZE)
+ await delegate.updateMany({
+ where: { id: { in: batch.map((v) => v.id) } },
+ data: { nullifiedAt },
+ })
+ }
+}
+
+interface VoteScoreEntry {
+ up: number
+ down: number
+}
+
async function recalculateListingScores(
- prisma: PrismaClient,
+ prisma: PrismaClientOrTransaction,
listingIds: Set,
type: ListingType,
): Promise {
- let count = 0
- for (const listingId of listingIds) {
- if (type === 'handheld') {
- const [upvotes, downvotes] = await Promise.all([
- prisma.vote.count({ where: { listingId, value: true, nullifiedAt: null } }),
- prisma.vote.count({ where: { listingId, value: false, nullifiedAt: null } }),
- ])
+ if (listingIds.size === 0) return 0
+
+ const ids = [...listingIds]
+ const scoreMap = new Map()
+ for (const id of ids) scoreMap.set(id, { up: 0, down: 0 })
+
+ if (type === 'handheld') {
+ const counts = await prisma.vote.groupBy({
+ by: ['listingId', 'value'],
+ where: { listingId: { in: ids }, nullifiedAt: null },
+ _count: { _all: true },
+ })
+ for (const row of counts) {
+ const entry = scoreMap.get(row.listingId)
+ if (entry) {
+ if (row.value) entry.up = row._count._all
+ else entry.down = row._count._all
+ }
+ }
+ for (const [id, { up, down }] of scoreMap) {
await prisma.listing.update({
- where: { id: listingId },
+ where: { id },
data: {
- upvoteCount: upvotes,
- downvoteCount: downvotes,
- voteCount: upvotes + downvotes,
- successRate: calculateWilsonScore(upvotes, downvotes),
+ upvoteCount: up,
+ downvoteCount: down,
+ voteCount: up + down,
+ successRate: calculateWilsonScore(up, down),
},
})
- } else {
- const [upvotes, downvotes] = await Promise.all([
- prisma.pcListingVote.count({
- where: { pcListingId: listingId, value: true, nullifiedAt: null },
- }),
- prisma.pcListingVote.count({
- where: { pcListingId: listingId, value: false, nullifiedAt: null },
- }),
- ])
+ }
+ } else {
+ const counts = await prisma.pcListingVote.groupBy({
+ by: ['pcListingId', 'value'],
+ where: { pcListingId: { in: ids }, nullifiedAt: null },
+ _count: { _all: true },
+ })
+ for (const row of counts) {
+ const entry = scoreMap.get(row.pcListingId)
+ if (entry) {
+ if (row.value) entry.up = row._count._all
+ else entry.down = row._count._all
+ }
+ }
+ for (const [id, { up, down }] of scoreMap) {
await prisma.pcListing.update({
- where: { id: listingId },
+ where: { id },
data: {
- upvoteCount: upvotes,
- downvoteCount: downvotes,
- voteCount: upvotes + downvotes,
- successRate: calculateWilsonScore(upvotes, downvotes),
+ upvoteCount: up,
+ downvoteCount: down,
+ voteCount: up + down,
+ successRate: calculateWilsonScore(up, down),
},
})
}
- count++
}
- return count
+
+ return listingIds.size
}
async function recalculateCommentScores(
- prisma: PrismaClient,
+ prisma: PrismaClientOrTransaction,
commentIds: Set,
type: ListingType,
): Promise {
- let count = 0
- for (const commentId of commentIds) {
- if (type === 'handheld') {
- const [upvotes, downvotes] = await Promise.all([
- prisma.commentVote.count({ where: { commentId, value: true, nullifiedAt: null } }),
- prisma.commentVote.count({ where: { commentId, value: false, nullifiedAt: null } }),
- ])
+ if (commentIds.size === 0) return 0
+
+ const ids = [...commentIds]
+ const scoreMap = new Map()
+ for (const id of ids) scoreMap.set(id, { up: 0, down: 0 })
+
+ if (type === 'handheld') {
+ const counts = await prisma.commentVote.groupBy({
+ by: ['commentId', 'value'],
+ where: { commentId: { in: ids }, nullifiedAt: null },
+ _count: { _all: true },
+ })
+ for (const row of counts) {
+ const entry = scoreMap.get(row.commentId)
+ if (entry) {
+ if (row.value) entry.up = row._count._all
+ else entry.down = row._count._all
+ }
+ }
+ for (const [id, { up, down }] of scoreMap) {
await prisma.comment.update({
- where: { id: commentId },
- data: { score: upvotes - downvotes },
+ where: { id },
+ data: { score: up - down },
})
- } else {
- const [upvotes, downvotes] = await Promise.all([
- prisma.pcListingCommentVote.count({ where: { commentId, value: true, nullifiedAt: null } }),
- prisma.pcListingCommentVote.count({
- where: { commentId, value: false, nullifiedAt: null },
- }),
- ])
+ }
+ } else {
+ const counts = await prisma.pcListingCommentVote.groupBy({
+ by: ['commentId', 'value'],
+ where: { commentId: { in: ids }, nullifiedAt: null },
+ _count: { _all: true },
+ })
+ for (const row of counts) {
+ const entry = scoreMap.get(row.commentId)
+ if (entry) {
+ if (row.value) entry.up = row._count._all
+ else entry.down = row._count._all
+ }
+ }
+ for (const [id, { up, down }] of scoreMap) {
await prisma.pcListingComment.update({
- where: { id: commentId },
- data: { score: upvotes - downvotes },
+ where: { id },
+ data: { score: up - down },
})
}
- count++
}
- return count
+
+ return commentIds.size
}
export async function nullifyUserVotes(
- prisma: PrismaClient,
+ prisma: PrismaClientOrTransaction,
params: NullifyParams,
): Promise {
const { userId, adminUserId, reason, includeCommentVotes, headers } = params
@@ -175,40 +252,16 @@ export async function nullifyUserVotes(
const now = new Date()
- // 2. Batch nullify votes
- for (let i = 0; i < handheldVotes.length; i += BATCH_SIZE) {
- const batch = handheldVotes.slice(i, i + BATCH_SIZE)
- await prisma.vote.updateMany({
- where: { id: { in: batch.map((v) => v.id) } },
- data: { nullifiedAt: now },
- })
- }
-
- for (let i = 0; i < pcVotes.length; i += BATCH_SIZE) {
- const batch = pcVotes.slice(i, i + BATCH_SIZE)
- await prisma.pcListingVote.updateMany({
- where: { id: { in: batch.map((v) => v.id) } },
- data: { nullifiedAt: now },
- })
- }
-
- for (let i = 0; i < commentVotes.length; i += BATCH_SIZE) {
- const batch = commentVotes.slice(i, i + BATCH_SIZE)
- await prisma.commentVote.updateMany({
- where: { id: { in: batch.map((v) => v.id) } },
- data: { nullifiedAt: now },
- })
- }
-
- for (let i = 0; i < pcCommentVotes.length; i += BATCH_SIZE) {
- const batch = pcCommentVotes.slice(i, i + BATCH_SIZE)
- await prisma.pcListingCommentVote.updateMany({
- where: { id: { in: batch.map((v) => v.id) } },
- data: { nullifiedAt: now },
- })
- }
+ // 2. Batch nullify votes β across different vote types in parallel,
+ // serially batched within each type to keep connection-pool pressure bounded.
+ await Promise.all([
+ batchUpdateNullifiedAt(prisma.vote, handheldVotes, now),
+ batchUpdateNullifiedAt(prisma.pcListingVote, pcVotes, now),
+ batchUpdateNullifiedAt(prisma.commentVote, commentVotes, now),
+ batchUpdateNullifiedAt(prisma.pcListingCommentVote, pcCommentVotes, now),
+ ])
- // 3. Recalculate listing scores
+ // 3. Recalculate listing scores β parallel across distinct tables.
const handheldListingIds = new Set(handheldVotes.map((v) => v.listingId))
const pcListingIds = new Set(pcVotes.map((v) => v.pcListingId))
const handheldCommentIds = new Set(commentVotes.map((v) => v.commentId))
@@ -229,69 +282,56 @@ export async function nullifyUserVotes(
trustAdjustments.set(targetUserId, (trustAdjustments.get(targetUserId) ?? 0) + amount)
}
- // Handheld listing votes: reverse trust for voter and authors
+ // Reverse listing vote trust for both handheld and PC
for (const vote of handheldVotes) {
- // Reverse voter trust: voter got +1 for either UPVOTE or DOWNVOTE
- addAdjustment(userId, -TRUST_ACTIONS[TrustAction.UPVOTE].weight)
-
- // Reverse author trust (skip self-votes)
+ const voterAction = vote.value ? TrustAction.UPVOTE : TrustAction.DOWNVOTE
+ addAdjustment(userId, -TRUST_ACTIONS[voterAction].weight)
if (vote.listing.authorId && vote.listing.authorId !== userId) {
- if (vote.value) {
- // Was upvote: author got +2 (LISTING_RECEIVED_UPVOTE), reverse it
- addAdjustment(
- vote.listing.authorId,
- -TRUST_ACTIONS[TrustAction.LISTING_RECEIVED_UPVOTE].weight,
- )
- } else {
- // Was downvote: author got -1 (LISTING_RECEIVED_DOWNVOTE), reverse it
- addAdjustment(
- vote.listing.authorId,
- -TRUST_ACTIONS[TrustAction.LISTING_RECEIVED_DOWNVOTE].weight,
- )
- }
+ const action = vote.value
+ ? TrustAction.LISTING_RECEIVED_UPVOTE
+ : TrustAction.LISTING_RECEIVED_DOWNVOTE
+ addAdjustment(vote.listing.authorId, -TRUST_ACTIONS[action].weight)
+ }
+ }
+
+ for (const vote of pcVotes) {
+ const voterAction = vote.value ? TrustAction.UPVOTE : TrustAction.DOWNVOTE
+ addAdjustment(userId, -TRUST_ACTIONS[voterAction].weight)
+ if (vote.pcListing.authorId && vote.pcListing.authorId !== userId) {
+ const action = vote.value
+ ? TrustAction.LISTING_RECEIVED_UPVOTE
+ : TrustAction.LISTING_RECEIVED_DOWNVOTE
+ addAdjustment(vote.pcListing.authorId, -TRUST_ACTIONS[action].weight)
}
}
- // Handheld comment votes: reverse author trust (skip self-votes)
+ // Reverse comment vote trust for both handheld and PC
for (const vote of commentVotes) {
if (vote.comment.userId !== userId) {
- if (vote.value) {
- addAdjustment(
- vote.comment.userId,
- -TRUST_ACTIONS[TrustAction.COMMENT_RECEIVED_UPVOTE].weight,
- )
- } else {
- addAdjustment(
- vote.comment.userId,
- -TRUST_ACTIONS[TrustAction.COMMENT_RECEIVED_DOWNVOTE].weight,
- )
- }
+ const action = vote.value
+ ? TrustAction.COMMENT_RECEIVED_UPVOTE
+ : TrustAction.COMMENT_RECEIVED_DOWNVOTE
+ addAdjustment(vote.comment.userId, -TRUST_ACTIONS[action].weight)
}
}
- // PC votes: no trust reversal (trust was never applied for PC votes)
- // PC comment votes: no trust reversal (trust was never applied)
-
- // Apply trust adjustments
- let trustAdjustmentCount = 0
- for (const [targetUserId, adjustment] of trustAdjustments) {
- if (adjustment === 0) continue
- try {
- await applyManualTrustAdjustment({
- userId: targetUserId,
- adjustment,
- reason: `Vote nullification: ${reason}`,
- adminUserId,
- })
- trustAdjustmentCount++
- } catch (err) {
- logger.error(
- `[vote-nullification] Failed to apply trust adjustment for user ${targetUserId}:`,
- err,
- )
+ for (const vote of pcCommentVotes) {
+ if (vote.comment.userId !== userId) {
+ const action = vote.value
+ ? TrustAction.COMMENT_RECEIVED_UPVOTE
+ : TrustAction.COMMENT_RECEIVED_DOWNVOTE
+ addAdjustment(vote.comment.userId, -TRUST_ACTIONS[action].weight)
}
}
+ // Apply trust adjustments using TrustService for transaction participation
+ const trustService = new TrustService(prisma)
+ const trustAdjustmentCount = await trustService.applyBulkManualAdjustments({
+ adjustments: trustAdjustments,
+ reason: `Vote nullification: ${reason}`,
+ adminUserId,
+ })
+
// 5. Audit log
void logAudit(prisma, {
actorId: adminUserId,
@@ -323,7 +363,7 @@ export async function nullifyUserVotes(
}
export async function restoreUserVotes(
- prisma: PrismaClient,
+ prisma: PrismaClientOrTransaction,
params: RestoreParams,
): Promise {
const { userId, adminUserId, reason, headers } = params
@@ -370,40 +410,15 @@ export async function restoreUserVotes(
}
}
- // 2. Clear nullifiedAt
- for (let i = 0; i < handheldVotes.length; i += BATCH_SIZE) {
- const batch = handheldVotes.slice(i, i + BATCH_SIZE)
- await prisma.vote.updateMany({
- where: { id: { in: batch.map((v) => v.id) } },
- data: { nullifiedAt: null },
- })
- }
-
- for (let i = 0; i < pcVotes.length; i += BATCH_SIZE) {
- const batch = pcVotes.slice(i, i + BATCH_SIZE)
- await prisma.pcListingVote.updateMany({
- where: { id: { in: batch.map((v) => v.id) } },
- data: { nullifiedAt: null },
- })
- }
-
- for (let i = 0; i < commentVotes.length; i += BATCH_SIZE) {
- const batch = commentVotes.slice(i, i + BATCH_SIZE)
- await prisma.commentVote.updateMany({
- where: { id: { in: batch.map((v) => v.id) } },
- data: { nullifiedAt: null },
- })
- }
-
- for (let i = 0; i < pcCommentVotes.length; i += BATCH_SIZE) {
- const batch = pcCommentVotes.slice(i, i + BATCH_SIZE)
- await prisma.pcListingCommentVote.updateMany({
- where: { id: { in: batch.map((v) => v.id) } },
- data: { nullifiedAt: null },
- })
- }
+ // 2. Clear nullifiedAt β parallel across vote types, serial batches within each type.
+ await Promise.all([
+ batchUpdateNullifiedAt(prisma.vote, handheldVotes, null),
+ batchUpdateNullifiedAt(prisma.pcListingVote, pcVotes, null),
+ batchUpdateNullifiedAt(prisma.commentVote, commentVotes, null),
+ batchUpdateNullifiedAt(prisma.pcListingCommentVote, pcCommentVotes, null),
+ ])
- // 3. Recalculate scores
+ // 3. Recalculate scores β parallel across distinct tables.
const handheldListingIds = new Set(handheldVotes.map((v) => v.listingId))
const pcListingIds = new Set(pcVotes.map((v) => v.pcListingId))
const handheldCommentIds = new Set(commentVotes.map((v) => v.commentId))
@@ -423,61 +438,55 @@ export async function restoreUserVotes(
trustAdjustments.set(targetUserId, (trustAdjustments.get(targetUserId) ?? 0) + amount)
}
+ // Re-apply listing vote trust for both handheld and PC
for (const vote of handheldVotes) {
- // Re-apply voter trust
- addAdjustment(userId, TRUST_ACTIONS[TrustAction.UPVOTE].weight)
-
- // Re-apply author trust (skip self-votes)
+ const voterAction = vote.value ? TrustAction.UPVOTE : TrustAction.DOWNVOTE
+ addAdjustment(userId, TRUST_ACTIONS[voterAction].weight)
if (vote.listing.authorId && vote.listing.authorId !== userId) {
- if (vote.value) {
- addAdjustment(
- vote.listing.authorId,
- TRUST_ACTIONS[TrustAction.LISTING_RECEIVED_UPVOTE].weight,
- )
- } else {
- addAdjustment(
- vote.listing.authorId,
- TRUST_ACTIONS[TrustAction.LISTING_RECEIVED_DOWNVOTE].weight,
- )
- }
+ const action = vote.value
+ ? TrustAction.LISTING_RECEIVED_UPVOTE
+ : TrustAction.LISTING_RECEIVED_DOWNVOTE
+ addAdjustment(vote.listing.authorId, TRUST_ACTIONS[action].weight)
+ }
+ }
+
+ for (const vote of pcVotes) {
+ const voterAction = vote.value ? TrustAction.UPVOTE : TrustAction.DOWNVOTE
+ addAdjustment(userId, TRUST_ACTIONS[voterAction].weight)
+ if (vote.pcListing.authorId && vote.pcListing.authorId !== userId) {
+ const action = vote.value
+ ? TrustAction.LISTING_RECEIVED_UPVOTE
+ : TrustAction.LISTING_RECEIVED_DOWNVOTE
+ addAdjustment(vote.pcListing.authorId, TRUST_ACTIONS[action].weight)
}
}
+ // Re-apply comment vote trust for both handheld and PC
for (const vote of commentVotes) {
if (vote.comment.userId !== userId) {
- if (vote.value) {
- addAdjustment(
- vote.comment.userId,
- TRUST_ACTIONS[TrustAction.COMMENT_RECEIVED_UPVOTE].weight,
- )
- } else {
- addAdjustment(
- vote.comment.userId,
- TRUST_ACTIONS[TrustAction.COMMENT_RECEIVED_DOWNVOTE].weight,
- )
- }
+ const action = vote.value
+ ? TrustAction.COMMENT_RECEIVED_UPVOTE
+ : TrustAction.COMMENT_RECEIVED_DOWNVOTE
+ addAdjustment(vote.comment.userId, TRUST_ACTIONS[action].weight)
}
}
- let trustAdjustmentCount = 0
- for (const [targetUserId, adjustment] of trustAdjustments) {
- if (adjustment === 0) continue
- try {
- await applyManualTrustAdjustment({
- userId: targetUserId,
- adjustment,
- reason: `Vote restoration: ${reason}`,
- adminUserId,
- })
- trustAdjustmentCount++
- } catch (err) {
- logger.error(
- `[vote-restoration] Failed to apply trust adjustment for user ${targetUserId}:`,
- err,
- )
+ for (const vote of pcCommentVotes) {
+ if (vote.comment.userId !== userId) {
+ const action = vote.value
+ ? TrustAction.COMMENT_RECEIVED_UPVOTE
+ : TrustAction.COMMENT_RECEIVED_DOWNVOTE
+ addAdjustment(vote.comment.userId, TRUST_ACTIONS[action].weight)
}
}
+ const trustService = new TrustService(prisma)
+ const trustAdjustmentCount = await trustService.applyBulkManualAdjustments({
+ adjustments: trustAdjustments,
+ reason: `Vote restoration: ${reason}`,
+ adminUserId,
+ })
+
// 5. Audit log
void logAudit(prisma, {
actorId: adminUserId,
diff --git a/src/server/utils/emulator-config/gamenative/gamenative.converter.test.ts b/src/server/utils/emulator-config/gamenative/gamenative.converter.test.ts
index 96e54c87..0cc0463c 100644
--- a/src/server/utils/emulator-config/gamenative/gamenative.converter.test.ts
+++ b/src/server/utils/emulator-config/gamenative/gamenative.converter.test.ts
@@ -18,10 +18,10 @@ describe('GameNative Converter', () => {
expect(config).toMatchObject({
name: '',
- screenSize: '854x480',
+ screenSize: '1280x720',
graphicsDriver: 'vortek',
dxwrapper: 'dxvk',
- audioDriver: 'alsa',
+ audioDriver: 'pulseaudio',
startupSelection: 1,
box64Version: '0.3.6',
box86Version: '0.3.2',
@@ -30,6 +30,12 @@ describe('GameNative Converter', () => {
wow64Mode: true,
showFPS: false,
csmt: true,
+ containerVariant: 'glibc',
+ emulator: 'FEXCore',
+ fexcoreVersion: '2603',
+ fexcorePreset: 'INTERMEDIATE',
+ useSteamInput: false,
+ dinputMapperType: 1,
})
})
@@ -38,8 +44,8 @@ describe('GameNative Converter', () => {
{ input: '1920x1080 (16:9)', expected: '1920x1080' },
{ input: '2560x1440', expected: '2560x1440' },
{ input: '854x480 (16:9)', expected: '854x480' },
- { input: 'invalid', expected: '854x480' }, // Default fallback
- { input: '', expected: '854x480' }, // Empty fallback
+ { input: 'invalid', expected: '1280x720' },
+ { input: '', expected: '1280x720' },
]
testCases.forEach(({ input, expected }) => {
@@ -63,7 +69,6 @@ describe('GameNative Converter', () => {
})
it('should handle environment variables properly', () => {
- // Test with empty value - should use default
const configEmpty = convertToGameNativeConfig({
listingId: 'test',
gameId: 'game',
@@ -80,9 +85,8 @@ describe('GameNative Converter', () => {
})
expect(configEmpty.envVars).toContain('ZINK_DESCRIPTORS=lazy')
- expect(configEmpty.envVars).toContain('MESA_VK_WSI_PRESENT_MODE=mailbox')
+ expect(configEmpty.envVars).toContain('PULSE_LATENCY_MSEC=144')
- // Test with custom value
const configCustom = convertToGameNativeConfig({
listingId: 'test',
gameId: 'game',
@@ -104,11 +108,11 @@ describe('GameNative Converter', () => {
it('should map graphics driver names correctly', () => {
const testCases = [
{ input: 'Vortek (Universal)', expected: 'vortek' },
- { input: 'Mesa Turnip', expected: 'turnip' },
- { input: 'VirGL', expected: 'virgl' },
- { input: 'Freedreno', expected: 'vortek' }, // Not a valid driver, defaults to vortek
- { input: 'Custom Driver', expected: 'vortek' }, // Unknown defaults to vortek
- { input: '', expected: 'vortek' }, // Default when empty
+ { input: 'Turnip (Adreno)', expected: 'turnip' },
+ { input: 'VirGL (Universal)', expected: 'virgl' },
+ { input: 'Adreno (Adreno)', expected: 'adreno' },
+ { input: 'Custom Driver', expected: 'vortek' },
+ { input: '', expected: 'vortek' },
]
testCases.forEach(({ input, expected }) => {
@@ -120,7 +124,7 @@ describe('GameNative Converter', () => {
customFieldDefinition: {
name: 'graphics_driver',
label: 'Graphics Driver',
- type: 'TEXT',
+ type: 'SELECT',
},
value: input,
},
@@ -138,7 +142,7 @@ describe('GameNative Converter', () => {
{ input: 'VKD3D', expected: 'vkd3d' },
{ input: 'CNC DDraw', expected: 'cnc-ddraw' },
{ input: 'Other', expected: 'dxvk' },
- { input: 'Unknown', expected: 'dxvk' }, // Default fallback
+ { input: 'Unknown', expected: 'dxvk' },
]
testCases.forEach(({ input, expected }) => {
@@ -163,10 +167,12 @@ describe('GameNative Converter', () => {
it('should map audio driver options correctly', () => {
const testCases = [
+ { input: 'alsa', expected: 'alsa' },
+ { input: 'pulse', expected: 'pulseaudio' },
+ { input: 'other', expected: 'pulseaudio' },
{ input: 'ALSA', expected: 'alsa' },
- { input: 'PulseAudio', expected: 'pulse' }, // Correct value is 'pulse'
- { input: 'Other', expected: 'alsa' },
- { input: 'Unknown', expected: 'alsa' }, // Default fallback
+ { input: 'PulseAudio', expected: 'pulseaudio' },
+ { input: 'Unknown', expected: 'pulseaudio' },
]
testCases.forEach(({ input, expected }) => {
@@ -195,7 +201,7 @@ describe('GameNative Converter', () => {
{ input: 'Essential (Load only essential services)', expected: 1 },
{ input: 'Aggressive (Stop services on startup)', expected: 2 },
{ input: 'Other', expected: 1 },
- { input: 'Unknown', expected: 1 }, // Default fallback
+ { input: 'Unknown', expected: 1 },
]
testCases.forEach(({ input, expected }) => {
@@ -218,8 +224,7 @@ describe('GameNative Converter', () => {
})
})
- it('should handle Box64 and Box86 versions with defaults', () => {
- // Test with provided values
+ it('should handle Box64 versions with defaults', () => {
const configWithValues = convertToGameNativeConfig({
listingId: 'test',
gameId: 'game',
@@ -228,25 +233,15 @@ describe('GameNative Converter', () => {
customFieldDefinition: {
name: 'box64_version',
label: 'Box64 Version',
- type: 'TEXT',
- },
- value: '0.3.6',
- },
- {
- customFieldDefinition: {
- name: 'box86_version',
- label: 'Box86 Version',
- type: 'TEXT',
+ type: 'SELECT',
},
- value: '0.3.4',
+ value: '0.3.8',
},
],
})
- expect(configWithValues.box64Version).toBe('0.3.6') // Invalid version defaults to 0.3.6
- expect(configWithValues.box86Version).toBe('0.3.2') // Invalid version defaults to 0.3.2
+ expect(configWithValues.box64Version).toBe('0.3.8')
- // Test with empty values - should use defaults
const configWithEmpty = convertToGameNativeConfig({
listingId: 'test',
gameId: 'game',
@@ -255,52 +250,58 @@ describe('GameNative Converter', () => {
customFieldDefinition: {
name: 'box64_version',
label: 'Box64 Version',
- type: 'TEXT',
+ type: 'SELECT',
},
value: '',
},
- {
- customFieldDefinition: {
- name: 'box86_version',
- label: 'Box86 Version',
- type: 'TEXT',
- },
- value: null,
- },
],
})
expect(configWithEmpty.box64Version).toBe('0.3.6')
- expect(configWithEmpty.box86Version).toBe('0.3.2')
})
- it('should map Box64 and Box86 presets correctly', () => {
- const presetOptions = [
- { input: 'Stability', expected: 'STABILITY' },
- { input: 'Compatibility', expected: 'COMPATIBILITY' },
- { input: 'Intermediate', expected: 'INTERMEDIATE' },
- { input: 'Performance', expected: 'PERFORMANCE' },
- { input: 'Other/Custom', expected: 'COMPATIBILITY' },
- { input: 'Unknown', expected: 'COMPATIBILITY' }, // Default fallback
- ]
+ it('should accept bionic-specific Box64 versions', () => {
+ const bionicVersions = ['0.3.2', '0.3.7', '0.4.0']
- presetOptions.forEach(({ input, expected }) => {
+ bionicVersions.forEach((version) => {
const config = convertToGameNativeConfig({
listingId: 'test',
gameId: 'game',
customFieldValues: [
{
customFieldDefinition: {
- name: 'box64_preset',
- label: 'Box64 Preset',
+ name: 'box64_version',
+ label: 'Box64 Version',
type: 'SELECT',
},
- value: input,
+ value: version,
},
+ ],
+ })
+
+ expect(config.box64Version).toBe(version)
+ })
+ })
+
+ it('should map Box64 presets correctly (lowercase input)', () => {
+ const presetOptions = [
+ { input: 'stability', expected: 'STABILITY' },
+ { input: 'compatibility', expected: 'COMPATIBILITY' },
+ { input: 'intermediate', expected: 'INTERMEDIATE' },
+ { input: 'performance', expected: 'PERFORMANCE' },
+ { input: 'denuvo', expected: 'DENUVO' },
+ { input: 'other/custom', expected: 'COMPATIBILITY' },
+ ]
+
+ presetOptions.forEach(({ input, expected }) => {
+ const config = convertToGameNativeConfig({
+ listingId: 'test',
+ gameId: 'game',
+ customFieldValues: [
{
customFieldDefinition: {
- name: 'box86_preset',
- label: 'Box86 Preset',
+ name: 'box64_preset',
+ label: 'Box64 Preset',
type: 'SELECT',
},
value: input,
@@ -309,7 +310,6 @@ describe('GameNative Converter', () => {
})
expect(config.box64Preset).toBe(expected)
- expect(config.box86Preset).toBe(expected)
})
})
@@ -330,65 +330,225 @@ describe('GameNative Converter', () => {
})
expect(config.execArgs).toBe('-skipmovies -nologo')
+ })
- // Test with empty value
- const configEmpty = convertToGameNativeConfig({
+ it('should merge dxvk_version into dxwrapperConfig', () => {
+ const config = convertToGameNativeConfig({
listingId: 'test',
gameId: 'game',
customFieldValues: [
{
customFieldDefinition: {
- name: 'exec_arguments',
- label: 'Exec Arguments',
+ name: 'dxvk_version',
+ label: 'DXVK Version',
+ type: 'SELECT',
+ },
+ value: 'async-1.10.3',
+ },
+ ],
+ })
+
+ expect(config.dxwrapperConfig).toContain('version=async-1.10.3')
+ })
+
+ it('should merge max_device_memory into graphicsDriverConfig', () => {
+ const config = convertToGameNativeConfig({
+ listingId: 'test',
+ gameId: 'game',
+ customFieldValues: [
+ {
+ customFieldDefinition: {
+ name: 'max_device_memory',
+ label: 'Max Device Memory',
type: 'TEXT',
},
- value: '',
+ value: '4096',
},
],
})
- expect(configEmpty.execArgs).toBe('')
+ expect(config.graphicsDriverConfig).toContain('maxDeviceMemory=4096')
})
- it('should handle DX wrapper config properly', () => {
+ it('should merge use_adrenotools_turnip into graphicsDriverConfig', () => {
const config = convertToGameNativeConfig({
listingId: 'test',
gameId: 'game',
customFieldValues: [
{
customFieldDefinition: {
- name: 'dx_wrapper_config',
- label: 'DX Wrapper Config',
- type: 'TEXTAREA',
+ name: 'use_adrenotools_turnip',
+ label: 'Use Adrenotools Turnip',
+ type: 'BOOLEAN',
},
- value: '2.6.1-gplasync',
+ value: true,
},
],
})
- expect(config.dxwrapperConfig).toBe('2.6.1-gplasync')
+ expect(config.graphicsDriverConfig).toContain('adrenotoolsTurnip=1')
})
- it('should convert all fields from the complete example listing', () => {
- // Using exact data from notes/GameNativeListingByIdExample.ts
- const input: GameNativeConfigInput = {
- listingId: 'ea4107c5-371b-4030-b42c-4469f251fe8b',
- gameId: 'f097b273-ad3d-4b2b-b6a4-d76856aafabf',
+ it('should write adrenotoolsTurnip=0 when disabled', () => {
+ const config = convertToGameNativeConfig({
+ listingId: 'test',
+ gameId: 'game',
customFieldValues: [
{
customFieldDefinition: {
- name: 'audio_driver',
- label: 'Audio Driver',
- type: 'SELECT',
+ name: 'use_adrenotools_turnip',
+ label: 'Use Adrenotools Turnip',
+ type: 'BOOLEAN',
},
- value: 'ALSA',
+ value: false,
},
+ ],
+ })
+
+ expect(config.graphicsDriverConfig).toContain('adrenotoolsTurnip=0')
+ })
+
+ it('should combine max_device_memory and use_adrenotools_turnip in graphicsDriverConfig', () => {
+ const config = convertToGameNativeConfig({
+ listingId: 'test',
+ gameId: 'game',
+ customFieldValues: [
{
customFieldDefinition: {
- name: 'average_fps',
- label: 'Average FPS',
+ name: 'max_device_memory',
+ label: 'Max Device Memory',
type: 'TEXT',
},
+ value: '4096',
+ },
+ {
+ customFieldDefinition: {
+ name: 'use_adrenotools_turnip',
+ label: 'Use Adrenotools Turnip',
+ type: 'BOOLEAN',
+ },
+ value: true,
+ },
+ ],
+ })
+
+ expect(config.graphicsDriverConfig).toContain('maxDeviceMemory=4096')
+ expect(config.graphicsDriverConfig).toContain('adrenotoolsTurnip=1')
+ })
+
+ it('should map container_variant correctly', () => {
+ const config = convertToGameNativeConfig({
+ listingId: 'test',
+ gameId: 'game',
+ customFieldValues: [
+ {
+ customFieldDefinition: {
+ name: 'container_variant',
+ label: 'Container Variant',
+ type: 'SELECT',
+ },
+ value: 'bionic',
+ },
+ ],
+ })
+
+ expect(config.containerVariant).toBe('bionic')
+ })
+
+ it('should map steam_type with ultra_light conversion', () => {
+ const config = convertToGameNativeConfig({
+ listingId: 'test',
+ gameId: 'game',
+ customFieldValues: [
+ {
+ customFieldDefinition: {
+ name: 'steam_type',
+ label: 'Steam Type',
+ type: 'SELECT',
+ },
+ value: 'ultra_light',
+ },
+ ],
+ })
+
+ expect(config.steamType).toBe('ultralight')
+ })
+
+ it('should map emulator from 64_bit_emulator field', () => {
+ const config = convertToGameNativeConfig({
+ listingId: 'test',
+ gameId: 'game',
+ customFieldValues: [
+ {
+ customFieldDefinition: {
+ name: '64_bit_emulator',
+ label: '64-bit Emulator',
+ type: 'SELECT',
+ },
+ value: 'box',
+ },
+ ],
+ })
+
+ expect(config.emulator).toBe('Box64')
+ })
+
+ it('should map fex_core_preset correctly (lowercase input)', () => {
+ const config = convertToGameNativeConfig({
+ listingId: 'test',
+ gameId: 'game',
+ customFieldValues: [
+ {
+ customFieldDefinition: {
+ name: 'fex_core_preset',
+ label: 'FEXCore Preset',
+ type: 'SELECT',
+ },
+ value: 'performance',
+ },
+ ],
+ })
+
+ expect(config.fexcorePreset).toBe('PERFORMANCE')
+ })
+
+ it('should map direct_input_mapper_type string values to numbers', () => {
+ const testCases = [
+ { input: 'standard', expected: 1 },
+ { input: 'xinput_mapper', expected: 2 },
+ ]
+
+ testCases.forEach(({ input, expected }) => {
+ const config = convertToGameNativeConfig({
+ listingId: 'test',
+ gameId: 'game',
+ customFieldValues: [
+ {
+ customFieldDefinition: {
+ name: 'direct_input_mapper_type',
+ label: 'DirectInput Mapper Type',
+ type: 'SELECT',
+ },
+ value: input,
+ },
+ ],
+ })
+
+ expect(config.dinputMapperType).toBe(expected)
+ })
+ })
+
+ it('should convert all fields from a complete listing', () => {
+ const input: GameNativeConfigInput = {
+ listingId: 'ea4107c5-371b-4030-b42c-4469f251fe8b',
+ gameId: 'f097b273-ad3d-4b2b-b6a4-d76856aafabf',
+ customFieldValues: [
+ {
+ customFieldDefinition: { name: 'audio_driver', label: 'Audio Driver', type: 'SELECT' },
+ value: 'alsa',
+ },
+ {
+ customFieldDefinition: { name: 'average_fps', label: 'Average FPS', type: 'TEXT' },
value: '120',
},
{
@@ -397,22 +557,18 @@ describe('GameNative Converter', () => {
label: 'Box64 Preset',
type: 'SELECT',
},
- value: 'Performance',
+ value: 'performance',
},
{
customFieldDefinition: {
name: 'box64_version',
label: 'Box64 Version',
- type: 'TEXT',
+ type: 'SELECT',
},
value: '0.3.6',
},
{
- customFieldDefinition: {
- name: 'dx_wrapper',
- label: 'DX Wrapper',
- type: 'SELECT',
- },
+ customFieldDefinition: { name: 'dx_wrapper', label: 'DX Wrapper', type: 'SELECT' },
value: 'DXVK',
},
{
@@ -421,7 +577,7 @@ describe('GameNative Converter', () => {
label: 'DX Wrapper Config',
type: 'TEXTAREA',
},
- value: '2.6.1-gplasync',
+ value: 'async=1',
},
{
customFieldDefinition: {
@@ -447,35 +603,19 @@ describe('GameNative Converter', () => {
},
value: '-skipmovies',
},
- {
- customFieldDefinition: {
- name: 'game_version',
- label: 'Game Version',
- type: 'TEXT',
- },
- value: '',
- },
{
customFieldDefinition: {
name: 'graphics_driver',
label: 'Graphics Driver',
- type: 'TEXT',
+ type: 'SELECT',
},
value: 'Vortek (Universal)',
},
- {
- customFieldDefinition: {
- name: 'media_url',
- label: 'Screenshots, Blog Post, etc',
- type: 'URL',
- },
- value: '',
- },
{
customFieldDefinition: {
name: 'resolution',
label: 'Resolution (Screen Size)',
- type: 'TEXT',
+ type: 'SELECT',
},
value: '1920x1080 (16:9)',
},
@@ -489,38 +629,51 @@ describe('GameNative Converter', () => {
},
{
customFieldDefinition: {
- name: 'youtube',
- label: 'YouTube',
- type: 'URL',
+ name: 'container_variant',
+ label: 'Container Variant',
+ type: 'SELECT',
+ },
+ value: 'bionic',
+ },
+ {
+ customFieldDefinition: {
+ name: '64_bit_emulator',
+ label: '64-bit Emulator',
+ type: 'SELECT',
},
- value: '',
+ value: 'fex',
+ },
+ {
+ customFieldDefinition: {
+ name: 'fex_core_preset',
+ label: 'FEXCore Preset',
+ type: 'SELECT',
+ },
+ value: 'intermediate',
},
],
}
const config = convertToGameNativeConfig(input)
- // Verify all mapped fields
expect(config.audioDriver).toBe('alsa')
expect(config.box64Preset).toBe('PERFORMANCE')
expect(config.box64Version).toBe('0.3.6')
expect(config.dxwrapper).toBe('dxvk')
- expect(config.dxwrapperConfig).toBe('2.6.1-gplasync')
- expect(config.envVars).toContain('ZINK_DESCRIPTORS=lazy') // Default since empty
+ expect(config.dxwrapperConfig).toBe('async=1')
+ expect(config.envVars).toContain('ZINK_DESCRIPTORS=lazy')
expect(config.execArgs).toBe('-skipmovies')
expect(config.graphicsDriver).toBe('vortek')
expect(config.screenSize).toBe('1920x1080')
expect(config.startupSelection).toBe(2)
+ expect(config.containerVariant).toBe('bionic')
+ expect(config.emulator).toBe('FEXCore')
+ expect(config.fexcorePreset).toBe('INTERMEDIATE')
- // Verify default values for missing fields
- expect(config.box86Version).toBe('0.3.2')
expect(config.box86Preset).toBe('COMPATIBILITY')
expect(config.wow64Mode).toBe(true)
expect(config.showFPS).toBe(false)
expect(config.launchRealSteam).toBe(false)
- expect(config.cpuList).toBe('0,1,2,3,4,5,6,7')
- expect(config.csmt).toBe(true)
- expect(config.sdlControllerAPI).toBe(true)
expect(config.enableXInput).toBe(true)
expect(config.enableDInput).toBe(true)
})
@@ -539,35 +692,19 @@ describe('GameNative Converter', () => {
value: 'v0.3.0',
},
{
- customFieldDefinition: {
- name: 'game_version',
- label: 'Game Version',
- type: 'TEXT',
- },
+ customFieldDefinition: { name: 'game_version', label: 'Game Version', type: 'TEXT' },
value: '1.0.0',
},
{
- customFieldDefinition: {
- name: 'average_fps',
- label: 'Average FPS',
- type: 'TEXT',
- },
+ customFieldDefinition: { name: 'average_fps', label: 'Average FPS', type: 'TEXT' },
value: '60',
},
{
- customFieldDefinition: {
- name: 'media_url',
- label: 'Media URL',
- type: 'URL',
- },
+ customFieldDefinition: { name: 'media_url', label: 'Media URL', type: 'URL' },
value: 'https://example.com',
},
{
- customFieldDefinition: {
- name: 'youtube',
- label: 'YouTube',
- type: 'URL',
- },
+ customFieldDefinition: { name: 'youtube', label: 'YouTube', type: 'URL' },
value: 'https://youtube.com/watch?v=123',
},
],
@@ -575,10 +712,8 @@ describe('GameNative Converter', () => {
const config = convertToGameNativeConfig(input)
- // These fields should not affect the config
- // Config should still have default values
expect(config.name).toBe('')
- expect(config.screenSize).toBe('854x480')
+ expect(config.screenSize).toBe('1280x720')
expect(config.graphicsDriver).toBe('vortek')
expect(config.dxwrapper).toBe('dxvk')
})
@@ -600,7 +735,7 @@ describe('GameNative Converter', () => {
customFieldDefinition: {
name: 'graphics_driver',
label: 'Graphics Driver',
- type: 'TEXT',
+ type: 'SELECT',
},
value: null,
},
@@ -617,9 +752,9 @@ describe('GameNative Converter', () => {
const config = convertToGameNativeConfig(input)
- expect(config.screenSize).toBe('854x480') // Default
- expect(config.graphicsDriver).toBe('vortek') // Default
- expect(config.execArgs).toBe('') // Empty string default
+ expect(config.screenSize).toBe('1280x720')
+ expect(config.graphicsDriver).toBe('vortek')
+ expect(config.execArgs).toBe('')
})
})
@@ -633,7 +768,7 @@ describe('GameNative Converter', () => {
customFieldDefinition: {
name: 'resolution',
label: 'Resolution',
- type: 'TEXT',
+ type: 'SELECT',
},
value: '1920x1080',
},
@@ -641,7 +776,7 @@ describe('GameNative Converter', () => {
customFieldDefinition: {
name: 'graphics_driver',
label: 'Graphics Driver',
- type: 'TEXT',
+ type: 'SELECT',
},
value: 'vortek',
},
@@ -655,10 +790,9 @@ describe('GameNative Converter', () => {
screenSize: '1920x1080',
graphicsDriver: 'vortek',
dxwrapper: 'dxvk',
- audioDriver: 'alsa',
+ audioDriver: 'pulseaudio',
})
- // Check formatting (should be pretty-printed)
expect(serialized).toContain('\n')
expect(serialized).toContain(' ')
})
@@ -674,18 +808,16 @@ describe('GameNative Converter', () => {
const parsed = JSON.parse(serialized)
expect(parsed).toBeDefined()
- expect(parsed.screenSize).toBe('854x480')
+ expect(parsed.screenSize).toBe('1280x720')
})
})
- describe('Edge cases and special scenarios', () => {
+ describe('Edge cases', () => {
it('should handle mixed case graphics driver names', () => {
const testCases = [
{ input: 'VORTEK', expected: 'vortek' },
{ input: 'Turnip', expected: 'turnip' },
{ input: 'VIRGL', expected: 'virgl' },
- { input: 'FREEDRENO', expected: 'vortek' }, // Not valid, defaults to vortek
- { input: 'MeSa', expected: 'vortek' }, // Not valid, defaults to vortek
]
testCases.forEach(({ input, expected }) => {
@@ -697,7 +829,7 @@ describe('GameNative Converter', () => {
customFieldDefinition: {
name: 'graphics_driver',
label: 'Graphics Driver',
- type: 'TEXT',
+ type: 'SELECT',
},
value: input,
},
@@ -713,7 +845,7 @@ describe('GameNative Converter', () => {
{ input: 'Resolution: 1920x1080', expected: '1920x1080' },
{ input: '1920x1080 pixels', expected: '1920x1080' },
{ input: 'Use 2560x1440 for best quality', expected: '2560x1440' },
- { input: 'No resolution here', expected: '854x480' }, // Default
+ { input: 'No resolution here', expected: '1280x720' },
]
testCases.forEach(({ input, expected }) => {
@@ -752,7 +884,7 @@ describe('GameNative Converter', () => {
],
})
- expect(config.envVars).toBe('VAR1=value1 VAR2=value2') // Trimmed but internal spacing preserved
+ expect(config.envVars).toBe('VAR1=value1 VAR2=value2')
})
it('should handle multiple fields updating simultaneously', () => {
@@ -764,7 +896,7 @@ describe('GameNative Converter', () => {
customFieldDefinition: {
name: 'resolution',
label: 'Resolution',
- type: 'TEXT',
+ type: 'SELECT',
},
value: '2560x1440',
},
@@ -772,7 +904,7 @@ describe('GameNative Converter', () => {
customFieldDefinition: {
name: 'graphics_driver',
label: 'Graphics Driver',
- type: 'TEXT',
+ type: 'SELECT',
},
value: 'turnip',
},
@@ -790,7 +922,7 @@ describe('GameNative Converter', () => {
label: 'Audio Driver',
type: 'SELECT',
},
- value: 'PulseAudio',
+ value: 'pulse',
},
{
customFieldDefinition: {
@@ -804,7 +936,7 @@ describe('GameNative Converter', () => {
customFieldDefinition: {
name: 'box64_version',
label: 'Box64 Version',
- type: 'TEXT',
+ type: 'SELECT',
},
value: '0.3.6',
},
@@ -814,7 +946,7 @@ describe('GameNative Converter', () => {
label: 'Box64 Preset',
type: 'SELECT',
},
- value: 'Stability',
+ value: 'stability',
},
],
})
@@ -822,7 +954,7 @@ describe('GameNative Converter', () => {
expect(config.screenSize).toBe('2560x1440')
expect(config.graphicsDriver).toBe('turnip')
expect(config.dxwrapper).toBe('vkd3d')
- expect(config.audioDriver).toBe('pulse')
+ expect(config.audioDriver).toBe('pulseaudio')
expect(config.startupSelection).toBe(0)
expect(config.box64Version).toBe('0.3.6')
expect(config.box64Preset).toBe('STABILITY')
diff --git a/src/server/utils/emulator-config/gamenative/gamenative.converter.ts b/src/server/utils/emulator-config/gamenative/gamenative.converter.ts
index bed404fa..eff096d4 100644
--- a/src/server/utils/emulator-config/gamenative/gamenative.converter.ts
+++ b/src/server/utils/emulator-config/gamenative/gamenative.converter.ts
@@ -10,19 +10,24 @@ import {
AUDIO_DRIVER_MAPPING,
STARTUP_SELECTION_MAPPING,
BOX64_PRESET_MAPPING,
- BOX86_PRESET_MAPPING,
+ EMULATOR_MAPPING,
+ CONTAINER_VARIANT_MAPPING,
+ STEAM_TYPE_MAPPING,
+ FEXCORE_PRESET_MAPPING,
} from './gamenative.defaults'
-import {
- type DxvkVersion,
- type ContainerConfig,
- type ScreenSize,
- type GraphicsDriver,
- type DxWrapper,
- type AudioDriver,
- type StartupSelection,
- type Box86Version,
- type Box64Version,
- type Box86_64Preset,
+import type {
+ ContainerConfig,
+ ScreenSize,
+ GraphicsDriver,
+ DxWrapper,
+ AudioDriver,
+ StartupSelection,
+ Box64Version,
+ Box86_64Preset,
+ ContainerVariant,
+ SteamType,
+ FEXCorePreset,
+ DinputMapperType,
} from './gamenative.types'
import type { Prisma } from '@orm'
@@ -42,9 +47,16 @@ export interface GameNativeConfigInput {
customFieldValues: CustomFieldValue[]
}
-// Export the properly typed config
export type GameNativeConfig = Required
+/**
+ * Appends a key=value pair to a comma-separated config string
+ */
+function appendToConfigString(existing: string, key: string, value: string): string {
+ if (!existing) return `${key}=${value}`
+ return `${existing},${key}=${value}`
+}
+
/**
* Maps custom field names to GameNative config keys and transforms values
*/
@@ -56,280 +68,145 @@ const FIELD_MAPPINGS: Record<
defaultIfEmpty?: unknown
}
> = {
- // Resolution mapping
resolution: {
key: 'screenSize',
transform: (value): ScreenSize => {
const resStr = String(value)
- // Extract resolution from formats like "1920x1080 (16:9)" or just "1920x1080"
const match = resStr.match(/(\d+x\d+)/)
return match ? match[1] : GameNativeDefaults.getDefaultScreenSize()
},
},
- // Environment variables - complex string needs special handling
env_variables: {
key: 'envVars',
transform: (value) => {
- // If empty or not provided, use default environment variables
return !value || String(value).trim() === ''
? GameNativeDefaults.getDefaultEnvVars()
: String(value).trim()
},
},
- // Graphics driver - handles both new SELECT values and legacy TEXT values
graphics_driver: {
key: 'graphicsDriver',
transform: (value): GraphicsDriver => GameNativeDefaults.detectGraphicsDriver(String(value)),
defaultIfEmpty: GameNativeDefaults.getDefaultGraphicsDriver(),
},
- // DX Wrapper
dx_wrapper: {
key: 'dxwrapper',
transform: (value): DxWrapper =>
DX_WRAPPER_MAPPING[String(value)] ?? GameNativeDefaults.getDefaultDxWrapper(),
},
- // DX Wrapper Config
dx_wrapper_config: {
key: 'dxwrapperConfig',
transform: (value) => String(value || ''),
defaultIfEmpty: '',
},
- // DXVK version
- dxvk_version: {
- key: 'dxvkVersion',
- transform: (value): DxvkVersion => {
- const version = String(value || GameNativeDefaults.getDefaultDxvkVersion()).trim()
- return GameNativeDefaults.isValidDxvkVersion(version)
- ? (version as DxvkVersion)
- : GameNativeDefaults.getDefaultDxvkVersion()
- },
- },
-
- // Audio driver
audio_driver: {
key: 'audioDriver',
transform: (value): AudioDriver =>
AUDIO_DRIVER_MAPPING[String(value)] ?? GameNativeDefaults.getDefaultAudioDriver(),
},
- // Execution arguments
exec_arguments: {
key: 'execArgs',
transform: (value) => String(value || ''),
defaultIfEmpty: '',
},
- // Startup selection
startup_selection: {
key: 'startupSelection',
transform: (value): StartupSelection =>
STARTUP_SELECTION_MAPPING[String(value)] ?? GameNativeDefaults.getDefaultStartupSelection(),
},
- // Box64 version
box64_version: {
key: 'box64Version',
transform: (value): Box64Version => {
const version = String(value || GameNativeDefaults.getDefaultBox64Version()).trim()
- // Validate against known versions using centralized validation
- return GameNativeDefaults.isValidBox64Version(version)
- ? (version as Box64Version)
- : GameNativeDefaults.getDefaultBox64Version()
+ if (GameNativeDefaults.isValidBox64Version(version)) return version
+ return GameNativeDefaults.getDefaultBox64Version()
},
defaultIfEmpty: GameNativeDefaults.getDefaultBox64Version(),
},
- // Box86 version (not in example but in target config)
- box86_version: {
- key: 'box86Version',
- transform: (value): Box86Version => {
- const version = String(value || GameNativeDefaults.getDefaultBox86Version()).trim()
- // Validate against known versions using centralized validation
- return GameNativeDefaults.isValidBox86Version(version)
- ? (version as Box86Version)
- : GameNativeDefaults.getDefaultBox86Version()
- },
- defaultIfEmpty: GameNativeDefaults.getDefaultBox86Version(),
- },
-
- // Box64 preset
box64_preset: {
key: 'box64Preset',
transform: (value): Box86_64Preset =>
BOX64_PRESET_MAPPING[String(value)] ?? GameNativeDefaults.getDefaultBoxPreset(),
},
- // Box86 preset (not in example but needed)
- box86_preset: {
- key: 'box86Preset',
- transform: (value): Box86_64Preset =>
- BOX86_PRESET_MAPPING[String(value)] ?? GameNativeDefaults.getDefaultBoxPreset(),
+ container_variant: {
+ key: 'containerVariant',
+ transform: (value): ContainerVariant =>
+ CONTAINER_VARIANT_MAPPING[String(value)] ?? GameNativeDefaults.getDefaultContainerVariant(),
},
- // Windows Components - Critical missing field
- // Convert individual boolean fields to wincomponents string
- use_native_direct3d: { key: 'wincomponents', transform: () => undefined }, // Handled specially
- use_native_directsound: { key: 'wincomponents', transform: () => undefined }, // Handled specially
- use_native_directmusic: { key: 'wincomponents', transform: () => undefined }, // Handled specially
- use_native_directshow: { key: 'wincomponents', transform: () => undefined }, // Handled specially
- use_native_directplay: { key: 'wincomponents', transform: () => undefined }, // Handled specially
- use_native_vcrun2010: { key: 'wincomponents', transform: () => undefined }, // Handled specially
- use_native_wmdecoder: { key: 'wincomponents', transform: () => undefined }, // Handled specially
-
- // Video Memory Size
- video_memory_size: {
- key: 'videoMemorySize',
- transform: (value) => {
- const sizeStr = String(value || GameNativeDefaults.getDefaultVideoMemorySize()).trim()
- // Validate against known sizes using centralized validation
- return GameNativeDefaults.isValidVideoMemorySize(sizeStr)
- ? sizeStr
- : GameNativeDefaults.getDefaultVideoMemorySize()
- },
- defaultIfEmpty: GameNativeDefaults.getDefaultVideoMemorySize(),
+ wine_version: {
+ key: 'wineVersion',
+ transform: (value) => String(value || ''),
+ defaultIfEmpty: '',
},
- // CPU Core Affinity
- cpu_list: {
- key: 'cpuList',
- transform: (value) => String(value || GameNativeDefaults.getDefaultCpuList()),
- defaultIfEmpty: GameNativeDefaults.getDefaultCpuList(),
+ steam_type: {
+ key: 'steamType',
+ transform: (value): SteamType =>
+ STEAM_TYPE_MAPPING[String(value)] ?? GameNativeDefaults.getDefaultSteamType(),
},
- cpu_list_wow64: {
- key: 'cpuListWoW64',
- transform: (value) => String(value || GameNativeDefaults.getDefaultCpuList()),
- defaultIfEmpty: GameNativeDefaults.getDefaultCpuList(),
+ dynamic_driver_version: {
+ key: 'graphicsDriverVersion',
+ transform: (value) => String(value || ''),
+ defaultIfEmpty: '',
},
- // WoW64 Mode
- wow64_mode: {
- key: 'wow64Mode',
- transform: (value) => Boolean(value ?? true),
- defaultIfEmpty: true,
+ fex_core_version: {
+ key: 'fexcoreVersion',
+ transform: (value) => String(value || ''),
+ defaultIfEmpty: '',
},
- // Show FPS Overlay
- show_fps: {
- key: 'showFPS',
- transform: (value) => Boolean(value ?? false),
- defaultIfEmpty: false,
+ fex_core_preset: {
+ key: 'fexcorePreset',
+ transform: (value): FEXCorePreset =>
+ FEXCORE_PRESET_MAPPING[String(value)] ?? GameNativeDefaults.getDefaultFexcorePreset(),
},
- // Input/Controller Settings
- sdl_controller_api: {
- key: 'sdlControllerAPI',
- transform: (value) => Boolean(value ?? true),
- defaultIfEmpty: true,
+ use_steam_input: {
+ key: 'useSteamInput',
+ transform: (value) => Boolean(value ?? false),
+ defaultIfEmpty: false,
},
- enable_xinput: {
+ enable_x_input_api: {
key: 'enableXInput',
transform: (value) => Boolean(value ?? true),
defaultIfEmpty: true,
},
- enable_dinput: {
+ enable_direct_input_api: {
key: 'enableDInput',
transform: (value) => Boolean(value ?? true),
defaultIfEmpty: true,
},
- dinput_mapper_type: {
+ direct_input_mapper_type: {
key: 'dinputMapperType',
- transform: (value) => {
+ transform: (value): DinputMapperType => {
+ const str = String(value)
+ if (str === 'xinput_mapper') return 2
+ if (str === 'standard') return 1
const num = Number(value)
- // 0: Standard, 1: XInput
- return num === 0 || num === 1 ? num : 1
+ if (num === 2) return 2
+ return 1
},
defaultIfEmpty: 1,
},
-
- disable_mouse_input: {
- key: 'disableMouseInput',
- transform: (value) => Boolean(value ?? false),
- defaultIfEmpty: false,
- },
-
- // Graphics/Rendering Settings
- csmt: {
- key: 'csmt',
- transform: (value) => Boolean(value ?? true),
- defaultIfEmpty: true,
- },
-
- video_pci_device_id: {
- key: 'videoPciDeviceID',
- transform: (value) => {
- const num = Number(value)
- return isNaN(num) ? GameNativeDefaults.getDefaultVideoPciDeviceId() : num
- },
- defaultIfEmpty: GameNativeDefaults.getDefaultVideoPciDeviceId(),
- },
-
- offscreen_rendering_mode: {
- key: 'offScreenRenderingMode',
- transform: (value) => {
- const mode = String(
- value || GameNativeDefaults.getDefaultOffscreenRenderingMode(),
- ).toLowerCase()
- return GameNativeDefaults.isValidOffscreenRenderingMode(mode)
- ? mode
- : GameNativeDefaults.getDefaultOffscreenRenderingMode()
- },
- defaultIfEmpty: GameNativeDefaults.getDefaultOffscreenRenderingMode(),
- },
-
- strict_shader_math: {
- key: 'strictShaderMath',
- transform: (value) => Boolean(value ?? true),
- defaultIfEmpty: true,
- },
-
- mouse_warp_override: {
- key: 'mouseWarpOverride',
- transform: (value) => {
- const mode = String(value || GameNativeDefaults.getDefaultMouseWarpOverride()).toLowerCase()
- return GameNativeDefaults.isValidMouseWarpOverride(mode)
- ? mode
- : GameNativeDefaults.getDefaultMouseWarpOverride()
- },
- defaultIfEmpty: GameNativeDefaults.getDefaultMouseWarpOverride(),
- },
-
- shader_backend: {
- key: 'shaderBackend',
- transform: (value) => String(value || GameNativeDefaults.getDefaultShaderBackend()),
- defaultIfEmpty: GameNativeDefaults.getDefaultShaderBackend(),
- },
-
- use_glsl: {
- key: 'useGLSL',
- transform: (value) => {
- const val = String(value || GameNativeDefaults.getDefaultUseGlsl())
- return GameNativeDefaults.isValidUseGlsl(val) ? val : GameNativeDefaults.getDefaultUseGlsl()
- },
- defaultIfEmpty: GameNativeDefaults.getDefaultUseGlsl(),
- },
-
- // Graphics Driver Version - dynamic field
- graphics_driver_version: {
- key: 'graphicsDriverVersion',
- transform: (value) => String(value || ''),
- defaultIfEmpty: '',
- },
}
-/**
- * Get default GameNative configuration
- */
function getDefaultConfig(): GameNativeConfig {
- // Use the typed default config from the types file
return { ...DEFAULT_CONFIG }
}
@@ -339,73 +216,73 @@ function getDefaultConfig(): GameNativeConfig {
export function convertToGameNativeConfig(input: GameNativeConfigInput): GameNativeConfig {
const config = getDefaultConfig()
- // Process each custom field value
+ const fieldValuesByName = new Map()
+ for (const fieldValue of input.customFieldValues) {
+ fieldValuesByName.set(fieldValue.customFieldDefinition.name, fieldValue.value)
+ }
+
for (const fieldValue of input.customFieldValues) {
const fieldName = fieldValue.customFieldDefinition.name
const mapping = FIELD_MAPPINGS[fieldName]
- if (mapping) {
- const value = fieldValue.value
+ if (!mapping) continue
- // Skip if empty and no defaultIfEmpty is specified
- if ((value === '' || value === null || value === undefined) && !mapping.defaultIfEmpty) {
- continue
- }
+ const value = fieldValue.value
- // Use defaultIfEmpty if value is empty
- const actualValue =
- value === '' || value === null || value === undefined ? mapping.defaultIfEmpty : value
+ if ((value === '' || value === null || value === undefined) && !mapping.defaultIfEmpty) {
+ continue
+ }
- // Transform and assign value
- const transformedValue = mapping.transform ? mapping.transform(actualValue) : actualValue
+ const actualValue =
+ value === '' || value === null || value === undefined ? mapping.defaultIfEmpty : value
- // Skip Windows Components fields - they're handled specially below
- if (mapping.key === 'wincomponents') {
- continue
- }
+ const transformedValue = mapping.transform ? mapping.transform(actualValue) : actualValue
- // Assign value to the correct key
- // We need to use Object.assign or explicit property access
- Object.assign(config, { [mapping.key]: transformedValue })
- }
+ Object.assign(config, { [mapping.key]: transformedValue })
}
- // Special handling for Windows Components - combine multiple boolean fields
- const winComponentFields = {
- use_native_direct3d: 'direct3d',
- use_native_directsound: 'directsound',
- use_native_directmusic: 'directmusic',
- use_native_directshow: 'directshow',
- use_native_directplay: 'directplay',
- use_native_vcrun2010: 'vcrun2010',
- use_native_wmdecoder: 'wmdecoder',
+ // Merge dxvk_version into dxwrapperConfig as version=X
+ const dxvkVersion = fieldValuesByName.get('dxvk_version')
+ if (dxvkVersion && String(dxvkVersion).trim()) {
+ config.dxwrapperConfig = appendToConfigString(
+ config.dxwrapperConfig,
+ 'version',
+ String(dxvkVersion).trim(),
+ )
}
- const winComponentsMap = new Map()
-
- // Set defaults using centralized configuration
- for (const [component, value] of Object.entries(
- GameNativeDefaults.getDefaultWindowsComponents(),
- )) {
- winComponentsMap.set(component, value)
+ // Merge max_device_memory into graphicsDriverConfig as maxDeviceMemory=N
+ const maxDeviceMemory = fieldValuesByName.get('max_device_memory')
+ if (maxDeviceMemory && String(maxDeviceMemory).trim()) {
+ config.graphicsDriverConfig = appendToConfigString(
+ config.graphicsDriverConfig,
+ 'maxDeviceMemory',
+ String(maxDeviceMemory).trim(),
+ )
}
- // Override with user values if present
- for (const fieldValue of input.customFieldValues) {
- const fieldName = fieldValue.customFieldDefinition.name
- const componentName = winComponentFields[fieldName as keyof typeof winComponentFields]
-
- if (componentName) {
- winComponentsMap.set(componentName, Boolean(fieldValue.value))
- }
+ // Handle 32_bit_emulator / 64_bit_emulator β emulator field
+ const emulator32 = fieldValuesByName.get('32_bit_emulator')
+ const emulator64 = fieldValuesByName.get('64_bit_emulator')
+ const emulatorValue = emulator64 ?? emulator32
+ if (emulatorValue && String(emulatorValue).trim()) {
+ config.emulator =
+ EMULATOR_MAPPING[String(emulatorValue)] ?? GameNativeDefaults.getDefaultEmulator()
}
- // Build wincomponents string
- const winComponentsParts: string[] = []
- for (const [component, useNative] of winComponentsMap) {
- winComponentsParts.push(`${component}=${useNative ? '1' : '0'}`)
+ // Handle use_adrenotools_turnip β graphicsDriverConfig.adrenotoolsTurnip
+ const useAdrenotoolsTurnip = fieldValuesByName.get('use_adrenotools_turnip')
+ if (useAdrenotoolsTurnip !== undefined && useAdrenotoolsTurnip !== null) {
+ const isEnabled =
+ useAdrenotoolsTurnip === true ||
+ useAdrenotoolsTurnip === 'true' ||
+ useAdrenotoolsTurnip === '1'
+ config.graphicsDriverConfig = appendToConfigString(
+ config.graphicsDriverConfig,
+ 'adrenotoolsTurnip',
+ isEnabled ? '1' : '0',
+ )
}
- config.wincomponents = winComponentsParts.join(',')
return config
}
diff --git a/src/server/utils/emulator-config/gamenative/gamenative.defaults.ts b/src/server/utils/emulator-config/gamenative/gamenative.defaults.ts
index 2334d5ba..facbebee 100644
--- a/src/server/utils/emulator-config/gamenative/gamenative.defaults.ts
+++ b/src/server/utils/emulator-config/gamenative/gamenative.defaults.ts
@@ -1,8 +1,8 @@
/**
* GameNative Configuration Defaults
*
- * Centralized default values for GameNative emulator configurations
- * to avoid hardcoding values throughout transform functions
+ * Source of truth: ContainerData.kt defaults, Container.java constants,
+ * DefaultVersion.java, arrays.xml
*/
import type {
@@ -15,82 +15,102 @@ import type {
Box86_64Preset,
ScreenSize,
DxvkVersion,
+ VKD3DVersion,
ContainerConfig,
+ Emulator,
+ ContainerVariant,
+ SteamType,
+ FEXCorePreset,
+ FEXCoreVersion,
+ ExternalDisplayMode,
+ SharpnessEffect,
} from './gamenative.types'
-// Default environment variables for GameNative
export const DEFAULT_ENV_VARS =
- 'ZINK_DESCRIPTORS=lazy ZINK_DEBUG=compact MESA_SHADER_CACHE_DISABLE=false MESA_SHADER_CACHE_MAX_SIZE=512MB mesa_glthread=true WINEESYNC=1 MESA_VK_WSI_PRESENT_MODE=mailbox TU_DEBUG=noconform DXVK_FRAME_RATE=60'
+ 'WRAPPER_MAX_IMAGE_COUNT=0 ZINK_DESCRIPTORS=lazy ZINK_DEBUG=compact MESA_SHADER_CACHE_DISABLE=false MESA_SHADER_CACHE_MAX_SIZE=512MB mesa_glthread=true WINEESYNC=1 MESA_VK_WSI_PRESENT_MODE=mailbox TU_DEBUG=noconform DXVK_FRAME_RATE=60 PULSE_LATENCY_MSEC=144'
-// Default resolution for GameNative
-export const DEFAULT_SCREEN_SIZE: ScreenSize = '854x480'
+export const DEFAULT_SCREEN_SIZE: ScreenSize = '1280x720'
-// Default graphics driver
export const DEFAULT_GRAPHICS_DRIVER: GraphicsDriver = 'vortek'
-// Graphics driver mapping for both new SELECT values and legacy TEXT values
export const GRAPHICS_DRIVER_MAPPING: Record = {
- // New SELECT values (exact match)
'VirGL (Universal)': 'virgl',
'Turnip (Adreno)': 'turnip',
'Vortek (Universal)': 'vortek',
-
- // Legacy TEXT values (for backward compatibility)
+ 'Adreno (Adreno)': 'adreno',
+ 'SD 8 Elite (SD 8 Elite)': 'sd-8-elite',
+ Wrapper: 'wrapper',
+ 'Wrapper-v2': 'wrapper-v2',
+ 'Wrapper-leegao': 'wrapper-leegao',
+ 'Wrapper-legacy': 'wrapper-legacy',
virgl: 'virgl',
turnip: 'turnip',
vortek: 'vortek',
+ adreno: 'adreno',
+ 'sd-8-elite': 'sd-8-elite',
+ wrapper: 'wrapper',
+ 'wrapper-v2': 'wrapper-v2',
+ 'wrapper-leegao': 'wrapper-leegao',
+ 'wrapper-legacy': 'wrapper-legacy',
VirGL: 'virgl',
Turnip: 'turnip',
Vortek: 'vortek',
VIRGL: 'virgl',
TURNIP: 'turnip',
VORTEK: 'vortek',
-
- // Partial matches for legacy TEXT entries
- adreno: 'turnip', // Common legacy entry
}
-// Default DX wrapper
export const DEFAULT_DX_WRAPPER: DxWrapper = 'dxvk'
-// Default DXVK version
-export const DEFAULT_DXVK_VERSION = '2.6.1-gplasync'
+export const DEFAULT_DXVK_VERSION: DxvkVersion = '2.6.1-gplasync'
-// Default audio driver
-export const DEFAULT_AUDIO_DRIVER: AudioDriver = 'alsa'
+export const DEFAULT_VKD3D_VERSION: VKD3DVersion = '2.14.1'
+
+export const DEFAULT_AUDIO_DRIVER: AudioDriver = 'pulseaudio'
-// Default startup selection (Essential services)
export const DEFAULT_STARTUP_SELECTION: StartupSelection = 1
-// Default Box versions
export const DEFAULT_BOX64_VERSION: Box64Version = '0.3.6'
export const DEFAULT_BOX86_VERSION: Box86Version = '0.3.2'
-// Default Box presets (Compatibility for best results)
export const DEFAULT_BOX_PRESET: Box86_64Preset = 'COMPATIBILITY'
-// Default video memory size in MB
+export const DEFAULT_EMULATOR: Emulator = 'FEXCore'
+
+export const DEFAULT_CONTAINER_VARIANT: ContainerVariant = 'glibc'
+
+export const DEFAULT_STEAM_TYPE: SteamType = 'normal'
+
+export const DEFAULT_FEXCORE_VERSION: FEXCoreVersion = '2603'
+export const DEFAULT_FEXCORE_PRESET: FEXCorePreset = 'INTERMEDIATE'
+export const DEFAULT_FEXCORE_TSO_MODE = 'Fast'
+export const DEFAULT_FEXCORE_X87_MODE = 'Fast'
+export const DEFAULT_FEXCORE_MULTIBLOCK = 'Disabled'
+
+export const DEFAULT_WINE_VERSION = 'wine-9.2-x86_64'
+
+export const DEFAULT_EXTERNAL_DISPLAY_MODE: ExternalDisplayMode = 'off'
+
+export const DEFAULT_SHARPNESS_EFFECT: SharpnessEffect = 'None'
+export const DEFAULT_SHARPNESS_LEVEL = 100
+export const DEFAULT_SHARPNESS_DENOISE = 100
+
export const DEFAULT_VIDEO_MEMORY_SIZE = '2048'
-// Default CPU affinity (all 8 cores)
export const DEFAULT_CPU_LIST = '0,1,2,3,4,5,6,7'
-// Default PCI device ID (NVIDIA GeForce GTX 480)
export const DEFAULT_VIDEO_PCI_DEVICE_ID = 1728
-// Default offscreen rendering mode
export const DEFAULT_OFFSCREEN_RENDERING_MODE = 'fbo'
-// Default mouse warp override
export const DEFAULT_MOUSE_WARP_OVERRIDE = 'disable'
-// Default shader backend
export const DEFAULT_SHADER_BACKEND = 'glsl'
-// Default GLSL usage
export const DEFAULT_USE_GLSL = 'enabled'
-// Valid video memory sizes (in MB)
+export const DEFAULT_RENDERER = 'gl'
+
export const VALID_VIDEO_MEMORY_SIZES = [
'32',
'64',
@@ -106,16 +126,12 @@ export const VALID_VIDEO_MEMORY_SIZES = [
'12288',
]
-// Valid offscreen rendering modes
export const VALID_OFFSCREEN_RENDERING_MODES = ['fbo', 'backbuffer']
-// Valid mouse warp override modes
export const VALID_MOUSE_WARP_OVERRIDE_MODES = ['disable', 'enable', 'force']
-// Valid GLSL values
export const VALID_USE_GLSL_VALUES = ['enabled', 'disabled']
-// Windows Components defaults
export const DEFAULT_WINDOWS_COMPONENTS = {
direct3d: true,
directsound: true,
@@ -124,9 +140,9 @@ export const DEFAULT_WINDOWS_COMPONENTS = {
directplay: false,
vcrun2010: true,
wmdecoder: true,
+ opengl: false,
}
-// Mapping objects for user-friendly values to internal values
export const DX_WRAPPER_MAPPING: Record = {
WineD3D: 'wined3d',
DXVK: 'dxvk',
@@ -136,9 +152,13 @@ export const DX_WRAPPER_MAPPING: Record = {
}
export const AUDIO_DRIVER_MAPPING: Record = {
+ alsa: 'alsa',
+ pulse: 'pulseaudio',
+ other: 'pulseaudio',
+ // Legacy/display label variants for backward compatibility
ALSA: 'alsa',
- PulseAudio: 'pulse', // Correct value is 'pulse' not 'pulseaudio'
- Other: 'alsa',
+ PulseAudio: 'pulseaudio',
+ Other: 'pulseaudio',
}
export const STARTUP_SELECTION_MAPPING: Record = {
@@ -149,98 +169,221 @@ export const STARTUP_SELECTION_MAPPING: Record = {
}
export const BOX64_PRESET_MAPPING: Record = {
+ stability: 'STABILITY',
+ compatibility: 'COMPATIBILITY',
+ intermediate: 'INTERMEDIATE',
+ performance: 'PERFORMANCE',
+ denuvo: 'DENUVO',
+ unity: 'UNITY',
+ unity_mono_bleeding_edge: 'UNITY_MONO_BLEEDING_EDGE',
+ other: 'COMPATIBILITY',
+ 'other/custom': 'COMPATIBILITY',
Stability: 'STABILITY',
Compatibility: 'COMPATIBILITY',
Intermediate: 'INTERMEDIATE',
Performance: 'PERFORMANCE',
+ Denuvo: 'DENUVO',
+ Unity: 'UNITY',
+ 'Unity Mono Bleeding Edge': 'UNITY_MONO_BLEEDING_EDGE',
'Other/Custom': 'COMPATIBILITY',
}
export const BOX86_PRESET_MAPPING: Record = {
+ stability: 'STABILITY',
+ compatibility: 'COMPATIBILITY',
+ intermediate: 'INTERMEDIATE',
+ performance: 'PERFORMANCE',
+ denuvo: 'DENUVO',
+ unity: 'UNITY',
+ unity_mono_bleeding_edge: 'UNITY_MONO_BLEEDING_EDGE',
+ 'other/custom': 'COMPATIBILITY',
Stability: 'STABILITY',
Compatibility: 'COMPATIBILITY',
Intermediate: 'INTERMEDIATE',
Performance: 'PERFORMANCE',
+ Denuvo: 'DENUVO',
+ Unity: 'UNITY',
+ 'Unity Mono Bleeding Edge': 'UNITY_MONO_BLEEDING_EDGE',
'Other/Custom': 'COMPATIBILITY',
}
+export const EMULATOR_MAPPING: Record = {
+ FEXCore: 'FEXCore',
+ Box64: 'Box64',
+ fex: 'FEXCore',
+ box: 'Box64',
+ Other: 'FEXCore',
+}
+
+export const CONTAINER_VARIANT_MAPPING: Record = {
+ glibc: 'glibc',
+ bionic: 'bionic',
+ Other: 'glibc',
+}
+
+export const STEAM_TYPE_MAPPING: Record = {
+ normal: 'normal',
+ light: 'light',
+ ultralight: 'ultralight',
+ ultra_light: 'ultralight',
+ Normal: 'normal',
+ Light: 'light',
+ Ultralight: 'ultralight',
+ Other: 'normal',
+}
+
+export const FEXCORE_PRESET_MAPPING: Record = {
+ stability: 'STABILITY',
+ compatibility: 'COMPATIBILITY',
+ intermediate: 'INTERMEDIATE',
+ performance: 'PERFORMANCE',
+ extreme: 'EXTREME',
+ denuvo: 'DENUVO',
+ other: 'INTERMEDIATE',
+ 'other/custom': 'INTERMEDIATE',
+ Stability: 'STABILITY',
+ Compatibility: 'COMPATIBILITY',
+ Intermediate: 'INTERMEDIATE',
+ Performance: 'PERFORMANCE',
+ Extreme: 'EXTREME',
+ Denuvo: 'DENUVO',
+ 'Other/Custom': 'INTERMEDIATE',
+}
+
+export const EXTERNAL_DISPLAY_MODE_MAPPING: Record = {
+ off: 'off',
+ touchpad: 'touchpad',
+ keyboard: 'keyboard',
+ hybrid: 'hybrid',
+ Off: 'off',
+ Touchpad: 'touchpad',
+ Keyboard: 'keyboard',
+ Hybrid: 'hybrid',
+ Other: 'off',
+}
+
+export const SHARPNESS_EFFECT_MAPPING: Record = {
+ None: 'None',
+ CAS: 'CAS',
+ DLS: 'DLS',
+ Other: 'None',
+}
+
const VALID_DXVK_VERSIONS: DxvkVersion[] = [
- '2.6.1-gplasync',
+ 'async-1.10.3',
+ '2.7.1',
'1.10.3',
+ '1.10.1',
'1.10.9-sarek',
+ '1.11.1-sarek',
'1.9.2',
'2.3.1',
'2.4-gplasync',
- 'async-1.10.3',
+ '2.4.1',
+ '2.4.1-gplasync',
+ '2.6.1-gplasync',
+ '2.6-arm64ec',
]
-/**
- * Default configuration values (from GameNative)
- */
+const VALID_VKD3D_VERSIONS: VKD3DVersion[] = ['2.6', '2.12', '2.13', '2.14.1', '3.0b']
+
+const VALID_BOX64_ALL_VERSIONS: Box64Version[] = [
+ '0.3.2',
+ '0.3.4',
+ '0.3.6',
+ '0.3.7',
+ '0.3.8',
+ '0.4.0',
+]
+
+const VALID_FEXCORE_VERSIONS: FEXCoreVersion[] = ['2507', '2508', '2511', '2512', '2601', '2603']
+
export const DEFAULT_CONFIG: Required = {
name: '',
- screenSize: '854x480',
+ screenSize: '1280x720',
envVars:
- 'ZINK_DESCRIPTORS=lazy ZINK_DEBUG=compact MESA_SHADER_CACHE_DISABLE=false MESA_SHADER_CACHE_MAX_SIZE=512MB mesa_glthread=true WINEESYNC=1 MESA_VK_WSI_PRESENT_MODE=mailbox TU_DEBUG=noconform',
+ 'WRAPPER_MAX_IMAGE_COUNT=0 ZINK_DESCRIPTORS=lazy ZINK_DEBUG=compact MESA_SHADER_CACHE_DISABLE=false MESA_SHADER_CACHE_MAX_SIZE=512MB mesa_glthread=true WINEESYNC=1 MESA_VK_WSI_PRESENT_MODE=mailbox TU_DEBUG=noconform DXVK_FRAME_RATE=60 PULSE_LATENCY_MSEC=144',
graphicsDriver: 'vortek',
graphicsDriverVersion: '',
+ graphicsDriverConfig: '',
dxwrapper: 'dxvk',
- dxvkVersion: '2.6.1-gplasync',
dxwrapperConfig: '',
- audioDriver: 'alsa',
+ audioDriver: 'pulseaudio',
wincomponents:
- 'direct3d=1,directsound=1,directmusic=0,directshow=0,directplay=0,vcrun2010=1,wmdecoder=1',
+ 'direct3d=1,directsound=1,directmusic=0,directshow=0,directplay=0,vcrun2010=1,wmdecoder=1,opengl=0',
+ drives: '',
execArgs: '',
executablePath: '',
+ installPath: '',
showFPS: false,
launchRealSteam: false,
- cpuList: '0,1,2,3,4,5,6,7', // Dynamic: matches Runtime.getRuntime().availableProcessors()
- cpuListWoW64: '0,1,2,3,4,5,6,7', // Same as cpuList
+ allowSteamUpdates: false,
+ steamType: 'normal',
+ cpuList: '0,1,2,3,4,5,6,7',
+ cpuListWoW64: '0,1,2,3,4,5,6,7',
wow64Mode: true,
- startupSelection: 1, // STARTUP_SELECTION_ESSENTIAL
+ startupSelection: 1,
box86Version: '0.3.2',
box64Version: '0.3.6',
box86Preset: 'COMPATIBILITY',
box64Preset: 'COMPATIBILITY',
- desktopTheme: 'LIGHT,IMAGE,#0277bd,854x480', // TODO: check if `854x480` is correct and needed
- sdlControllerAPI: true,
- enableXInput: true,
- enableDInput: true,
- dinputMapperType: 1,
- disableMouseInput: false,
+ desktopTheme: 'LIGHT,IMAGE,#0277bd',
+ containerVariant: 'glibc',
+ wineVersion: 'wine-9.2-x86_64',
+ emulator: 'FEXCore',
+ fexcoreVersion: '2603',
+ fexcoreTSOMode: 'Fast',
+ fexcoreX87Mode: 'Fast',
+ fexcoreMultiBlock: 'Disabled',
+ fexcorePreset: 'INTERMEDIATE',
+ renderer: 'gl',
csmt: true,
videoPciDeviceID: 1728,
offScreenRenderingMode: 'fbo',
strictShaderMath: true,
+ useDRI3: true,
videoMemorySize: '2048',
mouseWarpOverride: 'disable',
shaderBackend: 'glsl',
useGLSL: 'enabled',
+ sdlControllerAPI: true,
+ useSteamInput: false,
+ enableXInput: true,
+ enableDInput: true,
+ dinputMapperType: 1,
+ disableMouseInput: false,
+ touchscreenMode: false,
+ shooterMode: true,
+ gestureConfig: '',
+ externalDisplayMode: 'off',
+ externalDisplaySwap: false,
+ language: 'english',
+ forceDlc: false,
+ steamOfflineMode: false,
+ useLegacyDRM: false,
+ unpackFiles: false,
+ portraitMode: false,
+ sharpnessEffect: 'None',
+ sharpnessLevel: 100,
+ sharpnessDenoise: 100,
}
-// Helper functions for validation
export const GameNativeDefaults = {
- // Environment variables
getDefaultEnvVars: (): string => DEFAULT_ENV_VARS,
-
- // Screen size
getDefaultScreenSize: (): ScreenSize => DEFAULT_SCREEN_SIZE,
-
- // Graphics
getDefaultGraphicsDriver: (): GraphicsDriver => DEFAULT_GRAPHICS_DRIVER,
getDefaultDxWrapper: (): DxWrapper => DEFAULT_DX_WRAPPER,
getDefaultDxvkVersion: (): DxvkVersion => DEFAULT_DXVK_VERSION,
-
- // Audio
getDefaultAudioDriver: (): AudioDriver => DEFAULT_AUDIO_DRIVER,
-
- // System
getDefaultStartupSelection: (): StartupSelection => DEFAULT_STARTUP_SELECTION,
getDefaultBox64Version: (): Box64Version => DEFAULT_BOX64_VERSION,
getDefaultBox86Version: (): Box86Version => DEFAULT_BOX86_VERSION,
getDefaultBoxPreset: (): Box86_64Preset => DEFAULT_BOX_PRESET,
-
- // Video/Graphics settings
+ getDefaultEmulator: (): Emulator => DEFAULT_EMULATOR,
+ getDefaultContainerVariant: (): ContainerVariant => DEFAULT_CONTAINER_VARIANT,
+ getDefaultSteamType: (): SteamType => DEFAULT_STEAM_TYPE,
+ getDefaultFexcoreVersion: (): FEXCoreVersion => DEFAULT_FEXCORE_VERSION,
+ getDefaultFexcorePreset: (): FEXCorePreset => DEFAULT_FEXCORE_PRESET,
getDefaultVideoMemorySize: (): string => DEFAULT_VIDEO_MEMORY_SIZE,
getDefaultCpuList: (): string => DEFAULT_CPU_LIST,
getDefaultVideoPciDeviceId: (): number => DEFAULT_VIDEO_PCI_DEVICE_ID,
@@ -248,47 +391,53 @@ export const GameNativeDefaults = {
getDefaultMouseWarpOverride: (): string => DEFAULT_MOUSE_WARP_OVERRIDE,
getDefaultShaderBackend: (): string => DEFAULT_SHADER_BACKEND,
getDefaultUseGlsl: (): string => DEFAULT_USE_GLSL,
-
- // Windows components
getDefaultWindowsComponents: () => ({ ...DEFAULT_WINDOWS_COMPONENTS }),
- // Validation functions
isValidVideoMemorySize: (size: string): boolean => VALID_VIDEO_MEMORY_SIZES.includes(size),
isValidOffscreenRenderingMode: (mode: string): boolean =>
VALID_OFFSCREEN_RENDERING_MODES.includes(mode.toLowerCase()),
isValidMouseWarpOverride: (mode: string): boolean =>
VALID_MOUSE_WARP_OVERRIDE_MODES.includes(mode.toLowerCase()),
isValidUseGlsl: (value: string): boolean => VALID_USE_GLSL_VALUES.includes(value.toLowerCase()),
- isValidBox64Version: (version: string): boolean => version === '0.3.6' || version === '0.3.4',
- isValidBox86Version: (version: string): boolean => version === '0.3.2' || version === '0.3.7',
- isValidDxvkVersion: (version: string): boolean =>
- VALID_DXVK_VERSIONS.includes(version as DxvkVersion),
+ isValidBox64Version: (version: string): version is Box64Version =>
+ VALID_BOX64_ALL_VERSIONS.some((v) => v === version),
+ isValidBox86Version: (version: string): version is Box86Version =>
+ version === '0.3.2' || version === '0.3.7',
+ isValidDxvkVersion: (version: string): version is DxvkVersion =>
+ VALID_DXVK_VERSIONS.some((v) => v === version),
+ isValidVkd3dVersion: (version: string): version is VKD3DVersion =>
+ VALID_VKD3D_VERSIONS.some((v) => v === version),
+ isValidFexcoreVersion: (version: string): version is FEXCoreVersion =>
+ VALID_FEXCORE_VERSIONS.some((v) => v === version),
+ isValidEmulator: (value: string): value is Emulator => value === 'FEXCore' || value === 'Box64',
+ isValidContainerVariant: (value: string): value is ContainerVariant =>
+ value === 'glibc' || value === 'bionic',
- // Graphics driver detection with smart fallback for legacy TEXT values
detectGraphicsDriver: (value: string): GraphicsDriver => {
if (!value) return DEFAULT_GRAPHICS_DRIVER
const cleanValue = value.trim()
- // First try exact match for new SELECT values (these are reliable)
if (GRAPHICS_DRIVER_MAPPING[cleanValue]) return GRAPHICS_DRIVER_MAPPING[cleanValue]
- // For legacy TEXT values, normalize by trimming whitespace and converting to lowercase
const normalizedValue = cleanValue.toLowerCase()
- // Try exact match after normalization
for (const [key, driver] of Object.entries(GRAPHICS_DRIVER_MAPPING)) {
if (key.toLowerCase() === normalizedValue) return driver
}
- // Try substring matching for legacy TEXT entries (handles messy user input)
- if (normalizedValue.includes('turnip') || normalizedValue.includes('adreno')) {
- return 'turnip'
+ if (normalizedValue.includes('sd-8-elite') || normalizedValue.includes('sd 8 elite')) {
+ return 'sd-8-elite'
}
+ if (normalizedValue.includes('wrapper-leegao')) return 'wrapper-leegao'
+ if (normalizedValue.includes('wrapper-legacy')) return 'wrapper-legacy'
+ if (normalizedValue.includes('wrapper-v2')) return 'wrapper-v2'
+ if (normalizedValue.includes('wrapper')) return 'wrapper'
+ if (normalizedValue.includes('turnip')) return 'turnip'
+ if (normalizedValue.includes('adreno')) return 'adreno'
if (normalizedValue.includes('virgl')) return 'virgl'
if (normalizedValue.includes('vortek')) return 'vortek'
- // If we can't determine, fall back to default
return DEFAULT_GRAPHICS_DRIVER
},
}
diff --git a/src/server/utils/emulator-config/gamenative/gamenative.types.ts b/src/server/utils/emulator-config/gamenative/gamenative.types.ts
index 1f810c6d..175c670b 100644
--- a/src/server/utils/emulator-config/gamenative/gamenative.types.ts
+++ b/src/server/utils/emulator-config/gamenative/gamenative.types.ts
@@ -1,15 +1,14 @@
/**
* GameNative Container Configuration Type Definitions
*
- * Contains all type definitions for GameNative container configuration files.
+ * Source of truth: ContainerData.kt, Container.java, DefaultVersion.java, arrays.xml
*/
-// Screen resolution options
export type ScreenSize =
| '640x480' // 4:3
| '800x600' // 4:3
| '854x480' // 16:9 (default)
- | '960x544' // 16:9
+ | '960x540' // 16:9
| '1024x768' // 4:3
| '1280x720' // 16:9
| '1280x800' // 16:10
@@ -20,242 +19,234 @@ export type ScreenSize =
| '1920x1080' // 16:9
| string // Custom format: "WIDTHxHEIGHT"
-// Graphics driver options
export type GraphicsDriver =
- | 'vortek' // Universal (default) - uses Zink backend
- | 'turnip' // Adreno GPUs - uses Zink + Turnip Vulkan
- | 'virgl' // Universal - uses VirGL
+ | 'vortek'
+ | 'turnip'
+ | 'virgl'
+ | 'adreno'
+ | 'sd-8-elite'
+ | 'wrapper'
+ | 'wrapper-v2'
+ | 'wrapper-leegao'
+ | 'wrapper-legacy'
-// Graphics driver versions by driver type
export type GraphicsDriverVersions = {
- turnip: '25.1.0' | '25.2.0' | '25.0.0' | '24.1.0'
+ turnip: '25.1.0' | '25.2.0' | '25.3.0' | '25.0.0' | '24.1.0'
virgl: '23.1.9'
- vortek: '2.0'
- zink: '22.2.5' // Used internally by vortek/turnip
+ vortek: '2.1'
+ zink: '22.2.5'
+ adreno: '819.2' | '805'
+ sd8elite: '800.51' | '2-842.6'
}
-// DirectX wrapper options
-export type DxWrapper =
- | 'dxvk' // DXVK (default)
- | 'vkd3d' // VKD3D
- | 'wined3d' // WineD3D
- | 'cnc-ddraw' // CNC DDraw
+export type DxWrapper = 'dxvk' | 'vkd3d' | 'wined3d' | 'cnc-ddraw'
-// DXVK version options
export type DxvkVersion =
- | '2.6.1-gplasync'
+ | 'async-1.10.3'
+ | '2.7.1'
| '1.10.3'
+ | '1.10.1'
| '1.10.9-sarek'
+ | '1.11.1-sarek'
| '1.9.2'
| '2.3.1'
| '2.4-gplasync'
- | 'async-1.10.3'
+ | '2.4.1'
+ | '2.4.1-gplasync'
+ | '2.6.1-gplasync'
+ | '2.6-arm64ec'
+
+export type VKD3DVersion = '2.6' | '2.12' | '2.13' | '2.14.1' | '3.0b'
-// Audio driver options
-export type AudioDriver =
- | 'alsa' // ALSA (default)
- | 'pulse' // PulseAudio
+export type AudioDriver = 'alsa' | 'pulseaudio'
-// Video memory size options
export type VideoMemorySize =
- | '32' // 32 MB
- | '64' // 64 MB
- | '128' // 128 MB
- | '256' // 256 MB
- | '512' // 512 MB
- | '1024' // 1024 MB
- | '2048' // 2048 MB (default)
- | '4096' // 4096 MB
- | '6144' // 6144 MB
- | '8192' // 8192 MB
- | '10240' // 10240 MB
- | '12288' // 12288 MB
+ | '32'
+ | '64'
+ | '128'
+ | '256'
+ | '512'
+ | '1024'
+ | '2048'
+ | '4096'
+ | '6144'
+ | '8192'
+ | '10240'
+ | '12288'
-// Box86/64 performance presets
export type Box86_64Preset =
| 'STABILITY'
- | 'COMPATIBILITY' // Default
+ | 'COMPATIBILITY'
| 'INTERMEDIATE'
| 'PERFORMANCE'
- | string // Custom presets start with "CUSTOM-"
+ | 'DENUVO'
+ | 'UNITY'
+ | 'UNITY_MONO_BLEEDING_EDGE'
+ | 'CUSTOM'
+ | string
-// Box86/64 version options
export type Box86Version = '0.3.2' | '0.3.7'
-export type Box64Version = '0.3.6' | '0.3.4'
+export type Box64Version = '0.3.2' | '0.3.4' | '0.3.6' | '0.3.7' | '0.3.8' | '0.4.0'
+
+export type FEXCorePreset =
+ | 'STABILITY'
+ | 'COMPATIBILITY'
+ | 'INTERMEDIATE'
+ | 'PERFORMANCE'
+ | 'EXTREME'
+ | 'DENUVO'
+ | 'CUSTOM'
+ | string
-// Startup selection modes
-export type StartupSelection =
- | 0 // Normal (Load all services)
- | 1 // Essential (Load only essential services) - Default
- | 2 // Aggressive (Stop services on startup)
+export type FEXCoreVersion = '2507' | '2508' | '2511' | '2512' | '2601' | '2603'
+
+export type FEXCoreTSOMode = 'Fast' | 'Slow'
+export type FEXCoreX87Mode = 'Fast' | 'Slow'
+export type FEXCoreMultiBlock = 'Enabled' | 'Disabled'
+
+export type Emulator = 'FEXCore' | 'Box64'
+
+export type ContainerVariant = 'glibc' | 'bionic'
+
+export type SteamType = 'normal' | 'light' | 'ultralight'
+
+export type ExternalDisplayMode = 'off' | 'touchpad' | 'keyboard' | 'hybrid'
+
+export type SharpnessEffect = 'None' | 'CAS' | 'DLS'
+
+export type StartupSelection = 0 | 1 | 2
-// Wine desktop theme options
export type DesktopTheme = 'LIGHT' | 'DARK'
-// Wine desktop background type
-export type DesktopBackgroundType =
- | 'IMAGE' // Default
- | 'COLOR'
+export type DesktopBackgroundType = 'IMAGE' | 'COLOR'
-// Off-screen rendering modes
-export type OffScreenRenderingMode =
- | 'fbo' // FBO (default)
- | 'backbuffer' // Backbuffer
+export type OffScreenRenderingMode = 'fbo' | 'backbuffer'
-// Mouse warp override options
-export type MouseWarpOverride =
- | 'disable' // Disable (default)
- | 'enable' // Enable
- | 'force' // Force
+export type MouseWarpOverride = 'disable' | 'enable' | 'force'
-// DirectInput mapper type
-export type DinputMapperType =
- | 0 // Standard (Old Gamepads)
- | 1 // XInput (default)
+export type DinputMapperType = 1 | 2
-// Shader backend options
export type ShaderBackend = 'glsl'
-// Wine component configuration
export type WinComponents = string
-
-// Environment variables
export type EnvVars = string
-
-// CPU list format
export type CpuList = string
-/**
- * Complete container configuration interface
- */
+export type VulkanVersion = '1.1' | '1.2' | '1.3'
+export type BcnEmulation = 'none' | 'partial' | 'full' | 'auto'
+export type BcnEmulationType = 'software' | 'compute'
+export type PresentMode = 'mailbox' | 'fifo' | 'immediate' | 'relaxed'
+export type ResourceType = 'auto' | 'dmabuf' | 'ahb' | 'opaque'
+
+export interface DxWrapperConfig {
+ version?: DxvkVersion
+ framerate?: number
+ maxDeviceMemory?: number
+ async?: '0' | '1'
+ asyncCache?: '0' | '1'
+ vkd3dVersion?: VKD3DVersion
+ vkd3dLevel?: string
+ ddrawrapper?: 'none' | string
+ csmt?: number
+ gpuName?: string
+ videoMemorySize?: string
+ strict_shader_math?: '0' | '1'
+ OffscreenRenderingMode?: OffScreenRenderingMode
+ renderer?: string
+}
+
+export interface GraphicsDriverConfig {
+ vulkanVersion?: VulkanVersion
+ version?: string
+ blacklistedExtensions?: string
+ maxDeviceMemory?: number
+ presentMode?: PresentMode
+ syncFrame?: '0' | '1'
+ disablePresentWait?: '0' | '1'
+ resourceType?: ResourceType
+ bcnEmulation?: BcnEmulation
+ bcnEmulationType?: BcnEmulationType
+ bcnEmulationCache?: '0' | '1'
+ gpuName?: string
+ adrenotoolsTurnip?: '0' | '1'
+}
+
export interface ContainerConfig {
- /** Container name */
name?: string
-
- /** Screen resolution */
screenSize?: ScreenSize
-
- /** Environment variables */
envVars?: EnvVars
-
- /** Graphics driver */
graphicsDriver?: GraphicsDriver
-
- /** Graphics driver version */
graphicsDriverVersion?: string
-
- /** DirectX wrapper */
+ graphicsDriverConfig?: string
dxwrapper?: DxWrapper
-
- /** DirectX wrapper configuration */
dxwrapperConfig?: string
-
- /** DXVK version */
- dxvkVersion?: DxvkVersion
-
- /** Audio driver */
audioDriver?: AudioDriver
-
- /** Windows components configuration */
wincomponents?: WinComponents
-
- /** Execution arguments */
+ drives?: string
execArgs?: string
-
- /** Executable path */
executablePath?: string
-
- /** Show FPS overlay */
+ installPath?: string
showFPS?: boolean
-
- /** Launch real Steam client */
launchRealSteam?: boolean
-
- /** CPU core list */
+ allowSteamUpdates?: boolean
+ steamType?: SteamType
cpuList?: CpuList
-
- /** CPU core list for WoW64 */
cpuListWoW64?: CpuList
-
- /** Enable WoW64 mode */
wow64Mode?: boolean
-
- /** Startup selection mode */
startupSelection?: StartupSelection
-
- /** Box86 version */
box86Version?: Box86Version
-
- /** Box64 version */
box64Version?: Box64Version
-
- /** Box86 performance preset */
box86Preset?: Box86_64Preset
-
- /** Box64 performance preset */
box64Preset?: Box86_64Preset
-
- /**
- * Desktop theme configuration
- * Format: "THEME,BACKGROUND_TYPE,COLOR"
- *
- * Examples:
- * - "LIGHT,IMAGE,#0277bd" (default)
- * - "DARK,COLOR,#000000"
- * - "LIGHT,COLOR,#ffffff"
- */
desktopTheme?: string
-
- /** Enable SDL controller API */
- sdlControllerAPI?: boolean
-
- /** Enable XInput support */
- enableXInput?: boolean
-
- /** Enable DirectInput support */
- enableDInput?: boolean
-
- /** DirectInput mapper type */
- dinputMapperType?: DinputMapperType
-
- /** Disable mouse input */
- disableMouseInput?: boolean
-
- /** Enable Command Stream Multithreading */
+ containerVariant?: ContainerVariant
+ wineVersion?: string
+ emulator?: Emulator
+ fexcoreVersion?: FEXCoreVersion | string
+ fexcoreTSOMode?: FEXCoreTSOMode
+ fexcoreX87Mode?: FEXCoreX87Mode
+ fexcoreMultiBlock?: FEXCoreMultiBlock
+ fexcorePreset?: FEXCorePreset
+ renderer?: string
csmt?: boolean
-
- /**
- * Video PCI device ID for GPU emulation
- * Default: 1728 (NVIDIA GeForce GTX 480)
- */
videoPciDeviceID?: number
-
- /** Off-screen rendering mode */
offScreenRenderingMode?: OffScreenRenderingMode
-
- /** Enable strict shader math */
strictShaderMath?: boolean
-
- /** Video memory size in MB */
+ useDRI3?: boolean
videoMemorySize?: VideoMemorySize
-
- /** Mouse warp override setting */
mouseWarpOverride?: MouseWarpOverride
-
- /** Shader backend */
shaderBackend?: ShaderBackend
-
- /** Use GLSL shaders */
useGLSL?: 'enabled' | 'disabled'
+ sdlControllerAPI?: boolean
+ useSteamInput?: boolean
+ enableXInput?: boolean
+ enableDInput?: boolean
+ dinputMapperType?: DinputMapperType
+ disableMouseInput?: boolean
+ touchscreenMode?: boolean
+ shooterMode?: boolean
+ gestureConfig?: string
+ externalDisplayMode?: ExternalDisplayMode
+ externalDisplaySwap?: boolean
+ language?: string
+ forceDlc?: boolean
+ steamOfflineMode?: boolean
+ useLegacyDRM?: boolean
+ unpackFiles?: boolean
+ portraitMode?: boolean
+ sharpnessEffect?: SharpnessEffect
+ sharpnessLevel?: number
+ sharpnessDenoise?: number
}
-/**
- * Helper type for graphics driver version based on selected driver
- */
export type GraphicsDriverVersionFor = T extends 'turnip'
? GraphicsDriverVersions['turnip']
: T extends 'virgl'
? GraphicsDriverVersions['virgl']
: T extends 'vortek'
? GraphicsDriverVersions['vortek']
- : string
+ : T extends 'adreno'
+ ? GraphicsDriverVersions['adreno']
+ : T extends 'sd-8-elite'
+ ? GraphicsDriverVersions['sd8elite']
+ : string
diff --git a/src/server/utils/moderator-info.test.ts b/src/server/utils/moderator-info.test.ts
new file mode 100644
index 00000000..fc32b36e
--- /dev/null
+++ b/src/server/utils/moderator-info.test.ts
@@ -0,0 +1,51 @@
+import { describe, expect, it } from 'vitest'
+import { computeVoteCounts, type VoteForCounting } from './moderator-info'
+
+function vote(value: boolean, nullifiedAt: Date | null = null): VoteForCounting {
+ return { value, nullifiedAt }
+}
+
+describe('computeVoteCounts', () => {
+ it('returns zeroes for an empty array', () => {
+ expect(computeVoteCounts([])).toEqual({ up: 0, down: 0, nullified: 0 })
+ })
+
+ it('counts only active upvotes in `up`', () => {
+ const result = computeVoteCounts([vote(true), vote(true), vote(true)])
+ expect(result).toEqual({ up: 3, down: 0, nullified: 0 })
+ })
+
+ it('counts only active downvotes in `down`', () => {
+ const result = computeVoteCounts([vote(false), vote(false)])
+ expect(result).toEqual({ up: 0, down: 2, nullified: 0 })
+ })
+
+ it('counts nullified votes separately, regardless of value', () => {
+ const result = computeVoteCounts([
+ vote(true, new Date('2026-01-01')),
+ vote(false, new Date('2026-01-02')),
+ vote(true, new Date('2026-01-03')),
+ ])
+ expect(result).toEqual({ up: 0, down: 0, nullified: 3 })
+ })
+
+ it('returns the correct mix when active and nullified votes are interleaved', () => {
+ const result = computeVoteCounts([
+ vote(true), // up
+ vote(true, new Date()), // nullified
+ vote(false), // down
+ vote(false, new Date()), // nullified
+ vote(true), // up
+ vote(true), // up
+ vote(false), // down
+ ])
+ expect(result).toEqual({ up: 3, down: 2, nullified: 2 })
+ })
+
+ it('does NOT double-count: a nullified upvote contributes only to `nullified`', () => {
+ const result = computeVoteCounts([vote(true, new Date('2026-01-01'))])
+ expect(result.up).toBe(0)
+ expect(result.down).toBe(0)
+ expect(result.nullified).toBe(1)
+ })
+})
diff --git a/src/server/utils/moderator-info.ts b/src/server/utils/moderator-info.ts
new file mode 100644
index 00000000..c1ec794d
--- /dev/null
+++ b/src/server/utils/moderator-info.ts
@@ -0,0 +1,27 @@
+export interface VoteCounts {
+ up: number
+ down: number
+ nullified: number
+}
+
+// A nullified vote counts only in `nullified`, not in up/down.
+export interface VoteForCounting {
+ value: boolean
+ nullifiedAt: Date | null
+}
+
+export function computeVoteCounts(votes: readonly VoteForCounting[]): VoteCounts {
+ let up = 0
+ let down = 0
+ let nullified = 0
+ for (const vote of votes) {
+ if (vote.nullifiedAt !== null) {
+ nullified += 1
+ } else if (vote.value) {
+ up += 1
+ } else {
+ down += 1
+ }
+ }
+ return { up, down, nullified }
+}
diff --git a/src/server/utils/spam-check.test.ts b/src/server/utils/spam-check.test.ts
new file mode 100644
index 00000000..850a8cce
--- /dev/null
+++ b/src/server/utils/spam-check.test.ts
@@ -0,0 +1,107 @@
+import { TRPCError } from '@trpc/server'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import analytics from '@/lib/analytics'
+import { type PrismaClient } from '@orm'
+import { checkSpamContent } from './spam-check'
+import { SpamDetectionService } from './spamDetection'
+
+vi.mock('@/lib/analytics', () => ({
+ default: {
+ contentQuality: {
+ spamDetected: vi.fn(),
+ },
+ },
+}))
+
+const mockPrisma = {} as unknown as PrismaClient
+
+afterEach(() => vi.restoreAllMocks())
+
+describe('checkSpamContent', () => {
+ const USER_ID = 'user-123'
+
+ it('returns without side effects when content is not spam', async () => {
+ const detectSpy = vi.spyOn(SpamDetectionService.prototype, 'detectSpam').mockResolvedValue({
+ isSpam: false,
+ confidence: 0.1,
+ method: 'content_analysis',
+ })
+
+ await expect(
+ checkSpamContent({
+ prisma: mockPrisma,
+ userId: USER_ID,
+ content: 'Clean content',
+ entityType: 'listing',
+ }),
+ ).resolves.toBeUndefined()
+
+ expect(detectSpy).toHaveBeenCalledOnce()
+ expect(analytics.contentQuality.spamDetected).not.toHaveBeenCalled()
+ })
+
+ it('emits analytics and throws AppError.badRequest with reason when spam detected', async () => {
+ vi.spyOn(SpamDetectionService.prototype, 'detectSpam').mockResolvedValue({
+ isSpam: true,
+ confidence: 0.95,
+ method: 'rate_limiting',
+ reason: 'Exceeded rate limit: 4 listings in 5 minutes',
+ })
+
+ await expect(
+ checkSpamContent({
+ prisma: mockPrisma,
+ userId: USER_ID,
+ content: 'Flagged content',
+ entityType: 'listing',
+ }),
+ ).rejects.toThrow(/Spam detected: Exceeded rate limit/)
+
+ expect(analytics.contentQuality.spamDetected).toHaveBeenCalledTimes(1)
+ expect(analytics.contentQuality.spamDetected).toHaveBeenCalledWith({
+ entityType: 'listing',
+ entityId: USER_ID,
+ confidence: 0.95,
+ method: 'rate_limiting',
+ })
+ })
+
+ it('falls back to the community-guidelines message when reason is missing', async () => {
+ vi.spyOn(SpamDetectionService.prototype, 'detectSpam').mockResolvedValue({
+ isSpam: true,
+ confidence: 0.8,
+ method: 'pattern_matching',
+ })
+
+ await expect(
+ checkSpamContent({
+ prisma: mockPrisma,
+ userId: USER_ID,
+ content: 'Looks spammy but no explicit reason',
+ entityType: 'comment',
+ }),
+ ).rejects.toThrow(/community guidelines/i)
+ })
+
+ it('throws a BAD_REQUEST TRPCError', async () => {
+ vi.spyOn(SpamDetectionService.prototype, 'detectSpam').mockResolvedValue({
+ isSpam: true,
+ confidence: 0.9,
+ method: 'duplicate_detection',
+ reason: 'Duplicate of recent submission',
+ })
+
+ try {
+ await checkSpamContent({
+ prisma: mockPrisma,
+ userId: USER_ID,
+ content: 'duplicate',
+ entityType: 'comment',
+ })
+ throw new Error('Expected checkSpamContent to throw')
+ } catch (error) {
+ expect(error).toBeInstanceOf(TRPCError)
+ expect((error as TRPCError).code).toBe('BAD_REQUEST')
+ }
+ })
+})
diff --git a/src/server/utils/spam-check.ts b/src/server/utils/spam-check.ts
new file mode 100644
index 00000000..7890aefb
--- /dev/null
+++ b/src/server/utils/spam-check.ts
@@ -0,0 +1,35 @@
+import analytics from '@/lib/analytics'
+import { AppError } from '@/lib/errors'
+import { SpamDetectionService } from '@/server/utils/spamDetection'
+import { type PrismaClient } from '@orm'
+
+type SpamEntityType = 'listing' | 'comment'
+
+interface CheckSpamContentParams {
+ prisma: PrismaClient
+ userId: string
+ content: string
+ entityType: SpamEntityType
+}
+
+export async function checkSpamContent(params: CheckSpamContentParams): Promise {
+ const detector = new SpamDetectionService(params.prisma)
+ const result = await detector.detectSpam({
+ userId: params.userId,
+ content: params.content,
+ entityType: params.entityType,
+ })
+
+ if (!result.isSpam) return
+
+ analytics.contentQuality.spamDetected({
+ entityType: params.entityType,
+ entityId: params.userId,
+ confidence: result.confidence,
+ method: result.method,
+ })
+
+ throw AppError.badRequest(
+ `Spam detected: ${result.reason || 'Your content appears to be spam. Please review our community guidelines.'}`,
+ )
+}
diff --git a/src/server/utils/spamDetection.ts b/src/server/utils/spamDetection.ts
index 6c776ac5..7d7b3a81 100644
--- a/src/server/utils/spamDetection.ts
+++ b/src/server/utils/spamDetection.ts
@@ -43,7 +43,7 @@ const DEFAULT_CONFIG: Required = {
}
/**
- * Comprehensive spam detection service
+ * Spam detection service
* Checks content for spam using multiple detection methods
*/
export class SpamDetectionService {
diff --git a/src/server/utils/vote-trust-effects.test.ts b/src/server/utils/vote-trust-effects.test.ts
index dd572207..825c55fb 100644
--- a/src/server/utils/vote-trust-effects.test.ts
+++ b/src/server/utils/vote-trust-effects.test.ts
@@ -1,283 +1,646 @@
import { describe, expect, it, beforeEach, vi } from 'vitest'
import { TrustAction } from '@orm'
-import { handleVoteTrustEffects } from './vote-trust-effects'
+import { handleCommentVoteTrustEffects, handleListingVoteTrustEffects } from './vote-trust-effects'
-const mockApplyTrustAction = vi.fn()
-const mockReverseTrustAction = vi.fn()
+const mockLogAction = vi.fn()
+const mockReverseLogAction = vi.fn()
vi.mock('@/lib/trust/service', () => ({
- applyTrustAction: (...args: unknown[]) => mockApplyTrustAction(...args),
- reverseTrustAction: (...args: unknown[]) => mockReverseTrustAction(...args),
+ TrustService: vi.fn().mockImplementation(() => ({
+ logAction: mockLogAction,
+ reverseLogAction: mockReverseLogAction,
+ })),
}))
const USER_ID = 'voter-1'
const AUTHOR_ID = 'author-1'
const LISTING_ID = 'listing-1'
+const mockTx = {} as Parameters[0]['tx']
-describe('handleVoteTrustEffects', () => {
- beforeEach(() => {
- mockApplyTrustAction.mockResolvedValue(undefined)
- mockReverseTrustAction.mockResolvedValue(undefined)
- })
+beforeEach(() => {
+ vi.clearAllMocks()
+ mockLogAction.mockResolvedValue(undefined)
+ mockReverseLogAction.mockResolvedValue(undefined)
+})
- describe('vote created', () => {
- it('applies UPVOTE trust for voter on upvote', async () => {
- await handleVoteTrustEffects({
+describe('handleListingVoteTrustEffects', () => {
+ describe('created action', () => {
+ it('applies UPVOTE for voter and LISTING_RECEIVED_UPVOTE for author on upvote', async () => {
+ await handleListingVoteTrustEffects({
+ tx: mockTx,
action: 'created',
currentValue: true,
previousValue: null,
userId: USER_ID,
listingId: LISTING_ID,
+ listingType: 'handheld',
authorId: AUTHOR_ID,
})
- expect(mockApplyTrustAction).toHaveBeenCalledWith({
+ expect(mockLogAction).toHaveBeenCalledTimes(2)
+ expect(mockLogAction).toHaveBeenNthCalledWith(1, {
userId: USER_ID,
action: TrustAction.UPVOTE,
- context: { listingId: LISTING_ID },
+ metadata: { listingId: LISTING_ID },
})
- })
-
- it('applies DOWNVOTE trust for voter on downvote', async () => {
- await handleVoteTrustEffects({
- action: 'created',
- currentValue: false,
- previousValue: null,
- userId: USER_ID,
- listingId: LISTING_ID,
- authorId: AUTHOR_ID,
- })
-
- expect(mockApplyTrustAction).toHaveBeenCalledWith({
- userId: USER_ID,
- action: TrustAction.DOWNVOTE,
- context: { listingId: LISTING_ID },
- })
- })
-
- it('applies LISTING_RECEIVED_UPVOTE trust for author on upvote', async () => {
- await handleVoteTrustEffects({
- action: 'created',
- currentValue: true,
- previousValue: null,
- userId: USER_ID,
- listingId: LISTING_ID,
- authorId: AUTHOR_ID,
- })
-
- expect(mockApplyTrustAction).toHaveBeenCalledWith({
+ expect(mockLogAction).toHaveBeenNthCalledWith(2, {
userId: AUTHOR_ID,
action: TrustAction.LISTING_RECEIVED_UPVOTE,
- context: { listingId: LISTING_ID, voterId: USER_ID },
+ metadata: { listingId: LISTING_ID, voterId: USER_ID },
})
+ expect(mockReverseLogAction).not.toHaveBeenCalled()
})
- it('applies LISTING_RECEIVED_DOWNVOTE trust for author on downvote', async () => {
- await handleVoteTrustEffects({
+ it('applies DOWNVOTE for voter and LISTING_RECEIVED_DOWNVOTE for author on downvote', async () => {
+ await handleListingVoteTrustEffects({
+ tx: mockTx,
action: 'created',
currentValue: false,
previousValue: null,
userId: USER_ID,
listingId: LISTING_ID,
+ listingType: 'handheld',
authorId: AUTHOR_ID,
})
- expect(mockApplyTrustAction).toHaveBeenCalledWith({
+ expect(mockLogAction).toHaveBeenCalledWith({
+ userId: USER_ID,
+ action: TrustAction.DOWNVOTE,
+ metadata: { listingId: LISTING_ID },
+ })
+ expect(mockLogAction).toHaveBeenCalledWith({
userId: AUTHOR_ID,
action: TrustAction.LISTING_RECEIVED_DOWNVOTE,
- context: { listingId: LISTING_ID, voterId: USER_ID },
+ metadata: { listingId: LISTING_ID, voterId: USER_ID },
})
})
it('skips author trust on self-vote', async () => {
- await handleVoteTrustEffects({
+ await handleListingVoteTrustEffects({
+ tx: mockTx,
action: 'created',
currentValue: true,
previousValue: null,
userId: USER_ID,
listingId: LISTING_ID,
+ listingType: 'handheld',
authorId: USER_ID,
})
- expect(mockApplyTrustAction).toHaveBeenCalledTimes(1)
- expect(mockApplyTrustAction).toHaveBeenCalledWith(
+ expect(mockLogAction).toHaveBeenCalledTimes(1)
+ expect(mockLogAction).toHaveBeenCalledWith(
expect.objectContaining({ userId: USER_ID, action: TrustAction.UPVOTE }),
)
})
it('skips author trust when authorId is null', async () => {
- await handleVoteTrustEffects({
+ await handleListingVoteTrustEffects({
+ tx: mockTx,
action: 'created',
currentValue: true,
previousValue: null,
userId: USER_ID,
listingId: LISTING_ID,
+ listingType: 'handheld',
authorId: null,
})
- expect(mockApplyTrustAction).toHaveBeenCalledTimes(1)
+ expect(mockLogAction).toHaveBeenCalledTimes(1)
})
- it('does not call reverseTrustAction', async () => {
- await handleVoteTrustEffects({
+ it('uses pcListingId context key for PC listings', async () => {
+ await handleListingVoteTrustEffects({
+ tx: mockTx,
action: 'created',
currentValue: true,
previousValue: null,
userId: USER_ID,
listingId: LISTING_ID,
+ listingType: 'pc',
authorId: AUTHOR_ID,
})
- expect(mockReverseTrustAction).not.toHaveBeenCalled()
+ expect(mockLogAction).toHaveBeenCalledWith({
+ userId: USER_ID,
+ action: TrustAction.UPVOTE,
+ metadata: { pcListingId: LISTING_ID },
+ })
+ expect(mockLogAction).toHaveBeenCalledWith({
+ userId: AUTHOR_ID,
+ action: TrustAction.LISTING_RECEIVED_UPVOTE,
+ metadata: { pcListingId: LISTING_ID, voterId: USER_ID },
+ })
})
})
- describe('vote updated', () => {
- it('applies trust for the new vote value', async () => {
- await handleVoteTrustEffects({
+ describe('updated action (vote change β reverse then apply)', () => {
+ it('reverses previous upvote and applies downvote when changing from up to down', async () => {
+ await handleListingVoteTrustEffects({
+ tx: mockTx,
action: 'updated',
currentValue: false,
previousValue: true,
userId: USER_ID,
listingId: LISTING_ID,
+ listingType: 'handheld',
authorId: AUTHOR_ID,
})
- expect(mockApplyTrustAction).toHaveBeenCalledWith({
+ // Expect 2 reversals (voter + author) then 2 applications (voter + author)
+ expect(mockReverseLogAction).toHaveBeenCalledTimes(2)
+ expect(mockReverseLogAction).toHaveBeenCalledWith({
+ userId: USER_ID,
+ originalAction: TrustAction.UPVOTE,
+ metadata: { listingId: LISTING_ID },
+ })
+ expect(mockReverseLogAction).toHaveBeenCalledWith({
+ userId: AUTHOR_ID,
+ originalAction: TrustAction.LISTING_RECEIVED_UPVOTE,
+ metadata: { listingId: LISTING_ID, voterId: USER_ID },
+ })
+
+ expect(mockLogAction).toHaveBeenCalledTimes(2)
+ expect(mockLogAction).toHaveBeenCalledWith({
userId: USER_ID,
action: TrustAction.DOWNVOTE,
- context: { listingId: LISTING_ID },
+ metadata: { listingId: LISTING_ID },
})
- expect(mockApplyTrustAction).toHaveBeenCalledWith({
+ expect(mockLogAction).toHaveBeenCalledWith({
userId: AUTHOR_ID,
action: TrustAction.LISTING_RECEIVED_DOWNVOTE,
- context: { listingId: LISTING_ID, voterId: USER_ID },
+ metadata: { listingId: LISTING_ID, voterId: USER_ID },
})
})
- })
- describe('vote deleted (toggled off)', () => {
- it('reverses UPVOTE trust for voter when upvote was toggled off', async () => {
- await handleVoteTrustEffects({
- action: 'deleted',
+ it('reverses previous downvote and applies upvote when changing from down to up', async () => {
+ await handleListingVoteTrustEffects({
+ tx: mockTx,
+ action: 'updated',
currentValue: true,
- previousValue: true,
+ previousValue: false,
userId: USER_ID,
listingId: LISTING_ID,
+ listingType: 'handheld',
authorId: AUTHOR_ID,
})
- expect(mockReverseTrustAction).toHaveBeenCalledWith({
+ expect(mockReverseLogAction).toHaveBeenCalledWith({
userId: USER_ID,
- originalAction: TrustAction.UPVOTE,
- context: { listingId: LISTING_ID },
+ originalAction: TrustAction.DOWNVOTE,
+ metadata: { listingId: LISTING_ID },
+ })
+ expect(mockReverseLogAction).toHaveBeenCalledWith({
+ userId: AUTHOR_ID,
+ originalAction: TrustAction.LISTING_RECEIVED_DOWNVOTE,
+ metadata: { listingId: LISTING_ID, voterId: USER_ID },
+ })
+ expect(mockLogAction).toHaveBeenCalledWith({
+ userId: USER_ID,
+ action: TrustAction.UPVOTE,
+ metadata: { listingId: LISTING_ID },
+ })
+ expect(mockLogAction).toHaveBeenCalledWith({
+ userId: AUTHOR_ID,
+ action: TrustAction.LISTING_RECEIVED_UPVOTE,
+ metadata: { listingId: LISTING_ID, voterId: USER_ID },
})
})
- it('reverses DOWNVOTE trust for voter when downvote was toggled off', async () => {
- await handleVoteTrustEffects({
- action: 'deleted',
+ it('does nothing when previousValue is null (safety)', async () => {
+ await handleListingVoteTrustEffects({
+ tx: mockTx,
+ action: 'updated',
currentValue: false,
- previousValue: false,
+ previousValue: null,
userId: USER_ID,
listingId: LISTING_ID,
+ listingType: 'handheld',
authorId: AUTHOR_ID,
})
- expect(mockReverseTrustAction).toHaveBeenCalledWith({
+ expect(mockReverseLogAction).not.toHaveBeenCalled()
+ expect(mockLogAction).not.toHaveBeenCalled()
+ })
+
+ it('skips author reversals/applications on self-vote', async () => {
+ await handleListingVoteTrustEffects({
+ tx: mockTx,
+ action: 'updated',
+ currentValue: false,
+ previousValue: true,
userId: USER_ID,
- originalAction: TrustAction.DOWNVOTE,
- context: { listingId: LISTING_ID },
+ listingId: LISTING_ID,
+ listingType: 'handheld',
+ authorId: USER_ID,
+ })
+
+ // Only voter-side trust calls, one reverse and one apply
+ expect(mockReverseLogAction).toHaveBeenCalledTimes(1)
+ expect(mockLogAction).toHaveBeenCalledTimes(1)
+ })
+
+ it('uses pcListingId for PC listings on update', async () => {
+ await handleListingVoteTrustEffects({
+ tx: mockTx,
+ action: 'updated',
+ currentValue: false,
+ previousValue: true,
+ userId: USER_ID,
+ listingId: LISTING_ID,
+ listingType: 'pc',
+ authorId: AUTHOR_ID,
})
+
+ expect(mockReverseLogAction).toHaveBeenCalledWith(
+ expect.objectContaining({ metadata: { pcListingId: LISTING_ID } }),
+ )
+ expect(mockLogAction).toHaveBeenCalledWith(
+ expect.objectContaining({ metadata: { pcListingId: LISTING_ID } }),
+ )
})
+ })
- it('reverses LISTING_RECEIVED_UPVOTE trust for author when upvote was toggled off', async () => {
- await handleVoteTrustEffects({
+ describe('deleted action (vote toggled off)', () => {
+ it('reverses UPVOTE and LISTING_RECEIVED_UPVOTE when an upvote is toggled off', async () => {
+ await handleListingVoteTrustEffects({
+ tx: mockTx,
action: 'deleted',
currentValue: true,
previousValue: true,
userId: USER_ID,
listingId: LISTING_ID,
+ listingType: 'handheld',
authorId: AUTHOR_ID,
})
- expect(mockReverseTrustAction).toHaveBeenCalledWith({
+ expect(mockReverseLogAction).toHaveBeenCalledTimes(2)
+ expect(mockReverseLogAction).toHaveBeenCalledWith({
+ userId: USER_ID,
+ originalAction: TrustAction.UPVOTE,
+ metadata: { listingId: LISTING_ID },
+ })
+ expect(mockReverseLogAction).toHaveBeenCalledWith({
userId: AUTHOR_ID,
originalAction: TrustAction.LISTING_RECEIVED_UPVOTE,
- context: { listingId: LISTING_ID, voterId: USER_ID },
+ metadata: { listingId: LISTING_ID, voterId: USER_ID },
})
+ expect(mockLogAction).not.toHaveBeenCalled()
})
- it('reverses LISTING_RECEIVED_DOWNVOTE trust for author when downvote was toggled off', async () => {
- await handleVoteTrustEffects({
+ it('reverses DOWNVOTE and LISTING_RECEIVED_DOWNVOTE when a downvote is toggled off', async () => {
+ await handleListingVoteTrustEffects({
+ tx: mockTx,
action: 'deleted',
currentValue: false,
previousValue: false,
userId: USER_ID,
listingId: LISTING_ID,
+ listingType: 'handheld',
authorId: AUTHOR_ID,
})
- expect(mockReverseTrustAction).toHaveBeenCalledWith({
+ expect(mockReverseLogAction).toHaveBeenCalledWith({
+ userId: USER_ID,
+ originalAction: TrustAction.DOWNVOTE,
+ metadata: { listingId: LISTING_ID },
+ })
+ expect(mockReverseLogAction).toHaveBeenCalledWith({
userId: AUTHOR_ID,
originalAction: TrustAction.LISTING_RECEIVED_DOWNVOTE,
- context: { listingId: LISTING_ID, voterId: USER_ID },
+ metadata: { listingId: LISTING_ID, voterId: USER_ID },
})
})
- it('skips author trust reversal on self-vote toggle-off', async () => {
- await handleVoteTrustEffects({
+ it('does nothing when previousValue is null (safety)', async () => {
+ await handleListingVoteTrustEffects({
+ tx: mockTx,
action: 'deleted',
currentValue: true,
- previousValue: true,
+ previousValue: null,
userId: USER_ID,
listingId: LISTING_ID,
- authorId: USER_ID,
+ listingType: 'handheld',
+ authorId: AUTHOR_ID,
})
- expect(mockReverseTrustAction).toHaveBeenCalledTimes(1)
- expect(mockReverseTrustAction).toHaveBeenCalledWith(
- expect.objectContaining({ userId: USER_ID, originalAction: TrustAction.UPVOTE }),
- )
+ expect(mockReverseLogAction).not.toHaveBeenCalled()
+ expect(mockLogAction).not.toHaveBeenCalled()
})
- it('skips author trust reversal when authorId is null', async () => {
- await handleVoteTrustEffects({
+ it('skips author reversal on self-vote toggle-off', async () => {
+ await handleListingVoteTrustEffects({
+ tx: mockTx,
action: 'deleted',
currentValue: true,
previousValue: true,
userId: USER_ID,
listingId: LISTING_ID,
- authorId: null,
+ listingType: 'handheld',
+ authorId: USER_ID,
})
- expect(mockReverseTrustAction).toHaveBeenCalledTimes(1)
+ expect(mockReverseLogAction).toHaveBeenCalledTimes(1)
+ expect(mockReverseLogAction).toHaveBeenCalledWith(
+ expect.objectContaining({ userId: USER_ID, originalAction: TrustAction.UPVOTE }),
+ )
})
- it('does not reverse trust when previousValue is null', async () => {
- await handleVoteTrustEffects({
+ it('uses pcListingId for PC listings on delete', async () => {
+ await handleListingVoteTrustEffects({
+ tx: mockTx,
action: 'deleted',
currentValue: true,
- previousValue: null,
+ previousValue: true,
userId: USER_ID,
listingId: LISTING_ID,
+ listingType: 'pc',
authorId: AUTHOR_ID,
})
- expect(mockReverseTrustAction).not.toHaveBeenCalled()
+ expect(mockReverseLogAction).toHaveBeenCalledWith(
+ expect.objectContaining({
+ originalAction: TrustAction.UPVOTE,
+ metadata: { pcListingId: LISTING_ID },
+ }),
+ )
+ expect(mockReverseLogAction).toHaveBeenCalledWith(
+ expect.objectContaining({
+ originalAction: TrustAction.LISTING_RECEIVED_UPVOTE,
+ metadata: { pcListingId: LISTING_ID, voterId: USER_ID },
+ }),
+ )
})
+ })
+})
- it('does not call applyTrustAction', async () => {
- await handleVoteTrustEffects({
- action: 'deleted',
- currentValue: true,
+describe('handleCommentVoteTrustEffects', () => {
+ const COMMENT_AUTHOR_ID = 'comment-author-1'
+ const VOTER_ID = 'voter-1'
+ const COMMENT_ID = 'comment-1'
+ const PARENT_ENTITY_ID = 'parent-entity-1'
+ const commentMockTx = {} as Parameters[0]['tx']
+
+ function callWithDefaults(
+ overrides: Partial[0]>,
+ ) {
+ return handleCommentVoteTrustEffects({
+ tx: commentMockTx,
+ trustAction: 'upvote',
+ newValue: true,
+ previousValue: null,
+ commentAuthorId: COMMENT_AUTHOR_ID,
+ voterId: VOTER_ID,
+ commentId: COMMENT_ID,
+ parentEntityId: PARENT_ENTITY_ID,
+ listingType: 'handheld',
+ updatedScore: 1,
+ scoreChange: 1,
+ ...overrides,
+ })
+ }
+
+ describe('self-vote skipping', () => {
+ it('does nothing when commentAuthorId equals voterId', async () => {
+ await callWithDefaults({
+ commentAuthorId: 'same-user',
+ voterId: 'same-user',
+ })
+
+ expect(mockLogAction).not.toHaveBeenCalled()
+ expect(mockReverseLogAction).not.toHaveBeenCalled()
+ })
+ })
+
+ describe('upvote action', () => {
+ it('logs COMMENT_RECEIVED_UPVOTE for handheld with listingId', async () => {
+ await callWithDefaults({ trustAction: 'upvote', listingType: 'handheld' })
+
+ expect(mockLogAction).toHaveBeenCalledWith({
+ userId: COMMENT_AUTHOR_ID,
+ action: TrustAction.COMMENT_RECEIVED_UPVOTE,
+ targetUserId: VOTER_ID,
+ metadata: { commentId: COMMENT_ID, voterId: VOTER_ID, listingId: PARENT_ENTITY_ID },
+ })
+ })
+
+ it('logs COMMENT_RECEIVED_UPVOTE for PC with pcListingId', async () => {
+ await callWithDefaults({ trustAction: 'upvote', listingType: 'pc' })
+
+ expect(mockLogAction).toHaveBeenCalledWith({
+ userId: COMMENT_AUTHOR_ID,
+ action: TrustAction.COMMENT_RECEIVED_UPVOTE,
+ targetUserId: VOTER_ID,
+ metadata: { commentId: COMMENT_ID, voterId: VOTER_ID, pcListingId: PARENT_ENTITY_ID },
+ })
+ })
+ })
+
+ describe('downvote action', () => {
+ it('logs COMMENT_RECEIVED_DOWNVOTE for handheld', async () => {
+ await callWithDefaults({
+ trustAction: 'downvote',
+ newValue: false,
+ listingType: 'handheld',
+ })
+
+ expect(mockLogAction).toHaveBeenCalledWith({
+ userId: COMMENT_AUTHOR_ID,
+ action: TrustAction.COMMENT_RECEIVED_DOWNVOTE,
+ targetUserId: VOTER_ID,
+ metadata: { commentId: COMMENT_ID, voterId: VOTER_ID, listingId: PARENT_ENTITY_ID },
+ })
+ })
+ })
+
+ describe('change action (uses reverseLogAction for the reversal)', () => {
+ it('properly reverses previous downvote then applies upvote when changing to upvote', async () => {
+ await callWithDefaults({
+ trustAction: 'change',
+ newValue: true,
+ previousValue: false,
+ })
+
+ // Must use reverseLogAction (not logAction) to actually reverse weight
+ expect(mockReverseLogAction).toHaveBeenCalledTimes(1)
+ expect(mockReverseLogAction).toHaveBeenCalledWith({
+ userId: COMMENT_AUTHOR_ID,
+ originalAction: TrustAction.COMMENT_RECEIVED_DOWNVOTE,
+ targetUserId: VOTER_ID,
+ metadata: { commentId: COMMENT_ID, voterId: VOTER_ID, listingId: PARENT_ENTITY_ID },
+ })
+
+ // Then apply new upvote
+ expect(mockLogAction).toHaveBeenCalledTimes(1)
+ expect(mockLogAction).toHaveBeenCalledWith({
+ userId: COMMENT_AUTHOR_ID,
+ action: TrustAction.COMMENT_RECEIVED_UPVOTE,
+ targetUserId: VOTER_ID,
+ metadata: { commentId: COMMENT_ID, voterId: VOTER_ID, listingId: PARENT_ENTITY_ID },
+ })
+ })
+
+ it('properly reverses previous upvote then applies downvote when changing to downvote', async () => {
+ await callWithDefaults({
+ trustAction: 'change',
+ newValue: false,
previousValue: true,
- userId: USER_ID,
- listingId: LISTING_ID,
- authorId: AUTHOR_ID,
})
- expect(mockApplyTrustAction).not.toHaveBeenCalled()
+ expect(mockReverseLogAction).toHaveBeenCalledTimes(1)
+ expect(mockReverseLogAction).toHaveBeenCalledWith({
+ userId: COMMENT_AUTHOR_ID,
+ originalAction: TrustAction.COMMENT_RECEIVED_UPVOTE,
+ targetUserId: VOTER_ID,
+ metadata: expect.any(Object),
+ })
+
+ expect(mockLogAction).toHaveBeenCalledTimes(1)
+ expect(mockLogAction).toHaveBeenCalledWith({
+ userId: COMMENT_AUTHOR_ID,
+ action: TrustAction.COMMENT_RECEIVED_DOWNVOTE,
+ targetUserId: VOTER_ID,
+ metadata: expect.any(Object),
+ })
+ })
+
+ it('uses pcListingId for PC listing vote changes', async () => {
+ await callWithDefaults({
+ trustAction: 'change',
+ newValue: true,
+ previousValue: false,
+ listingType: 'pc',
+ })
+
+ expect(mockReverseLogAction).toHaveBeenCalledWith(
+ expect.objectContaining({
+ metadata: expect.objectContaining({ pcListingId: PARENT_ENTITY_ID }),
+ }),
+ )
+ expect(mockLogAction).toHaveBeenCalledWith(
+ expect.objectContaining({
+ metadata: expect.objectContaining({ pcListingId: PARENT_ENTITY_ID }),
+ }),
+ )
+ })
+ })
+
+ describe('remove action (uses reverseLogAction)', () => {
+ it('reverses COMMENT_RECEIVED_UPVOTE when previousValue was upvote', async () => {
+ await callWithDefaults({
+ trustAction: 'remove',
+ previousValue: true,
+ })
+
+ expect(mockReverseLogAction).toHaveBeenCalledWith({
+ userId: COMMENT_AUTHOR_ID,
+ originalAction: TrustAction.COMMENT_RECEIVED_UPVOTE,
+ targetUserId: VOTER_ID,
+ metadata: { commentId: COMMENT_ID, voterId: VOTER_ID, listingId: PARENT_ENTITY_ID },
+ })
+ // remove doesn't need to log a new action
+ expect(mockLogAction).not.toHaveBeenCalled()
+ })
+
+ it('reverses COMMENT_RECEIVED_DOWNVOTE when previousValue was downvote', async () => {
+ await callWithDefaults({
+ trustAction: 'remove',
+ previousValue: false,
+ })
+
+ expect(mockReverseLogAction).toHaveBeenCalledWith({
+ userId: COMMENT_AUTHOR_ID,
+ originalAction: TrustAction.COMMENT_RECEIVED_DOWNVOTE,
+ targetUserId: VOTER_ID,
+ metadata: expect.any(Object),
+ })
+ })
+
+ it('does nothing when previousValue is null (safety)', async () => {
+ await callWithDefaults({
+ trustAction: 'remove',
+ previousValue: null,
+ })
+
+ expect(mockReverseLogAction).not.toHaveBeenCalled()
+ expect(mockLogAction).not.toHaveBeenCalled()
+ })
+ })
+
+ describe('helpful comment threshold', () => {
+ it('logs HELPFUL_COMMENT when score crosses 5 from below', async () => {
+ await callWithDefaults({
+ trustAction: 'upvote',
+ updatedScore: 5,
+ scoreChange: 1,
+ })
+
+ expect(mockLogAction).toHaveBeenCalledWith({
+ userId: COMMENT_AUTHOR_ID,
+ action: TrustAction.HELPFUL_COMMENT,
+ metadata: {
+ commentId: COMMENT_ID,
+ listingId: PARENT_ENTITY_ID,
+ score: 5,
+ threshold: 5,
+ },
+ })
+ })
+
+ it('does NOT log HELPFUL_COMMENT when score was already at threshold', async () => {
+ await callWithDefaults({
+ trustAction: 'upvote',
+ updatedScore: 6,
+ scoreChange: 1,
+ })
+
+ expect(mockLogAction).not.toHaveBeenCalledWith(
+ expect.objectContaining({ action: TrustAction.HELPFUL_COMMENT }),
+ )
+ })
+
+ it('does NOT log HELPFUL_COMMENT when score is still below threshold', async () => {
+ await callWithDefaults({
+ trustAction: 'upvote',
+ updatedScore: 3,
+ scoreChange: 1,
+ })
+
+ expect(mockLogAction).not.toHaveBeenCalledWith(
+ expect.objectContaining({ action: TrustAction.HELPFUL_COMMENT }),
+ )
+ })
+
+ it('uses pcListingId in HELPFUL_COMMENT metadata for PC listings', async () => {
+ await callWithDefaults({
+ trustAction: 'upvote',
+ listingType: 'pc',
+ updatedScore: 5,
+ scoreChange: 1,
+ })
+
+ expect(mockLogAction).toHaveBeenCalledWith({
+ userId: COMMENT_AUTHOR_ID,
+ action: TrustAction.HELPFUL_COMMENT,
+ metadata: {
+ commentId: COMMENT_ID,
+ pcListingId: PARENT_ENTITY_ID,
+ score: 5,
+ threshold: 5,
+ },
+ })
+ })
+
+ it('does NOT log HELPFUL_COMMENT when score drops below threshold', async () => {
+ await callWithDefaults({
+ trustAction: 'downvote',
+ newValue: false,
+ updatedScore: 4,
+ scoreChange: -1,
+ })
+
+ expect(mockLogAction).not.toHaveBeenCalledWith(
+ expect.objectContaining({ action: TrustAction.HELPFUL_COMMENT }),
+ )
})
})
})
diff --git a/src/server/utils/vote-trust-effects.ts b/src/server/utils/vote-trust-effects.ts
index 44180ac8..55bd1f54 100644
--- a/src/server/utils/vote-trust-effects.ts
+++ b/src/server/utils/vote-trust-effects.ts
@@ -1,53 +1,202 @@
-import { applyTrustAction, reverseTrustAction } from '@/lib/trust/service'
-import { TrustAction } from '@orm'
+import { TrustService } from '@/lib/trust/service'
+import { type ListingType } from '@/schemas/common'
+import { TrustAction, type PrismaClient, type Prisma } from '@orm'
type VoteAction = 'created' | 'updated' | 'deleted'
-interface VoteTrustEffectsParams {
+type PrismaTransactionClient = Omit<
+ PrismaClient,
+ '$connect' | '$disconnect' | '$on' | '$transaction' | '$use' | '$extends'
+>
+
+interface ListingVoteTrustParams {
+ tx: PrismaTransactionClient | Prisma.TransactionClient
action: VoteAction
currentValue: boolean
previousValue: boolean | null
userId: string
listingId: string
+ listingType: ListingType
authorId: string | null
}
-export async function handleVoteTrustEffects(params: VoteTrustEffectsParams): Promise {
- const { action, currentValue, previousValue, userId, listingId, authorId } = params
+/**
+ * Applies or reverses trust effects when a listing vote is created, updated, or deleted.
+ * Works for both handheld and PC listings. Must be called within a transaction β pass `tx`.
+ */
+export async function handleListingVoteTrustEffects(params: ListingVoteTrustParams): Promise {
+ const { tx, action, currentValue, previousValue, userId, listingId, listingType, authorId } =
+ params
+
+ const contextKey = listingType === 'handheld' ? 'listingId' : 'pcListingId'
+ const context = { [contextKey]: listingId }
+ const trustService = new TrustService(tx)
- if (action === 'created' || action === 'updated') {
- await applyTrustAction({
+ const applyVoterTrust = (value: boolean) =>
+ trustService.logAction({
userId,
- action: currentValue ? TrustAction.UPVOTE : TrustAction.DOWNVOTE,
- context: { listingId },
+ action: value ? TrustAction.UPVOTE : TrustAction.DOWNVOTE,
+ metadata: context,
})
- if (authorId && authorId !== userId) {
- await applyTrustAction({
- userId: authorId,
- action: currentValue
- ? TrustAction.LISTING_RECEIVED_UPVOTE
- : TrustAction.LISTING_RECEIVED_DOWNVOTE,
- context: { listingId, voterId: userId },
- })
- }
+ const reverseVoterTrust = (value: boolean) =>
+ trustService.reverseLogAction({
+ userId,
+ originalAction: value ? TrustAction.UPVOTE : TrustAction.DOWNVOTE,
+ metadata: context,
+ })
+
+ const applyAuthorTrust = (value: boolean) => {
+ if (!authorId || authorId === userId) return Promise.resolve()
+ return trustService.logAction({
+ userId: authorId,
+ action: value ? TrustAction.LISTING_RECEIVED_UPVOTE : TrustAction.LISTING_RECEIVED_DOWNVOTE,
+ metadata: { ...context, voterId: userId },
+ })
}
- if (action === 'deleted' && previousValue !== null) {
- await reverseTrustAction({
- userId,
- originalAction: previousValue ? TrustAction.UPVOTE : TrustAction.DOWNVOTE,
- context: { listingId },
+ const reverseAuthorTrust = (value: boolean) => {
+ if (!authorId || authorId === userId) return Promise.resolve()
+ return trustService.reverseLogAction({
+ userId: authorId,
+ originalAction: value
+ ? TrustAction.LISTING_RECEIVED_UPVOTE
+ : TrustAction.LISTING_RECEIVED_DOWNVOTE,
+ metadata: { ...context, voterId: userId },
})
+ }
+
+ if (action === 'created') {
+ await applyVoterTrust(currentValue)
+ await applyAuthorTrust(currentValue)
+ return
+ }
+
+ if (action === 'updated' && previousValue !== null) {
+ await reverseVoterTrust(previousValue)
+ await reverseAuthorTrust(previousValue)
+ await applyVoterTrust(currentValue)
+ await applyAuthorTrust(currentValue)
+ return
+ }
+
+ if (action === 'deleted' && previousValue !== null) {
+ await reverseVoterTrust(previousValue)
+ await reverseAuthorTrust(previousValue)
+ }
+}
+
+type CommentVoteTrustAction = 'upvote' | 'downvote' | 'change' | 'remove'
+
+interface CommentVoteTrustParams {
+ tx: PrismaTransactionClient | Prisma.TransactionClient
+ trustAction: CommentVoteTrustAction
+ newValue: boolean
+ previousValue: boolean | null
+ commentAuthorId: string
+ voterId: string
+ commentId: string
+ parentEntityId: string
+ listingType: ListingType
+ updatedScore: number
+ scoreChange: number
+}
+
+const HELPFUL_THRESHOLD = 5
+
+/**
+ * Applies trust effects when a comment vote is created, changed, or removed.
+ * Works for both handheld and PC listing comments.
+ */
+export async function handleCommentVoteTrustEffects(params: CommentVoteTrustParams): Promise {
+ const {
+ tx,
+ trustAction,
+ newValue,
+ previousValue,
+ commentAuthorId,
+ voterId,
+ commentId,
+ parentEntityId,
+ listingType,
+ updatedScore,
+ scoreChange,
+ } = params
+
+ if (commentAuthorId === voterId) return
+
+ const trustService = new TrustService(tx)
+ const parentKey = listingType === 'handheld' ? 'listingId' : 'pcListingId'
+ const baseMeta = { commentId, voterId, [parentKey]: parentEntityId }
- if (authorId && authorId !== userId) {
- await reverseTrustAction({
- userId: authorId,
- originalAction: previousValue
- ? TrustAction.LISTING_RECEIVED_UPVOTE
- : TrustAction.LISTING_RECEIVED_DOWNVOTE,
- context: { listingId, voterId: userId },
+ if (trustAction === 'upvote') {
+ await trustService.logAction({
+ userId: commentAuthorId,
+ action: TrustAction.COMMENT_RECEIVED_UPVOTE,
+ targetUserId: voterId,
+ metadata: baseMeta,
+ })
+ } else if (trustAction === 'downvote') {
+ await trustService.logAction({
+ userId: commentAuthorId,
+ action: TrustAction.COMMENT_RECEIVED_DOWNVOTE,
+ targetUserId: voterId,
+ metadata: baseMeta,
+ })
+ } else if (trustAction === 'change') {
+ if (newValue) {
+ // Changed from downvote to upvote: reverse downvote, apply upvote
+ await trustService.reverseLogAction({
+ userId: commentAuthorId,
+ originalAction: TrustAction.COMMENT_RECEIVED_DOWNVOTE,
+ targetUserId: voterId,
+ metadata: baseMeta,
+ })
+ await trustService.logAction({
+ userId: commentAuthorId,
+ action: TrustAction.COMMENT_RECEIVED_UPVOTE,
+ targetUserId: voterId,
+ metadata: baseMeta,
+ })
+ } else {
+ // Changed from upvote to downvote: reverse upvote, apply downvote
+ await trustService.reverseLogAction({
+ userId: commentAuthorId,
+ originalAction: TrustAction.COMMENT_RECEIVED_UPVOTE,
+ targetUserId: voterId,
+ metadata: baseMeta,
+ })
+ await trustService.logAction({
+ userId: commentAuthorId,
+ action: TrustAction.COMMENT_RECEIVED_DOWNVOTE,
+ targetUserId: voterId,
+ metadata: baseMeta,
})
}
+ } else if (trustAction === 'remove' && previousValue !== null) {
+ const originalAction = previousValue
+ ? TrustAction.COMMENT_RECEIVED_UPVOTE
+ : TrustAction.COMMENT_RECEIVED_DOWNVOTE
+ await trustService.reverseLogAction({
+ userId: commentAuthorId,
+ originalAction,
+ targetUserId: voterId,
+ metadata: baseMeta,
+ })
+ }
+
+ // Helpful comment bonus when crossing the threshold
+ const previousScore = updatedScore - scoreChange
+ if (previousScore < HELPFUL_THRESHOLD && updatedScore >= HELPFUL_THRESHOLD) {
+ await trustService.logAction({
+ userId: commentAuthorId,
+ action: TrustAction.HELPFUL_COMMENT,
+ metadata: {
+ commentId,
+ [parentKey]: parentEntityId,
+ score: updatedScore,
+ threshold: HELPFUL_THRESHOLD,
+ },
+ })
}
}
diff --git a/src/shared/emulator-config/gamenative/index.ts b/src/shared/emulator-config/gamenative/index.ts
new file mode 100644
index 00000000..b8ceae35
--- /dev/null
+++ b/src/shared/emulator-config/gamenative/index.ts
@@ -0,0 +1,13 @@
+import { parseGameNativeConfigFromJson } from './parser'
+import { registerEmulatorConfigMapper } from '../index'
+import type { EmulatorConfigMapper } from '../types'
+
+const gamenativeMapper: EmulatorConfigMapper = {
+ slug: 'gamenative',
+ fileTypes: ['json'],
+ parse: parseGameNativeConfigFromJson,
+}
+
+registerEmulatorConfigMapper(gamenativeMapper)
+
+export default gamenativeMapper
diff --git a/src/shared/emulator-config/gamenative/mapping.ts b/src/shared/emulator-config/gamenative/mapping.ts
new file mode 100644
index 00000000..9a9dfbf0
--- /dev/null
+++ b/src/shared/emulator-config/gamenative/mapping.ts
@@ -0,0 +1,207 @@
+import {
+ GRAPHICS_DRIVER_MAPPING,
+ DX_WRAPPER_MAPPING,
+ STARTUP_SELECTION_MAPPING,
+ BOX64_PRESET_MAPPING,
+ FEXCORE_PRESET_MAPPING,
+} from '@/server/utils/emulator-config/gamenative/gamenative.defaults'
+
+function createReverseLookup>(
+ mapping: T,
+): Record {
+ const reverse: Record = {}
+ for (const [customValue, configValue] of Object.entries(mapping)) {
+ const key = String(configValue)
+ if (!(key in reverse)) {
+ reverse[key] = customValue
+ }
+ }
+ return reverse
+}
+
+const GRAPHICS_DRIVER_REVERSE = createReverseLookup(GRAPHICS_DRIVER_MAPPING)
+const DX_WRAPPER_REVERSE = createReverseLookup(DX_WRAPPER_MAPPING)
+const STARTUP_SELECTION_REVERSE = createReverseLookup(STARTUP_SELECTION_MAPPING)
+
+export interface GameNativeFieldMapping {
+ jsonPath: string | string[]
+ fromConfig?: (rawValue: unknown, fullConfig?: Record) => unknown
+}
+
+/**
+ * Parse a value from a comma-separated key=value config string
+ */
+export function parseConfigString(configStr: string, key: string): string | undefined {
+ if (!configStr) return undefined
+ for (const part of configStr.split(',')) {
+ const eqIndex = part.indexOf('=')
+ if (eqIndex === -1) continue
+ const k = part.slice(0, eqIndex).trim()
+ const v = part.slice(eqIndex + 1).trim()
+ if (k === key) return v
+ }
+ return undefined
+}
+
+export const GAMENATIVE_IMPORT_MAPPINGS: Record = {
+ resolution: {
+ jsonPath: 'screenSize',
+ },
+
+ env_variables: {
+ jsonPath: 'envVars',
+ },
+
+ graphics_driver: {
+ jsonPath: 'graphicsDriver',
+ fromConfig: (value) => GRAPHICS_DRIVER_REVERSE[String(value)] ?? String(value),
+ },
+
+ dx_wrapper: {
+ jsonPath: 'dxwrapper',
+ fromConfig: (value) => DX_WRAPPER_REVERSE[String(value)] ?? String(value),
+ },
+
+ dx_wrapper_config: {
+ jsonPath: 'dxwrapperConfig',
+ },
+
+ dxvk_version: {
+ jsonPath: ['dxwrapperConfig', 'dxvkVersion'],
+ fromConfig: (value, fullConfig) => {
+ const configStr = String(value || '')
+ const fromConfigStr = parseConfigString(configStr, 'version')
+ if (fromConfigStr) return fromConfigStr
+ // Fallback: check top-level dxvkVersion for old configs
+ if (fullConfig && typeof fullConfig['dxvkVersion'] === 'string') {
+ return fullConfig['dxvkVersion']
+ }
+ return configStr.includes('=') ? undefined : configStr || undefined
+ },
+ },
+
+ audio_driver: {
+ jsonPath: 'audioDriver',
+ fromConfig: (value) => {
+ const str = String(value)
+ if (str === 'pulseaudio' || str === 'pulse') return 'pulse'
+ if (str === 'alsa') return 'alsa'
+ return str
+ },
+ },
+
+ exec_arguments: {
+ jsonPath: 'execArgs',
+ },
+
+ startup_selection: {
+ jsonPath: 'startupSelection',
+ fromConfig: (value) => STARTUP_SELECTION_REVERSE[String(value)] ?? String(value),
+ },
+
+ box64_version: {
+ jsonPath: 'box64Version',
+ },
+
+ box64_preset: {
+ jsonPath: 'box64Preset',
+ fromConfig: (value) => {
+ const upper = String(value)
+ // Reverse: uppercase β lowercase for custom field
+ const reversed = createReverseLookup(BOX64_PRESET_MAPPING)
+ return reversed[upper] ?? upper.toLowerCase()
+ },
+ },
+
+ container_variant: {
+ jsonPath: 'containerVariant',
+ },
+
+ wine_version: {
+ jsonPath: 'wineVersion',
+ },
+
+ steam_type: {
+ jsonPath: 'steamType',
+ fromConfig: (value) => {
+ const str = String(value)
+ if (str === 'ultralight') return 'ultra_light'
+ return str
+ },
+ },
+
+ dynamic_driver_version: {
+ jsonPath: 'graphicsDriverVersion',
+ },
+
+ max_device_memory: {
+ jsonPath: ['graphicsDriverConfig', 'dxwrapperConfig'],
+ fromConfig: (value) => {
+ const configStr = String(value || '')
+ return parseConfigString(configStr, 'maxDeviceMemory')
+ },
+ },
+
+ use_adrenotools_turnip: {
+ jsonPath: 'graphicsDriverConfig',
+ fromConfig: (value) => {
+ const configStr = String(value || '')
+ return parseConfigString(configStr, 'adrenotoolsTurnip') === '1'
+ },
+ },
+
+ fex_core_version: {
+ jsonPath: 'fexcoreVersion',
+ },
+
+ '32_bit_emulator': {
+ jsonPath: 'emulator',
+ fromConfig: (value) => {
+ const str = String(value)
+ if (str === 'FEXCore') return 'fex'
+ if (str === 'Box64') return 'box'
+ return str
+ },
+ },
+
+ '64_bit_emulator': {
+ jsonPath: 'emulator',
+ fromConfig: (value) => {
+ const str = String(value)
+ if (str === 'FEXCore') return 'fex'
+ if (str === 'Box64') return 'box'
+ return str
+ },
+ },
+
+ fex_core_preset: {
+ jsonPath: 'fexcorePreset',
+ fromConfig: (value) => {
+ const upper = String(value)
+ const reversed = createReverseLookup(FEXCORE_PRESET_MAPPING)
+ return reversed[upper] ?? upper.toLowerCase()
+ },
+ },
+
+ use_steam_input: {
+ jsonPath: 'useSteamInput',
+ },
+
+ enable_x_input_api: {
+ jsonPath: 'enableXInput',
+ },
+
+ enable_direct_input_api: {
+ jsonPath: 'enableDInput',
+ },
+
+ direct_input_mapper_type: {
+ jsonPath: 'dinputMapperType',
+ fromConfig: (value) => {
+ const num = Number(value)
+ if (num === 1) return 'standard'
+ if (num === 2) return 'xinput_mapper'
+ return String(value)
+ },
+ },
+}
diff --git a/src/shared/emulator-config/gamenative/parser.test.ts b/src/shared/emulator-config/gamenative/parser.test.ts
new file mode 100644
index 00000000..cff4a433
--- /dev/null
+++ b/src/shared/emulator-config/gamenative/parser.test.ts
@@ -0,0 +1,627 @@
+import { describe, expect, it } from 'vitest'
+import { CustomFieldType } from '@orm'
+import { parseGameNativeConfigFromJson } from './parser'
+import type { CustomFieldImportDefinition } from '../types'
+
+const baseFields: CustomFieldImportDefinition[] = [
+ {
+ id: 'resolution',
+ name: 'resolution',
+ label: 'Resolution',
+ type: CustomFieldType.SELECT,
+ isRequired: false,
+ options: [
+ { value: '854x480', label: '854x480' },
+ { value: '1280x720', label: '1280x720' },
+ { value: '1920x1080', label: '1920x1080' },
+ ],
+ },
+ {
+ id: 'graphics_driver',
+ name: 'graphics_driver',
+ label: 'Graphics Driver',
+ type: CustomFieldType.SELECT,
+ isRequired: true,
+ options: [
+ { value: 'VirGL (Universal)', label: 'VirGL (Universal)' },
+ { value: 'Turnip (Adreno)', label: 'Turnip (Adreno)' },
+ { value: 'Vortek (Universal)', label: 'Vortek (Universal)' },
+ ],
+ },
+ {
+ id: 'dx_wrapper',
+ name: 'dx_wrapper',
+ label: 'DX Wrapper',
+ type: CustomFieldType.SELECT,
+ isRequired: true,
+ options: [
+ { value: 'WineD3D', label: 'WineD3D' },
+ { value: 'DXVK', label: 'DXVK' },
+ { value: 'VKD3D', label: 'VKD3D' },
+ ],
+ },
+ {
+ id: 'dxvk_version',
+ name: 'dxvk_version',
+ label: 'DXVK Version',
+ type: CustomFieldType.SELECT,
+ isRequired: false,
+ options: [
+ { value: '2.6.1-gplasync', label: '2.6.1-gplasync' },
+ { value: 'async-1.10.3', label: 'async-1.10.3' },
+ ],
+ },
+ {
+ id: 'audio_driver',
+ name: 'audio_driver',
+ label: 'Audio Driver',
+ type: CustomFieldType.SELECT,
+ isRequired: true,
+ options: [
+ { value: 'alsa', label: 'ALSA' },
+ { value: 'pulse', label: 'PulseAudio' },
+ ],
+ },
+ {
+ id: 'startup_selection',
+ name: 'startup_selection',
+ label: 'Startup Selection',
+ type: CustomFieldType.SELECT,
+ isRequired: false,
+ options: [
+ { value: 'Normal (Load all services)', label: 'Normal (Load all services)' },
+ { value: 'Essential (Load only essential services)', label: 'Essential' },
+ { value: 'Aggressive (Stop services on startup)', label: 'Aggressive' },
+ ],
+ },
+ {
+ id: 'box64_version',
+ name: 'box64_version',
+ label: 'Box64 Version',
+ type: CustomFieldType.SELECT,
+ isRequired: false,
+ options: [
+ { value: '0.3.6', label: '0.3.6' },
+ { value: '0.3.8', label: '0.3.8' },
+ ],
+ },
+ {
+ id: 'box64_preset',
+ name: 'box64_preset',
+ label: 'Box64 Preset',
+ type: CustomFieldType.SELECT,
+ isRequired: false,
+ options: [
+ { value: 'stability', label: 'Stability' },
+ { value: 'compatibility', label: 'Compatibility' },
+ { value: 'intermediate', label: 'Intermediate' },
+ { value: 'performance', label: 'Performance' },
+ ],
+ },
+ {
+ id: 'env_variables',
+ name: 'env_variables',
+ label: 'Environment Variables',
+ type: CustomFieldType.TEXTAREA,
+ isRequired: false,
+ },
+ {
+ id: 'exec_arguments',
+ name: 'exec_arguments',
+ label: 'Execution Arguments',
+ type: CustomFieldType.TEXT,
+ isRequired: false,
+ },
+ {
+ id: 'container_variant',
+ name: 'container_variant',
+ label: 'Container Variant',
+ type: CustomFieldType.SELECT,
+ isRequired: false,
+ options: [
+ { value: 'glibc', label: 'Glibc' },
+ { value: 'bionic', label: 'Bionic' },
+ ],
+ },
+ {
+ id: 'wine_version',
+ name: 'wine_version',
+ label: 'Wine Version',
+ type: CustomFieldType.TEXT,
+ isRequired: false,
+ },
+ {
+ id: 'steam_type',
+ name: 'steam_type',
+ label: 'Steam Type',
+ type: CustomFieldType.SELECT,
+ isRequired: false,
+ options: [
+ { value: 'normal', label: 'Normal' },
+ { value: 'light', label: 'Light' },
+ { value: 'ultra_light', label: 'Ultra Light' },
+ ],
+ },
+ {
+ id: 'dynamic_driver_version',
+ name: 'dynamic_driver_version',
+ label: 'Graphics Driver Version',
+ type: CustomFieldType.TEXT,
+ isRequired: false,
+ },
+ {
+ id: 'fex_core_version',
+ name: 'fex_core_version',
+ label: 'FEXCore Version',
+ type: CustomFieldType.SELECT,
+ isRequired: false,
+ options: [{ value: '2603', label: '2603' }],
+ },
+ {
+ id: '32_bit_emulator',
+ name: '32_bit_emulator',
+ label: '32-bit Emulator',
+ type: CustomFieldType.SELECT,
+ isRequired: false,
+ options: [
+ { value: 'fex', label: 'FEXCore' },
+ { value: 'box', label: 'Box64' },
+ ],
+ },
+ {
+ id: '64_bit_emulator',
+ name: '64_bit_emulator',
+ label: '64-bit Emulator',
+ type: CustomFieldType.SELECT,
+ isRequired: false,
+ options: [
+ { value: 'fex', label: 'FEXCore' },
+ { value: 'box', label: 'Box64' },
+ ],
+ },
+ {
+ id: 'fex_core_preset',
+ name: 'fex_core_preset',
+ label: 'FEXCore Preset',
+ type: CustomFieldType.SELECT,
+ isRequired: false,
+ options: [
+ { value: 'stability', label: 'Stability' },
+ { value: 'intermediate', label: 'Intermediate' },
+ { value: 'performance', label: 'Performance' },
+ ],
+ },
+ {
+ id: 'use_steam_input',
+ name: 'use_steam_input',
+ label: 'Use Steam Input',
+ type: CustomFieldType.BOOLEAN,
+ isRequired: false,
+ },
+ {
+ id: 'enable_x_input_api',
+ name: 'enable_x_input_api',
+ label: 'Enable XInput API',
+ type: CustomFieldType.BOOLEAN,
+ isRequired: false,
+ },
+ {
+ id: 'enable_direct_input_api',
+ name: 'enable_direct_input_api',
+ label: 'Enable DirectInput API',
+ type: CustomFieldType.BOOLEAN,
+ isRequired: false,
+ },
+ {
+ id: 'direct_input_mapper_type',
+ name: 'direct_input_mapper_type',
+ label: 'DirectInput Mapper Type',
+ type: CustomFieldType.SELECT,
+ isRequired: false,
+ options: [
+ { value: 'standard', label: 'Standard' },
+ { value: 'xinput_mapper', label: 'XInput Mapper' },
+ ],
+ },
+ {
+ id: 'use_adrenotools_turnip',
+ name: 'use_adrenotools_turnip',
+ label: 'Use Adrenotools Turnip',
+ type: CustomFieldType.BOOLEAN,
+ isRequired: false,
+ },
+ {
+ id: 'max_device_memory',
+ name: 'max_device_memory',
+ label: 'Max Device Memory',
+ type: CustomFieldType.TEXT,
+ isRequired: false,
+ },
+]
+
+const SAMPLE_CONFIG = {
+ screenSize: '1920x1080',
+ graphicsDriver: 'turnip',
+ graphicsDriverVersion: '25.2.0',
+ graphicsDriverConfig: 'adrenotoolsTurnip=1,maxDeviceMemory=4096',
+ dxwrapper: 'dxvk',
+ dxwrapperConfig: 'version=async-1.10.3,maxDeviceMemory=0',
+ audioDriver: 'pulseaudio',
+ startupSelection: 1,
+ box64Version: '0.3.6',
+ box64Preset: 'COMPATIBILITY',
+ envVars: 'ZINK_DESCRIPTORS=lazy MESA_SHADER_CACHE_DISABLE=false',
+ execArgs: '-windowed',
+ containerVariant: 'glibc',
+ wineVersion: 'wine-9.2-x86_64',
+ steamType: 'ultralight',
+ emulator: 'FEXCore',
+ fexcoreVersion: '2603',
+ fexcorePreset: 'INTERMEDIATE',
+ useSteamInput: false,
+ enableXInput: true,
+ enableDInput: true,
+ dinputMapperType: 1,
+}
+
+describe('parseGameNativeConfigFromJson', () => {
+ it('parses a full config and maps all known fields', () => {
+ const raw = JSON.stringify(SAMPLE_CONFIG)
+ const result = parseGameNativeConfigFromJson(raw, baseFields)
+
+ expect(result.warnings).toHaveLength(0)
+
+ const valueMap = new Map(result.values.map((v) => [v.id, v.value]))
+
+ expect(valueMap.get('resolution')).toBe('1920x1080')
+ expect(valueMap.get('graphics_driver')).toBe('Turnip (Adreno)')
+ expect(valueMap.get('dx_wrapper')).toBe('DXVK')
+ expect(valueMap.get('dxvk_version')).toBe('async-1.10.3')
+ expect(valueMap.get('audio_driver')).toBe('pulse')
+ expect(valueMap.get('startup_selection')).toBe('Essential (Load only essential services)')
+ expect(valueMap.get('box64_version')).toBe('0.3.6')
+ expect(valueMap.get('box64_preset')).toBe('compatibility')
+ expect(valueMap.get('env_variables')).toBe(
+ 'ZINK_DESCRIPTORS=lazy MESA_SHADER_CACHE_DISABLE=false',
+ )
+ expect(valueMap.get('exec_arguments')).toBe('-windowed')
+ expect(valueMap.get('container_variant')).toBe('glibc')
+ expect(valueMap.get('wine_version')).toBe('wine-9.2-x86_64')
+ expect(valueMap.get('steam_type')).toBe('ultra_light')
+ expect(valueMap.get('dynamic_driver_version')).toBe('25.2.0')
+ expect(valueMap.get('fex_core_version')).toBe('2603')
+ expect(valueMap.get('32_bit_emulator')).toBe('fex')
+ expect(valueMap.get('64_bit_emulator')).toBe('fex')
+ expect(valueMap.get('fex_core_preset')).toBe('intermediate')
+ expect(valueMap.get('use_steam_input')).toBe(false)
+ expect(valueMap.get('enable_x_input_api')).toBe(true)
+ expect(valueMap.get('enable_direct_input_api')).toBe(true)
+ expect(valueMap.get('direct_input_mapper_type')).toBe('standard')
+ expect(valueMap.get('use_adrenotools_turnip')).toBe(true)
+ expect(valueMap.get('max_device_memory')).toBe('4096')
+ })
+
+ it('extracts dxvk_version from dxwrapperConfig string', () => {
+ const raw = JSON.stringify({ dxwrapperConfig: 'version=2.6.1-gplasync,async=1' })
+ const fields: CustomFieldImportDefinition[] = [
+ {
+ id: 'dxvk_version',
+ name: 'dxvk_version',
+ label: 'DXVK Version',
+ type: CustomFieldType.SELECT,
+ isRequired: false,
+ },
+ ]
+ const result = parseGameNativeConfigFromJson(raw, fields)
+ const valueMap = new Map(result.values.map((v) => [v.id, v.value]))
+ expect(valueMap.get('dxvk_version')).toBe('2.6.1-gplasync')
+ })
+
+ it('falls back to top-level dxvkVersion for old configs', () => {
+ const raw = JSON.stringify({ dxvkVersion: '1.10.3', dxwrapperConfig: 'async=1' })
+ const fields: CustomFieldImportDefinition[] = [
+ {
+ id: 'dxvk_version',
+ name: 'dxvk_version',
+ label: 'DXVK Version',
+ type: CustomFieldType.SELECT,
+ isRequired: false,
+ },
+ ]
+ const result = parseGameNativeConfigFromJson(raw, fields)
+ const valueMap = new Map(result.values.map((v) => [v.id, v.value]))
+ expect(valueMap.get('dxvk_version')).toBe('1.10.3')
+ })
+
+ it('handles audio driver backward compatibility with "pulse"', () => {
+ const raw = JSON.stringify({ audioDriver: 'pulse' })
+ const fields: CustomFieldImportDefinition[] = [
+ {
+ id: 'audio_driver',
+ name: 'audio_driver',
+ label: 'Audio Driver',
+ type: CustomFieldType.SELECT,
+ isRequired: false,
+ },
+ ]
+ const result = parseGameNativeConfigFromJson(raw, fields)
+ const valueMap = new Map(result.values.map((v) => [v.id, v.value]))
+ expect(valueMap.get('audio_driver')).toBe('pulse')
+ })
+
+ it('handles minimal config with missing optional fields', () => {
+ const minimalConfig = {
+ graphicsDriver: 'vortek',
+ dxwrapper: 'wined3d',
+ audioDriver: 'alsa',
+ }
+ const raw = JSON.stringify(minimalConfig)
+ const result = parseGameNativeConfigFromJson(raw, baseFields)
+
+ const valueMap = new Map(result.values.map((v) => [v.id, v.value]))
+
+ expect(valueMap.get('graphics_driver')).toBe('Vortek (Universal)')
+ expect(valueMap.get('dx_wrapper')).toBe('WineD3D')
+ expect(valueMap.get('audio_driver')).toBe('alsa')
+ expect(valueMap.has('resolution')).toBe(false)
+ })
+
+ it('returns warning for invalid JSON', () => {
+ const result = parseGameNativeConfigFromJson('not valid json{', baseFields)
+
+ expect(result.values).toHaveLength(0)
+ expect(result.warnings).toContain('Failed to parse JSON configuration file.')
+ })
+
+ it('marks required fields as missing when not in JSON and no default', () => {
+ const raw = JSON.stringify({})
+ const fields: CustomFieldImportDefinition[] = [
+ {
+ id: 'graphics_driver',
+ name: 'graphics_driver',
+ label: 'Graphics Driver',
+ type: CustomFieldType.SELECT,
+ isRequired: true,
+ },
+ ]
+ const result = parseGameNativeConfigFromJson(raw, fields)
+
+ expect(result.missing).toContain('Graphics Driver')
+ })
+
+ it('uses defaultValue for unmapped fields', () => {
+ const raw = JSON.stringify(SAMPLE_CONFIG)
+ const fields: CustomFieldImportDefinition[] = [
+ {
+ id: 'unknown_field',
+ name: 'unknown_field',
+ label: 'Unknown Field',
+ type: CustomFieldType.TEXT,
+ isRequired: false,
+ defaultValue: 'fallback',
+ },
+ ]
+ const result = parseGameNativeConfigFromJson(raw, fields)
+ const valueMap = new Map(result.values.map((v) => [v.id, v.value]))
+
+ expect(valueMap.get('unknown_field')).toBe('fallback')
+ })
+
+ it('uses defaultValue when mapped JSON key is absent', () => {
+ const raw = JSON.stringify({})
+ const fields: CustomFieldImportDefinition[] = [
+ {
+ id: 'resolution',
+ name: 'resolution',
+ label: 'Resolution',
+ type: CustomFieldType.SELECT,
+ isRequired: false,
+ defaultValue: '854x480',
+ },
+ ]
+ const result = parseGameNativeConfigFromJson(raw, fields)
+ const valueMap = new Map(result.values.map((v) => [v.id, v.value]))
+
+ expect(valueMap.get('resolution')).toBe('854x480')
+ })
+
+ it('reverse-maps startup_selection numeric values', () => {
+ const configs = [
+ { startupSelection: 0, expected: 'Normal (Load all services)' },
+ { startupSelection: 1, expected: 'Essential (Load only essential services)' },
+ { startupSelection: 2, expected: 'Aggressive (Stop services on startup)' },
+ ]
+
+ for (const { startupSelection, expected } of configs) {
+ const raw = JSON.stringify({ startupSelection })
+ const fields: CustomFieldImportDefinition[] = [
+ {
+ id: 'startup_selection',
+ name: 'startup_selection',
+ label: 'Startup Selection',
+ type: CustomFieldType.SELECT,
+ isRequired: false,
+ },
+ ]
+ const result = parseGameNativeConfigFromJson(raw, fields)
+ const valueMap = new Map(result.values.map((v) => [v.id, v.value]))
+ expect(valueMap.get('startup_selection')).toBe(expected)
+ }
+ })
+
+ it('reverse-maps box64 presets from uppercase to lowercase', () => {
+ const presets = [
+ { preset: 'STABILITY', expected: 'stability' },
+ { preset: 'COMPATIBILITY', expected: 'compatibility' },
+ { preset: 'INTERMEDIATE', expected: 'intermediate' },
+ { preset: 'PERFORMANCE', expected: 'performance' },
+ ]
+
+ for (const { preset, expected } of presets) {
+ const raw = JSON.stringify({ box64Preset: preset })
+ const fields: CustomFieldImportDefinition[] = [
+ {
+ id: 'box64_preset',
+ name: 'box64_preset',
+ label: 'Box64 Preset',
+ type: CustomFieldType.SELECT,
+ isRequired: false,
+ },
+ ]
+ const result = parseGameNativeConfigFromJson(raw, fields)
+ const valueMap = new Map(result.values.map((v) => [v.id, v.value]))
+ expect(valueMap.get('box64_preset')).toBe(expected)
+ }
+ })
+
+ it('handles all graphics driver reverse mappings', () => {
+ const drivers = [
+ { config: 'virgl', expected: 'VirGL (Universal)' },
+ { config: 'turnip', expected: 'Turnip (Adreno)' },
+ { config: 'vortek', expected: 'Vortek (Universal)' },
+ ]
+
+ for (const { config, expected } of drivers) {
+ const raw = JSON.stringify({ graphicsDriver: config })
+ const fields: CustomFieldImportDefinition[] = [
+ {
+ id: 'graphics_driver',
+ name: 'graphics_driver',
+ label: 'Graphics Driver',
+ type: CustomFieldType.SELECT,
+ isRequired: true,
+ },
+ ]
+ const result = parseGameNativeConfigFromJson(raw, fields)
+ const valueMap = new Map(result.values.map((v) => [v.id, v.value]))
+ expect(valueMap.get('graphics_driver')).toBe(expected)
+ }
+ })
+
+ it('passes through unknown graphics driver values', () => {
+ const raw = JSON.stringify({ graphicsDriver: 'future_driver' })
+ const fields: CustomFieldImportDefinition[] = [
+ {
+ id: 'graphics_driver',
+ name: 'graphics_driver',
+ label: 'Graphics Driver',
+ type: CustomFieldType.SELECT,
+ isRequired: true,
+ },
+ ]
+ const result = parseGameNativeConfigFromJson(raw, fields)
+ const valueMap = new Map(result.values.map((v) => [v.id, v.value]))
+ expect(valueMap.get('graphics_driver')).toBe('future_driver')
+ })
+
+ it('reverse-maps dinputMapperType numbers to strings', () => {
+ const raw = JSON.stringify({ dinputMapperType: 2 })
+ const fields: CustomFieldImportDefinition[] = [
+ {
+ id: 'direct_input_mapper_type',
+ name: 'direct_input_mapper_type',
+ label: 'DirectInput Mapper Type',
+ type: CustomFieldType.SELECT,
+ isRequired: false,
+ },
+ ]
+ const result = parseGameNativeConfigFromJson(raw, fields)
+ const valueMap = new Map(result.values.map((v) => [v.id, v.value]))
+ expect(valueMap.get('direct_input_mapper_type')).toBe('xinput_mapper')
+ })
+
+ it('reverse-maps emulator field to fex/box values', () => {
+ const raw = JSON.stringify({ emulator: 'Box64' })
+ const fields: CustomFieldImportDefinition[] = [
+ {
+ id: '64_bit_emulator',
+ name: '64_bit_emulator',
+ label: '64-bit Emulator',
+ type: CustomFieldType.SELECT,
+ isRequired: false,
+ },
+ ]
+ const result = parseGameNativeConfigFromJson(raw, fields)
+ const valueMap = new Map(result.values.map((v) => [v.id, v.value]))
+ expect(valueMap.get('64_bit_emulator')).toBe('box')
+ })
+
+ it('detects use_adrenotools_turnip from graphicsDriverConfig', () => {
+ const configEnabled = JSON.stringify({
+ graphicsDriverConfig: 'adrenotoolsTurnip=1,vulkanVersion=1.3',
+ })
+ const configDisabled = JSON.stringify({
+ graphicsDriverConfig: 'adrenotoolsTurnip=0,vulkanVersion=1.3',
+ })
+ const configMissing = JSON.stringify({ graphicsDriverConfig: 'vulkanVersion=1.3' })
+ const fields: CustomFieldImportDefinition[] = [
+ {
+ id: 'use_adrenotools_turnip',
+ name: 'use_adrenotools_turnip',
+ label: 'Use Adrenotools Turnip',
+ type: CustomFieldType.BOOLEAN,
+ isRequired: false,
+ },
+ ]
+
+ const resultEnabled = parseGameNativeConfigFromJson(configEnabled, fields)
+ const mapEnabled = new Map(resultEnabled.values.map((v) => [v.id, v.value]))
+ expect(mapEnabled.get('use_adrenotools_turnip')).toBe(true)
+
+ const resultDisabled = parseGameNativeConfigFromJson(configDisabled, fields)
+ const mapDisabled = new Map(resultDisabled.values.map((v) => [v.id, v.value]))
+ expect(mapDisabled.get('use_adrenotools_turnip')).toBe(false)
+
+ const resultMissing = parseGameNativeConfigFromJson(configMissing, fields)
+ const mapMissing = new Map(resultMissing.values.map((v) => [v.id, v.value]))
+ expect(mapMissing.get('use_adrenotools_turnip')).toBe(false)
+ })
+
+ it('parses max_device_memory from graphicsDriverConfig', () => {
+ const raw = JSON.stringify({ graphicsDriverConfig: 'vulkanVersion=1.3,maxDeviceMemory=8192' })
+ const fields: CustomFieldImportDefinition[] = [
+ {
+ id: 'max_device_memory',
+ name: 'max_device_memory',
+ label: 'Max Device Memory',
+ type: CustomFieldType.TEXT,
+ isRequired: false,
+ },
+ ]
+ const result = parseGameNativeConfigFromJson(raw, fields)
+ const valueMap = new Map(result.values.map((v) => [v.id, v.value]))
+ expect(valueMap.get('max_device_memory')).toBe('8192')
+ })
+
+ it('reverse-maps FEXCore presets from uppercase to lowercase', () => {
+ const raw = JSON.stringify({ fexcorePreset: 'PERFORMANCE' })
+ const fields: CustomFieldImportDefinition[] = [
+ {
+ id: 'fex_core_preset',
+ name: 'fex_core_preset',
+ label: 'FEXCore Preset',
+ type: CustomFieldType.SELECT,
+ isRequired: false,
+ },
+ ]
+ const result = parseGameNativeConfigFromJson(raw, fields)
+ const valueMap = new Map(result.values.map((v) => [v.id, v.value]))
+ expect(valueMap.get('fex_core_preset')).toBe('performance')
+ })
+
+ it('reverse-maps steam_type ultralight to ultra_light', () => {
+ const raw = JSON.stringify({ steamType: 'ultralight' })
+ const fields: CustomFieldImportDefinition[] = [
+ {
+ id: 'steam_type',
+ name: 'steam_type',
+ label: 'Steam Type',
+ type: CustomFieldType.SELECT,
+ isRequired: false,
+ },
+ ]
+ const result = parseGameNativeConfigFromJson(raw, fields)
+ const valueMap = new Map(result.values.map((v) => [v.id, v.value]))
+ expect(valueMap.get('steam_type')).toBe('ultra_light')
+ })
+})
diff --git a/src/shared/emulator-config/gamenative/parser.ts b/src/shared/emulator-config/gamenative/parser.ts
new file mode 100644
index 00000000..93ad3a38
--- /dev/null
+++ b/src/shared/emulator-config/gamenative/parser.ts
@@ -0,0 +1,74 @@
+import { GAMENATIVE_IMPORT_MAPPINGS } from './mapping'
+import type { CustomFieldImportDefinition, EmulatorConfigImportResult } from '../types'
+
+function getNestedValue(obj: Record, path: string): unknown {
+ const keys = path.split('.')
+ let current: unknown = obj
+ for (const key of keys) {
+ if (current === null || current === undefined || typeof current !== 'object') {
+ return undefined
+ }
+ current = (current as Record)[key]
+ }
+ return current
+}
+
+export function parseGameNativeConfigFromJson(
+ raw: string,
+ customFields: CustomFieldImportDefinition[],
+): EmulatorConfigImportResult {
+ const values: { id: string; value: unknown }[] = []
+ const missing: string[] = []
+ const warnings: string[] = []
+
+ let config: Record
+ try {
+ config = JSON.parse(raw) as Record
+ } catch {
+ warnings.push('Failed to parse JSON configuration file.')
+ return { values, missing, warnings }
+ }
+
+ if (typeof config !== 'object' || config === null) {
+ warnings.push('Configuration file does not contain a valid JSON object.')
+ return { values, missing, warnings }
+ }
+
+ for (const field of customFields) {
+ const mapping = GAMENATIVE_IMPORT_MAPPINGS[field.name]
+ if (!mapping) {
+ if (field.defaultValue !== undefined && field.defaultValue !== null) {
+ values.push({ id: field.id, value: field.defaultValue })
+ } else if (field.isRequired) {
+ missing.push(field.label)
+ }
+ continue
+ }
+
+ const paths = Array.isArray(mapping.jsonPath) ? mapping.jsonPath : [mapping.jsonPath]
+ let rawValue: unknown
+ let found = false
+
+ for (const path of paths) {
+ rawValue = getNestedValue(config, path)
+ if (rawValue !== undefined) {
+ found = true
+ break
+ }
+ }
+
+ if (!found) {
+ if (field.defaultValue !== undefined && field.defaultValue !== null) {
+ values.push({ id: field.id, value: field.defaultValue })
+ } else if (field.isRequired) {
+ missing.push(field.label)
+ }
+ continue
+ }
+
+ const value = mapping.fromConfig ? mapping.fromConfig(rawValue, config) : rawValue
+ values.push({ id: field.id, value })
+ }
+
+ return { values, missing, warnings }
+}
diff --git a/src/test/setup.ts b/src/test/setup.ts
index 2cf42d0f..63409e14 100644
--- a/src/test/setup.ts
+++ b/src/test/setup.ts
@@ -17,13 +17,23 @@ vi.mock('@orm/sql', () => ({
// Mock the entire Prisma client module to prevent database initialization
vi.mock('@orm', () => ({
- // Prisma namespace for raw SQL tagged templates
+ // Prisma namespace β includes raw SQL tagged templates and the
+ // runtime-enum-like objects that router modules reference at module top
+ // (e.g. `const mode = Prisma.QueryMode.insensitive`).
Prisma: {
sql: (strings: TemplateStringsArray, ...values: unknown[]) => ({
strings: Array.from(strings),
values,
text: strings.join('?'),
}),
+ QueryMode: {
+ default: 'default',
+ insensitive: 'insensitive',
+ },
+ SortOrder: {
+ asc: 'asc',
+ desc: 'desc',
+ },
},
PrismaClient: vi.fn().mockImplementation(() => ({
$transaction: vi.fn(),
diff --git a/src/utils/user-bans.test.ts b/src/utils/user-bans.test.ts
new file mode 100644
index 00000000..3f4e2da5
--- /dev/null
+++ b/src/utils/user-bans.test.ts
@@ -0,0 +1,40 @@
+import { describe, expect, it } from 'vitest'
+import { hasActiveBans } from './user-bans'
+
+describe('hasActiveBans', () => {
+ it('returns false for null', () => {
+ expect(hasActiveBans(null)).toBe(false)
+ })
+
+ it('returns false for undefined', () => {
+ expect(hasActiveBans(undefined)).toBe(false)
+ })
+
+ it('returns false for a primitive', () => {
+ expect(hasActiveBans('user-id')).toBe(false)
+ expect(hasActiveBans(42)).toBe(false)
+ })
+
+ it('returns false when `userBans` is not a property', () => {
+ expect(hasActiveBans({})).toBe(false)
+ expect(hasActiveBans({ id: 'x', name: 'y' })).toBe(false)
+ })
+
+ it('returns false when `userBans` is null', () => {
+ expect(hasActiveBans({ userBans: null })).toBe(false)
+ })
+
+ it('returns false when `userBans` is not an array', () => {
+ expect(hasActiveBans({ userBans: 'not-an-array' })).toBe(false)
+ expect(hasActiveBans({ userBans: { id: 'x' } })).toBe(false)
+ })
+
+ it('returns false when `userBans` is an empty array', () => {
+ expect(hasActiveBans({ userBans: [] })).toBe(false)
+ })
+
+ it('returns true when `userBans` contains at least one entry', () => {
+ expect(hasActiveBans({ userBans: [{ id: 'ban-1' }] })).toBe(true)
+ expect(hasActiveBans({ userBans: [{ id: 'a' }, { id: 'b' }] })).toBe(true)
+ })
+})
diff --git a/src/utils/user-bans.ts b/src/utils/user-bans.ts
new file mode 100644
index 00000000..0788f05d
--- /dev/null
+++ b/src/utils/user-bans.ts
@@ -0,0 +1,10 @@
+interface AuthorWithPossibleBans {
+ userBans?: { id: string }[] | null
+}
+
+export function hasActiveBans(author: unknown): author is AuthorWithPossibleBans {
+ if (!author || typeof author !== 'object') return false
+ if (!('userBans' in author)) return false
+ const bans = (author as AuthorWithPossibleBans).userBans
+ return Array.isArray(bans) && bans.length > 0
+}
diff --git a/tests/accessibility.spec.ts b/tests/accessibility.spec.ts
index d40b63e3..06411553 100644
--- a/tests/accessibility.spec.ts
+++ b/tests/accessibility.spec.ts
@@ -8,24 +8,16 @@ test.describe('Accessibility Tests', () => {
const homePage = new HomePage(page)
await homePage.goto()
- // Check for h1
- const h1Elements = page.locator('h1')
- const h1Count = await h1Elements.count()
+ const h1Count = await page.locator('h1').count()
expect(h1Count).toBeGreaterThanOrEqual(1)
- expect(h1Count).toBeLessThanOrEqual(1) // Should only have one h1
+ expect(h1Count).toBeLessThanOrEqual(1)
- // Check heading hierarchy
- const headings = page.locator('h1, h2, h3, h4, h5, h6')
- const headingLevels = await headings.evaluateAll((elements) =>
- elements.map((el) => parseInt(el.tagName.substring(1))),
- )
+ const headingLevels = await page
+ .locator('h1, h2, h3, h4, h5, h6')
+ .evaluateAll((elements) => elements.map((el) => parseInt(el.tagName.substring(1))))
- // Verify no skipped heading levels
for (let i = 1; i < headingLevels.length; i++) {
- const diff = headingLevels[i] - headingLevels[i - 1]
- // Heading levels should not skip more than 1 level
- // TODO: Fix heading hierarchy in the app
- expect(diff).toBeLessThanOrEqual(2)
+ expect(headingLevels[i] - headingLevels[i - 1]).toBeLessThanOrEqual(2)
}
})
@@ -33,57 +25,46 @@ test.describe('Accessibility Tests', () => {
const gamesPage = new GamesPage(page)
await gamesPage.goto()
- // Check buttons have accessible names
- const buttons = page.locator('button')
- const buttonCount = await buttons.count()
-
- for (let i = 0; i < Math.min(buttonCount, 5); i++) {
- // Check first 5 buttons
- const button = buttons.nth(i)
- const accessibleName =
- (await button.getAttribute('aria-label')) ||
- (await button.textContent()) ||
- (await button.getAttribute('title'))
+ const buttonData = await page.locator('button').evaluateAll((elements) =>
+ elements.slice(0, 5).map((el) => ({
+ ariaLabel: el.getAttribute('aria-label'),
+ text: el.textContent,
+ title: el.getAttribute('title'),
+ })),
+ )
- expect(accessibleName).toBeTruthy()
- expect(accessibleName!.trim().length).toBeGreaterThan(0)
+ for (const btn of buttonData) {
+ const name = btn.ariaLabel || btn.text || btn.title
+ expect(name).toBeTruthy()
+ expect(name!.trim().length).toBeGreaterThan(0)
}
- // Check links have accessible text
- const links = page.locator('a')
- const linkCount = await links.count()
-
- for (let i = 0; i < Math.min(linkCount, 5); i++) {
- const link = links.nth(i)
- const linkText = (await link.textContent()) || (await link.getAttribute('aria-label'))
+ const linkData = await page.locator('a').evaluateAll((elements) =>
+ elements.slice(0, 5).map((el) => ({
+ text: el.textContent,
+ ariaLabel: el.getAttribute('aria-label'),
+ })),
+ )
- // Should not have generic link text
- expect(linkText?.toLowerCase()).not.toMatch(/^(click here|here|link)$/)
+ for (const link of linkData) {
+ const text = link.text || link.ariaLabel
+ expect(text?.toLowerCase()).not.toMatch(/^(click here|here|link)$/)
}
})
test('should have alt text for all images', async ({ page }) => {
const listingsPage = new ListingsPage(page)
await listingsPage.goto()
-
- // Wait for content to load
await listingsPage.verifyPageLoaded()
- // Check all images
- const images = page.locator('img')
- const imageCount = await images.count()
-
- for (let i = 0; i < imageCount; i++) {
- const img = images.nth(i)
- const altText = await img.getAttribute('alt')
+ const altTexts = await page
+ .locator('img')
+ .evaluateAll((elements) => elements.map((el) => el.getAttribute('alt')))
- // Every image should have alt text
+ for (const altText of altTexts) {
expect(altText).toBeDefined()
-
- // Decorative images should have empty alt=""
- // Content images should have descriptive alt
if (altText !== '') {
- expect(altText!.length).toBeGreaterThan(3) // Not just "img" or "pic"
+ expect(altText!.length).toBeGreaterThan(3)
}
}
})
@@ -92,28 +73,18 @@ test.describe('Accessibility Tests', () => {
const homePage = new HomePage(page)
await homePage.goto()
- // Tab through interactive elements
- const tabSequence = []
-
+ const tabData = []
for (let i = 0; i < 10; i++) {
await page.keyboard.press('Tab')
-
- const focusedElement = page.locator(':focus')
- const tagName = await focusedElement.evaluate((el) => el.tagName.toLowerCase())
- const text = await focusedElement.textContent().catch(() => '')
-
- tabSequence.push({ tagName, text })
-
- // Focused element should be visible
- await expect(focusedElement).toBeVisible()
+ const [tagName, text] = await page.evaluate(() => {
+ const focused = document.activeElement
+ return [focused?.tagName.toLowerCase() ?? '', focused?.textContent ?? '']
+ })
+ tabData.push({ tagName, text })
}
- // Should have focused on various interactive elements
const interactiveTags = ['a', 'button', 'input', 'select', 'textarea']
- const hasInteractiveElements = tabSequence.some((item) =>
- interactiveTags.includes(item.tagName),
- )
-
+ const hasInteractiveElements = tabData.some((item) => interactiveTags.includes(item.tagName))
expect(hasInteractiveElements).toBe(true)
})
@@ -121,41 +92,30 @@ test.describe('Accessibility Tests', () => {
const gamesPage = new GamesPage(page)
await gamesPage.goto()
- // Check text elements for potential contrast issues
- const textElements = page
+ const styles = await page
.locator('p, span, div, h1, h2, h3, h4, h5, h6')
.filter({ hasText: /\S+/ })
- const sampleSize = Math.min(await textElements.count(), 10)
-
- for (let i = 0; i < sampleSize; i++) {
- const element = textElements.nth(i)
-
- const styles = await element.evaluate((el) => {
- const computed = window.getComputedStyle(el)
- return {
- color: computed.color,
- backgroundColor: computed.backgroundColor,
- fontSize: computed.fontSize,
- fontWeight: computed.fontWeight,
- }
- })
-
- // Skip transparent elements as they inherit parent background
- if (
- styles.backgroundColor === 'rgba(0, 0, 0, 0)' ||
- styles.backgroundColor === 'transparent'
- ) {
+ .evaluateAll((elements) =>
+ elements.slice(0, 10).map((el) => {
+ const computed = window.getComputedStyle(el)
+ return {
+ color: computed.color,
+ backgroundColor: computed.backgroundColor,
+ fontSize: computed.fontSize,
+ }
+ }),
+ )
+
+ for (const s of styles) {
+ if (s.backgroundColor === 'rgba(0, 0, 0, 0)' || s.backgroundColor === 'transparent') {
continue
}
- // Basic check: text should not be same color as background
- if (styles.color !== 'rgba(0, 0, 0, 0)' && styles.color !== 'transparent') {
- expect(styles.color).not.toBe(styles.backgroundColor)
+ if (s.color !== 'rgba(0, 0, 0, 0)' && s.color !== 'transparent') {
+ expect(s.color).not.toBe(s.backgroundColor)
}
- // Text should be readable size
- const fontSize = parseInt(styles.fontSize)
- expect(fontSize).toBeGreaterThanOrEqual(12)
+ expect(parseInt(s.fontSize)).toBeGreaterThanOrEqual(12)
}
})
@@ -163,11 +123,9 @@ test.describe('Accessibility Tests', () => {
const homePage = new HomePage(page)
await homePage.goto()
- // Focus on first link
const firstLink = page.locator('a').first()
await firstLink.focus()
- // Check if focus is visible
const focusStyles = await firstLink.evaluate((el) => {
const computed = window.getComputedStyle(el)
return {
@@ -179,7 +137,6 @@ test.describe('Accessibility Tests', () => {
}
})
- // Should have some visual focus indicator
const hasFocusIndicator =
(focusStyles.outline && focusStyles.outline !== 'none') ||
(focusStyles.outlineWidth && focusStyles.outlineWidth !== '0px') ||
@@ -193,31 +150,24 @@ test.describe('Accessibility Tests', () => {
const listingsPage = new ListingsPage(page)
await listingsPage.goto()
- // Check all form inputs
- const inputs = page.locator('input, select, textarea')
- const inputCount = await inputs.count()
-
- for (let i = 0; i < inputCount; i++) {
- const input = inputs.nth(i)
- const inputId = await input.getAttribute('id')
- const ariaLabel = await input.getAttribute('aria-label')
- const ariaLabelledBy = await input.getAttribute('aria-labelledby')
- const placeholder = await input.getAttribute('placeholder')
-
- // Check for associated label
- let hasLabel = false
-
- if (inputId) {
- const label = page.locator(`label[for="${inputId}"]`)
- hasLabel = (await label.count()) > 0
- }
-
- // Input should have some form of label
- const hasAccessibleName = hasLabel || ariaLabel || ariaLabelledBy
-
- // Placeholder alone is not sufficient (except for search inputs which are commonly understood)
- // TODO: Add proper labels to form inputs
- expect(hasAccessibleName || !!placeholder).toBe(true)
+ const inputAccessibility = await page
+ .locator('input, select, textarea')
+ .evaluateAll((elements) =>
+ elements.map((el) => {
+ const id = el.getAttribute('id')
+ const hasLabel = id ? !!document.querySelector(`label[for="${CSS.escape(id)}"]`) : false
+ return {
+ hasLabel,
+ ariaLabel: el.getAttribute('aria-label'),
+ ariaLabelledBy: el.getAttribute('aria-labelledby'),
+ placeholder: el.getAttribute('placeholder'),
+ }
+ }),
+ )
+
+ for (const input of inputAccessibility) {
+ const hasAccessibleName = input.hasLabel || input.ariaLabel || input.ariaLabelledBy
+ expect(hasAccessibleName || !!input.placeholder).toBe(true)
}
})
@@ -225,13 +175,10 @@ test.describe('Accessibility Tests', () => {
const homePage = new HomePage(page)
await homePage.goto()
- // Navigate to trigger potential announcements
await homePage.navigateToGames()
- // Page title should update - wait for navigation to complete
await page.waitForLoadState('domcontentloaded')
const title = await page.title()
- // Title should be "Games | EmuReady" or similar
expect(title.toLowerCase()).toMatch(/games|emuready/)
})
@@ -239,25 +186,23 @@ test.describe('Accessibility Tests', () => {
const homePage = new HomePage(page)
await homePage.goto()
- // Look for skip links (often hidden until focused)
- const skipLinks = page.locator('a').filter({ hasText: /skip to (content|main|navigation)/i })
-
- if ((await skipLinks.count()) > 0) {
- const firstSkipLink = skipLinks.first()
-
- // Focus to make it visible
- await firstSkipLink.focus()
-
- // Should become visible when focused
- await expect(firstSkipLink).toBeVisible()
-
- // Should have proper href
- const href = await firstSkipLink.getAttribute('href')
- expect(href).toMatch(/^#\w+/)
+ const skipLinkData = await page
+ .locator('a')
+ .filter({ hasText: /skip to (content|main|navigation)/i })
+ .evaluateAll((elements) =>
+ elements.map((el) => ({
+ href: el.getAttribute('href'),
+ text: el.textContent,
+ })),
+ )
+
+ if (skipLinkData.length > 0) {
+ expect(skipLinkData[0].href).toMatch(/^#\w+/)
} else {
- // Check if main content has id for direct navigation
- const mainContent = page.locator('main, [role="main"]')
- const hasMainLandmark = (await mainContent.count()) > 0
+ const hasMainLandmark = await page
+ .locator('main, [role="main"]')
+ .count()
+ .then((c) => c > 0)
expect(hasMainLandmark).toBe(true)
}
})
@@ -266,79 +211,37 @@ test.describe('Accessibility Tests', () => {
const gamesPage = new GamesPage(page)
await gamesPage.goto()
- // Check html lang attribute
const htmlLang = await page.locator('html').getAttribute('lang')
expect(htmlLang).toBeTruthy()
- expect(htmlLang).toMatch(/^[a-z]{2}(-[A-Z]{2})?$/) // e.g., "en" or "en-US"
+ expect(htmlLang).toMatch(/^[a-z]{2}(-[A-Z]{2})?$/)
})
- test('should handle focus trap in modals', async ({ page }) => {
- const homePage = new HomePage(page)
- await homePage.goto()
-
- // Look for modal triggers
- const modalTriggers = page.locator('button').filter({ hasText: /sign in|sign up/i })
- const triggerCount = await modalTriggers.count()
- test.skip(triggerCount === 0, 'No modal triggers found on page')
-
- const trigger = modalTriggers.first()
- await trigger.click()
-
- // Check for standard ARIA dialog (our own modals)
- const standardModal = page.locator('[role="dialog"], [aria-modal="true"], .modal')
- const hasStandardModal = await standardModal
- .first()
- .waitFor({ state: 'visible', timeout: 5000 })
- .then(() => true)
- .catch(() => false)
-
- if (hasStandardModal) {
- // Tab should stay within modal
- await page.keyboard.press('Tab')
- await page.keyboard.press('Tab')
-
- const focusedElement = page.locator(':focus')
- const isInModal = await focusedElement.evaluate((el, modalSelector) => {
- const modal = document.querySelector(modalSelector)
- return modal ? modal.contains(el) : false
- }, '[role="dialog"], [aria-modal="true"], .modal')
-
- expect(isInModal).toBe(true)
-
- // Escape should close modal
- await page.keyboard.press('Escape')
-
- await standardModal.first().waitFor({ state: 'hidden', timeout: 5000 })
- const modalStillVisible = await standardModal.first().isVisible()
- expect(modalStillVisible).toBe(false)
- } else {
- // Clerk's auth modal (third-party, no role="dialog") β skip focus trap test
- // Clerk manages its own focus trapping and accessibility
- test.skip(true, 'Only third-party Clerk modal available β focus trap is managed by Clerk')
- }
+ test('should handle focus trap in modals', async () => {
+ test.skip(true, 'Focus trap verification depends on Clerk third-party modal behavior')
})
test('should have proper table accessibility', async ({ page }) => {
const listingsPage = new ListingsPage(page)
await listingsPage.goto()
- // Check for tables
const tables = page.locator('table')
const tableCount = await tables.count()
- test.skip(tableCount === 0, 'No tables found on listings page')
-
- const table = tables.first()
+ expect(tableCount).toBeGreaterThan(0)
- // Should have proper headers
- const headers = table.locator('th')
- const headerCount = await headers.count()
- expect(headerCount).toBeGreaterThan(0)
-
- // Headers should have scope (but it's not always required)
- const firstHeader = headers.first()
- const scope = await firstHeader.getAttribute('scope')
- if (scope) {
- expect(scope).toMatch(/^(col|row)$/)
+ const headerData = await tables
+ .first()
+ .locator('th')
+ .evaluateAll((elements) =>
+ elements.map((el) => ({
+ scope: el.getAttribute('scope'),
+ text: el.textContent,
+ })),
+ )
+
+ expect(headerData.length).toBeGreaterThan(0)
+
+ if (headerData[0].scope) {
+ expect(headerData[0].scope).toMatch(/^(col|row)$/)
}
})
})
@@ -348,39 +251,28 @@ test.describe('Screen Reader Tests', () => {
const homePage = new HomePage(page)
await homePage.goto()
- // Check for landmark regions
- const landmarks = {
- header: page.locator('header, [role="banner"]'),
- nav: page.locator('nav, [role="navigation"]'),
- main: page.locator('main, [role="main"]'),
- footer: page.locator('footer, [role="contentinfo"]'),
- }
+ const nav = page.locator('nav, [role="navigation"]')
+ const main = page.locator('main, [role="main"]')
- // At minimum, we should have nav and main
- const hasNav = (await landmarks.nav.count()) > 0
- const hasMain = (await landmarks.main.count()) > 0
- expect(hasNav || hasMain).toBe(true)
+ await expect(nav.first()).toBeVisible()
+ await expect(main.first()).toBeVisible()
})
test('should provide context for icon buttons', async ({ page }) => {
const gamesPage = new GamesPage(page)
await gamesPage.goto()
- // Find buttons that might only have icons
- const buttons = page.locator('button')
- const buttonCount = await buttons.count()
-
- for (let i = 0; i < Math.min(buttonCount, 10); i++) {
- const button = buttons.nth(i)
- const text = await button.textContent()
-
- // If button has no visible text (likely icon-only)
- if (!text || text.trim().length === 0) {
- const ariaLabel = await button.getAttribute('aria-label')
- const title = await button.getAttribute('title')
+ const buttonData = await page.locator('button').evaluateAll((elements) =>
+ elements.slice(0, 10).map((el) => ({
+ text: el.textContent?.trim() ?? '',
+ ariaLabel: el.getAttribute('aria-label'),
+ title: el.getAttribute('title'),
+ })),
+ )
- // Should have aria-label or title
- expect(ariaLabel || title).toBeTruthy()
+ for (const btn of buttonData) {
+ if (btn.text.length === 0) {
+ expect(btn.ariaLabel || btn.title).toBeTruthy()
}
}
})
diff --git a/tests/admin-dashboard.spec.ts b/tests/admin-dashboard.spec.ts
index eb7be2dd..6d8feca2 100644
--- a/tests/admin-dashboard.spec.ts
+++ b/tests/admin-dashboard.spec.ts
@@ -1,134 +1,55 @@
import { test, expect } from '@playwright/test'
-test.describe('Admin Dashboard Tests - Requires Admin Role', () => {
- test.use({ storageState: 'tests/.auth/admin.json' })
+test.describe('Admin Dashboard', () => {
+ test.use({ storageState: 'tests/.auth/super_admin.json' })
+
test.beforeEach(async ({ page }) => {
await page.goto('/admin', { waitUntil: 'domcontentloaded' })
await expect(page).toHaveURL(/\/admin/)
-
- // Wait for dashboard content to render
- await page
- .locator('[data-testid="admin-nav"]')
- .or(page.locator('nav').filter({ hasText: /systems|games/i }))
- .first()
- .waitFor({ state: 'visible', timeout: 15000 })
+ await page.locator('[data-testid="admin-nav"]').waitFor({ state: 'visible', timeout: 15000 })
})
- test('should display admin navigation menu with all required items', async ({ page }) => {
- // Admin navigation should be visible - specifically the QuickNavigation component
+ test('should display admin navigation with required menu items', async ({ page }) => {
const adminNav = page.locator('[data-testid="admin-nav"]')
await expect(adminNav).toBeVisible()
- // All admin menu items must be present (based on admin role permissions)
- const requiredMenuItems = ['Games', 'Systems', 'Devices', 'Emulators']
-
- for (const item of requiredMenuItems) {
- // Look for links specifically within the admin nav, use first() to avoid duplicates
+ for (const item of ['Games', 'Systems', 'Devices', 'Emulators']) {
const menuLink = adminNav
.locator('a')
.filter({ hasText: new RegExp(item, 'i') })
.first()
- await expect(menuLink).toBeVisible({ timeout: 5000 })
+ await expect(menuLink).toBeVisible()
}
})
- test('should display dashboard statistics with valid data', async ({ page }) => {
- // PlatformStats component renders stat sections with headings and numeric values
+ test('should display dashboard statistics', async ({ page }) => {
const mainContent = page.locator('main').first()
await expect(mainContent).toBeVisible()
- // Wait for dashboard data to load (stats or activity cards)
- await page
- .locator('text=/\\d+/')
- .first()
- .waitFor({ state: 'visible', timeout: 15000 })
- .catch(() => {})
-
- // Dashboard must have numeric stat values rendered
const statValues = mainContent.locator('text=/\\d+/')
+ await expect(statValues.first()).toBeVisible()
+
const statCount = await statValues.count()
- test.skip(statCount === 0, 'Dashboard statistics have not loaded')
expect(statCount).toBeGreaterThan(0)
})
- test('should show recent activity feed with entries', async ({ page }) => {
- // Activity cards are rendered with time range buttons (24h, 48h, 7d)
+ test('should show time range filter buttons', async ({ page }) => {
const timeRangeButtons = page.locator('button').filter({ hasText: /24h|48h|7d/i })
- const buttonCount = await timeRangeButtons.count()
-
- if (buttonCount > 0) {
- // Activity section is present with time range controls
- await expect(timeRangeButtons.first()).toBeVisible()
-
- // Clicking a time range button should keep the dashboard functional
- await timeRangeButtons.first().click()
- await page.waitForLoadState('domcontentloaded')
+ await expect(timeRangeButtons.first()).toBeVisible()
- // Dashboard main content must remain visible after interaction
- await expect(page.locator('main').first()).toBeVisible()
- } else {
- // If no time range buttons, PlatformStats or QuickNavigation must be present
- const quickNav = page.locator('[data-testid="admin-nav"]')
- await expect(quickNav).toBeVisible()
+ for (const range of ['24h', '48h', '7d']) {
+ const rangeButton = page
+ .locator('button')
+ .filter({ hasText: new RegExp(range, 'i') })
+ .first()
+ await expect(rangeButton).toBeVisible()
}
})
- test('should have functional quick actions', async ({ page }) => {
- // Quick Navigation component serves as quick actions
- const quickNav = page.locator('[data-testid="admin-nav"]')
- await expect(quickNav).toBeVisible()
-
- // Check that navigation links are present in the QuickNavigation
- const navLinks = quickNav.locator('a')
- const linkCount = await navLinks.count()
-
- // Must have at least 2 navigation links for quick access
- expect(linkCount).toBeGreaterThanOrEqual(2)
- })
-
- test('should display activity cards on dashboard', async ({ page }) => {
- // Wait for dashboard content to load
- await page
- .locator('button')
- .filter({ hasText: /24h|48h|7d/i })
- .first()
- .or(page.locator('[data-testid="admin-nav"]'))
- .waitFor({ state: 'visible', timeout: 15000 })
- .catch(() => {})
-
- // Activity cards contain time range buttons and data sections
- const timeRangeButtons = page.locator('button').filter({ hasText: /24h|48h|7d/i })
- const buttonCount = await timeRangeButtons.count()
-
- // Dashboard must have time range buttons (from activity cards) or QuickNavigation
- const quickNav = page.locator('[data-testid="admin-nav"]')
- const hasQuickNav = await quickNav.isVisible()
-
- expect(buttonCount > 0 || hasQuickNav).toBe(true)
- })
-
- test('should have time range filters on activity cards', async ({ page }) => {
- // Wait for dashboard content to load
- await page
- .locator('button')
- .filter({ hasText: /24h|48h|7d/i })
- .first()
- .waitFor({ state: 'visible', timeout: 15000 })
- .catch(() => {})
-
- // Activity cards have time range filter buttons: 24h, 48h, 7d
+ test('should switch time ranges on activity cards', async ({ page }) => {
const timeRangeButtons = page.locator('button').filter({ hasText: /24h|48h|7d/i })
- const buttonCount = await timeRangeButtons.count()
-
- test.skip(buttonCount === 0, 'No time range buttons found on dashboard')
-
- // Verify all expected time ranges are present
- for (const range of ['24h', '48h', '7d']) {
- const rangeButton = page.locator('button').filter({ hasText: new RegExp(range, 'i') })
- await expect(rangeButton.first()).toBeVisible()
- }
+ await expect(timeRangeButtons.first()).toBeVisible()
- // Click each time range button and verify the page remains stable
for (const range of ['24h', '48h', '7d']) {
const rangeButton = page
.locator('button')
@@ -140,121 +61,61 @@ test.describe('Admin Dashboard Tests - Requires Admin Role', () => {
}
})
- test('should have responsive admin layout', async ({ page }) => {
- // Test desktop view
- await page.setViewportSize({ width: 1280, height: 800 })
-
- // Wait for dashboard content to load
- await page
- .locator('[data-testid="admin-nav"]')
- .waitFor({ state: 'visible', timeout: 15000 })
- .catch(() => {})
-
- // QuickNavigation should be visible on desktop
+ test('should have functional quick action nav links', async ({ page }) => {
const quickNav = page.locator('[data-testid="admin-nav"]')
- await expect(quickNav).toBeVisible({ timeout: 5000 })
-
- // Dashboard main content must be visible
- await expect(page.locator('main').first()).toBeVisible()
-
- // Test mobile view
- await page.setViewportSize({ width: 375, height: 667 })
-
- // QuickNavigation should still be accessible on mobile
await expect(quickNav).toBeVisible()
- // Check if the QuickNavigation toggle button is present
- const toggleButton = quickNav.locator('button').first()
- if (await toggleButton.isVisible()) {
- // Toggle the navigation
- await toggleButton.click()
- await page.waitForLoadState('domcontentloaded')
- }
-
- // Navigation items should be accessible when expanded
const navLinks = quickNav.locator('a')
const linkCount = await navLinks.count()
-
- // If no links visible, try expanding the navigation first
- if (linkCount === 0) {
- const expandButton = quickNav.locator('button').first()
- if (await expandButton.isVisible()) {
- await expandButton.click()
- await page.waitForLoadState('domcontentloaded')
- }
- // Check again after expanding
- const expandedLinks = quickNav.locator('a')
- expect(await expandedLinks.count()).toBeGreaterThan(0)
- } else {
- expect(linkCount).toBeGreaterThan(0)
- }
+ expect(linkCount).toBeGreaterThanOrEqual(2)
})
- test('should have working admin search functionality', async ({ page }) => {
- // The admin dashboard doesn't have global search
- // Instead, verify that navigation links work to access different admin sections
+ test('should navigate to games admin page via nav link', async ({ page }) => {
const quickNav = page.locator('[data-testid="admin-nav"]')
- await expect(quickNav).toBeVisible()
-
- // Find and click a navigation link (e.g., Games)
const gamesLink = quickNav.locator('a').filter({ hasText: /games/i }).first()
await expect(gamesLink).toBeVisible()
+
await gamesLink.click()
+ await expect(page).toHaveURL(/\/admin\/games/)
+ })
- // Should navigate to games admin page
- await expect(page).toHaveURL(/\/admin\/games/, { timeout: 5000 })
+ test('should display responsive layout at mobile viewport', async ({ page }) => {
+ await page.setViewportSize({ width: 375, height: 667 })
- // Navigate back to dashboard
- await page.goto('/admin', { waitUntil: 'domcontentloaded' })
+ const quickNav = page.locator('[data-testid="admin-nav"]')
+ await expect(quickNav).toBeVisible()
+ await expect(page.locator('main').first()).toBeVisible()
})
- test('should have logout functionality', async ({ page }) => {
- // User menu button should be present for authenticated users
+ test('should show user menu button', async ({ page }) => {
const userMenuButton = page.getByRole('button', { name: /open user menu/i })
- await expect(userMenuButton).toBeVisible({ timeout: 5000 })
+ await expect(userMenuButton).toBeVisible()
})
})
-test.describe('Admin Dashboard Data Visualizations - Requires Admin Role', () => {
- test.use({ storageState: 'tests/.auth/admin.json' })
+test.describe('Admin Dashboard Data Visualizations', () => {
+ test.use({ storageState: 'tests/.auth/super_admin.json' })
+
test.beforeEach(async ({ page }) => {
await page.goto('/admin', { waitUntil: 'domcontentloaded' })
await expect(page).toHaveURL(/\/admin/)
-
- // Wait for dashboard content to render
- await page
- .locator('[data-testid="admin-nav"]')
- .or(page.locator('nav').filter({ hasText: /systems|games/i }))
- .first()
- .waitFor({ state: 'visible', timeout: 15000 })
+ await page.locator('[data-testid="admin-nav"]').waitFor({ state: 'visible', timeout: 15000 })
})
- test('should display interactive data charts', async ({ page }) => {
- // Wait for numeric data to appear
- await page
- .locator('text=/\\d+/')
- .first()
- .waitFor({ state: 'visible', timeout: 15000 })
- .catch(() => {})
+ test('should display numeric data on dashboard', async ({ page }) => {
+ const numbers = page.locator('text=/\\d+/')
+ await expect(numbers.first()).toBeVisible()
- // Dashboard should have numeric data indicators (stats, counts, etc.)
- const numbersOnPage = await page.locator('text=/\\d+/').count()
- expect(numbersOnPage).toBeGreaterThan(0)
+ const count = await numbers.count()
+ expect(count).toBeGreaterThan(0)
})
- test('should support data refresh functionality', async ({ page }) => {
- // ActivityCard refresh button uses aria-label="Refresh"
- const refreshButton = page.locator('button[aria-label="Refresh"]')
- const hasRefresh = await refreshButton
- .first()
- .isVisible({ timeout: 5000 })
- .catch(() => false)
- test.skip(!hasRefresh, 'No refresh button available on dashboard')
+ test('should have a refresh button on activity cards', async ({ page }) => {
+ const refreshButton = page.locator('button[aria-label="Refresh"]').first()
+ await expect(refreshButton).toBeVisible()
- await refreshButton.first().click()
+ await refreshButton.click()
await page.waitForLoadState('domcontentloaded')
-
- // Dashboard should remain functional after refresh
await expect(page.locator('main').first()).toBeVisible()
})
})
diff --git a/tests/admin-permissions.spec.ts b/tests/admin-permissions.spec.ts
index 7a74b8ec..e7eab6ac 100644
--- a/tests/admin-permissions.spec.ts
+++ b/tests/admin-permissions.spec.ts
@@ -1,126 +1,85 @@
import { test, expect } from '@playwright/test'
test.describe('Admin Permissions Tests - Requires Admin Role', () => {
- test.use({ storageState: 'tests/.auth/admin.json' })
+ test.use({ storageState: 'tests/.auth/super_admin.json' })
test.beforeEach(async ({ page }) => {
await page.goto('/admin/permissions', { waitUntil: 'domcontentloaded' })
await expect(page).toHaveURL(/\/admin\/permissions/)
+
+ await expect(page.locator('table').first()).toBeVisible()
})
test('should display permissions management page with table', async ({ page }) => {
- const mainContent = page.locator('main').first()
- await expect(mainContent).toBeVisible()
+ const table = page.locator('table').first()
+ await expect(table).toBeVisible()
- // Wait for page content to finish loading
- await page
- .getByText(/loading/i)
- .waitFor({ state: 'hidden', timeout: 15000 })
- .catch(() => {})
+ const headers = table.locator('thead th')
+ const headerTexts = await headers.allTextContents()
+ const headerString = headerTexts.join(' ').toLowerCase()
- const table = page.locator('table').first()
- const hasTable = await table.isVisible({ timeout: 10000 }).catch(() => false)
-
- if (hasTable) {
- const headers = table.locator('thead th')
- const headerTexts = await headers.allTextContents()
- const headerString = headerTexts.join(' ').toLowerCase()
-
- expect(headerString).toMatch(/label|permission|key/i)
-
- const rows = table.locator('tbody tr')
- const rowCount = await rows.count()
- if (rowCount > 0) {
- const firstRow = rows.first()
- await expect(firstRow).toBeVisible()
- }
- } else {
- // Wait for meaningful content to load before checking text
- await page
- .waitForFunction(
- (el) => el !== null && (el.textContent ?? '').trim().length > 10,
- await mainContent.elementHandle(),
- { timeout: 10000 },
- )
- .catch(() => {})
- const pageText = await mainContent.textContent()
- expect(pageText).toMatch(/permission/i)
- }
+ expect(headerString).toMatch(/label|permission|key/i)
+
+ const rows = table.locator('tbody tr')
+ const rowCount = await rows.count()
+ expect(rowCount).toBeGreaterThan(0)
+
+ await expect(rows.first()).toBeVisible()
})
test('should edit role permissions', async ({ page }) => {
- // EditButton uses title="Edit Permission"
const editButtons = page.locator('button[title="Edit Permission"]')
- const hasEdit = (await editButtons.count()) > 0
- test.skip(!hasEdit, 'No edit permission buttons available')
+ await expect(editButtons.first()).toBeVisible()
+ expect(await editButtons.count()).toBeGreaterThan(0)
await editButtons.first().click()
const permissionsModal = page.locator('[role="dialog"]')
- const hasModal = await permissionsModal.isVisible({ timeout: 3000 }).catch(() => false)
- test.skip(!hasModal, 'Permissions editor dialog did not appear')
+ await expect(permissionsModal).toBeVisible()
- // Modal should have form fields (key, label, description, category)
const formFields = permissionsModal.locator('input, textarea, select')
const fieldCount = await formFields.count()
expect(fieldCount).toBeGreaterThan(0)
- // Close the modal
const cancelButton = permissionsModal.locator('button').filter({ hasText: /cancel/i })
- if (await cancelButton.isVisible({ timeout: 1000 }).catch(() => false)) {
- await cancelButton.click()
- } else {
- await page.keyboard.press('Escape')
- }
+ await expect(cancelButton).toBeVisible()
+ await cancelButton.click()
})
test('should manage individual permissions', async ({ page }) => {
- // The permissions table IS the permissions list
const table = page.locator('table').first()
- const hasTable = await table.isVisible({ timeout: 5000 }).catch(() => false)
- test.skip(!hasTable, 'No permissions table visible')
+ await expect(table).toBeVisible()
const rows = table.locator('tbody tr')
const rowCount = await rows.count()
- test.skip(rowCount === 0, 'No permission rows found')
+ expect(rowCount).toBeGreaterThan(0)
const firstRow = rows.first()
await expect(firstRow).toBeVisible()
- // Each row should have cells with permission data
const cells = firstRow.locator('td')
const cellCount = await cells.count()
expect(cellCount).toBeGreaterThan(0)
- // First cell should contain the permission key or label
const firstCell = cells.first()
const cellText = await firstCell.textContent()
expect(cellText?.trim().length).toBeGreaterThan(0)
})
test('should create custom permissions', async ({ page }) => {
- // "Add Permission" button at top of page
const addButton = page.locator('button').filter({ hasText: /add permission/i })
- const hasAdd = await addButton.isVisible({ timeout: 3000 }).catch(() => false)
- test.skip(!hasAdd, 'No Add Permission button available')
+ await expect(addButton).toBeVisible()
await addButton.click()
- // Dialog opens for creating a new permission
const dialog = page.locator('[role="dialog"]')
- const hasDialog = await dialog.isVisible({ timeout: 3000 }).catch(() => false)
- test.skip(!hasDialog, 'Permission creation dialog did not appear')
+ await expect(dialog).toBeVisible()
- // Verify the dialog has input fields (key, label, description, category)
const formFields = dialog.locator('input, textarea, select')
const fieldCount = await formFields.count()
expect(fieldCount).toBeGreaterThan(0)
- // Close the dialog without creating
const cancelButton = dialog.locator('button').filter({ hasText: /cancel/i })
- if (await cancelButton.isVisible({ timeout: 1000 }).catch(() => false)) {
- await cancelButton.click()
- } else {
- await page.keyboard.press('Escape')
- }
+ await expect(cancelButton).toBeVisible()
+ await cancelButton.click()
})
})
diff --git a/tests/admin-reports.spec.ts b/tests/admin-reports.spec.ts
index 184c27ba..bcf75ba6 100644
--- a/tests/admin-reports.spec.ts
+++ b/tests/admin-reports.spec.ts
@@ -1,182 +1,111 @@
import { test, expect } from '@playwright/test'
test.describe('Admin Reports Management Tests - Requires Admin Role', () => {
- test.use({ storageState: 'tests/.auth/admin.json' })
+ test.use({ storageState: 'tests/.auth/super_admin.json' })
test.beforeEach(async ({ page }) => {
await page.goto('/admin/reports', { waitUntil: 'domcontentloaded' })
await expect(page).toHaveURL(/\/admin\/reports/)
+
+ await expect(page.locator('table').first()).toBeVisible()
})
test('should display reports dashboard with required elements', async ({ page }) => {
- const mainContent = page.locator('main').first()
- await expect(mainContent).toBeVisible()
+ const reportsTable = page.locator('table').first()
+ await expect(reportsTable).toBeVisible()
- // Wait for page content to finish loading
- await page
- .getByText(/loading/i)
- .waitFor({ state: 'hidden', timeout: 15000 })
- .catch(() => {})
+ const headers = reportsTable.locator('thead th')
+ const headerTexts = await headers.allTextContents()
+ const headerString = headerTexts.join(' ').toLowerCase()
- const reportsTable = page.locator('table').first()
- const hasTable = await reportsTable.isVisible({ timeout: 10000 }).catch(() => false)
-
- if (hasTable) {
- const headers = reportsTable.locator('thead th')
- const headerTexts = await headers.allTextContents()
- const headerString = headerTexts.join(' ').toLowerCase()
-
- expect(headerString).toMatch(/reason/i)
- expect(headerString).toMatch(/status/i)
-
- const reportRows = reportsTable.locator('tbody tr')
- const rowCount = await reportRows.count()
- expect(typeof rowCount).toBe('number')
- } else {
- // Wait for meaningful content to load before checking text
- await page
- .waitForFunction(
- (el) => el !== null && (el.textContent ?? '').trim().length > 10,
- await mainContent.elementHandle(),
- { timeout: 10000 },
- )
- .catch(() => {})
- const pageText = await mainContent.textContent()
- expect(pageText).toMatch(/report/i)
- }
+ expect(headerString).toMatch(/reason/i)
+ expect(headerString).toMatch(/status/i)
+
+ const reportRows = reportsTable.locator('tbody tr')
+ const rowCount = await reportRows.count()
+ expect(rowCount).toBeGreaterThanOrEqual(0)
})
test('should filter reports by status', async ({ page }) => {
- // Reports page uses a