diff --git a/.env.docker.example b/.env.docker.example index 8cfd56ce2..deccc7ed8 100644 --- a/.env.docker.example +++ b/.env.docker.example @@ -65,7 +65,6 @@ NEXT_PUBLIC_GA_ID="your_google_analytics_id_here" NEXT_PUBLIC_APP_ENV=local NEXT_PUBLIC_LOCAL_STORAGE_PREFIX="@DockerEmuReady_" NEXT_PUBLIC_EMUREADY_BETA_URL="https://play.google.com/store/apps/details?id=com.producdevity.emureadyapp" -NEXT_PUBLIC_VERCEL_ANALYTICS_ENABLED=false NEXT_PUBLIC_ENABLE_ANALYTICS=false NEXT_PUBLIC_ENABLE_KOFI_WIDGET=false NEXT_PUBLIC_ENABLE_SENTRY=false @@ -77,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 5e80fcf9f..5f565192d 100644 --- a/.env.example +++ b/.env.example @@ -39,7 +39,6 @@ NEXT_PUBLIC_APP_ENV=local NEXT_PUBLIC_GA_ID="Google-Analytics-ID" NEXT_PUBLIC_LOCAL_STORAGE_PREFIX="@LocalEmuReady_" NEXT_PUBLIC_EMUREADY_BETA_URL="https://play.google.com/store/apps/details?id=com.producdevity.emureadyapp" -NEXT_PUBLIC_VERCEL_ANALYTICS_ENABLED=false NEXT_PUBLIC_ENABLE_ANALYTICS=false NEXT_PUBLIC_ENABLE_KOFI_WIDGET=false NEXT_PUBLIC_ENABLE_SENTRY=false @@ -52,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 9dc7a9b4a..c1bb01153 100644 --- a/.env.test.example +++ b/.env.test.example @@ -30,7 +30,6 @@ NEXT_PUBLIC_APP_ENV=test NEXT_PUBLIC_GA_ID="" NEXT_PUBLIC_LOCAL_STORAGE_PREFIX="@TestEmuReady_" NEXT_PUBLIC_EMUREADY_BETA_URL="https://play.google.com/store/apps/details?id=com.producdevity.emureadyapp" -NEXT_PUBLIC_VERCEL_ANALYTICS_ENABLED=false NEXT_PUBLIC_ENABLE_ANALYTICS=false NEXT_PUBLIC_ENABLE_KOFI_WIDGET=false NEXT_PUBLIC_ENABLE_SENTRY=false @@ -41,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 68bd0e07d..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,11 +98,20 @@ 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 asks, or unless the TODO is directly made obsolete by the code change. - Prefer function declarations for top-level functions/components. +- Avoid object destructuring when values are used only once or when it makes + ownership less clear. Prefer `object.property` access unless destructuring + materially improves readability. - Component props interfaces should be named `Props`. - Do not destructure component props in function parameters; use `props.foo`. - Keep `useEffect` dependencies correct. diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 7a8478349..0ade674de 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,3 +1,43 @@ +# 🚀 EmuReady Release Notes – 22 August 2026 (v0.15.0) + +v0.15.0 brings the PC moderation workflow up to parity with handheld reports, hardens the web and mobile API paths, and cuts unnecessary server traffic. It also includes a set of smaller fixes across report browsing, comments, notifications, admin tools, and GameNative configuration. + +## Users + +- Handheld report search now combines the search term with approval and ownership rules correctly. Signed-out users see matching approved reports, signed-in users also see their own matching pending reports, and unrelated reports no longer leak into the results. +- Comments and replies can now be submitted with Cmd+Enter or Ctrl+Enter without bypassing validation or human-verification checks. +- Oversized cover art no longer pushes handheld or PC report detail cards outside their containers. +- Translation language detection now loads only when translated content is near the viewport, and cached translated text is reset when the source content changes. +- Notification lists load only while the notification menu is open. The unread badge refreshes less often in the background and refreshes again when the menu is opened. +- Handheld and PC report filters now use the async selectors consistently instead of depending on feature flags. +- New GameNative configurations now default `startup_selection` to Essential, matching the generated configuration. + +## Moderators and admins + +- Added a processed PC reports page with status filtering, search, sorting, pagination, status overrides, and reset-to-pending actions. +- Handheld and PC processed-report actions now share the same admin UI and keep trust changes, notifications, and cache invalidation aligned. +- Moderator-role users and above now receive notifications when handheld or PC reports are submitted through the web or mobile API. +- Report submission and moderation paths now share more of their validation and persistence logic, including description sanitization for PC and mobile submissions. +- Custom field template search now matches template names, descriptions, field names, and field labels, with a separate empty state when no filtered results match. +- CPU and GPU admin pages now request the correct brand category from the server instead of maintaining client-side brand allowlists. +- IGDB image selection is available to moderators while the normal author flow keeps its existing RAWG and TheGamesDB choices. +- Admin pages use more of the shared layout, error, statistics, and empty-state components. Deleting an in-use performance scale now completes its replacement step before removal. + +## Developers and contributors + +- Anonymous public GET lookups can use shared-cache headers. Authenticated requests, errors, batches, mutations, and requests carrying credentials remain `private, no-store`. +- Mobile CORS and origin handling were standardized. Browser requests with untrusted origin metadata are rejected, while native clients without browser origin headers continue to work. +- Explicit invalid `Authorization: ApiKey ...` credentials are rejected. The legacy `x-api-key` fallback remains temporarily permissive for shipped mobile clients that also use Bearer authentication. +- Steam batch lookup now uses one shared service for the mobile API and admin tools, preserves the requested App ID order, and returns explicit not-found results. +- CPU and GPU code now lives in feature-owned modules with shared contracts, repositories, services, policies, mappers, routers, and admin components. +- Mobile API documentation and generated OpenAPI output were refreshed for the current authentication, validation, hardware lookup, report, and Steam batch contracts. +- Game image handling now uses provider-aware validation and rendering, and the deprecated image proxy path was removed. +- Removed unused realtime notification/SSE code, Vercel Analytics integration, old popup code, and the experimental v2 listings implementation. +- Session analytics now derive sign-in method, page views, and interaction counts from actual client activity instead of placeholder values. +- Test coverage was expanded for mobile authentication, proxy origin handling, CORS, tRPC cache policy, Steam batch lookups, CPU/GPU modules, PC moderation, notifications, session tracking, admin search, report layout, and search visibility across user roles. + +--- + # 🚀 EmuReady Release Notes – 4 June 2026 (v0.14.0) This update is smaller than the v0.13.0 catch-up release, but it touches some important day-to-day workflows. The main themes are safer moderation, better spam protection, faster report browsing, and fewer stale-cache issues. Routine cleanup, small dependency churn, and test-only refactors are omitted unless they affect users, moderators, or maintenance. 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..7472c3fe5 100644 --- a/docs/MOBILE_API.md +++ b/docs/MOBILE_API.md @@ -1,18 +1,18 @@ -# EmuReady Mobile API (tRPC) +# EmuReady Public Integration API (mobile-compatible tRPC) -*Auto-generated on: 2026-05-25T13:13:26.417Z* +*Auto-generated on: 2026-07-06T20:07:17.160Z* ## Summary -- **Total Endpoints**: 112 +- **Total Endpoints**: 113 - **Public Endpoints**: 65 -- **Protected Endpoints**: 47 +- **Protected Endpoints**: 48 - **OpenAPI Version**: 3.0.0 ## Base URL `/api/mobile/trpc` ## Authentication -Protected endpoints require Bearer token authentication using Clerk JWT. +Protected endpoints require Bearer token authentication using Clerk JWT. Public integration requests can also include an issued API key in `x-api-key`. ## Interactive Documentation - **Swagger UI**: [/docs/api/swagger](https://emuready.com/docs/api/swagger) @@ -33,21 +33,21 @@ Protected endpoints require Bearer token authentication using Clerk JWT. #### 2. **getDeviceCompatibility** - **Method**: GET - **Path**: `/catalog.getDeviceCompatibility` -- **Description**: Get device compatibility scores by system Returns aggregated compatibility scores (0-100) for each system tested on a device. Scores are calculated from: - Performance ratings from authors - Community votes (Wilson score) - Developer verifications Results are cached for 10 minutes to reduce server load. +- **Description**: Get device compatibility scores by system Returns aggregated compatibility scores (0-100) for each system tested on a device. Scores are calculated from: - Performance ratings from authors - Community votes (Wilson score) - Developer verifications Results are cached for 15 minutes to reduce server load. - **Tags**: catalog #### 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 @@ -208,7 +208,7 @@ Protected endpoints require Bearer token authentication using Clerk JWT. #### 27. **batchBySteamAppIds** - **Method**: GET - **Path**: `/games.batchBySteamAppIds` -- **Description**: Batch lookup games by Steam App IDs Optimized for large batches (up to 1000 Steam App IDs) Returns games with their listings filtered by emulator if specified Results cached for 5 minutes to optimize repeated queries +- **Description**: Batch lookup games by Steam App IDs Optimized for large batches (up to 1000 Steam App IDs) Returns games with their listings filtered by emulator if specified Results cached for 15 minutes to optimize repeated queries - **Tags**: games @@ -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 bb0d6ef1a..2d2dbc6d3 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' @@ -21,7 +22,6 @@ const contentSecurityPolicyDirectives = [ "'unsafe-eval'", 'https://www.googletagmanager.com', 'https://static.cloudflareinsights.com', - 'https://va.vercel-scripts.com', 'https://*.clerk.com', 'https://*.clerk.accounts.dev', 'https://clerk.emuready.com', @@ -48,24 +48,7 @@ const contentSecurityPolicyDirectives = [ }, { name: 'img-src', - sources: [ - "'self'", - 'data:', - 'https://placehold.co', - 'https://*.clerk.com', - 'https://*.clerk.accounts.dev', - 'https://img.clerk.com', - 'https://clerk.emuready.com', - 'https://cdn.thegamesdb.net', - 'https://images.igdb.com', - 'https://media.rawg.io', - 'https://www.googletagmanager.com', - 'https://assets.nintendo.com', - 'https://*.google-analytics.com', - 'https://storage.ko-fi.com', - 'https://vercel.com', - 'https://files.catbox.moe', - ], + sources: ["'self'", 'data:', 'https:'], }, { name: 'font-src', @@ -94,7 +77,6 @@ const contentSecurityPolicyDirectives = [ 'https://clerk.emuready.com', 'wss://*.clerk.accounts.dev', 'wss://clerk.emuready.com', - 'https://va.vercel-scripts.com', 'https://challenges.cloudflare.com', 'https://storage.ko-fi.com', 'https://clerk-telemetry.com', @@ -167,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'], @@ -308,24 +280,6 @@ const nextConfig: NextConfig = { source: '/favicon/:path*', headers: [{ key: 'Cache-Control', value: 'public, max-age=86400, must-revalidate' }], }, - { - source: '/api/mobile/:path*', - headers: [ - { key: 'Cache-Control', value: 'no-store, no-cache, must-revalidate' }, - { key: 'Access-Control-Allow-Origin', value: '*' }, - { key: 'Access-Control-Allow-Methods', value: 'GET, POST, PUT, DELETE, OPTIONS' }, - { - key: 'Access-Control-Allow-Headers', - value: 'Content-Type, Authorization, x-trpc-source', - }, - { key: 'Access-Control-Expose-Headers', value: 'x-trpc-source' }, - ], - }, - // tRPC endpoints are dynamic; prevent intermediary/proxy caching - { - source: '/api/trpc/:path*', - headers: [{ key: 'Cache-Control', value: 'no-store, no-cache, must-revalidate' }], - }, { source: '/(.*)', headers: [ diff --git a/package.json b/package.json index 5c9df2d13..0d2d2bbdc 100644 --- a/package.json +++ b/package.json @@ -1,9 +1,9 @@ { "name": "emuready", - "version": "0.14.0", + "version": "0.15.0", "type": "module", "private": false, - "packageManager": "pnpm@11.5.1", + "packageManager": "pnpm@11.5.2", "license": "GPL-3.0-or-later", "author": "Producdevity", "scripts": { @@ -15,7 +15,6 @@ "clean": "rm -rf .next && rm -rf node_modules/.cache && rm -rf .eslintcache && rm -rf tsconfig.tsbuildinfo", "clean:all": "pnpm clean && rm -rf node_modules", "db:backup": "./scripts/db-backup.sh", - "db:backup:supabase": "./scripts/db-backup-supabase.sh", "db:generate": "pnpm exec prisma generate --sql", "db:migrate:create": "./scripts/db-cmd.sh pnpm exec prisma migrate dev --create-only", "db:migrate:deploy": "./scripts/db-cmd.sh pnpm exec prisma migrate deploy", @@ -31,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 .", @@ -80,8 +79,6 @@ "@trpc/react-query": "11.17.0", "@trpc/server": "11.17.0", "@types/react-syntax-highlighter": "^15.5.13", - "@vercel/analytics": "^1.5.0", - "@vercel/speed-insights": "^1.2.0", "axios": "1.16.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -90,7 +87,6 @@ "date-fns": "^4.1.0", "dompurify": "3.4.2", "framer-motion": "^12.11.0", - "franc": "^6.2.0", "franc-min": "^6.2.0", "fuse.js": "^7.1.0", "html-escaper": "^3.0.3", diff --git a/playwright.config.ts b/playwright.config.ts index 99c81291c..ac11fb179 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -21,7 +21,6 @@ function createWebServerEnv(): { [key: string]: string } { env.NEXT_PUBLIC_ENABLE_ANALYTICS = 'false' env.NEXT_PUBLIC_ENABLE_KOFI_WIDGET = 'false' env.NEXT_PUBLIC_ENABLE_SENTRY = 'false' - env.NEXT_PUBLIC_VERCEL_ANALYTICS_ENABLED = 'false' env.NEXT_PUBLIC_DISABLE_COOKIE_BANNER = 'true' env.PLAYWRIGHT_TEST = 'true' return env diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1350131c4..dd31e3803 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -96,12 +96,6 @@ importers: '@types/react-syntax-highlighter': specifier: ^15.5.13 version: 15.5.13 - '@vercel/analytics': - specifier: ^1.5.0 - version: 1.5.0(next@16.2.6(@babel/core@7.27.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)(vue-router@4.5.1(vue@3.5.17(typescript@5.8.3)))(vue@3.5.17(typescript@5.8.3)) - '@vercel/speed-insights': - specifier: ^1.2.0 - version: 1.2.0(next@16.2.6(@babel/core@7.27.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)(vue-router@4.5.1(vue@3.5.17(typescript@5.8.3)))(vue@3.5.17(typescript@5.8.3)) axios: specifier: 1.16.0 version: 1.16.0 @@ -126,9 +120,6 @@ importers: framer-motion: specifier: ^12.11.0 version: 12.19.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - franc: - specifier: ^6.2.0 - version: 6.2.0 franc-min: specifier: ^6.2.0 version: 6.2.0 @@ -3971,55 +3962,6 @@ packages: cpu: [x64] os: [win32] - '@vercel/analytics@1.5.0': - resolution: {integrity: sha512-MYsBzfPki4gthY5HnYN7jgInhAZ7Ac1cYDoRWFomwGHWEX7odTEzbtg9kf/QSo7XEsEAqlQugA6gJ2WS2DEa3g==} - peerDependencies: - '@remix-run/react': ^2 - '@sveltejs/kit': ^1 || ^2 - next: '>= 13' - react: ^18 || ^19 || ^19.0.0-rc - svelte: '>= 4' - vue: ^3 - vue-router: ^4 - peerDependenciesMeta: - '@remix-run/react': - optional: true - '@sveltejs/kit': - optional: true - next: - optional: true - react: - optional: true - svelte: - optional: true - vue: - optional: true - vue-router: - optional: true - - '@vercel/speed-insights@1.2.0': - resolution: {integrity: sha512-y9GVzrUJ2xmgtQlzFP2KhVRoCglwfRQgjyfY607aU0hh0Un6d0OUyrJkjuAlsV18qR4zfoFPs/BiIj9YDS6Wzw==} - peerDependencies: - '@sveltejs/kit': ^1 || ^2 - next: '>= 13' - react: ^18 || ^19 || ^19.0.0-rc - svelte: '>= 4' - vue: ^3 - vue-router: ^4 - peerDependenciesMeta: - '@sveltejs/kit': - optional: true - next: - optional: true - react: - optional: true - svelte: - optional: true - vue: - optional: true - vue-router: - optional: true - '@vitejs/plugin-react@4.6.0': resolution: {integrity: sha512-5Kgff+m8e2PB+9j51eGHEpn5kUzRKH2Ry0qGoe8ItJg7pqnkPrYPkDQZGgGmTa0EGarHrkjLvOdU3b1fzI8otQ==} engines: {node: ^14.18.0 || >=16.0.0} @@ -5267,9 +5209,6 @@ packages: franc-min@6.2.0: resolution: {integrity: sha512-1uDIEUSlUZgvJa2AKYR/dmJC66v/PvGQ9mWfI9nOr/kPpMFyvswK0gPXOwpYJYiYD008PpHLkGfG58SPjQJFxw==} - franc@6.2.0: - resolution: {integrity: sha512-rcAewP7PSHvjq7Kgd7dhj82zE071kX5B4W1M4ewYMf/P+i6YsDQmj62Xz3VQm9zyUzUXwhIde/wHLGCMrM+yGg==} - fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} @@ -12356,20 +12295,6 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.9.2': optional: true - '@vercel/analytics@1.5.0(next@16.2.6(@babel/core@7.27.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)(vue-router@4.5.1(vue@3.5.17(typescript@5.8.3)))(vue@3.5.17(typescript@5.8.3))': - optionalDependencies: - next: 16.2.6(@babel/core@7.27.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - react: 19.2.6 - vue: 3.5.17(typescript@5.8.3) - vue-router: 4.5.1(vue@3.5.17(typescript@5.8.3)) - - '@vercel/speed-insights@1.2.0(next@16.2.6(@babel/core@7.27.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)(vue-router@4.5.1(vue@3.5.17(typescript@5.8.3)))(vue@3.5.17(typescript@5.8.3))': - optionalDependencies: - next: 16.2.6(@babel/core@7.27.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - react: 19.2.6 - vue: 3.5.17(typescript@5.8.3) - vue-router: 4.5.1(vue@3.5.17(typescript@5.8.3)) - '@vitejs/plugin-react@4.6.0(vite@7.2.4(@types/node@20.19.1)(jiti@2.7.0)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0))': dependencies: '@babel/core': 7.27.7 @@ -13854,10 +13779,6 @@ snapshots: dependencies: trigram-utils: 2.0.1 - franc@6.2.0: - dependencies: - trigram-utils: 2.0.1 - fs.realpath@1.0.0: {} fsevents@2.3.2: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 407952f3a..e3236a9c4 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -4,7 +4,6 @@ allowBuilds: "@prisma/engines": true "@sentry/cli": true "@tailwindcss/oxide": true - "@vercel/speed-insights": false esbuild: true prisma: true sharp: true diff --git a/prisma/seed.ts b/prisma/seed.ts index c6febfb04..3c287c8e1 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -190,7 +190,6 @@ async function main() { console.warn('🗑️ Clearing database...') console.warn('I hope you know what you are doing 😅') - // Clear all data in the correct order (children before parents) await clearDb() console.info('✅ Database cleared!') @@ -199,13 +198,12 @@ async function main() { console.info('🌱 Starting database seed...') try { - // Seed in order of dependencies - await permissionsSeeder(prisma) // Seed permissions first + await permissionsSeeder(prisma) await performanceScalesSeeder(prisma) await systemsSeeder(prisma) + await emulatorsSeeder(prisma) await usersSeeder(prisma) await userModerationFixturesSeeder(prisma) - await emulatorsSeeder(prisma) await azaharCustomFieldsSeeder(prisma) await edenCustomFieldsSeeder(prisma) await gamenativeCustomFieldsSeeder(prisma) diff --git a/prisma/seeders/gamenativeCustomFieldsSeeder.ts b/prisma/seeders/gamenativeCustomFieldsSeeder.ts index 8ca275a4a..c3c6d48bd 100644 --- a/prisma/seeders/gamenativeCustomFieldsSeeder.ts +++ b/prisma/seeders/gamenativeCustomFieldsSeeder.ts @@ -199,7 +199,7 @@ const GAMENATIVE_CUSTOM_FIELDS: GameNativeCustomFieldSeed[] = [ type: CustomFieldType.SELECT, required: false, displayOrder: 9, - defaultValue: 'Aggressive (Stop services on startup)', + defaultValue: 'Essential (Load only essential services)', options: [ { value: 'Normal (Load all services)', label: 'Normal (Load all services)' }, { diff --git a/prisma/seeders/usersSeeder.ts b/prisma/seeders/usersSeeder.ts index 6831e5992..d2d694951 100644 --- a/prisma/seeders/usersSeeder.ts +++ b/prisma/seeders/usersSeeder.ts @@ -36,7 +36,6 @@ const users: UserData[] = [ username: 'moderator', role: Role.MODERATOR, }, - // TODO: Assign emulators to Developer User { email: 'developer@emuready.com', name: 'Developer User', @@ -64,11 +63,27 @@ const users: UserData[] = [ ] const DEFAULT_SEED_PASSWORD = 'DevPassword123!' +const DEVELOPER_SEED_EMAIL = 'developer@emuready.com' +const SUPER_ADMIN_SEED_EMAIL = 'superadmin@emuready.com' async function cleanupExistingUsers(prisma: PrismaClient) { console.info('🧹 Cleaning up existing seed users...') const clerk = await clerkClient() + const seedUserEmails = users.map((user) => user.email) + const seedUsers = await prisma.user.findMany({ + where: { email: { in: seedUserEmails } }, + select: { id: true }, + }) + + if (seedUsers.length > 0) { + const seedUserIds = seedUsers.map((user) => user.id) + await prisma.verifiedDeveloper.deleteMany({ + where: { + OR: [{ userId: { in: seedUserIds } }, { verifiedBy: { in: seedUserIds } }], + }, + }) + } for (const userData of users) { try { @@ -92,6 +107,56 @@ async function cleanupExistingUsers(prisma: PrismaClient) { console.info('✅ Cleanup completed') } +async function assignDeveloperEmulators(prisma: PrismaClient) { + const [developerUser, verifierUser, emulators] = await Promise.all([ + prisma.user.findUnique({ + where: { email: DEVELOPER_SEED_EMAIL }, + select: { id: true }, + }), + prisma.user.findUnique({ + where: { email: SUPER_ADMIN_SEED_EMAIL }, + select: { id: true }, + }), + prisma.emulator.findMany({ select: { id: true } }), + ]) + + if (!developerUser) { + throw new Error('Expected seeded developer user to exist before assigning emulators') + } + + if (emulators.length === 0) { + console.info('ℹ️ No emulators found to assign to the developer seed user.') + return + } + + const verifierId = verifierUser?.id ?? developerUser.id + + await prisma.$transaction( + emulators.map((emulator) => + prisma.verifiedDeveloper.upsert({ + where: { + userId_emulatorId: { + userId: developerUser.id, + emulatorId: emulator.id, + }, + }, + update: { + verifiedBy: verifierId, + notes: 'Seeded developer emulator access', + }, + create: { + userId: developerUser.id, + emulatorId: emulator.id, + verifiedBy: verifierId, + notes: 'Seeded developer emulator access', + }, + }), + ), + ) + + console.info(`✅ Assigned ${emulators.length} emulator(s) to the developer seed user`) +} + async function usersSeeder(prisma: PrismaClient, shouldCleanup = false) { if (shouldCleanup) { await cleanupExistingUsers(prisma) @@ -166,6 +231,8 @@ async function usersSeeder(prisma: PrismaClient, shouldCleanup = false) { throw new Error(`Failed to seed users: ${failedUsers.join(', ')}`) } + await assignDeveloperEmulators(prisma) + console.info('✅ Users seeding completed') console.info('📝 You can now log in with any of these accounts using the default password.') console.warn('⚠️ Note: Make sure your webhooks are configured for production environments.') diff --git a/public/api-docs/mobile-openapi.json b/public/api-docs/mobile-openapi.json index 397c14f31..1109ec888 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\nProtected routes require authentication via Clerk JWT token in the Authorization header. Public integration requests can also include an issued API key in `x-api-key` for attribution and quota tracking. Invalid `Authorization: ApiKey` credentials are rejected.\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.get?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.get?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.get\"\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": [ @@ -65,7 +65,7 @@ }, "path": { "type": "string", - "description": "tRPC procedure path (e.g., \"games.getGames\")" + "description": "tRPC procedure path (e.g., \"games.get\")" }, "zodError": { "type": "object", @@ -163,10 +163,12 @@ }, "deviceModelName": { "type": "string", + "maxLength": 120, "description": "Device model name (e.g., \"Pocket 5\")" }, "deviceBrandName": { "type": "string", + "maxLength": 80, "description": "Device brand name (e.g., \"Retroid\")" }, "systemIds": { @@ -175,6 +177,7 @@ "type": "string", "format": "uuid" }, + "maxItems": 100, "description": "Filter results to specific system IDs" }, "includeEmulatorBreakdown": { @@ -185,6 +188,7 @@ "minListingCount": { "type": "number", "minimum": 0, + "maximum": 100, "default": 1, "description": "Minimum number of listings required to include a system" } @@ -192,23 +196,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": { @@ -239,6 +455,7 @@ "properties": { "search": { "type": "string", + "maxLength": 100, "description": "Search devices by name" }, "brandId": { @@ -267,7 +484,8 @@ "format": "uuid" }, "search": { - "type": "string" + "type": "string", + "maxLength": 100 }, "limit": { "type": "number", @@ -300,7 +518,8 @@ "type": "object", "properties": { "search": { - "type": "string" + "type": "string", + "maxLength": 100 }, "systemId": { "type": "string", @@ -334,7 +553,8 @@ "properties": { "query": { "type": "string", - "minLength": 1 + "minLength": 1, + "maxLength": 100 } }, "required": [ @@ -360,7 +580,8 @@ "properties": { "gameName": { "type": "string", - "minLength": 2 + "minLength": 2, + "maxLength": 120 }, "maxResults": { "type": "number", @@ -379,7 +600,8 @@ "properties": { "gameName": { "type": "string", - "minLength": 2 + "minLength": 2, + "maxLength": 120 } }, "required": [ @@ -404,7 +626,8 @@ "properties": { "gameName": { "type": "string", - "minLength": 2 + "minLength": 2, + "maxLength": 120 }, "maxResults": { "type": "number", @@ -423,7 +646,8 @@ "properties": { "gameName": { "type": "string", - "minLength": 2 + "minLength": 2, + "maxLength": 120 } }, "required": [ @@ -448,7 +672,8 @@ "properties": { "gameName": { "type": "string", - "minLength": 2 + "minLength": 2, + "maxLength": 120 }, "maxResults": { "type": "number", @@ -467,7 +692,8 @@ "properties": { "gameName": { "type": "string", - "minLength": 2 + "minLength": 2, + "maxLength": 120 } }, "required": [ @@ -508,6 +734,7 @@ }, "emulatorName": { "type": "string", + "maxLength": 100, "description": "Filter listings by emulator name" }, "maxListingsPerGame": { @@ -533,12 +760,661 @@ ], "additionalProperties": false }, + "BatchBySteamAppIdsResponseSchema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "const": true + }, + "results": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "object", + "properties": { + "steamAppId": { + "type": "string" + }, + "game": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "title": { + "type": "string" + }, + "normalizedTitle": { + "type": "string", + "nullable": true + }, + "systemId": { + "type": "string" + }, + "imageUrl": { + "type": "string", + "nullable": true + }, + "boxartUrl": { + "type": "string", + "nullable": true + }, + "bannerUrl": { + "type": "string", + "nullable": true + }, + "tgdbGameId": { + "type": "number", + "nullable": true + }, + "metadata": {}, + "isErotic": { + "type": "boolean" + }, + "ageRating": { + "type": "string", + "nullable": true + }, + "status": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "system": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "key": { + "type": "string", + "nullable": true + } + }, + "required": [ + "id", + "name", + "key" + ], + "additionalProperties": true + }, + "_count": { + "type": "object", + "properties": { + "listings": { + "type": "number" + } + }, + "required": [ + "listings" + ], + "additionalProperties": true + }, + "listings": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "notes": { + "type": "string", + "nullable": true + }, + "upvoteCount": { + "type": "number" + }, + "downvoteCount": { + "type": "number" + }, + "voteCount": { + "type": "number" + }, + "successRate": { + "type": "number", + "nullable": true + }, + "deviceId": { + "type": "string" + }, + "gameId": { + "type": "string" + }, + "emulatorId": { + "type": "string" + }, + "performanceId": { + "type": "number" + }, + "device": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "modelName": { + "type": "string" + }, + "soc": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "manufacturer": { + "type": "string", + "nullable": true + }, + "architecture": { + "type": "string", + "nullable": true + }, + "processNode": { + "type": "string", + "nullable": true + }, + "cpuCores": { + "type": "number", + "nullable": true + }, + "gpuModel": { + "type": "string", + "nullable": true + } + }, + "required": [ + "id", + "name", + "manufacturer", + "architecture", + "processNode", + "cpuCores", + "gpuModel" + ], + "additionalProperties": true + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "id", + "modelName", + "soc" + ], + "additionalProperties": true + }, + "emulator": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "logo": { + "type": "string", + "nullable": true + } + }, + "required": [ + "id", + "name", + "logo" + ], + "additionalProperties": true + }, + "performance": { + "type": "object", + "properties": { + "id": { + "type": "number" + }, + "label": { + "type": "string" + }, + "rank": { + "type": "number" + }, + "description": { + "type": "string", + "nullable": true + } + }, + "required": [ + "id", + "label", + "rank", + "description" + ], + "additionalProperties": true + }, + "customFieldValues": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "listingId": { + "type": "string" + }, + "customFieldDefinitionId": { + "type": "string" + }, + "value": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": {} + }, + { + "type": "object", + "additionalProperties": {} + } + ] + } + }, + { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": {} + }, + { + "type": "object", + "additionalProperties": {} + } + ] + } + } + ] + }, + "customFieldDefinition": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string" + }, + "label": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "type", + "label", + "name" + ], + "additionalProperties": true + } + }, + "required": [ + "id", + "listingId", + "customFieldDefinitionId", + "value", + "customFieldDefinition" + ], + "additionalProperties": true + } + } + }, + "required": [ + "id", + "notes", + "upvoteCount", + "downvoteCount", + "voteCount", + "successRate", + "deviceId", + "gameId", + "emulatorId", + "performanceId", + "device", + "emulator", + "performance", + "customFieldValues" + ], + "additionalProperties": true + } + } + }, + "required": [ + "id", + "title", + "systemId", + "imageUrl", + "boxartUrl", + "bannerUrl", + "tgdbGameId", + "isErotic", + "status", + "createdAt", + "system", + "_count", + "listings" + ], + "additionalProperties": true + }, + { + "type": "null" + } + ] + }, + "matchStrategy": { + "type": "string", + "enum": [ + "metadata", + "exact", + "normalized", + "not_found" + ] + } + }, + "required": [ + "steamAppId", + "game", + "matchStrategy" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "game_id": { + "type": "string", + "nullable": true + }, + "steam_app_id": { + "type": "string" + }, + "title": { + "type": "string", + "nullable": true + }, + "performance": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "number" + }, + "label": { + "type": "string" + }, + "rank": { + "type": "number" + }, + "description": { + "type": "string", + "nullable": true + } + }, + "required": [ + "id", + "label", + "rank", + "description" + ], + "additionalProperties": true + }, + { + "type": "null" + } + ] + }, + "emulator": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "logo": { + "type": "string", + "nullable": true + } + }, + "required": [ + "id", + "name", + "logo" + ], + "additionalProperties": true + }, + { + "type": "null" + } + ] + }, + "device": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "modelName": { + "type": "string" + }, + "soc": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "manufacturer": { + "type": "string", + "nullable": true + }, + "architecture": { + "type": "string", + "nullable": true + }, + "processNode": { + "type": "string", + "nullable": true + }, + "cpuCores": { + "type": "number", + "nullable": true + }, + "gpuModel": { + "type": "string", + "nullable": true + } + }, + "required": [ + "id", + "name", + "manufacturer", + "architecture", + "processNode", + "cpuCores", + "gpuModel" + ], + "additionalProperties": true + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "id", + "modelName", + "soc" + ], + "additionalProperties": true + }, + { + "type": "null" + } + ] + }, + "listing": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string", + "nullable": true + }, + "notes": { + "type": "string", + "nullable": true + }, + "upvoteCount": { + "type": "number" + }, + "downvoteCount": { + "type": "number" + }, + "voteCount": { + "type": "number" + }, + "successRate": { + "type": "number", + "nullable": true + } + }, + "required": [ + "id", + "notes", + "upvoteCount", + "downvoteCount", + "voteCount", + "successRate" + ], + "additionalProperties": true + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "game_id", + "steam_app_id", + "title", + "performance", + "emulator", + "device", + "listing" + ], + "additionalProperties": true + } + ] + } + }, + "totalRequested": { + "type": "number" + }, + "totalFound": { + "type": "number" + }, + "totalNotFound": { + "type": "number" + } + }, + "required": [ + "success", + "results", + "totalRequested", + "totalFound", + "totalNotFound" + ], + "additionalProperties": true + }, "SearchSuggestionsSchema": { "type": "object", "properties": { "query": { "type": "string", - "minLength": 1 + "minLength": 1, + "maxLength": 100 }, "limit": { "type": "number", @@ -552,23 +1428,235 @@ ], "additionalProperties": false }, - "GetGpusSchema": { + "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": { - "search": { - "type": "string" + "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" }, - "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 }, "GetListingsSchema": { @@ -655,6 +1743,7 @@ }, "search": { "type": "string", + "maxLength": 100, "description": "Search listings by game name" } }, @@ -759,13 +1848,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 +2113,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": {} + } + ] } } ] @@ -1323,7 +2496,8 @@ ] }, "search": { - "type": "string" + "type": "string", + "maxLength": 100 }, "minMemory": { "type": "number", @@ -1410,13 +2584,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 +2869,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 +2937,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 +3400,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 +3569,7 @@ "properties": { "data": { "type": "object", - "description": "Response data from auth.validateToken" + "description": "Response data from auth.getSession" } } } @@ -2044,7 +3584,7 @@ "value": { "result": { "data": { - "message": "Response from auth.validateToken", + "message": "Response from auth.getSession", "data": { "id": "uuid-user", "email": "user@example.com", @@ -2076,7 +3616,7 @@ "data": { "code": "BAD_REQUEST", "httpStatus": 400, - "path": "auth.validateToken", + "path": "auth.getSession", "zodError": { "formErrors": [ "Required" @@ -2133,7 +3673,11 @@ } } }, - "security": [] + "security": [ + { + "ClerkAuth": [] + } + ] } }, "/auth.updateProfile": { @@ -2297,7 +3841,7 @@ "$ref": "#/components/schemas/DeleteMobileAccountSchema" }, "example": { - "confirmationText": "example" + "confirmationText": "DELETE" } } }, @@ -2430,8 +3974,8 @@ }, "/catalog.getDeviceCompatibility": { "get": { - "summary": "Get device compatibility scores by system Returns aggregated compatibility scores (0-100) for each system tested on a device. Scores are calculated from: - Performance ratings from authors - Community votes (Wilson score) - Developer verifications Results are cached for 10 minutes to reduce server load.", - "description": "Get device compatibility scores by system Returns aggregated compatibility scores (0-100) for each system tested on a device. Scores are calculated from: - Performance ratings from authors - Community votes (Wilson score) - Developer verifications Results are cached for 10 minutes to reduce server load.", + "summary": "Get device compatibility scores by system Returns aggregated compatibility scores (0-100) for each system tested on a device. Scores are calculated from: - Performance ratings from authors - Community votes (Wilson score) - Developer verifications Results are cached for 15 minutes to reduce server load.", + "description": "Get device compatibility scores by system Returns aggregated compatibility scores (0-100) for each system tested on a device. Scores are calculated from: - Performance ratings from authors - Community votes (Wilson score) - Developer verifications Results are cached for 15 minutes to reduce server load.", "tags": [ "catalog" ], @@ -2439,6 +3983,7 @@ { "name": "input", "in": "query", + "required": false, "schema": { "type": "string", "description": "SuperJSON wrapped input object" @@ -2476,43 +4021,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 +4114,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 +4123,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 +4145,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 +4160,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 +4272,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 +4303,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 +4318,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 +4554,7 @@ { "name": "input", "in": "query", + "required": true, "schema": { "type": "string", "description": "SuperJSON wrapped input object" @@ -3404,6 +4954,7 @@ { "name": "input", "in": "query", + "required": false, "schema": { "type": "string", "description": "SuperJSON wrapped input object" @@ -3935,6 +5486,7 @@ { "name": "input", "in": "query", + "required": false, "schema": { "type": "string", "description": "SuperJSON wrapped input object" @@ -4075,6 +5627,7 @@ { "name": "input", "in": "query", + "required": true, "schema": { "type": "string", "description": "SuperJSON wrapped input object" @@ -4215,6 +5768,7 @@ { "name": "input", "in": "query", + "required": false, "schema": { "type": "string", "description": "SuperJSON wrapped input object" @@ -4252,31 +5806,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 +6042,7 @@ { "name": "input", "in": "query", + "required": true, "schema": { "type": "string", "description": "SuperJSON wrapped input object" @@ -4648,6 +6185,7 @@ { "name": "input", "in": "query", + "required": true, "schema": { "type": "string", "description": "SuperJSON wrapped input object" @@ -4790,6 +6328,7 @@ { "name": "input", "in": "query", + "required": true, "schema": { "type": "string", "description": "SuperJSON wrapped input object" @@ -4932,6 +6471,7 @@ { "name": "input", "in": "query", + "required": true, "schema": { "type": "string", "description": "SuperJSON wrapped input object" @@ -5074,6 +6614,7 @@ { "name": "input", "in": "query", + "required": false, "schema": { "type": "string", "description": "SuperJSON wrapped input object" @@ -5216,6 +6757,7 @@ { "name": "input", "in": "query", + "required": true, "schema": { "type": "string", "description": "SuperJSON wrapped input object" @@ -5358,6 +6900,7 @@ { "name": "input", "in": "query", + "required": true, "schema": { "type": "string", "description": "SuperJSON wrapped input object" @@ -5500,6 +7043,7 @@ { "name": "input", "in": "query", + "required": false, "schema": { "type": "string", "description": "SuperJSON wrapped input object" @@ -5642,6 +7186,7 @@ { "name": "input", "in": "query", + "required": true, "schema": { "type": "string", "description": "SuperJSON wrapped input object" @@ -5784,6 +7329,7 @@ { "name": "input", "in": "query", + "required": true, "schema": { "type": "string", "description": "SuperJSON wrapped input object" @@ -5926,6 +7472,7 @@ { "name": "input", "in": "query", + "required": false, "schema": { "type": "string", "description": "SuperJSON wrapped input object" @@ -6059,8 +7606,8 @@ }, "/games.batchBySteamAppIds": { "get": { - "summary": "Batch lookup games by Steam App IDs Optimized for large batches (up to 1000 Steam App IDs) Returns games with their listings filtered by emulator if specified Results cached for 5 minutes to optimize repeated queries", - "description": "Batch lookup games by Steam App IDs Optimized for large batches (up to 1000 Steam App IDs) Returns games with their listings filtered by emulator if specified Results cached for 5 minutes to optimize repeated queries", + "summary": "Batch lookup games by Steam App IDs Optimized for large batches (up to 1000 Steam App IDs) Returns games with their listings filtered by emulator if specified Results cached for 15 minutes to optimize repeated queries", + "description": "Batch lookup games by Steam App IDs Optimized for large batches (up to 1000 Steam App IDs) Returns games with their listings filtered by emulator if specified Results cached for 15 minutes to optimize repeated queries", "tags": [ "games" ], @@ -6068,6 +7615,7 @@ { "name": "input", "in": "query", + "required": true, "schema": { "type": "string", "description": "SuperJSON wrapped input object" @@ -6089,8 +7637,7 @@ "description": "tRPC result wrapper containing the actual response data", "properties": { "data": { - "type": "object", - "description": "Response data from games.batchBySteamAppIds" + "$ref": "#/components/schemas/BatchBySteamAppIdsResponseSchema" } } } @@ -6105,14 +7652,41 @@ "value": { "result": { "data": { - "message": "Response from games.batchBySteamAppIds", - "data": { - "id": "uuid-game", - "title": "Super Mario Bros", - "systemId": "uuid-system", - "imageUrl": "https://example.com/game.jpg", - "status": "APPROVED" - } + "success": true, + "results": [ + { + "game_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + "steam_app_id": "220", + "title": "Half-Life 2", + "performance": { + "id": 1, + "label": "Perfect", + "rank": 1, + "description": null + }, + "emulator": { + "id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + "name": "GameHub", + "logo": null + }, + "device": { + "id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + "modelName": "Steam Deck", + "soc": null + }, + "listing": { + "id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + "notes": "Runs well", + "upvoteCount": 4, + "downvoteCount": 1, + "voteCount": 5, + "successRate": 0.8 + } + } + ], + "totalRequested": 1, + "totalFound": 1, + "totalNotFound": 0 } } } @@ -6594,6 +8168,7 @@ { "name": "input", "in": "query", + "required": true, "schema": { "type": "string", "description": "SuperJSON wrapped input object" @@ -6852,8 +8427,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 +8436,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 +8458,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 +8473,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 +8585,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 +8616,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 +8631,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 +8730,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 +9131,7 @@ { "name": "input", "in": "query", + "required": false, "schema": { "type": "string", "description": "SuperJSON wrapped input object" @@ -7656,8 +9267,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 +9276,7 @@ { "name": "input", "in": "query", + "required": false, "schema": { "type": "string", "description": "SuperJSON wrapped input object" @@ -7942,6 +9554,7 @@ { "name": "input", "in": "query", + "required": true, "schema": { "type": "string", "description": "SuperJSON wrapped input object" @@ -8086,6 +9699,7 @@ { "name": "input", "in": "query", + "required": true, "schema": { "type": "string", "description": "SuperJSON wrapped input object" @@ -8230,6 +9844,7 @@ { "name": "input", "in": "query", + "required": true, "schema": { "type": "string", "description": "SuperJSON wrapped input object" @@ -8986,6 +10601,7 @@ { "name": "input", "in": "query", + "required": true, "schema": { "type": "string", "description": "SuperJSON wrapped input object" @@ -9134,6 +10750,7 @@ { "name": "input", "in": "query", + "required": true, "schema": { "type": "string", "description": "SuperJSON wrapped input object" @@ -9733,6 +11350,7 @@ { "name": "input", "in": "query", + "required": true, "schema": { "type": "string", "description": "SuperJSON wrapped input object" @@ -10028,6 +11646,7 @@ { "name": "input", "in": "query", + "required": true, "schema": { "type": "string", "description": "SuperJSON wrapped input object" @@ -10328,6 +11947,7 @@ { "name": "input", "in": "query", + "required": false, "schema": { "type": "string", "description": "SuperJSON wrapped input object" @@ -11021,6 +12641,7 @@ { "name": "input", "in": "query", + "required": false, "schema": { "type": "string", "description": "SuperJSON wrapped input object" @@ -11449,8 +13070,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 +13079,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 +13101,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 +13116,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 +13216,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 +13225,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 +13247,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 +13262,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 +13371,7 @@ { "name": "input", "in": "query", + "required": false, "schema": { "type": "string", "description": "SuperJSON wrapped input object" @@ -13312,6 +14948,7 @@ { "name": "input", "in": "query", + "required": true, "schema": { "type": "string", "description": "SuperJSON wrapped input object" @@ -13726,6 +15363,7 @@ { "name": "input", "in": "query", + "required": true, "schema": { "type": "string", "description": "SuperJSON wrapped input object" @@ -16580,6 +18218,7 @@ { "name": "input", "in": "query", + "required": true, "schema": { "type": "string", "description": "SuperJSON wrapped input object" diff --git a/public/sw.js b/public/sw.js index 588dfca2d..b26f8ded8 100644 --- a/public/sw.js +++ b/public/sw.js @@ -39,7 +39,7 @@ /* ------------------------------------------------------------------ */ /** Name of the runtime cache used by this Service Worker. */ -const CACHE_NAME = 'emuready_v0.14.0' +const CACHE_NAME = 'emuready_v0.15.0' /** URLs cached during the installation step */ const urlsToCache = [ diff --git a/scripts/db-backup-supabase.sh b/scripts/db-backup-supabase.sh deleted file mode 100755 index ab0511fcc..000000000 --- a/scripts/db-backup-supabase.sh +++ /dev/null @@ -1,120 +0,0 @@ -#!/bin/sh - -# Supabase-compatible backup script -# Creates backups in formats that can be restored to Supabase - -# Get current date for backup filename -BACKUP_DATE=$(date +"%Y%m%d_%H%M%S") -BACKUP_DIR="./backups" -BACKUP_FILE_SQL="$BACKUP_DIR/supabase_backup_$BACKUP_DATE.sql" -MAX_BACKUPS=10 # Maximum number of backups to keep - -# Create backups directory if it doesn't exist -mkdir -p $BACKUP_DIR - -# Check if a specific PostgreSQL version is available -PG_VERSION=15 # Supabase uses PostgreSQL 15 -if [ -d "/opt/homebrew/opt/postgresql@$PG_VERSION" ]; then - echo "Using PostgreSQL $PG_VERSION from Homebrew..." - export PATH="/opt/homebrew/opt/postgresql@$PG_VERSION/bin:$PATH" -elif [ -d "/usr/local/opt/postgresql@$PG_VERSION" ]; then - echo "Using PostgreSQL $PG_VERSION from Homebrew..." - export PATH="/usr/local/opt/postgresql@$PG_VERSION/bin:$PATH" -else - echo "⚠️ PostgreSQL $PG_VERSION not found, using system version" -fi - -# Use dotenv to load environment variables from .env.local -echo "Creating Supabase-compatible backup using .env.local configuration..." - -# Check pg_dump version -PG_DUMP_VERSION=$(pg_dump --version | grep -oE '[0-9]+\.[0-9]+' | head -1) -echo "Local pg_dump version: $PG_DUMP_VERSION" - -# Run pg_dump through dotenv to use environment variables from .env.local -dotenv -e .env.local -- sh -c ' - # Use DATABASE_DIRECT_URL if available, otherwise fallback to DATABASE_URL - CONNECTION_URL=${DATABASE_DIRECT_URL:-$DATABASE_URL} - - # Remove any query parameters from the connection URL - CLEAN_URL=$(echo $CONNECTION_URL | sed "s/\?.*//") - - echo "Creating Supabase-compatible SQL backup..." - - # Create a comprehensive SQL backup that Supabase can restore - # Using --no-owner and --no-privileges to avoid permission issues - # Using --if-exists for DROP statements - # Using --create to include database creation - # Using --clean to add DROP statements - pg_dump "$CLEAN_URL" \ - --no-owner \ - --no-privileges \ - --no-comments \ - --schema=public \ - --quote-all-identifiers \ - --no-tablespaces \ - --no-unlogged-table-data \ - --disable-dollar-quoting \ - --column-inserts \ - --disable-triggers \ - --if-exists \ - --clean \ - -f '"$BACKUP_FILE_SQL"' 2> /tmp/pg_dump_error - - EXIT_CODE=$? - - if [ $EXIT_CODE -ne 0 ]; then - echo "❌ Backup failed:" - cat /tmp/pg_dump_error - rm -f /tmp/pg_dump_error - exit 1 - fi - - rm -f /tmp/pg_dump_error - - # Verify the backup file was created - if [ ! -f '"$BACKUP_FILE_SQL"' ]; then - echo "❌ Backup file was not created" - exit 1 - fi - - # Check backup file size - BACKUP_SIZE=$(du -h '"$BACKUP_FILE_SQL"' | cut -f1) - echo "✅ Backup created: '"$BACKUP_FILE_SQL"' ($BACKUP_SIZE)" - - # Create a quick verification of content - echo "" - echo "📊 Backup content summary:" - echo " Tables: $(grep -c "CREATE TABLE" '"$BACKUP_FILE_SQL"' || echo "0")" - echo " Indexes: $(grep -c "CREATE INDEX" '"$BACKUP_FILE_SQL"' || echo "0")" - echo " Constraints: $(grep -c "ADD CONSTRAINT" '"$BACKUP_FILE_SQL"' || echo "0")" - echo " Total lines: $(wc -l < '"$BACKUP_FILE_SQL"')" - - exit 0 -' - -# Check if backup was successful -if [ $? -eq 0 ]; then - echo "" - echo "✅ Supabase-compatible backup completed successfully!" - echo " File: $BACKUP_FILE_SQL" - echo "" - echo "💡 To restore this backup to Supabase:" - echo " 1. Go to Supabase Dashboard > Database > Backups" - echo " 2. Use SQL Editor to run the backup file" - echo " 3. Or use: psql < $BACKUP_FILE_SQL" - - # Clean up old backups - keep only the last MAX_BACKUPS - echo "" - echo "Cleaning up old backups (keeping last $MAX_BACKUPS)..." - - NUM_BACKUPS=$(ls -1 $BACKUP_DIR/supabase_backup_*.sql 2>/dev/null | wc -l) - if [ $NUM_BACKUPS -gt $MAX_BACKUPS ]; then - NUM_TO_DELETE=$((NUM_BACKUPS - MAX_BACKUPS)) - ls -1t $BACKUP_DIR/supabase_backup_*.sql | tail -n $NUM_TO_DELETE | xargs rm -f - echo "Deleted $NUM_TO_DELETE old backup(s)" - fi -else - echo "❌ Backup failed" - exit 1 -fi \ No newline at end of file diff --git a/scripts/db-backup.sh b/scripts/db-backup.sh index a6b14a407..fbd5b459c 100755 --- a/scripts/db-backup.sh +++ b/scripts/db-backup.sh @@ -1,18 +1,99 @@ #!/bin/sh -# Get current date for backup filename +set -u + BACKUP_DATE=$(date +"%Y%m%d_%H%M%S") -BACKUP_DIR="./backups" +BACKUP_DIR="${BACKUP_DIR:-./backups}" +BACKUP_SCHEMA="${BACKUP_SCHEMA:-public}" +PG_VERSION="${PG_VERSION:-15}" + BACKUP_FILE="$BACKUP_DIR/emuready_backup_$BACKUP_DATE.pgdump" -BACKUP_FILE_SQL="$BACKUP_DIR/emuready_backup_$BACKUP_DATE.sql" -BACKUP_FILE_DATA="$BACKUP_DIR/emuready_backup_$BACKUP_DATE.data.sql" -MAX_BACKUPS=10 # Maximum number of backups to keep +SHA_FILE="$BACKUP_FILE.sha256" +LOG_FILE="$BACKUP_DIR/emuready_backup_$BACKUP_DATE.log" +TMP_BACKUP_FILE="$BACKUP_DIR/.emuready_backup_$BACKUP_DATE.pgdump.tmp" +TMP_ERROR_FILE="$BACKUP_DIR/.emuready_backup_$BACKUP_DATE.log.tmp" + +usage() { + cat <<'USAGE' +Usage: + pnpm run db:backup -- '' + +Required connection: + Use Supabase's Direct connection string: + postgresql://postgres:@db..supabase.co:5432/postgres + +Do not use: + - Supabase transaction pooler URLs on port 6543 + - Supabase session pooler URLs on pooler.supabase.com + - URLs containing pgbouncer=true + +Supabase Dashboard: + Project -> Connect -> Direct connection +USAGE +} + +fail() { + echo "❌ $1" + + if [ -s "$TMP_ERROR_FILE" ]; then + mv "$TMP_ERROR_FILE" "$LOG_FILE" + echo "Log: $LOG_FILE" + else + rm -f "$TMP_ERROR_FILE" + fi + + rm -f "$TMP_BACKUP_FILE" + exit 1 +} + +if [ "$#" -ne 1 ]; then + if [ "$#" -gt 0 ] && [ "$1" = "--" ]; then + shift + fi +fi + +if [ "$#" -ne 1 ]; then + echo "❌ Missing required direct Postgres connection string." + echo "" + usage + exit 2 +fi + +CONNECTION_URL="$1" + +case "$CONNECTION_URL" in + *".pooler.supabase.com:"*) + echo "❌ Refusing Supabase pooler connection." + echo "" + echo "Use the Direct connection string instead:" + echo "postgresql://postgres:@db..supabase.co:5432/postgres" + exit 2 + ;; +esac + +case "$CONNECTION_URL" in + *":6543/"*) + echo "❌ Refusing transaction-pooler port 6543." + echo "" + echo "Use a direct Postgres host on port 5432 for pg_dump." + exit 2 + ;; +esac + +case "$CONNECTION_URL" in + *"pgbouncer=true"*) + echo "❌ Refusing pgbouncer=true connection string." + echo "" + echo "Use a direct Postgres connection string for pg_dump." + exit 2 + ;; +esac -# Create backups directory if it doesn't exist -mkdir -p $BACKUP_DIR +mkdir -p "$BACKUP_DIR" || { + echo "❌ Could not create backup directory: $BACKUP_DIR" + exit 1 +} -# Check if a specific PostgreSQL version is available -PG_VERSION=15 # Change this to match your server version if needed if [ -d "/opt/homebrew/opt/postgresql@$PG_VERSION" ]; then echo "Using PostgreSQL $PG_VERSION from Homebrew..." export PATH="/opt/homebrew/opt/postgresql@$PG_VERSION/bin:$PATH" @@ -21,123 +102,56 @@ elif [ -d "/usr/local/opt/postgresql@$PG_VERSION" ]; then export PATH="/usr/local/opt/postgresql@$PG_VERSION/bin:$PATH" fi -# Use dotenv to load environment variables from .env.local -echo "Running database backup using .env.local configuration..." - -# Check pg_dump version -PG_DUMP_VERSION=$(pg_dump --version | grep -oE '[0-9]+\.[0-9]+' | head -1) -echo "Local pg_dump version: $PG_DUMP_VERSION" - -# Run pg_dump through dotenv to use environment variables from .env.local -# Use the full connection string directly with pg_dump -dotenv -e .env.local -- sh -c ' - # Use DATABASE_DIRECT_URL if available, otherwise fallback to DATABASE_URL - CONNECTION_URL=${DATABASE_DIRECT_URL:-$DATABASE_URL} - - # Remove any query parameters from the connection URL - CLEAN_URL=$(echo $CONNECTION_URL | sed "s/\?.*//") - - echo "Attempting to backup database using direct connection string..." - - # Create both custom format and SQL format backups - echo "Creating custom format backup..." - pg_dump "$CLEAN_URL" -F c -f '"$BACKUP_FILE"' 2> /tmp/pg_dump_error - - if [ $? -eq 0 ]; then - echo "Creating full SQL backup with schema..." - pg_dump "$CLEAN_URL" --no-owner --no-privileges --column-inserts --schema=public --no-comments -f '"$BACKUP_FILE_SQL"' 2> /tmp/pg_dump_error_sql - - echo "Creating data-only SQL backup for existing databases..." - pg_dump "$CLEAN_URL" --no-owner --no-privileges --column-inserts --schema=public --no-comments --data-only --disable-triggers -f /tmp/backup_raw.sql 2> /tmp/pg_dump_error_data - - if [ $? -eq 0 ]; then - echo "Adding conflict resolution to SQL file..." - # Convert only INSERT statements to INSERT ... ON CONFLICT DO NOTHING - sed "s/^INSERT INTO \(.*\) VALUES \(.*\);$/INSERT INTO \1 VALUES \2 ON CONFLICT DO NOTHING;/g" /tmp/backup_raw.sql > '"$BACKUP_FILE_DATA"' - rm -f /tmp/backup_raw.sql - fi - if [ $? -ne 0 ]; then - echo "Data backup failed" - cat /tmp/pg_dump_error_data - rm -f /tmp/pg_dump_error_data - else - rm -f /tmp/pg_dump_error_data - fi - - if [ $? -ne 0 ]; then - echo "SQL backup failed, but custom format succeeded" - cat /tmp/pg_dump_error_sql - rm -f /tmp/pg_dump_error_sql - else - rm -f /tmp/pg_dump_error_sql - fi - fi - - # Check if the primary backup failed - PRIMARY_EXIT_CODE=$? - if [ $PRIMARY_EXIT_CODE -ne 0 ]; then - # Check if it was a version mismatch error - if grep -q "server version mismatch" /tmp/pg_dump_error; then - SERVER_VERSION=$(grep "server version" /tmp/pg_dump_error | grep -oE "[0-9]+\.[0-9]+" | head -1) - SERVER_MAJOR=$(echo $SERVER_VERSION | cut -d. -f1) - echo "⚠️ Version mismatch detected: Server is PostgreSQL $SERVER_VERSION but your pg_dump is version '"$PG_DUMP_VERSION"'" - echo "To fix this, you need to install PostgreSQL $SERVER_VERSION tools." - echo "" - echo "On macOS with Homebrew:" - echo " brew install postgresql@$SERVER_MAJOR" - echo " brew link --force postgresql@$SERVER_MAJOR" - echo "" - echo "On Ubuntu/Debian:" - echo " sudo apt-get install postgresql-client-$SERVER_MAJOR" - echo "" - echo "Then update PG_VERSION=$SERVER_MAJOR in this script." - echo "" - rm /tmp/pg_dump_error - exit 1 - else - cat /tmp/pg_dump_error - rm /tmp/pg_dump_error - exit 1 - fi - fi - - rm -f /tmp/pg_dump_error - exit 0 -' - -# Check if backup was successful -if [ $? -eq 0 ]; then - echo "✅ Database backup completed successfully:" - echo " Custom format: $BACKUP_FILE ($(du -h $BACKUP_FILE | cut -f1))" - if [ -f "$BACKUP_FILE_SQL" ]; then - echo " Full SQL: $BACKUP_FILE_SQL ($(du -h $BACKUP_FILE_SQL | cut -f1))" - fi - if [ -f "$BACKUP_FILE_DATA" ]; then - echo " Data-only SQL: $BACKUP_FILE_DATA ($(du -h $BACKUP_FILE_DATA | cut -f1))" - fi - echo "" - echo "💡 For new databases: use the full .sql file" - echo "💡 For existing databases: use the .data.sql file" - - # Clean up old backups - keep only the last MAX_BACKUPS of each type - echo "Cleaning up old backups (keeping last $MAX_BACKUPS)..." - - # Clean up .pgdump files - NUM_BACKUPS=$(ls -1 $BACKUP_DIR/emuready_backup_*.pgdump 2>/dev/null | wc -l) - if [ $NUM_BACKUPS -gt $MAX_BACKUPS ]; then - NUM_TO_DELETE=$((NUM_BACKUPS - MAX_BACKUPS)) - ls -1t $BACKUP_DIR/emuready_backup_*.pgdump | tail -n $NUM_TO_DELETE | xargs rm -f - echo "Deleted $NUM_TO_DELETE old .pgdump backup(s)" - fi - - # Clean up .sql files - NUM_BACKUPS=$(ls -1 $BACKUP_DIR/emuready_backup_*.sql 2>/dev/null | wc -l) - if [ $NUM_BACKUPS -gt $MAX_BACKUPS ]; then - NUM_TO_DELETE=$((NUM_BACKUPS - MAX_BACKUPS)) - ls -1t $BACKUP_DIR/emuready_backup_*.sql | tail -n $NUM_TO_DELETE | xargs rm -f - echo "Deleted $NUM_TO_DELETE old .sql backup(s)" - fi -else - echo "❌ Database backup failed" - exit 1 -fi \ No newline at end of file +command -v pg_dump >/dev/null 2>&1 || fail "pg_dump is not available" +command -v pg_restore >/dev/null 2>&1 || fail "pg_restore is not available" + +rm -f "$TMP_BACKUP_FILE" "$TMP_ERROR_FILE" +touch "$TMP_ERROR_FILE" || fail "Could not create backup log" + +{ + echo "Started: $(date -u +"%Y-%m-%dT%H:%M:%SZ")" + echo "pg_dump: $(pg_dump --version)" + echo "schema: $BACKUP_SCHEMA" + echo "format: custom" +} >> "$TMP_ERROR_FILE" + +echo "Running database backup with explicit direct connection string..." +echo "Local pg_dump version: $(pg_dump --version)" +echo "Schema: $BACKUP_SCHEMA" + +PGSSLMODE="${PGSSLMODE:-require}" \ +PGCONNECT_TIMEOUT="${PGCONNECT_TIMEOUT:-30}" \ +PGAPPNAME="${PGAPPNAME:-emuready_db_backup}" \ +pg_dump "$CONNECTION_URL" \ + --format=custom \ + --schema="$BACKUP_SCHEMA" \ + --no-owner \ + --no-privileges \ + --no-comments \ + --file="$TMP_BACKUP_FILE" \ + 2>> "$TMP_ERROR_FILE" || fail "pg_dump failed" + +[ -s "$TMP_BACKUP_FILE" ] || fail "Backup file was not created or is empty" + +echo "Verifying backup can be fully read by pg_restore..." +pg_restore --schema="$BACKUP_SCHEMA" --file=/dev/null "$TMP_BACKUP_FILE" 2>> "$TMP_ERROR_FILE" \ + || fail "Backup verification failed" + +mv "$TMP_BACKUP_FILE" "$BACKUP_FILE" || fail "Could not finalize backup file" + +if command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$BACKUP_FILE" > "$SHA_FILE" +fi + +{ + echo "Completed: $(date -u +"%Y-%m-%dT%H:%M:%SZ")" + echo "backup: $BACKUP_FILE" +} >> "$TMP_ERROR_FILE" + +mv "$TMP_ERROR_FILE" "$LOG_FILE" + +echo "✅ Database backup completed and verified:" +echo " Custom format: $BACKUP_FILE ($(du -h "$BACKUP_FILE" | cut -f1))" +[ -f "$SHA_FILE" ] && echo " SHA-256: $SHA_FILE" +echo " Log: $LOG_FILE" +echo " Cleanup: skipped; old backups are never deleted by this script" diff --git a/src/app/admin/AdminLayoutClient.tsx b/src/app/admin/AdminLayoutClient.tsx index 72b044b2e..7abc09258 100644 --- a/src/app/admin/AdminLayoutClient.tsx +++ b/src/app/admin/AdminLayoutClient.tsx @@ -8,6 +8,7 @@ import { useEffect, useState, type PropsWithChildren } from 'react' import { isNumber } from 'remeda' import { ADMIN_ROUTES } from '@/app/admin/config/routes' import { LoadingSpinner } from '@/components/ui/LoadingSpinner' +import { CACHE_DURATIONS, POLLING_INTERVALS } from '@/data/constants' import { api } from '@/lib/api' import { cn } from '@/lib/utils' import { hasPermission, PERMISSIONS } from '@/utils/permission-system' @@ -39,8 +40,8 @@ export default function AdminLayoutClient(props: PropsWithChildren) { const gameStatsQuery = api.games.stats.useQuery(undefined, { enabled: !!userQuery.data && hasPermission(userQuery.data.permissions, PERMISSIONS.VIEW_STATISTICS), - refetchInterval: 30000, - staleTime: 10000, + refetchInterval: POLLING_INTERVALS.SHORT, + staleTime: CACHE_DURATIONS.VERY_SHORT, refetchOnMount: true, refetchOnWindowFocus: true, }) @@ -48,8 +49,8 @@ export default function AdminLayoutClient(props: PropsWithChildren) { const listingStatsQuery = api.listings.stats.useQuery(undefined, { enabled: !!userQuery.data && hasPermission(userQuery.data.permissions, PERMISSIONS.VIEW_STATISTICS), - refetchInterval: 30000, - staleTime: 10000, + refetchInterval: POLLING_INTERVALS.SHORT, + staleTime: CACHE_DURATIONS.VERY_SHORT, refetchOnMount: true, refetchOnWindowFocus: true, }) @@ -57,8 +58,8 @@ export default function AdminLayoutClient(props: PropsWithChildren) { const pcListingStatsQuery = api.pcListings.stats.useQuery(undefined, { enabled: !!userQuery.data && hasPermission(userQuery.data.permissions, PERMISSIONS.VIEW_STATISTICS), - refetchInterval: 30000, - staleTime: 10000, + refetchInterval: POLLING_INTERVALS.SHORT, + staleTime: CACHE_DURATIONS.VERY_SHORT, refetchOnMount: true, refetchOnWindowFocus: true, }) @@ -67,8 +68,8 @@ export default function AdminLayoutClient(props: PropsWithChildren) { const reportsStatsQuery = api.listingReports.stats.useQuery(undefined, { enabled: !!userQuery.data && isSuperAdmin, - refetchInterval: 30000, - staleTime: 10000, + refetchInterval: POLLING_INTERVALS.SHORT, + staleTime: CACHE_DURATIONS.VERY_SHORT, refetchOnMount: true, refetchOnWindowFocus: true, }) diff --git a/src/app/admin/api-access/components/AdminApiAccessPanel.tsx b/src/app/admin/api-access/components/AdminApiAccessPanel.tsx index 40da6903c..4ba7896d8 100644 --- a/src/app/admin/api-access/components/AdminApiAccessPanel.tsx +++ b/src/app/admin/api-access/components/AdminApiAccessPanel.tsx @@ -1,16 +1,16 @@ 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' import { type ApiKeySortField } from '@/schemas/apiAccess' import getErrorMessage from '@/utils/getErrorMessage' import { hasRolePermission } from '@/utils/permissions' -import { ms } from '@/utils/time' import { Role } from '@orm' import { AdminCreateKeyForm, type AdminCreateFormState } from './AdminCreateKeyForm' import { AdminKeyTable } from './AdminKeyTable' @@ -67,11 +67,11 @@ export function AdminApiAccessPanel(props: Props) { ) const statsQuery = api.apiKeys.adminStats.useQuery(undefined, { - refetchInterval: ms.minutes(5), + refetchInterval: POLLING_INTERVALS.LONG, }) const canManageSystemKeys = hasRolePermission(props.userRole, Role.SUPER_ADMIN) const systemKeysQuery = api.apiKeys.adminSystemKeys.useQuery(undefined, { - refetchInterval: ms.minutes(10), + refetchInterval: POLLING_INTERVALS.EXTRA_LONG, enabled: canManageSystemKeys, }) diff --git a/src/app/admin/api-access/components/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 6daf4958e..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, @@ -10,16 +9,16 @@ import { LoadingSpinner, useConfirmDialog, } from '@/components/ui' -import { API_KEY_LIMITS } from '@/data/constants' +import { API_KEY_LIMITS, POLLING_INTERVALS } from '@/data/constants' import storageKeys from '@/data/storageKeys' import { useColumnVisibility } from '@/hooks' +import { useAdminTable } from '@/hooks/admin' import { type ColumnDefinition } from '@/hooks/useColumnVisibility' import { api } from '@/lib/api' import toast from '@/lib/toast' import { type ApiKeySortField } from '@/schemas/apiAccess' import { formatters, getLocale } from '@/utils/date' import getErrorMessage from '@/utils/getErrorMessage' -import { ms } from '@/utils/time' import { ApiUsagePeriod } from '@orm' import { DeveloperKeyTable } from './DeveloperKeyTable' import { KeySecretBanner } from './KeySecretBanner' @@ -75,7 +74,7 @@ export function DeveloperApiAccessPanel(props: Props) { }, ) const statsQuery = api.apiKeys.myStats.useQuery(undefined, { - refetchInterval: ms.minutes(5), + refetchInterval: POLLING_INTERVALS.LONG, }) const keys = listQuery.data?.keys ?? EMPTY_KEY_ROWS @@ -85,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(() => { @@ -209,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)) @@ -317,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 b36aaaffc..4dc0f82b9 100644 --- a/src/app/admin/audit-logs/page.tsx +++ b/src/app/admin/audit-logs/page.tsx @@ -4,12 +4,12 @@ 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, AdminSearchFilters, AdminTableContainer, + AdminTableNoResults, } from '@/components/admin' import { ColumnVisibilityControl, @@ -20,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' @@ -179,36 +181,8 @@ function AdminAuditLogsPage() { searchPlaceholder="Search by actor, target, entity ID, request, IP, user agent..." >
- - - - + + {logs.length === 0 ? ( -
-

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

-
+ ) : (
diff --git a/src/app/admin/badges/page.tsx b/src/app/admin/badges/page.tsx index d43c25550..6b9edea14 100644 --- a/src/app/admin/badges/page.tsx +++ b/src/app/admin/badges/page.tsx @@ -2,12 +2,12 @@ import { Plus, Users } from 'lucide-react' import { useState } from 'react' -import { useAdminTable } from '@/app/admin/hooks' import { AdminPageLayout, AdminTableContainer, AdminStatsDisplay, AdminSearchFilters, + AdminTableNoResults, } from '@/components/admin' import { Button, @@ -29,6 +29,7 @@ import { } from '@/components/ui' import storageKeys from '@/data/storageKeys' import { useColumnVisibility, type ColumnDefinition } from '@/hooks' +import { useAdminTable } from '@/hooks/admin' import { api } from '@/lib/api' import toast from '@/lib/toast' import { type RouterOutput } from '@/types/trpc' @@ -240,11 +241,11 @@ export default function AdminBadgesPage() { {badgesQuery.isPending ? ( ) : badges.length === 0 ? ( -
-

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

-
+ ) : ( <>
diff --git a/src/app/admin/brands/page.tsx b/src/app/admin/brands/page.tsx index ee3428ecc..c04e92d8b 100644 --- a/src/app/admin/brands/page.tsx +++ b/src/app/admin/brands/page.tsx @@ -2,8 +2,13 @@ import { useState } from 'react' import { isEmpty } from 'remeda' -import { useAdminTable } from '@/app/admin/hooks' -import { AdminTableContainer, AdminSearchFilters, AdminStatsDisplay } from '@/components/admin' +import { + AdminPageLayout, + AdminTableContainer, + AdminSearchFilters, + AdminStatsDisplay, + AdminTableNoResults, +} from '@/components/admin' import { Button, ColumnVisibilityControl, @@ -15,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' @@ -101,22 +107,17 @@ function AdminBrandsPage() { } } - // TODO: use AdminPageLayout like all the other admin pages return ( -
-
-
-

Device Brands

-

- Manage all device brands in the system -

-
-
+ {canManageDevices && } -
-
- + + } + > {brandsStatsQuery.data && ( -
)} @@ -235,7 +235,7 @@ function AdminBrandsPage() { brandName={brandName} onSuccess={handleModalSuccess} /> - + ) } export default AdminBrandsPage diff --git a/src/app/admin/components/AdminNavIcon.tsx b/src/app/admin/components/AdminNavIcon.tsx index 495a1d282..73a2d2526 100644 --- a/src/app/admin/components/AdminNavIcon.tsx +++ b/src/app/admin/components/AdminNavIcon.tsx @@ -36,6 +36,7 @@ const getAdminNavIcon = (href: string, className: string) => { if (href.includes(ADMIN_ROUTES.API_ACCESS_DEV)) return if (href.includes(ADMIN_ROUTES.API_ACCESS)) return if (href.includes(ADMIN_ROUTES.MANAGE_LISTINGS)) return + if (href.includes(ADMIN_ROUTES.PC_PROCESSED_LISTINGS)) return if (href.includes(ADMIN_ROUTES.PROCESSED_LISTINGS)) return if (href.includes(ADMIN_ROUTES.REPORTS)) return if (href.includes(ADMIN_ROUTES.USER_BANS)) return diff --git a/src/app/admin/components/QuickNavigation/QuickNavigation.tsx b/src/app/admin/components/AdminQuickNavigation.tsx similarity index 90% rename from src/app/admin/components/QuickNavigation/QuickNavigation.tsx rename to src/app/admin/components/AdminQuickNavigation.tsx index ba616b1b1..bf6b4916d 100644 --- a/src/app/admin/components/QuickNavigation/QuickNavigation.tsx +++ b/src/app/admin/components/AdminQuickNavigation.tsx @@ -4,17 +4,17 @@ import { ChevronDown, ChevronUp } from 'lucide-react' import Link from 'next/link' import { useState } from 'react' import { cn } from '@/lib/utils' -import { type AdminNavItem } from '../../data' -import ApprovalCountBadge from '../ApprovalCountBadge' +import { type AdminNavItem } from '../data' +import ApprovalCountBadge from './ApprovalCountBadge' -interface QuickNavigationProps { +interface Props { items: AdminNavItem[] title: string defaultExpanded?: boolean className?: string } -export function QuickNavigation(props: QuickNavigationProps) { +export function AdminQuickNavigation(props: Props) { const defaultExpanded = props.defaultExpanded ?? true const [isExpanded, setIsExpanded] = useState(defaultExpanded) @@ -51,7 +51,6 @@ export function QuickNavigation(props: QuickNavigationProps) { {isExpanded && (
- {/* Responsive grid that adjusts based on screen size */}
{props.items.map((item) => ( ({ + userMeUseQuery: vi.fn<() => UserQueryResult>(), + gamesStatsUseQuery: vi.fn<(input?: undefined, options?: unknown) => StatsQueryResult>(), + listingsStatsUseQuery: vi.fn<(input?: undefined, options?: unknown) => StatsQueryResult>(), + pcListingsStatsUseQuery: vi.fn<(input?: undefined, options?: unknown) => StatsQueryResult>(), +})) + vi.mock('@/lib/api', () => ({ api: { - users: { me: { useQuery: vi.fn() } }, - games: { stats: { useQuery: vi.fn() } }, - listings: { stats: { useQuery: vi.fn() } }, - pcListings: { stats: { useQuery: vi.fn() } }, + users: { me: { useQuery: apiMocks.userMeUseQuery } }, + games: { stats: { useQuery: apiMocks.gamesStatsUseQuery } }, + listings: { stats: { useQuery: apiMocks.listingsStatsUseQuery } }, + pcListings: { stats: { useQuery: apiMocks.pcListingsStatsUseQuery } }, }, })) -const mockUserQuery = vi.mocked(api.users.me.useQuery) -const mockGamesStatsQuery = vi.mocked(api.games.stats.useQuery) -const mockListingsStatsQuery = vi.mocked(api.listings.stats.useQuery) -const mockPcListingsStatsQuery = vi.mocked(api.pcListings.stats.useQuery) - describe('ApprovalCountBadge', () => { beforeEach(() => { vi.clearAllMocks() }) it('renders badge when count is available and user has permission', () => { - mockUserQuery.mockReturnValue({ + apiMocks.userMeUseQuery.mockReturnValue({ data: { permissions: [PERMISSIONS.VIEW_STATISTICS] }, - isLoading: false, - isError: false, - error: null, - trpc: {}, - } as any) - mockGamesStatsQuery.mockReturnValue({ + }) + apiMocks.gamesStatsUseQuery.mockReturnValue({ data: { pending: 3, approved: 0, rejected: 0, total: 3 }, - isLoading: false, - isError: false, - error: null, - trpc: {}, - } as any) - mockListingsStatsQuery.mockReturnValue({ - data: undefined, - isLoading: false, - isError: false, - error: null, - trpc: {}, - } as any) - mockPcListingsStatsQuery.mockReturnValue({ - data: undefined, - isLoading: false, - isError: false, - error: null, - trpc: {}, - } as any) + }) + apiMocks.listingsStatsUseQuery.mockReturnValue({}) + apiMocks.pcListingsStatsUseQuery.mockReturnValue({}) render() @@ -60,34 +56,12 @@ describe('ApprovalCountBadge', () => { }) it('returns null when user lacks permission', () => { - mockUserQuery.mockReturnValue({ + apiMocks.userMeUseQuery.mockReturnValue({ data: { permissions: [] }, - isLoading: false, - isError: false, - error: null, - trpc: {}, - } as any) - mockGamesStatsQuery.mockReturnValue({ - data: undefined, - isLoading: false, - isError: false, - error: null, - trpc: {}, - } as any) - mockListingsStatsQuery.mockReturnValue({ - data: undefined, - isLoading: false, - isError: false, - error: null, - trpc: {}, - } as any) - mockPcListingsStatsQuery.mockReturnValue({ - data: undefined, - isLoading: false, - isError: false, - error: null, - trpc: {}, - } as any) + }) + apiMocks.gamesStatsUseQuery.mockReturnValue({}) + apiMocks.listingsStatsUseQuery.mockReturnValue({}) + apiMocks.pcListingsStatsUseQuery.mockReturnValue({}) const { container } = render() @@ -95,34 +69,12 @@ describe('ApprovalCountBadge', () => { }) it('returns null for invalid href', () => { - mockUserQuery.mockReturnValue({ + apiMocks.userMeUseQuery.mockReturnValue({ data: { permissions: [PERMISSIONS.VIEW_STATISTICS] }, - isLoading: false, - isError: false, - error: null, - trpc: {}, - } as any) - mockGamesStatsQuery.mockReturnValue({ - data: undefined, - isLoading: false, - isError: false, - error: null, - trpc: {}, - } as any) - mockListingsStatsQuery.mockReturnValue({ - data: undefined, - isLoading: false, - isError: false, - error: null, - trpc: {}, - } as any) - mockPcListingsStatsQuery.mockReturnValue({ - data: undefined, - isLoading: false, - isError: false, - error: null, - trpc: {}, - } as any) + }) + apiMocks.gamesStatsUseQuery.mockReturnValue({}) + apiMocks.listingsStatsUseQuery.mockReturnValue({}) + apiMocks.pcListingsStatsUseQuery.mockReturnValue({}) render() expect(screen.queryByRole('status')).not.toBeInTheDocument() }) diff --git a/src/app/admin/components/ApprovalCountBadge.tsx b/src/app/admin/components/ApprovalCountBadge.tsx index 050f842ba..a60279f37 100644 --- a/src/app/admin/components/ApprovalCountBadge.tsx +++ b/src/app/admin/components/ApprovalCountBadge.tsx @@ -1,6 +1,7 @@ 'use client' import { Badge } from '@/components/ui' +import { CACHE_DURATIONS } from '@/data/constants' import { api } from '@/lib/api' import { cn } from '@/lib/utils' import { hasPermission, PERMISSIONS } from '@/utils/permission-system' @@ -29,26 +30,23 @@ export default function ApprovalCountBadge(props: Props) { const gameStatsQuery = api.games.stats.useQuery(undefined, { enabled: canViewStats && props.href === '/admin/games/approvals', - refetchInterval: 30000, - staleTime: 10000, - refetchOnMount: true, - refetchOnWindowFocus: true, + staleTime: CACHE_DURATIONS.SHORT, + refetchOnMount: false, + refetchOnWindowFocus: false, }) const listingStatsQuery = api.listings.stats.useQuery(undefined, { enabled: canViewStats && props.href === '/admin/approvals', - refetchInterval: 30000, - staleTime: 10000, - refetchOnMount: true, - refetchOnWindowFocus: true, + staleTime: CACHE_DURATIONS.SHORT, + refetchOnMount: false, + refetchOnWindowFocus: false, }) const pcListingStatsQuery = api.pcListings.stats.useQuery(undefined, { enabled: canViewStats && props.href === '/admin/pc-listing-approvals', - refetchInterval: 30000, - staleTime: 10000, - refetchOnMount: true, - refetchOnWindowFocus: true, + staleTime: CACHE_DURATIONS.SHORT, + refetchOnMount: false, + refetchOnWindowFocus: false, }) const statsMap = { diff --git a/src/app/admin/components/ImagePreviewModal.tsx b/src/app/admin/components/ImagePreviewModal.tsx index 84b3a182d..89a5926e9 100644 --- a/src/app/admin/components/ImagePreviewModal.tsx +++ b/src/app/admin/components/ImagePreviewModal.tsx @@ -1,9 +1,8 @@ 'use client' import { ExternalLink } from 'lucide-react' -import Image from 'next/image' import { useState } from 'react' -import { Modal, Button } from '@/components/ui' +import { Button, ImageRenderer, Modal } from '@/components/ui' import analytics from '@/lib/analytics' import { cn } from '@/lib/utils' import getImageUrl from '@/utils/getImageUrl' @@ -97,7 +96,7 @@ function ImagePreviewModal(props: Props) { {currentImageUrl && !failedImages.has(activeTab) ? (
- void + title: string + currentStatus: ApprovalStatus | null + newStatus: ApprovalStatus | null + overrideNotes: string + onOverrideNotesChange: (notes: string) => void + onSubmit: () => void + isLoading: boolean +} + +export function ApprovalStatusOverrideModal(props: Props) { + if (!props.currentStatus || !props.newStatus) return null + + const isReturningToPending = props.newStatus === ApprovalStatus.PENDING + + return ( + +
+

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

+ {isReturningToPending ? ( +

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

+ ) : ( +
+ + props.onOverrideNotesChange(ev.target.value)} + rows={4} + placeholder={`Notes for changing status to ${props.newStatus}...`} + className="w-full mt-1" + /> +
+ )} +
+ + +
+
+
+ ) +} diff --git a/src/app/admin/components/processed-reports/ProcessedReportsAdminPage.tsx b/src/app/admin/components/processed-reports/ProcessedReportsAdminPage.tsx new file mode 100644 index 000000000..a978e312e --- /dev/null +++ b/src/app/admin/components/processed-reports/ProcessedReportsAdminPage.tsx @@ -0,0 +1,223 @@ +'use client' + +import { useMemo, useState, type ChangeEvent } from 'react' +import { + AdminErrorState, + AdminPageLayout, + AdminSearchFilters, + AdminStatsDisplay, + AdminTableContainer, + AdminTableNoResults, +} from '@/components/admin' +import { + ColumnVisibilityControl, + DisplayToggleButton, + LoadingSpinner, + Pagination, + SelectInput, +} from '@/components/ui' +import storageKeys from '@/data/storageKeys' +import { + useColumnVisibility, + useEmulatorLogos, + useLocalStorage, + type ColumnDefinition, +} from '@/hooks' +import { hasPermission, PERMISSIONS } from '@/utils/permission-system' +import { hasRolePermission } from '@/utils/permissions' +import { ApprovalStatus, Role } from '@orm' +import { ApprovalStatusOverrideModal } from './ApprovalStatusOverrideModal' +import { ProcessedReportsTable } from './ProcessedReportsTable' +import { type ProcessedReportHardwareColumn, type ProcessedReportsAdminPageProps } from './types' + +const STATUS_FILTER_OPTIONS = [ + { id: 'all' as const, name: 'All Processed' }, + { id: ApprovalStatus.APPROVED, name: 'Approved' }, + { id: ApprovalStatus.REJECTED, name: 'Rejected' }, +] + +function buildColumns( + hardwareColumns: ProcessedReportHardwareColumn[], +): ColumnDefinition[] { + return [ + { key: 'game', label: 'Game', defaultVisible: true }, + { key: 'system', label: 'System', defaultVisible: true }, + ...hardwareColumns.map((column) => ({ + key: column.key, + label: column.label, + defaultVisible: column.defaultVisible, + })), + { key: 'emulator', label: 'Emulator', defaultVisible: true }, + { key: 'author', label: 'Author', defaultVisible: true }, + { key: 'status', label: 'Status', defaultVisible: true }, + { key: 'processedBy', label: 'Processed By', defaultVisible: true }, + { key: 'processedAt', label: 'Processed At', defaultVisible: true }, + { key: 'actions', label: 'Actions', alwaysVisible: true }, + ] +} + +export function ProcessedReportsAdminPage( + props: ProcessedReportsAdminPageProps, +) { + const columns = useMemo(() => buildColumns(props.hardwareColumns), [props.hardwareColumns]) + const columnVisibility = useColumnVisibility(columns, { storageKey: props.storageKey }) + const [showSystemIcons, setShowSystemIcons, isSystemIconsHydrated] = useLocalStorage( + storageKeys.showSystemIcons, + true, + ) + const emulatorLogos = useEmulatorLogos() + const [showOverrideModal, setShowOverrideModal] = useState(false) + const [selectedReport, setSelectedReport] = useState(null) + const [overrideNotes, setOverrideNotes] = useState('') + const [newStatusForOverride, setNewStatusForOverride] = useState(null) + + const handleFilterChange = (ev: ChangeEvent) => { + const value = ev.target.value as ApprovalStatus | 'all' + props.onFilterStatusChange(value === 'all' ? null : value) + props.table.setPage(1) + } + + const openOverrideModal = (report: TReport, targetStatus: ApprovalStatus) => { + setSelectedReport(report) + setNewStatusForOverride(targetStatus) + setOverrideNotes(props.accessors.getProcessedNotes(report) ?? '') + setShowOverrideModal(true) + } + + const closeOverrideModal = () => { + setShowOverrideModal(false) + setSelectedReport(null) + setOverrideNotes('') + setNewStatusForOverride(null) + } + + const handleOverrideSubmit = () => { + if (!selectedReport || !newStatusForOverride) return + + void props + .onOverrideStatus({ + report: selectedReport, + newStatus: newStatusForOverride, + overrideNotes: + newStatusForOverride === ApprovalStatus.PENDING ? undefined : overrideNotes || undefined, + }) + .then(closeOverrideModal) + .catch(() => undefined) + } + + if (props.errorMessage) { + return + } + + const canEditReports = hasPermission(props.currentUserPermissions, PERMISSIONS.EDIT_ANY_LISTING) + const canOverrideReports = hasRolePermission(props.currentUserRole, Role.SUPER_ADMIN) + const canViewUsers = hasPermission(props.currentUserPermissions, PERMISSIONS.MANAGE_USERS) + const selectedReportTitle = selectedReport ? props.accessors.getGameTitle(selectedReport) : '' + const overrideModalTitle = + newStatusForOverride === ApprovalStatus.PENDING + ? `Return to Pending Review: ${selectedReportTitle}` + : `Override Status: ${selectedReportTitle}` + + return ( + + setShowSystemIcons(!showSystemIcons)} + isHydrated={isSystemIconsHydrated} + logoLabel="Show System Icons" + nameLabel="Show System Names" + /> + + + + } + > + + + + table={props.table} + searchPlaceholder={props.searchPlaceholder} + onClear={() => props.onFilterStatusChange(null)} + > + + + + + {props.isReportsLoading ? ( + + ) : props.reports.length === 0 ? ( + + ) : ( + + )} + + + {props.pagination && props.pagination.pages > 1 && ( +
+ +
+ )} + + +
+ ) +} diff --git a/src/app/admin/components/processed-reports/ProcessedReportsTable.tsx b/src/app/admin/components/processed-reports/ProcessedReportsTable.tsx new file mode 100644 index 000000000..1ad1cf639 --- /dev/null +++ b/src/app/admin/components/processed-reports/ProcessedReportsTable.tsx @@ -0,0 +1,335 @@ +'use client' + +import { ExternalLink } from 'lucide-react' +import Link from 'next/link' +import { EmulatorIcon, SystemIcon } from '@/components/icons' +import { + ApproveButton, + EditButton, + LocalizedDate, + RejectButton, + SortableHeader, + Tooltip, + TooltipContent, + TooltipTrigger, + UndoButton, + ViewButton, + ViewUserButton, +} from '@/components/ui' +import { type UseColumnVisibilityReturn } from '@/hooks' +import { type UseAdminTableReturn } from '@/hooks/admin' +import analytics from '@/lib/analytics' +import { getApprovalStatusColor } from '@/utils/badge-colors' +import { ApprovalStatus } from '@orm' +import { type ProcessedReportAccessors, type ProcessedReportHardwareColumn } from './types' + +interface Props { + table: UseAdminTableReturn + reports: TReport[] + hardwareColumns: ProcessedReportHardwareColumn[] + columnVisibility: UseColumnVisibilityReturn + accessors: ProcessedReportAccessors + reportLabel: string + analyticsContext: string + showSystemIcons: boolean + isSystemIconsHydrated: boolean + showEmulatorLogos: boolean + isEmulatorLogosHydrated: boolean + canEditReports: boolean + canOverrideReports: boolean + canViewUsers: boolean + isOverridePending: boolean + onOpenOverrideModal: (report: TReport, targetStatus: ApprovalStatus) => void +} + +export function ProcessedReportsTable( + props: Props, +) { + return ( +
+
- {table.search - ? 'No brands found matching your search.' - : 'No brands found. Add your first brand.'} + +
+ + + {props.columnVisibility.isColumnVisible('game') && ( + + )} + {props.columnVisibility.isColumnVisible('system') && ( + + )} + {props.hardwareColumns.map( + (column) => + props.columnVisibility.isColumnVisible(column.key) && ( + + ), + )} + {props.columnVisibility.isColumnVisible('emulator') && ( + + )} + {props.columnVisibility.isColumnVisible('author') && ( + + )} + {props.columnVisibility.isColumnVisible('status') && ( + + )} + {props.columnVisibility.isColumnVisible('processedBy') && ( + + )} + {props.columnVisibility.isColumnVisible('processedAt') && ( + + )} + {props.columnVisibility.isColumnVisible('actions') && ( + + )} + + + + {props.reports.map((report) => ( + + ))} + +
+ Processed By + + Actions +
+
+ ) +} + +interface RowProps { + report: TReport + hardwareColumns: ProcessedReportHardwareColumn[] + columnVisibility: UseColumnVisibilityReturn + accessors: ProcessedReportAccessors + reportLabel: string + analyticsContext: string + showSystemIcons: boolean + isSystemIconsHydrated: boolean + showEmulatorLogos: boolean + isEmulatorLogosHydrated: boolean + canEditReports: boolean + canOverrideReports: boolean + canViewUsers: boolean + isOverridePending: boolean + onOpenOverrideModal: (report: TReport, targetStatus: ApprovalStatus) => void +} + +function ProcessedReportRow( + props: RowProps, +) { + const reportId = props.accessors.getId(props.report) + const reportHref = props.accessors.getViewHref(props.report) + const author = props.accessors.getAuthor(props.report) + const processedAt = props.accessors.getProcessedAt(props.report) + const status = props.accessors.getStatus(props.report) + const gameTitle = props.accessors.getGameTitle(props.report) + const systemName = props.accessors.getSystemName(props.report) + const systemKey = props.accessors.getSystemKey?.(props.report) + const emulatorName = props.accessors.getEmulatorName(props.report) + const emulatorLogo = props.accessors.getEmulatorLogo(props.report) + + return ( + + {props.columnVisibility.isColumnVisible('game') && ( + + { + analytics.contentDiscovery.externalLinkClicked({ + url: reportHref, + context: props.analyticsContext, + entityId: reportId, + }) + }} + > + {gameTitle} + + + + )} + {props.columnVisibility.isColumnVisible('system') && ( + + {props.isSystemIconsHydrated && props.showSystemIcons && systemKey ? ( +
+ + {systemName} +
+ ) : ( + systemName + )} + + )} + {props.hardwareColumns.map( + (column) => + props.columnVisibility.isColumnVisible(column.key) && ( + + {column.render(props.report)} + + ), + )} + {props.columnVisibility.isColumnVisible('emulator') && ( + + + + )} + {props.columnVisibility.isColumnVisible('author') && ( + + {author ? ( + + {author.name ?? 'N/A'} + + ) : ( + 'N/A' + )} + + )} + {props.columnVisibility.isColumnVisible('status') && ( + + + {status} + + + )} + {props.columnVisibility.isColumnVisible('processedBy') && ( + + {props.accessors.getProcessedByName(props.report) ?? 'N/A'} + + )} + {props.columnVisibility.isColumnVisible('processedAt') && ( + + {processedAt ? ( + + + + + + + + + + + ) : ( + 'N/A' + )} + + )} + {props.columnVisibility.isColumnVisible('actions') && ( + +
+ {props.canEditReports && ( + + )} + {props.canOverrideReports && ( + props.onOpenOverrideModal(props.report, ApprovalStatus.PENDING)} + disabled={props.isOverridePending} + /> + )} + {props.canOverrideReports && status === ApprovalStatus.APPROVED && ( + props.onOpenOverrideModal(props.report, ApprovalStatus.REJECTED)} + disabled={props.isOverridePending} + /> + )} + {props.canOverrideReports && status === ApprovalStatus.REJECTED && ( + props.onOpenOverrideModal(props.report, ApprovalStatus.APPROVED)} + disabled={props.isOverridePending} + /> + )} + {props.canViewUsers && author && ( + + )} + +
+ + )} + + ) +} diff --git a/src/app/admin/components/processed-reports/index.ts b/src/app/admin/components/processed-reports/index.ts new file mode 100644 index 000000000..e96fa1e0c --- /dev/null +++ b/src/app/admin/components/processed-reports/index.ts @@ -0,0 +1,2 @@ +export { ProcessedReportsAdminPage } from './ProcessedReportsAdminPage' +export type { ProcessedReportAccessors, ProcessedReportHardwareColumn } from './types' diff --git a/src/app/admin/components/processed-reports/types.ts b/src/app/admin/components/processed-reports/types.ts new file mode 100644 index 000000000..3010640f5 --- /dev/null +++ b/src/app/admin/components/processed-reports/types.ts @@ -0,0 +1,78 @@ +import type { UseAdminTableReturn } from '@/hooks/admin' +import type { ApprovalStatus, Role } from '@orm' +import type { ReactNode } from 'react' + +export interface ProcessedReportPagination { + page: number + pages: number + total: number + limit?: number +} + +export interface ProcessedReportStats { + total?: number + approved?: number + pending?: number + rejected?: number +} + +export interface ProcessedReportUser { + id: string + name?: string | null +} + +export interface ProcessedReportHardwareColumn { + key: string + label: string + sortField: TSortField + defaultVisible?: boolean + render: (report: TReport) => ReactNode +} + +export interface ProcessedReportAccessors { + getId: (report: TReport) => string + getGameTitle: (report: TReport) => string + getSystemName: (report: TReport) => string + getSystemKey?: (report: TReport) => string | null | undefined + getEmulatorName: (report: TReport) => string + getEmulatorLogo: (report: TReport) => string | null | undefined + getAuthor: (report: TReport) => ProcessedReportUser | null | undefined + getProcessedByName: (report: TReport) => string | null | undefined + getProcessedAt: (report: TReport) => Date | string | null | undefined + getProcessedNotes: (report: TReport) => string | null | undefined + getStatus: (report: TReport) => ApprovalStatus + getEditHref: (report: TReport) => string + getViewHref: (report: TReport) => string +} + +export interface ProcessedReportOverrideRequest { + report: TReport + newStatus: ApprovalStatus + overrideNotes?: string +} + +export interface ProcessedReportsAdminPageProps { + title: string + description: string + reportLabel: string + loadingText: string + errorMessage: string | null + searchPlaceholder: string + storageKey: string + analyticsContext: string + table: UseAdminTableReturn + reports: TReport[] + pagination?: ProcessedReportPagination + stats: ProcessedReportStats + isStatsLoading: boolean + isReportsLoading: boolean + currentUserPermissions?: string[] | null + currentUserRole?: Role | null + filterStatus: ApprovalStatus | null + hardwareColumns: ProcessedReportHardwareColumn[] + accessors: ProcessedReportAccessors + onFilterStatusChange: (status: ApprovalStatus | null) => void + onRetry: () => void + onOverrideStatus: (request: ProcessedReportOverrideRequest) => Promise + isOverridePending: boolean +} diff --git a/src/app/admin/config/routes.ts b/src/app/admin/config/routes.ts index 82afb1d43..d9f92b45b 100644 --- a/src/app/admin/config/routes.ts +++ b/src/app/admin/config/routes.ts @@ -45,6 +45,7 @@ export const ADMIN_ROUTES = { // Listings MANAGE_LISTINGS: '/admin/listings', PROCESSED_LISTINGS: '/admin/processed-listings', + PC_PROCESSED_LISTINGS: '/admin/pc-processed-listings', // Custom Fields FIELD_TEMPLATES: '/admin/custom-field-templates', diff --git a/src/app/admin/cpus/components/CpuModal.tsx b/src/app/admin/cpus/components/CpuModal.tsx deleted file mode 100644 index 062ae1737..000000000 --- a/src/app/admin/cpus/components/CpuModal.tsx +++ /dev/null @@ -1,148 +0,0 @@ -'use client' - -import { useState, useEffect, type SubmitEvent } 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 CpuData = RouterOutput['cpus']['get']['cpus'][number] - -interface Props { - isOpen: boolean - onClose: () => 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 2c5b7f61c..645bc9f41 100644 --- a/src/app/admin/cpus/page.tsx +++ b/src/app/admin/cpus/page.tsx @@ -1,319 +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 { - 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 }) - 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) - - // TODO: Temporary fix for brands query - // only keep 'Intel', 'AMD', and 'Apple' brands - const brands = (brandsQuery.data || []).filter((brand) => - ['intel', 'amd', 'apple'].includes(brand.name.toLowerCase()), - ) - - const invalidateCpuQueries = () => { - utils.cpus.get.invalidate().catch(console.error) - utils.cpus.options.invalidate().catch(console.error) - utils.cpus.stats.invalidate().catch(console.error) - } - - const openModal = (cpu?: CpuData) => { - setEditId(cpu?.id ?? null) - setCpuData(cpu ?? null) - 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)}`) - } - } - - // TODO: use AdminPageLayout like all the other admin pages - return ( -
-
-
-

CPUs

-

- Manage all CPU models for PC compatibility listings -

-
-
- - {canManageDevices && } -
-
- - - - - table={table} - searchPlaceholder="Search CPUs..." - onClear={() => table.setAdditionalParam('brandId', '')} - > - table.setAdditionalParam('brandId', value || '')} - items={[{ id: '', name: 'All Brands' }, ...brands]} - 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') && ( - - )} - - ))} - {!cpusQuery.isPending && cpusQuery.data?.cpus.length === 0 && ( - - - - )} - -
- 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} - /> - )} -
-
- {table.search || table.additionalParams.brandId - ? 'No CPUs found matching your search.' - : 'No CPUs found. Add your first CPU.'} -
- )} -
- - {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.test.tsx b/src/app/admin/custom-field-templates/page.test.tsx new file mode 100644 index 000000000..28eeffcf8 --- /dev/null +++ b/src/app/admin/custom-field-templates/page.test.tsx @@ -0,0 +1,140 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { CustomFieldType } from '@orm' +import CustomFieldTemplatesPage from './page' + +const apiMocks = vi.hoisted(() => ({ + customFieldTemplatesGetUseQuery: vi.fn(), + refetch: vi.fn(), +})) + +const navigationMocks = vi.hoisted(() => ({ + replace: vi.fn(), + searchParams: new URLSearchParams(), +})) + +vi.mock('next/navigation', () => ({ + useRouter: () => ({ + replace: navigationMocks.replace, + }), + useSearchParams: () => navigationMocks.searchParams, +})) + +vi.mock('@/lib/api', () => ({ + api: { + customFieldTemplates: { + get: { + useQuery: apiMocks.customFieldTemplatesGetUseQuery, + }, + }, + }, +})) + +interface MockTemplate { + id: string + name: string +} + +interface MockCustomFieldTemplateListProps { + templates: MockTemplate[] +} + +vi.mock('./components/CustomFieldTemplateList', () => ({ + default: (props: MockCustomFieldTemplateListProps) => ( +
+ {props.templates.map((template) => ( +
{template.name}
+ ))} +
+ ), +})) + +vi.mock('./components/CustomFieldTemplateFormModal', () => ({ + default: () =>
, +})) + +const templates = [ + { + id: 'template-performance', + name: 'Performance Template', + description: 'Emulator performance settings', + createdAt: new Date('2024-01-01T00:00:00.000Z'), + updatedAt: new Date('2024-01-01T00:00:00.000Z'), + fields: [ + { + id: 'field-frame-pacing', + name: 'framePacing', + label: 'Frame pacing', + type: CustomFieldType.TEXT, + options: null, + isRequired: false, + displayOrder: 0, + }, + ], + }, + { + id: 'template-controls', + name: 'Controls Template', + description: 'Input mapping defaults', + createdAt: new Date('2024-01-02T00:00:00.000Z'), + updatedAt: new Date('2024-01-02T00:00:00.000Z'), + fields: [ + { + id: 'field-layout', + name: 'controllerLayout', + label: 'Controller layout', + type: CustomFieldType.TEXT, + options: null, + isRequired: false, + displayOrder: 0, + }, + ], + }, +] + +describe('CustomFieldTemplatesPage', () => { + beforeEach(() => { + vi.clearAllMocks() + navigationMocks.searchParams = new URLSearchParams() + window.history.replaceState(null, '', '/admin/custom-field-templates') + apiMocks.customFieldTemplatesGetUseQuery.mockReturnValue({ + data: templates, + isPending: false, + error: null, + refetch: apiMocks.refetch, + }) + }) + + it('renders AdminSearchFilters and filters templates by field labels', () => { + render() + + fireEvent.change(screen.getByPlaceholderText('Search templates...'), { + target: { value: 'frame' }, + }) + + expect(screen.getByText('Performance Template')).toBeInTheDocument() + expect(screen.queryByText('Controls Template')).not.toBeInTheDocument() + }) + + it('filters templates by field names', () => { + render() + + fireEvent.change(screen.getByPlaceholderText('Search templates...'), { + target: { value: 'controllerlayout' }, + }) + + expect(screen.getByText('Controls Template')).toBeInTheDocument() + expect(screen.queryByText('Performance Template')).not.toBeInTheDocument() + }) + + it('shows a search-specific empty state when no templates match', () => { + render() + + fireEvent.change(screen.getByPlaceholderText('Search templates...'), { + target: { value: 'battery' }, + }) + + expect(screen.getByText('No custom field templates match your search.')).toBeInTheDocument() + expect(screen.queryByTestId('template-list')).not.toBeInTheDocument() + }) +}) diff --git a/src/app/admin/custom-field-templates/page.tsx b/src/app/admin/custom-field-templates/page.tsx index 12d84cb49..b874bd77d 100644 --- a/src/app/admin/custom-field-templates/page.tsx +++ b/src/app/admin/custom-field-templates/page.tsx @@ -1,23 +1,53 @@ 'use client' import { PlusCircle } from 'lucide-react' -import { useState } from 'react' +import { useMemo, useState } from 'react' import { AdminPageLayout, - // AdminSearchFilters, + AdminSearchFilters, AdminStatsDisplay, + 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' import CustomFieldTemplateList from './components/CustomFieldTemplateList' +type CustomFieldTemplate = RouterOutput['customFieldTemplates']['get'][number] +type CustomFieldTemplateSortField = 'name' + +const EMPTY_TEMPLATES: CustomFieldTemplate[] = [] + +function customFieldTemplateMatchesSearch(template: CustomFieldTemplate, searchTerm: string) { + if (!searchTerm) return true + + const searchableValues = [ + template.name, + template.description ?? '', + ...template.fields.flatMap((field) => [field.name, field.label]), + ] + + return searchableValues.some((value) => value.toLowerCase().includes(searchTerm)) +} + function CustomFieldTemplatesPage() { - const [searchQuery, _setSearchQuery] = useState('') + const table = useAdminTable() const [isFormModalOpen, setIsFormModalOpen] = useState(false) const [editingTemplateId, setEditingTemplateId] = useState(null) const customFieldTemplatesQuery = api.customFieldTemplates.get.useQuery() + const templates = customFieldTemplatesQuery.data ?? EMPTY_TEMPLATES + const totalTemplates = templates.length + const templatesWithFields = templates.filter((t) => t.fields.length > 0).length + const templatesWithoutFields = totalTemplates - templatesWithFields + const searchTerm = table.search.trim().toLowerCase() + const filteredTemplates = useMemo( + () => templates.filter((template) => customFieldTemplateMatchesSearch(template, searchTerm)), + [templates, searchTerm], + ) + const hasActiveSearch = searchTerm.length > 0 function handleOpenCreateModal() { setEditingTemplateId(null) @@ -53,11 +83,6 @@ function CustomFieldTemplatesPage() { ) } - const templates = customFieldTemplatesQuery.data ?? [] - const totalTemplates = templates.length - const templatesWithFields = templates.filter((t) => t.fields.length > 0).length - const templatesWithoutFields = totalTemplates - templatesWithFields - return ( - {/*TODO: fix this, AdminSearchFilters requires a table property, we need to convert this component to work like the other admin pages*/} - {/* setSearchQuery('')}*/} - {/*/>*/} + + table={table} + searchPlaceholder="Search templates..." + /> - {templates.length > 0 ? ( + {filteredTemplates.length > 0 ? ( - template.name.toLowerCase().includes(searchQuery.trim().toLowerCase()), - )} + templates={filteredTemplates} onEdit={handleOpenEditModal} onDeleteSuccess={customFieldTemplatesQuery.refetch} /> ) : ( -
-

- No custom field templates created yet. -

-

- Create your first template to get started. -

- -
+ + Create Your First Template + + ) : undefined + } + /> )} {/* Quick Navigation - Collapsible */} - + {/* Show error banner if API call failed */} diff --git a/src/app/admin/dashboard/components/ActivityCard/ReportActivityItem.tsx b/src/app/admin/dashboard/components/ActivityCard/ReportActivityItem.tsx index 4460bd752..fc6398b3b 100644 --- a/src/app/admin/dashboard/components/ActivityCard/ReportActivityItem.tsx +++ b/src/app/admin/dashboard/components/ActivityCard/ReportActivityItem.tsx @@ -11,8 +11,8 @@ interface Props { export function ReportActivityItem(props: Props) { const href = props.report.type === 'listing' - ? `/admin/reports?listing=${props.report.targetId}` - : `/admin/reports?pcListing=${props.report.targetId}` + ? `/listings/${props.report.targetId}` + : `/pc-listings/${props.report.targetId}` return (
diff --git a/src/app/admin/data.ts b/src/app/admin/data.ts index 7f07ab163..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, @@ -136,9 +136,15 @@ export const superAdminNavItems: AdminNavItem[] = [ }, { href: ADMIN_ROUTES.PROCESSED_LISTINGS, - label: 'Processed Listings', + label: 'Processed Reports', exact: true, - description: 'View all processed listings.', + description: 'View approved and rejected handheld reports.', + }, + { + href: ADMIN_ROUTES.PC_PROCESSED_LISTINGS, + label: 'PC Processed Reports', + exact: true, + description: 'View approved and rejected PC compatibility reports.', }, { href: ADMIN_ROUTES.REPORTS, @@ -231,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 dd3834741..9b8a1e786 100644 --- a/src/app/admin/devices/page.tsx +++ b/src/app/admin/devices/page.tsx @@ -2,12 +2,12 @@ import { useState } from 'react' import { isEmpty } from 'remeda' -import { useAdminTable } from '@/app/admin/hooks' import { AdminTableContainer, AdminSearchFilters, AdminStatsDisplay, AdminPageLayout, + AdminTableNoResults, } from '@/components/admin' import { Button, @@ -23,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' @@ -280,13 +281,12 @@ function AdminDevicesPage() { ))} {!devicesQuery.isPending && devicesQuery.data?.devices.length === 0 && ( - - {table.search || table.additionalParams.brandId - ? 'No devices found matching your search.' - : 'No devices found. Add your first device.'} + + )} 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} ) : filteredGames.length === 0 ? ( -
-

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

-
+ ) : ( <>
@@ -428,7 +427,7 @@ function GameApprovalsPage() { className="group relative block" >
- {game.title}
) : gamesQuery.data?.games.length === 0 ? ( -
-

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

-
+ ) : ( <>
@@ -369,7 +367,7 @@ function AdminGamesPage() { onClick={() => handleImageClick(game)} className="group relative block" > - {game.title} - {/* Image indicators */}
@@ -523,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/components/GpuViewModal.tsx b/src/app/admin/gpus/components/GpuViewModal.tsx deleted file mode 100644 index d676ebab0..000000000 --- a/src/app/admin/gpus/components/GpuViewModal.tsx +++ /dev/null @@ -1,48 +0,0 @@ -'use client' - -import { Modal, InputPlaceholder } from '@/components/ui' -import { type RouterOutput } from '@/types/trpc' - -type GpuData = RouterOutput['gpus']['get']['gpus'][number] - -interface Props { - isOpen: boolean - onClose: () => void - gpuData: GpuData | null -} - -function GpuViewModal(props: Props) { - if (!props.gpuData) return null - - return ( - -
-
- - - - - {props.gpuData._count && ( - - )} -
- -
- {/* TODO: Use the Button component? */} - -
-
-
- ) -} - -export default GpuViewModal diff --git a/src/app/admin/gpus/page.tsx b/src/app/admin/gpus/page.tsx index 6fb4bc8f2..c631f80ca 100644 --- a/src/app/admin/gpus/page.tsx +++ b/src/app/admin/gpus/page.tsx @@ -1,303 +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 }) - 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) - - // TODO: Temporary fix for brands query - // only keep 'Intel', 'AMD' and 'NVIDIA' brands - const brands = (brandsQuery.data || []).filter((brand) => - ['intel', 'amd', 'nvidia'].includes(brand.name.toLowerCase()), - ) - - const invalidateGpuQueries = () => { - utils.gpus.get.invalidate().catch(console.error) - utils.gpus.options.invalidate().catch(console.error) - utils.gpus.stats.invalidate().catch(console.error) - } - - const openModal = (gpu?: GpuData) => { - setEditId(gpu?.id ?? null) - setGpuData(gpu ?? null) - 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' }, ...brands]} - 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 199830e93..be82c45c9 100644 --- a/src/app/admin/listings/page.tsx +++ b/src/app/admin/listings/page.tsx @@ -1,16 +1,14 @@ '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, AdminStatsDisplay, AdminSearchFilters, + AdminTableNoResults, } from '@/components/admin' import { EmulatorIcon, SystemIcon } from '@/components/icons' import { @@ -21,6 +19,7 @@ import { DisplayToggleButton, Dropdown, EditButton, + ImageRenderer, LoadingSpinner, Pagination, SortableHeader, @@ -35,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' @@ -366,14 +366,17 @@ function AdminListingsPage() { ) : listings.length === 0 ? ( - -
-

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

-
+ + ) : ( @@ -386,7 +389,7 @@ function AdminListingsPage() {
- {listing.game.title}({ - defaultLimit: 20, defaultSortField: 'createdAt', defaultSortDirection: 'asc', }) @@ -133,7 +135,7 @@ function PcListingApprovalsPage() { const gameStatsQuery = api.games.stats.useQuery() const pcListingsStatsQuery = api.pcListings.stats.useQuery(undefined, { - refetchInterval: 30000, + refetchInterval: POLLING_INTERVALS.SHORT, }) const approvalModal = useCompatibilityReportReviewDecisionModal() @@ -568,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 new file mode 100644 index 000000000..88ad69622 --- /dev/null +++ b/src/app/admin/pc-processed-listings/page.tsx @@ -0,0 +1,173 @@ +'use client' + +import { useState } from 'react' +import { + type ProcessedReportAccessors, + ProcessedReportsAdminPage, + type ProcessedReportHardwareColumn, +} from '@/app/admin/components/processed-reports' +import storageKeys from '@/data/storageKeys' +import { getCpuLabel } from '@/features/hardware/cpu/shared/cpu-format' +import { getGpuLabel } from '@/features/hardware/gpu/shared/gpu-format' +import { useAdminTable } from '@/hooks/admin' +import { api } from '@/lib/api' +import { logger } from '@/lib/logger' +import toast from '@/lib/toast' +import { type RouterInput, type RouterOutput } from '@/types/trpc' +import getErrorMessage from '@/utils/getErrorMessage' +import { ApprovalStatus } from '@orm' + +type ProcessedPcListing = RouterOutput['pcListings']['getProcessed']['pcListings'][number] +type ProcessedPcListingSortField = + | 'processedAt' + | 'createdAt' + | 'status' + | 'game.title' + | 'game.system.name' + | 'cpu' + | 'gpu' + | 'emulator.name' + | 'author.name' + +function getProcessedGpuLabel(listing: ProcessedPcListing): string { + return listing.gpu ? getGpuLabel(listing.gpu) : 'Integrated / N/A' +} + +const PC_HARDWARE_COLUMNS: ProcessedReportHardwareColumn< + ProcessedPcListing, + ProcessedPcListingSortField +>[] = [ + { + key: 'cpu', + label: 'CPU', + sortField: 'cpu', + defaultVisible: true, + render: (listing) => getCpuLabel(listing.cpu), + }, + { + key: 'gpu', + label: 'GPU', + sortField: 'gpu', + defaultVisible: true, + render: getProcessedGpuLabel, + }, +] + +const PC_REPORT_ACCESSORS: ProcessedReportAccessors = { + getId: (listing) => listing.id, + getGameTitle: (listing) => listing.game.title, + getSystemName: (listing) => listing.game.system.name, + getSystemKey: (listing) => listing.game.system.key, + getEmulatorName: (listing) => listing.emulator.name, + getEmulatorLogo: (listing) => listing.emulator.logo, + getAuthor: (listing) => listing.author, + getProcessedByName: (listing) => listing.processedByUser?.name, + getProcessedAt: (listing) => listing.processedAt, + getProcessedNotes: (listing) => listing.processedNotes, + getStatus: (listing) => listing.status, + getEditHref: (listing) => `/admin/pc-listings/${listing.id}/edit`, + getViewHref: (listing) => `/pc-listings/${listing.id}`, +} + +function PcProcessedListingsPage() { + const table = useAdminTable({ + defaultLimit: 20, + defaultSortField: 'processedAt', + defaultSortDirection: 'desc', + }) + + const [filterStatus, setFilterStatus] = useState(null) + const currentUserQuery = api.users.me.useQuery() + const pcListingsStatsQuery = api.pcListings.stats.useQuery() + const processedPcListingsQuery = api.pcListings.getProcessed.useQuery({ + page: table.page, + limit: table.limit, + filterStatus: filterStatus ?? null, + search: table.debouncedSearch || null, + sortField: table.sortField ?? null, + sortDirection: table.sortDirection ?? null, + }) + + const utils = api.useUtils() + const invalidateAdminPcListingViews = async () => { + await Promise.all([ + utils.pcListings.getProcessed.invalidate(), + utils.pcListings.pending.invalidate(), + utils.pcListings.get.invalidate(), + utils.pcListings.stats.invalidate(), + ]) + } + + const overrideMutation = api.pcListings.overrideStatus.useMutation({ + onSuccess: async () => { + toast.success('PC report status updated.') + await invalidateAdminPcListingViews() + }, + onError: (err) => { + logger.error('Failed to override PC report status:', err) + toast.error(`Failed to override PC report status: ${getErrorMessage(err)}`) + }, + }) + + const resetToPendingMutation = api.pcListings.resetToPending.useMutation({ + onSuccess: async () => { + toast.success('PC report returned to pending review.') + await invalidateAdminPcListingViews() + }, + onError: (err) => { + logger.error('Failed to return PC report to pending review:', err) + toast.error(`Failed to return PC report to pending review: ${getErrorMessage(err)}`) + }, + }) + + const processedPcListings = processedPcListingsQuery.data?.pcListings ?? [] + + return ( + + title="PC Processed Reports" + description="Review approved and rejected PC compatibility reports. SUPER_ADMINs can override these decisions." + reportLabel="PC Compatibility Report" + loadingText="Loading processed PC reports..." + errorMessage={ + processedPcListingsQuery.error + ? `Error loading processed PC reports: ${processedPcListingsQuery.error.message}` + : null + } + searchPlaceholder="Search by game, system, CPU, GPU, author, emulator, or notes..." + storageKey={storageKeys.columnVisibility.adminPcProcessedListings} + analyticsContext="admin_processed_pc_reports_view" + table={table} + reports={processedPcListings} + pagination={processedPcListingsQuery.data?.pagination} + stats={pcListingsStatsQuery.data ?? {}} + isStatsLoading={pcListingsStatsQuery.isPending} + isReportsLoading={processedPcListingsQuery.isPending} + currentUserPermissions={currentUserQuery.data?.permissions} + currentUserRole={currentUserQuery.data?.role} + filterStatus={filterStatus} + hardwareColumns={PC_HARDWARE_COLUMNS} + accessors={PC_REPORT_ACCESSORS} + onFilterStatusChange={setFilterStatus} + onRetry={() => { + void processedPcListingsQuery.refetch() + }} + onOverrideStatus={async (request) => { + if (request.newStatus === ApprovalStatus.PENDING) { + await resetToPendingMutation.mutateAsync({ + pcListingId: request.report.id, + } satisfies RouterInput['pcListings']['resetToPending']) + return + } + + await overrideMutation.mutateAsync({ + pcListingId: request.report.id, + newStatus: request.newStatus, + overrideNotes: request.overrideNotes, + } satisfies RouterInput['pcListings']['overrideStatus']) + }} + isOverridePending={overrideMutation.isPending || resetToPendingMutation.isPending} + /> + ) +} + +export default PcProcessedListingsPage diff --git a/src/app/admin/performance/components/ReplacementSelectionModal.tsx b/src/app/admin/performance/components/ReplacementSelectionModal.tsx index 58a3ebf57..d6aea305a 100644 --- a/src/app/admin/performance/components/ReplacementSelectionModal.tsx +++ b/src/app/admin/performance/components/ReplacementSelectionModal.tsx @@ -33,15 +33,14 @@ function ReplacementSelectionModal(props: Props) { ) const handleDelete = async () => { - if (!props.scaleToDelete || !selectedReplacementId) return + if (!props.scaleToDelete || selectedReplacementId === null) return setError('') try { - // For now, we'll use the regular delete since the replacement functionality - // isn't implemented in the backend yet. This is marked as TODO. await deletePerformanceScale.mutateAsync({ id: props.scaleToDelete.id, + replacementId: selectedReplacementId, } satisfies RouterInput['performanceScales']['delete']) } catch (err) { setError(getErrorMessage(err, 'Failed to delete performance scale.')) @@ -96,8 +95,10 @@ function ReplacementSelectionModal(props: Props) { props.setOverrideNotes(ev.target.value)} - rows={4} - placeholder={`Notes for changing status to ${props.newStatus}...`} - className="w-full mt-1" - /> -
-
- - -
-
- - ) -} - -export default OverrideStatusModal diff --git a/src/app/admin/processed-listings/page.tsx b/src/app/admin/processed-listings/page.tsx index fb3b6dc44..971bddaad 100644 --- a/src/app/admin/processed-listings/page.tsx +++ b/src/app/admin/processed-listings/page.tsx @@ -1,348 +1,158 @@ 'use client' -import { ExternalLink } from 'lucide-react' -import Link from 'next/link' -import { useState, type ChangeEvent } from 'react' -import { useAdminTable } from '@/app/admin/hooks' +import { useState } from 'react' import { - AdminPageLayout, - AdminSearchFilters, - AdminStatsDisplay, - AdminTableContainer, - AdminTableNoResults, -} from '@/components/admin' -import { - ApproveButton, - ColumnVisibilityControl, - EditButton, - LoadingSpinner, - Pagination, - RejectButton, - SelectInput, - LocalizedDate, - UndoButton, -} from '@/components/ui' + type ProcessedReportAccessors, + ProcessedReportsAdminPage, + type ProcessedReportHardwareColumn, +} from '@/app/admin/components/processed-reports' import storageKeys from '@/data/storageKeys' -import { useColumnVisibility, type ColumnDefinition } from '@/hooks' -import analytics from '@/lib/analytics' +import { useAdminTable } from '@/hooks/admin' import { api } from '@/lib/api' +import { logger } from '@/lib/logger' import toast from '@/lib/toast' -import { type RouterOutput, type RouterInput } from '@/types/trpc' -import { getApprovalStatusColor } from '@/utils/badge-colors' +import { type RouterInput, type RouterOutput } from '@/types/trpc' import getErrorMessage from '@/utils/getErrorMessage' -import { hasPermission, PERMISSIONS } from '@/utils/permission-system' import { ApprovalStatus } from '@orm' -import OverrideStatusModal from './components/OverrideStatusModal' type ProcessedListing = RouterOutput['listings']['getProcessed']['listings'][number] - -const statusOptions = [ - { id: 'all' as const, name: 'All Processed' }, - { id: ApprovalStatus.APPROVED, name: 'Approved' }, - { id: ApprovalStatus.PENDING, name: 'Pending' }, - { id: ApprovalStatus.REJECTED, name: 'Rejected' }, -] - -const PROCESSED_LISTINGS_COLUMNS: ColumnDefinition[] = [ - { key: 'game', label: 'Game / System', defaultVisible: true }, - { key: 'author', label: 'Author', defaultVisible: true }, - { key: 'status', label: 'Status', defaultVisible: true }, - { key: 'processedBy', label: 'Processed By (Admin)', defaultVisible: true }, - { key: 'processedAt', label: 'Processed At', defaultVisible: true }, - { key: 'actions', label: 'Actions', alwaysVisible: true }, +type ProcessedListingSortField = + | 'processedAt' + | 'createdAt' + | 'status' + | 'game.title' + | 'game.system.name' + | 'device' + | 'emulator.name' + | 'author.name' + +const HANDHELD_HARDWARE_COLUMNS: ProcessedReportHardwareColumn< + ProcessedListing, + ProcessedListingSortField +>[] = [ + { + key: 'device', + label: 'Device', + sortField: 'device', + defaultVisible: true, + render: (listing) => `${listing.device.brand.name} ${listing.device.modelName}`, + }, ] -type ProcessedListingSortField = 'createdAt' | 'status' | 'game.title' +const HANDHELD_REPORT_ACCESSORS: ProcessedReportAccessors = { + getId: (listing) => listing.id, + getGameTitle: (listing) => listing.game.title, + getSystemName: (listing) => listing.game.system.name, + getSystemKey: (listing) => listing.game.system.key, + getEmulatorName: (listing) => listing.emulator.name, + getEmulatorLogo: (listing) => listing.emulator.logo, + getAuthor: (listing) => listing.author, + getProcessedByName: (listing) => listing.processedByUser?.name, + getProcessedAt: (listing) => listing.processedAt, + getProcessedNotes: (listing) => listing.processedNotes, + getStatus: (listing) => listing.status, + getEditHref: (listing) => `/admin/listings/${listing.id}/edit`, + getViewHref: (listing) => `/listings/${listing.id}`, +} function ProcessedListingsPage() { const table = useAdminTable({ defaultLimit: 20, - defaultSortField: 'createdAt', + defaultSortField: 'processedAt', defaultSortDirection: 'desc', }) - const columnVisibility = useColumnVisibility(PROCESSED_LISTINGS_COLUMNS, { - storageKey: storageKeys.columnVisibility.adminProcessedListings, - }) - const [filterStatus, setFilterStatus] = useState(null) - + const currentUserQuery = api.users.me.useQuery() const listingStatsQuery = api.listings.stats.useQuery() const processedListingsQuery = api.listings.getProcessed.useQuery({ page: table.page, limit: table.limit, - filterStatus: filterStatus ?? undefined, - search: table.debouncedSearch || undefined, + filterStatus: filterStatus ?? null, + search: table.debouncedSearch || null, + sortField: table.sortField ?? null, + sortDirection: table.sortDirection ?? null, }) - const processedListings = processedListingsQuery.data?.listings ?? [] - const paginationData = processedListingsQuery.data?.pagination - const userQuery = api.users.me.useQuery() - - const [showOverrideModal, setShowOverrideModal] = useState(false) - const [selectedListingForOverride, setSelectedListingForOverride] = - useState(null) - const [overrideNotes, setOverrideNotes] = useState('') - const [newStatusForOverride, setNewStatusForOverride] = useState(null) - const utils = api.useUtils() + const invalidateAdminListingViews = async () => { + await Promise.all([ + utils.listings.getProcessed.invalidate(), + utils.listings.getPending.invalidate(), + utils.listings.get.invalidate(), + utils.listings.stats.invalidate(), + ]) + } + const overrideMutation = api.listings.overrideApprovalStatus.useMutation({ onSuccess: async () => { - toast.success('Listing status overridden successfully!') - await utils.listings.getProcessed.invalidate() - await utils.listings.getPending.invalidate() - await utils.listings.get.invalidate() - closeOverrideModal() + toast.success('Handheld report status updated.') + await invalidateAdminListingViews() }, onError: (err) => { - console.error('Failed to override status:', err) - toast.error(`Failed to override status: ${getErrorMessage(err)}`) + logger.error('Failed to override handheld report status:', err) + toast.error(`Failed to override handheld report status: ${getErrorMessage(err)}`) }, }) - const openOverrideModal = (listing: ProcessedListing, targetStatus: ApprovalStatus) => { - setSelectedListingForOverride(listing) - setNewStatusForOverride(targetStatus) - setOverrideNotes(listing.processedNotes ?? '') - setShowOverrideModal(true) - } - - const closeOverrideModal = () => { - setShowOverrideModal(false) - setSelectedListingForOverride(null) - setOverrideNotes('') - setNewStatusForOverride(null) - } - - const handleOverrideSubmit = () => { - if (selectedListingForOverride && newStatusForOverride) { - overrideMutation.mutate({ - listingId: selectedListingForOverride.id, - newStatus: newStatusForOverride, - overrideNotes: overrideNotes ?? undefined, - } satisfies RouterInput['listings']['overrideApprovalStatus']) - } - } - - const handleFilterChange = (ev: ChangeEvent) => { - const value = ev.target.value as ApprovalStatus | 'all' - setFilterStatus(value === 'all' ? null : value) - table.setPage(1) - } + const resetToPendingMutation = api.listings.resetToPending.useMutation({ + onSuccess: async () => { + toast.success('Handheld report returned to pending review.') + await invalidateAdminListingViews() + }, + onError: (err) => { + logger.error('Failed to return handheld report to pending review:', err) + toast.error(`Failed to return handheld report to pending review: ${getErrorMessage(err)}`) + }, + }) - if (processedListingsQuery.error) { - return ( -
- Error loading processed listings: {processedListingsQuery.error.message} -
- ) - } + const processedListings = processedListingsQuery.data?.listings ?? [] return ( - + + title="Handheld Processed Reports" + description="Review approved and rejected handheld compatibility reports. SUPER_ADMINs can override these decisions." + reportLabel="Handheld Compatibility Report" + loadingText="Loading processed handheld reports..." + errorMessage={ + processedListingsQuery.error + ? `Error loading processed handheld reports: ${processedListingsQuery.error.message}` + : null } - > - - - - table={table} - searchPlaceholder="Search by game name, author, or notes..." - onClear={() => setFilterStatus(null)} - > - - - - - {processedListingsQuery.isPending ? ( - - ) : processedListings.length === 0 ? ( - - ) : ( - - - - {columnVisibility.isColumnVisible('game') && ( - - )} - {columnVisibility.isColumnVisible('author') && ( - - )} - {columnVisibility.isColumnVisible('status') && ( - - )} - {columnVisibility.isColumnVisible('processedBy') && ( - - )} - {columnVisibility.isColumnVisible('processedAt') && ( - - )} - {columnVisibility.isColumnVisible('actions') && ( - - )} - - - - {processedListings.map((listing) => ( - - {columnVisibility.isColumnVisible('game') && ( - - )} - {columnVisibility.isColumnVisible('author') && ( - - )} - {columnVisibility.isColumnVisible('status') && ( - - )} - {columnVisibility.isColumnVisible('processedBy') && ( - - )} - {columnVisibility.isColumnVisible('processedAt') && ( - - )} - {columnVisibility.isColumnVisible('actions') && ( - - )} - - ))} - -
- Game / System - - Author - - Status - - Processed By (Admin) - - Processed At - - Actions -
- { - analytics.contentDiscovery.externalLinkClicked({ - url: `/listings/${listing.id}`, - context: 'admin_processed_listings_view', - entityId: listing.id, - }) - }} - > - {listing.game.title} - - -
- {listing.game.system.name} -
-
- {listing.author?.name ?? 'N/A'} - - - {listing.status} - - - {listing.processedByUser?.name ?? 'N/A'} - - {listing.processedAt ? ( - - ) : ( - 'N/A' - )} - - {hasPermission(userQuery.data?.permissions, PERMISSIONS.EDIT_ANY_LISTING) && ( - - )} - {hasPermission(userQuery.data?.permissions, PERMISSIONS.APPROVE_LISTINGS) && ( - openOverrideModal(listing, ApprovalStatus.PENDING)} - /> - )} - {hasPermission(userQuery.data?.permissions, PERMISSIONS.APPROVE_LISTINGS) && - listing.status === ApprovalStatus.APPROVED && ( - openOverrideModal(listing, ApprovalStatus.REJECTED)} - /> - )} - {hasPermission(userQuery.data?.permissions, PERMISSIONS.APPROVE_LISTINGS) && - listing.status === ApprovalStatus.REJECTED && ( - openOverrideModal(listing, ApprovalStatus.APPROVED)} - /> - )} -
- )} -
- - {paginationData && paginationData.pages > 1 && ( - - )} - - -
+ searchPlaceholder="Search by game, system, device, author, emulator, or notes..." + storageKey={storageKeys.columnVisibility.adminProcessedListings} + analyticsContext="admin_processed_handheld_reports_view" + table={table} + reports={processedListings} + pagination={processedListingsQuery.data?.pagination} + stats={listingStatsQuery.data ?? {}} + isStatsLoading={listingStatsQuery.isPending} + isReportsLoading={processedListingsQuery.isPending} + currentUserPermissions={currentUserQuery.data?.permissions} + currentUserRole={currentUserQuery.data?.role} + filterStatus={filterStatus} + hardwareColumns={HANDHELD_HARDWARE_COLUMNS} + accessors={HANDHELD_REPORT_ACCESSORS} + onFilterStatusChange={setFilterStatus} + onRetry={() => { + void processedListingsQuery.refetch() + }} + onOverrideStatus={async (request) => { + if (request.newStatus === ApprovalStatus.PENDING) { + await resetToPendingMutation.mutateAsync({ + listingId: request.report.id, + } satisfies RouterInput['listings']['resetToPending']) + return + } + + await overrideMutation.mutateAsync({ + listingId: request.report.id, + newStatus: request.newStatus, + overrideNotes: request.overrideNotes, + } satisfies RouterInput['listings']['overrideApprovalStatus']) + }} + isOverridePending={overrideMutation.isPending || resetToPendingMutation.isPending} + /> ) } diff --git a/src/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 a40acba78..ca80f4066 100644 --- a/src/app/admin/reports/page.tsx +++ b/src/app/admin/reports/page.tsx @@ -2,12 +2,12 @@ import Link from 'next/link' import { useState } from 'react' -import { useAdminTable } from '@/app/admin/hooks' import { AdminPageLayout, AdminStatsDisplay, AdminSearchFilters, AdminTableContainer, + AdminTableNoResults, } from '@/components/admin' import { ColumnVisibilityControl, @@ -23,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 }, @@ -59,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 @@ -72,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() { @@ -115,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, @@ -132,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 activeStatsQuery = + selectedReportKind === 'handheld' ? listingReportsStatsQuery : pcReportsStatsQuery + const activeReportsQuery = + selectedReportKind === 'handheld' ? listingReportsQuery : pcReportsQuery + + const reports: AdminReportWithDetails[] = + selectedReportKind === 'handheld' + ? (listingReportsQuery.data?.reports.map(toHandheldAdminReport) ?? []) + : (pcReportsQuery.data?.reports.map(toPcAdminReport) ?? []) + const pagination = activeReportsQuery.data?.pagination - const reports = reportsQuery.data?.reports ?? [] - const pagination = reportsQuery.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 deleteReport = api.listingReports.delete.useMutation({ + 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!') - 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 updatePcListingStatus = api.pcListingReports.updateStatus.useMutation({ + onSuccess: () => { + toast.success('Report status updated successfully!') + invalidateReports() + }, + onError: (err) => { + toast.error(`Failed to update report status: ${getErrorMessage(err)}`) + }, + }) + + 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.`, @@ -175,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?', @@ -189,86 +250,111 @@ 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) + }} />
{reports.length === 0 ? ( -
-

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

-
+ ) : (
@@ -281,7 +367,7 @@ function AdminReportsPage() { )} {columnVisibility.isColumnVisible('listing') && ( )} {columnVisibility.isColumnVisible('reason') && ( @@ -346,16 +432,18 @@ function AdminReportsPage() { @@ -415,8 +503,8 @@ function AdminReportsPage() { handleMarkResolved(report)} title="Mark as Resolved" - isLoading={updateStatus.isPending} - disabled={updateStatus.isPending} + isLoading={isUpdateStatusPending} + disabled={isUpdateStatusPending} /> )} {hasPermission( @@ -435,8 +523,8 @@ function AdminReportsPage() { handleDelete(report)} title="Delete Report" - isLoading={deleteReport.isPending} - disabled={deleteReport.isPending} + isLoading={isDeletePending} + disabled={isDeletePending} /> )} @@ -461,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 20a72b5a1..9f1adf246 100644 --- a/src/app/admin/socs/page.tsx +++ b/src/app/admin/socs/page.tsx @@ -3,8 +3,8 @@ import { Cpu } from 'lucide-react' import { useState } from 'react' import { isEmpty } from 'remeda' -import { useAdminTable } from '@/app/admin/hooks' import { + AdminPageLayout, AdminTableContainer, AdminSearchFilters, AdminStatsDisplay, @@ -23,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' @@ -127,24 +128,17 @@ function AdminSoCsPage() { } } - // TODO: use AdminPageLayout like all the other admin pages return ( -
-
-
-

- System on Chips (SoCs) -

-

- Manage all processors and system on chips -

-
-
+ {canManageDevices && } -
-
- + + } + > -
+ ) } diff --git a/src/app/admin/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/title-id-tools/TitleIdTool.tsx b/src/app/admin/title-id-tools/TitleIdTool.tsx index 177f9c0cc..8c41a12da 100644 --- a/src/app/admin/title-id-tools/TitleIdTool.tsx +++ b/src/app/admin/title-id-tools/TitleIdTool.tsx @@ -1,11 +1,13 @@ 'use client' import { type FormEvent, useEffect, useMemo, useState } from 'react' +import { AdminTableNoResults } from '@/components/admin' import { Button } from '@/components/ui/Button' import { Card } from '@/components/ui/Card' import { Dropdown } from '@/components/ui/Dropdown' import { Input } from '@/components/ui/form/Input' import { LoadingSpinner } from '@/components/ui/LoadingSpinner' +import { CACHE_DURATIONS } from '@/data/constants' import { api } from '@/lib/api' import toast from '@/lib/toast' import { cn } from '@/lib/utils' @@ -15,7 +17,6 @@ import { type TitleIdProviderInfo, } from '@/schemas/titleId' import { formatters, getLocale } from '@/utils/date' -import { ms } from '@/utils/time' import { TitleIdBestMatch } from './components/TitleIdBestMatch' const EMPTY_PROVIDERS: TitleIdProviderInfo[] = [] @@ -49,7 +50,7 @@ function TitleIdTool() { { platformId: selectedProvider?.id ?? providers[0]?.id ?? TITLE_ID_PLATFORM_IDS[0] }, { enabled: statsQueryEnabled && Boolean(selectedProvider?.id), - staleTime: ms.minutes(15), + staleTime: CACHE_DURATIONS.LONG, }, ) @@ -227,13 +228,11 @@ function TitleIdTool() { ) : latestResults.length === 0 ? ( -
-

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

-
+ ) : (
{bestMatch && } diff --git a/src/app/admin/title-id-tools/components/BatchSteamLookup.tsx b/src/app/admin/title-id-tools/components/BatchSteamLookup.tsx index 99dac882c..5183c07fb 100644 --- a/src/app/admin/title-id-tools/components/BatchSteamLookup.tsx +++ b/src/app/admin/title-id-tools/components/BatchSteamLookup.tsx @@ -6,6 +6,7 @@ import { type FormEvent, useState, useMemo } from 'react' import { PrismLight as SyntaxHighlighter } from 'react-syntax-highlighter' import json from 'react-syntax-highlighter/dist/esm/languages/prism/json' import { solarizedDarkAtom, solarizedlight } from 'react-syntax-highlighter/dist/esm/styles/prism' +import { AdminTableNoResults } from '@/components/admin' import { Button } from '@/components/ui/Button' import { Card } from '@/components/ui/Card' import { Input } from '@/components/ui/form/Input' @@ -13,6 +14,11 @@ import { LoadingSpinner } from '@/components/ui/LoadingSpinner' import { api } from '@/lib/api' import toast from '@/lib/toast' import { cn } from '@/lib/utils' +import type { + BatchBySteamAppIdsResponse, + BatchGameResult, + MinimalGameResult, +} from '@/schemas/mobile' SyntaxHighlighter.registerLanguage('json', json) @@ -27,14 +33,52 @@ const SAMPLE_STEAM_APP_IDS = `220 80 240` -interface BatchResult { +type BatchResult = BatchBySteamAppIdsResponse['results'][number] + +interface BatchResultDisplay { steamAppId: string - game: { - id: string - title: string - _count: { listings: number } - } | null - matchStrategy: 'metadata' | 'exact' | 'normalized' | 'not_found' + title: string | null + found: boolean + matchStrategy: 'metadata' | 'exact' | 'normalized' | 'not_found' | 'minimal' + listingCount: number | null +} + +function isBatchGameResult(result: BatchResult): result is BatchGameResult { + return 'steamAppId' in result && typeof result.steamAppId === 'string' +} + +function isMinimalGameResult(result: BatchResult): result is MinimalGameResult { + return 'steam_app_id' in result && typeof result.steam_app_id === 'string' +} + +function toBatchResultDisplay(result: BatchResult): BatchResultDisplay { + if (isBatchGameResult(result)) { + return { + steamAppId: result.steamAppId, + title: result.game?.title ?? null, + found: result.game !== null, + matchStrategy: result.matchStrategy, + listingCount: result.game?._count.listings ?? null, + } + } + + if (!isMinimalGameResult(result)) { + return { + steamAppId: 'unknown', + title: null, + found: false, + matchStrategy: 'not_found', + listingCount: null, + } + } + + return { + steamAppId: result.steam_app_id, + title: result.title, + found: result.game_id !== null, + matchStrategy: 'minimal', + listingCount: result.listing ? 1 : null, + } } export function BatchSteamLookup() { @@ -53,7 +97,7 @@ export function BatchSteamLookup() { minimal?: boolean } | null>(null) - const batchLookupQuery = api.mobile.games.batchBySteamAppIds.useQuery( + const batchLookupQuery = api.titleIdTools.batchSteamAppIds.useQuery( queryInput ?? { steamAppIds: [] }, { enabled: queryInput !== null }, ) @@ -61,28 +105,7 @@ export function BatchSteamLookup() { const isLoading = batchLookupQuery.isFetching const responseData = batchLookupQuery.data - // Type guard for successful response - const isSuccessResponse = ( - data: unknown, - ): data is { - success: true - results: BatchResult[] - totalRequested: number - totalFound: number - totalNotFound: number - } => { - return ( - typeof data === 'object' && - data !== null && - 'success' in data && - data.success === true && - 'results' in data && - Array.isArray(data.results) - ) - } - - const isSuccess = isSuccessResponse(responseData) - const results = isSuccess ? responseData.results : [] + const results = responseData?.results ?? [] const parsedIds = useMemo(() => { return steamAppIds @@ -125,10 +148,14 @@ export function BatchSteamLookup() { const textResults = results .map((result) => { - if (!result.game) { - return `${result.steamAppId}: NOT FOUND` - } - return `${result.steamAppId}: ${result.game.title} (${result.matchStrategy}, ${result.game._count.listings} listings)` + const displayResult = toBatchResultDisplay(result) + if (!displayResult.found) return `${displayResult.steamAppId}: NOT FOUND` + + const listingSummary = + displayResult.listingCount === null + ? 'listings unavailable' + : `${displayResult.listingCount} listings` + return `${displayResult.steamAppId}: ${displayResult.title} (${displayResult.matchStrategy}, ${listingSummary})` }) .join('\n') @@ -252,7 +279,7 @@ export function BatchSteamLookup() {
- {isSuccess && responseData && ( + {responseData && (
@@ -293,13 +320,11 @@ export function BatchSteamLookup() {
) : results.length === 0 ? ( -
-

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

-
+ ) : (
@@ -331,10 +356,10 @@ export function BatchSteamLookup() {
{results.map((result, index) => { - const isFound = result.game !== null + const displayResult = toBatchResultDisplay(result) return ( - + diff --git a/src/app/admin/trust-logs/components/TrustStatsOverview.tsx b/src/app/admin/trust-logs/components/TrustStatsOverview.tsx deleted file mode 100644 index aa7b32ead..000000000 --- a/src/app/admin/trust-logs/components/TrustStatsOverview.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import { Shield, TrendingUp, Users } from 'lucide-react' -import type { RouterOutput } from '@/types/trpc' - -type TrustStats = NonNullable - -interface Props { - trustStatsData: TrustStats -} - -function TrustStatsOverview(props: Props) { - return ( -
-
-
-
- -
-
-

Total Actions

-

- {props.trustStatsData.totalActions} -

-
-
-
- -
-
-
- -
-
-

Total Users

-

- {props.trustStatsData.totalUsers} -

-
-
-
- -
-
-
- -
-
-

Trusted+ Users

-

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

-
-
-
-
- ) -} - -export default TrustStatsOverview diff --git a/src/app/admin/trust-logs/page.tsx b/src/app/admin/trust-logs/page.tsx index 58ea927ff..a36c40cd1 100644 --- a/src/app/admin/trust-logs/page.tsx +++ b/src/app/admin/trust-logs/page.tsx @@ -4,8 +4,13 @@ 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 { AdminTableContainer, AdminTableNoResults } from '@/components/admin' +import { + AdminErrorState, + AdminPageLayout, + AdminStatsDisplay, + AdminTableContainer, + AdminTableNoResults, +} from '@/components/admin' import { Button, Input, @@ -19,8 +24,8 @@ 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 { TrustStatsOverview } from '@/lib/dynamic-imports' import toast from '@/lib/toast' import { TRUST_ACTIONS } from '@/lib/trust/config' import { type RouterOutput } from '@/types/trpc' @@ -62,6 +67,11 @@ function AdminTrustLogsPage() { }) const trustStatsQuery = api.trust.getTrustStats.useQuery({}) + const trustedPlusUsers = trustStatsQuery.data + ? (trustStatsQuery.data.levelDistribution + ?.filter((level) => level.minScore >= 250) + .reduce((sum, level) => sum + level.count, 0) ?? 0) + : undefined const runMonthlyBonusMutation = api.trust.runMonthlyActiveBonus.useMutation({ onSuccess: (result) => { @@ -87,16 +97,13 @@ function AdminTrustLogsPage() { if (trustLogsQuery.error) { return ( -
-
-

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

- -
-
+ { + void trustLogsQuery.refetch() + }} + /> ) } @@ -111,17 +118,12 @@ function AdminTrustLogsPage() { : '-' } - // TODO: use AdminPageLayout like all the other admin pages return ( -
-
-
-

Trust System Logs

-

- Monitor and audit all trust score changes -

-
-
+
-
- - {/*TODO: check if we can use AdminStatsDisplay */} - {trustStatsQuery.data && } + + } + > + {/* Search and Filters */}
@@ -314,7 +334,7 @@ function AdminTrustLogsPage() { onPageChange={(newPage) => table.setPage(newPage)} /> )} -
+ ) } diff --git a/src/app/admin/user-bans/page.tsx b/src/app/admin/user-bans/page.tsx index d1a6e1729..864676f13 100644 --- a/src/app/admin/user-bans/page.tsx +++ b/src/app/admin/user-bans/page.tsx @@ -2,13 +2,13 @@ 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, AdminSearchFilters, AdminTableContainer, + AdminTableNoResults, } from '@/components/admin' import { Button, @@ -26,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' @@ -83,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, { @@ -245,13 +246,11 @@ function AdminUserBansPage() { {bans.length === 0 ? ( -
-

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

-
+ ) : (
- Listing + Report
- {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'}
{result.steamAppId} + {displayResult.steamAppId} + - {isFound && result.game ? ( - {result.game.title} + {displayResult.found && displayResult.title ? ( + {displayResult.title} ) : ( Not found @@ -356,26 +383,28 @@ export function BatchSteamLookup() { - {result.matchStrategy} + {displayResult.matchStrategy} - {isFound && result.game ? ( + {displayResult.found && displayResult.listingCount !== null ? ( - {result.game._count.listings} + {displayResult.listingCount} ) : ( - + - )}
@@ -417,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/components/UserBadgeModal.tsx b/src/app/admin/users/components/UserBadgeModal.tsx index 4e3081753..5f4878fc1 100644 --- a/src/app/admin/users/components/UserBadgeModal.tsx +++ b/src/app/admin/users/components/UserBadgeModal.tsx @@ -34,20 +34,16 @@ export default function UserBadgeModal(props: Props) { const [selectedBadgeId, setSelectedBadgeId] = useState(null) const [selectedColor, setSelectedColor] = useState('blue') - // Fetch all active badges const badgesQuery = api.badges.get.useQuery( - // TODO: Implement pagination if needed, probably not needed for badges { isActive: true, limit: 100 }, { enabled: props.isOpen }, ) - // Fetch user's current badges const userBadgesQuery = api.users.getUserById.useQuery( { userId: props.user?.id ?? '' }, { enabled: props.isOpen && Boolean(props.user?.id) }, ) - // Badge assignment mutations const assignBadgeMutation = api.badges.assignToUser.useMutation({ onSuccess: () => { toast.success('Badge assigned successfully') @@ -107,7 +103,6 @@ export default function UserBadgeModal(props: Props) {
- {/* Current Badges */}

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

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

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 24ab6cebc..88c67e48a 100644 --- a/src/app/admin/verified-developers/page.tsx +++ b/src/app/admin/verified-developers/page.tsx @@ -3,12 +3,12 @@ import { Shield, UserCheck } from 'lucide-react' import Image from 'next/image' import { useState } from 'react' -import { useAdminTable } from '@/app/admin/hooks' import { AdminPageLayout, AdminTableContainer, AdminStatsDisplay, AdminSearchFilters, + AdminTableNoResults, } from '@/components/admin' import { EmulatorIcon } from '@/components/icons' import { @@ -25,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' @@ -353,19 +354,15 @@ function AdminVerifiedDevelopersPage() { ))} {verifiedDevelopersQuery.data?.verifiedDevelopers.length === 0 && (

- )} 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/mobile/trpc/[trpc]/route.ts b/src/app/api/mobile/trpc/[trpc]/route.ts index 5d9b36963..ecfe2b4ca 100644 --- a/src/app/api/mobile/trpc/[trpc]/route.ts +++ b/src/app/api/mobile/trpc/[trpc]/route.ts @@ -3,18 +3,42 @@ import { connection, type NextRequest, NextResponse } from 'next/server' import { getCORSHeaders } from '@/lib/cors' import { createMobileTRPCFetchContext } from '@/server/api/mobileContext' import { mobileRouter } from '@/server/api/routers/mobile' +import { getTRPCResponseCacheHeaders, TRPC_PRIVATE_CACHE_CONTROL } from '@/server/api/trpc-cache' // Get CORS headers with additional tRPC headers function getTRPCCorsHeaders(request: NextRequest) { const baseHeaders = getCORSHeaders(request) return { ...baseHeaders, - 'Access-Control-Allow-Headers': 'Content-Type, Authorization, x-auth-token', - 'Access-Control-Expose-Headers': 'Content-Type', 'Access-Control-Max-Age': '86400', // 24 hours } } +function mergeVaryHeader(existing: string | null, next: string): string { + const values = new Set() + + for (const value of [existing, next]) { + if (!value) continue + + value + .split(',') + .map((entry) => entry.trim()) + .filter(Boolean) + .forEach((entry) => values.add(entry)) + } + + return [...values].join(', ') +} + +function setResponseHeader(response: Response, key: string, value: string) { + if (key.toLowerCase() === 'vary') { + response.headers.set(key, mergeVaryHeader(response.headers.get(key), value)) + return + } + + response.headers.set(key, value) +} + // Handle preflight OPTIONS requests export async function OPTIONS(request: NextRequest) { return new NextResponse(null, { @@ -50,16 +74,29 @@ const handler = async (req: NextRequest) => { console.error(`❌ Mobile tRPC failed on ${path ?? ''}: ${error.message}`) } : undefined, - responseMeta() { + responseMeta(opts) { return { - headers: corsHeaders, + headers: { + ...corsHeaders, + ...getTRPCResponseCacheHeaders({ + endpoint: 'mobile', + method: req.method, + type: opts.type, + info: opts.info, + hasErrors: opts.errors.length > 0, + eagerGeneration: opts.eagerGeneration, + session: opts.ctx?.session, + apiKey: opts.ctx?.apiKey, + headers: opts.ctx?.headers, + }), + }, } }, }) // Ensure CORS headers are set on the response Object.entries(corsHeaders).forEach(([key, value]) => { - response.headers.set(key, value) + setResponseHeader(response, key, value) }) return response @@ -82,6 +119,7 @@ const handler = async (req: NextRequest) => { status: 500, headers: { 'Content-Type': 'application/json', + 'Cache-Control': TRPC_PRIVATE_CACHE_CONTROL, ...corsHeaders, }, }, diff --git a/src/app/api/notifications/stream/route.ts b/src/app/api/notifications/stream/route.ts deleted file mode 100644 index 6d9718015..000000000 --- a/src/app/api/notifications/stream/route.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { auth } from '@clerk/nextjs/server' -import { connection, type NextRequest } from 'next/server' -import { logger } from '@/lib/logger' -import { - realtimeNotificationService, - createSSEResponse, -} from '@/server/notifications/realtimeService' - -export async function GET(request: NextRequest) { - await connection() - - try { - const { userId } = await auth() - - if (!userId) return new Response('Unauthorized', { status: 401 }) - - const stream = realtimeNotificationService.createSSEConnection(userId) - const origin = request.headers.get('origin') || undefined - - return createSSEResponse(stream, origin) - } catch (error) { - logger.error('SSE connection error:', error) - return new Response('Internal Server Error', { status: 500 }) - } -} diff --git a/src/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/api/retrocatalog/[brandName]/[modelName]/route.test.ts b/src/app/api/retrocatalog/[brandName]/[modelName]/route.test.ts index 2c53d2722..71e5d125d 100644 --- a/src/app/api/retrocatalog/[brandName]/[modelName]/route.test.ts +++ b/src/app/api/retrocatalog/[brandName]/[modelName]/route.test.ts @@ -2,6 +2,12 @@ import { NextRequest } from 'next/server' import { afterEach, describe, expect, it, vi } from 'vitest' import { GET } from './route' +vi.mock('@/lib/logger', () => ({ + logger: { + warn: vi.fn(), + }, +})) + const request = new NextRequest('http://localhost/api/retrocatalog/Retroid/Pocket%205') function contextFor(brandName: string, modelName: string) { @@ -21,6 +27,7 @@ describe('/api/retrocatalog/[brandName]/[modelName]', () => { const response = await GET(request, contextFor('Retroid', 'Pocket 5')) expect(response.status).toBe(200) + expect(response.headers.get('Cache-Control')).toBe('no-store') expect(await response.json()).toEqual([]) }) @@ -36,6 +43,9 @@ describe('/api/retrocatalog/[brandName]/[modelName]', () => { const response = await GET(request, contextFor('Retroid', 'Pocket 5')) expect(response.status).toBe(200) + expect(response.headers.get('Cache-Control')).toBe( + 'public, s-maxage=86400, stale-while-revalidate=3600', + ) expect(await response.json()).toEqual([device]) }) @@ -49,11 +59,21 @@ describe('/api/retrocatalog/[brandName]/[modelName]', () => { 'https://retrocatalog.com/api/catalog/retro-handhelds/Retro%2Fid/Pocket%205%3Fx%3D1', { headers: { Accept: 'application/json' }, - next: { revalidate: 86400 }, + cache: 'no-store', }, ) }) + it('does not cache empty RetroCatalog matches', async () => { + vi.stubGlobal('fetch', async () => Response.json([])) + + const response = await GET(request, contextFor('Retroid', 'Pocket 6')) + + expect(response.status).toBe(200) + expect(response.headers.get('Cache-Control')).toBe('no-store') + expect(await response.json()).toEqual([]) + }) + it('does not call RetroCatalog for invalid lookup parameters', async () => { const fetch = vi.fn(async () => Response.json([])) vi.stubGlobal('fetch', fetch) diff --git a/src/app/api/retrocatalog/[brandName]/[modelName]/route.ts b/src/app/api/retrocatalog/[brandName]/[modelName]/route.ts index 89f517d27..333400e6b 100644 --- a/src/app/api/retrocatalog/[brandName]/[modelName]/route.ts +++ b/src/app/api/retrocatalog/[brandName]/[modelName]/route.ts @@ -18,7 +18,7 @@ function isCatalogSegment(value: string) { } function emptyCatalogResponse() { - return NextResponse.json([], { headers: CATALOG_CACHE_HEADERS }) + return NextResponse.json([], { headers: { 'Cache-Control': 'no-store' } }) } function catalogUrl(brandName: string, modelName: string) { @@ -38,12 +38,22 @@ export async function GET( try { const response = await fetch(catalogUrl(brandName, modelName), { headers: { Accept: 'application/json' }, - next: { revalidate: 86400 }, + cache: 'no-store', }) - if (!response.ok) return emptyCatalogResponse() + if (!response.ok) { + logger.warn('[retrocatalog] Device lookup rejected', { + status: response.status, + brandName, + modelName, + }) + + return emptyCatalogResponse() + } + + const data: unknown = await response.json() + if (!Array.isArray(data) || data.length === 0) return emptyCatalogResponse() - const data = await response.json() return NextResponse.json(data, { headers: CATALOG_CACHE_HEADERS }) } catch (error) { logger.warn('[retrocatalog] Device lookup failed', { diff --git a/src/app/api/trpc/[trpc]/route.ts b/src/app/api/trpc/[trpc]/route.ts index d89c3841f..0f39cf772 100644 --- a/src/app/api/trpc/[trpc]/route.ts +++ b/src/app/api/trpc/[trpc]/route.ts @@ -2,6 +2,7 @@ import { fetchRequestHandler } from '@trpc/server/adapters/fetch' import { connection, type NextRequest } from 'next/server' import { appRouter } from '@/server/api/root' import { createAppRouterTRPCContext } from '@/server/api/trpc' +import { getTRPCResponseCacheHeaders } from '@/server/api/trpc-cache' const handler = async (req: NextRequest) => { return fetchRequestHandler({ @@ -15,6 +16,20 @@ const handler = async (req: NextRequest) => { console.error(`❌ tRPC failed on ${path ?? ''}: ${error.message}`) } : undefined, + responseMeta(opts) { + return { + headers: getTRPCResponseCacheHeaders({ + endpoint: 'web', + method: req.method, + type: opts.type, + info: opts.info, + hasErrors: opts.errors.length > 0, + eagerGeneration: opts.eagerGeneration, + session: opts.ctx?.session, + headers: opts.ctx?.headers, + }), + } + }, }) } 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 7d24e8113..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() && (
-
diff --git a/src/app/games/[id]/utils/getPcSpecsSummary.ts b/src/app/games/[id]/utils/getPcSpecsSummary.ts index b3dd358bd..11a3af005 100644 --- a/src/app/games/[id]/utils/getPcSpecsSummary.ts +++ b/src/app/games/[id]/utils/getPcSpecsSummary.ts @@ -1,3 +1,5 @@ +import { getCpuLabel } from '@/features/hardware/cpu/shared/cpu-format' +import { getGpuLabel } from '@/features/hardware/gpu/shared/gpu-format' import type { RouterOutput } from '@/types/trpc' type Game = NonNullable @@ -11,8 +13,8 @@ interface PcSpecsSummary { export function getPcSpecsSummary(listing: PcListing): PcSpecsSummary { const details = ( [ - listing.cpu && { label: 'CPU', value: `${listing.cpu.brand.name} ${listing.cpu.modelName}` }, - listing.gpu && { label: 'GPU', value: `${listing.gpu.brand.name} ${listing.gpu.modelName}` }, + listing.cpu && { label: 'CPU', value: getCpuLabel(listing.cpu) }, + listing.gpu && { label: 'GPU', value: getGpuLabel(listing.gpu) }, listing.memorySize !== null && listing.memorySize !== undefined ? { label: 'Memory', value: `${listing.memorySize}GB RAM` } : null, diff --git a/src/app/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" >
-
diff --git a/src/app/games/components/GameFilters.tsx b/src/app/games/components/GameFilters.tsx index d726bb488..8a22710fe 100644 --- a/src/app/games/components/GameFilters.tsx +++ b/src/app/games/components/GameFilters.tsx @@ -2,7 +2,7 @@ import { Joystick, Search, Filter, Eye, EyeOff, List } from 'lucide-react' import { type ChangeEvent } from 'react' -import { Input, Autocomplete, ThreeWayToggle, type ThreeWayToggleOption } from '@/components/ui' +import { Input, Autocomplete, SegmentedControl, type SegmentedControlOption } from '@/components/ui' import { api } from '@/lib/api' import { hasRolePermission } from '@/utils/permissions' import { Role } from '@orm' @@ -32,9 +32,9 @@ function GameFilters(props: Props) { const isModerator = hasRolePermission(userQuery.data?.role, Role.MODERATOR) const listingFilterOptions: [ - ThreeWayToggleOption, - ThreeWayToggleOption, - ThreeWayToggleOption, + SegmentedControlOption, + SegmentedControlOption, + SegmentedControlOption, ] = [ { value: 'all', label: 'All', icon: }, { @@ -84,7 +84,7 @@ function GameFilters(props: Props) { {isModerator && props.onListingFilterChange ? ( - { searchResults: { games: TGame[] } | null @@ -26,7 +26,7 @@ export function useGameSearch( { games: gameNamesAndSystems }, { enabled: gameNamesAndSystems.length > 0, - staleTime: ms.seconds(30), + staleTime: CACHE_DURATIONS.SHORT, refetchOnWindowFocus: true, }, ) 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" >
- = { @@ -19,15 +18,9 @@ const TIME_RANGE_LABELS: Record = { } export function HomeTrendingDevices() { - const trendingDevicesQuery = api.devices.trendingSummary.useQuery( - { - limit: HOME_PAGE_LIMITS.TRENDING_DEVICES, - }, - { - staleTime: ms.hours(6), - gcTime: ms.hours(12), - }, - ) + const trendingDevicesQuery = api.devices.trendingSummary.useQuery({ + limit: HOME_PAGE_LIMITS.TRENDING_DEVICES, + }) const [activeTimeRange, setActiveTimeRange] = useState('thisMonth') diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 6a4b15df3..b773c36d0 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -2,8 +2,6 @@ import './globals.css' import { ClerkProvider } from '@clerk/nextjs' import { shadesOfPurple } from '@clerk/themes' import { GoogleAnalytics } from '@next/third-parties/google' -import { Analytics } from '@vercel/analytics/next' -import { SpeedInsights } from '@vercel/speed-insights/next' import { type Metadata, type Viewport } from 'next' import { Inter } from 'next/font/google' import { connection } from 'next/server' @@ -53,20 +51,14 @@ export default function RootLayout(props: PropsWithChildren) { {env.ENABLE_ANALYTICS && ( <> + {env.GA_ID && } - - {env.GA_ID && } )} {env.ENABLE_KOFI_WIDGET && } )} - {env.ENABLE_ANALYTICS && env.VERCEL_ANALYTICS_ENABLED && ( - - - - )} diff --git a/src/app/listings/ListingsPage.tsx b/src/app/listings/ListingsPage.tsx index 46639deac..29b3ab047 100644 --- a/src/app/listings/ListingsPage.tsx +++ b/src/app/listings/ListingsPage.tsx @@ -29,6 +29,7 @@ import { SuccessRateBar } from '@/components/ui/SuccessRateBar' import { EditButton, ViewButton } from '@/components/ui/table-buttons' import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/Tooltip' import { VerifiedDeveloperBadge } from '@/components/ui/VerifiedDeveloperBadge' +import { CACHE_DURATIONS } from '@/data/constants' import storageKeys from '@/data/storageKeys' import { useEmulatorLogos, @@ -47,7 +48,6 @@ import { } from '@/utils/navigation-events' import { roleIncludesRole } from '@/utils/permission-system' import { hasRolePermission } from '@/utils/permissions' -import { ms } from '@/utils/time' import { Role, ApprovalStatus } from '@orm' import ListingsFiltersContent from './components/ListingsFiltersContent' import ListingsFiltersSidebar from './components/ListingsFiltersSidebar' @@ -66,10 +66,6 @@ const LISTINGS_COLUMNS: ColumnDefinition[] = [ { key: 'actions', label: 'Actions', alwaysVisible: true }, ] -const LOOKUP_DATA_STALE_TIME = ms.hours(6) -const LOOKUP_DATA_GC_TIME = ms.hours(12) -const USE_ASYNC_LISTING_FILTERS = process.env.NEXT_PUBLIC_ENABLE_ASYNC_LISTINGS_FILTERS === 'true' - function ListingsPage() { const { isSignedIn } = useUser() const router = useRouter() @@ -98,8 +94,8 @@ function ListingsPage() { }) const userPreferencesQuery = api.userPreferences.get.useQuery(undefined, { enabled: isSignedIn === true && !!userQuery.data, - staleTime: ms.seconds(30), - gcTime: ms.minutes(5), + staleTime: CACHE_DURATIONS.SHORT, + gcTime: CACHE_DURATIONS.MEDIUM, }) const userRole = userQuery?.data?.role @@ -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/EditListingButton.tsx b/src/app/listings/[id]/components/EditListingButton.tsx index 3d827a6f3..fa6404a31 100644 --- a/src/app/listings/[id]/components/EditListingButton.tsx +++ b/src/app/listings/[id]/components/EditListingButton.tsx @@ -20,7 +20,7 @@ function EditListingButton(props: Props) { { id: props.listingId }, { enabled: !!user?.id, - refetchInterval: 60000, // Refetch every minute to update time remaining + refetchOnWindowFocus: true, }, ) diff --git a/src/app/listings/[id]/components/ListingDetailsClient.tsx b/src/app/listings/[id]/components/ListingDetailsClient.tsx index 4abf7f117..ac9e2ccf4 100644 --- a/src/app/listings/[id]/components/ListingDetailsClient.tsx +++ b/src/app/listings/[id]/components/ListingDetailsClient.tsx @@ -95,18 +95,18 @@ function ListingDetailsClient(props: Props) { -
+
{/* Game Info */} -
+
{/* Game Image */} -
+
@@ -128,7 +128,7 @@ function ListingDetailsClient(props: Props) { fieldValues={props.listing?.customFieldValues ?? []} />
-
+
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) {

)} {columnVisibility.isColumnVisible('gpu') && ( )} {columnVisibility.isColumnVisible('memory') && ( diff --git a/src/app/pc-listings/[id]/components/EditPcListingButton.tsx b/src/app/pc-listings/[id]/components/EditPcListingButton.tsx index a0644ab2a..1201e91c8 100644 --- a/src/app/pc-listings/[id]/components/EditPcListingButton.tsx +++ b/src/app/pc-listings/[id]/components/EditPcListingButton.tsx @@ -20,7 +20,7 @@ function EditPcListingButton(props: Props) { { id: props.pcListingId }, { enabled: !!user?.id, - refetchInterval: 60000, // Refetch every minute to update time remaining + refetchOnWindowFocus: true, }, ) diff --git a/src/app/pc-listings/[id]/components/PcListingDetailsClient.tsx b/src/app/pc-listings/[id]/components/PcListingDetailsClient.tsx index 148cf0242..8f95deb97 100644 --- a/src/app/pc-listings/[id]/components/PcListingDetailsClient.tsx +++ b/src/app/pc-listings/[id]/components/PcListingDetailsClient.tsx @@ -138,18 +138,18 @@ function PcListingDetailsClient(props: Props) { -
+
{/* Game Info */} -
+
{/* Game Image */} -
+
@@ -192,7 +192,7 @@ function PcListingDetailsClient(props: Props) { />
-
+
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 - -type ReportReasonType = (typeof REPORT_REASONS)[number]['value'] +interface ModalContentProps { + onClose: () => void + pcListingId: string + onSuccess: () => void +} -function PcReportListingModal(props: Props) { - const [reason, setReason] = useState(ReportReason.SPAM) +function PcReportListingModalContent(props: ModalContentProps) { + const [reason, setReason] = useState(ReportReason.SPAM) const [description, setDescription] = useState('') const [error, setError] = useState('') - const createReport = api.pcListings.createReport.useMutation() + const createReport = api.pcListingReports.create.useMutation() const { user } = useUser() - // Reset form when modal opens/closes - useEffect(() => { - if (!props.isOpen) return - setReason(ReportReason.SPAM) - setDescription('') - setError('') - }, [props.isOpen]) - const handleSubmit = async (ev: FormEvent) => { ev.preventDefault() setError('') @@ -64,12 +50,11 @@ function PcReportListingModal(props: Props) { pcListingId: props.pcListingId, reason, description: description.trim() || undefined, - } satisfies RouterInput['pcListings']['createReport']) + } satisfies RouterInput['pcListingReports']['create']) - // Track content flagging in analytics if (user?.id) { analytics.contentQuality.contentFlagged({ - entityType: 'listing', + entityType: 'pc-listing', entityId: props.pcListingId, flaggedBy: user.id, reason, @@ -90,7 +75,7 @@ function PcReportListingModal(props: Props) { } return ( - +

@@ -109,11 +94,14 @@ function PcReportListingModal(props: Props) { 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/SessionTracker.test.tsx b/src/components/SessionTracker.test.tsx new file mode 100644 index 000000000..a857e80da --- /dev/null +++ b/src/components/SessionTracker.test.tsx @@ -0,0 +1,169 @@ +import { fireEvent, render, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import SessionTracker from './SessionTracker' + +type MockUser = { + id: string + externalAccounts?: { provider?: string }[] + primaryEmailAddress?: { id: string } | null +} + +const testState = vi.hoisted(() => ({ + analyticsAllowed: true, + pathname: '/', + user: null as MockUser | null, + analytics: { + user: { + signedIn: vi.fn(), + }, + session: { + featureDiscovered: vi.fn(), + pageView: vi.fn(), + sessionEnded: vi.fn(), + sessionStarted: vi.fn(), + }, + }, +})) + +vi.mock('@clerk/nextjs', () => ({ + useUser: () => ({ user: testState.user }), +})) + +vi.mock('next/navigation', () => ({ + usePathname: () => testState.pathname, +})) + +vi.mock('@/hooks', () => ({ + useCookieConsent: () => ({ analyticsAllowed: testState.analyticsAllowed }), +})) + +vi.mock('@/lib/analytics', () => ({ + default: testState.analytics, +})) + +describe('SessionTracker', () => { + beforeEach(() => { + vi.clearAllMocks() + testState.analyticsAllowed = true + testState.pathname = '/' + testState.user = null + }) + + it('does not track session activity when analytics are disabled', () => { + testState.analyticsAllowed = false + + render() + + fireEvent.click(document.body) + window.dispatchEvent(new Event('beforeunload')) + + expect(testState.analytics.session.sessionStarted).not.toHaveBeenCalled() + expect(testState.analytics.session.pageView).not.toHaveBeenCalled() + expect(testState.analytics.session.sessionEnded).not.toHaveBeenCalled() + }) + + it('tracks real page view and interaction counts when the session ends', async () => { + const view = render() + + await waitFor(() => { + expect(testState.analytics.session.sessionStarted).toHaveBeenCalledOnce() + expect(testState.analytics.session.pageView).toHaveBeenCalledOnce() + }) + expect(testState.analytics.session.pageView).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + loadTime: expect.any(Number), + pathname: '/', + }), + ) + + testState.pathname = '/games' + view.rerender() + + await waitFor(() => { + expect(testState.analytics.session.pageView).toHaveBeenCalledTimes(2) + }) + expect(testState.analytics.session.pageView).toHaveBeenLastCalledWith({ + pathname: '/games', + userId: undefined, + }) + + fireEvent.click(document.body) + fireEvent.keyDown(document, { key: 'Enter' }) + window.dispatchEvent(new Event('beforeunload')) + + expect(testState.analytics.session.sessionEnded).toHaveBeenCalledWith( + expect.objectContaining({ + duration: expect.any(Number), + interactions: 2, + pageViews: 2, + sessionId: expect.any(String), + }), + ) + }) + + it('reports the Clerk OAuth provider without counting sign-in as a page view', async () => { + const view = render() + + await waitFor(() => { + expect(testState.analytics.session.sessionStarted).toHaveBeenCalledOnce() + }) + + testState.user = { + id: 'user-1', + externalAccounts: [{ provider: 'oauth_google' }], + primaryEmailAddress: null, + } + view.rerender() + + await waitFor(() => { + expect(testState.analytics.user.signedIn).toHaveBeenCalledWith({ + method: 'google', + userId: 'user-1', + }) + }) + expect(testState.analytics.session.pageView).toHaveBeenCalledOnce() + }) + + it('falls back to email sign-in when the Clerk user has no OAuth provider', async () => { + const view = render() + + await waitFor(() => { + expect(testState.analytics.session.sessionStarted).toHaveBeenCalledOnce() + }) + + testState.user = { + id: 'user-2', + primaryEmailAddress: { id: 'email-1' }, + } + view.rerender() + + await waitFor(() => { + expect(testState.analytics.user.signedIn).toHaveBeenCalledWith({ + method: 'email', + userId: 'user-2', + }) + }) + }) + + it('falls back to clerk sign-in when the user has no OAuth provider or email', async () => { + const view = render() + + await waitFor(() => { + expect(testState.analytics.session.sessionStarted).toHaveBeenCalledOnce() + }) + + testState.user = { + id: 'user-3', + primaryEmailAddress: null, + } + view.rerender() + + await waitFor(() => { + expect(testState.analytics.user.signedIn).toHaveBeenCalledWith({ + method: 'clerk', + userId: 'user-3', + }) + }) + }) +}) diff --git a/src/components/SessionTracker.tsx b/src/components/SessionTracker.tsx index 45266c9f8..6e4d3ce96 100644 --- a/src/components/SessionTracker.tsx +++ b/src/components/SessionTracker.tsx @@ -6,10 +6,22 @@ import { useEffect, useRef } from 'react' import { useCookieConsent } from '@/hooks' import analytics from '@/lib/analytics' -// Generate a UUID compatible with older browsers +type SignInMethod = NonNullable[0]['method']> +type ClerkUser = NonNullable['user']> + +const INTERACTION_EVENTS: (keyof DocumentEventMap)[] = ['click', 'keydown', 'change', 'submit'] +const FEATURE_BY_PATHNAME: Partial> = { + '/pc-listings/new': 'pc-listing_creation', + '/listings/new': 'listing_creation', + '/profile': 'profile_management', + '/admin': 'admin_panel', + '/listings': 'listing_browser', + '/pc-listings': 'pc-listing_browser', + '/games': 'game_browser', +} + function generateUUID() { if (typeof crypto !== 'undefined' && crypto.randomUUID) return crypto.randomUUID() - // Fallback for browsers that don't support crypto.randomUUID return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { const r = (Math.random() * 16) | 0 const v = c === 'x' ? r : (r & 0x3) | 0x8 @@ -17,14 +29,43 @@ function generateUUID() { }) } +function mapExternalProvider(provider: string | undefined): SignInMethod | null { + switch (provider) { + case 'google': + case 'oauth_google': + return 'google' + case 'discord': + case 'oauth_discord': + return 'discord' + case 'github': + case 'oauth_github': + return 'github' + default: + return null + } +} + +function getSignInMethod(user: ClerkUser | null | undefined): SignInMethod { + const externalProvider = mapExternalProvider(user?.externalAccounts?.[0]?.provider) + if (externalProvider) return externalProvider + if (user?.primaryEmailAddress) return 'email' + return 'clerk' +} + function SessionTracker() { const { user } = useUser() const pathname = usePathname() const { analyticsAllowed } = useCookieConsent() + const userId = user?.id + const signInMethod = getSignInMethod(user) const sessionStartRef = useRef(null) - const pageLoadTimeRef = useRef(null) + const initialPageViewStartedAtRef = useRef(null) const sessionIdRef = useRef(null) const hasTrackedSessionStart = useRef(false) + const hasTrackedPageViewRef = useRef(false) + const pageViewCountRef = useRef(0) + const interactionCountRef = useRef(0) + const currentUserIdRef = useRef(undefined) const discoveredFeatures = useRef>(new Set()) const previousUserIdRef = useRef(undefined) @@ -33,85 +74,97 @@ function SessionTracker() { const now = Date.now() sessionStartRef.current = now - pageLoadTimeRef.current = now + initialPageViewStartedAtRef.current = now sessionIdRef.current = generateUUID() }, []) - // Track user sign-in when a user transitions from null/undefined to having a user + useEffect(() => { + currentUserIdRef.current = userId + }, [userId]) + useEffect(() => { if (!analyticsAllowed) return - const currentUserId = user?.id const previousUserId = previousUserIdRef.current - // If we now have a user but didn't before, and it's not the first load, track sign-in - if (currentUserId && !previousUserId && hasTrackedSessionStart.current) { + if (userId && !previousUserId && hasTrackedSessionStart.current) { analytics.user.signedIn({ - userId: currentUserId, - method: 'clerk', // TODO: figure out if we can get the SSO method from Clerk + userId, + method: signInMethod, }) } - // Update the previous user ID for next comparison - previousUserIdRef.current = currentUserId - }, [analyticsAllowed, user?.id]) + previousUserIdRef.current = userId + }, [analyticsAllowed, signInMethod, userId]) - // Track session start on the first load useEffect(() => { if (!analyticsAllowed || hasTrackedSessionStart.current || !sessionIdRef.current) return hasTrackedSessionStart.current = true analytics.session.sessionStarted({ - userId: user?.id, + userId, sessionId: sessionIdRef.current, referrer: document.referrer, userAgent: navigator.userAgent, }) - }, [analyticsAllowed, user?.id]) + }, [analyticsAllowed, userId]) - // Track page views when pathname changes useEffect(() => { - if (!analyticsAllowed || pageLoadTimeRef.current === null) return + if (!analyticsAllowed || initialPageViewStartedAtRef.current === null) return + + const initialLoadTime = hasTrackedPageViewRef.current + ? undefined + : Date.now() - initialPageViewStartedAtRef.current + const currentUserId = currentUserIdRef.current + const pageViewEvent: Parameters[0] = { + pathname, + userId: currentUserId, + } + if (initialLoadTime !== undefined) pageViewEvent.loadTime = initialLoadTime - const loadTime = Date.now() - pageLoadTimeRef.current + hasTrackedPageViewRef.current = true + pageViewCountRef.current += 1 if (process.env.NODE_ENV === 'development') { - return console.log('📊 Page View:', { + return console.log('Page View:', { pathname, - loadTime, - userSession: user ? 'authenticated' : 'anonymous', + loadTime: initialLoadTime, + userSession: currentUserId ? 'authenticated' : 'anonymous', }) } - analytics.session.pageView({ pathname, loadTime, userId: user?.id }) - - // Track feature discovery based on page visits - const featureMap: Record = { - '/pc-listings/new': 'pc-listing_creation', - '/listings/new': 'listing_creation', - '/profile': 'profile_management', - '/admin': 'admin_panel', - '/listings': 'listing_browser', - '/pc-listings': 'pc-listing_browser', - '/games': 'game_browser', - } + analytics.session.pageView(pageViewEvent) - const feature = featureMap[pathname] + const feature = FEATURE_BY_PATHNAME[pathname] if (feature && !discoveredFeatures.current.has(feature)) { discoveredFeatures.current.add(feature) analytics.session.featureDiscovered({ - userId: user?.id, - feature: feature, + userId: currentUserId, + feature, context: pathname, }) } + }, [analyticsAllowed, pathname]) + + useEffect(() => { + if (!analyticsAllowed) return + + const handleInteraction = () => { + interactionCountRef.current += 1 + } - // Reset page load timer - pageLoadTimeRef.current = Date.now() - }, [pathname, analyticsAllowed, user]) + for (const eventName of INTERACTION_EVENTS) { + document.addEventListener(eventName, handleInteraction, true) + } + + return () => { + for (const eventName of INTERACTION_EVENTS) { + document.removeEventListener(eventName, handleInteraction, true) + } + } + }, [analyticsAllowed]) - // Track session duration on page unloading useEffect(() => { if (!analyticsAllowed || sessionStartRef.current === null || !sessionIdRef.current) return @@ -121,17 +174,17 @@ function SessionTracker() { const sessionDuration = Date.now() - sessionStartRef.current analytics.session.sessionEnded({ - userId: user?.id, + userId: currentUserIdRef.current, sessionId: sessionIdRef.current, duration: sessionDuration, - pageViews: 1, // TODO: Track page views separately - interactions: 0, // TODO: Track interactions separately + pageViews: pageViewCountRef.current, + interactions: interactionCountRef.current, }) } window.addEventListener('beforeunload', handleBeforeUnload) return () => window.removeEventListener('beforeunload', handleBeforeUnload) - }, [analyticsAllowed, user]) + }, [analyticsAllowed]) return null } 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/admin/AdminTableNoResults.tsx b/src/components/admin/AdminTableNoResults.tsx index 16b2bbd06..ff9dd1519 100644 --- a/src/components/admin/AdminTableNoResults.tsx +++ b/src/components/admin/AdminTableNoResults.tsx @@ -1,18 +1,31 @@ import { BookDashed, type LucideIcon } from 'lucide-react' +import type { ReactNode } from 'react' interface Props { icon?: LucideIcon hasQuery: boolean + title?: string + queryTitle?: string + description?: string + queryDescription?: string + action?: ReactNode } export function AdminTableNoResults(props: Props) { const Icon = props.icon || BookDashed + const title = props.hasQuery + ? (props.queryTitle ?? 'No results found matching your search criteria.') + : (props.title ?? 'No results.') + const description = props.hasQuery ? props.queryDescription : props.description + return (
-

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

+

{title}

+ {description && ( +

{description}

+ )} + {props.action &&
{props.action}
}
) } diff --git a/src/components/comments/GenericCommentForm.test.tsx b/src/components/comments/GenericCommentForm.test.tsx new file mode 100644 index 000000000..a49cf1fb5 --- /dev/null +++ b/src/components/comments/GenericCommentForm.test.tsx @@ -0,0 +1,115 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { GenericCommentForm } from './GenericCommentForm' +import type { ReactNode } from 'react' + +const testMocks = vi.hoisted(() => ({ + useUser: vi.fn(() => ({ user: { id: 'user-1' } })), + submitWithHumanVerification: vi.fn( + async (callback: (humanVerificationToken: string) => Promise) => { + await callback('verification-token') + }, + ), + toastError: vi.fn(), + toastSuccess: vi.fn(), +})) + +vi.mock('@clerk/nextjs', () => ({ + useUser: testMocks.useUser, + SignInButton: (props: { children: ReactNode }) => props.children, +})) + +vi.mock('@/features/human-verification/client', () => ({ + useSubmitWithHumanVerification: () => testMocks.submitWithHumanVerification, +})) + +vi.mock('@/lib/toast', () => ({ + default: { + error: testMocks.toastError, + success: testMocks.toastSuccess, + }, +})) + +vi.mock('@/lib/dynamic-imports', async () => { + const actual = await vi.importActual<{ MarkdownEditor: unknown }>( + '@/components/ui/form/MarkdownEditor', + ) + + return { MarkdownEditor: actual.MarkdownEditor } +}) + +describe('GenericCommentForm', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it.each([ + ['Cmd+Enter', { metaKey: true }], + ['Ctrl+Enter', { ctrlKey: true }], + ])('submits comments with %s', async (_shortcut, keyModifiers) => { + const user = userEvent.setup() + const onSubmit = vi.fn().mockResolvedValue(undefined) + + render( + , + ) + + const editor = screen.getByRole('textbox') + await user.type(editor, 'Runs well') + + fireEvent.keyDown(editor, { key: 'Enter', ...keyModifiers }) + + await waitFor(() => { + expect(onSubmit).toHaveBeenCalledWith( + expect.objectContaining({ + content: 'Runs well', + humanVerificationToken: 'verification-token', + }), + ) + }) + }) + + it('does not submit when Enter is pressed without a modifier key', async () => { + const user = userEvent.setup() + const onSubmit = vi.fn().mockResolvedValue(undefined) + + render( + , + ) + + const editor = screen.getByRole('textbox') + await user.type(editor, 'Runs well') + + fireEvent.keyDown(editor, { key: 'Enter' }) + + expect(onSubmit).not.toHaveBeenCalled() + }) + + it('uses the normal validation flow when the shortcut is pressed with empty content', async () => { + const onSubmit = vi.fn().mockResolvedValue(undefined) + + render( + , + ) + + fireEvent.keyDown(screen.getByRole('textbox'), { key: 'Enter', metaKey: true }) + + await waitFor(() => { + expect(testMocks.toastError).toHaveBeenCalledWith('Please enter a comment') + }) + expect(onSubmit).not.toHaveBeenCalled() + }) +}) diff --git a/src/components/comments/GenericCommentForm.tsx b/src/components/comments/GenericCommentForm.tsx index 337519ff8..e8f85d6cb 100644 --- a/src/components/comments/GenericCommentForm.tsx +++ b/src/components/comments/GenericCommentForm.tsx @@ -2,7 +2,7 @@ import { useUser, SignInButton } from '@clerk/nextjs' import { Send, X } from 'lucide-react' -import { useState, type FormEvent } from 'react' +import { useState, type FormEvent, type KeyboardEvent } from 'react' import { Button } from '@/components/ui' import { useSubmitWithHumanVerification } from '@/features/human-verification/client' import { MarkdownEditor } from '@/lib/dynamic-imports' @@ -104,6 +104,15 @@ export function GenericCommentForm(props: GenericCommentFormProps) { props.onCancel?.() } + const handleEditorKeyDown = (ev: KeyboardEvent) => { + if (ev.key !== 'Enter' || (!ev.metaKey && !ev.ctrlKey)) return + + ev.preventDefault() + if (isLoading) return + + ev.currentTarget.form?.requestSubmit() + } + if (!user && props.config.showSignInPrompt !== false) { return (
@@ -146,6 +155,7 @@ export function GenericCommentForm(props: GenericCommentFormProps) { maxLength={maxLength} disabled={isLoading} className={cn(isReply && 'text-sm')} + onKeyDown={handleEditorKeyDown} />
@@ -163,7 +173,6 @@ export function GenericCommentForm(props: GenericCommentFormProps) { )} - {/*TODO: allow Cmd+Enter or Ctrl+Enter to submit*/} )} - {/*TODO: allow Cmd+Enter or Ctrl+Enter to submit*/}
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 && } - {/* Desktop Dropdown */} {isOpen && ( - {/* Header */}

Notifications

@@ -212,7 +213,6 @@ function NotificationCenter(props: Props) {
- {/* Notifications List */}
- {/* Footer */} {notifications.length > 0 && (
-
-
-
-
- - ) -} diff --git a/src/components/popups/StopKillingGamesPopup.tsx b/src/components/popups/StopKillingGamesPopup.tsx deleted file mode 100644 index 176f871f6..000000000 --- a/src/components/popups/StopKillingGamesPopup.tsx +++ /dev/null @@ -1,122 +0,0 @@ -'use client' - -import { AlertTriangle, X } from 'lucide-react' -import { useState, useEffect, useRef } from 'react' -import { Modal } from '@/components/ui' -import storageKeys from '@/data/storageKeys' -import analytics from '@/lib/analytics' -import { env } from '@/lib/env' - -const signPetitionUrl = 'https://eci.ec.europa.eu/045/public/#/screen/home' - -// TODO: check if we still need this for something -export function StopKillingGamesPopup() { - const [isOpen, setIsOpen] = useState(false) - - // Track the time when component mounts - const startTimeRef = useRef(Date.now()) - - // Function to get actual time spent on page in seconds - const getTimeOnPage = (): number => { - return Math.round((Date.now() - startTimeRef.current) / 1000) - } - - useEffect(() => { - if (!env.IS_PUBLIC_PRODUCTION) return - - // Don't show on admin pages - if (window.location.pathname.startsWith('/admin')) return - - const hasBeenDismissed = localStorage.getItem(storageKeys.popups.stopKillingGamesDismissed) - - if (hasBeenDismissed) return - - // Show popup after 20 seconds - const timer = setTimeout(() => { - setIsOpen(true) - }, 20000) - - return () => clearTimeout(timer) - }, []) - - function handleDismiss() { - analytics.engagement.stopKillingGamesDismissed({ - timeOnPage: getTimeOnPage(), - }) - localStorage.setItem(storageKeys.popups.stopKillingGamesDismissed, 'true') - setIsOpen(false) - } - - const handleClick = () => { - setIsOpen(false) - analytics.engagement.stopKillingGamesCTA({ timeOnPage: getTimeOnPage() }) - window.open(signPetitionUrl, '_blank', 'noopener,noreferrer') - } - - if (!env.IS_PUBLIC_PRODUCTION || !isOpen) return null - - return ( - -
-
-
- -
- -
-

- The Community needs your help to "Stop Killing Games" -

- -
-

- 765,634 people have already signed a petition urging publishers to make future games - playable even after servers shut down. -

-

- It calls for offline modes or private server support—to preserve ownership and - access. -

-

Once 1 million signatures are reached, the EU Commission must review it.

-

- - Care to help to hit the mark? - -

-

- - Learn more - -

-
- -
- - - - Sign the Petition - -
-
-
-
-
- ) -} diff --git a/src/components/popups/index.ts b/src/components/popups/index.ts deleted file mode 100644 index 427f0f8f5..000000000 --- a/src/components/popups/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './BetaWarningPopup' diff --git a/src/components/retrocatalog/RetroCatalogButton.tsx b/src/components/retrocatalog/RetroCatalogButton.tsx index 2621012ac..b5e906009 100644 --- a/src/components/retrocatalog/RetroCatalogButton.tsx +++ b/src/components/retrocatalog/RetroCatalogButton.tsx @@ -25,17 +25,15 @@ interface Props { } /** - * RetroCatalog specs button - shows only when device exists on RetroCatalog - * Opens device specs in new tab with tasteful hover animations + * RetroCatalog specs button + * shows only when device exists on RetroCatalog + * Opens device specs in new tab */ export function RetroCatalogButton(props: Props) { const { deviceId, brandName, modelName, variant = 'pill' } = props const [isHovered, setIsHovered] = useState(false) - const { exists, url, isLoading } = useRetroCatalogDevice({ - brandName, - modelName, - }) + const { exists, url, isLoading } = useRetroCatalogDevice({ brandName, modelName }) if (isLoading || !exists || !url) return null diff --git a/src/components/retrocatalog/useRetroCatalogDevice.ts b/src/components/retrocatalog/useRetroCatalogDevice.ts index 3c9124e04..d7db51cbc 100644 --- a/src/components/retrocatalog/useRetroCatalogDevice.ts +++ b/src/components/retrocatalog/useRetroCatalogDevice.ts @@ -1,8 +1,8 @@ 'use client' import { useQuery } from '@tanstack/react-query' +import { CACHE_DURATIONS } from '@/data/constants' import http from '@/rest/http' -import { ms } from '@/utils/time' const RETROCATALOG_REFERRER = '?referrer=emuready' @@ -58,8 +58,8 @@ export function useRetroCatalogDevice( queryKey: ['retrocatalog', options.brandName, options.modelName], queryFn: () => fetchRetroCatalogDevice(options.brandName, options.modelName), enabled: enabled && Boolean(options.brandName) && Boolean(options.modelName), - staleTime: ms.hours(24), - gcTime: ms.hours(48), + staleTime: CACHE_DURATIONS.STATIC, + gcTime: CACHE_DURATIONS.STATIC_GC, retry: false, refetchOnWindowFocus: false, refetchOnReconnect: false, diff --git a/src/components/ui/ImageRenderer.tsx b/src/components/ui/ImageRenderer.tsx new file mode 100644 index 000000000..9d25d9a83 --- /dev/null +++ b/src/components/ui/ImageRenderer.tsx @@ -0,0 +1,50 @@ +'use client' + +import Image, { type ImageProps } from 'next/image' +import { getImageRenderMode } from '@/utils/imageUrls' +import type { CSSProperties, ImgHTMLAttributes } from 'react' + +type NativeImageDimension = ImgHTMLAttributes['width'] + +type Props = Omit + +function createFillImageStyle(fill: ImageProps['fill'], style: ImageProps['style']): CSSProperties { + if (!fill) return style ?? {} + + return { + position: 'absolute', + height: '100%', + width: '100%', + inset: 0, + color: 'transparent', + ...style, + } +} + +function getNativeDimension(value: ImageProps['width']): NativeImageDimension { + if (typeof value === 'number' || typeof value === 'string') return value + return undefined +} + +export function ImageRenderer(props: Props) { + if (typeof props.src !== 'string' || getImageRenderMode(props.src) !== 'external-img') { + return + } + + return ( + {props.alt} + ) +} diff --git a/src/components/ui/OptimizedImage.tsx b/src/components/ui/OptimizedImage.tsx index b492a454c..2557b2448 100644 --- a/src/components/ui/OptimizedImage.tsx +++ b/src/components/ui/OptimizedImage.tsx @@ -1,9 +1,11 @@ 'use client' -import Image, { type ImageProps } from 'next/image' -import { useState } from 'react' -import { LoadingSpinner } from '@/components/ui' +import { type ImageProps } from 'next/image' +import { useEffect, useState } from 'react' import { cn } from '@/lib/utils' +import getImageUrl from '@/utils/getImageUrl' +import { ImageRenderer } from './ImageRenderer' +import { LoadingSpinner } from './LoadingSpinner' type ObjectFit = 'contain' | 'cover' | 'fill' | 'none' | 'scale-down' @@ -22,13 +24,13 @@ interface Props { height?: number className?: string imageClassName?: string - priority?: ImageProps['priority'] + preload?: ImageProps['preload'] unoptimized?: ImageProps['unoptimized'] loading?: ImageProps['loading'] + fetchPriority?: ImageProps['fetchPriority'] quality?: 50 | 75 | 85 | 100 fallbackSrc?: string objectFit?: ObjectFit - useProxy?: boolean } export function OptimizedImage(props: Props) { @@ -39,21 +41,14 @@ export function OptimizedImage(props: Props) { const resolveSrc = (): string => { if (error) return fallbackSrc - - const shouldProxy = props.useProxy ?? true - const src = props.src - - if (!shouldProxy) return src - - if (src.startsWith('/api/proxy-image')) return src - - if (src.startsWith('http://') || src.startsWith('https://')) { - return `/api/proxy-image?url=${encodeURIComponent(src)}` - } - - return src + return getImageUrl(props.src, null) } + useEffect(() => { + setIsLoading(true) + setError(false) + }, [props.src, fallbackSrc]) + const handleError = () => { setIsLoading(false) setError(true) @@ -66,7 +61,7 @@ export function OptimizedImage(props: Props) {
)} - 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 */} - { - // 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/ThreeWayToggle.tsx b/src/components/ui/SegmentedControl.tsx similarity index 76% rename from src/components/ui/ThreeWayToggle.tsx rename to src/components/ui/SegmentedControl.tsx index 4d28d8d87..f6ead0008 100644 --- a/src/components/ui/ThreeWayToggle.tsx +++ b/src/components/ui/SegmentedControl.tsx @@ -15,33 +15,32 @@ const paddingClasses = { lg: 'px-4', } -export interface ThreeWayToggleOption { +export interface SegmentedControlOption { value: T label: string icon?: ReactNode } interface Props { - options: [ThreeWayToggleOption, ThreeWayToggleOption, ThreeWayToggleOption] + options: readonly [SegmentedControlOption, ...SegmentedControlOption[]] value: T onChange: (value: T) => void className?: string size?: 'sm' | 'md' | 'lg' } -// TODO: I feel like the english language has a better name for this -export function ThreeWayToggle(props: Props) { +export function SegmentedControl(props: Props) { const size = props.size ?? 'md' return (
- {/* Options */} {props.options.map((option) => ( + +
+ + + {allowIgdbProvider && ( + + )}
-
- {selectedService === imageServiceMap.rawg +
+ {selectedService === 'rawg' ? 'Using RAWG.io for game images' - : 'Using TheGamesDB for game images'} + : selectedService === 'tgdb' + ? 'Using TheGamesDB for game images' + : 'Using IGDB for comprehensive game media'}
-
-
- {selectedService === imageServiceMap.rawg - ? 'RAWG.io provides comprehensive game data with screenshots and backgrounds' - : 'TheGamesDB offers high-quality boxart and game media from the community'} +
+ {selectedService === 'rawg' && + 'RAWG.io provides comprehensive game data with screenshots and backgrounds'} + {selectedService === 'tgdb' && + 'TheGamesDB offers high-quality boxart and game media from the community'} + {selectedService === 'igdb' && + 'IGDB provides rich media including covers, artworks, and screenshots with detailed metadata'} +
- {/* Animated Image Selector */}
- - {selectedService === imageServiceMap.rawg ? ( + + {selectedService === 'rawg' ? ( + ) : selectedService === 'igdb' ? ( + + + ) : ( = 2 && !selectedGameId, - staleTime: 5 * 60 * 1000, + staleTime: CACHE_DURATIONS.MEDIUM, }, ) @@ -74,7 +75,7 @@ export function IGDBImageSelector({ onImageSelect, onError, ...props }: Props) { { gameId: selectedGameId! }, { enabled: !!selectedGameId, - staleTime: 5 * 60 * 1000, + staleTime: CACHE_DURATIONS.MEDIUM, }, ) diff --git a/src/components/ui/image-selectors/providers/RawgImageSelector.tsx b/src/components/ui/image-selectors/providers/RawgImageSelector.tsx index 9c5295787..635eaca54 100644 --- a/src/components/ui/image-selectors/providers/RawgImageSelector.tsx +++ b/src/components/ui/image-selectors/providers/RawgImageSelector.tsx @@ -3,6 +3,7 @@ import { Search, Eye, Camera, Link as LinkIcon } from 'lucide-react' import { useState, useEffect, type KeyboardEvent, type MouseEvent } from 'react' import { Button, LoadingSpinner, OptimizedImage, Modal, Input, Toggle } from '@/components/ui' +import { CACHE_DURATIONS } from '@/data/constants' import useDebouncedValue from '@/hooks/useDebouncedValue' import { api } from '@/lib/api' import { getImageDisplayName } from '@/lib/rawg-utils' @@ -55,7 +56,7 @@ export function RawgImageSelector({ onImageSelect, onError, ...props }: Props) { }, { enabled: !useCustomUrl && debouncedSearchTerm.length >= 2, - staleTime: 5 * 60 * 1000, + staleTime: CACHE_DURATIONS.MEDIUM, }, ) @@ -287,9 +288,7 @@ export function RawgImageSelector({ onImageSelect, onError, ...props }: Props) { >
= 2, - staleTime: 5 * 60 * 1000, + staleTime: CACHE_DURATIONS.MEDIUM, }) // Update search term when gameTitle prop changes diff --git a/src/components/ui/index.ts b/src/components/ui/index.ts index ac2a45936..9243a37f8 100644 --- a/src/components/ui/index.ts +++ b/src/components/ui/index.ts @@ -19,6 +19,7 @@ export * from './DisplayToggleButton' export * from './Divider' export * from './Dropdown' export * from './ErrorFallback' +export * from './ImageRenderer' export * from './ListingVerificationBadge' export * from './LoadingSpinner' export * from './LocalizedDate' @@ -28,24 +29,20 @@ export * from './PageSkeletonLoading' export * from './Pagination' export * from './PerformanceBadge' export * from './Popover' -export * from './ProgressiveImage' -export * from './PullToRefresh' export * from './RoleBadge' export * from './SegmentedTabs' +export * from './SegmentedControl' export * from './Skeleton' export * from './SortableHeader' export * from './SuccessRateBar' -export * from './SwipeableCard' export * from './Switch' export * from './ThemeSelect' export * from './ThemeToggle' -export * from './ThreeWayToggle' export * from './Tooltip' export * from './UnderlineTabBar' export * from './TrustLevelBadge' export * from './UserBadgeItem' export * from './VerifiedDeveloperBadge' -export * from './VirtualScroller' export * from './VoteButtons' // Collection of components diff --git a/src/components/ui/markdown/TranslatableMarkdown.test.tsx b/src/components/ui/markdown/TranslatableMarkdown.test.tsx index d574af0c5..266051c38 100644 --- a/src/components/ui/markdown/TranslatableMarkdown.test.tsx +++ b/src/components/ui/markdown/TranslatableMarkdown.test.tsx @@ -1,12 +1,28 @@ -import { render } from '@testing-library/react' -import { type PropsWithChildren } from 'react' -import { describe, expect, it, vi } from 'vitest' +import { render, waitFor } from '@testing-library/react' +import { act, type PropsWithChildren } from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { TranslatableMarkdown } from './TranslatableMarkdown' interface MotionProps extends PropsWithChildren { className?: string } +interface UseTranslationOptions { + enabled?: boolean +} + +const useTranslationMock = vi.hoisted(() => + vi.fn((content: string, _options?: UseTranslationOptions) => ({ + displayedContent: content, + showTranslated: false, + isTranslating: false, + showTranslationOption: false, + toggleTranslation: vi.fn(), + getButtonLabel: () => 'Translate (BETA)', + getTranslationInfo: () => 'Translation available', + })), +) + vi.mock('framer-motion', () => ({ motion: { div: (props: MotionProps) =>
{props.children}
, @@ -14,6 +30,49 @@ vi.mock('framer-motion', () => ({ AnimatePresence: (props: PropsWithChildren) => props.children, })) +vi.mock('@/hooks/useTranslation', () => ({ + useTranslation: useTranslationMock, +})) + +const intersectionObservers: MockIntersectionObserver[] = [] + +function createIntersectionEntry(isIntersecting: boolean): IntersectionObserverEntry { + const rect = new DOMRect(0, 0, 0, 0) + + return { + boundingClientRect: rect, + intersectionRatio: isIntersecting ? 1 : 0, + intersectionRect: rect, + isIntersecting, + rootBounds: null, + target: document.createElement('div'), + time: 0, + } +} + +class MockIntersectionObserver implements IntersectionObserver { + readonly root: Element | Document | null = null + readonly rootMargin: string + readonly thresholds: readonly number[] = [] + + private readonly callback: IntersectionObserverCallback + + constructor(callback: IntersectionObserverCallback, options?: IntersectionObserverInit) { + this.callback = callback + this.rootMargin = options?.rootMargin ?? '0px' + intersectionObservers.push(this) + } + + disconnect = vi.fn() + observe = vi.fn() + takeRecords = vi.fn(() => []) + unobserve = vi.fn() + + trigger(isIntersecting: boolean) { + this.callback([createIntersectionEntry(isIntersecting)], this) + } +} + function getRenderedElement(element: Element | null): HTMLElement { if (element instanceof HTMLElement) return element @@ -21,6 +80,16 @@ function getRenderedElement(element: Element | null): HTMLElement { } describe('TranslatableMarkdown', () => { + beforeEach(() => { + useTranslationMock.mockClear() + intersectionObservers.length = 0 + vi.stubGlobal('IntersectionObserver', MockIntersectionObserver) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + it('preserves plain text newlines when requested', () => { const content = 'First line\nSecond line' const { container } = render() @@ -31,4 +100,21 @@ describe('TranslatableMarkdown', () => { expect(proseWrapper).toHaveClass('whitespace-pre-wrap') expect(proseWrapper.querySelector('p')).not.toBeInTheDocument() }) + + it('enables translation detection after the markdown reaches the viewport', async () => { + const content = 'Este texto necesita traduccion' + + render() + + expect(useTranslationMock).toHaveBeenLastCalledWith(content, { enabled: false }) + + await waitFor(() => expect(intersectionObservers).toHaveLength(1)) + act(() => { + intersectionObservers[0]?.trigger(true) + }) + + await waitFor(() => + expect(useTranslationMock).toHaveBeenLastCalledWith(content, { enabled: true }), + ) + }) }) diff --git a/src/components/ui/markdown/TranslatableMarkdown.tsx b/src/components/ui/markdown/TranslatableMarkdown.tsx index 07542b830..70d6eb439 100644 --- a/src/components/ui/markdown/TranslatableMarkdown.tsx +++ b/src/components/ui/markdown/TranslatableMarkdown.tsx @@ -2,6 +2,7 @@ import { motion, AnimatePresence } from 'framer-motion' import { Languages, Earth, Globe } from 'lucide-react' +import { useEffect, useState } from 'react' import { Button } from '@/components/ui' import { useTranslation } from '@/hooks/useTranslation' import { MarkdownRenderer } from './MarkdownRenderer' @@ -13,6 +14,35 @@ interface Props { } export function TranslatableMarkdown(props: Props) { + const [containerElement, setContainerElement] = useState(null) + const [isVisible, setIsVisible] = useState(false) + + useEffect(() => { + if (!containerElement || isVisible) return + + if (typeof IntersectionObserver === 'undefined') { + let didCancel = false + queueMicrotask(() => { + if (!didCancel) setIsVisible(true) + }) + return () => { + didCancel = true + } + } + + const observer = new IntersectionObserver( + ([entry]) => { + if (!entry?.isIntersecting) return + setIsVisible(true) + observer.disconnect() + }, + { rootMargin: '200px' }, + ) + + observer.observe(containerElement) + return () => observer.disconnect() + }, [containerElement, isVisible]) + const { displayedContent, showTranslated, @@ -21,14 +51,14 @@ export function TranslatableMarkdown(props: Props) { toggleTranslation, getButtonLabel, getTranslationInfo, - } = useTranslation(props.content) + } = useTranslation(props.content, { enabled: isVisible }) const ButtonIcon = isTranslating ? Languages : showTranslated ? Globe : Earth if (!props.content?.trim()) return null return ( -
+
{ if (isPcListing) { - verifyPcListingMutation.mutate({ + return verifyPcListingMutation.mutate({ pcListingId: props.listingId, notes: notes.trim() || undefined, }) - } else { - verifyListingMutation.mutate({ - listingId: props.listingId, - notes: notes.trim() || undefined, - }) } + verifyListingMutation.mutate({ + listingId: props.listingId, + notes: notes.trim() || undefined, + }) } const handleUnverify = () => { - if (isPcListing) { - if (props.verificationId) { - removeVerificationMutation.mutate({ - verificationId: props.verificationId, - }) - } - } else { - unverifyListingMutation.mutate({ - listingId: props.listingId, - }) + if (!isPcListing) { + return unverifyListingMutation.mutate({ listingId: props.listingId }) } + if (!props.verificationId) return + removeVerificationMutation.mutate({ verificationId: props.verificationId }) } - // Don't show button if user is not logged in if (!currentUserQuery.data) return null - // Don't show button if user is not a verified developer for this emulator if (!verifiedDeveloperQuery.data) return null - // Don't show button if user is the author (can't verify own listings) if (props.authorId === userId) return null - // Don't show button if user doesn't have at least DEVELOPER role if (!roleIncludesRole(currentUserQuery.data.role, Role.DEVELOPER)) return null const isLoading = isPcListing diff --git a/src/data/constants.ts b/src/data/constants.ts index b5a475390..3b5c19531 100644 --- a/src/data/constants.ts +++ b/src/data/constants.ts @@ -2,8 +2,9 @@ import { ms } from '@/utils/time' // Polling intervals in milliseconds export const POLLING_INTERVALS = { - NOTIFICATIONS: ms.minutes(3), - DEFAULT: ms.seconds(30), + SHORT: ms.minutes(1), + LONG: ms.minutes(5), + EXTRA_LONG: ms.minutes(10), } as const // Batch sizes for cursor-based iteration @@ -32,12 +33,24 @@ export const PAGINATION = { export const PAGE_SIZE_OPTIONS = [10, 25, 50] as const export type PageSizeOption = (typeof PAGE_SIZE_OPTIONS)[number] -// Cache durations in milliseconds TODO: use wherever possible +// Async entity lookup pagination for dropdowns and filter selectors +export const LOOKUP_PAGINATION = { + DEFAULT_LIMIT: 50, + MAX_LIMIT: 1000, + AUTOCOMPLETE_LIMIT: 20, +} as const + +// Cache durations in milliseconds export const CACHE_DURATIONS = { + VERY_SHORT: ms.seconds(10), SHORT: ms.minutes(1), MEDIUM: ms.minutes(5), LONG: ms.minutes(15), EXTRA_LONG: ms.hours(1), + LOOKUP: ms.hours(6), + LOOKUP_GC: ms.hours(12), + STATIC: ms.days(1), + STATIC_GC: ms.days(2), } as const // Rate limiting diff --git a/src/data/storageKeys.ts b/src/data/storageKeys.ts index ccc5ee701..34f2367cb 100644 --- a/src/data/storageKeys.ts +++ b/src/data/storageKeys.ts @@ -9,9 +9,7 @@ const storageKeys = { lastUsedDevice: `${PREFIX}new_listing_last_used_device`, }, popups: { - stopKillingGamesDismissed: `${PREFIX}stop_killing_games_dismissed`, voteReminderDismissed: `${PREFIX}vote_reminder_dismissed`, - betaWarningDismissed: `${PREFIX}beta_warning_dismissed_v2`, supportBannerDismissed: `${PREFIX}support_banner_dismissed`, }, cookies: { @@ -39,7 +37,8 @@ const storageKeys = { adminGames: `${PREFIX}admin_games_column_visibility`, adminListings: `${PREFIX}admin_listings_column_visibility`, adminPerformance: `${PREFIX}admin_performance_column_visibility`, - adminProcessedListings: `${PREFIX}admin_processed_listings_column_visibility`, + adminProcessedListings: `${PREFIX}admin_processed_reports_column_visibility`, + adminPcProcessedListings: `${PREFIX}admin_pc_processed_listings_column_visibility`, adminSoCs: `${PREFIX}admin_socs_column_visibility`, adminSystems: `${PREFIX}admin_systems_column_visibility`, adminTrustLogs: `${PREFIX}admin_trust_logs_column_visibility`, diff --git a/src/features/hardware/cpu/client/admin/AdminCpusView.tsx b/src/features/hardware/cpu/client/admin/AdminCpusView.tsx new file mode 100644 index 000000000..3d595e701 --- /dev/null +++ b/src/features/hardware/cpu/client/admin/AdminCpusView.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 { CpuFormModal } from './CpuFormModal' +import { CpuTable } from './CpuTable' +import { CpuViewModal } from './CpuViewModal' +import type { CpuDetail, CpuSortField } from '../../shared/cpu.types' + +const CPUS_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 AdminCpusView() { + const table = useAdminTable({ + defaultSortField: 'brand', + defaultSortDirection: 'asc', + }) + const search = table.debouncedSearch.trim() + + const columnVisibility = useColumnVisibility(CPUS_COLUMNS, { + storageKey: storageKeys.columnVisibility.adminCpus, + }) + + const cpusQuery = api.cpus.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 cpusStatsQuery = api.cpus.stats.useQuery() + const brandsQuery = api.deviceBrands.get.useQuery({ + limit: PAGINATION.MAX_LIMIT, + category: 'cpu', + }) + const deleteCpu = api.cpus.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 [selectedCpu, setSelectedCpu] = useState(null) + + const invalidateCpuQueries = () => { + utils.cpus.get.invalidate().catch(console.error) + utils.cpus.options.invalidate().catch(console.error) + utils.cpus.stats.invalidate().catch(console.error) + } + + const openFormModal = (cpu?: CpuDetail) => { + setSelectedCpu(cpu ?? null) + setFormModalOpen(true) + } + + const closeFormModal = () => { + setFormModalOpen(false) + setSelectedCpu(null) + } + + const openViewModal = (cpu: CpuDetail) => { + setSelectedCpu(cpu) + setViewModalOpen(true) + } + + const closeViewModal = () => { + setViewModalOpen(false) + setSelectedCpu(null) + } + + const handleFormSuccess = () => { + invalidateCpuQueries() + closeFormModal() + } + + 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 }) + 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 && 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 ( +
-
- -

No verified developers found.

-

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

-
+
+
- {listing.cpu - ? `${listing.cpu.brand.name} ${listing.cpu.modelName}` - : 'N/A'} + {listing.cpu ? getCpuLabel(listing.cpu) : 'N/A'} - {listing.gpu - ? `${listing.gpu.brand.name} ${listing.gpu.modelName}` - : 'Integrated'} + {listing.gpu ? getGpuLabel(listing.gpu) : 'Integrated'}
+ + + {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 57% rename from src/app/pc-listings/components/filters/AsyncCpuFilterSelect.tsx rename to src/features/hardware/cpu/client/components/AsyncCpuFilterSelect.tsx index 474daf663..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 AsyncMultiSelect, { + type Option, +} from '@/components/ui/form/async-multi-select/AsyncMultiSelect' +import { LOOKUP_PAGINATION } from '@/data/constants' import { api } from '@/lib/api' -import { ms } from '@/utils/time' +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: ms.hours(6), - gcTime: ms.hours(12), -} - export default function AsyncCpuFilterSelect(props: Props) { const [query, setQuery] = useState('') const [pageOffsets, setPageOffsets] = useState([0]) const byIdsQuery = api.cpus.getByIds.useQuery( { ids: props.value }, - { ...LOOKUP_DATA_QUERY_OPTIONS, enabled: props.value.length > 0 }, + { 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/features/hardware/gpu/client/admin/GpuViewModal.tsx b/src/features/hardware/gpu/client/admin/GpuViewModal.tsx new file mode 100644 index 000000000..47a515e75 --- /dev/null +++ b/src/features/hardware/gpu/client/admin/GpuViewModal.tsx @@ -0,0 +1,36 @@ +'use client' + +import { Button, InputPlaceholder, Modal } from '@/components/ui' +import type { GpuDetail } from '../../shared/gpu.types' + +interface Props { + isOpen: boolean + onClose: () => void + gpuData: GpuDetail | null +} + +export function GpuViewModal(props: Props) { + if (!props.gpuData) return null + + return ( + +
+
+ + + + +
+ +
+ +
+
+
+ ) +} 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 57% rename from src/app/pc-listings/components/filters/AsyncGpuFilterSelect.tsx rename to src/features/hardware/gpu/client/components/AsyncGpuFilterSelect.tsx index 679749d77..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 AsyncMultiSelect, { + type Option, +} from '@/components/ui/form/async-multi-select/AsyncMultiSelect' +import { LOOKUP_PAGINATION } from '@/data/constants' import { api } from '@/lib/api' -import { ms } from '@/utils/time' +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: ms.hours(6), - gcTime: ms.hours(12), -} - export default function AsyncGpuFilterSelect(props: Props) { const [query, setQuery] = useState('') const [pageOffsets, setPageOffsets] = useState([0]) const byIdsQuery = api.gpus.getByIds.useQuery( { ids: props.value }, - { ...LOOKUP_DATA_QUERY_OPTIONS, enabled: props.value.length > 0 }, + { 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/hooks/useRealtimeNotifications.ts b/src/hooks/useRealtimeNotifications.ts deleted file mode 100644 index 68668cd7d..000000000 --- a/src/hooks/useRealtimeNotifications.ts +++ /dev/null @@ -1,207 +0,0 @@ -'use client' - -import { useUser } from '@clerk/nextjs' -import { useCallback, useEffect, useRef, useState } from 'react' -import { z } from 'zod' -import { safeParseJSON } from '@/utils/client-validation' - -interface RealtimeNotification { - id: string - type: string - title: string - message: string - actionUrl?: string - createdAt: string -} - -interface SSEMessage { - type: 'connected' | 'notification' | 'unread_count' | 'ping' - data: unknown -} - -// Schemas for validation -const RealtimeNotificationSchema = z.object({ - id: z.string(), - type: z.string(), - title: z.string(), - message: z.string(), - actionUrl: z.string().optional(), - createdAt: z.string(), -}) - -const SSEMessageSchema = z.object({ - type: z.enum(['connected', 'notification', 'unread_count', 'ping']), - data: z.unknown(), -}) - -interface UseRealtimeNotificationsReturn { - isConnected: boolean - notifications: RealtimeNotification[] - unreadCount: number - connect: () => void - disconnect: () => void - markAsRead: (notificationId: string) => void - clearNotifications: () => void -} - -export function useRealtimeNotifications(): UseRealtimeNotificationsReturn { - const { user, isLoaded } = useUser() - const [isConnected, setIsConnected] = useState(false) - const [notifications, setNotifications] = useState([]) - const [unreadCount, setUnreadCount] = useState(0) - - const eventSourceRef = useRef(null) - const reconnectTimeoutRef = useRef(null) - const maxReconnectAttempts = 5 - const reconnectAttempts = useRef(0) - - const disconnect = useCallback(() => { - if (eventSourceRef.current) { - eventSourceRef.current.close() - eventSourceRef.current = null - } - - if (reconnectTimeoutRef.current) { - clearTimeout(reconnectTimeoutRef.current) - reconnectTimeoutRef.current = null - } - - setIsConnected(false) - }, []) - - const connect = useCallback(() => { - if (!user?.id || eventSourceRef.current) { - return - } - - try { - const eventSource = new EventSource(`/api/notifications/stream`) - eventSourceRef.current = eventSource - - eventSource.onopen = () => { - console.log('SSE connection opened') - setIsConnected(true) - reconnectAttempts.current = 0 - } - - eventSource.onmessage = (event) => { - try { - const message = safeParseJSON(event.data, SSEMessageSchema, { - type: 'ping', - data: null, - } as SSEMessage) - - switch (message.type) { - case 'connected': - console.log('Connected to notification stream') - break - - case 'notification': - const validationResult = RealtimeNotificationSchema.safeParse(message.data) - if (!validationResult.success) { - console.warn('Invalid notification data:', validationResult.error) - break - } - const notification = validationResult.data - setNotifications((prev) => [notification, ...prev].slice(0, 50)) // Keep last 50 - - // Show browser notification if permission granted - if (Notification.permission === 'granted') { - new Notification(notification.title, { - body: notification.message, - icon: '/favicon/favicon-32x32.png', - tag: notification.id, - }) - } - break - - case 'unread_count': - setUnreadCount((message.data as { count: number }).count) - break - - case 'ping': - // Respond to ping to keep connection alive - console.log('Received ping from server') - break - - default: - console.log('Unknown SSE message type:', message.type) - } - } catch (error) { - console.error('Error parsing SSE message:', error) - } - } - - eventSource.onerror = () => { - console.error('SSE connection error') - setIsConnected(false) - - // Attempt to reconnect - if (reconnectAttempts.current < maxReconnectAttempts) { - reconnectAttempts.current++ - const delay = Math.min(1000 * Math.pow(2, reconnectAttempts.current), 30000) // Exponential backoff, max 30s - - console.log( - `Attempting to reconnect in ${delay}ms (attempt ${reconnectAttempts.current})`, - ) - - reconnectTimeoutRef.current = setTimeout(() => { - disconnect() - connect() - }, delay) - } else { - console.error('Max reconnection attempts reached') - } - } - } catch (error) { - console.error('Failed to create SSE connection:', error) - } - }, [user?.id, disconnect]) - - const markAsRead = useCallback((notificationId: string) => { - setNotifications((prev) => - prev.map((notif) => (notif.id === notificationId ? { ...notif, read: true } : notif)), - ) - }, []) - - const clearNotifications = useCallback(() => { - setNotifications([]) - }, []) - - // Auto-connect when user is loaded - useEffect(() => { - if (isLoaded && user?.id) { - connect() - } - - return () => { - disconnect() - } - }, [isLoaded, user?.id, connect, disconnect]) - - // Request notification permission on mount - useEffect(() => { - if ('Notification' in window && Notification.permission === 'default') { - Notification.requestPermission().then((permission) => { - console.log('Notification permission:', permission) - }) - } - }, []) - - // Cleanup on unmount - useEffect(() => { - return () => { - disconnect() - } - }, [disconnect]) - - return { - isConnected, - notifications, - unreadCount, - connect, - disconnect, - markAsRead, - clearNotifications, - } -} diff --git a/src/hooks/useTranslation.tsx b/src/hooks/useTranslation.tsx index a825b7506..f0884edd0 100644 --- a/src/hooks/useTranslation.tsx +++ b/src/hooks/useTranslation.tsx @@ -4,31 +4,60 @@ import { useState, useEffect } from 'react' import { translateTextCached, shouldShowTranslation, getLanguageName } from '@/utils/translation' import type { TranslationResult } from '@/utils/translation.types' -export function useTranslation(content: string) { - const [showTranslated, setShowTranslated] = useState(false) - const [translation, setTranslation] = useState(null) +interface CachedTranslation { + content: string + result: TranslationResult +} + +interface CachedTranslationOption { + content: string + show: boolean +} + +interface Options { + enabled?: boolean +} + +export function useTranslation(content: string, options: Options = {}) { + const enabled = options.enabled ?? true + const [translatedContentKey, setTranslatedContentKey] = useState(null) + const [translationState, setTranslationState] = useState(null) const [isTranslating, setIsTranslating] = useState(false) - const [showTranslationOption, setShowTranslationOption] = useState(false) + const [translationOption, setTranslationOption] = useState(null) + + const translation = translationState?.content === content ? translationState.result : null + const showTranslated = translatedContentKey === content && Boolean(translation) + const showTranslationOption = + enabled && translationOption?.content === content ? translationOption.show : false useEffect(() => { - const shouldTranslate = shouldShowTranslation(content) - setShowTranslationOption(shouldTranslate) + if (!enabled || !content.trim()) return + + let cancelled = false + + async function updateTranslationOption() { + const shouldTranslate = await shouldShowTranslation(content) + if (!cancelled) setTranslationOption({ content, show: shouldTranslate }) + } + + void updateTranslationOption() - setShowTranslated(false) - setTranslation(null) - }, [content]) + return () => { + cancelled = true + } + }, [content, enabled]) const toggleTranslation = async () => { if (translation) { - setShowTranslated(!showTranslated) + setTranslatedContentKey(showTranslated ? null : content) return } setIsTranslating(true) try { const result = await translateTextCached(content) - setTranslation(result) - setShowTranslated(true) + setTranslationState({ content, result }) + setTranslatedContentKey(content) } catch (error) { console.error('Translation failed:', error) } finally { diff --git a/src/lib/analytics/actions.ts b/src/lib/analytics/actions.ts index 2f1a74870..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', @@ -51,12 +55,10 @@ export const ENGAGEMENT_ACTIONS = { COMMENT_VOTE_UP: 'comment_vote_up', GAME_VIEW: 'game_view', LISTING_VIEW: 'listing_view', - STOP_KILLING_GAMES_CTA: 'stop_killing_games_cta', USER_PROFILE_VIEW: 'user_profile_view', VOTE_DOWN: 'vote_down', VOTE_REMINDER_CLICKED: 'vote_reminder_clicked', VOTE_REMINDER_DISMISSED: 'vote_reminder_dismissed', - STOP_KILLING_GAMES_DISMISSED: 'stop_killing_games_dismissed', SUPPORT_BANNER_SHOWN: 'support_banner_shown', SUPPORT_BANNER_DISMISSED: 'support_banner_dismissed', SUPPORT_BANNER_CTA: 'support_banner_cta', diff --git a/src/lib/analytics/analytics.ts b/src/lib/analytics/analytics.ts index 79482c3b1..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, @@ -385,24 +423,6 @@ const analytics = { }) }, - stopKillingGamesDismissed: (params: { timeOnPage: number }) => { - sendAnalyticsEvent({ - category: ANALYTICS_CATEGORIES.ENGAGEMENT, - action: ENGAGEMENT_ACTIONS.STOP_KILLING_GAMES_DISMISSED, - entityType: 'popup', - metadata: { timeOnPage: params.timeOnPage }, - }) - }, - - stopKillingGamesCTA: (params: { timeOnPage: number }) => { - sendAnalyticsEvent({ - category: ANALYTICS_CATEGORIES.ENGAGEMENT, - action: ENGAGEMENT_ACTIONS.STOP_KILLING_GAMES_CTA, - entityType: 'popup', - metadata: { timeOnPage: params.timeOnPage }, - }) - }, - supportBannerShown: (params: { variant: string; page: string }) => { sendAnalyticsEvent({ category: ANALYTICS_CATEGORIES.ENGAGEMENT, @@ -1044,7 +1064,7 @@ const analytics = { }) }, - pageView: (params: { pathname: string; loadTime: number; userId?: string }) => { + pageView: (params: { pathname: string; loadTime?: number; userId?: string }) => { sendAnalyticsEvent({ category: ANALYTICS_CATEGORIES.SESSION, action: SESSION_ACTIONS.PAGE_VIEW, @@ -1062,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/analytics/utils/sendAnalyticsEvent.test.ts b/src/lib/analytics/utils/sendAnalyticsEvent.test.ts index bd149e207..a31dcc38c 100644 --- a/src/lib/analytics/utils/sendAnalyticsEvent.test.ts +++ b/src/lib/analytics/utils/sendAnalyticsEvent.test.ts @@ -5,17 +5,12 @@ const mocks = vi.hoisted(() => ({ isTrackingAllowed: vi.fn(() => true), loggerLog: vi.fn(), sendGAEvent: vi.fn(), - track: vi.fn(), })) vi.mock('@next/third-parties/google', () => ({ sendGAEvent: mocks.sendGAEvent, })) -vi.mock('@vercel/analytics', () => ({ - track: mocks.track, -})) - vi.mock('@/lib/logger', () => ({ logger: { log: mocks.loggerLog, @@ -41,6 +36,7 @@ afterEach(() => { vi.unstubAllEnvs() vi.resetModules() vi.clearAllMocks() + Reflect.deleteProperty(window, 'dataLayer') }) describe('sendAnalyticsEvent', () => { @@ -50,7 +46,6 @@ describe('sendAnalyticsEvent', () => { NEXT_PUBLIC_APP_ENV: 'production', NEXT_PUBLIC_ENABLE_ANALYTICS: 'false', NEXT_PUBLIC_GA_ID: 'G-TEST', - NEXT_PUBLIC_VERCEL_ANALYTICS_ENABLED: 'true', }) sendAnalyticsEvent({ @@ -58,17 +53,20 @@ describe('sendAnalyticsEvent', () => { action: 'support_banner_shown', }) - expect(mocks.track).not.toHaveBeenCalled() expect(mocks.sendGAEvent).not.toHaveBeenCalled() }) - it('sends enabled analytics services when the master analytics flag is enabled', async () => { + it('sends Google Analytics events when analytics are enabled', async () => { + Object.defineProperty(window, 'dataLayer', { + value: [], + configurable: true, + }) + const { sendAnalyticsEvent } = await loadSendAnalyticsEvent({ NODE_ENV: 'production', NEXT_PUBLIC_APP_ENV: 'production', NEXT_PUBLIC_ENABLE_ANALYTICS: 'true', NEXT_PUBLIC_GA_ID: 'G-TEST', - NEXT_PUBLIC_VERCEL_ANALYTICS_ENABLED: 'true', }) sendAnalyticsEvent({ @@ -76,10 +74,27 @@ describe('sendAnalyticsEvent', () => { action: 'support_banner_shown', }) - expect(mocks.track).toHaveBeenCalledWith( + expect(mocks.sendGAEvent).toHaveBeenCalledWith( + 'event', 'support_banner_shown', - expect.objectContaining({ category: ANALYTICS_CATEGORIES.ENGAGEMENT }), + expect.objectContaining({ event_category: ANALYTICS_CATEGORIES.ENGAGEMENT }), ) + }) + + it('initializes dataLayer before sending Google Analytics events', async () => { + const { sendAnalyticsEvent } = await loadSendAnalyticsEvent({ + NODE_ENV: 'production', + NEXT_PUBLIC_APP_ENV: 'production', + NEXT_PUBLIC_ENABLE_ANALYTICS: 'true', + NEXT_PUBLIC_GA_ID: 'G-TEST', + }) + + sendAnalyticsEvent({ + category: ANALYTICS_CATEGORIES.ENGAGEMENT, + action: 'support_banner_shown', + }) + + expect(window.dataLayer).toEqual([]) expect(mocks.sendGAEvent).toHaveBeenCalledWith( 'event', 'support_banner_shown', diff --git a/src/lib/analytics/utils/sendAnalyticsEvent.ts b/src/lib/analytics/utils/sendAnalyticsEvent.ts index e641c518f..2bf866ab6 100644 --- a/src/lib/analytics/utils/sendAnalyticsEvent.ts +++ b/src/lib/analytics/utils/sendAnalyticsEvent.ts @@ -1,16 +1,18 @@ import { sendGAEvent } from '@next/third-parties/google' -import { track } from '@vercel/analytics' import { type AnalyticsEventData } from '@/lib/analytics/analytics.types' import { env } from '@/lib/env' import { logger } from '@/lib/logger' import { isTrackingAllowed } from './isTrackingAllowed' -/** - * Send analytics event with proper consent checking and environment handling - */ + +function ensureGoogleAnalyticsDataLayer() { + if (typeof window === 'undefined') return + + if (!window.dataLayer) window.dataLayer = [] +} + export function sendAnalyticsEvent(params: AnalyticsEventData) { if (!isTrackingAllowed(params.category)) return - // Build event data with proper typing const eventData: Record = { category: params.category, action: params.action, @@ -40,17 +42,15 @@ export function sendAnalyticsEvent(params: AnalyticsEventData) { if (params.duration) eventData.duration = params.duration if (params.value !== undefined) eventData.value = params.value - // Add metadata if (params.metadata) { Object.entries(params.metadata).forEach(([key, value]) => { eventData[key] = value }) } - // Log in development, send it to external services only when explicitly enabled. if (env.IS_DEVELOPMENT_BUILD) { const context = typeof window !== 'undefined' ? 'CLIENT' : 'SERVER' - return logger.log(`📊 Analytics Event [${context}]:`, { + return logger.log(`Analytics Event [${context}]:`, { category: params.category, action: params.action, data: eventData, @@ -58,8 +58,9 @@ export function sendAnalyticsEvent(params: AnalyticsEventData) { } if (typeof window !== 'undefined' && env.ENABLE_ANALYTICS) { - if (env.VERCEL_ANALYTICS_ENABLED) track(params.action, eventData) if (env.GA_ID) { + ensureGoogleAnalyticsDataLayer() + sendGAEvent('event', params.action, { event_category: params.category, ...eventData, diff --git a/src/lib/api.tsx b/src/lib/api.tsx index 385da0237..dd69d5083 100644 --- a/src/lib/api.tsx +++ b/src/lib/api.tsx @@ -2,32 +2,60 @@ 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' import { shouldRetryTRPCQuery } from '@/lib/trpc-client-errors' -import { ms } from '@/utils/time' 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 +const MAX_TRPC_BATCH_ITEMS = 20 + export function TRPCProvider(props: PropsWithChildren) { - const [queryClient] = useState( - () => - new QueryClient({ - defaultOptions: { - queries: { - staleTime: ms.seconds(30), - gcTime: ms.minutes(5), - 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 +64,8 @@ export function TRPCProvider(props: PropsWithChildren) { url: '/api/trpc', transformer: superjson, headers: () => ({}), - maxURLLength: 2000, + maxURLLength: MAX_URL_LENGTH, + maxItems: MAX_TRPC_BATCH_ITEMS, }), ], }), diff --git a/src/lib/cors.test.ts b/src/lib/cors.test.ts index fd85b0d6c..3c7f151ee 100644 --- a/src/lib/cors.test.ts +++ b/src/lib/cors.test.ts @@ -1,3 +1,4 @@ +import { NextRequest } from 'next/server' import { afterEach, describe, expect, it, vi } from 'vitest' const allowedOrigins = ['https://emuready.com', 'capacitor://localhost'] @@ -124,3 +125,36 @@ describe('isAllowedRequestOrigin', () => { ).toBe(false) }) }) + +describe('getCORSHeaders', () => { + it('allows mobile authentication headers for configured origins', async () => { + const { getCORSHeaders } = await loadCors({ + ALLOWED_ORIGINS: 'capacitor://localhost', + }) + + const request = new NextRequest('https://emuready.com/api/mobile/trpc/games.get', { + headers: { origin: 'capacitor://localhost' }, + }) + const headers = getCORSHeaders(request) + + expect(headers['Access-Control-Allow-Origin']).toBe('capacitor://localhost') + expect(headers['Access-Control-Allow-Headers']).toContain('x-api-key') + expect(headers['Access-Control-Allow-Headers']).toContain('x-auth-token') + expect(headers['Access-Control-Allow-Headers']).toContain('x-trpc-source') + expect(headers['Access-Control-Expose-Headers']).toContain('x-trpc-source') + expect(headers.Vary).toBe('Origin') + }) + + it('does not echo unconfigured origins', async () => { + const { getCORSHeaders } = await loadCors({ + ALLOWED_ORIGINS: 'https://emuready.com', + NEXT_PUBLIC_APP_ENV: 'production', + }) + + const request = new NextRequest('https://emuready.com/api/mobile/trpc/games.get', { + headers: { origin: 'https://attacker.example' }, + }) + + expect(getCORSHeaders(request)['Access-Control-Allow-Origin']).toBe('null') + }) +}) diff --git a/src/lib/cors.ts b/src/lib/cors.ts index 991003ea9..67aa64fc3 100644 --- a/src/lib/cors.ts +++ b/src/lib/cors.ts @@ -28,6 +28,16 @@ const LOCAL_TEST_ORIGINS = [ 'http://127.0.0.1:3000', ] +const CORS_ALLOWED_METHODS = 'GET, POST, PUT, DELETE, OPTIONS' +const CORS_ALLOWED_HEADERS = [ + 'Content-Type', + 'Authorization', + 'x-api-key', + 'x-auth-token', + 'x-trpc-source', +].join(', ') +const CORS_EXPOSED_HEADERS = ['Content-Type', 'x-trpc-source'].join(', ') + function addMissingOrigins(origins: string[], additionalOrigins: string[]) { for (const origin of additionalOrigins) { if (!origins.includes(origin)) origins.push(origin) @@ -116,9 +126,11 @@ export function getCORSHeaders(request?: NextRequest): Record { console.error('CORS Error: No allowed origins configured in production') return { 'Access-Control-Allow-Origin': 'null', - 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS', - 'Access-Control-Allow-Headers': 'Content-Type, Authorization', + 'Access-Control-Allow-Methods': CORS_ALLOWED_METHODS, + 'Access-Control-Allow-Headers': CORS_ALLOWED_HEADERS, + 'Access-Control-Expose-Headers': CORS_EXPOSED_HEADERS, 'Access-Control-Allow-Credentials': 'true', + Vary: 'Origin', } } @@ -132,8 +144,10 @@ export function getCORSHeaders(request?: NextRequest): Record { return { 'Access-Control-Allow-Origin': allowOrigin, - 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS', - 'Access-Control-Allow-Headers': 'Content-Type, Authorization', + 'Access-Control-Allow-Methods': CORS_ALLOWED_METHODS, + 'Access-Control-Allow-Headers': CORS_ALLOWED_HEADERS, + 'Access-Control-Expose-Headers': CORS_EXPOSED_HEADERS, 'Access-Control-Allow-Credentials': 'true', + Vary: 'Origin', } } diff --git a/src/lib/dynamic-imports.tsx b/src/lib/dynamic-imports.tsx index 09cb259af..eeb137fe0 100644 --- a/src/lib/dynamic-imports.tsx +++ b/src/lib/dynamic-imports.tsx @@ -34,8 +34,3 @@ export const RolePermissionMatrix = dynamic( () => import('@/app/admin/permissions/components/RolePermissionMatrix'), { loading: LoadingFallback }, ) - -export const TrustStatsOverview = dynamic( - () => import('@/app/admin/trust-logs/components/TrustStatsOverview'), - { loading: LoadingFallback }, -) diff --git a/src/lib/env.ts b/src/lib/env.ts index 06b815593..8b7d4ef90 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -18,7 +18,6 @@ interface Env { GA_ID: string LOCAL_STORAGE_PREFIX: string ENABLE_SW: boolean - VERCEL_ANALYTICS_ENABLED: boolean DISABLE_COOKIE_BANNER: boolean APP_ENV: AppEnv IS_PUBLIC_PRODUCTION: boolean @@ -28,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 @@ -79,8 +77,6 @@ export const env = { ENABLE_SW: process.env.NEXT_PUBLIC_ENABLE_SW === 'true', - VERCEL_ANALYTICS_ENABLED: process.env.NEXT_PUBLIC_VERCEL_ANALYTICS_ENABLED === 'true', - DISABLE_COOKIE_BANNER: process.env.NEXT_PUBLIC_DISABLE_COOKIE_BANNER === 'true', APP_ENV, @@ -92,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 0c6cc8e55..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,9 +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 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 = { @@ -517,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/lib/seo/metadata.ts b/src/lib/seo/metadata.ts index 7960c4ba8..8029050e7 100644 --- a/src/lib/seo/metadata.ts +++ b/src/lib/seo/metadata.ts @@ -79,14 +79,6 @@ export const defaultMetadata: Metadata = { }, other: { 'theme-color': '#111828', - 'google-site-verification': process.env.NEXT_PUBLIC_GOOGLE_SITE_VERIFICATION || '', // TODO: add if we start caring - 'msvalidate.01': process.env.NEXT_PUBLIC_BING_SITE_VERIFICATION || '', // TODO: add if we start caring - 'yandex-verification': process.env.NEXT_PUBLIC_YANDEX_VERIFICATION || '', // TODO: add if we start caring - 'fb:app_id': process.env.NEXT_PUBLIC_FACEBOOK_APP_ID || '', // TODO: add if we start caring - }, - verification: { - google: process.env.NEXT_PUBLIC_GOOGLE_SITE_VERIFICATION, // TODO: add if we start caring - yandex: process.env.NEXT_PUBLIC_YANDEX_VERIFICATION, // TODO: add if we start caring }, appleWebApp: { capable: true, diff --git a/src/proxy.test.ts b/src/proxy.test.ts new file mode 100644 index 000000000..7783cb718 --- /dev/null +++ b/src/proxy.test.ts @@ -0,0 +1,73 @@ +import { NextRequest } from 'next/server' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { NextFetchEvent } from 'next/server' + +vi.mock('@clerk/nextjs/server', () => ({ + createRouteMatcher: () => () => false, + clerkMiddleware: + (handler: (auth: { protect: () => Promise }, req: NextRequest) => Promise) => + (req: NextRequest) => + handler({ protect: vi.fn(async () => undefined) }, req), +})) + +async function loadProxy() { + vi.resetModules() + vi.stubEnv('NEXT_PUBLIC_APP_ENV', 'production') + vi.stubEnv('NODE_ENV', 'production') + vi.stubEnv('PLAYWRIGHT_TEST', '') + vi.stubEnv('DISABLE_RATE_LIMIT', 'true') + vi.stubEnv('NEXT_PUBLIC_ALLOWED_ORIGINS', 'https://emuready.com') + vi.stubEnv('NEXT_PUBLIC_APP_URL', 'https://emuready.com') + + return import('./proxy') +} + +function createMobileTRPCRequest(headers: HeadersInit = {}, method = 'GET') { + return new NextRequest('https://emuready.com/api/mobile/trpc/games.get', { + method, + headers, + }) +} + +const fetchEvent = { waitUntil: vi.fn() } as unknown as NextFetchEvent + +afterEach(() => { + vi.unstubAllEnvs() + vi.resetModules() +}) + +describe('proxy mobile tRPC origin handling', () => { + it('allows native mobile tRPC requests without browser origin metadata', async () => { + const { proxy } = await loadProxy() + + const response = await proxy(createMobileTRPCRequest(), fetchEvent) + + expect(response.status).toBe(200) + expect(response.headers.get('X-Content-Type-Options')).toBe('nosniff') + }) + + it('rejects mobile tRPC requests that include an untrusted browser origin', async () => { + const { proxy } = await loadProxy() + + const response = await proxy( + createMobileTRPCRequest({ origin: 'https://attacker.example' }), + fetchEvent, + ) + + expect(response.status).toBe(403) + await expect(response.json()).resolves.toEqual({ + error: 'Access denied. Invalid origin.', + }) + }) + + it('lets mobile CORS preflight reach the route handler', async () => { + const { proxy } = await loadProxy() + + const response = await proxy( + createMobileTRPCRequest({ origin: 'https://attacker.example' }, 'OPTIONS'), + fetchEvent, + ) + + expect(response.status).toBe(200) + }) +}) diff --git a/src/proxy.ts b/src/proxy.ts index 216cc0955..2618f564a 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -78,6 +78,10 @@ function checkRateLimit(identifier: string): boolean { return true } +function hasRequestOriginMetadata(req: NextRequest): boolean { + return Boolean(req.headers.get('origin') || req.headers.get('referer')) +} + function isValidOrigin(req: NextRequest): boolean { const origin = req.headers.get('origin') const referer = req.headers.get('referer') @@ -105,6 +109,14 @@ function isValidOrigin(req: NextRequest): boolean { return false } +function isMobileTRPCPath(pathname: string): boolean { + return pathname.startsWith('/api/mobile/trpc/') || pathname.startsWith('/api/trpc/mobile.') +} + +function isProtectedTRPCPath(pathname: string): boolean { + return pathname.startsWith('/api/trpc/') || pathname.startsWith('/api/mobile/trpc/') +} + function isSameOriginSource(req: NextRequest, source: string | null): boolean { const sourceOrigin = getOriginFromUrl(source ?? '') if (!sourceOrigin) return false @@ -117,12 +129,11 @@ function isSameOriginSource(req: NextRequest, source: string | null): boolean { function protectTRPCAPI(req: NextRequest): NextResponse | null { const pathname = req.nextUrl.pathname + const isMobileTRPC = isMobileTRPCPath(pathname) - if (pathname.startsWith('/api/mobile/trpc/')) return null + if (!isProtectedTRPCPath(pathname)) return null - if (!pathname.startsWith('/api/trpc/')) return null - - if (pathname.startsWith('/api/trpc/mobile.')) return null + if (isMobileTRPC && req.method === 'OPTIONS') return null const clientId = getClientIdentifier(req) @@ -146,7 +157,11 @@ function protectTRPCAPI(req: NextRequest): NextResponse | null { ) } - if (!IS_AUTOMATED_TEST_ENVIRONMENT && !isValidOrigin(req)) { + const hasInvalidOrigin = + !IS_AUTOMATED_TEST_ENVIRONMENT && + (isMobileTRPC ? hasRequestOriginMetadata(req) && !isValidOrigin(req) : !isValidOrigin(req)) + + if (hasInvalidOrigin) { console.warn( `Invalid origin for client: ${clientId}, origin: ${req.headers.get('origin')}, referer: ${req.headers.get('referer')}, path: ${pathname}`, ) diff --git a/src/schemas/apiAccess.ts b/src/schemas/apiAccess.ts index a8d1abe2b..02f704a75 100644 --- a/src/schemas/apiAccess.ts +++ b/src/schemas/apiAccess.ts @@ -1,11 +1,11 @@ import { z } from 'zod' +import { SortDirectionSchema } from '@/schemas/common' import { ApiUsagePeriod } from '@orm' const quotaValueSchema = z.number().int().positive().max(1_000_000_000) const quotaSchema = quotaValueSchema.or(z.literal(0)).or(z.null()) export const ApiKeySortFieldSchema = z.enum(['name', 'createdAt', 'lastUsedAt', 'monthlyQuota']) -export const SortDirectionSchema = z.enum(['asc', 'desc']) export const CreateApiKeySchema = z.object({ name: z.string({ description: 'Friendly label for the key' }).trim().min(1).max(100).optional(), diff --git a/src/schemas/audit.ts b/src/schemas/audit.ts index 8eb1a9066..c9410977b 100644 --- a/src/schemas/audit.ts +++ b/src/schemas/audit.ts @@ -1,8 +1,7 @@ import { z } from 'zod' +import { SortDirectionSchema } from '@/schemas/common' import { AuditAction, AuditEntityType } from '@orm' -export const SortDirection = z.enum(['asc', 'desc']) - export const AuditLogSortField = z.enum([ 'createdAt', 'action', @@ -19,7 +18,7 @@ export const GetAuditLogsSchema = z actorId: z.string().uuid().optional(), targetUserId: z.string().uuid().optional(), sortField: AuditLogSortField.optional(), - sortDirection: SortDirection.optional(), + sortDirection: SortDirectionSchema.optional(), page: z.number().int().min(1).default(1), limit: z.number().int().min(1).max(100).default(50), dateFrom: z.string().datetime().optional(), diff --git a/src/schemas/common.ts b/src/schemas/common.ts index 06191bff6..5df2e13ea 100644 --- a/src/schemas/common.ts +++ b/src/schemas/common.ts @@ -1,11 +1,23 @@ import { z } from 'zod' +export const SortDirectionSchema = z.enum(['asc', 'desc']) +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({ search: z.string().default(''), page: z.number().int().positive().default(1), sortField: z.string().nullable().default(null), - sortDirection: z.enum(['asc', 'desc']).nullable().default(null), // TODO: extract + sortDirection: SortDirectionSchema.nullable().default(null), }) export const JsonValueSchema: z.ZodType = z.lazy(() => @@ -28,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 21490f726..000000000 --- a/src/schemas/cpu.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { z } from 'zod' -import { SortDirection } from '@/schemas/soc' - -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: SortDirection.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() }) - -// Type exports for repository use -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 ffca2eb24..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 { SortDirection } from '@/schemas/soc' +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']) @@ -13,7 +14,7 @@ export const GetDevicesSchema = z offset: z.number().default(0), page: z.number().optional(), sortField: DeviceSortField.nullable().optional(), - sortDirection: SortDirection.nullable().optional(), + sortDirection: SortDirectionSchema.nullable().optional(), }) .optional() @@ -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(), @@ -49,7 +51,6 @@ export const GetTrendingDevicesSummarySchema = z.object({ limit: z.number().int().min(1).max(20).default(HOME_PAGE_LIMITS.TRENDING_DEVICES), }) -// Type exports for repository use export type GetDevicesInput = z.input export type GetDeviceOptionsInput = z.input export type CreateDeviceInput = z.infer diff --git a/src/schemas/deviceBrand.ts b/src/schemas/deviceBrand.ts index bba2fedf2..b62aa28a2 100644 --- a/src/schemas/deviceBrand.ts +++ b/src/schemas/deviceBrand.ts @@ -1,14 +1,16 @@ import { z } from 'zod' +import { SortDirectionSchema } from '@/schemas/common' export const DeviceBrandSortField = z.enum(['name', 'devicesCount']) -export const SortDirection = z.enum(['asc', 'desc']) +export const DeviceBrandCategory = z.enum(['cpu', 'gpu']) export const GetDeviceBrandsSchema = z .object({ search: z.string().optional(), + category: DeviceBrandCategory.optional(), limit: z.number().default(50), sortField: DeviceBrandSortField.optional(), - sortDirection: SortDirection.optional(), + sortDirection: SortDirectionSchema.optional(), }) .optional() @@ -25,7 +27,6 @@ export const UpdateDeviceBrandSchema = z.object({ export const DeleteDeviceBrandSchema = z.object({ id: z.string().uuid() }) -// Type exports for repository use export type GetDeviceBrandsInput = z.input export type CreateDeviceBrandInput = z.infer export type UpdateDeviceBrandInput = z.infer diff --git a/src/schemas/emulator.ts b/src/schemas/emulator.ts index 241226456..ce36be38b 100644 --- a/src/schemas/emulator.ts +++ b/src/schemas/emulator.ts @@ -1,5 +1,5 @@ import { z } from 'zod' -import { SortDirection } from '@/schemas/soc' +import { SortDirectionSchema } from '@/schemas/common' export const EmulatorSortField = z.enum(['name', 'systemCount', 'listingCount']) @@ -10,7 +10,7 @@ export const GetEmulatorsSchema = z offset: z.number().default(0), page: z.number().optional(), sortField: EmulatorSortField.optional(), - sortDirection: SortDirection.optional(), + sortDirection: SortDirectionSchema.optional(), }) .optional() @@ -46,7 +46,6 @@ export const UpdateSupportedSystemsSchema = z.object({ systemIds: z.array(z.string().uuid()), }) -// Type exports for repository use export type GetEmulatorsInput = z.input export type CreateEmulatorInput = z.infer export type UpdateEmulatorInput = z.infer diff --git a/src/schemas/game.ts b/src/schemas/game.ts index 74e62059d..ee2e734b8 100644 --- a/src/schemas/game.ts +++ b/src/schemas/game.ts @@ -1,5 +1,6 @@ import { z } from 'zod' import { HumanVerificationTokenSchema } from '@/features/human-verification/shared/schema' +import { SortDirectionSchema } from '@/schemas/common' import { ApprovalStatus } from '@orm' export const GameSortField = z.enum([ @@ -10,8 +11,6 @@ export const GameSortField = z.enum([ 'status', ]) -export const SortDirection = z.enum(['asc', 'desc']) - export const GameListingFilter = z.enum(['all', 'withListings', 'noListings']) export const GameApprovalStatusSchema = z.enum([ @@ -41,7 +40,7 @@ export const GetGamesSchema = z offset: z.number().default(0), page: z.number().optional(), sortField: GameSortField.nullable().optional(), - sortDirection: SortDirection.nullable().optional(), + sortDirection: SortDirectionSchema.nullable().optional(), }) .optional() .transform((data) => { @@ -70,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(), @@ -86,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(), }) @@ -135,7 +129,7 @@ export const GetPendingGamesSchema = z offset: z.number().default(0), page: z.number().optional(), sortField: GameSortField.optional(), - sortDirection: SortDirection.optional(), + sortDirection: SortDirectionSchema.optional(), }) .optional() @@ -163,7 +157,6 @@ export const GetBestThreeDsTitleIdSchema = z.object({ export const GetThreeDsGamesStatsSchema = z.object({}).optional() -// Type exports for repository use export type GetGamesInput = z.input export type CreateGameInput = z.infer export type UpdateGameInput = z.infer diff --git a/src/schemas/gpu.ts b/src/schemas/gpu.ts deleted file mode 100644 index fa2ad1d3f..000000000 --- a/src/schemas/gpu.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { z } from 'zod' -import { SortDirection } from '@/schemas/soc' - -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: SortDirection.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() }) - -// Type exports for repository use -// Use z.input for types that include defaults (what you pass in) -// Use z.output for types after defaults are applied (what you get out) -// TODO: figure out why we use z.infer -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 eb03bcf22..0e5fc5638 100644 --- a/src/schemas/listingReport.ts +++ b/src/schemas/listingReport.ts @@ -1,5 +1,5 @@ import { z } from 'zod' -import { SortDirection } from '@/schemas/soc' +import { SortDirectionSchema } from '@/schemas/common' import { ReportReason, ReportStatus } from '@orm' export const ReportReasonSchema = z.nativeEnum(ReportReason) @@ -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 @@ -25,9 +25,9 @@ export const GetListingReportsSchema = z status: ReportStatusSchema.optional(), reason: ReportReasonSchema.optional(), sortField: ListingReportSortField.optional(), - sortDirection: SortDirection.optional(), - page: z.number().min(1).default(1), - limit: z.number().min(1).max(100).default(20), + sortDirection: SortDirectionSchema.optional(), + 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..3dc8e3707 100644 --- a/src/schemas/mobile.ts +++ b/src/schemas/mobile.ts @@ -1,8 +1,17 @@ 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' +const MOBILE_SEARCH_QUERY_MAX_LENGTH = 100 +const MOBILE_GAME_NAME_MAX_LENGTH = 120 +const MOBILE_EMULATOR_NAME_MAX_LENGTH = 100 +const MOBILE_DEVICE_MODEL_MAX_LENGTH = 120 +const MOBILE_DEVICE_BRAND_MAX_LENGTH = 80 +const MOBILE_SYSTEM_FILTER_LIMIT = 100 +const CATALOG_MIN_LISTING_COUNT_MAX = 100 + // Type-safe custom field value schema using discriminated union const CustomFieldValueSchema = z.discriminatedUnion('type', [ z.object({ @@ -69,38 +78,56 @@ export const GetListingsByGameSchema = z.object({ }) export const SearchGamesSchema = z.object({ - query: z.string().min(1), + query: z.string().min(1).max(MOBILE_SEARCH_QUERY_MAX_LENGTH), }) export const FindSwitchTitleIdMobileSchema = z.object({ - gameName: z.string().min(2, 'Game name must be at least 2 characters'), + gameName: z + .string() + .min(2, 'Game name must be at least 2 characters') + .max(MOBILE_GAME_NAME_MAX_LENGTH), maxResults: z.number().min(1).max(20).default(5), }) export const GetBestSwitchTitleIdMobileSchema = z.object({ - gameName: z.string().min(2, 'Game name must be at least 2 characters'), + gameName: z + .string() + .min(2, 'Game name must be at least 2 characters') + .max(MOBILE_GAME_NAME_MAX_LENGTH), }) export const GetSwitchGamesStatsMobileSchema = z.object({}).optional() export const FindThreeDsTitleIdMobileSchema = z.object({ - gameName: z.string().min(2, 'Game name must be at least 2 characters'), + gameName: z + .string() + .min(2, 'Game name must be at least 2 characters') + .max(MOBILE_GAME_NAME_MAX_LENGTH), maxResults: z.number().min(1).max(20).default(5), }) export const GetBestThreeDsTitleIdMobileSchema = z.object({ - gameName: z.string().min(2, 'Game name must be at least 2 characters'), + gameName: z + .string() + .min(2, 'Game name must be at least 2 characters') + .max(MOBILE_GAME_NAME_MAX_LENGTH), }) export const GetThreeDsGamesStatsMobileSchema = z.object({}).optional() export const FindSteamAppIdMobileSchema = z.object({ - gameName: z.string().min(2, 'Game name must be at least 2 characters'), + gameName: z + .string() + .min(2, 'Game name must be at least 2 characters') + .max(MOBILE_GAME_NAME_MAX_LENGTH), maxResults: z.number().min(1).max(20).default(5), }) export const GetBestSteamAppIdMobileSchema = z.object({ - gameName: z.string().min(2, 'Game name must be at least 2 characters'), + gameName: z + .string() + .min(2, 'Game name must be at least 2 characters') + .max(MOBILE_GAME_NAME_MAX_LENGTH), }) export const GetSteamGamesStatsMobileSchema = z.object({}).nullish() @@ -111,7 +138,11 @@ export const BatchBySteamAppIdsSchema = z.object({ .min(1, 'At least one Steam App ID is required') .max(1000, 'Maximum 1000 Steam App IDs per request') .describe('Steam App IDs to lookup (1-1000)'), - emulatorName: z.string().optional().describe('Filter listings by emulator name'), + emulatorName: z + .string() + .max(MOBILE_EMULATOR_NAME_MAX_LENGTH) + .optional() + .describe('Filter listings by emulator name'), maxListingsPerGame: z .number() .min(1) @@ -125,6 +156,148 @@ export const BatchBySteamAppIdsSchema = z.object({ .describe('Return minimal response with only essential fields'), }) +const BatchSteamSocSchema = z + .object({ + id: z.string(), + name: z.string(), + manufacturer: z.string().nullable(), + architecture: z.string().nullable(), + processNode: z.string().nullable(), + cpuCores: z.number().nullable(), + gpuModel: z.string().nullable(), + }) + .passthrough() + +const BatchSteamDeviceSchema = z + .object({ + id: z.string(), + modelName: z.string(), + soc: BatchSteamSocSchema.nullable(), + }) + .passthrough() + +const BatchSteamEmulatorSchema = z + .object({ + id: z.string(), + name: z.string(), + logo: z.string().nullable(), + }) + .passthrough() + +const BatchSteamPerformanceSchema = z + .object({ + id: z.number(), + label: z.string(), + rank: z.number(), + description: z.string().nullable(), + }) + .passthrough() + +const BatchSteamListingSummarySchema = z + .object({ + id: z.string().nullable(), + notes: z.string().nullable(), + upvoteCount: z.number(), + downvoteCount: z.number(), + voteCount: z.number(), + successRate: z.number().nullable(), + }) + .passthrough() + +const BatchSteamListingSchema = BatchSteamListingSummarySchema.extend({ + id: z.string(), + deviceId: z.string(), + gameId: z.string(), + emulatorId: z.string(), + performanceId: z.number(), + device: BatchSteamDeviceSchema, + emulator: BatchSteamEmulatorSchema, + performance: BatchSteamPerformanceSchema, + customFieldValues: z.array( + z + .object({ + id: z.string(), + listingId: z.string(), + customFieldDefinitionId: z.string(), + value: JsonValueSchema, + customFieldDefinition: z + .object({ + id: z.string(), + type: z.string(), + label: z.string(), + name: z.string(), + }) + .passthrough(), + }) + .passthrough(), + ), +}).passthrough() + +const BatchSteamGameSchema = z + .object({ + id: z.string(), + title: z.string(), + normalizedTitle: z.string().nullable().optional(), + systemId: z.string(), + imageUrl: z.string().nullable(), + boxartUrl: z.string().nullable(), + bannerUrl: z.string().nullable(), + tgdbGameId: z.number().nullable(), + metadata: z.unknown(), + isErotic: z.boolean(), + ageRating: z.string().nullable().optional(), + status: z.string(), + createdAt: z.date(), + system: z + .object({ + id: z.string(), + name: z.string(), + key: z.string().nullable(), + }) + .passthrough(), + _count: z + .object({ + listings: z.number(), + }) + .passthrough(), + listings: z.array(BatchSteamListingSchema), + }) + .passthrough() + +export const BatchSteamFullResultSchema = z + .object({ + steamAppId: z.string(), + game: BatchSteamGameSchema.nullable(), + matchStrategy: z.enum(['metadata', 'exact', 'normalized', 'not_found']), + }) + .passthrough() + +export const BatchSteamMinimalResultSchema = z + .object({ + game_id: z.string().nullable(), + steam_app_id: z.string(), + title: z.string().nullable(), + performance: BatchSteamPerformanceSchema.nullable(), + emulator: BatchSteamEmulatorSchema.nullable(), + device: BatchSteamDeviceSchema.nullable(), + listing: BatchSteamListingSummarySchema.nullable(), + }) + .passthrough() + +export const BatchBySteamAppIdsResponseSchema = z + .object({ + success: z.literal(true), + results: z.array(z.union([BatchSteamFullResultSchema, BatchSteamMinimalResultSchema])), + totalRequested: z.number(), + totalFound: z.number(), + totalNotFound: z.number(), + }) + .passthrough() + +export type BatchGameResult = z.output +export type MinimalGameResult = z.output +export type BatchBySteamAppIdsResponse = z.output + export const GetListingCommentsSchema = z.object({ listingId: z.string().uuid(), }) @@ -214,7 +387,11 @@ export const GetListingsSchema = z .array(z.union([z.number(), z.string().transform(Number)])) .optional() .describe('Filter by performance IDs'), - search: z.string().optional().describe('Search listings by game name'), + search: z + .string() + .max(MOBILE_SEARCH_QUERY_MAX_LENGTH) + .optional() + .describe('Search listings by game name'), }) .optional() .describe('Get listings with optional filters and pagination') @@ -223,7 +400,7 @@ export type GetListingsInput = z.infer export const GetGamesSchema = z .object({ - search: z.string().optional(), + search: z.string().max(MOBILE_SEARCH_QUERY_MAX_LENGTH).optional(), systemId: z.string().uuid().optional(), page: z .number() @@ -237,17 +414,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(), @@ -279,7 +445,11 @@ export type GetGamesResponse = z.infer export const GetDevicesSchema = z .object({ - search: z.string().optional().describe('Search devices by name'), + search: z + .string() + .max(MOBILE_SEARCH_QUERY_MAX_LENGTH) + .optional() + .describe('Search devices by name'), brandId: z.string().uuid().optional().describe('Filter by brand ID'), limit: z.number().min(1).max(1000).default(50).describe('Number of results to return (1-1000)'), }) @@ -288,7 +458,7 @@ export const GetDevicesSchema = z export const GetEmulatorsSchema = z.object({ systemId: z.string().uuid().optional(), - search: z.string().optional(), + search: z.string().max(MOBILE_SEARCH_QUERY_MAX_LENGTH).optional(), limit: z.number().min(1).max(100).default(50), }) @@ -324,7 +494,7 @@ export const UpdateNotificationPreferenceMobileSchema = z.object({ }) export const SearchSuggestionsSchema = z.object({ - query: z.string().min(1), + query: z.string().min(1).max(MOBILE_SEARCH_QUERY_MAX_LENGTH), limit: z.number().min(1).max(20).default(10), }) @@ -414,23 +584,11 @@ export const GetPcListingsSchema = z.object({ gpuId: z.string().uuid().optional(), emulatorId: z.string().uuid().optional(), os: z.nativeEnum(PcOs).optional(), - search: z.string().optional(), + search: z.string().max(MOBILE_SEARCH_QUERY_MAX_LENGTH).optional(), minMemory: z.number().min(1).max(256).optional(), 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), }) @@ -464,7 +622,7 @@ export const MobileAdminGetStatsSchema = z.object({}).optional() export const MobileAdminGetPendingListingsSchema = z .object({ - search: z.string().optional(), + search: z.string().max(MOBILE_SEARCH_QUERY_MAX_LENGTH).optional(), page: z .number() .min(1) @@ -486,7 +644,7 @@ export const MobileAdminRejectListingSchema = z.object({ export const MobileAdminGetPendingGamesSchema = z .object({ - search: z.string().optional(), + search: z.string().max(MOBILE_SEARCH_QUERY_MAX_LENGTH).optional(), page: z .number() .min(1) @@ -555,10 +713,19 @@ export const MobileAdminUpdateUserBanSchema = z.object({ export const GetDeviceCompatibilitySchema = z .object({ deviceId: z.string().uuid().optional().describe('Device UUID to fetch compatibility data for'), - deviceModelName: z.string().optional().describe('Device model name (e.g., "Pocket 5")'), - deviceBrandName: z.string().optional().describe('Device brand name (e.g., "Retroid")'), + deviceModelName: z + .string() + .max(MOBILE_DEVICE_MODEL_MAX_LENGTH) + .optional() + .describe('Device model name (e.g., "Pocket 5")'), + deviceBrandName: z + .string() + .max(MOBILE_DEVICE_BRAND_MAX_LENGTH) + .optional() + .describe('Device brand name (e.g., "Retroid")'), systemIds: z .array(z.string().uuid()) + .max(MOBILE_SYSTEM_FILTER_LIMIT) .optional() .describe('Filter results to specific system IDs'), includeEmulatorBreakdown: z @@ -568,6 +735,7 @@ export const GetDeviceCompatibilitySchema = z minListingCount: z .number() .min(0) + .max(CATALOG_MIN_LISTING_COUNT_MAX) .default(1) .describe('Minimum number of listings required to include a system'), }) 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 5fcdb9563..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' @@ -66,27 +66,32 @@ export const GetPendingPcListingsSchema = z export const DeletePcListingSchema = z.object({ id: z.string().uuid() }) -// TODO: Wire up a PC admin processed-listings page + router procedure for -// parity with handheld (`admin.getProcessed` + `src/app/admin/processed-listings/`). -// When doing so, extend this schema with `sortField` / `sortDirection` using the -// same shape as `GetProcessedSchema` in `./listing.ts`, and ideally share as much -// of the admin router logic as possible (the two codebases are drifting — fixes -// applied to handheld listings often miss their PC counterpart). Candidates for -// shared code: `buildProcessedOrderBy`, the search `where` builder, the -// approval-flow branches. See also: `src/server/api/utils/listingHelpers.ts` -// (handheld) vs `pcListingHelpers.ts` (PC) — these helpers already exist and -// should be the basis for a shared abstraction. export const GetProcessedPcSchema = z.object({ page: z.number().default(1), limit: z.number().default(10), - filterStatus: z.nativeEnum(ApprovalStatus).optional(), - search: z.string().optional(), + filterStatus: z.nativeEnum(ApprovalStatus).nullable().optional(), + search: z.string().nullable().optional(), + sortField: z + .enum([ + 'processedAt', + 'createdAt', + 'status', + 'game.title', + 'game.system.name', + 'cpu', + 'gpu', + 'emulator.name', + 'author.name', + ]) + .nullable() + .optional(), + sortDirection: z.enum(['asc', 'desc']).nullable().optional(), }) export const OverridePcApprovalStatusSchema = z.object({ pcListingId: z.string().uuid(), newStatus: z.nativeEnum(ApprovalStatus), // PENDING, APPROVED, or REJECTED - overrideNotes: z.string().optional(), + overrideNotes: z.string().nullable().optional(), }) export const ResetPcListingToPendingSchema = z.object({ @@ -115,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), @@ -189,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), @@ -269,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), @@ -276,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({ @@ -302,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/performanceScale.ts b/src/schemas/performanceScale.ts index 50c1a5b65..a69268ba4 100644 --- a/src/schemas/performanceScale.ts +++ b/src/schemas/performanceScale.ts @@ -1,5 +1,5 @@ import { z } from 'zod' -import { SortDirection } from '@/schemas/soc' +import { SortDirectionSchema } from '@/schemas/common' export const PerformanceScaleSortField = z.enum(['label', 'rank']) @@ -7,7 +7,7 @@ export const GetPerformanceScalesSchema = z .object({ search: z.string().optional(), sortField: PerformanceScaleSortField.optional(), - sortDirection: SortDirection.optional(), + sortDirection: SortDirectionSchema.optional(), }) .optional() @@ -26,9 +26,16 @@ export const UpdatePerformanceScaleSchema = z.object({ description: z.string().optional(), }) -export const DeletePerformanceScaleSchema = z.object({ id: z.number() }) +export const DeletePerformanceScaleSchema = z + .object({ + id: z.number(), + replacementId: z.number().optional(), + }) + .refine((data) => data.replacementId === undefined || data.replacementId !== data.id, { + message: 'Replacement performance scale must be different from the deleted scale', + path: ['replacementId'], + }) -// Type exports for repository use export type GetPerformanceScalesInput = z.input export type CreatePerformanceScaleInput = z.infer export type UpdatePerformanceScaleInput = z.infer diff --git a/src/schemas/permission.ts b/src/schemas/permission.ts index a5a3d80cc..2da193bd9 100644 --- a/src/schemas/permission.ts +++ b/src/schemas/permission.ts @@ -1,11 +1,10 @@ import { z } from 'zod' +import { SortDirectionSchema } from '@/schemas/common' import { Role, PermissionActionType } from '@orm' // Sorting and filtering schemas export const PermissionSortField = z.enum(['label', 'key', 'category', 'createdAt', 'updatedAt']) -export const SortDirection = z.enum(['asc', 'desc']) - export const PermissionCategory = z.enum(['CONTENT', 'MODERATION', 'USER_MANAGEMENT', 'SYSTEM']) // Get all permissions schema @@ -14,7 +13,7 @@ export const GetAllPermissionsSchema = z search: z.string().optional(), category: PermissionCategory.optional(), sortField: PermissionSortField.optional(), - sortDirection: SortDirection.optional(), + sortDirection: SortDirectionSchema.optional(), page: z.number().int().min(1).default(1), limit: z.number().int().min(1).max(100).default(50), includeSystemOnly: z.boolean().optional(), @@ -79,7 +78,7 @@ export const GetPermissionLogsSchema = z targetRole: z.nativeEnum(Role).optional(), permissionId: z.string().uuid().optional(), sortField: PermissionLogSortField.optional(), - sortDirection: SortDirection.optional(), + sortDirection: SortDirectionSchema.optional(), page: z.number().int().min(1).default(1), limit: z.number().int().min(1).max(100).default(20), dateFrom: z.string().datetime().optional(), diff --git a/src/schemas/soc.ts b/src/schemas/soc.ts index 0baa2c543..0ae44a509 100644 --- a/src/schemas/soc.ts +++ b/src/schemas/soc.ts @@ -1,7 +1,9 @@ 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']) -export const SortDirection = z.enum(['asc', 'desc']) export const GetSoCsSchema = z .object({ @@ -10,16 +12,15 @@ export const GetSoCsSchema = z offset: z.number().default(0), page: z.number().optional(), sortField: SoCSortField.optional(), - sortDirection: SortDirection.optional(), + sortDirection: SortDirectionSchema.optional(), }) .optional() 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,9 +42,10 @@ 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), +}) -// Type exports for repository use export type GetSoCsInput = z.input export type GetSoCOptionsInput = z.input export type CreateSoCInput = z.infer diff --git a/src/schemas/system.ts b/src/schemas/system.ts index 83c47bb9b..86793576a 100644 --- a/src/schemas/system.ts +++ b/src/schemas/system.ts @@ -1,13 +1,13 @@ import { z } from 'zod' +import { SortDirectionSchema } from '@/schemas/common' export const SystemSortField = z.enum(['name', 'key', 'gamesCount']) -export const SortDirection = z.enum(['asc', 'desc']) export const GetSystemsSchema = z .object({ search: z.string().nullable().optional(), sortField: SystemSortField.nullable().optional(), - sortDirection: SortDirection.nullable().optional(), + sortDirection: SortDirectionSchema.nullable().optional(), }) .optional() @@ -26,7 +26,6 @@ export const UpdateSystemSchema = z.object({ export const DeleteSystemSchema = z.object({ id: z.string().uuid() }) -// Type exports for repository use export type GetSystemsInput = z.input export type CreateSystemInput = z.infer export type UpdateSystemInput = z.infer diff --git a/src/schemas/user.ts b/src/schemas/user.ts index 76656b0bb..02ee7be7b 100644 --- a/src/schemas/user.ts +++ b/src/schemas/user.ts @@ -1,5 +1,6 @@ import { z } from 'zod' import { PAGINATION, CHAR_LIMITS } from '@/data/constants' +import { SortDirectionSchema } from '@/schemas/common' import { Role } from '@orm' export const UserSortField = z.enum([ @@ -14,13 +15,11 @@ export const UserSortField = z.enum([ 'followersCount', 'followingCount', ]) -export const SortDirection = z.enum(['asc', 'desc']) - export const GetAllUsersSchema = z .object({ search: z.string().nullable().optional(), sortField: UserSortField.nullable().optional(), - sortDirection: SortDirection.nullable().optional(), + sortDirection: SortDirectionSchema.nullable().optional(), page: z.number().int().min(1).default(1), limit: z.number().int().min(1).max(PAGINATION.MAX_LIMIT).default(PAGINATION.DEFAULT_LIMIT), }) diff --git a/src/schemas/userBan.ts b/src/schemas/userBan.ts index 71183ccfe..ed56b10e0 100644 --- a/src/schemas/userBan.ts +++ b/src/schemas/userBan.ts @@ -1,5 +1,5 @@ import { z } from 'zod' -import { SortDirection } from '@/schemas/soc' +import { SortDirectionSchema } from '@/schemas/common' export const UserBanSortField = z.enum(['bannedAt', 'expiresAt', 'isActive', 'reason']) @@ -29,7 +29,7 @@ export const GetUserBansSchema = z search: z.string().optional(), isActive: z.boolean().optional(), sortField: UserBanSortField.optional(), - sortDirection: SortDirection.optional(), + sortDirection: SortDirectionSchema.optional(), page: z.number().min(1).default(1), limit: z.number().min(1).max(100).default(20), }) diff --git a/src/schemas/voteInvestigation.ts b/src/schemas/voteInvestigation.ts index c4444e404..0572edf42 100644 --- a/src/schemas/voteInvestigation.ts +++ b/src/schemas/voteInvestigation.ts @@ -1,6 +1,5 @@ import { z } from 'zod' -import { ListingType } from '@/schemas/common' -import { SortDirection } from '@/schemas/soc' +import { ListingType, SortDirectionSchema } from '@/schemas/common' export const VoteTypeFilter = z.enum(['all', 'up', 'down']) export const ListingTypeFilter = z.enum(['all', ...ListingType.options]) @@ -13,7 +12,7 @@ export const GetUserVotesSchema = z.object({ voteType: VoteTypeFilter.default('all'), listingType: ListingTypeFilter.default('all'), sortField: VoteSortField.default('createdAt'), - sortDirection: SortDirection.default('desc'), + sortDirection: SortDirectionSchema.default('desc'), includeNullified: z.boolean().default(false), }) diff --git a/src/scripts/api/generate-api-docs.ts b/src/scripts/api/generate-api-docs.ts index 25f62811a..b62925aaa 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,233 +24,58 @@ 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) +function createGenericResponse(routerName: string, procedureName: string): unknown { return { - ...baseItem, - ...getRelationsForRouter(routerName), + message: `Response from ${routerName}.${procedureName}`, + data: getBaseItemStructure(routerName), } } -function createSimpleObject(routerName: string, _procedureName: string): unknown { - return getBaseItemStructure(routerName) -} +function getResponseExampleOverride(outputSchemaName: string | undefined): unknown | null { + if (outputSchemaName !== 'BatchBySteamAppIdsResponseSchema') return null -function createPaginatedList(routerName: string, _procedureName: string): unknown { return { - [getPluralName(routerName)]: [ + success: true, + results: [ { - ...getBaseItemStructure(routerName), - ...getRelationsForRouter(routerName), - _count: getCountStructure(routerName), + game_id: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx', + steam_app_id: '220', + title: 'Half-Life 2', + performance: { + id: 1, + label: 'Perfect', + rank: 1, + description: null, + }, + emulator: { + id: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx', + name: 'GameHub', + logo: null, + }, + device: { + id: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx', + modelName: 'Steam Deck', + soc: null, + }, + listing: { + id: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx', + notes: 'Runs well', + upvoteCount: 4, + downvoteCount: 1, + voteCount: 5, + successRate: 0.8, + }, }, ], - 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}`, - data: getBaseItemStructure(routerName), + totalRequested: 1, + totalFound: 1, + totalNotFound: 0, } } @@ -300,108 +125,169 @@ 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) +} - return relations[routerName] || {} +type DefinitionRef = { + ref: string + value: unknown } -function getCountStructure(routerName: string): Record { - const counts: Record> = { - games: { listings: 45 }, - listings: { votes: 12, comments: 3 }, - devices: { listings: 28 }, +function decodeJsonPointerSegment(segment: string): string { + return segment.replace(/~1/g, '/').replace(/~0/g, '~') +} + +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 cloneJsonSchema(schema: Record): Record { + const cloned: unknown = JSON.parse(JSON.stringify(schema)) + return isRecord(cloned) ? cloned : {} } -function getPluralName(routerName: string): string { - const plurals: Record = { - game: 'games', - listing: 'listings', - device: 'devices', - emulator: 'emulators', - notification: 'notifications', +function resolveDefinitionRefs( + value: unknown, + definitions: Record, + seenRefs = new Set(), +): unknown { + if (Array.isArray(value)) { + return value.map((item) => resolveDefinitionRefs(item, definitions, seenRefs)) } - return plurals[routerName] || `${routerName}s` + 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 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 { + if (schema.const !== undefined) return schema.const + + 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 +309,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 +325,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 +351,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 +399,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 +415,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 +437,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 +509,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 +528,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 +561,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 +581,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 +602,25 @@ 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 = + getResponseExampleOverride(outputSchemaName) ?? + (outputJsonSchema + ? generateExampleFromSchema(outputJsonSchema) + : createGenericResponse(routerInfo.router, procedure.name)) + // Build security requirement const security = procedure.auth === 'protected' ? [{ ClerkAuth: [] }] : [] @@ -727,10 +644,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 +655,7 @@ function generateSwaggerEndpoints(routerInfos: RouterInfo[]): { summary: 'Successful response', value: { result: { - data: generateResponseExampleByStructure( - routerInfo.router, - procedure.name, - procedure.returnStructure || 'generic-object', - ), + data: responseExample, }, }, }, @@ -855,15 +765,15 @@ 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/mobileContext.test.ts b/src/server/api/mobileContext.test.ts new file mode 100644 index 000000000..8352553a5 --- /dev/null +++ b/src/server/api/mobileContext.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest' +import { Role } from '@orm/client' +import type { FetchCreateContextFnOptions } from '@trpc/server/adapters/fetch' + +const authorizeApiKeyMock = vi.hoisted(() => vi.fn()) +const verifyTokenMock = vi.hoisted(() => vi.fn()) +const prismaMock = vi.hoisted(() => ({ + user: { + findUnique: vi.fn(), + }, + rolePermission: { + findMany: vi.fn(), + }, +})) + +vi.mock('@clerk/backend', () => ({ + verifyToken: verifyTokenMock, +})) + +vi.mock('@/server/db', () => ({ + prisma: prismaMock, +})) + +vi.mock('@/server/services/api-access.service', () => ({ + ApiAccessService: vi.fn().mockImplementation(function MockApiAccessService() { + return { + authorize: authorizeApiKeyMock, + } + }), +})) + +const { createMobileTRPCFetchContext } = await import('./mobileContext') + +function createFetchContextOptions(headers: HeadersInit = {}): FetchCreateContextFnOptions { + const req = new Request('https://www.emuready.com/api/mobile/trpc/games.get', { headers }) + + return { + req, + resHeaders: new Headers(), + info: { + accept: null, + type: 'query', + isBatchCall: false, + calls: [], + connectionParams: null, + signal: req.signal, + url: new URL(req.url), + }, + } +} + +describe('createMobileTRPCFetchContext', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('allows anonymous public mobile requests when no API key is provided', async () => { + const context = await createMobileTRPCFetchContext(createFetchContextOptions()) + + expect(context.session).toBeNull() + expect(context.apiKey).toBeNull() + expect(authorizeApiKeyMock).not.toHaveBeenCalled() + }) + + it('ignores an invalid x-api-key so public mobile requests still work anonymously', async () => { + authorizeApiKeyMock.mockResolvedValue(null) + + const context = await createMobileTRPCFetchContext( + createFetchContextOptions({ 'x-api-key': 'invalid-key' }), + ) + + expect(context.session).toBeNull() + expect(context.apiKey).toBeNull() + expect(authorizeApiKeyMock).toHaveBeenCalledWith('invalid-key') + }) + + it('keeps explicit Authorization ApiKey credentials strict', async () => { + authorizeApiKeyMock.mockResolvedValue(null) + + await expect( + createMobileTRPCFetchContext( + createFetchContextOptions({ authorization: 'ApiKey invalid-key' }), + ), + ).rejects.toMatchObject({ + code: 'UNAUTHORIZED', + message: 'Invalid API key', + }) + + expect(authorizeApiKeyMock).toHaveBeenCalledWith('invalid-key') + }) + + it('uses a valid Bearer token even when the optional x-api-key is invalid', async () => { + authorizeApiKeyMock.mockResolvedValue(null) + verifyTokenMock.mockResolvedValue({ sub: 'clerk-user-1' }) + prismaMock.user.findUnique.mockResolvedValueOnce({ id: 'user-1' }).mockResolvedValueOnce({ + id: 'user-1', + email: 'user@example.com', + name: 'Test User', + role: Role.USER, + settings: { showNsfw: false }, + }) + prismaMock.rolePermission.findMany.mockResolvedValue([ + { permission: { key: 'view_statistics' } }, + ]) + + const context = await createMobileTRPCFetchContext( + createFetchContextOptions({ + authorization: 'Bearer valid-token', + 'x-api-key': 'stale-app-key', + }), + ) + + expect(context.session?.user.id).toBe('user-1') + expect(context.apiKey).toBeNull() + expect(authorizeApiKeyMock).toHaveBeenCalledWith('stale-app-key') + expect(verifyTokenMock).toHaveBeenCalledWith( + 'valid-token', + expect.objectContaining({ + clockSkewInMs: 60000, + skipJwksCache: false, + }), + ) + }) +}) diff --git a/src/server/api/mobileContext.ts b/src/server/api/mobileContext.ts index 9e3c86b70..063e680a0 100644 --- a/src/server/api/mobileContext.ts +++ b/src/server/api/mobileContext.ts @@ -33,6 +33,11 @@ type Session = { user: User } +type ApiKeyCredential = { + rawKey: string + source: 'header' | 'authorization' +} + type CreateMobileContextOptions = { session: Nullable apiKey?: ApiKeyWithUser | null @@ -101,26 +106,46 @@ async function createSessionFromApiKey(apiKey: ApiKeyWithUser): Promise { - const rawKey = extractApiKey(headers) - if (!rawKey) return null + const credential = extractApiKey(headers) + if (!credential) return null const apiAccessService = new ApiAccessService(prisma) - return apiAccessService.authorize(rawKey) + const apiKey = await apiAccessService.authorize(credential.rawKey) + if (apiKey) return apiKey + + if (credential.source === 'authorization') AppError.unauthorized('Invalid API key') + + // Temporary EmuReadyApp compatibility: shipped clients may send a stale x-api-key + // together with Bearer auth. Treat it as absent so auth can fall through to + // Bearer/anonymous access, then remove this after the mobile app stops sending it. + // Until then, x-api-key must not be treated as an access-control boundary here. + return null } async function resolveClerkSessionFromHeaders(headers: Headers): Promise { 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/admin/titleIdTools.ts b/src/server/api/routers/admin/titleIdTools.ts index 76e409195..389f9b96a 100644 --- a/src/server/api/routers/admin/titleIdTools.ts +++ b/src/server/api/routers/admin/titleIdTools.ts @@ -1,5 +1,6 @@ import { AppError } from '@/lib/errors' import { logger } from '@/lib/logger' +import { BatchBySteamAppIdsResponseSchema, BatchBySteamAppIdsSchema } from '@/schemas/mobile' import { TitleIdSearchInputSchema, TitleIdStatsInputSchema, @@ -7,6 +8,7 @@ import { TitleIdStatsSchema, } from '@/schemas/titleId' import { createTRPCRouter, protectedProcedure } from '@/server/api/trpc' +import { lookupGamesBySteamAppIds } from '@/server/services/steam-batch-lookup.service' import { getBestTitleIdResult, getTitleIdProvider, @@ -67,4 +69,14 @@ export const titleIdToolsRouter = createTRPCRouter({ return AppError.internalError('Failed to fetch title ID statistics') } }), + + batchSteamAppIds: titleIdAccessProcedure + .input(BatchBySteamAppIdsSchema) + .output(BatchBySteamAppIdsResponseSchema) + .query(async ({ ctx, input }) => { + return lookupGamesBySteamAppIds(input, { + prisma: ctx.prisma, + showNsfw: ctx.session.user.showNsfw, + }) + }), }) 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 new file mode 100644 index 000000000..ad60cdadc --- /dev/null +++ b/src/server/api/routers/listingReports.test.ts @@ -0,0 +1,218 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +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') + +const mockEmitNotificationEvent = vi.fn() +vi.mock('@/server/notifications/eventEmitter', () => ({ + notificationEventEmitter: { emitNotificationEvent: mockEmitNotificationEvent }, + NOTIFICATION_EVENTS: { + REPORT_CREATED: 'report.created', + }, +})) + +vi.mock('@/server/utils/security-validation', () => ({ + sanitizeInput: vi.fn((value: string) => value.trim()), +})) + +const mockLogAction = vi.fn().mockResolvedValue(undefined) +vi.mock('@/lib/trust/service', () => ({ + TrustService: vi.fn().mockImplementation(function MockTrustService() { + return { logAction: mockLogAction, reverseLogAction: vi.fn() } + }), +})) + +const { listingReportsRouter } = await import('./listingReports') + +const USER_ID = '00000000-0000-4000-a000-000000000001' +const AUTHOR_ID = '00000000-0000-4000-a000-000000000002' +const LISTING_ID = '00000000-0000-4000-a000-000000000010' +const REPORT_ID = '00000000-0000-4000-a000-000000000020' + +function createMockPrisma() { + 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), + create: vi.fn().mockResolvedValue({ + id: REPORT_ID, + listingId: LISTING_ID, + reportedById: USER_ID, + reason: ReportReason.SPAM, + description: 'needs review', + listing: { + game: { title: 'Test Game' }, + author: { name: 'Report Author' }, + }, + }), + 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(), + options: { role?: Role; permissions?: string[] } = {}, +) { + return { + caller: listingReportsRouter.createCaller({ + session: { + user: { + id: USER_ID, + email: 'test@test.com', + name: 'Test User', + role: options.role ?? Role.USER, + permissions: options.permissions ?? [], + showNsfw: false, + }, + }, + prisma: prisma as never, + headers: new Headers(), + }), + prisma, + } +} + +describe('listingReportsRouter create', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('creates a report and emits a moderator notification event', async () => { + const { caller, prisma } = createCaller() + + const report = await caller.create({ + listingId: LISTING_ID, + reason: ReportReason.SPAM, + description: ' needs review ', + }) + + expect(report.id).toBe(REPORT_ID) + expect(prisma.listingReport.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + listingId: LISTING_ID, + reportedById: USER_ID, + description: 'needs review', + }), + }), + ) + expect(mockEmitNotificationEvent).toHaveBeenCalledWith({ + eventType: 'report.created', + entityType: 'listingReport', + entityId: REPORT_ID, + triggeredBy: USER_ID, + includeTriggeredBy: true, + payload: { + reportId: REPORT_ID, + contentId: LISTING_ID, + contentType: 'Compatibility Report', + actionUrl: `/listings/${LISTING_ID}`, + listingId: LISTING_ID, + }, + }) + }) + + 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 04a079e5b..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,11 +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 }) => { @@ -50,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' } }, ] } @@ -131,154 +129,31 @@ export const listingReportsRouter = createTRPCRouter({ const { listingId, reason, description } = input const userId = ctx.session.user.id - // Validate reason enum - validateEnum(reason, Object.values(ReportReason), 'reason') - - // Sanitize description if provided (plain text, not markdown) - const sanitizedDescription = description ? sanitizeInput(description) : description - - // Check if listing exists - const listing = await ctx.prisma.listing.findUnique({ - where: { id: listingId }, - include: { author: true }, - }) - - if (!listing) return ResourceError.listing.notFound() - - // Prevent users from reporting their own listings - if (listing.authorId === userId) { - return ResourceError.listingReport.cannotReportOwnListing() - } - - // Check if user already reported this listing - const existingReport = await ctx.prisma.listingReport.findUnique({ - where: { - listingId_reportedById: { - listingId, - reportedById: userId, - }, - }, - }) - - if (existingReport) return ResourceError.listingReport.alreadyExists() - - // TODO: Send notification to SUPER_ADMIN users + const reportSubmissionService = new ReportSubmissionService(ctx.prisma) - return await ctx.prisma.listingReport.create({ - data: { - listingId, - reportedById: userId, - reason, - description: sanitizedDescription, - }, - include: { - listing: { - include: { - game: { select: { title: true } }, - author: { select: { name: true } }, - }, - }, - }, + return await reportSubmissionService.createListingReport({ + listingId, + reportedById: userId, + reason, + description, }) }), 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/admin.test.ts b/src/server/api/routers/listings/admin.test.ts index 147f16bf1..9fa39d2bf 100644 --- a/src/server/api/routers/listings/admin.test.ts +++ b/src/server/api/routers/listings/admin.test.ts @@ -1,7 +1,10 @@ import { describe, expect, it, beforeEach, vi } from 'vitest' import { RISK_SIGNAL_TYPES } from '@/schemas/authorRisk' import { SUBMISSION_RISK_SIGNAL_TYPES } from '@/schemas/submissionRisk' -import { ApprovalStatus, Role } from '@orm/client' +import { invalidateListingSeo } from '@/server/cache/invalidation' +import { notificationEventEmitter } from '@/server/notifications/eventEmitter' +import { invalidateCatalogCompatibilityCacheForDevice } from '@/server/utils/cache/instances' +import { ApprovalStatus, Role, TrustAction } from '@orm' import type * as AuthorRiskService from '@/server/services/author-risk.service' vi.unmock('@/server/api/trpc') @@ -263,6 +266,237 @@ describe('listing admin pending approvals', () => { }) }) +describe('listing admin processed reports', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + function setupPrisma() { + const listing = { + findMany: vi.fn().mockResolvedValue([]), + count: vi.fn().mockResolvedValue(0), + } + const prismaMock = prisma as unknown as { + listing: typeof listing + } + + prismaMock.listing = listing + + return { listing } + } + + it('searches processed handheld reports across visible report columns', async () => { + const processedListing = { + id: LISTING_ID, + status: ApprovalStatus.REJECTED, + } + const { listing } = setupPrisma() + listing.findMany.mockResolvedValueOnce([processedListing]) + listing.count.mockResolvedValueOnce(1) + + const { caller } = createCaller({ role: Role.SUPER_ADMIN }) + + const result = await caller.getProcessed({ + page: 1, + limit: 20, + filterStatus: ApprovalStatus.REJECTED, + search: 'ayaneo', + sortField: 'device', + sortDirection: 'asc', + }) + + expect(listing.findMany).toHaveBeenCalledWith({ + where: { + NOT: { status: ApprovalStatus.PENDING }, + status: ApprovalStatus.REJECTED, + OR: [ + { game: { title: { contains: 'ayaneo', mode: 'insensitive' } } }, + { game: { system: { name: { contains: 'ayaneo', mode: 'insensitive' } } } }, + { device: { modelName: { contains: 'ayaneo', mode: 'insensitive' } } }, + { device: { brand: { name: { contains: 'ayaneo', mode: 'insensitive' } } } }, + { emulator: { name: { contains: 'ayaneo', mode: 'insensitive' } } }, + { author: { name: { contains: 'ayaneo', mode: 'insensitive' } } }, + { processedNotes: { contains: 'ayaneo', mode: 'insensitive' } }, + { notes: { contains: 'ayaneo', mode: 'insensitive' } }, + ], + }, + include: { + game: { include: { system: true } }, + device: { include: { brand: true } }, + emulator: true, + author: { select: { id: true, name: true } }, + performance: true, + processedByUser: { select: { id: true, name: true } }, + }, + orderBy: [{ device: { brand: { name: 'asc' } } }, { device: { modelName: 'asc' } }], + skip: 0, + take: 20, + }) + expect(listing.count).toHaveBeenCalledWith({ + where: expect.objectContaining({ + NOT: { status: ApprovalStatus.PENDING }, + status: ApprovalStatus.REJECTED, + }), + }) + expect(result.listings).toEqual([processedListing]) + expect(result.pagination.total).toBe(1) + }) +}) + +describe('listing admin processed report status overrides', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + function setupPrisma() { + const listing = { + findUnique: vi.fn(), + update: vi.fn(), + } + const prismaMock = prisma as unknown as { + listing: typeof listing + } + + prismaMock.listing = listing + + return { listing } + } + + it('emits a rejection notification when a processed handheld report is overridden to rejected', async () => { + const processedAt = new Date('2026-06-01T12:00:00.000Z') + const { listing } = setupPrisma() + listing.findUnique.mockResolvedValueOnce({ + id: LISTING_ID, + status: ApprovalStatus.APPROVED, + gameId: '00000000-0000-4000-a000-000000000030', + deviceId: '00000000-0000-4000-a000-000000000031', + emulatorId: '00000000-0000-4000-a000-000000000032', + authorId: AUTHOR_ID, + processedNotes: 'Old notes', + }) + listing.update.mockResolvedValueOnce({ + id: LISTING_ID, + status: ApprovalStatus.REJECTED, + processedAt, + }) + + const { caller } = createCaller({ role: Role.SUPER_ADMIN }) + + await caller.overrideStatus({ + listingId: LISTING_ID, + newStatus: ApprovalStatus.REJECTED, + overrideNotes: 'Incorrect report', + }) + + expect(listing.update).toHaveBeenCalledWith({ + where: { id: LISTING_ID }, + data: { + status: ApprovalStatus.REJECTED, + processedByUserId: ADMIN_ID, + processedAt: expect.any(Date), + processedNotes: 'Incorrect report', + }, + }) + expect(invalidateListingSeo).toHaveBeenCalledWith({ + id: LISTING_ID, + gameId: '00000000-0000-4000-a000-000000000030', + deviceId: '00000000-0000-4000-a000-000000000031', + emulatorId: '00000000-0000-4000-a000-000000000032', + }) + expect(invalidateCatalogCompatibilityCacheForDevice).toHaveBeenCalledWith( + '00000000-0000-4000-a000-000000000031', + ) + expect(mockApplyTrustAction).toHaveBeenCalledWith({ + userId: AUTHOR_ID, + action: TrustAction.LISTING_REJECTED, + context: { + listingId: LISTING_ID, + adminUserId: ADMIN_ID, + reason: 'Incorrect report', + }, + }) + expect(notificationEventEmitter.emitNotificationEvent).toHaveBeenCalledWith({ + eventType: 'LISTING_REJECTED', + entityType: 'listing', + entityId: LISTING_ID, + triggeredBy: ADMIN_ID, + payload: { + listingId: LISTING_ID, + rejectedBy: ADMIN_ID, + rejectedAt: processedAt, + rejectionReason: 'Incorrect report', + }, + }) + }) + + it('clears processed metadata without emitting a notification when overriding to pending', async () => { + const { listing } = setupPrisma() + listing.findUnique.mockResolvedValueOnce({ + id: LISTING_ID, + status: ApprovalStatus.REJECTED, + gameId: '00000000-0000-4000-a000-000000000030', + deviceId: '00000000-0000-4000-a000-000000000031', + emulatorId: '00000000-0000-4000-a000-000000000032', + authorId: AUTHOR_ID, + processedNotes: 'Rejected notes', + }) + listing.update.mockResolvedValueOnce({ + id: LISTING_ID, + status: ApprovalStatus.PENDING, + }) + + const { caller } = createCaller({ role: Role.SUPER_ADMIN }) + + await caller.overrideStatus({ + listingId: LISTING_ID, + newStatus: ApprovalStatus.PENDING, + }) + + expect(listing.update).toHaveBeenCalledWith({ + where: { id: LISTING_ID }, + data: { + status: ApprovalStatus.PENDING, + processedByUserId: null, + processedAt: null, + processedNotes: null, + }, + }) + expect(invalidateListingSeo).not.toHaveBeenCalled() + expect(mockApplyTrustAction).not.toHaveBeenCalled() + expect(notificationEventEmitter.emitNotificationEvent).not.toHaveBeenCalled() + }) + + it('invalidates public handheld report caches when resetting an approved report to pending', async () => { + const { listing } = setupPrisma() + listing.findUnique.mockResolvedValueOnce({ + id: LISTING_ID, + status: ApprovalStatus.APPROVED, + gameId: '00000000-0000-4000-a000-000000000030', + deviceId: '00000000-0000-4000-a000-000000000031', + emulatorId: '00000000-0000-4000-a000-000000000032', + }) + listing.update.mockResolvedValueOnce({ + id: LISTING_ID, + status: ApprovalStatus.PENDING, + }) + + const { caller } = createCaller({ role: Role.MODERATOR }) + + await caller.resetToPending({ listingId: LISTING_ID }) + + expect(invalidateListingSeo).toHaveBeenCalledWith({ + id: LISTING_ID, + gameId: '00000000-0000-4000-a000-000000000030', + deviceId: '00000000-0000-4000-a000-000000000031', + emulatorId: '00000000-0000-4000-a000-000000000032', + }) + expect(invalidateCatalogCompatibilityCacheForDevice).toHaveBeenCalledWith( + '00000000-0000-4000-a000-000000000031', + ) + expect(notificationEventEmitter.emitNotificationEvent).not.toHaveBeenCalled() + }) +}) + describe('listing admin auto risk rejection', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/src/server/api/routers/listings/admin.ts b/src/server/api/routers/listings/admin.ts index e2d03bfe7..5ff2e3d9a 100644 --- a/src/server/api/routers/listings/admin.ts +++ b/src/server/api/routers/listings/admin.ts @@ -28,6 +28,7 @@ import { protectedProcedure, } from '@/server/api/trpc' import { buildProcessedOrderBy } from '@/server/api/utils/listingHelpers' +import { getProcessedStatusTrustAction } from '@/server/api/utils/processedStatusTrust' import { invalidateListingSeo, invalidateListingsSeo } from '@/server/cache/invalidation' import { notificationEventEmitter, NOTIFICATION_EVENTS } from '@/server/notifications/eventEmitter' import { ListingsRepository } from '@/server/repositories/listings.repository' @@ -47,7 +48,8 @@ import { import { generateEmulatorConfig } from '@/server/utils/emulator-config/emulator-detector' import { paginate } from '@/server/utils/pagination' import { hasRolePermission } from '@/utils/permissions' -import { Prisma, ApprovalStatus, TrustAction, Role } from '@orm/client' +import { ApprovalStatus, Role, TrustAction } from '@orm' +import { Prisma } from '@orm/client' const LISTING_STATS_CACHE_KEY = 'listing-stats' @@ -350,7 +352,7 @@ export const adminRouter = createTRPCRouter({ const listing = await ctx.prisma.listing.findUnique({ where: { id: listingId }, - select: { id: true, status: true }, + select: { id: true, status: true, gameId: true, deviceId: true, emulatorId: true }, }) if (!listing) return ResourceError.listing.notFound() @@ -371,6 +373,16 @@ export const adminRouter = createTRPCRouter({ listingStatsCache.delete(LISTING_STATS_CACHE_KEY) + if (listing.status === ApprovalStatus.APPROVED) { + await invalidateListingSeo({ + id: listingId, + gameId: listing.gameId, + deviceId: listing.deviceId, + emulatorId: listing.emulatorId, + }) + invalidateCatalogCompatibilityCacheForDevice(listing.deviceId) + } + return updatedListing }), @@ -387,6 +399,10 @@ export const adminRouter = createTRPCRouter({ ? { OR: [ { game: { title: { contains: search, mode } } }, + { game: { system: { name: { contains: search, mode } } } }, + { device: { modelName: { contains: search, mode } } }, + { device: { brand: { name: { contains: search, mode } } } }, + { emulator: { name: { contains: search, mode } } }, { author: { name: { contains: search, mode } } }, { processedNotes: { contains: search, mode } }, { notes: { contains: search, mode } }, @@ -434,34 +450,91 @@ export const adminRouter = createTRPCRouter({ const listingToOverride = await ctx.prisma.listing.findUnique({ where: { id: listingId }, + select: { + id: true, + status: true, + gameId: true, + deviceId: true, + emulatorId: true, + authorId: true, + processedNotes: true, + }, }) if (!listingToOverride) return ResourceError.listing.notFound() const updatedListing = await ctx.prisma.listing.update({ where: { id: listingId }, - data: { - status: newStatus, - processedByUserId: superAdminUserId, // Log the SUPER_ADMIN as the latest processor - processedAt: new Date(), // Update timestamp to the override time - processedNotes: overrideNotes ?? listingToOverride.processedNotes, // Keep old notes if no new ones - }, + data: + newStatus === ApprovalStatus.PENDING + ? { + status: newStatus, + processedByUserId: null, + processedAt: null, + processedNotes: null, + } + : { + status: newStatus, + processedByUserId: superAdminUserId, + processedAt: new Date(), + processedNotes: overrideNotes ?? listingToOverride.processedNotes, + }, }) - // Emit notification event - notificationEventEmitter.emitNotificationEvent({ - eventType: NOTIFICATION_EVENTS.LISTING_STATUS_OVERRIDDEN, - entityType: 'listing', - entityId: listingId, - triggeredBy: superAdminUserId, - payload: { - listingId, - overriddenBy: superAdminUserId, - newStatus, - overriddenAt: updatedListing.processedAt, - overrideNotes: overrideNotes, - }, + if ( + listingToOverride.status === ApprovalStatus.APPROVED || + newStatus === ApprovalStatus.APPROVED + ) { + await invalidateListingSeo({ + id: listingId, + gameId: listingToOverride.gameId, + deviceId: listingToOverride.deviceId, + emulatorId: listingToOverride.emulatorId, + }) + invalidateCatalogCompatibilityCacheForDevice(listingToOverride.deviceId) + } + + const trustAction = getProcessedStatusTrustAction({ + previousStatus: listingToOverride.status, + newStatus, + authorId: listingToOverride.authorId, }) + if (trustAction) { + await applyTrustAction({ + userId: trustAction.userId, + action: trustAction.action, + context: { + listingId, + adminUserId: superAdminUserId, + reason: overrideNotes || 'listing_status_override', + }, + }) + } + + if (newStatus === ApprovalStatus.APPROVED || newStatus === ApprovalStatus.REJECTED) { + notificationEventEmitter.emitNotificationEvent({ + eventType: + newStatus === ApprovalStatus.APPROVED + ? NOTIFICATION_EVENTS.LISTING_APPROVED + : NOTIFICATION_EVENTS.LISTING_REJECTED, + entityType: 'listing', + entityId: listingId, + triggeredBy: superAdminUserId, + payload: + newStatus === ApprovalStatus.APPROVED + ? { + listingId, + approvedBy: superAdminUserId, + approvedAt: updatedListing.processedAt, + } + : { + listingId, + rejectedBy: superAdminUserId, + rejectedAt: updatedListing.processedAt, + rejectionReason: overrideNotes, + }, + }) + } // Invalidate listing stats cache listingStatsCache.delete(LISTING_STATS_CACHE_KEY) diff --git a/src/server/api/routers/listings/comments.test.ts b/src/server/api/routers/listings/comments.test.ts index 37060c3fd..a259f5e67 100644 --- a/src/server/api/routers/listings/comments.test.ts +++ b/src/server/api/routers/listings/comments.test.ts @@ -7,6 +7,10 @@ vi.unmock('@/server/api/root') const mockHandleCommentVoteTrustEffects = vi.fn().mockResolvedValue(undefined) const mockEmitNotificationEvent = vi.fn() const mockCheckSpamContent = vi.fn().mockResolvedValue(undefined) +const mockAnalyticsComment = vi.fn() +const mockAnalyticsCommentVote = vi.fn() +const mockAnalyticsFirstTimeAction = vi.fn() +const mockLoggerError = vi.fn() vi.mock('@/server/utils/vote-trust-effects', () => ({ handleCommentVoteTrustEffects: (...args: unknown[]) => mockHandleCommentVoteTrustEffects(...args), @@ -33,8 +37,19 @@ vi.mock('@/server/utils/spam-check', () => ({ vi.mock('@/lib/analytics', () => ({ default: { - engagement: { comment: vi.fn(), commentVote: vi.fn() }, - userJourney: { firstTimeAction: vi.fn() }, + engagement: { + comment: (...args: unknown[]) => mockAnalyticsComment(...args), + commentVote: (...args: unknown[]) => mockAnalyticsCommentVote(...args), + }, + userJourney: { + firstTimeAction: (...args: unknown[]) => mockAnalyticsFirstTimeAction(...args), + }, + }, +})) + +vi.mock('@/lib/logger', () => ({ + logger: { + error: (...args: unknown[]) => mockLoggerError(...args), }, })) @@ -44,6 +59,7 @@ const USER_ID = '00000000-0000-4000-a000-000000000001' const AUTHOR_ID = '00000000-0000-4000-a000-000000000002' const LISTING_ID = '00000000-0000-4000-a000-000000000010' const COMMENT_ID = '00000000-0000-4000-a000-000000000020' +const PARENT_COMMENT_ID = '00000000-0000-4000-a000-000000000021' function createMockPrisma() { const mockTx = { @@ -103,6 +119,10 @@ function createCaller(overrides: { userId?: string; role?: Role; prisma?: MockPr } } +function flushBackgroundTasks(): Promise { + return new Promise((resolve) => setTimeout(resolve, 0)) +} + describe('handheld comments router — voteComment', () => { beforeEach(() => { vi.clearAllMocks() @@ -244,6 +264,165 @@ describe('handheld comments router — create', () => { expect(prisma.comment.create).toHaveBeenCalled() }) + it('emits listing comment notification and analytics for a top-level comment', async () => { + const { caller } = createCaller() + + await caller.create({ + listingId: LISTING_ID, + content: 'Runs well with these settings', + }) + + expect(mockEmitNotificationEvent).toHaveBeenCalledWith({ + eventType: 'LISTING_COMMENTED', + entityType: 'listing', + entityId: LISTING_ID, + triggeredBy: USER_ID, + payload: { + listingId: LISTING_ID, + commentId: COMMENT_ID, + parentId: undefined, + commentText: 'Runs well with these settings', + }, + }) + expect(mockAnalyticsComment).toHaveBeenCalledWith({ + action: 'created', + commentId: COMMENT_ID, + listingId: LISTING_ID, + isReply: false, + contentLength: 'Runs well with these settings'.length, + }) + await flushBackgroundTasks() + + expect(mockAnalyticsFirstTimeAction).toHaveBeenCalledWith({ + userId: USER_ID, + action: 'first_comment', + }) + }) + + it('emits reply notification and analytics for a child comment', async () => { + const { caller, prisma } = createCaller() + prisma.comment.findUnique.mockResolvedValue({ listingId: LISTING_ID }) + + await caller.create({ + listingId: LISTING_ID, + content: 'Replying with more settings', + parentId: PARENT_COMMENT_ID, + }) + + expect(prisma.comment.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + parent: { connect: { id: PARENT_COMMENT_ID } }, + }), + }), + ) + expect(mockEmitNotificationEvent).toHaveBeenCalledWith( + expect.objectContaining({ + eventType: 'COMMENT_REPLIED', + payload: expect.objectContaining({ + listingId: LISTING_ID, + commentId: COMMENT_ID, + parentId: PARENT_COMMENT_ID, + commentText: 'Replying with more settings', + }), + }), + ) + expect(mockAnalyticsComment).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'reply', + isReply: true, + }), + ) + }) + + it('does not track first comment journey analytics after the first comment', async () => { + const { caller, prisma } = createCaller() + prisma.comment.count.mockResolvedValue(2) + + await caller.create({ + listingId: LISTING_ID, + content: 'Another comment', + }) + + await flushBackgroundTasks() + + expect(mockAnalyticsFirstTimeAction).not.toHaveBeenCalled() + }) + + it('returns the created comment when first-comment analytics fails', async () => { + const analyticsError = new Error('count failed') + const { caller, prisma } = createCaller() + prisma.comment.count.mockRejectedValue(analyticsError) + + const result = await caller.create({ + listingId: LISTING_ID, + content: 'Runs well with these settings', + }) + + expect(result.id).toBe(COMMENT_ID) + expect(prisma.comment.create).toHaveBeenCalled() + + await flushBackgroundTasks() + + expect(mockLoggerError).toHaveBeenCalledWith( + '[ListingCommentService] Failed to track first comment analytics', + expect.any(Error), + { + userId: USER_ID, + commentId: COMMENT_ID, + }, + ) + }) + + it('does not check spam or create when the listing is missing', async () => { + const { caller, prisma } = createCaller() + prisma.listing.findUnique.mockResolvedValue(null) + + await expect( + caller.create({ + listingId: LISTING_ID, + content: 'Runs well with these settings', + }), + ).rejects.toThrow('Report not found') + + expect(mockCheckSpamContent).not.toHaveBeenCalled() + expect(prisma.comment.create).not.toHaveBeenCalled() + }) + + it('does not check spam or create when the parent comment is missing', async () => { + const { caller, prisma } = createCaller() + prisma.comment.findUnique.mockResolvedValue(null) + + await expect( + caller.create({ + listingId: LISTING_ID, + content: 'Replying with more settings', + parentId: PARENT_COMMENT_ID, + }), + ).rejects.toThrow('Parent comment not found') + + expect(mockCheckSpamContent).not.toHaveBeenCalled() + expect(prisma.comment.create).not.toHaveBeenCalled() + }) + + it('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/comments.ts b/src/server/api/routers/listings/comments.ts index ad4ff94ba..890c08f09 100644 --- a/src/server/api/routers/listings/comments.ts +++ b/src/server/api/routers/listings/comments.ts @@ -16,90 +16,21 @@ import { canManageCommentPins } from '@/server/api/utils/pinPermissions' import { notificationEventEmitter, NOTIFICATION_EVENTS } from '@/server/notifications/eventEmitter' import { CommentsRepository } from '@/server/repositories/comments.repository' import { logAudit } from '@/server/services/audit.service' +import { ListingCommentService } from '@/server/services/listing-comment.service' import { isUserBanned } from '@/server/utils/query-builders' -import { checkSpamContent } from '@/server/utils/spam-check' import { handleCommentVoteTrustEffects } from '@/server/utils/vote-trust-effects' import { roleIncludesRole } from '@/utils/permission-system' import { canDeleteComment, canEditComment } from '@/utils/permissions' import { AuditAction, AuditEntityType, Role } from '@orm/client' export const commentsRouter = createTRPCRouter({ - // TODO: This should use a repository, too much logic in here. create: protectedProcedure.input(CreateCommentSchema).mutation(async ({ ctx, input }) => { - const { listingId, content, parentId, humanVerificationToken } = input - const userId = ctx.session.user.id - - const listing = await ctx.prisma.listing.findUnique({ - where: { id: listingId }, - }) - - if (!listing) return ResourceError.listing.notFound() - - // If parentId is provided, check if parent comment exists - if (parentId) { - const parentComment = await ctx.prisma.comment.findUnique({ where: { id: parentId } }) - - if (!parentComment) return ResourceError.comment.parentNotFound() - } - - const userExists = await ctx.prisma.user.findUnique({ - where: { id: userId }, - select: { id: true }, - }) - - if (!userExists) return ResourceError.user.notInDatabase(userId) - - await checkSpamContent({ - prisma: ctx.prisma, - userId, - content, - entityType: 'comment', - challengeMode: 'challenge', - humanVerificationToken, + const service = new ListingCommentService(ctx.prisma) + return service.create({ + ...input, + userId: ctx.session.user.id, headers: ctx.headers, }) - - const repository = new CommentsRepository(ctx.prisma) - const comment = await repository.create({ - content, - user: { connect: { id: userId } }, - listing: { connect: { id: listingId } }, - ...(parentId && { parent: { connect: { id: parentId } } }), - }) - - notificationEventEmitter.emitNotificationEvent({ - eventType: parentId - ? NOTIFICATION_EVENTS.COMMENT_REPLIED - : NOTIFICATION_EVENTS.LISTING_COMMENTED, - entityType: 'listing', - entityId: listingId, - triggeredBy: userId, - payload: { - listingId, - commentId: comment.id, - parentId: parentId ?? undefined, - commentText: content, - }, - }) - - analytics.engagement.comment({ - action: parentId ? 'reply' : 'created', - commentId: comment.id, - listingId: listingId, - isReply: !!parentId, - contentLength: content.length, - }) - - // Check if this is user's first comment for journey analytics - const userCommentCount = await ctx.prisma.comment.count({ - where: { userId: userId }, - }) - - if (userCommentCount === 1) { - analytics.userJourney.firstTimeAction({ userId: userId, action: 'first_comment' }) - } - - return comment }), get: publicProcedure.input(GetCommentsSchema).query(async ({ ctx, input }) => { diff --git a/src/server/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/catalog.ts b/src/server/api/routers/mobile/catalog.ts index 74a101ab6..ae65a3c67 100644 --- a/src/server/api/routers/mobile/catalog.ts +++ b/src/server/api/routers/mobile/catalog.ts @@ -18,7 +18,7 @@ export const mobileCatalogRouter = createMobileTRPCRouter({ * - Community votes (Wilson score) * - Developer verifications * - * Results are cached for 10 minutes to reduce server load. + * Results are cached for 15 minutes to reduce server load. */ getDeviceCompatibility: mobilePublicProcedure .input(GetDeviceCompatibilitySchema) 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/deviceBrands.ts b/src/server/api/routers/mobile/deviceBrands.ts index 6e00461f4..8d554fc0d 100644 --- a/src/server/api/routers/mobile/deviceBrands.ts +++ b/src/server/api/routers/mobile/deviceBrands.ts @@ -1,49 +1,23 @@ import { ResourceError } from '@/lib/errors' import { GetDeviceBrandsSchema, GetDeviceBrandByIdSchema } from '@/schemas/deviceBrand' import { createMobileTRPCRouter, mobilePublicProcedure } from '@/server/api/mobileContext' -import type { Prisma } from '@orm/client' +import { DeviceBrandsRepository } from '@/server/repositories/device-brands.repository' export const mobileDeviceBrandsRouter = createMobileTRPCRouter({ /** * Get device brands with search and sorting */ get: mobilePublicProcedure.input(GetDeviceBrandsSchema).query(async ({ ctx, input }) => { - const { search, limit, sortField, sortDirection } = input ?? {} - - const orderBy: Prisma.DeviceBrandOrderByWithRelationInput[] = [] - - if (sortField && sortDirection) { - switch (sortField) { - case 'name': - orderBy.push({ name: sortDirection }) - break - case 'devicesCount': - orderBy.push({ devices: { _count: sortDirection } }) - break - } - } - - // Default ordering if no sort specified - if (!orderBy.length) { - orderBy.push({ name: 'asc' }) - } - - return ctx.prisma.deviceBrand.findMany({ - where: search ? { name: { contains: search, mode: 'insensitive' } } : undefined, - include: { _count: { select: { devices: true } } }, - orderBy, - take: limit, - }) + const repository = new DeviceBrandsRepository(ctx.prisma) + return repository.list(input ?? {}) }), /** * Get device brand by ID */ getById: mobilePublicProcedure.input(GetDeviceBrandByIdSchema).query(async ({ ctx, input }) => { - const brand = await ctx.prisma.deviceBrand.findUnique({ - where: { id: input.id }, - include: { _count: { select: { devices: true } } }, - }) + const repository = new DeviceBrandsRepository(ctx.prisma) + const brand = await repository.byIdWithCounts(input.id) return brand || ResourceError.deviceBrand.notFound() }), diff --git a/src/server/api/routers/mobile/games.ts b/src/server/api/routers/mobile/games.ts index ae7c00ea5..d1e5749ba 100644 --- a/src/server/api/routers/mobile/games.ts +++ b/src/server/api/routers/mobile/games.ts @@ -13,12 +13,12 @@ import { GetBestSteamAppIdMobileSchema, GetSteamGamesStatsMobileSchema, BatchBySteamAppIdsSchema, + BatchBySteamAppIdsResponseSchema, type GetGamesResponse, } from '@/schemas/mobile' import { createMobileTRPCRouter, mobilePublicProcedure } from '@/server/api/mobileContext' import { GamesRepository } from '@/server/repositories/games.repository' -import { steamBatchQueryCache } from '@/server/utils/cache' -import { matchSteamAppIdsToNames, validateSteamAppIds } from '@/server/utils/steamGameBatcher' +import { lookupGamesBySteamAppIds } from '@/server/services/steam-batch-lookup.service' import { findSteamAppIdForGameName, getBestSteamAppIdMatch, @@ -35,127 +35,6 @@ import { getThreeDsGamesStats, } from '@/server/utils/threeDsGameSearch' -// Type definitions for batch Steam App ID responses -export type BatchGameResult = { - steamAppId: string - game: { - id: string - title: string - systemId: string - imageUrl: string | null - boxartUrl: string | null - bannerUrl: string | null - tgdbGameId: number | null - metadata: unknown - isErotic: boolean - status: string - createdAt: Date - system: { - id: string - name: string - key: string | null - } - _count: { - listings: number - } - listings: { - id: string - deviceId: string - gameId: string - emulatorId: string - performanceId: number - notes: string | null - upvoteCount: number - downvoteCount: number - voteCount: number - successRate: number | null - device: { - id: string - modelName: string - soc: { - id: string - name: string - manufacturer: string | null - architecture: string | null - processNode: string | null - cpuCores: number | null - gpuModel: string | null - } | null - } - emulator: { - id: string - name: string - logo: string | null - } - performance: { - id: number - label: string - rank: number - description: string | null - } - customFieldValues: { - id: string - listingId: string - customFieldDefinitionId: string - value: unknown - customFieldDefinition: { - id: string - type: string - label: string - name: string - } - }[] - }[] - } | null - matchStrategy: 'metadata' | 'exact' | 'normalized' | 'not_found' -} - -export type MinimalGameResult = { - game_id: string | null - steam_app_id: string - title: string | null - performance: { - id: number - label: string - rank: number - description: string | null - } | null - emulator: { - id: string - name: string - logo: string | null - } | null - device: { - id: string - modelName: string - soc: { - id: string - name: string - manufacturer: string | null - architecture: string | null - processNode: string | null - cpuCores: number | null - gpuModel: string | null - } | null - } | null - listing: { - id: string - notes: string | null - upvoteCount: number - downvoteCount: number - voteCount: number - successRate: number | null - } | null -} - -export type BatchBySteamAppIdsResponse = { - success: true - results: BatchGameResult[] | MinimalGameResult[] - totalRequested: number - totalFound: number - totalNotFound: number -} - export const mobileGamesRouter = createMobileTRPCRouter({ /** * Get games with search and filtering @@ -384,103 +263,15 @@ export const mobileGamesRouter = createMobileTRPCRouter({ * Batch lookup games by Steam App IDs * Optimized for large batches (up to 1000 Steam App IDs) * Returns games with their listings filtered by emulator if specified - * Results cached for 5 minutes to optimize repeated queries + * Results cached for 15 minutes to optimize repeated queries */ batchBySteamAppIds: mobilePublicProcedure .input(BatchBySteamAppIdsSchema) + .output(BatchBySteamAppIdsResponseSchema) .query(async ({ ctx, input }) => { - const { - steamAppIds, - emulatorName, - maxListingsPerGame, - showNsfw = false, - minimal = true, - } = input - - try { - // Validate Steam App IDs - const validation = validateSteamAppIds(steamAppIds) - if (!validation.valid) { - return AppError.badRequest(validation.errors.join(', ')) - } - - // Create cache key from sorted IDs and options - const sortedIds = [...steamAppIds].sort().join(',') - const cacheKey = `batch:${sortedIds}:${emulatorName ?? 'all'}:${maxListingsPerGame}:${showNsfw ?? false}:${minimal ?? false}` - - // Check cache first - const cachedResult = steamBatchQueryCache.get(cacheKey) - if (cachedResult) return cachedResult - - // Match Steam App IDs to game names - const matchResults = await matchSteamAppIdsToNames(steamAppIds) - - // Create Map of Steam App ID → Game Name - const steamAppIdToName = new Map() - for (const match of matchResults) { - if (match.gameName) { - steamAppIdToName.set(match.steamAppId, match.gameName) - } - } - - // Batch lookup games from database - const repository = new GamesRepository(ctx.prisma) - const results = await repository.batchBySteamAppIds(steamAppIdToName, { - emulatorName, - maxListingsPerGame, - showNsfw: showNsfw ?? ctx.session?.user?.showNsfw ?? false, - }) - - // Transform to minimal format if requested - const finalResults = minimal - ? results.map((result) => { - if (!result.game || result.game.listings.length === 0) { - return { - game_id: result.game?.id ?? null, - steam_app_id: result.steamAppId, - title: result.game?.title ?? null, - performance: null, - emulator: null, - device: null, - listing: null, - } - } - - const firstListing = result.game.listings[0] - return { - game_id: result.game.id, - steam_app_id: result.steamAppId, - title: result.game.title, - performance: firstListing?.performance ?? null, - emulator: firstListing?.emulator ?? null, - device: firstListing?.device ?? null, - listing: { - id: firstListing?.id ?? null, - notes: firstListing?.notes ?? null, - upvoteCount: firstListing?.upvoteCount ?? 0, - downvoteCount: firstListing?.downvoteCount ?? 0, - voteCount: firstListing?.voteCount ?? 0, - successRate: firstListing?.successRate ?? null, - }, - } - }) - : results - - const response = { - success: true as const, - results: finalResults, - totalRequested: steamAppIds.length, - totalFound: results.filter((r) => r.game !== null).length, - totalNotFound: results.filter((r) => r.game === null).length, - } - - // Cache the result - steamBatchQueryCache.set(cacheKey, response) - - return response - } catch (error) { - console.error('Error in batch Steam App ID lookup:', error) - return AppError.internalError('Failed to lookup games by Steam App IDs') - } + return lookupGamesBySteamAppIds(input, { + prisma: ctx.prisma, + showNsfw: ctx.session?.user?.showNsfw ?? false, + }) }), }) 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/listingReports.test.ts b/src/server/api/routers/mobile/listingReports.test.ts new file mode 100644 index 000000000..d1671b9c8 --- /dev/null +++ b/src/server/api/routers/mobile/listingReports.test.ts @@ -0,0 +1,125 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ReportReason, Role } from '@orm/client' + +vi.unmock('@/server/api/mobileContext') + +vi.mock('@/schemas/apiAccess', () => ({ + GetApiKeyUsageSchema: {}, + CreateApiKeySchema: {}, + UpdateApiKeySchema: {}, + RevokeApiKeySchema: {}, + ListApiKeysSchema: {}, +})) + +vi.mock('@/server/repositories/api-keys.repository', () => ({ + ApiKeysRepository: vi.fn().mockImplementation(function MockApiKeysRepository() { + return {} + }), +})) + +const mockEmitNotificationEvent = vi.fn() +vi.mock('@/server/notifications/eventEmitter', () => ({ + notificationEventEmitter: { emitNotificationEvent: mockEmitNotificationEvent }, + NOTIFICATION_EVENTS: { + REPORT_CREATED: 'report.created', + }, +})) + +vi.mock('@/server/utils/security-validation', () => ({ + sanitizeInput: vi.fn((value: string) => value.trim()), +})) + +const { mobileListingReportsRouter } = await import('./listingReports') + +const USER_ID = '00000000-0000-4000-a000-000000000001' +const AUTHOR_ID = '00000000-0000-4000-a000-000000000002' +const LISTING_ID = '00000000-0000-4000-a000-000000000010' +const REPORT_ID = '00000000-0000-4000-a000-000000000020' + +function createMockPrisma() { + return { + listing: { + findUnique: vi.fn().mockResolvedValue({ + id: LISTING_ID, + authorId: AUTHOR_ID, + author: { id: AUTHOR_ID }, + }), + }, + listingReport: { + findUnique: vi.fn().mockResolvedValue(null), + create: vi.fn().mockResolvedValue({ + id: REPORT_ID, + listingId: LISTING_ID, + reportedById: USER_ID, + }), + }, + } +} + +type MockPrisma = ReturnType + +function createCaller(prisma: MockPrisma = createMockPrisma()) { + return { + caller: mobileListingReportsRouter.createCaller({ + session: { + user: { + id: USER_ID, + email: 'test@test.com', + name: 'Test User', + role: Role.USER, + permissions: [], + showNsfw: false, + }, + }, + prisma: prisma as never, + headers: new Headers(), + apiKey: null, + }), + prisma, + } +} + +describe('mobileListingReportsRouter create', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('creates a report and emits the same moderator notification event as web', async () => { + const { caller, prisma } = createCaller() + + const result = await caller.create({ + listingId: LISTING_ID, + reason: ReportReason.SPAM, + description: ' needs review ', + }) + + expect(result).toEqual({ + id: REPORT_ID, + success: true, + message: 'Report submitted successfully', + }) + expect(prisma.listingReport.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + listingId: LISTING_ID, + reportedById: USER_ID, + description: 'needs review', + }), + }), + ) + expect(mockEmitNotificationEvent).toHaveBeenCalledWith({ + eventType: 'report.created', + entityType: 'listingReport', + entityId: REPORT_ID, + triggeredBy: USER_ID, + includeTriggeredBy: true, + payload: { + reportId: REPORT_ID, + contentId: LISTING_ID, + contentType: 'Compatibility Report', + actionUrl: `/listings/${LISTING_ID}`, + listingId: LISTING_ID, + }, + }) + }) +}) diff --git a/src/server/api/routers/mobile/listingReports.ts b/src/server/api/routers/mobile/listingReports.ts index e952910cf..e316538ee 100644 --- a/src/server/api/routers/mobile/listingReports.ts +++ b/src/server/api/routers/mobile/listingReports.ts @@ -1,4 +1,3 @@ -import { AppError, ResourceError } from '@/lib/errors' import { CreateListingReportSchema, GetUserReportStatsSchema } from '@/schemas/listingReport' import { createMobileTRPCRouter, @@ -6,49 +5,21 @@ import { mobilePublicProcedure, } from '@/server/api/mobileContext' import { getAuthorReportCounts } from '@/server/services/report-stats.service' +import { ReportSubmissionService } from '@/server/services/report-submission.service' export const mobileListingReportsRouter = createMobileTRPCRouter({ - /** - * Create a new listing report (user-facing) - */ create: mobileProtectedProcedure .input(CreateListingReportSchema) .mutation(async ({ ctx, input }) => { const { listingId, reason, description } = input const userId = ctx.session.user.id - // Check if listing exists - const listing = await ctx.prisma.listing.findUnique({ - where: { id: listingId }, - include: { author: true }, - }) - - if (!listing) return ResourceError.listing.notFound() - - // Prevent users from reporting their own listings - if (listing.authorId === userId) { - return AppError.badRequest('You cannot report your own listing') - } - - // Check if user already reported this listing - const existingReport = await ctx.prisma.listingReport.findUnique({ - where: { listingId_reportedById: { listingId, reportedById: userId } }, - }) - - if (existingReport) { - return AppError.badRequest('You have already reported this listing') - } - - const report = await ctx.prisma.listingReport.create({ - data: { listingId, reportedById: userId, reason, description }, - include: { - listing: { - include: { - game: { select: { title: true } }, - author: { select: { name: true } }, - }, - }, - }, + const reportSubmissionService = new ReportSubmissionService(ctx.prisma) + const report = await reportSubmissionService.createListingReport({ + listingId, + reportedById: userId, + reason, + description, }) return { 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 b6895311e..d4e5a0149 100644 --- a/src/server/api/routers/pcListings.test.ts +++ b/src/server/api/routers/pcListings.test.ts @@ -7,7 +7,7 @@ import { invalidatePcListingsSeo, } from '@/server/cache/invalidation' import { PERMISSIONS } from '@/utils/permission-system' -import { ApprovalStatus, PcOs, Role, TrustAction } from '@orm/client' +import { ApprovalStatus, PcOs, ReportReason, Role, TrustAction } from '@orm' vi.unmock('@/server/api/trpc') vi.unmock('@/server/api/root') @@ -46,6 +46,7 @@ vi.mock('@/server/notifications/eventEmitter', () => ({ COMMENT_REPLIED: 'COMMENT_REPLIED', PC_LISTING_APPROVED: 'PC_LISTING_APPROVED', PC_LISTING_REJECTED: 'PC_LISTING_REJECTED', + REPORT_CREATED: 'report.created', }, })) @@ -111,6 +112,7 @@ vi.mock('@/server/api/utils/pinPermissions', () => ({ vi.mock('@/server/utils/security-validation', () => ({ validatePagination: vi.fn((page, limit, max) => ({ page: page ?? 1, limit: limit ?? max ?? 20 })), + sanitizeInput: vi.fn((value: string) => value.trim()), })) const mockRepositoryCreate = vi.fn() @@ -190,18 +192,40 @@ 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([]), + count: vi.fn().mockResolvedValue(0), update: vi.fn(), updateMany: vi.fn().mockResolvedValue({ count: 0 }), }, + pcListingReport: { + findUnique: vi.fn().mockResolvedValue(null), + create: vi.fn().mockResolvedValue({ + id: '00000000-0000-4000-a000-000000000030', + pcListingId: LISTING_ID, + reportedById: USER_ID, + reason: ReportReason.SPAM, + description: 'needs review', + pcListing: { + game: { title: 'PC Test Game' }, + author: { name: 'PC Report Author' }, + }, + }), + }, user: { findUnique: vi.fn().mockResolvedValue({ id: ADMIN_ID }), }, userBan: { findMany: vi.fn().mockResolvedValue([]), }, + verifiedDeveloper: { + findMany: vi.fn().mockResolvedValue([]), + }, } return { @@ -470,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', () => { @@ -795,6 +838,159 @@ describe('pcListings trust integration', () => { }) }) + describe('getProcessed', () => { + it('loads processed PC reports with status, search, pagination, and sorting', async () => { + const processedListing = { + id: LISTING_ID, + status: ApprovalStatus.APPROVED, + } + const { caller, prisma } = createCaller({ userId: ADMIN_ID, role: Role.SUPER_ADMIN }) + prisma.pcListing.findMany.mockResolvedValueOnce([processedListing]) + prisma.pcListing.count.mockResolvedValueOnce(1) + + const result = await caller.getProcessed({ + page: 2, + limit: 10, + filterStatus: ApprovalStatus.APPROVED, + search: 'steam deck', + sortField: 'cpu', + sortDirection: 'asc', + }) + + expect(prisma.pcListing.findMany).toHaveBeenCalledWith({ + where: { + NOT: { status: ApprovalStatus.PENDING }, + status: ApprovalStatus.APPROVED, + OR: [ + { game: { title: { contains: 'steam deck', mode: 'insensitive' } } }, + { game: { system: { name: { contains: 'steam deck', mode: 'insensitive' } } } }, + { cpu: { modelName: { contains: 'steam deck', mode: 'insensitive' } } }, + { cpu: { brand: { name: { contains: 'steam deck', mode: 'insensitive' } } } }, + { gpu: { modelName: { contains: 'steam deck', mode: 'insensitive' } } }, + { gpu: { brand: { name: { contains: 'steam deck', mode: 'insensitive' } } } }, + { emulator: { name: { contains: 'steam deck', mode: 'insensitive' } } }, + { author: { name: { contains: 'steam deck', mode: 'insensitive' } } }, + { processedNotes: { contains: 'steam deck', mode: 'insensitive' } }, + { notes: { contains: 'steam deck', mode: 'insensitive' } }, + ], + }, + include: expect.objectContaining({ processedByUser: true }), + orderBy: [{ cpu: { brand: { name: 'asc' } } }, { cpu: { modelName: 'asc' } }], + skip: 10, + take: 10, + }) + expect(prisma.pcListing.count).toHaveBeenCalledWith({ + where: expect.objectContaining({ + NOT: { status: ApprovalStatus.PENDING }, + status: ApprovalStatus.APPROVED, + }), + }) + expect(result.pcListings).toEqual([processedListing]) + expect(result.pagination.total).toBe(1) + }) + }) + + describe('overrideStatus', () => { + it('updates a processed PC report, invalidates SEO, and emits a rejected event', async () => { + const gameId = '00000000-0000-4000-a000-000000000040' + const cpuId = '00000000-0000-4000-a000-000000000070' + const processedAt = new Date('2026-06-01T12:00:00.000Z') + const { caller, prisma } = createCaller({ userId: ADMIN_ID, role: Role.SUPER_ADMIN }) + prisma.pcListing.findUnique.mockResolvedValueOnce({ + id: LISTING_ID, + status: ApprovalStatus.APPROVED, + gameId, + cpuId, + gpuId: null, + authorId: AUTHOR_ID, + processedNotes: 'Old notes', + }) + prisma.pcListing.update.mockResolvedValueOnce({ + id: LISTING_ID, + status: ApprovalStatus.REJECTED, + processedAt, + }) + + await caller.overrideStatus({ + pcListingId: LISTING_ID, + newStatus: ApprovalStatus.REJECTED, + overrideNotes: 'Incorrect hardware', + }) + + expect(prisma.pcListing.update).toHaveBeenCalledWith({ + where: { id: LISTING_ID }, + data: { + status: ApprovalStatus.REJECTED, + processedByUserId: ADMIN_ID, + processedAt: expect.any(Date), + processedNotes: 'Incorrect hardware', + }, + }) + expect(invalidatePcListingSeo).toHaveBeenCalledWith({ + id: LISTING_ID, + gameId, + cpuId, + gpuId: null, + }) + expect(mockApplyTrustAction).toHaveBeenCalledWith({ + userId: AUTHOR_ID, + action: TrustAction.LISTING_REJECTED, + context: { + pcListingId: LISTING_ID, + adminUserId: ADMIN_ID, + reason: 'Incorrect hardware', + }, + }) + expect(mockEmitNotificationEvent).toHaveBeenCalledWith({ + eventType: 'PC_LISTING_REJECTED', + entityType: 'pcListing', + entityId: LISTING_ID, + triggeredBy: ADMIN_ID, + payload: { + pcListingId: LISTING_ID, + rejectedBy: ADMIN_ID, + rejectedAt: processedAt, + rejectionReason: 'Incorrect hardware', + }, + }) + }) + + it('clears processed metadata without emitting a notification when returning to pending', async () => { + const { caller, prisma } = createCaller({ userId: ADMIN_ID, role: Role.SUPER_ADMIN }) + prisma.pcListing.findUnique.mockResolvedValueOnce({ + id: LISTING_ID, + status: ApprovalStatus.REJECTED, + gameId: '00000000-0000-4000-a000-000000000040', + cpuId: '00000000-0000-4000-a000-000000000070', + gpuId: null, + authorId: AUTHOR_ID, + processedNotes: 'Rejected notes', + }) + prisma.pcListing.update.mockResolvedValueOnce({ + id: LISTING_ID, + status: ApprovalStatus.PENDING, + }) + + await caller.overrideStatus({ + pcListingId: LISTING_ID, + newStatus: ApprovalStatus.PENDING, + }) + + expect(prisma.pcListing.update).toHaveBeenCalledWith({ + where: { id: LISTING_ID }, + data: { + status: ApprovalStatus.PENDING, + processedByUserId: null, + processedAt: null, + processedNotes: null, + }, + }) + expect(invalidatePcListingSeo).not.toHaveBeenCalled() + expect(mockApplyTrustAction).not.toHaveBeenCalled() + expect(mockEmitNotificationEvent).not.toHaveBeenCalled() + }) + }) + describe('approve', () => { it('calls applyTrustAction with LISTING_APPROVED for author', async () => { mockRepositoryGetById.mockResolvedValue({ @@ -871,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({ @@ -902,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', () => { @@ -911,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 = { @@ -918,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', } @@ -927,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, @@ -940,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 }) @@ -956,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, @@ -974,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 00ff91ac6..7cfc19372 100644 --- a/src/server/api/routers/pcListings.ts +++ b/src/server/api/routers/pcListings.ts @@ -1,1935 +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, - PinPcListingCommentSchema, - RejectPcListingSchema, - RemovePcListingVerificationSchema, - UnpinPcListingCommentSchema, - UpdatePcListingAdminSchema, - UpdatePcListingCommentSchema, - UpdatePcListingReportSchema, - UpdatePcListingUserSchema, - UpdatePcPresetSchema, - VerifyPcListingAdminSchema, - VotePcListingCommentSchema, - VotePcListingSchema, -} from '@/schemas/pcListing' -import { - createListingProcedure, - createTRPCRouter, - adminProcedure, - moderatorProcedure, - permissionProcedure, - protectedProcedure, - publicProcedure, - viewStatisticsProcedure, -} from '@/server/api/trpc' -import { buildCommentTree, findCommentWithParent } from '@/server/api/utils/commentTree' -import { - buildPcListingOrderBy, - buildPcListingWhere, - pcListingAdminInclude, - pcListingDetailInclude, -} from '@/server/api/utils/pcListingHelpers' -import { canManageCommentPins } from '@/server/api/utils/pinPermissions' -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 { 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, - Prisma, - ReportStatus, - Role, - TrustAction, -} 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, - }, - }) - - 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 - }), - - 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 result = await ctx.prisma.pcListing.updateMany({ - where: { id: { in: pendingListings.map((l) => l.id) } }, - data: { - status: ApprovalStatus.APPROVED, - processedAt: new Date(), - processedByUserId: ctx.session.user.id, - }, - }) - - // Apply trust actions in parallel — distinct user adjustments, independent. - const listingsWithAuthor = pendingListings.filter( - (l): l is typeof l & { authorId: string } => l.authorId !== null, - ) - 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, - }, - }) - } - - 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 - - // Check if PC listing exists - const pcListing = await ctx.prisma.pcListing.findUnique({ - where: { id: pcListingId }, - include: { author: true }, - }) - - if (!pcListing) { - return ResourceError.pcListing.notFound() - } - - // Prevent users from reporting their own listings - if (pcListing.authorId === userId) { - return AppError.badRequest('You cannot report your own listing') - } - - // Check if user already reported this listing - const existingReport = await ctx.prisma.pcListingReport.findUnique({ - where: { - pcListingId_reportedById: { - pcListingId, - reportedById: userId, - }, - }, - }) - - if (existingReport) { - return AppError.badRequest('You have already reported this listing') - } - - return await ctx.prisma.pcListingReport.create({ - data: { - pcListingId, - reportedById: userId, - reason, - description, - }, - include: { - pcListing: { - include: { - game: { select: { title: true } }, - author: { select: { name: true } }, - }, - }, - }, - }) - }), - - 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/routers/performanceScales.ts b/src/server/api/routers/performanceScales.ts index da02d10cf..3539d99aa 100644 --- a/src/server/api/routers/performanceScales.ts +++ b/src/server/api/routers/performanceScales.ts @@ -21,6 +21,11 @@ export const performanceScalesRouter = createTRPCRouter({ return repository.list(input ?? {}) }), + getWithCounts: publicProcedure.input(GetPerformanceScalesSchema).query(async ({ ctx, input }) => { + const repository = new PerformanceScalesRepository(ctx.prisma) + return repository.listWithCounts(input ?? {}) + }), + byId: publicProcedure.input(GetPerformanceScaleByIdSchema).query(async ({ ctx, input }) => { const repository = new PerformanceScalesRepository(ctx.prisma) const scale = await repository.byNumericId(input.id) @@ -46,7 +51,7 @@ export const performanceScalesRouter = createTRPCRouter({ .input(DeletePerformanceScaleSchema) .mutation(async ({ ctx, input }) => { const repository = new PerformanceScalesRepository(ctx.prisma) - await repository.deleteByNumericId(input.id) + await repository.deleteByNumericIdWithReplacement(input.id, input.replacementId) return { success: true } }), }) diff --git a/src/server/api/routers/socs.test.ts b/src/server/api/routers/socs.test.ts new file mode 100644 index 000000000..220961dbe --- /dev/null +++ b/src/server/api/routers/socs.test.ts @@ -0,0 +1,89 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.unmock('@/server/api/trpc') +vi.unmock('@/server/api/root') + +const { socsRouter } = await import('./socs') + +function createMockPrisma() { + return { + soC: { + count: vi.fn().mockResolvedValue(42), + findMany: vi.fn().mockResolvedValue([ + { + id: 'soc-1', + name: 'Snapdragon 8 Gen 3', + manufacturer: 'Qualcomm', + architecture: 'ARM64', + processNode: '4nm', + cpuCores: 8, + gpuModel: 'Adreno 750', + _count: { devices: 3 }, + }, + ]), + }, + } +} + +type MockPrisma = ReturnType + +function createCaller(prisma: MockPrisma = createMockPrisma()) { + return { + caller: socsRouter.createCaller({ + session: null, + prisma: prisma as never, + headers: new Headers(), + }), + prisma, + } +} + +describe('socs router', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('get', () => { + it('uses page input to calculate the query offset', async () => { + const { caller, prisma } = createCaller() + + const result = await caller.get({ page: 3, limit: 10 }) + + expect(prisma.soC.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + skip: 20, + take: 10, + }), + ) + expect(result.pagination).toEqual( + expect.objectContaining({ + page: 3, + offset: 20, + limit: 10, + total: 42, + }), + ) + }) + + it('uses offset input when page is not provided', async () => { + const { caller, prisma } = createCaller() + + const result = await caller.get({ offset: 15, limit: 5 }) + + expect(prisma.soC.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + skip: 15, + take: 5, + }), + ) + expect(result.pagination).toEqual( + expect.objectContaining({ + page: 4, + offset: 15, + limit: 5, + total: 42, + }), + ) + }) + }) +}) diff --git a/src/server/api/routers/socs.ts b/src/server/api/routers/socs.ts index 5d276b8bb..1f5dc3d9f 100644 --- a/src/server/api/routers/socs.ts +++ b/src/server/api/routers/socs.ts @@ -15,32 +15,11 @@ import { viewStatisticsProcedure, } from '@/server/api/trpc' import { SoCsRepository } from '@/server/repositories/socs.repository' -import { paginate } from '@/server/utils/pagination' export const socsRouter = createTRPCRouter({ get: publicProcedure.input(GetSoCsSchema).query(async ({ ctx, input }) => { - // TODO: use paginate helpers const repository = new SoCsRepository(ctx.prisma) - const { limit = 20, offset = 0, page } = input ?? {} - - // Calculate actual offset based on page or use provided offset - const actualOffset = page ? (page - 1) * limit : (offset ?? 0) - - const [total, socs] = await Promise.all([ - repository.count(input ?? {}), - repository.list({ ...input, limit, offset: actualOffset }), - ]) - - const pagination = paginate({ - total: total, - page: page ?? Math.floor(actualOffset / limit) + 1, - limit, - }) - - return { - socs, - pagination, - } + return repository.list(input ?? {}) }), options: publicProcedure.input(GetSoCOptionsSchema).query(async ({ ctx, input }) => { diff --git a/src/server/api/trpc-cache.test.ts b/src/server/api/trpc-cache.test.ts new file mode 100644 index 000000000..5c9c1a658 --- /dev/null +++ b/src/server/api/trpc-cache.test.ts @@ -0,0 +1,156 @@ +import { describe, expect, it } from 'vitest' +import { + getTRPCResponseCacheHeaders, + TRPC_PRIVATE_CACHE_CONTROL, + TRPC_PUBLIC_LOOKUP_CACHE_CONTROL, +} from './trpc-cache' + +function requestInfo(paths: string[], isBatchCall = false) { + return { + isBatchCall, + calls: paths.map((path) => ({ path })), + } +} + +describe('getTRPCResponseCacheHeaders', () => { + it('publicly caches anonymous mobile catalog GET queries', () => { + const headers = getTRPCResponseCacheHeaders({ + endpoint: 'mobile', + method: 'GET', + type: 'query', + info: requestInfo(['catalog.getDeviceCompatibility']), + hasErrors: false, + session: null, + apiKey: null, + headers: new Headers(), + }) + + expect(headers['Cache-Control']).toBe(TRPC_PUBLIC_LOOKUP_CACHE_CONTROL) + }) + + it('publicly caches the web mobile compatibility alias only when anonymous', () => { + const headers = getTRPCResponseCacheHeaders({ + endpoint: 'web', + method: 'GET', + type: 'query', + info: requestInfo(['mobile.games.batchBySteamAppIds']), + hasErrors: false, + session: null, + headers: new Headers(), + }) + + expect(headers['Cache-Control']).toBe(TRPC_PUBLIC_LOOKUP_CACHE_CONTROL) + }) + + it('publicly caches anonymous web lookup queries that are already client lookup data', () => { + const headers = getTRPCResponseCacheHeaders({ + endpoint: 'web', + method: 'GET', + type: 'query', + info: requestInfo(['devices.options']), + hasErrors: false, + session: null, + headers: new Headers(), + }) + + expect(headers['Cache-Control']).toBe(TRPC_PUBLIC_LOOKUP_CACHE_CONTROL) + }) + + it('keeps authenticated requests private even for cacheable procedure paths', () => { + const headers = getTRPCResponseCacheHeaders({ + endpoint: 'mobile', + method: 'GET', + type: 'query', + info: requestInfo(['catalog.getDeviceCompatibility']), + hasErrors: false, + session: { user: { id: 'user-1' } }, + apiKey: null, + headers: new Headers(), + }) + + expect(headers['Cache-Control']).toBe(TRPC_PRIVATE_CACHE_CONTROL) + }) + + it('keeps requests with auth-capable headers private when no session resolved', () => { + const headers = getTRPCResponseCacheHeaders({ + endpoint: 'mobile', + method: 'GET', + type: 'query', + info: requestInfo(['games.batchBySteamAppIds']), + hasErrors: false, + session: null, + apiKey: null, + headers: new Headers({ authorization: 'Bearer invalid-token' }), + }) + + expect(headers['Cache-Control']).toBe(TRPC_PRIVATE_CACHE_CONTROL) + }) + + it('keeps batched requests private even when every path is individually cacheable', () => { + const headers = getTRPCResponseCacheHeaders({ + endpoint: 'mobile', + method: 'GET', + type: 'query', + info: requestInfo(['catalog.getDeviceCompatibility', 'games.batchBySteamAppIds'], true), + hasErrors: false, + session: null, + apiKey: null, + headers: new Headers(), + }) + + expect(headers['Cache-Control']).toBe(TRPC_PRIVATE_CACHE_CONTROL) + }) + + it('keeps eagerly generated response metadata private', () => { + const headers = getTRPCResponseCacheHeaders({ + endpoint: 'mobile', + method: 'GET', + type: 'query', + info: requestInfo(['catalog.getDeviceCompatibility']), + hasErrors: false, + eagerGeneration: true, + session: null, + apiKey: null, + headers: new Headers(), + }) + + expect(headers['Cache-Control']).toBe(TRPC_PRIVATE_CACHE_CONTROL) + }) + + it('keeps POST, mutation, and error responses private', () => { + const baseInput = { + endpoint: 'mobile' as const, + info: requestInfo(['catalog.getDeviceCompatibility']), + session: null, + apiKey: null, + headers: new Headers(), + } + + expect( + getTRPCResponseCacheHeaders({ + ...baseInput, + method: 'POST', + type: 'query', + hasErrors: false, + })['Cache-Control'], + ).toBe(TRPC_PRIVATE_CACHE_CONTROL) + + expect( + getTRPCResponseCacheHeaders({ + ...baseInput, + method: 'GET', + type: 'mutation', + hasErrors: false, + })['Cache-Control'], + ).toBe(TRPC_PRIVATE_CACHE_CONTROL) + + expect( + getTRPCResponseCacheHeaders({ + ...baseInput, + method: 'GET', + type: 'query', + hasErrors: true, + })['Cache-Control'], + ).toBe(TRPC_PRIVATE_CACHE_CONTROL) + }) +}) diff --git a/src/server/api/trpc-cache.ts b/src/server/api/trpc-cache.ts new file mode 100644 index 000000000..ef1ea020e --- /dev/null +++ b/src/server/api/trpc-cache.ts @@ -0,0 +1,88 @@ +export const TRPC_PRIVATE_CACHE_CONTROL = 'private, no-store' +export const TRPC_PUBLIC_LOOKUP_CACHE_CONTROL = + 'public, max-age=0, s-maxage=900, stale-while-revalidate=300' + +type Endpoint = 'mobile' | 'web' + +type CachePolicyInput = { + endpoint: Endpoint + method: string + type: string + info: + | { + isBatchCall: boolean + calls: readonly { path: string }[] + } + | undefined + hasErrors: boolean + eagerGeneration?: boolean + session: unknown + apiKey?: unknown + headers?: Headers | null +} + +const mobilePublicProcedureCache = new Map([ + ['catalog.getDeviceCompatibility', TRPC_PUBLIC_LOOKUP_CACHE_CONTROL], + ['games.batchBySteamAppIds', TRPC_PUBLIC_LOOKUP_CACHE_CONTROL], +]) + +const webPublicProcedureCache = new Map([ + ['mobile.catalog.getDeviceCompatibility', TRPC_PUBLIC_LOOKUP_CACHE_CONTROL], + ['mobile.games.batchBySteamAppIds', TRPC_PUBLIC_LOOKUP_CACHE_CONTROL], + ['cpus.options', TRPC_PUBLIC_LOOKUP_CACHE_CONTROL], + ['cpus.getByIds', TRPC_PUBLIC_LOOKUP_CACHE_CONTROL], + ['gpus.options', TRPC_PUBLIC_LOOKUP_CACHE_CONTROL], + ['gpus.getByIds', TRPC_PUBLIC_LOOKUP_CACHE_CONTROL], + ['devices.options', TRPC_PUBLIC_LOOKUP_CACHE_CONTROL], + ['devices.getByIds', TRPC_PUBLIC_LOOKUP_CACHE_CONTROL], + ['socs.options', TRPC_PUBLIC_LOOKUP_CACHE_CONTROL], + ['socs.getByIds', TRPC_PUBLIC_LOOKUP_CACHE_CONTROL], + ['systems.get', TRPC_PUBLIC_LOOKUP_CACHE_CONTROL], + ['emulators.get', TRPC_PUBLIC_LOOKUP_CACHE_CONTROL], + ['performanceScales.get', TRPC_PUBLIC_LOOKUP_CACHE_CONTROL], +]) + +function getProcedureCachePolicy(endpoint: Endpoint, path: string): string | undefined { + if (endpoint === 'mobile') return mobilePublicProcedureCache.get(path) + + return webPublicProcedureCache.get(path) +} + +function hasAuthCapableHeaders(headers: Headers | null | undefined): boolean { + if (!headers) return false + + return Boolean( + headers.get('authorization') || + headers.get('cookie') || + headers.get('x-api-key') || + headers.get('x-auth-token'), + ) +} + +function getSinglePath( + info: { isBatchCall: boolean; calls: readonly { path: string }[] } | undefined, +): string | null { + if (!info || info.isBatchCall || info.calls.length !== 1) return null + + return info.calls[0]?.path ?? null +} + +export function getTRPCResponseCacheHeaders(input: CachePolicyInput): Record { + const path = getSinglePath(input.info) + const cacheControl = path ? getProcedureCachePolicy(input.endpoint, path) : undefined + + if ( + cacheControl && + input.method === 'GET' && + input.type === 'query' && + !input.hasErrors && + input.eagerGeneration !== true && + !input.session && + !input.apiKey && + !hasAuthCapableHeaders(input.headers) + ) { + return { 'Cache-Control': cacheControl } + } + + return { 'Cache-Control': TRPC_PRIVATE_CACHE_CONTROL } +} 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/api/utils/pcListingHelpers.ts b/src/server/api/utils/pcListingHelpers.ts index 7b4a85d8f..85c3bd73f 100644 --- a/src/server/api/utils/pcListingHelpers.ts +++ b/src/server/api/utils/pcListingHelpers.ts @@ -95,6 +95,47 @@ export function buildPcListingOrderBy( return orderBy } +export type ProcessedPcListingSortField = + | 'processedAt' + | 'createdAt' + | 'status' + | 'game.title' + | 'game.system.name' + | 'cpu' + | 'gpu' + | 'emulator.name' + | 'author.name' + +export function buildProcessedPcListingOrderBy( + sortField: ProcessedPcListingSortField | null | undefined, + sortDirection: 'asc' | 'desc' | null | undefined, +): Prisma.PcListingOrderByWithRelationInput | Prisma.PcListingOrderByWithRelationInput[] { + const direction: Prisma.SortOrder = sortDirection ?? 'desc' + + switch (sortField) { + case 'createdAt': + return { createdAt: direction } + case 'status': + return { status: direction } + case 'game.title': + return { game: { title: direction } } + case 'game.system.name': + return { game: { system: { name: direction } } } + case 'cpu': + return [{ cpu: { brand: { name: direction } } }, { cpu: { modelName: direction } }] + case 'gpu': + return [{ gpu: { brand: { name: direction } } }, { gpu: { modelName: direction } }] + case 'emulator.name': + return { emulator: { name: direction } } + case 'author.name': + return { author: { name: direction } } + case 'processedAt': + case null: + case undefined: + return { processedAt: direction } + } +} + /** * Builds where clause for PC listings with banned user filtering */ diff --git a/src/server/api/utils/processedStatusTrust.ts b/src/server/api/utils/processedStatusTrust.ts new file mode 100644 index 000000000..2aef3bf82 --- /dev/null +++ b/src/server/api/utils/processedStatusTrust.ts @@ -0,0 +1,27 @@ +import { ApprovalStatus, TrustAction } from '@orm' + +interface ProcessedStatusTrustInput { + previousStatus: ApprovalStatus + newStatus: ApprovalStatus + authorId?: string | null +} + +interface ProcessedStatusTrustAction { + userId: string + action: TrustAction +} + +export function getProcessedStatusTrustAction( + input: ProcessedStatusTrustInput, +): ProcessedStatusTrustAction | null { + if (!input.authorId || input.previousStatus === input.newStatus) return null + + switch (input.newStatus) { + case ApprovalStatus.APPROVED: + return { userId: input.authorId, action: TrustAction.LISTING_APPROVED } + case ApprovalStatus.REJECTED: + return { userId: input.authorId, action: TrustAction.LISTING_REJECTED } + case ApprovalStatus.PENDING: + return null + } +} diff --git a/src/server/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/notifications/batchingService.ts b/src/server/notifications/batchingService.ts index 0f9d99347..58fa1120e 100644 --- a/src/server/notifications/batchingService.ts +++ b/src/server/notifications/batchingService.ts @@ -1,16 +1,10 @@ import { prisma } from '@/server/db' import { notificationAnalyticsService } from '@/server/notifications/analyticsService' -import { - NotificationDeliveryStatus, - DeliveryChannel, - NotificationCategory, - NotificationType, -} from '@orm/client' +import { NotificationDeliveryStatus, DeliveryChannel } from '@orm/client' import { createEmailService } from './emailService' -import { realtimeNotificationService } from './realtimeService' import type { NotificationData } from './types' -export interface BatchedNotification { +interface BatchedNotification { id: string userId: string data: NotificationData @@ -19,14 +13,14 @@ export interface BatchedNotification { maxAttempts: number } -export interface BatchConfig { +interface BatchConfig { batchSize: number batchIntervalMs: number maxRetries: number retryDelayMs: number } -export class NotificationBatchingService { +class NotificationBatchingService { private queue: BatchedNotification[] = [] private processing = false private batchTimer: NodeJS.Timeout | null = null @@ -36,16 +30,15 @@ export class NotificationBatchingService { constructor(config: Partial = {}) { this.config = { batchSize: 50, - batchIntervalMs: 30000, // 30 seconds + batchIntervalMs: 30000, maxRetries: 3, - retryDelayMs: 5000, // 5 seconds + retryDelayMs: 5000, ...config, } this.startBatchTimer() } - // Add notification to batch queue scheduleNotification( data: NotificationData, scheduledFor: Date = new Date(), @@ -65,7 +58,6 @@ export class NotificationBatchingService { this.queue.push(batchedNotification) console.log(`Notification scheduled for batch processing: ${id}`) - // Process immediately if batch is full if (this.queue.length >= this.config.batchSize) { this.processBatch().catch(console.error) } @@ -73,59 +65,6 @@ export class NotificationBatchingService { return id } - // Schedule weekly digest notifications - scheduleWeeklyDigest(userId: string): void { - const nextWeek = new Date() - nextWeek.setDate(nextWeek.getDate() + 7) - nextWeek.setHours(9, 0, 0, 0) // 9 AM next week - - this.scheduleNotification( - { - userId, - type: NotificationType.WEEKLY_DIGEST, - category: NotificationCategory.SYSTEM, - title: 'Weekly EmuReady Digest', - message: "Here's what happened this week in your gaming community.", - deliveryChannel: DeliveryChannel.EMAIL, - }, - nextWeek, - ) - } - - // Schedule maintenance notifications - scheduleMaintenanceNotification(scheduledFor: Date, title: string, message: string): void { - // Get all users who want maintenance notifications - prisma.user - .findMany({ - where: { - notificationPreferences: { - some: { - type: NotificationType.MAINTENANCE_NOTICE, - inAppEnabled: true, - }, - }, - }, - select: { id: true }, - }) - .then((users) => { - for (const user of users) { - this.scheduleNotification( - { - userId: user.id, - type: NotificationType.MAINTENANCE_NOTICE, - category: NotificationCategory.SYSTEM, - title, - message, - deliveryChannel: DeliveryChannel.BOTH, - }, - scheduledFor, - ) - } - }) - .catch(console.error) - } - - // Process batch of notifications private async processBatch(): Promise { if (this.processing || this.queue.length === 0) { return @@ -134,7 +73,6 @@ export class NotificationBatchingService { this.processing = true const now = new Date() - // Get notifications ready for processing const readyNotifications = this.queue.filter((notification) => notification.scheduledFor <= now) if (readyNotifications.length === 0) { @@ -142,7 +80,6 @@ export class NotificationBatchingService { return } - // Take up to batchSize notifications const batch = readyNotifications.slice(0, this.config.batchSize) console.log(`Processing batch of ${batch.length} notifications`) @@ -151,16 +88,13 @@ export class NotificationBatchingService { batch.map((notification) => this.processNotification(notification)), ) - // Handle results and retries for (let i = 0; i < batch.length; i++) { const notification = batch[i] const result = results[i] if (result.status === 'fulfilled' && result.value) { - // Success - remove from queue this.removeFromQueue(notification.id) } else { - // Failed - increment attempts and potentially retry notification.attempts++ if (notification.attempts >= notification.maxAttempts) { @@ -169,7 +103,6 @@ export class NotificationBatchingService { ) this.removeFromQueue(notification.id) } else { - // Schedule retry notification.scheduledFor = new Date(Date.now() + this.config.retryDelayMs) console.log( `Notification ${notification.id} scheduled for retry (attempt ${notification.attempts + 1})`, @@ -181,12 +114,10 @@ export class NotificationBatchingService { this.processing = false } - // Process individual notification private async processNotification(notification: BatchedNotification): Promise { try { const { data } = notification - // Create notification in database const dbNotification = await prisma.notification.create({ data: { userId: data.userId, @@ -201,20 +132,18 @@ export class NotificationBatchingService { }, }) - // Deliver via appropriate channels const deliveryPromises: Promise[] = [] - // In-app delivery if ( data.deliveryChannel === DeliveryChannel.IN_APP || data.deliveryChannel === DeliveryChannel.BOTH ) { - deliveryPromises.push(this.deliverInApp(dbNotification.id, data)) + deliveryPromises.push(Promise.resolve(true)) } - // Email delivery if ( - (data.deliveryChannel === 'EMAIL' || data.deliveryChannel === 'BOTH') && + (data.deliveryChannel === DeliveryChannel.EMAIL || + data.deliveryChannel === DeliveryChannel.BOTH) && this.emailService ) { deliveryPromises.push(this.deliverEmail(data)) @@ -223,7 +152,6 @@ export class NotificationBatchingService { const results = await Promise.all(deliveryPromises) const success = results.some((result) => result) - // Update delivery status await prisma.notification.update({ where: { id: dbNotification.id }, data: { @@ -233,7 +161,6 @@ export class NotificationBatchingService { }, }) - // Invalidate analytics cache when notifications are processed in batches if (success) { notificationAnalyticsService.clearCache() } @@ -245,42 +172,6 @@ export class NotificationBatchingService { } } - // Deliver in-app notification - private async deliverInApp(notificationId: string, data: NotificationData): Promise { - try { - const notification = await prisma.notification.findUnique({ - where: { id: notificationId }, - }) - - if (!notification) return false - - realtimeNotificationService.sendNotificationToUser(data.userId, { - id: notification.id, - type: notification.type, - title: notification.title, - message: notification.message, - actionUrl: notification.actionUrl || undefined, - createdAt: notification.createdAt.toISOString(), - }) - - // Update unread count - const unreadCount = await prisma.notification.count({ - where: { - userId: data.userId, - isRead: false, - }, - }) - - realtimeNotificationService.sendUnreadCountToUser(data.userId, unreadCount) - - return true - } catch (error) { - console.error('In-app delivery error:', error) - return false - } - } - - // Deliver email notification private async deliverEmail(data: NotificationData): Promise { if (!this.emailService) return false @@ -300,7 +191,6 @@ export class NotificationBatchingService { } } - // Remove notification from queue private removeFromQueue(id: string): void { const index = this.queue.findIndex((notification) => notification.id === id) if (index !== -1) { @@ -308,16 +198,13 @@ export class NotificationBatchingService { } } - // Start batch processing timer private startBatchTimer(): void { this.batchTimer = setInterval(() => { this.processBatch().catch(console.error) }, this.config.batchIntervalMs) - // Allow process to exit if no other tasks are queued this.batchTimer.unref?.() } - // Get queue status getQueueStatus(): { queueLength: number processing: boolean @@ -334,14 +221,6 @@ export class NotificationBatchingService { nextScheduled, } } - - // Cleanup - destroy() { - if (!this.batchTimer) return - clearInterval(this.batchTimer) - this.batchTimer = null - } } -// Singleton instance export const notificationBatchingService = new NotificationBatchingService() diff --git a/src/server/notifications/eventEmitter.ts b/src/server/notifications/eventEmitter.ts index e2bd4e9ed..6aeb347a7 100644 --- a/src/server/notifications/eventEmitter.ts +++ b/src/server/notifications/eventEmitter.ts @@ -6,6 +6,7 @@ export interface NotificationEventData { entityType: string entityId: string triggeredBy?: string + includeTriggeredBy?: boolean payload?: NotificationEventPayload } @@ -52,7 +53,6 @@ export const NOTIFICATION_EVENTS = { USER_MENTIONED: 'user.mentioned', LISTING_APPROVED: 'listing.approved', LISTING_REJECTED: 'listing.rejected', - LISTING_STATUS_OVERRIDDEN: 'listing.status_overridden', LISTING_VERIFIED: 'listing.verified', CONTENT_FLAGGED: 'content.flagged', GAME_ADDED: 'game.added', @@ -60,6 +60,8 @@ export const NOTIFICATION_EVENTS = { MAINTENANCE_SCHEDULED: 'maintenance.scheduled', FEATURE_ANNOUNCED: 'feature.announced', USER_ROLE_CHANGED: 'user.role_changed', + REPORT_CREATED: 'report.created', + REPORT_STATUS_CHANGED: 'report.status_changed', GAME_STATUS_OVERRIDDEN: 'game.status_overridden', PC_LISTING_APPROVED: 'pcListing.approved', PC_LISTING_REJECTED: 'pcListing.rejected', diff --git a/src/server/notifications/realtimeService.ts b/src/server/notifications/realtimeService.ts deleted file mode 100644 index d60618218..000000000 --- a/src/server/notifications/realtimeService.ts +++ /dev/null @@ -1,230 +0,0 @@ -import { getAllowedOrigins } from '@/lib/cors' - -interface SSEConnection { - userId: string - controller: ReadableStreamDefaultController - lastPing: number -} - -class RealtimeNotificationService { - private connections = new Map() - private pingInterval: NodeJS.Timeout | null = null - - // Connection limits - private readonly MAX_CONNECTIONS = 1000 // Maximum total connections - private readonly MAX_CONNECTIONS_PER_IP = 10 // Maximum connections per IP - private connectionsByIp = new Map>() // IP -> Set of userIds - - constructor() { - this.startPingInterval() - } - - // Create SSE connection for a user - createSSEConnection(userId: string, clientIp?: string): ReadableStream { - // Check total connection limit - if (this.connections.size >= this.MAX_CONNECTIONS) { - throw new Error('Server connection limit reached') - } - - // Check per-IP connection limit if IP is provided - if (clientIp) { - const ipConnections = this.connectionsByIp.get(clientIp) || new Set() - if (ipConnections.size >= this.MAX_CONNECTIONS_PER_IP) { - throw new Error('Connection limit exceeded for this IP') - } - } - - return new ReadableStream({ - start: (controller) => { - // Close existing connection for this user if any - if (this.connections.has(userId)) { - const existing = this.connections.get(userId) - existing?.controller.close() - this.connections.delete(userId) - } - - // Store connection - this.connections.set(userId, { - userId, - controller, - lastPing: Date.now(), - }) - - // Track IP connection if provided - if (clientIp) { - const ipConnections = this.connectionsByIp.get(clientIp) || new Set() - ipConnections.add(userId) - this.connectionsByIp.set(clientIp, ipConnections) - } - - // Send initial connection message - this.sendToUser(userId, { - type: 'connected', - data: { message: 'Connected to notification stream' }, - }) - - console.log(`SSE connection established for user: ${userId}`) - }, - cancel: () => { - this.connections.delete(userId) - - // Clean up IP tracking - if (clientIp) { - const ipConnections = this.connectionsByIp.get(clientIp) - if (ipConnections) { - ipConnections.delete(userId) - if (ipConnections.size === 0) { - this.connectionsByIp.delete(clientIp) - } - } - } - - console.log(`SSE connection closed for user: ${userId}`) - }, - }) - } - - // Send notification to specific user - sendNotificationToUser( - userId: string, - notification: { - id: string - type: string - title: string - message: string - actionUrl?: string - createdAt: string - }, - ): boolean { - return this.sendToUser(userId, { - type: 'notification', - data: notification, - }) - } - - // Send unread count update to user - sendUnreadCountToUser(userId: string, count: number): boolean { - return this.sendToUser(userId, { - type: 'unread_count', - data: { count }, - }) - } - - // Broadcast to all connected users - broadcast(message: { type: string; data: unknown }): void { - for (const [userId] of this.connections) { - this.sendToUser(userId, message) - } - } - - // Send message to specific user - private sendToUser(userId: string, message: { type: string; data: unknown }): boolean { - const connection = this.connections.get(userId) - if (!connection) return false - - try { - const sseData = `data: ${JSON.stringify(message)}\n\n` - connection.controller.enqueue(new TextEncoder().encode(sseData)) - return true - } catch (error) { - console.error(`Failed to send SSE message to user ${userId}:`, error) - this.connections.delete(userId) - return false - } - } - - // Keep connections alive with periodic pings - private startPingInterval(): void { - this.pingInterval = setInterval(() => { - const now = Date.now() - const staleConnections: string[] = [] - - for (const [userId, connection] of this.connections) { - // Send ping - const pingSuccess = this.sendToUser(userId, { - type: 'ping', - data: { timestamp: now }, - }) - - if (!pingSuccess || now - connection.lastPing > 60000) { - // Connection failed or hasn't responded to ping in 60 seconds - staleConnections.push(userId) - } else { - connection.lastPing = now - } - } - - // Clean up stale connections - for (const userId of staleConnections) { - this.connections.delete(userId) - console.log(`Removed stale SSE connection for user: ${userId}`) - } - }, 30000) // Ping every 30 seconds - // Let process exit if this is the only timer - this.pingInterval.unref?.() - } - - // Get connection status - getConnectionStatus(): { - totalConnections: number - connectedUsers: string[] - } { - return { - totalConnections: this.connections.size, - connectedUsers: Array.from(this.connections.keys()), - } - } - - // Cleanup - destroy(): void { - if (this.pingInterval) { - clearInterval(this.pingInterval) - this.pingInterval = null - } - - // Close all connections - for (const [userId, connection] of this.connections) { - try { - connection.controller.close() - } catch (error) { - console.error(`Error closing connection for user ${userId}:`, error) - } - } - - this.connections.clear() - this.connectionsByIp.clear() - } -} - -// Singleton instance -export const realtimeNotificationService = new RealtimeNotificationService() - -/** - * Helper function to create SSE response with proper CORS - * @param stream - * @param origin - */ -export function createSSEResponse(stream: ReadableStream, origin?: string): Response { - // Use centralized CORS configuration - const allowedOrigins = getAllowedOrigins() - - // Allow mobile apps (no origin) or explicitly allowed origins - const allowOrigin = !origin - ? '*' // No origin header (mobile apps) - : allowedOrigins.length === 0 - ? '*' // No origins configured (dev mode) - : allowedOrigins.includes(origin) - ? origin // Origin is allowed - : allowedOrigins[0] || '*' // Fallback to first allowed origin - - return new Response(stream, { - headers: { - 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache', - Connection: 'keep-alive', - 'Access-Control-Allow-Origin': allowOrigin, - 'Access-Control-Allow-Headers': 'Cache-Control', - 'Access-Control-Allow-Credentials': 'true', - }, - }) -} diff --git a/src/server/notifications/reportEvents.ts b/src/server/notifications/reportEvents.ts new file mode 100644 index 000000000..0ddcf222d --- /dev/null +++ b/src/server/notifications/reportEvents.ts @@ -0,0 +1,41 @@ +import { NOTIFICATION_EVENTS, notificationEventEmitter } from '@/server/notifications/eventEmitter' + +type ListingReportNotificationInput = { + type: 'listing' + reportId: string + listingId: string + reportedById: string +} + +type PcListingReportNotificationInput = { + type: 'pcListing' + reportId: string + pcListingId: string + reportedById: string +} + +type ReportNotificationInput = + | ListingReportNotificationInput + | PcListingReportNotificationInput + +export function emitReportCreatedNotification(input: ReportNotificationInput): void { + const isPcListing = input.type === 'pcListing' + const contentId = isPcListing ? input.pcListingId : input.listingId + + notificationEventEmitter.emitNotificationEvent({ + eventType: NOTIFICATION_EVENTS.REPORT_CREATED, + entityType: isPcListing ? 'pcListingReport' : 'listingReport', + entityId: input.reportId, + triggeredBy: input.reportedById, + includeTriggeredBy: true, + payload: { + reportId: input.reportId, + contentId, + contentType: isPcListing ? 'PC Compatibility Report' : 'Compatibility Report', + actionUrl: isPcListing + ? `/pc-listings/${contentId}` + : `/listings/${contentId}`, + ...(isPcListing ? { pcListingId: input.pcListingId } : { listingId: input.listingId }), + }, + }) +} diff --git a/src/server/notifications/service.test.ts b/src/server/notifications/service.test.ts index d72236f0b..6ba9a68f9 100644 --- a/src/server/notifications/service.test.ts +++ b/src/server/notifications/service.test.ts @@ -1,5 +1,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { DeliveryChannel, NotificationCategory, NotificationType } from '@orm/client' +import { + DeliveryChannel, + NotificationCategory, + NotificationDeliveryStatus, + NotificationType, + Role, +} from '@orm/client' import { NOTIFICATION_EVENTS } from './eventEmitter' import type { NotificationEventData } from './eventEmitter' import type { NotificationService } from './service' @@ -105,16 +111,9 @@ vi.mock('@/server/notifications/rateLimitService', () => ({ }, })) -vi.mock('@/server/notifications/realtimeService', () => ({ - realtimeNotificationService: { - sendNotificationToUser: vi.fn().mockReturnValue(true), - sendUnreadCountToUser: vi.fn(), - }, -})) - vi.mock('@/server/notifications/emailService', () => ({ createEmailService: vi.fn().mockReturnValue({ - sendNotificationEmail: vi.fn().mockResolvedValue({ success: true }), + sendNotificationEmail: mockSendNotificationEmail, }), })) @@ -122,12 +121,16 @@ vi.mock('@/lib/logger', () => ({ logger: { log: vi.fn(), error: vi.fn() }, })) -const { mockIterateFollowerUserIds, mockScheduleNotification } = vi.hoisted(() => ({ - mockIterateFollowerUserIds: vi.fn(), - mockScheduleNotification: vi - .fn<(data: NotificationData, scheduledFor?: Date, maxAttempts?: number) => string>() - .mockReturnValue('batch-id-1'), -})) +const { mockIterateFollowerUserIds, mockScheduleNotification, mockSendNotificationEmail } = + vi.hoisted(() => ({ + mockIterateFollowerUserIds: vi.fn(), + mockScheduleNotification: vi + .fn<(data: NotificationData, scheduledFor?: Date, maxAttempts?: number) => string>() + .mockReturnValue('batch-id-1'), + mockSendNotificationEmail: vi + .fn<(email: string, data: NotificationData) => Promise<{ success: boolean }>>() + .mockResolvedValue({ success: true }), + })) vi.mock('@/server/repositories/game-follow.repository', () => { class MockGameFollowRepository { iterateFollowerUserIds = mockIterateFollowerUserIds @@ -146,8 +149,6 @@ vi.mock('@/server/repositories/notification-preferences.repository', () => ({ }), })) -// ── Helpers ──────────────────────────────────────────────────────── - function resetMocks() { for (const model of Object.values(mockPrisma)) { for (const fn of Object.values(model)) { @@ -156,6 +157,8 @@ function resetMocks() { } mockIterateFollowerUserIds.mockReset() mockScheduleNotification.mockClear() + mockSendNotificationEmail.mockReset() + mockSendNotificationEmail.mockResolvedValue({ success: true }) mockPrisma.userBan.findMany.mockResolvedValue([]) mockPrisma.userRelationship.findMany.mockResolvedValue([]) mockPrisma.notification.findFirst.mockResolvedValue(null) @@ -261,6 +264,32 @@ describe('NotificationService', () => { expect(users).toContain('pc-author-1') }) + it('report.created returns moderator and higher users', async () => { + mockPrisma.user.findMany.mockResolvedValue([ + { id: 'moderator-1' }, + { id: 'admin-1' }, + { id: 'super-admin-1' }, + { id: 'reporter-1' }, + ]) + + const users = await serviceInternals.getUsersForEvent( + makeEvent({ + eventType: NOTIFICATION_EVENTS.REPORT_CREATED, + entityType: 'listingReport', + entityId: 'report-1', + triggeredBy: 'reporter-1', + includeTriggeredBy: true, + payload: { reportId: 'report-1', listingId: 'listing-1' }, + }), + ) + + expect(mockPrisma.user.findMany).toHaveBeenCalledWith({ + where: { role: { in: [Role.MODERATOR, Role.ADMIN, Role.SUPER_ADMIN] } }, + select: { id: true }, + }) + expect(users).toEqual(['moderator-1', 'admin-1', 'super-admin-1', 'reporter-1']) + }) + it('excludes the actor from recipients', async () => { mockPrisma.listing.findUnique.mockResolvedValue(makeListingRecord({ authorId: 'admin-1' })) @@ -516,6 +545,8 @@ describe('NotificationService', () => { ['pcListing.rejected', NotificationType.LISTING_REJECTED], ['game_follow.new_listing', NotificationType.FOLLOWED_GAME_NEW_LISTING], ['game_follow.new_pc_listing', NotificationType.FOLLOWED_GAME_NEW_PC_LISTING], + [NOTIFICATION_EVENTS.REPORT_CREATED, NotificationType.REPORT_CREATED], + [NOTIFICATION_EVENTS.REPORT_STATUS_CHANGED, NotificationType.REPORT_STATUS_CHANGED], ['listing.commented', NotificationType.COMMENT_ON_LISTING], ] @@ -572,6 +603,41 @@ describe('NotificationService', () => { }) }) + describe('createNotification', () => { + it('sends email for immediate BOTH delivery', async () => { + const { NotificationService } = await import('./service') + service = new NotificationService({ enableEmailDelivery: true }) + mockPrisma.notification.create.mockResolvedValue({ id: 'notification-1' }) + mockPrisma.notification.update.mockResolvedValue({ id: 'notification-1' }) + mockPrisma.user.findUnique.mockResolvedValue({ email: 'user@example.com' }) + + const result = await service.createNotification( + { + userId: 'user-1', + type: NotificationType.FEATURE_ANNOUNCEMENT, + category: NotificationCategory.SYSTEM, + title: 'Release available', + message: 'A new release is ready.', + deliveryChannel: DeliveryChannel.BOTH, + }, + { immediate: true }, + ) + + expect(result).toBe('notification-1') + expect(mockSendNotificationEmail).toHaveBeenCalledWith( + 'user@example.com', + expect.objectContaining({ + userId: 'user-1', + deliveryChannel: DeliveryChannel.BOTH, + }), + ) + expect(mockPrisma.notification.update).toHaveBeenCalledWith({ + where: { id: 'notification-1' }, + data: { deliveryStatus: NotificationDeliveryStatus.SENT }, + }) + }) + }) + describe('createNotificationFromEvent', () => { it('creates a scheduled listing approval notification from enriched listing data', async () => { mockPrisma.listing.findUnique.mockResolvedValue( diff --git a/src/server/notifications/service.ts b/src/server/notifications/service.ts index a42161fcc..90c26a699 100644 --- a/src/server/notifications/service.ts +++ b/src/server/notifications/service.ts @@ -19,7 +19,6 @@ import { import { createEmailService } from './emailService' import { type NotificationEventData, notificationEventEmitter } from './eventEmitter' import { notificationRateLimitService } from './rateLimitService' -import { realtimeNotificationService } from './realtimeService' import { notificationTemplateEngine, type TemplateContext } from './templates' import type { NotificationData, @@ -44,11 +43,6 @@ export class NotificationService { constructor(config: Partial = {}) { this.config = { enableEmailDelivery: false, - enableRealTimeDelivery: true, - maxRetries: 3, - retryDelayMs: 1000, - batchSize: 50, - rateLimitPerMinute: 60, ...config, } this.setupEventListeners() @@ -61,7 +55,6 @@ export class NotificationService { scheduledFor?: Date } = {}, ): Promise { - // Check rate limits first const rateLimitStatus = await notificationRateLimitService.checkRateLimit( data.userId, data.type, @@ -72,15 +65,12 @@ export class NotificationService { throw new Error(`Rate limit exceeded: ${rateLimitStatus.reason}`) } - // Record the notification for rate limiting notificationRateLimitService.recordNotification(data.userId, data.type) - // Determine if we should use immediate processing or batching const useImmediate = options.immediate ?? false const scheduledFor = options.scheduledFor ?? new Date() if (useImmediate) { - // Process immediately (for direct API calls, admin notifications, etc.) const notification = await prisma.notification.create({ data: { userId: data.userId, @@ -95,20 +85,14 @@ export class NotificationService { }, }) - // Attempt immediate delivery await this.deliverNotification(notification.id, data) - // Invalidate analytics cache when new notifications are created notificationAnalyticsService.clearCache() return notification.id - } else { - // Use batching service for better performance and retry logic - const batchId = notificationBatchingService.scheduleNotification(data, scheduledFor) - - console.log(`Notification scheduled for batch processing: ${batchId}`) - return batchId } + + return notificationBatchingService.scheduleNotification(data, scheduledFor) } async createNotificationFromEvent( @@ -116,9 +100,7 @@ export class NotificationService { userId: string, ): Promise { try { - // Map event type to notification type (with payload-based refinements) let notificationType = this.mapEventToNotificationType(eventData.eventType) - // Refine types that depend on payload values if (eventData.eventType === 'listing.voted') { const vote = eventData.payload?.voteValue ?? true notificationType = vote @@ -133,27 +115,22 @@ export class NotificationService { } if (!notificationType) return null - // Check user preferences const shouldSend = await this.shouldSendNotification(userId, notificationType, eventData) if (!shouldSend) return null - // Check for duplicate notifications before creating const isDuplicate = await this.checkForDuplicateNotification(userId, notificationType) if (isDuplicate) { logger.log(`Skipping duplicate notification for user ${userId}, type ${notificationType}`) return null } - // Enrich context with database data const enrichedContext = await this.enrichContextWithData(eventData, notificationType) - // Generate notification content const template = notificationTemplateEngine.generateTemplate( notificationType, enrichedContext, ) - // Create notification data const notificationData: NotificationData = { userId, type: notificationType, @@ -162,13 +139,11 @@ export class NotificationService { message: template.message, actionUrl: template.actionUrl, metadata: template.metadata, - deliveryChannel: DeliveryChannel.IN_APP, // Default to in-app for now + deliveryChannel: DeliveryChannel.IN_APP, } - // Event-driven notifications use batching for better performance - // This is especially important for bulk operations like listing approvals return await this.createNotification(notificationData, { - immediate: false, // Use batching for event-driven notifications + immediate: false, }) } catch (error) { logger.error('Error creating notification from event:', error) @@ -179,15 +154,14 @@ export class NotificationService { private async deliverNotification(notificationId: string, data: NotificationData): Promise { const deliveryResults: NotificationDeliveryResult[] = [] - // Always deliver in-app notifications - const inAppResult = await this.deliverInApp(notificationId, data) + const inAppResult = this.deliverInApp() deliveryResults.push(inAppResult) - // Deliver email if enabled and email service is configured if ( this.config.enableEmailDelivery && this.emailService && - data.deliveryChannel === DeliveryChannel.EMAIL + (data.deliveryChannel === DeliveryChannel.EMAIL || + data.deliveryChannel === DeliveryChannel.BOTH) ) { const user = await prisma.user.findUnique({ where: { id: data.userId }, @@ -200,7 +174,6 @@ export class NotificationService { } } - // Update delivery status based on results const hasSuccessfulDelivery = deliveryResults.some((result) => result.success) const allDeliveriesFailed = deliveryResults.every((result) => !result.success) @@ -216,49 +189,11 @@ export class NotificationService { }) } - private async deliverInApp( - notificationId: string, - data: NotificationData, - ): Promise { - try { - // Send real-time notification if user is connected - const notification = await prisma.notification.findUnique({ - where: { id: notificationId }, - }) - - if (notification) { - const sent = realtimeNotificationService.sendNotificationToUser(data.userId, { - id: notification.id, - type: notification.type, - title: notification.title, - message: notification.message, - actionUrl: notification.actionUrl || undefined, - createdAt: notification.createdAt.toISOString(), - }) - - // Also update unread count - const unreadCount = await prisma.notification.count({ - where: { userId: data.userId, isRead: false }, - }) - - realtimeNotificationService.sendUnreadCountToUser(data.userId, unreadCount) - - logger.log(`Real-time notification ${sent ? 'sent' : 'queued'} for user ${data.userId}`) - } - - return { - success: true, - channel: DeliveryChannel.IN_APP, - status: NotificationDeliveryStatus.SENT, - } - } catch (error) { - console.error('In-app delivery error:', error) - return { - success: false, - channel: DeliveryChannel.IN_APP, - status: NotificationDeliveryStatus.FAILED, - error: error instanceof Error ? error.message : 'Unknown error', - } + private deliverInApp(): NotificationDeliveryResult { + return { + success: true, + channel: DeliveryChannel.IN_APP, + status: NotificationDeliveryStatus.SENT, } } @@ -269,13 +204,10 @@ export class NotificationService { ): Promise { const preference = await this.prefRepo.getByType(userId, notificationType) - // If no preference exists and this type has an alias, check alias preference const aliasType = this.aliasPreferenceMap[notificationType] const aliasPreference = !preference && aliasType ? await this.prefRepo.getByType(userId, aliasType) : null - // If no preference exists, default to enabled for most notification types - // Only default to disabled for system notifications for non-admin users if (!preference && !aliasPreference) { if ( notificationType === NotificationType.MAINTENANCE_NOTICE || @@ -292,7 +224,6 @@ export class NotificationService { } if (!(preference?.inAppEnabled ?? aliasPreference?.inAppEnabled ?? true)) return false - // Check per-listing preferences for listing-related notifications if (eventData.payload?.listingId) { const listingPreference = await this.prefRepo.getListingPreference( userId, @@ -318,7 +249,6 @@ export class NotificationService { 'pcListing.approved': NotificationType.LISTING_APPROVED, 'listing.rejected': NotificationType.LISTING_REJECTED, 'pcListing.rejected': NotificationType.LISTING_REJECTED, - 'listing.status_overridden': NotificationType.LISTING_APPROVED, 'content.flagged': NotificationType.CONTENT_FLAGGED, 'game.added': NotificationType.GAME_ADDED, 'emulator.updated': NotificationType.EMULATOR_UPDATED, @@ -378,23 +308,12 @@ export class NotificationService { } async markAsRead(notificationId: string, userId: string): Promise { - // Update notification as read const updatedCount = await prisma.notification.updateMany({ - where: { id: notificationId, userId, isRead: false }, // Only update if currently unread + where: { id: notificationId, userId, isRead: false }, data: { isRead: true }, }) - // If notification was actually updated, invalidate caches and update real-time count if (updatedCount.count > 0) { - // Get updated unread count - const unreadCount = await prisma.notification.count({ - where: { userId, isRead: false }, - }) - - // Send real-time unread count update - realtimeNotificationService.sendUnreadCountToUser(userId, unreadCount) - - // Clear analytics cache since notification status changed notificationAnalyticsService.clearCache() logger.log(`Marked notification ${notificationId} as read for user ${userId}`) @@ -402,18 +321,12 @@ export class NotificationService { } async markAllAsRead(userId: string): Promise { - // Update all unread notifications as read const updatedCount = await prisma.notification.updateMany({ where: { userId, isRead: false }, data: { isRead: true }, }) - // If any notifications were updated, invalidate caches and update real-time count if (updatedCount.count > 0) { - // Send real-time unread count update (should be 0 after marking all as read) - realtimeNotificationService.sendUnreadCountToUser(userId, 0) - - // Clear analytics cache since notification status changed notificationAnalyticsService.clearCache() logger.log(`Marked ${updatedCount.count} notifications as read for user ${userId}`) @@ -421,30 +334,11 @@ export class NotificationService { } async deleteNotification(notificationId: string, userId: string): Promise { - // First check if the notification exists and is unread - const notification = await prisma.notification.findFirst({ - where: { id: notificationId, userId }, - select: { isRead: true }, - }) - - // Delete the notification const deletedCount = await prisma.notification.deleteMany({ where: { id: notificationId, userId }, }) - // If notification was deleted and was unread, update real-time count if (deletedCount.count > 0) { - // If the deleted notification was unread, update the unread count - if (notification && !notification.isRead) { - const unreadCount = await prisma.notification.count({ - where: { userId, isRead: false }, - }) - - // Send real-time unread count update - realtimeNotificationService.sendUnreadCountToUser(userId, unreadCount) - } - - // Clear analytics cache since a notification was deleted notificationAnalyticsService.clearCache() console.log(`Deleted notification ${notificationId} for user ${userId}`) @@ -475,7 +369,6 @@ export class NotificationService { } setupEventListeners(): void { - // Prevent duplicate listener registration if (this.listenersSetup) return notificationEventEmitter.onNotificationEvent(this.handleNotificationEvent.bind(this)) @@ -485,23 +378,19 @@ export class NotificationService { private async handleNotificationEvent(eventData: NotificationEventData): Promise { try { - // Get users to notify const userIds = await this.getUsersForEvent(eventData) - // Create notifications for each user const notificationPromises = userIds.map((userId) => this.createNotificationFromEvent(eventData, userId), ) await Promise.allSettled(notificationPromises) - // On listing approval, notify hardware-preference matches and game followers if (eventData.eventType === 'listing.approved' && eventData.payload?.listingId) { await this.notifyMatchingHardwareUsers(eventData, userIds) await this.notifyGameFollowers(eventData, userIds, 'listing') } - // On PC listing approval, notify game followers if (eventData.eventType === 'pcListing.approved' && eventData.payload?.pcListingId) { await this.notifyGameFollowers(eventData, userIds, 'pcListing') } @@ -634,7 +523,6 @@ export class NotificationService { switch (eventData.eventType) { case 'listing.verified': { - // Notify listing author (exclude actor via global filter below) if (eventData.payload?.listingId) { const listing = await prisma.listing.findUnique({ where: { id: eventData.payload.listingId }, @@ -646,7 +534,6 @@ export class NotificationService { } case 'listing.commented': case 'listing.voted': - // Get listing author (but not the person who triggered the event) if (eventData.payload?.listingId && eventData.triggeredBy) { const listing = await prisma.listing.findUnique({ where: { id: eventData.payload.listingId }, @@ -662,7 +549,6 @@ export class NotificationService { case 'comment.downvoted': case 'comment.created': case 'comment.replied': - // Notify comment author (but not the actor) if (eventData.payload?.commentId && eventData.triggeredBy) { const comment = await prisma.comment.findUnique({ where: { id: eventData.payload.commentId }, @@ -672,7 +558,6 @@ export class NotificationService { userIds.push(comment.userId) } } - // For replies, also notify the parent comment author if ( eventData.eventType === 'comment.replied' && eventData.payload?.parentId && @@ -693,7 +578,6 @@ export class NotificationService { break case 'user.mentioned': - // Get mentioned user if (eventData.payload?.userId) { userIds.push(eventData.payload.userId) } @@ -709,7 +593,6 @@ export class NotificationService { break case 'report.created': - // Notify moderators and above { const systemUsers = await prisma.user.findMany({ where: { @@ -722,14 +605,12 @@ export class NotificationService { break case 'report.status_changed': - // Notify reporting user if provided if (eventData.payload?.userId) { userIds.push(eventData.payload.userId) } break case 'listing.approved': - // Get listing author + users with matching hardware preferences if (eventData.payload?.listingId) { const listing = await prisma.listing.findUnique({ where: { id: eventData.payload.listingId }, @@ -737,14 +618,11 @@ export class NotificationService { }) if (listing) { userIds.push(listing.authorId) - // Hardware-match users are handled separately in handleNotificationEvent - // because they need a different notification type (NEW_DEVICE_LISTING) } } break case 'pcListing.approved': - // Get PC listing author — game followers handled separately in handleNotificationEvent if (eventData.payload?.pcListingId) { const pcListing = await prisma.pcListing.findUnique({ where: { id: eventData.payload.pcListingId }, @@ -757,7 +635,6 @@ export class NotificationService { break case 'listing.rejected': - // Get listing author if (eventData.payload?.listingId) { const listing = await prisma.listing.findUnique({ where: { id: eventData.payload.listingId }, @@ -795,18 +672,15 @@ export class NotificationService { break } - // Exclude the actor from recipients universally (no self-notifications) - if (eventData.triggeredBy) { + if (eventData.triggeredBy && !eventData.includeTriggeredBy) { const actorId = eventData.triggeredBy for (let i = userIds.length - 1; i >= 0; i--) { if (userIds[i] === actorId) userIds.splice(i, 1) } } - // Filter out banned users - they should not receive notifications const afterBanFilter = await this.filterBannedUsers(userIds) - // Filter out users who have blocked the triggering user return await this.filterBlockedUsers(afterBanFilter, eventData.triggeredBy) } @@ -824,15 +698,10 @@ export class NotificationService { return prisma.user.findMany({ where, select: { id: true } }) } - /** - * Filter out banned users from receiving notifications - * Banned users should not receive any notifications while their ban is active - */ private async filterBannedUsers(userIds: string[]): Promise { if (userIds.length === 0) return [] try { - // Get all users who have active bans const bannedUserIds = await prisma.userBan.findMany({ where: { userId: { in: userIds }, @@ -844,7 +713,6 @@ export class NotificationService { const bannedUserIdsSet = new Set(bannedUserIds.map((ban) => ban.userId)) - // Filter out banned users const filteredUserIds = userIds.filter((userId) => !bannedUserIdsSet.has(userId)) if (bannedUserIds.length > 0) { @@ -854,8 +722,6 @@ export class NotificationService { return filteredUserIds } catch (error) { console.error('Error filtering banned users from notifications:', error) - // If we can't filter banned users, return all userIds to avoid breaking notifications entirely - // This is a fallback - in production, you might want to handle this differently return userIds } } @@ -896,16 +762,11 @@ export class NotificationService { } } - /** - * Check for duplicate notifications to prevent spam - * Prevents the same notification from being sent multiple times for the same event - */ private async checkForDuplicateNotification( userId: string, notificationType: NotificationType, ): Promise { try { - // Define deduplication window based on notification type const deduplicationWindows: Partial> = { [NotificationType.LISTING_APPROVED]: ms.hours(1), [NotificationType.LISTING_REJECTED]: ms.hours(1), @@ -931,9 +792,6 @@ export class NotificationService { const windowMs = deduplicationWindows[notificationType] || ms.minutes(30) const windowStart = new Date(Date.now() - windowMs) - // Check for existing similar notifications within the deduplication window - // For simplicity, we check by userId, type, and time window only - // This prevents rapid-fire duplicate notifications for the same user and type const existingNotification = await prisma.notification.findFirst({ where: { userId, @@ -954,7 +812,6 @@ export class NotificationService { return false } catch (error) { console.error('Error checking for duplicate notifications:', error) - // If we can't check for duplicates, allow the notification to prevent breaking functionality return false } } @@ -967,7 +824,6 @@ export class NotificationService { const payload = eventData.payload || {} try { - // Get triggering user info if (eventData.triggeredBy) { const user = await prisma.user.findUnique({ where: { id: eventData.triggeredBy }, @@ -976,7 +832,6 @@ export class NotificationService { context.userName = user?.name || undefined } - // Enrich based on notification type and available data if (payload.listingId) { const listing = await prisma.listing.findUnique({ where: { id: payload.listingId }, @@ -1005,7 +860,6 @@ export class NotificationService { } } - // Enrich PC listing data if (payload.pcListingId && !context.listingId) { const pcListing = await prisma.pcListing.findUnique({ where: { id: payload.pcListingId }, @@ -1022,7 +876,6 @@ export class NotificationService { } } - // Handle comment-specific data if ( payload.commentId && (notificationType === NotificationType.LISTING_COMMENT || @@ -1049,7 +902,6 @@ export class NotificationService { context.listingId = comment.listingId context.parentCommentId = comment.parentId || undefined - // If we don't have listing data yet, fetch it if (!context.listingTitle && comment.listingId) { const listing = await prisma.listing.findUnique({ where: { id: comment.listingId }, @@ -1065,7 +917,6 @@ export class NotificationService { } } - // Handle game-specific data if (payload.gameId) { const game = await prisma.game.findUnique({ where: { id: payload.gameId }, @@ -1077,7 +928,6 @@ export class NotificationService { } } - // Handle device-specific data for NEW_DEVICE_LISTING if (payload.deviceId && notificationType === NotificationType.NEW_DEVICE_LISTING) { const device = await prisma.device.findUnique({ where: { id: payload.deviceId }, @@ -1091,7 +941,6 @@ export class NotificationService { } } - // Handle SOC-specific data for NEW_SOC_LISTING if (payload.socId && notificationType === NotificationType.NEW_SOC_LISTING) { const soc = await prisma.soC.findUnique({ where: { id: payload.socId }, @@ -1103,7 +952,6 @@ export class NotificationService { } } - // Handle emulator-specific data if (payload.emulatorId) { const emulator = await prisma.emulator.findUnique({ where: { id: payload.emulatorId }, @@ -1115,7 +963,6 @@ export class NotificationService { } } - // Handle role change specific data if (notificationType === NotificationType.ROLE_CHANGED) { context.oldRole = payload.oldRole as string context.newRole = payload.newRole as string @@ -1130,7 +977,6 @@ export class NotificationService { } } - // Handle moderation-specific data if ( notificationType === NotificationType.LISTING_APPROVED || notificationType === NotificationType.LISTING_REJECTED @@ -1154,7 +1000,6 @@ export class NotificationService { context.rejectedAt = payload.rejectedAt as string } - // Handle user ban/unban data if ( notificationType === NotificationType.USER_BANNED || notificationType === NotificationType.USER_UNBANNED @@ -1171,7 +1016,6 @@ export class NotificationService { } } - // Handle report data if ( notificationType === NotificationType.REPORT_CREATED || notificationType === NotificationType.REPORT_STATUS_CHANGED @@ -1185,7 +1029,6 @@ export class NotificationService { (payload.listingId ? `/listings/${payload.listingId}` : undefined) } - // Handle developer verified if (notificationType === NotificationType.VERIFIED_DEVELOPER && payload.emulatorId) { const emulator = await prisma.emulator.findUnique({ where: { id: payload.emulatorId as string }, @@ -1197,7 +1040,6 @@ export class NotificationService { } } - // Refine listing vote events to up/down based on payload if ( (notificationType === NotificationType.LISTING_UPVOTED || notificationType === NotificationType.LISTING_VOTE_UP || @@ -1208,7 +1050,6 @@ export class NotificationService { context.voteValue = payload.voteValue } - // Copy over any additional metadata Object.keys(payload).forEach((key) => { if (!context[key]) { context[key] = payload[key] @@ -1221,34 +1062,6 @@ export class NotificationService { return context } - /** - * Schedule a notification for future delivery (e.g., weekly digests, maintenance notices) - */ - async scheduleNotification( - data: NotificationData, - scheduledFor: Date, - maxAttempts?: number, - ): Promise { - return notificationBatchingService.scheduleNotification(data, scheduledFor, maxAttempts) - } - - /** - * Schedule weekly digest notifications for a user - */ - scheduleWeeklyDigest(userId: string): void { - notificationBatchingService.scheduleWeeklyDigest(userId) - } - - /** - * Schedule maintenance notifications for all users - */ - scheduleMaintenanceNotification(scheduledFor: Date, title: string, message: string): void { - notificationBatchingService.scheduleMaintenanceNotification(scheduledFor, title, message) - } - - /** - * Get current batching queue status - */ getBatchingQueueStatus() { return notificationBatchingService.getQueueStatus() } diff --git a/src/server/notifications/types.ts b/src/server/notifications/types.ts index c3832ed9a..8c46ee00a 100644 --- a/src/server/notifications/types.ts +++ b/src/server/notifications/types.ts @@ -5,17 +5,6 @@ import type { NotificationDeliveryStatus, } from '@orm/client' -export interface NotificationEvent { - id: string - eventType: string - entityType: string - entityId: string - triggeredBy?: string - payload?: Record - processedAt?: Date - createdAt: Date -} - export interface NotificationData { userId: string type: NotificationType @@ -27,19 +16,6 @@ export interface NotificationData { deliveryChannel?: DeliveryChannel } -export interface NotificationPreferenceData { - userId: string - type: NotificationType - inAppEnabled: boolean - emailEnabled: boolean -} - -export interface ListingNotificationPreferenceData { - userId: string - listingId: string - isEnabled: boolean -} - export interface NotificationTemplate { title: string message: string @@ -72,9 +48,4 @@ export interface NotificationEventPayload { export interface NotificationServiceConfig { enableEmailDelivery: boolean - enableRealTimeDelivery: boolean - maxRetries: number - retryDelayMs: number - batchSize: number - rateLimitPerMinute: number } 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 71145eab8..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 } @@ -123,21 +124,69 @@ export class CommentsRepository extends BaseRepository { }) } - /** - * Create a new comment - */ - async create( - data: Prisma.CommentCreateInput, - ): Promise> { - return this.prisma.comment.create({ - data, - include: CommentsRepository.includes.minimal, + async listingExists(listingId: string): Promise { + const listing = await this.handleDatabaseOperation( + () => this.prisma.listing.findUnique({ where: { id: listingId }, select: { id: true } }), + 'Listing', + ) + + return listing !== null + } + + async commentBelongsToListing(commentId: string, listingId: string): Promise { + const comment = await this.handleDatabaseOperation( + () => + this.prisma.comment.findUnique({ + where: { id: commentId }, + select: { listingId: true }, + }), + 'Comment', + ) + + return comment?.listingId === listingId + } + + async userExists(userId: string): Promise { + const user = await this.handleDatabaseOperation( + () => this.prisma.user.findUnique({ where: { id: userId }, select: { id: true } }), + 'User', + ) + + return user !== null + } + + async countByUser(userId: string): Promise { + return this.handleDatabaseOperation( + () => this.prisma.comment.count({ where: { userId } }), + 'Comment', + ) + } + + async create(data: Prisma.CommentCreateInput): Promise { + return this.handleDatabaseOperation( + () => + this.prisma.comment.create({ + data, + include: CommentsRepository.includes.minimal, + }), + 'Comment', + ) + } + + async createForListing(input: { + content: string + userId: string + listingId: string + parentId?: string + }): Promise { + return this.create({ + content: input.content, + user: { connect: { id: input.userId } }, + listing: { connect: { id: input.listingId } }, + ...(input.parentId ? { parent: { connect: { id: input.parentId } } } : {}), }) } - /** - * Update a comment - */ async update( id: string, data: Prisma.CommentUpdateInput, @@ -260,3 +309,7 @@ export class CommentsRepository extends BaseRepository { } } } + +export type MinimalComment = Prisma.CommentGetPayload<{ + include: typeof CommentsRepository.includes.minimal +}> 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/device-brands.repository.test.ts b/src/server/repositories/device-brands.repository.test.ts new file mode 100644 index 000000000..884e806e4 --- /dev/null +++ b/src/server/repositories/device-brands.repository.test.ts @@ -0,0 +1,94 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { type PrismaClient } from '@orm/client' +import { DeviceBrandsRepository } from './device-brands.repository' + +vi.mock('@orm/client', async () => { + const actual = await import('@orm/client') + return { + ...actual, + Prisma: { + ...actual.Prisma, + QueryMode: { insensitive: 'insensitive' }, + SortOrder: { asc: 'asc', desc: 'desc' }, + }, + } +}) + +function createMockPrisma() { + return { + deviceBrand: { + findMany: vi.fn().mockResolvedValue([]), + findUnique: vi.fn().mockResolvedValue(null), + count: vi.fn().mockResolvedValue(0), + }, + } as unknown as PrismaClient +} + +describe('DeviceBrandsRepository', () => { + let prisma: PrismaClient + let repository: DeviceBrandsRepository + + beforeEach(() => { + prisma = createMockPrisma() + repository = new DeviceBrandsRepository(prisma) + }) + + it('filters brands to CPU-backed brands when category is cpu', async () => { + await repository.list({ category: 'cpu', search: 'in', limit: 10 }) + + expect(prisma.deviceBrand.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + name: { contains: 'in', mode: 'insensitive' }, + cpus: { some: {} }, + }, + take: 10, + }), + ) + }) + + it('filters brands to GPU-backed brands when category is gpu', async () => { + await repository.list({ category: 'gpu' }) + + expect(prisma.deviceBrand.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + gpus: { some: {} }, + }, + }), + ) + }) + + it('uses the default limit when no limit is provided', async () => { + await repository.list() + + expect(prisma.deviceBrand.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + take: 50, + }), + ) + }) + + it('loads a brand by id with its device count', async () => { + await repository.byIdWithCounts('brand-id') + + expect(prisma.deviceBrand.findUnique).toHaveBeenCalledWith({ + where: { id: 'brand-id' }, + include: { + _count: { + select: { devices: true }, + }, + }, + }) + }) + + it('uses the same category filter when counting brands', async () => { + await repository.count({ category: 'cpu' }) + + expect(prisma.deviceBrand.count).toHaveBeenCalledWith({ + where: { + cpus: { some: {} }, + }, + }) + }) +}) diff --git a/src/server/repositories/device-brands.repository.ts b/src/server/repositories/device-brands.repository.ts index 7b63a666c..b94ce1059 100644 --- a/src/server/repositories/device-brands.repository.ts +++ b/src/server/repositories/device-brands.repository.ts @@ -25,13 +25,8 @@ export class DeviceBrandsRepository extends BaseRepository { ): Promise< Prisma.DeviceBrandGetPayload<{ include: typeof DeviceBrandsRepository.includes.withCounts }>[] > { - const { search, limit = 50, sortField = 'name', sortDirection } = filters - - const where: Prisma.DeviceBrandWhereInput = { - ...(search && { - name: { contains: search, mode: this.mode }, - }), - } + const { limit = 50, sortField = 'name', sortDirection } = filters + const where = this.buildWhereClause(filters) // Map schema sort fields to Prisma orderBy const orderBy: Prisma.DeviceBrandOrderByWithRelationInput = @@ -51,6 +46,18 @@ export class DeviceBrandsRepository extends BaseRepository { return this.prisma.deviceBrand.findUnique({ where: { id } }) } + async byIdWithCounts( + id: string, + ): Promise< + | Prisma.DeviceBrandGetPayload<{ include: typeof DeviceBrandsRepository.includes.withCounts }> + | null + > { + return this.prisma.deviceBrand.findUnique({ + where: { id }, + include: DeviceBrandsRepository.includes.withCounts, + }) + } + async create(data: CreateDeviceBrandInput): Promise { // Check for duplicate const exists = await this.existsByName(data.name) @@ -101,15 +108,19 @@ export class DeviceBrandsRepository extends BaseRepository { * Get total count with filters */ async count(filters: GetDeviceBrandsInput = {}): Promise { - const { search } = filters + const where = this.buildWhereClause(filters) + + return this.prisma.deviceBrand.count({ where }) + } - const where: Prisma.DeviceBrandWhereInput = { - ...(search && { - name: { contains: search, mode: this.mode }, + private buildWhereClause(filters: GetDeviceBrandsInput = {}): Prisma.DeviceBrandWhereInput { + return { + ...(filters.search && { + name: { contains: filters.search, mode: this.mode }, }), + ...(filters.category === 'cpu' && { cpus: { some: {} } }), + ...(filters.category === 'gpu' && { gpus: { some: {} } }), } - - return this.prisma.deviceBrand.count({ where }) } /** diff --git a/src/server/repositories/devices.repository.ts b/src/server/repositories/devices.repository.ts index defe8f61a..abea680da 100644 --- a/src/server/repositories/devices.repository.ts +++ b/src/server/repositories/devices.repository.ts @@ -1,9 +1,8 @@ import { startOfMonth, subDays } from 'date-fns' import { LRUCache } from 'lru-cache' -import { HOME_PAGE_LIMITS } from '@/data/constants' +import { CACHE_DURATIONS, HOME_PAGE_LIMITS, LOOKUP_PAGINATION, PAGINATION } from '@/data/constants' import { ResourceError } from '@/lib/errors' -import { type PaginationResult, paginate, calculateOffset } from '@/server/utils/pagination' -import { TIME_CONSTANTS } from '@/utils/time' +import { paginate, calculateOffset } from '@/server/utils/pagination' import { Prisma, ApprovalStatus } from '@orm/client' import { getTrendingDevices } from '@orm/sql' import { BaseRepository } from './base.repository' @@ -13,7 +12,9 @@ import type { GetDeviceOptionsInput, CreateDeviceInput, UpdateDeviceInput, + GetDevicesByIdsInput, } from '@/schemas/device' +import type { PaginationResult } from '@/schemas/pagination' export interface TrendingDevice { id: string @@ -31,7 +32,7 @@ export interface TrendingDevicesSummary { } const trendingDevicesSummaryCache = new LRUCache({ - ttl: TIME_CONSTANTS.SIX_HOURS, + ttl: CACHE_DURATIONS.LOOKUP, max: 20, }) @@ -93,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 } }, @@ -235,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) @@ -253,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 } @@ -265,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), @@ -339,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.test.ts b/src/server/repositories/listings.repository.test.ts new file mode 100644 index 000000000..920bd4aed --- /dev/null +++ b/src/server/repositories/listings.repository.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest' +import { ApprovalStatus, Prisma, Role } from '@orm' +import { ListingsRepository, type ListingFilters } from './listings.repository' + +const FILTERS = { + userId: 'user-123', + userRole: Role.USER, + search: 'zelda', +} satisfies ListingFilters + +describe('handheld listing repository query builder', () => { + it('keeps search and authenticated visibility filters conjunctive', () => { + const where = ListingsRepository.buildListWhere(FILTERS) + + expect(where).toMatchObject({ + AND: [ + { + OR: expect.arrayContaining([ + { + game: { + title: { contains: FILTERS.search, mode: Prisma.QueryMode.insensitive }, + }, + }, + ]), + }, + { + OR: [ + { status: ApprovalStatus.APPROVED }, + { status: ApprovalStatus.PENDING, authorId: FILTERS.userId }, + ], + }, + ], + }) + expect(where).not.toHaveProperty('OR') + }) +}) diff --git a/src/server/repositories/listings.repository.ts b/src/server/repositories/listings.repository.ts index 467978e43..4e769a061 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' @@ -183,9 +183,8 @@ export class ListingsRepository extends BaseRepository { * Build the where clause for listing queries * @param filters - Listing filter options including search, IDs, and user context * @returns Prisma where clause object - * @private */ - private buildWhereClause(filters: ListingFilters): Prisma.ListingWhereInput { + static buildListWhere(filters: ListingFilters): Prisma.ListingWhereInput { const where: Prisma.ListingWhereInput = {} let gameFilter: Prisma.GameWhereInput = {} @@ -265,9 +264,15 @@ export class ListingsRepository extends BaseRepository { ) if (statusFilter) { if (Array.isArray(statusFilter)) { - where.OR = where.OR - ? [...(Array.isArray(where.OR) ? where.OR : [where.OR]), ...statusFilter] - : statusFilter + if (where.OR) { + const existingAnd = Array.isArray(where.AND) ? where.AND : where.AND ? [where.AND] : [] + const existingOr = Array.isArray(where.OR) ? where.OR : [where.OR] + + where.AND = [...existingAnd, { OR: existingOr }, { OR: statusFilter }] + delete where.OR + } else { + where.OR = statusFilter + } } else { Object.assign(where, statusFilter) } @@ -300,7 +305,7 @@ export class ListingsRepository extends BaseRepository { const limit = filters.limit || 20 const offset = calculateOffset({ page: filters.page, offset: filters.offset }, limit) - const where = this.buildWhereClause(filters) + const where = ListingsRepository.buildListWhere(filters) // Build order by clause - now includes native success rate sorting! const orderBy = this.buildOrderBy(filters.sortField, filters.sortDirection) 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/performance-scales.repository.ts b/src/server/repositories/performance-scales.repository.ts index afba95af9..553f728bc 100644 --- a/src/server/repositories/performance-scales.repository.ts +++ b/src/server/repositories/performance-scales.repository.ts @@ -1,4 +1,4 @@ -import { ResourceError } from '@/lib/errors' +import { AppError, ResourceError } from '@/lib/errors' import { Prisma, type PerformanceScale } from '@orm/client' import { BaseRepository } from './base.repository' import type { @@ -7,13 +7,7 @@ import type { UpdatePerformanceScaleInput, } from '@/schemas/performanceScale' -/** - * Repository for PerformanceScale data access - * Note: PerformanceScale uses numeric IDs instead of UUIDs - * We override the base class methods to use number instead of string for IDs - */ export class PerformanceScalesRepository extends BaseRepository { - // Static query shapes for this repository static readonly includes = { default: {} satisfies Prisma.PerformanceScaleInclude, @@ -34,7 +28,6 @@ export class PerformanceScalesRepository extends BaseRepository { }), } - // Map schema sort fields to Prisma orderBy const orderBy: Prisma.PerformanceScaleOrderByWithRelationInput = sortField === 'label' ? { label: sortDirection || this.sortOrder } @@ -43,24 +36,20 @@ export class PerformanceScalesRepository extends BaseRepository { return this.prisma.performanceScale.findMany({ where, orderBy }) } - // Override base class method to use string ID (will convert internally) async byId(id: string): Promise { const numericId = parseInt(id, 10) if (isNaN(numericId)) return null return this.byNumericId(numericId) } - // Numeric ID version for internal use async byNumericId(id: number): Promise { return this.prisma.performanceScale.findUnique({ where: { id } }) } async create(data: CreatePerformanceScaleInput): Promise { - // Check for duplicate rank const rankExists = await this.existsByRank(data.rank) if (rankExists) throw ResourceError.performanceScale.rankAlreadyExists(data.rank) - // Check for duplicate label const labelExists = await this.existsByLabel(data.label) if (labelExists) throw ResourceError.performanceScale.alreadyExists(data.label) @@ -70,29 +59,24 @@ export class PerformanceScalesRepository extends BaseRepository { ) } - // Override base class method to use string ID (will convert internally) async update(id: string, data: Partial): Promise { const numericId = parseInt(id, 10) if (isNaN(numericId)) throw new Error('Invalid numeric ID') return this.updateByNumericId(numericId, data) } - // Numeric ID version for internal use async updateByNumericId( id: number, data: Partial, ): Promise { - // Check if scale exists const scale = await this.byNumericId(id) if (!scale) throw ResourceError.performanceScale.notFound() - // Check for duplicate rank if being updated if (data.rank !== undefined) { const rankExists = await this.existsByRank(data.rank, id) if (rankExists) throw ResourceError.performanceScale.rankAlreadyExists(data.rank) } - // Check for duplicate label if being updated if (data.label) { const labelExists = await this.existsByLabel(data.label, id) if (labelExists) throw ResourceError.performanceScale.alreadyExists(data.label) @@ -104,16 +88,17 @@ export class PerformanceScalesRepository extends BaseRepository { ) } - // Override base class method to use string ID (will convert internally) async delete(id: string): Promise { const numericId = parseInt(id, 10) if (isNaN(numericId)) throw new Error('Invalid numeric ID') await this.deleteByNumericId(numericId) } - // Numeric ID version for internal use async deleteByNumericId(id: number): Promise { - // Check if scale exists and has listings + await this.deleteByNumericIdWithReplacement(id) + } + + async deleteByNumericIdWithReplacement(id: number, replacementId?: number): Promise { const scale = await this.prisma.performanceScale.findUnique({ where: { id }, include: { _count: { select: { listings: true, pcListings: true } } }, @@ -121,20 +106,46 @@ export class PerformanceScalesRepository extends BaseRepository { if (!scale) throw ResourceError.performanceScale.notFound() + if (replacementId === id) { + throw AppError.badRequest( + 'Replacement performance scale must be different from the deleted scale', + ) + } + + if (replacementId !== undefined) { + const replacementScale = await this.prisma.performanceScale.findUnique({ + where: { id: replacementId }, + select: { id: true }, + }) + + if (!replacementScale) throw ResourceError.performanceScale.notFound() + } + const totalListings = scale._count.listings + scale._count.pcListings - if (totalListings > 0) { + if (totalListings > 0 && replacementId === undefined) { throw ResourceError.performanceScale.inUse(totalListings) } await this.handleDatabaseOperation( - () => this.prisma.performanceScale.delete({ where: { id } }), + () => + this.prisma.$transaction(async (tx) => { + if (replacementId !== undefined) { + await tx.listing.updateMany({ + where: { performanceId: id }, + data: { performanceId: replacementId }, + }) + await tx.pcListing.updateMany({ + where: { performanceId: id }, + data: { performanceId: replacementId }, + }) + } + + await tx.performanceScale.delete({ where: { id } }) + }), 'PerformanceScale', ) } - /** - * Get total count with filters - */ async count(filters: GetPerformanceScalesInput = {}): Promise { const { search } = filters @@ -150,9 +161,6 @@ export class PerformanceScalesRepository extends BaseRepository { return this.prisma.performanceScale.count({ where }) } - /** - * Check if rank is already taken - */ async existsByRank(rank: number, excludeId?: number): Promise { const scale = await this.prisma.performanceScale.findFirst({ where: { rank, ...(excludeId && { id: { not: excludeId } }) }, @@ -160,9 +168,6 @@ export class PerformanceScalesRepository extends BaseRepository { return !!scale } - /** - * Check if label exists - */ async existsByLabel(label: string, excludeId?: number): Promise { const scale = await this.prisma.performanceScale.findFirst({ where: { @@ -173,23 +178,34 @@ export class PerformanceScalesRepository extends BaseRepository { return !!scale } - /** - * Get performance scales with listing counts - */ - async listWithCounts(): Promise< + async listWithCounts(filters: GetPerformanceScalesInput = {}): Promise< Prisma.PerformanceScaleGetPayload<{ include: typeof PerformanceScalesRepository.includes.withCounts }>[] > { + const { search, sortField = 'rank', sortDirection } = filters + + const where: Prisma.PerformanceScaleWhereInput = { + ...(search && { + OR: [ + { label: { contains: search, mode: this.mode } }, + { description: { contains: search, mode: this.mode } }, + ], + }), + } + + const orderBy: Prisma.PerformanceScaleOrderByWithRelationInput = + sortField === 'label' + ? { label: sortDirection || this.sortOrder } + : { rank: sortDirection || this.sortOrder } + return this.prisma.performanceScale.findMany({ + where, include: PerformanceScalesRepository.includes.withCounts, - orderBy: { rank: this.sortOrder }, + orderBy, }) } - /** - * Get the next available rank - */ async getNextRank(): Promise { const highestRank = await this.prisma.performanceScale.findFirst({ orderBy: { rank: Prisma.SortOrder.desc }, @@ -198,9 +214,6 @@ export class PerformanceScalesRepository extends BaseRepository { return (highestRank?.rank ?? 0) + 1 } - /** - * Reorder performance scales - */ async reorder(scales: { id: number; rank: number }[]): Promise { await this.prisma.$transaction( scales.map((scale) => @@ -212,27 +225,18 @@ export class PerformanceScalesRepository extends BaseRepository { ) } - /** - * Get performance scale by label - */ async byLabel(label: string): Promise { return this.prisma.performanceScale.findFirst({ where: { label: { equals: label, mode: this.mode } }, }) } - /** - * Get performance scale by rank - */ async byRank(rank: number): Promise { return this.prisma.performanceScale.findFirst({ where: { rank }, }) } - /** - * Get statistics about performance scales - */ async stats(): Promise<{ total: number withListings: number 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.test.ts b/src/server/repositories/socs.repository.test.ts new file mode 100644 index 000000000..08e652acf --- /dev/null +++ b/src/server/repositories/socs.repository.test.ts @@ -0,0 +1,138 @@ +import { PrismaPg } from '@prisma/adapter-pg' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { PrismaClient } from '@orm/client' +import { SoCsRepository } from './socs.repository' +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' }, + }, + PrismaClient: vi.fn().mockImplementation(function MockPrismaClient() { + return { + soC: { + count: vi.fn(), + findMany: vi.fn(), + }, + } + }), + } +}) + +const mockSoc = { + id: 'soc-1', + name: 'Snapdragon 8 Gen 3', + manufacturer: 'Qualcomm', + architecture: 'ARM64', + processNode: '4nm', + cpuCores: 8, + gpuModel: 'Adreno 750', + _count: { devices: 3 }, +} + +function createMockPrisma() { + const prisma = new PrismaClient({ + adapter: new PrismaPg({ connectionString: 'postgresql://test:test@localhost:5432/test' }), + }) + vi.mocked(prisma.soC.count).mockResolvedValue(42) + vi.mocked(prisma.soC.findMany).mockResolvedValue([mockSoc] as never) + return prisma +} + +type MockPrisma = ReturnType + +describe('SoCsRepository', () => { + let prisma: MockPrisma + let repository: SoCsRepository + + beforeEach(() => { + prisma = createMockPrisma() + repository = new SoCsRepository(prisma) + }) + + describe('list', () => { + it('calculates database offset from page input', async () => { + const result = await repository.list({ page: 3, limit: 10 }) + + expect(prisma.soC.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + skip: 20, + take: 10, + orderBy: { name: 'asc' }, + }), + ) + expect(result.socs).toEqual([mockSoc]) + expect(result.pagination).toEqual( + expect.objectContaining({ + total: 42, + page: 3, + limit: 10, + offset: 20, + }), + ) + }) + + it('uses offset input when page is not provided', async () => { + const result = await repository.list({ offset: 15, limit: 5 }) + + expect(prisma.soC.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + skip: 15, + take: 5, + }), + ) + expect(result.pagination).toEqual( + expect.objectContaining({ + page: 4, + offset: 15, + limit: 5, + }), + ) + }) + + it('lets page input take precedence over offset input', async () => { + await repository.list({ page: 2, offset: 75, limit: 10 }) + + expect(prisma.soC.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + skip: 10, + take: 10, + }), + ) + }) + + it('applies search filters and devices count sorting to both queries', async () => { + await repository.list({ + search: 'snapdragon', + sortField: 'devicesCount', + sortDirection: 'desc', + limit: 20, + page: 1, + }) + + const where = { + OR: [ + { name: { contains: 'snapdragon', mode: 'insensitive' } }, + { manufacturer: { contains: 'snapdragon', mode: 'insensitive' } }, + { architecture: { contains: 'snapdragon', mode: 'insensitive' } }, + { gpuModel: { contains: 'snapdragon', mode: 'insensitive' } }, + ], + } + + expect(prisma.soC.count).toHaveBeenCalledWith({ where }) + expect(prisma.soC.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where, + orderBy: { devices: { _count: 'desc' } }, + skip: 0, + take: 20, + }), + ) + }) + }) +}) diff --git a/src/server/repositories/socs.repository.ts b/src/server/repositories/socs.repository.ts index 4e072bd47..3238af794 100644 --- a/src/server/repositories/socs.repository.ts +++ b/src/server/repositories/socs.repository.ts @@ -1,7 +1,9 @@ -import { PAGINATION } from '@/data/constants' +import { LOOKUP_PAGINATION, PAGINATION } from '@/data/constants' import { ResourceError } from '@/lib/errors' +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, @@ -9,6 +11,7 @@ import type { UpdateSoCInput, } from '@/schemas/soc' +type SoCFilters = NonNullable type SoCOptionFilters = NonNullable /** @@ -24,23 +27,15 @@ export class SoCsRepository extends BaseRepository { } satisfies Prisma.SoCInclude, } as const - async list( - filters: GetSoCsInput = {}, - ): Promise[]> { + async list(filters: SoCFilters = {}): Promise<{ + socs: Prisma.SoCGetPayload<{ include: typeof SoCsRepository.includes.withCounts }>[] + pagination: PaginationResult + }> { const sortDirection = filters.sortDirection ?? this.sortOrder const sortField = filters.sortField ?? 'name' - const { limit = PAGINATION.DEFAULT_LIMIT, offset = 0 } = filters - - const where: Prisma.SoCWhereInput = { - ...(filters.search && { - OR: [ - { name: { contains: filters.search, mode: this.mode } }, - { manufacturer: { contains: filters.search, mode: this.mode } }, - { architecture: { contains: filters.search, mode: this.mode } }, - { gpuModel: { contains: filters.search, mode: this.mode } }, - ], - }), - } + const { limit = PAGINATION.DEFAULT_LIMIT, offset = 0, page } = filters + const actualOffset = calculateOffset({ page, offset }, limit) + const where = this.buildWhere(filters) // Map schema sort fields to Prisma orderBy const orderBy: Prisma.SoCOrderByWithRelationInput = @@ -50,20 +45,31 @@ export class SoCsRepository extends BaseRepository { ? { devices: { _count: sortDirection } } : { name: sortDirection } - return this.prisma.soC.findMany({ - where, - include: SoCsRepository.includes.withCounts, - orderBy, - take: limit, - skip: offset, + const [total, socs] = await Promise.all([ + this.prisma.soC.count({ where }), + this.prisma.soC.findMany({ + where, + include: SoCsRepository.includes.withCounts, + orderBy, + take: limit, + skip: actualOffset, + }), + ]) + + const pagination = paginate({ + total, + page: page ?? Math.floor(actualOffset / limit) + 1, + limit, }) + + return { socs, pagination } } async options(filters: SoCOptionFilters = {}): Promise<{ 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 && { @@ -166,21 +172,21 @@ export class SoCsRepository extends BaseRepository { /** * Get total count with filters */ - async count(filters: GetSoCsInput = {}): Promise { - const { search } = filters + async count(filters: SoCFilters = {}): Promise { + return this.prisma.soC.count({ where: this.buildWhere(filters) }) + } - const where: Prisma.SoCWhereInput = { - ...(search && { + private buildWhere(filters: SoCFilters): Prisma.SoCWhereInput { + return { + ...(filters.search && { OR: [ - { name: { contains: search, mode: this.mode } }, - { manufacturer: { contains: search, mode: this.mode } }, - { architecture: { contains: search, mode: this.mode } }, - { gpuModel: { contains: search, mode: this.mode } }, + { name: { contains: filters.search, mode: this.mode } }, + { manufacturer: { contains: filters.search, mode: this.mode } }, + { architecture: { contains: filters.search, mode: this.mode } }, + { gpuModel: { contains: filters.search, mode: this.mode } }, ], }), } - - return this.prisma.soC.count({ where }) } /** 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/catalog.service.test.ts b/src/server/services/catalog.service.test.ts index f2404f9d0..17d520cf9 100644 --- a/src/server/services/catalog.service.test.ts +++ b/src/server/services/catalog.service.test.ts @@ -54,7 +54,7 @@ const cachedResponse = { }, systems: [], generatedAt: new Date('2026-01-01T00:00:00.000Z'), - cacheExpiresIn: 600, + cacheExpiresIn: 900, } describe('catalog compatibility cache', () => { diff --git a/src/server/services/catalog.service.ts b/src/server/services/catalog.service.ts index 2f5cdcea8..2a0af485b 100644 --- a/src/server/services/catalog.service.ts +++ b/src/server/services/catalog.service.ts @@ -31,6 +31,8 @@ export interface GetDeviceCompatibilityContext { userId?: string } +const CATALOG_COMPATIBILITY_CACHE_SECONDS = 900 + /** * Get device compatibility scores aggregated by system * @@ -45,7 +47,7 @@ export interface GetDeviceCompatibilityContext { * - When a system has < MINIMUM_DEVICE_LISTINGS (5) on the device, * data from other devices with the same SoC is included * - * Results are cached for 10 minutes to reduce server load. + * Results are cached for 15 minutes to reduce server load. */ export async function getDeviceCompatibility( input: GetDeviceCompatibilityInput, @@ -88,7 +90,7 @@ export async function getDeviceCompatibility( }, systems: [], generatedAt: new Date(), - cacheExpiresIn: 600, + cacheExpiresIn: CATALOG_COMPATIBILITY_CACHE_SECONDS, } } @@ -239,7 +241,7 @@ export async function getDeviceCompatibility( }, systems, generatedAt: new Date(), - cacheExpiresIn: 600, // 10 minutes + cacheExpiresIn: CATALOG_COMPATIBILITY_CACHE_SECONDS, } catalogCompatibilityCache.set(cacheKey, response) diff --git a/src/server/services/listing-comment.service.ts b/src/server/services/listing-comment.service.ts new file mode 100644 index 000000000..86fdd25c3 --- /dev/null +++ b/src/server/services/listing-comment.service.ts @@ -0,0 +1,104 @@ +import analytics from '@/lib/analytics' +import { ResourceError } from '@/lib/errors' +import { logger } from '@/lib/logger' +import { notificationEventEmitter, NOTIFICATION_EVENTS } from '@/server/notifications/eventEmitter' +import { CommentsRepository, type MinimalComment } from '@/server/repositories/comments.repository' +import { checkSpamContent } from '@/server/utils/spam-check' +import { type PrismaClient } from '@orm/client' + +interface CreateListingCommentInput { + listingId: string + content: string + userId: string + parentId?: string | null + humanVerificationToken?: string + headers?: Headers +} + +export class ListingCommentService { + private readonly comments: CommentsRepository + + constructor(private readonly prisma: PrismaClient) { + this.comments = new CommentsRepository(prisma) + } + + async create(input: CreateListingCommentInput): Promise { + if (!(await this.comments.listingExists(input.listingId))) { + return ResourceError.listing.notFound() + } + + if ( + input.parentId && + !(await this.comments.commentBelongsToListing(input.parentId, input.listingId)) + ) { + return ResourceError.comment.parentNotFound() + } + + if (!(await this.comments.userExists(input.userId))) { + return ResourceError.user.notInDatabase(input.userId) + } + + await checkSpamContent({ + prisma: this.prisma, + userId: input.userId, + content: input.content, + entityType: 'comment', + challengeMode: 'challenge', + humanVerificationToken: input.humanVerificationToken, + headers: input.headers, + }) + + const comment = await this.comments.createForListing({ + content: input.content, + userId: input.userId, + listingId: input.listingId, + parentId: input.parentId ?? undefined, + }) + + this.emitCreatedNotification(comment.id, input) + this.trackCreatedComment(comment.id, input) + void this.trackFirstComment(input.userId).catch((error: unknown) => { + logger.error('[ListingCommentService] Failed to track first comment analytics', error, { + userId: input.userId, + commentId: comment.id, + }) + }) + + return comment + } + + private emitCreatedNotification(commentId: string, input: CreateListingCommentInput): void { + notificationEventEmitter.emitNotificationEvent({ + eventType: input.parentId + ? NOTIFICATION_EVENTS.COMMENT_REPLIED + : NOTIFICATION_EVENTS.LISTING_COMMENTED, + entityType: 'listing', + entityId: input.listingId, + triggeredBy: input.userId, + payload: { + listingId: input.listingId, + commentId, + parentId: input.parentId ?? undefined, + commentText: input.content, + }, + }) + } + + private trackCreatedComment(commentId: string, input: CreateListingCommentInput): void { + analytics.engagement.comment({ + action: input.parentId ? 'reply' : 'created', + commentId, + listingId: input.listingId, + isReply: Boolean(input.parentId), + contentLength: input.content.length, + }) + } + + private async trackFirstComment(userId: string): Promise { + const userCommentCount = await this.comments.countByUser(userId) + + if (userCommentCount === 1) { + analytics.userJourney.firstTimeAction({ userId, action: 'first_comment' }) + } + } +} 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/services/report-submission.service.ts b/src/server/services/report-submission.service.ts new file mode 100644 index 000000000..f8b735181 --- /dev/null +++ b/src/server/services/report-submission.service.ts @@ -0,0 +1,130 @@ +import { ResourceError } from '@/lib/errors' +import { emitReportCreatedNotification } from '@/server/notifications/reportEvents' +import { sanitizeInput } from '@/server/utils/security-validation' +import { type PrismaClient, type ReportReason } from '@orm/client' + +type CreateListingReportInput = { + listingId: string + reason: ReportReason + description?: string + reportedById: string +} + +type CreatePcListingReportInput = { + pcListingId: string + reason: ReportReason + description?: string + reportedById: string +} + +function sanitizeOptionalDescription(description: string | undefined): string | undefined { + return description ? sanitizeInput(description) : description +} + +export class ReportSubmissionService { + constructor(private readonly prisma: PrismaClient) {} + + async createListingReport(input: CreateListingReportInput) { + const sanitizedDescription = sanitizeOptionalDescription(input.description) + + const listing = await this.prisma.listing.findUnique({ + where: { id: input.listingId }, + select: { authorId: true }, + }) + + if (!listing) return ResourceError.listing.notFound() + + if (listing.authorId === input.reportedById) { + return ResourceError.listingReport.cannotReportOwnListing() + } + + const existingReport = await this.prisma.listingReport.findUnique({ + where: { + listingId_reportedById: { + listingId: input.listingId, + reportedById: input.reportedById, + }, + }, + }) + + if (existingReport) return ResourceError.listingReport.alreadyExists() + + const report = await this.prisma.listingReport.create({ + data: { + listingId: input.listingId, + reportedById: input.reportedById, + reason: input.reason, + description: sanitizedDescription, + }, + include: { + listing: { + include: { + game: { select: { title: true } }, + author: { select: { name: true } }, + }, + }, + }, + }) + + emitReportCreatedNotification({ + type: 'listing', + reportId: report.id, + listingId: input.listingId, + reportedById: input.reportedById, + }) + + return report + } + + async createPcListingReport(input: CreatePcListingReportInput) { + const sanitizedDescription = sanitizeOptionalDescription(input.description) + + const pcListing = await this.prisma.pcListing.findUnique({ + where: { id: input.pcListingId }, + select: { authorId: true }, + }) + + if (!pcListing) return ResourceError.pcListing.notFound() + + if (pcListing.authorId === input.reportedById) { + return ResourceError.pcListingReport.cannotReportOwnListing() + } + + const existingReport = await this.prisma.pcListingReport.findUnique({ + where: { + pcListingId_reportedById: { + pcListingId: input.pcListingId, + reportedById: input.reportedById, + }, + }, + }) + + if (existingReport) return ResourceError.pcListingReport.alreadyExists() + + const report = await this.prisma.pcListingReport.create({ + data: { + pcListingId: input.pcListingId, + reportedById: input.reportedById, + reason: input.reason, + description: sanitizedDescription, + }, + include: { + pcListing: { + include: { + game: { select: { title: true } }, + author: { select: { name: true } }, + }, + }, + }, + }) + + emitReportCreatedNotification({ + type: 'pcListing', + reportId: report.id, + pcListingId: input.pcListingId, + reportedById: input.reportedById, + }) + + return report + } +} diff --git a/src/server/services/steam-batch-lookup.service.test.ts b/src/server/services/steam-batch-lookup.service.test.ts new file mode 100644 index 000000000..bf846a155 --- /dev/null +++ b/src/server/services/steam-batch-lookup.service.test.ts @@ -0,0 +1,196 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { PrismaClient } from '@orm/client' + +const validateSteamAppIdsMock = vi.hoisted(() => vi.fn()) +const matchSteamAppIdsToNamesMock = vi.hoisted(() => vi.fn()) +const batchBySteamAppIdsMock = vi.hoisted(() => vi.fn()) + +const steamBatchQueryCacheMock = vi.hoisted(() => ({ + get: vi.fn(), + set: vi.fn(), +})) + +const gamesRepositoryMock = vi.hoisted(() => + vi.fn().mockImplementation(function MockGamesRepository() { + return { + batchBySteamAppIds: batchBySteamAppIdsMock, + } + }), +) + +vi.mock('@/server/utils/cache', () => ({ + steamBatchQueryCache: steamBatchQueryCacheMock, +})) + +vi.mock('@/server/utils/steamGameBatcher', () => ({ + validateSteamAppIds: validateSteamAppIdsMock, + matchSteamAppIdsToNames: matchSteamAppIdsToNamesMock, +})) + +vi.mock('@/server/repositories/games.repository', () => ({ + GamesRepository: gamesRepositoryMock, +})) + +const { lookupGamesBySteamAppIds } = await import('./steam-batch-lookup.service') + +const prisma = {} as PrismaClient + +const gameWithListing = { + id: 'game-1', + title: 'Half-Life 2', + normalizedTitle: 'half life 2', + systemId: 'system-1', + imageUrl: null, + boxartUrl: null, + bannerUrl: null, + tgdbGameId: null, + metadata: { steamAppId: '220' }, + isErotic: false, + ageRating: null, + status: 'APPROVED', + createdAt: new Date('2026-01-01T00:00:00.000Z'), + system: { + id: 'system-1', + name: 'PC', + key: 'pc', + }, + _count: { + listings: 1, + }, + listings: [ + { + id: 'listing-1', + deviceId: 'device-1', + gameId: 'game-1', + emulatorId: 'emulator-1', + performanceId: 1, + notes: 'Runs well', + upvoteCount: 4, + downvoteCount: 1, + voteCount: 5, + successRate: 0.8, + device: { + id: 'device-1', + modelName: 'Pocket', + soc: null, + }, + emulator: { + id: 'emulator-1', + name: 'GameHub', + logo: null, + }, + performance: { + id: 1, + label: 'Perfect', + rank: 1, + description: null, + }, + customFieldValues: [], + }, + ], +} + +describe('lookupGamesBySteamAppIds', () => { + beforeEach(() => { + vi.clearAllMocks() + validateSteamAppIdsMock.mockReturnValue({ valid: true, errors: [] }) + steamBatchQueryCacheMock.get.mockReturnValue(undefined) + }) + + it('rejects invalid Steam App IDs before cache, Steam metadata, or repository work', async () => { + validateSteamAppIdsMock.mockReturnValue({ + valid: false, + errors: ['Invalid Steam App ID format: abc'], + }) + + await expect( + lookupGamesBySteamAppIds( + { + steamAppIds: ['abc'], + maxListingsPerGame: 1, + }, + { prisma }, + ), + ).rejects.toMatchObject({ + code: 'BAD_REQUEST', + message: 'Invalid Steam App ID format: abc', + }) + + expect(steamBatchQueryCacheMock.get).not.toHaveBeenCalled() + expect(matchSteamAppIdsToNamesMock).not.toHaveBeenCalled() + expect(gamesRepositoryMock).not.toHaveBeenCalled() + }) + + it('preserves unresolved Steam App IDs as not found results', async () => { + matchSteamAppIdsToNamesMock.mockResolvedValue([ + { steamAppId: '999', gameName: null, matchStrategy: 'not_found' }, + { steamAppId: '220', gameName: 'Half-Life 2', matchStrategy: 'exact' }, + ]) + batchBySteamAppIdsMock.mockResolvedValue([ + { + steamAppId: '220', + game: gameWithListing, + matchStrategy: 'exact', + }, + ]) + + const response = await lookupGamesBySteamAppIds( + { + steamAppIds: ['999', '220'], + emulatorName: 'GameHub', + maxListingsPerGame: 1, + minimal: true, + }, + { prisma, showNsfw: false }, + ) + + expect(response).toMatchObject({ + success: true, + totalRequested: 2, + totalFound: 1, + totalNotFound: 1, + }) + expect(response.results).toEqual([ + { + game_id: null, + steam_app_id: '999', + title: null, + performance: null, + emulator: null, + device: null, + listing: null, + }, + { + game_id: 'game-1', + steam_app_id: '220', + title: 'Half-Life 2', + performance: gameWithListing.listings[0].performance, + emulator: gameWithListing.listings[0].emulator, + device: gameWithListing.listings[0].device, + listing: { + id: 'listing-1', + notes: 'Runs well', + upvoteCount: 4, + downvoteCount: 1, + voteCount: 5, + successRate: 0.8, + }, + }, + ]) + + const repositoryInput = batchBySteamAppIdsMock.mock.calls[0]?.[0] + expect(repositoryInput).toBeInstanceOf(Map) + if (!(repositoryInput instanceof Map)) throw new Error('Expected repository input to be a Map') + + expect(Array.from(repositoryInput.entries())).toEqual([['220', 'Half-Life 2']]) + expect(batchBySteamAppIdsMock).toHaveBeenCalledWith(repositoryInput, { + emulatorName: 'GameHub', + maxListingsPerGame: 1, + showNsfw: false, + }) + expect(steamBatchQueryCacheMock.set).toHaveBeenCalledWith( + 'batch:999,220:GameHub:1:false:true', + response, + ) + }) +}) diff --git a/src/server/services/steam-batch-lookup.service.ts b/src/server/services/steam-batch-lookup.service.ts new file mode 100644 index 000000000..f51d11552 --- /dev/null +++ b/src/server/services/steam-batch-lookup.service.ts @@ -0,0 +1,118 @@ +import { TRPCError } from '@trpc/server' +import { AppError } from '@/lib/errors' +import { logger } from '@/lib/logger' +import { GamesRepository } from '@/server/repositories/games.repository' +import { steamBatchQueryCache } from '@/server/utils/cache' +import { matchSteamAppIdsToNames, validateSteamAppIds } from '@/server/utils/steamGameBatcher' +import type { BatchBySteamAppIdsResponse } from '@/schemas/mobile' +import type { PrismaClient } from '@orm/client' + +interface LookupGamesBySteamAppIdsInput { + steamAppIds: string[] + emulatorName?: string + maxListingsPerGame: number + showNsfw?: boolean + minimal?: boolean +} + +interface LookupGamesBySteamAppIdsContext { + prisma: PrismaClient + showNsfw?: boolean +} + +export async function lookupGamesBySteamAppIds( + input: LookupGamesBySteamAppIdsInput, + ctx: LookupGamesBySteamAppIdsContext, +): Promise { + const validation = validateSteamAppIds(input.steamAppIds) + if (!validation.valid) AppError.badRequest(validation.errors.join(', ')) + + try { + const showNsfw = input.showNsfw ?? ctx.showNsfw ?? false + const minimal = input.minimal ?? false + const requestedIds = input.steamAppIds.join(',') + const cacheKey = `batch:${requestedIds}:${input.emulatorName ?? 'all'}:${input.maxListingsPerGame}:${showNsfw}:${minimal}` + + const cachedResult = steamBatchQueryCache.get(cacheKey) + if (cachedResult) return cachedResult + + const matchResults = await matchSteamAppIdsToNames(input.steamAppIds) + const steamAppIdToName = new Map() + for (const match of matchResults) { + if (match.gameName) steamAppIdToName.set(match.steamAppId, match.gameName) + } + + const repositoryResults = + steamAppIdToName.size > 0 + ? await new GamesRepository(ctx.prisma).batchBySteamAppIds(steamAppIdToName, { + emulatorName: input.emulatorName, + maxListingsPerGame: input.maxListingsPerGame, + showNsfw, + }) + : [] + + const resultsBySteamAppId = new Map( + repositoryResults.map((result) => [result.steamAppId, result]), + ) + const orderedResults = input.steamAppIds.map((steamAppId) => { + const result = resultsBySteamAppId.get(steamAppId) + if (result) return result + + return { + steamAppId, + game: null, + matchStrategy: 'not_found' as const, + } + }) + + const finalResults = minimal + ? orderedResults.map((result) => { + if (!result.game || result.game.listings.length === 0) { + return { + game_id: result.game?.id ?? null, + steam_app_id: result.steamAppId, + title: result.game?.title ?? null, + performance: null, + emulator: null, + device: null, + listing: null, + } + } + + const firstListing = result.game.listings[0] + return { + game_id: result.game.id, + steam_app_id: result.steamAppId, + title: result.game.title, + performance: firstListing?.performance ?? null, + emulator: firstListing?.emulator ?? null, + device: firstListing?.device ?? null, + listing: { + id: firstListing?.id ?? null, + notes: firstListing?.notes ?? null, + upvoteCount: firstListing?.upvoteCount ?? 0, + downvoteCount: firstListing?.downvoteCount ?? 0, + voteCount: firstListing?.voteCount ?? 0, + successRate: firstListing?.successRate ?? null, + }, + } + }) + : orderedResults + + const response = { + success: true as const, + results: finalResults, + totalRequested: input.steamAppIds.length, + totalFound: orderedResults.filter((result) => result.game !== null).length, + totalNotFound: orderedResults.filter((result) => result.game === null).length, + } + + steamBatchQueryCache.set(cacheKey, response) + return response + } catch (error) { + if (error instanceof TRPCError) throw error + + logger.error('Error in batch Steam App ID lookup', error) + return AppError.internalError('Failed to lookup games by Steam App IDs') + } +} diff --git a/src/server/services/user-profile.service.test.ts b/src/server/services/user-profile.service.test.ts index f33e45527..27f90b09d 100644 --- a/src/server/services/user-profile.service.test.ts +++ b/src/server/services/user-profile.service.test.ts @@ -1,6 +1,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { Role, type PrismaClient } from '@orm/client' -import { checkProfileAccess, PRIVATE_PROFILE_SETTINGS } from './user-profile.service' +import { + checkProfileAccess, + PRIVATE_PROFILE_SETTINGS, + PROFILE_ACCESS_REASONS, +} from './user-profile.service' function createMockPrisma() { return { @@ -36,7 +40,7 @@ describe('user-profile.service', () => { const result = await checkProfileAccess(prisma, 'missing-id', {}) - expect(result).toEqual({ accessible: false, reason: 'not_found' }) + expect(result).toEqual({ accessible: false, reason: PROFILE_ACCESS_REASONS.NOT_FOUND }) }) it('should return banned when user has active ban and viewer is not mod', async () => { @@ -50,7 +54,7 @@ describe('user-profile.service', () => { currentUserRole: Role.USER, }) - expect(result).toEqual({ accessible: false, reason: 'banned' }) + expect(result).toEqual({ accessible: false, reason: PROFILE_ACCESS_REASONS.BANNED }) }) it('should return accessible with isBanned when user has active ban but viewer is MODERATOR', async () => { @@ -83,7 +87,7 @@ describe('user-profile.service', () => { currentUserRole: Role.USER, }) - expect(result).toEqual({ accessible: false, reason: 'private' }) + expect(result).toEqual({ accessible: false, reason: PROFILE_ACCESS_REASONS.PRIVATE }) }) it('should return accessible when profile is private but viewer is the owner', async () => { diff --git a/src/server/services/user-profile.service.ts b/src/server/services/user-profile.service.ts index d3267c8a5..1bda2581e 100644 --- a/src/server/services/user-profile.service.ts +++ b/src/server/services/user-profile.service.ts @@ -17,6 +17,15 @@ interface PrivacySettings { followingVisible: boolean } +export const PROFILE_ACCESS_REASONS = { + NOT_FOUND: 'not_found', + BANNED: 'banned', + PRIVATE: 'private', +} as const + +export type ProfileAccessReason = + (typeof PROFILE_ACCESS_REASONS)[keyof typeof PROFILE_ACCESS_REASONS] + interface AccessibleProfile { accessible: true isBanned: boolean @@ -29,7 +38,7 @@ interface AccessibleProfile { interface InaccessibleProfile { accessible: false - reason: 'not_found' | 'banned' | 'private' // TODO: use constants or enums + reason: ProfileAccessReason } export type ProfileAccessResult = AccessibleProfile | InaccessibleProfile @@ -77,7 +86,7 @@ export async function checkProfileAccess( }, }) - if (!user) return { accessible: false, reason: 'not_found' } + if (!user) return { accessible: false, reason: PROFILE_ACCESS_REASONS.NOT_FOUND } const isBanned = user.userBans.length > 0 const canViewBannedUsers = roleIncludesRole(ctx.currentUserRole, Role.MODERATOR) @@ -85,7 +94,7 @@ export async function checkProfileAccess( const isMod = canViewBannedUsers if (isBanned && !canViewBannedUsers) { - return { accessible: false, reason: 'banned' } + return { accessible: false, reason: PROFILE_ACCESS_REASONS.BANNED } } const privacySettings: PrivacySettings = { @@ -98,7 +107,7 @@ export async function checkProfileAccess( } if (!privacySettings.profilePublic && !isOwner && !isMod) { - return { accessible: false, reason: 'private' } + return { accessible: false, reason: PROFILE_ACCESS_REASONS.PRIVATE } } return { diff --git a/src/server/tgdb.ts b/src/server/tgdb.ts index c7262eb11..2986c3e58 100644 --- a/src/server/tgdb.ts +++ b/src/server/tgdb.ts @@ -1,5 +1,5 @@ import axios, { type AxiosResponse } from 'axios' -import { PLATFORM_MAPPINGS, type PlatformKey } from '@/data/constants' +import { CACHE_DURATIONS, PLATFORM_MAPPINGS, type PlatformKey } from '@/data/constants' import { isValidImageUrl } from '@/lib/tgdb-utils' import { tgdbGamesCache, @@ -163,7 +163,7 @@ export async function getPlatforms(): Promise { const response = await makeRequest('/v1/Platforms') - tgdbPlatformsCache.set(cacheKey, response, { ttl: 60 * 60 * 1000 }) + tgdbPlatformsCache.set(cacheKey, response, { ttl: CACHE_DURATIONS.EXTRA_LONG }) return response } diff --git a/src/server/utils/cache/instances.ts b/src/server/utils/cache/instances.ts index fa3fed181..16bf21781 100644 --- a/src/server/utils/cache/instances.ts +++ b/src/server/utils/cache/instances.ts @@ -1,7 +1,6 @@ import { LRUCache } from 'lru-cache' -import { TIME_CONSTANTS } from '@/utils/time' -import type { DeviceCompatibilityResponse } from '@/schemas/mobile' -import type { BatchBySteamAppIdsResponse } from '@/server/api/routers/mobile/games' +import { CACHE_DURATIONS } from '@/data/constants' +import type { BatchBySteamAppIdsResponse, DeviceCompatibilityResponse } from '@/schemas/mobile' import type { NotificationMetrics, ChannelMetrics, @@ -27,7 +26,7 @@ export const gameStatsCache = new LRUCache< total: number } >({ - ttl: TIME_CONSTANTS.FIVE_MINUTES, + ttl: CACHE_DURATIONS.MEDIUM, max: 100, }) @@ -40,7 +39,7 @@ export const listingStatsCache = new LRUCache< total: number } >({ - ttl: TIME_CONSTANTS.FIVE_MINUTES, + ttl: CACHE_DURATIONS.MEDIUM, max: 100, }) @@ -58,22 +57,22 @@ export const notificationAnalyticsCache = new LRUCache< clickRate: number }[] >({ - ttl: TIME_CONSTANTS.TEN_MINUTES, + ttl: CACHE_DURATIONS.LONG, max: 200, }) export const tgdbGamesCache = new LRUCache({ - ttl: TIME_CONSTANTS.TEN_MINUTES, + ttl: CACHE_DURATIONS.LONG, max: 200, }) export const tgdbImagesCache = new LRUCache({ - ttl: TIME_CONSTANTS.TEN_MINUTES, + ttl: CACHE_DURATIONS.LONG, max: 200, }) export const tgdbPlatformsCache = new LRUCache({ - ttl: TIME_CONSTANTS.TEN_MINUTES, + ttl: CACHE_DURATIONS.EXTRA_LONG, max: 10, }) @@ -84,27 +83,27 @@ export const tgdbImageUrlsCache = new LRUCache< bannerUrl?: string } >({ - ttl: TIME_CONSTANTS.TEN_MINUTES, + ttl: CACHE_DURATIONS.LONG, max: 500, }) export const tgdbGameImagesCache = new LRUCache>({ - ttl: TIME_CONSTANTS.TEN_MINUTES, + ttl: CACHE_DURATIONS.LONG, max: 100, }) export const driverVersionsCache = new LRUCache({ - ttl: TIME_CONSTANTS.THIRTY_MINUTES, + ttl: CACHE_DURATIONS.EXTRA_LONG, max: 1, }) export const steamBatchQueryCache = new LRUCache({ - ttl: TIME_CONSTANTS.TEN_MINUTES, + ttl: CACHE_DURATIONS.LONG, max: 100, }) export const catalogCompatibilityCache = new LRUCache({ - ttl: TIME_CONSTANTS.TEN_MINUTES, + ttl: CACHE_DURATIONS.LONG, max: 500, }) diff --git a/src/server/utils/driver-versions.ts b/src/server/utils/driver-versions.ts index 4b2abb80d..08cd59029 100644 --- a/src/server/utils/driver-versions.ts +++ b/src/server/utils/driver-versions.ts @@ -1,7 +1,7 @@ import axios, { type AxiosError } from 'axios' +import { CACHE_DURATIONS } from '@/data/constants' import { logger } from '@/lib/logger' import { driverVersionsCache } from '@/server/utils/cache/instances' -import { ms } from '@/utils/time' import type { DriverAsset, DriverRelease, DriverVersionsResponse } from '@/types/driver-versions' interface Repo { @@ -144,7 +144,7 @@ export async function getDriverVersions(): Promise { releases, rateLimited: false, } - driverVersionsCache.set(CACHE_KEY, payload, { ttl: ms.minutes(30) }) + driverVersionsCache.set(CACHE_KEY, payload, { ttl: CACHE_DURATIONS.EXTRA_LONG }) return payload } catch (error) { if (isRateLimitError(error)) { @@ -154,7 +154,7 @@ export async function getDriverVersions(): Promise { rateLimited: true, errorMessage: 'GitHub rate limit exceeded. Try again in a few minutes.', } - driverVersionsCache.set(CACHE_KEY, payload, { ttl: ms.minutes(5) }) + driverVersionsCache.set(CACHE_KEY, payload, { ttl: CACHE_DURATIONS.MEDIUM }) return payload } @@ -164,7 +164,7 @@ export async function getDriverVersions(): Promise { rateLimited: false, errorMessage: 'Failed to fetch driver versions. Please try again later.', } - driverVersionsCache.set(CACHE_KEY, payload, { ttl: ms.minutes(2) }) + driverVersionsCache.set(CACHE_KEY, payload, { ttl: CACHE_DURATIONS.MEDIUM }) return payload } } diff --git a/src/server/utils/emulator-config/eden/eden.defaults.ts b/src/server/utils/emulator-config/eden/eden.defaults.ts index 76e530ac6..5e0118b7e 100644 --- a/src/server/utils/emulator-config/eden/eden.defaults.ts +++ b/src/server/utils/emulator-config/eden/eden.defaults.ts @@ -165,8 +165,6 @@ export const AUDIO_OUTPUT_ENGINE_MAPPING: Record = { Null: 3, } -// Resolution multiplier mapping for Eden resolution setup -// TODO: update with new mappings from Eden, we added 0.25x export const RESOLUTION_MULTIPLIER_MAPPING: Record = { '0.25': 0, '0.25x': 0, 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/utils/steamGameBatcher.ts b/src/server/utils/steamGameBatcher.ts index 0b64197bc..32c3c5658 100644 --- a/src/server/utils/steamGameBatcher.ts +++ b/src/server/utils/steamGameBatcher.ts @@ -1,5 +1,5 @@ import { LRUCache } from 'lru-cache' -import { ms } from '@/utils/time' +import { CACHE_DURATIONS } from '@/data/constants' import { getSteamGamesData } from './steamGameSearch' const MAX_STEAM_APP_ID = 10000000 @@ -17,7 +17,7 @@ interface GameMatchResult { } const steamAppNameCache = new LRUCache({ - ttl: ms.hours(1), + ttl: CACHE_DURATIONS.EXTRA_LONG, max: 10000, }) diff --git a/src/server/utils/steamGameSearch.ts b/src/server/utils/steamGameSearch.ts index b4ce70a1b..ae457caa1 100644 --- a/src/server/utils/steamGameSearch.ts +++ b/src/server/utils/steamGameSearch.ts @@ -1,6 +1,6 @@ import Fuse from 'fuse.js' import { LRUCache } from 'lru-cache' -import { ms } from '@/utils/time' +import { CACHE_DURATIONS } from '@/data/constants' interface SteamAppEntry { appid: number @@ -28,12 +28,12 @@ interface CachedData { } const steamGamesDataCache = new LRUCache>({ - ttl: ms.days(1), + ttl: CACHE_DURATIONS.STATIC, max: 1, }) const steamGamesFuseCache = new LRUCache>({ - ttl: ms.days(1), + ttl: CACHE_DURATIONS.STATIC, max: 1, }) diff --git a/src/server/utils/switchGameSearch.ts b/src/server/utils/switchGameSearch.ts index 06915ee19..fa260a6ab 100644 --- a/src/server/utils/switchGameSearch.ts +++ b/src/server/utils/switchGameSearch.ts @@ -1,6 +1,6 @@ import Fuse from 'fuse.js' import { LRUCache } from 'lru-cache' -import { ms } from '@/utils/time' +import { CACHE_DURATIONS } from '@/data/constants' interface SwitchGameEntry { program_id: string @@ -21,12 +21,12 @@ interface CachedData { } const switchGamesDataCache = new LRUCache>({ - ttl: ms.days(1), + ttl: CACHE_DURATIONS.STATIC, max: 1, }) const switchGamesFuseCache = new LRUCache>({ - ttl: ms.days(1), + ttl: CACHE_DURATIONS.STATIC, max: 1, }) diff --git a/src/server/utils/threeDsGameSearch.ts b/src/server/utils/threeDsGameSearch.ts index c4c9f1464..7f8e110a6 100644 --- a/src/server/utils/threeDsGameSearch.ts +++ b/src/server/utils/threeDsGameSearch.ts @@ -1,6 +1,6 @@ import Fuse from 'fuse.js' import { LRUCache } from 'lru-cache' -import { ms } from '@/utils/time' +import { CACHE_DURATIONS } from '@/data/constants' import type { IFuseOptions } from 'fuse.js' interface RawThreeDsTitleEntry { @@ -79,12 +79,12 @@ const THREEDS_TITLES_URL = 'https://dantheman827.github.io/nus-info/titles.json' const THREEDS_TITLE_NAMES_URL = 'https://dantheman827.github.io/nus-info/title-names.json' const threeDsGamesDataCache = new LRUCache>({ - ttl: ms.days(1), + ttl: CACHE_DURATIONS.STATIC, max: 1, }) const threeDsGamesFuseCache = new LRUCache>({ - ttl: ms.days(1), + ttl: CACHE_DURATIONS.STATIC, max: 1, }) 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/types/static-images.d.ts b/src/types/static-images.d.ts new file mode 100644 index 000000000..323cd166d --- /dev/null +++ b/src/types/static-images.d.ts @@ -0,0 +1,6 @@ +declare module '*.png' { + import type { StaticImageData } from 'next/image' + + const image: StaticImageData + export default image +} diff --git a/src/utils/badge-colors.ts b/src/utils/badge-colors.ts index a4f8883be..952b27cf3 100644 --- a/src/utils/badge-colors.ts +++ b/src/utils/badge-colors.ts @@ -72,3 +72,17 @@ export function getPermissionCategoryBadgeVariant( ): BadgeVariant { return permissionCategoryVariantMap[permissionCategory] || 'default' } + +export function getSuccessRateBarColor(rate: number): string { + if (rate >= 95) return 'bg-green-600' + if (rate >= 85) return 'bg-green-500' + if (rate >= 75) return 'bg-green-400' + if (rate >= 65) return 'bg-lime-500' + if (rate >= 55) return 'bg-yellow-400' + if (rate >= 45) return 'bg-yellow-500' + if (rate >= 35) return 'bg-orange-400' + if (rate >= 25) return 'bg-orange-500' + if (rate >= 15) return 'bg-red-400' + if (rate >= 5) return 'bg-red-500' + return 'bg-red-600' +} diff --git a/src/utils/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/src/utils/translation.test.ts b/src/utils/translation.test.ts index d099cf6d6..177b1916a 100644 --- a/src/utils/translation.test.ts +++ b/src/utils/translation.test.ts @@ -1,6 +1,6 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import http from '@/rest/http' -import { detectLanguage, shouldShowTranslation, getUserLocale, translateText } from './translation' +import { detectLanguage, shouldShowTranslation, translateText } from './translation' vi.mock('@/rest/http', () => ({ default: { @@ -8,185 +8,79 @@ vi.mock('@/rest/http', () => ({ }, })) -// Mock the getUserLocale function -vi.mock('./translation', async () => { - const actual = await vi.importActual('./translation') - return { - ...actual, - getUserLocale: vi.fn(), - } -}) - -// Mock fetch for translation tests -const mockFetch = vi.fn() -global.fetch = mockFetch - -// Mock navigator for language detection -Object.defineProperty(window, 'navigator', { - writable: true, - value: { language: 'en-US' }, -}) +function setNavigatorLanguage(language: string) { + Object.defineProperty(window, 'navigator', { + configurable: true, + writable: true, + value: { language }, + }) +} describe('translation utilities', () => { beforeEach(() => { vi.clearAllMocks() - vi.mocked(getUserLocale).mockReturnValue('en') + setNavigatorLanguage('en-US') }) describe('detectLanguage', () => { - it('should detect English text correctly', () => { - const englishText = 'This is a test message in English that is long enough.' - const result = detectLanguage(englishText) + it('detects English text', async () => { + const result = await detectLanguage('This is a test message in English that is long enough.') expect(result.isEnglish).toBe(true) expect(result.detectedLanguage).toBe('en') expect(result.confidence).toBeGreaterThanOrEqual(0.8) - expect(result.confidence).toBeLessThanOrEqual(1) - }) - - it('should detect Portuguese text', () => { - const portugueseText = - 'Ele é perfeito mas não consigo jogar porque quando abro o arquivo do jogo ele vai mas n entra no jogo porque n tenho conta steam pra jogar normalmente o jogo poroso eu intalo o arquivo do game mas não abre' - const result = detectLanguage(portugueseText) - expect(result.isEnglish).toBe(false) - expect(['gl', 'pt']).toContain(result.detectedLanguage) - expect(typeof result.confidence).toBe('number') }) - it('should detect non-English text', () => { - const spanishText = 'Esto es un mensaje de prueba en español que es suficientemente largo.' - const result = detectLanguage(spanishText) + it('detects Portuguese-like text', async () => { + const result = await detectLanguage( + 'Ele e perfeito mas nao consigo jogar porque quando abro o arquivo do jogo ele vai mas n entra no jogo porque n tenho conta steam pra jogar normalmente o jogo poroso eu intalo o arquivo do game mas nao abre', + ) expect(result.isEnglish).toBe(false) - expect(result.detectedLanguage).not.toBe('en') - expect(typeof result.confidence).toBe('number') - }) - - it('should handle short text', () => { - const shortText = 'Hi' - const result = detectLanguage(shortText) - - expect(result.isEnglish).toBe(true) - expect(result.detectedLanguage).toBe('en') - expect(result.confidence).toBe(0) // Short text always gets 0 confidence - }) - - it('should handle detection errors gracefully', () => { - // Test with text that might cause detection to fail - const result = detectLanguage('12345 !@#$% ^^^^') - expect(result.isEnglish).toBe(true) // 'und' detection falls back to English - expect(result.detectedLanguage).toBe('en') - expect(typeof result.confidence).toBe('number') - expect(result.confidence).toBeLessThanOrEqual(0.3) // Low confidence for undetermined text + expect(['gl', 'pt']).toContain(result.detectedLanguage) }) - it('should ignore standalone URLs when detecting language', () => { - const text = - 'С модом на HD текстуры идет отлично\nhttps://github.com/Lin-zl522/Patapon-3-HD-Texture-Pack' - const result = detectLanguage(text) + it('ignores standalone URLs when detecting language', async () => { + const result = await detectLanguage( + 'С модом на HD текстуры идет отлично\nhttps://github.com/Lin-zl522/Patapon-3-HD-Texture-Pack', + ) expect(result.isEnglish).toBe(false) expect(result.detectedLanguage).toBe('ru') - expect(result.confidence).toBeGreaterThan(0.8) }) }) describe('shouldShowTranslation', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('should not show translation for short text', () => { - expect(shouldShowTranslation('Hi')).toBe(false) - expect(shouldShowTranslation('')).toBe(false) - expect(shouldShowTranslation('Test')).toBe(false) + it('does not show translation for short text', async () => { + await expect(shouldShowTranslation('Hi')).resolves.toBe(false) + await expect(shouldShowTranslation('')).resolves.toBe(false) + await expect(shouldShowTranslation('Test')).resolves.toBe(false) }) - it('should not show translation for English text', () => { - const englishText = 'This is a test message in English that is long enough.' - expect(shouldShowTranslation(englishText)).toBe(false) + it('does not show translation for English text', async () => { + await expect( + shouldShowTranslation('This is a test message in English that is long enough.'), + ).resolves.toBe(false) }) - it('should make a decision about showing translation based on text length and detection', () => { - vi.mocked(getUserLocale).mockReturnValue('en') - const spanishText = 'Esto es un mensaje de prueba en español que es suficientemente largo.' - const result = shouldShowTranslation(spanishText) - expect(typeof result).toBe('boolean') + it('shows translation for non-English text when user locale is English', async () => { + await expect( + shouldShowTranslation('Baguette, croissant et ce genre de choses'), + ).resolves.toBe(true) }) - it('should not show translation for non-English text when user locale is not English', () => { - vi.mocked(getUserLocale).mockReturnValue('es') - const spanishText = 'Esto es un mensaje de prueba en español que es suficientemente largo.' - const result = shouldShowTranslation(spanishText) - // Language detection might not be perfect, so we check the logic more carefully - // If the detected language matches user locale, it should return false - // If it doesn't match, it should return true (which is also valid behavior) - expect(typeof result).toBe('boolean') - }) - - it('should show translation for french text when user locale is English', () => { - vi.mocked(getUserLocale).mockReturnValue('en') - const frenchText = 'Baguette, croissant et ce genre de choses' - const result = shouldShowTranslation(frenchText) - expect(result).toBe(true) - }) + it('does not show translation when detected language matches user locale', async () => { + setNavigatorLanguage('es-ES') - it('should show translation for non-English text when user locale is English', () => { - vi.mocked(getUserLocale).mockReturnValue('en') - const dutchText = 'Iets met oliebollen, pindakaas en frikandellen of zo.' - const result = shouldShowTranslation(dutchText) - expect(result).toBe(true) - }) - - it('should show translation for Portuguese-like text detected as Galician (glg)', () => { - // Some Portuguese texts are detected by franc as 'glg' (Galician). - // By supporting 'glg' in our map, we should offer translation for EN users. - vi.mocked(getUserLocale).mockReturnValue('en') - const text = - 'Ele é perfeito mas não consigo jogar porque quando abro o arquivo do jogo ele vai mas n entra no jogo porque n tenho conta steam pra jogar normalmente o jogo poroso eu intalo o arquivo do game mas não abre' - const should = shouldShowTranslation(text) - expect(should).toBe(true) - }) - - it('should show translation when text mixes Portuguese and English segments', () => { - vi.mocked(getUserLocale).mockReturnValue('en') - const mixedText = `Mipmapping: completo (PS2 Mips) -Filtragem Trilinear: Habilitado (PS2) -Filtragem Anisotrópica: Desativado (PS2) -Precisão da Mesclagem das texturas: Básicos (Padrão) -Pré-carregamento de texturas: Completo (Hash Cache) -Modo de Downlaodde Hardware: Desicronizado (Não-Determinístico) - -o desempenho da versão Europe (SLES-53702) foi pior que a versão USA (SLUS-21134), e a unica coisa que tive que mudar foi a resolução, mas a versão nether 2.0-4248 teve um desempenho melho(mesmo que não foi muito) que a versão 2.0-3668 - -Mipmapping: Full (PS2 Mips) -Trilinear Filtering: Enabled (PS2) -Anisotropic Filtering: Disabled (Default) -Blending Accuracy: Basic (Default) -Texture Preloading: Full (Hash Cache) -Downlaod Hardware Mode: Unsynchronized (Non-Dterministic) - -The performance of the Europe version (SLES-53702) was worse than the USA version (SLUS-21134), and the only thing I had to change was the resolution, but the nether 2.0-4248 version performed better (even if not by much) than version 2.0-3668.` - const should = shouldShowTranslation(mixedText) - expect(should).toBe(true) - }) - - it('should show translation when non-English notes include URLs', () => { - vi.mocked(getUserLocale).mockReturnValue('en') - const text = - 'С модом на HD текстуры идет отлично\nhttps://github.com/Lin-zl522/Patapon-3-HD-Texture-Pack' - const should = shouldShowTranslation(text) - expect(should).toBe(true) + await expect( + shouldShowTranslation('Esto es un mensaje de prueba en espanol que es suficientemente largo.'), + ).resolves.toBe(false) }) }) describe('translateText', () => { const mockedHttp = http as unknown as { get: ReturnType } - beforeEach(() => { - mockedHttp.get.mockReset() - }) - it('falls back to segment translations when bulk translation returns original text', async () => { const text = `Filtragem Trilinear: Habilitado (PS2)\nTrilinear Filtering: Enabled (PS2)` diff --git a/src/utils/translation.ts b/src/utils/translation.ts index 2cf33531e..fc1e8da00 100644 --- a/src/utils/translation.ts +++ b/src/utils/translation.ts @@ -1,10 +1,15 @@ -import { franc, francAll } from 'franc' import http from '@/rest/http' import type { MyMemoryTranslationResponse, LanguageDetectionResult, TranslationResult, } from '@/utils/translation.types' +import type { Options as FrancOptions, TrigramTuple } from 'franc-min' + +interface FrancMinModule { + franc: (value?: string, options?: FrancOptions) => string + francAll: (value?: string, options?: FrancOptions) => TrigramTuple[] +} // Language data: franc ISO 639-3 codes mapped to MyMemory-supported ISO 639-1 codes with names // Only includes languages that MyMemory API actually supports for translation @@ -58,6 +63,24 @@ const URL_REGEX = /(https?:\/\/|www\.)[^\s)]+/gi const MARKDOWN_LINK_REGEX = /\[([^\]]+)\]\((https?:\/\/|www\.)[^)]+\)/gi const MIN_ENGLISH_CONFIDENCE = 0.8 const NON_ENGLISH_SCORE_MARGIN = 0.05 +const FRANC_MIN_LENGTH = 10 +const MIN_ALTERNATIVE_CONFIDENCE = 0.4 + +let francMinModulePromise: Promise | null = null +const detectionCache = new Map>() + +function loadFrancMin(): Promise { + francMinModulePromise ??= import('franc-min') + .then((module) => ({ + franc: module.franc, + francAll: module.francAll, + })) + .catch((error: unknown) => { + francMinModulePromise = null + throw error + }) + return francMinModulePromise +} function sanitizeForDetection(value: string): string { const withoutMarkdownLinks = value.replace(MARKDOWN_LINK_REGEX, '$1') @@ -79,6 +102,10 @@ function isMeaningfullyDifferent(original: string, translated: string): boolean return normalizeForComparison(original) !== normalizeForComparison(translated) } +function getFallbackDetection(confidence = 0): LanguageDetectionResult { + return { isEnglish: true, detectedLanguage: 'en', confidence } +} + async function requestTranslation({ text, source, target }: TranslationRequest) { const url = `https://api.mymemory.translated.net/get?q=${encodeURIComponent(text)}&langpair=${source}|${target}` @@ -128,7 +155,7 @@ async function translateBySegments({ continue } - const detection = detectLanguage(trimmed) + const detection = await detectLanguage(trimmed) if (detection.isEnglish || detection.detectedLanguage === target) { translatedSegments.push(segment) continue @@ -165,81 +192,93 @@ export function getUserLocale(): string { /** * Detect the language of the text. * @param text - The text to detect the language of. - * @return {LanguageDetectionResult} The language detection result. + * @return A promise resolving to the language detection result. */ -const FRANC_MIN_LENGTH = 10 -const MIN_ALTERNATIVE_CONFIDENCE = 0.4 - -export function detectLanguage(text: string): LanguageDetectionResult { - const sanitized = sanitizeForDetection(text) +async function detectSanitizedLanguage(sanitized: string): Promise { + try { + const { franc, francAll } = await loadFrancMin() + const francOptions = { minLength: FRANC_MIN_LENGTH } + const francCode = franc(sanitized, francOptions) + const candidates = francAll(sanitized, francOptions) - if (sanitized.length < 10) { - return { isEnglish: true, detectedLanguage: 'en', confidence: 0 } - } + const languageData = SUPPORTED_LANGUAGES[francCode] + const iso = languageData?.code ?? 'und' + const detectedConfidence = 1 - const francOptions = { minLength: FRANC_MIN_LENGTH } - const francCode = franc(sanitized, francOptions) - const candidates = francAll(sanitized, francOptions) + if (languageData && iso !== 'en') { + return { isEnglish: false, detectedLanguage: iso, confidence: detectedConfidence } + } - const languageData = SUPPORTED_LANGUAGES[francCode] - const iso = languageData?.code ?? 'und' - const detectedConfidence = 1 + const englishCandidateScore = + languageData?.code === 'en' + ? detectedConfidence + : (() => { + const candidate = candidates.find(([code, score]) => { + const data = SUPPORTED_LANGUAGES[code] + if (!data) return false + return data.code === 'en' && score >= MIN_ENGLISH_CONFIDENCE + }) + return candidate ? candidate[1] : 0 + })() + + const bestNonEnglishCandidate = candidates.reduce<{ code: string; score: number } | null>( + (current, [code, score]) => { + const data = SUPPORTED_LANGUAGES[code] + if (!data) return current + if (data.code === 'en') return current + if (score < MIN_ALTERNATIVE_CONFIDENCE) return current + if (!current || score > current.score) { + return { code: data.code, score } + } + return current + }, + null, + ) + + if ( + bestNonEnglishCandidate && + (englishCandidateScore === 0 || + englishCandidateScore - bestNonEnglishCandidate.score <= NON_ENGLISH_SCORE_MARGIN) + ) { + return { + isEnglish: false, + detectedLanguage: bestNonEnglishCandidate.code, + confidence: Math.max(0.9, bestNonEnglishCandidate.score), + } + } - if (languageData && iso !== 'en') { - return { isEnglish: false, detectedLanguage: iso, confidence: detectedConfidence } - } + if (languageData?.code === 'en') { + return { isEnglish: true, detectedLanguage: 'en', confidence: detectedConfidence } + } - const englishCandidateScore = - languageData?.code === 'en' - ? detectedConfidence - : (() => { - const candidate = candidates.find(([code, score]) => { - const data = SUPPORTED_LANGUAGES[code] - if (!data) return false - return data.code === 'en' && score >= MIN_ENGLISH_CONFIDENCE - }) - return candidate ? candidate[1] : 0 - })() - - const bestNonEnglishCandidate = candidates.reduce<{ code: string; score: number } | null>( - (current, [code, score]) => { - const data = SUPPORTED_LANGUAGES[code] - if (!data) return current - if (data.code === 'en') return current - if (score < MIN_ALTERNATIVE_CONFIDENCE) return current - if (!current || score > current.score) { - return { code: data.code, score } - } - return current - }, - null, - ) + if (englishCandidateScore >= MIN_ENGLISH_CONFIDENCE) { + return { isEnglish: true, detectedLanguage: 'en', confidence: englishCandidateScore } + } - if ( - bestNonEnglishCandidate && - (englishCandidateScore === 0 || - englishCandidateScore - bestNonEnglishCandidate.score <= NON_ENGLISH_SCORE_MARGIN) - ) { - return { - isEnglish: false, - detectedLanguage: bestNonEnglishCandidate.code, - confidence: Math.max(0.9, bestNonEnglishCandidate.score), + if (iso === 'und') { + return getFallbackDetection(0.1) } - } - if (languageData?.code === 'en') { - return { isEnglish: true, detectedLanguage: 'en', confidence: detectedConfidence } + return getFallbackDetection(1) + } catch (error) { + console.error('Language detection failed:', error) + return getFallbackDetection() } +} - if (englishCandidateScore >= MIN_ENGLISH_CONFIDENCE) { - return { isEnglish: true, detectedLanguage: 'en', confidence: englishCandidateScore } - } +export function detectLanguage(text: string): Promise { + const sanitized = sanitizeForDetection(text) - if (iso === 'und') { - return { isEnglish: true, detectedLanguage: 'en', confidence: 0.1 } + if (sanitized.length < FRANC_MIN_LENGTH) { + return Promise.resolve(getFallbackDetection()) } - return { isEnglish: true, detectedLanguage: 'en', confidence: 1 } + const cachedDetection = detectionCache.get(sanitized) + if (cachedDetection) return cachedDetection + + const detectionPromise = detectSanitizedLanguage(sanitized) + detectionCache.set(sanitized, detectionPromise) + return detectionPromise } /** @@ -255,7 +294,7 @@ export async function translateText( toLang?: string, ): Promise { const target = toLang ?? getUserLocale() - const source = fromLang ?? detectLanguage(text).detectedLanguage + const source = fromLang ?? (await detectLanguage(text)).detectedLanguage // No need to request if languages already match if (source === target) { @@ -332,12 +371,12 @@ export async function translateTextCached( /** * Check if translation should be shown based on text length and language detection. * @param text - The text to check. - * @return {boolean} True if translation should be shown, false otherwise. + * @return A promise resolving to true if translation should be shown. */ -export function shouldShowTranslation(text: string): boolean { - if (!text || text.trim().length < 10) return false +export async function shouldShowTranslation(text: string): Promise { + if (!text || text.trim().length < FRANC_MIN_LENGTH) return false - const detection = detectLanguage(text) + const detection = await detectLanguage(text) const userLocale = getUserLocale() // Only show translation for high-confidence non-English detection diff --git a/src/utils/vote.ts b/src/utils/vote.ts index d35b08317..d90463936 100644 --- a/src/utils/vote.ts +++ b/src/utils/vote.ts @@ -1,34 +1,3 @@ -/** - * Utility functions for vote-related calculations and styling - */ - -/** - * Get the color class for the success rate bar based on the rate - * TODO: probably move this to badgeColors.ts - * @param rate - Success rate percentage (0-100) - * @returns Tailwind CSS background color class - */ -export function getBarColor(rate: number): string { - if (rate >= 95) return 'bg-green-600' // Excellent - dark green - if (rate >= 85) return 'bg-green-500' // Very good - green - if (rate >= 75) return 'bg-green-400' // Good - light green - if (rate >= 65) return 'bg-lime-500' // Above average - lime - if (rate >= 55) return 'bg-yellow-400' // Average+ - light yellow - if (rate >= 45) return 'bg-yellow-500' // Average - yellow - if (rate >= 35) return 'bg-orange-400' // Below average - light orange - if (rate >= 25) return 'bg-orange-500' // Poor - orange - if (rate >= 15) return 'bg-red-400' // Bad - light red - if (rate >= 5) return 'bg-red-500' // Very bad - red - return 'bg-red-600' // Terrible - dark red -} - -/** - * Calculate the width percentage for the success rate bar - * When rate is 0 but there are votes, show full red bar (100%) - * @param rate - Success rate percentage (0-100) - * @param voteCount - Total number of votes - * @returns Width percentage for the bar - */ export function getBarWidth(rate: number, voteCount: number): number { return rate === 0 && voteCount > 0 ? 100 : rate } 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/admin-socs.spec.ts b/tests/admin-socs.spec.ts new file mode 100644 index 000000000..ad6c38e7c --- /dev/null +++ b/tests/admin-socs.spec.ts @@ -0,0 +1,32 @@ +import { test, expect } from './fixtures' +import type { Request } from '@playwright/test' + +function serializedRequest(request: Request) { + return decodeURIComponent(`${request.url()} ${request.postData() ?? ''}`) +} + +test.describe('Admin SoCs', () => { + test.use({ storageState: 'tests/.auth/super_admin.json' }) + + test('requests paginated SoCs and renders the table', async ({ page }) => { + const socsRequestPromise = page.waitForRequest((request) => + request.url().includes('/api/trpc/socs.get'), + ) + + await page.goto('/admin/socs', { waitUntil: 'domcontentloaded' }) + await expect(page).toHaveURL(/\/admin\/socs/) + + const socsRequest = await socsRequestPromise + const requestPayload = serializedRequest(socsRequest) + expect(requestPayload).toContain('"page":1') + expect(requestPayload).toContain('"limit":20') + + await expect(page.getByRole('heading', { name: 'System on Chips (SoCs)' })).toBeVisible() + + const socsTable = page.locator('table').first() + await expect(socsTable).toBeVisible() + await expect(socsTable.locator('thead')).toContainText('SoC Name') + await expect(socsTable.locator('thead')).toContainText('Manufacturer') + await expect(socsTable.locator('tbody tr').first()).toBeVisible() + }) +}) 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..9cfbcb0fc 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() } @@ -661,10 +667,10 @@ export async function expectOwnPcReportBlocked(page: Page): Promise { export async function withContext( browser: Browser, - storageState: string, + storageState: string | undefined, fn: (page: Page) => Promise, ) { - const ctx = await browser.newContext({ storageState }) + const ctx = storageState ? await browser.newContext({ storageState }) : await browser.newContext() await registerCookieConsent(ctx) const page = await ctx.newPage() await registerExternalServiceMocks(page) diff --git a/tests/helpers/external-services.ts b/tests/helpers/external-services.ts index 3d69adcf8..b32b3d9a2 100644 --- a/tests/helpers/external-services.ts +++ b/tests/helpers/external-services.ts @@ -6,14 +6,6 @@ const transparentPng = Buffer.from( ) export async function registerExternalServiceMocks(page: Page) { - await page.route('**/_vercel/speed-insights/script.js*', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/javascript', - body: '', - }) - }) - await page.route( 'https://storage.ko-fi.com/cdn/scripts/floating-chat-wrapper.css*', async (route) => { @@ -33,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/tests/search.spec.ts b/tests/search.spec.ts index 6762fc161..e7c1941bc 100644 --- a/tests/search.spec.ts +++ b/tests/search.spec.ts @@ -1,7 +1,262 @@ +import { randomUUID } from 'node:crypto' +import { createPrismaClient } from '@/server/prisma-client' +import { ApprovalStatus, Role, type Prisma } from '@orm' import { test, expect } from './fixtures' +import { withContext } from './helpers/data-factory' import { GamesPage } from './pages/GamesPage' import { ListingsPage } from './pages/ListingsPage' +const SEARCH_LISTING_KEYS = [ + 'approvedMatch', + 'approvedControl', + 'ownerPendingMatch', + 'ownerPendingControl', + 'otherPendingMatch', +] as const + +type SearchListingKey = (typeof SEARCH_LISTING_KEYS)[number] + +type HandheldSearchFixture = { + searchTerm: string + listings: Record +} + +type SearchAccessCase = { + label: string + storageState: string | undefined + ownerEmail: string + expectedListings: readonly SearchListingKey[] +} + +const E2E_USERS = { + [Role.USER]: { + ownerEmail: 'user@emuready.com', + storageState: 'tests/.auth/user.json', + }, + [Role.AUTHOR]: { + ownerEmail: 'author@emuready.com', + storageState: 'tests/.auth/author.json', + }, + [Role.DEVELOPER]: { + ownerEmail: 'developer@emuready.com', + storageState: 'tests/.auth/developer.json', + }, + [Role.MODERATOR]: { + ownerEmail: 'moderator@emuready.com', + storageState: 'tests/.auth/moderator.json', + }, + [Role.ADMIN]: { + ownerEmail: 'admin@emuready.com', + storageState: 'tests/.auth/admin.json', + }, + [Role.SUPER_ADMIN]: { + ownerEmail: 'superadmin@emuready.com', + storageState: 'tests/.auth/super_admin.json', + }, +} satisfies Record + +const PUBLIC_RESULTS: readonly SearchListingKey[] = ['approvedMatch'] +const AUTHENTICATED_RESULTS: readonly SearchListingKey[] = ['approvedMatch', 'ownerPendingMatch'] +const MODERATOR_RESULTS: readonly SearchListingKey[] = [ + 'approvedMatch', + 'ownerPendingMatch', + 'otherPendingMatch', +] + +const SEARCH_ACCESS_CASES = [ + { + label: 'anonymous', + storageState: undefined, + ownerEmail: E2E_USERS[Role.USER].ownerEmail, + expectedListings: PUBLIC_RESULTS, + }, + { + label: Role.USER, + ...E2E_USERS[Role.USER], + expectedListings: AUTHENTICATED_RESULTS, + }, + { + label: Role.AUTHOR, + ...E2E_USERS[Role.AUTHOR], + expectedListings: AUTHENTICATED_RESULTS, + }, + { + label: Role.DEVELOPER, + ...E2E_USERS[Role.DEVELOPER], + expectedListings: AUTHENTICATED_RESULTS, + }, + { + label: Role.MODERATOR, + ...E2E_USERS[Role.MODERATOR], + expectedListings: MODERATOR_RESULTS, + }, + { + label: Role.ADMIN, + ...E2E_USERS[Role.ADMIN], + expectedListings: MODERATOR_RESULTS, + }, + { + label: Role.SUPER_ADMIN, + ...E2E_USERS[Role.SUPER_ADMIN], + expectedListings: MODERATOR_RESULTS, + }, +] satisfies readonly SearchAccessCase[] + +async function createHandheldSearchFixture(ownerEmail: string): Promise { + const prisma = createPrismaClient() + + try { + const otherAuthorEmail = + ownerEmail === E2E_USERS[Role.AUTHOR].ownerEmail + ? E2E_USERS[Role.USER].ownerEmail + : E2E_USERS[Role.AUTHOR].ownerEmail + const [owner, otherAuthor] = await Promise.all([ + prisma.user.findUnique({ where: { email: ownerEmail }, select: { id: true } }), + prisma.user.findUnique({ where: { email: otherAuthorEmail }, select: { id: true } }), + ]) + if (!owner) throw new Error(`Expected seeded listing owner: ${ownerEmail}`) + if (!otherAuthor) throw new Error(`Expected seeded listing author: ${otherAuthorEmail}`) + + const game = await prisma.game.findFirst({ + where: { status: ApprovalStatus.APPROVED, isErotic: false }, + select: { id: true, systemId: true }, + }) + if (!game) throw new Error('Expected an approved game for listing search E2E') + + const [device, emulator, performance] = await Promise.all([ + prisma.device.findFirst({ select: { id: true } }), + prisma.emulator.findFirst({ + where: { systems: { some: { id: game.systemId } } }, + select: { id: true }, + }), + prisma.performanceScale.findFirst({ select: { id: true } }), + ]) + if (!device) throw new Error('Expected a device for listing search E2E') + if (!emulator) throw new Error('Expected an emulator for listing search E2E') + if (!performance) throw new Error('Expected a performance scale for listing search E2E') + + const fixtureToken = randomUUID().replaceAll('-', '') + const searchTerm = `rolesearch${fixtureToken}` + const controlTerm = `rolecontrol${fixtureToken}` + const createListing = ( + authorId: string, + status: ApprovalStatus, + notes: string, + ): Prisma.ListingUncheckedCreateInput => ({ + authorId, + gameId: game.id, + deviceId: device.id, + emulatorId: emulator.id, + performanceId: performance.id, + status, + processedAt: status === ApprovalStatus.APPROVED ? new Date() : null, + notes, + }) + + const [ + approvedMatch, + approvedControl, + ownerPendingMatch, + ownerPendingControl, + otherPendingMatch, + ] = await prisma.$transaction([ + prisma.listing.create({ + data: createListing(otherAuthor.id, ApprovalStatus.APPROVED, searchTerm), + select: { id: true }, + }), + prisma.listing.create({ + data: createListing(otherAuthor.id, ApprovalStatus.APPROVED, controlTerm), + select: { id: true }, + }), + prisma.listing.create({ + data: createListing(owner.id, ApprovalStatus.PENDING, searchTerm), + select: { id: true }, + }), + prisma.listing.create({ + data: createListing(owner.id, ApprovalStatus.PENDING, controlTerm), + select: { id: true }, + }), + prisma.listing.create({ + data: createListing(otherAuthor.id, ApprovalStatus.PENDING, searchTerm), + select: { id: true }, + }), + ]) + + const toFixtureListing = (id: string) => ({ id, path: `/listings/${id}` }) + const listings: HandheldSearchFixture['listings'] = { + approvedMatch: toFixtureListing(approvedMatch.id), + approvedControl: toFixtureListing(approvedControl.id), + ownerPendingMatch: toFixtureListing(ownerPendingMatch.id), + ownerPendingControl: toFixtureListing(ownerPendingControl.id), + otherPendingMatch: toFixtureListing(otherPendingMatch.id), + } + + return { + searchTerm, + listings, + } + } finally { + await prisma.$disconnect() + } +} + +async function deleteHandheldSearchFixture(fixture: HandheldSearchFixture): Promise { + const prisma = createPrismaClient() + + try { + await prisma.listing.deleteMany({ + where: { + id: { + in: Object.values(fixture.listings).map((listing) => listing.id), + }, + }, + }) + } finally { + await prisma.$disconnect() + } +} + +async function withHandheldSearchFixture( + ownerEmail: string, + run: (fixture: HandheldSearchFixture) => Promise, +): Promise { + const fixture = await createHandheldSearchFixture(ownerEmail) + + try { + await run(fixture) + } finally { + await deleteHandheldSearchFixture(fixture) + } +} + +test.describe('Handheld Report search visibility by role', () => { + for (const accessCase of SEARCH_ACCESS_CASES) { + test(`filters results for ${accessCase.label}`, async ({ browser }) => { + await withHandheldSearchFixture(accessCase.ownerEmail, async (fixture) => { + await withContext(browser, accessCase.storageState, async (page) => { + const listingsPage = new ListingsPage(page) + await listingsPage.goto() + await listingsPage.verifyPageLoaded() + + await listingsPage.searchListings(fixture.searchTerm) + + await expect(listingsPage.listingItems).toHaveCount(accessCase.expectedListings.length) + + for (const key of SEARCH_LISTING_KEYS) { + const listing = fixture.listings[key] + const link = page.locator(`a[href="${listing.path}"]`) + if (accessCase.expectedListings.includes(key)) { + await expect(link.first()).toBeVisible() + } else { + await expect(link).toHaveCount(0) + } + } + }) + }) + }) + } +}) + test.describe('Search Functionality Tests', () => { test('should search for games by title', async ({ page }) => { const gamesPage = new GamesPage(page) diff --git a/tests/third-party-services.spec.ts b/tests/third-party-services.spec.ts index 1e630a5aa..0a495b022 100644 --- a/tests/third-party-services.spec.ts +++ b/tests/third-party-services.spec.ts @@ -4,8 +4,6 @@ const OPTIONAL_SERVICE_REQUEST_PATTERNS = [ 'storage.ko-fi.com', 'googletagmanager.com', 'google-analytics.com', - '_vercel/insights', - '_vercel/speed-insights', 'ingest.us.sentry.io', ] as const 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"],