diff --git a/.env.docker.example b/.env.docker.example index b26be84cb..deccc7ed8 100644 --- a/.env.docker.example +++ b/.env.docker.example @@ -76,8 +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_PUBLIC_ENABLE_V2_LISTINGS=false NEXT_TELEMETRY_DISABLED=1 NEXT_IMAGE_UNOPTIMIZED=false diff --git a/.env.example b/.env.example index 6bbbb6ae8..5f565192d 100644 --- a/.env.example +++ b/.env.example @@ -51,8 +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_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..c1bb01153 100644 --- a/.env.test.example +++ b/.env.test.example @@ -40,8 +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_PUBLIC_ENABLE_V2_LISTINGS=false NEXT_TELEMETRY_DISABLED=1 NEXT_IMAGE_UNOPTIMIZED=true diff --git a/AGENTS.md b/AGENTS.md index f49386e81..351402462 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. @@ -60,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 diff --git a/config/image-hosts.ts b/config/image-hosts.ts new file mode 100644 index 000000000..eb8746aee --- /dev/null +++ b/config/image-hosts.ts @@ -0,0 +1,25 @@ +export const GAME_IMAGE_PROVIDER_HOST_PATTERNS = [ + 'media.rawg.io', + '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) => ({ + protocol: 'https' as const, + hostname, + pathname: '/**', +})) diff --git a/docs/MOBILE_API.md b/docs/MOBILE_API.md index 57e751106..2abcdaca3 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-15T12:16:27.168Z* ## 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 @@ -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 @@ -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/next.config.ts b/next.config.ts index 9069d8859..affdc29e9 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 '@config/image-hosts' import type { NextConfig } from 'next' import type { Configuration as WebpackConfiguration } from 'webpack' @@ -47,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', @@ -165,26 +149,16 @@ 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: [ - { 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/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..f7f24bab1 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" @@ -7656,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" ], @@ -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/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..a71feb8cb 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' @@ -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 eb4922707..4dc0f82b9 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, @@ -21,9 +20,11 @@ import { Pagination, LocalizedDate, Code, + Dropdown, } 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' @@ -180,36 +181,8 @@ function AdminAuditLogsPage() { searchPlaceholder="Search by actor, target, entity ID, request, IP, user agent..." >
- - - - + +
- {`${props.game.title} 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/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/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/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/[id]/components/GameEditForm.tsx b/src/app/admin/games/[id]/components/GameEditForm.tsx index 70625709f..90a0dc2c1 100644 --- a/src/app/admin/games/[id]/components/GameEditForm.tsx +++ b/src/app/admin/games/[id]/components/GameEditForm.tsx @@ -4,7 +4,6 @@ import { zodResolver } from '@hookform/resolvers/zod' import { useRouter } from 'next/navigation' import { useState } from 'react' import { useForm } from 'react-hook-form' -import { type infer as ZodInfer } from 'zod' import { Button, Input, Autocomplete } from '@/components/ui' import { AdminImageSelectorSwitcher } from '@/components/ui/image-selectors' import { api } from '@/lib/api' @@ -12,10 +11,12 @@ import toast from '@/lib/toast' import { type RouterOutput } from '@/types/trpc' import getErrorMessage from '@/utils/getErrorMessage' import updateGameSchema from '../form-schemas/updateGameSchema' +import type { z } from 'zod' type Game = NonNullable -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 */}
@@ -522,7 +520,6 @@ function AdminGamesPage() { )} - {/* Image Preview Modal */} setIsImagePreviewOpen(false)} 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 179e5033f..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 { useAdminTable } from '@/app/admin/hooks' -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 { 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/listings/page.tsx b/src/app/admin/listings/page.tsx index f63d7e14b..be82c45c9 100644 --- a/src/app/admin/listings/page.tsx +++ b/src/app/admin/listings/page.tsx @@ -1,11 +1,8 @@ 'use client' -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, @@ -22,6 +19,7 @@ import { DisplayToggleButton, Dropdown, EditButton, + ImageRenderer, LoadingSpinner, Pagination, SortableHeader, @@ -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' @@ -390,7 +389,7 @@ function AdminListingsPage() {
- {listing.game.title}({ - defaultLimit: 20, defaultSortField: 'createdAt', defaultSortDirection: 'asc', }) @@ -569,7 +570,7 @@ function PcListingApprovalsPage() { {columnVisibility.isColumnVisible('thumbnail') && ( {listing.game.imageUrl && ( - {listing.game.title} - {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 c32b0abbd..88ad69622 100644 --- a/src/app/admin/pc-processed-listings/page.tsx +++ b/src/app/admin/pc-processed-listings/page.tsx @@ -6,8 +6,10 @@ import { ProcessedReportsAdminPage, type ProcessedReportHardwareColumn, } from '@/app/admin/components/processed-reports' -import { useAdminTable } from '@/app/admin/hooks' 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' import toast from '@/lib/toast' @@ -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}`, + render: (listing) => getCpuLabel(listing.cpu), }, { key: 'gpu', label: 'GPU', sortField: 'gpu', defaultVisible: true, - render: getGpuLabel, + render: getProcessedGpuLabel, }, ] 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/adminReport.ts b/src/app/admin/reports/adminReport.ts new file mode 100644 index 000000000..7136ce7fc --- /dev/null +++ b/src/app/admin/reports/adminReport.ts @@ -0,0 +1,77 @@ +import { type RouterOutput } from '@/types/trpc' + +type ListingReportWithDetails = RouterOutput['listingReports']['get']['reports'][number] +type PcListingReportWithDetails = RouterOutput['pcListingReports']['get']['reports'][number] + +const ADMIN_REPORT_KIND = { + HANDHELD: 'handheld', + PC: 'pc', +} as const + +export const REPORT_TYPES = [ + { value: ADMIN_REPORT_KIND.HANDHELD, label: 'Handheld Reports' }, + { value: ADMIN_REPORT_KIND.PC, label: 'PC Reports' }, +] as const + +export type AdminReportKind = (typeof REPORT_TYPES)[number]['value'] + +export function isAdminReportKind(value: string): value is AdminReportKind { + return REPORT_TYPES.some((reportType) => reportType.value === value) +} + +export function toHandheldAdminReport(report: ListingReportWithDetails) { + return { + kind: ADMIN_REPORT_KIND.HANDHELD, + id: report.id, + reason: report.reason, + status: report.status, + description: report.description, + reviewNotes: report.reviewNotes, + reviewedAt: report.reviewedAt, + createdAt: report.createdAt, + reportedBy: report.reportedBy, + reviewedBy: report.reviewedBy, + compatibilityReport: { + id: report.listing.id, + href: `/listings/${report.listing.id}`, + reportLabel: 'Handheld Report', + gameTitle: report.listing.game.title, + hardwareFieldLabel: 'Device', + hardwareLabel: report.listing.device.modelName, + emulatorName: report.listing.emulator.name, + author: report.listing.author, + }, + } +} + +export function toPcAdminReport(report: PcListingReportWithDetails) { + const cpuName = report.pcListing.cpu?.modelName ?? 'Unknown CPU' + const gpuName = report.pcListing.gpu?.modelName ?? 'Integrated GPU' + + return { + kind: ADMIN_REPORT_KIND.PC, + id: report.id, + reason: report.reason, + status: report.status, + description: report.description, + reviewNotes: report.reviewNotes, + reviewedAt: report.reviewedAt, + createdAt: report.createdAt, + reportedBy: report.reportedBy, + reviewedBy: report.reviewedBy, + compatibilityReport: { + id: report.pcListing.id, + href: `/pc-listings/${report.pcListing.id}`, + reportLabel: 'PC Report', + gameTitle: report.pcListing.game.title, + hardwareFieldLabel: 'Hardware', + hardwareLabel: `${cpuName} / ${gpuName}`, + emulatorName: report.pcListing.emulator.name, + author: report.pcListing.author, + }, + } +} + +export type AdminReportWithDetails = + | ReturnType + | ReturnType diff --git a/src/app/admin/reports/components/ReportDetailsModal.tsx b/src/app/admin/reports/components/ReportDetailsModal.tsx index 1d7fa7983..a0667c5b1 100644 --- a/src/app/admin/reports/components/ReportDetailsModal.tsx +++ b/src/app/admin/reports/components/ReportDetailsModal.tsx @@ -1,12 +1,12 @@ 'use client' import { Button, Modal, Badge, LocalizedDate } from '@/components/ui' -import { type ListingReportWithDetails } from '../types' +import { type AdminReportWithDetails } from '../adminReport' interface Props { isOpen: boolean onClose: () => void - report?: ListingReportWithDetails + report?: AdminReportWithDetails } function ReportDetailsModal(props: Props) { @@ -17,7 +17,6 @@ function ReportDetailsModal(props: Props) { return (
- {/* Report Info */}

Report Information @@ -66,7 +65,6 @@ function ReportDetailsModal(props: Props) { )}

- {/* Reported User */}

Reported By

@@ -83,25 +81,34 @@ function ReportDetailsModal(props: Props) {
- {/* Listing Details */}

- Reported Listing + Reported Compatibility Report

+
+ +

+ {report.compatibilityReport.reportLabel} +

+
-

{report.listing.game.title}

+

+ {report.compatibilityReport.gameTitle} +

- {report.listing.device.modelName} + {report.compatibilityReport.hardwareLabel}

@@ -109,7 +116,7 @@ function ReportDetailsModal(props: Props) { Emulator

- {report.listing.emulator.name} + {report.compatibilityReport.emulatorName}

@@ -117,14 +124,13 @@ function ReportDetailsModal(props: Props) { Author

- {report.listing.author.name || 'Unknown'} + {report.compatibilityReport.author.name || 'Unknown'}

- {/* Review Notes */} {report.reviewNotes && (

@@ -141,19 +147,18 @@ function ReportDetailsModal(props: Props) {

)} - {/* Actions */}
-
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 4345ee907..ca80f4066 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, @@ -24,26 +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/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/app/api/proxy-image/route.ts b/src/app/api/proxy-image/route.ts deleted file mode 100644 index 91bf13c41..000000000 --- a/src/app/api/proxy-image/route.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { connection, type NextRequest } from 'next/server' -import { env } from '@/lib/env' -import { logger } from '@/lib/logger' - -/** - * Only allow http and https URLs to prevent SSRF attacks - */ -function isAllowedUrl(raw?: string | null): URL | null { - if (!raw) return null - try { - const u = new URL(raw) - if (u.protocol !== 'http:' && u.protocol !== 'https:') return null - return u - } catch { - return null - } -} - -export async function GET(req: NextRequest) { - await connection() - - const src = req.nextUrl.searchParams.get('url') - const url = isAllowedUrl(src) - if (!url) return new Response('Invalid or missing url parameter', { status: 400 }) - - try { - const upstream = await fetch(url.toString(), { - cache: env.IS_PRODUCTION_BUILD ? 'force-cache' : 'no-store', - redirect: 'follow', - headers: { - 'User-Agent': 'EmuReadyImageProxy/1.0 (+https://www.emuready.com)', - }, - }) - - if (!upstream.ok || !upstream.body) { - return new Response('Upstream fetch failed', { status: upstream.status || 502 }) - } - - const contentType = upstream.headers.get('content-type') || 'application/octet-stream' - const cacheControl = env.IS_PRODUCTION_BUILD - ? 'public, max-age=3600, s-maxage=3600, stale-while-revalidate=600' - : 'no-store, no-cache, must-revalidate' - - return new Response(upstream.body, { - status: 200, - headers: { - 'Content-Type': contentType, - 'Cache-Control': cacheControl, - 'CDN-Cache-Control': cacheControl, - 'Vercel-CDN-Cache-Control': cacheControl, - }, - }) - } catch (error) { - logger.error('[proxy-image] Error fetching image:', error) - return new Response('Bad Gateway', { status: 502 }) - } -} diff --git a/src/app/games/GamesPage.tsx b/src/app/games/GamesPage.tsx index 07200b3a4..d78283038 100644 --- a/src/app/games/GamesPage.tsx +++ b/src/app/games/GamesPage.tsx @@ -222,7 +222,7 @@ function GamesContent() { <>
{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} @@ -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/games/components/GameCard.tsx b/src/app/games/components/GameCard.tsx index 260978e39..ec199f4ef 100644 --- a/src/app/games/components/GameCard.tsx +++ b/src/app/games/components/GameCard.tsx @@ -1,4 +1,3 @@ -import Image from 'next/image' import Link from 'next/link' import { Badge, @@ -6,6 +5,7 @@ import { Tooltip, TooltipTrigger, TooltipContent, + ImageRenderer, } from '@/components/ui' import getGameImageUrl from '@/utils/images/getGameImageUrl' import { type Game, ApprovalStatus } from '@orm' @@ -15,7 +15,7 @@ interface Props { system?: { name: string } | null _count: { listings: number; pcListings: number } } - priority?: boolean + eagerLoad?: boolean } function GameCard(props: Props) { @@ -28,13 +28,13 @@ function GameCard(props: Props) { className="bg-white dark:bg-gray-800 rounded-xl shadow-md overflow-hidden hover:shadow-lg transition-shadow duration-200" >
- {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} = { } 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..29b3ab047 100644 --- a/src/app/listings/ListingsPage.tsx +++ b/src/app/listings/ListingsPage.tsx @@ -66,10 +66,6 @@ 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' - function ListingsPage() { const { isSignedIn } = useUser() const router = useRouter() @@ -112,39 +108,9 @@ 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 devicesQuery = api.devices.options.useQuery( - { limit: 10000 }, - { - enabled: !USE_ASYNC_LISTING_FILTERS, - 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 socsQuery = api.socs.options.useQuery( - { limit: 10000 }, - { - enabled: !USE_ASYNC_LISTING_FILTERS, - staleTime: LOOKUP_DATA_STALE_TIME, - gcTime: LOOKUP_DATA_GC_TIME, - }, - ) - 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 systemsQuery = api.systems.get.useQuery() + const emulatorsQuery = api.emulators.get.useQuery({ limit: 100 }) + const performanceScalesQuery = api.listings.performanceScales.useQuery() const filterParams: RouterInput['listings']['get'] = { page: listingsState.page, @@ -254,9 +220,6 @@ function ListingsPage() { return
Failed to load listings.
} - const devicesForFilters = devicesQuery.data?.devices ?? [] - const socsForFilters = socsQuery.data?.socs ?? [] - return (
@@ -270,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} @@ -306,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/[id]/components/ListingDetailsClient.tsx b/src/app/listings/[id]/components/ListingDetailsClient.tsx index 23ebcef9b..ac9e2ccf4 100644 --- a/src/app/listings/[id]/components/ListingDetailsClient.tsx +++ b/src/app/listings/[id]/components/ListingDetailsClient.tsx @@ -106,7 +106,7 @@ function ListingDetailsClient(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/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/app/pc-listings/components/PcFiltersContent.tsx b/src/app/pc-listings/components/PcFiltersContent.tsx index c8894bc17..db798580f 100644 --- a/src/app/pc-listings/components/PcFiltersContent.tsx +++ b/src/app/pc-listings/components/PcFiltersContent.tsx @@ -6,19 +6,11 @@ import { type ChangeEvent } from 'react' import { ListingsSearchBar, ActiveFiltersSummary } from '@/app/listings/shared/components' import { buildPcActiveFilterItems } from '@/app/pc-listings/utils/buildPcActiveFilterItems' import { MultiSelect, Input } from '@/components/ui' -import { - cpuOptions, - emulatorOptions, - gpuOptions, - performanceOptions, - systemOptions, -} from '@/utils/options' +import { type Option } from '@/components/ui/form/async-multi-select/AsyncMultiSelect' +import AsyncCpuFilterSelect from '@/features/hardware/cpu/client/components/AsyncCpuFilterSelect' +import AsyncGpuFilterSelect from '@/features/hardware/gpu/client/components/AsyncGpuFilterSelect' +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 } } interface Props { cpuIds: string[] @@ -29,13 +21,11 @@ interface Props { minMemory: number | null maxMemory: number | null searchTerm: string - cpus: CpuWithBrand[] - gpus: GpuWithBrand[] systems: System[] emulators: Emulator[] performanceScales: PerformanceScale[] - onCpuChange: (values: string[]) => void - onGpuChange: (values: string[]) => void + onCpuChange: (values: string[], selectedOptions: Option[]) => void + onGpuChange: (values: string[], selectedOptions: Option[]) => void onSystemChange: (values: string[]) => void onEmulatorChange: (values: string[]) => void onPerformanceChange: (values: string[]) => void @@ -80,8 +70,6 @@ export default function PcFiltersContent(props: Props) { props.onPerformanceChange(values) } - const ENABLE_ASYNC = process.env.NEXT_PUBLIC_ENABLE_ASYNC_LISTINGS_FILTERS === 'true' - const hasActiveFilters = props.searchTerm || props.systemIds.length > 0 || @@ -116,48 +104,24 @@ export default function PcFiltersContent(props: Props) { /> {/* CPUs */} - {ENABLE_ASYNC ? ( - } - value={props.cpuIds} - onChange={props.onCpuChange} - placeholder="All CPUs" - maxDisplayed={2} - /> - ) : ( - } - value={props.cpuIds} - onChange={props.onCpuChange} - options={cpuOptions(props.cpus)} - placeholder="All CPUs" - maxDisplayed={2} - /> - )} + } + value={props.cpuIds} + onChange={props.onCpuChange} + placeholder="All CPUs" + maxDisplayed={2} + /> {/* GPUs */} - {ENABLE_ASYNC ? ( - } - value={props.gpuIds} - onChange={props.onGpuChange} - placeholder="All GPUs" - maxDisplayed={2} - /> - ) : ( - } - value={props.gpuIds} - onChange={props.onGpuChange} - options={gpuOptions(props.gpus)} - placeholder="All GPUs" - maxDisplayed={2} - /> - )} + } + value={props.gpuIds} + onChange={props.onGpuChange} + placeholder="All GPUs" + maxDisplayed={2} + /> {/* Emulators */} void @@ -34,8 +30,6 @@ interface Props { minMemory: number | null maxMemory: number | null searchTerm: string - cpus: CpuWithBrand[] - gpus: GpuWithBrand[] systems: System[] emulators: Emulator[] performanceScales: PerformanceScale[] @@ -142,7 +136,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 }} @@ -170,15 +164,15 @@ export default function PcFiltersSidebar(props: Props) { const names = getSystemNames(props.systems, values) filterAnalytics.systems(values, names) }} - onCpuChange={(values) => { + onCpuChange={(values, selectedOptions: Option[]) => { props.onCpuChange(values) - const names = getCpuNames(props.cpus, values) - filterAnalytics.devices(values, names) + const names = selectedOptions.map((option) => option.name) + filterAnalytics.cpus(values, names) }} - onGpuChange={(values) => { + onGpuChange={(values, selectedOptions: Option[]) => { props.onGpuChange(values) - const names = getGpuNames(props.gpus, values) - filterAnalytics.devices(values, names) + const names = selectedOptions.map((option) => option.name) + filterAnalytics.gpus(values, names) }} onEmulatorChange={(values) => { props.onEmulatorChange(values) 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/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/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/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/navbar/Navbar.tsx b/src/components/navbar/Navbar.tsx index bef5a2779..e33c29ccf 100644 --- a/src/components/navbar/Navbar.tsx +++ b/src/components/navbar/Navbar.tsx @@ -8,20 +8,30 @@ 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 { env } from '@/lib/env' import { hasRolePermission } from '@/utils/permissions' import { Role } from '@orm' 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(() => { @@ -119,12 +129,10 @@ function Navbar() { {/* Right Section */}
- {user && } + {authReady && user && } - {!isLoaded ? ( -
- -
+ {!authReady ? ( + ) : ( <> {user ? ( @@ -141,18 +149,6 @@ function Navbar() { Admin )} - {hasRolePermission(userRole, Role.MODERATOR) && env.ENABLE_V2_LISTINGS && ( - - V2 - - )} Feed @@ -196,7 +192,7 @@ function Navbar() { {/* Mobile menu button */}
- {user && } + {authReady && user && }
)} - {props.alt} setIsLoading(false)} onError={handleError} 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/form/async-multi-select/AsyncMultiSelect.test.tsx b/src/components/ui/form/async-multi-select/AsyncMultiSelect.test.tsx index d33a2046b..6783523a4 100644 --- a/src/components/ui/form/async-multi-select/AsyncMultiSelect.test.tsx +++ b/src/components/ui/form/async-multi-select/AsyncMultiSelect.test.tsx @@ -181,10 +181,10 @@ describe('AsyncMultiSelect', () => { fireEvent.click(screen.getByRole('button', { name: 'Devices multi-select' })) fireEvent.click(screen.getByLabelText('Retroid Pocket 5')) - expect(onChange).toHaveBeenCalledWith([]) + expect(onChange).toHaveBeenCalledWith([], []) fireEvent.click(screen.getByLabelText('AYN Odin 2')) - expect(onChange).toHaveBeenCalledWith(['device-1', 'device-2']) + expect(onChange).toHaveBeenCalledWith(['device-1', 'device-2'], options) }) it('selects an option when the visible row text is clicked', () => { @@ -194,7 +194,7 @@ describe('AsyncMultiSelect', () => { fireEvent.click(screen.getByRole('button', { name: 'Devices multi-select' })) fireEvent.click(screen.getByText('AYN Odin 2')) - expect(onChange).toHaveBeenCalledWith(['device-2']) + expect(onChange).toHaveBeenCalledWith(['device-2'], [options[1]]) }) it('clears all selections from the dropdown footer', () => { @@ -204,7 +204,7 @@ describe('AsyncMultiSelect', () => { fireEvent.click(screen.getByRole('button', { name: 'Devices multi-select' })) fireEvent.click(screen.getByText('Clear all (2)')) - expect(onChange).toHaveBeenCalledWith([]) + expect(onChange).toHaveBeenCalledWith([], []) }) it('removes one selected chip without clearing the other selected values', () => { @@ -213,7 +213,7 @@ describe('AsyncMultiSelect', () => { fireEvent.click(screen.getByRole('button', { name: 'Remove Retroid Pocket 5' })) - expect(onChange).toHaveBeenCalledWith(['device-2']) + expect(onChange).toHaveBeenCalledWith(['device-2'], [options[1]]) }) it('shows empty and loading states', () => { diff --git a/src/components/ui/form/async-multi-select/AsyncMultiSelect.tsx b/src/components/ui/form/async-multi-select/AsyncMultiSelect.tsx index 04fe1773f..d3bf668b7 100644 --- a/src/components/ui/form/async-multi-select/AsyncMultiSelect.tsx +++ b/src/components/ui/form/async-multi-select/AsyncMultiSelect.tsx @@ -24,10 +24,11 @@ interface Props { label: string leftIcon?: ReactNode value: string[] - onChange: (values: string[]) => void + onChange: (values: string[], selectedOptions: Option[]) => void placeholder?: string className?: string maxDisplayed?: number + searchPlaceholder?: string // Data from wrappers options: Option[] @@ -124,19 +125,29 @@ export default function AsyncMultiSelect(props: Props) { loadMoreRequestedRef.current = false }, [props.hasMore, props.isFetching, props.options.length, query]) - // Selected options: merge page options + byIds; ordered by value - const selectedOptions = useMemo(() => { - const byIdMap = new Map(props.selectedByIds.map((o) => [o.id, o])) - const optionMap = new Map(props.options.map((o) => [o.id, o])) - const result: Option[] = [] - for (const id of props.value) { - const fromOptions = optionMap.get(id) - const fromByIds = byIdMap.get(id) - if (fromOptions) result.push(fromOptions) - else if (fromByIds) result.push(fromByIds) - } + const optionById = useMemo(() => { + const result = new Map() + for (const option of props.selectedByIds) result.set(option.id, option) + for (const option of props.options) result.set(option.id, option) return result - }, [props.options, props.selectedByIds, props.value]) + }, [props.options, props.selectedByIds]) + + const getSelectedOptions = useCallback( + (values: string[]) => { + const result: Option[] = [] + for (const id of values) { + const option = optionById.get(id) + if (option) result.push(option) + } + return result + }, + [optionById], + ) + + const selectedOptions = useMemo( + () => getSelectedOptions(props.value), + [getSelectedOptions, props.value], + ) const maxDisplayed = props.maxDisplayed ?? 2 const getDisplayText = () => { @@ -153,11 +164,14 @@ export default function AsyncMultiSelect(props: Props) { const newValue = props.value.includes(id) ? props.value.filter((v) => v !== id) : [...props.value, id] - props.onChange(newValue) + props.onChange(newValue, getSelectedOptions(newValue)) } - const handleClearAll = () => props.onChange([]) - const handleRemoveOption = (id: string) => props.onChange(props.value.filter((v) => v !== id)) + const handleClearAll = () => props.onChange([], []) + const handleRemoveOption = (id: string) => { + const newValue = props.value.filter((v) => v !== id) + props.onChange(newValue, getSelectedOptions(newValue)) + } const handleDropdownKeyDown = (ev: KeyboardEvent) => { if (ev.key !== 'Escape' || !isOpen) return @@ -240,7 +254,7 @@ export default function AsyncMultiSelect(props: Props) { setQuery(e.target.value)} className="w-full px-2 py-1 pr-8 text-sm border-0 focus:ring-0 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() && ( } + + } + > + + + + 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 56% rename from src/app/pc-listings/components/filters/AsyncCpuFilterSelect.tsx rename to src/features/hardware/cpu/client/components/AsyncCpuFilterSelect.tsx index 0b44440ba..13b0b6fdb 100644 --- a/src/app/pc-listings/components/filters/AsyncCpuFilterSelect.tsx +++ b/src/features/hardware/cpu/client/components/AsyncCpuFilterSelect.tsx @@ -1,63 +1,52 @@ 'use client' 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 AsyncMultiSelect, { + type Option, +} from '@/components/ui/form/async-multi-select/AsyncMultiSelect' +import { LOOKUP_PAGINATION } from '@/data/constants' import { api } from '@/lib/api' +import { toCpuSelectOption } from '../utils/cpuSelectOption' interface Props { label: string leftIcon?: ReactNode value: string[] - onChange: (values: string[]) => void + onChange: (values: string[], selectedOptions: Option[]) => void placeholder?: string className?: string 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 +55,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]) }, []) @@ -83,6 +75,7 @@ export default function AsyncCpuFilterSelect(props: Props) { hasMore={hasMore} onLoadMore={handleLoadMore} onQueryChange={handleQueryChange} + searchPlaceholder="Search CPUs..." /> ) } 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 56% rename from src/app/pc-listings/components/filters/AsyncGpuFilterSelect.tsx rename to src/features/hardware/gpu/client/components/AsyncGpuFilterSelect.tsx index 65bd67709..68d506745 100644 --- a/src/app/pc-listings/components/filters/AsyncGpuFilterSelect.tsx +++ b/src/features/hardware/gpu/client/components/AsyncGpuFilterSelect.tsx @@ -1,63 +1,52 @@ 'use client' 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 AsyncMultiSelect, { + type Option, +} from '@/components/ui/form/async-multi-select/AsyncMultiSelect' +import { LOOKUP_PAGINATION } from '@/data/constants' import { api } from '@/lib/api' +import { toGpuSelectOption } from '../utils/gpuSelectOption' interface Props { label: string leftIcon?: ReactNode value: string[] - onChange: (values: string[]) => void + onChange: (values: string[], selectedOptions: Option[]) => void placeholder?: string className?: string 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 +55,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]) }, []) @@ -83,6 +75,7 @@ export default function AsyncGpuFilterSelect(props: Props) { hasMore={hasMore} onLoadMore={handleLoadMore} onQueryChange={handleQueryChange} + searchPlaceholder="Search GPUs..." /> ) } 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..e956c4234 --- /dev/null +++ b/src/features/hardware/gpu/server/persistence/gpu.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 { + 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. +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/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 diff --git a/src/lib/analytics/actions.ts b/src/lib/analytics/actions.ts index c13337fa1..45858cdaa 100644 --- a/src/lib/analytics/actions.ts +++ b/src/lib/analytics/actions.ts @@ -30,12 +30,16 @@ export const FILTER_ACTIONS = { MY_LISTINGS: 'my_listings', SYSTEM: 'system', DEVICE: 'device', + CPU: 'cpu', + GPU: 'gpu', SOC: 'soc', EMULATOR: 'emulator', PERFORMANCE: 'performance', SEARCH: 'search', CLEAR_ALL: 'clear_all', CLEAR_DEVICE_FILTER: 'clear_device_filter', + CLEAR_CPU_FILTER: 'clear_cpu_filter', + CLEAR_GPU_FILTER: 'clear_gpu_filter', CLEAR_SYSTEM_FILTER: 'clear_system_filter', CLEAR_EMULATOR_FILTER: 'clear_emulator_filter', CLEAR_SOC_FILTER: 'clear_soc_filter', diff --git a/src/lib/analytics/analytics.ts b/src/lib/analytics/analytics.ts index 7d9205001..27b24273a 100644 --- a/src/lib/analytics/analytics.ts +++ b/src/lib/analytics/analytics.ts @@ -90,6 +90,30 @@ const analytics = { }) }, + cpu: (cpuIds: string[], cpuNames?: string[]) => { + sendAnalyticsEvent({ + category: ANALYTICS_CATEGORIES.FILTER, + action: FILTER_ACTIONS.CPU, + value: cpuIds.length.toString(), + metadata: { + count: cpuIds.length, + cpus: cpuNames?.join(',') || cpuIds.join(','), + }, + }) + }, + + gpu: (gpuIds: string[], gpuNames?: string[]) => { + sendAnalyticsEvent({ + category: ANALYTICS_CATEGORIES.FILTER, + action: FILTER_ACTIONS.GPU, + value: gpuIds.length.toString(), + metadata: { + count: gpuIds.length, + gpus: gpuNames?.join(',') || gpuIds.join(','), + }, + }) + }, + soc: (socIds: string[], socNames?: string[]) => { sendAnalyticsEvent({ category: ANALYTICS_CATEGORIES.FILTER, @@ -224,6 +248,20 @@ const analytics = { }) }, + clearCpuFilter: () => { + sendAnalyticsEvent({ + category: ANALYTICS_CATEGORIES.FILTER, + action: FILTER_ACTIONS.CLEAR_CPU_FILTER, + }) + }, + + clearGpuFilter: () => { + sendAnalyticsEvent({ + category: ANALYTICS_CATEGORIES.FILTER, + action: FILTER_ACTIONS.CLEAR_GPU_FILTER, + }) + }, + clearSocFilter: () => { sendAnalyticsEvent({ category: ANALYTICS_CATEGORIES.FILTER, @@ -1044,7 +1082,7 @@ const analytics = { contentQuality: { // TODO contentFlagged: (params: { - entityType: 'listing' | 'comment' | 'game' + entityType: 'pc-listing' | 'listing' | 'comment' | 'game' entityId: string flaggedBy: string reason: string diff --git a/src/lib/analytics/filterAnalytics.ts b/src/lib/analytics/filterAnalytics.ts index 065d69b7d..f39b4cb6f 100644 --- a/src/lib/analytics/filterAnalytics.ts +++ b/src/lib/analytics/filterAnalytics.ts @@ -9,6 +9,14 @@ export const filterAnalytics = { if (values.length === 0) return analytics.filter.clearDeviceFilter() analytics.filter.device(values, names) }, + cpus(values: string[], names: string[]) { + if (values.length === 0) return analytics.filter.clearCpuFilter() + analytics.filter.cpu(values, names) + }, + gpus(values: string[], names: string[]) { + if (values.length === 0) return analytics.filter.clearGpuFilter() + analytics.filter.gpu(values, names) + }, socs(values: string[], names: string[]) { if (values.length === 0) return analytics.filter.clearSocFilter() analytics.filter.soc(values, names) 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/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() ?? '', diff --git a/src/lib/errors.ts b/src/lib/errors.ts index 83abf9040..d10e82874 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 = { @@ -481,15 +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('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 report has already been resolved or dismissed and cannot be reopened.'), } static userBan = { @@ -523,14 +532,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..5df2e13ea 100644 --- a/src/schemas/common.ts +++ b/src/schemas/common.ts @@ -1,7 +1,16 @@ 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), +}) +export type MutationSuccess = z.output + +export function createMutationSuccess(): MutationSuccess { + return { success: true } +} // Admin table URL parameters export const AdminTableParamsSchema = z.object({ @@ -31,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/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/game.ts b/src/schemas/game.ts index 695525aa9..ee2e734b8 100644 --- a/src/schemas/game.ts +++ b/src/schemas/game.ts @@ -69,13 +69,20 @@ export const CheckExistingByNamesAndSystemsSchema = z.object({ ), }) +const OptionalGameImageUrlSchema = z + .string() + .trim() + .nullable() + .optional() + .transform((value) => (value === '' ? null : value)) + export const CreateGameSchema = z.object({ title: z.string().min(1), systemId: z.string().uuid(), humanVerificationToken: HumanVerificationTokenSchema.optional(), - imageUrl: z.string().nullable().optional(), - boxartUrl: z.string().nullable().optional(), - bannerUrl: z.string().nullable().optional(), + imageUrl: OptionalGameImageUrlSchema, + boxartUrl: OptionalGameImageUrlSchema, + bannerUrl: OptionalGameImageUrlSchema, tgdbGameId: z.number().nullable().optional(), // TODO: store in metadata igdbGameId: z.number().nullable().optional(), // TODO: For IGDB game creation (stored in metadata for now) isErotic: z.boolean().optional(), @@ -85,21 +92,9 @@ export const UpdateGameSchema = z.object({ id: z.string().uuid(), title: z.string().min(1), systemId: z.string().uuid(), - imageUrl: z - .string() - .nullable() - .optional() - .or(z.literal('').transform(() => null)), - boxartUrl: z - .string() - .nullable() - .optional() - .or(z.literal('').transform(() => null)), - bannerUrl: z - .string() - .nullable() - .optional() - .or(z.literal('').transform(() => null)), + imageUrl: OptionalGameImageUrlSchema, + boxartUrl: OptionalGameImageUrlSchema, + bannerUrl: OptionalGameImageUrlSchema, tgdbGameId: z.number().optional(), isErotic: z.boolean().optional(), }) 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/listingReport.ts b/src/schemas/listingReport.ts index 3ac18d7bd..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 @@ -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() @@ -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/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/pcListing.ts b/src/schemas/pcListing.ts index 85bbcd29c..0ab7ed732 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' @@ -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), @@ -274,6 +265,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), @@ -281,16 +274,22 @@ 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(), }) -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.coerce.number().int().min(1).default(1), + limit: z.coerce.number().int().min(1).max(100).default(20), + }) + .optional() // PC Listing Verification schemas export const VerifyPcListingSchema = z.object({ @@ -307,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(), 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 a9949f3e7..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,13 +19,13 @@ 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' 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,10 +48,11 @@ export const appRouter = createTRPCRouter({ activity: activityRouter, listings: listingsRouter, pcListings: pcListingsRouter, + 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/games.test.ts b/src/server/api/routers/games.test.ts index 85a4d2672..044720d29 100644 --- a/src/server/api/routers/games.test.ts +++ b/src/server/api/routers/games.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { ApprovalStatus, Role } from '@orm/client' +import { ERROR_MESSAGES } from '@/lib/errors' +import { ApprovalStatus, Role } from '@orm' vi.unmock('@/server/api/trpc') vi.unmock('@/server/api/root') @@ -130,5 +131,37 @@ describe('games router', () => { ) expect(mockGameStatsCacheDelete).toHaveBeenCalled() }) + + it('rejects arbitrary image URLs for regular users', async () => { + const { caller, prisma } = createCaller() + + await expect( + caller.create({ + title: 'Game With Arbitrary Art', + systemId: SYSTEM_ID, + imageUrl: 'https://example.com/game.jpg', + }), + ).rejects.toThrow(ERROR_MESSAGES.FORBIDDEN) + + expect(prisma.game.create).not.toHaveBeenCalled() + }) + + it('allows provider image URLs for regular users', async () => { + const { caller, prisma } = createCaller() + + await caller.create({ + title: 'Game With Provider Art', + systemId: SYSTEM_ID, + imageUrl: 'https://images.igdb.com/igdb/image/upload/t_cover_big/game.jpg', + }) + + expect(prisma.game.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + imageUrl: 'https://images.igdb.com/igdb/image/upload/t_cover_big/game.jpg', + }), + }), + ) + }) }) }) diff --git a/src/server/api/routers/games.ts b/src/server/api/routers/games.ts index 41617942a..364d06988 100644 --- a/src/server/api/routers/games.ts +++ b/src/server/api/routers/games.ts @@ -40,6 +40,7 @@ import { revalidateByTag, } from '@/server/cache/invalidation' import { notificationEventEmitter, NOTIFICATION_EVENTS } from '@/server/notifications/eventEmitter' +import { assertGameImageUrlsAllowed } from '@/server/policies/game-image-url.policy' import { GamesRepository } from '@/server/repositories/games.repository' import { gameStatsCache } from '@/server/utils/cache' import { buildOrderBy, paginate } from '@/server/utils/pagination' @@ -304,6 +305,8 @@ export const gamesRouter = createTRPCRouter({ isErotic: input.isErotic, } + assertGameImageUrlsAllowed(gameInput, ctx.session.user) + const system = await ctx.prisma.system.findUnique({ where: { id: gameInput.systemId }, }) @@ -407,6 +410,8 @@ export const gamesRouter = createTRPCRouter({ if (!existingGame) return ResourceError.game.notFound() + assertGameImageUrlsAllowed(data, ctx.session.user) + await validateGameConflicts(ctx.prisma, id, data, existingGame!) const result = await performGameUpdate(ctx.prisma, id, data, existingGame!) @@ -448,6 +453,8 @@ export const gamesRouter = createTRPCRouter({ return ResourceError.game.canOnlyEditPending() } + assertGameImageUrlsAllowed(data, ctx.session.user) + await validateGameConflicts(ctx.prisma, id, data, existingGame!) const result = await performGameUpdate(ctx.prisma, id, data, existingGame!) 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/listingReports.test.ts b/src/server/api/routers/listingReports.test.ts index 4bf5115bf..ad60cdadc 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('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/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/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/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/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) 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/api/routers/pcListingReports.test.ts b/src/server/api/routers/pcListingReports.test.ts new file mode 100644 index 000000000..07d0b39f1 --- /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({ + id: 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({ + id: 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({ + id: REPORT_ID, + status: ReportStatus.DISMISSED, + reviewNotes: 'Changing decision', + }), + ).rejects.toThrow('PC 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 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 new file mode 100644 index 000000000..12de3bf28 --- /dev/null +++ b/src/server/api/routers/pcListingReports.ts @@ -0,0 +1,149 @@ +import { ResourceError } from '@/lib/errors' +import { DeleteReportSchema, GetReportByIdSchema } from '@/schemas/listingReport' +import { + CreatePcListingReportSchema, + GetPcListingReportsSchema, + 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 { PERMISSIONS } from '@/utils/permission-system' +import { ReportStatus } 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 = input?.page ?? 1 + const limit = input?.limit ?? 20 + const normalizedSearch = search?.trim() || undefined + const offset = (page - 1) * limit + + const where: Prisma.PcListingReportWhereInput = {} + + if (normalizedSearch) { + where.OR = [ + { pcListing: { game: { title: { contains: normalizedSearch, mode: 'insensitive' } } } }, + { reportedBy: { name: { contains: normalizedSearch, mode: 'insensitive' } } }, + { description: { contains: normalizedSearch, 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.pcListingReport.notFound() + }), + + create: 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, + }) + }), + + updateStatus: permissionProcedure(PERMISSIONS.MANAGE_USER_BANS) + .input(UpdatePcListingReportSchema) + .mutation(async ({ ctx, input }) => { + return new ReportModerationService(ctx.prisma).updatePcListingReportStatus({ + reportId: 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 }) => { + return new ReportModerationService(ctx.prisma).deletePcListingReport(input.id) + }), +}) diff --git a/src/server/api/routers/pcListings.test.ts b/src/server/api/routers/pcListings.test.ts index 3144e2e6b..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', () => { @@ -581,48 +607,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({ @@ -1083,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({ @@ -1114,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', () => { @@ -1123,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 = { @@ -1130,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', } @@ -1139,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, @@ -1152,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 }) @@ -1168,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, @@ -1186,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.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..f1087f7b5 --- /dev/null +++ b/src/server/api/routers/pcListings/admin.ts @@ -0,0 +1,725 @@ +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 { PcListingBulkModerationService } from '@/server/services/pc-listing-bulk-moderation.service' +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 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 = transactionResult.pcListings.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, + reason: 'bulk_listing_approved', + }, + }), + ), + ) + + invalidatePcListingStatsCache() + + await invalidatePcListingsSeo(transactionResult.pcListings) + + for (const listing of transactionResult.pcListings) { + notificationEventEmitter.emitNotificationEvent({ + eventType: NOTIFICATION_EVENTS.PC_LISTING_APPROVED, + entityType: 'pcListing', + entityId: listing.id, + triggeredBy: adminUserId, + payload: { + pcListingId: listing.id, + gameId: listing.gameId, + approvedBy: adminUserId, + approvedAt: transactionResult.processedAt, + bulk: true, + }, + }) + } + + return { count: transactionResult.count } + }), + + bulkReject: protectedProcedure + .input(BulkRejectPcListingsSchema) + .mutation(async ({ ctx, input }) => { + 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 = transactionResult.pcListings.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, + reason: input.notes || 'bulk_listing_rejected', + }, + }), + ), + ) + + invalidatePcListingStatsCache() + + for (const listing of transactionResult.pcListings) { + notificationEventEmitter.emitNotificationEvent({ + eventType: NOTIFICATION_EVENTS.PC_LISTING_REJECTED, + entityType: 'pcListing', + entityId: listing.id, + triggeredBy: adminUserId, + payload: { + pcListingId: listing.id, + rejectedBy: adminUserId, + rejectedAt: transactionResult.processedAt, + rejectionReason: input.notes, + }, + }) + } + + return { count: transactionResult.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 }, + select: { + id: true, + gameId: true, + cpuId: true, + gpuId: true, + status: true, + }, + }) + + if (!pcListing) return ResourceError.pcListing.notFound() + + const customFieldCreateData = customFieldValues?.map((cfv) => ({ + pcListingId: id, + customFieldDefinitionId: cfv.customFieldDefinitionId, + value: toPrismaCustomFieldValue(cfv.value), + })) + + const updatedPcListing = await ctx.prisma.$transaction(async (tx) => { + await tx.pcListing.update({ + where: { id }, + data: { ...data, updatedAt: new Date() }, + }) + + 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, + 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..dd29cffca --- /dev/null +++ b/src/server/api/routers/pcListings/comments.ts @@ -0,0 +1,503 @@ +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 }, + select: { pcListingId: true }, + }) + + if (!parentComment || parentComment.pcListingId !== pcListingId) { + 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.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 new file mode 100644 index 000000000..e467eb202 --- /dev/null +++ b/src/server/api/routers/pcListings/utils.ts @@ -0,0 +1,47 @@ +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 { + 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 { + 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/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 } } }) }) 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/policies/game-image-url.policy.test.ts b/src/server/policies/game-image-url.policy.test.ts new file mode 100644 index 000000000..addb0a869 --- /dev/null +++ b/src/server/policies/game-image-url.policy.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest' +import { ERROR_MESSAGES } from '@/lib/errors' +import { Role } from '@orm' +import { assertGameImageUrlsAllowed, canUseArbitraryGameImageUrls } from './game-image-url.policy' + +describe('game-image-url policy', () => { + it('allows provider image URLs for regular users', () => { + expect(() => + assertGameImageUrlsAllowed( + { imageUrl: 'https://images.igdb.com/igdb/image/upload/t_cover_big/game.jpg' }, + { role: Role.USER, permissions: [] }, + ), + ).not.toThrow() + }) + + it('rejects arbitrary image URLs for regular users', () => { + expect(() => + assertGameImageUrlsAllowed( + { imageUrl: 'https://example.com/game.jpg' }, + { role: Role.USER, permissions: [] }, + ), + ).toThrow(ERROR_MESSAGES.FORBIDDEN) + }) + + it('allows arbitrary image URLs for moderators', () => { + expect(canUseArbitraryGameImageUrls({ role: Role.MODERATOR, permissions: [] })).toBe(true) + expect(() => + assertGameImageUrlsAllowed( + { bannerUrl: 'https://example.com/banner.jpg' }, + { role: Role.MODERATOR, permissions: [] }, + ), + ).not.toThrow() + }) + + it('allows arbitrary image URLs for users with game edit permissions', () => { + expect(() => + assertGameImageUrlsAllowed( + { boxartUrl: 'https://example.com/boxart.jpg' }, + { role: Role.USER, permissions: ['edit_games'] }, + ), + ).not.toThrow() + }) + + it('rejects unsafe image URL forms for every actor', () => { + expect(() => + assertGameImageUrlsAllowed( + { imageUrl: 'http://example.com/game.jpg' }, + { role: Role.MODERATOR, permissions: [] }, + ), + ).toThrow('Image URL must use HTTPS') + + expect(() => + assertGameImageUrlsAllowed( + { imageUrl: 'https://127.0.0.1/game.jpg' }, + { role: Role.MODERATOR, permissions: [] }, + ), + ).toThrow('localhost or a private network address') + + expect(() => + assertGameImageUrlsAllowed( + { imageUrl: 'https://example.com/game.svg' }, + { role: Role.MODERATOR, permissions: [] }, + ), + ).toThrow('SVG game images are not allowed') + }) +}) diff --git a/src/server/policies/game-image-url.policy.ts b/src/server/policies/game-image-url.policy.ts new file mode 100644 index 000000000..8dfb1eb29 --- /dev/null +++ b/src/server/policies/game-image-url.policy.ts @@ -0,0 +1,46 @@ +import { AppError } from '@/lib/errors' +import { getGameImageUrlValidationError, isKnownGameImageProviderUrl } from '@/utils/imageUrls' +import { hasPermission, PERMISSIONS, roleIncludesRole } from '@/utils/permission-system' +import { Role } from '@orm' + +type GameImageField = 'imageUrl' | 'boxartUrl' | 'bannerUrl' + +type GameImageInput = Partial> + +type Actor = { + role: Role + permissions?: string[] | null +} + +const GAME_IMAGE_FIELD_LABELS: Record = { + imageUrl: 'cover image URL', + boxartUrl: 'box art URL', + bannerUrl: 'banner image URL', +} + +export function canUseArbitraryGameImageUrls(actor: Actor): boolean { + return ( + roleIncludesRole(actor.role, Role.MODERATOR) || + hasPermission(actor.permissions, PERMISSIONS.EDIT_GAMES) || + hasPermission(actor.permissions, PERMISSIONS.MANAGE_GAMES) + ) +} + +export function assertGameImageUrlsAllowed(input: GameImageInput, actor: Actor): void { + const canUseArbitraryUrls = canUseArbitraryGameImageUrls(actor) + + for (const field of Object.keys(GAME_IMAGE_FIELD_LABELS) as GameImageField[]) { + const value = input[field] + if (!value) continue + + const validationError = getGameImageUrlValidationError(value) + if (validationError) { + AppError.badRequest(`Invalid ${GAME_IMAGE_FIELD_LABELS[field]}: ${validationError}`) + } + + if (isKnownGameImageProviderUrl(value)) continue + if (canUseArbitraryUrls) continue + + AppError.forbidden() + } +} 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..649c16d13 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 } @@ -132,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/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/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/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/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/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/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/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) + } + } +} 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/server/utils/security-validation.ts b/src/server/utils/security-validation.ts index 61b5ea834..570128577 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: Replace this module with schema-level validation and purpose-built sanitization; see #442. /** * Security validation utilities for critical runtime parameters @@ -74,6 +75,7 @@ export function validateEnum( /** * Validates pagination parameters * Prevents excessive data retrieval + * TODO: Move pagination constraints into Zod input schemas and delete this helper; see #442. */ 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: Replace denylist sanitization with field-specific escaping or a sanitizer library; see #442. */ 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 diff --git a/src/utils/getImageUrl.test.ts b/src/utils/getImageUrl.test.ts index b200939b3..47bbc4daa 100644 --- a/src/utils/getImageUrl.test.ts +++ b/src/utils/getImageUrl.test.ts @@ -34,18 +34,32 @@ describe('getImageUrl', () => { expect(result).toBe(localPath) }) - it('returns a proxied url when the url starts with http', () => { + it('returns a placeholder when an http url cannot be rendered safely', () => { const httpUrl = 'http://example.com/image.jpg' - const result = getImageUrl(httpUrl) + const result = getImageUrl(httpUrl, 'HTTP Game') - expect(result).toBe(`/api/proxy-image?url=${encodeURIComponent(httpUrl)}`) + expect(result).toBe('/placeholder-image-for-HTTP Game') }) - it('returns a proxied url when the url starts with https', () => { + it('returns an unknown https remote url directly for native browser rendering', () => { const httpsUrl = 'https://example.com/image.jpg' const result = getImageUrl(httpsUrl) - expect(result).toBe(`/api/proxy-image?url=${encodeURIComponent(httpsUrl)}`) + expect(result).toBe(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('returns a placeholder image when the url format is invalid', () => { @@ -58,7 +72,7 @@ describe('getImageUrl', () => { it('handles protocol-relative URLs correctly', () => { const protocolRelativeUrl = '//example.com/image.jpg' - const result = getImageUrl(protocolRelativeUrl, null, { useProxy: true }) + const result = getImageUrl(protocolRelativeUrl) expect(getSafePlaceholderImageUrl).toHaveBeenCalled() expect(result).toBe('/placeholder-image-for-unknown') diff --git a/src/utils/getImageUrl.ts b/src/utils/getImageUrl.ts index 1640f2af3..3395099e8 100644 --- a/src/utils/getImageUrl.ts +++ b/src/utils/getImageUrl.ts @@ -1,29 +1,20 @@ import { type Nullable } from '@/types/utils' import getSafePlaceholderImageUrl from './getSafePlaceholderImageUrl' +import { getImageRenderMode } from './imageUrls' -type Options = { - useProxy?: boolean -} /** - * Get a safe image URL for display, using a proxy if necessary. + * Get a safe image URL for display. * @param url - The original image URL. * @param title - Optional title for placeholder fallback. - * @param opts - Options to control proxy usage. * @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 +function getImageUrl(url: Nullable, title?: string | null): string { if (!url) return getSafePlaceholderImageUrl(title) - if (url.startsWith('/') && !url.startsWith('//')) { - return url // Local image, use directly - } - - if (url.startsWith('http://') || url.startsWith('https://')) { - return useProxy ? `/api/proxy-image?url=${encodeURIComponent(url)}` : url - } + const trimmedUrl = url.trim() + if (getImageRenderMode(trimmedUrl) !== 'invalid') return trimmedUrl - return getSafePlaceholderImageUrl(title ?? null) // Invalid URL format, use placeholder + return getSafePlaceholderImageUrl(title ?? null) } export default getImageUrl 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/imageUrls.test.ts b/src/utils/imageUrls.test.ts new file mode 100644 index 000000000..b6417fe59 --- /dev/null +++ b/src/utils/imageUrls.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from 'vitest' +import { + getGameImageUrlValidationError, + getImageRenderMode, + isKnownGameImageProviderUrl, +} from './imageUrls' + +describe('imageUrls', () => { + it('uses Next Image for local paths and configured remote hosts', () => { + expect(getImageRenderMode('/uploads/games/image.jpg')).toBe('next-image') + expect(getImageRenderMode('https://media.rawg.io/media/games/example.jpg')).toBe('next-image') + }) + + it('uses native browser image rendering for arbitrary HTTPS hosts', () => { + expect(getImageRenderMode('https://example.com/image.jpg')).toBe('external-img') + }) + + it('rejects unsupported URL forms for app image rendering', () => { + expect(getImageRenderMode('http://example.com/image.jpg')).toBe('invalid') + expect(getImageRenderMode('//example.com/image.jpg')).toBe('invalid') + expect(getImageRenderMode('not-a-url')).toBe('invalid') + }) + + it('classifies Next image hosts separately from game image providers', () => { + expect(getImageRenderMode('https://img.clerk.com/avatar.png')).toBe('next-image') + expect(isKnownGameImageProviderUrl('https://img.clerk.com/avatar.png')).toBe(false) + expect(isKnownGameImageProviderUrl('https://images.igdb.com/igdb/image/upload/game.jpg')).toBe( + true, + ) + }) + + it('allows verified store CDN hosts as known game image providers', () => { + expect( + isKnownGameImageProviderUrl( + 'https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/620/header.jpg?t=1745363004', + ), + ).toBe(true) + expect( + isKnownGameImageProviderUrl( + 'https://cdn1.epicgames.com/offer/fn/EN_FNECO_36-00_Blade_2560x1440_2560x1440-f6621f69507135ac955fe3b0a3945aa1', + ), + ).toBe(true) + expect( + isKnownGameImageProviderUrl( + 'https://cdn2.unrealengine.com/egs-rocketleague-psyonixllc-g1a-03-1920x1080-2ed8a1689f61.jpg', + ), + ).toBe(true) + expect( + isKnownGameImageProviderUrl( + 'https://images.gog-statics.com/c75e674590b8947542c809924df30bbef2190341163dd08668e243c266be70c5_product_card_v2_mobile_slider_639.jpg', + ), + ).toBe(true) + }) + + it('blocks localhost and private address literals', () => { + expect(getImageRenderMode('https://localhost/image.jpg')).toBe('invalid') + expect(getImageRenderMode('https://localhost./image.jpg')).toBe('invalid') + expect(getImageRenderMode('https://app.localhost/image.jpg')).toBe('invalid') + expect(getImageRenderMode('https://app.localhost./image.jpg')).toBe('invalid') + expect(getImageRenderMode('https://127.0.0.1/image.jpg')).toBe('invalid') + expect(getImageRenderMode('https://0177.0.0.1/image.jpg')).toBe('invalid') + expect(getImageRenderMode('https://2130706433/image.jpg')).toBe('invalid') + expect(getImageRenderMode('https://127.1/image.jpg')).toBe('invalid') + expect(getImageRenderMode('https://10.0.0.5/image.jpg')).toBe('invalid') + expect(getImageRenderMode('https://10.1/image.jpg')).toBe('invalid') + expect(getImageRenderMode('https://172.16.0.5/image.jpg')).toBe('invalid') + expect(getImageRenderMode('https://192.168.1.20/image.jpg')).toBe('invalid') + expect(getImageRenderMode('https://[::1]/image.jpg')).toBe('invalid') + expect(getImageRenderMode('https://[::ffff:127.0.0.1]/image.jpg')).toBe('invalid') + expect(getImageRenderMode('https://[::ffff:7f00:1]/image.jpg')).toBe('invalid') + expect(getImageRenderMode('https://[2002:0a00:1::]/image.jpg')).toBe('invalid') + expect(getImageRenderMode('https://[fc00::1]/image.jpg')).toBe('invalid') + expect(getImageRenderMode('https://[fd00::1]/image.jpg')).toBe('invalid') + expect(getImageRenderMode('https://[fe80::1]/image.jpg')).toBe('invalid') + expect(getImageRenderMode('https://example.com/image.jpg')).toBe('external-img') + expect(getImageRenderMode('https://[::ffff:8.8.8.8]/image.jpg')).toBe('external-img') + }) + + it('returns user-facing validation errors for unsafe game image URLs', () => { + expect(getGameImageUrlValidationError('')).toBeNull() + expect(getGameImageUrlValidationError('https://example.com/image.jpg')).toBeNull() + expect(getGameImageUrlValidationError('http://example.com/image.jpg')).toBe( + 'Image URL must use HTTPS.', + ) + expect(getGameImageUrlValidationError('https://127.0.0.1/image.jpg')).toBe( + 'Image URL cannot point to localhost or a private network address.', + ) + expect(getGameImageUrlValidationError('https://localhost./image.jpg')).toBe( + 'Image URL cannot point to localhost or a private network address.', + ) + expect(getGameImageUrlValidationError('https://[::ffff:127.0.0.1]/image.jpg')).toBe( + 'Image URL cannot point to localhost or a private network address.', + ) + expect(getGameImageUrlValidationError('https://example.com/image.svg')).toBe( + 'SVG game images are not allowed.', + ) + }) +}) diff --git a/src/utils/imageUrls.ts b/src/utils/imageUrls.ts new file mode 100644 index 000000000..100f712ff --- /dev/null +++ b/src/utils/imageUrls.ts @@ -0,0 +1,266 @@ +import { + GAME_IMAGE_PROVIDER_HOST_PATTERNS, + NEXT_IMAGE_REMOTE_HOST_PATTERNS, +} from '@config/image-hosts' + +export type ImageRenderMode = 'next-image' | 'external-img' | 'invalid' + +const BLOCKED_EXACT_HOSTS = new Set(['localhost', '0.0.0.0']) + +type IPv4Octets = readonly [number, number, number, number] +type IPv6Groups = readonly [number, number, number, number, number, number, number, number] + +function matchesHostPattern(hostname: string, pattern: string): boolean { + if (!pattern.startsWith('*.')) return hostname === pattern + + const parentHost = pattern.slice(2) + return hostname.endsWith(`.${parentHost}`) +} + +function normalizeHostname(hostname: string): string { + return hostname.toLowerCase().replace(/^\[/, '').replace(/\]$/, '').replace(/\.+$/, '') +} + +function parseIPv4Octet(value: string): number | null { + if (!/^\d+$/.test(value)) return null + + const octet = Number(value) + return Number.isInteger(octet) && octet >= 0 && octet <= 255 ? octet : null +} + +function parseIPv4Address(hostname: string): IPv4Octets | null { + const parts = hostname.split('.') + if (parts.length !== 4) return null + + const first = parseIPv4Octet(parts[0] ?? '') + const second = parseIPv4Octet(parts[1] ?? '') + const third = parseIPv4Octet(parts[2] ?? '') + const fourth = parseIPv4Octet(parts[3] ?? '') + if (first === null || second === null || third === null || fourth === null) return null + + return [first, second, third, fourth] +} + +function isBlockedIPv4Address(hostname: string): boolean { + const octets = parseIPv4Address(hostname) + if (!octets) return false + + return isBlockedIPv4Octets(octets) +} + +function isBlockedIPv4Octets(octets: IPv4Octets): boolean { + const [first, second, third] = octets + + return ( + first === 10 || + first === 127 || + first === 0 || + (first === 100 && second >= 64 && second <= 127) || + (first === 169 && second === 254) || + (first === 172 && second >= 16 && second <= 31) || + (first === 192 && second === 0 && third === 0) || + (first === 192 && second === 0 && third === 2) || + (first === 192 && second === 88 && third === 99) || + (first === 192 && second === 168) || + (first === 198 && (second === 18 || second === 19)) || + (first === 198 && second === 51 && third === 100) || + (first === 203 && second === 0 && third === 113) || + first >= 224 + ) +} + +function toIPv6Groups(groups: number[]): IPv6Groups | null { + if (groups.length !== 8) return null + + const [first, second, third, fourth, fifth, sixth, seventh, eighth] = groups + if ( + first === undefined || + second === undefined || + third === undefined || + fourth === undefined || + fifth === undefined || + sixth === undefined || + seventh === undefined || + eighth === undefined + ) { + return null + } + + return [first, second, third, fourth, fifth, sixth, seventh, eighth] +} + +function parseIPv6Group(group: string): number | null { + if (!/^[0-9a-f]{1,4}$/i.test(group)) return null + return Number.parseInt(group, 16) +} + +function parseIPv6Groups(value: string): number[] | null { + if (!value) return [] + + const parts = value.split(':') + const groups: number[] = [] + + for (const part of parts) { + if (part.includes('.')) { + const octets = parseIPv4Address(part) + if (!octets) return null + + const [first, second, third, fourth] = octets + groups.push((first << 8) | second, (third << 8) | fourth) + continue + } + + const group = parseIPv6Group(part) + if (group === null) return null + groups.push(group) + } + + return groups +} + +function parseIPv6Address(hostname: string): IPv6Groups | null { + if (!hostname.includes(':')) return null + + const doubleColonParts = hostname.split('::') + if (doubleColonParts.length > 2) return null + + if (doubleColonParts.length === 1) { + const groups = parseIPv6Groups(hostname) + return groups ? toIPv6Groups(groups) : null + } + + const [head = '', tail = ''] = doubleColonParts + const headGroups = parseIPv6Groups(head) + const tailGroups = parseIPv6Groups(tail) + if (!headGroups || !tailGroups) return null + + const missingGroupCount = 8 - headGroups.length - tailGroups.length + if (missingGroupCount < 1) return null + + return toIPv6Groups([...headGroups, ...Array(missingGroupCount).fill(0), ...tailGroups]) +} + +function getIPv4FromIPv6Groups(groups: IPv6Groups): IPv4Octets | null { + const isIPv4Mapped = + groups[0] === 0 && + groups[1] === 0 && + groups[2] === 0 && + groups[3] === 0 && + groups[4] === 0 && + groups[5] === 0xffff + const isIPv4Compatible = + groups[0] === 0 && + groups[1] === 0 && + groups[2] === 0 && + groups[3] === 0 && + groups[4] === 0 && + groups[5] === 0 + + if (!isIPv4Mapped && !isIPv4Compatible) return null + + return [groups[6] >> 8, groups[6] & 0xff, groups[7] >> 8, groups[7] & 0xff] +} + +function isBlockedIPv6(hostname: string): boolean { + const groups = parseIPv6Address(hostname) + if (!groups) return false + + const embeddedIPv4 = getIPv4FromIPv6Groups(groups) + if (embeddedIPv4 && isBlockedIPv4Octets(embeddedIPv4)) return true + + const sixToFourIPv4: IPv4Octets | null = + groups[0] === 0x2002 + ? [groups[1] >> 8, groups[1] & 0xff, groups[2] >> 8, groups[2] & 0xff] + : null + if (sixToFourIPv4 && isBlockedIPv4Octets(sixToFourIPv4)) return true + + const isUnspecified = groups.every((group) => group === 0) + const isLoopback = groups.slice(0, 7).every((group) => group === 0) && groups[7] === 1 + + return ( + isUnspecified || + isLoopback || + (groups[0] & 0xfe00) === 0xfc00 || + (groups[0] & 0xffc0) === 0xfe80 || + (groups[0] & 0xffc0) === 0xfec0 || + (groups[0] & 0xff00) === 0xff00 || + (groups[0] === 0x2001 && groups[1] === 0x0db8) + ) +} + +function isIPAddressLiteral(hostname: string): boolean { + return parseIPv4Address(hostname) !== null || parseIPv6Address(hostname) !== null +} + +function isLocalImagePath(src: string): boolean { + return src.startsWith('/') && !src.startsWith('//') +} + +function isBlockedImageHostname(hostname: string): boolean { + const normalizedHostname = normalizeHostname(hostname) + + return ( + BLOCKED_EXACT_HOSTS.has(normalizedHostname) || + normalizedHostname.endsWith('.localhost') || + (isIPAddressLiteral(normalizedHostname) && + (isBlockedIPv4Address(normalizedHostname) || isBlockedIPv6(normalizedHostname))) + ) +} + +function parseHttpsImageUrl(src: string): URL | null { + try { + const url = new URL(src) + if (url.protocol !== 'https:') return null + if (isBlockedImageHostname(url.hostname)) return null + return url + } catch { + return null + } +} + +function isKnownNextImageRemoteUrl(src: string): boolean { + const url = parseHttpsImageUrl(src) + if (!url) return false + + return NEXT_IMAGE_REMOTE_HOST_PATTERNS.some((pattern) => + matchesHostPattern(normalizeHostname(url.hostname), pattern), + ) +} + +export function isKnownGameImageProviderUrl(src: string): boolean { + const url = parseHttpsImageUrl(src) + if (!url) return false + + return GAME_IMAGE_PROVIDER_HOST_PATTERNS.some((pattern) => + matchesHostPattern(normalizeHostname(url.hostname), pattern), + ) +} + +export function getImageRenderMode(src: string): ImageRenderMode { + if (isLocalImagePath(src) || isKnownNextImageRemoteUrl(src)) return 'next-image' + if (parseHttpsImageUrl(src)) return 'external-img' + return 'invalid' +} + +export function getGameImageUrlValidationError(src: string): string | null { + const trimmedSrc = src.trim() + if (!trimmedSrc) return null + + let url: URL + try { + url = new URL(trimmedSrc) + } catch { + return 'Enter a valid image URL.' + } + + if (url.protocol !== 'https:') return 'Image URL must use HTTPS.' + if (isBlockedImageHostname(url.hostname)) { + return 'Image URL cannot point to localhost or a private network address.' + } + + if (url.pathname.toLowerCase().endsWith('.svg')) { + return 'SVG game images are not allowed.' + } + + return null +} diff --git a/src/utils/options.ts b/src/utils/options.ts index 4f3db0518..7e6a9da74 100644 --- a/src/utils/options.ts +++ b/src/utils/options.ts @@ -11,36 +11,3 @@ export function emulatorOptions(emulators: { id: string; name: string }[]): Opti export function performanceOptions(performance: { id: number; label: string }[]): Option[] { return performance.map(({ id, label }) => ({ id: id.toString(), name: label })) } - -export function deviceOptions( - devices: { id: string; modelName: string; brand: { name: string } }[], -): Option[] { - return devices.map((d) => ({ - id: d.id, - name: `${d.brand.name} ${d.modelName}`, - badgeName: d.modelName, - })) -} - -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 })) -} - -// Variant used in v2 filters where the display format is "Name (Manufacturer)" -export function socOptionsParens( - socs: { id: string; name: string; manufacturer: string }[], -): Option[] { - return socs.map((s) => ({ id: s.id, name: `${s.name} (${s.manufacturer})` })) -} 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 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/async-filters.spec.ts b/tests/async-filters.spec.ts new file mode 100644 index 000000000..dc9f622c4 --- /dev/null +++ b/tests/async-filters.spec.ts @@ -0,0 +1,183 @@ +import { expect } from '@playwright/test' +import { createPrismaClient } from '@/server/prisma-client' +import { ApprovalStatus } from '@orm' +import { test } from './fixtures' +import type { Locator, Page } from '@playwright/test' + +async function getAsyncFilterFixtures() { + const prisma = createPrismaClient() + + try { + const device = await prisma.device.findFirst({ + where: { + listings: { some: { status: ApprovalStatus.APPROVED } }, + soc: { isNot: null }, + }, + orderBy: [{ brand: { name: 'asc' } }, { modelName: 'asc' }], + select: { + id: true, + modelName: true, + brand: { select: { name: true } }, + soc: { select: { id: true, name: true, manufacturer: true } }, + }, + }) + if (!device) throw new Error('Expected an approved handheld report device fixture') + if (!device.soc) throw new Error('Expected handheld device fixture to have an SoC') + + const cpu = await prisma.cpu.findFirst({ + where: { pcListings: { some: { status: ApprovalStatus.APPROVED } } }, + orderBy: [{ brand: { name: 'asc' } }, { modelName: 'asc' }], + select: { + id: true, + modelName: true, + brand: { select: { name: true } }, + }, + }) + if (!cpu) throw new Error('Expected an approved PC report CPU fixture') + + const gpu = + (await prisma.gpu.findFirst({ + where: { pcListings: { some: { status: ApprovalStatus.APPROVED } } }, + orderBy: [{ brand: { name: 'asc' } }, { modelName: 'asc' }], + select: { + id: true, + modelName: true, + brand: { select: { name: true } }, + }, + })) ?? + (await prisma.gpu.findFirst({ + orderBy: [{ brand: { name: 'asc' } }, { modelName: 'asc' }], + select: { + id: true, + modelName: true, + brand: { select: { name: true } }, + }, + })) + if (!gpu) throw new Error('Expected a GPU fixture') + + return { + device: { + label: `${device.brand.name} ${device.modelName}`, + searchTerm: device.modelName, + }, + soc: { + label: `${device.soc.manufacturer} ${device.soc.name}`, + searchTerm: device.soc.name, + }, + cpu: { + label: `${cpu.brand.name} ${cpu.modelName}`, + searchTerm: cpu.modelName, + }, + gpu: { + label: `${gpu.brand.name} ${gpu.modelName}`, + searchTerm: gpu.modelName, + }, + } + } finally { + await prisma.$disconnect() + } +} + +async function waitForListingsTableIdle(page: Page) { + const tableBody = page.locator('table tbody').first() + const firstRow = page.locator('table tbody tr').first() + const noListingsMessage = page.getByText(/no listings found|no results|empty|nothing found/i) + + await expect(firstRow.or(noListingsMessage)).toBeVisible() + if (await tableBody.isVisible()) { + await expect(tableBody).not.toHaveClass(/opacity-50/) + } +} + +async function openFilterDropdown(filterButton: Locator) { + await filterButton.scrollIntoViewIfNeeded() + + for (let attempt = 0; attempt < 2; attempt += 1) { + await filterButton.click() + try { + await expect(filterButton).toHaveAttribute('aria-expanded', 'true', { timeout: 1000 }) + return + } catch { + // Retry once for pages that are still finishing client hydration. + } + } + + await expect(filterButton).toHaveAttribute('aria-expanded', 'true') +} + +async function selectAsyncFilterOption( + page: Page, + filterButton: Locator, + searchPlaceholder: string, + fixture: { label: string; searchTerm: string }, + expectedParam: string, +) { + await openFilterDropdown(filterButton) + + const searchInput = page.getByPlaceholder(searchPlaceholder) + await expect(searchInput).toBeVisible() + await searchInput.fill(fixture.searchTerm) + + const option = page + .getByTestId('async-multi-select-options') + .getByRole('checkbox', { name: fixture.label, exact: true }) + await expect(option).toBeVisible() + await expect(option).toHaveCount(1) + await option.click() + + await expect(page).toHaveURL(new RegExp(`[?&]${expectedParam}=`)) + await expect(filterButton).toContainText(fixture.label) + + await page.keyboard.press('Escape') + await expect(filterButton).toHaveAttribute('aria-expanded', 'false') +} + +test.describe('Async listing filters', () => { + test('applies and restores handheld Device and SoC filters', async ({ page }) => { + const fixtures = await getAsyncFilterFixtures() + + await page.goto('/listings', { waitUntil: 'domcontentloaded' }) + await waitForListingsTableIdle(page) + + const deviceFilter = page.getByRole('button', { name: /devices multi-select/i }) + const socFilter = page.getByRole('button', { name: /socs multi-select/i }) + + await selectAsyncFilterOption( + page, + deviceFilter, + 'Search devices...', + fixtures.device, + 'deviceIds', + ) + await selectAsyncFilterOption(page, socFilter, 'Search SoCs...', fixtures.soc, 'socIds') + + await page.reload({ waitUntil: 'domcontentloaded' }) + await waitForListingsTableIdle(page) + + await expect(deviceFilter).toContainText(fixtures.device.label) + await expect(socFilter).toContainText(fixtures.soc.label) + await expect(page.getByText(/devices: 1 selected/i)).toBeVisible() + await expect(page.getByText(/socs: 1 selected/i)).toBeVisible() + }) + + test('applies and restores PC CPU and GPU filters', async ({ page }) => { + const fixtures = await getAsyncFilterFixtures() + + await page.goto('/pc-listings', { waitUntil: 'domcontentloaded' }) + await waitForListingsTableIdle(page) + + const cpuFilter = page.getByRole('button', { name: /cpus multi-select/i }) + const gpuFilter = page.getByRole('button', { name: /gpus multi-select/i }) + + await selectAsyncFilterOption(page, cpuFilter, 'Search CPUs...', fixtures.cpu, 'cpuIds') + await selectAsyncFilterOption(page, gpuFilter, 'Search GPUs...', fixtures.gpu, 'gpuIds') + + await page.reload({ waitUntil: 'domcontentloaded' }) + await waitForListingsTableIdle(page) + + await expect(cpuFilter).toContainText(fixtures.cpu.label) + await expect(gpuFilter).toContainText(fixtures.gpu.label) + await expect(page.getByText(/cpus: 1 selected/i)).toBeVisible() + await expect(page.getByText(/gpus: 1 selected/i)).toBeVisible() + }) +}) diff --git a/tests/game-image-selectors.spec.ts b/tests/game-image-selectors.spec.ts new file mode 100644 index 000000000..41b6db89a --- /dev/null +++ b/tests/game-image-selectors.spec.ts @@ -0,0 +1,247 @@ +import { randomUUID } from 'node:crypto' +import { type Locator, type Page } from '@playwright/test' +import { createPrismaClient } from '@/server/prisma-client' +import { ApprovalStatus } from '@orm' +import { test, expect } from './fixtures' +import { registerCookieConsent } from './helpers/cookie-consent' +import { registerExternalServiceMocks } from './helpers/external-services' + +const ORIGINAL_COVER_URL = + 'https://media.rawg.io/media/games/5c0/5c0dd63002cb23f804aab327d40ef119.jpg' +const STEAM_COVER_URL = + 'https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/620/header.jpg?t=1745363004' +const ARBITRARY_BOXART_URL = 'https://example.com/e2e-boxart.jpg' +const GOG_BANNER_URL = + 'https://images.gog-statics.com/c75e674590b8947542c809924df30bbef2190341163dd08668e243c266be70c5_product_card_v2_mobile_slider_639.jpg' + +type GameFixture = { + id: string + title: string +} + +async function createGameFixture(): Promise { + const prisma = createPrismaClient() + + try { + const system = await prisma.system.findFirst({ select: { id: true } }) + if (!system) throw new Error('Expected at least one system for game image selector E2E') + + const superAdmin = await prisma.user.findUnique({ + where: { email: 'superadmin@emuready.com' }, + select: { id: true }, + }) + if (!superAdmin) throw new Error('Expected seeded super admin user for image selector E2E') + + const title = `E2E Image Selector ${randomUUID()}` + const game = await prisma.game.create({ + data: { + title, + systemId: system.id, + imageUrl: ORIGINAL_COVER_URL, + boxartUrl: null, + bannerUrl: null, + status: ApprovalStatus.APPROVED, + submittedBy: superAdmin.id, + submittedAt: new Date(), + approvedBy: superAdmin.id, + approvedAt: new Date(), + }, + select: { id: true, title: true }, + }) + + return game + } finally { + await prisma.$disconnect() + } +} + +async function deleteGameFixture(gameId: string): Promise { + const prisma = createPrismaClient() + + try { + await prisma.game.deleteMany({ where: { id: gameId } }) + } finally { + await prisma.$disconnect() + } +} + +async function getGameImageValues(gameId: string) { + const prisma = createPrismaClient() + + try { + const game = await prisma.game.findUnique({ + where: { id: gameId }, + select: { + imageUrl: true, + boxartUrl: true, + bannerUrl: true, + }, + }) + if (!game) throw new Error(`Expected E2E game to exist: ${gameId}`) + + return game + } finally { + await prisma.$disconnect() + } +} + +async function withGameFixture(run: (game: GameFixture) => Promise): Promise { + const game = await createGameFixture() + + try { + await run(game) + } finally { + await deleteGameFixture(game.id) + } +} + +function imageInput(page: Page, placeholder: string): Locator { + return page.getByPlaceholder(placeholder) +} + +function imageInputRow(input: Locator): Locator { + return input.locator( + 'xpath=ancestor::div[contains(concat(" ", normalize-space(@class), " "), " flex ") and contains(concat(" ", normalize-space(@class), " "), " gap-2 ")][1]', + ) +} + +function applyButtonFor(input: Locator): Locator { + return imageInputRow(input).getByRole('button', { name: 'Apply' }) +} + +function clearButtonFor(input: Locator): Locator { + return imageInputRow(input).getByRole('button', { name: 'Clear image URL' }) +} + +async function setImageField(input: Locator, value: string): Promise { + await input.fill(value) + await expect(applyButtonFor(input)).toBeEnabled() + await applyButtonFor(input).click() +} + +async function clearImageField(input: Locator): Promise { + await expect(clearButtonFor(input)).toBeVisible() + await clearButtonFor(input).click() + await expect(input).toHaveValue('') +} + +test.describe('Game image selectors', () => { + test.describe('admin edit form', () => { + test.use({ storageState: 'tests/.auth/super_admin.json' }) + + test('validates, saves, and clears cover, boxart, and banner image URLs', async ({ page }) => { + await withGameFixture(async (game) => { + await page.goto(`/admin/games/${game.id}`, { waitUntil: 'domcontentloaded' }) + await expect(page.getByRole('heading', { name: `Edit Game: ${game.title}` })).toBeVisible() + + const coverInput = imageInput(page, 'https://example.com/game-image.jpg') + const boxartInput = imageInput(page, 'https://example.com/boxart-image.jpg') + const bannerInput = imageInput(page, 'https://example.com/banner-image.jpg') + + await expect(coverInput).toHaveValue(ORIGINAL_COVER_URL) + + await coverInput.fill('http://example.com/not-allowed.jpg') + await expect(page.getByText('Image URL must use HTTPS.')).toBeVisible() + await expect(applyButtonFor(coverInput)).toBeDisabled() + + await setImageField(coverInput, STEAM_COVER_URL) + await setImageField(boxartInput, ARBITRARY_BOXART_URL) + await setImageField(bannerInput, GOG_BANNER_URL) + + await page.getByRole('button', { name: 'Save Changes' }).click() + + await expect + .poll(() => getGameImageValues(game.id)) + .toEqual({ + imageUrl: STEAM_COVER_URL, + boxartUrl: ARBITRARY_BOXART_URL, + bannerUrl: GOG_BANNER_URL, + }) + + await page.reload({ waitUntil: 'domcontentloaded' }) + await expect(coverInput).toHaveValue(STEAM_COVER_URL) + await expect(boxartInput).toHaveValue(ARBITRARY_BOXART_URL) + await expect(bannerInput).toHaveValue(GOG_BANNER_URL) + + await clearImageField(boxartInput) + await clearImageField(bannerInput) + await page.getByRole('button', { name: 'Save Changes' }).click() + + await expect + .poll(() => getGameImageValues(game.id)) + .toEqual({ + imageUrl: STEAM_COVER_URL, + boxartUrl: null, + bannerUrl: null, + }) + }) + }) + }) + + test.describe('public game image editor access', () => { + test('hides privileged image editing from regular users', async ({ browser }) => { + await withGameFixture(async (game) => { + const context = await browser.newContext({ storageState: 'tests/.auth/user.json' }) + await registerCookieConsent(context) + const page = await context.newPage() + await registerExternalServiceMocks(page) + + try { + await page.goto(`/games/${game.id}`, { waitUntil: 'domcontentloaded' }) + await expect(page.getByRole('heading', { name: game.title })).toBeVisible() + await expect(page.getByRole('button', { name: /edit cover image/i })).toHaveCount(0) + await expect(page.getByRole('button', { name: /edit boxart/i })).toHaveCount(0) + await expect(page.getByRole('button', { name: /edit banner/i })).toHaveCount(0) + } finally { + await context.close() + } + }) + }) + + test('shows manual and provider selectors to moderators', async ({ browser }) => { + await withGameFixture(async (game) => { + const context = await browser.newContext({ storageState: 'tests/.auth/moderator.json' }) + await registerCookieConsent(context) + const page = await context.newPage() + await registerExternalServiceMocks(page) + + try { + await page.goto(`/games/${game.id}`, { waitUntil: 'domcontentloaded' }) + await expect(page.getByRole('heading', { name: game.title })).toBeVisible() + + const editButton = page.getByRole('button', { name: /edit cover image/i }) + await expect(editButton).toBeAttached() + await editButton.click({ force: true }) + + const dialog = page.locator('[role="dialog"]') + await expect(dialog).toBeVisible() + await expect(dialog.getByRole('button', { name: 'Manual URL' })).toBeVisible() + await expect(dialog.getByRole('button', { name: 'RAWG.io' })).toBeVisible() + await expect(dialog.getByRole('button', { name: 'TheGamesDB' })).toBeVisible() + await expect(dialog.getByRole('button', { name: /IGDB/i })).toBeVisible() + await expect(dialog.getByPlaceholder('https://example.com/image.jpg')).toBeVisible() + } finally { + await context.close() + } + }) + }) + }) + + test.describe('new game image selector access', () => { + test.use({ storageState: 'tests/.auth/author.json' }) + + test('shows provider-only image selection to authors on manual game creation', async ({ + page, + }) => { + await page.goto('/games/new', { waitUntil: 'domcontentloaded' }) + + await expect( + page.getByRole('textbox', { name: 'Enter game title', exact: true }), + ).toBeVisible() + await expect(page.getByRole('button', { name: 'RAWG.io' })).toBeVisible() + await expect(page.getByRole('button', { name: /TheGamesDB/ })).toBeVisible() + await expect(page.getByRole('button', { name: 'Manual URL' })).toHaveCount(0) + await expect(page.getByRole('button', { name: /IGDB/i })).toHaveCount(0) + }) + }) +}) diff --git a/tests/global.setup.ts b/tests/global.setup.ts index 24707d7d7..4937603c7 100644 --- a/tests/global.setup.ts +++ b/tests/global.setup.ts @@ -3,6 +3,11 @@ import { clerkSetup } from '@clerk/testing/playwright' async function globalSetup() { console.log('🔧 Starting global setup for Playwright tests...') + if (process.env.PWTEST_SKIP_CLERK_SETUP === '1') { + console.log('⏭️ Skipping Clerk testing setup') + return + } + await clerkSetup() console.log('✅ Global setup completed - Clerk initialized') 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/helpers/external-services.ts b/tests/helpers/external-services.ts index 3a438082b..b32b3d9a2 100644 --- a/tests/helpers/external-services.ts +++ b/tests/helpers/external-services.ts @@ -25,16 +25,8 @@ export async function registerExternalServiceMocks(page: Page) { }) }) - await page.route(/\/api\/proxy-image(?:\?.*)?$/u, async (route) => { - await route.fulfill({ - status: 200, - contentType: 'image/png', - body: transparentPng, - }) - }) - await page.route( - /^https:\/\/(?:cdn\.thegamesdb\.net|media\.rawg\.io|images\.igdb\.com|assets\.nintendo\.com)\/.*/u, + /^https:\/\/(?:cdn\.thegamesdb\.net|media\.rawg\.io|images\.igdb\.com|assets\.nintendo\.com|shared\.akamai\.steamstatic\.com|cdn1\.epicgames\.com|cdn2\.unrealengine\.com|images\.gog-statics\.com)\/.*/u, async (route) => { await route.fulfill({ status: 200, 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() + }) + }) +}) diff --git a/tsconfig.json b/tsconfig.json index d40a002d3..0fcabda48 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -15,6 +15,7 @@ "incremental": true, "plugins": [{ "name": "next" }], "paths": { + "@config/*": ["./config/*"], "@/*": ["./src/*"], "@orm": ["./prisma/generated/client/browser"], "@orm/client": ["./prisma/generated/client/client"],