From 701f679ee0263134232f66a4798aff2a30603c60 Mon Sep 17 00:00:00 2001 From: Producdevity Date: Fri, 22 May 2026 15:08:18 +0200 Subject: [PATCH 01/22] feat: lru_cache and nextjs update --- .env.docker.example | 1 + .env.example | 1 + .env.test.example | 1 + eslint.config.mjs | 35 +- next.config.ts | 29 +- package.json | 28 +- pnpm-lock.yaml | 1276 ++++++++++------- .../components/MonitoringOverview.tsx | 73 - .../components/SEOMetricsDashboard.tsx | 247 ---- src/app/admin/monitoring/page.tsx | 15 +- src/app/api/cache/metrics/route.ts | 46 - src/app/api/cache/warm/route.ts | 65 - src/app/api/catalog/compatibility/route.ts | 13 +- src/app/api/health/route.ts | 9 +- src/app/api/proxy-image/route.ts | 4 - src/app/api/trpc/[trpc]/route.ts | 3 - src/app/api/upload/games/route.ts | 8 - src/app/api/upload/profile/route.ts | 7 - src/app/games/new/NewGamePage.tsx | 8 - src/app/layout.tsx | 66 +- src/app/sitemap.ts | 16 - src/components/admin/CacheMetrics.tsx | 152 -- src/lib/cache/seo-cache.ts | 234 --- src/lib/captcha/config.ts | 20 +- src/lib/captcha/verify.test.ts | 12 + src/lib/monitoring/seo-metrics.ts | 189 --- src/{middleware.ts => proxy.ts} | 78 +- src/schemas/cache.ts | 9 - src/server/api/root.ts | 2 - src/server/api/routers/cache.ts | 218 --- src/server/api/routers/listings/admin.test.ts | 1 + src/server/api/routers/listings/admin.ts | 14 +- src/server/api/routers/mobile/pcListings.ts | 39 +- src/server/api/routers/pcListings.test.ts | 12 +- src/server/api/routers/pcListings.ts | 74 +- src/server/cache/invalidation.test.ts | 49 + src/server/cache/invalidation.ts | 80 +- src/server/cache/warming.ts | 208 --- src/server/db/seo-queries.test.ts | 122 ++ src/server/db/seo-queries.ts | 357 ++--- src/server/notifications/analyticsService.ts | 7 - src/server/services/catalog.service.test.ts | 88 ++ src/server/services/catalog.service.ts | 29 +- src/server/tgdb.ts | 42 +- src/server/utils/cache/MemoryCache.test.ts | 263 ---- src/server/utils/cache/MemoryCache.ts | 210 --- src/server/utils/cache/index.ts | 6 - src/server/utils/cache/instances.ts | 114 +- src/server/utils/driver-versions.ts | 6 +- src/server/utils/steamGameBatcher.ts | 54 +- src/server/utils/steamGameSearch.ts | 83 +- src/server/utils/switchGameSearch.ts | 100 +- src/server/utils/threeDsGameSearch.ts | 33 +- tsconfig.json | 2 +- 54 files changed, 1643 insertions(+), 3215 deletions(-) delete mode 100644 src/app/admin/monitoring/components/MonitoringOverview.tsx delete mode 100644 src/app/admin/monitoring/components/SEOMetricsDashboard.tsx delete mode 100644 src/app/api/cache/metrics/route.ts delete mode 100644 src/app/api/cache/warm/route.ts delete mode 100644 src/components/admin/CacheMetrics.tsx delete mode 100644 src/lib/cache/seo-cache.ts delete mode 100644 src/lib/monitoring/seo-metrics.ts rename src/{middleware.ts => proxy.ts} (71%) delete mode 100644 src/schemas/cache.ts delete mode 100644 src/server/api/routers/cache.ts create mode 100644 src/server/cache/invalidation.test.ts delete mode 100644 src/server/cache/warming.ts create mode 100644 src/server/db/seo-queries.test.ts create mode 100644 src/server/services/catalog.service.test.ts delete mode 100644 src/server/utils/cache/MemoryCache.test.ts delete mode 100644 src/server/utils/cache/MemoryCache.ts diff --git a/.env.docker.example b/.env.docker.example index f2c2607b1..70841edd0 100644 --- a/.env.docker.example +++ b/.env.docker.example @@ -40,6 +40,7 @@ TUNNEL_TOKEN="your_cloudflare_tunnel_token_here" # Google reCAPTCHA for form protection NEXT_PUBLIC_RECAPTCHA_SITE_KEY="your_recaptcha_site_key_here" RECAPTCHA_SECRET_KEY="your_recaptcha_secret_key_here" +NEXT_PUBLIC_DISABLE_RECAPTCHA="false" # =========================================== # EMAIL SERVICES (Optional) diff --git a/.env.example b/.env.example index 8c4f20075..5efdaeff9 100644 --- a/.env.example +++ b/.env.example @@ -19,6 +19,7 @@ IGDB_CLIENT_KEY="IGDB-Client-Secret" # reCAPTCHA v3 NEXT_PUBLIC_RECAPTCHA_SITE_KEY=site-key-here RECAPTCHA_SECRET_KEY=secret-key-here +NEXT_PUBLIC_DISABLE_RECAPTCHA=false # Email Providers (For the future) EMAIL_ENABLED=false diff --git a/.env.test.example b/.env.test.example index 16229ad90..ff39873e6 100644 --- a/.env.test.example +++ b/.env.test.example @@ -9,6 +9,7 @@ CLERK_SECRET_KEY="sk_test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" # reCAPTCHA V3 NEXT_PUBLIC_RECAPTCHA_SITE_KEY= RECAPTCHA_SECRET_KEY= +NEXT_PUBLIC_DISABLE_RECAPTCHA=false # Email (For the future) EMAIL_ENABLED=false diff --git a/eslint.config.mjs b/eslint.config.mjs index 19ea77fc9..4198461f0 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,16 +1,7 @@ -import path from 'node:path' -import { fileURLToPath } from 'node:url' -import { FlatCompat } from '@eslint/eslintrc' import typescriptEslint from '@typescript-eslint/eslint-plugin' import typescriptParser from '@typescript-eslint/parser' -import importPlugin from 'eslint-plugin-import' - -const __filename = fileURLToPath(import.meta.url) -const __dirname = path.dirname(__filename) - -const compat = new FlatCompat({ - baseDirectory: __dirname, -}) +import eslintConfigPrettier from 'eslint-config-prettier' +import nextCoreWebVitals from 'eslint-config-next/core-web-vitals' const eslintConfig = [ { @@ -37,10 +28,8 @@ const eslintConfig = [ 'test-results/**', ], }, - // Next.js + Prettier via FlatCompat - ...compat.config({ - extends: ['next/core-web-vitals', 'next/typescript', 'prettier'], - }), // JS and TS global config + ...nextCoreWebVitals, + eslintConfigPrettier, { files: ['**/*.{js,jsx,ts,tsx}'], languageOptions: { @@ -55,14 +44,22 @@ const eslintConfig = [ }, }, rules: { - // Your custom JS rules 'prefer-template': 'error', 'no-useless-escape': 'off', 'no-case-declarations': 'off', 'no-prototype-builtins': 'off', 'no-redeclare': 'off', + // Next 16's flat config enables React Compiler rollout checks. Keep this upgrade + // focused on framework/caching behavior; enable these after fixing existing violations. + 'react-hooks/purity': 'off', + 'react-hooks/static-components': 'off', + 'react-hooks/immutability': 'off', + 'react-hooks/incompatible-library': 'off', + 'react-hooks/preserve-manual-memoization': 'off', + 'react-hooks/refs': 'off', + 'react-hooks/set-state-in-effect': 'off', }, - }, // TypeScript config + }, { files: ['**/*.{ts,tsx}'], languageOptions: { @@ -74,9 +71,6 @@ const eslintConfig = [ ecmaFeatures: { jsx: true }, }, }, - plugins: { - '@typescript-eslint': typescriptEslint, - }, rules: { ...typescriptEslint.configs.recommended.rules, '@typescript-eslint/array-type': ['error', { default: 'array' }], @@ -101,7 +95,6 @@ const eslintConfig = [ }, // Import plugin { files: ['**/*.{js,jsx,ts,tsx}'], - plugins: { import: importPlugin }, settings: { 'import/resolver': { typescript: { project: './tsconfig.json' }, diff --git a/next.config.ts b/next.config.ts index 76bdd4db9..9dff5afa0 100644 --- a/next.config.ts +++ b/next.config.ts @@ -203,6 +203,31 @@ const nextConfig: NextConfig = { allowedDevOrigins: ['dev.emuready.com', '127.0.0.1'], + cacheComponents: true, + + cacheLife: { + 'seo-record': { + stale: 300, + revalidate: 3600, + expire: 86400, + }, + 'seo-report': { + stale: 300, + revalidate: 1800, + expire: 43200, + }, + 'seo-sitemap': { + stale: 300, + revalidate: 21600, + expire: 172800, + }, + 'seo-miss': { + stale: 30, + revalidate: 60, + expire: 300, + }, + }, + turbopack: { rules: { '*.svg': { @@ -274,10 +299,6 @@ const nextConfig: NextConfig = { ], }, - eslint: { - dirs: ['src', 'tests'], - }, - webpack: (config: WebpackConfiguration) => { config.module?.rules?.push({ test: /\.svg$/, diff --git a/package.json b/package.json index b49c7ad97..ac82ecd07 100644 --- a/package.json +++ b/package.json @@ -61,7 +61,7 @@ "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@hookform/resolvers": "^5.0.1", - "@next/third-parties": "15.5.18", + "@next/third-parties": "16.2.6", "@prisma/adapter-pg": "7.8.0", "@prisma/client": "7.8.0", "@radix-ui/react-alert-dialog": "^1.1.14", @@ -99,13 +99,13 @@ "lucide-react": "^0.511.0", "mailersend": "^2.6.0", "markdown-it": "14.1.1", - "next": "15.5.18", + "next": "16.2.6", "next-themes": "^0.4.6", "pg": "8.21.0", "pg-pool": "3.14.0", "postcss": "8.5.14", - "react": "19.1.4", - "react-dom": "19.1.4", + "react": "19.2.6", + "react-dom": "19.2.6", "react-error-boundary": "^6.0.0", "react-google-recaptcha-v3": "^1.11.0", "react-hook-form": "^7.56.4", @@ -123,8 +123,8 @@ "@clerk/types": "4.101.23", "@eslint/eslintrc": "^3", "@eslint/js": "^9.28.0", - "@next/bundle-analyzer": "15.5.18", - "@next/eslint-plugin-next": "15.5.18", + "@next/bundle-analyzer": "16.2.6", + "@next/eslint-plugin-next": "16.2.6", "@playwright/test": "^1.60.0", "@svgr/webpack": "^8.1.0", "@tailwindcss/postcss": "^4.1.6", @@ -135,8 +135,8 @@ "@types/jsdom": "^21.1.7", "@types/markdown-it": "^14.1.2", "@types/node": "^20", - "@types/react": "19.1.8", - "@types/react-dom": "19.1.6", + "@types/react": "19.2.15", + "@types/react-dom": "19.2.3", "@types/swagger-jsdoc": "^6.0.4", "@types/webpack": "^5.28.5", "@typescript-eslint/eslint-plugin": "^8.34.0", @@ -145,7 +145,7 @@ "dotenv": "^16.5.0", "dotenv-cli": "^8.0.0", "eslint": "^9", - "eslint-config-next": "15.5.18", + "eslint-config-next": "16.2.6", "eslint-config-prettier": "^10.1.5", "eslint-import-resolver-typescript": "^4.4.3", "eslint-plugin-import": "^2.31.0", @@ -173,7 +173,13 @@ "sharp": "^0.33.5" }, "overrides": { - "@types/react": "19.1.8", - "@types/react-dom": "19.1.6" + "@types/react": "19.2.15", + "@types/react-dom": "19.2.3" + }, + "pnpm": { + "overrides": { + "@types/react": "19.2.15", + "@types/react-dom": "19.2.3" + } } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4e5e31beb..461bd0c37 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -16,76 +16,76 @@ importers: version: 3.896.0 '@clerk/backend': specifier: 2.33.3 - version: 2.33.3(react-dom@19.1.4(react@19.1.4))(react@19.1.4) + version: 2.33.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@clerk/nextjs': specifier: 6.39.3 - version: 6.39.3(next@15.5.18(@babel/core@7.27.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@19.1.4(react@19.1.4))(react@19.1.4))(react-dom@19.1.4(react@19.1.4))(react@19.1.4) + version: 6.39.3(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-dom@19.2.6(react@19.2.6))(react@19.2.6) '@clerk/themes': specifier: ^2.2.48 - version: 2.2.52(react-dom@19.1.4(react@19.1.4))(react@19.1.4) + version: 2.2.52(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@dnd-kit/core': specifier: ^6.3.1 - version: 6.3.1(react-dom@19.1.4(react@19.1.4))(react@19.1.4) + version: 6.3.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@dnd-kit/sortable': specifier: ^10.0.0 - version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.1.4(react@19.1.4))(react@19.1.4))(react@19.1.4) + version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6) '@dnd-kit/utilities': specifier: ^3.2.2 - version: 3.2.2(react@19.1.4) + version: 3.2.2(react@19.2.6) '@hookform/resolvers': specifier: ^5.0.1 - version: 5.1.1(react-hook-form@7.58.1(react@19.1.4)) + version: 5.1.1(react-hook-form@7.58.1(react@19.2.6)) '@next/third-parties': - specifier: 15.5.18 - version: 15.5.18(next@15.5.18(@babel/core@7.27.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@19.1.4(react@19.1.4))(react@19.1.4))(react@19.1.4) + specifier: 16.2.6 + version: 16.2.6(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) '@prisma/adapter-pg': specifier: 7.8.0 version: 7.8.0 '@prisma/client': specifier: 7.8.0 - version: 7.8.0(prisma@7.8.0(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.4(react@19.1.4))(react@19.1.4)(typescript@5.8.3))(typescript@5.8.3) + version: 7.8.0(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.8.3))(typescript@5.8.3) '@radix-ui/react-alert-dialog': specifier: ^1.1.14 - version: 1.1.14(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.4(react@19.1.4))(react@19.1.4) + version: 1.1.14(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@radix-ui/react-dialog': specifier: ^1.1.14 - version: 1.1.15(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.4(react@19.1.4))(react@19.1.4) + version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@radix-ui/react-popover': specifier: ^1.1.14 - version: 1.1.14(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.4(react@19.1.4))(react@19.1.4) + version: 1.1.14(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@radix-ui/react-slot': specifier: ^1.2.3 - version: 1.2.3(@types/react@19.1.8)(react@19.1.4) + version: 1.2.3(@types/react@19.2.15)(react@19.2.6) '@radix-ui/react-switch': specifier: ^1.2.6 - version: 1.2.6(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.4(react@19.1.4))(react@19.1.4) + version: 1.2.6(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@radix-ui/react-tooltip': specifier: ^1.2.7 - version: 1.2.7(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.4(react@19.1.4))(react@19.1.4) + version: 1.2.7(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@scalar/api-reference-react': specifier: ^0.7.30 - version: 0.7.30(axios@1.16.0)(react@19.1.4)(tailwindcss@4.1.11)(typescript@5.8.3) + version: 0.7.30(axios@1.16.0)(react@19.2.6)(tailwindcss@4.1.11)(typescript@5.8.3) '@sendgrid/mail': specifier: ^8.1.5 version: 8.1.5 '@sentry/nextjs': specifier: ^10.53.1 - version: 10.53.1(@opentelemetry/core@2.7.1(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.7.1(@opentelemetry/api@1.9.1))(next@15.5.18(@babel/core@7.27.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@19.1.4(react@19.1.4))(react@19.1.4))(react@19.1.4)(webpack@5.100.2) + version: 10.53.1(@opentelemetry/core@2.7.1(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.7.1(@opentelemetry/api@1.9.1))(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)(webpack@5.100.2) '@tailwindcss/typography': specifier: ^0.5.16 version: 0.5.16(tailwindcss@4.1.11) '@tanstack/react-query': specifier: 5.100.10 - version: 5.100.10(react@19.1.4) + version: 5.100.10(react@19.2.6) '@trpc/client': specifier: 11.17.0 version: 11.17.0(@trpc/server@11.17.0(typescript@5.8.3))(typescript@5.8.3) '@trpc/next': specifier: 11.17.0 - version: 11.17.0(@tanstack/react-query@5.100.10(react@19.1.4))(@trpc/client@11.17.0(@trpc/server@11.17.0(typescript@5.8.3))(typescript@5.8.3))(@trpc/react-query@11.17.0(@tanstack/react-query@5.100.10(react@19.1.4))(@trpc/client@11.17.0(@trpc/server@11.17.0(typescript@5.8.3))(typescript@5.8.3))(@trpc/server@11.17.0(typescript@5.8.3))(react@19.1.4)(typescript@5.8.3))(@trpc/server@11.17.0(typescript@5.8.3))(next@15.5.18(@babel/core@7.27.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@19.1.4(react@19.1.4))(react@19.1.4))(react-dom@19.1.4(react@19.1.4))(react@19.1.4)(typescript@5.8.3) + version: 11.17.0(@tanstack/react-query@5.100.10(react@19.2.6))(@trpc/client@11.17.0(@trpc/server@11.17.0(typescript@5.8.3))(typescript@5.8.3))(@trpc/react-query@11.17.0(@tanstack/react-query@5.100.10(react@19.2.6))(@trpc/client@11.17.0(@trpc/server@11.17.0(typescript@5.8.3))(typescript@5.8.3))(@trpc/server@11.17.0(typescript@5.8.3))(react@19.2.6)(typescript@5.8.3))(@trpc/server@11.17.0(typescript@5.8.3))(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-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.8.3) '@trpc/react-query': specifier: 11.17.0 - version: 11.17.0(@tanstack/react-query@5.100.10(react@19.1.4))(@trpc/client@11.17.0(@trpc/server@11.17.0(typescript@5.8.3))(typescript@5.8.3))(@trpc/server@11.17.0(typescript@5.8.3))(react@19.1.4)(typescript@5.8.3) + version: 11.17.0(@tanstack/react-query@5.100.10(react@19.2.6))(@trpc/client@11.17.0(@trpc/server@11.17.0(typescript@5.8.3))(typescript@5.8.3))(@trpc/server@11.17.0(typescript@5.8.3))(react@19.2.6)(typescript@5.8.3) '@trpc/server': specifier: 11.17.0 version: 11.17.0(typescript@5.8.3) @@ -94,10 +94,10 @@ importers: version: 15.5.13 '@vercel/analytics': specifier: ^1.5.0 - version: 1.5.0(next@15.5.18(@babel/core@7.27.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@19.1.4(react@19.1.4))(react@19.1.4))(react@19.1.4)(vue-router@4.5.1(vue@3.5.17(typescript@5.8.3)))(vue@3.5.17(typescript@5.8.3)) + 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@15.5.18(@babel/core@7.27.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@19.1.4(react@19.1.4))(react@19.1.4))(react@19.1.4)(vue-router@4.5.1(vue@3.5.17(typescript@5.8.3)))(vue@3.5.17(typescript@5.8.3)) + 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 @@ -109,7 +109,7 @@ importers: version: 2.1.1 cmdk: specifier: ^1.1.1 - version: 1.1.1(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.4(react@19.1.4))(react@19.1.4) + version: 1.1.1(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) critters: specifier: ^0.0.23 version: 0.0.23 @@ -121,7 +121,7 @@ importers: version: 3.4.2 framer-motion: specifier: ^12.11.0 - version: 12.19.2(react-dom@19.1.4(react@19.1.4))(react@19.1.4) + 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 @@ -142,7 +142,7 @@ importers: version: 11.1.0 lucide-react: specifier: ^0.511.0 - version: 0.511.0(react@19.1.4) + version: 0.511.0(react@19.2.6) mailersend: specifier: ^2.6.0 version: 2.6.0 @@ -150,11 +150,11 @@ importers: specifier: 14.1.1 version: 14.1.1 next: - specifier: 15.5.18 - version: 15.5.18(@babel/core@7.27.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@19.1.4(react@19.1.4))(react@19.1.4) + specifier: 16.2.6 + version: 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) next-themes: specifier: ^0.4.6 - version: 0.4.6(react-dom@19.1.4(react@19.1.4))(react@19.1.4) + version: 0.4.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6) pg: specifier: 8.21.0 version: 8.21.0 @@ -165,29 +165,29 @@ importers: specifier: 8.5.14 version: 8.5.14 react: - specifier: 19.1.4 - version: 19.1.4 + specifier: 19.2.6 + version: 19.2.6 react-dom: - specifier: 19.1.4 - version: 19.1.4(react@19.1.4) + specifier: 19.2.6 + version: 19.2.6(react@19.2.6) react-error-boundary: specifier: ^6.0.0 - version: 6.0.0(react@19.1.4) + version: 6.0.0(react@19.2.6) react-google-recaptcha-v3: specifier: ^1.11.0 - version: 1.11.0(react-dom@19.1.4(react@19.1.4))(react@19.1.4) + version: 1.11.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) react-hook-form: specifier: ^7.56.4 - version: 7.58.1(react@19.1.4) + version: 7.58.1(react@19.2.6) react-syntax-highlighter: specifier: ^15.6.6 - version: 15.6.6(react@19.1.4) + version: 15.6.6(react@19.2.6) remeda: specifier: ^2.22.1 version: 2.23.1 sonner: specifier: ^2.0.3 - version: 2.0.5(react-dom@19.1.4(react@19.1.4))(react@19.1.4) + version: 2.0.5(react-dom@19.2.6(react@19.2.6))(react@19.2.6) superjson: specifier: ^2.2.2 version: 2.2.2 @@ -206,10 +206,10 @@ importers: devDependencies: '@clerk/testing': specifier: ^1.8.0 - version: 1.9.0(@playwright/test@1.60.0)(react-dom@19.1.4(react@19.1.4))(react@19.1.4) + version: 1.9.0(@playwright/test@1.60.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@clerk/types': specifier: 4.101.23 - version: 4.101.23(react-dom@19.1.4(react@19.1.4))(react@19.1.4) + version: 4.101.23(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@eslint/eslintrc': specifier: ^3 version: 3.3.1 @@ -217,11 +217,11 @@ importers: specifier: ^9.28.0 version: 9.30.0 '@next/bundle-analyzer': - specifier: 15.5.18 - version: 15.5.18 + specifier: 16.2.6 + version: 16.2.6 '@next/eslint-plugin-next': - specifier: 15.5.18 - version: 15.5.18 + specifier: 16.2.6 + version: 16.2.6 '@playwright/test': specifier: ^1.60.0 version: 1.60.0 @@ -239,7 +239,7 @@ importers: version: 6.9.1 '@testing-library/react': specifier: ^16.3.2 - version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.4(react@19.1.4))(react@19.1.4) + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@testing-library/user-event': specifier: ^14.6.1 version: 14.6.1(@testing-library/dom@10.4.1) @@ -253,11 +253,11 @@ importers: specifier: ^20 version: 20.19.1 '@types/react': - specifier: 19.1.8 - version: 19.1.8 + specifier: 19.2.15 + version: 19.2.15 '@types/react-dom': - specifier: 19.1.6 - version: 19.1.6(@types/react@19.1.8) + specifier: 19.2.3 + version: 19.2.3(@types/react@19.2.15) '@types/swagger-jsdoc': specifier: ^6.0.4 version: 6.0.4 @@ -283,8 +283,8 @@ importers: specifier: ^9 version: 9.29.0(jiti@2.7.0) eslint-config-next: - specifier: 15.5.18 - version: 15.5.18(eslint@9.29.0(jiti@2.7.0))(typescript@5.8.3) + specifier: 16.2.6 + version: 16.2.6(@typescript-eslint/parser@8.39.0(eslint@9.29.0(jiti@2.7.0))(typescript@5.8.3))(eslint@9.29.0(jiti@2.7.0))(typescript@5.8.3) eslint-config-prettier: specifier: ^10.1.5 version: 10.1.5(eslint@9.29.0(jiti@2.7.0)) @@ -314,7 +314,7 @@ importers: version: 3.6.2 prisma: specifier: 7.8.0 - version: 7.8.0(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.4(react@19.1.4))(react@19.1.4)(typescript@5.8.3) + version: 7.8.0(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.8.3) swagger-jsdoc: specifier: ^6.2.8 version: 6.2.8(openapi-types@12.1.3) @@ -1434,10 +1434,20 @@ packages: peerDependencies: eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + '@eslint-community/eslint-utils@4.9.1': + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + '@eslint-community/regexpp@4.12.1': resolution: {integrity: sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==} engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + '@eslint/config-array@0.20.1': resolution: {integrity: sha512-OL0RJzC/CBzli0DrrR31qzj6d6i6Mm3HByuhflhl4LOBiWxN+3i6/t/ZQQNii4tjksXi8r2CRW1wMpWA2ULUEw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1904,71 +1914,71 @@ packages: '@napi-rs/wasm-runtime@0.2.12': resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} - '@next/bundle-analyzer@15.5.18': - resolution: {integrity: sha512-v5/UNFwYbBlRQg/Bt+wU65XuxCxPu1AeCOI6s4s6Cludsj7FdVO9E9uzr7GIj8OykSrYtGuEQAUX0Ulje8W2yw==} + '@next/bundle-analyzer@16.2.6': + resolution: {integrity: sha512-amPkVtHCTJAdBwyhhl5+qztHk24O4JlASgrWqh15AmnYi74apfZR46NGC0u4pM6BMAU1mYld4WdzD3cRBP3dOA==} - '@next/env@15.5.18': - resolution: {integrity: sha512-hAV85Ckd9QR6RvH04MEKwsfLTksvFpO47j9xwtoIuvuPnlwecpSi+uZTtm8HirVbtlI2Fnz//xpcSTjFdyJk+g==} + '@next/env@16.2.6': + resolution: {integrity: sha512-gd8HoHN4ufj73WmR3JmVolrpJR47ILK6LouP5xElPglaVxir6e1a7VzvTvDWkOoPXT9rkkTzyCxBu4yeZfZwcw==} - '@next/eslint-plugin-next@15.5.18': - resolution: {integrity: sha512-w4MYq8M26a8PNrfto0JosLf5/3ssln1rsyP96g2DkC8uFVymStM5DLSz5ElxxrPRg2XnTMnFo3kREFlhYvxhWw==} + '@next/eslint-plugin-next@16.2.6': + resolution: {integrity: sha512-Z8l6o4JWKUl755x4R+wogD86KPeU+Ckw4K+SYG4kHeOJtRenDeK+OSbGcqZpDtbwn9DsJVdir2UxmwXuinUbUw==} - '@next/swc-darwin-arm64@15.5.18': - resolution: {integrity: sha512-w0WvQf1n+txiwns/9pwIQteCJpZTbxzO2SE0FLcwuD4v0WEh1JPOjdyxWL21XwJsdpx8cFRjyzxzCS/siP7HcQ==} + '@next/swc-darwin-arm64@16.2.6': + resolution: {integrity: sha512-ZJGkkcNfYgrrMkqOdZ7zoLa1TOy0qpcMfk/z4Mh/FKUz40gVO+HNQWqmLxf67Z5WB64DRp0dhEbyHfel+6sJUg==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@next/swc-darwin-x64@15.5.18': - resolution: {integrity: sha512-znn71QmDuxm+BOaglihMZfvyySMnNljkVIY5Z2TCssBmm+WqL6c19VhtH5ktFkHa8EZ2bnTUpcNcmNSQsg67og==} + '@next/swc-darwin-x64@16.2.6': + resolution: {integrity: sha512-v/YLBHIY132Ced3puBJ7YJKw1lqsCrgcNo2aRJlCEyQrrCeRJlvGlnmxhPxNQI3KE3N1DN5r9TPNPvka3nq5RQ==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@next/swc-linux-arm64-gnu@15.5.18': - resolution: {integrity: sha512-yPPe5MNL+igZUa+OsqQJisqSfh6oarIuA1Q0BDxljGJhRQyZeP+WRHh7rs/jZUGMh5aY0YdIjXZG0VohkKkUdw==} + '@next/swc-linux-arm64-gnu@16.2.6': + resolution: {integrity: sha512-RPOvqlYBbcQjkz9VQQDZ2T2bARIjXZV1KFlt+V2Mr6SW/e4I9fcKsaA0hdyf2FHoTlsV2xnBd5Y912rP/1Ce6w==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [glibc] - '@next/swc-linux-arm64-musl@15.5.18': - resolution: {integrity: sha512-glaCczEWIrHsokFZ3pP08U4BpKxwIdnT+txdOM32OBgpL9Yw4aqx8NejmgtZQZOdstQ5f0L3CasIZudzCuD+nw==} + '@next/swc-linux-arm64-musl@16.2.6': + resolution: {integrity: sha512-URUTu1+dMkxJsPFgm+OeEvq9wf5sujw0EvgYy80TDGHTSLTnIHeqb0Eu8A3sC95IRgjejQL+kC4mw+4yPxiAXA==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [musl] - '@next/swc-linux-x64-gnu@15.5.18': - resolution: {integrity: sha512-oUfg2EgJmU3R0OCOWiokGFUTvZiPfXtriXiuF3YNxRoROCdgvTedHIzYoeKH34gsZxS/V7mHbfq2hpAHwhH1/A==} + '@next/swc-linux-x64-gnu@16.2.6': + resolution: {integrity: sha512-DOj182mPV8G3UkrayLoREM5YEYI+Dk5wv7Ox9xl1fFibAELEsFD0lDPfHIeILlutMMfdyhlzYPELG3peuKaurw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [glibc] - '@next/swc-linux-x64-musl@15.5.18': - resolution: {integrity: sha512-JLxSP3KTd9iu/bvUMQxH7RJo9xKSHf55/6RPE4a6FTSZygGn7uvZbCej0AHXydwkggQGSD9UddSjwv6Xz5ESfA==} + '@next/swc-linux-x64-musl@16.2.6': + resolution: {integrity: sha512-HKQ5SP/V/ub73UvF7n/zeJlxk2kLmtL7Wzrg4WfmkjmNos5onJ2tKu7yZOPdL18A6Svfn3max29ym+ry7NkK4g==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [musl] - '@next/swc-win32-arm64-msvc@15.5.18': - resolution: {integrity: sha512-ir1v7enP52K2HNz3tQQvwF+x7VNxBk1ciiZ18WBPvxf4C59IqdfmHPJYK3vH7rSxpuCVw/8C712wTXNAtEp+NA==} + '@next/swc-win32-arm64-msvc@16.2.6': + resolution: {integrity: sha512-LZXpTlPyS5v7HhSmnvsLGP3iIYgYOBnc8r8ArlT55sGHV89bR2HlDdBjWQ+PY6SJMmk8TuVGFuxalnP3k/0Dwg==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@next/swc-win32-x64-msvc@15.5.18': - resolution: {integrity: sha512-LIu5me6QTANCd25E7I5uIEfvgQ06RK7tvHAbYo3zCb3VpxQEPvMcSpd87NwUABDT6MbGPdEGR5VRiK4PPTJhQg==} + '@next/swc-win32-x64-msvc@16.2.6': + resolution: {integrity: sha512-F0+4i0h9J6C4eE3EAPWsoCk7UW/dbzOjyzxY0qnDUOYFu6FFmdZ6l97/XdV3/Nz3VYyO7UWjyEJUXkGqcoXfMA==} engines: {node: '>= 10'} cpu: [x64] os: [win32] - '@next/third-parties@15.5.18': - resolution: {integrity: sha512-Kp51CbeHgYelpkasFI1gAeAeMPfMTctLcmVIfE2MiRRfC6oYbR3+lO4S0NbNRppOZ7RkbB5P6uW/qOewxth6iA==} + '@next/third-parties@16.2.6': + resolution: {integrity: sha512-PDPIPVj1NX6Taxsl8OJteAUJ7iwR+QrokwWig68eh0cOmuNjC6MBL+ZzBjO8Bv0n/HOSqjGArZpM5KMSUxm+MQ==} peerDependencies: - next: ^13.0.0 || ^14.0.0 || ^15.0.0 + next: ^13.0.0 || ^14.0.0 || ^15.0.0 || ^16.0.0-beta.0 react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 '@nodelib/fs.scandir@2.1.5': @@ -2780,9 +2790,6 @@ packages: '@rtsao/scc@1.1.0': resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} - '@rushstack/eslint-patch@1.12.0': - resolution: {integrity: sha512-5EwMtOqvJMMa3HbmxLlF74e+3/HhwBTMcvt3nqVJgGCozO6hzIPOBlwm8mGVNR9SN2IJpxSnlxczyDjcn7qIyw==} - '@scalar/api-client@2.5.16': resolution: {integrity: sha512-pSsCm0jXqP7doctFL1l47Ctq22zaaTbSy5052hKkxKXosQfv7B77CNdfcIw+ibrt4xx+Epx2q9uc2Tolmcp/9w==} engines: {node: '>=20'} @@ -3630,10 +3637,10 @@ packages: '@types/pg@8.20.0': resolution: {integrity: sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==} - '@types/react-dom@19.1.6': - resolution: {integrity: sha512-4hOiT/dwO8Ko0gV1m/TJZYk3y0KBnY9vzDh7W+DH17b2HFSOGgdj33dhihPeuy3l0q23+4e+hoXHV6hCC4dCXw==} + '@types/react-dom@19.2.3': + resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} peerDependencies: - '@types/react': ^19.0.0 + '@types/react': ^19.2.0 '@types/react-syntax-highlighter@15.5.13': resolution: {integrity: sha512-uLGJ87j6Sz8UaBAooU0T6lWJ0dBmjZgN1PZTrj05TNql2/XpC6+4HhMT5syIdFUUt+FASfCeLLv4kBygNU+8qA==} @@ -3641,6 +3648,9 @@ packages: '@types/react@19.1.8': resolution: {integrity: sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g==} + '@types/react@19.2.15': + resolution: {integrity: sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q==} + '@types/swagger-jsdoc@6.0.4': resolution: {integrity: sha512-W+Xw5epcOZrF/AooUM/PccNMSAFOKWZA5dasNyMujTwsBkU74njSJBpvCCJhHAJ95XRMzQrrW844Btu0uoetwQ==} @@ -3681,6 +3691,14 @@ packages: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <6.0.0' + '@typescript-eslint/eslint-plugin@8.59.4': + resolution: {integrity: sha512-PegsU+XfyJJNjd4+u/k6f9yTyp0lEXXiPopUNobZcIAUJFGICFLN+sP0Rb3JehVmiij1Ph0dFGYqODoRo/2+6A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.59.4 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/parser@8.35.0': resolution: {integrity: sha512-6sMvZePQrnZH2/cJkwRpkT7DxoAWh+g6+GFRK6bV3YQo7ogi3SX5rgF6099r5Q53Ma5qeT7LGmOmuIutF4t3lA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -3695,6 +3713,13 @@ packages: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <6.0.0' + '@typescript-eslint/parser@8.59.4': + resolution: {integrity: sha512-zORHqO/tuhxY1zWuTvMUqddRxpiFJ72xVfcNoWpqdLjs6lfPbuQBJuW4pk+49/uBMy7Ssr4bzgjiKmmDB1UbZQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/project-service@8.35.0': resolution: {integrity: sha512-41xatqRwWZuhUMF/aZm2fcUsOFKNcG28xqRSS6ZVr9BVJtGExosLAm5A1OxTjRMagx8nJqva+P5zNIGt8RIgbQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -3707,6 +3732,12 @@ packages: peerDependencies: typescript: '>=4.8.4 <6.0.0' + '@typescript-eslint/project-service@8.59.4': + resolution: {integrity: sha512-Ly00Vu4oAacfDeHp2Zg85ioNG6l8HG+tN1D7J+xTHSxu9y0awYKJ2zH1rFBn8ZSfuGK+7FxK3Cgl3uAz0aZZLg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/scope-manager@8.35.0': resolution: {integrity: sha512-+AgL5+mcoLxl1vGjwNfiWq5fLDZM1TmTPYs2UkyHfFhgERxBbqHlNjRzhThJqz+ktBqTChRYY6zwbMwy0591AA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -3715,6 +3746,10 @@ packages: resolution: {integrity: sha512-8QOzff9UKxOh6npZQ/4FQu4mjdOCGSdO3p44ww0hk8Vu+IGbg0tB/H1LcTARRDzGCC8pDGbh2rissBuuoPgH8A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/scope-manager@8.59.4': + resolution: {integrity: sha512-mUeR/3H1WrTAddJrwut8OoPjfauaztMQmRwV5fQTUyNVJCLiUXXe4lGEyYIL2oFDpP7UtgbGJXCt72wT0z2S3Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/tsconfig-utils@8.35.0': resolution: {integrity: sha512-04k/7247kZzFraweuEirmvUj+W3bJLI9fX6fbo1Qm2YykuBvEhRTPl8tcxlYO8kZZW+HIXfkZNoasVb8EV4jpA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -3727,6 +3762,12 @@ packages: peerDependencies: typescript: '>=4.8.4 <6.0.0' + '@typescript-eslint/tsconfig-utils@8.59.4': + resolution: {integrity: sha512-DLCpnKgD4alVxTBSKulK+gU1KCqOgUXfDRDXh2mZgzokQKa/70ax93I2uVO3m/LLvIAtWZIFoiifudmIqAxpMA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/type-utils@8.35.0': resolution: {integrity: sha512-ceNNttjfmSEoM9PW87bWLDEIaLAyR+E6BoYJQ5PfaDau37UGca9Nyq3lBk8Bw2ad0AKvYabz6wxc7DMTO2jnNA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -3741,6 +3782,13 @@ packages: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <6.0.0' + '@typescript-eslint/type-utils@8.59.4': + resolution: {integrity: sha512-uonTuPAAKr9XaBGqJ3LjYTh72zy5DyGesljO9gtmk/eFW0W1fRHjnwVYKB35Lm8d5Q5CluEW3gPHjTvZTmgrfA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/types@8.35.0': resolution: {integrity: sha512-0mYH3emanku0vHw2aRLNGqe7EXh9WHEhi7kZzscrMDf6IIRUQ5Jk4wp1QrledE/36KtdZrVfKnE32eZCf/vaVQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -3749,6 +3797,10 @@ packages: resolution: {integrity: sha512-ArDdaOllnCj3yn/lzKn9s0pBQYmmyme/v1HbGIGB0GB/knFI3fWMHloC+oYTJW46tVbYnGKTMDK4ah1sC2v0Kg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/types@8.59.4': + resolution: {integrity: sha512-F1o7WJcCq+bc8dwcO/YsSEOudAH8RDtaOhM6wcAQhcUsFhnWQl81JKy48q1hoxAU0qrzM89+31GYh1515Zde3Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/typescript-estree@8.35.0': resolution: {integrity: sha512-F+BhnaBemgu1Qf8oHrxyw14wq6vbL8xwWKKMwTMwYIRmFFY/1n/9T/jpbobZL8vp7QyEUcC6xGrnAO4ua8Kp7w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -3761,6 +3813,12 @@ packages: peerDependencies: typescript: '>=4.8.4 <6.0.0' + '@typescript-eslint/typescript-estree@8.59.4': + resolution: {integrity: sha512-F+RuOmcDXo4+TPdfd/TCLS3m2nw8gE9XXyZLrA3JBfaA5tz9TtdkyD3YJFmPxulyc2cKbEok/CvFE3MgSLWnag==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/utils@8.35.0': resolution: {integrity: sha512-nqoMu7WWM7ki5tPgLVsmPM8CkqtoPUG6xXGeefM5t4x3XumOEKMoUZPdi+7F+/EotukN4R9OWdmDxN80fqoZeg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -3775,6 +3833,13 @@ packages: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <6.0.0' + '@typescript-eslint/utils@8.59.4': + resolution: {integrity: sha512-cYXeNAUsG4lJo5dbc1FcKm+JwIWrj1/UpTORsC6tGMjEZ81DYcvIr9/ueikhMa/Y/gDQYGp+YX9/xQrXje5BJw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/visitor-keys@8.35.0': resolution: {integrity: sha512-zTh2+1Y8ZpmeQaQVIc/ZZxsx8UzgKJyNg1PTvjzC7WMhPSVS8bfDX34k1SrwOf016qd5RU3az2UxUNue3IfQ5g==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -3783,6 +3848,10 @@ packages: resolution: {integrity: sha512-ldgiJ+VAhQCfIjeOgu8Kj5nSxds0ktPOSO9p4+0VDH2R2pLvQraaM5Oen2d7NxzMCm+Sn/vJT+mv2H5u6b/3fA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/visitor-keys@8.59.4': + resolution: {integrity: sha512-U3gxVaDVnuZKhSspW/MzMxE1kq7zOdc072FcSNoqA1I9p8HyKbBFfEHoWckBAMgNMph4MamwS5iTVzFmrnt8TQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} deprecated: Potential CWE-502 - Update to 1.3.1 or higher @@ -4329,6 +4398,11 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} + baseline-browser-mapping@2.10.31: + resolution: {integrity: sha512-MujYO3eP72uvmSE0i4wltsodRfIpZATP3jvzRNRGGxgzId7aVocVJJV3nf01qnzzKFGxQVC9bpWxl5cjxTr/7Q==} + engines: {node: '>=6.0.0'} + hasBin: true + better-result@2.9.2: resolution: {integrity: sha512-WIFoBPCdnTOdk9inkE1ZRvCZ4P0CpSkAiLlchC65N7n9DcjZ3NhqkBOlafzpOVnO8ixyi37kicmSJ3ENhPZl7Q==} @@ -4612,6 +4686,9 @@ packages: csstype@3.1.3: resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + cva@1.0.0-beta.2: resolution: {integrity: sha512-dqcOFe247I5pKxfuzqfq3seLL5iMYsTgo40Uw7+pKZAntPgFtR7Tmy59P5IVIq/XgB0NQWoIvYDt9TwHkuK8Cg==} peerDependencies: @@ -4666,6 +4743,15 @@ packages: supports-color: optional: true + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + decimal.js@10.5.0: resolution: {integrity: sha512-8vDa8Qxvr/+d94hSh5P3IJwI5t8/c0KsMp+g8bNw9cY2icONa5aPfvKeieW1WlG0WQYwwhJ7mjui2xtiePQSXw==} @@ -4883,10 +4969,10 @@ packages: resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} engines: {node: '>=12'} - eslint-config-next@15.5.18: - resolution: {integrity: sha512-HuoJU6uUPD00eyiud78IBnT4HLhztFj2V+ild2Uon5ZUrYZKe0Olu2QRD99e9IgL4/H1eg5Onka3BsfRW2U0Xw==} + eslint-config-next@16.2.6: + resolution: {integrity: sha512-z2ELYSkyrrJ6cuunTU8vhsT/RpouPkjaSah06nVW6Rg2Hpg0Vs8s497/e5s8G8qtdp4ccsiovz5P1rv+5VSW2Q==} peerDependencies: - eslint: ^7.23.0 || ^8.0.0 || ^9.0.0 + eslint: '>=9.0.0' typescript: '>=3.3.1' peerDependenciesMeta: typescript: @@ -4979,6 +5065,12 @@ packages: peerDependencies: eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 + eslint-plugin-react-hooks@7.1.1: + resolution: {integrity: sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==} + engines: {node: '>=18'} + peerDependencies: + eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0 + eslint-plugin-react@7.37.5: resolution: {integrity: sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==} engines: {node: '>=4'} @@ -5001,6 +5093,10 @@ packages: resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + eslint@9.29.0: resolution: {integrity: sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -5284,6 +5380,10 @@ packages: resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} engines: {node: '>=18'} + globals@16.4.0: + resolution: {integrity: sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw==} + engines: {node: '>=18'} + globalthis@1.0.4: resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} engines: {node: '>= 0.4'} @@ -5399,6 +5499,12 @@ packages: hastscript@9.0.1: resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==} + hermes-estree@0.25.1: + resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==} + + hermes-parser@0.25.1: + resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} + highlight.js@10.7.3: resolution: {integrity: sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==} @@ -6230,9 +6336,9 @@ packages: react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc - next@15.5.18: - resolution: {integrity: sha512-eKL8zUJkX9Y5lE+RX/2YJoItVdGlIscyVyboeD9wSpp0PaGqjoA4tTpT2qPqz9ax+5IzGESyLSeZ/RCwbSZ2uQ==} - engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0} + next@16.2.6: + resolution: {integrity: sha512-qOVgKJg1+At15NpeUP+eJgCHvTCgXsogweq87Ri/Ix7PkqQHg4sdaXmSFqKlgaIXE4kW0g25LE68W87UANlHtw==} + engines: {node: '>=20.9.0'} hasBin: true peerDependencies: '@opentelemetry/api': ^1.1.0 @@ -6617,10 +6723,10 @@ packages: rc9@3.0.1: resolution: {integrity: sha512-gMDyleLWVE+i6Sgtc0QbbY6pEKqYs97NGi6isHQPqYlLemPoO8dxQ3uGi0f4NiP98c+jMW6cG1Kx9dDwfvqARQ==} - react-dom@19.1.4: - resolution: {integrity: sha512-s2868ab/xo2SI6H4106A7aFI8Mrqa4xC6HZT/pBzYyQ3cBLqa88hu47xYD8xf+uECleN698Awn7RCWlkTiKnqQ==} + react-dom@19.2.6: + resolution: {integrity: sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==} peerDependencies: - react: ^19.1.4 + react: ^19.2.6 react-error-boundary@6.0.0: resolution: {integrity: sha512-gdlJjD7NWr0IfkPlaREN2d9uUZUlksrfOx7SX62VRerwXbMY6ftGCIZua1VG1aXFNOimhISsTq+Owp725b9SiA==} @@ -6684,8 +6790,8 @@ packages: peerDependencies: react: '>= 0.14.0' - react@19.1.4: - resolution: {integrity: sha512-DHINL3PAmPUiK1uszfbKiXqfE03eszdt5BpVSuEAHb5nfmNPwnsy7g39h2t8aXFc/Bv99GH81s+j8dobtD+jOw==} + react@19.2.6: + resolution: {integrity: sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==} engines: {node: '>=0.10.0'} readdirp@3.6.0: @@ -6840,8 +6946,8 @@ packages: resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} engines: {node: '>=v12.22.7'} - scheduler@0.26.0: - resolution: {integrity: sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==} + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} schema-utils@4.3.2: resolution: {integrity: sha512-Gn/JaSk/Mt9gYubxTtSn/QCV4em9mpAPiR1rqy/Ocu19u/G9J5WWdNoUT4SiV6mFC3y6cxyFcFwdzPM3FgxGAQ==} @@ -7238,6 +7344,12 @@ packages: peerDependencies: typescript: '>=4.8.4' + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + ts-deepmerge@7.0.3: resolution: {integrity: sha512-Du/ZW2RfwV/D4cmA5rXafYjBQVuvu4qGiEEla4EmEHVHgRdx68Gftx7i66jn2bzHPwSVZY36Ae6OuDn9el4ZKA==} engines: {node: '>=14.13.1'} @@ -7315,6 +7427,13 @@ packages: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <5.9.0' + typescript-eslint@8.59.4: + resolution: {integrity: sha512-Rw6+44QNFaXtgHSjPy+Kw8hrJniMYzR85E9yLmOLcfZ91/rz+JXQbDTCmc6ccxMPY6K6PgAq26f0JCBfR7LIPQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + typescript@5.8.3: resolution: {integrity: sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==} engines: {node: '>=14.17'} @@ -7744,6 +7863,12 @@ packages: peerDependencies: zod: ^3.24.1 + zod-validation-error@4.0.2: + resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + zod@3.24.1: resolution: {integrity: sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A==} @@ -9010,52 +9135,52 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.27.1 - '@clerk/backend@2.33.3(react-dom@19.1.4(react@19.1.4))(react@19.1.4)': + '@clerk/backend@2.33.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: - '@clerk/shared': 3.47.5(react-dom@19.1.4(react@19.1.4))(react@19.1.4) - '@clerk/types': 4.101.23(react-dom@19.1.4(react@19.1.4))(react@19.1.4) + '@clerk/shared': 3.47.5(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@clerk/types': 4.101.23(react-dom@19.2.6(react@19.2.6))(react@19.2.6) standardwebhooks: 1.0.0 tslib: 2.8.1 transitivePeerDependencies: - react - react-dom - '@clerk/clerk-react@5.61.6(react-dom@19.1.4(react@19.1.4))(react@19.1.4)': + '@clerk/clerk-react@5.61.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: - '@clerk/shared': 3.47.5(react-dom@19.1.4(react@19.1.4))(react@19.1.4) - react: 19.1.4 - react-dom: 19.1.4(react@19.1.4) + '@clerk/shared': 3.47.5(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) tslib: 2.8.1 - '@clerk/nextjs@6.39.3(next@15.5.18(@babel/core@7.27.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@19.1.4(react@19.1.4))(react@19.1.4))(react-dom@19.1.4(react@19.1.4))(react@19.1.4)': + '@clerk/nextjs@6.39.3(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-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: - '@clerk/backend': 2.33.3(react-dom@19.1.4(react@19.1.4))(react@19.1.4) - '@clerk/clerk-react': 5.61.6(react-dom@19.1.4(react@19.1.4))(react@19.1.4) - '@clerk/shared': 3.47.5(react-dom@19.1.4(react@19.1.4))(react@19.1.4) - '@clerk/types': 4.101.23(react-dom@19.1.4(react@19.1.4))(react@19.1.4) - next: 15.5.18(@babel/core@7.27.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@19.1.4(react@19.1.4))(react@19.1.4) - react: 19.1.4 - react-dom: 19.1.4(react@19.1.4) + '@clerk/backend': 2.33.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@clerk/clerk-react': 5.61.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@clerk/shared': 3.47.5(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@clerk/types': 4.101.23(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + 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 + react-dom: 19.2.6(react@19.2.6) server-only: 0.0.1 tslib: 2.8.1 - '@clerk/shared@3.47.5(react-dom@19.1.4(react@19.1.4))(react@19.1.4)': + '@clerk/shared@3.47.5(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: csstype: 3.1.3 dequal: 2.0.3 glob-to-regexp: 0.4.1 js-cookie: 3.0.5 std-env: 3.9.0 - swr: 2.3.4(react@19.1.4) + swr: 2.3.4(react@19.2.6) optionalDependencies: - react: 19.1.4 - react-dom: 19.1.4(react@19.1.4) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) - '@clerk/testing@1.9.0(@playwright/test@1.60.0)(react-dom@19.1.4(react@19.1.4))(react@19.1.4)': + '@clerk/testing@1.9.0(@playwright/test@1.60.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: - '@clerk/backend': 2.33.3(react-dom@19.1.4(react@19.1.4))(react@19.1.4) - '@clerk/shared': 3.47.5(react-dom@19.1.4(react@19.1.4))(react@19.1.4) - '@clerk/types': 4.101.23(react-dom@19.1.4(react@19.1.4))(react@19.1.4) + '@clerk/backend': 2.33.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@clerk/shared': 3.47.5(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@clerk/types': 4.101.23(react-dom@19.2.6(react@19.2.6))(react@19.2.6) dotenv: 16.4.7 optionalDependencies: '@playwright/test': 1.60.0 @@ -9063,17 +9188,17 @@ snapshots: - react - react-dom - '@clerk/themes@2.2.52(react-dom@19.1.4(react@19.1.4))(react@19.1.4)': + '@clerk/themes@2.2.52(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: - '@clerk/types': 4.101.23(react-dom@19.1.4(react@19.1.4))(react@19.1.4) + '@clerk/types': 4.101.23(react-dom@19.2.6(react@19.2.6))(react@19.2.6) tslib: 2.8.1 transitivePeerDependencies: - react - react-dom - '@clerk/types@4.101.23(react-dom@19.1.4(react@19.1.4))(react@19.1.4)': + '@clerk/types@4.101.23(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: - '@clerk/shared': 3.47.5(react-dom@19.1.4(react@19.1.4))(react@19.1.4) + '@clerk/shared': 3.47.5(react-dom@19.2.6(react@19.2.6))(react@19.2.6) transitivePeerDependencies: - react - react-dom @@ -9204,29 +9329,29 @@ snapshots: '@discoveryjs/json-ext@0.5.7': {} - '@dnd-kit/accessibility@3.1.1(react@19.1.4)': + '@dnd-kit/accessibility@3.1.1(react@19.2.6)': dependencies: - react: 19.1.4 + react: 19.2.6 tslib: 2.8.1 - '@dnd-kit/core@6.3.1(react-dom@19.1.4(react@19.1.4))(react@19.1.4)': + '@dnd-kit/core@6.3.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: - '@dnd-kit/accessibility': 3.1.1(react@19.1.4) - '@dnd-kit/utilities': 3.2.2(react@19.1.4) - react: 19.1.4 - react-dom: 19.1.4(react@19.1.4) + '@dnd-kit/accessibility': 3.1.1(react@19.2.6) + '@dnd-kit/utilities': 3.2.2(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) tslib: 2.8.1 - '@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@19.1.4(react@19.1.4))(react@19.1.4))(react@19.1.4)': + '@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)': dependencies: - '@dnd-kit/core': 6.3.1(react-dom@19.1.4(react@19.1.4))(react@19.1.4) - '@dnd-kit/utilities': 3.2.2(react@19.1.4) - react: 19.1.4 + '@dnd-kit/core': 6.3.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@dnd-kit/utilities': 3.2.2(react@19.2.6) + react: 19.2.6 tslib: 2.8.1 - '@dnd-kit/utilities@3.2.2(react@19.1.4)': + '@dnd-kit/utilities@3.2.2(react@19.2.6)': dependencies: - react: 19.1.4 + react: 19.2.6 tslib: 2.8.1 '@electric-sql/pglite-socket@0.1.1(@electric-sql/pglite@0.4.1)': @@ -9335,8 +9460,15 @@ snapshots: eslint: 9.29.0(jiti@2.7.0) eslint-visitor-keys: 3.4.3 + '@eslint-community/eslint-utils@4.9.1(eslint@9.29.0(jiti@2.7.0))': + dependencies: + eslint: 9.29.0(jiti@2.7.0) + eslint-visitor-keys: 3.4.3 + '@eslint-community/regexpp@4.12.1': {} + '@eslint-community/regexpp@4.12.2': {} + '@eslint/config-array@0.20.1': dependencies: '@eslint/object-schema': 2.1.6 @@ -9399,11 +9531,11 @@ snapshots: '@floating-ui/core': 1.7.5 '@floating-ui/utils': 0.2.11 - '@floating-ui/react-dom@2.1.8(react-dom@19.1.4(react@19.1.4))(react@19.1.4)': + '@floating-ui/react-dom@2.1.8(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@floating-ui/dom': 1.7.6 - react: 19.1.4 - react-dom: 19.1.4(react@19.1.4) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) '@floating-ui/utils@0.2.11': {} @@ -9429,10 +9561,10 @@ snapshots: dependencies: hono: 4.12.21 - '@hookform/resolvers@5.1.1(react-hook-form@7.58.1(react@19.1.4))': + '@hookform/resolvers@5.1.1(react-hook-form@7.58.1(react@19.2.6))': dependencies: '@standard-schema/utils': 0.3.0 - react-hook-form: 7.58.1(react@19.1.4) + react-hook-form: 7.58.1(react@19.2.6) '@humanfs/core@0.19.1': {} @@ -9743,47 +9875,47 @@ snapshots: '@tybys/wasm-util': 0.10.1 optional: true - '@next/bundle-analyzer@15.5.18': + '@next/bundle-analyzer@16.2.6': dependencies: webpack-bundle-analyzer: 4.10.1 transitivePeerDependencies: - bufferutil - utf-8-validate - '@next/env@15.5.18': {} + '@next/env@16.2.6': {} - '@next/eslint-plugin-next@15.5.18': + '@next/eslint-plugin-next@16.2.6': dependencies: fast-glob: 3.3.1 - '@next/swc-darwin-arm64@15.5.18': + '@next/swc-darwin-arm64@16.2.6': optional: true - '@next/swc-darwin-x64@15.5.18': + '@next/swc-darwin-x64@16.2.6': optional: true - '@next/swc-linux-arm64-gnu@15.5.18': + '@next/swc-linux-arm64-gnu@16.2.6': optional: true - '@next/swc-linux-arm64-musl@15.5.18': + '@next/swc-linux-arm64-musl@16.2.6': optional: true - '@next/swc-linux-x64-gnu@15.5.18': + '@next/swc-linux-x64-gnu@16.2.6': optional: true - '@next/swc-linux-x64-musl@15.5.18': + '@next/swc-linux-x64-musl@16.2.6': optional: true - '@next/swc-win32-arm64-msvc@15.5.18': + '@next/swc-win32-arm64-msvc@16.2.6': optional: true - '@next/swc-win32-x64-msvc@15.5.18': + '@next/swc-win32-x64-msvc@16.2.6': optional: true - '@next/third-parties@15.5.18(next@15.5.18(@babel/core@7.27.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@19.1.4(react@19.1.4))(react@19.1.4))(react@19.1.4)': + '@next/third-parties@16.2.6(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)': dependencies: - next: 15.5.18(@babel/core@7.27.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@19.1.4(react@19.1.4))(react@19.1.4) - react: 19.1.4 + 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 third-party-capital: 1.0.20 '@nodelib/fs.scandir@2.1.5': @@ -10049,11 +10181,11 @@ snapshots: '@prisma/client-runtime-utils@7.8.0': {} - '@prisma/client@7.8.0(prisma@7.8.0(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.4(react@19.1.4))(react@19.1.4)(typescript@5.8.3))(typescript@5.8.3)': + '@prisma/client@7.8.0(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.8.3))(typescript@5.8.3)': dependencies: '@prisma/client-runtime-utils': 7.8.0 optionalDependencies: - prisma: 7.8.0(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.4(react@19.1.4))(react@19.1.4)(typescript@5.8.3) + prisma: 7.8.0(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.8.3) typescript: 5.8.3 '@prisma/config@7.8.0': @@ -10134,13 +10266,13 @@ snapshots: env-paths: 3.0.0 proper-lockfile: 4.1.2 - '@prisma/studio-core@0.27.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.4(react@19.1.4))(react@19.1.4)': + '@prisma/studio-core@0.27.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: - '@radix-ui/react-toggle': 1.1.10(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.4(react@19.1.4))(react@19.1.4) - '@types/react': 19.1.8 + '@radix-ui/react-toggle': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@types/react': 19.2.15 chart.js: 4.5.1 - react: 19.1.4 - react-dom: 19.1.4(react@19.1.4) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) transitivePeerDependencies: - '@types/react-dom' @@ -10148,336 +10280,336 @@ snapshots: '@radix-ui/primitive@1.1.3': {} - '@radix-ui/react-alert-dialog@1.1.14(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.4(react@19.1.4))(react@19.1.4)': + '@radix-ui/react-alert-dialog@1.1.14(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@radix-ui/primitive': 1.1.2 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.8)(react@19.1.4) - '@radix-ui/react-context': 1.1.2(@types/react@19.1.8)(react@19.1.4) - '@radix-ui/react-dialog': 1.1.14(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.4(react@19.1.4))(react@19.1.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.4(react@19.1.4))(react@19.1.4) - '@radix-ui/react-slot': 1.2.3(@types/react@19.1.8)(react@19.1.4) - react: 19.1.4 - react-dom: 19.1.4(react@19.1.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-dialog': 1.1.14(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.15)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) optionalDependencies: - '@types/react': 19.1.8 - '@types/react-dom': 19.1.6(@types/react@19.1.8) + '@types/react': 19.2.15 + '@types/react-dom': 19.2.3(@types/react@19.2.15) - '@radix-ui/react-arrow@1.1.7(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.4(react@19.1.4))(react@19.1.4)': + '@radix-ui/react-arrow@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.4(react@19.1.4))(react@19.1.4) - react: 19.1.4 - react-dom: 19.1.4(react@19.1.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) optionalDependencies: - '@types/react': 19.1.8 - '@types/react-dom': 19.1.6(@types/react@19.1.8) + '@types/react': 19.2.15 + '@types/react-dom': 19.2.3(@types/react@19.2.15) - '@radix-ui/react-compose-refs@1.1.2(@types/react@19.1.8)(react@19.1.4)': + '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.15)(react@19.2.6)': dependencies: - react: 19.1.4 + react: 19.2.6 optionalDependencies: - '@types/react': 19.1.8 + '@types/react': 19.2.15 - '@radix-ui/react-context@1.1.2(@types/react@19.1.8)(react@19.1.4)': + '@radix-ui/react-context@1.1.2(@types/react@19.2.15)(react@19.2.6)': dependencies: - react: 19.1.4 + react: 19.2.6 optionalDependencies: - '@types/react': 19.1.8 + '@types/react': 19.2.15 - '@radix-ui/react-dialog@1.1.14(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.4(react@19.1.4))(react@19.1.4)': + '@radix-ui/react-dialog@1.1.14(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@radix-ui/primitive': 1.1.2 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.8)(react@19.1.4) - '@radix-ui/react-context': 1.1.2(@types/react@19.1.8)(react@19.1.4) - '@radix-ui/react-dismissable-layer': 1.1.10(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.4(react@19.1.4))(react@19.1.4) - '@radix-ui/react-focus-guards': 1.1.2(@types/react@19.1.8)(react@19.1.4) - '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.4(react@19.1.4))(react@19.1.4) - '@radix-ui/react-id': 1.1.1(@types/react@19.1.8)(react@19.1.4) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.4(react@19.1.4))(react@19.1.4) - '@radix-ui/react-presence': 1.1.4(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.4(react@19.1.4))(react@19.1.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.4(react@19.1.4))(react@19.1.4) - '@radix-ui/react-slot': 1.2.3(@types/react@19.1.8)(react@19.1.4) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.8)(react@19.1.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-dismissable-layer': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-focus-guards': 1.1.2(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-presence': 1.1.4(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.15)(react@19.2.6) aria-hidden: 1.2.6 - react: 19.1.4 - react-dom: 19.1.4(react@19.1.4) - react-remove-scroll: 2.7.1(@types/react@19.1.8)(react@19.1.4) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + react-remove-scroll: 2.7.1(@types/react@19.2.15)(react@19.2.6) optionalDependencies: - '@types/react': 19.1.8 - '@types/react-dom': 19.1.6(@types/react@19.1.8) + '@types/react': 19.2.15 + '@types/react-dom': 19.2.3(@types/react@19.2.15) - '@radix-ui/react-dialog@1.1.15(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.4(react@19.1.4))(react@19.1.4)': + '@radix-ui/react-dialog@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.8)(react@19.1.4) - '@radix-ui/react-context': 1.1.2(@types/react@19.1.8)(react@19.1.4) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.4(react@19.1.4))(react@19.1.4) - '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.1.8)(react@19.1.4) - '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.4(react@19.1.4))(react@19.1.4) - '@radix-ui/react-id': 1.1.1(@types/react@19.1.8)(react@19.1.4) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.4(react@19.1.4))(react@19.1.4) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.4(react@19.1.4))(react@19.1.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.4(react@19.1.4))(react@19.1.4) - '@radix-ui/react-slot': 1.2.3(@types/react@19.1.8)(react@19.1.4) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.8)(react@19.1.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.15)(react@19.2.6) aria-hidden: 1.2.6 - react: 19.1.4 - react-dom: 19.1.4(react@19.1.4) - react-remove-scroll: 2.7.1(@types/react@19.1.8)(react@19.1.4) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + react-remove-scroll: 2.7.1(@types/react@19.2.15)(react@19.2.6) optionalDependencies: - '@types/react': 19.1.8 - '@types/react-dom': 19.1.6(@types/react@19.1.8) + '@types/react': 19.2.15 + '@types/react-dom': 19.2.3(@types/react@19.2.15) - '@radix-ui/react-dismissable-layer@1.1.10(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.4(react@19.1.4))(react@19.1.4)': + '@radix-ui/react-dismissable-layer@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@radix-ui/primitive': 1.1.2 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.8)(react@19.1.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.4(react@19.1.4))(react@19.1.4) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.8)(react@19.1.4) - '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.1.8)(react@19.1.4) - react: 19.1.4 - react-dom: 19.1.4(react@19.1.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.15)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) optionalDependencies: - '@types/react': 19.1.8 - '@types/react-dom': 19.1.6(@types/react@19.1.8) + '@types/react': 19.2.15 + '@types/react-dom': 19.2.3(@types/react@19.2.15) - '@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.4(react@19.1.4))(react@19.1.4)': + '@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.8)(react@19.1.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.4(react@19.1.4))(react@19.1.4) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.8)(react@19.1.4) - '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.1.8)(react@19.1.4) - react: 19.1.4 - react-dom: 19.1.4(react@19.1.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.15)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) optionalDependencies: - '@types/react': 19.1.8 - '@types/react-dom': 19.1.6(@types/react@19.1.8) + '@types/react': 19.2.15 + '@types/react-dom': 19.2.3(@types/react@19.2.15) - '@radix-ui/react-focus-guards@1.1.2(@types/react@19.1.8)(react@19.1.4)': + '@radix-ui/react-focus-guards@1.1.2(@types/react@19.2.15)(react@19.2.6)': dependencies: - react: 19.1.4 + react: 19.2.6 optionalDependencies: - '@types/react': 19.1.8 + '@types/react': 19.2.15 - '@radix-ui/react-focus-guards@1.1.3(@types/react@19.1.8)(react@19.1.4)': + '@radix-ui/react-focus-guards@1.1.3(@types/react@19.2.15)(react@19.2.6)': dependencies: - react: 19.1.4 + react: 19.2.6 optionalDependencies: - '@types/react': 19.1.8 + '@types/react': 19.2.15 - '@radix-ui/react-focus-scope@1.1.7(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.4(react@19.1.4))(react@19.1.4)': + '@radix-ui/react-focus-scope@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.8)(react@19.1.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.4(react@19.1.4))(react@19.1.4) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.8)(react@19.1.4) - react: 19.1.4 - react-dom: 19.1.4(react@19.1.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.15)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) optionalDependencies: - '@types/react': 19.1.8 - '@types/react-dom': 19.1.6(@types/react@19.1.8) + '@types/react': 19.2.15 + '@types/react-dom': 19.2.3(@types/react@19.2.15) - '@radix-ui/react-id@1.1.1(@types/react@19.1.8)(react@19.1.4)': + '@radix-ui/react-id@1.1.1(@types/react@19.2.15)(react@19.2.6)': dependencies: - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.8)(react@19.1.4) - react: 19.1.4 + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.15)(react@19.2.6) + react: 19.2.6 optionalDependencies: - '@types/react': 19.1.8 + '@types/react': 19.2.15 - '@radix-ui/react-popover@1.1.14(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.4(react@19.1.4))(react@19.1.4)': + '@radix-ui/react-popover@1.1.14(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@radix-ui/primitive': 1.1.2 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.8)(react@19.1.4) - '@radix-ui/react-context': 1.1.2(@types/react@19.1.8)(react@19.1.4) - '@radix-ui/react-dismissable-layer': 1.1.10(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.4(react@19.1.4))(react@19.1.4) - '@radix-ui/react-focus-guards': 1.1.2(@types/react@19.1.8)(react@19.1.4) - '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.4(react@19.1.4))(react@19.1.4) - '@radix-ui/react-id': 1.1.1(@types/react@19.1.8)(react@19.1.4) - '@radix-ui/react-popper': 1.2.7(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.4(react@19.1.4))(react@19.1.4) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.4(react@19.1.4))(react@19.1.4) - '@radix-ui/react-presence': 1.1.4(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.4(react@19.1.4))(react@19.1.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.4(react@19.1.4))(react@19.1.4) - '@radix-ui/react-slot': 1.2.3(@types/react@19.1.8)(react@19.1.4) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.8)(react@19.1.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-dismissable-layer': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-focus-guards': 1.1.2(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-popper': 1.2.7(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-presence': 1.1.4(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.15)(react@19.2.6) aria-hidden: 1.2.6 - react: 19.1.4 - react-dom: 19.1.4(react@19.1.4) - react-remove-scroll: 2.7.1(@types/react@19.1.8)(react@19.1.4) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + react-remove-scroll: 2.7.1(@types/react@19.2.15)(react@19.2.6) optionalDependencies: - '@types/react': 19.1.8 - '@types/react-dom': 19.1.6(@types/react@19.1.8) - - '@radix-ui/react-popper@1.2.7(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.4(react@19.1.4))(react@19.1.4)': - dependencies: - '@floating-ui/react-dom': 2.1.8(react-dom@19.1.4(react@19.1.4))(react@19.1.4) - '@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.4(react@19.1.4))(react@19.1.4) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.8)(react@19.1.4) - '@radix-ui/react-context': 1.1.2(@types/react@19.1.8)(react@19.1.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.4(react@19.1.4))(react@19.1.4) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.8)(react@19.1.4) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.8)(react@19.1.4) - '@radix-ui/react-use-rect': 1.1.1(@types/react@19.1.8)(react@19.1.4) - '@radix-ui/react-use-size': 1.1.1(@types/react@19.1.8)(react@19.1.4) + '@types/react': 19.2.15 + '@types/react-dom': 19.2.3(@types/react@19.2.15) + + '@radix-ui/react-popper@1.2.7(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@floating-ui/react-dom': 2.1.8(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-use-rect': 1.1.1(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.15)(react@19.2.6) '@radix-ui/rect': 1.1.1 - react: 19.1.4 - react-dom: 19.1.4(react@19.1.4) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) optionalDependencies: - '@types/react': 19.1.8 - '@types/react-dom': 19.1.6(@types/react@19.1.8) + '@types/react': 19.2.15 + '@types/react-dom': 19.2.3(@types/react@19.2.15) - '@radix-ui/react-portal@1.1.9(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.4(react@19.1.4))(react@19.1.4)': + '@radix-ui/react-portal@1.1.9(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.4(react@19.1.4))(react@19.1.4) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.8)(react@19.1.4) - react: 19.1.4 - react-dom: 19.1.4(react@19.1.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.15)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) optionalDependencies: - '@types/react': 19.1.8 - '@types/react-dom': 19.1.6(@types/react@19.1.8) + '@types/react': 19.2.15 + '@types/react-dom': 19.2.3(@types/react@19.2.15) - '@radix-ui/react-presence@1.1.4(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.4(react@19.1.4))(react@19.1.4)': + '@radix-ui/react-presence@1.1.4(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.8)(react@19.1.4) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.8)(react@19.1.4) - react: 19.1.4 - react-dom: 19.1.4(react@19.1.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.15)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) optionalDependencies: - '@types/react': 19.1.8 - '@types/react-dom': 19.1.6(@types/react@19.1.8) + '@types/react': 19.2.15 + '@types/react-dom': 19.2.3(@types/react@19.2.15) - '@radix-ui/react-presence@1.1.5(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.4(react@19.1.4))(react@19.1.4)': + '@radix-ui/react-presence@1.1.5(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.8)(react@19.1.4) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.8)(react@19.1.4) - react: 19.1.4 - react-dom: 19.1.4(react@19.1.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.15)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) optionalDependencies: - '@types/react': 19.1.8 - '@types/react-dom': 19.1.6(@types/react@19.1.8) + '@types/react': 19.2.15 + '@types/react-dom': 19.2.3(@types/react@19.2.15) - '@radix-ui/react-primitive@2.1.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.4(react@19.1.4))(react@19.1.4)': + '@radix-ui/react-primitive@2.1.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: - '@radix-ui/react-slot': 1.2.3(@types/react@19.1.8)(react@19.1.4) - react: 19.1.4 - react-dom: 19.1.4(react@19.1.4) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.15)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) optionalDependencies: - '@types/react': 19.1.8 - '@types/react-dom': 19.1.6(@types/react@19.1.8) + '@types/react': 19.2.15 + '@types/react-dom': 19.2.3(@types/react@19.2.15) - '@radix-ui/react-slot@1.2.3(@types/react@19.1.8)(react@19.1.4)': + '@radix-ui/react-slot@1.2.3(@types/react@19.2.15)(react@19.2.6)': dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.8)(react@19.1.4) - react: 19.1.4 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.15)(react@19.2.6) + react: 19.2.6 optionalDependencies: - '@types/react': 19.1.8 + '@types/react': 19.2.15 - '@radix-ui/react-switch@1.2.6(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.4(react@19.1.4))(react@19.1.4)': + '@radix-ui/react-switch@1.2.6(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.8)(react@19.1.4) - '@radix-ui/react-context': 1.1.2(@types/react@19.1.8)(react@19.1.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.4(react@19.1.4))(react@19.1.4) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.8)(react@19.1.4) - '@radix-ui/react-use-previous': 1.1.1(@types/react@19.1.8)(react@19.1.4) - '@radix-ui/react-use-size': 1.1.1(@types/react@19.1.8)(react@19.1.4) - react: 19.1.4 - react-dom: 19.1.4(react@19.1.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.15)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) optionalDependencies: - '@types/react': 19.1.8 - '@types/react-dom': 19.1.6(@types/react@19.1.8) + '@types/react': 19.2.15 + '@types/react-dom': 19.2.3(@types/react@19.2.15) - '@radix-ui/react-toggle@1.1.10(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.4(react@19.1.4))(react@19.1.4)': + '@radix-ui/react-toggle@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.4(react@19.1.4))(react@19.1.4) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.8)(react@19.1.4) - react: 19.1.4 - react-dom: 19.1.4(react@19.1.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.15)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) optionalDependencies: - '@types/react': 19.1.8 - '@types/react-dom': 19.1.6(@types/react@19.1.8) + '@types/react': 19.2.15 + '@types/react-dom': 19.2.3(@types/react@19.2.15) - '@radix-ui/react-tooltip@1.2.7(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.4(react@19.1.4))(react@19.1.4)': + '@radix-ui/react-tooltip@1.2.7(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@radix-ui/primitive': 1.1.2 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.8)(react@19.1.4) - '@radix-ui/react-context': 1.1.2(@types/react@19.1.8)(react@19.1.4) - '@radix-ui/react-dismissable-layer': 1.1.10(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.4(react@19.1.4))(react@19.1.4) - '@radix-ui/react-id': 1.1.1(@types/react@19.1.8)(react@19.1.4) - '@radix-ui/react-popper': 1.2.7(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.4(react@19.1.4))(react@19.1.4) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.4(react@19.1.4))(react@19.1.4) - '@radix-ui/react-presence': 1.1.4(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.4(react@19.1.4))(react@19.1.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.4(react@19.1.4))(react@19.1.4) - '@radix-ui/react-slot': 1.2.3(@types/react@19.1.8)(react@19.1.4) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.8)(react@19.1.4) - '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.4(react@19.1.4))(react@19.1.4) - react: 19.1.4 - react-dom: 19.1.4(react@19.1.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-dismissable-layer': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-popper': 1.2.7(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-presence': 1.1.4(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) optionalDependencies: - '@types/react': 19.1.8 - '@types/react-dom': 19.1.6(@types/react@19.1.8) + '@types/react': 19.2.15 + '@types/react-dom': 19.2.3(@types/react@19.2.15) - '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.1.8)(react@19.1.4)': + '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.15)(react@19.2.6)': dependencies: - react: 19.1.4 + react: 19.2.6 optionalDependencies: - '@types/react': 19.1.8 + '@types/react': 19.2.15 - '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.1.8)(react@19.1.4)': + '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.15)(react@19.2.6)': dependencies: - '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.1.8)(react@19.1.4) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.8)(react@19.1.4) - react: 19.1.4 + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.15)(react@19.2.6) + react: 19.2.6 optionalDependencies: - '@types/react': 19.1.8 + '@types/react': 19.2.15 - '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.1.8)(react@19.1.4)': + '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.15)(react@19.2.6)': dependencies: - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.8)(react@19.1.4) - react: 19.1.4 + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.15)(react@19.2.6) + react: 19.2.6 optionalDependencies: - '@types/react': 19.1.8 + '@types/react': 19.2.15 - '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@19.1.8)(react@19.1.4)': + '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@19.2.15)(react@19.2.6)': dependencies: - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.8)(react@19.1.4) - react: 19.1.4 + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.15)(react@19.2.6) + react: 19.2.6 optionalDependencies: - '@types/react': 19.1.8 + '@types/react': 19.2.15 - '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.1.8)(react@19.1.4)': + '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.15)(react@19.2.6)': dependencies: - react: 19.1.4 + react: 19.2.6 optionalDependencies: - '@types/react': 19.1.8 + '@types/react': 19.2.15 - '@radix-ui/react-use-previous@1.1.1(@types/react@19.1.8)(react@19.1.4)': + '@radix-ui/react-use-previous@1.1.1(@types/react@19.2.15)(react@19.2.6)': dependencies: - react: 19.1.4 + react: 19.2.6 optionalDependencies: - '@types/react': 19.1.8 + '@types/react': 19.2.15 - '@radix-ui/react-use-rect@1.1.1(@types/react@19.1.8)(react@19.1.4)': + '@radix-ui/react-use-rect@1.1.1(@types/react@19.2.15)(react@19.2.6)': dependencies: '@radix-ui/rect': 1.1.1 - react: 19.1.4 + react: 19.2.6 optionalDependencies: - '@types/react': 19.1.8 + '@types/react': 19.2.15 - '@radix-ui/react-use-size@1.1.1(@types/react@19.1.8)(react@19.1.4)': + '@radix-ui/react-use-size@1.1.1(@types/react@19.2.15)(react@19.2.6)': dependencies: - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.8)(react@19.1.4) - react: 19.1.4 + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.15)(react@19.2.6) + react: 19.2.6 optionalDependencies: - '@types/react': 19.1.8 + '@types/react': 19.2.15 - '@radix-ui/react-visually-hidden@1.2.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.4(react@19.1.4))(react@19.1.4)': + '@radix-ui/react-visually-hidden@1.2.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.4(react@19.1.4))(react@19.1.4) - react: 19.1.4 - react-dom: 19.1.4(react@19.1.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) optionalDependencies: - '@types/react': 19.1.8 - '@types/react-dom': 19.1.6(@types/react@19.1.8) + '@types/react': 19.2.15 + '@types/react-dom': 19.2.3(@types/react@19.2.15) '@radix-ui/rect@1.1.1': {} @@ -10589,8 +10721,6 @@ snapshots: '@rtsao/scc@1.1.0': {} - '@rushstack/eslint-patch@1.12.0': {} - '@scalar/api-client@2.5.16(axios@1.16.0)(tailwindcss@4.1.11)(typescript@5.8.3)': dependencies: '@headlessui/tailwindcss': 0.2.2(tailwindcss@4.1.11) @@ -10644,11 +10774,11 @@ snapshots: - typescript - universal-cookie - '@scalar/api-reference-react@0.7.30(axios@1.16.0)(react@19.1.4)(tailwindcss@4.1.11)(typescript@5.8.3)': + '@scalar/api-reference-react@0.7.30(axios@1.16.0)(react@19.2.6)(tailwindcss@4.1.11)(typescript@5.8.3)': dependencies: '@scalar/api-reference': 1.32.6(axios@1.16.0)(tailwindcss@4.1.11)(typescript@5.8.3) '@scalar/types': 0.2.8 - react: 19.1.4 + react: 19.2.6 transitivePeerDependencies: - '@vue/composition-api' - async-validator @@ -11016,7 +11146,7 @@ snapshots: '@sentry/core@10.53.1': {} - '@sentry/nextjs@10.53.1(@opentelemetry/core@2.7.1(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.7.1(@opentelemetry/api@1.9.1))(next@15.5.18(@babel/core@7.27.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@19.1.4(react@19.1.4))(react@19.1.4))(react@19.1.4)(webpack@5.100.2)': + '@sentry/nextjs@10.53.1(@opentelemetry/core@2.7.1(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.7.1(@opentelemetry/api@1.9.1))(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)(webpack@5.100.2)': dependencies: '@opentelemetry/api': 1.9.1 '@opentelemetry/semantic-conventions': 1.40.0 @@ -11026,10 +11156,10 @@ snapshots: '@sentry/core': 10.53.1 '@sentry/node': 10.53.1 '@sentry/opentelemetry': 10.53.1(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.7.1(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.7.1(@opentelemetry/api@1.9.1))(@opentelemetry/semantic-conventions@1.40.0) - '@sentry/react': 10.53.1(react@19.1.4) + '@sentry/react': 10.53.1(react@19.2.6) '@sentry/vercel-edge': 10.53.1 '@sentry/webpack-plugin': 5.3.0(webpack@5.100.2) - next: 15.5.18(@babel/core@7.27.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@19.1.4(react@19.1.4))(react@19.1.4) + 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) rollup: 4.60.3 stacktrace-parser: 0.1.11 transitivePeerDependencies: @@ -11096,11 +11226,11 @@ snapshots: '@opentelemetry/semantic-conventions': 1.40.0 '@sentry/core': 10.53.1 - '@sentry/react@10.53.1(react@19.1.4)': + '@sentry/react@10.53.1(react@19.2.6)': dependencies: '@sentry/browser': 10.53.1 '@sentry/core': 10.53.1 - react: 19.1.4 + react: 19.2.6 '@sentry/vercel-edge@10.53.1': dependencies: @@ -11641,10 +11771,10 @@ snapshots: '@tanstack/query-core@5.100.10': {} - '@tanstack/react-query@5.100.10(react@19.1.4)': + '@tanstack/react-query@5.100.10(react@19.2.6)': dependencies: '@tanstack/query-core': 5.100.10 - react: 19.1.4 + react: 19.2.6 '@tanstack/virtual-core@3.13.12': {} @@ -11673,15 +11803,15 @@ snapshots: picocolors: 1.1.1 redent: 3.0.0 - '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.4(react@19.1.4))(react@19.1.4)': + '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@babel/runtime': 7.27.6 '@testing-library/dom': 10.4.1 - react: 19.1.4 - react-dom: 19.1.4(react@19.1.4) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) optionalDependencies: - '@types/react': 19.1.8 - '@types/react-dom': 19.1.6(@types/react@19.1.8) + '@types/react': 19.2.15 + '@types/react-dom': 19.2.3(@types/react@19.2.15) '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)': dependencies: @@ -11692,24 +11822,24 @@ snapshots: '@trpc/server': 11.17.0(typescript@5.8.3) typescript: 5.8.3 - '@trpc/next@11.17.0(@tanstack/react-query@5.100.10(react@19.1.4))(@trpc/client@11.17.0(@trpc/server@11.17.0(typescript@5.8.3))(typescript@5.8.3))(@trpc/react-query@11.17.0(@tanstack/react-query@5.100.10(react@19.1.4))(@trpc/client@11.17.0(@trpc/server@11.17.0(typescript@5.8.3))(typescript@5.8.3))(@trpc/server@11.17.0(typescript@5.8.3))(react@19.1.4)(typescript@5.8.3))(@trpc/server@11.17.0(typescript@5.8.3))(next@15.5.18(@babel/core@7.27.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@19.1.4(react@19.1.4))(react@19.1.4))(react-dom@19.1.4(react@19.1.4))(react@19.1.4)(typescript@5.8.3)': + '@trpc/next@11.17.0(@tanstack/react-query@5.100.10(react@19.2.6))(@trpc/client@11.17.0(@trpc/server@11.17.0(typescript@5.8.3))(typescript@5.8.3))(@trpc/react-query@11.17.0(@tanstack/react-query@5.100.10(react@19.2.6))(@trpc/client@11.17.0(@trpc/server@11.17.0(typescript@5.8.3))(typescript@5.8.3))(@trpc/server@11.17.0(typescript@5.8.3))(react@19.2.6)(typescript@5.8.3))(@trpc/server@11.17.0(typescript@5.8.3))(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-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.8.3)': dependencies: '@trpc/client': 11.17.0(@trpc/server@11.17.0(typescript@5.8.3))(typescript@5.8.3) '@trpc/server': 11.17.0(typescript@5.8.3) - next: 15.5.18(@babel/core@7.27.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@19.1.4(react@19.1.4))(react@19.1.4) - react: 19.1.4 - react-dom: 19.1.4(react@19.1.4) + 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 + react-dom: 19.2.6(react@19.2.6) typescript: 5.8.3 optionalDependencies: - '@tanstack/react-query': 5.100.10(react@19.1.4) - '@trpc/react-query': 11.17.0(@tanstack/react-query@5.100.10(react@19.1.4))(@trpc/client@11.17.0(@trpc/server@11.17.0(typescript@5.8.3))(typescript@5.8.3))(@trpc/server@11.17.0(typescript@5.8.3))(react@19.1.4)(typescript@5.8.3) + '@tanstack/react-query': 5.100.10(react@19.2.6) + '@trpc/react-query': 11.17.0(@tanstack/react-query@5.100.10(react@19.2.6))(@trpc/client@11.17.0(@trpc/server@11.17.0(typescript@5.8.3))(typescript@5.8.3))(@trpc/server@11.17.0(typescript@5.8.3))(react@19.2.6)(typescript@5.8.3) - '@trpc/react-query@11.17.0(@tanstack/react-query@5.100.10(react@19.1.4))(@trpc/client@11.17.0(@trpc/server@11.17.0(typescript@5.8.3))(typescript@5.8.3))(@trpc/server@11.17.0(typescript@5.8.3))(react@19.1.4)(typescript@5.8.3)': + '@trpc/react-query@11.17.0(@tanstack/react-query@5.100.10(react@19.2.6))(@trpc/client@11.17.0(@trpc/server@11.17.0(typescript@5.8.3))(typescript@5.8.3))(@trpc/server@11.17.0(typescript@5.8.3))(react@19.2.6)(typescript@5.8.3)': dependencies: - '@tanstack/react-query': 5.100.10(react@19.1.4) + '@tanstack/react-query': 5.100.10(react@19.2.6) '@trpc/client': 11.17.0(@trpc/server@11.17.0(typescript@5.8.3))(typescript@5.8.3) '@trpc/server': 11.17.0(typescript@5.8.3) - react: 19.1.4 + react: 19.2.6 typescript: 5.8.3 '@trpc/server@11.17.0(typescript@5.8.3)': @@ -11843,9 +11973,9 @@ snapshots: pg-protocol: 1.14.0 pg-types: 2.2.0 - '@types/react-dom@19.1.6(@types/react@19.1.8)': + '@types/react-dom@19.2.3(@types/react@19.2.15)': dependencies: - '@types/react': 19.1.8 + '@types/react': 19.2.15 '@types/react-syntax-highlighter@15.5.13': dependencies: @@ -11855,6 +11985,10 @@ snapshots: dependencies: csstype: 3.1.3 + '@types/react@19.2.15': + dependencies: + csstype: 3.2.3 + '@types/swagger-jsdoc@6.0.4': {} '@types/tedious@4.0.14': @@ -11917,6 +12051,22 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/eslint-plugin@8.59.4(@typescript-eslint/parser@8.59.4(eslint@9.29.0(jiti@2.7.0))(typescript@5.8.3))(eslint@9.29.0(jiti@2.7.0))(typescript@5.8.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.59.4(eslint@9.29.0(jiti@2.7.0))(typescript@5.8.3) + '@typescript-eslint/scope-manager': 8.59.4 + '@typescript-eslint/type-utils': 8.59.4(eslint@9.29.0(jiti@2.7.0))(typescript@5.8.3) + '@typescript-eslint/utils': 8.59.4(eslint@9.29.0(jiti@2.7.0))(typescript@5.8.3) + '@typescript-eslint/visitor-keys': 8.59.4 + eslint: 9.29.0(jiti@2.7.0) + ignore: 7.0.5 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@5.8.3) + typescript: 5.8.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/parser@8.35.0(eslint@9.29.0(jiti@2.7.0))(typescript@5.8.3)': dependencies: '@typescript-eslint/scope-manager': 8.35.0 @@ -11941,6 +12091,18 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/parser@8.59.4(eslint@9.29.0(jiti@2.7.0))(typescript@5.8.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.59.4 + '@typescript-eslint/types': 8.59.4 + '@typescript-eslint/typescript-estree': 8.59.4(typescript@5.8.3) + '@typescript-eslint/visitor-keys': 8.59.4 + debug: 4.4.3 + eslint: 9.29.0(jiti@2.7.0) + typescript: 5.8.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/project-service@8.35.0(typescript@5.8.3)': dependencies: '@typescript-eslint/tsconfig-utils': 8.39.0(typescript@5.8.3) @@ -11959,6 +12121,15 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/project-service@8.59.4(typescript@5.8.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.59.4(typescript@5.8.3) + '@typescript-eslint/types': 8.59.4 + debug: 4.4.3 + typescript: 5.8.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/scope-manager@8.35.0': dependencies: '@typescript-eslint/types': 8.35.0 @@ -11969,6 +12140,11 @@ snapshots: '@typescript-eslint/types': 8.39.0 '@typescript-eslint/visitor-keys': 8.39.0 + '@typescript-eslint/scope-manager@8.59.4': + dependencies: + '@typescript-eslint/types': 8.59.4 + '@typescript-eslint/visitor-keys': 8.59.4 + '@typescript-eslint/tsconfig-utils@8.35.0(typescript@5.8.3)': dependencies: typescript: 5.8.3 @@ -11977,6 +12153,10 @@ snapshots: dependencies: typescript: 5.8.3 + '@typescript-eslint/tsconfig-utils@8.59.4(typescript@5.8.3)': + dependencies: + typescript: 5.8.3 + '@typescript-eslint/type-utils@8.35.0(eslint@9.29.0(jiti@2.7.0))(typescript@5.8.3)': dependencies: '@typescript-eslint/typescript-estree': 8.35.0(typescript@5.8.3) @@ -12000,10 +12180,24 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/type-utils@8.59.4(eslint@9.29.0(jiti@2.7.0))(typescript@5.8.3)': + dependencies: + '@typescript-eslint/types': 8.59.4 + '@typescript-eslint/typescript-estree': 8.59.4(typescript@5.8.3) + '@typescript-eslint/utils': 8.59.4(eslint@9.29.0(jiti@2.7.0))(typescript@5.8.3) + debug: 4.4.3 + eslint: 9.29.0(jiti@2.7.0) + ts-api-utils: 2.5.0(typescript@5.8.3) + typescript: 5.8.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/types@8.35.0': {} '@typescript-eslint/types@8.39.0': {} + '@typescript-eslint/types@8.59.4': {} + '@typescript-eslint/typescript-estree@8.35.0(typescript@5.8.3)': dependencies: '@typescript-eslint/project-service': 8.35.0(typescript@5.8.3) @@ -12036,6 +12230,21 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/typescript-estree@8.59.4(typescript@5.8.3)': + dependencies: + '@typescript-eslint/project-service': 8.59.4(typescript@5.8.3) + '@typescript-eslint/tsconfig-utils': 8.59.4(typescript@5.8.3) + '@typescript-eslint/types': 8.59.4 + '@typescript-eslint/visitor-keys': 8.59.4 + debug: 4.4.3 + minimatch: 10.2.5 + semver: 7.7.3 + tinyglobby: 0.2.15 + ts-api-utils: 2.5.0(typescript@5.8.3) + typescript: 5.8.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/utils@8.35.0(eslint@9.29.0(jiti@2.7.0))(typescript@5.8.3)': dependencies: '@eslint-community/eslint-utils': 4.7.0(eslint@9.29.0(jiti@2.7.0)) @@ -12058,6 +12267,17 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/utils@8.59.4(eslint@9.29.0(jiti@2.7.0))(typescript@5.8.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.29.0(jiti@2.7.0)) + '@typescript-eslint/scope-manager': 8.59.4 + '@typescript-eslint/types': 8.59.4 + '@typescript-eslint/typescript-estree': 8.59.4(typescript@5.8.3) + eslint: 9.29.0(jiti@2.7.0) + typescript: 5.8.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/visitor-keys@8.35.0': dependencies: '@typescript-eslint/types': 8.35.0 @@ -12068,6 +12288,11 @@ snapshots: '@typescript-eslint/types': 8.39.0 eslint-visitor-keys: 4.2.1 + '@typescript-eslint/visitor-keys@8.59.4': + dependencies: + '@typescript-eslint/types': 8.59.4 + eslint-visitor-keys: 5.0.1 + '@ungap/structured-clone@1.3.0': {} '@unhead/dom@1.11.20': @@ -12152,17 +12377,17 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.9.2': optional: true - '@vercel/analytics@1.5.0(next@15.5.18(@babel/core@7.27.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@19.1.4(react@19.1.4))(react@19.1.4))(react@19.1.4)(vue-router@4.5.1(vue@3.5.17(typescript@5.8.3)))(vue@3.5.17(typescript@5.8.3))': + '@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: 15.5.18(@babel/core@7.27.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@19.1.4(react@19.1.4))(react@19.1.4) - react: 19.1.4 + 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@15.5.18(@babel/core@7.27.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@19.1.4(react@19.1.4))(react@19.1.4))(react@19.1.4)(vue-router@4.5.1(vue@3.5.17(typescript@5.8.3)))(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: 15.5.18(@babel/core@7.27.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@19.1.4(react@19.1.4))(react@19.1.4) - react: 19.1.4 + 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)) @@ -12619,6 +12844,8 @@ snapshots: balanced-match@4.0.4: {} + baseline-browser-mapping@2.10.31: {} + better-result@2.9.2: {} binary-extensions@2.3.0: {} @@ -12759,14 +12986,14 @@ snapshots: clsx@2.1.1: {} - cmdk@1.1.1(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.4(react@19.1.4))(react@19.1.4): + cmdk@1.1.1(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6): dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.8)(react@19.1.4) - '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.4(react@19.1.4))(react@19.1.4) - '@radix-ui/react-id': 1.1.1(@types/react@19.1.8)(react@19.1.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.4(react@19.1.4))(react@19.1.4) - react: 19.1.4 - react-dom: 19.1.4(react@19.1.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) transitivePeerDependencies: - '@types/react' - '@types/react-dom' @@ -12906,6 +13133,8 @@ snapshots: csstype@3.1.3: {} + csstype@3.2.3: {} + cva@1.0.0-beta.2(typescript@5.8.3): dependencies: clsx: 2.1.1 @@ -12953,6 +13182,10 @@ snapshots: optionalDependencies: supports-color: 5.5.0 + debug@4.4.3: + dependencies: + ms: 2.1.3 + decimal.js@10.5.0: {} decode-named-character-reference@1.2.0: @@ -13232,22 +13465,22 @@ snapshots: escape-string-regexp@5.0.0: {} - eslint-config-next@15.5.18(eslint@9.29.0(jiti@2.7.0))(typescript@5.8.3): + eslint-config-next@16.2.6(@typescript-eslint/parser@8.39.0(eslint@9.29.0(jiti@2.7.0))(typescript@5.8.3))(eslint@9.29.0(jiti@2.7.0))(typescript@5.8.3): dependencies: - '@next/eslint-plugin-next': 15.5.18 - '@rushstack/eslint-patch': 1.12.0 - '@typescript-eslint/eslint-plugin': 8.39.0(@typescript-eslint/parser@8.39.0(eslint@9.29.0(jiti@2.7.0))(typescript@5.8.3))(eslint@9.29.0(jiti@2.7.0))(typescript@5.8.3) - '@typescript-eslint/parser': 8.39.0(eslint@9.29.0(jiti@2.7.0))(typescript@5.8.3) + '@next/eslint-plugin-next': 16.2.6 eslint: 9.29.0(jiti@2.7.0) eslint-import-resolver-node: 0.3.9 eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.29.0(jiti@2.7.0)) eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.39.0(eslint@9.29.0(jiti@2.7.0))(typescript@5.8.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.29.0(jiti@2.7.0)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.29.0(jiti@2.7.0)) eslint-plugin-react: 7.37.5(eslint@9.29.0(jiti@2.7.0)) - eslint-plugin-react-hooks: 5.2.0(eslint@9.29.0(jiti@2.7.0)) + eslint-plugin-react-hooks: 7.1.1(eslint@9.29.0(jiti@2.7.0)) + globals: 16.4.0 + typescript-eslint: 8.59.4(eslint@9.29.0(jiti@2.7.0))(typescript@5.8.3) optionalDependencies: typescript: 5.8.3 transitivePeerDependencies: + - '@typescript-eslint/parser' - eslint-import-resolver-webpack - eslint-plugin-import-x - supports-color @@ -13404,6 +13637,17 @@ snapshots: dependencies: eslint: 9.29.0(jiti@2.7.0) + eslint-plugin-react-hooks@7.1.1(eslint@9.29.0(jiti@2.7.0)): + dependencies: + '@babel/core': 7.27.7 + '@babel/parser': 7.27.7 + eslint: 9.29.0(jiti@2.7.0) + hermes-parser: 0.25.1 + zod: 3.25.67 + zod-validation-error: 4.0.2(zod@3.25.67) + transitivePeerDependencies: + - supports-color + eslint-plugin-react@7.37.5(eslint@9.29.0(jiti@2.7.0)): dependencies: array-includes: 3.1.9 @@ -13440,6 +13684,8 @@ snapshots: eslint-visitor-keys@4.2.1: {} + eslint-visitor-keys@5.0.1: {} + eslint@9.29.0(jiti@2.7.0): dependencies: '@eslint-community/eslint-utils': 4.7.0(eslint@9.29.0(jiti@2.7.0)) @@ -13620,14 +13866,14 @@ snapshots: forwarded-parse@2.1.2: {} - framer-motion@12.19.2(react-dom@19.1.4(react@19.1.4))(react@19.1.4): + framer-motion@12.19.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6): dependencies: motion-dom: 12.19.0 motion-utils: 12.19.0 tslib: 2.8.1 optionalDependencies: - react: 19.1.4 - react-dom: 19.1.4(react@19.1.4) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) franc-min@6.2.0: dependencies: @@ -13746,6 +13992,8 @@ snapshots: globals@14.0.0: {} + globals@16.4.0: {} + globalthis@1.0.4: dependencies: define-properties: 1.2.1 @@ -13933,6 +14181,12 @@ snapshots: property-information: 7.1.0 space-separated-tokens: 2.0.2 + hermes-estree@0.25.1: {} + + hermes-parser@0.25.1: + dependencies: + hermes-estree: 0.25.1 + highlight.js@10.7.3: {} highlight.js@11.11.1: {} @@ -14455,9 +14709,9 @@ snapshots: lru.min@1.1.4: {} - lucide-react@0.511.0(react@19.1.4): + lucide-react@0.511.0(react@19.2.6): dependencies: - react: 19.1.4 + react: 19.2.6 lz-string@1.5.0: {} @@ -14891,29 +15145,30 @@ snapshots: neo-async@2.6.2: {} - next-themes@0.4.6(react-dom@19.1.4(react@19.1.4))(react@19.1.4): + next-themes@0.4.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6): dependencies: - react: 19.1.4 - react-dom: 19.1.4(react@19.1.4) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) - next@15.5.18(@babel/core@7.27.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@19.1.4(react@19.1.4))(react@19.1.4): + 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): dependencies: - '@next/env': 15.5.18 + '@next/env': 16.2.6 '@swc/helpers': 0.5.15 + baseline-browser-mapping: 2.10.31 caniuse-lite: 1.0.30001726 postcss: 8.4.31 - react: 19.1.4 - react-dom: 19.1.4(react@19.1.4) - styled-jsx: 5.1.6(@babel/core@7.27.7)(react@19.1.4) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + styled-jsx: 5.1.6(@babel/core@7.27.7)(react@19.2.6) optionalDependencies: - '@next/swc-darwin-arm64': 15.5.18 - '@next/swc-darwin-x64': 15.5.18 - '@next/swc-linux-arm64-gnu': 15.5.18 - '@next/swc-linux-arm64-musl': 15.5.18 - '@next/swc-linux-x64-gnu': 15.5.18 - '@next/swc-linux-x64-musl': 15.5.18 - '@next/swc-win32-arm64-msvc': 15.5.18 - '@next/swc-win32-x64-msvc': 15.5.18 + '@next/swc-darwin-arm64': 16.2.6 + '@next/swc-darwin-x64': 16.2.6 + '@next/swc-linux-arm64-gnu': 16.2.6 + '@next/swc-linux-arm64-musl': 16.2.6 + '@next/swc-linux-x64-gnu': 16.2.6 + '@next/swc-linux-x64-musl': 16.2.6 + '@next/swc-win32-arm64-msvc': 16.2.6 + '@next/swc-win32-x64-msvc': 16.2.6 '@opentelemetry/api': 1.9.1 '@playwright/test': 1.60.0 sharp: 0.34.5 @@ -15197,12 +15452,12 @@ snapshots: dependencies: parse-ms: 3.0.0 - prisma@7.8.0(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.4(react@19.1.4))(react@19.1.4)(typescript@5.8.3): + prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.8.3): dependencies: '@prisma/config': 7.8.0 '@prisma/dev': 0.24.3(typescript@5.8.3) '@prisma/engines': 7.8.0 - '@prisma/studio-core': 0.27.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.4(react@19.1.4))(react@19.1.4) + '@prisma/studio-core': 0.27.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) mysql2: 3.15.3 postgres: 3.4.7 optionalDependencies: @@ -15286,25 +15541,25 @@ snapshots: defu: 6.1.7 destr: 2.0.5 - react-dom@19.1.4(react@19.1.4): + react-dom@19.2.6(react@19.2.6): dependencies: - react: 19.1.4 - scheduler: 0.26.0 + react: 19.2.6 + scheduler: 0.27.0 - react-error-boundary@6.0.0(react@19.1.4): + react-error-boundary@6.0.0(react@19.2.6): dependencies: '@babel/runtime': 7.27.6 - react: 19.1.4 + react: 19.2.6 - react-google-recaptcha-v3@1.11.0(react-dom@19.1.4(react@19.1.4))(react@19.1.4): + react-google-recaptcha-v3@1.11.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6): dependencies: hoist-non-react-statics: 3.3.2 - react: 19.1.4 - react-dom: 19.1.4(react@19.1.4) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) - react-hook-form@7.58.1(react@19.1.4): + react-hook-form@7.58.1(react@19.2.6): dependencies: - react: 19.1.4 + react: 19.2.6 react-is@16.13.1: {} @@ -15312,44 +15567,44 @@ snapshots: react-refresh@0.17.0: {} - react-remove-scroll-bar@2.3.8(@types/react@19.1.8)(react@19.1.4): + react-remove-scroll-bar@2.3.8(@types/react@19.2.15)(react@19.2.6): dependencies: - react: 19.1.4 - react-style-singleton: 2.2.3(@types/react@19.1.8)(react@19.1.4) + react: 19.2.6 + react-style-singleton: 2.2.3(@types/react@19.2.15)(react@19.2.6) tslib: 2.8.1 optionalDependencies: - '@types/react': 19.1.8 + '@types/react': 19.2.15 - react-remove-scroll@2.7.1(@types/react@19.1.8)(react@19.1.4): + react-remove-scroll@2.7.1(@types/react@19.2.15)(react@19.2.6): dependencies: - react: 19.1.4 - react-remove-scroll-bar: 2.3.8(@types/react@19.1.8)(react@19.1.4) - react-style-singleton: 2.2.3(@types/react@19.1.8)(react@19.1.4) + react: 19.2.6 + react-remove-scroll-bar: 2.3.8(@types/react@19.2.15)(react@19.2.6) + react-style-singleton: 2.2.3(@types/react@19.2.15)(react@19.2.6) tslib: 2.8.1 - use-callback-ref: 1.3.3(@types/react@19.1.8)(react@19.1.4) - use-sidecar: 1.1.3(@types/react@19.1.8)(react@19.1.4) + use-callback-ref: 1.3.3(@types/react@19.2.15)(react@19.2.6) + use-sidecar: 1.1.3(@types/react@19.2.15)(react@19.2.6) optionalDependencies: - '@types/react': 19.1.8 + '@types/react': 19.2.15 - react-style-singleton@2.2.3(@types/react@19.1.8)(react@19.1.4): + react-style-singleton@2.2.3(@types/react@19.2.15)(react@19.2.6): dependencies: get-nonce: 1.0.1 - react: 19.1.4 + react: 19.2.6 tslib: 2.8.1 optionalDependencies: - '@types/react': 19.1.8 + '@types/react': 19.2.15 - react-syntax-highlighter@15.6.6(react@19.1.4): + react-syntax-highlighter@15.6.6(react@19.2.6): dependencies: '@babel/runtime': 7.27.6 highlight.js: 10.7.3 highlightjs-vue: 1.0.0 lowlight: 1.20.0 prismjs: 1.30.0 - react: 19.1.4 + react: 19.2.6 refractor: 3.6.0 - react@19.1.4: {} + react@19.2.6: {} readdirp@3.6.0: dependencies: @@ -15588,7 +15843,7 @@ snapshots: dependencies: xmlchars: 2.2.0 - scheduler@0.26.0: {} + scheduler@0.27.0: {} schema-utils@4.3.2: dependencies: @@ -15762,10 +16017,10 @@ snapshots: dot-case: 3.0.4 tslib: 2.8.1 - sonner@2.0.5(react-dom@19.1.4(react@19.1.4))(react@19.1.4): + sonner@2.0.5(react-dom@19.2.6(react@19.2.6))(react@19.2.6): dependencies: - react: 19.1.4 - react-dom: 19.1.4(react@19.1.4) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) source-map-js@1.2.1: {} @@ -15895,10 +16150,10 @@ snapshots: style-mod@4.1.2: {} - styled-jsx@5.1.6(@babel/core@7.27.7)(react@19.1.4): + styled-jsx@5.1.6(@babel/core@7.27.7)(react@19.2.6): dependencies: client-only: 0.0.1 - react: 19.1.4 + react: 19.2.6 optionalDependencies: '@babel/core': 7.27.7 @@ -15968,11 +16223,11 @@ snapshots: transitivePeerDependencies: - openapi-types - swr@2.3.4(react@19.1.4): + swr@2.3.4(react@19.2.6): dependencies: dequal: 2.0.3 - react: 19.1.4 - use-sync-external-store: 1.6.0(react@19.1.4) + react: 19.2.6 + use-sync-external-store: 1.6.0(react@19.2.6) symbol-tree@3.2.4: {} @@ -16065,6 +16320,10 @@ snapshots: dependencies: typescript: 5.8.3 + ts-api-utils@2.5.0(typescript@5.8.3): + dependencies: + typescript: 5.8.3 + ts-deepmerge@7.0.3: {} ts-node@10.9.2(@types/node@20.19.1)(typescript@5.8.3): @@ -16158,6 +16417,17 @@ snapshots: transitivePeerDependencies: - supports-color + typescript-eslint@8.59.4(eslint@9.29.0(jiti@2.7.0))(typescript@5.8.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.59.4(@typescript-eslint/parser@8.59.4(eslint@9.29.0(jiti@2.7.0))(typescript@5.8.3))(eslint@9.29.0(jiti@2.7.0))(typescript@5.8.3) + '@typescript-eslint/parser': 8.59.4(eslint@9.29.0(jiti@2.7.0))(typescript@5.8.3) + '@typescript-eslint/typescript-estree': 8.59.4(typescript@5.8.3) + '@typescript-eslint/utils': 8.59.4(eslint@9.29.0(jiti@2.7.0))(typescript@5.8.3) + eslint: 9.29.0(jiti@2.7.0) + typescript: 5.8.3 + transitivePeerDependencies: + - supports-color + typescript@5.8.3: {} uc.micro@2.1.0: {} @@ -16270,24 +16540,24 @@ snapshots: querystringify: 2.2.0 requires-port: 1.0.0 - use-callback-ref@1.3.3(@types/react@19.1.8)(react@19.1.4): + use-callback-ref@1.3.3(@types/react@19.2.15)(react@19.2.6): dependencies: - react: 19.1.4 + react: 19.2.6 tslib: 2.8.1 optionalDependencies: - '@types/react': 19.1.8 + '@types/react': 19.2.15 - use-sidecar@1.1.3(@types/react@19.1.8)(react@19.1.4): + use-sidecar@1.1.3(@types/react@19.2.15)(react@19.2.6): dependencies: detect-node-es: 1.1.0 - react: 19.1.4 + react: 19.2.6 tslib: 2.8.1 optionalDependencies: - '@types/react': 19.1.8 + '@types/react': 19.2.15 - use-sync-external-store@1.6.0(react@19.1.4): + use-sync-external-store@1.6.0(react@19.2.6): dependencies: - react: 19.1.4 + react: 19.2.6 util-deprecate@1.0.2: {} @@ -16587,6 +16857,10 @@ snapshots: dependencies: zod: 3.25.67 + zod-validation-error@4.0.2(zod@3.25.67): + dependencies: + zod: 3.25.67 + zod@3.24.1: {} zod@3.25.67: {} diff --git a/src/app/admin/monitoring/components/MonitoringOverview.tsx b/src/app/admin/monitoring/components/MonitoringOverview.tsx deleted file mode 100644 index 0bfe643e7..000000000 --- a/src/app/admin/monitoring/components/MonitoringOverview.tsx +++ /dev/null @@ -1,73 +0,0 @@ -'use client' - -import { Activity, Database, Gauge, TrendingUp } from 'lucide-react' -import { useEffect, useState } from 'react' -import { Card } from '@/components/ui' -import { seoMetrics, exportMetrics } from '@/lib/monitoring/seo-metrics' - -export function MonitoringOverview() { - const [metrics, setMetrics] = useState({ - cacheHitRate: 0, - avgResponseTime: 0, - bundleSize: 0, - performanceScore: 0, - }) - - useEffect(() => { - const updateMetrics = () => { - const seoData = seoMetrics.getAggregatedMetrics() - const exportData = exportMetrics() - - setMetrics({ - cacheHitRate: seoData.cacheHitRate, - avgResponseTime: seoData.avgTotalTime, - bundleSize: exportData.cache.totalSize, - performanceScore: Math.round(100 - seoData.avgTotalTime / 10), // Simple calculation for now - }) - } - - updateMetrics() - const interval = setInterval(updateMetrics, 5000) - return () => clearInterval(interval) - }, []) - - return ( -
- -
-

Cache Hit Rate

- -
-
{metrics.cacheHitRate.toFixed(1)}%
-

Real-time data

-
- - -
-

Avg Response Time

- -
-
{metrics.avgResponseTime}ms
-

Real-time data

-
- - -
-

Cache Size

- -
-
{(metrics.bundleSize / 1024).toFixed(1)} KB
-

Total cache storage

-
- - -
-

Performance Score

- -
-
{metrics.performanceScore}/100
-

Based on response time

-
-
- ) -} diff --git a/src/app/admin/monitoring/components/SEOMetricsDashboard.tsx b/src/app/admin/monitoring/components/SEOMetricsDashboard.tsx deleted file mode 100644 index 3d0e86120..000000000 --- a/src/app/admin/monitoring/components/SEOMetricsDashboard.tsx +++ /dev/null @@ -1,247 +0,0 @@ -'use client' - -import { BarChart as BarChartIcon, Clock, Database, TrendingUp } from 'lucide-react' -import { useState, useEffect } from 'react' -import { Card, Button, LineChart, DonutChart } from '@/components/ui' -import { exportMetrics, seoMetrics } from '@/lib/monitoring/seo-metrics' -import { cn } from '@/lib/utils' -import { ms } from '@/utils/time' - -export function SEOMetricsDashboard() { - const [metrics, setMetrics] = useState(seoMetrics.getAggregatedMetrics()) - const [exportData, setExportData] = useState(exportMetrics()) - const [timeSeriesData, setTimeSeriesData] = useState<{ x: number; y: number; label: string }[]>( - [], - ) - - useEffect(() => { - const updateMetrics = () => { - const newMetrics = seoMetrics.getAggregatedMetrics() - const newExportData = exportMetrics() - - setMetrics(newMetrics) - setExportData(newExportData) - - // Update time series data for response time chart - setTimeSeriesData((prev) => { - const now = Date.now() - const newPoint = { - x: now, - y: newMetrics.avgTotalTime, - label: `${formatMs(newMetrics.avgTotalTime)} at ${new Date(now).toLocaleTimeString()}`, - } - - // Keep only last 20 points - return [...prev, newPoint].slice(-20) - }) - } - - updateMetrics() - - const interval = setInterval(updateMetrics, ms.seconds(30)) - - return () => { - clearInterval(interval) - } - }, []) - - const formatMs = (ms: number) => { - if (ms < 1000) return `${ms}ms` - return `${(ms / 1000).toFixed(2)}s` - } - - const formatBytes = (bytes: number) => { - if (bytes < 1024) return `${bytes} B` - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB` - return `${(bytes / (1024 * 1024)).toFixed(1)} MB` - } - - return ( - -
-
-
-

SEO Metrics

-

Real-time performance monitoring

-
- -
-
-
- {/* Performance Overview */} -
-
-
- - Average Response Time -
-

{formatMs(metrics.avgTotalTime)}

-
- Cache: {formatMs(metrics.avgCacheLookupTime)} - DB: {formatMs(metrics.avgDatabaseQueryTime)} -
-
- -
-
- - Cache Performance -
-
- -
-
- Stale: {metrics.staleServeRate.toFixed(1)}% -
-
Sample: {metrics.sampleSize}
-
-
-
-
- - {/* Response Time Trend */} - {timeSeriesData.length > 1 && ( -
-
- - Response Time Trend (Last 10 minutes) -
-
- -
-
- )} - - {/* Cache Details */} -
-
- - Cache Storage Details -
-
-
-
- Total Size - {formatBytes(exportData.cache.totalSize)} -
-
- Total Entries - {exportData.cache.totalEntries} -
-
- Average Entry Size - {formatBytes(exportData.cache.averageSize)} -
-
- Oldest Entry Age - {formatMs(exportData.cache.oldestEntry)} -
-
-
-
- - {/* Response Time Statistics */} -
-
- - Response Time Distribution -
-
-
-
-
-
Average
-
{formatMs(metrics.avgTotalTime)}
-
-
-
Median
-
{formatMs(metrics.medianResponseTime)}
-
-
-
P95
-
{formatMs(metrics.p95ResponseTime)}
-
-
-
Max
-
{formatMs(metrics.maxResponseTime)}
-
-
-
-
-
- - - -
-
-
-
-
-
- ) -} - -function PerformanceIndicator({ - label, - value, - threshold, -}: { - label: string - value: number - threshold: number -}) { - const isGood = value >= threshold - - return ( -
- {label} -
-
-
-
- {value.toFixed(0)}% -
-
- ) -} diff --git a/src/app/admin/monitoring/page.tsx b/src/app/admin/monitoring/page.tsx index 8b7bf631d..740da45fc 100644 --- a/src/app/admin/monitoring/page.tsx +++ b/src/app/admin/monitoring/page.tsx @@ -1,33 +1,22 @@ import { type Metadata } from 'next' import { AdminPageLayout } from '@/components/admin' -import { CacheMetrics } from '@/components/admin/CacheMetrics' import { BundleSizeMonitor } from './components/BundleSizeMonitor' import { DatabaseConnectionMonitor } from './components/DatabaseConnectionMonitor' -import { MonitoringOverview } from './components/MonitoringOverview' import { PerformanceMetrics } from './components/PerformanceMetrics' -import { SEOMetricsDashboard } from './components/SEOMetricsDashboard' export const metadata: Metadata = { title: 'System Monitoring - Admin Dashboard', - description: 'Monitor system performance, cache metrics, and bundle sizes', + description: 'Monitor system performance and bundle sizes', } export default function MonitoringDashboard() { return ( - - - {/* Main Dashboard Sections */} -
- - -
- diff --git a/src/app/api/cache/metrics/route.ts b/src/app/api/cache/metrics/route.ts deleted file mode 100644 index 563c1763e..000000000 --- a/src/app/api/cache/metrics/route.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { auth } from '@clerk/nextjs/server' -import { NextResponse } from 'next/server' -import { getCacheMetrics } from '@/lib/cache/seo-cache' -import { exportMetrics, seoMetrics } from '@/lib/monitoring/seo-metrics' -import { hasRolePermission } from '@/utils/permissions' -import { Role } from '@orm' - -/** - * API endpoint to get cache metrics - * Only accessible by moderators and above - */ -export async function GET() { - const session = await auth() - - if (!session?.userId) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - // Check if user has moderator permissions or higher - const { sessionClaims } = session - const metadata = sessionClaims?.metadata as { role?: Role } | undefined - const userRole = metadata?.role - - if (!userRole || !hasRolePermission(userRole, Role.MODERATOR)) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) - } - - const cacheMetrics = getCacheMetrics() - const seoData = seoMetrics.getAggregatedMetrics() - const exportData = exportMetrics() - - // Combine all metrics - const combinedMetrics = { - // Cache metrics from seo-cache - ...cacheMetrics, - // Add seo-metrics data - entries: exportData.cache.entries.length, - size: exportData.cache.totalSize, - hitRate: seoData.cacheHitRate, - missRate: 100 - seoData.cacheHitRate, - avgResponseTime: seoData.avgTotalTime, - timestamp: new Date().toISOString(), - } - - return NextResponse.json(combinedMetrics) -} diff --git a/src/app/api/cache/warm/route.ts b/src/app/api/cache/warm/route.ts deleted file mode 100644 index fab4ce8e6..000000000 --- a/src/app/api/cache/warm/route.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { auth } from '@clerk/nextjs/server' -import { type NextRequest, NextResponse } from 'next/server' -import { - warmAllCaches, - warmPopularGames, - warmRecentListings, - warmSitemapData, -} from '@/server/cache/warming' -import { hasRolePermission } from '@/utils/permissions' -import { Role } from '@orm' - -/** - * API endpoint to manually trigger cache warming - * Only accessible by admins - */ -export async function POST(request: NextRequest) { - const session = await auth() - - if (!session?.userId) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - // Check if user has admin permissions - const { sessionClaims } = session - const metadata = sessionClaims?.metadata as { role?: Role } | undefined - const userRole = metadata?.role - - if (!userRole || !hasRolePermission(userRole, Role.ADMIN)) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) - } - - try { - const body = await request.json() - const { strategy = 'all' } = body - - let result - - switch (strategy) { - case 'games': - result = await warmPopularGames(50) - break - case 'listings': - result = await warmRecentListings(100) - break - case 'sitemap': - result = await warmSitemapData() - break - case 'all': - default: - result = await warmAllCaches() - break - } - - return NextResponse.json({ - success: true, - ...result, - strategy, - timestamp: new Date().toISOString(), - }) - } catch (error) { - console.error('Cache warming error:', error) - // Don't expose internal error details to client - return NextResponse.json({ error: 'Failed to warm cache' }, { status: 500 }) - } -} diff --git a/src/app/api/catalog/compatibility/route.ts b/src/app/api/catalog/compatibility/route.ts index d7b71bc0e..77c748a96 100644 --- a/src/app/api/catalog/compatibility/route.ts +++ b/src/app/api/catalog/compatibility/route.ts @@ -24,10 +24,8 @@ import { getDeviceCompatibility } from '@/server/services/catalog.service' */ export async function GET(request: NextRequest) { try { - // Validate API key and enforce quota limits const apiKey = await validateAndConsumeApiKey(request) - // Parse query parameters const searchParams = request.nextUrl.searchParams const deviceId = searchParams.get('deviceId') ?? undefined @@ -37,13 +35,11 @@ export async function GET(request: NextRequest) { const includeEmulatorBreakdownParam = searchParams.get('includeEmulatorBreakdown') const minListingCountParam = searchParams.get('minListingCount') - // Parse array and boolean parameters const systemIds = systemIdsParam ? systemIdsParam.split(',').filter(Boolean) : undefined const includeEmulatorBreakdown = includeEmulatorBreakdownParam !== null ? includeEmulatorBreakdownParam === 'true' : undefined const minListingCount = minListingCountParam ? parseInt(minListingCountParam, 10) : undefined - // Validate input with Zod schema const input = GetDeviceCompatibilitySchema.parse({ deviceId, deviceModelName, @@ -59,15 +55,15 @@ export async function GET(request: NextRequest) { userId: apiKey.user.id, }) - // Return JSON response return NextResponse.json(result, { status: 200, headers: { - 'Cache-Control': 'public, s-maxage=600, stale-while-revalidate=300', + // This endpoint consumes API-key quota and may include role-scoped visibility. + // Keep intermediary caches out of the path; the service still uses its own LRU. + 'Cache-Control': 'private, no-store', }, }) } catch (error) { - // Handle TRPCError (includes auth, quota, and other custom errors) if (error instanceof TRPCError) { const statusCodeMap: Record = { BAD_REQUEST: 400, @@ -89,7 +85,6 @@ export async function GET(request: NextRequest) { ) } - // Handle validation errors if (error instanceof ZodError) { return NextResponse.json( { @@ -103,7 +98,6 @@ export async function GET(request: NextRequest) { ) } - // Handle not found errors if (error instanceof Error && error.message.includes('not found')) { return NextResponse.json( { @@ -116,7 +110,6 @@ export async function GET(request: NextRequest) { ) } - // Handle unexpected errors console.error('Catalog compatibility API error:', error) return NextResponse.json( { diff --git a/src/app/api/health/route.ts b/src/app/api/health/route.ts index 4dc5dfb9a..d36e5b28c 100644 --- a/src/app/api/health/route.ts +++ b/src/app/api/health/route.ts @@ -2,8 +2,6 @@ import { NextResponse } from 'next/server' import { prisma } from '@/server/db' import type { NextRequest } from 'next/server' -export const dynamic = 'force-dynamic' - interface HealthResponse { status: 'healthy' | 'unhealthy' timestamp: string @@ -112,18 +110,15 @@ interface HealthResponse { */ export async function GET(_request: NextRequest) { try { - // Test database connectivity const dbStart = Date.now() await prisma.$queryRaw`SELECT 1` const dbLatency = Date.now() - dbStart - // Get memory usage const memUsage = process.memoryUsage() const memoryUsed = memUsage.rss const memoryTotal = memUsage.rss + memUsage.external const memoryPercentage = Math.round((memoryUsed / memoryTotal) * 100) - // Check if Clerk environment variables are set const authAvailable = !!( process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY && process.env.CLERK_SECRET_KEY ) @@ -145,8 +140,8 @@ export async function GET(_request: NextRequest) { }, system: { memory: { - used: Math.round(memoryUsed / 1024 / 1024), // MB - total: Math.round(memoryTotal / 1024 / 1024), // MB + used: Math.round(memoryUsed / 1024 / 1024), + total: Math.round(memoryTotal / 1024 / 1024), percentage: memoryPercentage, }, nodeVersion: process.version, diff --git a/src/app/api/proxy-image/route.ts b/src/app/api/proxy-image/route.ts index 477d7865e..9d9872d9c 100644 --- a/src/app/api/proxy-image/route.ts +++ b/src/app/api/proxy-image/route.ts @@ -1,11 +1,8 @@ import { type NextRequest } from 'next/server' import { logger } from '@/lib/logger' -export const dynamic = 'force-dynamic' - /** * Only allow http and https URLs to prevent SSRF attacks - * @param raw */ function isAllowedUrl(raw?: string | null): URL | null { if (!raw) return null @@ -48,7 +45,6 @@ export async function GET(req: NextRequest) { headers: { 'Content-Type': contentType, 'Cache-Control': cacheControl, - // Help common CDNs respect our intent 'CDN-Cache-Control': cacheControl, 'Vercel-CDN-Cache-Control': cacheControl, }, diff --git a/src/app/api/trpc/[trpc]/route.ts b/src/app/api/trpc/[trpc]/route.ts index 386d9546e..63d0cbf2f 100644 --- a/src/app/api/trpc/[trpc]/route.ts +++ b/src/app/api/trpc/[trpc]/route.ts @@ -3,9 +3,6 @@ import { type NextRequest } from 'next/server' import { appRouter } from '@/server/api/root' import { createAppRouterTRPCContext } from '@/server/api/trpc' -// Ensure this route is always treated as dynamic and never cached by Next -export const dynamic = 'force-dynamic' - const handler = async (req: NextRequest) => { return fetchRequestHandler({ endpoint: '/api/trpc', diff --git a/src/app/api/upload/games/route.ts b/src/app/api/upload/games/route.ts index a48c2ffde..50f69bd72 100644 --- a/src/app/api/upload/games/route.ts +++ b/src/app/api/upload/games/route.ts @@ -4,34 +4,26 @@ import { handleFileUpload } from '@/lib/upload' import getErrorMessage from '@/utils/getErrorMessage' import type { NextRequest } from 'next/server' -// Set route to be dynamic to prevent caching -export const dynamic = 'force-dynamic' - export async function POST(request: NextRequest) { try { - // Check authentication const { userId } = await auth() if (!userId) { return NextResponse.json({ error: 'Unauthorized access' }, { status: 401 }) } - // Get form data const formData = await request.formData() - // Handle upload using shared utility const result = await handleFileUpload(formData, userId, 'games') if (!result.success) { return NextResponse.json({ error: result.error }, { status: result.status }) } - // Return success response with cache headers const response = NextResponse.json({ success: true, imageUrl: result.imageUrl, }) - // Set cache control headers to prevent caching response.headers.set('Cache-Control', 'no-store, max-age=0') return response diff --git a/src/app/api/upload/profile/route.ts b/src/app/api/upload/profile/route.ts index 60ea9dfcf..9a8026463 100644 --- a/src/app/api/upload/profile/route.ts +++ b/src/app/api/upload/profile/route.ts @@ -3,33 +3,26 @@ import { NextResponse, type NextRequest } from 'next/server' import { handleFileUpload } from '@/lib/upload' import getErrorMessage from '@/utils/getErrorMessage' -export const dynamic = 'force-dynamic' - export async function POST(request: NextRequest) { try { - // Check authentication const { userId } = await auth() if (!userId) { return NextResponse.json({ error: 'Unauthorized access' }, { status: 401 }) } - // Get form data const formData = await request.formData() - // Handle upload using shared utility const result = await handleFileUpload(formData, userId, 'profiles') if (!result.success) { return NextResponse.json({ error: result.error }, { status: result.status }) } - // Return success response const response = NextResponse.json({ success: true, imageUrl: result.imageUrl, }) - // Set cache control headers to prevent caching response.headers.set('Cache-Control', 'no-store, max-age=0') return response diff --git a/src/app/games/new/NewGamePage.tsx b/src/app/games/new/NewGamePage.tsx index a1f0d33ab..176b584ff 100644 --- a/src/app/games/new/NewGamePage.tsx +++ b/src/app/games/new/NewGamePage.tsx @@ -20,9 +20,6 @@ import { hasRolePermission } from '@/utils/permissions' import { Role } from '@orm' import NotSignedInMessage from './components/NotSignedInMessage' -export const dynamic = 'force-dynamic' - -// Define the System type for Autocomplete interface SystemOption extends AutocompleteOptionBase { id: string name: string @@ -43,10 +40,8 @@ function AddGamePage() { const systemsQuery = api.systems.get.useQuery() const createGame = api.games.create.useMutation() - // Get user role from database using TRPC const userQuery = api.users.me.useQuery(undefined, { enabled: !!user }) - // Get the selected system's name based on systemId const selectedSystem = systemId ? systemsQuery.data?.find((system) => system.id === systemId) : null @@ -60,7 +55,6 @@ function AddGamePage() { return () => clearTimeout(timer) }, [success, error]) - // Redirect non-authors users to IGDB search page useEffect(() => { if ( isLoaded && @@ -78,7 +72,6 @@ function AddGamePage() { const isAuthor = hasRolePermission(userQuery.data.role, Role.AUTHOR) - // If not author, show loading while redirecting if (!isAuthor) return const handleSubmit = async (ev: FormEvent) => { @@ -95,7 +88,6 @@ function AddGamePage() { isErotic, }) - // Invalidate games queries to refresh the list await utils.games.get.invalidate() await utils.games.checkExistingByTgdbIds.invalidate() await utils.games.checkExistingByNamesAndSystems.invalidate() diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 3058ddad7..3639b1033 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -7,7 +7,8 @@ import { SpeedInsights } from '@vercel/speed-insights/next' import { type Metadata, type Viewport } from 'next' import { Inter } from 'next/font/google' import Script from 'next/script' -import { type PropsWithChildren } from 'react' +import { connection } from 'next/server' +import { Suspense, type PropsWithChildren } from 'react' import { Toaster } from 'sonner' import CookieConsent from '@/components/CookieConsent' import Footer from '@/components/footer/Footer' @@ -33,31 +34,28 @@ export const metadata: Metadata = defaultMetadata export default function RootLayout(props: PropsWithChildren) { return ( - - - - {/* Service Worker Registration / Unregister in dev - * - In dev (not production), always load to proactively unregister any SW and clear caches - * - In prod, only load when explicitly enabled via NEXT_PUBLIC_ENABLE_SW - */} - {(env.ENABLE_SW || !env.IS_PROD) && ( - - )} - - {/* Google Analytics Configuration */} - - + + )} + + + {env.IS_PROD && !env.DISABLE_COOKIE_BANNER && } @@ -77,8 +75,28 @@ export default function RootLayout(props: PropsWithChildren) { )} {env.VERCEL_ANALYTICS_ENABLED && } - - - + + + ) } + +function ClerkBoundary(props: PropsWithChildren) { + if (process.env.NODE_ENV !== 'development') { + return ( + {props.children} + ) + } + + return ( + + {props.children} + + ) +} + +async function DevelopmentClerkProvider(props: PropsWithChildren) { + await connection() + + return {props.children} +} diff --git a/src/app/sitemap.ts b/src/app/sitemap.ts index d083fb22c..14316e79a 100644 --- a/src/app/sitemap.ts +++ b/src/app/sitemap.ts @@ -5,66 +5,53 @@ import { getApprovedPcListingsForSitemap, } from '@/server/db/seo-queries' -// Revalidate sitemap every 6 hours (6 * 60 * 60 = 21600 seconds) -export const revalidate = 21600 - const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://emuready.com' export default async function sitemap(): Promise { - // Static pages const staticPages: MetadataRoute.Sitemap = [ { url: appUrl, - lastModified: new Date(), changeFrequency: 'daily', priority: 1, }, { url: `${appUrl}/games`, - lastModified: new Date(), changeFrequency: 'daily', priority: 0.9, }, { url: `${appUrl}/listings`, - lastModified: new Date(), changeFrequency: 'hourly', priority: 0.9, }, { url: `${appUrl}/pc-listings`, - lastModified: new Date(), changeFrequency: 'hourly', priority: 0.9, }, { url: `${appUrl}/emulators`, - lastModified: new Date(), changeFrequency: 'weekly', priority: 0.8, }, { url: `${appUrl}/devices`, - lastModified: new Date(), changeFrequency: 'weekly', priority: 0.8, }, { url: `${appUrl}/terms`, - lastModified: new Date(), changeFrequency: 'monthly', priority: 0.3, }, { url: `${appUrl}/privacy`, - lastModified: new Date(), changeFrequency: 'monthly', priority: 0.3, }, ] try { - // Fetch games for dynamic sitemap entries const games = await getApprovedGamesForSitemap(1000) const gamePages: MetadataRoute.Sitemap = games @@ -76,7 +63,6 @@ export default async function sitemap(): Promise { })) : [] - // Fetch recent listings const listings = await getApprovedListingsForSitemap(500) const listingPages: MetadataRoute.Sitemap = listings @@ -88,7 +74,6 @@ export default async function sitemap(): Promise { })) : [] - // Fetch PC listings const pcListings = await getApprovedPcListingsForSitemap(500) const pcListingPages: MetadataRoute.Sitemap = pcListings @@ -102,7 +87,6 @@ export default async function sitemap(): Promise { return [...staticPages, ...gamePages, ...listingPages, ...pcListingPages] } catch (error) { - // Return only static pages if there's an error fetching dynamic content console.error('Error generating sitemap:', error) return staticPages } diff --git a/src/components/admin/CacheMetrics.tsx b/src/components/admin/CacheMetrics.tsx deleted file mode 100644 index 10c4544e0..000000000 --- a/src/components/admin/CacheMetrics.tsx +++ /dev/null @@ -1,152 +0,0 @@ -'use client' - -import { Loader2, Activity, TrendingUp, Database, Timer } from 'lucide-react' -import { Divider, Card, DonutChart, BarChart } from '@/components/ui' -import { POLLING_INTERVALS } from '@/data/constants' -import { api } from '@/lib/api' - -export function CacheMetrics() { - const { - data: metrics, - isLoading, - error, - } = api.cache.getMetrics.useQuery(undefined, { - refetchInterval: POLLING_INTERVALS.DEFAULT, - refetchOnWindowFocus: false, - }) - - if (isLoading) { - return ( - - - - ) - } - - if (error || !metrics) { - return ( - -

- {error?.message || 'Unable to load cache metrics'} -

-
- ) - } - - const cacheMetrics = metrics.cache - const cacheUsagePercent = (cacheMetrics.size / cacheMetrics.maxSize) * 100 - const totalRequests = cacheMetrics.hits + cacheMetrics.misses - - return ( - -
-
-

- - SEO Cache Performance -

-

- Real-time cache metrics and performance indicators -

-
-
-
-
- - Hit Rate -
-
{cacheMetrics.hitRate}
-

- {totalRequests.toLocaleString()} total requests -

-
- -
-
- - Cache Size -
-
- {cacheMetrics.size} / {cacheMetrics.maxSize} -
-
-
-
-
-
- - - -
-
-
- - Stale Hits -
-
{cacheMetrics.staleHits.toLocaleString()}
-

Served while revalidating

-
- -
-

Cache Performance

- -
-
- - {/* Cache Distribution Chart */} -
-

Cache Request Distribution

-
- -
-
- -
- Last updated: {new Date(metrics.timestamp).toLocaleTimeString()} -
-
- - ) -} diff --git a/src/lib/cache/seo-cache.ts b/src/lib/cache/seo-cache.ts deleted file mode 100644 index 4efd8e61f..000000000 --- a/src/lib/cache/seo-cache.ts +++ /dev/null @@ -1,234 +0,0 @@ -import { LRUCache } from 'lru-cache' -import { ms } from '@/utils/time' - -/** - * caching solution for SEO data - * Uses in-memory LRU cache with Redis support when available - * TODO: implement Redis support for distributed caching - */ - -interface CacheOptions { - ttl?: number // Time to live in milliseconds - staleWhileRevalidate?: number // Serve stale content while revalidating -} - -interface CachedItem { - data: T - timestamp: number - ttl: number - staleWhileRevalidate: number -} - -// In-memory LRU cache configuration -const DEFAULT_TTL = ms.hours(1) -const DEFAULT_STALE_WHILE_REVALIDATE = ms.days(1) -const MAX_CACHE_SIZE = 500 // Maximum number of items -const MAX_CACHE_AGE = ms.days(1) - -// Initialize LRU cache -export const cache = new LRUCache>({ - max: MAX_CACHE_SIZE, - ttl: MAX_CACHE_AGE, - // Update age on get to keep hot items in cache - updateAgeOnGet: true, - // Track performance metrics - fetchMethod: async (key: string) => { - console.log(`[SEO Cache] Cache miss for key: ${key}`) - return undefined - }, -}) - -// Performance metrics -let cacheHits = 0 -let cacheMisses = 0 -let cacheStaleHits = 0 -let deduplicatedRequests = 0 - -// Request deduplication - tracks in-flight requests -const inFlightRequests = new Map>() - -export function getCacheMetrics() { - const hitRate = cacheHits / (cacheHits + cacheMisses) || 0 - return { - hits: cacheHits, - misses: cacheMisses, - staleHits: cacheStaleHits, - deduplicatedRequests, - hitRate: `${(hitRate * 100).toFixed(2)}%`, - size: cache.size, - maxSize: MAX_CACHE_SIZE, - inFlightRequests: inFlightRequests.size, - } -} - -/** - * Get item from cache with stale-while-revalidate support - */ -export async function getFromCache( - key: string, - fetchFn: () => Promise, - options: CacheOptions = {}, -): Promise { - const ttl = options.ttl ?? DEFAULT_TTL - const staleWhileRevalidate = options.staleWhileRevalidate ?? DEFAULT_STALE_WHILE_REVALIDATE - - try { - const cached = cache.get(key) - const now = Date.now() - - if (cached) { - const age = now - cached.timestamp - const isStale = age > cached.ttl - const isExpired = age > cached.ttl + cached.staleWhileRevalidate - - if (!isExpired) { - if (isStale) { - // Serve stale content and revalidate in background - cacheStaleHits++ - console.log(`[SEO Cache] Serving stale content for: ${key}`) - - // Revalidate in background without blocking - revalidateInBackground(key, fetchFn, ttl, staleWhileRevalidate) - } else { - // Fresh cache hit - cacheHits++ - } - return cached.data as T - } - } - - // Check if there's already an in-flight request for this key - const existingRequest = inFlightRequests.get(key) - if (existingRequest) { - console.log(`[SEO Cache] Deduplicating request for key: ${key}`) - deduplicatedRequests++ - return existingRequest as Promise - } - - // Cache miss or expired - create new request - cacheMisses++ - - // Create promise and store it for deduplication - const requestPromise = (async () => { - try { - const data = await fetchFn() - - if (data !== null) { - setCacheItem(key, data, ttl, staleWhileRevalidate) - } - - return data - } finally { - // Remove from in-flight map when done - inFlightRequests.delete(key) - } - })() - - // Store the promise for deduplication - inFlightRequests.set(key, requestPromise) - - return requestPromise - } catch (error) { - console.error(`[SEO Cache] Error for key ${key}:`, error) - // On error, try to return stale data if available - const cached = cache.get(key) - return (cached?.data as T) ?? null - } -} - -/** - * Set item in cache - */ -function setCacheItem(key: string, data: T, ttl: number, staleWhileRevalidate: number): void { - cache.set(key, { - data, - timestamp: Date.now(), - ttl, - staleWhileRevalidate, - }) -} - -/** - * Revalidate cache in background - */ -async function revalidateInBackground( - key: string, - fetchFn: () => Promise, - ttl: number, - staleWhileRevalidate: number, -): Promise { - // Check if there's already an in-flight request for this key - if (inFlightRequests.has(key)) { - console.log( - `[SEO Cache] Skipping background revalidation, request already in-flight for: ${key}`, - ) - return - } - - // Use setImmediate to ensure this doesn't block - setImmediate(async () => { - // Double-check in case another request started - if (inFlightRequests.has(key)) { - return - } - - const requestPromise = (async () => { - try { - console.log(`[SEO Cache] Revalidating in background: ${key}`) - const data = await fetchFn() - if (data !== null) { - setCacheItem(key, data, ttl, staleWhileRevalidate) - } - } catch (error) { - console.error(`[SEO Cache] Background revalidation failed for ${key}:`, error) - } finally { - inFlightRequests.delete(key) - } - })() - - // Store the promise for deduplication - inFlightRequests.set(key, requestPromise) - }) -} - -/** - * Invalidate cache entries by pattern - */ -export function invalidateCache(pattern: string | RegExp): number { - let invalidated = 0 - - for (const key of cache.keys()) { - if (typeof pattern === 'string' ? key.includes(pattern) : pattern.test(key)) { - cache.delete(key) - invalidated++ - } - } - - console.log(`[SEO Cache] Invalidated ${invalidated} entries matching pattern`) - return invalidated -} - -/** - * Clear entire cache - */ -export function clearCache(): void { - cache.clear() - console.log('[SEO Cache] Cache cleared') -} - -/** - * Warm cache with popular content - */ -export async function warmCache( - items: { key: string; fetchFn: () => Promise }[], - options: CacheOptions = {}, -): Promise { - console.log(`[SEO Cache] Warming cache with ${items.length} items`) - - // Process in batches to avoid overloading - const batchSize = 10 - for (let i = 0; i < items.length; i += batchSize) { - const batch = items.slice(i, i + batchSize) - await Promise.all(batch.map(({ key, fetchFn }) => getFromCache(key, fetchFn, options))) - } -} diff --git a/src/lib/captcha/config.ts b/src/lib/captcha/config.ts index 039ad5081..90c833031 100644 --- a/src/lib/captcha/config.ts +++ b/src/lib/captcha/config.ts @@ -1,10 +1,7 @@ export const RECAPTCHA_CONFIG = { siteKey: process.env.NEXT_PUBLIC_RECAPTCHA_SITE_KEY ?? '', secretKey: process.env.RECAPTCHA_SECRET_KEY ?? '', - // Score threshold (0.0 = likely bot, 1.0 = likely human) - // reCAPTCHA v3 recommends 0.5 as a starting point scoreThreshold: 0.5, - // Actions for different parts of the application actions: { CREATE_LISTING: 'create_listing', VOTE: 'vote', @@ -14,23 +11,32 @@ export const RECAPTCHA_CONFIG = { }, } as const -// Validate configuration if ( process.env.NODE_ENV !== 'test' && typeof window === 'undefined' && + !isCaptchaDisabled() && !RECAPTCHA_CONFIG.secretKey ) { console.warn('RECAPTCHA_SECRET_KEY is not set. CAPTCHA verification will be disabled.') } -if (process.env.NODE_ENV !== 'test' && typeof window !== 'undefined' && !RECAPTCHA_CONFIG.siteKey) { +if ( + process.env.NODE_ENV !== 'test' && + typeof window !== 'undefined' && + !isCaptchaDisabled() && + !RECAPTCHA_CONFIG.siteKey +) { console.warn('NEXT_PUBLIC_RECAPTCHA_SITE_KEY is not set. CAPTCHA will be disabled.') } +export function isCaptchaDisabled(): boolean { + return process.env.NEXT_PUBLIC_DISABLE_RECAPTCHA === 'true' +} + export function isCaptchaClientEnabled(): boolean { - return Boolean(RECAPTCHA_CONFIG.siteKey) + return !isCaptchaDisabled() && Boolean(RECAPTCHA_CONFIG.siteKey) } export function isCaptchaVerificationEnabled(): boolean { - return Boolean(RECAPTCHA_CONFIG.secretKey) + return !isCaptchaDisabled() && Boolean(RECAPTCHA_CONFIG.secretKey) } diff --git a/src/lib/captcha/verify.test.ts b/src/lib/captcha/verify.test.ts index 8b1d67af4..5fbf869e5 100644 --- a/src/lib/captcha/verify.test.ts +++ b/src/lib/captcha/verify.test.ts @@ -18,6 +18,18 @@ describe('captcha config', () => { expect(isCaptchaClientEnabled()).toBe(true) expect(isCaptchaVerificationEnabled()).toBe(false) }) + + it('uses the same explicit disable flag for client and server captcha', async () => { + vi.stubEnv('NEXT_PUBLIC_DISABLE_RECAPTCHA', 'true') + vi.stubEnv('NEXT_PUBLIC_RECAPTCHA_SITE_KEY', 'site-key') + vi.stubEnv('RECAPTCHA_SECRET_KEY', 'secret-key') + vi.resetModules() + + const { isCaptchaClientEnabled, isCaptchaVerificationEnabled } = await import('./config') + + expect(isCaptchaClientEnabled()).toBe(false) + expect(isCaptchaVerificationEnabled()).toBe(false) + }) }) describe('verifyRecaptcha', () => { diff --git a/src/lib/monitoring/seo-metrics.ts b/src/lib/monitoring/seo-metrics.ts deleted file mode 100644 index 3278430d5..000000000 --- a/src/lib/monitoring/seo-metrics.ts +++ /dev/null @@ -1,189 +0,0 @@ -import { cache } from '@/lib/cache/seo-cache' - -/** - * SEO Performance Monitoring - * Integrates with existing monitoring infrastructure - */ - -interface PerformanceMetrics { - cacheLookupTime: number - databaseQueryTime: number - totalTime: number - cacheHit: boolean - staleServed: boolean -} - -// Type guard for cached items -interface CachedItem { - data: T - timestamp: number - ttl: number - staleWhileRevalidate: number -} - -function isCachedItem(value: unknown): value is CachedItem { - return ( - value !== null && - typeof value === 'object' && - 'data' in value && - 'timestamp' in value && - 'ttl' in value && - 'staleWhileRevalidate' in value && - typeof (value as Record).timestamp === 'number' && - typeof (value as Record).ttl === 'number' - ) -} - -class SEOMetricsCollector { - private metrics: PerformanceMetrics[] = [] - private maxMetrics = 1000 // Keep last 1000 operations - - recordMetric(metric: PerformanceMetrics) { - this.metrics.push(metric) - - // Keep only recent metrics - if (this.metrics.length > this.maxMetrics) { - this.metrics = this.metrics.slice(-this.maxMetrics) - } - - // Log slow queries - if (metric.totalTime > 100) { - console.warn('[SEO Performance] Slow operation detected:', { - totalTime: `${metric.totalTime}ms`, - cacheHit: metric.cacheHit, - dbTime: `${metric.databaseQueryTime}ms`, - }) - } - } - - getAggregatedMetrics() { - if (this.metrics.length === 0) { - return { - avgCacheLookupTime: 0, - avgDatabaseQueryTime: 0, - avgTotalTime: 0, - cacheHitRate: 0, - staleServeRate: 0, - sampleSize: 0, - p95ResponseTime: 0, - maxResponseTime: 0, - minResponseTime: 0, - medianResponseTime: 0, - } - } - - const cacheHits = this.metrics.filter((m) => m.cacheHit).length - const staleServed = this.metrics.filter((m) => m.staleServed).length - - const sumMetrics = this.metrics.reduce( - (acc, m) => ({ - cacheLookup: acc.cacheLookup + m.cacheLookupTime, - dbQuery: acc.dbQuery + m.databaseQueryTime, - total: acc.total + m.totalTime, - }), - { cacheLookup: 0, dbQuery: 0, total: 0 }, - ) - - // Calculate percentiles and min/max - const sortedTimes = this.metrics.map((m) => m.totalTime).sort((a, b) => a - b) - - const p95Index = Math.floor(sortedTimes.length * 0.95) - const medianIndex = Math.floor(sortedTimes.length * 0.5) - - return { - avgCacheLookupTime: Math.round(sumMetrics.cacheLookup / this.metrics.length), - avgDatabaseQueryTime: Math.round(sumMetrics.dbQuery / this.metrics.length), - avgTotalTime: Math.round(sumMetrics.total / this.metrics.length), - cacheHitRate: (cacheHits / this.metrics.length) * 100, - staleServeRate: (staleServed / this.metrics.length) * 100, - sampleSize: this.metrics.length, - p95ResponseTime: Math.round(sortedTimes[p95Index] || 0), - maxResponseTime: Math.round(sortedTimes[sortedTimes.length - 1] || 0), - minResponseTime: Math.round(sortedTimes[0] || 0), - medianResponseTime: Math.round(sortedTimes[medianIndex] || 0), - } - } - - reset() { - this.metrics = [] - } -} - -export const seoMetrics = new SEOMetricsCollector() - -/** - * Performance monitoring wrapper - */ -export async function withMetrics( - operation: string, - fn: () => Promise, - options: { cacheKey?: string } = {}, -): Promise { - const startTime = performance.now() - let cacheHit = false - let staleServed = false - let dbQueryTime = 0 - - try { - // Check if this is a cache operation - if (options.cacheKey) { - const cachedValue = cache.get(options.cacheKey) - if (isCachedItem(cachedValue)) { - cacheHit = true - const age = Date.now() - cachedValue.timestamp - staleServed = age > cachedValue.ttl - } - } - - const dbStart = performance.now() - const result = await fn() - dbQueryTime = performance.now() - dbStart - - return result - } finally { - const totalTime = performance.now() - startTime - - seoMetrics.recordMetric({ - cacheLookupTime: cacheHit ? totalTime - dbQueryTime : 0, - databaseQueryTime: cacheHit ? 0 : dbQueryTime, - totalTime, - cacheHit, - staleServed, - }) - } -} - -/** - * Export metrics for external monitoring systems - */ -export function exportMetrics() { - const cacheMetrics = cache.dump().map(([key, value]) => { - // Safely access the cached item properties - let age = 0 - if (isCachedItem(value.value)) { - age = Date.now() - value.value.timestamp - } - - return { - key, - size: JSON.stringify(value).length, - age, - } - }) - - // Handle empty cache scenario - const totalSize = cacheMetrics.reduce((sum, m) => sum + m.size, 0) - const oldestEntry = cacheMetrics.length > 0 ? Math.max(...cacheMetrics.map((m) => m.age)) : 0 - - return { - cache: { - entries: cacheMetrics, - totalSize, - oldestEntry, - averageSize: cacheMetrics.length > 0 ? Math.round(totalSize / cacheMetrics.length) : 0, - totalEntries: cacheMetrics.length, - }, - performance: seoMetrics.getAggregatedMetrics(), - timestamp: new Date().toISOString(), - } -} diff --git a/src/middleware.ts b/src/proxy.ts similarity index 71% rename from src/middleware.ts rename to src/proxy.ts index 66faf56bc..0bf3eb786 100644 --- a/src/middleware.ts +++ b/src/proxy.ts @@ -1,17 +1,17 @@ import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server' import { NextResponse } from 'next/server' -import { getAllowedOrigins, isAllowedRequestOrigin } from '@/lib/cors' +import { getAllowedOrigins, getOriginFromUrl, isAllowedRequestOrigin } from '@/lib/cors' import { ms } from '@/utils/time' import type { NextRequest, NextFetchEvent } from 'next/server' -// In-memory rate limiting with automatic cleanup -// TODO: For distributed deployments, consider Redis or database-backed rate limiting +// Process-local rate limiting only; counts are not shared across server instances. const rateLimitMap = new Map() -// Rate limiting configuration -const RATE_LIMIT_REQUESTS = process.env.NODE_ENV === 'test' ? 10000 : 100 // Much higher limit for tests +const RATE_LIMIT_REQUESTS = process.env.NODE_ENV === 'test' ? 10000 : 100 const RATE_LIMIT_WINDOW = ms.minutes(3) +const RATE_LIMIT_CLEANUP_SAMPLE_RATE = 0.01 const DEV_NO_STORE_HOSTS = new Set(['dev.emuready.com']) +const LOCAL_RATE_LIMIT_BYPASS_IDENTIFIERS = new Set(['::1', '127.0.0.1', 'localhost', 'unknown']) function applyDevNoStoreHeader( response: T, @@ -29,39 +29,29 @@ function applyDevNoStoreHeader( } function getClientIdentifier(req: NextRequest): string { - // Use IP from forwarded headers or fallback const forwarded = req.headers.get('x-forwarded-for') const realIp = req.headers.get('x-real-ip') - const cfConnectingIp = req.headers.get('cf-connecting-ip') // Cloudflare + const cfConnectingIp = req.headers.get('cf-connecting-ip') if (forwarded) return forwarded.split(',')[0].trim() return realIp || cfConnectingIp || 'unknown' } -function checkRateLimit(identifier: string): boolean { - // Skip rate limiting if explicitly disabled (for E2E tests) - if (process.env.DISABLE_RATE_LIMIT === 'true') { - return true - } +function shouldBypassRateLimit(identifier: string): boolean { + if (process.env.DISABLE_RATE_LIMIT === 'true') return true - // Skip rate limiting for localhost in test/development environments - if ( - (process.env.NODE_ENV === 'test' || process.env.NODE_ENV === 'development') && - (identifier === '::1' || - identifier === '127.0.0.1' || - identifier === 'localhost' || - identifier === 'unknown') - ) { - return true - } + if (process.env.NODE_ENV !== 'test' && process.env.NODE_ENV !== 'development') return false + + return LOCAL_RATE_LIMIT_BYPASS_IDENTIFIERS.has(identifier) +} + +function checkRateLimit(identifier: string): boolean { + if (shouldBypassRateLimit(identifier)) return true const now = Date.now() - // Clean up expired entries to prevent memory leaks - // Only clean every 100-ish requests to avoid performance impact - if (Math.random() < 0.01) { - // 1% chance per request + if (Math.random() < RATE_LIMIT_CLEANUP_SAMPLE_RATE) { for (const [key, value] of rateLimitMap.entries()) { if (now > value.resetTime) rateLimitMap.delete(key) } @@ -70,14 +60,13 @@ function checkRateLimit(identifier: string): boolean { const userLimit = rateLimitMap.get(identifier) if (!userLimit || now > userLimit.resetTime) { - // Reset or create new limit window rateLimitMap.set(identifier, { count: 1, resetTime: now + RATE_LIMIT_WINDOW, }) return true } - // Slow down boy, rate limit exceeded + if (userLimit.count >= RATE_LIMIT_REQUESTS) return false userLimit.count++ @@ -90,6 +79,10 @@ function isValidOrigin(req: NextRequest): boolean { const allowedOrigins = getAllowedOrigins() + if (isSameOriginSource(req, origin) || isSameOriginSource(req, referer)) { + return true + } + if (isAllowedRequestOrigin({ allowedOrigins, source: origin })) { return true } @@ -98,7 +91,6 @@ function isValidOrigin(req: NextRequest): boolean { return true } - // Allow requests with no origin/referer (server-side, mobile apps, etc.) but check for the API key if (!origin && !referer) { const apiKey = req.headers.get('x-api-key') const internalApiKey = process.env.INTERNAL_API_KEY @@ -108,28 +100,28 @@ function isValidOrigin(req: NextRequest): boolean { return false } +function isSameOriginSource(req: NextRequest, source: string | null): boolean { + const sourceOrigin = getOriginFromUrl(source ?? '') + if (!sourceOrigin) return false + + const host = req.headers.get('host') + if (!host) return false + + return sourceOrigin === `${req.nextUrl.protocol}//${host}` +} + function protectTRPCAPI(req: NextRequest): NextResponse | null { const pathname = req.nextUrl.pathname - // Skip protection for mobile routes - they have their own CORS handling if (pathname.startsWith('/api/mobile/trpc/')) return null - // Only protect TRPC API routes if (!pathname.startsWith('/api/trpc/')) return null - // Skip protection for mobile procedures in the main tRPC router if (pathname.startsWith('/api/trpc/mobile.')) return null const clientId = getClientIdentifier(req) - // Check rate limit (skip if disabled or in test/dev environment for localhost) - const skipRateLimit = - process.env.DISABLE_RATE_LIMIT === 'true' || - ((process.env.NODE_ENV === 'test' || process.env.NODE_ENV === 'development') && - (clientId === '::1' || - clientId === '127.0.0.1' || - clientId === 'localhost' || - clientId === 'unknown')) + const skipRateLimit = shouldBypassRateLimit(clientId) if (!skipRateLimit && !checkRateLimit(clientId)) { console.warn(`Rate limit exceeded for client: ${clientId}, path: ${pathname}`) @@ -149,7 +141,6 @@ function protectTRPCAPI(req: NextRequest): NextResponse | null { ) } - // Check origin for public API routes (skip in test environment) if (process.env.NODE_ENV !== 'test' && !isValidOrigin(req)) { console.warn( `Invalid origin for client: ${clientId}, origin: ${req.headers.get('origin')}, referer: ${req.headers.get('referer')}, path: ${pathname}`, @@ -169,7 +160,6 @@ function protectTRPCAPI(req: NextRequest): NextResponse | null { ) } - // Add security headers to successful requests const response = applyDevNoStoreHeader(NextResponse.next(), req) response.headers.set('X-Content-Type-Options', 'nosniff') response.headers.set('X-Frame-Options', 'DENY') @@ -190,7 +180,7 @@ const handleClerkAuth = clerkMiddleware(async (auth, req) => { return }) -export default async function middleware(req: NextRequest, evt: NextFetchEvent) { +export async function proxy(req: NextRequest, evt: NextFetchEvent) { const pathname = req.nextUrl.pathname if (pathname.startsWith('/api/mobile/')) { @@ -212,9 +202,7 @@ export default async function middleware(req: NextRequest, evt: NextFetchEvent) export const config = { matcher: [ - // Skip Next.js internals and all static files, unless found in search params '/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)', - // Always run for API routes '/(api|trpc)(.*)', ], } diff --git a/src/schemas/cache.ts b/src/schemas/cache.ts deleted file mode 100644 index 102a37a48..000000000 --- a/src/schemas/cache.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { z } from 'zod' - -export const GetCacheEntrySchema = z.object({ - key: z.string(), -}) - -export const DeleteCacheEntrySchema = z.object({ - key: z.string(), -}) diff --git a/src/server/api/root.ts b/src/server/api/root.ts index 09badaaed..923774dbe 100644 --- a/src/server/api/root.ts +++ b/src/server/api/root.ts @@ -8,7 +8,6 @@ import { apiKeysRouter } from './routers/apiKeys' import { auditLogsRouter } from './routers/auditLogs' import { badgesRouter } from './routers/badges' import { bookmarksRouter } from './routers/bookmarks' -import { cacheRouter } from './routers/cache' import { cpusRouter } from './routers/cpus' import { customFieldCategoryRouter } from './routers/customFieldCategories' import { customFieldDefinitionRouter } from './routers/customFieldDefinitions' @@ -63,7 +62,6 @@ export const appRouter = createTRPCRouter({ userPreferences: userPreferencesRouter, userBans: userBansRouter, badges: badgesRouter, - cache: cacheRouter, notifications: notificationsRouter, customFieldCategories: customFieldCategoryRouter, customFieldDefinitions: customFieldDefinitionRouter, diff --git a/src/server/api/routers/cache.ts b/src/server/api/routers/cache.ts deleted file mode 100644 index 76042885d..000000000 --- a/src/server/api/routers/cache.ts +++ /dev/null @@ -1,218 +0,0 @@ -import { cache, getCacheMetrics } from '@/lib/cache/seo-cache' -import { AppError } from '@/lib/errors' -import { seoMetrics, exportMetrics } from '@/lib/monitoring/seo-metrics' -import { GetCacheEntrySchema, DeleteCacheEntrySchema } from '@/schemas/cache' -import { createTRPCRouter, superAdminProcedure } from '@/server/api/trpc' -import { - createQueryCacheKey, - QueryPerformanceMonitor, - suggestIndexes, - analyzeQueryComplexity, -} from '@/server/utils/query-performance' -import { ApprovalStatus, ReportStatus } from '@orm/client' - -export const cacheRouter = createTRPCRouter({ - /** - * Get cache metrics and statistics - */ - getMetrics: superAdminProcedure.query(async () => { - const cacheMetrics = getCacheMetrics() - const seoData = seoMetrics.getAggregatedMetrics() - const exportData = exportMetrics() - const queryMetrics = QueryPerformanceMonitor.getMetrics() - - return { - cache: cacheMetrics, - seo: seoData, - export: exportData, - query: queryMetrics, - timestamp: new Date().toISOString(), - } - }), - - /** - * Clear all cache entries - */ - clear: superAdminProcedure.mutation(async () => { - try { - cache.clear() - seoMetrics.reset() - - return { - success: true, - message: 'Cache cleared successfully', - timestamp: new Date().toISOString(), - } - } catch { - AppError.internalError('Failed to clear cache') - } - }), - - /** - * Warm the cache with critical data - */ - warm: superAdminProcedure.mutation(async ({ ctx }) => { - try { - // Warm critical paths - const warmedPaths = [] - - // Fetch popular games for cache warming - const popularGames = await ctx.prisma.game.findMany({ - take: 10, - orderBy: { listings: { _count: 'desc' } }, - select: { id: true, title: true }, - }) - - // Warm game metadata - for (const game of popularGames) { - const cacheKey = createQueryCacheKey('seo', 'game', { id: game.id }) - const cacheData = { - title: game.title, - description: `${game.title} compatibility information`, - } - cache.set(cacheKey, { - data: cacheData, - timestamp: Date.now(), - ttl: 3600 * 1000, // Convert to milliseconds - staleWhileRevalidate: 7200 * 1000, - }) - warmedPaths.push(cacheKey) - } - - // Fetch popular listings - const popularListings = await ctx.prisma.listing.findMany({ - take: 10, - where: { status: ApprovalStatus.APPROVED }, - orderBy: { votes: { _count: 'desc' } }, - include: { - game: { select: { title: true } }, - device: { include: { brand: true } }, - emulator: true, - }, - }) - - // Warm listing metadata - for (const listing of popularListings) { - const cacheKey = createQueryCacheKey('seo', 'listing', { - id: listing.id, - }) - const cacheData = { - title: `${listing.game.title} on ${listing.device.brand.name} ${listing.device.modelName}`, - description: `${listing.game.title} running on ${listing.emulator.name}`, - } - cache.set(cacheKey, { - data: cacheData, - timestamp: Date.now(), - ttl: 3600 * 1000, // Convert to milliseconds - staleWhileRevalidate: 7200 * 1000, - }) - warmedPaths.push(cacheKey) - } - - return { - success: true, - message: `Cache warmed with ${warmedPaths.length} entries`, - warmedPaths, - timestamp: new Date().toISOString(), - } - } catch (error) { - console.error('Cache warming error:', error) - AppError.internalError('Failed to warm cache') - } - }), - - /** - * Get specific cache entry - */ - get: superAdminProcedure.input(GetCacheEntrySchema).query(async ({ input }) => { - const value = cache.get(input.key) - return { - key: input.key, - value, - exists: value !== undefined, - timestamp: new Date().toISOString(), - } - }), - - /** - * Delete specific cache entry - */ - delete: superAdminProcedure.input(DeleteCacheEntrySchema).mutation(async ({ input }) => { - const existed = cache.has(input.key) - cache.delete(input.key) - - return { - success: true, - existed, - message: existed ? 'Cache entry deleted' : 'Entry did not exist', - timestamp: new Date().toISOString(), - } - }), - - /** - * Analyze query patterns and suggest database indexes - */ - analyzeQueries: superAdminProcedure.query(async () => { - // Common query patterns in the application - const queryPatterns = [ - { - name: 'Listings by status and author', - model: 'listing', - where: { status: ApprovalStatus.APPROVED, authorId: 'user_id' }, - orderBy: { createdAt: 'desc' }, - }, - { - name: 'Listings by game and device', - model: 'listing', - where: { gameId: 'game_id', deviceId: 'device_id' }, - orderBy: { createdAt: 'desc' }, - }, - { - name: 'Games by system with approval', - model: 'game', - where: { systemId: 'system_id', status: ApprovalStatus.APPROVED }, - orderBy: { title: 'asc' }, - }, - { - name: 'PC Listings by CPU and GPU', - model: 'pcListing', - where: { - cpuId: 'cpu_id', - gpuId: 'gpu_id', - status: ApprovalStatus.APPROVED, - }, - orderBy: { createdAt: 'desc' }, - }, - { - name: 'Users with listings count', - model: 'user', - where: { listings: { some: { status: ApprovalStatus.APPROVED } } }, - }, - { - name: 'Reports by status', - model: 'listingReport', - where: { status: ReportStatus.PENDING }, - orderBy: { createdAt: 'desc' }, - }, - ] - - const suggestions = queryPatterns.map((pattern) => ({ - pattern: pattern.name, - model: pattern.model, - complexity: analyzeQueryComplexity( - pattern.model === 'user' ? { listings: true } : undefined, - pattern.where, - ), - indexSuggestions: suggestIndexes(pattern.model, pattern.where, pattern.orderBy), - })) - - // Get actual query performance metrics - const performanceMetrics = QueryPerformanceMonitor.getMetrics() - - return { - suggestions, - performanceMetrics, - timestamp: new Date().toISOString(), - } - }), -}) diff --git a/src/server/api/routers/listings/admin.test.ts b/src/server/api/routers/listings/admin.test.ts index a3d80f030..cf7043700 100644 --- a/src/server/api/routers/listings/admin.test.ts +++ b/src/server/api/routers/listings/admin.test.ts @@ -51,6 +51,7 @@ vi.mock('@/server/cache/invalidation', () => ({ vi.mock('@/server/utils/cache/instances', () => ({ listingStatsCache: { delete: vi.fn(), get: vi.fn(), set: vi.fn() }, + invalidateCatalogCompatibilityCacheForDevice: vi.fn(), })) vi.mock('@/server/utils/emulator-config/emulator-detector', () => ({ diff --git a/src/server/api/routers/listings/admin.ts b/src/server/api/routers/listings/admin.ts index 607c198bf..81fc7fb02 100644 --- a/src/server/api/routers/listings/admin.ts +++ b/src/server/api/routers/listings/admin.ts @@ -222,9 +222,10 @@ export const adminRouter = createTRPCRouter({ await revalidateByTag(`device-${listingToApprove.deviceId}`) await revalidateByTag(`emulator-${listingToApprove.emulatorId}`) - // Invalidate catalog compatibility cache for this device - const { catalogCompatibilityCache } = await import('@/server/utils/cache/instances') - catalogCompatibilityCache.invalidatePattern(`device:${listingToApprove.deviceId}:*`) + const { invalidateCatalogCompatibilityCacheForDevice } = await import( + '@/server/utils/cache/instances' + ) + invalidateCatalogCompatibilityCacheForDevice(listingToApprove.deviceId) // Apply trust action for listing approval to the author if (listingToApprove.authorId) { @@ -342,9 +343,10 @@ export const adminRouter = createTRPCRouter({ // Invalidate listing stats cache listingStatsCache.delete(LISTING_STATS_CACHE_KEY) - // Invalidate catalog compatibility cache for this device - const { catalogCompatibilityCache } = await import('@/server/utils/cache/instances') - catalogCompatibilityCache.invalidatePattern(`device:${listingToReject.deviceId}:*`) + const { invalidateCatalogCompatibilityCacheForDevice } = await import( + '@/server/utils/cache/instances' + ) + invalidateCatalogCompatibilityCacheForDevice(listingToReject.deviceId) return updatedListing }), diff --git a/src/server/api/routers/mobile/pcListings.ts b/src/server/api/routers/mobile/pcListings.ts index 49bfb40a8..047beca35 100644 --- a/src/server/api/routers/mobile/pcListings.ts +++ b/src/server/api/routers/mobile/pcListings.ts @@ -14,9 +14,8 @@ import { } from '@/server/api/mobileContext' import { pcListingInclude, buildPcListingWhere } from '@/server/api/utils/pcListingHelpers' import { - invalidateListPages, - invalidateSitemap, - revalidateByTag, + invalidatePcListingSeo, + invalidatePcListingSeoForUpdate, } from '@/server/cache/invalidation' import { PcListingsRepository } from '@/server/repositories/pc-listings.repository' import { listingStatsCache } from '@/server/utils/cache' @@ -181,11 +180,13 @@ export const mobilePcListingsRouter = createMobileTRPCRouter({ // Invalidate stats cache listingStatsCache.delete('pc-listing-stats') - // Invalidate pages if approved if (created.status === ApprovalStatus.APPROVED) { - await invalidateListPages() - await invalidateSitemap() - await revalidateByTag('pc-listings') + await invalidatePcListingSeo({ + id: created.id, + gameId: created.gameId, + cpuId: created.cpuId, + gpuId: created.gpuId, + }) } return created @@ -197,10 +198,9 @@ export const mobilePcListingsRouter = createMobileTRPCRouter({ update: mobileProtectedProcedure.input(UpdatePcListingSchema).mutation(async ({ ctx, input }) => { const { id, customFieldValues, ...updateData } = input - // Check if user owns the listing const existing = await ctx.prisma.pcListing.findUnique({ where: { id }, - select: { authorId: true }, + select: { authorId: true, status: true, gameId: true, cpuId: true, gpuId: true }, }) if (!existing) return ResourceError.pcListing.notFound() @@ -209,7 +209,7 @@ export const mobilePcListingsRouter = createMobileTRPCRouter({ return ResourceError.pcListing.canOnlyEditOwn() } - return await ctx.prisma.pcListing.update({ + const updated = await ctx.prisma.pcListing.update({ where: { id }, data: { ...updateData, @@ -233,6 +233,25 @@ export const mobilePcListingsRouter = createMobileTRPCRouter({ _count: { select: { reports: true, developerVerifications: true } }, }, }) + + if (existing.status === ApprovalStatus.APPROVED) { + await invalidatePcListingSeoForUpdate( + { + id, + gameId: existing.gameId, + cpuId: existing.cpuId, + gpuId: existing.gpuId, + }, + { + id, + gameId: updated.gameId, + cpuId: updated.cpuId, + gpuId: updated.gpuId, + }, + ) + } + + return updated }), /** diff --git a/src/server/api/routers/pcListings.test.ts b/src/server/api/routers/pcListings.test.ts index 30b0d87d6..7e26f8532 100644 --- a/src/server/api/routers/pcListings.test.ts +++ b/src/server/api/routers/pcListings.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, beforeEach, vi } from 'vitest' import { RISK_SIGNAL_TYPES } from '@/schemas/authorRisk' import { SUBMISSION_RISK_SIGNAL_TYPES } from '@/schemas/submissionRisk' +import { invalidatePcListingsSeo } from '@/server/cache/invalidation' import { PERMISSIONS } from '@/utils/permission-system' import { ApprovalStatus, PcOs, Role, TrustAction } from '@orm/client' import type * as AuthorRiskService from '@/server/services/author-risk.service' @@ -62,9 +63,9 @@ vi.mock('@/server/utils/cache', () => ({ })) vi.mock('@/server/cache/invalidation', () => ({ - invalidateListPages: vi.fn().mockResolvedValue(undefined), - invalidateSitemap: vi.fn().mockResolvedValue(undefined), - revalidateByTag: vi.fn().mockResolvedValue(undefined), + invalidatePcListingSeo: vi.fn().mockResolvedValue(undefined), + invalidatePcListingSeoForUpdate: vi.fn().mockResolvedValue(undefined), + invalidatePcListingsSeo: vi.fn().mockResolvedValue(undefined), })) vi.mock('@/lib/analytics', () => ({ @@ -811,11 +812,15 @@ describe('pcListings trust integration', () => { const listing1 = { id: LISTING_ID, gameId: '00000000-0000-4000-a000-000000000040', + cpuId: '00000000-0000-4000-a000-000000000070', + gpuId: '00000000-0000-4000-a000-000000000080', authorId: AUTHOR_ID, } const listing2 = { id: '00000000-0000-4000-a000-000000000011', gameId: '00000000-0000-4000-a000-000000000041', + cpuId: '00000000-0000-4000-a000-000000000071', + gpuId: null, authorId: '00000000-0000-4000-a000-000000000050', } @@ -836,6 +841,7 @@ describe('pcListings trust integration', () => { action: TrustAction.LISTING_APPROVED, context: expect.objectContaining({ pcListingId: '00000000-0000-4000-a000-000000000011' }), }) + expect(invalidatePcListingsSeo).toHaveBeenCalledWith([listing1, listing2]) }) }) diff --git a/src/server/api/routers/pcListings.ts b/src/server/api/routers/pcListings.ts index 3676c4df1..863829813 100644 --- a/src/server/api/routers/pcListings.ts +++ b/src/server/api/routers/pcListings.ts @@ -57,9 +57,9 @@ import { } from '@/server/api/utils/pcListingHelpers' import { canManageCommentPins } from '@/server/api/utils/pinPermissions' import { - invalidateListPages, - invalidateSitemap, - revalidateByTag, + invalidatePcListingSeo, + invalidatePcListingSeoForUpdate, + invalidatePcListingsSeo, } from '@/server/cache/invalidation' import { NOTIFICATION_EVENTS, notificationEventEmitter } from '@/server/notifications/eventEmitter' import { PcListingsRepository } from '@/server/repositories/pc-listings.repository' @@ -340,16 +340,13 @@ export const pcListingsRouter = createTRPCRouter({ // Invalidate stats cache when PC listing is created listingStatsCache.delete('pc-listing-stats') - // Invalidate SEO cache if listing is approved if (newListing.status === ApprovalStatus.APPROVED) { - await invalidateListPages() - await invalidateSitemap() - await revalidateByTag('pc-listings') - await revalidateByTag(`game-${payload.gameId}`) - await revalidateByTag(`cpu-${payload.cpuId}`) - if (payload.gpuId) { - await revalidateByTag(`gpu-${payload.gpuId}`) - } + await invalidatePcListingSeo({ + id: newListing.id, + gameId: payload.gameId, + cpuId: payload.cpuId, + gpuId: payload.gpuId ?? null, + }) } return newListing @@ -371,19 +368,28 @@ export const pcListingsRouter = createTRPCRouter({ where: { id: input.id }, }) - // Invalidate stats cache when PC listing is deleted 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 - // First check if user can edit this PC listing const pcListing = await ctx.prisma.pcListing.findUnique({ where: { id: input.id }, - select: { authorId: true, status: true, processedAt: true }, + select: { + authorId: true, + status: true, + processedAt: true, + gameId: true, + cpuId: true, + gpuId: true, + }, }) if (!pcListing) return ResourceError.pcListing.notFound() @@ -473,6 +479,23 @@ export const pcListingsRouter = createTRPCRouter({ } } + 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 }), @@ -599,9 +622,15 @@ export const pcListingsRouter = createTRPCRouter({ }) } - // Invalidate stats cache when PC listing is 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', @@ -708,6 +737,15 @@ export const pcListingsRouter = createTRPCRouter({ 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 }), @@ -725,7 +763,7 @@ export const pcListingsRouter = createTRPCRouter({ const pendingListings = await ctx.prisma.pcListing.findMany({ where: { id: { in: input.pcListingIds }, status: ApprovalStatus.PENDING }, - select: { id: true, gameId: true, authorId: true }, + select: { id: true, gameId: true, cpuId: true, gpuId: true, authorId: true }, }) const result = await ctx.prisma.pcListing.updateMany({ @@ -757,6 +795,8 @@ export const pcListingsRouter = createTRPCRouter({ listingStatsCache.delete('pc-listing-stats') + await invalidatePcListingsSeo(pendingListings) + for (const listing of pendingListings) { notificationEventEmitter.emitNotificationEvent({ eventType: NOTIFICATION_EVENTS.PC_LISTING_APPROVED, diff --git a/src/server/cache/invalidation.test.ts b/src/server/cache/invalidation.test.ts new file mode 100644 index 000000000..0adf383c8 --- /dev/null +++ b/src/server/cache/invalidation.test.ts @@ -0,0 +1,49 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const nextCacheMocks = vi.hoisted(() => ({ + revalidatePath: vi.fn(), + revalidateTag: vi.fn(), +})) + +vi.mock('next/cache', () => nextCacheMocks) + +const { invalidatePcListing, invalidatePcListingSeoForUpdate, revalidateByTag } = await import( + './invalidation' +) + +describe('cache invalidation', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('uses the stale-while-revalidate profile for tag invalidation', async () => { + await revalidateByTag('games') + + expect(nextCacheMocks.revalidateTag).toHaveBeenCalledWith('games', 'max') + }) + + it('invalidates PC listing paths and tags consistently', async () => { + await invalidatePcListing('pc-listing-1') + + expect(nextCacheMocks.revalidatePath).toHaveBeenCalledWith('/pc-listings/pc-listing-1') + expect(nextCacheMocks.revalidateTag).toHaveBeenCalledWith('pc-listing-pc-listing-1', 'max') + }) + + it('deduplicates related PC listing SEO tags for updates', async () => { + await invalidatePcListingSeoForUpdate( + { id: 'pc-listing-1', gameId: 'old-game', cpuId: 'old-cpu', gpuId: 'gpu-1' }, + { id: 'pc-listing-1', gameId: 'new-game', cpuId: 'new-cpu', gpuId: 'gpu-1' }, + ) + + expect(nextCacheMocks.revalidatePath).toHaveBeenCalledWith('/pc-listings/pc-listing-1') + expect(nextCacheMocks.revalidatePath).toHaveBeenCalledWith('/pc-listings') + expect(nextCacheMocks.revalidatePath).toHaveBeenCalledWith('/sitemap.xml') + expect(nextCacheMocks.revalidateTag).toHaveBeenCalledWith('pc-listings', 'max') + expect(nextCacheMocks.revalidateTag).toHaveBeenCalledWith('game-old-game', 'max') + expect(nextCacheMocks.revalidateTag).toHaveBeenCalledWith('game-new-game', 'max') + expect(nextCacheMocks.revalidateTag).toHaveBeenCalledWith('cpu-old-cpu', 'max') + expect(nextCacheMocks.revalidateTag).toHaveBeenCalledWith('cpu-new-cpu', 'max') + expect(nextCacheMocks.revalidateTag).toHaveBeenCalledWith('gpu-gpu-1', 'max') + expect(nextCacheMocks.revalidateTag).toHaveBeenCalledTimes(8) + }) +}) diff --git a/src/server/cache/invalidation.ts b/src/server/cache/invalidation.ts index 8b148c352..e4b0d5122 100644 --- a/src/server/cache/invalidation.ts +++ b/src/server/cache/invalidation.ts @@ -1,18 +1,18 @@ import { revalidatePath, revalidateTag } from 'next/cache' -import { invalidateCache } from '@/lib/cache/seo-cache' -/** - * Cache invalidation strategies for SEO content - * Coordinates between in-memory cache and Next.js ISR cache - */ +interface PcListingSeoTarget { + id: string + gameId: string + cpuId: string + gpuId: string | null +} export async function invalidateGame(gameId: string) { const startTime = performance.now() - invalidateCache(`seo:game:${gameId}`) - try { revalidatePath(`/games/${gameId}`) + revalidateTag(`game-${gameId}`, 'max') } catch (error) { console.error(`Failed to revalidate game path: ${gameId}`, error) } @@ -24,40 +24,36 @@ export async function invalidateGame(gameId: string) { } export async function invalidateListing(listingId: string) { - invalidateCache(`seo:listing:${listingId}`) - try { revalidatePath(`/listings/${listingId}`) + revalidateTag(`listing-${listingId}`, 'max') } catch (error) { console.error(`Failed to revalidate listing path: ${listingId}`, error) } } export async function invalidatePcListing(pcListingId: string) { - invalidateCache(`seo:pclisting:${pcListingId}`) - try { revalidatePath(`/pc-listings/${pcListingId}`) + revalidateTag(`pc-listing-${pcListingId}`, 'max') } catch (error) { console.error(`Failed to revalidate PC listing path: ${pcListingId}`, error) } } export async function invalidateUser(userId: string) { - invalidateCache(`seo:user:${userId}`) - try { revalidatePath(`/users/${userId}`) + revalidateTag(`user-${userId}`, 'max') } catch (error) { console.error(`Failed to revalidate user path: ${userId}`, error) } } export async function invalidateSitemap() { - invalidateCache(/^seo:sitemap:/) - try { revalidatePath('/sitemap.xml') + revalidateTag('sitemap', 'max') } catch (error) { console.error('Failed to revalidate sitemap', error) } @@ -77,23 +73,61 @@ export async function invalidateGameRelatedContent(gameId: string) { const startTime = performance.now() await invalidateGame(gameId) - - const listingsInvalidated = invalidateCache( - new RegExp(`seo:(listing|pclisting):.*game:${gameId}`), - ) - await invalidateListPages() const duration = performance.now() - startTime - console.log( - `[SEO] Batch invalidation for game ${gameId}: ${listingsInvalidated} listings cleared in ${duration.toFixed(2)}ms`, + console.log(`[SEO] Batch invalidation for game ${gameId} completed in ${duration.toFixed(2)}ms`) +} + +export async function invalidatePcListingSeo(listing: PcListingSeoTarget) { + await invalidatePcListingSeoTargets([listing.id], [listing]) +} + +export async function invalidatePcListingSeoForUpdate( + previous: PcListingSeoTarget, + next: PcListingSeoTarget, +) { + await invalidatePcListingSeoTargets([previous.id], [previous, next]) +} + +export async function invalidatePcListingsSeo(listings: PcListingSeoTarget[]) { + await invalidatePcListingSeoTargets( + listings.map((listing) => listing.id), + listings, ) } export async function revalidateByTag(tag: string) { try { - revalidateTag(tag) + revalidateTag(tag, 'max') } catch (error) { console.error(`Failed to revalidate tag: ${tag}`, error) } } + +async function invalidatePcListingSeoTargets( + listingIds: string[], + tagTargets: PcListingSeoTarget[], +) { + if (listingIds.length === 0 && tagTargets.length === 0) return + + const uniqueListingIds = [...new Set(listingIds)] + const tags = collectPcListingSeoTags(tagTargets) + + await Promise.all(uniqueListingIds.map((listingId) => invalidatePcListing(listingId))) + await invalidateListPages() + await invalidateSitemap() + await Promise.all([...tags].map((tag) => revalidateByTag(tag))) +} + +function collectPcListingSeoTags(listings: PcListingSeoTarget[]): Set { + const tags = new Set(['pc-listings']) + + for (const listing of listings) { + tags.add(`game-${listing.gameId}`) + tags.add(`cpu-${listing.cpuId}`) + if (listing.gpuId) tags.add(`gpu-${listing.gpuId}`) + } + + return tags +} diff --git a/src/server/cache/warming.ts b/src/server/cache/warming.ts deleted file mode 100644 index 548f5bfc9..000000000 --- a/src/server/cache/warming.ts +++ /dev/null @@ -1,208 +0,0 @@ -import { warmCache } from '@/lib/cache/seo-cache' -import { prisma } from '@/server/db' -import { - getGameForSEO, - getListingForSEO, - getPcListingForSEO, - getApprovedGamesForSitemap, - getApprovedListingsForSitemap, - getApprovedPcListingsForSitemap, -} from '@/server/db/seo-queries' -import { ListingsRepository } from '@/server/repositories/listings.repository' -import { ms } from '@/utils/time' -import { ApprovalStatus } from '@orm/client' - -/** - * Cache warming strategies for popular content - * Runs periodically to pre-populate cache with frequently accessed data - */ - -interface WarmingResult { - warmedItems: number - duration: number - errors: string[] -} - -export async function warmPopularGames(limit = 50): Promise { - const startTime = Date.now() - const errors: string[] = [] - let warmedItems = 0 - - try { - // Get most popular games (those with most listings) - const popularGames = await prisma.game.findMany({ - where: { status: ApprovalStatus.APPROVED }, - select: { - id: true, - _count: { select: { listings: true, pcListings: true } }, - }, - orderBy: [{ listings: { _count: 'desc' } }], - take: limit, - }) - - // Warm cache for popular games - const gameWarmingTasks = popularGames.map((game) => ({ - key: `seo:game:${game.id}`, - fetchFn: () => getGameForSEO(game.id), - })) - - await warmCache(gameWarmingTasks, { - ttl: ms.hours(1), - staleWhileRevalidate: ms.days(1), - }) - - warmedItems += gameWarmingTasks.length - } catch (error) { - errors.push(`Failed to warm popular games: ${error}`) - } - - return { - warmedItems, - duration: Date.now() - startTime, - errors, - } -} - -export async function warmRecentListings(limit = 100): Promise { - const startTime = Date.now() - const errors: string[] = [] - let warmedItems = 0 - - try { - const repository = new ListingsRepository(prisma) - - // Get recent approved listings using repository - const recentListingsResult = await repository.listRecent(limit) - const recentListings = recentListingsResult.listings.map((listing) => ({ id: listing.id })) - - // Get recent approved PC listings - const recentPcListings = await prisma.pcListing.findMany({ - where: { status: ApprovalStatus.APPROVED }, - select: { id: true }, - orderBy: { createdAt: 'desc' }, - take: limit, - }) - - // Warm cache for listings separately - const listingTasks = recentListings.map((listing) => ({ - key: `seo:listing:${listing.id}`, - fetchFn: () => getListingForSEO(listing.id), - })) - - const pcListingTasks = recentPcListings.map((listing) => ({ - key: `seo:pclisting:${listing.id}`, - fetchFn: () => getPcListingForSEO(listing.id), - })) - - await warmCache(listingTasks, { - ttl: ms.minutes(30), - staleWhileRevalidate: ms.hours(12), - }) - - await warmCache(pcListingTasks, { - ttl: ms.minutes(30), - staleWhileRevalidate: ms.hours(12), - }) - - warmedItems += listingTasks.length + pcListingTasks.length - } catch (error) { - errors.push(`Failed to warm recent listings: ${error}`) - } - - return { - warmedItems, - duration: Date.now() - startTime, - errors, - } -} - -export async function warmSitemapData(): Promise { - const startTime = Date.now() - const errors: string[] = [] - let warmedItems = 0 - - try { - // Warm sitemap data - const sitemapTasks = [ - { - key: 'seo:sitemap:games:1000', - fetchFn: () => getApprovedGamesForSitemap(1000), - }, - { - key: 'seo:sitemap:listings:500', - fetchFn: () => getApprovedListingsForSitemap(500), - }, - { - key: 'seo:sitemap:pclistings:500', - fetchFn: () => getApprovedPcListingsForSitemap(500), - }, - ] - - await warmCache(sitemapTasks, { - ttl: ms.hours(6), - staleWhileRevalidate: ms.days(2), - }) - - warmedItems += sitemapTasks.length - } catch (error) { - errors.push(`Failed to warm sitemap data: ${error}`) - } - - return { - warmedItems, - duration: Date.now() - startTime, - errors, - } -} - -export async function warmAllCaches(): Promise { - console.log('[Cache Warming] Starting comprehensive cache warming...') - - const results = await Promise.all([ - warmPopularGames(50), - warmRecentListings(100), - warmSitemapData(), - ]) - - const totalWarmed = results.reduce((sum, r) => sum + r.warmedItems, 0) - const totalDuration = Math.max(...results.map((r) => r.duration)) - const allErrors = results.flatMap((r) => r.errors) - - console.info(`[Cache Warming] Completed: ${totalWarmed} items warmed in ${totalDuration}ms`) - - if (allErrors.length > 0) { - console.error('[Cache Warming] Errors encountered:', allErrors) - } - - return { - warmedItems: totalWarmed, - duration: totalDuration, - errors: allErrors, - } -} - -/** - * Schedule cache warming - * This should be called from a cron job or similar scheduler - */ -export function scheduleCacheWarming() { - // Initial warming on startup - setTimeout(() => { - warmAllCaches().catch(console.error) - }, ms.seconds(5)) - - // Warm popular content every hour - setInterval(() => { - warmPopularGames(30).catch(console.error) - }, ms.hours(1)) - - // Warm recent listings every 30 minutes - setInterval(() => { - warmRecentListings(50).catch(console.error) - }, ms.minutes(30)) - - // Warm sitemap data every 6 hours - setInterval(() => { - warmSitemapData().catch(console.error) - }, ms.hours(6)) -} diff --git a/src/server/db/seo-queries.test.ts b/src/server/db/seo-queries.test.ts new file mode 100644 index 000000000..5fbbdb59b --- /dev/null +++ b/src/server/db/seo-queries.test.ts @@ -0,0 +1,122 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const cacheMocks = vi.hoisted(() => ({ + cacheLife: vi.fn(), + cacheTag: vi.fn(), +})) + +const prismaMocks = vi.hoisted(() => ({ + game: { + findUnique: vi.fn(), + findMany: vi.fn(), + }, + listing: { + findUnique: vi.fn(), + findMany: vi.fn(), + }, + pcListing: { + findUnique: vi.fn(), + findMany: vi.fn(), + }, + user: { + findUnique: vi.fn(), + }, +})) + +vi.mock('next/cache', () => cacheMocks) +vi.mock('@/server/db', () => ({ prisma: prismaMocks })) + +const { getApprovedGamesForSitemap, getGameForSEO, getListingForSEO, getPcListingForSEO } = + await import('./seo-queries') + +describe('SEO query cache metadata', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('uses a short cache profile for missing SEO records', async () => { + prismaMocks.game.findUnique.mockResolvedValueOnce(null) + + await getGameForSEO('game-1') + + expect(cacheMocks.cacheTag).toHaveBeenCalledWith('games', 'game-game-1') + expect(cacheMocks.cacheLife).toHaveBeenCalledWith('seo-miss') + }) + + it('does not convert SEO query failures into cacheable misses', async () => { + const error = new Error('database unavailable') + prismaMocks.game.findUnique.mockRejectedValueOnce(error) + + await expect(getGameForSEO('game-1')).rejects.toThrow(error) + + expect(cacheMocks.cacheLife).not.toHaveBeenCalled() + }) + + it('tags handheld report metadata by listing, game, device, and emulator', async () => { + const listing = { + id: 'listing-1', + gameId: 'game-1', + deviceId: 'device-1', + emulatorId: 'emulator-1', + notes: null, + createdAt: new Date('2026-01-01T00:00:00.000Z'), + game: { title: 'Game', imageUrl: null }, + device: { modelName: 'Pocket', brand: { name: 'Brand' } }, + emulator: { name: 'Emulator' }, + performance: { label: 'Perfect', rank: 5 }, + author: { name: 'Author' }, + } + prismaMocks.listing.findUnique.mockResolvedValueOnce(listing) + + await getListingForSEO('listing-1') + + expect(cacheMocks.cacheTag).toHaveBeenCalledWith('listings', 'listing-listing-1') + expect(cacheMocks.cacheTag).toHaveBeenCalledWith( + 'game-game-1', + 'device-device-1', + 'emulator-emulator-1', + ) + expect(cacheMocks.cacheLife).toHaveBeenCalledWith('seo-report') + }) + + it('tags PC report metadata by listing, game, CPU, and GPU', async () => { + const listing = { + id: 'pc-listing-1', + gameId: 'game-1', + cpuId: 'cpu-1', + gpuId: 'gpu-1', + notes: null, + createdAt: new Date('2026-01-01T00:00:00.000Z'), + game: { title: 'Game', imageUrl: null }, + cpu: { modelName: 'CPU', brand: { name: 'Brand' } }, + gpu: { modelName: 'GPU', brand: { name: 'Brand' } }, + performance: { label: 'Perfect', rank: 5 }, + author: { name: 'Author' }, + } + prismaMocks.pcListing.findUnique.mockResolvedValueOnce(listing) + + await getPcListingForSEO('pc-listing-1') + + expect(cacheMocks.cacheTag).toHaveBeenCalledWith('pc-listings', 'pc-listing-pc-listing-1') + expect(cacheMocks.cacheTag).toHaveBeenCalledWith('game-game-1', 'cpu-cpu-1', 'gpu-gpu-1') + expect(cacheMocks.cacheLife).toHaveBeenCalledWith('seo-report') + }) + + it('uses the sitemap cache profile and tags sitemap data', async () => { + prismaMocks.game.findMany.mockResolvedValueOnce([]) + + await getApprovedGamesForSitemap(1000) + + expect(cacheMocks.cacheLife).toHaveBeenCalledWith('seo-sitemap') + expect(cacheMocks.cacheTag).toHaveBeenCalledWith('sitemap', 'games') + }) + + it('does not cache an empty sitemap when the sitemap query fails', async () => { + const error = new Error('database unavailable') + prismaMocks.game.findMany.mockRejectedValueOnce(error) + + await expect(getApprovedGamesForSitemap(1000)).rejects.toThrow(error) + + expect(cacheMocks.cacheLife).not.toHaveBeenCalled() + }) +}) diff --git a/src/server/db/seo-queries.ts b/src/server/db/seo-queries.ts index a2d6c5fcc..9631237a6 100644 --- a/src/server/db/seo-queries.ts +++ b/src/server/db/seo-queries.ts @@ -1,246 +1,165 @@ -import { getFromCache } from '@/lib/cache/seo-cache' -import { withMetrics } from '@/lib/monitoring/seo-metrics' +import { cacheLife, cacheTag } from 'next/cache' import { prisma } from '@/server/db' -import { ms } from '@/utils/time' import { ApprovalStatus } from '@orm/client' -/** - * Database queries optimized for SEO metadata generation. - * These queries are designed to be fast and fetch only the data needed for metadata. - * All queries use caching to minimize database load. - */ - -// Cache TTLs based on content type -const CACHE_TTL = { - GAME: ms.hours(1), - LISTING: ms.minutes(30), - USER: ms.hours(1), - SITEMAP: ms.hours(6), -} - -const CACHE_STALE = { - GAME: ms.days(1), - LISTING: ms.hours(12), - USER: ms.days(1), - SITEMAP: ms.days(2), -} +const SEO_CACHE_PROFILE = { + RECORD: 'seo-record', + REPORT: 'seo-report', + SITEMAP: 'seo-sitemap', + MISS: 'seo-miss', +} as const export async function getGameForSEO(id: string) { - return withMetrics( - 'getGameForSEO', - () => - getFromCache( - `seo:game:${id}`, - async () => { - try { - return await prisma.game.findUnique({ - where: { id }, - select: { - id: true, - title: true, - imageUrl: true, - system: { select: { name: true } }, - }, - }) - } catch { - return null - } - }, - { - ttl: CACHE_TTL.GAME, - staleWhileRevalidate: CACHE_STALE.GAME, - }, - ), - { cacheKey: `seo:game:${id}` }, - ) + 'use cache' + + const game = await prisma.game.findUnique({ + where: { id }, + select: { + id: true, + title: true, + imageUrl: true, + system: { select: { name: true } }, + }, + }) + + cacheTag('games', `game-${id}`) + cacheLife(game ? SEO_CACHE_PROFILE.RECORD : SEO_CACHE_PROFILE.MISS) + return game } export async function getListingForSEO(id: string) { - return withMetrics( - 'getListingForSEO', - () => - getFromCache( - `seo:listing:${id}`, - async () => { - try { - return await prisma.listing.findUnique({ - where: { id }, - select: { - id: true, - notes: true, - createdAt: true, - game: { select: { title: true, imageUrl: true } }, - device: { - select: { - modelName: true, - brand: { select: { name: true } }, - }, - }, - emulator: { select: { name: true } }, - performance: { select: { label: true, rank: true } }, - author: { select: { name: true } }, - }, - }) - } catch { - return null - } - }, - { - ttl: CACHE_TTL.LISTING, - staleWhileRevalidate: CACHE_STALE.LISTING, + 'use cache' + + const listing = await prisma.listing.findUnique({ + where: { id }, + select: { + id: true, + gameId: true, + deviceId: true, + emulatorId: true, + notes: true, + createdAt: true, + game: { select: { title: true, imageUrl: true } }, + device: { + select: { + modelName: true, + brand: { select: { name: true } }, }, - ), - { cacheKey: `seo:listing:${id}` }, - ) + }, + emulator: { select: { name: true } }, + performance: { select: { label: true, rank: true } }, + author: { select: { name: true } }, + }, + }) + + cacheTag('listings', `listing-${id}`) + if (listing) { + cacheTag( + `game-${listing.gameId}`, + `device-${listing.deviceId}`, + `emulator-${listing.emulatorId}`, + ) + } + + cacheLife(listing ? SEO_CACHE_PROFILE.REPORT : SEO_CACHE_PROFILE.MISS) + return listing } export async function getPcListingForSEO(id: string) { - return withMetrics( - 'getPcListingForSEO', - () => - getFromCache( - `seo:pclisting:${id}`, - async () => { - try { - return await prisma.pcListing.findUnique({ - where: { id }, - select: { - id: true, - notes: true, - createdAt: true, - game: { select: { title: true, imageUrl: true } }, - cpu: { - select: { - modelName: true, - brand: { select: { name: true } }, - }, - }, - gpu: { - select: { - modelName: true, - brand: { select: { name: true } }, - }, - }, - performance: { select: { label: true, rank: true } }, - author: { select: { name: true } }, - }, - }) - } catch { - return null - } + 'use cache' + + const listing = await prisma.pcListing.findUnique({ + where: { id }, + select: { + id: true, + gameId: true, + cpuId: true, + gpuId: true, + notes: true, + createdAt: true, + game: { select: { title: true, imageUrl: true } }, + cpu: { + select: { + modelName: true, + brand: { select: { name: true } }, }, - { - ttl: CACHE_TTL.LISTING, - staleWhileRevalidate: CACHE_STALE.LISTING, + }, + gpu: { + select: { + modelName: true, + brand: { select: { name: true } }, }, - ), - { cacheKey: `seo:pclisting:${id}` }, - ) + }, + performance: { select: { label: true, rank: true } }, + author: { select: { name: true } }, + }, + }) + + cacheTag('pc-listings', `pc-listing-${id}`) + if (listing) { + const tags = [`game-${listing.gameId}`, `cpu-${listing.cpuId}`] + if (listing.gpuId) tags.push(`gpu-${listing.gpuId}`) + cacheTag(...tags) + } + + cacheLife(listing ? SEO_CACHE_PROFILE.REPORT : SEO_CACHE_PROFILE.MISS) + return listing } export async function getUserForSEO(id: string) { - return withMetrics( - 'getUserForSEO', - () => - getFromCache( - `seo:user:${id}`, - async () => { - try { - return await prisma.user.findUnique({ - where: { id }, - select: { id: true, name: true, profileImage: true }, - }) - } catch { - return null - } - }, - { - ttl: CACHE_TTL.USER, - staleWhileRevalidate: CACHE_STALE.USER, - }, - ), - { cacheKey: `seo:user:${id}` }, - ) + 'use cache' + + const user = await prisma.user.findUnique({ + where: { id }, + select: { id: true, name: true, profileImage: true }, + }) + + cacheTag(`user-${id}`) + cacheLife(user ? SEO_CACHE_PROFILE.RECORD : SEO_CACHE_PROFILE.MISS) + return user } -// For sitemap generation - cached longer since it's expensive export async function getApprovedGamesForSitemap(limit = 1000) { - return withMetrics( - 'getApprovedGamesForSitemap', - () => - getFromCache( - `seo:sitemap:games:${limit}`, - async () => { - try { - return await prisma.game.findMany({ - where: { status: ApprovalStatus.APPROVED }, - select: { id: true, createdAt: true }, - orderBy: { createdAt: 'desc' }, - take: limit, - }) - } catch { - return [] - } - }, - { - ttl: CACHE_TTL.SITEMAP, - staleWhileRevalidate: CACHE_STALE.SITEMAP, - }, - ), - { cacheKey: `seo:sitemap:games:${limit}` }, - ) + 'use cache' + + const games = await prisma.game.findMany({ + where: { status: ApprovalStatus.APPROVED }, + select: { id: true, createdAt: true }, + orderBy: { createdAt: 'desc' }, + take: limit, + }) + + cacheLife(SEO_CACHE_PROFILE.SITEMAP) + cacheTag('sitemap', 'games') + return games } export async function getApprovedListingsForSitemap(limit = 500) { - return withMetrics( - 'getApprovedListingsForSitemap', - () => - getFromCache( - `seo:sitemap:listings:${limit}`, - async () => { - try { - return await prisma.listing.findMany({ - where: { status: ApprovalStatus.APPROVED }, - select: { id: true, createdAt: true }, - orderBy: { createdAt: 'desc' }, - take: limit, - }) - } catch { - return [] - } - }, - { - ttl: CACHE_TTL.SITEMAP, - staleWhileRevalidate: CACHE_STALE.SITEMAP, - }, - ), - { cacheKey: `seo:sitemap:listings:${limit}` }, - ) + 'use cache' + + const listings = await prisma.listing.findMany({ + where: { status: ApprovalStatus.APPROVED }, + select: { id: true, createdAt: true }, + orderBy: { createdAt: 'desc' }, + take: limit, + }) + + cacheLife(SEO_CACHE_PROFILE.SITEMAP) + cacheTag('sitemap', 'listings') + return listings } export async function getApprovedPcListingsForSitemap(limit = 500) { - return withMetrics( - 'getApprovedPcListingsForSitemap', - () => - getFromCache( - `seo:sitemap:pclistings:${limit}`, - async () => { - try { - return await prisma.pcListing.findMany({ - where: { status: ApprovalStatus.APPROVED }, - select: { id: true, createdAt: true }, - orderBy: { createdAt: 'desc' }, - take: limit, - }) - } catch { - return [] - } - }, - { - ttl: CACHE_TTL.SITEMAP, - staleWhileRevalidate: CACHE_STALE.SITEMAP, - }, - ), - { cacheKey: `seo:sitemap:pclistings:${limit}` }, - ) + 'use cache' + + const listings = await prisma.pcListing.findMany({ + where: { status: ApprovalStatus.APPROVED }, + select: { id: true, createdAt: true }, + orderBy: { createdAt: 'desc' }, + take: limit, + }) + + cacheLife(SEO_CACHE_PROFILE.SITEMAP) + cacheTag('sitemap', 'pc-listings') + return listings } diff --git a/src/server/notifications/analyticsService.ts b/src/server/notifications/analyticsService.ts index 032324a2b..40c168678 100644 --- a/src/server/notifications/analyticsService.ts +++ b/src/server/notifications/analyticsService.ts @@ -489,13 +489,6 @@ export class NotificationAnalyticsService { notificationAnalyticsCache.clear() } - /** - * Get cache statistics for monitoring - */ - getCacheStats() { - return notificationAnalyticsCache.getStats() - } - private buildDateFilter(startDate?: Date, endDate?: Date): Record { const filter: Record = {} diff --git a/src/server/services/catalog.service.test.ts b/src/server/services/catalog.service.test.ts new file mode 100644 index 000000000..f2404f9d0 --- /dev/null +++ b/src/server/services/catalog.service.test.ts @@ -0,0 +1,88 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { Role, type PrismaClient } from '@orm/client' + +const mockDeviceById = vi.hoisted(() => vi.fn()) +const mockFindByModelAndBrandName = vi.hoisted(() => vi.fn()) +const mockGetDeviceCompatibilityData = vi.hoisted(() => vi.fn()) +const mockGetSocCompatibilityData = vi.hoisted(() => vi.fn()) + +const cacheMocks = vi.hoisted(() => ({ + get: vi.fn(), + set: vi.fn(), +})) + +vi.mock('@/server/repositories/devices.repository', () => ({ + DevicesRepository: vi.fn().mockImplementation(function MockDevicesRepository() { + return { + byId: mockDeviceById, + findByModelAndBrandName: mockFindByModelAndBrandName, + } + }), +})) + +vi.mock('@/server/repositories/listings.repository', () => ({ + ListingsRepository: vi.fn().mockImplementation(function MockListingsRepository() { + return { + getDeviceCompatibilityData: mockGetDeviceCompatibilityData, + getSocCompatibilityData: mockGetSocCompatibilityData, + } + }), +})) + +vi.mock('@/server/utils/cache/instances', () => ({ + catalogCompatibilityCache: cacheMocks, +})) + +const { getDeviceCompatibility } = await import('./catalog.service') + +const device = { + id: 'device-1', + modelName: 'Pocket', + socId: 'soc-1', + brand: { name: 'Brand' }, + soc: { id: 'soc-1', name: 'SoC', manufacturer: 'Maker' }, +} + +const cachedResponse = { + device: { + id: 'device-1', + modelName: 'Pocket', + brandName: 'Brand', + socId: 'soc-1', + socName: 'SoC', + socManufacturer: 'Maker', + }, + systems: [], + generatedAt: new Date('2026-01-01T00:00:00.000Z'), + cacheExpiresIn: 600, +} + +describe('catalog compatibility cache', () => { + beforeEach(() => { + vi.clearAllMocks() + mockDeviceById.mockResolvedValue(device) + cacheMocks.get.mockReturnValue(cachedResponse) + }) + + it('uses a public visibility cache key for regular users', async () => { + await getDeviceCompatibility( + { deviceId: 'device-1', systemIds: ['system-b', 'system-a'] }, + { prisma: {} as PrismaClient, userRole: Role.USER, userId: 'user-1' }, + ) + + expect(cacheMocks.get).toHaveBeenCalledWith( + 'device:device-1:systems:system-a,system-b:breakdown:true:min:1:soc:soc-1:visibility:public', + ) + }) + + it('uses a moderator visibility cache key for moderators', async () => { + await getDeviceCompatibility( + { deviceId: 'device-1', includeEmulatorBreakdown: false, minListingCount: 3 }, + { prisma: {} as PrismaClient, userRole: Role.MODERATOR, userId: 'mod-1' }, + ) + + expect(cacheMocks.get).toHaveBeenCalledWith( + 'device:device-1:systems:all:breakdown:false:min:3:soc:soc-1:visibility:moderator', + ) + }) +}) diff --git a/src/server/services/catalog.service.ts b/src/server/services/catalog.service.ts index 2ac4b4c26..2f5cdcea8 100644 --- a/src/server/services/catalog.service.ts +++ b/src/server/services/catalog.service.ts @@ -8,12 +8,13 @@ import { MINIMUM_DEVICE_LISTINGS, type ScoringListingWithMetadata, } from '@/server/utils/compatibility-scoring' +import { hasRolePermission } from '@/utils/permissions' +import { Role, type PrismaClient } from '@orm/client' import type { DeviceCompatibilityResponse, EmulatorCompatibility, SystemCompatibility, } from '@/schemas/mobile' -import type { Role, PrismaClient } from '@orm/client' export interface GetDeviceCompatibilityInput { deviceId?: string @@ -53,7 +54,6 @@ export async function getDeviceCompatibility( const devicesRepo = new DevicesRepository(ctx.prisma) const listingsRepo = new ListingsRepository(ctx.prisma) - // Resolve device by ID or by name + brand let device if (input.deviceId) { device = await devicesRepo.byId(input.deviceId) @@ -63,14 +63,14 @@ export async function getDeviceCompatibility( if (!device) throw ResourceError.device.notFound() - // Create cache key from device ID and input parameters (include SoC in key) - const cacheKey = `device:${device.id}:systems:${input.systemIds?.sort().join(',') ?? 'all'}:breakdown:${input.includeEmulatorBreakdown ?? true}:min:${input.minListingCount ?? 1}:soc:${device.socId ?? 'none'}` + const systemKey = input.systemIds ? [...input.systemIds].sort().join(',') : 'all' + const visibilityBucket = + ctx.userRole && hasRolePermission(ctx.userRole, Role.MODERATOR) ? 'moderator' : 'public' + const cacheKey = `device:${device.id}:systems:${systemKey}:breakdown:${input.includeEmulatorBreakdown ?? true}:min:${input.minListingCount ?? 1}:soc:${device.socId ?? 'none'}:visibility:${visibilityBucket}` - // Check cache first const cached = catalogCompatibilityCache.get(cacheKey) if (cached) return cached - // Fetch device-specific listings const deviceListings = await listingsRepo.getDeviceCompatibilityData(device.id, { systemIds: input.systemIds, userRole: ctx.userRole, @@ -88,11 +88,10 @@ export async function getDeviceCompatibility( }, systems: [], generatedAt: new Date(), - cacheExpiresIn: 600, // 10 minutes + cacheExpiresIn: 600, } } - // Get all verified developers for emulators in these listings const allListings = [...deviceListings] const emulatorIds = [...new Set(allListings.map((l) => l.emulatorId))] const verifiedDevs = await ctx.prisma.verifiedDeveloper.findMany({ @@ -100,7 +99,6 @@ export async function getDeviceCompatibility( select: { userId: true, emulatorId: true }, }) - // Create a map of verified developers: userId_emulatorId -> true const verifiedDevMap = new Set() for (const vd of verifiedDevs) { verifiedDevMap.add(`${vd.userId}_${vd.emulatorId}`) @@ -113,13 +111,11 @@ export async function getDeviceCompatibility( const deviceSystemAggregations = aggregateBySystem(listingsWithMetadata) - // Track data source info per system const dataSourceMap = new Map< string, { dataSource: 'device' | 'soc'; deviceCount: number; socCount: number; deviceIds: Set } >() - // Initialize all device systems as 'device' source for (const sysAgg of deviceSystemAggregations) { dataSourceMap.set(sysAgg.system.id, { dataSource: 'device', @@ -129,12 +125,10 @@ export async function getDeviceCompatibility( }) } - // Identify systems needing SoC fallback const systemsNeedingFallback = deviceSystemAggregations.filter( (s) => s.listings.length < MINIMUM_DEVICE_LISTINGS, ) - // If device has SoC AND some systems need fallback, fetch SoC data if (device.socId && systemsNeedingFallback.length > 0) { const socListings = await listingsRepo.getSocCompatibilityData(device.socId, device.id, { systemIds: systemsNeedingFallback.map((s) => s.system.id), @@ -142,7 +136,6 @@ export async function getDeviceCompatibility( }) if (socListings.length > 0) { - // Track unique devices contributing SoC data per system const socDevicesBySystem = new Map>() for (const listing of socListings) { @@ -153,16 +146,13 @@ export async function getDeviceCompatibility( socDevicesBySystem.get(systemId)!.add(listing.deviceId) } - // Enhance SoC listings with metadata const socListingsWithMetadata: ScoringListingWithMetadata[] = socListings.map((listing) => ({ ...listing, isVerifiedDeveloper: verifiedDevMap.has(`${listing.authorId}_${listing.emulatorId}`), })) - // Combine device + SoC listings listingsWithMetadata = [...listingsWithMetadata, ...socListingsWithMetadata] - // Update data source info for systems that used SoC fallback for (const sysAgg of systemsNeedingFallback) { const systemId = sysAgg.system.id const socDevices = socDevicesBySystem.get(systemId) @@ -178,15 +168,12 @@ export async function getDeviceCompatibility( } } - // Re-aggregate with combined listings const systemAggregations = aggregateBySystem(listingsWithMetadata) - // Filter by minimum listing count const filteredSystems = systemAggregations.filter( (s) => s.listings.length >= (input.minListingCount ?? 1), ) - // Convert to response format const systems: SystemCompatibility[] = filteredSystems.map((systemAgg) => { const confidence = calculateConfidenceLevel(systemAgg.listings.length, systemAgg.totalVotes) const sourceInfo = dataSourceMap.get(systemAgg.system.id) @@ -255,7 +242,7 @@ export async function getDeviceCompatibility( cacheExpiresIn: 600, // 10 minutes } - catalogCompatibilityCache.set(cacheKey, response) // Cache the response + catalogCompatibilityCache.set(cacheKey, response) return response } diff --git a/src/server/tgdb.ts b/src/server/tgdb.ts index 5f449e587..c7262eb11 100644 --- a/src/server/tgdb.ts +++ b/src/server/tgdb.ts @@ -77,15 +77,12 @@ export async function searchGames( throw new TGDBError('Search query cannot be empty') } - // Get TGDB platform ID from system key const tgdbPlatformId = systemKey ? (PLATFORM_MAPPINGS.tgdb[systemKey as PlatformKey] ?? null) : null - // Create cache key for this search const cacheKey = createCacheKey('tgdb:searchGames', query.trim(), systemKey ?? 'none', page) - // Check cache first const cached = tgdbGamesCache.get(cacheKey) if (cached) return cached @@ -95,12 +92,10 @@ export async function searchGames( include: 'boxart', } - // Add platform filter if platform ID is provided if (tgdbPlatformId) params['filter[platform]'] = tgdbPlatformId.toString() const response = await makeRequest('/v1.1/Games/ByGameName', params) - // Score and sort the results if we have games if (response.data.games.length > 0) { const scoredResults = response.data.games.map((game, originalIndex) => ({ game, @@ -108,7 +103,6 @@ export async function searchGames( originalIndex, })) - // Sort by score (descending), then by original index (ascending) for ties scoredResults.sort((a, b) => b.score !== a.score ? b.score - a.score : a.originalIndex - b.originalIndex, ) @@ -116,13 +110,11 @@ export async function searchGames( response.data.games = scoredResults.map((item) => item.game) } - // Cache the response tgdbGamesCache.set(cacheKey, response) return response } -// Helper function to get boxart URL from search response export function getBoxartUrlFromGame( gameId: number, searchResponse: TGDBGamesByNameResponse, @@ -130,7 +122,6 @@ export function getBoxartUrlFromGame( const gameIdStr = gameId.toString() const boxartData = searchResponse.include?.boxart?.data[gameIdStr] - // Early returns to avoid deep nesting if (!boxartData?.length) return undefined if (!searchResponse.include?.boxart?.base_url) return undefined @@ -146,11 +137,9 @@ export async function getGameImages(gameIds: number[]): Promise a - b) // Sort for consistent caching + const sortedIds = [...gameIds].sort((a, b) => a - b) const cacheKey = createCacheKey('tgdb:getGameImages', sortedIds.join(',')) - // Check cache first const cached = tgdbImagesCache.get(cacheKey) if (cached) return cached @@ -159,17 +148,14 @@ export async function getGameImages(gameIds: number[]): Promise { - // Create cache key for platforms (this data rarely changes) const cacheKey = createCacheKey('tgdb:getPlatforms') - // Check cache first const cached = tgdbPlatformsCache.get(cacheKey) if (cached) { return cached @@ -177,8 +163,7 @@ export async function getPlatforms(): Promise { const response = await makeRequest('/v1/Platforms') - // Cache the response with longer TTL since platforms don't change often - tgdbPlatformsCache.set(cacheKey, response, 60 * 60 * 1000) // 1 hour + tgdbPlatformsCache.set(cacheKey, response, { ttl: 60 * 60 * 1000 }) return response } @@ -186,10 +171,8 @@ export async function getPlatforms(): Promise { export async function getGameImageUrls( gameId: number, ): Promise<{ boxartUrl?: string; bannerUrl?: string }> { - // Create cache key for this specific game's image URLs const cacheKey = createCacheKey('tgdb:getGameImageUrls', gameId) - // Check cache first const cached = tgdbImageUrlsCache.get(cacheKey) if (cached) return cached @@ -199,7 +182,6 @@ export async function getGameImageUrls( const gameIdStr = gameId.toString() const gameImagesData = imagesResponse.data.images[gameIdStr] ?? [] - // Use functional approach to find images const validImages = gameImagesData .filter((image) => image.filename && imagesResponse.data.base_url) .map((image) => ({ @@ -211,15 +193,12 @@ export async function getGameImageUrls( const boxartUrl = validImages.find((img) => img.type === 'boxart')?.fullUrl let bannerUrl = validImages.find((img) => img.type === 'banner')?.fullUrl - // Try alternative image types if banner is not found bannerUrl ??= validImages.find( (img) => img.type === 'fanart' || img.type === 'clearlogo', )?.fullUrl - // fallback to boxart if no banner found const result = { boxartUrl, bannerUrl: bannerUrl ? bannerUrl : boxartUrl } - // Cache the result tgdbImageUrlsCache.set(cacheKey, result) return result @@ -233,13 +212,10 @@ export async function searchGameImages( query: string, systemKey?: string | null, ): Promise> { - // Create cache key for this search const cacheKey = createCacheKey('tgdb:searchGameImages', query.trim(), systemKey ?? 'none') - // Check cache first - need to handle Map serialization const cached = tgdbGameImagesCache.get(cacheKey) if (cached) { - // Convert cached object back to Map const resultMap = new Map() Object.entries(cached).forEach(([gameId, images]) => { resultMap.set(parseInt(gameId), images) @@ -251,22 +227,18 @@ export async function searchGameImages( const gameImageMap = new Map() if (gamesResponse.data.games.length === 0) { - // Cache empty result too tgdbGameImagesCache.set(cacheKey, {}) return gameImageMap } try { - // Get images for all found games const gameIds = gamesResponse.data.games.map((game) => game.id) const imagesResponse = await getGameImages(gameIds) - // Process each game's images using functional approach gamesResponse.data.games.forEach((game) => { const boxartImages = createBoxartImages(game, gamesResponse) const otherImages = createOtherImages(game, imagesResponse) - // Combine and deduplicate images based on URL const allImages = [...boxartImages, ...otherImages] const uniqueImages = allImages.reduce((acc, image) => { const existingImage = acc.find((existing) => existing.url === image.url) @@ -276,7 +248,6 @@ export async function searchGameImages( if (uniqueImages.length > 0) gameImageMap.set(game.id, uniqueImages) }) - // Cache the result - convert Map to plain object for JSON serialization const cacheObject: Record = {} gameImageMap.forEach((images, gameId) => { cacheObject[gameId.toString()] = images @@ -284,13 +255,11 @@ export async function searchGameImages( tgdbGameImagesCache.set(cacheKey, cacheObject) } catch (error) { console.error('Error fetching game images from TGDB:', error) - // Continue without images rather than failing completely } return gameImageMap } -// Helper function to create boxart images from games response function createBoxartImages( game: TGDBGamesByNameResponse['data']['games'][0], gamesResponse: TGDBGamesByNameResponse, @@ -309,7 +278,7 @@ function createBoxartImages( url, resolution: boxart.resolution, id: boxart.id, - index, // Add index to ensure uniqueness + index, } }) .filter((boxart) => isValidImageUrl(boxart.url)) @@ -325,11 +294,6 @@ function createBoxartImages( })) } -/** - * Creates other image types (fanart, banner, etc.) from TGDB images response. - * @param game - The game object from TGDB search response. - * @param imagesResponse - The full images response from TGDB. - */ function createOtherImages( game: TGDBGamesByNameResponse['data']['games'][0], imagesResponse: TGDBGamesImagesResponse, diff --git a/src/server/utils/cache/MemoryCache.test.ts b/src/server/utils/cache/MemoryCache.test.ts deleted file mode 100644 index 18d8c1ec1..000000000 --- a/src/server/utils/cache/MemoryCache.test.ts +++ /dev/null @@ -1,263 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' -import MemoryCache from './MemoryCache' - -describe('MemoryCache', () => { - let cache: MemoryCache - let consoleDebugSpy: ReturnType - - beforeEach(() => { - // Mock console.debug to suppress cache cleanup messages - consoleDebugSpy = vi.spyOn(console, 'debug').mockImplementation(() => {}) - - cache = new MemoryCache({ - ttl: 50, // 50ms - much faster for tests - maxSize: 3, - cleanupInterval: 25, // 25ms - faster cleanup - }) - }) - - afterEach(() => { - cache.destroy() - consoleDebugSpy.mockRestore() - }) - - describe('basic operations', () => { - it('should store and retrieve values', () => { - cache.set('key1', 'value1') - expect(cache.get('key1')).toBe('value1') - }) - - it('should return undefined for non-existent keys', () => { - expect(cache.get('nonexistent')).toBeUndefined() - }) - - it('should delete values', () => { - cache.set('key1', 'value1') - expect(cache.delete('key1')).toBe(true) - expect(cache.get('key1')).toBeUndefined() - }) - - it('should return false when deleting non-existent keys', () => { - expect(cache.delete('nonexistent')).toBe(false) - }) - - it('should clear all values', () => { - cache.set('key1', 'value1') - cache.set('key2', 'value2') - cache.clear() - expect(cache.get('key1')).toBeUndefined() - expect(cache.get('key2')).toBeUndefined() - expect(cache.getSize()).toBe(0) - }) - }) - - describe('TTL (Time To Live)', () => { - it('should expire entries after TTL', async () => { - cache.set('key1', 'value1') - expect(cache.get('key1')).toBe('value1') - - // Wait for expiration (now much faster - 60ms) - await new Promise((resolve) => setTimeout(resolve, 60)) - - expect(cache.get('key1')).toBeUndefined() - }) - - it('should support custom TTL per entry', async () => { - cache.set('short', 'value1', 10) // 10ms TTL - cache.set('long', 'value2', 100) // 100ms TTL - - await new Promise((resolve) => setTimeout(resolve, 15)) - - expect(cache.get('short')).toBeUndefined() - expect(cache.get('long')).toBe('value2') - }) - - it('should update lastAccessed on get', () => { - cache.set('key1', 'value1') - - // Access the key multiple times - cache.get('key1') - cache.get('key1') - - expect(cache.get('key1')).toBe('value1') - }) - }) - - describe('LRU eviction', () => { - it('should evict least recently used items when at capacity', () => { - // Fill cache to capacity (maxSize: 3) - cache.set('key1', 'value1') - cache.set('key2', 'value2') - cache.set('key3', 'value3') - - // Access key1 to make it recently used - cache.get('key1') - - // Add fourth item, should evict key2 (least recently used) - cache.set('key4', 'value4') - - expect(cache.get('key1')).toBe('value1') // Still present (recently accessed) - expect(cache.get('key2')).toBeUndefined() // Evicted - expect(cache.get('key3')).toBe('value3') // Still present - expect(cache.get('key4')).toBe('value4') // Newly added - }) - - it('should not evict when updating existing keys', () => { - cache.set('key1', 'value1') - cache.set('key2', 'value2') - cache.set('key3', 'value3') - - // Update existing key (should not trigger eviction) - cache.set('key1', 'updated_value1') - - expect(cache.getSize()).toBe(3) - expect(cache.get('key1')).toBe('updated_value1') - expect(cache.get('key2')).toBe('value2') - expect(cache.get('key3')).toBe('value3') - }) - }) - - describe('pattern invalidation', () => { - it('should invalidate entries matching pattern', () => { - // Clear cache to ensure we start fresh for this test - cache.clear() - - cache.set('user:1:profile', 'profile1') - cache.set('user:1:settings', 'settings1') - cache.set('user:2:profile', 'profile2') - // Don't add the 4th item to avoid LRU eviction (maxSize: 3) - - const deletedCount = cache.invalidatePattern('user:1:*') - - expect(deletedCount).toBe(2) - expect(cache.get('user:1:profile')).toBeUndefined() - expect(cache.get('user:1:settings')).toBeUndefined() - expect(cache.get('user:2:profile')).toBe('profile2') - - // Now add the product item separately to test it wasn't affected - cache.set('product:1:info', 'product1') - expect(cache.get('product:1:info')).toBe('product1') - }) - - it('should handle complex patterns', () => { - cache.set('api:v1:users', 'users') - cache.set('api:v1:products', 'products') - cache.set('api:v2:users', 'users_v2') - - const deletedCount = cache.invalidatePattern('api:v1:*') - - expect(deletedCount).toBe(2) - expect(cache.get('api:v2:users')).toBe('users_v2') - }) - }) - - describe('statistics', () => { - it('should track cache statistics', () => { - const initialStats = cache.getStats() - expect(initialStats.hits).toBe(0) - expect(initialStats.misses).toBe(0) - expect(initialStats.size).toBe(0) - - cache.set('key1', 'value1') - cache.get('key1') // Hit - cache.get('nonexistent') // Miss - - const stats = cache.getStats() - expect(stats.hits).toBe(1) - expect(stats.misses).toBe(1) - expect(stats.size).toBe(1) - expect(stats.maxSize).toBe(3) - }) - - it('should track evictions', () => { - // Fill cache beyond capacity to trigger evictions - cache.set('key1', 'value1') - cache.set('key2', 'value2') - cache.set('key3', 'value3') - cache.set('key4', 'value4') // Should trigger eviction - - const stats = cache.getStats() - expect(stats.evictions).toBe(1) - }) - }) - - describe('cleanup and memory management', () => { - it('should automatically cleanup expired entries', async () => { - cache.set('temp1', 'value1', 10) // 10ms TTL - cache.set('temp2', 'value2', 10) // 10ms TTL - cache.set('permanent', 'value3', 200) // 200ms TTL - - expect(cache.getSize()).toBe(3) - - // Wait for cleanup to run (cleanupInterval: 25ms, entries expire after 10ms) - await new Promise((resolve) => setTimeout(resolve, 35)) - - expect(cache.getSize()).toBe(1) - expect(cache.get('permanent')).toBe('value3') - }) - - it('should destroy properly and clear timer', () => { - const destroySpy = vi.spyOn(cache, 'destroy') - - cache.destroy() - - expect(destroySpy).toHaveBeenCalled() - expect(cache.getSize()).toBe(0) - }) - }) - - describe('type safety', () => { - it('should work with different value types', () => { - const numberCache = new MemoryCache({ - ttl: 1000, - maxSize: 10, - }) - - const objectCache = new MemoryCache<{ id: number; name: string }>({ - ttl: 1000, - maxSize: 10, - }) - - numberCache.set('count', 42) - objectCache.set('user', { id: 1, name: 'John' }) - - expect(numberCache.get('count')).toBe(42) - expect(objectCache.get('user')).toEqual({ id: 1, name: 'John' }) - - numberCache.destroy() - objectCache.destroy() - }) - }) - - describe('edge cases', () => { - it('should handle rapid successive operations', () => { - // Rapidly set and get many values - for (let i = 0; i < 100; i++) { - cache.set(`key${i}`, `value${i}`) - } - - // Should only keep the last 3 due to maxSize: 3 - expect(cache.getSize()).toBe(3) - expect(cache.get('key99')).toBe('value99') - expect(cache.get('key98')).toBe('value98') - expect(cache.get('key97')).toBe('value97') - }) - - it('should handle setting the same key multiple times', () => { - cache.set('key1', 'value1') - cache.set('key1', 'value2') - cache.set('key1', 'value3') - - expect(cache.get('key1')).toBe('value3') - expect(cache.getSize()).toBe(1) - }) - - it('should handle empty string keys and values', () => { - cache.set('', 'empty_key') - cache.set('empty_value', '') - - expect(cache.get('')).toBe('empty_key') - expect(cache.get('empty_value')).toBe('') - }) - }) -}) diff --git a/src/server/utils/cache/MemoryCache.ts b/src/server/utils/cache/MemoryCache.ts deleted file mode 100644 index 0b99c3c06..000000000 --- a/src/server/utils/cache/MemoryCache.ts +++ /dev/null @@ -1,210 +0,0 @@ -/** - * High-performance in-memory cache with TTL, LRU eviction, and memory management - * Features: Time-to-live, Least Recently Used eviction, automatic cleanup, cache statistics - */ - -interface CacheEntry { - value: T - expires: number - lastAccessed: number - createdAt: number -} - -interface CacheStats { - hits: number - misses: number - size: number - maxSize: number - evictions: number -} - -interface CacheOptions { - ttl: number // Time to live in milliseconds - maxSize: number // Maximum number of entries - cleanupInterval?: number // Cleanup interval in milliseconds (default: 5 minutes) -} - -class MemoryCache { - private cache = new Map>() - private readonly options: Required - private cleanupTimer: NodeJS.Timeout | null = null - private entryCounter = 0 // Add counter for unique timestamps - private stats: CacheStats = { - hits: 0, - misses: 0, - size: 0, - maxSize: 0, - evictions: 0, - } - - constructor(options: CacheOptions) { - this.options = { - cleanupInterval: 5 * 60 * 1000, // 5 minutes default - ...options, - } - this.stats.maxSize = options.maxSize - this.startCleanupTimer() - } - - get(key: string): T | undefined { - const entry = this.cache.get(key) - - if (!entry) { - this.stats.misses++ - return undefined - } - - const now = Date.now() - - // Check if expired - if (entry.expires < now) { - this.cache.delete(key) - this.stats.misses++ - this.stats.size-- - return undefined - } - - // Update last accessed time for LRU with unique ordering - entry.lastAccessed = now + this.entryCounter++ * 0.001 - this.stats.hits++ - return entry.value - } - - set(key: string, value: T, customTtl?: number): void { - const now = Date.now() - const ttl = customTtl ?? this.options.ttl - - const entry: CacheEntry = { - value, - expires: now + ttl, - lastAccessed: now + this.entryCounter++ * 0.001, // Add microsecond precision - createdAt: now, - } - - const isExistingKey = this.cache.has(key) - - // If we're at capacity and this is a new key, evict LRU - if (!isExistingKey && this.cache.size >= this.options.maxSize) { - this.evictLRU() - } - - this.cache.set(key, entry) - - // Update stats only for new keys - if (!isExistingKey) { - this.stats.size++ - } - } - - delete(key: string): boolean { - const deleted = this.cache.delete(key) - if (deleted) { - this.stats.size-- - } - return deleted - } - - clear(): void { - this.cache.clear() - this.stats.size = 0 - } - - invalidatePattern(pattern: string): number { - // Escape special regex characters except for our wildcard * - const escapedPattern = pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/\\\*/g, '.*') - const regex = new RegExp(`^${escapedPattern}$`) - let deletedCount = 0 - - // Collect keys to delete first to avoid modifying during iteration - const keysToDelete: string[] = [] - for (const key of this.cache.keys()) { - if (regex.test(key)) { - keysToDelete.push(key) - } - } - - // Delete the collected keys - for (const key of keysToDelete) { - this.cache.delete(key) - deletedCount++ - } - - // Update stats.size to match actual cache size - this.stats.size = this.cache.size - - return deletedCount - } - - getStats(): Readonly { - return { ...this.stats } - } - - getSize(): number { - return this.cache.size - } - - getCreatedAt(key: string): Date | null { - const entry = this.cache.get(key) - if (!entry) return null - - // Update last accessed time - entry.lastAccessed = Date.now() - - return new Date(entry.createdAt) - } - - destroy(): void { - if (this.cleanupTimer) { - clearInterval(this.cleanupTimer) - this.cleanupTimer = null - } - this.clear() - } - - private evictLRU(): void { - let oldestKey: string | null = null - let oldestTime = Infinity - - for (const [key, entry] of this.cache.entries()) { - if (entry.lastAccessed < oldestTime) { - oldestTime = entry.lastAccessed - oldestKey = key - } - } - - if (oldestKey) { - this.cache.delete(oldestKey) - this.stats.evictions++ - this.stats.size-- - } - } - - private startCleanupTimer(): void { - this.cleanupTimer = setInterval(() => { - this.cleanup() - }, this.options.cleanupInterval) - // Allow process to exit if this is the only active timer - this.cleanupTimer.unref?.() - } - - private cleanup(): void { - const now = Date.now() - let cleanedCount = 0 - - for (const [key, entry] of this.cache.entries()) { - if (entry.expires < now) { - this.cache.delete(key) - cleanedCount++ - } - } - - // Update stats.size to match actual cache size - this.stats.size = this.cache.size - - if (cleanedCount > 0) { - console.debug(`Cache cleanup: removed ${cleanedCount} expired entries`) - } - } -} - -export default MemoryCache diff --git a/src/server/utils/cache/index.ts b/src/server/utils/cache/index.ts index 93bc865f6..32d7510c1 100644 --- a/src/server/utils/cache/index.ts +++ b/src/server/utils/cache/index.ts @@ -1,8 +1,2 @@ -/** - * Cache module exports - * in-memory caching with TTL, LRU eviction, and type safety - */ - -export { default as MemoryCache } from './MemoryCache' export * from './instances' export * from './utils' diff --git a/src/server/utils/cache/instances.ts b/src/server/utils/cache/instances.ts index 8c8338487..eefdc6bba 100644 --- a/src/server/utils/cache/instances.ts +++ b/src/server/utils/cache/instances.ts @@ -1,10 +1,5 @@ -/** - * Cache instances for different parts of the application - * Each cache is typed for its specific use case - */ - +import { LRUCache } from 'lru-cache' import { TIME_CONSTANTS } from '@/utils/time' -import MemoryCache from './MemoryCache' import type { DeviceCompatibilityResponse } from '@/schemas/mobile' import type { BatchBySteamAppIdsResponse } from '@/server/api/routers/mobile/games' import type { @@ -23,30 +18,34 @@ import type { } from '@/types/tgdb' import type { NotificationType } from '@orm/client' -// Game statistics cache -export const gameStatsCache = new MemoryCache<{ - pending: number - approved: number - rejected: number - total: number -}>({ +export const gameStatsCache = new LRUCache< + string, + { + pending: number + approved: number + rejected: number + total: number + } +>({ ttl: TIME_CONSTANTS.FIVE_MINUTES, - maxSize: 100, // Small cache for stats + max: 100, }) -// Listing statistics cache -export const listingStatsCache = new MemoryCache<{ - pending: number - approved: number - rejected: number - total: number -}>({ +export const listingStatsCache = new LRUCache< + string, + { + pending: number + approved: number + rejected: number + total: number + } +>({ ttl: TIME_CONSTANTS.FIVE_MINUTES, - maxSize: 100, // Small cache for stats + max: 100, }) -// Notification analytics cache - for expensive analytics queries -export const notificationAnalyticsCache = new MemoryCache< +export const notificationAnalyticsCache = new LRUCache< + string, | NotificationMetrics | ChannelMetrics | TypeMetrics @@ -59,53 +58,64 @@ export const notificationAnalyticsCache = new MemoryCache< clickRate: number }[] >({ - ttl: TIME_CONSTANTS.TEN_MINUTES, // analytics can be slightly stale - maxSize: 200, // Analytics queries with different date ranges and params + ttl: TIME_CONSTANTS.TEN_MINUTES, + max: 200, }) -// TGDB-specific caches with proper typing -export const tgdbGamesCache = new MemoryCache({ +export const tgdbGamesCache = new LRUCache({ ttl: TIME_CONSTANTS.TEN_MINUTES, - maxSize: 200, + max: 200, }) -export const tgdbImagesCache = new MemoryCache({ +export const tgdbImagesCache = new LRUCache({ ttl: TIME_CONSTANTS.TEN_MINUTES, - maxSize: 200, + max: 200, }) -export const tgdbPlatformsCache = new MemoryCache({ - ttl: TIME_CONSTANTS.TEN_MINUTES, // rarely change - maxSize: 10, +export const tgdbPlatformsCache = new LRUCache({ + ttl: TIME_CONSTANTS.TEN_MINUTES, + max: 10, }) -export const tgdbImageUrlsCache = new MemoryCache<{ - boxartUrl?: string - bannerUrl?: string -}>({ +export const tgdbImageUrlsCache = new LRUCache< + string, + { + boxartUrl?: string + bannerUrl?: string + } +>({ ttl: TIME_CONSTANTS.TEN_MINUTES, - maxSize: 500, + max: 500, }) -export const tgdbGameImagesCache = new MemoryCache>({ +export const tgdbGameImagesCache = new LRUCache>({ ttl: TIME_CONSTANTS.TEN_MINUTES, - maxSize: 100, + max: 100, }) -// Driver version cache to avoid hitting GitHub rate limits -export const driverVersionsCache = new MemoryCache({ +export const driverVersionsCache = new LRUCache({ ttl: TIME_CONSTANTS.THIRTY_MINUTES, - maxSize: 1, + max: 1, }) -// Batch Steam App ID lookup cache - for GameHub Lite integration -export const steamBatchQueryCache = new MemoryCache({ - ttl: TIME_CONSTANTS.TEN_MINUTES, // listings data changes frequently - maxSize: 100, // Cache up to 100 different batch queries +export const steamBatchQueryCache = new LRUCache({ + ttl: TIME_CONSTANTS.TEN_MINUTES, + max: 100, }) -// Catalog compatibility cache - for RetroCatalog integration -export const catalogCompatibilityCache = new MemoryCache({ - ttl: TIME_CONSTANTS.TEN_MINUTES, // balance freshness with server load - maxSize: 500, // cache popular device queries +export const catalogCompatibilityCache = new LRUCache({ + ttl: TIME_CONSTANTS.TEN_MINUTES, + max: 500, }) + +export function invalidateCatalogCompatibilityCacheForDevice(deviceId: string): number { + const prefix = `device:${deviceId}:` + let deleted = 0 + + for (const key of catalogCompatibilityCache.keys()) { + if (!key.startsWith(prefix)) continue + if (catalogCompatibilityCache.delete(key)) deleted++ + } + + return deleted +} diff --git a/src/server/utils/driver-versions.ts b/src/server/utils/driver-versions.ts index 9d4508bd0..4b2abb80d 100644 --- a/src/server/utils/driver-versions.ts +++ b/src/server/utils/driver-versions.ts @@ -144,7 +144,7 @@ export async function getDriverVersions(): Promise { releases, rateLimited: false, } - driverVersionsCache.set(CACHE_KEY, payload, ms.minutes(30)) + driverVersionsCache.set(CACHE_KEY, payload, { ttl: ms.minutes(30) }) 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, ms.minutes(5)) + driverVersionsCache.set(CACHE_KEY, payload, { ttl: ms.minutes(5) }) 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, ms.minutes(2)) + driverVersionsCache.set(CACHE_KEY, payload, { ttl: ms.minutes(2) }) return payload } } diff --git a/src/server/utils/steamGameBatcher.ts b/src/server/utils/steamGameBatcher.ts index cd76b17d4..0b64197bc 100644 --- a/src/server/utils/steamGameBatcher.ts +++ b/src/server/utils/steamGameBatcher.ts @@ -1,14 +1,7 @@ -/** - * Steam Game Batch Matcher - * Efficiently matches Steam App IDs to games in the database - * Optimized for large batches (up to 1000 Steam App IDs) - */ - +import { LRUCache } from 'lru-cache' import { ms } from '@/utils/time' -import { MemoryCache } from './cache' import { getSteamGamesData } from './steamGameSearch' -// Validation constants const MAX_STEAM_APP_ID = 10000000 const MAX_BATCH_SIZE = 1000 @@ -23,55 +16,41 @@ interface GameMatchResult { matchStrategy: 'metadata' | 'exact' | 'normalized' | 'not_found' } -// Cache for Steam App ID → Game Name mappings (1 hour TTL) -const steamAppNameCache = new MemoryCache({ +const steamAppNameCache = new LRUCache({ ttl: ms.hours(1), - maxSize: 10000, // Cache up to 10k mappings + max: 10000, }) -/** - * Normalize game titles for better matching - * Removes special characters, extra spaces, and converts to lowercase - */ export function normalizeGameTitle(title: string): string { return title .toLowerCase() - .replace(/[™®©]/g, '') // Remove trademark symbols - .replace(/[:\-–—]/g, ' ') // Convert separators to spaces - .replace(/\s+/g, ' ') // Collapse multiple spaces - .replace(/[^\w\s]/g, '') // Remove special characters + .replace(/[™®©]/g, '') + .replace(/[:\-–—]/g, ' ') + .replace(/\s+/g, ' ') + .replace(/[^\w\s]/g, '') .trim() } -/** - * Get Steam app information from our cached Steam app list - * This reuses the cache from our existing Steam App ID search feature - */ async function getSteamAppInfo(appIds: string[]): Promise> { const result = new Map() try { - // Get all Steam apps from cache (already implemented in steamGameSearch.ts) const allSteamApps = await getSteamGamesData() - // Create a lookup map for O(1) access const steamAppMap = new Map() for (const app of allSteamApps) { steamAppMap.set(String(app.appid), app) } - // Look up each requested app ID for (const appId of appIds) { const cacheKey = `steam-app-${appId}` - // Try cache first const cachedName = steamAppNameCache.get(cacheKey) if (cachedName) { result.set(appId, { appid: Number(appId), name: cachedName }) continue } - // Lookup in Steam app list const appInfo = steamAppMap.get(appId) if (appInfo) { result.set(appId, appInfo) @@ -85,14 +64,9 @@ async function getSteamAppInfo(appIds: string[]): Promise { const results: GameMatchResult[] = [] - // Get Steam app information for all requested IDs const steamApps = await getSteamAppInfo(steamAppIds) for (const appId of steamAppIds) { @@ -102,7 +76,7 @@ export async function matchSteamAppIdsToNames(steamAppIds: string[]): Promise { const normalizedMap = new Map() @@ -132,17 +103,12 @@ export function createNormalizedTitleMap(gameMatches: GameMatchResult[]): Map MAX_STEAM_APP_ID) { errors.push(`Steam App ID out of valid range: ${appId}`) diff --git a/src/server/utils/steamGameSearch.ts b/src/server/utils/steamGameSearch.ts index f1b4ba0c7..b4ce70a1b 100644 --- a/src/server/utils/steamGameSearch.ts +++ b/src/server/utils/steamGameSearch.ts @@ -1,13 +1,7 @@ -/** - * Steam game data management with fuzzy search capabilities - * Fetches and caches Steam game data from Steam Web API with periodic updates - */ - import Fuse from 'fuse.js' +import { LRUCache } from 'lru-cache' import { ms } from '@/utils/time' -import { MemoryCache } from './cache' -// Types for Steam game data interface SteamAppEntry { appid: number name: string @@ -28,19 +22,21 @@ interface SteamGameSearchResult { score: number } -// Cache for Steam games data - longer TTL since the list is large and updates periodically -const steamGamesDataCache = new MemoryCache({ +interface CachedData { + data: T + createdAt: Date +} + +const steamGamesDataCache = new LRUCache>({ ttl: ms.days(1), - maxSize: 1, + max: 1, }) -// Cache for Fuse.js search instance - rebuild when data updates -const steamGamesFuseCache = new MemoryCache>({ +const steamGamesFuseCache = new LRUCache>({ ttl: ms.days(1), - maxSize: 1, + max: 1, }) -// Configuration for fuzzy search const FUSE_OPTIONS = { keys: [{ name: 'name', weight: 1.0 }], threshold: 0.4, @@ -54,16 +50,11 @@ const FUSE_OPTIONS = { // ISteamApps/GetAppList/v2 was removed by Valve. The replacement requires a key. const STEAM_STORE_API_URL = 'https://api.steampowered.com/IStoreService/GetAppList/v1/' -const FETCH_TIMEOUT_MS = 30_000 // 30s — avoids hanging on serverless cold starts -const PAGE_SIZE = 50_000 // max allowed by the API +const FETCH_TIMEOUT_MS = 30_000 +const PAGE_SIZE = 50_000 -// In-flight deduplication: prevents concurrent cold starts from each firing a fetch let inflightFetch: Promise | null = null -/** - * Fetches Steam games data from IStoreService/GetAppList/v1 with pagination. - * Requires STEAM_API_KEY env var. - */ async function fetchSteamGamesData(): Promise { const apiKey = process.env.STEAM_API_KEY if (!apiKey) { @@ -119,16 +110,12 @@ async function fetchSteamGamesData(): Promise { return allApps } -/** - * Gets cached Steam games data or fetches fresh data - */ export async function getSteamGamesData(): Promise { const cacheKey = 'steam-games-data' const cachedData = steamGamesDataCache.get(cacheKey) - if (cachedData) return cachedData + if (cachedData) return cachedData.data - // Deduplicate concurrent fetches so only one in-flight request runs at a time if (!inflightFetch) { inflightFetch = fetchSteamGamesData().finally(() => { inflightFetch = null @@ -137,7 +124,7 @@ export async function getSteamGamesData(): Promise { try { const freshData = await inflightFetch - steamGamesDataCache.set(cacheKey, freshData) + steamGamesDataCache.set(cacheKey, { data: freshData, createdAt: new Date() }) return freshData } catch (error) { const message = error instanceof Error ? error.message : String(error) @@ -149,17 +136,12 @@ export async function getSteamGamesData(): Promise { } } -/** - * Gets or creates Fuse.js search instance - */ async function getFuseInstance(): Promise> { const cacheKey = 'steam-games-fuse' - // Try to get from cache first const cachedFuse = steamGamesFuseCache.get(cacheKey) if (cachedFuse) return cachedFuse - // Create new Fuse instance with fresh data const gamesData = await getSteamGamesData() const fuse = new Fuse(gamesData, FUSE_OPTIONS) @@ -167,9 +149,6 @@ async function getFuseInstance(): Promise> { return fuse } -/** - * Normalizes a title for better matching - */ function normalizeTitle(title: string): string { return title .toLowerCase() @@ -178,12 +157,6 @@ function normalizeTitle(title: string): string { .trim() } -/** - * Searches for Steam App ID based on game name using fuzzy matching - * @param gameName - The name of the game to search for - * @param maxResults - Maximum number of results to return (default: 5) - * @returns Array of search results with app IDs and match scores - */ export async function findSteamAppIdForGameName( gameName: string, maxResults: number = 5, @@ -195,17 +168,14 @@ export async function findSteamAppIdForGameName( const searchTerm = gameName.trim().toLowerCase() const searchResults = fuse.search(searchTerm, { limit: maxResults * 2 }) - // Filter and enhance results with better scoring const enhancedResults = searchResults .map((result) => { const item = result.item const fuseScore = result.score || 0 - // Calculate enhanced score based on multiple factors const nameMatch = item.name.toLowerCase() const normalizedMatch = normalizeTitle(item.name) - // Exact match bonus let bonusScore = 0 if (nameMatch === searchTerm || normalizedMatch === normalizeTitle(searchTerm)) { bonusScore = 40 @@ -213,7 +183,6 @@ export async function findSteamAppIdForGameName( bonusScore = 20 } - // Word match bonus const searchWords = searchTerm.split(' ').filter((w) => w.length > 2) const nameWords = nameMatch.split(' ') @@ -224,7 +193,6 @@ export async function findSteamAppIdForGameName( } }) - // Penalize soundtrack/dlc/demo entries unless specifically searched for let contentBonus = 0 const lowerName = nameMatch const isDlc = @@ -243,7 +211,6 @@ export async function findSteamAppIdForGameName( if (isSoundtrack && !searchesForSoundtrack) contentBonus = -15 if (isDemo && !searchesForDemo) contentBonus = -15 - // Final score calculation const baseScore = Math.round((1 - fuseScore) * 100) const finalScore = Math.min( 100, @@ -262,7 +229,6 @@ export async function findSteamAppIdForGameName( }) .filter((result) => result.score >= 30) .sort((a, b) => { - // Prioritize main game versions if scores are close if (Math.abs(a.score - b.score) <= 10) { const aExtra = a.isDlc || a.isSoundtrack || a.isDemo const bExtra = b.isDlc || b.isSoundtrack || b.isDemo @@ -273,7 +239,6 @@ export async function findSteamAppIdForGameName( }) .slice(0, maxResults) - // Remove the extra properties from final results return enhancedResults.map(({ isDlc, isSoundtrack, isDemo, ...result }) => result) } catch (error) { console.error('Error searching for Steam App ID:', error) @@ -281,19 +246,11 @@ export async function findSteamAppIdForGameName( } } -/** - * Gets the best matching App ID for a game name (highest score) - * @param gameName - The name of the game to search for - * @returns The best matching App ID or null if no good match found - */ export async function getBestSteamAppIdMatch(gameName: string): Promise { const results = await findSteamAppIdForGameName(gameName, 1) return results.length > 0 && results[0].score >= 50 ? results[0].appId : null } -/** - * Forces a refresh of the Steam games data cache - */ export async function refreshSteamGamesData(): Promise { try { steamGamesDataCache.clear() @@ -306,9 +263,6 @@ export async function refreshSteamGamesData(): Promise { } } -/** - * Gets statistics about the cached Steam games data - */ export async function getSteamGamesStats(): Promise<{ totalGames: number cacheStatus: 'hit' | 'miss' | 'empty' @@ -318,21 +272,20 @@ export async function getSteamGamesStats(): Promise<{ const cachedData = steamGamesDataCache.get(cacheKey) if (cachedData) { - const lastUpdated = steamGamesDataCache.getCreatedAt(cacheKey) return { - totalGames: cachedData.length, + totalGames: cachedData.data.length, cacheStatus: 'hit', - lastUpdated: lastUpdated || undefined, + lastUpdated: cachedData.createdAt, } } try { const freshData = await getSteamGamesData() - const lastUpdated = steamGamesDataCache.getCreatedAt(cacheKey) + const cached = steamGamesDataCache.get(cacheKey) return { totalGames: freshData.length, cacheStatus: 'miss', - lastUpdated: lastUpdated || undefined, + lastUpdated: cached?.createdAt, } } catch (error) { console.error('[steamGameSearch] getSteamGamesStats failed to load data:', error) diff --git a/src/server/utils/switchGameSearch.ts b/src/server/utils/switchGameSearch.ts index adf23b504..06915ee19 100644 --- a/src/server/utils/switchGameSearch.ts +++ b/src/server/utils/switchGameSearch.ts @@ -1,13 +1,7 @@ -/** - * Nintendo Switch game data management with fuzzy search capabilities - * Fetches and caches Switch game data from external source with periodic updates - */ - import Fuse from 'fuse.js' +import { LRUCache } from 'lru-cache' import { ms } from '@/utils/time' -import { MemoryCache } from './cache' -// Types for Switch game data interface SwitchGameEntry { program_id: string name: string @@ -21,38 +15,37 @@ interface SwitchGameSearchResult { score: number } -// Cache for Switch games data - longer TTL since data updates weekly -const switchGamesDataCache = new MemoryCache({ +interface CachedData { + data: T + createdAt: Date +} + +const switchGamesDataCache = new LRUCache>({ ttl: ms.days(1), - maxSize: 1, + max: 1, }) -// Cache for Fuse.js search instance - rebuild when data updates -const switchGamesFuseCache = new MemoryCache>({ +const switchGamesFuseCache = new LRUCache>({ ttl: ms.days(1), - maxSize: 1, + max: 1, }) -// Configuration for fuzzy search const FUSE_OPTIONS = { keys: [ { name: 'name', weight: 0.7 }, { name: 'title_normalized', weight: 0.3 }, ], - threshold: 0.4, // More strict threshold for better matches - distance: 50, // Reduced distance for better precision - minMatchCharLength: 3, // Require at least 3 characters to match + threshold: 0.4, + distance: 50, + minMatchCharLength: 3, includeScore: true, - includeMatches: true, // Include match details for better filtering - ignoreLocation: true, // Don't penalize matches at different positions - findAllMatches: true, // Find all matching patterns + includeMatches: true, + ignoreLocation: true, + findAllMatches: true, } const SWITCH_GAMES_URL = 'https://producdevity.github.io/switch-games-json/switchbrew_id_names.json' -/** - * Fetches Switch games data from external source - */ async function fetchSwitchGamesData(): Promise { const response = await fetch(SWITCH_GAMES_URL, { headers: { 'User-Agent': 'EmuReady-GameSearch/1.0' }, @@ -68,9 +61,7 @@ async function fetchSwitchGamesData(): Promise { throw new Error('Invalid Switch games data format: expected array') } - // Validate data structure for (const entry of data.slice(0, 5)) { - // Check first 5 entries if (!entry.program_id || !entry.name || !entry.title_normalized) { throw new Error('Invalid Switch game entry structure') } @@ -79,23 +70,17 @@ async function fetchSwitchGamesData(): Promise { return data } -/** - * Gets cached Switch games data or fetches fresh data - */ async function getSwitchGamesData(): Promise { const cacheKey = 'switch-games-data' - // Try to get from cache first const cachedData = switchGamesDataCache.get(cacheKey) - if (cachedData) return cachedData + if (cachedData) return cachedData.data - // Fetch fresh data and cache it try { const freshData = await fetchSwitchGamesData() - switchGamesDataCache.set(cacheKey, freshData) + switchGamesDataCache.set(cacheKey, { data: freshData, createdAt: new Date() }) return freshData } catch (error) { - // Add context to the error without losing the original stack trace if (error instanceof Error) { error.message = `Switch games data fetch failed: ${error.message}` } @@ -103,17 +88,12 @@ async function getSwitchGamesData(): Promise { } } -/** - * Gets or creates Fuse.js search instance - */ async function getFuseInstance(): Promise> { const cacheKey = 'switch-games-fuse' - // Try to get from cache first const cachedFuse = switchGamesFuseCache.get(cacheKey) if (cachedFuse) return cachedFuse - // Create new Fuse instance with fresh data const gamesData = await getSwitchGamesData() const fuse = new Fuse(gamesData, FUSE_OPTIONS) @@ -121,12 +101,6 @@ async function getFuseInstance(): Promise> { return fuse } -/** - * Searches for Nintendo Switch title ID based on game name using fuzzy matching - * @param gameName - The name of the game to search for - * @param maxResults - Maximum number of results to return (default: 5) - * @returns Array of search results with title IDs and match scores - */ export async function findTitleIdForGameName( gameName: string, maxResults: number = 5, @@ -136,19 +110,16 @@ export async function findTitleIdForGameName( try { const fuse = await getFuseInstance() const searchTerm = gameName.trim().toLowerCase() - const searchResults = fuse.search(searchTerm, { limit: maxResults * 2 }) // Get more results for filtering + const searchResults = fuse.search(searchTerm, { limit: maxResults * 2 }) - // Filter and enhance results with better scoring const enhancedResults = searchResults .map((result) => { const item = result.item const fuseScore = result.score || 0 - // Calculate enhanced score based on multiple factors const nameMatch = item.name.toLowerCase() const normalizedMatch = item.title_normalized.toLowerCase() - // Exact match bonus let bonusScore = 0 if (nameMatch === searchTerm || normalizedMatch === searchTerm) { bonusScore = 40 @@ -156,7 +127,6 @@ export async function findTitleIdForGameName( bonusScore = 20 } - // Word match bonus const searchWords = searchTerm.split(' ').filter((w) => w.length > 2) const nameWords = nameMatch.split(' ') const normalizedWords = normalizedMatch.split(' ') @@ -171,16 +141,14 @@ export async function findTitleIdForGameName( } }) - // Avoid demo/kiosk versions unless specifically searched for let demoBonus = 0 const isDemo = nameMatch.includes('demo') || nameMatch.includes('kiosk') const searchesForDemo = searchTerm.includes('demo') || searchTerm.includes('kiosk') if (isDemo && !searchesForDemo) { - demoBonus = -15 // Penalize demos if not specifically searched + demoBonus = -15 } - // Final score calculation const baseScore = Math.round((1 - fuseScore) * 100) const finalScore = Math.min( 100, @@ -195,9 +163,8 @@ export async function findTitleIdForGameName( isDemo, } }) - .filter((result) => result.score >= 30) // Only return results with decent scores + .filter((result) => result.score >= 30) .sort((a, b) => { - // Prioritize non-demo versions if scores are close if (Math.abs(a.score - b.score) <= 10) { if (a.isDemo && !b.isDemo) return 1 if (!a.isDemo && b.isDemo) return -1 @@ -206,7 +173,6 @@ export async function findTitleIdForGameName( }) .slice(0, maxResults) - // Remove the isDemo property from final results return enhancedResults.map(({ isDemo, ...result }) => result) } catch (error) { console.error('Error searching for Switch title ID:', error) @@ -214,29 +180,17 @@ export async function findTitleIdForGameName( } } -/** - * Gets the best matching title ID for a game name (highest score) - * @param gameName - The name of the game to search for - * @returns The best matching title ID or null if no good match found - */ export async function getBestTitleIdMatch(gameName: string): Promise { const results = await findTitleIdForGameName(gameName, 1) - // Return the best match if score is good enough (>= 50% for more flexibility) return results.length > 0 && results[0].score >= 50 ? results[0].titleId : null } -/** - * Forces a refresh of the Switch games data cache - * Useful for periodic updates or manual refresh - */ export async function refreshSwitchGamesData(): Promise { try { - // Clear caches to force fresh fetch switchGamesDataCache.clear() switchGamesFuseCache.clear() - // Pre-populate cache with fresh data await getSwitchGamesData() await getFuseInstance() } catch (error) { @@ -245,9 +199,6 @@ export async function refreshSwitchGamesData(): Promise { } } -/** - * Gets statistics about the cached Switch games data - */ export async function getSwitchGamesStats(): Promise<{ totalGames: number cacheStatus: 'hit' | 'miss' | 'empty' @@ -257,21 +208,20 @@ export async function getSwitchGamesStats(): Promise<{ const cachedData = switchGamesDataCache.get(cacheKey) if (cachedData) { - const lastUpdated = switchGamesDataCache.getCreatedAt(cacheKey) return { - totalGames: cachedData.length, + totalGames: cachedData.data.length, cacheStatus: 'hit', - lastUpdated: lastUpdated || undefined, + lastUpdated: cachedData.createdAt, } } try { const freshData = await getSwitchGamesData() - const lastUpdated = switchGamesDataCache.getCreatedAt(cacheKey) + const cached = switchGamesDataCache.get(cacheKey) return { totalGames: freshData.length, cacheStatus: 'miss', - lastUpdated: lastUpdated || undefined, + lastUpdated: cached?.createdAt, } } catch { return { diff --git a/src/server/utils/threeDsGameSearch.ts b/src/server/utils/threeDsGameSearch.ts index f94163ca0..c4c9f1464 100644 --- a/src/server/utils/threeDsGameSearch.ts +++ b/src/server/utils/threeDsGameSearch.ts @@ -1,11 +1,6 @@ -/** - * Nintendo 3DS title ID lookup with fuzzy search and caching - * Mirrors the Nintendo Switch lookup flow but sources data from the 3DS title manifest - */ - import Fuse from 'fuse.js' +import { LRUCache } from 'lru-cache' import { ms } from '@/utils/time' -import { MemoryCache } from './cache' import type { IFuseOptions } from 'fuse.js' interface RawThreeDsTitleEntry { @@ -37,6 +32,11 @@ export interface ThreeDsGameSearchResult { productCode: string | null } +interface CachedData { + data: T + createdAt: Date +} + const REGION_PRIORITY = [ 'US', 'GB', @@ -78,14 +78,14 @@ const FUSE_OPTIONS: IFuseOptions = { 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 MemoryCache({ +const threeDsGamesDataCache = new LRUCache>({ ttl: ms.days(1), - maxSize: 1, + max: 1, }) -const threeDsGamesFuseCache = new MemoryCache>({ +const threeDsGamesFuseCache = new LRUCache>({ ttl: ms.days(1), - maxSize: 1, + max: 1, }) function normalizeTitle(title: string): string { @@ -205,11 +205,11 @@ async function fetchThreeDsGamesData(): Promise { async function getThreeDsGamesData(): Promise { const cacheKey = '3ds-games-data' const cached = threeDsGamesDataCache.get(cacheKey) - if (cached) return cached + if (cached) return cached.data try { const fresh = await fetchThreeDsGamesData() - threeDsGamesDataCache.set(cacheKey, fresh) + threeDsGamesDataCache.set(cacheKey, { data: fresh, createdAt: new Date() }) return fresh } catch (error) { if (error instanceof Error) { @@ -358,21 +358,20 @@ export async function getThreeDsGamesStats(): Promise<{ const cached = threeDsGamesDataCache.get(cacheKey) if (cached) { - const lastUpdated = threeDsGamesDataCache.getCreatedAt(cacheKey) return { - totalGames: cached.length, + totalGames: cached.data.length, cacheStatus: 'hit', - lastUpdated: lastUpdated ?? undefined, + lastUpdated: cached.createdAt, } } try { const fresh = await getThreeDsGamesData() - const lastUpdated = threeDsGamesDataCache.getCreatedAt(cacheKey) + const cachedAfterRefresh = threeDsGamesDataCache.get(cacheKey) return { totalGames: fresh.length, cacheStatus: 'miss', - lastUpdated: lastUpdated ?? undefined, + lastUpdated: cachedAfterRefresh?.createdAt, } } catch { return { diff --git a/tsconfig.json b/tsconfig.json index 5a1351ef1..d40a002d3 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -11,7 +11,7 @@ "moduleResolution": "bundler", "resolveJsonModule": true, "isolatedModules": true, - "jsx": "preserve", + "jsx": "react-jsx", "incremental": true, "plugins": [{ "name": "next" }], "paths": { From 2813fb4c483a82f81609c8ab0266c79bc2ba7463 Mon Sep 17 00:00:00 2001 From: Producdevity Date: Fri, 22 May 2026 17:26:35 +0200 Subject: [PATCH 02/22] feat: implement cache invalidation for device catalogs and SEO updates, add dynamic copyright year handling in Footer component --- src/components/footer/Footer.tsx | 9 ++++++++- src/server/api/routers/listings/admin.ts | 17 +++++++++++++++++ src/server/api/routers/pcListings.ts | 17 +++++++++++++++++ 3 files changed, 42 insertions(+), 1 deletion(-) diff --git a/src/components/footer/Footer.tsx b/src/components/footer/Footer.tsx index 320e30728..ac29fe935 100644 --- a/src/components/footer/Footer.tsx +++ b/src/components/footer/Footer.tsx @@ -1,6 +1,7 @@ 'use client' import Link from 'next/link' +import { useEffect, useState } from 'react' import { FooterAppLinks } from '@/components/footer/components/FooterAppLinks' import { FooterBetaBadge } from '@/components/footer/components/FooterBetaBadge' import { FooterKofiButton } from '@/components/footer/components/FooterKofiButton' @@ -12,6 +13,12 @@ import analytics from '@/lib/analytics' import { env } from '@/lib/env' function Footer() { + const [copyrightYear, setCopyrightYear] = useState(null) + + useEffect(() => { + setCopyrightYear(new Date().getUTCFullYear()) + }, []) + return (