From 55a00f509d2f589c3abb0c6469263701447673f2 Mon Sep 17 00:00:00 2001 From: Producdevity Date: Thu, 4 Jun 2026 14:44:05 +0200 Subject: [PATCH 01/87] fix: use shared button in GPU view modal --- src/app/admin/gpus/components/GpuViewModal.tsx | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/src/app/admin/gpus/components/GpuViewModal.tsx b/src/app/admin/gpus/components/GpuViewModal.tsx index d676ebab0..941e088b2 100644 --- a/src/app/admin/gpus/components/GpuViewModal.tsx +++ b/src/app/admin/gpus/components/GpuViewModal.tsx @@ -1,6 +1,6 @@ 'use client' -import { Modal, InputPlaceholder } from '@/components/ui' +import { Button, Modal, InputPlaceholder } from '@/components/ui' import { type RouterOutput } from '@/types/trpc' type GpuData = RouterOutput['gpus']['get']['gpus'][number] @@ -31,14 +31,9 @@ function GpuViewModal(props: Props) {
- {/* TODO: Use the Button component? */} - +
From f3559e6c860977343424ebe56b0e2d955851b7fb Mon Sep 17 00:00:00 2001 From: Producdevity Date: Thu, 4 Jun 2026 14:44:07 +0200 Subject: [PATCH 02/87] feat: add comment submit shortcut --- .../comments/GenericCommentForm.test.tsx | 96 +++++++++++++++++++ .../comments/GenericCommentForm.tsx | 15 ++- src/components/ui/form/MarkdownEditor.tsx | 3 + 3 files changed, 111 insertions(+), 3 deletions(-) create mode 100644 src/components/comments/GenericCommentForm.test.tsx diff --git a/src/components/comments/GenericCommentForm.test.tsx b/src/components/comments/GenericCommentForm.test.tsx new file mode 100644 index 000000000..dd04bc704 --- /dev/null +++ b/src/components/comments/GenericCommentForm.test.tsx @@ -0,0 +1,96 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { GenericCommentForm } from './GenericCommentForm' +import type { ReactNode } from 'react' + +const testMocks = vi.hoisted(() => ({ + useUser: vi.fn(() => ({ user: { id: 'user-1' } })), + submitWithHumanVerification: vi.fn( + async (callback: (humanVerificationToken: string) => Promise) => { + await callback('verification-token') + }, + ), + toastError: vi.fn(), + toastSuccess: vi.fn(), +})) + +vi.mock('@clerk/nextjs', () => ({ + useUser: testMocks.useUser, + SignInButton: (props: { children: ReactNode }) => props.children, +})) + +vi.mock('@/features/human-verification/client', () => ({ + useSubmitWithHumanVerification: () => testMocks.submitWithHumanVerification, +})) + +vi.mock('@/lib/toast', () => ({ + default: { + error: testMocks.toastError, + success: testMocks.toastSuccess, + }, +})) + +vi.mock('@/lib/dynamic-imports', async () => { + const actual = await vi.importActual<{ MarkdownEditor: unknown }>( + '@/components/ui/form/MarkdownEditor', + ) + + return { MarkdownEditor: actual.MarkdownEditor } +}) + +describe('GenericCommentForm', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it.each([ + ['Cmd+Enter', { metaKey: true }], + ['Ctrl+Enter', { ctrlKey: true }], + ])('submits comments with %s', async (_shortcut, keyModifiers) => { + const user = userEvent.setup() + const onSubmit = vi.fn().mockResolvedValue(undefined) + + render( + , + ) + + const editor = screen.getByRole('textbox') + await user.type(editor, 'Runs well') + + fireEvent.keyDown(editor, { key: 'Enter', ...keyModifiers }) + + await waitFor(() => { + expect(onSubmit).toHaveBeenCalledWith( + expect.objectContaining({ + content: 'Runs well', + humanVerificationToken: 'verification-token', + }), + ) + }) + }) + + it('does not submit when Enter is pressed without a modifier key', async () => { + const user = userEvent.setup() + const onSubmit = vi.fn().mockResolvedValue(undefined) + + render( + , + ) + + const editor = screen.getByRole('textbox') + await user.type(editor, 'Runs well') + + fireEvent.keyDown(editor, { key: 'Enter' }) + + expect(onSubmit).not.toHaveBeenCalled() + }) +}) diff --git a/src/components/comments/GenericCommentForm.tsx b/src/components/comments/GenericCommentForm.tsx index 337519ff8..2be199f83 100644 --- a/src/components/comments/GenericCommentForm.tsx +++ b/src/components/comments/GenericCommentForm.tsx @@ -2,7 +2,7 @@ import { useUser, SignInButton } from '@clerk/nextjs' import { Send, X } from 'lucide-react' -import { useState, type FormEvent } from 'react' +import { useState, type FormEvent, type KeyboardEvent } from 'react' import { Button } from '@/components/ui' import { useSubmitWithHumanVerification } from '@/features/human-verification/client' import { MarkdownEditor } from '@/lib/dynamic-imports' @@ -104,6 +104,15 @@ export function GenericCommentForm(props: GenericCommentFormProps) { props.onCancel?.() } + const handleEditorKeyDown = (ev: KeyboardEvent) => { + if (ev.key !== 'Enter' || (!ev.metaKey && !ev.ctrlKey)) return + + ev.preventDefault() + if (isLoading || !content.trim() || content.length > maxLength) return + + ev.currentTarget.form?.requestSubmit() + } + if (!user && props.config.showSignInPrompt !== false) { return (
@@ -146,6 +155,7 @@ export function GenericCommentForm(props: GenericCommentFormProps) { maxLength={maxLength} disabled={isLoading} className={cn(isReply && 'text-sm')} + onKeyDown={handleEditorKeyDown} />
@@ -163,7 +173,6 @@ export function GenericCommentForm(props: GenericCommentFormProps) { )} - {/*TODO: allow Cmd+Enter or Ctrl+Enter to submit*/} )} - {/*TODO: allow Cmd+Enter or Ctrl+Enter to submit*/} - - - Sign the Petition - -
-
- - - - ) -} diff --git a/src/data/storageKeys.ts b/src/data/storageKeys.ts index ccc5ee701..3dc7325eb 100644 --- a/src/data/storageKeys.ts +++ b/src/data/storageKeys.ts @@ -9,7 +9,6 @@ const storageKeys = { lastUsedDevice: `${PREFIX}new_listing_last_used_device`, }, popups: { - stopKillingGamesDismissed: `${PREFIX}stop_killing_games_dismissed`, voteReminderDismissed: `${PREFIX}vote_reminder_dismissed`, betaWarningDismissed: `${PREFIX}beta_warning_dismissed_v2`, supportBannerDismissed: `${PREFIX}support_banner_dismissed`, diff --git a/src/lib/analytics/actions.ts b/src/lib/analytics/actions.ts index 2f1a74870..c13337fa1 100644 --- a/src/lib/analytics/actions.ts +++ b/src/lib/analytics/actions.ts @@ -51,12 +51,10 @@ export const ENGAGEMENT_ACTIONS = { COMMENT_VOTE_UP: 'comment_vote_up', GAME_VIEW: 'game_view', LISTING_VIEW: 'listing_view', - STOP_KILLING_GAMES_CTA: 'stop_killing_games_cta', USER_PROFILE_VIEW: 'user_profile_view', VOTE_DOWN: 'vote_down', VOTE_REMINDER_CLICKED: 'vote_reminder_clicked', VOTE_REMINDER_DISMISSED: 'vote_reminder_dismissed', - STOP_KILLING_GAMES_DISMISSED: 'stop_killing_games_dismissed', SUPPORT_BANNER_SHOWN: 'support_banner_shown', SUPPORT_BANNER_DISMISSED: 'support_banner_dismissed', SUPPORT_BANNER_CTA: 'support_banner_cta', diff --git a/src/lib/analytics/analytics.ts b/src/lib/analytics/analytics.ts index 79482c3b1..9c44e30ff 100644 --- a/src/lib/analytics/analytics.ts +++ b/src/lib/analytics/analytics.ts @@ -385,24 +385,6 @@ const analytics = { }) }, - stopKillingGamesDismissed: (params: { timeOnPage: number }) => { - sendAnalyticsEvent({ - category: ANALYTICS_CATEGORIES.ENGAGEMENT, - action: ENGAGEMENT_ACTIONS.STOP_KILLING_GAMES_DISMISSED, - entityType: 'popup', - metadata: { timeOnPage: params.timeOnPage }, - }) - }, - - stopKillingGamesCTA: (params: { timeOnPage: number }) => { - sendAnalyticsEvent({ - category: ANALYTICS_CATEGORIES.ENGAGEMENT, - action: ENGAGEMENT_ACTIONS.STOP_KILLING_GAMES_CTA, - entityType: 'popup', - metadata: { timeOnPage: params.timeOnPage }, - }) - }, - supportBannerShown: (params: { variant: string; page: string }) => { sendAnalyticsEvent({ category: ANALYTICS_CATEGORIES.ENGAGEMENT, From c418791f7b388b29523b6ac6fa2a37b463192df7 Mon Sep 17 00:00:00 2001 From: Producdevity Date: Thu, 4 Jun 2026 16:35:33 +0200 Subject: [PATCH 12/87] chore: remove unused SEO verification tags --- src/lib/seo/metadata.ts | 8 -------- 1 file changed, 8 deletions(-) diff --git a/src/lib/seo/metadata.ts b/src/lib/seo/metadata.ts index 7960c4ba8..8029050e7 100644 --- a/src/lib/seo/metadata.ts +++ b/src/lib/seo/metadata.ts @@ -79,14 +79,6 @@ export const defaultMetadata: Metadata = { }, other: { 'theme-color': '#111828', - 'google-site-verification': process.env.NEXT_PUBLIC_GOOGLE_SITE_VERIFICATION || '', // TODO: add if we start caring - 'msvalidate.01': process.env.NEXT_PUBLIC_BING_SITE_VERIFICATION || '', // TODO: add if we start caring - 'yandex-verification': process.env.NEXT_PUBLIC_YANDEX_VERIFICATION || '', // TODO: add if we start caring - 'fb:app_id': process.env.NEXT_PUBLIC_FACEBOOK_APP_ID || '', // TODO: add if we start caring - }, - verification: { - google: process.env.NEXT_PUBLIC_GOOGLE_SITE_VERIFICATION, // TODO: add if we start caring - yandex: process.env.NEXT_PUBLIC_YANDEX_VERIFICATION, // TODO: add if we start caring }, appleWebApp: { capable: true, From b1b6c81ec3343f004a068f789de0e2e1cfb3455c Mon Sep 17 00:00:00 2001 From: Producdevity Date: Thu, 4 Jun 2026 16:35:50 +0200 Subject: [PATCH 13/87] refactor: standardize admin page layouts --- src/app/admin/brands/page.tsx | 30 ++++++++++++++--------------- src/app/admin/cpus/page.tsx | 24 ++++++++++------------- src/app/admin/socs/page.tsx | 26 ++++++++++--------------- src/app/admin/trust-logs/page.tsx | 32 +++++++++++++++---------------- 4 files changed, 50 insertions(+), 62 deletions(-) diff --git a/src/app/admin/brands/page.tsx b/src/app/admin/brands/page.tsx index ee3428ecc..c54e0154f 100644 --- a/src/app/admin/brands/page.tsx +++ b/src/app/admin/brands/page.tsx @@ -3,7 +3,12 @@ import { useState } from 'react' import { isEmpty } from 'remeda' import { useAdminTable } from '@/app/admin/hooks' -import { AdminTableContainer, AdminSearchFilters, AdminStatsDisplay } from '@/components/admin' +import { + AdminPageLayout, + AdminTableContainer, + AdminSearchFilters, + AdminStatsDisplay, +} from '@/components/admin' import { Button, ColumnVisibilityControl, @@ -101,22 +106,17 @@ function AdminBrandsPage() { } } - // TODO: use AdminPageLayout like all the other admin pages return ( -
-
-
-

Device Brands

-

- Manage all device brands in the system -

-
-
+ {canManageDevices && } -
-
- + + } + > {brandsStatsQuery.data && ( -
+ ) } export default AdminBrandsPage diff --git a/src/app/admin/cpus/page.tsx b/src/app/admin/cpus/page.tsx index 2c5b7f61c..cc5a5f9d4 100644 --- a/src/app/admin/cpus/page.tsx +++ b/src/app/admin/cpus/page.tsx @@ -5,6 +5,7 @@ import { useState } from 'react' import { isEmpty } from 'remeda' import { useAdminTable } from '@/app/admin/hooks' import { + AdminPageLayout, AdminTableContainer, AdminSearchFilters, AdminStatsDisplay, @@ -135,22 +136,17 @@ function AdminCpusPage() { } } - // TODO: use AdminPageLayout like all the other admin pages return ( -
-
-
-

CPUs

-

- Manage all CPU models for PC compatibility listings -

-
-
+ {canManageDevices && } -
-
- + + } + > -
+ ) } diff --git a/src/app/admin/socs/page.tsx b/src/app/admin/socs/page.tsx index 20a72b5a1..70f56a8e7 100644 --- a/src/app/admin/socs/page.tsx +++ b/src/app/admin/socs/page.tsx @@ -5,6 +5,7 @@ import { useState } from 'react' import { isEmpty } from 'remeda' import { useAdminTable } from '@/app/admin/hooks' import { + AdminPageLayout, AdminTableContainer, AdminSearchFilters, AdminStatsDisplay, @@ -127,24 +128,17 @@ function AdminSoCsPage() { } } - // TODO: use AdminPageLayout like all the other admin pages return ( -
-
-
-

- System on Chips (SoCs) -

-

- Manage all processors and system on chips -

-
-
+ {canManageDevices && } -
-
- + + } + > -
+ ) } diff --git a/src/app/admin/trust-logs/page.tsx b/src/app/admin/trust-logs/page.tsx index 58ea927ff..383b8257c 100644 --- a/src/app/admin/trust-logs/page.tsx +++ b/src/app/admin/trust-logs/page.tsx @@ -5,7 +5,7 @@ import Link from 'next/link' import { useState } from 'react' import { isEmpty } from 'remeda' import { useAdminTable } from '@/app/admin/hooks' -import { AdminTableContainer, AdminTableNoResults } from '@/components/admin' +import { AdminPageLayout, AdminTableContainer, AdminTableNoResults } from '@/components/admin' import { Button, Input, @@ -87,7 +87,10 @@ function AdminTrustLogsPage() { if (trustLogsQuery.error) { return ( -
+

Error loading trust logs: {trustLogsQuery.error.message} @@ -96,7 +99,7 @@ function AdminTrustLogsPage() { Try Again

-
+ ) } @@ -111,17 +114,12 @@ function AdminTrustLogsPage() { : '-' } - // TODO: use AdminPageLayout like all the other admin pages return ( -
-
-
-

Trust System Logs

-

- Monitor and audit all trust score changes -

-
-
+
-
- + + } + > {/*TODO: check if we can use AdminStatsDisplay */} {trustStatsQuery.data && } @@ -314,7 +312,7 @@ function AdminTrustLogsPage() { onPageChange={(newPage) => table.setPage(newPage)} /> )} -
+ ) } From 8c8d8b94c653009208b881c9f4ecd0580aef9bf6 Mon Sep 17 00:00:00 2001 From: Producdevity Date: Thu, 4 Jun 2026 16:43:28 +0200 Subject: [PATCH 14/87] test: satisfy Prisma client constructor type --- src/server/repositories/socs.repository.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/server/repositories/socs.repository.test.ts b/src/server/repositories/socs.repository.test.ts index 0367691ae..08e652acf 100644 --- a/src/server/repositories/socs.repository.test.ts +++ b/src/server/repositories/socs.repository.test.ts @@ -1,3 +1,4 @@ +import { PrismaPg } from '@prisma/adapter-pg' import { beforeEach, describe, expect, it, vi } from 'vitest' import { PrismaClient } from '@orm/client' import { SoCsRepository } from './socs.repository' @@ -35,7 +36,9 @@ const mockSoc = { } function createMockPrisma() { - const prisma = new PrismaClient() + const prisma = new PrismaClient({ + adapter: new PrismaPg({ connectionString: 'postgresql://test:test@localhost:5432/test' }), + }) vi.mocked(prisma.soC.count).mockResolvedValue(42) vi.mocked(prisma.soC.findMany).mockResolvedValue([mockSoc] as never) return prisma From a2c508abe0ea93d26693c9f84ae8a3876acfa66b Mon Sep 17 00:00:00 2001 From: Producdevity Date: Thu, 4 Jun 2026 16:54:00 +0200 Subject: [PATCH 15/87] chore: remove unused beta warning popup --- src/components/popups/BetaWarningPopup.tsx | 112 --------------------- src/components/popups/index.ts | 1 - src/data/storageKeys.ts | 1 - 3 files changed, 114 deletions(-) delete mode 100644 src/components/popups/BetaWarningPopup.tsx delete mode 100644 src/components/popups/index.ts diff --git a/src/components/popups/BetaWarningPopup.tsx b/src/components/popups/BetaWarningPopup.tsx deleted file mode 100644 index 8b1721d84..000000000 --- a/src/components/popups/BetaWarningPopup.tsx +++ /dev/null @@ -1,112 +0,0 @@ -'use client' - -import { AlertTriangle, X } from 'lucide-react' -import { useState, useEffect } from 'react' -import { Modal } from '@/components/ui' -import storageKeys from '@/data/storageKeys' -import { env } from '@/lib/env' - -const discordUrl = process.env.NEXT_PUBLIC_DISCORD_LINK - -export function BetaWarningPopup() { - const [isOpen, setIsOpen] = useState(false) - - useEffect(() => { - if (!env.IS_PUBLIC_PRODUCTION) return - - // Don't show on admin pages - if (window.location.pathname.startsWith('/admin')) return - - const hasBeenDismissed = localStorage.getItem(storageKeys.popups.betaWarningDismissed) - - if (hasBeenDismissed) return - - // Small delay to ensure page has loaded - const timer = setTimeout(() => { - setIsOpen(true) - }, 1000) - - return () => clearTimeout(timer) - }, []) - - function handleDismiss() { - localStorage.setItem(storageKeys.popups.betaWarningDismissed, 'true') - setIsOpen(false) - } - - if (!env.IS_PUBLIC_PRODUCTION || !isOpen) return null - - return ( - -
-
-
- -
- -
-

- Welcome to EmuReady Beta! -

- -
-

- Thanks for checking out EmuReady! We're excited to have you here, but please - note that - - {' '} - we're still in beta testing - - . -

- -

What this means:

- -
    -
  • You may encounter bugs or unexpected behavior
  • -
  • Features may change or be temporarily unavailable
  • -
- -

- If you find any issues, please report them on our{' '} - - Discord - {' '} - or{' '} - - GitHub - - . - - Feel free to explore, create an account, and get familiar with the platform. - -

-
- -
- -
-
-
-
-
- ) -} diff --git a/src/components/popups/index.ts b/src/components/popups/index.ts deleted file mode 100644 index 427f0f8f5..000000000 --- a/src/components/popups/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './BetaWarningPopup' diff --git a/src/data/storageKeys.ts b/src/data/storageKeys.ts index 3dc7325eb..cd980e2c7 100644 --- a/src/data/storageKeys.ts +++ b/src/data/storageKeys.ts @@ -10,7 +10,6 @@ const storageKeys = { }, popups: { voteReminderDismissed: `${PREFIX}vote_reminder_dismissed`, - betaWarningDismissed: `${PREFIX}beta_warning_dismissed_v2`, supportBannerDismissed: `${PREFIX}support_banner_dismissed`, }, cookies: { From 36260ab2192fa7903ac3644faaaceef941da9550 Mon Sep 17 00:00:00 2001 From: Producdevity Date: Thu, 4 Jun 2026 17:13:06 +0200 Subject: [PATCH 16/87] fix: constrain listing cover layout --- .../[id]/components/ListingDetailsClient.tsx | 10 ++-- .../shared/components/GameImage.test.tsx | 54 +++++++++++++++++++ .../listings/shared/components/GameImage.tsx | 10 +++- .../components/PcListingDetailsClient.tsx | 10 ++-- 4 files changed, 72 insertions(+), 12 deletions(-) create mode 100644 src/app/listings/shared/components/GameImage.test.tsx diff --git a/src/app/listings/[id]/components/ListingDetailsClient.tsx b/src/app/listings/[id]/components/ListingDetailsClient.tsx index 4abf7f117..23ebcef9b 100644 --- a/src/app/listings/[id]/components/ListingDetailsClient.tsx +++ b/src/app/listings/[id]/components/ListingDetailsClient.tsx @@ -95,15 +95,15 @@ function ListingDetailsClient(props: Props) { -
+
{/* Game Info */} -
+
{/* Game Image */} -
+
-
+
({ + default: ( + props: ImgHTMLAttributes & { + fill?: boolean + priority?: boolean + unoptimized?: boolean + }, + ) => { + const { fill: _fill, priority: _priority, unoptimized: _unoptimized, ...imgProps } = props + return {String(imgProps.alt + }, +})) + +const game = { + id: 'game-1', + title: 'Wide Cover Game', + boxartUrl: 'https://example.com/large-cover.jpg', +} + +describe('GameImage', () => { + it('keeps rendered images contained inside their parent flex column', () => { + const { container } = render( + , + ) + + const wrapper = container.firstElementChild + + expect(wrapper).toHaveClass('w-full') + expect(wrapper).toHaveClass('max-w-full') + expect(wrapper).toHaveClass('min-w-0') + expect(wrapper).toHaveClass('overflow-hidden') + expect(wrapper).toHaveClass('aspect-video') + expect(screen.getByAltText(game.title)).toBeInTheDocument() + }) + + it('keeps fallback images contained when no art is available', () => { + const { container } = render( + , + ) + + const wrapper = container.firstElementChild + + expect(wrapper).toHaveClass('w-full') + expect(wrapper).toHaveClass('max-w-full') + expect(wrapper).toHaveClass('min-w-0') + expect(wrapper).toHaveClass('overflow-hidden') + expect(screen.getByText('Missing Art')).toBeInTheDocument() + }) +}) diff --git a/src/app/listings/shared/components/GameImage.tsx b/src/app/listings/shared/components/GameImage.tsx index 539a7a7f2..4d0337fb1 100644 --- a/src/app/listings/shared/components/GameImage.tsx +++ b/src/app/listings/shared/components/GameImage.tsx @@ -44,7 +44,7 @@ export function GameImage(props: Props) { return (
+
{props.game.title} -
+
{/* Game Info */} -
+
{/* Game Image */} -
+
-
+
Date: Thu, 4 Jun 2026 17:13:14 +0200 Subject: [PATCH 17/87] fix: restore custom field template search --- .../custom-field-templates/page.test.tsx | 135 ++++++++++++++++++ src/app/admin/custom-field-templates/page.tsx | 70 ++++++--- 2 files changed, 182 insertions(+), 23 deletions(-) create mode 100644 src/app/admin/custom-field-templates/page.test.tsx diff --git a/src/app/admin/custom-field-templates/page.test.tsx b/src/app/admin/custom-field-templates/page.test.tsx new file mode 100644 index 000000000..c2adfee98 --- /dev/null +++ b/src/app/admin/custom-field-templates/page.test.tsx @@ -0,0 +1,135 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { CustomFieldType } from '@orm' +import type CustomFieldTemplatesPageComponent from './page' + +const apiMocks = vi.hoisted(() => ({ + customFieldTemplatesGetUseQuery: vi.fn(), + refetch: vi.fn(), +})) + +const navigationMocks = vi.hoisted(() => ({ + replace: vi.fn(), + searchParams: new URLSearchParams(), +})) + +vi.mock('next/navigation', () => ({ + useRouter: () => ({ + replace: navigationMocks.replace, + }), + useSearchParams: () => navigationMocks.searchParams, +})) + +vi.mock('@/lib/api', () => ({ + api: { + customFieldTemplates: { + get: { + useQuery: apiMocks.customFieldTemplatesGetUseQuery, + }, + }, + }, +})) + +interface MockTemplate { + id: string + name: string +} + +interface MockCustomFieldTemplateListProps { + templates: MockTemplate[] +} + +vi.mock('./components/CustomFieldTemplateList', () => ({ + default: (props: MockCustomFieldTemplateListProps) => ( +
+ {props.templates.map((template) => ( +
{template.name}
+ ))} +
+ ), +})) + +vi.mock('./components/CustomFieldTemplateFormModal', () => ({ + default: () =>
, +})) + +let CustomFieldTemplatesPage: typeof CustomFieldTemplatesPageComponent + +const templates = [ + { + id: 'template-performance', + name: 'Performance Template', + description: 'Emulator performance settings', + createdAt: new Date('2024-01-01T00:00:00.000Z'), + updatedAt: new Date('2024-01-01T00:00:00.000Z'), + fields: [ + { + id: 'field-frame-pacing', + name: 'framePacing', + label: 'Frame pacing', + type: CustomFieldType.TEXT, + options: null, + isRequired: false, + displayOrder: 0, + }, + ], + }, + { + id: 'template-controls', + name: 'Controls Template', + description: 'Input mapping defaults', + createdAt: new Date('2024-01-02T00:00:00.000Z'), + updatedAt: new Date('2024-01-02T00:00:00.000Z'), + fields: [ + { + id: 'field-layout', + name: 'controllerLayout', + label: 'Controller layout', + type: CustomFieldType.TEXT, + options: null, + isRequired: false, + displayOrder: 0, + }, + ], + }, +] + +describe('CustomFieldTemplatesPage', () => { + beforeAll(async () => { + ;({ default: CustomFieldTemplatesPage } = await import('./page')) + }) + + beforeEach(() => { + vi.clearAllMocks() + navigationMocks.searchParams = new URLSearchParams() + window.history.replaceState(null, '', '/admin/custom-field-templates') + apiMocks.customFieldTemplatesGetUseQuery.mockReturnValue({ + data: templates, + isPending: false, + error: null, + refetch: apiMocks.refetch, + }) + }) + + it('renders AdminSearchFilters and filters templates by field labels', () => { + render() + + fireEvent.change(screen.getByPlaceholderText('Search templates...'), { + target: { value: 'frame' }, + }) + + expect(screen.getByText('Performance Template')).toBeInTheDocument() + expect(screen.queryByText('Controls Template')).not.toBeInTheDocument() + }) + + it('shows a search-specific empty state when no templates match', () => { + render() + + fireEvent.change(screen.getByPlaceholderText('Search templates...'), { + target: { value: 'battery' }, + }) + + expect(screen.getByText('No custom field templates match your search.')).toBeInTheDocument() + expect(screen.queryByTestId('template-list')).not.toBeInTheDocument() + }) +}) diff --git a/src/app/admin/custom-field-templates/page.tsx b/src/app/admin/custom-field-templates/page.tsx index 12d84cb49..d89d491ad 100644 --- a/src/app/admin/custom-field-templates/page.tsx +++ b/src/app/admin/custom-field-templates/page.tsx @@ -1,23 +1,48 @@ 'use client' import { PlusCircle } from 'lucide-react' -import { useState } from 'react' -import { - AdminPageLayout, - // AdminSearchFilters, - AdminStatsDisplay, -} from '@/components/admin' +import { useMemo, useState } from 'react' +import { useAdminTable } from '@/app/admin/hooks' +import { AdminPageLayout, AdminSearchFilters, AdminStatsDisplay } from '@/components/admin' import { Button, LoadingSpinner } from '@/components/ui' import { api } from '@/lib/api' +import { type RouterOutput } from '@/types/trpc' import CustomFieldTemplateFormModal from './components/CustomFieldTemplateFormModal' import CustomFieldTemplateList from './components/CustomFieldTemplateList' +type CustomFieldTemplate = RouterOutput['customFieldTemplates']['get'][number] +type CustomFieldTemplateSortField = 'name' + +const EMPTY_TEMPLATES: CustomFieldTemplate[] = [] + +function customFieldTemplateMatchesSearch(template: CustomFieldTemplate, searchTerm: string) { + if (!searchTerm) return true + + const searchableValues = [ + template.name, + template.description ?? '', + ...template.fields.flatMap((field) => [field.name, field.label]), + ] + + return searchableValues.some((value) => value.toLowerCase().includes(searchTerm)) +} + function CustomFieldTemplatesPage() { - const [searchQuery, _setSearchQuery] = useState('') + const table = useAdminTable() const [isFormModalOpen, setIsFormModalOpen] = useState(false) const [editingTemplateId, setEditingTemplateId] = useState(null) const customFieldTemplatesQuery = api.customFieldTemplates.get.useQuery() + const templates = customFieldTemplatesQuery.data ?? EMPTY_TEMPLATES + const totalTemplates = templates.length + const templatesWithFields = templates.filter((t) => t.fields.length > 0).length + const templatesWithoutFields = totalTemplates - templatesWithFields + const searchTerm = table.search.trim().toLowerCase() + const filteredTemplates = useMemo( + () => templates.filter((template) => customFieldTemplateMatchesSearch(template, searchTerm)), + [templates, searchTerm], + ) + const hasActiveSearch = searchTerm.length > 0 function handleOpenCreateModal() { setEditingTemplateId(null) @@ -53,11 +78,6 @@ function CustomFieldTemplatesPage() { ) } - const templates = customFieldTemplatesQuery.data ?? [] - const totalTemplates = templates.length - const templatesWithFields = templates.filter((t) => t.fields.length > 0).length - const templatesWithoutFields = totalTemplates - templatesWithFields - return ( - {/*TODO: fix this, AdminSearchFilters requires a table property, we need to convert this component to work like the other admin pages*/} - {/* setSearchQuery('')}*/} - {/*/>*/} + + table={table} + searchPlaceholder="Search templates..." + /> - {templates.length > 0 ? ( + {filteredTemplates.length > 0 ? ( - template.name.toLowerCase().includes(searchQuery.trim().toLowerCase()), - )} + templates={filteredTemplates} onEdit={handleOpenEditModal} onDeleteSuccess={customFieldTemplatesQuery.refetch} /> + ) : hasActiveSearch ? ( +
+

+ No custom field templates match your search. +

+

+ Try a different template name, description, or field label. +

+
) : (

From cd4e914451a6119609cb38da7ee59678530925c6 Mon Sep 17 00:00:00 2001 From: Producdevity Date: Thu, 4 Jun 2026 17:13:25 +0200 Subject: [PATCH 18/87] chore: use cache duration constants --- src/app/admin/AdminLayoutClient.tsx | 9 +++---- .../admin/components/ApprovalCountBadge.tsx | 7 +++--- src/app/admin/title-id-tools/TitleIdTool.tsx | 4 ++-- .../games/new/search/hooks/useGameSearch.ts | 4 ++-- .../home/components/HomeTrendingDevices.tsx | 7 +++--- src/app/listings/ListingsPage.tsx | 10 ++++---- .../[id]/components/ViewConfigButton.tsx | 4 ++-- .../filters/AsyncDeviceFilterSelect.tsx | 6 ++--- .../filters/AsyncSocFilterSelect.tsx | 6 ++--- .../custom-fields/hooks/useDriverVersions.ts | 4 ++-- src/app/listings/new/NewListingPage.tsx | 8 +++---- src/app/pc-listings/PcListingsPage.tsx | 6 ++--- .../filters/AsyncCpuFilterSelect.tsx | 6 ++--- .../filters/AsyncGpuFilterSelect.tsx | 6 ++--- src/app/pc-listings/new/NewPcListingPage.tsx | 6 ++--- src/app/profile/components/DeviceSelector.tsx | 6 ++--- src/app/profile/components/PcPresetModal.tsx | 6 ++--- src/app/profile/components/SocSelector.tsx | 6 ++--- src/app/v2/listings/V2ListingsPage.tsx | 10 ++++---- .../retrocatalog/useRetroCatalogDevice.ts | 6 ++--- .../providers/IGDBImageSelector.tsx | 5 ++-- .../providers/RawgImageSelector.tsx | 3 ++- .../providers/TGDBImageSelector.tsx | 3 ++- src/data/constants.ts | 11 ++++++++- src/lib/api.tsx | 6 ++--- src/server/repositories/devices.repository.ts | 5 ++-- src/server/tgdb.ts | 4 ++-- src/server/utils/cache/instances.ts | 24 +++++++++---------- src/server/utils/driver-versions.ts | 8 +++---- src/server/utils/steamGameBatcher.ts | 4 ++-- src/server/utils/steamGameSearch.ts | 6 ++--- src/server/utils/switchGameSearch.ts | 6 ++--- src/server/utils/threeDsGameSearch.ts | 6 ++--- 33 files changed, 115 insertions(+), 103 deletions(-) diff --git a/src/app/admin/AdminLayoutClient.tsx b/src/app/admin/AdminLayoutClient.tsx index 72b044b2e..ad4f2505b 100644 --- a/src/app/admin/AdminLayoutClient.tsx +++ b/src/app/admin/AdminLayoutClient.tsx @@ -8,6 +8,7 @@ import { useEffect, useState, type PropsWithChildren } from 'react' import { isNumber } from 'remeda' import { ADMIN_ROUTES } from '@/app/admin/config/routes' import { LoadingSpinner } from '@/components/ui/LoadingSpinner' +import { CACHE_DURATIONS } from '@/data/constants' import { api } from '@/lib/api' import { cn } from '@/lib/utils' import { hasPermission, PERMISSIONS } from '@/utils/permission-system' @@ -40,7 +41,7 @@ export default function AdminLayoutClient(props: PropsWithChildren) { enabled: !!userQuery.data && hasPermission(userQuery.data.permissions, PERMISSIONS.VIEW_STATISTICS), refetchInterval: 30000, - staleTime: 10000, + staleTime: CACHE_DURATIONS.TEN_SECONDS, refetchOnMount: true, refetchOnWindowFocus: true, }) @@ -49,7 +50,7 @@ export default function AdminLayoutClient(props: PropsWithChildren) { enabled: !!userQuery.data && hasPermission(userQuery.data.permissions, PERMISSIONS.VIEW_STATISTICS), refetchInterval: 30000, - staleTime: 10000, + staleTime: CACHE_DURATIONS.TEN_SECONDS, refetchOnMount: true, refetchOnWindowFocus: true, }) @@ -58,7 +59,7 @@ export default function AdminLayoutClient(props: PropsWithChildren) { enabled: !!userQuery.data && hasPermission(userQuery.data.permissions, PERMISSIONS.VIEW_STATISTICS), refetchInterval: 30000, - staleTime: 10000, + staleTime: CACHE_DURATIONS.TEN_SECONDS, refetchOnMount: true, refetchOnWindowFocus: true, }) @@ -68,7 +69,7 @@ export default function AdminLayoutClient(props: PropsWithChildren) { const reportsStatsQuery = api.listingReports.stats.useQuery(undefined, { enabled: !!userQuery.data && isSuperAdmin, refetchInterval: 30000, - staleTime: 10000, + staleTime: CACHE_DURATIONS.TEN_SECONDS, refetchOnMount: true, refetchOnWindowFocus: true, }) diff --git a/src/app/admin/components/ApprovalCountBadge.tsx b/src/app/admin/components/ApprovalCountBadge.tsx index 050f842ba..6460904b9 100644 --- a/src/app/admin/components/ApprovalCountBadge.tsx +++ b/src/app/admin/components/ApprovalCountBadge.tsx @@ -1,6 +1,7 @@ 'use client' import { Badge } from '@/components/ui' +import { CACHE_DURATIONS } from '@/data/constants' import { api } from '@/lib/api' import { cn } from '@/lib/utils' import { hasPermission, PERMISSIONS } from '@/utils/permission-system' @@ -30,7 +31,7 @@ export default function ApprovalCountBadge(props: Props) { const gameStatsQuery = api.games.stats.useQuery(undefined, { enabled: canViewStats && props.href === '/admin/games/approvals', refetchInterval: 30000, - staleTime: 10000, + staleTime: CACHE_DURATIONS.TEN_SECONDS, refetchOnMount: true, refetchOnWindowFocus: true, }) @@ -38,7 +39,7 @@ export default function ApprovalCountBadge(props: Props) { const listingStatsQuery = api.listings.stats.useQuery(undefined, { enabled: canViewStats && props.href === '/admin/approvals', refetchInterval: 30000, - staleTime: 10000, + staleTime: CACHE_DURATIONS.TEN_SECONDS, refetchOnMount: true, refetchOnWindowFocus: true, }) @@ -46,7 +47,7 @@ export default function ApprovalCountBadge(props: Props) { const pcListingStatsQuery = api.pcListings.stats.useQuery(undefined, { enabled: canViewStats && props.href === '/admin/pc-listing-approvals', refetchInterval: 30000, - staleTime: 10000, + staleTime: CACHE_DURATIONS.TEN_SECONDS, refetchOnMount: true, refetchOnWindowFocus: true, }) diff --git a/src/app/admin/title-id-tools/TitleIdTool.tsx b/src/app/admin/title-id-tools/TitleIdTool.tsx index 177f9c0cc..7724f0289 100644 --- a/src/app/admin/title-id-tools/TitleIdTool.tsx +++ b/src/app/admin/title-id-tools/TitleIdTool.tsx @@ -6,6 +6,7 @@ import { Card } from '@/components/ui/Card' import { Dropdown } from '@/components/ui/Dropdown' import { Input } from '@/components/ui/form/Input' import { LoadingSpinner } from '@/components/ui/LoadingSpinner' +import { CACHE_DURATIONS } from '@/data/constants' import { api } from '@/lib/api' import toast from '@/lib/toast' import { cn } from '@/lib/utils' @@ -15,7 +16,6 @@ import { type TitleIdProviderInfo, } from '@/schemas/titleId' import { formatters, getLocale } from '@/utils/date' -import { ms } from '@/utils/time' import { TitleIdBestMatch } from './components/TitleIdBestMatch' const EMPTY_PROVIDERS: TitleIdProviderInfo[] = [] @@ -49,7 +49,7 @@ function TitleIdTool() { { platformId: selectedProvider?.id ?? providers[0]?.id ?? TITLE_ID_PLATFORM_IDS[0] }, { enabled: statsQueryEnabled && Boolean(selectedProvider?.id), - staleTime: ms.minutes(15), + staleTime: CACHE_DURATIONS.LONG, }, ) diff --git a/src/app/games/new/search/hooks/useGameSearch.ts b/src/app/games/new/search/hooks/useGameSearch.ts index ece80977a..ed2cf2b8f 100644 --- a/src/app/games/new/search/hooks/useGameSearch.ts +++ b/src/app/games/new/search/hooks/useGameSearch.ts @@ -1,7 +1,7 @@ import { useRouter, usePathname } from 'next/navigation' import { useCallback, useMemo } from 'react' +import { CACHE_DURATIONS } from '@/data/constants' import { api } from '@/lib/api' -import { ms } from '@/utils/time' interface UseGameSearchOptions { searchResults: { games: TGame[] } | null @@ -26,7 +26,7 @@ export function useGameSearch( { games: gameNamesAndSystems }, { enabled: gameNamesAndSystems.length > 0, - staleTime: ms.seconds(30), + staleTime: CACHE_DURATIONS.THIRTY_SECONDS, refetchOnWindowFocus: true, }, ) diff --git a/src/app/home/components/HomeTrendingDevices.tsx b/src/app/home/components/HomeTrendingDevices.tsx index f98c67fb8..613720100 100644 --- a/src/app/home/components/HomeTrendingDevices.tsx +++ b/src/app/home/components/HomeTrendingDevices.tsx @@ -5,11 +5,10 @@ import { TrendingUp, ChevronRight, Smartphone, Cpu } from 'lucide-react' import Link from 'next/link' import { useState, useMemo } from 'react' import { RetroCatalogIndicator } from '@/components/retrocatalog' -import { HOME_PAGE_LIMITS } from '@/data/constants' +import { CACHE_DURATIONS, HOME_PAGE_LIMITS } from '@/data/constants' import analytics from '@/lib/analytics' import { api } from '@/lib/api' import { cn } from '@/lib/utils' -import { ms } from '@/utils/time' import { TimeRangeTabs, type TimeRangeId } from './TimeRangeTabs' const TIME_RANGE_LABELS: Record = { @@ -24,8 +23,8 @@ export function HomeTrendingDevices() { limit: HOME_PAGE_LIMITS.TRENDING_DEVICES, }, { - staleTime: ms.hours(6), - gcTime: ms.hours(12), + staleTime: CACHE_DURATIONS.SIX_HOURS, + gcTime: CACHE_DURATIONS.TWELVE_HOURS, }, ) diff --git a/src/app/listings/ListingsPage.tsx b/src/app/listings/ListingsPage.tsx index 46639deac..db9ea2ce5 100644 --- a/src/app/listings/ListingsPage.tsx +++ b/src/app/listings/ListingsPage.tsx @@ -29,6 +29,7 @@ import { SuccessRateBar } from '@/components/ui/SuccessRateBar' import { EditButton, ViewButton } from '@/components/ui/table-buttons' import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/Tooltip' import { VerifiedDeveloperBadge } from '@/components/ui/VerifiedDeveloperBadge' +import { CACHE_DURATIONS } from '@/data/constants' import storageKeys from '@/data/storageKeys' import { useEmulatorLogos, @@ -47,7 +48,6 @@ import { } from '@/utils/navigation-events' import { roleIncludesRole } from '@/utils/permission-system' import { hasRolePermission } from '@/utils/permissions' -import { ms } from '@/utils/time' import { Role, ApprovalStatus } from '@orm' import ListingsFiltersContent from './components/ListingsFiltersContent' import ListingsFiltersSidebar from './components/ListingsFiltersSidebar' @@ -66,8 +66,8 @@ const LISTINGS_COLUMNS: ColumnDefinition[] = [ { key: 'actions', label: 'Actions', alwaysVisible: true }, ] -const LOOKUP_DATA_STALE_TIME = ms.hours(6) -const LOOKUP_DATA_GC_TIME = ms.hours(12) +const LOOKUP_DATA_STALE_TIME = CACHE_DURATIONS.SIX_HOURS +const LOOKUP_DATA_GC_TIME = CACHE_DURATIONS.TWELVE_HOURS const USE_ASYNC_LISTING_FILTERS = process.env.NEXT_PUBLIC_ENABLE_ASYNC_LISTINGS_FILTERS === 'true' function ListingsPage() { @@ -98,8 +98,8 @@ function ListingsPage() { }) const userPreferencesQuery = api.userPreferences.get.useQuery(undefined, { enabled: isSignedIn === true && !!userQuery.data, - staleTime: ms.seconds(30), - gcTime: ms.minutes(5), + staleTime: CACHE_DURATIONS.THIRTY_SECONDS, + gcTime: CACHE_DURATIONS.MEDIUM, }) const userRole = userQuery?.data?.role diff --git a/src/app/listings/[id]/components/ViewConfigButton.tsx b/src/app/listings/[id]/components/ViewConfigButton.tsx index e2167ca2f..9cf5f6a03 100644 --- a/src/app/listings/[id]/components/ViewConfigButton.tsx +++ b/src/app/listings/[id]/components/ViewConfigButton.tsx @@ -3,12 +3,12 @@ import { Settings } from 'lucide-react' import { useState } from 'react' import { Button } from '@/components/ui' +import { CACHE_DURATIONS } from '@/data/constants' import { api } from '@/lib/api' import toast from '@/lib/toast' import { type EmulatorConfigType } from '@/server/utils/emulator-config/constants' import getErrorMessage from '@/utils/getErrorMessage' import { roleIncludesRole } from '@/utils/permission-system' -import { ms } from '@/utils/time' import { Role } from '@orm' import ViewConfigModal from './ViewConfigModal' @@ -41,7 +41,7 @@ function ViewConfigButton(props: Props) { { emulatorId: props.emulatorId }, { enabled: !!currentUserQuery.data?.id && isDeveloper && !isAdmin, - staleTime: ms.minutes(5), + staleTime: CACHE_DURATIONS.MEDIUM, }, ) diff --git a/src/app/listings/components/filters/AsyncDeviceFilterSelect.tsx b/src/app/listings/components/filters/AsyncDeviceFilterSelect.tsx index fb3a17a75..5f0c2e0fa 100644 --- a/src/app/listings/components/filters/AsyncDeviceFilterSelect.tsx +++ b/src/app/listings/components/filters/AsyncDeviceFilterSelect.tsx @@ -2,8 +2,8 @@ import { type ReactNode, useCallback, useMemo, useState } from 'react' import AsyncMultiSelect from '@/components/ui/form/async-multi-select/AsyncMultiSelect' +import { CACHE_DURATIONS } from '@/data/constants' import { api } from '@/lib/api' -import { ms } from '@/utils/time' interface Props { label: string @@ -17,8 +17,8 @@ interface Props { const PAGE_SIZE = 50 const LOOKUP_DATA_QUERY_OPTIONS = { - staleTime: ms.hours(6), - gcTime: ms.hours(12), + staleTime: CACHE_DURATIONS.SIX_HOURS, + gcTime: CACHE_DURATIONS.TWELVE_HOURS, } export default function AsyncDeviceFilterSelect(props: Props) { diff --git a/src/app/listings/components/filters/AsyncSocFilterSelect.tsx b/src/app/listings/components/filters/AsyncSocFilterSelect.tsx index ac568ed44..fb306fc95 100644 --- a/src/app/listings/components/filters/AsyncSocFilterSelect.tsx +++ b/src/app/listings/components/filters/AsyncSocFilterSelect.tsx @@ -2,8 +2,8 @@ import { type ReactNode, useCallback, useMemo, useState } from 'react' import AsyncMultiSelect from '@/components/ui/form/async-multi-select/AsyncMultiSelect' +import { CACHE_DURATIONS } from '@/data/constants' import { api } from '@/lib/api' -import { ms } from '@/utils/time' interface Props { label: string @@ -17,8 +17,8 @@ interface Props { const PAGE_SIZE = 50 const LOOKUP_DATA_QUERY_OPTIONS = { - staleTime: ms.hours(6), - gcTime: ms.hours(12), + staleTime: CACHE_DURATIONS.SIX_HOURS, + gcTime: CACHE_DURATIONS.TWELVE_HOURS, } export default function AsyncSocFilterSelect(props: Props) { diff --git a/src/app/listings/components/shared/custom-fields/hooks/useDriverVersions.ts b/src/app/listings/components/shared/custom-fields/hooks/useDriverVersions.ts index 1665de2a2..9c76d3996 100644 --- a/src/app/listings/components/shared/custom-fields/hooks/useDriverVersions.ts +++ b/src/app/listings/components/shared/custom-fields/hooks/useDriverVersions.ts @@ -1,13 +1,13 @@ +import { CACHE_DURATIONS } from '@/data/constants' import { api } from '@/lib/api' import { type RouterOutput } from '@/types/trpc' -import { ms } from '@/utils/time' export type DriverVersionsResponse = RouterOutput['listings']['driverVersions'] export type DriverRelease = DriverVersionsResponse['releases'][number] export function useDriverVersions() { const query = api.listings.driverVersions.useQuery(undefined, { - staleTime: ms.minutes(30), + staleTime: CACHE_DURATIONS.THIRTY_MINUTES, refetchOnWindowFocus: false, refetchOnReconnect: false, }) diff --git a/src/app/listings/new/NewListingPage.tsx b/src/app/listings/new/NewListingPage.tsx index 9dee18a70..19e671392 100644 --- a/src/app/listings/new/NewListingPage.tsx +++ b/src/app/listings/new/NewListingPage.tsx @@ -18,6 +18,7 @@ import '@/shared/emulator-config/eden' import '@/shared/emulator-config/azahar' import '@/shared/emulator-config/gamenative' import { Button, LoadingSpinner } from '@/components/ui' +import { CACHE_DURATIONS } from '@/data/constants' import { useSubmitWithHumanVerification } from '@/features/human-verification/client' import analytics from '@/lib/analytics' import { api } from '@/lib/api' @@ -29,7 +30,6 @@ import { type CustomFieldDefinitionWithOptions } from '@/utils/custom-field-vali 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, @@ -52,8 +52,8 @@ export type ListingFormValues = RouterInput['listings']['create'] const HIGHLIGHT_DURATION_MS = 1800 const LOOKUP_DATA_QUERY_OPTIONS = { - staleTime: ms.hours(6), - gcTime: ms.hours(12), + staleTime: CACHE_DURATIONS.SIX_HOURS, + gcTime: CACHE_DURATIONS.TWELVE_HOURS, } function AddListingPage() { @@ -106,7 +106,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: ms.minutes(30), + staleTime: CACHE_DURATIONS.THIRTY_MINUTES, refetchOnWindowFocus: false, refetchOnReconnect: false, }) diff --git a/src/app/pc-listings/PcListingsPage.tsx b/src/app/pc-listings/PcListingsPage.tsx index 357a1313a..db3f9574d 100644 --- a/src/app/pc-listings/PcListingsPage.tsx +++ b/src/app/pc-listings/PcListingsPage.tsx @@ -31,6 +31,7 @@ import { TooltipTrigger, ViewButton, } from '@/components/ui' +import { CACHE_DURATIONS } from '@/data/constants' import storageKeys from '@/data/storageKeys' import { useEmulatorLogos, @@ -49,7 +50,6 @@ import { } from '@/utils/navigation-events' import { roleIncludesRole } from '@/utils/permission-system' import { hasRolePermission } from '@/utils/permissions' -import { ms } from '@/utils/time' import { Role, ApprovalStatus } from '@orm' import PcFiltersContent from './components/PcFiltersContent' import PcFiltersSidebar from './components/PcFiltersSidebar' @@ -71,8 +71,8 @@ const PC_LISTINGS_COLUMNS: ColumnDefinition[] = [ ] const LOOKUP_DATA_QUERY_OPTIONS = { - staleTime: ms.hours(6), - gcTime: ms.hours(12), + staleTime: CACHE_DURATIONS.SIX_HOURS, + gcTime: CACHE_DURATIONS.TWELVE_HOURS, } const USE_ASYNC_LISTING_FILTERS = process.env.NEXT_PUBLIC_ENABLE_ASYNC_LISTINGS_FILTERS === 'true' diff --git a/src/app/pc-listings/components/filters/AsyncCpuFilterSelect.tsx b/src/app/pc-listings/components/filters/AsyncCpuFilterSelect.tsx index 474daf663..040bed234 100644 --- a/src/app/pc-listings/components/filters/AsyncCpuFilterSelect.tsx +++ b/src/app/pc-listings/components/filters/AsyncCpuFilterSelect.tsx @@ -2,8 +2,8 @@ import { type ReactNode, useCallback, useMemo, useState } from 'react' import AsyncMultiSelect from '@/components/ui/form/async-multi-select/AsyncMultiSelect' +import { CACHE_DURATIONS } from '@/data/constants' import { api } from '@/lib/api' -import { ms } from '@/utils/time' interface Props { label: string @@ -17,8 +17,8 @@ interface Props { const PAGE_SIZE = 50 const LOOKUP_DATA_QUERY_OPTIONS = { - staleTime: ms.hours(6), - gcTime: ms.hours(12), + staleTime: CACHE_DURATIONS.SIX_HOURS, + gcTime: CACHE_DURATIONS.TWELVE_HOURS, } export default function AsyncCpuFilterSelect(props: Props) { diff --git a/src/app/pc-listings/components/filters/AsyncGpuFilterSelect.tsx b/src/app/pc-listings/components/filters/AsyncGpuFilterSelect.tsx index 679749d77..dd983e8f4 100644 --- a/src/app/pc-listings/components/filters/AsyncGpuFilterSelect.tsx +++ b/src/app/pc-listings/components/filters/AsyncGpuFilterSelect.tsx @@ -2,8 +2,8 @@ import { type ReactNode, useCallback, useMemo, useState } from 'react' import AsyncMultiSelect from '@/components/ui/form/async-multi-select/AsyncMultiSelect' +import { CACHE_DURATIONS } from '@/data/constants' import { api } from '@/lib/api' -import { ms } from '@/utils/time' interface Props { label: string @@ -17,8 +17,8 @@ interface Props { const PAGE_SIZE = 50 const LOOKUP_DATA_QUERY_OPTIONS = { - staleTime: ms.hours(6), - gcTime: ms.hours(12), + staleTime: CACHE_DURATIONS.SIX_HOURS, + gcTime: CACHE_DURATIONS.TWELVE_HOURS, } export default function AsyncGpuFilterSelect(props: Props) { diff --git a/src/app/pc-listings/new/NewPcListingPage.tsx b/src/app/pc-listings/new/NewPcListingPage.tsx index ca019771f..03a9f64de 100644 --- a/src/app/pc-listings/new/NewPcListingPage.tsx +++ b/src/app/pc-listings/new/NewPcListingPage.tsx @@ -21,6 +21,7 @@ import { useFormKeyDown, } from '@/app/listings/hooks' import { Autocomplete, Button, Input, LoadingSpinner, SelectInput } from '@/components/ui' +import { CACHE_DURATIONS } from '@/data/constants' import { PC_OS_OPTIONS } from '@/data/pc-os' import { useSubmitWithHumanVerification } from '@/features/human-verification/client' import analytics from '@/lib/analytics' @@ -31,7 +32,6 @@ import { type RouterInput, type RouterOutput } from '@/types/trpc' import { type CustomFieldDefinitionWithOptions } from '@/utils/custom-field-validation' import { parseCustomFieldOptions, getCustomFieldDefaultValue } from '@/utils/custom-fields' import getErrorMessage from '@/utils/getErrorMessage' -import { ms } from '@/utils/time' import { PcOs } from '@orm' import createDynamicPcListingSchema from './form-schemas/createDynamicPcListingSchema' @@ -43,8 +43,8 @@ type PcPresetOption = RouterOutput['pcListings']['presets']['get'][number] const OS_OPTIONS = PC_OS_OPTIONS const LOOKUP_DATA_QUERY_OPTIONS = { - staleTime: ms.hours(6), - gcTime: ms.hours(12), + staleTime: CACHE_DURATIONS.SIX_HOURS, + gcTime: CACHE_DURATIONS.TWELVE_HOURS, } function AddPcListingPage() { diff --git a/src/app/profile/components/DeviceSelector.tsx b/src/app/profile/components/DeviceSelector.tsx index 8404d1baa..d0673f38a 100644 --- a/src/app/profile/components/DeviceSelector.tsx +++ b/src/app/profile/components/DeviceSelector.tsx @@ -4,11 +4,11 @@ import { motion, AnimatePresence } from 'framer-motion' import { Smartphone, Search, Loader2, ChevronDown, Check } from 'lucide-react' import { useState, useMemo } from 'react' import { Input } from '@/components/ui' +import { CACHE_DURATIONS } from '@/data/constants' import { api } from '@/lib/api' import { cn } from '@/lib/utils' import getErrorMessage from '@/utils/getErrorMessage' import { searchItems, getDeviceSearchText } from '@/utils/simpleSearch' -import { ms } from '@/utils/time' interface Device { id: string @@ -31,8 +31,8 @@ interface Props { } const LOOKUP_DATA_QUERY_OPTIONS = { - staleTime: ms.hours(6), - gcTime: ms.hours(12), + staleTime: CACHE_DURATIONS.SIX_HOURS, + gcTime: CACHE_DURATIONS.TWELVE_HOURS, } const EMPTY_DEVICES: Device[] = [] diff --git a/src/app/profile/components/PcPresetModal.tsx b/src/app/profile/components/PcPresetModal.tsx index 35ae0553a..57a808658 100644 --- a/src/app/profile/components/PcPresetModal.tsx +++ b/src/app/profile/components/PcPresetModal.tsx @@ -2,11 +2,11 @@ import { useCallback, useState, useEffect, type SubmitEvent } from 'react' import { Button, Input, Modal, Autocomplete, SelectInput } from '@/components/ui' +import { CACHE_DURATIONS } from '@/data/constants' import { PC_OS_OPTIONS } from '@/data/pc-os' import { api } from '@/lib/api' import { type RouterInput, type RouterOutput } from '@/types/trpc' import getErrorMessage from '@/utils/getErrorMessage' -import { ms } from '@/utils/time' import { PcOs } from '@orm' type PcPreset = RouterOutput['pcListings']['presets']['get'][number] @@ -25,8 +25,8 @@ interface Props { const OS_OPTIONS = PC_OS_OPTIONS const LOOKUP_DATA_QUERY_OPTIONS = { - staleTime: ms.hours(6), - gcTime: ms.hours(12), + staleTime: CACHE_DURATIONS.SIX_HOURS, + gcTime: CACHE_DURATIONS.TWELVE_HOURS, } function PcPresetModal(props: Props) { diff --git a/src/app/profile/components/SocSelector.tsx b/src/app/profile/components/SocSelector.tsx index b2c9f601a..75b80dc86 100644 --- a/src/app/profile/components/SocSelector.tsx +++ b/src/app/profile/components/SocSelector.tsx @@ -4,10 +4,10 @@ import { motion, AnimatePresence } from 'framer-motion' import { Search, Check, Cpu, ChevronDown } from 'lucide-react' import { useState, useMemo } from 'react' import { Input } from '@/components/ui' +import { CACHE_DURATIONS } from '@/data/constants' import { api } from '@/lib/api' import { cn } from '@/lib/utils' import getErrorMessage from '@/utils/getErrorMessage' -import { ms } from '@/utils/time' interface Soc { id: string @@ -21,8 +21,8 @@ interface Props { } const LOOKUP_DATA_QUERY_OPTIONS = { - staleTime: ms.hours(6), - gcTime: ms.hours(12), + staleTime: CACHE_DURATIONS.SIX_HOURS, + gcTime: CACHE_DURATIONS.TWELVE_HOURS, } function SocSelector(props: Props) { diff --git a/src/app/v2/listings/V2ListingsPage.tsx b/src/app/v2/listings/V2ListingsPage.tsx index c6bab30b8..663755a8b 100644 --- a/src/app/v2/listings/V2ListingsPage.tsx +++ b/src/app/v2/listings/V2ListingsPage.tsx @@ -6,12 +6,12 @@ import { Suspense, useState, useEffect, useMemo, useCallback } from 'react' import useListingsState from '@/app/listings/hooks/useListingsState' import { usePreferredHardwareFilters } from '@/app/listings/shared/hooks/usePreferredHardwareFilters' import { LoadingSpinner, PullToRefresh, Button } from '@/components/ui' +import { CACHE_DURATIONS } from '@/data/constants' import analytics from '@/lib/analytics' import { api } from '@/lib/api' import { cn } from '@/lib/utils' import { filterNullAndEmpty } from '@/utils/filter' import { systemOptions, deviceOptions, emulatorOptions, socOptionsParens } from '@/utils/options' -import { ms } from '@/utils/time' import { ListingFilters } from './components/ListingFilters' import { ListingsContent } from './components/ListingsContent' import { ListingsHeader } from './components/ListingsHeader' @@ -25,8 +25,8 @@ type SortField = NonNullable type ListingType = RouterOutput['listings']['get']['listings'][number] const LOOKUP_DATA_QUERY_OPTIONS = { - staleTime: ms.hours(6), - gcTime: ms.hours(12), + staleTime: CACHE_DURATIONS.SIX_HOURS, + gcTime: CACHE_DURATIONS.TWELVE_HOURS, } const USE_ASYNC_LISTING_FILTERS = process.env.NEXT_PUBLIC_ENABLE_ASYNC_LISTINGS_FILTERS === 'true' @@ -54,8 +54,8 @@ function V2ListingsPage() { const userQuery = api.users.me.useQuery() const userPreferencesQuery = api.userPreferences.get.useQuery(undefined, { enabled: !!userQuery.data, - staleTime: ms.seconds(30), - gcTime: ms.minutes(5), + staleTime: CACHE_DURATIONS.THIRTY_SECONDS, + gcTime: CACHE_DURATIONS.MEDIUM, }) const preferred = usePreferredHardwareFilters({ diff --git a/src/components/retrocatalog/useRetroCatalogDevice.ts b/src/components/retrocatalog/useRetroCatalogDevice.ts index 3c9124e04..d719be559 100644 --- a/src/components/retrocatalog/useRetroCatalogDevice.ts +++ b/src/components/retrocatalog/useRetroCatalogDevice.ts @@ -1,8 +1,8 @@ 'use client' import { useQuery } from '@tanstack/react-query' +import { CACHE_DURATIONS } from '@/data/constants' import http from '@/rest/http' -import { ms } from '@/utils/time' const RETROCATALOG_REFERRER = '?referrer=emuready' @@ -58,8 +58,8 @@ export function useRetroCatalogDevice( queryKey: ['retrocatalog', options.brandName, options.modelName], queryFn: () => fetchRetroCatalogDevice(options.brandName, options.modelName), enabled: enabled && Boolean(options.brandName) && Boolean(options.modelName), - staleTime: ms.hours(24), - gcTime: ms.hours(48), + staleTime: CACHE_DURATIONS.ONE_DAY, + gcTime: CACHE_DURATIONS.TWO_DAYS, retry: false, refetchOnWindowFocus: false, refetchOnReconnect: false, diff --git a/src/components/ui/image-selectors/providers/IGDBImageSelector.tsx b/src/components/ui/image-selectors/providers/IGDBImageSelector.tsx index 94926d0a3..1f602d84b 100644 --- a/src/components/ui/image-selectors/providers/IGDBImageSelector.tsx +++ b/src/components/ui/image-selectors/providers/IGDBImageSelector.tsx @@ -3,6 +3,7 @@ import { Search, Eye, Image as ImageIcon, Sparkles } from 'lucide-react' import { useState, useEffect, type KeyboardEvent } from 'react' import { Button, LoadingSpinner, Modal, Input, Badge, OptimizedImage } from '@/components/ui' +import { CACHE_DURATIONS } from '@/data/constants' import useDebouncedValue from '@/hooks/useDebouncedValue' import { api } from '@/lib/api' import { cn } from '@/lib/utils' @@ -65,7 +66,7 @@ export function IGDBImageSelector({ onImageSelect, onError, ...props }: Props) { { query: debouncedSearchTerm, limit: 10 }, { enabled: debouncedSearchTerm.length >= 2 && !selectedGameId, - staleTime: 5 * 60 * 1000, + staleTime: CACHE_DURATIONS.MEDIUM, }, ) @@ -74,7 +75,7 @@ export function IGDBImageSelector({ onImageSelect, onError, ...props }: Props) { { gameId: selectedGameId! }, { enabled: !!selectedGameId, - staleTime: 5 * 60 * 1000, + staleTime: CACHE_DURATIONS.MEDIUM, }, ) diff --git a/src/components/ui/image-selectors/providers/RawgImageSelector.tsx b/src/components/ui/image-selectors/providers/RawgImageSelector.tsx index 9c5295787..48cf6a4a7 100644 --- a/src/components/ui/image-selectors/providers/RawgImageSelector.tsx +++ b/src/components/ui/image-selectors/providers/RawgImageSelector.tsx @@ -3,6 +3,7 @@ import { Search, Eye, Camera, Link as LinkIcon } from 'lucide-react' import { useState, useEffect, type KeyboardEvent, type MouseEvent } from 'react' import { Button, LoadingSpinner, OptimizedImage, Modal, Input, Toggle } from '@/components/ui' +import { CACHE_DURATIONS } from '@/data/constants' import useDebouncedValue from '@/hooks/useDebouncedValue' import { api } from '@/lib/api' import { getImageDisplayName } from '@/lib/rawg-utils' @@ -55,7 +56,7 @@ export function RawgImageSelector({ onImageSelect, onError, ...props }: Props) { }, { enabled: !useCustomUrl && debouncedSearchTerm.length >= 2, - staleTime: 5 * 60 * 1000, + staleTime: CACHE_DURATIONS.MEDIUM, }, ) diff --git a/src/components/ui/image-selectors/providers/TGDBImageSelector.tsx b/src/components/ui/image-selectors/providers/TGDBImageSelector.tsx index 7383be08c..bd58c78e4 100644 --- a/src/components/ui/image-selectors/providers/TGDBImageSelector.tsx +++ b/src/components/ui/image-selectors/providers/TGDBImageSelector.tsx @@ -3,6 +3,7 @@ import { Search, Eye, Camera, LinkIcon } from 'lucide-react' import { useState, useEffect, type KeyboardEvent, type MouseEvent } from 'react' import { Button, LoadingSpinner, OptimizedImage, Modal, Input, Toggle } from '@/components/ui' +import { CACHE_DURATIONS } from '@/data/constants' import useDebouncedValue from '@/hooks/useDebouncedValue' import { api } from '@/lib/api' import { getImageDisplayName, getImageTypeDisplayName } from '@/lib/tgdb-utils' @@ -56,7 +57,7 @@ export function TGDBImageSelector({ onImageSelect, onError, ...props }: Props) { const searchQuery = api.tgdb.searchGameImages.useQuery(getSearchQuery(), { enabled: !useCustomUrl && debouncedSearchTerm.length >= 2, - staleTime: 5 * 60 * 1000, + staleTime: CACHE_DURATIONS.MEDIUM, }) // Update search term when gameTitle prop changes diff --git a/src/data/constants.ts b/src/data/constants.ts index b5a475390..39c11ef72 100644 --- a/src/data/constants.ts +++ b/src/data/constants.ts @@ -32,12 +32,21 @@ export const PAGINATION = { export const PAGE_SIZE_OPTIONS = [10, 25, 50] as const export type PageSizeOption = (typeof PAGE_SIZE_OPTIONS)[number] -// Cache durations in milliseconds TODO: use wherever possible +// Cache durations in milliseconds export const CACHE_DURATIONS = { + TEN_SECONDS: ms.seconds(10), + THIRTY_SECONDS: ms.seconds(30), SHORT: ms.minutes(1), + TWO_MINUTES: ms.minutes(2), MEDIUM: ms.minutes(5), + TEN_MINUTES: ms.minutes(10), LONG: ms.minutes(15), + THIRTY_MINUTES: ms.minutes(30), EXTRA_LONG: ms.hours(1), + SIX_HOURS: ms.hours(6), + TWELVE_HOURS: ms.hours(12), + ONE_DAY: ms.days(1), + TWO_DAYS: ms.days(2), } as const // Rate limiting diff --git a/src/lib/api.tsx b/src/lib/api.tsx index 385da0237..b537f4ec1 100644 --- a/src/lib/api.tsx +++ b/src/lib/api.tsx @@ -5,8 +5,8 @@ import { httpBatchLink } from '@trpc/client' import { createTRPCReact } from '@trpc/react-query' import { useState, type PropsWithChildren } from 'react' import superjson from 'superjson' +import { CACHE_DURATIONS } from '@/data/constants' import { shouldRetryTRPCQuery } from '@/lib/trpc-client-errors' -import { ms } from '@/utils/time' import type { AppRouter } from '@/types/trpc' export const api = createTRPCReact() @@ -17,8 +17,8 @@ export function TRPCProvider(props: PropsWithChildren) { new QueryClient({ defaultOptions: { queries: { - staleTime: ms.seconds(30), - gcTime: ms.minutes(5), + staleTime: CACHE_DURATIONS.THIRTY_SECONDS, + gcTime: CACHE_DURATIONS.MEDIUM, refetchOnWindowFocus: false, refetchOnReconnect: false, retry: shouldRetryTRPCQuery, diff --git a/src/server/repositories/devices.repository.ts b/src/server/repositories/devices.repository.ts index defe8f61a..06ffea60f 100644 --- a/src/server/repositories/devices.repository.ts +++ b/src/server/repositories/devices.repository.ts @@ -1,9 +1,8 @@ import { startOfMonth, subDays } from 'date-fns' import { LRUCache } from 'lru-cache' -import { HOME_PAGE_LIMITS } from '@/data/constants' +import { CACHE_DURATIONS, HOME_PAGE_LIMITS } from '@/data/constants' import { ResourceError } from '@/lib/errors' import { type PaginationResult, paginate, calculateOffset } from '@/server/utils/pagination' -import { TIME_CONSTANTS } from '@/utils/time' import { Prisma, ApprovalStatus } from '@orm/client' import { getTrendingDevices } from '@orm/sql' import { BaseRepository } from './base.repository' @@ -31,7 +30,7 @@ export interface TrendingDevicesSummary { } const trendingDevicesSummaryCache = new LRUCache({ - ttl: TIME_CONSTANTS.SIX_HOURS, + ttl: CACHE_DURATIONS.SIX_HOURS, max: 20, }) diff --git a/src/server/tgdb.ts b/src/server/tgdb.ts index c7262eb11..2986c3e58 100644 --- a/src/server/tgdb.ts +++ b/src/server/tgdb.ts @@ -1,5 +1,5 @@ import axios, { type AxiosResponse } from 'axios' -import { PLATFORM_MAPPINGS, type PlatformKey } from '@/data/constants' +import { CACHE_DURATIONS, PLATFORM_MAPPINGS, type PlatformKey } from '@/data/constants' import { isValidImageUrl } from '@/lib/tgdb-utils' import { tgdbGamesCache, @@ -163,7 +163,7 @@ export async function getPlatforms(): Promise { const response = await makeRequest('/v1/Platforms') - tgdbPlatformsCache.set(cacheKey, response, { ttl: 60 * 60 * 1000 }) + tgdbPlatformsCache.set(cacheKey, response, { ttl: CACHE_DURATIONS.EXTRA_LONG }) return response } diff --git a/src/server/utils/cache/instances.ts b/src/server/utils/cache/instances.ts index fa3fed181..477dc3f55 100644 --- a/src/server/utils/cache/instances.ts +++ b/src/server/utils/cache/instances.ts @@ -1,5 +1,5 @@ import { LRUCache } from 'lru-cache' -import { TIME_CONSTANTS } from '@/utils/time' +import { CACHE_DURATIONS } from '@/data/constants' import type { DeviceCompatibilityResponse } from '@/schemas/mobile' import type { BatchBySteamAppIdsResponse } from '@/server/api/routers/mobile/games' import type { @@ -27,7 +27,7 @@ export const gameStatsCache = new LRUCache< total: number } >({ - ttl: TIME_CONSTANTS.FIVE_MINUTES, + ttl: CACHE_DURATIONS.MEDIUM, max: 100, }) @@ -40,7 +40,7 @@ export const listingStatsCache = new LRUCache< total: number } >({ - ttl: TIME_CONSTANTS.FIVE_MINUTES, + ttl: CACHE_DURATIONS.MEDIUM, max: 100, }) @@ -58,22 +58,22 @@ export const notificationAnalyticsCache = new LRUCache< clickRate: number }[] >({ - ttl: TIME_CONSTANTS.TEN_MINUTES, + ttl: CACHE_DURATIONS.TEN_MINUTES, max: 200, }) export const tgdbGamesCache = new LRUCache({ - ttl: TIME_CONSTANTS.TEN_MINUTES, + ttl: CACHE_DURATIONS.TEN_MINUTES, max: 200, }) export const tgdbImagesCache = new LRUCache({ - ttl: TIME_CONSTANTS.TEN_MINUTES, + ttl: CACHE_DURATIONS.TEN_MINUTES, max: 200, }) export const tgdbPlatformsCache = new LRUCache({ - ttl: TIME_CONSTANTS.TEN_MINUTES, + ttl: CACHE_DURATIONS.TEN_MINUTES, max: 10, }) @@ -84,27 +84,27 @@ export const tgdbImageUrlsCache = new LRUCache< bannerUrl?: string } >({ - ttl: TIME_CONSTANTS.TEN_MINUTES, + ttl: CACHE_DURATIONS.TEN_MINUTES, max: 500, }) export const tgdbGameImagesCache = new LRUCache>({ - ttl: TIME_CONSTANTS.TEN_MINUTES, + ttl: CACHE_DURATIONS.TEN_MINUTES, max: 100, }) export const driverVersionsCache = new LRUCache({ - ttl: TIME_CONSTANTS.THIRTY_MINUTES, + ttl: CACHE_DURATIONS.THIRTY_MINUTES, max: 1, }) export const steamBatchQueryCache = new LRUCache({ - ttl: TIME_CONSTANTS.TEN_MINUTES, + ttl: CACHE_DURATIONS.TEN_MINUTES, max: 100, }) export const catalogCompatibilityCache = new LRUCache({ - ttl: TIME_CONSTANTS.TEN_MINUTES, + ttl: CACHE_DURATIONS.TEN_MINUTES, max: 500, }) diff --git a/src/server/utils/driver-versions.ts b/src/server/utils/driver-versions.ts index 4b2abb80d..f3316d654 100644 --- a/src/server/utils/driver-versions.ts +++ b/src/server/utils/driver-versions.ts @@ -1,7 +1,7 @@ import axios, { type AxiosError } from 'axios' +import { CACHE_DURATIONS } from '@/data/constants' import { logger } from '@/lib/logger' import { driverVersionsCache } from '@/server/utils/cache/instances' -import { ms } from '@/utils/time' import type { DriverAsset, DriverRelease, DriverVersionsResponse } from '@/types/driver-versions' interface Repo { @@ -144,7 +144,7 @@ export async function getDriverVersions(): Promise { releases, rateLimited: false, } - driverVersionsCache.set(CACHE_KEY, payload, { ttl: ms.minutes(30) }) + driverVersionsCache.set(CACHE_KEY, payload, { ttl: CACHE_DURATIONS.THIRTY_MINUTES }) return payload } catch (error) { if (isRateLimitError(error)) { @@ -154,7 +154,7 @@ export async function getDriverVersions(): Promise { rateLimited: true, errorMessage: 'GitHub rate limit exceeded. Try again in a few minutes.', } - driverVersionsCache.set(CACHE_KEY, payload, { ttl: ms.minutes(5) }) + driverVersionsCache.set(CACHE_KEY, payload, { ttl: CACHE_DURATIONS.MEDIUM }) return payload } @@ -164,7 +164,7 @@ export async function getDriverVersions(): Promise { rateLimited: false, errorMessage: 'Failed to fetch driver versions. Please try again later.', } - driverVersionsCache.set(CACHE_KEY, payload, { ttl: ms.minutes(2) }) + driverVersionsCache.set(CACHE_KEY, payload, { ttl: CACHE_DURATIONS.TWO_MINUTES }) return payload } } diff --git a/src/server/utils/steamGameBatcher.ts b/src/server/utils/steamGameBatcher.ts index 0b64197bc..32c3c5658 100644 --- a/src/server/utils/steamGameBatcher.ts +++ b/src/server/utils/steamGameBatcher.ts @@ -1,5 +1,5 @@ import { LRUCache } from 'lru-cache' -import { ms } from '@/utils/time' +import { CACHE_DURATIONS } from '@/data/constants' import { getSteamGamesData } from './steamGameSearch' const MAX_STEAM_APP_ID = 10000000 @@ -17,7 +17,7 @@ interface GameMatchResult { } const steamAppNameCache = new LRUCache({ - ttl: ms.hours(1), + ttl: CACHE_DURATIONS.EXTRA_LONG, max: 10000, }) diff --git a/src/server/utils/steamGameSearch.ts b/src/server/utils/steamGameSearch.ts index b4ce70a1b..58006bb2b 100644 --- a/src/server/utils/steamGameSearch.ts +++ b/src/server/utils/steamGameSearch.ts @@ -1,6 +1,6 @@ import Fuse from 'fuse.js' import { LRUCache } from 'lru-cache' -import { ms } from '@/utils/time' +import { CACHE_DURATIONS } from '@/data/constants' interface SteamAppEntry { appid: number @@ -28,12 +28,12 @@ interface CachedData { } const steamGamesDataCache = new LRUCache>({ - ttl: ms.days(1), + ttl: CACHE_DURATIONS.ONE_DAY, max: 1, }) const steamGamesFuseCache = new LRUCache>({ - ttl: ms.days(1), + ttl: CACHE_DURATIONS.ONE_DAY, max: 1, }) diff --git a/src/server/utils/switchGameSearch.ts b/src/server/utils/switchGameSearch.ts index 06915ee19..21439422a 100644 --- a/src/server/utils/switchGameSearch.ts +++ b/src/server/utils/switchGameSearch.ts @@ -1,6 +1,6 @@ import Fuse from 'fuse.js' import { LRUCache } from 'lru-cache' -import { ms } from '@/utils/time' +import { CACHE_DURATIONS } from '@/data/constants' interface SwitchGameEntry { program_id: string @@ -21,12 +21,12 @@ interface CachedData { } const switchGamesDataCache = new LRUCache>({ - ttl: ms.days(1), + ttl: CACHE_DURATIONS.ONE_DAY, max: 1, }) const switchGamesFuseCache = new LRUCache>({ - ttl: ms.days(1), + ttl: CACHE_DURATIONS.ONE_DAY, max: 1, }) diff --git a/src/server/utils/threeDsGameSearch.ts b/src/server/utils/threeDsGameSearch.ts index c4c9f1464..acdca5367 100644 --- a/src/server/utils/threeDsGameSearch.ts +++ b/src/server/utils/threeDsGameSearch.ts @@ -1,6 +1,6 @@ import Fuse from 'fuse.js' import { LRUCache } from 'lru-cache' -import { ms } from '@/utils/time' +import { CACHE_DURATIONS } from '@/data/constants' import type { IFuseOptions } from 'fuse.js' interface RawThreeDsTitleEntry { @@ -79,12 +79,12 @@ const THREEDS_TITLES_URL = 'https://dantheman827.github.io/nus-info/titles.json' const THREEDS_TITLE_NAMES_URL = 'https://dantheman827.github.io/nus-info/title-names.json' const threeDsGamesDataCache = new LRUCache>({ - ttl: ms.days(1), + ttl: CACHE_DURATIONS.ONE_DAY, max: 1, }) const threeDsGamesFuseCache = new LRUCache>({ - ttl: ms.days(1), + ttl: CACHE_DURATIONS.ONE_DAY, max: 1, }) From a80d4c7e08f356ed1af4fa872ba9e77af5228ace Mon Sep 17 00:00:00 2001 From: Producdevity Date: Thu, 4 Jun 2026 18:35:10 +0200 Subject: [PATCH 19/87] refactor: use admin error state for trust logs --- src/app/admin/trust-logs/page.tsx | 27 +++++++++++++-------------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/src/app/admin/trust-logs/page.tsx b/src/app/admin/trust-logs/page.tsx index 383b8257c..35033b6d4 100644 --- a/src/app/admin/trust-logs/page.tsx +++ b/src/app/admin/trust-logs/page.tsx @@ -5,7 +5,12 @@ import Link from 'next/link' import { useState } from 'react' import { isEmpty } from 'remeda' import { useAdminTable } from '@/app/admin/hooks' -import { AdminPageLayout, AdminTableContainer, AdminTableNoResults } from '@/components/admin' +import { + AdminErrorState, + AdminPageLayout, + AdminTableContainer, + AdminTableNoResults, +} from '@/components/admin' import { Button, Input, @@ -87,19 +92,13 @@ function AdminTrustLogsPage() { if (trustLogsQuery.error) { return ( - -

-

- Error loading trust logs: {trustLogsQuery.error.message} -

- -
- + { + void trustLogsQuery.refetch() + }} + /> ) } From 9adfe3dda62c9a5909e5fd1b2cbeabf24dff13c9 Mon Sep 17 00:00:00 2001 From: Producdevity Date: Thu, 4 Jun 2026 18:54:26 +0200 Subject: [PATCH 20/87] chore: use semantic cache duration buckets --- src/app/admin/AdminLayoutClient.tsx | 8 ++++---- .../admin/components/ApprovalCountBadge.tsx | 6 +++--- .../games/new/search/hooks/useGameSearch.ts | 2 +- .../home/components/HomeTrendingDevices.tsx | 4 ++-- src/app/listings/ListingsPage.tsx | 6 +++--- .../filters/AsyncDeviceFilterSelect.tsx | 4 ++-- .../filters/AsyncSocFilterSelect.tsx | 4 ++-- .../custom-fields/hooks/useDriverVersions.ts | 2 +- src/app/listings/new/NewListingPage.tsx | 6 +++--- src/app/pc-listings/PcListingsPage.tsx | 4 ++-- .../filters/AsyncCpuFilterSelect.tsx | 4 ++-- .../filters/AsyncGpuFilterSelect.tsx | 4 ++-- src/app/pc-listings/new/NewPcListingPage.tsx | 4 ++-- src/app/profile/components/DeviceSelector.tsx | 4 ++-- src/app/profile/components/PcPresetModal.tsx | 4 ++-- src/app/profile/components/SocSelector.tsx | 4 ++-- src/app/v2/listings/V2ListingsPage.tsx | 6 +++--- .../retrocatalog/useRetroCatalogDevice.ts | 4 ++-- src/data/constants.ts | 16 ++++++---------- src/lib/api.tsx | 2 +- src/server/repositories/devices.repository.ts | 2 +- src/server/utils/cache/instances.ts | 18 +++++++++--------- src/server/utils/driver-versions.ts | 4 ++-- src/server/utils/steamGameSearch.ts | 4 ++-- src/server/utils/switchGameSearch.ts | 4 ++-- src/server/utils/threeDsGameSearch.ts | 4 ++-- 26 files changed, 65 insertions(+), 69 deletions(-) diff --git a/src/app/admin/AdminLayoutClient.tsx b/src/app/admin/AdminLayoutClient.tsx index ad4f2505b..3cfa9d8b6 100644 --- a/src/app/admin/AdminLayoutClient.tsx +++ b/src/app/admin/AdminLayoutClient.tsx @@ -41,7 +41,7 @@ export default function AdminLayoutClient(props: PropsWithChildren) { enabled: !!userQuery.data && hasPermission(userQuery.data.permissions, PERMISSIONS.VIEW_STATISTICS), refetchInterval: 30000, - staleTime: CACHE_DURATIONS.TEN_SECONDS, + staleTime: CACHE_DURATIONS.VERY_SHORT, refetchOnMount: true, refetchOnWindowFocus: true, }) @@ -50,7 +50,7 @@ export default function AdminLayoutClient(props: PropsWithChildren) { enabled: !!userQuery.data && hasPermission(userQuery.data.permissions, PERMISSIONS.VIEW_STATISTICS), refetchInterval: 30000, - staleTime: CACHE_DURATIONS.TEN_SECONDS, + staleTime: CACHE_DURATIONS.VERY_SHORT, refetchOnMount: true, refetchOnWindowFocus: true, }) @@ -59,7 +59,7 @@ export default function AdminLayoutClient(props: PropsWithChildren) { enabled: !!userQuery.data && hasPermission(userQuery.data.permissions, PERMISSIONS.VIEW_STATISTICS), refetchInterval: 30000, - staleTime: CACHE_DURATIONS.TEN_SECONDS, + staleTime: CACHE_DURATIONS.VERY_SHORT, refetchOnMount: true, refetchOnWindowFocus: true, }) @@ -69,7 +69,7 @@ export default function AdminLayoutClient(props: PropsWithChildren) { const reportsStatsQuery = api.listingReports.stats.useQuery(undefined, { enabled: !!userQuery.data && isSuperAdmin, refetchInterval: 30000, - staleTime: CACHE_DURATIONS.TEN_SECONDS, + staleTime: CACHE_DURATIONS.VERY_SHORT, refetchOnMount: true, refetchOnWindowFocus: true, }) diff --git a/src/app/admin/components/ApprovalCountBadge.tsx b/src/app/admin/components/ApprovalCountBadge.tsx index 6460904b9..b1a5ceb94 100644 --- a/src/app/admin/components/ApprovalCountBadge.tsx +++ b/src/app/admin/components/ApprovalCountBadge.tsx @@ -31,7 +31,7 @@ export default function ApprovalCountBadge(props: Props) { const gameStatsQuery = api.games.stats.useQuery(undefined, { enabled: canViewStats && props.href === '/admin/games/approvals', refetchInterval: 30000, - staleTime: CACHE_DURATIONS.TEN_SECONDS, + staleTime: CACHE_DURATIONS.VERY_SHORT, refetchOnMount: true, refetchOnWindowFocus: true, }) @@ -39,7 +39,7 @@ export default function ApprovalCountBadge(props: Props) { const listingStatsQuery = api.listings.stats.useQuery(undefined, { enabled: canViewStats && props.href === '/admin/approvals', refetchInterval: 30000, - staleTime: CACHE_DURATIONS.TEN_SECONDS, + staleTime: CACHE_DURATIONS.VERY_SHORT, refetchOnMount: true, refetchOnWindowFocus: true, }) @@ -47,7 +47,7 @@ export default function ApprovalCountBadge(props: Props) { const pcListingStatsQuery = api.pcListings.stats.useQuery(undefined, { enabled: canViewStats && props.href === '/admin/pc-listing-approvals', refetchInterval: 30000, - staleTime: CACHE_DURATIONS.TEN_SECONDS, + staleTime: CACHE_DURATIONS.VERY_SHORT, refetchOnMount: true, refetchOnWindowFocus: true, }) diff --git a/src/app/games/new/search/hooks/useGameSearch.ts b/src/app/games/new/search/hooks/useGameSearch.ts index ed2cf2b8f..32dfad239 100644 --- a/src/app/games/new/search/hooks/useGameSearch.ts +++ b/src/app/games/new/search/hooks/useGameSearch.ts @@ -26,7 +26,7 @@ export function useGameSearch( { games: gameNamesAndSystems }, { enabled: gameNamesAndSystems.length > 0, - staleTime: CACHE_DURATIONS.THIRTY_SECONDS, + staleTime: CACHE_DURATIONS.SHORT, refetchOnWindowFocus: true, }, ) diff --git a/src/app/home/components/HomeTrendingDevices.tsx b/src/app/home/components/HomeTrendingDevices.tsx index 613720100..fd3564b72 100644 --- a/src/app/home/components/HomeTrendingDevices.tsx +++ b/src/app/home/components/HomeTrendingDevices.tsx @@ -23,8 +23,8 @@ export function HomeTrendingDevices() { limit: HOME_PAGE_LIMITS.TRENDING_DEVICES, }, { - staleTime: CACHE_DURATIONS.SIX_HOURS, - gcTime: CACHE_DURATIONS.TWELVE_HOURS, + staleTime: CACHE_DURATIONS.LOOKUP, + gcTime: CACHE_DURATIONS.LOOKUP_GC, }, ) diff --git a/src/app/listings/ListingsPage.tsx b/src/app/listings/ListingsPage.tsx index db9ea2ce5..6d3f869cc 100644 --- a/src/app/listings/ListingsPage.tsx +++ b/src/app/listings/ListingsPage.tsx @@ -66,8 +66,8 @@ const LISTINGS_COLUMNS: ColumnDefinition[] = [ { key: 'actions', label: 'Actions', alwaysVisible: true }, ] -const LOOKUP_DATA_STALE_TIME = CACHE_DURATIONS.SIX_HOURS -const LOOKUP_DATA_GC_TIME = CACHE_DURATIONS.TWELVE_HOURS +const LOOKUP_DATA_STALE_TIME = CACHE_DURATIONS.LOOKUP +const LOOKUP_DATA_GC_TIME = CACHE_DURATIONS.LOOKUP_GC const USE_ASYNC_LISTING_FILTERS = process.env.NEXT_PUBLIC_ENABLE_ASYNC_LISTINGS_FILTERS === 'true' function ListingsPage() { @@ -98,7 +98,7 @@ function ListingsPage() { }) const userPreferencesQuery = api.userPreferences.get.useQuery(undefined, { enabled: isSignedIn === true && !!userQuery.data, - staleTime: CACHE_DURATIONS.THIRTY_SECONDS, + staleTime: CACHE_DURATIONS.SHORT, gcTime: CACHE_DURATIONS.MEDIUM, }) diff --git a/src/app/listings/components/filters/AsyncDeviceFilterSelect.tsx b/src/app/listings/components/filters/AsyncDeviceFilterSelect.tsx index 5f0c2e0fa..c07ab6733 100644 --- a/src/app/listings/components/filters/AsyncDeviceFilterSelect.tsx +++ b/src/app/listings/components/filters/AsyncDeviceFilterSelect.tsx @@ -17,8 +17,8 @@ interface Props { const PAGE_SIZE = 50 const LOOKUP_DATA_QUERY_OPTIONS = { - staleTime: CACHE_DURATIONS.SIX_HOURS, - gcTime: CACHE_DURATIONS.TWELVE_HOURS, + staleTime: CACHE_DURATIONS.LOOKUP, + gcTime: CACHE_DURATIONS.LOOKUP_GC, } export default function AsyncDeviceFilterSelect(props: Props) { diff --git a/src/app/listings/components/filters/AsyncSocFilterSelect.tsx b/src/app/listings/components/filters/AsyncSocFilterSelect.tsx index fb306fc95..2b1759b90 100644 --- a/src/app/listings/components/filters/AsyncSocFilterSelect.tsx +++ b/src/app/listings/components/filters/AsyncSocFilterSelect.tsx @@ -17,8 +17,8 @@ interface Props { const PAGE_SIZE = 50 const LOOKUP_DATA_QUERY_OPTIONS = { - staleTime: CACHE_DURATIONS.SIX_HOURS, - gcTime: CACHE_DURATIONS.TWELVE_HOURS, + staleTime: CACHE_DURATIONS.LOOKUP, + gcTime: CACHE_DURATIONS.LOOKUP_GC, } export default function AsyncSocFilterSelect(props: Props) { diff --git a/src/app/listings/components/shared/custom-fields/hooks/useDriverVersions.ts b/src/app/listings/components/shared/custom-fields/hooks/useDriverVersions.ts index 9c76d3996..4a7f7a61a 100644 --- a/src/app/listings/components/shared/custom-fields/hooks/useDriverVersions.ts +++ b/src/app/listings/components/shared/custom-fields/hooks/useDriverVersions.ts @@ -7,7 +7,7 @@ export type DriverRelease = DriverVersionsResponse['releases'][number] export function useDriverVersions() { const query = api.listings.driverVersions.useQuery(undefined, { - staleTime: CACHE_DURATIONS.THIRTY_MINUTES, + staleTime: CACHE_DURATIONS.EXTRA_LONG, refetchOnWindowFocus: false, refetchOnReconnect: false, }) diff --git a/src/app/listings/new/NewListingPage.tsx b/src/app/listings/new/NewListingPage.tsx index 19e671392..45023c000 100644 --- a/src/app/listings/new/NewListingPage.tsx +++ b/src/app/listings/new/NewListingPage.tsx @@ -52,8 +52,8 @@ export type ListingFormValues = RouterInput['listings']['create'] const HIGHLIGHT_DURATION_MS = 1800 const LOOKUP_DATA_QUERY_OPTIONS = { - staleTime: CACHE_DURATIONS.SIX_HOURS, - gcTime: CACHE_DURATIONS.TWELVE_HOURS, + staleTime: CACHE_DURATIONS.LOOKUP, + gcTime: CACHE_DURATIONS.LOOKUP_GC, } function AddListingPage() { @@ -106,7 +106,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: CACHE_DURATIONS.THIRTY_MINUTES, + staleTime: CACHE_DURATIONS.EXTRA_LONG, refetchOnWindowFocus: false, refetchOnReconnect: false, }) diff --git a/src/app/pc-listings/PcListingsPage.tsx b/src/app/pc-listings/PcListingsPage.tsx index db3f9574d..5d4a277e8 100644 --- a/src/app/pc-listings/PcListingsPage.tsx +++ b/src/app/pc-listings/PcListingsPage.tsx @@ -71,8 +71,8 @@ const PC_LISTINGS_COLUMNS: ColumnDefinition[] = [ ] const LOOKUP_DATA_QUERY_OPTIONS = { - staleTime: CACHE_DURATIONS.SIX_HOURS, - gcTime: CACHE_DURATIONS.TWELVE_HOURS, + staleTime: CACHE_DURATIONS.LOOKUP, + gcTime: CACHE_DURATIONS.LOOKUP_GC, } const USE_ASYNC_LISTING_FILTERS = process.env.NEXT_PUBLIC_ENABLE_ASYNC_LISTINGS_FILTERS === 'true' diff --git a/src/app/pc-listings/components/filters/AsyncCpuFilterSelect.tsx b/src/app/pc-listings/components/filters/AsyncCpuFilterSelect.tsx index 040bed234..0b44440ba 100644 --- a/src/app/pc-listings/components/filters/AsyncCpuFilterSelect.tsx +++ b/src/app/pc-listings/components/filters/AsyncCpuFilterSelect.tsx @@ -17,8 +17,8 @@ interface Props { const PAGE_SIZE = 50 const LOOKUP_DATA_QUERY_OPTIONS = { - staleTime: CACHE_DURATIONS.SIX_HOURS, - gcTime: CACHE_DURATIONS.TWELVE_HOURS, + staleTime: CACHE_DURATIONS.LOOKUP, + gcTime: CACHE_DURATIONS.LOOKUP_GC, } export default function AsyncCpuFilterSelect(props: Props) { diff --git a/src/app/pc-listings/components/filters/AsyncGpuFilterSelect.tsx b/src/app/pc-listings/components/filters/AsyncGpuFilterSelect.tsx index dd983e8f4..65bd67709 100644 --- a/src/app/pc-listings/components/filters/AsyncGpuFilterSelect.tsx +++ b/src/app/pc-listings/components/filters/AsyncGpuFilterSelect.tsx @@ -17,8 +17,8 @@ interface Props { const PAGE_SIZE = 50 const LOOKUP_DATA_QUERY_OPTIONS = { - staleTime: CACHE_DURATIONS.SIX_HOURS, - gcTime: CACHE_DURATIONS.TWELVE_HOURS, + staleTime: CACHE_DURATIONS.LOOKUP, + gcTime: CACHE_DURATIONS.LOOKUP_GC, } export default function AsyncGpuFilterSelect(props: Props) { diff --git a/src/app/pc-listings/new/NewPcListingPage.tsx b/src/app/pc-listings/new/NewPcListingPage.tsx index 03a9f64de..7a6b94b23 100644 --- a/src/app/pc-listings/new/NewPcListingPage.tsx +++ b/src/app/pc-listings/new/NewPcListingPage.tsx @@ -43,8 +43,8 @@ type PcPresetOption = RouterOutput['pcListings']['presets']['get'][number] const OS_OPTIONS = PC_OS_OPTIONS const LOOKUP_DATA_QUERY_OPTIONS = { - staleTime: CACHE_DURATIONS.SIX_HOURS, - gcTime: CACHE_DURATIONS.TWELVE_HOURS, + staleTime: CACHE_DURATIONS.LOOKUP, + gcTime: CACHE_DURATIONS.LOOKUP_GC, } function AddPcListingPage() { diff --git a/src/app/profile/components/DeviceSelector.tsx b/src/app/profile/components/DeviceSelector.tsx index d0673f38a..f260e0eba 100644 --- a/src/app/profile/components/DeviceSelector.tsx +++ b/src/app/profile/components/DeviceSelector.tsx @@ -31,8 +31,8 @@ interface Props { } const LOOKUP_DATA_QUERY_OPTIONS = { - staleTime: CACHE_DURATIONS.SIX_HOURS, - gcTime: CACHE_DURATIONS.TWELVE_HOURS, + staleTime: CACHE_DURATIONS.LOOKUP, + gcTime: CACHE_DURATIONS.LOOKUP_GC, } const EMPTY_DEVICES: Device[] = [] diff --git a/src/app/profile/components/PcPresetModal.tsx b/src/app/profile/components/PcPresetModal.tsx index 57a808658..22242de53 100644 --- a/src/app/profile/components/PcPresetModal.tsx +++ b/src/app/profile/components/PcPresetModal.tsx @@ -25,8 +25,8 @@ interface Props { const OS_OPTIONS = PC_OS_OPTIONS const LOOKUP_DATA_QUERY_OPTIONS = { - staleTime: CACHE_DURATIONS.SIX_HOURS, - gcTime: CACHE_DURATIONS.TWELVE_HOURS, + staleTime: CACHE_DURATIONS.LOOKUP, + gcTime: CACHE_DURATIONS.LOOKUP_GC, } function PcPresetModal(props: Props) { diff --git a/src/app/profile/components/SocSelector.tsx b/src/app/profile/components/SocSelector.tsx index 75b80dc86..0f771df43 100644 --- a/src/app/profile/components/SocSelector.tsx +++ b/src/app/profile/components/SocSelector.tsx @@ -21,8 +21,8 @@ interface Props { } const LOOKUP_DATA_QUERY_OPTIONS = { - staleTime: CACHE_DURATIONS.SIX_HOURS, - gcTime: CACHE_DURATIONS.TWELVE_HOURS, + staleTime: CACHE_DURATIONS.LOOKUP, + gcTime: CACHE_DURATIONS.LOOKUP_GC, } function SocSelector(props: Props) { diff --git a/src/app/v2/listings/V2ListingsPage.tsx b/src/app/v2/listings/V2ListingsPage.tsx index 663755a8b..7c9887376 100644 --- a/src/app/v2/listings/V2ListingsPage.tsx +++ b/src/app/v2/listings/V2ListingsPage.tsx @@ -25,8 +25,8 @@ type SortField = NonNullable type ListingType = RouterOutput['listings']['get']['listings'][number] const LOOKUP_DATA_QUERY_OPTIONS = { - staleTime: CACHE_DURATIONS.SIX_HOURS, - gcTime: CACHE_DURATIONS.TWELVE_HOURS, + staleTime: CACHE_DURATIONS.LOOKUP, + gcTime: CACHE_DURATIONS.LOOKUP_GC, } const USE_ASYNC_LISTING_FILTERS = process.env.NEXT_PUBLIC_ENABLE_ASYNC_LISTINGS_FILTERS === 'true' @@ -54,7 +54,7 @@ function V2ListingsPage() { const userQuery = api.users.me.useQuery() const userPreferencesQuery = api.userPreferences.get.useQuery(undefined, { enabled: !!userQuery.data, - staleTime: CACHE_DURATIONS.THIRTY_SECONDS, + staleTime: CACHE_DURATIONS.SHORT, gcTime: CACHE_DURATIONS.MEDIUM, }) diff --git a/src/components/retrocatalog/useRetroCatalogDevice.ts b/src/components/retrocatalog/useRetroCatalogDevice.ts index d719be559..d7db51cbc 100644 --- a/src/components/retrocatalog/useRetroCatalogDevice.ts +++ b/src/components/retrocatalog/useRetroCatalogDevice.ts @@ -58,8 +58,8 @@ export function useRetroCatalogDevice( queryKey: ['retrocatalog', options.brandName, options.modelName], queryFn: () => fetchRetroCatalogDevice(options.brandName, options.modelName), enabled: enabled && Boolean(options.brandName) && Boolean(options.modelName), - staleTime: CACHE_DURATIONS.ONE_DAY, - gcTime: CACHE_DURATIONS.TWO_DAYS, + staleTime: CACHE_DURATIONS.STATIC, + gcTime: CACHE_DURATIONS.STATIC_GC, retry: false, refetchOnWindowFocus: false, refetchOnReconnect: false, diff --git a/src/data/constants.ts b/src/data/constants.ts index 39c11ef72..dae33f726 100644 --- a/src/data/constants.ts +++ b/src/data/constants.ts @@ -34,19 +34,15 @@ export type PageSizeOption = (typeof PAGE_SIZE_OPTIONS)[number] // Cache durations in milliseconds export const CACHE_DURATIONS = { - TEN_SECONDS: ms.seconds(10), - THIRTY_SECONDS: ms.seconds(30), - SHORT: ms.minutes(1), - TWO_MINUTES: ms.minutes(2), + VERY_SHORT: ms.seconds(10), + SHORT: ms.seconds(30), MEDIUM: ms.minutes(5), - TEN_MINUTES: ms.minutes(10), LONG: ms.minutes(15), - THIRTY_MINUTES: ms.minutes(30), EXTRA_LONG: ms.hours(1), - SIX_HOURS: ms.hours(6), - TWELVE_HOURS: ms.hours(12), - ONE_DAY: ms.days(1), - TWO_DAYS: ms.days(2), + LOOKUP: ms.hours(6), + LOOKUP_GC: ms.hours(12), + STATIC: ms.days(1), + STATIC_GC: ms.days(2), } as const // Rate limiting diff --git a/src/lib/api.tsx b/src/lib/api.tsx index b537f4ec1..01b26b0a7 100644 --- a/src/lib/api.tsx +++ b/src/lib/api.tsx @@ -17,7 +17,7 @@ export function TRPCProvider(props: PropsWithChildren) { new QueryClient({ defaultOptions: { queries: { - staleTime: CACHE_DURATIONS.THIRTY_SECONDS, + staleTime: CACHE_DURATIONS.SHORT, gcTime: CACHE_DURATIONS.MEDIUM, refetchOnWindowFocus: false, refetchOnReconnect: false, diff --git a/src/server/repositories/devices.repository.ts b/src/server/repositories/devices.repository.ts index 06ffea60f..c9d9a2e8a 100644 --- a/src/server/repositories/devices.repository.ts +++ b/src/server/repositories/devices.repository.ts @@ -30,7 +30,7 @@ export interface TrendingDevicesSummary { } const trendingDevicesSummaryCache = new LRUCache({ - ttl: CACHE_DURATIONS.SIX_HOURS, + ttl: CACHE_DURATIONS.LOOKUP, max: 20, }) diff --git a/src/server/utils/cache/instances.ts b/src/server/utils/cache/instances.ts index 477dc3f55..91c6becfb 100644 --- a/src/server/utils/cache/instances.ts +++ b/src/server/utils/cache/instances.ts @@ -58,22 +58,22 @@ export const notificationAnalyticsCache = new LRUCache< clickRate: number }[] >({ - ttl: CACHE_DURATIONS.TEN_MINUTES, + ttl: CACHE_DURATIONS.LONG, max: 200, }) export const tgdbGamesCache = new LRUCache({ - ttl: CACHE_DURATIONS.TEN_MINUTES, + ttl: CACHE_DURATIONS.LONG, max: 200, }) export const tgdbImagesCache = new LRUCache({ - ttl: CACHE_DURATIONS.TEN_MINUTES, + ttl: CACHE_DURATIONS.LONG, max: 200, }) export const tgdbPlatformsCache = new LRUCache({ - ttl: CACHE_DURATIONS.TEN_MINUTES, + ttl: CACHE_DURATIONS.LONG, max: 10, }) @@ -84,27 +84,27 @@ export const tgdbImageUrlsCache = new LRUCache< bannerUrl?: string } >({ - ttl: CACHE_DURATIONS.TEN_MINUTES, + ttl: CACHE_DURATIONS.LONG, max: 500, }) export const tgdbGameImagesCache = new LRUCache>({ - ttl: CACHE_DURATIONS.TEN_MINUTES, + ttl: CACHE_DURATIONS.LONG, max: 100, }) export const driverVersionsCache = new LRUCache({ - ttl: CACHE_DURATIONS.THIRTY_MINUTES, + ttl: CACHE_DURATIONS.EXTRA_LONG, max: 1, }) export const steamBatchQueryCache = new LRUCache({ - ttl: CACHE_DURATIONS.TEN_MINUTES, + ttl: CACHE_DURATIONS.LONG, max: 100, }) export const catalogCompatibilityCache = new LRUCache({ - ttl: CACHE_DURATIONS.TEN_MINUTES, + ttl: CACHE_DURATIONS.LONG, max: 500, }) diff --git a/src/server/utils/driver-versions.ts b/src/server/utils/driver-versions.ts index f3316d654..08cd59029 100644 --- a/src/server/utils/driver-versions.ts +++ b/src/server/utils/driver-versions.ts @@ -144,7 +144,7 @@ export async function getDriverVersions(): Promise { releases, rateLimited: false, } - driverVersionsCache.set(CACHE_KEY, payload, { ttl: CACHE_DURATIONS.THIRTY_MINUTES }) + driverVersionsCache.set(CACHE_KEY, payload, { ttl: CACHE_DURATIONS.EXTRA_LONG }) return payload } catch (error) { if (isRateLimitError(error)) { @@ -164,7 +164,7 @@ export async function getDriverVersions(): Promise { rateLimited: false, errorMessage: 'Failed to fetch driver versions. Please try again later.', } - driverVersionsCache.set(CACHE_KEY, payload, { ttl: CACHE_DURATIONS.TWO_MINUTES }) + driverVersionsCache.set(CACHE_KEY, payload, { ttl: CACHE_DURATIONS.MEDIUM }) return payload } } diff --git a/src/server/utils/steamGameSearch.ts b/src/server/utils/steamGameSearch.ts index 58006bb2b..ae457caa1 100644 --- a/src/server/utils/steamGameSearch.ts +++ b/src/server/utils/steamGameSearch.ts @@ -28,12 +28,12 @@ interface CachedData { } const steamGamesDataCache = new LRUCache>({ - ttl: CACHE_DURATIONS.ONE_DAY, + ttl: CACHE_DURATIONS.STATIC, max: 1, }) const steamGamesFuseCache = new LRUCache>({ - ttl: CACHE_DURATIONS.ONE_DAY, + ttl: CACHE_DURATIONS.STATIC, max: 1, }) diff --git a/src/server/utils/switchGameSearch.ts b/src/server/utils/switchGameSearch.ts index 21439422a..fa260a6ab 100644 --- a/src/server/utils/switchGameSearch.ts +++ b/src/server/utils/switchGameSearch.ts @@ -21,12 +21,12 @@ interface CachedData { } const switchGamesDataCache = new LRUCache>({ - ttl: CACHE_DURATIONS.ONE_DAY, + ttl: CACHE_DURATIONS.STATIC, max: 1, }) const switchGamesFuseCache = new LRUCache>({ - ttl: CACHE_DURATIONS.ONE_DAY, + ttl: CACHE_DURATIONS.STATIC, max: 1, }) diff --git a/src/server/utils/threeDsGameSearch.ts b/src/server/utils/threeDsGameSearch.ts index acdca5367..7f8e110a6 100644 --- a/src/server/utils/threeDsGameSearch.ts +++ b/src/server/utils/threeDsGameSearch.ts @@ -79,12 +79,12 @@ const THREEDS_TITLES_URL = 'https://dantheman827.github.io/nus-info/titles.json' const THREEDS_TITLE_NAMES_URL = 'https://dantheman827.github.io/nus-info/title-names.json' const threeDsGamesDataCache = new LRUCache>({ - ttl: CACHE_DURATIONS.ONE_DAY, + ttl: CACHE_DURATIONS.STATIC, max: 1, }) const threeDsGamesFuseCache = new LRUCache>({ - ttl: CACHE_DURATIONS.ONE_DAY, + ttl: CACHE_DURATIONS.STATIC, max: 1, }) From 33f955519119c2b1833170d1baa03b921f157b56 Mon Sep 17 00:00:00 2001 From: Producdevity Date: Thu, 4 Jun 2026 20:26:43 +0200 Subject: [PATCH 21/87] fix: use report terminology for CPU admin page --- src/app/admin/cpus/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/admin/cpus/page.tsx b/src/app/admin/cpus/page.tsx index cc5a5f9d4..f07b1170d 100644 --- a/src/app/admin/cpus/page.tsx +++ b/src/app/admin/cpus/page.tsx @@ -139,7 +139,7 @@ function AdminCpusPage() { return ( From 688592cee6126732784ed4c027e6c0431d11f4eb Mon Sep 17 00:00:00 2001 From: Producdevity Date: Thu, 4 Jun 2026 20:31:23 +0200 Subject: [PATCH 22/87] test custom field template field-name search --- src/app/admin/custom-field-templates/page.test.tsx | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/app/admin/custom-field-templates/page.test.tsx b/src/app/admin/custom-field-templates/page.test.tsx index c2adfee98..a53234f0a 100644 --- a/src/app/admin/custom-field-templates/page.test.tsx +++ b/src/app/admin/custom-field-templates/page.test.tsx @@ -122,6 +122,17 @@ describe('CustomFieldTemplatesPage', () => { expect(screen.queryByText('Controls Template')).not.toBeInTheDocument() }) + it('filters templates by field names', () => { + render() + + fireEvent.change(screen.getByPlaceholderText('Search templates...'), { + target: { value: 'controllerlayout' }, + }) + + expect(screen.getByText('Controls Template')).toBeInTheDocument() + expect(screen.queryByText('Performance Template')).not.toBeInTheDocument() + }) + it('shows a search-specific empty state when no templates match', () => { render() From dd0d7d152e38a4ca0bb666c6ee0e7fe6e1472302 Mon Sep 17 00:00:00 2001 From: Producdevity Date: Thu, 4 Jun 2026 20:31:37 +0200 Subject: [PATCH 23/87] align tgdb platforms cache ttl --- src/server/utils/cache/instances.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/server/utils/cache/instances.ts b/src/server/utils/cache/instances.ts index 91c6becfb..87fe7e06c 100644 --- a/src/server/utils/cache/instances.ts +++ b/src/server/utils/cache/instances.ts @@ -73,7 +73,7 @@ export const tgdbImagesCache = new LRUCache({ }) export const tgdbPlatformsCache = new LRUCache({ - ttl: CACHE_DURATIONS.LONG, + ttl: CACHE_DURATIONS.EXTRA_LONG, max: 10, }) From 0c32abc414865f69de7f757c5d62c2f50bc8394e Mon Sep 17 00:00:00 2001 From: Producdevity Date: Thu, 4 Jun 2026 20:38:38 +0200 Subject: [PATCH 24/87] centralize refetch intervals --- src/app/admin/AdminLayoutClient.tsx | 10 +++++----- .../api-access/components/AdminApiAccessPanel.tsx | 6 +++--- .../api-access/components/DeveloperApiAccessPanel.tsx | 5 ++--- src/app/admin/components/ApprovalCountBadge.tsx | 8 ++++---- src/app/admin/pc-listing-approvals/page.tsx | 3 ++- src/app/listings/[id]/components/EditListingButton.tsx | 3 ++- .../[id]/components/EditPcListingButton.tsx | 3 ++- src/data/constants.ts | 5 ++++- 8 files changed, 24 insertions(+), 19 deletions(-) diff --git a/src/app/admin/AdminLayoutClient.tsx b/src/app/admin/AdminLayoutClient.tsx index 3cfa9d8b6..7abc09258 100644 --- a/src/app/admin/AdminLayoutClient.tsx +++ b/src/app/admin/AdminLayoutClient.tsx @@ -8,7 +8,7 @@ import { useEffect, useState, type PropsWithChildren } from 'react' import { isNumber } from 'remeda' import { ADMIN_ROUTES } from '@/app/admin/config/routes' import { LoadingSpinner } from '@/components/ui/LoadingSpinner' -import { CACHE_DURATIONS } from '@/data/constants' +import { CACHE_DURATIONS, POLLING_INTERVALS } from '@/data/constants' import { api } from '@/lib/api' import { cn } from '@/lib/utils' import { hasPermission, PERMISSIONS } from '@/utils/permission-system' @@ -40,7 +40,7 @@ export default function AdminLayoutClient(props: PropsWithChildren) { const gameStatsQuery = api.games.stats.useQuery(undefined, { enabled: !!userQuery.data && hasPermission(userQuery.data.permissions, PERMISSIONS.VIEW_STATISTICS), - refetchInterval: 30000, + refetchInterval: POLLING_INTERVALS.SHORT, staleTime: CACHE_DURATIONS.VERY_SHORT, refetchOnMount: true, refetchOnWindowFocus: true, @@ -49,7 +49,7 @@ export default function AdminLayoutClient(props: PropsWithChildren) { const listingStatsQuery = api.listings.stats.useQuery(undefined, { enabled: !!userQuery.data && hasPermission(userQuery.data.permissions, PERMISSIONS.VIEW_STATISTICS), - refetchInterval: 30000, + refetchInterval: POLLING_INTERVALS.SHORT, staleTime: CACHE_DURATIONS.VERY_SHORT, refetchOnMount: true, refetchOnWindowFocus: true, @@ -58,7 +58,7 @@ export default function AdminLayoutClient(props: PropsWithChildren) { const pcListingStatsQuery = api.pcListings.stats.useQuery(undefined, { enabled: !!userQuery.data && hasPermission(userQuery.data.permissions, PERMISSIONS.VIEW_STATISTICS), - refetchInterval: 30000, + refetchInterval: POLLING_INTERVALS.SHORT, staleTime: CACHE_DURATIONS.VERY_SHORT, refetchOnMount: true, refetchOnWindowFocus: true, @@ -68,7 +68,7 @@ export default function AdminLayoutClient(props: PropsWithChildren) { const reportsStatsQuery = api.listingReports.stats.useQuery(undefined, { enabled: !!userQuery.data && isSuperAdmin, - refetchInterval: 30000, + refetchInterval: POLLING_INTERVALS.SHORT, staleTime: CACHE_DURATIONS.VERY_SHORT, refetchOnMount: true, refetchOnWindowFocus: true, diff --git a/src/app/admin/api-access/components/AdminApiAccessPanel.tsx b/src/app/admin/api-access/components/AdminApiAccessPanel.tsx index 40da6903c..93ac884e4 100644 --- a/src/app/admin/api-access/components/AdminApiAccessPanel.tsx +++ b/src/app/admin/api-access/components/AdminApiAccessPanel.tsx @@ -2,6 +2,7 @@ import { useState } from 'react' import { useAdminTable } from '@/app/admin/hooks' import { AdminPageLayout, AdminSearchFilters, AdminStatsDisplay } from '@/components/admin' import { Button, Card, ColumnVisibilityControl, useConfirmDialog } from '@/components/ui' +import { POLLING_INTERVALS } from '@/data/constants' import storageKeys from '@/data/storageKeys' import { useColumnVisibility } from '@/hooks' import { type ColumnDefinition } from '@/hooks/useColumnVisibility' @@ -10,7 +11,6 @@ import toast from '@/lib/toast' import { type ApiKeySortField } from '@/schemas/apiAccess' import getErrorMessage from '@/utils/getErrorMessage' import { hasRolePermission } from '@/utils/permissions' -import { ms } from '@/utils/time' import { Role } from '@orm' import { AdminCreateKeyForm, type AdminCreateFormState } from './AdminCreateKeyForm' import { AdminKeyTable } from './AdminKeyTable' @@ -67,11 +67,11 @@ export function AdminApiAccessPanel(props: Props) { ) const statsQuery = api.apiKeys.adminStats.useQuery(undefined, { - refetchInterval: ms.minutes(5), + refetchInterval: POLLING_INTERVALS.LONG, }) const canManageSystemKeys = hasRolePermission(props.userRole, Role.SUPER_ADMIN) const systemKeysQuery = api.apiKeys.adminSystemKeys.useQuery(undefined, { - refetchInterval: ms.minutes(10), + refetchInterval: POLLING_INTERVALS.EXTRA_LONG, enabled: canManageSystemKeys, }) diff --git a/src/app/admin/api-access/components/DeveloperApiAccessPanel.tsx b/src/app/admin/api-access/components/DeveloperApiAccessPanel.tsx index 6daf4958e..5decda5c8 100644 --- a/src/app/admin/api-access/components/DeveloperApiAccessPanel.tsx +++ b/src/app/admin/api-access/components/DeveloperApiAccessPanel.tsx @@ -10,7 +10,7 @@ import { LoadingSpinner, useConfirmDialog, } from '@/components/ui' -import { API_KEY_LIMITS } from '@/data/constants' +import { API_KEY_LIMITS, POLLING_INTERVALS } from '@/data/constants' import storageKeys from '@/data/storageKeys' import { useColumnVisibility } from '@/hooks' import { type ColumnDefinition } from '@/hooks/useColumnVisibility' @@ -19,7 +19,6 @@ import toast from '@/lib/toast' import { type ApiKeySortField } from '@/schemas/apiAccess' import { formatters, getLocale } from '@/utils/date' import getErrorMessage from '@/utils/getErrorMessage' -import { ms } from '@/utils/time' import { ApiUsagePeriod } from '@orm' import { DeveloperKeyTable } from './DeveloperKeyTable' import { KeySecretBanner } from './KeySecretBanner' @@ -75,7 +74,7 @@ export function DeveloperApiAccessPanel(props: Props) { }, ) const statsQuery = api.apiKeys.myStats.useQuery(undefined, { - refetchInterval: ms.minutes(5), + refetchInterval: POLLING_INTERVALS.LONG, }) const keys = listQuery.data?.keys ?? EMPTY_KEY_ROWS diff --git a/src/app/admin/components/ApprovalCountBadge.tsx b/src/app/admin/components/ApprovalCountBadge.tsx index b1a5ceb94..ea46c2898 100644 --- a/src/app/admin/components/ApprovalCountBadge.tsx +++ b/src/app/admin/components/ApprovalCountBadge.tsx @@ -1,7 +1,7 @@ 'use client' import { Badge } from '@/components/ui' -import { CACHE_DURATIONS } from '@/data/constants' +import { CACHE_DURATIONS, POLLING_INTERVALS } from '@/data/constants' import { api } from '@/lib/api' import { cn } from '@/lib/utils' import { hasPermission, PERMISSIONS } from '@/utils/permission-system' @@ -30,7 +30,7 @@ export default function ApprovalCountBadge(props: Props) { const gameStatsQuery = api.games.stats.useQuery(undefined, { enabled: canViewStats && props.href === '/admin/games/approvals', - refetchInterval: 30000, + refetchInterval: POLLING_INTERVALS.SHORT, staleTime: CACHE_DURATIONS.VERY_SHORT, refetchOnMount: true, refetchOnWindowFocus: true, @@ -38,7 +38,7 @@ export default function ApprovalCountBadge(props: Props) { const listingStatsQuery = api.listings.stats.useQuery(undefined, { enabled: canViewStats && props.href === '/admin/approvals', - refetchInterval: 30000, + refetchInterval: POLLING_INTERVALS.SHORT, staleTime: CACHE_DURATIONS.VERY_SHORT, refetchOnMount: true, refetchOnWindowFocus: true, @@ -46,7 +46,7 @@ export default function ApprovalCountBadge(props: Props) { const pcListingStatsQuery = api.pcListings.stats.useQuery(undefined, { enabled: canViewStats && props.href === '/admin/pc-listing-approvals', - refetchInterval: 30000, + refetchInterval: POLLING_INTERVALS.SHORT, staleTime: CACHE_DURATIONS.VERY_SHORT, refetchOnMount: true, refetchOnWindowFocus: true, diff --git a/src/app/admin/pc-listing-approvals/page.tsx b/src/app/admin/pc-listing-approvals/page.tsx index dfc3cc42c..37322c9c6 100644 --- a/src/app/admin/pc-listing-approvals/page.tsx +++ b/src/app/admin/pc-listing-approvals/page.tsx @@ -43,6 +43,7 @@ import { useConfirmDialog, ViewUserButton, } from '@/components/ui' +import { POLLING_INTERVALS } from '@/data/constants' import storageKeys from '@/data/storageKeys' import { useEmulatorLogos, @@ -133,7 +134,7 @@ function PcListingApprovalsPage() { const gameStatsQuery = api.games.stats.useQuery() const pcListingsStatsQuery = api.pcListings.stats.useQuery(undefined, { - refetchInterval: 30000, + refetchInterval: POLLING_INTERVALS.SHORT, }) const approvalModal = useCompatibilityReportReviewDecisionModal() diff --git a/src/app/listings/[id]/components/EditListingButton.tsx b/src/app/listings/[id]/components/EditListingButton.tsx index 3d827a6f3..48fce7839 100644 --- a/src/app/listings/[id]/components/EditListingButton.tsx +++ b/src/app/listings/[id]/components/EditListingButton.tsx @@ -4,6 +4,7 @@ import { useUser } from '@clerk/nextjs' import { Edit3, Clock } from 'lucide-react' import { useState } from 'react' import { Button } from '@/components/ui' +import { POLLING_INTERVALS } from '@/data/constants' import { api } from '@/lib/api' import EditListingModal from './EditListingModal' @@ -20,7 +21,7 @@ function EditListingButton(props: Props) { { id: props.listingId }, { enabled: !!user?.id, - refetchInterval: 60000, // Refetch every minute to update time remaining + refetchInterval: POLLING_INTERVALS.MEDIUM, }, ) diff --git a/src/app/pc-listings/[id]/components/EditPcListingButton.tsx b/src/app/pc-listings/[id]/components/EditPcListingButton.tsx index a0644ab2a..30ed94e8f 100644 --- a/src/app/pc-listings/[id]/components/EditPcListingButton.tsx +++ b/src/app/pc-listings/[id]/components/EditPcListingButton.tsx @@ -4,6 +4,7 @@ import { useUser } from '@clerk/nextjs' import { Edit3, Clock } from 'lucide-react' import { useState } from 'react' import { Button } from '@/components/ui' +import { POLLING_INTERVALS } from '@/data/constants' import { api } from '@/lib/api' import EditPcListingModal from './EditPcListingModal' @@ -20,7 +21,7 @@ function EditPcListingButton(props: Props) { { id: props.pcListingId }, { enabled: !!user?.id, - refetchInterval: 60000, // Refetch every minute to update time remaining + refetchInterval: POLLING_INTERVALS.MEDIUM, }, ) diff --git a/src/data/constants.ts b/src/data/constants.ts index dae33f726..acfd7ba94 100644 --- a/src/data/constants.ts +++ b/src/data/constants.ts @@ -2,8 +2,11 @@ import { ms } from '@/utils/time' // Polling intervals in milliseconds export const POLLING_INTERVALS = { + SHORT: ms.seconds(30), + MEDIUM: ms.minutes(1), NOTIFICATIONS: ms.minutes(3), - DEFAULT: ms.seconds(30), + LONG: ms.minutes(5), + EXTRA_LONG: ms.minutes(10), } as const // Batch sizes for cursor-based iteration From 692466a50c940b9327e4804033153154ca32a80b Mon Sep 17 00:00:00 2001 From: Producdevity Date: Thu, 4 Jun 2026 20:41:15 +0200 Subject: [PATCH 25/87] align short duration bucket --- src/app/listings/[id]/components/EditListingButton.tsx | 2 +- src/app/pc-listings/[id]/components/EditPcListingButton.tsx | 2 +- src/data/constants.ts | 5 ++--- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/app/listings/[id]/components/EditListingButton.tsx b/src/app/listings/[id]/components/EditListingButton.tsx index 48fce7839..6136263dd 100644 --- a/src/app/listings/[id]/components/EditListingButton.tsx +++ b/src/app/listings/[id]/components/EditListingButton.tsx @@ -21,7 +21,7 @@ function EditListingButton(props: Props) { { id: props.listingId }, { enabled: !!user?.id, - refetchInterval: POLLING_INTERVALS.MEDIUM, + refetchInterval: POLLING_INTERVALS.SHORT, }, ) diff --git a/src/app/pc-listings/[id]/components/EditPcListingButton.tsx b/src/app/pc-listings/[id]/components/EditPcListingButton.tsx index 30ed94e8f..1871602c4 100644 --- a/src/app/pc-listings/[id]/components/EditPcListingButton.tsx +++ b/src/app/pc-listings/[id]/components/EditPcListingButton.tsx @@ -21,7 +21,7 @@ function EditPcListingButton(props: Props) { { id: props.pcListingId }, { enabled: !!user?.id, - refetchInterval: POLLING_INTERVALS.MEDIUM, + refetchInterval: POLLING_INTERVALS.SHORT, }, ) diff --git a/src/data/constants.ts b/src/data/constants.ts index acfd7ba94..6d83d8709 100644 --- a/src/data/constants.ts +++ b/src/data/constants.ts @@ -2,8 +2,7 @@ import { ms } from '@/utils/time' // Polling intervals in milliseconds export const POLLING_INTERVALS = { - SHORT: ms.seconds(30), - MEDIUM: ms.minutes(1), + SHORT: ms.minutes(1), NOTIFICATIONS: ms.minutes(3), LONG: ms.minutes(5), EXTRA_LONG: ms.minutes(10), @@ -38,7 +37,7 @@ export type PageSizeOption = (typeof PAGE_SIZE_OPTIONS)[number] // Cache durations in milliseconds export const CACHE_DURATIONS = { VERY_SHORT: ms.seconds(10), - SHORT: ms.seconds(30), + SHORT: ms.minutes(1), MEDIUM: ms.minutes(5), LONG: ms.minutes(15), EXTRA_LONG: ms.hours(1), From 4680a9382b0c4d2af2918b7970fc183fee586ca4 Mon Sep 17 00:00:00 2001 From: Producdevity Date: Thu, 4 Jun 2026 20:42:35 +0200 Subject: [PATCH 26/87] refactor: use shared admin stats for trust logs --- .../components/TrustStatsOverview.tsx | 60 ------------------- src/app/admin/trust-logs/page.tsx | 29 ++++++++- src/components/admin/AdminStatsDisplay.tsx | 2 +- src/lib/dynamic-imports.tsx | 5 -- 4 files changed, 27 insertions(+), 69 deletions(-) delete mode 100644 src/app/admin/trust-logs/components/TrustStatsOverview.tsx diff --git a/src/app/admin/trust-logs/components/TrustStatsOverview.tsx b/src/app/admin/trust-logs/components/TrustStatsOverview.tsx deleted file mode 100644 index aa7b32ead..000000000 --- a/src/app/admin/trust-logs/components/TrustStatsOverview.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import { Shield, TrendingUp, Users } from 'lucide-react' -import type { RouterOutput } from '@/types/trpc' - -type TrustStats = NonNullable - -interface Props { - trustStatsData: TrustStats -} - -function TrustStatsOverview(props: Props) { - return ( -
-
-
-
- -
-
-

Total Actions

-

- {props.trustStatsData.totalActions} -

-
-
-
- -
-
-
- -
-
-

Total Users

-

- {props.trustStatsData.totalUsers} -

-
-
-
- -
-
-
- -
-
-

Trusted+ Users

-

- {props.trustStatsData.levelDistribution - ?.filter((level) => level.minScore >= 250) - ?.reduce((sum, level) => sum + level.count, 0) ?? 0} -

-
-
-
-
- ) -} - -export default TrustStatsOverview diff --git a/src/app/admin/trust-logs/page.tsx b/src/app/admin/trust-logs/page.tsx index 35033b6d4..f35b166eb 100644 --- a/src/app/admin/trust-logs/page.tsx +++ b/src/app/admin/trust-logs/page.tsx @@ -8,6 +8,7 @@ import { useAdminTable } from '@/app/admin/hooks' import { AdminErrorState, AdminPageLayout, + AdminStatsDisplay, AdminTableContainer, AdminTableNoResults, } from '@/components/admin' @@ -25,7 +26,6 @@ import { import storageKeys from '@/data/storageKeys' import { useColumnVisibility, type ColumnDefinition } from '@/hooks' import { api } from '@/lib/api' -import { TrustStatsOverview } from '@/lib/dynamic-imports' import toast from '@/lib/toast' import { TRUST_ACTIONS } from '@/lib/trust/config' import { type RouterOutput } from '@/types/trpc' @@ -67,6 +67,11 @@ function AdminTrustLogsPage() { }) const trustStatsQuery = api.trust.getTrustStats.useQuery({}) + const trustedPlusUsers = trustStatsQuery.data + ? (trustStatsQuery.data.levelDistribution + ?.filter((level) => level.minScore >= 250) + .reduce((sum, level) => sum + level.count, 0) ?? 0) + : undefined const runMonthlyBonusMutation = api.trust.runMonthlyActiveBonus.useMutation({ onSuccess: (result) => { @@ -135,8 +140,26 @@ function AdminTrustLogsPage() { } > - {/*TODO: check if we can use AdminStatsDisplay */} - {trustStatsQuery.data && } + {/* Search and Filters */}
diff --git a/src/components/admin/AdminStatsDisplay.tsx b/src/components/admin/AdminStatsDisplay.tsx index 3124edd73..6ecd4a2e4 100644 --- a/src/components/admin/AdminStatsDisplay.tsx +++ b/src/components/admin/AdminStatsDisplay.tsx @@ -69,7 +69,7 @@ export function AdminStatsDisplay(props: Props) { className={cn('text-2xl font-bold', colorClasses[stat.color] || colorClasses.gray)} title={stat.description} > - {stat.value?.toLocaleString() || '...'} + {stat.value != null ? stat.value.toLocaleString() : '...'}
{stat.label}
diff --git a/src/lib/dynamic-imports.tsx b/src/lib/dynamic-imports.tsx index 09cb259af..eeb137fe0 100644 --- a/src/lib/dynamic-imports.tsx +++ b/src/lib/dynamic-imports.tsx @@ -34,8 +34,3 @@ export const RolePermissionMatrix = dynamic( () => import('@/app/admin/permissions/components/RolePermissionMatrix'), { loading: LoadingFallback }, ) - -export const TrustStatsOverview = dynamic( - () => import('@/app/admin/trust-logs/components/TrustStatsOverview'), - { loading: LoadingFallback }, -) From ac117c1f0c972b718ff90df9825c5c3586e537d2 Mon Sep 17 00:00:00 2001 From: Producdevity Date: Thu, 4 Jun 2026 20:57:47 +0200 Subject: [PATCH 27/87] fix: restore admin stats fallback --- src/components/admin/AdminStatsDisplay.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/admin/AdminStatsDisplay.tsx b/src/components/admin/AdminStatsDisplay.tsx index 6ecd4a2e4..3124edd73 100644 --- a/src/components/admin/AdminStatsDisplay.tsx +++ b/src/components/admin/AdminStatsDisplay.tsx @@ -69,7 +69,7 @@ export function AdminStatsDisplay(props: Props) { className={cn('text-2xl font-bold', colorClasses[stat.color] || colorClasses.gray)} title={stat.description} > - {stat.value != null ? stat.value.toLocaleString() : '...'} + {stat.value?.toLocaleString() || '...'}
{stat.label}
From 5fbfa797eb6eac3e5ba0cda6d79e65ac08243cf4 Mon Sep 17 00:00:00 2001 From: Producdevity Date: Thu, 4 Jun 2026 21:14:08 +0200 Subject: [PATCH 28/87] refactor admin empty states --- src/app/admin/audit-logs/page.tsx | 15 +++---- src/app/admin/badges/page.tsx | 11 ++--- src/app/admin/brands/page.tsx | 20 +++++---- src/app/admin/cpus/page.tsx | 13 +++--- src/app/admin/custom-field-templates/page.tsx | 41 +++++++++---------- src/app/admin/devices/page.tsx | 14 +++---- src/app/admin/games/approvals/page.tsx | 13 +++--- src/app/admin/games/page.tsx | 13 +++--- src/app/admin/listings/page.tsx | 20 +++++---- src/app/admin/performance/page.tsx | 13 +++--- src/app/admin/reports/page.tsx | 13 +++--- src/app/admin/title-id-tools/TitleIdTool.tsx | 13 +++--- .../components/BatchSteamLookup.tsx | 13 +++--- src/app/admin/user-bans/page.tsx | 13 +++--- src/app/admin/verified-developers/page.tsx | 23 +++++------ src/components/admin/AdminTableNoResults.tsx | 19 +++++++-- 16 files changed, 139 insertions(+), 128 deletions(-) diff --git a/src/app/admin/audit-logs/page.tsx b/src/app/admin/audit-logs/page.tsx index b36aaaffc..eb4922707 100644 --- a/src/app/admin/audit-logs/page.tsx +++ b/src/app/admin/audit-logs/page.tsx @@ -10,6 +10,7 @@ import { AdminStatsDisplay, AdminSearchFilters, AdminTableContainer, + AdminTableNoResults, } from '@/components/admin' import { ColumnVisibilityControl, @@ -229,13 +230,13 @@ function AdminAuditLogsPage() { {logs.length === 0 ? ( -
-

- {table.search || selectedAction || selectedEntity || dateFrom || dateTo - ? 'No audit logs found matching your criteria.' - : 'No audit logs found.'} -

-
+ ) : (
diff --git a/src/app/admin/badges/page.tsx b/src/app/admin/badges/page.tsx index d43c25550..d3af209b8 100644 --- a/src/app/admin/badges/page.tsx +++ b/src/app/admin/badges/page.tsx @@ -8,6 +8,7 @@ import { AdminTableContainer, AdminStatsDisplay, AdminSearchFilters, + AdminTableNoResults, } from '@/components/admin' import { Button, @@ -240,11 +241,11 @@ export default function AdminBadgesPage() { {badgesQuery.isPending ? ( ) : badges.length === 0 ? ( -
-

- {table.search ? 'No badges found matching your search.' : 'No badges created yet.'} -

-
+ ) : ( <>
diff --git a/src/app/admin/brands/page.tsx b/src/app/admin/brands/page.tsx index ee3428ecc..bd106d97d 100644 --- a/src/app/admin/brands/page.tsx +++ b/src/app/admin/brands/page.tsx @@ -3,7 +3,12 @@ import { useState } from 'react' import { isEmpty } from 'remeda' import { useAdminTable } from '@/app/admin/hooks' -import { AdminTableContainer, AdminSearchFilters, AdminStatsDisplay } from '@/components/admin' +import { + AdminTableContainer, + AdminSearchFilters, + AdminStatsDisplay, + AdminTableNoResults, +} from '@/components/admin' import { Button, ColumnVisibilityControl, @@ -214,13 +219,12 @@ function AdminBrandsPage() { ))} {!brandsQuery.isPending && brandsQuery.data?.length === 0 && (
- )} diff --git a/src/app/admin/cpus/page.tsx b/src/app/admin/cpus/page.tsx index 2c5b7f61c..eaf462096 100644 --- a/src/app/admin/cpus/page.tsx +++ b/src/app/admin/cpus/page.tsx @@ -278,13 +278,12 @@ function AdminCpusPage() { ))} {!cpusQuery.isPending && cpusQuery.data?.cpus.length === 0 && ( - )} diff --git a/src/app/admin/custom-field-templates/page.tsx b/src/app/admin/custom-field-templates/page.tsx index d89d491ad..86282aac9 100644 --- a/src/app/admin/custom-field-templates/page.tsx +++ b/src/app/admin/custom-field-templates/page.tsx @@ -3,7 +3,12 @@ import { PlusCircle } from 'lucide-react' import { useMemo, useState } from 'react' import { useAdminTable } from '@/app/admin/hooks' -import { AdminPageLayout, AdminSearchFilters, AdminStatsDisplay } from '@/components/admin' +import { + AdminPageLayout, + AdminSearchFilters, + AdminStatsDisplay, + AdminTableNoResults, +} from '@/components/admin' import { Button, LoadingSpinner } from '@/components/ui' import { api } from '@/lib/api' import { type RouterOutput } from '@/types/trpc' @@ -112,27 +117,21 @@ function CustomFieldTemplatesPage() { onEdit={handleOpenEditModal} onDeleteSuccess={customFieldTemplatesQuery.refetch} /> - ) : hasActiveSearch ? ( -
-

- No custom field templates match your search. -

-

- Try a different template name, description, or field label. -

-
) : ( -
-

- No custom field templates created yet. -

-

- Create your first template to get started. -

- -
+ + Create Your First Template + + ) : undefined + } + /> )} - )} diff --git a/src/app/admin/games/approvals/page.tsx b/src/app/admin/games/approvals/page.tsx index aa1c8011c..a7fcc896b 100644 --- a/src/app/admin/games/approvals/page.tsx +++ b/src/app/admin/games/approvals/page.tsx @@ -11,6 +11,7 @@ import { AdminTableContainer, AdminStatsDisplay, AdminSearchFilters, + AdminTableNoResults, } from '@/components/admin' import { SystemIcon } from '@/components/icons' import { @@ -331,13 +332,11 @@ function GameApprovalsPage() { {pendingGamesQuery.isPending ? ( ) : filteredGames.length === 0 ? ( -
-

- {table.search - ? 'No games found matching your search.' - : 'No pending games to review.'} -

-
+ ) : ( <>
diff --git a/src/app/admin/games/page.tsx b/src/app/admin/games/page.tsx index 8967f4d27..d86a889f3 100644 --- a/src/app/admin/games/page.tsx +++ b/src/app/admin/games/page.tsx @@ -14,6 +14,7 @@ import { AdminStatsDisplay, AdminTableContainer, AdminSearchFilters, + AdminTableNoResults, } from '@/components/admin' import { ApprovalStatusBadge, @@ -277,13 +278,11 @@ function AdminGamesPage() {
) : gamesQuery.data?.games.length === 0 ? ( -
-

- {table.search || filters.systemId || filters.status - ? 'No games found matching your criteria.' - : 'No games found.'} -

-
+ ) : ( <>
diff --git a/src/app/admin/listings/page.tsx b/src/app/admin/listings/page.tsx index 199830e93..f63d7e14b 100644 --- a/src/app/admin/listings/page.tsx +++ b/src/app/admin/listings/page.tsx @@ -11,6 +11,7 @@ import { AdminTableContainer, AdminStatsDisplay, AdminSearchFilters, + AdminTableNoResults, } from '@/components/admin' import { EmulatorIcon, SystemIcon } from '@/components/icons' import { @@ -366,14 +367,17 @@ function AdminListingsPage() { ) : listings.length === 0 ? (
- ) : ( diff --git a/src/app/admin/performance/page.tsx b/src/app/admin/performance/page.tsx index 48dd9b0d8..23693b341 100644 --- a/src/app/admin/performance/page.tsx +++ b/src/app/admin/performance/page.tsx @@ -7,6 +7,7 @@ import { AdminStatsDisplay, AdminSearchFilters, AdminTableContainer, + AdminTableNoResults, } from '@/components/admin' import { Button, @@ -146,13 +147,11 @@ function AdminPerformancePage() { {performanceScales.length === 0 ? ( -
-

- {table.search - ? 'No performance scales found matching your search.' - : 'No performance scales found.'} -

-
+ ) : (
- {table.search - ? 'No brands found matching your search.' - : 'No brands found. Add your first brand.'} + +
- {table.search || table.additionalParams.brandId - ? 'No CPUs found matching your search.' - : 'No CPUs found. Add your first CPU.'} + +
- {table.search || table.additionalParams.brandId - ? 'No devices found matching your search.' - : 'No devices found. Add your first device.'} + +
-
-

- {table.search || filters.status || filters.systemId || filters.emulatorId - ? 'No compatibility reports found matching your filters.' - : 'No compatibility reports found.'} -

-
+
+
diff --git a/src/app/admin/reports/page.tsx b/src/app/admin/reports/page.tsx index a40acba78..4345ee907 100644 --- a/src/app/admin/reports/page.tsx +++ b/src/app/admin/reports/page.tsx @@ -8,6 +8,7 @@ import { AdminStatsDisplay, AdminSearchFilters, AdminTableContainer, + AdminTableNoResults, } from '@/components/admin' import { ColumnVisibilityControl, @@ -262,13 +263,11 @@ function AdminReportsPage() { {reports.length === 0 ? ( -
-

- {table.search || selectedReason || selectedStatus - ? 'No reports found matching your criteria.' - : 'No reports found.'} -

-
+ ) : (
diff --git a/src/app/admin/title-id-tools/TitleIdTool.tsx b/src/app/admin/title-id-tools/TitleIdTool.tsx index 177f9c0cc..2bce85752 100644 --- a/src/app/admin/title-id-tools/TitleIdTool.tsx +++ b/src/app/admin/title-id-tools/TitleIdTool.tsx @@ -1,6 +1,7 @@ 'use client' import { type FormEvent, useEffect, useMemo, useState } from 'react' +import { AdminTableNoResults } from '@/components/admin' import { Button } from '@/components/ui/Button' import { Card } from '@/components/ui/Card' import { Dropdown } from '@/components/ui/Dropdown' @@ -227,13 +228,11 @@ function TitleIdTool() { ) : latestResults.length === 0 ? ( -
-

- {searchMutation.data - ? 'No matching titles were found for the provided query.' - : 'Run a search to see title IDs and scoring details.'} -

-
+ ) : (
{bestMatch && } diff --git a/src/app/admin/title-id-tools/components/BatchSteamLookup.tsx b/src/app/admin/title-id-tools/components/BatchSteamLookup.tsx index 99dac882c..f9a513f81 100644 --- a/src/app/admin/title-id-tools/components/BatchSteamLookup.tsx +++ b/src/app/admin/title-id-tools/components/BatchSteamLookup.tsx @@ -6,6 +6,7 @@ import { type FormEvent, useState, useMemo } from 'react' import { PrismLight as SyntaxHighlighter } from 'react-syntax-highlighter' import json from 'react-syntax-highlighter/dist/esm/languages/prism/json' import { solarizedDarkAtom, solarizedlight } from 'react-syntax-highlighter/dist/esm/styles/prism' +import { AdminTableNoResults } from '@/components/admin' import { Button } from '@/components/ui/Button' import { Card } from '@/components/ui/Card' import { Input } from '@/components/ui/form/Input' @@ -293,13 +294,11 @@ export function BatchSteamLookup() {
) : results.length === 0 ? ( -
-

- {batchLookupQuery.data - ? 'No results to display.' - : 'Enter Steam App IDs and click lookup to see results.'} -

-
+ ) : (
diff --git a/src/app/admin/user-bans/page.tsx b/src/app/admin/user-bans/page.tsx index d1a6e1729..99f394512 100644 --- a/src/app/admin/user-bans/page.tsx +++ b/src/app/admin/user-bans/page.tsx @@ -9,6 +9,7 @@ import { AdminStatsDisplay, AdminSearchFilters, AdminTableContainer, + AdminTableNoResults, } from '@/components/admin' import { Button, @@ -245,13 +246,11 @@ function AdminUserBansPage() { {bans.length === 0 ? ( -
-

- {table.search || selectedStatus !== '' - ? 'No bans found matching your criteria.' - : 'No bans found.'} -

-
+ ) : (
diff --git a/src/app/admin/verified-developers/page.tsx b/src/app/admin/verified-developers/page.tsx index 24ab6cebc..5e6062a38 100644 --- a/src/app/admin/verified-developers/page.tsx +++ b/src/app/admin/verified-developers/page.tsx @@ -9,6 +9,7 @@ import { AdminTableContainer, AdminStatsDisplay, AdminSearchFilters, + AdminTableNoResults, } from '@/components/admin' import { EmulatorIcon } from '@/components/icons' import { @@ -353,19 +354,15 @@ function AdminVerifiedDevelopersPage() { ))} {verifiedDevelopersQuery.data?.verifiedDevelopers.length === 0 && ( - )} diff --git a/src/components/admin/AdminTableNoResults.tsx b/src/components/admin/AdminTableNoResults.tsx index 16b2bbd06..ff9dd1519 100644 --- a/src/components/admin/AdminTableNoResults.tsx +++ b/src/components/admin/AdminTableNoResults.tsx @@ -1,18 +1,31 @@ import { BookDashed, type LucideIcon } from 'lucide-react' +import type { ReactNode } from 'react' interface Props { icon?: LucideIcon hasQuery: boolean + title?: string + queryTitle?: string + description?: string + queryDescription?: string + action?: ReactNode } export function AdminTableNoResults(props: Props) { const Icon = props.icon || BookDashed + const title = props.hasQuery + ? (props.queryTitle ?? 'No results found matching your search criteria.') + : (props.title ?? 'No results.') + const description = props.hasQuery ? props.queryDescription : props.description + return (
-

- {props.hasQuery ? 'No results found matching your search criteria.' : 'No results.'} -

+

{title}

+ {description && ( +

{description}

+ )} + {props.action &&
{props.action}
}
) } From 426dd1426f97c5a302d274114a3be961061b3dcc Mon Sep 17 00:00:00 2001 From: Producdevity Date: Thu, 4 Jun 2026 21:18:25 +0200 Subject: [PATCH 29/87] chore: address admin profile ui todos --- prisma/seed.ts | 2 +- prisma/seeders/usersSeeder.ts | 69 ++++++++++++++++++- .../components/ReplacementSelectionModal.tsx | 13 ++-- src/app/admin/performance/page.tsx | 20 +++++- src/app/admin/performance/types.ts | 4 ++ .../admin/users/components/UserBadgeModal.tsx | 1 - .../components/DeviceAndSocPreferences.tsx | 8 +-- .../connections/SocialConnectionList.tsx | 24 ++++++- src/schemas/performanceScale.ts | 10 ++- src/server/api/routers/performanceScales.ts | 7 +- .../performance-scales.repository.ts | 58 ++++++++++++++-- .../emulator-config/eden/eden.defaults.ts | 1 - 12 files changed, 190 insertions(+), 27 deletions(-) diff --git a/prisma/seed.ts b/prisma/seed.ts index c6febfb04..dd860ded1 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -203,9 +203,9 @@ async function main() { await permissionsSeeder(prisma) // Seed permissions first await performanceScalesSeeder(prisma) await systemsSeeder(prisma) + await emulatorsSeeder(prisma) await usersSeeder(prisma) await userModerationFixturesSeeder(prisma) - await emulatorsSeeder(prisma) await azaharCustomFieldsSeeder(prisma) await edenCustomFieldsSeeder(prisma) await gamenativeCustomFieldsSeeder(prisma) diff --git a/prisma/seeders/usersSeeder.ts b/prisma/seeders/usersSeeder.ts index 6831e5992..d2d694951 100644 --- a/prisma/seeders/usersSeeder.ts +++ b/prisma/seeders/usersSeeder.ts @@ -36,7 +36,6 @@ const users: UserData[] = [ username: 'moderator', role: Role.MODERATOR, }, - // TODO: Assign emulators to Developer User { email: 'developer@emuready.com', name: 'Developer User', @@ -64,11 +63,27 @@ const users: UserData[] = [ ] const DEFAULT_SEED_PASSWORD = 'DevPassword123!' +const DEVELOPER_SEED_EMAIL = 'developer@emuready.com' +const SUPER_ADMIN_SEED_EMAIL = 'superadmin@emuready.com' async function cleanupExistingUsers(prisma: PrismaClient) { console.info('🧹 Cleaning up existing seed users...') const clerk = await clerkClient() + const seedUserEmails = users.map((user) => user.email) + const seedUsers = await prisma.user.findMany({ + where: { email: { in: seedUserEmails } }, + select: { id: true }, + }) + + if (seedUsers.length > 0) { + const seedUserIds = seedUsers.map((user) => user.id) + await prisma.verifiedDeveloper.deleteMany({ + where: { + OR: [{ userId: { in: seedUserIds } }, { verifiedBy: { in: seedUserIds } }], + }, + }) + } for (const userData of users) { try { @@ -92,6 +107,56 @@ async function cleanupExistingUsers(prisma: PrismaClient) { console.info('✅ Cleanup completed') } +async function assignDeveloperEmulators(prisma: PrismaClient) { + const [developerUser, verifierUser, emulators] = await Promise.all([ + prisma.user.findUnique({ + where: { email: DEVELOPER_SEED_EMAIL }, + select: { id: true }, + }), + prisma.user.findUnique({ + where: { email: SUPER_ADMIN_SEED_EMAIL }, + select: { id: true }, + }), + prisma.emulator.findMany({ select: { id: true } }), + ]) + + if (!developerUser) { + throw new Error('Expected seeded developer user to exist before assigning emulators') + } + + if (emulators.length === 0) { + console.info('ℹ️ No emulators found to assign to the developer seed user.') + return + } + + const verifierId = verifierUser?.id ?? developerUser.id + + await prisma.$transaction( + emulators.map((emulator) => + prisma.verifiedDeveloper.upsert({ + where: { + userId_emulatorId: { + userId: developerUser.id, + emulatorId: emulator.id, + }, + }, + update: { + verifiedBy: verifierId, + notes: 'Seeded developer emulator access', + }, + create: { + userId: developerUser.id, + emulatorId: emulator.id, + verifiedBy: verifierId, + notes: 'Seeded developer emulator access', + }, + }), + ), + ) + + console.info(`✅ Assigned ${emulators.length} emulator(s) to the developer seed user`) +} + async function usersSeeder(prisma: PrismaClient, shouldCleanup = false) { if (shouldCleanup) { await cleanupExistingUsers(prisma) @@ -166,6 +231,8 @@ async function usersSeeder(prisma: PrismaClient, shouldCleanup = false) { throw new Error(`Failed to seed users: ${failedUsers.join(', ')}`) } + await assignDeveloperEmulators(prisma) + console.info('✅ Users seeding completed') console.info('📝 You can now log in with any of these accounts using the default password.') console.warn('⚠️ Note: Make sure your webhooks are configured for production environments.') diff --git a/src/app/admin/performance/components/ReplacementSelectionModal.tsx b/src/app/admin/performance/components/ReplacementSelectionModal.tsx index 58a3ebf57..d6aea305a 100644 --- a/src/app/admin/performance/components/ReplacementSelectionModal.tsx +++ b/src/app/admin/performance/components/ReplacementSelectionModal.tsx @@ -33,15 +33,14 @@ function ReplacementSelectionModal(props: Props) { ) const handleDelete = async () => { - if (!props.scaleToDelete || !selectedReplacementId) return + if (!props.scaleToDelete || selectedReplacementId === null) return setError('') try { - // For now, we'll use the regular delete since the replacement functionality - // isn't implemented in the backend yet. This is marked as TODO. await deletePerformanceScale.mutateAsync({ id: props.scaleToDelete.id, + replacementId: selectedReplacementId, } satisfies RouterInput['performanceScales']['delete']) } catch (err) { setError(getErrorMessage(err, 'Failed to delete performance scale.')) @@ -96,8 +95,10 @@ function ReplacementSelectionModal(props: Props) { - - - )}
-
- -

No verified developers found.

-

- {table.search || emulatorFilter - ? 'Try adjusting your search or filters.' - : 'Add your first verified developer.'} -

-
+
+
- -
)} From 3e9053919583d95cb0badfc7845710823b085266 Mon Sep 17 00:00:00 2001 From: Producdevity Date: Fri, 5 Jun 2026 01:10:25 +0200 Subject: [PATCH 31/87] Refine admin profile PR feedback --- prisma/seed.ts | 4 +- .../admin/users/components/UserBadgeModal.tsx | 5 -- .../components/DeviceAndSocPreferences.tsx | 17 +------ .../components/connections/BlockedList.tsx | 2 + .../components/connections/FollowersList.tsx | 4 ++ .../components/connections/FriendsList.tsx | 2 + .../connections/SocialConnectionList.tsx | 44 +++++++++++++----- src/schemas/performanceScale.ts | 1 - .../performance-scales.repository.ts | 46 ------------------- .../emulator-config/eden/eden.defaults.ts | 1 - 10 files changed, 44 insertions(+), 82 deletions(-) diff --git a/prisma/seed.ts b/prisma/seed.ts index dd860ded1..3c287c8e1 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -190,7 +190,6 @@ async function main() { console.warn('🗑️ Clearing database...') console.warn('I hope you know what you are doing 😅') - // Clear all data in the correct order (children before parents) await clearDb() console.info('✅ Database cleared!') @@ -199,8 +198,7 @@ async function main() { console.info('🌱 Starting database seed...') try { - // Seed in order of dependencies - await permissionsSeeder(prisma) // Seed permissions first + await permissionsSeeder(prisma) await performanceScalesSeeder(prisma) await systemsSeeder(prisma) await emulatorsSeeder(prisma) diff --git a/src/app/admin/users/components/UserBadgeModal.tsx b/src/app/admin/users/components/UserBadgeModal.tsx index 108d0fcad..5f4878fc1 100644 --- a/src/app/admin/users/components/UserBadgeModal.tsx +++ b/src/app/admin/users/components/UserBadgeModal.tsx @@ -34,19 +34,16 @@ export default function UserBadgeModal(props: Props) { const [selectedBadgeId, setSelectedBadgeId] = useState(null) const [selectedColor, setSelectedColor] = useState('blue') - // Fetch all active badges const badgesQuery = api.badges.get.useQuery( { isActive: true, limit: 100 }, { enabled: props.isOpen }, ) - // Fetch user's current badges const userBadgesQuery = api.users.getUserById.useQuery( { userId: props.user?.id ?? '' }, { enabled: props.isOpen && Boolean(props.user?.id) }, ) - // Badge assignment mutations const assignBadgeMutation = api.badges.assignToUser.useMutation({ onSuccess: () => { toast.success('Badge assigned successfully') @@ -106,7 +103,6 @@ export default function UserBadgeModal(props: Props) {
- {/* Current Badges */}

Current Badges ({userBadges.length}) @@ -158,7 +154,6 @@ export default function UserBadgeModal(props: Props) { )}

- {/* Assign New Badge */} {availableBadges.length > 0 && (

diff --git a/src/app/profile/components/DeviceAndSocPreferences.tsx b/src/app/profile/components/DeviceAndSocPreferences.tsx index f5b9fedf5..ee3fc973a 100644 --- a/src/app/profile/components/DeviceAndSocPreferences.tsx +++ b/src/app/profile/components/DeviceAndSocPreferences.tsx @@ -72,9 +72,6 @@ function DeviceAndSocPreferences(props: Props) { }, }) - /** - * Invalidate all user preferences related queries. - */ const invalidatePreferences = () => { Promise.all([ utils.userPreferences.get.invalidate(), @@ -90,11 +87,9 @@ function DeviceAndSocPreferences(props: Props) { updatePreferences.mutate({ [key]: value }) } - // Debounce refs to prevent excessive API calls const deviceTimeoutRef = useRef(null) const socTimeoutRef = useRef(null) - // Cleanup timeouts on unmount useEffect(() => { return () => { if (deviceTimeoutRef.current) { @@ -117,15 +112,13 @@ function DeviceAndSocPreferences(props: Props) { ) => { const deviceIds = devices.map((device) => device.id) - // Clear existing timeout if (deviceTimeoutRef.current) { clearTimeout(deviceTimeoutRef.current) } - // Set new timeout for debounced update deviceTimeoutRef.current = setTimeout(() => { bulkUpdateDevices.mutate({ deviceIds }) - }, 500) // 500ms debounce + }, 500) }, [bulkUpdateDevices], ) @@ -134,15 +127,13 @@ function DeviceAndSocPreferences(props: Props) { (socs: typeof selectedSocs) => { const socIds = socs.map((soc) => soc.id) - // Clear existing timeout if (socTimeoutRef.current) { clearTimeout(socTimeoutRef.current) } - // Set new timeout for debounced update socTimeoutRef.current = setTimeout(() => { bulkUpdateSocs.mutate({ socIds }) - }, 500) // 500ms debounce + }, 500) }, [bulkUpdateSocs], ) @@ -177,13 +168,11 @@ function DeviceAndSocPreferences(props: Props) { const { data: preferences } = props.preferencesQuery - // Map the data structure to what the selectors expect const selectedDevices = preferences.devicePreferences?.map((pref) => pref.device) || [] const selectedSocs = preferences.socPreferences?.map((pref) => pref.soc) || [] return (
- {/* Listing Filters */}
@@ -245,7 +234,6 @@ function DeviceAndSocPreferences(props: Props) {
- {/* Device Preferences */}
@@ -262,7 +250,6 @@ function DeviceAndSocPreferences(props: Props) {
- {/* SOC Preferences */}
diff --git a/src/app/profile/components/connections/BlockedList.tsx b/src/app/profile/components/connections/BlockedList.tsx index 553c2d2f5..cc79e8c15 100644 --- a/src/app/profile/components/connections/BlockedList.tsx +++ b/src/app/profile/components/connections/BlockedList.tsx @@ -51,6 +51,8 @@ function BlockedList(props: Props) { pagination={query.data?.pagination} onPageChange={props.onPageChange} emptyMessage="No blocked users" + actionSkeletonClassName="w-24" + skeletonRows={props.limit} renderAction={(user) => ( + +
+ + +
-
+
{selectedService === imageServiceMap.rawg ? 'Using RAWG.io for game images' - : 'Using TheGamesDB for game images'} + : selectedService === imageServiceMap.tgdb + ? 'Using TheGamesDB for game images' + : 'Using IGDB for comprehensive game media'}
-
-
- {selectedService === imageServiceMap.rawg - ? 'RAWG.io provides comprehensive game data with screenshots and backgrounds' - : 'TheGamesDB offers high-quality boxart and game media from the community'} +
+ {selectedService === imageServiceMap.rawg && + 'RAWG.io provides comprehensive game data with screenshots and backgrounds'} + {selectedService === imageServiceMap.tgdb && + 'TheGamesDB offers high-quality boxart and game media from the community'} + {selectedService === imageServiceMap.igdb && + 'IGDB provides rich media including covers, artworks, and screenshots with detailed metadata'} +
- {/* Animated Image Selector */}
- + {selectedService === imageServiceMap.rawg ? ( + ) : selectedService === imageServiceMap.igdb ? ( + + + ) : ( + // Admin table URL parameters export const AdminTableParamsSchema = z.object({ search: z.string().default(''), page: z.number().int().positive().default(1), sortField: z.string().nullable().default(null), - sortDirection: z.enum(['asc', 'desc']).nullable().default(null), // TODO: extract + sortDirection: SortDirection.nullable().default(null), }) export const JsonValueSchema: z.ZodType = z.lazy(() => diff --git a/src/schemas/deviceBrand.ts b/src/schemas/deviceBrand.ts index bba2fedf2..a4f933728 100644 --- a/src/schemas/deviceBrand.ts +++ b/src/schemas/deviceBrand.ts @@ -1,7 +1,8 @@ import { z } from 'zod' +import { SortDirection } from '@/schemas/common' export const DeviceBrandSortField = z.enum(['name', 'devicesCount']) -export const SortDirection = z.enum(['asc', 'desc']) +export { SortDirection } export const GetDeviceBrandsSchema = z .object({ diff --git a/src/schemas/game.ts b/src/schemas/game.ts index 74e62059d..8adc16785 100644 --- a/src/schemas/game.ts +++ b/src/schemas/game.ts @@ -1,5 +1,6 @@ import { z } from 'zod' import { HumanVerificationTokenSchema } from '@/features/human-verification/shared/schema' +import { SortDirection } from '@/schemas/common' import { ApprovalStatus } from '@orm' export const GameSortField = z.enum([ @@ -10,7 +11,7 @@ export const GameSortField = z.enum([ 'status', ]) -export const SortDirection = z.enum(['asc', 'desc']) +export { SortDirection } export const GameListingFilter = z.enum(['all', 'withListings', 'noListings']) diff --git a/src/schemas/gpu.ts b/src/schemas/gpu.ts index fa2ad1d3f..68d4b45f1 100644 --- a/src/schemas/gpu.ts +++ b/src/schemas/gpu.ts @@ -43,7 +43,7 @@ export const DeleteGpuSchema = z.object({ id: z.string().uuid() }) // Type exports for repository use // Use z.input for types that include defaults (what you pass in) // Use z.output for types after defaults are applied (what you get out) -// TODO: figure out why we use z.infer +// The remaining schemas do not apply defaults or transforms, so z.infer matches their parsed shape. export type GetGpusInput = z.input export type GetGpuOptionsInput = z.input export type CreateGpuInput = z.infer diff --git a/src/schemas/permission.ts b/src/schemas/permission.ts index a5a3d80cc..fd0a1c7e1 100644 --- a/src/schemas/permission.ts +++ b/src/schemas/permission.ts @@ -1,10 +1,11 @@ import { z } from 'zod' +import { SortDirection } from '@/schemas/common' import { Role, PermissionActionType } from '@orm' // Sorting and filtering schemas export const PermissionSortField = z.enum(['label', 'key', 'category', 'createdAt', 'updatedAt']) -export const SortDirection = z.enum(['asc', 'desc']) +export { SortDirection } export const PermissionCategory = z.enum(['CONTENT', 'MODERATION', 'USER_MANAGEMENT', 'SYSTEM']) diff --git a/src/schemas/soc.ts b/src/schemas/soc.ts index 0baa2c543..400364b9c 100644 --- a/src/schemas/soc.ts +++ b/src/schemas/soc.ts @@ -1,7 +1,8 @@ import { z } from 'zod' +import { SortDirection } from '@/schemas/common' export const SoCSortField = z.enum(['name', 'manufacturer', 'devicesCount']) -export const SortDirection = z.enum(['asc', 'desc']) +export { SortDirection } export const GetSoCsSchema = z .object({ diff --git a/src/schemas/system.ts b/src/schemas/system.ts index 83c47bb9b..d48605c1c 100644 --- a/src/schemas/system.ts +++ b/src/schemas/system.ts @@ -1,7 +1,8 @@ import { z } from 'zod' +import { SortDirection } from '@/schemas/common' export const SystemSortField = z.enum(['name', 'key', 'gamesCount']) -export const SortDirection = z.enum(['asc', 'desc']) +export { SortDirection } export const GetSystemsSchema = z .object({ diff --git a/src/schemas/user.ts b/src/schemas/user.ts index 76656b0bb..870032f19 100644 --- a/src/schemas/user.ts +++ b/src/schemas/user.ts @@ -1,5 +1,6 @@ import { z } from 'zod' import { PAGINATION, CHAR_LIMITS } from '@/data/constants' +import { SortDirection } from '@/schemas/common' import { Role } from '@orm' export const UserSortField = z.enum([ @@ -14,7 +15,7 @@ export const UserSortField = z.enum([ 'followersCount', 'followingCount', ]) -export const SortDirection = z.enum(['asc', 'desc']) +export { SortDirection } export const GetAllUsersSchema = z .object({ diff --git a/src/server/services/user-profile.service.test.ts b/src/server/services/user-profile.service.test.ts index f33e45527..27f90b09d 100644 --- a/src/server/services/user-profile.service.test.ts +++ b/src/server/services/user-profile.service.test.ts @@ -1,6 +1,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { Role, type PrismaClient } from '@orm/client' -import { checkProfileAccess, PRIVATE_PROFILE_SETTINGS } from './user-profile.service' +import { + checkProfileAccess, + PRIVATE_PROFILE_SETTINGS, + PROFILE_ACCESS_REASONS, +} from './user-profile.service' function createMockPrisma() { return { @@ -36,7 +40,7 @@ describe('user-profile.service', () => { const result = await checkProfileAccess(prisma, 'missing-id', {}) - expect(result).toEqual({ accessible: false, reason: 'not_found' }) + expect(result).toEqual({ accessible: false, reason: PROFILE_ACCESS_REASONS.NOT_FOUND }) }) it('should return banned when user has active ban and viewer is not mod', async () => { @@ -50,7 +54,7 @@ describe('user-profile.service', () => { currentUserRole: Role.USER, }) - expect(result).toEqual({ accessible: false, reason: 'banned' }) + expect(result).toEqual({ accessible: false, reason: PROFILE_ACCESS_REASONS.BANNED }) }) it('should return accessible with isBanned when user has active ban but viewer is MODERATOR', async () => { @@ -83,7 +87,7 @@ describe('user-profile.service', () => { currentUserRole: Role.USER, }) - expect(result).toEqual({ accessible: false, reason: 'private' }) + expect(result).toEqual({ accessible: false, reason: PROFILE_ACCESS_REASONS.PRIVATE }) }) it('should return accessible when profile is private but viewer is the owner', async () => { diff --git a/src/server/services/user-profile.service.ts b/src/server/services/user-profile.service.ts index d3267c8a5..1bda2581e 100644 --- a/src/server/services/user-profile.service.ts +++ b/src/server/services/user-profile.service.ts @@ -17,6 +17,15 @@ interface PrivacySettings { followingVisible: boolean } +export const PROFILE_ACCESS_REASONS = { + NOT_FOUND: 'not_found', + BANNED: 'banned', + PRIVATE: 'private', +} as const + +export type ProfileAccessReason = + (typeof PROFILE_ACCESS_REASONS)[keyof typeof PROFILE_ACCESS_REASONS] + interface AccessibleProfile { accessible: true isBanned: boolean @@ -29,7 +38,7 @@ interface AccessibleProfile { interface InaccessibleProfile { accessible: false - reason: 'not_found' | 'banned' | 'private' // TODO: use constants or enums + reason: ProfileAccessReason } export type ProfileAccessResult = AccessibleProfile | InaccessibleProfile @@ -77,7 +86,7 @@ export async function checkProfileAccess( }, }) - if (!user) return { accessible: false, reason: 'not_found' } + if (!user) return { accessible: false, reason: PROFILE_ACCESS_REASONS.NOT_FOUND } const isBanned = user.userBans.length > 0 const canViewBannedUsers = roleIncludesRole(ctx.currentUserRole, Role.MODERATOR) @@ -85,7 +94,7 @@ export async function checkProfileAccess( const isMod = canViewBannedUsers if (isBanned && !canViewBannedUsers) { - return { accessible: false, reason: 'banned' } + return { accessible: false, reason: PROFILE_ACCESS_REASONS.BANNED } } const privacySettings: PrivacySettings = { @@ -98,7 +107,7 @@ export async function checkProfileAccess( } if (!privacySettings.profilePublic && !isOwner && !isMod) { - return { accessible: false, reason: 'private' } + return { accessible: false, reason: PROFILE_ACCESS_REASONS.PRIVATE } } return { diff --git a/src/utils/badge-colors.ts b/src/utils/badge-colors.ts index a4f8883be..952b27cf3 100644 --- a/src/utils/badge-colors.ts +++ b/src/utils/badge-colors.ts @@ -72,3 +72,17 @@ export function getPermissionCategoryBadgeVariant( ): BadgeVariant { return permissionCategoryVariantMap[permissionCategory] || 'default' } + +export function getSuccessRateBarColor(rate: number): string { + if (rate >= 95) return 'bg-green-600' + if (rate >= 85) return 'bg-green-500' + if (rate >= 75) return 'bg-green-400' + if (rate >= 65) return 'bg-lime-500' + if (rate >= 55) return 'bg-yellow-400' + if (rate >= 45) return 'bg-yellow-500' + if (rate >= 35) return 'bg-orange-400' + if (rate >= 25) return 'bg-orange-500' + if (rate >= 15) return 'bg-red-400' + if (rate >= 5) return 'bg-red-500' + return 'bg-red-600' +} diff --git a/src/utils/vote.ts b/src/utils/vote.ts index d35b08317..d90463936 100644 --- a/src/utils/vote.ts +++ b/src/utils/vote.ts @@ -1,34 +1,3 @@ -/** - * Utility functions for vote-related calculations and styling - */ - -/** - * Get the color class for the success rate bar based on the rate - * TODO: probably move this to badgeColors.ts - * @param rate - Success rate percentage (0-100) - * @returns Tailwind CSS background color class - */ -export function getBarColor(rate: number): string { - if (rate >= 95) return 'bg-green-600' // Excellent - dark green - if (rate >= 85) return 'bg-green-500' // Very good - green - if (rate >= 75) return 'bg-green-400' // Good - light green - if (rate >= 65) return 'bg-lime-500' // Above average - lime - if (rate >= 55) return 'bg-yellow-400' // Average+ - light yellow - if (rate >= 45) return 'bg-yellow-500' // Average - yellow - if (rate >= 35) return 'bg-orange-400' // Below average - light orange - if (rate >= 25) return 'bg-orange-500' // Poor - orange - if (rate >= 15) return 'bg-red-400' // Bad - light red - if (rate >= 5) return 'bg-red-500' // Very bad - red - return 'bg-red-600' // Terrible - dark red -} - -/** - * Calculate the width percentage for the success rate bar - * When rate is 0 but there are votes, show full red bar (100%) - * @param rate - Success rate percentage (0-100) - * @param voteCount - Total number of votes - * @returns Width percentage for the bar - */ export function getBarWidth(rate: number, voteCount: number): number { return rate === 0 && voteCount > 0 ? 100 : rate } From 32bc3e4e047aeccb128f1f83bfe937752676a95c Mon Sep 17 00:00:00 2001 From: Producdevity Date: Fri, 5 Jun 2026 13:43:06 +0200 Subject: [PATCH 39/87] fix: align GameNative startup default --- prisma/seeders/gamenativeCustomFieldsSeeder.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/prisma/seeders/gamenativeCustomFieldsSeeder.ts b/prisma/seeders/gamenativeCustomFieldsSeeder.ts index 8ca275a4a..c3c6d48bd 100644 --- a/prisma/seeders/gamenativeCustomFieldsSeeder.ts +++ b/prisma/seeders/gamenativeCustomFieldsSeeder.ts @@ -199,7 +199,7 @@ const GAMENATIVE_CUSTOM_FIELDS: GameNativeCustomFieldSeed[] = [ type: CustomFieldType.SELECT, required: false, displayOrder: 9, - defaultValue: 'Aggressive (Stop services on startup)', + defaultValue: 'Essential (Load only essential services)', options: [ { value: 'Normal (Load all services)', label: 'Normal (Load all services)' }, { From 79ac32cdac052b417fe657a200b1f4033faa8485 Mon Sep 17 00:00:00 2001 From: Producdevity Date: Fri, 5 Jun 2026 13:55:00 +0200 Subject: [PATCH 40/87] reduce notification polling --- src/app/api/notifications/stream/route.ts | 25 -- .../notifications/NotificationCenter.tsx | 24 +- src/data/constants.ts | 1 - src/hooks/useRealtimeNotifications.ts | 207 ---------------- src/server/notifications/batchingService.ts | 40 +-- src/server/notifications/realtimeService.ts | 230 ------------------ src/server/notifications/service.test.ts | 7 - src/server/notifications/service.ts | 85 +------ src/server/notifications/types.ts | 1 - 9 files changed, 29 insertions(+), 591 deletions(-) delete mode 100644 src/app/api/notifications/stream/route.ts delete mode 100644 src/hooks/useRealtimeNotifications.ts delete mode 100644 src/server/notifications/realtimeService.ts diff --git a/src/app/api/notifications/stream/route.ts b/src/app/api/notifications/stream/route.ts deleted file mode 100644 index 6d9718015..000000000 --- a/src/app/api/notifications/stream/route.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { auth } from '@clerk/nextjs/server' -import { connection, type NextRequest } from 'next/server' -import { logger } from '@/lib/logger' -import { - realtimeNotificationService, - createSSEResponse, -} from '@/server/notifications/realtimeService' - -export async function GET(request: NextRequest) { - await connection() - - try { - const { userId } = await auth() - - if (!userId) return new Response('Unauthorized', { status: 401 }) - - const stream = realtimeNotificationService.createSSEConnection(userId) - const origin = request.headers.get('origin') || undefined - - return createSSEResponse(stream, origin) - } catch (error) { - logger.error('SSE connection error:', error) - return new Response('Internal Server Error', { status: 500 }) - } -} diff --git a/src/components/notifications/NotificationCenter.tsx b/src/components/notifications/NotificationCenter.tsx index 5304169a3..d3edec50c 100644 --- a/src/components/notifications/NotificationCenter.tsx +++ b/src/components/notifications/NotificationCenter.tsx @@ -6,7 +6,7 @@ import { Bell, X } from 'lucide-react' import { useRouter } from 'next/navigation' import { useState, useEffect, type MouseEvent } from 'react' import { createPortal } from 'react-dom' -import { POLLING_INTERVALS } from '@/data/constants' +import { CACHE_DURATIONS, POLLING_INTERVALS } from '@/data/constants' import { api } from '@/lib/api' import toast from '@/lib/toast' import { cn } from '@/lib/utils' @@ -27,15 +27,18 @@ function NotificationCenter(props: Props) { const notificationsQuery = api.notifications.get.useQuery( { limit: 10, offset: 0 }, { - enabled: !!user, - refetchOnWindowFocus: true, - refetchInterval: POLLING_INTERVALS.NOTIFICATIONS, + enabled: !!user && isOpen, + refetchOnWindowFocus: isOpen, + refetchInterval: isOpen ? POLLING_INTERVALS.SHORT : false, + staleTime: CACHE_DURATIONS.VERY_SHORT, }, ) const unreadCountQuery = api.notifications.getUnreadCount.useQuery(undefined, { enabled: !!user, + staleTime: CACHE_DURATIONS.SHORT, refetchOnWindowFocus: true, - refetchInterval: POLLING_INTERVALS.NOTIFICATIONS, + refetchInterval: isOpen ? false : POLLING_INTERVALS.EXTRA_LONG, + refetchIntervalInBackground: false, }) // Mutations @@ -85,6 +88,15 @@ function NotificationCenter(props: Props) { router.push('/notifications') } + const handleToggleNotifications = () => { + const nextIsOpen = !isOpen + setIsOpen(nextIsOpen) + + if (nextIsOpen) { + void utils.notifications.getUnreadCount.invalidate() + } + } + // Add escape key handler useEffect(() => { const handleEscape = (event: KeyboardEvent) => { @@ -158,7 +170,7 @@ function NotificationCenter(props: Props) { } onClick={(ev) => { ev.stopPropagation() - setIsOpen(!isOpen) + handleToggleNotifications() }} className="relative p-2 text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100 transition-colors" disabled={isLoading} diff --git a/src/data/constants.ts b/src/data/constants.ts index 6d83d8709..831f31375 100644 --- a/src/data/constants.ts +++ b/src/data/constants.ts @@ -3,7 +3,6 @@ import { ms } from '@/utils/time' // Polling intervals in milliseconds export const POLLING_INTERVALS = { SHORT: ms.minutes(1), - NOTIFICATIONS: ms.minutes(3), LONG: ms.minutes(5), EXTRA_LONG: ms.minutes(10), } as const diff --git a/src/hooks/useRealtimeNotifications.ts b/src/hooks/useRealtimeNotifications.ts deleted file mode 100644 index 68668cd7d..000000000 --- a/src/hooks/useRealtimeNotifications.ts +++ /dev/null @@ -1,207 +0,0 @@ -'use client' - -import { useUser } from '@clerk/nextjs' -import { useCallback, useEffect, useRef, useState } from 'react' -import { z } from 'zod' -import { safeParseJSON } from '@/utils/client-validation' - -interface RealtimeNotification { - id: string - type: string - title: string - message: string - actionUrl?: string - createdAt: string -} - -interface SSEMessage { - type: 'connected' | 'notification' | 'unread_count' | 'ping' - data: unknown -} - -// Schemas for validation -const RealtimeNotificationSchema = z.object({ - id: z.string(), - type: z.string(), - title: z.string(), - message: z.string(), - actionUrl: z.string().optional(), - createdAt: z.string(), -}) - -const SSEMessageSchema = z.object({ - type: z.enum(['connected', 'notification', 'unread_count', 'ping']), - data: z.unknown(), -}) - -interface UseRealtimeNotificationsReturn { - isConnected: boolean - notifications: RealtimeNotification[] - unreadCount: number - connect: () => void - disconnect: () => void - markAsRead: (notificationId: string) => void - clearNotifications: () => void -} - -export function useRealtimeNotifications(): UseRealtimeNotificationsReturn { - const { user, isLoaded } = useUser() - const [isConnected, setIsConnected] = useState(false) - const [notifications, setNotifications] = useState([]) - const [unreadCount, setUnreadCount] = useState(0) - - const eventSourceRef = useRef(null) - const reconnectTimeoutRef = useRef(null) - const maxReconnectAttempts = 5 - const reconnectAttempts = useRef(0) - - const disconnect = useCallback(() => { - if (eventSourceRef.current) { - eventSourceRef.current.close() - eventSourceRef.current = null - } - - if (reconnectTimeoutRef.current) { - clearTimeout(reconnectTimeoutRef.current) - reconnectTimeoutRef.current = null - } - - setIsConnected(false) - }, []) - - const connect = useCallback(() => { - if (!user?.id || eventSourceRef.current) { - return - } - - try { - const eventSource = new EventSource(`/api/notifications/stream`) - eventSourceRef.current = eventSource - - eventSource.onopen = () => { - console.log('SSE connection opened') - setIsConnected(true) - reconnectAttempts.current = 0 - } - - eventSource.onmessage = (event) => { - try { - const message = safeParseJSON(event.data, SSEMessageSchema, { - type: 'ping', - data: null, - } as SSEMessage) - - switch (message.type) { - case 'connected': - console.log('Connected to notification stream') - break - - case 'notification': - const validationResult = RealtimeNotificationSchema.safeParse(message.data) - if (!validationResult.success) { - console.warn('Invalid notification data:', validationResult.error) - break - } - const notification = validationResult.data - setNotifications((prev) => [notification, ...prev].slice(0, 50)) // Keep last 50 - - // Show browser notification if permission granted - if (Notification.permission === 'granted') { - new Notification(notification.title, { - body: notification.message, - icon: '/favicon/favicon-32x32.png', - tag: notification.id, - }) - } - break - - case 'unread_count': - setUnreadCount((message.data as { count: number }).count) - break - - case 'ping': - // Respond to ping to keep connection alive - console.log('Received ping from server') - break - - default: - console.log('Unknown SSE message type:', message.type) - } - } catch (error) { - console.error('Error parsing SSE message:', error) - } - } - - eventSource.onerror = () => { - console.error('SSE connection error') - setIsConnected(false) - - // Attempt to reconnect - if (reconnectAttempts.current < maxReconnectAttempts) { - reconnectAttempts.current++ - const delay = Math.min(1000 * Math.pow(2, reconnectAttempts.current), 30000) // Exponential backoff, max 30s - - console.log( - `Attempting to reconnect in ${delay}ms (attempt ${reconnectAttempts.current})`, - ) - - reconnectTimeoutRef.current = setTimeout(() => { - disconnect() - connect() - }, delay) - } else { - console.error('Max reconnection attempts reached') - } - } - } catch (error) { - console.error('Failed to create SSE connection:', error) - } - }, [user?.id, disconnect]) - - const markAsRead = useCallback((notificationId: string) => { - setNotifications((prev) => - prev.map((notif) => (notif.id === notificationId ? { ...notif, read: true } : notif)), - ) - }, []) - - const clearNotifications = useCallback(() => { - setNotifications([]) - }, []) - - // Auto-connect when user is loaded - useEffect(() => { - if (isLoaded && user?.id) { - connect() - } - - return () => { - disconnect() - } - }, [isLoaded, user?.id, connect, disconnect]) - - // Request notification permission on mount - useEffect(() => { - if ('Notification' in window && Notification.permission === 'default') { - Notification.requestPermission().then((permission) => { - console.log('Notification permission:', permission) - }) - } - }, []) - - // Cleanup on unmount - useEffect(() => { - return () => { - disconnect() - } - }, [disconnect]) - - return { - isConnected, - notifications, - unreadCount, - connect, - disconnect, - markAsRead, - clearNotifications, - } -} diff --git a/src/server/notifications/batchingService.ts b/src/server/notifications/batchingService.ts index 0f9d99347..80acb4dd4 100644 --- a/src/server/notifications/batchingService.ts +++ b/src/server/notifications/batchingService.ts @@ -7,7 +7,6 @@ import { NotificationType, } from '@orm/client' import { createEmailService } from './emailService' -import { realtimeNotificationService } from './realtimeService' import type { NotificationData } from './types' export interface BatchedNotification { @@ -204,12 +203,12 @@ export class NotificationBatchingService { // Deliver via appropriate channels const deliveryPromises: Promise[] = [] - // In-app delivery + // In-app delivery is complete once the notification record exists. if ( data.deliveryChannel === DeliveryChannel.IN_APP || data.deliveryChannel === DeliveryChannel.BOTH ) { - deliveryPromises.push(this.deliverInApp(dbNotification.id, data)) + deliveryPromises.push(Promise.resolve(true)) } // Email delivery @@ -245,41 +244,6 @@ export class NotificationBatchingService { } } - // Deliver in-app notification - private async deliverInApp(notificationId: string, data: NotificationData): Promise { - try { - const notification = await prisma.notification.findUnique({ - where: { id: notificationId }, - }) - - if (!notification) return false - - realtimeNotificationService.sendNotificationToUser(data.userId, { - id: notification.id, - type: notification.type, - title: notification.title, - message: notification.message, - actionUrl: notification.actionUrl || undefined, - createdAt: notification.createdAt.toISOString(), - }) - - // Update unread count - const unreadCount = await prisma.notification.count({ - where: { - userId: data.userId, - isRead: false, - }, - }) - - realtimeNotificationService.sendUnreadCountToUser(data.userId, unreadCount) - - return true - } catch (error) { - console.error('In-app delivery error:', error) - return false - } - } - // Deliver email notification private async deliverEmail(data: NotificationData): Promise { if (!this.emailService) return false diff --git a/src/server/notifications/realtimeService.ts b/src/server/notifications/realtimeService.ts deleted file mode 100644 index d60618218..000000000 --- a/src/server/notifications/realtimeService.ts +++ /dev/null @@ -1,230 +0,0 @@ -import { getAllowedOrigins } from '@/lib/cors' - -interface SSEConnection { - userId: string - controller: ReadableStreamDefaultController - lastPing: number -} - -class RealtimeNotificationService { - private connections = new Map() - private pingInterval: NodeJS.Timeout | null = null - - // Connection limits - private readonly MAX_CONNECTIONS = 1000 // Maximum total connections - private readonly MAX_CONNECTIONS_PER_IP = 10 // Maximum connections per IP - private connectionsByIp = new Map>() // IP -> Set of userIds - - constructor() { - this.startPingInterval() - } - - // Create SSE connection for a user - createSSEConnection(userId: string, clientIp?: string): ReadableStream { - // Check total connection limit - if (this.connections.size >= this.MAX_CONNECTIONS) { - throw new Error('Server connection limit reached') - } - - // Check per-IP connection limit if IP is provided - if (clientIp) { - const ipConnections = this.connectionsByIp.get(clientIp) || new Set() - if (ipConnections.size >= this.MAX_CONNECTIONS_PER_IP) { - throw new Error('Connection limit exceeded for this IP') - } - } - - return new ReadableStream({ - start: (controller) => { - // Close existing connection for this user if any - if (this.connections.has(userId)) { - const existing = this.connections.get(userId) - existing?.controller.close() - this.connections.delete(userId) - } - - // Store connection - this.connections.set(userId, { - userId, - controller, - lastPing: Date.now(), - }) - - // Track IP connection if provided - if (clientIp) { - const ipConnections = this.connectionsByIp.get(clientIp) || new Set() - ipConnections.add(userId) - this.connectionsByIp.set(clientIp, ipConnections) - } - - // Send initial connection message - this.sendToUser(userId, { - type: 'connected', - data: { message: 'Connected to notification stream' }, - }) - - console.log(`SSE connection established for user: ${userId}`) - }, - cancel: () => { - this.connections.delete(userId) - - // Clean up IP tracking - if (clientIp) { - const ipConnections = this.connectionsByIp.get(clientIp) - if (ipConnections) { - ipConnections.delete(userId) - if (ipConnections.size === 0) { - this.connectionsByIp.delete(clientIp) - } - } - } - - console.log(`SSE connection closed for user: ${userId}`) - }, - }) - } - - // Send notification to specific user - sendNotificationToUser( - userId: string, - notification: { - id: string - type: string - title: string - message: string - actionUrl?: string - createdAt: string - }, - ): boolean { - return this.sendToUser(userId, { - type: 'notification', - data: notification, - }) - } - - // Send unread count update to user - sendUnreadCountToUser(userId: string, count: number): boolean { - return this.sendToUser(userId, { - type: 'unread_count', - data: { count }, - }) - } - - // Broadcast to all connected users - broadcast(message: { type: string; data: unknown }): void { - for (const [userId] of this.connections) { - this.sendToUser(userId, message) - } - } - - // Send message to specific user - private sendToUser(userId: string, message: { type: string; data: unknown }): boolean { - const connection = this.connections.get(userId) - if (!connection) return false - - try { - const sseData = `data: ${JSON.stringify(message)}\n\n` - connection.controller.enqueue(new TextEncoder().encode(sseData)) - return true - } catch (error) { - console.error(`Failed to send SSE message to user ${userId}:`, error) - this.connections.delete(userId) - return false - } - } - - // Keep connections alive with periodic pings - private startPingInterval(): void { - this.pingInterval = setInterval(() => { - const now = Date.now() - const staleConnections: string[] = [] - - for (const [userId, connection] of this.connections) { - // Send ping - const pingSuccess = this.sendToUser(userId, { - type: 'ping', - data: { timestamp: now }, - }) - - if (!pingSuccess || now - connection.lastPing > 60000) { - // Connection failed or hasn't responded to ping in 60 seconds - staleConnections.push(userId) - } else { - connection.lastPing = now - } - } - - // Clean up stale connections - for (const userId of staleConnections) { - this.connections.delete(userId) - console.log(`Removed stale SSE connection for user: ${userId}`) - } - }, 30000) // Ping every 30 seconds - // Let process exit if this is the only timer - this.pingInterval.unref?.() - } - - // Get connection status - getConnectionStatus(): { - totalConnections: number - connectedUsers: string[] - } { - return { - totalConnections: this.connections.size, - connectedUsers: Array.from(this.connections.keys()), - } - } - - // Cleanup - destroy(): void { - if (this.pingInterval) { - clearInterval(this.pingInterval) - this.pingInterval = null - } - - // Close all connections - for (const [userId, connection] of this.connections) { - try { - connection.controller.close() - } catch (error) { - console.error(`Error closing connection for user ${userId}:`, error) - } - } - - this.connections.clear() - this.connectionsByIp.clear() - } -} - -// Singleton instance -export const realtimeNotificationService = new RealtimeNotificationService() - -/** - * Helper function to create SSE response with proper CORS - * @param stream - * @param origin - */ -export function createSSEResponse(stream: ReadableStream, origin?: string): Response { - // Use centralized CORS configuration - const allowedOrigins = getAllowedOrigins() - - // Allow mobile apps (no origin) or explicitly allowed origins - const allowOrigin = !origin - ? '*' // No origin header (mobile apps) - : allowedOrigins.length === 0 - ? '*' // No origins configured (dev mode) - : allowedOrigins.includes(origin) - ? origin // Origin is allowed - : allowedOrigins[0] || '*' // Fallback to first allowed origin - - return new Response(stream, { - headers: { - 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache', - Connection: 'keep-alive', - 'Access-Control-Allow-Origin': allowOrigin, - 'Access-Control-Allow-Headers': 'Cache-Control', - 'Access-Control-Allow-Credentials': 'true', - }, - }) -} diff --git a/src/server/notifications/service.test.ts b/src/server/notifications/service.test.ts index d72236f0b..7800d440a 100644 --- a/src/server/notifications/service.test.ts +++ b/src/server/notifications/service.test.ts @@ -105,13 +105,6 @@ vi.mock('@/server/notifications/rateLimitService', () => ({ }, })) -vi.mock('@/server/notifications/realtimeService', () => ({ - realtimeNotificationService: { - sendNotificationToUser: vi.fn().mockReturnValue(true), - sendUnreadCountToUser: vi.fn(), - }, -})) - vi.mock('@/server/notifications/emailService', () => ({ createEmailService: vi.fn().mockReturnValue({ sendNotificationEmail: vi.fn().mockResolvedValue({ success: true }), diff --git a/src/server/notifications/service.ts b/src/server/notifications/service.ts index a42161fcc..dbb6dafe3 100644 --- a/src/server/notifications/service.ts +++ b/src/server/notifications/service.ts @@ -19,7 +19,6 @@ import { import { createEmailService } from './emailService' import { type NotificationEventData, notificationEventEmitter } from './eventEmitter' import { notificationRateLimitService } from './rateLimitService' -import { realtimeNotificationService } from './realtimeService' import { notificationTemplateEngine, type TemplateContext } from './templates' import type { NotificationData, @@ -44,7 +43,6 @@ export class NotificationService { constructor(config: Partial = {}) { this.config = { enableEmailDelivery: false, - enableRealTimeDelivery: true, maxRetries: 3, retryDelayMs: 1000, batchSize: 50, @@ -180,7 +178,7 @@ export class NotificationService { const deliveryResults: NotificationDeliveryResult[] = [] // Always deliver in-app notifications - const inAppResult = await this.deliverInApp(notificationId, data) + const inAppResult = this.deliverInApp() deliveryResults.push(inAppResult) // Deliver email if enabled and email service is configured @@ -216,49 +214,11 @@ export class NotificationService { }) } - private async deliverInApp( - notificationId: string, - data: NotificationData, - ): Promise { - try { - // Send real-time notification if user is connected - const notification = await prisma.notification.findUnique({ - where: { id: notificationId }, - }) - - if (notification) { - const sent = realtimeNotificationService.sendNotificationToUser(data.userId, { - id: notification.id, - type: notification.type, - title: notification.title, - message: notification.message, - actionUrl: notification.actionUrl || undefined, - createdAt: notification.createdAt.toISOString(), - }) - - // Also update unread count - const unreadCount = await prisma.notification.count({ - where: { userId: data.userId, isRead: false }, - }) - - realtimeNotificationService.sendUnreadCountToUser(data.userId, unreadCount) - - logger.log(`Real-time notification ${sent ? 'sent' : 'queued'} for user ${data.userId}`) - } - - return { - success: true, - channel: DeliveryChannel.IN_APP, - status: NotificationDeliveryStatus.SENT, - } - } catch (error) { - console.error('In-app delivery error:', error) - return { - success: false, - channel: DeliveryChannel.IN_APP, - status: NotificationDeliveryStatus.FAILED, - error: error instanceof Error ? error.message : 'Unknown error', - } + private deliverInApp(): NotificationDeliveryResult { + return { + success: true, + channel: DeliveryChannel.IN_APP, + status: NotificationDeliveryStatus.SENT, } } @@ -384,16 +344,8 @@ export class NotificationService { data: { isRead: true }, }) - // If notification was actually updated, invalidate caches and update real-time count + // If notification was actually updated, invalidate caches if (updatedCount.count > 0) { - // Get updated unread count - const unreadCount = await prisma.notification.count({ - where: { userId, isRead: false }, - }) - - // Send real-time unread count update - realtimeNotificationService.sendUnreadCountToUser(userId, unreadCount) - // Clear analytics cache since notification status changed notificationAnalyticsService.clearCache() @@ -408,11 +360,8 @@ export class NotificationService { data: { isRead: true }, }) - // If any notifications were updated, invalidate caches and update real-time count + // If any notifications were updated, invalidate caches if (updatedCount.count > 0) { - // Send real-time unread count update (should be 0 after marking all as read) - realtimeNotificationService.sendUnreadCountToUser(userId, 0) - // Clear analytics cache since notification status changed notificationAnalyticsService.clearCache() @@ -421,29 +370,13 @@ export class NotificationService { } async deleteNotification(notificationId: string, userId: string): Promise { - // First check if the notification exists and is unread - const notification = await prisma.notification.findFirst({ - where: { id: notificationId, userId }, - select: { isRead: true }, - }) - // Delete the notification const deletedCount = await prisma.notification.deleteMany({ where: { id: notificationId, userId }, }) - // If notification was deleted and was unread, update real-time count + // If notification was deleted, invalidate caches if (deletedCount.count > 0) { - // If the deleted notification was unread, update the unread count - if (notification && !notification.isRead) { - const unreadCount = await prisma.notification.count({ - where: { userId, isRead: false }, - }) - - // Send real-time unread count update - realtimeNotificationService.sendUnreadCountToUser(userId, unreadCount) - } - // Clear analytics cache since a notification was deleted notificationAnalyticsService.clearCache() diff --git a/src/server/notifications/types.ts b/src/server/notifications/types.ts index c3832ed9a..a5f5ba333 100644 --- a/src/server/notifications/types.ts +++ b/src/server/notifications/types.ts @@ -72,7 +72,6 @@ export interface NotificationEventPayload { export interface NotificationServiceConfig { enableEmailDelivery: boolean - enableRealTimeDelivery: boolean maxRetries: number retryDelayMs: number batchSize: number From b94585838364d85e9e7b48ff373b493bc38d2e20 Mon Sep 17 00:00:00 2001 From: Producdevity Date: Fri, 5 Jun 2026 14:06:15 +0200 Subject: [PATCH 41/87] clean notification files --- .../notifications/NotificationCenter.tsx | 21 ---- src/server/notifications/batchingService.ts | 32 +---- src/server/notifications/service.test.ts | 2 - src/server/notifications/service.ts | 113 ++---------------- 4 files changed, 11 insertions(+), 157 deletions(-) diff --git a/src/components/notifications/NotificationCenter.tsx b/src/components/notifications/NotificationCenter.tsx index d3edec50c..ec0fe76a3 100644 --- a/src/components/notifications/NotificationCenter.tsx +++ b/src/components/notifications/NotificationCenter.tsx @@ -41,7 +41,6 @@ function NotificationCenter(props: Props) { refetchIntervalInBackground: false, }) - // Mutations const markAsReadMutation = api.notifications.markAsRead.useMutation({ onMutate: () => setIsLoading(true), onSuccess: () => { @@ -97,7 +96,6 @@ function NotificationCenter(props: Props) { } } - // Add escape key handler useEffect(() => { const handleEscape = (event: KeyboardEvent) => { if (event.key === 'Escape' && isOpen) { @@ -116,21 +114,17 @@ function NotificationCenter(props: Props) { } }, [isOpen]) - // Don't render anything if user is not authenticated if (!user) return null const handleNotificationClick = (notification: (typeof notifications)[0]) => { - // Mark as read if not already read (swallow error if it fails) if (!notification.isRead) { markAsReadMutation.mutateAsync({ notificationId: notification.id }).catch(console.error) } setIsOpen(false) - // Navigate based on actionUrl if available if (notification.actionUrl) return router.push(notification.actionUrl) - // Try to extract route from metadata if actionUrl is not available const metadata = notification.metadata as Record if (typeof metadata?.listingId === 'string') { router.push(`/listings/${metadata.listingId}`) @@ -139,7 +133,6 @@ function NotificationCenter(props: Props) { } else if (typeof metadata?.userId === 'string') { router.push(`/users/${metadata.userId}`) } else { - // Default to notifications page if no specific route router.push('/notifications') } } @@ -153,7 +146,6 @@ function NotificationCenter(props: Props) { } const handleBackdropClick = (ev: MouseEvent) => { - // Only close if the click is directly on the backdrop, not bubbling from child elements if (ev.target !== ev.currentTarget) return setIsOpen(false) } @@ -163,7 +155,6 @@ function NotificationCenter(props: Props) { return (
- {/* Notification Bell Button */} - {/* Desktop Dropdown */} {isOpen && ( - {/* Header */}

Notifications

@@ -224,7 +213,6 @@ function NotificationCenter(props: Props) {
- {/* Notifications List */}
- {/* Footer */} {notifications.length > 0 && (
diff --git a/src/components/ui/image-selectors/ImageSelectorSwitcher.tsx b/src/components/ui/image-selectors/ImageSelectorSwitcher.tsx index 349c08311..4e5c131fd 100644 --- a/src/components/ui/image-selectors/ImageSelectorSwitcher.tsx +++ b/src/components/ui/image-selectors/ImageSelectorSwitcher.tsx @@ -15,7 +15,7 @@ interface Props { selectedImageUrl?: string onImageSelect: (imageUrl: string) => void onError?: (error: string) => void - allowProviderSwitching?: boolean + allowIgdbProvider?: boolean className?: string } @@ -25,7 +25,7 @@ type ImageService = (typeof serviceOrder)[number] export function ImageSelectorSwitcher(props: Props) { const [selectedService, setSelectedService] = useState('tgdb') const [direction, setDirection] = useState(0) - const allowProviderSwitching = props.allowProviderSwitching === true + const allowIgdbProvider = props.allowIgdbProvider === true const handleServiceChange = (service: ImageService) => { if (service === selectedService) return @@ -34,20 +34,6 @@ export function ImageSelectorSwitcher(props: Props) { setSelectedService(service) } - if (!allowProviderSwitching) { - return ( -
- -
- ) - } - const slideVariants = { initial: (direction: number) => ({ x: direction > 0 ? 300 : -300, @@ -69,10 +55,16 @@ export function ImageSelectorSwitcher(props: Props) {
-
+
- + +
+ IGDB +
+ + NEW + + + )}
From d0f967307d6e1081d5fa0cf92783b92b961a291f Mon Sep 17 00:00:00 2001 From: Producdevity Date: Fri, 5 Jun 2026 22:38:01 +0200 Subject: [PATCH 52/87] Clean up session tracker code --- src/components/SessionTracker.test.tsx | 6 ++-- src/components/SessionTracker.tsx | 49 +++++++++++--------------- 2 files changed, 23 insertions(+), 32 deletions(-) diff --git a/src/components/SessionTracker.test.tsx b/src/components/SessionTracker.test.tsx index d6e476a30..a857e80da 100644 --- a/src/components/SessionTracker.test.tsx +++ b/src/components/SessionTracker.test.tsx @@ -49,14 +49,13 @@ describe('SessionTracker', () => { testState.user = null }) - it('does not track session activity when analytics are disabled', async () => { + it('does not track session activity when analytics are disabled', () => { testState.analyticsAllowed = false render() fireEvent.click(document.body) window.dispatchEvent(new Event('beforeunload')) - await new Promise((resolve) => setTimeout(resolve, 0)) expect(testState.analytics.session.sessionStarted).not.toHaveBeenCalled() expect(testState.analytics.session.pageView).not.toHaveBeenCalled() @@ -70,7 +69,8 @@ describe('SessionTracker', () => { expect(testState.analytics.session.sessionStarted).toHaveBeenCalledOnce() expect(testState.analytics.session.pageView).toHaveBeenCalledOnce() }) - expect(testState.analytics.session.pageView.mock.calls[0]?.[0]).toEqual( + expect(testState.analytics.session.pageView).toHaveBeenNthCalledWith( + 1, expect.objectContaining({ loadTime: expect.any(Number), pathname: '/', diff --git a/src/components/SessionTracker.tsx b/src/components/SessionTracker.tsx index 87b49434f..6e4d3ce96 100644 --- a/src/components/SessionTracker.tsx +++ b/src/components/SessionTracker.tsx @@ -10,11 +10,18 @@ type SignInMethod = NonNullable[0]['m type ClerkUser = NonNullable['user']> const INTERACTION_EVENTS: (keyof DocumentEventMap)[] = ['click', 'keydown', 'change', 'submit'] +const FEATURE_BY_PATHNAME: Partial> = { + '/pc-listings/new': 'pc-listing_creation', + '/listings/new': 'listing_creation', + '/profile': 'profile_management', + '/admin': 'admin_panel', + '/listings': 'listing_browser', + '/pc-listings': 'pc-listing_browser', + '/games': 'game_browser', +} -// Generate a UUID compatible with older browsers function generateUUID() { if (typeof crypto !== 'undefined' && crypto.randomUUID) return crypto.randomUUID() - // Fallback for browsers that don't support crypto.randomUUID return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { const r = (Math.random() * 16) | 0 const v = c === 'x' ? r : (r & 0x3) | 0x8 @@ -52,7 +59,7 @@ function SessionTracker() { const userId = user?.id const signInMethod = getSignInMethod(user) const sessionStartRef = useRef(null) - const pageLoadTimeRef = useRef(null) + const initialPageViewStartedAtRef = useRef(null) const sessionIdRef = useRef(null) const hasTrackedSessionStart = useRef(false) const hasTrackedPageViewRef = useRef(false) @@ -67,7 +74,7 @@ function SessionTracker() { const now = Date.now() sessionStartRef.current = now - pageLoadTimeRef.current = now + initialPageViewStartedAtRef.current = now sessionIdRef.current = generateUUID() }, []) @@ -75,13 +82,11 @@ function SessionTracker() { currentUserIdRef.current = userId }, [userId]) - // Track user sign-in when a user transitions from null/undefined to having a user useEffect(() => { if (!analyticsAllowed) return const previousUserId = previousUserIdRef.current - // If we now have a user but didn't before, and it's not the first load, track sign-in if (userId && !previousUserId && hasTrackedSessionStart.current) { analytics.user.signedIn({ userId, @@ -89,11 +94,9 @@ function SessionTracker() { }) } - // Update the previous user ID for next comparison previousUserIdRef.current = userId }, [analyticsAllowed, signInMethod, userId]) - // Track session start on the first load useEffect(() => { if (!analyticsAllowed || hasTrackedSessionStart.current || !sessionIdRef.current) return @@ -107,54 +110,43 @@ function SessionTracker() { }) }, [analyticsAllowed, userId]) - // Track page views when pathname changes useEffect(() => { - if (!analyticsAllowed || pageLoadTimeRef.current === null) return + if (!analyticsAllowed || initialPageViewStartedAtRef.current === null) return - const loadTime = hasTrackedPageViewRef.current ? undefined : Date.now() - pageLoadTimeRef.current + const initialLoadTime = hasTrackedPageViewRef.current + ? undefined + : Date.now() - initialPageViewStartedAtRef.current const currentUserId = currentUserIdRef.current const pageViewEvent: Parameters[0] = { pathname, userId: currentUserId, } - if (loadTime !== undefined) pageViewEvent.loadTime = loadTime + if (initialLoadTime !== undefined) pageViewEvent.loadTime = initialLoadTime hasTrackedPageViewRef.current = true pageViewCountRef.current += 1 if (process.env.NODE_ENV === 'development') { - return console.log('📊 Page View:', { + return console.log('Page View:', { pathname, - loadTime, + loadTime: initialLoadTime, userSession: currentUserId ? 'authenticated' : 'anonymous', }) } analytics.session.pageView(pageViewEvent) - // Track feature discovery based on page visits - const featureMap: Record = { - '/pc-listings/new': 'pc-listing_creation', - '/listings/new': 'listing_creation', - '/profile': 'profile_management', - '/admin': 'admin_panel', - '/listings': 'listing_browser', - '/pc-listings': 'pc-listing_browser', - '/games': 'game_browser', - } - - const feature = featureMap[pathname] + const feature = FEATURE_BY_PATHNAME[pathname] if (feature && !discoveredFeatures.current.has(feature)) { discoveredFeatures.current.add(feature) analytics.session.featureDiscovered({ userId: currentUserId, - feature: feature, + feature, context: pathname, }) } }, [analyticsAllowed, pathname]) - // Count basic user interactions for the session summary useEffect(() => { if (!analyticsAllowed) return @@ -173,7 +165,6 @@ function SessionTracker() { } }, [analyticsAllowed]) - // Track session duration on page unloading useEffect(() => { if (!analyticsAllowed || sessionStartRef.current === null || !sessionIdRef.current) return From 83c36285ef0eb16ae554c81f4c1199f8d950fac3 Mon Sep 17 00:00:00 2001 From: Producdevity Date: Sat, 6 Jun 2026 10:30:46 +0200 Subject: [PATCH 53/87] Notify moderators about submitted reports --- src/server/api/routers/listingReports.test.ts | 120 +++++++++++++++++ src/server/api/routers/listingReports.ts | 19 +-- .../api/routers/mobile/listingReports.test.ts | 124 ++++++++++++++++++ .../api/routers/mobile/listingReports.ts | 18 ++- src/server/api/routers/pcListings.test.ts | 59 ++++++++- src/server/api/routers/pcListings.ts | 20 ++- src/server/notifications/eventEmitter.ts | 2 + src/server/notifications/reportEvents.ts | 40 ++++++ src/server/notifications/service.test.ts | 28 ++++ src/server/notifications/service.ts | 8 +- 10 files changed, 414 insertions(+), 24 deletions(-) create mode 100644 src/server/api/routers/listingReports.test.ts create mode 100644 src/server/api/routers/mobile/listingReports.test.ts create mode 100644 src/server/notifications/reportEvents.ts diff --git a/src/server/api/routers/listingReports.test.ts b/src/server/api/routers/listingReports.test.ts new file mode 100644 index 000000000..59849ec62 --- /dev/null +++ b/src/server/api/routers/listingReports.test.ts @@ -0,0 +1,120 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ReportReason, Role } from '@orm/client' + +vi.unmock('@/server/api/trpc') +vi.unmock('@/server/api/root') + +const mockEmitNotificationEvent = vi.fn() +vi.mock('@/server/notifications/eventEmitter', () => ({ + notificationEventEmitter: { emitNotificationEvent: mockEmitNotificationEvent }, + NOTIFICATION_EVENTS: { + REPORT_CREATED: 'report.created', + }, +})) + +vi.mock('@/server/utils/security-validation', () => ({ + validateEnum: vi.fn(), + sanitizeInput: vi.fn((value: string) => value.trim()), + validatePagination: vi.fn((page, limit, max) => ({ page: page ?? 1, limit: limit ?? max ?? 20 })), +})) + +vi.mock('@/lib/trust/service', () => ({ + TrustService: vi.fn().mockImplementation(function MockTrustService() { + return { logAction: vi.fn(), reverseLogAction: vi.fn() } + }), +})) + +const { listingReportsRouter } = await import('./listingReports') + +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 REPORT_ID = '00000000-0000-4000-a000-000000000020' + +function createMockPrisma() { + return { + listing: { + findUnique: vi.fn().mockResolvedValue({ + id: LISTING_ID, + authorId: AUTHOR_ID, + author: { id: AUTHOR_ID }, + }), + }, + listingReport: { + findUnique: vi.fn().mockResolvedValue(null), + create: vi.fn().mockResolvedValue({ + id: REPORT_ID, + listingId: LISTING_ID, + reportedById: USER_ID, + reason: ReportReason.SPAM, + description: 'needs review', + listing: { + game: { title: 'Test Game' }, + author: { name: 'Report Author' }, + }, + }), + }, + } +} + +type MockPrisma = ReturnType + +function createCaller(prisma: MockPrisma = createMockPrisma()) { + return { + caller: listingReportsRouter.createCaller({ + session: { + user: { + id: USER_ID, + email: 'test@test.com', + name: 'Test User', + role: Role.USER, + permissions: [], + showNsfw: false, + }, + }, + prisma: prisma as never, + headers: new Headers(), + }), + prisma, + } +} + +describe('listingReportsRouter create', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('creates a report and emits a moderator notification event', async () => { + const { caller, prisma } = createCaller() + + const report = await caller.create({ + listingId: LISTING_ID, + reason: ReportReason.SPAM, + description: ' needs review ', + }) + + expect(report.id).toBe(REPORT_ID) + expect(prisma.listingReport.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + listingId: LISTING_ID, + reportedById: USER_ID, + description: 'needs review', + }), + }), + ) + expect(mockEmitNotificationEvent).toHaveBeenCalledWith({ + eventType: 'report.created', + entityType: 'listingReport', + entityId: REPORT_ID, + triggeredBy: USER_ID, + payload: { + reportId: REPORT_ID, + contentId: LISTING_ID, + contentType: 'Compatibility Report', + actionUrl: `/admin/reports?listing=${LISTING_ID}`, + listingId: LISTING_ID, + }, + }) + }) +}) diff --git a/src/server/api/routers/listingReports.ts b/src/server/api/routers/listingReports.ts index 04a079e5b..5c999f48b 100644 --- a/src/server/api/routers/listingReports.ts +++ b/src/server/api/routers/listingReports.ts @@ -15,6 +15,7 @@ import { protectedProcedure, publicProcedure, } from '@/server/api/trpc' +import { emitReportCreatedNotification } from '@/server/notifications/reportEvents' import { getAuthorReportCounts } from '@/server/services/report-stats.service' import { paginate } from '@/server/utils/pagination' import { validateEnum, sanitizeInput, validatePagination } from '@/server/utils/security-validation' @@ -131,13 +132,10 @@ export const listingReportsRouter = createTRPCRouter({ const { listingId, reason, description } = input const userId = ctx.session.user.id - // Validate reason enum validateEnum(reason, Object.values(ReportReason), 'reason') - // Sanitize description if provided (plain text, not markdown) const sanitizedDescription = description ? sanitizeInput(description) : description - // Check if listing exists const listing = await ctx.prisma.listing.findUnique({ where: { id: listingId }, include: { author: true }, @@ -145,12 +143,10 @@ export const listingReportsRouter = createTRPCRouter({ if (!listing) return ResourceError.listing.notFound() - // Prevent users from reporting their own listings if (listing.authorId === userId) { return ResourceError.listingReport.cannotReportOwnListing() } - // Check if user already reported this listing const existingReport = await ctx.prisma.listingReport.findUnique({ where: { listingId_reportedById: { @@ -162,9 +158,7 @@ export const listingReportsRouter = createTRPCRouter({ if (existingReport) return ResourceError.listingReport.alreadyExists() - // TODO: Send notification to SUPER_ADMIN users - - return await ctx.prisma.listingReport.create({ + const report = await ctx.prisma.listingReport.create({ data: { listingId, reportedById: userId, @@ -180,6 +174,15 @@ export const listingReportsRouter = createTRPCRouter({ }, }, }) + + emitReportCreatedNotification({ + type: 'listing', + reportId: report.id, + listingId, + reportedById: userId, + }) + + return report }), updateStatus: permissionProcedure(PERMISSIONS.MANAGE_USER_BANS) diff --git a/src/server/api/routers/mobile/listingReports.test.ts b/src/server/api/routers/mobile/listingReports.test.ts new file mode 100644 index 000000000..ac7a1deec --- /dev/null +++ b/src/server/api/routers/mobile/listingReports.test.ts @@ -0,0 +1,124 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ReportReason, Role } from '@orm/client' + +vi.unmock('@/server/api/mobileContext') + +vi.mock('@/schemas/apiAccess', () => ({ + GetApiKeyUsageSchema: {}, + CreateApiKeySchema: {}, + UpdateApiKeySchema: {}, + RevokeApiKeySchema: {}, + ListApiKeysSchema: {}, +})) + +vi.mock('@/server/repositories/api-keys.repository', () => ({ + ApiKeysRepository: vi.fn().mockImplementation(function MockApiKeysRepository() { + return {} + }), +})) + +const mockEmitNotificationEvent = vi.fn() +vi.mock('@/server/notifications/eventEmitter', () => ({ + notificationEventEmitter: { emitNotificationEvent: mockEmitNotificationEvent }, + NOTIFICATION_EVENTS: { + REPORT_CREATED: 'report.created', + }, +})) + +vi.mock('@/server/utils/security-validation', () => ({ + sanitizeInput: vi.fn((value: string) => value.trim()), +})) + +const { mobileListingReportsRouter } = await import('./listingReports') + +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 REPORT_ID = '00000000-0000-4000-a000-000000000020' + +function createMockPrisma() { + return { + listing: { + findUnique: vi.fn().mockResolvedValue({ + id: LISTING_ID, + authorId: AUTHOR_ID, + author: { id: AUTHOR_ID }, + }), + }, + listingReport: { + findUnique: vi.fn().mockResolvedValue(null), + create: vi.fn().mockResolvedValue({ + id: REPORT_ID, + listingId: LISTING_ID, + reportedById: USER_ID, + }), + }, + } +} + +type MockPrisma = ReturnType + +function createCaller(prisma: MockPrisma = createMockPrisma()) { + return { + caller: mobileListingReportsRouter.createCaller({ + session: { + user: { + id: USER_ID, + email: 'test@test.com', + name: 'Test User', + role: Role.USER, + permissions: [], + showNsfw: false, + }, + }, + prisma: prisma as never, + headers: new Headers(), + apiKey: null, + }), + prisma, + } +} + +describe('mobileListingReportsRouter create', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('creates a report and emits the same moderator notification event as web', async () => { + const { caller, prisma } = createCaller() + + const result = await caller.create({ + listingId: LISTING_ID, + reason: ReportReason.SPAM, + description: ' needs review ', + }) + + expect(result).toEqual({ + id: REPORT_ID, + success: true, + message: 'Report submitted successfully', + }) + expect(prisma.listingReport.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + listingId: LISTING_ID, + reportedById: USER_ID, + description: 'needs review', + }), + }), + ) + expect(mockEmitNotificationEvent).toHaveBeenCalledWith({ + eventType: 'report.created', + entityType: 'listingReport', + entityId: REPORT_ID, + triggeredBy: USER_ID, + payload: { + reportId: REPORT_ID, + contentId: LISTING_ID, + contentType: 'Compatibility Report', + actionUrl: `/admin/reports?listing=${LISTING_ID}`, + listingId: LISTING_ID, + }, + }) + }) +}) diff --git a/src/server/api/routers/mobile/listingReports.ts b/src/server/api/routers/mobile/listingReports.ts index e952910cf..8ef725d05 100644 --- a/src/server/api/routers/mobile/listingReports.ts +++ b/src/server/api/routers/mobile/listingReports.ts @@ -5,19 +5,18 @@ import { mobileProtectedProcedure, mobilePublicProcedure, } from '@/server/api/mobileContext' +import { emitReportCreatedNotification } from '@/server/notifications/reportEvents' import { getAuthorReportCounts } from '@/server/services/report-stats.service' +import { sanitizeInput } from '@/server/utils/security-validation' export const mobileListingReportsRouter = createMobileTRPCRouter({ - /** - * Create a new listing report (user-facing) - */ create: mobileProtectedProcedure .input(CreateListingReportSchema) .mutation(async ({ ctx, input }) => { const { listingId, reason, description } = input const userId = ctx.session.user.id + const sanitizedDescription = description ? sanitizeInput(description) : description - // Check if listing exists const listing = await ctx.prisma.listing.findUnique({ where: { id: listingId }, include: { author: true }, @@ -25,12 +24,10 @@ export const mobileListingReportsRouter = createMobileTRPCRouter({ if (!listing) return ResourceError.listing.notFound() - // Prevent users from reporting their own listings if (listing.authorId === userId) { return AppError.badRequest('You cannot report your own listing') } - // Check if user already reported this listing const existingReport = await ctx.prisma.listingReport.findUnique({ where: { listingId_reportedById: { listingId, reportedById: userId } }, }) @@ -40,7 +37,7 @@ export const mobileListingReportsRouter = createMobileTRPCRouter({ } const report = await ctx.prisma.listingReport.create({ - data: { listingId, reportedById: userId, reason, description }, + data: { listingId, reportedById: userId, reason, description: sanitizedDescription }, include: { listing: { include: { @@ -51,6 +48,13 @@ export const mobileListingReportsRouter = createMobileTRPCRouter({ }, }) + emitReportCreatedNotification({ + type: 'listing', + reportId: report.id, + listingId, + reportedById: userId, + }) + return { id: report.id, success: true, diff --git a/src/server/api/routers/pcListings.test.ts b/src/server/api/routers/pcListings.test.ts index b6895311e..66aa1f125 100644 --- a/src/server/api/routers/pcListings.test.ts +++ b/src/server/api/routers/pcListings.test.ts @@ -7,7 +7,7 @@ import { invalidatePcListingsSeo, } from '@/server/cache/invalidation' import { PERMISSIONS } from '@/utils/permission-system' -import { ApprovalStatus, PcOs, Role, TrustAction } from '@orm/client' +import { ApprovalStatus, PcOs, ReportReason, Role, TrustAction } from '@orm/client' vi.unmock('@/server/api/trpc') vi.unmock('@/server/api/root') @@ -46,6 +46,7 @@ vi.mock('@/server/notifications/eventEmitter', () => ({ COMMENT_REPLIED: 'COMMENT_REPLIED', PC_LISTING_APPROVED: 'PC_LISTING_APPROVED', PC_LISTING_REJECTED: 'PC_LISTING_REJECTED', + REPORT_CREATED: 'report.created', }, })) @@ -111,6 +112,7 @@ vi.mock('@/server/api/utils/pinPermissions', () => ({ vi.mock('@/server/utils/security-validation', () => ({ validatePagination: vi.fn((page, limit, max) => ({ page: page ?? 1, limit: limit ?? max ?? 20 })), + sanitizeInput: vi.fn((value: string) => value.trim()), })) const mockRepositoryCreate = vi.fn() @@ -196,6 +198,20 @@ function createMockPrisma() { update: vi.fn(), updateMany: vi.fn().mockResolvedValue({ count: 0 }), }, + pcListingReport: { + findUnique: vi.fn().mockResolvedValue(null), + create: vi.fn().mockResolvedValue({ + id: '00000000-0000-4000-a000-000000000030', + pcListingId: LISTING_ID, + reportedById: USER_ID, + reason: ReportReason.SPAM, + description: 'needs review', + pcListing: { + game: { title: 'PC Test Game' }, + author: { name: 'PC Report Author' }, + }, + }), + }, user: { findUnique: vi.fn().mockResolvedValue({ id: ADMIN_ID }), }, @@ -564,6 +580,47 @@ describe('pcListings trust integration', () => { }) }) + describe('createReport', () => { + it('creates a PC report and emits a moderator notification event', async () => { + const { caller, prisma } = createCaller() + prisma.pcListing.findUnique.mockResolvedValue({ + id: LISTING_ID, + authorId: AUTHOR_ID, + author: { id: AUTHOR_ID }, + }) + + const report = await caller.createReport({ + pcListingId: LISTING_ID, + reason: ReportReason.SPAM, + description: ' needs review ', + }) + + expect(report.id).toBe('00000000-0000-4000-a000-000000000030') + expect(prisma.pcListingReport.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + pcListingId: LISTING_ID, + reportedById: USER_ID, + description: 'needs review', + }), + }), + ) + expect(mockEmitNotificationEvent).toHaveBeenCalledWith({ + eventType: 'report.created', + entityType: 'pcListingReport', + entityId: '00000000-0000-4000-a000-000000000030', + triggeredBy: USER_ID, + payload: { + reportId: '00000000-0000-4000-a000-000000000030', + contentId: LISTING_ID, + contentType: 'PC Compatibility Report', + actionUrl: `/admin/reports?pcListing=${LISTING_ID}`, + pcListingId: LISTING_ID, + }, + }) + }) + }) + describe('byId', () => { it('hides review risk profiles for non-reviewers', async () => { mockRepositoryGetByIdWithDetails.mockResolvedValueOnce({ diff --git a/src/server/api/routers/pcListings.ts b/src/server/api/routers/pcListings.ts index 00ff91ac6..4e64b05c9 100644 --- a/src/server/api/routers/pcListings.ts +++ b/src/server/api/routers/pcListings.ts @@ -61,6 +61,7 @@ import { invalidatePcListingsSeo, } from '@/server/cache/invalidation' import { NOTIFICATION_EVENTS, notificationEventEmitter } from '@/server/notifications/eventEmitter' +import { emitReportCreatedNotification } from '@/server/notifications/reportEvents' import { PcListingsRepository } from '@/server/repositories/pc-listings.repository' import { UserPcPresetsRepository } from '@/server/repositories/user-pc-presets.repository' import { logAudit } from '@/server/services/audit.service' @@ -76,7 +77,7 @@ import { listingStatsCache } from '@/server/utils/cache' import { normalizeCustomFieldValues } from '@/server/utils/custom-field-values' import { paginate } from '@/server/utils/pagination' import { isUserBanned } from '@/server/utils/query-builders' -import { validatePagination } from '@/server/utils/security-validation' +import { sanitizeInput, validatePagination } from '@/server/utils/security-validation' import { checkSpamContent } from '@/server/utils/spam-check' import { updatePcListingVoteCounts } from '@/server/utils/vote-counts' import { @@ -1687,8 +1688,8 @@ export const pcListingsRouter = createTRPCRouter({ .mutation(async ({ ctx, input }) => { const { pcListingId, reason, description } = input const userId = ctx.session.user.id + const sanitizedDescription = description ? sanitizeInput(description) : description - // Check if PC listing exists const pcListing = await ctx.prisma.pcListing.findUnique({ where: { id: pcListingId }, include: { author: true }, @@ -1698,12 +1699,10 @@ export const pcListingsRouter = createTRPCRouter({ return ResourceError.pcListing.notFound() } - // Prevent users from reporting their own listings if (pcListing.authorId === userId) { return AppError.badRequest('You cannot report your own listing') } - // Check if user already reported this listing const existingReport = await ctx.prisma.pcListingReport.findUnique({ where: { pcListingId_reportedById: { @@ -1717,12 +1716,12 @@ export const pcListingsRouter = createTRPCRouter({ return AppError.badRequest('You have already reported this listing') } - return await ctx.prisma.pcListingReport.create({ + const report = await ctx.prisma.pcListingReport.create({ data: { pcListingId, reportedById: userId, reason, - description, + description: sanitizedDescription, }, include: { pcListing: { @@ -1733,6 +1732,15 @@ export const pcListingsRouter = createTRPCRouter({ }, }, }) + + emitReportCreatedNotification({ + type: 'pcListing', + reportId: report.id, + pcListingId, + reportedById: userId, + }) + + return report }), getReports: permissionProcedure(PERMISSIONS.VIEW_USER_BANS) diff --git a/src/server/notifications/eventEmitter.ts b/src/server/notifications/eventEmitter.ts index e2bd4e9ed..1cee9b6d7 100644 --- a/src/server/notifications/eventEmitter.ts +++ b/src/server/notifications/eventEmitter.ts @@ -60,6 +60,8 @@ export const NOTIFICATION_EVENTS = { MAINTENANCE_SCHEDULED: 'maintenance.scheduled', FEATURE_ANNOUNCED: 'feature.announced', USER_ROLE_CHANGED: 'user.role_changed', + REPORT_CREATED: 'report.created', + REPORT_STATUS_CHANGED: 'report.status_changed', GAME_STATUS_OVERRIDDEN: 'game.status_overridden', PC_LISTING_APPROVED: 'pcListing.approved', PC_LISTING_REJECTED: 'pcListing.rejected', diff --git a/src/server/notifications/reportEvents.ts b/src/server/notifications/reportEvents.ts new file mode 100644 index 000000000..5e1676600 --- /dev/null +++ b/src/server/notifications/reportEvents.ts @@ -0,0 +1,40 @@ +import { NOTIFICATION_EVENTS, notificationEventEmitter } from '@/server/notifications/eventEmitter' + +type ListingReportNotificationInput = { + type: 'listing' + reportId: string + listingId: string + reportedById: string +} + +type PcListingReportNotificationInput = { + type: 'pcListing' + reportId: string + pcListingId: string + reportedById: string +} + +type ReportNotificationInput = + | ListingReportNotificationInput + | PcListingReportNotificationInput + +export function emitReportCreatedNotification(input: ReportNotificationInput): void { + const isPcListing = input.type === 'pcListing' + const contentId = isPcListing ? input.pcListingId : input.listingId + + notificationEventEmitter.emitNotificationEvent({ + eventType: NOTIFICATION_EVENTS.REPORT_CREATED, + entityType: isPcListing ? 'pcListingReport' : 'listingReport', + entityId: input.reportId, + triggeredBy: input.reportedById, + payload: { + reportId: input.reportId, + contentId, + contentType: isPcListing ? 'PC Compatibility Report' : 'Compatibility Report', + actionUrl: isPcListing + ? `/admin/reports?pcListing=${contentId}` + : `/admin/reports?listing=${contentId}`, + ...(isPcListing ? { pcListingId: input.pcListingId } : { listingId: input.listingId }), + }, + }) +} diff --git a/src/server/notifications/service.test.ts b/src/server/notifications/service.test.ts index 235bb19f3..c37b2bf2f 100644 --- a/src/server/notifications/service.test.ts +++ b/src/server/notifications/service.test.ts @@ -4,6 +4,7 @@ import { NotificationCategory, NotificationDeliveryStatus, NotificationType, + Role, } from '@orm/client' import { NOTIFICATION_EVENTS } from './eventEmitter' import type { NotificationEventData } from './eventEmitter' @@ -263,6 +264,31 @@ describe('NotificationService', () => { expect(users).toContain('pc-author-1') }) + it('report.created returns moderator and higher users', async () => { + mockPrisma.user.findMany.mockResolvedValue([ + { id: 'moderator-1' }, + { id: 'admin-1' }, + { id: 'super-admin-1' }, + { id: 'reporter-1' }, + ]) + + const users = await serviceInternals.getUsersForEvent( + makeEvent({ + eventType: NOTIFICATION_EVENTS.REPORT_CREATED, + entityType: 'listingReport', + entityId: 'report-1', + triggeredBy: 'reporter-1', + payload: { reportId: 'report-1', listingId: 'listing-1' }, + }), + ) + + expect(mockPrisma.user.findMany).toHaveBeenCalledWith({ + where: { role: { in: [Role.MODERATOR, Role.ADMIN, Role.SUPER_ADMIN] } }, + select: { id: true }, + }) + expect(users).toEqual(['moderator-1', 'admin-1', 'super-admin-1', 'reporter-1']) + }) + it('excludes the actor from recipients', async () => { mockPrisma.listing.findUnique.mockResolvedValue(makeListingRecord({ authorId: 'admin-1' })) @@ -518,6 +544,8 @@ describe('NotificationService', () => { ['pcListing.rejected', NotificationType.LISTING_REJECTED], ['game_follow.new_listing', NotificationType.FOLLOWED_GAME_NEW_LISTING], ['game_follow.new_pc_listing', NotificationType.FOLLOWED_GAME_NEW_PC_LISTING], + [NOTIFICATION_EVENTS.REPORT_CREATED, NotificationType.REPORT_CREATED], + [NOTIFICATION_EVENTS.REPORT_STATUS_CHANGED, NotificationType.REPORT_STATUS_CHANGED], ['listing.commented', NotificationType.COMMENT_ON_LISTING], ] diff --git a/src/server/notifications/service.ts b/src/server/notifications/service.ts index 80b6d7854..b500018de 100644 --- a/src/server/notifications/service.ts +++ b/src/server/notifications/service.ts @@ -17,7 +17,11 @@ import { Role, } from '@orm/client' import { createEmailService } from './emailService' -import { type NotificationEventData, notificationEventEmitter } from './eventEmitter' +import { + NOTIFICATION_EVENTS, + type NotificationEventData, + notificationEventEmitter, +} from './eventEmitter' import { notificationRateLimitService } from './rateLimitService' import { notificationTemplateEngine, type TemplateContext } from './templates' import type { @@ -673,7 +677,7 @@ export class NotificationService { break } - if (eventData.triggeredBy) { + if (eventData.triggeredBy && eventData.eventType !== NOTIFICATION_EVENTS.REPORT_CREATED) { const actorId = eventData.triggeredBy for (let i = userIds.length - 1; i >= 0; i--) { if (userIds[i] === actorId) userIds.splice(i, 1) From fd17056c21bed844c9a5711b03e830fd96fd4134 Mon Sep 17 00:00:00 2001 From: Producdevity Date: Sat, 6 Jun 2026 16:04:55 +0200 Subject: [PATCH 54/87] Remove Vercel analytics integrations --- .env.docker.example | 1 - .env.example | 1 - .env.test.example | 1 - next.config.ts | 2 - package.json | 2 - playwright.config.ts | 1 - pnpm-lock.yaml | 69 ------------------- pnpm-workspace.yaml | 1 - src/app/layout.tsx | 8 --- src/app/privacy/page.tsx | 4 +- .../utils/sendAnalyticsEvent.test.ts | 14 +--- src/lib/analytics/utils/sendAnalyticsEvent.ts | 11 +-- src/lib/env.ts | 3 - tests/helpers/external-services.ts | 8 --- tests/third-party-services.spec.ts | 2 - 15 files changed, 5 insertions(+), 123 deletions(-) diff --git a/.env.docker.example b/.env.docker.example index 8cfd56ce2..b26be84cb 100644 --- a/.env.docker.example +++ b/.env.docker.example @@ -65,7 +65,6 @@ NEXT_PUBLIC_GA_ID="your_google_analytics_id_here" NEXT_PUBLIC_APP_ENV=local NEXT_PUBLIC_LOCAL_STORAGE_PREFIX="@DockerEmuReady_" NEXT_PUBLIC_EMUREADY_BETA_URL="https://play.google.com/store/apps/details?id=com.producdevity.emureadyapp" -NEXT_PUBLIC_VERCEL_ANALYTICS_ENABLED=false NEXT_PUBLIC_ENABLE_ANALYTICS=false NEXT_PUBLIC_ENABLE_KOFI_WIDGET=false NEXT_PUBLIC_ENABLE_SENTRY=false diff --git a/.env.example b/.env.example index 5e80fcf9f..6bbbb6ae8 100644 --- a/.env.example +++ b/.env.example @@ -39,7 +39,6 @@ NEXT_PUBLIC_APP_ENV=local NEXT_PUBLIC_GA_ID="Google-Analytics-ID" NEXT_PUBLIC_LOCAL_STORAGE_PREFIX="@LocalEmuReady_" NEXT_PUBLIC_EMUREADY_BETA_URL="https://play.google.com/store/apps/details?id=com.producdevity.emureadyapp" -NEXT_PUBLIC_VERCEL_ANALYTICS_ENABLED=false NEXT_PUBLIC_ENABLE_ANALYTICS=false NEXT_PUBLIC_ENABLE_KOFI_WIDGET=false NEXT_PUBLIC_ENABLE_SENTRY=false diff --git a/.env.test.example b/.env.test.example index 9dc7a9b4a..19d4b7a8a 100644 --- a/.env.test.example +++ b/.env.test.example @@ -30,7 +30,6 @@ NEXT_PUBLIC_APP_ENV=test NEXT_PUBLIC_GA_ID="" NEXT_PUBLIC_LOCAL_STORAGE_PREFIX="@TestEmuReady_" NEXT_PUBLIC_EMUREADY_BETA_URL="https://play.google.com/store/apps/details?id=com.producdevity.emureadyapp" -NEXT_PUBLIC_VERCEL_ANALYTICS_ENABLED=false NEXT_PUBLIC_ENABLE_ANALYTICS=false NEXT_PUBLIC_ENABLE_KOFI_WIDGET=false NEXT_PUBLIC_ENABLE_SENTRY=false diff --git a/next.config.ts b/next.config.ts index bb0d6ef1a..9069d8859 100644 --- a/next.config.ts +++ b/next.config.ts @@ -21,7 +21,6 @@ const contentSecurityPolicyDirectives = [ "'unsafe-eval'", 'https://www.googletagmanager.com', 'https://static.cloudflareinsights.com', - 'https://va.vercel-scripts.com', 'https://*.clerk.com', 'https://*.clerk.accounts.dev', 'https://clerk.emuready.com', @@ -94,7 +93,6 @@ const contentSecurityPolicyDirectives = [ 'https://clerk.emuready.com', 'wss://*.clerk.accounts.dev', 'wss://clerk.emuready.com', - 'https://va.vercel-scripts.com', 'https://challenges.cloudflare.com', 'https://storage.ko-fi.com', 'https://clerk-telemetry.com', diff --git a/package.json b/package.json index a872e5d6c..7cf8ae0a3 100644 --- a/package.json +++ b/package.json @@ -80,8 +80,6 @@ "@trpc/react-query": "11.17.0", "@trpc/server": "11.17.0", "@types/react-syntax-highlighter": "^15.5.13", - "@vercel/analytics": "^1.5.0", - "@vercel/speed-insights": "^1.2.0", "axios": "1.16.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/playwright.config.ts b/playwright.config.ts index 99c81291c..ac11fb179 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -21,7 +21,6 @@ function createWebServerEnv(): { [key: string]: string } { env.NEXT_PUBLIC_ENABLE_ANALYTICS = 'false' env.NEXT_PUBLIC_ENABLE_KOFI_WIDGET = 'false' env.NEXT_PUBLIC_ENABLE_SENTRY = 'false' - env.NEXT_PUBLIC_VERCEL_ANALYTICS_ENABLED = 'false' env.NEXT_PUBLIC_DISABLE_COOKIE_BANNER = 'true' env.PLAYWRIGHT_TEST = 'true' return env diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 37146b6ad..dd31e3803 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -96,12 +96,6 @@ importers: '@types/react-syntax-highlighter': specifier: ^15.5.13 version: 15.5.13 - '@vercel/analytics': - specifier: ^1.5.0 - version: 1.5.0(next@16.2.6(@babel/core@7.27.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)(vue-router@4.5.1(vue@3.5.17(typescript@5.8.3)))(vue@3.5.17(typescript@5.8.3)) - '@vercel/speed-insights': - specifier: ^1.2.0 - version: 1.2.0(next@16.2.6(@babel/core@7.27.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)(vue-router@4.5.1(vue@3.5.17(typescript@5.8.3)))(vue@3.5.17(typescript@5.8.3)) axios: specifier: 1.16.0 version: 1.16.0 @@ -3968,55 +3962,6 @@ packages: cpu: [x64] os: [win32] - '@vercel/analytics@1.5.0': - resolution: {integrity: sha512-MYsBzfPki4gthY5HnYN7jgInhAZ7Ac1cYDoRWFomwGHWEX7odTEzbtg9kf/QSo7XEsEAqlQugA6gJ2WS2DEa3g==} - peerDependencies: - '@remix-run/react': ^2 - '@sveltejs/kit': ^1 || ^2 - next: '>= 13' - react: ^18 || ^19 || ^19.0.0-rc - svelte: '>= 4' - vue: ^3 - vue-router: ^4 - peerDependenciesMeta: - '@remix-run/react': - optional: true - '@sveltejs/kit': - optional: true - next: - optional: true - react: - optional: true - svelte: - optional: true - vue: - optional: true - vue-router: - optional: true - - '@vercel/speed-insights@1.2.0': - resolution: {integrity: sha512-y9GVzrUJ2xmgtQlzFP2KhVRoCglwfRQgjyfY607aU0hh0Un6d0OUyrJkjuAlsV18qR4zfoFPs/BiIj9YDS6Wzw==} - peerDependencies: - '@sveltejs/kit': ^1 || ^2 - next: '>= 13' - react: ^18 || ^19 || ^19.0.0-rc - svelte: '>= 4' - vue: ^3 - vue-router: ^4 - peerDependenciesMeta: - '@sveltejs/kit': - optional: true - next: - optional: true - react: - optional: true - svelte: - optional: true - vue: - optional: true - vue-router: - optional: true - '@vitejs/plugin-react@4.6.0': resolution: {integrity: sha512-5Kgff+m8e2PB+9j51eGHEpn5kUzRKH2Ry0qGoe8ItJg7pqnkPrYPkDQZGgGmTa0EGarHrkjLvOdU3b1fzI8otQ==} engines: {node: ^14.18.0 || >=16.0.0} @@ -12350,20 +12295,6 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.9.2': optional: true - '@vercel/analytics@1.5.0(next@16.2.6(@babel/core@7.27.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)(vue-router@4.5.1(vue@3.5.17(typescript@5.8.3)))(vue@3.5.17(typescript@5.8.3))': - optionalDependencies: - next: 16.2.6(@babel/core@7.27.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - react: 19.2.6 - vue: 3.5.17(typescript@5.8.3) - vue-router: 4.5.1(vue@3.5.17(typescript@5.8.3)) - - '@vercel/speed-insights@1.2.0(next@16.2.6(@babel/core@7.27.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)(vue-router@4.5.1(vue@3.5.17(typescript@5.8.3)))(vue@3.5.17(typescript@5.8.3))': - optionalDependencies: - next: 16.2.6(@babel/core@7.27.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - react: 19.2.6 - vue: 3.5.17(typescript@5.8.3) - vue-router: 4.5.1(vue@3.5.17(typescript@5.8.3)) - '@vitejs/plugin-react@4.6.0(vite@7.2.4(@types/node@20.19.1)(jiti@2.7.0)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0))': dependencies: '@babel/core': 7.27.7 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 407952f3a..e3236a9c4 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -4,7 +4,6 @@ allowBuilds: "@prisma/engines": true "@sentry/cli": true "@tailwindcss/oxide": true - "@vercel/speed-insights": false esbuild: true prisma: true sharp: true diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 6a4b15df3..12cee3b15 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -2,8 +2,6 @@ import './globals.css' import { ClerkProvider } from '@clerk/nextjs' import { shadesOfPurple } from '@clerk/themes' import { GoogleAnalytics } from '@next/third-parties/google' -import { Analytics } from '@vercel/analytics/next' -import { SpeedInsights } from '@vercel/speed-insights/next' import { type Metadata, type Viewport } from 'next' import { Inter } from 'next/font/google' import { connection } from 'next/server' @@ -55,18 +53,12 @@ export default function RootLayout(props: PropsWithChildren) { <> - {env.GA_ID && } )} {env.ENABLE_KOFI_WIDGET && } )} - {env.ENABLE_ANALYTICS && env.VERCEL_ANALYTICS_ENABLED && ( - - - - )} diff --git a/src/app/privacy/page.tsx b/src/app/privacy/page.tsx index e5a312329..1a6d47a1f 100644 --- a/src/app/privacy/page.tsx +++ b/src/app/privacy/page.tsx @@ -14,7 +14,7 @@ function PrivacyPolicyPage() {

Privacy Policy

- Last updated: August 1, 2025 + Last updated: June 6, 2026

@@ -169,7 +169,7 @@ function PrivacyPolicyPage() { Clerk: User authentication and account management
  • - Vercel: Website hosting and analytics + Vercel: Website hosting
  • Supabase: Database and storage services diff --git a/src/lib/analytics/utils/sendAnalyticsEvent.test.ts b/src/lib/analytics/utils/sendAnalyticsEvent.test.ts index bd149e207..07b7a3757 100644 --- a/src/lib/analytics/utils/sendAnalyticsEvent.test.ts +++ b/src/lib/analytics/utils/sendAnalyticsEvent.test.ts @@ -5,17 +5,12 @@ const mocks = vi.hoisted(() => ({ isTrackingAllowed: vi.fn(() => true), loggerLog: vi.fn(), sendGAEvent: vi.fn(), - track: vi.fn(), })) vi.mock('@next/third-parties/google', () => ({ sendGAEvent: mocks.sendGAEvent, })) -vi.mock('@vercel/analytics', () => ({ - track: mocks.track, -})) - vi.mock('@/lib/logger', () => ({ logger: { log: mocks.loggerLog, @@ -50,7 +45,6 @@ describe('sendAnalyticsEvent', () => { NEXT_PUBLIC_APP_ENV: 'production', NEXT_PUBLIC_ENABLE_ANALYTICS: 'false', NEXT_PUBLIC_GA_ID: 'G-TEST', - NEXT_PUBLIC_VERCEL_ANALYTICS_ENABLED: 'true', }) sendAnalyticsEvent({ @@ -58,17 +52,15 @@ describe('sendAnalyticsEvent', () => { action: 'support_banner_shown', }) - expect(mocks.track).not.toHaveBeenCalled() expect(mocks.sendGAEvent).not.toHaveBeenCalled() }) - it('sends enabled analytics services when the master analytics flag is enabled', async () => { + it('sends Google Analytics events when analytics are enabled', async () => { const { sendAnalyticsEvent } = await loadSendAnalyticsEvent({ NODE_ENV: 'production', NEXT_PUBLIC_APP_ENV: 'production', NEXT_PUBLIC_ENABLE_ANALYTICS: 'true', NEXT_PUBLIC_GA_ID: 'G-TEST', - NEXT_PUBLIC_VERCEL_ANALYTICS_ENABLED: 'true', }) sendAnalyticsEvent({ @@ -76,10 +68,6 @@ describe('sendAnalyticsEvent', () => { action: 'support_banner_shown', }) - expect(mocks.track).toHaveBeenCalledWith( - 'support_banner_shown', - expect.objectContaining({ category: ANALYTICS_CATEGORIES.ENGAGEMENT }), - ) expect(mocks.sendGAEvent).toHaveBeenCalledWith( 'event', 'support_banner_shown', diff --git a/src/lib/analytics/utils/sendAnalyticsEvent.ts b/src/lib/analytics/utils/sendAnalyticsEvent.ts index e641c518f..1a367cff7 100644 --- a/src/lib/analytics/utils/sendAnalyticsEvent.ts +++ b/src/lib/analytics/utils/sendAnalyticsEvent.ts @@ -1,16 +1,12 @@ import { sendGAEvent } from '@next/third-parties/google' -import { track } from '@vercel/analytics' import { type AnalyticsEventData } from '@/lib/analytics/analytics.types' import { env } from '@/lib/env' import { logger } from '@/lib/logger' import { isTrackingAllowed } from './isTrackingAllowed' -/** - * Send analytics event with proper consent checking and environment handling - */ + export function sendAnalyticsEvent(params: AnalyticsEventData) { if (!isTrackingAllowed(params.category)) return - // Build event data with proper typing const eventData: Record = { category: params.category, action: params.action, @@ -40,17 +36,15 @@ export function sendAnalyticsEvent(params: AnalyticsEventData) { if (params.duration) eventData.duration = params.duration if (params.value !== undefined) eventData.value = params.value - // Add metadata if (params.metadata) { Object.entries(params.metadata).forEach(([key, value]) => { eventData[key] = value }) } - // Log in development, send it to external services only when explicitly enabled. if (env.IS_DEVELOPMENT_BUILD) { const context = typeof window !== 'undefined' ? 'CLIENT' : 'SERVER' - return logger.log(`📊 Analytics Event [${context}]:`, { + return logger.log(`Analytics Event [${context}]:`, { category: params.category, action: params.action, data: eventData, @@ -58,7 +52,6 @@ export function sendAnalyticsEvent(params: AnalyticsEventData) { } if (typeof window !== 'undefined' && env.ENABLE_ANALYTICS) { - if (env.VERCEL_ANALYTICS_ENABLED) track(params.action, eventData) if (env.GA_ID) { sendGAEvent('event', params.action, { event_category: params.category, diff --git a/src/lib/env.ts b/src/lib/env.ts index 06b815593..47f68f09d 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -18,7 +18,6 @@ interface Env { GA_ID: string LOCAL_STORAGE_PREFIX: string ENABLE_SW: boolean - VERCEL_ANALYTICS_ENABLED: boolean DISABLE_COOKIE_BANNER: boolean APP_ENV: AppEnv IS_PUBLIC_PRODUCTION: boolean @@ -79,8 +78,6 @@ export const env = { ENABLE_SW: process.env.NEXT_PUBLIC_ENABLE_SW === 'true', - VERCEL_ANALYTICS_ENABLED: process.env.NEXT_PUBLIC_VERCEL_ANALYTICS_ENABLED === 'true', - DISABLE_COOKIE_BANNER: process.env.NEXT_PUBLIC_DISABLE_COOKIE_BANNER === 'true', APP_ENV, diff --git a/tests/helpers/external-services.ts b/tests/helpers/external-services.ts index 3d69adcf8..3a438082b 100644 --- a/tests/helpers/external-services.ts +++ b/tests/helpers/external-services.ts @@ -6,14 +6,6 @@ const transparentPng = Buffer.from( ) export async function registerExternalServiceMocks(page: Page) { - await page.route('**/_vercel/speed-insights/script.js*', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/javascript', - body: '', - }) - }) - await page.route( 'https://storage.ko-fi.com/cdn/scripts/floating-chat-wrapper.css*', async (route) => { diff --git a/tests/third-party-services.spec.ts b/tests/third-party-services.spec.ts index 1e630a5aa..0a495b022 100644 --- a/tests/third-party-services.spec.ts +++ b/tests/third-party-services.spec.ts @@ -4,8 +4,6 @@ const OPTIONAL_SERVICE_REQUEST_PATTERNS = [ 'storage.ko-fi.com', 'googletagmanager.com', 'google-analytics.com', - '_vercel/insights', - '_vercel/speed-insights', 'ingest.us.sentry.io', ] as const From b84bbcdf4f3d26f14ad94c4fff72e4cd70db2587 Mon Sep 17 00:00:00 2001 From: Producdevity Date: Sat, 6 Jun 2026 16:41:55 +0200 Subject: [PATCH 55/87] refactor handheld comment creation --- .../api/routers/listings/comments.test.ts | 125 +++++++++++++++++- src/server/api/routers/listings/comments.ts | 79 +---------- .../repositories/comments.repository.ts | 72 ++++++++-- .../services/listing-comment.service.ts | 95 +++++++++++++ 4 files changed, 283 insertions(+), 88 deletions(-) create mode 100644 src/server/services/listing-comment.service.ts diff --git a/src/server/api/routers/listings/comments.test.ts b/src/server/api/routers/listings/comments.test.ts index 37060c3fd..b83c7fc2a 100644 --- a/src/server/api/routers/listings/comments.test.ts +++ b/src/server/api/routers/listings/comments.test.ts @@ -7,6 +7,9 @@ vi.unmock('@/server/api/root') const mockHandleCommentVoteTrustEffects = vi.fn().mockResolvedValue(undefined) const mockEmitNotificationEvent = vi.fn() const mockCheckSpamContent = vi.fn().mockResolvedValue(undefined) +const mockAnalyticsComment = vi.fn() +const mockAnalyticsCommentVote = vi.fn() +const mockAnalyticsFirstTimeAction = vi.fn() vi.mock('@/server/utils/vote-trust-effects', () => ({ handleCommentVoteTrustEffects: (...args: unknown[]) => mockHandleCommentVoteTrustEffects(...args), @@ -33,8 +36,13 @@ vi.mock('@/server/utils/spam-check', () => ({ vi.mock('@/lib/analytics', () => ({ default: { - engagement: { comment: vi.fn(), commentVote: vi.fn() }, - userJourney: { firstTimeAction: vi.fn() }, + engagement: { + comment: (...args: unknown[]) => mockAnalyticsComment(...args), + commentVote: (...args: unknown[]) => mockAnalyticsCommentVote(...args), + }, + userJourney: { + firstTimeAction: (...args: unknown[]) => mockAnalyticsFirstTimeAction(...args), + }, }, })) @@ -44,6 +52,7 @@ 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' +const PARENT_COMMENT_ID = '00000000-0000-4000-a000-000000000021' function createMockPrisma() { const mockTx = { @@ -244,6 +253,118 @@ describe('handheld comments router — create', () => { expect(prisma.comment.create).toHaveBeenCalled() }) + it('emits listing comment notification and analytics for a top-level comment', async () => { + const { caller } = createCaller() + + await caller.create({ + listingId: LISTING_ID, + content: 'Runs well with these settings', + }) + + expect(mockEmitNotificationEvent).toHaveBeenCalledWith({ + eventType: 'LISTING_COMMENTED', + entityType: 'listing', + entityId: LISTING_ID, + triggeredBy: USER_ID, + payload: { + listingId: LISTING_ID, + commentId: COMMENT_ID, + parentId: undefined, + commentText: 'Runs well with these settings', + }, + }) + expect(mockAnalyticsComment).toHaveBeenCalledWith({ + action: 'created', + commentId: COMMENT_ID, + listingId: LISTING_ID, + isReply: false, + contentLength: 'Runs well with these settings'.length, + }) + expect(mockAnalyticsFirstTimeAction).toHaveBeenCalledWith({ + userId: USER_ID, + action: 'first_comment', + }) + }) + + it('emits reply notification and analytics for a child comment', async () => { + const { caller, prisma } = createCaller() + prisma.comment.findUnique.mockResolvedValue({ id: PARENT_COMMENT_ID }) + + await caller.create({ + listingId: LISTING_ID, + content: 'Replying with more settings', + parentId: PARENT_COMMENT_ID, + }) + + expect(prisma.comment.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + parent: { connect: { id: PARENT_COMMENT_ID } }, + }), + }), + ) + expect(mockEmitNotificationEvent).toHaveBeenCalledWith( + expect.objectContaining({ + eventType: 'COMMENT_REPLIED', + payload: expect.objectContaining({ + listingId: LISTING_ID, + commentId: COMMENT_ID, + parentId: PARENT_COMMENT_ID, + commentText: 'Replying with more settings', + }), + }), + ) + expect(mockAnalyticsComment).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'reply', + isReply: true, + }), + ) + }) + + it('does not track first comment journey analytics after the first comment', async () => { + const { caller, prisma } = createCaller() + prisma.comment.count.mockResolvedValue(2) + + await caller.create({ + listingId: LISTING_ID, + content: 'Another comment', + }) + + expect(mockAnalyticsFirstTimeAction).not.toHaveBeenCalled() + }) + + it('does not check spam or create when the listing is missing', async () => { + const { caller, prisma } = createCaller() + prisma.listing.findUnique.mockResolvedValue(null) + + await expect( + caller.create({ + listingId: LISTING_ID, + content: 'Runs well with these settings', + }), + ).rejects.toThrow('Report not found') + + expect(mockCheckSpamContent).not.toHaveBeenCalled() + expect(prisma.comment.create).not.toHaveBeenCalled() + }) + + it('does not check spam or create when the parent comment is missing', async () => { + const { caller, prisma } = createCaller() + prisma.comment.findUnique.mockResolvedValue(null) + + await expect( + caller.create({ + listingId: LISTING_ID, + content: 'Replying with more settings', + parentId: PARENT_COMMENT_ID, + }), + ).rejects.toThrow('Parent comment not found') + + expect(mockCheckSpamContent).not.toHaveBeenCalled() + expect(prisma.comment.create).not.toHaveBeenCalled() + }) + it('passes a human verification token to the spam check when retrying creation', async () => { const { caller, prisma } = createCaller() diff --git a/src/server/api/routers/listings/comments.ts b/src/server/api/routers/listings/comments.ts index ad4ff94ba..890c08f09 100644 --- a/src/server/api/routers/listings/comments.ts +++ b/src/server/api/routers/listings/comments.ts @@ -16,90 +16,21 @@ import { canManageCommentPins } from '@/server/api/utils/pinPermissions' import { notificationEventEmitter, NOTIFICATION_EVENTS } from '@/server/notifications/eventEmitter' import { CommentsRepository } from '@/server/repositories/comments.repository' import { logAudit } from '@/server/services/audit.service' +import { ListingCommentService } from '@/server/services/listing-comment.service' import { isUserBanned } from '@/server/utils/query-builders' -import { checkSpamContent } from '@/server/utils/spam-check' import { handleCommentVoteTrustEffects } from '@/server/utils/vote-trust-effects' import { roleIncludesRole } from '@/utils/permission-system' import { canDeleteComment, canEditComment } from '@/utils/permissions' import { AuditAction, AuditEntityType, Role } from '@orm/client' export const commentsRouter = createTRPCRouter({ - // TODO: This should use a repository, too much logic in here. create: protectedProcedure.input(CreateCommentSchema).mutation(async ({ ctx, input }) => { - const { listingId, content, parentId, humanVerificationToken } = input - const userId = ctx.session.user.id - - const listing = await ctx.prisma.listing.findUnique({ - where: { id: listingId }, - }) - - if (!listing) return ResourceError.listing.notFound() - - // If parentId is provided, check if parent comment exists - if (parentId) { - const parentComment = await ctx.prisma.comment.findUnique({ where: { id: parentId } }) - - if (!parentComment) return ResourceError.comment.parentNotFound() - } - - const userExists = await ctx.prisma.user.findUnique({ - where: { id: userId }, - select: { id: true }, - }) - - if (!userExists) return ResourceError.user.notInDatabase(userId) - - await checkSpamContent({ - prisma: ctx.prisma, - userId, - content, - entityType: 'comment', - challengeMode: 'challenge', - humanVerificationToken, + const service = new ListingCommentService(ctx.prisma) + return service.create({ + ...input, + userId: ctx.session.user.id, headers: ctx.headers, }) - - const repository = new CommentsRepository(ctx.prisma) - const comment = await repository.create({ - content, - user: { connect: { id: userId } }, - listing: { connect: { id: listingId } }, - ...(parentId && { parent: { connect: { id: parentId } } }), - }) - - notificationEventEmitter.emitNotificationEvent({ - eventType: parentId - ? NOTIFICATION_EVENTS.COMMENT_REPLIED - : NOTIFICATION_EVENTS.LISTING_COMMENTED, - entityType: 'listing', - entityId: listingId, - triggeredBy: userId, - payload: { - listingId, - commentId: comment.id, - parentId: parentId ?? undefined, - commentText: content, - }, - }) - - analytics.engagement.comment({ - action: parentId ? 'reply' : 'created', - commentId: comment.id, - listingId: listingId, - isReply: !!parentId, - contentLength: content.length, - }) - - // Check if this is user's first comment for journey analytics - const userCommentCount = await ctx.prisma.comment.count({ - where: { userId: userId }, - }) - - if (userCommentCount === 1) { - analytics.userJourney.firstTimeAction({ userId: userId, action: 'first_comment' }) - } - - return comment }), get: publicProcedure.input(GetCommentsSchema).query(async ({ ctx, input }) => { diff --git a/src/server/repositories/comments.repository.ts b/src/server/repositories/comments.repository.ts index 71145eab8..ca197cbd7 100644 --- a/src/server/repositories/comments.repository.ts +++ b/src/server/repositories/comments.repository.ts @@ -123,21 +123,65 @@ export class CommentsRepository extends BaseRepository { }) } - /** - * Create a new comment - */ - async create( - data: Prisma.CommentCreateInput, - ): Promise> { - return this.prisma.comment.create({ - data, - include: CommentsRepository.includes.minimal, + async listingExists(listingId: string): Promise { + const listing = await this.handleDatabaseOperation( + () => this.prisma.listing.findUnique({ where: { id: listingId }, select: { id: true } }), + 'Listing', + ) + + return listing !== null + } + + async commentExists(commentId: string): Promise { + const comment = await this.handleDatabaseOperation( + () => this.prisma.comment.findUnique({ where: { id: commentId }, select: { id: true } }), + 'Comment', + ) + + return comment !== null + } + + async userExists(userId: string): Promise { + const user = await this.handleDatabaseOperation( + () => this.prisma.user.findUnique({ where: { id: userId }, select: { id: true } }), + 'User', + ) + + return user !== null + } + + async countByUser(userId: string): Promise { + return this.handleDatabaseOperation( + () => this.prisma.comment.count({ where: { userId } }), + 'Comment', + ) + } + + async create(data: Prisma.CommentCreateInput): Promise { + return this.handleDatabaseOperation( + () => + this.prisma.comment.create({ + data, + include: CommentsRepository.includes.minimal, + }), + 'Comment', + ) + } + + async createForListing(input: { + content: string + userId: string + listingId: string + parentId?: string + }): Promise { + return this.create({ + content: input.content, + user: { connect: { id: input.userId } }, + listing: { connect: { id: input.listingId } }, + ...(input.parentId ? { parent: { connect: { id: input.parentId } } } : {}), }) } - /** - * Update a comment - */ async update( id: string, data: Prisma.CommentUpdateInput, @@ -260,3 +304,7 @@ export class CommentsRepository extends BaseRepository { } } } + +export type MinimalComment = Prisma.CommentGetPayload<{ + include: typeof CommentsRepository.includes.minimal +}> diff --git a/src/server/services/listing-comment.service.ts b/src/server/services/listing-comment.service.ts new file mode 100644 index 000000000..4fe81b165 --- /dev/null +++ b/src/server/services/listing-comment.service.ts @@ -0,0 +1,95 @@ +import analytics from '@/lib/analytics' +import { ResourceError } from '@/lib/errors' +import { notificationEventEmitter, NOTIFICATION_EVENTS } from '@/server/notifications/eventEmitter' +import { CommentsRepository, type MinimalComment } from '@/server/repositories/comments.repository' +import { checkSpamContent } from '@/server/utils/spam-check' +import { type PrismaClient } from '@orm/client' + +interface CreateListingCommentInput { + listingId: string + content: string + userId: string + parentId?: string | null + humanVerificationToken?: string + headers?: Headers +} + +export class ListingCommentService { + private readonly comments: CommentsRepository + + constructor(private readonly prisma: PrismaClient) { + this.comments = new CommentsRepository(prisma) + } + + async create(input: CreateListingCommentInput): Promise { + if (!(await this.comments.listingExists(input.listingId))) { + return ResourceError.listing.notFound() + } + + if (input.parentId && !(await this.comments.commentExists(input.parentId))) { + return ResourceError.comment.parentNotFound() + } + + if (!(await this.comments.userExists(input.userId))) { + return ResourceError.user.notInDatabase(input.userId) + } + + await checkSpamContent({ + prisma: this.prisma, + userId: input.userId, + content: input.content, + entityType: 'comment', + challengeMode: 'challenge', + humanVerificationToken: input.humanVerificationToken, + headers: input.headers, + }) + + const comment = await this.comments.createForListing({ + content: input.content, + userId: input.userId, + listingId: input.listingId, + parentId: input.parentId ?? undefined, + }) + + this.emitCreatedNotification(comment.id, input) + this.trackCreatedComment(comment.id, input) + await this.trackFirstComment(input.userId) + + return comment + } + + private emitCreatedNotification(commentId: string, input: CreateListingCommentInput): void { + notificationEventEmitter.emitNotificationEvent({ + eventType: input.parentId + ? NOTIFICATION_EVENTS.COMMENT_REPLIED + : NOTIFICATION_EVENTS.LISTING_COMMENTED, + entityType: 'listing', + entityId: input.listingId, + triggeredBy: input.userId, + payload: { + listingId: input.listingId, + commentId, + parentId: input.parentId ?? undefined, + commentText: input.content, + }, + }) + } + + private trackCreatedComment(commentId: string, input: CreateListingCommentInput): void { + analytics.engagement.comment({ + action: input.parentId ? 'reply' : 'created', + commentId, + listingId: input.listingId, + isReply: Boolean(input.parentId), + contentLength: input.content.length, + }) + } + + private async trackFirstComment(userId: string): Promise { + const userCommentCount = await this.comments.countByUser(userId) + + if (userCommentCount === 1) { + analytics.userJourney.firstTimeAction({ userId, action: 'first_comment' }) + } + } +} From c596f6446ffcb402ed9d365d08334e979aa247af Mon Sep 17 00:00:00 2001 From: Producdevity Date: Sat, 6 Jun 2026 16:59:51 +0200 Subject: [PATCH 56/87] Refine report notification handling --- .../ActivityCard/ReportActivityItem.tsx | 4 +- src/server/api/routers/listingReports.test.ts | 3 +- src/server/api/routers/listingReports.ts | 51 +------ .../api/routers/mobile/listingReports.test.ts | 3 +- .../api/routers/mobile/listingReports.ts | 43 +----- src/server/api/routers/pcListings.test.ts | 3 +- src/server/api/routers/pcListings.ts | 57 +------- src/server/notifications/eventEmitter.ts | 1 + src/server/notifications/reportEvents.ts | 5 +- src/server/notifications/service.test.ts | 1 + src/server/notifications/service.ts | 3 +- .../services/report-submission.service.ts | 130 ++++++++++++++++++ 12 files changed, 160 insertions(+), 144 deletions(-) create mode 100644 src/server/services/report-submission.service.ts diff --git a/src/app/admin/dashboard/components/ActivityCard/ReportActivityItem.tsx b/src/app/admin/dashboard/components/ActivityCard/ReportActivityItem.tsx index 4460bd752..fc6398b3b 100644 --- a/src/app/admin/dashboard/components/ActivityCard/ReportActivityItem.tsx +++ b/src/app/admin/dashboard/components/ActivityCard/ReportActivityItem.tsx @@ -11,8 +11,8 @@ interface Props { export function ReportActivityItem(props: Props) { const href = props.report.type === 'listing' - ? `/admin/reports?listing=${props.report.targetId}` - : `/admin/reports?pcListing=${props.report.targetId}` + ? `/listings/${props.report.targetId}` + : `/pc-listings/${props.report.targetId}` return (
    diff --git a/src/server/api/routers/listingReports.test.ts b/src/server/api/routers/listingReports.test.ts index 59849ec62..4bf5115bf 100644 --- a/src/server/api/routers/listingReports.test.ts +++ b/src/server/api/routers/listingReports.test.ts @@ -108,11 +108,12 @@ describe('listingReportsRouter create', () => { entityType: 'listingReport', entityId: REPORT_ID, triggeredBy: USER_ID, + includeTriggeredBy: true, payload: { reportId: REPORT_ID, contentId: LISTING_ID, contentType: 'Compatibility Report', - actionUrl: `/admin/reports?listing=${LISTING_ID}`, + actionUrl: `/listings/${LISTING_ID}`, listingId: LISTING_ID, }, }) diff --git a/src/server/api/routers/listingReports.ts b/src/server/api/routers/listingReports.ts index 5c999f48b..d780346c7 100644 --- a/src/server/api/routers/listingReports.ts +++ b/src/server/api/routers/listingReports.ts @@ -15,8 +15,8 @@ import { protectedProcedure, publicProcedure, } from '@/server/api/trpc' -import { emitReportCreatedNotification } from '@/server/notifications/reportEvents' import { getAuthorReportCounts } from '@/server/services/report-stats.service' +import { ReportSubmissionService } from '@/server/services/report-submission.service' import { paginate } from '@/server/utils/pagination' import { validateEnum, sanitizeInput, validatePagination } from '@/server/utils/security-validation' import { PERMISSIONS } from '@/utils/permission-system' @@ -134,55 +134,14 @@ export const listingReportsRouter = createTRPCRouter({ validateEnum(reason, Object.values(ReportReason), 'reason') - const sanitizedDescription = description ? sanitizeInput(description) : description + const reportSubmissionService = new ReportSubmissionService(ctx.prisma) - const listing = await ctx.prisma.listing.findUnique({ - where: { id: listingId }, - include: { author: true }, - }) - - if (!listing) return ResourceError.listing.notFound() - - if (listing.authorId === userId) { - return ResourceError.listingReport.cannotReportOwnListing() - } - - const existingReport = await ctx.prisma.listingReport.findUnique({ - where: { - listingId_reportedById: { - listingId, - reportedById: userId, - }, - }, - }) - - if (existingReport) return ResourceError.listingReport.alreadyExists() - - const report = await ctx.prisma.listingReport.create({ - data: { - listingId, - reportedById: userId, - reason, - description: sanitizedDescription, - }, - include: { - listing: { - include: { - game: { select: { title: true } }, - author: { select: { name: true } }, - }, - }, - }, - }) - - emitReportCreatedNotification({ - type: 'listing', - reportId: report.id, + return await reportSubmissionService.createListingReport({ listingId, reportedById: userId, + reason, + description, }) - - return report }), updateStatus: permissionProcedure(PERMISSIONS.MANAGE_USER_BANS) diff --git a/src/server/api/routers/mobile/listingReports.test.ts b/src/server/api/routers/mobile/listingReports.test.ts index ac7a1deec..d1671b9c8 100644 --- a/src/server/api/routers/mobile/listingReports.test.ts +++ b/src/server/api/routers/mobile/listingReports.test.ts @@ -112,11 +112,12 @@ describe('mobileListingReportsRouter create', () => { entityType: 'listingReport', entityId: REPORT_ID, triggeredBy: USER_ID, + includeTriggeredBy: true, payload: { reportId: REPORT_ID, contentId: LISTING_ID, contentType: 'Compatibility Report', - actionUrl: `/admin/reports?listing=${LISTING_ID}`, + actionUrl: `/listings/${LISTING_ID}`, listingId: LISTING_ID, }, }) diff --git a/src/server/api/routers/mobile/listingReports.ts b/src/server/api/routers/mobile/listingReports.ts index 8ef725d05..e316538ee 100644 --- a/src/server/api/routers/mobile/listingReports.ts +++ b/src/server/api/routers/mobile/listingReports.ts @@ -1,13 +1,11 @@ -import { AppError, ResourceError } from '@/lib/errors' import { CreateListingReportSchema, GetUserReportStatsSchema } from '@/schemas/listingReport' import { createMobileTRPCRouter, mobileProtectedProcedure, mobilePublicProcedure, } from '@/server/api/mobileContext' -import { emitReportCreatedNotification } from '@/server/notifications/reportEvents' import { getAuthorReportCounts } from '@/server/services/report-stats.service' -import { sanitizeInput } from '@/server/utils/security-validation' +import { ReportSubmissionService } from '@/server/services/report-submission.service' export const mobileListingReportsRouter = createMobileTRPCRouter({ create: mobileProtectedProcedure @@ -15,44 +13,13 @@ export const mobileListingReportsRouter = createMobileTRPCRouter({ .mutation(async ({ ctx, input }) => { const { listingId, reason, description } = input const userId = ctx.session.user.id - const sanitizedDescription = description ? sanitizeInput(description) : description - const listing = await ctx.prisma.listing.findUnique({ - where: { id: listingId }, - include: { author: true }, - }) - - if (!listing) return ResourceError.listing.notFound() - - if (listing.authorId === userId) { - return AppError.badRequest('You cannot report your own listing') - } - - const existingReport = await ctx.prisma.listingReport.findUnique({ - where: { listingId_reportedById: { listingId, reportedById: userId } }, - }) - - if (existingReport) { - return AppError.badRequest('You have already reported this listing') - } - - const report = await ctx.prisma.listingReport.create({ - data: { listingId, reportedById: userId, reason, description: sanitizedDescription }, - include: { - listing: { - include: { - game: { select: { title: true } }, - author: { select: { name: true } }, - }, - }, - }, - }) - - emitReportCreatedNotification({ - type: 'listing', - reportId: report.id, + const reportSubmissionService = new ReportSubmissionService(ctx.prisma) + const report = await reportSubmissionService.createListingReport({ listingId, reportedById: userId, + reason, + description, }) return { diff --git a/src/server/api/routers/pcListings.test.ts b/src/server/api/routers/pcListings.test.ts index 66aa1f125..d60abece5 100644 --- a/src/server/api/routers/pcListings.test.ts +++ b/src/server/api/routers/pcListings.test.ts @@ -610,11 +610,12 @@ describe('pcListings trust integration', () => { entityType: 'pcListingReport', entityId: '00000000-0000-4000-a000-000000000030', triggeredBy: USER_ID, + includeTriggeredBy: true, payload: { reportId: '00000000-0000-4000-a000-000000000030', contentId: LISTING_ID, contentType: 'PC Compatibility Report', - actionUrl: `/admin/reports?pcListing=${LISTING_ID}`, + actionUrl: `/pc-listings/${LISTING_ID}`, pcListingId: LISTING_ID, }, }) diff --git a/src/server/api/routers/pcListings.ts b/src/server/api/routers/pcListings.ts index 4e64b05c9..b82a3bfb1 100644 --- a/src/server/api/routers/pcListings.ts +++ b/src/server/api/routers/pcListings.ts @@ -61,10 +61,10 @@ import { invalidatePcListingsSeo, } from '@/server/cache/invalidation' import { NOTIFICATION_EVENTS, notificationEventEmitter } from '@/server/notifications/eventEmitter' -import { emitReportCreatedNotification } from '@/server/notifications/reportEvents' import { PcListingsRepository } from '@/server/repositories/pc-listings.repository' import { UserPcPresetsRepository } from '@/server/repositories/user-pc-presets.repository' import { logAudit } from '@/server/services/audit.service' +import { ReportSubmissionService } from '@/server/services/report-submission.service' import { autoRejectRiskyPcReports } from '@/server/services/review-risk-auto-reject.service' import { attachReviewRiskProfiles, @@ -77,7 +77,7 @@ import { listingStatsCache } from '@/server/utils/cache' import { normalizeCustomFieldValues } from '@/server/utils/custom-field-values' import { paginate } from '@/server/utils/pagination' import { isUserBanned } from '@/server/utils/query-builders' -import { sanitizeInput, validatePagination } from '@/server/utils/security-validation' +import { validatePagination } from '@/server/utils/security-validation' import { checkSpamContent } from '@/server/utils/spam-check' import { updatePcListingVoteCounts } from '@/server/utils/vote-counts' import { @@ -1688,59 +1688,14 @@ export const pcListingsRouter = createTRPCRouter({ .mutation(async ({ ctx, input }) => { const { pcListingId, reason, description } = input const userId = ctx.session.user.id - const sanitizedDescription = description ? sanitizeInput(description) : description + const reportSubmissionService = new ReportSubmissionService(ctx.prisma) - const pcListing = await ctx.prisma.pcListing.findUnique({ - where: { id: pcListingId }, - include: { author: true }, - }) - - if (!pcListing) { - return ResourceError.pcListing.notFound() - } - - if (pcListing.authorId === userId) { - return AppError.badRequest('You cannot report your own listing') - } - - const existingReport = await ctx.prisma.pcListingReport.findUnique({ - where: { - pcListingId_reportedById: { - pcListingId, - reportedById: userId, - }, - }, - }) - - if (existingReport) { - return AppError.badRequest('You have already reported this listing') - } - - const report = await ctx.prisma.pcListingReport.create({ - data: { - pcListingId, - reportedById: userId, - reason, - description: sanitizedDescription, - }, - include: { - pcListing: { - include: { - game: { select: { title: true } }, - author: { select: { name: true } }, - }, - }, - }, - }) - - emitReportCreatedNotification({ - type: 'pcListing', - reportId: report.id, + return await reportSubmissionService.createPcListingReport({ pcListingId, reportedById: userId, + reason, + description, }) - - return report }), getReports: permissionProcedure(PERMISSIONS.VIEW_USER_BANS) diff --git a/src/server/notifications/eventEmitter.ts b/src/server/notifications/eventEmitter.ts index 1cee9b6d7..3398fe035 100644 --- a/src/server/notifications/eventEmitter.ts +++ b/src/server/notifications/eventEmitter.ts @@ -6,6 +6,7 @@ export interface NotificationEventData { entityType: string entityId: string triggeredBy?: string + includeTriggeredBy?: boolean payload?: NotificationEventPayload } diff --git a/src/server/notifications/reportEvents.ts b/src/server/notifications/reportEvents.ts index 5e1676600..0ddcf222d 100644 --- a/src/server/notifications/reportEvents.ts +++ b/src/server/notifications/reportEvents.ts @@ -27,13 +27,14 @@ export function emitReportCreatedNotification(input: ReportNotificationInput): v entityType: isPcListing ? 'pcListingReport' : 'listingReport', entityId: input.reportId, triggeredBy: input.reportedById, + includeTriggeredBy: true, payload: { reportId: input.reportId, contentId, contentType: isPcListing ? 'PC Compatibility Report' : 'Compatibility Report', actionUrl: isPcListing - ? `/admin/reports?pcListing=${contentId}` - : `/admin/reports?listing=${contentId}`, + ? `/pc-listings/${contentId}` + : `/listings/${contentId}`, ...(isPcListing ? { pcListingId: input.pcListingId } : { listingId: input.listingId }), }, }) diff --git a/src/server/notifications/service.test.ts b/src/server/notifications/service.test.ts index c37b2bf2f..6ba9a68f9 100644 --- a/src/server/notifications/service.test.ts +++ b/src/server/notifications/service.test.ts @@ -278,6 +278,7 @@ describe('NotificationService', () => { entityType: 'listingReport', entityId: 'report-1', triggeredBy: 'reporter-1', + includeTriggeredBy: true, payload: { reportId: 'report-1', listingId: 'listing-1' }, }), ) diff --git a/src/server/notifications/service.ts b/src/server/notifications/service.ts index b500018de..3085c1156 100644 --- a/src/server/notifications/service.ts +++ b/src/server/notifications/service.ts @@ -18,7 +18,6 @@ import { } from '@orm/client' import { createEmailService } from './emailService' import { - NOTIFICATION_EVENTS, type NotificationEventData, notificationEventEmitter, } from './eventEmitter' @@ -677,7 +676,7 @@ export class NotificationService { break } - if (eventData.triggeredBy && eventData.eventType !== NOTIFICATION_EVENTS.REPORT_CREATED) { + if (eventData.triggeredBy && !eventData.includeTriggeredBy) { const actorId = eventData.triggeredBy for (let i = userIds.length - 1; i >= 0; i--) { if (userIds[i] === actorId) userIds.splice(i, 1) diff --git a/src/server/services/report-submission.service.ts b/src/server/services/report-submission.service.ts new file mode 100644 index 000000000..da6632bf8 --- /dev/null +++ b/src/server/services/report-submission.service.ts @@ -0,0 +1,130 @@ +import { AppError, ResourceError } from '@/lib/errors' +import { emitReportCreatedNotification } from '@/server/notifications/reportEvents' +import { sanitizeInput } from '@/server/utils/security-validation' +import { type PrismaClient, type ReportReason } from '@orm/client' + +type CreateListingReportInput = { + listingId: string + reason: ReportReason + description?: string + reportedById: string +} + +type CreatePcListingReportInput = { + pcListingId: string + reason: ReportReason + description?: string + reportedById: string +} + +function sanitizeOptionalDescription(description: string | undefined): string | undefined { + return description ? sanitizeInput(description) : description +} + +export class ReportSubmissionService { + constructor(private readonly prisma: PrismaClient) {} + + async createListingReport(input: CreateListingReportInput) { + const sanitizedDescription = sanitizeOptionalDescription(input.description) + + const listing = await this.prisma.listing.findUnique({ + where: { id: input.listingId }, + select: { authorId: true }, + }) + + if (!listing) return ResourceError.listing.notFound() + + if (listing.authorId === input.reportedById) { + return ResourceError.listingReport.cannotReportOwnListing() + } + + const existingReport = await this.prisma.listingReport.findUnique({ + where: { + listingId_reportedById: { + listingId: input.listingId, + reportedById: input.reportedById, + }, + }, + }) + + if (existingReport) return ResourceError.listingReport.alreadyExists() + + const report = await this.prisma.listingReport.create({ + data: { + listingId: input.listingId, + reportedById: input.reportedById, + reason: input.reason, + description: sanitizedDescription, + }, + include: { + listing: { + include: { + game: { select: { title: true } }, + author: { select: { name: true } }, + }, + }, + }, + }) + + emitReportCreatedNotification({ + type: 'listing', + reportId: report.id, + listingId: input.listingId, + reportedById: input.reportedById, + }) + + return report + } + + async createPcListingReport(input: CreatePcListingReportInput) { + const sanitizedDescription = sanitizeOptionalDescription(input.description) + + const pcListing = await this.prisma.pcListing.findUnique({ + where: { id: input.pcListingId }, + select: { authorId: true }, + }) + + if (!pcListing) return ResourceError.pcListing.notFound() + + if (pcListing.authorId === input.reportedById) { + return AppError.forbidden('You cannot report your own listing') + } + + const existingReport = await this.prisma.pcListingReport.findUnique({ + where: { + pcListingId_reportedById: { + pcListingId: input.pcListingId, + reportedById: input.reportedById, + }, + }, + }) + + if (existingReport) return AppError.conflict('You have already reported this listing') + + const report = await this.prisma.pcListingReport.create({ + data: { + pcListingId: input.pcListingId, + reportedById: input.reportedById, + reason: input.reason, + description: sanitizedDescription, + }, + include: { + pcListing: { + include: { + game: { select: { title: true } }, + author: { select: { name: true } }, + }, + }, + }, + }) + + emitReportCreatedNotification({ + type: 'pcListing', + reportId: report.id, + pcListingId: input.pcListingId, + reportedById: input.reportedById, + }) + + return report + } +} From 7142dd18cd9e2f2780805e3fdb48926f4f5c7772 Mon Sep 17 00:00:00 2001 From: Producdevity Date: Sat, 6 Jun 2026 17:34:27 +0200 Subject: [PATCH 57/87] Align report submission errors --- src/lib/errors.ts | 6 ++++++ src/server/services/report-submission.service.ts | 6 +++--- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/lib/errors.ts b/src/lib/errors.ts index 0c6cc8e55..83abf9040 100644 --- a/src/lib/errors.ts +++ b/src/lib/errors.ts @@ -486,6 +486,12 @@ export class ResourceError { cannotReportOwnListing: () => AppError.forbidden('You cannot report your own listing'), } + static pcListingReport = { + notFound: () => AppError.notFound('PC listing report'), + alreadyExists: () => AppError.conflict('You have already reported this listing'), + cannotReportOwnListing: () => AppError.forbidden('You cannot report your own listing'), + } + static userBan = { notFound: () => AppError.notFound('User ban'), alreadyBanned: () => AppError.conflict('User already has an active ban'), diff --git a/src/server/services/report-submission.service.ts b/src/server/services/report-submission.service.ts index da6632bf8..f8b735181 100644 --- a/src/server/services/report-submission.service.ts +++ b/src/server/services/report-submission.service.ts @@ -1,4 +1,4 @@ -import { AppError, ResourceError } from '@/lib/errors' +import { ResourceError } from '@/lib/errors' import { emitReportCreatedNotification } from '@/server/notifications/reportEvents' import { sanitizeInput } from '@/server/utils/security-validation' import { type PrismaClient, type ReportReason } from '@orm/client' @@ -87,7 +87,7 @@ export class ReportSubmissionService { if (!pcListing) return ResourceError.pcListing.notFound() if (pcListing.authorId === input.reportedById) { - return AppError.forbidden('You cannot report your own listing') + return ResourceError.pcListingReport.cannotReportOwnListing() } const existingReport = await this.prisma.pcListingReport.findUnique({ @@ -99,7 +99,7 @@ export class ReportSubmissionService { }, }) - if (existingReport) return AppError.conflict('You have already reported this listing') + if (existingReport) return ResourceError.pcListingReport.alreadyExists() const report = await this.prisma.pcListingReport.create({ data: { From 5e4e835ed6915c89bfc37d449ba01401a3b5d9ff Mon Sep 17 00:00:00 2001 From: Producdevity Date: Sat, 6 Jun 2026 19:12:26 +0200 Subject: [PATCH 58/87] Handle first-comment analytics failures --- .../api/routers/listings/comments.test.ts | 40 +++++++++++++++++++ .../services/listing-comment.service.ts | 8 +++- 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/src/server/api/routers/listings/comments.test.ts b/src/server/api/routers/listings/comments.test.ts index b83c7fc2a..8bbf03e64 100644 --- a/src/server/api/routers/listings/comments.test.ts +++ b/src/server/api/routers/listings/comments.test.ts @@ -10,6 +10,7 @@ const mockCheckSpamContent = vi.fn().mockResolvedValue(undefined) const mockAnalyticsComment = vi.fn() const mockAnalyticsCommentVote = vi.fn() const mockAnalyticsFirstTimeAction = vi.fn() +const mockLoggerError = vi.fn() vi.mock('@/server/utils/vote-trust-effects', () => ({ handleCommentVoteTrustEffects: (...args: unknown[]) => mockHandleCommentVoteTrustEffects(...args), @@ -46,6 +47,12 @@ vi.mock('@/lib/analytics', () => ({ }, })) +vi.mock('@/lib/logger', () => ({ + logger: { + error: (...args: unknown[]) => mockLoggerError(...args), + }, +})) + const { commentsRouter } = await import('./comments') const USER_ID = '00000000-0000-4000-a000-000000000001' @@ -112,6 +119,10 @@ function createCaller(overrides: { userId?: string; role?: Role; prisma?: MockPr } } +function flushBackgroundTasks(): Promise { + return new Promise((resolve) => setTimeout(resolve, 0)) +} + describe('handheld comments router — voteComment', () => { beforeEach(() => { vi.clearAllMocks() @@ -280,6 +291,8 @@ describe('handheld comments router — create', () => { isReply: false, contentLength: 'Runs well with these settings'.length, }) + await flushBackgroundTasks() + expect(mockAnalyticsFirstTimeAction).toHaveBeenCalledWith({ userId: USER_ID, action: 'first_comment', @@ -331,9 +344,36 @@ describe('handheld comments router — create', () => { content: 'Another comment', }) + await flushBackgroundTasks() + expect(mockAnalyticsFirstTimeAction).not.toHaveBeenCalled() }) + it('returns the created comment when first-comment analytics fails', async () => { + const analyticsError = new Error('count failed') + const { caller, prisma } = createCaller() + prisma.comment.count.mockRejectedValue(analyticsError) + + const result = await caller.create({ + listingId: LISTING_ID, + content: 'Runs well with these settings', + }) + + expect(result.id).toBe(COMMENT_ID) + expect(prisma.comment.create).toHaveBeenCalled() + + await flushBackgroundTasks() + + expect(mockLoggerError).toHaveBeenCalledWith( + '[ListingCommentService] Failed to track first comment analytics', + expect.any(Error), + { + userId: USER_ID, + commentId: COMMENT_ID, + }, + ) + }) + it('does not check spam or create when the listing is missing', async () => { const { caller, prisma } = createCaller() prisma.listing.findUnique.mockResolvedValue(null) diff --git a/src/server/services/listing-comment.service.ts b/src/server/services/listing-comment.service.ts index 4fe81b165..4dd96192b 100644 --- a/src/server/services/listing-comment.service.ts +++ b/src/server/services/listing-comment.service.ts @@ -1,5 +1,6 @@ import analytics from '@/lib/analytics' import { ResourceError } from '@/lib/errors' +import { logger } from '@/lib/logger' import { notificationEventEmitter, NOTIFICATION_EVENTS } from '@/server/notifications/eventEmitter' import { CommentsRepository, type MinimalComment } from '@/server/repositories/comments.repository' import { checkSpamContent } from '@/server/utils/spam-check' @@ -53,7 +54,12 @@ export class ListingCommentService { this.emitCreatedNotification(comment.id, input) this.trackCreatedComment(comment.id, input) - await this.trackFirstComment(input.userId) + void this.trackFirstComment(input.userId).catch((error: unknown) => { + logger.error('[ListingCommentService] Failed to track first comment analytics', error, { + userId: input.userId, + commentId: comment.id, + }) + }) return comment } From 7cec92b3b7d24c45ecab0bdfe84a410077fb0d46 Mon Sep 17 00:00:00 2001 From: Producdevity Date: Sat, 6 Jun 2026 20:22:45 +0200 Subject: [PATCH 59/87] refactor: simplify unverify logic in GenericVerifyButton --- src/components/verify/GenericVerifyButton.tsx | 30 ++++++------------- 1 file changed, 9 insertions(+), 21 deletions(-) diff --git a/src/components/verify/GenericVerifyButton.tsx b/src/components/verify/GenericVerifyButton.tsx index 3844412de..2683fa728 100644 --- a/src/components/verify/GenericVerifyButton.tsx +++ b/src/components/verify/GenericVerifyButton.tsx @@ -40,7 +40,6 @@ export default function GenericVerifyButton(props: Props) { const currentUserQuery = api.users.me.useQuery() const userId = currentUserQuery.data?.id - // Check if user is verified developer for this emulator const verifiedDeveloperQuery = api.emulators.getVerifiedDeveloper.useQuery( { emulatorId: props.emulatorId }, { enabled: !!userId && !!props.emulatorId }, @@ -99,42 +98,31 @@ export default function GenericVerifyButton(props: Props) { const handleVerify = () => { if (isPcListing) { - verifyPcListingMutation.mutate({ + return verifyPcListingMutation.mutate({ pcListingId: props.listingId, notes: notes.trim() || undefined, }) - } else { - verifyListingMutation.mutate({ - listingId: props.listingId, - notes: notes.trim() || undefined, - }) } + verifyListingMutation.mutate({ + listingId: props.listingId, + notes: notes.trim() || undefined, + }) } const handleUnverify = () => { - if (isPcListing) { - if (props.verificationId) { - removeVerificationMutation.mutate({ - verificationId: props.verificationId, - }) - } - } else { - unverifyListingMutation.mutate({ - listingId: props.listingId, - }) + if (!isPcListing) { + return unverifyListingMutation.mutate({ listingId: props.listingId }) } + if (!props.verificationId) return + removeVerificationMutation.mutate({ verificationId: props.verificationId }) } - // Don't show button if user is not logged in if (!currentUserQuery.data) return null - // Don't show button if user is not a verified developer for this emulator if (!verifiedDeveloperQuery.data) return null - // Don't show button if user is the author (can't verify own listings) if (props.authorId === userId) return null - // Don't show button if user doesn't have at least DEVELOPER role if (!roleIncludesRole(currentUserQuery.data.role, Role.DEVELOPER)) return null const isLoading = isPcListing From aaf0c7014161fe1a3c7f93965e622d391c6b55ce Mon Sep 17 00:00:00 2001 From: Producdevity Date: Sun, 7 Jun 2026 18:15:33 +0200 Subject: [PATCH 60/87] Remove Supabase-specific backup script and update generic backup logic --- package.json | 1 - scripts/db-backup-supabase.sh | 120 --------------- scripts/db-backup.sh | 272 ++++++++++++++++++---------------- 3 files changed, 143 insertions(+), 250 deletions(-) delete mode 100755 scripts/db-backup-supabase.sh diff --git a/package.json b/package.json index 7cf8ae0a3..b06585ee0 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,6 @@ "clean": "rm -rf .next && rm -rf node_modules/.cache && rm -rf .eslintcache && rm -rf tsconfig.tsbuildinfo", "clean:all": "pnpm clean && rm -rf node_modules", "db:backup": "./scripts/db-backup.sh", - "db:backup:supabase": "./scripts/db-backup-supabase.sh", "db:generate": "pnpm exec prisma generate --sql", "db:migrate:create": "./scripts/db-cmd.sh pnpm exec prisma migrate dev --create-only", "db:migrate:deploy": "./scripts/db-cmd.sh pnpm exec prisma migrate deploy", diff --git a/scripts/db-backup-supabase.sh b/scripts/db-backup-supabase.sh deleted file mode 100755 index ab0511fcc..000000000 --- a/scripts/db-backup-supabase.sh +++ /dev/null @@ -1,120 +0,0 @@ -#!/bin/sh - -# Supabase-compatible backup script -# Creates backups in formats that can be restored to Supabase - -# Get current date for backup filename -BACKUP_DATE=$(date +"%Y%m%d_%H%M%S") -BACKUP_DIR="./backups" -BACKUP_FILE_SQL="$BACKUP_DIR/supabase_backup_$BACKUP_DATE.sql" -MAX_BACKUPS=10 # Maximum number of backups to keep - -# Create backups directory if it doesn't exist -mkdir -p $BACKUP_DIR - -# Check if a specific PostgreSQL version is available -PG_VERSION=15 # Supabase uses PostgreSQL 15 -if [ -d "/opt/homebrew/opt/postgresql@$PG_VERSION" ]; then - echo "Using PostgreSQL $PG_VERSION from Homebrew..." - export PATH="/opt/homebrew/opt/postgresql@$PG_VERSION/bin:$PATH" -elif [ -d "/usr/local/opt/postgresql@$PG_VERSION" ]; then - echo "Using PostgreSQL $PG_VERSION from Homebrew..." - export PATH="/usr/local/opt/postgresql@$PG_VERSION/bin:$PATH" -else - echo "⚠️ PostgreSQL $PG_VERSION not found, using system version" -fi - -# Use dotenv to load environment variables from .env.local -echo "Creating Supabase-compatible backup using .env.local configuration..." - -# Check pg_dump version -PG_DUMP_VERSION=$(pg_dump --version | grep -oE '[0-9]+\.[0-9]+' | head -1) -echo "Local pg_dump version: $PG_DUMP_VERSION" - -# Run pg_dump through dotenv to use environment variables from .env.local -dotenv -e .env.local -- sh -c ' - # Use DATABASE_DIRECT_URL if available, otherwise fallback to DATABASE_URL - CONNECTION_URL=${DATABASE_DIRECT_URL:-$DATABASE_URL} - - # Remove any query parameters from the connection URL - CLEAN_URL=$(echo $CONNECTION_URL | sed "s/\?.*//") - - echo "Creating Supabase-compatible SQL backup..." - - # Create a comprehensive SQL backup that Supabase can restore - # Using --no-owner and --no-privileges to avoid permission issues - # Using --if-exists for DROP statements - # Using --create to include database creation - # Using --clean to add DROP statements - pg_dump "$CLEAN_URL" \ - --no-owner \ - --no-privileges \ - --no-comments \ - --schema=public \ - --quote-all-identifiers \ - --no-tablespaces \ - --no-unlogged-table-data \ - --disable-dollar-quoting \ - --column-inserts \ - --disable-triggers \ - --if-exists \ - --clean \ - -f '"$BACKUP_FILE_SQL"' 2> /tmp/pg_dump_error - - EXIT_CODE=$? - - if [ $EXIT_CODE -ne 0 ]; then - echo "❌ Backup failed:" - cat /tmp/pg_dump_error - rm -f /tmp/pg_dump_error - exit 1 - fi - - rm -f /tmp/pg_dump_error - - # Verify the backup file was created - if [ ! -f '"$BACKUP_FILE_SQL"' ]; then - echo "❌ Backup file was not created" - exit 1 - fi - - # Check backup file size - BACKUP_SIZE=$(du -h '"$BACKUP_FILE_SQL"' | cut -f1) - echo "✅ Backup created: '"$BACKUP_FILE_SQL"' ($BACKUP_SIZE)" - - # Create a quick verification of content - echo "" - echo "📊 Backup content summary:" - echo " Tables: $(grep -c "CREATE TABLE" '"$BACKUP_FILE_SQL"' || echo "0")" - echo " Indexes: $(grep -c "CREATE INDEX" '"$BACKUP_FILE_SQL"' || echo "0")" - echo " Constraints: $(grep -c "ADD CONSTRAINT" '"$BACKUP_FILE_SQL"' || echo "0")" - echo " Total lines: $(wc -l < '"$BACKUP_FILE_SQL"')" - - exit 0 -' - -# Check if backup was successful -if [ $? -eq 0 ]; then - echo "" - echo "✅ Supabase-compatible backup completed successfully!" - echo " File: $BACKUP_FILE_SQL" - echo "" - echo "💡 To restore this backup to Supabase:" - echo " 1. Go to Supabase Dashboard > Database > Backups" - echo " 2. Use SQL Editor to run the backup file" - echo " 3. Or use: psql < $BACKUP_FILE_SQL" - - # Clean up old backups - keep only the last MAX_BACKUPS - echo "" - echo "Cleaning up old backups (keeping last $MAX_BACKUPS)..." - - NUM_BACKUPS=$(ls -1 $BACKUP_DIR/supabase_backup_*.sql 2>/dev/null | wc -l) - if [ $NUM_BACKUPS -gt $MAX_BACKUPS ]; then - NUM_TO_DELETE=$((NUM_BACKUPS - MAX_BACKUPS)) - ls -1t $BACKUP_DIR/supabase_backup_*.sql | tail -n $NUM_TO_DELETE | xargs rm -f - echo "Deleted $NUM_TO_DELETE old backup(s)" - fi -else - echo "❌ Backup failed" - exit 1 -fi \ No newline at end of file diff --git a/scripts/db-backup.sh b/scripts/db-backup.sh index a6b14a407..fbd5b459c 100755 --- a/scripts/db-backup.sh +++ b/scripts/db-backup.sh @@ -1,18 +1,99 @@ #!/bin/sh -# Get current date for backup filename +set -u + BACKUP_DATE=$(date +"%Y%m%d_%H%M%S") -BACKUP_DIR="./backups" +BACKUP_DIR="${BACKUP_DIR:-./backups}" +BACKUP_SCHEMA="${BACKUP_SCHEMA:-public}" +PG_VERSION="${PG_VERSION:-15}" + BACKUP_FILE="$BACKUP_DIR/emuready_backup_$BACKUP_DATE.pgdump" -BACKUP_FILE_SQL="$BACKUP_DIR/emuready_backup_$BACKUP_DATE.sql" -BACKUP_FILE_DATA="$BACKUP_DIR/emuready_backup_$BACKUP_DATE.data.sql" -MAX_BACKUPS=10 # Maximum number of backups to keep +SHA_FILE="$BACKUP_FILE.sha256" +LOG_FILE="$BACKUP_DIR/emuready_backup_$BACKUP_DATE.log" +TMP_BACKUP_FILE="$BACKUP_DIR/.emuready_backup_$BACKUP_DATE.pgdump.tmp" +TMP_ERROR_FILE="$BACKUP_DIR/.emuready_backup_$BACKUP_DATE.log.tmp" + +usage() { + cat <<'USAGE' +Usage: + pnpm run db:backup -- '' + +Required connection: + Use Supabase's Direct connection string: + postgresql://postgres:@db..supabase.co:5432/postgres + +Do not use: + - Supabase transaction pooler URLs on port 6543 + - Supabase session pooler URLs on pooler.supabase.com + - URLs containing pgbouncer=true + +Supabase Dashboard: + Project -> Connect -> Direct connection +USAGE +} + +fail() { + echo "❌ $1" + + if [ -s "$TMP_ERROR_FILE" ]; then + mv "$TMP_ERROR_FILE" "$LOG_FILE" + echo "Log: $LOG_FILE" + else + rm -f "$TMP_ERROR_FILE" + fi + + rm -f "$TMP_BACKUP_FILE" + exit 1 +} + +if [ "$#" -ne 1 ]; then + if [ "$#" -gt 0 ] && [ "$1" = "--" ]; then + shift + fi +fi + +if [ "$#" -ne 1 ]; then + echo "❌ Missing required direct Postgres connection string." + echo "" + usage + exit 2 +fi + +CONNECTION_URL="$1" + +case "$CONNECTION_URL" in + *".pooler.supabase.com:"*) + echo "❌ Refusing Supabase pooler connection." + echo "" + echo "Use the Direct connection string instead:" + echo "postgresql://postgres:@db..supabase.co:5432/postgres" + exit 2 + ;; +esac + +case "$CONNECTION_URL" in + *":6543/"*) + echo "❌ Refusing transaction-pooler port 6543." + echo "" + echo "Use a direct Postgres host on port 5432 for pg_dump." + exit 2 + ;; +esac + +case "$CONNECTION_URL" in + *"pgbouncer=true"*) + echo "❌ Refusing pgbouncer=true connection string." + echo "" + echo "Use a direct Postgres connection string for pg_dump." + exit 2 + ;; +esac -# Create backups directory if it doesn't exist -mkdir -p $BACKUP_DIR +mkdir -p "$BACKUP_DIR" || { + echo "❌ Could not create backup directory: $BACKUP_DIR" + exit 1 +} -# Check if a specific PostgreSQL version is available -PG_VERSION=15 # Change this to match your server version if needed if [ -d "/opt/homebrew/opt/postgresql@$PG_VERSION" ]; then echo "Using PostgreSQL $PG_VERSION from Homebrew..." export PATH="/opt/homebrew/opt/postgresql@$PG_VERSION/bin:$PATH" @@ -21,123 +102,56 @@ elif [ -d "/usr/local/opt/postgresql@$PG_VERSION" ]; then export PATH="/usr/local/opt/postgresql@$PG_VERSION/bin:$PATH" fi -# Use dotenv to load environment variables from .env.local -echo "Running database backup using .env.local configuration..." - -# Check pg_dump version -PG_DUMP_VERSION=$(pg_dump --version | grep -oE '[0-9]+\.[0-9]+' | head -1) -echo "Local pg_dump version: $PG_DUMP_VERSION" - -# Run pg_dump through dotenv to use environment variables from .env.local -# Use the full connection string directly with pg_dump -dotenv -e .env.local -- sh -c ' - # Use DATABASE_DIRECT_URL if available, otherwise fallback to DATABASE_URL - CONNECTION_URL=${DATABASE_DIRECT_URL:-$DATABASE_URL} - - # Remove any query parameters from the connection URL - CLEAN_URL=$(echo $CONNECTION_URL | sed "s/\?.*//") - - echo "Attempting to backup database using direct connection string..." - - # Create both custom format and SQL format backups - echo "Creating custom format backup..." - pg_dump "$CLEAN_URL" -F c -f '"$BACKUP_FILE"' 2> /tmp/pg_dump_error - - if [ $? -eq 0 ]; then - echo "Creating full SQL backup with schema..." - pg_dump "$CLEAN_URL" --no-owner --no-privileges --column-inserts --schema=public --no-comments -f '"$BACKUP_FILE_SQL"' 2> /tmp/pg_dump_error_sql - - echo "Creating data-only SQL backup for existing databases..." - pg_dump "$CLEAN_URL" --no-owner --no-privileges --column-inserts --schema=public --no-comments --data-only --disable-triggers -f /tmp/backup_raw.sql 2> /tmp/pg_dump_error_data - - if [ $? -eq 0 ]; then - echo "Adding conflict resolution to SQL file..." - # Convert only INSERT statements to INSERT ... ON CONFLICT DO NOTHING - sed "s/^INSERT INTO \(.*\) VALUES \(.*\);$/INSERT INTO \1 VALUES \2 ON CONFLICT DO NOTHING;/g" /tmp/backup_raw.sql > '"$BACKUP_FILE_DATA"' - rm -f /tmp/backup_raw.sql - fi - if [ $? -ne 0 ]; then - echo "Data backup failed" - cat /tmp/pg_dump_error_data - rm -f /tmp/pg_dump_error_data - else - rm -f /tmp/pg_dump_error_data - fi - - if [ $? -ne 0 ]; then - echo "SQL backup failed, but custom format succeeded" - cat /tmp/pg_dump_error_sql - rm -f /tmp/pg_dump_error_sql - else - rm -f /tmp/pg_dump_error_sql - fi - fi - - # Check if the primary backup failed - PRIMARY_EXIT_CODE=$? - if [ $PRIMARY_EXIT_CODE -ne 0 ]; then - # Check if it was a version mismatch error - if grep -q "server version mismatch" /tmp/pg_dump_error; then - SERVER_VERSION=$(grep "server version" /tmp/pg_dump_error | grep -oE "[0-9]+\.[0-9]+" | head -1) - SERVER_MAJOR=$(echo $SERVER_VERSION | cut -d. -f1) - echo "⚠️ Version mismatch detected: Server is PostgreSQL $SERVER_VERSION but your pg_dump is version '"$PG_DUMP_VERSION"'" - echo "To fix this, you need to install PostgreSQL $SERVER_VERSION tools." - echo "" - echo "On macOS with Homebrew:" - echo " brew install postgresql@$SERVER_MAJOR" - echo " brew link --force postgresql@$SERVER_MAJOR" - echo "" - echo "On Ubuntu/Debian:" - echo " sudo apt-get install postgresql-client-$SERVER_MAJOR" - echo "" - echo "Then update PG_VERSION=$SERVER_MAJOR in this script." - echo "" - rm /tmp/pg_dump_error - exit 1 - else - cat /tmp/pg_dump_error - rm /tmp/pg_dump_error - exit 1 - fi - fi - - rm -f /tmp/pg_dump_error - exit 0 -' - -# Check if backup was successful -if [ $? -eq 0 ]; then - echo "✅ Database backup completed successfully:" - echo " Custom format: $BACKUP_FILE ($(du -h $BACKUP_FILE | cut -f1))" - if [ -f "$BACKUP_FILE_SQL" ]; then - echo " Full SQL: $BACKUP_FILE_SQL ($(du -h $BACKUP_FILE_SQL | cut -f1))" - fi - if [ -f "$BACKUP_FILE_DATA" ]; then - echo " Data-only SQL: $BACKUP_FILE_DATA ($(du -h $BACKUP_FILE_DATA | cut -f1))" - fi - echo "" - echo "💡 For new databases: use the full .sql file" - echo "💡 For existing databases: use the .data.sql file" - - # Clean up old backups - keep only the last MAX_BACKUPS of each type - echo "Cleaning up old backups (keeping last $MAX_BACKUPS)..." - - # Clean up .pgdump files - NUM_BACKUPS=$(ls -1 $BACKUP_DIR/emuready_backup_*.pgdump 2>/dev/null | wc -l) - if [ $NUM_BACKUPS -gt $MAX_BACKUPS ]; then - NUM_TO_DELETE=$((NUM_BACKUPS - MAX_BACKUPS)) - ls -1t $BACKUP_DIR/emuready_backup_*.pgdump | tail -n $NUM_TO_DELETE | xargs rm -f - echo "Deleted $NUM_TO_DELETE old .pgdump backup(s)" - fi - - # Clean up .sql files - NUM_BACKUPS=$(ls -1 $BACKUP_DIR/emuready_backup_*.sql 2>/dev/null | wc -l) - if [ $NUM_BACKUPS -gt $MAX_BACKUPS ]; then - NUM_TO_DELETE=$((NUM_BACKUPS - MAX_BACKUPS)) - ls -1t $BACKUP_DIR/emuready_backup_*.sql | tail -n $NUM_TO_DELETE | xargs rm -f - echo "Deleted $NUM_TO_DELETE old .sql backup(s)" - fi -else - echo "❌ Database backup failed" - exit 1 -fi \ No newline at end of file +command -v pg_dump >/dev/null 2>&1 || fail "pg_dump is not available" +command -v pg_restore >/dev/null 2>&1 || fail "pg_restore is not available" + +rm -f "$TMP_BACKUP_FILE" "$TMP_ERROR_FILE" +touch "$TMP_ERROR_FILE" || fail "Could not create backup log" + +{ + echo "Started: $(date -u +"%Y-%m-%dT%H:%M:%SZ")" + echo "pg_dump: $(pg_dump --version)" + echo "schema: $BACKUP_SCHEMA" + echo "format: custom" +} >> "$TMP_ERROR_FILE" + +echo "Running database backup with explicit direct connection string..." +echo "Local pg_dump version: $(pg_dump --version)" +echo "Schema: $BACKUP_SCHEMA" + +PGSSLMODE="${PGSSLMODE:-require}" \ +PGCONNECT_TIMEOUT="${PGCONNECT_TIMEOUT:-30}" \ +PGAPPNAME="${PGAPPNAME:-emuready_db_backup}" \ +pg_dump "$CONNECTION_URL" \ + --format=custom \ + --schema="$BACKUP_SCHEMA" \ + --no-owner \ + --no-privileges \ + --no-comments \ + --file="$TMP_BACKUP_FILE" \ + 2>> "$TMP_ERROR_FILE" || fail "pg_dump failed" + +[ -s "$TMP_BACKUP_FILE" ] || fail "Backup file was not created or is empty" + +echo "Verifying backup can be fully read by pg_restore..." +pg_restore --schema="$BACKUP_SCHEMA" --file=/dev/null "$TMP_BACKUP_FILE" 2>> "$TMP_ERROR_FILE" \ + || fail "Backup verification failed" + +mv "$TMP_BACKUP_FILE" "$BACKUP_FILE" || fail "Could not finalize backup file" + +if command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$BACKUP_FILE" > "$SHA_FILE" +fi + +{ + echo "Completed: $(date -u +"%Y-%m-%dT%H:%M:%SZ")" + echo "backup: $BACKUP_FILE" +} >> "$TMP_ERROR_FILE" + +mv "$TMP_ERROR_FILE" "$LOG_FILE" + +echo "✅ Database backup completed and verified:" +echo " Custom format: $BACKUP_FILE ($(du -h "$BACKUP_FILE" | cut -f1))" +[ -f "$SHA_FILE" ] && echo " SHA-256: $SHA_FILE" +echo " Log: $LOG_FILE" +echo " Cleanup: skipped; old backups are never deleted by this script" From 678497fe9630435679cef8f7018d2b237ef745ee Mon Sep 17 00:00:00 2001 From: Producdevity Date: Sun, 7 Jun 2026 18:17:16 +0200 Subject: [PATCH 61/87] fix: GA datalayer warning --- src/app/layout.tsx | 2 +- .../utils/sendAnalyticsEvent.test.ts | 27 +++++++++++++++++++ src/lib/analytics/utils/sendAnalyticsEvent.ts | 8 ++++++ 3 files changed, 36 insertions(+), 1 deletion(-) diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 12cee3b15..b773c36d0 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -51,9 +51,9 @@ export default function RootLayout(props: PropsWithChildren) { {env.ENABLE_ANALYTICS && ( <> + {env.GA_ID && } - {env.GA_ID && } )} {env.ENABLE_KOFI_WIDGET && } diff --git a/src/lib/analytics/utils/sendAnalyticsEvent.test.ts b/src/lib/analytics/utils/sendAnalyticsEvent.test.ts index 07b7a3757..a31dcc38c 100644 --- a/src/lib/analytics/utils/sendAnalyticsEvent.test.ts +++ b/src/lib/analytics/utils/sendAnalyticsEvent.test.ts @@ -36,6 +36,7 @@ afterEach(() => { vi.unstubAllEnvs() vi.resetModules() vi.clearAllMocks() + Reflect.deleteProperty(window, 'dataLayer') }) describe('sendAnalyticsEvent', () => { @@ -56,6 +57,31 @@ describe('sendAnalyticsEvent', () => { }) it('sends Google Analytics events when analytics are enabled', async () => { + Object.defineProperty(window, 'dataLayer', { + value: [], + configurable: true, + }) + + const { sendAnalyticsEvent } = await loadSendAnalyticsEvent({ + NODE_ENV: 'production', + NEXT_PUBLIC_APP_ENV: 'production', + NEXT_PUBLIC_ENABLE_ANALYTICS: 'true', + NEXT_PUBLIC_GA_ID: 'G-TEST', + }) + + sendAnalyticsEvent({ + category: ANALYTICS_CATEGORIES.ENGAGEMENT, + action: 'support_banner_shown', + }) + + expect(mocks.sendGAEvent).toHaveBeenCalledWith( + 'event', + 'support_banner_shown', + expect.objectContaining({ event_category: ANALYTICS_CATEGORIES.ENGAGEMENT }), + ) + }) + + it('initializes dataLayer before sending Google Analytics events', async () => { const { sendAnalyticsEvent } = await loadSendAnalyticsEvent({ NODE_ENV: 'production', NEXT_PUBLIC_APP_ENV: 'production', @@ -68,6 +94,7 @@ describe('sendAnalyticsEvent', () => { action: 'support_banner_shown', }) + expect(window.dataLayer).toEqual([]) expect(mocks.sendGAEvent).toHaveBeenCalledWith( 'event', 'support_banner_shown', diff --git a/src/lib/analytics/utils/sendAnalyticsEvent.ts b/src/lib/analytics/utils/sendAnalyticsEvent.ts index 1a367cff7..2bf866ab6 100644 --- a/src/lib/analytics/utils/sendAnalyticsEvent.ts +++ b/src/lib/analytics/utils/sendAnalyticsEvent.ts @@ -4,6 +4,12 @@ import { env } from '@/lib/env' import { logger } from '@/lib/logger' import { isTrackingAllowed } from './isTrackingAllowed' +function ensureGoogleAnalyticsDataLayer() { + if (typeof window === 'undefined') return + + if (!window.dataLayer) window.dataLayer = [] +} + export function sendAnalyticsEvent(params: AnalyticsEventData) { if (!isTrackingAllowed(params.category)) return @@ -53,6 +59,8 @@ export function sendAnalyticsEvent(params: AnalyticsEventData) { if (typeof window !== 'undefined' && env.ENABLE_ANALYTICS) { if (env.GA_ID) { + ensureGoogleAnalyticsDataLayer() + sendGAEvent('event', params.action, { event_category: params.category, ...eventData, From bf4736cc28e3a31ffdb7053b37e098781df1f8e8 Mon Sep 17 00:00:00 2001 From: Producdevity Date: Sun, 7 Jun 2026 18:19:46 +0200 Subject: [PATCH 62/87] fix: update cache strategy and error handling in retrocatalog route --- .../[brandName]/[modelName]/route.test.ts | 22 ++++++++++++++++++- .../[brandName]/[modelName]/route.ts | 18 +++++++++++---- .../retrocatalog/RetroCatalogButton.tsx | 10 ++++----- 3 files changed, 39 insertions(+), 11 deletions(-) diff --git a/src/app/api/retrocatalog/[brandName]/[modelName]/route.test.ts b/src/app/api/retrocatalog/[brandName]/[modelName]/route.test.ts index 2c53d2722..71e5d125d 100644 --- a/src/app/api/retrocatalog/[brandName]/[modelName]/route.test.ts +++ b/src/app/api/retrocatalog/[brandName]/[modelName]/route.test.ts @@ -2,6 +2,12 @@ import { NextRequest } from 'next/server' import { afterEach, describe, expect, it, vi } from 'vitest' import { GET } from './route' +vi.mock('@/lib/logger', () => ({ + logger: { + warn: vi.fn(), + }, +})) + const request = new NextRequest('http://localhost/api/retrocatalog/Retroid/Pocket%205') function contextFor(brandName: string, modelName: string) { @@ -21,6 +27,7 @@ describe('/api/retrocatalog/[brandName]/[modelName]', () => { const response = await GET(request, contextFor('Retroid', 'Pocket 5')) expect(response.status).toBe(200) + expect(response.headers.get('Cache-Control')).toBe('no-store') expect(await response.json()).toEqual([]) }) @@ -36,6 +43,9 @@ describe('/api/retrocatalog/[brandName]/[modelName]', () => { const response = await GET(request, contextFor('Retroid', 'Pocket 5')) expect(response.status).toBe(200) + expect(response.headers.get('Cache-Control')).toBe( + 'public, s-maxage=86400, stale-while-revalidate=3600', + ) expect(await response.json()).toEqual([device]) }) @@ -49,11 +59,21 @@ describe('/api/retrocatalog/[brandName]/[modelName]', () => { 'https://retrocatalog.com/api/catalog/retro-handhelds/Retro%2Fid/Pocket%205%3Fx%3D1', { headers: { Accept: 'application/json' }, - next: { revalidate: 86400 }, + cache: 'no-store', }, ) }) + it('does not cache empty RetroCatalog matches', async () => { + vi.stubGlobal('fetch', async () => Response.json([])) + + const response = await GET(request, contextFor('Retroid', 'Pocket 6')) + + expect(response.status).toBe(200) + expect(response.headers.get('Cache-Control')).toBe('no-store') + expect(await response.json()).toEqual([]) + }) + it('does not call RetroCatalog for invalid lookup parameters', async () => { const fetch = vi.fn(async () => Response.json([])) vi.stubGlobal('fetch', fetch) diff --git a/src/app/api/retrocatalog/[brandName]/[modelName]/route.ts b/src/app/api/retrocatalog/[brandName]/[modelName]/route.ts index 89f517d27..333400e6b 100644 --- a/src/app/api/retrocatalog/[brandName]/[modelName]/route.ts +++ b/src/app/api/retrocatalog/[brandName]/[modelName]/route.ts @@ -18,7 +18,7 @@ function isCatalogSegment(value: string) { } function emptyCatalogResponse() { - return NextResponse.json([], { headers: CATALOG_CACHE_HEADERS }) + return NextResponse.json([], { headers: { 'Cache-Control': 'no-store' } }) } function catalogUrl(brandName: string, modelName: string) { @@ -38,12 +38,22 @@ export async function GET( try { const response = await fetch(catalogUrl(brandName, modelName), { headers: { Accept: 'application/json' }, - next: { revalidate: 86400 }, + cache: 'no-store', }) - if (!response.ok) return emptyCatalogResponse() + if (!response.ok) { + logger.warn('[retrocatalog] Device lookup rejected', { + status: response.status, + brandName, + modelName, + }) + + return emptyCatalogResponse() + } + + const data: unknown = await response.json() + if (!Array.isArray(data) || data.length === 0) return emptyCatalogResponse() - const data = await response.json() return NextResponse.json(data, { headers: CATALOG_CACHE_HEADERS }) } catch (error) { logger.warn('[retrocatalog] Device lookup failed', { diff --git a/src/components/retrocatalog/RetroCatalogButton.tsx b/src/components/retrocatalog/RetroCatalogButton.tsx index 2621012ac..b5e906009 100644 --- a/src/components/retrocatalog/RetroCatalogButton.tsx +++ b/src/components/retrocatalog/RetroCatalogButton.tsx @@ -25,17 +25,15 @@ interface Props { } /** - * RetroCatalog specs button - shows only when device exists on RetroCatalog - * Opens device specs in new tab with tasteful hover animations + * RetroCatalog specs button + * shows only when device exists on RetroCatalog + * Opens device specs in new tab */ export function RetroCatalogButton(props: Props) { const { deviceId, brandName, modelName, variant = 'pill' } = props const [isHovered, setIsHovered] = useState(false) - const { exists, url, isLoading } = useRetroCatalogDevice({ - brandName, - modelName, - }) + const { exists, url, isLoading } = useRetroCatalogDevice({ brandName, modelName }) if (isLoading || !exists || !url) return null From 446bde7f9c76d3e47a61826bfbb5400a7811a7ce Mon Sep 17 00:00:00 2001 From: Producdevity Date: Mon, 8 Jun 2026 13:23:28 +0200 Subject: [PATCH 63/87] feat: add PC processed listings page and related functionality --- src/app/admin/components/AdminNavIcon.tsx | 1 + ...avigation.tsx => AdminQuickNavigation.tsx} | 9 +- .../ApprovalStatusOverrideModal.tsx | 90 ++++ .../ProcessedReportsAdminPage.tsx | 223 +++++++++ .../ProcessedReportsTable.tsx | 335 ++++++++++++++ .../components/processed-reports/index.ts | 2 + .../components/processed-reports/types.ts | 78 ++++ src/app/admin/config/routes.ts | 1 + src/app/admin/dashboard/AdminDashboard.tsx | 8 +- src/app/admin/data.ts | 10 +- src/app/admin/pc-processed-listings/page.tsx | 171 +++++++ .../components/OverrideStatusModal.tsx | 81 ---- src/app/admin/processed-listings/page.tsx | 424 +++++------------- src/data/storageKeys.ts | 3 +- src/schemas/pcListing.ts | 31 +- src/server/api/routers/listings/admin.test.ts | 236 +++++++++- src/server/api/routers/listings/admin.ts | 115 ++++- src/server/api/routers/pcListings.test.ts | 156 ++++++- src/server/api/routers/pcListings.ts | 167 ++++++- src/server/api/utils/pcListingHelpers.ts | 41 ++ src/server/api/utils/processedStatusTrust.ts | 27 ++ src/server/notifications/eventEmitter.ts | 1 - src/server/notifications/service.ts | 6 +- 23 files changed, 1765 insertions(+), 451 deletions(-) rename src/app/admin/components/{QuickNavigation/QuickNavigation.tsx => AdminQuickNavigation.tsx} (90%) create mode 100644 src/app/admin/components/processed-reports/ApprovalStatusOverrideModal.tsx create mode 100644 src/app/admin/components/processed-reports/ProcessedReportsAdminPage.tsx create mode 100644 src/app/admin/components/processed-reports/ProcessedReportsTable.tsx create mode 100644 src/app/admin/components/processed-reports/index.ts create mode 100644 src/app/admin/components/processed-reports/types.ts create mode 100644 src/app/admin/pc-processed-listings/page.tsx delete mode 100644 src/app/admin/processed-listings/components/OverrideStatusModal.tsx create mode 100644 src/server/api/utils/processedStatusTrust.ts diff --git a/src/app/admin/components/AdminNavIcon.tsx b/src/app/admin/components/AdminNavIcon.tsx index 495a1d282..73a2d2526 100644 --- a/src/app/admin/components/AdminNavIcon.tsx +++ b/src/app/admin/components/AdminNavIcon.tsx @@ -36,6 +36,7 @@ const getAdminNavIcon = (href: string, className: string) => { if (href.includes(ADMIN_ROUTES.API_ACCESS_DEV)) return if (href.includes(ADMIN_ROUTES.API_ACCESS)) return if (href.includes(ADMIN_ROUTES.MANAGE_LISTINGS)) return + if (href.includes(ADMIN_ROUTES.PC_PROCESSED_LISTINGS)) return if (href.includes(ADMIN_ROUTES.PROCESSED_LISTINGS)) return if (href.includes(ADMIN_ROUTES.REPORTS)) return if (href.includes(ADMIN_ROUTES.USER_BANS)) return diff --git a/src/app/admin/components/QuickNavigation/QuickNavigation.tsx b/src/app/admin/components/AdminQuickNavigation.tsx similarity index 90% rename from src/app/admin/components/QuickNavigation/QuickNavigation.tsx rename to src/app/admin/components/AdminQuickNavigation.tsx index ba616b1b1..bf6b4916d 100644 --- a/src/app/admin/components/QuickNavigation/QuickNavigation.tsx +++ b/src/app/admin/components/AdminQuickNavigation.tsx @@ -4,17 +4,17 @@ import { ChevronDown, ChevronUp } from 'lucide-react' import Link from 'next/link' import { useState } from 'react' import { cn } from '@/lib/utils' -import { type AdminNavItem } from '../../data' -import ApprovalCountBadge from '../ApprovalCountBadge' +import { type AdminNavItem } from '../data' +import ApprovalCountBadge from './ApprovalCountBadge' -interface QuickNavigationProps { +interface Props { items: AdminNavItem[] title: string defaultExpanded?: boolean className?: string } -export function QuickNavigation(props: QuickNavigationProps) { +export function AdminQuickNavigation(props: Props) { const defaultExpanded = props.defaultExpanded ?? true const [isExpanded, setIsExpanded] = useState(defaultExpanded) @@ -51,7 +51,6 @@ export function QuickNavigation(props: QuickNavigationProps) { {isExpanded && (
    - {/* Responsive grid that adjusts based on screen size */}
    {props.items.map((item) => ( void + title: string + currentStatus: ApprovalStatus | null + newStatus: ApprovalStatus | null + overrideNotes: string + onOverrideNotesChange: (notes: string) => void + onSubmit: () => void + isLoading: boolean +} + +export function ApprovalStatusOverrideModal(props: Props) { + if (!props.currentStatus || !props.newStatus) return null + + const isReturningToPending = props.newStatus === ApprovalStatus.PENDING + + return ( + +
    +

    + Current Status:{' '} + + {props.currentStatus} + +
    + New Status:{' '} + + {props.newStatus} + +

    + {isReturningToPending ? ( +

    + This returns the report to the review queue and clears its processed admin, processed + date, and processed notes. +

    + ) : ( +
    + + props.onOverrideNotesChange(ev.target.value)} + rows={4} + placeholder={`Notes for changing status to ${props.newStatus}...`} + className="w-full mt-1" + /> +
    + )} +
    + + +
    +
    +
    + ) +} diff --git a/src/app/admin/components/processed-reports/ProcessedReportsAdminPage.tsx b/src/app/admin/components/processed-reports/ProcessedReportsAdminPage.tsx new file mode 100644 index 000000000..a978e312e --- /dev/null +++ b/src/app/admin/components/processed-reports/ProcessedReportsAdminPage.tsx @@ -0,0 +1,223 @@ +'use client' + +import { useMemo, useState, type ChangeEvent } from 'react' +import { + AdminErrorState, + AdminPageLayout, + AdminSearchFilters, + AdminStatsDisplay, + AdminTableContainer, + AdminTableNoResults, +} from '@/components/admin' +import { + ColumnVisibilityControl, + DisplayToggleButton, + LoadingSpinner, + Pagination, + SelectInput, +} from '@/components/ui' +import storageKeys from '@/data/storageKeys' +import { + useColumnVisibility, + useEmulatorLogos, + useLocalStorage, + type ColumnDefinition, +} from '@/hooks' +import { hasPermission, PERMISSIONS } from '@/utils/permission-system' +import { hasRolePermission } from '@/utils/permissions' +import { ApprovalStatus, Role } from '@orm' +import { ApprovalStatusOverrideModal } from './ApprovalStatusOverrideModal' +import { ProcessedReportsTable } from './ProcessedReportsTable' +import { type ProcessedReportHardwareColumn, type ProcessedReportsAdminPageProps } from './types' + +const STATUS_FILTER_OPTIONS = [ + { id: 'all' as const, name: 'All Processed' }, + { id: ApprovalStatus.APPROVED, name: 'Approved' }, + { id: ApprovalStatus.REJECTED, name: 'Rejected' }, +] + +function buildColumns( + hardwareColumns: ProcessedReportHardwareColumn[], +): ColumnDefinition[] { + return [ + { key: 'game', label: 'Game', defaultVisible: true }, + { key: 'system', label: 'System', defaultVisible: true }, + ...hardwareColumns.map((column) => ({ + key: column.key, + label: column.label, + defaultVisible: column.defaultVisible, + })), + { key: 'emulator', label: 'Emulator', defaultVisible: true }, + { key: 'author', label: 'Author', defaultVisible: true }, + { key: 'status', label: 'Status', defaultVisible: true }, + { key: 'processedBy', label: 'Processed By', defaultVisible: true }, + { key: 'processedAt', label: 'Processed At', defaultVisible: true }, + { key: 'actions', label: 'Actions', alwaysVisible: true }, + ] +} + +export function ProcessedReportsAdminPage( + props: ProcessedReportsAdminPageProps, +) { + const columns = useMemo(() => buildColumns(props.hardwareColumns), [props.hardwareColumns]) + const columnVisibility = useColumnVisibility(columns, { storageKey: props.storageKey }) + const [showSystemIcons, setShowSystemIcons, isSystemIconsHydrated] = useLocalStorage( + storageKeys.showSystemIcons, + true, + ) + const emulatorLogos = useEmulatorLogos() + const [showOverrideModal, setShowOverrideModal] = useState(false) + const [selectedReport, setSelectedReport] = useState(null) + const [overrideNotes, setOverrideNotes] = useState('') + const [newStatusForOverride, setNewStatusForOverride] = useState(null) + + const handleFilterChange = (ev: ChangeEvent) => { + const value = ev.target.value as ApprovalStatus | 'all' + props.onFilterStatusChange(value === 'all' ? null : value) + props.table.setPage(1) + } + + const openOverrideModal = (report: TReport, targetStatus: ApprovalStatus) => { + setSelectedReport(report) + setNewStatusForOverride(targetStatus) + setOverrideNotes(props.accessors.getProcessedNotes(report) ?? '') + setShowOverrideModal(true) + } + + const closeOverrideModal = () => { + setShowOverrideModal(false) + setSelectedReport(null) + setOverrideNotes('') + setNewStatusForOverride(null) + } + + const handleOverrideSubmit = () => { + if (!selectedReport || !newStatusForOverride) return + + void props + .onOverrideStatus({ + report: selectedReport, + newStatus: newStatusForOverride, + overrideNotes: + newStatusForOverride === ApprovalStatus.PENDING ? undefined : overrideNotes || undefined, + }) + .then(closeOverrideModal) + .catch(() => undefined) + } + + if (props.errorMessage) { + return + } + + const canEditReports = hasPermission(props.currentUserPermissions, PERMISSIONS.EDIT_ANY_LISTING) + const canOverrideReports = hasRolePermission(props.currentUserRole, Role.SUPER_ADMIN) + const canViewUsers = hasPermission(props.currentUserPermissions, PERMISSIONS.MANAGE_USERS) + const selectedReportTitle = selectedReport ? props.accessors.getGameTitle(selectedReport) : '' + const overrideModalTitle = + newStatusForOverride === ApprovalStatus.PENDING + ? `Return to Pending Review: ${selectedReportTitle}` + : `Override Status: ${selectedReportTitle}` + + return ( + + setShowSystemIcons(!showSystemIcons)} + isHydrated={isSystemIconsHydrated} + logoLabel="Show System Icons" + nameLabel="Show System Names" + /> + + + + } + > + + + + table={props.table} + searchPlaceholder={props.searchPlaceholder} + onClear={() => props.onFilterStatusChange(null)} + > + + + + + {props.isReportsLoading ? ( + + ) : props.reports.length === 0 ? ( + + ) : ( + + )} + + + {props.pagination && props.pagination.pages > 1 && ( +
    + +
    + )} + + +
    + ) +} diff --git a/src/app/admin/components/processed-reports/ProcessedReportsTable.tsx b/src/app/admin/components/processed-reports/ProcessedReportsTable.tsx new file mode 100644 index 000000000..6ccaa01dd --- /dev/null +++ b/src/app/admin/components/processed-reports/ProcessedReportsTable.tsx @@ -0,0 +1,335 @@ +'use client' + +import { ExternalLink } from 'lucide-react' +import Link from 'next/link' +import { type UseAdminTableReturn } from '@/app/admin/hooks' +import { EmulatorIcon, SystemIcon } from '@/components/icons' +import { + ApproveButton, + EditButton, + LocalizedDate, + RejectButton, + SortableHeader, + Tooltip, + TooltipContent, + TooltipTrigger, + UndoButton, + ViewButton, + ViewUserButton, +} from '@/components/ui' +import { type UseColumnVisibilityReturn } from '@/hooks' +import analytics from '@/lib/analytics' +import { getApprovalStatusColor } from '@/utils/badge-colors' +import { ApprovalStatus } from '@orm' +import { type ProcessedReportAccessors, type ProcessedReportHardwareColumn } from './types' + +interface Props { + table: UseAdminTableReturn + reports: TReport[] + hardwareColumns: ProcessedReportHardwareColumn[] + columnVisibility: UseColumnVisibilityReturn + accessors: ProcessedReportAccessors + reportLabel: string + analyticsContext: string + showSystemIcons: boolean + isSystemIconsHydrated: boolean + showEmulatorLogos: boolean + isEmulatorLogosHydrated: boolean + canEditReports: boolean + canOverrideReports: boolean + canViewUsers: boolean + isOverridePending: boolean + onOpenOverrideModal: (report: TReport, targetStatus: ApprovalStatus) => void +} + +export function ProcessedReportsTable( + props: Props, +) { + return ( +
    + + + + {props.columnVisibility.isColumnVisible('game') && ( + + )} + {props.columnVisibility.isColumnVisible('system') && ( + + )} + {props.hardwareColumns.map( + (column) => + props.columnVisibility.isColumnVisible(column.key) && ( + + ), + )} + {props.columnVisibility.isColumnVisible('emulator') && ( + + )} + {props.columnVisibility.isColumnVisible('author') && ( + + )} + {props.columnVisibility.isColumnVisible('status') && ( + + )} + {props.columnVisibility.isColumnVisible('processedBy') && ( + + )} + {props.columnVisibility.isColumnVisible('processedAt') && ( + + )} + {props.columnVisibility.isColumnVisible('actions') && ( + + )} + + + + {props.reports.map((report) => ( + + ))} + +
    + Processed By + + Actions +
    +
    + ) +} + +interface RowProps { + report: TReport + hardwareColumns: ProcessedReportHardwareColumn[] + columnVisibility: UseColumnVisibilityReturn + accessors: ProcessedReportAccessors + reportLabel: string + analyticsContext: string + showSystemIcons: boolean + isSystemIconsHydrated: boolean + showEmulatorLogos: boolean + isEmulatorLogosHydrated: boolean + canEditReports: boolean + canOverrideReports: boolean + canViewUsers: boolean + isOverridePending: boolean + onOpenOverrideModal: (report: TReport, targetStatus: ApprovalStatus) => void +} + +function ProcessedReportRow( + props: RowProps, +) { + const reportId = props.accessors.getId(props.report) + const reportHref = props.accessors.getViewHref(props.report) + const author = props.accessors.getAuthor(props.report) + const processedAt = props.accessors.getProcessedAt(props.report) + const status = props.accessors.getStatus(props.report) + const gameTitle = props.accessors.getGameTitle(props.report) + const systemName = props.accessors.getSystemName(props.report) + const systemKey = props.accessors.getSystemKey?.(props.report) + const emulatorName = props.accessors.getEmulatorName(props.report) + const emulatorLogo = props.accessors.getEmulatorLogo(props.report) + + return ( + + {props.columnVisibility.isColumnVisible('game') && ( + + { + analytics.contentDiscovery.externalLinkClicked({ + url: reportHref, + context: props.analyticsContext, + entityId: reportId, + }) + }} + > + {gameTitle} + + + + )} + {props.columnVisibility.isColumnVisible('system') && ( + + {props.isSystemIconsHydrated && props.showSystemIcons && systemKey ? ( +
    + + {systemName} +
    + ) : ( + systemName + )} + + )} + {props.hardwareColumns.map( + (column) => + props.columnVisibility.isColumnVisible(column.key) && ( + + {column.render(props.report)} + + ), + )} + {props.columnVisibility.isColumnVisible('emulator') && ( + + + + )} + {props.columnVisibility.isColumnVisible('author') && ( + + {author ? ( + + {author.name ?? 'N/A'} + + ) : ( + 'N/A' + )} + + )} + {props.columnVisibility.isColumnVisible('status') && ( + + + {status} + + + )} + {props.columnVisibility.isColumnVisible('processedBy') && ( + + {props.accessors.getProcessedByName(props.report) ?? 'N/A'} + + )} + {props.columnVisibility.isColumnVisible('processedAt') && ( + + {processedAt ? ( + + + + + + + + + + + ) : ( + 'N/A' + )} + + )} + {props.columnVisibility.isColumnVisible('actions') && ( + +
    + {props.canEditReports && ( + + )} + {props.canOverrideReports && ( + props.onOpenOverrideModal(props.report, ApprovalStatus.PENDING)} + disabled={props.isOverridePending} + /> + )} + {props.canOverrideReports && status === ApprovalStatus.APPROVED && ( + props.onOpenOverrideModal(props.report, ApprovalStatus.REJECTED)} + disabled={props.isOverridePending} + /> + )} + {props.canOverrideReports && status === ApprovalStatus.REJECTED && ( + props.onOpenOverrideModal(props.report, ApprovalStatus.APPROVED)} + disabled={props.isOverridePending} + /> + )} + {props.canViewUsers && author && ( + + )} + +
    + + )} + + ) +} diff --git a/src/app/admin/components/processed-reports/index.ts b/src/app/admin/components/processed-reports/index.ts new file mode 100644 index 000000000..e96fa1e0c --- /dev/null +++ b/src/app/admin/components/processed-reports/index.ts @@ -0,0 +1,2 @@ +export { ProcessedReportsAdminPage } from './ProcessedReportsAdminPage' +export type { ProcessedReportAccessors, ProcessedReportHardwareColumn } from './types' diff --git a/src/app/admin/components/processed-reports/types.ts b/src/app/admin/components/processed-reports/types.ts new file mode 100644 index 000000000..8794f5275 --- /dev/null +++ b/src/app/admin/components/processed-reports/types.ts @@ -0,0 +1,78 @@ +import type { UseAdminTableReturn } from '@/app/admin/hooks' +import type { ApprovalStatus, Role } from '@orm' +import type { ReactNode } from 'react' + +export interface ProcessedReportPagination { + page: number + pages: number + total: number + limit?: number +} + +export interface ProcessedReportStats { + total?: number + approved?: number + pending?: number + rejected?: number +} + +export interface ProcessedReportUser { + id: string + name?: string | null +} + +export interface ProcessedReportHardwareColumn { + key: string + label: string + sortField: TSortField + defaultVisible?: boolean + render: (report: TReport) => ReactNode +} + +export interface ProcessedReportAccessors { + getId: (report: TReport) => string + getGameTitle: (report: TReport) => string + getSystemName: (report: TReport) => string + getSystemKey?: (report: TReport) => string | null | undefined + getEmulatorName: (report: TReport) => string + getEmulatorLogo: (report: TReport) => string | null | undefined + getAuthor: (report: TReport) => ProcessedReportUser | null | undefined + getProcessedByName: (report: TReport) => string | null | undefined + getProcessedAt: (report: TReport) => Date | string | null | undefined + getProcessedNotes: (report: TReport) => string | null | undefined + getStatus: (report: TReport) => ApprovalStatus + getEditHref: (report: TReport) => string + getViewHref: (report: TReport) => string +} + +export interface ProcessedReportOverrideRequest { + report: TReport + newStatus: ApprovalStatus + overrideNotes?: string +} + +export interface ProcessedReportsAdminPageProps { + title: string + description: string + reportLabel: string + loadingText: string + errorMessage: string | null + searchPlaceholder: string + storageKey: string + analyticsContext: string + table: UseAdminTableReturn + reports: TReport[] + pagination?: ProcessedReportPagination + stats: ProcessedReportStats + isStatsLoading: boolean + isReportsLoading: boolean + currentUserPermissions?: string[] | null + currentUserRole?: Role | null + filterStatus: ApprovalStatus | null + hardwareColumns: ProcessedReportHardwareColumn[] + accessors: ProcessedReportAccessors + onFilterStatusChange: (status: ApprovalStatus | null) => void + onRetry: () => void + onOverrideStatus: (request: ProcessedReportOverrideRequest) => Promise + isOverridePending: boolean +} diff --git a/src/app/admin/config/routes.ts b/src/app/admin/config/routes.ts index 82afb1d43..d9f92b45b 100644 --- a/src/app/admin/config/routes.ts +++ b/src/app/admin/config/routes.ts @@ -45,6 +45,7 @@ export const ADMIN_ROUTES = { // Listings MANAGE_LISTINGS: '/admin/listings', PROCESSED_LISTINGS: '/admin/processed-listings', + PC_PROCESSED_LISTINGS: '/admin/pc-processed-listings', // Custom Fields FIELD_TEMPLATES: '/admin/custom-field-templates', diff --git a/src/app/admin/dashboard/AdminDashboard.tsx b/src/app/admin/dashboard/AdminDashboard.tsx index 75629c9ac..08561bcca 100644 --- a/src/app/admin/dashboard/AdminDashboard.tsx +++ b/src/app/admin/dashboard/AdminDashboard.tsx @@ -2,8 +2,8 @@ import { Users, FileText, MessageSquare, AlertTriangle, Ban } from 'lucide-react' import { useCallback, useEffect, useState } from 'react' +import { AdminQuickNavigation } from '@/app/admin/components/AdminQuickNavigation' import { ErrorBoundary } from '@/app/admin/components/ErrorBoundary' -import { QuickNavigation } from '@/app/admin/components/QuickNavigation/QuickNavigation' import { ADMIN_ROUTES } from '@/app/admin/config/routes' import { type AdminNavItem } from '@/app/admin/data' import { api } from '@/lib/api' @@ -104,7 +104,11 @@ export function AdminDashboard(props: Props) {
    {/* Quick Navigation - Collapsible */} - + {/* Show error banner if API call failed */} diff --git a/src/app/admin/data.ts b/src/app/admin/data.ts index 7f07ab163..750135e6a 100644 --- a/src/app/admin/data.ts +++ b/src/app/admin/data.ts @@ -136,9 +136,15 @@ export const superAdminNavItems: AdminNavItem[] = [ }, { href: ADMIN_ROUTES.PROCESSED_LISTINGS, - label: 'Processed Listings', + label: 'Processed Reports', exact: true, - description: 'View all processed listings.', + description: 'View approved and rejected handheld reports.', + }, + { + href: ADMIN_ROUTES.PC_PROCESSED_LISTINGS, + label: 'PC Processed Reports', + exact: true, + description: 'View approved and rejected PC compatibility reports.', }, { href: ADMIN_ROUTES.REPORTS, diff --git a/src/app/admin/pc-processed-listings/page.tsx b/src/app/admin/pc-processed-listings/page.tsx new file mode 100644 index 000000000..c32b0abbd --- /dev/null +++ b/src/app/admin/pc-processed-listings/page.tsx @@ -0,0 +1,171 @@ +'use client' + +import { useState } from 'react' +import { + type ProcessedReportAccessors, + ProcessedReportsAdminPage, + type ProcessedReportHardwareColumn, +} from '@/app/admin/components/processed-reports' +import { useAdminTable } from '@/app/admin/hooks' +import storageKeys from '@/data/storageKeys' +import { api } from '@/lib/api' +import { logger } from '@/lib/logger' +import toast from '@/lib/toast' +import { type RouterInput, type RouterOutput } from '@/types/trpc' +import getErrorMessage from '@/utils/getErrorMessage' +import { ApprovalStatus } from '@orm' + +type ProcessedPcListing = RouterOutput['pcListings']['getProcessed']['pcListings'][number] +type ProcessedPcListingSortField = + | 'processedAt' + | 'createdAt' + | 'status' + | 'game.title' + | 'game.system.name' + | 'cpu' + | 'gpu' + | 'emulator.name' + | 'author.name' + +function getGpuLabel(listing: ProcessedPcListing): string { + return listing.gpu ? `${listing.gpu.brand.name} ${listing.gpu.modelName}` : 'Integrated / N/A' +} + +const PC_HARDWARE_COLUMNS: ProcessedReportHardwareColumn< + ProcessedPcListing, + ProcessedPcListingSortField +>[] = [ + { + key: 'cpu', + label: 'CPU', + sortField: 'cpu', + defaultVisible: true, + render: (listing) => `${listing.cpu.brand.name} ${listing.cpu.modelName}`, + }, + { + key: 'gpu', + label: 'GPU', + sortField: 'gpu', + defaultVisible: true, + render: getGpuLabel, + }, +] + +const PC_REPORT_ACCESSORS: ProcessedReportAccessors = { + getId: (listing) => listing.id, + getGameTitle: (listing) => listing.game.title, + getSystemName: (listing) => listing.game.system.name, + getSystemKey: (listing) => listing.game.system.key, + getEmulatorName: (listing) => listing.emulator.name, + getEmulatorLogo: (listing) => listing.emulator.logo, + getAuthor: (listing) => listing.author, + getProcessedByName: (listing) => listing.processedByUser?.name, + getProcessedAt: (listing) => listing.processedAt, + getProcessedNotes: (listing) => listing.processedNotes, + getStatus: (listing) => listing.status, + getEditHref: (listing) => `/admin/pc-listings/${listing.id}/edit`, + getViewHref: (listing) => `/pc-listings/${listing.id}`, +} + +function PcProcessedListingsPage() { + const table = useAdminTable({ + defaultLimit: 20, + defaultSortField: 'processedAt', + defaultSortDirection: 'desc', + }) + + const [filterStatus, setFilterStatus] = useState(null) + const currentUserQuery = api.users.me.useQuery() + const pcListingsStatsQuery = api.pcListings.stats.useQuery() + const processedPcListingsQuery = api.pcListings.getProcessed.useQuery({ + page: table.page, + limit: table.limit, + filterStatus: filterStatus ?? null, + search: table.debouncedSearch || null, + sortField: table.sortField ?? null, + sortDirection: table.sortDirection ?? null, + }) + + const utils = api.useUtils() + const invalidateAdminPcListingViews = async () => { + await Promise.all([ + utils.pcListings.getProcessed.invalidate(), + utils.pcListings.pending.invalidate(), + utils.pcListings.get.invalidate(), + utils.pcListings.stats.invalidate(), + ]) + } + + const overrideMutation = api.pcListings.overrideStatus.useMutation({ + onSuccess: async () => { + toast.success('PC report status updated.') + await invalidateAdminPcListingViews() + }, + onError: (err) => { + logger.error('Failed to override PC report status:', err) + toast.error(`Failed to override PC report status: ${getErrorMessage(err)}`) + }, + }) + + const resetToPendingMutation = api.pcListings.resetToPending.useMutation({ + onSuccess: async () => { + toast.success('PC report returned to pending review.') + await invalidateAdminPcListingViews() + }, + onError: (err) => { + logger.error('Failed to return PC report to pending review:', err) + toast.error(`Failed to return PC report to pending review: ${getErrorMessage(err)}`) + }, + }) + + const processedPcListings = processedPcListingsQuery.data?.pcListings ?? [] + + return ( + + title="PC Processed Reports" + description="Review approved and rejected PC compatibility reports. SUPER_ADMINs can override these decisions." + reportLabel="PC Compatibility Report" + loadingText="Loading processed PC reports..." + errorMessage={ + processedPcListingsQuery.error + ? `Error loading processed PC reports: ${processedPcListingsQuery.error.message}` + : null + } + searchPlaceholder="Search by game, system, CPU, GPU, author, emulator, or notes..." + storageKey={storageKeys.columnVisibility.adminPcProcessedListings} + analyticsContext="admin_processed_pc_reports_view" + table={table} + reports={processedPcListings} + pagination={processedPcListingsQuery.data?.pagination} + stats={pcListingsStatsQuery.data ?? {}} + isStatsLoading={pcListingsStatsQuery.isPending} + isReportsLoading={processedPcListingsQuery.isPending} + currentUserPermissions={currentUserQuery.data?.permissions} + currentUserRole={currentUserQuery.data?.role} + filterStatus={filterStatus} + hardwareColumns={PC_HARDWARE_COLUMNS} + accessors={PC_REPORT_ACCESSORS} + onFilterStatusChange={setFilterStatus} + onRetry={() => { + void processedPcListingsQuery.refetch() + }} + onOverrideStatus={async (request) => { + if (request.newStatus === ApprovalStatus.PENDING) { + await resetToPendingMutation.mutateAsync({ + pcListingId: request.report.id, + } satisfies RouterInput['pcListings']['resetToPending']) + return + } + + await overrideMutation.mutateAsync({ + pcListingId: request.report.id, + newStatus: request.newStatus, + overrideNotes: request.overrideNotes, + } satisfies RouterInput['pcListings']['overrideStatus']) + }} + isOverridePending={overrideMutation.isPending || resetToPendingMutation.isPending} + /> + ) +} + +export default PcProcessedListingsPage diff --git a/src/app/admin/processed-listings/components/OverrideStatusModal.tsx b/src/app/admin/processed-listings/components/OverrideStatusModal.tsx deleted file mode 100644 index 5bf2b460e..000000000 --- a/src/app/admin/processed-listings/components/OverrideStatusModal.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import { Button, Input, Modal } from '@/components/ui' -import { type RouterOutput } from '@/types/trpc' -import { getApprovalStatusColor } from '@/utils/badge-colors' -import { ApprovalStatus } from '@orm' - -type ProcessedListing = RouterOutput['listings']['getProcessed']['listings'][number] - -interface Props { - isOpen: boolean - onClose: () => void - selectedListing: ProcessedListing | null - newStatus: ApprovalStatus | null - overrideNotes: string - setOverrideNotes: (notes: string) => void - onSubmit: () => void - isLoading: boolean -} - -function OverrideStatusModal(props: Props) { - if (!props.selectedListing || !props.newStatus) return null - - return ( - -
    -

    - Current Status:{' '} - - {props.selectedListing.status} - -
    - New Status:{' '} - - {props.newStatus} - -

    -
    - - props.setOverrideNotes(ev.target.value)} - rows={4} - placeholder={`Notes for changing status to ${props.newStatus}...`} - className="w-full mt-1" - /> -
    -
    - - -
    -
    -
    - ) -} - -export default OverrideStatusModal diff --git a/src/app/admin/processed-listings/page.tsx b/src/app/admin/processed-listings/page.tsx index fb3b6dc44..2d8f3d352 100644 --- a/src/app/admin/processed-listings/page.tsx +++ b/src/app/admin/processed-listings/page.tsx @@ -1,348 +1,158 @@ 'use client' -import { ExternalLink } from 'lucide-react' -import Link from 'next/link' -import { useState, type ChangeEvent } from 'react' -import { useAdminTable } from '@/app/admin/hooks' -import { - AdminPageLayout, - AdminSearchFilters, - AdminStatsDisplay, - AdminTableContainer, - AdminTableNoResults, -} from '@/components/admin' +import { useState } from 'react' import { - ApproveButton, - ColumnVisibilityControl, - EditButton, - LoadingSpinner, - Pagination, - RejectButton, - SelectInput, - LocalizedDate, - UndoButton, -} from '@/components/ui' + type ProcessedReportAccessors, + ProcessedReportsAdminPage, + type ProcessedReportHardwareColumn, +} from '@/app/admin/components/processed-reports' +import { useAdminTable } from '@/app/admin/hooks' import storageKeys from '@/data/storageKeys' -import { useColumnVisibility, type ColumnDefinition } from '@/hooks' -import analytics from '@/lib/analytics' import { api } from '@/lib/api' +import { logger } from '@/lib/logger' import toast from '@/lib/toast' -import { type RouterOutput, type RouterInput } from '@/types/trpc' -import { getApprovalStatusColor } from '@/utils/badge-colors' +import { type RouterInput, type RouterOutput } from '@/types/trpc' import getErrorMessage from '@/utils/getErrorMessage' -import { hasPermission, PERMISSIONS } from '@/utils/permission-system' import { ApprovalStatus } from '@orm' -import OverrideStatusModal from './components/OverrideStatusModal' type ProcessedListing = RouterOutput['listings']['getProcessed']['listings'][number] - -const statusOptions = [ - { id: 'all' as const, name: 'All Processed' }, - { id: ApprovalStatus.APPROVED, name: 'Approved' }, - { id: ApprovalStatus.PENDING, name: 'Pending' }, - { id: ApprovalStatus.REJECTED, name: 'Rejected' }, +type ProcessedListingSortField = + | 'processedAt' + | 'createdAt' + | 'status' + | 'game.title' + | 'game.system.name' + | 'device' + | 'emulator.name' + | 'author.name' + +const HANDHELD_HARDWARE_COLUMNS: ProcessedReportHardwareColumn< + ProcessedListing, + ProcessedListingSortField +>[] = [ + { + key: 'device', + label: 'Device', + sortField: 'device', + defaultVisible: true, + render: (listing) => `${listing.device.brand.name} ${listing.device.modelName}`, + }, ] -const PROCESSED_LISTINGS_COLUMNS: ColumnDefinition[] = [ - { key: 'game', label: 'Game / System', defaultVisible: true }, - { key: 'author', label: 'Author', defaultVisible: true }, - { key: 'status', label: 'Status', defaultVisible: true }, - { key: 'processedBy', label: 'Processed By (Admin)', defaultVisible: true }, - { key: 'processedAt', label: 'Processed At', defaultVisible: true }, - { key: 'actions', label: 'Actions', alwaysVisible: true }, -] - -type ProcessedListingSortField = 'createdAt' | 'status' | 'game.title' +const HANDHELD_REPORT_ACCESSORS: ProcessedReportAccessors = { + getId: (listing) => listing.id, + getGameTitle: (listing) => listing.game.title, + getSystemName: (listing) => listing.game.system.name, + getSystemKey: (listing) => listing.game.system.key, + getEmulatorName: (listing) => listing.emulator.name, + getEmulatorLogo: (listing) => listing.emulator.logo, + getAuthor: (listing) => listing.author, + getProcessedByName: (listing) => listing.processedByUser?.name, + getProcessedAt: (listing) => listing.processedAt, + getProcessedNotes: (listing) => listing.processedNotes, + getStatus: (listing) => listing.status, + getEditHref: (listing) => `/admin/listings/${listing.id}/edit`, + getViewHref: (listing) => `/listings/${listing.id}`, +} function ProcessedListingsPage() { const table = useAdminTable({ defaultLimit: 20, - defaultSortField: 'createdAt', + defaultSortField: 'processedAt', defaultSortDirection: 'desc', }) - const columnVisibility = useColumnVisibility(PROCESSED_LISTINGS_COLUMNS, { - storageKey: storageKeys.columnVisibility.adminProcessedListings, - }) - const [filterStatus, setFilterStatus] = useState(null) - + const currentUserQuery = api.users.me.useQuery() const listingStatsQuery = api.listings.stats.useQuery() const processedListingsQuery = api.listings.getProcessed.useQuery({ page: table.page, limit: table.limit, - filterStatus: filterStatus ?? undefined, - search: table.debouncedSearch || undefined, + filterStatus: filterStatus ?? null, + search: table.debouncedSearch || null, + sortField: table.sortField ?? null, + sortDirection: table.sortDirection ?? null, }) - const processedListings = processedListingsQuery.data?.listings ?? [] - const paginationData = processedListingsQuery.data?.pagination - const userQuery = api.users.me.useQuery() - - const [showOverrideModal, setShowOverrideModal] = useState(false) - const [selectedListingForOverride, setSelectedListingForOverride] = - useState(null) - const [overrideNotes, setOverrideNotes] = useState('') - const [newStatusForOverride, setNewStatusForOverride] = useState(null) - const utils = api.useUtils() + const invalidateAdminListingViews = async () => { + await Promise.all([ + utils.listings.getProcessed.invalidate(), + utils.listings.getPending.invalidate(), + utils.listings.get.invalidate(), + utils.listings.stats.invalidate(), + ]) + } + const overrideMutation = api.listings.overrideApprovalStatus.useMutation({ onSuccess: async () => { - toast.success('Listing status overridden successfully!') - await utils.listings.getProcessed.invalidate() - await utils.listings.getPending.invalidate() - await utils.listings.get.invalidate() - closeOverrideModal() + toast.success('Handheld report status updated.') + await invalidateAdminListingViews() }, onError: (err) => { - console.error('Failed to override status:', err) - toast.error(`Failed to override status: ${getErrorMessage(err)}`) + logger.error('Failed to override handheld report status:', err) + toast.error(`Failed to override handheld report status: ${getErrorMessage(err)}`) }, }) - const openOverrideModal = (listing: ProcessedListing, targetStatus: ApprovalStatus) => { - setSelectedListingForOverride(listing) - setNewStatusForOverride(targetStatus) - setOverrideNotes(listing.processedNotes ?? '') - setShowOverrideModal(true) - } - - const closeOverrideModal = () => { - setShowOverrideModal(false) - setSelectedListingForOverride(null) - setOverrideNotes('') - setNewStatusForOverride(null) - } - - const handleOverrideSubmit = () => { - if (selectedListingForOverride && newStatusForOverride) { - overrideMutation.mutate({ - listingId: selectedListingForOverride.id, - newStatus: newStatusForOverride, - overrideNotes: overrideNotes ?? undefined, - } satisfies RouterInput['listings']['overrideApprovalStatus']) - } - } - - const handleFilterChange = (ev: ChangeEvent) => { - const value = ev.target.value as ApprovalStatus | 'all' - setFilterStatus(value === 'all' ? null : value) - table.setPage(1) - } + const resetToPendingMutation = api.listings.resetToPending.useMutation({ + onSuccess: async () => { + toast.success('Handheld report returned to pending review.') + await invalidateAdminListingViews() + }, + onError: (err) => { + logger.error('Failed to return handheld report to pending review:', err) + toast.error(`Failed to return handheld report to pending review: ${getErrorMessage(err)}`) + }, + }) - if (processedListingsQuery.error) { - return ( -
    - Error loading processed listings: {processedListingsQuery.error.message} -
    - ) - } + const processedListings = processedListingsQuery.data?.listings ?? [] return ( - + + title="Handheld Processed Reports" + description="Review approved and rejected handheld compatibility reports. SUPER_ADMINs can override these decisions." + reportLabel="Handheld Compatibility Report" + loadingText="Loading processed handheld reports..." + errorMessage={ + processedListingsQuery.error + ? `Error loading processed handheld reports: ${processedListingsQuery.error.message}` + : null } - > - - - - table={table} - searchPlaceholder="Search by game name, author, or notes..." - onClear={() => setFilterStatus(null)} - > - - - - - {processedListingsQuery.isPending ? ( - - ) : processedListings.length === 0 ? ( - - ) : ( - - - - {columnVisibility.isColumnVisible('game') && ( - - )} - {columnVisibility.isColumnVisible('author') && ( - - )} - {columnVisibility.isColumnVisible('status') && ( - - )} - {columnVisibility.isColumnVisible('processedBy') && ( - - )} - {columnVisibility.isColumnVisible('processedAt') && ( - - )} - {columnVisibility.isColumnVisible('actions') && ( - - )} - - - - {processedListings.map((listing) => ( - - {columnVisibility.isColumnVisible('game') && ( - - )} - {columnVisibility.isColumnVisible('author') && ( - - )} - {columnVisibility.isColumnVisible('status') && ( - - )} - {columnVisibility.isColumnVisible('processedBy') && ( - - )} - {columnVisibility.isColumnVisible('processedAt') && ( - - )} - {columnVisibility.isColumnVisible('actions') && ( - - )} - - ))} - -
    - Game / System - - Author - - Status - - Processed By (Admin) - - Processed At - - Actions -
    - { - analytics.contentDiscovery.externalLinkClicked({ - url: `/listings/${listing.id}`, - context: 'admin_processed_listings_view', - entityId: listing.id, - }) - }} - > - {listing.game.title} - - -
    - {listing.game.system.name} -
    -
    - {listing.author?.name ?? 'N/A'} - - - {listing.status} - - - {listing.processedByUser?.name ?? 'N/A'} - - {listing.processedAt ? ( - - ) : ( - 'N/A' - )} - - {hasPermission(userQuery.data?.permissions, PERMISSIONS.EDIT_ANY_LISTING) && ( - - )} - {hasPermission(userQuery.data?.permissions, PERMISSIONS.APPROVE_LISTINGS) && ( - openOverrideModal(listing, ApprovalStatus.PENDING)} - /> - )} - {hasPermission(userQuery.data?.permissions, PERMISSIONS.APPROVE_LISTINGS) && - listing.status === ApprovalStatus.APPROVED && ( - openOverrideModal(listing, ApprovalStatus.REJECTED)} - /> - )} - {hasPermission(userQuery.data?.permissions, PERMISSIONS.APPROVE_LISTINGS) && - listing.status === ApprovalStatus.REJECTED && ( - openOverrideModal(listing, ApprovalStatus.APPROVED)} - /> - )} -
    - )} -
    - - {paginationData && paginationData.pages > 1 && ( - - )} - - -
    + searchPlaceholder="Search by game, system, device, author, emulator, or notes..." + storageKey={storageKeys.columnVisibility.adminProcessedListings} + analyticsContext="admin_processed_handheld_reports_view" + table={table} + reports={processedListings} + pagination={processedListingsQuery.data?.pagination} + stats={listingStatsQuery.data ?? {}} + isStatsLoading={listingStatsQuery.isPending} + isReportsLoading={processedListingsQuery.isPending} + currentUserPermissions={currentUserQuery.data?.permissions} + currentUserRole={currentUserQuery.data?.role} + filterStatus={filterStatus} + hardwareColumns={HANDHELD_HARDWARE_COLUMNS} + accessors={HANDHELD_REPORT_ACCESSORS} + onFilterStatusChange={setFilterStatus} + onRetry={() => { + void processedListingsQuery.refetch() + }} + onOverrideStatus={async (request) => { + if (request.newStatus === ApprovalStatus.PENDING) { + await resetToPendingMutation.mutateAsync({ + listingId: request.report.id, + } satisfies RouterInput['listings']['resetToPending']) + return + } + + await overrideMutation.mutateAsync({ + listingId: request.report.id, + newStatus: request.newStatus, + overrideNotes: request.overrideNotes, + } satisfies RouterInput['listings']['overrideApprovalStatus']) + }} + isOverridePending={overrideMutation.isPending || resetToPendingMutation.isPending} + /> ) } diff --git a/src/data/storageKeys.ts b/src/data/storageKeys.ts index cd980e2c7..34f2367cb 100644 --- a/src/data/storageKeys.ts +++ b/src/data/storageKeys.ts @@ -37,7 +37,8 @@ const storageKeys = { adminGames: `${PREFIX}admin_games_column_visibility`, adminListings: `${PREFIX}admin_listings_column_visibility`, adminPerformance: `${PREFIX}admin_performance_column_visibility`, - adminProcessedListings: `${PREFIX}admin_processed_listings_column_visibility`, + adminProcessedListings: `${PREFIX}admin_processed_reports_column_visibility`, + adminPcProcessedListings: `${PREFIX}admin_pc_processed_listings_column_visibility`, adminSoCs: `${PREFIX}admin_socs_column_visibility`, adminSystems: `${PREFIX}admin_systems_column_visibility`, adminTrustLogs: `${PREFIX}admin_trust_logs_column_visibility`, diff --git a/src/schemas/pcListing.ts b/src/schemas/pcListing.ts index 5fcdb9563..85bbcd29c 100644 --- a/src/schemas/pcListing.ts +++ b/src/schemas/pcListing.ts @@ -66,27 +66,32 @@ 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), - filterStatus: z.nativeEnum(ApprovalStatus).optional(), - search: z.string().optional(), + filterStatus: z.nativeEnum(ApprovalStatus).nullable().optional(), + search: z.string().nullable().optional(), + sortField: z + .enum([ + 'processedAt', + 'createdAt', + 'status', + 'game.title', + 'game.system.name', + 'cpu', + 'gpu', + 'emulator.name', + 'author.name', + ]) + .nullable() + .optional(), + sortDirection: z.enum(['asc', 'desc']).nullable().optional(), }) export const OverridePcApprovalStatusSchema = z.object({ pcListingId: z.string().uuid(), newStatus: z.nativeEnum(ApprovalStatus), // PENDING, APPROVED, or REJECTED - overrideNotes: z.string().optional(), + overrideNotes: z.string().nullable().optional(), }) export const ResetPcListingToPendingSchema = z.object({ diff --git a/src/server/api/routers/listings/admin.test.ts b/src/server/api/routers/listings/admin.test.ts index 147f16bf1..9fa39d2bf 100644 --- a/src/server/api/routers/listings/admin.test.ts +++ b/src/server/api/routers/listings/admin.test.ts @@ -1,7 +1,10 @@ import { describe, expect, it, beforeEach, vi } from 'vitest' import { RISK_SIGNAL_TYPES } from '@/schemas/authorRisk' import { SUBMISSION_RISK_SIGNAL_TYPES } from '@/schemas/submissionRisk' -import { ApprovalStatus, Role } from '@orm/client' +import { invalidateListingSeo } from '@/server/cache/invalidation' +import { notificationEventEmitter } from '@/server/notifications/eventEmitter' +import { invalidateCatalogCompatibilityCacheForDevice } from '@/server/utils/cache/instances' +import { ApprovalStatus, Role, TrustAction } from '@orm' import type * as AuthorRiskService from '@/server/services/author-risk.service' vi.unmock('@/server/api/trpc') @@ -263,6 +266,237 @@ describe('listing admin pending approvals', () => { }) }) +describe('listing admin processed reports', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + function setupPrisma() { + const listing = { + findMany: vi.fn().mockResolvedValue([]), + count: vi.fn().mockResolvedValue(0), + } + const prismaMock = prisma as unknown as { + listing: typeof listing + } + + prismaMock.listing = listing + + return { listing } + } + + it('searches processed handheld reports across visible report columns', async () => { + const processedListing = { + id: LISTING_ID, + status: ApprovalStatus.REJECTED, + } + const { listing } = setupPrisma() + listing.findMany.mockResolvedValueOnce([processedListing]) + listing.count.mockResolvedValueOnce(1) + + const { caller } = createCaller({ role: Role.SUPER_ADMIN }) + + const result = await caller.getProcessed({ + page: 1, + limit: 20, + filterStatus: ApprovalStatus.REJECTED, + search: 'ayaneo', + sortField: 'device', + sortDirection: 'asc', + }) + + expect(listing.findMany).toHaveBeenCalledWith({ + where: { + NOT: { status: ApprovalStatus.PENDING }, + status: ApprovalStatus.REJECTED, + OR: [ + { game: { title: { contains: 'ayaneo', mode: 'insensitive' } } }, + { game: { system: { name: { contains: 'ayaneo', mode: 'insensitive' } } } }, + { device: { modelName: { contains: 'ayaneo', mode: 'insensitive' } } }, + { device: { brand: { name: { contains: 'ayaneo', mode: 'insensitive' } } } }, + { emulator: { name: { contains: 'ayaneo', mode: 'insensitive' } } }, + { author: { name: { contains: 'ayaneo', mode: 'insensitive' } } }, + { processedNotes: { contains: 'ayaneo', mode: 'insensitive' } }, + { notes: { contains: 'ayaneo', mode: 'insensitive' } }, + ], + }, + include: { + game: { include: { system: true } }, + device: { include: { brand: true } }, + emulator: true, + author: { select: { id: true, name: true } }, + performance: true, + processedByUser: { select: { id: true, name: true } }, + }, + orderBy: [{ device: { brand: { name: 'asc' } } }, { device: { modelName: 'asc' } }], + skip: 0, + take: 20, + }) + expect(listing.count).toHaveBeenCalledWith({ + where: expect.objectContaining({ + NOT: { status: ApprovalStatus.PENDING }, + status: ApprovalStatus.REJECTED, + }), + }) + expect(result.listings).toEqual([processedListing]) + expect(result.pagination.total).toBe(1) + }) +}) + +describe('listing admin processed report status overrides', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + function setupPrisma() { + const listing = { + findUnique: vi.fn(), + update: vi.fn(), + } + const prismaMock = prisma as unknown as { + listing: typeof listing + } + + prismaMock.listing = listing + + return { listing } + } + + it('emits a rejection notification when a processed handheld report is overridden to rejected', async () => { + const processedAt = new Date('2026-06-01T12:00:00.000Z') + const { listing } = setupPrisma() + listing.findUnique.mockResolvedValueOnce({ + id: LISTING_ID, + status: ApprovalStatus.APPROVED, + gameId: '00000000-0000-4000-a000-000000000030', + deviceId: '00000000-0000-4000-a000-000000000031', + emulatorId: '00000000-0000-4000-a000-000000000032', + authorId: AUTHOR_ID, + processedNotes: 'Old notes', + }) + listing.update.mockResolvedValueOnce({ + id: LISTING_ID, + status: ApprovalStatus.REJECTED, + processedAt, + }) + + const { caller } = createCaller({ role: Role.SUPER_ADMIN }) + + await caller.overrideStatus({ + listingId: LISTING_ID, + newStatus: ApprovalStatus.REJECTED, + overrideNotes: 'Incorrect report', + }) + + expect(listing.update).toHaveBeenCalledWith({ + where: { id: LISTING_ID }, + data: { + status: ApprovalStatus.REJECTED, + processedByUserId: ADMIN_ID, + processedAt: expect.any(Date), + processedNotes: 'Incorrect report', + }, + }) + expect(invalidateListingSeo).toHaveBeenCalledWith({ + id: LISTING_ID, + gameId: '00000000-0000-4000-a000-000000000030', + deviceId: '00000000-0000-4000-a000-000000000031', + emulatorId: '00000000-0000-4000-a000-000000000032', + }) + expect(invalidateCatalogCompatibilityCacheForDevice).toHaveBeenCalledWith( + '00000000-0000-4000-a000-000000000031', + ) + expect(mockApplyTrustAction).toHaveBeenCalledWith({ + userId: AUTHOR_ID, + action: TrustAction.LISTING_REJECTED, + context: { + listingId: LISTING_ID, + adminUserId: ADMIN_ID, + reason: 'Incorrect report', + }, + }) + expect(notificationEventEmitter.emitNotificationEvent).toHaveBeenCalledWith({ + eventType: 'LISTING_REJECTED', + entityType: 'listing', + entityId: LISTING_ID, + triggeredBy: ADMIN_ID, + payload: { + listingId: LISTING_ID, + rejectedBy: ADMIN_ID, + rejectedAt: processedAt, + rejectionReason: 'Incorrect report', + }, + }) + }) + + it('clears processed metadata without emitting a notification when overriding to pending', async () => { + const { listing } = setupPrisma() + listing.findUnique.mockResolvedValueOnce({ + id: LISTING_ID, + status: ApprovalStatus.REJECTED, + gameId: '00000000-0000-4000-a000-000000000030', + deviceId: '00000000-0000-4000-a000-000000000031', + emulatorId: '00000000-0000-4000-a000-000000000032', + authorId: AUTHOR_ID, + processedNotes: 'Rejected notes', + }) + listing.update.mockResolvedValueOnce({ + id: LISTING_ID, + status: ApprovalStatus.PENDING, + }) + + const { caller } = createCaller({ role: Role.SUPER_ADMIN }) + + await caller.overrideStatus({ + listingId: LISTING_ID, + newStatus: ApprovalStatus.PENDING, + }) + + expect(listing.update).toHaveBeenCalledWith({ + where: { id: LISTING_ID }, + data: { + status: ApprovalStatus.PENDING, + processedByUserId: null, + processedAt: null, + processedNotes: null, + }, + }) + expect(invalidateListingSeo).not.toHaveBeenCalled() + expect(mockApplyTrustAction).not.toHaveBeenCalled() + expect(notificationEventEmitter.emitNotificationEvent).not.toHaveBeenCalled() + }) + + it('invalidates public handheld report caches when resetting an approved report to pending', async () => { + const { listing } = setupPrisma() + listing.findUnique.mockResolvedValueOnce({ + id: LISTING_ID, + status: ApprovalStatus.APPROVED, + gameId: '00000000-0000-4000-a000-000000000030', + deviceId: '00000000-0000-4000-a000-000000000031', + emulatorId: '00000000-0000-4000-a000-000000000032', + }) + listing.update.mockResolvedValueOnce({ + id: LISTING_ID, + status: ApprovalStatus.PENDING, + }) + + const { caller } = createCaller({ role: Role.MODERATOR }) + + await caller.resetToPending({ listingId: LISTING_ID }) + + expect(invalidateListingSeo).toHaveBeenCalledWith({ + id: LISTING_ID, + gameId: '00000000-0000-4000-a000-000000000030', + deviceId: '00000000-0000-4000-a000-000000000031', + emulatorId: '00000000-0000-4000-a000-000000000032', + }) + expect(invalidateCatalogCompatibilityCacheForDevice).toHaveBeenCalledWith( + '00000000-0000-4000-a000-000000000031', + ) + expect(notificationEventEmitter.emitNotificationEvent).not.toHaveBeenCalled() + }) +}) + describe('listing admin auto risk rejection', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/src/server/api/routers/listings/admin.ts b/src/server/api/routers/listings/admin.ts index e2d03bfe7..5ff2e3d9a 100644 --- a/src/server/api/routers/listings/admin.ts +++ b/src/server/api/routers/listings/admin.ts @@ -28,6 +28,7 @@ import { protectedProcedure, } from '@/server/api/trpc' import { buildProcessedOrderBy } from '@/server/api/utils/listingHelpers' +import { getProcessedStatusTrustAction } from '@/server/api/utils/processedStatusTrust' import { invalidateListingSeo, invalidateListingsSeo } from '@/server/cache/invalidation' import { notificationEventEmitter, NOTIFICATION_EVENTS } from '@/server/notifications/eventEmitter' import { ListingsRepository } from '@/server/repositories/listings.repository' @@ -47,7 +48,8 @@ import { import { generateEmulatorConfig } from '@/server/utils/emulator-config/emulator-detector' import { paginate } from '@/server/utils/pagination' import { hasRolePermission } from '@/utils/permissions' -import { Prisma, ApprovalStatus, TrustAction, Role } from '@orm/client' +import { ApprovalStatus, Role, TrustAction } from '@orm' +import { Prisma } from '@orm/client' const LISTING_STATS_CACHE_KEY = 'listing-stats' @@ -350,7 +352,7 @@ export const adminRouter = createTRPCRouter({ const listing = await ctx.prisma.listing.findUnique({ where: { id: listingId }, - select: { id: true, status: true }, + select: { id: true, status: true, gameId: true, deviceId: true, emulatorId: true }, }) if (!listing) return ResourceError.listing.notFound() @@ -371,6 +373,16 @@ export const adminRouter = createTRPCRouter({ listingStatsCache.delete(LISTING_STATS_CACHE_KEY) + if (listing.status === ApprovalStatus.APPROVED) { + await invalidateListingSeo({ + id: listingId, + gameId: listing.gameId, + deviceId: listing.deviceId, + emulatorId: listing.emulatorId, + }) + invalidateCatalogCompatibilityCacheForDevice(listing.deviceId) + } + return updatedListing }), @@ -387,6 +399,10 @@ export const adminRouter = createTRPCRouter({ ? { OR: [ { game: { title: { contains: search, mode } } }, + { game: { system: { name: { contains: search, mode } } } }, + { device: { modelName: { contains: search, mode } } }, + { device: { brand: { name: { contains: search, mode } } } }, + { emulator: { name: { contains: search, mode } } }, { author: { name: { contains: search, mode } } }, { processedNotes: { contains: search, mode } }, { notes: { contains: search, mode } }, @@ -434,34 +450,91 @@ export const adminRouter = createTRPCRouter({ const listingToOverride = await ctx.prisma.listing.findUnique({ where: { id: listingId }, + select: { + id: true, + status: true, + gameId: true, + deviceId: true, + emulatorId: true, + authorId: true, + processedNotes: true, + }, }) if (!listingToOverride) return ResourceError.listing.notFound() const updatedListing = await ctx.prisma.listing.update({ where: { id: listingId }, - data: { - status: newStatus, - processedByUserId: superAdminUserId, // Log the SUPER_ADMIN as the latest processor - processedAt: new Date(), // Update timestamp to the override time - processedNotes: overrideNotes ?? listingToOverride.processedNotes, // Keep old notes if no new ones - }, + data: + newStatus === ApprovalStatus.PENDING + ? { + status: newStatus, + processedByUserId: null, + processedAt: null, + processedNotes: null, + } + : { + status: newStatus, + processedByUserId: superAdminUserId, + processedAt: new Date(), + processedNotes: overrideNotes ?? listingToOverride.processedNotes, + }, }) - // Emit notification event - notificationEventEmitter.emitNotificationEvent({ - eventType: NOTIFICATION_EVENTS.LISTING_STATUS_OVERRIDDEN, - entityType: 'listing', - entityId: listingId, - triggeredBy: superAdminUserId, - payload: { - listingId, - overriddenBy: superAdminUserId, - newStatus, - overriddenAt: updatedListing.processedAt, - overrideNotes: overrideNotes, - }, + if ( + listingToOverride.status === ApprovalStatus.APPROVED || + newStatus === ApprovalStatus.APPROVED + ) { + await invalidateListingSeo({ + id: listingId, + gameId: listingToOverride.gameId, + deviceId: listingToOverride.deviceId, + emulatorId: listingToOverride.emulatorId, + }) + invalidateCatalogCompatibilityCacheForDevice(listingToOverride.deviceId) + } + + const trustAction = getProcessedStatusTrustAction({ + previousStatus: listingToOverride.status, + newStatus, + authorId: listingToOverride.authorId, }) + if (trustAction) { + await applyTrustAction({ + userId: trustAction.userId, + action: trustAction.action, + context: { + listingId, + adminUserId: superAdminUserId, + reason: overrideNotes || 'listing_status_override', + }, + }) + } + + if (newStatus === ApprovalStatus.APPROVED || newStatus === ApprovalStatus.REJECTED) { + notificationEventEmitter.emitNotificationEvent({ + eventType: + newStatus === ApprovalStatus.APPROVED + ? NOTIFICATION_EVENTS.LISTING_APPROVED + : NOTIFICATION_EVENTS.LISTING_REJECTED, + entityType: 'listing', + entityId: listingId, + triggeredBy: superAdminUserId, + payload: + newStatus === ApprovalStatus.APPROVED + ? { + listingId, + approvedBy: superAdminUserId, + approvedAt: updatedListing.processedAt, + } + : { + listingId, + rejectedBy: superAdminUserId, + rejectedAt: updatedListing.processedAt, + rejectionReason: overrideNotes, + }, + }) + } // Invalidate listing stats cache listingStatsCache.delete(LISTING_STATS_CACHE_KEY) diff --git a/src/server/api/routers/pcListings.test.ts b/src/server/api/routers/pcListings.test.ts index d60abece5..3144e2e6b 100644 --- a/src/server/api/routers/pcListings.test.ts +++ b/src/server/api/routers/pcListings.test.ts @@ -7,7 +7,7 @@ import { invalidatePcListingsSeo, } from '@/server/cache/invalidation' import { PERMISSIONS } from '@/utils/permission-system' -import { ApprovalStatus, PcOs, ReportReason, Role, TrustAction } from '@orm/client' +import { ApprovalStatus, PcOs, ReportReason, Role, TrustAction } from '@orm' vi.unmock('@/server/api/trpc') vi.unmock('@/server/api/root') @@ -195,6 +195,7 @@ function createMockPrisma() { pcListing: { findUnique: vi.fn(), findMany: vi.fn().mockResolvedValue([]), + count: vi.fn().mockResolvedValue(0), update: vi.fn(), updateMany: vi.fn().mockResolvedValue({ count: 0 }), }, @@ -853,6 +854,159 @@ describe('pcListings trust integration', () => { }) }) + describe('getProcessed', () => { + it('loads processed PC reports with status, search, pagination, and sorting', async () => { + const processedListing = { + id: LISTING_ID, + status: ApprovalStatus.APPROVED, + } + const { caller, prisma } = createCaller({ userId: ADMIN_ID, role: Role.SUPER_ADMIN }) + prisma.pcListing.findMany.mockResolvedValueOnce([processedListing]) + prisma.pcListing.count.mockResolvedValueOnce(1) + + const result = await caller.getProcessed({ + page: 2, + limit: 10, + filterStatus: ApprovalStatus.APPROVED, + search: 'steam deck', + sortField: 'cpu', + sortDirection: 'asc', + }) + + expect(prisma.pcListing.findMany).toHaveBeenCalledWith({ + where: { + NOT: { status: ApprovalStatus.PENDING }, + status: ApprovalStatus.APPROVED, + OR: [ + { game: { title: { contains: 'steam deck', mode: 'insensitive' } } }, + { game: { system: { name: { contains: 'steam deck', mode: 'insensitive' } } } }, + { cpu: { modelName: { contains: 'steam deck', mode: 'insensitive' } } }, + { cpu: { brand: { name: { contains: 'steam deck', mode: 'insensitive' } } } }, + { gpu: { modelName: { contains: 'steam deck', mode: 'insensitive' } } }, + { gpu: { brand: { name: { contains: 'steam deck', mode: 'insensitive' } } } }, + { emulator: { name: { contains: 'steam deck', mode: 'insensitive' } } }, + { author: { name: { contains: 'steam deck', mode: 'insensitive' } } }, + { processedNotes: { contains: 'steam deck', mode: 'insensitive' } }, + { notes: { contains: 'steam deck', mode: 'insensitive' } }, + ], + }, + include: expect.objectContaining({ processedByUser: true }), + orderBy: [{ cpu: { brand: { name: 'asc' } } }, { cpu: { modelName: 'asc' } }], + skip: 10, + take: 10, + }) + expect(prisma.pcListing.count).toHaveBeenCalledWith({ + where: expect.objectContaining({ + NOT: { status: ApprovalStatus.PENDING }, + status: ApprovalStatus.APPROVED, + }), + }) + expect(result.pcListings).toEqual([processedListing]) + expect(result.pagination.total).toBe(1) + }) + }) + + describe('overrideStatus', () => { + it('updates a processed PC report, invalidates SEO, and emits a rejected event', async () => { + const gameId = '00000000-0000-4000-a000-000000000040' + const cpuId = '00000000-0000-4000-a000-000000000070' + const processedAt = new Date('2026-06-01T12:00:00.000Z') + const { caller, prisma } = createCaller({ userId: ADMIN_ID, role: Role.SUPER_ADMIN }) + prisma.pcListing.findUnique.mockResolvedValueOnce({ + id: LISTING_ID, + status: ApprovalStatus.APPROVED, + gameId, + cpuId, + gpuId: null, + authorId: AUTHOR_ID, + processedNotes: 'Old notes', + }) + prisma.pcListing.update.mockResolvedValueOnce({ + id: LISTING_ID, + status: ApprovalStatus.REJECTED, + processedAt, + }) + + await caller.overrideStatus({ + pcListingId: LISTING_ID, + newStatus: ApprovalStatus.REJECTED, + overrideNotes: 'Incorrect hardware', + }) + + expect(prisma.pcListing.update).toHaveBeenCalledWith({ + where: { id: LISTING_ID }, + data: { + status: ApprovalStatus.REJECTED, + processedByUserId: ADMIN_ID, + processedAt: expect.any(Date), + processedNotes: 'Incorrect hardware', + }, + }) + expect(invalidatePcListingSeo).toHaveBeenCalledWith({ + id: LISTING_ID, + gameId, + cpuId, + gpuId: null, + }) + expect(mockApplyTrustAction).toHaveBeenCalledWith({ + userId: AUTHOR_ID, + action: TrustAction.LISTING_REJECTED, + context: { + pcListingId: LISTING_ID, + adminUserId: ADMIN_ID, + reason: 'Incorrect hardware', + }, + }) + expect(mockEmitNotificationEvent).toHaveBeenCalledWith({ + eventType: 'PC_LISTING_REJECTED', + entityType: 'pcListing', + entityId: LISTING_ID, + triggeredBy: ADMIN_ID, + payload: { + pcListingId: LISTING_ID, + rejectedBy: ADMIN_ID, + rejectedAt: processedAt, + rejectionReason: 'Incorrect hardware', + }, + }) + }) + + it('clears processed metadata without emitting a notification when returning to pending', async () => { + const { caller, prisma } = createCaller({ userId: ADMIN_ID, role: Role.SUPER_ADMIN }) + prisma.pcListing.findUnique.mockResolvedValueOnce({ + id: LISTING_ID, + status: ApprovalStatus.REJECTED, + gameId: '00000000-0000-4000-a000-000000000040', + cpuId: '00000000-0000-4000-a000-000000000070', + gpuId: null, + authorId: AUTHOR_ID, + processedNotes: 'Rejected notes', + }) + prisma.pcListing.update.mockResolvedValueOnce({ + id: LISTING_ID, + status: ApprovalStatus.PENDING, + }) + + await caller.overrideStatus({ + pcListingId: LISTING_ID, + newStatus: ApprovalStatus.PENDING, + }) + + expect(prisma.pcListing.update).toHaveBeenCalledWith({ + where: { id: LISTING_ID }, + data: { + status: ApprovalStatus.PENDING, + processedByUserId: null, + processedAt: null, + processedNotes: null, + }, + }) + expect(invalidatePcListingSeo).not.toHaveBeenCalled() + expect(mockApplyTrustAction).not.toHaveBeenCalled() + expect(mockEmitNotificationEvent).not.toHaveBeenCalled() + }) + }) + describe('approve', () => { it('calls applyTrustAction with LISTING_APPROVED for author', async () => { mockRepositoryGetById.mockResolvedValue({ diff --git a/src/server/api/routers/pcListings.ts b/src/server/api/routers/pcListings.ts index b82a3bfb1..7e4fb11ac 100644 --- a/src/server/api/routers/pcListings.ts +++ b/src/server/api/routers/pcListings.ts @@ -24,6 +24,8 @@ import { GetPcListingVerificationsSchema, GetPcPresetsSchema, GetPendingPcListingsSchema, + GetProcessedPcSchema, + OverridePcApprovalStatusSchema, PinPcListingCommentSchema, RejectPcListingSchema, RemovePcListingVerificationSchema, @@ -45,16 +47,19 @@ import { permissionProcedure, protectedProcedure, publicProcedure, + superAdminProcedure, viewStatisticsProcedure, } from '@/server/api/trpc' import { buildCommentTree, findCommentWithParent } from '@/server/api/utils/commentTree' import { buildPcListingOrderBy, buildPcListingWhere, + buildProcessedPcListingOrderBy, pcListingAdminInclude, pcListingDetailInclude, } from '@/server/api/utils/pcListingHelpers' import { canManageCommentPins } from '@/server/api/utils/pinPermissions' +import { getProcessedStatusTrustAction } from '@/server/api/utils/processedStatusTrust' import { invalidatePcListingSeo, invalidatePcListingSeoForUpdate, @@ -91,15 +96,8 @@ import { hasRolePermission, isModerator, } from '@/utils/permissions' -import { - ApprovalStatus, - AuditAction, - AuditEntityType, - Prisma, - ReportStatus, - Role, - TrustAction, -} from '@orm/client' +import { ApprovalStatus, AuditAction, AuditEntityType, ReportStatus, Role, TrustAction } from '@orm' +import { Prisma } from '@orm/client' function isJsonRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) @@ -640,6 +638,8 @@ export const pcListingsRouter = createTRPCRouter({ payload: { pcListingId: input.pcListingId, gameId: pcListing.gameId, + approvedBy: ctx.session.user.id, + approvedAt: approvedListing.processedAt, }, }) @@ -750,6 +750,148 @@ export const pcListingsRouter = createTRPCRouter({ return updatedListing }), + getProcessed: superAdminProcedure.input(GetProcessedPcSchema).query(async ({ ctx, input }) => { + const { page, limit, filterStatus, search, sortField, sortDirection } = input + const skip = (page - 1) * limit + + const baseWhere: Prisma.PcListingWhereInput = { + NOT: { status: ApprovalStatus.PENDING }, + ...(filterStatus ? { status: filterStatus } : {}), + } + + const searchWhere: Prisma.PcListingWhereInput = search + ? { + OR: [ + { game: { title: { contains: search, mode: 'insensitive' } } }, + { game: { system: { name: { contains: search, mode: 'insensitive' } } } }, + { cpu: { modelName: { contains: search, mode: 'insensitive' } } }, + { cpu: { brand: { name: { contains: search, mode: 'insensitive' } } } }, + { gpu: { modelName: { contains: search, mode: 'insensitive' } } }, + { gpu: { brand: { name: { contains: search, mode: 'insensitive' } } } }, + { emulator: { name: { contains: search, mode: 'insensitive' } } }, + { author: { name: { contains: search, mode: 'insensitive' } } }, + { processedNotes: { contains: search, mode: 'insensitive' } }, + { notes: { contains: search, mode: 'insensitive' } }, + ], + } + : {} + + const where = buildPcListingWhere({ ...baseWhere, ...searchWhere }, true) + const orderBy = buildProcessedPcListingOrderBy(sortField, sortDirection) + + const [pcListings, total] = await Promise.all([ + ctx.prisma.pcListing.findMany({ + where, + include: pcListingAdminInclude, + orderBy, + skip, + take: limit, + }), + ctx.prisma.pcListing.count({ where }), + ]) + + return { + pcListings, + pagination: paginate({ total, page, limit }), + } + }), + + overrideStatus: superAdminProcedure + .input(OverridePcApprovalStatusSchema) + .mutation(async ({ ctx, input }) => { + const { pcListingId, newStatus, overrideNotes } = input + const superAdminUserId = ctx.session.user.id + + const pcListing = await ctx.prisma.pcListing.findUnique({ + where: { id: pcListingId }, + select: { + id: true, + status: true, + gameId: true, + cpuId: true, + gpuId: true, + authorId: true, + processedNotes: true, + }, + }) + + if (!pcListing) return ResourceError.pcListing.notFound() + + const updatedPcListing = await ctx.prisma.pcListing.update({ + where: { id: pcListingId }, + data: + newStatus === ApprovalStatus.PENDING + ? { + status: newStatus, + processedByUserId: null, + processedAt: null, + processedNotes: null, + } + : { + status: newStatus, + processedByUserId: superAdminUserId, + processedAt: new Date(), + processedNotes: overrideNotes ?? pcListing.processedNotes, + }, + }) + + listingStatsCache.delete('pc-listing-stats') + + if (pcListing.status === ApprovalStatus.APPROVED || newStatus === ApprovalStatus.APPROVED) { + await invalidatePcListingSeo({ + id: pcListingId, + gameId: pcListing.gameId, + cpuId: pcListing.cpuId, + gpuId: pcListing.gpuId, + }) + } + + const trustAction = getProcessedStatusTrustAction({ + previousStatus: pcListing.status, + newStatus, + authorId: pcListing.authorId, + }) + if (trustAction) { + await applyTrustAction({ + userId: trustAction.userId, + action: trustAction.action, + context: { + pcListingId, + adminUserId: superAdminUserId, + reason: overrideNotes || 'pc_listing_status_override', + }, + }) + } + + if (newStatus === ApprovalStatus.APPROVED || newStatus === ApprovalStatus.REJECTED) { + notificationEventEmitter.emitNotificationEvent({ + eventType: + newStatus === ApprovalStatus.APPROVED + ? NOTIFICATION_EVENTS.PC_LISTING_APPROVED + : NOTIFICATION_EVENTS.PC_LISTING_REJECTED, + entityType: 'pcListing', + entityId: pcListingId, + triggeredBy: superAdminUserId, + payload: + newStatus === ApprovalStatus.APPROVED + ? { + pcListingId, + gameId: pcListing.gameId, + approvedBy: superAdminUserId, + approvedAt: updatedPcListing.processedAt, + } + : { + pcListingId, + rejectedBy: superAdminUserId, + rejectedAt: updatedPcListing.processedAt, + rejectionReason: overrideNotes, + }, + }) + } + + return updatedPcListing + }), + bulkApprove: protectedProcedure .input(BulkApprovePcListingsSchema) .mutation(async ({ ctx, input }) => { @@ -766,17 +908,17 @@ export const pcListingsRouter = createTRPCRouter({ where: { id: { in: input.pcListingIds }, status: ApprovalStatus.PENDING }, select: { id: true, gameId: true, cpuId: true, gpuId: true, authorId: true }, }) + const approvedAt = new Date() const result = await ctx.prisma.pcListing.updateMany({ where: { id: { in: pendingListings.map((l) => l.id) } }, data: { status: ApprovalStatus.APPROVED, - processedAt: new Date(), + processedAt: approvedAt, processedByUserId: ctx.session.user.id, }, }) - // Apply trust actions in parallel — distinct user adjustments, independent. const listingsWithAuthor = pendingListings.filter( (l): l is typeof l & { authorId: string } => l.authorId !== null, ) @@ -807,6 +949,9 @@ export const pcListingsRouter = createTRPCRouter({ payload: { pcListingId: listing.id, gameId: listing.gameId, + approvedBy: ctx.session.user.id, + approvedAt, + bulk: true, }, }) } diff --git a/src/server/api/utils/pcListingHelpers.ts b/src/server/api/utils/pcListingHelpers.ts index 7b4a85d8f..85c3bd73f 100644 --- a/src/server/api/utils/pcListingHelpers.ts +++ b/src/server/api/utils/pcListingHelpers.ts @@ -95,6 +95,47 @@ export function buildPcListingOrderBy( return orderBy } +export type ProcessedPcListingSortField = + | 'processedAt' + | 'createdAt' + | 'status' + | 'game.title' + | 'game.system.name' + | 'cpu' + | 'gpu' + | 'emulator.name' + | 'author.name' + +export function buildProcessedPcListingOrderBy( + sortField: ProcessedPcListingSortField | null | undefined, + sortDirection: 'asc' | 'desc' | null | undefined, +): Prisma.PcListingOrderByWithRelationInput | Prisma.PcListingOrderByWithRelationInput[] { + 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 'cpu': + return [{ cpu: { brand: { name: direction } } }, { cpu: { modelName: direction } }] + case 'gpu': + return [{ gpu: { brand: { name: direction } } }, { gpu: { 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 } + } +} + /** * Builds where clause for PC listings with banned user filtering */ diff --git a/src/server/api/utils/processedStatusTrust.ts b/src/server/api/utils/processedStatusTrust.ts new file mode 100644 index 000000000..2aef3bf82 --- /dev/null +++ b/src/server/api/utils/processedStatusTrust.ts @@ -0,0 +1,27 @@ +import { ApprovalStatus, TrustAction } from '@orm' + +interface ProcessedStatusTrustInput { + previousStatus: ApprovalStatus + newStatus: ApprovalStatus + authorId?: string | null +} + +interface ProcessedStatusTrustAction { + userId: string + action: TrustAction +} + +export function getProcessedStatusTrustAction( + input: ProcessedStatusTrustInput, +): ProcessedStatusTrustAction | null { + if (!input.authorId || input.previousStatus === input.newStatus) return null + + switch (input.newStatus) { + case ApprovalStatus.APPROVED: + return { userId: input.authorId, action: TrustAction.LISTING_APPROVED } + case ApprovalStatus.REJECTED: + return { userId: input.authorId, action: TrustAction.LISTING_REJECTED } + case ApprovalStatus.PENDING: + return null + } +} diff --git a/src/server/notifications/eventEmitter.ts b/src/server/notifications/eventEmitter.ts index 3398fe035..6aeb347a7 100644 --- a/src/server/notifications/eventEmitter.ts +++ b/src/server/notifications/eventEmitter.ts @@ -53,7 +53,6 @@ export const NOTIFICATION_EVENTS = { USER_MENTIONED: 'user.mentioned', LISTING_APPROVED: 'listing.approved', LISTING_REJECTED: 'listing.rejected', - LISTING_STATUS_OVERRIDDEN: 'listing.status_overridden', LISTING_VERIFIED: 'listing.verified', CONTENT_FLAGGED: 'content.flagged', GAME_ADDED: 'game.added', diff --git a/src/server/notifications/service.ts b/src/server/notifications/service.ts index 3085c1156..90c26a699 100644 --- a/src/server/notifications/service.ts +++ b/src/server/notifications/service.ts @@ -17,10 +17,7 @@ import { Role, } from '@orm/client' import { createEmailService } from './emailService' -import { - type NotificationEventData, - notificationEventEmitter, -} from './eventEmitter' +import { type NotificationEventData, notificationEventEmitter } from './eventEmitter' import { notificationRateLimitService } from './rateLimitService' import { notificationTemplateEngine, type TemplateContext } from './templates' import type { @@ -252,7 +249,6 @@ export class NotificationService { 'pcListing.approved': NotificationType.LISTING_APPROVED, 'listing.rejected': NotificationType.LISTING_REJECTED, 'pcListing.rejected': NotificationType.LISTING_REJECTED, - 'listing.status_overridden': NotificationType.LISTING_APPROVED, 'content.flagged': NotificationType.CONTENT_FLAGGED, 'game.added': NotificationType.GAME_ADDED, 'emulator.updated': NotificationType.EMULATOR_UPDATED, From 373dcdfbf07e1f9081d7c81a9f8b33c6a1788bdd Mon Sep 17 00:00:00 2001 From: Producdevity Date: Mon, 8 Jun 2026 18:17:17 +0200 Subject: [PATCH 64/87] refactor: streamline error handling and role validation in TRPC helpers --- src/server/api/trpc.ts | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/src/server/api/trpc.ts b/src/server/api/trpc.ts index 1f0c7dd5e..4448b7e4a 100644 --- a/src/server/api/trpc.ts +++ b/src/server/api/trpc.ts @@ -6,7 +6,7 @@ import superjson from 'superjson' import { ZodError } from 'zod' import analytics from '@/lib/analytics' import { getSerializableAppError } from '@/lib/app-error-cause' -import { AppError } from '@/lib/errors' +import { AppError, ERROR_CODES } from '@/lib/errors' import { prisma } from '@/server/db' import { hasDeveloperAccessToEmulator } from '@/server/utils/permissions' import { type Nullable } from '@/types/utils' @@ -165,7 +165,7 @@ const t = initTRPC.context().create({ transformer: superjson, errorFormatter(ctx) { // Track errors for analytics - if (ctx.error.code !== 'UNAUTHORIZED' && ctx.error.code !== 'FORBIDDEN') { + if (ctx.error.code !== ERROR_CODES.UNAUTHORIZED && ctx.error.code !== ERROR_CODES.FORBIDDEN) { analytics.performance.errorOccurred({ errorType: ctx.error.code || 'UNKNOWN', errorMessage: ctx.error.message, @@ -230,9 +230,7 @@ export const authorProcedure = t.procedure.use(performanceMiddleware).use(({ ctx if (!ctx.session?.user) return AppError.unauthorized() // For now, we consider User as Author - if (!hasRolePermission(ctx.session.user.role, Role.USER)) { - return AppError.forbidden() - } + if (!hasRolePermission(ctx.session.user.role, Role.USER)) return AppError.forbidden() return next({ ctx: { @@ -249,7 +247,7 @@ export const moderatorProcedure = t.procedure.use(performanceMiddleware).use(({ if (!ctx.session?.user) AppError.unauthorized() if (!hasRolePermission(ctx.session.user.role, Role.MODERATOR)) { - AppError.insufficientRole(Role.MODERATOR) + return AppError.insufficientRole(Role.MODERATOR) } return next({ ctx: { session: { ...ctx.session, user: ctx.session.user } } }) @@ -262,7 +260,7 @@ export const developerProcedure = t.procedure.use(performanceMiddleware).use(({ if (!ctx.session?.user) AppError.unauthorized() if (!hasRolePermission(ctx.session.user.role, Role.DEVELOPER)) { - AppError.insufficientRole(Role.DEVELOPER) + return AppError.insufficientRole(Role.DEVELOPER) } return next({ ctx: { session: { ...ctx.session, user: ctx.session.user } } }) @@ -275,7 +273,7 @@ export const adminProcedure = t.procedure.use(performanceMiddleware).use(({ ctx, if (!ctx.session?.user) AppError.unauthorized() if (!hasRolePermission(ctx.session.user.role, Role.ADMIN)) { - AppError.insufficientRole(Role.ADMIN) + return AppError.insufficientRole(Role.ADMIN) } return next({ ctx: { session: { ...ctx.session, user: ctx.session.user } } }) @@ -288,7 +286,7 @@ export const superAdminProcedure = t.procedure.use(performanceMiddleware).use(({ if (!ctx.session?.user) return AppError.unauthorized() if (!hasRolePermission(ctx.session.user.role, Role.SUPER_ADMIN)) { - AppError.insufficientRole(Role.SUPER_ADMIN) + return AppError.insufficientRole(Role.SUPER_ADMIN) } return next({ ctx: { session: { ...ctx.session, user: ctx.session.user } } }) @@ -343,7 +341,7 @@ export function multiPermissionProcedure(requiredPermissions: string[]) { (permission) => !hasPermissionInContext(ctx, permission), ) - if (missingPermissions.length > 0) AppError.insufficientPermissions(missingPermissions) + if (missingPermissions.length > 0) return AppError.insufficientPermissions(missingPermissions) return next({ ctx: { ...ctx, session: { ...ctx.session, user: ctx.session.user } } }) }) @@ -360,7 +358,7 @@ export function anyPermissionProcedure(requiredPermissions: string[]) { hasPermissionInContext(ctx, permission), ) - if (!hasAnyPermission) AppError.insufficientRoles(requiredPermissions) + if (!hasAnyPermission) return AppError.insufficientRoles(requiredPermissions) return next({ ctx: { ...ctx, session: { ...ctx.session, user: ctx.session.user } } }) }) From 93e90f31c96077e939cc4f9e4eb2e4160bea4238 Mon Sep 17 00:00:00 2001 From: Producdevity Date: Mon, 8 Jun 2026 20:29:11 +0200 Subject: [PATCH 65/87] refactor: reorganize routers and update PC listing report functionality --- .../[id]/components/PcReportListingModal.tsx | 4 +- src/schemas/pcListing.ts | 20 +- src/server/api/root.ts | 2 + src/server/api/routers/listings/core.ts | 2 +- src/server/api/routers/listings/index.ts | 1 - src/server/api/routers/pcListingReports.ts | 228 ++ src/server/api/routers/pcListings.test.ts | 42 - src/server/api/routers/pcListings.ts | 2071 +---------------- src/server/api/routers/pcListings/admin.ts | 732 ++++++ src/server/api/routers/pcListings/comments.ts | 500 ++++ src/server/api/routers/pcListings/core.ts | 602 +++++ src/server/api/routers/pcListings/index.ts | 4 + src/server/api/routers/pcListings/utils.ts | 42 + .../repositories/listings.repository.ts | 2 +- src/server/utils/security-validation.ts | 3 + .../validate-custom-fields.ts} | 0 16 files changed, 2162 insertions(+), 2093 deletions(-) create mode 100644 src/server/api/routers/pcListingReports.ts create mode 100644 src/server/api/routers/pcListings/admin.ts create mode 100644 src/server/api/routers/pcListings/comments.ts create mode 100644 src/server/api/routers/pcListings/core.ts create mode 100644 src/server/api/routers/pcListings/index.ts create mode 100644 src/server/api/routers/pcListings/utils.ts rename src/server/{api/routers/listings/validation.ts => utils/validate-custom-fields.ts} (100%) diff --git a/src/app/pc-listings/[id]/components/PcReportListingModal.tsx b/src/app/pc-listings/[id]/components/PcReportListingModal.tsx index e1e71795d..d99a17225 100644 --- a/src/app/pc-listings/[id]/components/PcReportListingModal.tsx +++ b/src/app/pc-listings/[id]/components/PcReportListingModal.tsx @@ -39,7 +39,7 @@ function PcReportListingModal(props: Props) { const [description, setDescription] = useState('') const [error, setError] = useState('') - const createReport = api.pcListings.createReport.useMutation() + const createReport = api.pcListingReports.create.useMutation() const { user } = useUser() // Reset form when modal opens/closes @@ -64,7 +64,7 @@ function PcReportListingModal(props: Props) { pcListingId: props.pcListingId, reason, description: description.trim() || undefined, - } satisfies RouterInput['pcListings']['createReport']) + } satisfies RouterInput['pcListingReports']['create']) // Track content flagging in analytics if (user?.id) { diff --git a/src/schemas/pcListing.ts b/src/schemas/pcListing.ts index 85bbcd29c..40e1428fb 100644 --- a/src/schemas/pcListing.ts +++ b/src/schemas/pcListing.ts @@ -1,7 +1,7 @@ import { z } from 'zod' import { PAGINATION, CHAR_LIMITS } from '@/data/constants' import { HumanVerificationTokenSchema } from '@/features/human-verification/shared/schema' -import { JsonValueSchema } from '@/schemas/common' +import { JsonValueSchema, SortDirectionSchema } from '@/schemas/common' import { CreatePcListingBaseSchema } from '@/schemas/listingCreate' import { REVIEW_RISK_FILTERS, ReviewRiskFilterSchema } from '@/schemas/submissionRisk' import { ApprovalStatus, PcOs, ReportReason, ReportStatus } from '@orm' @@ -274,6 +274,8 @@ export const UnpinPcListingCommentSchema = z.object({ }) // PC Listing Report schemas +export const PcListingReportSortField = z.enum(['createdAt', 'updatedAt', 'status', 'reason']) + export const CreatePcListingReportSchema = z.object({ pcListingId: z.string().uuid(), reason: z.nativeEnum(ReportReason), @@ -286,11 +288,17 @@ export const UpdatePcListingReportSchema = z.object({ reviewNotes: z.string().max(1000).optional(), }) -export const GetPcListingReportsSchema = z.object({ - status: z.nativeEnum(ReportStatus).optional(), - page: z.number().min(1).default(1), - limit: z.number().min(1).max(100).default(20), -}) +export const GetPcListingReportsSchema = z + .object({ + search: z.string().optional(), + status: z.nativeEnum(ReportStatus).optional(), + reason: z.nativeEnum(ReportReason).optional(), + sortField: PcListingReportSortField.optional(), + sortDirection: SortDirectionSchema.optional(), + page: z.number().min(1).default(1), + limit: z.number().min(1).max(100).default(20), + }) + .optional() // PC Listing Verification schemas export const VerifyPcListingSchema = z.object({ diff --git a/src/server/api/root.ts b/src/server/api/root.ts index a9949f3e7..506b1962e 100644 --- a/src/server/api/root.ts +++ b/src/server/api/root.ts @@ -25,6 +25,7 @@ import { listingsRouter } from './routers/listings' import { listingVerificationsRouter } from './routers/listingVerifications' import { mobileRouter } from './routers/mobile' import { notificationsRouter } from './routers/notifications' +import { pcListingReportsRouter } from './routers/pcListingReports' import { pcListingsRouter } from './routers/pcListings' import { performanceScalesRouter } from './routers/performanceScales' import { permissionLogsRouter } from './routers/permissionLogs' @@ -47,6 +48,7 @@ export const appRouter = createTRPCRouter({ activity: activityRouter, listings: listingsRouter, pcListings: pcListingsRouter, + pcListingReports: pcListingReportsRouter, apiKeys: apiKeysRouter, devices: devicesRouter, cpus: cpusRouter, diff --git a/src/server/api/routers/listings/core.ts b/src/server/api/routers/listings/core.ts index 405fd22a0..02bd08016 100644 --- a/src/server/api/routers/listings/core.ts +++ b/src/server/api/routers/listings/core.ts @@ -32,12 +32,12 @@ import { isUserBanned } from '@/server/utils/query-builders' import { sanitizeInput, validatePagination } from '@/server/utils/security-validation' import { checkSpamContent } from '@/server/utils/spam-check' import { withSavepoint } from '@/server/utils/transactions' +import { validateCustomFields } from '@/server/utils/validate-custom-fields' import { updateListingVoteCounts } from '@/server/utils/vote-counts' import { handleListingVoteTrustEffects } from '@/server/utils/vote-trust-effects' import { roleIncludesRole } from '@/utils/permission-system' import { ms } from '@/utils/time' import { ApprovalStatus, Prisma, Role, TrustAction } from '@orm/client' -import { validateCustomFields } from './validation' const EDIT_TIME_LIMIT_MINUTES = 60 const EDIT_TIME_LIMIT = ms.minutes(EDIT_TIME_LIMIT_MINUTES) diff --git a/src/server/api/routers/listings/index.ts b/src/server/api/routers/listings/index.ts index f02fc174d..eeaf3e74e 100644 --- a/src/server/api/routers/listings/index.ts +++ b/src/server/api/routers/listings/index.ts @@ -1,4 +1,3 @@ export { coreRouter } from './core' export { commentsRouter } from './comments' export { adminRouter } from './admin' -export { validateCustomFields } from './validation' diff --git a/src/server/api/routers/pcListingReports.ts b/src/server/api/routers/pcListingReports.ts new file mode 100644 index 000000000..6f0b83a4b --- /dev/null +++ b/src/server/api/routers/pcListingReports.ts @@ -0,0 +1,228 @@ +import { ResourceError } from '@/lib/errors' +import { TrustService } from '@/lib/trust/service' +import { DeleteReportSchema, GetReportByIdSchema } from '@/schemas/listingReport' +import { + CreatePcListingReportSchema, + GetPcListingReportsSchema, + UpdatePcListingReportSchema, +} from '@/schemas/pcListing' +import { createTRPCRouter, permissionProcedure, protectedProcedure } from '@/server/api/trpc' +import { ReportSubmissionService } from '@/server/services/report-submission.service' +import { paginate } from '@/server/utils/pagination' +import { validateEnum, sanitizeInput, validatePagination } from '@/server/utils/security-validation' +import { PERMISSIONS } from '@/utils/permission-system' +import { ApprovalStatus, ReportReason, ReportStatus, TrustAction } from '@orm' +import { type Prisma } from '@orm/client' + +export const pcListingReportsRouter = createTRPCRouter({ + stats: permissionProcedure(PERMISSIONS.VIEW_STATISTICS).query(async ({ ctx }) => { + const [pending, underReview, resolved, dismissed] = await Promise.all([ + ctx.prisma.pcListingReport.count({ where: { status: ReportStatus.PENDING } }), + ctx.prisma.pcListingReport.count({ where: { status: ReportStatus.UNDER_REVIEW } }), + ctx.prisma.pcListingReport.count({ where: { status: ReportStatus.RESOLVED } }), + ctx.prisma.pcListingReport.count({ where: { status: ReportStatus.DISMISSED } }), + ]) + + return { + total: pending + underReview + resolved + dismissed, + pending, + underReview, + resolved, + dismissed, + } + }), + + get: permissionProcedure(PERMISSIONS.VIEW_USER_BANS) + .input(GetPcListingReportsSchema) + .query(async ({ ctx, input }) => { + const { + search, + status, + reason, + sortField = 'createdAt', + sortDirection = 'desc', + } = input ?? {} + + const { page, limit } = validatePagination(input?.page, input?.limit, 50) + const sanitizedSearch = search ? sanitizeInput(search) : undefined + const offset = (page - 1) * limit + + const where: Prisma.PcListingReportWhereInput = {} + + if (sanitizedSearch) { + where.OR = [ + { pcListing: { game: { title: { contains: sanitizedSearch, mode: 'insensitive' } } } }, + { reportedBy: { name: { contains: sanitizedSearch, mode: 'insensitive' } } }, + { description: { contains: sanitizedSearch, mode: 'insensitive' } }, + ] + } + + if (status) where.status = status + if (reason) where.reason = reason + + const orderBy: Prisma.PcListingReportOrderByWithRelationInput = {} + if (sortField && sortDirection) orderBy[sortField] = sortDirection + + const [reports, total] = await Promise.all([ + ctx.prisma.pcListingReport.findMany({ + where, + orderBy, + skip: offset, + take: limit, + include: { + pcListing: { + include: { + game: { select: { id: true, title: true } }, + author: { select: { id: true, name: true } }, + cpu: true, + gpu: true, + emulator: { select: { id: true, name: true } }, + }, + }, + reportedBy: { select: { id: true, name: true, email: true } }, + reviewedBy: { select: { id: true, name: true } }, + }, + }), + ctx.prisma.pcListingReport.count({ where }), + ]) + + return { + reports, + pagination: paginate({ total: total, page, limit: limit }), + } + }), + + byId: permissionProcedure(PERMISSIONS.VIEW_USER_BANS) + .input(GetReportByIdSchema) + .query(async ({ ctx, input }) => { + const report = await ctx.prisma.pcListingReport.findUnique({ + where: { id: input.id }, + include: { + pcListing: { + include: { + game: true, + author: { select: { id: true, name: true, email: true } }, + cpu: true, + gpu: true, + emulator: true, + performance: true, + }, + }, + reportedBy: { select: { id: true, name: true, email: true } }, + reviewedBy: { select: { id: true, name: true } }, + }, + }) + + return report || ResourceError.listingReport.notFound() + }), + + create: protectedProcedure.input(CreatePcListingReportSchema).mutation(async ({ ctx, input }) => { + const { pcListingId, reason, description } = input + const userId = ctx.session.user.id + + validateEnum(reason, Object.values(ReportReason), 'reason') + + const reportSubmissionService = new ReportSubmissionService(ctx.prisma) + + return await reportSubmissionService.createPcListingReport({ + pcListingId, + reportedById: userId, + reason, + description, + }) + }), + + updateStatus: permissionProcedure(PERMISSIONS.MANAGE_USER_BANS) + .input(UpdatePcListingReportSchema) + .mutation(async ({ ctx, input }) => { + const { reportId, status, reviewNotes } = input + const reviewerId = ctx.session.user.id + + validateEnum(status, Object.values(ReportStatus), 'status') + + const report = await ctx.prisma.pcListingReport.findUnique({ + where: { id: reportId }, + include: { pcListing: true }, + }) + + if (!report) { + return ResourceError.listingReport.notFound() + } + + if ( + status === ReportStatus.RESOLVED && + report.pcListing?.status === ApprovalStatus.APPROVED + ) { + await ctx.prisma.pcListing.update({ + where: { id: report.pcListingId }, + data: { + status: ApprovalStatus.REJECTED, + processedAt: new Date(), + processedByUserId: reviewerId, + processedNotes: `Rejected due to report: ${reviewNotes || 'No additional notes'}`, + }, + }) + } + + const trustService = new TrustService(ctx.prisma) + + if (status === ReportStatus.RESOLVED) { + await trustService.logAction({ + userId: report.reportedById, + action: TrustAction.REPORT_CONFIRMED, + metadata: { + reportId, + pcListingId: report.pcListingId, + reviewedBy: reviewerId, + reason: report.reason, + }, + }) + } else if (status === ReportStatus.DISMISSED) { + await trustService.logAction({ + userId: report.reportedById, + action: TrustAction.FALSE_REPORT, + metadata: { + reportId, + pcListingId: report.pcListingId, + reviewedBy: reviewerId, + reason: report.reason, + reviewNotes, + }, + }) + } + + return ctx.prisma.pcListingReport.update({ + where: { id: reportId }, + data: { + status, + reviewNotes, + reviewedById: reviewerId, + reviewedAt: new Date(), + }, + include: { + pcListing: { + include: { + game: { select: { title: true } }, + author: { select: { name: true } }, + }, + }, + reportedBy: { select: { name: true } }, + reviewedBy: { select: { name: true } }, + }, + }) + }), + + delete: permissionProcedure(PERMISSIONS.MANAGE_USER_BANS) + .input(DeleteReportSchema) + .mutation(async ({ ctx, input }) => { + const report = await ctx.prisma.pcListingReport.findUnique({ + where: { id: input.id }, + }) + + if (!report) return ResourceError.listingReport.notFound() + + return ctx.prisma.pcListingReport.delete({ + where: { id: input.id }, + }) + }), +}) diff --git a/src/server/api/routers/pcListings.test.ts b/src/server/api/routers/pcListings.test.ts index 3144e2e6b..355a86f71 100644 --- a/src/server/api/routers/pcListings.test.ts +++ b/src/server/api/routers/pcListings.test.ts @@ -581,48 +581,6 @@ describe('pcListings trust integration', () => { }) }) - describe('createReport', () => { - it('creates a PC report and emits a moderator notification event', async () => { - const { caller, prisma } = createCaller() - prisma.pcListing.findUnique.mockResolvedValue({ - id: LISTING_ID, - authorId: AUTHOR_ID, - author: { id: AUTHOR_ID }, - }) - - const report = await caller.createReport({ - pcListingId: LISTING_ID, - reason: ReportReason.SPAM, - description: ' needs review ', - }) - - expect(report.id).toBe('00000000-0000-4000-a000-000000000030') - expect(prisma.pcListingReport.create).toHaveBeenCalledWith( - expect.objectContaining({ - data: expect.objectContaining({ - pcListingId: LISTING_ID, - reportedById: USER_ID, - description: 'needs review', - }), - }), - ) - expect(mockEmitNotificationEvent).toHaveBeenCalledWith({ - eventType: 'report.created', - entityType: 'pcListingReport', - entityId: '00000000-0000-4000-a000-000000000030', - triggeredBy: USER_ID, - includeTriggeredBy: true, - payload: { - reportId: '00000000-0000-4000-a000-000000000030', - contentId: LISTING_ID, - contentType: 'PC Compatibility Report', - actionUrl: `/pc-listings/${LISTING_ID}`, - pcListingId: LISTING_ID, - }, - }) - }) - }) - describe('byId', () => { it('hides review risk profiles for non-reviewers', async () => { mockRepositoryGetByIdWithDetails.mockResolvedValueOnce({ diff --git a/src/server/api/routers/pcListings.ts b/src/server/api/routers/pcListings.ts index 7e4fb11ac..7cfc19372 100644 --- a/src/server/api/routers/pcListings.ts +++ b/src/server/api/routers/pcListings.ts @@ -1,2043 +1,34 @@ -import analytics from '@/lib/analytics' -import { AppError, ResourceError } from '@/lib/errors' -import { applyTrustAction, TrustService } from '@/lib/trust/service' -import { - ApprovePcListingSchema, - BulkApprovePcListingsSchema, - BulkRejectPcListingsSchema, - CreatePcListingCommentSchema, - CreatePcListingReportSchema, - CreatePcListingSchema, - CreatePcPresetSchema, - ResetPcListingToPendingSchema, - DeletePcListingCommentSchema, - DeletePcListingSchema, - DeletePcPresetSchema, - GetAllPcListingsAdminSchema, - GetPcListingByIdSchema, - GetPcListingCommentsSchema, - GetPcListingForAdminEditSchema, - GetPcListingForUserEditSchema, - GetPcListingReportsSchema, - GetPcListingsSchema, - GetPcListingUserVoteSchema, - GetPcListingVerificationsSchema, - GetPcPresetsSchema, - GetPendingPcListingsSchema, - GetProcessedPcSchema, - OverridePcApprovalStatusSchema, - PinPcListingCommentSchema, - RejectPcListingSchema, - RemovePcListingVerificationSchema, - UnpinPcListingCommentSchema, - UpdatePcListingAdminSchema, - UpdatePcListingCommentSchema, - UpdatePcListingReportSchema, - UpdatePcListingUserSchema, - UpdatePcPresetSchema, - VerifyPcListingAdminSchema, - VotePcListingCommentSchema, - VotePcListingSchema, -} from '@/schemas/pcListing' -import { - createListingProcedure, - createTRPCRouter, - adminProcedure, - moderatorProcedure, - permissionProcedure, - protectedProcedure, - publicProcedure, - superAdminProcedure, - viewStatisticsProcedure, -} from '@/server/api/trpc' -import { buildCommentTree, findCommentWithParent } from '@/server/api/utils/commentTree' -import { - buildPcListingOrderBy, - buildPcListingWhere, - buildProcessedPcListingOrderBy, - pcListingAdminInclude, - pcListingDetailInclude, -} from '@/server/api/utils/pcListingHelpers' -import { canManageCommentPins } from '@/server/api/utils/pinPermissions' -import { getProcessedStatusTrustAction } from '@/server/api/utils/processedStatusTrust' -import { - invalidatePcListingSeo, - invalidatePcListingSeoForUpdate, - invalidatePcListingsSeo, -} from '@/server/cache/invalidation' -import { NOTIFICATION_EVENTS, notificationEventEmitter } from '@/server/notifications/eventEmitter' -import { PcListingsRepository } from '@/server/repositories/pc-listings.repository' -import { UserPcPresetsRepository } from '@/server/repositories/user-pc-presets.repository' -import { logAudit } from '@/server/services/audit.service' -import { ReportSubmissionService } from '@/server/services/report-submission.service' -import { autoRejectRiskyPcReports } from '@/server/services/review-risk-auto-reject.service' -import { - attachReviewRiskProfiles, - attachReviewRiskProfileForViewer, - computeReviewRiskProfiles, - getAutoRejectableReviewRiskPreviewForCandidates, - getRiskOnlyReviewPage, -} from '@/server/services/review-risk.service' -import { listingStatsCache } from '@/server/utils/cache' -import { normalizeCustomFieldValues } from '@/server/utils/custom-field-values' -import { paginate } from '@/server/utils/pagination' -import { isUserBanned } from '@/server/utils/query-builders' -import { validatePagination } from '@/server/utils/security-validation' -import { checkSpamContent } from '@/server/utils/spam-check' -import { updatePcListingVoteCounts } from '@/server/utils/vote-counts' -import { - handleCommentVoteTrustEffects, - handleListingVoteTrustEffects, -} from '@/server/utils/vote-trust-effects' -import { PERMISSIONS, roleIncludesRole } from '@/utils/permission-system' -import { - canDeleteComment, - canEditComment, - hasRolePermission, - isModerator, -} from '@/utils/permissions' -import { ApprovalStatus, AuditAction, AuditEntityType, ReportStatus, Role, TrustAction } from '@orm' -import { Prisma } from '@orm/client' - -function isJsonRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value) -} - -function toPrismaNestedJsonValue(value: unknown): Prisma.InputJsonValue | null { - if (value === null) return null - if (typeof value === 'string') return value - if (typeof value === 'number') return value - if (typeof value === 'boolean') return value - if (Array.isArray(value)) return value.map(toPrismaNestedJsonValue) - if (isJsonRecord(value)) { - const result: Record = {} - for (const [key, entryValue] of Object.entries(value)) { - result[key] = toPrismaNestedJsonValue(entryValue) - } - - return result - } - - return AppError.invalidInput('customFieldValues') -} - -function toPrismaCustomFieldValue(value: unknown): Prisma.InputJsonValue | typeof Prisma.JsonNull { - if (value === undefined) return Prisma.JsonNull - - const normalizedValue = toPrismaNestedJsonValue(value) - if (normalizedValue === null) return Prisma.JsonNull - - return normalizedValue -} +import { createTRPCRouter } from '@/server/api/trpc' +import { adminRouter } from './pcListings/admin' +import { commentsRouter } from './pcListings/comments' +import { coreRouter } from './pcListings/core' export const pcListingsRouter = createTRPCRouter({ - // PC Listing procedures - get: publicProcedure.input(GetPcListingsSchema).query(async ({ ctx, input }) => { - const repository = new PcListingsRepository(ctx.prisma) - const canSeeBannedUsers = ctx.session?.user ? isModerator(ctx.session.user.role) : false - - // Validate and sanitize pagination parameters - const { page, limit } = validatePagination(input.page, input.limit, 50) - - const result = await repository.list({ - ...input, - sortDirection: input.sortDirection ?? undefined, - userId: ctx.session?.user?.id, - userRole: ctx.session?.user?.role, - showNsfw: ctx.session?.user?.showNsfw, - canSeeBannedUsers, - approvalStatus: input.approvalStatus || ApprovalStatus.APPROVED, - page, - limit, - }) - - return { - pcListings: result.pcListings, - pagination: result.pagination, - } - }), - - byId: publicProcedure.input(GetPcListingByIdSchema).query(async ({ ctx, input }) => { - const repository = new PcListingsRepository(ctx.prisma) - const userRole = ctx.session?.user?.role - const canSeeBannedUsers = userRole ? isModerator(userRole) : false - - const pcListing = await repository.getByIdWithDetails( - input.id, - canSeeBannedUsers, - ctx.session?.user?.id, - ) - - if (!pcListing) return ResourceError.pcListing.notFound() - - return await attachReviewRiskProfileForViewer({ - prisma: ctx.prisma, - listing: pcListing, - userRole, - }) - }), - - canEdit: protectedProcedure.input(GetPcListingForUserEditSchema).query(async ({ ctx, input }) => { - const EDIT_TIME_LIMIT_MINUTES = 60 - - const pcListing = await ctx.prisma.pcListing.findUnique({ - where: { id: input.id }, - select: { authorId: true, status: true, processedAt: true }, - }) - - if (!pcListing) { - return { - canEdit: false, - isOwner: false, - reason: 'PC listing not found', - } - } - - // Check ownership - const isOwner = pcListing.authorId === ctx.session.user.id - - // Moderators and higher can always edit any PC listing (but still reflect true ownership) - if (hasRolePermission(ctx.session.user.role, Role.MODERATOR)) { - return { - canEdit: true, - isOwner, - reason: 'Moderator can edit any PC listing', - } - } - - if (!isOwner) { - return { canEdit: false, isOwner: false, reason: 'Not your PC listing' } - } - - // PENDING PC listings can always be edited by the author - if (pcListing.status === ApprovalStatus.PENDING) { - return { - canEdit: true, - isOwner: true, - reason: 'Pending PC listings can always be edited', - isPending: true, - } - } - - // REJECTED PC listings cannot be edited - if (pcListing.status === ApprovalStatus.REJECTED) { - return { - canEdit: false, - isOwner: true, - reason: 'Rejected PC listings cannot be edited. Please create a new listing.', - } - } - - // APPROVED PC listings can be edited for 1 hour after approval - if (pcListing.status === ApprovalStatus.APPROVED) { - if (!pcListing.processedAt) { - return { - canEdit: false, - isOwner: true, - reason: 'No approval time found', - } - } - - const now = new Date() - const timeSinceApproval = now.getTime() - pcListing.processedAt.getTime() - const timeLimit = EDIT_TIME_LIMIT_MINUTES * 60 * 1000 - - const remainingTime = timeLimit - timeSinceApproval - const remainingMinutes = Math.floor(remainingTime / (60 * 1000)) - - if (timeSinceApproval > timeLimit) { - return { - canEdit: false, - isOwner: true, - reason: `Edit time expired (${EDIT_TIME_LIMIT_MINUTES} minutes after approval)`, - timeExpired: true, - } - } - - return { - canEdit: true, - isOwner: true, - remainingMinutes: Math.max(0, remainingMinutes), - remainingTime: Math.max(0, remainingTime), - isApproved: true, - } - } - - return { - canEdit: false, - isOwner: true, - reason: 'Invalid PC listing status', - } - }), - - getForUserEdit: protectedProcedure - .input(GetPcListingForUserEditSchema) - .query(async ({ ctx, input }) => { - const pcListing = await ctx.prisma.pcListing.findUnique({ - where: { id: input.id }, - include: { - ...pcListingDetailInclude, - emulator: { - include: { - customFieldDefinitions: { - orderBy: [{ categoryId: 'asc' }, { categoryOrder: 'asc' }, { displayOrder: 'asc' }], - }, - }, - }, - }, - }) - - if (!pcListing) return ResourceError.pcListing.notFound() - - // Only allow owners or moderators to fetch for editing - if ( - pcListing.authorId !== ctx.session.user.id && - !roleIncludesRole(ctx.session.user.role, Role.MODERATOR) - ) { - return ResourceError.pcListing.canOnlyEditOwn() - } - - return pcListing - }), - - create: createListingProcedure.input(CreatePcListingSchema).mutation(async ({ ctx, input }) => { - const { humanVerificationToken, ...payload } = input - const authorId = ctx.session.user.id - - await checkSpamContent({ - prisma: ctx.prisma, - userId: authorId, - content: payload.notes ?? '', - entityType: 'pcListing', - challengeMode: 'challenge', - humanVerificationToken, - headers: ctx.headers, - }) - - const repository = new PcListingsRepository(ctx.prisma) - const newListing = await repository.create({ - authorId, - userRole: ctx.session.user.role, - gameId: payload.gameId, - cpuId: payload.cpuId, - gpuId: payload.gpuId ?? null, - emulatorId: payload.emulatorId, - performanceId: payload.performanceId, - memorySize: payload.memorySize, - os: payload.os, - osVersion: payload.osVersion, - notes: payload.notes ?? null, - customFieldValues: normalizeCustomFieldValues(payload.customFieldValues), - }) - - await applyTrustAction({ - userId: authorId, - action: TrustAction.LISTING_CREATED, - context: { pcListingId: newListing.id }, - }) - - // Invalidate stats cache when PC listing is created - listingStatsCache.delete('pc-listing-stats') - - if (newListing.status === ApprovalStatus.APPROVED) { - await invalidatePcListingSeo({ - id: newListing.id, - gameId: payload.gameId, - cpuId: payload.cpuId, - gpuId: payload.gpuId ?? null, - }) - } - - return newListing - }), - - delete: protectedProcedure.input(DeletePcListingSchema).mutation(async ({ ctx, input }) => { - const pcListing = await ctx.prisma.pcListing.findUnique({ - where: { id: input.id }, - }) - - if (!pcListing) return ResourceError.pcListing.notFound() - - // Only author can delete their own PC listing - if (pcListing.authorId !== ctx.session.user.id) { - return ResourceError.pcListing.canOnlyDeleteOwn() - } - - const deletedListing = await ctx.prisma.pcListing.delete({ - where: { id: input.id }, - }) - - listingStatsCache.delete('pc-listing-stats') - - if (pcListing.status === ApprovalStatus.APPROVED) { - await invalidatePcListingSeo(pcListing) - } - - return deletedListing - }), - - update: protectedProcedure.input(UpdatePcListingUserSchema).mutation(async ({ ctx, input }) => { - const EDIT_TIME_LIMIT_MINUTES = 60 - - const pcListing = await ctx.prisma.pcListing.findUnique({ - where: { id: input.id }, - select: { - authorId: true, - status: true, - processedAt: true, - gameId: true, - cpuId: true, - gpuId: true, - }, - }) - - if (!pcListing) return ResourceError.pcListing.notFound() - - // Only allow owners or moderators to edit - if ( - pcListing.authorId !== ctx.session.user.id && - !hasRolePermission(ctx.session.user.role, Role.MODERATOR) - ) { - return ResourceError.pcListing.canOnlyEditOwn() - } - - // Check edit permissions based on PC listing status - switch (pcListing.status) { - case ApprovalStatus.REJECTED: - // Moderators can edit rejected listings - if (!hasRolePermission(ctx.session.user.role, Role.MODERATOR)) { - return ResourceError.pcListing.cannotEditRejected() - } - break - - case ApprovalStatus.APPROVED: { - // Moderators can always edit approved listings - if (hasRolePermission(ctx.session.user.role, Role.MODERATOR)) break - - // Regular users have a time limit for editing approved listings - if (!pcListing.processedAt) return ResourceError.pcListing.approvalTimeNotFound() - - const now = new Date() - const timeSinceApproval = now.getTime() - pcListing.processedAt.getTime() - const timeLimit = EDIT_TIME_LIMIT_MINUTES * 60 * 1000 - - if (timeSinceApproval > timeLimit) { - return ResourceError.pcListing.editTimeExpired(EDIT_TIME_LIMIT_MINUTES) - } - break - } - - case ApprovalStatus.PENDING: - // Pending listings can always be edited by their author - break - - default: - return AppError.badRequest('Invalid PC listing status') - } - - // Validate referenced entities exist - const [performance] = await Promise.all([ - ctx.prisma.performanceScale.findUnique({ where: { id: input.performanceId } }), - ]) - - if (!performance) return ResourceError.performanceScale.notFound() - - // Update PC listing and handle custom field values - const { id, customFieldValues, ...updateData } = input - - const updatedPcListing = await ctx.prisma.pcListing.update({ - where: { id }, - data: { ...updateData, updatedAt: new Date() }, - include: { - game: { include: { system: true } }, - cpu: { include: { brand: true } }, - gpu: { include: { brand: true } }, - emulator: true, - performance: true, - author: true, - customFieldValues: { - include: { customFieldDefinition: { include: { category: true } } }, - }, - }, - }) - - // Handle custom field values if provided - if (customFieldValues) { - // Delete existing custom field values - await ctx.prisma.pcListingCustomFieldValue.deleteMany({ where: { pcListingId: id } }) - - // Create new custom field values - if (customFieldValues.length > 0) { - await ctx.prisma.pcListingCustomFieldValue.createMany({ - data: customFieldValues.map((cfv) => ({ - pcListingId: id, - customFieldDefinitionId: cfv.customFieldDefinitionId, - value: toPrismaCustomFieldValue(cfv.value), - })), - }) - } - } - - if (pcListing.status === ApprovalStatus.APPROVED) { - await invalidatePcListingSeoForUpdate( - { - id, - gameId: pcListing.gameId, - cpuId: pcListing.cpuId, - gpuId: pcListing.gpuId, - }, - { - id, - gameId: updatedPcListing.gameId, - cpuId: updatedPcListing.cpuId, - gpuId: updatedPcListing.gpuId, - }, - ) - } - - return updatedPcListing - }), - - // Admin procedures - pending: protectedProcedure.input(GetPendingPcListingsSchema).query(async ({ ctx, input }) => { - // Check if user has permission to view pending listings - const isModerator = hasRolePermission(ctx.session.user.role, Role.MODERATOR) - const isDeveloper = hasRolePermission(ctx.session.user.role, Role.DEVELOPER) - - if (!isModerator && !isDeveloper) { - return ResourceError.pcListing.requiresDeveloperToView() - } - - const repository = new PcListingsRepository(ctx.prisma) - const { - search, - page = 1, - limit = 20, - sortField, - sortDirection = 'asc', - riskFilter = 'all', - } = input ?? {} - const filterRiskyListings = riskFilter === 'risky' - - // For developers, filter by their assigned emulators - let emulatorIds: string[] | undefined - if (!isModerator && isDeveloper) { - emulatorIds = await repository.getVerifiedEmulatorIds(ctx.session.user.id) - - if (emulatorIds.length === 0) { - // Developer has no assigned emulators, return empty results - return { - pcListings: [], - pagination: paginate({ total: 0, page, limit }), - } - } - } - - if (filterRiskyListings) { - const riskPage = await getRiskOnlyReviewPage({ - prisma: ctx.prisma, - page, - limit, - loadCandidates: () => - repository.getPendingListingRiskCandidates({ - emulatorIds, - search, - sortField, - sortDirection: sortDirection ?? 'asc', - }), - loadItemsByIds: (pcListingIds) => - repository.getPendingListingsByIds(pcListingIds, { - emulatorIds, - search, - }), - }) - - return { - pcListings: riskPage.items, - pagination: paginate({ total: riskPage.total, page, limit }), - } - } - - const result = await repository.getPendingListings({ - emulatorIds, - search, - page, - limit, - sortField, - sortDirection: sortDirection ?? 'asc', - }) - - const riskProfiles = await computeReviewRiskProfiles(ctx.prisma, result.pcListings) - const paginatedPcListings = attachReviewRiskProfiles(result.pcListings, riskProfiles) - - return { - pcListings: paginatedPcListings, - pagination: result.pagination, - } - }), - - approve: protectedProcedure.input(ApprovePcListingSchema).mutation(async ({ ctx, input }) => { - // Check if user has permission to approve listings - // Either through MODERATOR role or being a verified developer - const isModerator = hasRolePermission(ctx.session.user.role, Role.MODERATOR) - const isDeveloper = hasRolePermission(ctx.session.user.role, Role.DEVELOPER) - - if (!isModerator && !isDeveloper) { - return ResourceError.pcListing.requiresDeveloperToApprove() - } - - const repository = new PcListingsRepository(ctx.prisma) - const pcListing = await repository.getById(input.pcListingId) - - if (!pcListing) return ResourceError.pcListing.notFound() - - if (pcListing.status !== ApprovalStatus.PENDING) { - return ResourceError.pcListing.notPending() - } - - // For developers, verify they can approve this emulator's listings - if (!isModerator && isDeveloper) { - const isVerified = await repository.isDeveloperVerifiedForEmulator( - ctx.session.user.id, - pcListing.emulatorId, - ) - - if (!isVerified) { - return ResourceError.pcListing.mustBeVerifiedToApprove() - } - } - - const approvedListing = await repository.approve(input.pcListingId, ctx.session.user.id) - - if (pcListing.authorId) { - await applyTrustAction({ - userId: pcListing.authorId, - action: TrustAction.LISTING_APPROVED, - context: { - pcListingId: input.pcListingId, - adminUserId: ctx.session.user.id, - reason: 'listing_approved', - }, - }) - } - - listingStatsCache.delete('pc-listing-stats') - - await invalidatePcListingSeo({ - id: input.pcListingId, - gameId: pcListing.gameId, - cpuId: pcListing.cpuId, - gpuId: pcListing.gpuId, - }) - - notificationEventEmitter.emitNotificationEvent({ - eventType: NOTIFICATION_EVENTS.PC_LISTING_APPROVED, - entityType: 'pcListing', - entityId: input.pcListingId, - triggeredBy: ctx.session.user.id, - payload: { - pcListingId: input.pcListingId, - gameId: pcListing.gameId, - approvedBy: ctx.session.user.id, - approvedAt: approvedListing.processedAt, - }, - }) - - return approvedListing - }), - - reject: protectedProcedure.input(RejectPcListingSchema).mutation(async ({ ctx, input }) => { - // Check if user has permission to reject listings - // Either through MODERATOR role or being a verified developer - const isModerator = hasRolePermission(ctx.session.user.role, Role.MODERATOR) - const isDeveloper = hasRolePermission(ctx.session.user.role, Role.DEVELOPER) - - if (!isModerator && !isDeveloper) { - return ResourceError.pcListing.requiresDeveloperToReject() - } - - const repository = new PcListingsRepository(ctx.prisma) - const pcListing = await repository.getById(input.pcListingId) - - if (!pcListing) return ResourceError.pcListing.notFound() - - if (pcListing.status !== ApprovalStatus.PENDING) { - return ResourceError.pcListing.notPending() - } - - // For developers, verify they can reject this emulator's listings - if (!isModerator && isDeveloper) { - const isVerified = await repository.isDeveloperVerifiedForEmulator( - ctx.session.user.id, - pcListing.emulatorId, - ) - - if (!isVerified) { - return ResourceError.pcListing.mustBeVerifiedToReject() - } - } - - const rejectedListing = await repository.reject( - input.pcListingId, - ctx.session.user.id, - input.notes, - ) - - if (pcListing.authorId) { - await applyTrustAction({ - userId: pcListing.authorId, - action: TrustAction.LISTING_REJECTED, - context: { - pcListingId: input.pcListingId, - adminUserId: ctx.session.user.id, - reason: input.notes || 'listing_rejected', - }, - }) - } - - // Invalidate stats cache when PC listing is rejected - listingStatsCache.delete('pc-listing-stats') - - notificationEventEmitter.emitNotificationEvent({ - eventType: NOTIFICATION_EVENTS.PC_LISTING_REJECTED, - entityType: 'pcListing', - entityId: input.pcListingId, - triggeredBy: ctx.session.user.id, - payload: { - pcListingId: input.pcListingId, - rejectedBy: ctx.session.user.id, - rejectedAt: rejectedListing.processedAt, - rejectionReason: input.notes, - }, - }) - - return rejectedListing - }), - - resetToPending: moderatorProcedure - .input(ResetPcListingToPendingSchema) - .mutation(async ({ ctx, input }) => { - const repository = new PcListingsRepository(ctx.prisma) - const pcListing = await repository.getById(input.pcListingId) - - if (!pcListing) return ResourceError.pcListing.notFound() - - if (pcListing.status === ApprovalStatus.PENDING) { - return ResourceError.pcListing.alreadyPending() - } - - const updatedListing = await ctx.prisma.pcListing.update({ - where: { id: input.pcListingId }, - data: { - status: ApprovalStatus.PENDING, - processedByUserId: null, - processedAt: null, - processedNotes: null, - }, - }) - - listingStatsCache.delete('pc-listing-stats') - - if (pcListing.status === ApprovalStatus.APPROVED) { - await invalidatePcListingSeo({ - id: input.pcListingId, - gameId: pcListing.gameId, - cpuId: pcListing.cpuId, - gpuId: pcListing.gpuId, - }) - } - - return updatedListing - }), - - getProcessed: superAdminProcedure.input(GetProcessedPcSchema).query(async ({ ctx, input }) => { - const { page, limit, filterStatus, search, sortField, sortDirection } = input - const skip = (page - 1) * limit - - const baseWhere: Prisma.PcListingWhereInput = { - NOT: { status: ApprovalStatus.PENDING }, - ...(filterStatus ? { status: filterStatus } : {}), - } - - const searchWhere: Prisma.PcListingWhereInput = search - ? { - OR: [ - { game: { title: { contains: search, mode: 'insensitive' } } }, - { game: { system: { name: { contains: search, mode: 'insensitive' } } } }, - { cpu: { modelName: { contains: search, mode: 'insensitive' } } }, - { cpu: { brand: { name: { contains: search, mode: 'insensitive' } } } }, - { gpu: { modelName: { contains: search, mode: 'insensitive' } } }, - { gpu: { brand: { name: { contains: search, mode: 'insensitive' } } } }, - { emulator: { name: { contains: search, mode: 'insensitive' } } }, - { author: { name: { contains: search, mode: 'insensitive' } } }, - { processedNotes: { contains: search, mode: 'insensitive' } }, - { notes: { contains: search, mode: 'insensitive' } }, - ], - } - : {} - - const where = buildPcListingWhere({ ...baseWhere, ...searchWhere }, true) - const orderBy = buildProcessedPcListingOrderBy(sortField, sortDirection) - - const [pcListings, total] = await Promise.all([ - ctx.prisma.pcListing.findMany({ - where, - include: pcListingAdminInclude, - orderBy, - skip, - take: limit, - }), - ctx.prisma.pcListing.count({ where }), - ]) - - return { - pcListings, - pagination: paginate({ total, page, limit }), - } - }), - - overrideStatus: superAdminProcedure - .input(OverridePcApprovalStatusSchema) - .mutation(async ({ ctx, input }) => { - const { pcListingId, newStatus, overrideNotes } = input - const superAdminUserId = ctx.session.user.id - - const pcListing = await ctx.prisma.pcListing.findUnique({ - where: { id: pcListingId }, - select: { - id: true, - status: true, - gameId: true, - cpuId: true, - gpuId: true, - authorId: true, - processedNotes: true, - }, - }) - - if (!pcListing) return ResourceError.pcListing.notFound() - - const updatedPcListing = await ctx.prisma.pcListing.update({ - where: { id: pcListingId }, - data: - newStatus === ApprovalStatus.PENDING - ? { - status: newStatus, - processedByUserId: null, - processedAt: null, - processedNotes: null, - } - : { - status: newStatus, - processedByUserId: superAdminUserId, - processedAt: new Date(), - processedNotes: overrideNotes ?? pcListing.processedNotes, - }, - }) - - listingStatsCache.delete('pc-listing-stats') - - if (pcListing.status === ApprovalStatus.APPROVED || newStatus === ApprovalStatus.APPROVED) { - await invalidatePcListingSeo({ - id: pcListingId, - gameId: pcListing.gameId, - cpuId: pcListing.cpuId, - gpuId: pcListing.gpuId, - }) - } - - const trustAction = getProcessedStatusTrustAction({ - previousStatus: pcListing.status, - newStatus, - authorId: pcListing.authorId, - }) - if (trustAction) { - await applyTrustAction({ - userId: trustAction.userId, - action: trustAction.action, - context: { - pcListingId, - adminUserId: superAdminUserId, - reason: overrideNotes || 'pc_listing_status_override', - }, - }) - } - - if (newStatus === ApprovalStatus.APPROVED || newStatus === ApprovalStatus.REJECTED) { - notificationEventEmitter.emitNotificationEvent({ - eventType: - newStatus === ApprovalStatus.APPROVED - ? NOTIFICATION_EVENTS.PC_LISTING_APPROVED - : NOTIFICATION_EVENTS.PC_LISTING_REJECTED, - entityType: 'pcListing', - entityId: pcListingId, - triggeredBy: superAdminUserId, - payload: - newStatus === ApprovalStatus.APPROVED - ? { - pcListingId, - gameId: pcListing.gameId, - approvedBy: superAdminUserId, - approvedAt: updatedPcListing.processedAt, - } - : { - pcListingId, - rejectedBy: superAdminUserId, - rejectedAt: updatedPcListing.processedAt, - rejectionReason: overrideNotes, - }, - }) - } - - return updatedPcListing - }), - - bulkApprove: protectedProcedure - .input(BulkApprovePcListingsSchema) - .mutation(async ({ ctx, input }) => { - // Check if user has permission to approve listings - // Either through MODERATOR role or being a verified developer - const isModerator = hasRolePermission(ctx.session.user.role, Role.MODERATOR) - const isDeveloper = hasRolePermission(ctx.session.user.role, Role.DEVELOPER) - - if (!isModerator && !isDeveloper) { - return ResourceError.pcListing.requiresDeveloperToApprove() - } - - const pendingListings = await ctx.prisma.pcListing.findMany({ - where: { id: { in: input.pcListingIds }, status: ApprovalStatus.PENDING }, - select: { id: true, gameId: true, cpuId: true, gpuId: true, authorId: true }, - }) - const approvedAt = new Date() - - const result = await ctx.prisma.pcListing.updateMany({ - where: { id: { in: pendingListings.map((l) => l.id) } }, - data: { - status: ApprovalStatus.APPROVED, - processedAt: approvedAt, - processedByUserId: ctx.session.user.id, - }, - }) - - const listingsWithAuthor = pendingListings.filter( - (l): l is typeof l & { authorId: string } => l.authorId !== null, - ) - await Promise.all( - listingsWithAuthor.map((listing) => - applyTrustAction({ - userId: listing.authorId, - action: TrustAction.LISTING_APPROVED, - context: { - pcListingId: listing.id, - adminUserId: ctx.session.user.id, - reason: 'bulk_listing_approved', - }, - }), - ), - ) - - listingStatsCache.delete('pc-listing-stats') - - await invalidatePcListingsSeo(pendingListings) - - for (const listing of pendingListings) { - notificationEventEmitter.emitNotificationEvent({ - eventType: NOTIFICATION_EVENTS.PC_LISTING_APPROVED, - entityType: 'pcListing', - entityId: listing.id, - triggeredBy: ctx.session.user.id, - payload: { - pcListingId: listing.id, - gameId: listing.gameId, - approvedBy: ctx.session.user.id, - approvedAt, - bulk: true, - }, - }) - } - - return { count: result.count } - }), - - bulkReject: protectedProcedure - .input(BulkRejectPcListingsSchema) - .mutation(async ({ ctx, input }) => { - // Check if user has permission to reject listings - // Either through MODERATOR role or being a verified developer - const isModerator = hasRolePermission(ctx.session.user.role, Role.MODERATOR) - const isDeveloper = hasRolePermission(ctx.session.user.role, Role.DEVELOPER) - - if (!isModerator && !isDeveloper) { - return ResourceError.pcListing.requiresDeveloperToReject() - } - - const pendingListings = await ctx.prisma.pcListing.findMany({ - where: { id: { in: input.pcListingIds }, status: ApprovalStatus.PENDING }, - select: { id: true, authorId: true }, - }) - - const result = await ctx.prisma.pcListing.updateMany({ - where: { - id: { in: pendingListings.map((l) => l.id) }, - }, - data: { - status: ApprovalStatus.REJECTED, - processedAt: new Date(), - processedByUserId: ctx.session.user.id, - processedNotes: input.notes, - }, - }) - - // Apply trust actions in parallel — distinct user adjustments, independent. - const listingsWithAuthor = pendingListings.filter( - (l): l is typeof l & { authorId: string } => l.authorId !== null, - ) - await Promise.all( - listingsWithAuthor.map((listing) => - applyTrustAction({ - userId: listing.authorId, - action: TrustAction.LISTING_REJECTED, - context: { - pcListingId: listing.id, - adminUserId: ctx.session.user.id, - reason: input.notes || 'bulk_listing_rejected', - }, - }), - ), - ) - - // Invalidate stats cache when PC listings are bulk rejected - listingStatsCache.delete('pc-listing-stats') - - for (const listing of pendingListings) { - notificationEventEmitter.emitNotificationEvent({ - eventType: NOTIFICATION_EVENTS.PC_LISTING_REJECTED, - entityType: 'pcListing', - entityId: listing.id, - triggeredBy: ctx.session.user.id, - payload: { - pcListingId: listing.id, - rejectedBy: ctx.session.user.id, - rejectedAt: new Date(), - rejectionReason: input.notes, - }, - }) - } - - return { count: result.count } - }), - - autoRejectRiskyPreview: adminProcedure.query(async ({ ctx }) => { - const repository = new PcListingsRepository(ctx.prisma) - - return getAutoRejectableReviewRiskPreviewForCandidates({ - prisma: ctx.prisma, - loadCandidates: () => repository.getPendingListingRiskCandidates({}), - }) - }), - - autoRejectRisky: adminProcedure.mutation(async ({ ctx }) => { - const adminUserId = ctx.session.user.id - - const adminUserExists = await ctx.prisma.user.findUnique({ - where: { id: adminUserId }, - select: { id: true }, - }) - if (!adminUserExists) return ResourceError.user.notInDatabase(adminUserId) - - return autoRejectRiskyPcReports({ - prisma: ctx.prisma, - adminUserId, - }) - }), - - getAll: permissionProcedure(PERMISSIONS.APPROVE_LISTINGS) - .input(GetAllPcListingsAdminSchema) - .query(async ({ ctx, input }) => { - const { - page = 1, - limit = 20, - sortField, - sortDirection, - search, - statusFilter, - systemFilter, - emulatorFilter, - osFilter, - } = input - - const offset = (page - 1) * limit - - const baseWhere: Prisma.PcListingWhereInput = { - ...(statusFilter ? { status: statusFilter } : {}), - ...(systemFilter ? { game: { systemId: systemFilter } } : {}), - ...(emulatorFilter ? { emulatorId: emulatorFilter } : {}), - ...(osFilter ? { os: osFilter } : {}), - ...(search - ? { - OR: [ - { game: { title: { contains: search, mode: 'insensitive' } } }, - { - cpu: { modelName: { contains: search, mode: 'insensitive' } }, - }, - { - gpu: { modelName: { contains: search, mode: 'insensitive' } }, - }, - { - emulator: { name: { contains: search, mode: 'insensitive' } }, - }, - { author: { name: { contains: search, mode: 'insensitive' } } }, - ], - } - : {}), - } - - // Moderators can see listings from banned users - const where = buildPcListingWhere(baseWhere, true) - const orderBy = buildPcListingOrderBy(sortField, sortDirection ?? undefined) - - const [pcListings, total] = await Promise.all([ - ctx.prisma.pcListing.findMany({ - where, - include: pcListingAdminInclude, - orderBy, - skip: offset, - take: limit, - }), - ctx.prisma.pcListing.count({ where }), - ]) - - return { - pcListings, - pagination: paginate({ total: total, page, limit: limit }), - } - }), - - getForEdit: permissionProcedure(PERMISSIONS.APPROVE_LISTINGS) - .input(GetPcListingForAdminEditSchema) - .query(async ({ ctx, input }) => { - const pcListing = await ctx.prisma.pcListing.findUnique({ - where: { id: input.id }, - include: pcListingDetailInclude, - }) - - return pcListing ?? ResourceError.pcListing.notFound() - }), - - updateAdmin: permissionProcedure(PERMISSIONS.APPROVE_LISTINGS) - .input(UpdatePcListingAdminSchema) - .mutation(async ({ ctx, input }) => { - const { id, customFieldValues, ...data } = input - - const pcListing = await ctx.prisma.pcListing.findUnique({ - where: { id }, - include: { customFieldValues: true }, - }) - - if (!pcListing) return ResourceError.pcListing.notFound() - - const updatedPcListing = await ctx.prisma.pcListing.update({ - where: { id }, - data: { ...data, updatedAt: new Date() }, - include: pcListingDetailInclude, - }) - - if (customFieldValues) { - await ctx.prisma.pcListingCustomFieldValue.deleteMany({ - where: { pcListingId: id }, - }) - - if (customFieldValues.length > 0) { - await ctx.prisma.pcListingCustomFieldValue.createMany({ - data: customFieldValues.map((cfv) => ({ - pcListingId: id, - customFieldDefinitionId: cfv.customFieldDefinitionId, - value: toPrismaCustomFieldValue(cfv.value), - })), - }) - } - } - - const previousSeoTarget = { - id, - gameId: pcListing.gameId, - cpuId: pcListing.cpuId, - gpuId: pcListing.gpuId, - } - const nextSeoTarget = { - id, - gameId: updatedPcListing.gameId, - cpuId: updatedPcListing.cpuId, - gpuId: updatedPcListing.gpuId, - } - const wasApproved = pcListing.status === ApprovalStatus.APPROVED - const isApproved = updatedPcListing.status === ApprovalStatus.APPROVED - - if (wasApproved && isApproved) { - await invalidatePcListingSeoForUpdate(previousSeoTarget, nextSeoTarget) - } else if (wasApproved) { - await invalidatePcListingSeo(previousSeoTarget) - } else if (isApproved) { - await invalidatePcListingSeo(nextSeoTarget) - } - - return updatedPcListing - }), - - stats: viewStatisticsProcedure.query(async ({ ctx }) => { - const STATS_CACHE_KEY = 'pc-listing-stats' - const cached = listingStatsCache.get(STATS_CACHE_KEY) - if (cached) return cached - - const repository = new PcListingsRepository(ctx.prisma) - const stats = await repository.stats() - - listingStatsCache.set(STATS_CACHE_KEY, stats) - return stats - }), - - // PC Preset procedures - presets: { - get: protectedProcedure.input(GetPcPresetsSchema).query(async ({ ctx, input }) => { - const repository = new UserPcPresetsRepository(ctx.prisma) - const userId = input.userId ?? ctx.session.user.id - - return await repository.listByUserId(userId, { - requestingUserId: ctx.session.user.id, - userRole: ctx.session.user.role, - }) - }), - - create: protectedProcedure.input(CreatePcPresetSchema).mutation(async ({ ctx, input }) => { - const repository = new UserPcPresetsRepository(ctx.prisma) - - return await repository.create({ - userId: ctx.session.user.id, - name: input.name, - cpuId: input.cpuId, - gpuId: input.gpuId, - memorySize: input.memorySize, - os: input.os, - osVersion: input.osVersion, - }) - }), - - update: protectedProcedure.input(UpdatePcPresetSchema).mutation(async ({ ctx, input }) => { - const { id, ...data } = input - const repository = new UserPcPresetsRepository(ctx.prisma) - - return await repository.update(id, ctx.session.user.id, data, { - requestingUserRole: ctx.session.user.role, - }) - }), - - delete: protectedProcedure.input(DeletePcPresetSchema).mutation(async ({ ctx, input }) => { - const repository = new UserPcPresetsRepository(ctx.prisma) - await repository.delete(input.id, ctx.session.user.id, { - requestingUserRole: ctx.session.user.role, - }) - return { success: true } - }), - }, - - // Voting endpoints - vote: protectedProcedure.input(VotePcListingSchema).mutation(async ({ ctx, input }) => { - const { pcListingId, value } = input - const userId = ctx.session.user.id - - if (await isUserBanned(ctx.prisma, userId)) { - return AppError.shadowBanned() - } - - const pcListing = await ctx.prisma.pcListing.findUnique({ - where: { id: pcListingId }, - }) - - if (!pcListing) return ResourceError.pcListing.notFound() - - // Fetch existingVote INSIDE the transaction to avoid race conditions between - // concurrent votes on the same (user, pcListing) pair. - const voteResult = await ctx.prisma.$transaction(async (tx) => { - const existingVote = await tx.pcListingVote.findUnique({ - where: { userId_pcListingId: { userId, pcListingId } }, - }) - - let result: { - vote: { userId: string; pcListingId: string; value: boolean } | null - action: 'created' | 'updated' | 'deleted' - previousValue: boolean | null - } - - if (!existingVote) { - const vote = await tx.pcListingVote.create({ - data: { userId, pcListingId, value }, - }) - await updatePcListingVoteCounts(tx, pcListingId, 'create', value) - result = { vote, action: 'created', previousValue: null } - } else if (existingVote.value === value) { - await tx.pcListingVote.delete({ - where: { userId_pcListingId: { userId, pcListingId } }, - }) - await updatePcListingVoteCounts(tx, pcListingId, 'delete', undefined, existingVote.value) - result = { vote: null, action: 'deleted', previousValue: existingVote.value } - } else { - const vote = await tx.pcListingVote.update({ - where: { userId_pcListingId: { userId, pcListingId } }, - data: { value }, - }) - await updatePcListingVoteCounts(tx, pcListingId, 'update', value, existingVote.value) - result = { vote, action: 'updated', previousValue: existingVote.value } - } - - await handleListingVoteTrustEffects({ - tx, - action: result.action, - currentValue: value, - previousValue: result.previousValue, - userId, - listingId: pcListingId, - listingType: 'pc', - authorId: pcListing.authorId, - }) - - return result - }) - - // Only notify the author when a vote was created or updated — toggle-off should not fire. - if (voteResult.action === 'created' || voteResult.action === 'updated') { - if (voteResult.vote) { - notificationEventEmitter.emitNotificationEvent({ - eventType: NOTIFICATION_EVENTS.LISTING_VOTED, - entityType: 'pcListing', - entityId: pcListingId, - triggeredBy: userId, - payload: { - pcListingId, - voteValue: value, - }, - }) - } - } - - const finalVoteValue = voteResult.action === 'deleted' ? null : value - analytics.engagement.vote({ - listingId: pcListingId, - voteValue: finalVoteValue, - previousVote: voteResult.previousValue, - }) - - return voteResult.vote - }), - - getUserVote: protectedProcedure - .input(GetPcListingUserVoteSchema) - .query(async ({ ctx, input }) => { - const repository = new PcListingsRepository(ctx.prisma) - const vote = await repository.getUserVote(ctx.session.user.id, input.pcListingId) - return { vote } - }), - - // Comments endpoints - getComments: publicProcedure.input(GetPcListingCommentsSchema).query(async ({ ctx, input }) => { - const { pcListingId, sortBy = 'newest', limit = 50, offset = 0 } = input - - const pcListing = await ctx.prisma.pcListing.findUnique({ - where: { id: pcListingId }, - select: { - id: true, - emulatorId: true, - pinnedCommentId: true, - pinnedAt: true, - pinnedByUser: { select: { id: true, name: true, profileImage: true, role: true } }, - }, - }) - - if (!pcListing) return ResourceError.pcListing.notFound() - - const allComments = await ctx.prisma.pcListingComment.findMany({ - where: { - pcListingId, - deletedAt: null, - }, - include: { - user: { - select: { id: true, name: true, profileImage: true, role: true }, - }, - }, - }) - - let userCommentVotes: Record = {} - if (ctx.session?.user) { - const votes = await ctx.prisma.pcListingCommentVote.findMany({ - where: { - userId: ctx.session.user.id, - comment: { pcListingId }, - }, - select: { commentId: true, value: true }, - }) - - userCommentVotes = votes.reduce( - (acc, vote) => ({ - ...acc, - [vote.commentId]: vote.value, - }), - {} as Record, - ) - } - - const commentsWithVotes = allComments.map((comment) => ({ - ...comment, - userVote: userCommentVotes[comment.id] ?? null, - })) - - let commentsTree = buildCommentTree(commentsWithVotes, { replySort: 'asc' }) - - commentsTree.sort((a, b) => { - switch (sortBy) { - case 'oldest': - return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() - case 'score': - return (b.score ?? 0) - (a.score ?? 0) - case 'newest': - default: - return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() - } - }) - - let pinnedCommentPayload: { - comment: (typeof commentsTree)[number] - parentId: string | null - isReply: boolean - } | null = null - - if (pcListing.pinnedCommentId) { - const located = findCommentWithParent(commentsTree, pcListing.pinnedCommentId) - - if (located) { - pinnedCommentPayload = { - comment: located.comment, - parentId: located.parent?.id ?? null, - isReply: Boolean(located.parent), - } - - if (!located.parent) { - commentsTree = commentsTree.filter((comment) => comment.id !== located.comment.id) - } - } - } - - const paginatedComments = commentsTree.slice(offset, offset + limit) - - return { - comments: paginatedComments, - pinnedComment: pinnedCommentPayload - ? { - comment: pinnedCommentPayload.comment, - isReply: pinnedCommentPayload.isReply, - parentId: pinnedCommentPayload.parentId, - pinnedBy: pcListing.pinnedByUser, - pinnedAt: pcListing.pinnedAt, - } - : null, - } - }), - - createComment: protectedProcedure - .input(CreatePcListingCommentSchema) - .mutation(async ({ ctx, input }) => { - const { pcListingId, content, parentId, humanVerificationToken } = input - const userId = ctx.session.user.id - - // Check if PC listing exists - const pcListing = await ctx.prisma.pcListing.findUnique({ - where: { id: pcListingId }, - }) - - if (!pcListing) return ResourceError.pcListing.notFound() - - // If parentId is provided, check if parent comment exists - if (parentId) { - const parentComment = await ctx.prisma.pcListingComment.findUnique({ - where: { id: parentId }, - }) - - if (!parentComment) return ResourceError.comment.parentNotFound() - } - - await checkSpamContent({ - prisma: ctx.prisma, - userId, - content, - entityType: 'pcComment', - challengeMode: 'challenge', - humanVerificationToken, - headers: ctx.headers, - }) - - const comment = await ctx.prisma.pcListingComment.create({ - data: { content, userId, pcListingId, parentId }, - include: { - user: { - select: { id: true, name: true, profileImage: true, role: true }, - }, - }, - }) - - notificationEventEmitter.emitNotificationEvent({ - eventType: parentId - ? NOTIFICATION_EVENTS.COMMENT_REPLIED - : NOTIFICATION_EVENTS.LISTING_COMMENTED, - entityType: 'pcListing', - entityId: pcListingId, - triggeredBy: userId, - payload: { - pcListingId, - commentId: comment.id, - parentId, - commentText: content, - }, - }) - - analytics.engagement.comment({ - action: parentId ? 'reply' : 'created', - commentId: comment.id, - listingId: pcListingId, - isReply: !!parentId, - contentLength: content.length, - }) - - return comment - }), - - updateComment: protectedProcedure - .input(UpdatePcListingCommentSchema) - .mutation(async ({ ctx, input }) => { - const comment = await ctx.prisma.pcListingComment.findUnique({ - where: { id: input.commentId }, - include: { user: { select: { id: true } } }, - }) - - if (!comment) return ResourceError.comment.notFound() - if (comment.deletedAt) return ResourceError.comment.cannotEditDeleted() - - const canEdit = canEditComment(ctx.session.user.role, comment.user.id, ctx.session.user.id) - - if (!canEdit) { - return ResourceError.comment.noPermission('edit') - } - - return await ctx.prisma.pcListingComment.update({ - where: { id: input.commentId }, - data: { - content: input.content, - isEdited: true, - updatedAt: new Date(), - }, - include: { - user: { - select: { id: true, name: true, profileImage: true, role: true }, - }, - }, - }) - }), - - deleteComment: protectedProcedure - .input(DeletePcListingCommentSchema) - .mutation(async ({ ctx, input }) => { - const comment = await ctx.prisma.pcListingComment.findUnique({ - where: { id: input.commentId }, - include: { - user: { select: { id: true } }, - pcListing: { - select: { - id: true, - pinnedCommentId: true, - }, - }, - }, - }) - - if (!comment) return ResourceError.comment.notFound() - if (comment.deletedAt) return ResourceError.comment.alreadyDeleted() - - const canDelete = canDeleteComment( - ctx.session.user.role, - comment.user.id, - ctx.session.user.id, - ) - - if (!canDelete) { - return ResourceError.comment.noPermission('delete') - } - - const wasPinned = comment.pcListing?.pinnedCommentId === comment.id - - const updatedComment = await ctx.prisma.pcListingComment.update({ - where: { id: input.commentId }, - data: { deletedAt: new Date() }, - }) - - if (wasPinned && comment.pcListing) { - await ctx.prisma.pcListing.update({ - where: { id: comment.pcListing.id }, - data: { - pinnedCommentId: null, - pinnedByUserId: null, - pinnedAt: null, - }, - }) - - void logAudit(ctx.prisma, { - actorId: ctx.session.user.id, - action: AuditAction.UNPIN, - entityType: AuditEntityType.COMMENT, - entityId: comment.id, - metadata: { - pcListingId: comment.pcListing.id, - reason: 'comment_deleted', - }, - }) - } - - return updatedComment - }), - - voteComment: protectedProcedure - .input(VotePcListingCommentSchema) - .mutation(async ({ ctx, input }) => { - const { commentId, value } = input - const userId = ctx.session.user.id - - // Block banned users from voting (vague error preserves shadow ban) - if (await isUserBanned(ctx.prisma, userId)) { - return AppError.shadowBanned() - } - - const comment = await ctx.prisma.pcListingComment.findUnique({ - where: { id: commentId }, - }) - - if (!comment) { - return ResourceError.comment.notFound() - } - - // Fetch `existingVote` inside the transaction: two concurrent votes - // from the same user could both read null and both attempt to insert, - // producing a Prisma P2002 on the second. Keeping the read and write - // under the same isolation avoids the race. - return await ctx.prisma.$transaction(async (tx) => { - const existingVote = await tx.pcListingCommentVote.findUnique({ - where: { userId_commentId: { userId, commentId } }, - }) - - let voteResult - let scoreChange: number - let trustAction: 'upvote' | 'downvote' | 'change' | 'remove' | null - - if (existingVote) { - if (existingVote.value === value) { - await tx.pcListingCommentVote.delete({ - where: { userId_commentId: { userId, commentId } }, - }) - scoreChange = existingVote.value ? -1 : 1 - voteResult = { message: 'Vote removed' } - trustAction = 'remove' - } else { - voteResult = await tx.pcListingCommentVote.update({ - where: { userId_commentId: { userId, commentId } }, - data: { value }, - }) - scoreChange = value ? 2 : -2 - trustAction = 'change' - } - } else { - voteResult = await tx.pcListingCommentVote.create({ - data: { userId, commentId, value }, - }) - scoreChange = value ? 1 : -1 - trustAction = value ? 'upvote' : 'downvote' - } - - const updatedComment = await tx.pcListingComment.update({ - where: { id: commentId }, - data: { score: { increment: scoreChange } }, - }) - - if (trustAction) { - await handleCommentVoteTrustEffects({ - tx, - trustAction, - newValue: value, - previousValue: existingVote?.value ?? null, - commentAuthorId: comment.userId, - voterId: userId, - commentId, - parentEntityId: comment.pcListingId, - listingType: 'pc', - updatedScore: updatedComment.score, - scoreChange, - }) - } - - // Notify comment author on new votes / direction changes; skip on toggle-off. - if (trustAction !== null && trustAction !== 'remove') { - notificationEventEmitter.emitNotificationEvent({ - eventType: NOTIFICATION_EVENTS.COMMENT_VOTED, - entityType: 'comment', - entityId: comment.id, - triggeredBy: userId, - payload: { - pcListingId: comment.pcListingId, - commentId: comment.id, - voteValue: value, - }, - }) - } - - return voteResult - }) - }), - - pinComment: protectedProcedure - .input(PinPcListingCommentSchema) - .mutation(async ({ ctx, input }) => { - const { commentId, pcListingId, replaceExisting } = input - const userId = ctx.session.user.id - const userRole = ctx.session.user.role - - const comment = await ctx.prisma.pcListingComment.findUnique({ - where: { id: commentId }, - include: { - pcListing: { - select: { - id: true, - emulatorId: true, - pinnedCommentId: true, - pinnedByUserId: true, - }, - }, - }, - }) - - if (!comment) return ResourceError.comment.notFound() - if (comment.deletedAt) return ResourceError.comment.alreadyDeleted() - if (comment.pcListingId !== pcListingId) { - return AppError.badRequest('Comment does not belong to this PC listing') - } - if (!comment.pcListing) return ResourceError.pcListing.notFound() - - const pcListing = comment.pcListing - - const canPin = await canManageCommentPins({ - prisma: ctx.prisma, - userRole, - userId, - emulatorId: pcListing.emulatorId, - }) - - if (!canPin) return ResourceError.comment.noPermission('pin') - - if ( - pcListing.pinnedCommentId && - pcListing.pinnedCommentId !== comment.id && - !replaceExisting - ) { - return ResourceError.comment.alreadyPinned() - } - - const previousPinnedId = pcListing.pinnedCommentId - - const updatedPcListing = await ctx.prisma.pcListing.update({ - where: { id: pcListing.id }, - data: { - pinnedCommentId: comment.id, - pinnedByUserId: userId, - pinnedAt: new Date(), - }, - select: { - id: true, - pinnedCommentId: true, - pinnedAt: true, - }, - }) - - void logAudit(ctx.prisma, { - actorId: userId, - action: AuditAction.PIN, - entityType: AuditEntityType.COMMENT, - entityId: comment.id, - metadata: { - pcListingId: pcListing.id, - previousPinnedCommentId: previousPinnedId, - }, - }) - - return updatedPcListing - }), - - unpinComment: protectedProcedure - .input(UnpinPcListingCommentSchema) - .mutation(async ({ ctx, input }) => { - const { pcListingId } = input - const userId = ctx.session.user.id - const userRole = ctx.session.user.role - - const pcListing = await ctx.prisma.pcListing.findUnique({ - where: { id: pcListingId }, - select: { - id: true, - emulatorId: true, - pinnedCommentId: true, - }, - }) - - if (!pcListing) return ResourceError.pcListing.notFound() - if (!pcListing.pinnedCommentId) return ResourceError.comment.notPinned() - - const canUnpin = await canManageCommentPins({ - prisma: ctx.prisma, - userRole, - userId, - emulatorId: pcListing.emulatorId, - }) - - if (!canUnpin) return ResourceError.comment.noPermission('unpin') - - const previousPinnedId = pcListing.pinnedCommentId - - await ctx.prisma.pcListing.update({ - where: { id: pcListing.id }, - data: { - pinnedCommentId: null, - pinnedByUserId: null, - pinnedAt: null, - }, - }) - - void logAudit(ctx.prisma, { - actorId: userId, - action: AuditAction.UNPIN, - entityType: AuditEntityType.COMMENT, - entityId: previousPinnedId, - metadata: { - pcListingId: pcListing.id, - }, - }) - - return { success: true } - }), - - // Reporting endpoints - createReport: protectedProcedure - .input(CreatePcListingReportSchema) - .mutation(async ({ ctx, input }) => { - const { pcListingId, reason, description } = input - const userId = ctx.session.user.id - const reportSubmissionService = new ReportSubmissionService(ctx.prisma) - - return await reportSubmissionService.createPcListingReport({ - pcListingId, - reportedById: userId, - reason, - description, - }) - }), - - getReports: permissionProcedure(PERMISSIONS.VIEW_USER_BANS) - .input(GetPcListingReportsSchema) - .query(async ({ ctx, input }) => { - const { status, page = 1, limit = 20 } = input - const offset = (page - 1) * limit - - const where: Prisma.PcListingReportWhereInput = {} - if (status) { - where.status = status - } - - const [reports, total] = await Promise.all([ - ctx.prisma.pcListingReport.findMany({ - where, - orderBy: { createdAt: 'desc' }, - skip: offset, - take: limit, - include: { - pcListing: { - include: { - game: { select: { id: true, title: true } }, - author: { select: { id: true, name: true } }, - cpu: true, - gpu: true, - emulator: { select: { id: true, name: true } }, - }, - }, - reportedBy: { select: { id: true, name: true, email: true } }, - reviewedBy: { select: { id: true, name: true } }, - }, - }), - ctx.prisma.pcListingReport.count({ where }), - ]) - - return { - reports, - pagination: paginate({ total: total, page, limit: limit }), - } - }), - - updateReport: permissionProcedure(PERMISSIONS.MANAGE_USER_BANS) - .input(UpdatePcListingReportSchema) - .mutation(async ({ ctx, input }) => { - const { reportId, status, reviewNotes } = input - const reviewerId = ctx.session.user.id - - const report = await ctx.prisma.pcListingReport.findUnique({ - where: { id: reportId }, - include: { pcListing: true }, - }) - - if (!report) { - return ResourceError.listingReport.notFound() - } - - // If resolving the report and marking listing as rejected - if ( - status === ReportStatus.RESOLVED && - report.pcListing?.status === ApprovalStatus.APPROVED - ) { - // Update the listing status to rejected - await ctx.prisma.pcListing.update({ - where: { id: report.pcListingId }, - data: { - status: ApprovalStatus.REJECTED, - processedAt: new Date(), - processedByUserId: reviewerId, - processedNotes: `Rejected due to report: ${reviewNotes || 'No additional notes'}`, - }, - }) - } - - // Award trust points based on report outcome - const trustService = new TrustService(ctx.prisma) - - if (status === ReportStatus.RESOLVED) { - // Report was confirmed - reward the reporter - await trustService.logAction({ - userId: report.reportedById, - action: TrustAction.REPORT_CONFIRMED, - metadata: { - reportId, - pcListingId: report.pcListingId, - reviewedBy: reviewerId, - reason: report.reason, - }, - }) - } else if (status === ReportStatus.DISMISSED) { - // Report was false/malicious - penalize the reporter - await trustService.logAction({ - userId: report.reportedById, - action: TrustAction.FALSE_REPORT, - metadata: { - reportId, - pcListingId: report.pcListingId, - reviewedBy: reviewerId, - reason: report.reason, - reviewNotes, - }, - }) - } - - return await ctx.prisma.pcListingReport.update({ - where: { id: reportId }, - data: { - status, - reviewNotes, - reviewedById: reviewerId, - reviewedAt: new Date(), - }, - include: { - pcListing: { - include: { - game: { select: { title: true } }, - author: { select: { name: true } }, - }, - }, - reportedBy: { select: { name: true } }, - reviewedBy: { select: { name: true } }, - }, - }) - }), - - // Verification endpoints - verify: permissionProcedure(PERMISSIONS.APPROVE_LISTINGS) - .input(VerifyPcListingAdminSchema) - .mutation(async ({ ctx, input }) => { - const { pcListingId, notes } = input - const verifierId = ctx.session.user.id - - // Check if PC listing exists - const pcListing = await ctx.prisma.pcListing.findUnique({ - where: { id: pcListingId }, - }) - - if (!pcListing) { - return ResourceError.pcListing.notFound() - } - - // Check if user already verified this listing - const existingVerification = await ctx.prisma.pcListingDeveloperVerification.findUnique({ - where: { - pcListingId_verifiedBy: { - pcListingId, - verifiedBy: verifierId, - }, - }, - }) - - if (existingVerification) { - return AppError.badRequest('You have already verified this listing') - } - - return await ctx.prisma.pcListingDeveloperVerification.create({ - data: { - pcListingId, - verifiedBy: verifierId, - notes, - }, - include: { - developer: { select: { id: true, name: true } }, - }, - }) - }), - - removeVerification: permissionProcedure(PERMISSIONS.APPROVE_LISTINGS) - .input(RemovePcListingVerificationSchema) - .mutation(async ({ ctx, input }) => { - const verification = await ctx.prisma.pcListingDeveloperVerification.findUnique({ - where: { id: input.verificationId }, - }) - - if (!verification) { - return ResourceError.verification.notFound() - } - - // Only allow the verifier or admin to remove verification - if (verification.verifiedBy !== ctx.session.user.id && !isModerator(ctx.session.user.role)) { - return ResourceError.verification.canOnlyRemoveOwn() - } - - return await ctx.prisma.pcListingDeveloperVerification.delete({ - where: { id: input.verificationId }, - }) - }), - - getVerifications: publicProcedure - .input(GetPcListingVerificationsSchema) - .query(async ({ ctx, input }) => { - return await ctx.prisma.pcListingDeveloperVerification.findMany({ - where: { pcListingId: input.pcListingId }, - include: { - developer: { select: { id: true, name: true } }, - }, - orderBy: { verifiedAt: 'desc' }, - }) - }), + // Core listing operations (CRUD, voting, verification, presets) + ...coreRouter._def.procedures, + + // Admin operations + pending: adminRouter.getPending, + approve: adminRouter.approve, + reject: adminRouter.reject, + resetToPending: adminRouter.resetToPending, + getProcessed: adminRouter.getProcessed, + overrideStatus: adminRouter.overrideStatus, + bulkApprove: adminRouter.bulkApprove, + bulkReject: adminRouter.bulkReject, + autoRejectRiskyPreview: adminRouter.autoRejectRiskyPreview, + autoRejectRisky: adminRouter.autoRejectRisky, + getAll: adminRouter.get, + getForEdit: adminRouter.getForEdit, + updateAdmin: adminRouter.updateListing, + stats: adminRouter.stats, + + // Comment operations + getComments: commentsRouter.get, + createComment: commentsRouter.create, + updateComment: commentsRouter.edit, + deleteComment: commentsRouter.delete, + voteComment: commentsRouter.vote, + pinComment: commentsRouter.pinComment, + unpinComment: commentsRouter.unpinComment, }) diff --git a/src/server/api/routers/pcListings/admin.ts b/src/server/api/routers/pcListings/admin.ts new file mode 100644 index 000000000..a3c4e4a3a --- /dev/null +++ b/src/server/api/routers/pcListings/admin.ts @@ -0,0 +1,732 @@ +import { ResourceError } from '@/lib/errors' +import { applyTrustAction } from '@/lib/trust/service' +import { + ApprovePcListingSchema, + BulkApprovePcListingsSchema, + BulkRejectPcListingsSchema, + GetAllPcListingsAdminSchema, + RejectPcListingSchema, + GetPcListingForAdminEditSchema, + GetPendingPcListingsSchema, + GetProcessedPcSchema, + OverridePcApprovalStatusSchema, + ResetPcListingToPendingSchema, + UpdatePcListingAdminSchema, +} from '@/schemas/pcListing' +import { + adminProcedure, + createTRPCRouter, + moderatorProcedure, + permissionProcedure, + protectedProcedure, + superAdminProcedure, + viewStatisticsProcedure, +} from '@/server/api/trpc' +import { + buildPcListingOrderBy, + buildPcListingWhere, + buildProcessedPcListingOrderBy, + pcListingAdminInclude, + pcListingDetailInclude, +} from '@/server/api/utils/pcListingHelpers' +import { getProcessedStatusTrustAction } from '@/server/api/utils/processedStatusTrust' +import { + invalidatePcListingSeo, + invalidatePcListingSeoForUpdate, + invalidatePcListingsSeo, +} from '@/server/cache/invalidation' +import { NOTIFICATION_EVENTS, notificationEventEmitter } from '@/server/notifications/eventEmitter' +import { PcListingsRepository } from '@/server/repositories/pc-listings.repository' +import { autoRejectRiskyPcReports } from '@/server/services/review-risk-auto-reject.service' +import { + attachReviewRiskProfiles, + computeReviewRiskProfiles, + getAutoRejectableReviewRiskPreviewForCandidates, + getRiskOnlyReviewPage, +} from '@/server/services/review-risk.service' +import { listingStatsCache } from '@/server/utils/cache' +import { paginate } from '@/server/utils/pagination' +import { PERMISSIONS } from '@/utils/permission-system' +import { hasRolePermission } from '@/utils/permissions' +import { ApprovalStatus, Role, TrustAction } from '@orm' +import { type Prisma } from '@orm/client' +import { + invalidatePcListingStatsCache, + PC_LISTING_STATS_CACHE_KEY, + toPrismaCustomFieldValue, +} from './utils' + +export const adminRouter = createTRPCRouter({ + getPending: protectedProcedure.input(GetPendingPcListingsSchema).query(async ({ ctx, input }) => { + const isModerator = hasRolePermission(ctx.session.user.role, Role.MODERATOR) + const isDeveloper = hasRolePermission(ctx.session.user.role, Role.DEVELOPER) + + if (!isModerator && !isDeveloper) { + return ResourceError.pcListing.requiresDeveloperToView() + } + + const repository = new PcListingsRepository(ctx.prisma) + const { + search, + page = 1, + limit = 20, + sortField, + sortDirection = 'asc', + riskFilter = 'all', + } = input ?? {} + const filterRiskyListings = riskFilter === 'risky' + + let emulatorIds: string[] | undefined + if (!isModerator && isDeveloper) { + emulatorIds = await repository.getVerifiedEmulatorIds(ctx.session.user.id) + + if (emulatorIds.length === 0) { + return { + pcListings: [], + pagination: paginate({ total: 0, page, limit }), + } + } + } + + if (filterRiskyListings) { + const riskPage = await getRiskOnlyReviewPage({ + prisma: ctx.prisma, + page, + limit, + loadCandidates: () => + repository.getPendingListingRiskCandidates({ + emulatorIds, + search, + sortField, + sortDirection: sortDirection ?? 'asc', + }), + loadItemsByIds: (pcListingIds) => + repository.getPendingListingsByIds(pcListingIds, { + emulatorIds, + search, + }), + }) + + return { + pcListings: riskPage.items, + pagination: paginate({ total: riskPage.total, page, limit }), + } + } + + const result = await repository.getPendingListings({ + emulatorIds, + search, + page, + limit, + sortField, + sortDirection: sortDirection ?? 'asc', + }) + + const riskProfiles = await computeReviewRiskProfiles(ctx.prisma, result.pcListings) + const paginatedPcListings = attachReviewRiskProfiles(result.pcListings, riskProfiles) + + return { + pcListings: paginatedPcListings, + pagination: result.pagination, + } + }), + + approve: protectedProcedure.input(ApprovePcListingSchema).mutation(async ({ ctx, input }) => { + const isModerator = hasRolePermission(ctx.session.user.role, Role.MODERATOR) + const isDeveloper = hasRolePermission(ctx.session.user.role, Role.DEVELOPER) + + if (!isModerator && !isDeveloper) { + return ResourceError.pcListing.requiresDeveloperToApprove() + } + + const repository = new PcListingsRepository(ctx.prisma) + const pcListing = await repository.getById(input.pcListingId) + + if (!pcListing) return ResourceError.pcListing.notFound() + + if (pcListing.status !== ApprovalStatus.PENDING) { + return ResourceError.pcListing.notPending() + } + + if (!isModerator && isDeveloper) { + const isVerified = await repository.isDeveloperVerifiedForEmulator( + ctx.session.user.id, + pcListing.emulatorId, + ) + + if (!isVerified) { + return ResourceError.pcListing.mustBeVerifiedToApprove() + } + } + + const approvedListing = await repository.approve(input.pcListingId, ctx.session.user.id) + + if (pcListing.authorId) { + await applyTrustAction({ + userId: pcListing.authorId, + action: TrustAction.LISTING_APPROVED, + context: { + pcListingId: input.pcListingId, + adminUserId: ctx.session.user.id, + reason: 'listing_approved', + }, + }) + } + + invalidatePcListingStatsCache() + + await invalidatePcListingSeo({ + id: input.pcListingId, + gameId: pcListing.gameId, + cpuId: pcListing.cpuId, + gpuId: pcListing.gpuId, + }) + + notificationEventEmitter.emitNotificationEvent({ + eventType: NOTIFICATION_EVENTS.PC_LISTING_APPROVED, + entityType: 'pcListing', + entityId: input.pcListingId, + triggeredBy: ctx.session.user.id, + payload: { + pcListingId: input.pcListingId, + gameId: pcListing.gameId, + approvedBy: ctx.session.user.id, + approvedAt: approvedListing.processedAt, + }, + }) + + return approvedListing + }), + + reject: protectedProcedure.input(RejectPcListingSchema).mutation(async ({ ctx, input }) => { + const isModerator = hasRolePermission(ctx.session.user.role, Role.MODERATOR) + const isDeveloper = hasRolePermission(ctx.session.user.role, Role.DEVELOPER) + + if (!isModerator && !isDeveloper) { + return ResourceError.pcListing.requiresDeveloperToReject() + } + + const repository = new PcListingsRepository(ctx.prisma) + const pcListing = await repository.getById(input.pcListingId) + + if (!pcListing) return ResourceError.pcListing.notFound() + + if (pcListing.status !== ApprovalStatus.PENDING) { + return ResourceError.pcListing.notPending() + } + + if (!isModerator && isDeveloper) { + const isVerified = await repository.isDeveloperVerifiedForEmulator( + ctx.session.user.id, + pcListing.emulatorId, + ) + + if (!isVerified) { + return ResourceError.pcListing.mustBeVerifiedToReject() + } + } + + const rejectedListing = await repository.reject( + input.pcListingId, + ctx.session.user.id, + input.notes, + ) + + if (pcListing.authorId) { + await applyTrustAction({ + userId: pcListing.authorId, + action: TrustAction.LISTING_REJECTED, + context: { + pcListingId: input.pcListingId, + adminUserId: ctx.session.user.id, + reason: input.notes || 'listing_rejected', + }, + }) + } + + invalidatePcListingStatsCache() + + notificationEventEmitter.emitNotificationEvent({ + eventType: NOTIFICATION_EVENTS.PC_LISTING_REJECTED, + entityType: 'pcListing', + entityId: input.pcListingId, + triggeredBy: ctx.session.user.id, + payload: { + pcListingId: input.pcListingId, + rejectedBy: ctx.session.user.id, + rejectedAt: rejectedListing.processedAt, + rejectionReason: input.notes, + }, + }) + + return rejectedListing + }), + + resetToPending: moderatorProcedure + .input(ResetPcListingToPendingSchema) + .mutation(async ({ ctx, input }) => { + const repository = new PcListingsRepository(ctx.prisma) + const pcListing = await repository.getById(input.pcListingId) + + if (!pcListing) return ResourceError.pcListing.notFound() + + if (pcListing.status === ApprovalStatus.PENDING) { + return ResourceError.pcListing.alreadyPending() + } + + const updatedListing = await ctx.prisma.pcListing.update({ + where: { id: input.pcListingId }, + data: { + status: ApprovalStatus.PENDING, + processedByUserId: null, + processedAt: null, + processedNotes: null, + }, + }) + + invalidatePcListingStatsCache() + + if (pcListing.status === ApprovalStatus.APPROVED) { + await invalidatePcListingSeo({ + id: input.pcListingId, + gameId: pcListing.gameId, + cpuId: pcListing.cpuId, + gpuId: pcListing.gpuId, + }) + } + + return updatedListing + }), + + getProcessed: superAdminProcedure.input(GetProcessedPcSchema).query(async ({ ctx, input }) => { + const { page, limit, filterStatus, search, sortField, sortDirection } = input + const skip = (page - 1) * limit + + const baseWhere: Prisma.PcListingWhereInput = { + NOT: { status: ApprovalStatus.PENDING }, + ...(filterStatus ? { status: filterStatus } : {}), + } + + const searchWhere: Prisma.PcListingWhereInput = search + ? { + OR: [ + { game: { title: { contains: search, mode: 'insensitive' } } }, + { game: { system: { name: { contains: search, mode: 'insensitive' } } } }, + { cpu: { modelName: { contains: search, mode: 'insensitive' } } }, + { cpu: { brand: { name: { contains: search, mode: 'insensitive' } } } }, + { gpu: { modelName: { contains: search, mode: 'insensitive' } } }, + { gpu: { brand: { name: { contains: search, mode: 'insensitive' } } } }, + { emulator: { name: { contains: search, mode: 'insensitive' } } }, + { author: { name: { contains: search, mode: 'insensitive' } } }, + { processedNotes: { contains: search, mode: 'insensitive' } }, + { notes: { contains: search, mode: 'insensitive' } }, + ], + } + : {} + + const where = buildPcListingWhere({ ...baseWhere, ...searchWhere }, true) + const orderBy = buildProcessedPcListingOrderBy(sortField, sortDirection) + + const [pcListings, total] = await Promise.all([ + ctx.prisma.pcListing.findMany({ + where, + include: pcListingAdminInclude, + orderBy, + skip, + take: limit, + }), + ctx.prisma.pcListing.count({ where }), + ]) + + return { + pcListings, + pagination: paginate({ total, page, limit }), + } + }), + + overrideStatus: superAdminProcedure + .input(OverridePcApprovalStatusSchema) + .mutation(async ({ ctx, input }) => { + const { pcListingId, newStatus, overrideNotes } = input + const superAdminUserId = ctx.session.user.id + + const pcListing = await ctx.prisma.pcListing.findUnique({ + where: { id: pcListingId }, + select: { + id: true, + status: true, + gameId: true, + cpuId: true, + gpuId: true, + authorId: true, + processedNotes: true, + }, + }) + + if (!pcListing) return ResourceError.pcListing.notFound() + + const updatedPcListing = await ctx.prisma.pcListing.update({ + where: { id: pcListingId }, + data: + newStatus === ApprovalStatus.PENDING + ? { + status: newStatus, + processedByUserId: null, + processedAt: null, + processedNotes: null, + } + : { + status: newStatus, + processedByUserId: superAdminUserId, + processedAt: new Date(), + processedNotes: overrideNotes ?? pcListing.processedNotes, + }, + }) + + invalidatePcListingStatsCache() + + if (pcListing.status === ApprovalStatus.APPROVED || newStatus === ApprovalStatus.APPROVED) { + await invalidatePcListingSeo({ + id: pcListingId, + gameId: pcListing.gameId, + cpuId: pcListing.cpuId, + gpuId: pcListing.gpuId, + }) + } + + const trustAction = getProcessedStatusTrustAction({ + previousStatus: pcListing.status, + newStatus, + authorId: pcListing.authorId, + }) + if (trustAction) { + await applyTrustAction({ + userId: trustAction.userId, + action: trustAction.action, + context: { + pcListingId, + adminUserId: superAdminUserId, + reason: overrideNotes || 'pc_listing_status_override', + }, + }) + } + + if (newStatus === ApprovalStatus.APPROVED || newStatus === ApprovalStatus.REJECTED) { + notificationEventEmitter.emitNotificationEvent({ + eventType: + newStatus === ApprovalStatus.APPROVED + ? NOTIFICATION_EVENTS.PC_LISTING_APPROVED + : NOTIFICATION_EVENTS.PC_LISTING_REJECTED, + entityType: 'pcListing', + entityId: pcListingId, + triggeredBy: superAdminUserId, + payload: + newStatus === ApprovalStatus.APPROVED + ? { + pcListingId, + gameId: pcListing.gameId, + approvedBy: superAdminUserId, + approvedAt: updatedPcListing.processedAt, + } + : { + pcListingId, + rejectedBy: superAdminUserId, + rejectedAt: updatedPcListing.processedAt, + rejectionReason: overrideNotes, + }, + }) + } + + return updatedPcListing + }), + + bulkApprove: protectedProcedure + .input(BulkApprovePcListingsSchema) + .mutation(async ({ ctx, input }) => { + const isModerator = hasRolePermission(ctx.session.user.role, Role.MODERATOR) + const isDeveloper = hasRolePermission(ctx.session.user.role, Role.DEVELOPER) + + if (!isModerator && !isDeveloper) { + return ResourceError.pcListing.requiresDeveloperToApprove() + } + + const pendingListings = await ctx.prisma.pcListing.findMany({ + where: { id: { in: input.pcListingIds }, status: ApprovalStatus.PENDING }, + select: { id: true, gameId: true, cpuId: true, gpuId: true, authorId: true }, + }) + const approvedAt = new Date() + + const result = await ctx.prisma.pcListing.updateMany({ + where: { id: { in: pendingListings.map((l) => l.id) } }, + data: { + status: ApprovalStatus.APPROVED, + processedAt: approvedAt, + processedByUserId: ctx.session.user.id, + }, + }) + + const listingsWithAuthor = pendingListings.filter( + (l): l is typeof l & { authorId: string } => l.authorId !== null, + ) + await Promise.all( + listingsWithAuthor.map((listing) => + applyTrustAction({ + userId: listing.authorId, + action: TrustAction.LISTING_APPROVED, + context: { + pcListingId: listing.id, + adminUserId: ctx.session.user.id, + reason: 'bulk_listing_approved', + }, + }), + ), + ) + + invalidatePcListingStatsCache() + + await invalidatePcListingsSeo(pendingListings) + + for (const listing of pendingListings) { + notificationEventEmitter.emitNotificationEvent({ + eventType: NOTIFICATION_EVENTS.PC_LISTING_APPROVED, + entityType: 'pcListing', + entityId: listing.id, + triggeredBy: ctx.session.user.id, + payload: { + pcListingId: listing.id, + gameId: listing.gameId, + approvedBy: ctx.session.user.id, + approvedAt, + bulk: true, + }, + }) + } + + return { count: result.count } + }), + + bulkReject: protectedProcedure + .input(BulkRejectPcListingsSchema) + .mutation(async ({ ctx, input }) => { + const isModerator = hasRolePermission(ctx.session.user.role, Role.MODERATOR) + const isDeveloper = hasRolePermission(ctx.session.user.role, Role.DEVELOPER) + + if (!isModerator && !isDeveloper) { + return ResourceError.pcListing.requiresDeveloperToReject() + } + + const pendingListings = await ctx.prisma.pcListing.findMany({ + where: { id: { in: input.pcListingIds }, status: ApprovalStatus.PENDING }, + select: { id: true, authorId: true }, + }) + + const result = await ctx.prisma.pcListing.updateMany({ + where: { + id: { in: pendingListings.map((l) => l.id) }, + }, + data: { + status: ApprovalStatus.REJECTED, + processedAt: new Date(), + processedByUserId: ctx.session.user.id, + processedNotes: input.notes, + }, + }) + + const listingsWithAuthor = pendingListings.filter( + (l): l is typeof l & { authorId: string } => l.authorId !== null, + ) + await Promise.all( + listingsWithAuthor.map((listing) => + applyTrustAction({ + userId: listing.authorId, + action: TrustAction.LISTING_REJECTED, + context: { + pcListingId: listing.id, + adminUserId: ctx.session.user.id, + reason: input.notes || 'bulk_listing_rejected', + }, + }), + ), + ) + + invalidatePcListingStatsCache() + + for (const listing of pendingListings) { + notificationEventEmitter.emitNotificationEvent({ + eventType: NOTIFICATION_EVENTS.PC_LISTING_REJECTED, + entityType: 'pcListing', + entityId: listing.id, + triggeredBy: ctx.session.user.id, + payload: { + pcListingId: listing.id, + rejectedBy: ctx.session.user.id, + rejectedAt: new Date(), + rejectionReason: input.notes, + }, + }) + } + + return { count: result.count } + }), + + autoRejectRiskyPreview: adminProcedure.query(async ({ ctx }) => { + const repository = new PcListingsRepository(ctx.prisma) + + return getAutoRejectableReviewRiskPreviewForCandidates({ + prisma: ctx.prisma, + loadCandidates: () => repository.getPendingListingRiskCandidates({}), + }) + }), + + autoRejectRisky: adminProcedure.mutation(async ({ ctx }) => { + const adminUserId = ctx.session.user.id + + const adminUserExists = await ctx.prisma.user.findUnique({ + where: { id: adminUserId }, + select: { id: true }, + }) + if (!adminUserExists) return ResourceError.user.notInDatabase(adminUserId) + + return autoRejectRiskyPcReports({ + prisma: ctx.prisma, + adminUserId, + }) + }), + + get: permissionProcedure(PERMISSIONS.APPROVE_LISTINGS) + .input(GetAllPcListingsAdminSchema) + .query(async ({ ctx, input }) => { + const { + page = 1, + limit = 20, + sortField, + sortDirection, + search, + statusFilter, + systemFilter, + emulatorFilter, + osFilter, + } = input + + const offset = (page - 1) * limit + + const baseWhere: Prisma.PcListingWhereInput = { + ...(statusFilter ? { status: statusFilter } : {}), + ...(systemFilter ? { game: { systemId: systemFilter } } : {}), + ...(emulatorFilter ? { emulatorId: emulatorFilter } : {}), + ...(osFilter ? { os: osFilter } : {}), + ...(search + ? { + OR: [ + { game: { title: { contains: search, mode: 'insensitive' } } }, + { cpu: { modelName: { contains: search, mode: 'insensitive' } } }, + { gpu: { modelName: { contains: search, mode: 'insensitive' } } }, + { emulator: { name: { contains: search, mode: 'insensitive' } } }, + { author: { name: { contains: search, mode: 'insensitive' } } }, + ], + } + : {}), + } + + const where = buildPcListingWhere(baseWhere, true) + const orderBy = buildPcListingOrderBy(sortField, sortDirection ?? undefined) + + const [pcListings, total] = await Promise.all([ + ctx.prisma.pcListing.findMany({ + where, + include: pcListingAdminInclude, + orderBy, + skip: offset, + take: limit, + }), + ctx.prisma.pcListing.count({ where }), + ]) + + return { + pcListings, + pagination: paginate({ total: total, page, limit: limit }), + } + }), + + getForEdit: permissionProcedure(PERMISSIONS.APPROVE_LISTINGS) + .input(GetPcListingForAdminEditSchema) + .query(async ({ ctx, input }) => { + const pcListing = await ctx.prisma.pcListing.findUnique({ + where: { id: input.id }, + include: pcListingDetailInclude, + }) + + return pcListing ?? ResourceError.pcListing.notFound() + }), + + updateListing: permissionProcedure(PERMISSIONS.APPROVE_LISTINGS) + .input(UpdatePcListingAdminSchema) + .mutation(async ({ ctx, input }) => { + const { id, customFieldValues, ...data } = input + + const pcListing = await ctx.prisma.pcListing.findUnique({ + where: { id }, + include: { customFieldValues: true }, + }) + + if (!pcListing) return ResourceError.pcListing.notFound() + + const updatedPcListing = await ctx.prisma.pcListing.update({ + where: { id }, + data: { ...data, updatedAt: new Date() }, + include: pcListingDetailInclude, + }) + + if (customFieldValues) { + await ctx.prisma.pcListingCustomFieldValue.deleteMany({ + where: { pcListingId: id }, + }) + + if (customFieldValues.length > 0) { + await ctx.prisma.pcListingCustomFieldValue.createMany({ + data: customFieldValues.map((cfv) => ({ + pcListingId: id, + customFieldDefinitionId: cfv.customFieldDefinitionId, + value: toPrismaCustomFieldValue(cfv.value), + })), + }) + } + } + + const previousSeoTarget = { + id, + gameId: pcListing.gameId, + cpuId: pcListing.cpuId, + gpuId: pcListing.gpuId, + } + const nextSeoTarget = { + id, + gameId: updatedPcListing.gameId, + cpuId: updatedPcListing.cpuId, + gpuId: updatedPcListing.gpuId, + } + const wasApproved = pcListing.status === ApprovalStatus.APPROVED + const isApproved = updatedPcListing.status === ApprovalStatus.APPROVED + + if (wasApproved && isApproved) { + await invalidatePcListingSeoForUpdate(previousSeoTarget, nextSeoTarget) + } else if (wasApproved) { + await invalidatePcListingSeo(previousSeoTarget) + } else if (isApproved) { + await invalidatePcListingSeo(nextSeoTarget) + } + + return updatedPcListing + }), + + stats: viewStatisticsProcedure.query(async ({ ctx }) => { + const cached = listingStatsCache.get(PC_LISTING_STATS_CACHE_KEY) + if (cached) return cached + + const repository = new PcListingsRepository(ctx.prisma) + const stats = await repository.stats() + + listingStatsCache.set(PC_LISTING_STATS_CACHE_KEY, stats) + return stats + }), +}) diff --git a/src/server/api/routers/pcListings/comments.ts b/src/server/api/routers/pcListings/comments.ts new file mode 100644 index 000000000..3dac592b1 --- /dev/null +++ b/src/server/api/routers/pcListings/comments.ts @@ -0,0 +1,500 @@ +import analytics from '@/lib/analytics' +import { AppError, ResourceError } from '@/lib/errors' +import { + CreatePcListingCommentSchema, + DeletePcListingCommentSchema, + GetPcListingCommentsSchema, + PinPcListingCommentSchema, + UnpinPcListingCommentSchema, + UpdatePcListingCommentSchema, + VotePcListingCommentSchema, +} from '@/schemas/pcListing' +import { createTRPCRouter, protectedProcedure, publicProcedure } from '@/server/api/trpc' +import { buildCommentTree, findCommentWithParent } from '@/server/api/utils/commentTree' +import { canManageCommentPins } from '@/server/api/utils/pinPermissions' +import { NOTIFICATION_EVENTS, notificationEventEmitter } from '@/server/notifications/eventEmitter' +import { logAudit } from '@/server/services/audit.service' +import { isUserBanned } from '@/server/utils/query-builders' +import { checkSpamContent } from '@/server/utils/spam-check' +import { handleCommentVoteTrustEffects } from '@/server/utils/vote-trust-effects' +import { canDeleteComment, canEditComment } from '@/utils/permissions' +import { AuditAction, AuditEntityType } from '@orm' + +export const commentsRouter = createTRPCRouter({ + get: publicProcedure.input(GetPcListingCommentsSchema).query(async ({ ctx, input }) => { + const { pcListingId, sortBy = 'newest', limit = 50, offset = 0 } = input + + const pcListing = await ctx.prisma.pcListing.findUnique({ + where: { id: pcListingId }, + select: { + id: true, + emulatorId: true, + pinnedCommentId: true, + pinnedAt: true, + pinnedByUser: { select: { id: true, name: true, profileImage: true, role: true } }, + }, + }) + + if (!pcListing) return ResourceError.pcListing.notFound() + + const allComments = await ctx.prisma.pcListingComment.findMany({ + where: { + pcListingId, + deletedAt: null, + }, + include: { + user: { + select: { id: true, name: true, profileImage: true, role: true }, + }, + }, + }) + + let userCommentVotes: Record = {} + if (ctx.session?.user) { + const votes = await ctx.prisma.pcListingCommentVote.findMany({ + where: { + userId: ctx.session.user.id, + comment: { pcListingId }, + }, + select: { commentId: true, value: true }, + }) + + userCommentVotes = votes.reduce( + (acc, vote) => ({ + ...acc, + [vote.commentId]: vote.value, + }), + {} as Record, + ) + } + + const commentsWithVotes = allComments.map((comment) => ({ + ...comment, + userVote: userCommentVotes[comment.id] ?? null, + })) + + let commentsTree = buildCommentTree(commentsWithVotes, { replySort: 'asc' }) + + commentsTree.sort((a, b) => { + switch (sortBy) { + case 'oldest': + return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() + case 'score': + return (b.score ?? 0) - (a.score ?? 0) + case 'newest': + default: + return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() + } + }) + + let pinnedCommentPayload: { + comment: (typeof commentsTree)[number] + parentId: string | null + isReply: boolean + } | null = null + + if (pcListing.pinnedCommentId) { + const located = findCommentWithParent(commentsTree, pcListing.pinnedCommentId) + + if (located) { + pinnedCommentPayload = { + comment: located.comment, + parentId: located.parent?.id ?? null, + isReply: Boolean(located.parent), + } + + if (!located.parent) { + commentsTree = commentsTree.filter((comment) => comment.id !== located.comment.id) + } + } + } + + const paginatedComments = commentsTree.slice(offset, offset + limit) + + return { + comments: paginatedComments, + pinnedComment: pinnedCommentPayload + ? { + comment: pinnedCommentPayload.comment, + isReply: pinnedCommentPayload.isReply, + parentId: pinnedCommentPayload.parentId, + pinnedBy: pcListing.pinnedByUser, + pinnedAt: pcListing.pinnedAt, + } + : null, + } + }), + + create: protectedProcedure + .input(CreatePcListingCommentSchema) + .mutation(async ({ ctx, input }) => { + const { pcListingId, content, parentId, humanVerificationToken } = input + const userId = ctx.session.user.id + + const pcListing = await ctx.prisma.pcListing.findUnique({ + where: { id: pcListingId }, + }) + + if (!pcListing) return ResourceError.pcListing.notFound() + + if (parentId) { + const parentComment = await ctx.prisma.pcListingComment.findUnique({ + where: { id: parentId }, + }) + + if (!parentComment) return ResourceError.comment.parentNotFound() + } + + await checkSpamContent({ + prisma: ctx.prisma, + userId, + content, + entityType: 'pcComment', + challengeMode: 'challenge', + humanVerificationToken, + headers: ctx.headers, + }) + + const comment = await ctx.prisma.pcListingComment.create({ + data: { content, userId, pcListingId, parentId }, + include: { + user: { + select: { id: true, name: true, profileImage: true, role: true }, + }, + }, + }) + + notificationEventEmitter.emitNotificationEvent({ + eventType: parentId + ? NOTIFICATION_EVENTS.COMMENT_REPLIED + : NOTIFICATION_EVENTS.LISTING_COMMENTED, + entityType: 'pcListing', + entityId: pcListingId, + triggeredBy: userId, + payload: { + pcListingId, + commentId: comment.id, + parentId, + commentText: content, + }, + }) + + analytics.engagement.comment({ + action: parentId ? 'reply' : 'created', + commentId: comment.id, + listingId: pcListingId, + isReply: !!parentId, + contentLength: content.length, + }) + + return comment + }), + + edit: protectedProcedure.input(UpdatePcListingCommentSchema).mutation(async ({ ctx, input }) => { + const comment = await ctx.prisma.pcListingComment.findUnique({ + where: { id: input.commentId }, + include: { user: { select: { id: true } } }, + }) + + if (!comment) return ResourceError.comment.notFound() + if (comment.deletedAt) return ResourceError.comment.cannotEditDeleted() + + const canEdit = canEditComment(ctx.session.user.role, comment.user.id, ctx.session.user.id) + + if (!canEdit) { + return ResourceError.comment.noPermission('edit') + } + + return ctx.prisma.pcListingComment.update({ + where: { id: input.commentId }, + data: { + content: input.content, + isEdited: true, + updatedAt: new Date(), + }, + include: { + user: { + select: { id: true, name: true, profileImage: true, role: true }, + }, + }, + }) + }), + + delete: protectedProcedure + .input(DeletePcListingCommentSchema) + .mutation(async ({ ctx, input }) => { + const comment = await ctx.prisma.pcListingComment.findUnique({ + where: { id: input.commentId }, + include: { + user: { select: { id: true } }, + pcListing: { + select: { + id: true, + pinnedCommentId: true, + }, + }, + }, + }) + + if (!comment) return ResourceError.comment.notFound() + if (comment.deletedAt) return ResourceError.comment.alreadyDeleted() + + const canDelete = canDeleteComment( + ctx.session.user.role, + comment.user.id, + ctx.session.user.id, + ) + + if (!canDelete) { + return ResourceError.comment.noPermission('delete') + } + + const wasPinned = comment.pcListing?.pinnedCommentId === comment.id + + const updatedComment = await ctx.prisma.pcListingComment.update({ + where: { id: input.commentId }, + data: { deletedAt: new Date() }, + }) + + if (wasPinned && comment.pcListing) { + await ctx.prisma.pcListing.update({ + where: { id: comment.pcListing.id }, + data: { + pinnedCommentId: null, + pinnedByUserId: null, + pinnedAt: null, + }, + }) + + void logAudit(ctx.prisma, { + actorId: ctx.session.user.id, + action: AuditAction.UNPIN, + entityType: AuditEntityType.COMMENT, + entityId: comment.id, + metadata: { + pcListingId: comment.pcListing.id, + reason: 'comment_deleted', + }, + }) + } + + return updatedComment + }), + + vote: protectedProcedure.input(VotePcListingCommentSchema).mutation(async ({ ctx, input }) => { + const { commentId, value } = input + const userId = ctx.session.user.id + + if (await isUserBanned(ctx.prisma, userId)) { + return AppError.shadowBanned() + } + + const comment = await ctx.prisma.pcListingComment.findUnique({ + where: { id: commentId }, + }) + + if (!comment) { + return ResourceError.comment.notFound() + } + + return await ctx.prisma.$transaction(async (tx) => { + const existingVote = await tx.pcListingCommentVote.findUnique({ + where: { userId_commentId: { userId, commentId } }, + }) + + let voteResult + let scoreChange: number + let trustAction: 'upvote' | 'downvote' | 'change' | 'remove' | null + + if (existingVote) { + if (existingVote.value === value) { + await tx.pcListingCommentVote.delete({ + where: { userId_commentId: { userId, commentId } }, + }) + scoreChange = existingVote.value ? -1 : 1 + voteResult = { message: 'Vote removed' } + trustAction = 'remove' + } else { + voteResult = await tx.pcListingCommentVote.update({ + where: { userId_commentId: { userId, commentId } }, + data: { value }, + }) + scoreChange = value ? 2 : -2 + trustAction = 'change' + } + } else { + voteResult = await tx.pcListingCommentVote.create({ + data: { userId, commentId, value }, + }) + scoreChange = value ? 1 : -1 + trustAction = value ? 'upvote' : 'downvote' + } + + const updatedComment = await tx.pcListingComment.update({ + where: { id: commentId }, + data: { score: { increment: scoreChange } }, + }) + + if (trustAction) { + await handleCommentVoteTrustEffects({ + tx, + trustAction, + newValue: value, + previousValue: existingVote?.value ?? null, + commentAuthorId: comment.userId, + voterId: userId, + commentId, + parentEntityId: comment.pcListingId, + listingType: 'pc', + updatedScore: updatedComment.score, + scoreChange, + }) + } + + if (trustAction !== null && trustAction !== 'remove') { + notificationEventEmitter.emitNotificationEvent({ + eventType: NOTIFICATION_EVENTS.COMMENT_VOTED, + entityType: 'comment', + entityId: comment.id, + triggeredBy: userId, + payload: { + pcListingId: comment.pcListingId, + commentId: comment.id, + voteValue: value, + }, + }) + } + + return voteResult + }) + }), + + pinComment: protectedProcedure + .input(PinPcListingCommentSchema) + .mutation(async ({ ctx, input }) => { + const { commentId, pcListingId, replaceExisting } = input + const userId = ctx.session.user.id + const userRole = ctx.session.user.role + + const comment = await ctx.prisma.pcListingComment.findUnique({ + where: { id: commentId }, + include: { + pcListing: { + select: { + id: true, + emulatorId: true, + pinnedCommentId: true, + pinnedByUserId: true, + }, + }, + }, + }) + + if (!comment) return ResourceError.comment.notFound() + if (comment.deletedAt) return ResourceError.comment.alreadyDeleted() + if (comment.pcListingId !== pcListingId) { + return AppError.badRequest('Comment does not belong to this PC listing') + } + if (!comment.pcListing) return ResourceError.pcListing.notFound() + + const pcListing = comment.pcListing + + const canPin = await canManageCommentPins({ + prisma: ctx.prisma, + userRole, + userId, + emulatorId: pcListing.emulatorId, + }) + + if (!canPin) return ResourceError.comment.noPermission('pin') + + if ( + pcListing.pinnedCommentId && + pcListing.pinnedCommentId !== comment.id && + !replaceExisting + ) { + return ResourceError.comment.alreadyPinned() + } + + const previousPinnedId = pcListing.pinnedCommentId + + const updatedPcListing = await ctx.prisma.pcListing.update({ + where: { id: pcListing.id }, + data: { + pinnedCommentId: comment.id, + pinnedByUserId: userId, + pinnedAt: new Date(), + }, + select: { + id: true, + pinnedCommentId: true, + pinnedAt: true, + }, + }) + + void logAudit(ctx.prisma, { + actorId: userId, + action: AuditAction.PIN, + entityType: AuditEntityType.COMMENT, + entityId: comment.id, + metadata: { + pcListingId: pcListing.id, + previousPinnedCommentId: previousPinnedId, + }, + }) + + return updatedPcListing + }), + + unpinComment: protectedProcedure + .input(UnpinPcListingCommentSchema) + .mutation(async ({ ctx, input }) => { + const { pcListingId } = input + const userId = ctx.session.user.id + const userRole = ctx.session.user.role + + const pcListing = await ctx.prisma.pcListing.findUnique({ + where: { id: pcListingId }, + select: { + id: true, + emulatorId: true, + pinnedCommentId: true, + }, + }) + + if (!pcListing) return ResourceError.pcListing.notFound() + if (!pcListing.pinnedCommentId) return ResourceError.comment.notPinned() + + const canUnpin = await canManageCommentPins({ + prisma: ctx.prisma, + userRole, + userId, + emulatorId: pcListing.emulatorId, + }) + + if (!canUnpin) return ResourceError.comment.noPermission('unpin') + + const previousPinnedId = pcListing.pinnedCommentId + + await ctx.prisma.pcListing.update({ + where: { id: pcListing.id }, + data: { + pinnedCommentId: null, + pinnedByUserId: null, + pinnedAt: null, + }, + }) + + void logAudit(ctx.prisma, { + actorId: userId, + action: AuditAction.UNPIN, + entityType: AuditEntityType.COMMENT, + entityId: previousPinnedId, + metadata: { + pcListingId: pcListing.id, + }, + }) + + return { success: true } + }), +}) diff --git a/src/server/api/routers/pcListings/core.ts b/src/server/api/routers/pcListings/core.ts new file mode 100644 index 000000000..66f219b7b --- /dev/null +++ b/src/server/api/routers/pcListings/core.ts @@ -0,0 +1,602 @@ +import analytics from '@/lib/analytics' +import { AppError, ResourceError } from '@/lib/errors' +import { applyTrustAction } from '@/lib/trust/service' +import { + CreatePcListingSchema, + CreatePcPresetSchema, + DeletePcListingSchema, + DeletePcPresetSchema, + GetPcListingByIdSchema, + GetPcListingForUserEditSchema, + GetPcListingUserVoteSchema, + GetPcListingVerificationsSchema, + GetPcListingsSchema, + GetPcPresetsSchema, + RemovePcListingVerificationSchema, + UpdatePcListingUserSchema, + UpdatePcPresetSchema, + VerifyPcListingAdminSchema, + VotePcListingSchema, +} from '@/schemas/pcListing' +import { + createListingProcedure, + createTRPCRouter, + permissionProcedure, + protectedProcedure, + publicProcedure, +} from '@/server/api/trpc' +import { pcListingDetailInclude } from '@/server/api/utils/pcListingHelpers' +import { + invalidatePcListingSeo, + invalidatePcListingSeoForUpdate, +} from '@/server/cache/invalidation' +import { NOTIFICATION_EVENTS, notificationEventEmitter } from '@/server/notifications/eventEmitter' +import { PcListingsRepository } from '@/server/repositories/pc-listings.repository' +import { UserPcPresetsRepository } from '@/server/repositories/user-pc-presets.repository' +import { attachReviewRiskProfileForViewer } from '@/server/services/review-risk.service' +import { normalizeCustomFieldValues } from '@/server/utils/custom-field-values' +import { isUserBanned } from '@/server/utils/query-builders' +import { validatePagination } from '@/server/utils/security-validation' +import { checkSpamContent } from '@/server/utils/spam-check' +import { updatePcListingVoteCounts } from '@/server/utils/vote-counts' +import { handleListingVoteTrustEffects } from '@/server/utils/vote-trust-effects' +import { PERMISSIONS, roleIncludesRole } from '@/utils/permission-system' +import { hasRolePermission, isModerator } from '@/utils/permissions' +import { ApprovalStatus, Role, TrustAction } from '@orm' +import { invalidatePcListingStatsCache, toPrismaCustomFieldValue } from './utils' + +export const coreRouter = createTRPCRouter({ + get: publicProcedure.input(GetPcListingsSchema).query(async ({ ctx, input }) => { + const repository = new PcListingsRepository(ctx.prisma) + const canSeeBannedUsers = ctx.session?.user ? isModerator(ctx.session.user.role) : false + + const { page, limit } = validatePagination(input.page, input.limit, 50) + + const result = await repository.list({ + ...input, + sortDirection: input.sortDirection ?? undefined, + userId: ctx.session?.user?.id, + userRole: ctx.session?.user?.role, + showNsfw: ctx.session?.user?.showNsfw, + canSeeBannedUsers, + approvalStatus: input.approvalStatus || ApprovalStatus.APPROVED, + page, + limit, + }) + + return { + pcListings: result.pcListings, + pagination: result.pagination, + } + }), + + byId: publicProcedure.input(GetPcListingByIdSchema).query(async ({ ctx, input }) => { + const repository = new PcListingsRepository(ctx.prisma) + const userRole = ctx.session?.user?.role + const canSeeBannedUsers = userRole ? isModerator(userRole) : false + + const pcListing = await repository.getByIdWithDetails( + input.id, + canSeeBannedUsers, + ctx.session?.user?.id, + ) + + if (!pcListing) return ResourceError.pcListing.notFound() + + return await attachReviewRiskProfileForViewer({ + prisma: ctx.prisma, + listing: pcListing, + userRole, + }) + }), + + canEdit: protectedProcedure.input(GetPcListingForUserEditSchema).query(async ({ ctx, input }) => { + const EDIT_TIME_LIMIT_MINUTES = 60 + + const pcListing = await ctx.prisma.pcListing.findUnique({ + where: { id: input.id }, + select: { authorId: true, status: true, processedAt: true }, + }) + + if (!pcListing) { + return { + canEdit: false, + isOwner: false, + reason: 'PC listing not found', + } + } + + const isOwner = pcListing.authorId === ctx.session.user.id + + if (hasRolePermission(ctx.session.user.role, Role.MODERATOR)) { + return { + canEdit: true, + isOwner, + reason: 'Moderator can edit any PC listing', + } + } + + if (!isOwner) { + return { canEdit: false, isOwner: false, reason: 'Not your PC listing' } + } + + if (pcListing.status === ApprovalStatus.PENDING) { + return { + canEdit: true, + isOwner: true, + reason: 'Pending PC listings can always be edited', + isPending: true, + } + } + + if (pcListing.status === ApprovalStatus.REJECTED) { + return { + canEdit: false, + isOwner: true, + reason: 'Rejected PC listings cannot be edited. Please create a new listing.', + } + } + + if (pcListing.status === ApprovalStatus.APPROVED) { + if (!pcListing.processedAt) { + return { + canEdit: false, + isOwner: true, + reason: 'No approval time found', + } + } + + const now = new Date() + const timeSinceApproval = now.getTime() - pcListing.processedAt.getTime() + const timeLimit = EDIT_TIME_LIMIT_MINUTES * 60 * 1000 + + const remainingTime = timeLimit - timeSinceApproval + const remainingMinutes = Math.floor(remainingTime / (60 * 1000)) + + if (timeSinceApproval > timeLimit) { + return { + canEdit: false, + isOwner: true, + reason: `Edit time expired (${EDIT_TIME_LIMIT_MINUTES} minutes after approval)`, + timeExpired: true, + } + } + + return { + canEdit: true, + isOwner: true, + remainingMinutes: Math.max(0, remainingMinutes), + remainingTime: Math.max(0, remainingTime), + isApproved: true, + } + } + + return { + canEdit: false, + isOwner: true, + reason: 'Invalid PC listing status', + } + }), + + getForUserEdit: protectedProcedure + .input(GetPcListingForUserEditSchema) + .query(async ({ ctx, input }) => { + const pcListing = await ctx.prisma.pcListing.findUnique({ + where: { id: input.id }, + include: { + ...pcListingDetailInclude, + emulator: { + include: { + customFieldDefinitions: { + orderBy: [{ categoryId: 'asc' }, { categoryOrder: 'asc' }, { displayOrder: 'asc' }], + }, + }, + }, + }, + }) + + if (!pcListing) return ResourceError.pcListing.notFound() + + if ( + pcListing.authorId !== ctx.session.user.id && + !roleIncludesRole(ctx.session.user.role, Role.MODERATOR) + ) { + return ResourceError.pcListing.canOnlyEditOwn() + } + + return pcListing + }), + + create: createListingProcedure.input(CreatePcListingSchema).mutation(async ({ ctx, input }) => { + const { humanVerificationToken, ...payload } = input + const authorId = ctx.session.user.id + + await checkSpamContent({ + prisma: ctx.prisma, + userId: authorId, + content: payload.notes ?? '', + entityType: 'pcListing', + challengeMode: 'challenge', + humanVerificationToken, + headers: ctx.headers, + }) + + const repository = new PcListingsRepository(ctx.prisma) + const newListing = await repository.create({ + authorId, + userRole: ctx.session.user.role, + gameId: payload.gameId, + cpuId: payload.cpuId, + gpuId: payload.gpuId ?? null, + emulatorId: payload.emulatorId, + performanceId: payload.performanceId, + memorySize: payload.memorySize, + os: payload.os, + osVersion: payload.osVersion, + notes: payload.notes ?? null, + customFieldValues: normalizeCustomFieldValues(payload.customFieldValues), + }) + + await applyTrustAction({ + userId: authorId, + action: TrustAction.LISTING_CREATED, + context: { pcListingId: newListing.id }, + }) + + invalidatePcListingStatsCache() + + if (newListing.status === ApprovalStatus.APPROVED) { + await invalidatePcListingSeo({ + id: newListing.id, + gameId: payload.gameId, + cpuId: payload.cpuId, + gpuId: payload.gpuId ?? null, + }) + } + + return newListing + }), + + delete: protectedProcedure.input(DeletePcListingSchema).mutation(async ({ ctx, input }) => { + const pcListing = await ctx.prisma.pcListing.findUnique({ + where: { id: input.id }, + }) + + if (!pcListing) return ResourceError.pcListing.notFound() + + if (pcListing.authorId !== ctx.session.user.id) { + return ResourceError.pcListing.canOnlyDeleteOwn() + } + + const deletedListing = await ctx.prisma.pcListing.delete({ + where: { id: input.id }, + }) + + invalidatePcListingStatsCache() + + if (pcListing.status === ApprovalStatus.APPROVED) { + await invalidatePcListingSeo(pcListing) + } + + return deletedListing + }), + + update: protectedProcedure.input(UpdatePcListingUserSchema).mutation(async ({ ctx, input }) => { + const EDIT_TIME_LIMIT_MINUTES = 60 + + const pcListing = await ctx.prisma.pcListing.findUnique({ + where: { id: input.id }, + select: { + authorId: true, + status: true, + processedAt: true, + gameId: true, + cpuId: true, + gpuId: true, + }, + }) + + if (!pcListing) return ResourceError.pcListing.notFound() + + if ( + pcListing.authorId !== ctx.session.user.id && + !hasRolePermission(ctx.session.user.role, Role.MODERATOR) + ) { + return ResourceError.pcListing.canOnlyEditOwn() + } + + switch (pcListing.status) { + case ApprovalStatus.REJECTED: + if (!hasRolePermission(ctx.session.user.role, Role.MODERATOR)) { + return ResourceError.pcListing.cannotEditRejected() + } + break + + case ApprovalStatus.APPROVED: { + if (hasRolePermission(ctx.session.user.role, Role.MODERATOR)) break + + if (!pcListing.processedAt) return ResourceError.pcListing.approvalTimeNotFound() + + const now = new Date() + const timeSinceApproval = now.getTime() - pcListing.processedAt.getTime() + const timeLimit = EDIT_TIME_LIMIT_MINUTES * 60 * 1000 + + if (timeSinceApproval > timeLimit) { + return ResourceError.pcListing.editTimeExpired(EDIT_TIME_LIMIT_MINUTES) + } + break + } + + case ApprovalStatus.PENDING: + break + + default: + return AppError.badRequest('Invalid PC listing status') + } + + const [performance] = await Promise.all([ + ctx.prisma.performanceScale.findUnique({ where: { id: input.performanceId } }), + ]) + + if (!performance) return ResourceError.performanceScale.notFound() + + const { id, customFieldValues, ...updateData } = input + + const updatedPcListing = await ctx.prisma.pcListing.update({ + where: { id }, + data: { ...updateData, updatedAt: new Date() }, + include: { + game: { include: { system: true } }, + cpu: { include: { brand: true } }, + gpu: { include: { brand: true } }, + emulator: true, + performance: true, + author: true, + customFieldValues: { + include: { customFieldDefinition: { include: { category: true } } }, + }, + }, + }) + + if (customFieldValues) { + await ctx.prisma.pcListingCustomFieldValue.deleteMany({ where: { pcListingId: id } }) + + if (customFieldValues.length > 0) { + await ctx.prisma.pcListingCustomFieldValue.createMany({ + data: customFieldValues.map((cfv) => ({ + pcListingId: id, + customFieldDefinitionId: cfv.customFieldDefinitionId, + value: toPrismaCustomFieldValue(cfv.value), + })), + }) + } + } + + if (pcListing.status === ApprovalStatus.APPROVED) { + await invalidatePcListingSeoForUpdate( + { + id, + gameId: pcListing.gameId, + cpuId: pcListing.cpuId, + gpuId: pcListing.gpuId, + }, + { + id, + gameId: updatedPcListing.gameId, + cpuId: updatedPcListing.cpuId, + gpuId: updatedPcListing.gpuId, + }, + ) + } + + return updatedPcListing + }), + + vote: protectedProcedure.input(VotePcListingSchema).mutation(async ({ ctx, input }) => { + const { pcListingId, value } = input + const userId = ctx.session.user.id + + if (await isUserBanned(ctx.prisma, userId)) { + return AppError.shadowBanned() + } + + const pcListing = await ctx.prisma.pcListing.findUnique({ + where: { id: pcListingId }, + }) + + if (!pcListing) return ResourceError.pcListing.notFound() + + const voteResult = await ctx.prisma.$transaction(async (tx) => { + const existingVote = await tx.pcListingVote.findUnique({ + where: { userId_pcListingId: { userId, pcListingId } }, + }) + + let result: { + vote: { userId: string; pcListingId: string; value: boolean } | null + action: 'created' | 'updated' | 'deleted' + previousValue: boolean | null + } + + if (!existingVote) { + const vote = await tx.pcListingVote.create({ + data: { userId, pcListingId, value }, + }) + await updatePcListingVoteCounts(tx, pcListingId, 'create', value) + result = { vote, action: 'created', previousValue: null } + } else if (existingVote.value === value) { + await tx.pcListingVote.delete({ + where: { userId_pcListingId: { userId, pcListingId } }, + }) + await updatePcListingVoteCounts(tx, pcListingId, 'delete', undefined, existingVote.value) + result = { vote: null, action: 'deleted', previousValue: existingVote.value } + } else { + const vote = await tx.pcListingVote.update({ + where: { userId_pcListingId: { userId, pcListingId } }, + data: { value }, + }) + await updatePcListingVoteCounts(tx, pcListingId, 'update', value, existingVote.value) + result = { vote, action: 'updated', previousValue: existingVote.value } + } + + await handleListingVoteTrustEffects({ + tx, + action: result.action, + currentValue: value, + previousValue: result.previousValue, + userId, + listingId: pcListingId, + listingType: 'pc', + authorId: pcListing.authorId, + }) + + return result + }) + + if (voteResult.action === 'created' || voteResult.action === 'updated') { + if (voteResult.vote) { + notificationEventEmitter.emitNotificationEvent({ + eventType: NOTIFICATION_EVENTS.LISTING_VOTED, + entityType: 'pcListing', + entityId: pcListingId, + triggeredBy: userId, + payload: { + pcListingId, + voteValue: value, + }, + }) + } + } + + const finalVoteValue = voteResult.action === 'deleted' ? null : value + analytics.engagement.vote({ + listingId: pcListingId, + voteValue: finalVoteValue, + previousVote: voteResult.previousValue, + }) + + return voteResult.vote + }), + + getUserVote: protectedProcedure + .input(GetPcListingUserVoteSchema) + .query(async ({ ctx, input }) => { + const repository = new PcListingsRepository(ctx.prisma) + const vote = await repository.getUserVote(ctx.session.user.id, input.pcListingId) + return { vote } + }), + + presets: { + get: protectedProcedure.input(GetPcPresetsSchema).query(async ({ ctx, input }) => { + const repository = new UserPcPresetsRepository(ctx.prisma) + const userId = input.userId ?? ctx.session.user.id + + return await repository.listByUserId(userId, { + requestingUserId: ctx.session.user.id, + userRole: ctx.session.user.role, + }) + }), + + create: protectedProcedure.input(CreatePcPresetSchema).mutation(async ({ ctx, input }) => { + const repository = new UserPcPresetsRepository(ctx.prisma) + + return await repository.create({ + userId: ctx.session.user.id, + name: input.name, + cpuId: input.cpuId, + gpuId: input.gpuId, + memorySize: input.memorySize, + os: input.os, + osVersion: input.osVersion, + }) + }), + + update: protectedProcedure.input(UpdatePcPresetSchema).mutation(async ({ ctx, input }) => { + const { id, ...data } = input + const repository = new UserPcPresetsRepository(ctx.prisma) + + return await repository.update(id, ctx.session.user.id, data, { + requestingUserRole: ctx.session.user.role, + }) + }), + + delete: protectedProcedure.input(DeletePcPresetSchema).mutation(async ({ ctx, input }) => { + const repository = new UserPcPresetsRepository(ctx.prisma) + await repository.delete(input.id, ctx.session.user.id, { + requestingUserRole: ctx.session.user.role, + }) + return { success: true } + }), + }, + + // Verification + verify: permissionProcedure(PERMISSIONS.APPROVE_LISTINGS) + .input(VerifyPcListingAdminSchema) + .mutation(async ({ ctx, input }) => { + const { pcListingId, notes } = input + const verifierId = ctx.session.user.id + + const pcListing = await ctx.prisma.pcListing.findUnique({ + where: { id: pcListingId }, + }) + + if (!pcListing) { + return ResourceError.pcListing.notFound() + } + + const existingVerification = await ctx.prisma.pcListingDeveloperVerification.findUnique({ + where: { + pcListingId_verifiedBy: { + pcListingId, + verifiedBy: verifierId, + }, + }, + }) + + if (existingVerification) { + return AppError.badRequest('You have already verified this listing') + } + + return ctx.prisma.pcListingDeveloperVerification.create({ + data: { + pcListingId, + verifiedBy: verifierId, + notes, + }, + include: { + developer: { select: { id: true, name: true } }, + }, + }) + }), + + removeVerification: permissionProcedure(PERMISSIONS.APPROVE_LISTINGS) + .input(RemovePcListingVerificationSchema) + .mutation(async ({ ctx, input }) => { + const verification = await ctx.prisma.pcListingDeveloperVerification.findUnique({ + where: { id: input.verificationId }, + }) + + if (!verification) { + return ResourceError.verification.notFound() + } + + if (verification.verifiedBy !== ctx.session.user.id && !isModerator(ctx.session.user.role)) { + return ResourceError.verification.canOnlyRemoveOwn() + } + + return ctx.prisma.pcListingDeveloperVerification.delete({ + where: { id: input.verificationId }, + }) + }), + + getVerifications: publicProcedure + .input(GetPcListingVerificationsSchema) + .query(async ({ ctx, input }) => { + return ctx.prisma.pcListingDeveloperVerification.findMany({ + where: { pcListingId: input.pcListingId }, + include: { + developer: { select: { id: true, name: true } }, + }, + orderBy: { verifiedAt: 'desc' }, + }) + }), +}) diff --git a/src/server/api/routers/pcListings/index.ts b/src/server/api/routers/pcListings/index.ts new file mode 100644 index 000000000..e8e8cd013 --- /dev/null +++ b/src/server/api/routers/pcListings/index.ts @@ -0,0 +1,4 @@ +export { coreRouter } from './core' +export { adminRouter } from './admin' +export { commentsRouter } from './comments' +export { invalidatePcListingStatsCache, toPrismaCustomFieldValue } from './utils' diff --git a/src/server/api/routers/pcListings/utils.ts b/src/server/api/routers/pcListings/utils.ts new file mode 100644 index 000000000..4c4d4faea --- /dev/null +++ b/src/server/api/routers/pcListings/utils.ts @@ -0,0 +1,42 @@ +import { AppError } from '@/lib/errors' +import { listingStatsCache } from '@/server/utils/cache' +import { Prisma } from '@orm/client' + +export const PC_LISTING_STATS_CACHE_KEY = 'pc-listing-stats' + +export function invalidatePcListingStatsCache(): void { + listingStatsCache.delete(PC_LISTING_STATS_CACHE_KEY) +} + +function isJsonRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function toPrismaNestedJsonValue(value: unknown): Prisma.InputJsonValue | null { + if (value === null) return null + if (typeof value === 'string') return value + if (typeof value === 'number') return value + if (typeof value === 'boolean') return value + if (Array.isArray(value)) return value.map(toPrismaNestedJsonValue) + if (isJsonRecord(value)) { + const result: Record = {} + for (const [key, entryValue] of Object.entries(value)) { + result[key] = toPrismaNestedJsonValue(entryValue) + } + + return result + } + + return AppError.invalidInput('customFieldValues') +} + +export function toPrismaCustomFieldValue( + value: unknown, +): Prisma.InputJsonValue | typeof Prisma.JsonNull { + if (value === undefined) return Prisma.JsonNull + + const normalizedValue = toPrismaNestedJsonValue(value) + if (normalizedValue === null) return Prisma.JsonNull + + return normalizedValue +} diff --git a/src/server/repositories/listings.repository.ts b/src/server/repositories/listings.repository.ts index 467978e43..8b9bfdd6b 100644 --- a/src/server/repositories/listings.repository.ts +++ b/src/server/repositories/listings.repository.ts @@ -2,7 +2,6 @@ import { PAGINATION } from '@/data/constants' import { AppError, ResourceError } from '@/lib/errors' import { canUserAutoApprove } from '@/lib/trust/service' import { EMULATOR_VERSION_FIELD_NAME } from '@/schemas/submissionRisk' -import { validateCustomFields } from '@/server/api/routers/listings/validation' import { computeVoteCounts } from '@/server/utils/moderator-info' import { paginate, calculateOffset } from '@/server/utils/pagination' import { @@ -11,6 +10,7 @@ import { buildShadowBanFilter, buildApprovalStatusFilter, } from '@/server/utils/query-builders' +import { validateCustomFields } from '@/server/utils/validate-custom-fields' import { roleIncludesRole } from '@/utils/permission-system' import { calculateWilsonScore } from '@/utils/wilson-score' import { Prisma, ApprovalStatus, Role } from '@orm/client' diff --git a/src/server/utils/security-validation.ts b/src/server/utils/security-validation.ts index 61b5ea834..df8dbf149 100644 --- a/src/server/utils/security-validation.ts +++ b/src/server/utils/security-validation.ts @@ -1,4 +1,5 @@ import { AppError } from '@/lib/errors' +// TODO: carefully consider wtf this file is. seems like none of this is how it should be done. /** * Security validation utilities for critical runtime parameters @@ -74,6 +75,7 @@ export function validateEnum( /** * Validates pagination parameters * Prevents excessive data retrieval + * TODO: this needs to get the fuck out of here. zod validates, this is bs. */ export function validatePagination( page?: number, @@ -89,6 +91,7 @@ export function validatePagination( /** * Sanitizes user input to prevent XSS and injection * Removes potentially dangerous characters + * TODO: this is like insufficient or not the proper way of doing it. */ export function sanitizeInput(input: string): string { return input diff --git a/src/server/api/routers/listings/validation.ts b/src/server/utils/validate-custom-fields.ts similarity index 100% rename from src/server/api/routers/listings/validation.ts rename to src/server/utils/validate-custom-fields.ts From e9843e762d32488ab5e31e7d3d8b39e13eda6665 Mon Sep 17 00:00:00 2001 From: Producdevity Date: Tue, 9 Jun 2026 11:57:36 +0200 Subject: [PATCH 66/87] refactor: remove unused schemas and streamline PC listing validation --- src/schemas/pcListing.ts | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/src/schemas/pcListing.ts b/src/schemas/pcListing.ts index 40e1428fb..5db4dd891 100644 --- a/src/schemas/pcListing.ts +++ b/src/schemas/pcListing.ts @@ -120,11 +120,6 @@ export const VerifyPcListingAdminSchema = z.object({ notes: z.string().optional(), }) -export const UnverifyPcListingAdminSchema = z.object({ - pcListingId: z.string().uuid(), - notes: z.string().optional(), -}) - // Admin schemas for PC listing management export const GetAllPcListingsAdminSchema = z.object({ page: z.number().int().positive().default(1), @@ -194,10 +189,6 @@ export const UpdatePcListingUserSchema = z.object({ .optional(), }) -export const GetPcListingForOwnerEditSchema = z.object({ - id: z.string().uuid(), -}) - // PC Preset schemas export const CreatePcPresetSchema = z.object({ name: z.string().min(1).max(50), @@ -295,8 +286,8 @@ export const GetPcListingReportsSchema = z reason: z.nativeEnum(ReportReason).optional(), sortField: PcListingReportSortField.optional(), sortDirection: SortDirectionSchema.optional(), - page: z.number().min(1).default(1), - limit: z.number().min(1).max(100).default(20), + page: z.coerce.number().int().min(1).default(1), + limit: z.coerce.number().int().min(1).max(100).default(20), }) .optional() @@ -315,9 +306,6 @@ export const GetPcListingVerificationsSchema = z.object({ }) // User permissions and editing -export const CanEditPcListingSchema = z.object({ - pcListingId: z.string().uuid(), -}) export const GetPcListingForUserEditSchema = z.object({ id: z.string().uuid(), From ab8d895c647b4cc1194bbd864a4777fbe03da252 Mon Sep 17 00:00:00 2001 From: Producdevity Date: Thu, 11 Jun 2026 20:33:10 +0200 Subject: [PATCH 67/87] refactor: remove V2 listing components and associated logic --- .env.docker.example | 1 - .env.example | 1 - .env.test.example | 1 - src/app/v2/listings/V2ListingsPage.tsx | 509 ------------------ src/app/v2/listings/components/EmptyState.tsx | 37 -- .../v2/listings/components/ListingCard.tsx | 468 ---------------- .../v2/listings/components/ListingFilters.tsx | 455 ---------------- .../listings/components/ListingsContent.tsx | 205 ------- .../v2/listings/components/ListingsHeader.tsx | 142 ----- .../v2/listings/components/QuickFilters.tsx | 426 --------------- src/app/v2/listings/components/SearchBar.tsx | 173 ------ src/app/v2/listings/page.tsx | 13 - src/components/navbar/Navbar.tsx | 13 - src/components/ui/ProgressiveImage.tsx | 92 ---- src/components/ui/PullToRefresh.tsx | 149 ----- src/components/ui/SwipeableCard.test.tsx | 33 -- src/components/ui/SwipeableCard.tsx | 78 --- src/components/ui/VirtualScroller.tsx | 179 ------ src/components/ui/index.ts | 4 - src/lib/env.ts | 2 - 20 files changed, 2981 deletions(-) delete mode 100644 src/app/v2/listings/V2ListingsPage.tsx delete mode 100644 src/app/v2/listings/components/EmptyState.tsx delete mode 100644 src/app/v2/listings/components/ListingCard.tsx delete mode 100644 src/app/v2/listings/components/ListingFilters.tsx delete mode 100644 src/app/v2/listings/components/ListingsContent.tsx delete mode 100644 src/app/v2/listings/components/ListingsHeader.tsx delete mode 100644 src/app/v2/listings/components/QuickFilters.tsx delete mode 100644 src/app/v2/listings/components/SearchBar.tsx delete mode 100644 src/app/v2/listings/page.tsx delete mode 100644 src/components/ui/ProgressiveImage.tsx delete mode 100644 src/components/ui/PullToRefresh.tsx delete mode 100644 src/components/ui/SwipeableCard.test.tsx delete mode 100644 src/components/ui/SwipeableCard.tsx delete mode 100644 src/components/ui/VirtualScroller.tsx diff --git a/.env.docker.example b/.env.docker.example index b26be84cb..2a067f147 100644 --- a/.env.docker.example +++ b/.env.docker.example @@ -77,7 +77,6 @@ NEXT_PUBLIC_EMUREADY_LITE_GITHUB_URL="https://github.com/Producdevity/EmuReadyLi NEXT_PUBLIC_APP_URL="https://dev.emuready.com" NEXT_PUBLIC_ENABLE_SW=false NEXT_PUBLIC_ENABLE_ASYNC_LISTINGS_FILTERS=false -NEXT_PUBLIC_ENABLE_V2_LISTINGS=false NEXT_TELEMETRY_DISABLED=1 NEXT_IMAGE_UNOPTIMIZED=false diff --git a/.env.example b/.env.example index 6bbbb6ae8..c64100fbb 100644 --- a/.env.example +++ b/.env.example @@ -52,7 +52,6 @@ NEXT_PUBLIC_APP_URL="http://localhost:3000" # Make sure to change this if you ar NEXT_PUBLIC_ENABLE_SW=false NEXT_PUBLIC_ENABLE_PATREON_VERIFICATION=true NEXT_PUBLIC_ENABLE_ASYNC_LISTINGS_FILTERS=false -NEXT_PUBLIC_ENABLE_V2_LISTINGS=false NEXT_TELEMETRY_DISABLED=1 NEXT_IMAGE_UNOPTIMIZED=false diff --git a/.env.test.example b/.env.test.example index 19d4b7a8a..e5f219489 100644 --- a/.env.test.example +++ b/.env.test.example @@ -41,7 +41,6 @@ NEXT_PUBLIC_ENABLE_PATREON_VERIFICATION=true NEXT_PUBLIC_ENABLE_SW=false NEXT_PUBLIC_DISABLE_COOKIE_BANNER=true NEXT_PUBLIC_ENABLE_ASYNC_LISTINGS_FILTERS=false -NEXT_PUBLIC_ENABLE_V2_LISTINGS=false NEXT_TELEMETRY_DISABLED=1 NEXT_IMAGE_UNOPTIMIZED=true diff --git a/src/app/v2/listings/V2ListingsPage.tsx b/src/app/v2/listings/V2ListingsPage.tsx deleted file mode 100644 index 7c9887376..000000000 --- a/src/app/v2/listings/V2ListingsPage.tsx +++ /dev/null @@ -1,509 +0,0 @@ -'use client' - -import { motion, AnimatePresence } from 'framer-motion' -import { User, ArrowUp } from 'lucide-react' -import { Suspense, useState, useEffect, useMemo, useCallback } from 'react' -import useListingsState from '@/app/listings/hooks/useListingsState' -import { usePreferredHardwareFilters } from '@/app/listings/shared/hooks/usePreferredHardwareFilters' -import { LoadingSpinner, PullToRefresh, Button } from '@/components/ui' -import { CACHE_DURATIONS } from '@/data/constants' -import analytics from '@/lib/analytics' -import { api } from '@/lib/api' -import { cn } from '@/lib/utils' -import { filterNullAndEmpty } from '@/utils/filter' -import { systemOptions, deviceOptions, emulatorOptions, socOptionsParens } from '@/utils/options' -import { ListingFilters } from './components/ListingFilters' -import { ListingsContent } from './components/ListingsContent' -import { ListingsHeader } from './components/ListingsHeader' -import { QuickFilters } from './components/QuickFilters' -import { SearchBar } from './components/SearchBar' -import type { SortDirection } from '@/types/api' -import type { RouterOutput, RouterInput } from '@/types/trpc' - -type SortField = NonNullable - -type ListingType = RouterOutput['listings']['get']['listings'][number] - -const LOOKUP_DATA_QUERY_OPTIONS = { - staleTime: CACHE_DURATIONS.LOOKUP, - gcTime: CACHE_DURATIONS.LOOKUP_GC, -} -const USE_ASYNC_LISTING_FILTERS = process.env.NEXT_PUBLIC_ENABLE_ASYNC_LISTINGS_FILTERS === 'true' - -function V2ListingsPage() { - const listingsState = useListingsState() - - // UI State - specific to v2 - const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid') - const [showFilters, setShowFilters] = useState(false) - const [showSystemIcons, _setShowSystemIcons] = useState(false) - const [showScrollToTop, setShowScrollToTop] = useState(false) - - // Infinite scrolling state - const [page, setPage] = useState(1) - const [hasMoreItems, setHasMoreItems] = useState(true) - const [allListings, setAllListings] = useState([]) - const [myListingsOnly, setMyListingsOnly] = useState(false) - - const performanceScalesQuery = api.listings.performanceScales.useQuery( - undefined, - LOOKUP_DATA_QUERY_OPTIONS, - ) - - // User preferences and device filtering - const userQuery = api.users.me.useQuery() - const userPreferencesQuery = api.userPreferences.get.useQuery(undefined, { - enabled: !!userQuery.data, - staleTime: CACHE_DURATIONS.SHORT, - gcTime: CACHE_DURATIONS.MEDIUM, - }) - - const preferred = usePreferredHardwareFilters({ - userPreferences: userPreferencesQuery.data, - deviceIds: listingsState.deviceIds, - socIds: listingsState.socIds, - }) - - // Filter params for API call - const filterParams: RouterInput['listings']['get'] = useMemo( - () => ({ - page: page, - limit: 15, // Increased for better mobile experience - ...filterNullAndEmpty({ - systemIds: listingsState.systemIds.length > 0 ? listingsState.systemIds : undefined, - deviceIds: preferred.appliedDeviceIds, - socIds: preferred.appliedSocIds, - emulatorIds: listingsState.emulatorIds.length > 0 ? listingsState.emulatorIds : undefined, - performanceIds: - listingsState.performanceIds.length > 0 ? listingsState.performanceIds : undefined, - searchTerm: listingsState.search || undefined, - myListingsOnly: myListingsOnly && userQuery.data?.id ? true : undefined, - sortField: listingsState.sortField ?? undefined, - sortDirection: listingsState.sortDirection ?? undefined, - }), - }), - [ - listingsState.systemIds, - listingsState.emulatorIds, - listingsState.performanceIds, - listingsState.search, - listingsState.sortField, - listingsState.sortDirection, - myListingsOnly, - preferred.appliedDeviceIds, - preferred.appliedSocIds, - page, - userQuery.data?.id, - ], - ) - - // Main listings query - const listingsQuery = api.listings.get.useQuery(filterParams, { - refetchOnWindowFocus: false, - refetchOnMount: false, - retry: 1, - }) - - // Handle query results for infinite scrolling - useEffect(() => { - if (!listingsQuery.data) return - - setAllListings((prev) => { - if (page === 1) { - return listingsQuery.data.listings - } else { - const existingIds = new Set(prev.map((item) => item.id)) - const newListings = listingsQuery.data.listings.filter((item) => !existingIds.has(item.id)) - return [...prev, ...newListings] - } - }) - - setHasMoreItems(page < (listingsQuery.data.pagination?.pages || 1)) - }, [listingsQuery.data, page]) - - // Track search analytics - useEffect(() => { - if ( - listingsState.search && - listingsState.search.length > 2 && - !listingsQuery.isPending && - listingsQuery.data - ) { - analytics.contentDiscovery.searchPerformed({ - query: listingsState.search, - resultCount: listingsQuery.data.listings.length, - category: 'v2_listings', - page: 'v2/listings', - }) - } - }, [listingsQuery.data, listingsQuery.isPending, listingsState.search]) - - // Scroll to top functionality - useEffect(() => { - const handleScroll = () => { - setShowScrollToTop(window.scrollY > 400) - } - - window.addEventListener('scroll', handleScroll) - return () => window.removeEventListener('scroll', handleScroll) - }, []) - - const scrollToTop = () => { - window.scrollTo({ top: 0, behavior: 'smooth' }) - if (navigator.vibrate) { - navigator.vibrate(25) - } - } - - const toggleMyListings = () => { - setMyListingsOnly(!myListingsOnly) - setPage(1) - setAllListings([]) - - if (navigator.vibrate) { - navigator.vibrate(50) - } - - analytics.filter.myListings(!myListingsOnly) - } - - const loadMoreListings = useCallback(() => { - if (hasMoreItems && !listingsQuery.isPending && !listingsQuery.isFetching) { - setPage((prev) => prev + 1) - } - }, [hasMoreItems, listingsQuery.isPending, listingsQuery.isFetching]) - - const handleRefresh = useCallback(async () => { - if (listingsQuery.isPending || listingsQuery.isFetching) return - - try { - await listingsQuery.refetch() - setPage(1) - setAllListings([]) - - if (navigator.vibrate) { - navigator.vibrate(100) - } - } catch (error) { - console.error('Error refreshing listings:', error) - } - }, [listingsQuery]) - - // Enhanced filter handlers - const handleDeviceChange = useCallback( - (values: string[]) => { - listingsState.setDeviceIds(values) - setPage(1) - setAllListings([]) - - // When user manually selects devices, disable user preference filtering - if (values.length > 0) { - preferred.setUserDeviceFilterDisabled(true) - preferred.setUserSocFilterDisabled(true) - } - // When clearing device selections, ensure user preferences are disabled - if (values.length === 0) { - preferred.setUserDeviceFilterDisabled(true) - } - - analytics.filter.device(values) - }, - [listingsState, preferred], - ) - - const handleSocChange = useCallback( - (values: string[]) => { - listingsState.setSocIds(values) - setPage(1) - setAllListings([]) - - // When user manually selects SoCs, disable user preference filtering - if (values.length > 0) { - preferred.setUserSocFilterDisabled(true) - preferred.setUserDeviceFilterDisabled(true) - } - // When clearing SOC selections, ensure user preferences are disabled - if (values.length === 0) { - preferred.setUserSocFilterDisabled(true) - } - - analytics.filter.soc(values) - }, - [listingsState, preferred], - ) - - const handleSystemChange = useCallback( - (values: string[]) => { - listingsState.setSystemIds(values) - setPage(1) - setAllListings([]) - analytics.filter.system(values) - }, - [listingsState], - ) - - const handleEmulatorChange = useCallback( - (values: string[]) => { - listingsState.setEmulatorIds(values) - setPage(1) - setAllListings([]) - analytics.filter.emulator(values) - }, - [listingsState], - ) - - const handlePerformanceChange = useCallback( - (values: number[]) => { - listingsState.setPerformanceIds(values) - setPage(1) - setAllListings([]) - analytics.filter.performance(values) - }, - [listingsState], - ) - - // Clear all filters - const clearAllFilters = useCallback(() => { - listingsState.setSystemIds([]) - listingsState.setDeviceIds([]) - listingsState.setSocIds([]) - listingsState.setEmulatorIds([]) - listingsState.setPerformanceIds([]) - listingsState.setSearch('') - listingsState.setSortField(null) - listingsState.setSortDirection(null) - setMyListingsOnly(false) - setPage(1) - setAllListings([]) - preferred.setUserDeviceFilterDisabled(false) - preferred.setUserSocFilterDisabled(false) - analytics.filter.clearAll() - }, [listingsState, preferred]) - - // Check if any filters are active - const hasActiveFilters = - listingsState.systemIds.length > 0 || - listingsState.deviceIds.length > 0 || - listingsState.socIds.length > 0 || - listingsState.emulatorIds.length > 0 || - listingsState.performanceIds.length > 0 || - listingsState.search.length > 0 || - myListingsOnly - - // Properly typed handleSort function - const handleSort = (field: SortField, direction?: SortDirection) => { - listingsState.setSortField(field) - listingsState.setSortDirection(direction || null) - } - - const systemsQuery = api.systems.get.useQuery(undefined, LOOKUP_DATA_QUERY_OPTIONS) - const devicesQuery = api.devices.options.useQuery( - { limit: 500, offset: 0 }, - { ...LOOKUP_DATA_QUERY_OPTIONS, enabled: !USE_ASYNC_LISTING_FILTERS }, - ) - const emulatorsQuery = api.emulators.get.useQuery( - { limit: 500, offset: 0 }, - LOOKUP_DATA_QUERY_OPTIONS, - ) - const socsQuery = api.socs.options.useQuery( - { limit: 500, offset: 0 }, - { ...LOOKUP_DATA_QUERY_OPTIONS, enabled: !USE_ASYNC_LISTING_FILTERS }, - ) - - // Transform preloaded data into options format - const systemOpts = useMemo( - () => (systemsQuery.data ? systemOptions(systemsQuery.data) : undefined), - [systemsQuery.data], - ) - - const deviceOpts = useMemo( - () => (devicesQuery.data ? deviceOptions(devicesQuery.data.devices) : undefined), - [devicesQuery.data], - ) - - const emulatorOpts = useMemo( - () => (emulatorsQuery.data ? emulatorOptions(emulatorsQuery.data.emulators) : undefined), - [emulatorsQuery.data], - ) - - const socOpts = useMemo( - () => (socsQuery.data ? socOptionsParens(socsQuery.data.socs) : undefined), - [socsQuery.data], - ) - - // Handle errors - if (listingsQuery.error) { - return ( -
    -
    -
    -

    - Error Loading Listings -

    -

    {listingsQuery.error.message}

    - -
    -
    -
    - ) - } - - return ( - -
    -
    - {/* Header */} -
    - - - {/* Search Bar */} - listingsState.setSearch(value)} - showFilters={showFilters} - onToggleFilters={() => setShowFilters(!showFilters)} - activeFilterCount={ - hasActiveFilters - ? Object.values({ - systems: listingsState.systemIds.length, - devices: listingsState.deviceIds.length, - socs: listingsState.socIds.length, - emulators: listingsState.emulatorIds.length, - performance: listingsState.performanceIds.length, - search: listingsState.search ? 1 : 0, - myListings: myListingsOnly ? 1 : 0, - }).reduce((sum, count) => sum + count, 0) - : 0 - } - /> - - {/* Quick Filter Chips */} - -
    - - {/* Advanced Filters Overlay - Bottom Sheet on Mobile */} - handlePerformanceChange(values.map(Number))} - performanceScales={performanceScalesQuery.data} - useAsyncHardwareFilters={USE_ASYNC_LISTING_FILTERS} - deviceIds={listingsState.deviceIds} - handleDeviceChange={handleDeviceChange} - deviceOptions={deviceOpts} - emulatorIds={listingsState.emulatorIds} - handleEmulatorChange={handleEmulatorChange} - emulatorOptions={emulatorOpts} - socIds={listingsState.socIds} - handleSocChange={handleSocChange} - socOptions={socOpts} - /> - - {/* Listings Content */} - -
    - - {/* Floating Action Buttons */} -
    - {/* My Listings Toggle */} - {userQuery.data && ( - - - - )} - - {/* Scroll to Top */} - - {showScrollToTop && ( - - - - )} - -
    -
    -
    - ) -} - -export default function V2ListingsPageWithSuspense() { - return ( - }> - - - ) -} diff --git a/src/app/v2/listings/components/EmptyState.tsx b/src/app/v2/listings/components/EmptyState.tsx deleted file mode 100644 index 5425ac039..000000000 --- a/src/app/v2/listings/components/EmptyState.tsx +++ /dev/null @@ -1,37 +0,0 @@ -'use client' - -import { Search } from 'lucide-react' -import Link from 'next/link' -import { Button } from '@/components/ui' - -interface Props { - hasActiveFilters: boolean - clearAllFilters: () => void -} - -export function EmptyState(props: Props) { - return ( -
    -
    - -
    -

    - No listings found -

    -

    - {props.hasActiveFilters - ? 'Try adjusting your filters or search terms' - : 'Be the first to add a listing!'} -

    - {props.hasActiveFilters ? ( - - ) : ( - - )} -
    - ) -} diff --git a/src/app/v2/listings/components/ListingCard.tsx b/src/app/v2/listings/components/ListingCard.tsx deleted file mode 100644 index b820e6a9f..000000000 --- a/src/app/v2/listings/components/ListingCard.tsx +++ /dev/null @@ -1,468 +0,0 @@ -import { ExternalLink, Clock, Heart, MessageSquare, ThumbsUp } from 'lucide-react' -import { useRouter } from 'next/navigation' -import { useState, type MouseEvent } from 'react' -import { EmulatorIcon, SystemIcon } from '@/components/icons' -import { RetroCatalogButton } from '@/components/retrocatalog' -import { - Button, - PerformanceBadge, - SuccessRateBar, - Tooltip, - TooltipTrigger, - TooltipContent, - ProgressiveImage, - SwipeableCard, - LocalizedDate, -} from '@/components/ui' -import { cn } from '@/lib/utils' -import getGameImageUrl from '@/utils/images/getGameImageUrl' -import { - isAnchorNavigationTarget, - openInNewTab, - shouldOpenInNewTab, -} from '@/utils/navigation-events' -import { ApprovalStatus } from '@orm' -import type { RouterOutput } from '@/types/trpc' - -type Listing = RouterOutput['listings']['get']['listings'][number] - -interface Props { - listing: Listing - viewMode: 'grid' | 'list' - showSystemIcons?: boolean - onLike?: () => void - onComment?: () => void -} - -export function ListingCard({ - listing, - viewMode, - showSystemIcons = false, - onLike, - onComment, -}: Props) { - const router = useRouter() - const [isLiked, setIsLiked] = useState(false) - const listingHref = `/listings/${listing.id}` - const gameHref = `/games/${listing.game.id}` - - const handleLike = () => { - setIsLiked(!isLiked) - if (onLike) onLike() - - // Trigger haptic feedback - if (navigator.vibrate) navigator.vibrate(50) - } - - const handleComment = () => { - if (onComment) onComment() - else router.push(`/listings/${listing.id}#comments`) - } - - const navigateToGame = (ev: MouseEvent) => { - ev.stopPropagation() - - if (shouldOpenInNewTab(ev)) { - ev.preventDefault() - openInNewTab(gameHref) - return - } - - router.push(gameHref) - } - - const navigateToListing = (ev: MouseEvent) => { - if (isAnchorNavigationTarget(ev)) return - - if (shouldOpenInNewTab(ev)) { - ev.preventDefault() - openInNewTab(listingHref) - return - } - - router.push(listingHref) - } - - const openListingFromAuxClick = (ev: MouseEvent) => { - if (isAnchorNavigationTarget(ev) || !shouldOpenInNewTab(ev)) return - - ev.preventDefault() - openInNewTab(listingHref) - } - - // Get game cover image or placeholder - const gameCoverUrl = - getGameImageUrl(listing.game as Parameters[0]) || - '/placeholder/game.svg' - - // Generate accessible labels - const gameTitle = listing.game.title - const deviceName = listing.device - ? `${listing.device.brand.name} ${listing.device.modelName}` - : 'Unknown Device' - const emulatorName = listing.emulator?.name || 'Unknown Emulator' - const performanceLabel = listing.performance?.label || 'N/A' - const successRate = Math.round(listing.successRate * 100) - const voteCount = listing._count.votes - - return ( - - {viewMode === 'grid' ? ( -
    - {/* Game Cover Image - Hero Section */} -
    - - - {/* Overlay gradient */} -
    - - {/* Status indicators - top right */} -
    - {listing.status === ApprovalStatus.PENDING && ( - - -
    - - Pending -
    -
    - Pending approval -
    - )} - {listing.isVerifiedDeveloper && ( - - -
    - ✓ Verified -
    -
    - Verified Developer -
    - )} -
    - - {/* Performance badge - bottom left overlay */} - - - {/* Quick action - top left */} - -
    - - {/* Card Content */} -
    - {/* Title and System */} -
    - - -

    - {listing.game.title} -

    -
    - {listing.game.title} -
    - -
    - {listing.game.system?.key ? ( - - ) : ( - - {listing.game.system?.name} - - )} -
    -
    - - {/* Device & Emulator Info */} -
    -
    - - Device - - - {deviceName} - - {listing.device && ( -
    ev.stopPropagation()} - > - -
    - )} -
    - - {listing.emulator && ( -
    - - Emulator - - -
    - )} -
    - - {/* Success Rate */} -
    -
    - - Success Rate - - - {successRate}% - -
    - -
    - - {/* Footer */} -
    -
    - {listing.author?.name ?? 'Anonymous'} - - - - -
    - -
    - - - -
    -
    -
    -
    - ) : ( - <> - {/* List View - Mobile-First Design */} -
    -
    - {/* Game Image - Always visible but smaller on mobile */} -
    -
    - -
    -
    - - {/* Content Section */} -
    - {/* Title and Status Row */} -
    -

    - {listing.game.title} -

    - {listing.status === ApprovalStatus.PENDING && ( - - - - - Pending approval - - )} -
    - - {/* System and Device Info - Responsive layout */} -
    -
    - - {showSystemIcons && listing.game.system?.key ? ( - - ) : ( - {listing.game.system?.name} - )} - - - {deviceName} - {listing.device && ( -
    ev.stopPropagation()} - > - -
    - )} -
    - - {listing.emulator && ( -
    - Emulator: - -
    - )} -
    - - {/* Author and Date */} -
    - {listing.author?.name ?? 'Anonymous'} - - - - -
    -
    - - {/* Performance and Success Rate - Right Side */} -
    - {/* Performance Badge - Hidden on mobile, visible on tablet+ */} -
    - -
    - - {/* Success Rate - Compact for mobile */} -
    -
    - - {successRate}% -
    - -
    -
    -
    - - {/* Performance Badge for Mobile - Bottom Row */} -
    - -
    - - -
    -
    -
    - - )} - - ) -} diff --git a/src/app/v2/listings/components/ListingFilters.tsx b/src/app/v2/listings/components/ListingFilters.tsx deleted file mode 100644 index 97a95de76..000000000 --- a/src/app/v2/listings/components/ListingFilters.tsx +++ /dev/null @@ -1,455 +0,0 @@ -'use client' - -import { AnimatePresence, motion } from 'framer-motion' -import { - ChevronDown, - X, - Filter, - RotateCcw, - Smartphone, - Cpu, - Gamepad, - Zap, - Search, -} from 'lucide-react' -import { useState, useEffect, type ReactNode } from 'react' -import AsyncDeviceFilterSelect from '@/app/listings/components/filters/AsyncDeviceFilterSelect' -import AsyncSocFilterSelect from '@/app/listings/components/filters/AsyncSocFilterSelect' -import { MultiSelect, Button, Input, Badge } from '@/components/ui' -import analytics from '@/lib/analytics' -import { cn } from '@/lib/utils' - -interface PerformanceScale { - id: number - label: string - rank: number - description: string | null -} - -interface FilterSection { - id: string - title: string - icon: ReactNode - isAdvanced?: boolean -} - -interface Props { - showFilters: boolean - setShowFilters: (show: boolean) => void - hasActiveFilters: boolean - clearAllFilters: () => void - // System filters - systemIds: string[] - handleSystemChange: (values: string[]) => void - systemOptions: { id: string; name: string }[] | undefined - // Performance filters - performanceIds: string[] - handlePerformanceChange: (values: string[]) => void - performanceScales: PerformanceScale[] | undefined - // Device filters - useAsyncHardwareFilters: boolean - deviceIds: string[] - handleDeviceChange: (values: string[]) => void - deviceOptions: { id: string; name: string }[] | undefined - // Emulator filters - emulatorIds: string[] - handleEmulatorChange: (values: string[]) => void - emulatorOptions: { id: string; name: string }[] | undefined - // SoC filters - socIds: string[] - handleSocChange: (values: string[]) => void - socOptions: { id: string; name: string }[] | undefined -} - -const filterSections: FilterSection[] = [ - { id: 'systems', title: 'Systems', icon: }, - { - id: 'performance', - title: 'Performance', - icon: , - }, - { - id: 'devices', - title: 'Devices', - icon: , - isAdvanced: true, - }, - { - id: 'emulators', - title: 'Emulators', - icon: , - isAdvanced: true, - }, - { - id: 'socs', - title: 'System on Chips', - icon: , - isAdvanced: true, - }, -] - -export function ListingFilters(props: Props) { - const [showAdvancedFilters, setShowAdvancedFilters] = useState(false) - const [searchTerm, setSearchTerm] = useState('') - const [expandedSections, setExpandedSections] = useState>( - new Set(['systems', 'performance']), - ) - const [isClient, setIsClient] = useState(false) - - useEffect(() => { - setIsClient(true) - }, []) - - // Calculate active filter count - const activeFilterCount = [ - props.systemIds.length, - props.deviceIds.length, - props.emulatorIds.length, - props.socIds.length, - props.performanceIds.length, - searchTerm ? 1 : 0, - ].reduce((sum, count) => sum + count, 0) - - const toggleSection = (sectionId: string) => { - const newExpanded = new Set(expandedSections) - if (newExpanded.has(sectionId)) { - newExpanded.delete(sectionId) - } else { - newExpanded.add(sectionId) - } - setExpandedSections(newExpanded) - } - - const handleClearAll = () => { - props.clearAllFilters() - setSearchTerm('') - analytics.filter.clearAll() - - // Haptic feedback - if (navigator.vibrate) { - navigator.vibrate(50) - } - } - - const handleApplyFilters = () => { - props.setShowFilters(false) - - // Track analytics - use existing methods - if (props.systemIds.length > 0) analytics.filter.system(props.systemIds) - if (props.deviceIds.length > 0) analytics.filter.device(props.deviceIds) - if (props.emulatorIds.length > 0) analytics.filter.emulator(props.emulatorIds) - if (props.socIds.length > 0) analytics.filter.soc(props.socIds) - if (props.performanceIds.length > 0) - analytics.filter.performance(props.performanceIds.map(Number)) - - // Haptic feedback - if (navigator.vibrate) { - navigator.vibrate(100) - } - } - - if (!isClient) return null - - return ( - - {props.showFilters && ( - <> - {/* Mobile Bottom Sheet */} - props.setShowFilters(false)} - /> - - -
    - {/* Mobile Handle */} -
    - -
    - - {/* Header */} -
    -
    - -

    - Filter Listings -

    - {activeFilterCount > 0 && ( - - {activeFilterCount} - - )} -
    - -
    - - -
    -
    - - {/* Search Bar */} - -
    - - setSearchTerm(e.target.value)} - className="pl-10 pr-4 py-3 text-base border-2 border-gray-200 dark:border-gray-600 focus:border-blue-500 dark:focus:border-blue-400 rounded-xl transition-colors" - /> - {searchTerm && ( - - )} -
    -
    - - {/* Filter Content */} -
    - {filterSections.map((section) => { - const isVisible = !section.isAdvanced || showAdvancedFilters - const isExpanded = expandedSections.has(section.id) - - if (!isVisible) return null - - return ( - - - - - {isExpanded && ( - - {section.id === 'systems' && props.systemOptions && ( - - )} - - {section.id === 'performance' && ( - ({ - id: scale.id.toString(), - name: `${scale.label}${scale.description ? ` - ${scale.description}` : ''}`, - }))} - maxDisplayed={3} - className="mobile-optimized" - /> - )} - - {section.id === 'devices' && props.useAsyncHardwareFilters && ( - - )} - - {section.id === 'devices' && - !props.useAsyncHardwareFilters && - props.deviceOptions && ( - - )} - - {section.id === 'emulators' && props.emulatorOptions && ( - - )} - - {section.id === 'socs' && props.useAsyncHardwareFilters && ( - - )} - - {section.id === 'socs' && - !props.useAsyncHardwareFilters && - props.socOptions && ( - - )} - - )} - - - ) - })} -
    - - {/* Footer Actions */} -
    - - - -
    -
    -
    - - )} -
    - ) -} diff --git a/src/app/v2/listings/components/ListingsContent.tsx b/src/app/v2/listings/components/ListingsContent.tsx deleted file mode 100644 index 71c30dc7b..000000000 --- a/src/app/v2/listings/components/ListingsContent.tsx +++ /dev/null @@ -1,205 +0,0 @@ -'use client' - -import { LoadingSpinner, VirtualScroller, Pagination } from '@/components/ui' -import { useMediaQuery } from '@/hooks' -import { cn } from '@/lib/utils' -import { EmptyState } from './EmptyState' -import { ListingCard } from './ListingCard' -import type { RouterOutput } from '@/types/trpc' - -type ListingType = RouterOutput['listings']['get']['listings'][number] - -interface Props { - allListings: ListingType[] - viewMode: 'grid' | 'list' - showSystemIcons: boolean - isLoading: boolean - isFetching: boolean - hasMoreItems: boolean - page: number - totalPages?: number - loadMoreListings: () => void - onPageChange?: (page: number) => void - hasActiveFilters: boolean - clearAllFilters: () => void -} - -export function ListingsContent(props: Props) { - const { - allListings, - viewMode, - showSystemIcons, - isLoading, - isFetching, - hasMoreItems, - page, - totalPages, - loadMoreListings, - onPageChange, - hasActiveFilters, - clearAllFilters, - } = props - - // Use mobile-first approach: VirtualScroller on mobile, grid with pagination on desktop - const isMobile = useMediaQuery('(max-width: 768px)') - - // Loading state - Skeleton Loader - if (isLoading && page === 1) { - return ( -
    - {Array.from({ length: 6 }).map((_, index) => ( -
    - {viewMode === 'grid' ? ( -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    - ) : ( - <> -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    - - )} -
    - ))} -
    - ) - } - - // No listings found - if (allListings.length === 0) { - return - } - - // Listings content - return ( -
    - {viewMode === 'list' ? ( - // List view: Simple scrollable list with proper spacing -
    - {allListings.map((listing) => ( - - ))} - - {/* Load more button for list view */} - {hasMoreItems && ( -
    - -
    - )} -
    - ) : isMobile ? ( - // Mobile grid view: Use VirtualScroller for performance with proper grid - ( -
    - -
    - )} - itemHeight={380} - onEndReached={loadMoreListings} - endReachedThreshold={300} - getItemKey={(item) => item.id} - overscan={3} - className="pb-12 grid grid-cols-1 sm:grid-cols-2 gap-4" - /> - ) : ( - // Desktop grid view: Use CSS Grid with pagination - <> -
    - {allListings.map((listing) => ( - - ))} -
    - - {/* Pagination for desktop grid view */} - {totalPages && totalPages > 1 && onPageChange && ( -
    - -
    - )} - - )} - - {/* Loading indicator for mobile grid view only */} - {isMobile && viewMode === 'grid' && (isLoading || isFetching) && page > 1 && ( -
    - -
    - )} - - {/* End of results message for mobile grid view only */} - {isMobile && - viewMode === 'grid' && - !hasMoreItems && - allListings.length > 0 && - !isLoading && - !isFetching && ( -
    - You've reached the end of the listings -
    - )} -
    - ) -} diff --git a/src/app/v2/listings/components/ListingsHeader.tsx b/src/app/v2/listings/components/ListingsHeader.tsx deleted file mode 100644 index ed023322e..000000000 --- a/src/app/v2/listings/components/ListingsHeader.tsx +++ /dev/null @@ -1,142 +0,0 @@ -'use client' - -import { motion } from 'framer-motion' -import { Grid, List, Plus, Sparkles } from 'lucide-react' -import Link from 'next/link' -import { Button } from '@/components/ui' -import { cn } from '@/lib/utils' - -interface Props { - viewMode: 'grid' | 'list' - setViewMode: (mode: 'grid' | 'list') => void - listingsCount: number - isLoading: boolean -} - -export function ListingsHeader(props: Props) { - return ( - <> - -
    - -

    - Handheld Reports -

    - - - V2 - -
    - - - {props.isLoading ? ( - - - ) : ( - - {props.listingsCount.toLocaleString()} listing - {props.listingsCount !== 1 ? 's' : ''} found - - )} - -
    - -
    - {/* View Mode Toggle */} - - - - -
    -
    - - {/* Mobile Add Listing FAB */} - - - - - )} - -
    - ) -} diff --git a/src/app/v2/listings/components/SearchBar.tsx b/src/app/v2/listings/components/SearchBar.tsx deleted file mode 100644 index b3b1d1228..000000000 --- a/src/app/v2/listings/components/SearchBar.tsx +++ /dev/null @@ -1,173 +0,0 @@ -'use client' - -import { motion, AnimatePresence } from 'framer-motion' -import { Filter, Search, X } from 'lucide-react' -import { useState, useRef, useEffect, type KeyboardEvent } from 'react' -import { Button, Input } from '@/components/ui' -import { cn } from '@/lib/utils' - -interface Props { - search: string - onSearchChange: (value: string) => void - showFilters: boolean - onToggleFilters: () => void - activeFilterCount?: number -} - -export function SearchBar(props: Props) { - const [isFocused, setIsFocused] = useState(false) - const [searchHistory, setSearchHistory] = useState([]) - const inputRef = useRef(null) - - // Load search history from localStorage on mount - useEffect(() => { - const history = localStorage.getItem('v2-search-history') - if (history) { - try { - setSearchHistory(JSON.parse(history).slice(0, 5)) // Keep only recent 5 - } catch (error) { - console.warn('Failed to parse search history:', error) - } - } - }, []) - - const handleSearchChange = (value: string) => { - props.onSearchChange(value) - } - - const handleSearchSubmit = () => { - if (props.search.trim() && !searchHistory.includes(props.search.trim())) { - const newHistory = [props.search.trim(), ...searchHistory].slice(0, 5) - setSearchHistory(newHistory) - localStorage.setItem('v2-search-history', JSON.stringify(newHistory)) - } - inputRef.current?.blur() - } - - const handleKeyPress = (e: KeyboardEvent) => { - if (e.key === 'Enter') { - handleSearchSubmit() - } - if (e.key === 'Escape') { - inputRef.current?.blur() - } - } - - const handleHistorySelect = (term: string) => { - props.onSearchChange(term) - setIsFocused(false) - inputRef.current?.blur() - } - - const clearSearch = () => { - props.onSearchChange('') - inputRef.current?.focus() - } - - return ( -
    - - - - handleSearchChange(e.target.value)} - onFocus={() => setIsFocused(true)} - onBlur={() => setTimeout(() => setIsFocused(false), 150)} - onKeyDown={handleKeyPress} - className={cn( - 'pl-12 pr-24 h-14 text-base rounded-2xl transition-all duration-200', - 'border-2 bg-white dark:bg-gray-800', - 'placeholder:text-gray-400 dark:placeholder:text-gray-500', - isFocused - ? 'border-blue-500 dark:border-blue-400 shadow-lg shadow-blue-500/10' - : 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600', - )} - /> - - {/* Clear Search Button */} - - {props.search && ( - - - - )} - - - {/* Filter Toggle Button */} - - - - {/* Search History Dropdown */} - - {isFocused && searchHistory.length > 0 && ( - -
    - - Recent Searches - -
    -
    - {searchHistory.map((term, index) => ( - handleHistorySelect(term)} - className="w-full text-left px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors flex items-center gap-3 border-b border-gray-50 dark:border-gray-700 last:border-b-0" - initial={{ opacity: 0, x: -10 }} - animate={{ opacity: 1, x: 0 }} - transition={{ delay: index * 0.03 }} - > - - {term} - - ))} -
    -
    - )} -
    -
    - ) -} diff --git a/src/app/v2/listings/page.tsx b/src/app/v2/listings/page.tsx deleted file mode 100644 index 8e13f1479..000000000 --- a/src/app/v2/listings/page.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { type Metadata } from 'next' -import { generatePageMetadata } from '@/lib/seo/metadata' -import V2ListingsPage from './V2ListingsPage' - -export const metadata: Metadata = generatePageMetadata( - 'Compatibility Reports V2', - 'Enhanced compatibility reports interface with advanced filtering and search capabilities.', - '/v2/listings', -) - -export default function Page() { - return -} diff --git a/src/components/navbar/Navbar.tsx b/src/components/navbar/Navbar.tsx index bef5a2779..15c33446a 100644 --- a/src/components/navbar/Navbar.tsx +++ b/src/components/navbar/Navbar.tsx @@ -9,7 +9,6 @@ import { LogoIcon, LoadingIcon } from '@/components/icons' import NotificationCenter from '@/components/notifications/NotificationCenter' import { ThemeToggle } from '@/components/ui' import analytics from '@/lib/analytics' -import { env } from '@/lib/env' import { hasRolePermission } from '@/utils/permissions' import { Role } from '@orm' import { navbarItems } from './data' @@ -141,18 +140,6 @@ function Navbar() { Admin )} - {hasRolePermission(userRole, Role.MODERATOR) && env.ENABLE_V2_LISTINGS && ( - - V2 - - )} Feed diff --git a/src/components/ui/ProgressiveImage.tsx b/src/components/ui/ProgressiveImage.tsx deleted file mode 100644 index f7dab32c5..000000000 --- a/src/components/ui/ProgressiveImage.tsx +++ /dev/null @@ -1,92 +0,0 @@ -'use client' - -import Image from 'next/image' -import { useEffect, useState, type ReactNode } from 'react' -import { cn } from '@/lib/utils' -import { LoadingSpinner } from './LoadingSpinner' - -interface Props { - src: string - alt: string - className?: string - imgClassName?: string - placeholderSrc?: string - width?: number - height?: number - loadingComponent?: ReactNode - onLoad?: () => void -} - -export function ProgressiveImage(props: Props) { - const [imgSrc, setImgSrc] = useState(props.placeholderSrc || props.src) - const [imgLoaded, setImgLoaded] = useState(false) - const [isLoading, setIsLoading] = useState(true) - - // only destructure functions - const { onLoad } = props - - useEffect(() => { - // Reset state when src changes - setImgLoaded(false) - setIsLoading(true) - setImgSrc(props.placeholderSrc || props.src) - }, [props.src, props.placeholderSrc]) - - useEffect(() => { - // Skip if we're already using the full resolution image - if (imgSrc === props.src && imgLoaded) return - - // Use the HTML Image constructor to preload the image - const img = new window.Image() - img.src = props.src - - img.onload = () => { - setImgSrc(props.src) - setImgLoaded(true) - setIsLoading(false) - if (onLoad) onLoad() - } - - return () => { - img.onload = null - } - }, [props.src, imgSrc, imgLoaded, onLoad]) - - return ( -
    - {/* Image */} - {props.alt} { - // Only mark as loaded if we're showing the full resolution image - if (imgSrc === props.src) { - setImgLoaded(true) - setIsLoading(false) - } - }} - onError={() => setIsLoading(false)} - unoptimized // TEMP: until we aren't broke anymore - /> - - {isLoading && ( -
    - {props.loadingComponent || } -
    - )} -
    - ) -} diff --git a/src/components/ui/PullToRefresh.tsx b/src/components/ui/PullToRefresh.tsx deleted file mode 100644 index 38cc5232a..000000000 --- a/src/components/ui/PullToRefresh.tsx +++ /dev/null @@ -1,149 +0,0 @@ -'use client' - -import { motion, useMotionValue, useTransform, useAnimation } from 'framer-motion' -import { ArrowDown } from 'lucide-react' -import { useState, useRef, useEffect, type PropsWithChildren } from 'react' -import { cn } from '@/lib/utils' - -interface Props extends PropsWithChildren { - onRefresh: () => Promise - pullDistance?: number - className?: string - refreshingText?: string - pullingText?: string - releaseText?: string - enableHaptics?: boolean -} - -export function PullToRefresh({ - onRefresh, - children, - pullDistance = 100, - className, - refreshingText = 'Refreshing...', - pullingText = 'Pull to refresh', - releaseText = 'Release to refresh', - enableHaptics = true, -}: Props) { - const [refreshing, setRefreshing] = useState(false) - const [isPulling, setIsPulling] = useState(false) - const [canRefresh, setCanRefresh] = useState(false) - const containerRef = useRef(null) - const startY = useRef(0) - const currentY = useRef(0) - const y = useMotionValue(0) - const controls = useAnimation() - - // Transform the pull indicator's opacity and scale based on pull distance - const indicatorOpacity = useTransform(y, [0, pullDistance * 0.4, pullDistance], [0, 0.8, 1]) - - const indicatorScale = useTransform(y, [0, pullDistance], [0.8, 1]) - - const indicatorRotate = useTransform(y, [0, pullDistance], [0, 180]) - - // Set up event listeners - useEffect(() => { - const container = containerRef.current - if (!container) return - - // Handle touch start - const handleTouchStart = (e: TouchEvent) => { - // Only enable pull to refresh when at top of the page - if (window.scrollY <= 0) { - startY.current = e.touches[0].clientY - setIsPulling(true) - } - } - - // Handle touch move - const handleTouchMove = (e: TouchEvent) => { - if (!isPulling) return - - currentY.current = e.touches[0].clientY - const pullLength = Math.max(0, currentY.current - startY.current) - - // Apply resistance to the pull - const resistance = 0.4 - const newY = pullLength * resistance - - if (newY > 0) { - // Prevent default only when actually pulling down - e.preventDefault() - y.set(newY) - setCanRefresh(newY >= pullDistance) - } - } - - // Handle touch end - const handleTouchEnd = async () => { - if (!isPulling) return - - if (canRefresh) { - // Trigger haptic feedback if available - if (enableHaptics && navigator.vibrate) { - navigator.vibrate([20, 40, 20]) - } - - setRefreshing(true) - controls.start({ y: pullDistance * 0.4 }) - - try { - await onRefresh() - } finally { - setRefreshing(false) - controls.start({ y: 0 }) - } - } else { - controls.start({ y: 0 }) - } - - setIsPulling(false) - setCanRefresh(false) - } - - container.addEventListener('touchstart', handleTouchStart, { - passive: false, - }) - container.addEventListener('touchmove', handleTouchMove, { passive: false }) - container.addEventListener('touchend', handleTouchEnd) - - return () => { - container.removeEventListener('touchstart', handleTouchStart) - container.removeEventListener('touchmove', handleTouchMove) - container.removeEventListener('touchend', handleTouchEnd) - } - }, [isPulling, canRefresh, pullDistance, onRefresh, enableHaptics, controls, y]) - - return ( -
    - {/* Pull indicator */} - - - - -
    - {refreshing ? refreshingText : canRefresh ? releaseText : pullingText} -
    -
    - - {/* Content */} - {children} -
    - ) -} diff --git a/src/components/ui/SwipeableCard.test.tsx b/src/components/ui/SwipeableCard.test.tsx deleted file mode 100644 index d85c470c9..000000000 --- a/src/components/ui/SwipeableCard.test.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { fireEvent, render, screen } from '@testing-library/react' -import { describe, expect, it, vi } from 'vitest' -import { SwipeableCard } from './SwipeableCard' -import type { MouseEvent } from 'react' - -describe('SwipeableCard', () => { - it('passes click events to the click handler', () => { - const handleClick = vi.fn((event: MouseEvent) => { - expect(event.ctrlKey).toBe(true) - }) - - render(Open report) - - fireEvent.click(screen.getByText('Open report'), { ctrlKey: true }) - - expect(handleClick).toHaveBeenCalledTimes(1) - }) - - it('passes middle-click events to the auxiliary click handler', () => { - const handleAuxClick = vi.fn((event: MouseEvent) => { - expect(event.button).toBe(1) - }) - - render(Open report) - - fireEvent( - screen.getByText('Open report'), - new MouseEvent('auxclick', { bubbles: true, button: 1 }), - ) - - expect(handleAuxClick).toHaveBeenCalledTimes(1) - }) -}) diff --git a/src/components/ui/SwipeableCard.tsx b/src/components/ui/SwipeableCard.tsx deleted file mode 100644 index fe0c22ece..000000000 --- a/src/components/ui/SwipeableCard.tsx +++ /dev/null @@ -1,78 +0,0 @@ -'use client' - -import { motion, useMotionValue, useTransform } from 'framer-motion' -import { type PropsWithChildren, type MouseEvent, useState } from 'react' -import { cn } from '@/lib/utils' -import type { PanInfo } from 'framer-motion' - -interface Props extends PropsWithChildren { - onSwipeLeft?: () => void - onSwipeRight?: () => void - onClick?: (e: MouseEvent) => void - onAuxClick?: (e: MouseEvent) => void - className?: string - swipeThreshold?: number - enableHaptics?: boolean -} - -export function SwipeableCard(props: Props) { - const swipeThreshold = props.swipeThreshold ?? 100 - const enableHaptics = props.enableHaptics ?? true - - const [isSwiping, setIsSwiping] = useState(false) - const x = useMotionValue(0) - - const opacity = useTransform(x, [-swipeThreshold * 2, 0, swipeThreshold * 2], [0.5, 1, 0.5]) - - const rotate = useTransform(x, [-swipeThreshold * 2, 0, swipeThreshold * 2], [-8, 0, 8]) - - const handleDragEnd = (_event: unknown, _info: PanInfo) => { - const xOffset = x.get() - - x.set(0) - - if (xOffset < -swipeThreshold && props.onSwipeLeft) { - props.onSwipeLeft() - - if (enableHaptics && navigator.vibrate) { - navigator.vibrate(50) - } - } else if (xOffset > swipeThreshold && props.onSwipeRight) { - props.onSwipeRight() - - if (enableHaptics && navigator.vibrate) navigator.vibrate(50) - } - - setIsSwiping(false) - } - - const handleClick = (e: MouseEvent) => { - if (!isSwiping && props.onClick) props.onClick(e) - } - - const handleAuxClick = (e: MouseEvent) => { - if (!isSwiping && props.onAuxClick) props.onAuxClick(e) - } - - return ( - setIsSwiping(true)} - onDragEnd={handleDragEnd} - onClick={handleClick} - onAuxClick={handleAuxClick} - whileTap={{ scale: isSwiping ? 1 : 0.98 }} - > - {props.children} - - ) -} diff --git a/src/components/ui/VirtualScroller.tsx b/src/components/ui/VirtualScroller.tsx deleted file mode 100644 index d43976ad5..000000000 --- a/src/components/ui/VirtualScroller.tsx +++ /dev/null @@ -1,179 +0,0 @@ -'use client' - -import { useState, useRef, useEffect, useCallback } from 'react' -import { cn } from '@/lib/utils' -import type { ReactNode } from 'react' - -interface Props { - items: T[] - renderItem: (item: T, index: number) => ReactNode - itemHeight: number | ((item: T, index: number) => number) - className?: string - overscan?: number - scrollingDelay?: number - onEndReached?: () => void - endReachedThreshold?: number - getItemKey?: (item: T, index: number) => string | number -} - -export function VirtualScroller({ - items, - renderItem, - itemHeight, - className, - overscan = 3, - scrollingDelay = 150, - onEndReached, - endReachedThreshold = 500, - getItemKey = (_, index) => index, -}: Props) { - const [scrollTop, setScrollTop] = useState(0) - const [containerHeight, setContainerHeight] = useState(0) - const containerRef = useRef(null) - const scrollTimerRef = useRef(null) - const lastEndReachedRef = useRef(false) - - // Calculate item heights - const getItemHeight = useCallback( - (item: T, index: number) => { - return typeof itemHeight === 'function' ? itemHeight(item, index) : itemHeight - }, - [itemHeight], - ) - - // Calculate total content height - const totalHeight = items.reduce((total, item, index) => total + getItemHeight(item, index), 0) - - // Determine which items to render - const getVisibleItems = useCallback(() => { - if (!items.length) return { items: [], startIndex: 0, endIndex: 0 } - - let startIndex = 0 - let endIndex = 0 - let currentOffset = 0 - - // Find start index - for (let i = 0; i < items.length; i++) { - const height = getItemHeight(items[i], i) - if (currentOffset + height > scrollTop - overscan * height) { - startIndex = i - break - } - currentOffset += height - } - - // Ensure we don't start beyond the last item - startIndex = Math.min(startIndex, items.length - 1) - - // Find end index - currentOffset = 0 - for (let i = 0; i < items.length; i++) { - const height = getItemHeight(items[i], i) - currentOffset += height - if (currentOffset > scrollTop + containerHeight + overscan * height) { - endIndex = i - break - } - } - - // If we didn't find an end index, use the last item - if (endIndex === 0) { - endIndex = items.length - 1 - } - - return { - items: items.slice(startIndex, endIndex + 1), - startIndex, - endIndex, - } - }, [items, scrollTop, containerHeight, overscan, getItemHeight]) - - // Calculate offsets for each item - const getItemOffsets = useCallback(() => { - const offsets: number[] = [0] - let currentOffset = 0 - - for (let i = 0; i < items.length; i++) { - const height = getItemHeight(items[i], i) - currentOffset += height - offsets.push(currentOffset) - } - - return offsets - }, [items, getItemHeight]) - - const itemOffsets = getItemOffsets() - const { items: visibleItems, startIndex } = getVisibleItems() - - // Handle scroll events - const handleScroll = useCallback(() => { - if (!containerRef.current) return - - const { scrollTop, clientHeight, scrollHeight } = containerRef.current - setScrollTop(scrollTop) - setContainerHeight(clientHeight) - - // Handle scroll end detection - if (scrollTimerRef.current) { - clearTimeout(scrollTimerRef.current) - } - - scrollTimerRef.current = setTimeout(() => { - // This is where we would use isScrolling if needed - }, scrollingDelay) - - // Check if we're near the end to trigger onEndReached - const isNearEnd = scrollHeight - scrollTop - clientHeight < endReachedThreshold - if (isNearEnd && onEndReached && !lastEndReachedRef.current) { - lastEndReachedRef.current = true - onEndReached() - } else if (!isNearEnd) { - lastEndReachedRef.current = false - } - }, [scrollingDelay, endReachedThreshold, onEndReached]) - - // Initialize container height and add scroll listener - useEffect(() => { - const container = containerRef.current - if (!container) return - - setContainerHeight(container.clientHeight) - container.addEventListener('scroll', handleScroll) - - return () => { - container.removeEventListener('scroll', handleScroll) - if (scrollTimerRef.current) { - clearTimeout(scrollTimerRef.current) - } - } - }, [handleScroll]) - - return ( -
    -
    - {visibleItems.map((item, index) => { - const actualIndex = startIndex + index - const key = getItemKey(item, actualIndex) - return ( -
    - {renderItem(item, actualIndex)} -
    - ) - })} -
    -
    - ) -} diff --git a/src/components/ui/index.ts b/src/components/ui/index.ts index 839b8489c..2b21a41e6 100644 --- a/src/components/ui/index.ts +++ b/src/components/ui/index.ts @@ -28,15 +28,12 @@ export * from './PageSkeletonLoading' export * from './Pagination' export * from './PerformanceBadge' export * from './Popover' -export * from './ProgressiveImage' -export * from './PullToRefresh' export * from './RoleBadge' export * from './SegmentedTabs' export * from './SegmentedControl' export * from './Skeleton' export * from './SortableHeader' export * from './SuccessRateBar' -export * from './SwipeableCard' export * from './Switch' export * from './ThemeSelect' export * from './ThemeToggle' @@ -45,7 +42,6 @@ export * from './UnderlineTabBar' export * from './TrustLevelBadge' export * from './UserBadgeItem' export * from './VerifiedDeveloperBadge' -export * from './VirtualScroller' export * from './VoteButtons' // Collection of components diff --git a/src/lib/env.ts b/src/lib/env.ts index 47f68f09d..8b7d4ef90 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -27,7 +27,6 @@ interface Env { ENABLE_ANALYTICS: boolean ENABLE_KOFI_WIDGET: boolean ENABLE_SENTRY: boolean - ENABLE_V2_LISTINGS: boolean ENABLE_PATREON_VERIFICATION: boolean ENABLE_ANDROID_DOWNLOADS: boolean TURNSTILE_SITE_KEY: string @@ -89,7 +88,6 @@ export const env = { ENABLE_KOFI_WIDGET: process.env.NEXT_PUBLIC_ENABLE_KOFI_WIDGET === 'true', ENABLE_SENTRY: process.env.NEXT_PUBLIC_ENABLE_SENTRY === 'true', - ENABLE_V2_LISTINGS: process.env.NEXT_PUBLIC_ENABLE_V2_LISTINGS === 'true', ENABLE_PATREON_VERIFICATION: process.env.NEXT_PUBLIC_ENABLE_PATREON_VERIFICATION === 'true', ENABLE_ANDROID_DOWNLOADS: process.env.NEXT_PUBLIC_ENABLE_ANDROID_DOWNLOADS === 'true', TURNSTILE_SITE_KEY: process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY?.trim() ?? '', From 1f850d06d4acd2cd70a2623743e4c2bc5e9fe965 Mon Sep 17 00:00:00 2001 From: Producdevity Date: Thu, 11 Jun 2026 21:25:36 +0200 Subject: [PATCH 68/87] refactor: consolidate admin hooks under a common module and update imports --- .../components/AdminApiAccessPanel.tsx | 2 +- .../api-access/components/AdminKeyTable.tsx | 2 +- .../components/DeveloperApiAccessPanel.tsx | 29 +++++++----------- .../components/DeveloperKeyTable.tsx | 2 +- src/app/admin/approvals/page.tsx | 2 +- src/app/admin/audit-logs/page.tsx | 2 +- src/app/admin/badges/page.tsx | 2 +- src/app/admin/brands/page.tsx | 2 +- .../ProcessedReportsTable.tsx | 2 +- .../components/processed-reports/types.ts | 2 +- src/app/admin/custom-field-templates/page.tsx | 2 +- src/app/admin/devices/page.tsx | 2 +- src/app/admin/emulators/page.tsx | 2 +- src/app/admin/entitlements/page.tsx | 2 +- src/app/admin/games/approvals/page.tsx | 2 +- src/app/admin/games/page.tsx | 3 +- src/app/admin/gpus/page.tsx | 2 +- src/app/admin/listings/page.tsx | 3 +- src/app/admin/pc-listing-approvals/page.tsx | 2 +- src/app/admin/pc-processed-listings/page.tsx | 4 +-- src/app/admin/performance/page.tsx | 2 +- src/app/admin/permission-logs/page.tsx | 2 +- src/app/admin/permissions/page.tsx | 2 +- src/app/admin/processed-listings/page.tsx | 2 +- src/app/admin/reports/page.tsx | 2 +- src/app/admin/socs/page.tsx | 2 +- src/app/admin/systems/page.tsx | 2 +- src/app/admin/trust-logs/page.tsx | 2 +- src/app/admin/user-bans/page.tsx | 30 +++++++++---------- src/app/admin/users/page.tsx | 2 +- src/app/admin/verified-developers/page.tsx | 2 +- .../components/VoterSection.tsx | 8 +---- src/components/admin/AdminSearchFilters.tsx | 2 +- src/{app/admin/hooks => hooks/admin}/index.ts | 1 + .../hooks => hooks/admin}/useAdminFilters.ts | 0 .../admin}/useAdminTable.test.ts | 0 .../hooks => hooks/admin}/useAdminTable.ts | 0 .../admin}/useReviewRiskFilter.ts | 0 38 files changed, 58 insertions(+), 74 deletions(-) rename src/{app/admin/hooks => hooks/admin}/index.ts (67%) rename src/{app/admin/hooks => hooks/admin}/useAdminFilters.ts (100%) rename src/{app/admin/hooks => hooks/admin}/useAdminTable.test.ts (100%) rename src/{app/admin/hooks => hooks/admin}/useAdminTable.ts (100%) rename src/{app/admin/hooks => hooks/admin}/useReviewRiskFilter.ts (100%) diff --git a/src/app/admin/api-access/components/AdminApiAccessPanel.tsx b/src/app/admin/api-access/components/AdminApiAccessPanel.tsx index 93ac884e4..4ba7896d8 100644 --- a/src/app/admin/api-access/components/AdminApiAccessPanel.tsx +++ b/src/app/admin/api-access/components/AdminApiAccessPanel.tsx @@ -1,10 +1,10 @@ import { useState } from 'react' -import { useAdminTable } from '@/app/admin/hooks' import { AdminPageLayout, AdminSearchFilters, AdminStatsDisplay } from '@/components/admin' import { Button, Card, ColumnVisibilityControl, useConfirmDialog } from '@/components/ui' import { POLLING_INTERVALS } from '@/data/constants' import storageKeys from '@/data/storageKeys' import { useColumnVisibility } from '@/hooks' +import { useAdminTable } from '@/hooks/admin' import { type ColumnDefinition } from '@/hooks/useColumnVisibility' import { api } from '@/lib/api' import toast from '@/lib/toast' diff --git a/src/app/admin/api-access/components/AdminKeyTable.tsx b/src/app/admin/api-access/components/AdminKeyTable.tsx index 6f48f56ad..d6df47f63 100644 --- a/src/app/admin/api-access/components/AdminKeyTable.tsx +++ b/src/app/admin/api-access/components/AdminKeyTable.tsx @@ -1,4 +1,3 @@ -import { type UseAdminTableReturn } from '@/app/admin/hooks/useAdminTable' import { AdminTableContainer, AdminTableNoResults } from '@/components/admin' import { Badge, @@ -9,6 +8,7 @@ import { RefreshButton, SortableHeader, } from '@/components/ui' +import { type UseAdminTableReturn } from '@/hooks/admin' import { type UseColumnVisibilityReturn } from '@/hooks/useColumnVisibility' import { type ApiKeySortField } from '@/schemas/apiAccess' import { formatters, getLocale } from '@/utils/date' diff --git a/src/app/admin/api-access/components/DeveloperApiAccessPanel.tsx b/src/app/admin/api-access/components/DeveloperApiAccessPanel.tsx index 5decda5c8..02fa9d095 100644 --- a/src/app/admin/api-access/components/DeveloperApiAccessPanel.tsx +++ b/src/app/admin/api-access/components/DeveloperApiAccessPanel.tsx @@ -1,5 +1,4 @@ -import { useEffect, useMemo, useState } from 'react' -import { useAdminTable } from '@/app/admin/hooks' +import { useMemo, useState } from 'react' import { AdminPageLayout, AdminSearchFilters, AdminStatsDisplay } from '@/components/admin' import { Badge, @@ -13,6 +12,7 @@ import { import { API_KEY_LIMITS, POLLING_INTERVALS } from '@/data/constants' import storageKeys from '@/data/storageKeys' import { useColumnVisibility } from '@/hooks' +import { useAdminTable } from '@/hooks/admin' import { type ColumnDefinition } from '@/hooks/useColumnVisibility' import { api } from '@/lib/api' import toast from '@/lib/toast' @@ -84,35 +84,26 @@ export function DeveloperApiAccessPanel(props: Props) { const [dialogState, setDialogState] = useState(null) const [latestSecret, setLatestSecret] = useState(null) - useEffect(() => { - if (keys.length === 0) { - setSelectedKeyId(null) - return - } - if (!selectedKeyId || !keys.some((key) => key.id === selectedKeyId)) { - setSelectedKeyId(keys[0].id) - } - }, [keys, selectedKeyId]) - - const selectedKey = keys.find((key) => key.id === selectedKeyId) ?? null + const selectedKey = keys.find((key) => key.id === selectedKeyId) ?? keys[0] ?? null + const effectiveSelectedKeyId = selectedKey?.id ?? null const selectedKeyStatus = selectedKey ? getKeyStatusLabel(selectedKey) : null const monthUsageQuery = api.apiKeys.usage.useQuery( { - id: selectedKeyId ?? '', + id: effectiveSelectedKeyId ?? '', period: ApiUsagePeriod.MONTH, limit: API_KEY_LIMITS.USAGE_SERIES_LIMIT, }, - { enabled: Boolean(selectedKeyId) }, + { enabled: Boolean(effectiveSelectedKeyId) }, ) const weekUsageQuery = api.apiKeys.usage.useQuery( { - id: selectedKeyId ?? '', + id: effectiveSelectedKeyId ?? '', period: ApiUsagePeriod.WEEK, limit: API_KEY_LIMITS.USAGE_SERIES_LIMIT, }, - { enabled: Boolean(selectedKeyId) }, + { enabled: Boolean(effectiveSelectedKeyId) }, ) const monthlySummary = useMemo(() => { @@ -208,7 +199,7 @@ export function DeveloperApiAccessPanel(props: Props) { try { await revokeMutation.mutateAsync({ id: keyId }) await listQuery.refetch() - if (selectedKeyId === keyId) setSelectedKeyId(null) + if (effectiveSelectedKeyId === keyId) setSelectedKeyId(null) toast.success('API key revoked successfully.') } catch (error) { toast.error(getErrorMessage(error)) @@ -316,7 +307,7 @@ export function DeveloperApiAccessPanel(props: Props) { table={table} columnVisibility={columnVisibility} keys={keys} - selectedKeyId={selectedKeyId} + selectedKeyId={effectiveSelectedKeyId} includeRevoked={includeRevoked} isLoading={listQuery.isPending} pagination={pagination} diff --git a/src/app/admin/api-access/components/DeveloperKeyTable.tsx b/src/app/admin/api-access/components/DeveloperKeyTable.tsx index 9ff0c056f..658515f0e 100644 --- a/src/app/admin/api-access/components/DeveloperKeyTable.tsx +++ b/src/app/admin/api-access/components/DeveloperKeyTable.tsx @@ -1,4 +1,3 @@ -import { type UseAdminTableReturn } from '@/app/admin/hooks/useAdminTable' import { AdminTableContainer, AdminTableNoResults } from '@/components/admin' import { Badge, @@ -8,6 +7,7 @@ import { RefreshButton, SortableHeader, } from '@/components/ui' +import { type UseAdminTableReturn } from '@/hooks/admin' import { type UseColumnVisibilityReturn } from '@/hooks/useColumnVisibility' import { cn } from '@/lib/utils' import { type ApiKeySortField } from '@/schemas/apiAccess' diff --git a/src/app/admin/approvals/page.tsx b/src/app/admin/approvals/page.tsx index 2aec08b8e..7c02fc93e 100644 --- a/src/app/admin/approvals/page.tsx +++ b/src/app/admin/approvals/page.tsx @@ -5,7 +5,6 @@ import Link from 'next/link' import { useRouter } from 'next/navigation' import { useState } from 'react' import { isEmpty } from 'remeda' -import { useAdminTable, useReviewRiskFilter } from '@/app/admin/hooks' import { confirmBulkApproval } from '@/app/admin/utils' import { AdminErrorState, @@ -49,6 +48,7 @@ import { useColumnVisibility, type ColumnDefinition, } from '@/hooks' +import { useAdminTable, useReviewRiskFilter } from '@/hooks/admin' import analytics from '@/lib/analytics' import { api } from '@/lib/api' import { logger } from '@/lib/logger' diff --git a/src/app/admin/audit-logs/page.tsx b/src/app/admin/audit-logs/page.tsx index eb4922707..ea7066a38 100644 --- a/src/app/admin/audit-logs/page.tsx +++ b/src/app/admin/audit-logs/page.tsx @@ -4,7 +4,6 @@ import { Shield } from 'lucide-react' import Link from 'next/link' import { useState } from 'react' import { ADMIN_ROUTES } from '@/app/admin/config/routes' -import { useAdminTable } from '@/app/admin/hooks' import { AdminPageLayout, AdminStatsDisplay, @@ -24,6 +23,7 @@ import { } from '@/components/ui' import storageKeys from '@/data/storageKeys' import { useColumnVisibility, type ColumnDefinition } from '@/hooks' +import { useAdminTable } from '@/hooks/admin' import { api } from '@/lib/api' import { formatEnumLabel } from '@/utils/format' import { AuditAction, AuditEntityType } from '@orm' diff --git a/src/app/admin/badges/page.tsx b/src/app/admin/badges/page.tsx index dad2890ec..6b9edea14 100644 --- a/src/app/admin/badges/page.tsx +++ b/src/app/admin/badges/page.tsx @@ -2,7 +2,6 @@ import { Plus, Users } from 'lucide-react' import { useState } from 'react' -import { useAdminTable } from '@/app/admin/hooks' import { AdminPageLayout, AdminTableContainer, @@ -30,6 +29,7 @@ import { } from '@/components/ui' import storageKeys from '@/data/storageKeys' import { useColumnVisibility, type ColumnDefinition } from '@/hooks' +import { useAdminTable } from '@/hooks/admin' import { api } from '@/lib/api' import toast from '@/lib/toast' import { type RouterOutput } from '@/types/trpc' diff --git a/src/app/admin/brands/page.tsx b/src/app/admin/brands/page.tsx index bf3b9766b..c04e92d8b 100644 --- a/src/app/admin/brands/page.tsx +++ b/src/app/admin/brands/page.tsx @@ -2,7 +2,6 @@ import { useState } from 'react' import { isEmpty } from 'remeda' -import { useAdminTable } from '@/app/admin/hooks' import { AdminPageLayout, AdminTableContainer, @@ -21,6 +20,7 @@ import { } from '@/components/ui' import storageKeys from '@/data/storageKeys' import { useColumnVisibility, type ColumnDefinition } from '@/hooks' +import { useAdminTable } from '@/hooks/admin' import { api } from '@/lib/api' import toast from '@/lib/toast' import { type RouterInput } from '@/types/trpc' diff --git a/src/app/admin/components/processed-reports/ProcessedReportsTable.tsx b/src/app/admin/components/processed-reports/ProcessedReportsTable.tsx index 6ccaa01dd..1ad1cf639 100644 --- a/src/app/admin/components/processed-reports/ProcessedReportsTable.tsx +++ b/src/app/admin/components/processed-reports/ProcessedReportsTable.tsx @@ -2,7 +2,6 @@ import { ExternalLink } from 'lucide-react' import Link from 'next/link' -import { type UseAdminTableReturn } from '@/app/admin/hooks' import { EmulatorIcon, SystemIcon } from '@/components/icons' import { ApproveButton, @@ -18,6 +17,7 @@ import { ViewUserButton, } from '@/components/ui' import { type UseColumnVisibilityReturn } from '@/hooks' +import { type UseAdminTableReturn } from '@/hooks/admin' import analytics from '@/lib/analytics' import { getApprovalStatusColor } from '@/utils/badge-colors' import { ApprovalStatus } from '@orm' diff --git a/src/app/admin/components/processed-reports/types.ts b/src/app/admin/components/processed-reports/types.ts index 8794f5275..3010640f5 100644 --- a/src/app/admin/components/processed-reports/types.ts +++ b/src/app/admin/components/processed-reports/types.ts @@ -1,4 +1,4 @@ -import type { UseAdminTableReturn } from '@/app/admin/hooks' +import type { UseAdminTableReturn } from '@/hooks/admin' import type { ApprovalStatus, Role } from '@orm' import type { ReactNode } from 'react' diff --git a/src/app/admin/custom-field-templates/page.tsx b/src/app/admin/custom-field-templates/page.tsx index 86282aac9..b874bd77d 100644 --- a/src/app/admin/custom-field-templates/page.tsx +++ b/src/app/admin/custom-field-templates/page.tsx @@ -2,7 +2,6 @@ import { PlusCircle } from 'lucide-react' import { useMemo, useState } from 'react' -import { useAdminTable } from '@/app/admin/hooks' import { AdminPageLayout, AdminSearchFilters, @@ -10,6 +9,7 @@ import { AdminTableNoResults, } from '@/components/admin' import { Button, LoadingSpinner } from '@/components/ui' +import { useAdminTable } from '@/hooks/admin' import { api } from '@/lib/api' import { type RouterOutput } from '@/types/trpc' import CustomFieldTemplateFormModal from './components/CustomFieldTemplateFormModal' diff --git a/src/app/admin/devices/page.tsx b/src/app/admin/devices/page.tsx index 30590909d..9b8a1e786 100644 --- a/src/app/admin/devices/page.tsx +++ b/src/app/admin/devices/page.tsx @@ -2,7 +2,6 @@ import { useState } from 'react' import { isEmpty } from 'remeda' -import { useAdminTable } from '@/app/admin/hooks' import { AdminTableContainer, AdminSearchFilters, @@ -24,6 +23,7 @@ import { } from '@/components/ui' import storageKeys from '@/data/storageKeys' import { useColumnVisibility, type ColumnDefinition } from '@/hooks' +import { useAdminTable } from '@/hooks/admin' import { api } from '@/lib/api' import toast from '@/lib/toast' import { type RouterInput, type RouterOutput } from '@/types/trpc' diff --git a/src/app/admin/emulators/page.tsx b/src/app/admin/emulators/page.tsx index ec38efda3..a6b71c224 100644 --- a/src/app/admin/emulators/page.tsx +++ b/src/app/admin/emulators/page.tsx @@ -4,7 +4,6 @@ import { LinkIcon, UnlinkIcon } from 'lucide-react' import Link from 'next/link' import { useState } from 'react' import EmulatorModal from '@/app/admin/emulators/components/EmulatorModal' -import { useAdminTable } from '@/app/admin/hooks' import { AdminPageLayout, AdminSearchFilters, @@ -29,6 +28,7 @@ import { } from '@/components/ui' import storageKeys from '@/data/storageKeys' import { type ColumnDefinition, useColumnVisibility, useEmulatorLogos } from '@/hooks' +import { useAdminTable } from '@/hooks/admin' import { api } from '@/lib/api' import toast from '@/lib/toast' import { type RouterInput, type RouterOutput } from '@/types/trpc' diff --git a/src/app/admin/entitlements/page.tsx b/src/app/admin/entitlements/page.tsx index b73ccbc4e..84e0deda4 100644 --- a/src/app/admin/entitlements/page.tsx +++ b/src/app/admin/entitlements/page.tsx @@ -1,7 +1,6 @@ 'use client' import { useCallback, useMemo, useState } from 'react' -import { useAdminTable } from '@/app/admin/hooks/useAdminTable' import { AdminPageLayout, AdminSearchFilters, @@ -23,6 +22,7 @@ import { UndoButton, } from '@/components/ui' import storageKeys from '@/data/storageKeys' +import { useAdminTable } from '@/hooks/admin' import { useColumnVisibility } from '@/hooks/useColumnVisibility' import { api } from '@/lib/api' import toast from '@/lib/toast' diff --git a/src/app/admin/games/approvals/page.tsx b/src/app/admin/games/approvals/page.tsx index a7fcc896b..75a388e0a 100644 --- a/src/app/admin/games/approvals/page.tsx +++ b/src/app/admin/games/approvals/page.tsx @@ -5,7 +5,6 @@ import Link from 'next/link' import { useState } from 'react' import { isEmpty } from 'remeda' import ImagePreviewModal from '@/app/admin/components/ImagePreviewModal' -import { useAdminTable } from '@/app/admin/hooks' import { AdminPageLayout, AdminTableContainer, @@ -34,6 +33,7 @@ import { } from '@/components/ui' import storageKeys from '@/data/storageKeys' import { useLocalStorage, useColumnVisibility, type ColumnDefinition } from '@/hooks' +import { useAdminTable } from '@/hooks/admin' import analytics from '@/lib/analytics' import { api } from '@/lib/api' import toast from '@/lib/toast' diff --git a/src/app/admin/games/page.tsx b/src/app/admin/games/page.tsx index d86a889f3..8afd5cee7 100644 --- a/src/app/admin/games/page.tsx +++ b/src/app/admin/games/page.tsx @@ -7,8 +7,6 @@ import { useState } from 'react' import { isEmpty, isNullish } from 'remeda' import ImageIndicators from '@/app/admin/components/ImageIndicators' import ImagePreviewModal from '@/app/admin/components/ImagePreviewModal' -import { useAdminTable } from '@/app/admin/hooks' -import { useAdminFilters } from '@/app/admin/hooks/useAdminFilters' import { AdminPageLayout, AdminStatsDisplay, @@ -38,6 +36,7 @@ import { } from '@/components/ui' import storageKeys from '@/data/storageKeys' import { useColumnVisibility, type ColumnDefinition } from '@/hooks' +import { useAdminTable, useAdminFilters } from '@/hooks/admin' import { api } from '@/lib/api' import { logger } from '@/lib/logger' import toast from '@/lib/toast' diff --git a/src/app/admin/gpus/page.tsx b/src/app/admin/gpus/page.tsx index 179e5033f..24d2ee21f 100644 --- a/src/app/admin/gpus/page.tsx +++ b/src/app/admin/gpus/page.tsx @@ -3,7 +3,6 @@ import { Gpu } from 'lucide-react' import { useState } from 'react' import { isEmpty } from 'remeda' -import { useAdminTable } from '@/app/admin/hooks' import { AdminTableContainer, AdminSearchFilters, @@ -26,6 +25,7 @@ import { } from '@/components/ui' import storageKeys from '@/data/storageKeys' import { useColumnVisibility, type ColumnDefinition } from '@/hooks' +import { useAdminTable } from '@/hooks/admin' import { api } from '@/lib/api' import toast from '@/lib/toast' import { type RouterInput, type RouterOutput } from '@/types/trpc' diff --git a/src/app/admin/listings/page.tsx b/src/app/admin/listings/page.tsx index f63d7e14b..5ed387925 100644 --- a/src/app/admin/listings/page.tsx +++ b/src/app/admin/listings/page.tsx @@ -4,8 +4,6 @@ import Image from 'next/image' import Link from 'next/link' import { useState } from 'react' import { isEmpty } from 'remeda' -import { useAdminTable } from '@/app/admin/hooks' -import { useAdminFilters } from '@/app/admin/hooks/useAdminFilters' import { AdminPageLayout, AdminTableContainer, @@ -36,6 +34,7 @@ import { useColumnVisibility, type ColumnDefinition, } from '@/hooks' +import { useAdminTable, useAdminFilters } from '@/hooks/admin' import analytics from '@/lib/analytics' import { api } from '@/lib/api' import { type RouterInput, type RouterOutput } from '@/types/trpc' diff --git a/src/app/admin/pc-listing-approvals/page.tsx b/src/app/admin/pc-listing-approvals/page.tsx index 37322c9c6..01dd4d80f 100644 --- a/src/app/admin/pc-listing-approvals/page.tsx +++ b/src/app/admin/pc-listing-approvals/page.tsx @@ -6,7 +6,6 @@ import Link from 'next/link' import { useRouter } from 'next/navigation' import { useState } from 'react' import { isEmpty } from 'remeda' -import { useAdminTable, useReviewRiskFilter } from '@/app/admin/hooks' import { confirmBulkApproval } from '@/app/admin/utils' import { AdminErrorState, @@ -51,6 +50,7 @@ import { useColumnVisibility, type ColumnDefinition, } from '@/hooks' +import { useAdminTable, useReviewRiskFilter } from '@/hooks/admin' import analytics from '@/lib/analytics' import { api } from '@/lib/api' import { logger } from '@/lib/logger' diff --git a/src/app/admin/pc-processed-listings/page.tsx b/src/app/admin/pc-processed-listings/page.tsx index c32b0abbd..18c9ba685 100644 --- a/src/app/admin/pc-processed-listings/page.tsx +++ b/src/app/admin/pc-processed-listings/page.tsx @@ -6,8 +6,8 @@ import { ProcessedReportsAdminPage, type ProcessedReportHardwareColumn, } from '@/app/admin/components/processed-reports' -import { useAdminTable } from '@/app/admin/hooks' import storageKeys from '@/data/storageKeys' +import { useAdminTable } from '@/hooks/admin' import { api } from '@/lib/api' import { logger } from '@/lib/logger' import toast from '@/lib/toast' @@ -40,7 +40,7 @@ const PC_HARDWARE_COLUMNS: ProcessedReportHardwareColumn< label: 'CPU', sortField: 'cpu', defaultVisible: true, - render: (listing) => `${listing.cpu.brand.name} ${listing.cpu.modelName}`, + render: (listing) => `${listing.cpu.brand.name} ${listing.cpu.modelName}`, // TODO: replace with render: (listing) => getCpuLabel(listing.cpu), }, { key: 'gpu', diff --git a/src/app/admin/performance/page.tsx b/src/app/admin/performance/page.tsx index d27bcc503..23bb0e9a9 100644 --- a/src/app/admin/performance/page.tsx +++ b/src/app/admin/performance/page.tsx @@ -1,7 +1,6 @@ 'use client' import { useState } from 'react' -import { useAdminTable } from '@/app/admin/hooks' import { AdminPageLayout, AdminStatsDisplay, @@ -21,6 +20,7 @@ import { } from '@/components/ui' import storageKeys from '@/data/storageKeys' import { useColumnVisibility, type ColumnDefinition } from '@/hooks' +import { useAdminTable } from '@/hooks/admin' import { api } from '@/lib/api' import toast from '@/lib/toast' import { type RouterInput } from '@/types/trpc' diff --git a/src/app/admin/permission-logs/page.tsx b/src/app/admin/permission-logs/page.tsx index faa6e19fd..edcd3d272 100644 --- a/src/app/admin/permission-logs/page.tsx +++ b/src/app/admin/permission-logs/page.tsx @@ -4,7 +4,6 @@ import { FileText } from 'lucide-react' import Link from 'next/link' import { useState } from 'react' import { ADMIN_ROUTES } from '@/app/admin/config/routes' -import { useAdminTable } from '@/app/admin/hooks' import { AdminPageLayout, AdminStatsDisplay, @@ -24,6 +23,7 @@ import { } from '@/components/ui' import storageKeys from '@/data/storageKeys' import { useColumnVisibility, type ColumnDefinition } from '@/hooks' +import { useAdminTable } from '@/hooks/admin' import { api } from '@/lib/api' import { PermissionActionType, Role } from '@orm' diff --git a/src/app/admin/permissions/page.tsx b/src/app/admin/permissions/page.tsx index 0971d756a..f896f4b41 100644 --- a/src/app/admin/permissions/page.tsx +++ b/src/app/admin/permissions/page.tsx @@ -1,7 +1,6 @@ 'use client' import { useState } from 'react' -import { useAdminTable } from '@/app/admin/hooks' import { AdminPageLayout, AdminStatsDisplay, @@ -25,6 +24,7 @@ import { } from '@/components/ui' import storageKeys from '@/data/storageKeys' import { useColumnVisibility, type ColumnDefinition } from '@/hooks' +import { useAdminTable } from '@/hooks/admin' import { api } from '@/lib/api' import { RolePermissionMatrix } from '@/lib/dynamic-imports' import toast from '@/lib/toast' diff --git a/src/app/admin/processed-listings/page.tsx b/src/app/admin/processed-listings/page.tsx index 2d8f3d352..971bddaad 100644 --- a/src/app/admin/processed-listings/page.tsx +++ b/src/app/admin/processed-listings/page.tsx @@ -6,8 +6,8 @@ import { ProcessedReportsAdminPage, type ProcessedReportHardwareColumn, } from '@/app/admin/components/processed-reports' -import { useAdminTable } from '@/app/admin/hooks' import storageKeys from '@/data/storageKeys' +import { useAdminTable } from '@/hooks/admin' import { api } from '@/lib/api' import { logger } from '@/lib/logger' import toast from '@/lib/toast' diff --git a/src/app/admin/reports/page.tsx b/src/app/admin/reports/page.tsx index 4345ee907..75a8f7eae 100644 --- a/src/app/admin/reports/page.tsx +++ b/src/app/admin/reports/page.tsx @@ -2,7 +2,6 @@ import Link from 'next/link' import { useState } from 'react' -import { useAdminTable } from '@/app/admin/hooks' import { AdminPageLayout, AdminStatsDisplay, @@ -27,6 +26,7 @@ import { } from '@/components/ui' import storageKeys from '@/data/storageKeys' import { useColumnVisibility, type ColumnDefinition } from '@/hooks' +import { useAdminTable } from '@/hooks/admin' import { api } from '@/lib/api' import toast from '@/lib/toast' import { type ReportReasonType, type ReportStatusType } from '@/schemas/listingReport' diff --git a/src/app/admin/socs/page.tsx b/src/app/admin/socs/page.tsx index 70f56a8e7..9f1adf246 100644 --- a/src/app/admin/socs/page.tsx +++ b/src/app/admin/socs/page.tsx @@ -3,7 +3,6 @@ import { Cpu } from 'lucide-react' import { useState } from 'react' import { isEmpty } from 'remeda' -import { useAdminTable } from '@/app/admin/hooks' import { AdminPageLayout, AdminTableContainer, @@ -24,6 +23,7 @@ import { } from '@/components/ui' import storageKeys from '@/data/storageKeys' import { useColumnVisibility, type ColumnDefinition } from '@/hooks' +import { useAdminTable } from '@/hooks/admin' import { api } from '@/lib/api' import toast from '@/lib/toast' import { type RouterInput, type RouterOutput } from '@/types/trpc' diff --git a/src/app/admin/systems/page.tsx b/src/app/admin/systems/page.tsx index 7de133caf..6db3f806b 100644 --- a/src/app/admin/systems/page.tsx +++ b/src/app/admin/systems/page.tsx @@ -2,7 +2,6 @@ import { useState } from 'react' import { isEmpty } from 'remeda' -import { useAdminTable } from '@/app/admin/hooks' import { AdminTableContainer, AdminSearchFilters, @@ -21,6 +20,7 @@ import { } from '@/components/ui' import storageKeys from '@/data/storageKeys' import { useColumnVisibility, type ColumnDefinition } from '@/hooks' +import { useAdminTable } from '@/hooks/admin' import { api } from '@/lib/api' import toast from '@/lib/toast' import { type RouterInput } from '@/types/trpc' diff --git a/src/app/admin/trust-logs/page.tsx b/src/app/admin/trust-logs/page.tsx index f35b166eb..a36c40cd1 100644 --- a/src/app/admin/trust-logs/page.tsx +++ b/src/app/admin/trust-logs/page.tsx @@ -4,7 +4,6 @@ import { Shield, Calendar, Search } from 'lucide-react' import Link from 'next/link' import { useState } from 'react' import { isEmpty } from 'remeda' -import { useAdminTable } from '@/app/admin/hooks' import { AdminErrorState, AdminPageLayout, @@ -25,6 +24,7 @@ import { } from '@/components/ui' import storageKeys from '@/data/storageKeys' import { useColumnVisibility, type ColumnDefinition } from '@/hooks' +import { useAdminTable } from '@/hooks/admin' import { api } from '@/lib/api' import toast from '@/lib/toast' import { TRUST_ACTIONS } from '@/lib/trust/config' diff --git a/src/app/admin/user-bans/page.tsx b/src/app/admin/user-bans/page.tsx index 99f394512..864676f13 100644 --- a/src/app/admin/user-bans/page.tsx +++ b/src/app/admin/user-bans/page.tsx @@ -2,8 +2,7 @@ import { useUser } from '@clerk/nextjs' import { useSearchParams, useRouter } from 'next/navigation' -import { useState, useEffect } from 'react' -import { useAdminTable } from '@/app/admin/hooks' +import { useState } from 'react' import { AdminPageLayout, AdminStatsDisplay, @@ -27,6 +26,7 @@ import { import { ViewButton, DeleteButton, UndoButton } from '@/components/ui/table-buttons' import storageKeys from '@/data/storageKeys' import { useColumnVisibility, type ColumnDefinition } from '@/hooks' +import { useAdminTable } from '@/hooks/admin' import { api } from '@/lib/api' import toast from '@/lib/toast' import { type RouterInput } from '@/types/trpc' @@ -84,17 +84,17 @@ function AdminUserBansPage() { userId: undefined, }) - // Handle query params to auto-open modal - useEffect(() => { - const action = searchParams.get('action') - const userId = searchParams.get('userId') + const queryBanUserId = + searchParams.get('action') === 'ban' ? (searchParams.get('userId') ?? undefined) : undefined + const displayedCreateBanModal: CreateBanModalState = { + isOpen: createBanModal.isOpen || Boolean(queryBanUserId), + userId: createBanModal.userId ?? queryBanUserId, + } - if (action === 'ban' && userId) { - setCreateBanModal({ isOpen: true, userId }) - // Clean up URL after opening modal - router.replace('/admin/user-bans') - } - }, [searchParams, router]) + const closeCreateBanModal = () => { + setCreateBanModal({ isOpen: false }) + if (queryBanUserId) router.replace('/admin/user-bans') + } // Get current user data to check permissions const currentUserQuery = api.users.me.useQuery(undefined, { @@ -416,9 +416,9 @@ function AdminUserBansPage() { /> setCreateBanModal({ isOpen: false })} - userId={createBanModal.userId} + isOpen={displayedCreateBanModal.isOpen} + onClose={closeCreateBanModal} + userId={displayedCreateBanModal.userId} onSuccess={() => { utils.userBans.get.invalidate().catch(console.error) utils.userBans.stats.invalidate().catch(console.error) diff --git a/src/app/admin/users/page.tsx b/src/app/admin/users/page.tsx index 896f99c74..c77e82510 100644 --- a/src/app/admin/users/page.tsx +++ b/src/app/admin/users/page.tsx @@ -4,7 +4,6 @@ import { ShieldUser, User, Award, Gavel } from 'lucide-react' import { useSearchParams, useRouter } from 'next/navigation' import { useState } from 'react' import { isEmpty } from 'remeda' -import { useAdminTable } from '@/app/admin/hooks' import { AdminPageLayout, AdminStatsDisplay, @@ -26,6 +25,7 @@ import { } from '@/components/ui' import storageKeys from '@/data/storageKeys' import { useColumnVisibility, type ColumnDefinition } from '@/hooks' +import { useAdminTable } from '@/hooks/admin' import { api } from '@/lib/api' import toast from '@/lib/toast' import { type RouterOutput, type RouterInput } from '@/types/trpc' diff --git a/src/app/admin/verified-developers/page.tsx b/src/app/admin/verified-developers/page.tsx index 5e6062a38..88c67e48a 100644 --- a/src/app/admin/verified-developers/page.tsx +++ b/src/app/admin/verified-developers/page.tsx @@ -3,7 +3,6 @@ import { Shield, UserCheck } from 'lucide-react' import Image from 'next/image' import { useState } from 'react' -import { useAdminTable } from '@/app/admin/hooks' import { AdminPageLayout, AdminTableContainer, @@ -26,6 +25,7 @@ import { } from '@/components/ui' import storageKeys from '@/data/storageKeys' import { useColumnVisibility, type ColumnDefinition } from '@/hooks' +import { useAdminTable } from '@/hooks/admin' import { api } from '@/lib/api' import toast from '@/lib/toast' import getErrorMessage from '@/utils/getErrorMessage' diff --git a/src/app/admin/vote-investigation/components/VoterSection.tsx b/src/app/admin/vote-investigation/components/VoterSection.tsx index 3e0ce0a95..971e4ce58 100644 --- a/src/app/admin/vote-investigation/components/VoterSection.tsx +++ b/src/app/admin/vote-investigation/components/VoterSection.tsx @@ -14,7 +14,6 @@ import { import Link from 'next/link' import { type ChangeEvent, useEffect, useRef, useState } from 'react' import { ADMIN_ROUTES } from '@/app/admin/config/routes' -import { useAdminTable } from '@/app/admin/hooks/useAdminTable' import { AdminTableContainer } from '@/components/admin' import { Badge, @@ -29,6 +28,7 @@ import { useConfirmDialog, } from '@/components/ui' import storageKeys from '@/data/storageKeys' +import { useAdminTable } from '@/hooks/admin' import { useColumnVisibility, type ColumnDefinition } from '@/hooks/useColumnVisibility' import { api } from '@/lib/api' import toast from '@/lib/toast' @@ -183,12 +183,6 @@ function VoterSection() { return () => document.removeEventListener('mousedown', handleClickOutside) }, []) - useEffect(() => { - if (userSearchQuery.data && userSearch.length >= 2 && !selectedUser) { - setShowDropdown(true) - } - }, [userSearchQuery.data, userSearch, selectedUser]) - const handleChangeUserSearch = (ev: ChangeEvent) => { setUserSearch(ev.target.value) if (!selectedUser && ev.target.value.length >= 2) { diff --git a/src/components/admin/AdminSearchFilters.tsx b/src/components/admin/AdminSearchFilters.tsx index 74d43d404..6f5c66773 100644 --- a/src/components/admin/AdminSearchFilters.tsx +++ b/src/components/admin/AdminSearchFilters.tsx @@ -1,7 +1,7 @@ import { Search } from 'lucide-react' import { type PropsWithChildren } from 'react' -import { type UseAdminTableReturn } from '@/app/admin/hooks/useAdminTable' import { ClearButton, Input } from '@/components/ui' +import { type UseAdminTableReturn } from '@/hooks/admin' interface Props extends PropsWithChildren { searchPlaceholder?: string diff --git a/src/app/admin/hooks/index.ts b/src/hooks/admin/index.ts similarity index 67% rename from src/app/admin/hooks/index.ts rename to src/hooks/admin/index.ts index f2f30bc19..dacab84a9 100644 --- a/src/app/admin/hooks/index.ts +++ b/src/hooks/admin/index.ts @@ -1,2 +1,3 @@ +export * from './useAdminFilters' export * from './useAdminTable' export * from './useReviewRiskFilter' diff --git a/src/app/admin/hooks/useAdminFilters.ts b/src/hooks/admin/useAdminFilters.ts similarity index 100% rename from src/app/admin/hooks/useAdminFilters.ts rename to src/hooks/admin/useAdminFilters.ts diff --git a/src/app/admin/hooks/useAdminTable.test.ts b/src/hooks/admin/useAdminTable.test.ts similarity index 100% rename from src/app/admin/hooks/useAdminTable.test.ts rename to src/hooks/admin/useAdminTable.test.ts diff --git a/src/app/admin/hooks/useAdminTable.ts b/src/hooks/admin/useAdminTable.ts similarity index 100% rename from src/app/admin/hooks/useAdminTable.ts rename to src/hooks/admin/useAdminTable.ts diff --git a/src/app/admin/hooks/useReviewRiskFilter.ts b/src/hooks/admin/useReviewRiskFilter.ts similarity index 100% rename from src/app/admin/hooks/useReviewRiskFilter.ts rename to src/hooks/admin/useReviewRiskFilter.ts From a36606dd8dd5ee96faa8db6fe5005b8be2a1d4c2 Mon Sep 17 00:00:00 2001 From: Producdevity Date: Thu, 11 Jun 2026 21:35:43 +0200 Subject: [PATCH 69/87] refactor: centralize remote image patterns and proxy logic, update related utilities and tests --- next.config.ts | 13 +----- src/components/ui/OptimizedImage.tsx | 15 +------ src/data/image-hosts.ts | 17 ++++++++ src/utils/getImageUrl.test.ts | 32 +++++++++++++- src/utils/getImageUrl.ts | 4 +- src/utils/getSafePlaceholderImageUrl.test.ts | 6 +-- src/utils/getSafePlaceholderImageUrl.ts | 3 +- src/utils/imageProxy.test.ts | 44 ++++++++++++++++++++ src/utils/imageProxy.ts | 34 +++++++++++++++ 9 files changed, 135 insertions(+), 33 deletions(-) create mode 100644 src/data/image-hosts.ts create mode 100644 src/utils/imageProxy.test.ts create mode 100644 src/utils/imageProxy.ts diff --git a/next.config.ts b/next.config.ts index 9069d8859..dfc050091 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,5 +1,6 @@ import NextBundleAnalyzer from '@next/bundle-analyzer' import { withSentryConfig } from '@sentry/nextjs' +import { NEXT_IMAGE_REMOTE_PATTERNS } from '@/data/image-hosts' import type { NextConfig } from 'next' import type { Configuration as WebpackConfiguration } from 'webpack' @@ -174,17 +175,7 @@ const nextConfig: NextConfig = { { pathname: '/placeholder/**' }, { pathname: '/assets/android-app/**' }, ], - remotePatterns: [ - { protocol: 'https', hostname: 'placehold.co', pathname: '/**' }, - { protocol: 'https', hostname: 'media.rawg.io', pathname: '/**' }, - { protocol: 'https', hostname: '*.clerk.com', pathname: '/**' }, - { protocol: 'https', hostname: '*.clerk.accounts.dev', pathname: '/**' }, - { protocol: 'https', hostname: 'cdn.thegamesdb.net', pathname: '/**' }, - { protocol: 'https', hostname: 'images.igdb.com', pathname: '/**' }, - { protocol: 'https', hostname: 'assets.nintendo.com', pathname: '/**' }, - { protocol: 'https', hostname: 'storage.ko-fi.com', pathname: '/**' }, - { protocol: 'https', hostname: 'ko-fi.com', pathname: '/**' }, - ], + remotePatterns: NEXT_IMAGE_REMOTE_PATTERNS, }, allowedDevOrigins: ['dev.emuready.com', '127.0.0.1'], diff --git a/src/components/ui/OptimizedImage.tsx b/src/components/ui/OptimizedImage.tsx index b492a454c..8ea5e510b 100644 --- a/src/components/ui/OptimizedImage.tsx +++ b/src/components/ui/OptimizedImage.tsx @@ -4,6 +4,7 @@ import Image, { type ImageProps } from 'next/image' import { useState } from 'react' import { LoadingSpinner } from '@/components/ui' import { cn } from '@/lib/utils' +import { resolveImageProxyUrl } from '@/utils/imageProxy' type ObjectFit = 'contain' | 'cover' | 'fill' | 'none' | 'scale-down' @@ -39,19 +40,7 @@ export function OptimizedImage(props: Props) { const resolveSrc = (): string => { if (error) return fallbackSrc - - const shouldProxy = props.useProxy ?? true - const src = props.src - - if (!shouldProxy) return src - - if (src.startsWith('/api/proxy-image')) return src - - if (src.startsWith('http://') || src.startsWith('https://')) { - return `/api/proxy-image?url=${encodeURIComponent(src)}` - } - - return src + return resolveImageProxyUrl(props.src, props.useProxy) } const handleError = () => { diff --git a/src/data/image-hosts.ts b/src/data/image-hosts.ts new file mode 100644 index 000000000..08e058344 --- /dev/null +++ b/src/data/image-hosts.ts @@ -0,0 +1,17 @@ +export const NEXT_IMAGE_REMOTE_HOST_PATTERNS = [ + 'placehold.co', + 'media.rawg.io', + '*.clerk.com', + '*.clerk.accounts.dev', + 'cdn.thegamesdb.net', + 'images.igdb.com', + 'assets.nintendo.com', + 'storage.ko-fi.com', + 'ko-fi.com', +] as const + +export const NEXT_IMAGE_REMOTE_PATTERNS = NEXT_IMAGE_REMOTE_HOST_PATTERNS.map((hostname) => ({ + protocol: 'https' as const, + hostname, + pathname: '/**', +})) diff --git a/src/utils/getImageUrl.test.ts b/src/utils/getImageUrl.test.ts index b200939b3..e0daa4d5b 100644 --- a/src/utils/getImageUrl.test.ts +++ b/src/utils/getImageUrl.test.ts @@ -34,20 +34,48 @@ describe('getImageUrl', () => { expect(result).toBe(localPath) }) - it('returns a proxied url when the url starts with http', () => { + it('returns a proxied url when an http url cannot be optimized directly', () => { const httpUrl = 'http://example.com/image.jpg' const result = getImageUrl(httpUrl) expect(result).toBe(`/api/proxy-image?url=${encodeURIComponent(httpUrl)}`) }) - it('returns a proxied url when the url starts with https', () => { + it('returns a proxied url when an https host is not configured for next/image', () => { const httpsUrl = 'https://example.com/image.jpg' const result = getImageUrl(httpsUrl) expect(result).toBe(`/api/proxy-image?url=${encodeURIComponent(httpsUrl)}`) }) + it('returns a configured next/image remote url directly', () => { + const imageUrl = 'https://images.igdb.com/igdb/image/upload/t_cover_big/game.jpg' + const result = getImageUrl(imageUrl) + + expect(result).toBe(imageUrl) + }) + + it('supports wildcard configured next/image remote hosts', () => { + const imageUrl = 'https://img.clerk.com/avatar.png' + const result = getImageUrl(imageUrl) + + expect(result).toBe(imageUrl) + }) + + it('can force proxying for a configured next/image remote url', () => { + const imageUrl = 'https://images.igdb.com/igdb/image/upload/t_cover_big/game.jpg' + const result = getImageUrl(imageUrl, null, { useProxy: true }) + + expect(result).toBe(`/api/proxy-image?url=${encodeURIComponent(imageUrl)}`) + }) + + it('can force direct usage for an unknown remote url', () => { + const imageUrl = 'https://example.com/image.jpg' + const result = getImageUrl(imageUrl, null, { useProxy: false }) + + expect(result).toBe(imageUrl) + }) + it('returns a placeholder image when the url format is invalid', () => { const invalidUrl = 'invalid-url-format' const result = getImageUrl(invalidUrl, 'Invalid URL Game') diff --git a/src/utils/getImageUrl.ts b/src/utils/getImageUrl.ts index 1640f2af3..058f3486f 100644 --- a/src/utils/getImageUrl.ts +++ b/src/utils/getImageUrl.ts @@ -1,5 +1,6 @@ import { type Nullable } from '@/types/utils' import getSafePlaceholderImageUrl from './getSafePlaceholderImageUrl' +import { resolveImageProxyUrl } from './imageProxy' type Options = { useProxy?: boolean @@ -12,7 +13,6 @@ type Options = { * @returns A valid image URL or a placeholder if the URL is invalid. */ function getImageUrl(url: Nullable, title?: string | null, opts?: Options): string { - const useProxy = opts?.useProxy ?? true if (!url) return getSafePlaceholderImageUrl(title) if (url.startsWith('/') && !url.startsWith('//')) { @@ -20,7 +20,7 @@ function getImageUrl(url: Nullable, title?: string | null, opts?: Option } if (url.startsWith('http://') || url.startsWith('https://')) { - return useProxy ? `/api/proxy-image?url=${encodeURIComponent(url)}` : url + return resolveImageProxyUrl(url, opts?.useProxy) } return getSafePlaceholderImageUrl(title ?? null) // Invalid URL format, use placeholder diff --git a/src/utils/getSafePlaceholderImageUrl.test.ts b/src/utils/getSafePlaceholderImageUrl.test.ts index ea08dc0bf..5bb269052 100644 --- a/src/utils/getSafePlaceholderImageUrl.test.ts +++ b/src/utils/getSafePlaceholderImageUrl.test.ts @@ -6,7 +6,7 @@ describe('getSafePlaceholderImageUrl', () => { const title = 'Game Title' const result = getSafePlaceholderImageUrl(title) - expect(result).toContain('/api/proxy-image?url=https://placehold.co/') + expect(result).toContain('https://placehold.co/') expect(result).toContain(encodeURIComponent(title)) }) @@ -14,10 +14,10 @@ describe('getSafePlaceholderImageUrl', () => { const resultNull = getSafePlaceholderImageUrl(null) const resultUndefined = getSafePlaceholderImageUrl(undefined) - expect(resultNull).toContain('/api/proxy-image?url=https://placehold.co/') + expect(resultNull).toContain('https://placehold.co/') expect(resultNull).toContain(encodeURIComponent('')) - expect(resultUndefined).toContain('/api/proxy-image?url=https://placehold.co/') + expect(resultUndefined).toContain('https://placehold.co/') expect(resultUndefined).toContain(encodeURIComponent('')) }) diff --git a/src/utils/getSafePlaceholderImageUrl.ts b/src/utils/getSafePlaceholderImageUrl.ts index 1c765a4bd..7bb3ed68b 100644 --- a/src/utils/getSafePlaceholderImageUrl.ts +++ b/src/utils/getSafePlaceholderImageUrl.ts @@ -11,8 +11,7 @@ function getSafePlaceholderImageUrl(title?: string | null): string { .substring(0, 15) // limit length .trimEnd() // ensure we do not end with a space after truncation - // Directly encode the string to prevent any potential XSS in URL - return `/api/proxy-image?url=https://placehold.co/400x300/9ca3af/1e293b?text=${encodeURIComponent(safeTitle)}` + return `https://placehold.co/400x300/9ca3af/1e293b?text=${encodeURIComponent(safeTitle)}` } export default getSafePlaceholderImageUrl diff --git a/src/utils/imageProxy.test.ts b/src/utils/imageProxy.test.ts new file mode 100644 index 000000000..f17290ba3 --- /dev/null +++ b/src/utils/imageProxy.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest' +import { isKnownNextImageRemoteUrl, resolveImageProxyUrl, shouldProxyImageUrl } from './imageProxy' + +describe('imageProxy', () => { + it('uses configured next/image remote hosts directly', () => { + const src = 'https://media.rawg.io/media/games/example.jpg' + + expect(isKnownNextImageRemoteUrl(src)).toBe(true) + expect(shouldProxyImageUrl(src)).toBe(false) + expect(resolveImageProxyUrl(src)).toBe(src) + }) + + it('supports configured wildcard remote hosts', () => { + const src = 'https://img.clerk.com/avatar.png' + + expect(isKnownNextImageRemoteUrl(src)).toBe(true) + expect(shouldProxyImageUrl(src)).toBe(false) + }) + + it('proxies unknown remote hosts by default', () => { + const src = 'https://example.com/image.jpg' + + expect(isKnownNextImageRemoteUrl(src)).toBe(false) + expect(shouldProxyImageUrl(src)).toBe(true) + expect(resolveImageProxyUrl(src)).toBe(`/api/proxy-image?url=${encodeURIComponent(src)}`) + }) + + it('proxies http URLs because next/image remote patterns only allow https hosts', () => { + expect(shouldProxyImageUrl('http://media.rawg.io/media/games/example.jpg')).toBe(true) + }) + + it('respects explicit proxy overrides', () => { + const knownSrc = 'https://images.igdb.com/igdb/image/upload/t_cover_big/game.jpg' + const unknownSrc = 'https://example.com/image.jpg' + + expect(shouldProxyImageUrl(knownSrc, true)).toBe(true) + expect(shouldProxyImageUrl(unknownSrc, false)).toBe(false) + }) + + it('never proxies local paths', () => { + expect(shouldProxyImageUrl('/uploads/games/image.jpg', true)).toBe(false) + expect(resolveImageProxyUrl('/uploads/games/image.jpg', true)).toBe('/uploads/games/image.jpg') + }) +}) diff --git a/src/utils/imageProxy.ts b/src/utils/imageProxy.ts new file mode 100644 index 000000000..691fd25ad --- /dev/null +++ b/src/utils/imageProxy.ts @@ -0,0 +1,34 @@ +import { NEXT_IMAGE_REMOTE_HOST_PATTERNS } from '@/data/image-hosts' + +function matchesHostPattern(hostname: string, pattern: string): boolean { + if (!pattern.startsWith('*.')) return hostname === pattern + + const parentHost = pattern.slice(2) + return hostname.endsWith(`.${parentHost}`) +} + +export function isKnownNextImageRemoteUrl(src: string): boolean { + try { + const url = new URL(src) + if (url.protocol !== 'https:') return false + + return NEXT_IMAGE_REMOTE_HOST_PATTERNS.some((pattern) => + matchesHostPattern(url.hostname, pattern), + ) + } catch { + return false + } +} + +export function shouldProxyImageUrl(src: string, useProxy?: boolean): boolean { + if (src.startsWith('/') && !src.startsWith('//')) return false + if (!src.startsWith('http://') && !src.startsWith('https://')) return false + + if (useProxy !== undefined) return useProxy + return !isKnownNextImageRemoteUrl(src) +} + +export function resolveImageProxyUrl(src: string, useProxy?: boolean): string { + if (!shouldProxyImageUrl(src, useProxy)) return src + return `/api/proxy-image?url=${encodeURIComponent(src)}` +} From 19f30c7a241473855a8a707656a5dfcca82086b9 Mon Sep 17 00:00:00 2001 From: Producdevity Date: Sat, 13 Jun 2026 11:56:37 +0200 Subject: [PATCH 70/87] refactor: create a feature/domain-based architecture, started with CPU and GPU entities. --- AGENTS.md | 46 +- docs/MOBILE_API.md | 136 +- eslint.config.mjs | 82 +- package.json | 2 +- public/api-docs/mobile-openapi.json | 1319 ++++++++++++++--- src/app/admin/approvals/page.tsx | 1 - src/app/admin/audit-logs/page.tsx | 33 +- src/app/admin/cpus/components/CpuModal.tsx | 148 -- .../admin/cpus/components/CpuViewModal.tsx | 49 - src/app/admin/cpus/page.tsx | 302 +--- src/app/admin/data.ts | 8 +- .../admin/devices/components/DeviceModal.tsx | 232 ++- src/app/admin/gpus/components/GpuModal.tsx | 152 -- src/app/admin/gpus/page.tsx | 302 +--- .../[id]/edit/components/ListingEditForm.tsx | 13 +- src/app/admin/pc-listing-approvals/page.tsx | 7 +- src/app/admin/pc-processed-listings/page.tsx | 10 +- src/app/games/[id]/utils/getPcSpecsSummary.ts | 6 +- .../home/components/HomeTrendingDevices.tsx | 14 +- src/app/listings/ListingsPage.tsx | 43 +- .../components/ListingsFiltersContent.tsx | 3 +- .../filters/AsyncDeviceFilterSelect.test.tsx | 32 +- .../filters/AsyncDeviceFilterSelect.tsx | 24 +- .../filters/AsyncSocFilterSelect.tsx | 24 +- src/app/listings/hooks/useEmulatorLoader.ts | 6 +- src/app/listings/hooks/useGameLoader.ts | 3 +- src/app/listings/new/NewListingPage.tsx | 172 ++- .../shared/utils/asyncListingFilters.ts | 3 + src/app/pc-listings/PcListingsPage.tsx | 38 +- .../components/PcFiltersContent.tsx | 30 +- .../components/PcFiltersSidebar.tsx | 11 +- src/app/pc-listings/new/NewPcListingPage.tsx | 199 ++- src/app/profile/components/DeviceSelector.tsx | 9 +- src/app/profile/components/PcPresetModal.tsx | 99 +- src/app/profile/components/PcPresets.tsx | 22 +- src/app/profile/components/SocSelector.tsx | 9 +- src/components/navbar/Navbar.tsx | 21 +- src/data/constants.ts | 7 + .../cpu/client/admin/AdminCpusView.tsx | 210 +++ .../cpu/client/admin/CpuFormModal.test.tsx | 113 ++ .../cpu/client/admin/CpuFormModal.tsx | 135 ++ .../cpu/client/admin/CpuTable.test.tsx | 74 + .../hardware/cpu/client/admin/CpuTable.tsx | 107 ++ .../cpu/client/admin/CpuViewModal.tsx | 36 + .../components}/AsyncCpuFilterSelect.test.tsx | 6 +- .../components}/AsyncCpuFilterSelect.tsx | 42 +- .../cpu/client/utils/cpuSelectOption.ts | 11 + .../hardware/cpu/server/cpu.mapper.ts | 26 + .../hardware/cpu/server/cpu.policy.test.ts | 41 + .../hardware/cpu/server/cpu.policy.ts | 10 + .../cpu/server/cpu.repository.test.ts | 335 +++++ .../hardware/cpu/server/cpu.repository.ts | 189 +++ .../cpu/server/cpu.repository.types.ts | 45 + .../hardware/cpu/server/cpu.router.test.ts | 157 ++ .../hardware/cpu/server/cpu.router.ts | 67 + .../hardware/cpu/server/cpu.rules.test.ts | 32 + src/features/hardware/cpu/server/cpu.rules.ts | 14 + .../hardware/cpu/server/cpu.service.test.ts | 307 ++++ .../hardware/cpu/server/cpu.service.ts | 144 ++ .../cpu/server/persistence/cpu.errors.test.ts | 58 + .../cpu/server/persistence/cpu.errors.ts | 27 + .../cpu/server/persistence/cpu.prisma.ts | 56 + .../cpu/server/persistence/cpu.query.test.ts | 134 ++ .../cpu/server/persistence/cpu.query.ts | 182 +++ .../hardware/cpu/shared/cpu-format.test.ts | 17 + .../hardware/cpu/shared/cpu-format.ts | 5 + .../hardware/cpu/shared/cpu.schemas.ts | 124 ++ src/features/hardware/cpu/shared/cpu.types.ts | 43 + .../gpu/client/admin/AdminGpusView.tsx | 210 +++ .../gpu/client/admin/GpuFormModal.test.tsx | 113 ++ .../gpu/client/admin/GpuFormModal.tsx | 135 ++ .../gpu/client/admin/GpuTable.test.tsx | 74 + .../hardware/gpu/client/admin/GpuTable.tsx | 107 ++ .../gpu/client/admin}/GpuViewModal.tsx | 25 +- .../components}/AsyncGpuFilterSelect.test.tsx | 12 +- .../components}/AsyncGpuFilterSelect.tsx | 42 +- .../gpu/client/utils/gpuSelectOption.ts | 11 + .../hardware/gpu/server/gpu.mapper.ts | 26 + .../hardware/gpu/server/gpu.policy.test.ts | 41 + .../hardware/gpu/server/gpu.policy.ts | 10 + .../gpu/server/gpu.repository.test.ts | 335 +++++ .../hardware/gpu/server/gpu.repository.ts | 189 +++ .../gpu/server/gpu.repository.types.ts | 45 + .../hardware/gpu/server/gpu.router.test.ts | 157 ++ .../hardware/gpu/server/gpu.router.ts | 67 + .../hardware/gpu/server/gpu.rules.test.ts | 32 + src/features/hardware/gpu/server/gpu.rules.ts | 14 + .../hardware/gpu/server/gpu.service.test.ts | 307 ++++ .../hardware/gpu/server/gpu.service.ts | 144 ++ .../gpu/server/persistence/gpu.errors.test.ts | 58 + .../gpu/server/persistence/gpu.errors.ts | 27 + .../gpu/server/persistence/gpu.prisma.ts | 56 + .../gpu/server/persistence/gpu.query.test.ts | 134 ++ .../gpu/server/persistence/gpu.query.ts | 186 +++ .../hardware/gpu/shared/gpu-format.test.ts | 17 + .../hardware/gpu/shared/gpu-format.ts | 5 + .../hardware/gpu/shared/gpu.schemas.ts | 124 ++ src/features/hardware/gpu/shared/gpu.types.ts | 43 + src/lib/api.tsx | 63 +- src/lib/errors.ts | 4 +- src/schemas/common.ts | 9 + src/schemas/cpu.ts | 47 - src/schemas/device.ts | 10 +- src/schemas/gpu.ts | 47 - src/schemas/mobile.ts | 24 +- src/schemas/pagination.test.ts | 31 + src/schemas/pagination.ts | 51 + src/schemas/soc.ts | 9 +- src/scripts/api/generate-api-docs.ts | 614 +++----- src/scripts/api/mobile-schema-registry.ts | 25 + src/server/api/root.ts | 8 +- src/server/api/routers/cpus.ts | 73 - src/server/api/routers/gpus.ts | 75 - src/server/api/routers/mobile/cpus.test.ts | 113 ++ src/server/api/routers/mobile/cpus.ts | 33 +- src/server/api/routers/mobile/gpus.test.ts | 113 ++ src/server/api/routers/mobile/gpus.ts | 33 +- .../routers/mobile/pcListings.cpus.test.ts | 87 ++ .../routers/mobile/pcListings.gpus.test.ts | 87 ++ src/server/api/routers/mobile/pcListings.ts | 79 +- src/server/auth/actor.test.ts | 56 + src/server/auth/actor.ts | 54 + .../persistence/prisma.repository.test.ts | 43 + src/server/persistence/prisma.repository.ts | 23 + .../repositories/api-keys.repository.ts | 8 +- .../repositories/comments.repository.ts | 13 +- src/server/repositories/cpus.repository.ts | 326 ---- src/server/repositories/devices.repository.ts | 18 +- .../repositories/emulators.repository.ts | 3 +- src/server/repositories/games.repository.ts | 3 +- src/server/repositories/gpus.repository.ts | 321 ---- src/server/repositories/socs.repository.ts | 7 +- src/server/repositories/types.ts | 2 +- src/server/utils/pagination.test.ts | 71 +- src/server/utils/pagination.ts | 85 +- src/utils/options.ts | 12 - src/utils/text.test.ts | 22 +- src/utils/text.ts | 14 +- 138 files changed, 8378 insertions(+), 3448 deletions(-) delete mode 100644 src/app/admin/cpus/components/CpuModal.tsx delete mode 100644 src/app/admin/cpus/components/CpuViewModal.tsx delete mode 100644 src/app/admin/gpus/components/GpuModal.tsx create mode 100644 src/app/listings/shared/utils/asyncListingFilters.ts create mode 100644 src/features/hardware/cpu/client/admin/AdminCpusView.tsx create mode 100644 src/features/hardware/cpu/client/admin/CpuFormModal.test.tsx create mode 100644 src/features/hardware/cpu/client/admin/CpuFormModal.tsx create mode 100644 src/features/hardware/cpu/client/admin/CpuTable.test.tsx create mode 100644 src/features/hardware/cpu/client/admin/CpuTable.tsx create mode 100644 src/features/hardware/cpu/client/admin/CpuViewModal.tsx rename src/{app/pc-listings/components/filters => features/hardware/cpu/client/components}/AsyncCpuFilterSelect.test.tsx (91%) rename src/{app/pc-listings/components/filters => features/hardware/cpu/client/components}/AsyncCpuFilterSelect.tsx (61%) create mode 100644 src/features/hardware/cpu/client/utils/cpuSelectOption.ts create mode 100644 src/features/hardware/cpu/server/cpu.mapper.ts create mode 100644 src/features/hardware/cpu/server/cpu.policy.test.ts create mode 100644 src/features/hardware/cpu/server/cpu.policy.ts create mode 100644 src/features/hardware/cpu/server/cpu.repository.test.ts create mode 100644 src/features/hardware/cpu/server/cpu.repository.ts create mode 100644 src/features/hardware/cpu/server/cpu.repository.types.ts create mode 100644 src/features/hardware/cpu/server/cpu.router.test.ts create mode 100644 src/features/hardware/cpu/server/cpu.router.ts create mode 100644 src/features/hardware/cpu/server/cpu.rules.test.ts create mode 100644 src/features/hardware/cpu/server/cpu.rules.ts create mode 100644 src/features/hardware/cpu/server/cpu.service.test.ts create mode 100644 src/features/hardware/cpu/server/cpu.service.ts create mode 100644 src/features/hardware/cpu/server/persistence/cpu.errors.test.ts create mode 100644 src/features/hardware/cpu/server/persistence/cpu.errors.ts create mode 100644 src/features/hardware/cpu/server/persistence/cpu.prisma.ts create mode 100644 src/features/hardware/cpu/server/persistence/cpu.query.test.ts create mode 100644 src/features/hardware/cpu/server/persistence/cpu.query.ts create mode 100644 src/features/hardware/cpu/shared/cpu-format.test.ts create mode 100644 src/features/hardware/cpu/shared/cpu-format.ts create mode 100644 src/features/hardware/cpu/shared/cpu.schemas.ts create mode 100644 src/features/hardware/cpu/shared/cpu.types.ts create mode 100644 src/features/hardware/gpu/client/admin/AdminGpusView.tsx create mode 100644 src/features/hardware/gpu/client/admin/GpuFormModal.test.tsx create mode 100644 src/features/hardware/gpu/client/admin/GpuFormModal.tsx create mode 100644 src/features/hardware/gpu/client/admin/GpuTable.test.tsx create mode 100644 src/features/hardware/gpu/client/admin/GpuTable.tsx rename src/{app/admin/gpus/components => features/hardware/gpu/client/admin}/GpuViewModal.tsx (55%) rename src/{app/pc-listings/components/filters => features/hardware/gpu/client/components}/AsyncGpuFilterSelect.test.tsx (81%) rename src/{app/pc-listings/components/filters => features/hardware/gpu/client/components}/AsyncGpuFilterSelect.tsx (61%) create mode 100644 src/features/hardware/gpu/client/utils/gpuSelectOption.ts create mode 100644 src/features/hardware/gpu/server/gpu.mapper.ts create mode 100644 src/features/hardware/gpu/server/gpu.policy.test.ts create mode 100644 src/features/hardware/gpu/server/gpu.policy.ts create mode 100644 src/features/hardware/gpu/server/gpu.repository.test.ts create mode 100644 src/features/hardware/gpu/server/gpu.repository.ts create mode 100644 src/features/hardware/gpu/server/gpu.repository.types.ts create mode 100644 src/features/hardware/gpu/server/gpu.router.test.ts create mode 100644 src/features/hardware/gpu/server/gpu.router.ts create mode 100644 src/features/hardware/gpu/server/gpu.rules.test.ts create mode 100644 src/features/hardware/gpu/server/gpu.rules.ts create mode 100644 src/features/hardware/gpu/server/gpu.service.test.ts create mode 100644 src/features/hardware/gpu/server/gpu.service.ts create mode 100644 src/features/hardware/gpu/server/persistence/gpu.errors.test.ts create mode 100644 src/features/hardware/gpu/server/persistence/gpu.errors.ts create mode 100644 src/features/hardware/gpu/server/persistence/gpu.prisma.ts create mode 100644 src/features/hardware/gpu/server/persistence/gpu.query.test.ts create mode 100644 src/features/hardware/gpu/server/persistence/gpu.query.ts create mode 100644 src/features/hardware/gpu/shared/gpu-format.test.ts create mode 100644 src/features/hardware/gpu/shared/gpu-format.ts create mode 100644 src/features/hardware/gpu/shared/gpu.schemas.ts create mode 100644 src/features/hardware/gpu/shared/gpu.types.ts delete mode 100644 src/schemas/cpu.ts delete mode 100644 src/schemas/gpu.ts create mode 100644 src/schemas/pagination.test.ts create mode 100644 src/schemas/pagination.ts create mode 100644 src/scripts/api/mobile-schema-registry.ts delete mode 100644 src/server/api/routers/cpus.ts delete mode 100644 src/server/api/routers/gpus.ts create mode 100644 src/server/api/routers/mobile/cpus.test.ts create mode 100644 src/server/api/routers/mobile/gpus.test.ts create mode 100644 src/server/api/routers/mobile/pcListings.cpus.test.ts create mode 100644 src/server/api/routers/mobile/pcListings.gpus.test.ts create mode 100644 src/server/auth/actor.test.ts create mode 100644 src/server/auth/actor.ts create mode 100644 src/server/persistence/prisma.repository.test.ts create mode 100644 src/server/persistence/prisma.repository.ts delete mode 100644 src/server/repositories/cpus.repository.ts delete mode 100644 src/server/repositories/gpus.repository.ts diff --git a/AGENTS.md b/AGENTS.md index f49386e81..5d7d96088 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,15 +33,53 @@ This file is the source of working guidance for AI coding agents in this reposit - Feature folders should follow the project-structure guidance from Bulletproof React: https://github.com/alan2207/bulletproof-react/blob/master/docs/project-structure.md - Within `src/features/*`, prefer scoped subdirectories such as `components`, `hooks`, `utils`, `server`, and `shared` instead of flat feature folders. -- Routers in `src/server/api/routers/` are thin orchestration layers. They handle auth context, schema-validated input, repository/service calls, and response formatting. +- Feature-owned modules may colocate client, server, and shared code under + `src/features///`. +- Use this feature module structure for new or actively-refactored domain code: + - `shared/` contains Zod schemas, types, constants, and pure formatting helpers usable by client and server. + - `server/` contains repositories, services, policies, mappers, and feature-owned tRPC routers. + - `client/` contains hooks, reusable components, and workflow views. Add client API wrappers only when they remove real duplication or encode a stable UI contract. + - `client/admin/` is allowed for admin-only workflows. +- Import direction matters more than folder names: + - `client/` may import from its feature `shared/` and app-wide client-safe utilities. + - `server/` may import from its feature `shared/`, server utilities, and repositories. + - `shared/` must not import from `client/`, `server/`, `src/app`, or server-only libraries. + - `src/app/**` routes/pages should compose feature modules; feature modules should not import from `src/app/**`. +- tRPC routers are transport adapters. Feature-specific routers should live in the feature `server/` folder when that feature owns the full use case; `src/server/api/root.ts` should only compose them. +- Legacy routers in `src/server/api/routers/` are thin orchestration layers. They handle auth context, schema-validated input, repository/service calls, and response formatting. - Do not put raw Prisma queries or business logic in routers. -- Define Zod schemas in `src/schemas/*`; do not define inline schemas in router `.input(...)` calls. -- All database access belongs in repository classes under `src/server/repositories/` extending `BaseRepository`. +- Define Zod schemas in feature `shared/*.schemas.ts` for feature-owned code, or in `src/schemas/*` for legacy/shared code. Do not define inline schemas in router `.input(...)` calls. +- Feature-owned tRPC procedures should declare `.output(...)` with Zod schemas. Compatibility transports that must keep legacy shapes should still have explicit legacy output schemas instead of returning raw Prisma payloads by convention. +- All database access belongs in repository classes. Feature-owned repositories may live in feature `server/` folders; legacy/shared repositories may remain under `src/server/repositories/`. +- New or actively-refactored feature-owned Prisma repositories should extend `PrismaRepository` or `PrismaWriteRepository` from `src/server/persistence/prisma.repository.ts` for Prisma client ownership and shared write handling. Do not extend the legacy `BaseRepository` unless the inherited behavior is deliberately required and documented. +- Do not add generic CRUD methods to shared repository bases. Prisma already provides typed CRUD; feature repositories should expose domain/use-case persistence operations with named select contracts. +- For feature-owned Prisma repositories, prefer a `server/persistence/` subfolder for named `select` contracts, query builders, and Prisma error translation. Derive repository record types from Prisma `GetPayload` plus those named `select` contracts instead of hand-maintaining structural copies. +- Services should depend on the concrete feature repository by default. Do not add service-owned `Pick` contracts only for tests. +- Add repository interfaces only for real boundaries: multiple implementations, external provider adapters, lifecycle concerns that route composition cannot handle directly, or domain/application layers that intentionally must not depend on infrastructure. - Repositories should use project error helpers and consistent database operation handling. -- Multi-step business logic, external API orchestration, and complex calculations belong in services under `src/server/services/`. +- Multi-step business logic, external API orchestration, and complex calculations belong in services under feature `server/` folders or legacy `src/server/services/`. +- Use policy functions for reusable authorization/business access rules that must be shared across transports. Routers may still use broad auth procedures, but services should enforce feature-level capabilities when the use case can be called from multiple transports. - Use `AppError` and `ResourceError` helpers instead of raw `Error`, raw strings, or one-off `TRPCError` usage. - Use specialized procedures such as `protectedProcedure`, `adminProcedure`, and `permissionProcedure(...)` instead of ad hoc permission checks. +## API Compatibility And Legacy Contracts + +- Before introducing any `legacy` select, repository method, route, endpoint, + schema, type, mapper, compatibility branch, or response shape, audit known + consumers first. For mobile/public API work, this includes + `/Volumes/T9/Coding/personal/2026/Emulation/EmuReadyApp` when it is available, + or a temporary clone of `Producdevity/EmuReadyApp` on the `master` branch when + the local app checkout is unavailable or not production-synced. +- Record which fields consumers actually read. Do not preserve fields only + because they existed in an old Prisma payload, old inferred type, or old API + response. +- Decide compatibility case by case: remove unused old fields, migrate the + consumer, keep one standardized endpoint with a small low-cost superset, or + keep a separate legacy contract only when the audited consumer behavior truly + requires it. +- Legacy contracts must have an owner, a reason, and an expected removal path. + Remove legacy code as soon as audited consumers do not need it. + ## Database And Prisma - Treat database changes as high risk. diff --git a/docs/MOBILE_API.md b/docs/MOBILE_API.md index 57e751106..cfa962ae0 100644 --- a/docs/MOBILE_API.md +++ b/docs/MOBILE_API.md @@ -1,11 +1,11 @@ -# EmuReady Mobile API (tRPC) +# EmuReady Public Integration API (mobile-compatible tRPC) -*Auto-generated on: 2026-05-25T13:13:26.417Z* +*Auto-generated on: 2026-06-12T17:07:50.301Z* ## Summary -- **Total Endpoints**: 112 +- **Total Endpoints**: 113 - **Public Endpoints**: 65 -- **Protected Endpoints**: 47 +- **Protected Endpoints**: 48 - **OpenAPI Version**: 3.0.0 ## Base URL @@ -40,14 +40,14 @@ Protected endpoints require Bearer token authentication using Clerk JWT. #### 3. **get** - **Method**: GET - **Path**: `/cpus.get` -- **Description**: Get CPUs with search, filtering, and pagination +- **Description**: Get CPUs with search, filtering, and pagination. - **Tags**: cpus #### 4. **getById** - **Method**: GET - **Path**: `/cpus.getById` -- **Description**: Get CPU by ID +- **Description**: Get CPU by ID. - **Tags**: cpus @@ -250,14 +250,14 @@ Protected endpoints require Bearer token authentication using Clerk JWT. #### 33. **get** - **Method**: GET - **Path**: `/gpus.get` -- **Description**: Get GPUs with search, filtering, and pagination +- **Description**: Get GPUs with search, filtering, and pagination. - **Tags**: gpus #### 34. **getById** - **Method**: GET - **Path**: `/gpus.getById` -- **Description**: Get GPU by ID +- **Description**: Get GPU by ID. - **Tags**: gpus @@ -341,14 +341,14 @@ Protected endpoints require Bearer token authentication using Clerk JWT. #### 46. **cpus** - **Method**: GET - **Path**: `/pcListings.cpus` -- **Description**: Get CPUs for mobile +- **Description**: Get CPUs for PC compatibility report filters. - **Tags**: pcListings #### 47. **gpus** - **Method**: GET - **Path**: `/pcListings.gpus` -- **Description**: Get GPUs for mobile +- **Description**: Get GPUs for PC compatibility report filters. - **Tags**: pcListings @@ -482,7 +482,15 @@ Protected endpoints require Bearer token authentication using Clerk JWT. ### Protected Endpoints (Authentication Required) -#### 1. **updateProfile** +#### 1. **getSession** +- **Method**: GET +- **Path**: `/auth.getSession` +- **Description**: Get current user session info +- **Tags**: auth + +- **Authentication**: Bearer token required + +#### 2. **updateProfile** - **Method**: POST - **Path**: `/auth.updateProfile` - **Description**: Update mobile profile @@ -491,7 +499,7 @@ Protected endpoints require Bearer token authentication using Clerk JWT. - **Content-Type**: application/json - **Authentication**: Bearer token required -#### 2. **deleteAccount** +#### 3. **deleteAccount** - **Method**: POST - **Path**: `/auth.deleteAccount` - **Description**: Delete account @@ -500,7 +508,7 @@ Protected endpoints require Bearer token authentication using Clerk JWT. - **Content-Type**: application/json - **Authentication**: Bearer token required -#### 3. **isVerifiedDeveloper** +#### 4. **isVerifiedDeveloper** - **Method**: GET - **Path**: `/developers.isVerifiedDeveloper` - **Description**: Check if a user is a verified developer for an emulator @@ -508,15 +516,15 @@ Protected endpoints require Bearer token authentication using Clerk JWT. - **Authentication**: Bearer token required -#### 4. **create** +#### 5. **create** - **Method**: POST - **Path**: `/listingReports.create` -- **Description**: Create a new listing report (user-facing) +- **Description**: create - listingReports - **Tags**: listingReports - **Authentication**: Bearer token required -#### 5. **byUser** +#### 6. **byUser** - **Method**: GET - **Path**: `/listings.byUser` - **Description**: Get user listings @@ -524,7 +532,7 @@ Protected endpoints require Bearer token authentication using Clerk JWT. - **Authentication**: Bearer token required -#### 6. **create** +#### 7. **create** - **Method**: POST - **Path**: `/listings.create` - **Description**: Create a new listing @@ -533,7 +541,7 @@ Protected endpoints require Bearer token authentication using Clerk JWT. - **Content-Type**: application/json - **Authentication**: Bearer token required -#### 7. **update** +#### 8. **update** - **Method**: POST - **Path**: `/listings.update` - **Description**: Update a listing @@ -542,7 +550,7 @@ Protected endpoints require Bearer token authentication using Clerk JWT. - **Content-Type**: application/json - **Authentication**: Bearer token required -#### 8. **delete** +#### 9. **delete** - **Method**: POST - **Path**: `/listings.delete` - **Description**: Delete a listing @@ -551,7 +559,7 @@ Protected endpoints require Bearer token authentication using Clerk JWT. - **Content-Type**: application/json - **Authentication**: Bearer token required -#### 9. **vote** +#### 10. **vote** - **Method**: POST - **Path**: `/listings.vote` - **Description**: Vote on a listing @@ -560,7 +568,7 @@ Protected endpoints require Bearer token authentication using Clerk JWT. - **Content-Type**: application/json - **Authentication**: Bearer token required -#### 10. **userVote** +#### 11. **userVote** - **Method**: GET - **Path**: `/listings.userVote` - **Description**: Get user's vote on a listing @@ -568,7 +576,7 @@ Protected endpoints require Bearer token authentication using Clerk JWT. - **Authentication**: Bearer token required -#### 11. **createComment** +#### 12. **createComment** - **Method**: POST - **Path**: `/listings.createComment` - **Description**: Create a comment @@ -577,7 +585,7 @@ Protected endpoints require Bearer token authentication using Clerk JWT. - **Content-Type**: application/json - **Authentication**: Bearer token required -#### 12. **updateComment** +#### 13. **updateComment** - **Method**: POST - **Path**: `/listings.updateComment` - **Description**: Update a comment @@ -586,7 +594,7 @@ Protected endpoints require Bearer token authentication using Clerk JWT. - **Content-Type**: application/json - **Authentication**: Bearer token required -#### 13. **deleteComment** +#### 14. **deleteComment** - **Method**: POST - **Path**: `/listings.deleteComment` - **Description**: Delete a comment @@ -595,7 +603,7 @@ Protected endpoints require Bearer token authentication using Clerk JWT. - **Content-Type**: application/json - **Authentication**: Bearer token required -#### 14. **voteComment** +#### 15. **voteComment** - **Method**: POST - **Path**: `/listings.voteComment` - **Description**: Vote on a comment @@ -604,7 +612,7 @@ Protected endpoints require Bearer token authentication using Clerk JWT. - **Content-Type**: application/json - **Authentication**: Bearer token required -#### 15. **getUserCommentVotes** +#### 16. **getUserCommentVotes** - **Method**: GET - **Path**: `/listings.getUserCommentVotes` - **Description**: Get user votes for multiple comments @@ -612,7 +620,7 @@ Protected endpoints require Bearer token authentication using Clerk JWT. - **Authentication**: Bearer token required -#### 16. **reportComment** +#### 17. **reportComment** - **Method**: POST - **Path**: `/listings.reportComment` - **Description**: Report a comment @@ -621,7 +629,7 @@ Protected endpoints require Bearer token authentication using Clerk JWT. - **Content-Type**: application/json - **Authentication**: Bearer token required -#### 17. **get** +#### 18. **get** - **Method**: GET - **Path**: `/notifications.get` - **Description**: Get notifications with pagination @@ -629,7 +637,7 @@ Protected endpoints require Bearer token authentication using Clerk JWT. - **Authentication**: Bearer token required -#### 18. **unreadCount** +#### 19. **unreadCount** - **Method**: GET - **Path**: `/notifications.unreadCount` - **Description**: Get unread notification count @@ -637,7 +645,7 @@ Protected endpoints require Bearer token authentication using Clerk JWT. - **Authentication**: Bearer token required -#### 19. **markAsRead** +#### 20. **markAsRead** - **Method**: POST - **Path**: `/notifications.markAsRead` - **Description**: Mark notification as read @@ -646,7 +654,7 @@ Protected endpoints require Bearer token authentication using Clerk JWT. - **Content-Type**: application/json - **Authentication**: Bearer token required -#### 20. **markAllAsRead** +#### 21. **markAllAsRead** - **Method**: POST - **Path**: `/notifications.markAllAsRead` - **Description**: Mark all notifications as read @@ -654,7 +662,7 @@ Protected endpoints require Bearer token authentication using Clerk JWT. - **Authentication**: Bearer token required -#### 21. **create** +#### 22. **create** - **Method**: POST - **Path**: `/pcListings.create` - **Description**: Create a new PC listing @@ -663,7 +671,7 @@ Protected endpoints require Bearer token authentication using Clerk JWT. - **Content-Type**: application/json - **Authentication**: Bearer token required -#### 22. **update** +#### 23. **update** - **Method**: POST - **Path**: `/pcListings.update` - **Description**: Update a PC listing @@ -672,7 +680,7 @@ Protected endpoints require Bearer token authentication using Clerk JWT. - **Content-Type**: application/json - **Authentication**: Bearer token required -#### 23. **get** +#### 24. **get** - **Method**: GET - **Path**: `/pcPresets.get` - **Description**: Get current user's PC presets @@ -680,7 +688,7 @@ Protected endpoints require Bearer token authentication using Clerk JWT. - **Authentication**: Bearer token required -#### 24. **create** +#### 25. **create** - **Method**: POST - **Path**: `/pcPresets.create` - **Description**: Create a new PC preset @@ -689,7 +697,7 @@ Protected endpoints require Bearer token authentication using Clerk JWT. - **Content-Type**: application/json - **Authentication**: Bearer token required -#### 25. **update** +#### 26. **update** - **Method**: POST - **Path**: `/pcPresets.update` - **Description**: Update an existing PC preset @@ -698,7 +706,7 @@ Protected endpoints require Bearer token authentication using Clerk JWT. - **Content-Type**: application/json - **Authentication**: Bearer token required -#### 26. **delete** +#### 27. **delete** - **Method**: POST - **Path**: `/pcPresets.delete` - **Description**: Delete a PC preset @@ -707,7 +715,7 @@ Protected endpoints require Bearer token authentication using Clerk JWT. - **Content-Type**: application/json - **Authentication**: Bearer token required -#### 27. **get** +#### 28. **get** - **Method**: GET - **Path**: `/preferences.get` - **Description**: get - preferences @@ -715,7 +723,7 @@ Protected endpoints require Bearer token authentication using Clerk JWT. - **Authentication**: Bearer token required -#### 28. **update** +#### 29. **update** - **Method**: POST - **Path**: `/preferences.update` - **Description**: update - preferences @@ -724,7 +732,7 @@ Protected endpoints require Bearer token authentication using Clerk JWT. - **Content-Type**: application/json - **Authentication**: Bearer token required -#### 29. **addDevice** +#### 30. **addDevice** - **Method**: POST - **Path**: `/preferences.addDevice` - **Description**: addDevice - preferences @@ -733,7 +741,7 @@ Protected endpoints require Bearer token authentication using Clerk JWT. - **Content-Type**: application/json - **Authentication**: Bearer token required -#### 30. **removeDevice** +#### 31. **removeDevice** - **Method**: POST - **Path**: `/preferences.removeDevice` - **Description**: removeDevice - preferences @@ -742,7 +750,7 @@ Protected endpoints require Bearer token authentication using Clerk JWT. - **Content-Type**: application/json - **Authentication**: Bearer token required -#### 31. **bulkUpdateDevices** +#### 32. **bulkUpdateDevices** - **Method**: POST - **Path**: `/preferences.bulkUpdateDevices` - **Description**: bulkUpdateDevices - preferences @@ -751,7 +759,7 @@ Protected endpoints require Bearer token authentication using Clerk JWT. - **Content-Type**: application/json - **Authentication**: Bearer token required -#### 32. **bulkUpdateSocs** +#### 33. **bulkUpdateSocs** - **Method**: POST - **Path**: `/preferences.bulkUpdateSocs` - **Description**: bulkUpdateSocs - preferences @@ -760,7 +768,7 @@ Protected endpoints require Bearer token authentication using Clerk JWT. - **Content-Type**: application/json - **Authentication**: Bearer token required -#### 33. **currentProfile** +#### 34. **currentProfile** - **Method**: GET - **Path**: `/preferences.currentProfile` - **Description**: currentProfile - preferences @@ -768,7 +776,7 @@ Protected endpoints require Bearer token authentication using Clerk JWT. - **Authentication**: Bearer token required -#### 34. **profile** +#### 35. **profile** - **Method**: GET - **Path**: `/preferences.profile` - **Description**: profile - preferences @@ -776,7 +784,7 @@ Protected endpoints require Bearer token authentication using Clerk JWT. - **Authentication**: Bearer token required -#### 35. **updateProfile** +#### 36. **updateProfile** - **Method**: POST - **Path**: `/preferences.updateProfile` - **Description**: updateProfile - preferences @@ -785,7 +793,7 @@ Protected endpoints require Bearer token authentication using Clerk JWT. - **Content-Type**: application/json - **Authentication**: Bearer token required -#### 36. **follow** +#### 37. **follow** - **Method**: POST - **Path**: `/social.follow` - **Description**: follow - social @@ -793,7 +801,7 @@ Protected endpoints require Bearer token authentication using Clerk JWT. - **Authentication**: Bearer token required -#### 37. **unfollow** +#### 38. **unfollow** - **Method**: POST - **Path**: `/social.unfollow` - **Description**: unfollow - social @@ -801,7 +809,7 @@ Protected endpoints require Bearer token authentication using Clerk JWT. - **Authentication**: Bearer token required -#### 38. **removeFollower** +#### 39. **removeFollower** - **Method**: POST - **Path**: `/social.removeFollower` - **Description**: removeFollower - social @@ -809,7 +817,7 @@ Protected endpoints require Bearer token authentication using Clerk JWT. - **Authentication**: Bearer token required -#### 39. **sendFriendRequest** +#### 40. **sendFriendRequest** - **Method**: POST - **Path**: `/social.sendFriendRequest` - **Description**: sendFriendRequest - social @@ -817,7 +825,7 @@ Protected endpoints require Bearer token authentication using Clerk JWT. - **Authentication**: Bearer token required -#### 40. **respondFriendRequest** +#### 41. **respondFriendRequest** - **Method**: POST - **Path**: `/social.respondFriendRequest` - **Description**: respondFriendRequest - social @@ -825,7 +833,7 @@ Protected endpoints require Bearer token authentication using Clerk JWT. - **Authentication**: Bearer token required -#### 41. **getFriendRequests** +#### 42. **getFriendRequests** - **Method**: GET - **Path**: `/social.getFriendRequests` - **Description**: getFriendRequests - social @@ -833,7 +841,7 @@ Protected endpoints require Bearer token authentication using Clerk JWT. - **Authentication**: Bearer token required -#### 42. **getFriends** +#### 43. **getFriends** - **Method**: GET - **Path**: `/social.getFriends` - **Description**: getFriends - social @@ -841,7 +849,7 @@ Protected endpoints require Bearer token authentication using Clerk JWT. - **Authentication**: Bearer token required -#### 43. **blockUser** +#### 44. **blockUser** - **Method**: POST - **Path**: `/social.blockUser` - **Description**: blockUser - social @@ -849,7 +857,7 @@ Protected endpoints require Bearer token authentication using Clerk JWT. - **Authentication**: Bearer token required -#### 44. **unblockUser** +#### 45. **unblockUser** - **Method**: POST - **Path**: `/social.unblockUser` - **Description**: unblockUser - social @@ -857,7 +865,7 @@ Protected endpoints require Bearer token authentication using Clerk JWT. - **Authentication**: Bearer token required -#### 45. **getBlockedUsers** +#### 46. **getBlockedUsers** - **Method**: GET - **Path**: `/social.getBlockedUsers` - **Description**: getBlockedUsers - social @@ -865,7 +873,7 @@ Protected endpoints require Bearer token authentication using Clerk JWT. - **Authentication**: Bearer token required -#### 46. **getActivityFeed** +#### 47. **getActivityFeed** - **Method**: GET - **Path**: `/social.getActivityFeed` - **Description**: getActivityFeed - social @@ -873,7 +881,7 @@ Protected endpoints require Bearer token authentication using Clerk JWT. - **Authentication**: Bearer token required -#### 47. **myInfo** +#### 48. **myInfo** - **Method**: GET - **Path**: `/trust.myInfo` - **Description**: Get current user's trust score and level @@ -889,11 +897,13 @@ All endpoints return consistent error responses: ```json { "error": { - "message": "Error description", - "code": "ERROR_CODE", - "data": { - "code": "TRPC_ERROR_CODE", - "httpStatus": 400 + "json": { + "message": "Error description", + "code": -32600, + "data": { + "code": "TRPC_ERROR_CODE", + "httpStatus": 400 + } } } } diff --git a/eslint.config.mjs b/eslint.config.mjs index 758ce6075..41e68bf24 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -12,13 +12,92 @@ const featureNames = existsSync('./src/features') .map((entry) => entry.name) : [] -const featureBoundaryZones = featureNames.map((featureName) => ({ +const featureScopeNames = new Set(['client', 'components', 'hooks', 'server', 'shared', 'utils']) + +function hasFeatureScopeDirectory(featurePath) { + if (!existsSync(featurePath)) return false + + return readdirSync(featurePath, { withFileTypes: true }).some( + (entry) => entry.isDirectory() && featureScopeNames.has(entry.name), + ) +} + +const featureModuleNames = featureNames.flatMap((featureName) => { + const featurePath = `./src/features/${featureName}` + if (!existsSync(featurePath)) return [] + + return readdirSync(featurePath, { withFileTypes: true }) + .filter((entry) => entry.isDirectory() && !featureScopeNames.has(entry.name)) + .map((entry) => `${featureName}/${entry.name}`) +}) + +const topLevelFeatureLayerRootNames = featureNames.filter((featureName) => + hasFeatureScopeDirectory(`./src/features/${featureName}`), +) + +const featureLayerRootNames = [...topLevelFeatureLayerRootNames, ...featureModuleNames] + +const topLevelFeatureBoundaryZones = featureNames.map((featureName) => ({ target: `./src/features/${featureName}`, from: './src/features', except: [`./${featureName}`], message: 'Features must not import from other features. Compose features at the route layer.', })) +const nestedFeatureBoundaryZones = featureModuleNames.map((featureModuleName) => { + const [domainName, moduleName] = featureModuleName.split('/') + + return { + target: `./src/features/${featureModuleName}`, + from: `./src/features/${domainName}`, + except: [`./${moduleName}`, './shared'], + message: + 'Feature modules must not import from sibling modules. Extract shared domain code or compose modules at the route layer.', + } +}) + +const featureLayerBoundaryZones = featureLayerRootNames.flatMap((featureRootName) => [ + { + target: `./src/features/${featureRootName}/shared`, + from: `./src/features/${featureRootName}`, + except: ['./shared'], + message: 'Feature shared code must not import from client, server, or workflow layers.', + }, + { + target: `./src/features/${featureRootName}/client`, + from: `./src/features/${featureRootName}/server`, + message: 'Feature client code must not import server code.', + }, + { + target: `./src/features/${featureRootName}/client`, + from: './src/server', + message: 'Feature client code must not import app-wide server code.', + }, + { + target: `./src/features/${featureRootName}/server`, + from: `./src/features/${featureRootName}/client`, + message: 'Feature server code must not import client code.', + }, + { + target: `./src/features/${featureRootName}/shared`, + from: './src/server', + message: 'Feature shared code must stay client-safe and must not import server utilities.', + }, +]) + +const featureToAppRouteBoundaryZone = { + target: './src/features', + from: './src/app', + message: 'Feature modules must not import from Next.js app routes. Compose features in app routes.', +} + +const featureBoundaryZones = [ + ...topLevelFeatureBoundaryZones, + ...nestedFeatureBoundaryZones, + ...featureLayerBoundaryZones, + featureToAppRouteBoundaryZone, +] + const eslintConfig = [ { ignores: [ @@ -33,7 +112,6 @@ const eslintConfig = [ 'coverage/**', 'dist/**', 'next-env.d.ts', - 'next-env.d.ts', 'node_modules/**', 'notes/**', 'out/**', diff --git a/package.json b/package.json index b06585ee0..ab67c0db0 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "dev:profile": "NEXT_CPU_PROF=1 NEXT_TURBOPACK_TRACING=1 next dev --turbopack", "dev:debug": "DEBUG=next:* next dev --turbopack", "docs:generate": "tsx src/scripts/api/generate-api-docs.ts", - "docs:watch": "nodemon --watch src/server/api/routers/mobile --watch src/schemas/mobile.ts --exec \"pnpm docs:generate\"", + "docs:watch": "nodemon --watch src/server/api/routers/mobile --watch src/features --watch src/schemas --exec \"pnpm docs:generate\"", "format": "prettier --write .", "lint": "eslint .", "lint:fix": "eslint --fix .", diff --git a/public/api-docs/mobile-openapi.json b/public/api-docs/mobile-openapi.json index 397c14f31..b94d751ef 100644 --- a/public/api-docs/mobile-openapi.json +++ b/public/api-docs/mobile-openapi.json @@ -1,8 +1,8 @@ { "openapi": "3.0.0", "info": { - "title": "EmuReady Mobile API (tRPC)", - "description": "\n# EmuReady Mobile tRPC API\n\nComplete API documentation for EmuReady mobile applications built with tRPC.\n\n## tRPC HTTP Method Conventions\n\nNOTE: the protected routes require authentication via Clerk JWT token in the Authorization header. This isn't implemented yet.\n\ntRPC uses HTTP method semantics with fetchRequestHandler:\n- **Queries** use **GET** requests with input as query parameter\n- **Mutations** use **POST** requests with input in request body\n\n### Schema References:\n\nAll input schemas are defined in the **components/schemas** section. When you see a parameter referencing a schema (e.g., GetEmulatorsSchema), check the schemas section for the complete structure with field types, validations, and defaults.\n\n### Usage Examples:\n\n```bash\n# Query: Get games with search and limit (GET with SuperJSON wrapped input)\n# Schema: See components/schemas/GetGamesSchema\ncurl -X GET \"https://www.emuready.com/api/mobile/trpc/games.getGames?input=%7B%22json%22%3A%7B%22search%22%3A%22mario%22%2C%22limit%22%3A5%7D%7D\" \\\n -H \"Content-Type: application/json\"\n\n# Query: Get popular games (GET, no input required)\ncurl -X GET \"https://www.emuready.com/api/mobile/trpc/games.getPopularGames\" \\\n -H \"Content-Type: application/json\"\n\n# Query: Get listings with filters (GET with SuperJSON wrapped input)\ncurl -X GET \"https://www.emuready.com/api/mobile/trpc/listings.getListings?input=%7B%22json%22%3A%7B%22page%22%3A1%2C%22limit%22%3A10%2C%22search%22%3A%22zelda%22%7D%7D\" \\\n -H \"Content-Type: application/json\"\n\n# Mutation: Create listing (POST with request body)\ncurl -X POST \"https://www.emuready.com/api/mobile/trpc/listings.createListing\" \\\n -H \"Content-Type: application/json\" \\\n -H \"Authorization: Bearer YOUR_JWT_TOKEN\" \\\n -d '{\"gameId\":\"uuid\",\"deviceId\":\"uuid\",\"emulatorId\":\"uuid\",\"performanceId\":\"uuid\"}'\n\n# Protected query with authentication (GET with query parameter and auth header)\ncurl -X GET \"https://www.emuready.com/api/mobile/trpc/listings.getUserListings?input=%7B%22userId%22%3A%22uuid%22%7D\" \\\n -H \"Content-Type: application/json\" \\\n -H \"Authorization: Bearer YOUR_JWT_TOKEN\"\n```\n\n### Important Notes:\n\n**For Queries (GET requests):**\n✅ Use GET method\n✅ Send input wrapped in SuperJSON format: `{\"json\":{\"field\":\"value\"}}`\n✅ URL-encode the entire JSON string\n✅ Many endpoints have defaults and don't require input\n✅ Input parameter format: `?input={\"json\":{\"field\":\"value\"}}` (URL-encoded)\n\n**For Mutations (POST requests):**\n✅ Use POST method\n✅ Send input as JSON in request body\n✅ Set Content-Type: application/json\n\n### Response Format:\nAll responses are wrapped in a tRPC result object:\n```json\n{\n \"result\": {\n \"data\": /* response data */\n }\n}\n```\n\n### Error Response Format:\n```json\n{\n \"error\": {\n \"json\": {\n \"message\": \"Error message\",\n \"code\": -32600,\n \"data\": {\n \"code\": \"BAD_REQUEST\",\n \"httpStatus\": 400,\n \"path\": \"games.getGames\"\n }\n }\n }\n}\n```\n\nThis API provides endpoints for:\n- Game emulation listings management\n- User authentication and profiles \n- Device and hardware information\n- Emulator data and compatibility\n- Community features (comments, votes)\n ", + "title": "EmuReady Public Integration API (mobile-compatible tRPC)", + "description": "\n# EmuReady Public Integration tRPC API\n\nAPI documentation for the mobile-compatible public integration surface built with tRPC.\n\n## tRPC HTTP Method Conventions\n\nNOTE: the protected routes require authentication via Clerk JWT token in the Authorization header. This isn't implemented yet.\n\ntRPC uses HTTP method semantics with fetchRequestHandler:\n- **Queries** use **GET** requests with input as query parameter\n- **Mutations** use **POST** requests with input in request body\n\n### Schema References:\n\nAll input schemas are defined in the **components/schemas** section. When you see a parameter referencing a schema (e.g., GetEmulatorsSchema), check the schemas section for the complete structure with field types, validations, and defaults.\n\n### Usage Examples:\n\n```bash\n# Query: Get games with search and limit (GET with SuperJSON wrapped input)\n# Schema: See components/schemas/GetGamesSchema\ncurl -X GET \"https://www.emuready.com/api/mobile/trpc/games.getGames?input=%7B%22json%22%3A%7B%22search%22%3A%22mario%22%2C%22limit%22%3A5%7D%7D\" \\\n -H \"Content-Type: application/json\"\n\n# Query: Get popular games (GET, no input required)\ncurl -X GET \"https://www.emuready.com/api/mobile/trpc/games.getPopularGames\" \\\n -H \"Content-Type: application/json\"\n\n# Query: Get listings with filters (GET with SuperJSON wrapped input)\ncurl -X GET \"https://www.emuready.com/api/mobile/trpc/listings.getListings?input=%7B%22json%22%3A%7B%22page%22%3A1%2C%22limit%22%3A10%2C%22search%22%3A%22zelda%22%7D%7D\" \\\n -H \"Content-Type: application/json\"\n\n# Mutation: Create listing (POST with request body)\ncurl -X POST \"https://www.emuready.com/api/mobile/trpc/listings.createListing\" \\\n -H \"Content-Type: application/json\" \\\n -H \"Authorization: Bearer YOUR_JWT_TOKEN\" \\\n -d '{\"gameId\":\"uuid\",\"deviceId\":\"uuid\",\"emulatorId\":\"uuid\",\"performanceId\":\"uuid\"}'\n\n# Protected query with authentication (GET with SuperJSON wrapped input and auth header)\ncurl -X GET \"https://www.emuready.com/api/mobile/trpc/listings.getUserListings?input=%7B%22json%22%3A%7B%22userId%22%3A%22uuid%22%7D%7D\" \\\n -H \"Content-Type: application/json\" \\\n -H \"Authorization: Bearer YOUR_JWT_TOKEN\"\n```\n\n### Important Notes:\n\n**For Queries (GET requests):**\n✅ Use GET method\n✅ Send input wrapped in SuperJSON format: `{\"json\":{\"field\":\"value\"}}`\n✅ URL-encode the entire JSON string\n✅ Many endpoints have defaults and don't require input\n✅ Input parameter format: `?input={\"json\":{\"field\":\"value\"}}` (URL-encoded)\n\n**For Mutations (POST requests):**\n✅ Use POST method\n✅ Send input as JSON in request body\n✅ Set Content-Type: application/json\n\n### Response Format:\nAll responses are wrapped in a tRPC result object:\n```json\n{\n \"result\": {\n \"data\": /* response data */\n }\n}\n```\n\n### Error Response Format:\n```json\n{\n \"error\": {\n \"json\": {\n \"message\": \"Error message\",\n \"code\": -32600,\n \"data\": {\n \"code\": \"BAD_REQUEST\",\n \"httpStatus\": 400,\n \"path\": \"games.getGames\"\n }\n }\n }\n}\n```\n\nThis API provides endpoints for:\n- Game emulation listings management\n- User authentication and profiles \n- Device and hardware information\n- Emulator data and compatibility\n- Community features (comments, votes)\n ", "version": "1.0.0", "contact": { "name": "EmuReady API Support", @@ -16,7 +16,7 @@ "servers": [ { "url": "/api/mobile/trpc", - "description": "Mobile API Base URL" + "description": "Mobile-compatible public integration API base URL" } ], "security": [ @@ -192,23 +192,235 @@ "additionalProperties": false, "description": "Fetch device compatibility scores aggregated by system" }, - "GetCpusSchema": { + "MobileGetCpusSchema": { + "anyOf": [ + { + "not": {} + }, + { + "type": "object", + "properties": { + "search": { + "type": "string" + }, + "brandId": { + "type": "string", + "format": "uuid" + }, + "limit": { + "type": "number", + "default": 20 + }, + "offset": { + "type": "number", + "default": 0 + }, + "page": { + "type": "number" + }, + "sortField": { + "type": "string", + "enum": [ + "brand", + "modelName", + "pcListings" + ] + }, + "sortDirection": { + "type": "string", + "enum": [ + "asc", + "desc" + ] + } + }, + "additionalProperties": false + } + ] + }, + "MobileCpuListResponseSchema": { "type": "object", "properties": { - "search": { - "type": "string" + "cpus": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "brandId": { + "type": "string", + "format": "uuid" + }, + "modelName": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "brand": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ], + "additionalProperties": false + }, + "_count": { + "type": "object", + "properties": { + "pcListings": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "pcListings" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "brandId", + "modelName", + "createdAt", + "brand", + "_count" + ], + "additionalProperties": false + } + }, + "pagination": { + "type": "object", + "properties": { + "total": { + "type": "integer", + "minimum": 0 + }, + "pages": { + "type": "integer", + "minimum": 0 + }, + "page": { + "type": "integer", + "exclusiveMinimum": 0 + }, + "offset": { + "type": "integer", + "minimum": 0 + }, + "limit": { + "type": "integer", + "exclusiveMinimum": 0 + }, + "hasNextPage": { + "type": "boolean" + }, + "hasPreviousPage": { + "type": "boolean" + } + }, + "required": [ + "total", + "pages", + "page", + "offset", + "limit", + "hasNextPage", + "hasPreviousPage" + ], + "additionalProperties": false + } + }, + "required": [ + "cpus", + "pagination" + ], + "additionalProperties": false + }, + "GetCpuByIdSchema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + } + }, + "required": [ + "id" + ], + "additionalProperties": false + }, + "MobileCpuListItemSchema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" }, "brandId": { "type": "string", "format": "uuid" }, - "limit": { - "type": "number", - "minimum": 1, - "maximum": 100, - "default": 50 + "modelName": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "brand": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ], + "additionalProperties": false + }, + "_count": { + "type": "object", + "properties": { + "pcListings": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "pcListings" + ], + "additionalProperties": false } }, + "required": [ + "id", + "brandId", + "modelName", + "createdAt", + "brand", + "_count" + ], "additionalProperties": false }, "IsVerifiedDeveloperSchema": { @@ -548,29 +760,241 @@ } }, "required": [ - "query" + "query" + ], + "additionalProperties": false + }, + "MobileGetGpusSchema": { + "anyOf": [ + { + "not": {} + }, + { + "type": "object", + "properties": { + "search": { + "type": "string" + }, + "brandId": { + "type": "string", + "format": "uuid" + }, + "limit": { + "type": "number", + "default": 20 + }, + "offset": { + "type": "number", + "default": 0 + }, + "page": { + "type": "number" + }, + "sortField": { + "type": "string", + "enum": [ + "brand", + "modelName", + "pcListings" + ] + }, + "sortDirection": { + "type": "string", + "enum": [ + "asc", + "desc" + ] + } + }, + "additionalProperties": false + } + ] + }, + "MobileGpuListResponseSchema": { + "type": "object", + "properties": { + "gpus": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "brandId": { + "type": "string", + "format": "uuid" + }, + "modelName": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "brand": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ], + "additionalProperties": false + }, + "_count": { + "type": "object", + "properties": { + "pcListings": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "pcListings" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "brandId", + "modelName", + "createdAt", + "brand", + "_count" + ], + "additionalProperties": false + } + }, + "pagination": { + "type": "object", + "properties": { + "total": { + "type": "integer", + "minimum": 0 + }, + "pages": { + "type": "integer", + "minimum": 0 + }, + "page": { + "type": "integer", + "exclusiveMinimum": 0 + }, + "offset": { + "type": "integer", + "minimum": 0 + }, + "limit": { + "type": "integer", + "exclusiveMinimum": 0 + }, + "hasNextPage": { + "type": "boolean" + }, + "hasPreviousPage": { + "type": "boolean" + } + }, + "required": [ + "total", + "pages", + "page", + "offset", + "limit", + "hasNextPage", + "hasPreviousPage" + ], + "additionalProperties": false + } + }, + "required": [ + "gpus", + "pagination" + ], + "additionalProperties": false + }, + "GetGpuByIdSchema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + } + }, + "required": [ + "id" + ], + "additionalProperties": false + }, + "MobileGpuListItemSchema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "brandId": { + "type": "string", + "format": "uuid" + }, + "modelName": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "brand": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ], + "additionalProperties": false + }, + "_count": { + "type": "object", + "properties": { + "pcListings": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "pcListings" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "brandId", + "modelName", + "createdAt", + "brand", + "_count" ], "additionalProperties": false }, - "GetGpusSchema": { - "type": "object", - "properties": { - "search": { - "type": "string" - }, - "brandId": { - "type": "string", - "format": "uuid" - }, - "limit": { - "type": "number", - "minimum": 1, - "maximum": 100, - "default": 50 - } - }, - "additionalProperties": false - }, "GetListingsSchema": { "anyOf": [ { @@ -759,13 +1183,55 @@ { "type": "array", "items": { - "$ref": "#/definitions/CreateListingSchema/properties/customFieldValues/anyOf/0/items/properties/value" + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": {} + }, + { + "type": "object", + "additionalProperties": {} + } + ] } }, { "type": "object", "additionalProperties": { - "$ref": "#/definitions/CreateListingSchema/properties/customFieldValues/anyOf/0/items/properties/value" + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": {} + }, + { + "type": "object", + "additionalProperties": {} + } + ] } } ] @@ -982,13 +1448,55 @@ { "type": "array", "items": { - "$ref": "#/definitions/UpdateListingSchema/properties/customFieldValues/items/anyOf/1/properties/value" + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": {} + }, + { + "type": "object", + "additionalProperties": {} + } + ] } }, { "type": "object", "additionalProperties": { - "$ref": "#/definitions/UpdateListingSchema/properties/customFieldValues/items/anyOf/1/properties/value" + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": {} + }, + { + "type": "object", + "additionalProperties": {} + } + ] } } ] @@ -1410,13 +1918,55 @@ { "type": "array", "items": { - "$ref": "#/definitions/CreatePcListingSchema/properties/customFieldValues/items/properties/value" + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": {} + }, + { + "type": "object", + "additionalProperties": {} + } + ] } }, { "type": "object", "additionalProperties": { - "$ref": "#/definitions/CreatePcListingSchema/properties/customFieldValues/items/properties/value" + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": {} + }, + { + "type": "object", + "additionalProperties": {} + } + ] } } ] @@ -1653,13 +2203,55 @@ { "type": "array", "items": { - "$ref": "#/definitions/UpdatePcListingSchema/properties/customFieldValues/items/anyOf/1/properties/value" + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": {} + }, + { + "type": "object", + "additionalProperties": {} + } + ] } }, { "type": "object", "additionalProperties": { - "$ref": "#/definitions/UpdatePcListingSchema/properties/customFieldValues/items/anyOf/1/properties/value" + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": {} + }, + { + "type": "object", + "additionalProperties": {} + } + ] } } ] @@ -1679,6 +2271,158 @@ ], "additionalProperties": false }, + "MobilePcListingCpusSchema": { + "type": "object", + "properties": { + "search": { + "type": "string" + }, + "brandId": { + "type": "string", + "format": "uuid" + }, + "limit": { + "type": "number", + "minimum": 1, + "maximum": 100, + "default": 50 + } + }, + "additionalProperties": false + }, + "MobilePcListingCpuResponseSchema": { + "type": "object", + "properties": { + "cpus": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "brandId": { + "type": "string", + "format": "uuid" + }, + "modelName": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "brand": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "brandId", + "modelName", + "createdAt", + "brand" + ], + "additionalProperties": false + } + } + }, + "required": [ + "cpus" + ], + "additionalProperties": false + }, + "MobilePcListingGpusSchema": { + "type": "object", + "properties": { + "search": { + "type": "string" + }, + "brandId": { + "type": "string", + "format": "uuid" + }, + "limit": { + "type": "number", + "minimum": 1, + "maximum": 100, + "default": 50 + } + }, + "additionalProperties": false + }, + "MobilePcListingGpuResponseSchema": { + "type": "object", + "properties": { + "gpus": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "brandId": { + "type": "string", + "format": "uuid" + }, + "modelName": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "brand": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "brandId", + "modelName", + "createdAt", + "brand" + ], + "additionalProperties": false + } + } + }, + "required": [ + "gpus" + ], + "additionalProperties": false + }, "GetPcPresetsSchema": { "type": "object", "properties": { @@ -1990,31 +2734,161 @@ "name": "trust", "description": "Trust related endpoints" }, - { - "name": "users", - "description": "Users related endpoints" - } - ], - "paths": { - "/auth.validateToken": { + { + "name": "users", + "description": "Users related endpoints" + } + ], + "paths": { + "/auth.validateToken": { + "get": { + "summary": "Validate JWT token", + "description": "Validate JWT token", + "tags": [ + "auth" + ], + "parameters": [ + { + "name": "input", + "in": "query", + "required": true, + "schema": { + "type": "string", + "description": "SuperJSON wrapped input object" + }, + "description": "SuperJSON wrapped input matching ValidateTokenSchema schema. See components/schemas/ValidateTokenSchema for structure.", + "example": "{\"json\":{\"token\":\"example\"}}" + } + ], + "responses": { + "200": { + "description": "Successful tRPC response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "result": { + "type": "object", + "description": "tRPC result wrapper containing the actual response data", + "properties": { + "data": { + "type": "object", + "description": "Response data from auth.validateToken" + } + } + } + }, + "required": [ + "result" + ] + }, + "examples": { + "success": { + "summary": "Successful response", + "value": { + "result": { + "data": { + "message": "Response from auth.validateToken", + "data": { + "id": "uuid-user", + "email": "user@example.com", + "name": "John Doe" + } + } + } + } + } + } + } + } + }, + "400": { + "description": "Bad Request - Invalid input parameters or malformed JSON", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TRPCError" + }, + "examples": { + "invalidInput": { + "summary": "Invalid input example", + "value": { + "error": { + "json": { + "message": "Input validation failed", + "code": -32600, + "data": { + "code": "BAD_REQUEST", + "httpStatus": 400, + "path": "auth.validateToken", + "zodError": { + "formErrors": [ + "Required" + ], + "fieldErrors": {} + } + } + } + } + } + } + } + } + } + }, + "401": { + "description": "Unauthorized - Authentication required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TRPCError" + } + } + } + }, + "403": { + "description": "Forbidden - Insufficient permissions", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TRPCError" + } + } + } + }, + "404": { + "description": "Not Found - Resource not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TRPCError" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TRPCError" + } + } + } + } + }, + "security": [] + } + }, + "/auth.getSession": { "get": { - "summary": "Validate JWT token", - "description": "Validate JWT token", + "summary": "Get current user session info", + "description": "Get current user session info", "tags": [ "auth" ], - "parameters": [ - { - "name": "input", - "in": "query", - "schema": { - "type": "string", - "description": "SuperJSON wrapped input object" - }, - "description": "SuperJSON wrapped input matching ValidateTokenSchema schema. See components/schemas/ValidateTokenSchema for structure.", - "example": "{\"json\":{\"token\":\"example\"}}" - } - ], + "parameters": [], "responses": { "200": { "description": "Successful tRPC response", @@ -2029,7 +2903,7 @@ "properties": { "data": { "type": "object", - "description": "Response data from auth.validateToken" + "description": "Response data from auth.getSession" } } } @@ -2044,7 +2918,7 @@ "value": { "result": { "data": { - "message": "Response from auth.validateToken", + "message": "Response from auth.getSession", "data": { "id": "uuid-user", "email": "user@example.com", @@ -2076,7 +2950,7 @@ "data": { "code": "BAD_REQUEST", "httpStatus": 400, - "path": "auth.validateToken", + "path": "auth.getSession", "zodError": { "formErrors": [ "Required" @@ -2133,7 +3007,11 @@ } } }, - "security": [] + "security": [ + { + "ClerkAuth": [] + } + ] } }, "/auth.updateProfile": { @@ -2439,6 +3317,7 @@ { "name": "input", "in": "query", + "required": false, "schema": { "type": "string", "description": "SuperJSON wrapped input object" @@ -2476,43 +3355,11 @@ "value": { "result": { "data": { - "device": { - "id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", - "modelName": "example", - "brandName": "example" - }, - "systems": [ - { - "id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", - "name": "example", - "key": "example", - "compatibilityScore": 1, - "confidence": "example", - "dataSource": "example", - "metrics": { - "totalListings": 1, - "uniqueGames": 1, - "avgPerformanceRank": 1, - "developerVerifiedCount": 1, - "totalVotes": 1, - "authoredByDeveloperCount": 1 - }, - "emulators": [ - { - "id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", - "name": "example", - "key": "example", - "listingCount": 1, - "avgCompatibilityScore": 1, - "avgPerformanceRank": 1, - "developerVerifiedCount": 1 - } - ], - "lastUpdated": "example" - } - ], - "generatedAt": "example", - "cacheExpiresIn": 1 + "message": "Response from catalog.getDeviceCompatibility", + "data": { + "id": "uuid-generic", + "name": "Generic Item" + } } } } @@ -2601,8 +3448,8 @@ }, "/cpus.get": { "get": { - "summary": "Get CPUs with search, filtering, and pagination", - "description": "Get CPUs with search, filtering, and pagination", + "summary": "Get CPUs with search, filtering, and pagination.", + "description": "Get CPUs with search, filtering, and pagination.", "tags": [ "cpus" ], @@ -2610,12 +3457,13 @@ { "name": "input", "in": "query", + "required": false, "schema": { "type": "string", "description": "SuperJSON wrapped input object" }, - "description": "SuperJSON wrapped input matching GetCpusSchema schema. See components/schemas/GetCpusSchema for structure.", - "example": "{\"json\":{\"search\":\"mario\",\"limit\":10}}" + "description": "SuperJSON wrapped input matching MobileGetCpusSchema schema. See components/schemas/MobileGetCpusSchema for structure.", + "example": "{\"json\":{}}" } ], "responses": { @@ -2631,8 +3479,7 @@ "description": "tRPC result wrapper containing the actual response data", "properties": { "data": { - "type": "object", - "description": "Response data from cpus.get" + "$ref": "#/components/schemas/MobileCpuListResponseSchema" } } } @@ -2647,10 +3494,29 @@ "value": { "result": { "data": { - "message": "Response from cpus.get", - "data": { - "id": "uuid-generic", - "name": "Generic Item" + "cpus": [ + { + "id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + "brandId": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + "modelName": "example", + "createdAt": "example", + "brand": { + "id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + "name": "example" + }, + "_count": { + "pcListings": 1 + } + } + ], + "pagination": { + "total": 1, + "pages": 1, + "page": 1, + "offset": 1, + "limit": 10, + "hasNextPage": false, + "hasPreviousPage": false } } } @@ -2740,12 +3606,24 @@ }, "/cpus.getById": { "get": { - "summary": "Get CPU by ID", - "description": "Get CPU by ID", + "summary": "Get CPU by ID.", + "description": "Get CPU by ID.", "tags": [ "cpus" ], - "parameters": [], + "parameters": [ + { + "name": "input", + "in": "query", + "required": true, + "schema": { + "type": "string", + "description": "SuperJSON wrapped input object" + }, + "description": "SuperJSON wrapped input matching GetCpuByIdSchema schema. See components/schemas/GetCpuByIdSchema for structure.", + "example": "{\"json\":{\"id\":\"xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx\"}}" + } + ], "responses": { "200": { "description": "Successful tRPC response", @@ -2759,8 +3637,7 @@ "description": "tRPC result wrapper containing the actual response data", "properties": { "data": { - "type": "object", - "description": "Response data from cpus.getById" + "$ref": "#/components/schemas/MobileCpuListItemSchema" } } } @@ -2775,10 +3652,16 @@ "value": { "result": { "data": { - "message": "Response from cpus.getById", - "data": { - "id": "uuid-generic", - "name": "Generic Item" + "id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + "brandId": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + "modelName": "example", + "createdAt": "example", + "brand": { + "id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + "name": "example" + }, + "_count": { + "pcListings": 1 } } } @@ -3005,6 +3888,7 @@ { "name": "input", "in": "query", + "required": true, "schema": { "type": "string", "description": "SuperJSON wrapped input object" @@ -3404,6 +4288,7 @@ { "name": "input", "in": "query", + "required": false, "schema": { "type": "string", "description": "SuperJSON wrapped input object" @@ -3935,6 +4820,7 @@ { "name": "input", "in": "query", + "required": false, "schema": { "type": "string", "description": "SuperJSON wrapped input object" @@ -4075,6 +4961,7 @@ { "name": "input", "in": "query", + "required": true, "schema": { "type": "string", "description": "SuperJSON wrapped input object" @@ -4215,6 +5102,7 @@ { "name": "input", "in": "query", + "required": false, "schema": { "type": "string", "description": "SuperJSON wrapped input object" @@ -4252,31 +5140,13 @@ "value": { "result": { "data": { - "games": [ - { - "id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", - "title": "example", - "systemId": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", - "isErotic": false, - "status": "example", - "createdAt": "example", - "system": { - "id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", - "name": "example" - }, - "_count": { - "listings": 1 - } - } - ], - "pagination": { - "total": 1, - "pages": 1, - "page": 1, - "offset": 1, - "limit": 10, - "hasNextPage": false, - "hasPreviousPage": false + "message": "Response from games.get", + "data": { + "id": "uuid-game", + "title": "Super Mario Bros", + "systemId": "uuid-system", + "imageUrl": "https://example.com/game.jpg", + "status": "APPROVED" } } } @@ -4506,6 +5376,7 @@ { "name": "input", "in": "query", + "required": true, "schema": { "type": "string", "description": "SuperJSON wrapped input object" @@ -4648,6 +5519,7 @@ { "name": "input", "in": "query", + "required": true, "schema": { "type": "string", "description": "SuperJSON wrapped input object" @@ -4790,6 +5662,7 @@ { "name": "input", "in": "query", + "required": true, "schema": { "type": "string", "description": "SuperJSON wrapped input object" @@ -4932,6 +5805,7 @@ { "name": "input", "in": "query", + "required": true, "schema": { "type": "string", "description": "SuperJSON wrapped input object" @@ -5074,6 +5948,7 @@ { "name": "input", "in": "query", + "required": false, "schema": { "type": "string", "description": "SuperJSON wrapped input object" @@ -5216,6 +6091,7 @@ { "name": "input", "in": "query", + "required": true, "schema": { "type": "string", "description": "SuperJSON wrapped input object" @@ -5358,6 +6234,7 @@ { "name": "input", "in": "query", + "required": true, "schema": { "type": "string", "description": "SuperJSON wrapped input object" @@ -5500,6 +6377,7 @@ { "name": "input", "in": "query", + "required": false, "schema": { "type": "string", "description": "SuperJSON wrapped input object" @@ -5642,6 +6520,7 @@ { "name": "input", "in": "query", + "required": true, "schema": { "type": "string", "description": "SuperJSON wrapped input object" @@ -5784,6 +6663,7 @@ { "name": "input", "in": "query", + "required": true, "schema": { "type": "string", "description": "SuperJSON wrapped input object" @@ -5926,6 +6806,7 @@ { "name": "input", "in": "query", + "required": false, "schema": { "type": "string", "description": "SuperJSON wrapped input object" @@ -6068,6 +6949,7 @@ { "name": "input", "in": "query", + "required": true, "schema": { "type": "string", "description": "SuperJSON wrapped input object" @@ -6594,6 +7476,7 @@ { "name": "input", "in": "query", + "required": true, "schema": { "type": "string", "description": "SuperJSON wrapped input object" @@ -6852,8 +7735,8 @@ }, "/gpus.get": { "get": { - "summary": "Get GPUs with search, filtering, and pagination", - "description": "Get GPUs with search, filtering, and pagination", + "summary": "Get GPUs with search, filtering, and pagination.", + "description": "Get GPUs with search, filtering, and pagination.", "tags": [ "gpus" ], @@ -6861,12 +7744,13 @@ { "name": "input", "in": "query", + "required": false, "schema": { "type": "string", "description": "SuperJSON wrapped input object" }, - "description": "SuperJSON wrapped input matching GetGpusSchema schema. See components/schemas/GetGpusSchema for structure.", - "example": "{\"json\":{\"search\":\"mario\",\"limit\":10}}" + "description": "SuperJSON wrapped input matching MobileGetGpusSchema schema. See components/schemas/MobileGetGpusSchema for structure.", + "example": "{\"json\":{}}" } ], "responses": { @@ -6882,8 +7766,7 @@ "description": "tRPC result wrapper containing the actual response data", "properties": { "data": { - "type": "object", - "description": "Response data from gpus.get" + "$ref": "#/components/schemas/MobileGpuListResponseSchema" } } } @@ -6898,10 +7781,29 @@ "value": { "result": { "data": { - "message": "Response from gpus.get", - "data": { - "id": "uuid-generic", - "name": "Generic Item" + "gpus": [ + { + "id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + "brandId": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + "modelName": "example", + "createdAt": "example", + "brand": { + "id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + "name": "example" + }, + "_count": { + "pcListings": 1 + } + } + ], + "pagination": { + "total": 1, + "pages": 1, + "page": 1, + "offset": 1, + "limit": 10, + "hasNextPage": false, + "hasPreviousPage": false } } } @@ -6991,12 +7893,24 @@ }, "/gpus.getById": { "get": { - "summary": "Get GPU by ID", - "description": "Get GPU by ID", + "summary": "Get GPU by ID.", + "description": "Get GPU by ID.", "tags": [ "gpus" ], - "parameters": [], + "parameters": [ + { + "name": "input", + "in": "query", + "required": true, + "schema": { + "type": "string", + "description": "SuperJSON wrapped input object" + }, + "description": "SuperJSON wrapped input matching GetGpuByIdSchema schema. See components/schemas/GetGpuByIdSchema for structure.", + "example": "{\"json\":{\"id\":\"xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx\"}}" + } + ], "responses": { "200": { "description": "Successful tRPC response", @@ -7010,8 +7924,7 @@ "description": "tRPC result wrapper containing the actual response data", "properties": { "data": { - "type": "object", - "description": "Response data from gpus.getById" + "$ref": "#/components/schemas/MobileGpuListItemSchema" } } } @@ -7026,10 +7939,16 @@ "value": { "result": { "data": { - "message": "Response from gpus.getById", - "data": { - "id": "uuid-generic", - "name": "Generic Item" + "id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + "brandId": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + "modelName": "example", + "createdAt": "example", + "brand": { + "id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + "name": "example" + }, + "_count": { + "pcListings": 1 } } } @@ -7119,8 +8038,7 @@ }, "/listingReports.create": { "post": { - "summary": "Create a new listing report (user-facing)", - "description": "Create a new listing report (user-facing)", + "summary": "create - listingReports", "tags": [ "listingReports" ], @@ -7521,6 +8439,7 @@ { "name": "input", "in": "query", + "required": false, "schema": { "type": "string", "description": "SuperJSON wrapped input object" @@ -7665,6 +8584,7 @@ { "name": "input", "in": "query", + "required": false, "schema": { "type": "string", "description": "SuperJSON wrapped input object" @@ -7942,6 +8862,7 @@ { "name": "input", "in": "query", + "required": true, "schema": { "type": "string", "description": "SuperJSON wrapped input object" @@ -8086,6 +9007,7 @@ { "name": "input", "in": "query", + "required": true, "schema": { "type": "string", "description": "SuperJSON wrapped input object" @@ -8230,6 +9152,7 @@ { "name": "input", "in": "query", + "required": true, "schema": { "type": "string", "description": "SuperJSON wrapped input object" @@ -8986,6 +9909,7 @@ { "name": "input", "in": "query", + "required": true, "schema": { "type": "string", "description": "SuperJSON wrapped input object" @@ -9134,6 +10058,7 @@ { "name": "input", "in": "query", + "required": true, "schema": { "type": "string", "description": "SuperJSON wrapped input object" @@ -9733,6 +10658,7 @@ { "name": "input", "in": "query", + "required": true, "schema": { "type": "string", "description": "SuperJSON wrapped input object" @@ -10028,6 +10954,7 @@ { "name": "input", "in": "query", + "required": true, "schema": { "type": "string", "description": "SuperJSON wrapped input object" @@ -10328,6 +11255,7 @@ { "name": "input", "in": "query", + "required": false, "schema": { "type": "string", "description": "SuperJSON wrapped input object" @@ -11021,6 +11949,7 @@ { "name": "input", "in": "query", + "required": false, "schema": { "type": "string", "description": "SuperJSON wrapped input object" @@ -11449,8 +12378,8 @@ }, "/pcListings.cpus": { "get": { - "summary": "Get CPUs for mobile", - "description": "Get CPUs for mobile", + "summary": "Get CPUs for PC compatibility report filters.", + "description": "Get CPUs for PC compatibility report filters.", "tags": [ "pcListings" ], @@ -11458,11 +12387,12 @@ { "name": "input", "in": "query", + "required": false, "schema": { "type": "string", "description": "SuperJSON wrapped input object" }, - "description": "SuperJSON wrapped input matching GetCpusSchema schema. See components/schemas/GetCpusSchema for structure.", + "description": "SuperJSON wrapped input matching MobilePcListingCpusSchema schema. See components/schemas/MobilePcListingCpusSchema for structure.", "example": "{\"json\":{\"search\":\"mario\",\"limit\":10}}" } ], @@ -11479,8 +12409,7 @@ "description": "tRPC result wrapper containing the actual response data", "properties": { "data": { - "type": "object", - "description": "Response data from pcListings.cpus" + "$ref": "#/components/schemas/MobilePcListingCpuResponseSchema" } } } @@ -11495,11 +12424,18 @@ "value": { "result": { "data": { - "message": "Response from pcListings.cpus", - "data": { - "id": "uuid-generic", - "name": "Generic Item" - } + "cpus": [ + { + "id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + "brandId": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + "modelName": "example", + "createdAt": "example", + "brand": { + "id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + "name": "example" + } + } + ] } } } @@ -11588,8 +12524,8 @@ }, "/pcListings.gpus": { "get": { - "summary": "Get GPUs for mobile", - "description": "Get GPUs for mobile", + "summary": "Get GPUs for PC compatibility report filters.", + "description": "Get GPUs for PC compatibility report filters.", "tags": [ "pcListings" ], @@ -11597,11 +12533,12 @@ { "name": "input", "in": "query", + "required": false, "schema": { "type": "string", "description": "SuperJSON wrapped input object" }, - "description": "SuperJSON wrapped input matching GetGpusSchema schema. See components/schemas/GetGpusSchema for structure.", + "description": "SuperJSON wrapped input matching MobilePcListingGpusSchema schema. See components/schemas/MobilePcListingGpusSchema for structure.", "example": "{\"json\":{\"search\":\"mario\",\"limit\":10}}" } ], @@ -11618,8 +12555,7 @@ "description": "tRPC result wrapper containing the actual response data", "properties": { "data": { - "type": "object", - "description": "Response data from pcListings.gpus" + "$ref": "#/components/schemas/MobilePcListingGpuResponseSchema" } } } @@ -11634,11 +12570,18 @@ "value": { "result": { "data": { - "message": "Response from pcListings.gpus", - "data": { - "id": "uuid-generic", - "name": "Generic Item" - } + "gpus": [ + { + "id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + "brandId": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + "modelName": "example", + "createdAt": "example", + "brand": { + "id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + "name": "example" + } + } + ] } } } @@ -11736,6 +12679,7 @@ { "name": "input", "in": "query", + "required": false, "schema": { "type": "string", "description": "SuperJSON wrapped input object" @@ -13312,6 +14256,7 @@ { "name": "input", "in": "query", + "required": true, "schema": { "type": "string", "description": "SuperJSON wrapped input object" @@ -13726,6 +14671,7 @@ { "name": "input", "in": "query", + "required": true, "schema": { "type": "string", "description": "SuperJSON wrapped input object" @@ -16580,6 +17526,7 @@ { "name": "input", "in": "query", + "required": true, "schema": { "type": "string", "description": "SuperJSON wrapped input object" diff --git a/src/app/admin/approvals/page.tsx b/src/app/admin/approvals/page.tsx index 7c02fc93e..a71feb8cb 100644 --- a/src/app/admin/approvals/page.tsx +++ b/src/app/admin/approvals/page.tsx @@ -91,7 +91,6 @@ function AdminApprovalsPage() { const router = useRouter() const table = useAdminTable({ - defaultLimit: 20, defaultSortField: 'createdAt', defaultSortDirection: 'asc', }) diff --git a/src/app/admin/audit-logs/page.tsx b/src/app/admin/audit-logs/page.tsx index ea7066a38..4dc0f82b9 100644 --- a/src/app/admin/audit-logs/page.tsx +++ b/src/app/admin/audit-logs/page.tsx @@ -20,6 +20,7 @@ import { Pagination, LocalizedDate, Code, + Dropdown, } from '@/components/ui' import storageKeys from '@/data/storageKeys' import { useColumnVisibility, type ColumnDefinition } from '@/hooks' @@ -180,36 +181,8 @@ function AdminAuditLogsPage() { searchPlaceholder="Search by actor, target, entity ID, request, IP, user agent..." >
    - - - - + + void - editId: string | null - cpuData: CpuData | null - onSuccess: () => void -} - -function CpuModal(props: Props) { - const createCpu = api.cpus.create.useMutation() - const updateCpu = api.cpus.update.useMutation() - const deviceBrandsQuery = api.deviceBrands.get.useQuery({ limit: 100 }) - - const [brandId, setBrandId] = useState('') - const [modelName, setModelName] = useState('') - const [error, setError] = useState('') - const [success, setSuccess] = useState('') - - // Update form fields when cpuData changes - useEffect(() => { - if (props.cpuData) { - setBrandId(props.cpuData.brand.id) - setModelName(props.cpuData.modelName) - } else { - setBrandId('') - setModelName('') - } - setError('') - setSuccess('') - }, [props.cpuData, props.isOpen]) - - const handleSubmit = async (ev: SubmitEvent) => { - ev.preventDefault() - setError('') - setSuccess('') - try { - const cpuData = { - brandId, - modelName, - } - - if (props.editId) { - await updateCpu.mutateAsync({ - id: props.editId, - ...cpuData, - } satisfies RouterInput['cpus']['update']) - setSuccess('CPU updated!') - props.onSuccess() - } else { - await createCpu.mutateAsync(cpuData satisfies RouterInput['cpus']['create']) - setSuccess('CPU created!') - props.onSuccess() - } - - // Reset form - setBrandId('') - setModelName('') - } catch (err) { - setError(getErrorMessage(err, 'Failed to save CPU.')) - } - } - - return ( - -
    -
    - - setBrandId(value ?? '')} - items={deviceBrandsQuery.data ?? []} - optionToValue={(brand) => brand.id} - optionToLabel={(brand) => brand.name} - placeholder="Select a brand…" - className="w-full" - filterKeys={['name']} - /> -
    - -
    - - setModelName(e.target.value)} - required - className="w-full" - placeholder="e.g., Core i7-13700K" - /> -
    - - {error && ( -
    - {error} -
    - )} - - {success && ( -
    - {success} -
    - )} - -
    - - -
    -
    -
    - ) -} - -export default CpuModal diff --git a/src/app/admin/cpus/components/CpuViewModal.tsx b/src/app/admin/cpus/components/CpuViewModal.tsx deleted file mode 100644 index 330d12901..000000000 --- a/src/app/admin/cpus/components/CpuViewModal.tsx +++ /dev/null @@ -1,49 +0,0 @@ -'use client' - -import { Modal, InputPlaceholder } from '@/components/ui' -import { type RouterOutput } from '@/types/trpc' - -type CpuData = RouterOutput['cpus']['get']['cpus'][number] - -interface Props { - isOpen: boolean - onClose: () => void - cpuData: CpuData | null -} - -function CpuViewModal(props: Props) { - if (!props.cpuData) return null - - const { cpuData } = props - - return ( - -
    -
    - - - - - {cpuData._count && ( - - )} -
    - -
    - -
    -
    -
    - ) -} - -export default CpuViewModal diff --git a/src/app/admin/cpus/page.tsx b/src/app/admin/cpus/page.tsx index 328d60bc4..645bc9f41 100644 --- a/src/app/admin/cpus/page.tsx +++ b/src/app/admin/cpus/page.tsx @@ -1,297 +1,11 @@ -'use client' +import { type Metadata } from 'next' +import AdminCpusView from '@/features/hardware/cpu/client/admin/AdminCpusView' -import { Cpu } from 'lucide-react' -import { useState } from 'react' -import { isEmpty } from 'remeda' -import { useAdminTable } from '@/app/admin/hooks' -import { - AdminPageLayout, - AdminTableContainer, - AdminSearchFilters, - AdminStatsDisplay, - AdminTableNoResults, -} from '@/components/admin' -import { - Badge, - Button, - ColumnVisibilityControl, - SortableHeader, - useConfirmDialog, - Autocomplete, - LoadingSpinner, - DeleteButton, - EditButton, - ViewButton, - Pagination, -} from '@/components/ui' -import storageKeys from '@/data/storageKeys' -import { useColumnVisibility, type ColumnDefinition } from '@/hooks' -import { api } from '@/lib/api' -import toast from '@/lib/toast' -import { type RouterInput, type RouterOutput } from '@/types/trpc' -import getErrorMessage from '@/utils/getErrorMessage' -import { hasPermission, PERMISSIONS } from '@/utils/permission-system' -import CpuModal from './components/CpuModal' -import CpuViewModal from './components/CpuViewModal' - -type CpuSortField = 'brand' | 'modelName' | 'pcListings' -type CpuData = RouterOutput['cpus']['get']['cpus'][number] - -const CPUS_COLUMNS: ColumnDefinition[] = [ - { key: 'brand', label: 'Brand', defaultVisible: true }, - { key: 'model', label: 'Model', defaultVisible: true }, - { key: 'listings', label: 'PC Listings', defaultVisible: true }, - { key: 'actions', label: 'Actions', alwaysVisible: true }, -] - -function AdminCpusPage() { - const table = useAdminTable({ - defaultSortField: 'brand', - defaultSortDirection: 'asc', - }) - - const columnVisibility = useColumnVisibility(CPUS_COLUMNS, { - storageKey: storageKeys.columnVisibility.adminCpus, - }) - - const cpusQuery = api.cpus.get.useQuery({ - search: isEmpty(table.debouncedSearch) ? undefined : table.debouncedSearch, - sortField: table.sortField ?? undefined, - sortDirection: table.sortDirection ?? undefined, - limit: table.limit, - page: table.page, - brandId: table.additionalParams.brandId || undefined, - }) - - const cpusStatsQuery = api.cpus.stats.useQuery() - const brandsQuery = api.deviceBrands.get.useQuery({ limit: 100, category: 'cpu' }) - const deleteCpu = api.cpus.delete.useMutation() - const confirm = useConfirmDialog() - - const [modalOpen, setModalOpen] = useState(false) - const [viewModalOpen, setViewModalOpen] = useState(false) - const [editId, setEditId] = useState(null) - const [cpuData, setCpuData] = useState(null) - - const utils = api.useUtils() - - const userQuery = api.users.me.useQuery() - const canManageDevices = hasPermission(userQuery.data?.permissions, PERMISSIONS.MANAGE_DEVICES) - - const invalidateCpuQueries = () => { - utils.cpus.get.invalidate().catch(console.error) - utils.cpus.options.invalidate().catch(console.error) - utils.cpus.stats.invalidate().catch(console.error) - } - - const openModal = (cpu?: CpuData) => { - setEditId(cpu?.id ?? null) - setCpuData(cpu ?? null) - setModalOpen(true) - } - - const closeModal = () => { - setModalOpen(false) - setEditId(null) - setCpuData(null) - } - - const openViewModal = (cpu: CpuData) => { - setCpuData(cpu) - setViewModalOpen(true) - } - - const closeViewModal = () => { - setViewModalOpen(false) - setCpuData(null) - } - - const handleModalSuccess = () => { - invalidateCpuQueries() - closeModal() - } - - const handleDelete = async (id: string) => { - const confirmed = await confirm({ - title: 'Delete CPU', - description: 'Are you sure you want to delete this CPU? This action cannot be undone.', - }) - - if (!confirmed) return - - try { - await deleteCpu.mutateAsync({ - id, - } satisfies RouterInput['cpus']['delete']) - invalidateCpuQueries() - toast.success('CPU deleted successfully!') - } catch (err) { - toast.error(`Failed to delete CPU: ${getErrorMessage(err)}`) - } - } - - return ( - - - {canManageDevices && } - - } - > - - - - table={table} - searchPlaceholder="Search CPUs..." - onClear={() => table.setAdditionalParam('brandId', '')} - > - table.setAdditionalParam('brandId', value || '')} - items={[{ id: '', name: 'All Brands' }, ...(brandsQuery.data || [])]} - optionToValue={(brand) => brand.id} - optionToLabel={(brand) => brand.name} - className="w-full md:w-64" - placeholder="Filter by brand" - filterKeys={['name']} - /> - - - - {cpusQuery.isPending ? ( - - ) : cpusQuery.data?.cpus.length === 0 ? ( - - ) : ( - - - - {columnVisibility.isColumnVisible('brand') && ( - - )} - {columnVisibility.isColumnVisible('model') && ( - - )} - {columnVisibility.isColumnVisible('listings') && ( - - )} - {columnVisibility.isColumnVisible('actions') && ( - - )} - - - - {cpusQuery.data?.cpus.map((cpu) => ( - - {columnVisibility.isColumnVisible('brand') && ( - - )} - {columnVisibility.isColumnVisible('model') && ( - - )} - {columnVisibility.isColumnVisible('listings') && ( - - )} - {columnVisibility.isColumnVisible('actions') && ( - - )} - - ))} - -
    - Actions -
    - {cpu.brand.name} - - {cpu.modelName} - - {cpu._count.pcListings} - -
    - openViewModal(cpu)} title="View CPU Details" /> - {canManageDevices && ( - openModal(cpu)} title="Edit CPU" /> - )} - {canManageDevices && ( - handleDelete(cpu.id)} - title="Delete CPU" - isLoading={deleteCpu.isPending} - disabled={deleteCpu.isPending} - /> - )} -
    -
    - )} -
    - - {cpusQuery.data && cpusQuery.data.pagination.pages > 1 && ( - table.setPage(newPage)} - /> - )} - - - - -
    - ) +export const metadata: Metadata = { + title: 'CPUs - Admin', + description: 'Manage CPU hardware catalog entries for PC Compatibility Reports.', } -export default AdminCpusPage +export default function AdminCpusPage() { + return +} diff --git a/src/app/admin/data.ts b/src/app/admin/data.ts index 750135e6a..b183ab736 100644 --- a/src/app/admin/data.ts +++ b/src/app/admin/data.ts @@ -63,13 +63,13 @@ export const adminNavItems: AdminNavItem[] = [ href: ADMIN_ROUTES.CPUS, label: 'CPUs', exact: true, - description: 'Manage CPU models for PC compatibility.', + description: 'Manage CPU hardware catalog entries for PC Compatibility Reports.', }, { href: ADMIN_ROUTES.GPUS, label: 'GPUs', exact: true, - description: 'Manage GPU models for PC compatibility.', + description: 'Manage GPU hardware catalog entries for PC Compatibility Reports.', }, { href: ADMIN_ROUTES.EMULATORS, @@ -237,13 +237,13 @@ export const moderatorNavItems: AdminNavItem[] = [ href: ADMIN_ROUTES.CPUS, label: 'CPUs', exact: true, - description: 'Manage CPU models for PC compatibility.', + description: 'Manage CPU hardware catalog entries for PC Compatibility Reports.', }, { href: ADMIN_ROUTES.GPUS, label: 'GPUs', exact: true, - description: 'Manage GPU models for PC compatibility.', + description: 'Manage GPU hardware catalog entries for PC Compatibility Reports.', }, { href: ADMIN_ROUTES.SOCS, diff --git a/src/app/admin/devices/components/DeviceModal.tsx b/src/app/admin/devices/components/DeviceModal.tsx index d697457a4..89b38222f 100644 --- a/src/app/admin/devices/components/DeviceModal.tsx +++ b/src/app/admin/devices/components/DeviceModal.tsx @@ -1,7 +1,8 @@ 'use client' -import { useState, useEffect, type FormEvent } from 'react' +import { useState, type FormEvent } from 'react' import { Button, Input, Modal, Autocomplete } from '@/components/ui' +import { LOOKUP_PAGINATION } from '@/data/constants' import { api } from '@/lib/api' import { type RouterInput, type RouterOutput } from '@/types/trpc' import getErrorMessage from '@/utils/getErrorMessage' @@ -17,37 +18,54 @@ interface Props { } function DeviceModal(props: Props) { + const formKey = [ + props.isOpen ? 'open' : 'closed', + props.editId ?? 'new', + props.deviceData?.id ?? 'no-device', + ].join(':') + + return ( + + + + ) +} + +interface FormProps { + editId: string | null + deviceData: DeviceData | null + onClose: () => void + onSuccess: () => void +} + +function DeviceModalForm(props: FormProps) { const createDevice = api.devices.create.useMutation() const updateDevice = api.devices.update.useMutation() const deviceBrandsQuery = api.deviceBrands.get.useQuery({ limit: 100 }) // TODO: Make this selector async instead of preloading 1000 options. - const socsQuery = api.socs.options.useQuery({ limit: 1000 }) + const socsQuery = api.socs.options.useQuery({ limit: LOOKUP_PAGINATION.MAX_LIMIT }) - const [brandId, setBrandId] = useState('') - const [modelName, setModelName] = useState('') - const [socId, setSocId] = useState('') + const [brandId, setBrandId] = useState(props.deviceData?.brandId ?? '') + const [modelName, setModelName] = useState(props.deviceData?.modelName ?? '') + const [socId, setSocId] = useState(props.deviceData?.socId ?? '') const [error, setError] = useState('') - const [success, setSuccess] = useState('') - - // Update form fields when deviceData changes - useEffect(() => { - if (props.deviceData) { - setBrandId(props.deviceData.brandId) - setModelName(props.deviceData.modelName) - setSocId(props.deviceData.socId ?? '') - } else { - setBrandId('') - setModelName('') - setSocId('') - } - setError('') - setSuccess('') - }, [props.deviceData, props.isOpen]) const handleSubmit = async (ev: FormEvent) => { ev.preventDefault() setError('') - setSuccess('') try { const deviceData = { brandId, @@ -60,115 +78,89 @@ function DeviceModal(props: Props) { id: props.editId, ...deviceData, } satisfies RouterInput['devices']['update']) - setSuccess('Device updated!') props.onSuccess() } else { await createDevice.mutateAsync(deviceData satisfies RouterInput['devices']['create']) - setSuccess('Device created!') props.onSuccess() } - - // Reset form - setBrandId('') - setModelName('') - setSocId('') } catch (err) { setError(getErrorMessage(err, 'Failed to save device.')) } } return ( - -
    -
    - - setBrandId(value ?? '')} - items={deviceBrandsQuery.data ?? []} - optionToValue={(brand) => brand.id} - optionToLabel={(brand) => brand.name} - placeholder="Select a brand..." - className="w-full" - filterKeys={['name']} - /> -
    - -
    - - setModelName(e.target.value)} - required - className="w-full" - placeholder="Enter model name" - /> -
    - -
    - - setSocId(value ?? '')} - items={socsQuery.data?.socs ?? []} - optionToValue={(soc) => soc.id} - optionToLabel={(soc) => `${soc.manufacturer} ${soc.name}`} - placeholder="Select a SoC..." - className="w-full" - filterKeys={['name', 'manufacturer']} - /> -
    - - {error && ( -
    - {error} -
    - )} - - {success && ( -
    - {success} -
    - )} - -
    - - -
    -
    -
    +
    +
    + + setBrandId(value ?? '')} + items={deviceBrandsQuery.data ?? []} + optionToValue={(brand) => brand.id} + optionToLabel={(brand) => brand.name} + placeholder="Select a brand..." + className="w-full" + filterKeys={['name']} + /> +
    + +
    + + setModelName(e.target.value)} + required + className="w-full" + placeholder="Enter model name" + /> +
    + +
    + + setSocId(value ?? '')} + items={socsQuery.data?.socs ?? []} + optionToValue={(soc) => soc.id} + optionToLabel={(soc) => `${soc.manufacturer} ${soc.name}`} + placeholder="Select a SoC..." + className="w-full" + filterKeys={['name', 'manufacturer']} + /> +
    + + {error && ( +
    {error}
    + )} + +
    + + +
    +
    ) } diff --git a/src/app/admin/gpus/components/GpuModal.tsx b/src/app/admin/gpus/components/GpuModal.tsx deleted file mode 100644 index bb6cf5a6e..000000000 --- a/src/app/admin/gpus/components/GpuModal.tsx +++ /dev/null @@ -1,152 +0,0 @@ -'use client' - -import { useState, useEffect, type FormEvent } from 'react' -import { Button, Input, Modal, Autocomplete } from '@/components/ui' -import { api } from '@/lib/api' -import { type RouterInput, type RouterOutput } from '@/types/trpc' -import getErrorMessage from '@/utils/getErrorMessage' - -type GpuData = RouterOutput['gpus']['get']['gpus'][number] - -interface Props { - isOpen: boolean - onClose: () => void - editId: string | null - gpuData: GpuData | null - onSuccess: () => void -} - -function GpuModal(props: Props) { - const createGpu = api.gpus.create.useMutation() - const updateGpu = api.gpus.update.useMutation() - const deviceBrandsQuery = api.deviceBrands.get.useQuery({ limit: 100 }) - - const [brandId, setBrandId] = useState('') - const [modelName, setModelName] = useState('') - const [error, setError] = useState('') - const [success, setSuccess] = useState('') - - // Update form fields when gpuData changes - useEffect(() => { - if (props.gpuData) { - setBrandId(props.gpuData.brand.id) - setModelName(props.gpuData.modelName) - } else { - setBrandId('') - setModelName('') - } - setError('') - setSuccess('') - }, [props.gpuData, props.isOpen]) - - const handleSubmit = async (ev: FormEvent) => { - ev.preventDefault() - setError('') - setSuccess('') - try { - const gpuData = { - brandId, - modelName, - } - - if (props.editId) { - await updateGpu.mutateAsync({ - id: props.editId, - ...gpuData, - } satisfies RouterInput['gpus']['update']) - setSuccess('GPU updated!') - props.onSuccess() - } else { - await createGpu.mutateAsync(gpuData satisfies RouterInput['gpus']['create']) - setSuccess('GPU created!') - props.onSuccess() - } - - // Reset form - setBrandId('') - setModelName('') - } catch (err) { - setError(getErrorMessage(err, 'Failed to save GPU.')) - } - } - - return ( - -
    -
    - - setBrandId(value ?? '')} - items={deviceBrandsQuery.data ?? []} - optionToValue={(brand) => brand.id} - optionToLabel={(brand) => brand.name} - placeholder="Select a brand..." - className="w-full" - filterKeys={['name']} - /> -
    - -
    - - setModelName(e.target.value)} - required - className="w-full" - placeholder="e.g., GeForce RTX 4090" - /> -
    - - {error && ( -
    - {error} -
    - )} - - {success && ( -
    - {success} -
    - )} - -
    - - -
    -
    -
    - ) -} - -export default GpuModal diff --git a/src/app/admin/gpus/page.tsx b/src/app/admin/gpus/page.tsx index 24d2ee21f..c631f80ca 100644 --- a/src/app/admin/gpus/page.tsx +++ b/src/app/admin/gpus/page.tsx @@ -1,297 +1,11 @@ -'use client' +import { type Metadata } from 'next' +import AdminGpusView from '@/features/hardware/gpu/client/admin/AdminGpusView' -import { Gpu } from 'lucide-react' -import { useState } from 'react' -import { isEmpty } from 'remeda' -import { - AdminTableContainer, - AdminSearchFilters, - AdminStatsDisplay, - AdminTableNoResults, - AdminPageLayout, -} from '@/components/admin' -import { - Badge, - Button, - ColumnVisibilityControl, - SortableHeader, - useConfirmDialog, - Autocomplete, - LoadingSpinner, - DeleteButton, - EditButton, - ViewButton, - Pagination, -} from '@/components/ui' -import storageKeys from '@/data/storageKeys' -import { useColumnVisibility, type ColumnDefinition } from '@/hooks' -import { useAdminTable } from '@/hooks/admin' -import { api } from '@/lib/api' -import toast from '@/lib/toast' -import { type RouterInput, type RouterOutput } from '@/types/trpc' -import getErrorMessage from '@/utils/getErrorMessage' -import { hasPermission, PERMISSIONS } from '@/utils/permission-system' -import GpuModal from './components/GpuModal' -import GpuViewModal from './components/GpuViewModal' - -type GpuSortField = 'brand' | 'modelName' | 'pcListings' -type GpuData = RouterOutput['gpus']['get']['gpus'][number] - -const GPUS_COLUMNS: ColumnDefinition[] = [ - { key: 'brand', label: 'Brand', defaultVisible: true }, - { key: 'model', label: 'Model', defaultVisible: true }, - { key: 'listings', label: 'PC Listings', defaultVisible: true }, - { key: 'actions', label: 'Actions', alwaysVisible: true }, -] - -function AdminGpusPage() { - const table = useAdminTable({ - defaultSortField: 'brand', - defaultSortDirection: 'asc', - }) - - const columnVisibility = useColumnVisibility(GPUS_COLUMNS, { - storageKey: storageKeys.columnVisibility.adminGpus, - }) - - const gpusQuery = api.gpus.get.useQuery({ - search: isEmpty(table.debouncedSearch) ? undefined : table.debouncedSearch, - sortField: table.sortField ?? undefined, - sortDirection: table.sortDirection ?? undefined, - limit: table.limit, - page: table.page, - brandId: table.additionalParams.brandId || undefined, - }) - - const gpusStatsQuery = api.gpus.stats.useQuery() - const brandsQuery = api.deviceBrands.get.useQuery({ limit: 100, category: 'gpu' }) - const deleteGpu = api.gpus.delete.useMutation() - const confirm = useConfirmDialog() - - const [modalOpen, setModalOpen] = useState(false) - const [viewModalOpen, setViewModalOpen] = useState(false) - const [editId, setEditId] = useState(null) - const [gpuData, setGpuData] = useState(null) - - const utils = api.useUtils() - - const userQuery = api.users.me.useQuery() - const canManageDevices = hasPermission(userQuery.data?.permissions, PERMISSIONS.MANAGE_DEVICES) - - const invalidateGpuQueries = () => { - utils.gpus.get.invalidate().catch(console.error) - utils.gpus.options.invalidate().catch(console.error) - utils.gpus.stats.invalidate().catch(console.error) - } - - const openModal = (gpu?: GpuData) => { - setEditId(gpu?.id ?? null) - setGpuData(gpu ?? null) - setModalOpen(true) - } - - const closeModal = () => { - setModalOpen(false) - setEditId(null) - setGpuData(null) - } - - const openViewModal = (gpu: GpuData) => { - setGpuData(gpu) - setViewModalOpen(true) - } - - const closeViewModal = () => { - setViewModalOpen(false) - setGpuData(null) - } - - const handleModalSuccess = () => { - invalidateGpuQueries() - closeModal() - } - - const handleDelete = async (id: string) => { - const confirmed = await confirm({ - title: 'Delete GPU', - description: 'Are you sure you want to delete this GPU? This action cannot be undone.', - }) - - if (!confirmed) return - - try { - await deleteGpu.mutateAsync({ - id, - } satisfies RouterInput['gpus']['delete']) - invalidateGpuQueries() - toast.success('GPU deleted successfully!') - } catch (err) { - toast.error(`Failed to delete GPU: ${getErrorMessage(err)}`) - } - } - - return ( - - - {canManageDevices && } - - } - > - - - - table={table} - searchPlaceholder="Search GPUs..." - onClear={() => table.setAdditionalParam('brandId', '')} - > - table.setAdditionalParam('brandId', value || '')} - items={[{ id: '', name: 'All Brands' }, ...(brandsQuery.data || [])]} - optionToValue={(brand) => brand.id} - optionToLabel={(brand) => brand.name} - className="w-full md:w-64" - placeholder="Filter by brand" - filterKeys={['name']} - /> - - - - {gpusQuery.isPending ? ( - - ) : gpusQuery.data?.gpus.length === 0 ? ( - - ) : ( - - - - {columnVisibility.isColumnVisible('brand') && ( - - )} - {columnVisibility.isColumnVisible('model') && ( - - )} - {columnVisibility.isColumnVisible('listings') && ( - - )} - {columnVisibility.isColumnVisible('actions') && ( - - )} - - - - {gpusQuery.data?.gpus.map((gpu) => ( - - {columnVisibility.isColumnVisible('brand') && ( - - )} - {columnVisibility.isColumnVisible('model') && ( - - )} - {columnVisibility.isColumnVisible('listings') && ( - - )} - {columnVisibility.isColumnVisible('actions') && ( - - )} - - ))} - -
    - Actions -
    - {gpu.brand.name} - - {gpu.modelName} - - {gpu._count.pcListings} - -
    - openViewModal(gpu)} title="View GPU Details" /> - {canManageDevices && ( - openModal(gpu)} title="Edit GPU" /> - )} - {canManageDevices && ( - handleDelete(gpu.id)} - title="Delete GPU" - isLoading={deleteGpu.isPending} - disabled={deleteGpu.isPending} - /> - )} -
    -
    - )} -
    - - {gpusQuery.data && gpusQuery.data.pagination.pages > 1 && ( - table.setPage(newPage)} - /> - )} - - - - -
    - ) +export const metadata: Metadata = { + title: 'GPUs - Admin', + description: 'Manage GPU hardware catalog entries for PC Compatibility Reports.', } -export default AdminGpusPage +export default function AdminGpusPage() { + return +} diff --git a/src/app/admin/listings/[id]/edit/components/ListingEditForm.tsx b/src/app/admin/listings/[id]/edit/components/ListingEditForm.tsx index 17dc95210..b64860377 100644 --- a/src/app/admin/listings/[id]/edit/components/ListingEditForm.tsx +++ b/src/app/admin/listings/[id]/edit/components/ListingEditForm.tsx @@ -3,7 +3,7 @@ import { zodResolver } from '@hookform/resolvers/zod' import { useRouter } from 'next/navigation' import { useState, useEffect, useCallback } from 'react' -import { useForm, Controller } from 'react-hook-form' +import { useForm, Controller, useWatch } from 'react-hook-form' import { type z } from 'zod' import { FormValidationSummary, @@ -19,6 +19,7 @@ import { Autocomplete, LocalizedDate, } from '@/components/ui' +import { LOOKUP_PAGINATION } from '@/data/constants' import { api } from '@/lib/api' import toast from '@/lib/toast' import { UpdateListingAdminSchema } from '@/schemas/listing' @@ -70,7 +71,7 @@ function ListingEditForm(props: Props) { if (!query || query.trim().length === 0) return [] const result = await utils.client.games.get.query({ search: query, - limit: 50, + limit: LOOKUP_PAGINATION.AUTOCOMPLETE_LIMIT, }) return result.games.map((game) => ({ id: game.id, @@ -91,7 +92,7 @@ function ListingEditForm(props: Props) { try { const result = await utils.client.emulators.get.query({ search: query || undefined, // Pass undefined instead of empty string - limit: 50, + limit: LOOKUP_PAGINATION.AUTOCOMPLETE_LIMIT, }) return result.emulators.map((emulator) => ({ id: emulator.id, @@ -111,7 +112,7 @@ function ListingEditForm(props: Props) { try { const result = await utils.client.devices.options.query({ search: query || undefined, // Pass undefined instead of empty string - limit: 50, + limit: LOOKUP_PAGINATION.AUTOCOMPLETE_LIMIT, }) return result.devices.map((device) => ({ id: device.id, @@ -159,7 +160,7 @@ function ListingEditForm(props: Props) { value: cfv.value, })) - const { register, handleSubmit, formState, setValue, watch, control, getValues } = + const { register, handleSubmit, formState, setValue, control, getValues } = useForm({ resolver: zodResolver(UpdateListingAdminSchema), defaultValues: { @@ -175,7 +176,7 @@ function ListingEditForm(props: Props) { }) // Watch for selected emulator to fetch its custom fields - const selectedEmulatorId = watch('emulatorId') + const selectedEmulatorId = useWatch({ control, name: 'emulatorId' }) const customFieldsQuery = api.customFieldDefinitions.getByEmulator.useQuery( { emulatorId: selectedEmulatorId }, { enabled: !!selectedEmulatorId, refetchOnWindowFocus: false, refetchOnReconnect: false }, diff --git a/src/app/admin/pc-listing-approvals/page.tsx b/src/app/admin/pc-listing-approvals/page.tsx index 01dd4d80f..80007e3c2 100644 --- a/src/app/admin/pc-listing-approvals/page.tsx +++ b/src/app/admin/pc-listing-approvals/page.tsx @@ -44,6 +44,8 @@ import { } from '@/components/ui' import { POLLING_INTERVALS } from '@/data/constants' import storageKeys from '@/data/storageKeys' +import { getCpuLabel } from '@/features/hardware/cpu/shared/cpu-format' +import { getGpuLabel } from '@/features/hardware/gpu/shared/gpu-format' import { useEmulatorLogos, useLocalStorage, @@ -97,7 +99,6 @@ function PcListingApprovalsPage() { const router = useRouter() const table = useAdminTable({ - defaultLimit: 20, defaultSortField: 'createdAt', defaultSortDirection: 'asc', }) @@ -609,12 +610,12 @@ function PcListingApprovalsPage() { )} {columnVisibility.isColumnVisible('cpu') && ( - {listing.cpu.brand.name} {listing.cpu.modelName} + {getCpuLabel(listing.cpu)} )} {columnVisibility.isColumnVisible('gpu') && ( - {listing.gpu?.brand.name} {listing.gpu?.modelName} + {listing.gpu ? getGpuLabel(listing.gpu) : 'Integrated'} )} {columnVisibility.isColumnVisible('emulator') && ( diff --git a/src/app/admin/pc-processed-listings/page.tsx b/src/app/admin/pc-processed-listings/page.tsx index 18c9ba685..88ad69622 100644 --- a/src/app/admin/pc-processed-listings/page.tsx +++ b/src/app/admin/pc-processed-listings/page.tsx @@ -7,6 +7,8 @@ import { type ProcessedReportHardwareColumn, } from '@/app/admin/components/processed-reports' import storageKeys from '@/data/storageKeys' +import { getCpuLabel } from '@/features/hardware/cpu/shared/cpu-format' +import { getGpuLabel } from '@/features/hardware/gpu/shared/gpu-format' import { useAdminTable } from '@/hooks/admin' import { api } from '@/lib/api' import { logger } from '@/lib/logger' @@ -27,8 +29,8 @@ type ProcessedPcListingSortField = | 'emulator.name' | 'author.name' -function getGpuLabel(listing: ProcessedPcListing): string { - return listing.gpu ? `${listing.gpu.brand.name} ${listing.gpu.modelName}` : 'Integrated / N/A' +function getProcessedGpuLabel(listing: ProcessedPcListing): string { + return listing.gpu ? getGpuLabel(listing.gpu) : 'Integrated / N/A' } const PC_HARDWARE_COLUMNS: ProcessedReportHardwareColumn< @@ -40,14 +42,14 @@ const PC_HARDWARE_COLUMNS: ProcessedReportHardwareColumn< label: 'CPU', sortField: 'cpu', defaultVisible: true, - render: (listing) => `${listing.cpu.brand.name} ${listing.cpu.modelName}`, // TODO: replace with render: (listing) => getCpuLabel(listing.cpu), + render: (listing) => getCpuLabel(listing.cpu), }, { key: 'gpu', label: 'GPU', sortField: 'gpu', defaultVisible: true, - render: getGpuLabel, + render: getProcessedGpuLabel, }, ] diff --git a/src/app/games/[id]/utils/getPcSpecsSummary.ts b/src/app/games/[id]/utils/getPcSpecsSummary.ts index b3dd358bd..11a3af005 100644 --- a/src/app/games/[id]/utils/getPcSpecsSummary.ts +++ b/src/app/games/[id]/utils/getPcSpecsSummary.ts @@ -1,3 +1,5 @@ +import { getCpuLabel } from '@/features/hardware/cpu/shared/cpu-format' +import { getGpuLabel } from '@/features/hardware/gpu/shared/gpu-format' import type { RouterOutput } from '@/types/trpc' type Game = NonNullable @@ -11,8 +13,8 @@ interface PcSpecsSummary { export function getPcSpecsSummary(listing: PcListing): PcSpecsSummary { const details = ( [ - listing.cpu && { label: 'CPU', value: `${listing.cpu.brand.name} ${listing.cpu.modelName}` }, - listing.gpu && { label: 'GPU', value: `${listing.gpu.brand.name} ${listing.gpu.modelName}` }, + listing.cpu && { label: 'CPU', value: getCpuLabel(listing.cpu) }, + listing.gpu && { label: 'GPU', value: getGpuLabel(listing.gpu) }, listing.memorySize !== null && listing.memorySize !== undefined ? { label: 'Memory', value: `${listing.memorySize}GB RAM` } : null, diff --git a/src/app/home/components/HomeTrendingDevices.tsx b/src/app/home/components/HomeTrendingDevices.tsx index fd3564b72..79713ef11 100644 --- a/src/app/home/components/HomeTrendingDevices.tsx +++ b/src/app/home/components/HomeTrendingDevices.tsx @@ -5,7 +5,7 @@ import { TrendingUp, ChevronRight, Smartphone, Cpu } from 'lucide-react' import Link from 'next/link' import { useState, useMemo } from 'react' import { RetroCatalogIndicator } from '@/components/retrocatalog' -import { CACHE_DURATIONS, HOME_PAGE_LIMITS } from '@/data/constants' +import { HOME_PAGE_LIMITS } from '@/data/constants' import analytics from '@/lib/analytics' import { api } from '@/lib/api' import { cn } from '@/lib/utils' @@ -18,15 +18,9 @@ const TIME_RANGE_LABELS: Record = { } export function HomeTrendingDevices() { - const trendingDevicesQuery = api.devices.trendingSummary.useQuery( - { - limit: HOME_PAGE_LIMITS.TRENDING_DEVICES, - }, - { - staleTime: CACHE_DURATIONS.LOOKUP, - gcTime: CACHE_DURATIONS.LOOKUP_GC, - }, - ) + const trendingDevicesQuery = api.devices.trendingSummary.useQuery({ + limit: HOME_PAGE_LIMITS.TRENDING_DEVICES, + }) const [activeTimeRange, setActiveTimeRange] = useState('thisMonth') diff --git a/src/app/listings/ListingsPage.tsx b/src/app/listings/ListingsPage.tsx index 6d3f869cc..e0be88574 100644 --- a/src/app/listings/ListingsPage.tsx +++ b/src/app/listings/ListingsPage.tsx @@ -11,6 +11,7 @@ import { MobileFiltersFab, ListingsTableSkeleton, } from '@/app/listings/shared/components' +import { shouldUseAsyncListingFilters } from '@/app/listings/shared/utils/asyncListingFilters' import CommunitySupportBanner from '@/components/banners/CommunitySupportBanner' import { EmulatorIcon, SystemIcon } from '@/components/icons' import { BannedUserBadge } from '@/components/ui/BannedUserBadge' @@ -29,7 +30,7 @@ import { SuccessRateBar } from '@/components/ui/SuccessRateBar' import { EditButton, ViewButton } from '@/components/ui/table-buttons' import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/Tooltip' import { VerifiedDeveloperBadge } from '@/components/ui/VerifiedDeveloperBadge' -import { CACHE_DURATIONS } from '@/data/constants' +import { CACHE_DURATIONS, LOOKUP_PAGINATION } from '@/data/constants' import storageKeys from '@/data/storageKeys' import { useEmulatorLogos, @@ -66,9 +67,7 @@ const LISTINGS_COLUMNS: ColumnDefinition[] = [ { key: 'actions', label: 'Actions', alwaysVisible: true }, ] -const LOOKUP_DATA_STALE_TIME = CACHE_DURATIONS.LOOKUP -const LOOKUP_DATA_GC_TIME = CACHE_DURATIONS.LOOKUP_GC -const USE_ASYNC_LISTING_FILTERS = process.env.NEXT_PUBLIC_ENABLE_ASYNC_LISTINGS_FILTERS === 'true' +const USE_ASYNC_LISTING_FILTERS = shouldUseAsyncListingFilters() function ListingsPage() { const { isSignedIn } = useUser() @@ -112,39 +111,17 @@ function ListingsPage() { socIds: listingsState.socIds, }) - const systemsQuery = api.systems.get.useQuery(undefined, { - staleTime: LOOKUP_DATA_STALE_TIME, - gcTime: LOOKUP_DATA_GC_TIME, - }) - // TODO: Remove this legacy fallback once async filters no longer need an opt-out. + const systemsQuery = api.systems.get.useQuery() const devicesQuery = api.devices.options.useQuery( - { limit: 10000 }, - { - enabled: !USE_ASYNC_LISTING_FILTERS, - staleTime: LOOKUP_DATA_STALE_TIME, - gcTime: LOOKUP_DATA_GC_TIME, - }, + { limit: LOOKUP_PAGINATION.MAX_LIMIT }, + { enabled: !USE_ASYNC_LISTING_FILTERS }, ) - // TODO: Remove this legacy fallback once async filters no longer need an opt-out. const socsQuery = api.socs.options.useQuery( - { limit: 10000 }, - { - enabled: !USE_ASYNC_LISTING_FILTERS, - staleTime: LOOKUP_DATA_STALE_TIME, - gcTime: LOOKUP_DATA_GC_TIME, - }, + { limit: LOOKUP_PAGINATION.MAX_LIMIT }, + { enabled: !USE_ASYNC_LISTING_FILTERS }, ) - const emulatorsQuery = api.emulators.get.useQuery( - { limit: 100 }, - { - staleTime: LOOKUP_DATA_STALE_TIME, - gcTime: LOOKUP_DATA_GC_TIME, - }, - ) - const performanceScalesQuery = api.listings.performanceScales.useQuery(undefined, { - staleTime: LOOKUP_DATA_STALE_TIME, - gcTime: LOOKUP_DATA_GC_TIME, - }) + const emulatorsQuery = api.emulators.get.useQuery({ limit: 100 }) + const performanceScalesQuery = api.listings.performanceScales.useQuery() const filterParams: RouterInput['listings']['get'] = { page: listingsState.page, diff --git a/src/app/listings/components/ListingsFiltersContent.tsx b/src/app/listings/components/ListingsFiltersContent.tsx index 627f87b29..e553cd6a6 100644 --- a/src/app/listings/components/ListingsFiltersContent.tsx +++ b/src/app/listings/components/ListingsFiltersContent.tsx @@ -3,6 +3,7 @@ import { motion } from 'framer-motion' import { Joystick, MonitorSmartphone, Cpu, Gamepad, Rocket } from 'lucide-react' import { ActiveFiltersSummary, ListingsSearchBar } from '@/app/listings/shared/components' +import { shouldUseAsyncListingFilters } from '@/app/listings/shared/utils/asyncListingFilters' import { buildActiveFilterItems } from '@/app/listings/shared/utils/buildActiveFilterItems' import { MultiSelect } from '@/components/ui' import { @@ -38,7 +39,7 @@ interface Props { } export default function ListingsFiltersContent(props: Props) { - const ENABLE_ASYNC_LISTINGS = process.env.NEXT_PUBLIC_ENABLE_ASYNC_LISTINGS_FILTERS === 'true' + const ENABLE_ASYNC_LISTINGS = shouldUseAsyncListingFilters() const hasActiveFilters = props.systemIds.length > 0 || diff --git a/src/app/listings/components/filters/AsyncDeviceFilterSelect.test.tsx b/src/app/listings/components/filters/AsyncDeviceFilterSelect.test.tsx index 35fb6395f..cd4276a35 100644 --- a/src/app/listings/components/filters/AsyncDeviceFilterSelect.test.tsx +++ b/src/app/listings/components/filters/AsyncDeviceFilterSelect.test.tsx @@ -1,5 +1,6 @@ import { render, screen, fireEvent, act } from '@testing-library/react' import { describe, it, expect, vi, beforeAll, beforeEach } from 'vitest' +import { LOOKUP_PAGINATION } from '@/data/constants' import type AsyncDeviceFilterSelectComponent from './AsyncDeviceFilterSelect' const apiMocks = vi.hoisted(() => ({ @@ -30,23 +31,18 @@ interface IdsInput { interface OptionsQueryDescriptor { input: OptionsInput - queryOptions: unknown + queryOptions: unknown | undefined } interface QueryProxy { devices: { - options: (input: OptionsInput, queryOptions: unknown) => OptionsQueryDescriptor + options: (input: OptionsInput, queryOptions?: unknown) => OptionsQueryDescriptor } } let requestedOptionInputs: OptionsInput[] = [] let requestedQueryOptions: unknown[] = [] -const queryOptions = expect.objectContaining({ - staleTime: expect.any(Number), - gcTime: expect.any(Number), -}) - const firstPageData = { devices: [ { @@ -124,8 +120,10 @@ describe('AsyncDeviceFilterSelect', () => { { ids: ['device-selected'] }, expect.objectContaining({ enabled: true }), ) - expect(requestedOptionInputs).toEqual([{ search: undefined, limit: 50, offset: 0 }]) - expect(requestedQueryOptions[0]).toEqual(queryOptions) + expect(requestedOptionInputs).toEqual([ + { search: undefined, limit: LOOKUP_PAGINATION.DEFAULT_LIMIT, offset: 0 }, + ]) + expect(requestedQueryOptions[0]).toBeUndefined() fireEvent.click(screen.getByRole('button', { name: 'Devices multi-select' })) expect(screen.getByText('Retroid Pocket 5')).toBeInTheDocument() @@ -165,8 +163,10 @@ describe('AsyncDeviceFilterSelect', () => { vi.advanceTimersByTime(300) }) - expect(requestedOptionInputs).toEqual([{ search: 'odin', limit: 50, offset: 0 }]) - expect(requestedQueryOptions[0]).toEqual(queryOptions) + expect(requestedOptionInputs).toEqual([ + { search: 'odin', limit: LOOKUP_PAGINATION.DEFAULT_LIMIT, offset: 0 }, + ]) + expect(requestedQueryOptions[0]).toBeUndefined() } finally { vi.useRealTimers() } @@ -183,9 +183,13 @@ describe('AsyncDeviceFilterSelect', () => { fireEvent.scroll(scrollContainer) expect(requestedOptionInputs).toEqual([ - { search: undefined, limit: 50, offset: 0 }, - { search: undefined, limit: 50, offset: 50 }, + { search: undefined, limit: LOOKUP_PAGINATION.DEFAULT_LIMIT, offset: 0 }, + { + search: undefined, + limit: LOOKUP_PAGINATION.DEFAULT_LIMIT, + offset: LOOKUP_PAGINATION.DEFAULT_LIMIT, + }, ]) - expect(requestedQueryOptions[1]).toEqual(queryOptions) + expect(requestedQueryOptions[1]).toBeUndefined() }) }) diff --git a/src/app/listings/components/filters/AsyncDeviceFilterSelect.tsx b/src/app/listings/components/filters/AsyncDeviceFilterSelect.tsx index c07ab6733..6d3745a6f 100644 --- a/src/app/listings/components/filters/AsyncDeviceFilterSelect.tsx +++ b/src/app/listings/components/filters/AsyncDeviceFilterSelect.tsx @@ -2,7 +2,7 @@ import { type ReactNode, useCallback, useMemo, useState } from 'react' import AsyncMultiSelect from '@/components/ui/form/async-multi-select/AsyncMultiSelect' -import { CACHE_DURATIONS } from '@/data/constants' +import { LOOKUP_PAGINATION } from '@/data/constants' import { api } from '@/lib/api' interface Props { @@ -15,27 +15,22 @@ interface Props { maxDisplayed?: number } -const PAGE_SIZE = 50 -const LOOKUP_DATA_QUERY_OPTIONS = { - staleTime: CACHE_DURATIONS.LOOKUP, - gcTime: CACHE_DURATIONS.LOOKUP_GC, -} - export default function AsyncDeviceFilterSelect(props: Props) { const [query, setQuery] = useState('') const [pageOffsets, setPageOffsets] = useState([0]) const byIdsQuery = api.devices.getByIds.useQuery( { ids: props.value }, - { ...LOOKUP_DATA_QUERY_OPTIONS, enabled: props.value.length > 0 }, + { enabled: props.value.length > 0 }, ) const pageQueries = api.useQueries((t) => pageOffsets.map((offset) => - t.devices.options( - { search: query || undefined, limit: PAGE_SIZE, offset }, - LOOKUP_DATA_QUERY_OPTIONS, - ), + t.devices.options({ + search: query || undefined, + limit: LOOKUP_PAGINATION.DEFAULT_LIMIT, + offset, + }), ), ) @@ -66,7 +61,10 @@ export default function AsyncDeviceFilterSelect(props: Props) { const isFetching = pageQueries.some((pageQuery) => pageQuery.isFetching) const handleLoadMore = useCallback(() => { - setPageOffsets((offsets) => [...offsets, offsets[offsets.length - 1] + PAGE_SIZE]) + setPageOffsets((offsets) => [ + ...offsets, + offsets[offsets.length - 1] + LOOKUP_PAGINATION.DEFAULT_LIMIT, + ]) }, []) const handleQueryChange = useCallback((q: string) => { diff --git a/src/app/listings/components/filters/AsyncSocFilterSelect.tsx b/src/app/listings/components/filters/AsyncSocFilterSelect.tsx index 2b1759b90..4dea76e9e 100644 --- a/src/app/listings/components/filters/AsyncSocFilterSelect.tsx +++ b/src/app/listings/components/filters/AsyncSocFilterSelect.tsx @@ -2,7 +2,7 @@ import { type ReactNode, useCallback, useMemo, useState } from 'react' import AsyncMultiSelect from '@/components/ui/form/async-multi-select/AsyncMultiSelect' -import { CACHE_DURATIONS } from '@/data/constants' +import { LOOKUP_PAGINATION } from '@/data/constants' import { api } from '@/lib/api' interface Props { @@ -15,27 +15,22 @@ interface Props { maxDisplayed?: number } -const PAGE_SIZE = 50 -const LOOKUP_DATA_QUERY_OPTIONS = { - staleTime: CACHE_DURATIONS.LOOKUP, - gcTime: CACHE_DURATIONS.LOOKUP_GC, -} - export default function AsyncSocFilterSelect(props: Props) { const [query, setQuery] = useState('') const [pageOffsets, setPageOffsets] = useState([0]) const byIdsQuery = api.socs.getByIds.useQuery( { ids: props.value }, - { ...LOOKUP_DATA_QUERY_OPTIONS, enabled: props.value.length > 0 }, + { enabled: props.value.length > 0 }, ) const pageQueries = api.useQueries((t) => pageOffsets.map((offset) => - t.socs.options( - { search: query || undefined, limit: PAGE_SIZE, offset }, - LOOKUP_DATA_QUERY_OPTIONS, - ), + t.socs.options({ + search: query || undefined, + limit: LOOKUP_PAGINATION.DEFAULT_LIMIT, + offset, + }), ), ) @@ -66,7 +61,10 @@ export default function AsyncSocFilterSelect(props: Props) { const isFetching = pageQueries.some((pageQuery) => pageQuery.isFetching) const handleLoadMore = useCallback(() => { - setPageOffsets((offsets) => [...offsets, offsets[offsets.length - 1] + PAGE_SIZE]) + setPageOffsets((offsets) => [ + ...offsets, + offsets[offsets.length - 1] + LOOKUP_PAGINATION.DEFAULT_LIMIT, + ]) }, []) const handleQueryChange = useCallback((q: string) => { diff --git a/src/app/listings/hooks/useEmulatorLoader.ts b/src/app/listings/hooks/useEmulatorLoader.ts index 2594e8f67..131bfa86f 100644 --- a/src/app/listings/hooks/useEmulatorLoader.ts +++ b/src/app/listings/hooks/useEmulatorLoader.ts @@ -1,4 +1,5 @@ import { useCallback, useState } from 'react' +import { LOOKUP_PAGINATION } from '@/data/constants' import { api } from '@/lib/api' import { type EmulatorOption, type GameOption } from '../components/shared' @@ -17,7 +18,10 @@ export function useEmulatorLoader(selectedGame: GameOption | null) { } try { - const result = await utils.emulators.get.fetch({ search: query }) + const result = await utils.emulators.get.fetch({ + search: query, + limit: LOOKUP_PAGINATION.AUTOCOMPLETE_LIMIT, + }) const filteredEmulators = result.emulators .filter((emulator) => diff --git a/src/app/listings/hooks/useGameLoader.ts b/src/app/listings/hooks/useGameLoader.ts index c7987a74c..000ede69c 100644 --- a/src/app/listings/hooks/useGameLoader.ts +++ b/src/app/listings/hooks/useGameLoader.ts @@ -1,4 +1,5 @@ import { useCallback, useState } from 'react' +import { LOOKUP_PAGINATION } from '@/data/constants' import { api } from '@/lib/api' import { type GameOption } from '../components/shared' @@ -13,7 +14,7 @@ export function useGameLoader() { try { const result = await utils.games.get.fetch({ search: query, - limit: 20, + limit: LOOKUP_PAGINATION.AUTOCOMPLETE_LIMIT, listingFilter: 'all', }) return ( diff --git a/src/app/listings/new/NewListingPage.tsx b/src/app/listings/new/NewListingPage.tsx index 45023c000..ceed1c551 100644 --- a/src/app/listings/new/NewListingPage.tsx +++ b/src/app/listings/new/NewListingPage.tsx @@ -13,12 +13,12 @@ import { useState, type ChangeEvent, } from 'react' -import { useForm, Controller } from 'react-hook-form' +import { useForm, Controller, useWatch } 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 { CACHE_DURATIONS } from '@/data/constants' +import { CACHE_DURATIONS, LOOKUP_PAGINATION } from '@/data/constants' import { useSubmitWithHumanVerification } from '@/features/human-verification/client' import analytics from '@/lib/analytics' import { api } from '@/lib/api' @@ -51,9 +51,17 @@ import { reconcileDriverValue } from '../components/shared/custom-fields/driverV export type ListingFormValues = RouterInput['listings']['create'] const HIGHLIGHT_DURATION_MS = 1800 -const LOOKUP_DATA_QUERY_OPTIONS = { - staleTime: CACHE_DURATIONS.LOOKUP, - gcTime: CACHE_DURATIONS.LOOKUP_GC, +type ImportSummary = { filled: number; missing: string[] } +type ImportFeedbackState = { + emulatorSlug: string | null + highlightedFieldIds: string[] + summary: ImportSummary | null +} + +const EMPTY_IMPORT_FEEDBACK: ImportFeedbackState = { + emulatorSlug: null, + highlightedFieldIds: [], + summary: null, } function AddListingPage() { @@ -69,16 +77,9 @@ function AddListingPage() { const [selectedDevice, setSelectedDevice] = useState(null) const [deviceSearchTerm, setDeviceSearchTerm] = useState('') const [emulatorInputFocus, setEmulatorInputFocus] = useState(false) - const [parsedCustomFields, setParsedCustomFields] = useState( - [], - ) - const [schemaState, setSchemaState] = useState< - typeof listingFormSchema | ReturnType - >(listingFormSchema) - const [highlightedFieldIds, setHighlightedFieldIds] = useState([]) - const [importSummary, setImportSummary] = useState<{ filled: number; missing: string[] } | null>( - null, - ) + const [importFeedback, setImportFeedback] = useState(EMPTY_IMPORT_FEEDBACK) + const schemaRef = useRef>(listingFormSchema) + const previousCustomFieldIdsKeyRef = useRef('') const importHighlightTimeoutRef = useRef(null) const fileInputRef = useRef(null) @@ -87,7 +88,8 @@ function AddListingPage() { useEmulatorLoader(selectedGame) const form = useForm({ - resolver: zodResolver(schemaState), + resolver: (values, context, options) => + zodResolver(schemaRef.current)(values, context, options), defaultValues: { gameId: gameIdFromUrl ?? '', deviceId: '', @@ -98,7 +100,7 @@ function AddListingPage() { }, }) - const selectedEmulatorId = form.watch('emulatorId') + const selectedEmulatorId = useWatch({ control: form.control, name: 'emulatorId' }) const selectedEmulatorOption = useMemo(() => { if (!selectedEmulatorId) return undefined @@ -110,6 +112,48 @@ function AddListingPage() { refetchOnWindowFocus: false, refetchOnReconnect: false, }) + const customFieldDefinitionsQuery = api.customFieldDefinitions.getByEmulator.useQuery( + { emulatorId: selectedEmulatorId }, + { + enabled: !!selectedEmulatorId && selectedEmulatorId.trim() !== '', + refetchOnWindowFocus: false, + refetchOnReconnect: false, + }, + ) + const parsedCustomFields = useMemo(() => { + if (!customFieldDefinitionsQuery.data) return [] + + return customFieldDefinitionsQuery.data.map((field): CustomFieldDefinitionWithOptions => { + const parsedOptions = parseCustomFieldOptions(field) + return { + ...field, + parsedOptions, + defaultValue: field.defaultValue as string | number | boolean | null | undefined, + } + }) + }, [customFieldDefinitionsQuery.data]) + const customFieldIdsKey = useMemo( + () => parsedCustomFields.map((field) => field.id).join('|'), + [parsedCustomFields], + ) + const normalizedEmulatorName = ( + selectedEmulatorOption?.name ?? + customFieldDefinitionsQuery.data?.[0]?.emulator?.name ?? + '' + ) + .trim() + .toLowerCase() + const importerSlugMap: Record = { + eden: 'eden', + azahar: 'azahar', + gamenative: 'gamenative', + } + const selectedEmulatorSlug = importerSlugMap[normalizedEmulatorName] ?? null + const importSummary = + importFeedback.emulatorSlug === selectedEmulatorSlug ? importFeedback.summary : null + const highlightedFieldIds = + importFeedback.emulatorSlug === selectedEmulatorSlug ? importFeedback.highlightedFieldIds : [] + const handleImportResult = useCallback( (result: { values: { id: string; value: unknown }[] @@ -164,14 +208,20 @@ function AddListingPage() { }, 0) const uniqueMissing = Array.from(new Set(result.missing)) - setImportSummary({ filled: changedCount, missing: uniqueMissing }) - - setHighlightedFieldIds(result.values.map((entry) => entry.id)) + const nextHighlightedFieldIds = result.values.map((entry) => entry.id) + setImportFeedback({ + emulatorSlug: selectedEmulatorSlug, + highlightedFieldIds: nextHighlightedFieldIds, + summary: { filled: changedCount, missing: uniqueMissing }, + }) if (importHighlightTimeoutRef.current) { window.clearTimeout(importHighlightTimeoutRef.current) } importHighlightTimeoutRef.current = window.setTimeout(() => { - setHighlightedFieldIds([]) + setImportFeedback((currentFeedback) => { + if (currentFeedback.emulatorSlug !== selectedEmulatorSlug) return currentFeedback + return { ...currentFeedback, highlightedFieldIds: [] } + }) }, HIGHLIGHT_DURATION_MS) const changedFieldsMessage = @@ -187,37 +237,10 @@ function AddListingPage() { result.warnings.forEach((warning) => toast.warning(warning)) }, - [form, parsedCustomFields, driverVersionsQuery.data?.releases], + [form, parsedCustomFields, driverVersionsQuery.data?.releases, selectedEmulatorSlug], ) - const performanceScalesQuery = api.listings.performanceScales.useQuery( - undefined, - LOOKUP_DATA_QUERY_OPTIONS, - ) - const customFieldDefinitionsQuery = api.customFieldDefinitions.getByEmulator.useQuery( - { emulatorId: selectedEmulatorId }, - { - enabled: !!selectedEmulatorId && selectedEmulatorId.trim() !== '', - refetchOnWindowFocus: false, - refetchOnReconnect: false, - }, - ) - - const normalizedEmulatorName = ( - selectedEmulatorOption?.name ?? - customFieldDefinitionsQuery.data?.[0]?.emulator?.name ?? - '' - ) - .trim() - .toLowerCase() - - const importerSlugMap: Record = { - eden: 'eden', - azahar: 'azahar', - gamenative: 'gamenative', - } - - const selectedEmulatorSlug = importerSlugMap[normalizedEmulatorName] ?? null + const performanceScalesQuery = api.listings.performanceScales.useQuery() const { importFile: importEmulatorConfig, @@ -275,7 +298,7 @@ function AddListingPage() { try { const result = await utils.devices.options.fetch({ search: query, - limit: 50, + limit: LOOKUP_PAGINATION.AUTOCOMPLETE_LIMIT, }) const devices = result.devices || [] return devices @@ -309,7 +332,7 @@ function AddListingPage() { } }, []) - const selectedGameId = form.watch('gameId') + const selectedGameId = useWatch({ control: form.control, name: 'gameId' }) const { isInitialGameLoaded } = usePreSelectedGame({ gameIdFromUrl, form, @@ -326,11 +349,8 @@ function AddListingPage() { const game = games.find((g) => g.id === selectedGameId) if (game) setSelectedGame(game) }) - } else if (isInitialGameLoaded && !selectedGameId) { - setSelectedGame(null) - form.setValue('emulatorId', '') } - }, [selectedGameId, selectedGame, gameSearchTerm, loadGameItems, form, isInitialGameLoaded]) + }, [selectedGameId, selectedGame, gameSearchTerm, loadGameItems, isInitialGameLoaded]) // Clear emulator when game changes and load initial emulators useEffect(() => { @@ -342,37 +362,17 @@ function AddListingPage() { } }, [selectedGame, form, loadEmulatorItems, setAvailableEmulators]) - // Update custom field definitions when emulator changes + // Sync custom field defaults when emulator-specific definitions change. useEffect(() => { - if (!customFieldDefinitionsQuery.data) return - - const parsed = customFieldDefinitionsQuery.data.map( - (field): CustomFieldDefinitionWithOptions => { - const parsedOptions = parseCustomFieldOptions(field) - return { - ...field, - parsedOptions, - defaultValue: field.defaultValue as string | number | boolean | null | undefined, - } - }, - ) - - const isSameAsCurrent = - parsedCustomFields.length === parsed.length && - parsedCustomFields.every((f, i) => f.id === parsed[i]?.id) - if (isSameAsCurrent) return - - setParsedCustomFields(parsed) - - const dynamicSchema = createDynamicListingSchema(parsed) - setSchemaState(dynamicSchema) + schemaRef.current = createDynamicListingSchema(parsedCustomFields) + if (previousCustomFieldIdsKeyRef.current === customFieldIdsKey) return + previousCustomFieldIdsKeyRef.current = customFieldIdsKey const currentValues = form.getValues() - form.reset() form.reset(currentValues) - const currentCustomValues = form.watch('customFieldValues') ?? [] - const newCustomValues = parsed.map((field) => { + const currentCustomValues = form.getValues('customFieldValues') ?? [] + const newCustomValues = parsedCustomFields.map((field) => { const existingValueObj = currentCustomValues.find( (cv) => cv.customFieldDefinitionId === field.id, ) @@ -386,7 +386,7 @@ function AddListingPage() { } }) form.setValue('customFieldValues', newCustomValues) - }, [customFieldDefinitionsQuery.data, form, parsedCustomFields]) + }, [customFieldIdsKey, form, parsedCustomFields]) const createListingMutation = api.listings.create.useMutation({ onSuccess: async (data) => { @@ -418,11 +418,6 @@ function AddListingPage() { }, }) - useEffect(() => { - setImportSummary(null) - setHighlightedFieldIds([]) - }, [selectedEmulatorSlug]) - const onSubmit = async (data: ListingFormValues) => { if (!currentUserQuery.data?.id) { return toast.error('You must be signed in to create a Compatibility Report.') @@ -467,6 +462,7 @@ function AddListingPage() { onGameSelect={(game: GameOption | null) => { setSelectedGame(game) if (game) return + form.setValue('gameId', '') form.setValue('emulatorId', '') form.setValue('customFieldValues', []) }} diff --git a/src/app/listings/shared/utils/asyncListingFilters.ts b/src/app/listings/shared/utils/asyncListingFilters.ts new file mode 100644 index 000000000..00796785f --- /dev/null +++ b/src/app/listings/shared/utils/asyncListingFilters.ts @@ -0,0 +1,3 @@ +export function shouldUseAsyncListingFilters(): boolean { + return process.env.NEXT_PUBLIC_ENABLE_ASYNC_LISTINGS_FILTERS !== 'false' +} diff --git a/src/app/pc-listings/PcListingsPage.tsx b/src/app/pc-listings/PcListingsPage.tsx index 5d4a277e8..0c3237807 100644 --- a/src/app/pc-listings/PcListingsPage.tsx +++ b/src/app/pc-listings/PcListingsPage.tsx @@ -10,6 +10,7 @@ import { MobileFilterSheet, ListingsTableSkeleton, } from '@/app/listings/shared/components' +import { shouldUseAsyncListingFilters } from '@/app/listings/shared/utils/asyncListingFilters' import CommunitySupportBanner from '@/components/banners/CommunitySupportBanner' import { EmulatorIcon, SystemIcon } from '@/components/icons' import { @@ -31,8 +32,10 @@ import { TooltipTrigger, ViewButton, } from '@/components/ui' -import { CACHE_DURATIONS } from '@/data/constants' +import { LOOKUP_PAGINATION } from '@/data/constants' import storageKeys from '@/data/storageKeys' +import { getCpuLabel } from '@/features/hardware/cpu/shared/cpu-format' +import { getGpuLabel } from '@/features/hardware/gpu/shared/gpu-format' import { useEmulatorLogos, useLocalStorage, @@ -70,11 +73,7 @@ const PC_LISTINGS_COLUMNS: ColumnDefinition[] = [ { key: 'actions', label: 'Actions', alwaysVisible: true }, ] -const LOOKUP_DATA_QUERY_OPTIONS = { - staleTime: CACHE_DURATIONS.LOOKUP, - gcTime: CACHE_DURATIONS.LOOKUP_GC, -} -const USE_ASYNC_LISTING_FILTERS = process.env.NEXT_PUBLIC_ENABLE_ASYNC_LISTINGS_FILTERS === 'true' +const USE_ASYNC_LISTING_FILTERS = shouldUseAsyncListingFilters() function PcListingsPage() { const router = useRouter() @@ -103,22 +102,17 @@ function PcListingsPage() { const isAdmin = userRole ? hasRolePermission(userRole, Role.ADMIN) : false const isModerator = userRole ? roleIncludesRole(userRole, Role.MODERATOR) : false - // TODO: Remove this legacy fallback once async PC filters no longer need an opt-out. const cpusQuery = api.cpus.options.useQuery( - { limit: 1000 }, - { ...LOOKUP_DATA_QUERY_OPTIONS, enabled: !USE_ASYNC_LISTING_FILTERS }, + { limit: LOOKUP_PAGINATION.MAX_LIMIT }, + { enabled: !USE_ASYNC_LISTING_FILTERS }, ) - // TODO: Remove this legacy fallback once async PC filters no longer need an opt-out. const gpusQuery = api.gpus.options.useQuery( - { limit: 1000 }, - { ...LOOKUP_DATA_QUERY_OPTIONS, enabled: !USE_ASYNC_LISTING_FILTERS }, - ) - const emulatorsQuery = api.emulators.get.useQuery({ limit: 100 }, LOOKUP_DATA_QUERY_OPTIONS) - const performanceScalesQuery = api.listings.performanceScales.useQuery( - undefined, - LOOKUP_DATA_QUERY_OPTIONS, + { limit: LOOKUP_PAGINATION.MAX_LIMIT }, + { enabled: !USE_ASYNC_LISTING_FILTERS }, ) - const systemsQuery = api.systems.get.useQuery(undefined, LOOKUP_DATA_QUERY_OPTIONS) + const emulatorsQuery = api.emulators.get.useQuery({ limit: 100 }) + const performanceScalesQuery = api.listings.performanceScales.useQuery() + const systemsQuery = api.systems.get.useQuery() const filterParams: RouterInput['pcListings']['get'] = { page: listingsState.page, @@ -556,16 +550,12 @@ function PcListingsPage() { )} {columnVisibility.isColumnVisible('cpu') && ( - {listing.cpu - ? `${listing.cpu.brand.name} ${listing.cpu.modelName}` - : 'N/A'} + {listing.cpu ? getCpuLabel(listing.cpu) : 'N/A'} )} {columnVisibility.isColumnVisible('gpu') && ( - {listing.gpu - ? `${listing.gpu.brand.name} ${listing.gpu.modelName}` - : 'Integrated'} + {listing.gpu ? getGpuLabel(listing.gpu) : 'Integrated'} )} {columnVisibility.isColumnVisible('memory') && ( diff --git a/src/app/pc-listings/components/PcFiltersContent.tsx b/src/app/pc-listings/components/PcFiltersContent.tsx index c8894bc17..6dc722d43 100644 --- a/src/app/pc-listings/components/PcFiltersContent.tsx +++ b/src/app/pc-listings/components/PcFiltersContent.tsx @@ -4,21 +4,17 @@ import { AnimatePresence } from 'framer-motion' import { Cpu, HardDrive, Rocket, MonitorSpeaker, Gamepad2, MemoryStick } from 'lucide-react' import { type ChangeEvent } from 'react' import { ListingsSearchBar, ActiveFiltersSummary } from '@/app/listings/shared/components' +import { shouldUseAsyncListingFilters } from '@/app/listings/shared/utils/asyncListingFilters' import { buildPcActiveFilterItems } from '@/app/pc-listings/utils/buildPcActiveFilterItems' import { MultiSelect, Input } from '@/components/ui' -import { - cpuOptions, - emulatorOptions, - gpuOptions, - performanceOptions, - systemOptions, -} from '@/utils/options' +import AsyncCpuFilterSelect from '@/features/hardware/cpu/client/components/AsyncCpuFilterSelect' +import { toCpuSelectOption } from '@/features/hardware/cpu/client/utils/cpuSelectOption' +import AsyncGpuFilterSelect from '@/features/hardware/gpu/client/components/AsyncGpuFilterSelect' +import { toGpuSelectOption } from '@/features/hardware/gpu/client/utils/gpuSelectOption' +import { emulatorOptions, performanceOptions, systemOptions } from '@/utils/options' import { type System, type PerformanceScale, type Emulator } from '@orm' -import AsyncCpuFilterSelect from './filters/AsyncCpuFilterSelect' -import AsyncGpuFilterSelect from './filters/AsyncGpuFilterSelect' - -type CpuWithBrand = { id: string; modelName: string; brand: { name: string } } -type GpuWithBrand = { id: string; modelName: string; brand: { name: string } } +import type { CpuSummary } from '@/features/hardware/cpu/shared/cpu.types' +import type { GpuSummary } from '@/features/hardware/gpu/shared/gpu.types' interface Props { cpuIds: string[] @@ -29,8 +25,8 @@ interface Props { minMemory: number | null maxMemory: number | null searchTerm: string - cpus: CpuWithBrand[] - gpus: GpuWithBrand[] + cpus: CpuSummary[] + gpus: GpuSummary[] systems: System[] emulators: Emulator[] performanceScales: PerformanceScale[] @@ -80,7 +76,7 @@ export default function PcFiltersContent(props: Props) { props.onPerformanceChange(values) } - const ENABLE_ASYNC = process.env.NEXT_PUBLIC_ENABLE_ASYNC_LISTINGS_FILTERS === 'true' + const ENABLE_ASYNC = shouldUseAsyncListingFilters() const hasActiveFilters = props.searchTerm || @@ -131,7 +127,7 @@ export default function PcFiltersContent(props: Props) { leftIcon={} value={props.cpuIds} onChange={props.onCpuChange} - options={cpuOptions(props.cpus)} + options={props.cpus.map((cpu) => toCpuSelectOption(cpu))} placeholder="All CPUs" maxDisplayed={2} /> @@ -153,7 +149,7 @@ export default function PcFiltersContent(props: Props) { leftIcon={} value={props.gpuIds} onChange={props.onGpuChange} - options={gpuOptions(props.gpus)} + options={props.gpus.map((gpu) => toGpuSelectOption(gpu))} placeholder="All GPUs" maxDisplayed={2} /> diff --git a/src/app/pc-listings/components/PcFiltersSidebar.tsx b/src/app/pc-listings/components/PcFiltersSidebar.tsx index 7c9ff5bb2..181905b63 100644 --- a/src/app/pc-listings/components/PcFiltersSidebar.tsx +++ b/src/app/pc-listings/components/PcFiltersSidebar.tsx @@ -17,11 +17,10 @@ import { import { buildPcActiveFilterItems } from '@/app/pc-listings/utils/buildPcActiveFilterItems' import { filterAnalytics } from '@/lib/analytics/filterAnalytics' import PcFiltersContent from './PcFiltersContent' +import type { CpuSummary } from '@/features/hardware/cpu/shared/cpu.types' +import type { GpuSummary } from '@/features/hardware/gpu/shared/gpu.types' import type { System, PerformanceScale, Emulator } from '@orm' -type CpuWithBrand = { id: string; modelName: string; brand: { name: string } } -type GpuWithBrand = { id: string; modelName: string; brand: { name: string } } - interface Props { isCollapsed?: boolean onToggleCollapse?: () => void @@ -34,8 +33,8 @@ interface Props { minMemory: number | null maxMemory: number | null searchTerm: string - cpus: CpuWithBrand[] - gpus: GpuWithBrand[] + cpus: CpuSummary[] + gpus: GpuSummary[] systems: System[] emulators: Emulator[] performanceScales: PerformanceScale[] @@ -142,7 +141,7 @@ export default function PcFiltersSidebar(props: Props) { initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: 10 }} - onClick={props.onClearAll} + onClick={handleClearAll} className="w-8 h-8 rounded-lg bg-red-100 dark:bg-red-900/30 hover:bg-red-200 dark:hover:bg-red-900/50 flex items-center justify-center transition-colors" whileHover={{ scale: 1.1, rotate: 5 }} whileTap={{ scale: 0.95 }} diff --git a/src/app/pc-listings/new/NewPcListingPage.tsx b/src/app/pc-listings/new/NewPcListingPage.tsx index 7a6b94b23..5fca8833c 100644 --- a/src/app/pc-listings/new/NewPcListingPage.tsx +++ b/src/app/pc-listings/new/NewPcListingPage.tsx @@ -3,8 +3,8 @@ import { zodResolver } from '@hookform/resolvers/zod' import Link from 'next/link' import { useRouter, useSearchParams } from 'next/navigation' -import { Suspense, useCallback, useEffect, useState } from 'react' -import { Controller, useForm } from 'react-hook-form' +import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { Controller, useForm, useWatch } from 'react-hook-form' import { CustomFieldsFormSection, FormValidationSummary, @@ -21,8 +21,10 @@ import { useFormKeyDown, } from '@/app/listings/hooks' import { Autocomplete, Button, Input, LoadingSpinner, SelectInput } from '@/components/ui' -import { CACHE_DURATIONS } from '@/data/constants' +import { LOOKUP_PAGINATION } from '@/data/constants' import { PC_OS_OPTIONS } from '@/data/pc-os' +import { getCpuLabel } from '@/features/hardware/cpu/shared/cpu-format' +import { getGpuLabel } from '@/features/hardware/gpu/shared/gpu-format' import { useSubmitWithHumanVerification } from '@/features/human-verification/client' import analytics from '@/lib/analytics' import { api } from '@/lib/api' @@ -37,21 +39,20 @@ import createDynamicPcListingSchema from './form-schemas/createDynamicPcListingS export type PcListingFormValues = RouterInput['pcListings']['create'] -type CpuOption = RouterOutput['cpus']['options']['cpus'][number] -type GpuOption = RouterOutput['gpus']['options']['gpus'][number] +type CpuSummary = RouterOutput['cpus']['options']['cpus'][number] +type GpuSummary = RouterOutput['gpus']['options']['gpus'][number] type PcPresetOption = RouterOutput['pcListings']['presets']['get'][number] const OS_OPTIONS = PC_OS_OPTIONS -const LOOKUP_DATA_QUERY_OPTIONS = { - staleTime: CACHE_DURATIONS.LOOKUP, - gcTime: CACHE_DURATIONS.LOOKUP_GC, -} +const EMPTY_PC_LISTING_SCHEMA = createDynamicPcListingSchema([]) function AddPcListingPage() { const router = useRouter() const searchParams = useSearchParams() const currentUserQuery = api.users.me.useQuery() const submitWithHumanVerification = useSubmitWithHumanVerification() + const schemaRef = useRef(EMPTY_PC_LISTING_SCHEMA) + const previousCustomFieldIdsKeyRef = useRef('') const gameIdFromUrl = searchParams.get('gameId') @@ -59,19 +60,9 @@ function AddPcListingPage() { const [selectedEmulator, setSelectedEmulator] = useState(null) const [selectedPreset, setSelectedPreset] = useState(null) const [emulatorInputFocus, setEmulatorInputFocus] = useState(false) - const [parsedCustomFields, setParsedCustomFields] = useState( - [], - ) - const [schemaState, setSchemaState] = useState>( - createDynamicPcListingSchema([]), - ) - const utils = api.useUtils() const createPcListing = api.pcListings.create.useMutation() - const performanceScalesQuery = api.performanceScales.get.useQuery( - undefined, - LOOKUP_DATA_QUERY_OPTIONS, - ) + const performanceScalesQuery = api.performanceScales.get.useQuery() const presetsQuery = api.pcListings.presets.get.useQuery({}) const { handleKeyDown } = useFormKeyDown() @@ -80,10 +71,13 @@ function AddPcListingPage() { useEmulatorLoader(selectedGame) const loadCpuItems = useCallback( - async (query: string): Promise => { + async (query: string): Promise => { if (query.length < 2) return Promise.resolve([]) try { - const result = await utils.cpus.options.fetch({ search: query, limit: 20 }) + const result = await utils.cpus.options.fetch({ + search: query, + limit: LOOKUP_PAGINATION.AUTOCOMPLETE_LIMIT, + }) return result.cpus ?? [] } catch (error) { console.error('Error fetching CPUs:', error) @@ -94,10 +88,13 @@ function AddPcListingPage() { ) const loadGpuItems = useCallback( - async (query: string): Promise => { + async (query: string): Promise => { if (query.length < 2) return Promise.resolve([]) try { - const result = await utils.gpus.options.fetch({ search: query, limit: 20 }) + const result = await utils.gpus.options.fetch({ + search: query, + limit: LOOKUP_PAGINATION.AUTOCOMPLETE_LIMIT, + }) return result.gpus ?? [] } catch (error) { console.error('Error fetching GPUs:', error) @@ -108,7 +105,8 @@ function AddPcListingPage() { ) const form = useForm({ - resolver: zodResolver(schemaState), + resolver: (values, context, options) => + zodResolver(schemaRef.current)(values, context, options), defaultValues: { gameId: gameIdFromUrl ?? '', cpuId: '', @@ -123,7 +121,7 @@ function AddPcListingPage() { }, }) - const selectedEmulatorId = form.watch('emulatorId') + const selectedEmulatorId = useWatch({ control: form.control, name: 'emulatorId' }) const customFieldDefinitionsQuery = api.customFieldDefinitions.getByEmulator.useQuery( { emulatorId: selectedEmulatorId }, { @@ -132,6 +130,22 @@ function AddPcListingPage() { refetchOnReconnect: false, }, ) + const parsedCustomFields = useMemo(() => { + if (!customFieldDefinitionsQuery.data) return [] + + return customFieldDefinitionsQuery.data.map((field): CustomFieldDefinitionWithOptions => { + const parsedOptions = parseCustomFieldOptions(field) + return { + ...field, + parsedOptions, + defaultValue: field.defaultValue as string | number | boolean | null | undefined, + } + }) + }, [customFieldDefinitionsQuery.data]) + const customFieldIdsKey = useMemo( + () => parsedCustomFields.map((field) => field.id).join('|'), + [parsedCustomFields], + ) usePreSelectedGame({ gameIdFromUrl, @@ -140,37 +154,17 @@ function AddPcListingPage() { onSearchTermChange: setGameSearchTerm, }) - // Update custom field definitions when emulator changes + // Sync custom field defaults when emulator-specific definitions change. useEffect(() => { - if (!customFieldDefinitionsQuery.data) return - - const parsed = customFieldDefinitionsQuery.data.map( - (field): CustomFieldDefinitionWithOptions => { - const parsedOptions = parseCustomFieldOptions(field) - return { - ...field, - parsedOptions, - defaultValue: field.defaultValue as string | number | boolean | null | undefined, - } - }, - ) - - const isSameAsCurrent = - parsedCustomFields.length === parsed.length && - parsedCustomFields.every((f, i) => f.id === parsed[i]?.id) - if (isSameAsCurrent) return - - setParsedCustomFields(parsed) - - const dynamicSchema = createDynamicPcListingSchema(parsed) - setSchemaState(dynamicSchema) + schemaRef.current = createDynamicPcListingSchema(parsedCustomFields) + if (previousCustomFieldIdsKeyRef.current === customFieldIdsKey) return + previousCustomFieldIdsKeyRef.current = customFieldIdsKey const currentValues = form.getValues() - form.reset() form.reset(currentValues) - const currentCustomValues = form.watch('customFieldValues') ?? [] - const newCustomValues = parsed.map((field) => { + const currentCustomValues = form.getValues('customFieldValues') ?? [] + const newCustomValues = parsedCustomFields.map((field) => { const existingValueObj = currentCustomValues.find( (cv) => cv.customFieldDefinitionId === field.id, ) @@ -184,7 +178,7 @@ function AddPcListingPage() { } }) form.setValue('customFieldValues', newCustomValues) - }, [customFieldDefinitionsQuery.data, form, parsedCustomFields]) + }, [customFieldIdsKey, form, parsedCustomFields]) // Clear emulator when game changes and load initial emulators useEffect(() => { @@ -196,53 +190,42 @@ function AddPcListingPage() { } }, [selectedGame, form, loadEmulatorItems, setAvailableEmulators]) - const onSubmit = useCallback( - async (data: PcListingFormValues) => { - if (!currentUserQuery.data?.id) { - return toast.error('You must be signed in to create a Compatibility Report.') - } - try { - const result = await submitWithHumanVerification((humanVerificationToken) => - createPcListing.mutateAsync({ - ...data, - humanVerificationToken, - }), - ) - - analytics.listing.created({ - listingId: result.id, - gameId: data.gameId, - systemId: selectedGame?.system?.id || '', - emulatorId: data.emulatorId, - deviceId: 'pc', - performanceId: data.performanceId, - hasCustomFields: parsedCustomFields.length > 0, - customFieldCount: parsedCustomFields.length, - }) - - // Invalidate queries to refresh data - await utils.pcListings.get.invalidate() - if (data.gameId) { - await utils.games.byId.invalidate({ id: data.gameId }) - } + async function onSubmit(data: PcListingFormValues) { + if (!currentUserQuery.data?.id) { + toast.error('You must be signed in to create a Compatibility Report.') + return + } + try { + const result = await submitWithHumanVerification((humanVerificationToken) => + createPcListing.mutateAsync({ + ...data, + humanVerificationToken, + }), + ) - toast.success('PC Report created! It will be reviewed before going live.') - router.push(`/pc-listings/${result.id}`) - } catch (error) { - const message = getErrorMessage(error) - toast.error(`Failed to create PC Report: ${message}`) + analytics.listing.created({ + listingId: result.id, + gameId: data.gameId, + systemId: selectedGame?.system?.id || '', + emulatorId: data.emulatorId, + deviceId: 'pc', + performanceId: data.performanceId, + hasCustomFields: parsedCustomFields.length > 0, + customFieldCount: parsedCustomFields.length, + }) + + await utils.pcListings.get.invalidate() + if (data.gameId) { + await utils.games.byId.invalidate({ id: data.gameId }) } - }, - [ - currentUserQuery.data?.id, - createPcListing, - submitWithHumanVerification, - selectedGame?.system?.id, - parsedCustomFields.length, - router, - utils, - ], - ) + + toast.success('PC Report created! It will be reviewed before going live.') + router.push(`/pc-listings/${result.id}`) + } catch (error) { + const message = getErrorMessage(error) + toast.error(`Failed to create PC Report: ${message}`) + } + } const handlePresetSelect = (preset: PcPresetOption) => { setSelectedPreset(preset) @@ -262,9 +245,6 @@ function AddPcListingPage() { form.setValue('osVersion', '') } - const formatCpuLabel = (cpu: CpuOption) => `${cpu.brand.name} ${cpu.modelName}` - const formatGpuLabel = (gpu: GpuOption) => `${gpu.brand.name} ${gpu.modelName}` - return ( CPU: - {selectedPreset.cpu.brand.name} {selectedPreset.cpu.modelName} + {getCpuLabel(selectedPreset.cpu)}
    {selectedPreset.gpu && (
    GPU: - {selectedPreset.gpu.brand.name} {selectedPreset.gpu.modelName} + {getGpuLabel(selectedPreset.gpu)}
    )} @@ -368,13 +348,9 @@ function AddPcListingPage() { {preset.name}
    +
    {getCpuLabel(preset.cpu)}
    - {preset.cpu.brand.name} {preset.cpu.modelName} -
    -
    - {preset.gpu - ? `${preset.gpu.brand.name} ${preset.gpu.modelName}` - : 'Integrated Graphics'} + {preset.gpu ? getGpuLabel(preset.gpu) : 'Integrated Graphics'}
    {preset.memorySize}GB •{' '} @@ -400,6 +376,7 @@ function AddPcListingPage() { onGameSelect={(game: GameOption | null) => { setSelectedGame(game) if (game) return + form.setValue('gameId', '') form.setValue('emulatorId', '') form.setValue('customFieldValues', []) }} @@ -438,7 +415,7 @@ function AddPcListingPage() { onChange={(value) => field.onChange(value || '')} loadItems={loadCpuItems} optionToValue={(cpu) => cpu.id} - optionToLabel={formatCpuLabel} + optionToLabel={getCpuLabel} placeholder="Select a CPU..." className="w-full" filterKeys={['modelName']} @@ -466,7 +443,7 @@ function AddPcListingPage() { onChange={(value) => field.onChange(value || '')} loadItems={loadGpuItems} optionToValue={(gpu) => gpu.id} - optionToLabel={formatGpuLabel} + optionToLabel={getGpuLabel} placeholder="Select a GPU..." className="w-full" filterKeys={['modelName']} diff --git a/src/app/profile/components/DeviceSelector.tsx b/src/app/profile/components/DeviceSelector.tsx index f260e0eba..aef02a2b5 100644 --- a/src/app/profile/components/DeviceSelector.tsx +++ b/src/app/profile/components/DeviceSelector.tsx @@ -4,7 +4,7 @@ import { motion, AnimatePresence } from 'framer-motion' import { Smartphone, Search, Loader2, ChevronDown, Check } from 'lucide-react' import { useState, useMemo } from 'react' import { Input } from '@/components/ui' -import { CACHE_DURATIONS } from '@/data/constants' +import { LOOKUP_PAGINATION } from '@/data/constants' import { api } from '@/lib/api' import { cn } from '@/lib/utils' import getErrorMessage from '@/utils/getErrorMessage' @@ -30,11 +30,6 @@ interface Props { className?: string } -const LOOKUP_DATA_QUERY_OPTIONS = { - staleTime: CACHE_DURATIONS.LOOKUP, - gcTime: CACHE_DURATIONS.LOOKUP_GC, -} - const EMPTY_DEVICES: Device[] = [] function DeviceSelector(props: Props) { @@ -42,7 +37,7 @@ function DeviceSelector(props: Props) { const [expandedBrands, setExpandedBrands] = useState>(new Set()) // TODO: Make this selector async instead of preloading 1000 options. - const devicesQuery = api.devices.options.useQuery({ limit: 1000 }, LOOKUP_DATA_QUERY_OPTIONS) + const devicesQuery = api.devices.options.useQuery({ limit: LOOKUP_PAGINATION.MAX_LIMIT }) const devices = devicesQuery.data?.devices ?? EMPTY_DEVICES const filteredDevices = useMemo(() => { diff --git a/src/app/profile/components/PcPresetModal.tsx b/src/app/profile/components/PcPresetModal.tsx index 22242de53..708ea0432 100644 --- a/src/app/profile/components/PcPresetModal.tsx +++ b/src/app/profile/components/PcPresetModal.tsx @@ -1,9 +1,11 @@ 'use client' -import { useCallback, useState, useEffect, type SubmitEvent } from 'react' +import { useCallback, useState, type SubmitEvent } from 'react' import { Button, Input, Modal, Autocomplete, SelectInput } from '@/components/ui' -import { CACHE_DURATIONS } from '@/data/constants' +import { LOOKUP_PAGINATION } from '@/data/constants' import { PC_OS_OPTIONS } from '@/data/pc-os' +import { getCpuLabel } from '@/features/hardware/cpu/shared/cpu-format' +import { getGpuLabel } from '@/features/hardware/gpu/shared/gpu-format' import { api } from '@/lib/api' import { type RouterInput, type RouterOutput } from '@/types/trpc' import getErrorMessage from '@/utils/getErrorMessage' @@ -13,50 +15,45 @@ type PcPreset = RouterOutput['pcListings']['presets']['get'][number] type PcPresetMutationResult = | RouterOutput['pcListings']['presets']['create'] | RouterOutput['pcListings']['presets']['update'] -type CpuOption = RouterOutput['cpus']['options']['cpus'][number] -type GpuOption = RouterOutput['gpus']['options']['gpus'][number] +type CpuSummary = RouterOutput['cpus']['options']['cpus'][number] +type GpuSummary = RouterOutput['gpus']['options']['gpus'][number] interface Props { - isOpen: boolean onClose: () => void preset: PcPreset | null onSuccess: (data?: PcPresetMutationResult) => void } -const OS_OPTIONS = PC_OS_OPTIONS -const LOOKUP_DATA_QUERY_OPTIONS = { - staleTime: CACHE_DURATIONS.LOOKUP, - gcTime: CACHE_DURATIONS.LOOKUP_GC, -} - function PcPresetModal(props: Props) { const utils = api.useUtils() const createPreset = api.pcListings.presets.create.useMutation() const updatePreset = api.pcListings.presets.update.useMutation() - const [name, setName] = useState('') - const [cpuId, setCpuId] = useState('') - const [gpuId, setGpuId] = useState('') - const [memorySize, setMemorySize] = useState('') - const [os, setOs] = useState(PcOs.WINDOWS) - const [osVersion, setOsVersion] = useState('') + const [name, setName] = useState(props.preset?.name ?? '') + const [cpuId, setCpuId] = useState(props.preset?.cpuId ?? '') + const [gpuId, setGpuId] = useState(props.preset?.gpuId ?? '') + const [memorySize, setMemorySize] = useState(props.preset?.memorySize.toString() ?? '') + const [os, setOs] = useState(props.preset?.os ?? PcOs.WINDOWS) + const [osVersion, setOsVersion] = useState(props.preset?.osVersion ?? '') const [error, setError] = useState('') - const [success, setSuccess] = useState('') const selectedCpuQuery = api.cpus.getByIds.useQuery( { ids: cpuId ? [cpuId] : [] }, - { ...LOOKUP_DATA_QUERY_OPTIONS, enabled: cpuId !== '' }, + { enabled: cpuId !== '' }, ) const selectedGpuQuery = api.gpus.getByIds.useQuery( { ids: gpuId ? [gpuId] : [] }, - { ...LOOKUP_DATA_QUERY_OPTIONS, enabled: gpuId !== '' }, + { enabled: gpuId !== '' }, ) const loadCpuItems = useCallback( - async (query: string): Promise => { + async (query: string): Promise => { if (query.length < 2) return [] try { - const result = await utils.cpus.options.fetch({ search: query, limit: 20 }) + const result = await utils.cpus.options.fetch({ + search: query, + limit: LOOKUP_PAGINATION.AUTOCOMPLETE_LIMIT, + }) return result.cpus } catch (err) { console.error('Error fetching CPUs:', err) @@ -67,10 +64,13 @@ function PcPresetModal(props: Props) { ) const loadGpuItems = useCallback( - async (query: string): Promise => { + async (query: string): Promise => { if (query.length < 2) return [] try { - const result = await utils.gpus.options.fetch({ search: query, limit: 20 }) + const result = await utils.gpus.options.fetch({ + search: query, + limit: LOOKUP_PAGINATION.AUTOCOMPLETE_LIMIT, + }) return result.gpus } catch (err) { console.error('Error fetching GPUs:', err) @@ -80,31 +80,9 @@ function PcPresetModal(props: Props) { [utils.gpus.options], ) - // Update form fields when preset changes - useEffect(() => { - if (props.preset) { - setName(props.preset.name) - setCpuId(props.preset.cpuId) - setGpuId(props.preset.gpuId || '') - setMemorySize(props.preset.memorySize.toString()) - setOs(props.preset.os) - setOsVersion(props.preset.osVersion) - } else { - setName('') - setCpuId('') - setGpuId('') - setMemorySize('') - setOs(PcOs.WINDOWS) - setOsVersion('') - } - setError('') - setSuccess('') - }, [props.preset, props.isOpen]) - const handleSubmit = async (ev: SubmitEvent) => { ev.preventDefault() setError('') - setSuccess('') const memorySizeNum = parseInt(memorySize) if (isNaN(memorySizeNum) || memorySizeNum < 1 || memorySizeNum > 256) { @@ -127,36 +105,21 @@ function PcPresetModal(props: Props) { id: props.preset.id, ...presetData, } satisfies RouterInput['pcListings']['presets']['update']) - setSuccess('PC preset updated!') props.onSuccess(updated) } else { const created = await createPreset.mutateAsync( presetData satisfies RouterInput['pcListings']['presets']['create'], ) - setSuccess('PC preset created!') props.onSuccess(created) } - - // Reset form - setName('') - setCpuId('') - setGpuId('') - setMemorySize('') - setOs(PcOs.WINDOWS) - setOsVersion('') } catch (err) { setError(getErrorMessage(err, 'Failed to save PC preset.')) } } - const formatCpuLabel = (cpu: { brand: { name: string }; modelName: string }) => - `${cpu.brand.name} ${cpu.modelName}` - const formatGpuLabel = (gpu: { brand: { name: string }; modelName: string }) => - `${gpu.brand.name} ${gpu.modelName}` - return ( cpu.id} - optionToLabel={formatCpuLabel} + optionToLabel={getCpuLabel} placeholder="Select a CPU..." className="w-full" minCharsToTrigger={2} @@ -206,7 +169,7 @@ function PcPresetModal(props: Props) { items={selectedGpuQuery.data ?? []} loadItems={loadGpuItems} optionToValue={(gpu) => gpu.id} - optionToLabel={formatGpuLabel} + optionToLabel={getGpuLabel} placeholder="Select a GPU..." className="w-full" minCharsToTrigger={2} @@ -240,7 +203,7 @@ function PcPresetModal(props: Props) { ({ + options={PC_OS_OPTIONS.map((opt) => ({ id: opt.value, name: opt.label, }))} @@ -272,12 +235,6 @@ function PcPresetModal(props: Props) {
    )} - {success && ( -
    - {success} -
    - )} -
    ) } diff --git a/src/app/profile/components/SocSelector.tsx b/src/app/profile/components/SocSelector.tsx index 0f771df43..f43aa00c8 100644 --- a/src/app/profile/components/SocSelector.tsx +++ b/src/app/profile/components/SocSelector.tsx @@ -4,7 +4,7 @@ import { motion, AnimatePresence } from 'framer-motion' import { Search, Check, Cpu, ChevronDown } from 'lucide-react' import { useState, useMemo } from 'react' import { Input } from '@/components/ui' -import { CACHE_DURATIONS } from '@/data/constants' +import { LOOKUP_PAGINATION } from '@/data/constants' import { api } from '@/lib/api' import { cn } from '@/lib/utils' import getErrorMessage from '@/utils/getErrorMessage' @@ -20,16 +20,11 @@ interface Props { onSocsChange: (socs: Soc[]) => void } -const LOOKUP_DATA_QUERY_OPTIONS = { - staleTime: CACHE_DURATIONS.LOOKUP, - gcTime: CACHE_DURATIONS.LOOKUP_GC, -} - function SocSelector(props: Props) { const [searchTerm, setSearchTerm] = useState('') const [expandedManufacturers, setExpandedManufacturers] = useState>(new Set()) // TODO: Make this selector async instead of preloading 1000 options. - const socsQuery = api.socs.options.useQuery({ limit: 1000 }, LOOKUP_DATA_QUERY_OPTIONS) + const socsQuery = api.socs.options.useQuery({ limit: LOOKUP_PAGINATION.MAX_LIMIT }) const filteredSocs = useMemo(() => { const allSocs: Soc[] = diff --git a/src/components/navbar/Navbar.tsx b/src/components/navbar/Navbar.tsx index 15c33446a..5112241ff 100644 --- a/src/components/navbar/Navbar.tsx +++ b/src/components/navbar/Navbar.tsx @@ -8,6 +8,7 @@ import { useState, useCallback, useEffect } from 'react' import { LogoIcon, LoadingIcon } from '@/components/icons' import NotificationCenter from '@/components/notifications/NotificationCenter' import { ThemeToggle } from '@/components/ui' +import useMounted from '@/hooks/useMounted' import analytics from '@/lib/analytics' import { hasRolePermission } from '@/utils/permissions' import { Role } from '@orm' @@ -15,12 +16,22 @@ import { navbarItems } from './data' import MobileSearchOverlay from './MobileSearchOverlay' import NavbarExpandableSearch from './NavbarExpandableSearch' +function AuthLoadingIndicator() { + return ( +
    + +
    + ) +} + function Navbar() { const { user, isLoaded } = useUser() + const mounted = useMounted() const [mobileMenuOpen, setMobileMenuOpen] = useState(false) const [mobileSearchOpen, setMobileSearchOpen] = useState(false) const [scrolled, setScrolled] = useState(false) const pathname = usePathname() + const authReady = mounted && isLoaded // Handle scroll effect for navbar useEffect(() => { @@ -118,12 +129,10 @@ function Navbar() { {/* Right Section */}
    - {user && } + {authReady && user && } - {!isLoaded ? ( -
    - -
    + {!authReady ? ( + ) : ( <> {user ? ( @@ -183,7 +192,7 @@ function Navbar() { {/* Mobile menu button */}
    - {user && } + {authReady && user && } } + + } + > + + + + table={table} + searchPlaceholder="Search CPUs..." + onClear={() => table.setAdditionalParam('brandId', '')} + > + table.setAdditionalParam('brandId', value || '')} + items={[{ id: '', name: 'All Brands' }, ...(brandsQuery.data || [])]} + optionToValue={(brand) => brand.id} + optionToLabel={(brand) => brand.name} + className="w-full md:w-64" + placeholder="Filter by brand" + filterKeys={['name']} + /> + + + + {cpusQuery.isPending ? ( + + ) : ( + + )} + + + {cpusQuery.data && cpusQuery.data.pagination.pages > 1 && ( + + )} + + + + + + ) +} diff --git a/src/features/hardware/cpu/client/admin/CpuFormModal.test.tsx b/src/features/hardware/cpu/client/admin/CpuFormModal.test.tsx new file mode 100644 index 000000000..8a0895f0b --- /dev/null +++ b/src/features/hardware/cpu/client/admin/CpuFormModal.test.tsx @@ -0,0 +1,113 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import type { CpuFormModal as CpuFormModalComponent } from './CpuFormModal' +import type { CpuDetail } from '../../shared/cpu.types' + +const apiMocks = vi.hoisted(() => ({ + createMutateAsync: vi.fn(), + deviceBrandsUseQuery: vi.fn(), + updateMutateAsync: vi.fn(), +})) + +vi.mock('@/lib/api', () => ({ + api: { + cpus: { + create: { + useMutation: () => ({ mutateAsync: apiMocks.createMutateAsync, isPending: false }), + }, + update: { + useMutation: () => ({ mutateAsync: apiMocks.updateMutateAsync, isPending: false }), + }, + }, + deviceBrands: { + get: { + useQuery: apiMocks.deviceBrandsUseQuery, + }, + }, + }, +})) + +let CpuFormModal: typeof CpuFormModalComponent + +const BRAND_ID = '00000000-0000-4000-a000-000000000002' +const CPU_ID = '00000000-0000-4000-a000-000000000001' + +const cpu = { + id: CPU_ID, + modelName: 'Core i7-13700K', + brand: { + id: BRAND_ID, + name: 'Intel', + }, + pcListingCount: 3, +} satisfies CpuDetail + +describe('CpuFormModal', () => { + beforeAll(async () => { + ;({ CpuFormModal } = await import('./CpuFormModal')) + }) + + beforeEach(() => { + vi.clearAllMocks() + apiMocks.createMutateAsync.mockResolvedValue(cpu) + apiMocks.updateMutateAsync.mockResolvedValue(cpu) + apiMocks.deviceBrandsUseQuery.mockReturnValue({ + data: [{ id: BRAND_ID, name: 'Intel' }], + }) + }) + + it('creates a CPU from the selected brand and model input', async () => { + const onSuccess = vi.fn() + render() + + fireEvent.focus(screen.getByPlaceholderText('Select a brand...')) + fireEvent.mouseDown(await screen.findByRole('option', { name: 'Intel' })) + fireEvent.change(screen.getByPlaceholderText('e.g., Core i7-13700K'), { + target: { value: ' Core i7-13700K ' }, + }) + fireEvent.click(screen.getByRole('button', { name: 'Create' })) + + await waitFor(() => { + expect(apiMocks.createMutateAsync).toHaveBeenCalledWith({ + brandId: BRAND_ID, + modelName: ' Core i7-13700K ', + }) + }) + expect(onSuccess).toHaveBeenCalled() + }) + + it('updates an existing CPU while preserving the selected brand id', async () => { + const onSuccess = vi.fn() + render() + + fireEvent.change(screen.getByPlaceholderText('e.g., Core i7-13700K'), { + target: { value: 'Core i9-14900K' }, + }) + fireEvent.click(screen.getByRole('button', { name: 'Save' })) + + await waitFor(() => { + expect(apiMocks.updateMutateAsync).toHaveBeenCalledWith({ + id: CPU_ID, + brandId: BRAND_ID, + modelName: 'Core i9-14900K', + }) + }) + expect(onSuccess).toHaveBeenCalled() + }) + + it('shows mutation errors without reporting success', async () => { + const onSuccess = vi.fn() + apiMocks.createMutateAsync.mockRejectedValueOnce(new Error('Duplicate CPU')) + render() + + fireEvent.focus(screen.getByPlaceholderText('Select a brand...')) + fireEvent.mouseDown(await screen.findByRole('option', { name: 'Intel' })) + fireEvent.change(screen.getByPlaceholderText('e.g., Core i7-13700K'), { + target: { value: 'Core i7-13700K' }, + }) + fireEvent.click(screen.getByRole('button', { name: 'Create' })) + + expect(await screen.findByText('Duplicate CPU')).toBeInTheDocument() + expect(onSuccess).not.toHaveBeenCalled() + }) +}) diff --git a/src/features/hardware/cpu/client/admin/CpuFormModal.tsx b/src/features/hardware/cpu/client/admin/CpuFormModal.tsx new file mode 100644 index 000000000..3b6475127 --- /dev/null +++ b/src/features/hardware/cpu/client/admin/CpuFormModal.tsx @@ -0,0 +1,135 @@ +'use client' + +import { useState, type SubmitEvent } from 'react' +import { Autocomplete, Button, Input, Modal } from '@/components/ui' +import { PAGINATION } from '@/data/constants' +import { api } from '@/lib/api' +import getErrorMessage from '@/utils/getErrorMessage' +import type { CreateCpuInput, CpuDetail, UpdateCpuInput } from '../../shared/cpu.types' + +interface Props { + isOpen: boolean + onClose: () => void + cpuData: CpuDetail | null + onSuccess: () => void +} + +export function CpuFormModal(props: Props) { + const formKey = props.cpuData?.id ?? 'new' + + return ( + + + + ) +} + +interface CpuFormProps { + onClose: () => void + cpuData: CpuDetail | null + onSuccess: () => void +} + +function CpuForm(props: CpuFormProps) { + const createCpu = api.cpus.create.useMutation() + const updateCpu = api.cpus.update.useMutation() + const deviceBrandsQuery = api.deviceBrands.get.useQuery({ + limit: PAGINATION.MAX_LIMIT, + category: 'cpu', + }) + + const [brandId, setBrandId] = useState(props.cpuData?.brand.id ?? '') + const [modelName, setModelName] = useState(props.cpuData?.modelName ?? '') + const [error, setError] = useState('') + + const handleSubmit = async (ev: SubmitEvent) => { + ev.preventDefault() + setError('') + + try { + const cpuData = { + brandId, + modelName, + } satisfies CreateCpuInput + + if (props.cpuData) { + await updateCpu.mutateAsync({ + id: props.cpuData.id, + ...cpuData, + } satisfies UpdateCpuInput) + } else { + await createCpu.mutateAsync(cpuData) + } + + props.onSuccess() + } catch (err) { + setError(getErrorMessage(err, 'Failed to save CPU.')) + } + } + + return ( +
    +
    + + setBrandId(value ?? '')} + items={deviceBrandsQuery.data ?? []} + optionToValue={(brand) => brand.id} + optionToLabel={(brand) => brand.name} + placeholder="Select a brand..." + className="w-full" + filterKeys={['name']} + /> +
    + +
    + + setModelName(ev.target.value)} + required + className="w-full" + placeholder="e.g., Core i7-13700K" + /> +
    + + {error && ( +
    {error}
    + )} + +
    + + +
    +
    + ) +} diff --git a/src/features/hardware/cpu/client/admin/CpuTable.test.tsx b/src/features/hardware/cpu/client/admin/CpuTable.test.tsx new file mode 100644 index 000000000..15ba48a45 --- /dev/null +++ b/src/features/hardware/cpu/client/admin/CpuTable.test.tsx @@ -0,0 +1,74 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { CpuTable } from './CpuTable' +import type { CpuDetail } from '../../shared/cpu.types' + +const cpu = { + id: '00000000-0000-4000-a000-000000000001', + modelName: 'Core i7-13700K', + brand: { + id: '00000000-0000-4000-a000-000000000002', + name: 'Intel', + }, + pcListingCount: 3, +} satisfies CpuDetail + +const visibleColumns = { + isColumnVisible: () => true, +} + +function renderTable(overrides: Partial[0]> = {}) { + return render( + , + ) +} + +describe('CpuTable', () => { + it('renders stable CPU columns with PC Compatibility Report wording', () => { + renderTable() + + expect(screen.getByText('Intel')).toBeInTheDocument() + expect(screen.getByText('Core i7-13700K')).toBeInTheDocument() + expect(screen.getByText('PC Reports')).toBeInTheDocument() + expect(screen.getByText('3')).toBeInTheDocument() + }) + + it('hides mutation actions when the actor cannot manage devices', () => { + renderTable({ canManageDevices: false }) + + expect(screen.getByRole('button', { name: 'View CPU Details' })).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Edit CPU' })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Delete CPU' })).not.toBeInTheDocument() + }) + + it('wires view, edit, delete, and sort interactions', () => { + const onDelete = vi.fn() + const onEdit = vi.fn() + const onSort = vi.fn() + const onView = vi.fn() + renderTable({ onDelete, onEdit, onSort, onView }) + + fireEvent.click(screen.getByRole('button', { name: 'View CPU Details' })) + fireEvent.click(screen.getByRole('button', { name: 'Edit CPU' })) + fireEvent.click(screen.getByRole('button', { name: 'Delete CPU' })) + fireEvent.click(screen.getByText('Brand')) + + expect(onView).toHaveBeenCalledWith(cpu) + expect(onEdit).toHaveBeenCalledWith(cpu) + expect(onDelete).toHaveBeenCalledWith(cpu.id) + expect(onSort).toHaveBeenCalledWith('brand') + }) +}) diff --git a/src/features/hardware/cpu/client/admin/CpuTable.tsx b/src/features/hardware/cpu/client/admin/CpuTable.tsx new file mode 100644 index 000000000..ca7077a2d --- /dev/null +++ b/src/features/hardware/cpu/client/admin/CpuTable.tsx @@ -0,0 +1,107 @@ +'use client' + +import { Cpu } from 'lucide-react' +import { AdminTableNoResults } from '@/components/admin' +import { Badge, DeleteButton, EditButton, SortableHeader, ViewButton } from '@/components/ui' +import type { CpuDetail } from '../../shared/cpu.types' + +interface Props { + cpus: CpuDetail[] + hasQuery: boolean + canManageDevices: boolean + isDeleting: boolean + columnVisibility: { + isColumnVisible: (key: string) => boolean + } + sortField: string | null + sortDirection: 'asc' | 'desc' | null + onSort: (field: string) => void + onView: (cpu: CpuDetail) => void + onEdit: (cpu: CpuDetail) => void + onDelete: (id: string) => void +} + +export function CpuTable(props: Props) { + if (props.cpus.length === 0) { + return + } + + return ( + + + + {props.columnVisibility.isColumnVisible('brand') && ( + + )} + {props.columnVisibility.isColumnVisible('model') && ( + + )} + {props.columnVisibility.isColumnVisible('listings') && ( + + )} + {props.columnVisibility.isColumnVisible('actions') && ( + + )} + + + + {props.cpus.map((cpu) => ( + + {props.columnVisibility.isColumnVisible('brand') && ( + + )} + {props.columnVisibility.isColumnVisible('model') && ( + + )} + {props.columnVisibility.isColumnVisible('listings') && ( + + )} + {props.columnVisibility.isColumnVisible('actions') && ( + + )} + + ))} + +
    + Actions +
    + {cpu.brand.name} + + {cpu.modelName} + + {cpu.pcListingCount} + +
    + props.onView(cpu)} title="View CPU Details" /> + {props.canManageDevices && ( + props.onEdit(cpu)} title="Edit CPU" /> + )} + {props.canManageDevices && ( + props.onDelete(cpu.id)} + title="Delete CPU" + isLoading={props.isDeleting} + /> + )} +
    +
    + ) +} diff --git a/src/features/hardware/cpu/client/admin/CpuViewModal.tsx b/src/features/hardware/cpu/client/admin/CpuViewModal.tsx new file mode 100644 index 000000000..63eea24b8 --- /dev/null +++ b/src/features/hardware/cpu/client/admin/CpuViewModal.tsx @@ -0,0 +1,36 @@ +'use client' + +import { Button, InputPlaceholder, Modal } from '@/components/ui' +import type { CpuDetail } from '../../shared/cpu.types' + +interface Props { + isOpen: boolean + onClose: () => void + cpuData: CpuDetail | null +} + +export function CpuViewModal(props: Props) { + if (!props.cpuData) return null + + return ( + +
    +
    + + + + +
    + +
    + +
    +
    +
    + ) +} diff --git a/src/app/pc-listings/components/filters/AsyncCpuFilterSelect.test.tsx b/src/features/hardware/cpu/client/components/AsyncCpuFilterSelect.test.tsx similarity index 91% rename from src/app/pc-listings/components/filters/AsyncCpuFilterSelect.test.tsx rename to src/features/hardware/cpu/client/components/AsyncCpuFilterSelect.test.tsx index fb501b8cc..fcea195e6 100644 --- a/src/app/pc-listings/components/filters/AsyncCpuFilterSelect.test.tsx +++ b/src/features/hardware/cpu/client/components/AsyncCpuFilterSelect.test.tsx @@ -1,5 +1,5 @@ -import { render, screen, fireEvent } from '@testing-library/react' -import { describe, it, expect, vi, beforeAll, beforeEach } from 'vitest' +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' import type AsyncCpuFilterSelectComponent from './AsyncCpuFilterSelect' const apiMocks = vi.hoisted(() => ({ @@ -77,7 +77,7 @@ describe('AsyncCpuFilterSelect', () => { setupApiMocks() }) - it('maps CPU option and selected labels', () => { + it('maps CPU summaries to dropdown and selected labels', () => { render() expect(screen.getByText('AMD Ryzen 7 7800X3D')).toBeInTheDocument() diff --git a/src/app/pc-listings/components/filters/AsyncCpuFilterSelect.tsx b/src/features/hardware/cpu/client/components/AsyncCpuFilterSelect.tsx similarity index 61% rename from src/app/pc-listings/components/filters/AsyncCpuFilterSelect.tsx rename to src/features/hardware/cpu/client/components/AsyncCpuFilterSelect.tsx index 0b44440ba..d517a6ab1 100644 --- a/src/app/pc-listings/components/filters/AsyncCpuFilterSelect.tsx +++ b/src/features/hardware/cpu/client/components/AsyncCpuFilterSelect.tsx @@ -2,8 +2,9 @@ import { type ReactNode, useCallback, useMemo, useState } from 'react' import AsyncMultiSelect from '@/components/ui/form/async-multi-select/AsyncMultiSelect' -import { CACHE_DURATIONS } from '@/data/constants' +import { LOOKUP_PAGINATION } from '@/data/constants' import { api } from '@/lib/api' +import { toCpuSelectOption } from '../utils/cpuSelectOption' interface Props { label: string @@ -15,49 +16,35 @@ interface Props { maxDisplayed?: number } -const PAGE_SIZE = 50 -const LOOKUP_DATA_QUERY_OPTIONS = { - staleTime: CACHE_DURATIONS.LOOKUP, - gcTime: CACHE_DURATIONS.LOOKUP_GC, -} - export default function AsyncCpuFilterSelect(props: Props) { const [query, setQuery] = useState('') const [pageOffsets, setPageOffsets] = useState([0]) const byIdsQuery = api.cpus.getByIds.useQuery( { ids: props.value }, - { ...LOOKUP_DATA_QUERY_OPTIONS, enabled: props.value.length > 0 }, + { enabled: props.value.length > 0 }, ) const pageQueries = api.useQueries((t) => pageOffsets.map((offset) => - t.cpus.options( - { search: query || undefined, limit: PAGE_SIZE, offset }, - LOOKUP_DATA_QUERY_OPTIONS, - ), + t.cpus.options({ + search: query || undefined, + limit: LOOKUP_PAGINATION.DEFAULT_LIMIT, + offset, + }), ), ) const options = useMemo( () => pageQueries.flatMap((pageQuery) => - (pageQuery.data?.cpus ?? []).map((c) => ({ - id: c.id, - name: `${c.brand.name} ${c.modelName}`, - badgeName: c.modelName, - })), + (pageQuery.data?.cpus ?? []).map((cpu) => toCpuSelectOption(cpu)), ), [pageQueries], ) const selectedByIds = useMemo( - () => - (byIdsQuery.data ?? []).map((c) => ({ - id: c.id, - name: `${c.brand.name} ${c.modelName}`, - badgeName: c.modelName, - })), + () => (byIdsQuery.data ?? []).map((cpu) => toCpuSelectOption(cpu)), [byIdsQuery.data], ) @@ -66,11 +53,14 @@ export default function AsyncCpuFilterSelect(props: Props) { const isFetching = pageQueries.some((pageQuery) => pageQuery.isFetching) const handleLoadMore = useCallback(() => { - setPageOffsets((offsets) => [...offsets, offsets[offsets.length - 1] + PAGE_SIZE]) + setPageOffsets((offsets) => [ + ...offsets, + offsets[offsets.length - 1] + LOOKUP_PAGINATION.DEFAULT_LIMIT, + ]) }, []) - const handleQueryChange = useCallback((q: string) => { - setQuery(q) + const handleQueryChange = useCallback((nextQuery: string) => { + setQuery(nextQuery) setPageOffsets([0]) }, []) diff --git a/src/features/hardware/cpu/client/utils/cpuSelectOption.ts b/src/features/hardware/cpu/client/utils/cpuSelectOption.ts new file mode 100644 index 000000000..1197b8d12 --- /dev/null +++ b/src/features/hardware/cpu/client/utils/cpuSelectOption.ts @@ -0,0 +1,11 @@ +import { getCpuLabel } from '../../shared/cpu-format' +import type { CpuSummary } from '../../shared/cpu.types' +import type { Option } from '@/components/ui/form/async-multi-select/AsyncMultiSelect' + +export function toCpuSelectOption(cpu: CpuSummary): Option { + return { + id: cpu.id, + name: getCpuLabel(cpu), + badgeName: cpu.modelName, + } +} diff --git a/src/features/hardware/cpu/server/cpu.mapper.ts b/src/features/hardware/cpu/server/cpu.mapper.ts new file mode 100644 index 000000000..5f8c29fd7 --- /dev/null +++ b/src/features/hardware/cpu/server/cpu.mapper.ts @@ -0,0 +1,26 @@ +import { CpuDetailSchema, CpuSummarySchema } from '../shared/cpu.schemas' +import type { CpuDetailRecord, CpuSummaryRecord } from './cpu.repository.types' +import type { CpuDetail, CpuSummary } from '../shared/cpu.types' + +export function toCpuSummaryDto(cpu: CpuSummaryRecord): CpuSummary { + return CpuSummarySchema.parse({ + id: cpu.id, + modelName: cpu.modelName, + brand: { + id: cpu.brand.id, + name: cpu.brand.name, + }, + }) +} + +export function toCpuDetailDto(cpu: CpuDetailRecord): CpuDetail { + return CpuDetailSchema.parse({ + id: cpu.id, + modelName: cpu.modelName, + brand: { + id: cpu.brand.id, + name: cpu.brand.name, + }, + pcListingCount: cpu._count.pcListings, + }) +} diff --git a/src/features/hardware/cpu/server/cpu.policy.test.ts b/src/features/hardware/cpu/server/cpu.policy.test.ts new file mode 100644 index 000000000..8d13400d9 --- /dev/null +++ b/src/features/hardware/cpu/server/cpu.policy.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest' +import { PERMISSIONS } from '@/utils/permission-system' +import { Role } from '@orm/client' +import { assertCanManageCpu, assertCanViewCpuStats } from './cpu.policy' +import type { UserActor } from '@/server/auth/actor' + +const baseActor = { + type: 'user', + userId: 'user-id', + role: Role.ADMIN, + showNsfw: false, +} satisfies Omit + +describe('cpu.policy', () => { + it('allows CPU management with the manage devices permission', () => { + expect(() => + assertCanManageCpu({ + ...baseActor, + permissions: [PERMISSIONS.MANAGE_DEVICES], + }), + ).not.toThrow() + }) + + it('rejects CPU management without the manage devices permission', () => { + expect(() => + assertCanManageCpu({ + ...baseActor, + permissions: [], + }), + ).toThrow('You need the following permissions: manage_devices') + }) + + it('allows CPU stats with the view statistics permission', () => { + expect(() => + assertCanViewCpuStats({ + ...baseActor, + permissions: [PERMISSIONS.VIEW_STATISTICS], + }), + ).not.toThrow() + }) +}) diff --git a/src/features/hardware/cpu/server/cpu.policy.ts b/src/features/hardware/cpu/server/cpu.policy.ts new file mode 100644 index 000000000..db1c01337 --- /dev/null +++ b/src/features/hardware/cpu/server/cpu.policy.ts @@ -0,0 +1,10 @@ +import { requireActorPermission, type Actor } from '@/server/auth/actor' +import { PERMISSIONS } from '@/utils/permission-system' + +export function assertCanManageCpu(actor: Actor): void { + requireActorPermission(actor, PERMISSIONS.MANAGE_DEVICES) +} + +export function assertCanViewCpuStats(actor: Actor): void { + requireActorPermission(actor, PERMISSIONS.VIEW_STATISTICS) +} diff --git a/src/features/hardware/cpu/server/cpu.repository.test.ts b/src/features/hardware/cpu/server/cpu.repository.test.ts new file mode 100644 index 000000000..86fd5ddc1 --- /dev/null +++ b/src/features/hardware/cpu/server/cpu.repository.test.ts @@ -0,0 +1,335 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { PAGINATION } from '@/data/constants' +import { prisma } from '@/server/db' +import { CpuRepository } from './cpu.repository' +import { + CPU_DELETE_GUARD_SELECT, + CPU_DETAIL_SELECT, + CPU_MOBILE_LIST_SELECT, + CPU_MOBILE_PC_LISTING_SELECT, + CPU_MODEL_CONFLICT_SELECT, + CPU_SUMMARY_SELECT, +} from './persistence/cpu.prisma' +import type * as OrmClient from '@orm/client' + +const CPU_ID = '00000000-0000-4000-a000-000000000001' +const BRAND_ID = '00000000-0000-4000-a000-000000000002' +const CREATED_AT = new Date('2024-01-01T00:00:00.000Z') + +const mockPrisma = vi.hoisted(() => ({ + cpu: { + count: vi.fn(), + create: vi.fn(), + delete: vi.fn(), + findFirst: vi.fn(), + findMany: vi.fn(), + findUnique: vi.fn(), + update: vi.fn(), + }, +})) + +vi.mock('@/server/db', () => ({ prisma: mockPrisma })) + +vi.mock('@orm/client', async () => { + const actual = await vi.importActual('@orm/client') + return { + ...actual, + Prisma: { + ...actual.Prisma, + QueryMode: { insensitive: 'insensitive' }, + SortOrder: { asc: 'asc', desc: 'desc' }, + }, + } +}) + +describe('CPU repository persistence adapter', () => { + let repository: CpuRepository + + beforeEach(() => { + mockPrisma.cpu.count.mockReset() + mockPrisma.cpu.create.mockReset() + mockPrisma.cpu.delete.mockReset() + mockPrisma.cpu.findFirst.mockReset() + mockPrisma.cpu.findMany.mockReset() + mockPrisma.cpu.findUnique.mockReset() + mockPrisma.cpu.update.mockReset() + repository = new CpuRepository(prisma) + }) + + it('creates a CPU with the explicit detail select contract', async () => { + mockPrisma.cpu.create.mockResolvedValueOnce({ + id: CPU_ID, + modelName: 'Core i7-13700K', + brand: { id: BRAND_ID, name: 'Intel' }, + _count: { pcListings: 0 }, + }) + + await repository.create({ brandId: BRAND_ID, modelName: 'Core i7-13700K' }) + + expect(mockPrisma.cpu.create).toHaveBeenCalledWith({ + data: { brandId: BRAND_ID, modelName: 'Core i7-13700K' }, + select: CPU_DETAIL_SELECT, + }) + }) + + it('translates database unique constraint errors for writes', async () => { + const error = new Error('Unique constraint failed') + Object.assign(error, { code: 'P2002' }) + mockPrisma.cpu.create.mockRejectedValueOnce(error) + + await expect( + repository.create({ brandId: BRAND_ID, modelName: 'Core i7-13700K' }), + ).rejects.toThrow('A CPU with model name "Core i7-13700K" already exists for this brand') + }) + + it('updates a CPU with the explicit detail select contract', async () => { + mockPrisma.cpu.update.mockResolvedValueOnce({ + id: CPU_ID, + modelName: 'Core i7-13700K', + brand: { id: BRAND_ID, name: 'Intel' }, + _count: { pcListings: 0 }, + }) + + await repository.update(CPU_ID, { brandId: BRAND_ID, modelName: 'Core i7-13700K' }) + + expect(mockPrisma.cpu.update).toHaveBeenCalledWith({ + where: { id: CPU_ID }, + data: { brandId: BRAND_ID, modelName: 'Core i7-13700K' }, + select: CPU_DETAIL_SELECT, + }) + }) + + it('finds case-insensitive model conflicts for the selected brand', async () => { + mockPrisma.cpu.findFirst.mockResolvedValueOnce({ id: CPU_ID }) + + await expect( + repository.findModelNameConflict({ + brandId: BRAND_ID, + modelName: 'Core i7-13700K', + excludeId: CPU_ID, + }), + ).resolves.toEqual({ id: CPU_ID }) + + expect(mockPrisma.cpu.findFirst).toHaveBeenCalledWith({ + where: { + brandId: BRAND_ID, + modelName: { equals: 'Core i7-13700K', mode: 'insensitive' }, + id: { not: CPU_ID }, + }, + select: CPU_MODEL_CONFLICT_SELECT, + }) + }) + + it('lists CPUs with the explicit detail select contract', async () => { + mockPrisma.cpu.findMany.mockResolvedValueOnce([ + { + id: CPU_ID, + modelName: 'Core i7-13700K', + brand: { id: BRAND_ID, name: 'Intel' }, + _count: { pcListings: 0 }, + }, + ]) + mockPrisma.cpu.count.mockResolvedValueOnce(1) + + await expect(repository.list({ page: 1, limit: PAGINATION.DEFAULT_LIMIT })).resolves.toEqual({ + cpus: [ + { + id: CPU_ID, + modelName: 'Core i7-13700K', + brand: { id: BRAND_ID, name: 'Intel' }, + _count: { pcListings: 0 }, + }, + ], + pagination: { + total: 1, + pages: 1, + page: 1, + offset: 0, + limit: PAGINATION.DEFAULT_LIMIT, + hasNextPage: false, + hasPreviousPage: false, + }, + }) + expect(mockPrisma.cpu.findMany).toHaveBeenCalledWith( + expect.objectContaining({ select: CPU_DETAIL_SELECT }), + ) + }) + + it('lists CPU summaries by id with the explicit summary select contract', async () => { + mockPrisma.cpu.findMany.mockResolvedValueOnce([ + { + id: CPU_ID, + modelName: 'Core i7-13700K', + brand: { id: BRAND_ID, name: 'Intel' }, + }, + ]) + + await expect(repository.listByIds([CPU_ID])).resolves.toEqual([ + { + id: CPU_ID, + modelName: 'Core i7-13700K', + brand: { id: BRAND_ID, name: 'Intel' }, + }, + ]) + + expect(mockPrisma.cpu.findMany).toHaveBeenCalledWith({ + where: { id: { in: [CPU_ID] } }, + select: CPU_SUMMARY_SELECT, + }) + }) + + it('lists mobile compatibility CPUs with the old scalar fields and counts', async () => { + mockPrisma.cpu.findMany.mockResolvedValueOnce([ + { + id: CPU_ID, + brandId: BRAND_ID, + modelName: 'Core i7-13700K', + createdAt: CREATED_AT, + brand: { id: BRAND_ID, name: 'Intel' }, + _count: { pcListings: 0 }, + }, + ]) + mockPrisma.cpu.count.mockResolvedValueOnce(1) + + await expect(repository.listMobileCompatibility({ page: 1, limit: 1000 })).resolves.toEqual({ + cpus: [ + { + id: CPU_ID, + brandId: BRAND_ID, + modelName: 'Core i7-13700K', + createdAt: CREATED_AT, + brand: { id: BRAND_ID, name: 'Intel' }, + _count: { pcListings: 0 }, + }, + ], + pagination: { + total: 1, + pages: 1, + page: 1, + offset: 0, + limit: 1000, + hasNextPage: false, + hasPreviousPage: false, + }, + }) + expect(mockPrisma.cpu.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + select: CPU_MOBILE_LIST_SELECT, + take: 1000, + }), + ) + }) + + it('reads mobile PC listing CPUs with the old route query contract', async () => { + mockPrisma.cpu.findMany.mockResolvedValueOnce([ + { + id: CPU_ID, + brandId: BRAND_ID, + modelName: 'Core i7-13700K', + createdAt: CREATED_AT, + brand: { id: BRAND_ID, name: 'Intel' }, + }, + ]) + + await expect( + repository.pcListingMobileCpuCompatibility({ search: 'Core', limit: 100 }), + ).resolves.toEqual({ + cpus: [ + { + id: CPU_ID, + brandId: BRAND_ID, + modelName: 'Core i7-13700K', + createdAt: CREATED_AT, + brand: { id: BRAND_ID, name: 'Intel' }, + }, + ], + }) + + expect(mockPrisma.cpu.findMany).toHaveBeenCalledWith({ + where: { + OR: [ + { modelName: { contains: 'Core', mode: 'insensitive' } }, + { brand: { name: { contains: 'Core', mode: 'insensitive' } } }, + ], + }, + select: CPU_MOBILE_PC_LISTING_SELECT, + orderBy: { modelName: 'asc' }, + take: 100, + }) + }) + + it('reads CPU dropdown pages with summary select and lookahead pagination', async () => { + mockPrisma.cpu.findMany.mockResolvedValueOnce([ + { + id: CPU_ID, + modelName: 'Core i7-13700K', + brand: { id: BRAND_ID, name: 'Intel' }, + }, + { + id: '00000000-0000-4000-a000-000000000003', + modelName: 'Core i9-14900K', + brand: { id: BRAND_ID, name: 'Intel' }, + }, + ]) + + await expect(repository.options({ search: 'Intel', limit: 1, offset: 5 })).resolves.toEqual({ + cpus: [ + { + id: CPU_ID, + modelName: 'Core i7-13700K', + brand: { id: BRAND_ID, name: 'Intel' }, + }, + ], + hasMore: true, + }) + + expect(mockPrisma.cpu.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + select: CPU_SUMMARY_SELECT, + skip: 5, + take: 2, + }), + ) + }) + + it('reads the delete guard with the explicit delete guard select contract', async () => { + mockPrisma.cpu.findUnique.mockResolvedValueOnce({ + id: CPU_ID, + _count: { pcListings: 0, presets: 0 }, + }) + + await expect(repository.findDeleteGuardById(CPU_ID)).resolves.toEqual({ + id: CPU_ID, + _count: { pcListings: 0, presets: 0 }, + }) + + expect(mockPrisma.cpu.findUnique).toHaveBeenCalledWith({ + where: { id: CPU_ID }, + select: CPU_DELETE_GUARD_SELECT, + }) + }) + + it('deletes a CPU by id with a minimal select contract', async () => { + mockPrisma.cpu.delete.mockResolvedValueOnce({ id: CPU_ID }) + + await repository.delete(CPU_ID) + + expect(mockPrisma.cpu.delete).toHaveBeenCalledWith({ + where: { id: CPU_ID }, + select: { id: true }, + }) + }) + + it('returns CPU usage stats from PC report counts', async () => { + mockPrisma.cpu.count.mockResolvedValueOnce(3).mockResolvedValueOnce(2) + + await expect(repository.stats()).resolves.toEqual({ + total: 5, + withListings: 3, + withoutListings: 2, + }) + + expect(mockPrisma.cpu.count).toHaveBeenCalledWith({ where: { pcListings: { some: {} } } }) + expect(mockPrisma.cpu.count).toHaveBeenCalledWith({ where: { pcListings: { none: {} } } }) + }) +}) diff --git a/src/features/hardware/cpu/server/cpu.repository.ts b/src/features/hardware/cpu/server/cpu.repository.ts new file mode 100644 index 000000000..52497b6d1 --- /dev/null +++ b/src/features/hardware/cpu/server/cpu.repository.ts @@ -0,0 +1,189 @@ +import { PrismaWriteRepository } from '@/server/persistence/prisma.repository' +import { paginationResult } from '@/server/utils/pagination' +import { type CpuWriteContext, translateCpuWriteError } from './persistence/cpu.errors' +import { + CPU_DELETE_GUARD_SELECT, + CPU_DETAIL_SELECT, + CPU_MOBILE_LIST_SELECT, + CPU_MOBILE_PC_LISTING_SELECT, + CPU_MODEL_CONFLICT_SELECT, + CPU_SUMMARY_SELECT, +} from './persistence/cpu.prisma' +import { + buildCpuListQuery, + buildCpuModelNameConflictWhere, + buildCpuOptionsQuery, + buildMobileCpuListQuery, + buildMobilePcListingCpuQuery, +} from './persistence/cpu.query' +import type { + CpuDetailRecord, + CpuDeleteGuardRecord, + CpuListResult, + CpuMobileListResult, + CpuMobilePcListingResult, + CpuModelNameConflictInput, + CpuModelNameConflictRecord, + CpuOptionsFilters, + CpuOptionsResult, + CpuSummaryRecord, + UpdateCpuData, +} from './cpu.repository.types' +import type { + CreateCpuInput, + GetCpusInput, + MobileGetCpusInput, + MobilePcListingCpusInput, +} from '../shared/cpu.types' + +export class CpuRepository extends PrismaWriteRepository { + protected translateWriteError(error: unknown, context: CpuWriteContext): never { + return translateCpuWriteError(error, context) + } + + async byIdWithCounts(id: string): Promise { + return this.prisma.cpu.findUnique({ + where: { id }, + select: CPU_DETAIL_SELECT, + }) + } + + async findDeleteGuardById(id: string): Promise { + return this.prisma.cpu.findUnique({ + where: { id }, + select: CPU_DELETE_GUARD_SELECT, + }) + } + + async listByIds(ids: string[]): Promise { + if (ids.length === 0) return [] + + return this.prisma.cpu.findMany({ + where: { id: { in: ids } }, + select: CPU_SUMMARY_SELECT, + }) + } + + async list(filters: GetCpusInput = {}): Promise { + const query = buildCpuListQuery(filters) + + const [cpus, total] = await Promise.all([ + this.prisma.cpu.findMany({ + where: query.where, + select: CPU_DETAIL_SELECT, + orderBy: query.orderBy, + take: query.pagination.limit, + skip: query.pagination.offset, + }), + this.prisma.cpu.count({ where: query.where }), + ]) + + return { + cpus, + pagination: paginationResult(total, query.pagination), + } + } + + async listMobileCompatibility(filters: MobileGetCpusInput = {}): Promise { + const query = buildMobileCpuListQuery(filters) + + const [cpus, total] = await Promise.all([ + this.prisma.cpu.findMany({ + where: query.where, + select: CPU_MOBILE_LIST_SELECT, + orderBy: query.orderBy, + take: query.pagination.limit, + skip: query.pagination.offset, + }), + this.prisma.cpu.count({ where: query.where }), + ]) + + return { + cpus, + pagination: paginationResult(total, query.pagination), + } + } + + async byIdMobileCompatibility(id: string): Promise { + return this.prisma.cpu.findUnique({ + where: { id }, + select: CPU_MOBILE_LIST_SELECT, + }) + } + + async pcListingMobileCpuCompatibility( + filters: MobilePcListingCpusInput, + ): Promise { + const query = buildMobilePcListingCpuQuery(filters) + const cpus = await this.prisma.cpu.findMany({ + where: query.where, + select: CPU_MOBILE_PC_LISTING_SELECT, + orderBy: query.orderBy, + take: query.limit, + }) + + return { cpus } + } + + async options(filters: CpuOptionsFilters = {}): Promise { + const query = buildCpuOptionsQuery(filters) + const cpus = await this.prisma.cpu.findMany({ + where: query.where, + select: CPU_SUMMARY_SELECT, + orderBy: query.orderBy, + take: query.limit + 1, + skip: query.offset, + }) + + return { + cpus: cpus.slice(0, query.limit), + hasMore: cpus.length > query.limit, + } + } + + async findModelNameConflict( + input: CpuModelNameConflictInput, + ): Promise { + return this.prisma.cpu.findFirst({ + where: buildCpuModelNameConflictWhere(input), + select: CPU_MODEL_CONFLICT_SELECT, + }) + } + + async create(data: CreateCpuInput): Promise { + return this.executeWrite(() => this.prisma.cpu.create({ data, select: CPU_DETAIL_SELECT }), { + action: 'create', + modelName: data.modelName, + }) + } + + async update(id: string, data: UpdateCpuData): Promise { + return this.executeWrite( + () => this.prisma.cpu.update({ where: { id }, data, select: CPU_DETAIL_SELECT }), + { action: 'update', modelName: data.modelName }, + ) + } + + async delete(id: string): Promise { + await this.executeWrite(() => this.prisma.cpu.delete({ where: { id }, select: { id: true } }), { + action: 'delete', + }) + } + + async stats(): Promise<{ + total: number + withListings: number + withoutListings: number + }> { + const [withListings, withoutListings] = await Promise.all([ + this.prisma.cpu.count({ where: { pcListings: { some: {} } } }), + this.prisma.cpu.count({ where: { pcListings: { none: {} } } }), + ]) + + return { + total: withListings + withoutListings, + withListings, + withoutListings, + } + } +} diff --git a/src/features/hardware/cpu/server/cpu.repository.types.ts b/src/features/hardware/cpu/server/cpu.repository.types.ts new file mode 100644 index 000000000..4bb7e6e96 --- /dev/null +++ b/src/features/hardware/cpu/server/cpu.repository.types.ts @@ -0,0 +1,45 @@ +import type { GetCpuOptionsInput, UpdateCpuInput } from '../shared/cpu.types' +import type { + CpuDetailRecord, + CpuMobileListRecord, + CpuMobilePcListingRecord, + CpuSummaryRecord, +} from './persistence/cpu.prisma' +import type { PaginationResult } from '@/schemas/pagination' + +export type { + CpuDeleteGuardRecord, + CpuDetailRecord, + CpuMobileListRecord, + CpuMobilePcListingRecord, + CpuModelNameConflictRecord, + CpuSummaryRecord, +} from './persistence/cpu.prisma' + +export type CpuListResult = { + cpus: CpuDetailRecord[] + pagination: PaginationResult +} + +export type CpuOptionsResult = { + cpus: CpuSummaryRecord[] + hasMore: boolean +} + +export type CpuMobileListResult = { + cpus: CpuMobileListRecord[] + pagination: PaginationResult +} + +export type CpuMobilePcListingResult = { + cpus: CpuMobilePcListingRecord[] +} + +export type CpuOptionsFilters = NonNullable +export type UpdateCpuData = Omit + +export type CpuModelNameConflictInput = { + brandId: string + modelName: string + excludeId?: string +} diff --git a/src/features/hardware/cpu/server/cpu.router.test.ts b/src/features/hardware/cpu/server/cpu.router.test.ts new file mode 100644 index 000000000..9bc11f672 --- /dev/null +++ b/src/features/hardware/cpu/server/cpu.router.test.ts @@ -0,0 +1,157 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { prisma } from '@/server/db' +import { PERMISSIONS } from '@/utils/permission-system' +import { Role } from '@orm/client' + +vi.unmock('@/server/api/trpc') +vi.unmock('@/server/api/root') + +const mockPrisma = vi.hoisted(() => ({ + cpu: { + count: vi.fn(), + create: vi.fn(), + delete: vi.fn(), + findFirst: vi.fn(), + findMany: vi.fn(), + findUnique: vi.fn(), + update: vi.fn(), + }, +})) + +vi.mock('@/server/db', () => ({ prisma: mockPrisma })) + +const { cpuRouter } = await import('./cpu.router') + +const USER_ID = '00000000-0000-4000-a000-000000000010' +const CPU_ID = '00000000-0000-4000-a000-000000000001' +const BRAND_ID = '00000000-0000-4000-a000-000000000002' + +const cpuWithCounts = { + id: CPU_ID, + brandId: BRAND_ID, + modelName: 'Core i7-13700K', + createdAt: new Date('2026-01-01T00:00:00.000Z'), + brand: { + id: BRAND_ID, + name: 'Intel', + createdAt: new Date('2026-01-01T00:00:00.000Z'), + }, + _count: { pcListings: 2 }, +} + +function createCaller(overrides: { permissions?: string[] } = {}) { + return { + caller: cpuRouter.createCaller({ + session: { + user: { + id: USER_ID, + email: 'test@test.com', + name: 'Test User', + role: Role.ADMIN, + permissions: overrides.permissions ?? [], + showNsfw: false, + }, + }, + prisma, + headers: new Headers(), + }), + } +} + +describe('cpuRouter', () => { + beforeEach(() => { + mockPrisma.cpu.count.mockReset() + mockPrisma.cpu.create.mockReset() + mockPrisma.cpu.delete.mockReset() + mockPrisma.cpu.findFirst.mockReset() + mockPrisma.cpu.findMany.mockReset() + mockPrisma.cpu.findUnique.mockReset() + mockPrisma.cpu.update.mockReset() + }) + + it('returns stable web DTOs from get and hides Prisma relation count details', async () => { + const { caller } = createCaller() + mockPrisma.cpu.findMany.mockResolvedValueOnce([cpuWithCounts]) + mockPrisma.cpu.count.mockResolvedValueOnce(1) + + const result = await caller.get({ page: 2, limit: 10, search: 'Intel' }) + + expect(mockPrisma.cpu.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + skip: 10, + take: 10, + }), + ) + expect(result).toEqual({ + cpus: [ + { + id: CPU_ID, + modelName: 'Core i7-13700K', + brand: { id: BRAND_ID, name: 'Intel' }, + pcListingCount: 2, + }, + ], + pagination: { + total: 1, + pages: 1, + page: 2, + offset: 10, + limit: 10, + hasNextPage: false, + hasPreviousPage: true, + }, + }) + expect(result.cpus[0]).not.toHaveProperty('_count') + }) + + it('creates a CPU through validation, policy, repository, service, and DTO output', async () => { + const { caller } = createCaller({ permissions: [PERMISSIONS.MANAGE_DEVICES] }) + mockPrisma.cpu.findFirst.mockResolvedValueOnce(null) + mockPrisma.cpu.create.mockResolvedValueOnce(cpuWithCounts) + + const result = await caller.create({ + brandId: BRAND_ID, + modelName: ' Core i7-13700K ', + }) + + expect(mockPrisma.cpu.create).toHaveBeenCalledWith({ + data: { brandId: BRAND_ID, modelName: 'Core i7-13700K' }, + select: { + id: true, + modelName: true, + brand: { select: { id: true, name: true } }, + _count: { select: { pcListings: true } }, + }, + }) + expect(result).toEqual({ + id: CPU_ID, + modelName: 'Core i7-13700K', + brand: { id: BRAND_ID, name: 'Intel' }, + pcListingCount: 2, + }) + }) + + it('rejects create before database access when the session lacks manage-device permission', async () => { + const { caller } = createCaller() + + await expect( + caller.create({ + brandId: BRAND_ID, + modelName: 'Core i7-13700K', + }), + ).rejects.toThrow('You need the following permissions: manage_devices') + expect(mockPrisma.cpu.findFirst).not.toHaveBeenCalled() + expect(mockPrisma.cpu.create).not.toHaveBeenCalled() + }) + + it('returns CPU stats only when the session has statistics permission', async () => { + const { caller } = createCaller({ permissions: [PERMISSIONS.VIEW_STATISTICS] }) + mockPrisma.cpu.count.mockResolvedValueOnce(3).mockResolvedValueOnce(2) + + await expect(caller.stats()).resolves.toEqual({ + total: 5, + withListings: 3, + withoutListings: 2, + }) + }) +}) diff --git a/src/features/hardware/cpu/server/cpu.router.ts b/src/features/hardware/cpu/server/cpu.router.ts new file mode 100644 index 000000000..228218899 --- /dev/null +++ b/src/features/hardware/cpu/server/cpu.router.ts @@ -0,0 +1,67 @@ +import { MutationSuccessSchema } from '@/schemas/common' +import { createTRPCRouter, protectedProcedure, publicProcedure } from '@/server/api/trpc' +import { createActorFromSession } from '@/server/auth/actor' +import { createCpuService } from './cpu.service' +import { + CreateCpuSchema, + DeleteCpuSchema, + GetCpuByIdSchema, + GetCpuOptionsSchema, + GetCpusByIdsSchema, + GetCpusSchema, + CpuDetailSchema, + CpuListResponseSchema, + CpuOptionsResponseSchema, + CpuStatsSchema, + CpusByIdsResponseSchema, + UpdateCpuSchema, +} from '../shared/cpu.schemas' + +export const cpuRouter = createTRPCRouter({ + get: publicProcedure + .input(GetCpusSchema) + .output(CpuListResponseSchema) + .query(async ({ ctx, input }) => createCpuService(ctx.prisma).list(input ?? {})), + + options: publicProcedure + .input(GetCpuOptionsSchema) + .output(CpuOptionsResponseSchema) + .query(async ({ ctx, input }) => createCpuService(ctx.prisma).options(input ?? {})), + + byId: publicProcedure + .input(GetCpuByIdSchema) + .output(CpuDetailSchema) + .query(async ({ ctx, input }) => createCpuService(ctx.prisma).byId(input.id)), + + getByIds: publicProcedure + .input(GetCpusByIdsSchema) + .output(CpusByIdsResponseSchema) + .query(async ({ ctx, input }) => createCpuService(ctx.prisma).listByIds(input)), + + create: protectedProcedure + .input(CreateCpuSchema) + .output(CpuDetailSchema) + .mutation(async ({ ctx, input }) => + createCpuService(ctx.prisma).create(createActorFromSession(ctx.session), input), + ), + + update: protectedProcedure + .input(UpdateCpuSchema) + .output(CpuDetailSchema) + .mutation(async ({ ctx, input }) => + createCpuService(ctx.prisma).update(createActorFromSession(ctx.session), input), + ), + + delete: protectedProcedure + .input(DeleteCpuSchema) + .output(MutationSuccessSchema) + .mutation(async ({ ctx, input }) => + createCpuService(ctx.prisma).delete(createActorFromSession(ctx.session), input), + ), + + stats: protectedProcedure + .output(CpuStatsSchema) + .query(async ({ ctx }) => + createCpuService(ctx.prisma).stats(createActorFromSession(ctx.session)), + ), +}) diff --git a/src/features/hardware/cpu/server/cpu.rules.test.ts b/src/features/hardware/cpu/server/cpu.rules.test.ts new file mode 100644 index 000000000..60ba2c86e --- /dev/null +++ b/src/features/hardware/cpu/server/cpu.rules.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest' +import { assertCpuCanBeDeleted, assertCpuModelNameAvailable } from './cpu.rules' + +describe('cpu.rules', () => { + it('allows writes when no model-name conflict exists', () => { + expect(() => assertCpuModelNameAvailable(null, 'Core i7-13700K')).not.toThrow() + }) + + it('blocks writes when a model-name conflict exists', () => { + expect(() => assertCpuModelNameAvailable({ id: 'cpu-id' }, 'Core i7-13700K')).toThrow( + 'A CPU with model name "Core i7-13700K" already exists for this brand', + ) + }) + + it('allows deleting unused CPUs', () => { + expect(() => + assertCpuCanBeDeleted({ + id: 'cpu-id', + _count: { pcListings: 0, presets: 0 }, + }), + ).not.toThrow() + }) + + it('blocks deleting CPUs used by reports or presets', () => { + expect(() => + assertCpuCanBeDeleted({ + id: 'cpu-id', + _count: { pcListings: 2, presets: 1 }, + }), + ).toThrow('Cannot delete CPU that is used in 3 records') + }) +}) diff --git a/src/features/hardware/cpu/server/cpu.rules.ts b/src/features/hardware/cpu/server/cpu.rules.ts new file mode 100644 index 000000000..5cedecb1f --- /dev/null +++ b/src/features/hardware/cpu/server/cpu.rules.ts @@ -0,0 +1,14 @@ +import { ResourceError } from '@/lib/errors' +import type { CpuDeleteGuardRecord, CpuModelNameConflictRecord } from './cpu.repository.types' + +export function assertCpuModelNameAvailable( + conflict: CpuModelNameConflictRecord | null, + modelName: string, +): void { + if (conflict) throw ResourceError.cpu.alreadyExists(modelName) +} + +export function assertCpuCanBeDeleted(cpu: CpuDeleteGuardRecord): void { + const usageCount = cpu._count.pcListings + cpu._count.presets + if (usageCount > 0) throw ResourceError.cpu.inUse(usageCount) +} diff --git a/src/features/hardware/cpu/server/cpu.service.test.ts b/src/features/hardware/cpu/server/cpu.service.test.ts new file mode 100644 index 000000000..445f16cf2 --- /dev/null +++ b/src/features/hardware/cpu/server/cpu.service.test.ts @@ -0,0 +1,307 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { PAGINATION } from '@/data/constants' +import { prisma } from '@/server/db' +import { PERMISSIONS } from '@/utils/permission-system' +import { Role } from '@orm/client' +import { CpuRepository } from './cpu.repository' +import { CpuService } from './cpu.service' +import type { CpuDetailRecord, CpuMobileListRecord } from './cpu.repository.types' +import type { Actor } from '@/server/auth/actor' + +const CPU_ID = '00000000-0000-4000-a000-000000000001' +const BRAND_ID = '00000000-0000-4000-a000-000000000002' +const CREATED_AT = new Date('2024-01-01T00:00:00.000Z') + +const cpuWithCounts = { + id: CPU_ID, + modelName: 'Core i7-13700K', + brand: { id: BRAND_ID, name: 'Intel' }, + _count: { pcListings: 4 }, +} satisfies CpuDetailRecord + +const mobileCpuRecord = { + id: CPU_ID, + brandId: BRAND_ID, + modelName: 'Core i7-13700K', + createdAt: CREATED_AT, + brand: { id: BRAND_ID, name: 'Intel' }, + _count: { pcListings: 4 }, +} satisfies CpuMobileListRecord + +const mockPrisma = vi.hoisted(() => ({ + cpu: { + count: vi.fn(), + create: vi.fn(), + delete: vi.fn(), + findFirst: vi.fn(), + findMany: vi.fn(), + findUnique: vi.fn(), + update: vi.fn(), + }, +})) + +vi.mock('@/server/db', () => ({ prisma: mockPrisma })) + +function createActor(permissions: string[]): Actor { + return { + type: 'user', + userId: 'user-id', + role: Role.ADMIN, + permissions, + showNsfw: false, + } +} + +function createMockRepository() { + const repository = new CpuRepository(prisma) + + return { + repository, + byIdWithCounts: vi.spyOn(repository, 'byIdWithCounts'), + byIdMobileCompatibility: vi.spyOn(repository, 'byIdMobileCompatibility'), + create: vi.spyOn(repository, 'create'), + delete: vi.spyOn(repository, 'delete'), + findDeleteGuardById: vi.spyOn(repository, 'findDeleteGuardById'), + findModelNameConflict: vi.spyOn(repository, 'findModelNameConflict'), + list: vi.spyOn(repository, 'list'), + listByIds: vi.spyOn(repository, 'listByIds'), + listMobileCompatibility: vi.spyOn(repository, 'listMobileCompatibility'), + options: vi.spyOn(repository, 'options'), + pcListingMobileCpuCompatibility: vi.spyOn(repository, 'pcListingMobileCpuCompatibility'), + stats: vi.spyOn(repository, 'stats'), + update: vi.spyOn(repository, 'update'), + } +} + +type MockCpuRepository = ReturnType + +function createService(repository: MockCpuRepository = createMockRepository()) { + return { + repository, + service: new CpuService(repository.repository), + } +} + +describe('CpuService', () => { + beforeEach(() => { + vi.restoreAllMocks() + }) + + it('maps list results to stable CPU DTOs', async () => { + const { repository, service } = createService() + repository.list.mockResolvedValueOnce({ + cpus: [cpuWithCounts], + pagination: { + total: 1, + pages: 1, + page: 1, + offset: 0, + limit: PAGINATION.DEFAULT_LIMIT, + hasNextPage: false, + hasPreviousPage: false, + }, + }) + + const result = await service.list({ page: 1, limit: PAGINATION.DEFAULT_LIMIT }) + + expect(result.cpus).toEqual([ + { + id: CPU_ID, + modelName: 'Core i7-13700K', + brand: { id: BRAND_ID, name: 'Intel' }, + pcListingCount: 4, + }, + ]) + expect(result.cpus[0]).not.toHaveProperty('_count') + }) + + it('preserves mobile CPU list compatibility responses', async () => { + const { repository, service } = createService() + repository.listMobileCompatibility.mockResolvedValueOnce({ + cpus: [mobileCpuRecord], + pagination: { + total: 1, + pages: 1, + page: 1, + offset: 0, + limit: 1000, + hasNextPage: false, + hasPreviousPage: false, + }, + }) + + await expect(service.listMobileCompatibility({ page: 1, limit: 1000 })).resolves.toEqual({ + cpus: [mobileCpuRecord], + pagination: { + total: 1, + pages: 1, + page: 1, + offset: 0, + limit: 1000, + hasNextPage: false, + hasPreviousPage: false, + }, + }) + }) + + it('preserves mobile CPU detail compatibility responses', async () => { + const { repository, service } = createService() + repository.byIdMobileCompatibility.mockResolvedValueOnce(mobileCpuRecord) + + await expect(service.byIdMobileCompatibility(CPU_ID)).resolves.toEqual(mobileCpuRecord) + }) + + it('preserves mobile PC listing CPU compatibility responses', async () => { + const { repository, service } = createService() + repository.pcListingMobileCpuCompatibility.mockResolvedValueOnce({ + cpus: [ + { + id: CPU_ID, + brandId: BRAND_ID, + modelName: 'Core i7-13700K', + createdAt: CREATED_AT, + brand: { id: BRAND_ID, name: 'Intel' }, + }, + ], + }) + + await expect(service.pcListingMobileCpuCompatibility({ limit: 100 })).resolves.toEqual({ + cpus: [ + { + id: CPU_ID, + brandId: BRAND_ID, + modelName: 'Core i7-13700K', + createdAt: CREATED_AT, + brand: { id: BRAND_ID, name: 'Intel' }, + }, + ], + }) + }) + + it('normalizes model names before creating a CPU', async () => { + const { repository, service } = createService() + repository.findModelNameConflict.mockResolvedValueOnce(null) + repository.create.mockResolvedValueOnce(cpuWithCounts) + + const result = await service.create(createActor([PERMISSIONS.MANAGE_DEVICES]), { + brandId: BRAND_ID, + modelName: ' Core i7-13700K ', + }) + + expect(repository.findModelNameConflict).toHaveBeenCalledWith({ + brandId: BRAND_ID, + modelName: 'Core i7-13700K', + }) + expect(repository.create).toHaveBeenCalledWith({ + brandId: BRAND_ID, + modelName: 'Core i7-13700K', + }) + expect(result).toEqual({ + id: CPU_ID, + modelName: 'Core i7-13700K', + brand: { id: BRAND_ID, name: 'Intel' }, + pcListingCount: 4, + }) + }) + + it('rejects CPU creation before touching the repository when the actor lacks permission', async () => { + const { repository, service } = createService() + + await expect( + service.create(createActor([]), { + brandId: BRAND_ID, + modelName: 'Core i7-13700K', + }), + ).rejects.toThrow('You need the following permissions: manage_devices') + expect(repository.findModelNameConflict).not.toHaveBeenCalled() + expect(repository.create).not.toHaveBeenCalled() + }) + + it('rejects duplicate CPU model names before creating', async () => { + const { repository, service } = createService() + repository.findModelNameConflict.mockResolvedValueOnce({ id: CPU_ID }) + + await expect( + service.create(createActor([PERMISSIONS.MANAGE_DEVICES]), { + brandId: BRAND_ID, + modelName: 'Core i7-13700K', + }), + ).rejects.toThrow('A CPU with model name "Core i7-13700K" already exists for this brand') + expect(repository.create).not.toHaveBeenCalled() + }) + + it('normalizes model names before updating a CPU', async () => { + const { repository, service } = createService() + repository.findModelNameConflict.mockResolvedValueOnce(null) + repository.update.mockResolvedValueOnce(cpuWithCounts) + + await service.update(createActor([PERMISSIONS.MANAGE_DEVICES]), { + id: CPU_ID, + brandId: BRAND_ID, + modelName: ' Core i7-13700K ', + }) + + expect(repository.findModelNameConflict).toHaveBeenCalledWith({ + brandId: BRAND_ID, + modelName: 'Core i7-13700K', + excludeId: CPU_ID, + }) + expect(repository.update).toHaveBeenCalledWith(CPU_ID, { + brandId: BRAND_ID, + modelName: 'Core i7-13700K', + }) + }) + + it('rejects deleting a missing CPU before writing', async () => { + const { repository, service } = createService() + repository.findDeleteGuardById.mockResolvedValueOnce(null) + + await expect( + service.delete(createActor([PERMISSIONS.MANAGE_DEVICES]), { id: CPU_ID }), + ).rejects.toThrow('CPU not found') + expect(repository.delete).not.toHaveBeenCalled() + }) + + it('blocks deleting CPUs that are used by reports or presets before writing', async () => { + const { repository, service } = createService() + repository.findDeleteGuardById.mockResolvedValueOnce({ + id: CPU_ID, + _count: { pcListings: 3, presets: 1 }, + }) + + await expect( + service.delete(createActor([PERMISSIONS.MANAGE_DEVICES]), { id: CPU_ID }), + ).rejects.toThrow('Cannot delete CPU that is used in 4 records') + expect(repository.delete).not.toHaveBeenCalled() + }) + + it('deletes unused CPUs after checking the delete guard', async () => { + const { repository, service } = createService() + repository.findDeleteGuardById.mockResolvedValueOnce({ + id: CPU_ID, + _count: { pcListings: 0, presets: 0 }, + }) + repository.delete.mockResolvedValueOnce(undefined) + + await expect( + service.delete(createActor([PERMISSIONS.MANAGE_DEVICES]), { id: CPU_ID }), + ).resolves.toEqual({ success: true }) + expect(repository.delete).toHaveBeenCalledWith(CPU_ID) + }) + + it('requires the statistics permission before returning CPU stats', async () => { + const { repository, service } = createService() + repository.stats.mockResolvedValueOnce({ total: 5, withListings: 3, withoutListings: 2 }) + + await expect(service.stats(createActor([]))).rejects.toThrow( + 'You need the following permissions: view_statistics', + ) + expect(repository.stats).not.toHaveBeenCalled() + + await expect(service.stats(createActor([PERMISSIONS.VIEW_STATISTICS]))).resolves.toEqual({ + total: 5, + withListings: 3, + withoutListings: 2, + }) + }) +}) diff --git a/src/features/hardware/cpu/server/cpu.service.ts b/src/features/hardware/cpu/server/cpu.service.ts new file mode 100644 index 000000000..86e1e6fac --- /dev/null +++ b/src/features/hardware/cpu/server/cpu.service.ts @@ -0,0 +1,144 @@ +import { ResourceError } from '@/lib/errors' +import { createMutationSuccess, type MutationSuccess } from '@/schemas/common' +import { type Actor } from '@/server/auth/actor' +import { type PrismaRepositoryClient } from '@/server/persistence/prisma.repository' +import { normalizeWhitespace } from '@/utils/text' +import { toCpuDetailDto, toCpuSummaryDto } from './cpu.mapper' +import { assertCanManageCpu, assertCanViewCpuStats } from './cpu.policy' +import { CpuRepository } from './cpu.repository' +import { assertCpuCanBeDeleted, assertCpuModelNameAvailable } from './cpu.rules' +import { + CpuListResponseSchema, + CpuOptionsResponseSchema, + CpuStatsSchema, + CpusByIdsResponseSchema, + MobileCpuListItemSchema, + MobileCpuListResponseSchema, + MobilePcListingCpuResponseSchema, +} from '../shared/cpu.schemas' +import type { + CreateCpuInput, + DeleteCpuInput, + GetCpuOptionsInput, + GetCpusByIdsInput, + GetCpusInput, + CpuDetail, + CpuListResponse, + CpuOptionsResponse, + CpuStats, + CpusByIdsResponse, + MobileGetCpusInput, + MobileCpuListItem, + MobileCpuListResponse, + MobilePcListingCpusInput, + MobilePcListingCpuResponse, + UpdateCpuInput, +} from '../shared/cpu.types' + +export class CpuService { + constructor(private readonly repository: CpuRepository) {} + + async list(input: GetCpusInput = {}): Promise { + const result = await this.repository.list(input ?? {}) + + return CpuListResponseSchema.parse({ + cpus: result.cpus.map((cpu) => toCpuDetailDto(cpu)), + pagination: result.pagination, + }) + } + + async listMobileCompatibility(input: MobileGetCpusInput = {}): Promise { + const result = await this.repository.listMobileCompatibility(input ?? {}) + return MobileCpuListResponseSchema.parse(result) + } + + async byIdMobileCompatibility(id: string): Promise { + const cpu = await this.repository.byIdMobileCompatibility(id) + if (!cpu) throw ResourceError.cpu.notFound() + + return MobileCpuListItemSchema.parse(cpu) + } + + async pcListingMobileCpuCompatibility( + input: MobilePcListingCpusInput, + ): Promise { + const result = await this.repository.pcListingMobileCpuCompatibility(input) + return MobilePcListingCpuResponseSchema.parse(result) + } + + async options(input: GetCpuOptionsInput = {}): Promise { + const result = await this.repository.options(input ?? {}) + + return CpuOptionsResponseSchema.parse({ + cpus: result.cpus.map((cpu) => toCpuSummaryDto(cpu)), + hasMore: result.hasMore, + }) + } + + async byId(id: string): Promise { + const cpu = await this.repository.byIdWithCounts(id) + if (!cpu) throw ResourceError.cpu.notFound() + + return toCpuDetailDto(cpu) + } + + async listByIds(input: GetCpusByIdsInput): Promise { + const cpus = await this.repository.listByIds(input.ids) + return CpusByIdsResponseSchema.parse(cpus.map((cpu) => toCpuSummaryDto(cpu))) + } + + async create(actor: Actor, input: CreateCpuInput): Promise { + assertCanManageCpu(actor) + const modelName = normalizeWhitespace(input.modelName) + const conflict = await this.repository.findModelNameConflict({ + brandId: input.brandId, + modelName, + }) + assertCpuModelNameAvailable(conflict, modelName) + + const cpu = await this.repository.create({ + brandId: input.brandId, + modelName, + }) + + return toCpuDetailDto(cpu) + } + + async update(actor: Actor, input: UpdateCpuInput): Promise { + assertCanManageCpu(actor) + const modelName = normalizeWhitespace(input.modelName) + const conflict = await this.repository.findModelNameConflict({ + brandId: input.brandId, + modelName, + excludeId: input.id, + }) + assertCpuModelNameAvailable(conflict, modelName) + + const cpu = await this.repository.update(input.id, { + brandId: input.brandId, + modelName, + }) + + return toCpuDetailDto(cpu) + } + + async delete(actor: Actor, input: DeleteCpuInput): Promise { + assertCanManageCpu(actor) + + const cpu = await this.repository.findDeleteGuardById(input.id) + if (!cpu) throw ResourceError.cpu.notFound() + assertCpuCanBeDeleted(cpu) + + await this.repository.delete(input.id) + return createMutationSuccess() + } + + async stats(actor: Actor): Promise { + assertCanViewCpuStats(actor) + return CpuStatsSchema.parse(await this.repository.stats()) + } +} + +export function createCpuService(prisma: PrismaRepositoryClient): CpuService { + return new CpuService(new CpuRepository(prisma)) +} diff --git a/src/features/hardware/cpu/server/persistence/cpu.errors.test.ts b/src/features/hardware/cpu/server/persistence/cpu.errors.test.ts new file mode 100644 index 000000000..d440b116a --- /dev/null +++ b/src/features/hardware/cpu/server/persistence/cpu.errors.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest' +import { translateCpuWriteError } from './cpu.errors' + +function prismaError(code: string): Error { + const error = new Error(`Prisma ${code}`) + Object.assign(error, { code }) + return error +} + +describe('translateCpuWriteError', () => { + it('maps create and update foreign key failures to missing CPU brand errors', () => { + expect(() => + translateCpuWriteError(prismaError('P2003'), { + action: 'create', + modelName: 'Core i7-13700K', + }), + ).toThrow('Device brand not found') + + expect(() => + translateCpuWriteError(prismaError('P2003'), { + action: 'update', + modelName: 'Core i7-13700K', + }), + ).toThrow('Device brand not found') + }) + + it('maps delete foreign key failures to an in-use CPU error without inventing a count', () => { + expect(() => translateCpuWriteError(prismaError('P2003'), { action: 'delete' })).toThrow( + 'Cannot delete CPU as it is currently in use', + ) + + expect(() => translateCpuWriteError(prismaError('P2003'), { action: 'delete' })).not.toThrow( + '1 records', + ) + }) + + it('maps update and delete missing-record failures to CPU not found', () => { + expect(() => + translateCpuWriteError(prismaError('P2025'), { + action: 'update', + modelName: 'Core i7-13700K', + }), + ).toThrow('CPU not found') + + expect(() => translateCpuWriteError(prismaError('P2025'), { action: 'delete' })).toThrow( + 'CPU not found', + ) + }) + + it('does not report impossible create missing-record failures as CPU not found', () => { + expect(() => + translateCpuWriteError(prismaError('P2025'), { + action: 'create', + modelName: 'Core i7-13700K', + }), + ).toThrow('Database error during CPU create') + }) +}) diff --git a/src/features/hardware/cpu/server/persistence/cpu.errors.ts b/src/features/hardware/cpu/server/persistence/cpu.errors.ts new file mode 100644 index 000000000..99c40933e --- /dev/null +++ b/src/features/hardware/cpu/server/persistence/cpu.errors.ts @@ -0,0 +1,27 @@ +import { AppError, ResourceError } from '@/lib/errors' +import { isPrismaError, PRISMA_ERROR_CODES } from '@/server/utils/prisma-errors' + +export type CpuWriteContext = { + action: 'create' | 'update' | 'delete' + modelName?: string +} + +export function translateCpuWriteError(error: unknown, context: CpuWriteContext): never { + if (isPrismaError(error, PRISMA_ERROR_CODES.UNIQUE_CONSTRAINT_VIOLATION)) { + throw ResourceError.cpu.alreadyExists(context.modelName ?? 'this model') + } + + if (isPrismaError(error, PRISMA_ERROR_CODES.FOREIGN_KEY_CONSTRAINT_VIOLATION)) { + if (context.action === 'delete') throw ResourceError.cpu.inUse() + throw ResourceError.deviceBrand.notFound() + } + + if ( + isPrismaError(error, PRISMA_ERROR_CODES.RECORD_NOT_FOUND) && + (context.action === 'update' || context.action === 'delete') + ) { + throw ResourceError.cpu.notFound() + } + + throw AppError.databaseError(`CPU ${context.action}`) +} diff --git a/src/features/hardware/cpu/server/persistence/cpu.prisma.ts b/src/features/hardware/cpu/server/persistence/cpu.prisma.ts new file mode 100644 index 000000000..de770f4ef --- /dev/null +++ b/src/features/hardware/cpu/server/persistence/cpu.prisma.ts @@ -0,0 +1,56 @@ +import type { Prisma } from '@orm/client' + +const cpuBrandSelect = { + id: true, + name: true, +} satisfies Prisma.DeviceBrandSelect + +export const CPU_DETAIL_SELECT = { + id: true, + modelName: true, + brand: { select: cpuBrandSelect }, + _count: { select: { pcListings: true } }, +} satisfies Prisma.CpuSelect + +export const CPU_SUMMARY_SELECT = { + id: true, + modelName: true, + brand: { select: cpuBrandSelect }, +} satisfies Prisma.CpuSelect + +export const CPU_MOBILE_LIST_SELECT = { + id: true, + brandId: true, + modelName: true, + createdAt: true, + brand: { select: cpuBrandSelect }, + _count: { select: { pcListings: true } }, +} satisfies Prisma.CpuSelect + +export const CPU_MOBILE_PC_LISTING_SELECT = { + id: true, + brandId: true, + modelName: true, + createdAt: true, + brand: { select: cpuBrandSelect }, +} satisfies Prisma.CpuSelect + +export const CPU_MODEL_CONFLICT_SELECT = { + id: true, +} satisfies Prisma.CpuSelect + +export const CPU_DELETE_GUARD_SELECT = { + id: true, + _count: { select: { pcListings: true, presets: true } }, +} satisfies Prisma.CpuSelect + +export type CpuDetailRecord = Prisma.CpuGetPayload<{ select: typeof CPU_DETAIL_SELECT }> +export type CpuSummaryRecord = Prisma.CpuGetPayload<{ select: typeof CPU_SUMMARY_SELECT }> +export type CpuMobileListRecord = Prisma.CpuGetPayload<{ select: typeof CPU_MOBILE_LIST_SELECT }> +export type CpuMobilePcListingRecord = Prisma.CpuGetPayload<{ + select: typeof CPU_MOBILE_PC_LISTING_SELECT +}> +export type CpuModelNameConflictRecord = Prisma.CpuGetPayload<{ + select: typeof CPU_MODEL_CONFLICT_SELECT +}> +export type CpuDeleteGuardRecord = Prisma.CpuGetPayload<{ select: typeof CPU_DELETE_GUARD_SELECT }> diff --git a/src/features/hardware/cpu/server/persistence/cpu.query.test.ts b/src/features/hardware/cpu/server/persistence/cpu.query.test.ts new file mode 100644 index 000000000..4527fd9f8 --- /dev/null +++ b/src/features/hardware/cpu/server/persistence/cpu.query.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it, vi } from 'vitest' +import { + buildCpuListQuery, + buildCpuModelNameConflictWhere, + buildCpuOptionsQuery, + buildCpuOrderBy, + buildCpuWhere, + buildMobileCpuListQuery, + buildMobilePcListingCpuQuery, +} from './cpu.query' +import type * as OrmClient from '@orm/client' + +vi.mock('@orm/client', async () => { + const actual = await vi.importActual('@orm/client') + return { + ...actual, + Prisma: { + ...actual.Prisma, + QueryMode: { insensitive: 'insensitive' }, + SortOrder: { asc: 'asc', desc: 'desc' }, + }, + } +}) + +const BRAND_ID = '00000000-0000-4000-a000-000000000002' +const CPU_ID = '00000000-0000-4000-a000-000000000001' + +describe('cpu.query', () => { + it('builds the shared CPU search predicate for model, brand, and combined brand-model terms', () => { + expect(buildCpuWhere(' Intel Core i7 ', BRAND_ID)).toEqual({ + brandId: BRAND_ID, + OR: [ + { modelName: { equals: 'Intel Core i7', mode: 'insensitive' } }, + { brand: { name: { equals: 'Intel Core i7', mode: 'insensitive' } } }, + { modelName: { contains: 'Intel Core i7', mode: 'insensitive' } }, + { brand: { name: { contains: 'Intel Core i7', mode: 'insensitive' } } }, + { + AND: [ + { brand: { name: { contains: 'Intel', mode: 'insensitive' } } }, + { modelName: { contains: 'Core i7', mode: 'insensitive' } }, + ], + }, + ], + }) + }) + + it('builds stable CPU ordering with explicit defaults', () => { + expect(buildCpuOrderBy()).toEqual([{ brand: { name: 'asc' } }, { modelName: 'asc' }]) + expect(buildCpuOrderBy('pcListings', 'desc')).toEqual([{ pcListings: { _count: 'desc' } }]) + }) + + it('builds paginated list query primitives', () => { + expect(buildCpuListQuery({ page: 3, limit: 25, sortField: 'modelName' })).toEqual({ + where: {}, + orderBy: [{ modelName: 'asc' }], + pagination: { + limit: 25, + offset: 50, + page: 3, + }, + }) + }) + + it('builds CPU dropdown query primitives with lookahead pagination', () => { + expect(buildCpuOptionsQuery({ search: 'Ryzen', offset: 10, limit: 5 })).toEqual({ + where: { + OR: [ + { modelName: { equals: 'Ryzen', mode: 'insensitive' } }, + { brand: { name: { equals: 'Ryzen', mode: 'insensitive' } } }, + { modelName: { contains: 'Ryzen', mode: 'insensitive' } }, + { brand: { name: { contains: 'Ryzen', mode: 'insensitive' } } }, + ], + }, + orderBy: [{ brand: { name: 'asc' } }, { modelName: 'asc' }], + limit: 5, + offset: 10, + }) + }) + + it('builds mobile CPU list query primitives with the old search behavior', () => { + expect(buildMobileCpuListQuery({ search: 'Intel Core i7', page: 2, limit: 1000 })).toEqual({ + where: { + OR: [ + { modelName: { equals: 'Intel Core i7', mode: 'insensitive' } }, + { brand: { name: { equals: 'Intel Core i7', mode: 'insensitive' } } }, + { modelName: { contains: 'Intel Core i7', mode: 'insensitive' } }, + { brand: { name: { contains: 'Intel Core i7', mode: 'insensitive' } } }, + { + AND: [ + { brand: { name: { contains: 'Intel', mode: 'insensitive' } } }, + { modelName: { contains: 'Core i7', mode: 'insensitive' } }, + ], + }, + ], + }, + orderBy: [{ brand: { name: 'asc' } }, { modelName: 'asc' }], + pagination: { + limit: 1000, + offset: 1000, + page: 2, + }, + }) + }) + + it('builds mobile PC listing CPU query primitives with the old simple search behavior', () => { + expect( + buildMobilePcListingCpuQuery({ search: 'Ryzen', brandId: BRAND_ID, limit: 100 }), + ).toEqual({ + where: { + brandId: BRAND_ID, + OR: [ + { modelName: { contains: 'Ryzen', mode: 'insensitive' } }, + { brand: { name: { contains: 'Ryzen', mode: 'insensitive' } } }, + ], + }, + orderBy: { modelName: 'asc' }, + limit: 100, + }) + }) + + it('builds case-insensitive model conflict predicates', () => { + expect( + buildCpuModelNameConflictWhere({ + brandId: BRAND_ID, + modelName: 'Core i7-13700K', + excludeId: CPU_ID, + }), + ).toEqual({ + brandId: BRAND_ID, + modelName: { equals: 'Core i7-13700K', mode: 'insensitive' }, + id: { not: CPU_ID }, + }) + }) +}) diff --git a/src/features/hardware/cpu/server/persistence/cpu.query.ts b/src/features/hardware/cpu/server/persistence/cpu.query.ts new file mode 100644 index 000000000..ec204fe7e --- /dev/null +++ b/src/features/hardware/cpu/server/persistence/cpu.query.ts @@ -0,0 +1,182 @@ +import { LOOKUP_PAGINATION } from '@/data/constants' +import { resolvePagination, type ResolvedPagination } from '@/server/utils/pagination' +import { Prisma } from '@orm/client' +import type { + GetCpuOptionsInput, + GetCpusInput, + CpuSortField, + MobileGetCpusInput, + MobilePcListingCpusInput, +} from '../../shared/cpu.types' + +type CpuOptionsFilters = NonNullable +type MobilePcListingCpuFilters = MobilePcListingCpusInput +type CpuOrderByFactory = (direction: Prisma.SortOrder) => Prisma.CpuOrderByWithRelationInput[] + +const CPU_QUERY_MODE = Prisma.QueryMode.insensitive +const CPU_DEFAULT_SORT = Prisma.SortOrder.asc +const CPU_ORDER_BY = { + brand: (direction) => [{ brand: { name: direction } }], + modelName: (direction) => [{ modelName: direction }], + pcListings: (direction) => [{ pcListings: { _count: direction } }], +} satisfies Record + +export type CpuListQuery = { + where: Prisma.CpuWhereInput + orderBy: Prisma.CpuOrderByWithRelationInput[] + pagination: ResolvedPagination +} + +export type CpuOptionsQuery = { + where: Prisma.CpuWhereInput + orderBy: Prisma.CpuOrderByWithRelationInput[] + limit: number + offset: number +} + +export type MobilePcListingCpuQuery = { + where: Prisma.CpuWhereInput + orderBy: Prisma.CpuOrderByWithRelationInput + limit: number +} + +export type CpuModelNameConflictQuery = { + brandId: string + modelName: string + excludeId?: string +} + +export function buildCpuListQuery(filters: GetCpusInput = {}): CpuListQuery { + return { + where: buildCpuWhere(filters?.search, filters?.brandId), + orderBy: buildCpuOrderBy(filters?.sortField, filters?.sortDirection), + pagination: resolvePagination(filters), + } +} + +export function buildCpuOptionsQuery(filters: CpuOptionsFilters = {}): CpuOptionsQuery { + return { + where: buildCpuWhere(filters.search, filters.brandId), + orderBy: defaultCpuOrderBy(), + limit: filters.limit ?? LOOKUP_PAGINATION.DEFAULT_LIMIT, + offset: filters.offset ?? 0, + } +} + +export function buildMobileCpuListQuery(filters: MobileGetCpusInput = {}): CpuListQuery { + return { + where: buildMobileCpuCatalogCompatibilityWhere(filters?.search, filters?.brandId), + orderBy: buildCpuOrderBy(filters?.sortField, filters?.sortDirection), + pagination: resolvePagination(filters), + } +} + +export function buildMobilePcListingCpuQuery( + filters: MobilePcListingCpuFilters, +): MobilePcListingCpuQuery { + return { + where: buildMobilePcListingCpuWhere(filters.search, filters.brandId), + orderBy: { modelName: CPU_DEFAULT_SORT }, + limit: filters.limit ?? LOOKUP_PAGINATION.DEFAULT_LIMIT, + } +} + +export function buildCpuModelNameConflictWhere( + query: CpuModelNameConflictQuery, +): Prisma.CpuWhereInput { + return { + brandId: query.brandId, + modelName: { equals: query.modelName, mode: CPU_QUERY_MODE }, + ...(query.excludeId ? { id: { not: query.excludeId } } : {}), + } +} + +export function buildCpuWhere(search?: string, brandId?: string): Prisma.CpuWhereInput { + const where: Prisma.CpuWhereInput = {} + const query = search?.trim() + + if (brandId) where.brandId = brandId + if (!query) return where + + const parts = query.split(/\s+/) + const brandCandidate = parts[0] + const modelCandidate = parts.slice(1).join(' ') + + where.OR = [ + { modelName: { equals: query, mode: CPU_QUERY_MODE } }, + { brand: { name: { equals: query, mode: CPU_QUERY_MODE } } }, + { modelName: { contains: query, mode: CPU_QUERY_MODE } }, + { brand: { name: { contains: query, mode: CPU_QUERY_MODE } } }, + ] + + if (brandCandidate && modelCandidate) { + where.OR.push({ + AND: [ + { brand: { name: { contains: brandCandidate, mode: CPU_QUERY_MODE } } }, + { modelName: { contains: modelCandidate, mode: CPU_QUERY_MODE } }, + ], + }) + } + + return where +} + +// Preserves the pre-feature mobile/public CPU catalog search semantics until that API is versioned. +function buildMobileCpuCatalogCompatibilityWhere( + search?: string, + brandId?: string, +): Prisma.CpuWhereInput { + const where: Prisma.CpuWhereInput = {} + + if (brandId) where.brandId = brandId + + if (search) { + where.OR = [ + { modelName: { equals: search, mode: CPU_QUERY_MODE } }, + { brand: { name: { equals: search, mode: CPU_QUERY_MODE } } }, + { modelName: { contains: search, mode: CPU_QUERY_MODE } }, + { brand: { name: { contains: search, mode: CPU_QUERY_MODE } } }, + ] + + if (search.includes(' ')) { + where.OR.push({ + AND: [ + { brand: { name: { contains: search.split(' ')[0], mode: CPU_QUERY_MODE } } }, + { + modelName: { contains: search.split(' ').slice(1).join(' '), mode: CPU_QUERY_MODE }, + }, + ], + }) + } + } + + return where +} + +function buildMobilePcListingCpuWhere(search?: string, brandId?: string): Prisma.CpuWhereInput { + const where: Prisma.CpuWhereInput = {} + + if (brandId) where.brandId = brandId + if (!search) return where + + where.OR = [ + { modelName: { contains: search, mode: CPU_QUERY_MODE } }, + { brand: { name: { contains: search, mode: CPU_QUERY_MODE } } }, + ] + + return where +} + +export function buildCpuOrderBy( + sortField?: CpuSortField | null, + sortDirection?: Prisma.SortOrder | null, +): Prisma.CpuOrderByWithRelationInput[] { + const direction = sortDirection ?? CPU_DEFAULT_SORT + if (!sortField) return defaultCpuOrderBy() + + return CPU_ORDER_BY[sortField](direction) +} + +function defaultCpuOrderBy(): Prisma.CpuOrderByWithRelationInput[] { + return [{ brand: { name: CPU_DEFAULT_SORT } }, { modelName: CPU_DEFAULT_SORT }] +} diff --git a/src/features/hardware/cpu/shared/cpu-format.test.ts b/src/features/hardware/cpu/shared/cpu-format.test.ts new file mode 100644 index 000000000..b49a8eb3d --- /dev/null +++ b/src/features/hardware/cpu/shared/cpu-format.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest' +import { getCpuLabel } from './cpu-format' + +const cpu = { + id: '4f5a48f9-5173-4db0-9f3b-d10a5aa7a111', + modelName: 'Ryzen 7 7800X3D', + brand: { + id: '4f5a48f9-5173-4db0-9f3b-d10a5aa7a222', + name: 'AMD', + }, +} + +describe('cpu-format', () => { + it('builds the user-facing CPU label from brand and model', () => { + expect(getCpuLabel(cpu)).toBe('AMD Ryzen 7 7800X3D') + }) +}) diff --git a/src/features/hardware/cpu/shared/cpu-format.ts b/src/features/hardware/cpu/shared/cpu-format.ts new file mode 100644 index 000000000..da72b7d7b --- /dev/null +++ b/src/features/hardware/cpu/shared/cpu-format.ts @@ -0,0 +1,5 @@ +import type { CpuLabelInput } from './cpu.types' + +export function getCpuLabel(cpu: CpuLabelInput): string { + return `${cpu.brand.name} ${cpu.modelName}` +} diff --git a/src/features/hardware/cpu/shared/cpu.schemas.ts b/src/features/hardware/cpu/shared/cpu.schemas.ts new file mode 100644 index 000000000..b0452f5e6 --- /dev/null +++ b/src/features/hardware/cpu/shared/cpu.schemas.ts @@ -0,0 +1,124 @@ +import { z } from 'zod' +import { LOOKUP_PAGINATION, PAGINATION } from '@/data/constants' +import { SortDirectionSchema } from '@/schemas/common' +import { + LookupPaginationInputSchema, + PaginationInputSchema, + PaginationResultSchema, +} from '@/schemas/pagination' + +export const CpuSortFieldSchema = z.enum(['brand', 'modelName', 'pcListings']) + +export const GetCpusSchema = z + .object({ + search: z.string().optional(), + brandId: z.string().uuid().optional(), + sortField: CpuSortFieldSchema.optional(), + sortDirection: SortDirectionSchema.optional(), + }) + .merge(PaginationInputSchema) + .optional() + +export const GetCpuOptionsSchema = z + .object({ + search: z.string().optional(), + brandId: z.string().uuid().optional(), + }) + .merge(LookupPaginationInputSchema) + .optional() + +// Mobile/public compatibility contract for the existing CPU catalog route. +export const MobileGetCpusSchema = z + .object({ + search: z.string().optional(), + brandId: z.string().uuid().optional(), + limit: z.number().default(PAGINATION.DEFAULT_LIMIT), + offset: z.number().default(0), + page: z.number().optional(), + sortField: CpuSortFieldSchema.optional(), + sortDirection: SortDirectionSchema.optional(), + }) + .optional() + +export const MobilePcListingCpusSchema = z.object({ + search: z.string().optional(), + brandId: z.string().uuid().optional(), + limit: z.number().min(1).max(PAGINATION.MAX_LIMIT).default(LOOKUP_PAGINATION.DEFAULT_LIMIT), +}) + +export const GetCpuByIdSchema = z.object({ id: z.string().uuid() }) +export const GetCpusByIdsSchema = z.object({ + ids: z.array(z.string().uuid()).min(1).max(LOOKUP_PAGINATION.MAX_LIMIT), +}) + +export const CreateCpuSchema = z.object({ + brandId: z.string().uuid(), + modelName: z.string().trim().min(1), +}) + +export const UpdateCpuSchema = z.object({ + id: z.string().uuid(), + brandId: z.string().uuid(), + modelName: z.string().trim().min(1), +}) + +export const DeleteCpuSchema = z.object({ id: z.string().uuid() }) + +export const CpuBrandSchema = z.object({ + id: z.string().uuid(), + name: z.string(), +}) + +export const CpuSummarySchema = z.object({ + id: z.string().uuid(), + modelName: z.string(), + brand: CpuBrandSchema, +}) + +export const CpuDetailSchema = CpuSummarySchema.extend({ + pcListingCount: z.number().int().min(0), +}) + +export const CpuListResponseSchema = z.object({ + cpus: z.array(CpuDetailSchema), + pagination: PaginationResultSchema, +}) + +export const CpuOptionsResponseSchema = z.object({ + cpus: z.array(CpuSummarySchema), + hasMore: z.boolean(), +}) + +export const MobileCpuListItemSchema = z.object({ + id: z.string().uuid(), + brandId: z.string().uuid(), + modelName: z.string(), + createdAt: z.date(), + brand: CpuBrandSchema, + _count: z.object({ pcListings: z.number().int().min(0) }), +}) + +export const MobileCpuListResponseSchema = z.object({ + cpus: z.array(MobileCpuListItemSchema), + pagination: PaginationResultSchema, +}) + +export const MobilePcListingCpuSchema = z.object({ + id: z.string().uuid(), + brandId: z.string().uuid(), + modelName: z.string(), + createdAt: z.date(), + brand: CpuBrandSchema, +}) + +export const MobilePcListingCpuResponseSchema = z.object({ + cpus: z.array(MobilePcListingCpuSchema), +}) + +export const CpusByIdsResponseSchema = z.array(CpuSummarySchema) + +export const CpuStatsSchema = z.object({ + total: z.number().int().min(0), + withListings: z.number().int().min(0), + withoutListings: z.number().int().min(0), +}) diff --git a/src/features/hardware/cpu/shared/cpu.types.ts b/src/features/hardware/cpu/shared/cpu.types.ts new file mode 100644 index 000000000..ea601c826 --- /dev/null +++ b/src/features/hardware/cpu/shared/cpu.types.ts @@ -0,0 +1,43 @@ +import type { + CreateCpuSchema, + DeleteCpuSchema, + GetCpuOptionsSchema, + GetCpusByIdsSchema, + GetCpusSchema, + CpuDetailSchema, + CpuListResponseSchema, + CpuOptionsResponseSchema, + CpuStatsSchema, + CpuSummarySchema, + CpuSortFieldSchema, + CpusByIdsResponseSchema, + MobileGetCpusSchema, + MobileCpuListItemSchema, + MobileCpuListResponseSchema, + MobilePcListingCpusSchema, + MobilePcListingCpuResponseSchema, + UpdateCpuSchema, +} from './cpu.schemas' +import type { z } from 'zod' + +export type CpuSortField = z.output +export type GetCpusInput = z.input +export type GetCpuOptionsInput = z.input +export type MobileGetCpusInput = z.input +export type MobilePcListingCpusInput = z.input +export type CreateCpuInput = z.output +export type UpdateCpuInput = z.output +export type DeleteCpuInput = z.output +export type GetCpusByIdsInput = z.output +export type CpuSummary = z.output +export type CpuLabelInput = Pick & { + brand: Pick +} +export type CpuDetail = z.output +export type CpuListResponse = z.output +export type CpuOptionsResponse = z.output +export type CpusByIdsResponse = z.output +export type CpuStats = z.output +export type MobileCpuListItem = z.output +export type MobileCpuListResponse = z.output +export type MobilePcListingCpuResponse = z.output diff --git a/src/features/hardware/gpu/client/admin/AdminGpusView.tsx b/src/features/hardware/gpu/client/admin/AdminGpusView.tsx new file mode 100644 index 000000000..a22e1f36d --- /dev/null +++ b/src/features/hardware/gpu/client/admin/AdminGpusView.tsx @@ -0,0 +1,210 @@ +'use client' + +import { useState } from 'react' +import { + AdminPageLayout, + AdminSearchFilters, + AdminStatsDisplay, + AdminTableContainer, +} from '@/components/admin' +import { + Autocomplete, + Button, + ColumnVisibilityControl, + LoadingSpinner, + Pagination, + useConfirmDialog, +} from '@/components/ui' +import { PAGINATION } from '@/data/constants' +import storageKeys from '@/data/storageKeys' +import { useColumnVisibility, type ColumnDefinition } from '@/hooks' +import { useAdminTable } from '@/hooks/admin' +import { api } from '@/lib/api' +import toast from '@/lib/toast' +import getErrorMessage from '@/utils/getErrorMessage' +import { hasPermission, PERMISSIONS } from '@/utils/permission-system' +import { GpuFormModal } from './GpuFormModal' +import { GpuTable } from './GpuTable' +import { GpuViewModal } from './GpuViewModal' +import type { GpuDetail, GpuSortField } from '../../shared/gpu.types' + +const GPUS_COLUMNS: ColumnDefinition[] = [ + { key: 'brand', label: 'Brand', defaultVisible: true }, + { key: 'model', label: 'Model', defaultVisible: true }, + { key: 'listings', label: 'PC Reports', defaultVisible: true }, + { key: 'actions', label: 'Actions', alwaysVisible: true }, +] + +export default function AdminGpusView() { + const table = useAdminTable({ + defaultSortField: 'brand', + defaultSortDirection: 'asc', + }) + const search = table.debouncedSearch.trim() + + const columnVisibility = useColumnVisibility(GPUS_COLUMNS, { + storageKey: storageKeys.columnVisibility.adminGpus, + }) + + const gpusQuery = api.gpus.get.useQuery({ + search: search || undefined, + sortField: table.sortField ?? undefined, + sortDirection: table.sortDirection ?? undefined, + limit: table.limit, + page: table.page, + brandId: table.additionalParams.brandId || undefined, + }) + + const gpusStatsQuery = api.gpus.stats.useQuery() + const brandsQuery = api.deviceBrands.get.useQuery({ + limit: PAGINATION.MAX_LIMIT, + category: 'gpu', + }) + const deleteGpu = api.gpus.delete.useMutation() + const confirm = useConfirmDialog() + const utils = api.useUtils() + const userQuery = api.users.me.useQuery() + const canManageDevices = hasPermission(userQuery.data?.permissions, PERMISSIONS.MANAGE_DEVICES) + + const [formModalOpen, setFormModalOpen] = useState(false) + const [viewModalOpen, setViewModalOpen] = useState(false) + const [selectedGpu, setSelectedGpu] = useState(null) + + const invalidateGpuQueries = () => { + utils.gpus.get.invalidate().catch(console.error) + utils.gpus.options.invalidate().catch(console.error) + utils.gpus.stats.invalidate().catch(console.error) + } + + const openFormModal = (gpu?: GpuDetail) => { + setSelectedGpu(gpu ?? null) + setFormModalOpen(true) + } + + const closeFormModal = () => { + setFormModalOpen(false) + setSelectedGpu(null) + } + + const openViewModal = (gpu: GpuDetail) => { + setSelectedGpu(gpu) + setViewModalOpen(true) + } + + const closeViewModal = () => { + setViewModalOpen(false) + setSelectedGpu(null) + } + + const handleFormSuccess = () => { + invalidateGpuQueries() + closeFormModal() + } + + const handleDelete = async (id: string) => { + const confirmed = await confirm({ + title: 'Delete GPU', + description: 'Are you sure you want to delete this GPU? This action cannot be undone.', + }) + + if (!confirmed) return + + try { + await deleteGpu.mutateAsync({ id }) + invalidateGpuQueries() + toast.success('GPU deleted successfully!') + } catch (err) { + toast.error(`Failed to delete GPU: ${getErrorMessage(err)}`) + } + } + + return ( + + + {canManageDevices && } + + } + > + + + + table={table} + searchPlaceholder="Search GPUs..." + onClear={() => table.setAdditionalParam('brandId', '')} + > + table.setAdditionalParam('brandId', value || '')} + items={[{ id: '', name: 'All Brands' }, ...(brandsQuery.data || [])]} + optionToValue={(brand) => brand.id} + optionToLabel={(brand) => brand.name} + className="w-full md:w-64" + placeholder="Filter by brand" + filterKeys={['name']} + /> + + + + {gpusQuery.isPending ? ( + + ) : ( + + )} + + + {gpusQuery.data && gpusQuery.data.pagination.pages > 1 && ( + + )} + + + + + + ) +} diff --git a/src/features/hardware/gpu/client/admin/GpuFormModal.test.tsx b/src/features/hardware/gpu/client/admin/GpuFormModal.test.tsx new file mode 100644 index 000000000..0503340d5 --- /dev/null +++ b/src/features/hardware/gpu/client/admin/GpuFormModal.test.tsx @@ -0,0 +1,113 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import type { GpuFormModal as GpuFormModalComponent } from './GpuFormModal' +import type { GpuDetail } from '../../shared/gpu.types' + +const apiMocks = vi.hoisted(() => ({ + createMutateAsync: vi.fn(), + deviceBrandsUseQuery: vi.fn(), + updateMutateAsync: vi.fn(), +})) + +vi.mock('@/lib/api', () => ({ + api: { + gpus: { + create: { + useMutation: () => ({ mutateAsync: apiMocks.createMutateAsync, isPending: false }), + }, + update: { + useMutation: () => ({ mutateAsync: apiMocks.updateMutateAsync, isPending: false }), + }, + }, + deviceBrands: { + get: { + useQuery: apiMocks.deviceBrandsUseQuery, + }, + }, + }, +})) + +let GpuFormModal: typeof GpuFormModalComponent + +const BRAND_ID = '00000000-0000-4000-a000-000000000002' +const GPU_ID = '00000000-0000-4000-a000-000000000001' + +const gpu = { + id: GPU_ID, + modelName: 'GeForce RTX 4090', + brand: { + id: BRAND_ID, + name: 'NVIDIA', + }, + pcListingCount: 3, +} satisfies GpuDetail + +describe('GpuFormModal', () => { + beforeAll(async () => { + ;({ GpuFormModal } = await import('./GpuFormModal')) + }) + + beforeEach(() => { + vi.clearAllMocks() + apiMocks.createMutateAsync.mockResolvedValue(gpu) + apiMocks.updateMutateAsync.mockResolvedValue(gpu) + apiMocks.deviceBrandsUseQuery.mockReturnValue({ + data: [{ id: BRAND_ID, name: 'NVIDIA' }], + }) + }) + + it('creates a GPU from the selected brand and model input', async () => { + const onSuccess = vi.fn() + render() + + fireEvent.focus(screen.getByPlaceholderText('Select a brand...')) + fireEvent.mouseDown(await screen.findByRole('option', { name: 'NVIDIA' })) + fireEvent.change(screen.getByPlaceholderText('e.g., GeForce RTX 4090'), { + target: { value: ' GeForce RTX 4090 ' }, + }) + fireEvent.click(screen.getByRole('button', { name: 'Create' })) + + await waitFor(() => { + expect(apiMocks.createMutateAsync).toHaveBeenCalledWith({ + brandId: BRAND_ID, + modelName: ' GeForce RTX 4090 ', + }) + }) + expect(onSuccess).toHaveBeenCalled() + }) + + it('updates an existing GPU while preserving the selected brand id', async () => { + const onSuccess = vi.fn() + render() + + fireEvent.change(screen.getByPlaceholderText('e.g., GeForce RTX 4090'), { + target: { value: 'GeForce RTX 4080' }, + }) + fireEvent.click(screen.getByRole('button', { name: 'Save' })) + + await waitFor(() => { + expect(apiMocks.updateMutateAsync).toHaveBeenCalledWith({ + id: GPU_ID, + brandId: BRAND_ID, + modelName: 'GeForce RTX 4080', + }) + }) + expect(onSuccess).toHaveBeenCalled() + }) + + it('shows mutation errors without reporting success', async () => { + const onSuccess = vi.fn() + apiMocks.createMutateAsync.mockRejectedValueOnce(new Error('Duplicate GPU')) + render() + + fireEvent.focus(screen.getByPlaceholderText('Select a brand...')) + fireEvent.mouseDown(await screen.findByRole('option', { name: 'NVIDIA' })) + fireEvent.change(screen.getByPlaceholderText('e.g., GeForce RTX 4090'), { + target: { value: 'GeForce RTX 4090' }, + }) + fireEvent.click(screen.getByRole('button', { name: 'Create' })) + + expect(await screen.findByText('Duplicate GPU')).toBeInTheDocument() + expect(onSuccess).not.toHaveBeenCalled() + }) +}) diff --git a/src/features/hardware/gpu/client/admin/GpuFormModal.tsx b/src/features/hardware/gpu/client/admin/GpuFormModal.tsx new file mode 100644 index 000000000..5c2748047 --- /dev/null +++ b/src/features/hardware/gpu/client/admin/GpuFormModal.tsx @@ -0,0 +1,135 @@ +'use client' + +import { useState, type SubmitEvent } from 'react' +import { Autocomplete, Button, Input, Modal } from '@/components/ui' +import { PAGINATION } from '@/data/constants' +import { api } from '@/lib/api' +import getErrorMessage from '@/utils/getErrorMessage' +import type { CreateGpuInput, GpuDetail, UpdateGpuInput } from '../../shared/gpu.types' + +interface Props { + isOpen: boolean + onClose: () => void + gpuData: GpuDetail | null + onSuccess: () => void +} + +export function GpuFormModal(props: Props) { + const formKey = props.gpuData?.id ?? 'new' + + return ( + + + + ) +} + +interface GpuFormProps { + onClose: () => void + gpuData: GpuDetail | null + onSuccess: () => void +} + +function GpuForm(props: GpuFormProps) { + const createGpu = api.gpus.create.useMutation() + const updateGpu = api.gpus.update.useMutation() + const deviceBrandsQuery = api.deviceBrands.get.useQuery({ + limit: PAGINATION.MAX_LIMIT, + category: 'gpu', + }) + + const [brandId, setBrandId] = useState(props.gpuData?.brand.id ?? '') + const [modelName, setModelName] = useState(props.gpuData?.modelName ?? '') + const [error, setError] = useState('') + + const handleSubmit = async (ev: SubmitEvent) => { + ev.preventDefault() + setError('') + + try { + const gpuData = { + brandId, + modelName, + } satisfies CreateGpuInput + + if (props.gpuData) { + await updateGpu.mutateAsync({ + id: props.gpuData.id, + ...gpuData, + } satisfies UpdateGpuInput) + } else { + await createGpu.mutateAsync(gpuData) + } + + props.onSuccess() + } catch (err) { + setError(getErrorMessage(err, 'Failed to save GPU.')) + } + } + + return ( +
    +
    + + setBrandId(value ?? '')} + items={deviceBrandsQuery.data ?? []} + optionToValue={(brand) => brand.id} + optionToLabel={(brand) => brand.name} + placeholder="Select a brand..." + className="w-full" + filterKeys={['name']} + /> +
    + +
    + + setModelName(ev.target.value)} + required + className="w-full" + placeholder="e.g., GeForce RTX 4090" + /> +
    + + {error && ( +
    {error}
    + )} + +
    + + +
    +
    + ) +} diff --git a/src/features/hardware/gpu/client/admin/GpuTable.test.tsx b/src/features/hardware/gpu/client/admin/GpuTable.test.tsx new file mode 100644 index 000000000..b3c328c78 --- /dev/null +++ b/src/features/hardware/gpu/client/admin/GpuTable.test.tsx @@ -0,0 +1,74 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { GpuTable } from './GpuTable' +import type { GpuDetail } from '../../shared/gpu.types' + +const gpu = { + id: '00000000-0000-4000-a000-000000000001', + modelName: 'GeForce RTX 4090', + brand: { + id: '00000000-0000-4000-a000-000000000002', + name: 'NVIDIA', + }, + pcListingCount: 3, +} satisfies GpuDetail + +const visibleColumns = { + isColumnVisible: () => true, +} + +function renderTable(overrides: Partial[0]> = {}) { + return render( + , + ) +} + +describe('GpuTable', () => { + it('renders stable GPU columns with PC Compatibility Report wording', () => { + renderTable() + + expect(screen.getByText('NVIDIA')).toBeInTheDocument() + expect(screen.getByText('GeForce RTX 4090')).toBeInTheDocument() + expect(screen.getByText('PC Reports')).toBeInTheDocument() + expect(screen.getByText('3')).toBeInTheDocument() + }) + + it('hides mutation actions when the actor cannot manage devices', () => { + renderTable({ canManageDevices: false }) + + expect(screen.getByRole('button', { name: 'View GPU Details' })).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Edit GPU' })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Delete GPU' })).not.toBeInTheDocument() + }) + + it('wires view, edit, delete, and sort interactions', () => { + const onDelete = vi.fn() + const onEdit = vi.fn() + const onSort = vi.fn() + const onView = vi.fn() + renderTable({ onDelete, onEdit, onSort, onView }) + + fireEvent.click(screen.getByRole('button', { name: 'View GPU Details' })) + fireEvent.click(screen.getByRole('button', { name: 'Edit GPU' })) + fireEvent.click(screen.getByRole('button', { name: 'Delete GPU' })) + fireEvent.click(screen.getByText('Brand')) + + expect(onView).toHaveBeenCalledWith(gpu) + expect(onEdit).toHaveBeenCalledWith(gpu) + expect(onDelete).toHaveBeenCalledWith(gpu.id) + expect(onSort).toHaveBeenCalledWith('brand') + }) +}) diff --git a/src/features/hardware/gpu/client/admin/GpuTable.tsx b/src/features/hardware/gpu/client/admin/GpuTable.tsx new file mode 100644 index 000000000..c4ec45eb0 --- /dev/null +++ b/src/features/hardware/gpu/client/admin/GpuTable.tsx @@ -0,0 +1,107 @@ +'use client' + +import { Gpu } from 'lucide-react' +import { AdminTableNoResults } from '@/components/admin' +import { Badge, DeleteButton, EditButton, SortableHeader, ViewButton } from '@/components/ui' +import type { GpuDetail } from '../../shared/gpu.types' + +interface Props { + gpus: GpuDetail[] + hasQuery: boolean + canManageDevices: boolean + isDeleting: boolean + columnVisibility: { + isColumnVisible: (key: string) => boolean + } + sortField: string | null + sortDirection: 'asc' | 'desc' | null + onSort: (field: string) => void + onView: (gpu: GpuDetail) => void + onEdit: (gpu: GpuDetail) => void + onDelete: (id: string) => void +} + +export function GpuTable(props: Props) { + if (props.gpus.length === 0) { + return + } + + return ( + + + + {props.columnVisibility.isColumnVisible('brand') && ( + + )} + {props.columnVisibility.isColumnVisible('model') && ( + + )} + {props.columnVisibility.isColumnVisible('listings') && ( + + )} + {props.columnVisibility.isColumnVisible('actions') && ( + + )} + + + + {props.gpus.map((gpu) => ( + + {props.columnVisibility.isColumnVisible('brand') && ( + + )} + {props.columnVisibility.isColumnVisible('model') && ( + + )} + {props.columnVisibility.isColumnVisible('listings') && ( + + )} + {props.columnVisibility.isColumnVisible('actions') && ( + + )} + + ))} + +
    + Actions +
    + {gpu.brand.name} + + {gpu.modelName} + + {gpu.pcListingCount} + +
    + props.onView(gpu)} title="View GPU Details" /> + {props.canManageDevices && ( + props.onEdit(gpu)} title="Edit GPU" /> + )} + {props.canManageDevices && ( + props.onDelete(gpu.id)} + title="Delete GPU" + isLoading={props.isDeleting} + /> + )} +
    +
    + ) +} diff --git a/src/app/admin/gpus/components/GpuViewModal.tsx b/src/features/hardware/gpu/client/admin/GpuViewModal.tsx similarity index 55% rename from src/app/admin/gpus/components/GpuViewModal.tsx rename to src/features/hardware/gpu/client/admin/GpuViewModal.tsx index 941e088b2..47a515e75 100644 --- a/src/app/admin/gpus/components/GpuViewModal.tsx +++ b/src/features/hardware/gpu/client/admin/GpuViewModal.tsx @@ -1,17 +1,15 @@ 'use client' -import { Button, Modal, InputPlaceholder } from '@/components/ui' -import { type RouterOutput } from '@/types/trpc' - -type GpuData = RouterOutput['gpus']['get']['gpus'][number] +import { Button, InputPlaceholder, Modal } from '@/components/ui' +import type { GpuDetail } from '../../shared/gpu.types' interface Props { isOpen: boolean onClose: () => void - gpuData: GpuData | null + gpuData: GpuDetail | null } -function GpuViewModal(props: Props) { +export function GpuViewModal(props: Props) { if (!props.gpuData) return null return ( @@ -21,17 +19,14 @@ function GpuViewModal(props: Props) { - - {props.gpuData._count && ( - - )} +
    -
    @@ -39,5 +34,3 @@ function GpuViewModal(props: Props) { ) } - -export default GpuViewModal diff --git a/src/app/pc-listings/components/filters/AsyncGpuFilterSelect.test.tsx b/src/features/hardware/gpu/client/components/AsyncGpuFilterSelect.test.tsx similarity index 81% rename from src/app/pc-listings/components/filters/AsyncGpuFilterSelect.test.tsx rename to src/features/hardware/gpu/client/components/AsyncGpuFilterSelect.test.tsx index 97bb9e9a5..09bf52ed3 100644 --- a/src/app/pc-listings/components/filters/AsyncGpuFilterSelect.test.tsx +++ b/src/features/hardware/gpu/client/components/AsyncGpuFilterSelect.test.tsx @@ -1,5 +1,5 @@ -import { render, screen, fireEvent } from '@testing-library/react' -import { describe, it, expect, vi, beforeAll, beforeEach } from 'vitest' +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' import type AsyncGpuFilterSelectComponent from './AsyncGpuFilterSelect' const apiMocks = vi.hoisted(() => ({ @@ -49,7 +49,9 @@ function setupApiMocks() { return descriptors.map(() => ({ data: { - gpus: [{ id: 'gpu-1', modelName: 'RTX 4070', brand: { id: 'nvidia', name: 'NVIDIA' } }], + gpus: [ + { id: 'gpu-1', modelName: 'GeForce RTX 4070', brand: { id: 'nvidia', name: 'NVIDIA' } }, + ], hasMore: false, }, isFetching: false, @@ -75,11 +77,11 @@ describe('AsyncGpuFilterSelect', () => { setupApiMocks() }) - it('maps GPU option and selected labels', () => { + it('maps GPU summaries to dropdown and selected labels', () => { render() expect(screen.getByText('AMD Radeon RX 7800 XT')).toBeInTheDocument() fireEvent.click(screen.getByRole('button', { name: 'GPUs multi-select' })) - expect(screen.getByText('NVIDIA RTX 4070')).toBeInTheDocument() + expect(screen.getByText('NVIDIA GeForce RTX 4070')).toBeInTheDocument() }) }) diff --git a/src/app/pc-listings/components/filters/AsyncGpuFilterSelect.tsx b/src/features/hardware/gpu/client/components/AsyncGpuFilterSelect.tsx similarity index 61% rename from src/app/pc-listings/components/filters/AsyncGpuFilterSelect.tsx rename to src/features/hardware/gpu/client/components/AsyncGpuFilterSelect.tsx index 65bd67709..ec2c434af 100644 --- a/src/app/pc-listings/components/filters/AsyncGpuFilterSelect.tsx +++ b/src/features/hardware/gpu/client/components/AsyncGpuFilterSelect.tsx @@ -2,8 +2,9 @@ import { type ReactNode, useCallback, useMemo, useState } from 'react' import AsyncMultiSelect from '@/components/ui/form/async-multi-select/AsyncMultiSelect' -import { CACHE_DURATIONS } from '@/data/constants' +import { LOOKUP_PAGINATION } from '@/data/constants' import { api } from '@/lib/api' +import { toGpuSelectOption } from '../utils/gpuSelectOption' interface Props { label: string @@ -15,49 +16,35 @@ interface Props { maxDisplayed?: number } -const PAGE_SIZE = 50 -const LOOKUP_DATA_QUERY_OPTIONS = { - staleTime: CACHE_DURATIONS.LOOKUP, - gcTime: CACHE_DURATIONS.LOOKUP_GC, -} - export default function AsyncGpuFilterSelect(props: Props) { const [query, setQuery] = useState('') const [pageOffsets, setPageOffsets] = useState([0]) const byIdsQuery = api.gpus.getByIds.useQuery( { ids: props.value }, - { ...LOOKUP_DATA_QUERY_OPTIONS, enabled: props.value.length > 0 }, + { enabled: props.value.length > 0 }, ) const pageQueries = api.useQueries((t) => pageOffsets.map((offset) => - t.gpus.options( - { search: query || undefined, limit: PAGE_SIZE, offset }, - LOOKUP_DATA_QUERY_OPTIONS, - ), + t.gpus.options({ + search: query || undefined, + limit: LOOKUP_PAGINATION.DEFAULT_LIMIT, + offset, + }), ), ) const options = useMemo( () => pageQueries.flatMap((pageQuery) => - (pageQuery.data?.gpus ?? []).map((g) => ({ - id: g.id, - name: `${g.brand.name} ${g.modelName}`, - badgeName: g.modelName, - })), + (pageQuery.data?.gpus ?? []).map((gpu) => toGpuSelectOption(gpu)), ), [pageQueries], ) const selectedByIds = useMemo( - () => - (byIdsQuery.data ?? []).map((g) => ({ - id: g.id, - name: `${g.brand.name} ${g.modelName}`, - badgeName: g.modelName, - })), + () => (byIdsQuery.data ?? []).map((gpu) => toGpuSelectOption(gpu)), [byIdsQuery.data], ) @@ -66,11 +53,14 @@ export default function AsyncGpuFilterSelect(props: Props) { const isFetching = pageQueries.some((pageQuery) => pageQuery.isFetching) const handleLoadMore = useCallback(() => { - setPageOffsets((offsets) => [...offsets, offsets[offsets.length - 1] + PAGE_SIZE]) + setPageOffsets((offsets) => [ + ...offsets, + offsets[offsets.length - 1] + LOOKUP_PAGINATION.DEFAULT_LIMIT, + ]) }, []) - const handleQueryChange = useCallback((q: string) => { - setQuery(q) + const handleQueryChange = useCallback((nextQuery: string) => { + setQuery(nextQuery) setPageOffsets([0]) }, []) diff --git a/src/features/hardware/gpu/client/utils/gpuSelectOption.ts b/src/features/hardware/gpu/client/utils/gpuSelectOption.ts new file mode 100644 index 000000000..03cd06275 --- /dev/null +++ b/src/features/hardware/gpu/client/utils/gpuSelectOption.ts @@ -0,0 +1,11 @@ +import { getGpuLabel } from '../../shared/gpu-format' +import type { GpuSummary } from '../../shared/gpu.types' +import type { Option } from '@/components/ui/form/async-multi-select/AsyncMultiSelect' + +export function toGpuSelectOption(gpu: GpuSummary): Option { + return { + id: gpu.id, + name: getGpuLabel(gpu), + badgeName: gpu.modelName, + } +} diff --git a/src/features/hardware/gpu/server/gpu.mapper.ts b/src/features/hardware/gpu/server/gpu.mapper.ts new file mode 100644 index 000000000..0c1e82942 --- /dev/null +++ b/src/features/hardware/gpu/server/gpu.mapper.ts @@ -0,0 +1,26 @@ +import { GpuDetailSchema, GpuSummarySchema } from '../shared/gpu.schemas' +import type { GpuDetailRecord, GpuSummaryRecord } from './gpu.repository.types' +import type { GpuDetail, GpuSummary } from '../shared/gpu.types' + +export function toGpuSummaryDto(gpu: GpuSummaryRecord): GpuSummary { + return GpuSummarySchema.parse({ + id: gpu.id, + modelName: gpu.modelName, + brand: { + id: gpu.brand.id, + name: gpu.brand.name, + }, + }) +} + +export function toGpuDetailDto(gpu: GpuDetailRecord): GpuDetail { + return GpuDetailSchema.parse({ + id: gpu.id, + modelName: gpu.modelName, + brand: { + id: gpu.brand.id, + name: gpu.brand.name, + }, + pcListingCount: gpu._count.pcListings, + }) +} diff --git a/src/features/hardware/gpu/server/gpu.policy.test.ts b/src/features/hardware/gpu/server/gpu.policy.test.ts new file mode 100644 index 000000000..373a34a35 --- /dev/null +++ b/src/features/hardware/gpu/server/gpu.policy.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest' +import { PERMISSIONS } from '@/utils/permission-system' +import { Role } from '@orm/client' +import { assertCanManageGpu, assertCanViewGpuStats } from './gpu.policy' +import type { UserActor } from '@/server/auth/actor' + +const baseActor = { + type: 'user', + userId: 'user-id', + role: Role.ADMIN, + showNsfw: false, +} satisfies Omit + +describe('gpu.policy', () => { + it('allows GPU management with the manage devices permission', () => { + expect(() => + assertCanManageGpu({ + ...baseActor, + permissions: [PERMISSIONS.MANAGE_DEVICES], + }), + ).not.toThrow() + }) + + it('rejects GPU management without the manage devices permission', () => { + expect(() => + assertCanManageGpu({ + ...baseActor, + permissions: [], + }), + ).toThrow('You need the following permissions: manage_devices') + }) + + it('allows GPU stats with the view statistics permission', () => { + expect(() => + assertCanViewGpuStats({ + ...baseActor, + permissions: [PERMISSIONS.VIEW_STATISTICS], + }), + ).not.toThrow() + }) +}) diff --git a/src/features/hardware/gpu/server/gpu.policy.ts b/src/features/hardware/gpu/server/gpu.policy.ts new file mode 100644 index 000000000..8783d8e53 --- /dev/null +++ b/src/features/hardware/gpu/server/gpu.policy.ts @@ -0,0 +1,10 @@ +import { requireActorPermission, type Actor } from '@/server/auth/actor' +import { PERMISSIONS } from '@/utils/permission-system' + +export function assertCanManageGpu(actor: Actor): void { + requireActorPermission(actor, PERMISSIONS.MANAGE_DEVICES) +} + +export function assertCanViewGpuStats(actor: Actor): void { + requireActorPermission(actor, PERMISSIONS.VIEW_STATISTICS) +} diff --git a/src/features/hardware/gpu/server/gpu.repository.test.ts b/src/features/hardware/gpu/server/gpu.repository.test.ts new file mode 100644 index 000000000..4e250b992 --- /dev/null +++ b/src/features/hardware/gpu/server/gpu.repository.test.ts @@ -0,0 +1,335 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { PAGINATION } from '@/data/constants' +import { prisma } from '@/server/db' +import { GpuRepository } from './gpu.repository' +import { + GPU_DELETE_GUARD_SELECT, + GPU_DETAIL_SELECT, + GPU_MOBILE_LIST_SELECT, + GPU_MOBILE_PC_LISTING_SELECT, + GPU_MODEL_CONFLICT_SELECT, + GPU_SUMMARY_SELECT, +} from './persistence/gpu.prisma' +import type * as OrmClient from '@orm/client' + +const GPU_ID = '00000000-0000-4000-a000-000000000001' +const BRAND_ID = '00000000-0000-4000-a000-000000000002' +const CREATED_AT = new Date('2024-01-01T00:00:00.000Z') + +const mockPrisma = vi.hoisted(() => ({ + gpu: { + count: vi.fn(), + create: vi.fn(), + delete: vi.fn(), + findFirst: vi.fn(), + findMany: vi.fn(), + findUnique: vi.fn(), + update: vi.fn(), + }, +})) + +vi.mock('@/server/db', () => ({ prisma: mockPrisma })) + +vi.mock('@orm/client', async () => { + const actual = await vi.importActual('@orm/client') + return { + ...actual, + Prisma: { + ...actual.Prisma, + QueryMode: { insensitive: 'insensitive' }, + SortOrder: { asc: 'asc', desc: 'desc' }, + }, + } +}) + +describe('GPU repository persistence adapter', () => { + let repository: GpuRepository + + beforeEach(() => { + mockPrisma.gpu.count.mockReset() + mockPrisma.gpu.create.mockReset() + mockPrisma.gpu.delete.mockReset() + mockPrisma.gpu.findFirst.mockReset() + mockPrisma.gpu.findMany.mockReset() + mockPrisma.gpu.findUnique.mockReset() + mockPrisma.gpu.update.mockReset() + repository = new GpuRepository(prisma) + }) + + it('creates a GPU with the explicit detail select contract', async () => { + mockPrisma.gpu.create.mockResolvedValueOnce({ + id: GPU_ID, + modelName: 'GeForce RTX 4090', + brand: { id: BRAND_ID, name: 'NVIDIA' }, + _count: { pcListings: 0 }, + }) + + await repository.create({ brandId: BRAND_ID, modelName: 'GeForce RTX 4090' }) + + expect(mockPrisma.gpu.create).toHaveBeenCalledWith({ + data: { brandId: BRAND_ID, modelName: 'GeForce RTX 4090' }, + select: GPU_DETAIL_SELECT, + }) + }) + + it('translates database unique constraint errors for writes', async () => { + const error = new Error('Unique constraint failed') + Object.assign(error, { code: 'P2002' }) + mockPrisma.gpu.create.mockRejectedValueOnce(error) + + await expect( + repository.create({ brandId: BRAND_ID, modelName: 'GeForce RTX 4090' }), + ).rejects.toThrow('A GPU with model name "GeForce RTX 4090" already exists for this brand') + }) + + it('updates a GPU with the explicit detail select contract', async () => { + mockPrisma.gpu.update.mockResolvedValueOnce({ + id: GPU_ID, + modelName: 'GeForce RTX 4090', + brand: { id: BRAND_ID, name: 'NVIDIA' }, + _count: { pcListings: 0 }, + }) + + await repository.update(GPU_ID, { brandId: BRAND_ID, modelName: 'GeForce RTX 4090' }) + + expect(mockPrisma.gpu.update).toHaveBeenCalledWith({ + where: { id: GPU_ID }, + data: { brandId: BRAND_ID, modelName: 'GeForce RTX 4090' }, + select: GPU_DETAIL_SELECT, + }) + }) + + it('finds case-insensitive model conflicts for the selected brand', async () => { + mockPrisma.gpu.findFirst.mockResolvedValueOnce({ id: GPU_ID }) + + await expect( + repository.findModelNameConflict({ + brandId: BRAND_ID, + modelName: 'GeForce RTX 4090', + excludeId: GPU_ID, + }), + ).resolves.toEqual({ id: GPU_ID }) + + expect(mockPrisma.gpu.findFirst).toHaveBeenCalledWith({ + where: { + brandId: BRAND_ID, + modelName: { equals: 'GeForce RTX 4090', mode: 'insensitive' }, + id: { not: GPU_ID }, + }, + select: GPU_MODEL_CONFLICT_SELECT, + }) + }) + + it('lists GPUs with the explicit detail select contract', async () => { + mockPrisma.gpu.findMany.mockResolvedValueOnce([ + { + id: GPU_ID, + modelName: 'GeForce RTX 4090', + brand: { id: BRAND_ID, name: 'NVIDIA' }, + _count: { pcListings: 0 }, + }, + ]) + mockPrisma.gpu.count.mockResolvedValueOnce(1) + + await expect(repository.list({ page: 1, limit: PAGINATION.DEFAULT_LIMIT })).resolves.toEqual({ + gpus: [ + { + id: GPU_ID, + modelName: 'GeForce RTX 4090', + brand: { id: BRAND_ID, name: 'NVIDIA' }, + _count: { pcListings: 0 }, + }, + ], + pagination: { + total: 1, + pages: 1, + page: 1, + offset: 0, + limit: PAGINATION.DEFAULT_LIMIT, + hasNextPage: false, + hasPreviousPage: false, + }, + }) + expect(mockPrisma.gpu.findMany).toHaveBeenCalledWith( + expect.objectContaining({ select: GPU_DETAIL_SELECT }), + ) + }) + + it('lists GPU summaries by id with the explicit summary select contract', async () => { + mockPrisma.gpu.findMany.mockResolvedValueOnce([ + { + id: GPU_ID, + modelName: 'GeForce RTX 4090', + brand: { id: BRAND_ID, name: 'NVIDIA' }, + }, + ]) + + await expect(repository.listByIds([GPU_ID])).resolves.toEqual([ + { + id: GPU_ID, + modelName: 'GeForce RTX 4090', + brand: { id: BRAND_ID, name: 'NVIDIA' }, + }, + ]) + + expect(mockPrisma.gpu.findMany).toHaveBeenCalledWith({ + where: { id: { in: [GPU_ID] } }, + select: GPU_SUMMARY_SELECT, + }) + }) + + it('lists mobile compatibility GPUs with the old scalar fields and counts', async () => { + mockPrisma.gpu.findMany.mockResolvedValueOnce([ + { + id: GPU_ID, + brandId: BRAND_ID, + modelName: 'GeForce RTX 4090', + createdAt: CREATED_AT, + brand: { id: BRAND_ID, name: 'NVIDIA' }, + _count: { pcListings: 0 }, + }, + ]) + mockPrisma.gpu.count.mockResolvedValueOnce(1) + + await expect(repository.listMobileCompatibility({ page: 1, limit: 1000 })).resolves.toEqual({ + gpus: [ + { + id: GPU_ID, + brandId: BRAND_ID, + modelName: 'GeForce RTX 4090', + createdAt: CREATED_AT, + brand: { id: BRAND_ID, name: 'NVIDIA' }, + _count: { pcListings: 0 }, + }, + ], + pagination: { + total: 1, + pages: 1, + page: 1, + offset: 0, + limit: 1000, + hasNextPage: false, + hasPreviousPage: false, + }, + }) + expect(mockPrisma.gpu.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + select: GPU_MOBILE_LIST_SELECT, + take: 1000, + }), + ) + }) + + it('reads mobile PC listing GPUs with the old route query contract', async () => { + mockPrisma.gpu.findMany.mockResolvedValueOnce([ + { + id: GPU_ID, + brandId: BRAND_ID, + modelName: 'GeForce RTX 4090', + createdAt: CREATED_AT, + brand: { id: BRAND_ID, name: 'NVIDIA' }, + }, + ]) + + await expect( + repository.pcListingMobileGpuCompatibility({ search: 'RTX', limit: 100 }), + ).resolves.toEqual({ + gpus: [ + { + id: GPU_ID, + brandId: BRAND_ID, + modelName: 'GeForce RTX 4090', + createdAt: CREATED_AT, + brand: { id: BRAND_ID, name: 'NVIDIA' }, + }, + ], + }) + + expect(mockPrisma.gpu.findMany).toHaveBeenCalledWith({ + where: { + OR: [ + { modelName: { contains: 'RTX', mode: 'insensitive' } }, + { brand: { name: { contains: 'RTX', mode: 'insensitive' } } }, + ], + }, + select: GPU_MOBILE_PC_LISTING_SELECT, + orderBy: { modelName: 'asc' }, + take: 100, + }) + }) + + it('reads GPU dropdown pages with summary select and lookahead pagination', async () => { + mockPrisma.gpu.findMany.mockResolvedValueOnce([ + { + id: GPU_ID, + modelName: 'GeForce RTX 4090', + brand: { id: BRAND_ID, name: 'NVIDIA' }, + }, + { + id: '00000000-0000-4000-a000-000000000003', + modelName: 'GeForce RTX 4080', + brand: { id: BRAND_ID, name: 'NVIDIA' }, + }, + ]) + + await expect(repository.options({ search: 'NVIDIA', limit: 1, offset: 5 })).resolves.toEqual({ + gpus: [ + { + id: GPU_ID, + modelName: 'GeForce RTX 4090', + brand: { id: BRAND_ID, name: 'NVIDIA' }, + }, + ], + hasMore: true, + }) + + expect(mockPrisma.gpu.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + select: GPU_SUMMARY_SELECT, + skip: 5, + take: 2, + }), + ) + }) + + it('reads the delete guard with the explicit delete guard select contract', async () => { + mockPrisma.gpu.findUnique.mockResolvedValueOnce({ + id: GPU_ID, + _count: { pcListings: 0, presets: 0 }, + }) + + await expect(repository.findDeleteGuardById(GPU_ID)).resolves.toEqual({ + id: GPU_ID, + _count: { pcListings: 0, presets: 0 }, + }) + + expect(mockPrisma.gpu.findUnique).toHaveBeenCalledWith({ + where: { id: GPU_ID }, + select: GPU_DELETE_GUARD_SELECT, + }) + }) + + it('deletes a GPU by id with a minimal select contract', async () => { + mockPrisma.gpu.delete.mockResolvedValueOnce({ id: GPU_ID }) + + await repository.delete(GPU_ID) + + expect(mockPrisma.gpu.delete).toHaveBeenCalledWith({ + where: { id: GPU_ID }, + select: { id: true }, + }) + }) + + it('returns GPU usage stats from PC report counts', async () => { + mockPrisma.gpu.count.mockResolvedValueOnce(3).mockResolvedValueOnce(2) + + await expect(repository.stats()).resolves.toEqual({ + total: 5, + withListings: 3, + withoutListings: 2, + }) + + expect(mockPrisma.gpu.count).toHaveBeenCalledWith({ where: { pcListings: { some: {} } } }) + expect(mockPrisma.gpu.count).toHaveBeenCalledWith({ where: { pcListings: { none: {} } } }) + }) +}) diff --git a/src/features/hardware/gpu/server/gpu.repository.ts b/src/features/hardware/gpu/server/gpu.repository.ts new file mode 100644 index 000000000..c0f951bf3 --- /dev/null +++ b/src/features/hardware/gpu/server/gpu.repository.ts @@ -0,0 +1,189 @@ +import { PrismaWriteRepository } from '@/server/persistence/prisma.repository' +import { paginationResult } from '@/server/utils/pagination' +import { type GpuWriteContext, translateGpuWriteError } from './persistence/gpu.errors' +import { + GPU_DELETE_GUARD_SELECT, + GPU_DETAIL_SELECT, + GPU_MOBILE_LIST_SELECT, + GPU_MOBILE_PC_LISTING_SELECT, + GPU_MODEL_CONFLICT_SELECT, + GPU_SUMMARY_SELECT, +} from './persistence/gpu.prisma' +import { + buildGpuListQuery, + buildGpuModelNameConflictWhere, + buildGpuOptionsQuery, + buildMobileGpuListQuery, + buildMobilePcListingGpuQuery, +} from './persistence/gpu.query' +import type { + GpuDetailRecord, + GpuDeleteGuardRecord, + GpuListResult, + GpuMobileListResult, + GpuMobilePcListingResult, + GpuModelNameConflictInput, + GpuModelNameConflictRecord, + GpuOptionsFilters, + GpuOptionsResult, + GpuSummaryRecord, + UpdateGpuData, +} from './gpu.repository.types' +import type { + CreateGpuInput, + GetGpusInput, + MobileGetGpusInput, + MobilePcListingGpusInput, +} from '../shared/gpu.types' + +export class GpuRepository extends PrismaWriteRepository { + protected translateWriteError(error: unknown, context: GpuWriteContext): never { + return translateGpuWriteError(error, context) + } + + async byIdWithCounts(id: string): Promise { + return this.prisma.gpu.findUnique({ + where: { id }, + select: GPU_DETAIL_SELECT, + }) + } + + async findDeleteGuardById(id: string): Promise { + return this.prisma.gpu.findUnique({ + where: { id }, + select: GPU_DELETE_GUARD_SELECT, + }) + } + + async listByIds(ids: string[]): Promise { + if (ids.length === 0) return [] + + return this.prisma.gpu.findMany({ + where: { id: { in: ids } }, + select: GPU_SUMMARY_SELECT, + }) + } + + async list(filters: GetGpusInput = {}): Promise { + const query = buildGpuListQuery(filters) + + const [gpus, total] = await Promise.all([ + this.prisma.gpu.findMany({ + where: query.where, + select: GPU_DETAIL_SELECT, + orderBy: query.orderBy, + take: query.pagination.limit, + skip: query.pagination.offset, + }), + this.prisma.gpu.count({ where: query.where }), + ]) + + return { + gpus, + pagination: paginationResult(total, query.pagination), + } + } + + async listMobileCompatibility(filters: MobileGetGpusInput = {}): Promise { + const query = buildMobileGpuListQuery(filters) + + const [gpus, total] = await Promise.all([ + this.prisma.gpu.findMany({ + where: query.where, + select: GPU_MOBILE_LIST_SELECT, + orderBy: query.orderBy, + take: query.pagination.limit, + skip: query.pagination.offset, + }), + this.prisma.gpu.count({ where: query.where }), + ]) + + return { + gpus, + pagination: paginationResult(total, query.pagination), + } + } + + async byIdMobileCompatibility(id: string): Promise { + return this.prisma.gpu.findUnique({ + where: { id }, + select: GPU_MOBILE_LIST_SELECT, + }) + } + + async pcListingMobileGpuCompatibility( + filters: MobilePcListingGpusInput, + ): Promise { + const query = buildMobilePcListingGpuQuery(filters) + const gpus = await this.prisma.gpu.findMany({ + where: query.where, + select: GPU_MOBILE_PC_LISTING_SELECT, + orderBy: query.orderBy, + take: query.limit, + }) + + return { gpus } + } + + async options(filters: GpuOptionsFilters = {}): Promise { + const query = buildGpuOptionsQuery(filters) + const gpus = await this.prisma.gpu.findMany({ + where: query.where, + select: GPU_SUMMARY_SELECT, + orderBy: query.orderBy, + take: query.limit + 1, + skip: query.offset, + }) + + return { + gpus: gpus.slice(0, query.limit), + hasMore: gpus.length > query.limit, + } + } + + async findModelNameConflict( + input: GpuModelNameConflictInput, + ): Promise { + return this.prisma.gpu.findFirst({ + where: buildGpuModelNameConflictWhere(input), + select: GPU_MODEL_CONFLICT_SELECT, + }) + } + + async create(data: CreateGpuInput): Promise { + return this.executeWrite(() => this.prisma.gpu.create({ data, select: GPU_DETAIL_SELECT }), { + action: 'create', + modelName: data.modelName, + }) + } + + async update(id: string, data: UpdateGpuData): Promise { + return this.executeWrite( + () => this.prisma.gpu.update({ where: { id }, data, select: GPU_DETAIL_SELECT }), + { action: 'update', modelName: data.modelName }, + ) + } + + async delete(id: string): Promise { + await this.executeWrite(() => this.prisma.gpu.delete({ where: { id }, select: { id: true } }), { + action: 'delete', + }) + } + + async stats(): Promise<{ + total: number + withListings: number + withoutListings: number + }> { + const [withListings, withoutListings] = await Promise.all([ + this.prisma.gpu.count({ where: { pcListings: { some: {} } } }), + this.prisma.gpu.count({ where: { pcListings: { none: {} } } }), + ]) + + return { + total: withListings + withoutListings, + withListings, + withoutListings, + } + } +} diff --git a/src/features/hardware/gpu/server/gpu.repository.types.ts b/src/features/hardware/gpu/server/gpu.repository.types.ts new file mode 100644 index 000000000..eb278726c --- /dev/null +++ b/src/features/hardware/gpu/server/gpu.repository.types.ts @@ -0,0 +1,45 @@ +import type { GetGpuOptionsInput, UpdateGpuInput } from '../shared/gpu.types' +import type { + GpuDetailRecord, + GpuMobileListRecord, + GpuMobilePcListingRecord, + GpuSummaryRecord, +} from './persistence/gpu.prisma' +import type { PaginationResult } from '@/schemas/pagination' + +export type { + GpuDeleteGuardRecord, + GpuDetailRecord, + GpuMobileListRecord, + GpuMobilePcListingRecord, + GpuModelNameConflictRecord, + GpuSummaryRecord, +} from './persistence/gpu.prisma' + +export type GpuListResult = { + gpus: GpuDetailRecord[] + pagination: PaginationResult +} + +export type GpuOptionsResult = { + gpus: GpuSummaryRecord[] + hasMore: boolean +} + +export type GpuMobileListResult = { + gpus: GpuMobileListRecord[] + pagination: PaginationResult +} + +export type GpuMobilePcListingResult = { + gpus: GpuMobilePcListingRecord[] +} + +export type GpuOptionsFilters = NonNullable +export type UpdateGpuData = Omit + +export type GpuModelNameConflictInput = { + brandId: string + modelName: string + excludeId?: string +} diff --git a/src/features/hardware/gpu/server/gpu.router.test.ts b/src/features/hardware/gpu/server/gpu.router.test.ts new file mode 100644 index 000000000..efb130f05 --- /dev/null +++ b/src/features/hardware/gpu/server/gpu.router.test.ts @@ -0,0 +1,157 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { prisma } from '@/server/db' +import { PERMISSIONS } from '@/utils/permission-system' +import { Role } from '@orm/client' + +vi.unmock('@/server/api/trpc') +vi.unmock('@/server/api/root') + +const mockPrisma = vi.hoisted(() => ({ + gpu: { + count: vi.fn(), + create: vi.fn(), + delete: vi.fn(), + findFirst: vi.fn(), + findMany: vi.fn(), + findUnique: vi.fn(), + update: vi.fn(), + }, +})) + +vi.mock('@/server/db', () => ({ prisma: mockPrisma })) + +const { gpuRouter } = await import('./gpu.router') + +const USER_ID = '00000000-0000-4000-a000-000000000010' +const GPU_ID = '00000000-0000-4000-a000-000000000001' +const BRAND_ID = '00000000-0000-4000-a000-000000000002' + +const gpuWithCounts = { + id: GPU_ID, + brandId: BRAND_ID, + modelName: 'GeForce RTX 4090', + createdAt: new Date('2026-01-01T00:00:00.000Z'), + brand: { + id: BRAND_ID, + name: 'NVIDIA', + createdAt: new Date('2026-01-01T00:00:00.000Z'), + }, + _count: { pcListings: 2 }, +} + +function createCaller(overrides: { permissions?: string[] } = {}) { + return { + caller: gpuRouter.createCaller({ + session: { + user: { + id: USER_ID, + email: 'test@test.com', + name: 'Test User', + role: Role.ADMIN, + permissions: overrides.permissions ?? [], + showNsfw: false, + }, + }, + prisma, + headers: new Headers(), + }), + } +} + +describe('gpuRouter', () => { + beforeEach(() => { + mockPrisma.gpu.count.mockReset() + mockPrisma.gpu.create.mockReset() + mockPrisma.gpu.delete.mockReset() + mockPrisma.gpu.findFirst.mockReset() + mockPrisma.gpu.findMany.mockReset() + mockPrisma.gpu.findUnique.mockReset() + mockPrisma.gpu.update.mockReset() + }) + + it('returns stable web DTOs from get and hides Prisma relation count details', async () => { + const { caller } = createCaller() + mockPrisma.gpu.findMany.mockResolvedValueOnce([gpuWithCounts]) + mockPrisma.gpu.count.mockResolvedValueOnce(1) + + const result = await caller.get({ page: 2, limit: 10, search: 'NVIDIA' }) + + expect(mockPrisma.gpu.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + skip: 10, + take: 10, + }), + ) + expect(result).toEqual({ + gpus: [ + { + id: GPU_ID, + modelName: 'GeForce RTX 4090', + brand: { id: BRAND_ID, name: 'NVIDIA' }, + pcListingCount: 2, + }, + ], + pagination: { + total: 1, + pages: 1, + page: 2, + offset: 10, + limit: 10, + hasNextPage: false, + hasPreviousPage: true, + }, + }) + expect(result.gpus[0]).not.toHaveProperty('_count') + }) + + it('creates a GPU through validation, policy, repository, service, and DTO output', async () => { + const { caller } = createCaller({ permissions: [PERMISSIONS.MANAGE_DEVICES] }) + mockPrisma.gpu.findFirst.mockResolvedValueOnce(null) + mockPrisma.gpu.create.mockResolvedValueOnce(gpuWithCounts) + + const result = await caller.create({ + brandId: BRAND_ID, + modelName: ' GeForce RTX 4090 ', + }) + + expect(mockPrisma.gpu.create).toHaveBeenCalledWith({ + data: { brandId: BRAND_ID, modelName: 'GeForce RTX 4090' }, + select: { + id: true, + modelName: true, + brand: { select: { id: true, name: true } }, + _count: { select: { pcListings: true } }, + }, + }) + expect(result).toEqual({ + id: GPU_ID, + modelName: 'GeForce RTX 4090', + brand: { id: BRAND_ID, name: 'NVIDIA' }, + pcListingCount: 2, + }) + }) + + it('rejects create before database access when the session lacks manage-device permission', async () => { + const { caller } = createCaller() + + await expect( + caller.create({ + brandId: BRAND_ID, + modelName: 'GeForce RTX 4090', + }), + ).rejects.toThrow('You need the following permissions: manage_devices') + expect(mockPrisma.gpu.findFirst).not.toHaveBeenCalled() + expect(mockPrisma.gpu.create).not.toHaveBeenCalled() + }) + + it('returns GPU stats only when the session has statistics permission', async () => { + const { caller } = createCaller({ permissions: [PERMISSIONS.VIEW_STATISTICS] }) + mockPrisma.gpu.count.mockResolvedValueOnce(3).mockResolvedValueOnce(2) + + await expect(caller.stats()).resolves.toEqual({ + total: 5, + withListings: 3, + withoutListings: 2, + }) + }) +}) diff --git a/src/features/hardware/gpu/server/gpu.router.ts b/src/features/hardware/gpu/server/gpu.router.ts new file mode 100644 index 000000000..101434d8e --- /dev/null +++ b/src/features/hardware/gpu/server/gpu.router.ts @@ -0,0 +1,67 @@ +import { MutationSuccessSchema } from '@/schemas/common' +import { createTRPCRouter, protectedProcedure, publicProcedure } from '@/server/api/trpc' +import { createActorFromSession } from '@/server/auth/actor' +import { createGpuService } from './gpu.service' +import { + CreateGpuSchema, + DeleteGpuSchema, + GetGpuByIdSchema, + GetGpuOptionsSchema, + GetGpusByIdsSchema, + GetGpusSchema, + GpuDetailSchema, + GpuListResponseSchema, + GpuOptionsResponseSchema, + GpuStatsSchema, + GpusByIdsResponseSchema, + UpdateGpuSchema, +} from '../shared/gpu.schemas' + +export const gpuRouter = createTRPCRouter({ + get: publicProcedure + .input(GetGpusSchema) + .output(GpuListResponseSchema) + .query(async ({ ctx, input }) => createGpuService(ctx.prisma).list(input ?? {})), + + options: publicProcedure + .input(GetGpuOptionsSchema) + .output(GpuOptionsResponseSchema) + .query(async ({ ctx, input }) => createGpuService(ctx.prisma).options(input ?? {})), + + byId: publicProcedure + .input(GetGpuByIdSchema) + .output(GpuDetailSchema) + .query(async ({ ctx, input }) => createGpuService(ctx.prisma).byId(input.id)), + + getByIds: publicProcedure + .input(GetGpusByIdsSchema) + .output(GpusByIdsResponseSchema) + .query(async ({ ctx, input }) => createGpuService(ctx.prisma).listByIds(input)), + + create: protectedProcedure + .input(CreateGpuSchema) + .output(GpuDetailSchema) + .mutation(async ({ ctx, input }) => + createGpuService(ctx.prisma).create(createActorFromSession(ctx.session), input), + ), + + update: protectedProcedure + .input(UpdateGpuSchema) + .output(GpuDetailSchema) + .mutation(async ({ ctx, input }) => + createGpuService(ctx.prisma).update(createActorFromSession(ctx.session), input), + ), + + delete: protectedProcedure + .input(DeleteGpuSchema) + .output(MutationSuccessSchema) + .mutation(async ({ ctx, input }) => + createGpuService(ctx.prisma).delete(createActorFromSession(ctx.session), input), + ), + + stats: protectedProcedure + .output(GpuStatsSchema) + .query(async ({ ctx }) => + createGpuService(ctx.prisma).stats(createActorFromSession(ctx.session)), + ), +}) diff --git a/src/features/hardware/gpu/server/gpu.rules.test.ts b/src/features/hardware/gpu/server/gpu.rules.test.ts new file mode 100644 index 000000000..c0f6e43f9 --- /dev/null +++ b/src/features/hardware/gpu/server/gpu.rules.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest' +import { assertGpuCanBeDeleted, assertGpuModelNameAvailable } from './gpu.rules' + +describe('gpu.rules', () => { + it('allows writes when no model-name conflict exists', () => { + expect(() => assertGpuModelNameAvailable(null, 'GeForce RTX 4090')).not.toThrow() + }) + + it('blocks writes when a model-name conflict exists', () => { + expect(() => assertGpuModelNameAvailable({ id: 'gpu-id' }, 'GeForce RTX 4090')).toThrow( + 'A GPU with model name "GeForce RTX 4090" already exists for this brand', + ) + }) + + it('allows deleting unused GPUs', () => { + expect(() => + assertGpuCanBeDeleted({ + id: 'gpu-id', + _count: { pcListings: 0, presets: 0 }, + }), + ).not.toThrow() + }) + + it('blocks deleting GPUs used by reports or presets', () => { + expect(() => + assertGpuCanBeDeleted({ + id: 'gpu-id', + _count: { pcListings: 2, presets: 1 }, + }), + ).toThrow('Cannot delete GPU that is used in 3 records') + }) +}) diff --git a/src/features/hardware/gpu/server/gpu.rules.ts b/src/features/hardware/gpu/server/gpu.rules.ts new file mode 100644 index 000000000..73479b93c --- /dev/null +++ b/src/features/hardware/gpu/server/gpu.rules.ts @@ -0,0 +1,14 @@ +import { ResourceError } from '@/lib/errors' +import type { GpuDeleteGuardRecord, GpuModelNameConflictRecord } from './gpu.repository.types' + +export function assertGpuModelNameAvailable( + conflict: GpuModelNameConflictRecord | null, + modelName: string, +): void { + if (conflict) throw ResourceError.gpu.alreadyExists(modelName) +} + +export function assertGpuCanBeDeleted(gpu: GpuDeleteGuardRecord): void { + const usageCount = gpu._count.pcListings + gpu._count.presets + if (usageCount > 0) throw ResourceError.gpu.inUse(usageCount) +} diff --git a/src/features/hardware/gpu/server/gpu.service.test.ts b/src/features/hardware/gpu/server/gpu.service.test.ts new file mode 100644 index 000000000..2f2d18a3d --- /dev/null +++ b/src/features/hardware/gpu/server/gpu.service.test.ts @@ -0,0 +1,307 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { PAGINATION } from '@/data/constants' +import { prisma } from '@/server/db' +import { PERMISSIONS } from '@/utils/permission-system' +import { Role } from '@orm/client' +import { GpuRepository } from './gpu.repository' +import { GpuService } from './gpu.service' +import type { GpuDetailRecord, GpuMobileListRecord } from './gpu.repository.types' +import type { Actor } from '@/server/auth/actor' + +const GPU_ID = '00000000-0000-4000-a000-000000000001' +const BRAND_ID = '00000000-0000-4000-a000-000000000002' +const CREATED_AT = new Date('2024-01-01T00:00:00.000Z') + +const gpuWithCounts = { + id: GPU_ID, + modelName: 'GeForce RTX 4090', + brand: { id: BRAND_ID, name: 'NVIDIA' }, + _count: { pcListings: 4 }, +} satisfies GpuDetailRecord + +const mobileGpuRecord = { + id: GPU_ID, + brandId: BRAND_ID, + modelName: 'GeForce RTX 4090', + createdAt: CREATED_AT, + brand: { id: BRAND_ID, name: 'NVIDIA' }, + _count: { pcListings: 4 }, +} satisfies GpuMobileListRecord + +const mockPrisma = vi.hoisted(() => ({ + gpu: { + count: vi.fn(), + create: vi.fn(), + delete: vi.fn(), + findFirst: vi.fn(), + findMany: vi.fn(), + findUnique: vi.fn(), + update: vi.fn(), + }, +})) + +vi.mock('@/server/db', () => ({ prisma: mockPrisma })) + +function createActor(permissions: string[]): Actor { + return { + type: 'user', + userId: 'user-id', + role: Role.ADMIN, + permissions, + showNsfw: false, + } +} + +function createMockRepository() { + const repository = new GpuRepository(prisma) + + return { + repository, + byIdWithCounts: vi.spyOn(repository, 'byIdWithCounts'), + byIdMobileCompatibility: vi.spyOn(repository, 'byIdMobileCompatibility'), + create: vi.spyOn(repository, 'create'), + delete: vi.spyOn(repository, 'delete'), + findDeleteGuardById: vi.spyOn(repository, 'findDeleteGuardById'), + findModelNameConflict: vi.spyOn(repository, 'findModelNameConflict'), + list: vi.spyOn(repository, 'list'), + listByIds: vi.spyOn(repository, 'listByIds'), + listMobileCompatibility: vi.spyOn(repository, 'listMobileCompatibility'), + options: vi.spyOn(repository, 'options'), + pcListingMobileGpuCompatibility: vi.spyOn(repository, 'pcListingMobileGpuCompatibility'), + stats: vi.spyOn(repository, 'stats'), + update: vi.spyOn(repository, 'update'), + } +} + +type MockGpuRepository = ReturnType + +function createService(repository: MockGpuRepository = createMockRepository()) { + return { + repository, + service: new GpuService(repository.repository), + } +} + +describe('GpuService', () => { + beforeEach(() => { + vi.restoreAllMocks() + }) + + it('maps list results to stable GPU DTOs', async () => { + const { repository, service } = createService() + repository.list.mockResolvedValueOnce({ + gpus: [gpuWithCounts], + pagination: { + total: 1, + pages: 1, + page: 1, + offset: 0, + limit: PAGINATION.DEFAULT_LIMIT, + hasNextPage: false, + hasPreviousPage: false, + }, + }) + + const result = await service.list({ page: 1, limit: PAGINATION.DEFAULT_LIMIT }) + + expect(result.gpus).toEqual([ + { + id: GPU_ID, + modelName: 'GeForce RTX 4090', + brand: { id: BRAND_ID, name: 'NVIDIA' }, + pcListingCount: 4, + }, + ]) + expect(result.gpus[0]).not.toHaveProperty('_count') + }) + + it('preserves mobile GPU list compatibility responses', async () => { + const { repository, service } = createService() + repository.listMobileCompatibility.mockResolvedValueOnce({ + gpus: [mobileGpuRecord], + pagination: { + total: 1, + pages: 1, + page: 1, + offset: 0, + limit: 1000, + hasNextPage: false, + hasPreviousPage: false, + }, + }) + + await expect(service.listMobileCompatibility({ page: 1, limit: 1000 })).resolves.toEqual({ + gpus: [mobileGpuRecord], + pagination: { + total: 1, + pages: 1, + page: 1, + offset: 0, + limit: 1000, + hasNextPage: false, + hasPreviousPage: false, + }, + }) + }) + + it('preserves mobile GPU detail compatibility responses', async () => { + const { repository, service } = createService() + repository.byIdMobileCompatibility.mockResolvedValueOnce(mobileGpuRecord) + + await expect(service.byIdMobileCompatibility(GPU_ID)).resolves.toEqual(mobileGpuRecord) + }) + + it('preserves mobile PC listing GPU compatibility responses', async () => { + const { repository, service } = createService() + repository.pcListingMobileGpuCompatibility.mockResolvedValueOnce({ + gpus: [ + { + id: GPU_ID, + brandId: BRAND_ID, + modelName: 'GeForce RTX 4090', + createdAt: CREATED_AT, + brand: { id: BRAND_ID, name: 'NVIDIA' }, + }, + ], + }) + + await expect(service.pcListingMobileGpuCompatibility({ limit: 100 })).resolves.toEqual({ + gpus: [ + { + id: GPU_ID, + brandId: BRAND_ID, + modelName: 'GeForce RTX 4090', + createdAt: CREATED_AT, + brand: { id: BRAND_ID, name: 'NVIDIA' }, + }, + ], + }) + }) + + it('normalizes model names before creating a GPU', async () => { + const { repository, service } = createService() + repository.findModelNameConflict.mockResolvedValueOnce(null) + repository.create.mockResolvedValueOnce(gpuWithCounts) + + const result = await service.create(createActor([PERMISSIONS.MANAGE_DEVICES]), { + brandId: BRAND_ID, + modelName: ' GeForce RTX 4090 ', + }) + + expect(repository.findModelNameConflict).toHaveBeenCalledWith({ + brandId: BRAND_ID, + modelName: 'GeForce RTX 4090', + }) + expect(repository.create).toHaveBeenCalledWith({ + brandId: BRAND_ID, + modelName: 'GeForce RTX 4090', + }) + expect(result).toEqual({ + id: GPU_ID, + modelName: 'GeForce RTX 4090', + brand: { id: BRAND_ID, name: 'NVIDIA' }, + pcListingCount: 4, + }) + }) + + it('rejects GPU creation before touching the repository when the actor lacks permission', async () => { + const { repository, service } = createService() + + await expect( + service.create(createActor([]), { + brandId: BRAND_ID, + modelName: 'GeForce RTX 4090', + }), + ).rejects.toThrow('You need the following permissions: manage_devices') + expect(repository.findModelNameConflict).not.toHaveBeenCalled() + expect(repository.create).not.toHaveBeenCalled() + }) + + it('rejects duplicate GPU model names before creating', async () => { + const { repository, service } = createService() + repository.findModelNameConflict.mockResolvedValueOnce({ id: GPU_ID }) + + await expect( + service.create(createActor([PERMISSIONS.MANAGE_DEVICES]), { + brandId: BRAND_ID, + modelName: 'GeForce RTX 4090', + }), + ).rejects.toThrow('A GPU with model name "GeForce RTX 4090" already exists for this brand') + expect(repository.create).not.toHaveBeenCalled() + }) + + it('normalizes model names before updating a GPU', async () => { + const { repository, service } = createService() + repository.findModelNameConflict.mockResolvedValueOnce(null) + repository.update.mockResolvedValueOnce(gpuWithCounts) + + await service.update(createActor([PERMISSIONS.MANAGE_DEVICES]), { + id: GPU_ID, + brandId: BRAND_ID, + modelName: ' GeForce RTX 4090 ', + }) + + expect(repository.findModelNameConflict).toHaveBeenCalledWith({ + brandId: BRAND_ID, + modelName: 'GeForce RTX 4090', + excludeId: GPU_ID, + }) + expect(repository.update).toHaveBeenCalledWith(GPU_ID, { + brandId: BRAND_ID, + modelName: 'GeForce RTX 4090', + }) + }) + + it('rejects deleting a missing GPU before writing', async () => { + const { repository, service } = createService() + repository.findDeleteGuardById.mockResolvedValueOnce(null) + + await expect( + service.delete(createActor([PERMISSIONS.MANAGE_DEVICES]), { id: GPU_ID }), + ).rejects.toThrow('GPU not found') + expect(repository.delete).not.toHaveBeenCalled() + }) + + it('blocks deleting GPUs that are used by reports or presets before writing', async () => { + const { repository, service } = createService() + repository.findDeleteGuardById.mockResolvedValueOnce({ + id: GPU_ID, + _count: { pcListings: 3, presets: 1 }, + }) + + await expect( + service.delete(createActor([PERMISSIONS.MANAGE_DEVICES]), { id: GPU_ID }), + ).rejects.toThrow('Cannot delete GPU that is used in 4 records') + expect(repository.delete).not.toHaveBeenCalled() + }) + + it('deletes unused GPUs after checking the delete guard', async () => { + const { repository, service } = createService() + repository.findDeleteGuardById.mockResolvedValueOnce({ + id: GPU_ID, + _count: { pcListings: 0, presets: 0 }, + }) + repository.delete.mockResolvedValueOnce(undefined) + + await expect( + service.delete(createActor([PERMISSIONS.MANAGE_DEVICES]), { id: GPU_ID }), + ).resolves.toEqual({ success: true }) + expect(repository.delete).toHaveBeenCalledWith(GPU_ID) + }) + + it('requires the statistics permission before returning GPU stats', async () => { + const { repository, service } = createService() + repository.stats.mockResolvedValueOnce({ total: 5, withListings: 3, withoutListings: 2 }) + + await expect(service.stats(createActor([]))).rejects.toThrow( + 'You need the following permissions: view_statistics', + ) + expect(repository.stats).not.toHaveBeenCalled() + + await expect(service.stats(createActor([PERMISSIONS.VIEW_STATISTICS]))).resolves.toEqual({ + total: 5, + withListings: 3, + withoutListings: 2, + }) + }) +}) diff --git a/src/features/hardware/gpu/server/gpu.service.ts b/src/features/hardware/gpu/server/gpu.service.ts new file mode 100644 index 000000000..ad2e48306 --- /dev/null +++ b/src/features/hardware/gpu/server/gpu.service.ts @@ -0,0 +1,144 @@ +import { ResourceError } from '@/lib/errors' +import { createMutationSuccess, type MutationSuccess } from '@/schemas/common' +import { type Actor } from '@/server/auth/actor' +import { type PrismaRepositoryClient } from '@/server/persistence/prisma.repository' +import { normalizeWhitespace } from '@/utils/text' +import { toGpuDetailDto, toGpuSummaryDto } from './gpu.mapper' +import { assertCanManageGpu, assertCanViewGpuStats } from './gpu.policy' +import { GpuRepository } from './gpu.repository' +import { assertGpuCanBeDeleted, assertGpuModelNameAvailable } from './gpu.rules' +import { + GpuListResponseSchema, + GpuOptionsResponseSchema, + GpuStatsSchema, + GpusByIdsResponseSchema, + MobileGpuListItemSchema, + MobileGpuListResponseSchema, + MobilePcListingGpuResponseSchema, +} from '../shared/gpu.schemas' +import type { + CreateGpuInput, + DeleteGpuInput, + GetGpuOptionsInput, + GetGpusByIdsInput, + GetGpusInput, + GpuDetail, + GpuListResponse, + GpuOptionsResponse, + GpuStats, + GpusByIdsResponse, + MobileGetGpusInput, + MobileGpuListItem, + MobileGpuListResponse, + MobilePcListingGpusInput, + MobilePcListingGpuResponse, + UpdateGpuInput, +} from '../shared/gpu.types' + +export class GpuService { + constructor(private readonly repository: GpuRepository) {} + + async list(input: GetGpusInput = {}): Promise { + const result = await this.repository.list(input ?? {}) + + return GpuListResponseSchema.parse({ + gpus: result.gpus.map((gpu) => toGpuDetailDto(gpu)), + pagination: result.pagination, + }) + } + + async listMobileCompatibility(input: MobileGetGpusInput = {}): Promise { + const result = await this.repository.listMobileCompatibility(input ?? {}) + return MobileGpuListResponseSchema.parse(result) + } + + async byIdMobileCompatibility(id: string): Promise { + const gpu = await this.repository.byIdMobileCompatibility(id) + if (!gpu) throw ResourceError.gpu.notFound() + + return MobileGpuListItemSchema.parse(gpu) + } + + async pcListingMobileGpuCompatibility( + input: MobilePcListingGpusInput, + ): Promise { + const result = await this.repository.pcListingMobileGpuCompatibility(input) + return MobilePcListingGpuResponseSchema.parse(result) + } + + async options(input: GetGpuOptionsInput = {}): Promise { + const result = await this.repository.options(input ?? {}) + + return GpuOptionsResponseSchema.parse({ + gpus: result.gpus.map((gpu) => toGpuSummaryDto(gpu)), + hasMore: result.hasMore, + }) + } + + async byId(id: string): Promise { + const gpu = await this.repository.byIdWithCounts(id) + if (!gpu) throw ResourceError.gpu.notFound() + + return toGpuDetailDto(gpu) + } + + async listByIds(input: GetGpusByIdsInput): Promise { + const gpus = await this.repository.listByIds(input.ids) + return GpusByIdsResponseSchema.parse(gpus.map((gpu) => toGpuSummaryDto(gpu))) + } + + async create(actor: Actor, input: CreateGpuInput): Promise { + assertCanManageGpu(actor) + const modelName = normalizeWhitespace(input.modelName) + const conflict = await this.repository.findModelNameConflict({ + brandId: input.brandId, + modelName, + }) + assertGpuModelNameAvailable(conflict, modelName) + + const gpu = await this.repository.create({ + brandId: input.brandId, + modelName, + }) + + return toGpuDetailDto(gpu) + } + + async update(actor: Actor, input: UpdateGpuInput): Promise { + assertCanManageGpu(actor) + const modelName = normalizeWhitespace(input.modelName) + const conflict = await this.repository.findModelNameConflict({ + brandId: input.brandId, + modelName, + excludeId: input.id, + }) + assertGpuModelNameAvailable(conflict, modelName) + + const gpu = await this.repository.update(input.id, { + brandId: input.brandId, + modelName, + }) + + return toGpuDetailDto(gpu) + } + + async delete(actor: Actor, input: DeleteGpuInput): Promise { + assertCanManageGpu(actor) + + const gpu = await this.repository.findDeleteGuardById(input.id) + if (!gpu) throw ResourceError.gpu.notFound() + assertGpuCanBeDeleted(gpu) + + await this.repository.delete(input.id) + return createMutationSuccess() + } + + async stats(actor: Actor): Promise { + assertCanViewGpuStats(actor) + return GpuStatsSchema.parse(await this.repository.stats()) + } +} + +export function createGpuService(prisma: PrismaRepositoryClient): GpuService { + return new GpuService(new GpuRepository(prisma)) +} diff --git a/src/features/hardware/gpu/server/persistence/gpu.errors.test.ts b/src/features/hardware/gpu/server/persistence/gpu.errors.test.ts new file mode 100644 index 000000000..49389f75b --- /dev/null +++ b/src/features/hardware/gpu/server/persistence/gpu.errors.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest' +import { translateGpuWriteError } from './gpu.errors' + +function prismaError(code: string): Error { + const error = new Error(`Prisma ${code}`) + Object.assign(error, { code }) + return error +} + +describe('translateGpuWriteError', () => { + it('maps create and update foreign key failures to missing GPU brand errors', () => { + expect(() => + translateGpuWriteError(prismaError('P2003'), { + action: 'create', + modelName: 'GeForce RTX 4090', + }), + ).toThrow('Device brand not found') + + expect(() => + translateGpuWriteError(prismaError('P2003'), { + action: 'update', + modelName: 'GeForce RTX 4090', + }), + ).toThrow('Device brand not found') + }) + + it('maps delete foreign key failures to an in-use GPU error without inventing a count', () => { + expect(() => translateGpuWriteError(prismaError('P2003'), { action: 'delete' })).toThrow( + 'Cannot delete GPU as it is currently in use', + ) + + expect(() => translateGpuWriteError(prismaError('P2003'), { action: 'delete' })).not.toThrow( + '1 records', + ) + }) + + it('maps update and delete missing-record failures to GPU not found', () => { + expect(() => + translateGpuWriteError(prismaError('P2025'), { + action: 'update', + modelName: 'GeForce RTX 4090', + }), + ).toThrow('GPU not found') + + expect(() => translateGpuWriteError(prismaError('P2025'), { action: 'delete' })).toThrow( + 'GPU not found', + ) + }) + + it('does not report impossible create missing-record failures as GPU not found', () => { + expect(() => + translateGpuWriteError(prismaError('P2025'), { + action: 'create', + modelName: 'GeForce RTX 4090', + }), + ).toThrow('Database error during GPU create') + }) +}) diff --git a/src/features/hardware/gpu/server/persistence/gpu.errors.ts b/src/features/hardware/gpu/server/persistence/gpu.errors.ts new file mode 100644 index 000000000..5a049eeb9 --- /dev/null +++ b/src/features/hardware/gpu/server/persistence/gpu.errors.ts @@ -0,0 +1,27 @@ +import { AppError, ResourceError } from '@/lib/errors' +import { isPrismaError, PRISMA_ERROR_CODES } from '@/server/utils/prisma-errors' + +export type GpuWriteContext = { + action: 'create' | 'update' | 'delete' + modelName?: string +} + +export function translateGpuWriteError(error: unknown, context: GpuWriteContext): never { + if (isPrismaError(error, PRISMA_ERROR_CODES.UNIQUE_CONSTRAINT_VIOLATION)) { + throw ResourceError.gpu.alreadyExists(context.modelName ?? 'this model') + } + + if (isPrismaError(error, PRISMA_ERROR_CODES.FOREIGN_KEY_CONSTRAINT_VIOLATION)) { + if (context.action === 'delete') throw ResourceError.gpu.inUse() + throw ResourceError.deviceBrand.notFound() + } + + if ( + isPrismaError(error, PRISMA_ERROR_CODES.RECORD_NOT_FOUND) && + (context.action === 'update' || context.action === 'delete') + ) { + throw ResourceError.gpu.notFound() + } + + throw AppError.databaseError(`GPU ${context.action}`) +} diff --git a/src/features/hardware/gpu/server/persistence/gpu.prisma.ts b/src/features/hardware/gpu/server/persistence/gpu.prisma.ts new file mode 100644 index 000000000..95e869b4d --- /dev/null +++ b/src/features/hardware/gpu/server/persistence/gpu.prisma.ts @@ -0,0 +1,56 @@ +import type { Prisma } from '@orm/client' + +const gpuBrandSelect = { + id: true, + name: true, +} satisfies Prisma.DeviceBrandSelect + +export const GPU_DETAIL_SELECT = { + id: true, + modelName: true, + brand: { select: gpuBrandSelect }, + _count: { select: { pcListings: true } }, +} satisfies Prisma.GpuSelect + +export const GPU_SUMMARY_SELECT = { + id: true, + modelName: true, + brand: { select: gpuBrandSelect }, +} satisfies Prisma.GpuSelect + +export const GPU_MOBILE_LIST_SELECT = { + id: true, + brandId: true, + modelName: true, + createdAt: true, + brand: { select: gpuBrandSelect }, + _count: { select: { pcListings: true } }, +} satisfies Prisma.GpuSelect + +export const GPU_MOBILE_PC_LISTING_SELECT = { + id: true, + brandId: true, + modelName: true, + createdAt: true, + brand: { select: gpuBrandSelect }, +} satisfies Prisma.GpuSelect + +export const GPU_MODEL_CONFLICT_SELECT = { + id: true, +} satisfies Prisma.GpuSelect + +export const GPU_DELETE_GUARD_SELECT = { + id: true, + _count: { select: { pcListings: true, presets: true } }, +} satisfies Prisma.GpuSelect + +export type GpuDetailRecord = Prisma.GpuGetPayload<{ select: typeof GPU_DETAIL_SELECT }> +export type GpuSummaryRecord = Prisma.GpuGetPayload<{ select: typeof GPU_SUMMARY_SELECT }> +export type GpuMobileListRecord = Prisma.GpuGetPayload<{ select: typeof GPU_MOBILE_LIST_SELECT }> +export type GpuMobilePcListingRecord = Prisma.GpuGetPayload<{ + select: typeof GPU_MOBILE_PC_LISTING_SELECT +}> +export type GpuModelNameConflictRecord = Prisma.GpuGetPayload<{ + select: typeof GPU_MODEL_CONFLICT_SELECT +}> +export type GpuDeleteGuardRecord = Prisma.GpuGetPayload<{ select: typeof GPU_DELETE_GUARD_SELECT }> diff --git a/src/features/hardware/gpu/server/persistence/gpu.query.test.ts b/src/features/hardware/gpu/server/persistence/gpu.query.test.ts new file mode 100644 index 000000000..0f1a6dd51 --- /dev/null +++ b/src/features/hardware/gpu/server/persistence/gpu.query.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it, vi } from 'vitest' +import { + buildGpuListQuery, + buildGpuModelNameConflictWhere, + buildGpuOptionsQuery, + buildGpuOrderBy, + buildGpuWhere, + buildMobileGpuListQuery, + buildMobilePcListingGpuQuery, +} from './gpu.query' +import type * as OrmClient from '@orm/client' + +vi.mock('@orm/client', async () => { + const actual = await vi.importActual('@orm/client') + return { + ...actual, + Prisma: { + ...actual.Prisma, + QueryMode: { insensitive: 'insensitive' }, + SortOrder: { asc: 'asc', desc: 'desc' }, + }, + } +}) + +const BRAND_ID = '00000000-0000-4000-a000-000000000002' +const GPU_ID = '00000000-0000-4000-a000-000000000001' + +describe('gpu.query', () => { + it('builds the shared GPU search predicate for model, brand, and combined brand-model terms', () => { + expect(buildGpuWhere(' NVIDIA RTX 4090 ', BRAND_ID)).toEqual({ + brandId: BRAND_ID, + OR: [ + { modelName: { equals: 'NVIDIA RTX 4090', mode: 'insensitive' } }, + { brand: { name: { equals: 'NVIDIA RTX 4090', mode: 'insensitive' } } }, + { modelName: { contains: 'NVIDIA RTX 4090', mode: 'insensitive' } }, + { brand: { name: { contains: 'NVIDIA RTX 4090', mode: 'insensitive' } } }, + { + AND: [ + { brand: { name: { contains: 'NVIDIA', mode: 'insensitive' } } }, + { modelName: { contains: 'RTX 4090', mode: 'insensitive' } }, + ], + }, + ], + }) + }) + + it('builds stable GPU ordering with explicit defaults', () => { + expect(buildGpuOrderBy()).toEqual([{ brand: { name: 'asc' } }, { modelName: 'asc' }]) + expect(buildGpuOrderBy('pcListings', 'desc')).toEqual([{ pcListings: { _count: 'desc' } }]) + }) + + it('builds paginated list query primitives', () => { + expect(buildGpuListQuery({ page: 3, limit: 25, sortField: 'modelName' })).toEqual({ + where: {}, + orderBy: [{ modelName: 'asc' }], + pagination: { + limit: 25, + offset: 50, + page: 3, + }, + }) + }) + + it('builds GPU dropdown query primitives with lookahead pagination', () => { + expect(buildGpuOptionsQuery({ search: 'Radeon', offset: 10, limit: 5 })).toEqual({ + where: { + OR: [ + { modelName: { equals: 'Radeon', mode: 'insensitive' } }, + { brand: { name: { equals: 'Radeon', mode: 'insensitive' } } }, + { modelName: { contains: 'Radeon', mode: 'insensitive' } }, + { brand: { name: { contains: 'Radeon', mode: 'insensitive' } } }, + ], + }, + orderBy: [{ brand: { name: 'asc' } }, { modelName: 'asc' }], + limit: 5, + offset: 10, + }) + }) + + it('builds mobile GPU list query primitives with the old search behavior', () => { + expect(buildMobileGpuListQuery({ search: 'NVIDIA RTX 4090', page: 2, limit: 1000 })).toEqual({ + where: { + OR: [ + { modelName: { equals: 'NVIDIA RTX 4090', mode: 'insensitive' } }, + { brand: { name: { equals: 'NVIDIA RTX 4090', mode: 'insensitive' } } }, + { modelName: { contains: 'NVIDIA RTX 4090', mode: 'insensitive' } }, + { brand: { name: { contains: 'NVIDIA RTX 4090', mode: 'insensitive' } } }, + { + AND: [ + { brand: { name: { contains: 'NVIDIA', mode: 'insensitive' } } }, + { modelName: { contains: 'RTX 4090', mode: 'insensitive' } }, + ], + }, + ], + }, + orderBy: [{ brand: { name: 'asc' } }, { modelName: 'asc' }], + pagination: { + limit: 1000, + offset: 1000, + page: 2, + }, + }) + }) + + it('builds mobile PC listing GPU query primitives with the old simple search behavior', () => { + expect( + buildMobilePcListingGpuQuery({ search: 'Radeon', brandId: BRAND_ID, limit: 100 }), + ).toEqual({ + where: { + brandId: BRAND_ID, + OR: [ + { modelName: { contains: 'Radeon', mode: 'insensitive' } }, + { brand: { name: { contains: 'Radeon', mode: 'insensitive' } } }, + ], + }, + orderBy: { modelName: 'asc' }, + limit: 100, + }) + }) + + it('builds case-insensitive model conflict predicates scoped to the selected brand', () => { + expect( + buildGpuModelNameConflictWhere({ + brandId: BRAND_ID, + modelName: 'GeForce RTX 4090', + excludeId: GPU_ID, + }), + ).toEqual({ + brandId: BRAND_ID, + modelName: { equals: 'GeForce RTX 4090', mode: 'insensitive' }, + id: { not: GPU_ID }, + }) + }) +}) diff --git a/src/features/hardware/gpu/server/persistence/gpu.query.ts b/src/features/hardware/gpu/server/persistence/gpu.query.ts new file mode 100644 index 000000000..c12481953 --- /dev/null +++ b/src/features/hardware/gpu/server/persistence/gpu.query.ts @@ -0,0 +1,186 @@ +import { LOOKUP_PAGINATION } from '@/data/constants' +import { resolvePagination, type ResolvedPagination } from '@/server/utils/pagination' +import { Prisma } from '@orm/client' +import type { + GetGpuOptionsInput, + GetGpusInput, + GpuSortField, + MobileGetGpusInput, + MobilePcListingGpusInput, +} from '../../shared/gpu.types' + +type GpuOptionsFilters = NonNullable +type MobilePcListingGpuFilters = MobilePcListingGpusInput +type GpuOrderByFactory = (direction: Prisma.SortOrder) => Prisma.GpuOrderByWithRelationInput[] + +const GPU_QUERY_MODE = Prisma.QueryMode.insensitive +const GPU_DEFAULT_SORT = Prisma.SortOrder.asc +const GPU_ORDER_BY = { + brand: (direction) => [{ brand: { name: direction } }], + modelName: (direction) => [{ modelName: direction }], + pcListings: (direction) => [{ pcListings: { _count: direction } }], +} satisfies Record + +export type GpuListQuery = { + where: Prisma.GpuWhereInput + orderBy: Prisma.GpuOrderByWithRelationInput[] + pagination: ResolvedPagination +} + +export type GpuOptionsQuery = { + where: Prisma.GpuWhereInput + orderBy: Prisma.GpuOrderByWithRelationInput[] + limit: number + offset: number +} + +export type MobilePcListingGpuQuery = { + where: Prisma.GpuWhereInput + orderBy: Prisma.GpuOrderByWithRelationInput + limit: number +} + +export type GpuModelNameConflictQuery = { + brandId: string + modelName: string + excludeId?: string +} + +export function buildGpuListQuery(filters: GetGpusInput = {}): GpuListQuery { + return { + where: buildGpuWhere(filters?.search, filters?.brandId), + orderBy: buildGpuOrderBy(filters?.sortField, filters?.sortDirection), + pagination: resolvePagination(filters), + } +} + +export function buildGpuOptionsQuery(filters: GpuOptionsFilters = {}): GpuOptionsQuery { + return { + where: buildGpuWhere(filters.search, filters.brandId), + orderBy: defaultGpuOrderBy(), + limit: filters.limit ?? LOOKUP_PAGINATION.DEFAULT_LIMIT, + offset: filters.offset ?? 0, + } +} + +export function buildMobileGpuListQuery(filters: MobileGetGpusInput = {}): GpuListQuery { + return { + where: buildMobileGpuCatalogCompatibilityWhere(filters?.search, filters?.brandId), + orderBy: buildGpuOrderBy(filters?.sortField, filters?.sortDirection), + pagination: resolvePagination(filters), + } +} + +export function buildMobilePcListingGpuQuery( + filters: MobilePcListingGpuFilters, +): MobilePcListingGpuQuery { + return { + where: buildMobilePcListingGpuWhere(filters.search, filters.brandId), + orderBy: { modelName: GPU_DEFAULT_SORT }, + limit: filters.limit ?? LOOKUP_PAGINATION.DEFAULT_LIMIT, + } +} + +export function buildGpuModelNameConflictWhere( + query: GpuModelNameConflictQuery, +): Prisma.GpuWhereInput { + return { + brandId: query.brandId, + modelName: { equals: query.modelName, mode: GPU_QUERY_MODE }, + ...(query.excludeId ? { id: { not: query.excludeId } } : {}), + } +} + +export function buildGpuWhere(search?: string, brandId?: string): Prisma.GpuWhereInput { + const where: Prisma.GpuWhereInput = {} + const query = search?.trim() + + if (brandId) where.brandId = brandId + if (!query) return where + + const parts = query.split(/\s+/) + const brandCandidate = parts[0] + const modelCandidate = parts.slice(1).join(' ') + + where.OR = [ + { modelName: { equals: query, mode: GPU_QUERY_MODE } }, + { brand: { name: { equals: query, mode: GPU_QUERY_MODE } } }, + { modelName: { contains: query, mode: GPU_QUERY_MODE } }, + { brand: { name: { contains: query, mode: GPU_QUERY_MODE } } }, + ] + + if (brandCandidate && modelCandidate) { + where.OR.push({ + AND: [ + { brand: { name: { contains: brandCandidate, mode: GPU_QUERY_MODE } } }, + { modelName: { contains: modelCandidate, mode: GPU_QUERY_MODE } }, + ], + }) + } + + return where +} + +// +/** + * Preserves the pre-feature mobile/public GPU catalog search semantics until that API is versioned. + * @deprecated + */ +function buildMobileGpuCatalogCompatibilityWhere( + search?: string, + brandId?: string, +): Prisma.GpuWhereInput { + const where: Prisma.GpuWhereInput = {} + + if (brandId) where.brandId = brandId + + if (search) { + where.OR = [ + { modelName: { equals: search, mode: GPU_QUERY_MODE } }, + { brand: { name: { equals: search, mode: GPU_QUERY_MODE } } }, + { modelName: { contains: search, mode: GPU_QUERY_MODE } }, + { brand: { name: { contains: search, mode: GPU_QUERY_MODE } } }, + ] + + if (search.includes(' ')) { + where.OR.push({ + AND: [ + { brand: { name: { contains: search.split(' ')[0], mode: GPU_QUERY_MODE } } }, + { + modelName: { contains: search.split(' ').slice(1).join(' '), mode: GPU_QUERY_MODE }, + }, + ], + }) + } + } + + return where +} + +function buildMobilePcListingGpuWhere(search?: string, brandId?: string): Prisma.GpuWhereInput { + const where: Prisma.GpuWhereInput = {} + + if (brandId) where.brandId = brandId + if (!search) return where + + where.OR = [ + { modelName: { contains: search, mode: GPU_QUERY_MODE } }, + { brand: { name: { contains: search, mode: GPU_QUERY_MODE } } }, + ] + + return where +} + +export function buildGpuOrderBy( + sortField?: GpuSortField | null, + sortDirection?: Prisma.SortOrder | null, +): Prisma.GpuOrderByWithRelationInput[] { + const direction = sortDirection ?? GPU_DEFAULT_SORT + if (!sortField) return defaultGpuOrderBy() + + return GPU_ORDER_BY[sortField](direction) +} + +function defaultGpuOrderBy(): Prisma.GpuOrderByWithRelationInput[] { + return [{ brand: { name: GPU_DEFAULT_SORT } }, { modelName: GPU_DEFAULT_SORT }] +} diff --git a/src/features/hardware/gpu/shared/gpu-format.test.ts b/src/features/hardware/gpu/shared/gpu-format.test.ts new file mode 100644 index 000000000..f7cdcb751 --- /dev/null +++ b/src/features/hardware/gpu/shared/gpu-format.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest' +import { getGpuLabel } from './gpu-format' + +const gpu = { + id: '4f5a48f9-5173-4db0-9f3b-d10a5aa7a111', + modelName: 'GeForce RTX 4090', + brand: { + id: '4f5a48f9-5173-4db0-9f3b-d10a5aa7a222', + name: 'NVIDIA', + }, +} + +describe('gpu-format', () => { + it('builds the user-facing GPU label from brand and model', () => { + expect(getGpuLabel(gpu)).toBe('NVIDIA GeForce RTX 4090') + }) +}) diff --git a/src/features/hardware/gpu/shared/gpu-format.ts b/src/features/hardware/gpu/shared/gpu-format.ts new file mode 100644 index 000000000..7b87ad729 --- /dev/null +++ b/src/features/hardware/gpu/shared/gpu-format.ts @@ -0,0 +1,5 @@ +import type { GpuLabelInput } from './gpu.types' + +export function getGpuLabel(gpu: GpuLabelInput): string { + return `${gpu.brand.name} ${gpu.modelName}` +} diff --git a/src/features/hardware/gpu/shared/gpu.schemas.ts b/src/features/hardware/gpu/shared/gpu.schemas.ts new file mode 100644 index 000000000..87bc36196 --- /dev/null +++ b/src/features/hardware/gpu/shared/gpu.schemas.ts @@ -0,0 +1,124 @@ +import { z } from 'zod' +import { LOOKUP_PAGINATION, PAGINATION } from '@/data/constants' +import { SortDirectionSchema } from '@/schemas/common' +import { + LookupPaginationInputSchema, + PaginationInputSchema, + PaginationResultSchema, +} from '@/schemas/pagination' + +export const GpuSortFieldSchema = z.enum(['brand', 'modelName', 'pcListings']) + +export const GetGpusSchema = z + .object({ + search: z.string().optional(), + brandId: z.string().uuid().optional(), + sortField: GpuSortFieldSchema.optional(), + sortDirection: SortDirectionSchema.optional(), + }) + .merge(PaginationInputSchema) + .optional() + +export const GetGpuOptionsSchema = z + .object({ + search: z.string().optional(), + brandId: z.string().uuid().optional(), + }) + .merge(LookupPaginationInputSchema) + .optional() + +// Mobile/public compatibility contract for the existing GPU catalog route. +export const MobileGetGpusSchema = z + .object({ + search: z.string().optional(), + brandId: z.string().uuid().optional(), + limit: z.number().default(PAGINATION.DEFAULT_LIMIT), + offset: z.number().default(0), + page: z.number().optional(), + sortField: GpuSortFieldSchema.optional(), + sortDirection: SortDirectionSchema.optional(), + }) + .optional() + +export const MobilePcListingGpusSchema = z.object({ + search: z.string().optional(), + brandId: z.string().uuid().optional(), + limit: z.number().min(1).max(PAGINATION.MAX_LIMIT).default(LOOKUP_PAGINATION.DEFAULT_LIMIT), +}) + +export const GetGpuByIdSchema = z.object({ id: z.string().uuid() }) +export const GetGpusByIdsSchema = z.object({ + ids: z.array(z.string().uuid()).min(1).max(LOOKUP_PAGINATION.MAX_LIMIT), +}) + +export const CreateGpuSchema = z.object({ + brandId: z.string().uuid(), + modelName: z.string().trim().min(1), +}) + +export const UpdateGpuSchema = z.object({ + id: z.string().uuid(), + brandId: z.string().uuid(), + modelName: z.string().trim().min(1), +}) + +export const DeleteGpuSchema = z.object({ id: z.string().uuid() }) + +export const GpuBrandSchema = z.object({ + id: z.string().uuid(), + name: z.string(), +}) + +export const GpuSummarySchema = z.object({ + id: z.string().uuid(), + modelName: z.string(), + brand: GpuBrandSchema, +}) + +export const GpuDetailSchema = GpuSummarySchema.extend({ + pcListingCount: z.number().int().min(0), +}) + +export const GpuListResponseSchema = z.object({ + gpus: z.array(GpuDetailSchema), + pagination: PaginationResultSchema, +}) + +export const GpuOptionsResponseSchema = z.object({ + gpus: z.array(GpuSummarySchema), + hasMore: z.boolean(), +}) + +export const MobileGpuListItemSchema = z.object({ + id: z.string().uuid(), + brandId: z.string().uuid(), + modelName: z.string(), + createdAt: z.date(), + brand: GpuBrandSchema, + _count: z.object({ pcListings: z.number().int().min(0) }), +}) + +export const MobileGpuListResponseSchema = z.object({ + gpus: z.array(MobileGpuListItemSchema), + pagination: PaginationResultSchema, +}) + +export const MobilePcListingGpuSchema = z.object({ + id: z.string().uuid(), + brandId: z.string().uuid(), + modelName: z.string(), + createdAt: z.date(), + brand: GpuBrandSchema, +}) + +export const MobilePcListingGpuResponseSchema = z.object({ + gpus: z.array(MobilePcListingGpuSchema), +}) + +export const GpusByIdsResponseSchema = z.array(GpuSummarySchema) + +export const GpuStatsSchema = z.object({ + total: z.number().int().min(0), + withListings: z.number().int().min(0), + withoutListings: z.number().int().min(0), +}) diff --git a/src/features/hardware/gpu/shared/gpu.types.ts b/src/features/hardware/gpu/shared/gpu.types.ts new file mode 100644 index 000000000..e9d84ef39 --- /dev/null +++ b/src/features/hardware/gpu/shared/gpu.types.ts @@ -0,0 +1,43 @@ +import type { + CreateGpuSchema, + DeleteGpuSchema, + GetGpuOptionsSchema, + GetGpusByIdsSchema, + GetGpusSchema, + GpuDetailSchema, + GpuListResponseSchema, + GpuOptionsResponseSchema, + GpuStatsSchema, + GpuSummarySchema, + GpuSortFieldSchema, + GpusByIdsResponseSchema, + MobileGetGpusSchema, + MobileGpuListItemSchema, + MobileGpuListResponseSchema, + MobilePcListingGpusSchema, + MobilePcListingGpuResponseSchema, + UpdateGpuSchema, +} from './gpu.schemas' +import type { z } from 'zod' + +export type GpuSortField = z.output +export type GetGpusInput = z.input +export type GetGpuOptionsInput = z.input +export type MobileGetGpusInput = z.input +export type MobilePcListingGpusInput = z.input +export type CreateGpuInput = z.output +export type UpdateGpuInput = z.output +export type DeleteGpuInput = z.output +export type GetGpusByIdsInput = z.output +export type GpuSummary = z.output +export type GpuLabelInput = Pick & { + brand: Pick +} +export type GpuDetail = z.output +export type GpuListResponse = z.output +export type GpuOptionsResponse = z.output +export type GpusByIdsResponse = z.output +export type GpuStats = z.output +export type MobileGpuListItem = z.output +export type MobileGpuListResponse = z.output +export type MobilePcListingGpuResponse = z.output diff --git a/src/lib/api.tsx b/src/lib/api.tsx index 01b26b0a7..842c547a0 100644 --- a/src/lib/api.tsx +++ b/src/lib/api.tsx @@ -2,7 +2,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { httpBatchLink } from '@trpc/client' -import { createTRPCReact } from '@trpc/react-query' +import { createTRPCReact, getQueryKey } from '@trpc/react-query' import { useState, type PropsWithChildren } from 'react' import superjson from 'superjson' import { CACHE_DURATIONS } from '@/data/constants' @@ -11,23 +11,50 @@ import type { AppRouter } from '@/types/trpc' export const api = createTRPCReact() +function configureQueryDefaults(queryClient: QueryClient) { + const lookupDefaults = { + staleTime: CACHE_DURATIONS.LOOKUP, + gcTime: CACHE_DURATIONS.LOOKUP_GC, + } + + queryClient.setQueryDefaults(getQueryKey(api.cpus.options), lookupDefaults) + queryClient.setQueryDefaults(getQueryKey(api.cpus.getByIds), lookupDefaults) + queryClient.setQueryDefaults(getQueryKey(api.gpus.options), lookupDefaults) + queryClient.setQueryDefaults(getQueryKey(api.gpus.getByIds), lookupDefaults) + queryClient.setQueryDefaults(getQueryKey(api.devices.options), lookupDefaults) + queryClient.setQueryDefaults(getQueryKey(api.devices.getByIds), lookupDefaults) + queryClient.setQueryDefaults(getQueryKey(api.socs.options), lookupDefaults) + queryClient.setQueryDefaults(getQueryKey(api.socs.getByIds), lookupDefaults) + queryClient.setQueryDefaults(getQueryKey(api.systems.get), lookupDefaults) + queryClient.setQueryDefaults(getQueryKey(api.emulators.get), lookupDefaults) + queryClient.setQueryDefaults(getQueryKey(api.performanceScales.get), lookupDefaults) + queryClient.setQueryDefaults(getQueryKey(api.listings.performanceScales), lookupDefaults) +} + +function createQueryClient() { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: CACHE_DURATIONS.SHORT, + gcTime: CACHE_DURATIONS.MEDIUM, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + retry: shouldRetryTRPCQuery, + retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000), + }, + mutations: { retry: false }, + }, + }) + + configureQueryDefaults(queryClient) + + return queryClient +} + +const MAX_URL_LENGTH = 2000 + export function TRPCProvider(props: PropsWithChildren) { - const [queryClient] = useState( - () => - new QueryClient({ - defaultOptions: { - queries: { - staleTime: CACHE_DURATIONS.SHORT, - gcTime: CACHE_DURATIONS.MEDIUM, - refetchOnWindowFocus: false, - refetchOnReconnect: false, - retry: shouldRetryTRPCQuery, - retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000), - }, - mutations: { retry: false }, - }, - }), - ) + const [queryClient] = useState(createQueryClient) const [trpcClient] = useState(() => api.createClient({ @@ -36,7 +63,7 @@ export function TRPCProvider(props: PropsWithChildren) { url: '/api/trpc', transformer: superjson, headers: () => ({}), - maxURLLength: 2000, + maxURLLength: MAX_URL_LENGTH, }), ], }), diff --git a/src/lib/errors.ts b/src/lib/errors.ts index 83abf9040..b33168ed3 100644 --- a/src/lib/errors.ts +++ b/src/lib/errors.ts @@ -523,14 +523,14 @@ export class ResourceError { notFound: () => AppError.notFound('CPU'), alreadyExists: (modelName: string) => AppError.conflict(`A CPU with model name "${modelName}" already exists for this brand`), - inUse: (count: number) => AppError.resourceInUse('CPU', count), + inUse: (count?: number) => AppError.resourceInUse('CPU', count), } static gpu = { notFound: () => AppError.notFound('GPU'), alreadyExists: (modelName: string) => AppError.conflict(`A GPU with model name "${modelName}" already exists for this brand`), - inUse: (count: number) => AppError.resourceInUse('GPU', count), + inUse: (count?: number) => AppError.resourceInUse('GPU', count), } static pcPreset = { diff --git a/src/schemas/common.ts b/src/schemas/common.ts index 35b9ddec5..c81dcdf06 100644 --- a/src/schemas/common.ts +++ b/src/schemas/common.ts @@ -3,6 +3,15 @@ import { z } from 'zod' export const SortDirectionSchema = z.enum(['asc', 'desc']) export type SortDirection = z.infer +export const MutationSuccessSchema = z.object({ + success: z.literal(true), +}) +export type MutationSuccess = z.output + +export function createMutationSuccess(): MutationSuccess { + return { success: true } +} + // Admin table URL parameters export const AdminTableParamsSchema = z.object({ search: z.string().default(''), diff --git a/src/schemas/cpu.ts b/src/schemas/cpu.ts deleted file mode 100644 index 683887d10..000000000 --- a/src/schemas/cpu.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { z } from 'zod' -import { SortDirectionSchema } from '@/schemas/common' - -export const CpuSortField = z.enum(['brand', 'modelName', 'pcListings']) - -export const GetCpusSchema = z - .object({ - search: z.string().optional(), - brandId: z.string().uuid().optional(), - limit: z.number().default(20), - offset: z.number().default(0), - page: z.number().optional(), - sortField: CpuSortField.optional(), - sortDirection: SortDirectionSchema.optional(), - }) - .optional() - -export const GetCpuOptionsSchema = z - .object({ - search: z.string().optional(), - brandId: z.string().uuid().optional(), - limit: z.number().int().min(1).max(10000).default(50), - offset: z.number().int().min(0).default(0), - }) - .optional() - -export const GetCpuByIdSchema = z.object({ id: z.string().uuid() }) -export const GetCpusByIdsSchema = z.object({ ids: z.array(z.string().uuid()).min(1).max(100) }) - -export const CreateCpuSchema = z.object({ - brandId: z.string().uuid(), - modelName: z.string().min(1), -}) - -export const UpdateCpuSchema = z.object({ - id: z.string().uuid(), - brandId: z.string().uuid(), - modelName: z.string().min(1), -}) - -export const DeleteCpuSchema = z.object({ id: z.string().uuid() }) - -export type GetCpusInput = z.input -export type GetCpuOptionsInput = z.input -export type CreateCpuInput = z.infer -export type UpdateCpuInput = z.infer -export type GetCpusByIdsInput = z.infer diff --git a/src/schemas/device.ts b/src/schemas/device.ts index de2b8b651..8c8fa71f9 100644 --- a/src/schemas/device.ts +++ b/src/schemas/device.ts @@ -1,6 +1,7 @@ import { z } from 'zod' -import { HOME_PAGE_LIMITS } from '@/data/constants' +import { HOME_PAGE_LIMITS, LOOKUP_PAGINATION } from '@/data/constants' import { SortDirectionSchema } from '@/schemas/common' +import { LookupPaginationInputSchema } from '@/schemas/pagination' export const DeviceSortField = z.enum(['brand', 'modelName', 'soc', 'listings']) @@ -22,13 +23,14 @@ export const GetDeviceOptionsSchema = z search: z.string().nullable().optional(), brandId: z.string().uuid().nullable().optional(), socId: z.string().uuid().nullable().optional(), - limit: z.number().int().min(1).max(10000).default(50), - offset: z.number().int().min(0).default(0), }) + .merge(LookupPaginationInputSchema) .optional() export const GetDeviceByIdSchema = z.object({ id: z.string().uuid() }) -export const GetDevicesByIdsSchema = z.object({ ids: z.array(z.string().uuid()).min(1).max(100) }) +export const GetDevicesByIdsSchema = z.object({ + ids: z.array(z.string().uuid()).min(1).max(LOOKUP_PAGINATION.MAX_LIMIT), +}) export const CreateDeviceSchema = z.object({ brandId: z.string().uuid(), diff --git a/src/schemas/gpu.ts b/src/schemas/gpu.ts deleted file mode 100644 index 122912d54..000000000 --- a/src/schemas/gpu.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { z } from 'zod' -import { SortDirectionSchema } from '@/schemas/common' - -export const GpuSortField = z.enum(['brand', 'modelName', 'pcListings']) - -export const GetGpusSchema = z - .object({ - search: z.string().optional(), - brandId: z.string().uuid().optional(), - limit: z.number().default(20), - offset: z.number().default(0), - page: z.number().optional(), - sortField: GpuSortField.optional(), - sortDirection: SortDirectionSchema.optional(), - }) - .optional() - -export const GetGpuOptionsSchema = z - .object({ - search: z.string().optional(), - brandId: z.string().uuid().optional(), - limit: z.number().int().min(1).max(10000).default(50), - offset: z.number().int().min(0).default(0), - }) - .optional() - -export const GetGpuByIdSchema = z.object({ id: z.string().uuid() }) -export const GetGpusByIdsSchema = z.object({ ids: z.array(z.string().uuid()).min(1).max(100) }) - -export const CreateGpuSchema = z.object({ - brandId: z.string().uuid(), - modelName: z.string().min(1), -}) - -export const UpdateGpuSchema = z.object({ - id: z.string().uuid(), - brandId: z.string().uuid(), - modelName: z.string().min(1), -}) - -export const DeleteGpuSchema = z.object({ id: z.string().uuid() }) - -export type GetGpusInput = z.input -export type GetGpuOptionsInput = z.input -export type CreateGpuInput = z.infer -export type UpdateGpuInput = z.infer -export type GetGpusByIdsInput = z.infer diff --git a/src/schemas/mobile.ts b/src/schemas/mobile.ts index 0606ff295..36d99a912 100644 --- a/src/schemas/mobile.ts +++ b/src/schemas/mobile.ts @@ -1,6 +1,7 @@ import { z } from 'zod' import { JsonValueSchema } from '@/schemas/common' import { CreateListingBaseSchema, CreatePcListingBaseSchema } from '@/schemas/listingCreate' +import { PaginationResultSchema } from '@/schemas/pagination' import { ReportReason, ReportStatus, PcOs, CustomFieldType, NotificationType } from '@orm' // Type-safe custom field value schema using discriminated union @@ -237,17 +238,6 @@ export const GetGamesSchema = z export type GetGamesInput = z.infer -// Response schemas for documentation generation -export const PaginationResultSchema = z.object({ - total: z.number(), - pages: z.number(), - page: z.number(), - offset: z.number(), - limit: z.number(), - hasNextPage: z.boolean(), - hasPreviousPage: z.boolean(), -}) - export const GameMobileSchema = z.object({ id: z.string().uuid(), title: z.string(), @@ -419,18 +409,6 @@ export const GetPcListingsSchema = z.object({ maxMemory: z.number().min(1).max(256).optional(), }) -export const GetCpusSchema = z.object({ - search: z.string().optional(), - brandId: z.string().uuid().optional(), - limit: z.number().min(1).max(100).default(50), -}) - -export const GetGpusSchema = z.object({ - search: z.string().optional(), - brandId: z.string().uuid().optional(), - limit: z.number().min(1).max(100).default(50), -}) - export const GetPcPresetsSchema = z.object({ limit: z.number().min(1).max(50).default(20), }) diff --git a/src/schemas/pagination.test.ts b/src/schemas/pagination.test.ts new file mode 100644 index 000000000..98879a25e --- /dev/null +++ b/src/schemas/pagination.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest' +import { LOOKUP_PAGINATION, PAGINATION } from '@/data/constants' +import { LookupPaginationInputSchema, PaginationInputSchema } from './pagination' + +describe('PaginationInputSchema', () => { + it('uses the shared pagination defaults', () => { + expect(PaginationInputSchema.parse({})).toEqual({ + limit: PAGINATION.DEFAULT_LIMIT, + offset: 0, + }) + }) + + it('rejects limits above the shared pagination maximum', () => { + expect(() => PaginationInputSchema.parse({ limit: PAGINATION.MAX_LIMIT + 1 })).toThrow() + }) +}) + +describe('LookupPaginationInputSchema', () => { + it('uses the shared lookup defaults', () => { + expect(LookupPaginationInputSchema.parse({})).toEqual({ + limit: LOOKUP_PAGINATION.DEFAULT_LIMIT, + offset: 0, + }) + }) + + it('rejects limits above the shared lookup maximum', () => { + expect(() => + LookupPaginationInputSchema.parse({ limit: LOOKUP_PAGINATION.MAX_LIMIT + 1 }), + ).toThrow() + }) +}) diff --git a/src/schemas/pagination.ts b/src/schemas/pagination.ts new file mode 100644 index 000000000..038f8771e --- /dev/null +++ b/src/schemas/pagination.ts @@ -0,0 +1,51 @@ +import { z } from 'zod' +import { LOOKUP_PAGINATION, PAGINATION } from '@/data/constants' + +type PaginationInputSchemaOptions = { + defaultLimit?: number + maxLimit?: number +} + +export const PaginationResultSchema = z.object({ + total: z.number().int().min(0), + pages: z.number().int().min(0), + page: z.number().int().positive(), + offset: z.number().int().min(0), + limit: z.number().int().positive(), + hasNextPage: z.boolean(), + hasPreviousPage: z.boolean(), +}) + +function createPaginationInputSchema(options: PaginationInputSchemaOptions = {}) { + const defaultLimit = options.defaultLimit ?? PAGINATION.DEFAULT_LIMIT + const maxLimit = options.maxLimit ?? PAGINATION.MAX_LIMIT + + return z.object({ + limit: z.number().int().min(1).max(maxLimit).default(defaultLimit), + offset: z.number().int().min(0).default(0), + page: z.number().int().positive().optional(), + }) +} + +function createOffsetPaginationInputSchema(options: PaginationInputSchemaOptions = {}) { + const defaultLimit = options.defaultLimit ?? PAGINATION.DEFAULT_LIMIT + const maxLimit = options.maxLimit ?? PAGINATION.MAX_LIMIT + + return z.object({ + limit: z.number().int().min(1).max(maxLimit).default(defaultLimit), + offset: z.number().int().min(0).default(0), + }) +} + +export const PaginationInputSchema = createPaginationInputSchema() +export const LookupPaginationInputSchema = createOffsetPaginationInputSchema({ + defaultLimit: LOOKUP_PAGINATION.DEFAULT_LIMIT, + maxLimit: LOOKUP_PAGINATION.MAX_LIMIT, +}) + +export type PaginationResult = z.output +export type PaginationInput = z.input +export type PaginatedResponse = { + items: T[] + pagination: PaginationResult +} diff --git a/src/schemas/soc.ts b/src/schemas/soc.ts index 7ed6bd4b5..0ae44a509 100644 --- a/src/schemas/soc.ts +++ b/src/schemas/soc.ts @@ -1,5 +1,7 @@ import { z } from 'zod' +import { LOOKUP_PAGINATION } from '@/data/constants' import { SortDirectionSchema } from '@/schemas/common' +import { LookupPaginationInputSchema } from '@/schemas/pagination' export const SoCSortField = z.enum(['name', 'manufacturer', 'devicesCount']) @@ -17,9 +19,8 @@ export const GetSoCsSchema = z export const GetSoCOptionsSchema = z .object({ search: z.string().optional(), - limit: z.number().int().min(1).max(10000).default(50), - offset: z.number().int().min(0).default(0), }) + .merge(LookupPaginationInputSchema) .optional() export const GetSoCByIdSchema = z.object({ @@ -41,7 +42,9 @@ export const DeleteSoCSchema = z.object({ id: z.string().uuid(), }) -export const GetSoCsByIdsSchema = z.object({ ids: z.array(z.string().uuid()).min(1).max(100) }) +export const GetSoCsByIdsSchema = z.object({ + ids: z.array(z.string().uuid()).min(1).max(LOOKUP_PAGINATION.MAX_LIMIT), +}) export type GetSoCsInput = z.input export type GetSoCOptionsInput = z.input diff --git a/src/scripts/api/generate-api-docs.ts b/src/scripts/api/generate-api-docs.ts index 25f62811a..f72031315 100644 --- a/src/scripts/api/generate-api-docs.ts +++ b/src/scripts/api/generate-api-docs.ts @@ -3,8 +3,8 @@ import { readdirSync, readFileSync, writeFileSync } from 'fs' import { join } from 'path' import { zodToJsonSchema } from 'zod-to-json-schema' -import * as mobileSchemas from '@/schemas/mobile' -import * as mobileAuthSchemas from '@/schemas/mobileAuth' +import { getMobileApiSchema } from './mobile-schema-registry' +import type { z } from 'zod' interface SwaggerEndpoint { path: string @@ -24,229 +24,12 @@ interface RouterInfo { name: string type: 'query' | 'mutation' input?: string + output?: string auth: 'public' | 'protected' description?: string - returnStructure?: string }[] } -/** - * Extracts return type annotation from procedure code - * E.g., `: Promise` -> 'DeviceCompatibilityResponse' - */ -function extractReturnType(procedureBlock: string): string | null { - const returnTypeMatch = procedureBlock.match(/:\s*Promise<(\w+)>/) - if (returnTypeMatch) { - return returnTypeMatch[1] - } - return null -} - -function analyzeReturnStructure(filePath: string, procedureName: string): string { - try { - const content = readFileSync(filePath, 'utf-8') - - // Find the procedure by looking for the procedure name and analyzing its block - const startIndex = content.indexOf(`${procedureName}:`) - if (startIndex === -1) return 'unknown' - - // Find enough of the procedure to extract return type annotation - // Look for the opening of the query/mutation function (where return type is declared) - const queryOrMutationStart = content.substring(startIndex).search(/\.(query|mutation)\s*\(/) - - if (queryOrMutationStart === -1) return 'unknown' - - const signatureEnd = startIndex + queryOrMutationStart + 300 - const procedureBlock = content.substring(startIndex, Math.min(signatureEnd, content.length)) - - const returnType = extractReturnType(procedureBlock) - if (returnType) { - const schemaName = `${returnType}Schema` - const schema = - (mobileSchemas as Record)[schemaName] || - (mobileAuthSchemas as Record)[schemaName] - if (schema) return `schema:${returnType}` - } - - return 'generic-object' - - // Fallback pattern matching (disabled to prevent documentation inconsistencies) - // If you need to re-enable this, uncomment the code below and remove the early return above - /* - if (procedureBlock.includes('ctx.prisma') && procedureBlock.includes('findMany')) { - if (procedureBlock.includes('_count') && procedureBlock.includes('include')) { - return 'array-with-relations-and-counts' - } else if (procedureBlock.includes('include')) { - return 'array-with-relations' - } else { - return 'array-simple' - } - } - - if (procedureBlock.includes('ctx.prisma') && procedureBlock.includes('findUnique')) { - return procedureBlock.includes('include') ? 'object-with-relations' : 'object-simple' - } - - if (procedureBlock.includes('pagination') || procedureBlock.includes('total')) { - return 'paginated-list' - } - - if (procedureBlock.includes('create') || procedureBlock.includes('update')) { - return 'mutation-result' - } - - if (procedureBlock.includes('count')) { - return 'count-result' - } - - // Analyze router context to infer likely structure - if ( - filePath.includes('games') && - procedureName.startsWith('get') && - !procedureName.includes('ById') - ) { - return 'array-with-relations-and-counts' - } - if (filePath.includes('listings') && procedureName === 'getListings') { - return 'paginated-list' - } - if (procedureName.includes('ById')) { - return 'object-with-relations' - } - - return 'generic-object' - */ - } catch (error) { - console.warn( - `Could not analyze return structure for ${procedureName}:`, - error instanceof Error ? error.message : String(error), - ) - return 'unknown' - } -} - -function generateResponseExampleByStructure( - routerName: string, - procedureName: string, - structure: string, -): unknown { - if (structure.startsWith('schema:')) { - const returnType = structure.replace('schema:', '') - const schemaName = `${returnType}Schema` - const schema = - (mobileSchemas as Record)[schemaName] || - (mobileAuthSchemas as Record)[schemaName] - - if (schema) { - try { - const jsonSchema = zodToJsonSchema(schema as never, schemaName) as Record - return generateExampleFromSchema(jsonSchema) - } catch (error) { - console.warn(`Failed to generate example from schema ${schemaName}:`, error) - } - } - } - - // Fallback: Use structure analysis to generate examples for common patterns - switch (structure) { - case 'array-with-relations-and-counts': - return createArrayWithRelationsAndCounts(routerName, procedureName) - case 'array-with-relations': - return createArrayWithRelations(routerName, procedureName) - case 'array-simple': - return createSimpleArray(routerName, procedureName) - case 'object-with-relations': - return createObjectWithRelations(routerName, procedureName) - case 'object-simple': - return createSimpleObject(routerName, procedureName) - case 'paginated-list': - return createPaginatedList(routerName, procedureName) - case 'mutation-result': - return createMutationResult(routerName, procedureName) - case 'count-result': - return { count: 42 } - default: - return createGenericResponse(routerName, procedureName) - } -} - -function createArrayWithRelationsAndCounts(routerName: string, _procedureName: string): unknown { - const baseItem = getBaseItemStructure(routerName) - return [ - { - ...baseItem, - ...getRelationsForRouter(routerName), - _count: getCountStructure(routerName), - }, - ] -} - -function createArrayWithRelations(routerName: string, _procedureName: string): unknown { - const baseItem = getBaseItemStructure(routerName) - return [ - { - ...baseItem, - ...getRelationsForRouter(routerName), - }, - ] -} - -function createSimpleArray(routerName: string, _procedureName: string): unknown { - return [getBaseItemStructure(routerName)] -} - -function createObjectWithRelations(routerName: string, _procedureName: string): unknown { - const baseItem = getBaseItemStructure(routerName) - return { - ...baseItem, - ...getRelationsForRouter(routerName), - } -} - -function createSimpleObject(routerName: string, _procedureName: string): unknown { - return getBaseItemStructure(routerName) -} - -function createPaginatedList(routerName: string, _procedureName: string): unknown { - return { - [getPluralName(routerName)]: [ - { - ...getBaseItemStructure(routerName), - ...getRelationsForRouter(routerName), - _count: getCountStructure(routerName), - }, - ], - pagination: { - total: 156, - pages: 8, - page: 1, - limit: 20, - hasNextPage: true, - hasPreviousPage: false, - }, - } -} - -function createMutationResult(routerName: string, procedureName: string): unknown { - if (procedureName.startsWith('create')) { - return { - id: 'uuid-generated', - message: 'Created successfully', - ...getBaseItemStructure(routerName), - } - } - if (procedureName.startsWith('update')) { - return { - id: 'uuid-updated', - message: 'Updated successfully', - } - } - if (procedureName.startsWith('delete')) { - return { success: true, message: 'Deleted successfully' } - } - return { success: true } -} - function createGenericResponse(routerName: string, procedureName: string): unknown { return { message: `Response from ${routerName}.${procedureName}`, @@ -300,108 +83,167 @@ function getBaseItemStructure(routerName: string): Record { return structures[routerName] || { id: 'uuid-generic', name: 'Generic Item' } } -function getRelationsForRouter(routerName: string): Record { - const relations: Record> = { - games: { - system: { - id: 'uuid-system', - name: 'Nintendo Entertainment System', - key: 'nes', - }, - }, - listings: { - game: { id: 'uuid-game', title: 'Super Mario Bros' }, - device: { - id: 'uuid-device', - modelName: 'Steam Deck', - brand: { name: 'Valve' }, - }, - emulator: { id: 'uuid-emulator', name: 'RetroArch' }, - performance: { id: 1, label: 'Perfect', rank: 1 }, - author: { id: 'uuid-user', name: 'GameTester' }, - }, - devices: { - brand: { id: 'uuid-brand', name: 'Valve' }, - soc: { id: 'uuid-soc', name: 'AMD APU' }, - }, - } +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +type DefinitionRef = { + ref: string + value: unknown +} - return relations[routerName] || {} +function decodeJsonPointerSegment(segment: string): string { + return segment.replace(/~1/g, '/').replace(/~0/g, '~') } -function getCountStructure(routerName: string): Record { - const counts: Record> = { - games: { listings: 45 }, - listings: { votes: 12, comments: 3 }, - devices: { listings: 28 }, +function resolveDefinitionRef( + ref: unknown, + definitions: Record, +): DefinitionRef | null { + if (typeof ref !== 'string') return null + if (!ref.startsWith('#/definitions/')) return null + + let current: unknown = definitions + const segments = ref + .slice('#/definitions/'.length) + .split('/') + .map((segment) => decodeJsonPointerSegment(segment)) + + for (const segment of segments) { + if (Array.isArray(current)) { + const index = Number(segment) + if (!Number.isInteger(index)) return null + current = current[index] + continue + } + + if (!isRecord(current)) return null + current = current[segment] } - return counts[routerName] || {} + return { ref, value: current } } -function getPluralName(routerName: string): string { - const plurals: Record = { - game: 'games', - listing: 'listings', - device: 'devices', - emulator: 'emulators', - notification: 'notifications', +function cloneJsonSchema(schema: Record): Record { + const cloned: unknown = JSON.parse(JSON.stringify(schema)) + return isRecord(cloned) ? cloned : {} +} + +function resolveDefinitionRefs( + value: unknown, + definitions: Record, + seenRefs = new Set(), +): unknown { + if (Array.isArray(value)) { + return value.map((item) => resolveDefinitionRefs(item, definitions, seenRefs)) + } + + if (!isRecord(value)) return value + + const definitionRef = resolveDefinitionRef(value.$ref, definitions) + if (definitionRef) { + if (seenRefs.has(definitionRef.ref)) return {} + + const nextSeenRefs = new Set(seenRefs) + nextSeenRefs.add(definitionRef.ref) + + const resolvedDefinition = resolveDefinitionRefs(definitionRef.value, definitions, nextSeenRefs) + const siblingEntries = Object.entries(value).filter( + ([key]) => key !== '$ref' && key !== '$schema' && key !== 'definitions', + ) + + if (isRecord(resolvedDefinition)) { + return resolveDefinitionRefs( + { + ...resolvedDefinition, + ...Object.fromEntries(siblingEntries), + }, + definitions, + nextSeenRefs, + ) + } + + return resolvedDefinition + } + + const resolved: Record = {} + + for (const [key, childValue] of Object.entries(value)) { + if (key === '$schema' || key === 'definitions') continue + resolved[key] = resolveDefinitionRefs(childValue, definitions, seenRefs) } - return plurals[routerName] || `${routerName}s` + return resolved } -function generateExampleFromSchema(jsonSchema: Record): Record { - const example: Record = {} +function resolveReferencedSchema(jsonSchema: Record): Record { + if (!isRecord(jsonSchema.definitions)) return jsonSchema - // Handle direct properties - let properties = jsonSchema.properties as Record> | undefined - let required = jsonSchema.required as string[] | undefined - - // Handle $ref definitions - if (!properties && jsonSchema.definitions && jsonSchema.$ref) { - const refName = (jsonSchema.$ref as string).split('/').pop() - if (refName) { - const definitions = jsonSchema.definitions as Record> - const definition = definitions[refName] - if (definition) { - properties = definition.properties as Record> - required = definition.required as string[] | undefined - } + const resolved = resolveDefinitionRefs(jsonSchema, jsonSchema.definitions) + + return isRecord(resolved) ? resolved : {} +} + +function generateScalarExample(propName: string, schema: Record): unknown { + const propType = schema.type as string | undefined + const format = schema.format as string | undefined + + switch (propType) { + case 'string': + if (format === 'uuid') return 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx' + if (propName.toLowerCase().includes('search')) return 'mario' + return 'example' + case 'number': + case 'integer': + if (propName === 'limit') return 10 + if (propName === 'page') return 1 + return schema.default ?? 1 + case 'boolean': + return schema.default ?? false + default: + return schema.default + } +} + +function generateExampleFromSchema(jsonSchema: Record): unknown { + const resolvedSchema = resolveReferencedSchema(jsonSchema) + const schemaType = resolvedSchema.type as string | undefined + + if (schemaType === 'array') { + const items = resolvedSchema.items + if (items && typeof items === 'object' && !Array.isArray(items)) { + return [generateExampleFromSchema(items as Record)] } + + return [] } + if (schemaType && schemaType !== 'object' && !resolvedSchema.properties) { + return generateScalarExample('', resolvedSchema) + } + + const example: Record = {} + + // Handle direct properties + const properties = resolvedSchema.properties as + | Record> + | undefined + const required = resolvedSchema.required as string[] | undefined + if (!properties) return {} for (const [propName, propSchema] of Object.entries(properties)) { const isRequired = required?.includes(propName) || false const propType = propSchema.type as string - const format = propSchema.format as string | undefined // Only include required fields and some common optional ones in examples if (isRequired || ['search', 'limit', 'page'].includes(propName)) { switch (propType) { case 'string': - if (format === 'uuid') { - example[propName] = 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx' - } else if (propName.toLowerCase().includes('search')) { - example[propName] = 'mario' - } else { - example[propName] = 'example' - } - break case 'number': case 'integer': - if (propName === 'limit') { - example[propName] = 10 - } else if (propName === 'page') { - example[propName] = 1 - } else { - example[propName] = propSchema.default ?? 1 - } - break case 'boolean': - example[propName] = propSchema.default ?? false + example[propName] = generateScalarExample(propName, propSchema) break case 'array': // Handle array types @@ -423,7 +265,12 @@ function generateExampleFromSchema(jsonSchema: Record): Record< } else if (itemType === 'object') { // Recursively generate example for nested object const nestedExample = generateExampleFromSchema(items) - example[propName] = Object.keys(nestedExample).length > 0 ? [nestedExample] : [] + example[propName] = + typeof nestedExample === 'object' && + nestedExample !== null && + Object.keys(nestedExample).length > 0 + ? [nestedExample] + : [] } else { example[propName] = [] } @@ -434,7 +281,11 @@ function generateExampleFromSchema(jsonSchema: Record): Record< case 'object': // Recursively generate example for nested object const nestedObjExample = generateExampleFromSchema(propSchema) - if (Object.keys(nestedObjExample).length > 0) { + if ( + typeof nestedObjExample === 'object' && + nestedObjExample !== null && + Object.keys(nestedObjExample).length > 0 + ) { example[propName] = nestedObjExample } break @@ -456,9 +307,10 @@ function extractRouterInfo(filePath: string): RouterInfo | null { const procedures: RouterInfo['procedures'] = [] - // Extract procedure definitions - handle multiline patterns + // Extract explicit tRPC procedure chains. The docs generator only trusts schemas declared + // in .input(...) and .output(...); response examples for uncontracted procedures stay generic. const procedureRegex = - /(\w+):\s*(mobilePublicProcedure|mobileProtectedProcedure)\s*(?:\.input\((\w+)\))?\s*\.(query|mutation)/g + /(\w+):\s*(mobilePublicProcedure|mobileProtectedProcedure)([\s\S]*?)\.(query|mutation)\s*\(/g let match // First, find where nested routers are defined @@ -503,7 +355,9 @@ function extractRouterInfo(filePath: string): RouterInfo | null { } while ((match = procedureRegex.exec(content)) !== null) { - const [, name, authType, inputSchema, type] = match + const [, name, authType, procedureChain, type] = match + const inputSchema = procedureChain.match(/\.input\((\w+)\)/)?.[1] + const outputSchema = procedureChain.match(/\.output\((\w+)\)/)?.[1] // Check if this procedure is inside a nested router let isInNestedRouter = false @@ -517,33 +371,15 @@ function extractRouterInfo(filePath: string): RouterInfo | null { // Skip procedures that are inside nested routers if (isInNestedRouter) continue - // Extract JSDoc comment for this procedure - const beforeProcedure = content.substring(0, match.index) - const lastCommentMatch = beforeProcedure.match(/\/\*\*[\s\S]*?\*\//g) - let description = lastCommentMatch - ? lastCommentMatch[lastCommentMatch.length - 1] - .replace(/\/\*\*|\*\//g, '') // Remove /** and */ - .replace(/^\s*\*\s?/gm, '') // Remove leading * from each line - .trim() - .replace(/\n\s*\n/g, '\n') // Remove empty lines - .replace(/\n/g, ' ') // Join lines with space - : undefined - - // Skip comments that are clearly for nested routers, not procedures - if (description && description.toLowerCase().includes('nested router')) { - description = undefined - } - - // Use the JSDoc description as is, since we're now excluding nested router procedures - const finalDescription = description + const description = extractAdjacentJsDoc(content, match.index) procedures.push({ name, type: type as 'query' | 'mutation', input: inputSchema, + output: outputSchema, auth: authType === 'mobileProtectedProcedure' ? 'protected' : 'public', - description: finalDescription || description, - returnStructure: analyzeReturnStructure(filePath, name), + description, }) } @@ -557,31 +393,43 @@ function extractRouterInfo(filePath: string): RouterInfo | null { } } +function extractAdjacentJsDoc(content: string, procedureIndex: number): string | undefined { + const beforeProcedure = content.substring(0, procedureIndex) + const commentEnd = beforeProcedure.lastIndexOf('*/') + if (commentEnd === -1) return undefined + + const trailingContent = beforeProcedure.slice(commentEnd + 2) + if (trailingContent.trim() !== '') return undefined + + const commentStart = beforeProcedure.lastIndexOf('/**', commentEnd) + if (commentStart === -1) return undefined + + const description = beforeProcedure + .slice(commentStart, commentEnd + 2) + .replace(/\/\*\*|\*\//g, '') + .replace(/^\s*\*\s?/gm, '') + .trim() + .replace(/\n\s*\n/g, '\n') + .replace(/\n/g, ' ') + + if (description.toLowerCase().includes('nested router')) return undefined + + return description +} + /** * Convert JSON Schema Draft 7 to OpenAPI 3.0 compatible format * Handles nullable types properly for OpenAPI 3.0 */ function convertJsonSchemaToOpenApi30(schema: Record): Record { - // Deep clone to avoid mutating original - const converted = JSON.parse(JSON.stringify(schema)) as Record - - // If schema has definitions with a $ref pointing to it, flatten it - if (converted.definitions && converted.$ref) { - const refPath = (converted.$ref as string).split('/').pop() - const definitions = converted.definitions as Record - if (refPath && definitions[refPath]) { - const definition = definitions[refPath] as Record - // Copy all properties from the definition to the root - Object.assign(converted, definition) - // Remove JSON Schema specific properties - delete converted.definitions - delete converted.$ref - delete converted.$schema - } - } + const cloned = cloneJsonSchema(schema) + const definitions = isRecord(cloned.definitions) ? cloned.definitions : {} + const resolved = resolveDefinitionRefs(cloned, definitions) + const converted = isRecord(resolved) ? resolved : {} // Remove JSON Schema specific properties that aren't valid in OpenAPI delete converted.$schema + delete converted.definitions function processSchema(obj: Record): void { // Handle array type format (OpenAPI 3.1) to nullable format (OpenAPI 3.0) @@ -617,15 +465,12 @@ function convertJsonSchemaToOpenApi30(schema: Record): Record) } else if ( - !Array.isArray(value) && + Array.isArray(value) && (key === 'allOf' || key === 'anyOf' || key === 'oneOf') ) { - // Process schemas in these arrays - if (Array.isArray(value)) { - for (const item of value) { - if (item && typeof item === 'object') { - processSchema(item as Record) - } + for (const item of value) { + if (item && typeof item === 'object' && !Array.isArray(item)) { + processSchema(item as Record) } } } else if (!Array.isArray(value) && typeof value === 'object' && key !== 'definitions') { @@ -639,6 +484,20 @@ function convertJsonSchemaToOpenApi30(schema: Record): Record { + return zodToJsonSchema(schema, schemaName) as Record +} + +function addComponentSchema( + schemas: Record, + schemaName: string, + schema: z.ZodTypeAny, +): Record { + const jsonSchema = toJsonSchema(schema, schemaName) + schemas[schemaName] = convertJsonSchemaToOpenApi30(jsonSchema) + return jsonSchema +} + function generateSwaggerEndpoints(routerInfos: RouterInfo[]): { endpoints: SwaggerEndpoint[] schemas: Record @@ -658,16 +517,10 @@ function generateSwaggerEndpoints(routerInfos: RouterInfo[]): { if (procedure.input) { const schemaName = procedure.input - const schema = - (mobileSchemas as Record)[schemaName] || - (mobileAuthSchemas as Record)[schemaName] + const schema = getMobileApiSchema(schemaName) if (schema) { - const jsonSchema = zodToJsonSchema(schema as never, schemaName) as Record - - // Convert to OpenAPI 3.0 format (handles nullable properly) - // Add schema to components/schemas - schemas[schemaName] = convertJsonSchemaToOpenApi30(jsonSchema) + const jsonSchema = addComponentSchema(schemas, schemaName, schema) if (method === 'post') { // Mutations use POST with request body @@ -684,8 +537,9 @@ function generateSwaggerEndpoints(routerInfos: RouterInfo[]): { } else { // Queries use GET with input query parameter containing JSON string const schemaExample = generateExampleFromSchema(jsonSchema) + const resolvedInputSchema = resolveReferencedSchema(jsonSchema) const hasRequiredFields = - jsonSchema.required && (jsonSchema.required as string[]).length > 0 + Array.isArray(resolvedInputSchema.required) && resolvedInputSchema.required.length > 0 parameters = [ { @@ -704,6 +558,23 @@ function generateSwaggerEndpoints(routerInfos: RouterInfo[]): { } } + const outputSchemaName = procedure.output + const outputSchema = outputSchemaName ? getMobileApiSchema(outputSchemaName) : null + const outputJsonSchema = + outputSchemaName && outputSchema + ? addComponentSchema(schemas, outputSchemaName, outputSchema) + : null + const responseDataSchema = + outputSchemaName && outputSchema + ? { $ref: `#/components/schemas/${outputSchemaName}` } + : { + type: 'object', + description: `Response data from ${routerInfo.router}.${procedure.name}`, + } + const responseExample = outputJsonSchema + ? generateExampleFromSchema(outputJsonSchema) + : createGenericResponse(routerInfo.router, procedure.name) + // Build security requirement const security = procedure.auth === 'protected' ? [{ ClerkAuth: [] }] : [] @@ -727,10 +598,7 @@ function generateSwaggerEndpoints(routerInfos: RouterInfo[]): { type: 'object', description: 'tRPC result wrapper containing the actual response data', properties: { - data: { - type: 'object', - description: `Response data from ${routerInfo.router}.${procedure.name}`, - }, + data: responseDataSchema, }, }, }, @@ -741,11 +609,7 @@ function generateSwaggerEndpoints(routerInfos: RouterInfo[]): { summary: 'Successful response', value: { result: { - data: generateResponseExampleByStructure( - routerInfo.router, - procedure.name, - procedure.returnStructure || 'generic-object', - ), + data: responseExample, }, }, }, @@ -855,11 +719,11 @@ function generateOpenAPISpec(endpoints: SwaggerEndpoint[], schemas: Record[] = [ + commonSchemas, + cpuSchemas, + gpuSchemas, + mobileSchemas, + mobileAuthSchemas, + paginationSchemas, +] + +export function getMobileApiSchema(schemaName: string): z.ZodTypeAny | null { + for (const schemaModule of schemaModules) { + const schema = schemaModule[schemaName] + if (schema instanceof z.ZodType) return schema + } + + return null +} diff --git a/src/server/api/root.ts b/src/server/api/root.ts index 506b1962e..7f6cfbb78 100644 --- a/src/server/api/root.ts +++ b/src/server/api/root.ts @@ -1,3 +1,5 @@ +import { cpuRouter } from '@/features/hardware/cpu/server/cpu.router' +import { gpuRouter } from '@/features/hardware/gpu/server/gpu.router' import { createTRPCRouter } from '@/server/api/trpc' import { accountRouter } from './routers/account' import { activityRouter } from './routers/admin/activity' @@ -8,7 +10,6 @@ import { apiKeysRouter } from './routers/apiKeys' import { auditLogsRouter } from './routers/auditLogs' import { badgesRouter } from './routers/badges' import { bookmarksRouter } from './routers/bookmarks' -import { cpusRouter } from './routers/cpus' import { customFieldCategoryRouter } from './routers/customFieldCategories' import { customFieldDefinitionRouter } from './routers/customFieldDefinitions' import { customFieldTemplateRouter } from './routers/customFieldTemplates' @@ -18,7 +19,6 @@ import { emulatorsRouter } from './routers/emulators' import { entitlementsRouter } from './routers/entitlements' import { gameFollowsRouter } from './routers/gameFollows' import { gamesRouter } from './routers/games' -import { gpusRouter } from './routers/gpus' import { igdbRouter } from './routers/igdb' import { listingReportsRouter } from './routers/listingReports' import { listingsRouter } from './routers/listings' @@ -51,8 +51,8 @@ export const appRouter = createTRPCRouter({ pcListingReports: pcListingReportsRouter, apiKeys: apiKeysRouter, devices: devicesRouter, - cpus: cpusRouter, - gpus: gpusRouter, + cpus: cpuRouter, + gpus: gpuRouter, deviceBrands: deviceBrandsRouter, socs: socsRouter, games: gamesRouter, diff --git a/src/server/api/routers/cpus.ts b/src/server/api/routers/cpus.ts deleted file mode 100644 index 0c8d637eb..000000000 --- a/src/server/api/routers/cpus.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { ResourceError } from '@/lib/errors' -import { - CreateCpuSchema, - DeleteCpuSchema, - GetCpuByIdSchema, - GetCpuOptionsSchema, - GetCpusByIdsSchema, - GetCpusSchema, - UpdateCpuSchema, -} from '@/schemas/cpu' -import { - createTRPCRouter, - manageDevicesProcedure, - publicProcedure, - viewStatisticsProcedure, -} from '@/server/api/trpc' -import { CpusRepository } from '@/server/repositories/cpus.repository' - -export const cpusRouter = createTRPCRouter({ - get: publicProcedure.input(GetCpusSchema).query(async ({ ctx, input }) => { - const repository = new CpusRepository(ctx.prisma) - return repository.list(input ?? {}) - }), - - options: publicProcedure.input(GetCpuOptionsSchema).query(async ({ ctx, input }) => { - const repository = new CpusRepository(ctx.prisma) - return repository.options(input ?? {}) - }), - - byId: publicProcedure.input(GetCpuByIdSchema).query(async ({ ctx, input }) => { - const repository = new CpusRepository(ctx.prisma) - const cpu = await repository.byIdWithCounts(input.id) - return cpu ?? ResourceError.cpu.notFound() - }), - - getByIds: publicProcedure.input(GetCpusByIdsSchema).query(async ({ ctx, input }) => { - const repository = new CpusRepository(ctx.prisma) - return await repository.listByIds(input.ids) - }), - - create: manageDevicesProcedure.input(CreateCpuSchema).mutation(async ({ ctx, input }) => { - const repository = new CpusRepository(ctx.prisma) - const created = await repository.create(input) - return repository.byIdWithCounts(created.id) - }), - - update: manageDevicesProcedure.input(UpdateCpuSchema).mutation(async ({ ctx, input }) => { - const repository = new CpusRepository(ctx.prisma) - const { id, ...data } = input - - const updated = await repository.update(id, data) - return repository.byIdWithCounts(updated.id) - }), - - delete: manageDevicesProcedure.input(DeleteCpuSchema).mutation(async ({ ctx, input }) => { - const repository = new CpusRepository(ctx.prisma) - await repository.delete(input.id) - return { success: true } - }), - - stats: viewStatisticsProcedure.query(async ({ ctx }) => { - const [withListings, withoutListings] = await Promise.all([ - ctx.prisma.cpu.count({ where: { pcListings: { some: {} } } }), - ctx.prisma.cpu.count({ where: { pcListings: { none: {} } } }), - ]) - - return { - total: withListings + withoutListings, - withListings, - withoutListings, - } - }), -}) diff --git a/src/server/api/routers/gpus.ts b/src/server/api/routers/gpus.ts deleted file mode 100644 index 0ec78de93..000000000 --- a/src/server/api/routers/gpus.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { ResourceError } from '@/lib/errors' -import { - CreateGpuSchema, - DeleteGpuSchema, - GetGpuByIdSchema, - GetGpuOptionsSchema, - GetGpusByIdsSchema, - GetGpusSchema, - UpdateGpuSchema, -} from '@/schemas/gpu' -import { - createTRPCRouter, - manageDevicesProcedure, - publicProcedure, - viewStatisticsProcedure, -} from '@/server/api/trpc' -import { GpusRepository } from '@/server/repositories/gpus.repository' - -export const gpusRouter = createTRPCRouter({ - get: publicProcedure.input(GetGpusSchema).query(async ({ ctx, input }) => { - const repository = new GpusRepository(ctx.prisma) - return repository.list(input ?? {}) - }), - - options: publicProcedure.input(GetGpuOptionsSchema).query(async ({ ctx, input }) => { - const repository = new GpusRepository(ctx.prisma) - return repository.options(input ?? {}) - }), - - byId: publicProcedure.input(GetGpuByIdSchema).query(async ({ ctx, input }) => { - const repository = new GpusRepository(ctx.prisma) - const gpu = await repository.byIdWithCounts(input.id) - return gpu ?? ResourceError.gpu.notFound() - }), - - getByIds: publicProcedure.input(GetGpusByIdsSchema).query(async ({ ctx, input }) => { - const repository = new GpusRepository(ctx.prisma) - return await repository.listByIds(input.ids) - }), - - create: manageDevicesProcedure.input(CreateGpuSchema).mutation(async ({ ctx, input }) => { - const repository = new GpusRepository(ctx.prisma) - - const created = await repository.create(input) - return repository.byIdWithCounts(created.id) - }), - - update: manageDevicesProcedure.input(UpdateGpuSchema).mutation(async ({ ctx, input }) => { - const repository = new GpusRepository(ctx.prisma) - const { id, ...data } = input - - const updated = await repository.update(id, data) - return repository.byIdWithCounts(updated.id) - }), - - delete: manageDevicesProcedure.input(DeleteGpuSchema).mutation(async ({ ctx, input }) => { - const repository = new GpusRepository(ctx.prisma) - await repository.delete(input.id) - return { success: true } - }), - - stats: viewStatisticsProcedure.query(async ({ ctx }) => { - const [total, withListings, withoutListings] = await Promise.all([ - ctx.prisma.gpu.count(), - ctx.prisma.gpu.count({ where: { pcListings: { some: {} } } }), - ctx.prisma.gpu.count({ where: { pcListings: { none: {} } } }), - ]) - - return { - total, - withListings, - withoutListings, - } - }), -}) diff --git a/src/server/api/routers/mobile/cpus.test.ts b/src/server/api/routers/mobile/cpus.test.ts new file mode 100644 index 000000000..02331c2a4 --- /dev/null +++ b/src/server/api/routers/mobile/cpus.test.ts @@ -0,0 +1,113 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { CPU_MOBILE_LIST_SELECT } from '@/features/hardware/cpu/server/persistence/cpu.prisma' +import { prisma } from '@/server/db' + +vi.unmock('@/server/api/mobileContext') + +const mockPrisma = vi.hoisted(() => ({ + cpu: { + count: vi.fn(), + findMany: vi.fn(), + findUnique: vi.fn(), + }, +})) + +vi.mock('@/server/db', () => ({ prisma: mockPrisma })) + +vi.mock('@/schemas/apiAccess', () => ({ + CreateApiKeySchema: {}, + GetApiKeyUsageSchema: {}, + ListApiKeysSchema: {}, + RevokeApiKeySchema: {}, + UpdateApiKeySchema: {}, +})) + +vi.mock('@/server/repositories/api-keys.repository', () => ({ + ApiKeysRepository: vi.fn().mockImplementation(function MockApiKeysRepository() { + return {} + }), +})) + +const { mobileCpusRouter } = await import('./cpus') + +const CPU_ID = '00000000-0000-4000-a000-000000000001' +const BRAND_ID = '00000000-0000-4000-a000-000000000002' +const CREATED_AT = new Date('2024-01-01T00:00:00.000Z') + +const cpuRecord = { + id: CPU_ID, + brandId: BRAND_ID, + modelName: 'Ryzen 7 7800X3D', + createdAt: CREATED_AT, + brand: { id: BRAND_ID, name: 'AMD' }, + _count: { pcListings: 7 }, +} + +function createCaller() { + return { + caller: mobileCpusRouter.createCaller({ + session: null, + prisma, + headers: new Headers(), + apiKey: null, + }), + } +} + +describe('mobileCpusRouter', () => { + beforeEach(() => { + mockPrisma.cpu.count.mockReset() + mockPrisma.cpu.findMany.mockReset() + mockPrisma.cpu.findUnique.mockReset() + }) + + it('returns the existing mobile CPU list compatibility shape', async () => { + const { caller } = createCaller() + mockPrisma.cpu.findMany.mockResolvedValueOnce([cpuRecord]) + mockPrisma.cpu.count.mockResolvedValueOnce(1) + + const result = await caller.get({ page: 1, limit: 20 }) + + expect(mockPrisma.cpu.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + select: CPU_MOBILE_LIST_SELECT, + }), + ) + expect(result.cpus[0]).toEqual(cpuRecord) + expect(result.cpus[0]).not.toHaveProperty('pcListingCount') + }) + + it('preserves the old mobile CPU list high-limit behavior', async () => { + const { caller } = createCaller() + mockPrisma.cpu.findMany.mockResolvedValueOnce([]) + mockPrisma.cpu.count.mockResolvedValueOnce(0) + + await caller.get({ page: 1, limit: 1000 }) + + expect(mockPrisma.cpu.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + take: 1000, + }), + ) + }) + + it('returns the existing mobile CPU detail compatibility shape', async () => { + const { caller } = createCaller() + mockPrisma.cpu.findUnique.mockResolvedValueOnce(cpuRecord) + + const result = await caller.getById({ id: CPU_ID }) + + expect(mockPrisma.cpu.findUnique).toHaveBeenCalledWith({ + where: { id: CPU_ID }, + select: CPU_MOBILE_LIST_SELECT, + }) + expect(result).toEqual(cpuRecord) + }) + + it('returns the existing CPU not-found error for missing getById results', async () => { + const { caller } = createCaller() + mockPrisma.cpu.findUnique.mockResolvedValueOnce(null) + + await expect(caller.getById({ id: CPU_ID })).rejects.toThrow('CPU not found') + }) +}) diff --git a/src/server/api/routers/mobile/cpus.ts b/src/server/api/routers/mobile/cpus.ts index 87fe17f0a..335b99ed1 100644 --- a/src/server/api/routers/mobile/cpus.ts +++ b/src/server/api/routers/mobile/cpus.ts @@ -1,23 +1,28 @@ -import { ResourceError } from '@/lib/errors' -import { GetCpusSchema, GetCpuByIdSchema } from '@/schemas/cpu' +import { createCpuService } from '@/features/hardware/cpu/server/cpu.service' +import { + GetCpuByIdSchema, + MobileCpuListItemSchema, + MobileCpuListResponseSchema, + MobileGetCpusSchema, +} from '@/features/hardware/cpu/shared/cpu.schemas' import { createMobileTRPCRouter, mobilePublicProcedure } from '@/server/api/mobileContext' -import { CpusRepository } from '@/server/repositories/cpus.repository' export const mobileCpusRouter = createMobileTRPCRouter({ /** - * Get CPUs with search, filtering, and pagination + * Get CPUs with search, filtering, and pagination. */ - get: mobilePublicProcedure.input(GetCpusSchema).query(async ({ ctx, input }) => { - const repository = new CpusRepository(ctx.prisma) - return repository.list(input ?? {}, { limited: true }) - }), + get: mobilePublicProcedure + .input(MobileGetCpusSchema) + .output(MobileCpuListResponseSchema) + .query(async ({ ctx, input }) => createCpuService(ctx.prisma).listMobileCompatibility(input)), /** - * Get CPU by ID + * Get CPU by ID. */ - getById: mobilePublicProcedure.input(GetCpuByIdSchema).query(async ({ ctx, input }) => { - const repository = new CpusRepository(ctx.prisma) - const cpu = await repository.byIdWithCounts(input.id, { limited: true }) - return cpu || ResourceError.cpu.notFound() - }), + getById: mobilePublicProcedure + .input(GetCpuByIdSchema) + .output(MobileCpuListItemSchema) + .query(async ({ ctx, input }) => + createCpuService(ctx.prisma).byIdMobileCompatibility(input.id), + ), }) diff --git a/src/server/api/routers/mobile/gpus.test.ts b/src/server/api/routers/mobile/gpus.test.ts new file mode 100644 index 000000000..cc37ae036 --- /dev/null +++ b/src/server/api/routers/mobile/gpus.test.ts @@ -0,0 +1,113 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { GPU_MOBILE_LIST_SELECT } from '@/features/hardware/gpu/server/persistence/gpu.prisma' +import { prisma } from '@/server/db' + +vi.unmock('@/server/api/mobileContext') + +const mockPrisma = vi.hoisted(() => ({ + gpu: { + count: vi.fn(), + findMany: vi.fn(), + findUnique: vi.fn(), + }, +})) + +vi.mock('@/server/db', () => ({ prisma: mockPrisma })) + +vi.mock('@/schemas/apiAccess', () => ({ + CreateApiKeySchema: {}, + GetApiKeyUsageSchema: {}, + ListApiKeysSchema: {}, + RevokeApiKeySchema: {}, + UpdateApiKeySchema: {}, +})) + +vi.mock('@/server/repositories/api-keys.repository', () => ({ + ApiKeysRepository: vi.fn().mockImplementation(function MockApiKeysRepository() { + return {} + }), +})) + +const { mobileGpusRouter } = await import('./gpus') + +const GPU_ID = '00000000-0000-4000-a000-000000000001' +const BRAND_ID = '00000000-0000-4000-a000-000000000002' +const CREATED_AT = new Date('2024-01-01T00:00:00.000Z') + +const gpuRecord = { + id: GPU_ID, + brandId: BRAND_ID, + modelName: 'GeForce RTX 4090', + createdAt: CREATED_AT, + brand: { id: BRAND_ID, name: 'NVIDIA' }, + _count: { pcListings: 7 }, +} + +function createCaller() { + return { + caller: mobileGpusRouter.createCaller({ + session: null, + prisma, + headers: new Headers(), + apiKey: null, + }), + } +} + +describe('mobileGpusRouter', () => { + beforeEach(() => { + mockPrisma.gpu.count.mockReset() + mockPrisma.gpu.findMany.mockReset() + mockPrisma.gpu.findUnique.mockReset() + }) + + it('returns the existing mobile GPU list compatibility shape', async () => { + const { caller } = createCaller() + mockPrisma.gpu.findMany.mockResolvedValueOnce([gpuRecord]) + mockPrisma.gpu.count.mockResolvedValueOnce(1) + + const result = await caller.get({ page: 1, limit: 20 }) + + expect(mockPrisma.gpu.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + select: GPU_MOBILE_LIST_SELECT, + }), + ) + expect(result.gpus[0]).toEqual(gpuRecord) + expect(result.gpus[0]).not.toHaveProperty('pcListingCount') + }) + + it('preserves the old mobile GPU list high-limit behavior', async () => { + const { caller } = createCaller() + mockPrisma.gpu.findMany.mockResolvedValueOnce([]) + mockPrisma.gpu.count.mockResolvedValueOnce(0) + + await caller.get({ page: 1, limit: 1000 }) + + expect(mockPrisma.gpu.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + take: 1000, + }), + ) + }) + + it('returns the existing mobile GPU detail compatibility shape', async () => { + const { caller } = createCaller() + mockPrisma.gpu.findUnique.mockResolvedValueOnce(gpuRecord) + + const result = await caller.getById({ id: GPU_ID }) + + expect(mockPrisma.gpu.findUnique).toHaveBeenCalledWith({ + where: { id: GPU_ID }, + select: GPU_MOBILE_LIST_SELECT, + }) + expect(result).toEqual(gpuRecord) + }) + + it('returns the existing GPU not-found error for missing getById results', async () => { + const { caller } = createCaller() + mockPrisma.gpu.findUnique.mockResolvedValueOnce(null) + + await expect(caller.getById({ id: GPU_ID })).rejects.toThrow('GPU not found') + }) +}) diff --git a/src/server/api/routers/mobile/gpus.ts b/src/server/api/routers/mobile/gpus.ts index 1192ebe9f..2e6213725 100644 --- a/src/server/api/routers/mobile/gpus.ts +++ b/src/server/api/routers/mobile/gpus.ts @@ -1,23 +1,28 @@ -import { ResourceError } from '@/lib/errors' -import { GetGpusSchema, GetGpuByIdSchema } from '@/schemas/gpu' +import { createGpuService } from '@/features/hardware/gpu/server/gpu.service' +import { + GetGpuByIdSchema, + MobileGetGpusSchema, + MobileGpuListItemSchema, + MobileGpuListResponseSchema, +} from '@/features/hardware/gpu/shared/gpu.schemas' import { createMobileTRPCRouter, mobilePublicProcedure } from '@/server/api/mobileContext' -import { GpusRepository } from '@/server/repositories/gpus.repository' export const mobileGpusRouter = createMobileTRPCRouter({ /** - * Get GPUs with search, filtering, and pagination + * Get GPUs with search, filtering, and pagination. */ - get: mobilePublicProcedure.input(GetGpusSchema).query(async ({ ctx, input }) => { - const repository = new GpusRepository(ctx.prisma) - return repository.list(input ?? {}, { limited: true }) - }), + get: mobilePublicProcedure + .input(MobileGetGpusSchema) + .output(MobileGpuListResponseSchema) + .query(async ({ ctx, input }) => createGpuService(ctx.prisma).listMobileCompatibility(input)), /** - * Get GPU by ID + * Get GPU by ID. */ - getById: mobilePublicProcedure.input(GetGpuByIdSchema).query(async ({ ctx, input }) => { - const repository = new GpusRepository(ctx.prisma) - const gpu = await repository.byIdWithCounts(input.id, { limited: true }) - return gpu || ResourceError.gpu.notFound() - }), + getById: mobilePublicProcedure + .input(GetGpuByIdSchema) + .output(MobileGpuListItemSchema) + .query(async ({ ctx, input }) => + createGpuService(ctx.prisma).byIdMobileCompatibility(input.id), + ), }) diff --git a/src/server/api/routers/mobile/pcListings.cpus.test.ts b/src/server/api/routers/mobile/pcListings.cpus.test.ts new file mode 100644 index 000000000..2c937da53 --- /dev/null +++ b/src/server/api/routers/mobile/pcListings.cpus.test.ts @@ -0,0 +1,87 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { PAGINATION } from '@/data/constants' +import { CPU_MOBILE_PC_LISTING_SELECT } from '@/features/hardware/cpu/server/persistence/cpu.prisma' +import { prisma } from '@/server/db' + +vi.unmock('@/server/api/mobileContext') + +const mockPrisma = vi.hoisted(() => ({ + cpu: { + findMany: vi.fn(), + }, +})) + +vi.mock('@/server/db', () => ({ prisma: mockPrisma })) + +vi.mock('@/schemas/apiAccess', () => ({ + CreateApiKeySchema: {}, + GetApiKeyUsageSchema: {}, + ListApiKeysSchema: {}, + RevokeApiKeySchema: {}, + UpdateApiKeySchema: {}, +})) + +vi.mock('@/server/repositories/api-keys.repository', () => ({ + ApiKeysRepository: vi.fn().mockImplementation(function MockApiKeysRepository() { + return {} + }), +})) + +const { mobilePcListingsRouter } = await import('./pcListings') + +const CPU_ID = '00000000-0000-4000-a000-000000000001' +const BRAND_ID = '00000000-0000-4000-a000-000000000002' +const CREATED_AT = new Date('2024-01-01T00:00:00.000Z') + +const cpuRecord = { + id: CPU_ID, + brandId: BRAND_ID, + modelName: 'Ryzen 7 7800X3D', + createdAt: CREATED_AT, + brand: { id: BRAND_ID, name: 'AMD' }, +} + +function createCaller() { + return { + caller: mobilePcListingsRouter.createCaller({ + session: null, + prisma, + headers: new Headers(), + apiKey: null, + }), + } +} + +describe('mobilePcListingsRouter CPU compatibility endpoint', () => { + beforeEach(() => { + mockPrisma.cpu.findMany.mockReset() + }) + + it('returns the existing PC listing CPU compatibility shape', async () => { + const { caller } = createCaller() + mockPrisma.cpu.findMany.mockResolvedValueOnce([cpuRecord]) + + const result = await caller.cpus({ search: 'Ryzen', limit: 100 }) + + expect(mockPrisma.cpu.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + select: CPU_MOBILE_PC_LISTING_SELECT, + orderBy: { modelName: 'asc' }, + take: 100, + }), + ) + expect(result).toEqual({ + cpus: [cpuRecord], + }) + expect(result).not.toHaveProperty('hasMore') + expect(result.cpus[0]).not.toHaveProperty('_count') + expect(result.cpus[0]).not.toHaveProperty('pcListingCount') + }) + + it('rejects CPU helper limits above the bounded PC listing contract', async () => { + const { caller } = createCaller() + + await expect(caller.cpus({ limit: PAGINATION.MAX_LIMIT + 1 })).rejects.toThrow() + expect(mockPrisma.cpu.findMany).not.toHaveBeenCalled() + }) +}) diff --git a/src/server/api/routers/mobile/pcListings.gpus.test.ts b/src/server/api/routers/mobile/pcListings.gpus.test.ts new file mode 100644 index 000000000..85cf84e49 --- /dev/null +++ b/src/server/api/routers/mobile/pcListings.gpus.test.ts @@ -0,0 +1,87 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { PAGINATION } from '@/data/constants' +import { GPU_MOBILE_PC_LISTING_SELECT } from '@/features/hardware/gpu/server/persistence/gpu.prisma' +import { prisma } from '@/server/db' + +vi.unmock('@/server/api/mobileContext') + +const mockPrisma = vi.hoisted(() => ({ + gpu: { + findMany: vi.fn(), + }, +})) + +vi.mock('@/server/db', () => ({ prisma: mockPrisma })) + +vi.mock('@/schemas/apiAccess', () => ({ + CreateApiKeySchema: {}, + GetApiKeyUsageSchema: {}, + ListApiKeysSchema: {}, + RevokeApiKeySchema: {}, + UpdateApiKeySchema: {}, +})) + +vi.mock('@/server/repositories/api-keys.repository', () => ({ + ApiKeysRepository: vi.fn().mockImplementation(function MockApiKeysRepository() { + return {} + }), +})) + +const { mobilePcListingsRouter } = await import('./pcListings') + +const GPU_ID = '00000000-0000-4000-a000-000000000001' +const BRAND_ID = '00000000-0000-4000-a000-000000000002' +const CREATED_AT = new Date('2024-01-01T00:00:00.000Z') + +const gpuRecord = { + id: GPU_ID, + brandId: BRAND_ID, + modelName: 'GeForce RTX 4090', + createdAt: CREATED_AT, + brand: { id: BRAND_ID, name: 'NVIDIA' }, +} + +function createCaller() { + return { + caller: mobilePcListingsRouter.createCaller({ + session: null, + prisma, + headers: new Headers(), + apiKey: null, + }), + } +} + +describe('mobilePcListingsRouter GPU compatibility endpoint', () => { + beforeEach(() => { + mockPrisma.gpu.findMany.mockReset() + }) + + it('returns the existing PC listing GPU compatibility shape', async () => { + const { caller } = createCaller() + mockPrisma.gpu.findMany.mockResolvedValueOnce([gpuRecord]) + + const result = await caller.gpus({ search: 'RTX', limit: 100 }) + + expect(mockPrisma.gpu.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + select: GPU_MOBILE_PC_LISTING_SELECT, + orderBy: { modelName: 'asc' }, + take: 100, + }), + ) + expect(result).toEqual({ + gpus: [gpuRecord], + }) + expect(result).not.toHaveProperty('hasMore') + expect(result.gpus[0]).not.toHaveProperty('_count') + expect(result.gpus[0]).not.toHaveProperty('pcListingCount') + }) + + it('rejects GPU helper limits above the bounded PC listing contract', async () => { + const { caller } = createCaller() + + await expect(caller.gpus({ limit: PAGINATION.MAX_LIMIT + 1 })).rejects.toThrow() + expect(mockPrisma.gpu.findMany).not.toHaveBeenCalled() + }) +}) diff --git a/src/server/api/routers/mobile/pcListings.ts b/src/server/api/routers/mobile/pcListings.ts index 43231062f..f32e973cb 100644 --- a/src/server/api/routers/mobile/pcListings.ts +++ b/src/server/api/routers/mobile/pcListings.ts @@ -1,12 +1,16 @@ +import { createCpuService } from '@/features/hardware/cpu/server/cpu.service' +import { + MobilePcListingCpuResponseSchema, + MobilePcListingCpusSchema, +} from '@/features/hardware/cpu/shared/cpu.schemas' +import { createGpuService } from '@/features/hardware/gpu/server/gpu.service' +import { + MobilePcListingGpuResponseSchema, + MobilePcListingGpusSchema, +} from '@/features/hardware/gpu/shared/gpu.schemas' import { ResourceError } from '@/lib/errors' import { applyTrustAction } from '@/lib/trust/service' -import { - CreatePcListingSchema, - GetCpusSchema, - GetGpusSchema, - GetPcListingsSchema, - UpdatePcListingSchema, -} from '@/schemas/mobile' +import { CreatePcListingSchema, GetPcListingsSchema, UpdatePcListingSchema } from '@/schemas/mobile' import { GetPcListingByIdSchema } from '@/schemas/pcListing' import { createMobileTRPCRouter, @@ -264,55 +268,22 @@ export const mobilePcListingsRouter = createMobileTRPCRouter({ }), /** - * Get CPUs for mobile + * Get CPUs for PC compatibility report filters. */ - cpus: mobilePublicProcedure.input(GetCpusSchema).query(async ({ ctx, input }) => { - const mode = Prisma.QueryMode.insensitive - - const where = { - ...(input.search && { - OR: [ - { modelName: { contains: input.search, mode } }, - { brand: { name: { contains: input.search, mode } } }, - ], - }), - ...(input.brandId && { brandId: input.brandId }), - } - - const cpus = await ctx.prisma.cpu.findMany({ - where, - take: input.limit, - orderBy: { modelName: 'asc' }, - include: { brand: { select: { id: true, name: true } } }, - }) - - return { cpus } - }), + cpus: mobilePublicProcedure + .input(MobilePcListingCpusSchema) + .output(MobilePcListingCpuResponseSchema) + .query(async ({ ctx, input }) => + createCpuService(ctx.prisma).pcListingMobileCpuCompatibility(input), + ), /** - * Get GPUs for mobile + * Get GPUs for PC compatibility report filters. */ - gpus: mobilePublicProcedure.input(GetGpusSchema).query(async ({ ctx, input }) => { - const mode = Prisma.QueryMode.insensitive - const { search, brandId, limit } = input - - const where = { - ...(search && { - OR: [ - { modelName: { contains: search, mode } }, - { brand: { name: { contains: search, mode } } }, - ], - }), - ...(brandId && { brandId }), - } - - const gpus = await ctx.prisma.gpu.findMany({ - where, - take: limit, - orderBy: { modelName: 'asc' }, - include: { brand: { select: { id: true, name: true } } }, - }) - - return { gpus } - }), + gpus: mobilePublicProcedure + .input(MobilePcListingGpusSchema) + .output(MobilePcListingGpuResponseSchema) + .query(async ({ ctx, input }) => + createGpuService(ctx.prisma).pcListingMobileGpuCompatibility(input), + ), }) diff --git a/src/server/auth/actor.test.ts b/src/server/auth/actor.test.ts new file mode 100644 index 000000000..76324bf26 --- /dev/null +++ b/src/server/auth/actor.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest' +import { PERMISSIONS } from '@/utils/permission-system' +import { Role } from '@orm/client' +import { createActorFromSession, requireActorPermission, requireUserActor } from './actor' + +const session = { + user: { + id: '00000000-0000-4000-a000-000000000001', + email: 'test@test.com', + name: 'Test User', + role: Role.ADMIN, + permissions: [PERMISSIONS.MANAGE_DEVICES], + showNsfw: true, + }, +} + +describe('actor', () => { + it('creates an anonymous actor from an empty session', () => { + expect(createActorFromSession(null)).toEqual({ type: 'anonymous' }) + }) + + it('creates a user actor from the authenticated session payload', () => { + expect(createActorFromSession(session)).toEqual({ + type: 'user', + userId: session.user.id, + role: Role.ADMIN, + permissions: [PERMISSIONS.MANAGE_DEVICES], + showNsfw: true, + }) + }) + + it('rejects user-only behavior for anonymous actors', () => { + expect(() => requireUserActor({ type: 'anonymous' })).toThrow( + 'You must be logged in to perform this action', + ) + }) + + it('returns the user actor when the required permission is present', () => { + const actor = createActorFromSession(session) + + expect(requireActorPermission(actor, PERMISSIONS.MANAGE_DEVICES)).toEqual(actor) + }) + + it('rejects missing permissions', () => { + const actor = createActorFromSession({ + user: { + ...session.user, + permissions: [], + }, + }) + + expect(() => requireActorPermission(actor, PERMISSIONS.MANAGE_DEVICES)).toThrow( + 'You need the following permissions: manage_devices', + ) + }) +}) diff --git a/src/server/auth/actor.ts b/src/server/auth/actor.ts new file mode 100644 index 000000000..a98148d25 --- /dev/null +++ b/src/server/auth/actor.ts @@ -0,0 +1,54 @@ +import { AppError } from '@/lib/errors' +import { hasPermission, type PermissionKey } from '@/utils/permission-system' +import { type Role } from '@orm/client' + +export type AnonymousActor = { + type: 'anonymous' +} + +export type UserActor = { + type: 'user' + userId: string + role: Role + permissions: string[] + showNsfw: boolean +} + +export type Actor = AnonymousActor | UserActor + +type SessionLike = { + user?: { + id: string + role: Role + permissions: string[] + showNsfw?: boolean | null + } +} | null + +export function createActorFromSession(session: SessionLike | undefined): Actor { + if (!session?.user) return { type: 'anonymous' } + + return { + type: 'user', + userId: session.user.id, + role: session.user.role, + permissions: session.user.permissions, + showNsfw: session.user.showNsfw ?? false, + } +} + +export function requireUserActor(actor: Actor): UserActor { + if (actor.type === 'anonymous') throw AppError.unauthorized() + + return actor +} + +export function requireActorPermission(actor: Actor, permission: PermissionKey): UserActor { + const user = requireUserActor(actor) + + if (!hasPermission(user.permissions, permission)) { + throw AppError.insufficientPermissions(permission) + } + + return user +} diff --git a/src/server/persistence/prisma.repository.test.ts b/src/server/persistence/prisma.repository.test.ts new file mode 100644 index 000000000..5d9d52dcc --- /dev/null +++ b/src/server/persistence/prisma.repository.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it, vi } from 'vitest' +import { prisma } from '@/server/db' +import { PrismaWriteRepository } from './prisma.repository' + +type TestWriteContext = { + action: 'write' +} + +const mockPrisma = vi.hoisted(() => ({})) + +vi.mock('@/server/db', () => ({ prisma: mockPrisma })) + +class TestRepository extends PrismaWriteRepository { + executeTestWrite(operation: () => Promise): Promise { + return this.executeWrite(operation, { action: 'write' }) + } + + protected translateWriteError(error: unknown, context: TestWriteContext): never { + if (error instanceof Error) { + throw new Error(`${context.action}: ${error.message}`) + } + + throw new Error(context.action) + } +} + +describe('PrismaWriteRepository', () => { + it('returns the write operation result', async () => { + const repository = new TestRepository(prisma) + + await expect( + repository.executeTestWrite(() => Promise.resolve({ id: 'cpu-id' })), + ).resolves.toEqual({ id: 'cpu-id' }) + }) + + it('delegates write failures to the repository translator', async () => { + const repository = new TestRepository(prisma) + + await expect( + repository.executeTestWrite(() => Promise.reject(new Error('failed'))), + ).rejects.toThrow('write: failed') + }) +}) diff --git a/src/server/persistence/prisma.repository.ts b/src/server/persistence/prisma.repository.ts new file mode 100644 index 000000000..7003d4684 --- /dev/null +++ b/src/server/persistence/prisma.repository.ts @@ -0,0 +1,23 @@ +import type { Prisma, PrismaClient } from '@orm/client' + +export type PrismaRepositoryClient = PrismaClient | Prisma.TransactionClient + +export abstract class PrismaRepository { + protected constructor(protected readonly prisma: PrismaRepositoryClient) {} +} + +export abstract class PrismaWriteRepository extends PrismaRepository { + constructor(prisma: PrismaRepositoryClient) { + super(prisma) + } + + protected async executeWrite(operation: () => Promise, context: WriteContext): Promise { + try { + return await operation() + } catch (error) { + this.translateWriteError(error, context) + } + } + + protected abstract translateWriteError(error: unknown, context: WriteContext): never +} diff --git a/src/server/repositories/api-keys.repository.ts b/src/server/repositories/api-keys.repository.ts index 82b8b2db0..8f3eae248 100644 --- a/src/server/repositories/api-keys.repository.ts +++ b/src/server/repositories/api-keys.repository.ts @@ -10,14 +10,10 @@ import { type ListApiKeysInput, type UpdateApiKeyQuotaInput, } from '@/schemas/apiAccess' -import { - calculateOffset, - paginate, - buildOrderBy, - type PaginationResult, -} from '@/server/utils/pagination' +import { calculateOffset, paginate, buildOrderBy } from '@/server/utils/pagination' import { Prisma, ApiUsagePeriod } from '@orm/client' import { BaseRepository } from './base.repository' +import type { PaginationResult } from '@/schemas/pagination' const USAGE_WINDOW_FACTORY: Record Date> = { [ApiUsagePeriod.MINUTE]: (now) => startOfMinute(now), diff --git a/src/server/repositories/comments.repository.ts b/src/server/repositories/comments.repository.ts index ca197cbd7..441f5cca3 100644 --- a/src/server/repositories/comments.repository.ts +++ b/src/server/repositories/comments.repository.ts @@ -1,8 +1,9 @@ import { PAGINATION } from '@/data/constants' -import { type PaginationResult, paginate, calculateOffset } from '@/server/utils/pagination' +import { paginate, calculateOffset } from '@/server/utils/pagination' import { roleIncludesRole } from '@/utils/permission-system' import { type Prisma, Role } from '@orm/client' import { BaseRepository } from './base.repository' +import type { PaginationResult } from '@/schemas/pagination' export interface CommentFilters { listingId?: string @@ -58,7 +59,7 @@ export class CommentsRepository extends BaseRepository { const where = this.buildWhereClause(filters) const orderBy = this.buildOrderBy(sortField, sortDirection) - const actualOffset = calculateOffset({ page, offset }, limit ?? 20) + const actualOffset = calculateOffset({ page, offset }, limit) const [total, comments] = await Promise.all([ this.prisma.comment.count({ where }), @@ -67,14 +68,14 @@ export class CommentsRepository extends BaseRepository { include: CommentsRepository.includes.default, orderBy, skip: actualOffset, - take: limit ?? 20, + take: limit, }), ]) const pagination = paginate({ - total: total, - page: page ?? Math.floor(actualOffset / (limit ?? 20)) + 1, - limit: limit ?? 20, + total, + page: page ?? Math.floor(actualOffset / limit) + 1, + limit, }) return { comments, pagination } diff --git a/src/server/repositories/cpus.repository.ts b/src/server/repositories/cpus.repository.ts deleted file mode 100644 index 0246db9cd..000000000 --- a/src/server/repositories/cpus.repository.ts +++ /dev/null @@ -1,326 +0,0 @@ -import { PAGINATION } from '@/data/constants' -import { ResourceError } from '@/lib/errors' -import { type PaginationResult, paginate, calculateOffset } from '@/server/utils/pagination' -import { Prisma } from '@orm/client' -import { BaseRepository } from './base.repository' -import type { - GetCpusInput, - GetCpuOptionsInput, - CreateCpuInput, - UpdateCpuInput, -} from '@/schemas/cpu' - -type CpuOptionFilters = NonNullable - -/** - * Repository for CPU data access - */ -export class CpusRepository extends BaseRepository { - // Static query shapes for this repository - static readonly includes = { - default: { - brand: true, - } satisfies Prisma.CpuInclude, - - limited: { - brand: { select: { id: true, name: true } }, - } satisfies Prisma.CpuInclude, - - withCounts: { - brand: true, - _count: { select: { pcListings: true } }, - } satisfies Prisma.CpuInclude, - - withCountsLimited: { - brand: { select: { id: true, name: true } }, - _count: { select: { pcListings: true } }, - } satisfies Prisma.CpuInclude, - } as const - - static readonly selects = { - option: { - id: true, - modelName: true, - brand: { select: { id: true, name: true } }, - } satisfies Prisma.CpuSelect, - } as const - - async byId( - id: string, - options: { limited?: boolean } = {}, - ): Promise | null> { - return this.prisma.cpu.findUnique({ - where: { id }, - include: options.limited ? CpusRepository.includes.limited : CpusRepository.includes.default, - }) - } - - /** - * Get CPU by ID with counts - */ - async byIdWithCounts( - id: string, - options: { limited?: boolean } = {}, - ): Promise | null> { - return this.prisma.cpu.findUnique({ - where: { id }, - include: options.limited - ? CpusRepository.includes.withCountsLimited - : CpusRepository.includes.withCounts, - }) - } - - async create( - data: CreateCpuInput, - ): Promise> { - // Validate brand exists - const brand = await this.prisma.deviceBrand.findUnique({ - where: { id: data.brandId }, - }) - if (!brand) throw ResourceError.deviceBrand.notFound() - - // Check for duplicate model name - const exists = await this.existsByModelName(data.modelName) - if (exists) throw ResourceError.cpu.alreadyExists(data.modelName) - - return this.prisma.cpu.create({ - data, - include: CpusRepository.includes.default, - }) - } - - async update( - id: string, - data: Partial, - ): Promise> { - const cpu = await this.byId(id) - if (!cpu) throw ResourceError.cpu.notFound() - - if (data.brandId) { - const brand = await this.prisma.deviceBrand.findUnique({ - where: { id: data.brandId }, - }) - if (!brand) throw ResourceError.deviceBrand.notFound() - } - - if (data.modelName) { - const exists = await this.existsByModelName(data.modelName, id) - if (exists) throw ResourceError.cpu.alreadyExists(data.modelName) - } - - return this.prisma.cpu.update({ - where: { id }, - data, - include: CpusRepository.includes.default, - }) - } - - async delete(id: string): Promise { - // Check if CPU exists and get usage count - const existingCpu = await this.prisma.cpu.findUnique({ - where: { id }, - include: { _count: { select: { pcListings: true } } }, - }) - - if (!existingCpu) throw ResourceError.cpu.notFound() - - // Check if CPU is in use - if (existingCpu._count.pcListings > 0) { - throw ResourceError.cpu.inUse(existingCpu._count.pcListings) - } - - await this.prisma.cpu.delete({ where: { id } }) - } - - /** - * Get total count with filters (for pagination) - */ - async count(filters: GetCpusInput = {}): Promise { - const { search, brandId } = filters - const where = this.buildWhereClause(search, brandId) - return this.prisma.cpu.count({ where }) - } - - /** - * Check if CPU model exists (for validation) - */ - async existsByModelName(modelName: string, excludeId?: string): Promise { - const cpu = await this.prisma.cpu.findFirst({ - where: { - modelName: { equals: modelName, mode: this.mode }, - ...(excludeId && { id: { not: excludeId } }), - }, - }) - return !!cpu - } - - /** - * Get CPUs with PC listing counts - sorted by popularity - */ - async listWithCounts( - limit: number = 10, - ): Promise[]> { - return this.prisma.cpu.findMany({ - include: CpusRepository.includes.withCounts, - orderBy: { pcListings: { _count: Prisma.SortOrder.desc } }, - take: limit, - }) - } - - /** - * Get CPUs by a list of IDs (limited include) - */ - async listByIds(ids: string[]) { - if (ids.length === 0) return [] - return this.prisma.cpu.findMany({ - where: { id: { in: ids } }, - include: CpusRepository.includes.limited, - }) - } - - async options(filters: CpuOptionFilters = {}): Promise<{ - cpus: Prisma.CpuGetPayload<{ select: typeof CpusRepository.selects.option }>[] - hasMore: boolean - }> { - const limit = filters.limit ?? 50 - const offset = filters.offset ?? 0 - const cpus = await this.prisma.cpu.findMany({ - where: this.buildWhereClause(filters.search, filters.brandId), - select: CpusRepository.selects.option, - orderBy: [{ brand: { name: this.sortOrder } }, { modelName: this.sortOrder }], - take: limit + 1, - skip: offset, - }) - - return { - cpus: cpus.slice(0, limit), - hasMore: cpus.length > limit, - } - } - - /** - * Get CPUs with pagination metadata - * Supports both web and mobile usage via options - */ - async list( - filters: GetCpusInput = {}, - options: { limited?: boolean } = {}, - ): Promise<{ - cpus: Prisma.CpuGetPayload<{ - include: - | typeof CpusRepository.includes.withCounts - | typeof CpusRepository.includes.withCountsLimited - }>[] - pagination: PaginationResult - }> { - const { - search, - brandId, - limit = PAGINATION.DEFAULT_LIMIT, - offset = 0, - page, - sortField, - sortDirection, - } = filters - - const actualOffset = calculateOffset({ page, offset }, limit) - const where = this.buildWhereClause(search, brandId) - const orderBy = this.buildOrderBy(sortField, sortDirection) - - const [cpus, total] = await Promise.all([ - this.prisma.cpu.findMany({ - where, - include: options.limited - ? CpusRepository.includes.withCountsLimited - : CpusRepository.includes.withCounts, - orderBy, - take: limit, - skip: actualOffset, - }), - this.prisma.cpu.count({ where }), - ]) - - const pagination = paginate({ - total: total, - page: page ?? Math.floor(actualOffset / limit) + 1, - limit: limit, - }) - - return { cpus, pagination } - } - - /** - * Build where clause matching router logic exactly - */ - private buildWhereClause(search?: string, brandId?: string): Prisma.CpuWhereInput { - const where: Prisma.CpuWhereInput = {} - - if (brandId) where.brandId = brandId - - if (search) { - where.OR = [ - // Exact match for model name (highest priority) - { modelName: { equals: search, mode: this.mode } }, - // Exact match for brand name - { brand: { name: { equals: search, mode: this.mode } } }, - // Contains match for model name - { modelName: { contains: search, mode: this.mode } }, - // Contains match for brand name - { brand: { name: { contains: search, mode: this.mode } } }, - // Brand + Model combination search (e.g., "Intel Core i7") - ...(search.includes(' ') - ? [ - { - AND: [ - { brand: { name: { contains: search.split(' ')[0], mode: this.mode } } }, - { - modelName: { contains: search.split(' ').slice(1).join(' '), mode: this.mode }, - }, - ], - }, - ] - : []), - ] - } - - return where - } - - /** - * Build orderBy clause matching router logic - */ - private buildOrderBy( - sortField?: string | null, - sortDirection?: Prisma.SortOrder | null, - ): Prisma.CpuOrderByWithRelationInput[] { - const orderBy: Prisma.CpuOrderByWithRelationInput[] = [] - const direction = sortDirection || this.sortOrder - - if (sortField) { - switch (sortField) { - case 'brand': - orderBy.push({ brand: { name: direction } }) - break - case 'modelName': - orderBy.push({ modelName: direction }) - break - case 'pcListings': - orderBy.push({ pcListings: { _count: direction } }) - break - } - } - - // Default ordering if no sort specified - if (!orderBy.length) { - orderBy.push({ brand: { name: this.sortOrder } }, { modelName: this.sortOrder }) - } - - return orderBy - } -} diff --git a/src/server/repositories/devices.repository.ts b/src/server/repositories/devices.repository.ts index c9d9a2e8a..abea680da 100644 --- a/src/server/repositories/devices.repository.ts +++ b/src/server/repositories/devices.repository.ts @@ -1,8 +1,8 @@ import { startOfMonth, subDays } from 'date-fns' import { LRUCache } from 'lru-cache' -import { CACHE_DURATIONS, HOME_PAGE_LIMITS } from '@/data/constants' +import { CACHE_DURATIONS, HOME_PAGE_LIMITS, LOOKUP_PAGINATION, PAGINATION } from '@/data/constants' import { ResourceError } from '@/lib/errors' -import { type PaginationResult, paginate, calculateOffset } from '@/server/utils/pagination' +import { paginate, calculateOffset } from '@/server/utils/pagination' import { Prisma, ApprovalStatus } from '@orm/client' import { getTrendingDevices } from '@orm/sql' import { BaseRepository } from './base.repository' @@ -12,7 +12,9 @@ import type { GetDeviceOptionsInput, CreateDeviceInput, UpdateDeviceInput, + GetDevicesByIdsInput, } from '@/schemas/device' +import type { PaginationResult } from '@/schemas/pagination' export interface TrendingDevice { id: string @@ -92,7 +94,7 @@ export class DevicesRepository extends BaseRepository { /** * Get Devices by a list of IDs (limited include) */ - async listByIds(ids: string[]) { + async listByIds(ids: GetDevicesByIdsInput['ids']) { if (ids.length === 0) return [] return this.prisma.device.findMany({ where: { id: { in: ids } }, @@ -234,7 +236,7 @@ export class DevicesRepository extends BaseRepository { devices: Prisma.DeviceGetPayload<{ include: typeof DevicesRepository.includes.withCounts }>[] pagination: PaginationResult }> { - const limit = input.limit ?? 20 + const limit = input.limit ?? PAGINATION.DEFAULT_LIMIT const actualOffset = calculateOffset({ page: input.page, offset: input.offset }, limit) const where = this.buildWhere(input) @@ -252,9 +254,9 @@ export class DevicesRepository extends BaseRepository { ]) const pagination = paginate({ - total: total, + total, page: input.page ?? Math.floor(actualOffset / limit) + 1, - limit: limit, + limit, }) return { devices, pagination } @@ -264,7 +266,7 @@ export class DevicesRepository extends BaseRepository { devices: Prisma.DeviceGetPayload<{ select: typeof DevicesRepository.selects.option }>[] hasMore: boolean }> { - const limit = input.limit ?? 50 + const limit = input.limit ?? LOOKUP_PAGINATION.DEFAULT_LIMIT const offset = input.offset ?? 0 const devices = await this.prisma.device.findMany({ where: this.buildWhere(input), @@ -338,7 +340,7 @@ export class DevicesRepository extends BaseRepository { }[] pagination: PaginationResult }> { - const limit = filters.limit ?? 20 + const limit = filters.limit ?? PAGINATION.DEFAULT_LIMIT const page = filters.page ?? 1 const actualOffset = calculateOffset({ page }, limit) diff --git a/src/server/repositories/emulators.repository.ts b/src/server/repositories/emulators.repository.ts index 8904336f0..4a1b0bcab 100644 --- a/src/server/repositories/emulators.repository.ts +++ b/src/server/repositories/emulators.repository.ts @@ -1,8 +1,9 @@ import { PAGINATION } from '@/data/constants' import { ResourceError } from '@/lib/errors' -import { type PaginationResult, paginate, calculateOffset } from '@/server/utils/pagination' +import { paginate, calculateOffset } from '@/server/utils/pagination' import { ApprovalStatus, Prisma } from '@orm/client' import { BaseRepository } from './base.repository' +import type { PaginationResult } from '@/schemas/pagination' export interface EmulatorFilters { search?: string | null diff --git a/src/server/repositories/games.repository.ts b/src/server/repositories/games.repository.ts index 5c3312064..6a85b9394 100644 --- a/src/server/repositories/games.repository.ts +++ b/src/server/repositories/games.repository.ts @@ -1,11 +1,12 @@ import { PAGINATION } from '@/data/constants' -import { type PaginationResult, paginate, calculateOffset } from '@/server/utils/pagination' +import { paginate, calculateOffset } from '@/server/utils/pagination' import { buildShadowBanFilter } from '@/server/utils/query-builders' import { normalizeGameTitle } from '@/server/utils/steamGameBatcher' import { hasRolePermission } from '@/utils/permissions' import { normalizeString } from '@/utils/text' import { Prisma, ApprovalStatus, Role } from '@orm/client' import { BaseRepository } from './base.repository' +import type { PaginationResult } from '@/schemas/pagination' // Type guard for game metadata with Steam App ID function hasSteamAppId(metadata: unknown): metadata is { steamAppId: string } { diff --git a/src/server/repositories/gpus.repository.ts b/src/server/repositories/gpus.repository.ts deleted file mode 100644 index 6bcc092bc..000000000 --- a/src/server/repositories/gpus.repository.ts +++ /dev/null @@ -1,321 +0,0 @@ -import { PAGINATION } from '@/data/constants' -import { ResourceError } from '@/lib/errors' -import { type PaginationResult, paginate, calculateOffset } from '@/server/utils/pagination' -import { Prisma } from '@orm/client' -import { BaseRepository } from './base.repository' -import type { - GetGpusInput, - GetGpuOptionsInput, - CreateGpuInput, - UpdateGpuInput, -} from '@/schemas/gpu' - -type GpuOptionFilters = NonNullable - -/** - * Repository for GPU data access - */ -export class GpusRepository extends BaseRepository { - // Static query shapes for this repository - static readonly includes = { - default: { - brand: true, - } satisfies Prisma.GpuInclude, - - limited: { - brand: { select: { id: true, name: true } }, - } satisfies Prisma.GpuInclude, - - withCounts: { - brand: true, - _count: { select: { pcListings: true } }, - } satisfies Prisma.GpuInclude, - - counts: { - _count: { select: { pcListings: true } }, - } satisfies Prisma.GpuInclude, - - withCountsLimited: { - brand: { select: { id: true, name: true } }, - _count: { select: { pcListings: true } }, - } satisfies Prisma.GpuInclude, - } as const - - static readonly selects = { - option: { - id: true, - modelName: true, - brand: { select: { id: true, name: true } }, - } satisfies Prisma.GpuSelect, - } as const - - async byId( - id: string, - options: { limited?: boolean } = {}, - ): Promise | null> { - return this.prisma.gpu.findUnique({ - where: { id }, - include: options.limited ? GpusRepository.includes.limited : GpusRepository.includes.default, - }) - } - - /** - * Get GPU by ID with counts - */ - async byIdWithCounts( - id: string, - options: { limited?: boolean } = {}, - ): Promise | null> { - return this.prisma.gpu.findUnique({ - where: { id }, - include: options.limited - ? GpusRepository.includes.withCountsLimited - : GpusRepository.includes.withCounts, - }) - } - - async create( - data: CreateGpuInput, - ): Promise> { - // Validate brand exists - const brand = await this.prisma.deviceBrand.findUnique({ where: { id: data.brandId } }) - if (!brand) throw ResourceError.deviceBrand.notFound() - - // Check for duplicate model name - const exists = await this.existsByModelName(data.modelName) - if (exists) throw ResourceError.gpu.alreadyExists(data.modelName) - - return this.prisma.gpu.create({ data, include: GpusRepository.includes.default }) - } - - async update( - id: string, - data: Partial, - ): Promise> { - // Check if GPU exists - const gpu = await this.byId(id) - if (!gpu) throw ResourceError.gpu.notFound() - - // Validate brand exists if being updated - if (data.brandId) { - const brand = await this.prisma.deviceBrand.findUnique({ where: { id: data.brandId } }) - if (!brand) throw ResourceError.deviceBrand.notFound() - } - - // Check for duplicate model name if being updated - if (data.modelName) { - const exists = await this.existsByModelName(data.modelName, id) - if (exists) throw ResourceError.gpu.alreadyExists(data.modelName) - } - - return this.prisma.gpu.update({ where: { id }, data, include: GpusRepository.includes.default }) - } - - async delete(id: string): Promise { - // Check if GPU exists and get usage count - const existingGpu = await this.prisma.gpu.findUnique({ - where: { id }, - include: GpusRepository.includes.counts, - }) - - if (!existingGpu) throw ResourceError.gpu.notFound() - - // Check if GPU is in use - if (existingGpu._count.pcListings > 0) { - throw ResourceError.gpu.inUse(existingGpu._count.pcListings) - } - - await this.prisma.gpu.delete({ where: { id } }) - } - - /** - * Get total count with filters (for pagination) - */ - async count(filters: GetGpusInput = {}): Promise { - const { search, brandId } = filters - const where = this.buildWhereClause(search, brandId) - return this.prisma.gpu.count({ where }) - } - - /** - * Check if GPU model exists (for validation) - */ - async existsByModelName(modelName: string, excludeId?: string): Promise { - const gpu = await this.prisma.gpu.findFirst({ - where: { - modelName: { equals: modelName, mode: this.mode }, - ...(excludeId && { id: { not: excludeId } }), - }, - }) - return !!gpu - } - - /** - * Get GPUs with PC listing counts - */ - async listWithCounts( - limit: number = 10, - offset: number = 0, - ): Promise[]> { - return this.prisma.gpu.findMany({ - include: GpusRepository.includes.withCounts, - orderBy: { pcListings: { _count: Prisma.SortOrder.desc } }, - take: limit, - skip: offset, - }) - } - - /** - * Get GPUs by a list of IDs (limited include) - */ - async listByIds(ids: string[]) { - if (ids.length === 0) return [] - return this.prisma.gpu.findMany({ - where: { id: { in: ids } }, - include: GpusRepository.includes.limited, - }) - } - - async options(filters: GpuOptionFilters = {}): Promise<{ - gpus: Prisma.GpuGetPayload<{ select: typeof GpusRepository.selects.option }>[] - hasMore: boolean - }> { - const limit = filters.limit ?? 50 - const offset = filters.offset ?? 0 - const gpus = await this.prisma.gpu.findMany({ - where: this.buildWhereClause(filters.search, filters.brandId), - select: GpusRepository.selects.option, - orderBy: [{ brand: { name: this.sortOrder } }, { modelName: this.sortOrder }], - take: limit + 1, - skip: offset, - }) - - return { - gpus: gpus.slice(0, limit), - hasMore: gpus.length > limit, - } - } - - /** - * Get GPUs with pagination metadata - * Supports both web and mobile usage via options - */ - async list( - filters: GetGpusInput = {}, - options: { limited?: boolean } = {}, - ): Promise<{ - gpus: Prisma.GpuGetPayload<{ - include: - | typeof GpusRepository.includes.withCounts - | typeof GpusRepository.includes.withCountsLimited - }>[] - pagination: PaginationResult - }> { - const { - search, - brandId, - limit = PAGINATION.DEFAULT_LIMIT, - offset = 0, - page, - sortField, - sortDirection, - } = filters - - const actualOffset = calculateOffset({ page, offset }, limit) - const where = this.buildWhereClause(search, brandId) - const orderBy = this.buildOrderBy(sortField, sortDirection) - - const [gpus, total] = await Promise.all([ - this.prisma.gpu.findMany({ - where, - include: options.limited - ? GpusRepository.includes.withCountsLimited - : GpusRepository.includes.withCounts, - orderBy, - take: limit, - skip: actualOffset, - }), - this.prisma.gpu.count({ where }), - ]) - - const pagination = paginate({ - total, - limit, - page: page ?? Math.floor(actualOffset / limit) + 1, - }) - - return { gpus, pagination } - } - - private buildWhereClause(search?: string, brandId?: string): Prisma.GpuWhereInput { - const where: Prisma.GpuWhereInput = {} - - if (brandId) where.brandId = brandId - - if (search) { - where.OR = [ - // Exact match for model name (highest priority) - { modelName: { equals: search, mode: this.mode } }, - // Exact match for brand name - { brand: { name: { equals: search, mode: this.mode } } }, - // Contains match for model name - { modelName: { contains: search, mode: this.mode } }, - // Contains match for brand name - { brand: { name: { contains: search, mode: this.mode } } }, - // Brand + Model combination search (e.g., "NVIDIA RTX 4090") - ...(search.includes(' ') - ? [ - { - AND: [ - { brand: { name: { contains: search.split(' ')[0], mode: this.mode } } }, - { - modelName: { contains: search.split(' ').slice(1).join(' '), mode: this.mode }, - }, - ], - }, - ] - : []), - ] - } - - return where - } - - /** - * Build orderBy clause matching router logic - */ - private buildOrderBy( - sortField?: string | null, - sortDirection?: Prisma.SortOrder | null, - ): Prisma.GpuOrderByWithRelationInput[] { - const orderBy: Prisma.GpuOrderByWithRelationInput[] = [] - const direction = sortDirection || this.sortOrder - - if (sortField) { - switch (sortField) { - case 'brand': - orderBy.push({ brand: { name: direction } }) - break - case 'modelName': - orderBy.push({ modelName: direction }) - break - case 'pcListings': - orderBy.push({ pcListings: { _count: direction } }) - break - } - } - - // Default ordering if no sort specified - if (!orderBy.length) { - orderBy.push({ brand: { name: this.sortOrder } }, { modelName: this.sortOrder }) - } - - return orderBy - } -} diff --git a/src/server/repositories/socs.repository.ts b/src/server/repositories/socs.repository.ts index af8f33194..3238af794 100644 --- a/src/server/repositories/socs.repository.ts +++ b/src/server/repositories/socs.repository.ts @@ -1,8 +1,9 @@ -import { PAGINATION } from '@/data/constants' +import { LOOKUP_PAGINATION, PAGINATION } from '@/data/constants' import { ResourceError } from '@/lib/errors' -import { calculateOffset, paginate, type PaginationResult } from '@/server/utils/pagination' +import { calculateOffset, paginate } from '@/server/utils/pagination' import { Prisma, type SoC } from '@orm/client' import { BaseRepository } from './base.repository' +import type { PaginationResult } from '@/schemas/pagination' import type { GetSoCsInput, GetSoCOptionsInput, @@ -68,7 +69,7 @@ export class SoCsRepository extends BaseRepository { socs: Pick[] hasMore: boolean }> { - const limit = filters.limit ?? 50 + const limit = filters.limit ?? LOOKUP_PAGINATION.DEFAULT_LIMIT const offset = filters.offset ?? 0 const where: Prisma.SoCWhereInput = { ...(filters.search && { diff --git a/src/server/repositories/types.ts b/src/server/repositories/types.ts index 25c077383..bf2f25fff 100644 --- a/src/server/repositories/types.ts +++ b/src/server/repositories/types.ts @@ -1,4 +1,4 @@ -import type { PaginationResult } from '@/server/utils/pagination' +import type { PaginationResult } from '@/schemas/pagination' import type { Role } from '@orm/client' export interface VisibilityContext { diff --git a/src/server/utils/pagination.test.ts b/src/server/utils/pagination.test.ts index 643be34f3..7dfe28846 100644 --- a/src/server/utils/pagination.test.ts +++ b/src/server/utils/pagination.test.ts @@ -1,16 +1,34 @@ -import { describe, it, expect, vi } from 'vitest' +import { describe, it, expect } from 'vitest' import { paginate, paginatedResponse, - paginatedQuery, buildOrderBy, buildSearchConditions, contains, + resolvePagination, } from './pagination' type TestOrderBy = Record describe('pagination utilities', () => { + describe('resolvePagination', () => { + it('resolves page-based pagination to database offset', () => { + expect(resolvePagination({ page: 3, limit: 25 })).toEqual({ + page: 3, + limit: 25, + offset: 50, + }) + }) + + it('resolves offset-based pagination back to the matching page', () => { + expect(resolvePagination({ offset: 40, limit: 20 })).toEqual({ + page: 3, + limit: 20, + offset: 40, + }) + }) + }) + describe('paginate', () => { it('should create pagination metadata with page', () => { const result = paginate({ total: 100, page: 3, limit: 10 }) @@ -96,55 +114,6 @@ describe('pagination utilities', () => { }) }) - describe('paginatedQuery', () => { - it('should execute count and findMany in parallel', async () => { - const mockModel = { - count: vi.fn().mockResolvedValue(100), - findMany: vi.fn().mockResolvedValue([{ id: 1 }, { id: 2 }]), - } - - const where = { status: 'active' } - const orderBy = { createdAt: 'desc' } - - const result = await paginatedQuery(mockModel, { where, orderBy }, { page: 2 }, 10) - - expect(mockModel.count).toHaveBeenCalledWith({ where }) - expect(mockModel.findMany).toHaveBeenCalledWith({ - where, - orderBy, - skip: 10, - take: 10, - }) - - expect(result).toEqual({ - items: [{ id: 1 }, { id: 2 }], - pagination: { - total: 100, - pages: 10, - page: 2, - offset: 10, - limit: 10, - hasNextPage: true, - hasPreviousPage: true, - }, - }) - }) - - it('should use default limit when not specified', async () => { - const mockModel = { - count: vi.fn().mockResolvedValue(50), - findMany: vi.fn().mockResolvedValue([]), - } - - await paginatedQuery(mockModel, {}, { page: 1 }, 25) - - expect(mockModel.findMany).toHaveBeenCalledWith({ - skip: 0, - take: 25, - }) - }) - }) - describe('buildOrderBy', () => { const sortConfig = { title: (dir: 'asc' | 'desc') => ({ title: dir }), diff --git a/src/server/utils/pagination.ts b/src/server/utils/pagination.ts index d75aa666d..94002bd58 100644 --- a/src/server/utils/pagination.ts +++ b/src/server/utils/pagination.ts @@ -1,25 +1,12 @@ +import { PAGINATION } from '@/data/constants' import { toArray } from '@/utils/array' +import type { PaginatedResponse, PaginationInput, PaginationResult } from '@/schemas/pagination' import type { SortDirection } from '@/types/api' -export interface PaginationInput { - limit?: number - offset?: number - page?: number -} - -export interface PaginationResult { - total: number - pages: number - page: number - offset: number +export interface ResolvedPagination { limit: number - hasNextPage: boolean - hasPreviousPage: boolean -} - -export interface PaginatedResponse { - items: T[] - pagination: PaginationResult + offset: number + page: number } /** @@ -36,6 +23,20 @@ export function calculateOffset( return page ? (page - 1) * limit : (offset ?? 0) } +export function resolvePagination( + input: PaginationInput | undefined, + defaultLimit = PAGINATION.DEFAULT_LIMIT, +): ResolvedPagination { + const limit = input?.limit ?? defaultLimit + const offset = calculateOffset({ page: input?.page, offset: input?.offset ?? 0 }, limit) + + return { + limit, + offset, + page: input?.page ?? Math.floor(offset / limit) + 1, + } +} + interface PaginateParams { total: number page: number @@ -61,6 +62,10 @@ export function paginate(params: PaginateParams): PaginationResult { } } +export function paginationResult(total: number, pagination: ResolvedPagination): PaginationResult { + return paginate({ total, page: pagination.page, limit: pagination.limit }) +} + /** * Create a paginated response - clean API * @param params - Response parameters @@ -80,50 +85,6 @@ export function paginatedResponse(params: { } } -/** - * Execute a paginated Prisma query with consistent pagination handling - * @param model - Prisma model to query - * @param args - Prisma findMany arguments (where, orderBy, include, etc.) - * @param paginationInput - Pagination parameters - * @param defaultLimit - Default items per page if not specified - * @returns Paginated response - */ -export async function paginatedQuery( - model: { - count: (args?: { where?: unknown }) => Promise - findMany: (args?: unknown) => Promise - }, - args: { - where?: unknown - orderBy?: unknown - include?: unknown - select?: unknown - }, - paginationInput: PaginationInput, - defaultLimit = 20, -): Promise> { - const limit = paginationInput.limit ?? defaultLimit - const actualOffset = calculateOffset(paginationInput, limit) - - // Execute count and findMany queries in parallel for better performance - const [total, items] = await Promise.all([ - model.count({ where: args.where }), - model.findMany({ - ...args, - skip: actualOffset, - take: limit, - }), - ]) - - const actualPage = paginationInput.page ?? Math.floor(actualOffset / limit) + 1 - const pagination = paginate({ total, page: actualPage, limit }) - - return { - items, - pagination, - } -} - /** * Build orderBy clause from sort field and direction * The generic type T represents the shape of orderBy objects diff --git a/src/utils/options.ts b/src/utils/options.ts index 4f3db0518..7bfc56cac 100644 --- a/src/utils/options.ts +++ b/src/utils/options.ts @@ -22,18 +22,6 @@ export function deviceOptions( })) } -export function cpuOptions( - cpus: { id: string; modelName: string; brand: { name: string } }[], -): Option[] { - return deviceOptions(cpus) -} - -export function gpuOptions( - gpus: { id: string; modelName: string; brand: { name: string } }[], -): Option[] { - return deviceOptions(gpus) -} - export function socOptions(socs: { id: string; name: string; manufacturer: string }[]): Option[] { return socs.map((s) => ({ id: s.id, name: `${s.manufacturer} ${s.name}`, badgeName: s.name })) } diff --git a/src/utils/text.test.ts b/src/utils/text.test.ts index 2effbd973..f0df0bccb 100644 --- a/src/utils/text.test.ts +++ b/src/utils/text.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { formatCountLabel, normalizeString, normalizeStrings, bytesToHuman } from './text' +import { formatCountLabel, normalizeString, normalizeWhitespace, bytesToHuman } from './text' describe('formatCountLabel', () => { it('should format count label correctly', () => { @@ -76,21 +76,11 @@ describe('normalizeString', () => { }) }) -describe('normalizeStrings', () => { - it('should normalize an array of strings', () => { - const input = ['Astérix', 'Obélix', 'Pokémon'] - const expected = ['asterix', 'obelix', 'pokemon'] - expect(normalizeStrings(input)).toEqual(expected) - }) - - it('should handle empty array', () => { - expect(normalizeStrings([])).toEqual([]) - }) - - it('should handle array with mixed strings', () => { - const input = ['CAFÉ', 'naïve', 'hello world'] - const expected = ['cafe', 'naive', 'hello world'] - expect(normalizeStrings(input)).toEqual(expected) +describe('normalizeWhitespace', () => { + it('should trim and collapse whitespace while preserving casing and accents', () => { + expect(normalizeWhitespace(' GeForce RTX 4090 ')).toBe('GeForce RTX 4090') + expect(normalizeWhitespace(' Ryzen\t7\n7800X3D ')).toBe('Ryzen 7 7800X3D') + expect(normalizeWhitespace(' Café Pro ')).toBe('Café Pro') }) }) diff --git a/src/utils/text.ts b/src/utils/text.ts index e8cf85813..0c812c65a 100644 --- a/src/utils/text.ts +++ b/src/utils/text.ts @@ -8,6 +8,10 @@ export function formatCountLabel(word: string, count: number) { return `${count} ${word}${count === 1 ? '' : 's'}` } +export function normalizeWhitespace(value: string): string { + return value.trim().replace(/\s+/g, ' ') +} + /** * Normalizes a string by removing accents and converting to lowercase. * Useful for accent-insensitive searching. @@ -24,16 +28,6 @@ export function normalizeString(str: string): string { .toLowerCase() } -/** - * Normalizes an array of strings by removing accents and converting to lowercase. - * - * @example - * normalizeStrings(["Astérix", "Obélix"]) // ["asterix", "obelix"] - */ -export function normalizeStrings(strings: string[]): string[] { - return strings.map(normalizeString) -} - /** * Pretty formats a byte size into a human-readable string (e.g., "1.5 MB"). * @param bytes From a5e414cc3084fe13e2ca5c8d5e6a918d84a01a76 Mon Sep 17 00:00:00 2001 From: Producdevity Date: Sat, 13 Jun 2026 15:20:55 +0200 Subject: [PATCH 71/87] fix: move report moderation out of routers --- src/lib/errors.ts | 10 + src/schemas/listingReport.ts | 4 +- src/server/api/routers/listingReports.test.ts | 113 ++++++- src/server/api/routers/listingReports.ts | 117 +------- .../api/routers/pcListingReports.test.ts | 186 ++++++++++++ src/server/api/routers/pcListingReports.ts | 111 +------ .../report-moderation.repository.ts | 153 ++++++++++ .../services/report-moderation.service.ts | 284 ++++++++++++++++++ 8 files changed, 771 insertions(+), 207 deletions(-) create mode 100644 src/server/api/routers/pcListingReports.test.ts create mode 100644 src/server/repositories/report-moderation.repository.ts create mode 100644 src/server/services/report-moderation.service.ts diff --git a/src/lib/errors.ts b/src/lib/errors.ts index b33168ed3..04c676257 100644 --- a/src/lib/errors.ts +++ b/src/lib/errors.ts @@ -400,6 +400,8 @@ export class ResourceError { AppError.forbidden('You can only approve PC listings for emulators you are verified for'), mustBeVerifiedToReject: () => AppError.forbidden('You can only reject PC listings for emulators you are verified for'), + bulkAlreadyProcessed: () => + AppError.conflict('Some selected PC reports were already processed. Refresh and try again.'), } static notification = { @@ -484,12 +486,20 @@ export class ResourceError { notFound: () => AppError.notFound('Listing report'), alreadyExists: () => AppError.conflict('You have already reported this listing'), cannotReportOwnListing: () => AppError.forbidden('You cannot report your own listing'), + cannotChangeFinalStatus: () => + AppError.conflict( + 'Listing report has already been resolved or dismissed and cannot be reopened.', + ), } static pcListingReport = { notFound: () => AppError.notFound('PC listing report'), alreadyExists: () => AppError.conflict('You have already reported this listing'), cannotReportOwnListing: () => AppError.forbidden('You cannot report your own listing'), + cannotChangeFinalStatus: () => + AppError.conflict( + 'PC listing report has already been resolved or dismissed and cannot be reopened.', + ), } static userBan = { diff --git a/src/schemas/listingReport.ts b/src/schemas/listingReport.ts index 3ac18d7bd..2784d29e8 100644 --- a/src/schemas/listingReport.ts +++ b/src/schemas/listingReport.ts @@ -26,8 +26,8 @@ export const GetListingReportsSchema = z reason: ReportReasonSchema.optional(), sortField: ListingReportSortField.optional(), sortDirection: SortDirectionSchema.optional(), - page: z.number().min(1).default(1), - limit: z.number().min(1).max(100).default(20), + page: z.coerce.number().int().min(1).default(1), + limit: z.coerce.number().int().min(1).max(100).default(20), }) .optional() diff --git a/src/server/api/routers/listingReports.test.ts b/src/server/api/routers/listingReports.test.ts index 4bf5115bf..67073e83e 100644 --- a/src/server/api/routers/listingReports.test.ts +++ b/src/server/api/routers/listingReports.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { ReportReason, Role } from '@orm/client' +import { PERMISSIONS } from '@/utils/permission-system' +import { ApprovalStatus, ReportReason, ReportStatus, Role, TrustAction } from '@orm/client' vi.unmock('@/server/api/trpc') vi.unmock('@/server/api/root') @@ -13,14 +14,13 @@ vi.mock('@/server/notifications/eventEmitter', () => ({ })) vi.mock('@/server/utils/security-validation', () => ({ - validateEnum: vi.fn(), sanitizeInput: vi.fn((value: string) => value.trim()), - validatePagination: vi.fn((page, limit, max) => ({ page: page ?? 1, limit: limit ?? max ?? 20 })), })) +const mockLogAction = vi.fn().mockResolvedValue(undefined) vi.mock('@/lib/trust/service', () => ({ TrustService: vi.fn().mockImplementation(function MockTrustService() { - return { logAction: vi.fn(), reverseLogAction: vi.fn() } + return { logAction: mockLogAction, reverseLogAction: vi.fn() } }), })) @@ -32,13 +32,14 @@ const LISTING_ID = '00000000-0000-4000-a000-000000000010' const REPORT_ID = '00000000-0000-4000-a000-000000000020' function createMockPrisma() { - return { + const tx = { listing: { findUnique: vi.fn().mockResolvedValue({ id: LISTING_ID, authorId: AUTHOR_ID, author: { id: AUTHOR_ID }, }), + update: vi.fn().mockResolvedValue({ id: LISTING_ID }), }, listingReport: { findUnique: vi.fn().mockResolvedValue(null), @@ -53,13 +54,32 @@ function createMockPrisma() { author: { name: 'Report Author' }, }, }), + update: vi.fn().mockResolvedValue({ id: REPORT_ID, status: ReportStatus.RESOLVED }), + delete: vi.fn().mockResolvedValue({ id: REPORT_ID }), + }, + user: { + findUnique: vi.fn().mockResolvedValue({ trustScore: 0 }), + update: vi.fn().mockResolvedValue({ id: USER_ID }), + }, + trustActionLog: { + create: vi.fn().mockResolvedValue({ id: 'trust-log-id' }), }, } + + return { + ...tx, + $transaction: vi.fn(async (callback: (transaction: typeof tx) => Promise) => + callback(tx), + ), + } } type MockPrisma = ReturnType -function createCaller(prisma: MockPrisma = createMockPrisma()) { +function createCaller( + prisma: MockPrisma = createMockPrisma(), + options: { role?: Role; permissions?: string[] } = {}, +) { return { caller: listingReportsRouter.createCaller({ session: { @@ -67,8 +87,8 @@ function createCaller(prisma: MockPrisma = createMockPrisma()) { id: USER_ID, email: 'test@test.com', name: 'Test User', - role: Role.USER, - permissions: [], + role: options.role ?? Role.USER, + permissions: options.permissions ?? [], showNsfw: false, }, }, @@ -118,4 +138,81 @@ describe('listingReportsRouter create', () => { }, }) }) + + it('updates report status, listing status, and trust effects inside one transaction', async () => { + const { caller, prisma } = createCaller(createMockPrisma(), { + role: Role.ADMIN, + permissions: [PERMISSIONS.MANAGE_USER_BANS], + }) + prisma.listingReport.findUnique.mockResolvedValue({ + id: REPORT_ID, + listingId: LISTING_ID, + reportedById: USER_ID, + reason: ReportReason.SPAM, + status: ReportStatus.PENDING, + listing: { status: ApprovalStatus.APPROVED }, + }) + + await caller.updateStatus({ + id: REPORT_ID, + status: ReportStatus.RESOLVED, + reviewNotes: 'Confirmed spam', + }) + + expect(prisma.$transaction).toHaveBeenCalled() + expect(prisma.listing.update).toHaveBeenCalledWith({ + where: { id: LISTING_ID }, + data: expect.objectContaining({ + status: ApprovalStatus.REJECTED, + processedByUserId: USER_ID, + processedNotes: 'Rejected due to report: Confirmed spam', + }), + }) + expect(mockLogAction).toHaveBeenCalledWith({ + userId: USER_ID, + action: TrustAction.REPORT_CONFIRMED, + metadata: { + reportId: REPORT_ID, + listingId: LISTING_ID, + reviewedBy: USER_ID, + reason: ReportReason.SPAM, + }, + }) + expect(prisma.listingReport.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: REPORT_ID }, + data: expect.objectContaining({ + status: ReportStatus.RESOLVED, + reviewedById: USER_ID, + }), + }), + ) + }) + + it('prevents changing a report after it reaches a final status', async () => { + const { caller, prisma } = createCaller(createMockPrisma(), { + role: Role.ADMIN, + permissions: [PERMISSIONS.MANAGE_USER_BANS], + }) + prisma.listingReport.findUnique.mockResolvedValue({ + id: REPORT_ID, + listingId: LISTING_ID, + reportedById: USER_ID, + reason: ReportReason.SPAM, + status: ReportStatus.RESOLVED, + listing: { status: ApprovalStatus.REJECTED }, + }) + + await expect( + caller.updateStatus({ + id: REPORT_ID, + status: ReportStatus.DISMISSED, + reviewNotes: 'Changing decision', + }), + ).rejects.toThrow('Listing report has already been resolved or dismissed') + + expect(prisma.listing.update).not.toHaveBeenCalled() + expect(mockLogAction).not.toHaveBeenCalled() + expect(prisma.listingReport.update).not.toHaveBeenCalled() + }) }) diff --git a/src/server/api/routers/listingReports.ts b/src/server/api/routers/listingReports.ts index d780346c7..ebdce7d79 100644 --- a/src/server/api/routers/listingReports.ts +++ b/src/server/api/routers/listingReports.ts @@ -1,5 +1,4 @@ import { ResourceError } from '@/lib/errors' -import { TrustService } from '@/lib/trust/service' import { CreateListingReportSchema, DeleteReportSchema, @@ -15,12 +14,12 @@ import { protectedProcedure, publicProcedure, } from '@/server/api/trpc' +import { ReportModerationService } from '@/server/services/report-moderation.service' import { getAuthorReportCounts } from '@/server/services/report-stats.service' import { ReportSubmissionService } from '@/server/services/report-submission.service' import { paginate } from '@/server/utils/pagination' -import { validateEnum, sanitizeInput, validatePagination } from '@/server/utils/security-validation' import { PERMISSIONS } from '@/utils/permission-system' -import { ApprovalStatus, type Prisma, ReportStatus, TrustAction, ReportReason } from '@orm/client' +import { type Prisma, type ReportReason, ReportStatus } from '@orm/client' export const listingReportsRouter = createTRPCRouter({ stats: permissionProcedure(PERMISSIONS.VIEW_STATISTICS).query(async ({ ctx }) => { @@ -51,22 +50,20 @@ export const listingReportsRouter = createTRPCRouter({ sortDirection = 'desc', } = input ?? {} - // Validate pagination - const { page, limit } = validatePagination(input?.page, input?.limit, 50) - - // Sanitize search term (plain text, not markdown) - const sanitizedSearch = search ? sanitizeInput(search) : undefined + const page = input?.page ?? 1 + const limit = input?.limit ?? 20 + const normalizedSearch = search?.trim() || undefined const offset = (page - 1) * limit // Build where clause const where: Prisma.ListingReportWhereInput = {} - if (sanitizedSearch) { + if (normalizedSearch) { where.OR = [ - { listing: { game: { title: { contains: sanitizedSearch, mode: 'insensitive' } } } }, - { reportedBy: { name: { contains: sanitizedSearch, mode: 'insensitive' } } }, - { description: { contains: sanitizedSearch, mode: 'insensitive' } }, + { listing: { game: { title: { contains: normalizedSearch, mode: 'insensitive' } } } }, + { reportedBy: { name: { contains: normalizedSearch, mode: 'insensitive' } } }, + { description: { contains: normalizedSearch, mode: 'insensitive' } }, ] } @@ -132,8 +129,6 @@ export const listingReportsRouter = createTRPCRouter({ const { listingId, reason, description } = input const userId = ctx.session.user.id - validateEnum(reason, Object.values(ReportReason), 'reason') - const reportSubmissionService = new ReportSubmissionService(ctx.prisma) return await reportSubmissionService.createListingReport({ @@ -147,100 +142,18 @@ export const listingReportsRouter = createTRPCRouter({ updateStatus: permissionProcedure(PERMISSIONS.MANAGE_USER_BANS) .input(UpdateReportStatusSchema) .mutation(async ({ ctx, input }) => { - const { id, status, reviewNotes } = input - const reviewerId = ctx.session.user.id - - // Validate status enum - validateEnum(status, Object.values(ReportStatus), 'status') - - const report = await ctx.prisma.listingReport.findUnique({ - where: { id }, - include: { - listing: true, - }, - }) - - if (!report) { - return ResourceError.listingReport.notFound() - } - - // If resolving the report and marking listing as rejected - if (status === ReportStatus.RESOLVED && report.listing?.status === ApprovalStatus.APPROVED) { - // Update the listing status to rejected - await ctx.prisma.listing.update({ - where: { id: report.listingId }, - data: { - status: ApprovalStatus.REJECTED, - processedAt: new Date(), - processedByUserId: reviewerId, - processedNotes: `Rejected due to report: ${reviewNotes || 'No additional notes'}`, - }, - }) - } - - // Award trust points based on report outcome - const trustService = new TrustService(ctx.prisma) - - if (status === ReportStatus.RESOLVED) { - // Report was confirmed - reward the reporter - await trustService.logAction({ - userId: report.reportedById, - action: TrustAction.REPORT_CONFIRMED, - metadata: { - reportId: id, - listingId: report.listingId, - reviewedBy: reviewerId, - reason: report.reason, - }, - }) - } else if (status === ReportStatus.DISMISSED) { - // Report was false/malicious - penalize the reporter - await trustService.logAction({ - userId: report.reportedById, - action: TrustAction.FALSE_REPORT, - metadata: { - reportId: id, - listingId: report.listingId, - reviewedBy: reviewerId, - reason: report.reason, - reviewNotes, - }, - }) - } - - return ctx.prisma.listingReport.update({ - where: { id }, - data: { - status, - reviewNotes, - reviewedById: reviewerId, - reviewedAt: new Date(), - }, - include: { - listing: { - include: { - game: { select: { title: true } }, - author: { select: { name: true } }, - }, - }, - reportedBy: { select: { name: true } }, - reviewedBy: { select: { name: true } }, - }, + return new ReportModerationService(ctx.prisma).updateListingReportStatus({ + id: input.id, + status: input.status, + reviewNotes: input.reviewNotes, + reviewerId: ctx.session.user.id, }) }), delete: permissionProcedure(PERMISSIONS.MANAGE_USER_BANS) .input(DeleteReportSchema) .mutation(async ({ ctx, input }) => { - const report = await ctx.prisma.listingReport.findUnique({ - where: { id: input.id }, - }) - - if (!report) ResourceError.listingReport.notFound() - - return ctx.prisma.listingReport.delete({ - where: { id: input.id }, - }) + return new ReportModerationService(ctx.prisma).deleteListingReport(input.id) }), getUserReportStats: permissionProcedure(PERMISSIONS.VIEW_USER_BANS) diff --git a/src/server/api/routers/pcListingReports.test.ts b/src/server/api/routers/pcListingReports.test.ts new file mode 100644 index 000000000..11af1a774 --- /dev/null +++ b/src/server/api/routers/pcListingReports.test.ts @@ -0,0 +1,186 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { PERMISSIONS } from '@/utils/permission-system' +import { ApprovalStatus, ReportReason, ReportStatus, Role, TrustAction } from '@orm' + +vi.unmock('@/server/api/trpc') + +const mockLogAction = vi.fn().mockResolvedValue(undefined) +const mockTrustService = vi.fn().mockImplementation(function MockTrustService() { + return { logAction: mockLogAction } +}) + +vi.mock('@/lib/trust/service', () => ({ + TrustService: mockTrustService, +})) + +const { pcListingReportsRouter } = await import('./pcListingReports') + +const USER_ID = '00000000-0000-4000-a000-000000000001' +const REPORT_ID = '00000000-0000-4000-a000-000000000020' +const PC_LISTING_ID = '00000000-0000-4000-a000-000000000030' + +function createPrismaError(code: string): Error & { code: string } { + return Object.assign(new Error(`Prisma error ${code}`), { code }) +} + +function createMockPrisma() { + const tx = { + pcListing: { + update: vi.fn().mockResolvedValue({ id: PC_LISTING_ID }), + }, + pcListingReport: { + count: vi.fn().mockResolvedValue(0), + findMany: vi.fn().mockResolvedValue([]), + findUnique: vi.fn().mockResolvedValue(null), + update: vi.fn().mockResolvedValue({ id: REPORT_ID, status: ReportStatus.RESOLVED }), + delete: vi.fn().mockResolvedValue({ id: REPORT_ID }), + }, + user: { + findUnique: vi.fn().mockResolvedValue({ trustScore: 0 }), + update: vi.fn().mockResolvedValue({ id: USER_ID }), + }, + trustActionLog: { + create: vi.fn().mockResolvedValue({ id: 'trust-log-id' }), + }, + } + + return { + ...tx, + $transaction: vi.fn(async (callback: (transaction: typeof tx) => Promise) => + callback(tx), + ), + } +} + +type MockPrisma = ReturnType + +function createCaller(prisma: MockPrisma = createMockPrisma()) { + return { + caller: pcListingReportsRouter.createCaller({ + session: { + user: { + id: USER_ID, + email: 'test@test.com', + name: 'Test User', + role: Role.ADMIN, + permissions: [PERMISSIONS.MANAGE_USER_BANS, PERMISSIONS.VIEW_USER_BANS], + showNsfw: false, + }, + }, + prisma: prisma as never, + headers: new Headers(), + }), + prisma, + } +} + +describe('pcListingReportsRouter', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('updates report status, listing status, and trust effects inside one transaction', async () => { + const { caller, prisma } = createCaller() + prisma.pcListingReport.findUnique.mockResolvedValue({ + id: REPORT_ID, + pcListingId: PC_LISTING_ID, + reportedById: USER_ID, + reason: ReportReason.SPAM, + status: ReportStatus.PENDING, + pcListing: { status: ApprovalStatus.APPROVED }, + }) + + await caller.updateStatus({ + reportId: REPORT_ID, + status: ReportStatus.RESOLVED, + reviewNotes: 'Confirmed spam', + }) + + expect(prisma.$transaction).toHaveBeenCalled() + expect(prisma.pcListing.update).toHaveBeenCalledWith({ + where: { id: PC_LISTING_ID }, + data: expect.objectContaining({ + status: ApprovalStatus.REJECTED, + processedByUserId: USER_ID, + processedNotes: 'Rejected due to report: Confirmed spam', + }), + }) + expect(mockTrustService).toHaveBeenCalledWith( + expect.objectContaining({ pcListingReport: prisma.pcListingReport }), + ) + expect(mockLogAction).toHaveBeenCalledWith({ + userId: USER_ID, + action: TrustAction.REPORT_CONFIRMED, + metadata: { + reportId: REPORT_ID, + pcListingId: PC_LISTING_ID, + reviewedBy: USER_ID, + reason: ReportReason.SPAM, + }, + }) + expect(prisma.pcListingReport.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: REPORT_ID }, + data: expect.objectContaining({ + status: ReportStatus.RESOLVED, + reviewedById: USER_ID, + }), + }), + ) + }) + + it('does not duplicate trust effects when the status is unchanged', async () => { + const { caller, prisma } = createCaller() + prisma.pcListingReport.findUnique.mockResolvedValue({ + id: REPORT_ID, + pcListingId: PC_LISTING_ID, + reportedById: USER_ID, + reason: ReportReason.SPAM, + status: ReportStatus.RESOLVED, + pcListing: { status: ApprovalStatus.APPROVED }, + }) + + await caller.updateStatus({ + reportId: REPORT_ID, + status: ReportStatus.RESOLVED, + reviewNotes: 'Already handled', + }) + + expect(prisma.pcListing.update).not.toHaveBeenCalled() + expect(mockLogAction).not.toHaveBeenCalled() + expect(prisma.pcListingReport.update).toHaveBeenCalled() + }) + + it('prevents changing a PC report after it reaches a final status', async () => { + const { caller, prisma } = createCaller() + prisma.pcListingReport.findUnique.mockResolvedValue({ + id: REPORT_ID, + pcListingId: PC_LISTING_ID, + reportedById: USER_ID, + reason: ReportReason.SPAM, + status: ReportStatus.RESOLVED, + pcListing: { status: ApprovalStatus.REJECTED }, + }) + + await expect( + caller.updateStatus({ + reportId: REPORT_ID, + status: ReportStatus.DISMISSED, + reviewNotes: 'Changing decision', + }), + ).rejects.toThrow('PC listing report has already been resolved or dismissed') + + expect(prisma.pcListing.update).not.toHaveBeenCalled() + expect(mockLogAction).not.toHaveBeenCalled() + expect(prisma.pcListingReport.update).not.toHaveBeenCalled() + }) + + it('maps missing report deletes to the PC report not-found error without preloading', async () => { + const { caller, prisma } = createCaller() + prisma.pcListingReport.delete.mockRejectedValue(createPrismaError('P2025')) + + await expect(caller.delete({ id: REPORT_ID })).rejects.toThrow('PC listing report not found') + + expect(prisma.pcListingReport.findUnique).not.toHaveBeenCalled() + }) +}) diff --git a/src/server/api/routers/pcListingReports.ts b/src/server/api/routers/pcListingReports.ts index 6f0b83a4b..0033b236f 100644 --- a/src/server/api/routers/pcListingReports.ts +++ b/src/server/api/routers/pcListingReports.ts @@ -1,5 +1,4 @@ import { ResourceError } from '@/lib/errors' -import { TrustService } from '@/lib/trust/service' import { DeleteReportSchema, GetReportByIdSchema } from '@/schemas/listingReport' import { CreatePcListingReportSchema, @@ -7,11 +6,11 @@ import { UpdatePcListingReportSchema, } from '@/schemas/pcListing' import { createTRPCRouter, permissionProcedure, protectedProcedure } from '@/server/api/trpc' +import { ReportModerationService } from '@/server/services/report-moderation.service' import { ReportSubmissionService } from '@/server/services/report-submission.service' import { paginate } from '@/server/utils/pagination' -import { validateEnum, sanitizeInput, validatePagination } from '@/server/utils/security-validation' import { PERMISSIONS } from '@/utils/permission-system' -import { ApprovalStatus, ReportReason, ReportStatus, TrustAction } from '@orm' +import { ReportStatus } from '@orm' import { type Prisma } from '@orm/client' export const pcListingReportsRouter = createTRPCRouter({ @@ -43,17 +42,18 @@ export const pcListingReportsRouter = createTRPCRouter({ sortDirection = 'desc', } = input ?? {} - const { page, limit } = validatePagination(input?.page, input?.limit, 50) - const sanitizedSearch = search ? sanitizeInput(search) : undefined + const page = input?.page ?? 1 + const limit = input?.limit ?? 20 + const normalizedSearch = search?.trim() || undefined const offset = (page - 1) * limit const where: Prisma.PcListingReportWhereInput = {} - if (sanitizedSearch) { + if (normalizedSearch) { where.OR = [ - { pcListing: { game: { title: { contains: sanitizedSearch, mode: 'insensitive' } } } }, - { reportedBy: { name: { contains: sanitizedSearch, mode: 'insensitive' } } }, - { description: { contains: sanitizedSearch, mode: 'insensitive' } }, + { pcListing: { game: { title: { contains: normalizedSearch, mode: 'insensitive' } } } }, + { reportedBy: { name: { contains: normalizedSearch, mode: 'insensitive' } } }, + { description: { contains: normalizedSearch, mode: 'insensitive' } }, ] } @@ -113,15 +113,13 @@ export const pcListingReportsRouter = createTRPCRouter({ }, }) - return report || ResourceError.listingReport.notFound() + return report || ResourceError.pcListingReport.notFound() }), create: protectedProcedure.input(CreatePcListingReportSchema).mutation(async ({ ctx, input }) => { const { pcListingId, reason, description } = input const userId = ctx.session.user.id - validateEnum(reason, Object.values(ReportReason), 'reason') - const reportSubmissionService = new ReportSubmissionService(ctx.prisma) return await reportSubmissionService.createPcListingReport({ @@ -135,94 +133,17 @@ export const pcListingReportsRouter = createTRPCRouter({ updateStatus: permissionProcedure(PERMISSIONS.MANAGE_USER_BANS) .input(UpdatePcListingReportSchema) .mutation(async ({ ctx, input }) => { - const { reportId, status, reviewNotes } = input - const reviewerId = ctx.session.user.id - - validateEnum(status, Object.values(ReportStatus), 'status') - - const report = await ctx.prisma.pcListingReport.findUnique({ - where: { id: reportId }, - include: { pcListing: true }, - }) - - if (!report) { - return ResourceError.listingReport.notFound() - } - - if ( - status === ReportStatus.RESOLVED && - report.pcListing?.status === ApprovalStatus.APPROVED - ) { - await ctx.prisma.pcListing.update({ - where: { id: report.pcListingId }, - data: { - status: ApprovalStatus.REJECTED, - processedAt: new Date(), - processedByUserId: reviewerId, - processedNotes: `Rejected due to report: ${reviewNotes || 'No additional notes'}`, - }, - }) - } - - const trustService = new TrustService(ctx.prisma) - - if (status === ReportStatus.RESOLVED) { - await trustService.logAction({ - userId: report.reportedById, - action: TrustAction.REPORT_CONFIRMED, - metadata: { - reportId, - pcListingId: report.pcListingId, - reviewedBy: reviewerId, - reason: report.reason, - }, - }) - } else if (status === ReportStatus.DISMISSED) { - await trustService.logAction({ - userId: report.reportedById, - action: TrustAction.FALSE_REPORT, - metadata: { - reportId, - pcListingId: report.pcListingId, - reviewedBy: reviewerId, - reason: report.reason, - reviewNotes, - }, - }) - } - - return ctx.prisma.pcListingReport.update({ - where: { id: reportId }, - data: { - status, - reviewNotes, - reviewedById: reviewerId, - reviewedAt: new Date(), - }, - include: { - pcListing: { - include: { - game: { select: { title: true } }, - author: { select: { name: true } }, - }, - }, - reportedBy: { select: { name: true } }, - reviewedBy: { select: { name: true } }, - }, + return new ReportModerationService(ctx.prisma).updatePcListingReportStatus({ + reportId: input.reportId, + status: input.status, + reviewNotes: input.reviewNotes, + reviewerId: ctx.session.user.id, }) }), delete: permissionProcedure(PERMISSIONS.MANAGE_USER_BANS) .input(DeleteReportSchema) .mutation(async ({ ctx, input }) => { - const report = await ctx.prisma.pcListingReport.findUnique({ - where: { id: input.id }, - }) - - if (!report) return ResourceError.listingReport.notFound() - - return ctx.prisma.pcListingReport.delete({ - where: { id: input.id }, - }) + return new ReportModerationService(ctx.prisma).deletePcListingReport(input.id) }), }) diff --git a/src/server/repositories/report-moderation.repository.ts b/src/server/repositories/report-moderation.repository.ts new file mode 100644 index 000000000..2a3ddcee4 --- /dev/null +++ b/src/server/repositories/report-moderation.repository.ts @@ -0,0 +1,153 @@ +import { + PrismaRepository, + type PrismaRepositoryClient, +} from '@/server/persistence/prisma.repository' +import { ApprovalStatus, type Prisma, type ReportStatus } from '@orm/client' + +const LISTING_REPORT_MODERATION_SELECT = { + id: true, + listingId: true, + reportedById: true, + reason: true, + status: true, + listing: { select: { status: true } }, +} satisfies Prisma.ListingReportSelect + +const PC_LISTING_REPORT_MODERATION_SELECT = { + id: true, + pcListingId: true, + reportedById: true, + reason: true, + status: true, + pcListing: { select: { status: true } }, +} satisfies Prisma.PcListingReportSelect + +const LISTING_REPORT_STATUS_RESULT_INCLUDE = { + listing: { + include: { + game: { select: { title: true } }, + author: { select: { name: true } }, + }, + }, + reportedBy: { select: { name: true } }, + reviewedBy: { select: { name: true } }, +} satisfies Prisma.ListingReportInclude + +const PC_LISTING_REPORT_STATUS_RESULT_INCLUDE = { + pcListing: { + include: { + game: { select: { title: true } }, + author: { select: { name: true } }, + }, + }, + reportedBy: { select: { name: true } }, + reviewedBy: { select: { name: true } }, +} satisfies Prisma.PcListingReportInclude + +export type ListingReportModerationRecord = Prisma.ListingReportGetPayload<{ + select: typeof LISTING_REPORT_MODERATION_SELECT +}> + +export type PcListingReportModerationRecord = Prisma.PcListingReportGetPayload<{ + select: typeof PC_LISTING_REPORT_MODERATION_SELECT +}> + +export class ReportModerationRepository extends PrismaRepository { + constructor(prisma: PrismaRepositoryClient) { + super(prisma) + } + + findListingReportForModeration(id: string): Promise { + return this.prisma.listingReport.findUnique({ + where: { id }, + select: LISTING_REPORT_MODERATION_SELECT, + }) + } + + findPcListingReportForModeration(id: string): Promise { + return this.prisma.pcListingReport.findUnique({ + where: { id }, + select: PC_LISTING_REPORT_MODERATION_SELECT, + }) + } + + rejectListingFromReport(params: { + listingId: string + reviewerId: string + reviewNotes?: string + processedAt: Date + }) { + return this.prisma.listing.update({ + where: { id: params.listingId }, + data: { + status: ApprovalStatus.REJECTED, + processedAt: params.processedAt, + processedByUserId: params.reviewerId, + processedNotes: `Rejected due to report: ${params.reviewNotes || 'No additional notes'}`, + }, + }) + } + + rejectPcListingFromReport(params: { + pcListingId: string + reviewerId: string + reviewNotes?: string + processedAt: Date + }) { + return this.prisma.pcListing.update({ + where: { id: params.pcListingId }, + data: { + status: ApprovalStatus.REJECTED, + processedAt: params.processedAt, + processedByUserId: params.reviewerId, + processedNotes: `Rejected due to report: ${params.reviewNotes || 'No additional notes'}`, + }, + }) + } + + updateListingReportStatus(params: { + id: string + status: ReportStatus + reviewNotes?: string + reviewerId: string + reviewedAt: Date + }) { + return this.prisma.listingReport.update({ + where: { id: params.id }, + data: { + status: params.status, + reviewNotes: params.reviewNotes, + reviewedById: params.reviewerId, + reviewedAt: params.reviewedAt, + }, + include: LISTING_REPORT_STATUS_RESULT_INCLUDE, + }) + } + + updatePcListingReportStatus(params: { + id: string + status: ReportStatus + reviewNotes?: string + reviewerId: string + reviewedAt: Date + }) { + return this.prisma.pcListingReport.update({ + where: { id: params.id }, + data: { + status: params.status, + reviewNotes: params.reviewNotes, + reviewedById: params.reviewerId, + reviewedAt: params.reviewedAt, + }, + include: PC_LISTING_REPORT_STATUS_RESULT_INCLUDE, + }) + } + + deleteListingReport(id: string) { + return this.prisma.listingReport.delete({ where: { id } }) + } + + deletePcListingReport(id: string) { + return this.prisma.pcListingReport.delete({ where: { id } }) + } +} diff --git a/src/server/services/report-moderation.service.ts b/src/server/services/report-moderation.service.ts new file mode 100644 index 000000000..f24f2a553 --- /dev/null +++ b/src/server/services/report-moderation.service.ts @@ -0,0 +1,284 @@ +import { ResourceError } from '@/lib/errors' +import { TrustService } from '@/lib/trust/service' +import { + ReportModerationRepository, + type ListingReportModerationRecord, + type PcListingReportModerationRecord, +} from '@/server/repositories/report-moderation.repository' +import { isPrismaError, PRISMA_ERROR_CODES } from '@/server/utils/prisma-errors' +import { + ApprovalStatus, + ReportStatus, + TrustAction, + type Prisma, + type PrismaClient, +} from '@orm/client' + +const FINAL_REPORT_STATUSES: ReadonlySet = new Set([ + ReportStatus.RESOLVED, + ReportStatus.DISMISSED, +]) + +interface UpdateListingReportStatusInput { + id: string + status: ReportStatus + reviewNotes?: string + reviewerId: string +} + +interface UpdatePcListingReportStatusInput { + reportId: string + status: ReportStatus + reviewNotes?: string + reviewerId: string +} + +function isFinalReportStatus(status: ReportStatus): boolean { + return FINAL_REPORT_STATUSES.has(status) +} + +function assertCanTransitionReportStatus(params: { + currentStatus: ReportStatus + nextStatus: ReportStatus + onFinalStatusChange: () => never +}): void { + if (params.currentStatus === params.nextStatus) return + + if (isFinalReportStatus(params.currentStatus)) { + params.onFinalStatusChange() + } +} + +function shouldRejectReportedContent(params: { + statusChanged: boolean + nextStatus: ReportStatus + currentListingStatus: ApprovalStatus | null | undefined +}): boolean { + return ( + params.statusChanged && + params.nextStatus === ReportStatus.RESOLVED && + params.currentListingStatus === ApprovalStatus.APPROVED + ) +} + +async function applyListingReportTrustEffect(params: { + tx: Prisma.TransactionClient + report: ListingReportModerationRecord + nextStatus: ReportStatus + reviewerId: string + reviewNotes?: string +}): Promise { + const trustService = new TrustService(params.tx) + + if (params.nextStatus === ReportStatus.RESOLVED) { + await trustService.logAction({ + userId: params.report.reportedById, + action: TrustAction.REPORT_CONFIRMED, + metadata: { + reportId: params.report.id, + listingId: params.report.listingId, + reviewedBy: params.reviewerId, + reason: params.report.reason, + }, + }) + return + } + + if (params.nextStatus === ReportStatus.DISMISSED) { + await trustService.logAction({ + userId: params.report.reportedById, + action: TrustAction.FALSE_REPORT, + metadata: { + reportId: params.report.id, + listingId: params.report.listingId, + reviewedBy: params.reviewerId, + reason: params.report.reason, + reviewNotes: params.reviewNotes, + }, + }) + } +} + +async function applyPcListingReportTrustEffect(params: { + tx: Prisma.TransactionClient + report: PcListingReportModerationRecord + nextStatus: ReportStatus + reviewerId: string + reviewNotes?: string +}): Promise { + const trustService = new TrustService(params.tx) + + if (params.nextStatus === ReportStatus.RESOLVED) { + await trustService.logAction({ + userId: params.report.reportedById, + action: TrustAction.REPORT_CONFIRMED, + metadata: { + reportId: params.report.id, + pcListingId: params.report.pcListingId, + reviewedBy: params.reviewerId, + reason: params.report.reason, + }, + }) + return + } + + if (params.nextStatus === ReportStatus.DISMISSED) { + await trustService.logAction({ + userId: params.report.reportedById, + action: TrustAction.FALSE_REPORT, + metadata: { + reportId: params.report.id, + pcListingId: params.report.pcListingId, + reviewedBy: params.reviewerId, + reason: params.report.reason, + reviewNotes: params.reviewNotes, + }, + }) + } +} + +function translateListingReportModerationError(error: unknown): never { + if (isPrismaError(error, PRISMA_ERROR_CODES.RECORD_NOT_FOUND)) { + return ResourceError.listingReport.notFound() + } + + throw error +} + +function translatePcListingReportModerationError(error: unknown): never { + if (isPrismaError(error, PRISMA_ERROR_CODES.RECORD_NOT_FOUND)) { + return ResourceError.pcListingReport.notFound() + } + + throw error +} + +export class ReportModerationService { + constructor(private readonly prisma: PrismaClient) {} + + async updateListingReportStatus(input: UpdateListingReportStatusInput) { + try { + return await this.prisma.$transaction(async (tx) => { + const repository = new ReportModerationRepository(tx) + const report = await repository.findListingReportForModeration(input.id) + + if (!report) return ResourceError.listingReport.notFound() + + assertCanTransitionReportStatus({ + currentStatus: report.status, + nextStatus: input.status, + onFinalStatusChange: ResourceError.listingReport.cannotChangeFinalStatus, + }) + + const statusChanged = report.status !== input.status + const reviewedAt = new Date() + + if ( + shouldRejectReportedContent({ + statusChanged, + nextStatus: input.status, + currentListingStatus: report.listing?.status, + }) + ) { + await repository.rejectListingFromReport({ + listingId: report.listingId, + reviewerId: input.reviewerId, + reviewNotes: input.reviewNotes, + processedAt: reviewedAt, + }) + } + + if (statusChanged && isFinalReportStatus(input.status)) { + await applyListingReportTrustEffect({ + tx, + report, + nextStatus: input.status, + reviewerId: input.reviewerId, + reviewNotes: input.reviewNotes, + }) + } + + return repository.updateListingReportStatus({ + id: input.id, + status: input.status, + reviewNotes: input.reviewNotes, + reviewerId: input.reviewerId, + reviewedAt, + }) + }) + } catch (error) { + translateListingReportModerationError(error) + } + } + + async updatePcListingReportStatus(input: UpdatePcListingReportStatusInput) { + try { + return await this.prisma.$transaction(async (tx) => { + const repository = new ReportModerationRepository(tx) + const report = await repository.findPcListingReportForModeration(input.reportId) + + if (!report) return ResourceError.pcListingReport.notFound() + + assertCanTransitionReportStatus({ + currentStatus: report.status, + nextStatus: input.status, + onFinalStatusChange: ResourceError.pcListingReport.cannotChangeFinalStatus, + }) + + const statusChanged = report.status !== input.status + const reviewedAt = new Date() + + if ( + shouldRejectReportedContent({ + statusChanged, + nextStatus: input.status, + currentListingStatus: report.pcListing?.status, + }) + ) { + await repository.rejectPcListingFromReport({ + pcListingId: report.pcListingId, + reviewerId: input.reviewerId, + reviewNotes: input.reviewNotes, + processedAt: reviewedAt, + }) + } + + if (statusChanged && isFinalReportStatus(input.status)) { + await applyPcListingReportTrustEffect({ + tx, + report, + nextStatus: input.status, + reviewerId: input.reviewerId, + reviewNotes: input.reviewNotes, + }) + } + + return repository.updatePcListingReportStatus({ + id: input.reportId, + status: input.status, + reviewNotes: input.reviewNotes, + reviewerId: input.reviewerId, + reviewedAt, + }) + }) + } catch (error) { + translatePcListingReportModerationError(error) + } + } + + async deleteListingReport(id: string) { + try { + return await new ReportModerationRepository(this.prisma).deleteListingReport(id) + } catch (error) { + translateListingReportModerationError(error) + } + } + + async deletePcListingReport(id: string) { + try { + return await new ReportModerationRepository(this.prisma).deletePcListingReport(id) + } catch (error) { + translatePcListingReportModerationError(error) + } + } +} From b5173cd764f69d8397f4e685261c88966dfc08c0 Mon Sep 17 00:00:00 2001 From: Producdevity Date: Sat, 13 Jun 2026 15:21:56 +0200 Subject: [PATCH 72/87] fix: harden pc listing moderation workflows --- .../api/routers/listings/comments.test.ts | 20 +- src/server/api/routers/pcListings.test.ts | 199 +++++++++++++++++- src/server/api/routers/pcListings/admin.ts | 137 ++++++------ src/server/api/routers/pcListings/comments.ts | 5 +- .../api/routers/pcListings/utils.test.ts | 22 ++ src/server/api/routers/pcListings/utils.ts | 7 +- .../repositories/comments.repository.ts | 10 +- .../pc-listing-bulk-moderation.repository.ts | 97 +++++++++ .../services/listing-comment.service.ts | 5 +- .../pc-listing-bulk-moderation.service.ts | 152 +++++++++++++ src/server/utils/security-validation.ts | 6 +- 11 files changed, 569 insertions(+), 91 deletions(-) create mode 100644 src/server/api/routers/pcListings/utils.test.ts create mode 100644 src/server/repositories/pc-listing-bulk-moderation.repository.ts create mode 100644 src/server/services/pc-listing-bulk-moderation.service.ts diff --git a/src/server/api/routers/listings/comments.test.ts b/src/server/api/routers/listings/comments.test.ts index 8bbf03e64..a259f5e67 100644 --- a/src/server/api/routers/listings/comments.test.ts +++ b/src/server/api/routers/listings/comments.test.ts @@ -301,7 +301,7 @@ describe('handheld comments router — create', () => { it('emits reply notification and analytics for a child comment', async () => { const { caller, prisma } = createCaller() - prisma.comment.findUnique.mockResolvedValue({ id: PARENT_COMMENT_ID }) + prisma.comment.findUnique.mockResolvedValue({ listingId: LISTING_ID }) await caller.create({ listingId: LISTING_ID, @@ -405,6 +405,24 @@ describe('handheld comments router — create', () => { expect(prisma.comment.create).not.toHaveBeenCalled() }) + it('does not check spam or create when the parent comment belongs to another handheld report', async () => { + const { caller, prisma } = createCaller() + prisma.comment.findUnique.mockResolvedValue({ + listingId: '00000000-0000-4000-a000-000000000099', + }) + + await expect( + caller.create({ + listingId: LISTING_ID, + content: 'Replying with more settings', + parentId: PARENT_COMMENT_ID, + }), + ).rejects.toThrow('Parent comment not found') + + expect(mockCheckSpamContent).not.toHaveBeenCalled() + expect(prisma.comment.create).not.toHaveBeenCalled() + }) + it('passes a human verification token to the spam check when retrying creation', async () => { const { caller, prisma } = createCaller() diff --git a/src/server/api/routers/pcListings.test.ts b/src/server/api/routers/pcListings.test.ts index 355a86f71..d4e5a0149 100644 --- a/src/server/api/routers/pcListings.test.ts +++ b/src/server/api/routers/pcListings.test.ts @@ -192,6 +192,10 @@ function createMockPrisma() { findUnique: vi.fn(), update: vi.fn().mockResolvedValue({ id: COMMENT_ID, score: 1 }), }, + pcListingCustomFieldValue: { + deleteMany: vi.fn().mockResolvedValue({ count: 0 }), + createMany: vi.fn().mockResolvedValue({ count: 0 }), + }, pcListing: { findUnique: vi.fn(), findMany: vi.fn().mockResolvedValue([]), @@ -219,6 +223,9 @@ function createMockPrisma() { userBan: { findMany: vi.fn().mockResolvedValue([]), }, + verifiedDeveloper: { + findMany: vi.fn().mockResolvedValue([]), + }, } return { @@ -487,6 +494,25 @@ describe('pcListings trust integration', () => { }) expect(prisma.pcListingComment.create).toHaveBeenCalled() }) + + it('rejects replies when the parent comment belongs to another PC report', async () => { + const { caller, prisma } = createCaller() + prisma.pcListing.findUnique.mockResolvedValue({ id: LISTING_ID, authorId: AUTHOR_ID }) + prisma.pcListingComment.findUnique.mockResolvedValue({ + pcListingId: '00000000-0000-4000-a000-000000000099', + }) + + await expect( + caller.createComment({ + pcListingId: LISTING_ID, + content: 'Reply attached to the wrong report', + parentId: COMMENT_ID, + }), + ).rejects.toThrow('Parent comment not found') + + expect(mockCheckSpamContent).not.toHaveBeenCalled() + expect(prisma.pcListingComment.create).not.toHaveBeenCalled() + }) }) describe('create', () => { @@ -1041,14 +1067,15 @@ describe('pcListings trust integration', () => { role: Role.MODERATOR, permissions: [PERMISSIONS.APPROVE_LISTINGS], }) - prisma.pcListing.findUnique.mockResolvedValue({ - id: LISTING_ID, - gameId, - cpuId, - gpuId: null, - status: ApprovalStatus.PENDING, - customFieldValues: [], - }) + prisma.pcListing.findUnique + .mockResolvedValueOnce({ + id: LISTING_ID, + gameId, + cpuId, + gpuId: null, + status: ApprovalStatus.PENDING, + }) + .mockResolvedValueOnce(updatedListing) prisma.pcListing.update.mockResolvedValue(updatedListing) await caller.updateAdmin({ @@ -1072,6 +1099,70 @@ describe('pcListings trust integration', () => { }) expect(invalidatePcListingSeoForUpdate).not.toHaveBeenCalled() }) + + it('replaces custom field values inside the admin update transaction and returns the final report', async () => { + const gameId = '00000000-0000-4000-a000-000000000040' + const cpuId = '00000000-0000-4000-a000-000000000070' + const emulatorId = '00000000-0000-4000-a000-000000000060' + const customFieldDefinitionId = '00000000-0000-4000-a000-000000000090' + const updatedListing = { + id: LISTING_ID, + gameId, + cpuId, + gpuId: null, + status: ApprovalStatus.APPROVED, + customFieldValues: [ + { + customFieldDefinitionId, + value: 'Enabled', + }, + ], + } + + const { caller, prisma } = createCaller({ + userId: ADMIN_ID, + role: Role.MODERATOR, + permissions: [PERMISSIONS.APPROVE_LISTINGS], + }) + prisma.pcListing.findUnique + .mockResolvedValueOnce({ + id: LISTING_ID, + gameId, + cpuId, + gpuId: null, + status: ApprovalStatus.APPROVED, + }) + .mockResolvedValueOnce(updatedListing) + + const result = await caller.updateAdmin({ + id: LISTING_ID, + gameId, + cpuId, + emulatorId, + performanceId: 1, + memorySize: 16, + os: PcOs.WINDOWS, + osVersion: '11', + notes: 'Updated report', + status: ApprovalStatus.APPROVED, + customFieldValues: [{ customFieldDefinitionId, value: 'Enabled' }], + }) + + expect(prisma.$transaction).toHaveBeenCalled() + expect(prisma.pcListingCustomFieldValue.deleteMany).toHaveBeenCalledWith({ + where: { pcListingId: LISTING_ID }, + }) + expect(prisma.pcListingCustomFieldValue.createMany).toHaveBeenCalledWith({ + data: [ + { + pcListingId: LISTING_ID, + customFieldDefinitionId, + value: 'Enabled', + }, + ], + }) + expect(result).toBe(updatedListing) + }) }) describe('bulkApprove', () => { @@ -1081,6 +1172,7 @@ describe('pcListings trust integration', () => { gameId: '00000000-0000-4000-a000-000000000040', cpuId: '00000000-0000-4000-a000-000000000070', gpuId: '00000000-0000-4000-a000-000000000080', + emulatorId: '00000000-0000-4000-a000-000000000060', authorId: AUTHOR_ID, } const listing2 = { @@ -1088,6 +1180,7 @@ describe('pcListings trust integration', () => { gameId: '00000000-0000-4000-a000-000000000041', cpuId: '00000000-0000-4000-a000-000000000071', gpuId: null, + emulatorId: '00000000-0000-4000-a000-000000000061', authorId: '00000000-0000-4000-a000-000000000050', } @@ -1097,6 +1190,14 @@ describe('pcListings trust integration', () => { await caller.bulkApprove({ pcListingIds: [listing1.id, listing2.id] }) + expect(prisma.pcListing.updateMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + id: { in: [listing1.id, listing2.id] }, + status: ApprovalStatus.PENDING, + }, + }), + ) expect(mockApplyTrustAction).toHaveBeenCalledTimes(2) expect(mockApplyTrustAction).toHaveBeenCalledWith({ userId: AUTHOR_ID, @@ -1110,14 +1211,65 @@ describe('pcListings trust integration', () => { }) expect(invalidatePcListingsSeo).toHaveBeenCalledWith([listing1, listing2]) }) + + it('prevents developers from bulk approving PC reports for unverified emulators', async () => { + const { caller, prisma } = createCaller({ userId: ADMIN_ID, role: Role.DEVELOPER }) + prisma.pcListing.findMany.mockResolvedValue([ + { + id: LISTING_ID, + gameId: '00000000-0000-4000-a000-000000000040', + cpuId: '00000000-0000-4000-a000-000000000070', + gpuId: null, + emulatorId: '00000000-0000-4000-a000-000000000060', + authorId: AUTHOR_ID, + }, + ]) + prisma.verifiedDeveloper.findMany.mockResolvedValue([ + { emulatorId: '00000000-0000-4000-a000-000000000061' }, + ]) + + await expect(caller.bulkApprove({ pcListingIds: [LISTING_ID] })).rejects.toThrow( + 'You can only approve PC listings for emulators you are verified for', + ) + + expect(prisma.pcListing.updateMany).not.toHaveBeenCalled() + expect(mockApplyTrustAction).not.toHaveBeenCalled() + }) + + it('does not emit side effects when a pending PC report changes before bulk approve writes', async () => { + const listing = { + id: LISTING_ID, + gameId: '00000000-0000-4000-a000-000000000040', + cpuId: '00000000-0000-4000-a000-000000000070', + gpuId: null, + emulatorId: '00000000-0000-4000-a000-000000000060', + authorId: AUTHOR_ID, + } + + const { caller, prisma } = createCaller({ userId: ADMIN_ID, role: Role.MODERATOR }) + prisma.pcListing.findMany.mockResolvedValue([listing]) + prisma.pcListing.updateMany.mockResolvedValue({ count: 0 }) + + await expect(caller.bulkApprove({ pcListingIds: [listing.id] })).rejects.toThrow( + 'Some selected PC reports were already processed', + ) + + expect(mockApplyTrustAction).not.toHaveBeenCalled() + expect(invalidatePcListingsSeo).not.toHaveBeenCalled() + }) }) describe('bulkReject', () => { it('calls applyTrustAction with LISTING_REJECTED for each listing author', async () => { - const listing1 = { id: LISTING_ID, authorId: AUTHOR_ID } + const listing1 = { + id: LISTING_ID, + authorId: AUTHOR_ID, + emulatorId: '00000000-0000-4000-a000-000000000060', + } const listing2 = { id: '00000000-0000-4000-a000-000000000011', authorId: '00000000-0000-4000-a000-000000000050', + emulatorId: '00000000-0000-4000-a000-000000000061', } const { caller, prisma } = createCaller({ userId: ADMIN_ID, role: Role.MODERATOR }) @@ -1126,6 +1278,14 @@ describe('pcListings trust integration', () => { await caller.bulkReject({ pcListingIds: [listing1.id, listing2.id], notes: 'Spam' }) + expect(prisma.pcListing.updateMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + id: { in: [listing1.id, listing2.id] }, + status: ApprovalStatus.PENDING, + }, + }), + ) expect(mockApplyTrustAction).toHaveBeenCalledTimes(2) expect(mockApplyTrustAction).toHaveBeenCalledWith({ userId: AUTHOR_ID, @@ -1144,6 +1304,27 @@ describe('pcListings trust integration', () => { }), }) }) + + it('prevents developers from bulk rejecting PC reports for unverified emulators', async () => { + const { caller, prisma } = createCaller({ userId: ADMIN_ID, role: Role.DEVELOPER }) + prisma.pcListing.findMany.mockResolvedValue([ + { + id: LISTING_ID, + authorId: AUTHOR_ID, + emulatorId: '00000000-0000-4000-a000-000000000060', + }, + ]) + prisma.verifiedDeveloper.findMany.mockResolvedValue([ + { emulatorId: '00000000-0000-4000-a000-000000000061' }, + ]) + + await expect( + caller.bulkReject({ pcListingIds: [LISTING_ID], notes: 'Spam' }), + ).rejects.toThrow('You can only reject PC listings for emulators you are verified for') + + expect(prisma.pcListing.updateMany).not.toHaveBeenCalled() + expect(mockApplyTrustAction).not.toHaveBeenCalled() + }) }) describe('autoRejectRisky', () => { diff --git a/src/server/api/routers/pcListings/admin.ts b/src/server/api/routers/pcListings/admin.ts index a3c4e4a3a..f1087f7b5 100644 --- a/src/server/api/routers/pcListings/admin.ts +++ b/src/server/api/routers/pcListings/admin.ts @@ -37,6 +37,7 @@ import { } from '@/server/cache/invalidation' import { NOTIFICATION_EVENTS, notificationEventEmitter } from '@/server/notifications/eventEmitter' import { PcListingsRepository } from '@/server/repositories/pc-listings.repository' +import { PcListingBulkModerationService } from '@/server/services/pc-listing-bulk-moderation.service' import { autoRejectRiskyPcReports } from '@/server/services/review-risk-auto-reject.service' import { attachReviewRiskProfiles, @@ -443,29 +444,17 @@ export const adminRouter = createTRPCRouter({ bulkApprove: protectedProcedure .input(BulkApprovePcListingsSchema) .mutation(async ({ ctx, input }) => { - const isModerator = hasRolePermission(ctx.session.user.role, Role.MODERATOR) - const isDeveloper = hasRolePermission(ctx.session.user.role, Role.DEVELOPER) - - if (!isModerator && !isDeveloper) { - return ResourceError.pcListing.requiresDeveloperToApprove() - } - - const pendingListings = await ctx.prisma.pcListing.findMany({ - where: { id: { in: input.pcListingIds }, status: ApprovalStatus.PENDING }, - select: { id: true, gameId: true, cpuId: true, gpuId: true, authorId: true }, - }) - const approvedAt = new Date() - - const result = await ctx.prisma.pcListing.updateMany({ - where: { id: { in: pendingListings.map((l) => l.id) } }, - data: { - status: ApprovalStatus.APPROVED, - processedAt: approvedAt, - processedByUserId: ctx.session.user.id, + const adminUserId = ctx.session.user.id + const bulkModeration = new PcListingBulkModerationService(ctx.prisma) + const transactionResult = await bulkModeration.bulkApprove({ + pcListingIds: input.pcListingIds, + actor: { + userId: adminUserId, + role: ctx.session.user.role, }, }) - const listingsWithAuthor = pendingListings.filter( + const listingsWithAuthor = transactionResult.pcListings.filter( (l): l is typeof l & { authorId: string } => l.authorId !== null, ) await Promise.all( @@ -475,7 +464,7 @@ export const adminRouter = createTRPCRouter({ action: TrustAction.LISTING_APPROVED, context: { pcListingId: listing.id, - adminUserId: ctx.session.user.id, + adminUserId, reason: 'bulk_listing_approved', }, }), @@ -484,55 +473,42 @@ export const adminRouter = createTRPCRouter({ invalidatePcListingStatsCache() - await invalidatePcListingsSeo(pendingListings) + await invalidatePcListingsSeo(transactionResult.pcListings) - for (const listing of pendingListings) { + for (const listing of transactionResult.pcListings) { notificationEventEmitter.emitNotificationEvent({ eventType: NOTIFICATION_EVENTS.PC_LISTING_APPROVED, entityType: 'pcListing', entityId: listing.id, - triggeredBy: ctx.session.user.id, + triggeredBy: adminUserId, payload: { pcListingId: listing.id, gameId: listing.gameId, - approvedBy: ctx.session.user.id, - approvedAt, + approvedBy: adminUserId, + approvedAt: transactionResult.processedAt, bulk: true, }, }) } - return { count: result.count } + return { count: transactionResult.count } }), bulkReject: protectedProcedure .input(BulkRejectPcListingsSchema) .mutation(async ({ ctx, input }) => { - const isModerator = hasRolePermission(ctx.session.user.role, Role.MODERATOR) - const isDeveloper = hasRolePermission(ctx.session.user.role, Role.DEVELOPER) - - if (!isModerator && !isDeveloper) { - return ResourceError.pcListing.requiresDeveloperToReject() - } - - const pendingListings = await ctx.prisma.pcListing.findMany({ - where: { id: { in: input.pcListingIds }, status: ApprovalStatus.PENDING }, - select: { id: true, authorId: true }, - }) - - const result = await ctx.prisma.pcListing.updateMany({ - where: { - id: { in: pendingListings.map((l) => l.id) }, - }, - data: { - status: ApprovalStatus.REJECTED, - processedAt: new Date(), - processedByUserId: ctx.session.user.id, - processedNotes: input.notes, + const adminUserId = ctx.session.user.id + const bulkModeration = new PcListingBulkModerationService(ctx.prisma) + const transactionResult = await bulkModeration.bulkReject({ + pcListingIds: input.pcListingIds, + notes: input.notes, + actor: { + userId: adminUserId, + role: ctx.session.user.role, }, }) - const listingsWithAuthor = pendingListings.filter( + const listingsWithAuthor = transactionResult.pcListings.filter( (l): l is typeof l & { authorId: string } => l.authorId !== null, ) await Promise.all( @@ -542,7 +518,7 @@ export const adminRouter = createTRPCRouter({ action: TrustAction.LISTING_REJECTED, context: { pcListingId: listing.id, - adminUserId: ctx.session.user.id, + adminUserId, reason: input.notes || 'bulk_listing_rejected', }, }), @@ -551,22 +527,22 @@ export const adminRouter = createTRPCRouter({ invalidatePcListingStatsCache() - for (const listing of pendingListings) { + for (const listing of transactionResult.pcListings) { notificationEventEmitter.emitNotificationEvent({ eventType: NOTIFICATION_EVENTS.PC_LISTING_REJECTED, entityType: 'pcListing', entityId: listing.id, - triggeredBy: ctx.session.user.id, + triggeredBy: adminUserId, payload: { pcListingId: listing.id, - rejectedBy: ctx.session.user.id, - rejectedAt: new Date(), + rejectedBy: adminUserId, + rejectedAt: transactionResult.processedAt, rejectionReason: input.notes, }, }) } - return { count: result.count } + return { count: transactionResult.count } }), autoRejectRiskyPreview: adminProcedure.query(async ({ ctx }) => { @@ -666,32 +642,49 @@ export const adminRouter = createTRPCRouter({ const pcListing = await ctx.prisma.pcListing.findUnique({ where: { id }, - include: { customFieldValues: true }, + select: { + id: true, + gameId: true, + cpuId: true, + gpuId: true, + status: true, + }, }) if (!pcListing) return ResourceError.pcListing.notFound() - const updatedPcListing = await ctx.prisma.pcListing.update({ - where: { id }, - data: { ...data, updatedAt: new Date() }, - include: pcListingDetailInclude, - }) + const customFieldCreateData = customFieldValues?.map((cfv) => ({ + pcListingId: id, + customFieldDefinitionId: cfv.customFieldDefinitionId, + value: toPrismaCustomFieldValue(cfv.value), + })) - if (customFieldValues) { - await ctx.prisma.pcListingCustomFieldValue.deleteMany({ - where: { pcListingId: id }, + const updatedPcListing = await ctx.prisma.$transaction(async (tx) => { + await tx.pcListing.update({ + where: { id }, + data: { ...data, updatedAt: new Date() }, }) - if (customFieldValues.length > 0) { - await ctx.prisma.pcListingCustomFieldValue.createMany({ - data: customFieldValues.map((cfv) => ({ - pcListingId: id, - customFieldDefinitionId: cfv.customFieldDefinitionId, - value: toPrismaCustomFieldValue(cfv.value), - })), + if (customFieldCreateData !== undefined) { + await tx.pcListingCustomFieldValue.deleteMany({ + where: { pcListingId: id }, }) + + if (customFieldCreateData.length > 0) { + await tx.pcListingCustomFieldValue.createMany({ + data: customFieldCreateData, + }) + } } - } + + const finalPcListing = await tx.pcListing.findUnique({ + where: { id }, + include: pcListingDetailInclude, + }) + + if (!finalPcListing) return ResourceError.pcListing.notFound() + return finalPcListing + }) const previousSeoTarget = { id, diff --git a/src/server/api/routers/pcListings/comments.ts b/src/server/api/routers/pcListings/comments.ts index 3dac592b1..dd29cffca 100644 --- a/src/server/api/routers/pcListings/comments.ts +++ b/src/server/api/routers/pcListings/comments.ts @@ -140,9 +140,12 @@ export const commentsRouter = createTRPCRouter({ if (parentId) { const parentComment = await ctx.prisma.pcListingComment.findUnique({ where: { id: parentId }, + select: { pcListingId: true }, }) - if (!parentComment) return ResourceError.comment.parentNotFound() + if (!parentComment || parentComment.pcListingId !== pcListingId) { + return ResourceError.comment.parentNotFound() + } } await checkSpamContent({ diff --git a/src/server/api/routers/pcListings/utils.test.ts b/src/server/api/routers/pcListings/utils.test.ts new file mode 100644 index 000000000..11565a1bc --- /dev/null +++ b/src/server/api/routers/pcListings/utils.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest' +import { toPrismaCustomFieldValue } from './utils' + +describe('pcListings router utilities', () => { + it('preserves JSON-compatible custom field values', () => { + expect( + toPrismaCustomFieldValue({ + enabled: true, + values: ['quality', 60, null], + }), + ).toEqual({ + enabled: true, + values: ['quality', 60, null], + }) + }) + + it('rejects non-plain objects instead of converting them to empty records', () => { + expect(() => toPrismaCustomFieldValue(new Date('2026-01-01T00:00:00.000Z'))).toThrow( + 'Invalid input for field: customFieldValues', + ) + }) +}) diff --git a/src/server/api/routers/pcListings/utils.ts b/src/server/api/routers/pcListings/utils.ts index 4c4d4faea..e467eb202 100644 --- a/src/server/api/routers/pcListings/utils.ts +++ b/src/server/api/routers/pcListings/utils.ts @@ -9,7 +9,12 @@ export function invalidatePcListingStatsCache(): void { } function isJsonRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value) + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + return false + } + + const prototype = Object.getPrototypeOf(value) + return prototype === Object.prototype || prototype === null } function toPrismaNestedJsonValue(value: unknown): Prisma.InputJsonValue | null { diff --git a/src/server/repositories/comments.repository.ts b/src/server/repositories/comments.repository.ts index 441f5cca3..649c16d13 100644 --- a/src/server/repositories/comments.repository.ts +++ b/src/server/repositories/comments.repository.ts @@ -133,13 +133,17 @@ export class CommentsRepository extends BaseRepository { return listing !== null } - async commentExists(commentId: string): Promise { + async commentBelongsToListing(commentId: string, listingId: string): Promise { const comment = await this.handleDatabaseOperation( - () => this.prisma.comment.findUnique({ where: { id: commentId }, select: { id: true } }), + () => + this.prisma.comment.findUnique({ + where: { id: commentId }, + select: { listingId: true }, + }), 'Comment', ) - return comment !== null + return comment?.listingId === listingId } async userExists(userId: string): Promise { diff --git a/src/server/repositories/pc-listing-bulk-moderation.repository.ts b/src/server/repositories/pc-listing-bulk-moderation.repository.ts new file mode 100644 index 000000000..e758c3924 --- /dev/null +++ b/src/server/repositories/pc-listing-bulk-moderation.repository.ts @@ -0,0 +1,97 @@ +import { + PrismaRepository, + type PrismaRepositoryClient, +} from '@/server/persistence/prisma.repository' +import { ApprovalStatus, type Prisma } from '@orm/client' + +const PC_BULK_APPROVE_SELECT = { + id: true, + gameId: true, + cpuId: true, + gpuId: true, + authorId: true, + emulatorId: true, +} satisfies Prisma.PcListingSelect + +const PC_BULK_REJECT_SELECT = { + id: true, + authorId: true, + emulatorId: true, +} satisfies Prisma.PcListingSelect + +export type PcBulkApproveTarget = Prisma.PcListingGetPayload<{ + select: typeof PC_BULK_APPROVE_SELECT +}> + +export type PcBulkRejectTarget = Prisma.PcListingGetPayload<{ + select: typeof PC_BULK_REJECT_SELECT +}> + +export type PcBulkModerationTarget = PcBulkApproveTarget | PcBulkRejectTarget + +export class PcListingBulkModerationRepository extends PrismaRepository { + constructor(prisma: PrismaRepositoryClient) { + super(prisma) + } + + listPendingForBulkApprove(pcListingIds: string[]): Promise { + return this.prisma.pcListing.findMany({ + where: { id: { in: pcListingIds }, status: ApprovalStatus.PENDING }, + select: PC_BULK_APPROVE_SELECT, + }) + } + + listPendingForBulkReject(pcListingIds: string[]): Promise { + return this.prisma.pcListing.findMany({ + where: { id: { in: pcListingIds }, status: ApprovalStatus.PENDING }, + select: PC_BULK_REJECT_SELECT, + }) + } + + async listVerifiedEmulatorIds(userId: string): Promise { + const verifiedDevelopers = await this.prisma.verifiedDeveloper.findMany({ + where: { userId }, + select: { emulatorId: true }, + }) + + return verifiedDevelopers.map((verification) => verification.emulatorId) + } + + approvePendingByIds(params: { + pcListingIds: string[] + processedByUserId: string + processedAt: Date + }): Promise { + return this.prisma.pcListing.updateMany({ + where: { + id: { in: params.pcListingIds }, + status: ApprovalStatus.PENDING, + }, + data: { + status: ApprovalStatus.APPROVED, + processedAt: params.processedAt, + processedByUserId: params.processedByUserId, + }, + }) + } + + rejectPendingByIds(params: { + pcListingIds: string[] + processedByUserId: string + processedAt: Date + processedNotes?: string + }): Promise { + return this.prisma.pcListing.updateMany({ + where: { + id: { in: params.pcListingIds }, + status: ApprovalStatus.PENDING, + }, + data: { + status: ApprovalStatus.REJECTED, + processedAt: params.processedAt, + processedByUserId: params.processedByUserId, + processedNotes: params.processedNotes, + }, + }) + } +} diff --git a/src/server/services/listing-comment.service.ts b/src/server/services/listing-comment.service.ts index 4dd96192b..86fdd25c3 100644 --- a/src/server/services/listing-comment.service.ts +++ b/src/server/services/listing-comment.service.ts @@ -27,7 +27,10 @@ export class ListingCommentService { return ResourceError.listing.notFound() } - if (input.parentId && !(await this.comments.commentExists(input.parentId))) { + if ( + input.parentId && + !(await this.comments.commentBelongsToListing(input.parentId, input.listingId)) + ) { return ResourceError.comment.parentNotFound() } diff --git a/src/server/services/pc-listing-bulk-moderation.service.ts b/src/server/services/pc-listing-bulk-moderation.service.ts new file mode 100644 index 000000000..1af76c57f --- /dev/null +++ b/src/server/services/pc-listing-bulk-moderation.service.ts @@ -0,0 +1,152 @@ +import { ResourceError } from '@/lib/errors' +import { + PcListingBulkModerationRepository, + type PcBulkApproveTarget, + type PcBulkModerationTarget, +} from '@/server/repositories/pc-listing-bulk-moderation.repository' +import { hasRolePermission } from '@/utils/permissions' +import { Role, type PrismaClient, type Role as UserRole } from '@orm/client' + +type PcBulkModerationAction = 'approve' | 'reject' + +interface PcBulkModerationActor { + userId: string + role: UserRole +} + +interface BulkApprovePcListingsInput { + pcListingIds: string[] + actor: PcBulkModerationActor +} + +interface BulkRejectPcListingsInput { + pcListingIds: string[] + notes?: string + actor: PcBulkModerationActor +} + +interface PcBulkModerationResult { + pcListings: TListing[] + count: number + processedAt: Date +} + +function canModerateAsModerator(role: UserRole): boolean { + return hasRolePermission(role, Role.MODERATOR) +} + +function canModerateAsDeveloper(role: UserRole): boolean { + return hasRolePermission(role, Role.DEVELOPER) +} + +async function assertDeveloperCanModeratePcListings(params: { + repository: PcListingBulkModerationRepository + userId: string + action: PcBulkModerationAction + pcListings: PcBulkModerationTarget[] +}): Promise { + const verifiedEmulatorIds = new Set( + await params.repository.listVerifiedEmulatorIds(params.userId), + ) + const hasUnauthorizedListings = params.pcListings.some( + (pcListing) => !verifiedEmulatorIds.has(pcListing.emulatorId), + ) + + if (!hasUnauthorizedListings) return + + if (params.action === 'approve') return ResourceError.pcListing.mustBeVerifiedToApprove() + return ResourceError.pcListing.mustBeVerifiedToReject() +} + +function assertBulkUpdateCount(expectedCount: number, actualCount: number): void { + if (expectedCount === actualCount) return + + ResourceError.pcListing.bulkAlreadyProcessed() +} + +export class PcListingBulkModerationService { + constructor(private readonly prisma: PrismaClient) {} + + async bulkApprove( + input: BulkApprovePcListingsInput, + ): Promise> { + const isModerator = canModerateAsModerator(input.actor.role) + const isDeveloper = canModerateAsDeveloper(input.actor.role) + + if (!isModerator && !isDeveloper) { + return ResourceError.pcListing.requiresDeveloperToApprove() + } + + const processedAt = new Date() + + return this.prisma.$transaction(async (tx) => { + const repository = new PcListingBulkModerationRepository(tx) + const pendingListings = await repository.listPendingForBulkApprove(input.pcListingIds) + + if (!isModerator && isDeveloper) { + await assertDeveloperCanModeratePcListings({ + repository, + userId: input.actor.userId, + action: 'approve', + pcListings: pendingListings, + }) + } + + if (pendingListings.length === 0) { + return { pcListings: pendingListings, count: 0, processedAt } + } + + const result = await repository.approvePendingByIds({ + pcListingIds: pendingListings.map((pcListing) => pcListing.id), + processedByUserId: input.actor.userId, + processedAt, + }) + + assertBulkUpdateCount(pendingListings.length, result.count) + + return { pcListings: pendingListings, count: result.count, processedAt } + }) + } + + async bulkReject( + input: BulkRejectPcListingsInput, + ): Promise> { + const isModerator = canModerateAsModerator(input.actor.role) + const isDeveloper = canModerateAsDeveloper(input.actor.role) + + if (!isModerator && !isDeveloper) { + return ResourceError.pcListing.requiresDeveloperToReject() + } + + const processedAt = new Date() + + return this.prisma.$transaction(async (tx) => { + const repository = new PcListingBulkModerationRepository(tx) + const pendingListings = await repository.listPendingForBulkReject(input.pcListingIds) + + if (!isModerator && isDeveloper) { + await assertDeveloperCanModeratePcListings({ + repository, + userId: input.actor.userId, + action: 'reject', + pcListings: pendingListings, + }) + } + + if (pendingListings.length === 0) { + return { pcListings: pendingListings, count: 0, processedAt } + } + + const result = await repository.rejectPendingByIds({ + pcListingIds: pendingListings.map((pcListing) => pcListing.id), + processedByUserId: input.actor.userId, + processedAt, + processedNotes: input.notes, + }) + + assertBulkUpdateCount(pendingListings.length, result.count) + + return { pcListings: pendingListings, count: result.count, processedAt } + }) + } +} diff --git a/src/server/utils/security-validation.ts b/src/server/utils/security-validation.ts index df8dbf149..570128577 100644 --- a/src/server/utils/security-validation.ts +++ b/src/server/utils/security-validation.ts @@ -1,5 +1,5 @@ import { AppError } from '@/lib/errors' -// TODO: carefully consider wtf this file is. seems like none of this is how it should be done. +// TODO: Replace this module with schema-level validation and purpose-built sanitization; see #442. /** * Security validation utilities for critical runtime parameters @@ -75,7 +75,7 @@ export function validateEnum( /** * Validates pagination parameters * Prevents excessive data retrieval - * TODO: this needs to get the fuck out of here. zod validates, this is bs. + * TODO: Move pagination constraints into Zod input schemas and delete this helper; see #442. */ export function validatePagination( page?: number, @@ -91,7 +91,7 @@ export function validatePagination( /** * Sanitizes user input to prevent XSS and injection * Removes potentially dangerous characters - * TODO: this is like insufficient or not the proper way of doing it. + * TODO: Replace denylist sanitization with field-specific escaping or a sanitizer library; see #442. */ export function sanitizeInput(input: string): string { return input From 92bfd2d8bb7c0cfc57bb54df7ffb1cbf26766fc0 Mon Sep 17 00:00:00 2001 From: Producdevity Date: Mon, 15 Jun 2026 20:09:17 +0200 Subject: [PATCH 73/87] chore: update AGENTS.md with rules about types/interfaces --- AGENTS.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 5d7d96088..351402462 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -98,6 +98,12 @@ This file is the source of working guidance for AI coding agents in this reposit - Do not use casts to hide type problems. Fix the underlying type issue. - Handle null and undefined explicitly. - Use generated Prisma types where appropriate. +- Prefer deriving types from existing contracts instead of hand-maintaining + structural copies. Use Prisma `GetPayload`, Zod `z.input`/`z.output`, tRPC + `RouterInput`/`RouterOutput`, `ReturnType`, and `typeof` on const contracts + before adding a new interface or structural type alias. Add new manual + interfaces/types only for genuinely new UI/application state or external + boundaries that cannot be inferred, and keep them narrow and local. - Do not add unused functions, exports, or speculative helpers. - Remove dead code when refactoring. - Do not remove or rewrite existing TODO comments unless the user explicitly From 983ff70eb9ecbb64653ee2448d6c0be241ef4135 Mon Sep 17 00:00:00 2001 From: Producdevity Date: Tue, 16 Jun 2026 15:11:38 +0200 Subject: [PATCH 74/87] refactor: always use async listing filters --- .env.docker.example | 1 - .env.example | 1 - .env.test.example | 1 - src/app/listings/ListingsPage.tsx | 20 +- .../components/ListingsFiltersContent.tsx | 76 ++------ .../components/ListingsFiltersSidebar.tsx | 33 ++-- .../filters/AsyncDeviceFilterSelect.tsx | 7 +- .../filters/AsyncSocFilterSelect.tsx | 7 +- .../shared/utils/asyncListingFilters.ts | 3 - .../listings/shared/utils/selectedLabels.ts | 37 ---- src/app/pc-listings/PcListingsPage.tsx | 19 -- .../components/PcFiltersContent.tsx | 70 ++----- .../components/PcFiltersSidebar.tsx | 19 +- .../AsyncMultiSelect.test.tsx | 10 +- .../async-multi-select/AsyncMultiSelect.tsx | 48 +++-- src/data/constants.ts | 2 +- .../components/AsyncCpuFilterSelect.tsx | 7 +- .../components/AsyncGpuFilterSelect.tsx | 7 +- src/lib/analytics/actions.ts | 4 + src/lib/analytics/analytics.ts | 40 +++- src/lib/analytics/filterAnalytics.ts | 8 + src/utils/options.ts | 21 --- tests/async-filters.spec.ts | 174 ++++++++++++++++++ 23 files changed, 346 insertions(+), 269 deletions(-) delete mode 100644 src/app/listings/shared/utils/asyncListingFilters.ts create mode 100644 tests/async-filters.spec.ts diff --git a/.env.docker.example b/.env.docker.example index 2a067f147..deccc7ed8 100644 --- a/.env.docker.example +++ b/.env.docker.example @@ -76,7 +76,6 @@ NEXT_PUBLIC_GITHUB_URL="https://github.com/Producdevity/EmuReady" NEXT_PUBLIC_EMUREADY_LITE_GITHUB_URL="https://github.com/Producdevity/EmuReadyLite/releases" NEXT_PUBLIC_APP_URL="https://dev.emuready.com" NEXT_PUBLIC_ENABLE_SW=false -NEXT_PUBLIC_ENABLE_ASYNC_LISTINGS_FILTERS=false NEXT_TELEMETRY_DISABLED=1 NEXT_IMAGE_UNOPTIMIZED=false diff --git a/.env.example b/.env.example index c64100fbb..5f565192d 100644 --- a/.env.example +++ b/.env.example @@ -51,7 +51,6 @@ NEXT_PUBLIC_EMUREADY_LITE_GITHUB_URL="https://github.com/Producdevity/EmuReadyLi NEXT_PUBLIC_APP_URL="http://localhost:3000" # Make sure to change this if you are using a tunnel NEXT_PUBLIC_ENABLE_SW=false NEXT_PUBLIC_ENABLE_PATREON_VERIFICATION=true -NEXT_PUBLIC_ENABLE_ASYNC_LISTINGS_FILTERS=false NEXT_TELEMETRY_DISABLED=1 NEXT_IMAGE_UNOPTIMIZED=false diff --git a/.env.test.example b/.env.test.example index e5f219489..c1bb01153 100644 --- a/.env.test.example +++ b/.env.test.example @@ -40,7 +40,6 @@ NEXT_PUBLIC_APP_URL="https://dev.emuready.com" NEXT_PUBLIC_ENABLE_PATREON_VERIFICATION=true NEXT_PUBLIC_ENABLE_SW=false NEXT_PUBLIC_DISABLE_COOKIE_BANNER=true -NEXT_PUBLIC_ENABLE_ASYNC_LISTINGS_FILTERS=false NEXT_TELEMETRY_DISABLED=1 NEXT_IMAGE_UNOPTIMIZED=true diff --git a/src/app/listings/ListingsPage.tsx b/src/app/listings/ListingsPage.tsx index e0be88574..29b3ab047 100644 --- a/src/app/listings/ListingsPage.tsx +++ b/src/app/listings/ListingsPage.tsx @@ -11,7 +11,6 @@ import { MobileFiltersFab, ListingsTableSkeleton, } from '@/app/listings/shared/components' -import { shouldUseAsyncListingFilters } from '@/app/listings/shared/utils/asyncListingFilters' import CommunitySupportBanner from '@/components/banners/CommunitySupportBanner' import { EmulatorIcon, SystemIcon } from '@/components/icons' import { BannedUserBadge } from '@/components/ui/BannedUserBadge' @@ -30,7 +29,7 @@ import { SuccessRateBar } from '@/components/ui/SuccessRateBar' import { EditButton, ViewButton } from '@/components/ui/table-buttons' import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/Tooltip' import { VerifiedDeveloperBadge } from '@/components/ui/VerifiedDeveloperBadge' -import { CACHE_DURATIONS, LOOKUP_PAGINATION } from '@/data/constants' +import { CACHE_DURATIONS } from '@/data/constants' import storageKeys from '@/data/storageKeys' import { useEmulatorLogos, @@ -67,8 +66,6 @@ const LISTINGS_COLUMNS: ColumnDefinition[] = [ { key: 'actions', label: 'Actions', alwaysVisible: true }, ] -const USE_ASYNC_LISTING_FILTERS = shouldUseAsyncListingFilters() - function ListingsPage() { const { isSignedIn } = useUser() const router = useRouter() @@ -112,14 +109,6 @@ function ListingsPage() { }) const systemsQuery = api.systems.get.useQuery() - const devicesQuery = api.devices.options.useQuery( - { limit: LOOKUP_PAGINATION.MAX_LIMIT }, - { enabled: !USE_ASYNC_LISTING_FILTERS }, - ) - const socsQuery = api.socs.options.useQuery( - { limit: LOOKUP_PAGINATION.MAX_LIMIT }, - { enabled: !USE_ASYNC_LISTING_FILTERS }, - ) const emulatorsQuery = api.emulators.get.useQuery({ limit: 100 }) const performanceScalesQuery = api.listings.performanceScales.useQuery() @@ -231,9 +220,6 @@ function ListingsPage() { return
    Failed to load listings.
    } - const devicesForFilters = devicesQuery.data?.devices ?? [] - const socsForFilters = socsQuery.data?.socs ?? [] - return (
    @@ -247,8 +233,6 @@ function ListingsPage() { performanceIds={listingsState.performanceIds} searchTerm={listingsState.searchInput} systems={systemsQuery.data ?? []} - devices={devicesForFilters} - socs={socsForFilters} emulators={emulatorsQuery.data?.emulators ?? []} performanceScales={performanceScalesQuery.data ?? []} onSystemChange={handleSystemChange} @@ -283,8 +267,6 @@ function ListingsPage() { performanceIds={listingsState.performanceIds} searchTerm={listingsState.searchInput} systems={systemsQuery.data ?? []} - devices={devicesForFilters} - socs={socsForFilters} emulators={emulatorsQuery.data?.emulators ?? []} performanceScales={performanceScalesQuery.data ?? []} onSystemChange={handleSystemChange} diff --git a/src/app/listings/components/ListingsFiltersContent.tsx b/src/app/listings/components/ListingsFiltersContent.tsx index e553cd6a6..f5b606e15 100644 --- a/src/app/listings/components/ListingsFiltersContent.tsx +++ b/src/app/listings/components/ListingsFiltersContent.tsx @@ -3,16 +3,10 @@ import { motion } from 'framer-motion' import { Joystick, MonitorSmartphone, Cpu, Gamepad, Rocket } from 'lucide-react' import { ActiveFiltersSummary, ListingsSearchBar } from '@/app/listings/shared/components' -import { shouldUseAsyncListingFilters } from '@/app/listings/shared/utils/asyncListingFilters' import { buildActiveFilterItems } from '@/app/listings/shared/utils/buildActiveFilterItems' import { MultiSelect } from '@/components/ui' -import { - performanceOptions, - deviceOptions, - socOptions, - systemOptions, - emulatorOptions, -} from '@/utils/options' +import { type Option } from '@/components/ui/form/async-multi-select/AsyncMultiSelect' +import { performanceOptions, systemOptions, emulatorOptions } from '@/utils/options' import AsyncDeviceFilterSelect from './filters/AsyncDeviceFilterSelect' import AsyncSocFilterSelect from './filters/AsyncSocFilterSelect' @@ -24,13 +18,11 @@ interface Props { performanceIds: number[] searchTerm: string systems: { id: string; name: string }[] - devices: { id: string; modelName: string; brand: { name: string } }[] - socs: { id: string; name: string; manufacturer: string }[] emulators: { id: string; name: string }[] performanceScales: { id: number; label: string }[] onSystemChange: (values: string[]) => void - onDeviceChange: (values: string[]) => void - onSocChange: (values: string[]) => void + onDeviceChange: (values: string[], selectedOptions: Option[]) => void + onSocChange: (values: string[], selectedOptions: Option[]) => void onEmulatorChange: (values: string[]) => void onPerformanceChange: (values: string[]) => void onSearchChange: (value: string) => void @@ -39,8 +31,6 @@ interface Props { } export default function ListingsFiltersContent(props: Props) { - const ENABLE_ASYNC_LISTINGS = shouldUseAsyncListingFilters() - const hasActiveFilters = props.systemIds.length > 0 || props.deviceIds.length > 0 || @@ -77,49 +67,23 @@ export default function ListingsFiltersContent(props: Props) { maxDisplayed={1} /> - {ENABLE_ASYNC_LISTINGS ? ( - } - value={props.deviceIds} - onChange={props.onDeviceChange} - placeholder="All devices" - maxDisplayed={1} - /> - ) : ( - } - value={props.deviceIds} - onChange={props.onDeviceChange} - options={deviceOptions(props.devices)} - color="green" - placeholder="All devices" - maxDisplayed={1} - /> - )} + } + value={props.deviceIds} + onChange={props.onDeviceChange} + placeholder="All devices" + maxDisplayed={1} + /> - {ENABLE_ASYNC_LISTINGS ? ( - } - value={props.socIds} - onChange={props.onSocChange} - placeholder="All SoCs" - maxDisplayed={1} - /> - ) : ( - } - value={props.socIds} - onChange={props.onSocChange} - options={socOptions(props.socs)} - color="purple" - placeholder="All SoCs" - maxDisplayed={1} - /> - )} + } + value={props.socIds} + onChange={props.onSocChange} + placeholder="All SoCs" + maxDisplayed={1} + /> void @@ -79,18 +72,28 @@ function ListingsFiltersSidebar(props: FiltersProps) { filterAnalytics.systems(values, names) } - const handleDeviceChange = (values: string[]) => { + const handleDeviceChange = (values: string[], selectedOptions: Option[]) => { props.onDeviceChange(values) - const names = getDeviceNames(props.devices, values) + const names = selectedOptions.map((option) => option.name) filterAnalytics.devices(values, names) } - const handleSocChange = (values: string[]) => { + const handleSocChange = (values: string[], selectedOptions: Option[]) => { props.onSocChange(values) - const names = getSocNames(props.socs, values) + const names = selectedOptions.map((option) => option.name) filterAnalytics.socs(values, names) } + const handleClearDeviceFilter = () => { + props.onDeviceChange([]) + filterAnalytics.devices([], []) + } + + const handleClearSocFilter = () => { + props.onSocChange([]) + filterAnalytics.socs([], []) + } + const handleEmulatorChange = (values: string[]) => { props.onEmulatorChange(values) const names = getEmulatorNames(props.emulators, values) @@ -228,8 +231,6 @@ function ListingsFiltersSidebar(props: FiltersProps) { performanceIds={props.performanceIds} searchTerm={props.searchTerm} systems={props.systems} - devices={props.devices} - socs={props.socs} emulators={props.emulators} performanceScales={props.performanceScales} onSystemChange={handleSystemChange} @@ -267,7 +268,7 @@ function ListingsFiltersSidebar(props: FiltersProps) { -
    diff --git a/src/app/admin/reports/components/ReportStatusModal.tsx b/src/app/admin/reports/components/ReportStatusModal.tsx index f877e9aaf..fb56171b0 100644 --- a/src/app/admin/reports/components/ReportStatusModal.tsx +++ b/src/app/admin/reports/components/ReportStatusModal.tsx @@ -1,18 +1,23 @@ 'use client' -import { useState, useEffect, type SubmitEvent, type ChangeEvent } from 'react' +import { useState, type SubmitEvent, type ChangeEvent } from 'react' import { Button, Input, Modal } from '@/components/ui' import { api } from '@/lib/api' -import { type ReportStatusType } from '@/schemas/listingReport' import { type RouterInput } from '@/types/trpc' import getErrorMessage from '@/utils/getErrorMessage' import { ReportStatus } from '@orm' -import { type ListingReportWithDetails } from '../types' +import { type AdminReportWithDetails } from '../adminReport' interface Props { isOpen: boolean onClose: () => void - report?: ListingReportWithDetails + report?: AdminReportWithDetails + onSuccess: () => void +} + +interface ContentProps { + onClose: () => void + report: AdminReportWithDetails onSuccess: () => void } @@ -22,50 +27,49 @@ const STATUSES = [ { value: ReportStatus.DISMISSED, label: 'Dismissed' }, ] as const -function ReportStatusModal(props: Props) { - const [status, setStatus] = useState(ReportStatus.UNDER_REVIEW) - const [reviewNotes, setReviewNotes] = useState('') +type ReviewableReportStatus = (typeof STATUSES)[number]['value'] + +function isReviewableReportStatus(value: string): value is ReviewableReportStatus { + return STATUSES.some((status) => status.value === value) +} + +function getInitialStatus(report: AdminReportWithDetails): ReviewableReportStatus { + return report.status === ReportStatus.PENDING ? ReportStatus.UNDER_REVIEW : report.status +} + +function ReportStatusModalContent(props: ContentProps) { + const [status, setStatus] = useState(getInitialStatus(props.report)) + const [reviewNotes, setReviewNotes] = useState(props.report.reviewNotes || '') const [error, setError] = useState('') const [success, setSuccess] = useState('') - const updateReportStatus = api.listingReports.updateStatus.useMutation() - - // Reset form when modal opens/closes - useEffect(() => { - if (props.isOpen && props.report) { - setStatus( - props.report.status === ReportStatus.PENDING - ? ReportStatus.UNDER_REVIEW - : props.report.status, - ) - setReviewNotes(props.report.reviewNotes || '') - setError('') - setSuccess('') - } else if (!props.isOpen) { - setStatus(ReportStatus.UNDER_REVIEW) - setReviewNotes('') - setError('') - setSuccess('') - } - }, [props.isOpen, props.report]) + const updateListingReportStatus = api.listingReports.updateStatus.useMutation() + const updatePcListingReportStatus = api.pcListingReports.updateStatus.useMutation() + const isPending = updateListingReportStatus.isPending || updatePcListingReportStatus.isPending const handleSubmit = async (ev: SubmitEvent) => { ev.preventDefault() - if (!props.report) return setError('') setSuccess('') try { - await updateReportStatus.mutateAsync({ - id: props.report.id, - status, - reviewNotes: reviewNotes.trim() || undefined, - } satisfies RouterInput['listingReports']['updateStatus']) + if (props.report.kind === 'handheld') { + await updateListingReportStatus.mutateAsync({ + id: props.report.id, + status, + reviewNotes: reviewNotes.trim() || undefined, + } satisfies RouterInput['listingReports']['updateStatus']) + } else { + await updatePcListingReportStatus.mutateAsync({ + id: props.report.id, + status, + reviewNotes: reviewNotes.trim() || undefined, + } satisfies RouterInput['pcListingReports']['updateStatus']) + } setSuccess('Report status updated successfully!') - // Close modal after short delay setTimeout(() => { props.onSuccess() }, 1000) @@ -74,22 +78,20 @@ function ReportStatusModal(props: Props) { } } - if (!props.report) return null - return (
    - {/* Report Summary */}

    Report Summary

    - Listing: {props.report.listing.game.title} + {props.report.compatibilityReport.reportLabel}:{' '} + {props.report.compatibilityReport.gameTitle}

    Reason: {props.report.reason.replace(/_/g, ' ')} @@ -104,7 +106,6 @@ function ReportStatusModal(props: Props) { )}

    - {/* Status Selection */}
    - {/* Review Notes */}
    - {/* Status-specific help text */} {status === ReportStatus.RESOLVED && (

    Resolved: Use this when the report is valid and appropriate action - has been taken (e.g., listing was removed, user was warned, etc.). + has been taken (e.g., report was removed, user was warned, etc.).

    )} @@ -186,11 +188,7 @@ function ReportStatusModal(props: Props) { -
    @@ -199,4 +197,17 @@ function ReportStatusModal(props: Props) { ) } +function ReportStatusModal(props: Props) { + if (!props.isOpen || !props.report) return null + + return ( + + ) +} + export default ReportStatusModal diff --git a/src/app/admin/reports/page.tsx b/src/app/admin/reports/page.tsx index 75a8f7eae..ca80f4066 100644 --- a/src/app/admin/reports/page.tsx +++ b/src/app/admin/reports/page.tsx @@ -23,27 +23,34 @@ import { LocalizedDate, Code, Dropdown, + type BadgeVariant, } from '@/components/ui' import storageKeys from '@/data/storageKeys' import { useColumnVisibility, type ColumnDefinition } from '@/hooks' import { useAdminTable } from '@/hooks/admin' import { api } from '@/lib/api' import toast from '@/lib/toast' -import { type ReportReasonType, type ReportStatusType } from '@/schemas/listingReport' import { type RouterInput } from '@/types/trpc' import getErrorMessage from '@/utils/getErrorMessage' import { hasPermission, PERMISSIONS } from '@/utils/permission-system' import { ReportReason, ReportStatus } from '@orm' +import { + REPORT_TYPES, + type AdminReportKind, + type AdminReportWithDetails, + isAdminReportKind, + toHandheldAdminReport, + toPcAdminReport, +} from './adminReport' import ReportDetailsModal from './components/ReportDetailsModal' import ReportStatusModal from './components/ReportStatusModal' -import { type ReportModalState, type ReportStatusModalState } from './types' import UserDetailsModal from '../users/components/UserDetailsModal' type ReportSortField = 'createdAt' | 'updatedAt' | 'status' | 'reason' const REPORT_COLUMNS: ColumnDefinition[] = [ { key: 'id', label: 'ID', defaultVisible: false }, - { key: 'listing', label: 'Listing', defaultVisible: true }, + { key: 'listing', label: 'Report', defaultVisible: true }, { key: 'reason', label: 'Reason', defaultVisible: true }, { key: 'status', label: 'Status', defaultVisible: true }, { key: 'reportedBy', label: 'Reported By', defaultVisible: true }, @@ -60,7 +67,7 @@ const REPORT_REASONS = [ value: ReportReason.MISLEADING_INFORMATION, label: 'Misleading Information', }, - { value: ReportReason.FAKE_LISTING, label: 'Fake Listing' }, + { value: ReportReason.FAKE_LISTING, label: 'Fake Report' }, { value: ReportReason.COPYRIGHT_VIOLATION, label: 'Copyright Violation' }, { value: ReportReason.OTHER, label: 'Other' }, ] as const @@ -73,38 +80,38 @@ const REPORT_STATUSES = [ { value: ReportStatus.DISMISSED, label: 'Dismissed' }, ] as const -const getReasonBadgeVariant = (reason: ReportReasonType) => { - switch (reason) { - case ReportReason.INAPPROPRIATE_CONTENT: - return 'danger' - case ReportReason.SPAM: - return 'warning' - case ReportReason.MISLEADING_INFORMATION: - return 'danger' - case ReportReason.FAKE_LISTING: - return 'danger' - case ReportReason.COPYRIGHT_VIOLATION: - return 'danger' - case ReportReason.OTHER: - return 'default' - default: - return 'default' +type ReportReasonFilter = (typeof REPORT_REASONS)[number]['value'] +type ReportStatusFilter = (typeof REPORT_STATUSES)[number]['value'] + +function isReportReasonFilter(value: string): value is ReportReasonFilter { + return REPORT_REASONS.some((reason) => reason.value === value) +} + +function isReportStatusFilter(value: string): value is ReportStatusFilter { + return REPORT_STATUSES.some((status) => status.value === value) +} + +const getReasonBadgeVariant = (reason: ReportReason) => { + const reasonBadgeVariantsMap: Record = { + [ReportReason.INAPPROPRIATE_CONTENT]: 'danger', + [ReportReason.SPAM]: 'warning', + [ReportReason.MISLEADING_INFORMATION]: 'danger', + [ReportReason.FAKE_LISTING]: 'danger', + [ReportReason.COPYRIGHT_VIOLATION]: 'danger', + [ReportReason.OTHER]: 'default', } + return reasonBadgeVariantsMap[reason] ?? 'default' } -const getStatusBadgeVariant = (status: ReportStatusType) => { - switch (status) { - case ReportStatus.PENDING: - return 'warning' - case ReportStatus.UNDER_REVIEW: - return 'info' - case ReportStatus.RESOLVED: - return 'success' - case ReportStatus.DISMISSED: - return 'default' - default: - return 'default' +const getStatusBadgeVariant = (status: ReportStatus) => { + const statusBadgeVariantsMap: Record = { + [ReportStatus.PENDING]: 'warning', + [ReportStatus.UNDER_REVIEW]: 'info', + [ReportStatus.RESOLVED]: 'success', + [ReportStatus.DISMISSED]: 'default', } + + return statusBadgeVariantsMap[status] ?? 'default' } function AdminReportsPage() { @@ -116,16 +123,16 @@ function AdminReportsPage() { storageKey: storageKeys.columnVisibility.adminReports, }) - const [selectedReason, setSelectedReason] = useState('') - const [selectedStatus, setSelectedStatus] = useState('') - const [reportDetailsModal, setReportDetailsModal] = useState({ isOpen: false }) - const [reportStatusModal, setReportStatusModal] = useState({ - isOpen: false, - }) + const [selectedReportKind, setSelectedReportKind] = useState('handheld') + const [selectedReason, setSelectedReason] = useState('') + const [selectedStatus, setSelectedStatus] = useState('') + const [reportDetailsModalReport, setReportDetailsModalReport] = + useState(null) + const [reportStatusModalReport, setReportStatusModalReport] = + useState(null) const [selectedUserId, setSelectedUserId] = useState(null) - const reportsStatsQuery = api.listingReports.stats.useQuery() - const reportsQuery = api.listingReports.get.useQuery({ + const reportQueryInput = { search: table.debouncedSearch || undefined, reason: selectedReason || undefined, status: selectedStatus || undefined, @@ -133,42 +140,88 @@ function AdminReportsPage() { sortDirection: table.sortDirection ?? undefined, page: table.page, limit: table.limit, + } + + const listingReportsStatsQuery = api.listingReports.stats.useQuery(undefined, { + enabled: selectedReportKind === 'handheld', + }) + const pcReportsStatsQuery = api.pcListingReports.stats.useQuery(undefined, { + enabled: selectedReportKind === 'pc', + }) + const listingReportsQuery = api.listingReports.get.useQuery(reportQueryInput, { + enabled: selectedReportKind === 'handheld', + }) + const pcReportsQuery = api.pcListingReports.get.useQuery(reportQueryInput, { + enabled: selectedReportKind === 'pc', }) - const reports = reportsQuery.data?.reports ?? [] - const pagination = reportsQuery.data?.pagination + const activeStatsQuery = + selectedReportKind === 'handheld' ? listingReportsStatsQuery : pcReportsStatsQuery + const activeReportsQuery = + selectedReportKind === 'handheld' ? listingReportsQuery : pcReportsQuery - const deleteReport = api.listingReports.delete.useMutation({ + const reports: AdminReportWithDetails[] = + selectedReportKind === 'handheld' + ? (listingReportsQuery.data?.reports.map(toHandheldAdminReport) ?? []) + : (pcReportsQuery.data?.reports.map(toPcAdminReport) ?? []) + const pagination = activeReportsQuery.data?.pagination + + const invalidateReports = () => { + utils.listingReports.get.invalidate().catch(console.error) + utils.listingReports.stats.invalidate().catch(console.error) + utils.pcListingReports.get.invalidate().catch(console.error) + utils.pcListingReports.stats.invalidate().catch(console.error) + } + + const deleteListingReport = api.listingReports.delete.useMutation({ onSuccess: () => { toast.success('Report deleted successfully!') - utils.listingReports.get.invalidate().catch(console.error) - utils.listingReports.stats.invalidate().catch(console.error) + invalidateReports() }, onError: (err) => { toast.error(`Failed to delete report: ${getErrorMessage(err)}`) }, }) - const updateStatus = api.listingReports.updateStatus.useMutation({ + const deletePcListingReport = api.pcListingReports.delete.useMutation({ + onSuccess: () => { + toast.success('Report deleted successfully!') + invalidateReports() + }, + onError: (err) => { + toast.error(`Failed to delete report: ${getErrorMessage(err)}`) + }, + }) + + const updateListingStatus = api.listingReports.updateStatus.useMutation({ + onSuccess: () => { + toast.success('Report status updated successfully!') + invalidateReports() + }, + onError: (err) => { + toast.error(`Failed to update report status: ${getErrorMessage(err)}`) + }, + }) + + const updatePcListingStatus = api.pcListingReports.updateStatus.useMutation({ onSuccess: () => { toast.success('Report status updated successfully!') - utils.listingReports.get.invalidate().catch(console.error) - utils.listingReports.stats.invalidate().catch(console.error) + invalidateReports() }, onError: (err) => { toast.error(`Failed to update report status: ${getErrorMessage(err)}`) }, }) - const handleViewDetails = (report: (typeof reports)[0]) => { - setReportDetailsModal({ isOpen: true, report }) + const handleViewDetails = (report: AdminReportWithDetails) => { + setReportDetailsModalReport(report) } - const handleUpdateStatus = (report: (typeof reports)[0]) => { - setReportStatusModal({ isOpen: true, report }) + const handleUpdateStatus = (report: AdminReportWithDetails) => { + setReportStatusModalReport(report) } - const handleDelete = async (report: (typeof reports)[0]) => { + const handleDelete = async (report: AdminReportWithDetails) => { const confirmed = await confirm({ title: 'Delete Report', description: `Are you sure you want to delete this report? This action cannot be undone.`, @@ -176,12 +229,19 @@ function AdminReportsPage() { if (!confirmed) return - deleteReport.mutate({ + if (report.kind === 'handheld') { + deleteListingReport.mutate({ + id: report.id, + } satisfies RouterInput['listingReports']['delete']) + return + } + + deletePcListingReport.mutate({ id: report.id, - } satisfies RouterInput['listingReports']['delete']) + } satisfies RouterInput['pcListingReports']['delete']) } - const handleMarkResolved = async (report: (typeof reports)[0]) => { + const handleMarkResolved = async (report: AdminReportWithDetails) => { const confirmed = await confirm({ title: 'Mark as Resolved', description: 'Are you sure you want to mark this report as resolved?', @@ -190,73 +250,100 @@ function AdminReportsPage() { if (!confirmed) return - updateStatus.mutate({ + if (report.kind === 'handheld') { + updateListingStatus.mutate({ + id: report.id, + status: ReportStatus.RESOLVED, + reviewNotes: 'Marked as resolved', + } satisfies RouterInput['listingReports']['updateStatus']) + return + } + + updatePcListingStatus.mutate({ id: report.id, status: ReportStatus.RESOLVED, reviewNotes: 'Marked as resolved', - } satisfies RouterInput['listingReports']['updateStatus']) + } satisfies RouterInput['pcListingReports']['updateStatus']) } - const statsData = reportsStatsQuery.data + const statsData = activeStatsQuery.data ? [ { label: 'Total Reports', - value: reportsStatsQuery.data.total, + value: activeStatsQuery.data.total, color: 'blue' as const, }, { label: 'Pending', - value: reportsStatsQuery.data.pending, + value: activeStatsQuery.data.pending, color: 'yellow' as const, }, { label: 'Under Review', - value: reportsStatsQuery.data.underReview, + value: activeStatsQuery.data.underReview, color: 'blue' as const, }, { label: 'Resolved', - value: reportsStatsQuery.data.resolved, + value: activeStatsQuery.data.resolved, color: 'green' as const, }, { label: 'Dismissed', - value: reportsStatsQuery.data.dismissed, + value: activeStatsQuery.data.dismissed, color: 'gray' as const, }, ] : [] - if (reportsQuery.isPending) return + const isDeletePending = deleteListingReport.isPending || deletePcListingReport.isPending + const isUpdateStatusPending = updateListingStatus.isPending || updatePcListingStatus.isPending + + if (activeReportsQuery.isPending) return return ( } > - + table={table} - searchPlaceholder="Search reports by listing, user, or description..." + searchPlaceholder="Search reports by compatibility report, user, or description..." onClear={() => { setSelectedReason('') setSelectedStatus('') }} >
    + { + if (!isAdminReportKind(value)) return + setSelectedReportKind(value) + table.setPage(1) + }} + /> setSelectedReason(value as ReportReasonType | '')} + onChange={(value) => { + if (!isReportReasonFilter(value)) return + setSelectedReason(value) + }} /> setSelectedStatus(value as ReportStatusType | '')} + onChange={(value) => { + if (!isReportStatusFilter(value)) return + setSelectedStatus(value) + }} />
    @@ -280,7 +367,7 @@ function AdminReportsPage() { )} {columnVisibility.isColumnVisible('listing') && ( - Listing + Report )} {columnVisibility.isColumnVisible('reason') && ( @@ -345,16 +432,18 @@ function AdminReportsPage() {
    - {report.listing.game.title} + {report.compatibilityReport.gameTitle}
    - {report.listing.device.modelName} • {report.listing.emulator.name} + {report.compatibilityReport.hardwareLabel} •{' '} + {report.compatibilityReport.emulatorName}
    - by {report.listing.author.name || 'Unknown'} + {report.compatibilityReport.reportLabel} by{' '} + {report.compatibilityReport.author.name || 'Unknown'}
    @@ -414,8 +503,8 @@ function AdminReportsPage() { handleMarkResolved(report)} title="Mark as Resolved" - isLoading={updateStatus.isPending} - disabled={updateStatus.isPending} + isLoading={isUpdateStatusPending} + disabled={isUpdateStatusPending} /> )} {hasPermission( @@ -434,8 +523,8 @@ function AdminReportsPage() { handleDelete(report)} title="Delete Report" - isLoading={deleteReport.isPending} - disabled={deleteReport.isPending} + isLoading={isDeletePending} + disabled={isDeletePending} /> )}
    @@ -460,19 +549,18 @@ function AdminReportsPage() { )} setReportDetailsModal({ isOpen: false })} + report={reportDetailsModalReport ?? undefined} + isOpen={reportDetailsModalReport !== null} + onClose={() => setReportDetailsModalReport(null)} /> setReportStatusModal({ isOpen: false })} + report={reportStatusModalReport ?? undefined} + isOpen={reportStatusModalReport !== null} + onClose={() => setReportStatusModalReport(null)} onSuccess={() => { - setReportStatusModal({ isOpen: false }) - utils.listingReports.get.invalidate().catch(console.error) - utils.listingReports.stats.invalidate().catch(console.error) + setReportStatusModalReport(null) + invalidateReports() }} /> diff --git a/src/app/admin/reports/types.ts b/src/app/admin/reports/types.ts deleted file mode 100644 index 8876338a9..000000000 --- a/src/app/admin/reports/types.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { type RouterOutput } from '@/types/trpc' - -export type ListingReportWithDetails = RouterOutput['listingReports']['get']['reports'][0] - -export interface ReportModalState { - isOpen: boolean - report?: ListingReportWithDetails -} - -export interface ReportStatusModalState { - isOpen: boolean - report?: ListingReportWithDetails -} diff --git a/src/app/listings/[id]/components/ReportListingModal.tsx b/src/app/listings/[id]/components/ReportListingModal.tsx index 2016314fc..6466ba486 100644 --- a/src/app/listings/[id]/components/ReportListingModal.tsx +++ b/src/app/listings/[id]/components/ReportListingModal.tsx @@ -1,12 +1,16 @@ 'use client' import { useUser } from '@clerk/nextjs' -import { useState, useEffect, type FormEvent } from 'react' +import { useState, type FormEvent } from 'react' +import { + REPORT_REASON_OPTIONS, + type ReportReasonOptionValue, + isReportReasonOptionValue, +} from '@/app/listings/shared/utils/reportReasonOptions' import { Button, Modal } from '@/components/ui' import analytics from '@/lib/analytics' import { api } from '@/lib/api' import toast from '@/lib/toast' -import { type ReportReasonType } from '@/schemas/listingReport' import { type RouterInput } from '@/types/trpc' import getErrorMessage from '@/utils/getErrorMessage' import { ReportReason } from '@orm' @@ -18,37 +22,20 @@ interface Props { onSuccess: () => void } -const REPORT_REASONS = [ - { value: ReportReason.SPAM, label: 'Spam or repetitive content' }, - { - value: ReportReason.INAPPROPRIATE_CONTENT, - label: 'Inappropriate or offensive content', - }, - { - value: ReportReason.MISLEADING_INFORMATION, - label: 'Misleading or false information', - }, - { value: ReportReason.FAKE_LISTING, label: 'Fake or fabricated listing' }, - { value: ReportReason.COPYRIGHT_VIOLATION, label: 'Copyright violation' }, - { value: ReportReason.OTHER, label: 'Other (please specify)' }, -] as const +interface ModalContentProps { + onClose: () => void + listingId: string + onSuccess: () => void +} -function ReportListingModal(props: Props) { - const [reason, setReason] = useState(ReportReason.SPAM) +function ReportListingModalContent(props: ModalContentProps) { + const [reason, setReason] = useState(ReportReason.SPAM) const [description, setDescription] = useState('') const [error, setError] = useState('') const createReport = api.listingReports.create.useMutation() const { user } = useUser() - // Reset form when modal opens/closes - useEffect(() => { - if (!props.isOpen) return - setReason(ReportReason.SPAM) - setDescription('') - setError('') - }, [props.isOpen]) - const handleSubmit = async (ev: FormEvent) => { ev.preventDefault() setError('') @@ -65,7 +52,6 @@ function ReportListingModal(props: Props) { description: description.trim() || undefined, } satisfies RouterInput['listingReports']['create']) - // Track content flagging in analytics if (user?.id) { analytics.contentQuality.contentFlagged({ entityType: 'listing', @@ -89,7 +75,7 @@ function ReportListingModal(props: Props) { } return ( - +

    @@ -108,11 +94,14 @@ function ReportListingModal(props: Props) { setReason(e.target.value as ReportReasonType)} + onChange={(ev) => { + if (!isReportReasonOptionValue(ev.target.value)) return + setReason(ev.target.value) + }} className="w-full rounded-md border border-gray-300 dark:border-gray-600 px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white" required > - {REPORT_REASONS.map((reasonOption) => ( + {REPORT_REASON_OPTIONS.map((reasonOption) => ( @@ -133,7 +121,7 @@ function PcReportListingModal(props: Props) { id="description" value={description} onChange={(ev) => setDescription(ev.target.value)} - placeholder="Please provide additional context about why you're reporting this PC listing..." + placeholder="Please provide additional context about why you're reporting this PC compatibility report..." className="w-full rounded-md border border-gray-300 dark:border-gray-600 px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white" rows={4} maxLength={1000} @@ -175,4 +163,16 @@ function PcReportListingModal(props: Props) { ) } +function PcReportListingModal(props: Props) { + if (!props.isOpen) return null + + return ( + + ) +} + export default PcReportListingModal diff --git a/src/lib/errors.ts b/src/lib/errors.ts index 04c676257..d10e82874 100644 --- a/src/lib/errors.ts +++ b/src/lib/errors.ts @@ -483,23 +483,22 @@ export class ResourceError { } static listingReport = { - notFound: () => AppError.notFound('Listing report'), - alreadyExists: () => AppError.conflict('You have already reported this listing'), - cannotReportOwnListing: () => AppError.forbidden('You cannot report your own listing'), + notFound: () => AppError.notFound('Report'), + alreadyExists: () => AppError.conflict('You have already reported this compatibility report'), + cannotReportOwnListing: () => + AppError.forbidden('You cannot report your own compatibility report'), cannotChangeFinalStatus: () => - AppError.conflict( - 'Listing report has already been resolved or dismissed and cannot be reopened.', - ), + AppError.conflict('Report has already been resolved or dismissed and cannot be reopened.'), } static pcListingReport = { - notFound: () => AppError.notFound('PC listing report'), - alreadyExists: () => AppError.conflict('You have already reported this listing'), - cannotReportOwnListing: () => AppError.forbidden('You cannot report your own listing'), + notFound: () => AppError.notFound('PC report'), + alreadyExists: () => + AppError.conflict('You have already reported this PC compatibility report'), + cannotReportOwnListing: () => + AppError.forbidden('You cannot report your own PC compatibility report'), cannotChangeFinalStatus: () => - AppError.conflict( - 'PC listing report has already been resolved or dismissed and cannot be reopened.', - ), + AppError.conflict('PC report has already been resolved or dismissed and cannot be reopened.'), } static userBan = { diff --git a/src/schemas/listingReport.ts b/src/schemas/listingReport.ts index 2784d29e8..0e5fc5638 100644 --- a/src/schemas/listingReport.ts +++ b/src/schemas/listingReport.ts @@ -10,13 +10,13 @@ export const ListingReportSortField = z.enum(['createdAt', 'updatedAt', 'status' export const CreateListingReportSchema = z.object({ listingId: z.string().uuid(), reason: ReportReasonSchema, - description: z.string().optional(), + description: z.string().max(1000).optional(), }) export const UpdateReportStatusSchema = z.object({ id: z.string().uuid(), status: ReportStatusSchema, - reviewNotes: z.string().optional(), + reviewNotes: z.string().max(1000).optional(), }) export const GetListingReportsSchema = z @@ -49,6 +49,3 @@ export const GetUserReportsSchema = z.object({ export const GetUserReportStatsSchema = z.object({ userId: z.string().uuid(), }) - -export type ReportReasonType = ReportReason -export type ReportStatusType = ReportStatus diff --git a/src/schemas/pcListing.ts b/src/schemas/pcListing.ts index 5db4dd891..0ab7ed732 100644 --- a/src/schemas/pcListing.ts +++ b/src/schemas/pcListing.ts @@ -274,7 +274,7 @@ export const CreatePcListingReportSchema = z.object({ }) export const UpdatePcListingReportSchema = z.object({ - reportId: z.string().uuid(), + id: z.string().uuid(), status: z.nativeEnum(ReportStatus), reviewNotes: z.string().max(1000).optional(), }) diff --git a/src/server/api/routers/listingReports.test.ts b/src/server/api/routers/listingReports.test.ts index 67073e83e..ad60cdadc 100644 --- a/src/server/api/routers/listingReports.test.ts +++ b/src/server/api/routers/listingReports.test.ts @@ -209,7 +209,7 @@ describe('listingReportsRouter create', () => { status: ReportStatus.DISMISSED, reviewNotes: 'Changing decision', }), - ).rejects.toThrow('Listing report has already been resolved or dismissed') + ).rejects.toThrow('Report has already been resolved or dismissed') expect(prisma.listing.update).not.toHaveBeenCalled() expect(mockLogAction).not.toHaveBeenCalled() diff --git a/src/server/api/routers/pcListingReports.test.ts b/src/server/api/routers/pcListingReports.test.ts index 11af1a774..07d0b39f1 100644 --- a/src/server/api/routers/pcListingReports.test.ts +++ b/src/server/api/routers/pcListingReports.test.ts @@ -91,7 +91,7 @@ describe('pcListingReportsRouter', () => { }) await caller.updateStatus({ - reportId: REPORT_ID, + id: REPORT_ID, status: ReportStatus.RESOLVED, reviewNotes: 'Confirmed spam', }) @@ -141,7 +141,7 @@ describe('pcListingReportsRouter', () => { }) await caller.updateStatus({ - reportId: REPORT_ID, + id: REPORT_ID, status: ReportStatus.RESOLVED, reviewNotes: 'Already handled', }) @@ -164,11 +164,11 @@ describe('pcListingReportsRouter', () => { await expect( caller.updateStatus({ - reportId: REPORT_ID, + id: REPORT_ID, status: ReportStatus.DISMISSED, reviewNotes: 'Changing decision', }), - ).rejects.toThrow('PC listing report has already been resolved or dismissed') + ).rejects.toThrow('PC report has already been resolved or dismissed') expect(prisma.pcListing.update).not.toHaveBeenCalled() expect(mockLogAction).not.toHaveBeenCalled() @@ -179,7 +179,7 @@ describe('pcListingReportsRouter', () => { const { caller, prisma } = createCaller() prisma.pcListingReport.delete.mockRejectedValue(createPrismaError('P2025')) - await expect(caller.delete({ id: REPORT_ID })).rejects.toThrow('PC listing report not found') + await expect(caller.delete({ id: REPORT_ID })).rejects.toThrow('PC report not found') expect(prisma.pcListingReport.findUnique).not.toHaveBeenCalled() }) diff --git a/src/server/api/routers/pcListingReports.ts b/src/server/api/routers/pcListingReports.ts index 0033b236f..12de3bf28 100644 --- a/src/server/api/routers/pcListingReports.ts +++ b/src/server/api/routers/pcListingReports.ts @@ -134,7 +134,7 @@ export const pcListingReportsRouter = createTRPCRouter({ .input(UpdatePcListingReportSchema) .mutation(async ({ ctx, input }) => { return new ReportModerationService(ctx.prisma).updatePcListingReportStatus({ - reportId: input.reportId, + reportId: input.id, status: input.status, reviewNotes: input.reviewNotes, reviewerId: ctx.session.user.id, diff --git a/tests/admin-reports.spec.ts b/tests/admin-reports.spec.ts index 0d90a30f1..cd904995f 100644 --- a/tests/admin-reports.spec.ts +++ b/tests/admin-reports.spec.ts @@ -1,7 +1,22 @@ import { test, expect } from './fixtures' +import { + HANDHELD_REPORT_DESCRIPTION, + PC_REPORT_DESCRIPTION, + openFirstAdminReportDetails, + searchAdminReports, + selectAdminReportType, +} from './helpers/admin-reports' +import { createPcReport, createReport, withContext } from './helpers/data-factory' test.describe('Admin Reports Management Tests - Requires Admin Role', () => { test.use({ storageState: 'tests/.auth/super_admin.json' }) + test.beforeAll(async ({ browser }) => { + await withContext(browser, 'tests/.auth/author.json', async (page) => { + await createReport(page, HANDHELD_REPORT_DESCRIPTION) + await createPcReport(page, PC_REPORT_DESCRIPTION) + }) + }) + test.beforeEach(async ({ page }) => { await page.goto('/admin/reports', { waitUntil: 'domcontentloaded' }) await expect(page).toHaveURL(/\/admin\/reports/) @@ -41,19 +56,34 @@ test.describe('Admin Reports Management Tests - Requires Admin Role', () => { await expect(table).toBeVisible() }) - test('should display report details', async ({ page }) => { - const viewButtons = page.locator('button[title="View Report Details"]') - await expect(viewButtons.first()).toBeVisible() - expect(await viewButtons.count()).toBeGreaterThan(0) + test('should display reported handheld compatibility report details', async ({ page }) => { + await selectAdminReportType(page, 'Handheld Reports') + await searchAdminReports(page, HANDHELD_REPORT_DESCRIPTION, 'listingReports.get') + + const reportModal = await openFirstAdminReportDetails(page) + + await expect(reportModal).toContainText(HANDHELD_REPORT_DESCRIPTION) + await expect(reportModal).toContainText('Reported Compatibility Report') + await expect(reportModal).toContainText('Handheld Report') + await expect(reportModal.getByText('Device', { exact: true })).toBeVisible() + await expect(reportModal.getByRole('button', { name: /view report/i })).toBeVisible() + + const closeButton = reportModal.locator('button').filter({ hasText: /close/i }) + await expect(closeButton).toBeVisible() + await closeButton.click() + }) - await viewButtons.first().click() + test('should display reported PC compatibility report details', async ({ page }) => { + await selectAdminReportType(page, 'PC Reports') + await searchAdminReports(page, PC_REPORT_DESCRIPTION, 'pcListingReports.get') - const reportModal = page.locator('[role="dialog"]') - await expect(reportModal).toBeVisible() + const reportModal = await openFirstAdminReportDetails(page) - const modalContent = await reportModal.textContent() - expect(modalContent).toBeTruthy() - expect(modalContent?.length).toBeGreaterThan(0) + await expect(reportModal).toContainText(PC_REPORT_DESCRIPTION) + await expect(reportModal).toContainText('Reported Compatibility Report') + await expect(reportModal).toContainText('PC Report') + await expect(reportModal.getByText('Hardware', { exact: true })).toBeVisible() + await expect(reportModal.getByRole('button', { name: /view report/i })).toBeVisible() const closeButton = reportModal.locator('button').filter({ hasText: /close/i }) await expect(closeButton).toBeVisible() @@ -97,7 +127,7 @@ test.describe('Admin Reports Management Tests - Requires Admin Role', () => { const dialog = page.locator('[role="dialog"]') await expect(dialog).toBeVisible() - const viewListingButton = dialog.getByRole('button', { name: /view listing/i }) + const viewListingButton = dialog.getByRole('button', { name: /view report/i }) await expect(viewListingButton).toBeVisible() const closeButton = dialog.getByRole('button', { name: /^close$/i }) diff --git a/tests/helpers/admin-reports.ts b/tests/helpers/admin-reports.ts new file mode 100644 index 000000000..ece8eb9df --- /dev/null +++ b/tests/helpers/admin-reports.ts @@ -0,0 +1,47 @@ +import { expect } from '@playwright/test' +import type { Locator, Page } from '@playwright/test' + +export const HANDHELD_REPORT_DESCRIPTION = 'E2E test report for admin-reports testing' +export const PC_REPORT_DESCRIPTION = 'E2E test PC report for admin-reports testing' + +export async function selectAdminReportType(page: Page, label: 'Handheld Reports' | 'PC Reports') { + const reportTypeButton = page + .locator('button') + .filter({ hasText: /handheld reports|pc reports/i }) + .first() + await expect(reportTypeButton).toBeVisible() + + if ((await reportTypeButton.textContent())?.includes(label)) return + + await reportTypeButton.click() + await page + .locator('div') + .filter({ hasText: new RegExp(`^${label}$`) }) + .last() + .click() + await expect(reportTypeButton).toContainText(label) +} + +export async function searchAdminReports(page: Page, query: string, routeName: string) { + const searchInput = page.getByPlaceholder(/search reports by compatibility report/i) + await expect(searchInput).toBeVisible() + + const searchResponse = page.waitForResponse( + (response) => response.url().includes(routeName) && response.ok(), + ) + + await searchInput.fill(query) + await searchResponse + + await expect(page.locator('table tbody tr').first()).toBeVisible() +} + +export async function openFirstAdminReportDetails(page: Page): Promise { + const viewButtons = page.locator('button[title="View Report Details"]') + await expect(viewButtons.first()).toBeVisible() + await viewButtons.first().click() + + const reportModal = page.locator('[role="dialog"]') + await expect(reportModal).toBeVisible() + return reportModal +} diff --git a/tests/helpers/data-factory.ts b/tests/helpers/data-factory.ts index 20f239d8d..f0c914988 100644 --- a/tests/helpers/data-factory.ts +++ b/tests/helpers/data-factory.ts @@ -582,21 +582,27 @@ async function openReportDialog(page: Page, listingPath: string): Promise await reportButton.click() } -export async function createReport(page: Page): Promise { +export async function createReport( + page: Page, + description = 'E2E test report for admin-reports testing', +): Promise { const target = await createApprovedHandheldListingFixture(REPORT_TARGET_AUTHOR_EMAIL) await openReportDialog(page, target.path) - const dialog = await submitReportDialog(page, 'E2E test report for admin-reports testing') + const dialog = await submitReportDialog(page, description) await expect(dialog).toBeHidden() } -export async function createPcReport(page: Page): Promise { +export async function createPcReport( + page: Page, + description = 'E2E test PC report for admin-reports testing', +): Promise { const target = await createApprovedPcListingFixture(REPORT_TARGET_AUTHOR_EMAIL) await openReportDialog(page, target.path) - const dialog = await submitReportDialog(page, 'E2E test PC report for admin-reports testing') + const dialog = await submitReportDialog(page, description) await expect(dialog).toBeHidden() } diff --git a/tests/reporting.spec.ts b/tests/reporting.spec.ts new file mode 100644 index 000000000..a28b740ac --- /dev/null +++ b/tests/reporting.spec.ts @@ -0,0 +1,56 @@ +import { randomUUID } from 'node:crypto' +import { test, expect } from './fixtures' +import { + openFirstAdminReportDetails, + searchAdminReports, + selectAdminReportType, +} from './helpers/admin-reports' +import { createPcReport, createReport, withContext } from './helpers/data-factory' + +test.describe('Report submission and admin review', () => { + test('submits a handheld report and shows it in admin report details', async ({ browser }) => { + const description = `E2E handheld report admin review ${randomUUID()}` + + await withContext(browser, 'tests/.auth/author.json', async (page) => { + await createReport(page, description) + }) + + await withContext(browser, 'tests/.auth/super_admin.json', async (page) => { + await page.goto('/admin/reports', { waitUntil: 'domcontentloaded' }) + await expect(page.locator('table').first()).toBeVisible() + + await selectAdminReportType(page, 'Handheld Reports') + await searchAdminReports(page, description, 'listingReports.get') + + const reportModal = await openFirstAdminReportDetails(page) + + await expect(reportModal).toContainText(description) + await expect(reportModal).toContainText('Handheld Report') + await expect(reportModal.getByText('Device', { exact: true })).toBeVisible() + await expect(reportModal.getByRole('button', { name: /view report/i })).toBeVisible() + }) + }) + + test('submits a PC report and shows it in admin report details', async ({ browser }) => { + const description = `E2E PC report admin review ${randomUUID()}` + + await withContext(browser, 'tests/.auth/author.json', async (page) => { + await createPcReport(page, description) + }) + + await withContext(browser, 'tests/.auth/super_admin.json', async (page) => { + await page.goto('/admin/reports', { waitUntil: 'domcontentloaded' }) + await expect(page.locator('table').first()).toBeVisible() + + await selectAdminReportType(page, 'PC Reports') + await searchAdminReports(page, description, 'pcListingReports.get') + + const reportModal = await openFirstAdminReportDetails(page) + + await expect(reportModal).toContainText(description) + await expect(reportModal).toContainText('PC Report') + await expect(reportModal.getByText('Hardware', { exact: true })).toBeVisible() + await expect(reportModal.getByRole('button', { name: /view report/i })).toBeVisible() + }) + }) +}) From 8faf6ca1ec0e0559bfa094cfbce88af837bdbbc5 Mon Sep 17 00:00:00 2001 From: Producdevity Date: Tue, 16 Jun 2026 15:14:32 +0200 Subject: [PATCH 76/87] chore: clean stale api compatibility wording --- docs/MOBILE_API.md | 4 ++-- public/api-docs/mobile-openapi.json | 4 ++-- src/features/hardware/gpu/server/persistence/gpu.query.ts | 6 +----- src/schemas/common.ts | 8 ++++---- src/server/api/routers/mobile/listings.ts | 2 +- 5 files changed, 10 insertions(+), 14 deletions(-) diff --git a/docs/MOBILE_API.md b/docs/MOBILE_API.md index cfa962ae0..2abcdaca3 100644 --- a/docs/MOBILE_API.md +++ b/docs/MOBILE_API.md @@ -1,6 +1,6 @@ # EmuReady Public Integration API (mobile-compatible tRPC) -*Auto-generated on: 2026-06-12T17:07:50.301Z* +*Auto-generated on: 2026-06-15T12:16:27.168Z* ## Summary - **Total Endpoints**: 113 @@ -285,7 +285,7 @@ Protected endpoints require Bearer token authentication using Clerk JWT. #### 38. **getListings** - **Method**: GET - **Path**: `/listings.getListings` -- **Description**: @deprecated Use 'get' instead - kept for backwards compatibility with Eden +- **Description**: Use 'get' instead - kept for backwards compatibility with Eden - **Tags**: listings diff --git a/public/api-docs/mobile-openapi.json b/public/api-docs/mobile-openapi.json index b94d751ef..f7f24bab1 100644 --- a/public/api-docs/mobile-openapi.json +++ b/public/api-docs/mobile-openapi.json @@ -8575,8 +8575,8 @@ }, "/listings.getListings": { "get": { - "summary": "@deprecated Use 'get' instead - kept for backwards compatibility with Eden", - "description": "@deprecated Use 'get' instead - kept for backwards compatibility with Eden", + "summary": "Use 'get' instead - kept for backwards compatibility with Eden", + "description": "Use 'get' instead - kept for backwards compatibility with Eden", "tags": [ "listings" ], diff --git a/src/features/hardware/gpu/server/persistence/gpu.query.ts b/src/features/hardware/gpu/server/persistence/gpu.query.ts index c12481953..e956c4234 100644 --- a/src/features/hardware/gpu/server/persistence/gpu.query.ts +++ b/src/features/hardware/gpu/server/persistence/gpu.query.ts @@ -121,11 +121,7 @@ export function buildGpuWhere(search?: string, brandId?: string): Prisma.GpuWher return where } -// -/** - * Preserves the pre-feature mobile/public GPU catalog search semantics until that API is versioned. - * @deprecated - */ +// Preserves the pre-feature mobile/public GPU catalog search semantics until that API is versioned. function buildMobileGpuCatalogCompatibilityWhere( search?: string, brandId?: string, diff --git a/src/schemas/common.ts b/src/schemas/common.ts index c81dcdf06..5df2e13ea 100644 --- a/src/schemas/common.ts +++ b/src/schemas/common.ts @@ -1,7 +1,7 @@ import { z } from 'zod' export const SortDirectionSchema = z.enum(['asc', 'desc']) -export type SortDirection = z.infer +export type SortDirection = z.output export const MutationSuccessSchema = z.object({ success: z.literal(true), @@ -40,12 +40,12 @@ export const FilterValueSchema = z.object({ label: z.string(), }) -export type FilterValue = z.infer +export type FilterValue = z.output // Listing type: handheld vs PC export const ListingType = z.enum(['handheld', 'pc']) -export type ListingType = z.infer +export type ListingType = z.output // Severity level export const Severity = z.enum(['low', 'medium', 'high']) -export type Severity = z.infer +export type Severity = z.output diff --git a/src/server/api/routers/mobile/listings.ts b/src/server/api/routers/mobile/listings.ts index 7767606d7..538c981f5 100644 --- a/src/server/api/routers/mobile/listings.ts +++ b/src/server/api/routers/mobile/listings.ts @@ -102,7 +102,7 @@ export const mobileListingsRouter = createMobileTRPCRouter({ .query(async ({ ctx, input }) => getListingsHelper(ctx, input)), /** - * @deprecated Use 'get' instead - kept for backwards compatibility with Eden + * Use 'get' instead - kept for backwards compatibility with Eden */ getListings: mobilePublicProcedure .input(GetListingsSchema) From efc6fea396982b5a449a4816b5cca17ca47d6c08 Mon Sep 17 00:00:00 2001 From: Producdevity Date: Tue, 16 Jun 2026 15:15:44 +0200 Subject: [PATCH 77/87] fix: keep navbar auth hydration stable --- src/components/navbar/Navbar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/navbar/Navbar.tsx b/src/components/navbar/Navbar.tsx index 5112241ff..e33c29ccf 100644 --- a/src/components/navbar/Navbar.tsx +++ b/src/components/navbar/Navbar.tsx @@ -245,7 +245,7 @@ function Navbar() { ))} - {!isLoaded ? ( + {!authReady ? (

    Loading...
    ) : ( <> From 9d49ae181ee0948c20c3bbfcb23e610aeefe6431 Mon Sep 17 00:00:00 2001 From: Producdevity Date: Wed, 17 Jun 2026 13:50:10 +0200 Subject: [PATCH 78/87] refactor: remove deprecated image proxy logic, replace with provider-based game image validation and rendering --- {src/data => config}/image-hosts.ts | 16 +- next.config.ts | 27 +- .../admin/components/ImagePreviewModal.tsx | 5 +- .../games/[id]/components/GameEditForm.tsx | 13 +- .../form-schemas/updateGameSchema.test.ts | 33 +++ .../[id]/form-schemas/updateGameSchema.ts | 9 +- .../approvals/components/GameDetailsModal.tsx | 13 +- .../components/ImagePreviewModal.tsx | 5 +- src/app/admin/games/approvals/page.tsx | 4 +- src/app/admin/games/page.tsx | 6 +- src/app/admin/listings/page.tsx | 4 +- src/app/admin/pc-listing-approvals/page.tsx | 4 +- src/app/api/proxy-image/route.ts | 57 ---- src/app/games/GamesPage.tsx | 2 +- .../games/[id]/components/GameBoxartImage.tsx | 9 +- .../games/[id]/components/GameEditForm.tsx | 5 +- src/app/games/components/GameCard.tsx | 8 +- .../home/components/HomeFeaturedContent.tsx | 5 +- .../[id]/components/ListingDetailsClient.tsx | 2 +- .../components/shared/details/AuthorPanel.tsx | 1 - .../shared/selectors/GameSelector.tsx | 8 +- .../shared/components/GameImage.test.tsx | 4 +- .../listings/shared/components/GameImage.tsx | 8 +- .../components/PcListingDetailsClient.tsx | 2 +- .../review/CompatibilityReportReviewModal.tsx | 13 +- .../footer/components/FooterPatreonButton.tsx | 2 - src/components/game-follows/GameFollowRow.tsx | 5 +- .../game-search/GameSearchResultImage.tsx | 4 +- src/components/icons/EmulatorIcon.tsx | 1 - src/components/ui/ImageRenderer.tsx | 50 ++++ src/components/ui/OptimizedImage.tsx | 25 +- .../AdminImageSelectorSwitcher.tsx | 33 +-- .../providers/RawgImageSelector.tsx | 8 +- src/components/ui/index.ts | 1 + src/schemas/game.ts | 31 +- src/server/api/routers/games.test.ts | 35 ++- src/server/api/routers/games.ts | 7 + .../policies/game-image-url.policy.test.ts | 66 +++++ src/server/policies/game-image-url.policy.ts | 46 +++ src/utils/getImageUrl.test.ts | 26 +- src/utils/getImageUrl.ts | 21 +- src/utils/imageProxy.test.ts | 44 --- src/utils/imageProxy.ts | 34 --- src/utils/imageUrls.test.ts | 98 +++++++ src/utils/imageUrls.ts | 266 ++++++++++++++++++ tests/game-image-selectors.spec.ts | 247 ++++++++++++++++ tests/global.setup.ts | 5 + tests/helpers/external-services.ts | 10 +- tsconfig.json | 1 + 49 files changed, 1007 insertions(+), 322 deletions(-) rename {src/data => config}/image-hosts.ts (65%) create mode 100644 src/app/admin/games/[id]/form-schemas/updateGameSchema.test.ts delete mode 100644 src/app/api/proxy-image/route.ts create mode 100644 src/components/ui/ImageRenderer.tsx create mode 100644 src/server/policies/game-image-url.policy.test.ts create mode 100644 src/server/policies/game-image-url.policy.ts delete mode 100644 src/utils/imageProxy.test.ts delete mode 100644 src/utils/imageProxy.ts create mode 100644 src/utils/imageUrls.test.ts create mode 100644 src/utils/imageUrls.ts create mode 100644 tests/game-image-selectors.spec.ts diff --git a/src/data/image-hosts.ts b/config/image-hosts.ts similarity index 65% rename from src/data/image-hosts.ts rename to config/image-hosts.ts index 08e058344..eb8746aee 100644 --- a/src/data/image-hosts.ts +++ b/config/image-hosts.ts @@ -1,13 +1,21 @@ -export const NEXT_IMAGE_REMOTE_HOST_PATTERNS = [ - 'placehold.co', +export const GAME_IMAGE_PROVIDER_HOST_PATTERNS = [ 'media.rawg.io', - '*.clerk.com', - '*.clerk.accounts.dev', 'cdn.thegamesdb.net', 'images.igdb.com', 'assets.nintendo.com', + 'shared.akamai.steamstatic.com', + 'cdn1.epicgames.com', + 'cdn2.unrealengine.com', + 'images.gog-statics.com', +] as const + +export const NEXT_IMAGE_REMOTE_HOST_PATTERNS = [ + 'placehold.co', + '*.clerk.com', + '*.clerk.accounts.dev', 'storage.ko-fi.com', 'ko-fi.com', + ...GAME_IMAGE_PROVIDER_HOST_PATTERNS, ] as const export const NEXT_IMAGE_REMOTE_PATTERNS = NEXT_IMAGE_REMOTE_HOST_PATTERNS.map((hostname) => ({ diff --git a/next.config.ts b/next.config.ts index dfc050091..affdc29e9 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,6 +1,6 @@ import NextBundleAnalyzer from '@next/bundle-analyzer' import { withSentryConfig } from '@sentry/nextjs' -import { NEXT_IMAGE_REMOTE_PATTERNS } from '@/data/image-hosts' +import { NEXT_IMAGE_REMOTE_PATTERNS } from '@config/image-hosts' import type { NextConfig } from 'next' import type { Configuration as WebpackConfiguration } from 'webpack' @@ -48,24 +48,7 @@ const contentSecurityPolicyDirectives = [ }, { name: 'img-src', - sources: [ - "'self'", - 'data:', - 'https://placehold.co', - 'https://*.clerk.com', - 'https://*.clerk.accounts.dev', - 'https://img.clerk.com', - 'https://clerk.emuready.com', - 'https://cdn.thegamesdb.net', - 'https://images.igdb.com', - 'https://media.rawg.io', - 'https://www.googletagmanager.com', - 'https://assets.nintendo.com', - 'https://*.google-analytics.com', - 'https://storage.ko-fi.com', - 'https://vercel.com', - 'https://files.catbox.moe', - ], + sources: ["'self'", 'data:', 'https:'], }, { name: 'font-src', @@ -166,14 +149,14 @@ function createContentSecurityPolicy(): string { const nextConfig: NextConfig = { images: { unoptimized: process.env.NEXT_IMAGE_UNOPTIMIZED === 'true', - dangerouslyAllowSVG: true, qualities: [50, 75, 85, 100], + maximumRedirects: 0, + maximumResponseBody: 5_000_000, localPatterns: [ - // Allow any query on the proxy route - { pathname: '/api/proxy-image' }, { pathname: '/_next/**' }, { pathname: '/placeholder/**' }, { pathname: '/assets/android-app/**' }, + { pathname: '/uploads/**' }, ], remotePatterns: NEXT_IMAGE_REMOTE_PATTERNS, }, diff --git a/src/app/admin/components/ImagePreviewModal.tsx b/src/app/admin/components/ImagePreviewModal.tsx index 84b3a182d..89a5926e9 100644 --- a/src/app/admin/components/ImagePreviewModal.tsx +++ b/src/app/admin/components/ImagePreviewModal.tsx @@ -1,9 +1,8 @@ 'use client' import { ExternalLink } from 'lucide-react' -import Image from 'next/image' import { useState } from 'react' -import { Modal, Button } from '@/components/ui' +import { Button, ImageRenderer, Modal } from '@/components/ui' import analytics from '@/lib/analytics' import { cn } from '@/lib/utils' import getImageUrl from '@/utils/getImageUrl' @@ -97,7 +96,7 @@ function ImagePreviewModal(props: Props) { {currentImageUrl && !failedImages.has(activeTab) ? (
    - {`${props.game.title} -type UpdateGameInput = ZodInfer +type UpdateGameFormInput = z.input +type UpdateGameInput = z.output interface Props { game: Game @@ -47,7 +48,11 @@ export function GameEditForm(props: Props) { }, }) - const { register, handleSubmit, formState, setValue, watch } = useForm({ + const { register, handleSubmit, formState, setValue, watch } = useForm< + UpdateGameFormInput, + unknown, + UpdateGameInput + >({ resolver: zodResolver(updateGameSchema), defaultValues: { title: props.game.title, @@ -61,9 +66,7 @@ export function GameEditForm(props: Props) { }) const onSubmit = (data: UpdateGameInput) => { - console.log('Form data being sent:', { id: props.game.id, ...data }) setIsSubmitting(true) - // The schema now handles transformation of empty strings to undefined updateGame.mutate({ id: props.game.id, ...data }) } diff --git a/src/app/admin/games/[id]/form-schemas/updateGameSchema.test.ts b/src/app/admin/games/[id]/form-schemas/updateGameSchema.test.ts new file mode 100644 index 000000000..e59b58fab --- /dev/null +++ b/src/app/admin/games/[id]/form-schemas/updateGameSchema.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest' +import updateGameSchema from './updateGameSchema' + +const baseGameInput = { + title: 'Alan Wake', + systemId: '504bca13-6f70-4303-86d4-99a60380a883', + isErotic: false, +} + +describe('updateGameSchema', () => { + it('converts cleared image fields to null so existing URLs can be removed', () => { + const result = updateGameSchema.parse({ + ...baseGameInput, + imageUrl: ' ', + boxartUrl: '', + bannerUrl: '', + }) + + expect(result.imageUrl).toBeNull() + expect(result.boxartUrl).toBeNull() + expect(result.bannerUrl).toBeNull() + }) + + it('keeps valid HTTPS image URLs trimmed', () => { + const result = updateGameSchema.parse({ + ...baseGameInput, + imageUrl: ' https://media.rawg.io/media/games/example.jpg ', + bannerUrl: undefined, + }) + + expect(result.imageUrl).toBe('https://media.rawg.io/media/games/example.jpg') + }) +}) diff --git a/src/app/admin/games/[id]/form-schemas/updateGameSchema.ts b/src/app/admin/games/[id]/form-schemas/updateGameSchema.ts index d1e28d6a4..4b3834681 100644 --- a/src/app/admin/games/[id]/form-schemas/updateGameSchema.ts +++ b/src/app/admin/games/[id]/form-schemas/updateGameSchema.ts @@ -1,12 +1,13 @@ import { z } from 'zod' +import { getGameImageUrlValidationError } from '@/utils/imageUrls' const imageUrlSchema = z .string() - .transform((val) => val.trim()) // Trim whitespace - .refine((val) => val === '' || val.startsWith('http://') || val.startsWith('https://'), { - message: 'Must be a valid URL starting with http:// or https://', + .transform((val) => val.trim()) + .refine((val) => !getGameImageUrlValidationError(val), { + message: 'Must be a valid HTTPS image URL', }) - .transform((val) => val || undefined) // Convert empty string to undefined + .transform((val) => val || null) .optional() const updateGameSchema = z.object({ diff --git a/src/app/admin/games/approvals/components/GameDetailsModal.tsx b/src/app/admin/games/approvals/components/GameDetailsModal.tsx index b9945b64d..dd859144b 100644 --- a/src/app/admin/games/approvals/components/GameDetailsModal.tsx +++ b/src/app/admin/games/approvals/components/GameDetailsModal.tsx @@ -15,7 +15,14 @@ import { useRouter } from 'next/navigation' import { useState } from 'react' import { isNumber } from 'remeda' import { type ProcessingAction } from '@/app/admin/games/approvals/page' -import { Modal, Button, ApprovalStatusBadge, Code, LocalizedDate } from '@/components/ui' +import { + ApprovalStatusBadge, + Button, + Code, + ImageRenderer, + LocalizedDate, + Modal, +} from '@/components/ui' import analytics from '@/lib/analytics' import { api } from '@/lib/api' import { logger } from '@/lib/logger' @@ -155,7 +162,7 @@ export default function GameDetailsModal(props: Props) {
    {hasAnyImage && (
    - {props.selectedGame.title} handleImageClick(activeImageTab)} className="w-full block" > - {`${props.selectedGame.title}
    - {`${props.game.title}
    - {game.title} handleImageClick(game)} className="group relative block" > - {game.title} - {/* Image indicators */}
    @@ -521,7 +520,6 @@ function AdminGamesPage() { )} - {/* Image Preview Modal */} setIsImagePreviewOpen(false)} diff --git a/src/app/admin/listings/page.tsx b/src/app/admin/listings/page.tsx index 5ed387925..be82c45c9 100644 --- a/src/app/admin/listings/page.tsx +++ b/src/app/admin/listings/page.tsx @@ -1,6 +1,5 @@ 'use client' -import Image from 'next/image' import Link from 'next/link' import { useState } from 'react' import { isEmpty } from 'remeda' @@ -20,6 +19,7 @@ import { DisplayToggleButton, Dropdown, EditButton, + ImageRenderer, LoadingSpinner, Pagination, SortableHeader, @@ -389,7 +389,7 @@ function AdminListingsPage() {
    - {listing.game.title} {listing.game.imageUrl && ( - {listing.game.title}
    {games.map((game, index) => ( - + ))}
    diff --git a/src/app/games/[id]/components/GameBoxartImage.tsx b/src/app/games/[id]/components/GameBoxartImage.tsx index b450de48a..fff6b5f60 100644 --- a/src/app/games/[id]/components/GameBoxartImage.tsx +++ b/src/app/games/[id]/components/GameBoxartImage.tsx @@ -121,9 +121,10 @@ export function GameBoxartImage(props: Props) { } const getCurrentImageUrl = () => { - return ( - getImageUrl(getFieldValue(activeImageType), props.game.title) || getGameImageUrl(props.game) - ) + const activeImageUrl = getFieldValue(activeImageType) + return activeImageUrl + ? getImageUrl(activeImageUrl, props.game.title) + : getGameImageUrl(props.game) } const availableImageTypes: ImageField[] = ['imageUrl', 'boxartUrl', 'bannerUrl'] @@ -175,7 +176,7 @@ export function GameBoxartImage(props: Props) { imageClassName="w-full max-h-96" objectFit="contain" fallbackSrc="/placeholder/game.svg" - priority + preload quality={75} /> diff --git a/src/app/games/[id]/components/GameEditForm.tsx b/src/app/games/[id]/components/GameEditForm.tsx index 709779436..2e7d65828 100644 --- a/src/app/games/[id]/components/GameEditForm.tsx +++ b/src/app/games/[id]/components/GameEditForm.tsx @@ -2,10 +2,9 @@ import { useUser } from '@clerk/nextjs' import { ImageIcon, X } from 'lucide-react' -import Image from 'next/image' import { useRouter } from 'next/navigation' import { useState, type FormEvent } from 'react' -import { Button, Input, Badge, EditButton } from '@/components/ui' +import { Badge, Button, EditButton, ImageRenderer, Input } from '@/components/ui' import { ImageSelectorSwitcher } from '@/components/ui/image-selectors' import analytics from '@/lib/analytics' import { api } from '@/lib/api' @@ -319,7 +318,7 @@ export function GameEditForm(props: Props) { {/* Image Preview */} {getCurrentImageUrl() && (
    - {`${title}
    - {props.game.title}
    diff --git a/src/app/home/components/HomeFeaturedContent.tsx b/src/app/home/components/HomeFeaturedContent.tsx index b79eef8db..4d3c71696 100644 --- a/src/app/home/components/HomeFeaturedContent.tsx +++ b/src/app/home/components/HomeFeaturedContent.tsx @@ -1,7 +1,6 @@ import { MessageCircle, ThumbsUp } from 'lucide-react' -import Image from 'next/image' import Link from 'next/link' -import { LoadingSpinner, PerformanceBadge, SuccessRateBar } from '@/components/ui' +import { ImageRenderer, LoadingSpinner, PerformanceBadge, SuccessRateBar } from '@/components/ui' import { api } from '@/lib/api' import getImageUrl from '@/utils/getImageUrl' @@ -31,7 +30,7 @@ export function HomeFeaturedContent() { className="group bg-white/80 dark:bg-gray-800/80 rounded-2xl overflow-hidden shadow-xl hover:shadow-2xl transition duration-500 transform hover:scale-[1.02] backdrop-blur-sm border border-gray-200/50 dark:border-gray-700/50" >
    - {listing.game.title}
    diff --git a/src/app/listings/components/shared/details/AuthorPanel.tsx b/src/app/listings/components/shared/details/AuthorPanel.tsx index 6f16d5634..e1585eea0 100644 --- a/src/app/listings/components/shared/details/AuthorPanel.tsx +++ b/src/app/listings/components/shared/details/AuthorPanel.tsx @@ -24,7 +24,6 @@ export function AuthorPanel(props: Props) { fill sizes="64px" className="object-cover" - priority unoptimized /> ) : ( diff --git a/src/app/listings/components/shared/selectors/GameSelector.tsx b/src/app/listings/components/shared/selectors/GameSelector.tsx index 47a736667..49fe0f475 100644 --- a/src/app/listings/components/shared/selectors/GameSelector.tsx +++ b/src/app/listings/components/shared/selectors/GameSelector.tsx @@ -1,12 +1,12 @@ 'use client' import { Puzzle } from 'lucide-react' -import Image from 'next/image' import { Controller } from 'react-hook-form' import { type Control, type FieldPath, type FieldValues } from 'react-hook-form' -import { Autocomplete } from '@/components/ui' +import { Autocomplete, ImageRenderer } from '@/components/ui' import { logger } from '@/lib/logger' import { type Nullable } from '@/types/utils' +import getImageUrl from '@/utils/getImageUrl' import { ApprovalStatus } from '@orm' import { SelectedItemCard } from '../SelectedItemCard' import { type GameOption } from '../types' @@ -37,8 +37,8 @@ export function GameSelector( leftContent={ thumbnailUrl ? (
    - {props.selectedGame.title} ({ default: ( props: ImgHTMLAttributes & { fill?: boolean - priority?: boolean + preload?: boolean unoptimized?: boolean }, ) => { - const { fill: _fill, priority: _priority, unoptimized: _unoptimized, ...imgProps } = props + const { fill: _fill, preload: _preload, unoptimized: _unoptimized, ...imgProps } = props return {String(imgProps.alt }, })) diff --git a/src/app/listings/shared/components/GameImage.tsx b/src/app/listings/shared/components/GameImage.tsx index 4d0337fb1..76583f724 100644 --- a/src/app/listings/shared/components/GameImage.tsx +++ b/src/app/listings/shared/components/GameImage.tsx @@ -1,7 +1,7 @@ 'use client' -import Image from 'next/image' import { useState } from 'react' +import { ImageRenderer } from '@/components/ui' import { cn } from '@/lib/utils' import getImageUrl from '@/utils/getImageUrl' @@ -16,7 +16,7 @@ interface Props { className?: string prioritizeBanner?: boolean sizes?: string - priority?: boolean + preload?: boolean aspectRatio?: 'square' | 'video' | 'poster' | 'auto' showFallback?: boolean } @@ -67,13 +67,13 @@ export function GameImage(props: Props) { props.className, )} > - {props.game.title} setImageError(true)} unoptimized={false} /> diff --git a/src/app/pc-listings/[id]/components/PcListingDetailsClient.tsx b/src/app/pc-listings/[id]/components/PcListingDetailsClient.tsx index b10163101..8f95deb97 100644 --- a/src/app/pc-listings/[id]/components/PcListingDetailsClient.tsx +++ b/src/app/pc-listings/[id]/components/PcListingDetailsClient.tsx @@ -149,7 +149,7 @@ function PcListingDetailsClient(props: Props) { className="w-full max-w-full aspect-video rounded-lg shadow-md" aspectRatio="video" showFallback={true} - priority={true} + preload={true} />
    diff --git a/src/components/compatibility/review/CompatibilityReportReviewModal.tsx b/src/components/compatibility/review/CompatibilityReportReviewModal.tsx index 96e74276f..d47058d6d 100644 --- a/src/components/compatibility/review/CompatibilityReportReviewModal.tsx +++ b/src/components/compatibility/review/CompatibilityReportReviewModal.tsx @@ -1,6 +1,5 @@ 'use client' -import Image from 'next/image' import Link from 'next/link' import { useState } from 'react' import { @@ -8,7 +7,15 @@ import { type CompatibilityCustomFieldValue, } from '@/components/compatibility/custom-fields' import { EmulatorIcon, SystemIcon } from '@/components/icons' -import { Badge, Button, Input, LocalizedDate, Modal, PerformanceBadge } from '@/components/ui' +import { + Badge, + Button, + ImageRenderer, + Input, + LocalizedDate, + Modal, + PerformanceBadge, +} from '@/components/ui' import { useEmulatorLogos } from '@/hooks' import getImageUrl from '@/utils/getImageUrl' import { @@ -48,7 +55,7 @@ function GameInfoSection(props: { game: CompatibilityReportReviewGame }) {

    Game

    {props.game.imageUrl && ( - {props.game.title} )} - {/* Avoid hydration mismatch; render nothing until mounted */}
    {/* Accent pulse */} diff --git a/src/components/game-follows/GameFollowRow.tsx b/src/components/game-follows/GameFollowRow.tsx index 9f7157633..6b0856751 100644 --- a/src/components/game-follows/GameFollowRow.tsx +++ b/src/components/game-follows/GameFollowRow.tsx @@ -1,9 +1,8 @@ 'use client' import { Gamepad2 } from 'lucide-react' -import Image from 'next/image' import Link from 'next/link' -import { Badge, LocalizedDate } from '@/components/ui' +import { Badge, ImageRenderer, LocalizedDate } from '@/components/ui' import { cn } from '@/lib/utils' import { type RouterOutput } from '@/types/trpc' @@ -36,7 +35,7 @@ function GameFollowRow(props: Props) { onClick={props.onClick} > {game.imageUrl ? ( - {game.title}
    diff --git a/src/components/ui/ImageRenderer.tsx b/src/components/ui/ImageRenderer.tsx new file mode 100644 index 000000000..9d25d9a83 --- /dev/null +++ b/src/components/ui/ImageRenderer.tsx @@ -0,0 +1,50 @@ +'use client' + +import Image, { type ImageProps } from 'next/image' +import { getImageRenderMode } from '@/utils/imageUrls' +import type { CSSProperties, ImgHTMLAttributes } from 'react' + +type NativeImageDimension = ImgHTMLAttributes['width'] + +type Props = Omit + +function createFillImageStyle(fill: ImageProps['fill'], style: ImageProps['style']): CSSProperties { + if (!fill) return style ?? {} + + return { + position: 'absolute', + height: '100%', + width: '100%', + inset: 0, + color: 'transparent', + ...style, + } +} + +function getNativeDimension(value: ImageProps['width']): NativeImageDimension { + if (typeof value === 'number' || typeof value === 'string') return value + return undefined +} + +export function ImageRenderer(props: Props) { + if (typeof props.src !== 'string' || getImageRenderMode(props.src) !== 'external-img') { + return {props.alt} + } + + return ( + {props.alt} + ) +} diff --git a/src/components/ui/OptimizedImage.tsx b/src/components/ui/OptimizedImage.tsx index 8ea5e510b..2557b2448 100644 --- a/src/components/ui/OptimizedImage.tsx +++ b/src/components/ui/OptimizedImage.tsx @@ -1,10 +1,11 @@ 'use client' -import Image, { type ImageProps } from 'next/image' -import { useState } from 'react' -import { LoadingSpinner } from '@/components/ui' +import { type ImageProps } from 'next/image' +import { useEffect, useState } from 'react' import { cn } from '@/lib/utils' -import { resolveImageProxyUrl } from '@/utils/imageProxy' +import getImageUrl from '@/utils/getImageUrl' +import { ImageRenderer } from './ImageRenderer' +import { LoadingSpinner } from './LoadingSpinner' type ObjectFit = 'contain' | 'cover' | 'fill' | 'none' | 'scale-down' @@ -23,13 +24,13 @@ interface Props { height?: number className?: string imageClassName?: string - priority?: ImageProps['priority'] + preload?: ImageProps['preload'] unoptimized?: ImageProps['unoptimized'] loading?: ImageProps['loading'] + fetchPriority?: ImageProps['fetchPriority'] quality?: 50 | 75 | 85 | 100 fallbackSrc?: string objectFit?: ObjectFit - useProxy?: boolean } export function OptimizedImage(props: Props) { @@ -40,9 +41,14 @@ export function OptimizedImage(props: Props) { const resolveSrc = (): string => { if (error) return fallbackSrc - return resolveImageProxyUrl(props.src, props.useProxy) + return getImageUrl(props.src, null) } + useEffect(() => { + setIsLoading(true) + setError(false) + }, [props.src, fallbackSrc]) + const handleError = () => { setIsLoading(false) setError(true) @@ -55,7 +61,7 @@ export function OptimizedImage(props: Props) {
    )} - {props.alt} setIsLoading(false)} onError={handleError} diff --git a/src/components/ui/image-selectors/AdminImageSelectorSwitcher.tsx b/src/components/ui/image-selectors/AdminImageSelectorSwitcher.tsx index 9c170d697..415ec9c8c 100644 --- a/src/components/ui/image-selectors/AdminImageSelectorSwitcher.tsx +++ b/src/components/ui/image-selectors/AdminImageSelectorSwitcher.tsx @@ -2,15 +2,10 @@ import { motion, AnimatePresence } from 'framer-motion' import { Database, Zap, Link, X, Check, Sparkles } from 'lucide-react' -import Image from 'next/image' -import { useState } from 'react' -import { Button, Input } from '@/components/ui' +import { useEffect, useState } from 'react' +import { ImageRenderer, Input, Button } from '@/components/ui' import getImageUrl from '@/utils/getImageUrl' -import { - validateImageUrl, - getImageValidationError, - IMAGE_EXTENSIONS, -} from '@/utils/imageValidation' +import { getGameImageUrlValidationError } from '@/utils/imageUrls' import { IGDBImageSelector } from './providers/IGDBImageSelector' import { RawgImageSelector } from './providers/RawgImageSelector' import { TGDBImageSelector } from './providers/TGDBImageSelector' @@ -42,10 +37,16 @@ export function AdminImageSelectorSwitcher(props: Props) { const [isValidUrl, setIsValidUrl] = useState(false) const [showApplied, setShowApplied] = useState(false) + useEffect(() => { + const selectedImageUrl = props.selectedImageUrl ?? '' + setManualUrl(selectedImageUrl) + setIsValidUrl(selectedImageUrl ? !getGameImageUrlValidationError(selectedImageUrl) : false) + }, [props.selectedImageUrl]) + const validateUrl = (url: string) => { - const result = validateImageUrl(url) - setIsValidUrl(result.isValid) - return result.isValid + const isValid = !getGameImageUrlValidationError(url) + setIsValidUrl(isValid) + return isValid } const handleManualUrlChange = (url: string) => { @@ -65,7 +66,7 @@ export function AdminImageSelectorSwitcher(props: Props) { } else if (trimmedUrl === '') { props.onImageSelect('') } else { - props.onError?.(getImageValidationError(trimmedUrl)) + props.onError?.(getGameImageUrlValidationError(trimmedUrl) ?? 'Invalid image URL') } } @@ -208,8 +209,7 @@ export function AdminImageSelectorSwitcher(props: Props) {
    - {selectedService === 'url' && - `Paste any image URL (${IMAGE_EXTENSIONS.join(', ')}) from the web`} + {selectedService === 'url' && 'HTTPS image URL'} {selectedService === 'rawg' && 'RAWG.io provides comprehensive game data with screenshots and backgrounds'} {selectedService === 'tgdb' && @@ -253,6 +253,7 @@ export function AdminImageSelectorSwitcher(props: Props) { {manualUrl.trim() && (