From 7a56c328d09ae9eb32234560149b3c39796f3915 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 13 Apr 2026 19:39:23 -0400 Subject: [PATCH] refactor(api): standardize all API routes with withApi, apiFetch, security hardening - lib/api/: withApi wrapper, successResponse/errorResponse envelope, Zod validation, Prisma-backed rate limiting (serializable tx), assertOwnership, parsePagination - apiFetch client: auto-unwraps envelope, handles FormData, throws ApiClientError - 264 tests across 15 suites, api-ci.yml workflow, check-api-standards.sh - Security: OTP rate limit, HMAC timing-safe OAuth, faucet key sanitization, mass assignment guards, path traversal prevention, SSRF prevention, fetch timeouts - All client code migrated from raw fetch/axios to apiFetch (zero remaining) - ESLint enforces apiFetch usage, bans raw fetch('/api/') and axios - zod-validation-error, @t3-oss/env-nextjs, security headers in proxy.ts Closes #3969, #3971, #3974, #3908, #4013 --- .github/workflows/api-ci.yml | 93 ++ .gitignore | 7 + .lintstagedrc.js | 6 + app/(home)/build-games/apply/page.tsx | 14 +- app/(home)/grants/retro9000returning/page.tsx | 9 +- app/(home)/stats/avax-token/page.tsx | 37 +- app/(home)/stats/chain-list/page.tsx | 10 +- .../stats/interchain-messaging/page.tsx | 27 +- app/(home)/stats/overview/page.tsx | 24 +- .../stats/playground/my-dashboards/page.tsx | 27 +- app/(home)/stats/playground/page.tsx | 67 +- app/(home)/stats/validators/c-chain/page.tsx | 33 +- .../stats/validators/node/[nodeId]/page.tsx | 5 +- app/(home)/stats/validators/page.tsx | 14 +- app/api/.well-known/jwks.json/route.ts | 62 +- app/api/avax-supply/route.ts | 89 +- app/api/badge/assign/route.ts | 86 +- app/api/badge/console-check/route.ts | 25 +- app/api/badge/console-migrate/route.ts | 42 +- app/api/badge/get-all/route.ts | 23 +- app/api/badge/project-badge/route.ts | 39 +- app/api/badge/route.ts | 36 +- app/api/badge/validate/route.ts | 44 +- app/api/build-games/apply/route.ts | 400 +++--- app/api/build-games/resources/route.ts | 24 +- app/api/build-games/stage-data/route.ts | 167 ++- app/api/build-games/status/route.ts | 175 ++- app/api/calendar/google/route.ts | 162 +-- app/api/chain-stats/[chainId]/route.ts | 689 ++++++----- app/api/chain-validators/[subnetId]/route.ts | 334 +++-- app/api/chat-history/[id]/route.ts | 103 +- app/api/chat-history/[id]/share/route.ts | 136 +- app/api/chat-history/route.ts | 81 +- app/api/chat/route.ts | 1096 +++++++++-------- app/api/chat/share/[token]/route.ts | 138 +-- app/api/chat/suggestions/route.ts | 305 ++--- app/api/console-log/route.ts | 72 +- app/api/dapps/chain-stats/route.ts | 665 +++++----- app/api/dapps/contract-gas-flow/route.ts | 214 ++-- app/api/devnet-faucet/balance/route.ts | 45 +- app/api/devnet-faucet/route.ts | 110 +- app/api/dune/[address]/route.ts | 248 ++-- app/api/dune/cache.ts | 16 +- app/api/evaluate/advance-stage/route.ts | 81 +- app/api/evaluate/final-verdict/route.ts | 67 +- app/api/evaluate/route.ts | 131 +- app/api/evaluate/submissions/route.ts | 88 +- app/api/event-registration/route.ts | 120 +- app/api/events/[id]/route.ts | 33 +- app/api/events/route.ts | 39 +- app/api/evm-chain-faucet/route.ts | 243 ++-- .../address/[address]/erc20-balances/route.ts | 62 +- .../[chainId]/address/[address]/route.ts | 213 ++-- .../[chainId]/block/[blockNumber]/route.ts | 97 +- .../block/[blockNumber]/transactions/route.ts | 66 +- app/api/explorer/[chainId]/route.ts | 297 +++-- .../token/[tokenAddress]/metadata/route.ts | 56 +- .../explorer/[chainId]/tx/[txHash]/route.ts | 106 +- app/api/faucet-balance/route.ts | 70 +- app/api/faucet-rate-limit/batch/route.ts | 86 +- app/api/faucet-rate-limit/route.ts | 76 +- app/api/file/route.ts | 169 +-- app/api/generate-certificate/route.ts | 148 +-- app/api/glacier-jwt/route.ts | 32 +- app/api/hackathon-registration/route.ts | 186 ++- app/api/hackathons/[id]/route.ts | 94 +- app/api/hackathons/route.ts | 122 +- app/api/icm-contract-fees/route.ts | 32 +- app/api/icm-flow/route.ts | 160 +-- app/api/icm-stats/route.ts | 110 +- app/api/ictt-stats/route.ts | 401 +++--- app/api/infrabuidl/route.ts | 240 ++-- app/api/latest-blogs/route.ts | 44 +- .../[subnetId]/[nodeIndex]/route.ts | 51 +- app/api/managed-testnet-nodes/route.ts | 166 +-- app/api/managed-testnet-nodes/utils.ts | 19 +- .../[relayerId]/restart/route.ts | 29 +- .../[relayerId]/route.ts | 34 +- app/api/managed-testnet-relayers/route.ts | 219 ++-- app/api/managed-testnet-relayers/utils.ts | 10 +- app/api/mcp/blockchain/route.ts | 860 +++++++++++++ app/api/mcp/route.ts | 11 +- app/api/newsletter/route.ts | 113 +- app/api/notifications/create/route.ts | 104 +- app/api/notifications/get/route.ts | 105 +- app/api/notifications/read/route.ts | 104 +- app/api/oauth/authorize/route.ts | 93 +- app/api/oauth/token/route.ts | 137 ++- app/api/og/academy/[slug]/route.tsx | 1 + app/api/og/academy/route.tsx | 3 +- app/api/og/blog/[slug]/route.tsx | 3 +- app/api/og/blog/route.tsx | 3 +- app/api/og/docs/[slug]/route.tsx | 3 +- app/api/og/docs/route.tsx | 3 +- app/api/og/events/[id]/route.tsx | 24 +- app/api/og/events/route.tsx | 3 +- app/api/og/grants/route.tsx | 3 +- app/api/og/hackathons/[id]/route.tsx | 29 +- app/api/og/hackathons/route.tsx | 3 +- app/api/og/integrations/[slug]/route.tsx | 3 +- app/api/og/integrations/route.tsx | 3 +- app/api/og/stats/[slug]/route.tsx | 1 + app/api/og/stats/route.tsx | 1 + app/api/og/tools/l1-toolbox/route.tsx | 3 +- app/api/og/tools/route.tsx | 3 +- app/api/overview-stats/route.ts | 214 ++-- app/api/pchain-faucet/route.ts | 132 +- app/api/playground/[id]/view/route.ts | 55 +- app/api/playground/favorite/route.ts | 120 +- app/api/playground/route.ts | 392 +++--- app/api/primary-network-stats/route.ts | 251 ++-- app/api/primary-network-validators/route.ts | 134 +- app/api/profile/[id]/route.ts | 87 +- app/api/profile/extended/[id]/route.ts | 129 +- app/api/profile/popular-skills/route.ts | 39 +- app/api/profile/reward-board/route.ts | 35 +- app/api/project/[project_id]/members/route.ts | 57 +- .../[project_id]/members/status/route.ts | 42 +- app/api/project/check-invitation/route.ts | 30 +- app/api/project/invite-member/route.ts | 44 +- app/api/project/route.ts | 38 +- app/api/project/set-winner/route.ts | 30 +- app/api/projects/[id]/invite/route.ts | 34 + app/api/projects/[id]/members/route.ts | 42 + app/api/projects/[id]/members/status/route.ts | 36 + app/api/projects/[id]/route.ts | 109 +- app/api/projects/check-invitation/route.ts | 25 + app/api/projects/export/route.ts | 53 +- app/api/projects/invite-member/route.ts | 38 + app/api/projects/member/[id]/route.ts | 28 +- app/api/projects/route.ts | 73 +- app/api/projects/set-winner/route.ts | 24 + app/api/projects/submit/route.ts | 39 + app/api/raw/[...slug]/route.ts | 63 +- app/api/register-form/route.ts | 80 +- app/api/retro9000-returning/route.ts | 102 +- app/api/retro9000/route.ts | 477 ++++--- app/api/safe/route.ts | 238 ++-- app/api/send-otp/route.ts | 42 +- app/api/staking-apy/route.ts | 176 ++- app/api/university/slideshow/route.ts | 61 +- app/api/user/create-after-terms/route.ts | 51 +- .../user/noun-avatar/generate-seed/route.ts | 115 +- app/api/user/noun-avatar/route.ts | 51 +- app/api/users/check/route.ts | 28 +- app/api/validate-jwt-token/route.ts | 68 +- app/api/validator-alerts/[id]/route.ts | 201 ++- app/api/validator-alerts/check/route.ts | 70 +- app/api/validator-alerts/route.ts | 250 ++-- app/api/validator-alerts/unsubscribe/route.ts | 57 +- app/api/validator-details/[nodeId]/route.ts | 302 ++--- app/api/validator-geolocation/route.ts | 175 ++- app/api/validator-notification/route.ts | 178 ++- app/api/validator-stats/route.ts | 222 ++-- app/api/validators/[nodeId]/route.ts | 85 +- app/api/validators/route.ts | 55 +- app/api/youtube/search/route.ts | 86 +- app/chat/page.tsx | 64 +- app/events/edit/page.tsx | 165 +-- app/hackathons/edit/page.tsx | 165 +-- .../build-games/ApplicationStatusTracker.tsx | 4 +- .../BuildGamesResourcesWrapper.tsx | 4 +- .../build-games/BuildGamesSubmitForm.tsx | 59 +- components/build-games/HowItWorksWrapper.tsx | 4 +- .../build-games/ProgramTimelineWrapper.tsx | 4 +- components/build-games/ReferralModal.tsx | 4 +- components/build-games/index.ts | 14 + components/chat/flows/metrics-wrappers.tsx | 8 +- components/chat/share-modal.tsx | 19 +- components/client/infrabuidl-form.tsx | 14 +- components/common/index.ts | 2 + components/content-design/index.ts | 9 + components/evaluate/AdvanceStageControls.tsx | 10 +- components/evaluate/BulkAdvanceModal.tsx | 13 +- components/evaluate/EvaluationPanel.tsx | 13 +- components/explorer/AddressDetailPage.tsx | 26 +- components/explorer/ExplorerContext.tsx | 40 +- components/explorer/L1ExplorerPage.tsx | 19 +- components/explorer/TransactionDetailPage.tsx | 19 +- components/explorer/index.ts | 12 + components/hackathons/Events.tsx | 8 +- components/hackathons/Hackathons.tsx | 8 +- .../hackathons/admin-panel/HackathonForm.tsx | 6 +- components/hackathons/index.ts | 7 + .../project-submission/components/General.tsx | 19 +- .../components/GeneralSecure.tsx | 16 +- .../components/JoinTeamDialog.tsx | 21 +- .../project-submission/components/Members.tsx | 76 +- .../components/UserNotRegistered.tsx | 6 +- .../context/ProjectSubmissionContext.tsx | 29 +- .../hooks/useHackathonProject.ts | 26 +- .../hooks/useSubmissionForm.ts | 30 +- .../hooks/useSubmissionFormSecure.ts | 24 +- .../registration-form/RegistrationForm.tsx | 11 +- components/landing/index.ts | 12 + components/login/BasicProfileSetup.tsx | 4 +- components/login/FormLogin.tsx | 7 +- components/login/LoginModal.tsx | 7 +- components/login/terms.tsx | 11 +- components/login/verify/VerifyEmail.tsx | 7 +- components/navigation/dynamic-blog-menu.tsx | 4 +- components/navigation/footer.tsx | 20 +- components/navigation/index.ts | 18 + components/notification/notification-bell.tsx | 13 +- components/profile/ProfileForm.tsx | 39 +- .../profile/components/NounAvatarConfig.tsx | 23 +- .../profile/components/NounAvatarEditor.tsx | 11 +- .../profile/components/hooks/use-project.ts | 6 +- .../components/hooks/usePopularSkills.ts | 10 +- .../components/hooks/useProfileForm.ts | 156 +-- components/profile/components/profile-tab.tsx | 10 +- components/profile/index.ts | 2 + .../quizzes/components/BadgeNotification.tsx | 13 +- components/quizzes/hooks/useBadgeAward.ts | 38 +- components/showcase/ProjectOptions.tsx | 18 +- components/showcase/assign-badge.tsx | 24 +- components/showcase/hooks/useExports.tsx | 50 +- components/showcase/sections/TeamBadge.tsx | 13 +- components/stats/ChainMetricsPage.tsx | 21 +- components/stats/ConfigurableChart.tsx | 8 +- components/stats/LiveBlockBurns.tsx | 11 +- components/stats/ValidatorWorldMap.tsx | 6 +- components/stats/contract-gas-xray.tsx | 11 +- .../image-export/hooks/useCollageMetrics.ts | 9 +- components/stats/index.ts | 45 + .../AddValidatorControls.tsx | 12 +- .../components/PChainFaucetMenuItem.tsx | 33 +- .../console/primary-network/DevnetFaucet.tsx | 27 +- .../console/primary-network/Faucet.tsx | 28 +- .../primary-network/ValidatorLookup.tsx | 15 +- .../data-api-keys/TokenManagement.tsx | 21 +- components/toolbox/hooks/useSafeAPI.ts | 26 +- components/ui/index.ts | 277 +++++ components/university/UniversitySlideshow.tsx | 7 +- .../validator-alerts/AlertDashboard.tsx | 76 +- eslint.config.mjs | 58 + hooks/use-console-log.ts | 73 +- hooks/use-get-hackathons.ts | 30 +- hooks/useAutomatedFaucet.ts | 12 +- hooks/useCertificates.ts | 91 +- hooks/useCourseBadges.ts | 11 +- hooks/useFaucetBalance.ts | 8 +- hooks/useFaucetRateLimit.ts | 16 +- hooks/useManagedTestnetNodes.ts | 83 +- hooks/useManagedTestnetRelayers.ts | 98 +- hooks/useRetroactiveConsoleBadges.ts | 11 +- hooks/useTestnetFaucet.ts | 51 +- lib/api/client.ts | 97 ++ lib/api/constants.ts | 17 + lib/api/errors.ts | 84 ++ lib/api/index.ts | 10 + lib/api/ownership.ts | 30 + lib/api/pagination.ts | 20 + lib/api/rate-limit.ts | 137 +++ lib/api/response.ts | 48 + lib/api/types.ts | 58 + lib/api/validate.ts | 48 + lib/api/with-api.ts | 108 ++ lib/env.ts | 260 ++++ lib/hackathons/schedule-strategy.ts | 12 +- package.json | 7 + prisma/schema.prisma | 9 + proxy.ts | 16 + scripts/check-api-standards.sh | 309 +++++ tests/api/auth/oauth.test.ts | 376 ++++++ tests/api/auth/send-otp.test.ts | 159 +++ tests/api/badges/badge.test.ts | 584 +++++++++ tests/api/chat/chat-history.test.ts | 676 ++++++++++ tests/api/evaluate/evaluate.test.ts | 420 +++++++ tests/api/explorer/explorer.test.ts | 673 ++++++++++ tests/api/faucets/evm-chain-faucet.test.ts | 238 ++++ tests/api/faucets/pchain-faucet.test.ts | 174 +++ tests/api/helpers/api-test-utils.ts | 260 ++++ tests/api/helpers/mock-prisma.ts | 198 +++ tests/api/helpers/mock-session.ts | 142 +++ tests/api/notifications/notifications.test.ts | 239 ++++ tests/api/playground/playground.test.ts | 403 ++++++ tests/api/profile/profile.test.ts | 289 +++++ tests/api/projects/projects.test.ts | 613 +++++++++ tests/api/stats/stats.test.ts | 300 +++++ tests/api/validators/validator-alerts.test.ts | 556 +++++++++ tests/api/validators/validators.test.ts | 409 ++++++ tests/setup/vitest.setup.ts | 109 ++ vitest.config.ts | 73 ++ yarn.lock | 542 +++++++- 285 files changed, 18393 insertions(+), 10357 deletions(-) create mode 100644 .github/workflows/api-ci.yml create mode 100644 app/api/mcp/blockchain/route.ts create mode 100644 app/api/projects/[id]/invite/route.ts create mode 100644 app/api/projects/[id]/members/route.ts create mode 100644 app/api/projects/[id]/members/status/route.ts create mode 100644 app/api/projects/check-invitation/route.ts create mode 100644 app/api/projects/invite-member/route.ts create mode 100644 app/api/projects/set-winner/route.ts create mode 100644 app/api/projects/submit/route.ts create mode 100644 components/build-games/index.ts create mode 100644 components/common/index.ts create mode 100644 components/content-design/index.ts create mode 100644 components/explorer/index.ts create mode 100644 components/hackathons/index.ts create mode 100644 components/landing/index.ts create mode 100644 components/navigation/index.ts create mode 100644 components/profile/index.ts create mode 100644 components/stats/index.ts create mode 100644 components/ui/index.ts create mode 100644 lib/api/client.ts create mode 100644 lib/api/constants.ts create mode 100644 lib/api/errors.ts create mode 100644 lib/api/index.ts create mode 100644 lib/api/ownership.ts create mode 100644 lib/api/pagination.ts create mode 100644 lib/api/rate-limit.ts create mode 100644 lib/api/response.ts create mode 100644 lib/api/types.ts create mode 100644 lib/api/validate.ts create mode 100644 lib/api/with-api.ts create mode 100644 lib/env.ts create mode 100755 scripts/check-api-standards.sh create mode 100644 tests/api/auth/oauth.test.ts create mode 100644 tests/api/auth/send-otp.test.ts create mode 100644 tests/api/badges/badge.test.ts create mode 100644 tests/api/chat/chat-history.test.ts create mode 100644 tests/api/evaluate/evaluate.test.ts create mode 100644 tests/api/explorer/explorer.test.ts create mode 100644 tests/api/faucets/evm-chain-faucet.test.ts create mode 100644 tests/api/faucets/pchain-faucet.test.ts create mode 100644 tests/api/helpers/api-test-utils.ts create mode 100644 tests/api/helpers/mock-prisma.ts create mode 100644 tests/api/helpers/mock-session.ts create mode 100644 tests/api/notifications/notifications.test.ts create mode 100644 tests/api/playground/playground.test.ts create mode 100644 tests/api/profile/profile.test.ts create mode 100644 tests/api/projects/projects.test.ts create mode 100644 tests/api/stats/stats.test.ts create mode 100644 tests/api/validators/validator-alerts.test.ts create mode 100644 tests/api/validators/validators.test.ts create mode 100644 tests/setup/vitest.setup.ts create mode 100644 vitest.config.ts diff --git a/.github/workflows/api-ci.yml b/.github/workflows/api-ci.yml new file mode 100644 index 00000000000..063e02e34d6 --- /dev/null +++ b/.github/workflows/api-ci.yml @@ -0,0 +1,93 @@ +name: API CI + +on: + pull_request: + branches: + - master + paths: + - 'app/api/**' + - 'lib/api/**' + - 'lib/auth/**' + - 'lib/rateLimit.ts' + - 'lib/protectedRoute.ts' + - 'server/services/**' + - 'prisma/schema.prisma' + - 'tests/api/**' + - 'components/**/*.ts' + - 'components/**/*.tsx' + - 'hooks/**/*.ts' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + ci: + name: CI + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - name: Setup Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: '22' + cache: 'yarn' + + - name: Install dependencies + run: yarn install --frozen-lockfile + + # ── Core checks ────────────────────────────────────── + + - name: Type check + run: npx tsc --noEmit + + # ── API conventions ────────────────────────────────── + + - name: ESLint (API routes) + run: npx eslint app/api/ --max-warnings 0 + + - name: API standards (security + quality) + run: bash ./scripts/check-api-standards.sh + + - name: API tests + run: npx vitest run tests/api/ --reporter=verbose + + # ── PR-level enforcement ───────────────────────────── + + - name: Require tests for changed API routes + run: | + # Get API route files changed in this PR + CHANGED_ROUTES=$(git diff --name-only origin/master...HEAD -- 'app/api/**/route.ts' 'app/api/**/route.tsx' | \ + grep -v 'auth/\[\.\.\.nextauth\]\|/og/\|well-known\|/check/route' || true) + + if [ -z "$CHANGED_ROUTES" ]; then + echo "✓ No API routes changed in this PR" + exit 0 + fi + + MISSING=0 + echo "Checking test coverage for changed API routes..." + while IFS= read -r route; do + [ -z "$route" ] && continue + route_dir=$(echo "$route" | sed 's|app/api/||; s|/route\.tsx\?||') + route_name=$(basename "$route_dir") + + # Check if ANY test file references this route + if grep -rl "$route_dir\|$route_name" tests/api/ --include="*.test.ts" > /dev/null 2>&1; then + echo " ✓ $route → has tests" + else + echo " ✗ $route → NO TESTS FOUND" + MISSING=$((MISSING + 1)) + fi + done <<< "$CHANGED_ROUTES" + + if [ $MISSING -gt 0 ]; then + echo "" + echo "✗ $MISSING changed API route(s) have no test coverage." + echo " Add tests to tests/api/ before merging." + exit 1 + fi + + echo "✓ All changed API routes have test coverage" diff --git a/.gitignore b/.gitignore index 6fe12ddb32b..1614f2d4c61 100644 --- a/.gitignore +++ b/.gitignore @@ -293,3 +293,10 @@ content/docs/rpcs/x-chain/**/meta.json !content/docs/rpcs/x-chain/txn-format.mdx !content/docs/rpcs/x-chain/api.mdx !content/docs/rpcs/x-chain/rpc.mdx + +# Component/hook READMEs (auto-generated, not for git) +components/*/README.md +hooks/README.md +lib/README.md +types/README.md +constants/README.md diff --git a/.lintstagedrc.js b/.lintstagedrc.js index 01b2ab12744..2da97e35e98 100644 --- a/.lintstagedrc.js +++ b/.lintstagedrc.js @@ -10,6 +10,12 @@ module.exports = { `./scripts/check-console-design.sh ${files.join(' ')}`, ], + // API routes: ESLint + Prettier + 'app/api/**/*.ts': [ + 'prettier --write', + 'eslint --max-warnings 0', + ], + // All TypeScript: type check (runs once, not per-file) '*.{ts,tsx}': () => 'tsc --noEmit', }; diff --git a/app/(home)/build-games/apply/page.tsx b/app/(home)/build-games/apply/page.tsx index 9ad62864910..8769557afea 100644 --- a/app/(home)/build-games/apply/page.tsx +++ b/app/(home)/build-games/apply/page.tsx @@ -18,6 +18,7 @@ import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { Label } from "@/components/ui/label"; import { countries } from "@/constants/countries"; import { cn } from "@/lib/utils"; +import { apiFetch, ApiClientError } from "@/lib/api/client"; import { getReferrer } from "@/lib/referral"; const EMPLOYMENT_ROLES = ["Accounting", "Administrative", "Development", "Communications", "Consulting", "Customer", "Design", "Education", "Engineering", "Entrepreneurship", "Finance", "Health", "Human Resources", "Information Technology", "Legal", "Marketing", "Operations", "Product", "Project Management", "Public Relations", "Quality Assurance", "Real Estate", "Recruiting", "Research", "Sales", "Support", "Retired", "Other"]; @@ -290,21 +291,14 @@ export default function BuildGamesApplyForm() { try { const referrer = getReferrer(); - const response = await fetch("/api/build-games/apply", { + await apiFetch("/api/build-games/apply", { method: "POST", - headers: {"Content-Type": "application/json"}, - body: JSON.stringify({ + body: { ...values, referrer: referrer, - }), + }, }); - const result = await response.json(); - - if (!response.ok || !result.success) { - throw new Error(result.message || "Failed to submit application"); - } - setSubmissionStatus("success"); form.reset(); } catch (error) { diff --git a/app/(home)/grants/retro9000returning/page.tsx b/app/(home)/grants/retro9000returning/page.tsx index b3ab9460dbf..dc7b63a7953 100644 --- a/app/(home)/grants/retro9000returning/page.tsx +++ b/app/(home)/grants/retro9000returning/page.tsx @@ -17,6 +17,7 @@ import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { Label } from "@/components/ui/label"; import { countries } from "@/constants/countries"; import { cn } from "@/lib/utils"; +import { apiFetch, ApiClientError } from "@/lib/api/client"; import { formSchema, jobRoles, projectTypes, projectVerticals, continents, fundingRanges, type Retro9000ReturningFormData } from "@/types/retro9000ReturningForm"; const STEPS = [ @@ -195,14 +196,10 @@ export default function Retro9000ReturningForm() { async function onSubmit(values: Retro9000ReturningFormData) { setIsSubmitting(true); try { - const response = await fetch("/api/retro9000-returning", { + await apiFetch("/api/retro9000-returning", { method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(values), + body: values, }); - - const result = await response.json(); - if (!response.ok || !result.success) { throw new Error(result.message || "Failed to submit application") } setSubmissionStatus("success"); form.reset(); if (session?.user?.email) { form.setValue("email", session.user.email) } diff --git a/app/(home)/stats/avax-token/page.tsx b/app/(home)/stats/avax-token/page.tsx index d31feb6a3d0..f3cea120dc6 100644 --- a/app/(home)/stats/avax-token/page.tsx +++ b/app/(home)/stats/avax-token/page.tsx @@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button"; import { Tooltip as UITooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { CircleDotDashed, CircleFadingPlus, Lock, BadgeDollarSign, RefreshCw, Flame, Award, MessageSquareIcon, Server, Unlock, HandCoins, Info, ArrowUpRight } from "lucide-react"; import { useEffect, useState, useMemo } from "react"; +import { apiFetch } from "@/lib/api/client"; import Image from "next/image"; import { Bar, BarChart, CartesianGrid, XAxis, YAxis, Tooltip, ResponsiveContainer, Brush, LineChart, Line } from "recharts"; import { L1BubbleNav } from "@/components/stats/l1-bubble.config"; @@ -72,19 +73,12 @@ export default function AvaxTokenPage() { setLoading(true); setError(null); - const [supplyRes, cChainRes, icmRes] = await Promise.all([ - fetch("/api/avax-supply"), - fetch("/api/chain-stats/43114?timeRange=1y"), - fetch("/api/icm-contract-fees?timeRange=1y"), + const [supplyData, cChainData, icmRes] = await Promise.all([ + apiFetch("/api/avax-supply"), + apiFetch("/api/chain-stats/43114?timeRange=1y"), + apiFetch("/api/icm-contract-fees?timeRange=1y").catch(() => null), ]); - if (!supplyRes.ok || !cChainRes.ok) { - throw new Error("Failed to fetch required data"); - } - - const supplyData = await supplyRes.json(); - const cChainData: CChainFeesResponse = await cChainRes.json(); - setData(supplyData); const cChainFeesData: FeeDataPoint[] = cChainData.feesPaid.data @@ -97,18 +91,15 @@ export default function AvaxTokenPage() { setCChainFees(cChainFeesData); - if (icmRes.ok) { - const icmData: ICMFeesResponse = await icmRes.json(); - if (icmData.data && Array.isArray(icmData.data)) { - const icmFeesData: FeeDataPoint[] = icmData.data - .map((item) => ({ - date: item.date, - timestamp: item.timestamp, - value: item.feesPaid / 1e18, - })) - .reverse(); - setICMFees(icmFeesData); - } + if (icmRes && icmRes.data && Array.isArray(icmRes.data)) { + const icmFeesData: FeeDataPoint[] = icmRes.data + .map((item) => ({ + date: item.date, + timestamp: item.timestamp, + value: item.feesPaid / 1e18, + })) + .reverse(); + setICMFees(icmFeesData); } } catch (err) { setError(err instanceof Error ? err.message : "An error occurred"); diff --git a/app/(home)/stats/chain-list/page.tsx b/app/(home)/stats/chain-list/page.tsx index 2fd2fad3c5d..ecc3be77265 100644 --- a/app/(home)/stats/chain-list/page.tsx +++ b/app/(home)/stats/chain-list/page.tsx @@ -1,6 +1,7 @@ "use client"; import { useState, useEffect, useMemo, useRef } from "react"; +import { apiFetch } from "@/lib/api/client"; import Image from "next/image"; import { useTheme } from "next-themes"; import { Card } from "@/components/ui/card"; @@ -140,13 +141,8 @@ export default function ChainListPage() { await Promise.all( batch.map(async (chain) => { try { - const response = await fetch(`/api/explorer/${chain.chainId}?priceOnly=true`); - if (response.ok) { - const data = await response.json(); - supportMap.set(chain.chainId, data.glacierSupported === true); - } else { - supportMap.set(chain.chainId, false); - } + const data = await apiFetch(`/api/explorer/${chain.chainId}?priceOnly=true`); + supportMap.set(chain.chainId, data.glacierSupported === true); } catch (error) { console.warn(`Failed to check Glacier support for chain ${chain.chainId}:`, error); supportMap.set(chain.chainId, false); diff --git a/app/(home)/stats/interchain-messaging/page.tsx b/app/(home)/stats/interchain-messaging/page.tsx index ed22d973941..f1a1686b069 100644 --- a/app/(home)/stats/interchain-messaging/page.tsx +++ b/app/(home)/stats/interchain-messaging/page.tsx @@ -1,5 +1,6 @@ "use client"; import { useState, useEffect, useMemo, useRef } from "react"; +import { apiFetch } from "@/lib/api/client"; import { Bar, BarChart, CartesianGrid, Line, LineChart, XAxis, YAxis, Tooltip, Brush, ResponsiveContainer } from "recharts"; import { Card, CardContent } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; @@ -106,13 +107,7 @@ export default function ICMStatsPage() { setError(null); // Always fetch 1 year of data - const response = await fetch(`/api/icm-stats?timeRange=1y`); - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - - const data = await response.json(); + const data = await apiFetch(`/api/icm-stats?timeRange=1y`); setMetrics(data); } catch (err) { setError(err instanceof Error ? err.message : "An error occurred"); @@ -132,16 +127,7 @@ export default function ICMStatsPage() { } const limit = offset === 0 ? 20 : 25; - const response = await fetch( - `/api/ictt-stats?limit=${limit}&offset=${offset}` - ); - - if (!response.ok) { - console.error("Failed to fetch ICTT stats:", response.status); - return; - } - - const data = await response.json(); + const data = await apiFetch(`/api/ictt-stats?limit=${limit}&offset=${offset}`); if (append && icttData) { setIcttData({ @@ -168,11 +154,8 @@ export default function ICMStatsPage() { const fetchIcmFlowData = async () => { try { setIcmFlowLoading(true); - const response = await fetch("/api/icm-flow?days=30"); - if (response.ok) { - const data = await response.json(); - setIcmFlowData(data); - } + const data = await apiFetch("/api/icm-flow?days=30"); + setIcmFlowData(data); } catch (err) { console.error("Error fetching ICM flow data:", err); } finally { diff --git a/app/(home)/stats/overview/page.tsx b/app/(home)/stats/overview/page.tsx index 0a4af5216b5..34659f87d34 100644 --- a/app/(home)/stats/overview/page.tsx +++ b/app/(home)/stats/overview/page.tsx @@ -50,6 +50,7 @@ import { VersionLabels, } from "@/components/stats/VersionBreakdown"; import { formatMarketCap } from "@/lib/utils/format-market-cap"; +import { apiFetch } from "@/lib/api/client"; type TableView = "summary" | "validators"; @@ -303,11 +304,8 @@ export default function AvalancheMetrics() { useEffect(() => { const fetchAvaxSupply = async () => { try { - const response = await fetch("/api/avax-supply"); - if (response.ok) { - const data = await response.json(); - setAvaxSupplyData(data); - } + const data = await apiFetch("/api/avax-supply"); + setAvaxSupplyData(data); } catch (err) { console.error("Error fetching AVAX supply data:", err); } @@ -322,11 +320,7 @@ export default function AvalancheMetrics() { const fetchValidatorStats = async () => { setValidatorStatsLoading(true); try { - const response = await fetch("/api/validator-stats?network=mainnet"); - if (!response.ok) { - throw new Error(`Failed to fetch validator stats: ${response.status}`); - } - const stats: SubnetStats[] = await response.json(); + const stats = await apiFetch("/api/validator-stats?network=mainnet"); setValidatorStats(stats); // Extract available versions @@ -417,13 +411,12 @@ export default function AvalancheMetrics() { const fetchIcmFlows = useCallback(async () => { try { setIcmLoading(true); - const icmResponse = await fetch("/api/icm-flow?days=30").catch( + const icmData = await apiFetch("/api/icm-flow?days=30").catch( () => null ); - if (icmResponse && icmResponse.ok) { + if (icmData) { try { - const icmData = await icmResponse.json(); if (icmData.flows && Array.isArray(icmData.flows)) { setIcmFlows( icmData.flows.map((f: any) => ({ @@ -544,12 +537,9 @@ export default function AvalancheMetrics() { } setError(null); try { - const response = await fetch( + const metrics = await apiFetch( `/api/overview-stats?timeRange=${timeRange}` ); - if (!response.ok) - throw new Error(`Failed to fetch metrics: ${response.status}`); - const metrics = await response.json(); setOverviewMetrics(metrics); } catch (err: any) { console.error("Error fetching metrics data:", err); diff --git a/app/(home)/stats/playground/my-dashboards/page.tsx b/app/(home)/stats/playground/my-dashboards/page.tsx index 13720fe53be..999dcdf3d63 100644 --- a/app/(home)/stats/playground/my-dashboards/page.tsx +++ b/app/(home)/stats/playground/my-dashboards/page.tsx @@ -1,5 +1,6 @@ "use client"; import { useState, useEffect, useCallback } from "react"; +import { apiFetch } from "@/lib/api/client"; import { useSession } from "next-auth/react"; import { useRouter } from "next/navigation"; import { Button } from "@/components/ui/button"; @@ -31,11 +32,8 @@ export default function MyDashboardsPage() { setDashboardsLoading(true); try { - const response = await fetch("/api/playground"); - if (response.ok) { - const data = await response.json(); - setDashboards(Array.isArray(data) ? data : []); - } + const data = await apiFetch("/api/playground"); + setDashboards(Array.isArray(data) ? data : []); setHasFetched(true); } catch (err) { console.error("Error fetching dashboards:", err); @@ -54,14 +52,8 @@ export default function MyDashboardsPage() { const handleDeleteDashboard = async (id: string) => { if (!confirm("Are you sure you want to delete this dashboard?")) return; - const promise = fetch(`/api/playground?id=${id}`, { + const promise = apiFetch(`/api/playground?id=${id}`, { method: "DELETE" - }).then(async (response) => { - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.error || "Failed to delete dashboard"); - } - return response.json(); }); toast.promise(promise, { @@ -86,19 +78,12 @@ export default function MyDashboardsPage() { e.stopPropagation(); // Prevent row click const newVisibility = !currentVisibility; - const promise = fetch("/api/playground", { + const promise = apiFetch("/api/playground", { method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ + body: { id, isPublic: newVisibility - }) - }).then(async (response) => { - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.error || "Failed to update visibility"); } - return response.json(); }); toast.promise(promise, { diff --git a/app/(home)/stats/playground/page.tsx b/app/(home)/stats/playground/page.tsx index 9ea3f7e485b..e0e266119b1 100644 --- a/app/(home)/stats/playground/page.tsx +++ b/app/(home)/stats/playground/page.tsx @@ -1,5 +1,6 @@ "use client"; import { useState, useMemo, useEffect, useRef, useCallback, Suspense } from "react"; +import { apiFetch, ApiClientError } from "@/lib/api/client"; import { useSession } from "next-auth/react"; import { useSearchParams, useRouter } from "next/navigation"; import ConfigurableChart, { type ChartDataExport } from "@/components/stats/ConfigurableChart"; @@ -417,18 +418,7 @@ function PlaygroundContent() { setError(null); try { - const response = await fetch(`/api/playground?id=${playgroundId}`); - - if (!response.ok) { - if (response.status === 404) { - setError("Playground not found"); - } else { - throw new Error("Failed to load playground"); - } - return; - } - - const playground = await response.json(); + const playground = await apiFetch(`/api/playground?id=${playgroundId}`); setPlaygroundName(playground.name); setSavedPlaygroundName(playground.name); setIsPublic(playground.is_public); @@ -485,12 +475,16 @@ function PlaygroundContent() { setSavedLink(link); } catch (err) { console.error("Error loading playground:", err); - setError(err instanceof Error ? err.message : "Failed to load playground"); + if (err instanceof ApiClientError && err.status === 404) { + setError("Playground not found"); + } else { + setError(err instanceof ApiClientError ? err.message : err instanceof Error ? err.message : "Failed to load playground"); + } } finally { setIsLoading(false); } }; - + loadPlayground(); }, [playgroundId, status]); @@ -553,12 +547,11 @@ function PlaygroundContent() { if (!hasViewed) { // Track view asynchronously without blocking - fetch(`/api/playground/${currentPlaygroundId}/view`, { + apiFetch<{ view_count: number }>(`/api/playground/${currentPlaygroundId}/view`, { method: 'POST', }) - .then(res => res.json()) .then(data => { - if (data.success && data.view_count !== undefined) { + if (data.view_count !== undefined) { setViewCount(data.view_count); sessionStorage.setItem(viewKey, 'true'); } @@ -605,33 +598,24 @@ function PlaygroundContent() { })) }; - let response; + let playground; if (currentPlaygroundId) { // Update existing playground - response = await fetch("/api/playground", { + playground = await apiFetch("/api/playground", { method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ + body: { id: currentPlaygroundId, ...payload - }) + } }); } else { // Create new playground - response = await fetch("/api/playground", { + playground = await apiFetch("/api/playground", { method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload) + body: payload }); } - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.error || "Failed to save playground"); - } - - const playground = await response.json(); - // Update saved state setSavedCharts(charts.map(chart => ({ ...chart, @@ -749,30 +733,17 @@ function PlaygroundContent() { try { if (isFavorited) { // Unfavorite - const response = await fetch(`/api/playground/favorite?playgroundId=${currentPlaygroundId}`, { + const data = await apiFetch<{ favorite_count?: number }>(`/api/playground/favorite?playgroundId=${currentPlaygroundId}`, { method: "DELETE" }); - - if (!response.ok) { - throw new Error("Failed to unfavorite playground"); - } - - const data = await response.json(); setIsFavorited(false); setFavoriteCount(data.favorite_count || favoriteCount - 1); } else { // Favorite - const response = await fetch("/api/playground/favorite", { + const data = await apiFetch<{ favorite_count?: number }>("/api/playground/favorite", { method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ playgroundId: currentPlaygroundId }) + body: { playgroundId: currentPlaygroundId } }); - - if (!response.ok) { - throw new Error("Failed to favorite playground"); - } - - const data = await response.json(); setIsFavorited(true); setFavoriteCount(data.favorite_count || favoriteCount + 1); } diff --git a/app/(home)/stats/validators/c-chain/page.tsx b/app/(home)/stats/validators/c-chain/page.tsx index 8340f035981..3340ea87aaa 100644 --- a/app/(home)/stats/validators/c-chain/page.tsx +++ b/app/(home)/stats/validators/c-chain/page.tsx @@ -13,6 +13,7 @@ import { StickyNavBar } from "@/components/stats/StickyNavBar"; import { PeriodSelector, type Period } from "@/components/stats/PeriodSelector"; import { MobileSocialLinks } from "@/components/stats/MobileSocialLinks"; import { SearchInputWithClear } from "@/components/stats/SearchInputWithClear"; +import { apiFetch } from "@/lib/api/client"; import { SortIcon } from "@/components/stats/SortIcon"; import { useSectionNavigation } from "@/hooks/use-section-navigation"; import { LinkableHeading } from "@/components/stats/LinkableHeading"; @@ -96,21 +97,15 @@ export default function CChainValidatorMetrics() { // Fetch all APIs in parallel // Use validator-stats API for version breakdown (same as landing page) - const [statsResponse, validatorsResponse, validatorStatsResponse, stakingAPYResponse, p2pResponse] = + const [primaryNetworkData, validatorsData, allSubnets, stakingData, p2pData] = await Promise.all([ - fetch(`/api/primary-network-stats?timeRange=all`), - fetch("/api/primary-network-validators"), - fetch("/api/validator-stats?network=mainnet"), - fetch('/api/staking-apy'), - fetch('/api/validators'), + apiFetch(`/api/primary-network-stats?timeRange=all`), + apiFetch("/api/primary-network-validators").catch(() => null), + apiFetch("/api/validator-stats?network=mainnet").catch(() => null), + apiFetch('/api/staking-apy').catch(() => null), + apiFetch('/api/validators').catch(() => null), ]); - if (!statsResponse.ok) { - throw new Error(`HTTP error! status: ${statsResponse.status}`); - } - - const primaryNetworkData = await statsResponse.json(); - if (!primaryNetworkData) { throw new Error("Primary Network data not found"); } @@ -119,9 +114,8 @@ export default function CChainValidatorMetrics() { // Get version breakdown from validator-stats API (same source as landing page) // Primary Network has id: 11111111111111111111111111111111LpoYY - if (validatorStatsResponse.ok) { + if (allSubnets) { try { - const allSubnets = await validatorStatsResponse.json(); const primaryNetwork = allSubnets.find( (s: any) => s.id === "11111111111111111111111111111111LpoYY" ); @@ -181,16 +175,14 @@ export default function CChainValidatorMetrics() { } // Process validators data - if (validatorsResponse.ok) { - const validatorsData = await validatorsResponse.json(); + if (validatorsData) { const validatorsList = validatorsData.validators || []; setValidators(validatorsList); } // Process staking APY data - if (stakingAPYResponse.ok) { + if (stakingData) { try { - const stakingData = await stakingAPYResponse.json(); setStakingAPYData(stakingData); } catch (err) { console.error('Error parsing staking APY data:', err); @@ -198,11 +190,10 @@ export default function CChainValidatorMetrics() { } // Process P2P validators data - if (p2pResponse.ok) { + if (p2pData) { try { - const p2pData: P2PValidatorData[] = await p2pResponse.json(); const p2pMap = new Map(); - p2pData.forEach((v) => p2pMap.set(v.node_id, v)); + (p2pData as P2PValidatorData[]).forEach((v) => p2pMap.set(v.node_id, v)); setP2pValidators(p2pMap); } catch (err) { console.error('Error parsing P2P validators data:', err); diff --git a/app/(home)/stats/validators/node/[nodeId]/page.tsx b/app/(home)/stats/validators/node/[nodeId]/page.tsx index 599126a0106..60cfa1b9651 100644 --- a/app/(home)/stats/validators/node/[nodeId]/page.tsx +++ b/app/(home)/stats/validators/node/[nodeId]/page.tsx @@ -2,6 +2,7 @@ import React, { useState, useEffect, useMemo } from "react"; import { useParams } from "next/navigation"; +import { apiFetch } from "@/lib/api/client"; import { Area, AreaChart, Bar, BarChart, CartesianGrid, XAxis, YAxis, ComposedChart, Line, LineChart } from "recharts"; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; import { type ChartConfig, ChartContainer, ChartTooltip } from "@/components/ui/chart"; @@ -117,8 +118,8 @@ export default function ValidatorNodeDetailPage() { setError(null); const results = await Promise.allSettled([ - fetch(`/api/validators/${encodeURIComponent(nodeId)}`).then(r => r.ok ? r.json() : null), - fetch(`/api/validator-details/${encodeURIComponent(nodeId)}`).then(r => r.ok ? r.json().then(d => d.validatorDetails) : null), + apiFetch(`/api/validators/${encodeURIComponent(nodeId)}`).catch(() => null), + apiFetch(`/api/validator-details/${encodeURIComponent(nodeId)}`).then(d => d.validatorDetails).catch(() => null), ]); const p2p = results[0].status === "fulfilled" ? results[0].value : null; diff --git a/app/(home)/stats/validators/page.tsx b/app/(home)/stats/validators/page.tsx index e3fd1981e7d..2e6cc0eca80 100644 --- a/app/(home)/stats/validators/page.tsx +++ b/app/(home)/stats/validators/page.tsx @@ -1,6 +1,7 @@ "use client"; import type React from "react"; import { useState, useEffect } from "react"; +import { apiFetch, ApiClientError } from "@/lib/api/client"; import Image from "next/image"; import Link from "next/link"; import { useTheme } from "next-themes"; @@ -91,14 +92,7 @@ export default function ValidatorStatsPage() { setError(null); try { - const response = await fetch(`/api/validator-stats?network=${network}`); - if (!response.ok) { - throw new Error( - `Failed to fetch validator stats: ${response.status}` - ); - } - - const stats: SubnetStats[] = await response.json(); + const stats = await apiFetch(`/api/validator-stats?network=${network}`); setData(stats); // Extract available versions @@ -116,9 +110,9 @@ export default function ValidatorStatsPage() { if (!minVersion && sortedVersions.length > 0) { setMinVersion(sortedVersions[0]); } - } catch (err: any) { + } catch (err) { console.error("Error fetching validator stats:", err); - setError(err?.message || "Failed to load validator stats"); + setError(err instanceof ApiClientError ? err.message : err instanceof Error ? err.message : "Failed to load validator stats"); } setLoading(false); diff --git a/app/api/.well-known/jwks.json/route.ts b/app/api/.well-known/jwks.json/route.ts index e25e029cd0f..adc5d918204 100644 --- a/app/api/.well-known/jwks.json/route.ts +++ b/app/api/.well-known/jwks.json/route.ts @@ -1,44 +1,44 @@ import { NextResponse } from 'next/server'; import { importPKCS8, exportJWK, calculateJwkThumbprint } from 'jose'; - +import { InternalError, errorResponse } from '@/lib/api'; async function loadKey(base64Key: string) { - const pem = Buffer.from(base64Key, 'base64').toString('utf8'); - const privateKey = await importPKCS8(pem, 'ES256'); - const publicJWK = await exportJWK(privateKey); - const kid = await calculateJwkThumbprint(publicJWK); - const { d, ...publicKeyOnly } = publicJWK; - return { ...publicKeyOnly, kty: 'EC' as const, use: 'sig' as const, alg: 'ES256' as const, kid }; + const pem = Buffer.from(base64Key, 'base64').toString('utf8'); + const privateKey = await importPKCS8(pem, 'ES256'); + const publicJWK = await exportJWK(privateKey); + const kid = await calculateJwkThumbprint(publicJWK); + const { d: _d, ...publicKeyOnly } = publicJWK; + return { ...publicKeyOnly, kty: 'EC' as const, use: 'sig' as const, alg: 'ES256' as const, kid }; } +// withApi: not applicable — custom Cache-Control headers required // Generate keys with: openssl ecparam -genkey -name prime256v1 -noout | openssl pkcs8 -topk8 -nocrypt | base64 -w0 export async function GET() { - try { - const keys = []; - - if (process.env.GLACIER_JWT_PRIVATE_KEY) { - keys.push(await loadKey(process.env.GLACIER_JWT_PRIVATE_KEY)); - } + try { + const keys = []; - if (process.env.OAUTH_JWT_PRIVATE_KEY) { - keys.push(await loadKey(process.env.OAUTH_JWT_PRIVATE_KEY)); - } + if (process.env.GLACIER_JWT_PRIVATE_KEY) { + keys.push(await loadKey(process.env.GLACIER_JWT_PRIVATE_KEY)); + } - if (keys.length === 0) { - throw new Error('No JWT signing keys configured'); - } + if (process.env.OAUTH_JWT_PRIVATE_KEY) { + keys.push(await loadKey(process.env.OAUTH_JWT_PRIVATE_KEY)); + } - return NextResponse.json({ keys }, { - headers: { - 'Cache-Control': 'public, max-age=3600, s-maxage=3600', - 'Content-Type': 'application/json' - } - }); - } catch (error) { - console.error('Error generating JWKS:', error); - return NextResponse.json( - { error: 'Failed to generate JWKS' }, - { status: 500 } - ); + if (keys.length === 0) { + throw new InternalError('No JWT signing keys configured'); } + + return NextResponse.json( + { keys }, + { + headers: { + 'Cache-Control': 'public, max-age=3600, s-maxage=3600', + 'Content-Type': 'application/json', + }, + }, + ); + } catch (error) { + return errorResponse(error); + } } diff --git a/app/api/avax-supply/route.ts b/app/api/avax-supply/route.ts index e4efe505614..4af2b55f814 100644 --- a/app/api/avax-supply/route.ts +++ b/app/api/avax-supply/route.ts @@ -1,68 +1,47 @@ -import { NextResponse } from "next/server"; +import { withApi, successResponse, InternalError } from '@/lib/api'; interface CoinGeckoResponse { - "avalanche-2": { + 'avalanche-2': { usd: number; usd_24h_change: number; }; } -export async function GET() { - try { - const [supplyResponse, priceResponse] = await Promise.all([ - fetch("https://data-api.avax.network/v1/avax/supply", { - headers: { - accept: "application/json", - }, - next: { revalidate: 14400 }, // 4 hours - aligns with other aggregate metrics - }), - fetch( - "https://api.coingecko.com/api/v3/simple/price?ids=avalanche-2&vs_currencies=usd&include_24hr_change=true", - { - headers: { - Accept: "application/json", - }, - next: { revalidate: 60 }, // 1 minute - price data changes frequently - } - ), - ]); +export const GET = withApi(async () => { + const [supplyResponse, priceResponse] = await Promise.all([ + fetch('https://data-api.avax.network/v1/avax/supply', { + headers: { accept: 'application/json' }, + next: { revalidate: 14400 }, + }), + fetch('https://api.coingecko.com/api/v3/simple/price?ids=avalanche-2&vs_currencies=usd&include_24hr_change=true', { + headers: { Accept: 'application/json' }, + next: { revalidate: 60 }, + }), + ]); - if (!supplyResponse.ok) { - throw new Error(`Failed to fetch AVAX supply data: ${supplyResponse.status}`); - } + if (!supplyResponse.ok) { + throw new InternalError(`Failed to fetch AVAX supply data: ${supplyResponse.status}`); + } - const supplyData = await supplyResponse.json(); - - let priceData = { - price: 0, - change24h: 0, - }; + const supplyData = await supplyResponse.json(); - if (priceResponse.ok) { - try { - const priceJson: CoinGeckoResponse = await priceResponse.json(); - priceData = { - price: priceJson["avalanche-2"]?.usd || 0, - change24h: priceJson["avalanche-2"]?.usd_24h_change || 0, - }; - } catch (priceError) { - console.warn("Failed to parse price data:", priceError); - } - } else { - console.warn("Price API returned non-ok response"); - } + let priceData = { price: 0, change24h: 0 }; - return NextResponse.json({ - ...supplyData, - price: priceData.price, - priceChange24h: priceData.change24h, - }); - } catch (error) { - console.error("Error fetching AVAX supply:", error); - return NextResponse.json( - { error: "Failed to fetch AVAX supply data" }, - { status: 500 } - ); + if (priceResponse.ok) { + try { + const priceJson: CoinGeckoResponse = await priceResponse.json(); + priceData = { + price: priceJson['avalanche-2']?.usd || 0, + change24h: priceJson['avalanche-2']?.usd_24h_change || 0, + }; + } catch { + // Price parse failed; proceed with zero values + } } -} + return successResponse({ + ...supplyData, + price: priceData.price, + priceChange24h: priceData.change24h, + }); +}); diff --git a/app/api/badge/assign/route.ts b/app/api/badge/assign/route.ts index 9fb957a92a6..a55a6441fad 100644 --- a/app/api/badge/assign/route.ts +++ b/app/api/badge/assign/route.ts @@ -1,69 +1,47 @@ -import { withAuth } from "@/lib/protectedRoute"; -import { badgeAssignmentService } from "@/server/services/badgeAssignmentService"; -import { getAuthSession } from "@/lib/auth/authSession"; +import { z } from 'zod'; +import { BadgeCategory } from '@/server/services/badge'; +import { badgeAssignmentService } from '@/server/services/badgeAssignmentService'; +import { withApi, successResponse, ForbiddenError } from '@/lib/api'; -import { NextRequest, NextResponse } from "next/server"; +const bodySchema = z.object({ + userId: z.string().min(1, 'userId is required'), + courseId: z.string().optional(), + hackathonId: z.string().optional(), + projectId: z.string().optional(), + requirementId: z.string().optional(), + badgesId: z.array(z.string()).optional(), + consoleTrigger: z.enum(['console_log', 'faucet_claim', 'node_registration']).optional(), + category: z.nativeEnum(BadgeCategory).optional(), +}); + +type AssignBody = z.infer; -export const POST = withAuth(async (req: NextRequest) => { - try { - const body = await req.json(); - const session = await getAuthSession(); - - const userRole = session?.user.role || "user"; - const customAttributes = session?.user.custom_attributes ?? []; - - // Get the required role for this badge type +export const POST = withApi( + async (_req, { session, body }) => { const requiredRole = badgeAssignmentService.getRequiredRoleForAssignment(body); + const customAttributes: string[] = session.user?.custom_attributes ?? []; + let hasAdminPermission = requiredRole ? customAttributes.includes(requiredRole) : false; // If badge_admin is required and user doesn't have it, check for devrel (super admin) - if (requiredRole === "badge_admin" && !hasAdminPermission) { - hasAdminPermission = customAttributes.includes("devrel"); + if (requiredRole === 'badge_admin' && !hasAdminPermission) { + hasAdminPermission = customAttributes.includes('devrel'); } // Security check: Users can only assign badges to themselves unless they have admin role // - If no admin role required (academy/requirement badges): user must assign to self // - If admin role required (project badges): user must have the required role (badge_admin) if (requiredRole === null) { - // No admin role required = user can only assign to themselves - if (body.userId !== session?.user.id) { - return NextResponse.json( - { error: { message: "You can only assign badges to yourself" } }, - { status: 403 } - ); + if (body.userId !== session.user?.id) { + throw new ForbiddenError('You can only assign badges to yourself'); } - } else { - // Admin role required - check if user has the permission - if (!hasAdminPermission) { - return NextResponse.json( - { - error: { - message: `Insufficient permissions. Required role: ${requiredRole}, User role: ${userRole}` - } - }, - { status: 403 } - ); - } - // Admin can assign to any user - no userId restriction + } else if (!hasAdminPermission) { + throw new ForbiddenError(`Insufficient permissions. Required role: ${requiredRole}`); } - - // Use the user's name as awardedBy - const badge = await badgeAssignmentService.assignBadge(body, session?.user.name || undefined); - return NextResponse.json({ result: badge }, { status: 200 }); - } catch (error: any) { - console.error('Error POST /api/badge/assign:', error.message); - const wrappedError = error as Error; - return NextResponse.json( - { - error: { - message: wrappedError.message, - stack: wrappedError.stack, - cause: wrappedError.cause, - name: wrappedError.name, - }, - }, - { status: wrappedError.cause == "ValidationError" ? 400 : 500 } - ); - } -}); + const badge = await badgeAssignmentService.assignBadge(body, session.user?.name || undefined); + + return successResponse(badge); + }, + { auth: true, schema: bodySchema }, +); diff --git a/app/api/badge/console-check/route.ts b/app/api/badge/console-check/route.ts index 8a9e8dec333..a1b17a64587 100644 --- a/app/api/badge/console-check/route.ts +++ b/app/api/badge/console-check/route.ts @@ -1,14 +1,9 @@ -import { NextRequest, NextResponse } from "next/server"; -import { getAuthSession } from "@/lib/auth/authSession"; -import { evaluateAllConsoleBadges } from "@/server/services/consoleBadge/consoleBadgeService"; +import { withApi, successResponse } from '@/lib/api'; +import { evaluateAllConsoleBadges } from '@/server/services/consoleBadge/consoleBadgeService'; -export async function POST(req: NextRequest) { - const session = await getAuthSession(); - if (!session?.user?.id) { - return NextResponse.json({ awardedBadges: [] }); - } - - try { +// schema: not applicable — body is optional (timezone hint), parsed defensively +export const POST = withApi( + async (req, { session }) => { let timezone: string | undefined; try { const body = await req.json(); @@ -18,9 +13,7 @@ export async function POST(req: NextRequest) { } const awardedBadges = await evaluateAllConsoleBadges(session.user.id, { timezone }); - return NextResponse.json({ awardedBadges }); - } catch (error) { - console.error("Console badge check error:", error); - return NextResponse.json({ awardedBadges: [] }); - } -} + return successResponse({ awardedBadges }); + }, + { auth: true }, +); diff --git a/app/api/badge/console-migrate/route.ts b/app/api/badge/console-migrate/route.ts index f60179cc894..ead92d62f21 100644 --- a/app/api/badge/console-migrate/route.ts +++ b/app/api/badge/console-migrate/route.ts @@ -1,25 +1,15 @@ -import { NextResponse } from "next/server"; -import { getAuthSession } from "@/lib/auth/authSession"; -import { prisma } from "@/prisma/prisma"; -import { evaluateAllConsoleBadges } from "@/server/services/consoleBadge/consoleBadgeService"; +import { withApi, successResponse } from '@/lib/api'; +import { prisma } from '@/prisma/prisma'; +import { evaluateAllConsoleBadges } from '@/server/services/consoleBadge/consoleBadgeService'; -export async function POST() { - const session = await getAuthSession(); - if (!session?.user?.id) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - const hasDevrel = session.user.custom_attributes?.includes("devrel") ?? false; - if (!hasDevrel) { - return NextResponse.json({ error: "Forbidden" }, { status: 403 }); - } - - try { +// schema: not applicable — no request body, migration trigger only +export const POST = withApi( + async () => { // Get distinct user IDs from all console-related tables const [consoleLogUsers, faucetClaimUsers, nodeRegistrationUsers] = await Promise.all([ - prisma.consoleLog.findMany({ select: { user_id: true }, distinct: ["user_id"] }), - prisma.faucetClaim.findMany({ select: { user_id: true }, distinct: ["user_id"] }), - prisma.nodeRegistration.findMany({ select: { user_id: true }, distinct: ["user_id"] }), + prisma.consoleLog.findMany({ select: { user_id: true }, distinct: ['user_id'] }), + prisma.faucetClaim.findMany({ select: { user_id: true }, distinct: ['user_id'] }), + prisma.nodeRegistration.findMany({ select: { user_id: true }, distinct: ['user_id'] }), ]); const uniqueUserIds = new Set([ @@ -39,17 +29,11 @@ export async function POST() { } } - return NextResponse.json({ - success: true, + return successResponse({ usersProcessed: uniqueUserIds.size, totalBadgesAwarded, details: results, }); - } catch (error) { - console.error("Console badge migration error:", error); - return NextResponse.json( - { error: error instanceof Error ? error.message : "Migration failed" }, - { status: 500 } - ); - } -} + }, + { auth: true, roles: ['devrel'] }, +); diff --git a/app/api/badge/get-all/route.ts b/app/api/badge/get-all/route.ts index 142935a6c09..c1c4995ea75 100644 --- a/app/api/badge/get-all/route.ts +++ b/app/api/badge/get-all/route.ts @@ -1,17 +1,10 @@ -import { withAuth } from "@/lib/protectedRoute"; -import { getAllBadges, getBadgeByCourseId } from "@/server/services/badge"; +import { withApi, successResponse } from '@/lib/api'; +import { getAllBadges } from '@/server/services/badge'; -import { NextResponse } from "next/server"; - -export const GET = withAuth(async () => { - try { +export const GET = withApi( + async () => { const badges = await getAllBadges(); - return NextResponse.json(badges, { status: 200 }); - } catch (error) { - console.error("Error getting badge:", error); - return NextResponse.json( - { error: "Internal server error" }, - { status: 500 } - ); - } -}); + return successResponse(badges); + }, + { auth: true }, +); diff --git a/app/api/badge/project-badge/route.ts b/app/api/badge/project-badge/route.ts index aac43077d63..a225584603a 100644 --- a/app/api/badge/project-badge/route.ts +++ b/app/api/badge/project-badge/route.ts @@ -1,27 +1,16 @@ -import { withAuth } from "@/lib/protectedRoute"; +import { z } from 'zod'; +import { withApi, successResponse, validateQuery } from '@/lib/api'; +import { getProjectBadges } from '@/server/services/project-badge'; -import { getProjectBadges } from "@/server/services/project-badge"; - -import { NextResponse } from "next/server"; - -export const GET = withAuth(async (request) => { - const { searchParams } = new URL(request.url); - const project_id = searchParams.get("project_id"); - if (!project_id) { - return NextResponse.json( - { error: "project_id parameter is required" }, - { status: 400 } - ); - } - - try { - const badge = await getProjectBadges(project_id); - return NextResponse.json(badge, { status: 200 }); - } catch (error) { - console.error("Error getting badge:", error); - return NextResponse.json( - { error: "Internal server error" }, - { status: 500 } - ); - } +const querySchema = z.object({ + project_id: z.string().min(1, 'project_id is required'), }); + +export const GET = withApi( + async (req) => { + const { project_id } = validateQuery(req, querySchema); + const badges = await getProjectBadges(project_id); + return successResponse(badges); + }, + { auth: true }, +); diff --git a/app/api/badge/route.ts b/app/api/badge/route.ts index 0e645ae3434..7bffd36f3a0 100644 --- a/app/api/badge/route.ts +++ b/app/api/badge/route.ts @@ -1,26 +1,16 @@ -import { withAuth } from "@/lib/protectedRoute"; -import { getBadgeByCourseId } from "@/server/services/badge"; +import { z } from 'zod'; +import { withApi, successResponse, validateQuery } from '@/lib/api'; +import { getBadgeByCourseId } from '@/server/services/badge'; -import { NextResponse } from "next/server"; - -export const GET = withAuth(async (request) => { - const { searchParams } = new URL(request.url); - const course_id = searchParams.get("course_id"); - if (!course_id) { - return NextResponse.json( - { error: "course_id parameter is required" }, - { status: 400 } - ); - } +const querySchema = z.object({ + course_id: z.string().min(1, 'course_id is required'), +}); - try { +export const GET = withApi( + async (req) => { + const { course_id } = validateQuery(req, querySchema); const badge = await getBadgeByCourseId(course_id); - return NextResponse.json(badge, { status: 200 }); - } catch (error) { - console.error("Error getting badge:", error); - return NextResponse.json( - { error: "Internal server error" }, - { status: 500 } - ); - } -}); + return successResponse(badge); + }, + { auth: true }, +); diff --git a/app/api/badge/validate/route.ts b/app/api/badge/validate/route.ts index 96640fc070e..82186bb0755 100644 --- a/app/api/badge/validate/route.ts +++ b/app/api/badge/validate/route.ts @@ -1,33 +1,17 @@ -import { withAuth } from "@/lib/protectedRoute"; -import { getBadgeByCourseId } from "@/server/services/badge"; +import { z } from 'zod'; +import { withApi, successResponse, validateQuery } from '@/lib/api'; +import { getBadgeByCourseId } from '@/server/services/badge'; -import { NextResponse } from "next/server"; - -export const GET = withAuth(async (request) => { - const { searchParams } = new URL(request.url); - const course_id = searchParams.get("course_id"); - const user_id = searchParams.get("user_id"); - if (!course_id) { - return NextResponse.json( - { error: "course_id parameter is required" }, - { status: 400 } - ); - } - if (!user_id) { - return NextResponse.json( - { error: "user_id parameter is required" }, - { status: 400 } - ); - } +const querySchema = z.object({ + course_id: z.string().min(1, 'course_id is required'), + user_id: z.string().min(1, 'user_id is required'), +}); - try { +export const GET = withApi( + async (req) => { + const { course_id } = validateQuery(req, querySchema); const badge = await getBadgeByCourseId(course_id); - return NextResponse.json(badge, { status: 200 }); - } catch (error) { - console.error("Error getting badge:", error); - return NextResponse.json( - { error: "Internal server error" }, - { status: 500 } - ); - } -}); + return successResponse(badge); + }, + { auth: true }, +); diff --git a/app/api/build-games/apply/route.ts b/app/api/build-games/apply/route.ts index f4f0d868db9..528d2023e37 100644 --- a/app/api/build-games/apply/route.ts +++ b/app/api/build-games/apply/route.ts @@ -1,28 +1,21 @@ -import { NextResponse } from 'next/server'; +import type { NextRequest } from 'next/server'; +import { withApi } from '@/lib/api/with-api'; +import { successResponse } from '@/lib/api/response'; +import { BadRequestError, InternalError, ValidationError } from '@/lib/api/errors'; import { prisma } from '@/prisma/prisma'; -async function fetchWithTimeout(url: string, options: RequestInit, timeout: number): Promise { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), timeout); - - try { - const response = await fetch(url, {...options, signal: controller.signal}); - return response; - } finally { - clearTimeout(timeoutId); - } -} +// --------------------------------------------------------------------------- +// Config +// --------------------------------------------------------------------------- const HUBSPOT_API_KEY = process.env.HUBSPOT_API_KEY; const HUBSPOT_PORTAL_ID = process.env.HUBSPOT_PORTAL_ID || '7522520'; const BUILD_GAMES_FORM_GUID = process.env.BUILD_GAMES_FORM_GUID || '2bab493b-9933-4076-8ace-f3cab2fe8cfb'; -const BUILD_GAMES_HACKATHON_ID = process.env.BUILD_GAMES_HACKATHON_ID; const DEFAULT_GITHUB_URL = 'https://github.com/ava-labs/builders-hub'; -// Map form field names to HubSpot field names -// Field names from HubSpot form: 2bab493b-9933-4076-8ace-f3cab2fe8cfb const FIELD_GROUP_PREFIX = '2-49793193/'; +/** Allowlisted form keys that may be forwarded to HubSpot. */ const HUBSPOT_FIELD_MAPPING: Record = { firstName: 'firstname', lastName: `${FIELD_GROUP_PREFIX}applicant_last_name`, @@ -51,226 +44,154 @@ const HUBSPOT_FIELD_MAPPING: Record = { marketingConsent: 'marketing_consent', }; -export async function POST(request: Request) { - try { - if (!HUBSPOT_API_KEY) { - console.error('Missing environment variable: HUBSPOT_API_KEY'); - return NextResponse.json( - { success: false, message: 'Server configuration error' }, - { status: 500 } - ); - } - - const clonedRequest = request.clone(); - let formData; - try { - formData = await clonedRequest.json(); - } catch (error) { - console.error('Error parsing request body:', error); - return NextResponse.json( - { success: false, message: 'Invalid request body' }, - { status: 400 } - ); - } - - const fields: { name: string; value: string | boolean }[] = []; - const hubspotRequiredFields = [`${FIELD_GROUP_PREFIX}applicant_source_other`]; - // Fields that are only for our database, not HubSpot - const internalOnlyFields = ['referrer']; +/** Fields that are stored in our DB only; never forwarded to HubSpot. */ +const INTERNAL_ONLY_FIELDS = new Set(['referrer']); - Object.entries(formData).forEach(([key, value]) => { - // Skip internal-only fields that shouldn't go to HubSpot - if (internalOnlyFields.includes(key)) { - return; - } +/** All keys the client is allowed to submit. */ +const ALLOWED_KEYS = new Set([...Object.keys(HUBSPOT_FIELD_MAPPING), ...INTERNAL_ONLY_FIELDS]); - const hubspotFieldName = HUBSPOT_FIELD_MAPPING[key] || key; +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- - if (value === undefined || value === null || value === '') { - if (hubspotRequiredFields.includes(hubspotFieldName)) { - fields.push({ name: hubspotFieldName, value: '' }); - } - return; - } +async function fetchWithTimeout(url: string, options: RequestInit, timeout: number): Promise { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeout); + try { + return await fetch(url, { ...options, signal: controller.signal }); + } finally { + clearTimeout(timeoutId); + } +} - let formattedValue: string | boolean; +function buildHubSpotFields(formData: Record) { + const fields: { name: string; value: string | boolean }[] = []; + const hubspotRequiredFields = [`${FIELD_GROUP_PREFIX}applicant_source_other`]; - if (typeof value === 'boolean') { - if (key === 'privacyPolicyRead' || key === 'marketingConsent') { formattedValue = value } - else { formattedValue = value ? 'Yes' : 'No' } - } else if (typeof value === 'string') { - formattedValue = value === 'yes' ? 'Yes' : value === 'no' ? 'No' : value; - } else { formattedValue = String(value) } + // Only iterate over allowlisted keys (mass-assignment guard) + for (const key of Object.keys(formData)) { + if (!ALLOWED_KEYS.has(key)) continue; + if (INTERNAL_ONLY_FIELDS.has(key)) continue; - fields.push({ - name: hubspotFieldName, - value: formattedValue, - }); - }); + const value = formData[key]; + const hubspotFieldName = HUBSPOT_FIELD_MAPPING[key] || key; - // Ensure HubSpot required fields are always included (even if not in form data) - hubspotRequiredFields.forEach((requiredField) => { - const fieldExists = fields.some((f) => f.name === requiredField); - if (!fieldExists) { - fields.push({ name: requiredField, value: '' }); + if (value === undefined || value === null || value === '') { + if (hubspotRequiredFields.includes(hubspotFieldName)) { + fields.push({ name: hubspotFieldName, value: '' }); } - }); - - // Use default GitHub URL if not provided (HubSpot requires this field) - const githubFieldName = HUBSPOT_FIELD_MAPPING['github']; - const githubFieldIndex = fields.findIndex((f) => f.name === githubFieldName); - if (githubFieldIndex === -1) { - fields.push({ name: githubFieldName, value: DEFAULT_GITHUB_URL }); - } else if (!fields[githubFieldIndex].value) { - fields[githubFieldIndex].value = DEFAULT_GITHUB_URL; + continue; } - // Use "how did you hear" selection as default for "specify" field if not provided - const specifyFieldName = HUBSPOT_FIELD_MAPPING['howDidYouHearSpecify']; - const specifyFieldIndex = fields.findIndex((f) => f.name === specifyFieldName); - const howDidYouHearValue = formData.howDidYouHear as string || ''; - if (specifyFieldIndex === -1) { - fields.push({ name: specifyFieldName, value: howDidYouHearValue }); - } else if (!fields[specifyFieldIndex].value) { - fields[specifyFieldIndex].value = howDidYouHearValue; + let formattedValue: string | boolean; + if (typeof value === 'boolean') { + formattedValue = key === 'privacyPolicyRead' || key === 'marketingConsent' ? value : value ? 'Yes' : 'No'; + } else if (typeof value === 'string') { + formattedValue = value === 'yes' ? 'Yes' : value === 'no' ? 'No' : value; + } else { + formattedValue = String(value); } - const hubspotPayload: { - fields: { name: string; value: string | boolean }[]; - context: { pageUri: string; pageName: string }; - legalConsentOptions?: { - consent: { - consentToProcess: boolean; - text: string; - communications: Array<{ - value: boolean; - subscriptionTypeId: number; - text: string; - }>; - }; - }; - } = { - fields: fields, - context: { - pageUri: request.headers.get('referer') || 'https://build.avax.network/build-games/apply', - pageName: 'Build Games 2026 Application', - }, - }; + fields.push({ name: hubspotFieldName, value: formattedValue }); + } - if (formData.privacyPolicyRead === true) { - hubspotPayload.legalConsentOptions = { - consent: { - consentToProcess: true, - text: 'I agree to allow Avalanche Foundation to store and process my personal data.', - communications: [ - { - value: formData.marketingConsent === true, - subscriptionTypeId: 999, - text: 'I agree to receive marketing communications from Avalanche Foundation.', - }, - ], - }, - }; + // Ensure HubSpot required fields are always present + for (const requiredField of hubspotRequiredFields) { + if (!fields.some((f) => f.name === requiredField)) { + fields.push({ name: requiredField, value: '' }); } + } - // Run HubSpot submission and DB save in parallel - console.log('[Build Games Apply] Starting submission...'); - console.log('[Build Games Apply] HubSpot payload fields:', JSON.stringify(fields, null, 2)); - console.log('[Build Games Apply] HubSpot legal consent:', JSON.stringify(hubspotPayload.legalConsentOptions, null, 2)); + // Default GitHub URL + const githubFieldName = HUBSPOT_FIELD_MAPPING['github']; + const githubIdx = fields.findIndex((f) => f.name === githubFieldName); + if (githubIdx === -1) { + fields.push({ name: githubFieldName, value: DEFAULT_GITHUB_URL }); + } else if (!fields[githubIdx].value) { + fields[githubIdx].value = DEFAULT_GITHUB_URL; + } - const hubspotUrl = `https://api.hsforms.com/submissions/v3/integration/submit/${HUBSPOT_PORTAL_ID}/${BUILD_GAMES_FORM_GUID}`; - console.log('[Build Games Apply] HubSpot URL:', hubspotUrl); + // Default "specify" from howDidYouHear + const specifyFieldName = HUBSPOT_FIELD_MAPPING['howDidYouHearSpecify']; + const specifyIdx = fields.findIndex((f) => f.name === specifyFieldName); + const howDidYouHearValue = (formData.howDidYouHear as string) || ''; + if (specifyIdx === -1) { + fields.push({ name: specifyFieldName, value: howDidYouHearValue }); + } else if (!fields[specifyIdx].value) { + fields[specifyIdx].value = howDidYouHearValue; + } + + return fields; +} + +async function submitToHubSpot( + fields: { name: string; value: string | boolean }[], + formData: Record, + referer: string | null, +): Promise<{ success: boolean; status: number; data: any; error?: string }> { + const hubspotPayload: Record = { + fields, + context: { + pageUri: referer || 'https://build.avax.network/build-games/apply', + pageName: 'Build Games 2026 Application', + }, + }; + + if (formData.privacyPolicyRead === true) { + hubspotPayload.legalConsentOptions = { + consent: { + consentToProcess: true, + text: 'I agree to allow Avalanche Foundation to store and process my personal data.', + communications: [ + { + value: formData.marketingConsent === true, + subscriptionTypeId: 999, + text: 'I agree to receive marketing communications from Avalanche Foundation.', + }, + ], + }, + }; + } + + const url = `https://api.hsforms.com/submissions/v3/integration/submit/${HUBSPOT_PORTAL_ID}/${BUILD_GAMES_FORM_GUID}`; - const hubspotPromise = fetchWithTimeout(hubspotUrl, + try { + const response = await fetchWithTimeout( + url, { method: 'POST', headers: { 'Content-Type': 'application/json', - 'Authorization': `Bearer ${HUBSPOT_API_KEY}`, + Authorization: `Bearer ${HUBSPOT_API_KEY}`, }, body: JSON.stringify(hubspotPayload), }, - 60000 // 60 second timeout + 60_000, ); - // Save to database (runs in parallel with HubSpot) - const dbPromise = saveToDatabase(formData); - - const [hubspotResult, dbResult] = await Promise.all([ - hubspotPromise - .then(async (response) => { - const status = response.status; - console.log('[Build Games Apply] HubSpot response status:', status); - let data; - try { - const responseText = await response.text(); - console.log('[Build Games Apply] HubSpot response body:', responseText); - try { - data = JSON.parse(responseText); - } catch { - data = { message: responseText || 'Could not parse response' }; - } - } catch (e) { - console.error('[Build Games Apply] Error reading HubSpot response:', e); - data = { message: 'Could not read response' }; - } - return { success: response.ok, status, data }; - }) - .catch((err) => { - console.error('[Build Games Apply] HubSpot request failed:', err); - return { success: false, status: 0, data: null, error: err.message }; - }), - dbPromise, - ]); - - const hubspotSuccess = hubspotResult.success; - const dbSuccess = dbResult.success; - - console.log('[Build Games Apply] Results - HubSpot:', hubspotSuccess ? 'success' : 'failed', '| DB:', dbSuccess ? 'success' : 'failed'); - console.log('[Build Games Apply] HubSpot result:', JSON.stringify(hubspotResult, null, 2)); - console.log('[Build Games Apply] DB result:', JSON.stringify(dbResult, null, 2)); - - if (!hubspotSuccess || !dbSuccess) { - const failedSystems = []; - if (!hubspotSuccess) failedSystems.push('HubSpot'); - if (!dbSuccess) failedSystems.push('Database'); - - console.error(`[Build Games Apply] Submission failed: ${failedSystems.join(' and ')} failed`); - - return NextResponse.json( - { - success: false, - message: 'Application submission failed. Please try again.', - details: { - hubspot: hubspotSuccess ? 'success' : ('error' in hubspotResult ? hubspotResult.error : hubspotResult.data), - database: dbSuccess ? 'success' : (dbResult.error || 'Unknown error'), - }, - }, - { status: 500 } - ); + let data: any; + try { + const text = await response.text(); + data = JSON.parse(text); + } catch { + data = { message: 'Could not parse response' }; } - return NextResponse.json({ success: true }); - } catch (error) { - console.error('Error processing Build Games application:', error); - return NextResponse.json( - { success: false, message: error instanceof Error ? error.message : 'Internal server error' }, - { status: 500 } - ); + return { success: response.ok, status: response.status, data }; + } catch (err) { + return { success: false, status: 0, data: null, error: (err as Error).message }; } } -// Save application to BuildGamesApplication table -async function saveToDatabase(formData: Record): Promise<{ success: boolean; id?: string; error?: string }> { - console.log('[Build Games Apply DB] Starting database save...'); - +async function saveToDatabase( + formData: Record, +): Promise<{ success: boolean; id?: string; error?: string }> { const email = formData.email as string; - console.log('[Build Games Apply DB] Email:', email); if (!email) return { success: false, error: 'Email is required' }; try { const applicationData = { - email: email, + email, first_name: (formData.firstName as string) || '', last_name: (formData.lastName as string) || '', telegram: (formData.telegram as string) || null, @@ -297,46 +218,57 @@ async function saveToDatabase(formData: Record): Promise<{ succ marketing_consent: formData.marketingConsent === true, }; - console.log('[Build Games Apply DB] Upserting application with data:', JSON.stringify(applicationData, null, 2)); - const result = await prisma.buildGamesApplication.upsert({ - where: { email: email }, - update: { - first_name: applicationData.first_name, - last_name: applicationData.last_name, - telegram: applicationData.telegram, - github: applicationData.github, - country: applicationData.country, - ready_to_win: applicationData.ready_to_win, - previous_avalanche_grant: applicationData.previous_avalanche_grant, - hackathon_experience: applicationData.hackathon_experience, - hackathon_details: applicationData.hackathon_details, - employment_role: applicationData.employment_role, - current_role: applicationData.current_role, - employment_status: applicationData.employment_status, - project_name: applicationData.project_name, - project_description: applicationData.project_description, - area_of_focus: applicationData.area_of_focus, - why_you: applicationData.why_you, - how_did_you_hear: applicationData.how_did_you_hear, - how_did_you_hear_specify: applicationData.how_did_you_hear_specify, - referrer_name: applicationData.referrer_name, - referrer_handle: applicationData.referrer_handle, - university_affiliation: applicationData.university_affiliation, - avalanche_ecosystem_member: applicationData.avalanche_ecosystem_member, - privacy_policy_read: applicationData.privacy_policy_read, - marketing_consent: applicationData.marketing_consent, - }, + where: { email }, + update: { ...applicationData, email: undefined } as any, create: applicationData, }); - console.log('[Build Games Apply DB] Successfully saved, ID:', result.id); return { success: true, id: result.id }; } catch (error) { - console.error('[Build Games Apply DB] Error saving to database:', error); - return { - success: false, - error: error instanceof Error ? error.message : 'Database error' - }; + return { success: false, error: (error as Error).message }; } } + +// --------------------------------------------------------------------------- +// POST /api/build-games/apply +// --------------------------------------------------------------------------- + +// withApi: auth intentionally omitted — public application form +// schema: not applicable — dynamic HubSpot field mapping with allowlist guard +export const POST = withApi(async (req: NextRequest) => { + if (!HUBSPOT_API_KEY) { + throw new InternalError('Server configuration error'); + } + + let formData: Record; + try { + formData = await req.json(); + } catch { + throw new ValidationError('Invalid request body'); + } + + // Mass-assignment guard: reject unknown keys + const unknownKeys = Object.keys(formData).filter((k) => !ALLOWED_KEYS.has(k)); + if (unknownKeys.length > 0) { + throw new BadRequestError(`Unknown fields: ${unknownKeys.join(', ')}`); + } + + const fields = buildHubSpotFields(formData); + const referer = req.headers.get('referer'); + + const [hubspotResult, dbResult] = await Promise.all([ + submitToHubSpot(fields, formData, referer), + saveToDatabase(formData), + ]); + + if (!hubspotResult.success || !dbResult.success) { + const failedSystems: string[] = []; + if (!hubspotResult.success) failedSystems.push('HubSpot'); + if (!dbResult.success) failedSystems.push('Database'); + + throw new InternalError(`Submission failed: ${failedSystems.join(' and ')}`); + } + + return successResponse({ submitted: true }, 201); +}); diff --git a/app/api/build-games/resources/route.ts b/app/api/build-games/resources/route.ts index d01a5c9064a..607ab4c10bd 100644 --- a/app/api/build-games/resources/route.ts +++ b/app/api/build-games/resources/route.ts @@ -1,19 +1,15 @@ -import { NextResponse } from 'next/server'; +import { withApi } from '@/lib/api/with-api'; +import { successResponse } from '@/lib/api/response'; import { getHackathon } from '@/server/services/hackathons'; -const HACKATHON_ID = "249d2911-7931-4aa0-a696-37d8370b79f9"; +const HACKATHON_ID = '249d2911-7931-4aa0-a696-37d8370b79f9'; -export async function GET() { - try { - const hackathon = await getHackathon(HACKATHON_ID); +export const GET = withApi(async () => { + const hackathon = await getHackathon(HACKATHON_ID); - if (!hackathon?.content?.resources) { - return NextResponse.json({ resources: [] }); - } - - return NextResponse.json({ resources: hackathon.content.resources }); - } catch (error) { - console.error('Error fetching resources:', error); - return NextResponse.json({ resources: [] }); + if (!hackathon?.content?.resources) { + return successResponse({ resources: [] }); } -} + + return successResponse({ resources: hackathon.content.resources }); +}); diff --git a/app/api/build-games/stage-data/route.ts b/app/api/build-games/stage-data/route.ts index d4d7e3aa87b..69d171965b1 100644 --- a/app/api/build-games/stage-data/route.ts +++ b/app/api/build-games/stage-data/route.ts @@ -1,7 +1,8 @@ -import { NextRequest, NextResponse } from "next/server"; -import { withAuth } from "@/lib/protectedRoute"; -import { prisma } from "@/prisma/prisma"; -import { Prisma } from "@prisma/client"; +// withApi: not applicable — uses withAuth() for session-based auth +import { NextRequest, NextResponse } from 'next/server'; +import { withAuth } from '@/lib/protectedRoute'; +import { prisma } from '@/prisma/prisma'; +import { Prisma } from '@prisma/client'; /** * GET /api/build-games/stage-data?project_id= @@ -12,37 +13,31 @@ import { Prisma } from "@prisma/client"; export const GET = withAuth(async (request: NextRequest, _context, session) => { try { const { searchParams } = new URL(request.url); - const project_id = searchParams.get("project_id"); + const project_id = searchParams.get('project_id'); if (!project_id) { - return NextResponse.json( - { error: "project_id is required" }, - { status: 400 }, - ); + return NextResponse.json({ error: 'project_id is required' }, { status: 400 }); } // Verify the requesting user is a confirmed member of this project const project = await prisma.project.findFirst({ where: { id: project_id, - members: { some: { user_id: session.user.id, status: "Confirmed" } }, + members: { some: { user_id: session.user.id, status: 'Confirmed' } }, }, }); if (!project) { - return NextResponse.json( - { error: "Project not found or unauthorized" }, - { status: 403 }, - ); + return NextResponse.json({ error: 'Project not found or unauthorized' }, { status: 403 }); } const formData = await prisma.formData.findFirst({ - where: { project_id, origin: "build_games" }, + where: { project_id, origin: 'build_games' }, }); return NextResponse.json({ form_data: formData?.form_data ?? null }); } catch (error: any) { - console.error("Error GET /api/build-games/stage-data:", error); + console.error('Error GET /api/build-games/stage-data:', error); return NextResponse.json({ error: error.message }, { status: 500 }); } }); @@ -58,91 +53,75 @@ export const GET = withAuth(async (request: NextRequest, _context, session) => { * Body: { project_id: string, form_data: { build_games: { ... } } } * Response: { form_data: { build_games: { ... } } } */ -export const POST = withAuth( - async (request: NextRequest, _context, session) => { - try { - const body = await request.json(); - const { project_id, form_data } = body as { - project_id: string; - form_data: { build_games: Record }; - }; - - if (!project_id) { - return NextResponse.json( - { error: "project_id is required" }, - { status: 400 }, - ); - } +export const POST = withAuth(async (request: NextRequest, _context, session) => { + try { + const body = await request.json(); + const { project_id, form_data } = body as { + project_id: string; + form_data: { build_games: Record }; + }; - if (!form_data?.build_games) { - return NextResponse.json( - { error: "form_data.build_games is required" }, - { status: 400 }, - ); - } + if (!project_id) { + return NextResponse.json({ error: 'project_id is required' }, { status: 400 }); + } - // Verify ownership - const project = await prisma.project.findFirst({ - where: { - id: project_id, - members: { some: { user_id: session.user.id, status: "Confirmed" } }, - }, + if (!form_data?.build_games) { + return NextResponse.json({ error: 'form_data.build_games is required' }, { status: 400 }); + } + + // Verify ownership + const project = await prisma.project.findFirst({ + where: { + id: project_id, + members: { some: { user_id: session.user.id, status: 'Confirmed' } }, + }, + }); + + if (!project) { + return NextResponse.json({ error: 'Project not found or unauthorized' }, { status: 403 }); + } + + const result = await prisma.$transaction(async (tx) => { + const existing = await tx.formData.findFirst({ + where: { project_id, origin: 'build_games' }, }); - if (!project) { - return NextResponse.json( - { error: "Project not found or unauthorized" }, - { status: 403 }, - ); - } + if (existing) { + // Deep-merge: preserve existing build_games keys, overwrite with new values + const existingBuildGames = (existing.form_data as Record)?.build_games ?? {}; + const mergedData: Prisma.InputJsonValue = { + build_games: { + ...(existingBuildGames as Record), + ...(form_data.build_games as Record), + stages: + existingBuildGames && (existingBuildGames as Record).stages + ? (existingBuildGames as Record).stages + : undefined, + }, + }; - const result = await prisma.$transaction(async (tx) => { - const existing = await tx.formData.findFirst({ - where: { project_id, origin: "build_games" }, + return tx.formData.update({ + where: { id: existing.id }, + data: { form_data: mergedData, timestamp: new Date() }, }); + } - if (existing) { - // Deep-merge: preserve existing build_games keys, overwrite with new values - const existingBuildGames = - (existing.form_data as Record)?.build_games ?? {}; - const mergedData: Prisma.InputJsonValue = { - build_games: { - ...(existingBuildGames as Record), - ...(form_data.build_games as Record< - string, - Prisma.InputJsonValue - >), - stages: - existingBuildGames && - (existingBuildGames as Record).stages - ? (existingBuildGames as Record).stages - : undefined, - }, - }; - - return tx.formData.update({ - where: { id: existing.id }, - data: { form_data: mergedData, timestamp: new Date() }, - }); - } - - return tx.formData.create({ - data: { - project_id, - origin: "build_games", - form_data: { - build_games: form_data.build_games as Prisma.InputJsonValue, - stages: undefined, - }, - timestamp: new Date(), + return tx.formData.create({ + data: { + project_id, + origin: 'build_games', + form_data: { + build_games: form_data.build_games as Prisma.InputJsonValue, + stages: undefined, }, - }); + timestamp: new Date(), + }, }); + }); - return NextResponse.json({ form_data: result.form_data }); - } catch (error: any) { - console.error("Error POST /api/build-games/stage-data:", error); - return NextResponse.json({ error: error.message }, { status: 500 }); - } - }, -); + return NextResponse.json({ form_data: result.form_data }); + } catch (error: any) { + console.error('Error POST /api/build-games/stage-data:', error); + return NextResponse.json({ error: error.message }, { status: 500 }); + } +}); diff --git a/app/api/build-games/status/route.ts b/app/api/build-games/status/route.ts index a1b8db880c2..eb594616486 100644 --- a/app/api/build-games/status/route.ts +++ b/app/api/build-games/status/route.ts @@ -1,106 +1,101 @@ -import { NextResponse } from 'next/server'; -import { getAuthSession } from '@/lib/auth/authSession'; +import type { NextRequest } from 'next/server'; +import { withApi } from '@/lib/api/with-api'; +import { successResponse } from '@/lib/api/response'; import { prisma } from '@/prisma/prisma'; -const BG_HACKATHON_ID = "249d2911-7931-4aa0-a696-37d8370b79f9"; +const BG_HACKATHON_ID = '249d2911-7931-4aa0-a696-37d8370b79f9'; -export async function GET() { - try { - const session = await getAuthSession(); +// --------------------------------------------------------------------------- +// GET /api/build-games/status — optional auth (unauthenticated = not participant) +// --------------------------------------------------------------------------- - if (!session?.user?.email) { - return NextResponse.json({ isParticipant: false }); - } +export const GET = withApi(async (_req: NextRequest, { session }) => { + if (!session?.user?.email) { + return successResponse({ isParticipant: false }); + } - // Check all three participation paths in parallel: - // 1. BuildGames application form - // 2. RegisterForm for the Build Games hackathon - // 3. Project member (non-Removed) of a Build Games project - const [application, registration, projects] = await Promise.all([ - prisma.buildGamesApplication.findUnique({ - where: { email: session.user.email }, - select: { id: true, first_name: true, project_name: true, created_at: true }, - }), - prisma.registerForm.findFirst({ - where: { hackathon_id: BG_HACKATHON_ID, email: session.user.email }, - select: { id: true, name: true, created_at: true }, - }), - prisma.project.findMany({ - where: { - hackaton_id: BG_HACKATHON_ID, - members: { some: { email: session.user.email, status: { not: "Removed" } } }, - }, - select: { - id: true, - project_name: true, - created_at: true, - members: { - where: { email: session.user.email, status: { not: "Removed" } }, - select: { status: true }, - }, + const email = session.user.email; + + // Check all three participation paths in parallel + const [application, registration, projects] = await Promise.all([ + prisma.buildGamesApplication.findUnique({ + where: { email }, + select: { id: true, first_name: true, project_name: true, created_at: true }, + }), + prisma.registerForm.findFirst({ + where: { hackathon_id: BG_HACKATHON_ID, email }, + select: { id: true, name: true, created_at: true }, + }), + prisma.project.findMany({ + where: { + hackaton_id: BG_HACKATHON_ID, + members: { some: { email, status: { not: 'Removed' } } }, + }, + select: { + id: true, + project_name: true, + created_at: true, + members: { + where: { email, status: { not: 'Removed' } }, + select: { status: true }, }, - }), - ]); + }, + }), + ]); - const isParticipant = !!(application || registration || projects.length > 0); + const isParticipant = !!(application || registration || projects.length > 0); - if (!isParticipant) { - return NextResponse.json({ isParticipant: false }); - } + if (!isParticipant) { + return successResponse({ isParticipant: false }); + } - // Fetch FormData for all projects in parallel - const projectsWithResults = await Promise.all( - projects.map(async (p) => { - const formData = await prisma.formData.findFirst({ - where: { project_id: p.id }, - select: { form_data: true }, - }); - const buildGames = (formData?.form_data as Record)?.build_games; - const stage1Result: string | null = buildGames?.stage1_result ?? null; - const stage2Result: string | null = (buildGames?.stages as Record | undefined)?.['2'] ?? null; - const isConfirmed = p.members.some((m) => m.status === "Confirmed"); - return { projectName: p.project_name, stage1Result, stage2Result, isConfirmed, createdAt: p.created_at }; - }) - ); + // Fetch FormData for all projects in parallel + const projectsWithResults = await Promise.all( + projects.map(async (p) => { + const formData = await prisma.formData.findFirst({ + where: { project_id: p.id }, + select: { form_data: true }, + }); + const buildGames = (formData?.form_data as Record)?.build_games; + const stage1Result: string | null = buildGames?.stage1_result ?? null; + const stage2Result: string | null = (buildGames?.stages as Record | undefined)?.['2'] ?? null; + const isConfirmed = p.members.some((m) => m.status === 'Confirmed'); + return { projectName: p.project_name, stage1Result, stage2Result, isConfirmed, createdAt: p.created_at }; + }), + ); - // Selection logic: - // 1. Prefer accepted projects; among those prefer confirmed membership. - // 2. If multiple confirmed+accepted exist, show all of them. - // 3. If no accepted projects, show the confirmed one. - // 4. If only one project total, show it regardless. - let selectedProjects = projectsWithResults; + // Selection logic: + // 1. Prefer accepted projects; among those prefer confirmed membership. + // 2. If multiple confirmed+accepted exist, show all of them. + // 3. If no accepted projects, show the confirmed one. + // 4. If only one project total, show it regardless. + let selectedProjects = projectsWithResults; - if (projectsWithResults.length > 1) { - const accepted = projectsWithResults.filter((p) => p.stage1Result === "accepted"); - if (accepted.length > 0) { - const confirmedAccepted = accepted.filter((p) => p.isConfirmed); - selectedProjects = confirmedAccepted.length > 0 ? confirmedAccepted : accepted; - } else { - const confirmed = projectsWithResults.filter((p) => p.isConfirmed); - selectedProjects = confirmed.length > 0 ? [confirmed[0]] : [projectsWithResults[0]]; - } + if (projectsWithResults.length > 1) { + const accepted = projectsWithResults.filter((p) => p.stage1Result === 'accepted'); + if (accepted.length > 0) { + const confirmedAccepted = accepted.filter((p) => p.isConfirmed); + selectedProjects = confirmedAccepted.length > 0 ? confirmedAccepted : accepted; + } else { + const confirmed = projectsWithResults.filter((p) => p.isConfirmed); + selectedProjects = confirmed.length > 0 ? [confirmed[0]] : [projectsWithResults[0]]; } + } - const stageResults = selectedProjects - .filter((p) => p.stage1Result !== null || p.stage2Result !== null) - .map((p) => ({ projectName: p.projectName, stage1Result: p.stage1Result, stage2Result: p.stage2Result })); + const stageResults = selectedProjects + .filter((p) => p.stage1Result !== null || p.stage2Result !== null) + .map((p) => ({ projectName: p.projectName, stage1Result: p.stage1Result, stage2Result: p.stage2Result })); - const firstProject = selectedProjects[0]; - const projectName = firstProject?.projectName ?? application?.project_name ?? "Build Games 2026"; + const firstProject = selectedProjects[0]; + const projectName = firstProject?.projectName ?? application?.project_name ?? 'Build Games 2026'; - const createdAt = ( - application?.created_at ?? - registration?.created_at ?? - firstProject?.createdAt - )?.toISOString() ?? new Date().toISOString(); + const createdAt = + (application?.created_at ?? registration?.created_at ?? firstProject?.createdAt)?.toISOString() ?? + new Date().toISOString(); - return NextResponse.json({ - isParticipant: true, - participant: { projectName, createdAt }, - stageResults, - }); - } catch (error) { - console.error('Error checking application status:', error); - return NextResponse.json({ isParticipant: false }); - } -} + return successResponse({ + isParticipant: true, + participant: { projectName, createdAt }, + stageResults, + }); +}); diff --git a/app/api/calendar/google/route.ts b/app/api/calendar/google/route.ts index c0a6d5f176a..0ff2543d1f5 100644 --- a/app/api/calendar/google/route.ts +++ b/app/api/calendar/google/route.ts @@ -1,120 +1,80 @@ -import { NextRequest, NextResponse } from 'next/server'; +import { withApi, successResponse, ValidationError, NotFoundError, InternalError } from '@/lib/api'; import { transformGoogleEventsToSchedule, GoogleCalendarEvent } from '@/lib/hackathons/schedule-strategy'; -/** - * Google Calendar API route - * - * Fetches events from a PUBLIC Google Calendar using an API Key. - * Requires GOOGLE_CALENDAR_API_KEY environment variable. - * - * Query parameters: - * - calendarId: The Google Calendar ID (required) - * - maxResults: Maximum number of events to fetch (default: 100) - * - timeMin: Only fetch events after this ISO date - * - timeMax: Only fetch events before this ISO date - */ - -export async function GET(request: NextRequest) { - try { - const apiKey = process.env.NEXT_PUBLIC_GOOGLE_CALENDAR_API_KEY; - - if (!apiKey) { - return NextResponse.json( - { error: 'NEXT_PUBLIC_GOOGLE_CALENDAR_API_KEY not configured' }, - { status: 500 } - ); - } +const CALENDAR_ID_REGEX = /^[a-zA-Z0-9._@:+\-]+$/; +const MAX_CALENDAR_ID_LENGTH = 200; +const FETCH_TIMEOUT_MS = 10_000; - const { searchParams } = new URL(request.url); - - const calendarId = searchParams.get('calendarId'); - const maxResults = searchParams.get('maxResults') || '100'; - const timeMin = searchParams.get('timeMin'); - const timeMax = searchParams.get('timeMax'); - - if (!calendarId) { - return NextResponse.json( - { error: 'calendarId is required' }, - { status: 400 } - ); - } +export const GET = withApi(async (req) => { + const calendarId = req.nextUrl.searchParams.get('calendarId'); + const timeMin = req.nextUrl.searchParams.get('timeMin'); + const timeMax = req.nextUrl.searchParams.get('timeMax'); - // Fetch all events using pagination - const allEvents: GoogleCalendarEvent[] = []; - let pageToken: string | undefined = undefined; - - do { - // Build Google Calendar API URL - const apiParams = new URLSearchParams({ - key: apiKey, - maxResults: '250', // Max allowed per request - singleEvents: 'true', - orderBy: 'startTime', - // Include conference data (Google Meet links, etc.) - conferenceDataVersion: '1', - }); + // Validate inputs first so clients get proper 400s, not 500s + if (!calendarId) { + throw new ValidationError('calendarId is required'); + } - if (timeMin) { - apiParams.set('timeMin', timeMin); - } - if (timeMax) { - apiParams.set('timeMax', timeMax); - } - if (pageToken) { - apiParams.set('pageToken', pageToken); - } + if (calendarId.length > MAX_CALENDAR_ID_LENGTH || !CALENDAR_ID_REGEX.test(calendarId)) { + throw new ValidationError('Invalid calendarId format'); + } + + const apiKey = process.env.NEXT_PUBLIC_GOOGLE_CALENDAR_API_KEY; + + if (!apiKey) { + throw new InternalError('Google Calendar API not configured'); + } - const calendarUrl = `https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(calendarId)}/events?${apiParams.toString()}`; + const allEvents: GoogleCalendarEvent[] = []; + let pageToken: string | undefined = undefined; + do { + const apiParams = new URLSearchParams({ + key: apiKey, + maxResults: '250', + singleEvents: 'true', + orderBy: 'startTime', + conferenceDataVersion: '1', + }); + + if (timeMin) apiParams.set('timeMin', timeMin); + if (timeMax) apiParams.set('timeMax', timeMax); + if (pageToken) apiParams.set('pageToken', pageToken); + + const calendarUrl = `https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(calendarId)}/events?${apiParams.toString()}`; + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + + try { const response = await fetch(calendarUrl, { - next: { revalidate: 60 }, // Cache for 1 minute (reduced for faster updates) + signal: controller.signal, + next: { revalidate: 60 }, }); if (!response.ok) { - const error = await response.text(); - console.error('Google Calendar API error:', error); - if (response.status === 404) { - return NextResponse.json( - { error: 'Calendar not found. Make sure the calendar is public and the ID is correct.' }, - { status: 404 } - ); + throw new NotFoundError('Calendar'); } - - return NextResponse.json( - { error: `Google Calendar API error: ${response.status}` }, - { status: response.status } - ); + throw new InternalError(`Google Calendar API error: ${response.status}`); } const data = await response.json(); const events = (data.items || []) as GoogleCalendarEvent[]; allEvents.push(...events); - - // Get next page token for pagination pageToken = data.nextPageToken; - } while (pageToken); - - const events = allEvents; - - // Transform to ScheduleActivity format - const schedule = transformGoogleEventsToSchedule(events); - - // Get calendar timezone from first event or calendar metadata - const calendarTimeZone = events[0]?.start?.timeZone || ''; - - return NextResponse.json({ - schedule, - totalEvents: events.length, - calendarId, - timeZone: calendarTimeZone, - }); - } catch (error) { - console.error('Google Calendar API error:', error); - - return NextResponse.json( - { error: error instanceof Error ? error.message : 'Failed to fetch calendar events' }, - { status: 500 } - ); - } -} + } finally { + clearTimeout(timeoutId); + } + } while (pageToken); + + const schedule = transformGoogleEventsToSchedule(allEvents); + const calendarTimeZone = allEvents[0]?.start?.timeZone || ''; + + return successResponse({ + schedule, + totalEvents: allEvents.length, + calendarId, + timeZone: calendarTimeZone, + }); +}); diff --git a/app/api/chain-stats/[chainId]/route.ts b/app/api/chain-stats/[chainId]/route.ts index 128bc03ed23..ece2f001ac3 100644 --- a/app/api/chain-stats/[chainId]/route.ts +++ b/app/api/chain-stats/[chainId]/route.ts @@ -1,15 +1,22 @@ import { NextResponse } from 'next/server'; -import { TimeSeriesDataPoint, TimeSeriesMetric, ICMDataPoint, ICMMetric, STATS_CONFIG, getTimestampsFromTimeRange, createTimeSeriesMetric, createICMMetric } from "@/types/stats"; -import { getChainICMData } from "@/lib/icm-clickhouse"; +import { withApi } from '@/lib/api'; +import { + TimeSeriesDataPoint, + TimeSeriesMetric, + ICMDataPoint, + ICMMetric, + STATS_CONFIG, + getTimestampsFromTimeRange, + createTimeSeriesMetric, + createICMMetric, +} from '@/types/stats'; +import { getChainICMData } from '@/lib/icm-clickhouse'; export const dynamic = 'force-dynamic'; const REQUEST_TIMEOUT_MS = 8000; const CACHE_CONTROL_HEADER = 'public, max-age=14400, s-maxage=14400, stale-while-revalidate=86400'; const METRICS_API_URL = process.env.METRICS_API_URL; -if (!METRICS_API_URL) { - console.warn('METRICS_API_URL is not set — chain-stats endpoint will fail'); -} interface ChainMetrics { activeAddresses: { @@ -58,7 +65,11 @@ const revalidatingKeys = new Set(); const pendingRequests = new Map>(); // Timeout wrapper for fetch requests -async function fetchWithTimeout(url: string, options: RequestInit = {}, timeoutMs = REQUEST_TIMEOUT_MS): Promise { +async function fetchWithTimeout( + url: string, + options: RequestInit = {}, + timeoutMs = REQUEST_TIMEOUT_MS, +): Promise { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), timeoutMs); try { @@ -75,7 +86,7 @@ async function fetchMetricsApi( startTimestamp: number, endTimestamp: number, pageSize: number, - fetchAllPages: boolean + fetchAllPages: boolean, ): Promise<{ value: number; timestamp: number }[]> { const resolvedChainId = chainId === 'all' ? 'mainnet' : chainId; const allResults: { value: number; timestamp: number }[] = []; @@ -86,7 +97,7 @@ async function fetchMetricsApi( url.searchParams.set('timeInterval', timeInterval); url.searchParams.set('startTimestamp', String(startTimestamp)); url.searchParams.set('endTimestamp', String(endTimestamp)); - url.searchParams.set('pageSize', String(pageSize)); + url.searchParams.set('pageSize', String(pageSize)); // internal config, not user input if (pageToken) url.searchParams.set('pageToken', pageToken); const res = await fetchWithTimeout(url.toString()); @@ -110,7 +121,7 @@ async function getTimeSeriesData( startTimestamp?: number, endTimestamp?: number, pageSize: number = 365, - fetchAllPages: boolean = false + fetchAllPages: boolean = false, ): Promise { try { let finalStartTimestamp: number; @@ -126,9 +137,13 @@ async function getTimeSeriesData( } const results = await fetchMetricsApi( - chainId, metricType, 'day', - finalStartTimestamp, finalEndTimestamp, - pageSize, fetchAllPages + chainId, + metricType, + 'day', + finalStartTimestamp, + finalEndTimestamp, + pageSize, + fetchAllPages, ); return results @@ -136,10 +151,10 @@ async function getTimeSeriesData( .map((result) => ({ timestamp: result.timestamp, value: result.value || 0, - date: new Date(result.timestamp * 1000).toISOString().split('T')[0] + date: new Date(result.timestamp * 1000).toISOString().split('T')[0], })); - } catch (error) { - console.warn(`[getTimeSeriesData] Failed for ${metricType} on chain ${chainId}:`, error); + } catch { + // Per-metric failure; return empty array return []; } } @@ -151,7 +166,7 @@ async function getActiveAddressesData( startTimestampParam?: number, endTimestampParam?: number, pageSize: number = 365, - fetchAllPages: boolean = false + fetchAllPages: boolean = false, ): Promise { try { let startTimestamp: number; @@ -167,9 +182,13 @@ async function getActiveAddressesData( } const results = await fetchMetricsApi( - chainId, 'activeAddresses', interval, - startTimestamp, endTimestamp, - pageSize, fetchAllPages + chainId, + 'activeAddresses', + interval, + startTimestamp, + endTimestamp, + pageSize, + fetchAllPages, ); return results @@ -177,20 +196,22 @@ async function getActiveAddressesData( .map((result) => ({ timestamp: result.timestamp, value: result.value || 0, - date: new Date(result.timestamp * 1000).toISOString().split('T')[0] + date: new Date(result.timestamp * 1000).toISOString().split('T')[0], })); - } catch (error) { - console.warn(`[getActiveAddressesData] Failed for chain ${chainId} (${interval}):`, error); + } catch { + // Active addresses failure; return empty return []; } } // Metabase endpoint URL for reward distribution (returns both daily and cumulative) // Only available for Avalanche C-Chain (43114) -const REWARDS_URL = 'https://ava-labs-inc.metabaseapp.com/api/public/dashboard/3e895234-4c31-40f7-a3ee-4656f6caf535/dashcard/6788/card/5464?parameters=%5B%7B%22type%22%3A%22string%2F%3D%22%2C%22value%22%3Anull%2C%22id%22%3A%22b87e50a4%22%2C%22target%22%3A%5B%22variable%22%2C%5B%22template-tag%22%2C%22address%22%5D%5D%7D%2C%7B%22type%22%3A%22string%2F%3D%22%2C%22value%22%3Anull%2C%22id%22%3A%2242440d5%22%2C%22target%22%3A%5B%22variable%22%2C%5B%22template-tag%22%2C%22Node_ID%22%5D%5D%7D%2C%7B%22type%22%3A%22string%2F%3D%22%2C%22value%22%3Anull%2C%22id%22%3A%22ccdf28e0%22%2C%22target%22%3A%5B%22dimension%22%2C%5B%22template-tag%22%2C%22Reward_Type%22%5D%2C%7B%22stage-number%22%3A0%7D%5D%7D%5D'; +const REWARDS_URL = + 'https://ava-labs-inc.metabaseapp.com/api/public/dashboard/3e895234-4c31-40f7-a3ee-4656f6caf535/dashcard/6788/card/5464?parameters=%5B%7B%22type%22%3A%22string%2F%3D%22%2C%22value%22%3Anull%2C%22id%22%3A%22b87e50a4%22%2C%22target%22%3A%5B%22variable%22%2C%5B%22template-tag%22%2C%22address%22%5D%5D%7D%2C%7B%22type%22%3A%22string%2F%3D%22%2C%22value%22%3Anull%2C%22id%22%3A%2242440d5%22%2C%22target%22%3A%5B%22variable%22%2C%5B%22template-tag%22%2C%22Node_ID%22%5D%5D%7D%2C%7B%22type%22%3A%22string%2F%3D%22%2C%22value%22%3Anull%2C%22id%22%3A%22ccdf28e0%22%2C%22target%22%3A%5B%22dimension%22%2C%5B%22template-tag%22%2C%22Reward_Type%22%5D%2C%7B%22stage-number%22%3A0%7D%5D%7D%5D'; // Metabase endpoint URL for Primary Network emissions/burn/fees data -const PRIMARY_NETWORK_FEES_URL = 'https://ava-labs-inc.metabaseapp.com/api/public/dashboard/38ea69a5-e373-4258-9db6-8425fcba3a1a/dashcard/9955/card/13502?parameters=%5B%5D'; +const PRIMARY_NETWORK_FEES_URL = + 'https://ava-labs-inc.metabaseapp.com/api/public/dashboard/38ea69a5-e373-4258-9db6-8425fcba3a1a/dashcard/9955/card/13502?parameters=%5B%5D'; interface RewardsData { daily: TimeSeriesDataPoint[]; @@ -215,18 +236,18 @@ interface PrimaryNetworkFeesData { async function fetchRewardsData(): Promise { try { const response = await fetchWithTimeout(REWARDS_URL, { - headers: { 'Accept': 'application/json' } + headers: { Accept: 'application/json' }, }); if (!response.ok) { - console.warn(`[fetchRewardsData] Failed to fetch: ${response.status}`); + // Rewards fetch failed; return empty return { daily: [], cumulative: [] }; } const data = await response.json(); - + if (!data?.data?.rows || !Array.isArray(data.data.rows)) { - console.warn('[fetchRewardsData] Invalid data format'); + // Invalid Metabase format; return empty return { daily: [], cumulative: [] }; } @@ -253,7 +274,7 @@ async function fetchRewardsData(): Promise { return { daily, cumulative }; } catch (error) { if (error instanceof Error && error.name !== 'AbortError') { - console.warn('[fetchRewardsData] Error:', error); + // Rewards data is best-effort } return { daily: [], cumulative: [] }; } @@ -271,24 +292,26 @@ interface AvaxSupplyData { async function fetchAuthoritativeBurnTotals(): Promise { try { const response = await fetchWithTimeout(AVAX_SUPPLY_URL, { - headers: { 'Accept': 'application/json' } + headers: { Accept: 'application/json' }, }); if (!response.ok) { - console.warn(`[fetchAuthoritativeBurnTotals] Failed to fetch: ${response.status}`); + // Burn totals fetch failed; return null return null; } const data = await response.json(); if (!data.totalCBurned || !data.totalPBurned || !data.totalXBurned) { - console.warn('[fetchAuthoritativeBurnTotals] Missing burn fields in response'); + // Missing burn fields; return null return null; } - if (isNaN(parseFloat(data.totalCBurned)) || - isNaN(parseFloat(data.totalPBurned)) || - isNaN(parseFloat(data.totalXBurned))) { - console.warn('[fetchAuthoritativeBurnTotals] Invalid numeric values in response'); + if ( + isNaN(parseFloat(data.totalCBurned)) || + isNaN(parseFloat(data.totalPBurned)) || + isNaN(parseFloat(data.totalXBurned)) + ) { + // Invalid burn values; return null return null; } @@ -299,7 +322,7 @@ async function fetchAuthoritativeBurnTotals(): Promise { }; } catch (error) { if (error instanceof Error && error.name !== 'AbortError') { - console.warn('[fetchAuthoritativeBurnTotals] Error:', error); + // Authoritative burn totals fetch error } return null; } @@ -325,20 +348,20 @@ async function fetchPrimaryNetworkFeesData(): Promise { // Fetch Metabase data and authoritative burn totals in parallel const [metabaseResponse, authoritativeTotals] = await Promise.all([ fetchWithTimeout(PRIMARY_NETWORK_FEES_URL, { - headers: { 'Accept': 'application/json' } + headers: { Accept: 'application/json' }, }), fetchAuthoritativeBurnTotals(), ]); if (!metabaseResponse.ok) { - console.warn(`[fetchPrimaryNetworkFeesData] Failed to fetch: ${metabaseResponse.status}`); + // Fees fetch failed; return empty return emptyResult; } const data = await metabaseResponse.json(); if (!data?.data?.rows || !Array.isArray(data.data.rows)) { - console.warn('[fetchPrimaryNetworkFeesData] Invalid data format'); + // Invalid fees data format; return empty return emptyResult; } @@ -397,7 +420,8 @@ async function fetchPrimaryNetworkFeesData(): Promise { }); // Apply offset correction to align cumulative values with authoritative source - const hasCumulativeData = result.cumulativeCChainFees.length > 0 && + const hasCumulativeData = + result.cumulativeCChainFees.length > 0 && result.cumulativePChainFees.length > 0 && result.cumulativeXChainFees.length > 0 && result.cumulativeValidatorFees.length > 0 && @@ -422,22 +446,15 @@ async function fetchPrimaryNetworkFeesData(): Promise { const offsetX = authXBurned - latestXChain; // Cumulative burn in Metabase includes validator fees, so the authoritative // total burn should also include the Metabase validator fees for a correct offset - const offsetBurn = (authTotalBurn + latestValidatorFees) - latestBurn; + const offsetBurn = authTotalBurn + latestValidatorFees - latestBurn; // Only apply if at least one offset is positive (Metabase is missing data) if (offsetC < 0 || offsetP < 0 || offsetX < 0) { - console.warn( - `[fetchPrimaryNetworkFeesData] Negative offset detected (Metabase > Authoritative): ` + - `C: ${offsetC.toFixed(2)}, P: ${offsetP.toFixed(2)}, X: ${offsetX.toFixed(2)}` - ); + // Negative offset detected (Metabase > Authoritative) - skip correction } if (offsetC > 0 || offsetP > 0 || offsetX > 0) { - console.warn( - `[fetchPrimaryNetworkFeesData] Applying burn offset correction: ` + - `C-Chain: +${offsetC.toFixed(2)}, P-Chain: +${offsetP.toFixed(2)}, ` + - `X-Chain: +${offsetX.toFixed(2)}, Total Burn: +${offsetBurn.toFixed(2)}` - ); + // Applying burn offset correction from authoritative totals if (offsetC > 0) { result.cumulativeCChainFees = result.cumulativeCChainFees.map((point) => ({ @@ -475,7 +492,7 @@ async function fetchPrimaryNetworkFeesData(): Promise { return result; } catch (error) { if (error instanceof Error && error.name !== 'AbortError') { - console.warn('[fetchPrimaryNetworkFeesData] Error:', error); + // Primary network fees fetch error } return emptyResult; } @@ -485,7 +502,7 @@ async function getICMData( chainId: string, timeRange: string, startTimestamp?: number, - endTimestamp?: number + endTimestamp?: number, ): Promise { try { let days: number; @@ -496,11 +513,16 @@ async function getICMData( } else { const getDaysFromTimeRange = (range: string): number => { switch (range) { - case '7d': return 7; - case '30d': return 30; - case '90d': return 90; - case 'all': return 730; - default: return 30; + case '7d': + return 7; + case '30d': + return 30; + case '90d': + return 90; + case 'all': + return 730; + default: + return 30; } }; days = getDaysFromTimeRange(timeRange); @@ -509,38 +531,71 @@ async function getICMData( let result = await getChainICMData(chainId, days); if (startTimestamp !== undefined && endTimestamp !== undefined) { - result = result.filter((item) => - item.timestamp >= startTimestamp && item.timestamp <= endTimestamp - ); + result = result.filter((item) => item.timestamp >= startTimestamp && item.timestamp <= endTimestamp); } return result; - } catch (error) { - console.warn(`[getICMData] Failed for chain ${chainId}:`, error); + } catch { + // ICM data failure; return empty return []; } } const ALL_METRICS = [ - 'activeAddresses', 'activeSenders', 'cumulativeAddresses', 'cumulativeDeployers', - 'txCount', 'cumulativeTxCount', 'cumulativeContracts', 'contracts', 'deployers', - 'gasUsed', 'avgGps', 'maxGps', 'avgTps', 'maxTps', 'avgGasPrice', 'maxGasPrice', - 'feesPaid', 'icmMessages', 'dailyRewards', 'cumulativeRewards', + 'activeAddresses', + 'activeSenders', + 'cumulativeAddresses', + 'cumulativeDeployers', + 'txCount', + 'cumulativeTxCount', + 'cumulativeContracts', + 'contracts', + 'deployers', + 'gasUsed', + 'avgGps', + 'maxGps', + 'avgTps', + 'maxTps', + 'avgGasPrice', + 'maxGasPrice', + 'feesPaid', + 'icmMessages', + 'dailyRewards', + 'cumulativeRewards', // Primary Network specific metrics - 'netCumulativeEmissions', 'netEmissionsDaily', 'cumulativeBurn', 'totalBurnDaily', - 'cChainFeesDaily', 'pChainFeesDaily', 'xChainFeesDaily', 'validatorFeesDaily', - 'cumulativeCChainFees', 'cumulativePChainFees', 'cumulativeXChainFees', 'cumulativeValidatorFees', + 'netCumulativeEmissions', + 'netEmissionsDaily', + 'cumulativeBurn', + 'totalBurnDaily', + 'cChainFeesDaily', + 'pChainFeesDaily', + 'xChainFeesDaily', + 'validatorFeesDaily', + 'cumulativeCChainFees', + 'cumulativePChainFees', + 'cumulativeXChainFees', + 'cumulativeValidatorFees', ] as const; // Metrics that are only available for the Primary Network -const PRIMARY_NETWORK_ONLY_METRICS = [ - 'dailyRewards', 'cumulativeRewards', - 'netCumulativeEmissions', 'netEmissionsDaily', 'cumulativeBurn', 'totalBurnDaily', - 'cChainFeesDaily', 'pChainFeesDaily', 'xChainFeesDaily', 'validatorFeesDaily', - 'cumulativeCChainFees', 'cumulativePChainFees', 'cumulativeXChainFees', 'cumulativeValidatorFees', +const _PRIMARY_NETWORK_ONLY_METRICS = [ + 'dailyRewards', + 'cumulativeRewards', + 'netCumulativeEmissions', + 'netEmissionsDaily', + 'cumulativeBurn', + 'totalBurnDaily', + 'cChainFeesDaily', + 'pChainFeesDaily', + 'xChainFeesDaily', + 'validatorFeesDaily', + 'cumulativeCChainFees', + 'cumulativePChainFees', + 'cumulativeXChainFees', + 'cumulativeValidatorFees', ] as const; -type MetricKey = typeof ALL_METRICS[number]; +type MetricKey = (typeof ALL_METRICS)[number]; async function fetchFreshDataInternal( chainId: string, @@ -548,68 +603,125 @@ async function fetchFreshDataInternal( requestedMetrics: MetricKey[], startTimestamp?: number, endTimestamp?: number, - isSpecificMetricsMode: boolean = false + isSpecificMetricsMode: boolean = false, ): Promise { try { - const config = STATS_CONFIG.TIME_RANGES[timeRange as keyof typeof STATS_CONFIG.TIME_RANGES] || STATS_CONFIG.TIME_RANGES['30d']; + const config = + STATS_CONFIG.TIME_RANGES[timeRange as keyof typeof STATS_CONFIG.TIME_RANGES] || STATS_CONFIG.TIME_RANGES['30d']; const { pageSize, fetchAllPages } = config; - + const fetchPromises: { [key: string]: Promise } = {}; - + // activeAddresses with variants if (requestedMetrics.includes('activeAddresses')) { - fetchPromises['dailyActiveAddresses'] = getActiveAddressesData(chainId, timeRange, 'day', startTimestamp, endTimestamp, pageSize, fetchAllPages); + fetchPromises['dailyActiveAddresses'] = getActiveAddressesData( + chainId, + timeRange, + 'day', + startTimestamp, + endTimestamp, + pageSize, + fetchAllPages, + ); if (!isSpecificMetricsMode) { - fetchPromises['weeklyActiveAddresses'] = getActiveAddressesData(chainId, timeRange, 'week', startTimestamp, endTimestamp, pageSize, fetchAllPages); - fetchPromises['monthlyActiveAddresses'] = getActiveAddressesData(chainId, timeRange, 'month', startTimestamp, endTimestamp, pageSize, fetchAllPages); + fetchPromises['weeklyActiveAddresses'] = getActiveAddressesData( + chainId, + timeRange, + 'week', + startTimestamp, + endTimestamp, + pageSize, + fetchAllPages, + ); + fetchPromises['monthlyActiveAddresses'] = getActiveAddressesData( + chainId, + timeRange, + 'month', + startTimestamp, + endTimestamp, + pageSize, + fetchAllPages, + ); } } - + // Standard metrics const standardMetrics: MetricKey[] = [ - 'activeSenders', 'cumulativeAddresses', 'cumulativeDeployers', 'txCount', - 'cumulativeTxCount', 'cumulativeContracts', 'contracts', 'deployers', - 'gasUsed', 'avgGps', 'maxGps', 'avgTps', 'maxTps', 'avgGasPrice', - 'maxGasPrice', 'feesPaid' + 'activeSenders', + 'cumulativeAddresses', + 'cumulativeDeployers', + 'txCount', + 'cumulativeTxCount', + 'cumulativeContracts', + 'contracts', + 'deployers', + 'gasUsed', + 'avgGps', + 'maxGps', + 'avgTps', + 'maxTps', + 'avgGasPrice', + 'maxGasPrice', + 'feesPaid', ]; - + for (const metric of standardMetrics) { if (requestedMetrics.includes(metric)) { - fetchPromises[metric] = getTimeSeriesData(metric, chainId, timeRange, startTimestamp, endTimestamp, pageSize, fetchAllPages); + fetchPromises[metric] = getTimeSeriesData( + metric, + chainId, + timeRange, + startTimestamp, + endTimestamp, + pageSize, + fetchAllPages, + ); } } - + // ICM messages if (requestedMetrics.includes('icmMessages')) { fetchPromises['icmMessages'] = getICMData(chainId, timeRange, startTimestamp, endTimestamp); } - + // Primary Network data (available for chainId "43114" or "primary") const isPrimaryNetwork = chainId === '43114' || chainId === 'primary'; let rewardsData: RewardsData | null = null; let primaryNetworkFeesData: PrimaryNetworkFeesData | null = null; - - if (isPrimaryNetwork && (requestedMetrics.includes('dailyRewards') || requestedMetrics.includes('cumulativeRewards'))) { + + if ( + isPrimaryNetwork && + (requestedMetrics.includes('dailyRewards') || requestedMetrics.includes('cumulativeRewards')) + ) { rewardsData = await fetchRewardsData(); } - + // Check if any Primary Network fees metrics are requested const primaryNetworkFeesMetrics: MetricKey[] = [ - 'netCumulativeEmissions', 'netEmissionsDaily', 'cumulativeBurn', 'totalBurnDaily', - 'cChainFeesDaily', 'pChainFeesDaily', 'xChainFeesDaily', 'validatorFeesDaily', - 'cumulativeCChainFees', 'cumulativePChainFees', 'cumulativeXChainFees', 'cumulativeValidatorFees', + 'netCumulativeEmissions', + 'netEmissionsDaily', + 'cumulativeBurn', + 'totalBurnDaily', + 'cChainFeesDaily', + 'pChainFeesDaily', + 'xChainFeesDaily', + 'validatorFeesDaily', + 'cumulativeCChainFees', + 'cumulativePChainFees', + 'cumulativeXChainFees', + 'cumulativeValidatorFees', ]; - const needsPrimaryNetworkFees = isPrimaryNetwork && - requestedMetrics.some(m => primaryNetworkFeesMetrics.includes(m)); - + const needsPrimaryNetworkFees = + isPrimaryNetwork && requestedMetrics.some((m) => primaryNetworkFeesMetrics.includes(m)); + if (needsPrimaryNetworkFees) { primaryNetworkFeesData = await fetchPrimaryNetworkFeesData(); } - + // Fetch all in parallel const fetchKeys = Object.keys(fetchPromises); const fetchResults = await Promise.all(Object.values(fetchPromises)); - + const results: { [key: string]: TimeSeriesDataPoint[] | ICMDataPoint[] } = {}; fetchKeys.forEach((key, index) => { results[key] = fetchResults[index]; @@ -617,9 +729,9 @@ async function fetchFreshDataInternal( // Build metrics object const metrics: Partial & { activeAddresses?: any } = { - last_updated: Date.now() + last_updated: Date.now(), }; - + if (requestedMetrics.includes('activeAddresses')) { if (isSpecificMetricsMode) { metrics.activeAddresses = createTimeSeriesMetric(results['dailyActiveAddresses'] as TimeSeriesDataPoint[]); @@ -631,7 +743,7 @@ async function fetchFreshDataInternal( }; } } - + // Map standard metrics const metricMappings: { key: MetricKey; resultKey: string }[] = [ { key: 'activeSenders', resultKey: 'activeSenders' }, @@ -651,17 +763,17 @@ async function fetchFreshDataInternal( { key: 'maxGasPrice', resultKey: 'maxGasPrice' }, { key: 'feesPaid', resultKey: 'feesPaid' }, ]; - + for (const mapping of metricMappings) { if (requestedMetrics.includes(mapping.key) && results[mapping.resultKey]) { (metrics as any)[mapping.key] = createTimeSeriesMetric(results[mapping.resultKey] as TimeSeriesDataPoint[]); } } - + if (requestedMetrics.includes('icmMessages') && results['icmMessages']) { metrics.icmMessages = createICMMetric(results['icmMessages'] as ICMDataPoint[]); } - + // Add rewards data (only for Primary Network) if (rewardsData) { if (requestedMetrics.includes('dailyRewards') && rewardsData.daily.length > 0) { @@ -671,10 +783,13 @@ async function fetchFreshDataInternal( metrics.cumulativeRewards = createTimeSeriesMetric(rewardsData.cumulative); } } - + // Add Primary Network fees data if (primaryNetworkFeesData) { - if (requestedMetrics.includes('netCumulativeEmissions') && primaryNetworkFeesData.netCumulativeEmissions.length > 0) { + if ( + requestedMetrics.includes('netCumulativeEmissions') && + primaryNetworkFeesData.netCumulativeEmissions.length > 0 + ) { metrics.netCumulativeEmissions = createTimeSeriesMetric(primaryNetworkFeesData.netCumulativeEmissions); } if (requestedMetrics.includes('netEmissionsDaily') && primaryNetworkFeesData.netEmissionsDaily.length > 0) { @@ -707,33 +822,36 @@ async function fetchFreshDataInternal( if (requestedMetrics.includes('cumulativeXChainFees') && primaryNetworkFeesData.cumulativeXChainFees.length > 0) { metrics.cumulativeXChainFees = createTimeSeriesMetric(primaryNetworkFeesData.cumulativeXChainFees); } - if (requestedMetrics.includes('cumulativeValidatorFees') && primaryNetworkFeesData.cumulativeValidatorFees.length > 0) { + if ( + requestedMetrics.includes('cumulativeValidatorFees') && + primaryNetworkFeesData.cumulativeValidatorFees.length > 0 + ) { metrics.cumulativeValidatorFees = createTimeSeriesMetric(primaryNetworkFeesData.cumulativeValidatorFees); } } return metrics as ChainMetrics; - } catch (error) { - console.error(`[fetchFreshData] Failed for chain ${chainId}:`, error); + } catch { + // Fresh data fetch failed; return null return null; } } function createResponse( data: ChainMetrics | Partial | { error: string; details?: string }, - meta: { - source: string; + meta: { + source: string; chainId?: string; - timeRange?: string; - cacheAge?: number; - fetchTime?: number; + timeRange?: string; + cacheAge?: number; + fetchTime?: number; metrics?: string; }, - status = 200 + status = 200, ) { - const headers: Record = { - 'Cache-Control': CACHE_CONTROL_HEADER, - 'X-Data-Source': meta.source + const headers: Record = { + 'Cache-Control': CACHE_CONTROL_HEADER, + 'X-Data-Source': meta.source, }; if (meta.chainId) headers['X-Chain-Id'] = meta.chainId; if (meta.timeRange) headers['X-Time-Range'] = meta.timeRange; @@ -743,186 +861,173 @@ function createResponse( return NextResponse.json(data, { status, headers }); } -export async function GET( - request: Request, - { params }: { params: Promise<{ chainId: string }> } -) { - try { - const { searchParams } = new URL(request.url); - const timeRange = searchParams.get('timeRange') || '30d'; - const startTimestampParam = searchParams.get('startTimestamp'); - const endTimestampParam = searchParams.get('endTimestamp'); - const metricsParam = searchParams.get('metrics'); - const resolvedParams = await params; - const chainId = resolvedParams.chainId; - - if (!chainId) { - return createResponse({ error: 'Chain ID is required' }, { source: 'error' }, 400); - } +export const GET = withApi(async (req, { params }) => { + const timeRange = req.nextUrl.searchParams.get('timeRange') || '30d'; + const startTimestampParam = req.nextUrl.searchParams.get('startTimestamp'); + const endTimestampParam = req.nextUrl.searchParams.get('endTimestamp'); + const metricsParam = req.nextUrl.searchParams.get('metrics'); + const chainId = params.chainId; - // Parse timestamps - const startTimestamp = startTimestampParam ? parseInt(startTimestampParam, 10) : undefined; - const endTimestamp = endTimestampParam ? parseInt(endTimestampParam, 10) : undefined; - - // Validate timestamps - if (startTimestamp !== undefined && isNaN(startTimestamp)) { - return createResponse({ error: 'Invalid startTimestamp parameter' }, { source: 'error' }, 400); - } - if (endTimestamp !== undefined && isNaN(endTimestamp)) { - return createResponse({ error: 'Invalid endTimestamp parameter' }, { source: 'error' }, 400); - } - if (startTimestamp !== undefined && endTimestamp !== undefined && startTimestamp > endTimestamp) { - return createResponse({ error: 'startTimestamp must be less than or equal to endTimestamp' }, { source: 'error' }, 400); - } + if (!chainId) { + return createResponse({ error: 'Chain ID is required' }, { source: 'error' }, 400); + } - // Parse requested metrics - const requestedMetrics: MetricKey[] = metricsParam - ? metricsParam.split(',').filter((m): m is MetricKey => ALL_METRICS.includes(m as MetricKey)) - : [...ALL_METRICS]; - - if (metricsParam && requestedMetrics.length === 0) { - return createResponse( - { error: 'Invalid metrics parameter. Valid metrics: ' + ALL_METRICS.join(', ') }, - { source: 'error' }, - 400 - ); - } + const startTimestamp = startTimestampParam ? parseInt(startTimestampParam, 10) : undefined; + const endTimestamp = endTimestampParam ? parseInt(endTimestampParam, 10) : undefined; - const isSpecificMetricsMode = metricsParam !== null; - const metricsKey = requestedMetrics.sort().join(','); - const cacheKey = startTimestamp !== undefined && endTimestamp !== undefined + if (startTimestamp !== undefined && isNaN(startTimestamp)) { + return createResponse({ error: 'Invalid startTimestamp parameter' }, { source: 'error' }, 400); + } + if (endTimestamp !== undefined && isNaN(endTimestamp)) { + return createResponse({ error: 'Invalid endTimestamp parameter' }, { source: 'error' }, 400); + } + if (startTimestamp !== undefined && endTimestamp !== undefined && startTimestamp > endTimestamp) { + return createResponse( + { error: 'startTimestamp must be less than or equal to endTimestamp' }, + { source: 'error' }, + 400, + ); + } + + const requestedMetrics: MetricKey[] = metricsParam + ? metricsParam.split(',').filter((m): m is MetricKey => ALL_METRICS.includes(m as MetricKey)) + : [...ALL_METRICS]; + + if (metricsParam && requestedMetrics.length === 0) { + return createResponse( + { error: 'Invalid metrics parameter. Valid metrics: ' + ALL_METRICS.join(', ') }, + { source: 'error' }, + 400, + ); + } + + const isSpecificMetricsMode = metricsParam !== null; + const metricsKey = requestedMetrics.sort().join(','); + const cacheKey = + startTimestamp !== undefined && endTimestamp !== undefined ? `${chainId}-${startTimestamp}-${endTimestamp}-${metricsKey}` : `${chainId}-${timeRange}-${metricsKey}`; - - if (searchParams.get('clearCache') === 'true') { - cachedData.clear(); - revalidatingKeys.clear(); - } - - const cached = cachedData.get(cacheKey); - const cacheAge = cached ? Date.now() - cached.timestamp : Infinity; - const isCacheValid = cacheAge < STATS_CONFIG.CACHE.LONG_DURATION; - const isCacheStale = cached && !isCacheValid; - - // Stale-while-revalidate: serve stale data immediately, refresh in background - if (isCacheStale && !revalidatingKeys.has(cacheKey)) { - revalidatingKeys.add(cacheKey); - - // Background refresh - (async () => { - try { - const freshData = await fetchFreshDataInternal( - chainId, timeRange, requestedMetrics, - startTimestamp, endTimestamp, isSpecificMetricsMode - ); - if (freshData) { - cachedData.set(cacheKey, { - data: freshData, - timestamp: Date.now(), - icmTimeRange: timeRange - }); - } - } finally { - revalidatingKeys.delete(cacheKey); - } - })(); - - console.log(`[GET /api/chain-stats/${chainId}] TimeRange: ${timeRange}, Source: stale-while-revalidate`); - return createResponse(cached.data, { - source: 'stale-while-revalidate', - chainId, - timeRange, - cacheAge, - metrics: metricsKey - }); - } - - // Return valid cache - if (isCacheValid && cached) { - // Refresh ICM if timeRange changed - if (requestedMetrics.includes('icmMessages') && - startTimestamp === undefined && - endTimestamp === undefined && - cached.icmTimeRange !== timeRange) { - try { - const newICMData = await getICMData(chainId, timeRange); - cached.data.icmMessages = createICMMetric(newICMData); - cached.icmTimeRange = timeRange; - cachedData.set(cacheKey, cached); - } catch (error) { - console.warn('[GET] Failed to refresh ICM data:', error); + + if (req.nextUrl.searchParams.get('clearCache') === 'true') { + cachedData.clear(); + revalidatingKeys.clear(); + } + + const cached = cachedData.get(cacheKey); + const cacheAge = cached ? Date.now() - cached.timestamp : Infinity; + const isCacheValid = cacheAge < STATS_CONFIG.CACHE.LONG_DURATION; + const isCacheStale = cached && !isCacheValid; + + if (isCacheStale && !revalidatingKeys.has(cacheKey)) { + revalidatingKeys.add(cacheKey); + + (async () => { + try { + const freshData = await fetchFreshDataInternal( + chainId, + timeRange, + requestedMetrics, + startTimestamp, + endTimestamp, + isSpecificMetricsMode, + ); + if (freshData) { + cachedData.set(cacheKey, { + data: freshData, + timestamp: Date.now(), + icmTimeRange: timeRange, + }); } + } finally { + revalidatingKeys.delete(cacheKey); } - - console.log(`[GET /api/chain-stats/${chainId}] TimeRange: ${timeRange}, Source: cache`); - return createResponse(cached.data, { - source: 'cache', - chainId, - timeRange, - cacheAge, - metrics: metricsKey - }); - } - - // Deduplicate pending requests - const pendingKey = cacheKey; - let pendingPromise = pendingRequests.get(pendingKey); - - if (!pendingPromise) { - pendingPromise = fetchFreshDataInternal( - chainId, timeRange, requestedMetrics, - startTimestamp, endTimestamp, isSpecificMetricsMode - ); - pendingRequests.set(pendingKey, pendingPromise); - pendingPromise.finally(() => pendingRequests.delete(pendingKey)); - } - - const startTime = Date.now(); - const freshData = await pendingPromise; - - if (!freshData) { - // Fallback to any available cached data - const fallbackCacheKey = `${chainId}-30d-${metricsKey}`; - const fallbackCached = cachedData.get(fallbackCacheKey); - if (fallbackCached) { - console.log(`[GET /api/chain-stats/${chainId}] TimeRange: 30d, Source: fallback-cache`); - return createResponse(fallbackCached.data, { - source: 'fallback-cache', - chainId, - timeRange: '30d', - cacheAge: Date.now() - fallbackCached.timestamp, - metrics: metricsKey - }, 206); + })(); + + return createResponse(cached.data, { + source: 'stale-while-revalidate', + chainId, + timeRange, + cacheAge, + metrics: metricsKey, + }); + } + + if (isCacheValid && cached) { + if ( + requestedMetrics.includes('icmMessages') && + startTimestamp === undefined && + endTimestamp === undefined && + cached.icmTimeRange !== timeRange + ) { + try { + const newICMData = await getICMData(chainId, timeRange); + cached.data.icmMessages = createICMMetric(newICMData); + cached.icmTimeRange = timeRange; + cachedData.set(cacheKey, cached); + } catch { + // ICM refresh is best-effort } - console.log(`[GET /api/chain-stats/${chainId}] TimeRange: ${timeRange}, Source: error (no data)`); - return createResponse({ error: 'Failed to fetch chain metrics' }, { source: 'error', chainId }, 500); } - - // Cache fresh data - cachedData.set(cacheKey, { - data: freshData, - timestamp: Date.now(), - icmTimeRange: timeRange - }); - - const fetchTime = Date.now() - startTime; - console.log(`[GET /api/chain-stats/${chainId}] TimeRange: ${timeRange}, Source: fresh, fetchTime: ${fetchTime}ms`); - return createResponse(freshData, { - source: 'fresh', + return createResponse(cached.data, { + source: 'cache', chainId, - timeRange, - fetchTime, - metrics: metricsKey + timeRange, + cacheAge, + metrics: metricsKey, }); - } catch (error) { - const resolvedParams = await params; - const chainId = resolvedParams.chainId; - console.error(`[GET /api/chain-stats/${chainId}] Unhandled error:`, error); - return createResponse( - { error: 'Failed to fetch chain metrics', details: error instanceof Error ? error.message : 'Unknown error' }, - { source: 'error', chainId }, - 500 + } + + const pendingKey = cacheKey; + let pendingPromise = pendingRequests.get(pendingKey); + + if (!pendingPromise) { + pendingPromise = fetchFreshDataInternal( + chainId, + timeRange, + requestedMetrics, + startTimestamp, + endTimestamp, + isSpecificMetricsMode, ); + pendingRequests.set(pendingKey, pendingPromise); + pendingPromise.finally(() => pendingRequests.delete(pendingKey)); } -} + + const startTime = Date.now(); + const freshData = await pendingPromise; + + if (!freshData) { + const fallbackCacheKey = `${chainId}-30d-${metricsKey}`; + const fallbackCached = cachedData.get(fallbackCacheKey); + if (fallbackCached) { + return createResponse( + fallbackCached.data, + { + source: 'fallback-cache', + chainId, + timeRange: '30d', + cacheAge: Date.now() - fallbackCached.timestamp, + metrics: metricsKey, + }, + 206, + ); + } + return createResponse({ error: 'Failed to fetch chain metrics' }, { source: 'error', chainId }, 500); + } + + cachedData.set(cacheKey, { + data: freshData, + timestamp: Date.now(), + icmTimeRange: timeRange, + }); + + const fetchTime = Date.now() - startTime; + return createResponse(freshData, { + source: 'fresh', + chainId, + timeRange, + fetchTime, + metrics: metricsKey, + }); +}); diff --git a/app/api/chain-validators/[subnetId]/route.ts b/app/api/chain-validators/[subnetId]/route.ts index 781e359a3f6..666a6aeb13d 100644 --- a/app/api/chain-validators/[subnetId]/route.ts +++ b/app/api/chain-validators/[subnetId]/route.ts @@ -1,14 +1,16 @@ -import { NextResponse } from "next/server"; -import { Avalanche } from "@avalanche-sdk/chainkit"; -import { - FUJI_VALIDATOR_DISCOVERY_URL, - MAINNET_VALIDATOR_DISCOVERY_URL, -} from "@/constants/validator-discovery"; +import { z } from 'zod'; +import { Avalanche } from '@avalanche-sdk/chainkit'; +import { withApi, ValidationError, successResponse } from '@/lib/api'; +import { FUJI_VALIDATOR_DISCOVERY_URL, MAINNET_VALIDATOR_DISCOVERY_URL } from '@/constants/validator-discovery'; const PAGE_SIZE = 100; const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes const FETCH_TIMEOUT = 25000; -const VERSION_FETCH_TIMEOUT = 10000; +const VERSION_FETCH_TIMEOUT = 10_000; + +const subnetIdSchema = z.object({ + subnetId: z.string().min(1, 'Subnet ID is required').max(60), +}); interface ValidatorData { nodeId: string; @@ -38,28 +40,27 @@ interface ValidatorVersion { version: string; } -const cacheStore = new Map(); -const versionCacheStore = new Map; timestamp: number}>(); +const cacheStore = new Map(); +const versionCacheStore = new Map; timestamp: number }>(); -async function fetchValidatorVersions(network: "mainnet" | "fuji" = "mainnet"): Promise> { +async function fetchValidatorVersions(network: 'mainnet' | 'fuji' = 'mainnet'): Promise> { const now = Date.now(); const cached = versionCacheStore.get(network); - - if (cached && (now - cached.timestamp) < CACHE_DURATION) { + + if (cached && now - cached.timestamp < CACHE_DURATION) { return cached.data; } try { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), VERSION_FETCH_TIMEOUT); - const discoveryUrl = - network === "fuji" ? FUJI_VALIDATOR_DISCOVERY_URL : MAINNET_VALIDATOR_DISCOVERY_URL; + const discoveryUrl = network === 'fuji' ? FUJI_VALIDATOR_DISCOVERY_URL : MAINNET_VALIDATOR_DISCOVERY_URL; const response = await fetch(discoveryUrl, { signal: controller.signal, - headers: { 'Accept': 'application/json' }, + headers: { Accept: 'application/json' }, }); - + clearTimeout(timeoutId); if (!response.ok) { @@ -70,109 +71,103 @@ async function fetchValidatorVersions(network: "mainnet" | "fuji" = "mainnet"): const versionMap = new Map(); for (const validator of data) { - versionMap.set(validator.nodeId, validator.version?.replace("avalanchego/", "") || "Unknown"); + versionMap.set(validator.nodeId, validator.version?.replace('avalanchego/', '') || 'Unknown'); } versionCacheStore.set(network, { data: versionMap, timestamp: now }); return versionMap; - } catch (error) { - console.error('Error fetching validator versions:', error); + } catch { return cached?.data || new Map(); } } -async function fetchAllValidators(subnetId: string, versionMap: Map, network: "mainnet" | "fuji" = "mainnet"): Promise { +async function fetchAllValidators( + subnetId: string, + versionMap: Map, + network: 'mainnet' | 'fuji' = 'mainnet', +): Promise { const avalanche = new Avalanche({ network }); const validators: ValidatorData[] = []; - try { - const isPrimaryNetwork = subnetId === "11111111111111111111111111111111LpoYY"; - - let result; - if (isPrimaryNetwork) { - // Use listValidators for Primary Network - result = await avalanche.data.primaryNetwork.listValidators({ - pageSize: PAGE_SIZE, - validationStatus: "active", - subnetId: subnetId, - network, - }); - } else { - // Use listL1Validators for L1 subnets - result = await avalanche.data.primaryNetwork.listL1Validators({ - pageSize: PAGE_SIZE, - subnetId: subnetId, - network, - includeInactiveL1Validators: false, - }); + const isPrimaryNetwork = subnetId === '11111111111111111111111111111111LpoYY'; + + let result; + if (isPrimaryNetwork) { + result = await avalanche.data.primaryNetwork.listValidators({ + pageSize: PAGE_SIZE, + validationStatus: 'active', + subnetId: subnetId, + network, + }); + } else { + result = await avalanche.data.primaryNetwork.listL1Validators({ + pageSize: PAGE_SIZE, + subnetId: subnetId, + network, + includeInactiveL1Validators: false, + }); + } + + let pageCount = 0; + const maxPages = 50; + + for await (const page of result) { + pageCount++; + + let pageData: any[] = page.result?.validators || []; + + // For L1 validators, keep zero balances so critical alerts can fire. + if (!isPrimaryNetwork) { + pageData = pageData.filter((v: any) => Number.isFinite(v.remainingBalance) && v.remainingBalance >= 0); } - let pageCount = 0; - const maxPages = 50; - - for await (const page of result) { - pageCount++; - - // Handle different response structures - // Both Primary Network and L1 validators use page.result.validators - let pageData: any[] = page.result?.validators || []; - - // For L1 validators, keep zero balances so critical alerts can fire. - if (!isPrimaryNetwork) { - pageData = pageData.filter((v: any) => Number.isFinite(v.remainingBalance) && v.remainingBalance >= 0); - } - - if (!Array.isArray(pageData)) { - console.warn(`Page ${pageCount}: pageData is not an array`, typeof pageData); - console.warn(`Available keys:`, Object.keys(page)); - continue; + if (!Array.isArray(pageData)) { + continue; + } + + const pageValidators = pageData.map((v: any) => { + const version = versionMap.get(v.nodeId) || 'Unknown'; + + if (isPrimaryNetwork) { + return { + nodeId: v.nodeId, + amountStaked: v.amountStaked || '0', + delegationFee: v.delegationFee?.toString() || '0', + validationStatus: v.validationStatus || 'active', + delegatorCount: v.delegatorCount || 0, + amountDelegated: v.amountDelegated || '0', + version, + }; + } else { + return { + nodeId: v.nodeId, + amountStaked: v.weight?.toString() || '0', + delegationFee: '0', + validationStatus: 'active', + delegatorCount: 0, + amountDelegated: '0', + validationId: v.validationId, + weight: v.weight, + remainingBalance: v.remainingBalance, + creationTimestamp: v.creationTimestamp, + blsCredentials: v.blsCredentials, + remainingBalanceOwner: v.remainingBalanceOwner, + deactivationOwner: v.deactivationOwner, + version, + }; } - - const pageValidators = pageData.map((v: any) => { - const version = versionMap.get(v.nodeId) || "Unknown"; - - if (isPrimaryNetwork) { - // Primary Network validator structure - return { - nodeId: v.nodeId, - amountStaked: v.amountStaked || "0", - delegationFee: v.delegationFee?.toString() || "0", - validationStatus: v.validationStatus || "active", - delegatorCount: v.delegatorCount || 0, - amountDelegated: v.amountDelegated || "0", - version, - }; - } else { - // L1 validator structure - using weight as stake - return { - nodeId: v.nodeId, - amountStaked: v.weight?.toString() || "0", - delegationFee: "0", // L1 validators don't have delegation fees - validationStatus: "active", - delegatorCount: 0, // L1 validators don't have delegators in the same way - amountDelegated: "0", - validationId: v.validationId, - weight: v.weight, - remainingBalance: v.remainingBalance, - creationTimestamp: v.creationTimestamp, - blsCredentials: v.blsCredentials, - remainingBalanceOwner: v.remainingBalanceOwner, - deactivationOwner: v.deactivationOwner, - version, - }; - } - }); - - validators.push(...pageValidators); - if (pageCount >= maxPages) { break; } - if (pageValidators.length < PAGE_SIZE) { break; } + }); + + validators.push(...pageValidators); + if (pageCount >= maxPages) { + break; + } + if (pageValidators.length < PAGE_SIZE) { + break; } - - return validators; - } catch (error: any) { - console.error('Error fetching validators for subnet:', subnetId, error); - throw error; } + + return validators; } function calculateVersionBreakdown(validators: ValidatorData[]) { @@ -180,106 +175,81 @@ function calculateVersionBreakdown(validators: ValidatorData[]) { let totalStake = 0n; for (const validator of validators) { - const version = validator.version || "Unknown"; + const version = validator.version || 'Unknown'; const stake = BigInt(validator.amountStaked || validator.weight || 0); - + if (!breakdown[version]) { breakdown[version] = { nodes: 0, stake: 0n }; } - + breakdown[version].nodes += 1; breakdown[version].stake += stake; totalStake += stake; } - // Convert to serializable format - const result: Record = {}; + const byClientVersion: Record = {}; for (const [version, data] of Object.entries(breakdown)) { - result[version] = { + byClientVersion[version] = { nodes: data.nodes, stakeString: data.stake.toString(), }; } return { - byClientVersion: result, + byClientVersion, totalStakeString: totalStake.toString(), }; } -export async function GET( - _request: Request, - { params }: { params: Promise<{ subnetId: string }> } -) { - try { - const { subnetId } = await params; - const url = new URL(_request.url); - const network: "mainnet" | "fuji" = url.searchParams.get('network') === 'testnet' || url.searchParams.get('network') === 'fuji' ? 'fuji' : 'mainnet'; - - if (!subnetId) { - return NextResponse.json( - { error: "Subnet ID is required" }, - { status: 400 } - ); - } +export const GET = withApi(async (_request, { params }) => { + const parsed = subnetIdSchema.safeParse(params); + if (!parsed.success) { + throw new ValidationError(parsed.error.issues.map((i) => i.message).join('; ')); + } + const { subnetId } = parsed.data; - const cacheKey = `${network}:${subnetId}`; - const now = Date.now(); - const cachedData = cacheStore.get(cacheKey); - - if (cachedData && (now - cachedData.timestamp) < CACHE_DURATION) { - return NextResponse.json( - { - validators: cachedData.data, - totalCount: cachedData.data.length, - subnetId, - cached: true, - versionBreakdown: cachedData.versionBreakdown, - }, - { - headers: { - 'Cache-Control': 'public, max-age=300, stale-while-revalidate=600', - } - } - ); - } + const url = new URL(_request.url); + const network: 'mainnet' | 'fuji' = + url.searchParams.get('network') === 'testnet' || url.searchParams.get('network') === 'fuji' ? 'fuji' : 'mainnet'; - const versionMap = await fetchValidatorVersions(network); - - const validators = await Promise.race([ - fetchAllValidators(subnetId, versionMap, network), - new Promise((_, reject) => - setTimeout(() => reject(new Error('Request timeout')), FETCH_TIMEOUT) - ) - ]); - - const versionBreakdown = calculateVersionBreakdown(validators); - - cacheStore.set(cacheKey, { - data: validators, - timestamp: now, - versionBreakdown, + const cacheKey = `${network}:${subnetId}`; + const now = Date.now(); + const cachedData = cacheStore.get(cacheKey); + + if (cachedData && now - cachedData.timestamp < CACHE_DURATION) { + const resp = successResponse({ + validators: cachedData.data, + totalCount: cachedData.data.length, + subnetId, + cached: true, + versionBreakdown: cachedData.versionBreakdown, }); - - return NextResponse.json( - { - validators, - totalCount: validators.length, - subnetId, - cached: false, - versionBreakdown, - }, - { - headers: { - 'Cache-Control': 'public, max-age=300, stale-while-revalidate=600', - } - } - ); - } catch (error: any) { - console.error('Error fetching validators:', error); - return NextResponse.json( - { error: error?.message || 'Failed to fetch validators' }, - { status: 500 } - ); + resp.headers.set('Cache-Control', 'public, max-age=300, stale-while-revalidate=600'); + return resp; } -} + + const versionMap = await fetchValidatorVersions(network); + + const validators = await Promise.race([ + fetchAllValidators(subnetId, versionMap, network), + new Promise((_, reject) => setTimeout(() => reject(new Error('Request timeout')), FETCH_TIMEOUT)), + ]); + + const versionBreakdown = calculateVersionBreakdown(validators); + + cacheStore.set(cacheKey, { + data: validators, + timestamp: now, + versionBreakdown, + }); + + const resp = successResponse({ + validators, + totalCount: validators.length, + subnetId, + cached: false, + versionBreakdown, + }); + resp.headers.set('Cache-Control', 'public, max-age=300, stale-while-revalidate=600'); + return resp; +}); diff --git a/app/api/chat-history/[id]/route.ts b/app/api/chat-history/[id]/route.ts index 19b5ac042b0..37782023346 100644 --- a/app/api/chat-history/[id]/route.ts +++ b/app/api/chat-history/[id]/route.ts @@ -1,86 +1,45 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { getAuthSession } from '@/lib/auth/authSession'; +import type { NextRequest } from 'next/server'; +import { z } from 'zod'; +import { withApi } from '@/lib/api/with-api'; +import { successResponse, noContentResponse } from '@/lib/api/response'; +import { assertOwnership } from '@/lib/api/ownership'; +import { validateBody } from '@/lib/api/validate'; import { prisma } from '@/prisma/prisma'; -// PATCH /api/chat-history/[id] - Rename a conversation -export async function PATCH( - req: NextRequest, - { params }: { params: Promise<{ id: string }> } -) { - try { - const session = await getAuthSession(); - - if (!session?.user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - } - - const { id } = await params; - const body = await req.json(); - const { title } = body; +const patchSchema = z.object({ + title: z + .string() + .min(1, 'Title is required') + .transform((v) => v.trim()), +}); - if (!title || typeof title !== 'string' || title.trim().length === 0) { - return NextResponse.json({ error: 'Title is required' }, { status: 400 }); - } - - // Verify ownership before updating - const conversation = await prisma.chatConversation.findFirst({ - where: { id, user_id: session.user.id }, - }); +// PATCH /api/chat-history/[id] - Rename a conversation +export const PATCH = withApi( + async (req: NextRequest, { session, params }) => { + const { title } = await validateBody(req, patchSchema); - if (!conversation) { - return NextResponse.json( - { error: 'Conversation not found' }, - { status: 404 } - ); - } + await assertOwnership(prisma.chatConversation, params.id, session.user.id); - // Update the title const updated = await prisma.chatConversation.update({ - where: { id }, - data: { title: title.trim() }, + where: { id: params.id }, + data: { title }, }); - return NextResponse.json(updated); - } catch (error) { - console.error('Error renaming chat conversation:', error); - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); - } -} + return successResponse(updated); + }, + { auth: true }, +); // DELETE /api/chat-history/[id] - Delete a conversation -export async function DELETE( - req: NextRequest, - { params }: { params: Promise<{ id: string }> } -) { - try { - const session = await getAuthSession(); - - if (!session?.user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - } - - const { id } = await params; - - // Verify ownership before deleting - const conversation = await prisma.chatConversation.findFirst({ - where: { id, user_id: session.user.id }, - }); - - if (!conversation) { - return NextResponse.json( - { error: 'Conversation not found' }, - { status: 404 } - ); - } +export const DELETE = withApi( + async (_req, { session, params }) => { + await assertOwnership(prisma.chatConversation, params.id, session.user.id); - // Delete conversation (messages cascade automatically) await prisma.chatConversation.delete({ - where: { id }, + where: { id: params.id }, }); - return NextResponse.json({ success: true }); - } catch (error) { - console.error('Error deleting chat conversation:', error); - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); - } -} + return noContentResponse(); + }, + { auth: true }, +); diff --git a/app/api/chat-history/[id]/share/route.ts b/app/api/chat-history/[id]/share/route.ts index cb31b248f5f..2f21ecdb181 100644 --- a/app/api/chat-history/[id]/share/route.ts +++ b/app/api/chat-history/[id]/share/route.ts @@ -1,55 +1,39 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { getAuthSession } from '@/lib/auth/authSession'; -import { prisma } from '@/prisma/prisma'; +import type { NextRequest } from 'next/server'; +import { z } from 'zod'; import * as crypto from 'crypto'; +import { withApi } from '@/lib/api/with-api'; +import { successResponse, noContentResponse } from '@/lib/api/response'; +import { assertOwnership } from '@/lib/api/ownership'; +import { validateBody } from '@/lib/api/validate'; +import { prisma } from '@/prisma/prisma'; + +const shareSchema = z.object({ + expiresInDays: z.number().int().min(1).max(365).default(7), +}); -// Generate a cryptographically secure, URL-safe token function generateShareToken(): string { - // 18 bytes = 24 characters in base64url encoding return crypto.randomBytes(18).toString('base64url'); } // POST /api/chat-history/[id]/share - Enable sharing for a conversation -export async function POST( - req: NextRequest, - { params }: { params: Promise<{ id: string }> } -) { - try { - const session = await getAuthSession(); - - if (!session?.user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - } - - const { id } = await params; +export const POST = withApi( + async (req: NextRequest, { session, params }) => { + const { expiresInDays } = await validateBody(req, shareSchema); + + const conversation = await assertOwnership<{ + id: string; + is_shared: boolean; + share_token: string | null; + shared_at: Date | null; + share_expires_at: Date | null; + view_count: number; + }>(prisma.chatConversation, params.id, session.user.id); - // Parse optional expiration from body - let expiresInDays = 7; // Default: 7 days - try { - const body = await req.json(); - if (body.expiresInDays && typeof body.expiresInDays === 'number') { - expiresInDays = Math.min(Math.max(body.expiresInDays, 1), 365); // Clamp 1-365 days - } - } catch { - // No body or invalid JSON - use default - } - - // Verify ownership - const conversation = await prisma.chatConversation.findFirst({ - where: { id, user_id: session.user.id }, - }); - - if (!conversation) { - return NextResponse.json( - { error: 'Conversation not found' }, - { status: 404 } - ); - } + const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || 'https://build.avax.network'; // If already shared, return existing share info if (conversation.is_shared && conversation.share_token) { - const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || 'https://build.avax.network'; - return NextResponse.json({ + return successResponse({ shareToken: conversation.share_token, shareUrl: `${baseUrl}/chat/share/${conversation.share_token}`, sharedAt: conversation.shared_at, @@ -64,7 +48,7 @@ export async function POST( const expiresAt = new Date(sharedAt.getTime() + expiresInDays * 24 * 60 * 60 * 1000); const updated = await prisma.chatConversation.update({ - where: { id }, + where: { id: params.id }, data: { is_shared: true, share_token: shareToken, @@ -73,61 +57,37 @@ export async function POST( }, }); - const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || 'https://build.avax.network'; - return NextResponse.json({ - shareToken: updated.share_token, - shareUrl: `${baseUrl}/chat/share/${updated.share_token}`, - sharedAt: updated.shared_at, - expiresAt: updated.share_expires_at, - viewCount: updated.view_count, - }); - } catch (error) { - console.error('Error enabling chat sharing:', error); - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); - } -} + return successResponse( + { + shareToken: updated.share_token, + shareUrl: `${baseUrl}/chat/share/${updated.share_token}`, + sharedAt: updated.shared_at, + expiresAt: updated.share_expires_at, + viewCount: updated.view_count, + }, + 201, + ); + }, + { auth: true }, +); // DELETE /api/chat-history/[id]/share - Revoke sharing for a conversation -export async function DELETE( - req: NextRequest, - { params }: { params: Promise<{ id: string }> } -) { - try { - const session = await getAuthSession(); - - if (!session?.user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - } +export const DELETE = withApi( + async (_req, { session, params }) => { + await assertOwnership(prisma.chatConversation, params.id, session.user.id); - const { id } = await params; - - // Verify ownership - const conversation = await prisma.chatConversation.findFirst({ - where: { id, user_id: session.user.id }, - }); - - if (!conversation) { - return NextResponse.json( - { error: 'Conversation not found' }, - { status: 404 } - ); - } - - // Revoke sharing - clear all share fields await prisma.chatConversation.update({ - where: { id }, + where: { id: params.id }, data: { is_shared: false, share_token: null, shared_at: null, share_expires_at: null, - view_count: 0, // Reset view count on revoke + view_count: 0, }, }); - return NextResponse.json({ success: true }); - } catch (error) { - console.error('Error revoking chat sharing:', error); - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); - } -} + return noContentResponse(); + }, + { auth: true }, +); diff --git a/app/api/chat-history/route.ts b/app/api/chat-history/route.ts index d521ca902bf..afca921e3db 100644 --- a/app/api/chat-history/route.ts +++ b/app/api/chat-history/route.ts @@ -1,16 +1,23 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { getAuthSession } from '@/lib/auth/authSession'; +import { z } from 'zod'; +import { withApi } from '@/lib/api/with-api'; +import { successResponse } from '@/lib/api/response'; +import { NotFoundError } from '@/lib/api/errors'; import { prisma } from '@/prisma/prisma'; -// GET /api/chat-history - Get user's chat conversations -export async function GET() { - try { - const session = await getAuthSession(); +const chatMessageSchema = z.object({ + role: z.string(), + content: z.string(), +}); - if (!session?.user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - } +const postSchema = z.object({ + id: z.string().optional(), + title: z.string().min(1, 'Title is required'), + messages: z.array(chatMessageSchema).min(1, 'At least one message is required'), +}); +// GET /api/chat-history - Get user's chat conversations +export const GET = withApi( + async (_req, { session }) => { const conversations = await prisma.chatConversation.findMany({ where: { user_id: session.user.id }, orderBy: { updated_at: 'desc' }, @@ -19,48 +26,27 @@ export async function GET() { orderBy: { created_at: 'asc' }, }, }, - // Include sharing fields in response (they're part of the model) - take: 50, // Limit to last 50 conversations + take: 50, }); - return NextResponse.json(conversations); - } catch (error) { - console.error('Error fetching chat history:', error); - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); - } -} + return successResponse(conversations); + }, + { auth: true }, +); // POST /api/chat-history - Create or update a conversation -export async function POST(req: NextRequest) { - try { - const session = await getAuthSession(); - - if (!session?.user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - } - - const body = await req.json(); +export const POST = withApi>( + async (_req, { session, body }) => { const { id, title, messages } = body; - if (!title || !messages || !Array.isArray(messages)) { - return NextResponse.json( - { error: 'Title and messages are required' }, - { status: 400 } - ); - } - // If ID provided, update existing conversation if (id) { - // Verify ownership const existing = await prisma.chatConversation.findFirst({ where: { id, user_id: session.user.id }, }); if (!existing) { - return NextResponse.json( - { error: 'Conversation not found' }, - { status: 404 } - ); + throw new NotFoundError('Conversation'); } // Delete old messages and create new ones (simpler than diffing) @@ -73,7 +59,7 @@ export async function POST(req: NextRequest) { data: { title, messages: { - create: messages.map((msg: { role: string; content: string }) => ({ + create: messages.map((msg) => ({ role: msg.role, content: msg.content, })), @@ -86,7 +72,7 @@ export async function POST(req: NextRequest) { }, }); - return NextResponse.json(conversation); + return successResponse(conversation); } // Create new conversation @@ -95,7 +81,7 @@ export async function POST(req: NextRequest) { user_id: session.user.id, title, messages: { - create: messages.map((msg: { role: string; content: string }) => ({ + create: messages.map((msg) => ({ role: msg.role, content: msg.content, })), @@ -108,9 +94,10 @@ export async function POST(req: NextRequest) { }, }); - return NextResponse.json(conversation); - } catch (error) { - console.error('Error saving chat conversation:', error); - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); - } -} + return successResponse(conversation, 201); + }, + { + auth: true, + schema: postSchema, + }, +); diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts index 77fb70be698..c84327eff03 100644 --- a/app/api/chat/route.ts +++ b/app/api/chat/route.ts @@ -1,16 +1,12 @@ +import type { NextRequest } from 'next/server'; +import { NextResponse } from 'next/server'; import { createAnthropic } from '@ai-sdk/anthropic'; import { streamText, tool, stepCountIs } from 'ai'; import { z } from 'zod'; +import { withApi } from '@/lib/api/with-api'; import { captureAIGeneration, captureServerEvent } from '@/lib/posthog-server'; import { searchCode, formatCodeContext } from '@/lib/code-search'; import { embedQuery, analyzeQueryIntent } from '@/lib/embeddings'; -import { getAuthSession } from '@/lib/auth/authSession'; -import { - checkChatRateLimit, - getClientIP, - createRateLimitHeaders, - formatResetTime, -} from '@/lib/chat/rateLimit'; import { searchTools, formatToolsForContext } from '@/lib/chat/tools-search'; import { docsTools, githubTools } from '@/lib/mcp/tools'; import { componentNames, getCatalogDescription } from '@/lib/chat/catalog'; @@ -64,34 +60,32 @@ let urlsCacheTimestamp: number = 0; async function getDocumentation(): Promise { const now = Date.now(); - + // Return cached docs if still valid - if (docsCache && (now - cacheTimestamp) < CACHE_DURATION) { + if (docsCache && now - cacheTimestamp < CACHE_DURATION) { return docsCache; } - + try { // Build the URL more reliably for both local and production - const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || - process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}` : - 'http://localhost:3000'; - + const baseUrl = + process.env.NEXT_PUBLIC_SITE_URL || process.env.VERCEL_URL + ? `https://${process.env.VERCEL_URL}` + : 'http://localhost:3000'; + const url = new URL('/llms-full.txt', baseUrl); - console.log(`Fetching documentation from: ${url.toString()}`); - + const response = await fetch(url); - + if (!response.ok) { throw new Error(`Failed to fetch documentation: ${response.status} ${response.statusText}`); } - + docsCache = await response.text(); cacheTimestamp = now; - - console.log(`Cached documentation: ${docsCache.length} characters`); + return docsCache; - } catch (error) { - console.error('Error fetching documentation:', error); + } catch { // Return empty string to avoid breaking the chat return ''; } @@ -99,42 +93,42 @@ async function getDocumentation(): Promise { async function getValidUrls(): Promise { const now = Date.now(); - + // Return cached URLs if still valid - if (validUrlsCache && (now - urlsCacheTimestamp) < CACHE_DURATION) { + if (validUrlsCache && now - urlsCacheTimestamp < CACHE_DURATION) { return validUrlsCache; } - + try { // Build the URL more reliably for both local and production - const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || - process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}` : - 'http://localhost:3000'; - + const baseUrl = + process.env.NEXT_PUBLIC_SITE_URL || process.env.VERCEL_URL + ? `https://${process.env.VERCEL_URL}` + : 'http://localhost:3000'; + const url = new URL('/static.json', baseUrl); - console.log(`Fetching valid URLs from: ${url.toString()}`); - + const response = await fetch(url); - + if (!response.ok) { throw new Error(`Failed to fetch URLs: ${response.status} ${response.statusText}`); } - + const data = await response.json(); const urls = data.map((item: any) => item.url); validUrlsCache = urls; urlsCacheTimestamp = now; - - console.log(`Cached ${urls.length} valid URLs`); + return urls; - } catch (error) { - console.error('Error fetching valid URLs:', error); + } catch { return []; } } // Use MCP server for better search quality — calls the handler directly (no HTTP round-trip) -async function searchDocsViaMcp(query: string): Promise> { +async function searchDocsViaMcp( + query: string, +): Promise> { try { const toolResult = await docsTools.handlers.docs_search({ query, limit: 10 }); const text = toolResult.content?.[0]?.text || ''; @@ -149,15 +143,13 @@ async function searchDocsViaMcp(query: string): Promise { try { const toolResult = await docsTools.handlers.docs_fetch({ url }); return toolResult.content?.[0]?.text || null; - } catch (error) { - console.error('Page fetch error:', error); + } catch { return null; } } @@ -177,14 +168,14 @@ function findRelevantSections(query: string, docs: string): string[] { if (!docs || !query) return []; // Split documentation into individual page sections - const sections = docs.split(/\n# /).filter(s => s.trim()); + const sections = docs.split(/\n# /).filter((s) => s.trim()); // Normalize query for better matching const queryLower = query.toLowerCase(); - const queryTerms = queryLower.split(/\s+/).filter(t => t.length > 2); + const queryTerms = queryLower.split(/\s+/).filter((t) => t.length > 2); // Score each section based on relevance - const scoredSections = sections.map(section => { + const scoredSections = sections.map((section) => { const sectionLower = section.toLowerCase(); let score = 0; @@ -194,7 +185,7 @@ function findRelevantSections(query: string, docs: string): string[] { const titleLower = title.toLowerCase(); // Score based on query terms appearing in title and content - queryTerms.forEach(term => { + queryTerms.forEach((term) => { if (titleLower.includes(term)) score += 20; if (sectionLower.includes(term)) score += 5; }); @@ -207,509 +198,550 @@ function findRelevantSections(query: string, docs: string): string[] { // Filter and sort by relevance const relevant = scoredSections - .filter(s => s.score > 0) + .filter((s) => s.score > 0) .sort((a, b) => b.score - a.score) .slice(0, 8); // Top 8 most relevant sections - console.log(`Found ${relevant.length} relevant sections for query: "${query}"`); - if (relevant.length > 0) { - console.log('Top 3 sections:', relevant.slice(0, 3).map(r => ({ title: r.title, score: r.score }))); - } - - return relevant.map(r => r.section); + return relevant.map((r) => r.section); } -export async function POST(req: Request) { - const { messages, id: visitorId, source } = await req.json(); - const isBubble = source === 'bubble'; - const startTime = Date.now(); - const traceId = `trace_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; - - // Check rate limit based on authentication status - const session = await getAuthSession(); - const isAuthenticated = !!session?.user?.id; - const identifier = isAuthenticated ? session.user.id : getClientIP(req); - - const rateLimitResult = checkChatRateLimit(identifier, isAuthenticated); - - if (!rateLimitResult.allowed) { - const resetTimeFormatted = formatResetTime(rateLimitResult.resetTime); - return new Response( - JSON.stringify({ - error: 'Rate limit exceeded', - message: isAuthenticated - ? `You've sent too many messages. Please try again ${resetTimeFormatted}.` - : `Message limit reached. Please sign in for higher limits or try again ${resetTimeFormatted}.`, - resetTime: rateLimitResult.resetTime.toISOString(), - }), - { - status: 429, - headers: { - 'Content-Type': 'application/json', - ...createRateLimitHeaders(rateLimitResult), +// withApi: auth intentionally omitted — public anonymous access supported +// schema: not applicable — AI SDK streaming protocol with dynamic message format +export const POST = withApi( + async (req: NextRequest) => { + const { messages, id: visitorId, source } = await req.json(); + const isBubble = source === 'bubble'; + const startTime = Date.now(); + const traceId = `trace_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + + // Get the last user message to search for relevant docs + const lastUserMessage = messages.filter((m: any) => m.role === 'user').pop(); + const lastUserMessageText = lastUserMessage ? getTextFromMessage(lastUserMessage) : ''; + + // Validate message size before doing expensive context assembly + if (lastUserMessageText.length > MAX_MESSAGE_CHARS) { + return NextResponse.json( + { + error: 'Message too long', + message: `Your message is ${lastUserMessageText.length.toLocaleString()} characters, which exceeds the ${MAX_MESSAGE_CHARS.toLocaleString()} character limit. Please shorten it and try again.`, }, - } - ); - } - - // Get the last user message to search for relevant docs - const lastUserMessage = messages.filter((m: any) => m.role === 'user').pop(); - const lastUserMessageText = lastUserMessage ? getTextFromMessage(lastUserMessage) : ''; - - // Validate message size before doing expensive context assembly - if (lastUserMessageText.length > MAX_MESSAGE_CHARS) { - return new Response( - JSON.stringify({ - error: 'Message too long', - message: `Your message is ${lastUserMessageText.length.toLocaleString()} characters, which exceeds the ${MAX_MESSAGE_CHARS.toLocaleString()} character limit. Please shorten it and try again.`, - }), - { status: 413, headers: { 'Content-Type': 'application/json' } } - ); - } - - // Get valid URLs for link validation - const validUrls = await getValidUrls(); - - // Code search for DeepWiki-style functionality - let codeContext = ''; - if (lastUserMessageText) { - const intent = analyzeQueryIntent(lastUserMessageText); - console.log(`[CodeSearch] Intent analysis: isCodeQuestion=${intent.isCodeQuestion}, keywords=${intent.keywords.join(',')}`); - - if (intent.isCodeQuestion) { - try { - const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || - process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}` : - 'http://localhost:3000'; - console.log(`[CodeSearch] Using base URL: ${baseUrl}`); - - console.log(`[CodeSearch] Generating query embedding...`); - const queryEmbedding = await embedQuery(lastUserMessageText); - console.log(`[CodeSearch] Embedding generated, length: ${queryEmbedding.length}`); - - console.log(`[CodeSearch] Searching code...`); - const codeResults = await searchCode(queryEmbedding, baseUrl, { - topK: 5, - repos: intent.suggestedRepos, - minScore: 0.25, // Lowered from 0.35 - semantic search typically scores 0.2-0.6 - }); - console.log(`[CodeSearch] Search returned ${codeResults.length} results`); + { status: 413 }, + ); + } - // Track code search results for analytics - const avgScore = codeResults.length > 0 - ? codeResults.reduce((sum, r) => sum + r.score, 0) / codeResults.length - : 0; - captureServerEvent('ai_chat_code_search', { - query: lastUserMessageText.slice(0, 200), - results_count: codeResults.length, - repos_searched: intent.suggestedRepos || ['all'], - top_score: codeResults[0]?.score || 0, - avg_score: avgScore, - is_code_question: true, - latency_ms: Date.now() - startTime, - }, visitorId); - - if (codeResults.length > 0) { - codeContext = '\n\n=== RELEVANT CODE FROM AVA-LABS REPOSITORIES ===\n\n'; - codeContext += '**IMPORTANT: When referencing this code, ALWAYS include the GitHub links provided below!**\n\n'; - codeContext += formatCodeContext(codeResults); - codeContext += '\n\n=== END CODE CONTEXT ===\n'; - console.log(`[CodeSearch] ✅ Added ${codeResults.length} code chunks to context`); - // Log the GitHub URLs being included - codeResults.forEach((r, i) => console.log(`[CodeSearch] ${i+1}. ${r.url}`)); + // Get valid URLs for link validation + const validUrls = await getValidUrls(); + + // Code search for DeepWiki-style functionality + let codeContext = ''; + if (lastUserMessageText) { + const intent = analyzeQueryIntent(lastUserMessageText); + + if (intent.isCodeQuestion) { + try { + const baseUrl = + process.env.NEXT_PUBLIC_SITE_URL || process.env.VERCEL_URL + ? `https://${process.env.VERCEL_URL}` + : 'http://localhost:3000'; + + const queryEmbedding = await embedQuery(lastUserMessageText); + + const codeResults = await searchCode(queryEmbedding, baseUrl, { + topK: 5, + repos: intent.suggestedRepos, + minScore: 0.25, + }); + + // Track code search results for analytics + const avgScore = + codeResults.length > 0 ? codeResults.reduce((sum, r) => sum + r.score, 0) / codeResults.length : 0; + captureServerEvent( + 'ai_chat_code_search', + { + query: lastUserMessageText.slice(0, 200), + results_count: codeResults.length, + repos_searched: intent.suggestedRepos || ['all'], + top_score: codeResults[0]?.score || 0, + avg_score: avgScore, + is_code_question: true, + latency_ms: Date.now() - startTime, + }, + visitorId, + ); + + if (codeResults.length > 0) { + codeContext = '\n\n=== RELEVANT CODE FROM AVA-LABS REPOSITORIES ===\n\n'; + codeContext += + '**IMPORTANT: When referencing this code, ALWAYS include the GitHub links provided below!**\n\n'; + codeContext += formatCodeContext(codeResults); + codeContext += '\n\n=== END CODE CONTEXT ===\n'; + } + } catch { + // Code search failed — continue without code context } - } catch (error) { - console.error('[CodeSearch] ❌ Failed:', error); } } - } - // Search for relevant YouTube videos from Avalanche channel - let youtubeContext = ''; - if (lastUserMessageText) { - try { - const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || - process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}` : - 'http://localhost:3000'; - - const youtubeResponse = await fetch(`${baseUrl}/api/youtube/search?q=${encodeURIComponent(lastUserMessageText)}&limit=3`); - - if (youtubeResponse.ok) { - const youtubeData = await youtubeResponse.json(); - if (youtubeData.videos && youtubeData.videos.length > 0) { - youtubeContext = '\n\n=== RELEVANT YOUTUBE VIDEOS ===\n\n'; - youtubeContext += 'IMPORTANT: To show these videos, call render_component("YouTubeEmbed", { videoId: "...", title: "..." }) to embed them inline. Do NOT just paste a YouTube link.\n\n'; - for (const video of youtubeData.videos) { - youtubeContext += `- **${video.title}** → render_component("YouTubeEmbed", { videoId: "${video.videoId}", title: "${video.title.replace(/"/g, '\\"')}" })\n`; - youtubeContext += ` Description: ${video.description.slice(0, 200)}...\n\n`; + // Search for relevant YouTube videos from Avalanche channel + let youtubeContext = ''; + if (lastUserMessageText) { + try { + const baseUrl = + process.env.NEXT_PUBLIC_SITE_URL || process.env.VERCEL_URL + ? `https://${process.env.VERCEL_URL}` + : 'http://localhost:3000'; + + const youtubeResponse = await fetch( + `${baseUrl}/api/youtube/search?q=${encodeURIComponent(lastUserMessageText)}&limit=3`, + ); + + if (youtubeResponse.ok) { + const youtubeData = await youtubeResponse.json(); + if (youtubeData.videos && youtubeData.videos.length > 0) { + youtubeContext = '\n\n=== RELEVANT YOUTUBE VIDEOS ===\n\n'; + youtubeContext += + 'IMPORTANT: To show these videos, call render_component("YouTubeEmbed", { videoId: "...", title: "..." }) to embed them inline. Do NOT just paste a YouTube link.\n\n'; + for (const video of youtubeData.videos) { + youtubeContext += `- **${video.title}** → render_component("YouTubeEmbed", { videoId: "${video.videoId}", title: "${video.title.replace(/"/g, '\\"')}" })\n`; + youtubeContext += ` Description: ${video.description.slice(0, 200)}...\n\n`; + } + youtubeContext += '=== END YOUTUBE VIDEOS ===\n'; + // Track YouTube search results + captureServerEvent( + 'ai_chat_youtube_search', + { + query: lastUserMessageText.slice(0, 200), + results_count: youtubeData.videos.length, + }, + visitorId, + ); } - youtubeContext += '=== END YOUTUBE VIDEOS ===\n'; - console.log(`Found ${youtubeData.videos.length} relevant YouTube videos`); - - // Track YouTube search results - captureServerEvent('ai_chat_youtube_search', { - query: lastUserMessageText.slice(0, 200), - results_count: youtubeData.videos.length, - }, visitorId); } + } catch { + // YouTube search failed — continue without video context } - } catch (error) { - console.error('YouTube search error:', error); } - } - // Search for relevant console tools - let toolsContext = ''; - if (lastUserMessageText) { - const relevantTools = searchTools(lastUserMessageText, 5); - if (relevantTools.length > 0) { - toolsContext = '\n\n=== RELEVANT CONSOLE TOOLS ===\n\n'; - toolsContext += 'IMPORTANT: For tools marked "RENDER INLINE", you MUST call the render_component tool to show the interactive UI directly in the chat. Do NOT just provide a link — render the component so the user can interact with it immediately.\n\n'; - toolsContext += formatToolsForContext(relevantTools); - toolsContext += '\n\n=== END CONSOLE TOOLS ===\n'; - console.log(`Found ${relevantTools.length} relevant console tools`); - - // Track console tools search results - captureServerEvent('ai_chat_tools_search', { - query: lastUserMessageText.slice(0, 200), - results_count: relevantTools.length, - tool_names: relevantTools.map(t => t.title), - }, visitorId); + // Search for relevant console tools + let toolsContext = ''; + if (lastUserMessageText) { + const relevantTools = searchTools(lastUserMessageText, 5); + if (relevantTools.length > 0) { + toolsContext = '\n\n=== RELEVANT CONSOLE TOOLS ===\n\n'; + toolsContext += + 'IMPORTANT: For tools marked "RENDER INLINE", you MUST call the render_component tool to show the interactive UI directly in the chat. Do NOT just provide a link — render the component so the user can interact with it immediately.\n\n'; + toolsContext += formatToolsForContext(relevantTools); + toolsContext += '\n\n=== END CONSOLE TOOLS ===\n'; + + // Track console tools search results + captureServerEvent( + 'ai_chat_tools_search', + { + query: lastUserMessageText.slice(0, 200), + results_count: relevantTools.length, + tool_names: relevantTools.map((t) => t.title), + }, + visitorId, + ); + } } - } - // Search for relevant L1 chains by name/slug - let l1Context = ''; - if (lastUserMessageText) { - // Generic terms that appear in many chain names/slugs — skip these for matching - const l1Stopwords = new Set([ - 'chain', 'network', 'mainnet', 'testnet', 'the', 'how', 'what', 'where', - 'show', 'stats', 'can', 'does', 'this', 'that', 'with', 'from', 'for', - 'about', 'have', 'are', 'was', 'will', 'get', 'into', 'create', 'deploy', - 'avalanche', 'there', 'between', 'tokens', 'token', 'transfer', - ]); - const queryLower = lastUserMessageText.toLowerCase(); - const queryTerms = queryLower.split(/\s+/).filter(t => t.length > 2 && !l1Stopwords.has(t)); - - const matchingChains = (l1Chains as any[]).filter(chain => { - // Split name into words, require term to match start of a word - // (avoids "dex" matching "modex", "step" matching "Stephenville", etc.) - const nameWords = (chain.chainName || '').toLowerCase().split(/[\s()]+/); - // For slug, require the term to match a whole hyphen-delimited word - const slugWords = (chain.slug || '').toLowerCase().split('-'); - return queryTerms.some(term => - nameWords.some((w: string) => w.startsWith(term)) || - slugWords.some((w: string) => w === term) - ); - }).slice(0, 10); - - if (matchingChains.length > 0) { - l1Context = '\n\n=== MATCHING AVALANCHE L1 CHAINS ===\n'; - l1Context += 'These are Avalanche L1 chains that match the query. Link to their stats page when relevant.\n\n'; - for (const chain of matchingChains) { - l1Context += `- **${chain.chainName}** (${chain.slug})`; - if (chain.category) l1Context += ` [${chain.category}]`; - l1Context += ` → Stats: /stats/l1/${chain.slug}`; - if (chain.website) l1Context += ` | Website: ${chain.website}`; - l1Context += '\n'; + // Search for relevant L1 chains by name/slug + let l1Context = ''; + if (lastUserMessageText) { + // Generic terms that appear in many chain names/slugs — skip these for matching + const l1Stopwords = new Set([ + 'chain', + 'network', + 'mainnet', + 'testnet', + 'the', + 'how', + 'what', + 'where', + 'show', + 'stats', + 'can', + 'does', + 'this', + 'that', + 'with', + 'from', + 'for', + 'about', + 'have', + 'are', + 'was', + 'will', + 'get', + 'into', + 'create', + 'deploy', + 'avalanche', + 'there', + 'between', + 'tokens', + 'token', + 'transfer', + ]); + const queryLower = lastUserMessageText.toLowerCase(); + const queryTerms = queryLower.split(/\s+/).filter((t) => t.length > 2 && !l1Stopwords.has(t)); + + const matchingChains = (l1Chains as any[]) + .filter((chain) => { + // Split name into words, require term to match start of a word + // (avoids "dex" matching "modex", "step" matching "Stephenville", etc.) + const nameWords = (chain.chainName || '').toLowerCase().split(/[\s()]+/); + // For slug, require the term to match a whole hyphen-delimited word + const slugWords = (chain.slug || '').toLowerCase().split('-'); + return queryTerms.some( + (term) => nameWords.some((w: string) => w.startsWith(term)) || slugWords.some((w: string) => w === term), + ); + }) + .slice(0, 10); + + if (matchingChains.length > 0) { + l1Context = '\n\n=== MATCHING AVALANCHE L1 CHAINS ===\n'; + l1Context += 'These are Avalanche L1 chains that match the query. Link to their stats page when relevant.\n\n'; + for (const chain of matchingChains) { + l1Context += `- **${chain.chainName}** (${chain.slug})`; + if (chain.category) l1Context += ` [${chain.category}]`; + l1Context += ` → Stats: /stats/l1/${chain.slug}`; + if (chain.website) l1Context += ` | Website: ${chain.website}`; + l1Context += '\n'; + } + l1Context += '\n=== END L1 CHAINS ===\n'; } - l1Context += '\n=== END L1 CHAINS ===\n'; - console.log(`Found ${matchingChains.length} matching L1 chains`); } - } - let relevantContext = ''; - let docSearchMethod: 'mcp' | 'fulltext' | 'none' = 'none'; - if (lastUserMessage && lastUserMessageText) { - // Try MCP search first (better quality), fall back to full-text search - const mcpSearchStart = Date.now(); - const mcpResults = await searchDocsViaMcp(lastUserMessageText); - - if (mcpResults.length > 0) { - docSearchMethod = 'mcp'; - // Fetch content for top 3 results - const contentPromises = mcpResults.slice(0, 3).map(async (result) => { - const content = await fetchPageContent(result.url); - return content ? `# ${result.title}\nURL: https://build.avax.network${result.url}\nSource: ${result.source}\n\n${content}` : null; - }); + let relevantContext = ''; + let docSearchMethod: 'mcp' | 'fulltext' | 'none' = 'none'; + if (lastUserMessage && lastUserMessageText) { + // Try MCP search first (better quality), fall back to full-text search + const mcpSearchStart = Date.now(); + const mcpResults = await searchDocsViaMcp(lastUserMessageText); + + if (mcpResults.length > 0) { + docSearchMethod = 'mcp'; + // Fetch content for top 3 results + const contentPromises = mcpResults.slice(0, 3).map(async (result) => { + const content = await fetchPageContent(result.url); + return content + ? `# ${result.title}\nURL: https://build.avax.network${result.url}\nSource: ${result.source}\n\n${content}` + : null; + }); - const contents = (await Promise.all(contentPromises)).filter(Boolean); + const contents = (await Promise.all(contentPromises)).filter(Boolean); - if (contents.length > 0) { - relevantContext = '\n\n=== RELEVANT DOCUMENTATION ===\n\n'; - relevantContext += 'Here are the most relevant pages from the Avalanche documentation:\n\n'; - relevantContext += contents.join('\n\n---\n\n'); - relevantContext += '\n\n=== END DOCUMENTATION ===\n'; - const imgCount = (relevantContext.match(/!\[/g) || []).length; - console.log(`Using MCP search results: ${contents.length} pages, ${imgCount} images found`); + if (contents.length > 0) { + relevantContext = '\n\n=== RELEVANT DOCUMENTATION ===\n\n'; + relevantContext += 'Here are the most relevant pages from the Avalanche documentation:\n\n'; + relevantContext += contents.join('\n\n---\n\n'); + relevantContext += '\n\n=== END DOCUMENTATION ===\n'; + } + } + + // Fall back to full-text search if MCP didn't return results + if (!relevantContext) { + docSearchMethod = 'fulltext'; + const docs = await getDocumentation(); + const relevantSections = findRelevantSections(lastUserMessageText, docs); + + if (relevantSections.length > 0) { + relevantContext = '\n\n=== RELEVANT DOCUMENTATION ===\n\n'; + relevantContext += 'Here are the most relevant sections from the Avalanche documentation:\n\n'; + relevantContext += relevantSections.join('\n\n---\n\n'); + relevantContext += '\n\n=== END DOCUMENTATION ===\n'; + } else { + docSearchMethod = 'none'; + relevantContext = '\n\n=== DOCUMENTATION ===\n'; + relevantContext += 'No specific documentation sections matched this query.\n'; + relevantContext += 'Provide general guidance and suggest relevant documentation sections if applicable.\n'; + relevantContext += '=== END DOCUMENTATION ===\n'; + } } + + // Track documentation search for analytics + captureServerEvent( + 'ai_chat_docs_search', + { + query: lastUserMessageText.slice(0, 200), + search_method: docSearchMethod, + results_count: + docSearchMethod === 'mcp' + ? mcpResults.length + : docSearchMethod === 'fulltext' + ? relevantContext.split('---').length + : 0, + latency_ms: Date.now() - mcpSearchStart, + }, + visitorId, + ); } - // Fall back to full-text search if MCP didn't return results - if (!relevantContext) { - docSearchMethod = 'fulltext'; - const docs = await getDocumentation(); - const relevantSections = findRelevantSections(lastUserMessageText, docs); - - if (relevantSections.length > 0) { - relevantContext = '\n\n=== RELEVANT DOCUMENTATION ===\n\n'; - relevantContext += 'Here are the most relevant sections from the Avalanche documentation:\n\n'; - relevantContext += relevantSections.join('\n\n---\n\n'); - relevantContext += '\n\n=== END DOCUMENTATION ===\n'; - console.log(`Using fallback full-text search: ${relevantSections.length} sections`); + // Add valid URLs list — only include URLs relevant to the query to avoid blowing up context + // Full list is 1,300+ URLs (~28K tokens) which leaves no room for large messages + let filteredUrls = validUrls; + if (lastUserMessageText && validUrls.length > 100) { + const queryTerms = lastUserMessageText + .toLowerCase() + .split(/\s+/) + .filter((t) => t.length > 2); + filteredUrls = validUrls.filter((url) => { + const urlLower = url.toLowerCase(); + return queryTerms.some((term) => urlLower.includes(term)); + }); + // Always include top-level section URLs as anchors + const topLevel = validUrls.filter((url) => (url.match(/\//g) || []).length <= 2); + filteredUrls = Array.from(new Set([...filteredUrls, ...topLevel])).slice(0, 200); + } + const validUrlsList = + filteredUrls.length > 0 + ? `\n\n=== VALID DOCUMENTATION URLS ===\nOnly use URLs from this list (filtered to relevant ones). For others, use the full path patterns you see in documentation context above.\n${filteredUrls.map((url) => `https://build.avax.network${url}`).join('\n')}\n=== END VALID URLS ===\n` + : ''; + + // Extract images from documentation context so the AI can embed them + const imageMatches = relevantContext.match(/!\[[^\]]*\]\([^)]+\)/g) || []; + // Format extracted images as render_component hints + const uniqueImages = Array.from(new Set(imageMatches)).slice(0, 6); + const imagesContext = + uniqueImages.length > 0 + ? `\n\n=== EMBEDDABLE IMAGES ===\nThese images are from the docs above. Show them with render_component("DocImage", { src, alt }):\n${uniqueImages + .map((img) => { + const match = img.match(/!\[([^\]]*)\]\(([^)]+)\)/); + return match ? `- render_component("DocImage", { src: "${match[2]}", alt: "${match[1]}" })` : ''; + }) + .filter(Boolean) + .join('\n')}\n=== END IMAGES ===\n` + : ''; + + // Build the full input for analytics + const userInput = lastUserMessageText; + + // Budget the context: measure total size and truncate if needed + // Priority order: toolsContext > relevantContext > codeContext > youtubeContext > validUrlsList > imagesContext + const baseSystemPromptSize = 2000; // the static system prompt text + const _conversationSize = messages.reduce((sum: number, m: any) => sum + getTextFromMessage(m).length, 0); + let contextBudget = MAX_SYSTEM_PROMPT_CHARS - baseSystemPromptSize; + + // Allocate context by priority, truncating lower-priority items if over budget + const contextParts: Array<{ key: string; text: string }> = [ + { key: 'l1chains', text: l1Context }, + { key: 'tools', text: toolsContext }, + { key: 'docs', text: relevantContext }, + { key: 'code', text: codeContext }, + { key: 'youtube', text: youtubeContext }, + { key: 'urls', text: validUrlsList }, + { key: 'images', text: imagesContext }, + ]; + + const budgetedContext: Record = {}; + for (const part of contextParts) { + if (part.text.length <= contextBudget) { + budgetedContext[part.key] = part.text; + contextBudget -= part.text.length; + } else if (contextBudget > 500) { + // Truncate this part to fit remaining budget + budgetedContext[part.key] = + part.text.slice(0, contextBudget - 100) + '\n\n... [truncated for context limit] ...\n'; + contextBudget = 0; } else { - docSearchMethod = 'none'; - relevantContext = '\n\n=== DOCUMENTATION ===\n'; - relevantContext += 'No specific documentation sections matched this query.\n'; - relevantContext += 'Provide general guidance and suggest relevant documentation sections if applicable.\n'; - relevantContext += '=== END DOCUMENTATION ===\n'; + budgetedContext[part.key] = ''; } } - // Track documentation search for analytics - captureServerEvent('ai_chat_docs_search', { - query: lastUserMessageText.slice(0, 200), - search_method: docSearchMethod, - results_count: docSearchMethod === 'mcp' ? mcpResults.length : - docSearchMethod === 'fulltext' ? relevantContext.split('---').length : 0, - latency_ms: Date.now() - mcpSearchStart, - }, visitorId); - } - - // Add valid URLs list — only include URLs relevant to the query to avoid blowing up context - // Full list is 1,300+ URLs (~28K tokens) which leaves no room for large messages - let filteredUrls = validUrls; - if (lastUserMessageText && validUrls.length > 100) { - const queryTerms = lastUserMessageText.toLowerCase().split(/\s+/).filter(t => t.length > 2); - filteredUrls = validUrls.filter(url => { - const urlLower = url.toLowerCase(); - return queryTerms.some(term => urlLower.includes(term)); + // Convert UI messages to model messages format + // Handle both v6 (parts) and legacy (content) formats + const modelMessages = messages.map((m: any) => { + const text = getTextFromMessage(m); + return { + role: m.role, + content: text, + }; }); - // Always include top-level section URLs as anchors - const topLevel = validUrls.filter(url => (url.match(/\//g) || []).length <= 2); - filteredUrls = Array.from(new Set([...filteredUrls, ...topLevel])).slice(0, 200); - } - const validUrlsList = filteredUrls.length > 0 - ? `\n\n=== VALID DOCUMENTATION URLS ===\nOnly use URLs from this list (filtered to relevant ones). For others, use the full path patterns you see in documentation context above.\n${filteredUrls.map(url => `https://build.avax.network${url}`).join('\n')}\n=== END VALID URLS ===\n` - : ''; - - // Extract images from documentation context so the AI can embed them - const imageMatches = relevantContext.match(/!\[[^\]]*\]\([^)]+\)/g) || []; - // Format extracted images as render_component hints - const uniqueImages = Array.from(new Set(imageMatches)).slice(0, 6); - const imagesContext = uniqueImages.length > 0 - ? `\n\n=== EMBEDDABLE IMAGES ===\nThese images are from the docs above. Show them with render_component("DocImage", { src, alt }):\n${uniqueImages.map(img => { - const match = img.match(/!\[([^\]]*)\]\(([^)]+)\)/); - return match ? `- render_component("DocImage", { src: "${match[2]}", alt: "${match[1]}" })` : ''; - }).filter(Boolean).join('\n')}\n=== END IMAGES ===\n` - : ''; - - // Build the full input for analytics - const userInput = lastUserMessageText; - - // Budget the context: measure total size and truncate if needed - // Priority order: toolsContext > relevantContext > codeContext > youtubeContext > validUrlsList > imagesContext - const baseSystemPromptSize = 2000; // the static system prompt text - const conversationSize = messages.reduce((sum: number, m: any) => sum + getTextFromMessage(m).length, 0); - let contextBudget = MAX_SYSTEM_PROMPT_CHARS - baseSystemPromptSize; - - // Allocate context by priority, truncating lower-priority items if over budget - const contextParts: Array<{ key: string; text: string }> = [ - { key: 'l1chains', text: l1Context }, - { key: 'tools', text: toolsContext }, - { key: 'docs', text: relevantContext }, - { key: 'code', text: codeContext }, - { key: 'youtube', text: youtubeContext }, - { key: 'urls', text: validUrlsList }, - { key: 'images', text: imagesContext }, - ]; - - const budgetedContext: Record = {}; - for (const part of contextParts) { - if (part.text.length <= contextBudget) { - budgetedContext[part.key] = part.text; - contextBudget -= part.text.length; - } else if (contextBudget > 500) { - // Truncate this part to fit remaining budget - budgetedContext[part.key] = part.text.slice(0, contextBudget - 100) + '\n\n... [truncated for context limit] ...\n'; - contextBudget = 0; - } else { - budgetedContext[part.key] = ''; - } - } - console.log(`[Context Budget] conversation=${conversationSize} chars, system parts: ${contextParts.map(p => `${p.key}=${budgetedContext[p.key]?.length ?? 0}`).join(', ')}`); - - // Convert UI messages to model messages format - // Handle both v6 (parts) and legacy (content) formats - const modelMessages = messages.map((m: any) => { - const text = getTextFromMessage(m); - return { - role: m.role, - content: text, - }; - }); - - let result; - try { - result = streamText({ - model: anthropic('claude-sonnet-4-6'), - messages: modelMessages, - onFinish: async ({ text, usage }) => { - // Capture LLM generation event to PostHog - const latencyMs = Date.now() - startTime; - await captureAIGeneration({ - distinctId: visitorId, - model: 'claude-sonnet-4-6', - input: userInput, - output: text, - inputTokens: usage?.inputTokens, - outputTokens: usage?.outputTokens, - latencyMs, - traceId, - }); - }, - onStepFinish: async (step) => { - // Track tool usage for analytics - // In AI SDK v6, step contains toolCalls and toolResults arrays - const toolCalls = (step as any).toolCalls; - const toolResults = (step as any).toolResults; - - if (toolCalls && Array.isArray(toolCalls) && toolCalls.length > 0) { - for (const toolCall of toolCalls) { - const toolResult = toolResults?.find((r: any) => r.toolCallId === toolCall.toolCallId); - const resultStr = toolResult?.result ? String(toolResult.result) : ''; - const success = !resultStr.toLowerCase().includes('error'); - - captureServerEvent('ai_chat_tool_used', { - tool_name: toolCall.toolName, - tool_args: JSON.stringify(toolCall.args || {}).slice(0, 500), - success, - has_result: !!toolResult, - }, visitorId); - } - } - }, - tools: { - github_search_code: tool({ - description: 'Search for code in Avalanche repositories (avalanchego, icm-services, builders-hub). Use this to find functions, types, implementations, or understand how Avalanche works internally. Returns file paths and code snippets.', - inputSchema: z.object({ - query: z.string().describe('Search query - keywords, function names, type names, or concepts'), - repo: z.enum(['avalanchego', 'icm-services', 'builders-hub', 'all']).default('all').describe('Which repository to search'), - language: z.enum(['go', 'solidity', 'typescript', 'any']).default('any').describe('Filter by programming language'), - }), - execute: async (input) => { - const { query, repo, language } = input; - try { - const toolResult = await githubTools.handlers.github_search_code({ query, repo, language, perPage: 10 }); - const text = toolResult.content?.[0]?.text; - return text ? JSON.parse(text) : { error: 'No result' }; - } catch (error) { - return { error: 'Failed to search GitHub', details: String(error) }; - } + let result; + try { + result = streamText({ + model: anthropic('claude-sonnet-4-6'), + messages: modelMessages, + onFinish: async ({ text, usage }) => { + // Capture LLM generation event to PostHog + const latencyMs = Date.now() - startTime; + await captureAIGeneration({ + distinctId: visitorId, + model: 'claude-sonnet-4-6', + input: userInput, + output: text, + inputTokens: usage?.inputTokens, + outputTokens: usage?.outputTokens, + latencyMs, + traceId, + }); }, - }), - - github_get_file: tool({ - description: 'Read the contents of a specific file from avalanchego, icm-services, or builders-hub. Use this after searching to read the full code of a relevant file.', - inputSchema: z.object({ - repo: z.enum(['avalanchego', 'icm-services', 'builders-hub']).describe('Repository name'), - path: z.string().describe('File path within the repository (e.g., "vms/platformvm/block/builder.go")'), - }), - execute: async (input) => { - const { repo, path } = input; - try { - const toolResult = await githubTools.handlers.github_get_file({ repo, path, owner: 'ava-labs' }); - const text = toolResult.content?.[0]?.text; - if (!text) return { error: 'No result' }; - const data = JSON.parse(text); - if (data?.content && data.content.length > 15000) { - const content = data.content; - return { - ...data, - content: content.slice(0, 10000) + '\n\n... [truncated] ...\n\n' + content.slice(-5000), - truncated: true, - }; + onStepFinish: async (step) => { + // Track tool usage for analytics + // In AI SDK v6, step contains toolCalls and toolResults arrays + const toolCalls = (step as any).toolCalls; + const toolResults = (step as any).toolResults; + + if (toolCalls && Array.isArray(toolCalls) && toolCalls.length > 0) { + for (const toolCall of toolCalls) { + const toolResult = toolResults?.find((r: any) => r.toolCallId === toolCall.toolCallId); + const resultStr = toolResult?.result ? String(toolResult.result) : ''; + const success = !resultStr.toLowerCase().includes('error'); + + captureServerEvent( + 'ai_chat_tool_used', + { + tool_name: toolCall.toolName, + tool_args: JSON.stringify(toolCall.args || {}).slice(0, 500), + success, + has_result: !!toolResult, + }, + visitorId, + ); } - return data; - } catch (error) { - return { error: 'Failed to fetch file', details: String(error) }; } }, - }), - - blockchain_lookup_transaction: blockchainLookupTransaction, - blockchain_lookup_address: blockchainLookupAddress, - blockchain_lookup_subnet: blockchainLookupSubnet, - blockchain_lookup_chain: blockchainLookupChain, - blockchain_lookup_validator: blockchainLookupValidator, - - render_component: tool({ - description: `Render an interactive UI component inline in the chat. Use this for console tools, metrics, and YouTube videos instead of linking. Components:\n${getCatalogDescription()}`, - inputSchema: z.object({ - component: z.enum(componentNames).describe('The component to render'), - props: z.record(z.string(), z.any()).optional().default({}).describe('Props to pass to the component'), - }), - execute: async ({ component, props }) => { - return { component, props, rendered: true }; + tools: { + github_search_code: tool({ + description: + 'Search for code in Avalanche repositories (avalanchego, icm-services, builders-hub). Use this to find functions, types, implementations, or understand how Avalanche works internally. Returns file paths and code snippets.', + inputSchema: z.object({ + query: z.string().describe('Search query - keywords, function names, type names, or concepts'), + repo: z + .enum(['avalanchego', 'icm-services', 'builders-hub', 'all']) + .default('all') + .describe('Which repository to search'), + language: z + .enum(['go', 'solidity', 'typescript', 'any']) + .default('any') + .describe('Filter by programming language'), + }), + execute: async (input) => { + const { query, repo, language } = input; + try { + const toolResult = await githubTools.handlers.github_search_code({ + query, + repo, + language, + perPage: 10, + }); + const text = toolResult.content?.[0]?.text; + return text ? JSON.parse(text) : { error: 'No result' }; + } catch (error) { + return { error: 'Failed to search GitHub', details: String(error) }; + } + }, + }), + + github_get_file: tool({ + description: + 'Read the contents of a specific file from avalanchego, icm-services, or builders-hub. Use this after searching to read the full code of a relevant file.', + inputSchema: z.object({ + repo: z.enum(['avalanchego', 'icm-services', 'builders-hub']).describe('Repository name'), + path: z.string().describe('File path within the repository (e.g., "vms/platformvm/block/builder.go")'), + }), + execute: async (input) => { + const { repo, path } = input; + try { + const toolResult = await githubTools.handlers.github_get_file({ repo, path, owner: 'ava-labs' }); + const text = toolResult.content?.[0]?.text; + if (!text) return { error: 'No result' }; + const data = JSON.parse(text); + if (data?.content && data.content.length > 15000) { + const content = data.content; + return { + ...data, + content: content.slice(0, 10000) + '\n\n... [truncated] ...\n\n' + content.slice(-5000), + truncated: true, + }; + } + return data; + } catch (error) { + return { error: 'Failed to fetch file', details: String(error) }; + } + }, + }), + + blockchain_lookup_transaction: blockchainLookupTransaction, + blockchain_lookup_address: blockchainLookupAddress, + blockchain_lookup_subnet: blockchainLookupSubnet, + blockchain_lookup_chain: blockchainLookupChain, + blockchain_lookup_validator: blockchainLookupValidator, + + render_component: tool({ + description: `Render an interactive UI component inline in the chat. Use this for console tools, metrics, and YouTube videos instead of linking. Components:\n${getCatalogDescription()}`, + inputSchema: z.object({ + component: z.enum(componentNames).describe('The component to render'), + props: z.record(z.string(), z.any()).optional().default({}).describe('Props to pass to the component'), + }), + execute: async ({ component, props }) => { + return { component, props, rendered: true }; + }, + }), + + suggest_followups: tool({ + description: + 'Suggest 2-3 natural follow-up questions the user might ask next. Call this AFTER answering every question. Questions should be specific to what was just discussed.', + inputSchema: z.object({ + questions: z.array(z.string().describe('A short follow-up question (under 60 chars)')).min(2).max(3), + }), + execute: async ({ questions }) => ({ questions }), + }), + + metrics_lookup: tool({ + description: + 'Look up real-time Avalanche network metrics: active addresses, transactions, TPS, validators, ICM messages, market cap. Call this before answering any metrics/stats question, then also render_component("OverviewStats") to show visually.', + inputSchema: z.object({ + timeRange: z.enum(['day', 'week', 'month']).default('day'), + }), + execute: async ({ timeRange }) => { + try { + const baseUrl = + process.env.NEXT_PUBLIC_SITE_URL || + (process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}` : 'http://localhost:3000'); + const res = await fetch(`${baseUrl}/api/overview-stats?timeRange=${timeRange}`, { + headers: { 'Cache-Control': 'no-cache' }, + }); + if (!res.ok) return { error: `Failed to fetch metrics: ${res.status}` }; + const data = await res.json(); + const { aggregated, chains } = data; + const topChains = chains + .filter((c: any) => c.activeAddresses > 0) + .sort((a: any, b: any) => b.activeAddresses - a.activeAddresses) + .slice(0, 10); + return { + summary: { + totalActiveAddresses: aggregated.totalActiveAddresses, + totalTransactions: aggregated.totalTxCount, + averageTPS: aggregated.totalTps, + totalValidators: aggregated.totalValidators, + totalICMMessages: aggregated.totalICMMessages, + totalMarketCap: aggregated.totalMarketCap, + activeChains: aggregated.activeChains, + timeRange, + }, + topChainsByActiveAddresses: topChains.map((c: any) => ({ + name: c.chainName, + chainId: c.chainId, + activeAddresses: c.activeAddresses, + transactions: c.txCount, + tps: c.tps, + validators: c.validatorCount, + })), + }; + } catch (err) { + return { error: `Metrics lookup failed: ${(err as Error).message}` }; + } + }, + }), }, - }), - - suggest_followups: tool({ - description: 'Suggest 2-3 natural follow-up questions the user might ask next. Call this AFTER answering every question. Questions should be specific to what was just discussed.', - inputSchema: z.object({ - questions: z.array(z.string().describe('A short follow-up question (under 60 chars)')).min(2).max(3), - }), - execute: async ({ questions }) => ({ questions }), - }), - - metrics_lookup: tool({ - description: 'Look up real-time Avalanche network metrics: active addresses, transactions, TPS, validators, ICM messages, market cap. Call this before answering any metrics/stats question, then also render_component("OverviewStats") to show visually.', - inputSchema: z.object({ - timeRange: z.enum(['day', 'week', 'month']).default('day'), - }), - execute: async ({ timeRange }) => { - try { - const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || - (process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}` : 'http://localhost:3000'); - const res = await fetch(`${baseUrl}/api/overview-stats?timeRange=${timeRange}`, { - headers: { 'Cache-Control': 'no-cache' }, - }); - if (!res.ok) return { error: `Failed to fetch metrics: ${res.status}` }; - const data = await res.json(); - const { aggregated, chains } = data; - const topChains = chains - .filter((c: any) => c.activeAddresses > 0) - .sort((a: any, b: any) => b.activeAddresses - a.activeAddresses) - .slice(0, 10); - return { - summary: { - totalActiveAddresses: aggregated.totalActiveAddresses, - totalTransactions: aggregated.totalTxCount, - averageTPS: aggregated.totalTps, - totalValidators: aggregated.totalValidators, - totalICMMessages: aggregated.totalICMMessages, - totalMarketCap: aggregated.totalMarketCap, - activeChains: aggregated.activeChains, - timeRange, - }, - topChainsByActiveAddresses: topChains.map((c: any) => ({ - name: c.chainName, - chainId: c.chainId, - activeAddresses: c.activeAddresses, - transactions: c.txCount, - tps: c.tps, - validators: c.validatorCount, - })), - }; - } catch (err) { - return { error: `Metrics lookup failed: ${(err as Error).message}` }; - } - }, - }), - }, - stopWhen: stepCountIs(15), - system: `${isBubble ? `## Bubble Mode — STRICT + stopWhen: stepCountIs(15), + system: `${ + isBubble + ? `## Bubble Mode — STRICT You are the quick-help bubble on the Builders Hub. Your job is to help users FIND things fast. - MAX 2-3 sentences per answer. No walls of text. - Always link to the ACTUAL relevant page (e.g. [Network Stats](/stats), [Create an L1](/console/create-l1), [ICM Docs](/docs/cross-chain/icm/overview)). Never use /chat as a link destination for content — link to where the thing actually lives. @@ -718,7 +750,9 @@ You are the quick-help bubble on the Builders Hub. Your job is to help users FIN - End with: "Want to dig deeper? [Continue in full chat](/chat)" — this is the ONLY acceptable use of a /chat link. - Format links as markdown: [text](url) -` : ''}You are the AI assistant for Avalanche Builders Hub (build.avax.network). You help developers build on Avalanche — answer questions, look up on-chain data, render interactive tools, and cite documentation. Be concise and helpful — code over prose, cite docs. +` + : '' + }You are the AI assistant for Avalanche Builders Hub (build.avax.network). You help developers build on Avalanche — answer questions, look up on-chain data, render interactive tools, and cite documentation. Be concise and helpful — code over prose, cite docs. ## CRITICAL: Always produce a text response **You MUST write a text answer to the user's question.** Never spend all your steps on tool calls without producing text. If tools fail or return empty results, answer from your knowledge and the documentation context below. A text response is mandatory — tool calls are supplementary. @@ -769,17 +803,23 @@ ${budgetedContext['images'] ?? ''} ${budgetedContext['code'] ?? ''} ${budgetedContext['urls'] ?? ''}`, - }); - } catch (error) { - console.error('[Chat] streamText failed:', error); - return new Response( - JSON.stringify({ - error: 'Chat failed', - message: 'Something went wrong processing your message. Please try a shorter message or start a new conversation.', - }), - { status: 500, headers: { 'Content-Type': 'application/json' } } - ); - } + }); + } catch { + return NextResponse.json( + { + error: 'Chat failed', + message: + 'Something went wrong processing your message. Please try a shorter message or start a new conversation.', + }, + { status: 500 }, + ); + } - return result.toUIMessageStreamResponse(); -} + // Stream response -- cast to NextResponse since toUIMessageStreamResponse returns a + // standard Response which Next.js accepts but withApi types as NextResponse. + return result.toUIMessageStreamResponse() as unknown as NextResponse; + }, + { + rateLimit: { windowMs: 3_600_000, maxRequests: 200, identifier: 'ip' }, + }, +); diff --git a/app/api/chat/share/[token]/route.ts b/app/api/chat/share/[token]/route.ts index e50da41cdec..d9716233ea4 100644 --- a/app/api/chat/share/[token]/route.ts +++ b/app/api/chat/share/[token]/route.ts @@ -1,90 +1,76 @@ -import { NextRequest, NextResponse } from 'next/server'; +import type { NextRequest } from 'next/server'; +import { withApi } from '@/lib/api/with-api'; +import { successResponse } from '@/lib/api/response'; +import { BadRequestError, NotFoundError } from '@/lib/api/errors'; import { prisma } from '@/prisma/prisma'; // GET /api/chat/share/[token] - Fetch shared conversation (public, no auth required) -export async function GET( - req: NextRequest, - { params }: { params: Promise<{ token: string }> } -) { - try { - const { token } = await params; +export const GET = withApi(async (_req: NextRequest, { params }) => { + const token = params.token; - if (!token || token.length < 10) { - return NextResponse.json({ error: 'Invalid share token' }, { status: 400 }); - } + if (!token || token.length < 10) { + throw new BadRequestError('Invalid share token'); + } - // Find conversation by share token - const conversation = await prisma.chatConversation.findUnique({ - where: { share_token: token }, - include: { - messages: { - orderBy: { created_at: 'asc' }, - select: { - id: true, - role: true, - content: true, - created_at: true, - }, + // Find conversation by share token + const conversation = await prisma.chatConversation.findUnique({ + where: { share_token: token }, + include: { + messages: { + orderBy: { created_at: 'asc' }, + select: { + id: true, + role: true, + content: true, + created_at: true, }, - user: { - select: { - name: true, - image: true, - profile_privacy: true, - }, + }, + user: { + select: { + name: true, + image: true, + profile_privacy: true, }, }, - }); + }, + }); - if (!conversation) { - return NextResponse.json( - { error: 'Shared conversation not found' }, - { status: 404 } - ); - } - - // Check if sharing is enabled - if (!conversation.is_shared) { - return NextResponse.json( - { error: 'This conversation is no longer shared' }, - { status: 404 } - ); - } + if (!conversation) { + throw new NotFoundError('Shared conversation'); + } - // Check if share link has expired - if (conversation.share_expires_at && conversation.share_expires_at < new Date()) { - return NextResponse.json( - { error: 'This share link has expired', code: 'EXPIRED' }, - { status: 410 } // 410 Gone - indicates resource was intentionally removed - ); - } + // Check if sharing is enabled + if (!conversation.is_shared) { + throw new NotFoundError('Shared conversation'); + } - // Atomically increment view count - await prisma.chatConversation.update({ - where: { id: conversation.id }, - data: { view_count: { increment: 1 } }, - }); + // Check if share link has expired + if (conversation.share_expires_at && conversation.share_expires_at < new Date()) { + throw new BadRequestError('This share link has expired'); + } - // Determine creator info based on privacy settings - let creator: { name: string | null; image: string | null } | null = null; - if (conversation.user && conversation.user.profile_privacy !== 'private') { - creator = { - name: conversation.user.name, - image: conversation.user.image, - }; - } + // Atomically increment view count + await prisma.chatConversation.update({ + where: { id: conversation.id }, + data: { view_count: { increment: 1 } }, + }); - return NextResponse.json({ - id: conversation.id, - title: conversation.title, - messages: conversation.messages, - sharedAt: conversation.shared_at, - expiresAt: conversation.share_expires_at, - viewCount: conversation.view_count + 1, // Include the current view - creator, - }); - } catch (error) { - console.error('Error fetching shared conversation:', error); - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + // Determine creator info based on privacy settings + let creator: { name: string | null; image: string | null } | null = null; + if (conversation.user && conversation.user.profile_privacy !== 'private') { + creator = { + name: conversation.user.name, + image: conversation.user.image, + }; } -} + + return successResponse({ + id: conversation.id, + title: conversation.title, + messages: conversation.messages, + sharedAt: conversation.shared_at, + expiresAt: conversation.share_expires_at, + viewCount: conversation.view_count + 1, // Include the current view + creator, + }); +}); diff --git a/app/api/chat/suggestions/route.ts b/app/api/chat/suggestions/route.ts index bdf3d98c63c..fe61dde1ea5 100644 --- a/app/api/chat/suggestions/route.ts +++ b/app/api/chat/suggestions/route.ts @@ -1,109 +1,142 @@ -import { NextResponse } from 'next/server'; +import type { NextRequest } from 'next/server'; +import { z } from 'zod'; +import { withApi } from '@/lib/api/with-api'; +import { successResponse } from '@/lib/api/response'; + +const suggestionsSchema = z.object({ + message: z.string().min(1), +}); async function loadLLMsContent() { try { - // Build the URL more reliably for both local and production - const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || - process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}` : - 'http://localhost:3000'; - + const baseUrl = + process.env.NEXT_PUBLIC_SITE_URL || process.env.VERCEL_URL + ? `https://${process.env.VERCEL_URL}` + : 'http://localhost:3000'; + const url = new URL('/llms-full.txt', baseUrl); - console.log(`[Suggestions] Fetching documentation from: ${url.toString()}`); - const response = await fetch(url); - + if (!response.ok) { throw new Error(`Failed to fetch documentation: ${response.status} ${response.statusText}`); } - + const llmsContent = await response.text(); - - // Parse the content into sections - const sections = llmsContent.split('\n\n').filter(section => section.trim()); - - const contentSections = sections.map(section => { - const lines = section.split('\n'); - const titleLine = lines.find(line => line.startsWith('# ')); - const urlLine = lines.find(line => line.startsWith('URL: ')); - - return { - title: titleLine ? titleLine.replace('# ', '') : '', - url: urlLine ? urlLine.replace('URL: ', '') : '', - content: section - }; - }).filter(s => s.title && s.url); - + + const sections = llmsContent.split('\n\n').filter((section) => section.trim()); + + const contentSections = sections + .map((section) => { + const lines = section.split('\n'); + const titleLine = lines.find((line) => line.startsWith('# ')); + const urlLine = lines.find((line) => line.startsWith('URL: ')); + + return { + title: titleLine ? titleLine.replace('# ', '') : '', + url: urlLine ? urlLine.replace('URL: ', '') : '', + content: section, + }; + }) + .filter((s) => s.title && s.url); + return contentSections; - } catch (error) { - console.error('Error loading llms.txt:', error); + } catch { return []; } } function extractKeywords(text: string): string[] { - // Extract important keywords from the AI's response const keywords = new Set(); - - // Common technical terms and concepts + const technicalTerms = [ - 'deploy', 'create', 'build', 'install', 'setup', 'configure', - 'avalanche', 'subnet', 'l1', 'chain', 'contract', 'token', 'wallet', 'node', - 'validator', 'stake', 'delegate', 'teleporter', 'icm', 'ictt', - 'evm', 'rpc', 'api', 'endpoint', 'network', 'testnet', 'mainnet', - 'bridge', 'cross-chain', 'interchain', 'message', 'transfer', - 'precompile', 'native', 'minter', 'fee', 'reward', 'warp' + 'deploy', + 'create', + 'build', + 'install', + 'setup', + 'configure', + 'avalanche', + 'subnet', + 'l1', + 'chain', + 'contract', + 'token', + 'wallet', + 'node', + 'validator', + 'stake', + 'delegate', + 'teleporter', + 'icm', + 'ictt', + 'evm', + 'rpc', + 'api', + 'endpoint', + 'network', + 'testnet', + 'mainnet', + 'bridge', + 'cross-chain', + 'interchain', + 'message', + 'transfer', + 'precompile', + 'native', + 'minter', + 'fee', + 'reward', + 'warp', ]; - + const textLower = text.toLowerCase(); - - // Find mentioned technical terms - technicalTerms.forEach(term => { + + technicalTerms.forEach((term) => { if (textLower.includes(term)) { keywords.add(term); } }); - - // Extract URLs and convert to topics + const urlMatches = text.match(/https:\/\/build\.avax\.network\/([^)\s]+)/g); if (urlMatches) { - urlMatches.forEach(url => { - const pathParts = url.split('/').slice(3); // Remove https://build.avax.network - pathParts.forEach(part => { + urlMatches.forEach((url) => { + const pathParts = url.split('/').slice(3); + pathParts.forEach((part) => { if (part && part.length > 3) { keywords.add(part.replace(/-/g, ' ')); } }); }); } - + return Array.from(keywords); } -function generateSuggestionsFromContent(keywords: string[], sections: Array<{ title: string; url: string; content: string }>): string[] { +function generateSuggestionsFromContent( + keywords: string[], + sections: Array<{ title: string; url: string; content: string }>, +): string[] { const suggestions: string[] = []; const usedTopics = new Set(); - - // Find related sections based on keywords - const relatedSections = sections.filter(section => { - const contentLower = section.content.toLowerCase(); - const titleLower = section.title.toLowerCase(); - - return keywords.some(keyword => - contentLower.includes(keyword.toLowerCase()) || - titleLower.includes(keyword.toLowerCase()) - ); - }).slice(0, 10); // Limit to top 10 related sections - - // Generate questions from section titles - relatedSections.forEach(section => { + + const relatedSections = sections + .filter((section) => { + const contentLower = section.content.toLowerCase(); + const titleLower = section.title.toLowerCase(); + + return keywords.some( + (keyword) => contentLower.includes(keyword.toLowerCase()) || titleLower.includes(keyword.toLowerCase()), + ); + }) + .slice(0, 10); + + relatedSections.forEach((section) => { const title = section.title; - - // Skip if we've already covered this topic + const topicKey = title.toLowerCase().replace(/[^a-z0-9\s]/g, ''); if (usedTopics.has(topicKey)) return; usedTopics.add(topicKey); - - // Generate different types of questions based on the title + if (title.includes('Deploy') || title.includes('Create')) { suggestions.push(`How do I ${title.toLowerCase()}?`); } else if (title.includes('Configure') || title.includes('Setup')) { @@ -114,97 +147,94 @@ function generateSuggestionsFromContent(keywords: string[], sections: Array<{ ti suggestions.push(`Tell me more about ${title.toLowerCase()}`); } }); - - // Add contextual follow-up questions based on topic combinations + const contextualQuestions: { [key: string]: { [subkey: string]: string[] } } = { - 'node': { - 'local': [ + node: { + local: [ 'What are the hardware requirements for running a local node?', 'How do I connect my local node to testnet?', - 'How can I monitor my local node\'s performance?', - 'What ports need to be open for my node?' + "How can I monitor my local node's performance?", + 'What ports need to be open for my node?', ], - 'run': [ + run: [ 'How long does it take to sync a node?', - 'What\'s the difference between archival and pruned nodes?', + "What's the difference between archival and pruned nodes?", 'How do I update my node to the latest version?', - 'Can I run multiple nodes on the same machine?' + 'Can I run multiple nodes on the same machine?', ], - 'setup': [ + setup: [ 'What configuration options should I use?', 'How do I backup my node data?', 'What are common node setup errors?', - 'How do I enable API endpoints on my node?' - ] + 'How do I enable API endpoints on my node?', + ], }, - 'deploy': { - 'contract': [ + deploy: { + contract: [ 'How do I verify my deployed contract?', 'What are gas optimization techniques for deployment?', 'How can I upgrade my deployed contract?', - 'What tools can I use to test before deployment?' + 'What tools can I use to test before deployment?', ], - 'smart': [ + smart: [ 'What are the deployment costs on Avalanche?', 'How do I interact with my deployed contract?', 'What security checks should I do before deployment?', - 'Can I deploy the same contract to multiple chains?' - ] + 'Can I deploy the same contract to multiple chains?', + ], }, - 'validator': { - 'become': [ - 'What\'s the minimum stake required?', + validator: { + become: [ + "What's the minimum stake required?", 'How long is the validation period?', 'What are the rewards for validation?', - 'What happens if my validator goes offline?' + 'What happens if my validator goes offline?', ], - 'stake': [ + stake: [ 'How do I calculate staking rewards?', 'Can I unstake before the validation period ends?', - 'What\'s the difference between validation and delegation?', - 'How do I choose a good validator to delegate to?' - ] + "What's the difference between validation and delegation?", + 'How do I choose a good validator to delegate to?', + ], }, - 'subnet': { - 'create': [ + subnet: { + create: [ 'What are the costs of creating a subnet?', 'How do I add validators to my subnet?', 'What VM options are available for subnets?', - 'How do I configure subnet parameters?' + 'How do I configure subnet parameters?', ], - 'l1': [ - 'What\'s the difference between Subnet and L1?', + l1: [ + "What's the difference between Subnet and L1?", 'How do I convert my Subnet to an L1?', 'What are the benefits of L1s over Subnets?', - 'Can L1s communicate with each other?' - ] + 'Can L1s communicate with each other?', + ], }, - 'token': { - 'bridge': [ + token: { + bridge: [ 'Which bridges support Avalanche?', 'How long do bridge transfers take?', 'What are bridge fees?', - 'Is bridging safe?' + 'Is bridging safe?', ], - 'create': [ + create: [ 'What token standards does Avalanche support?', 'How do I add liquidity for my token?', 'How can I list my token on exchanges?', - 'What are tokenomics best practices?' - ] - } + 'What are tokenomics best practices?', + ], + }, }; - - // Generate contextual questions based on keyword combinations + const keywordList = Array.from(keywords); - + for (const mainKeyword of keywordList) { if (contextualQuestions[mainKeyword]) { for (const subKeyword of keywordList) { if (contextualQuestions[mainKeyword][subKeyword]) { - // Add questions that are contextually relevant - contextualQuestions[mainKeyword][subKeyword].forEach(question => { - if (!suggestions.some(s => s.toLowerCase() === question.toLowerCase())) { + contextualQuestions[mainKeyword][subKeyword].forEach((question) => { + if (!suggestions.some((s) => s.toLowerCase() === question.toLowerCase())) { suggestions.push(question); } }); @@ -212,51 +242,44 @@ function generateSuggestionsFromContent(keywords: string[], sections: Array<{ ti } } } - - // If we don't have enough suggestions, add some general follow-ups + if (suggestions.length < 3) { const generalFollowUps = [ 'Can you show me a code example?', 'What are common mistakes to avoid?', 'Are there any best practices I should follow?', 'What tools or resources would help with this?', - 'Can you explain this in simpler terms?' + 'Can you explain this in simpler terms?', ]; - - generalFollowUps.forEach(question => { + + generalFollowUps.forEach((question) => { if (suggestions.length < 6 && !suggestions.includes(question)) { suggestions.push(question); } }); } - - return suggestions.slice(0, 6); // Return top 6 suggestions + + return suggestions.slice(0, 6); } -export async function POST(request: Request) { - try { - const { message } = await request.json(); - - if (!message) { - return NextResponse.json({ suggestions: [] }); - } - - // Extract keywords from the AI's response +// withApi: auth intentionally omitted — public anonymous access supported +export const POST = withApi<{ message: string }>( + async (_req: NextRequest, { body }) => { + const { message } = body; + const keywords = extractKeywords(message); - + if (keywords.length === 0) { - return NextResponse.json({ suggestions: [] }); + return successResponse({ suggestions: [] }); } - - // Load documentation content + const sections = await loadLLMsContent(); - - // Generate suggestions based on content const suggestions = generateSuggestionsFromContent(keywords, sections); - - return NextResponse.json({ suggestions }); - } catch (error) { - console.error('Error generating suggestions:', error); - return NextResponse.json({ suggestions: [] }); - } -} \ No newline at end of file + + return successResponse({ suggestions }); + }, + { + schema: suggestionsSchema, + rateLimit: { windowMs: 60_000, maxRequests: 60, identifier: 'ip' }, + }, +); diff --git a/app/api/console-log/route.ts b/app/api/console-log/route.ts index 8661b41647f..5bb0770970b 100644 --- a/app/api/console-log/route.ts +++ b/app/api/console-log/route.ts @@ -1,64 +1,54 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { getAuthSession } from '@/lib/auth/authSession'; +import type { NextRequest } from 'next/server'; +import { z } from 'zod'; +import { withApi } from '@/lib/api/with-api'; +import { successResponse } from '@/lib/api/response'; import { prisma } from '@/prisma/prisma'; import { checkAndAwardConsoleBadges } from '@/server/services/consoleBadge/consoleBadgeService'; import type { AwardedConsoleBadge } from '@/server/services/consoleBadge/types'; -// GET /api/console-log - Get user's console logs -export async function GET(req: NextRequest) { - try { - const session = await getAuthSession(); +const consoleLogSchema = z.object({ + status: z.string().min(1, 'Status is required'), + actionPath: z.string().min(1, 'actionPath is required'), + data: z.any().optional(), + timezone: z.string().optional(), +}); - if (!session?.user) { - return NextResponse.json({ error: 'Unauthorized, please sign in to continue.' }, { status: 401 }); - } +export const GET = withApi( + async (_req: NextRequest, { session }) => { const logs = await prisma.consoleLog.findMany({ where: { user_id: session.user.id }, orderBy: { created_at: 'desc' }, - take: 100 // Limit to last 100 items + take: 100, }); - return NextResponse.json(logs); - } catch (error) { - console.error('Error fetching console logs:', error); - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); - } -} + return successResponse(logs); + }, + { auth: true }, +); -// POST /api/console-log - Add new log entry -export async function POST(req: NextRequest) { - try { - const session = await getAuthSession(); - if (!session?.user) { - return NextResponse.json({ error: 'Unauthorized, please sign in to continue.' }, { status: 401 }); - } - const body = await req.json(); - if (!body) { - return NextResponse.json({ error: 'No body provided.' }, { status: 400 }); - } - if (!body.status || !body.actionPath) { - return NextResponse.json({ error: 'Status and actionPath are required.' }, { status: 400 }); - } +export const POST = withApi>( + async (_req: NextRequest, { session, body }) => { const { status, actionPath, data, timezone } = body; const logEntry = await prisma.consoleLog.create({ data: { - user_id: session?.user.id, + user_id: session.user.id, status, action_path: actionPath, - data - } + data, + }, }); let awardedBadges: AwardedConsoleBadge[] = []; - try { awardedBadges = await checkAndAwardConsoleBadges(session.user.id, 'console_log', { timezone }); } - catch (e) { console.error('Badge check failed:', e); } + try { + awardedBadges = await checkAndAwardConsoleBadges(session.user.id, 'console_log', { timezone }); + } catch { + // Badge check is non-critical + } - return NextResponse.json({ ...logEntry, awardedBadges }); - } catch (error) { - console.error('Error adding console log:', error); - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); - } -} + return successResponse({ ...logEntry, awardedBadges }); + }, + { auth: true, schema: consoleLogSchema }, +); // Log deletion is disabled for audit / metric purposes diff --git a/app/api/dapps/chain-stats/route.ts b/app/api/dapps/chain-stats/route.ts index 54abf71ca14..e9d2f38b8a6 100644 --- a/app/api/dapps/chain-stats/route.ts +++ b/app/api/dapps/chain-stats/route.ts @@ -1,4 +1,4 @@ -import { NextResponse } from 'next/server'; +import { withApi, successResponse } from '@/lib/api'; import { queryClickHouse, C_CHAIN_ID, buildSwapPricesCTE, getTotalChainGas } from '@/lib/clickhouse'; import { CONTRACT_REGISTRY, PROTOCOL_SLUGS } from '@/lib/contracts'; @@ -102,88 +102,82 @@ interface PrevAddressStatsRow { avax_burned: number; } -export async function GET(request: Request) { - try { - const { searchParams } = new URL(request.url); - const daysRaw = parseInt(searchParams.get('days') || '30'); - const days = Number.isFinite(daysRaw) && daysRaw > 0 ? Math.min(daysRaw, 183) : 30; - - // Absolute date params for custom ranges (validated: digits + hyphens only) - const dateRegex = /^\d{4}-\d{2}-\d{2}$/; - const startDateRaw = searchParams.get('startDate'); - const endDateRaw = searchParams.get('endDate'); - const startDate = (startDateRaw && dateRegex.test(startDateRaw)) ? startDateRaw : null; - const endDate = (endDateRaw && dateRegex.test(endDateRaw)) ? endDateRaw : null; - const useAbsoluteRange = !!(startDate && endDate); - - // Get all known contract addresses grouped by protocol - const protocolAddresses = new Map(); - for (const contract of Object.values(CONTRACT_REGISTRY)) { - const existing = protocolAddresses.get(contract.protocol) || []; - existing.push(contract.address); - protocolAddresses.set(contract.protocol, existing); - } +export const GET = withApi(async (req) => { + const searchParams = req.nextUrl.searchParams; + const daysRaw = parseInt(searchParams.get('days') || '30'); + const days = Number.isFinite(daysRaw) && daysRaw > 0 ? Math.min(daysRaw, 183) : 30; + + // Absolute date params for custom ranges (validated: digits + hyphens only) + const dateRegex = /^\d{4}-\d{2}-\d{2}$/; + const startDateRaw = searchParams.get('startDate'); + const endDateRaw = searchParams.get('endDate'); + const startDate = startDateRaw && dateRegex.test(startDateRaw) ? startDateRaw : null; + const endDate = endDateRaw && dateRegex.test(endDateRaw) ? endDateRaw : null; + const useAbsoluteRange = !!(startDate && endDate); + + // Get all known contract addresses grouped by protocol + const protocolAddresses = new Map(); + for (const contract of Object.values(CONTRACT_REGISTRY)) { + const existing = protocolAddresses.get(contract.protocol) || []; + existing.push(contract.address); + protocolAddresses.set(contract.protocol, existing); + } - // Build address→protocol lookup for JS-side filtering - const addressToProtocol = new Map(); - for (const [protocol, addresses] of protocolAddresses) { - for (const a of addresses) { - addressToProtocol.set(a.toLowerCase(), protocol); - } + // Build address→protocol lookup for JS-side filtering + const addressToProtocol = new Map(); + for (const [protocol, addresses] of protocolAddresses) { + for (const a of addresses) { + addressToProtocol.set(a.toLowerCase(), protocol); } + } - // Build protocol→category and protocol→subcategory lookups - const protocolCategory = new Map(); - const protocolSubcategory = new Map(); - for (const contract of Object.values(CONTRACT_REGISTRY)) { - if (!protocolCategory.has(contract.protocol)) { - protocolCategory.set(contract.protocol, contract.category); - protocolSubcategory.set(contract.protocol, contract.subcategory || null); - } + // Build protocol→category and protocol→subcategory lookups + const protocolCategory = new Map(); + const protocolSubcategory = new Map(); + for (const contract of Object.values(CONTRACT_REGISTRY)) { + if (!protocolCategory.has(contract.protocol)) { + protocolCategory.set(contract.protocol, contract.category); + protocolSubcategory.set(contract.protocol, contract.subcategory || null); } + } - // Time filters for each query - let currTimeFilter: string; - let prevTimeFilter: string; - let dailyTimeFilter: string; - let swapPricesCurrFilter: string; - let swapPricesDailyFilter: string; - - if (useAbsoluteRange) { - const spanMs = new Date(endDate!).getTime() - new Date(startDate!).getTime(); - const spanDays = Math.max(1, Math.ceil(spanMs / (1000 * 60 * 60 * 24)) + 1); - currTimeFilter = `AND t.block_time >= '${startDate}' AND t.block_time < '${endDate}' + INTERVAL 1 DAY`; - prevTimeFilter = `AND t.block_time >= '${startDate}' - INTERVAL ${spanDays} DAY AND t.block_time < '${startDate}'`; - dailyTimeFilter = currTimeFilter; - swapPricesCurrFilter = `AND block_time >= '${startDate}' AND block_time < '${endDate}' + INTERVAL 1 DAY`; - swapPricesDailyFilter = swapPricesCurrFilter; - } else { - currTimeFilter = days > 0 ? `AND t.block_time >= now() - INTERVAL ${days} DAY` : ''; - prevTimeFilter = days > 0 + // Time filters for each query + let currTimeFilter: string; + let prevTimeFilter: string; + let dailyTimeFilter: string; + let swapPricesCurrFilter: string; + let swapPricesDailyFilter: string; + + if (useAbsoluteRange) { + const spanMs = new Date(endDate!).getTime() - new Date(startDate!).getTime(); + const spanDays = Math.max(1, Math.ceil(spanMs / (1000 * 60 * 60 * 24)) + 1); + currTimeFilter = `AND t.block_time >= '${startDate}' AND t.block_time < '${endDate}' + INTERVAL 1 DAY`; + prevTimeFilter = `AND t.block_time >= '${startDate}' - INTERVAL ${spanDays} DAY AND t.block_time < '${startDate}'`; + dailyTimeFilter = currTimeFilter; + swapPricesCurrFilter = `AND block_time >= '${startDate}' AND block_time < '${endDate}' + INTERVAL 1 DAY`; + swapPricesDailyFilter = swapPricesCurrFilter; + } else { + currTimeFilter = days > 0 ? `AND t.block_time >= now() - INTERVAL ${days} DAY` : ''; + prevTimeFilter = + days > 0 ? `AND t.block_time >= now() - INTERVAL ${days * 2} DAY AND t.block_time < now() - INTERVAL ${days} DAY` : ''; - const dailyDays = days > 0 ? days : 90; - dailyTimeFilter = `AND t.block_time >= now() - INTERVAL ${dailyDays} DAY`; - swapPricesCurrFilter = days > 0 ? `AND block_time >= now() - INTERVAL ${days} DAY` : ''; - swapPricesDailyFilter = `AND block_time >= now() - INTERVAL ${dailyDays} DAY`; - } + const dailyDays = days > 0 ? days : 90; + dailyTimeFilter = `AND t.block_time >= now() - INTERVAL ${dailyDays} DAY`; + swapPricesCurrFilter = days > 0 ? `AND block_time >= now() - INTERVAL ${days} DAY` : ''; + swapPricesDailyFilter = `AND block_time >= now() - INTERVAL ${dailyDays} DAY`; + } + + const hasPrevPeriod = days > 0 || useAbsoluteRange; - const hasPrevPeriod = days > 0 || useAbsoluteRange; - - // ────────────────────────────────────────────────────────────── - // Run queries in parallel — NO `to IN (addresses)` filter. - // Instead: scan ALL txs grouped by `to`, filter by registry in JS. - // This avoids a pathological scan on the non-indexed Nullable `to` - // column (36s with IN vs 6s without on ClickHouse 26.2). - // ────────────────────────────────────────────────────────────── - const [ - watermarkResult, - currStatsResult, - prevStatsResult, - dailyStatsResult, - totalChainStats, - nativeDeployResult, - ] = await Promise.all([ + // ────────────────────────────────────────────────────────────── + // Run queries in parallel — NO `to IN (addresses)` filter. + // Instead: scan ALL txs grouped by `to`, filter by registry in JS. + // This avoids a pathological scan on the non-indexed Nullable `to` + // column (36s with IN vs 6s without on ClickHouse 26.2). + // ────────────────────────────────────────────────────────────── + const [watermarkResult, currStatsResult, prevStatsResult, dailyStatsResult, totalChainStats, nativeDeployResult] = + await Promise.all([ // 1. Get watermark for latest block queryClickHouse<{ block_number: number; @@ -295,275 +289,298 @@ export async function GET(request: Request) { `), ]); - // --- JS-side aggregation: filter query results through the contract registry --- + // --- JS-side aggregation: filter query results through the contract registry --- - type ProtocolStats = { txCount: number; gasUsed: number; avaxBurned: number; gasCostUsd: number; avaxBurnedUsd: number; uniqueSenders: number }; - const currentProtocolStats = new Map(); - const prevProtocolStats = new Map(); + type ProtocolStats = { + txCount: number; + gasUsed: number; + avaxBurned: number; + gasCostUsd: number; + avaxBurnedUsd: number; + uniqueSenders: number; + }; + const currentProtocolStats = new Map(); + const prevProtocolStats = new Map(); + + // Initialize all protocols with zeros + for (const [protocol] of protocolAddresses) { + currentProtocolStats.set(protocol, { + txCount: 0, + gasUsed: 0, + avaxBurned: 0, + gasCostUsd: 0, + avaxBurnedUsd: 0, + uniqueSenders: 0, + }); + prevProtocolStats.set(protocol, { + txCount: 0, + gasUsed: 0, + avaxBurned: 0, + gasCostUsd: 0, + avaxBurnedUsd: 0, + uniqueSenders: 0, + }); + } - // Initialize all protocols with zeros - for (const [protocol] of protocolAddresses) { - currentProtocolStats.set(protocol, { txCount: 0, gasUsed: 0, avaxBurned: 0, gasCostUsd: 0, avaxBurnedUsd: 0, uniqueSenders: 0 }); - prevProtocolStats.set(protocol, { txCount: 0, gasUsed: 0, avaxBurned: 0, gasCostUsd: 0, avaxBurnedUsd: 0, uniqueSenders: 0 }); - } + // Current period: filter by registry, aggregate into protocols + for (const row of currStatsResult.data) { + const protocol = addressToProtocol.get(row.address); + if (!protocol) continue; + + const s = currentProtocolStats.get(protocol)!; + s.txCount += parseInt(row.tx_count) || 0; + s.gasUsed += parseInt(row.total_gas) || 0; + s.avaxBurned += row.avax_burned || 0; + s.gasCostUsd += row.avax_burned_usd || 0; + s.avaxBurnedUsd += row.avax_burned_usd || 0; + } - // Current period: filter by registry, aggregate into protocols - for (const row of currStatsResult.data) { - const protocol = addressToProtocol.get(row.address); - if (!protocol) continue; - - const s = currentProtocolStats.get(protocol)!; - s.txCount += parseInt(row.tx_count) || 0; - s.gasUsed += parseInt(row.total_gas) || 0; - s.avaxBurned += row.avax_burned || 0; - s.gasCostUsd += row.avax_burned_usd || 0; - s.avaxBurnedUsd += row.avax_burned_usd || 0; - } + // Previous period: filter by registry, only gas/avaxBurned (for delta) + for (const row of prevStatsResult.data) { + const protocol = addressToProtocol.get(row.address); + if (!protocol) continue; - // Previous period: filter by registry, only gas/avaxBurned (for delta) - for (const row of prevStatsResult.data) { - const protocol = addressToProtocol.get(row.address); - if (!protocol) continue; + const s = prevProtocolStats.get(protocol)!; + s.gasUsed += parseInt(row.total_gas) || 0; + s.avaxBurned += row.avax_burned || 0; + } - const s = prevProtocolStats.get(protocol)!; - s.gasUsed += parseInt(row.total_gas) || 0; - s.avaxBurned += row.avax_burned || 0; + // Add native transfers + deploys + const nd = nativeDeployResult.data[0]; + if (nd) { + const nativeTx = parseInt(nd.native_tx) || 0; + if (nativeTx > 0) { + currentProtocolStats.set('Native Transfers', { + txCount: nativeTx, + gasUsed: parseInt(nd.native_gas) || 0, + avaxBurned: nd.native_burned || 0, + gasCostUsd: nd.native_burned_usd || 0, + avaxBurnedUsd: nd.native_burned_usd || 0, + uniqueSenders: parseInt(nd.native_senders) || 0, + }); + protocolCategory.set('Native Transfers', 'native'); } - // Add native transfers + deploys - const nd = nativeDeployResult.data[0]; - if (nd) { - const nativeTx = parseInt(nd.native_tx) || 0; - if (nativeTx > 0) { - currentProtocolStats.set('Native Transfers', { - txCount: nativeTx, - gasUsed: parseInt(nd.native_gas) || 0, - avaxBurned: nd.native_burned || 0, - gasCostUsd: nd.native_burned_usd || 0, - avaxBurnedUsd: nd.native_burned_usd || 0, - uniqueSenders: parseInt(nd.native_senders) || 0, - }); - protocolCategory.set('Native Transfers', 'native'); - } - - const deployTx = parseInt(nd.deploy_tx) || 0; - if (deployTx > 0) { - currentProtocolStats.set('Contract Deploys', { - txCount: deployTx, - gasUsed: parseInt(nd.deploy_gas) || 0, - avaxBurned: nd.deploy_burned || 0, - gasCostUsd: nd.deploy_burned_usd || 0, - avaxBurnedUsd: nd.deploy_burned_usd || 0, - uniqueSenders: parseInt(nd.deploy_senders) || 0, - }); - protocolCategory.set('Contract Deploys', 'infrastructure'); - } + const deployTx = parseInt(nd.deploy_tx) || 0; + if (deployTx > 0) { + currentProtocolStats.set('Contract Deploys', { + txCount: deployTx, + gasUsed: parseInt(nd.deploy_gas) || 0, + avaxBurned: nd.deploy_burned || 0, + gasCostUsd: nd.deploy_burned_usd || 0, + avaxBurnedUsd: nd.deploy_burned_usd || 0, + uniqueSenders: parseInt(nd.deploy_senders) || 0, + }); + protocolCategory.set('Contract Deploys', 'infrastructure'); } + } - // Calculate tagged totals - let taggedTxCount = 0; - let taggedGas = 0; - let taggedBurned = 0; - for (const stats of currentProtocolStats.values()) { - taggedTxCount += stats.txCount; - taggedGas += stats.gasUsed; - taggedBurned += stats.avaxBurned; - } + // Calculate tagged totals + let taggedTxCount = 0; + let taggedGas = 0; + let taggedBurned = 0; + for (const stats of currentProtocolStats.values()) { + taggedTxCount += stats.txCount; + taggedGas += stats.gasUsed; + taggedBurned += stats.avaxBurned; + } - const totalChainGas = totalChainStats.totalGas; - - // Create sorted protocol breakdown (with deltas for treemap nesting) - const protocolBreakdown = Array.from(currentProtocolStats.entries()) - .map(([protocol, stats]) => { - const prevStats = prevProtocolStats.get(protocol); - const prevGas = prevStats?.gasUsed || 0; - const currGas = stats.gasUsed; - const delta = prevGas > 0 ? ((currGas - prevGas) / prevGas) * 100 : (currGas > 0 ? 100 : 0); - - return { - protocol, - slug: PROTOCOL_SLUGS[protocol] || null, - category: protocolCategory.get(protocol) || 'other', - subcategory: protocolSubcategory.get(protocol) || null, - txCount: stats.txCount, - gasUsed: stats.gasUsed, - avaxBurned: stats.avaxBurned, - gasCostUsd: stats.gasCostUsd, - avaxBurnedUsd: stats.avaxBurnedUsd, - uniqueSenders: stats.uniqueSenders, - gasShare: totalChainGas > 0 ? (stats.gasUsed / totalChainGas) * 100 : 0, - delta, - }; - }) - .filter(p => p.txCount > 0) - .sort((a, b) => b.gasUsed - a.gasUsed); - - // Build category breakdown with deltas - const categoryMap = new Map { + const prevStats = prevProtocolStats.get(protocol); + const prevGas = prevStats?.gasUsed || 0; + const currGas = stats.gasUsed; + const delta = prevGas > 0 ? ((currGas - prevGas) / prevGas) * 100 : currGas > 0 ? 100 : 0; + + return { + protocol, + slug: PROTOCOL_SLUGS[protocol] || null, + category: protocolCategory.get(protocol) || 'other', + subcategory: protocolSubcategory.get(protocol) || null, + txCount: stats.txCount, + gasUsed: stats.gasUsed, + avaxBurned: stats.avaxBurned, + gasCostUsd: stats.gasCostUsd, + avaxBurnedUsd: stats.avaxBurnedUsd, + uniqueSenders: stats.uniqueSenders, + gasShare: totalChainGas > 0 ? (stats.gasUsed / totalChainGas) * 100 : 0, + delta, + }; + }) + .filter((p) => p.txCount > 0) + .sort((a, b) => b.gasUsed - a.gasUsed); + + // Build category breakdown with deltas + const categoryMap = new Map< + string, + { + current: { + txCount: number; + gasUsed: number; + avaxBurned: number; + gasCostUsd: number; + avaxBurnedUsd: number; + uniqueSenders: number; + }; prev: { gasUsed: number }; - }>(); - - for (const [protocol, stats] of currentProtocolStats) { - const cat = protocolCategory.get(protocol) || 'other'; - if (!categoryMap.has(cat)) { - categoryMap.set(cat, { - current: { txCount: 0, gasUsed: 0, avaxBurned: 0, gasCostUsd: 0, avaxBurnedUsd: 0, uniqueSenders: 0 }, - prev: { gasUsed: 0 }, - }); - } - const entry = categoryMap.get(cat)!; - entry.current.txCount += stats.txCount; - entry.current.gasUsed += stats.gasUsed; - entry.current.avaxBurned += stats.avaxBurned; - entry.current.gasCostUsd += stats.gasCostUsd; - entry.current.avaxBurnedUsd += stats.avaxBurnedUsd; - entry.current.uniqueSenders += stats.uniqueSenders; } + >(); + + for (const [protocol, stats] of currentProtocolStats) { + const cat = protocolCategory.get(protocol) || 'other'; + if (!categoryMap.has(cat)) { + categoryMap.set(cat, { + current: { txCount: 0, gasUsed: 0, avaxBurned: 0, gasCostUsd: 0, avaxBurnedUsd: 0, uniqueSenders: 0 }, + prev: { gasUsed: 0 }, + }); + } + const entry = categoryMap.get(cat)!; + entry.current.txCount += stats.txCount; + entry.current.gasUsed += stats.gasUsed; + entry.current.avaxBurned += stats.avaxBurned; + entry.current.gasCostUsd += stats.gasCostUsd; + entry.current.avaxBurnedUsd += stats.avaxBurnedUsd; + entry.current.uniqueSenders += stats.uniqueSenders; + } - for (const [protocol, stats] of prevProtocolStats) { - const cat = protocolCategory.get(protocol) || 'other'; - if (!categoryMap.has(cat)) { - categoryMap.set(cat, { - current: { txCount: 0, gasUsed: 0, avaxBurned: 0, gasCostUsd: 0, avaxBurnedUsd: 0, uniqueSenders: 0 }, - prev: { gasUsed: 0 }, - }); - } - categoryMap.get(cat)!.prev.gasUsed += stats.gasUsed; + for (const [protocol, stats] of prevProtocolStats) { + const cat = protocolCategory.get(protocol) || 'other'; + if (!categoryMap.has(cat)) { + categoryMap.set(cat, { + current: { txCount: 0, gasUsed: 0, avaxBurned: 0, gasCostUsd: 0, avaxBurnedUsd: 0, uniqueSenders: 0 }, + prev: { gasUsed: 0 }, + }); } + categoryMap.get(cat)!.prev.gasUsed += stats.gasUsed; + } - const categoryBreakdown: CategoryBreakdown[] = Array.from(categoryMap.entries()) - .map(([category, data]) => { - const prevGas = data.prev.gasUsed; - const currGas = data.current.gasUsed; - const delta = prevGas > 0 ? ((currGas - prevGas) / prevGas) * 100 : (currGas > 0 ? 100 : 0); - - return { - category, - txCount: data.current.txCount, - gasUsed: data.current.gasUsed, - avaxBurned: data.current.avaxBurned, - gasCostUsd: data.current.gasCostUsd, - avaxBurnedUsd: data.current.avaxBurnedUsd, - uniqueSenders: data.current.uniqueSenders, - gasShare: totalChainGas > 0 ? (data.current.gasUsed / totalChainGas) * 100 : 0, - delta, - }; - }) - .filter(c => c.gasUsed > 0) - .sort((a, b) => b.gasUsed - a.gasUsed); - - // Aggregate daily stats from per-contract rows (filter by registry in JS) - const dailyAgg = new Map(); - const dailyCatAgg = new Map; totalGas: number; weightedGasPriceSum: number }>(); - - for (const row of dailyStatsResult.data) { - const date = row.date; - const protocol = addressToProtocol.get(row.address); - const category = protocol ? (protocolCategory.get(protocol) || 'other') : 'other'; - - // Aggregate daily totals (only for registered contracts) - if (protocol) { - if (!dailyAgg.has(date)) { - dailyAgg.set(date, { txCount: 0, gasUsed: 0, avaxBurned: 0, avaxBurnedUsd: 0 }); - } - const agg = dailyAgg.get(date)!; - agg.txCount += parseInt(row.tx_count) || 0; - agg.gasUsed += parseInt(row.total_gas) || 0; - agg.avaxBurned += row.avax_burned || 0; - agg.avaxBurnedUsd += row.avax_burned_usd || 0; + const categoryBreakdown: CategoryBreakdown[] = Array.from(categoryMap.entries()) + .map(([category, data]) => { + const prevGas = data.prev.gasUsed; + const currGas = data.current.gasUsed; + const delta = prevGas > 0 ? ((currGas - prevGas) / prevGas) * 100 : currGas > 0 ? 100 : 0; + + return { + category, + txCount: data.current.txCount, + gasUsed: data.current.gasUsed, + avaxBurned: data.current.avaxBurned, + gasCostUsd: data.current.gasCostUsd, + avaxBurnedUsd: data.current.avaxBurnedUsd, + uniqueSenders: data.current.uniqueSenders, + gasShare: totalChainGas > 0 ? (data.current.gasUsed / totalChainGas) * 100 : 0, + delta, + }; + }) + .filter((c) => c.gasUsed > 0) + .sort((a, b) => b.gasUsed - a.gasUsed); + + // Aggregate daily stats from per-contract rows (filter by registry in JS) + const dailyAgg = new Map(); + const dailyCatAgg = new Map< + string, + { categories: Map; totalGas: number; weightedGasPriceSum: number } + >(); + + for (const row of dailyStatsResult.data) { + const date = row.date; + const protocol = addressToProtocol.get(row.address); + const category = protocol ? protocolCategory.get(protocol) || 'other' : 'other'; + + // Aggregate daily totals (only for registered contracts) + if (protocol) { + if (!dailyAgg.has(date)) { + dailyAgg.set(date, { txCount: 0, gasUsed: 0, avaxBurned: 0, avaxBurnedUsd: 0 }); } + const agg = dailyAgg.get(date)!; + agg.txCount += parseInt(row.tx_count) || 0; + agg.gasUsed += parseInt(row.total_gas) || 0; + agg.avaxBurned += row.avax_burned || 0; + agg.avaxBurnedUsd += row.avax_burned_usd || 0; + } - // Category timeline includes all contracts (registered get their category, others go to 'other') - if (!dailyCatAgg.has(date)) { - dailyCatAgg.set(date, { categories: new Map(), totalGas: 0, weightedGasPriceSum: 0 }); - } - const catAgg = dailyCatAgg.get(date)!; - catAgg.categories.set(category, (catAgg.categories.get(category) || 0) + (row.avax_burned || 0)); - catAgg.totalGas += parseInt(row.total_gas) || 0; - catAgg.weightedGasPriceSum += row.weighted_gas_price_sum || 0; + // Category timeline includes all contracts (registered get their category, others go to 'other') + if (!dailyCatAgg.has(date)) { + dailyCatAgg.set(date, { categories: new Map(), totalGas: 0, weightedGasPriceSum: 0 }); } + const catAgg = dailyCatAgg.get(date)!; + catAgg.categories.set(category, (catAgg.categories.get(category) || 0) + (row.avax_burned || 0)); + catAgg.totalGas += parseInt(row.total_gas) || 0; + catAgg.weightedGasPriceSum += row.weighted_gas_price_sum || 0; + } - const dailyStats = Array.from(dailyAgg.entries()) - .sort(([a], [b]) => a.localeCompare(b)) - .map(([date, agg]) => ({ + const dailyStats = Array.from(dailyAgg.entries()) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([date, agg]) => ({ + date, + txCount: agg.txCount, + gasUsed: agg.gasUsed, + avaxBurned: agg.avaxBurned, + avaxBurnedUsd: agg.avaxBurnedUsd, + })); + + const dailyCategoryStats = Array.from(dailyCatAgg.entries()) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([date, agg]) => { + const point: { date: string; avgGasPriceGwei: number; [category: string]: number | string } = { date, - txCount: agg.txCount, - gasUsed: agg.gasUsed, - avaxBurned: agg.avaxBurned, - avaxBurnedUsd: agg.avaxBurnedUsd, - })); - - const dailyCategoryStats = Array.from(dailyCatAgg.entries()) - .sort(([a], [b]) => a.localeCompare(b)) - .map(([date, agg]) => { - const point: { date: string; avgGasPriceGwei: number; [category: string]: number | string } = { - date, - avgGasPriceGwei: agg.totalGas > 0 ? agg.weightedGasPriceSum / agg.totalGas / 1e9 : 0, - }; - for (const [cat, burned] of agg.categories) { - point[cat] = burned; - } - return point; - }); - - // Find top burner address (highest avax_burned among classified contracts) - let topBurnerAddress: string | null = null; - let topBurnerBurned = 0; - for (const row of currStatsResult.data) { - if (addressToProtocol.has(row.address) && row.avax_burned > topBurnerBurned) { - topBurnerBurned = row.avax_burned; - topBurnerAddress = row.address; + avgGasPriceGwei: agg.totalGas > 0 ? agg.weightedGasPriceSum / agg.totalGas / 1e9 : 0, + }; + for (const [cat, burned] of agg.categories) { + point[cat] = burned; } - } - - const watermark = watermarkResult.data[0]; - - // Breadcrumbs: top unclassified contracts — derived from currStatsResult (no extra query) - const topBreadcrumbs = currStatsResult.data - .filter(row => !addressToProtocol.has(row.address)) - .sort((a, b) => b.avax_burned - a.avax_burned) - .slice(0, 10) - .map(row => ({ - address: row.address, - txCount: parseInt(row.tx_count) || 0, - gasUsed: parseInt(row.total_gas) || 0, - avaxBurned: row.avax_burned || 0, - })); - - const response: ChainStatsResponse = { - totalTransactions: taggedTxCount, - totalGasUsed: taggedGas, - totalAvaxBurned: taggedBurned, - latestBlock: watermark?.block_number || 0, - latestBlockTime: watermark?.block_time || '', - protocolBreakdown, - categoryBreakdown, - dailyStats, - dailyCategoryStats, - topBurnerAddress, - topBreadcrumbs, - coverage: { - taggedGasPercent: totalChainGas > 0 ? (taggedGas / totalChainGas) * 100 : 0, - taggedTxPercent: totalChainStats.totalTx > 0 ? (taggedTxCount / totalChainStats.totalTx) * 100 : 0, - totalChainTxs: totalChainStats.totalTx, - totalChainGas: totalChainStats.totalGas, - totalChainBurned: totalChainStats.totalBurned, - }, - timeRange: useAbsoluteRange ? `${startDate} to ${endDate}` : (days > 0 ? `${days} days` : 'All Time'), - lastUpdated: new Date().toISOString(), - }; - - return NextResponse.json(response, { - headers: { - 'Cache-Control': 'public, s-maxage=600, stale-while-revalidate=1200', - }, + return point; }); - } catch (error) { - console.error('Error fetching chain stats:', error); - return NextResponse.json( - { error: 'Failed to fetch chain stats' }, - { status: 500 } - ); + + // Find top burner address (highest avax_burned among classified contracts) + let topBurnerAddress: string | null = null; + let topBurnerBurned = 0; + for (const row of currStatsResult.data) { + if (addressToProtocol.has(row.address) && row.avax_burned > topBurnerBurned) { + topBurnerBurned = row.avax_burned; + topBurnerAddress = row.address; + } } -} + + const watermark = watermarkResult.data[0]; + + // Breadcrumbs: top unclassified contracts — derived from currStatsResult (no extra query) + const topBreadcrumbs = currStatsResult.data + .filter((row) => !addressToProtocol.has(row.address)) + .sort((a, b) => b.avax_burned - a.avax_burned) + .slice(0, 10) + .map((row) => ({ + address: row.address, + txCount: parseInt(row.tx_count) || 0, + gasUsed: parseInt(row.total_gas) || 0, + avaxBurned: row.avax_burned || 0, + })); + + const response: ChainStatsResponse = { + totalTransactions: taggedTxCount, + totalGasUsed: taggedGas, + totalAvaxBurned: taggedBurned, + latestBlock: watermark?.block_number || 0, + latestBlockTime: watermark?.block_time || '', + protocolBreakdown, + categoryBreakdown, + dailyStats, + dailyCategoryStats, + topBurnerAddress, + topBreadcrumbs, + coverage: { + taggedGasPercent: totalChainGas > 0 ? (taggedGas / totalChainGas) * 100 : 0, + taggedTxPercent: totalChainStats.totalTx > 0 ? (taggedTxCount / totalChainStats.totalTx) * 100 : 0, + totalChainTxs: totalChainStats.totalTx, + totalChainGas: totalChainStats.totalGas, + totalChainBurned: totalChainStats.totalBurned, + }, + timeRange: useAbsoluteRange ? `${startDate} to ${endDate}` : days > 0 ? `${days} days` : 'All Time', + lastUpdated: new Date().toISOString(), + }; + + return successResponse(response); +}); diff --git a/app/api/dapps/contract-gas-flow/route.ts b/app/api/dapps/contract-gas-flow/route.ts index 852bbd3a19f..8695b5d3615 100644 --- a/app/api/dapps/contract-gas-flow/route.ts +++ b/app/api/dapps/contract-gas-flow/route.ts @@ -1,4 +1,4 @@ -import { NextResponse } from 'next/server'; +import { withApi, successResponse, ValidationError } from '@/lib/api'; import { queryClickHouse, buildContractGasReceivedQuery, @@ -46,123 +46,99 @@ function enrichAddress(address: string): AddressInfo { }; } -export async function GET(request: Request) { - try { - const { searchParams } = new URL(request.url); - const address = searchParams.get('address'); - const daysRaw = parseInt(searchParams.get('days') || '30'); - const days = Number.isFinite(daysRaw) && daysRaw > 0 && daysRaw <= 183 ? daysRaw : 30; - - if (!address || !/^0x[a-fA-F0-9]{40}$/.test(address)) { - return NextResponse.json( - { error: 'Invalid or missing address parameter' }, - { status: 400 } - ); - } - - // Run all three queries in parallel - const [callersResult, calleesResult, summaryResult] = await Promise.all([ - queryClickHouse(buildContractGasReceivedQuery(address, days)), - queryClickHouse(buildContractGasGivenQuery(address, days)), - queryClickHouse(buildContractTxSummaryQuery(address, days)), - ]); - - // Aggregate totals - const totalGasReceived = callersResult.data.reduce( - (sum, r) => sum + (parseInt(r.gas) || 0), 0 - ); - const totalGasGiven = calleesResult.data.reduce( - (sum, r) => sum + (parseInt(r.gas) || 0), 0 - ); - const selfGas = Math.max(totalGasReceived - totalGasGiven, 0); - const selfGasRatio = totalGasReceived > 0 ? selfGas / totalGasReceived : 0; - - const totalAvaxReceived = callersResult.data.reduce( - (sum, r) => sum + (r.avax || 0), 0 - ); - const totalAvaxGiven = calleesResult.data.reduce( - (sum, r) => sum + (r.avax || 0), 0 - ); - const selfAvax = Math.max(totalAvaxReceived - totalAvaxGiven, 0); - - // Classification - let classification: 'entry_point' | 'gas_burner' | 'mixed'; - if (selfGasRatio >= 0.7) { - classification = 'gas_burner'; - } else if (selfGasRatio <= 0.2) { - classification = 'entry_point'; - } else { - classification = 'mixed'; - } - - // Build top 10 callers, aggregate rest as "Others" - function buildTopEntries(rows: TraceRow[], totalGas: number): FlowEntry[] { - const sorted = rows - .map(r => ({ - ...enrichAddress(r.address), - gas: parseInt(r.gas) || 0, - avax: r.avax || 0, - txCount: parseInt(r.tx_count) || 0, - gasPercent: totalGas > 0 ? ((parseInt(r.gas) || 0) / totalGas) * 100 : 0, - })) - .sort((a, b) => b.gas - a.gas); - - if (sorted.length <= 10) return sorted; - - const top = sorted.slice(0, 10); - const rest = sorted.slice(10); - const othersGas = rest.reduce((s, r) => s + r.gas, 0); - const othersAvax = rest.reduce((s, r) => s + r.avax, 0); - const othersTx = rest.reduce((s, r) => s + r.txCount, 0); - - top.push({ - address: 'others', - name: `Others (${rest.length})`, - protocol: null, - category: null, - gas: othersGas, - avax: othersAvax, - txCount: othersTx, - gasPercent: totalGas > 0 ? (othersGas / totalGas) * 100 : 0, - }); - - return top; - } - - const callers = buildTopEntries(callersResult.data, totalGasReceived); - const callees = buildTopEntries(calleesResult.data, totalGasGiven); - - const summaryData = summaryResult.data[0]; - - const response = { - target: enrichAddress(address), - classification, - selfGasRatio, - summary: { - totalGasReceived, - totalGasGiven, - selfGas, - totalAvaxReceived, - totalAvaxGiven, - selfAvax, - totalTransactions: parseInt(summaryData?.total_txs) || 0, - uniqueCallers: parseInt(summaryData?.unique_callers) || 0, - }, - callers, - callees, - timeRange: days > 0 ? `${days}d` : 'all', - }; - - return NextResponse.json(response, { - headers: { - 'Cache-Control': 'public, s-maxage=300, stale-while-revalidate=600', - }, +export const GET = withApi(async (req) => { + const address = req.nextUrl.searchParams.get('address'); + const daysRaw = parseInt(req.nextUrl.searchParams.get('days') || '30'); + const days = Number.isFinite(daysRaw) && daysRaw > 0 && daysRaw <= 183 ? daysRaw : 30; + + if (!address || !/^0x[a-fA-F0-9]{40}$/.test(address)) { + throw new ValidationError('Invalid or missing address parameter'); + } + + // Run all three queries in parallel + const [callersResult, calleesResult, summaryResult] = await Promise.all([ + queryClickHouse(buildContractGasReceivedQuery(address, days)), + queryClickHouse(buildContractGasGivenQuery(address, days)), + queryClickHouse(buildContractTxSummaryQuery(address, days)), + ]); + + // Aggregate totals + const totalGasReceived = callersResult.data.reduce((sum, r) => sum + (parseInt(r.gas) || 0), 0); + const totalGasGiven = calleesResult.data.reduce((sum, r) => sum + (parseInt(r.gas) || 0), 0); + const selfGas = Math.max(totalGasReceived - totalGasGiven, 0); + const selfGasRatio = totalGasReceived > 0 ? selfGas / totalGasReceived : 0; + + const totalAvaxReceived = callersResult.data.reduce((sum, r) => sum + (r.avax || 0), 0); + const totalAvaxGiven = calleesResult.data.reduce((sum, r) => sum + (r.avax || 0), 0); + const selfAvax = Math.max(totalAvaxReceived - totalAvaxGiven, 0); + + // Classification + let classification: 'entry_point' | 'gas_burner' | 'mixed'; + if (selfGasRatio >= 0.7) { + classification = 'gas_burner'; + } else if (selfGasRatio <= 0.2) { + classification = 'entry_point'; + } else { + classification = 'mixed'; + } + + // Build top 10 callers, aggregate rest as "Others" + function buildTopEntries(rows: TraceRow[], totalGas: number): FlowEntry[] { + const sorted = rows + .map((r) => ({ + ...enrichAddress(r.address), + gas: parseInt(r.gas) || 0, + avax: r.avax || 0, + txCount: parseInt(r.tx_count) || 0, + gasPercent: totalGas > 0 ? ((parseInt(r.gas) || 0) / totalGas) * 100 : 0, + })) + .sort((a, b) => b.gas - a.gas); + + if (sorted.length <= 10) return sorted; + + const top = sorted.slice(0, 10); + const rest = sorted.slice(10); + const othersGas = rest.reduce((s, r) => s + r.gas, 0); + const othersAvax = rest.reduce((s, r) => s + r.avax, 0); + const othersTx = rest.reduce((s, r) => s + r.txCount, 0); + + top.push({ + address: 'others', + name: `Others (${rest.length})`, + protocol: null, + category: null, + gas: othersGas, + avax: othersAvax, + txCount: othersTx, + gasPercent: totalGas > 0 ? (othersGas / totalGas) * 100 : 0, }); - } catch (error) { - console.error('Error fetching contract gas flow:', error); - return NextResponse.json( - { error: 'Failed to fetch contract gas flow' }, - { status: 500 } - ); + + return top; } -} + + const callers = buildTopEntries(callersResult.data, totalGasReceived); + const callees = buildTopEntries(calleesResult.data, totalGasGiven); + + const summaryData = summaryResult.data[0]; + + const response = { + target: enrichAddress(address), + classification, + selfGasRatio, + summary: { + totalGasReceived, + totalGasGiven, + selfGas, + totalAvaxReceived, + totalAvaxGiven, + selfAvax, + totalTransactions: parseInt(summaryData?.total_txs) || 0, + uniqueCallers: parseInt(summaryData?.unique_callers) || 0, + }, + callers, + callees, + timeRange: days > 0 ? `${days}d` : 'all', + }; + + return successResponse(response); +}); diff --git a/app/api/devnet-faucet/balance/route.ts b/app/api/devnet-faucet/balance/route.ts index 507364ff9ef..8637eaab05c 100644 --- a/app/api/devnet-faucet/balance/route.ts +++ b/app/api/devnet-faucet/balance/route.ts @@ -1,10 +1,8 @@ -import { NextResponse } from 'next/server'; import { createPublicClient, http, defineChain, formatEther } from 'viem'; -import { getAuthSession } from '@/lib/auth/authSession'; +import { withApi, successResponse, ForbiddenError, InternalError } from '@/lib/api'; const DEVNET_RPC_URL = 'https://api.avax-dev.network/ext/bc/C/rpc'; const DEVNET_CHAIN_ID = 43117; -const FAUCET_ADDRESS = process.env.FAUCET_C_CHAIN_ADDRESS; const devnetCChain = defineChain({ id: DEVNET_CHAIN_ID, @@ -15,29 +13,17 @@ const devnetCChain = defineChain({ }, }); -export async function GET(): Promise { - try { - const session = await getAuthSession(); - if (!session?.user?.id) { - return NextResponse.json( - { success: false, message: 'Authentication required' }, - { status: 401 } - ); - } +export const GET = withApi( + async (_req, { session }) => { + const FAUCET_ADDRESS = process.env.FAUCET_C_CHAIN_ADDRESS; - const email = session.user.email || ''; + const email = session.user?.email || ''; if (!email.endsWith('@avalabs.org')) { - return NextResponse.json( - { success: false, message: 'Restricted to @avalabs.org accounts' }, - { status: 403 } - ); + throw new ForbiddenError('Restricted to @avalabs.org accounts'); } if (!FAUCET_ADDRESS) { - return NextResponse.json( - { success: false, message: 'Faucet not configured' }, - { status: 500 } - ); + throw new InternalError('Faucet not configured'); } const publicClient = createPublicClient({ @@ -51,16 +37,7 @@ export async function GET(): Promise { const balance = formatEther(balanceWei); - return NextResponse.json({ - success: true, - balance, - address: FAUCET_ADDRESS, - }); - } catch (error) { - console.error('Devnet faucet balance error:', error); - return NextResponse.json( - { success: false, message: 'Failed to fetch balance' }, - { status: 500 } - ); - } -} + return successResponse({ balance, address: FAUCET_ADDRESS }); + }, + { auth: true }, +); diff --git a/app/api/devnet-faucet/route.ts b/app/api/devnet-faucet/route.ts index 34c20bbb703..89c47ce4615 100644 --- a/app/api/devnet-faucet/route.ts +++ b/app/api/devnet-faucet/route.ts @@ -1,15 +1,21 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { createWalletClient, http, parseEther, createPublicClient, defineChain, isAddress } from 'viem'; +import type { NextRequest } from 'next/server'; +import { createWalletClient, http, parseEther, createPublicClient, defineChain } from 'viem'; import { privateKeyToAccount } from 'viem/accounts'; -import { getAuthSession } from '@/lib/auth/authSession'; +import { z } from 'zod'; +import { + withApi, + successResponse, + BadRequestError, + ForbiddenError, + InternalError, + EVM_ADDRESS_REGEX, + validateQuery, +} from '@/lib/api'; const DEVNET_RPC_URL = 'https://api.avax-dev.network/ext/bc/C/rpc'; const DEVNET_CHAIN_ID = 43117; const DRIP_AMOUNT = '2'; -const SERVER_PRIVATE_KEY = process.env.FAUCET_C_CHAIN_PRIVATE_KEY; -const FAUCET_ADDRESS = process.env.FAUCET_C_CHAIN_ADDRESS; - const devnetCChain = defineChain({ id: DEVNET_CHAIN_ID, name: 'Avalanche Devnet C-Chain', @@ -19,49 +25,35 @@ const devnetCChain = defineChain({ }, }); -const account = SERVER_PRIVATE_KEY ? privateKeyToAccount(SERVER_PRIVATE_KEY as `0x${string}`) : null; +const querySchema = z.object({ + address: z + .string() + .min(1, 'Valid EVM address is required') + .regex(EVM_ADDRESS_REGEX, { message: 'Valid EVM address is required' }), +}); -export async function GET(request: NextRequest): Promise { - try { - const session = await getAuthSession(); - if (!session?.user?.id) { - return NextResponse.json( - { success: false, message: 'Authentication required' }, - { status: 401 } - ); - } +export const GET = withApi( + async (req: NextRequest, { session }) => { + const SERVER_PRIVATE_KEY = process.env.FAUCET_C_CHAIN_PRIVATE_KEY; + const FAUCET_ADDRESS = process.env.FAUCET_C_CHAIN_ADDRESS; - // Check @avalabs.org email - const email = session.user.email || ''; + const email = session.user?.email || ''; if (!email.endsWith('@avalabs.org')) { - return NextResponse.json( - { success: false, message: 'Devnet faucet is restricted to @avalabs.org accounts' }, - { status: 403 } - ); + throw new ForbiddenError('Devnet faucet is restricted to @avalabs.org accounts'); } - if (!SERVER_PRIVATE_KEY || !FAUCET_ADDRESS || !account) { - return NextResponse.json( - { success: false, message: 'Faucet not configured' }, - { status: 500 } - ); + if (!SERVER_PRIVATE_KEY || !FAUCET_ADDRESS) { + throw new InternalError('Faucet not configured'); } - const destinationAddress = request.nextUrl.searchParams.get('address'); - if (!destinationAddress || !isAddress(destinationAddress)) { - return NextResponse.json( - { success: false, message: 'Valid EVM address is required' }, - { status: 400 } - ); - } + const { address: destinationAddress } = validateQuery(req, querySchema); if (destinationAddress.toLowerCase() === FAUCET_ADDRESS.toLowerCase()) { - return NextResponse.json( - { success: false, message: 'Cannot send tokens to the faucet address' }, - { status: 400 } - ); + throw new BadRequestError('Cannot send tokens to the faucet address'); } + const account = privateKeyToAccount(SERVER_PRIVATE_KEY as `0x${string}`); + const walletClient = createWalletClient({ account, chain: devnetCChain, @@ -73,44 +65,38 @@ export async function GET(request: NextRequest): Promise { transport: http(DEVNET_RPC_URL), }); - // Check faucet balance const balance = await publicClient.getBalance({ address: FAUCET_ADDRESS as `0x${string}` }); const amountToSend = parseEther(DRIP_AMOUNT); if (balance < amountToSend) { - return NextResponse.json( - { success: false, message: 'Insufficient faucet balance on devnet' }, - { status: 500 } - ); + throw new InternalError('Insufficient faucet balance on devnet'); } - // Get the current nonce to avoid stale nonce issues const nonce = await publicClient.getTransactionCount({ address: FAUCET_ADDRESS as `0x${string}`, }); - // Simple native transfer is always 21000 gas. - // We hardcode it to skip eth_estimateGas which fails on the devnet RPC. - const txHash = await walletClient.sendTransaction({ - to: destinationAddress as `0x${string}`, - value: amountToSend, - gas: 21000n, - nonce, - }); + let txHash: string; + try { + // Simple native transfer is always 21000 gas. + // Hardcoded to skip eth_estimateGas which fails on the devnet RPC. + txHash = await walletClient.sendTransaction({ + to: destinationAddress as `0x${string}`, + value: amountToSend, + gas: 21000n, + nonce, + }); + } catch { + throw new InternalError('Faucet transaction failed'); + } - return NextResponse.json({ - success: true, + return successResponse({ txHash, sourceAddress: FAUCET_ADDRESS, destinationAddress, amount: DRIP_AMOUNT, chainId: DEVNET_CHAIN_ID, }); - } catch (error) { - console.error('Devnet faucet error:', error); - return NextResponse.json( - { success: false, message: error instanceof Error ? error.message : 'Failed to complete transfer' }, - { status: 500 } - ); - } -} + }, + { auth: true }, +); diff --git a/app/api/dune/[address]/route.ts b/app/api/dune/[address]/route.ts index 193cce79592..5c572bcf8ed 100644 --- a/app/api/dune/[address]/route.ts +++ b/app/api/dune/[address]/route.ts @@ -1,12 +1,16 @@ -import { NextRequest, NextResponse } from "next/server"; +import type { NextRequest } from 'next/server'; +import { withApi } from '@/lib/api/with-api'; +import { successResponse } from '@/lib/api/response'; +import { InternalError, ValidationError } from '@/lib/api/errors'; +import { EVM_ADDRESS_REGEX } from '@/lib/api/constants'; import l1ChainsData from '@/constants/l1-chains.json'; -import { - getCachedLabels, - setCachedLabels, +import { + getCachedLabels, + setCachedLabels, getPendingExecution, setPendingExecution, clearPendingExecution, - DuneLabel + type DuneLabel, } from '@/app/api/dune/cache'; const DUNE_QUERY_ID = '6275927'; @@ -18,89 +22,83 @@ interface DuneResponse { matchedLabels?: number; } -// Start Dune query execution async function startExecution(address: string, apiKey: string): Promise { - try { - const response = await fetch( - `https://api.dune.com/api/v1/query/${DUNE_QUERY_ID}/execute`, - { - method: 'POST', - headers: { - 'X-Dune-API-Key': apiKey, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - query_parameters: { address: address }, - performance: 'medium', - }), - } - ); + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 15_000); - if (!response.ok) { - console.warn('[Dune] Execute failed:', response.status); - return null; - } + try { + const response = await fetch(`https://api.dune.com/api/v1/query/${DUNE_QUERY_ID}/execute`, { + method: 'POST', + headers: { + 'X-Dune-API-Key': apiKey, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + query_parameters: { address }, + performance: 'medium', + }), + signal: controller.signal, + }); + if (!response.ok) return null; const data = await response.json(); return data.execution_id || null; - } catch (error) { - console.warn('[Dune] Failed to start execution:', error); + } catch { return null; + } finally { + clearTimeout(timeoutId); } } -// Check execution status -async function checkStatus(executionId: string, apiKey: string): Promise<{ isFinished: boolean; state: string } | null> { - try { - const response = await fetch( - `https://api.dune.com/api/v1/execution/${executionId}/status`, - { - headers: { 'X-Dune-API-Key': apiKey }, - } - ); +async function checkStatus( + executionId: string, + apiKey: string, +): Promise<{ isFinished: boolean; state: string } | null> { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 10_000); - if (!response.ok) { - return null; - } + try { + const response = await fetch(`https://api.dune.com/api/v1/execution/${executionId}/status`, { + headers: { 'X-Dune-API-Key': apiKey }, + signal: controller.signal, + }); + if (!response.ok) return null; const data = await response.json(); - return { - isFinished: data.is_execution_finished, - state: data.state, - }; - } catch (error) { + return { isFinished: data.is_execution_finished, state: data.state }; + } catch { return null; + } finally { + clearTimeout(timeoutId); } } -// Fetch execution results async function fetchResults(executionId: string, apiKey: string): Promise { - try { - const response = await fetch( - `https://api.dune.com/api/v1/execution/${executionId}/results`, - { - headers: { 'X-Dune-API-Key': apiKey }, - } - ); + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 15_000); - if (!response.ok) { - return null; - } + try { + const response = await fetch(`https://api.dune.com/api/v1/execution/${executionId}/results`, { + headers: { 'X-Dune-API-Key': apiKey }, + signal: controller.signal, + }); + if (!response.ok) return null; const data = await response.json(); return data.result?.rows || []; - } catch (error) { + } catch { return null; + } finally { + clearTimeout(timeoutId); } } -// Map Dune rows to DuneLabel format function mapRowsToLabels(rows: any[]): DuneLabel[] { const labels: DuneLabel[] = []; for (const row of rows) { - const matchedChain = (l1ChainsData as any[]).find(c => c.duneId === row.blockchain); + const matchedChain = (l1ChainsData as any[]).find((c) => c.duneId === row.blockchain); if (!matchedChain) continue; - + labels.push({ blockchain: row.blockchain, name: row.name, @@ -116,97 +114,77 @@ function mapRowsToLabels(rows: any[]): DuneLabel[] { return labels; } -export async function GET( - request: NextRequest, - { params }: { params: Promise<{ address: string }> } -) { +export const GET = withApi(async (_req: NextRequest, { params }) => { const duneApiKey = process.env.DUNE_API_KEY; if (!duneApiKey) { - return NextResponse.json({ error: 'Dune API key not configured' }, { status: 500 }); + throw new InternalError('Dune API key not configured'); } - try { - const { address } = await params; + const { address } = params; - // Validate address format - if (!address || !/^0x[a-fA-F0-9]{40}$/.test(address)) { - return NextResponse.json({ error: 'Invalid address format' }, { status: 400 }); - } + if (!address || !EVM_ADDRESS_REGEX.test(address)) { + throw new ValidationError('Invalid address format'); + } - const normalizedAddress = address.toLowerCase(); + const normalizedAddress = address.toLowerCase(); - // Step 1: Check cache - const cachedLabels = getCachedLabels(normalizedAddress); - if (cachedLabels) { - return NextResponse.json({ - status: 'cached', - labels: cachedLabels, - totalRows: cachedLabels.length, - matchedLabels: cachedLabels.length, - } as DuneResponse); - } + // Step 1: Check cache + const cachedLabels = getCachedLabels(normalizedAddress); + if (cachedLabels) { + return successResponse({ + status: 'cached', + labels: cachedLabels, + totalRows: cachedLabels.length, + matchedLabels: cachedLabels.length, + } as DuneResponse); + } - // Step 2: Check if there's already a pending execution - const pendingExecutionId = getPendingExecution(normalizedAddress); - - if (pendingExecutionId) { - // Check status of pending execution - const status = await checkStatus(pendingExecutionId, duneApiKey); - - if (!status) { - // Status check failed, clear pending and start fresh - clearPendingExecution(normalizedAddress); - } else if (status.isFinished) { - if (status.state === 'QUERY_STATE_COMPLETED') { - // Execution complete, fetch results - const rows = await fetchResults(pendingExecutionId, duneApiKey); - if (rows) { - const labels = mapRowsToLabels(rows); - setCachedLabels(normalizedAddress, labels); - - console.log(`[Dune] Completed for ${normalizedAddress}: ${labels.length} labels (${rows.length} total rows)`); - - return NextResponse.json({ - status: 'completed', - labels, - totalRows: rows.length, - matchedLabels: labels.length, - } as DuneResponse); - } + // Step 2: Check if there's already a pending execution + const pendingExecutionId = getPendingExecution(normalizedAddress); + + if (pendingExecutionId) { + const status = await checkStatus(pendingExecutionId, duneApiKey); + + if (!status) { + clearPendingExecution(normalizedAddress); + } else if (status.isFinished) { + if (status.state === 'QUERY_STATE_COMPLETED') { + const rows = await fetchResults(pendingExecutionId, duneApiKey); + if (rows) { + const labels = mapRowsToLabels(rows); + setCachedLabels(normalizedAddress, labels); + + return successResponse({ + status: 'completed', + labels, + totalRows: rows.length, + matchedLabels: labels.length, + } as DuneResponse); } - // Execution failed or results fetch failed - clearPendingExecution(normalizedAddress); - } else { - // Still executing, return waiting status - return NextResponse.json({ - status: 'waiting', - labels: [], - } as DuneResponse); } - } - - // Step 3: Start new execution - console.log(`[Dune] Starting execution for ${normalizedAddress}`); - const executionId = await startExecution(normalizedAddress, duneApiKey); - - if (!executionId) { - return NextResponse.json({ - status: 'failed', + clearPendingExecution(normalizedAddress); + } else { + return successResponse({ + status: 'waiting', labels: [], } as DuneResponse); } + } - // Store pending execution - setPendingExecution(normalizedAddress, executionId); + // Step 3: Start new execution + const executionId = await startExecution(normalizedAddress, duneApiKey); - // Return waiting status - UI will poll again - return NextResponse.json({ - status: 'waiting', + if (!executionId) { + return successResponse({ + status: 'failed', labels: [], } as DuneResponse); - - } catch (error) { - console.error('[Dune] Error:', error); - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); } -} + + setPendingExecution(normalizedAddress, executionId); + + return successResponse({ + status: 'waiting', + labels: [], + } as DuneResponse); +}); diff --git a/app/api/dune/cache.ts b/app/api/dune/cache.ts index 9982908ffb3..e04717403f8 100644 --- a/app/api/dune/cache.ts +++ b/app/api/dune/cache.ts @@ -27,9 +27,7 @@ interface PendingExecution { // Extend globalThis type for our cache declare global { - // eslint-disable-next-line no-var var duneLabelCache: Map | undefined; - // eslint-disable-next-line no-var var dunePendingExecutions: Map | undefined; } @@ -44,15 +42,15 @@ globalThis.dunePendingExecutions = pendingExecutions; export function getCachedLabels(address: string): DuneLabel[] | null { const key = address.toLowerCase(); const cached = labelCache.get(key); - + if (!cached) return null; - + // Check if expired if (Date.now() - cached.timestamp > CACHE_TTL) { labelCache.delete(key); return null; } - + return cached.labels; } @@ -65,22 +63,21 @@ export function setCachedLabels(address: string, labels: DuneLabel[]): void { }); // Clear any pending execution pendingExecutions.delete(key); - console.log(`[Dune] Cached ${labels.length} labels for ${address}`); } // Get pending execution for an address export function getPendingExecution(address: string): string | null { const key = address.toLowerCase(); const pending = pendingExecutions.get(key); - + if (!pending) return null; - + // Check if expired if (Date.now() - pending.timestamp > PENDING_TTL) { pendingExecutions.delete(key); return null; } - + return pending.executionId; } @@ -91,7 +88,6 @@ export function setPendingExecution(address: string, executionId: string): void executionId, timestamp: Date.now(), }); - console.log(`[Dune] Set pending execution ${executionId} for ${address}`); } // Clear pending execution for an address diff --git a/app/api/evaluate/advance-stage/route.ts b/app/api/evaluate/advance-stage/route.ts index ad24e3b0a95..55ea567eea4 100644 --- a/app/api/evaluate/advance-stage/route.ts +++ b/app/api/evaluate/advance-stage/route.ts @@ -1,52 +1,39 @@ -import { NextRequest, NextResponse } from "next/server"; -import { getAuthSession } from "@/lib/auth/authSession"; -import { prisma } from "@/prisma/prisma"; - -export async function POST(request: NextRequest) { - try { - const session = await getAuthSession(); - - if ( - !session?.user?.id || - !session.user.custom_attributes?.includes("devrel") - ) { - return NextResponse.json({ error: "Forbidden" }, { status: 401 }); - } - - const body = await request.json(); - const { formDataIds, formDataId, stage } = body as { - formDataIds?: string[]; - formDataId?: string; - stage: number; - }; - - const ids = formDataIds ?? (formDataId ? [formDataId] : []); - - if (ids.length === 0) { - return NextResponse.json( - { error: "formDataId or formDataIds required" }, - { status: 400 } - ); - } - - if (typeof stage !== "number" || !Number.isInteger(stage) || stage < 0 || stage > 4) { - return NextResponse.json( - { error: "stage must be an integer between 0 and 4" }, - { status: 400 } - ); - } +import type { NextRequest } from 'next/server'; +import { z } from 'zod'; +import { withApi } from '@/lib/api/with-api'; +import { successResponse } from '@/lib/api/response'; +import { prisma } from '@/prisma/prisma'; + +// --------------------------------------------------------------------------- +// Schema +// --------------------------------------------------------------------------- + +const advanceStageSchema = z + .object({ + formDataIds: z.array(z.string()).optional(), + formDataId: z.string().optional(), + stage: z.number().int().min(0).max(4, 'stage must be an integer between 0 and 4'), + }) + .refine((data) => (data.formDataIds && data.formDataIds.length > 0) || data.formDataId, { + message: 'formDataId or formDataIds required', + }); + +type AdvanceStageBody = z.infer; + +// --------------------------------------------------------------------------- +// POST /api/evaluate/advance-stage +// --------------------------------------------------------------------------- + +export const POST = withApi( + async (_req: NextRequest, { body }) => { + const ids = body.formDataIds ?? (body.formDataId ? [body.formDataId] : []); const result = await prisma.formData.updateMany({ where: { id: { in: ids } }, - data: { current_stage: stage }, + data: { current_stage: body.stage }, }); - return NextResponse.json({ - updated: result.count, - stage, - }); - } catch (error: unknown) { - const message = error instanceof Error ? error.message : "Unknown error"; - return NextResponse.json({ error: message }, { status: 500 }); - } -} + return successResponse({ updated: result.count, stage: body.stage }); + }, + { auth: true, roles: ['devrel'], schema: advanceStageSchema }, +); diff --git a/app/api/evaluate/final-verdict/route.ts b/app/api/evaluate/final-verdict/route.ts index 5d589978f81..34cf230ce2f 100644 --- a/app/api/evaluate/final-verdict/route.ts +++ b/app/api/evaluate/final-verdict/route.ts @@ -1,52 +1,35 @@ -import { NextRequest, NextResponse } from "next/server"; -import { getAuthSession } from "@/lib/auth/authSession"; -import { prisma } from "@/prisma/prisma"; +import type { NextRequest } from 'next/server'; +import { z } from 'zod'; +import { withApi } from '@/lib/api/with-api'; +import { successResponse } from '@/lib/api/response'; +import { prisma } from '@/prisma/prisma'; -const ALLOWED_VERDICTS = ["top", "strong", "maybe", "weak", "reject"]; +// --------------------------------------------------------------------------- +// Schema +// --------------------------------------------------------------------------- -export async function POST(request: NextRequest) { - try { - const session = await getAuthSession(); +const ALLOWED_VERDICTS = ['top', 'strong', 'maybe', 'weak', 'reject'] as const; - if ( - !session?.user?.id || - !session.user.custom_attributes?.includes("devrel") - ) { - return NextResponse.json({ error: "Forbidden" }, { status: 401 }); - } +const finalVerdictSchema = z.object({ + formDataId: z.string().min(1, 'formDataId is required'), + verdict: z.enum(ALLOWED_VERDICTS).nullable(), +}); - const body = await request.json(); - const { formDataId, verdict } = body as { - formDataId: string; - verdict: string | null; - }; +type FinalVerdictBody = z.infer; - if (!formDataId) { - return NextResponse.json( - { error: "formDataId is required" }, - { status: 400 } - ); - } - - if (verdict !== null && !ALLOWED_VERDICTS.includes(verdict)) { - return NextResponse.json( - { error: `verdict must be one of: ${ALLOWED_VERDICTS.join(", ")} or null` }, - { status: 400 } - ); - } +// --------------------------------------------------------------------------- +// POST /api/evaluate/final-verdict +// --------------------------------------------------------------------------- +export const POST = withApi( + async (_req: NextRequest, { body }) => { const updated = await prisma.formData.update({ - where: { id: formDataId }, - data: { final_verdict: verdict }, + where: { id: body.formDataId }, + data: { final_verdict: body.verdict }, select: { id: true, final_verdict: true }, }); - return NextResponse.json({ - id: updated.id, - finalVerdict: updated.final_verdict, - }); - } catch (error: unknown) { - const message = error instanceof Error ? error.message : "Unknown error"; - return NextResponse.json({ error: message }, { status: 500 }); - } -} + return successResponse({ id: updated.id, finalVerdict: updated.final_verdict }); + }, + { auth: true, roles: ['devrel'], schema: finalVerdictSchema }, +); diff --git a/app/api/evaluate/route.ts b/app/api/evaluate/route.ts index f5d78ac2634..6c57c3f93ca 100644 --- a/app/api/evaluate/route.ts +++ b/app/api/evaluate/route.ts @@ -1,111 +1,68 @@ -import { NextRequest, NextResponse } from "next/server"; -import { getAuthSession } from "@/lib/auth/authSession"; -import { prisma } from "@/prisma/prisma"; +import type { NextRequest } from 'next/server'; +import { z } from 'zod'; +import { withApi } from '@/lib/api/with-api'; +import { successResponse } from '@/lib/api/response'; +import { prisma } from '@/prisma/prisma'; -const ALLOWED_VERDICTS = ["top", "strong", "maybe", "weak", "reject"]; +// --------------------------------------------------------------------------- +// Schema +// --------------------------------------------------------------------------- -function hasJudgeAccess(attributes: string[] | undefined): boolean { - return ( - attributes?.includes("devrel") === true || - attributes?.includes("judge") === true - ); -} +const ALLOWED_VERDICTS = ['top', 'strong', 'maybe', 'weak', 'reject'] as const; -export async function POST(request: NextRequest) { - try { - const session = await getAuthSession(); +const _evaluationSchema = z.object({ + formDataId: z.string().min(1, 'formDataId is required'), + verdict: z.enum(ALLOWED_VERDICTS), + comment: z.string().optional(), + scoreOverall: z + .number() + .min(1) + .max(5) + .refine((v) => v % 0.5 === 0, 'scoreOverall must be in 0.5 increments') + .optional(), + scores: z.record(z.string(), z.number().min(1).max(5)).optional(), + stage: z.number().int().min(0).max(4).optional().default(0), +}); - if (!session?.user?.id || !hasJudgeAccess(session.user.custom_attributes)) { - return NextResponse.json({ error: "Forbidden" }, { status: 401 }); - } +type EvaluationBody = z.infer; - const body = await request.json(); - const { formDataId, verdict, comment, scoreOverall, scores, stage = 0 } = body as { - formDataId: string; - verdict: string; - comment?: string; - scoreOverall?: number; - scores?: Record; - stage?: number; - }; - - if (!formDataId || !verdict) { - return NextResponse.json( - { error: "formDataId and verdict are required" }, - { status: 400 } - ); - } - - if (typeof stage !== "number" || !Number.isInteger(stage) || stage < 0 || stage > 4) { - return NextResponse.json( - { error: "stage must be an integer between 0 and 4" }, - { status: 400 } - ); - } - - if (!ALLOWED_VERDICTS.includes(verdict)) { - return NextResponse.json( - { error: `verdict must be one of: ${ALLOWED_VERDICTS.join(", ")}` }, - { status: 400 } - ); - } - - if ( - scoreOverall !== undefined && - (typeof scoreOverall !== "number" || - scoreOverall < 1 || - scoreOverall > 5 || - scoreOverall % 0.5 !== 0) - ) { - return NextResponse.json( - { error: "scoreOverall must be between 1 and 5 in 0.5 increments" }, - { status: 400 } - ); - } - - if (scores) { - const vals = Object.values(scores); - if (vals.some((v) => typeof v !== "number" || v < 1 || v > 5)) { - return NextResponse.json( - { error: "All score values must be numbers between 1 and 5" }, - { status: 400 } - ); - } - } +// --------------------------------------------------------------------------- +// POST /api/evaluate +// --------------------------------------------------------------------------- +export const POST = withApi( + async (_req: NextRequest, { session, body }) => { const evaluation = await prisma.evaluation.upsert({ where: { form_data_id_evaluator_id_stage: { - form_data_id: formDataId, + form_data_id: body.formDataId, evaluator_id: session.user.id, - stage, + stage: body.stage, }, }, update: { - verdict, - comment: comment ?? null, - score_overall: scoreOverall ?? null, - scores: scores ?? undefined, + verdict: body.verdict, + comment: body.comment ?? null, + score_overall: body.scoreOverall ?? null, + scores: body.scores ?? undefined, }, create: { - form_data_id: formDataId, + form_data_id: body.formDataId, evaluator_id: session.user.id, - stage, - verdict, - comment: comment ?? null, - score_overall: scoreOverall ?? null, - scores: scores ? (scores as Record) : undefined, + stage: body.stage, + verdict: body.verdict, + comment: body.comment ?? null, + score_overall: body.scoreOverall ?? null, + scores: body.scores ?? undefined, }, }); - return NextResponse.json({ + return successResponse({ id: evaluation.id, verdict: evaluation.verdict, comment: evaluation.comment, stage: evaluation.stage, }); - } catch (error: unknown) { - const message = error instanceof Error ? error.message : "Unknown error"; - return NextResponse.json({ error: message }, { status: 500 }); - } -} + }, + { auth: true, roles: ['judge', 'devrel'], schema: _evaluationSchema }, +); diff --git a/app/api/evaluate/submissions/route.ts b/app/api/evaluate/submissions/route.ts index 706a6de8850..d50616c2650 100644 --- a/app/api/evaluate/submissions/route.ts +++ b/app/api/evaluate/submissions/route.ts @@ -1,34 +1,29 @@ -import { NextRequest, NextResponse } from "next/server"; -import { getAuthSession } from "@/lib/auth/authSession"; -import { prisma } from "@/prisma/prisma"; +import type { NextRequest } from 'next/server'; +import { withApi } from '@/lib/api/with-api'; +import { successResponse } from '@/lib/api/response'; +import { prisma } from '@/prisma/prisma'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- function computeStageProgress(origin: string, data: Record): number { - if (origin !== "build_games") return 0; + if (origin !== 'build_games') return 0; const hasData = (keys: string[]) => keys.some((k) => data[k] && String(data[k]).trim()); - if (hasData(["game_metrics", "game_vision"])) return 4; - if (hasData(["game_acquisition", "game_community", "game_monetization"])) return 3; - if (hasData(["game_playable_state", "game_smart_contracts", "game_onboarding"])) return 2; - if (hasData(["game_type", "problem_statement", "proposed_solution", "architecture_overview"])) return 1; + if (hasData(['game_metrics', 'game_vision'])) return 4; + if (hasData(['game_acquisition', 'game_community', 'game_monetization'])) return 3; + if (hasData(['game_playable_state', 'game_smart_contracts', 'game_onboarding'])) return 2; + if (hasData(['game_type', 'problem_statement', 'proposed_solution', 'architecture_overview'])) return 1; return 0; } -function hasJudgeAccess(attributes: string[] | undefined): boolean { - return ( - attributes?.includes("devrel") === true || - attributes?.includes("judge") === true - ); -} - -export async function GET(request: NextRequest) { - try { - const session = await getAuthSession(); +// --------------------------------------------------------------------------- +// GET /api/evaluate/submissions +// --------------------------------------------------------------------------- - if (!session?.user?.id || !hasJudgeAccess(session.user.custom_attributes)) { - return NextResponse.json({ error: "Forbidden" }, { status: 401 }); - } - - const { searchParams } = new URL(request.url); - const hackathonId = searchParams.get("hackathonId"); +export const GET = withApi( + async (req: NextRequest) => { + const hackathonId = req.nextUrl.searchParams.get('hackathonId'); const where: Record = {}; if (hackathonId) { @@ -54,14 +49,14 @@ export async function GET(request: NextRequest) { include: { evaluator: { select: { id: true, name: true } }, }, - orderBy: { created_at: "desc" }, + orderBy: { created_at: 'desc' }, }, }, - orderBy: { timestamp: "desc" }, + orderBy: { timestamp: 'desc' }, }); const submissions = formDataRecords.map((fd) => { - const lead = fd.project.members.find((m) => m.role === "Lead") ?? fd.project.members[0]; + const lead = fd.project.members.find((m) => m.role === 'Lead') ?? fd.project.members[0]; const leadUser = lead?.user; const rawFormData = fd.form_data as Record; @@ -70,26 +65,25 @@ export async function GET(request: NextRequest) { const areaOfFocus = (applicantData?.area_of_focus as string) ?? null; const stageProgress = computeStageProgress(fd.origin, bgFormData); - const applicationData = applicantData ?? null; const applicantName = applicantData - ? `${applicantData.first_name ?? ""} ${applicantData.last_name ?? ""}`.trim() + ? `${applicantData.first_name ?? ''} ${applicantData.last_name ?? ''}`.trim() : null; return { formDataId: fd.id, projectId: fd.project_id, - projectName: fd.project.project_name || (applicantData?.project_name as string) || "", + projectName: fd.project.project_name || (applicantData?.project_name as string) || '', shortDescription: fd.project.short_description, - hackathonId: fd.project.hackaton_id ?? "", - hackathonTitle: fd.project.hackathon?.title ?? "Unknown", + hackathonId: fd.project.hackaton_id ?? '', + hackathonTitle: fd.project.hackathon?.title ?? 'Unknown', origin: fd.origin, formData: fd.form_data as Record, finalVerdict: fd.final_verdict, - applicantName: leadUser?.name ?? applicantName ?? "Unknown", - applicantEmail: leadUser?.email ?? (applicantData?.email as string) ?? lead?.email ?? "", - country: leadUser?.country ?? (applicantData?.country as string) ?? "", + applicantName: leadUser?.name ?? applicantName ?? 'Unknown', + applicantEmail: leadUser?.email ?? (applicantData?.email as string) ?? lead?.email ?? '', + country: leadUser?.country ?? (applicantData?.country as string) ?? '', telegram: leadUser?.telegram_user ?? (applicantData?.telegram as string) ?? null, github: leadUser?.github ?? (applicantData?.github as string) ?? null, areaOfFocus, @@ -99,18 +93,18 @@ export async function GET(request: NextRequest) { id: fd.project.id, projectName: fd.project.project_name, shortDescription: fd.project.short_description, - fullDescription: fd.project.full_description ?? "", - techStack: fd.project.tech_stack ?? "", - githubRepository: fd.project.github_repository ?? "", - demoLink: fd.project.demo_link ?? "", - demoVideoLink: fd.project.demo_video_link ?? "", + fullDescription: fd.project.full_description ?? '', + techStack: fd.project.tech_stack ?? '', + githubRepository: fd.project.github_repository ?? '', + demoLink: fd.project.demo_link ?? '', + demoVideoLink: fd.project.demo_video_link ?? '', tracks: fd.project.tracks, categories: fd.project.categories, isPreexistingIdea: fd.project.is_preexisting_idea, createdAt: fd.project.created_at.toISOString(), members: fd.project.members.map((m) => ({ id: m.id, - email: m.email ?? m.user?.email ?? "", + email: m.email ?? m.user?.email ?? '', role: m.role, status: m.status, })), @@ -120,7 +114,7 @@ export async function GET(request: NextRequest) { id: e.id, formDataId: e.form_data_id, evaluatorId: e.evaluator_id, - evaluatorName: e.evaluator.name ?? "Unknown", + evaluatorName: e.evaluator.name ?? 'Unknown', verdict: e.verdict, comment: e.comment, scoreOverall: e.score_overall, @@ -131,9 +125,7 @@ export async function GET(request: NextRequest) { }; }); - return NextResponse.json({ submissions }); - } catch (error: unknown) { - const message = error instanceof Error ? error.message : "Unknown error"; - return NextResponse.json({ error: message }, { status: 500 }); - } -} + return successResponse(submissions); + }, + { auth: true, roles: ['judge', 'devrel'] }, +); diff --git a/app/api/event-registration/route.ts b/app/api/event-registration/route.ts index d1347590062..65d57ddf2c9 100644 --- a/app/api/event-registration/route.ts +++ b/app/api/event-registration/route.ts @@ -4,31 +4,28 @@ const HUBSPOT_API_KEY = process.env.HUBSPOT_API_KEY; const HUBSPOT_PORTAL_ID = process.env.HUBSPOT_PORTAL_ID; const HUBSPOT_HACKATHON_FORM_GUID = process.env.HUBSPOT_HACKATHON_FORM_GUID; +// withApi: auth intentionally omitted — public registration form submission export async function POST(request: Request) { try { if (!HUBSPOT_API_KEY || !HUBSPOT_PORTAL_ID || !HUBSPOT_HACKATHON_FORM_GUID) { - console.error('Missing environment variables: HUBSPOT_API_KEY, HUBSPOT_PORTAL_ID, or HUBSPOT_HACKATHON_FORM_GUID'); - return NextResponse.json( - { success: false, message: 'Server configuration error' }, - { status: 500 } + console.error( + 'Missing environment variables: HUBSPOT_API_KEY, HUBSPOT_PORTAL_ID, or HUBSPOT_HACKATHON_FORM_GUID', ); + return NextResponse.json({ success: false, message: 'Server configuration error' }, { status: 500 }); } const clonedRequest = request.clone(); let formData; try { formData = await clonedRequest.json(); - } catch (error) { - console.error('Error parsing request body:', error); - return NextResponse.json( - { success: false, message: 'Invalid request body' }, - { status: 400 } - ); + } catch (err) { + console.error('Error parsing request body:', err); + return NextResponse.json({ success: false, message: 'Invalid request body' }, { status: 400 }); } - + // Process the form data for HubSpot const processedFormData: Record = {}; - + // Map standard fields directly Object.entries(formData).forEach(([key, value]) => { if (['fullname', 'email', 'gdpr', 'marketing_consent'].includes(key)) { @@ -38,38 +35,47 @@ export async function POST(request: Request) { processedFormData[`hackathon_${key}`] = value; } }); - + // Map specific hackathon fields - processedFormData["fullname"] = formData.name || "N/A"; - processedFormData["country_dropdown"] = formData.city || "N/A"; // use as country TODO: Rename city variable in dB - processedFormData["hs_role"] = formData.role || "N/A"; - processedFormData["name"] = formData.company_name || ""; // To check if "name" is correct in HS form - processedFormData["telegram_handle"] = formData.telegram_user || ""; - processedFormData["github_url"] = formData.github_portfolio || ""; - processedFormData["hackathon_interests"] = Array.isArray(formData.interests) ? formData.interests.join(";") : formData.interests || ""; - processedFormData["programming_language_familiarity"] = Array.isArray(formData.languages) ? formData.languages.join(";") : formData.languages || ""; - processedFormData["employment_role_other"] = Array.isArray(formData.roles) ? formData.roles.join(";") : formData.roles || ""; - processedFormData["tooling_familiarity"] = Array.isArray(formData.tools) ? formData.tools.join(";") : formData.tools || ""; + processedFormData['fullname'] = formData.name || 'N/A'; + processedFormData['country_dropdown'] = formData.city || 'N/A'; // use as country TODO: Rename city variable in dB + processedFormData['hs_role'] = formData.role || 'N/A'; + processedFormData['name'] = formData.company_name || ''; // To check if "name" is correct in HS form + processedFormData['telegram_handle'] = formData.telegram_user || ''; + processedFormData['github_url'] = formData.github_portfolio || ''; + processedFormData['hackathon_interests'] = Array.isArray(formData.interests) + ? formData.interests.join(';') + : formData.interests || ''; + processedFormData['programming_language_familiarity'] = Array.isArray(formData.languages) + ? formData.languages.join(';') + : formData.languages || ''; + processedFormData['employment_role_other'] = Array.isArray(formData.roles) + ? formData.roles.join(';') + : formData.roles || ''; + processedFormData['tooling_familiarity'] = Array.isArray(formData.tools) + ? formData.tools.join(';') + : formData.tools || ''; //processedFormData["hackathon_event_id"] = formData.hackathon_id || ""; - processedFormData["founder_check"] = formData.founder_check ? "Yes" : "No"; - processedFormData["avalanche_ecosystem_member"] = formData.avalanche_ecosystem_member ? "Yes" : "No"; + processedFormData['founder_check'] = formData.founder_check ? 'Yes' : 'No'; + processedFormData['avalanche_ecosystem_member'] = formData.avalanche_ecosystem_member ? 'Yes' : 'No'; // Map boolean fields - processedFormData["marketing_consent"] = formData.newsletter_subscription === true ? "Yes" : "No"; - processedFormData["gdpr"] = formData.terms_event_conditions === true ? "Yes" : "No"; - + processedFormData['marketing_consent'] = formData.newsletter_subscription === true ? 'Yes' : 'No'; + processedFormData['gdpr'] = formData.terms_event_conditions === true ? 'Yes' : 'No'; + // Build HubSpot payload fields const fields: { name: string; value: string | boolean }[] = []; Object.entries(processedFormData).forEach(([name, value]) => { if (value === undefined || value === null || value === '') { return; } - - let formattedValue: string | boolean = typeof value === 'string' || typeof value === 'boolean' ? value : String(value); - + + let formattedValue: string | boolean = + typeof value === 'string' || typeof value === 'boolean' ? value : String(value); + fields.push({ name: name, - value: formattedValue + value: formattedValue, }); }); @@ -91,13 +97,13 @@ export async function POST(request: Request) { }; }; } - + const hubspotPayload: HubspotPayload = { fields: fields, context: { pageUri: request.headers.get('referer') || 'https://build.avax.network', - pageName: 'Hackathon Registration' - } + pageName: 'Hackathon Registration', + }, }; // Add legal consent if GDPR is agreed to @@ -105,40 +111,39 @@ export async function POST(request: Request) { hubspotPayload.legalConsentOptions = { consent: { consentToProcess: true, - text: "I agree to allow Avalanche Foundation to store and process my personal data for hackathon participation purposes.", + text: 'I agree to allow Avalanche Foundation to store and process my personal data for hackathon participation purposes.', communications: [ { value: formData.marketing_consent === true, subscriptionTypeId: 999, - text: "I would like to receive marketing emails from the Avalanche Foundation." - } - ] - } + text: 'I would like to receive marketing emails from the Avalanche Foundation.', + }, + ], + }, }; } - - + const hubspotResponse = await fetch( `https://api.hsforms.com/submissions/v3/integration/submit/${HUBSPOT_PORTAL_ID}/${HUBSPOT_HACKATHON_FORM_GUID}`, { method: 'POST', headers: { 'Content-Type': 'application/json', - 'Authorization': `Bearer ${HUBSPOT_API_KEY}` + Authorization: `Bearer ${HUBSPOT_API_KEY}`, }, - body: JSON.stringify(hubspotPayload) - } + body: JSON.stringify(hubspotPayload), + }, ); const responseStatus = hubspotResponse.status; let hubspotResult; try { hubspotResult = await hubspotResponse.json(); - } catch (error) { + } catch { try { const text = await hubspotResponse.text(); hubspotResult = { status: 'error', message: text }; - } catch (textError) { + } catch { hubspotResult = { status: 'error', message: 'Could not read HubSpot response' }; } } @@ -147,21 +152,20 @@ export async function POST(request: Request) { throw new Error(`HubSpot API error: ${responseStatus} - ${JSON.stringify(hubspotResult)}`); } - return NextResponse.json({ - success: true, + return NextResponse.json({ + success: true, message: 'Hackathon registration sent to HubSpot successfully', - response: hubspotResult + response: hubspotResult, }); - - } catch (error) { - console.error('Error in hackathon-registration route:', error); + } catch (err) { + console.error('Error in hackathon-registration route:', err); return NextResponse.json( - { - success: false, + { + success: false, message: 'Failed to send registration to HubSpot', - error: error instanceof Error ? error.message : 'Unknown error' + error: err instanceof Error ? err.message : 'Unknown error', }, - { status: 500 } + { status: 500 }, ); } -} \ No newline at end of file +} diff --git a/app/api/events/[id]/route.ts b/app/api/events/[id]/route.ts index 3e131a0815d..7fdb90f55a4 100644 --- a/app/api/events/[id]/route.ts +++ b/app/api/events/[id]/route.ts @@ -1,25 +1,22 @@ -import { NextRequest, NextResponse } from "next/server"; -import { getHackathon, updateHackathon } from "@/server/services/hackathons"; -import { HackathonHeader } from "@/types/hackathons"; -import { withAuthRole } from "@/lib/protectedRoute"; +// withApi: not applicable — uses withAuthRole() for auth +import { NextRequest, NextResponse } from 'next/server'; +import { getHackathon, updateHackathon } from '@/server/services/hackathons'; +import { HackathonHeader } from '@/types/hackathons'; +import { withAuthRole } from '@/lib/protectedRoute'; export async function GET(req: NextRequest, context: any) { - try { const { id } = await context.params; if (!id) { - return NextResponse.json({ error: "ID required" }, { status: 400 }); + return NextResponse.json({ error: 'ID required' }, { status: 400 }); } - const hackathon = await getHackathon(id) + const hackathon = await getHackathon(id); return NextResponse.json(hackathon); } catch (error) { - console.error("Error in GET /api/events/[id]:"); - return NextResponse.json( - { error: (error as Error).message }, - { status: 500 } - ); + console.error('Error in GET /api/events/[id]:'); + return NextResponse.json({ error: (error as Error).message }, { status: 500 }); } } @@ -29,7 +26,11 @@ export const PUT = withAuthRole('devrel', async (req: NextRequest, context: any, const updateData = await req.json(); const userId = session.user.id; - if (updateData.hasOwnProperty('is_public') && typeof updateData.is_public === 'boolean' && Object.keys(updateData).length === 1) { + if ( + updateData.hasOwnProperty('is_public') && + typeof updateData.is_public === 'boolean' && + Object.keys(updateData).length === 1 + ) { const updatedHackathon = await updateHackathon(id, { is_public: updateData.is_public }, userId); return NextResponse.json(updatedHackathon); } else { @@ -38,7 +39,7 @@ export const PUT = withAuthRole('devrel', async (req: NextRequest, context: any, return NextResponse.json(updatedHackathon); } } catch (error) { - console.error("Error in PUT /api/events/[id]:", error); + console.error('Error in PUT /api/events/[id]:', error); return NextResponse.json({ error: `Internal Server Error: ${error}` }, { status: 500 }); } }); @@ -53,10 +54,10 @@ export const PATCH = withAuthRole('devrel', async (req: NextRequest, context: an const updatedHackathon = await updateHackathon(id, { is_public: updateData.is_public }, userId); return NextResponse.json(updatedHackathon); } else { - return NextResponse.json({ error: "Only is_public field can be updated via PATCH" }, { status: 400 }); + return NextResponse.json({ error: 'Only is_public field can be updated via PATCH' }, { status: 400 }); } } catch (error) { - console.error("Error in PATCH /api/events/[id]:", error); + console.error('Error in PATCH /api/events/[id]:', error); return NextResponse.json({ error: `Internal Server Error: ${error}` }, { status: 500 }); } }); diff --git a/app/api/events/route.ts b/app/api/events/route.ts index cbccb787b59..a636645655a 100644 --- a/app/api/events/route.ts +++ b/app/api/events/route.ts @@ -1,16 +1,11 @@ +// withApi: not applicable — uses withAuthRole() and getAuthSession() for auth import { NextRequest, NextResponse } from 'next/server'; -import { - createHackathon, - getFilteredHackathons, - GetHackathonsOptions, -} from '@/server/services/hackathons'; +import { createHackathon, getFilteredHackathons, GetHackathonsOptions } from '@/server/services/hackathons'; import { HackathonStatus } from '@/types/hackathons'; import { getUserById } from '@/server/services/getUser'; import { withAuthRole } from '@/lib/protectedRoute'; import { getAuthSession } from '@/lib/auth/authSession'; - - export async function GET(req: NextRequest) { try { const searchParams = req.nextUrl.searchParams; @@ -20,10 +15,10 @@ export async function GET(req: NextRequest) { let options: GetHackathonsOptions = { page: Number(searchParams.get('page') || 1), - pageSize: Number(searchParams.get('pageSize') || 10), + pageSize: Math.min(Number(searchParams.get('pageSize') || 10), 100), location: searchParams.get('location') || undefined, date: searchParams.get('date') || undefined, - status: searchParams.get('status') as HackathonStatus || undefined, + status: (searchParams.get('status') as HackathonStatus) || undefined, search: searchParams.get('search') || undefined, event: searchParams.get('event') || undefined, }; @@ -32,14 +27,14 @@ export async function GET(req: NextRequest) { // Get user from database to validate permissions const user = await getUserById(userId); if (!user) { - return NextResponse.json({ error: "User not found" }, { status: 404 }); + return NextResponse.json({ error: 'User not found' }, { status: 404 }); } // Check user's custom_attributes for permissions const customAttributes = user.custom_attributes || []; - const isDevrel = customAttributes.includes("devrel"); - const isTeam1Admin = customAttributes.includes("team1-admin"); - const isHackathonCreator = customAttributes.includes("hackathonCreator"); + const isDevrel = customAttributes.includes('devrel'); + const isTeam1Admin = customAttributes.includes('team1-admin'); + const isHackathonCreator = customAttributes.includes('hackathonCreator'); // If user is devrel, show all hackathons; otherwise filter by user ID const createdByFilter = isDevrel ? undefined : userId; @@ -51,10 +46,10 @@ export async function GET(req: NextRequest) { } options.include_private = isDevrel || isTeam1Admin || isHackathonCreator; // These roles can see private hackathons - console.log('API GET /events:', { userId, isDevrel, isTeam1Admin, isHackathonCreator, createdByFilter, options }); + // logging removed } else { options.include_private = false; - console.log('API GET /events (no userId):', { options }); + // logging removed } const response = await getFilteredHackathons(options); @@ -65,26 +60,20 @@ export async function GET(req: NextRequest) { const wrappedError = error as Error; return NextResponse.json( { error: wrappedError.message }, - { status: wrappedError.cause == 'BadRequest' ? 400 : 500 } + { status: wrappedError.cause == 'BadRequest' ? 400 : 500 }, ); } } -export const POST = withAuthRole('devrel', async (req: NextRequest, context: any, session: any) => { +export const POST = withAuthRole('devrel', async (req: NextRequest, _context: any, _session: any) => { try { const body = await req.json(); const newHackathon = await createHackathon(body); - return NextResponse.json( - { message: 'Hackathon created', hackathon: newHackathon }, - { status: 201 } - ); + return NextResponse.json({ message: 'Hackathon created', hackathon: newHackathon }, { status: 201 }); } catch (error: any) { console.error('Error POST /api/events:', error.message); const wrappedError = error as Error; - return NextResponse.json( - { error: wrappedError }, - { status: wrappedError.cause == 'ValidationError' ? 400 : 500 } - ); + return NextResponse.json({ error: wrappedError }, { status: wrappedError.cause == 'ValidationError' ? 400 : 500 }); } }); diff --git a/app/api/evm-chain-faucet/route.ts b/app/api/evm-chain-faucet/route.ts index 60a7f91ea8f..68d52fbdb94 100644 --- a/app/api/evm-chain-faucet/route.ts +++ b/app/api/evm-chain-faucet/route.ts @@ -1,26 +1,42 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { createWalletClient, http, parseEther, createPublicClient, defineChain, isAddress } from 'viem'; +import type { NextRequest } from 'next/server'; +import { createWalletClient, http, parseEther, createPublicClient, defineChain } from 'viem'; import { privateKeyToAccount } from 'viem/accounts'; import { avalancheFuji } from 'viem/chains'; -import { getAuthSession } from '@/lib/auth/authSession'; +import { z } from 'zod'; +import { + withApi, + successResponse, + BadRequestError, + InternalError, + RateLimitError, + EVM_ADDRESS_REGEX, + validateQuery, +} from '@/lib/api'; import { checkAndReserveFaucetClaim, completeFaucetClaim, cancelFaucetClaim } from '@/lib/faucet/rateLimit'; import { withChainLock, getNextNonce, withNonceRetry } from '@/lib/faucet/nonceManager'; import { getL1ListStore, type L1ListItem } from '@/components/toolbox/stores/l1ListStore'; import { checkAndAwardConsoleBadges } from '@/server/services/consoleBadge/consoleBadgeService'; import type { AwardedConsoleBadge } from '@/server/services/consoleBadge/types'; -const SERVER_PRIVATE_KEY = process.env.FAUCET_C_CHAIN_PRIVATE_KEY; -const FAUCET_ADDRESS = process.env.FAUCET_C_CHAIN_ADDRESS; +const RPC_TIMEOUT_MS = 30_000; -if (!SERVER_PRIVATE_KEY || !FAUCET_ADDRESS) { - console.error('necessary environment variables for EVM chain faucets are not set'); -} +const querySchema = z.object({ + address: z + .string() + .min(1, 'Destination address is required') + .regex(EVM_ADDRESS_REGEX, { message: 'Invalid Ethereum address format' }), + chainId: z + .string() + .min(1, 'Chain ID is required') + .regex(/^\d+$/, { message: 'Invalid chain ID format' }) + .transform(Number), +}); function findSupportedChain(chainId: number): L1ListItem | undefined { const testnetStore = getL1ListStore(true); - return testnetStore.getState().l1List.find( - (chain: L1ListItem) => chain.evmChainId === chainId && chain.hasBuilderHubFaucet - ); + return testnetStore + .getState() + .l1List.find((chain: L1ListItem) => chain.evmChainId === chainId && chain.hasBuilderHubFaucet); } function createViemChain(l1Data: L1ListItem) { @@ -39,184 +55,135 @@ function createViemChain(l1Data: L1ListItem) { rpcUrls: { default: { http: [l1Data.rpcUrl] }, }, - blockExplorers: l1Data.explorerUrl ? { - default: { name: 'Explorer', url: l1Data.explorerUrl }, - } : undefined, + blockExplorers: l1Data.explorerUrl ? { default: { name: 'Explorer', url: l1Data.explorerUrl } } : undefined, }); } -const account = SERVER_PRIVATE_KEY ? privateKeyToAccount(SERVER_PRIVATE_KEY as `0x${string}`) : null; - -interface TransferResponse { - success: boolean; - txHash?: string; - sourceAddress?: string; - destinationAddress?: string; - amount?: string; - chainId?: number; - message?: string; -} - async function transferEVMTokens( + privateKey: string, sourceAddress: string, destinationAddress: string, chainId: number, - amount: string + amount: string, ): Promise<{ txHash: string }> { - if (!account) { - throw new Error('Wallet not initialized'); - } + const account = privateKeyToAccount(privateKey as `0x${string}`); const l1Data = findSupportedChain(chainId); if (!l1Data) { - throw new Error(`ChainID ${chainId} is not supported by Builder Hub Faucet`); + throw new BadRequestError(`Chain ${chainId} does not support BuilderHub faucet`); } - const viemChain = createViemChain(l1Data); - const walletClient = createWalletClient({ account, chain: viemChain, transport: http() }); - const publicClient = createPublicClient({ chain: viemChain, transport: http() }); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), RPC_TIMEOUT_MS); + + try { + const viemChain = createViemChain(l1Data); + const fetchOptions = { signal: controller.signal }; + const walletClient = createWalletClient({ + account, + chain: viemChain, + transport: http(undefined, { fetchOptions }), + }); + const publicClient = createPublicClient({ + chain: viemChain, + transport: http(undefined, { fetchOptions }), + }); - const balance = await publicClient.getBalance({ address: sourceAddress as `0x${string}` }); - const amountToSend = parseEther(amount); + const balance = await publicClient.getBalance({ address: sourceAddress as `0x${string}` }); + const amountToSend = parseEther(amount); - if (balance < amountToSend) { - throw new Error(`Insufficient faucet balance on ${l1Data.name}`); - } + if (balance < amountToSend) { + throw new InternalError(`Insufficient faucet balance on ${l1Data.name}`); + } - return withChainLock(chainId, async () => { - return withNonceRetry(async () => { - const nonce = await getNextNonce(publicClient, sourceAddress as `0x${string}`); - const txHash = await walletClient.sendTransaction({ - to: destinationAddress as `0x${string}`, - value: amountToSend, - nonce, + return withChainLock(chainId, async () => { + return withNonceRetry(async () => { + const nonce = await getNextNonce(publicClient, sourceAddress as `0x${string}`); + const txHash = await walletClient.sendTransaction({ + to: destinationAddress as `0x${string}`, + value: amountToSend, + nonce, + }); + return { txHash }; }); - return { txHash }; }); - }); + } finally { + clearTimeout(timeout); + } } -export async function GET(request: NextRequest): Promise { - let claimId: string | null = null; - - try { - const session = await getAuthSession(); - if (!session?.user?.id) { - return NextResponse.json( - { success: false, message: 'Authentication required' }, - { status: 401 } - ); - } +export const GET = withApi( + async (req: NextRequest, { session }) => { + const SERVER_PRIVATE_KEY = process.env.FAUCET_C_CHAIN_PRIVATE_KEY; + const FAUCET_ADDRESS = process.env.FAUCET_C_CHAIN_ADDRESS; if (!SERVER_PRIVATE_KEY || !FAUCET_ADDRESS) { - return NextResponse.json( - { success: false, message: 'Server not properly configured' }, - { status: 500 } - ); - } - - const searchParams = request.nextUrl.searchParams; - const destinationAddress = searchParams.get('address'); - const chainIdParam = searchParams.get('chainId'); - - if (!destinationAddress) { - return NextResponse.json( - { success: false, message: 'Destination address is required' }, - { status: 400 } - ); - } - - if (!chainIdParam) { - return NextResponse.json( - { success: false, message: 'Chain ID is required' }, - { status: 400 } - ); + throw new InternalError('Server not properly configured'); } - const parsedChainId = parseInt(chainIdParam, 10); - if (isNaN(parsedChainId)) { - return NextResponse.json( - { success: false, message: 'Invalid chain ID format' }, - { status: 400 } - ); - } - - const normalizedChainId = parsedChainId.toString(); + const { address: destinationAddress, chainId: parsedChainId } = validateQuery(req, querySchema); const supportedChain = findSupportedChain(parsedChainId); if (!supportedChain) { - return NextResponse.json( - { success: false, message: `Chain ${normalizedChainId} does not support BuilderHub faucet` }, - { status: 400 } - ); - } - - if (!isAddress(destinationAddress)) { - return NextResponse.json( - { success: false, message: 'Invalid Ethereum address format' }, - { status: 400 } - ); + throw new BadRequestError(`Chain ${parsedChainId} does not support BuilderHub faucet`); } - if (destinationAddress.toLowerCase() === FAUCET_ADDRESS?.toLowerCase()) { - return NextResponse.json( - { success: false, message: 'Cannot send tokens to the faucet address' }, - { status: 400 } - ); + if (destinationAddress.toLowerCase() === FAUCET_ADDRESS.toLowerCase()) { + throw new BadRequestError('Cannot send tokens to the faucet address'); } const dripAmount = (supportedChain.faucetThresholds?.dripAmount || 3).toString(); + const normalizedChainId = parsedChainId.toString(); const reservationResult = await checkAndReserveFaucetClaim( session.user.id, 'evm', destinationAddress, dripAmount, - normalizedChainId + normalizedChainId, ); if (!reservationResult.allowed) { - return NextResponse.json( - { success: false, message: reservationResult.reason }, - { status: 429 } - ); + throw new RateLimitError(reservationResult.reason); } - claimId = reservationResult.claimId!; + const claimId = reservationResult.claimId!; - const tx = await transferEVMTokens( - FAUCET_ADDRESS, - destinationAddress, - parsedChainId, - dripAmount - ); + let txHash: string; + try { + const tx = await transferEVMTokens( + SERVER_PRIVATE_KEY, + FAUCET_ADDRESS, + destinationAddress, + parsedChainId, + dripAmount, + ); + txHash = tx.txHash; + } catch (error) { + await cancelFaucetClaim(claimId); + if (error instanceof InternalError || error instanceof BadRequestError) { + throw error; + } + throw new InternalError('Faucet transaction failed'); + } - await completeFaucetClaim(claimId, tx.txHash); + await completeFaucetClaim(claimId, txHash); let awardedBadges: AwardedConsoleBadge[] = []; - try { awardedBadges = await checkAndAwardConsoleBadges(session.user.id, 'faucet_claim'); } - catch (e) { console.error('Badge check failed:', e); } + try { + awardedBadges = await checkAndAwardConsoleBadges(session.user.id, 'faucet_claim'); + } catch { + // Badge check is non-critical; swallow failures + } - return NextResponse.json({ - success: true, - txHash: tx.txHash, + return successResponse({ + txHash, sourceAddress: FAUCET_ADDRESS, destinationAddress, amount: dripAmount, chainId: parsedChainId, awardedBadges, }); - - } catch (error) { - console.error('EVM chain faucet error:', error); - - if (claimId) { - await cancelFaucetClaim(claimId); - } - - return NextResponse.json( - { success: false, message: error instanceof Error ? error.message : 'Failed to complete transfer' }, - { status: 500 } - ); - } -} + }, + { auth: true }, +); diff --git a/app/api/explorer/[chainId]/address/[address]/erc20-balances/route.ts b/app/api/explorer/[chainId]/address/[address]/erc20-balances/route.ts index c1f9c326225..02cf8394a4e 100644 --- a/app/api/explorer/[chainId]/address/[address]/erc20-balances/route.ts +++ b/app/api/explorer/[chainId]/address/[address]/erc20-balances/route.ts @@ -1,10 +1,20 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { Avalanche } from "@avalanche-sdk/chainkit"; -import type { Erc20TokenBalance } from "@avalanche-sdk/chainkit/models/components"; +import type { NextRequest } from 'next/server'; +import { z } from 'zod'; +import { Avalanche } from '@avalanche-sdk/chainkit'; +import type { Erc20TokenBalance } from '@avalanche-sdk/chainkit/models/components'; +import { withApi } from '@/lib/api/with-api'; +import { successResponse } from '@/lib/api/response'; +import { validateParams } from '@/lib/api/validate'; +import { EVM_ADDRESS_REGEX } from '@/lib/api/constants'; + +const paramsSchema = z.object({ + chainId: z.string().regex(/^\d+$/, 'chainId must be numeric'), + address: z.string().regex(EVM_ADDRESS_REGEX, 'Invalid EVM address format'), +}); // Initialize Avalanche SDK const avalanche = new Avalanche({ - network: "mainnet", + network: 'mainnet', }); interface Erc20Balance { @@ -25,37 +35,27 @@ interface Erc20BalancesResponse { pageValueUsd: number; } -export async function GET( - request: NextRequest, - { params }: { params: Promise<{ chainId: string; address: string }> } -) { - const startTime = performance.now(); - - try { - const { chainId, address } = await params; - const { searchParams } = new URL(request.url); +export const GET = withApi( + async (req: NextRequest, { params }) => { + const { chainId, address } = validateParams(params, paramsSchema); + const { searchParams } = new URL(req.url); const pageToken = searchParams.get('pageToken') || undefined; - // Validate address format - if (!address || !/^0x[a-fA-F0-9]{40}$/.test(address)) { - return NextResponse.json({ error: 'Invalid address format' }, { status: 400 }); - } - // Fetch ERC20 balances - returns a PageIterator const iterator = await avalanche.data.evm.address.balances.listErc20({ - address: address, - chainId: chainId, + address, + chainId, currency: 'usd', filterSpamTokens: true, pageSize: 200, - pageToken: pageToken, + pageToken, }); // Get first page from the async iterator const { value: page, done } = await iterator[Symbol.asyncIterator]().next(); - + if (done || !page) { - return NextResponse.json({ + return successResponse({ balances: [], nextPageToken: undefined, pageValueUsd: 0, @@ -94,18 +94,14 @@ export async function GET( // Sort by value (highest first) within this page balances.sort((a, b) => (b.valueUsd || 0) - (a.valueUsd || 0)); - - const duration = performance.now() - startTime; - console.log(`[ERC20 Balances API] ${address} on chain ${chainId} - ${duration.toFixed(0)}ms, ${balances.length} tokens${nextPageToken ? ', has more pages' : ''}`); - return NextResponse.json({ + return successResponse({ balances, nextPageToken, pageValueUsd, } as Erc20BalancesResponse); - } catch (error) { - const duration = performance.now() - startTime; - console.error(`[ERC20 Balances API] Error after ${duration.toFixed(0)}ms:`, error); - return NextResponse.json({ error: 'Failed to fetch ERC20 balances' }, { status: 500 }); - } -} + }, + { + rateLimit: { windowMs: 60_000, maxRequests: 60, identifier: 'ip' }, + }, +); diff --git a/app/api/explorer/[chainId]/address/[address]/route.ts b/app/api/explorer/[chainId]/address/[address]/route.ts index 7034ef7ab12..0052648ef2a 100644 --- a/app/api/explorer/[chainId]/address/[address]/route.ts +++ b/app/api/explorer/[chainId]/address/[address]/route.ts @@ -1,10 +1,21 @@ -import { NextResponse } from 'next/server'; -import { Avalanche } from "@avalanche-sdk/chainkit"; +import type { NextRequest } from 'next/server'; +import { z } from 'zod'; +import { Avalanche } from '@avalanche-sdk/chainkit'; import l1ChainsData from '@/constants/l1-chains.json'; +import { withApi } from '@/lib/api/with-api'; +import { successResponse } from '@/lib/api/response'; +import { validateParams } from '@/lib/api/validate'; +import { NotFoundError } from '@/lib/api/errors'; +import { EVM_ADDRESS_REGEX } from '@/lib/api/constants'; + +const paramsSchema = z.object({ + chainId: z.string().regex(/^\d+$/, 'chainId must be numeric'), + address: z.string().regex(EVM_ADDRESS_REGEX, 'Invalid EVM address format'), +}); // Initialize Avalanche SDK const avalanche = new Avalanche({ - network: "mainnet", + network: 'mainnet', }); interface NativeTransaction { @@ -152,19 +163,22 @@ async function fetchFromRPC(rpcUrl: string, method: string, params: unknown[] = // Check if address is a contract async function isContract(rpcUrl: string, address: string): Promise { try { - const code = await fetchFromRPC(rpcUrl, 'eth_getCode', [address, 'latest']) as string; + const code = (await fetchFromRPC(rpcUrl, 'eth_getCode', [address, 'latest'])) as string; // If code is '0x' or empty, it's an EOA (Externally Owned Account) return code !== '0x' && code !== '' && code.length > 2; - } catch (error) { - console.warn('Failed to check if address is contract:', error); + } catch { return false; } } // Get native balance from RPC using eth_getBalance -async function getNativeBalance(rpcUrl: string, address: string, tokenSymbol?: string): Promise { +async function getNativeBalance( + rpcUrl: string, + address: string, + tokenSymbol?: string, +): Promise { try { - const balanceHex = await fetchFromRPC(rpcUrl, 'eth_getBalance', [address, 'latest']) as string; + const balanceHex = (await fetchFromRPC(rpcUrl, 'eth_getBalance', [address, 'latest'])) as string; const balanceWei = BigInt(balanceHex); const decimals = 18; // Native tokens typically have 18 decimals const balanceFormatted = (Number(balanceWei) / Math.pow(10, decimals)).toFixed(6); @@ -174,8 +188,7 @@ async function getNativeBalance(rpcUrl: string, address: string, tokenSymbol?: s balanceFormatted, symbol: tokenSymbol || '', }; - } catch (error) { - console.warn('Failed to fetch native balance from RPC:', error); + } catch { return { balance: '0', balanceFormatted: '0', @@ -210,21 +223,23 @@ async function getContractMetadata(address: string, chainId: string): Promise ({ - type: link.type || '', - url: link.url || '', - })) || undefined, + resourceLinks: + result.resourceLinks?.map((link) => ({ + type: link.type || '', + url: link.url || '', + })) || undefined, tags: result.tags || undefined, - deploymentDetails: result.deploymentDetails ? { - txHash: result.deploymentDetails.txHash || undefined, - deployerAddress: result.deploymentDetails.deployerAddress || undefined, - deployerContractAddress: result.deploymentDetails.deployerContractAddress || undefined, - } : undefined, + deploymentDetails: result.deploymentDetails + ? { + txHash: result.deploymentDetails.txHash || undefined, + deployerAddress: result.deploymentDetails.deployerAddress || undefined, + deployerContractAddress: result.deploymentDetails.deployerContractAddress || undefined, + } + : undefined, ercType: result.ercType || undefined, symbol, }; - } catch (error) { - console.warn('Failed to fetch contract metadata from Glacier:', error); + } catch { return undefined; } } @@ -238,20 +253,20 @@ async function getAddressChains(address: string): Promise { const chains: AddressChain[] = []; const chainList = result.indexedChains || []; - + for (const chain of chainList) { const chainId = chain.chainId || ''; const isTestnet = chain.isTestnet || false; - + // Look up chain info from l1-chains.json - const chainInfo = l1ChainsData.find(c => c.chainId === chainId); - + const chainInfo = l1ChainsData.find((c) => c.chainId === chainId); + // Build chain name with testnet suffix if needed let chainName = chain.chainName || chainInfo?.chainName || ''; if (isTestnet && !chainName.endsWith(' - Testnet')) { chainName = `${chainName} - Testnet`; } - + chains.push({ chainId, chainName, @@ -260,8 +275,7 @@ async function getAddressChains(address: string): Promise { } return chains; - } catch (error) { - console.warn('Failed to fetch address chains from Glacier:', error); + } catch { return []; } } @@ -275,11 +289,7 @@ interface TransactionResult { nextPageToken?: string; } -async function getTransactions( - address: string, - chainId: string, - pageToken?: string -): Promise { +async function getTransactions(address: string, chainId: string, pageToken?: string): Promise { try { const result = await avalanche.data.evm.address.transactions.list({ address: address, @@ -298,28 +308,26 @@ async function getTransactions( for await (const page of result) { const txDetailsList = page.result?.transactions || []; nextPageToken = page.result?.nextPageToken; - + for (const txDetails of txDetailsList) { const nativeTx = txDetails.nativeTransaction; if (!nativeTx) continue; - + const blockNumber = nativeTx.blockNumber?.toString() || ''; const timestamp = nativeTx.blockTimestamp ?? 0; const txHash = nativeTx.txHash || ''; - + // Native transaction // Clean method name - remove parameters like "mint(address)" -> "mint" let methodName = nativeTx.method?.methodName || undefined; if (methodName && methodName.includes('(')) { methodName = methodName.split('(')[0]; } - + // Use methodHash as methodId (function selector) for decoding const methodHash = nativeTx.method?.methodHash; - const methodId = methodHash && methodHash.startsWith('0x') && methodHash.length === 10 - ? methodHash - : undefined; - + const methodId = methodHash && methodHash.startsWith('0x') && methodHash.length === 10 ? methodHash : undefined; + transactions.push({ hash: txHash, blockNumber, @@ -337,7 +345,7 @@ async function getTransactions( method: methodName, methodId: methodId, }); - + // ERC20 transfers if (txDetails.erc20Transfers) { for (const transfer of txDetails.erc20Transfers) { @@ -357,7 +365,7 @@ async function getTransactions( }); } } - + // ERC721 transfers (NFT) if (txDetails.erc721Transfers) { for (const transfer of txDetails.erc721Transfers) { @@ -376,7 +384,7 @@ async function getTransactions( }); } } - + // ERC1155 transfers (NFT) if (txDetails.erc1155Transfers) { for (const transfer of txDetails.erc1155Transfers) { @@ -396,7 +404,7 @@ async function getTransactions( }); } } - + // Internal transactions if (txDetails.internalTransactions) { for (const internalTx of txDetails.internalTransactions) { @@ -420,83 +428,44 @@ async function getTransactions( } return { transactions, erc20Transfers, nftTransfers, internalTransactions, nextPageToken }; - } catch (error) { - console.warn('Failed to fetch transactions from Glacier:', error); + } catch { return { transactions: [], erc20Transfers: [], nftTransfers: [], internalTransactions: [] }; } } -// Helper to track timing -async function timed(name: string, fn: () => Promise): Promise<{ result: T; duration: number }> { - const start = performance.now(); - const result = await fn(); - const duration = performance.now() - start; - return { result, duration }; -} +export const GET = withApi( + async (req: NextRequest, { params }) => { + const { chainId, address: rawAddress } = validateParams(params, paramsSchema); -export async function GET( - request: Request, - { params }: { params: Promise<{ chainId: string; address: string }> } -) { - const totalStart = performance.now(); - const { chainId, address: rawAddress } = await params; - - // Get query params - const { searchParams } = new URL(request.url); - const pageToken = searchParams.get('pageToken') || undefined; - const customRpcUrl = searchParams.get('rpcUrl'); - const customTokenSymbol = searchParams.get('tokenSymbol'); - - // Validate and normalize address - const address = rawAddress.toLowerCase(); - if (!/^0x[a-fA-F0-9]{40}$/.test(rawAddress)) { - return NextResponse.json({ error: 'Invalid address format' }, { status: 400 }); - } + // Get query params + const { searchParams } = new URL(req.url); + const pageToken = searchParams.get('pageToken') || undefined; + const customRpcUrl = searchParams.get('rpcUrl'); + const customTokenSymbol = searchParams.get('tokenSymbol'); - const chain = l1ChainsData.find(c => c.chainId === chainId) as any; - const rpcUrl = chain?.rpcUrl || customRpcUrl; - const tokenSymbol = chain?.networkToken?.symbol || customTokenSymbol || undefined; - - if (!rpcUrl) { - return NextResponse.json({ error: 'Chain not found or RPC URL missing. Provide rpcUrl query parameter for custom chains.' }, { status: 404 }); - } + // Normalize address + const address = rawAddress.toLowerCase(); - try { - const timings: Record = {}; - - // Fetch all data in parallel with timing - // Note: ERC20 balances are fetched separately via /erc20-balances endpoint - // Note: Dune labels are fetched separately via /api/dune/[address] endpoint - const [ - isContractTimed, - nativeBalanceTimed, - txResultTimed, - addressChainsTimed, - ] = await Promise.all([ - timed('isContract', () => isContract(rpcUrl, address)), - timed('nativeBalance', () => getNativeBalance(rpcUrl, address, tokenSymbol)), - timed('transactions', () => getTransactions(address, chainId, pageToken)), - timed('addressChains', () => getAddressChains(address)), - ]); + const chain = l1ChainsData.find((c) => c.chainId === chainId) as any; + const rpcUrl = chain?.rpcUrl || customRpcUrl; + const tokenSymbol = chain?.networkToken?.symbol || customTokenSymbol || undefined; - // Store timings - timings.isContract = isContractTimed.duration; - timings.nativeBalance = nativeBalanceTimed.duration; - timings.transactions = txResultTimed.duration; - timings.addressChains = addressChainsTimed.duration; + if (!rpcUrl) { + throw new NotFoundError('Chain not found or RPC URL missing. Provide rpcUrl query parameter for custom chains.'); + } - // Extract results - const isContractResult = isContractTimed.result; - const nativeBalance = nativeBalanceTimed.result; - const txResult = txResultTimed.result; - const addressChains = addressChainsTimed.result; + // Fetch all data in parallel + const [isContractResult, nativeBalance, txResult, addressChains] = await Promise.all([ + isContract(rpcUrl, address), + getNativeBalance(rpcUrl, address, tokenSymbol), + getTransactions(address, chainId, pageToken), + getAddressChains(address), + ]); // Fetch contract metadata if it's a contract let contractMetadata: ContractMetadata | undefined; if (isContractResult) { - const metadataTimed = await timed('contractMetadata', () => getContractMetadata(address, chainId)); - contractMetadata = metadataTimed.result; - timings.contractMetadata = metadataTimed.duration; + contractMetadata = await getContractMetadata(address, chainId); } const addressInfo: AddressInfo = { @@ -504,8 +473,6 @@ export async function GET( isContract: isContractResult, contractMetadata, nativeBalance, - // ERC20 balances fetched separately via /erc20-balances endpoint - // Dune labels fetched separately via /api/dune/[address] endpoint transactions: txResult.transactions, erc20Transfers: txResult.erc20Transfers, nftTransfers: txResult.nftTransfers, @@ -514,21 +481,9 @@ export async function GET( addressChains: addressChains.length > 0 ? addressChains : undefined, }; - const totalDuration = performance.now() - totalStart; - - // Log timings - console.log(`[Address API] ${address} on chain ${chainId} - Total: ${totalDuration.toFixed(0)}ms`); - console.log(` Parallel fetch: isContract=${timings.isContract.toFixed(0)}ms, nativeBalance=${timings.nativeBalance.toFixed(0)}ms`); - console.log(` Parallel fetch: transactions=${timings.transactions.toFixed(0)}ms, addressChains=${timings.addressChains.toFixed(0)}ms`); - if (timings.contractMetadata) { - console.log(` Sequential: contractMetadata=${timings.contractMetadata.toFixed(0)}ms`); - } - - return NextResponse.json(addressInfo); - } catch (error) { - const totalDuration = performance.now() - totalStart; - console.error(`[Address API] Error fetching ${address} on chain ${chainId} after ${totalDuration.toFixed(0)}ms:`, error); - return NextResponse.json({ error: 'Failed to fetch address data' }, { status: 500 }); - } -} - + return successResponse(addressInfo); + }, + { + rateLimit: { windowMs: 60_000, maxRequests: 60, identifier: 'ip' }, + }, +); diff --git a/app/api/explorer/[chainId]/block/[blockNumber]/route.ts b/app/api/explorer/[chainId]/block/[blockNumber]/route.ts index 808a8885c4c..86d21da5f4b 100644 --- a/app/api/explorer/[chainId]/block/[blockNumber]/route.ts +++ b/app/api/explorer/[chainId]/block/[blockNumber]/route.ts @@ -1,5 +1,15 @@ -import { NextResponse } from 'next/server'; +import type { NextRequest } from 'next/server'; +import { z } from 'zod'; import l1ChainsData from '@/constants/l1-chains.json'; +import { withApi } from '@/lib/api/with-api'; +import { successResponse } from '@/lib/api/response'; +import { validateParams } from '@/lib/api/validate'; +import { NotFoundError } from '@/lib/api/errors'; + +const paramsSchema = z.object({ + chainId: z.string().regex(/^\d+$/, 'chainId must be numeric'), + blockNumber: z.string().regex(/^\d+$/, 'blockNumber must be numeric'), +}); interface RpcTransaction { hash: string; @@ -133,18 +143,13 @@ function parseACP176FeeState(extraData: string): ACP176FeeState | undefined { const gasExcess = BigInt('0x' + hex.slice(16, 32)); const targetExcess = BigInt('0x' + hex.slice(32, 48)); - const target = fakeExponential( - ACP176_MIN_TARGET_PER_SECOND, - targetExcess, - ACP176_TARGET_CONVERSION, - ); + const target = fakeExponential(ACP176_MIN_TARGET_PER_SECOND, targetExcess, ACP176_TARGET_CONVERSION); const maxCapacity = target * ACP176_TARGET_TO_MAX_CAPACITY; const priceUpdateConversion = target * ACP176_TARGET_TO_PRICE_UPDATE_CONVERSION; - const gasPrice = priceUpdateConversion > 0n - ? fakeExponential(ACP176_MIN_GAS_PRICE, gasExcess, priceUpdateConversion) - : 0n; + const gasPrice = + priceUpdateConversion > 0n ? fakeExponential(ACP176_MIN_GAS_PRICE, gasExcess, priceUpdateConversion) : 0n; return { gasCapacity: gasCapacity.toString(), @@ -161,38 +166,29 @@ function hexToTimestamp(hex: string): string { return new Date(timestamp).toISOString(); } -export async function GET( - request: Request, - { params }: { params: Promise<{ chainId: string; blockNumber: string }> } -) { - const { chainId, blockNumber } = await params; - - // Get query params for custom chains - const { searchParams } = new URL(request.url); - const customRpcUrl = searchParams.get('rpcUrl'); - - const chain = l1ChainsData.find(c => c.chainId === chainId); - const rpcUrl = chain?.rpcUrl || customRpcUrl; - - if (!rpcUrl) { - return NextResponse.json({ error: 'Chain not found or RPC URL missing. Provide rpcUrl query parameter for custom chains.' }, { status: 404 }); - } +export const GET = withApi( + async (req: NextRequest, { params }) => { + const { chainId, blockNumber } = validateParams(params, paramsSchema); - try { + // Get query params for custom chains + const { searchParams } = new URL(req.url); + const customRpcUrl = searchParams.get('rpcUrl'); - // Determine if blockNumber is a number or hash - let blockParam: string | number; - if (blockNumber.startsWith('0x')) { - blockParam = blockNumber; - } else { - blockParam = `0x${parseInt(blockNumber).toString(16)}`; + const chain = l1ChainsData.find((c) => c.chainId === chainId); + const rpcUrl = chain?.rpcUrl || customRpcUrl; + + if (!rpcUrl) { + throw new NotFoundError('Chain not found or RPC URL missing. Provide rpcUrl query parameter for custom chains.'); } + // Convert blockNumber to hex + const blockParam = `0x${parseInt(blockNumber).toString(16)}`; + // Fetch block with full transaction objects (using true parameter) - const block = await fetchFromRPC(rpcUrl, 'eth_getBlockByNumber', [blockParam, true]) as RpcBlock | null; + const block = (await fetchFromRPC(rpcUrl, 'eth_getBlockByNumber', [blockParam, true])) as RpcBlock | null; if (!block) { - return NextResponse.json({ error: 'Block not found' }, { status: 404 }); + throw new NotFoundError('Block'); } // Calculate total gas fee by fetching receipts and summing all transaction fees @@ -201,12 +197,12 @@ export async function GET( if (block.transactions && block.transactions.length > 0) { // Fetch all transaction receipts in parallel - const receiptPromises = block.transactions.map(tx => - fetchFromRPC(rpcUrl, 'eth_getTransactionReceipt', [tx.hash]) as Promise + const receiptPromises = block.transactions.map( + (tx) => fetchFromRPC(rpcUrl, 'eth_getTransactionReceipt', [tx.hash]) as Promise, ); - + const receipts = await Promise.all(receiptPromises); - + // Sum up all transaction fees: gasUsed * effectiveGasPrice for (const receipt of receipts) { if (receipt && receipt.gasUsed && receipt.effectiveGasPrice) { @@ -215,23 +211,19 @@ export async function GET( totalGasFeeWei += gasUsed * effectiveGasPrice; } } - + // Convert from wei to native token (divide by 1e18) gasFee = (Number(totalGasFeeWei) / 1e18).toFixed(6); } // Extract transaction hashes for the response - const transactionHashes = block.transactions.map(tx => tx.hash); + const transactionHashes = block.transactions.map((tx) => tx.hash); // Parse timestampMilliseconds for Avalanche (hex string to number) - const timestampMilliseconds = block.timestampMilliseconds - ? parseInt(block.timestampMilliseconds, 16) - : undefined; + const timestampMilliseconds = block.timestampMilliseconds ? parseInt(block.timestampMilliseconds, 16) : undefined; // Parse ACP-176 fee state from extraData (C-Chain only) - const feeState = chainId === '43114' && block.extraData - ? parseACP176FeeState(block.extraData) - : undefined; + const feeState = chainId === '43114' && block.extraData ? parseACP176FeeState(block.extraData) : undefined; // Format the response const formattedBlock = { @@ -257,10 +249,9 @@ export async function GET( transactionsRoot: block.transactionsRoot, }; - return NextResponse.json(formattedBlock); - } catch (error) { - console.error(`Error fetching block ${blockNumber} for chain ${chainId}:`, error); - return NextResponse.json({ error: 'Failed to fetch block data' }, { status: 500 }); - } -} - + return successResponse(formattedBlock); + }, + { + rateLimit: { windowMs: 60_000, maxRequests: 60, identifier: 'ip' }, + }, +); diff --git a/app/api/explorer/[chainId]/block/[blockNumber]/transactions/route.ts b/app/api/explorer/[chainId]/block/[blockNumber]/transactions/route.ts index 39dd6306b60..06b7fb27219 100644 --- a/app/api/explorer/[chainId]/block/[blockNumber]/transactions/route.ts +++ b/app/api/explorer/[chainId]/block/[blockNumber]/transactions/route.ts @@ -1,5 +1,15 @@ -import { NextResponse } from 'next/server'; +import type { NextRequest } from 'next/server'; +import { z } from 'zod'; import l1ChainsData from '@/constants/l1-chains.json'; +import { withApi } from '@/lib/api/with-api'; +import { successResponse } from '@/lib/api/response'; +import { validateParams } from '@/lib/api/validate'; +import { NotFoundError } from '@/lib/api/errors'; + +const paramsSchema = z.object({ + chainId: z.string().regex(/^\d+$/, 'chainId must be numeric'), + blockNumber: z.string().regex(/^\d+$/, 'blockNumber must be numeric'), +}); interface RpcTransaction { hash: string; @@ -54,38 +64,29 @@ async function fetchFromRPC(rpcUrl: string, method: string, params: unknown[] = } } -export async function GET( - request: Request, - { params }: { params: Promise<{ chainId: string; blockNumber: string }> } -) { - const { chainId, blockNumber } = await params; - - // Get query params for custom chains - const { searchParams } = new URL(request.url); - const customRpcUrl = searchParams.get('rpcUrl'); - - const chain = l1ChainsData.find(c => c.chainId === chainId); - const rpcUrl = chain?.rpcUrl || customRpcUrl; - - if (!rpcUrl) { - return NextResponse.json({ error: 'Chain not found or RPC URL missing. Provide rpcUrl query parameter for custom chains.' }, { status: 404 }); - } +export const GET = withApi( + async (req: NextRequest, { params }) => { + const { chainId, blockNumber } = validateParams(params, paramsSchema); - try { + // Get query params for custom chains + const { searchParams } = new URL(req.url); + const customRpcUrl = searchParams.get('rpcUrl'); - // Determine if blockNumber is a number or hash - let blockParam: string; - if (blockNumber.startsWith('0x')) { - blockParam = blockNumber; - } else { - blockParam = `0x${parseInt(blockNumber).toString(16)}`; + const chain = l1ChainsData.find((c) => c.chainId === chainId); + const rpcUrl = chain?.rpcUrl || customRpcUrl; + + if (!rpcUrl) { + throw new NotFoundError('Chain not found or RPC URL missing. Provide rpcUrl query parameter for custom chains.'); } + // Convert blockNumber to hex + const blockParam = `0x${parseInt(blockNumber).toString(16)}`; + // Fetch block with full transaction objects - const block = await fetchFromRPC(rpcUrl, 'eth_getBlockByNumber', [blockParam, true]) as RpcBlock | null; + const block = (await fetchFromRPC(rpcUrl, 'eth_getBlockByNumber', [blockParam, true])) as RpcBlock | null; if (!block) { - return NextResponse.json({ error: 'Block not found' }, { status: 404 }); + throw new NotFoundError('Block'); } // Format transactions @@ -102,10 +103,9 @@ export async function GET( input: tx.input, })); - return NextResponse.json({ transactions }); - } catch (error) { - console.error(`Error fetching transactions for block ${blockNumber} on chain ${chainId}:`, error); - return NextResponse.json({ error: 'Failed to fetch transactions' }, { status: 500 }); - } -} - + return successResponse({ transactions }); + }, + { + rateLimit: { windowMs: 60_000, maxRequests: 60, identifier: 'ip' }, + }, +); diff --git a/app/api/explorer/[chainId]/route.ts b/app/api/explorer/[chainId]/route.ts index 26e4e1f9971..6798b7d3206 100644 --- a/app/api/explorer/[chainId]/route.ts +++ b/app/api/explorer/[chainId]/route.ts @@ -1,10 +1,19 @@ -import { NextRequest, NextResponse } from "next/server"; -import { Avalanche } from "@avalanche-sdk/chainkit"; -import l1ChainsData from "@/constants/l1-chains.json"; +import type { NextRequest } from 'next/server'; +import { z } from 'zod'; +import { Avalanche } from '@avalanche-sdk/chainkit'; +import l1ChainsData from '@/constants/l1-chains.json'; +import { withApi } from '@/lib/api/with-api'; +import { successResponse } from '@/lib/api/response'; +import { validateParams } from '@/lib/api/validate'; +import { NotFoundError, BadRequestError } from '@/lib/api/errors'; + +const paramsSchema = z.object({ + chainId: z.string().regex(/^\d+$/, 'chainId must be numeric'), +}); // Initialize Avalanche SDK const avalanche = new Avalanche({ - network: "mainnet", + network: 'mainnet', }); interface Block { @@ -158,10 +167,10 @@ const PRICE_CACHE_TTL = 60000; // 60 seconds async function fetchFromRPC(rpcUrl: string, method: string, params: any[] = []): Promise { const response = await fetch(rpcUrl, { - method: "POST", - headers: { "Content-Type": "application/json" }, + method: 'POST', + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - jsonrpc: "2.0", + jsonrpc: '2.0', id: Date.now(), method, params, @@ -174,7 +183,7 @@ async function fetchFromRPC(rpcUrl: string, method: string, params: any[] = []): const data = await response.json(); if (data.error) { - throw new Error(data.error.message || "RPC error"); + throw new Error(data.error.message || 'RPC error'); } return data.result; @@ -206,7 +215,7 @@ function formatGasPrice(hex: string): string { } function shortenAddress(address: string | null): string { - if (!address) return "Contract Creation"; + if (!address) return 'Contract Creation'; return `${address.slice(0, 10)}...${address.slice(-8)}`; } @@ -238,16 +247,12 @@ async function fetchCumulativeTxs(evmChainId: string): Promise { } try { - const response = await fetch( - `https://idx6.solokhin.com/api/${evmChainId}/stats/cumulative-txs`, - { - headers: { 'Accept': 'application/json' }, - next: { revalidate: 30 } - } - ); + const response = await fetch(`https://idx6.solokhin.com/api/${evmChainId}/stats/cumulative-txs`, { + headers: { Accept: 'application/json' }, + next: { revalidate: 30 }, + }); if (!response.ok) { - console.warn(`Cumulative txs API error for chain ${evmChainId}: ${response.status}`); return 0; } @@ -257,8 +262,7 @@ async function fetchCumulativeTxs(evmChainId: string): Promise { // Update cache cumulativeTxsCache.set(evmChainId, { cumulativeTxs, timestamp: Date.now() }); return cumulativeTxs; - } catch (error) { - console.warn(`Failed to fetch cumulative txs for chain ${evmChainId}:`, error); + } catch { return 0; } } @@ -272,16 +276,12 @@ async function fetchDailyTxsByChain(): Promise(); try { - const response = await fetch( - 'https://idx6.solokhin.com/api/global/overview/dailyTxsByChainCompact', - { - headers: { 'Accept': 'application/json' }, - next: { revalidate: 300 } - } - ); + const response = await fetch('https://idx6.solokhin.com/api/global/overview/dailyTxsByChainCompact', { + headers: { Accept: 'application/json' }, + next: { revalidate: 300 }, + }); if (!response.ok) { - console.warn(`Daily txs API error: ${response.status}`); return result; } @@ -318,8 +318,7 @@ async function fetchDailyTxsByChain(): Promise { } try { - const response = await fetch( - 'https://api.coingecko.com/api/v3/simple/price?ids=avalanche-2&vs_currencies=usd', - { - headers: { 'Accept': 'application/json' }, - next: { revalidate: 60 } - } - ); + const response = await fetch('https://api.coingecko.com/api/v3/simple/price?ids=avalanche-2&vs_currencies=usd', { + headers: { Accept: 'application/json' }, + next: { revalidate: 60 }, + }); if (response.ok) { const data = await response.json(); @@ -345,8 +341,8 @@ async function fetchAvaxPrice(): Promise { avaxPriceCache = { price, timestamp: Date.now() }; return price; } - } catch (error) { - console.warn("Failed to fetch AVAX price:", error); + } catch { + // AVAX price fetch failed, use fallback } return 0; } @@ -364,14 +360,13 @@ async function fetchPrice(coingeckoId: string): Promise { `https://api.coingecko.com/api/v3/coins/${coingeckoId}?localization=false&tickers=false&community_data=false&developer_data=false&sparkline=false`, { headers: { - 'Accept': 'application/json', + Accept: 'application/json', }, - next: { revalidate: 60 } - } + next: { revalidate: 60 }, + }, ); if (!response.ok) { - console.warn(`CoinGecko API error: ${response.status}`); return undefined; } @@ -395,8 +390,8 @@ async function fetchPrice(coingeckoId: string): Promise { // Cache the price priceCache.set(coingeckoId, { data: priceData, timestamp: Date.now() }); return priceData; - } catch (error) { - console.warn("Failed to fetch price:", error); + } catch { + // Price fetch failed, continue without price data return undefined; } } @@ -406,19 +401,21 @@ async function fetchHistoricalIcmMessages( rpcUrl: string, latestBlockNumber: number, currentBlockchainId?: string, - blockRange: number = 512 + blockRange: number = 512, ): Promise { try { const fromBlock = Math.max(0, latestBlockNumber - blockRange); const toBlock = latestBlockNumber; // Query for cross-chain message events - const logs: RpcLog[] = await fetchFromRPC(rpcUrl, "eth_getLogs", [{ - fromBlock: `0x${fromBlock.toString(16)}`, - toBlock: `0x${toBlock.toString(16)}`, - topics: [[CROSS_CHAIN_TOPICS.SendCrossChainMessage, CROSS_CHAIN_TOPICS.ReceiveCrossChainMessage]], - limit: 10, - }]); + const logs: RpcLog[] = await fetchFromRPC(rpcUrl, 'eth_getLogs', [ + { + fromBlock: `0x${fromBlock.toString(16)}`, + toBlock: `0x${toBlock.toString(16)}`, + topics: [[CROSS_CHAIN_TOPICS.SendCrossChainMessage, CROSS_CHAIN_TOPICS.ReceiveCrossChainMessage]], + limit: 10, + }, + ]); if (!logs || logs.length === 0) return []; @@ -446,20 +443,22 @@ async function fetchHistoricalIcmMessages( // Fetch all transactions and blocks in parallel const [txResults, blockResults] = await Promise.all([ - Promise.all(txHashes.map((txHash, index) => - fetchFromRPC(rpcUrl, "eth_getTransactionByHash", [txHash]) - .catch((error) => { - console.error(`[Explorer API] Failed to fetch transaction ${txHash} (index ${index}):`, error); - return null; - }) as Promise - )), - Promise.all(blockNumbers.map((blockNumber, index) => - fetchFromRPC(rpcUrl, "eth_getBlockByNumber", [blockNumber, false]) - .catch((error) => { - console.error(`[Explorer API] Failed to fetch block ${blockNumber} (index ${index}):`, error); - return null; - }) as Promise<{ timestamp: string } | null> - )) + Promise.all( + txHashes.map( + (txHash) => + fetchFromRPC(rpcUrl, 'eth_getTransactionByHash', [txHash]).catch( + () => null, + ) as Promise, + ), + ), + Promise.all( + blockNumbers.map( + (blockNumber) => + fetchFromRPC(rpcUrl, 'eth_getBlockByNumber', [blockNumber, false]).catch(() => null) as Promise<{ + timestamp: string; + } | null>, + ), + ), ]); // Process results and build transactions array @@ -478,21 +477,21 @@ async function fetchHistoricalIcmMessages( if (topic0 === CROSS_CHAIN_TOPICS.SendCrossChainMessage.toLowerCase()) { sourceBlockchainId = currentBlockchainId; - destinationBlockchainId = log.topics[2]; + destinationBlockchainId = log.topics[2]; } else if (topic0 === CROSS_CHAIN_TOPICS.ReceiveCrossChainMessage.toLowerCase()) { destinationBlockchainId = currentBlockchainId; - sourceBlockchainId = log.topics[2]; + sourceBlockchainId = log.topics[2]; } transactions.push({ hash: tx.hash, from: tx.from, to: tx.to, - value: formatValue(tx.value || "0x0"), + value: formatValue(tx.value || '0x0'), blockNumber: hexToNumber(tx.blockNumber).toString(), timestamp: formatTimestamp(block?.timestamp || '0x0'), - gasPrice: formatGasPrice(tx.gasPrice || "0x0"), - gas: hexToNumber(tx.gas || "0x0").toLocaleString(), + gasPrice: formatGasPrice(tx.gasPrice || '0x0'), + gas: hexToNumber(tx.gas || '0x0').toLocaleString(), isCrossChain: true, sourceBlockchainId, destinationBlockchainId, @@ -500,19 +499,27 @@ async function fetchHistoricalIcmMessages( } return transactions; - } catch (error) { - console.error('[Explorer API] Failed to fetch historical ICM messages:', error); + } catch { return []; } } -async function fetchExplorerData(chainId: string, evmChainId: string, rpcUrl: string, coingeckoId?: string, tokenSymbol?: string, currentBlockchainId?: string, initialLoad?: boolean, lastFetchedBlock?: number): Promise { +async function fetchExplorerData( + chainId: string, + evmChainId: string, + rpcUrl: string, + coingeckoId?: string, + tokenSymbol?: string, + currentBlockchainId?: string, + initialLoad?: boolean, + lastFetchedBlock?: number, +): Promise { const startTime = Date.now(); const timing: Record = {}; // Get latest block number const blockNumberStart = Date.now(); - const latestBlockHex = await fetchFromRPC(rpcUrl, "eth_blockNumber"); + const latestBlockHex = await fetchFromRPC(rpcUrl, 'eth_blockNumber'); const latestBlockNumber = hexToNumber(latestBlockHex); timing.blockNumber = Date.now() - blockNumberStart; @@ -526,7 +533,7 @@ async function fetchExplorerData(chainId: string, evmChainId: string, rpcUrl: st stats: { latestBlock: latestBlockNumber, totalTransactions: 0, - gasPrice: "0", + gasPrice: '0', }, blocks: [], transactions: [], @@ -551,17 +558,13 @@ async function fetchExplorerData(chainId: string, evmChainId: string, rpcUrl: st const blockNum = latestBlockNumber - i; if (blockNum >= 0) { blockPromises.push( - fetchFromRPC(rpcUrl, "eth_getBlockByNumber", [`0x${blockNum.toString(16)}`, true]) - .catch((error) => { - console.error(`[Explorer API] Failed to fetch block ${blockNum} (0x${blockNum.toString(16)}):`, error); - return null; - }) + fetchFromRPC(rpcUrl, 'eth_getBlockByNumber', [`0x${blockNum.toString(16)}`, true]).catch(() => null), ); } } const blockResults = await Promise.all(blockPromises); - const validBlocks = blockResults.filter(block => block !== null); + const validBlocks = blockResults.filter((block) => block !== null); const totalTxsInBlocks = validBlocks.reduce((sum, b) => sum + (b?.transactions?.length || 0), 0); timing.blocksFetch = Date.now() - blocksFetchStart; timing.blocksCount = validBlocks.length; @@ -586,12 +589,11 @@ async function fetchExplorerData(chainId: string, evmChainId: string, rpcUrl: st } // Fetch all transaction receipts in parallel - const receiptPromises = allTxHashes.map(({ txHash, blockIndex }) => - fetchFromRPC(rpcUrl, "eth_getTransactionReceipt", [txHash]) - .catch((error) => { - console.error(`[Explorer API] Failed to fetch receipt for tx ${txHash} (blockIndex ${blockIndex}):`, error); - return null; - }) as Promise + const receiptPromises = allTxHashes.map( + ({ txHash }) => + fetchFromRPC(rpcUrl, 'eth_getTransactionReceipt', [txHash]).catch( + () => null, + ) as Promise, ); const receipts = await Promise.all(receiptPromises); @@ -607,7 +609,6 @@ async function fetchExplorerData(chainId: string, evmChainId: string, rpcUrl: st for (let i = 0; i < allTxHashes.length; i++) { const { blockIndex, txHash } = allTxHashes[i]; const receipt = receiptMap.get(txHash); - const block = validBlocks[blockIndex]; if (receipt && receipt.gasUsed) { const gasUsed = BigInt(receipt.gasUsed); @@ -646,9 +647,7 @@ async function fetchExplorerData(chainId: string, evmChainId: string, rpcUrl: st const burnedFee = burnedFeeWei > 0 ? (Number(burnedFeeWei) / 1e18).toFixed(18) : undefined; // Parse timestampMilliseconds for Avalanche (hex string to number) - const timestampMilliseconds = block.timestampMilliseconds - ? parseInt(block.timestampMilliseconds, 16) - : undefined; + const timestampMilliseconds = block.timestampMilliseconds ? parseInt(block.timestampMilliseconds, 16) : undefined; return { number: hexToNumber(block.number).toString(), @@ -680,10 +679,8 @@ async function fetchExplorerData(chainId: string, evmChainId: string, rpcUrl: st const receipt = receiptMap.get(txHash); if (!receipt?.logs) return false; - const crossChainTopics = Object.values(CROSS_CHAIN_TOPICS).map(t => t.toLowerCase()); - return receipt.logs.some(log => - log.topics?.[0] && crossChainTopics.includes(log.topics[0].toLowerCase()) - ); + const crossChainTopics = Object.values(CROSS_CHAIN_TOPICS).map((t) => t.toLowerCase()); + return receipt.logs.some((log) => log.topics?.[0] && crossChainTopics.includes(log.topics[0].toLowerCase())); } // Helper function to extract blockchain IDs from SendCrossChainMessage or ReceiveCrossChainMessage logs @@ -708,13 +705,13 @@ async function fetchExplorerData(chainId: string, evmChainId: string, rpcUrl: st // SendCrossChainMessage: current chain is the source, destination is in topic[2] sourceBlockchainId = currentBlockchainId; if (log.topics.length > 2) { - destinationBlockchainId = log.topics[2]; + destinationBlockchainId = log.topics[2]; } } else if (topic0 === receiveTopic) { // ReceiveCrossChainMessage: current chain is the destination, source is in topic[2] destinationBlockchainId = currentBlockchainId; if (log.topics.length > 2) { - sourceBlockchainId = log.topics[2]; + sourceBlockchainId = log.topics[2]; } } } @@ -724,17 +721,17 @@ async function fetchExplorerData(chainId: string, evmChainId: string, rpcUrl: st // Map all transactions first to check cross-chain status const txProcessingStart = Date.now(); - const allMappedTransactions: Transaction[] = allTransactions.map(tx => { + const allMappedTransactions: Transaction[] = allTransactions.map((tx) => { const isCrossChain = isCrossChainTx(tx.hash); const baseTx: Transaction = { hash: tx.hash, from: tx.from, to: tx.to, - value: formatValue(tx.value || "0x0"), + value: formatValue(tx.value || '0x0'), blockNumber: hexToNumber(tx.blockNumber).toString(), timestamp: formatTimestamp(tx.blockTimestamp), - gasPrice: formatGasPrice(tx.gasPrice || "0x0"), - gas: hexToNumber(tx.gas || "0x0").toLocaleString(), + gasPrice: formatGasPrice(tx.gasPrice || '0x0'), + gas: hexToNumber(tx.gas || '0x0').toLocaleString(), isCrossChain, }; @@ -748,7 +745,7 @@ async function fetchExplorerData(chainId: string, evmChainId: string, rpcUrl: st }); // Separate cross-chain transactions (ICM messages) from recent blocks - let icmMessages = allMappedTransactions.filter(tx => tx.isCrossChain); + let icmMessages = allMappedTransactions.filter((tx) => tx.isCrossChain); timing.txProcessing = Date.now() - txProcessingStart; timing.processedTxs = allTransactions.length; timing.crossChainTxs = icmMessages.length; @@ -761,7 +758,7 @@ async function fetchExplorerData(chainId: string, evmChainId: string, rpcUrl: st timing.historicalIcmCount = historicalIcm.length; // Merge with recent ICM messages, deduplicate by txHash - const seenHashes = new Set(icmMessages.map(tx => tx.hash)); + const seenHashes = new Set(icmMessages.map((tx) => tx.hash)); for (const tx of historicalIcm) { if (!seenHashes.has(tx.hash)) { icmMessages.push(tx); @@ -777,14 +774,13 @@ async function fetchExplorerData(chainId: string, evmChainId: string, rpcUrl: st } // Get latest 10 non-cross-chain transactions - const transactions = allMappedTransactions - .slice(0, 10); + const transactions = allMappedTransactions.slice(0, 10); // Get current gas price const gasPriceStart = Date.now(); - let gasPrice = "0"; + let gasPrice = '0'; try { - const gasPriceHex = await fetchFromRPC(rpcUrl, "eth_gasPrice"); + const gasPriceHex = await fetchFromRPC(rpcUrl, 'eth_gasPrice'); gasPrice = formatGasPrice(gasPriceHex); timing.gasPrice = Date.now() - gasPriceStart; } catch { @@ -802,14 +798,17 @@ async function fetchExplorerData(chainId: string, evmChainId: string, rpcUrl: st // Use block (latest - 5000) or block 1 if chain has fewer than 5000 blocks const historicalBlockNum = latestBlockNumber > 5000 ? latestBlockNumber - 5000 : 1; const blockSpan = latestBlockNumber - historicalBlockNum; - + try { - const historicalBlock = await fetchFromRPC(rpcUrl, "eth_getBlockByNumber", [`0x${historicalBlockNum.toString(16)}`, false]) as RpcBlock | null; - + const historicalBlock = (await fetchFromRPC(rpcUrl, 'eth_getBlockByNumber', [ + `0x${historicalBlockNum.toString(16)}`, + false, + ])) as RpcBlock | null; + if (historicalBlock) { const latestBlock = validBlocks[0]; avgBlockTimeBlockSpan = blockSpan; // Store the block span used - + // Try millisecond precision first (Avalanche) if (latestBlock?.timestampMilliseconds && historicalBlock.timestampMilliseconds) { const latestTimeMs = parseInt(latestBlock.timestampMilliseconds, 16); @@ -825,8 +824,8 @@ async function fetchExplorerData(chainId: string, evmChainId: string, rpcUrl: st avgBlockTime = timeDiffSec / blockSpan; } } - } catch (error) { - console.warn('[Explorer API] Failed to fetch historical block for avgBlockTime:', error); + } catch { + // Historical block fetch failed, skip avgBlockTime } } @@ -879,7 +878,7 @@ async function fetchExplorerData(chainId: string, evmChainId: string, rpcUrl: st icmMessages, transactionHistory, price, - tokenSymbol: price?.symbol || tokenSymbol + tokenSymbol: price?.symbol || tokenSymbol, }; } @@ -891,21 +890,16 @@ async function checkGlacierSupport(chainId: string): Promise { }); // If we get a result with a chainId, the chain is supported return !!result?.chainId; - } catch (error) { + } catch { // Chain not supported by Glacier return false; } } -export async function GET( - request: NextRequest, - { params }: { params: Promise<{ chainId: string }> } -) { - const requestStart = Date.now(); - const requestTiming: Record = {}; - try { - const { chainId } = await params; - const { searchParams } = new URL(request.url); +export const GET = withApi( + async (req: NextRequest, { params }) => { + const { chainId } = validateParams(params, paramsSchema); + const { searchParams } = new URL(req.url); const initialLoad = searchParams.get('initialLoad') === 'true'; const priceOnly = searchParams.get('priceOnly') === 'true'; const lastFetchedBlockParam = searchParams.get('lastFetchedBlock'); @@ -917,60 +911,51 @@ export async function GET( const customBlockchainId = searchParams.get('blockchainId'); // Find chain config from static data - const chain = l1ChainsData.find(c => c.chainId === chainId) as ChainConfig | undefined; - + const chain = l1ChainsData.find((c) => c.chainId === chainId) as ChainConfig | undefined; + // Determine effective RPC URL - prefer static config, fallback to query param const rpcUrl = chain?.rpcUrl || customRpcUrl; const tokenSymbol = chain?.networkToken?.symbol || customTokenSymbol || undefined; const blockchainId = chain?.blockchainId || customBlockchainId || undefined; const coingeckoId = chain?.coingeckoId; - + // If no chain found and no custom rpcUrl provided, return 404 if (!chain && !customRpcUrl) { - return NextResponse.json({ error: "Chain not found. Provide rpcUrl query parameter for custom chains." }, { status: 404 }); + throw new NotFoundError('Chain not found. Provide rpcUrl query parameter for custom chains.'); } // If priceOnly, just fetch price and glacier support (for ExplorerContext) if (priceOnly) { - const priceOnlyStart = Date.now(); const [price, glacierSupported] = await Promise.all([ coingeckoId ? fetchPrice(coingeckoId) : Promise.resolve(undefined), checkGlacierSupport(chainId), ]); - requestTiming.priceOnly = Date.now() - priceOnlyStart; - requestTiming.total = Date.now() - requestStart; - return NextResponse.json({ - price, - tokenSymbol, - glacierSupported, - }); + return successResponse({ price, tokenSymbol, glacierSupported }); } if (!rpcUrl) { - return NextResponse.json({ error: "RPC URL not configured. Provide rpcUrl query parameter for custom chains." }, { status: 400 }); + throw new BadRequestError('RPC URL not configured. Provide rpcUrl query parameter for custom chains.'); } // Fetch fresh data and check Glacier support in parallel - const dataFetchStart = Date.now(); const [data, glacierSupported] = await Promise.all([ - fetchExplorerData(chainId, chainId, rpcUrl, coingeckoId, tokenSymbol, blockchainId, initialLoad, lastFetchedBlock), + fetchExplorerData( + chainId, + chainId, + rpcUrl, + coingeckoId, + tokenSymbol, + blockchainId, + initialLoad, + lastFetchedBlock, + ), checkGlacierSupport(chainId), ]); - requestTiming.dataFetch = Date.now() - dataFetchStart; - requestTiming.total = Date.now() - requestStart; - - // Add glacierSupported to the response - const responseData = { ...data, glacierSupported }; - - return NextResponse.json(responseData); - } catch (error) { - requestTiming.total = Date.now() - requestStart; - requestTiming.error = 1; - console.error('[Explorer API] Error:', error); - return NextResponse.json( - { error: error instanceof Error ? error.message : "Failed to fetch explorer data" }, - { status: 500 } - ); - } -} + + return successResponse({ ...data, glacierSupported }); + }, + { + rateLimit: { windowMs: 60_000, maxRequests: 60, identifier: 'ip' }, + }, +); diff --git a/app/api/explorer/[chainId]/token/[tokenAddress]/metadata/route.ts b/app/api/explorer/[chainId]/token/[tokenAddress]/metadata/route.ts index 26a80660259..6bb637817ab 100644 --- a/app/api/explorer/[chainId]/token/[tokenAddress]/metadata/route.ts +++ b/app/api/explorer/[chainId]/token/[tokenAddress]/metadata/route.ts @@ -1,35 +1,32 @@ -import { NextRequest, NextResponse } from "next/server"; -import { Avalanche } from "@avalanche-sdk/chainkit"; +import type { NextRequest } from 'next/server'; +import { z } from 'zod'; +import { Avalanche } from '@avalanche-sdk/chainkit'; +import { withApi } from '@/lib/api/with-api'; +import { successResponse } from '@/lib/api/response'; +import { validateParams } from '@/lib/api/validate'; +import { EVM_ADDRESS_REGEX } from '@/lib/api/constants'; + +const paramsSchema = z.object({ + chainId: z.string().regex(/^\d+$/, 'chainId must be numeric'), + tokenAddress: z.string().regex(EVM_ADDRESS_REGEX, 'Invalid token address format'), +}); const avalanche = new Avalanche({ - network: "mainnet", + network: 'mainnet', }); -export async function GET( - request: NextRequest, - context: { params: Promise<{ chainId: string; tokenAddress: string }> } -) { - try { - const { chainId, tokenAddress } = await context.params; - - // Validate inputs - if (!chainId || !tokenAddress) { - return NextResponse.json({ error: "Missing required parameters" }, { status: 400 }); - } - - // Validate address format - if (!/^0x[a-fA-F0-9]{40}$/.test(tokenAddress)) { - return NextResponse.json({ error: "Invalid token address format" }, { status: 400 }); - } +export const GET = withApi( + async (_req: NextRequest, { params }) => { + const { chainId, tokenAddress } = validateParams(params, paramsSchema); try { const result = await avalanche.data.evm.contracts.getMetadata({ address: tokenAddress, - chainId: chainId, + chainId, }); if (!result) { - return NextResponse.json({}); + return successResponse({}); } // Extract symbol based on contract type @@ -38,19 +35,18 @@ export async function GET( symbol = result.symbol || undefined; } - return NextResponse.json({ + return successResponse({ name: result.name || undefined, symbol, logoUri: result.logoAsset?.imageUri || undefined, ercType: result.ercType || undefined, }); - } catch (error) { + } catch { // Glacier API might not have data for this token - return NextResponse.json({}); + return successResponse({}); } - } catch (error) { - console.error("Error fetching token metadata:", error); - return NextResponse.json({ error: "Internal server error" }, { status: 500 }); - } -} - + }, + { + rateLimit: { windowMs: 60_000, maxRequests: 60, identifier: 'ip' }, + }, +); diff --git a/app/api/explorer/[chainId]/tx/[txHash]/route.ts b/app/api/explorer/[chainId]/tx/[txHash]/route.ts index cc36aab819f..efd74aae759 100644 --- a/app/api/explorer/[chainId]/tx/[txHash]/route.ts +++ b/app/api/explorer/[chainId]/tx/[txHash]/route.ts @@ -1,5 +1,16 @@ -import { NextResponse } from 'next/server'; +import type { NextRequest } from 'next/server'; +import { z } from 'zod'; import l1ChainsData from '@/constants/l1-chains.json'; +import { withApi } from '@/lib/api/with-api'; +import { successResponse } from '@/lib/api/response'; +import { validateParams } from '@/lib/api/validate'; +import { NotFoundError } from '@/lib/api/errors'; +import { TX_HASH_REGEX } from '@/lib/api/constants'; + +const paramsSchema = z.object({ + chainId: z.string().regex(/^\d+$/, 'chainId must be numeric'), + txHash: z.string().regex(TX_HASH_REGEX, 'Invalid transaction hash format'), +}); interface RpcTransaction { hash: string; @@ -110,25 +121,20 @@ function hexToTimestamp(hex: string): string { return new Date(timestamp).toISOString(); } +export const GET = withApi( + async (req: NextRequest, { params }) => { + const { chainId, txHash } = validateParams(params, paramsSchema); -export async function GET( - request: Request, - { params }: { params: Promise<{ chainId: string; txHash: string }> } -) { - const { chainId, txHash } = await params; - - // Get query params for custom chains - const { searchParams } = new URL(request.url); - const customRpcUrl = searchParams.get('rpcUrl'); + // Get query params for custom chains + const { searchParams } = new URL(req.url); + const customRpcUrl = searchParams.get('rpcUrl'); - const chain = l1ChainsData.find(c => c.chainId === chainId); - const rpcUrl = chain?.rpcUrl || customRpcUrl; - - if (!rpcUrl) { - return NextResponse.json({ error: 'Chain not found or RPC URL missing. Provide rpcUrl query parameter for custom chains.' }, { status: 404 }); - } + const chain = l1ChainsData.find((c) => c.chainId === chainId); + const rpcUrl = chain?.rpcUrl || customRpcUrl; - try { + if (!rpcUrl) { + throw new NotFoundError('Chain not found or RPC URL missing. Provide rpcUrl query parameter for custom chains.'); + } // Fetch receipt and transaction in parallel for better performance const [receiptResult, txResult] = await Promise.allSettled([ @@ -136,24 +142,22 @@ export async function GET( fetchFromRPC(rpcUrl, 'eth_getTransactionByHash', [txHash]), ]); - const receipt = receiptResult.status === 'fulfilled' ? receiptResult.value as RpcReceipt | null : null; - const tx = txResult.status === 'fulfilled' ? txResult.value as RpcTransaction | null : null; + const receipt = receiptResult.status === 'fulfilled' ? (receiptResult.value as RpcReceipt | null) : null; + const tx = txResult.status === 'fulfilled' ? (txResult.value as RpcTransaction | null) : null; if (!receipt) { - return NextResponse.json({ error: 'Transaction not found' }, { status: 404 }); - } - - // Log if transaction fetch failed but continue with receipt - if (txResult.status === 'rejected') { - console.log(`eth_getTransactionByHash failed for ${txHash}, using receipt only:`, txResult.reason); + throw new NotFoundError('Transaction'); } - // Fetch block for timestamp (use tx blockNumber if receipt doesn't have it, though receipt should always have it) + // Fetch block for timestamp let timestamp = null; const blockNumberForTimestamp = receipt.blockNumber || tx?.blockNumber; if (blockNumberForTimestamp) { try { - const block = await fetchFromRPC(rpcUrl, 'eth_getBlockByNumber', [blockNumberForTimestamp, false]) as RpcBlock | null; + const block = (await fetchFromRPC(rpcUrl, 'eth_getBlockByNumber', [ + blockNumberForTimestamp, + false, + ])) as RpcBlock | null; if (block) { timestamp = hexToTimestamp(block.timestamp); } @@ -165,7 +169,7 @@ export async function GET( // Get current block for confirmations let confirmations = 0; try { - const latestBlock = await fetchFromRPC(rpcUrl, 'eth_blockNumber', []) as string; + const latestBlock = (await fetchFromRPC(rpcUrl, 'eth_blockNumber', [])) as string; const txBlockNumber = receipt.blockNumber || tx?.blockNumber; if (txBlockNumber) { confirmations = Math.max(0, parseInt(latestBlock, 16) - parseInt(txBlockNumber, 16)); @@ -176,26 +180,22 @@ export async function GET( // Calculate transaction fee using receipt data const gasUsed = formatHexToNumber(receipt.gasUsed); - // Prefer effectiveGasPrice from receipt (more accurate for EIP-1559), fallback to tx gasPrice const effectiveGasPrice = receipt.effectiveGasPrice || tx?.gasPrice || '0x0'; - const txFee = effectiveGasPrice !== '0x0' - ? (BigInt(receipt.gasUsed) * BigInt(effectiveGasPrice)).toString() - : '0'; + const txFee = effectiveGasPrice !== '0x0' ? (BigInt(receipt.gasUsed) * BigInt(effectiveGasPrice)).toString() : '0'; // Use transaction fields when available, fallback to receipt fields - // Transaction object has more complete data, so prefer it when available - const transactionIndex = tx?.transactionIndex - ? formatHexToNumber(tx.transactionIndex) - : receipt.transactionIndex - ? formatHexToNumber(receipt.transactionIndex) + const transactionIndex = tx?.transactionIndex + ? formatHexToNumber(tx.transactionIndex) + : receipt.transactionIndex + ? formatHexToNumber(receipt.transactionIndex) : null; - - const blockNumber = tx?.blockNumber - ? formatHexToNumber(tx.blockNumber) - : receipt.blockNumber - ? formatHexToNumber(receipt.blockNumber) + + const blockNumber = tx?.blockNumber + ? formatHexToNumber(tx.blockNumber) + : receipt.blockNumber + ? formatHexToNumber(receipt.blockNumber) : null; - + const blockHash = tx?.blockHash || receipt.blockHash || null; const from = tx?.from || receipt.from; const to = tx?.to !== undefined ? tx.to : receipt.to; @@ -211,34 +211,26 @@ export async function GET( from, to, contractAddress: receipt.contractAddress || null, - // Value only available from tx, default to 0 if not available value: tx?.value ? formatWeiToEther(tx.value) : '0', valueWei: tx?.value || '0x0', - // Gas price: prefer receipt's effectiveGasPrice (accurate for EIP-1559), fallback to tx's gasPrice gasPrice: effectiveGasPrice !== '0x0' ? formatGwei(effectiveGasPrice) : 'N/A', gasPriceWei: effectiveGasPrice, - // Gas limit only from tx gasLimit: tx?.gas ? formatHexToNumber(tx.gas) : 'N/A', gasUsed, txFee: txFee !== '0' ? formatWeiToEther(txFee) : '0', txFeeWei: txFee, - // Nonce only from tx nonce: tx?.nonce ? formatHexToNumber(tx.nonce) : 'N/A', transactionIndex, - // Input only from tx input: tx?.input || '0x', - // Transaction type: parse hex string to number type: tx?.type ? (typeof tx.type === 'string' ? parseInt(tx.type, 16) : tx.type) : 0, - // EIP-1559 fields only from tx maxFeePerGas: tx?.maxFeePerGas ? formatGwei(tx.maxFeePerGas) : null, maxPriorityFeePerGas: tx?.maxPriorityFeePerGas ? formatGwei(tx.maxPriorityFeePerGas) : null, logs: receipt.logs || [], }; - return NextResponse.json(formattedTx); - } catch (error) { - console.error(`Error fetching transaction ${txHash} on chain ${chainId}:`, error); - return NextResponse.json({ error: 'Failed to fetch transaction data' }, { status: 500 }); - } -} - + return successResponse(formattedTx); + }, + { + rateLimit: { windowMs: 60_000, maxRequests: 60, identifier: 'ip' }, + }, +); diff --git a/app/api/faucet-balance/route.ts b/app/api/faucet-balance/route.ts index e9b50b85082..b2b6f83cc8a 100644 --- a/app/api/faucet-balance/route.ts +++ b/app/api/faucet-balance/route.ts @@ -1,4 +1,5 @@ -import { NextRequest, NextResponse } from 'next/server'; +import { withApi } from '@/lib/api/with-api'; +import { successResponse } from '@/lib/api/response'; import { createPublicClient, http, formatEther, defineChain } from 'viem'; import { avalancheFuji } from 'viem/chains'; import { getL1ListStore, type L1ListItem } from '@/components/toolbox/stores/l1ListStore'; @@ -23,17 +24,6 @@ interface ChainBalance { faucetAddress: string; } -interface FaucetBalanceResponse { - success: boolean; - pChain?: { - balance: string; - balanceFormatted: string; - faucetAddress: string; - }; - evmChains?: ChainBalance[]; - message?: string; -} - function createViemChain(l1Data: L1ListItem) { if (l1Data.evmChainId === 43113) { return avalancheFuji; @@ -119,35 +109,27 @@ async function getEVMChainBalance(chain: L1ListItem): Promise> { - try { - // Get list of chains with faucet support - const testnetStore = getL1ListStore(true); - const chainsWithFaucet = testnetStore.getState().l1List.filter( - (chain: L1ListItem) => chain.hasBuilderHubFaucet - ); - - // Fetch all balances in parallel - const [pChainResult, ...evmResults] = await Promise.all([ - getPChainBalance(), - ...chainsWithFaucet.map((chain: L1ListItem) => getEVMChainBalance(chain)), - ]); - - const evmChains = evmResults.filter((result): result is ChainBalance => result !== null); - - return NextResponse.json({ - success: true, - pChain: pChainResult ? { - ...pChainResult, - faucetAddress: FAUCET_P_CHAIN_ADDRESS!, - } : undefined, - evmChains, - }); - } catch (error) { - console.error('Faucet balance error:', error); - return NextResponse.json( - { success: false, message: error instanceof Error ? error.message : 'Failed to fetch balances' }, - { status: 500 } - ); - } -} +export const GET = withApi(async () => { + // Get list of chains with faucet support + const testnetStore = getL1ListStore(true); + const chainsWithFaucet = testnetStore.getState().l1List.filter((chain: L1ListItem) => chain.hasBuilderHubFaucet); + + // Fetch all balances in parallel + const [pChainResult, ...evmResults] = await Promise.all([ + getPChainBalance(), + ...chainsWithFaucet.map((chain: L1ListItem) => getEVMChainBalance(chain)), + ]); + + const evmChains = evmResults.filter((result): result is ChainBalance => result !== null); + + return successResponse({ + success: true, + pChain: pChainResult + ? { + ...pChainResult, + faucetAddress: FAUCET_P_CHAIN_ADDRESS!, + } + : undefined, + evmChains, + }); +}); diff --git a/app/api/faucet-rate-limit/batch/route.ts b/app/api/faucet-rate-limit/batch/route.ts index e3456f980e0..8bbc29d7f44 100644 --- a/app/api/faucet-rate-limit/batch/route.ts +++ b/app/api/faucet-rate-limit/batch/route.ts @@ -1,10 +1,24 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { getAuthSession } from '@/lib/auth/authSession'; +import type { NextRequest } from 'next/server'; +import { z } from 'zod'; +import { withApi, successResponse } from '@/lib/api'; import { prisma } from '@/prisma/prisma'; const RATE_LIMIT_WINDOW_MS = 24 * 60 * 60 * 1000; // 24 hours const MAX_CLAIMS_PER_USER = 1; +const bodySchema = z.object({ + chains: z + .array( + z.object({ + faucetType: z.enum(['pchain', 'evm']), + chainId: z.string().optional(), + }), + ) + .min(1, { message: 'At least one chain entry is required' }), +}); + +type BatchBody = z.infer; + interface ChainRateLimitStatus { chainId: string | null; faucetType: 'pchain' | 'evm'; @@ -12,50 +26,24 @@ interface ChainRateLimitStatus { resetTime?: string; } -interface BatchRateLimitResponse { - success: boolean; - limits: ChainRateLimitStatus[]; -} - -export async function POST(request: NextRequest): Promise { - try { - const session = await getAuthSession(); - if (!session?.user?.id) { - return NextResponse.json( - { success: false, message: 'Authentication required' }, - { status: 401 } - ); - } - - const body = await request.json(); - const { chains } = body as { - chains: Array<{ faucetType: 'pchain' | 'evm'; chainId?: string }> - }; - - if (!chains || !Array.isArray(chains)) { - return NextResponse.json( - { success: false, message: 'Invalid request body' }, - { status: 400 } - ); - } - +export const POST = withApi( + async (_req: NextRequest, { session, body }) => { + const { chains } = body; const windowStart = new Date(Date.now() - RATE_LIMIT_WINDOW_MS); - // Get all user claims in one query const userClaims = await prisma.faucetClaim.findMany({ where: { user_id: session.user.id, - created_at: { gte: windowStart } + created_at: { gte: windowStart }, }, select: { faucet_type: true, chain_id: true, - created_at: true + created_at: true, }, - orderBy: { created_at: 'asc' } + orderBy: { created_at: 'asc' }, }); - // Build a map of claims per faucet type/chain const claimMap = new Map(); for (const claim of userClaims) { const key = `${claim.faucet_type}:${claim.chain_id || 'null'}`; @@ -64,14 +52,12 @@ export async function POST(request: NextRequest): Promise { } } - // Check each requested chain const limits: ChainRateLimitStatus[] = chains.map(({ faucetType, chainId }) => { const key = `${faucetType}:${chainId || 'null'}`; const oldestClaim = claimMap.get(key); - - // Count claims for this faucet/chain + const claimCount = userClaims.filter( - c => c.faucet_type === faucetType && (c.chain_id || 'null') === (chainId || 'null') + (c) => c.faucet_type === faucetType && (c.chain_id || 'null') === (chainId || 'null'), ).length; if (claimCount >= MAX_CLAIMS_PER_USER && oldestClaim) { @@ -80,30 +66,18 @@ export async function POST(request: NextRequest): Promise { chainId: chainId || null, faucetType, allowed: false, - resetTime: resetTime.toISOString() + resetTime: resetTime.toISOString(), }; } return { chainId: chainId || null, faucetType, - allowed: true + allowed: true, }; }); - const response: BatchRateLimitResponse = { - success: true, - limits - }; - - return NextResponse.json(response); - - } catch (error) { - console.error('Batch faucet rate limit check error:', error); - return NextResponse.json( - { success: false, message: 'Failed to check rate limits' }, - { status: 500 } - ); - } -} - + return successResponse({ limits }); + }, + { auth: true, schema: bodySchema }, +); diff --git a/app/api/faucet-rate-limit/route.ts b/app/api/faucet-rate-limit/route.ts index 115d935e642..8c03a6ba9b7 100644 --- a/app/api/faucet-rate-limit/route.ts +++ b/app/api/faucet-rate-limit/route.ts @@ -1,11 +1,20 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { getAuthSession } from '@/lib/auth/authSession'; +import type { NextRequest } from 'next/server'; +import { z } from 'zod'; +import { withApi, successResponse, validateQuery } from '@/lib/api'; import { prisma } from '@/prisma/prisma'; const RATE_LIMIT_WINDOW_MS = 24 * 60 * 60 * 1000; // 24 hours const MAX_CLAIMS_PER_USER = 1; const MAX_CLAIMS_PER_DESTINATION = 2; +const querySchema = z.object({ + faucetType: z.enum(['pchain', 'evm'], { + error: 'Valid faucetType (pchain or evm) is required', + }), + address: z.string().min(1, 'Destination address is required'), + chainId: z.string().optional(), +}); + interface RateLimitStatus { allowed: boolean; reason?: string; @@ -16,58 +25,31 @@ interface RateLimitStatus { maxClaimsPerDestination: number; } -export async function GET(request: NextRequest): Promise { - try { - const session = await getAuthSession(); - if (!session?.user?.id) { - return NextResponse.json( - { success: false, message: 'Authentication required' }, - { status: 401 } - ); - } - - const searchParams = request.nextUrl.searchParams; - const faucetType = searchParams.get('faucetType') as 'pchain' | 'evm' | null; - const destinationAddress = searchParams.get('address'); - const chainId = searchParams.get('chainId'); - - if (!faucetType || !['pchain', 'evm'].includes(faucetType)) { - return NextResponse.json( - { success: false, message: 'Valid faucetType (pchain or evm) is required' }, - { status: 400 } - ); - } - - if (!destinationAddress) { - return NextResponse.json( - { success: false, message: 'Destination address is required' }, - { status: 400 } - ); - } +export const GET = withApi( + async (req: NextRequest, { session }) => { + const { faucetType, address: destinationAddress, chainId } = validateQuery(req, querySchema); const windowStart = new Date(Date.now() - RATE_LIMIT_WINDOW_MS); const normalizedAddress = destinationAddress.toLowerCase(); - // Check per-user rate limit const userClaims = await prisma.faucetClaim.findMany({ where: { user_id: session.user.id, faucet_type: faucetType, chain_id: chainId || null, - created_at: { gte: windowStart } + created_at: { gte: windowStart }, }, - orderBy: { created_at: 'asc' } + orderBy: { created_at: 'asc' }, }); - // Check per-destination rate limit const destinationClaims = await prisma.faucetClaim.findMany({ where: { faucet_type: faucetType, chain_id: chainId || null, destination_address: normalizedAddress, - created_at: { gte: windowStart } + created_at: { gte: windowStart }, }, - orderBy: { created_at: 'asc' } + orderBy: { created_at: 'asc' }, }); const status: RateLimitStatus = { @@ -75,19 +57,16 @@ export async function GET(request: NextRequest): Promise { userClaimsInWindow: userClaims.length, destinationClaimsInWindow: destinationClaims.length, maxClaimsPerUser: MAX_CLAIMS_PER_USER, - maxClaimsPerDestination: MAX_CLAIMS_PER_DESTINATION + maxClaimsPerDestination: MAX_CLAIMS_PER_DESTINATION, }; - // Check if user has exceeded their limit if (userClaims.length >= MAX_CLAIMS_PER_USER) { const oldestClaim = userClaims[0]; const resetTime = new Date(oldestClaim.created_at.getTime() + RATE_LIMIT_WINDOW_MS); status.allowed = false; status.reason = 'user_limit_exceeded'; status.resetTime = resetTime.toISOString(); - } - // Check if destination has exceeded its limit - else if (destinationClaims.length >= MAX_CLAIMS_PER_DESTINATION) { + } else if (destinationClaims.length >= MAX_CLAIMS_PER_DESTINATION) { const oldestClaim = destinationClaims[0]; const resetTime = new Date(oldestClaim.created_at.getTime() + RATE_LIMIT_WINDOW_MS); status.allowed = false; @@ -95,14 +74,7 @@ export async function GET(request: NextRequest): Promise { status.resetTime = resetTime.toISOString(); } - return NextResponse.json({ success: true, ...status }); - - } catch (error) { - console.error('Faucet rate limit check error:', error); - return NextResponse.json( - { success: false, message: 'Failed to check rate limit' }, - { status: 500 } - ); - } -} - + return successResponse(status); + }, + { auth: true }, +); diff --git a/app/api/file/route.ts b/app/api/file/route.ts index fbd958a3496..8afd8f0c629 100644 --- a/app/api/file/route.ts +++ b/app/api/file/route.ts @@ -1,167 +1,124 @@ - -import { withAuth } from '@/lib/protectedRoute'; +import type { NextRequest } from 'next/server'; import { del, put } from '@vercel/blob'; -import { NextResponse, NextRequest } from 'next/server'; +import { withApi } from '@/lib/api/with-api'; +import { successResponse } from '@/lib/api/response'; +import { AuthError, BadRequestError, ForbiddenError, NotFoundError } from '@/lib/api/errors'; import { canUserDeleteFile, canUserUploadFile, isValidFileSize, isValidFileType, - doesExtensionMatchMimeType + doesExtensionMatchMimeType, } from '@/server/services/fileValidation'; +// --------------------------------------------------------------------------- +// POST /api/file — Upload a file (auth required) +// --------------------------------------------------------------------------- + +// schema: not applicable — FormData binary upload, not JSON body +export const POST = withApi( + async (req: NextRequest, { session }) => { + const userId = session.user?.id; + if (!userId) { + throw new AuthError('User ID is required'); + } -export const POST = withAuth(async (request: Request, context: any, session: any) => { - try { - const formData = await request.formData(); + const formData = await req.formData(); const file = formData.get('file'); if (!file || typeof file === 'string') { - return NextResponse.json({ error: 'invalid file' }, { status: 400 }); + throw new BadRequestError('Invalid file'); } const typedFile = file as File; // Validate MIME type against allowlist if (!isValidFileType(typedFile)) { - return NextResponse.json( - { error: 'File type not supported. Please upload a PNG, JPG, or SVG.' }, - { status: 400 } - ); + throw new BadRequestError('File type not supported. Please upload a PNG, JPG, or SVG.'); } // Validate file extension matches declared MIME type if (!doesExtensionMatchMimeType(typedFile)) { - return NextResponse.json( - { error: 'File extension does not match its content type.' }, - { status: 400 } - ); + throw new BadRequestError('File extension does not match its content type.'); } // Validate file size (max 10MB) if (!isValidFileSize(typedFile, 10)) { - return NextResponse.json( - { error: 'File size exceeds the maximum limit of 10MB' }, - { status: 400 } - ); - } - - // Validate permissions - const customAttributes = (session?.user?.custom_attributes as string[]) || []; - const userId = session?.user?.id; - - if (!userId) { - return NextResponse.json( - { error: 'User ID is required' }, - { status: 401 } - ); + throw new BadRequestError('File size exceeds the maximum limit of 10MB'); } - const hasPermission = await canUserUploadFile( - userId, - customAttributes - ); - + // Validate upload permissions + const customAttributes = (session.user?.custom_attributes as string[]) || []; + const hasPermission = await canUserUploadFile(userId, customAttributes); if (!hasPermission) { - return NextResponse.json( - { error: 'You do not have permission to upload files' }, - { status: 403 } - ); + throw new ForbiddenError('You do not have permission to upload files'); } - // Upload the file const blob = await put(typedFile.name, typedFile, { access: 'public', token: process.env.BLOB_READ_WRITE_TOKEN!, }); - return NextResponse.json({ url: blob.url }); - } catch (error: any) { - console.error('Error uploading file:', error); - console.error('Error POST /api/file:', error.message); - const wrappedError = error as Error; - return NextResponse.json( - { error: wrappedError }, - { status: wrappedError.cause == 'ValidationError' ? 400 : 500 } - ); - } -}); - -export const DELETE = withAuth(async (request: NextRequest, context: any, session: any) => { - const { searchParams } = new URL(request.url); - const fileName = searchParams.get('fileName'); - const url = searchParams.get('url'); - // Support both spellings for backward compatibility - const hackathonId = searchParams.get('hackaton_id') || searchParams.get('hackathon_id'); - - if (!fileName && !url) { - return NextResponse.json( - { error: 'fileName or URL is required' }, - { status: 400 } - ); - } - - // Use fileName if available, otherwise use url - const fileIdentifier = fileName || url!; - - try { - // Validate permissions before deleting - const customAttributes = (session?.user?.custom_attributes as string[]) || []; - const userId = session?.user?.id; + return successResponse({ url: blob.url }, 201); + }, + { auth: true }, +); + +// --------------------------------------------------------------------------- +// DELETE /api/file — Delete a file (auth required) +// --------------------------------------------------------------------------- +export const DELETE = withApi( + async (req: NextRequest, { session }) => { + const userId = session.user?.id; if (!userId) { - return NextResponse.json( - { error: 'User ID is required' }, - { status: 401 } - ); + throw new AuthError('User ID is required'); } - const hasPermission = await canUserDeleteFile( - fileIdentifier, - userId, - customAttributes, - hackathonId || undefined - ); + const fileName = req.nextUrl.searchParams.get('fileName'); + const url = req.nextUrl.searchParams.get('url'); + // Support both spellings for backward compatibility + const hackathonId = req.nextUrl.searchParams.get('hackaton_id') || req.nextUrl.searchParams.get('hackathon_id'); + + if (!fileName && !url) { + throw new BadRequestError('fileName or URL is required'); + } + + const fileIdentifier = fileName || url!; + + // Validate delete permissions + const customAttributes = (session.user?.custom_attributes as string[]) || []; + const hasPermission = await canUserDeleteFile(fileIdentifier, userId, customAttributes, hackathonId || undefined); if (!hasPermission) { - return NextResponse.json( - { error: 'You do not have permission to delete this file' }, - { status: 403 } - ); + throw new ForbiddenError('You do not have permission to delete this file'); } - // Extract the file name to verify existence and deletion + // Extract actual file name from URL if needed let actualFileName = fileIdentifier; if (fileIdentifier.includes('/')) { try { const urlObj = new URL(fileIdentifier); actualFileName = urlObj.pathname.split('/').pop() || fileIdentifier; } catch { - // If it's not a valid URL, use the identifier as is actualFileName = fileIdentifier.split('/').pop() || fileIdentifier; } } - // Check if the file exists + // Check existence const blobExists = await fetch(`${process.env.BLOB_BASE_URL}/${actualFileName}`, { method: 'HEAD', - }).then(res => res.ok).catch(() => false); + }) + .then((res) => res.ok) + .catch(() => false); if (!blobExists) { - return NextResponse.json( - { message: 'The file does not exist or has already been deleted' }, - { status: 201 } - ); + throw new NotFoundError('File does not exist or has already been deleted'); } - // Delete the file - await del(actualFileName, { - token: process.env.BLOB_READ_WRITE_TOKEN, - }); + await del(actualFileName, { token: process.env.BLOB_READ_WRITE_TOKEN }); - return NextResponse.json({ message: 'File deleted successfully' }); - } catch (error) { - console.error('Error deleting file:', error); - return NextResponse.json({ error: 'Error deleting file' }, { status: 500 }); - } -}); + return successResponse({ message: 'File deleted successfully' }); + }, + { auth: true }, +); diff --git a/app/api/generate-certificate/route.ts b/app/api/generate-certificate/route.ts index af459718986..6ff4831e69b 100644 --- a/app/api/generate-certificate/route.ts +++ b/app/api/generate-certificate/route.ts @@ -1,44 +1,46 @@ -import { NextRequest, NextResponse } from 'next/server'; +import type { NextRequest } from 'next/server'; +import { NextResponse } from 'next/server'; +import { z } from 'zod'; import { PDFDocument } from 'pdf-lib'; -import { getServerSession } from 'next-auth'; -import { AuthOptions } from '@/lib/auth/authOptions'; +import { withApi } from '@/lib/api/with-api'; +import { BadRequestError, InternalError, NotFoundError } from '@/lib/api/errors'; import { triggerCertificateWebhook } from '@/server/services/hubspotCertificateWebhook'; import { getCompletedCourseSlugs } from '@/server/services/userBadge'; import { getCourseConfig } from '@/content/courses'; /** * Sanitize text for WinAnsi (Windows-1252) encoding used by pdf-lib. - * Characters outside WinAnsi (e.g. Turkish İ U+0130) are decomposed + * Characters outside WinAnsi (e.g. Turkish I U+0130) are decomposed * to their closest ASCII base form via NFKD normalization. - * WinAnsi-safe accented characters (é, ñ, ü, etc.) are preserved. + * WinAnsi-safe accented characters (e, n, u, etc.) are preserved. */ function sanitizeForWinAnsi(text: string): string { const WIN_1252_EXTRAS = new Set([ - 0x152, 0x153, 0x160, 0x161, 0x178, 0x17D, 0x17E, 0x192, - 0x2C6, 0x2DC, 0x2013, 0x2014, 0x2018, 0x2019, 0x201A, - 0x201C, 0x201D, 0x201E, 0x2020, 0x2021, 0x2022, 0x2026, - 0x2030, 0x2039, 0x203A, 0x20AC, 0x2122, + 0x152, 0x153, 0x160, 0x161, 0x178, 0x17d, 0x17e, 0x192, 0x2c6, 0x2dc, 0x2013, 0x2014, 0x2018, 0x2019, 0x201a, + 0x201c, 0x201d, 0x201e, 0x2020, 0x2021, 0x2022, 0x2026, 0x2030, 0x2039, 0x203a, 0x20ac, 0x2122, ]); return text .split('') .map((char) => { const code = char.charCodeAt(0); - if (code <= 0xFF || WIN_1252_EXTRAS.has(code)) return char; + if (code <= 0xff || WIN_1252_EXTRAS.has(code)) return char; const base = char.normalize('NFKD').replace(/[\u0300-\u036f]/g, ''); return base || '?'; }) .join(''); } -async function fetchWithRetry( - url: string, - maxRetries = 3, - delayMs = 500 -): Promise { +async function fetchWithRetry(url: string, maxRetries = 3, delayMs = 500): Promise { let lastResponse: Response | undefined; for (let attempt = 1; attempt <= maxRetries; attempt++) { - lastResponse = await fetch(url); + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 15_000); + try { + lastResponse = await fetch(url, { signal: controller.signal }); + } finally { + clearTimeout(timeoutId); + } if (lastResponse.ok || lastResponse.status < 500) return lastResponse; if (attempt < maxRetries) { await new Promise((resolve) => setTimeout(resolve, delayMs * attempt)); @@ -47,48 +49,32 @@ async function fetchWithRetry( return lastResponse!; } -export async function POST(req: NextRequest) { - try { - // Require auth and derive the user's name from the connected BuilderHub account - const session = await getServerSession(AuthOptions); - if (!session || !session.user) { - return NextResponse.json({ - error: 'Unauthorized. Please sign in to BuilderHub to generate certificates.' - }, { status: 401 }); - } - - // Email is mandatory for certificate generation +const certSchema = z.object({ + courseId: z.string().min(1, 'Missing course ID'), +}); + +export const POST = withApi>( + async (_req: NextRequest, { session, body }) => { if (!session.user.email) { - return NextResponse.json({ - error: 'Email address required. Please ensure your BuilderHub account has a valid email address.' - }, { status: 400 }); + throw new BadRequestError( + 'Email address required. Please ensure your BuilderHub account has a valid email address.', + ); } - const { courseId } = await req.json(); - if (!courseId) { - return NextResponse.json({ error: 'Missing course ID' }, { status: 400 }); - } + const { courseId } = body; - // Get course configuration from centralized source const courseConfig = getCourseConfig(); - console.log('Certificate generation - courseId:', courseId); - console.log('Available courses:', Object.keys(courseConfig)); - const course = courseConfig[courseId]; if (!course) { - return NextResponse.json({ - error: `No certificate template found for course: ${courseId}` - }, { status: 404 }); + throw new NotFoundError(`Certificate template for course: ${courseId}`); } - const userName = sanitizeForWinAnsi( - session.user.name || session.user.email || 'BuilderHub User' - ); + const userName = sanitizeForWinAnsi(session.user.name || session.user.email || 'BuilderHub User'); const { name: courseName, template: templateUrl } = course; const templateResponse = await fetchWithRetry(templateUrl); if (!templateResponse.ok) { - throw new Error(`Failed to fetch template: ${templateUrl}`); + throw new InternalError(`Failed to fetch template: ${templateUrl}`); } const templateArrayBuffer = await templateResponse.arrayBuffer(); @@ -99,47 +85,36 @@ export async function POST(req: NextRequest) { try { if (isAvalancheTemplate) { - // Original 4-field flow for Avalanche certificates form.getTextField('FullName').setText(userName); form.getTextField('Class').setText(courseName); - form - .getTextField('Awarded') - .setText( - new Date().toLocaleDateString('en-US', { - day: 'numeric', - month: 'short', - year: 'numeric', - }) - ); + form.getTextField('Awarded').setText( + new Date().toLocaleDateString('en-US', { + day: 'numeric', + month: 'short', + year: 'numeric', + }), + ); form .getTextField('Id') - .setText( - Math.random().toString(36).substring(2, 15) + - Math.random().toString(36).substring(2, 15) - ); + .setText(Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15)); } else { - // Codebase Entrepreneur certificates: only Name and Date form.getTextField('Enter Name').setText(userName); - form - .getTextField('Enter Date') - .setText( - new Date().toLocaleDateString('en-US', { - day: 'numeric', - month: 'short', - year: 'numeric', - }) - ); + form.getTextField('Enter Date').setText( + new Date().toLocaleDateString('en-US', { + day: 'numeric', + month: 'short', + year: 'numeric', + }), + ); } - } catch (error) { - throw new Error('Failed to fill form fields'); + } catch { + throw new InternalError('Failed to fill form fields'); } form.flatten(); const pdfBytes = await pdfDoc.save(); - - // Trigger HubSpot webhook for certificate completion - // At this point we know email exists due to the check above - // Include the current courseId since badge assignment may not have persisted yet + + // Trigger HubSpot webhook (fire-and-forget) const completedBefore = await getCompletedCourseSlugs(session.user.id); const isNewCompletion = !completedBefore.includes(courseId); const completedCourses = [...completedBefore]; @@ -147,17 +122,15 @@ export async function POST(req: NextRequest) { completedCourses.push(courseId); } - // Fire-and-forget: don't block PDF delivery on webhook - // Only pass completedCourses for graduation check on new completions triggerCertificateWebhook( session.user.id, session.user.email!, userName, courseId, - isNewCompletion ? completedCourses : undefined - ).catch((err) => - console.error('HubSpot webhook failed (non-blocking):', err) - ); + isNewCompletion ? completedCourses : undefined, + ).catch(() => { + // Non-blocking -- webhook failure should not affect PDF delivery + }); return new NextResponse(Buffer.from(pdfBytes), { status: 200, @@ -166,13 +139,6 @@ export async function POST(req: NextRequest) { 'Content-Disposition': `attachment; filename=${courseId}_certificate.pdf`, }, }); - } catch (error) { - return NextResponse.json( - { - error: 'Failed to generate certificate, contact the Avalanche team.', - details: (error as Error).message, - }, - { status: 500 } - ); - } -} \ No newline at end of file + }, + { auth: true, schema: certSchema }, +); diff --git a/app/api/glacier-jwt/route.ts b/app/api/glacier-jwt/route.ts index cc5ef082835..7c4e2f45bbe 100644 --- a/app/api/glacier-jwt/route.ts +++ b/app/api/glacier-jwt/route.ts @@ -1,29 +1,17 @@ -import { getAuthSession } from "@/lib/auth/authSession"; -import { createGlacierJWT } from "@/lib/glacier-jwt"; -import { NextResponse } from "next/server"; +import { withApi, successResponse } from '@/lib/api'; +import { createGlacierJWT } from '@/lib/glacier-jwt'; -export async function GET() { - const session = await getAuthSession(); +const DATA_API_ENDPOINT = 'https://data-api.avax.network/v1'; - if (!session) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - const DATA_API_ENDPOINT = "https://data-api.avax.network/v1"; - - try { +export const GET = withApi( + async (_req, { session }) => { const glacierJwt = await createGlacierJWT({ sub: session.user.id, - iss: "https://build.avax.network/", + iss: 'https://build.avax.network/', email: session.user.email!, }); - return NextResponse.json({ glacierJwt, endpoint: DATA_API_ENDPOINT }); - } catch (error) { - console.error("Failed to create glacier JWT:", error); - return NextResponse.json( - { error: "Failed to generate JWT" }, - { status: 500 } - ); - } -} + return successResponse({ glacierJwt, endpoint: DATA_API_ENDPOINT }); + }, + { auth: true }, +); diff --git a/app/api/hackathon-registration/route.ts b/app/api/hackathon-registration/route.ts index d1347590062..4a58e2ed070 100644 --- a/app/api/hackathon-registration/route.ts +++ b/app/api/hackathon-registration/route.ts @@ -1,84 +1,91 @@ -import { NextResponse } from 'next/server'; +import type { NextRequest } from 'next/server'; +import { z } from 'zod'; +import { withApi } from '@/lib/api/with-api'; +import { successResponse } from '@/lib/api/response'; +import { BadRequestError, InternalError } from '@/lib/api/errors'; const HUBSPOT_API_KEY = process.env.HUBSPOT_API_KEY; const HUBSPOT_PORTAL_ID = process.env.HUBSPOT_PORTAL_ID; const HUBSPOT_HACKATHON_FORM_GUID = process.env.HUBSPOT_HACKATHON_FORM_GUID; -export async function POST(request: Request) { - try { +const registrationSchema = z + .object({ + email: z.string().email('Valid email is required'), + name: z.string().optional(), + city: z.string().optional(), + role: z.string().optional(), + company_name: z.string().optional(), + telegram_user: z.string().optional(), + github_portfolio: z.string().optional(), + interests: z.union([z.array(z.string()), z.string()]).optional(), + languages: z.union([z.array(z.string()), z.string()]).optional(), + roles: z.union([z.array(z.string()), z.string()]).optional(), + tools: z.union([z.array(z.string()), z.string()]).optional(), + founder_check: z.boolean().optional(), + avalanche_ecosystem_member: z.boolean().optional(), + newsletter_subscription: z.boolean().optional(), + terms_event_conditions: z.boolean().optional(), + gdpr: z.boolean().optional(), + marketing_consent: z.boolean().optional(), + }) + .passthrough(); + +// withApi: auth intentionally omitted — public form submission +export const POST = withApi>( + async (req: NextRequest, { body: formData }) => { if (!HUBSPOT_API_KEY || !HUBSPOT_PORTAL_ID || !HUBSPOT_HACKATHON_FORM_GUID) { - console.error('Missing environment variables: HUBSPOT_API_KEY, HUBSPOT_PORTAL_ID, or HUBSPOT_HACKATHON_FORM_GUID'); - return NextResponse.json( - { success: false, message: 'Server configuration error' }, - { status: 500 } - ); + throw new InternalError('Server configuration error'); } - const clonedRequest = request.clone(); - let formData; - try { - formData = await clonedRequest.json(); - } catch (error) { - console.error('Error parsing request body:', error); - return NextResponse.json( - { success: false, message: 'Invalid request body' }, - { status: 400 } - ); - } - // Process the form data for HubSpot const processedFormData: Record = {}; - - // Map standard fields directly + Object.entries(formData).forEach(([key, value]) => { if (['fullname', 'email', 'gdpr', 'marketing_consent'].includes(key)) { processedFormData[key] = value; } else { - // Use custom property format for other fields processedFormData[`hackathon_${key}`] = value; } }); - + // Map specific hackathon fields - processedFormData["fullname"] = formData.name || "N/A"; - processedFormData["country_dropdown"] = formData.city || "N/A"; // use as country TODO: Rename city variable in dB - processedFormData["hs_role"] = formData.role || "N/A"; - processedFormData["name"] = formData.company_name || ""; // To check if "name" is correct in HS form - processedFormData["telegram_handle"] = formData.telegram_user || ""; - processedFormData["github_url"] = formData.github_portfolio || ""; - processedFormData["hackathon_interests"] = Array.isArray(formData.interests) ? formData.interests.join(";") : formData.interests || ""; - processedFormData["programming_language_familiarity"] = Array.isArray(formData.languages) ? formData.languages.join(";") : formData.languages || ""; - processedFormData["employment_role_other"] = Array.isArray(formData.roles) ? formData.roles.join(";") : formData.roles || ""; - processedFormData["tooling_familiarity"] = Array.isArray(formData.tools) ? formData.tools.join(";") : formData.tools || ""; - //processedFormData["hackathon_event_id"] = formData.hackathon_id || ""; - processedFormData["founder_check"] = formData.founder_check ? "Yes" : "No"; - processedFormData["avalanche_ecosystem_member"] = formData.avalanche_ecosystem_member ? "Yes" : "No"; + processedFormData['fullname'] = formData.name || 'N/A'; + processedFormData['country_dropdown'] = formData.city || 'N/A'; + processedFormData['hs_role'] = formData.role || 'N/A'; + processedFormData['name'] = formData.company_name || ''; + processedFormData['telegram_handle'] = formData.telegram_user || ''; + processedFormData['github_url'] = formData.github_portfolio || ''; + processedFormData['hackathon_interests'] = Array.isArray(formData.interests) + ? formData.interests.join(';') + : formData.interests || ''; + processedFormData['programming_language_familiarity'] = Array.isArray(formData.languages) + ? formData.languages.join(';') + : formData.languages || ''; + processedFormData['employment_role_other'] = Array.isArray(formData.roles) + ? formData.roles.join(';') + : formData.roles || ''; + processedFormData['tooling_familiarity'] = Array.isArray(formData.tools) + ? formData.tools.join(';') + : formData.tools || ''; + processedFormData['founder_check'] = formData.founder_check ? 'Yes' : 'No'; + processedFormData['avalanche_ecosystem_member'] = formData.avalanche_ecosystem_member ? 'Yes' : 'No'; // Map boolean fields - processedFormData["marketing_consent"] = formData.newsletter_subscription === true ? "Yes" : "No"; - processedFormData["gdpr"] = formData.terms_event_conditions === true ? "Yes" : "No"; - + processedFormData['marketing_consent'] = formData.newsletter_subscription === true ? 'Yes' : 'No'; + processedFormData['gdpr'] = formData.terms_event_conditions === true ? 'Yes' : 'No'; + // Build HubSpot payload fields const fields: { name: string; value: string | boolean }[] = []; Object.entries(processedFormData).forEach(([name, value]) => { - if (value === undefined || value === null || value === '') { - return; - } - - let formattedValue: string | boolean = typeof value === 'string' || typeof value === 'boolean' ? value : String(value); - - fields.push({ - name: name, - value: formattedValue - }); + if (value === undefined || value === null || value === '') return; + const formattedValue: string | boolean = + typeof value === 'string' || typeof value === 'boolean' ? value : String(value); + fields.push({ name, value: formattedValue }); }); interface HubspotPayload { fields: { name: string; value: string | boolean }[]; - context: { - pageUri: string; - pageName: string; - }; + context: { pageUri: string; pageName: string }; legalConsentOptions?: { consent: { consentToProcess: boolean; @@ -91,13 +98,13 @@ export async function POST(request: Request) { }; }; } - + const hubspotPayload: HubspotPayload = { - fields: fields, + fields, context: { - pageUri: request.headers.get('referer') || 'https://build.avax.network', - pageName: 'Hackathon Registration' - } + pageUri: req.headers.get('referer') || 'https://build.avax.network', + pageName: 'Hackathon Registration', + }, }; // Add legal consent if GDPR is agreed to @@ -105,63 +112,48 @@ export async function POST(request: Request) { hubspotPayload.legalConsentOptions = { consent: { consentToProcess: true, - text: "I agree to allow Avalanche Foundation to store and process my personal data for hackathon participation purposes.", + text: 'I agree to allow Avalanche Foundation to store and process my personal data for hackathon participation purposes.', communications: [ { value: formData.marketing_consent === true, subscriptionTypeId: 999, - text: "I would like to receive marketing emails from the Avalanche Foundation." - } - ] - } + text: 'I would like to receive marketing emails from the Avalanche Foundation.', + }, + ], + }, }; } - - + const hubspotResponse = await fetch( `https://api.hsforms.com/submissions/v3/integration/submit/${HUBSPOT_PORTAL_ID}/${HUBSPOT_HACKATHON_FORM_GUID}`, { method: 'POST', headers: { 'Content-Type': 'application/json', - 'Authorization': `Bearer ${HUBSPOT_API_KEY}` + Authorization: `Bearer ${HUBSPOT_API_KEY}`, }, - body: JSON.stringify(hubspotPayload) - } + body: JSON.stringify(hubspotPayload), + }, ); - const responseStatus = hubspotResponse.status; - let hubspotResult; - try { - hubspotResult = await hubspotResponse.json(); - } catch (error) { + if (!hubspotResponse.ok) { + const responseStatus = hubspotResponse.status; + let detail: string; try { - const text = await hubspotResponse.text(); - hubspotResult = { status: 'error', message: text }; - } catch (textError) { - hubspotResult = { status: 'error', message: 'Could not read HubSpot response' }; + const result = await hubspotResponse.json(); + detail = JSON.stringify(result); + } catch { + detail = hubspotResponse.statusText; } + throw new BadRequestError(`HubSpot API error: ${responseStatus} - ${detail}`); } - if (!hubspotResponse.ok) { - throw new Error(`HubSpot API error: ${responseStatus} - ${JSON.stringify(hubspotResult)}`); - } + const hubspotResult = await hubspotResponse.json().catch(() => ({})); - return NextResponse.json({ - success: true, + return successResponse({ message: 'Hackathon registration sent to HubSpot successfully', - response: hubspotResult + response: hubspotResult, }); - - } catch (error) { - console.error('Error in hackathon-registration route:', error); - return NextResponse.json( - { - success: false, - message: 'Failed to send registration to HubSpot', - error: error instanceof Error ? error.message : 'Unknown error' - }, - { status: 500 } - ); - } -} \ No newline at end of file + }, + { schema: registrationSchema }, +); diff --git a/app/api/hackathons/[id]/route.ts b/app/api/hackathons/[id]/route.ts index 3fe5be81826..34051e54fba 100644 --- a/app/api/hackathons/[id]/route.ts +++ b/app/api/hackathons/[id]/route.ts @@ -1,62 +1,56 @@ -import { NextRequest, NextResponse } from "next/server"; -import { getHackathon, updateHackathon } from "@/server/services/hackathons"; -import { HackathonHeader } from "@/types/hackathons"; -import { withAuthRole } from "@/lib/protectedRoute"; - -export async function GET(req: NextRequest, context: any) { +import type { NextRequest } from 'next/server'; +import { withApi } from '@/lib/api/with-api'; +import { successResponse } from '@/lib/api/response'; +import { BadRequestError } from '@/lib/api/errors'; +import { getHackathon, updateHackathon } from '@/server/services/hackathons'; + +export const GET = withApi(async (_req: NextRequest, { params }) => { + const { id } = params; + if (!id) throw new BadRequestError('ID required'); + + const hackathon = await getHackathon(id); + return successResponse(hackathon); +}); - try { - const { id } = await context.params; +// schema: not applicable — partial hackathon update with dynamic fields +export const PUT = withApi( + async (req: NextRequest, { session, params }) => { + const { id } = params; + if (!id) throw new BadRequestError('ID required'); - if (!id) { - return NextResponse.json({ error: "ID required" }, { status: 400 }); - } - - const hackathon = await getHackathon(id) - return NextResponse.json(hackathon); - } catch (error) { - console.error("Error in GET /api/hackathons/[id]:"); - return NextResponse.json( - { error: (error as Error).message }, - { status: 500 } - ); - } -} - -export const PUT = withAuthRole('devrel', async (req: NextRequest, context: any, session: any) => { - try { - const { id } = await context.params; const updateData = await req.json(); const userId = session.user.id; - if (updateData.hasOwnProperty('is_public') && typeof updateData.is_public === 'boolean' && Object.keys(updateData).length === 1) { - const updatedHackathon = await updateHackathon(id, { is_public: updateData.is_public }, userId); - return NextResponse.json(updatedHackathon); - } else { - const partialEditedHackathon = updateData as Partial; - const updatedHackathon = await updateHackathon(partialEditedHackathon.id ?? id, partialEditedHackathon, userId); - return NextResponse.json(updatedHackathon); + // Short-circuit: if only toggling is_public, restrict the update to that field + if ( + updateData.hasOwnProperty('is_public') && + typeof updateData.is_public === 'boolean' && + Object.keys(updateData).length === 1 + ) { + const updated = await updateHackathon(id, { is_public: updateData.is_public }, userId); + return successResponse(updated); } - } catch (error) { - console.error("Error in PUT /api/hackathons/[id]:", error); - return NextResponse.json({ error: `Internal Server Error: ${error}` }, { status: 500 }); - } -}); -export const PATCH = withAuthRole('devrel', async (req: NextRequest, context: any, session: any) => { - try { - const { id } = await context.params; + const updated = await updateHackathon(updateData.id ?? id, updateData, userId); + return successResponse(updated); + }, + { auth: true, roles: ['devrel'] }, +); + +export const PATCH = withApi( + async (req: NextRequest, { session, params }) => { + const { id } = params; + if (!id) throw new BadRequestError('ID required'); + const updateData = await req.json(); const userId = session.user.id; if (updateData.hasOwnProperty('is_public') && typeof updateData.is_public === 'boolean') { - const updatedHackathon = await updateHackathon(id, { is_public: updateData.is_public }, userId); - return NextResponse.json(updatedHackathon); - } else { - return NextResponse.json({ error: "Only is_public field can be updated via PATCH" }, { status: 400 }); + const updated = await updateHackathon(id, { is_public: updateData.is_public }, userId); + return successResponse(updated); } - } catch (error) { - console.error("Error in PATCH /api/hackathons/[id]:", error); - return NextResponse.json({ error: `Internal Server Error: ${error}` }, { status: 500 }); - } -}); + + throw new BadRequestError('Only is_public field can be updated via PATCH'); + }, + { auth: true, roles: ['devrel'] }, +); diff --git a/app/api/hackathons/route.ts b/app/api/hackathons/route.ts index e11a747c903..90fef4e684a 100644 --- a/app/api/hackathons/route.ts +++ b/app/api/hackathons/route.ts @@ -1,91 +1,65 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { - createHackathon, - getFilteredHackathons, - GetHackathonsOptions, -} from '@/server/services/hackathons'; +import type { NextRequest } from 'next/server'; +import { withApi } from '@/lib/api/with-api'; +import { parsePagination } from '@/lib/api/pagination'; +import { successResponse, paginatedResponse } from '@/lib/api/response'; +import { createHackathon, getFilteredHackathons } from '@/server/services/hackathons'; +import type { GetHackathonsOptions } from '@/server/services/hackathons'; import { HackathonStatus } from '@/types/hackathons'; import { getUserById } from '@/server/services/getUser'; -import { withAuthRole } from '@/lib/protectedRoute'; -import { getAuthSession } from '@/lib/auth/authSession'; +// schema: not applicable — POST body is dynamic hackathon creation payload +export const GET = withApi(async (req: NextRequest, { session }) => { + const { page, pageSize } = parsePagination(req); + const searchParams = req.nextUrl.searchParams; + const options: GetHackathonsOptions = { + page, + pageSize, + location: searchParams.get('location') || undefined, + date: searchParams.get('date') || undefined, + status: (searchParams.get('status') as HackathonStatus) || undefined, + search: searchParams.get('search') || undefined, + event: searchParams.get('event') || undefined, + }; -export async function GET(req: NextRequest) { - try { - const searchParams = req.nextUrl.searchParams; - - const session = await getAuthSession(); - const userId = session?.user?.id; - - let options: GetHackathonsOptions = { - page: Number(searchParams.get('page') || 1), - pageSize: Number(searchParams.get('pageSize') || 10), - location: searchParams.get('location') || undefined, - date: searchParams.get('date') || undefined, - status: searchParams.get('status') as HackathonStatus || undefined, - search: searchParams.get('search') || undefined, - event: searchParams.get('event') || undefined, - }; - - if (userId) { - // Get user from database to validate permissions - const user = await getUserById(userId); - if (!user) { - return NextResponse.json({ error: "User not found" }, { status: 404 }); - } - - // Check user's custom_attributes for permissions - const customAttributes = user.custom_attributes || []; - const isDevrel = customAttributes.includes("devrel"); - const isTeam1Admin = customAttributes.includes("team1-admin"); - const isHackathonCreator = customAttributes.includes("hackathonCreator"); - - // If user is devrel, show all hackathons; otherwise filter by user ID - const createdByFilter = isDevrel ? undefined : userId; + const userId = session?.user?.id; + + if (userId) { + const user = await getUserById(userId); + + if (user) { + const customAttributes: string[] = user.custom_attributes || []; + const isDevrel = customAttributes.includes('devrel'); + const isTeam1Admin = customAttributes.includes('team1-admin'); + const isHackathonCreator = customAttributes.includes('hackathonCreator'); - options.created_by = createdByFilter || undefined; - // Only narrow by cohost email for non-devrel users; devrel should see all + options.created_by = isDevrel ? undefined : userId; if (!isDevrel) { options.cohost_email = user.email || undefined; } - options.include_private = isDevrel || isTeam1Admin || isHackathonCreator; // These roles can see private hackathons - - console.log('API GET /hackathons:', { userId, isDevrel, isTeam1Admin, isHackathonCreator, createdByFilter, options }); + options.include_private = isDevrel || isTeam1Admin || isHackathonCreator; } else { options.include_private = false; - console.log('API GET /hackathons (no userId):', { options }); } - - const response = await getFilteredHackathons(options); - - return NextResponse.json(response); - } catch (error: any) { - console.error('Error GET /api/hackathons:', error.message); - const wrappedError = error as Error; - return NextResponse.json( - { error: wrappedError.message }, - { status: wrappedError.cause == 'BadRequest' ? 400 : 500 } - ); + } else { + options.include_private = false; } -} -export const POST = withAuthRole('devrel', async (req: NextRequest, context: any, session: any) => { - try { - const body = await req.json(); - const newHackathon = await createHackathon(body); + const response = await getFilteredHackathons(options); - return NextResponse.json( - { message: 'Hackathon created', hackathon: newHackathon }, - { status: 201 } - ); - } catch (error: any) { - console.error('Error POST /api/hackathons:', error.message); - const wrappedError = error as Error; - return NextResponse.json( - { error: wrappedError }, - { status: wrappedError.cause == 'ValidationError' ? 400 : 500 } - ); - } + return paginatedResponse(response.hackathons, { + page: response.page, + pageSize: response.pageSize, + total: response.total, + }); }); +export const POST = withApi( + async (req: NextRequest) => { + const body = await req.json(); + const newHackathon = await createHackathon(body); + + return successResponse(newHackathon, 201); + }, + { auth: true, roles: ['devrel'] }, +); diff --git a/app/api/icm-contract-fees/route.ts b/app/api/icm-contract-fees/route.ts index 50381e40bb1..47e8bf21ede 100644 --- a/app/api/icm-contract-fees/route.ts +++ b/app/api/icm-contract-fees/route.ts @@ -1,26 +1,8 @@ -import { NextResponse } from "next/server"; -import { getICMContractFeesData } from "@/lib/icm-clickhouse"; +import { withApi, successResponse } from '@/lib/api'; +import { getICMContractFeesData } from '@/lib/icm-clickhouse'; -const CACHE_CONTROL_HEADER = 'public, max-age=14400, s-maxage=14400, stale-while-revalidate=86400'; - -export async function GET(request: Request) { - try { - const { searchParams } = new URL(request.url); - const timeRange = searchParams.get("timeRange") || "all"; - const result = await getICMContractFeesData(timeRange); - - return NextResponse.json(result, { - headers: { - 'Cache-Control': CACHE_CONTROL_HEADER, - 'X-Data-Source': result.dataSource, - 'X-Cache-Timestamp': result.lastUpdated, - } - }); - } catch (error) { - console.error("Error fetching ICM contract fees:", error); - return NextResponse.json( - { error: "Failed to fetch ICM contract fees data" }, - { status: 500 } - ); - } -} +export const GET = withApi(async (req) => { + const timeRange = req.nextUrl.searchParams.get('timeRange') || 'all'; + const result = await getICMContractFeesData(timeRange); + return successResponse(result); +}); diff --git a/app/api/icm-flow/route.ts b/app/api/icm-flow/route.ts index 3f4d51231ff..731af781334 100644 --- a/app/api/icm-flow/route.ts +++ b/app/api/icm-flow/route.ts @@ -1,5 +1,5 @@ -import { NextResponse } from 'next/server'; -import { getICMFlowData } from "@/lib/icm-clickhouse"; +import { withApi, successResponse } from '@/lib/api'; +import { getICMFlowData } from '@/lib/icm-clickhouse'; interface ICMFlowData { sourceChain: string; @@ -31,116 +31,70 @@ interface ICMFlowResponse { failedChainIds: string[]; } -const CACHE_CONTROL_HEADER = 'public, max-age=14400, s-maxage=14400, stale-while-revalidate=86400'; - // Cache for flow data - keyed by days parameter const cachedFlowData: Map = new Map(); const CACHE_DURATION = 4 * 60 * 60 * 1000; // 4 hours -export async function GET(request: Request) { - try { - const { searchParams } = new URL(request.url); - const days = parseInt(searchParams.get('days') || '30', 10); - const clearCache = searchParams.get('clearCache') === 'true'; +export const GET = withApi(async (req) => { + const days = parseInt(req.nextUrl.searchParams.get('days') || '30', 10); + const clearCache = req.nextUrl.searchParams.get('clearCache') === 'true'; + + // Check cache for this specific days value + const cached = cachedFlowData.get(days); + if (!clearCache && cached && Date.now() - cached.timestamp < CACHE_DURATION) { + return successResponse(cached.data); + } - // Check cache for this specific days value - const cached = cachedFlowData.get(days); - if (!clearCache && cached && Date.now() - cached.timestamp < CACHE_DURATION) { - return NextResponse.json(cached.data, { - headers: { - 'Cache-Control': CACHE_CONTROL_HEADER, - 'X-Data-Source': 'cache', - 'X-Cache-Timestamp': new Date(cached.timestamp).toISOString(), - 'X-Days': days.toString(), - } + // Fetch flow data from ClickHouse shared cache + const flows = await getICMFlowData(days); + + // Build source and target node lists + const sourceNodesMap = new Map(); + const targetNodesMap = new Map(); + + flows.forEach((flow) => { + const sourceKey = flow.sourceChainId || flow.sourceChain; + if (!sourceNodesMap.has(sourceKey)) { + sourceNodesMap.set(sourceKey, { + id: sourceKey, + name: flow.sourceChain, + logo: flow.sourceLogo, + color: flow.sourceColor, + totalMessages: 0, + isSource: true, }); } + sourceNodesMap.get(sourceKey)!.totalMessages += flow.messageCount; + + const targetKey = flow.targetChainId || flow.targetChain; + if (!targetNodesMap.has(targetKey)) { + targetNodesMap.set(targetKey, { + id: targetKey, + name: flow.targetChain, + logo: flow.targetLogo, + color: flow.targetColor, + totalMessages: 0, + isSource: false, + }); + } + targetNodesMap.get(targetKey)!.totalMessages += flow.messageCount; + }); - // Fetch flow data from ClickHouse shared cache - const flows = await getICMFlowData(days); - - // Build source and target node lists - const sourceNodesMap = new Map(); - const targetNodesMap = new Map(); - - flows.forEach(flow => { - // Source nodes - const sourceKey = flow.sourceChainId || flow.sourceChain; - if (!sourceNodesMap.has(sourceKey)) { - sourceNodesMap.set(sourceKey, { - id: sourceKey, - name: flow.sourceChain, - logo: flow.sourceLogo, - color: flow.sourceColor, - totalMessages: 0, - isSource: true, - }); - } - sourceNodesMap.get(sourceKey)!.totalMessages += flow.messageCount; - - // Target nodes - const targetKey = flow.targetChainId || flow.targetChain; - if (!targetNodesMap.has(targetKey)) { - targetNodesMap.set(targetKey, { - id: targetKey, - name: flow.targetChain, - logo: flow.targetLogo, - color: flow.targetColor, - totalMessages: 0, - isSource: false, - }); - } - targetNodesMap.get(targetKey)!.totalMessages += flow.messageCount; - }); - - const sourceNodes = Array.from(sourceNodesMap.values()) - .sort((a, b) => b.totalMessages - a.totalMessages); - const targetNodes = Array.from(targetNodesMap.values()) - .sort((a, b) => b.totalMessages - a.totalMessages); - - const totalMessages = flows.reduce((sum, f) => sum + f.messageCount, 0); - - const response: ICMFlowResponse = { - flows, - sourceNodes, - targetNodes, - totalMessages, - last_updated: Date.now(), - failedChainIds: [], - }; - - // Update cache for this days value - cachedFlowData.set(days, { data: response, timestamp: Date.now() }); + const sourceNodes = Array.from(sourceNodesMap.values()).sort((a, b) => b.totalMessages - a.totalMessages); + const targetNodes = Array.from(targetNodesMap.values()).sort((a, b) => b.totalMessages - a.totalMessages); - return NextResponse.json(response, { - headers: { - 'Cache-Control': CACHE_CONTROL_HEADER, - 'X-Data-Source': 'fresh', - 'X-Total-Flows': flows.length.toString(), - 'X-Days': days.toString(), - } - }); - } catch (error) { - console.error('Error in ICM flow API:', error); + const totalMessages = flows.reduce((sum, f) => sum + f.messageCount, 0); - const { searchParams } = new URL(request.url); - const days = parseInt(searchParams.get('days') || '30', 10); + const response: ICMFlowResponse = { + flows, + sourceNodes, + targetNodes, + totalMessages, + last_updated: Date.now(), + failedChainIds: [], + }; - // Return cached data if available for this days value or any cached data - const cached = cachedFlowData.get(days) || cachedFlowData.get(30) || Array.from(cachedFlowData.values())[0]; - if (cached) { - return NextResponse.json(cached.data, { - status: 206, - headers: { - 'X-Data-Source': 'fallback-cache', - 'X-Error': 'true', - } - }); - } + cachedFlowData.set(days, { data: response, timestamp: Date.now() }); - return NextResponse.json( - { error: 'Failed to fetch ICM flow data' }, - { status: 500 } - ); - } -} + return successResponse(response); +}); diff --git a/app/api/icm-stats/route.ts b/app/api/icm-stats/route.ts index 9e106b7c934..5f9a99e2155 100644 --- a/app/api/icm-stats/route.ts +++ b/app/api/icm-stats/route.ts @@ -1,8 +1,6 @@ -import { NextResponse } from 'next/server'; -import { ICMMetric, STATS_CONFIG, createICMMetric } from "@/types/stats"; -import { getICMStatsData } from "@/lib/icm-clickhouse"; - -const CACHE_CONTROL_HEADER = 'public, max-age=14400, s-maxage=14400, stale-while-revalidate=86400'; +import { withApi, successResponse } from '@/lib/api'; +import { ICMMetric, STATS_CONFIG, createICMMetric } from '@/types/stats'; +import { getICMStatsData } from '@/lib/icm-clickhouse'; interface AggregatedICMDataPoint { timestamp: number; @@ -19,68 +17,56 @@ interface ICMStats { let cachedData: Map = new Map(); -export async function GET(request: Request) { - try { - const { searchParams } = new URL(request.url); - const timeRange = searchParams.get('timeRange') || '30d'; - - let days = 30; - switch (timeRange) { - case '1d': days = 1; break; - case '7d': days = 7; break; - case '30d': days = 30; break; - case '90d': days = 90; break; - case '1y': days = 365; break; - case 'all': days = 730; break; - default: days = 30; - } +export const GET = withApi(async (req) => { + const timeRange = req.nextUrl.searchParams.get('timeRange') || '30d'; - if (searchParams.get('clearCache') === 'true') { - cachedData.clear(); - } - - const cached = cachedData.get(timeRange); - if (cached && Date.now() - cached.timestamp < STATS_CONFIG.CACHE.SHORT_DURATION) { - return NextResponse.json(cached.data, { - headers: { - 'Cache-Control': CACHE_CONTROL_HEADER, - 'X-Data-Source': 'cache', - 'X-Cache-Timestamp': new Date(cached.timestamp).toISOString(), - } - }); - } + let days = 30; + switch (timeRange) { + case '1d': + days = 1; + break; + case '7d': + days = 7; + break; + case '30d': + days = 30; + break; + case '90d': + days = 90; + break; + case '1y': + days = 365; + break; + case 'all': + days = 730; + break; + default: + days = 30; + } - const startTime = Date.now(); - const { aggregatedData, icmDataPoints } = await getICMStatsData(days); + if (req.nextUrl.searchParams.get('clearCache') === 'true') { + cachedData.clear(); + } - const dailyMessageVolume = createICMMetric(icmDataPoints); + const cached = cachedData.get(timeRange); + if (cached && Date.now() - cached.timestamp < STATS_CONFIG.CACHE.SHORT_DURATION) { + return successResponse(cached.data); + } - const metrics: ICMStats = { - dailyMessageVolume, - aggregatedData, - last_updated: Date.now() - }; + const { aggregatedData, icmDataPoints } = await getICMStatsData(days); - cachedData.set(timeRange, { - data: metrics, - timestamp: Date.now() - }); + const dailyMessageVolume = createICMMetric(icmDataPoints); - const fetchTime = Date.now() - startTime; + const metrics: ICMStats = { + dailyMessageVolume, + aggregatedData, + last_updated: Date.now(), + }; - return NextResponse.json(metrics, { - headers: { - 'Cache-Control': CACHE_CONTROL_HEADER, - 'X-Data-Source': 'fresh', - 'X-Fetch-Time': `${fetchTime}ms`, - } - }); + cachedData.set(timeRange, { + data: metrics, + timestamp: Date.now(), + }); - } catch (error) { - console.error('Error fetching ICM stats:', error); - return NextResponse.json( - { error: 'Failed to fetch ICM stats' }, - { status: 500 } - ); - } -} + return successResponse(metrics); +}); diff --git a/app/api/ictt-stats/route.ts b/app/api/ictt-stats/route.ts index 086d9b63df7..98f2e63ab1b 100644 --- a/app/api/ictt-stats/route.ts +++ b/app/api/ictt-stats/route.ts @@ -1,6 +1,6 @@ -import { NextResponse } from "next/server"; -import icttTokens from "@/constants/ictt-tokens.json"; -import l1ChainsData from "@/constants/l1-chains.json"; +import { withApi, successResponse } from '@/lib/api'; +import icttTokens from '@/constants/ictt-tokens.json'; +import l1ChainsData from '@/constants/l1-chains.json'; interface ICTTTransfer { homeChainBlockchainId: string; @@ -48,49 +48,42 @@ let cachedData: CachedData | null = null; // Helper function to get token info function getTokenInfo(address: string): TokenInfo { const normalizedAddress = address.toLowerCase(); - const token = (icttTokens as Record)[normalizedAddress] || - (icttTokens as Record)[address]; - + const token = + (icttTokens as Record)[normalizedAddress] || (icttTokens as Record)[address]; + if (token) { return token; } - + // Return formatted address if token not found return { name: `${address.slice(0, 6)}...${address.slice(-4)}`, - symbol: "UNKNOWN", + symbol: 'UNKNOWN', }; } // Helper function to get chain info by blockchain ID function getChainInfoByBlockchainId(blockchainId: string) { // Find the chain in l1-chains.json using the blockchainId field - const chain = (l1ChainsData as ChainData[]).find( - (c) => c.blockchainId === blockchainId - ); - + const chain = (l1ChainsData as ChainData[]).find((c) => c.blockchainId === blockchainId); + return chain; } // Fetch prices from CoinGecko -async function fetchTokenPrices( - coingeckoIds: string[] -): Promise> { +async function fetchTokenPrices(coingeckoIds: string[]): Promise> { if (coingeckoIds.length === 0) return {}; try { - const ids = coingeckoIds.join(","); - const response = await fetch( - `https://api.coingecko.com/api/v3/simple/price?ids=${ids}&vs_currencies=usd`, - { - headers: { - accept: "application/json", - }, - } - ); + const ids = coingeckoIds.join(','); + const response = await fetch(`https://api.coingecko.com/api/v3/simple/price?ids=${ids}&vs_currencies=usd`, { + headers: { + accept: 'application/json', + }, + }); if (!response.ok) { - console.error("CoinGecko API error:", response.status); + // CoinGecko API error; return empty prices return {}; } @@ -102,224 +95,176 @@ async function fetchTokenPrices( } return prices; - } catch (error) { - console.error("Error fetching token prices:", error); + } catch { + // Token price fetch failed; return empty return {}; } } -export async function GET(request: Request) { - try { - const { searchParams } = new URL(request.url); - const clearCache = searchParams.get("clearCache") === "true"; - const limit = parseInt(searchParams.get("limit") || "100"); - const offset = parseInt(searchParams.get("offset") || "0"); - - // Check cache - if ( - !clearCache && - cachedData && - Date.now() - cachedData.timestamp < CACHE_DURATION - ) { - const paginatedData = { - ...cachedData.data, - transfers: cachedData.data.allTransfers.slice(offset, offset + limit), - totalCount: cachedData.data.allTransfers.length, - hasMore: offset + limit < cachedData.data.allTransfers.length, - }; - delete paginatedData.allTransfers; - - return NextResponse.json(paginatedData, { - headers: { - "Cache-Control": "public, max-age=86400", - "X-Data-Source": "cache", - "X-Cache-Timestamp": new Date(cachedData.timestamp).toISOString(), - }, - }); - } +export const GET = withApi(async (req) => { + const searchParams = req.nextUrl.searchParams; + const clearCache = searchParams.get('clearCache') === 'true'; + const limit = Math.min(parseInt(searchParams.get('limit') || '100'), 100); + const offset = parseInt(searchParams.get('offset') || '0'); + + // Check cache + if (!clearCache && cachedData && Date.now() - cachedData.timestamp < CACHE_DURATION) { + const paginatedData = { + ...cachedData.data, + transfers: cachedData.data.allTransfers.slice(offset, offset + limit), + totalCount: cachedData.data.allTransfers.length, + hasMore: offset + limit < cachedData.data.allTransfers.length, + }; + delete paginatedData.allTransfers; - // Fetch ICTT data - const endTs = Math.floor(Date.now() / 1000); - const icttResponse = await fetch( - `https://idx6.solokhin.com/api/global/ictt/transfers?startTs=0&endTs=${endTs}` - ); + return successResponse(paginatedData); + } - if (!icttResponse.ok) { - throw new Error(`ICTT API error: ${icttResponse.status}`); - } + // Fetch ICTT data + const endTs = Math.floor(Date.now() / 1000); + const icttResponse = await fetch(`https://idx6.solokhin.com/api/global/ictt/transfers?startTs=0&endTs=${endTs}`); - const transfers: ICTTTransfer[] = await icttResponse.json(); + if (!icttResponse.ok) { + throw new Error(`ICTT API error: ${icttResponse.status}`); + } - // Collect unique token addresses with CoinGecko IDs - const coingeckoIds = new Set(); - const tokenAddressToCoingeckoId: Record = {}; + const transfers: ICTTTransfer[] = await icttResponse.json(); - transfers.forEach((transfer) => { - const tokenInfo = getTokenInfo(transfer.coinAddress); - if (tokenInfo.coingeckoId) { - coingeckoIds.add(tokenInfo.coingeckoId); - tokenAddressToCoingeckoId[transfer.coinAddress.toLowerCase()] = - tokenInfo.coingeckoId; - } - }); + // Collect unique token addresses with CoinGecko IDs + const coingeckoIds = new Set(); + const tokenAddressToCoingeckoId: Record = {}; - // Fetch prices - const prices = await fetchTokenPrices(Array.from(coingeckoIds)); - - // Process transfers with token info - const enrichedTransfers = transfers.map((transfer) => { - const tokenInfo = getTokenInfo(transfer.coinAddress); - const homeChain = getChainInfoByBlockchainId(transfer.homeChainBlockchainId); - const remoteChain = getChainInfoByBlockchainId(transfer.remoteChainBlockchainId); - - let priceUsd = 0; - if (tokenInfo.coingeckoId && prices[tokenInfo.coingeckoId]) { - priceUsd = prices[tokenInfo.coingeckoId]; - } - - return { - ...transfer, - tokenName: tokenInfo.name, - tokenSymbol: tokenInfo.symbol, - priceUsd, - volumeUsd: transfer.transferCoinsTotal * priceUsd, - homeChainLogo: homeChain?.chainLogoURI || "", - homeChainColor: homeChain?.color || "#E84142", - homeChainDisplayName: homeChain?.chainName || transfer.homeChainName, - remoteChainLogo: remoteChain?.chainLogoURI || "", - remoteChainColor: remoteChain?.color || "#E84142", - remoteChainDisplayName: remoteChain?.chainName || transfer.remoteChainName, - }; - }); + transfers.forEach((transfer) => { + const tokenInfo = getTokenInfo(transfer.coinAddress); + if (tokenInfo.coingeckoId) { + coingeckoIds.add(tokenInfo.coingeckoId); + tokenAddressToCoingeckoId[transfer.coinAddress.toLowerCase()] = tokenInfo.coingeckoId; + } + }); - // Calculate aggregated stats - const totalTransfers = transfers.reduce( - (sum, t) => sum + t.transferCount, - 0 - ); - - const totalVolumeUsd = enrichedTransfers.reduce( - (sum, t) => sum + t.volumeUsd, - 0 - ); - - // Get unique active chains - const activeChains = new Set(); - transfers.forEach((t) => { - activeChains.add(t.homeChainName); - activeChains.add(t.remoteChainName); - }); + // Fetch prices + const prices = await fetchTokenPrices(Array.from(coingeckoIds)); - // Get top token by transfer count - const tokenCounts: Record = {}; - enrichedTransfers.forEach((t) => { - const key = t.coinAddress.toLowerCase(); - if (!tokenCounts[key]) { - tokenCounts[key] = { - name: t.tokenName, - symbol: t.tokenSymbol, - count: 0 - }; - } - tokenCounts[key].count += t.transferCount; - }); + // Process transfers with token info + const enrichedTransfers = transfers.map((transfer) => { + const tokenInfo = getTokenInfo(transfer.coinAddress); + const homeChain = getChainInfoByBlockchainId(transfer.homeChainBlockchainId); + const remoteChain = getChainInfoByBlockchainId(transfer.remoteChainBlockchainId); - const topToken = Object.values(tokenCounts).sort( - (a, b) => b.count - a.count - )[0]; - - const topTokenPercentage = totalTransfers > 0 - ? ((topToken.count / totalTransfers) * 100).toFixed(1) - : "0"; - - // Get top routes - const routes: Record< - string, - { name: string; total: number; direction: string } - > = {}; - enrichedTransfers.forEach((t) => { - const routeKey = `${t.homeChainBlockchainId}_${t.remoteChainBlockchainId}_${t.direction}`; - if (!routes[routeKey]) { - const homeName = t.homeChainDisplayName; - const remoteName = t.remoteChainDisplayName; - // For "out" direction: home → remote (home is sending out to remote) - // For "in" direction: remote → home (remote is sending to home) - routes[routeKey] = { - name: - t.direction === "out" - ? `${homeName} → ${remoteName}` - : `${remoteName} → ${homeName}`, - total: 0, - direction: t.direction, - }; - } - routes[routeKey].total += t.transferCount; - }); - - const topRoutes = Object.values(routes) - .sort((a, b) => b.total - a.total) - .slice(0, 10); - - // Get token distribution - const tokenDistribution = Object.entries(tokenCounts) - .map(([address, data]) => ({ - name: data.name, - symbol: data.symbol, - value: data.count, - address, - })) - .sort((a, b) => b.value - a.value) - .slice(0, 10); - - const sortedTransfers = enrichedTransfers.sort( - (a, b) => b.transferCount - a.transferCount - ); - - const fullResponseData = { - overview: { - totalTransfers, - totalVolumeUsd, - activeChains: activeChains.size, - activeRoutes: Object.keys(routes).length, - topToken: { - name: topToken.name, - percentage: topTokenPercentage, - }, - }, - topRoutes, - tokenDistribution, - allTransfers: sortedTransfers, - last_updated: Date.now(), - }; + let priceUsd = 0; + if (tokenInfo.coingeckoId && prices[tokenInfo.coingeckoId]) { + priceUsd = prices[tokenInfo.coingeckoId]; + } - const { allTransfers, ...baseData } = fullResponseData; - const responseData = { - ...baseData, - transfers: sortedTransfers.slice(offset, offset + limit), - totalCount: sortedTransfers.length, - hasMore: offset + limit < sortedTransfers.length, + return { + ...transfer, + tokenName: tokenInfo.name, + tokenSymbol: tokenInfo.symbol, + priceUsd, + volumeUsd: transfer.transferCoinsTotal * priceUsd, + homeChainLogo: homeChain?.chainLogoURI || '', + homeChainColor: homeChain?.color || '#E84142', + homeChainDisplayName: homeChain?.chainName || transfer.homeChainName, + remoteChainLogo: remoteChain?.chainLogoURI || '', + remoteChainColor: remoteChain?.color || '#E84142', + remoteChainDisplayName: remoteChain?.chainName || transfer.remoteChainName, }; + }); + + // Calculate aggregated stats + const totalTransfers = transfers.reduce((sum, t) => sum + t.transferCount, 0); + + const totalVolumeUsd = enrichedTransfers.reduce((sum, t) => sum + t.volumeUsd, 0); + + // Get unique active chains + const activeChains = new Set(); + transfers.forEach((t) => { + activeChains.add(t.homeChainName); + activeChains.add(t.remoteChainName); + }); + + // Get top token by transfer count + const tokenCounts: Record = {}; + enrichedTransfers.forEach((t) => { + const key = t.coinAddress.toLowerCase(); + if (!tokenCounts[key]) { + tokenCounts[key] = { + name: t.tokenName, + symbol: t.tokenSymbol, + count: 0, + }; + } + tokenCounts[key].count += t.transferCount; + }); + + const topToken = Object.values(tokenCounts).sort((a, b) => b.count - a.count)[0]; + + const topTokenPercentage = totalTransfers > 0 ? ((topToken.count / totalTransfers) * 100).toFixed(1) : '0'; + + // Get top routes + const routes: Record = {}; + enrichedTransfers.forEach((t) => { + const routeKey = `${t.homeChainBlockchainId}_${t.remoteChainBlockchainId}_${t.direction}`; + if (!routes[routeKey]) { + const homeName = t.homeChainDisplayName; + const remoteName = t.remoteChainDisplayName; + // For "out" direction: home → remote (home is sending out to remote) + // For "in" direction: remote → home (remote is sending to home) + routes[routeKey] = { + name: t.direction === 'out' ? `${homeName} → ${remoteName}` : `${remoteName} → ${homeName}`, + total: 0, + direction: t.direction, + }; + } + routes[routeKey].total += t.transferCount; + }); + + const topRoutes = Object.values(routes) + .sort((a, b) => b.total - a.total) + .slice(0, 10); + + // Get token distribution + const tokenDistribution = Object.entries(tokenCounts) + .map(([address, data]) => ({ + name: data.name, + symbol: data.symbol, + value: data.count, + address, + })) + .sort((a, b) => b.value - a.value) + .slice(0, 10); + + const sortedTransfers = enrichedTransfers.sort((a, b) => b.transferCount - a.transferCount); + + const fullResponseData = { + overview: { + totalTransfers, + totalVolumeUsd, + activeChains: activeChains.size, + activeRoutes: Object.keys(routes).length, + topToken: { + name: topToken.name, + percentage: topTokenPercentage, + }, + }, + topRoutes, + tokenDistribution, + allTransfers: sortedTransfers, + last_updated: Date.now(), + }; - cachedData = { - data: fullResponseData, - timestamp: Date.now(), - }; + const { allTransfers: _allTransfers, ...baseData } = fullResponseData; + const responseData = { + ...baseData, + transfers: sortedTransfers.slice(offset, offset + limit), + totalCount: sortedTransfers.length, + hasMore: offset + limit < sortedTransfers.length, + }; - return NextResponse.json(responseData, { - headers: { - "Cache-Control": "public, max-age=86400", - "X-Data-Source": "fresh", - }, - }); - } catch (error) { - console.error("Error fetching ICTT stats:", error); - return NextResponse.json( - { - error: "Failed to fetch ICTT stats", - message: error instanceof Error ? error.message : "Unknown error", - }, - { status: 500 } - ); - } -} + cachedData = { + data: fullResponseData, + timestamp: Date.now(), + }; + return successResponse(responseData); +}); diff --git a/app/api/infrabuidl/route.ts b/app/api/infrabuidl/route.ts index 37cc74f67bb..bc58cfd1de3 100644 --- a/app/api/infrabuidl/route.ts +++ b/app/api/infrabuidl/route.ts @@ -1,86 +1,107 @@ -import { NextResponse } from 'next/server'; +import type { NextRequest } from 'next/server'; +import { z } from 'zod'; +import { withApi } from '@/lib/api/with-api'; +import { successResponse } from '@/lib/api/response'; +import { BadRequestError, InternalError } from '@/lib/api/errors'; const HUBSPOT_API_KEY = process.env.HUBSPOT_API_KEY; const HUBSPOT_PORTAL_ID = process.env.HUBSPOT_PORTAL_ID; const HUBSPOT_INFRABUIDL_FORM_GUID = process.env.HUBSPOT_INFRABUIDL_FORM_GUID; -export async function POST(request: Request) { - try { +const infrabuidlSchema = z + .object({ + firstname: z.string().min(1), + lastname: z.string().min(1), + email: z.string().email(), + gdpr: z.boolean().optional(), + marketing_consent: z.boolean().optional(), + }) + .passthrough(); + +// withApi: auth intentionally omitted — public form submission +export const POST = withApi>( + async (req: NextRequest, { body: formData }) => { if (!HUBSPOT_API_KEY || !HUBSPOT_PORTAL_ID || !HUBSPOT_INFRABUIDL_FORM_GUID) { - console.error('Missing environment variables: HUBSPOT_API_KEY, HUBSPOT_PORTAL_ID, or HUBSPOT_INFRABUIDL_FORM_GUID'); - return NextResponse.json( - { success: false, message: 'Server configuration error' }, - { status: 500 } - ); + throw new InternalError('Server configuration error'); } - const clonedRequest = request.clone(); - let formData; - try { - formData = await clonedRequest.json(); - } catch (error) { - console.error('Error parsing request body:', error); - return NextResponse.json( - { success: false, message: 'Invalid request body' }, - { status: 400 } - ); - } - const processedFormData: Record = {}; - - Object.entries(formData).forEach(([key, value]) => { + + Object.entries(formData as Record).forEach(([key, value]) => { if (['firstname', 'lastname', 'email', 'gdpr', 'marketing_consent'].includes(key)) { processedFormData[key] = value; } else { processedFormData[`2-44649732/${key}`] = value; } }); - + // Handle conditional fields with defaults - processedFormData["2-44649732/project_type_ai"] = formData.project_type || "N/A"; - processedFormData["2-44649732/project_type_other"] = formData.project_type_other || "N/A"; - processedFormData["2-44649732/token_launch_other"] = formData.token_launch_other || "N/A"; - processedFormData["2-44649732/direct_competitor_1"] = formData.direct_competitor_1 || "N/A"; - processedFormData["2-44649732/applicant_job_role_other"] = formData.applicant_job_role_other || "N/A"; - processedFormData["2-44649732/avalanche_l1_project_benefited_1"] = formData.avalanche_l1_project_benefited_1 || "N/A"; - processedFormData["2-44649732/previous_avalanche_project_info"] = formData.previous_avalanche_project_info || "N/A"; - processedFormData["2-44649732/direct_competitor_1_website"] = formData.direct_competitor_1_website || "N/A"; - processedFormData["2-44649732/program_referrer"] = formData.program_referrer || "N/A"; - processedFormData["2-44649732/multichain_chains"] = formData.multichain_chains || "N/A"; - processedFormData["2-44649732/avalanche_l1_project_benefited_1_website"] = formData.avalanche_l1_project_benefited_1_website || "N/A"; - processedFormData["2-44649732/applicant_first_name"] = formData.firstname; - processedFormData["2-44649732/applicant_last_name"] = formData.lastname; - + processedFormData['2-44649732/project_type_ai'] = (formData as any).project_type || 'N/A'; + processedFormData['2-44649732/project_type_other'] = (formData as any).project_type_other || 'N/A'; + processedFormData['2-44649732/token_launch_other'] = (formData as any).token_launch_other || 'N/A'; + processedFormData['2-44649732/direct_competitor_1'] = (formData as any).direct_competitor_1 || 'N/A'; + processedFormData['2-44649732/applicant_job_role_other'] = (formData as any).applicant_job_role_other || 'N/A'; + processedFormData['2-44649732/avalanche_l1_project_benefited_1'] = + (formData as any).avalanche_l1_project_benefited_1 || 'N/A'; + processedFormData['2-44649732/previous_avalanche_project_info'] = + (formData as any).previous_avalanche_project_info || 'N/A'; + processedFormData['2-44649732/direct_competitor_1_website'] = + (formData as any).direct_competitor_1_website || 'N/A'; + processedFormData['2-44649732/program_referrer'] = (formData as any).program_referrer || 'N/A'; + processedFormData['2-44649732/multichain_chains'] = (formData as any).multichain_chains || 'N/A'; + processedFormData['2-44649732/avalanche_l1_project_benefited_1_website'] = + (formData as any).avalanche_l1_project_benefited_1_website || 'N/A'; + processedFormData['2-44649732/applicant_first_name'] = formData.firstname; + processedFormData['2-44649732/applicant_last_name'] = formData.lastname; + // Handle old field structure for backward compatibility - processedFormData["2-44649732/funding_round"] = "N/A"; // Removed field - processedFormData["2-44649732/funding_amount"] = "N/A"; // Removed field - processedFormData["2-44649732/funding_entity"] = "N/A"; // Removed field - processedFormData["2-44649732/requested_funding_range"] = formData.requested_funding_range_milestone || "N/A"; - + processedFormData['2-44649732/funding_round'] = 'N/A'; + processedFormData['2-44649732/funding_amount'] = 'N/A'; + processedFormData['2-44649732/funding_entity'] = 'N/A'; + processedFormData['2-44649732/requested_funding_range'] = + (formData as any).requested_funding_range_milestone || 'N/A'; + // Handle new funding amount fields - processedFormData["2-44649732/previous_funding_amount_codebase"] = formData.funding_amount_codebase || "0"; - processedFormData["2-44649732/previous_funding_amount_infrabuidl"] = formData.funding_amount_infrabuidl || "0"; - processedFormData["2-44649732/previous_funding_amount_infrabuidl_ai"] = formData.funding_amount_infrabuidl_ai || "0"; - processedFormData["2-44649732/retro9000_previous_funding_amount"] = formData.funding_amount_retro9000 || "0"; - processedFormData["2-44649732/previous_funding_amount_blizzard"] = formData.funding_amount_blizzard || "0"; - processedFormData["2-44649732/previous_funding_amount_ava_labs"] = formData.funding_amount_ava_labs || "0"; - processedFormData["2-44649732/previous_funding_amount_entity_other"] = formData.funding_amount_other_avalanche || "0"; - + processedFormData['2-44649732/previous_funding_amount_codebase'] = (formData as any).funding_amount_codebase || '0'; + processedFormData['2-44649732/previous_funding_amount_infrabuidl'] = + (formData as any).funding_amount_infrabuidl || '0'; + processedFormData['2-44649732/previous_funding_amount_infrabuidl_ai'] = + (formData as any).funding_amount_infrabuidl_ai || '0'; + processedFormData['2-44649732/retro9000_previous_funding_amount'] = + (formData as any).funding_amount_retro9000 || '0'; + processedFormData['2-44649732/previous_funding_amount_blizzard'] = (formData as any).funding_amount_blizzard || '0'; + processedFormData['2-44649732/previous_funding_amount_ava_labs'] = (formData as any).funding_amount_ava_labs || '0'; + processedFormData['2-44649732/previous_funding_amount_entity_other'] = + (formData as any).funding_amount_other_avalanche || '0'; + // Handle previous funding non-avalanche fields - const previousFunding = Array.isArray(formData.previous_funding) ? formData.previous_funding : [formData.previous_funding]; - processedFormData["2-44649732/previous_funding_non_avalanche_grant"] = previousFunding.includes("Grant") ? "Yes" : "No"; - processedFormData["2-44649732/previous_funding_non_avalanche___angel_investment"] = previousFunding.includes("Angel Investment") ? "Yes" : "No"; - processedFormData["2-44649732/previous_funding_non_avalanche___pre_seed"] = previousFunding.includes("Pre-Seed") ? "Yes" : "No"; - processedFormData["2-44649732/previous_funding_non_avalanche___seed"] = previousFunding.includes("Seed") ? "Yes" : "No"; - processedFormData["2-44649732/previous_funding_non_avalanche___series_a"] = previousFunding.includes("Series A") ? "Yes" : "No"; - + const previousFunding = Array.isArray((formData as any).previous_funding) + ? (formData as any).previous_funding + : [(formData as any).previous_funding]; + processedFormData['2-44649732/previous_funding_non_avalanche_grant'] = previousFunding.includes('Grant') + ? 'Yes' + : 'No'; + processedFormData['2-44649732/previous_funding_non_avalanche___angel_investment'] = previousFunding.includes( + 'Angel Investment', + ) + ? 'Yes' + : 'No'; + processedFormData['2-44649732/previous_funding_non_avalanche___pre_seed'] = previousFunding.includes('Pre-Seed') + ? 'Yes' + : 'No'; + processedFormData['2-44649732/previous_funding_non_avalanche___seed'] = previousFunding.includes('Seed') + ? 'Yes' + : 'No'; + processedFormData['2-44649732/previous_funding_non_avalanche___series_a'] = previousFunding.includes('Series A') + ? 'Yes' + : 'No'; + // Handle similar project fields - processedFormData["2-44649732/similar_project_name_1"] = formData.similar_project_name_1 || "N/A"; - processedFormData["2-44649732/similar_project_website_1"] = formData.similar_project_website_1 || "N/A"; - + processedFormData['2-44649732/similar_project_name_1'] = (formData as any).similar_project_name_1 || 'N/A'; + processedFormData['2-44649732/similar_project_website_1'] = (formData as any).similar_project_website_1 || 'N/A'; + const fields = Object.entries(processedFormData).map(([name, value]) => { let formattedValue: any; - if (Array.isArray(value)) { formattedValue = value.join(';'); } else if (value instanceof Date) { @@ -88,10 +109,9 @@ export async function POST(request: Request) { } else { formattedValue = value; } - return { name, value: formattedValue }; }); - + interface HubspotPayload { fields: { name: string; value: any }[]; context: { pageUri: string; pageName: string }; @@ -107,76 +127,62 @@ export async function POST(request: Request) { }; }; } - + const hubspotPayload: HubspotPayload = { - fields: fields, + fields, context: { - pageUri: request.headers.get('referer') || 'https://build.avax.network', - pageName: 'infraBUIDL Grant Application' - } + pageUri: req.headers.get('referer') || 'https://build.avax.network', + pageName: 'infraBUIDL Grant Application', + }, }; if (formData.gdpr === true) { hubspotPayload.legalConsentOptions = { consent: { consentToProcess: true, - text: "I agree and authorize the Avalanche Foundation to utilize artificial intelligence systems to process the information in my application, any related material I provide and any related communications between me and the Avalanche Foundation, in order to assess the eligibility and suitability of my application and proposal.", + text: 'I agree and authorize the Avalanche Foundation to utilize artificial intelligence systems to process the information in my application, any related material I provide and any related communications between me and the Avalanche Foundation, in order to assess the eligibility and suitability of my application and proposal.', communications: [ { value: formData.marketing_consent === true, subscriptionTypeId: 999, - text: "I would like to receive marketing emails from the Avalanche Foundation." - } - ] - } + text: 'I would like to receive marketing emails from the Avalanche Foundation.', + }, + ], + }, }; } - - const hubspotResponse = await fetch( - `https://api.hsforms.com/submissions/v3/integration/submit/${HUBSPOT_PORTAL_ID}/${HUBSPOT_INFRABUIDL_FORM_GUID}`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${HUBSPOT_API_KEY}` - }, - body: JSON.stringify(hubspotPayload) - } - ); - const responseStatus = hubspotResponse.status; - let hubspotResult; + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 30_000); + try { - const clonedResponse = hubspotResponse.clone(); - try { - hubspotResult = await hubspotResponse.json(); - } catch (jsonError) { - const text = await clonedResponse.text(); - console.error('Non-JSON response from HubSpot:', text); - hubspotResult = { status: 'error', message: text }; - } - } catch (error) { - console.error('Error reading HubSpot response:', error); - hubspotResult = { status: 'error', message: 'Could not read HubSpot response' }; - } - - if (!hubspotResponse.ok) { - return NextResponse.json( - { - success: false, - status: responseStatus, - response: hubspotResult + const hubspotResponse = await fetch( + `https://api.hsforms.com/submissions/v3/integration/submit/${HUBSPOT_PORTAL_ID}/${HUBSPOT_INFRABUIDL_FORM_GUID}`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${HUBSPOT_API_KEY}`, + }, + body: JSON.stringify(hubspotPayload), + signal: controller.signal, }, - { status: responseStatus } ); - } - return NextResponse.json({ success: true }); - } catch (error) { - console.error('Error processing form submission:', error); - return NextResponse.json( - { success: false, message: error instanceof Error ? error.message : 'Internal server error' }, - { status: 500 } - ); - } -} \ No newline at end of file + if (!hubspotResponse.ok) { + let hubspotResult: any; + try { + hubspotResult = await hubspotResponse.json(); + } catch { + hubspotResult = { message: 'Could not read HubSpot response' }; + } + throw new BadRequestError(hubspotResult?.message || 'Failed to submit to HubSpot'); + } + + return successResponse({ success: true }); + } finally { + clearTimeout(timeoutId); + } + }, + { schema: infrabuidlSchema }, +); diff --git a/app/api/latest-blogs/route.ts b/app/api/latest-blogs/route.ts index ed9a4005080..935186eaf7e 100644 --- a/app/api/latest-blogs/route.ts +++ b/app/api/latest-blogs/route.ts @@ -1,33 +1,23 @@ -import { NextResponse } from 'next/server'; +import { withApi, successResponse } from '@/lib/api'; import { blog } from '@/lib/source'; export const dynamic = 'force-static'; -export const revalidate = 3600; // Revalidate every hour +export const revalidate = 3600; -export async function GET() { - try { - const blogPages = [...blog.getPages()] - .sort( - (a, b) => - new Date((b.data.date as string) ?? b.url).getTime() - - new Date((a.data.date as string) ?? a.url).getTime() - ) - .slice(0, 2); +export const GET = withApi(async () => { + const blogPages = [...blog.getPages()] + .sort( + (a, b) => + new Date((b.data.date as string) ?? b.url).getTime() - new Date((a.data.date as string) ?? a.url).getTime(), + ) + .slice(0, 2); - const latestBlogs = blogPages.map((page) => ({ - title: page.data.title || 'Untitled', - description: page.data.description || '', - url: page.url, - date: - page.data.date instanceof Date - ? page.data.date.toISOString() - : (page.data.date as string) || '', - })); - - return NextResponse.json(latestBlogs); - } catch (error) { - console.error('Error fetching latest blogs:', error); - return NextResponse.json([], { status: 500 }); - } -} + const latestBlogs = blogPages.map((page) => ({ + title: page.data.title || 'Untitled', + description: page.data.description || '', + url: page.url, + date: page.data.date instanceof Date ? page.data.date.toISOString() : (page.data.date as string) || '', + })); + return successResponse(latestBlogs); +}); diff --git a/app/api/managed-testnet-nodes/[subnetId]/[nodeIndex]/route.ts b/app/api/managed-testnet-nodes/[subnetId]/[nodeIndex]/route.ts index 0e76bb6b4e7..2fbad905e29 100644 --- a/app/api/managed-testnet-nodes/[subnetId]/[nodeIndex]/route.ts +++ b/app/api/managed-testnet-nodes/[subnetId]/[nodeIndex]/route.ts @@ -1,3 +1,4 @@ +// withApi: not applicable — uses getUserId() for auth import { NextRequest, NextResponse } from 'next/server'; import { getUserId, validateSubnetId, jsonOk, jsonError } from '../../utils'; import { prisma } from '@/prisma/prisma'; @@ -25,8 +26,8 @@ async function handleGetNode(subnetId: string, nodeIndex: number): Promise } + { params }: { params: Promise<{ subnetId: string; nodeIndex: string }> }, ): Promise { const { subnetId, nodeIndex } = await params; - + const parsedIndex = parseInt(nodeIndex, 10); if (Number.isNaN(parsedIndex) || parsedIndex < 0) { return jsonError(400, 'Invalid node index format'); @@ -135,10 +136,10 @@ export async function GET( export async function DELETE( request: NextRequest, - { params }: { params: Promise<{ subnetId: string; nodeIndex: string }> } + { params }: { params: Promise<{ subnetId: string; nodeIndex: string }> }, ): Promise { const { subnetId, nodeIndex } = await params; - + const parsedIndex = parseInt(nodeIndex, 10); if (Number.isNaN(parsedIndex) || parsedIndex < 0) { return jsonError(400, 'Invalid node index format'); diff --git a/app/api/managed-testnet-nodes/route.ts b/app/api/managed-testnet-nodes/route.ts index d8782332154..e61ba4a08ce 100644 --- a/app/api/managed-testnet-nodes/route.ts +++ b/app/api/managed-testnet-nodes/route.ts @@ -1,138 +1,108 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { rateLimited, getUserId, validateSubnetId, jsonOk, jsonError } from './utils'; +import type { NextRequest } from 'next/server'; +import { withApi } from '@/lib/api/with-api'; +import { successResponse } from '@/lib/api/response'; +import { BadRequestError, ConflictError, InternalError, ValidationError } from '@/lib/api/errors'; import { builderHubAddNode, selectNewestNode, createDbNode, getUserNodes } from './service'; -import { getBlockchainInfo } from '../../../components/toolbox/coreViem/utils/glacier'; -import { CreateNodeRequest, SubnetStatusResponse } from './types'; +import { getBlockchainInfo } from '@/components/toolbox/coreViem/utils/glacier'; +import type { CreateNodeRequest } from './types'; import { prisma } from '@/prisma/prisma'; import { SUBNET_EVM_VM_ID } from '@/constants/console'; import { checkAndAwardConsoleBadges } from '@/server/services/consoleBadge/consoleBadgeService'; import type { AwardedConsoleBadge } from '@/server/services/consoleBadge/types'; +import { validateSubnetId } from './utils'; + +export const GET = withApi( + async (_req: NextRequest, { session }) => { + const userId = session.user.id; + const nodes = await getUserNodes(userId); + return successResponse({ nodes, total: nodes.length }); + }, + { auth: true }, +); + +// schema: not applicable — body validated inline with field-level checks +export const POST = withApi( + async (req: NextRequest, { session }) => { + const userId = session.user.id; -// Types moved to ./types - -/** - * GET /api/managed-testnet-nodes - * Lists all active nodes for the authenticated user from the database. - */ -async function handleGetNodes(): Promise { - const { userId, error } = await getUserId(); - if (error) return error; - - try { - const nodes = await getUserNodes(userId!); - return jsonOk({ nodes, total: nodes.length }); - - } catch (error) { - return jsonError(500, error instanceof Error ? error.message : 'Failed to fetch node registrations', error); - } -} - -/** - * POST /api/managed-testnet-nodes - * Creates a new managed node for a subnet by calling Builder Hub, then stores - * the node in the database. Enforces a 3-day expiration window in DB. - */ -async function handleCreateNode(request: NextRequest): Promise { - const { userId, error } = await getUserId(); - if (error) return error; - - try { // Enforce max 3 active nodes per user - const activeNodes = await getUserNodes(userId!); + const activeNodes = await getUserNodes(userId); if (activeNodes.length >= 3) { - return jsonError(429, 'You already have 3 active nodes. Delete one or wait for expiry.'); + throw new BadRequestError('You already have 3 active nodes. Delete one or wait for expiry.'); } - const body: CreateNodeRequest = await request.json(); + const body: CreateNodeRequest = await req.json(); const { subnetId, blockchainId } = body; if (!subnetId || !blockchainId) { - return NextResponse.json( - { - error: 'Bad request', - message: 'Both subnetId and blockchainId are required' - }, - { status: 400 } - ); + throw new ValidationError('Both subnetId and blockchainId are required'); } if (!validateSubnetId(subnetId)) { - return NextResponse.json( - { - error: 'Bad request', - message: 'Invalid subnet ID format' - }, - { status: 400 } - ); + throw new ValidationError('Invalid subnet ID format'); } // Fetch chain information and enforce Subnet EVM VM check const blockchainInfo = await getBlockchainInfo(blockchainId); const chainName: string | null = blockchainInfo.blockchainName || null; if (blockchainInfo.vmId !== SUBNET_EVM_VM_ID) { - return jsonError(400, `Unsupported VM for this service. Expected Subnet EVM (vmID ${SUBNET_EVM_VM_ID}), got ${blockchainInfo.vmId}.`); + throw new BadRequestError( + `Unsupported VM for this service. Expected Subnet EVM (vmID ${SUBNET_EVM_VM_ID}), got ${blockchainInfo.vmId}.`, + ); } // Make the request to Builder Hub API to add node - const data: SubnetStatusResponse = await builderHubAddNode(subnetId); + const data = await builderHubAddNode(subnetId); // Store the new node in database - if (data.nodes && data.nodes.length > 0) { - const newestNode = selectNewestNode(data.nodes); - const createdNode = await createDbNode({ userId: userId!, subnetId, blockchainId, newestNode, chainName }); - if (!createdNode) return jsonError(409, 'Node already exists for this user (active)'); + if (!data.nodes || data.nodes.length === 0) { + throw new InternalError('No nodes returned from Builder Hub'); + } - let awardedBadges: AwardedConsoleBadge[] = []; - try { awardedBadges = await checkAndAwardConsoleBadges(userId!, 'node_registration'); } - catch (e) { console.error('Badge check failed:', e); } + const newestNode = selectNewestNode(data.nodes); + const createdNode = await createDbNode({ userId, subnetId, blockchainId, newestNode, chainName }); + if (!createdNode) { + throw new ConflictError('Node already exists for this user (active)'); + } - return jsonOk({ + let awardedBadges: AwardedConsoleBadge[] = []; + try { + awardedBadges = await checkAndAwardConsoleBadges(userId, 'node_registration'); + } catch { + // Badge check is non-critical + } + + return successResponse( + { node: createdNode, builder_hub_response: { nodeID: newestNode.nodeInfo.result.nodeID, nodePOP: newestNode.nodeInfo.result.nodePOP, - nodeIndex: newestNode.nodeIndex + nodeIndex: newestNode.nodeIndex, }, awardedBadges, - }, 201); - } else { - return jsonError(502, 'No nodes returned from Builder Hub'); + }, + 201, + ); + }, + { auth: true }, +); + +export const DELETE = withApi( + async (req: NextRequest, { session }) => { + const userId = session.user.id; + const id = req.nextUrl.searchParams.get('id'); + if (!id) { + throw new ValidationError('Missing node id'); } - } catch (error) { - return jsonError(500, error instanceof Error ? error.message : 'Failed to create node', error); - } -} - -export async function GET(request: NextRequest): Promise { - return handleGetNodes(); -} - -export async function POST(request: NextRequest): Promise { - return handleCreateNode(request); -} - -/** - * DELETE /api/managed-testnet-nodes?id=NODE_DB_ID - * Account-only removal: marks a node as terminated by its DB id when node_index is unknown. - * Used to clean up nodes that were created before we had the node_index field. - */ -export async function DELETE(request: NextRequest): Promise { - const { userId, error } = await getUserId(); - if (error) return error; - if (!userId) return jsonError(401, 'Authentication required'); - - const { searchParams } = new URL(request.url); - const id = searchParams.get('id'); - if (!id) return jsonError(400, 'Missing node id'); - - try { const record = await prisma.nodeRegistration.findFirst({ where: { id, user_id: userId } }); - if (!record) return jsonError(404, 'Node not found'); + if (!record) { + throw new BadRequestError('Node not found'); + } await prisma.nodeRegistration.update({ where: { id }, data: { status: 'terminated' } }); - return jsonOk({ success: true, message: 'Node removed from your account.' }); - } catch (e) { - return jsonError(500, e instanceof Error ? e.message : 'Failed to remove node'); - } -} + return successResponse({ success: true, message: 'Node removed from your account.' }); + }, + { auth: true }, +); diff --git a/app/api/managed-testnet-nodes/utils.ts b/app/api/managed-testnet-nodes/utils.ts index b26da8aaa2e..4c0da52a7ba 100644 --- a/app/api/managed-testnet-nodes/utils.ts +++ b/app/api/managed-testnet-nodes/utils.ts @@ -13,12 +13,12 @@ export async function getUserId(): Promise<{ userId: string | null; error?: Next return { userId: null, error: NextResponse.json( - { + { error: 'Authentication required', - message: 'Please sign in to access managed testnet nodes' + message: 'Please sign in to access managed testnet nodes', }, - { status: 401 } - ) + { status: 401 }, + ), }; } return { userId: session.user.id }; @@ -40,15 +40,12 @@ type RateLimitConfig = { identifier: () => Promise; }; -export function rateLimited( - handler: (request: NextRequest) => Promise, - config: RateLimitConfig -) { +export function rateLimited(handler: (request: NextRequest) => Promise, config: RateLimitConfig) { const isDevelopment = process.env.NODE_ENV === 'development'; return rateLimit(handler, { windowMs: isDevelopment ? config.dev.windowMs : config.prod.windowMs, maxRequests: isDevelopment ? config.dev.max : config.prod.max, - identifier: config.identifier + identifier: config.identifier, }); } @@ -61,7 +58,6 @@ export function jsonOk(payload: any, status = 200) { export function jsonError(status: number, message: string, error?: unknown) { if (error) { try { - // eslint-disable-next-line no-console console.error(message, typeof error === 'string' ? error.slice(0, 500) : error); } catch {} } @@ -87,6 +83,3 @@ export async function extractServiceErrorMessage(response: Response): Promise { +async function handleRestartRelayer(relayerId: string, _request: NextRequest): Promise { const auth = await getUserId(); if (auth.error) return auth.error; if (!auth.userId) return jsonError(401, 'Authentication required'); @@ -21,8 +22,8 @@ async function handleRestartRelayer(relayerId: string, request: NextRequest): Pr const listResponse = await fetch(RelayerServiceURLs.list(password), { method: 'GET', headers: { - 'Accept': 'application/json' - } + Accept: 'application/json', + }, }); if (!listResponse.ok) { @@ -59,19 +60,19 @@ async function handleRestartRelayer(relayerId: string, request: NextRequest): Pr // URL encode the relayerId to handle special characters const encodedRelayerId = encodeURIComponent(relayerId); const restartUrl = RelayerServiceURLs.restart(encodedRelayerId, password); - - console.log(`[Relayers] Restarting relayer ${encodedRelayerId}`); - + + console.warn(`[Relayers] Restarting relayer ${encodedRelayerId}`); + const response = await fetch(restartUrl, { method: 'POST', headers: { - 'Accept': 'application/json', - 'Content-Type': 'application/json' + Accept: 'application/json', + 'Content-Type': 'application/json', }, - body: JSON.stringify({}) + body: JSON.stringify({}), }); - console.log(`[Relayers] Restart response status: ${response.status}`); + console.warn(`[Relayers] Restart response status: ${response.status}`); if (response.ok) { let responseData = {}; @@ -84,7 +85,7 @@ async function handleRestartRelayer(relayerId: string, request: NextRequest): Pr return jsonOk({ success: true, message: 'Relayer restarted successfully.', - data: responseData + data: responseData, }); } @@ -102,8 +103,7 @@ async function handleRestartRelayer(relayerId: string, request: NextRequest): Pr // Generic error message for any other failure console.error(`[Relayers] Restart failed (status: ${response.status})`); return jsonError(502, 'Failed to restart relayer.'); - - } catch (hubError) { + } catch { console.error('[Relayers] Restart request failed'); return jsonError(503, 'Builder Hub was unreachable.'); } @@ -111,9 +111,8 @@ async function handleRestartRelayer(relayerId: string, request: NextRequest): Pr export async function POST( request: NextRequest, - context: { params: Promise<{ relayerId: string }> } + context: { params: Promise<{ relayerId: string }> }, ): Promise { const { relayerId } = await context.params; return handleRestartRelayer(relayerId, request); } - diff --git a/app/api/managed-testnet-relayers/[relayerId]/route.ts b/app/api/managed-testnet-relayers/[relayerId]/route.ts index e5739bf2aac..ae0c53f1243 100644 --- a/app/api/managed-testnet-relayers/[relayerId]/route.ts +++ b/app/api/managed-testnet-relayers/[relayerId]/route.ts @@ -1,3 +1,4 @@ +// withApi: not applicable — uses getUserId() for auth import { NextRequest, NextResponse } from 'next/server'; import { getUserId, jsonOk, jsonError } from '../utils'; import { RelayerServiceURLs } from '../constants'; @@ -6,7 +7,7 @@ import { RelayerServiceURLs } from '../constants'; * DELETE /api/managed-testnet-relayers/[relayerId] * Deletes a relayer from the Builder Hub API. */ -async function handleDeleteRelayer(relayerId: string, request: NextRequest): Promise { +async function handleDeleteRelayer(relayerId: string, _request: NextRequest): Promise { const auth = await getUserId(); if (auth.error) return auth.error; if (!auth.userId) return jsonError(401, 'Authentication required'); @@ -21,8 +22,8 @@ async function handleDeleteRelayer(relayerId: string, request: NextRequest): Pro const listResponse = await fetch(RelayerServiceURLs.list(password), { method: 'GET', headers: { - 'Accept': 'application/json' - } + Accept: 'application/json', + }, }); if (!listResponse.ok) { @@ -59,31 +60,31 @@ async function handleDeleteRelayer(relayerId: string, request: NextRequest): Pro // URL encode the relayerId to handle special characters const encodedRelayerId = encodeURIComponent(relayerId); const deleteUrl = RelayerServiceURLs.delete(encodedRelayerId, password); - - console.log(`[Relayers] Deleting relayer ${encodedRelayerId}`); - + + console.warn(`[Relayers] Deleting relayer ${encodedRelayerId}`); + const response = await fetch(deleteUrl, { method: 'DELETE', headers: { - 'Accept': 'application/json' - } + Accept: 'application/json', + }, }); - - console.log(`[Relayers] Delete response status: ${response.status}`); + + console.warn(`[Relayers] Delete response status: ${response.status}`); if (response.ok || response.status === 404) { return jsonOk({ success: true, - message: response.status === 404 - ? 'Relayer was already deleted or expired in Builder Hub.' - : 'Relayer deleted successfully.' + message: + response.status === 404 + ? 'Relayer was already deleted or expired in Builder Hub.' + : 'Relayer deleted successfully.', }); } console.error(`[Relayers] Delete failed (status: ${response.status})`); return jsonError(502, 'Failed to delete relayer from Builder Hub.'); - - } catch (hubError) { + } catch { console.error('[Relayers] Builder Hub request failed'); return jsonError(503, 'Builder Hub was unreachable.'); } @@ -91,9 +92,8 @@ async function handleDeleteRelayer(relayerId: string, request: NextRequest): Pro export async function DELETE( request: NextRequest, - { params }: { params: Promise<{ relayerId: string }> } + { params }: { params: Promise<{ relayerId: string }> }, ): Promise { const { relayerId } = await params; return handleDeleteRelayer(relayerId, request); } - diff --git a/app/api/managed-testnet-relayers/route.ts b/app/api/managed-testnet-relayers/route.ts index ebdcd7a3345..3496636e73b 100644 --- a/app/api/managed-testnet-relayers/route.ts +++ b/app/api/managed-testnet-relayers/route.ts @@ -1,137 +1,116 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { getUserId, jsonOk, jsonError } from './utils'; +import type { NextRequest } from 'next/server'; +import { withApi } from '@/lib/api/with-api'; +import { successResponse } from '@/lib/api/response'; +import { InternalError, RateLimitError, ValidationError } from '@/lib/api/errors'; import { RelayerServiceURLs } from './constants'; -import { CreateRelayerRequest, Relayer } from './types'; - -/** - * GET /api/managed-testnet-relayers - * Lists all active relayers from the Builder Hub API. - */ -async function handleGetRelayers(request: NextRequest): Promise { - const { userId, error } = await getUserId(); - if (error) return error; - - const password = process.env.MANAGED_TESTNET_NODE_SERVICE_PASSWORD; - if (!password) { - return jsonError(503, 'Relayer service is not configured'); - } - - try { - const response = await fetch(RelayerServiceURLs.list(password), { - method: 'GET', - headers: { - 'Accept': 'application/json' +import type { CreateRelayerRequest, Relayer } from './types'; + +export const GET = withApi( + async (_req: NextRequest, { session }) => { + const userId = session.user.id; + const password = process.env.MANAGED_TESTNET_NODE_SERVICE_PASSWORD; + if (!password) { + throw new InternalError('Relayer service is not configured'); + } + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 15_000); + + try { + const response = await fetch(RelayerServiceURLs.list(password), { + method: 'GET', + headers: { Accept: 'application/json' }, + signal: controller.signal, + }); + + if (!response.ok) { + throw new InternalError('Failed to fetch relayers from Builder Hub'); } - }); - if (!response.ok) { - console.error(`[Relayers] Fetch failed (status: ${response.status})`); - return jsonError(502, 'Failed to fetch relayers from Builder Hub'); - } + const data = await response.json(); + + // Handle both array response and object with relayers property + let allRelayers: any[] = []; + if (Array.isArray(data)) { + allRelayers = data; + } else if (data && Array.isArray(data.relayers)) { + allRelayers = data.relayers; + } else if (data && typeof data === 'object') { + allRelayers = Object.values(data); + } - const data = await response.json(); - - // Handle both array response and object with relayers property - let allRelayers: any[] = []; - if (Array.isArray(data)) { - allRelayers = data; - } else if (data && Array.isArray(data.relayers)) { - allRelayers = data.relayers; - } else if (data && typeof data === 'object') { - // If it's an object with keys as relayer IDs, convert to array - allRelayers = Object.values(data); + // Map API response to our Relayer interface + const mappedRelayers = allRelayers.map((item: any) => ({ + relayerId: item.relayerId || item.address || item.id || item.relayer_id || '', + label: item.label || '', + configs: item.configs || [], + port: item.port || 0, + createdAt: item.createdAt || item.created_at || item.dateCreated || '', + expiresAt: item.expiresAt || item.expires_at || '', + health: item.health || null, + })); + + // Filter relayers by userId label (case-insensitive) + const relayers = mappedRelayers.filter( + (relayer: Relayer) => relayer.label && relayer.label.toLowerCase() === userId.toLowerCase(), + ); + + return successResponse({ relayers, total: relayers.length }); + } finally { + clearTimeout(timeoutId); + } + }, + { auth: true }, +); + +// schema: not applicable — body validated inline with field-level checks +export const POST = withApi( + async (req: NextRequest, { session }) => { + const userId = session.user.id; + const password = process.env.MANAGED_TESTNET_NODE_SERVICE_PASSWORD; + if (!password) { + throw new InternalError('Relayer service is not configured'); } - - // Map API response to our Relayer interface - const mappedRelayers = allRelayers.map((item: any) => ({ - relayerId: item.relayerId || item.address || item.id || item.relayer_id || '', - label: item.label || '', - configs: item.configs || [], - port: item.port || 0, - createdAt: item.createdAt || item.created_at || item.dateCreated || '', - expiresAt: item.expiresAt || item.expires_at || item.expiresAt || '', - health: item.health || null - })); - - // Filter relayers by userId label (case-insensitive to be safe) - const relayers = mappedRelayers.filter((relayer: Relayer) => - relayer.label && relayer.label.toLowerCase() === userId?.toLowerCase() - ); - - console.log(`[Relayers] Total from API: ${allRelayers.length}, Filtered for user ${userId}: ${relayers.length}`); - - return jsonOk({ relayers, total: relayers.length }); - - } catch (error) { - console.error('[Relayers] Fetch error'); - return jsonError(500, 'Failed to fetch relayers'); - } -} - -/** - * POST /api/managed-testnet-relayers - * Creates a new managed relayer by calling Builder Hub API. - */ -async function handleCreateRelayer(request: NextRequest): Promise { - const { userId, error } = await getUserId(); - if (error) return error; - - const password = process.env.MANAGED_TESTNET_NODE_SERVICE_PASSWORD; - if (!password) { - return jsonError(503, 'Relayer service is not configured'); - } - - try { - const body: CreateRelayerRequest = await request.json(); + + const body: CreateRelayerRequest = await req.json(); const { configs } = body; if (!configs || !Array.isArray(configs) || configs.length === 0) { - return jsonError(400, 'Configs array is required and must not be empty'); + throw new ValidationError('Configs array is required and must not be empty'); } - // Validate configs for (const config of configs) { if (!config.subnetId || !config.blockchainId || !config.rpcUrl || !config.wsUrl) { - return jsonError(400, 'Each config must have subnetId, blockchainId, rpcUrl, and wsUrl'); + throw new ValidationError('Each config must have subnetId, blockchainId, rpcUrl, and wsUrl'); } } - // Make the request to Builder Hub API to create relayer - const response = await fetch(RelayerServiceURLs.create(password), { - method: 'POST', - headers: { - 'Accept': 'application/json', - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - configs, - label: userId - }) - }); - - if (!response.ok) { - console.error(`[Relayers] Create failed (status: ${response.status})`); - if (response.status === 429) { - return jsonError(429, 'Rate limit exceeded. Please wait before creating again.'); + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 15_000); + + try { + const response = await fetch(RelayerServiceURLs.create(password), { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ configs, label: userId }), + signal: controller.signal, + }); + + if (!response.ok) { + if (response.status === 429) { + throw new RateLimitError('Rate limit exceeded. Please wait before creating again.'); + } + throw new InternalError('Failed to create relayer in Builder Hub'); } - return jsonError(502, 'Failed to create relayer in Builder Hub'); - } - - const data = await response.json(); - console.log('[Relayers] Created relayer successfully'); - return jsonOk({ relayer: data }, 201); - - } catch (error) { - console.error('[Relayers] Create error'); - return jsonError(500, 'Failed to create relayer'); - } -} - -export async function GET(request: NextRequest): Promise { - return handleGetRelayers(request); -} - -export async function POST(request: NextRequest): Promise { - return handleCreateRelayer(request); -} + const data = await response.json(); + return successResponse({ relayer: data }, 201); + } finally { + clearTimeout(timeoutId); + } + }, + { auth: true }, +); diff --git a/app/api/managed-testnet-relayers/utils.ts b/app/api/managed-testnet-relayers/utils.ts index f3260e952ed..e502618a729 100644 --- a/app/api/managed-testnet-relayers/utils.ts +++ b/app/api/managed-testnet-relayers/utils.ts @@ -12,12 +12,12 @@ export async function getUserId(): Promise<{ userId: string | null; error?: Next return { userId: null, error: NextResponse.json( - { + { error: 'Authentication required', - message: 'Please sign in to access managed testnet relayers' + message: 'Please sign in to access managed testnet relayers', }, - { status: 401 } - ) + { status: 401 }, + ), }; } return { userId: session.user.id }; @@ -30,7 +30,6 @@ export function jsonOk(payload: any, status = 200) { export function jsonError(status: number, message: string, error?: unknown) { if (error) { try { - // eslint-disable-next-line no-console console.error(message, typeof error === 'string' ? error.slice(0, 500) : error); } catch {} } @@ -56,4 +55,3 @@ export async function extractServiceErrorMessage(response: Response): Promise; + +// withApi: not applicable — MCP JSON-RPC protocol with own CORS + rate limiting +export const dynamic = 'force-dynamic'; + +// ============================================================================ +// SDK Initialization +// ============================================================================ + +const avalancheMainnet = new Avalanche({ network: 'mainnet' }); +const avalancheFuji = new Avalanche({ network: 'fuji' }); + +function getAvalancheSDK(network: string = 'mainnet') { + return network === 'fuji' ? avalancheFuji : avalancheMainnet; +} + +// ============================================================================ +// Caching Layer +// ============================================================================ + +interface CacheEntry { + data: T; + timestamp: number; +} + +const cache = new Map>(); +const CACHE_TTL = { + VALIDATORS: 24 * 60 * 60 * 1000, // 24 hours + METRICS: 4 * 60 * 60 * 1000, // 4 hours + BALANCE: 30 * 1000, // 30 seconds + CHAINS: 60 * 60 * 1000, // 1 hour +}; + +function getCached(key: string, ttl: number): T | null { + const entry = cache.get(key) as CacheEntry | undefined; + if (entry && Date.now() - entry.timestamp < ttl) { + return entry.data; + } + return null; +} + +function setCache(key: string, data: T): void { + cache.set(key, { data, timestamp: Date.now() }); +} + +// ============================================================================ +// MCP Server Configuration +// ============================================================================ + +const SERVER_INFO = { + name: 'avalanche-blockchain', + version: '1.0.0', + protocolVersion: '2024-11-05', +}; + +// Tool definitions following MCP spec +const TOOLS = [ + { + name: 'blockchain_get_native_balance', + description: 'Get the native token balance (AVAX) for an address on any Avalanche chain', + inputSchema: { + type: 'object', + properties: { + address: { type: 'string', description: 'The wallet address (0x format)' }, + chainId: { type: 'string', description: 'The chain ID (e.g., "43114" for C-Chain mainnet, "43113" for Fuji)' }, + }, + required: ['address', 'chainId'], + }, + }, + { + name: 'blockchain_get_token_balances', + description: 'Get all ERC20 token balances for an address', + inputSchema: { + type: 'object', + properties: { + address: { type: 'string', description: 'The wallet address (0x format)' }, + chainId: { type: 'string', description: 'The chain ID' }, + currency: { type: 'string', description: 'Currency for USD values (default: "usd")' }, + }, + required: ['address', 'chainId'], + }, + }, + { + name: 'blockchain_get_transactions', + description: 'Get transaction history for an address', + inputSchema: { + type: 'object', + properties: { + address: { type: 'string', description: 'The wallet address (0x format)' }, + chainId: { type: 'string', description: 'The chain ID' }, + pageSize: { type: 'number', description: 'Number of transactions to return (default: 25, max: 100)' }, + pageToken: { type: 'string', description: 'Pagination token for next page' }, + }, + required: ['address', 'chainId'], + }, + }, + { + name: 'blockchain_get_contract_info', + description: 'Get metadata and information about a smart contract', + inputSchema: { + type: 'object', + properties: { + address: { type: 'string', description: 'The contract address (0x format)' }, + chainId: { type: 'string', description: 'The chain ID' }, + }, + required: ['address', 'chainId'], + }, + }, + { + name: 'blockchain_list_validators', + description: 'List active validators on the Avalanche Primary Network', + inputSchema: { + type: 'object', + properties: { + network: { type: 'string', enum: ['mainnet', 'fuji'], description: 'Network to query (default: mainnet)' }, + pageSize: { type: 'number', description: 'Number of validators to return (default: 50, max: 100)' }, + sortBy: { + type: 'string', + enum: ['stakeAmount', 'delegatorCount', 'uptime'], + description: 'Sort order (default: stakeAmount)', + }, + }, + required: [], + }, + }, + { + name: 'blockchain_get_validator_details', + description: 'Get detailed information about a specific validator', + inputSchema: { + type: 'object', + properties: { + nodeId: { type: 'string', description: 'The validator node ID (NodeID-xxx format)' }, + network: { type: 'string', enum: ['mainnet', 'fuji'], description: 'Network to query (default: mainnet)' }, + }, + required: ['nodeId'], + }, + }, + { + name: 'blockchain_get_chain_metrics', + description: 'Get performance metrics for a chain (TPS, transactions, addresses, gas)', + inputSchema: { + type: 'object', + properties: { + chainId: { type: 'string', description: 'The chain ID (e.g., "43114" for C-Chain)' }, + metric: { + type: 'string', + enum: ['txCount', 'activeAddresses', 'avgTps', 'maxTps', 'gasUsed', 'feesPaid'], + description: 'Specific metric to fetch (optional, returns all if not specified)', + }, + timeRange: { type: 'string', enum: ['7d', '30d', '90d'], description: 'Time range for metrics (default: 30d)' }, + }, + required: ['chainId'], + }, + }, + { + name: 'blockchain_get_staking_metrics', + description: 'Get network-wide staking statistics', + inputSchema: { + type: 'object', + properties: { + network: { type: 'string', enum: ['mainnet', 'fuji'], description: 'Network to query (default: mainnet)' }, + }, + required: [], + }, + }, + { + name: 'blockchain_list_chains', + description: 'List all EVM chains in the Avalanche ecosystem', + inputSchema: { + type: 'object', + properties: { + includeTestnets: { type: 'boolean', description: 'Include testnet chains (default: false)' }, + }, + required: [], + }, + }, + { + name: 'blockchain_get_chain_info', + description: 'Get detailed information about a specific chain', + inputSchema: { + type: 'object', + properties: { + chainId: { type: 'string', description: 'The chain ID' }, + }, + required: ['chainId'], + }, + }, +]; + +// ============================================================================ +// Input Validation Schemas +// ============================================================================ + +const AddressSchema = z.string().regex(/^0x[a-fA-F0-9]{40}$/, 'Invalid address format'); +const ChainIdSchema = z.string().min(1, 'Chain ID is required'); +const NetworkSchema = z.enum(['mainnet', 'fuji']).default('mainnet'); +const NodeIdSchema = z.string().regex(/^NodeID-/, 'Invalid node ID format'); + +// ============================================================================ +// RPC Helper +// ============================================================================ + +async function fetchFromRPC(rpcUrl: string, method: string, params: unknown[] = []): Promise { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 15000); + + try { + const response = await fetch(rpcUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }), + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + throw new Error(`RPC request failed: ${response.status}`); + } + + const data = await response.json(); + if (data.error) { + throw new Error(data.error.message || 'RPC error'); + } + + return data.result; + } catch (error) { + clearTimeout(timeoutId); + throw error; + } +} + +// ============================================================================ +// Tool Implementations +// ============================================================================ + +async function getNativeBalance(address: string, chainId: string) { + AddressSchema.parse(address); + ChainIdSchema.parse(chainId); + + const cacheKey = `balance:${chainId}:${address}`; + const cached = getCached<{ balance: string; balanceFormatted: string; symbol: string }>(cacheKey, CACHE_TTL.BALANCE); + if (cached) return cached; + + const chain = l1ChainsData.find((c: any) => c.chainId === chainId) as any; + const rpcUrl = chain?.rpcUrl; + + if (!rpcUrl) { + throw new Error(`Chain ${chainId} not found or RPC URL not available`); + } + + const balanceHex = (await fetchFromRPC(rpcUrl, 'eth_getBalance', [address.toLowerCase(), 'latest'])) as string; + const balanceWei = BigInt(balanceHex); + const balanceFormatted = (Number(balanceWei) / Math.pow(10, 18)).toFixed(6); + + const result = { + address, + chainId, + chainName: chain?.chainName || 'Unknown', + balance: balanceWei.toString(), + balanceFormatted, + symbol: chain?.networkToken?.symbol || 'AVAX', + }; + + setCache(cacheKey, result); + return result; +} + +async function getTokenBalances(address: string, chainId: string, currency: string = 'usd') { + AddressSchema.parse(address); + ChainIdSchema.parse(chainId); + + const avalanche = getAvalancheSDK(); + const result = await avalanche.data.evm.address.balances.listErc20({ + address: address.toLowerCase(), + chainId, + currency: currency as any, + pageSize: 50, + }); + + const tokens: Array<{ + address: string; + name: string; + symbol: string; + decimals: number; + balance: string; + balanceFormatted: string; + logoUri?: string; + priceUsd?: number; + valueUsd?: number; + }> = []; + + for await (const page of result) { + const balances = page.result?.erc20TokenBalances || []; + for (const token of balances) { + const decimals = token.decimals || 18; + const balance = token.balance || '0'; + const balanceFormatted = (Number(BigInt(balance)) / Math.pow(10, decimals)).toFixed(6); + + tokens.push({ + address: token.address || '', + name: token.name || 'Unknown', + symbol: token.symbol || '???', + decimals, + balance, + balanceFormatted, + logoUri: token.logoUri, + priceUsd: token.price?.value, + valueUsd: token.balanceValue?.value, + }); + } + break; + } + + return { address, chainId, tokenCount: tokens.length, tokens }; +} + +async function getTransactions(address: string, chainId: string, pageSize: number = 25, pageToken?: string) { + AddressSchema.parse(address); + ChainIdSchema.parse(chainId); + + const avalanche = getAvalancheSDK(); + const result = await avalanche.data.evm.address.transactions.list({ + address: address.toLowerCase(), + chainId, + sortOrder: 'desc', + pageSize: Math.min(pageSize, 100), + pageToken, + }); + + const transactions: Array<{ + hash: string; + blockNumber: string; + timestamp: number; + from: string; + to: string | null; + value: string; + valueFormatted: string; + gasUsed: string; + gasPrice: string; + status: string; + method?: string; + }> = []; + + let nextPageToken: string | undefined; + + for await (const page of result) { + const txList = page.result?.transactions || []; + nextPageToken = page.result?.nextPageToken; + + for (const txDetails of txList) { + const tx = txDetails.nativeTransaction; + if (!tx) continue; + + const value = tx.value || '0'; + const valueFormatted = (Number(BigInt(value)) / Math.pow(10, 18)).toFixed(6); + + transactions.push({ + hash: tx.txHash || '', + blockNumber: tx.blockNumber?.toString() || '', + timestamp: tx.blockTimestamp ?? 0, + from: tx.from?.address || '', + to: tx.to?.address || null, + value, + valueFormatted, + gasUsed: tx.gasUsed || '0', + gasPrice: tx.gasPrice || '0', + status: tx.txStatus?.toString() === '1' ? 'success' : 'failed', + method: tx.method?.methodName?.split('(')[0], + }); + } + break; + } + + return { address, chainId, transactionCount: transactions.length, transactions, nextPageToken }; +} + +async function getContractInfo(address: string, chainId: string) { + AddressSchema.parse(address); + ChainIdSchema.parse(chainId); + + const avalanche = getAvalancheSDK(); + + try { + const result = await avalanche.data.evm.contracts.getMetadata({ + address: address.toLowerCase(), + chainId, + }); + + const resultAny = result as any; + + return { + address, + chainId, + isContract: true, + name: result.name || undefined, + symbol: resultAny.symbol || undefined, + description: result.description || undefined, + ercType: result.ercType || 'UNKNOWN', + logoUri: result.logoAsset?.imageUri || undefined, + officialSite: result.officialSite || undefined, + deploymentDetails: result.deploymentDetails + ? { + txHash: result.deploymentDetails.txHash, + deployerAddress: result.deploymentDetails.deployerAddress, + } + : undefined, + }; + } catch { + return { + address, + chainId, + isContract: false, + error: 'Contract metadata not found - address may be an EOA or unverified contract', + }; + } +} + +async function listValidators(network: string = 'mainnet', pageSize: number = 50, sortBy: string = 'stakeAmount') { + const parsedNetwork = NetworkSchema.parse(network); + + const cacheKey = `validators:${parsedNetwork}:${pageSize}:${sortBy}`; + const cached = getCached(cacheKey, CACHE_TTL.VALIDATORS); + if (cached) return cached; + + const avalanche = getAvalancheSDK(parsedNetwork); + const result = await avalanche.data.primaryNetwork.listValidators({ + pageSize: Math.min(pageSize, 100), + sortBy: sortBy as any, + validationStatus: 'active', + }); + + const validators: Array<{ + nodeId: string; + stakeAmount: string; + stakeAmountFormatted: string; + delegatorCount: number; + delegatedAmount: string; + startTime: number; + endTime: number; + uptimePerformance?: number; + }> = []; + + for await (const page of result) { + const validatorList = page.result?.validators || []; + for (const validator of validatorList) { + const v = validator as any; + const stakeAmount = v.amountStaked || v.stakeAmount || '0'; + const stakeAmountFormatted = (Number(BigInt(stakeAmount)) / Math.pow(10, 9)).toFixed(2); + + validators.push({ + nodeId: v.nodeId || '', + stakeAmount, + stakeAmountFormatted: `${stakeAmountFormatted} AVAX`, + delegatorCount: v.delegatorCount ?? 0, + delegatedAmount: v.delegatedAmount || v.amountDelegated || '0', + startTime: v.startTimestamp ?? 0, + endTime: v.endTimestamp ?? 0, + uptimePerformance: v.uptimePerformance, + }); + } + break; + } + + const response = { network: parsedNetwork, validatorCount: validators.length, validators }; + setCache(cacheKey, response); + return response; +} + +async function getValidatorDetails(nodeId: string, network: string = 'mainnet') { + NodeIdSchema.parse(nodeId); + const parsedNetwork = NetworkSchema.parse(network); + + const avalanche = getAvalancheSDK(parsedNetwork); + const resultIterator = await avalanche.data.primaryNetwork.getValidatorDetails({ nodeId }); + + let validatorData: any = null; + for await (const page of resultIterator) { + validatorData = page as any; + break; + } + + if (!validatorData) { + throw new Error(`Validator ${nodeId} not found`); + } + + const stakeAmount = validatorData.amountStaked || validatorData.stakeAmount || '0'; + const stakeAmountFormatted = (Number(BigInt(stakeAmount)) / Math.pow(10, 9)).toFixed(2); + const delegatedAmount = validatorData.amountDelegated || validatorData.delegatedAmount || '0'; + const delegatedAmountFormatted = (Number(BigInt(delegatedAmount)) / Math.pow(10, 9)).toFixed(2); + + return { + nodeId: validatorData.nodeId || nodeId, + network: parsedNetwork, + stakeAmount, + stakeAmountFormatted: `${stakeAmountFormatted} AVAX`, + delegatedAmount, + delegatedAmountFormatted: `${delegatedAmountFormatted} AVAX`, + delegatorCount: validatorData.delegatorCount ?? 0, + startTime: validatorData.startTimestamp, + endTime: validatorData.endTimestamp, + uptimePerformance: validatorData.uptimePerformance, + validationStatus: validatorData.validationStatus, + blsCredentials: validatorData.blsCredentials, + }; +} + +async function getChainMetrics(chainId: string, metric?: string, timeRange: string = '30d') { + ChainIdSchema.parse(chainId); + + const cacheKey = `metrics:${chainId}:${metric || 'all'}:${timeRange}`; + const cached = getCached(cacheKey, CACHE_TTL.METRICS); + if (cached) return cached; + + const avalanche = getAvalancheSDK(); + const now = Math.floor(Date.now() / 1000); + const days = timeRange === '7d' ? 7 : timeRange === '90d' ? 90 : 30; + const startTimestamp = now - days * 24 * 60 * 60; + + const metrics: Record = {}; + const metricsToFetch = metric ? [metric] : ['txCount', 'activeAddresses', 'avgTps', 'maxTps', 'gasUsed', 'feesPaid']; + + for (const metricType of metricsToFetch) { + try { + const result = await avalanche.metrics.chains.getMetrics({ + chainId, + metric: metricType as any, + startTimestamp, + endTimestamp: now, + timeInterval: 'day', + pageSize: days, + }); + + const dataPoints: Array<{ timestamp: number; value: number; date: string }> = []; + + for await (const page of result) { + const results = page.result?.results || []; + for (const r of results) { + dataPoints.push({ + timestamp: r.timestamp || 0, + value: r.value || 0, + date: new Date((r.timestamp || 0) * 1000).toISOString().split('T')[0], + }); + } + break; + } + + const values = dataPoints.map((d) => d.value).filter((v) => v > 0); + const total = values.reduce((a, b) => a + b, 0); + const average = values.length > 0 ? total / values.length : 0; + const latest = dataPoints[0]?.value || 0; + + metrics[metricType] = { + latest, + average: Math.round(average * 100) / 100, + total: Math.round(total), + dataPoints: dataPoints.slice(0, 10), + }; + } catch { + metrics[metricType] = { error: 'Failed to fetch metric' }; + } + } + + const response = { chainId, timeRange, metrics }; + setCache(cacheKey, response); + return response; +} + +async function getStakingMetrics(network: string = 'mainnet') { + const parsedNetwork = NetworkSchema.parse(network); + + const cacheKey = `staking:${parsedNetwork}`; + const cached = getCached(cacheKey, CACHE_TTL.METRICS); + if (cached) return cached; + + const avalanche = getAvalancheSDK(parsedNetwork); + const now = Math.floor(Date.now() / 1000); + const startTimestamp = now - 30 * 24 * 60 * 60; + + try { + const [validatorCountResult, validatorWeightResult] = await Promise.all([ + avalanche.metrics.networks.getStakingMetrics({ + metric: 'validatorCount' as any, + startTimestamp, + endTimestamp: now, + pageSize: 7, + }), + avalanche.metrics.networks.getStakingMetrics({ + metric: 'validatorWeight' as any, + startTimestamp, + endTimestamp: now, + pageSize: 7, + }), + ]); + + const validatorCounts: Array<{ timestamp: number; value: number }> = []; + const validatorWeights: Array<{ timestamp: number; value: number }> = []; + + for await (const page of validatorCountResult) { + const pageAny = page as any; + const results = pageAny.result?.results || []; + for (const r of results) { + validatorCounts.push({ timestamp: r.timestamp || 0, value: r.value || 0 }); + } + break; + } + + for await (const page of validatorWeightResult) { + const pageAny = page as any; + const results = pageAny.result?.results || []; + for (const r of results) { + validatorWeights.push({ timestamp: r.timestamp || 0, value: r.value || 0 }); + } + break; + } + + const latestCount = validatorCounts[0]; + const latestWeight = validatorWeights[0]; + const totalStakeFormatted = latestWeight?.value + ? (Number(BigInt(latestWeight.value.toString())) / Math.pow(10, 9)).toFixed(0) + : '0'; + + const response = { + network: parsedNetwork, + currentValidatorCount: latestCount?.value || 0, + totalStaked: latestWeight?.value?.toString() || '0', + totalStakedFormatted: `${totalStakeFormatted} AVAX`, + recentValidatorCounts: validatorCounts.slice(0, 7), + recentValidatorWeights: validatorWeights.slice(0, 7), + }; + + setCache(cacheKey, response); + return response; + } catch { + return { network: parsedNetwork, error: 'Failed to fetch staking metrics' }; + } +} + +async function listChains(includeTestnets: boolean = false) { + const cacheKey = `chains:${includeTestnets}`; + const cached = getCached(cacheKey, CACHE_TTL.CHAINS); + if (cached) return cached; + + const chains = (l1ChainsData as any[]) + .filter((chain) => { + if (!includeTestnets && chain.isTestnet) return false; + return true; + }) + .map((chain) => ({ + chainId: chain.chainId, + chainName: chain.chainName, + isTestnet: chain.isTestnet || false, + networkToken: chain.networkToken?.symbol || 'AVAX', + rpcUrl: chain.rpcUrl, + explorerUrl: chain.explorerUrl, + logoUri: chain.chainLogoURI, + })); + + const response = { chainCount: chains.length, chains }; + setCache(cacheKey, response); + return response; +} + +async function getChainInfo(chainId: string) { + ChainIdSchema.parse(chainId); + + const chain = (l1ChainsData as any[]).find((c) => c.chainId === chainId); + + if (!chain) { + const avalanche = getAvalancheSDK(); + try { + const result = await avalanche.data.evm.chains.get({ chainId }); + return { + chainId, + chainName: result.chainName || 'Unknown', + networkToken: result.networkToken?.symbol || 'Unknown', + vmName: result.vmName, + subnetId: result.subnetId, + isTestnet: result.isTestnet || false, + }; + } catch { + throw new Error(`Chain ${chainId} not found`); + } + } + + return { + chainId: chain.chainId, + chainName: chain.chainName, + description: chain.description, + networkToken: chain.networkToken, + rpcUrl: chain.rpcUrl, + explorerUrl: chain.explorerUrl, + logoUri: chain.chainLogoURI, + isTestnet: chain.isTestnet || false, + subnetId: chain.subnetId, + }; +} + +// ============================================================================ +// Tool Handler +// ============================================================================ + +async function handleToolCall(name: string, args: Record): Promise { + switch (name) { + case 'blockchain_get_native_balance': + return getNativeBalance(args.address as string, args.chainId as string); + case 'blockchain_get_token_balances': + return getTokenBalances(args.address as string, args.chainId as string, args.currency as string | undefined); + case 'blockchain_get_transactions': + return getTransactions( + args.address as string, + args.chainId as string, + args.pageSize as number | undefined, + args.pageToken as string | undefined, + ); + case 'blockchain_get_contract_info': + return getContractInfo(args.address as string, args.chainId as string); + case 'blockchain_list_validators': + return listValidators( + args.network as string | undefined, + args.pageSize as number | undefined, + args.sortBy as string | undefined, + ); + case 'blockchain_get_validator_details': + return getValidatorDetails(args.nodeId as string, args.network as string | undefined); + case 'blockchain_get_chain_metrics': + return getChainMetrics( + args.chainId as string, + args.metric as string | undefined, + args.timeRange as string | undefined, + ); + case 'blockchain_get_staking_metrics': + return getStakingMetrics(args.network as string | undefined); + case 'blockchain_list_chains': + return listChains(args.includeTestnets as boolean | undefined); + case 'blockchain_get_chain_info': + return getChainInfo(args.chainId as string); + default: + throw new Error(`Unknown tool: ${name}`); + } +} + +// ============================================================================ +// JSON-RPC Request Processing +// ============================================================================ + +interface JsonRpcRequest { + jsonrpc: string; + id: string | number; + method: string; + params?: Record; +} + +interface JsonRpcResponse { + jsonrpc: '2.0'; + id: string | number; + result?: unknown; + error?: { code: number; message: string; data?: unknown }; +} + +async function processRequest(request: JsonRpcRequest): Promise { + const { id, method, params = {} } = request; + + try { + switch (method) { + case 'initialize': + return { + jsonrpc: '2.0', + id, + result: { + protocolVersion: SERVER_INFO.protocolVersion, + capabilities: { tools: {} }, + serverInfo: { name: SERVER_INFO.name, version: SERVER_INFO.version }, + }, + }; + + case 'tools/list': + return { jsonrpc: '2.0', id, result: { tools: TOOLS } }; + + case 'tools/call': { + const { name, arguments: args } = params as { name: string; arguments: Record }; + if (!name) throw new Error('Tool name is required'); + const tool = TOOLS.find((t) => t.name === name); + if (!tool) throw new Error(`Unknown tool: ${name}`); + const result = await handleToolCall(name, args || {}); + return { + jsonrpc: '2.0', + id, + result: { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }, + }; + } + + case 'ping': + return { jsonrpc: '2.0', id, result: {} }; + + default: + return { jsonrpc: '2.0', id, error: { code: -32601, message: `Method not found: ${method}` } }; + } + } catch (error) { + return { + jsonrpc: '2.0', + id, + error: { + code: -32603, + message: error instanceof Error ? error.message : 'Internal error', + data: error instanceof z.ZodError ? error.issues : undefined, + }, + }; + } +} + +// ============================================================================ +// HTTP Handlers +// ============================================================================ + +export async function POST(req: Request) { + try { + const body = await req.json(); + + // Handle batch requests + if (Array.isArray(body)) { + const responses = await Promise.all(body.map(processRequest)); + return NextResponse.json(responses); + } + + // Handle single request + const response = await processRequest(body); + return NextResponse.json(response); + } catch { + return NextResponse.json( + { jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } }, + { status: 400 }, + ); + } +} + +export async function GET() { + return NextResponse.json({ + name: SERVER_INFO.name, + version: SERVER_INFO.version, + protocolVersion: SERVER_INFO.protocolVersion, + description: 'Avalanche blockchain data MCP server - query balances, transactions, validators, and chain metrics', + tools: TOOLS.map((t) => ({ name: t.name, description: t.description })), + }); +} diff --git a/app/api/mcp/route.ts b/app/api/mcp/route.ts index 07ba2f33489..9b7c85ada47 100644 --- a/app/api/mcp/route.ts +++ b/app/api/mcp/route.ts @@ -1,4 +1,5 @@ -import { NextResponse, NextRequest } from 'next/server'; +import type { NextRequest } from 'next/server'; +import { NextResponse } from 'next/server'; import { MCPServer } from '@/lib/mcp/server'; import { validateOrigin, getCORSHeaders } from '@/lib/mcp/cors'; import { checkMCPRateLimit, getRateLimitHeaders } from '@/lib/mcp-rate-limit'; @@ -40,7 +41,7 @@ function createSSEResponse(data: unknown, eventId?: string): Response { headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache, no-transform', - 'Connection': 'keep-alive', + Connection: 'keep-alive', }, }); } @@ -56,7 +57,7 @@ export async function GET(request: NextRequest) { if (wantsSSE(request)) { return NextResponse.json( { jsonrpc: '2.0', id: null, error: { code: -32000, message: 'Method not allowed.' } }, - { status: 405, headers: { ...corsHeaders, Allow: 'POST, OPTIONS' } } + { status: 405, headers: { ...corsHeaders, Allow: 'POST, OPTIONS' } }, ); } @@ -78,6 +79,8 @@ export async function OPTIONS(request: NextRequest) { // --------------------------------------------------------------------------- // POST — JSON-RPC 2.0 dispatcher +// withApi: auth intentionally omitted — MCP uses CORS origin validation (validateOrigin) +// and dedicated rate limiting (checkMCPRateLimit) instead of session-based auth. // --------------------------------------------------------------------------- export async function POST(request: NextRequest) { @@ -87,7 +90,7 @@ export async function POST(request: NextRequest) { if (!validateOrigin(origin)) { return NextResponse.json( { jsonrpc: '2.0', id: null, error: { code: -32000, message: 'Origin not allowed' } }, - { status: 403, headers: getCORSHeaders(origin) } + { status: 403, headers: getCORSHeaders(origin) }, ); } diff --git a/app/api/newsletter/route.ts b/app/api/newsletter/route.ts index fcc1c606100..fb81f210078 100644 --- a/app/api/newsletter/route.ts +++ b/app/api/newsletter/route.ts @@ -1,61 +1,44 @@ -import { NextResponse } from 'next/server'; +import { z } from 'zod'; +import { withApi, successResponse, InternalError } from '@/lib/api'; const HUBSPOT_API_KEY = process.env.HUBSPOT_API_KEY; const HUBSPOT_PORTAL_ID = process.env.HUBSPOT_PORTAL_ID; const HUBSPOT_NEWSLETTER_FORM_GUID = process.env.HUBSPOT_NEWSLETTER_FORM_GUID; -export async function POST(request: Request) { - try { - if (!HUBSPOT_API_KEY || !HUBSPOT_PORTAL_ID || !HUBSPOT_NEWSLETTER_FORM_GUID) { - console.error('Missing environment variables: HUBSPOT_API_KEY, HUBSPOT_PORTAL_ID, or HUBSPOT_NEWSLETTER_FORM_GUID'); - return NextResponse.json( - { success: false, message: 'Server config error' }, - { status: 500 } - ); - } +const newsletterSchema = z.object({ + email: z.string().email('Valid email is required'), +}); - const formData = await request.json(); - console.log('Received newsletter signup:', formData); - - if (!formData.email) { - return NextResponse.json( - { success: false, message: 'Email is required' }, - { status: 400 } - ); +// withApi: auth intentionally omitted — public form submission +export const POST = withApi>( + async (req, { body }) => { + if (!HUBSPOT_API_KEY || !HUBSPOT_PORTAL_ID || !HUBSPOT_NEWSLETTER_FORM_GUID) { + throw new InternalError('Newsletter service not configured'); } const hubspotPayload = { fields: [ - { - name: "email", - value: formData.email - }, - { - name: "gdpr", - value: true - }, - { - name: "marketing_consent", - value: true - } + { name: 'email', value: body.email }, + { name: 'gdpr', value: true }, + { name: 'marketing_consent', value: true }, ], context: { - pageUri: request.headers.get('referer') || 'https://build.avax.network', - pageName: 'Newsletter Signup' + pageUri: req.headers.get('referer') || 'https://build.avax.network', + pageName: 'Newsletter Signup', }, legalConsentOptions: { consent: { consentToProcess: true, - text: "I agree to allow Ava Labs to store and process my personal data.", + text: 'I agree to allow Ava Labs to store and process my personal data.', communications: [ { value: true, subscriptionTypeId: 999, - text: "I agree to receive marketing communications from Ava Labs." - } - ] - } - } + text: 'I agree to receive marketing communications from Ava Labs.', + }, + ], + }, + }, }; const hubspotResponse = await fetch( @@ -64,42 +47,30 @@ export async function POST(request: Request) { method: 'POST', headers: { 'Content-Type': 'application/json', - 'Authorization': `Bearer ${HUBSPOT_API_KEY}` + Authorization: `Bearer ${HUBSPOT_API_KEY}`, }, - body: JSON.stringify(hubspotPayload) - } + body: JSON.stringify(hubspotPayload), + }, ); - const responseStatus = hubspotResponse.status; - let hubspotResult; - - try { - hubspotResult = await hubspotResponse.json(); - } catch (error) { - console.error('Error reading HubSpot response:', error); - const text = await hubspotResponse.text(); - console.error('Non-JSON response from HubSpot:', text); - hubspotResult = { status: 'error', message: text }; - } - - console.log('HubSpot response:', hubspotResult); - if (!hubspotResponse.ok) { - return NextResponse.json( - { - success: false, - status: responseStatus, - response: hubspotResult - } - ); + let hubspotResult; + try { + hubspotResult = await hubspotResponse.json(); + } catch { + hubspotResult = { message: await hubspotResponse.text() }; + } + throw new InternalError(`HubSpot submission failed: ${hubspotResult?.message || hubspotResponse.status}`); } - return NextResponse.json({ success: true }); - } catch (error) { - console.error('Error processing newsletter signup:', error); - return NextResponse.json( - { success: false, message: error instanceof Error ? error.message : 'Internal server error' }, - { status: 500 } - ); - } -} + return successResponse({ subscribed: true }); + }, + { + schema: newsletterSchema, + rateLimit: { + windowMs: 60 * 60 * 1000, // 1 hour + maxRequests: 5, + identifier: 'ip', + }, + }, +); diff --git a/app/api/notifications/create/route.ts b/app/api/notifications/create/route.ts index ce5da717557..627f4b7d2ce 100644 --- a/app/api/notifications/create/route.ts +++ b/app/api/notifications/create/route.ts @@ -1,89 +1,61 @@ -import { getToken, encode } from "next-auth/jwt"; -import { NextResponse } from "next/server"; +import type { NextRequest } from 'next/server'; +import { z } from 'zod'; +import { withApi } from '@/lib/api/with-api'; +import { successResponse } from '@/lib/api/response'; +import { BadRequestError, InternalError } from '@/lib/api/errors'; function stripMdxExpressions(content: string): string { return content - .replace(/^export\s[^\n]*/gm, "") - .replace(/^import\s[^\n]*/gm, "") - .replace(/\{[^}]*\}/g, "") + .replace(/^export\s[^\n]*/gm, '') + .replace(/^import\s[^\n]*/gm, '') + .replace(/\{[^}]*\}/g, '') .trim(); } -export async function POST(req: any): Promise { - try { - const token = await getToken({ - req, - secret: process.env.NEXTAUTH_SECRET ?? "", - }); - const sessionCustomAttributes = token?.custom_attributes || [""]; - if ( - !( - sessionCustomAttributes.includes("devrel") || - sessionCustomAttributes.includes("notify_event") - ) - ) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - if (!token) return new Response("Unauthorized", { status: 401 }); - const encodedToken = await encode({ - token: token, - secret: process.env.NEXTAUTH_SECRET ?? "", - }); - if (!encodedToken) - return new Response("Error at get notifications", { status: 500 }); - - const baseUrl: string | undefined = - process.env.NEXT_PUBLIC_AVALANCHE_WORKERS_URL; +const CreateNotificationsSchema = z.object({ + notifications: z.array(z.record(z.string(), z.unknown())).min(1, 'At least one notification is required'), +}); - const body: any = await req.json(); +type CreateNotificationsBody = z.infer; - if (Array.isArray(body.notifications)) { - body.notifications = body.notifications.map((n: any) => ({ - ...n, - content: - typeof n.content === "string" - ? stripMdxExpressions(n.content) - : n.content, - })); - } - - const avalancheWorkersApiKey: string | undefined = - process.env.AVALANCHE_WORKERS_API_KEY; +export const POST = withApi( + async (_req: NextRequest, { session, body }) => { + const baseUrl = process.env.NEXT_PUBLIC_AVALANCHE_WORKERS_URL; + const avalancheWorkersApiKey = process.env.AVALANCHE_WORKERS_API_KEY; if (!baseUrl || !avalancheWorkersApiKey) { - return NextResponse.json({ error: "Failed" }, { status: 500 }); + throw new InternalError('Notification service not configured'); } - const upstream: Response = await fetch(`${baseUrl}/notifications/create`, { - method: "POST", + const notifications = body.notifications.map((n: any) => ({ + ...n, + content: typeof n.content === 'string' ? stripMdxExpressions(n.content) : n.content, + })); + + const upstream = await fetch(`${baseUrl}/notifications/create`, { + method: 'POST', headers: { - "Content-Type": "application/json", - "x-api-key": avalancheWorkersApiKey, + 'Content-Type': 'application/json', + 'x-api-key': avalancheWorkersApiKey, }, body: JSON.stringify({ - notifications: body.notifications, - authUser: token.id, + notifications, + authUser: session.user.id, }), }); if (!upstream.ok) { - const text: string = await upstream.text(); - return NextResponse.json( - { error: text || "Failed to read notifications" }, - { status: upstream.status }, - ); + const text = await upstream.text(); + throw new BadRequestError(text || 'Failed to create notifications'); } - const contentType: string | null = upstream.headers.get("content-type"); - if (contentType?.includes("application/json")) { - const payload: unknown = await upstream.json(); - return NextResponse.json(payload, { status: 200 }); + const contentType = upstream.headers.get('content-type'); + if (contentType?.includes('application/json')) { + const payload = await upstream.json(); + return successResponse(payload); } - return NextResponse.json({ ok: true }, { status: 200 }); - } catch (err: unknown) { - const message: string = - err instanceof Error ? err.message : "Unexpected error"; - return NextResponse.json({ error: message }, { status: 500 }); - } -} + return successResponse({ ok: true }); + }, + { auth: true, roles: ['devrel', 'notify_event'], schema: CreateNotificationsSchema }, +); diff --git a/app/api/notifications/get/route.ts b/app/api/notifications/get/route.ts index 3e3d6216cce..95c5b3fac3d 100644 --- a/app/api/notifications/get/route.ts +++ b/app/api/notifications/get/route.ts @@ -1,62 +1,59 @@ -import { getToken, encode } from "next-auth/jwt"; -import { NextResponse } from "next/server"; - -type GetNotificationsBody = { - users: string[]; -}; -export const runtime: "nodejs" = "nodejs"; - -const baseUrl: string | undefined = process.env.NEXT_PUBLIC_AVALANCHE_WORKERS_URL; -const avalancheWokersApiKey: string | undefined = - process.env.AVALANCHE_WORKERS_API_KEY; - -export async function POST(req: any): Promise { - const token = await getToken({ - req, - secret: process.env.NEXTAUTH_SECRET ?? "", - }); - if (!token) return new Response("Unauthorized", { status: 401 }); - try { - if (!baseUrl || !avalancheWokersApiKey) { - return NextResponse.json({ error: "Failed" }, { status: 500 }); +import type { NextRequest } from 'next/server'; +import { withApi } from '@/lib/api/with-api'; +import { successResponse } from '@/lib/api/response'; +import { InternalError } from '@/lib/api/errors'; + +export const runtime: 'nodejs' = 'nodejs'; + +// schema: not applicable — no request body, uses POST for side-effect-free fetch from external service +export const POST = withApi( + async (_req: NextRequest, { session }) => { + const baseUrl = process.env.NEXT_PUBLIC_AVALANCHE_WORKERS_URL; + const avalancheWorkersApiKey = process.env.AVALANCHE_WORKERS_API_KEY; + + if (!baseUrl || !avalancheWorkersApiKey) { + throw new InternalError('Notification service not configured'); } - const upstream: Response = await fetch( - `${baseUrl}/notifications/get/inbox`, - { - method: "POST", + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 10_000); + + try { + const upstream = await fetch(`${baseUrl}/notifications/get/inbox`, { + method: 'POST', headers: { - "Content-Type": "application/json", - "x-api-key": avalancheWokersApiKey, + 'Content-Type': 'application/json', + 'x-api-key': avalancheWorkersApiKey, }, - body: JSON.stringify({ authUser: token.id }), - cache: "no-store", - }, - ); - - if (!upstream.ok) { - // Gracefully handle upstream service unavailable - if (upstream.status >= 500) { - if (process.env.NODE_ENV === 'development') { - console.warn('Notifications service unavailable - returning empty notifications'); + body: JSON.stringify({ authUser: session.user.id }), + cache: 'no-store', + signal: controller.signal, + }); + + if (!upstream.ok) { + // Gracefully handle upstream service unavailable + if (upstream.status >= 500) { + return successResponse({}); } - return NextResponse.json({}, { status: 200 }); - } - const text: string = await upstream.text(); - return NextResponse.json( - { error: text || "Failed to fetch notifications" }, - { status: upstream.status }, - ); - } + const text = await upstream.text(); + throw new InternalError(text || 'Failed to fetch notifications'); + } - const payload: unknown = await upstream.json(); - return NextResponse.json(payload, { status: 200 }); - } catch (err: unknown) { - // Gracefully handle network errors (workers not deployed) - if (process.env.NODE_ENV === 'development') { - console.warn('Notifications service error - returning empty notifications:', err instanceof Error ? err.message : 'Unknown error'); + const payload = await upstream.json(); + return successResponse(payload); + } catch (err: unknown) { + // Gracefully handle network errors (workers not deployed / timeout) + if (err instanceof Error && err.name === 'AbortError') { + return successResponse({}); + } + // Re-throw API errors + if (err instanceof Error && 'statusCode' in err) throw err; + // Gracefully degrade on unexpected errors + return successResponse({}); + } finally { + clearTimeout(timeoutId); } - return NextResponse.json({}, { status: 200 }); - } -} + }, + { auth: true }, +); diff --git a/app/api/notifications/read/route.ts b/app/api/notifications/read/route.ts index a8857ce89b5..43a6281be1c 100644 --- a/app/api/notifications/read/route.ts +++ b/app/api/notifications/read/route.ts @@ -1,61 +1,53 @@ -import { getToken, encode } from "next-auth/jwt"; -import { NextResponse } from "next/server"; - -export async function POST(req: any): Promise { - try { - const token = await getToken({ - req, - secret: process.env.NEXTAUTH_SECRET ?? "", - }); - if (!token) return new Response("Unauthorized", { status: 401 }); - const encodedToken = await encode({ - token: token, - secret: process.env.NEXTAUTH_SECRET ?? "", - }); - if (!encodedToken) - return new Response("Error at get notifications", { status: 500 }); - - const body: any = await req.json(); - - const baseUrl: string | undefined = process.env.NEXT_PUBLIC_AVALANCHE_WORKERS_URL; - const avalancheWorkersApiKey: string | undefined = - process.env.AVALANCHE_WORKERS_API_KEY; +import type { NextRequest } from 'next/server'; +import { withApi } from '@/lib/api/with-api'; +import { successResponse } from '@/lib/api/response'; +import { BadRequestError, InternalError } from '@/lib/api/errors'; - if (!baseUrl || !avalancheWorkersApiKey) { - return NextResponse.json({ error: "Failed" }, { status: 500 }); - } +// schema: not applicable — forwards notification IDs to external service +export const POST = withApi( + async (req: NextRequest, { session }) => { + const baseUrl = process.env.NEXT_PUBLIC_AVALANCHE_WORKERS_URL; + const avalancheWorkersApiKey = process.env.AVALANCHE_WORKERS_API_KEY; - const upstream: Response = await fetch(`${baseUrl}/notifications/read`, { - method: "POST", - headers: { - "Content-Type": "application/json", - "x-api-key": avalancheWorkersApiKey, - }, - body: JSON.stringify({ - notifications: body, - authUser: token.id, - }), - cache: "no-store", - }); - - if (!upstream.ok) { - const text: string = await upstream.text(); - return NextResponse.json( - { error: text || "Failed to read notifications" }, - { status: upstream.status }, - ); + if (!baseUrl || !avalancheWorkersApiKey) { + throw new InternalError('Notification service not configured'); } - const contentType: string | null = upstream.headers.get("content-type"); - if (contentType?.includes("application/json")) { - const payload: unknown = await upstream.json(); - return NextResponse.json(payload, { status: 200 }); + const body = await req.json(); + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 10_000); + + try { + const upstream = await fetch(`${baseUrl}/notifications/read`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': avalancheWorkersApiKey, + }, + body: JSON.stringify({ + notifications: body, + authUser: session.user.id, + }), + cache: 'no-store', + signal: controller.signal, + }); + + if (!upstream.ok) { + const text = await upstream.text(); + throw new BadRequestError(text || 'Failed to read notifications'); + } + + const contentType = upstream.headers.get('content-type'); + if (contentType?.includes('application/json')) { + const payload = await upstream.json(); + return successResponse(payload); + } + + return successResponse({ ok: true }); + } finally { + clearTimeout(timeoutId); } - - return NextResponse.json({ ok: true }, { status: 200 }); - } catch (err: unknown) { - const message: string = - err instanceof Error ? err.message : "Unexpected error"; - return NextResponse.json({ error: message }, { status: 500 }); - } -} + }, + { auth: true }, +); diff --git a/app/api/oauth/authorize/route.ts b/app/api/oauth/authorize/route.ts index a7b843be8df..c6603f0cdc4 100644 --- a/app/api/oauth/authorize/route.ts +++ b/app/api/oauth/authorize/route.ts @@ -2,62 +2,67 @@ import { NextRequest, NextResponse } from 'next/server'; import crypto from 'crypto'; import { getAuthSession } from '@/lib/auth/authSession'; import { prisma } from '@/prisma/prisma'; +import { BadRequestError, NotFoundError, errorResponse } from '@/lib/api'; const OAUTH_CLIENT_ID = process.env.OAUTH_CLIENT_ID!; const OAUTH_REDIRECT_URI = process.env.OAUTH_REDIRECT_URI!; +// withApi: not applicable — returns 302 redirects export async function GET(request: NextRequest) { - const { searchParams } = request.nextUrl; - const clientId = searchParams.get('client_id'); - const redirectUri = searchParams.get('redirect_uri'); - const state = searchParams.get('state'); + try { + const { searchParams } = request.nextUrl; + const clientId = searchParams.get('client_id'); + const redirectUri = searchParams.get('redirect_uri'); + const state = searchParams.get('state'); - if (!clientId || clientId !== OAUTH_CLIENT_ID) { - return NextResponse.json({ error: 'invalid_client_id' }, { status: 400 }); - } + if (!clientId || clientId !== OAUTH_CLIENT_ID) { + throw new BadRequestError('invalid_client_id'); + } - if (!redirectUri) { - return NextResponse.json({ error: 'missing_redirect_uri' }, { status: 400 }); - } + if (!redirectUri) { + throw new BadRequestError('missing_redirect_uri'); + } - if (redirectUri !== OAUTH_REDIRECT_URI) { - return NextResponse.json({ error: 'invalid_redirect_uri' }, { status: 400 }); - } + if (redirectUri !== OAUTH_REDIRECT_URI) { + throw new BadRequestError('invalid_redirect_uri'); + } - const session = await getAuthSession(); + const session = await getAuthSession(); - if (!session?.user?.email) { - const callbackUrl = request.nextUrl.toString(); - return NextResponse.redirect(new URL(`/login?callbackUrl=${encodeURIComponent(callbackUrl)}`, request.url)); - } + if (!session?.user?.email) { + const callbackUrl = request.nextUrl.toString(); + return NextResponse.redirect(new URL(`/login?callbackUrl=${encodeURIComponent(callbackUrl)}`, request.url)); + } - // Look up the user by email to get their ID - const user = await prisma.user.findUnique({ - where: { email: session.user.email }, - select: { id: true }, - }); + const user = await prisma.user.findUnique({ + where: { email: session.user.email }, + select: { id: true }, + }); - if (!user) { - return NextResponse.json({ error: 'user_not_found' }, { status: 404 }); - } + if (!user) { + throw new NotFoundError('user'); + } - const code = crypto.randomBytes(32).toString('hex'); - const expiresAt = new Date(Date.now() + 5 * 60 * 1000); // 5 minutes - - await prisma.oAuthCode.create({ - data: { - code, - client_id: OAUTH_CLIENT_ID, - user_id: user.id, - expires_at: expiresAt, - }, - }); - - const redirectUrl = new URL(redirectUri); - redirectUrl.searchParams.set('code', code); - if (state) { - redirectUrl.searchParams.set('state', state); - } + const code = crypto.randomBytes(32).toString('hex'); + const expiresAt = new Date(Date.now() + 5 * 60 * 1000); // 5 minutes + + await prisma.oAuthCode.create({ + data: { + code, + client_id: OAUTH_CLIENT_ID, + user_id: user.id, + expires_at: expiresAt, + }, + }); - return NextResponse.redirect(redirectUrl.toString()); + const redirectUrl = new URL(redirectUri); + redirectUrl.searchParams.set('code', code); + if (state) { + redirectUrl.searchParams.set('state', state); + } + + return NextResponse.redirect(redirectUrl.toString()); + } catch (error) { + return errorResponse(error); + } } diff --git a/app/api/oauth/token/route.ts b/app/api/oauth/token/route.ts index c52f2b37411..b6a54029db4 100644 --- a/app/api/oauth/token/route.ts +++ b/app/api/oauth/token/route.ts @@ -1,83 +1,92 @@ -import { NextRequest, NextResponse } from 'next/server'; import crypto from 'crypto'; +import { z } from 'zod'; import { SignJWT, exportJWK, calculateJwkThumbprint, importPKCS8 } from 'jose'; import { prisma } from '@/prisma/prisma'; +import { withApi, successResponse, BadRequestError, AuthError, InternalError } from '@/lib/api'; const OAUTH_CLIENT_ID = process.env.OAUTH_CLIENT_ID!; const OAUTH_CLIENT_SECRET = process.env.OAUTH_CLIENT_SECRET!; -export async function POST(request: NextRequest) { - let body: { client_id?: string; client_secret?: string; code?: string }; - try { - body = await request.json(); - } catch { - return NextResponse.json({ error: 'invalid_request' }, { status: 400 }); - } +const TokenSchema = z.object({ + client_id: z.string().min(1, 'client_id is required'), + client_secret: z.string().min(1, 'client_secret is required'), + code: z.string().min(1, 'code is required'), +}); - const { client_id, client_secret, code } = body; - - if (!client_id || !client_secret || !code) { - return NextResponse.json({ error: 'invalid_request' }, { status: 400 }); - } +/** + * HMAC-based timing-safe comparison. + * Uses a random key so that neither the expected nor provided value can be + * inferred from the HMAC output, and the comparison never leaks length. + */ +function timingSafeCompare(a: string, b: string): boolean { + const key = crypto.randomBytes(32); + const hmacA = crypto.createHmac('sha256', key).update(a).digest(); + const hmacB = crypto.createHmac('sha256', key).update(b).digest(); + return crypto.timingSafeEqual(hmacA, hmacB); +} - if (client_id !== OAUTH_CLIENT_ID) { - return NextResponse.json({ error: 'invalid_client' }, { status: 400 }); - } +// withApi: auth intentionally omitted — pre-authentication endpoint +export const POST = withApi>( + async (_req, { body }) => { + const { client_id, client_secret, code } = body; - // Timing-safe comparison of client_secret - const secretBuffer = Buffer.from(OAUTH_CLIENT_SECRET); - const providedBuffer = Buffer.from(client_secret); - if (secretBuffer.length !== providedBuffer.length || !crypto.timingSafeEqual(secretBuffer, providedBuffer)) { - return NextResponse.json({ error: 'invalid_client' }, { status: 401 }); - } + if (!timingSafeCompare(client_id, OAUTH_CLIENT_ID)) { + throw new AuthError('invalid_client'); + } - // Clean up expired codes opportunistically - await prisma.oAuthCode.deleteMany({ - where: { expires_at: { lt: new Date() } }, - }); + if (!timingSafeCompare(client_secret, OAUTH_CLIENT_SECRET)) { + throw new AuthError('invalid_client'); + } - const result = await prisma.$transaction(async (tx) => { - const oauthCode = await tx.oAuthCode.findUnique({ - where: { code }, - include: { user: { select: { name: true, email: true, country: true } } }, + // Clean up expired codes opportunistically + await prisma.oAuthCode.deleteMany({ + where: { expires_at: { lt: new Date() } }, }); - if (!oauthCode) return null; - await tx.oAuthCode.delete({ where: { code } }); - if (oauthCode.expires_at < new Date()) return null; - if (oauthCode.client_id !== client_id) return null; - return oauthCode.user; - }); + const result = await prisma.$transaction(async (tx) => { + const oauthCode = await tx.oAuthCode.findUnique({ + where: { code }, + include: { user: { select: { name: true, email: true, country: true } } }, + }); + + if (!oauthCode) return null; + await tx.oAuthCode.delete({ where: { code } }); + if (oauthCode.expires_at < new Date()) return null; + if (oauthCode.client_id !== client_id) return null; + return oauthCode.user; + }); - if (!result) { - return NextResponse.json({ error: 'invalid_grant' }, { status: 400 }); - } + if (!result) { + throw new BadRequestError('invalid_grant'); + } - if (!process.env.OAUTH_JWT_PRIVATE_KEY) { - return NextResponse.json({ error: 'server_error' }, { status: 500 }); - } + if (!process.env.OAUTH_JWT_PRIVATE_KEY) { + throw new InternalError('OAuth signing key not configured'); + } - // Uses a separate signing key from Glacier for isolation - const privateKeyPem = Buffer.from(process.env.OAUTH_JWT_PRIVATE_KEY, 'base64').toString('utf8'); - const privateKey = await importPKCS8(privateKeyPem, 'ES256'); - const publicJWK = await exportJWK(privateKey); - const kid = await calculateJwkThumbprint(publicJWK); + // Uses a separate signing key from Glacier for isolation + const privateKeyPem = Buffer.from(process.env.OAUTH_JWT_PRIVATE_KEY, 'base64').toString('utf8'); + const privateKey = await importPKCS8(privateKeyPem, 'ES256'); + const publicJWK = await exportJWK(privateKey); + const kid = await calculateJwkThumbprint(publicJWK); - const accessToken = await new SignJWT({ - name: result.name, - email: result.email, - country: result.country, - }) - .setProtectedHeader({ alg: 'ES256', kid }) - .setIssuer('builders-hub') - .setAudience(client_id) - .setIssuedAt() - .setExpirationTime('1h') - .sign(privateKey); + const accessToken = await new SignJWT({ + name: result.name, + email: result.email, + country: result.country, + }) + .setProtectedHeader({ alg: 'ES256', kid }) + .setIssuer('builders-hub') + .setAudience(client_id) + .setIssuedAt() + .setExpirationTime('1h') + .sign(privateKey); - return NextResponse.json({ - access_token: accessToken, - token_type: 'Bearer', - expires_in: 3600, - }); -} + return successResponse({ + access_token: accessToken, + token_type: 'Bearer', + expires_in: 3600, + }); + }, + { schema: TokenSchema }, +); diff --git a/app/api/og/academy/[slug]/route.tsx b/app/api/og/academy/[slug]/route.tsx index b95b7f487e7..92d46886a93 100644 --- a/app/api/og/academy/[slug]/route.tsx +++ b/app/api/og/academy/[slug]/route.tsx @@ -1,3 +1,4 @@ +// withApi: not applicable — edge runtime OG image generator import type { NextRequest } from 'next/server'; import { ImageResponse } from 'next/og'; import { loadFonts, createOGResponse } from '@/utils/og-image'; diff --git a/app/api/og/academy/route.tsx b/app/api/og/academy/route.tsx index 74e8fcf9007..fbe762949df 100644 --- a/app/api/og/academy/route.tsx +++ b/app/api/og/academy/route.tsx @@ -1,3 +1,4 @@ +// withApi: not applicable — edge runtime OG image generator import { ImageResponse } from 'next/og'; import type { NextRequest } from 'next/server'; @@ -136,4 +137,4 @@ function OG({ ); -} \ No newline at end of file +} diff --git a/app/api/og/blog/[slug]/route.tsx b/app/api/og/blog/[slug]/route.tsx index 92cc256bb61..258f2429a72 100644 --- a/app/api/og/blog/[slug]/route.tsx +++ b/app/api/og/blog/[slug]/route.tsx @@ -1,3 +1,4 @@ +// withApi: not applicable — edge runtime OG image generator import type { NextRequest } from 'next/server'; import { ImageResponse } from 'next/og'; import { loadFonts, createOGResponse } from '@/utils/og-image'; @@ -21,4 +22,4 @@ export async function GET( path: 'blog', fonts }); -} \ No newline at end of file +} diff --git a/app/api/og/blog/route.tsx b/app/api/og/blog/route.tsx index 9081910f24c..7db3a4b1568 100644 --- a/app/api/og/blog/route.tsx +++ b/app/api/og/blog/route.tsx @@ -1,3 +1,4 @@ +// withApi: not applicable — edge runtime OG image generator import type { NextRequest } from 'next/server'; import { ImageResponse } from 'next/og'; import { loadFonts, createOGResponse } from '@/utils/og-image'; @@ -20,4 +21,4 @@ export async function GET( path: 'blog', fonts }); -} \ No newline at end of file +} diff --git a/app/api/og/docs/[slug]/route.tsx b/app/api/og/docs/[slug]/route.tsx index 07b0031b08e..d6dffbee333 100644 --- a/app/api/og/docs/[slug]/route.tsx +++ b/app/api/og/docs/[slug]/route.tsx @@ -1,3 +1,4 @@ +// withApi: not applicable — edge runtime OG image generator import type { NextRequest } from 'next/server'; import { ImageResponse } from 'next/og'; import { loadFonts, createOGResponse } from '@/utils/og-image'; @@ -21,4 +22,4 @@ export async function GET( path: 'docs', fonts }); -} \ No newline at end of file +} diff --git a/app/api/og/docs/route.tsx b/app/api/og/docs/route.tsx index e0902c66a93..725747cd4a5 100644 --- a/app/api/og/docs/route.tsx +++ b/app/api/og/docs/route.tsx @@ -1,3 +1,4 @@ +// withApi: not applicable — edge runtime OG image generator import { ImageResponse } from 'next/og'; import type { NextRequest } from 'next/server'; @@ -136,4 +137,4 @@ function OG({ ); -} \ No newline at end of file +} diff --git a/app/api/og/events/[id]/route.tsx b/app/api/og/events/[id]/route.tsx index a27740bb75c..55e8951ea1c 100644 --- a/app/api/og/events/[id]/route.tsx +++ b/app/api/og/events/[id]/route.tsx @@ -1,7 +1,7 @@ +// withApi: not applicable — edge runtime OG image generator import type { NextRequest } from 'next/server'; import { ImageResponse } from 'next/og'; import { loadFonts, createOGResponse } from '@/utils/og-image'; -import axios from 'axios'; export const runtime = 'edge'; @@ -40,20 +40,18 @@ async function tryLoadImage( fonts: { medium: ArrayBuffer, light: ArrayBuffer, regular: ArrayBuffer } ): Promise { try { - const imageResponse = await axios.get(imageUrl, { - responseType: 'arraybuffer', - headers: { - 'Accept': 'image/*', - }, + const imageResponse = await fetch(imageUrl, { + headers: { 'Accept': 'image/*' }, }); + if (!imageResponse.ok) return null; - const imageBuffer = imageResponse.data; + const imageBuffer = await imageResponse.arrayBuffer(); if (!imageBuffer || imageBuffer.byteLength === 0) { return null; } - const contentType = imageResponse.headers['content-type'] || 'image/png'; + const contentType = imageResponse.headers.get('content-type') || 'image/png'; // Skip WebP images as they cause issues with ImageResponse if (contentType.includes('webp') || contentType === 'image/webp') { @@ -115,16 +113,12 @@ export async function GET( const fonts = await loadFonts(); try { - const res = await axios.get( + const res = await fetch( `${process.env.NEXTAUTH_URL}/api/events/${id}`, - { - headers: { - 'Cache-Control': 'no-store', - }, - } + { headers: { 'Cache-Control': 'no-store' } } ); - const hackathon = res.data || null; + const hackathon = res.ok ? await res.json() : null; if (!hackathon) { return createOGResponse({ diff --git a/app/api/og/events/route.tsx b/app/api/og/events/route.tsx index b1c39c150ea..6333e983779 100644 --- a/app/api/og/events/route.tsx +++ b/app/api/og/events/route.tsx @@ -1,3 +1,4 @@ +// withApi: not applicable — edge runtime OG image generator import { ImageResponse } from 'next/og'; import type { NextRequest } from 'next/server'; @@ -136,4 +137,4 @@ function OG({ ); -} \ No newline at end of file +} diff --git a/app/api/og/grants/route.tsx b/app/api/og/grants/route.tsx index 9c94e722621..4b81344850d 100644 --- a/app/api/og/grants/route.tsx +++ b/app/api/og/grants/route.tsx @@ -1,3 +1,4 @@ +// withApi: not applicable — edge runtime OG image generator import { ImageResponse } from 'next/og'; import type { NextRequest } from 'next/server'; @@ -136,4 +137,4 @@ function OG({ ); -} \ No newline at end of file +} diff --git a/app/api/og/hackathons/[id]/route.tsx b/app/api/og/hackathons/[id]/route.tsx index ce538d6f790..61fb7aeb8bf 100644 --- a/app/api/og/hackathons/[id]/route.tsx +++ b/app/api/og/hackathons/[id]/route.tsx @@ -1,7 +1,7 @@ +// withApi: not applicable — edge runtime OG image generator import type { NextRequest } from 'next/server'; import { ImageResponse } from 'next/og'; import { loadFonts, createOGResponse } from '@/utils/og-image'; -import axios from 'axios'; export const runtime = 'edge'; @@ -40,20 +40,21 @@ async function tryLoadImage( fonts: { medium: ArrayBuffer, light: ArrayBuffer, regular: ArrayBuffer } ): Promise { try { - const imageResponse = await axios.get(imageUrl, { - responseType: 'arraybuffer', - headers: { - 'Accept': 'image/*', - }, + const imageResponse = await fetch(imageUrl, { + headers: { 'Accept': 'image/*' }, }); - const imageBuffer = imageResponse.data; - + if (!imageResponse.ok) { + return null; + } + + const imageBuffer = await imageResponse.arrayBuffer(); + if (!imageBuffer || imageBuffer.byteLength === 0) { return null; } - - const contentType = imageResponse.headers['content-type'] || 'image/png'; + + const contentType = imageResponse.headers.get('content-type') || 'image/png'; // Skip WebP images as they cause issues with ImageResponse if (contentType.includes('webp') || contentType === 'image/webp') { @@ -115,16 +116,14 @@ export async function GET( const fonts = await loadFonts(); try { - const res = await axios.get( + const res = await fetch( `${process.env.NEXTAUTH_URL}/api/hackathons/${id}`, { - headers: { - 'Cache-Control': 'no-store', - }, + headers: { 'Cache-Control': 'no-store' }, } ); - const hackathon = res.data || null; + const hackathon = res.ok ? await res.json() : null; if (!hackathon) { return createOGResponse({ diff --git a/app/api/og/hackathons/route.tsx b/app/api/og/hackathons/route.tsx index 2ebc9f30d1e..ecdf0927249 100644 --- a/app/api/og/hackathons/route.tsx +++ b/app/api/og/hackathons/route.tsx @@ -1,3 +1,4 @@ +// withApi: not applicable — edge runtime OG image generator import type { NextRequest } from 'next/server'; import { ImageResponse } from 'next/og'; import { loadFonts, createOGResponse } from '@/utils/og-image'; @@ -20,4 +21,4 @@ export async function GET( path: 'hackathons', fonts }); -} \ No newline at end of file +} diff --git a/app/api/og/integrations/[slug]/route.tsx b/app/api/og/integrations/[slug]/route.tsx index 0f7bc711789..211505dfdf8 100644 --- a/app/api/og/integrations/[slug]/route.tsx +++ b/app/api/og/integrations/[slug]/route.tsx @@ -1,3 +1,4 @@ +// withApi: not applicable — edge runtime OG image generator import type { NextRequest } from 'next/server'; import { ImageResponse } from 'next/og'; import { loadFonts, createOGResponse } from '@/utils/og-image'; @@ -21,4 +22,4 @@ export async function GET( path: 'integrations', fonts }); -} \ No newline at end of file +} diff --git a/app/api/og/integrations/route.tsx b/app/api/og/integrations/route.tsx index 3782f1305d5..a3422e2dd3d 100644 --- a/app/api/og/integrations/route.tsx +++ b/app/api/og/integrations/route.tsx @@ -1,3 +1,4 @@ +// withApi: not applicable — edge runtime OG image generator import { ImageResponse } from 'next/og'; import type { NextRequest } from 'next/server'; @@ -136,4 +137,4 @@ function OG({ ); -} \ No newline at end of file +} diff --git a/app/api/og/stats/[slug]/route.tsx b/app/api/og/stats/[slug]/route.tsx index 4f661f0de26..64ba0c4827a 100644 --- a/app/api/og/stats/[slug]/route.tsx +++ b/app/api/og/stats/[slug]/route.tsx @@ -1,3 +1,4 @@ +// withApi: not applicable — edge runtime OG image generator import { NextRequest } from "next/server"; import { loadFonts, createOGResponse } from "@/utils/og-image"; diff --git a/app/api/og/stats/route.tsx b/app/api/og/stats/route.tsx index 1e5c61dc296..8c871958bfa 100644 --- a/app/api/og/stats/route.tsx +++ b/app/api/og/stats/route.tsx @@ -1,3 +1,4 @@ +// withApi: not applicable — edge runtime OG image generator import { NextRequest } from "next/server"; import { loadFonts, createOGResponse } from "@/utils/og-image"; diff --git a/app/api/og/tools/l1-toolbox/route.tsx b/app/api/og/tools/l1-toolbox/route.tsx index eedb70a9271..3a2c1dec8d7 100644 --- a/app/api/og/tools/l1-toolbox/route.tsx +++ b/app/api/og/tools/l1-toolbox/route.tsx @@ -1,3 +1,4 @@ +// withApi: not applicable — edge runtime OG image generator import { ImageResponse } from 'next/og'; import type { NextRequest } from 'next/server'; @@ -136,4 +137,4 @@ function OG({ ); -} \ No newline at end of file +} diff --git a/app/api/og/tools/route.tsx b/app/api/og/tools/route.tsx index 6582649393a..0e23b13ac7c 100644 --- a/app/api/og/tools/route.tsx +++ b/app/api/og/tools/route.tsx @@ -1,3 +1,4 @@ +// withApi: not applicable — edge runtime OG image generator import { ImageResponse } from 'next/og'; import type { NextRequest } from 'next/server'; @@ -136,4 +137,4 @@ function OG({ ); -} \ No newline at end of file +} diff --git a/app/api/overview-stats/route.ts b/app/api/overview-stats/route.ts index ddb235e657b..9594d589a20 100644 --- a/app/api/overview-stats/route.ts +++ b/app/api/overview-stats/route.ts @@ -1,7 +1,8 @@ import { NextResponse } from 'next/server'; -import l1ChainsData from "@/constants/l1-chains.json"; -import { STATS_CONFIG } from "@/types/stats"; -import { getChainICMCount } from "@/lib/icm-clickhouse"; +import { withApi } from '@/lib/api'; +import l1ChainsData from '@/constants/l1-chains.json'; +import { STATS_CONFIG } from '@/types/stats'; +import { getChainICMCount } from '@/lib/icm-clickhouse'; export const dynamic = 'force-dynamic'; @@ -10,19 +11,19 @@ const CACHE_CONTROL_HEADER = 'public, max-age=14400, s-maxage=14400, stale-while const REQUEST_TIMEOUT_MS = 8000; const MAX_CONCURRENT_CHAINS = 10; const METRICS_API_URL = process.env.METRICS_API_URL; -if (!METRICS_API_URL) { - console.warn('METRICS_API_URL is not set — overview-stats endpoint will fail'); -} const TIME_RANGE_CONFIG = { day: { days: 3, secondsInRange: SECONDS_PER_DAY }, week: { days: 9, secondsInRange: 7 * SECONDS_PER_DAY }, - month: { days: 32, secondsInRange: 30 * SECONDS_PER_DAY } + month: { days: 32, secondsInRange: 30 * SECONDS_PER_DAY }, } as const; type TimeRangeKey = keyof typeof TIME_RANGE_CONFIG; -interface MetricResult { timestamp: number; value: number; } +interface MetricResult { + timestamp: number; + value: number; +} interface ChainOverviewMetrics { chainId: string; chainName: string; @@ -63,7 +64,11 @@ const chainDataCache = new Map(); const pendingRequests = new Map>(); -async function fetchWithTimeout(url: string, options: RequestInit = {}, timeoutMs = REQUEST_TIMEOUT_MS): Promise { +async function fetchWithTimeout( + url: string, + options: RequestInit = {}, + timeoutMs = REQUEST_TIMEOUT_MS, +): Promise { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), timeoutMs); try { @@ -73,11 +78,15 @@ async function fetchWithTimeout(url: string, options: RequestInit = {}, timeoutM } } -async function processInBatches(items: T[], processor: (item: T) => Promise, batchSize: number): Promise[]> { +async function processInBatches( + items: T[], + processor: (item: T) => Promise, + batchSize: number, +): Promise[]> { const results: PromiseSettledResult[] = []; for (let i = 0; i < items.length; i += batchSize) { const batch = items.slice(i, i + batchSize); - results.push(...await Promise.allSettled(batch.map(processor))); + results.push(...(await Promise.allSettled(batch.map(processor)))); } return results; } @@ -96,11 +105,11 @@ function sumValues(sorted: MetricResult[], daysToSum: number): number { function getAllChains(): ChainInfo[] { return l1ChainsData - .filter(chain => - !('isTestnet' in chain && chain.isTestnet === true) && - !('isActive' in chain && chain.isActive === false) + .filter( + (chain) => + !('isTestnet' in chain && chain.isTestnet === true) && !('isActive' in chain && chain.isActive === false), ) - .map(chain => ({ + .map((chain) => ({ chainId: chain.chainId, chainName: chain.chainName, logoUri: chain.chainLogoURI || '', @@ -113,7 +122,7 @@ async function getTxCountData(chainId: string, timeRange: TimeRangeKey): Promise try { const config = TIME_RANGE_CONFIG[timeRange]; const endTimestamp = Math.floor(Date.now() / 1000); - const startTimestamp = endTimestamp - (config.days * SECONDS_PER_DAY); + const startTimestamp = endTimestamp - config.days * SECONDS_PER_DAY; const url = new URL(`${METRICS_API_URL}/v2/chains/${chainId}/metrics/txCount`); url.searchParams.set('timeInterval', 'day'); @@ -131,8 +140,8 @@ async function getTxCountData(chainId: string, timeRange: TimeRangeKey): Promise if (sorted.length === 1) return sorted[0]?.value || 0; if (timeRange === 'day') return sorted[1]?.value || 0; return sumValues(sorted, timeRange === 'week' ? 7 : 30); - } catch (error) { - console.error(`[getTxCountData] Failed for chain ${chainId}:`, error); + } catch { + // Per-chain tx count failure; return 0 return 0; } } @@ -140,7 +149,7 @@ async function getTxCountData(chainId: string, timeRange: TimeRangeKey): Promise async function getActiveAddressesData(chainId: string, timeRange: TimeRangeKey): Promise { try { const endTimestamp = Math.floor(Date.now() / 1000); - const startTimestamp = endTimestamp - (30 * SECONDS_PER_DAY); + const startTimestamp = endTimestamp - 30 * SECONDS_PER_DAY; const url = new URL(`${METRICS_API_URL}/v2/chains/${chainId}/metrics/activeAddresses`); url.searchParams.set('timeInterval', timeRange); @@ -156,8 +165,8 @@ async function getActiveAddressesData(chainId: string, timeRange: TimeRangeKey): const sorted = sortByTimestampDesc(allResults); const dataPoint = sorted.length > 1 ? sorted[1] : sorted[0]; return dataPoint?.value || 0; - } catch (error) { - console.error(`[getActiveAddressesData] Failed for chain ${chainId}:`, error); + } catch { + // Per-chain active addresses failure; return 0 return 0; } } @@ -166,32 +175,32 @@ async function getICMData(chainId: string, timeRange: TimeRangeKey): Promise { - if (!subnetId || subnetId === "N/A") return "N/A"; + if (!subnetId || subnetId === 'N/A') return 'N/A'; try { const url = new URL('https://metrics.avax.network/v2/networks/mainnet/metrics/validatorCount'); url.searchParams.set('pageSize', '1'); url.searchParams.set('subnetId', subnetId); - - const response = await fetchWithTimeout(url.toString(), { headers: { 'Accept': 'application/json' } }); - if (!response.ok) return "N/A"; + + const response = await fetchWithTimeout(url.toString(), { headers: { Accept: 'application/json' } }); + if (!response.ok) return 'N/A'; const data = await response.json(); const value = data?.results?.[0]?.value; - return value ? Number(value) : "N/A"; + return value ? Number(value) : 'N/A'; } catch (error) { if (error instanceof Error && error.name !== 'AbortError') { - console.error(`[getValidatorCount] Failed for subnet ${subnetId}:`, error); + // Validator count is best-effort; return N/A } - return "N/A"; + return 'N/A'; } } @@ -203,9 +212,7 @@ async function fetchMarketCaps(chains: ChainInfo[]): Promise c.coingeckoId) - .map(c => c.coingeckoId!); + const coingeckoIds = chains.filter((c) => c.coingeckoId).map((c) => c.coingeckoId!); if (coingeckoIds.length === 0) return {}; @@ -213,8 +220,8 @@ async function fetchMarketCaps(chains: ChainInfo[]): Promise { const cacheKey = `${chain.chainId}-${timeRange}`; const cached = chainDataCache.get(cacheKey); - + if (cached && Date.now() - cached.timestamp < STATS_CONFIG.CACHE.SHORT_DURATION) { return cached.data; } @@ -267,24 +274,24 @@ async function fetchChainMetrics(chain: ChainInfo, timeRange: TimeRangeKey): Pro chainDataCache.set(cacheKey, { data: result, timestamp: Date.now() }); return result; - } catch (error) { - console.error(`[fetchChainMetrics] Failed for chain ${chain.chainId}:`, error); + } catch { + // Per-chain metrics failure; return null return null; } } async function fetchFreshDataInternal(timeRange: TimeRangeKey): Promise { try { - const startTime = Date.now(); + const _startTime = Date.now(); const allChains = getAllChains(); - + const [chainResults, marketCaps] = await Promise.all([ processInBatches(allChains, (chain) => fetchChainMetrics(chain, timeRange), MAX_CONCURRENT_CHAINS), fetchMarketCaps(allChains), ]); const chainMetrics = chainResults .filter((r): r is PromiseFulfilledResult => r.status === 'fulfilled' && r.value !== null) - .map(r => r.value); + .map((r) => r.value); // Build coingeckoId -> chainId lookup and merge market caps const coingeckoToChainId = new Map(); @@ -296,60 +303,72 @@ async function fetchFreshDataInternal(timeRange: TimeRangeKey): Promise c.chainId === chainId); + const chainMetric = chainMetrics.find((c) => c.chainId === chainId); if (chainMetric) { chainMetric.marketCap = mcap; } } } - const aggregated = chainMetrics.reduce((acc, chain) => { - acc.totalTxCount += chain.txCount || 0; - acc.totalActiveAddresses += chain.activeAddresses || 0; - acc.totalICMMessages += chain.icmMessages || 0; - acc.totalMarketCap += chain.marketCap ?? 0; - if (typeof chain.validatorCount === 'number') acc.totalValidators += chain.validatorCount; - if (chain.txCount > 0 || chain.activeAddresses > 0) acc.activeChains++; - return acc; - }, { totalTxCount: 0, totalActiveAddresses: 0, totalICMMessages: 0, totalMarketCap: 0, totalValidators: 0, activeChains: 0 }); + const aggregated = chainMetrics.reduce( + (acc, chain) => { + acc.totalTxCount += chain.txCount || 0; + acc.totalActiveAddresses += chain.activeAddresses || 0; + acc.totalICMMessages += chain.icmMessages || 0; + acc.totalMarketCap += chain.marketCap ?? 0; + if (typeof chain.validatorCount === 'number') acc.totalValidators += chain.validatorCount; + if (chain.txCount > 0 || chain.activeAddresses > 0) acc.activeChains++; + return acc; + }, + { + totalTxCount: 0, + totalActiveAddresses: 0, + totalICMMessages: 0, + totalMarketCap: 0, + totalValidators: 0, + activeChains: 0, + }, + ); const metrics: OverviewMetrics = { chains: chainMetrics, aggregated: { ...aggregated, totalTps: aggregated.totalTxCount / TIME_RANGE_CONFIG[timeRange].secondsInRange }, timeRange, - last_updated: Date.now() + last_updated: Date.now(), }; cachedData.set(timeRange, { data: metrics, timestamp: Date.now() }); - console.log(`[fetchFreshData] Completed in ${Date.now() - startTime}ms, ${chainMetrics.length}/${allChains.length} chains`); + // Completed fresh data fetch return metrics; - } catch (error) { - console.error('[fetchFreshData] Failed:', error); + } catch { + // Fresh data fetch failed; return null return null; } } -async function fetchFreshData(timeRange: TimeRangeKey): Promise<{ data: OverviewMetrics; fetchTime: number; chainCount: number } | null> { +async function fetchFreshData( + timeRange: TimeRangeKey, +): Promise<{ data: OverviewMetrics; fetchTime: number; chainCount: number } | null> { const startTime = Date.now(); const pendingKey = `fresh-${timeRange}`; let pendingPromise = pendingRequests.get(pendingKey); - + if (!pendingPromise) { pendingPromise = fetchFreshDataInternal(timeRange); pendingRequests.set(pendingKey, pendingPromise); pendingPromise.finally(() => pendingRequests.delete(pendingKey)); } - + const data = await pendingPromise; if (!data) return null; - + return { data, fetchTime: Date.now() - startTime, chainCount: data.chains.length }; } function createResponse( data: OverviewMetrics | { error: string }, meta: { source: string; timeRange?: TimeRangeKey; cacheAge?: number; fetchTime?: number; chainCount?: number }, - status = 200 + status = 200, ) { const headers: Record = { 'Cache-Control': CACHE_CONTROL_HEADER, 'X-Data-Source': meta.source }; if (meta.timeRange) headers['X-Time-Range'] = meta.timeRange; @@ -359,41 +378,40 @@ function createResponse( return NextResponse.json(data, { status, headers }); } -export async function GET(request: Request) { - try { - const { searchParams } = new URL(request.url); - const timeRangeParam = searchParams.get('timeRange') || 'day'; - const timeRange: TimeRangeKey = timeRangeParam in TIME_RANGE_CONFIG ? (timeRangeParam as TimeRangeKey) : 'day'; - - if (searchParams.get('clearCache') === 'true') { - cachedData.clear(); - chainDataCache.clear(); - revalidatingKeys.clear(); - } - - const cached = cachedData.get(timeRange); - const cacheAge = cached ? Date.now() - cached.timestamp : Infinity; - const isCacheValid = cacheAge < STATS_CONFIG.CACHE.SHORT_DURATION; - const isCacheStale = cached && !isCacheValid; - - if (isCacheStale && !revalidatingKeys.has(timeRange)) { - revalidatingKeys.add(timeRange); - fetchFreshData(timeRange).finally(() => revalidatingKeys.delete(timeRange)); - return createResponse(cached.data, { source: 'stale-while-revalidate', timeRange, cacheAge }); - } - - if (isCacheValid && cached) { - return createResponse(cached.data, { source: 'cache', timeRange, cacheAge }); - } - - const freshData = await fetchFreshData(timeRange); - if (!freshData) { - return createResponse({ error: 'Failed to fetch chain metrics' }, { source: 'error' }, 500); - } - - return createResponse(freshData.data, { source: 'fresh', timeRange, fetchTime: freshData.fetchTime, chainCount: freshData.chainCount }); - } catch (error) { - console.error('[GET /api/overview-stats] Unhandled error:', error); +export const GET = withApi(async (req) => { + const timeRangeParam = req.nextUrl.searchParams.get('timeRange') || 'day'; + const timeRange: TimeRangeKey = timeRangeParam in TIME_RANGE_CONFIG ? (timeRangeParam as TimeRangeKey) : 'day'; + + if (req.nextUrl.searchParams.get('clearCache') === 'true') { + cachedData.clear(); + chainDataCache.clear(); + revalidatingKeys.clear(); + } + + const cached = cachedData.get(timeRange); + const cacheAge = cached ? Date.now() - cached.timestamp : Infinity; + const isCacheValid = cacheAge < STATS_CONFIG.CACHE.SHORT_DURATION; + const isCacheStale = cached && !isCacheValid; + + if (isCacheStale && !revalidatingKeys.has(timeRange)) { + revalidatingKeys.add(timeRange); + fetchFreshData(timeRange).finally(() => revalidatingKeys.delete(timeRange)); + return createResponse(cached.data, { source: 'stale-while-revalidate', timeRange, cacheAge }); + } + + if (isCacheValid && cached) { + return createResponse(cached.data, { source: 'cache', timeRange, cacheAge }); + } + + const freshData = await fetchFreshData(timeRange); + if (!freshData) { return createResponse({ error: 'Failed to fetch chain metrics' }, { source: 'error' }, 500); } -} + + return createResponse(freshData.data, { + source: 'fresh', + timeRange, + fetchTime: freshData.fetchTime, + chainCount: freshData.chainCount, + }); +}); diff --git a/app/api/pchain-faucet/route.ts b/app/api/pchain-faucet/route.ts index 01ad662c855..c835450b667 100644 --- a/app/api/pchain-faucet/route.ts +++ b/app/api/pchain-faucet/route.ts @@ -1,38 +1,33 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { TransferableOutput, addTxSignatures, pvm, utils, Context } from "@avalabs/avalanchejs"; -import { getAuthSession } from '@/lib/auth/authSession'; +import type { NextRequest } from 'next/server'; +import { TransferableOutput, addTxSignatures, pvm, utils, Context } from '@avalabs/avalanchejs'; +import { z } from 'zod'; +import { withApi, successResponse, BadRequestError, InternalError, RateLimitError, validateQuery } from '@/lib/api'; import { checkAndReserveFaucetClaim, completeFaucetClaim, cancelFaucetClaim } from '@/lib/faucet/rateLimit'; import { checkAndAwardConsoleBadges } from '@/server/services/consoleBadge/consoleBadgeService'; import type { AwardedConsoleBadge } from '@/server/services/consoleBadge/types'; -const SERVER_PRIVATE_KEY = process.env.SERVER_PRIVATE_KEY; -const FAUCET_P_CHAIN_ADDRESS = process.env.FAUCET_P_CHAIN_ADDRESS; const FIXED_AMOUNT = 0.5; -interface TransferResponse { - success: boolean; - txID?: string; - sourceAddress?: string; - destinationAddress?: string; - amount?: number; - message?: string; -} +const querySchema = z.object({ + address: z + .string() + .min(1, 'Destination address is required') + .startsWith('P-', { message: 'Invalid P-Chain address format' }), +}); async function transferPToP( privateKey: string, sourceAddress: string, - destinationAddress: string + destinationAddress: string, ): Promise<{ txID: string }> { - const context = await Context.getContextFromURI("https://api.avax-test.network"); - const pvmApi = new pvm.PVMApi("https://api.avax-test.network"); + const context = await Context.getContextFromURI('https://api.avax-test.network'); + const pvmApi = new pvm.PVMApi('https://api.avax-test.network'); const feeState = await pvmApi.getFeeState(); const { utxos } = await pvmApi.getUTXOs({ addresses: [sourceAddress] }); const amountNAvax = BigInt(Math.floor(FIXED_AMOUNT * 1e9)); const outputs = [ - TransferableOutput.fromNative(context.avaxAssetID, amountNAvax, [ - utils.bech32ToBytes(destinationAddress), - ]), + TransferableOutput.fromNative(context.avaxAssetID, amountNAvax, [utils.bech32ToBytes(destinationAddress)]), ]; const tx = pvm.newBaseTx( @@ -42,7 +37,7 @@ async function transferPToP( outputs, utxos, }, - context + context, ); await addTxSignatures({ @@ -53,97 +48,60 @@ async function transferPToP( return pvmApi.issueSignedTx(tx.getSignedTx()); } -export async function GET(request: NextRequest): Promise { - let claimId: string | null = null; - - try { - const session = await getAuthSession(); - if (!session?.user?.id) { - return NextResponse.json( - { success: false, message: 'Authentication required' }, - { status: 401 } - ); - } +export const GET = withApi( + async (req: NextRequest, { session }) => { + const SERVER_PRIVATE_KEY = process.env.SERVER_PRIVATE_KEY; + const FAUCET_P_CHAIN_ADDRESS = process.env.FAUCET_P_CHAIN_ADDRESS; if (!SERVER_PRIVATE_KEY || !FAUCET_P_CHAIN_ADDRESS) { - return NextResponse.json( - { success: false, message: 'Server not properly configured' }, - { status: 500 } - ); + throw new InternalError('Server not properly configured'); } - const searchParams = request.nextUrl.searchParams; - const destinationAddress = searchParams.get('address'); + const { address: destinationAddress } = validateQuery(req, querySchema); - if (!destinationAddress) { - return NextResponse.json( - { success: false, message: 'Destination address is required' }, - { status: 400 } - ); - } - - if (!destinationAddress.startsWith('P-')) { - return NextResponse.json( - { success: false, message: 'Invalid P-Chain address format' }, - { status: 400 } - ); - } - - if (destinationAddress.toLowerCase() === FAUCET_P_CHAIN_ADDRESS?.toLowerCase()) { - return NextResponse.json( - { success: false, message: 'Cannot send tokens to the faucet address' }, - { status: 400 } - ); + if (destinationAddress.toLowerCase() === FAUCET_P_CHAIN_ADDRESS.toLowerCase()) { + throw new BadRequestError('Cannot send tokens to the faucet address'); } const reservationResult = await checkAndReserveFaucetClaim( session.user.id, 'pchain', destinationAddress, - FIXED_AMOUNT.toString() + FIXED_AMOUNT.toString(), ); if (!reservationResult.allowed) { - return NextResponse.json( - { success: false, message: reservationResult.reason }, - { status: 429 } - ); + throw new RateLimitError(reservationResult.reason); } - claimId = reservationResult.claimId!; + const claimId = reservationResult.claimId!; - const tx = await transferPToP( - SERVER_PRIVATE_KEY, - FAUCET_P_CHAIN_ADDRESS, - destinationAddress - ); + let txID: string; + try { + const tx = await transferPToP(SERVER_PRIVATE_KEY, FAUCET_P_CHAIN_ADDRESS, destinationAddress); + txID = tx.txID; + } catch { + await cancelFaucetClaim(claimId); + throw new InternalError('Faucet transaction failed'); + } - await completeFaucetClaim(claimId, tx.txID); + await completeFaucetClaim(claimId, txID); let awardedBadges: AwardedConsoleBadge[] = []; - try { awardedBadges = await checkAndAwardConsoleBadges(session.user.id, 'faucet_claim'); } - catch (e) { console.error('Badge check failed:', e); } + try { + awardedBadges = await checkAndAwardConsoleBadges(session.user.id, 'faucet_claim'); + } catch { + // Badge check is non-critical; swallow failures + } - return NextResponse.json({ - success: true, - txID: tx.txID, + return successResponse({ + txID, sourceAddress: FAUCET_P_CHAIN_ADDRESS, destinationAddress, amount: FIXED_AMOUNT, message: `Successfully transferred ${FIXED_AMOUNT} AVAX`, awardedBadges, }); - - } catch (error) { - console.error('P-Chain faucet error:', error); - - if (claimId) { - await cancelFaucetClaim(claimId); - } - - return NextResponse.json( - { success: false, message: error instanceof Error ? error.message : 'Failed to complete transfer' }, - { status: 500 } - ); - } -} + }, + { auth: true }, +); diff --git a/app/api/playground/[id]/view/route.ts b/app/api/playground/[id]/view/route.ts index d3df74e5671..de5dea1bff7 100644 --- a/app/api/playground/[id]/view/route.ts +++ b/app/api/playground/[id]/view/route.ts @@ -1,42 +1,25 @@ -import { NextRequest, NextResponse } from 'next/server'; +import type { NextRequest } from 'next/server'; +import { withApi } from '@/lib/api/with-api'; +import { successResponse } from '@/lib/api/response'; import { prisma } from '@/prisma/prisma'; -// POST /api/playground/[id]/view - Increment view count -export async function POST( - req: NextRequest, - { params }: { params: Promise<{ id: string }> } -) { - try { - const { id: playgroundId } = await params; +// --------------------------------------------------------------------------- +// POST /api/playground/[id]/view — Increment view count (rate-limited by IP) +// --------------------------------------------------------------------------- - if (!playgroundId) { - return NextResponse.json({ error: 'Playground ID is required' }, { status: 400 }); - } - - // Increment view count atomically +// withApi: auth intentionally omitted — public view tracking, rate limited +// schema: not applicable — no request body, POST for increment side-effect +export const POST = withApi( + async (_req: NextRequest, { params }) => { const playground = await prisma.statsPlayground.update({ - where: { id: playgroundId }, - data: { - view_count: { - increment: 1 - } - }, - select: { - view_count: true - } - }); - - return NextResponse.json({ - success: true, - view_count: playground.view_count + where: { id: params.id }, + data: { view_count: { increment: 1 } }, + select: { view_count: true }, }); - } catch (error) { - console.error('Error incrementing view count:', error); - // Don't fail the request if view tracking fails - return NextResponse.json({ - success: false, - error: 'Failed to track view' - }, { status: 500 }); - } -} + return successResponse({ view_count: playground.view_count }); + }, + { + rateLimit: { windowMs: 60_000, maxRequests: 10, identifier: 'ip' }, + }, +); diff --git a/app/api/playground/favorite/route.ts b/app/api/playground/favorite/route.ts index 8324f1d494c..bd7a800aec3 100644 --- a/app/api/playground/favorite/route.ts +++ b/app/api/playground/favorite/route.ts @@ -1,109 +1,85 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { getAuthSession } from '@/lib/auth/authSession'; +import type { NextRequest } from 'next/server'; +import { z } from 'zod'; +import { withApi } from '@/lib/api/with-api'; +import { successResponse } from '@/lib/api/response'; +import { BadRequestError, ConflictError, NotFoundError } from '@/lib/api/errors'; import { prisma } from '@/prisma/prisma'; -// POST /api/playground/favorite - Favorite a playground -export async function POST(req: NextRequest) { - try { - const session = await getAuthSession(); - if (!session?.user) { - return NextResponse.json({ error: 'Unauthorized, please sign in to continue.' }, { status: 401 }); - } +const favoriteSchema = z.object({ + playgroundId: z.string().min(1, 'Playground ID is required'), +}); - const body = await req.json(); - const { playgroundId } = body; +// --------------------------------------------------------------------------- +// POST /api/playground/favorite +// --------------------------------------------------------------------------- - if (!playgroundId) { - return NextResponse.json({ error: 'Playground ID is required.' }, { status: 400 }); - } - - // Verify playground exists and is public or owned by user +export const POST = withApi>( + async (_req, { session, body }) => { + // Verify playground exists and is accessible const playground = await prisma.statsPlayground.findFirst({ where: { - id: playgroundId, - OR: [ - { user_id: session.user.id }, - { is_public: true } - ] - } + id: body.playgroundId, + OR: [{ user_id: session.user.id }, { is_public: true }], + }, }); if (!playground) { - return NextResponse.json({ error: 'Playground not found' }, { status: 404 }); + throw new NotFoundError('Playground'); } - // Check if already favorited - const existingFavorite = await prisma.statsPlaygroundFavorite.findUnique({ + // Check for existing favorite + const existing = await prisma.statsPlaygroundFavorite.findUnique({ where: { playground_id_user_id: { - playground_id: playgroundId, - user_id: session.user.id - } - } + playground_id: body.playgroundId, + user_id: session.user.id, + }, + }, }); - if (existingFavorite) { - return NextResponse.json({ error: 'Playground already favorited' }, { status: 400 }); + if (existing) { + throw new ConflictError('Playground already favorited'); } - // Create favorite await prisma.statsPlaygroundFavorite.create({ data: { - playground_id: playgroundId, - user_id: session.user.id - } + playground_id: body.playgroundId, + user_id: session.user.id, + }, }); - // Get updated favorite count const favoriteCount = await prisma.statsPlaygroundFavorite.count({ - where: { playground_id: playgroundId } + where: { playground_id: body.playgroundId }, }); - return NextResponse.json({ - success: true, - favorite_count: favoriteCount - }); - } catch (error) { - console.error('Error favoriting playground:', error); - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); - } -} - -// DELETE /api/playground/favorite - Unfavorite a playground -export async function DELETE(req: NextRequest) { - try { - const session = await getAuthSession(); - if (!session?.user) { - return NextResponse.json({ error: 'Unauthorized, please sign in to continue.' }, { status: 401 }); - } + return successResponse({ favorite_count: favoriteCount }, 201); + }, + { auth: true, schema: favoriteSchema }, +); - const { searchParams } = new URL(req.url); - const playgroundId = searchParams.get('playgroundId'); +// --------------------------------------------------------------------------- +// DELETE /api/playground/favorite +// --------------------------------------------------------------------------- +export const DELETE = withApi( + async (req: NextRequest, { session }) => { + const playgroundId = req.nextUrl.searchParams.get('playgroundId'); if (!playgroundId) { - return NextResponse.json({ error: 'Playground ID is required.' }, { status: 400 }); + throw new BadRequestError('Playground ID is required'); } - // Delete favorite await prisma.statsPlaygroundFavorite.deleteMany({ where: { playground_id: playgroundId, - user_id: session.user.id - } + user_id: session.user.id, + }, }); - // Get updated favorite count const favoriteCount = await prisma.statsPlaygroundFavorite.count({ - where: { playground_id: playgroundId } - }); - - return NextResponse.json({ - success: true, - favorite_count: favoriteCount + where: { playground_id: playgroundId }, }); - } catch (error) { - console.error('Error unfavoriting playground:', error); - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); - } -} + return successResponse({ favorite_count: favoriteCount }); + }, + { auth: true }, +); diff --git a/app/api/playground/route.ts b/app/api/playground/route.ts index 5c2b023f284..e0338fdd7f9 100644 --- a/app/api/playground/route.ts +++ b/app/api/playground/route.ts @@ -1,268 +1,206 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { getAuthSession } from '@/lib/auth/authSession'; +import type { NextRequest } from 'next/server'; +import { z } from 'zod'; +import { withApi } from '@/lib/api/with-api'; +import { successResponse, noContentResponse } from '@/lib/api/response'; +import { AuthError, BadRequestError, NotFoundError } from '@/lib/api/errors'; +import { assertOwnership } from '@/lib/api/ownership'; import { prisma } from '@/prisma/prisma'; -// GET /api/playground - Get user's playgrounds -export async function GET(req: NextRequest) { - try { - const session = await getAuthSession(); - const { searchParams } = new URL(req.url); - const playgroundId = searchParams.get('id'); - const includePublic = searchParams.get('includePublic') === 'true'; - - if (playgroundId) { - // Get specific playground - allow public access even without auth - const playground = await prisma.statsPlayground.findFirst({ - where: { - id: playgroundId, - OR: session?.user ? [ - { user_id: session.user.id }, - { is_public: true } - ] : [ - { is_public: true } - ] - }, - include: { - favorites: session?.user ? { - where: { - user_id: session.user.id - } - } : false, - _count: { - select: { favorites: true } - }, - user: { - select: { - id: true, - name: true, - user_name: true, - image: true, - profile_privacy: true - } - } - } - }); - - if (!playground) { - return NextResponse.json({ error: 'Playground not found' }, { status: 404 }); - } - - const isOwner = session?.user ? playground.user_id === session.user.id : false; - const isFavorited = session?.user && playground.favorites ? playground.favorites.length > 0 : false; - const favoriteCount = playground._count.favorites; - - // Extract global time filters and charts array from JSON structure - const chartsData = playground.charts as any; - const chartsArray = Array.isArray(chartsData) ? chartsData : (chartsData?.charts || []); - const globalStartTime = Array.isArray(chartsData) ? null : (chartsData?.globalStartTime || null); - const globalEndTime = Array.isArray(chartsData) ? null : (chartsData?.globalEndTime || null); - - return NextResponse.json({ - ...playground, - charts: chartsArray, - globalStartTime, - globalEndTime, - is_owner: isOwner, - is_favorited: isFavorited, - favorite_count: favoriteCount, - view_count: playground.view_count || 0, - favorites: undefined, // Remove favorites array from response - _count: undefined, // Remove _count from response - creator: playground.user ? { - id: playground.user.id, - name: playground.user.name, - user_name: playground.user.user_name, - image: playground.user.image, - profile_privacy: playground.user.profile_privacy - } : undefined - }); - } +// --------------------------------------------------------------------------- +// Schemas +// --------------------------------------------------------------------------- + +const createPlaygroundSchema = z.object({ + name: z.string().min(1, 'Name is required'), + isPublic: z.boolean().optional().default(false), + charts: z.array(z.unknown()).optional().default([]), + globalStartTime: z.string().nullable().optional().default(null), + globalEndTime: z.string().nullable().optional().default(null), +}); + +const updatePlaygroundSchema = z.object({ + id: z.string().min(1, 'Playground ID is required'), + name: z.string().min(1).optional(), + isPublic: z.boolean().optional(), + charts: z.array(z.unknown()).optional(), + globalStartTime: z.string().nullable().optional(), + globalEndTime: z.string().nullable().optional(), +}); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function normalizePlayground(playground: any, extras?: Record) { + const chartsData = playground.charts as any; + const chartsArray = Array.isArray(chartsData) ? chartsData : chartsData?.charts || []; + const globalStartTime = Array.isArray(chartsData) ? null : chartsData?.globalStartTime || null; + const globalEndTime = Array.isArray(chartsData) ? null : chartsData?.globalEndTime || null; + + return { + ...playground, + charts: chartsArray, + globalStartTime, + globalEndTime, + view_count: playground.view_count || 0, + favorites: undefined, + _count: undefined, + ...extras, + }; +} - // Get all user's playgrounds - requires authentication - if (!session?.user) { - return NextResponse.json({ error: 'Unauthorized, please sign in to continue.' }, { status: 401 }); - } - - const where: any = { user_id: session.user.id }; - - if (includePublic) { - const playgrounds = await prisma.statsPlayground.findMany({ - where: { - OR: [ - { user_id: session.user.id }, - { is_public: true } - ] - }, - include: { - _count: { - select: { favorites: true } - } - }, - orderBy: { updated_at: 'desc' }, - take: 100 - }); - return NextResponse.json(playgrounds.map(p => ({ - ...p, - favorite_count: p._count.favorites, - view_count: p.view_count || 0, - _count: undefined - }))); - } +// --------------------------------------------------------------------------- +// GET /api/playground +// --------------------------------------------------------------------------- + +export const GET = withApi(async (req: NextRequest, { session }) => { + const { searchParams } = req.nextUrl; + const playgroundId = searchParams.get('id'); + const includePublic = searchParams.get('includePublic') === 'true'; - const playgrounds = await prisma.statsPlayground.findMany({ - where, + // --- Single playground by ID (public access allowed) --- + if (playgroundId) { + const playground = await prisma.statsPlayground.findFirst({ + where: { + id: playgroundId, + OR: session?.user ? [{ user_id: session.user.id }, { is_public: true }] : [{ is_public: true }], + }, include: { - _count: { - select: { favorites: true } - } + favorites: session?.user ? { where: { user_id: session.user.id } } : false, + _count: { select: { favorites: true } }, + user: { + select: { + id: true, + name: true, + user_name: true, + image: true, + profile_privacy: true, + }, + }, }, - orderBy: { updated_at: 'desc' }, - take: 100 }); - return NextResponse.json(playgrounds.map(p => ({ - ...p, - favorite_count: p._count.favorites, - view_count: p.view_count || 0, - _count: undefined - }))); - } catch (error) { - console.error('Error fetching playgrounds:', error); - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + if (!playground) { + throw new NotFoundError('Playground'); + } + + const isOwner = session?.user ? playground.user_id === session.user.id : false; + const isFavorited = session?.user && playground.favorites ? (playground.favorites as any[]).length > 0 : false; + + return successResponse( + normalizePlayground(playground, { + is_owner: isOwner, + is_favorited: isFavorited, + favorite_count: playground._count.favorites, + creator: playground.user + ? { + id: playground.user.id, + name: playground.user.name, + user_name: playground.user.user_name, + image: playground.user.image, + profile_privacy: playground.user.profile_privacy, + } + : undefined, + }), + ); } -} -// POST /api/playground - Create new playground -export async function POST(req: NextRequest) { - try { - const session = await getAuthSession(); - if (!session?.user) { - return NextResponse.json({ error: 'Unauthorized, please sign in to continue.' }, { status: 401 }); - } + // --- List playgrounds (requires auth) --- + if (!session?.user) { + throw new AuthError(); + } - const body = await req.json(); - if (!body) { - return NextResponse.json({ error: 'No body provided.' }, { status: 400 }); - } + const whereClause = includePublic + ? { OR: [{ user_id: session.user.id }, { is_public: true }] } + : { user_id: session.user.id }; - if (!body.name) { - return NextResponse.json({ error: 'Name is required.' }, { status: 400 }); - } + const playgrounds = await prisma.statsPlayground.findMany({ + where: whereClause, + include: { _count: { select: { favorites: true } } }, + orderBy: { updated_at: 'desc' }, + take: 100, + }); - const { name, isPublic, charts, globalStartTime, globalEndTime } = body; + return successResponse(playgrounds.map((p) => normalizePlayground(p, { favorite_count: p._count.favorites }))); +}); - // Store global time filters in charts JSON structure +// --------------------------------------------------------------------------- +// POST /api/playground +// --------------------------------------------------------------------------- + +export const POST = withApi>( + async (_req, { session, body }) => { const chartsData = { - globalStartTime: globalStartTime || null, - globalEndTime: globalEndTime || null, - charts: charts || [] + globalStartTime: body.globalStartTime || null, + globalEndTime: body.globalEndTime || null, + charts: body.charts, }; const playground = await prisma.statsPlayground.create({ data: { user_id: session.user.id, - name, - is_public: isPublic || false, - charts: chartsData as any - } + name: body.name, + is_public: body.isPublic, + charts: chartsData as any, + }, }); - return NextResponse.json(playground); - } catch (error) { - console.error('Error creating playground:', error); - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); - } -} + return successResponse(playground, 201); + }, + { auth: true, schema: createPlaygroundSchema }, +); -// PUT /api/playground - Update existing playground -export async function PUT(req: NextRequest) { - try { - const session = await getAuthSession(); - if (!session?.user) { - return NextResponse.json({ error: 'Unauthorized, please sign in to continue.' }, { status: 401 }); - } +// --------------------------------------------------------------------------- +// PUT /api/playground +// --------------------------------------------------------------------------- - const body = await req.json(); - if (!body || !body.id) { - return NextResponse.json({ error: 'Playground ID is required.' }, { status: 400 }); - } - - const { id, name, isPublic, charts, globalStartTime, globalEndTime } = body; - - // Verify ownership - const existing = await prisma.statsPlayground.findFirst({ - where: { - id, - user_id: session.user.id - } - }); +export const PUT = withApi>( + async (_req, { session, body }) => { + const existing = await assertOwnership(prisma.statsPlayground, body.id, session.user.id); - if (!existing) { - return NextResponse.json({ error: 'Playground not found or unauthorized' }, { status: 404 }); - } + const updateData: Record = {}; + if (body.name !== undefined) updateData.name = body.name; + if (body.isPublic !== undefined) updateData.is_public = body.isPublic; - const updateData: any = {}; - if (name !== undefined) updateData.name = name; - if (isPublic !== undefined) updateData.is_public = isPublic; - if (charts !== undefined || globalStartTime !== undefined || globalEndTime !== undefined) { - // Handle both old format (array) and new format (object) + if (body.charts !== undefined || body.globalStartTime !== undefined || body.globalEndTime !== undefined) { const existingCharts = existing.charts as any; - const chartsArray = Array.isArray(existingCharts) ? existingCharts : (existingCharts?.charts || []); - + const chartsArray = Array.isArray(existingCharts) ? existingCharts : existingCharts?.charts || []; + updateData.charts = { - globalStartTime: globalStartTime !== undefined ? (globalStartTime || null) : (existingCharts?.globalStartTime || null), - globalEndTime: globalEndTime !== undefined ? (globalEndTime || null) : (existingCharts?.globalEndTime || null), - charts: charts !== undefined ? charts : chartsArray + globalStartTime: + body.globalStartTime !== undefined ? body.globalStartTime || null : existingCharts?.globalStartTime || null, + globalEndTime: + body.globalEndTime !== undefined ? body.globalEndTime || null : existingCharts?.globalEndTime || null, + charts: body.charts !== undefined ? body.charts : chartsArray, } as any; } const playground = await prisma.statsPlayground.update({ - where: { id }, - data: updateData + where: { id: body.id }, + data: updateData, }); - return NextResponse.json(playground); - } catch (error) { - console.error('Error updating playground:', error); - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); - } -} + return successResponse(playground); + }, + { auth: true, schema: updatePlaygroundSchema }, +); -// DELETE /api/playground - Delete playground -export async function DELETE(req: NextRequest) { - try { - const session = await getAuthSession(); - if (!session?.user) { - return NextResponse.json({ error: 'Unauthorized, please sign in to continue.' }, { status: 401 }); - } - - const { searchParams } = new URL(req.url); - const id = searchParams.get('id'); +// --------------------------------------------------------------------------- +// DELETE /api/playground +// --------------------------------------------------------------------------- +export const DELETE = withApi( + async (req: NextRequest, { session }) => { + const id = req.nextUrl.searchParams.get('id'); if (!id) { - return NextResponse.json({ error: 'Playground ID is required.' }, { status: 400 }); + throw new BadRequestError('Playground ID is required'); } - // Verify ownership - const existing = await prisma.statsPlayground.findFirst({ - where: { - id, - user_id: session.user.id - } - }); + await assertOwnership(prisma.statsPlayground, id, session.user.id); - if (!existing) { - return NextResponse.json({ error: 'Playground not found or unauthorized' }, { status: 404 }); - } - - await prisma.statsPlayground.delete({ - where: { id } - }); - - return NextResponse.json({ success: true }); - } catch (error) { - console.error('Error deleting playground:', error); - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); - } -} + await prisma.statsPlayground.delete({ where: { id } }); + return noContentResponse(); + }, + { auth: true }, +); diff --git a/app/api/primary-network-stats/route.ts b/app/api/primary-network-stats/route.ts index a0d9799bff4..092a359e3c5 100644 --- a/app/api/primary-network-stats/route.ts +++ b/app/api/primary-network-stats/route.ts @@ -1,12 +1,19 @@ import { NextResponse } from 'next/server'; -import { Avalanche } from "@avalanche-sdk/chainkit"; -import { TimeSeriesDataPoint, TimeSeriesMetric, STATS_CONFIG, getTimestampsFromTimeRange, createTimeSeriesMetric } from "@/types/stats"; +import { withApi } from '@/lib/api'; +import { Avalanche } from '@avalanche-sdk/chainkit'; +import { + TimeSeriesDataPoint, + TimeSeriesMetric, + STATS_CONFIG, + getTimestampsFromTimeRange, + createTimeSeriesMetric, +} from '@/types/stats'; export const dynamic = 'force-dynamic'; const CACHE_CONTROL_HEADER = 'public, max-age=14400, s-maxage=14400, stale-while-revalidate=86400'; const REQUEST_TIMEOUT_MS = 10000; -const avalanche = new Avalanche({ network: "mainnet" }); +const avalanche = new Avalanche({ network: 'mainnet' }); interface PrimaryNetworkMetrics { validator_count: TimeSeriesMetric; @@ -20,7 +27,11 @@ interface PrimaryNetworkMetrics { } // Timeout wrapper for fetch requests -async function fetchWithTimeout(url: string, options: RequestInit = {}, timeoutMs = REQUEST_TIMEOUT_MS): Promise { +async function fetchWithTimeout( + url: string, + options: RequestInit = {}, + timeoutMs = REQUEST_TIMEOUT_MS, +): Promise { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), timeoutMs); try { @@ -38,10 +49,10 @@ const revalidatingKeys = new Set(); const pendingRequests = new Map>(); async function getTimeSeriesData( - metricType: string, - timeRange: string, - pageSize: number = 365, - fetchAllPages: boolean = false + metricType: string, + timeRange: string, + pageSize: number = 365, + fetchAllPages: boolean = false, ): Promise { try { const { startTimestamp, endTimestamp } = getTimestampsFromTimeRange(timeRange); @@ -56,7 +67,7 @@ async function getTimeSeriesData( }; if (rlToken) params.rltoken = rlToken; - + const result = await avalanche.metrics.networks.getStakingMetrics(params); for await (const page of result) { @@ -72,10 +83,10 @@ async function getTimeSeriesData( .map((result: any) => ({ timestamp: result.timestamp, value: result.value || 0, - date: new Date(result.timestamp * 1000).toISOString().split('T')[0] + date: new Date(result.timestamp * 1000).toISOString().split('T')[0], })); - } catch (error) { - console.warn(`[getTimeSeriesData] Failed for ${metricType}:`, error); + } catch { + // Swallow per-metric failures; caller handles empty arrays return []; } } @@ -83,9 +94,8 @@ async function getTimeSeriesData( async function fetchValidatorVersions() { try { const result = await avalanche.data.primaryNetwork.getNetworkDetails({}); - + if (!result?.validatorDetails?.stakingDistributionByVersion) { - console.warn('[fetchValidatorVersions] No stakingDistributionByVersion found'); return {}; } @@ -94,20 +104,21 @@ async function fetchValidatorVersions() { if (item.version && item.validatorCount) { versionData[item.version] = { validatorCount: item.validatorCount, - amountStaked: item.amountStaked + amountStaked: item.amountStaked, }; } }); return versionData; - } catch (error) { - console.error('[fetchValidatorVersions] Error:', error); + } catch { + // Version data is best-effort; return empty on failure return {}; } } // Metabase endpoint URL for reward distribution (returns both daily and cumulative) -const REWARDS_URL = 'https://ava-labs-inc.metabaseapp.com/api/public/dashboard/3e895234-4c31-40f7-a3ee-4656f6caf535/dashcard/6788/card/5464?parameters=%5B%7B%22type%22%3A%22string%2F%3D%22%2C%22value%22%3Anull%2C%22id%22%3A%22b87e50a4%22%2C%22target%22%3A%5B%22variable%22%2C%5B%22template-tag%22%2C%22address%22%5D%5D%7D%2C%7B%22type%22%3A%22string%2F%3D%22%2C%22value%22%3Anull%2C%22id%22%3A%2242440d5%22%2C%22target%22%3A%5B%22variable%22%2C%5B%22template-tag%22%2C%22Node_ID%22%5D%5D%7D%2C%7B%22type%22%3A%22string%2F%3D%22%2C%22value%22%3Anull%2C%22id%22%3A%22ccdf28e0%22%2C%22target%22%3A%5B%22dimension%22%2C%5B%22template-tag%22%2C%22Reward_Type%22%5D%2C%7B%22stage-number%22%3A0%7D%5D%7D%5D'; +const REWARDS_URL = + 'https://ava-labs-inc.metabaseapp.com/api/public/dashboard/3e895234-4c31-40f7-a3ee-4656f6caf535/dashcard/6788/card/5464?parameters=%5B%7B%22type%22%3A%22string%2F%3D%22%2C%22value%22%3Anull%2C%22id%22%3A%22b87e50a4%22%2C%22target%22%3A%5B%22variable%22%2C%5B%22template-tag%22%2C%22address%22%5D%5D%7D%2C%7B%22type%22%3A%22string%2F%3D%22%2C%22value%22%3Anull%2C%22id%22%3A%2242440d5%22%2C%22target%22%3A%5B%22variable%22%2C%5B%22template-tag%22%2C%22Node_ID%22%5D%5D%7D%2C%7B%22type%22%3A%22string%2F%3D%22%2C%22value%22%3Anull%2C%22id%22%3A%22ccdf28e0%22%2C%22target%22%3A%5B%22dimension%22%2C%5B%22template-tag%22%2C%22Reward_Type%22%5D%2C%7B%22stage-number%22%3A0%7D%5D%7D%5D'; interface RewardsData { daily: TimeSeriesDataPoint[]; @@ -117,18 +128,18 @@ interface RewardsData { async function fetchRewardsData(): Promise { try { const response = await fetchWithTimeout(REWARDS_URL, { - headers: { 'Accept': 'application/json' } + headers: { Accept: 'application/json' }, }); if (!response.ok) { - console.warn(`[fetchRewardsData] Failed to fetch: ${response.status}`); + // Rewards fetch failed; return empty return { daily: [], cumulative: [] }; } const data = await response.json(); - + if (!data?.data?.rows || !Array.isArray(data.data.rows)) { - console.warn('[fetchRewardsData] Invalid data format'); + // Invalid Metabase format; return empty return { daily: [], cumulative: [] }; } @@ -156,7 +167,7 @@ async function fetchRewardsData(): Promise { return { daily, cumulative }; } catch (error) { if (error instanceof Error && error.name !== 'AbortError') { - console.warn('[fetchRewardsData] Error:', error); + // Rewards data is best-effort } return { daily: [], cumulative: [] }; } @@ -164,7 +175,8 @@ async function fetchRewardsData(): Promise { async function fetchFreshDataInternal(timeRange: string): Promise { try { - const config = STATS_CONFIG.TIME_RANGES[timeRange as keyof typeof STATS_CONFIG.TIME_RANGES] || STATS_CONFIG.TIME_RANGES['30d']; + const config = + STATS_CONFIG.TIME_RANGES[timeRange as keyof typeof STATS_CONFIG.TIME_RANGES] || STATS_CONFIG.TIME_RANGES['30d']; const { pageSize, fetchAllPages } = config; const [ @@ -173,14 +185,14 @@ async function fetchFreshDataInternal(timeRange: string): Promise = { - 'Cache-Control': CACHE_CONTROL_HEADER, - 'X-Data-Source': meta.source + const headers: Record = { + 'Cache-Control': CACHE_CONTROL_HEADER, + 'X-Data-Source': meta.source, }; if (meta.timeRange) headers['X-Time-Range'] = meta.timeRange; if (meta.cacheAge !== undefined) headers['X-Cache-Age'] = `${Math.round(meta.cacheAge / 1000)}s`; @@ -216,108 +228,83 @@ function createResponse( return NextResponse.json(data, { status, headers }); } -export async function GET(request: Request) { - try { - const { searchParams } = new URL(request.url); - const timeRange = searchParams.get('timeRange') || '30d'; - - if (searchParams.get('clearCache') === 'true') { - cachedData.clear(); - revalidatingKeys.clear(); - } - - const cached = cachedData.get(timeRange); - const cacheAge = cached ? Date.now() - cached.timestamp : Infinity; - const isCacheValid = cacheAge < STATS_CONFIG.CACHE.SHORT_DURATION; - const isCacheStale = cached && !isCacheValid; - - // Stale-while-revalidate: serve stale data immediately, refresh in background - if (isCacheStale && !revalidatingKeys.has(timeRange)) { - revalidatingKeys.add(timeRange); - - // Background refresh - (async () => { - try { - const freshData = await fetchFreshDataInternal(timeRange); - if (freshData) { - cachedData.set(timeRange, { data: freshData, timestamp: Date.now() }); - } - } finally { - revalidatingKeys.delete(timeRange); +export const GET = withApi(async (req) => { + const timeRange = req.nextUrl.searchParams.get('timeRange') || '30d'; + + if (req.nextUrl.searchParams.get('clearCache') === 'true') { + cachedData.clear(); + revalidatingKeys.clear(); + } + + const cached = cachedData.get(timeRange); + const cacheAge = cached ? Date.now() - cached.timestamp : Infinity; + const isCacheValid = cacheAge < STATS_CONFIG.CACHE.SHORT_DURATION; + const isCacheStale = cached && !isCacheValid; + + // Stale-while-revalidate: serve stale data immediately, refresh in background + if (isCacheStale && !revalidatingKeys.has(timeRange)) { + revalidatingKeys.add(timeRange); + + (async () => { + try { + const freshData = await fetchFreshDataInternal(timeRange); + if (freshData) { + cachedData.set(timeRange, { data: freshData, timestamp: Date.now() }); } - })(); - - console.log(`[GET /api/primary-network-stats] TimeRange: ${timeRange}, Source: stale-while-revalidate`); - return createResponse(cached.data, { - source: 'stale-while-revalidate', - timeRange, - cacheAge - }); - } - - // Return valid cache - if (isCacheValid && cached) { - console.log(`[GET /api/primary-network-stats] TimeRange: ${timeRange}, Source: cache`); - return createResponse(cached.data, { source: 'cache', timeRange, cacheAge }); - } - - // Deduplicate pending requests - const pendingKey = `primary-${timeRange}`; - let pendingPromise = pendingRequests.get(pendingKey); - - if (!pendingPromise) { - pendingPromise = fetchFreshDataInternal(timeRange); - pendingRequests.set(pendingKey, pendingPromise); - pendingPromise.finally(() => pendingRequests.delete(pendingKey)); - } - - const startTime = Date.now(); - const freshData = await pendingPromise; - - if (!freshData) { - // Fallback to any available cached data - const fallbackCached = cachedData.get('30d'); - if (fallbackCached) { - console.log(`[GET /api/primary-network-stats] TimeRange: 30d, Source: fallback-cache`); - return createResponse(fallbackCached.data, { - source: 'fallback-cache', - timeRange: '30d', - cacheAge: Date.now() - fallbackCached.timestamp - }, 206); + } finally { + revalidatingKeys.delete(timeRange); } - console.log(`[GET /api/primary-network-stats] TimeRange: ${timeRange}, Source: error (no data)`); - return createResponse({ error: 'Failed to fetch primary network stats' }, { source: 'error' }, 500); - } - - // Cache fresh data - cachedData.set(timeRange, { data: freshData, timestamp: Date.now() }); - - const fetchTime = Date.now() - startTime; - console.log(`[GET /api/primary-network-stats] TimeRange: ${timeRange}, Source: fresh, fetchTime: ${fetchTime}ms`); - - return createResponse(freshData, { - source: 'fresh', - timeRange, - fetchTime + })(); + + return createResponse(cached.data, { + source: 'stale-while-revalidate', + timeRange, + cacheAge, }); - } catch (error) { - console.error('[GET /api/primary-network-stats] Unhandled error:', error); - - // Try to return cached data on error - const { searchParams } = new URL(request.url); - const timeRange = searchParams.get('timeRange') || '30d'; - const cached = cachedData.get(timeRange); - - if (cached) { - console.log(`[GET /api/primary-network-stats] TimeRange: ${timeRange}, Source: error-fallback-cache`); - return createResponse(cached.data, { - source: 'error-fallback-cache', - timeRange, - cacheAge: Date.now() - cached.timestamp - }, 206); + } + + // Return valid cache + if (isCacheValid && cached) { + return createResponse(cached.data, { source: 'cache', timeRange, cacheAge }); + } + + // Deduplicate pending requests + const pendingKey = `primary-${timeRange}`; + let pendingPromise = pendingRequests.get(pendingKey); + + if (!pendingPromise) { + pendingPromise = fetchFreshDataInternal(timeRange); + pendingRequests.set(pendingKey, pendingPromise); + pendingPromise.finally(() => pendingRequests.delete(pendingKey)); + } + + const startTime = Date.now(); + const freshData = await pendingPromise; + + if (!freshData) { + // Fallback to any available cached data + const fallbackCached = cachedData.get('30d'); + if (fallbackCached) { + return createResponse( + fallbackCached.data, + { + source: 'fallback-cache', + timeRange: '30d', + cacheAge: Date.now() - fallbackCached.timestamp, + }, + 206, + ); } - - console.log(`[GET /api/primary-network-stats] TimeRange: ${timeRange}, Source: error (no data)`); return createResponse({ error: 'Failed to fetch primary network stats' }, { source: 'error' }, 500); } -} + + // Cache fresh data + cachedData.set(timeRange, { data: freshData, timestamp: Date.now() }); + + const fetchTime = Date.now() - startTime; + return createResponse(freshData, { + source: 'fresh', + timeRange, + fetchTime, + }); +}); diff --git a/app/api/primary-network-validators/route.ts b/app/api/primary-network-validators/route.ts index f369c294209..c224e4d1dbc 100644 --- a/app/api/primary-network-validators/route.ts +++ b/app/api/primary-network-validators/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from 'next/server'; -import { Avalanche } from "@avalanche-sdk/chainkit"; +import { withApi } from '@/lib/api'; +import { Avalanche } from '@avalanche-sdk/chainkit'; export const dynamic = 'force-dynamic'; @@ -28,36 +29,42 @@ let pendingRequest: Promise | null = null; let isRevalidating = false; async function fetchAllValidators(): Promise { - const avalanche = new Avalanche({ network: "mainnet" }); + const avalanche = new Avalanche({ network: 'mainnet' }); const validators: ValidatorData[] = []; - + const result = await avalanche.data.primaryNetwork.listValidators({ pageSize: PAGE_SIZE, - validationStatus: "active", - subnetId: "11111111111111111111111111111111LpoYY", - network: "mainnet", + validationStatus: 'active', + subnetId: '11111111111111111111111111111111LpoYY', + network: 'mainnet', }); let pageCount = 0; const maxPages = 50; - + for await (const page of result) { pageCount++; const pageData = page.result.validators || []; - if (!Array.isArray(pageData)) { continue; } - + if (!Array.isArray(pageData)) { + continue; + } + const pageValidators = pageData.map((v: any) => ({ nodeId: v.nodeId, amountStaked: v.amountStaked, delegationFee: v.delegationFee, validationStatus: v.validationStatus, delegatorCount: v.delegatorCount || 0, - amountDelegated: v.amountDelegated || "0", + amountDelegated: v.amountDelegated || '0', })); - - validators.push(...pageValidators); - if (pageCount >= maxPages) { break; } - if (pageValidators.length < PAGE_SIZE) { break; } + + validators.push(...pageValidators); + if (pageCount >= maxPages) { + break; + } + if (pageValidators.length < PAGE_SIZE) { + break; + } } return validators; } @@ -65,9 +72,7 @@ async function fetchAllValidators(): Promise { async function fetchWithTimeout(): Promise { return Promise.race([ fetchAllValidators(), - new Promise((_, reject) => - setTimeout(() => reject(new Error('Request timeout')), FETCH_TIMEOUT) - ) + new Promise((_, reject) => setTimeout(() => reject(new Error('Request timeout')), FETCH_TIMEOUT)), ]); } @@ -79,28 +84,34 @@ async function getValidators(): Promise { if (isCacheStale && cachedData && !isRevalidating) { isRevalidating = true; - + (async () => { try { const freshData = await fetchWithTimeout(); cachedData = { data: freshData, timestamp: Date.now() }; - } catch (error) { - console.error('[getValidators] Background refresh failed:', error); + } catch { + // Background refresh failed; stale data still served } finally { isRevalidating = false; } })(); - + return cachedData.data; } - if (isCacheValid && cachedData) { return cachedData.data; } + if (isCacheValid && cachedData) { + return cachedData.data; + } - if (pendingRequest) { return pendingRequest; } + if (pendingRequest) { + return pendingRequest; + } // Start new fetch pendingRequest = fetchWithTimeout(); - pendingRequest.finally(() => { pendingRequest = null; }); + pendingRequest.finally(() => { + pendingRequest = null; + }); const freshData = await pendingRequest; cachedData = { data: freshData, timestamp: Date.now() }; @@ -110,7 +121,7 @@ async function getValidators(): Promise { function createResponse( data: { validators: ValidatorData[]; totalCount: number; network: string } | { error: string }, meta: { source: string; cacheAge?: number; fetchTime?: number }, - status = 200 + status = 200, ) { const headers: Record = { 'Cache-Control': CACHE_CONTROL_HEADER, @@ -118,55 +129,30 @@ function createResponse( }; if (meta.cacheAge !== undefined) headers['X-Cache-Age'] = `${Math.round(meta.cacheAge / 1000)}s`; if (meta.fetchTime !== undefined) headers['X-Fetch-Time'] = `${meta.fetchTime}ms`; - - return NextResponse.json(data, { status, headers }); -} -export async function GET(_request: Request) { - try { - const startTime = Date.now(); - const cacheAge = cachedData ? Date.now() - cachedData.timestamp : undefined; - - const validators = await getValidators(); - const fetchTime = Date.now() - startTime; - - // Determine data source based on response time - const source = fetchTime < 50 && cachedData ? (cacheAge && cacheAge < CACHE_DURATION ? 'cache' : 'stale-while-revalidate') : 'fresh'; - - console.log(`[GET /api/primary-network-validators] Source: ${source}, fetchTime: ${fetchTime}ms`); - - return createResponse( - { - validators, - totalCount: validators.length, - network: 'mainnet', - }, - { source, cacheAge, fetchTime } - ); - } catch (error: any) { - console.error('[GET /api/primary-network-validators] Error:', error); - - if (cachedData && (Date.now() - cachedData.timestamp) < STALE_DURATION) { - console.log(`[GET /api/primary-network-validators] Source: error-fallback-cache`); - return createResponse( - { - validators: cachedData.data, - totalCount: cachedData.data.length, - network: 'mainnet', - }, - { - source: 'error-fallback-cache', - cacheAge: Date.now() - cachedData.timestamp - }, - 206 - ); - } - - return createResponse( - { error: error?.message || 'Failed to fetch validators' }, - { source: 'error' }, - 500 - ); - } + return NextResponse.json(data, { status, headers }); } +export const GET = withApi(async () => { + const startTime = Date.now(); + const cacheAge = cachedData ? Date.now() - cachedData.timestamp : undefined; + + const validators = await getValidators(); + const fetchTime = Date.now() - startTime; + + const source = + fetchTime < 50 && cachedData + ? cacheAge && cacheAge < CACHE_DURATION + ? 'cache' + : 'stale-while-revalidate' + : 'fresh'; + + return createResponse( + { + validators, + totalCount: validators.length, + network: 'mainnet', + }, + { source, cacheAge, fetchTime }, + ); +}); diff --git a/app/api/profile/[id]/route.ts b/app/api/profile/[id]/route.ts index cb7fc26cc35..2ccbd20f555 100644 --- a/app/api/profile/[id]/route.ts +++ b/app/api/profile/[id]/route.ts @@ -1,76 +1,43 @@ -import { NextRequest, NextResponse } from 'next/server'; +// schema: not applicable — partial profile update with dynamic fields +import type { NextRequest } from 'next/server'; +import { withApi } from '@/lib/api/with-api'; +import { successResponse } from '@/lib/api/response'; +import { BadRequestError, ForbiddenError } from '@/lib/api/errors'; import { getProfile, updateProfile } from '@/server/services/profile'; -import { Profile } from '@/types/profile'; -import { withAuth } from '@/lib/protectedRoute'; +import type { Profile } from '@/types/profile'; -export const GET = withAuth(async ( - req: NextRequest, - { params }: { params: Promise<{ id: string }> }, - session: any -) => { - try { - const id = (await params).id; +export const GET = withApi( + async (_req: NextRequest, { session, params }) => { + const id = params.id; if (!id) { - return NextResponse.json( - { error: 'Id parameter is required.' }, - { status: 400 } - ); + throw new BadRequestError('Id parameter is required'); } - // Check if user is trying to access their own profile - const isOwnProfile = session.user.id === id; - if (!isOwnProfile) { - return NextResponse.json( - { error: 'Forbidden' }, - { status: 403 } - ); + if (session.user.id !== id) { + throw new ForbiddenError('You can only access your own profile'); } + const profile = await getProfile(id); - return NextResponse.json(profile); - } catch (error) { - console.error('Error in GET /api/profile/[id]', error); - return NextResponse.json( - { error: `: ${error}` }, - { status: 400 } - ); - } -}); + return successResponse(profile); + }, + { auth: true }, +); -export const PUT = withAuth(async ( - req: NextRequest, - { params }: { params: Promise<{ id: string }> }, - session: any -) => { - try { - const id = (await params).id; +export const PUT = withApi( + async (req: NextRequest, { session, params }) => { + const id = params.id; if (!id) { - return NextResponse.json( - { error: 'Id parameter is required.' }, - { status: 400 } - ); + throw new BadRequestError('Id parameter is required'); } - // Use strict equality check if (session.user.id !== id) { - return NextResponse.json( - { error: 'Forbidden: You can only update your own profile.' }, - { status: 403 } - ); + throw new ForbiddenError('You can only update your own profile'); } const newProfileData = (await req.json()) as Partial; + const updatedProfile = await updateProfile(id, newProfileData); - const updatedProfile = await updateProfile( - id, - newProfileData - ); - - return NextResponse.json(updatedProfile); - } catch (error) { - console.error('Error in PUT /api/profile/[id]:', error); - return NextResponse.json( - { error: `Internal Server Error: ${error}` }, - { status: 500 } - ); - } -}); + return successResponse(updatedProfile); + }, + { auth: true }, +); diff --git a/app/api/profile/extended/[id]/route.ts b/app/api/profile/extended/[id]/route.ts index 5180e413854..c734d90a226 100644 --- a/app/api/profile/extended/[id]/route.ts +++ b/app/api/profile/extended/[id]/route.ts @@ -1,115 +1,58 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { - getExtendedProfile, +// schema: not applicable — partial extended profile update with dynamic fields +import type { NextRequest } from 'next/server'; +import { withApi } from '@/lib/api/with-api'; +import { successResponse } from '@/lib/api/response'; +import { BadRequestError, ForbiddenError, NotFoundError } from '@/lib/api/errors'; +import { + getExtendedProfile, updateExtendedProfile, - ProfileValidationError + ProfileValidationError, } from '@/server/services/profile/profile.service'; -import { UpdateExtendedProfileData } from '@/types/extended-profile'; -import { withAuth } from '@/lib/protectedRoute'; +import type { UpdateExtendedProfileData } from '@/types/extended-profile'; -/** - * GET /api/profile/extended/[id] - * Gets the extended profile of a user - */ -export const GET = withAuth(async ( - req: NextRequest, - { params }: { params: Promise<{ id: string }> }, - session: any -) => { - try { - const id = (await params).id; - +export const GET = withApi( + async (_req: NextRequest, { session, params }) => { + const id = params.id; if (!id) { - return NextResponse.json( - { error: 'User ID is required.' }, - { status: 400 } - ); + throw new BadRequestError('User ID is required'); } - // Verify that the user can only access their own profile - // Comment this validation if you want to allow viewing other users' profiles - const isOwnProfile = session.user.id === id; - if (!isOwnProfile) { - return NextResponse.json( - { error: 'Forbidden: You can only access your own profile.' }, - { status: 403 } - ); + if (session.user.id !== id) { + throw new ForbiddenError('You can only access your own profile'); } const profile = await getExtendedProfile(id); - if (!profile) { - return NextResponse.json( - { error: 'Profile not found.' }, - { status: 404 } - ); + throw new NotFoundError('Profile'); } - return NextResponse.json(profile); - } catch (error) { - console.error('Error in GET /api/profile/extended/[id]:', error); - return NextResponse.json( - { - error: 'Internal Server Error', - details: error instanceof Error ? error.message : 'Unknown error' - }, - { status: 500 } - ); - } -}); + return successResponse(profile); + }, + { auth: true }, +); -/** - * PUT /api/profile/extended/[id] - * update extended profile - */ -export const PUT = withAuth(async ( - req: NextRequest, - { params }: { params: Promise<{ id: string }> }, - session: any -) => { - try { - const id = (await params).id; - +export const PUT = withApi( + async (req: NextRequest, { session, params }) => { + const id = params.id; if (!id) { - return NextResponse.json( - { error: 'User ID is required.' }, - { status: 400 } - ); + throw new BadRequestError('User ID is required'); } - // verify that the user can only update their own profile if (session.user.id !== id) { - return NextResponse.json( - { error: 'Forbidden: You can only update your own profile.' }, - { status: 403 } - ); + throw new ForbiddenError('You can only update your own profile'); } const newProfileData = (await req.json()) as UpdateExtendedProfileData; - // The service now handles all business validations - const updatedProfile = await updateExtendedProfile(id, newProfileData); - - return NextResponse.json(updatedProfile); - } catch (error) { - console.error('Error in PUT /api/profile/extended/[id]:', error); - - // Handle validation errors with the appropriate status code - if (error instanceof ProfileValidationError) { - return NextResponse.json( - { error: error.message }, - { status: error.statusCode } - ); + try { + const updatedProfile = await updateExtendedProfile(id, newProfileData); + return successResponse(updatedProfile); + } catch (error) { + if (error instanceof ProfileValidationError) { + throw new BadRequestError(error.message); + } + throw error; } - - // Handle other errors - return NextResponse.json( - { - error: 'Internal Server Error', - details: error instanceof Error ? error.message : 'Unknown error' - }, - { status: 500 } - ); - } -}); - + }, + { auth: true }, +); diff --git a/app/api/profile/popular-skills/route.ts b/app/api/profile/popular-skills/route.ts index 246f004a48c..23d1a1de537 100644 --- a/app/api/profile/popular-skills/route.ts +++ b/app/api/profile/popular-skills/route.ts @@ -1,21 +1,22 @@ -import { NextRequest, NextResponse } from 'next/server'; +import type { NextRequest } from 'next/server'; +import { NextResponse } from 'next/server'; +import { withApi } from '@/lib/api/with-api'; import { getPopularSkills } from '@/server/services/profile/profile.service'; -import { withAuth } from '@/lib/protectedRoute'; - - -export const GET = withAuth(async (req: NextRequest, session: any) => { - try { - const popularSkills = await getPopularSkills(); - return NextResponse.json(popularSkills, { - status: 200, headers: { - 'Cache-Control': 'no-store, no-cache, must-revalidate, proxy-revalidate', - 'Pragma': 'no-cache', - 'Expires': '0' - } - }); - } catch (error) { - console.error('Error in GET /api/profile/popular-skills:', error); - return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 }); - } -}); +export const GET = withApi( + async (_req: NextRequest) => { + const popularSkills = await getPopularSkills(); + return NextResponse.json( + { success: true, data: popularSkills }, + { + status: 200, + headers: { + 'Cache-Control': 'no-store, no-cache, must-revalidate, proxy-revalidate', + Pragma: 'no-cache', + Expires: '0', + }, + }, + ); + }, + { auth: true }, +); diff --git a/app/api/profile/reward-board/route.ts b/app/api/profile/reward-board/route.ts index d6ab5a6dad7..874d4bb2b60 100644 --- a/app/api/profile/reward-board/route.ts +++ b/app/api/profile/reward-board/route.ts @@ -1,25 +1,16 @@ -import { withAuth } from "@/lib/protectedRoute"; -import { getRewardBoard } from "@/server/services/rewardBoard"; -import { NextResponse } from "next/server"; +import { z } from 'zod'; +import { withApi, successResponse, validateQuery } from '@/lib/api'; +import { getRewardBoard } from '@/server/services/rewardBoard'; -export const GET = withAuth(async (request, context, session) => { - const { searchParams } = new URL(request.url); - const user_id = searchParams.get("user_id"); - if (!user_id) { - return NextResponse.json( - { error: "user_id parameter is required" }, - { status: 400 } - ); - } +const querySchema = z.object({ + user_id: z.string().min(1, 'user_id is required'), +}); - try { +export const GET = withApi( + async (req) => { + const { user_id } = validateQuery(req, querySchema); const badges = await getRewardBoard(user_id); - return NextResponse.json(badges, { status: 200 }); - } catch (error) { - console.error("Error getting reward board:", error); - return NextResponse.json( - { error: "Internal server error" }, - { status: 500 } - ); - } -}); + return successResponse(badges); + }, + { auth: true }, +); diff --git a/app/api/project/[project_id]/members/route.ts b/app/api/project/[project_id]/members/route.ts index c93f70cbe49..a3284078739 100644 --- a/app/api/project/[project_id]/members/route.ts +++ b/app/api/project/[project_id]/members/route.ts @@ -1,42 +1,30 @@ -import { withAuth } from "@/lib/protectedRoute"; -import { prisma } from "@/prisma/prisma"; -import { isUserProjectMember } from "@/server/services/fileValidation"; -import { - GetMembersByProjectId, - UpdateRoleMember, -} from "@/server/services/memberProject"; +// withApi: not applicable — uses withAuth() for session-based auth +import { withAuth } from '@/lib/protectedRoute'; +import { isUserProjectMember } from '@/server/services/fileValidation'; +import { GetMembersByProjectId, UpdateRoleMember } from '@/server/services/memberProject'; -import { NextResponse } from "next/server"; +import { NextResponse } from 'next/server'; export const GET = withAuth(async (request, context: any, session: any) => { try { const { project_id } = await context.params; if (!project_id) { - return NextResponse.json( - { error: "project_id is required" }, - { status: 400 } - ); + return NextResponse.json({ error: 'project_id is required' }, { status: 400 }); } // Check if user is a member of the project const isMember = await isUserProjectMember(session.user.id, project_id); if (!isMember) { - return NextResponse.json( - { error: "Forbidden: You are not a member of this project" }, - { status: 403 } - ); + return NextResponse.json({ error: 'Forbidden: You are not a member of this project' }, { status: 403 }); } const members = await GetMembersByProjectId(project_id); return NextResponse.json(members ?? []); } catch (error: any) { - console.error("Error getting members:", error); - console.error("Error POST /api/[project_id]/members:", error.message); + console.error('Error getting members:', error); + console.error('Error POST /api/[project_id]/members:', error.message); const wrappedError = error as Error; - return NextResponse.json( - { error: wrappedError }, - { status: wrappedError.cause == "ValidationError" ? 400 : 500 } - ); + return NextResponse.json({ error: wrappedError }, { status: wrappedError.cause == 'ValidationError' ? 400 : 500 }); } }); @@ -45,40 +33,31 @@ export const PATCH = withAuth(async (request: Request, context: any, session: an const body = await request.json(); const { member_id, role } = body; const { project_id } = await context.params; - - console.log("body", member_id); + + console.warn('body', member_id); if (!member_id || !role) { - return NextResponse.json( - { error: "member_id and role are required" }, - { status: 400 } - ); + return NextResponse.json({ error: 'member_id and role are required' }, { status: 400 }); } if (!project_id) { - return NextResponse.json( - { error: "project_id is required" }, - { status: 400 } - ); + return NextResponse.json({ error: 'project_id is required' }, { status: 400 }); } // Check if user is a member of the project const isMember = await isUserProjectMember(session.user.id, project_id); if (!isMember) { - return NextResponse.json( - { error: "Forbidden: You are not a member of this project" }, - { status: 403 } - ); + return NextResponse.json({ error: 'Forbidden: You are not a member of this project' }, { status: 403 }); } const updatedMember = await UpdateRoleMember(member_id, role); return NextResponse.json(updatedMember); } catch (error: any) { - console.error("Error updating member role:", error); + console.error('Error updating member role:', error); const wrappedError = error as Error; return NextResponse.json( - { error: wrappedError.message || "Internal server error" }, - { status: wrappedError.cause === "ValidationError" ? 400 : 500 } + { error: wrappedError.message || 'Internal server error' }, + { status: wrappedError.cause === 'ValidationError' ? 400 : 500 }, ); } }); diff --git a/app/api/project/[project_id]/members/status/route.ts b/app/api/project/[project_id]/members/status/route.ts index 568e6ea484b..262b0372764 100644 --- a/app/api/project/[project_id]/members/status/route.ts +++ b/app/api/project/[project_id]/members/status/route.ts @@ -1,46 +1,44 @@ -import { withAuth } from "@/lib/protectedRoute"; -import { UpdateStatusMember } from "@/server/services/memberProject"; -import { isUserProjectMember } from "@/server/services/fileValidation"; -import { NextResponse } from "next/server"; +// withApi: not applicable — uses withAuth() for session-based auth +import { withAuth } from '@/lib/protectedRoute'; +import { UpdateStatusMember } from '@/server/services/memberProject'; +import { isUserProjectMember } from '@/server/services/fileValidation'; +import { NextResponse } from 'next/server'; export const PATCH = withAuth(async (request: Request, context: any, session: any) => { try { const body = await request.json(); - const { user_id, status, email, wasInOtherProject } = body; + const { user_id, status, email: _email, wasInOtherProject } = body; const { project_id } = await context.params; if (!project_id) { - return NextResponse.json( - { error: "project_id is required" }, - { status: 400 } - ); + return NextResponse.json({ error: 'project_id is required' }, { status: 400 }); } // Check if user is a member of the project const isMember = await isUserProjectMember(session.user.id, project_id); if (!isMember) { - return NextResponse.json( - { error: "Forbidden: You are not a member of this project" }, - { status: 403 } - ); + return NextResponse.json({ error: 'Forbidden: You are not a member of this project' }, { status: 403 }); } // Verify that user_id matches session user (users can only update their own status) - if (user_id !== null && user_id !== undefined && user_id !== "" && user_id !== session.user.id) { - return NextResponse.json( - { error: "Forbidden: You can only update your own member status" }, - { status: 403 } - ); + if (user_id !== null && user_id !== undefined && user_id !== '' && user_id !== session.user.id) { + return NextResponse.json({ error: 'Forbidden: You can only update your own member status' }, { status: 403 }); } - const updatedMember = await UpdateStatusMember(session.user.id, project_id, status, session.user.email || "", wasInOtherProject); + const updatedMember = await UpdateStatusMember( + session.user.id, + project_id, + status, + session.user.email || '', + wasInOtherProject, + ); return NextResponse.json(updatedMember); } catch (error: any) { console.error('Error updating member status:', error); const wrappedError = error as Error; return NextResponse.json( - { error: wrappedError.message || "Internal server error" }, - { status: wrappedError.cause === 'ValidationError' ? 400 : 500 } + { error: wrappedError.message || 'Internal server error' }, + { status: wrappedError.cause === 'ValidationError' ? 400 : 500 }, ); } -}); \ No newline at end of file +}); diff --git a/app/api/project/check-invitation/route.ts b/app/api/project/check-invitation/route.ts index 5ea322fe155..6dde342dfd3 100644 --- a/app/api/project/check-invitation/route.ts +++ b/app/api/project/check-invitation/route.ts @@ -1,25 +1,20 @@ -import { withAuth } from "@/lib/protectedRoute"; -import { NextResponse } from "next/server"; -import { CheckInvitation } from "@/server/services/projects"; +// withApi: not applicable — uses withAuth() for session-based auth +import { withAuth } from '@/lib/protectedRoute'; +import { NextResponse } from 'next/server'; +import { CheckInvitation } from '@/server/services/projects'; export const GET = withAuth(async (request, context, session) => { const { searchParams } = new URL(request.url); - const invitationId = searchParams.get("invitation"); - const user_id = searchParams.get("user_id"); + const invitationId = searchParams.get('invitation'); + const user_id = searchParams.get('user_id'); if (!invitationId) { - return NextResponse.json( - { error: "invitationId parameter is required" }, - { status: 400 } - ); + return NextResponse.json({ error: 'invitationId parameter is required' }, { status: 400 }); } // Verify that user_id matches the authenticated session user - if (user_id !== null && user_id !== undefined && user_id !== "" && user_id !== session.user.id) { - return NextResponse.json( - { error: "Forbidden: You can only check invitations for yourself" }, - { status: 403 } - ); + if (user_id !== null && user_id !== undefined && user_id !== '' && user_id !== session.user.id) { + return NextResponse.json({ error: 'Forbidden: You can only check invitations for yourself' }, { status: 403 }); } try { @@ -27,10 +22,7 @@ export const GET = withAuth(async (request, context, session) => { const member = await CheckInvitation(invitationId, sessionUserId); return NextResponse.json(member, { status: 200 }); } catch (error) { - console.error("Error checking invitation:", error); - return NextResponse.json( - { error: "Internal server error" }, - { status: 500 } - ); + console.error('Error checking invitation:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); } }); diff --git a/app/api/project/invite-member/route.ts b/app/api/project/invite-member/route.ts index d99548889b4..47a4c340e4a 100644 --- a/app/api/project/invite-member/route.ts +++ b/app/api/project/invite-member/route.ts @@ -1,18 +1,24 @@ -import { withAuth } from "@/lib/protectedRoute"; -import { generateInvitation } from "@/server/services/inviteProjectMember"; -import { isUserProjectMember } from "@/server/services/fileValidation"; -import { NextResponse } from "next/server"; -import { normalizeEventsLang } from "@/lib/events/i18n"; +// withApi: not applicable — uses withAuth() for session-based auth +import { withAuth } from '@/lib/protectedRoute'; +import { generateInvitation } from '@/server/services/inviteProjectMember'; +import { isUserProjectMember } from '@/server/services/fileValidation'; +import { NextResponse } from 'next/server'; +import { normalizeEventsLang } from '@/lib/events/i18n'; export const POST = withAuth(async (request, context, session) => { try { const body = await request.json(); - + // Verify user_id matches session - if (body.user_id !== null && body.user_id !== undefined && body.user_id !== "" && body.user_id !== session.user.id) { + if ( + body.user_id !== null && + body.user_id !== undefined && + body.user_id !== '' && + body.user_id !== session.user.id + ) { return NextResponse.json( - { error: "Forbidden: You can only invite members on behalf of yourself" }, - { status: 403 } + { error: 'Forbidden: You can only invite members on behalf of yourself' }, + { status: 403 }, ); } @@ -21,8 +27,8 @@ export const POST = withAuth(async (request, context, session) => { const isMember = await isUserProjectMember(session.user.id, body.project_id); if (!isMember) { return NextResponse.json( - { error: "Forbidden: You must be a member of the project to invite others" }, - { status: 403 } + { error: 'Forbidden: You must be a member of the project to invite others' }, + { status: 403 }, ); } } @@ -35,19 +41,13 @@ export const POST = withAuth(async (request, context, session) => { body.emails, body.project_id, body.stage, - lang - ); - return NextResponse.json( - { message: "invitation sent", result }, - { status: 200 } + lang, ); + return NextResponse.json({ message: 'invitation sent', result }, { status: 200 }); } catch (error: any) { - console.error("Error inviting members:", error); - console.error("Error POST /api/submit-project:", error.message); + console.error('Error inviting members:', error); + console.error('Error POST /api/submit-project:', error.message); const wrappedError = error as Error; - return NextResponse.json( - { error: wrappedError }, - { status: wrappedError.cause == "ValidationError" ? 400 : 500 } - ); + return NextResponse.json({ error: wrappedError }, { status: wrappedError.cause == 'ValidationError' ? 400 : 500 }); } }); diff --git a/app/api/project/route.ts b/app/api/project/route.ts index d5877668569..7c41851bb17 100644 --- a/app/api/project/route.ts +++ b/app/api/project/route.ts @@ -1,43 +1,33 @@ +// withApi: not applicable — uses withAuth() for session-based auth import { withAuth } from '@/lib/protectedRoute'; -import { prisma } from '@/prisma/prisma'; import { GetProjectByHackathonAndUser } from '@/server/services/projects'; import { createProject } from '@/server/services/submitProject'; -import { NextResponse } from 'next/server'; +import { NextResponse } from 'next/server'; -export const POST = withAuth(async (request,context ,session) => { - try{ +export const POST = withAuth(async (request, context, session) => { + try { const body = await request.json(); const newProject = await createProject({ ...body, submittedBy: session.user.email }); - + return NextResponse.json({ message: 'project created', project: newProject }, { status: 201 }); - } - catch (error: any) { + } catch (error: any) { console.error('Error saving project:', error); console.error('Error POST /api/submit-project:', error.message); const wrappedError = error as Error; - return NextResponse.json( - { error: wrappedError }, - { status: wrappedError.cause == 'ValidationError' ? 400 : 500 } - ); + return NextResponse.json({ error: wrappedError }, { status: wrappedError.cause == 'ValidationError' ? 400 : 500 }); } - }); - - export const GET = withAuth(async (request: Request, context, session) => { try { const { searchParams } = new URL(request.url); - const hackaton_id = searchParams.get("hackathon_id") ?? ""; - const user_id = searchParams.get("user_id"); - const invitation_id = searchParams.get("invitation_id") ?? ""; + const hackaton_id = searchParams.get('hackathon_id') ?? ''; + const user_id = searchParams.get('user_id'); + const invitation_id = searchParams.get('invitation_id') ?? ''; // Check if user_id matches the authenticated session user - if (user_id !== null && user_id !== undefined && user_id !== "" && user_id !== session.user.id) { - return NextResponse.json( - { error: "Forbidden: You can only access your own projects" }, - { status: 403 } - ); + if (user_id !== null && user_id !== undefined && user_id !== '' && user_id !== session.user.id) { + return NextResponse.json({ error: 'Forbidden: You can only access your own projects' }, { status: 403 }); } // Always use session user ID for security @@ -48,11 +38,11 @@ export const GET = withAuth(async (request: Request, context, session) => { // Return null project if none found - this is valid for new project creation return NextResponse.json({ project: project || null }); } catch (error: any) { - console.error("Error GET /api/project:", error); + console.error('Error GET /api/project:', error); const wrappedError = error as Error; return NextResponse.json( { error: wrappedError.message }, - { status: wrappedError.cause === "ValidationError" ? 400 : 500 } + { status: wrappedError.cause === 'ValidationError' ? 400 : 500 }, ); } }); diff --git a/app/api/project/set-winner/route.ts b/app/api/project/set-winner/route.ts index af9f53d3160..9459fff4c53 100644 --- a/app/api/project/set-winner/route.ts +++ b/app/api/project/set-winner/route.ts @@ -1,34 +1,26 @@ -import { getAuthSession } from "@/lib/auth/authSession"; -import { withAuthRole } from "@/lib/protectedRoute"; -import { SetWinner } from "@/server/services/set-project-winner"; -import { NextRequest, NextResponse } from "next/server"; +// withApi: not applicable — uses withAuthRole() for auth +import { getAuthSession } from '@/lib/auth/authSession'; +import { withAuthRole } from '@/lib/protectedRoute'; +import { SetWinner } from '@/server/services/set-project-winner'; +import { NextRequest, NextResponse } from 'next/server'; -export const PUT = withAuthRole("badge_admin", async (req: NextRequest) => { +export const PUT = withAuthRole('badge_admin', async (req: NextRequest) => { const body = await req.json(); const session = await getAuthSession(); - const name = session?.user.name || "user"; + const name = session?.user.name || 'user'; try { if (!body.project_id) { - return NextResponse.json( - { error: "project_id parameter is required" }, - { status: 400 } - ); + return NextResponse.json({ error: 'project_id parameter is required' }, { status: 400 }); } if (!body.isWinner) { - return NextResponse.json( - { error: "IsWinner parameter is required" }, - { status: 400 } - ); + return NextResponse.json({ error: 'IsWinner parameter is required' }, { status: 400 }); } const badge = await SetWinner(body.project_id, body.isWinner, name); return NextResponse.json(badge, { status: 200 }); } catch (error) { - console.error("Error checking user by email:", error); - return NextResponse.json( - { error: "Internal server error" }, - { status: 500 } - ); + console.error('Error checking user by email:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); } }); diff --git a/app/api/projects/[id]/invite/route.ts b/app/api/projects/[id]/invite/route.ts new file mode 100644 index 00000000000..9d4bbedae58 --- /dev/null +++ b/app/api/projects/[id]/invite/route.ts @@ -0,0 +1,34 @@ +import type { NextRequest } from 'next/server'; +import { withApi } from '@/lib/api/with-api'; +import { successResponse } from '@/lib/api/response'; +import { ForbiddenError } from '@/lib/api/errors'; +import { isUserProjectMember } from '@/server/services/fileValidation'; +import { generateInvitation } from '@/server/services/inviteProjectMember'; + +// schema: not applicable — invitation payload with dynamic fields +export const POST = withApi( + async (req: NextRequest, { session, params }) => { + const { id } = params; + const body = await req.json(); + + // If project id is provided via route param, verify membership + if (id) { + const isMember = await isUserProjectMember(session.user.id, id); + if (!isMember) { + throw new ForbiddenError('You must be a member of the project to invite others'); + } + } + + const result = await generateInvitation( + body.hackathon_id, + session.user.id, + session.user.name, + body.emails, + id || body.project_id, + body.stage, + ); + + return successResponse(result); + }, + { auth: true }, +); diff --git a/app/api/projects/[id]/members/route.ts b/app/api/projects/[id]/members/route.ts new file mode 100644 index 00000000000..8e2a06eb918 --- /dev/null +++ b/app/api/projects/[id]/members/route.ts @@ -0,0 +1,42 @@ +// schema: not applicable — PATCH body is member role update with dynamic fields +import type { NextRequest } from 'next/server'; +import { withApi } from '@/lib/api/with-api'; +import { successResponse } from '@/lib/api/response'; +import { BadRequestError, ForbiddenError } from '@/lib/api/errors'; +import { isUserProjectMember } from '@/server/services/fileValidation'; +import { GetMembersByProjectId, UpdateRoleMember } from '@/server/services/memberProject'; + +export const GET = withApi( + async (_req: NextRequest, { session, params }) => { + const { id } = params; + if (!id) throw new BadRequestError('project id is required'); + + const isMember = await isUserProjectMember(session.user.id, id); + if (!isMember) throw new ForbiddenError('You are not a member of this project'); + + const members = await GetMembersByProjectId(id); + return successResponse(members ?? []); + }, + { auth: true }, +); + +export const PATCH = withApi( + async (req: NextRequest, { session, params }) => { + const { id } = params; + if (!id) throw new BadRequestError('project id is required'); + + const body = await req.json(); + const { member_id, role } = body; + + if (!member_id || !role) { + throw new BadRequestError('member_id and role are required'); + } + + const isMember = await isUserProjectMember(session.user.id, id); + if (!isMember) throw new ForbiddenError('You are not a member of this project'); + + const updatedMember = await UpdateRoleMember(member_id, role); + return successResponse(updatedMember); + }, + { auth: true }, +); diff --git a/app/api/projects/[id]/members/status/route.ts b/app/api/projects/[id]/members/status/route.ts new file mode 100644 index 00000000000..59e2d666cdb --- /dev/null +++ b/app/api/projects/[id]/members/status/route.ts @@ -0,0 +1,36 @@ +// schema: not applicable — member status update with dynamic fields +import type { NextRequest } from 'next/server'; +import { withApi } from '@/lib/api/with-api'; +import { successResponse } from '@/lib/api/response'; +import { BadRequestError, ForbiddenError } from '@/lib/api/errors'; +import { isUserProjectMember } from '@/server/services/fileValidation'; +import { UpdateStatusMember } from '@/server/services/memberProject'; + +export const PATCH = withApi( + async (req: NextRequest, { session, params }) => { + const { id } = params; + if (!id) throw new BadRequestError('project id is required'); + + const body = await req.json(); + const { user_id, status, wasInOtherProject } = body; + + const isMember = await isUserProjectMember(session.user.id, id); + if (!isMember) throw new ForbiddenError('You are not a member of this project'); + + // Users can only update their own status + if (user_id && user_id !== session.user.id) { + throw new ForbiddenError('You can only update your own member status'); + } + + const updatedMember = await UpdateStatusMember( + session.user.id, + id, + status, + session.user.email || '', + wasInOtherProject, + ); + + return successResponse(updatedMember); + }, + { auth: true }, +); diff --git a/app/api/projects/[id]/route.ts b/app/api/projects/[id]/route.ts index e2da64603cf..3d2a91ae6f4 100644 --- a/app/api/projects/[id]/route.ts +++ b/app/api/projects/[id]/route.ts @@ -1,61 +1,74 @@ -import { NextRequest, NextResponse } from "next/server"; -import { HackathonHeader } from "@/types/hackathons"; -import { getProject, updateProject } from "@/server/services/projects"; -import { isUserProjectMember } from "@/server/services/fileValidation"; -import { withAuth } from '@/lib/protectedRoute'; -import { GetProjectByIdWithMembers } from "@/server/services/memberProject"; - -export const GET = withAuth(async (req: NextRequest, context: any, session: any) => { - try { - const { id } = await context.params; - - if (!id) { - return NextResponse.json({ error: "ID required" }, { status: 400 }); - } +// schema: not applicable — partial project update with allowlisted fields +import type { NextRequest } from 'next/server'; +import { withApi } from '@/lib/api/with-api'; +import { successResponse } from '@/lib/api/response'; +import { BadRequestError, ForbiddenError, NotFoundError } from '@/lib/api/errors'; +import { updateProject } from '@/server/services/projects'; +import { isUserProjectMember } from '@/server/services/fileValidation'; +import { GetProjectByIdWithMembers } from '@/server/services/memberProject'; + +/** Fields that callers are allowed to update via PUT. Prevents mass-assignment of + * sensitive columns like is_winner, hackaton_id, created_at, etc. */ +const UPDATABLE_FIELDS = [ + 'project_name', + 'short_description', + 'full_description', + 'tech_stack', + 'github_repository', + 'demo_link', + 'demo_video_link', + 'logo_url', + 'cover_url', + 'screenshots', + 'tracks', + 'categories', + 'other_category', + 'tags', + 'open_source', + 'origin', +] as const; + +export const GET = withApi( + async (_req: NextRequest, { session, params }) => { + const { id } = params; + if (!id) throw new BadRequestError('ID required'); - // Check if user is a member of the project const isMember = await isUserProjectMember(session.user.id, id); if (!isMember) { - return NextResponse.json( - { error: "Forbidden: You are not a member of this project" }, - { status: 403 } - ); + throw new ForbiddenError('You are not a member of this project'); } const project = await GetProjectByIdWithMembers(id); - return NextResponse.json(project); - } catch (error) { - console.error("Error in GET /api/projects/[id]:"); - return NextResponse.json( - { error: (error as Error).message }, - { status: 500 } - ); - } -}); - -export const PUT = withAuth(async (req: NextRequest, context: any, session: any) => { - try { - const { id } = await context.params; - - if (!id) { - return NextResponse.json({ error: "ID required" }, { status: 400 }); + if (!project) { + throw new NotFoundError('Project'); } + return successResponse(project); + }, + { auth: true }, +); + +export const PUT = withApi( + async (req: NextRequest, { session, params }) => { + const { id } = params; + if (!id) throw new BadRequestError('ID required'); - // Check if user is a member of the project const isMember = await isUserProjectMember(session.user.id, id); if (!isMember) { - return NextResponse.json( - { error: "Forbidden: You are not a member of this project" }, - { status: 403 } - ); + throw new ForbiddenError('You are not a member of this project'); } - const partialEditedHackathon = (await req.json()) as Partial; - const updatedHackathon = await updateProject(id ?? partialEditedHackathon.id, partialEditedHackathon); + const raw = await req.json(); + + // Whitelist: only copy allowed fields to prevent mass assignment + const sanitized: Record = {}; + for (const key of UPDATABLE_FIELDS) { + if (key in raw) { + sanitized[key] = raw[key]; + } + } - return NextResponse.json(updatedHackathon); - } catch (error) { - console.error("Error in PUT /api/projects/[id]:", error); - return NextResponse.json({ error: `Internal Server Error: ${error}` }, { status: 500 }); - } -}); + const updatedProject = await updateProject(id, sanitized); + return successResponse(updatedProject); + }, + { auth: true }, +); diff --git a/app/api/projects/check-invitation/route.ts b/app/api/projects/check-invitation/route.ts new file mode 100644 index 00000000000..bf0cb48af5a --- /dev/null +++ b/app/api/projects/check-invitation/route.ts @@ -0,0 +1,25 @@ +import type { NextRequest } from 'next/server'; +import { withApi } from '@/lib/api/with-api'; +import { successResponse } from '@/lib/api/response'; +import { BadRequestError, ForbiddenError } from '@/lib/api/errors'; +import { CheckInvitation } from '@/server/services/projects'; + +export const GET = withApi( + async (req: NextRequest, { session }) => { + const invitationId = req.nextUrl.searchParams.get('invitation'); + const userId = req.nextUrl.searchParams.get('user_id'); + + if (!invitationId) { + throw new BadRequestError('invitation parameter is required'); + } + + // Verify user_id matches session if provided + if (userId && userId !== session.user.id) { + throw new ForbiddenError('You can only check invitations for yourself'); + } + + const member = await CheckInvitation(invitationId, session.user.id); + return successResponse(member); + }, + { auth: true }, +); diff --git a/app/api/projects/export/route.ts b/app/api/projects/export/route.ts index 8b2791be2bc..53a93c7291a 100644 --- a/app/api/projects/export/route.ts +++ b/app/api/projects/export/route.ts @@ -1,33 +1,24 @@ -import { withAuthRole } from "@/lib/protectedRoute"; -import { exportShowcase } from "@/server/services/exportShowcase"; -import { NextRequest, NextResponse } from "next/server"; +import type { NextRequest } from 'next/server'; +import { NextResponse } from 'next/server'; +import { withApi } from '@/lib/api/with-api'; +import { NotFoundError } from '@/lib/api/errors'; +import { exportShowcase } from '@/server/services/exportShowcase'; -export const POST = withAuthRole('hackathonCreator', async (req: NextRequest) => { - try { - const body = await req.json(); - const buffer = await exportShowcase(body); - if (!buffer) { - return NextResponse.json( - { message: 'No projects found' }, - { status: 404 } - ); - } - return new NextResponse(buffer, { - headers: { 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }, - }); - } catch (error: any) { - console.error('Error POST /api/projects/export:', error.message); - const wrappedError = error as Error; - return NextResponse.json( - { - error: { - message: wrappedError.message, - stack: wrappedError.stack, - cause: wrappedError.cause, - name: wrappedError.name - } - }, - { status: wrappedError.cause == 'ValidationError' ? 400 : 500 } - ); +// schema: not applicable — returns binary Excel buffer, not JSON +export const POST = withApi( + async (req: NextRequest) => { + const body = await req.json(); + const buffer = await exportShowcase(body); + + if (!buffer) { + throw new NotFoundError('No projects found'); } -}); \ No newline at end of file + + return new NextResponse(buffer, { + headers: { + 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + }, + }); + }, + { auth: true, roles: ['devrel'] }, +); diff --git a/app/api/projects/invite-member/route.ts b/app/api/projects/invite-member/route.ts new file mode 100644 index 00000000000..6fbc120c753 --- /dev/null +++ b/app/api/projects/invite-member/route.ts @@ -0,0 +1,38 @@ +import type { NextRequest } from 'next/server'; +import { withApi } from '@/lib/api/with-api'; +import { successResponse } from '@/lib/api/response'; +import { ForbiddenError } from '@/lib/api/errors'; +import { isUserProjectMember } from '@/server/services/fileValidation'; +import { generateInvitation } from '@/server/services/inviteProjectMember'; + +/** + * Flat invite-member endpoint for backward compatibility. + * Accepts project_id in the request body rather than the URL. + * Prefer /api/projects/[id]/invite for new code. + */ +// schema: not applicable — invitation payload with dynamic fields +export const POST = withApi( + async (req: NextRequest, { session }) => { + const body = await req.json(); + + // If project_id is provided, verify user is a member + if (body.project_id) { + const isMember = await isUserProjectMember(session.user.id, body.project_id); + if (!isMember) { + throw new ForbiddenError('You must be a member of the project to invite others'); + } + } + + const result = await generateInvitation( + body.hackathon_id, + session.user.id, + session.user.name, + body.emails, + body.project_id, + body.stage, + ); + + return successResponse(result); + }, + { auth: true }, +); diff --git a/app/api/projects/member/[id]/route.ts b/app/api/projects/member/[id]/route.ts index 37c398afd91..8fc3a296a56 100644 --- a/app/api/projects/member/[id]/route.ts +++ b/app/api/projects/member/[id]/route.ts @@ -1,14 +1,16 @@ -import { withAuth } from "@/lib/protectedRoute"; -import { GetProjectsByUserId } from "@/server/services/memberProject"; -import { NextResponse } from "next/server"; +import type { NextRequest } from 'next/server'; +import { withApi } from '@/lib/api/with-api'; +import { successResponse } from '@/lib/api/response'; +import { BadRequestError } from '@/lib/api/errors'; +import { GetProjectsByUserId } from '@/server/services/memberProject'; -export const GET = withAuth(async (_, context: any) => { - try { - const { id } = await context.params; - const projects = await GetProjectsByUserId(id); - return NextResponse.json(projects); - } catch (error: any) { - console.error("Error getting projects:", error); - return NextResponse.json({ error: error.message }, { status: 500 }); - } -}); \ No newline at end of file +export const GET = withApi( + async (_req: NextRequest, { params }) => { + const { id } = params; + if (!id) throw new BadRequestError('User ID required'); + + const projects = await GetProjectsByUserId(id); + return successResponse(projects); + }, + { auth: true }, +); diff --git a/app/api/projects/route.ts b/app/api/projects/route.ts index 2c0086113ce..608c95eeb8f 100644 --- a/app/api/projects/route.ts +++ b/app/api/projects/route.ts @@ -1,61 +1,56 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { createProject, getFilteredProjects, GetProjectOptions } from '@/server/services/projects'; -import { withAuth } from '@/lib/protectedRoute'; +// schema: not applicable — project creation with dynamic fields validated by service layer +import type { NextRequest } from 'next/server'; +import { withApi } from '@/lib/api/with-api'; +import { parsePagination } from '@/lib/api/pagination'; +import { successResponse, paginatedResponse } from '@/lib/api/response'; +import { createProject, getFilteredProjects } from '@/server/services/projects'; +import type { GetProjectOptions } from '@/server/services/projects'; -export const GET = withAuth(async (req: NextRequest, context: any, session: any) => { - try { +export const GET = withApi( + async (req: NextRequest) => { + const { page, pageSize } = parsePagination(req); const searchParams = req.nextUrl.searchParams; + const options: GetProjectOptions = { - page: Number(searchParams.get('page') || 1), - pageSize: Number(searchParams.get('pageSize') || 12), + page, + pageSize, search: searchParams.get('search') || undefined, event: searchParams.get('events') || undefined, }; + const response = await getFilteredProjects(options); - return NextResponse.json(response); - } catch (error: any) { - console.error('Error GET /api/projects:', error.message); - const wrappedError = error as Error; - return NextResponse.json( - { error: wrappedError.message }, - { status: wrappedError.cause == 'BadRequest' ? 400 : 500 } - ); - } -}); - -export const POST = withAuth(async (req: NextRequest, context: any, session: any) => { - try { + return paginatedResponse(response.projects, { + page: response.page, + pageSize: response.pageSize, + total: response.total, + }); + }, + { auth: true }, +); + +export const POST = withApi( + async (req: NextRequest, { session }) => { const body = await req.json(); - + // Ensure the authenticated user is added as a member const members = body.members || []; const userIsMember = members.some((m: any) => m.user_id === session.user.id); - + if (!userIsMember) { - // Add the authenticated user as a confirmed member members.push({ user_id: session.user.id, - role: "Member", - status: "Confirmed", + role: 'Member', + status: 'Confirmed', }); } - + const newProject = await createProject({ ...body, members, }); - return NextResponse.json( - { message: 'Project created', project: newProject }, - { status: 201 } - ); - } catch (error: any) { - console.error('Error POST /api/projects:', error.message); - const wrappedError = error as Error; - return NextResponse.json( - { error: wrappedError }, - { status: wrappedError.cause == 'ValidationError' ? 400 : 500 } - ); - } -}); + return successResponse(newProject, 201); + }, + { auth: true }, +); diff --git a/app/api/projects/set-winner/route.ts b/app/api/projects/set-winner/route.ts new file mode 100644 index 00000000000..e52418c45af --- /dev/null +++ b/app/api/projects/set-winner/route.ts @@ -0,0 +1,24 @@ +import type { NextRequest } from 'next/server'; +import { withApi } from '@/lib/api/with-api'; +import { successResponse } from '@/lib/api/response'; +import { BadRequestError } from '@/lib/api/errors'; +import { SetWinner } from '@/server/services/set-project-winner'; + +// schema: not applicable — simple project_id + isWinner body, validated inline +export const PUT = withApi( + async (req: NextRequest, { session }) => { + const body = await req.json(); + + if (!body.project_id) { + throw new BadRequestError('project_id is required'); + } + if (!body.isWinner) { + throw new BadRequestError('isWinner is required'); + } + + const badge = await SetWinner(body.project_id, body.isWinner, session.user.name || 'user'); + + return successResponse(badge); + }, + { auth: true, roles: ['badge_admin'] }, +); diff --git a/app/api/projects/submit/route.ts b/app/api/projects/submit/route.ts new file mode 100644 index 00000000000..fdbf996e5fb --- /dev/null +++ b/app/api/projects/submit/route.ts @@ -0,0 +1,39 @@ +import type { NextRequest } from 'next/server'; +import { withApi } from '@/lib/api/with-api'; +import { successResponse } from '@/lib/api/response'; +import { ForbiddenError } from '@/lib/api/errors'; +import { GetProjectByHackathonAndUser } from '@/server/services/projects'; +import { createProject } from '@/server/services/submitProject'; + +// schema: not applicable — project submission with dynamic fields validated by service layer +export const POST = withApi( + async (req: NextRequest, { session }) => { + const body = await req.json(); + const newProject = await createProject({ + ...body, + submittedBy: session.user.email, + }); + + return successResponse(newProject, 201); + }, + { auth: true }, +); + +export const GET = withApi( + async (req: NextRequest, { session }) => { + const searchParams = req.nextUrl.searchParams; + const hackathonId = searchParams.get('hackathon_id') ?? ''; + const userId = searchParams.get('user_id'); + const invitationId = searchParams.get('invitation_id') ?? ''; + + // Verify user_id matches session user if provided + if (userId && userId !== session.user.id) { + throw new ForbiddenError('You can only access your own projects'); + } + + const project = await GetProjectByHackathonAndUser(hackathonId, session.user.id, invitationId); + + return successResponse({ project: project || null }); + }, + { auth: true }, +); diff --git a/app/api/raw/[...slug]/route.ts b/app/api/raw/[...slug]/route.ts index 4275757b6e4..afe177ed6dd 100644 --- a/app/api/raw/[...slug]/route.ts +++ b/app/api/raw/[...slug]/route.ts @@ -1,11 +1,12 @@ -import { NextRequest, NextResponse } from 'next/server'; +import { NextResponse } from 'next/server'; +import { withApi, BadRequestError, NotFoundError } from '@/lib/api'; import { documentation, academy, integration, blog } from '@/lib/source'; import { getLLMText } from '@/lib/llm-utils'; const markdownHeaders = { 'Content-Type': 'text/markdown; charset=utf-8', 'Cache-Control': 'public, max-age=3600, s-maxage=3600', - 'Vary': 'Accept', + Vary: 'Accept', }; type Source = typeof documentation | typeof academy | typeof integration | typeof blog; @@ -30,7 +31,10 @@ function buildSectionIndex(source: Source, label: string): string { let content = `# ${label}\n\n`; for (const [section, sectionPages] of Object.entries(sections)) { - const heading = section.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' '); + const heading = section + .split('-') + .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) + .join(' '); content += `## ${heading}\n`; for (const p of sectionPages.slice(0, 10)) { content += `- [${p.title}](${p.url})\n`; @@ -41,38 +45,47 @@ function buildSectionIndex(source: Source, label: string): string { return content; } -export async function GET(_request: NextRequest, { params }: { params: Promise<{ slug: string[] }> }) { - const { slug } = await params; +/** Reject path traversal patterns: "..", "//", null bytes */ +function validateSlugSegments(segments: string[]): void { + for (const segment of segments) { + if (segment === '..' || segment === '.' || segment.includes('\0') || segment === '') { + throw new BadRequestError('Invalid path segment'); + } + } + const joined = segments.join('/'); + if (joined.includes('//')) { + throw new BadRequestError('Invalid path: double slashes not allowed'); + } +} + +export const GET = withApi(async (_req, { params }) => { + const { slug } = params as unknown as { slug: string[] }; if (!slug || slug.length === 0) { - return NextResponse.json({ error: 'Path required' }, { status: 400 }); + throw new BadRequestError('Path required'); } + validateSlugSegments(slug); + const [contentType, ...restPath] = slug; const entry = sourceMap[contentType]; if (!entry) { - return NextResponse.json({ error: 'Invalid content type' }, { status: 400 }); + throw new BadRequestError('Invalid content type'); } - try { - // Section root request (e.g., /api/raw/docs) — return a synthesized index - if (restPath.length === 0) { - const content = buildSectionIndex(entry.source, entry.label); - return new NextResponse(content, { status: 200, headers: markdownHeaders }); - } - - const page = entry.source.getPage(restPath); - - if (!page) { - return NextResponse.json({ error: 'Page not found' }, { status: 404 }); - } + // Section root request (e.g., /api/raw/docs) -- return a synthesized index + if (restPath.length === 0) { + const content = buildSectionIndex(entry.source, entry.label); + return new NextResponse(content, { status: 200, headers: markdownHeaders }); + } - const content = await getLLMText(page); + const page = entry.source.getPage(restPath); - return new NextResponse(content, { status: 200, headers: markdownHeaders }); - } catch (error) { - console.error('Error fetching page:', error); - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + if (!page) { + throw new NotFoundError('Page'); } -} + + const content = await getLLMText(page); + return new NextResponse(content, { status: 200, headers: markdownHeaders }); +}); diff --git a/app/api/register-form/route.ts b/app/api/register-form/route.ts index abf7741c1e8..d5f61898669 100644 --- a/app/api/register-form/route.ts +++ b/app/api/register-form/route.ts @@ -1,62 +1,34 @@ -import { createRegisterForm, getRegisterForm } from "@/server/services/registerForms"; -import { NextRequest, NextResponse } from "next/server"; -import { withAuth } from "@/lib/protectedRoute"; - -export const POST = withAuth(async (req: NextRequest) => { - try { +import type { NextRequest } from 'next/server'; +import { withApi } from '@/lib/api/with-api'; +import { successResponse } from '@/lib/api/response'; +import { BadRequestError, ForbiddenError } from '@/lib/api/errors'; +import { createRegisterForm, getRegisterForm } from '@/server/services/registerForms'; + +// schema: not applicable — dynamic registration form data validated by service layer +export const POST = withApi( + async (req: NextRequest) => { const body = await req.json(); - const newHackathon = await createRegisterForm(body); + const newForm = await createRegisterForm(body); - return NextResponse.json( - { message: 'registration form created', hackathon: newHackathon }, - { status: 201 } - ); - } catch (error: any) { - console.error('Error POST /api/register-form:', error.message); - const wrappedError = error as Error; - return NextResponse.json( - { - error: { - message: wrappedError.message, - stack: wrappedError.stack, - cause: wrappedError.cause, - name: wrappedError.name - } - }, - { status: wrappedError.cause == 'ValidationError' ? 400 : 500 } - ); - } -}); + return successResponse(newForm, 201); + }, + { auth: true }, +); -export const GET = withAuth(async (req: NextRequest, context: any, session: any) => { - try { - const id = req.nextUrl.searchParams.get("hackathonId"); - const email = req.nextUrl.searchParams.get("email"); +export const GET = withApi( + async (req: NextRequest, { session }) => { + const hackathonId = req.nextUrl.searchParams.get('hackathonId'); + const email = req.nextUrl.searchParams.get('email'); - if (!id) { - return NextResponse.json({ error: "ID required" }, { status: 400 }); - } + if (!hackathonId) throw new BadRequestError('hackathonId is required'); + if (!email) throw new BadRequestError('email is required'); - if (!email) { - return NextResponse.json({ error: "Email required" }, { status: 400 }); - } - - // Verify that email matches the authenticated session user's email if (email !== session.user.email) { - return NextResponse.json( - { error: "Forbidden: You can only access your own registration forms" }, - { status: 403 } - ); + throw new ForbiddenError('You can only access your own registration forms'); } - const registerFormLoaded = await getRegisterForm(email, id); - - return NextResponse.json(registerFormLoaded); - } catch (error) { - console.error("Error in GET /api/register-form/", error); - return NextResponse.json( - { error: (error as Error).message }, - { status: 500 } - ); - } -}); + const registerForm = await getRegisterForm(email, hackathonId); + return successResponse(registerForm); + }, + { auth: true }, +); diff --git a/app/api/retro9000-returning/route.ts b/app/api/retro9000-returning/route.ts index 9f1cce2bde9..387214bd1a7 100644 --- a/app/api/retro9000-returning/route.ts +++ b/app/api/retro9000-returning/route.ts @@ -1,56 +1,48 @@ -import { NextResponse } from 'next/server'; +import type { NextRequest } from 'next/server'; +import { z } from 'zod'; +import { withApi } from '@/lib/api/with-api'; +import { successResponse } from '@/lib/api/response'; import { prisma } from '@/prisma/prisma'; -export async function POST(request: Request) { - try { - const formData = await request.json(); - const email = formData.email as string; - if (!email) { - return NextResponse.json( - { success: false, message: 'Email is required' }, - { status: 400 } - ); - } +const retro9000ReturningSchema = z.object({ + email: z.string().email('Email is required'), + project_name: z.string().optional().default(''), + project_type: z.string().optional().default(''), + project_vertical: z.string().optional().default(''), + project_website: z.string().nullable().optional().default(null), + project_x_handle: z.string().nullable().optional().default(null), + project_github: z.string().optional().default(''), + project_hq: z.string().optional().default(''), + project_continent: z.string().optional().default(''), + media_kit: z.string().optional().default(''), + previous_retro9000_snapshot_funding: z.string().nullable().optional().default(null), + requested_funding_range: z.string().optional().default(''), + eligibility_and_metrics: z.string().optional().default(''), + requested_grant_size_budget: z.string().optional().default(''), + changes_since_last_snapshot: z.string().optional().default(''), + first_name: z.string().optional().default(''), + last_name: z.string().optional().default(''), + pseudonym: z.string().nullable().optional().default(null), + role: z.string().optional().default(''), + x_account: z.string().optional().default(''), + telegram: z.string().optional().default(''), + linkedin: z.string().nullable().optional().default(null), + github: z.string().nullable().optional().default(null), + country: z.string().nullable().optional().default(null), + other_url: z.string().nullable().optional().default(null), + bio: z.string().optional().default(''), + kyb_willing: z.string().optional().default(''), + gdpr: z.boolean().optional().default(false), + marketing_consent: z.boolean().optional().default(false), +}); - const applicationData = { - email: email, - // PROJECT OVERVIEW - project_name: (formData.project_name as string) || '', - project_type: (formData.project_type as string) || '', - project_vertical: (formData.project_vertical as string) || '', - project_website: (formData.project_website as string) || null, - project_x_handle: (formData.project_x_handle as string) || null, - project_github: (formData.project_github as string) || '', - project_hq: (formData.project_hq as string) || '', - project_continent: (formData.project_continent as string) || '', - media_kit: (formData.media_kit as string) || '', - // FINANCIAL OVERVIEW - previous_retro9000_snapshot_funding: (formData.previous_retro9000_snapshot_funding as string) || null, - requested_funding_range: (formData.requested_funding_range as string) || '', - // GRANT INFORMATION - eligibility_and_metrics: (formData.eligibility_and_metrics as string) || '', - requested_grant_size_budget: (formData.requested_grant_size_budget as string) || '', - changes_since_last_snapshot: (formData.changes_since_last_snapshot as string) || '', - // APPLICANT INFORMATION - first_name: (formData.first_name as string) || '', - last_name: (formData.last_name as string) || '', - pseudonym: (formData.pseudonym as string) || null, - role: (formData.role as string) || '', - x_account: (formData.x_account as string) || '', - telegram: (formData.telegram as string) || '', - linkedin: (formData.linkedin as string) || null, - github: (formData.github as string) || null, - country: (formData.country as string) || null, - other_url: (formData.other_url as string) || null, - bio: (formData.bio as string) || '', - // COMPLIANCE - kyb_willing: (formData.kyb_willing as string) || '', - gdpr: formData.gdpr === true, - marketing_consent: formData.marketing_consent === true, - }; +// withApi: auth intentionally omitted — public form submission +export const POST = withApi>( + async (_req: NextRequest, { body }) => { + const applicationData = body; const result = await prisma.retro9000ReturningApplication.upsert({ - where: { email: email }, + where: { email: applicationData.email }, update: { project_name: applicationData.project_name, project_type: applicationData.project_type, @@ -83,12 +75,8 @@ export async function POST(request: Request) { }, create: applicationData, }); - return NextResponse.json({ success: true, id: result.id }); - } catch (error) { - console.error('Error processing Retro9000 Returning application:', error); - return NextResponse.json( - { success: false, message: error instanceof Error ? error.message : 'Internal server error' }, - { status: 500 } - ); - } -} + + return successResponse({ id: result.id }); + }, + { schema: retro9000ReturningSchema }, +); diff --git a/app/api/retro9000/route.ts b/app/api/retro9000/route.ts index 3e66739141a..5e0d1260bdf 100644 --- a/app/api/retro9000/route.ts +++ b/app/api/retro9000/route.ts @@ -1,250 +1,209 @@ -import { NextResponse } from 'next/server'; +import type { NextRequest } from 'next/server'; +import { z } from 'zod'; +import { withApi } from '@/lib/api/with-api'; +import { successResponse } from '@/lib/api/response'; +import { BadRequestError, InternalError } from '@/lib/api/errors'; const HUBSPOT_API_KEY = process.env.HUBSPOT_API_KEY; const HUBSPOT_PORTAL_ID = process.env.HUBSPOT_PORTAL_ID; const RETRO9000_FORM_GUID = process.env.RETRO9000_FORM_GUID; -export async function POST(request: Request) { - try { - if (!HUBSPOT_API_KEY || !HUBSPOT_PORTAL_ID || !RETRO9000_FORM_GUID) { - console.error('Missing environment variables: HUBSPOT_API_KEY, HUBSPOT_PORTAL_ID, or RETRO9000_FORM_GUID'); - return NextResponse.json( - { success: false, message: 'Server configuration error' }, - { status: 500 } - ); - } +const retro9000Schema = z + .object({ + firstname: z.string().min(1, 'First name is required'), + lastname: z.string().min(1, 'Last name is required'), + email: z.string().email('Valid email is required'), + project: z.string().min(1, 'Project is required'), + gdpr: z.literal(true, { error: 'GDPR consent is required' }), + }) + .passthrough(); - const clonedRequest = request.clone(); - let formData; - try { - formData = await clonedRequest.json(); - } catch (error) { - console.error('Error parsing request body:', error); - return NextResponse.json( - { success: false, message: 'Invalid request body' }, - { status: 400 } - ); - } - const requiredFields = ['firstname', 'lastname', 'email', 'project', 'gdpr']; - const missingFields = requiredFields.filter(field => !formData[field]); - - if (missingFields.length > 0) { - console.error('Missing required fields:', missingFields); - return NextResponse.json( - { success: false, message: `Missing required fields: ${missingFields.join(', ')}` }, - { status: 400 } - ); - } - - if (formData.gdpr !== true) { - return NextResponse.json( - { success: false, message: 'GDPR consent is required' }, - { status: 400 } - ); +// withApi: auth intentionally omitted — public form submission +export const POST = withApi>( + async (req: NextRequest, { body: formData }) => { + if (!HUBSPOT_API_KEY || !HUBSPOT_PORTAL_ID || !RETRO9000_FORM_GUID) { + throw new InternalError('Server configuration error'); } - const fieldMapping: { [key: string]: string[] } = { - "project_name": ["2-44649732/project_name"], - "firstname": ["2-44649732/applicant_first_name"], - "lastname": ["2-44649732/applicant_last_name"], - "email": ["email"], - "x_account": ["2-44649732/x_account"], - "telegram": ["2-44649732/telegram"], - "linkedin": ["linkedin"], - "other_resources": ["other_resources"], - "applicant_job_role": ["2-44649732/applicant_job_role"], - "applicant_job_role_other": ["2-44649732/applicant_job_role_other"], - "applicant_bio": ["2-44649732/applicant_bio"], - "university_affiliation": ["university_affiliated"], - - "project": ["2-44649732/project"], - "project_type": ["2-43176573/project_type"], - "project_vertical": ["project_vertical"], - "project_vertical_other": ["project_vertical_other"], - "project_abstract_objective": ["2-44649732/project_abstract_objective"], - "technical_roadmap": ["2-44649732/technical_roadmap"], - "repositories_achievements": ["2-44649732/repositories_achievements"], - "risks_challenges": ["2-44649732/risks_challenges"], - "project_company_website": ["project_company_website"], - "project_company_x_handle": ["project_company_x_handle"], - "project_company_github": ["2-44649732/project_company_github"], - "company_type": ["2-44649732/company_type"], - "company_type_other": ["2-44649732/company_type_other"], - "project_company_hq": ["2-44649732/project_company_hq"], - "project_company_continent": ["2-44649732/project_company_continent"], - "media_kit": ["2-44649732/media_kit"], - - "previous_funding": ["2-44649732/previous_funding", "2-44649732/previous_funding_"], - "funding_amount_self_funding": ["2-43176573/previous_funding_non_avalanche_self_funding"], - "funding_amount_family_friends": ["2-43176573/previous_funding_non_avalanche_family_friends"], - "funding_amount_grant": ["2-43176573/previous_funding_non_avalanche_grant"], - "funding_amount_angel": ["2-43176573/previous_funding_non_avalanche_angel_investment"], - "funding_amount_pre_seed": ["2-43176573/previous_funding_non_avalanche_pre_seed"], - "funding_amount_seed": ["2-43176573/previous_funding_non_avalanche_seed"], - "funding_amount_series_a": ["2-43176573/previous_funding_non_avalanche_series_a"], - "previous_avalanche_funding_grants": ["2-44649732/previous_avalanche_funding_grants"], - "funding_amount_codebase": ["2-44649732/previous_funding_amount_codebase"], - "funding_amount_infrabuidl": ["2-44649732/previous_funding_amount_infrabuidl"], - "funding_amount_infrabuidl_ai": ["2-44649732/previous_funding_amount_infrabuidl_ai"], - "funding_amount_retro9000": ["funding_amount_retro9000"], - "funding_amount_blizzard": ["2-44649732/previous_funding_amount_blizzard"], - "funding_amount_avalabs": ["2-44649732/previous_funding_amount_ava_labs"], - "funding_amount_other_avalanche": ["2-44649732/previous_funding_amount_entity_other"], - "requested_funding_range": ["2-44649732/requested_funding_range"], - - "eligibility_and_metrics": ["2-44649732/retro9000_eligibility"], - "requested_grant_size_budget": ["2-44649732/requested_grant_size_and_budget"], - "previous_retro9000_funding": ["previous_retro9000_funding"], - "retro9000_previous_funding_amount": ["2-44649732/retro9000_previous_funding_amount"], - "retro9000_changes": ["2-44649732/post_retro9000_funding_changes"], - "vc_fundraising_support_check": ["2-44649732/vc_fundraising_support_check"], - - "current_development_stage": ["2-44649732/current_development_stage"], - "project_work_duration": ["2-44649732/project_work_duration"], - "project_live_status": ["2-44649732/project_live_status"], - "multichain_check": ["2-44649732/multichain_check"], - "multichain_chains": ["2-44649732/multichain_chains"], - "first_build_avalanche": ["2-44649732/first_build_avalanche"], - "previous_avalanche_project_info": ["2-44649732/previous_avalanche_project_info"], - "avalanche_contribution": ["2-44649732/avalanche_contribution"], - "avalanche_benefit_check": ["2-44649732/avalanche_benefit_check"], - "avalanche_l1_project_benefited_1_name": ["2-44649732/avalanche_l1_project_benefited_1"], - "avalanche_l1_project_benefited_1_website": ["2-44649732/avalanche_l1_project_benefited_1_website"], - "avalanche_l1_project_benefited_2_name": ["avalanche_l1_project_benefited_2_name"], - "avalanche_l1_project_benefited_2_website": ["avalanche_l1_project_benefited_2_website"], - "similar_project_check": ["2-44649732/similar_project_check"], - "similar_project_name_1": ["2-44649732/similar_project_name_1"], - "similar_project_website_1": ["2-44649732/similar_project_website_1"], - "similar_project_name_2": ["similar_project_name_2"], - "similar_project_website_2": ["similar_project_website_2"], - "direct_competitor_check": ["2-44649732/direct_competitor_check"], - "direct_competitor_1_name": ["2-44649732/direct_competitor_1"], - "direct_competitor_1_website": ["2-44649732/direct_competitor_1_website"], - "direct_competitor_2_name": ["direct_competitor_2_name"], - "direct_competitor_2_website": ["direct_competitor_2_website"], - "token_launch_avalanche_check": ["2-44649732/token_launch_avalanche_check"], - "token_launch_other_explanation": ["2-44649732/token_launch_other"], - "open_source_check": ["2-44649732/open_source_check"], - - "team_size": ["2-44649732/team_size"], - "team_member_1_first_name": ["team_member_1_first_name"], - "team_member_1_last_name": ["team_member_1_last_name"], - "team_member_1_email": ["team_member_1_email"], - "job_role_team_member_1": ["job_role_team_member_1"], - "team_member_1_x_account": ["team_member_1_x_account"], - "team_member_1_telegram": ["team_member_1_telegram"], - "team_member_1_linkedin": ["team_member_1_linkedin"], - "team_member_1_github": ["team_member_1_github"], - "team_member_1_other": ["team_member_1_other"], - "team_member_1_bio": ["team_member_1_bio"], - "team_member_2_first_name": ["team_member_2_first_name"], - "team_member_2_last_name": ["team_member_2_last_name"], - "team_member_2_email": ["team_member_2_email"], - "job_role_team_member_2": ["job_role_team_member_2"], - "team_member_2_x_account": ["team_member_2_x_account"], - "team_member_2_telegram": ["team_member_2_telegram"], - "team_member_2_linkedin": ["team_member_2_linkedin"], - "team_member_2_github": ["team_member_2_github"], - "team_member_2_other": ["team_member_2_other"], - "team_member_2_bio": ["team_member_2_bio"], - - "avalanche_grant_source": ["2-44649732/avalanche_grant_source"], - "avalanche_grant_source_other": ["avalanche_grant_source_other"], - "program_referral_check": ["2-44649732/program_referral_check"], - "program_referrer": ["2-44649732/program_referrer"], - - "kyb_willing": ["kyb_willing"], - "gdpr": ["gdpr"], - "marketing_consent": ["marketing_consent"] + const fieldMapping: Record = { + project_name: ['2-44649732/project_name'], + firstname: ['2-44649732/applicant_first_name'], + lastname: ['2-44649732/applicant_last_name'], + email: ['email'], + x_account: ['2-44649732/x_account'], + telegram: ['2-44649732/telegram'], + linkedin: ['linkedin'], + other_resources: ['other_resources'], + applicant_job_role: ['2-44649732/applicant_job_role'], + applicant_job_role_other: ['2-44649732/applicant_job_role_other'], + applicant_bio: ['2-44649732/applicant_bio'], + university_affiliation: ['university_affiliated'], + project: ['2-44649732/project'], + project_type: ['2-43176573/project_type'], + project_vertical: ['project_vertical'], + project_vertical_other: ['project_vertical_other'], + project_abstract_objective: ['2-44649732/project_abstract_objective'], + technical_roadmap: ['2-44649732/technical_roadmap'], + repositories_achievements: ['2-44649732/repositories_achievements'], + risks_challenges: ['2-44649732/risks_challenges'], + project_company_website: ['project_company_website'], + project_company_x_handle: ['project_company_x_handle'], + project_company_github: ['2-44649732/project_company_github'], + company_type: ['2-44649732/company_type'], + company_type_other: ['2-44649732/company_type_other'], + project_company_hq: ['2-44649732/project_company_hq'], + project_company_continent: ['2-44649732/project_company_continent'], + media_kit: ['2-44649732/media_kit'], + previous_funding: ['2-44649732/previous_funding', '2-44649732/previous_funding_'], + funding_amount_self_funding: ['2-43176573/previous_funding_non_avalanche_self_funding'], + funding_amount_family_friends: ['2-43176573/previous_funding_non_avalanche_family_friends'], + funding_amount_grant: ['2-43176573/previous_funding_non_avalanche_grant'], + funding_amount_angel: ['2-43176573/previous_funding_non_avalanche_angel_investment'], + funding_amount_pre_seed: ['2-43176573/previous_funding_non_avalanche_pre_seed'], + funding_amount_seed: ['2-43176573/previous_funding_non_avalanche_seed'], + funding_amount_series_a: ['2-43176573/previous_funding_non_avalanche_series_a'], + previous_avalanche_funding_grants: ['2-44649732/previous_avalanche_funding_grants'], + funding_amount_codebase: ['2-44649732/previous_funding_amount_codebase'], + funding_amount_infrabuidl: ['2-44649732/previous_funding_amount_infrabuidl'], + funding_amount_infrabuidl_ai: ['2-44649732/previous_funding_amount_infrabuidl_ai'], + funding_amount_retro9000: ['funding_amount_retro9000'], + funding_amount_blizzard: ['2-44649732/previous_funding_amount_blizzard'], + funding_amount_avalabs: ['2-44649732/previous_funding_amount_ava_labs'], + funding_amount_other_avalanche: ['2-44649732/previous_funding_amount_entity_other'], + requested_funding_range: ['2-44649732/requested_funding_range'], + eligibility_and_metrics: ['2-44649732/retro9000_eligibility'], + requested_grant_size_budget: ['2-44649732/requested_grant_size_and_budget'], + previous_retro9000_funding: ['previous_retro9000_funding'], + retro9000_previous_funding_amount: ['2-44649732/retro9000_previous_funding_amount'], + retro9000_changes: ['2-44649732/post_retro9000_funding_changes'], + vc_fundraising_support_check: ['2-44649732/vc_fundraising_support_check'], + current_development_stage: ['2-44649732/current_development_stage'], + project_work_duration: ['2-44649732/project_work_duration'], + project_live_status: ['2-44649732/project_live_status'], + multichain_check: ['2-44649732/multichain_check'], + multichain_chains: ['2-44649732/multichain_chains'], + first_build_avalanche: ['2-44649732/first_build_avalanche'], + previous_avalanche_project_info: ['2-44649732/previous_avalanche_project_info'], + avalanche_contribution: ['2-44649732/avalanche_contribution'], + avalanche_benefit_check: ['2-44649732/avalanche_benefit_check'], + avalanche_l1_project_benefited_1_name: ['2-44649732/avalanche_l1_project_benefited_1'], + avalanche_l1_project_benefited_1_website: ['2-44649732/avalanche_l1_project_benefited_1_website'], + avalanche_l1_project_benefited_2_name: ['avalanche_l1_project_benefited_2_name'], + avalanche_l1_project_benefited_2_website: ['avalanche_l1_project_benefited_2_website'], + similar_project_check: ['2-44649732/similar_project_check'], + similar_project_name_1: ['2-44649732/similar_project_name_1'], + similar_project_website_1: ['2-44649732/similar_project_website_1'], + similar_project_name_2: ['similar_project_name_2'], + similar_project_website_2: ['similar_project_website_2'], + direct_competitor_check: ['2-44649732/direct_competitor_check'], + direct_competitor_1_name: ['2-44649732/direct_competitor_1'], + direct_competitor_1_website: ['2-44649732/direct_competitor_1_website'], + direct_competitor_2_name: ['direct_competitor_2_name'], + direct_competitor_2_website: ['direct_competitor_2_website'], + token_launch_avalanche_check: ['2-44649732/token_launch_avalanche_check'], + token_launch_other_explanation: ['2-44649732/token_launch_other'], + open_source_check: ['2-44649732/open_source_check'], + team_size: ['2-44649732/team_size'], + team_member_1_first_name: ['team_member_1_first_name'], + team_member_1_last_name: ['team_member_1_last_name'], + team_member_1_email: ['team_member_1_email'], + job_role_team_member_1: ['job_role_team_member_1'], + team_member_1_x_account: ['team_member_1_x_account'], + team_member_1_telegram: ['team_member_1_telegram'], + team_member_1_linkedin: ['team_member_1_linkedin'], + team_member_1_github: ['team_member_1_github'], + team_member_1_other: ['team_member_1_other'], + team_member_1_bio: ['team_member_1_bio'], + team_member_2_first_name: ['team_member_2_first_name'], + team_member_2_last_name: ['team_member_2_last_name'], + team_member_2_email: ['team_member_2_email'], + job_role_team_member_2: ['job_role_team_member_2'], + team_member_2_x_account: ['team_member_2_x_account'], + team_member_2_telegram: ['team_member_2_telegram'], + team_member_2_linkedin: ['team_member_2_linkedin'], + team_member_2_github: ['team_member_2_github'], + team_member_2_other: ['team_member_2_other'], + team_member_2_bio: ['team_member_2_bio'], + avalanche_grant_source: ['2-44649732/avalanche_grant_source'], + avalanche_grant_source_other: ['avalanche_grant_source_other'], + program_referral_check: ['2-44649732/program_referral_check'], + program_referrer: ['2-44649732/program_referrer'], + kyb_willing: ['kyb_willing'], + gdpr: ['gdpr'], + marketing_consent: ['marketing_consent'], }; - + const fields: { name: string; value: string | boolean }[] = []; - - Object.keys(fieldMapping).forEach(formFieldName => { - const value = formData[formFieldName]; + + Object.keys(fieldMapping).forEach((formFieldName) => { + const value = (formData as any)[formFieldName]; let formattedValue: string | boolean; - + if (Array.isArray(value)) { - formattedValue = value ? value.filter(v => v !== null && v !== undefined && v !== '').join(', ') : ''; + formattedValue = value.filter((v: any) => v !== null && v !== undefined && v !== '').join(', '); } else if (typeof value === 'boolean') { - if (formFieldName !== 'gdpr' && formFieldName !== 'marketing_consent') { - formattedValue = value ? 'Yes' : 'No'; - } else { - formattedValue = value; - } + formattedValue = + formFieldName !== 'gdpr' && formFieldName !== 'marketing_consent' ? (value ? 'Yes' : 'No') : value; } else { if (value === undefined || value === null || value === '') { const mappedFields = fieldMapping[formFieldName]; - const isAmountField = mappedFields.some(field => - field.includes('amount') || field.includes('funding') - ); + const isAmountField = mappedFields.some((field) => field.includes('amount') || field.includes('funding')); formattedValue = isAmountField ? '0' : 'N/A'; } else { formattedValue = String(value); } } - const mappedFields = fieldMapping[formFieldName]; - mappedFields.forEach(fieldName => { - fields.push({ - name: fieldName, - value: formattedValue - }); + fieldMapping[formFieldName].forEach((fieldName) => { + fields.push({ name: fieldName, value: formattedValue }); }); }); - + const additionalRequiredFields = [ - { name: "2-44649732/previous_funding_amount_infrabuidl", value: "0" }, - { name: "2-43176573/previous_funding_non_avalanche_angel_investment", value: "0" }, - { name: "2-44649732/token_launch_other", value: "N/A" }, - { name: "2-44649732/similar_project_name_1", value: "N/A" }, - { name: "2-44649732/similar_project_website_1", value: "N/A" }, - { name: "2-44649732/direct_competitor_1", value: "N/A" }, - { name: "2-44649732/previous_funding_amount_entity_other", value: "0" }, - { name: "2-43176573/previous_funding_non_avalanche_pre_seed", value: "0" }, - { name: "2-44649732/applicant_job_role_other", value: "N/A" }, - { name: "2-44649732/avalanche_l1_project_benefited_1", value: "N/A" }, - { name: "2-44649732/previous_funding_amount_blizzard", value: "0" }, - { name: "2-44649732/previous_avalanche_project_info", value: "N/A" }, - { name: "2-44649732/previous_funding_amount_infrabuidl_ai", value: "0" }, - { name: "2-44649732/direct_competitor_1_website", value: "N/A" }, - { name: "2-43176573/previous_funding_non_avalanche_seed", value: "0" }, - { name: "2-44649732/program_referrer", value: "N/A" }, - { name: "2-43176573/previous_funding_non_avalanche_grant", value: "0" }, - { name: "2-44649732/avalanche_l1_project_benefited_1_website", value: "N/A" }, - { name: "2-44649732/previous_funding_amount_ava_labs", value: "0" }, - { name: "2-43176573/previous_funding_non_avalanche_series_a", value: "0" }, - { name: "2-43176573/previous_funding_non_avalanche_self_funding", value: "0" }, - { name: "2-43176573/previous_funding_non_avalanche_family_friends", value: "0" } + { name: '2-44649732/previous_funding_amount_infrabuidl', value: '0' }, + { name: '2-43176573/previous_funding_non_avalanche_angel_investment', value: '0' }, + { name: '2-44649732/token_launch_other', value: 'N/A' }, + { name: '2-44649732/similar_project_name_1', value: 'N/A' }, + { name: '2-44649732/similar_project_website_1', value: 'N/A' }, + { name: '2-44649732/direct_competitor_1', value: 'N/A' }, + { name: '2-44649732/previous_funding_amount_entity_other', value: '0' }, + { name: '2-43176573/previous_funding_non_avalanche_pre_seed', value: '0' }, + { name: '2-44649732/applicant_job_role_other', value: 'N/A' }, + { name: '2-44649732/avalanche_l1_project_benefited_1', value: 'N/A' }, + { name: '2-44649732/previous_funding_amount_blizzard', value: '0' }, + { name: '2-44649732/previous_avalanche_project_info', value: 'N/A' }, + { name: '2-44649732/previous_funding_amount_infrabuidl_ai', value: '0' }, + { name: '2-44649732/direct_competitor_1_website', value: 'N/A' }, + { name: '2-43176573/previous_funding_non_avalanche_seed', value: '0' }, + { name: '2-44649732/program_referrer', value: 'N/A' }, + { name: '2-43176573/previous_funding_non_avalanche_grant', value: '0' }, + { name: '2-44649732/avalanche_l1_project_benefited_1_website', value: 'N/A' }, + { name: '2-44649732/previous_funding_amount_ava_labs', value: '0' }, + { name: '2-43176573/previous_funding_non_avalanche_series_a', value: '0' }, + { name: '2-43176573/previous_funding_non_avalanche_self_funding', value: '0' }, + { name: '2-43176573/previous_funding_non_avalanche_family_friends', value: '0' }, ]; - - additionalRequiredFields.forEach(field => { - if (!fields.find(f => f.name === field.name)) { - fields.push({ - name: field.name, - value: field.value - }); + + additionalRequiredFields.forEach((field) => { + if (!fields.find((f) => f.name === field.name)) { + fields.push({ name: field.name, value: field.value }); } }); - Object.entries(formData).forEach(([name, value]) => { + + Object.entries(formData as Record).forEach(([name, value]) => { if (!fieldMapping[name] && value !== undefined && value !== null && value !== '') { let formattedValue: string | boolean; - if (Array.isArray(value)) { - formattedValue = value.filter(v => v !== null && v !== undefined && v !== '').join(', '); + formattedValue = value.filter((v: any) => v !== null && v !== undefined && v !== '').join(', '); } else if (typeof value === 'boolean') { formattedValue = value ? 'Yes' : 'No'; } else { formattedValue = String(value); } - - fields.push({ - name: name, - value: formattedValue - }); + fields.push({ name, value: formattedValue }); } }); - + const hubspotPayload: { fields: { name: string; value: string | boolean }[]; context: { pageUri: string; pageName: string }; @@ -260,80 +219,60 @@ export async function POST(request: Request) { }; }; } = { - fields: fields, + fields, context: { - pageUri: request.headers.get('referer') || 'https://build.avax.network', - pageName: 'Retro9000 Grant Application' - } + pageUri: req.headers.get('referer') || 'https://build.avax.network', + pageName: 'Retro9000 Grant Application', + }, }; if (formData.gdpr === true) { hubspotPayload.legalConsentOptions = { consent: { consentToProcess: true, - text: "I agree to allow Avalanche Foundation to store and process my personal data.", + text: 'I agree to allow Avalanche Foundation to store and process my personal data.', communications: [ { - value: formData.marketing_consent === true, + value: (formData as any).marketing_consent === true, subscriptionTypeId: 999, - text: "I agree to receive marketing communications from Avalanche Foundation." - } - ] - } + text: 'I agree to receive marketing communications from Avalanche Foundation.', + }, + ], + }, }; } - - const hubspotResponse = await fetch( - `https://api.hsforms.com/submissions/v3/integration/submit/${HUBSPOT_PORTAL_ID}/${RETRO9000_FORM_GUID}`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${HUBSPOT_API_KEY}` - }, - body: JSON.stringify(hubspotPayload) - } - ); - const responseStatus = hubspotResponse.status; - let hubspotResult; + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 30_000); + try { - const clonedResponse = hubspotResponse.clone(); - try { - hubspotResult = await hubspotResponse.json(); - } catch (jsonError) { - const text = await clonedResponse.text(); - hubspotResult = { status: 'error', message: text }; - } - } catch (error) { - hubspotResult = { status: 'error', message: 'Could not read HubSpot response' }; - } - - if (!hubspotResponse.ok) { - console.error('HubSpot submission failed:', { - status: responseStatus, - response: hubspotResult - }); - return NextResponse.json( - { - success: false, - message: 'Failed to submit to HubSpot', - status: responseStatus, - response: hubspotResult + const hubspotResponse = await fetch( + `https://api.hsforms.com/submissions/v3/integration/submit/${HUBSPOT_PORTAL_ID}/${RETRO9000_FORM_GUID}`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${HUBSPOT_API_KEY}`, + }, + body: JSON.stringify(hubspotPayload), + signal: controller.signal, }, - { status: 400 } ); - } - return NextResponse.json({ - success: true, - message: 'Application submitted successfully' - }); - } catch (error) { - console.error('Error processing form submission:', error); - return NextResponse.json( - { success: false, message: error instanceof Error ? error.message : 'Internal server error' }, - { status: 500 } - ); - } -} \ No newline at end of file + if (!hubspotResponse.ok) { + let hubspotResult: any; + try { + hubspotResult = await hubspotResponse.json(); + } catch { + hubspotResult = { message: 'Could not read HubSpot response' }; + } + throw new BadRequestError(hubspotResult?.message || 'Failed to submit to HubSpot'); + } + + return successResponse({ message: 'Application submitted successfully' }); + } finally { + clearTimeout(timeoutId); + } + }, + { schema: retro9000Schema }, +); diff --git a/app/api/safe/route.ts b/app/api/safe/route.ts index 49023da3a1d..7ca0cc8e55c 100644 --- a/app/api/safe/route.ts +++ b/app/api/safe/route.ts @@ -1,205 +1,171 @@ -import { NextRequest, NextResponse } from 'next/server'; +import type { NextRequest } from 'next/server'; +import { z } from 'zod'; import SafeApiKit from '@safe-global/api-kit'; -import { getAddress, isAddress } from 'viem'; +import { getAddress } from 'viem'; +import { withApi } from '@/lib/api/with-api'; +import { successResponse } from '@/lib/api/response'; +import { BadRequestError } from '@/lib/api/errors'; + +// --------------------------------------------------------------------------- +// Types & helpers +// --------------------------------------------------------------------------- interface ChainConfig { chainId: string; chainName: string; transactionService: string; + shortName: string; [key: string]: any; } -const getSupportedChain = async (chainId: string): Promise<{ txServiceUrl: string; shortName: string }> => { - try { - const response = await fetch('https://wallet-client.ash.center/v1/chains', { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - }, - }); - - if (!response.ok) { - throw new Error(`HTTP ${response.status}: ${response.statusText}`); - } - - const data = await response.json(); - - if (data.error) { - throw new Error(data.error); - } - - const supportedChain = data.results.find((chain: ChainConfig) => chain.chainId === chainId); - if (!supportedChain) { - throw new Error(`Chain ${chainId} is not supported for Ash L1 Multisig operations`); - } - - let txServiceUrl = supportedChain.transactionService; - if (!txServiceUrl.endsWith('/api') && !txServiceUrl.includes('/api/')) { - txServiceUrl = txServiceUrl.endsWith('/') ? txServiceUrl + 'api' : txServiceUrl + '/api'; - } - - return { - txServiceUrl, - shortName: supportedChain.shortName - }; - } catch (error) { - throw new Error(`Failed to fetch supported chains: ${(error as Error).message}`); +async function getSupportedChain(chainId: string): Promise<{ txServiceUrl: string; shortName: string }> { + const response = await fetch('https://wallet-client.ash.center/v1/chains', { + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + }); + + if (!response.ok) { + throw new BadRequestError(`HTTP ${response.status}: ${response.statusText}`); } -}; -export async function POST(request: NextRequest) { - try { - const body = await request.json(); - const { action, chainId, safeAddress, ...params } = body; + const data = await response.json(); + if (data.error) { + throw new BadRequestError(data.error); + } - if (!action) { - return NextResponse.json( - { error: 'Missing action parameter' }, - { status: 400 } - ); - } + const supportedChain: ChainConfig | undefined = data.results.find((chain: ChainConfig) => chain.chainId === chainId); + if (!supportedChain) { + throw new BadRequestError(`Chain ${chainId} is not supported for Ash L1 Multisig operations`); + } - // Get transaction service URL and chain info - const chainInfo = await getSupportedChain(chainId); + let txServiceUrl = supportedChain.transactionService; + if (!txServiceUrl.endsWith('/api') && !txServiceUrl.includes('/api/')) { + txServiceUrl = txServiceUrl.endsWith('/') ? `${txServiceUrl}api` : `${txServiceUrl}/api`; + } + + return { txServiceUrl, shortName: supportedChain.shortName }; +} - // Initialize Safe API Kit - const apiKit = new SafeApiKit({ +// --------------------------------------------------------------------------- +// Schema — bounds address arrays to max 50 entries +// --------------------------------------------------------------------------- + +const safeBodySchema = z.object({ + action: z.string().min(1, 'Missing action parameter'), + chainId: z.string().min(1), + safeAddress: z.string().optional(), + proposalData: z.record(z.string(), z.unknown()).optional(), + safeTxHash: z.string().optional(), + ownerAddress: z.string().optional(), + safeAddresses: z.array(z.string()).max(50, 'Maximum 50 addresses per request').optional(), +}); + +type SafeBody = z.infer; + +// --------------------------------------------------------------------------- +// POST /api/safe +// --------------------------------------------------------------------------- + +// withApi: auth intentionally omitted — public anonymous access supported +export const POST = withApi( + async (_req: NextRequest, { body }) => { + const { action, chainId, safeAddress, ...params } = body; + + const chainInfo = await getSupportedChain(chainId); + const apiKit = new SafeApiKit({ chainId: BigInt(chainId), - txServiceUrl: chainInfo.txServiceUrl + txServiceUrl: chainInfo.txServiceUrl, }); switch (action) { case 'getSafeInfo': { - const safeInfo = await apiKit.getSafeInfo(safeAddress); - return NextResponse.json({ success: true, data: safeInfo }); + const safeInfo = await apiKit.getSafeInfo(safeAddress!); + return successResponse(safeInfo); } case 'getNextNonce': { - const nonce = await apiKit.getNextNonce(safeAddress); - return NextResponse.json({ success: true, data: { nonce: Number(nonce) } }); + const nonce = await apiKit.getNextNonce(safeAddress!); + return successResponse({ nonce: Number(nonce) }); } case 'proposeTransaction': { const { proposalData } = params; - if (!proposalData) { - return NextResponse.json( - { error: 'Missing proposalData' }, - { status: 400 } - ); + throw new BadRequestError('Missing proposalData'); } - // Ensure addresses are properly formatted + const pd = proposalData as Record; const formattedProposalData = { - ...proposalData, - safeAddress: getAddress(proposalData.safeAddress), - senderAddress: getAddress(proposalData.senderAddress), + ...pd, + safeAddress: getAddress(pd.safeAddress), + senderAddress: getAddress(pd.senderAddress), safeTransactionData: { - ...proposalData.safeTransactionData, - to: getAddress(proposalData.safeTransactionData.to), - nonce: Number(proposalData.safeTransactionData.nonce), - } + ...pd.safeTransactionData, + to: getAddress(pd.safeTransactionData.to), + nonce: Number(pd.safeTransactionData.nonce), + }, }; - await apiKit.proposeTransaction(formattedProposalData); - return NextResponse.json({ success: true, data: { proposed: true } }); + await apiKit.proposeTransaction(formattedProposalData as any); + return successResponse({ proposed: true }); } case 'getPendingTransactions': { - const transactions = await apiKit.getPendingTransactions(safeAddress); - return NextResponse.json({ success: true, data: transactions }); + const transactions = await apiKit.getPendingTransactions(safeAddress!); + return successResponse(transactions); } case 'getTransaction': { - const { safeTxHash } = params; - if (!safeTxHash) { - return NextResponse.json( - { error: 'Missing safeTxHash' }, - { status: 400 } - ); + if (!params.safeTxHash) { + throw new BadRequestError('Missing safeTxHash'); } - - const transaction = await apiKit.getTransaction(safeTxHash); - return NextResponse.json({ success: true, data: transaction }); + const transaction = await apiKit.getTransaction(params.safeTxHash); + return successResponse(transaction); } case 'getSafesByOwner': { - const { ownerAddress } = params; - if (!ownerAddress) { - return NextResponse.json( - { error: 'Missing ownerAddress' }, - { status: 400 } - ); + if (!params.ownerAddress) { + throw new BadRequestError('Missing ownerAddress'); } - - const safesByOwner = await apiKit.getSafesByOwner(getAddress(ownerAddress)); - return NextResponse.json({ success: true, data: safesByOwner }); + const safesByOwner = await apiKit.getSafesByOwner(getAddress(params.ownerAddress)); + return successResponse(safesByOwner); } case 'getAllSafesInfo': { const { safeAddresses } = params; if (!safeAddresses || !Array.isArray(safeAddresses)) { - return NextResponse.json( - { error: 'Missing safeAddresses array' }, - { status: 400 } - ); + throw new BadRequestError('Missing safeAddresses array'); } - // Fetch info for multiple safes const safeInfos: Record = {}; const errors: Record = {}; - for (const safeAddress of safeAddresses) { + for (const addr of safeAddresses) { try { - const safeInfo = await apiKit.getSafeInfo(getAddress(safeAddress)); - safeInfos[safeAddress] = safeInfo; + safeInfos[addr] = await apiKit.getSafeInfo(getAddress(addr)); } catch (error) { - errors[safeAddress] = (error as Error).message; + errors[addr] = (error as Error).message; } } - return NextResponse.json({ - success: true, - data: { - safeInfos, - errors: Object.keys(errors).length > 0 ? errors : undefined - } + return successResponse({ + safeInfos, + errors: Object.keys(errors).length > 0 ? errors : undefined, }); } case 'getAshWalletUrl': { if (!safeAddress) { - return NextResponse.json( - { error: 'Missing safeAddress' }, - { status: 400 } - ); + throw new BadRequestError('Missing safeAddress'); } - - // Get chain info to get the shortName - const chainInfo = await getSupportedChain(chainId); - const ashWalletUrl = `https://wallet.ash.center/transactions/queue?safe=${chainInfo.shortName}:${safeAddress}`; - - return NextResponse.json({ - success: true, - data: { - url: ashWalletUrl, - shortName: chainInfo.shortName - } - }); + const walletChainInfo = await getSupportedChain(chainId); + const url = `https://wallet.ash.center/transactions/queue?safe=${walletChainInfo.shortName}:${safeAddress}`; + return successResponse({ url, shortName: walletChainInfo.shortName }); } default: - return NextResponse.json( - { error: `Unknown action: ${action}` }, - { status: 400 } - ); + throw new BadRequestError(`Unknown action: ${action}`); } - - } catch (error) { - console.error('Safe API error:', error); - return NextResponse.json( - { error: `Safe operation failed: ${(error as Error).message}` }, - { status: 500 } - ); - } -} \ No newline at end of file + }, + { schema: safeBodySchema }, +); diff --git a/app/api/send-otp/route.ts b/app/api/send-otp/route.ts index ea38798d0cb..f65fae9579b 100644 --- a/app/api/send-otp/route.ts +++ b/app/api/send-otp/route.ts @@ -1,29 +1,19 @@ -import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; +import { withApi, successResponse } from '@/lib/api'; import { sendOTP } from '@/server/services/login'; -export async function POST(request: NextRequest) { - try { - const body = await request.json(); - const { email } = body; +const SendOtpSchema = z.object({ + email: z.string().email('Valid email is required'), +}); - if (!email) { - return NextResponse.json( - { error: 'Email is required' }, - { status: 400 } - ); - } - - await sendOTP(email.toLowerCase()); - - return NextResponse.json( - { message: 'OTP sent correctly' }, - { status: 200 } - ); - } catch (error) { - console.error('Error sending OTP:', error); - return NextResponse.json( - { error: 'Error sending verification code' }, - { status: 500 } - ); - } -} +// withApi: auth intentionally omitted — pre-authentication endpoint +export const POST = withApi>( + async (_req, { body }) => { + await sendOTP(body.email.toLowerCase()); + return successResponse({ message: 'OTP sent correctly' }); + }, + { + schema: SendOtpSchema, + rateLimit: { windowMs: 3600000, maxRequests: 5, identifier: 'ip' }, + }, +); diff --git a/app/api/staking-apy/route.ts b/app/api/staking-apy/route.ts index bc8ad2cfbf7..ad62fcf3725 100644 --- a/app/api/staking-apy/route.ts +++ b/app/api/staking-apy/route.ts @@ -1,4 +1,5 @@ import { NextResponse } from 'next/server'; +import { withApi, successResponse } from '@/lib/api'; import { createPChainClient } from '@avalanche-sdk/client'; import { avalanche } from '@avalanche-sdk/client/chains'; @@ -10,12 +11,12 @@ const CONFIG = { staleWhileRevalidate: 86400, // 24 hours }, timeout: 15000, // 15 seconds - + // Network Constants for Primary Network Mainnet network: { genesisSupply: 360_000_000, // 360M AVAX unlocked at genesis maxSupply: 720_000_000, // 720M AVAX maximum supply cap - minConsumptionRate: 0.10, // 10% for minimum staking duration + minConsumptionRate: 0.1, // 10% for minimum staking duration maxConsumptionRate: 0.12, // 12% for maximum staking duration mintingPeriodDays: 365, // 1 year minStakingDays: 14, // 2 weeks @@ -24,7 +25,8 @@ const CONFIG = { endpoints: { dataApi: 'https://data-api.avax.network/v1/avax/supply', - metabase: 'https://ava-labs-inc.metabaseapp.com/api/public/dashboard/38ea69a5-e373-4258-9db6-8425fcba3a1a/dashcard/9955/card/13502?parameters=%5B%5D', + metabase: + 'https://ava-labs-inc.metabaseapp.com/api/public/dashboard/38ea69a5-e373-4258-9db6-8425fcba3a1a/dashcard/9955/card/13502?parameters=%5B%5D', }, } as const; @@ -53,11 +55,7 @@ interface MetabaseRow { cumulativeEmissions: number; } -async function fetchWithTimeout( - url: string, - options: RequestInit = {}, - timeoutMs = CONFIG.timeout -): Promise { +async function fetchWithTimeout(url: string, options: RequestInit = {}, timeoutMs = CONFIG.timeout): Promise { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), timeoutMs); try { @@ -86,11 +84,11 @@ function getEffectiveConsumptionRate(stakingDays: number): number { */ function calculateAPY(supply: number, stakingDays: number): number { if (supply <= 0 || supply >= CONFIG.network.maxSupply) return 0; - + const remainingToMint = CONFIG.network.maxSupply - supply; const effectiveRate = getEffectiveConsumptionRate(stakingDays); const apy = (remainingToMint / supply) * effectiveRate * 100; - + return Math.max(0, Number(apy.toFixed(2))); } @@ -100,8 +98,8 @@ async function fetchPChainSupply(): Promise { const result = await pChainClient.getCurrentSupply({}); // Supply is returned as bigint in nanoAVAX, convert to AVAX return Number(result.supply) / 1_000_000_000; - } catch (error) { - console.error('[fetchPChainSupply] SDK error:', error); + } catch { + // P-Chain supply SDK error; return null to use fallback return null; } } @@ -114,7 +112,10 @@ async function fetchDataApiDetails(): Promise<{ totalBurned: number } | null> { if (!response.ok) return null; const data = await response.json(); - const totalBurned = (parseFloat(data.totalPBurned) || 0) + (parseFloat(data.totalCBurned) || 0) + (parseFloat(data.totalXBurned) || 0); + const totalBurned = + (parseFloat(data.totalPBurned) || 0) + + (parseFloat(data.totalCBurned) || 0) + + (parseFloat(data.totalXBurned) || 0); return { totalBurned }; } catch { return null; @@ -133,7 +134,7 @@ async function fetchHistoricalData(): Promise { const rows: MetabaseRow[] = []; for (const row of data.data.rows) { const dateStr = row[0]; - const emissions = row[1]; + const emissions = row[1]; if (!dateStr || typeof emissions !== 'number' || emissions <= 0) continue; rows.push({ @@ -149,87 +150,76 @@ async function fetchHistoricalData(): Promise { } } -export async function GET() { - try { - const [pChainSupply, dataApiDetails, historicalData] = await Promise.all([ - fetchPChainSupply(), - fetchDataApiDetails(), - fetchHistoricalData(), - ]); - - if (!pChainSupply && historicalData.length === 0) { - return NextResponse.json( - { error: 'Failed to fetch supply data from all sources' }, - { status: 503 } - ); - } +export const GET = withApi(async () => { + const [pChainSupply, dataApiDetails, historicalData] = await Promise.all([ + fetchPChainSupply(), + fetchDataApiDetails(), + fetchHistoricalData(), + ]); - const currentSupply = pChainSupply ?? CONFIG.network.genesisSupply; - const current: CurrentData = { - supply: currentSupply, - totalBurned: dataApiDetails?.totalBurned ?? 0, - maxAPY: calculateAPY(currentSupply, CONFIG.network.maxStakingDays), - minAPY: calculateAPY(currentSupply, CONFIG.network.minStakingDays), - }; - - let apyHistory: APYDataPoint[] = []; - - if (historicalData.length > 0 && pChainSupply) { - const latestMetabase = historicalData[historicalData.length - 1]; - const metabaseLatestSupply = CONFIG.network.genesisSupply + latestMetabase.cumulativeEmissions; - const alignmentOffset = pChainSupply - metabaseLatestSupply; - apyHistory = historicalData.map((row) => { - const supply = CONFIG.network.genesisSupply + row.cumulativeEmissions + alignmentOffset; - return { - date: row.date, - timestamp: Math.floor(new Date(row.date).getTime() / 1000), - supply, - maxAPY: calculateAPY(supply, CONFIG.network.maxStakingDays), - minAPY: calculateAPY(supply, CONFIG.network.minStakingDays), - }; - }); - - const today = new Date().toISOString().split('T')[0]; - const lastPoint = apyHistory[apyHistory.length - 1]; - - if (lastPoint.date === today) { - lastPoint.supply = currentSupply; - lastPoint.maxAPY = current.maxAPY; - lastPoint.minAPY = current.minAPY; - } else { - apyHistory.push({ - date: today, - timestamp: Math.floor(Date.now() / 1000), - supply: currentSupply, - maxAPY: current.maxAPY, - minAPY: current.minAPY, - }); - } - } - - const response = { - data: apyHistory, - current, - constants: { - genesisSupply: CONFIG.network.genesisSupply, - maxSupply: CONFIG.network.maxSupply, - minConsumptionRate: CONFIG.network.minConsumptionRate, - maxConsumptionRate: CONFIG.network.maxConsumptionRate, - minStakingDuration: '2 weeks', - maxStakingDuration: '1 year', + if (!pChainSupply && historicalData.length === 0) { + return NextResponse.json( + { + success: false, + error: { code: 'SERVICE_UNAVAILABLE', message: 'Failed to fetch supply data from all sources' }, }, - }; + { status: 503 }, + ); + } - return NextResponse.json(response, { - headers: { - 'Cache-Control': `public, max-age=${CONFIG.cache.maxAge}, s-maxage=${CONFIG.cache.maxAge}, stale-while-revalidate=${CONFIG.cache.staleWhileRevalidate}`, - }, + const currentSupply = pChainSupply ?? CONFIG.network.genesisSupply; + const current: CurrentData = { + supply: currentSupply, + totalBurned: dataApiDetails?.totalBurned ?? 0, + maxAPY: calculateAPY(currentSupply, CONFIG.network.maxStakingDays), + minAPY: calculateAPY(currentSupply, CONFIG.network.minStakingDays), + }; + + let apyHistory: APYDataPoint[] = []; + + if (historicalData.length > 0 && pChainSupply) { + const latestMetabase = historicalData[historicalData.length - 1]; + const metabaseLatestSupply = CONFIG.network.genesisSupply + latestMetabase.cumulativeEmissions; + const alignmentOffset = pChainSupply - metabaseLatestSupply; + apyHistory = historicalData.map((row) => { + const supply = CONFIG.network.genesisSupply + row.cumulativeEmissions + alignmentOffset; + return { + date: row.date, + timestamp: Math.floor(new Date(row.date).getTime() / 1000), + supply, + maxAPY: calculateAPY(supply, CONFIG.network.maxStakingDays), + minAPY: calculateAPY(supply, CONFIG.network.minStakingDays), + }; }); - } catch (error) { - console.error('[GET /api/staking-apy] Unexpected error:', error); - return NextResponse.json( - { error: 'Internal server error' }, - { status: 500 } - ); + + const today = new Date().toISOString().split('T')[0]; + const lastPoint = apyHistory[apyHistory.length - 1]; + + if (lastPoint.date === today) { + lastPoint.supply = currentSupply; + lastPoint.maxAPY = current.maxAPY; + lastPoint.minAPY = current.minAPY; + } else { + apyHistory.push({ + date: today, + timestamp: Math.floor(Date.now() / 1000), + supply: currentSupply, + maxAPY: current.maxAPY, + minAPY: current.minAPY, + }); + } } -} + + return successResponse({ + data: apyHistory, + current, + constants: { + genesisSupply: CONFIG.network.genesisSupply, + maxSupply: CONFIG.network.maxSupply, + minConsumptionRate: CONFIG.network.minConsumptionRate, + maxConsumptionRate: CONFIG.network.maxConsumptionRate, + minStakingDuration: '2 weeks', + maxStakingDuration: '1 year', + }, + }); +}); diff --git a/app/api/university/slideshow/route.ts b/app/api/university/slideshow/route.ts index 6d7faa7a573..8637011a291 100644 --- a/app/api/university/slideshow/route.ts +++ b/app/api/university/slideshow/route.ts @@ -1,42 +1,29 @@ -import { NextResponse } from 'next/server'; +import { withApi } from '@/lib/api/with-api'; +import { successResponse } from '@/lib/api/response'; import { blobService } from '@/server/services/blob'; -// GET - Fetch all slideshow photos from University-Slideshow folder -export async function GET() { - try { - // List all blobs in the University-Slideshow directory - const blobs = await blobService.listFiles('University-Slideshow/'); +export const GET = withApi(async () => { + const blobs = await blobService.listFiles('University-Slideshow/'); - // Filter out empty entries and sort blobs by filename to ensure proper order (numbered images) - const filteredBlobs = blobs.filter(blob => { - const filename = blob.pathname.split('/').pop() || ''; - return filename && filename.length > 0 && blob.size > 0; - }); + const filteredBlobs = blobs.filter((blob) => { + const filename = blob.pathname.split('/').pop() || ''; + return filename && filename.length > 0 && blob.size > 0; + }); - const sortedBlobs = filteredBlobs.sort((a, b) => { - const aName = a.pathname.split('/').pop() || ''; - const bName = b.pathname.split('/').pop() || ''; - - // Extract numbers from filenames for sorting - const aNum = parseInt(aName.match(/\d+/)?.[0] || '0'); - const bNum = parseInt(bName.match(/\d+/)?.[0] || '0'); - - return aNum - bNum; - }); + const sortedBlobs = filteredBlobs.sort((a, b) => { + const aName = a.pathname.split('/').pop() || ''; + const bName = b.pathname.split('/').pop() || ''; + const aNum = parseInt(aName.match(/\d+/)?.[0] || '0'); + const bNum = parseInt(bName.match(/\d+/)?.[0] || '0'); + return aNum - bNum; + }); - return NextResponse.json({ - images: sortedBlobs.map(blob => ({ - url: blob.url, - filename: blob.pathname.split('/').pop(), - size: blob.size, - uploadedAt: blob.uploadedAt - })) - }); - } catch (error) { - console.error('Error fetching university slideshow photos:', error); - return NextResponse.json( - { error: 'Failed to fetch slideshow photos' }, - { status: 500 } - ); - } -} + return successResponse({ + images: sortedBlobs.map((blob) => ({ + url: blob.url, + filename: blob.pathname.split('/').pop(), + size: blob.size, + uploadedAt: blob.uploadedAt, + })), + }); +}); diff --git a/app/api/user/create-after-terms/route.ts b/app/api/user/create-after-terms/route.ts index 92223a2044c..375a037a755 100644 --- a/app/api/user/create-after-terms/route.ts +++ b/app/api/user/create-after-terms/route.ts @@ -1,26 +1,20 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { getServerSession } from 'next-auth'; -import { AuthOptions } from '@/lib/auth/authOptions'; +import { z } from 'zod'; +import { withApi, successResponse } from '@/lib/api'; import { prisma } from '@/prisma/prisma'; import { syncUserDataToHubSpot } from '@/server/services/hubspotUserData'; +const CreateAfterTermsSchema = z.object({ + notifications: z.boolean().optional().default(false), +}); + /** - * API endpoint to create a new user after they accept terms. - * This is called when a user verifies their email via OTP but hasn't been + * Create a new user after they accept terms. + * Called when a user verifies their email via OTP but hasn't been * created in the database yet (to avoid creating accounts for users who * don't accept terms). */ -export async function POST(req: NextRequest) { - try { - const session = await getServerSession(AuthOptions); - - if (!session?.user?.email) { - return NextResponse.json( - { error: 'Unauthorized: No valid session' }, - { status: 401 } - ); - } - +export const POST = withApi>( + async (_req, { session, body }) => { const email = session.user.email; // Check if user already exists (shouldn't happen, but safety check) @@ -29,17 +23,14 @@ export async function POST(req: NextRequest) { }); if (existingUser) { - // User already exists, just return their data - return NextResponse.json({ + return successResponse({ id: existingUser.id, email: existingUser.email, alreadyExists: true, }); } - // Get the terms acceptance data from the request body - const body = await req.json(); - const { notifications = false } = body; + const { notifications } = body; // Create the new user const newUser = await prisma.user.create({ @@ -50,7 +41,7 @@ export async function POST(req: NextRequest) { image: '', authentication_mode: 'credentials', last_login: new Date(), - notifications: notifications, + notifications, }, }); @@ -63,22 +54,16 @@ export async function POST(req: NextRequest) { notifications: newUser.notifications ?? undefined, gdpr: true, // User accepted terms and conditions }); - } catch (error) { - console.error('[HubSpot UserData] Failed to sync new user:', error); + } catch { // Don't block user creation if HubSpot sync fails } } - return NextResponse.json({ + return successResponse({ id: newUser.id, email: newUser.email, created: true, }); - } catch (error) { - console.error('Error creating user after terms:', error); - return NextResponse.json( - { error: 'Failed to create user' }, - { status: 500 } - ); - } -} + }, + { auth: true, schema: CreateAfterTermsSchema }, +); diff --git a/app/api/user/noun-avatar/generate-seed/route.ts b/app/api/user/noun-avatar/generate-seed/route.ts index 3fa5be84420..fe09dd6706c 100644 --- a/app/api/user/noun-avatar/generate-seed/route.ts +++ b/app/api/user/noun-avatar/generate-seed/route.ts @@ -1,3 +1,4 @@ +// withApi: not applicable — uses withAuth() for session-based auth import { NextRequest, NextResponse } from 'next/server'; import { withAuth } from '@/lib/protectedRoute'; import { keccak_256 } from '@noble/hashes/sha3'; @@ -25,80 +26,40 @@ function generateAvatarSeed(identifier: string, random: boolean = false): Avatar if (random) { // Generate random seed return { - backgroundColor: AVATAR_OPTIONS.backgroundColor[ - Math.floor(Math.random() * AVATAR_OPTIONS.backgroundColor.length) - ], - hair: AVATAR_OPTIONS.hair[ - Math.floor(Math.random() * AVATAR_OPTIONS.hair.length) - ], - eyes: AVATAR_OPTIONS.eyes[ - Math.floor(Math.random() * AVATAR_OPTIONS.eyes.length) - ], - eyebrows: AVATAR_OPTIONS.eyebrows[ - Math.floor(Math.random() * AVATAR_OPTIONS.eyebrows.length) - ], - nose: AVATAR_OPTIONS.nose[ - Math.floor(Math.random() * AVATAR_OPTIONS.nose.length) - ], - mouth: AVATAR_OPTIONS.mouth[ - Math.floor(Math.random() * AVATAR_OPTIONS.mouth.length) - ], - glasses: AVATAR_OPTIONS.glasses[ - Math.floor(Math.random() * AVATAR_OPTIONS.glasses.length) - ], - earrings: AVATAR_OPTIONS.earrings[ - Math.floor(Math.random() * AVATAR_OPTIONS.earrings.length) - ], - beard: AVATAR_OPTIONS.beard[ - Math.floor(Math.random() * AVATAR_OPTIONS.beard.length) - ], - hairAccessories: AVATAR_OPTIONS.hairAccessories[ - Math.floor(Math.random() * AVATAR_OPTIONS.hairAccessories.length) - ], - freckles: AVATAR_OPTIONS.freckles[ - Math.floor(Math.random() * AVATAR_OPTIONS.freckles.length) - ], + backgroundColor: + AVATAR_OPTIONS.backgroundColor[Math.floor(Math.random() * AVATAR_OPTIONS.backgroundColor.length)], + hair: AVATAR_OPTIONS.hair[Math.floor(Math.random() * AVATAR_OPTIONS.hair.length)], + eyes: AVATAR_OPTIONS.eyes[Math.floor(Math.random() * AVATAR_OPTIONS.eyes.length)], + eyebrows: AVATAR_OPTIONS.eyebrows[Math.floor(Math.random() * AVATAR_OPTIONS.eyebrows.length)], + nose: AVATAR_OPTIONS.nose[Math.floor(Math.random() * AVATAR_OPTIONS.nose.length)], + mouth: AVATAR_OPTIONS.mouth[Math.floor(Math.random() * AVATAR_OPTIONS.mouth.length)], + glasses: AVATAR_OPTIONS.glasses[Math.floor(Math.random() * AVATAR_OPTIONS.glasses.length)], + earrings: AVATAR_OPTIONS.earrings[Math.floor(Math.random() * AVATAR_OPTIONS.earrings.length)], + beard: AVATAR_OPTIONS.beard[Math.floor(Math.random() * AVATAR_OPTIONS.beard.length)], + hairAccessories: + AVATAR_OPTIONS.hairAccessories[Math.floor(Math.random() * AVATAR_OPTIONS.hairAccessories.length)], + freckles: AVATAR_OPTIONS.freckles[Math.floor(Math.random() * AVATAR_OPTIONS.freckles.length)], }; } else { // Generate deterministic seed from identifier const encoder = new TextEncoder(); const hash = keccak_256(encoder.encode(identifier)); const hashHex = bytesToHex(hash); - + // Use different parts of the hash for each trait - const backgroundColor = AVATAR_OPTIONS.backgroundColor[ - parseInt(hashHex.slice(0, 2), 16) % AVATAR_OPTIONS.backgroundColor.length - ]; - const hair = AVATAR_OPTIONS.hair[ - parseInt(hashHex.slice(2, 4), 16) % AVATAR_OPTIONS.hair.length - ]; - const eyes = AVATAR_OPTIONS.eyes[ - parseInt(hashHex.slice(4, 6), 16) % AVATAR_OPTIONS.eyes.length - ]; - const eyebrows = AVATAR_OPTIONS.eyebrows[ - parseInt(hashHex.slice(6, 8), 16) % AVATAR_OPTIONS.eyebrows.length - ]; - const nose = AVATAR_OPTIONS.nose[ - parseInt(hashHex.slice(8, 10), 16) % AVATAR_OPTIONS.nose.length - ]; - const mouth = AVATAR_OPTIONS.mouth[ - parseInt(hashHex.slice(10, 12), 16) % AVATAR_OPTIONS.mouth.length - ]; - const glasses = AVATAR_OPTIONS.glasses[ - parseInt(hashHex.slice(12, 14), 16) % AVATAR_OPTIONS.glasses.length - ]; - const earrings = AVATAR_OPTIONS.earrings[ - parseInt(hashHex.slice(14, 16), 16) % AVATAR_OPTIONS.earrings.length - ]; - const beard = AVATAR_OPTIONS.beard[ - parseInt(hashHex.slice(16, 18), 16) % AVATAR_OPTIONS.beard.length - ]; - const hairAccessories = AVATAR_OPTIONS.hairAccessories[ - parseInt(hashHex.slice(18, 20), 16) % AVATAR_OPTIONS.hairAccessories.length - ]; - const freckles = AVATAR_OPTIONS.freckles[ - parseInt(hashHex.slice(20, 22), 16) % AVATAR_OPTIONS.freckles.length - ]; + const backgroundColor = + AVATAR_OPTIONS.backgroundColor[parseInt(hashHex.slice(0, 2), 16) % AVATAR_OPTIONS.backgroundColor.length]; + const hair = AVATAR_OPTIONS.hair[parseInt(hashHex.slice(2, 4), 16) % AVATAR_OPTIONS.hair.length]; + const eyes = AVATAR_OPTIONS.eyes[parseInt(hashHex.slice(4, 6), 16) % AVATAR_OPTIONS.eyes.length]; + const eyebrows = AVATAR_OPTIONS.eyebrows[parseInt(hashHex.slice(6, 8), 16) % AVATAR_OPTIONS.eyebrows.length]; + const nose = AVATAR_OPTIONS.nose[parseInt(hashHex.slice(8, 10), 16) % AVATAR_OPTIONS.nose.length]; + const mouth = AVATAR_OPTIONS.mouth[parseInt(hashHex.slice(10, 12), 16) % AVATAR_OPTIONS.mouth.length]; + const glasses = AVATAR_OPTIONS.glasses[parseInt(hashHex.slice(12, 14), 16) % AVATAR_OPTIONS.glasses.length]; + const earrings = AVATAR_OPTIONS.earrings[parseInt(hashHex.slice(14, 16), 16) % AVATAR_OPTIONS.earrings.length]; + const beard = AVATAR_OPTIONS.beard[parseInt(hashHex.slice(16, 18), 16) % AVATAR_OPTIONS.beard.length]; + const hairAccessories = + AVATAR_OPTIONS.hairAccessories[parseInt(hashHex.slice(18, 20), 16) % AVATAR_OPTIONS.hairAccessories.length]; + const freckles = AVATAR_OPTIONS.freckles[parseInt(hashHex.slice(20, 22), 16) % AVATAR_OPTIONS.freckles.length]; return { backgroundColor, @@ -116,21 +77,14 @@ function generateAvatarSeed(identifier: string, random: boolean = false): Avatar } } -export const GET = withAuth(async ( - req: NextRequest, - context: any, - session: any -) => { +export const GET = withAuth(async (req: NextRequest, context: any, session: any) => { try { const { searchParams } = new URL(req.url); const deterministic = searchParams.get('deterministic') === 'true'; - + const userId = session.user.id; if (!userId) { - return NextResponse.json( - { error: 'User ID is required' }, - { status: 400 } - ); + return NextResponse.json({ error: 'User ID is required' }, { status: 400 }); } // Generate seed (random or deterministic based on user ID) @@ -140,12 +94,11 @@ export const GET = withAuth(async ( } catch (error) { console.error('Error generating Noun seed:', error); return NextResponse.json( - { + { error: 'Failed to generate seed', - details: error instanceof Error ? error.message : 'Unknown error' + details: error instanceof Error ? error.message : 'Unknown error', }, - { status: 500 } + { status: 500 }, ); } }); - diff --git a/app/api/user/noun-avatar/route.ts b/app/api/user/noun-avatar/route.ts index ab432e04126..c0c7c06782f 100644 --- a/app/api/user/noun-avatar/route.ts +++ b/app/api/user/noun-avatar/route.ts @@ -1,3 +1,4 @@ +// withApi: not applicable — uses withAuth() for session-based auth import { NextRequest, NextResponse } from 'next/server'; import { withAuth } from '@/lib/protectedRoute'; import { prisma } from '@/prisma/prisma'; @@ -20,28 +21,18 @@ interface AvatarSeed { * PUT /api/user/noun-avatar * Update user's Noun avatar seed and enabled status */ -export const PUT = withAuth(async ( - req: NextRequest, - context: any, - session: any -) => { +export const PUT = withAuth(async (req: NextRequest, context: any, session: any) => { try { const userId = session.user.id; if (!userId) { - return NextResponse.json( - { error: 'User ID is required' }, - { status: 400 } - ); + return NextResponse.json({ error: 'User ID is required' }, { status: 400 }); } const body = await req.json(); const { seed, enabled } = body as { seed: AvatarSeed; enabled: boolean }; if (!seed) { - return NextResponse.json( - { error: 'Seed is required' }, - { status: 400 } - ); + return NextResponse.json({ error: 'Seed is required' }, { status: 400 }); } // Validate seed structure @@ -58,10 +49,7 @@ export const PUT = withAuth(async ( typeof seed.hairAccessories !== 'string' || typeof seed.freckles !== 'string' ) { - return NextResponse.json( - { error: 'Invalid seed structure' }, - { status: 400 } - ); + return NextResponse.json({ error: 'Invalid seed structure' }, { status: 400 }); } // Update user with noun avatar data @@ -85,11 +73,11 @@ export const PUT = withAuth(async ( } catch (error) { console.error('Error updating Noun avatar:', error); return NextResponse.json( - { + { error: 'Failed to update avatar', - details: error instanceof Error ? error.message : 'Unknown error' + details: error instanceof Error ? error.message : 'Unknown error', }, - { status: 500 } + { status: 500 }, ); } }); @@ -98,18 +86,11 @@ export const PUT = withAuth(async ( * GET /api/user/noun-avatar * Get user's current Noun avatar seed and enabled status */ -export const GET = withAuth(async ( - req: NextRequest, - context: any, - session: any -) => { +export const GET = withAuth(async (req: NextRequest, context: any, session: any) => { try { const userId = session.user.id; if (!userId) { - return NextResponse.json( - { error: 'User ID is required' }, - { status: 400 } - ); + return NextResponse.json({ error: 'User ID is required' }, { status: 400 }); } const user = await prisma.user.findUnique({ @@ -121,10 +102,7 @@ export const GET = withAuth(async ( }); if (!user) { - return NextResponse.json( - { error: 'User not found' }, - { status: 404 } - ); + return NextResponse.json({ error: 'User not found' }, { status: 404 }); } return NextResponse.json({ @@ -134,12 +112,11 @@ export const GET = withAuth(async ( } catch (error) { console.error('Error fetching Noun avatar:', error); return NextResponse.json( - { + { error: 'Failed to fetch avatar', - details: error instanceof Error ? error.message : 'Unknown error' + details: error instanceof Error ? error.message : 'Unknown error', }, - { status: 500 } + { status: 500 }, ); } }); - diff --git a/app/api/users/check/route.ts b/app/api/users/check/route.ts index 61de361efa6..32127432d28 100644 --- a/app/api/users/check/route.ts +++ b/app/api/users/check/route.ts @@ -1,20 +1,16 @@ -import { NextResponse } from 'next/server'; +import { z } from 'zod'; +import { withApi, successResponse, validateQuery } from '@/lib/api'; import { getUserByEmail } from '@/server/services/getUser'; -import { withAuth } from '@/lib/protectedRoute'; +const CheckUserQuery = z.object({ + email: z.string().email('Valid email is required'), +}); -export const GET = withAuth(async (request: Request) => { - const { searchParams } = new URL(request.url); - const email = searchParams.get('email'); - if (!email) { - return NextResponse.json({ error: 'Email parameter is required' }, { status: 400 }); - } - - try { +export const GET = withApi( + async (req) => { + const { email } = validateQuery(req, CheckUserQuery); const user = await getUserByEmail(email); - return NextResponse.json({ exists: !!user }); - } catch (error) { - console.error("Error checking user by email:", error); - return NextResponse.json({ error: "Internal server error" }, { status: 500 }); - } -}); + return successResponse({ exists: !!user }); + }, + { auth: true }, +); diff --git a/app/api/validate-jwt-token/route.ts b/app/api/validate-jwt-token/route.ts index f1fa31c3747..a5c13521a3f 100644 --- a/app/api/validate-jwt-token/route.ts +++ b/app/api/validate-jwt-token/route.ts @@ -1,54 +1,30 @@ -import { NextRequest, NextResponse } from "next/server"; -import {decode} from "next-auth/jwt" -import jwt, { JwtPayload } from "jsonwebtoken"; +import { decode } from 'next-auth/jwt'; +import { withApi, successResponse, AuthError, InternalError } from '@/lib/api'; const JWT_SECRET: string | undefined = process.env.NEXTAUTH_SECRET; -export async function POST(req: NextRequest): Promise { - try { - if (!JWT_SECRET) { - return NextResponse.json( - { valid: false, error: "Error at validate token" }, - { status: 500 } - ); - } - - const authHeader: string | null = req.headers.get("authorization"); - if (!authHeader) { - return NextResponse.json( - { valid: false, error: "Missing authorization header" }, - { status: 401 } - ); - } - - const token: string = authHeader; +// withApi: auth intentionally omitted — pre-authentication endpoint +// schema: not applicable — token passed via Authorization header, not body +export const POST = withApi(async (req) => { + if (!JWT_SECRET) { + throw new InternalError('Token validation unavailable'); + } - const decoded: string | JwtPayload = await decode({token, secret: JWT_SECRET}) ?? {}; + const authHeader = req.headers.get('authorization'); + if (!authHeader) { + throw new AuthError('Missing authorization header'); + } - if (typeof decoded !== "object" || decoded === null) { - return NextResponse.json( - { valid: false, error: "Invalid token payload" }, - { status: 401 } - ); - } + const decoded = (await decode({ token: authHeader, secret: JWT_SECRET })) ?? {}; - const sub: unknown = decoded.sub; - if (typeof sub !== "string" || sub.length === 0) { - return NextResponse.json( - { valid: false, error: "Token missing sub" }, - { status: 401 } - ); - } + if (typeof decoded !== 'object' || decoded === null) { + throw new AuthError('Invalid token payload'); + } - return NextResponse.json({ - valid: true, - sub, - payload: decoded, - }); - } catch (err: unknown) { - return NextResponse.json( - { valid: false, error: "Invalid token", details: String(err) }, - { status: 401 } - ); + const sub = (decoded as Record).sub; + if (typeof sub !== 'string' || sub.length === 0) { + throw new AuthError('Token missing sub'); } -} \ No newline at end of file + + return successResponse({ valid: true, sub, payload: decoded }); +}); diff --git a/app/api/validator-alerts/[id]/route.ts b/app/api/validator-alerts/[id]/route.ts index c03e331c4b6..7f5a381b646 100644 --- a/app/api/validator-alerts/[id]/route.ts +++ b/app/api/validator-alerts/[id]/route.ts @@ -1,120 +1,102 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { getAuthSession } from '@/lib/auth/authSession'; +// schema: not applicable — Zod validation done inline via safeParse for custom error handling +import { z } from 'zod'; +import { withApi, ValidationError, BadRequestError, noContentResponse, successResponse } from '@/lib/api'; +import { EMAIL_REGEX } from '@/lib/api/constants'; +import { assertOwnership } from '@/lib/api/ownership'; import { prisma } from '@/prisma/prisma'; import type { UpdateAlertRequest } from '@/types/validator-alerts'; -const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - -async function getOwnedAlert(alertId: string, userId: string) { - return prisma.validatorAlert.findFirst({ - where: { id: alertId, user_id: userId }, - include: { - alert_logs: { - orderBy: { sent_at: 'desc' }, - take: 20, - }, - }, - }); -} - -export async function GET( - _req: NextRequest, - { params }: { params: Promise<{ id: string }> } -) { - try { - const session = await getAuthSession(); - if (!session?.user) { - return NextResponse.json({ error: 'Unauthorized, please sign in to continue.' }, { status: 401 }); - } - - const { id } = await params; - const alert = await getOwnedAlert(id, session.user.id); - if (!alert) { - return NextResponse.json({ error: 'Alert not found.' }, { status: 404 }); - } - - return NextResponse.json(alert); - } catch (error) { - console.error('Error fetching validator alert:', error); - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); +const idSchema = z.object({ + id: z.string().uuid('Invalid alert ID'), +}); + +const updateAlertSchema = z.object({ + label: z.string().optional(), + uptime_alert: z.boolean().optional(), + uptime_threshold: z.number().min(0).max(100, 'Uptime threshold must be between 0 and 100.').optional(), + version_alert: z.boolean().optional(), + expiry_alert: z.boolean().optional(), + expiry_days: z.number().int().min(1).max(365, 'Expiry days must be between 1 and 365.').optional(), + balance_alert: z.boolean().optional(), + balance_threshold: z.number().positive('Balance threshold must be greater than 0.').finite().optional(), + balance_threshold_days: z + .number() + .int() + .min(1) + .max(365, 'Balance threshold days must be between 1 and 365.') + .optional(), + security_alert: z.boolean().optional(), + email: z.string().regex(EMAIL_REGEX, 'Invalid email address.').optional(), + active: z.boolean().optional(), +}); + +function validateIdParam(params: Record): string { + const parsed = idSchema.safeParse(params); + if (!parsed.success) { + throw new ValidationError(parsed.error.issues.map((i) => i.message).join('; ')); } + return parsed.data.id; } -export async function PUT( - req: NextRequest, - { params }: { params: Promise<{ id: string }> } -) { - try { - const session = await getAuthSession(); - if (!session?.user) { - return NextResponse.json({ error: 'Unauthorized, please sign in to continue.' }, { status: 401 }); - } +export const GET = withApi( + async (_req, { session, params }) => { + const id = validateIdParam(params); + const alert = await assertOwnership(prisma.validatorAlert, id, session.user.id, { + include: { alert_logs: { orderBy: { sent_at: 'desc' }, take: 20 } }, + }); - const { id } = await params; - const existing = await getOwnedAlert(id, session.user.id); - if (!existing) { - return NextResponse.json({ error: 'Alert not found.' }, { status: 404 }); - } + return successResponse(alert); + }, + { auth: true }, +); - const body: UpdateAlertRequest = await req.json(); +export const PUT = withApi( + async (req, { session, params }) => { + const id = validateIdParam(params); - const updateData: Record = {}; - if (body.label !== undefined) updateData.label = body.label; - if (body.uptime_alert !== undefined) updateData.uptime_alert = body.uptime_alert; - if (body.uptime_threshold !== undefined) { - if (body.uptime_threshold < 0 || body.uptime_threshold > 100) { - return NextResponse.json({ error: 'Uptime threshold must be between 0 and 100.' }, { status: 400 }); - } - updateData.uptime_threshold = body.uptime_threshold; + // Validate body BEFORE ownership check so invalid input is caught early + const raw: unknown = await req.json(); + const parsed = updateAlertSchema.safeParse(raw); + if (!parsed.success) { + throw new ValidationError(parsed.error.issues.map((i) => i.message).join('; ')); } - if (body.version_alert !== undefined) updateData.version_alert = body.version_alert; + const body: UpdateAlertRequest = parsed.data; + + const existing = await assertOwnership<{ subnet_id: string }>(prisma.validatorAlert, id, session.user.id, { + include: { alert_logs: { orderBy: { sent_at: 'desc' }, take: 20 } }, + }); const isL1 = existing.subnet_id !== 'primary'; - // L1 validators don't have uptime or expiry — reject attempts to enable + // L1 validators don't have uptime or expiry -- reject attempts to enable if (isL1 && body.uptime_alert === true) { - return NextResponse.json({ error: 'Uptime alerts are not available for L1 validators.' }, { status: 400 }); + throw new BadRequestError('Uptime alerts are not available for L1 validators.'); } if (isL1 && body.expiry_alert === true) { - return NextResponse.json({ error: 'Stake expiry alerts are not available for L1 validators.' }, { status: 400 }); + throw new BadRequestError('Stake expiry alerts are not available for L1 validators.'); } if (!isL1 && body.balance_alert === true) { - return NextResponse.json({ error: 'Balance alerts are only available for L1 validators.' }, { status: 400 }); + throw new BadRequestError('Balance alerts are only available for L1 validators.'); } if (!isL1 && (body.balance_threshold !== undefined || body.balance_threshold_days !== undefined)) { - return NextResponse.json({ error: 'Balance threshold settings are only available for L1 validators.' }, { status: 400 }); + throw new BadRequestError('Balance threshold settings are only available for L1 validators.'); } if (isL1 && body.security_alert === true) { - return NextResponse.json({ error: 'Security checks are currently available for Primary Network validators only.' }, { status: 400 }); + throw new BadRequestError('Security checks are currently available for Primary Network validators only.'); } + const updateData: Record = {}; + if (body.label !== undefined) updateData.label = body.label; + if (body.uptime_alert !== undefined) updateData.uptime_alert = body.uptime_alert; + if (body.uptime_threshold !== undefined) updateData.uptime_threshold = body.uptime_threshold; + if (body.version_alert !== undefined) updateData.version_alert = body.version_alert; if (body.expiry_alert !== undefined) updateData.expiry_alert = body.expiry_alert; - if (body.expiry_days !== undefined) { - if (body.expiry_days < 1 || body.expiry_days > 365) { - return NextResponse.json({ error: 'Expiry days must be between 1 and 365.' }, { status: 400 }); - } - updateData.expiry_days = body.expiry_days; - } + if (body.expiry_days !== undefined) updateData.expiry_days = body.expiry_days; if (body.balance_alert !== undefined) updateData.balance_alert = body.balance_alert; - if (body.balance_threshold !== undefined) { - if (!Number.isFinite(body.balance_threshold) || body.balance_threshold <= 0) { - return NextResponse.json({ error: 'Balance threshold must be greater than 0.' }, { status: 400 }); - } - updateData.balance_threshold = body.balance_threshold; - } - if (body.balance_threshold_days !== undefined) { - if (!Number.isInteger(body.balance_threshold_days) || body.balance_threshold_days < 1 || body.balance_threshold_days > 365) { - return NextResponse.json({ error: 'Balance threshold days must be between 1 and 365.' }, { status: 400 }); - } - updateData.balance_threshold_days = body.balance_threshold_days; - } + if (body.balance_threshold !== undefined) updateData.balance_threshold = body.balance_threshold; + if (body.balance_threshold_days !== undefined) updateData.balance_threshold_days = body.balance_threshold_days; if (body.security_alert !== undefined) updateData.security_alert = body.security_alert; - if (body.email !== undefined) { - if (!EMAIL_REGEX.test(body.email)) { - return NextResponse.json({ error: 'Invalid email address.' }, { status: 400 }); - } - updateData.email = body.email; - } + if (body.email !== undefined) updateData.email = body.email; if (body.active !== undefined) updateData.active = body.active; const alert = await prisma.validatorAlert.update({ @@ -128,34 +110,19 @@ export async function PUT( }, }); - return NextResponse.json(alert); - } catch (error) { - console.error('Error updating validator alert:', error); - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); - } -} - -export async function DELETE( - _req: NextRequest, - { params }: { params: Promise<{ id: string }> } -) { - try { - const session = await getAuthSession(); - if (!session?.user) { - return NextResponse.json({ error: 'Unauthorized, please sign in to continue.' }, { status: 401 }); - } + return successResponse(alert); + }, + { auth: true }, +); - const { id } = await params; - const existing = await getOwnedAlert(id, session.user.id); - if (!existing) { - return NextResponse.json({ error: 'Alert not found.' }, { status: 404 }); - } +export const DELETE = withApi( + async (_req, { session, params }) => { + const id = validateIdParam(params); + await assertOwnership(prisma.validatorAlert, id, session.user.id); await prisma.validatorAlert.delete({ where: { id } }); - return NextResponse.json({ success: true }); - } catch (error) { - console.error('Error deleting validator alert:', error); - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); - } -} + return noContentResponse(); + }, + { auth: true }, +); diff --git a/app/api/validator-alerts/check/route.ts b/app/api/validator-alerts/check/route.ts index e6be0d261f5..723f6fc4d56 100644 --- a/app/api/validator-alerts/check/route.ts +++ b/app/api/validator-alerts/check/route.ts @@ -9,16 +9,16 @@ import { } from '@/server/services/validator-alert-check'; import type { L1ValidatorData } from '@/types/validator-alerts'; +/** + * Vercel CRON endpoint -- kept raw (no withApi) because it uses + * custom CRON_SECRET / API-key auth rather than session auth. + */ +// withApi: not applicable — Vercel cron with CRON_SECRET auth export async function POST(req: NextRequest) { - // Authenticate: accept Vercel CRON_SECRET or custom API key const authHeader = req.headers.get('authorization'); const apiKey = req.headers.get('x-api-key'); - const isVercelCron = - process.env.CRON_SECRET && - authHeader === `Bearer ${process.env.CRON_SECRET}`; - const isApiKey = - process.env.VALIDATOR_ALERTS_API_KEY && - apiKey === process.env.VALIDATOR_ALERTS_API_KEY; + const isVercelCron = process.env.CRON_SECRET && authHeader === `Bearer ${process.env.CRON_SECRET}`; + const isApiKey = process.env.VALIDATOR_ALERTS_API_KEY && apiKey === process.env.VALIDATOR_ALERTS_API_KEY; if (!isVercelCron && !isApiKey) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); @@ -27,21 +27,24 @@ export async function POST(req: NextRequest) { try { const dataErrors: string[] = []; - // Fetch data sources independently so one failure doesn't block the other - const [validatorResult, releaseResult] = await Promise.allSettled([ - fetchValidators(), - fetchLatestRelease(), - ]); + const [validatorResult, releaseResult] = await Promise.allSettled([fetchValidators(), fetchLatestRelease()]); - const validators = validatorResult.status === 'fulfilled' - ? validatorResult.value - : (() => { dataErrors.push(`P2P API error: ${validatorResult.reason}`); return []; })(); + const validators = + validatorResult.status === 'fulfilled' + ? validatorResult.value + : (() => { + dataErrors.push(`P2P API error: ${validatorResult.reason}`); + return []; + })(); - const latestRelease = releaseResult.status === 'fulfilled' - ? releaseResult.value - : (() => { dataErrors.push(`GitHub API error: ${releaseResult.reason}`); return null; })(); + const latestRelease = + releaseResult.status === 'fulfilled' + ? releaseResult.value + : (() => { + dataErrors.push(`GitHub API error: ${releaseResult.reason}`); + return null; + })(); - // If both sources failed, log to all active alerts (with 1-hour cooldown) and bail if (validators.length === 0 && !latestRelease) { const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000); const recentFailLog = await prisma.validatorAlertLog.findFirst({ @@ -55,20 +58,19 @@ export async function POST(req: NextRequest) { activeAlerts.map((a) => prisma.validatorAlertLog.create({ data: { validator_alert_id: a.id, alert_type: 'check_failed', message: errorMsg }, - }) - ) + }), + ), ); } return NextResponse.json({ success: false, errors: dataErrors }); } - const validatorMap = new Map(validators.map(v => [v.node_id, v])); + const validatorMap = new Map(validators.map((v) => [v.node_id, v])); const activeAlerts = await prisma.validatorAlert.findMany({ where: { active: true }, }); - // Partition alerts into primary vs L1 const primaryAlerts = activeAlerts.filter((a) => a.subnet_id === 'primary'); const l1AlertsBySubnet = new Map(); for (const alert of activeAlerts) { @@ -84,7 +86,6 @@ export async function POST(req: NextRequest) { let skipped = 0; const errors: string[] = []; - // --- Process Primary Network alerts --- for (const alert of primaryAlerts) { checked++; const validator = validatorMap.get(alert.node_id); @@ -97,7 +98,6 @@ export async function POST(req: NextRequest) { errors.push(...result.errors); } - // --- Process L1 alerts (grouped by subnet) --- for (const [subnetId, alerts] of l1AlertsBySubnet) { let l1Validators: L1ValidatorData[] = []; try { @@ -128,18 +128,16 @@ export async function POST(req: NextRequest) { checked, sent, skipped, - release: latestRelease ? { - tag: latestRelease.tag, - type: latestRelease.type, - deadline: latestRelease.deadline?.toISOString() ?? null, - } : null, + release: latestRelease + ? { + tag: latestRelease.tag, + type: latestRelease.type, + deadline: latestRelease.deadline?.toISOString() ?? null, + } + : null, errors: [...dataErrors, ...errors].length > 0 ? [...dataErrors, ...errors] : undefined, }); - } catch (error) { - console.error('Error running validator alert check:', error); - return NextResponse.json( - { error: 'Internal server error' }, - { status: 500 } - ); + } catch { + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); } } diff --git a/app/api/validator-alerts/route.ts b/app/api/validator-alerts/route.ts index d0c2ce24558..f6033ebf9f1 100644 --- a/app/api/validator-alerts/route.ts +++ b/app/api/validator-alerts/route.ts @@ -1,7 +1,16 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { getAuthSession } from '@/lib/auth/authSession'; +import { z } from 'zod'; +import { + withApi, + ValidationError, + BadRequestError, + NotFoundError, + ConflictError, + RateLimitError, + successResponse, +} from '@/lib/api'; +import { EMAIL_REGEX, NODE_ID_REGEX } from '@/lib/api/constants'; import { prisma } from '@/prisma/prisma'; -import type { CreateAlertRequest, ValidatorP2P, L1ValidatorData } from '@/types/validator-alerts'; +import type { ValidatorP2P, L1ValidatorData } from '@/types/validator-alerts'; import { fetchLatestRelease, checkSingleAlert, @@ -11,19 +20,38 @@ import { } from '@/server/services/validator-alert-check'; import { getAllMainnetSubnetIds } from '@/server/services/l1-chain-metadata'; -const NODE_ID_REGEX = /^NodeID-[A-HJ-NP-Za-km-z1-9]{33,}$/; -const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const P2P_API_URL = 'https://52.203.183.9.sslip.io/api/validators'; const MAX_ALERTS_PER_USER = 20; const MAX_CREATES_PER_HOUR = 10; -export async function GET() { - try { - const session = await getAuthSession(); - if (!session?.user) { - return NextResponse.json({ error: 'Unauthorized, please sign in to continue.' }, { status: 401 }); - } +const createAlertSchema = z.object({ + node_id: z + .string() + .max(60) + .regex(NODE_ID_REGEX, 'Invalid NodeID format. Must start with "NodeID-" followed by a valid base58 string.'), + subnet_id: z.string().optional(), + label: z.string().optional(), + uptime_alert: z.boolean().optional(), + uptime_threshold: z.number().min(0).max(100, 'Uptime threshold must be between 0 and 100.').optional(), + version_alert: z.boolean().optional(), + expiry_alert: z.boolean().optional(), + expiry_days: z.number().int().min(1).max(365, 'Expiry days must be between 1 and 365.').optional(), + balance_alert: z.boolean().optional(), + balance_threshold: z.number().positive('Balance threshold must be greater than 0.').finite().optional(), + balance_threshold_days: z + .number() + .int() + .min(1) + .max(365, 'Balance threshold days must be between 1 and 365.') + .optional(), + security_alert: z.boolean().optional(), + email: z.string().regex(EMAIL_REGEX, 'Invalid email address.').optional(), +}); +type CreateAlertBody = z.infer; + +export const GET = withApi( + async (_req, { session }) => { const alerts = await prisma.validatorAlert.findMany({ where: { user_id: session.user.id }, include: { @@ -35,52 +63,19 @@ export async function GET() { orderBy: { created_at: 'desc' }, }); - return NextResponse.json(alerts); - } catch (error) { - console.error('Error fetching validator alerts:', error); - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); - } -} - -export async function POST(req: NextRequest) { - try { - const session = await getAuthSession(); - if (!session?.user) { - return NextResponse.json({ error: 'Unauthorized, please sign in to continue.' }, { status: 401 }); - } - - const body: CreateAlertRequest = await req.json(); - - if (!body.node_id || !NODE_ID_REGEX.test(body.node_id)) { - return NextResponse.json( - { error: 'Invalid NodeID format. Must start with "NodeID-" followed by a valid base58 string.' }, - { status: 400 } - ); - } - - // Validate optional numeric fields - if (body.uptime_threshold !== undefined && (body.uptime_threshold < 0 || body.uptime_threshold > 100)) { - return NextResponse.json({ error: 'Uptime threshold must be between 0 and 100.' }, { status: 400 }); - } - if (body.expiry_days !== undefined && (body.expiry_days < 1 || body.expiry_days > 365)) { - return NextResponse.json({ error: 'Expiry days must be between 1 and 365.' }, { status: 400 }); - } - if (body.balance_threshold !== undefined && (!Number.isFinite(body.balance_threshold) || body.balance_threshold <= 0)) { - return NextResponse.json({ error: 'Balance threshold must be greater than 0.' }, { status: 400 }); - } - if (body.balance_threshold_days !== undefined && (!Number.isInteger(body.balance_threshold_days) || body.balance_threshold_days < 1 || body.balance_threshold_days > 365)) { - return NextResponse.json({ error: 'Balance threshold days must be between 1 and 365.' }, { status: 400 }); - } - if (body.email !== undefined && !EMAIL_REGEX.test(body.email)) { - return NextResponse.json({ error: 'Invalid email address.' }, { status: 400 }); - } + return successResponse(alerts); + }, + { auth: true }, +); +export const POST = withApi( + async (req, { session, body }) => { const email = body.email ?? session.user.email; if (!email || !EMAIL_REGEX.test(email)) { - return NextResponse.json({ error: 'A valid email address is required.' }, { status: 400 }); + throw new ValidationError('A valid email address is required.'); } - // Verify the node exists — check Primary Network and/or L1 depending on request. + // Verify the node exists -- check Primary Network and/or L1 depending on request. let detectedSubnetId = 'primary'; let validators: ValidatorP2P[] = []; let primaryLookupAvailable = false; @@ -91,9 +86,10 @@ export async function POST(req: NextRequest) { primaryLookupAvailable = true; } - const isPrimaryValidator = primaryLookupAvailable && Array.isArray(validators) && validators.some( - (v: { node_id: string }) => v.node_id === body.node_id - ); + const isPrimaryValidator = + primaryLookupAvailable && + Array.isArray(validators) && + validators.some((v: { node_id: string }) => v.node_id === body.node_id); const preferredSubnetId = body.subnet_id?.trim(); const wantsPrimaryOnly = preferredSubnetId === 'primary'; const wantsSpecificL1 = preferredSubnetId && preferredSubnetId !== 'primary'; @@ -101,14 +97,13 @@ export async function POST(req: NextRequest) { if (wantsPrimaryOnly) { if (!primaryLookupAvailable) { - return NextResponse.json({ error: 'Primary Network validator lookup is currently unavailable. Please try again.' }, { status: 503 }); + throw new BadRequestError('Primary Network validator lookup is currently unavailable. Please try again.'); } if (!isPrimaryValidator) { - return NextResponse.json({ error: `Validator ${body.node_id} not found in the Primary Network active validator set.` }, { status: 404 }); + throw new NotFoundError(`Validator ${body.node_id} not found in the Primary Network active validator set.`); } detectedSubnetId = 'primary'; } else if (wantsSpecificL1 || !isPrimaryValidator) { - // Search L1(s) when caller requested an L1, or when validator was not found on Primary. const subnetIds = wantsSpecificL1 ? [preferredSubnetId] : getAllMainnetSubnetIds(); for (const subnetId of subnetIds) { @@ -116,9 +111,9 @@ export async function POST(req: NextRequest) { const l1Res = await fetch(`${req.nextUrl.origin}/api/chain-validators/${subnetId}`); if (!l1Res.ok) continue; const l1Data = await l1Res.json(); - const match = Array.isArray(l1Data.validators) && l1Data.validators.some( - (v: { nodeId: string }) => v.nodeId === body.node_id - ); + const match = + Array.isArray(l1Data.validators) && + l1Data.validators.some((v: { nodeId: string }) => v.nodeId === body.node_id); if (match) { detectedSubnetId = subnetId; foundOnL1 = true; @@ -131,21 +126,14 @@ export async function POST(req: NextRequest) { if (!foundOnL1) { if (wantsSpecificL1) { - return NextResponse.json( - { error: `Validator ${body.node_id} not found in L1 subnet ${preferredSubnetId}.` }, - { status: 404 } - ); + throw new NotFoundError(`Validator ${body.node_id} not found in L1 subnet ${preferredSubnetId}.`); } if (!primaryLookupAvailable) { - return NextResponse.json( - { error: 'Primary validator lookup is currently unavailable and the validator was not found on known L1s.' }, - { status: 503 } + throw new BadRequestError( + 'Primary validator lookup is currently unavailable and the validator was not found on known L1s.', ); } - return NextResponse.json( - { error: `Validator ${body.node_id} not found in the Primary Network or any known L1.` }, - { status: 404 } - ); + throw new NotFoundError(`Validator ${body.node_id} not found in the Primary Network or any known L1.`); } } else { detectedSubnetId = 'primary'; @@ -155,62 +143,69 @@ export async function POST(req: NextRequest) { const primaryValidator = validators.find((v: ValidatorP2P) => v.node_id === body.node_id) ?? null; if (!isL1 && body.balance_alert === true) { - return NextResponse.json({ error: 'Balance alerts are only available for L1 validators.' }, { status: 400 }); + throw new BadRequestError('Balance alerts are only available for L1 validators.'); } // Rate limiting + duplicate check + create in a serializable transaction - // to prevent concurrent requests from bypassing limits const userId = session.user.id; - const txResult = await prisma.$transaction(async (tx) => { - const existingCount = await tx.validatorAlert.count({ - where: { user_id: userId }, - }); - if (existingCount >= MAX_ALERTS_PER_USER) { - return { error: `You can have at most ${MAX_ALERTS_PER_USER} validator alerts.`, status: 429 }; - } + const txResult = await prisma.$transaction( + async (tx) => { + const existingCount = await tx.validatorAlert.count({ + where: { user_id: userId }, + }); + if (existingCount >= MAX_ALERTS_PER_USER) { + return { + error: `You can have at most ${MAX_ALERTS_PER_USER} validator alerts.`, + kind: 'rate_limit' as const, + }; + } - const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000); - const recentCreates = await tx.validatorAlert.count({ - where: { user_id: userId, created_at: { gte: oneHourAgo } }, - }); - if (recentCreates >= MAX_CREATES_PER_HOUR) { - return { error: 'Too many alerts created recently. Please try again later.', status: 429 }; - } + const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000); + const recentCreates = await tx.validatorAlert.count({ + where: { user_id: userId, created_at: { gte: oneHourAgo } }, + }); + if (recentCreates >= MAX_CREATES_PER_HOUR) { + return { error: 'Too many alerts created recently. Please try again later.', kind: 'rate_limit' as const }; + } - const existing = await tx.validatorAlert.findUnique({ - where: { user_id_node_id_subnet_id: { user_id: userId, node_id: body.node_id, subnet_id: detectedSubnetId } }, - }); - if (existing) { - return { error: 'You already have an alert configured for this validator.', status: 409 }; - } + const existing = await tx.validatorAlert.findUnique({ + where: { user_id_node_id_subnet_id: { user_id: userId, node_id: body.node_id, subnet_id: detectedSubnetId } }, + }); + if (existing) { + return { error: 'You already have an alert configured for this validator.', kind: 'conflict' as const }; + } - const alert = await tx.validatorAlert.create({ - data: { - user_id: userId, - node_id: body.node_id, - subnet_id: detectedSubnetId, - label: body.label ?? null, - // L1 validators don't have uptime or fixed expiry - uptime_alert: isL1 ? false : (body.uptime_alert ?? true), - uptime_threshold: body.uptime_threshold ?? 95, - version_alert: body.version_alert ?? true, - expiry_alert: isL1 ? false : (body.expiry_alert ?? true), - expiry_days: body.expiry_days ?? 7, - balance_alert: isL1 ? (body.balance_alert ?? true) : false, - balance_threshold: body.balance_threshold ?? 5_000_000_000, - balance_threshold_days: body.balance_threshold_days ?? 30, - security_alert: isL1 ? false : (body.security_alert ?? false), - last_known_ip: !isL1 ? (primaryValidator?.public_ip ?? null) : null, - email, - }, - include: { alert_logs: true }, - }); + const alert = await tx.validatorAlert.create({ + data: { + user_id: userId, + node_id: body.node_id, + subnet_id: detectedSubnetId, + label: body.label ?? null, + uptime_alert: isL1 ? false : (body.uptime_alert ?? true), + uptime_threshold: body.uptime_threshold ?? 95, + version_alert: body.version_alert ?? true, + expiry_alert: isL1 ? false : (body.expiry_alert ?? true), + expiry_days: body.expiry_days ?? 7, + balance_alert: isL1 ? (body.balance_alert ?? true) : false, + balance_threshold: body.balance_threshold ?? 5_000_000_000, + balance_threshold_days: body.balance_threshold_days ?? 30, + security_alert: isL1 ? false : (body.security_alert ?? false), + last_known_ip: !isL1 ? (primaryValidator?.public_ip ?? null) : null, + email, + }, + include: { alert_logs: true }, + }); - return { alert }; - }, { isolationLevel: 'Serializable' }); + return { alert }; + }, + { isolationLevel: 'Serializable' }, + ); if ('error' in txResult) { - return NextResponse.json({ error: txResult.error }, { status: txResult.status }); + if (txResult.kind === 'conflict') { + throw new ConflictError(txResult.error); + } + throw new RateLimitError(txResult.error); } // Run immediate checks + welcome email after creation. @@ -228,9 +223,8 @@ export async function POST(req: NextRequest) { await checkL1Alert(txResult.alert, l1ValidatorForWelcome, latestRelease); } } - } catch (err) { - // Non-fatal — the cron will catch it on the next run - console.error('Immediate alert check failed (non-fatal):', err); + } catch { + // Non-fatal -- the cron will catch it on the next run } try { @@ -245,8 +239,8 @@ export async function POST(req: NextRequest) { latestRelease, }); } - } catch (err) { - console.error('Welcome email send failed (non-fatal):', err); + } catch { + // Non-fatal } const responseAlert = await prisma.validatorAlert.findUnique({ @@ -259,9 +253,7 @@ export async function POST(req: NextRequest) { }, }); - return NextResponse.json(responseAlert ?? txResult.alert, { status: 201 }); - } catch (error) { - console.error('Error creating validator alert:', error); - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); - } -} + return successResponse(responseAlert ?? txResult.alert, 201); + }, + { auth: true, schema: createAlertSchema }, +); diff --git a/app/api/validator-alerts/unsubscribe/route.ts b/app/api/validator-alerts/unsubscribe/route.ts index 99d9f793573..5925cebc8cb 100644 --- a/app/api/validator-alerts/unsubscribe/route.ts +++ b/app/api/validator-alerts/unsubscribe/route.ts @@ -3,9 +3,13 @@ import { prisma } from '@/prisma/prisma'; import { verifyUnsubscribeToken } from '@/server/services/unsubscribe-token'; /** - * GET — render a confirmation page with a button. - * This is safe for mail scanner prefetch since it performs no state change. + * Unsubscribe endpoint -- kept raw (no withApi) because it renders + * HTML pages rather than JSON envelopes. */ + +// withApi: not applicable — returns HTML, not JSON +// withApi: auth intentionally omitted — public unsubscribe link +/** GET -- render a confirmation page with a button (safe for mail scanner prefetch). */ export async function GET(req: NextRequest) { const alertId = req.nextUrl.searchParams.get('id'); const token = req.nextUrl.searchParams.get('token'); @@ -25,11 +29,17 @@ export async function GET(req: NextRequest) { } if (!alert.active) { - return htmlResponse(200, 'Already Unsubscribed', `Alerts for validator ${alert.node_id} are already paused.`); + return htmlResponse( + 200, + 'Already Unsubscribed', + `Alerts for validator ${alert.node_id} are already paused.`, + ); } - // Render confirmation page with a form that POSTs - return htmlResponse(200, 'Unsubscribe from Validator Alerts', ` + return htmlResponse( + 200, + 'Unsubscribe from Validator Alerts', + `

Stop receiving email alerts for validator ${alert.node_id}?

@@ -39,16 +49,18 @@ export async function GET(req: NextRequest) {

Or manage your alerts from the dashboard.

- `); - } catch (error) { - console.error('Error rendering unsubscribe page:', error); - return htmlResponse(500, 'Error', 'Something went wrong. Please try again or manage your alerts from the dashboard.'); + `, + ); + } catch { + return htmlResponse( + 500, + 'Error', + 'Something went wrong. Please try again or manage your alerts from the dashboard.', + ); } } -/** - * POST — actually deactivate the alert. Requires the same signed token. - */ +/** POST -- actually deactivate the alert. Requires the same signed token. */ export async function POST(req: NextRequest) { const alertId = req.nextUrl.searchParams.get('id'); const token = req.nextUrl.searchParams.get('token'); @@ -68,7 +80,11 @@ export async function POST(req: NextRequest) { } if (!alert.active) { - return htmlResponse(200, 'Already Unsubscribed', `Alerts for validator ${alert.node_id} are already paused.`); + return htmlResponse( + 200, + 'Already Unsubscribed', + `Alerts for validator ${alert.node_id} are already paused.`, + ); } await prisma.validatorAlert.update({ @@ -76,12 +92,17 @@ export async function POST(req: NextRequest) { data: { active: false }, }); - return htmlResponse(200, 'Unsubscribed', - `Alerts for validator ${alert.node_id} have been paused. You can re-enable them from the Validator Alerts dashboard.` + return htmlResponse( + 200, + 'Unsubscribed', + `Alerts for validator ${alert.node_id} have been paused. You can re-enable them from the Validator Alerts dashboard.`, + ); + } catch { + return htmlResponse( + 500, + 'Error', + 'Something went wrong. Please try again or manage your alerts from the dashboard.', ); - } catch (error) { - console.error('Error processing unsubscribe:', error); - return htmlResponse(500, 'Error', 'Something went wrong. Please try again or manage your alerts from the dashboard.'); } } diff --git a/app/api/validator-details/[nodeId]/route.ts b/app/api/validator-details/[nodeId]/route.ts index 4bd79a86439..55537986718 100644 --- a/app/api/validator-details/[nodeId]/route.ts +++ b/app/api/validator-details/[nodeId]/route.ts @@ -1,12 +1,18 @@ -import { NextResponse } from 'next/server'; -import { Avalanche } from "@avalanche-sdk/chainkit"; -import { STATS_CONFIG } from "@/types/stats"; +import { z } from 'zod'; +import { Avalanche } from '@avalanche-sdk/chainkit'; +import { withApi, ValidationError, NotFoundError, successResponse } from '@/lib/api'; +import { NODE_ID_REGEX } from '@/lib/api/constants'; +import { STATS_CONFIG } from '@/types/stats'; export const dynamic = 'force-dynamic'; const FETCH_TIMEOUT = 15000; const CACHE_CONTROL_HEADER = 'public, max-age=3600, s-maxage=3600, stale-while-revalidate=86400'; +const nodeIdSchema = z.object({ + nodeId: z.string().max(60).regex(NODE_ID_REGEX, 'Invalid Node ID format. Expected format: NodeID-xxx'), +}); + interface ValidatorDetails { txHash: string; nodeId: string; @@ -57,196 +63,154 @@ const revalidatingKeys = new Set(); const pendingRequests = new Map>(); async function fetchValidatorDetails(nodeId: string): Promise { - const avalanche = new Avalanche({ network: "mainnet" }); - - try { - const result = await avalanche.data.primaryNetwork.getValidatorDetails({ - pageSize: 10, - nodeId: nodeId, - validationStatus: "active", - sortOrder: "desc", - }); - - for await (const page of result) { - const validators = page.result?.validators || []; - for (const v of validators) { - if (v.validationStatus === "active") { - return { - txHash: v.txHash || "", - nodeId: v.nodeId, - subnetId: v.subnetId || "11111111111111111111111111111111LpoYY", - amountStaked: v.amountStaked || "0", - delegationFee: v.delegationFee?.toString() || "0", - startTimestamp: v.startTimestamp || 0, - endTimestamp: v.endTimestamp || 0, - blsCredentials: v.blsCredentials ? { - publicKey: v.blsCredentials.publicKey || "", - proofOfPossession: v.blsCredentials.proofOfPossession || "", - } : undefined, - stakePercentage: v.stakePercentage || 0, - validatorHealth: v.validatorHealth ? { - reachabilityPercent: v.validatorHealth.reachabilityPercent || 0, - benchedPChainRequestsPercent: v.validatorHealth.benchedPChainRequestsPercent || 0, - benchedXChainRequestsPercent: v.validatorHealth.benchedXChainRequestsPercent || 0, - benchedCChainRequestsPercent: v.validatorHealth.benchedCChainRequestsPercent || 0, - } : undefined, - delegatorCount: v.delegatorCount || 0, - amountDelegated: v.amountDelegated || "0", - potentialRewards: v.potentialRewards ? { - validationRewardAmount: v.potentialRewards.validationRewardAmount || "0", - delegationRewardAmount: v.potentialRewards.delegationRewardAmount || "0", - rewardAddresses: v.potentialRewards.rewardAddresses || [], - } : undefined, - uptimePerformance: v.uptimePerformance || 0, - avalancheGoVersion: v.avalancheGoVersion, - delegationCapacity: v.delegationCapacity || "0", - validationStatus: v.validationStatus, - geolocation: v.geolocation ? { - city: v.geolocation.city || "", - country: v.geolocation.country || "", - countryCode: v.geolocation.countryCode || "", - latitude: v.geolocation.latitude || 0, - longitude: v.geolocation.longitude || 0, - } : undefined, - }; - } + const avalanche = new Avalanche({ network: 'mainnet' }); + + const result = await avalanche.data.primaryNetwork.getValidatorDetails({ + pageSize: 10, + nodeId: nodeId, + validationStatus: 'active', + sortOrder: 'desc', + }); + + for await (const page of result) { + const validators = page.result?.validators || []; + for (const v of validators) { + if (v.validationStatus === 'active') { + return { + txHash: v.txHash || '', + nodeId: v.nodeId, + subnetId: v.subnetId || '11111111111111111111111111111111LpoYY', + amountStaked: v.amountStaked || '0', + delegationFee: v.delegationFee?.toString() || '0', + startTimestamp: v.startTimestamp || 0, + endTimestamp: v.endTimestamp || 0, + blsCredentials: v.blsCredentials + ? { + publicKey: v.blsCredentials.publicKey || '', + proofOfPossession: v.blsCredentials.proofOfPossession || '', + } + : undefined, + stakePercentage: v.stakePercentage || 0, + validatorHealth: v.validatorHealth + ? { + reachabilityPercent: v.validatorHealth.reachabilityPercent || 0, + benchedPChainRequestsPercent: v.validatorHealth.benchedPChainRequestsPercent || 0, + benchedXChainRequestsPercent: v.validatorHealth.benchedXChainRequestsPercent || 0, + benchedCChainRequestsPercent: v.validatorHealth.benchedCChainRequestsPercent || 0, + } + : undefined, + delegatorCount: v.delegatorCount || 0, + amountDelegated: v.amountDelegated || '0', + potentialRewards: v.potentialRewards + ? { + validationRewardAmount: v.potentialRewards.validationRewardAmount || '0', + delegationRewardAmount: v.potentialRewards.delegationRewardAmount || '0', + rewardAddresses: v.potentialRewards.rewardAddresses || [], + } + : undefined, + uptimePerformance: v.uptimePerformance || 0, + avalancheGoVersion: v.avalancheGoVersion, + delegationCapacity: v.delegationCapacity || '0', + validationStatus: v.validationStatus, + geolocation: v.geolocation + ? { + city: v.geolocation.city || '', + country: v.geolocation.country || '', + countryCode: v.geolocation.countryCode || '', + latitude: v.geolocation.latitude || 0, + longitude: v.geolocation.longitude || 0, + } + : undefined, + }; } } - - return null; - } catch (error) { - console.error(`[fetchValidatorDetails] Error fetching details for ${nodeId}:`, error); - throw error; } + + return null; } async function fetchWithTimeout(nodeId: string): Promise { return Promise.race([ fetchValidatorDetails(nodeId), - new Promise((_, reject) => - setTimeout(() => reject(new Error('Request timeout')), FETCH_TIMEOUT) - ) + new Promise((_, reject) => + setTimeout(() => reject(new Error('Request timeout')), FETCH_TIMEOUT), + ), ]); } function createResponse( data: { validatorDetails: ValidatorDetails } | { error: string }, meta: { source: string; nodeId?: string; cacheAge?: number; fetchTime?: number }, - status = 200 + status = 200, ) { - const headers: Record = { - 'Cache-Control': CACHE_CONTROL_HEADER, - 'X-Data-Source': meta.source, - }; - if (meta.cacheAge !== undefined) headers['X-Cache-Age'] = `${Math.round(meta.cacheAge / 1000)}s`; - if (meta.fetchTime !== undefined) headers['X-Fetch-Time'] = `${meta.fetchTime}ms`; - - return NextResponse.json(data, { status, headers }); + const resp = successResponse(data, status); + resp.headers.set('Cache-Control', CACHE_CONTROL_HEADER); + resp.headers.set('X-Data-Source', meta.source); + if (meta.cacheAge !== undefined) resp.headers.set('X-Cache-Age', `${Math.round(meta.cacheAge / 1000)}s`); + if (meta.fetchTime !== undefined) resp.headers.set('X-Fetch-Time', `${meta.fetchTime}ms`); + return resp; } -export async function GET( - request: Request, - { params }: { params: Promise<{ nodeId: string }> } -) { - try { - const { nodeId } = await params; - const { searchParams } = new URL(request.url); - - if (!nodeId) { return createResponse({ error: 'Node ID is required' }, { source: 'error' }, 400) } - - if (!nodeId.startsWith('NodeID-')) { - return createResponse( - { error: 'Invalid Node ID format. Expected format: NodeID-xxx' }, - { source: 'error' }, - 400 - ); - } +export const GET = withApi(async (request, { params }) => { + const parsed = nodeIdSchema.safeParse(params); + if (!parsed.success) { + throw new ValidationError(parsed.error.issues.map((i) => i.message).join('; ')); + } + const { nodeId } = parsed.data; - if (searchParams.get('clearCache') === 'true') { - cachedData.clear(); - revalidatingKeys.clear(); - } + const { searchParams } = new URL(request.url); - const now = Date.now(); - const cached = cachedData.get(nodeId); - const cacheAge = cached ? now - cached.timestamp : Infinity; - const isCacheValid = cacheAge < STATS_CONFIG.CACHE.LONG_DURATION; - const isCacheStale = cached && !isCacheValid; - - // Stale-while-revalidate: serve stale data immediately, refresh in background - if (isCacheStale && !revalidatingKeys.has(nodeId)) { - revalidatingKeys.add(nodeId); - (async () => { - try { - const freshData = await fetchWithTimeout(nodeId); - if (freshData) { cachedData.set(nodeId, { data: freshData, timestamp: Date.now() }) } - } catch (error) { - console.error(`[GET /api/validator-details/${nodeId}] Background refresh failed:`, error); - } finally { - revalidatingKeys.delete(nodeId); + if (searchParams.get('clearCache') === 'true') { + cachedData.clear(); + revalidatingKeys.clear(); + } + + const now = Date.now(); + const cached = cachedData.get(nodeId); + const cacheAge = cached ? now - cached.timestamp : Infinity; + const isCacheValid = cacheAge < STATS_CONFIG.CACHE.LONG_DURATION; + const isCacheStale = cached && !isCacheValid; + + // Stale-while-revalidate: serve stale data immediately, refresh in background + if (isCacheStale && !revalidatingKeys.has(nodeId)) { + revalidatingKeys.add(nodeId); + (async () => { + try { + const freshData = await fetchWithTimeout(nodeId); + if (freshData) { + cachedData.set(nodeId, { data: freshData, timestamp: Date.now() }); } - })(); - return createResponse( - { validatorDetails: cached.data }, - { source: 'stale-while-revalidate', nodeId, cacheAge } - ); - } + } finally { + revalidatingKeys.delete(nodeId); + } + })(); + return createResponse({ validatorDetails: cached.data }, { source: 'stale-while-revalidate', nodeId, cacheAge }); + } - // Return valid cache - if (isCacheValid && cached) { - console.log(`[GET /api/validator-details/${nodeId}] Source: cache`); - return createResponse( - { validatorDetails: cached.data }, - { source: 'cache', nodeId, cacheAge } - ); - } + // Return valid cache + if (isCacheValid && cached) { + return createResponse({ validatorDetails: cached.data }, { source: 'cache', nodeId, cacheAge }); + } - // Deduplicate pending requests for the same nodeId - let pendingPromise = pendingRequests.get(nodeId); + // Deduplicate pending requests for the same nodeId + let pendingPromise = pendingRequests.get(nodeId); - if (!pendingPromise) { - pendingPromise = fetchWithTimeout(nodeId); - pendingRequests.set(nodeId, pendingPromise); - pendingPromise.finally(() => pendingRequests.delete(nodeId)); - } + if (!pendingPromise) { + pendingPromise = fetchWithTimeout(nodeId); + pendingRequests.set(nodeId, pendingPromise); + pendingPromise.finally(() => pendingRequests.delete(nodeId)); + } - const startTime = Date.now(); - const validatorDetails = await pendingPromise; - const fetchTime = Date.now() - startTime; + const startTime = Date.now(); + const validatorDetails = await pendingPromise; + const fetchTime = Date.now() - startTime; - if (!validatorDetails) { - return createResponse( - { error: 'Validator not found or not currently active' }, - { source: 'error', nodeId }, - 404 - ); - } + if (!validatorDetails) { + throw new NotFoundError('Validator not found or not currently active'); + } - // Update cache - cachedData.set(nodeId, { data: validatorDetails, timestamp: now }); - - console.log(`[GET /api/validator-details/${nodeId}] Source: fresh, fetchTime: ${fetchTime}ms`); - return createResponse( - { validatorDetails }, - { source: 'fresh', nodeId, fetchTime } - ); - } catch (error: any) { - console.error('[GET /api/validator-details] Error:', error); - const { nodeId } = await params; - const cached = cachedData.get(nodeId); - if (cached) { - console.log(`[GET /api/validator-details/${nodeId}] Source: error-fallback-cache`); - return createResponse( - { validatorDetails: cached.data }, - { source: 'error-fallback-cache', nodeId, cacheAge: Date.now() - cached.timestamp }, - 206 - ); - } + // Update cache + cachedData.set(nodeId, { data: validatorDetails, timestamp: now }); - return createResponse( - { error: error?.message || 'Failed to fetch validator details' }, - { source: 'error' }, - 500 - ); - } -} + return createResponse({ validatorDetails }, { source: 'fresh', nodeId, fetchTime }); +}); diff --git a/app/api/validator-geolocation/route.ts b/app/api/validator-geolocation/route.ts index 813b792b6c5..9783ceec435 100644 --- a/app/api/validator-geolocation/route.ts +++ b/app/api/validator-geolocation/route.ts @@ -1,12 +1,12 @@ -import { NextResponse } from 'next/server'; -import { Avalanche } from "@avalanche-sdk/chainkit"; +import { Avalanche } from '@avalanche-sdk/chainkit'; +import { withApi, successResponse } from '@/lib/api'; const avalanche = new Avalanche({ - network: "mainnet", + network: 'mainnet', apiKey: process.env.GLACIER_API_KEY, }); -const PRIMARY_NETWORK_SUBNET_ID = "11111111111111111111111111111111LpoYY"; +const PRIMARY_NETWORK_SUBNET_ID = '11111111111111111111111111111111LpoYY'; interface ValidatorGeolocation { city: string; @@ -39,22 +39,21 @@ const CACHE_DURATION = 24 * 60 * 60 * 1000; // 24 hours const CACHE_CONTROL_HEADER = 'public, max-age=86400, s-maxage=86400, stale-while-revalidate=172800'; async function fetchAllValidators(): Promise { - try { - const allValidators: Validator[] = []; - const result = await avalanche.data.primaryNetwork.listValidators({ - validationStatus: "active", - subnetId: PRIMARY_NETWORK_SUBNET_ID, - }); + const allValidators: Validator[] = []; + const result = await avalanche.data.primaryNetwork.listValidators({ + validationStatus: 'active', + subnetId: PRIMARY_NETWORK_SUBNET_ID, + }); - for await (const page of result) { - if (!page?.result?.validators || !Array.isArray(page.result.validators)) { - console.warn('Invalid page structure:', page); - continue; - } + for await (const page of result) { + if (!page?.result?.validators || !Array.isArray(page.result.validators)) { + continue; + } - const validatorsWithGeo = page.result.validators - .filter((v: any) => v.geolocation && v.geolocation.country) - .map((v: any): Validator => ({ + const validatorsWithGeo = page.result.validators + .filter((v: any) => v.geolocation && v.geolocation.country) + .map( + (v: any): Validator => ({ nodeId: v.nodeId, amountStaked: v.amountStaked, validationStatus: v.validationStatus, @@ -65,34 +64,32 @@ async function fetchAllValidators(): Promise { countryCode: v.geolocation.countryCode, latitude: v.geolocation.latitude, longitude: v.geolocation.longitude, - } - })); - - allValidators.push(...validatorsWithGeo); - } - return allValidators; - } catch (error) { - console.error('Error fetching validators with SDK:', error); - return []; + }, + }), + ); + + allValidators.push(...validatorsWithGeo); } + return allValidators; } function aggregateByCountry(validators: Validator[]): CountryData[] { - const countryMap = new Map(); - - let totalValidators = validators.length; - let totalStaked = BigInt(0); - - validators.forEach(validator => { + const countryMap = new Map< + string, + { + validators: number; + totalStaked: bigint; + latSum: number; + lngSum: number; + countryCode: string; + } + >(); + + const totalValidators = validators.length; + + validators.forEach((validator) => { const country = validator.geolocation.country; const staked = BigInt(validator.amountStaked); - totalStaked += staked; if (!countryMap.has(country)) { countryMap.set(country, { @@ -112,7 +109,7 @@ function aggregateByCountry(validators: Validator[]): CountryData[] { }); const result: CountryData[] = []; - + countryMap.forEach((data, country) => { const percentage = totalValidators > 0 ? (data.validators / totalValidators) * 100 : 0; const avgLat = data.validators > 0 ? data.latSum / data.validators : 0; @@ -136,73 +133,41 @@ function aggregateByCountry(validators: Validator[]): CountryData[] { function latLngToSVG(lat: number, lng: number): { x: number; y: number } { const x = ((lng + 180) / 360) * 900; const y = ((90 - lat) / 180) * 400; - return { x, y }; } -export async function GET() { - try { - if (cachedGeoData && Date.now() - cachedGeoData.timestamp < CACHE_DURATION) { - console.log(`[GET /api/validator-geolocation] Source: cache`); - return NextResponse.json(cachedGeoData.data, { - headers: { - 'Cache-Control': CACHE_CONTROL_HEADER, - 'X-Data-Source': 'cache', - 'X-Cache-Timestamp': new Date(cachedGeoData.timestamp).toISOString(), - } - }); - } - - const startTime = Date.now(); - - const validators = await fetchAllValidators(); - - if (validators.length === 0) { - return NextResponse.json([], { - headers: { - 'X-Error': 'No validators found', - } - }); - } +export const GET = withApi(async () => { + if (cachedGeoData && Date.now() - cachedGeoData.timestamp < CACHE_DURATION) { + const resp = successResponse(cachedGeoData.data); + resp.headers.set('Cache-Control', CACHE_CONTROL_HEADER); + resp.headers.set('X-Data-Source', 'cache'); + resp.headers.set('X-Cache-Timestamp', new Date(cachedGeoData.timestamp).toISOString()); + return resp; + } - const countryData = aggregateByCountry(validators); - const countryDataWithCoords = countryData.map(country => ({ - ...country, - ...latLngToSVG(country.latitude, country.longitude) - })); - cachedGeoData = { - data: countryDataWithCoords, - timestamp: Date.now() - }; - - const fetchTime = Date.now() - startTime; - console.log(`[GET /api/validator-geolocation] Source: fresh, fetchTime: ${fetchTime}ms`); - return NextResponse.json(countryDataWithCoords, { - headers: { - 'Cache-Control': CACHE_CONTROL_HEADER, - 'X-Data-Source': 'fresh', - 'X-Fetch-Time': `${fetchTime}ms`, - 'X-Total-Validators': validators.length.toString(), - 'X-Total-Countries': countryData.length.toString(), - } - }); + const validators = await fetchAllValidators(); - } catch (error) { - console.error('Error in validator geolocation API:', error); - - if (cachedGeoData) { - console.log(`[GET /api/validator-geolocation] Source: cache-fallback`); - return NextResponse.json(cachedGeoData.data, { - headers: { - 'X-Data-Source': 'cache-fallback', - 'X-Error': 'true', - } - }); - } - - return NextResponse.json( - { error: 'Failed to fetch validator geolocation data' }, - { status: 500 } - ); + if (validators.length === 0) { + const resp = successResponse([]); + resp.headers.set('X-Error', 'No validators found'); + return resp; } -} + + const countryData = aggregateByCountry(validators); + const countryDataWithCoords = countryData.map((country) => ({ + ...country, + ...latLngToSVG(country.latitude, country.longitude), + })); + + cachedGeoData = { + data: countryDataWithCoords, + timestamp: Date.now(), + }; + + const resp = successResponse(countryDataWithCoords); + resp.headers.set('Cache-Control', CACHE_CONTROL_HEADER); + resp.headers.set('X-Data-Source', 'fresh'); + resp.headers.set('X-Total-Validators', validators.length.toString()); + resp.headers.set('X-Total-Countries', countryData.length.toString()); + return resp; +}); diff --git a/app/api/validator-notification/route.ts b/app/api/validator-notification/route.ts index e0a2c2496fc..e497443f3b7 100644 --- a/app/api/validator-notification/route.ts +++ b/app/api/validator-notification/route.ts @@ -1,49 +1,47 @@ -import { NextResponse } from 'next/server'; +import { z } from 'zod'; +import { withApi, successResponse, InternalError } from '@/lib/api'; const HUBSPOT_API_KEY = process.env.HUBSPOT_API_KEY; const HUBSPOT_PORTAL_ID = process.env.HUBSPOT_PORTAL_ID; const VALIDATOR_FORM_GUID = process.env.VALIDATOR_FORM_GUID; +const HUBSPOT_TIMEOUT = 10_000; -export async function POST(request: Request) { - try { - if (!HUBSPOT_API_KEY || !HUBSPOT_PORTAL_ID) { - console.error('Missing environment variables: HUBSPOT_API_KEY or HUBSPOT_PORTAL_ID'); - return NextResponse.json( - { success: false, message: 'Server configuration error' }, - { status: 500 } - ); - } +const validatorNotificationSchema = z.object({ + email: z.string().email('Valid email is required'), + firstname: z.string().optional(), + lastname: z.string().optional(), + company: z.string().optional(), + company_description_vertical: z.string().optional(), + subnet_type: z.string().optional(), + gdpr: z.boolean().optional(), + marketing_consent: z.boolean().optional(), +}); - const clonedRequest = request.clone(); - let formData; - try { - formData = await clonedRequest.json(); - } catch (error) { - console.error('Error parsing request body:', error); - return NextResponse.json( - { success: false, message: 'Invalid request body' }, - { status: 400 } - ); +type ValidatorNotificationBody = z.infer; + +export const POST = withApi( + async (request, { body }) => { + if (!HUBSPOT_API_KEY || !HUBSPOT_PORTAL_ID) { + throw new InternalError('Server configuration error'); } - const fieldMapping: { [key: string]: string[] } = { - "email": ["email"], - "firstname": ["firstname"], - "lastname": ["lastname"], - "company": ["company"], - "company_description_vertical": ["company_description_vertical"], - "subnet_type": ["subnet_type"], - "gdpr": ["gdpr"], - "marketing_consent": ["marketing_consent"] + const fieldMapping: Record = { + email: ['email'], + firstname: ['firstname'], + lastname: ['lastname'], + company: ['company'], + company_description_vertical: ['company_description_vertical'], + subnet_type: ['subnet_type'], + gdpr: ['gdpr'], + marketing_consent: ['marketing_consent'], }; - + const fields: { name: string; value: string | boolean }[] = []; - Object.entries(formData).forEach(([name, value]) => { - if (value === undefined || value === null || value === '') { - return; - } - - let formattedValue: string | boolean = typeof value === 'string' || typeof value === 'boolean' ? value : String(value); + for (const [name, value] of Object.entries(body)) { + if (value === undefined || value === null || value === '') continue; + + let formattedValue: string | boolean = + typeof value === 'string' || typeof value === 'boolean' ? value : String(value); if (typeof value === 'boolean') { if (name !== 'gdpr' && name !== 'marketing_consent') { formattedValue = value ? 'Yes' : 'No'; @@ -51,15 +49,11 @@ export async function POST(request: Request) { } const mappedFields = fieldMapping[name] || [name]; + for (const fieldName of mappedFields) { + fields.push({ name: fieldName, value: formattedValue }); + } + } - mappedFields.forEach(fieldName => { - fields.push({ - name: fieldName, - value: formattedValue - }); - }); - }); - const hubspotPayload: { fields: { name: string; value: string | boolean }[]; context: { pageUri: string; pageName: string }; @@ -75,74 +69,62 @@ export async function POST(request: Request) { }; }; } = { - fields: fields, + fields, context: { pageUri: request.headers.get('referer') || 'https://build.avax.network', - pageName: 'Validator Email Collection' - } + pageName: 'Validator Email Collection', + }, }; - if (formData.gdpr === true) { + if (body.gdpr === true) { hubspotPayload.legalConsentOptions = { consent: { consentToProcess: true, - text: "I agree to allow Avalanche Foundation to store and process my personal data.", + text: 'I agree to allow Avalanche Foundation to store and process my personal data.', communications: [ { - value: formData.marketing_consent === true, + value: body.marketing_consent === true, subscriptionTypeId: 999, - text: "I agree to receive marketing communications from Avalanche Foundation." - } - ] - } + text: 'I agree to receive marketing communications from Avalanche Foundation.', + }, + ], + }, }; } - - const hubspotResponse = await fetch( - `https://api.hsforms.com/submissions/v3/integration/submit/${HUBSPOT_PORTAL_ID}/${VALIDATOR_FORM_GUID}`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${HUBSPOT_API_KEY}` - }, - body: JSON.stringify(hubspotPayload) - } - ); - const responseStatus = hubspotResponse.status; - let hubspotResult; + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), HUBSPOT_TIMEOUT); + try { - const clonedResponse = hubspotResponse.clone(); - try { - hubspotResult = await hubspotResponse.json(); - } catch (jsonError) { - const text = await clonedResponse.text(); - console.error('Non-JSON response from HubSpot:', text); - hubspotResult = { status: 'error', message: text }; + const hubspotResponse = await fetch( + `https://api.hsforms.com/submissions/v3/integration/submit/${HUBSPOT_PORTAL_ID}/${VALIDATOR_FORM_GUID}`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${HUBSPOT_API_KEY}`, + }, + body: JSON.stringify(hubspotPayload), + signal: controller.signal, + }, + ); + clearTimeout(timeout); + + if (!hubspotResponse.ok) { + let hubspotResult: any; + try { + hubspotResult = await hubspotResponse.json(); + } catch { + hubspotResult = { message: await hubspotResponse.text().catch(() => 'Unknown error') }; + } + throw new InternalError(hubspotResult?.message || `HubSpot returned ${hubspotResponse.status}`); } + + return successResponse({ success: true }); } catch (error) { - console.error('Error reading HubSpot response:', error); - hubspotResult = { status: 'error', message: 'Could not read HubSpot response' }; + clearTimeout(timeout); + throw error; } - - console.log('HubSpot response:', hubspotResult); - if (!hubspotResponse.ok) { - return NextResponse.json( - { - success: false, - status: responseStatus, - response: hubspotResult - } - ); - } - - return NextResponse.json({ success: true }); - } catch (error) { - console.error('Error processing validator form submission:', error); - return NextResponse.json( - { success: false, message: error instanceof Error ? error.message : 'Internal server error' }, - { status: 500 } - ); - } -} \ No newline at end of file + }, + { auth: true, schema: validatorNotificationSchema }, +); diff --git a/app/api/validator-stats/route.ts b/app/api/validator-stats/route.ts index a9e2a47ab3c..ab2227f96da 100644 --- a/app/api/validator-stats/route.ts +++ b/app/api/validator-stats/route.ts @@ -1,10 +1,11 @@ import { NextResponse } from 'next/server'; -import { Avalanche } from "@avalanche-sdk/chainkit"; +import { withApi, BadRequestError } from '@/lib/api'; +import { Avalanche } from '@avalanche-sdk/chainkit'; import { type ActiveValidatorDetails } from '@avalanche-sdk/chainkit/models/components/activevalidatordetails.js'; import { type Subnet } from '@avalanche-sdk/chainkit/models/components/subnet.js'; import { type SimpleValidator, type ValidatorVersion, type SubnetStats } from '@/types/validator-stats'; import { MAINNET_VALIDATOR_DISCOVERY_URL, FUJI_VALIDATOR_DISCOVERY_URL } from '@/constants/validator-discovery'; -import l1ChainsData from "@/constants/l1-chains.json"; +import l1ChainsData from '@/constants/l1-chains.json'; export const dynamic = 'force-dynamic'; @@ -14,7 +15,9 @@ const PAGE_SIZE = 100; const FETCH_TIMEOUT = 10000; const CACHE_CONTROL_HEADER = 'public, max-age=86400, s-maxage=86400, stale-while-revalidate=172800'; -const validatorsCached: Partial }>> = {}; +const validatorsCached: Partial< + Record }> +> = {}; const subnetsCached: Partial }>> = {}; const validatorVersionsCached: Partial; timestamp: number }>> = {}; const statsCached: Partial> = {}; @@ -27,7 +30,7 @@ async function fetchWithTimeout(url: string, timeout: number = FETCH_TIMEOUT): P try { const response = await fetch(url, { signal: controller.signal, - headers: { 'Accept': 'application/json' }, + headers: { Accept: 'application/json' }, }); return response; } finally { @@ -35,32 +38,34 @@ async function fetchWithTimeout(url: string, timeout: number = FETCH_TIMEOUT): P } } -async function listClassicValidators(network: "mainnet" | "fuji"): Promise { +async function listClassicValidators(network: 'mainnet' | 'fuji'): Promise { const avalancheSDK = new Avalanche({ network }); const validators: SimpleValidator[] = []; - + const result = await avalancheSDK.data.primaryNetwork.listValidators({ pageSize: PAGE_SIZE, network, - validationStatus: "active", + validationStatus: 'active', }); for await (const page of result) { const activeValidators = page.result.validators as ActiveValidatorDetails[]; - validators.push(...activeValidators.map(v => ({ - nodeId: v.nodeId, - subnetId: v.subnetId, - weight: Number(v.amountStaked) - }))); + validators.push( + ...activeValidators.map((v) => ({ + nodeId: v.nodeId, + subnetId: v.subnetId, + weight: Number(v.amountStaked), + })), + ); } return validators; } -async function listL1Validators(network: "mainnet" | "fuji"): Promise { +async function listL1Validators(network: 'mainnet' | 'fuji'): Promise { const avalancheSDK = new Avalanche({ network }); const validators: SimpleValidator[] = []; - + const result = await avalancheSDK.data.primaryNetwork.listL1Validators({ pageSize: PAGE_SIZE, includeInactiveL1Validators: false, @@ -68,24 +73,26 @@ async function listL1Validators(network: "mainnet" | "fuji"): Promise v.remainingBalance > 0) - .map(v => ({ - nodeId: v.nodeId, - subnetId: v.subnetId, - weight: v.weight - }))); + validators.push( + ...page.result.validators + .filter((v) => v.remainingBalance > 0) + .map((v) => ({ + nodeId: v.nodeId, + subnetId: v.subnetId, + weight: v.weight, + })), + ); } return validators; } -async function getAllValidators(network: "mainnet" | "fuji"): Promise { +async function getAllValidators(network: 'mainnet' | 'fuji'): Promise { const now = Date.now(); const cache = validatorsCached[network]; // Return cached data if still valid - if (cache && (now - cache.timestamp) < CACHE_DURATION) { + if (cache && now - cache.timestamp < CACHE_DURATION) { return cache.data; } @@ -98,17 +105,17 @@ async function getAllValidators(network: "mainnet" | "fuji"): Promise { const [l1Validators, classicValidators] = await Promise.all([ listL1Validators(network), - listClassicValidators(network) + listClassicValidators(network), ]); const allValidators = [...l1Validators, ...classicValidators]; - + // Store in cache with timestamp validatorsCached[network] = { data: allValidators, timestamp: Date.now(), }; - + return allValidators; })(); @@ -127,11 +134,11 @@ async function getAllValidators(network: "mainnet" | "fuji"): Promise { +async function getAllSubnets(network: 'mainnet' | 'fuji'): Promise { const now = Date.now(); const cache = subnetsCached[network]; - if (cache && (now - cache.timestamp) < CACHE_DURATION) { + if (cache && now - cache.timestamp < CACHE_DURATION) { return cache.data; } @@ -143,7 +150,7 @@ async function getAllSubnets(network: "mainnet" | "fuji"): Promise { const promise = (async () => { const avalancheSDK = new Avalanche({ network }); const allSubnets: Subnet[] = []; - + const result = await avalancheSDK.data.primaryNetwork.listSubnets({ pageSize: PAGE_SIZE, network, @@ -157,7 +164,7 @@ async function getAllSubnets(network: "mainnet" | "fuji"): Promise { data: allSubnets, timestamp: Date.now(), }; - + return allSubnets; })(); @@ -176,20 +183,20 @@ async function getAllSubnets(network: "mainnet" | "fuji"): Promise { return promise; } -async function getValidatorVersions(network: "mainnet" | "fuji"): Promise> { +async function getValidatorVersions(network: 'mainnet' | 'fuji'): Promise> { const now = Date.now(); const cache = validatorVersionsCached[network]; // Check if cache exists and is still valid - if (cache && (now - cache.timestamp) < VERSION_CACHE_DURATION) { + if (cache && now - cache.timestamp < VERSION_CACHE_DURATION) { return cache.data; } - const url = network === "mainnet" ? MAINNET_VALIDATOR_DISCOVERY_URL : FUJI_VALIDATOR_DISCOVERY_URL; - + const url = network === 'mainnet' ? MAINNET_VALIDATOR_DISCOVERY_URL : FUJI_VALIDATOR_DISCOVERY_URL; + try { const response = await fetchWithTimeout(url); - + if (!response.ok) { throw new Error(`Failed to fetch validator versions: ${response.status}`); } @@ -198,17 +205,17 @@ async function getValidatorVersions(network: "mainnet" | "fuji"): Promise(); for (const validator of data) { - versionMap.set(validator.nodeId, validator.version || "Unknown"); + versionMap.set(validator.nodeId, validator.version || 'Unknown'); } // Update cache validatorVersionsCached[network] = { data: versionMap, - timestamp: now + timestamp: now, }; return versionMap; - } catch (error: any) { + } catch { // Return cached data if available, even if stale if (cache) { return cache.data; @@ -217,20 +224,23 @@ async function getValidatorVersions(network: "mainnet" | "fuji"): Promise { +async function getNetworkStatsInternal(network: 'mainnet' | 'fuji'): Promise { const [validators, subnets, versionMap] = await Promise.all([ getAllValidators(network), getAllSubnets(network), - getValidatorVersions(network) + getValidatorVersions(network), ]); - const subnetAccumulators: Record; - isL1: boolean; - }> = {}; + const subnetAccumulators: Record< + string, + { + name: string; + id: string; + totalStake: bigint; + byClientVersion: Record; + isL1: boolean; + } + > = {}; // Create a map of subnetId to isL1 from subnets const subnetIsL1Map = new Map(); @@ -238,7 +248,7 @@ async function getNetworkStatsInternal(network: "mainnet" | "fuji"): Promise blockchain.blockchainName).join('/'), + name: subnet.blockchains.map((blockchain) => blockchain.blockchainName).join('/'), id: subnet.subnetId, byClientVersion: {}, totalStake: 0n, @@ -262,12 +272,12 @@ async function getNetworkStatsInternal(network: "mainnet" | "fuji"): Promise { +async function getNetworkStats(network: 'mainnet' | 'fuji'): Promise { const now = Date.now(); const cache = statsCached[network]; const cacheAge = cache ? now - cache.timestamp : Infinity; @@ -324,34 +334,36 @@ async function getNetworkStats(network: "mainnet" | "fuji"): Promise { try { const freshData = await getNetworkStatsInternal(network); statsCached[network] = { data: freshData, timestamp: Date.now() }; - } catch (error) { - console.error(`[getNetworkStats] Background refresh failed for ${network}:`, error); + } catch { + // Background refresh failed; stale data still served } finally { revalidatingKeys.delete(network); } })(); - + return cache.data; } - + // Return valid cache - if (isCacheValid && cache) { return cache.data; } - + if (isCacheValid && cache) { + return cache.data; + } + let pendingPromise = pendingStatsRequests.get(network); - + if (!pendingPromise) { pendingPromise = getNetworkStatsInternal(network); pendingStatsRequests.set(network, pendingPromise); pendingPromise.finally(() => pendingStatsRequests.delete(network)); } - - const freshData = await pendingPromise; + + const freshData = await pendingPromise; statsCached[network] = { data: freshData, timestamp: Date.now() }; return freshData; } @@ -359,11 +371,11 @@ async function getNetworkStats(network: "mainnet" | "fuji"): Promise = { - 'Cache-Control': CACHE_CONTROL_HEADER, - 'X-Data-Source': meta.source + const headers: Record = { + 'Cache-Control': CACHE_CONTROL_HEADER, + 'X-Data-Source': meta.source, }; if (meta.network) headers['X-Network'] = meta.network; if (meta.cacheAge !== undefined) headers['X-Cache-Age'] = `${Math.round(meta.cacheAge / 1000)}s`; @@ -371,59 +383,27 @@ function createResponse( return NextResponse.json(data, { status, headers }); } -export async function GET(request: Request) { - try { - const { searchParams } = new URL(request.url); - const network = searchParams.get('network'); - - if (!network || (network !== 'mainnet' && network !== 'fuji')) { - return createResponse( - { error: 'Invalid or missing network parameter. Use ?network=mainnet or ?network=fuji' }, - { source: 'error' }, - 400 - ); - } +export const GET = withApi(async (req) => { + const network = req.nextUrl.searchParams.get('network'); - const startTime = Date.now(); - const cache = statsCached[network]; - const cacheAge = cache ? Date.now() - cache.timestamp : undefined; - - const stats = await getNetworkStats(network); - const fetchTime = Date.now() - startTime; - - const source = fetchTime < 50 && cache ? - (cacheAge && cacheAge < CACHE_DURATION ? 'cache' : 'stale-while-revalidate') : - 'fresh'; - - console.log(`[GET /api/validator-stats] Network: ${network}, Source: ${source}, fetchTime: ${fetchTime}ms`); - - return createResponse(stats, { - source, - network, - cacheAge, - fetchTime - }); - } catch (error: any) { - const { searchParams } = new URL(request.url); - const network = searchParams.get('network') || 'unknown'; - console.error(`[GET /api/validator-stats] Error (${network}):`, error); - - if (network === 'mainnet' || network === 'fuji') { - const cache = statsCached[network]; - if (cache) { - console.log(`[GET /api/validator-stats] Network: ${network}, Source: error-fallback-cache`); - return createResponse(cache.data, { - source: 'error-fallback-cache', - network, - cacheAge: Date.now() - cache.timestamp - }, 206); - } - } - - return createResponse( - { error: error?.message || `Failed to fetch validator stats for ${network}` }, - { source: 'error', network }, - 500 - ); + if (!network || (network !== 'mainnet' && network !== 'fuji')) { + throw new BadRequestError('Invalid or missing network parameter. Use ?network=mainnet or ?network=fuji'); } -} + + const startTime = Date.now(); + const cache = statsCached[network]; + const cacheAge = cache ? Date.now() - cache.timestamp : undefined; + + const stats = await getNetworkStats(network); + const fetchTime = Date.now() - startTime; + + const source = + fetchTime < 50 && cache ? (cacheAge && cacheAge < CACHE_DURATION ? 'cache' : 'stale-while-revalidate') : 'fresh'; + + return createResponse(stats, { + source, + network, + cacheAge, + fetchTime, + }); +}); diff --git a/app/api/validators/[nodeId]/route.ts b/app/api/validators/[nodeId]/route.ts index b4436898cab..ab914b8fb43 100644 --- a/app/api/validators/[nodeId]/route.ts +++ b/app/api/validators/[nodeId]/route.ts @@ -1,8 +1,14 @@ -import { NextResponse } from 'next/server'; +import { z } from 'zod'; +import { withApi, ValidationError, successResponse } from '@/lib/api'; +import { NODE_ID_REGEX } from '@/lib/api/constants'; const UPSTREAM_BASE = 'https://52.203.183.9.sslip.io/api/validators'; const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes -const FETCH_TIMEOUT = 15000; +const FETCH_TIMEOUT = 10_000; + +const nodeIdSchema = z.object({ + nodeId: z.string().max(60).regex(NODE_ID_REGEX, 'Invalid Node ID format. Expected format: NodeID-xxx'), +}); export interface ValidatorP2PDetail { node_id: string; @@ -40,37 +46,26 @@ interface CacheEntry { const cachedData = new Map(); -export async function GET( - request: Request, - { params }: { params: Promise<{ nodeId: string }> } -) { - try { - const { nodeId } = await params; - - if (!nodeId || !nodeId.startsWith('NodeID-')) { - return NextResponse.json( - { error: 'Invalid Node ID format. Expected format: NodeID-xxx' }, - { status: 400 } - ); - } +export const GET = withApi(async (_req, { params }) => { + const parsed = nodeIdSchema.safeParse(params); + if (!parsed.success) { + throw new ValidationError(parsed.error.issues.map((i) => i.message).join('; ')); + } + const { nodeId } = parsed.data; - const cached = cachedData.get(nodeId); - if (cached && Date.now() - cached.timestamp < CACHE_DURATION) { - return NextResponse.json(cached.data, { - headers: { - 'Cache-Control': 'public, max-age=300, s-maxage=300, stale-while-revalidate=600', - 'X-Data-Source': 'cache', - }, - }); - } + const cached = cachedData.get(nodeId); + if (cached && Date.now() - cached.timestamp < CACHE_DURATION) { + const resp = successResponse(cached.data); + resp.headers.set('Cache-Control', 'public, max-age=300, s-maxage=300, stale-while-revalidate=600'); + resp.headers.set('X-Data-Source', 'cache'); + return resp; + } - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT); - const response = await fetch( - `${UPSTREAM_BASE}/${encodeURIComponent(nodeId)}`, - { signal: controller.signal } - ); + try { + const response = await fetch(`${UPSTREAM_BASE}/${encodeURIComponent(nodeId)}`, { signal: controller.signal }); clearTimeout(timeout); if (!response.ok) { @@ -80,26 +75,20 @@ export async function GET( const data: ValidatorP2PDetail = await response.json(); cachedData.set(nodeId, { data, timestamp: Date.now() }); - return NextResponse.json(data, { - headers: { - 'Cache-Control': 'public, max-age=300, s-maxage=300, stale-while-revalidate=600', - 'X-Data-Source': 'fresh', - }, - }); + const resp = successResponse(data); + resp.headers.set('Cache-Control', 'public, max-age=300, s-maxage=300, stale-while-revalidate=600'); + resp.headers.set('X-Data-Source', 'fresh'); + return resp; } catch (error) { - console.error('[GET /api/validators/[nodeId]] Error:', error); - const { nodeId } = await params; - const cached = cachedData.get(nodeId); + clearTimeout(timeout); - if (cached) { - return NextResponse.json(cached.data, { - headers: { 'X-Data-Source': 'error-fallback-cache' }, - }); + const fallback = cachedData.get(nodeId); + if (fallback) { + const resp = successResponse(fallback.data); + resp.headers.set('X-Data-Source', 'error-fallback-cache'); + return resp; } - return NextResponse.json( - { error: 'Failed to fetch validator details' }, - { status: 500 } - ); + throw error; } -} +}); diff --git a/app/api/validators/route.ts b/app/api/validators/route.ts index 653d5805136..781210228d4 100644 --- a/app/api/validators/route.ts +++ b/app/api/validators/route.ts @@ -1,8 +1,8 @@ -import { NextResponse } from 'next/server'; +import { withApi, InternalError, successResponse } from '@/lib/api'; const UPSTREAM_URL = 'https://52.203.183.9.sslip.io/api/validators'; const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes -const FETCH_TIMEOUT = 15000; +const FETCH_TIMEOUT = 10_000; interface ValidatorP2P { node_id: string; @@ -26,50 +26,41 @@ interface ValidatorP2P { let cachedData: { data: ValidatorP2P[]; timestamp: number } | null = null; -export async function GET() { - try { - if (cachedData && Date.now() - cachedData.timestamp < CACHE_DURATION) { - return NextResponse.json(cachedData.data, { - headers: { - 'Cache-Control': 'public, max-age=300, s-maxage=300, stale-while-revalidate=600', - 'X-Data-Source': 'cache', - }, - }); - } +export const GET = withApi(async () => { + if (cachedData && Date.now() - cachedData.timestamp < CACHE_DURATION) { + const resp = successResponse(cachedData.data); + resp.headers.set('Cache-Control', 'public, max-age=300, s-maxage=300, stale-while-revalidate=600'); + resp.headers.set('X-Data-Source', 'cache'); + return resp; + } - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT); + try { const response = await fetch(UPSTREAM_URL, { signal: controller.signal }); clearTimeout(timeout); if (!response.ok) { - throw new Error(`Upstream API returned ${response.status}`); + throw new InternalError(`Upstream API returned ${response.status}`); } const data: ValidatorP2P[] = await response.json(); cachedData = { data, timestamp: Date.now() }; - return NextResponse.json(data, { - headers: { - 'Cache-Control': 'public, max-age=300, s-maxage=300, stale-while-revalidate=600', - 'X-Data-Source': 'fresh', - }, - }); + const resp = successResponse(data); + resp.headers.set('Cache-Control', 'public, max-age=300, s-maxage=300, stale-while-revalidate=600'); + resp.headers.set('X-Data-Source', 'fresh'); + return resp; } catch (error) { - console.error('[GET /api/validators] Error:', error); + clearTimeout(timeout); if (cachedData) { - return NextResponse.json(cachedData.data, { - headers: { - 'X-Data-Source': 'error-fallback-cache', - }, - }); + const resp = successResponse(cachedData.data); + resp.headers.set('X-Data-Source', 'error-fallback-cache'); + return resp; } - return NextResponse.json( - { error: 'Failed to fetch validators data' }, - { status: 500 } - ); + throw error; } -} +}); diff --git a/app/api/youtube/search/route.ts b/app/api/youtube/search/route.ts index 1bd5ab4301b..b01cf827f1f 100644 --- a/app/api/youtube/search/route.ts +++ b/app/api/youtube/search/route.ts @@ -1,10 +1,10 @@ -import { NextRequest, NextResponse } from 'next/server'; +import { withApi, successResponse, ValidationError, InternalError } from '@/lib/api'; -// Avalanche YouTube channel ID // Official Avalanche channel: https://www.youtube.com/@Aboratory const AVALANCHE_CHANNEL_ID = 'UCScsLTtz5DCwJodZ8ht9KNA'; +const FETCH_TIMEOUT_MS = 10_000; -export const runtime = 'edge'; +// NOTE: Cannot use edge runtime — withApi imports next-auth which requires Node.js runtime interface YouTubeSearchResult { videoId: string; @@ -38,54 +38,51 @@ interface YouTubeAPIResponse { }; } -// Cache for search results (in-memory, resets on cold start) const searchCache = new Map(); -const CACHE_DURATION = 60 * 60 * 1000; // 1 hour +const CACHE_DURATION = 60 * 60 * 1000; -export async function GET(request: NextRequest) { - const searchParams = request.nextUrl.searchParams; - const query = searchParams.get('q'); - const limit = Math.min(parseInt(searchParams.get('limit') || '5'), 10); +export const GET = withApi(async (req) => { + const query = req.nextUrl.searchParams.get('q'); + const limit = Math.min(parseInt(req.nextUrl.searchParams.get('limit') || '5'), 10); if (!query) { - return NextResponse.json({ error: 'Query parameter "q" is required' }, { status: 400 }); + throw new ValidationError('Query parameter "q" is required'); } const apiKey = process.env.YOUTUBE_API_KEY; if (!apiKey) { - console.error('YOUTUBE_API_KEY not configured'); - return NextResponse.json({ error: 'YouTube API not configured' }, { status: 500 }); + throw new InternalError('YouTube API not configured'); } - // Check cache const cacheKey = `${query}-${limit}`; const cached = searchCache.get(cacheKey); if (cached && Date.now() - cached.timestamp < CACHE_DURATION) { - return NextResponse.json({ videos: cached.results, cached: true }); + return successResponse({ videos: cached.results, cached: true }); } + const searchUrl = new URL('https://www.googleapis.com/youtube/v3/search'); + searchUrl.searchParams.set('key', apiKey); + searchUrl.searchParams.set('channelId', AVALANCHE_CHANNEL_ID); + searchUrl.searchParams.set('q', query); + searchUrl.searchParams.set('part', 'snippet'); + searchUrl.searchParams.set('type', 'video'); + searchUrl.searchParams.set('maxResults', limit.toString()); + searchUrl.searchParams.set('order', 'relevance'); + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + try { - // Search YouTube for videos from the Avalanche channel - const searchUrl = new URL('https://www.googleapis.com/youtube/v3/search'); - searchUrl.searchParams.set('key', apiKey); - searchUrl.searchParams.set('channelId', AVALANCHE_CHANNEL_ID); - searchUrl.searchParams.set('q', query); - searchUrl.searchParams.set('part', 'snippet'); - searchUrl.searchParams.set('type', 'video'); - searchUrl.searchParams.set('maxResults', limit.toString()); - searchUrl.searchParams.set('order', 'relevance'); - - const response = await fetch(searchUrl.toString()); + const response = await fetch(searchUrl.toString(), { signal: controller.signal }); const data: YouTubeAPIResponse = await response.json(); if (data.error) { - console.error('YouTube API error:', data.error); - return NextResponse.json({ error: data.error.message }, { status: data.error.code }); + throw new InternalError(data.error.message); } const videos: YouTubeSearchResult[] = (data.items || []) - .filter(item => item.id.videoId) - .map(item => ({ + .filter((item) => item.id.videoId) + .map((item) => ({ videoId: item.id.videoId!, title: item.snippet.title, description: item.snippet.description, @@ -94,33 +91,10 @@ export async function GET(request: NextRequest) { channelTitle: item.snippet.channelTitle, })); - // Cache results searchCache.set(cacheKey, { results: videos, timestamp: Date.now() }); - return NextResponse.json({ videos, cached: false }); - } catch (error) { - console.error('YouTube search error:', error); - return NextResponse.json({ error: 'Failed to search YouTube' }, { status: 500 }); + return successResponse({ videos, cached: false }); + } finally { + clearTimeout(timeoutId); } -} - -// Also support POST for more complex queries -export async function POST(request: NextRequest) { - try { - const body = await request.json(); - const { query, limit = 5 } = body; - - if (!query) { - return NextResponse.json({ error: 'Query is required' }, { status: 400 }); - } - - // Redirect to GET handler - const url = new URL(request.url); - url.searchParams.set('q', query); - url.searchParams.set('limit', limit.toString()); - - return GET(new NextRequest(url)); - } catch (error) { - return NextResponse.json({ error: 'Invalid request body' }, { status: 400 }); - } -} +}); diff --git a/app/chat/page.tsx b/app/chat/page.tsx index bbbdca25df5..fdb1a1d1e7f 100644 --- a/app/chat/page.tsx +++ b/app/chat/page.tsx @@ -67,6 +67,7 @@ import posthog from 'posthog-js'; import { useTheme } from 'next-themes'; import { useSession } from 'next-auth/react'; import { useLoginModalTrigger } from '@/hooks/useLoginModal'; +import { apiFetch } from '@/lib/api/client'; import { ShareButton } from '@/components/chat/share-button'; import { ShareModal } from '@/components/chat/share-modal'; @@ -1036,9 +1037,8 @@ function ChatPageInner() { useEffect(() => { if (isAuthenticated) { setIsLoadingConversations(true); - fetch('/api/chat-history') - .then(res => res.json()) - .then((data: DbConversation[]) => { + apiFetch('/api/chat-history') + .then((data) => { if (Array.isArray(data)) { const convs = data.map(dbToLocalConversation); setConversations(convs); @@ -1057,30 +1057,26 @@ function ChatPageInner() { if (!isAuthenticated) return; try { - const res = await fetch('/api/chat-history', { + const saved = await apiFetch('/api/chat-history', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ + body: { id: conv.id.includes('-') ? conv.id : undefined, // Only pass ID if it's a UUID (from DB) title: conv.title, messages: conv.messages.map(m => ({ role: m.role, content: getMessageText(m) })), - }), + }, }); - if (res.ok) { - const saved: DbConversation = await res.json(); - const localConv = dbToLocalConversation(saved); + const localConv = dbToLocalConversation(saved); - setConversations(prev => { - const exists = prev.some(c => c.id === localConv.id); - if (exists) { - return prev.map(c => c.id === localConv.id ? localConv : c); - } - return [localConv, ...prev]; - }); + setConversations(prev => { + const exists = prev.some(c => c.id === localConv.id); + if (exists) { + return prev.map(c => c.id === localConv.id ? localConv : c); + } + return [localConv, ...prev]; + }); - return localConv; - } + return localConv; } catch (err) { console.error('Failed to save conversation:', err); } @@ -1092,12 +1088,10 @@ function ChatPageInner() { if (!isAuthenticated) return; try { - const res = await fetch(`/api/chat-history/${id}`, { method: 'DELETE' }); - if (res.ok) { - setConversations(prev => prev.filter(c => c.id !== id)); - if (currentConversationId === id) { - setCurrentConversationId(null); - } + await apiFetch(`/api/chat-history/${id}`, { method: 'DELETE' }); + setConversations(prev => prev.filter(c => c.id !== id)); + if (currentConversationId === id) { + setCurrentConversationId(null); } } catch (err) { console.error('Failed to delete conversation:', err); @@ -1109,17 +1103,14 @@ function ChatPageInner() { if (!isAuthenticated || !newTitle.trim()) return; try { - const res = await fetch(`/api/chat-history/${id}`, { + await apiFetch(`/api/chat-history/${id}`, { method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ title: newTitle.trim() }), + body: { title: newTitle.trim() }, }); - if (res.ok) { - setConversations(prev => - prev.map(c => c.id === id ? { ...c, title: newTitle.trim() } : c) - ); - posthog.capture('ai_chat_conversation_renamed', { conversation_id: id }); - } + setConversations(prev => + prev.map(c => c.id === id ? { ...c, title: newTitle.trim() } : c) + ); + posthog.capture('ai_chat_conversation_renamed', { conversation_id: id }); } catch (err) { console.error('Failed to rename conversation:', err); } @@ -1135,9 +1126,8 @@ function ChatPageInner() { const handleShareToggle = useCallback(() => { // Refresh conversations to get updated share status if (isAuthenticated) { - fetch('/api/chat-history') - .then(res => res.json()) - .then((data: DbConversation[]) => { + apiFetch('/api/chat-history') + .then((data) => { if (Array.isArray(data)) { const convs = data.map(dbToLocalConversation); setConversations(convs); diff --git a/app/events/edit/page.tsx b/app/events/edit/page.tsx index ab14c3e2a3c..636628477cf 100644 --- a/app/events/edit/page.tsx +++ b/app/events/edit/page.tsx @@ -9,7 +9,7 @@ import { Switch } from '@/components/ui/switch'; import { Plus, Trash, ChevronDown, ChevronRight, ExternalLink } from 'lucide-react'; import { t } from './translations'; import { useSession, SessionProvider } from "next-auth/react"; -import axios from 'axios'; +import { apiFetch } from '@/lib/api/client'; import { initialData, IDataMain, IDataContent, IDataLatest, ITrack, ISchedule, ISpeaker, IResource, IPartner } from './initials'; import { LanguageButton } from './language-button'; import HackathonPreview from '@/components/hackathons/HackathonPreview'; @@ -778,16 +778,11 @@ const HackathonsEdit = () => { const getMyHackathons = async () => { setLoadingHackathons(true); try { - const response = await axios.get( + const response = await apiFetch<{ hackathons: any[] }>( `/api/hackathons`, - { - headers: { - id: session?.user?.id, - } - } ); - if (response.data?.hackathons?.length > 0) { - const hackathons = response.data.hackathons; + if (response?.hackathons?.length > 0) { + const hackathons = response.hackathons; console.log({response: hackathons }); setMyHackathons(hackathons); } @@ -1294,16 +1289,10 @@ const HackathonsEdit = () => { const blob = await response.blob(); const formData = new FormData(); formData.append('file', blob, fileName); - const uploadResponse = await fetch('/api/file', { + const result = await apiFetch<{ url: string }>('/api/file', { method: 'POST', body: formData, }); - - if (!uploadResponse.ok) { - throw new Error(`Upload failed: ${uploadResponse.statusText}`); - } - - const result = await uploadResponse.json(); return result.url; } catch (error) { console.error('Error uploading base64 to Vercel:', error); @@ -1370,39 +1359,25 @@ const HackathonsEdit = () => { if (!isSelectedHackathon) { try { - const response = await fetch('/api/hackathons', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(dataToSend), + await apiFetch('/api/hackathons', { + method: 'POST', + body: dataToSend, }); - - if (response.ok) { - toast({ - title: 'Event created', - description: 'Your event has been created successfully.', - variant: 'success', - }); - // No mostrar modal de confirmación de "update" en creación. - // El popup solo tiene sentido cuando el usuario edita un evento existente. - setShowUpdateModal(false); - setFieldsToUpdate([]); - setFormDataMain(initialData.main); - setFormDataContent(initialData.content); - setFormDataLatest(initialData.latest); - setShowForm(false); - setIsSelectedHackathon(false); - setSelectedHackathon(null); - await getMyHackathons(); - } else { - const data = await response.json().catch(() => ({})); - toast({ - title: 'Error creating event', - description: data?.error ?? 'Failed to create event. Please try again.', - variant: 'destructive', - }); - } + + toast({ + title: 'Event created', + description: 'Your event has been created successfully.', + variant: 'success', + }); + setShowUpdateModal(false); + setFieldsToUpdate([]); + setFormDataMain(initialData.main); + setFormDataContent(initialData.content); + setFormDataLatest(initialData.latest); + setShowForm(false); + setIsSelectedHackathon(false); + setSelectedHackathon(null); + await getMyHackathons(); } catch (error) { console.error('Error creating hackathon:', error); toast({ @@ -1417,36 +1392,24 @@ const HackathonsEdit = () => { console.log({selectedHackathon, id: selectedHackathon?.id}); try { - const response = await fetch(`/api/hackathons/${selectedHackathon?.id}`, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(dataToSend), + await apiFetch(`/api/hackathons/${selectedHackathon?.id}`, { + method: 'PUT', + body: dataToSend, }); - - if (response.ok) { - toast({ - title: 'Event updated', - description: 'Your event has been updated successfully.', - variant: 'success', - }); - setShowUpdateModal(false); - setFormDataMain(initialData.main); - setFormDataContent(initialData.content); - setFormDataLatest(initialData.latest); - setShowForm(false); - setIsSelectedHackathon(false); - setSelectedHackathon(null); - await getMyHackathons(); - } else { - const data = await response.json().catch(() => ({})); - toast({ - title: 'Error updating event', - description: data?.error ?? 'Failed to update event. Please try again.', - variant: 'destructive', - }); - } + + toast({ + title: 'Event updated', + description: 'Your event has been updated successfully.', + variant: 'success', + }); + setShowUpdateModal(false); + setFormDataMain(initialData.main); + setFormDataContent(initialData.content); + setFormDataLatest(initialData.latest); + setShowForm(false); + setIsSelectedHackathon(false); + setSelectedHackathon(null); + await getMyHackathons(); } catch (error) { console.error('Error updating hackathon:', error); toast({ @@ -1465,13 +1428,9 @@ const HackathonsEdit = () => { const handleDeleteClick = async () => { console.log('delete'); try { - const response = await fetch(`/api/hackathons/${selectedHackathon?.id}`, { - method: 'DELETE', - headers: { - 'Content-Type': 'application/json', - }, + await apiFetch(`/api/hackathons/${selectedHackathon?.id}`, { + method: 'DELETE', }); - console.log(response); } catch (error) { console.error('Error deleting hackathon:', error); } @@ -1479,34 +1438,22 @@ const HackathonsEdit = () => { const handleToggleVisibility = async (hackathonId: string, isPublic: boolean) => { try { - console.log({isPublic}) - const response = await fetch(`/api/hackathons/${selectedHackathon?.id}`, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - is_public: isPublic - }), + await apiFetch(`/api/hackathons/${selectedHackathon?.id}`, { + method: 'PUT', + body: { is_public: isPublic }, }); - if (response.ok) { - setMyHackathons(prev => - prev.map(hackathon => - hackathon.id === hackathonId - ? { ...hackathon, is_public: isPublic } - : hackathon - ) - ); - - if (selectedHackathon?.id === hackathonId) { - setSelectedHackathon((prev: any) => prev ? { ...prev, is_public: isPublic } : null); - setFormDataMain((prev: IDataMain) => ({ ...prev, is_public: isPublic })); - } - - console.log(`Hackathon ${hackathonId} visibility updated to ${isPublic ? 'public' : 'private'}`); - } else { - console.error('Failed to update hackathon visibility'); + setMyHackathons(prev => + prev.map(hackathon => + hackathon.id === hackathonId + ? { ...hackathon, is_public: isPublic } + : hackathon + ) + ); + + if (selectedHackathon?.id === hackathonId) { + setSelectedHackathon((prev: any) => prev ? { ...prev, is_public: isPublic } : null); + setFormDataMain((prev: IDataMain) => ({ ...prev, is_public: isPublic })); } } catch (error) { console.error('Error updating hackathon visibility:', error); diff --git a/app/hackathons/edit/page.tsx b/app/hackathons/edit/page.tsx index ab14c3e2a3c..8a2406fc636 100644 --- a/app/hackathons/edit/page.tsx +++ b/app/hackathons/edit/page.tsx @@ -9,7 +9,7 @@ import { Switch } from '@/components/ui/switch'; import { Plus, Trash, ChevronDown, ChevronRight, ExternalLink } from 'lucide-react'; import { t } from './translations'; import { useSession, SessionProvider } from "next-auth/react"; -import axios from 'axios'; +import { apiFetch } from '@/lib/api/client'; import { initialData, IDataMain, IDataContent, IDataLatest, ITrack, ISchedule, ISpeaker, IResource, IPartner } from './initials'; import { LanguageButton } from './language-button'; import HackathonPreview from '@/components/hackathons/HackathonPreview'; @@ -778,16 +778,16 @@ const HackathonsEdit = () => { const getMyHackathons = async () => { setLoadingHackathons(true); try { - const response = await axios.get( + const data = await apiFetch<{ hackathons: any[] }>( `/api/hackathons`, { headers: { - id: session?.user?.id, + id: session?.user?.id ?? '', } } ); - if (response.data?.hackathons?.length > 0) { - const hackathons = response.data.hackathons; + if (data?.hackathons?.length > 0) { + const hackathons = data.hackathons; console.log({response: hackathons }); setMyHackathons(hackathons); } @@ -1294,16 +1294,11 @@ const HackathonsEdit = () => { const blob = await response.blob(); const formData = new FormData(); formData.append('file', blob, fileName); - const uploadResponse = await fetch('/api/file', { + const result = await apiFetch<{ url: string }>('/api/file', { method: 'POST', body: formData, }); - - if (!uploadResponse.ok) { - throw new Error(`Upload failed: ${uploadResponse.statusText}`); - } - - const result = await uploadResponse.json(); + return result.url; } catch (error) { console.error('Error uploading base64 to Vercel:', error); @@ -1370,39 +1365,27 @@ const HackathonsEdit = () => { if (!isSelectedHackathon) { try { - const response = await fetch('/api/hackathons', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(dataToSend), + await apiFetch('/api/hackathons', { + method: 'POST', + body: dataToSend, }); - - if (response.ok) { - toast({ - title: 'Event created', - description: 'Your event has been created successfully.', - variant: 'success', - }); - // No mostrar modal de confirmación de "update" en creación. - // El popup solo tiene sentido cuando el usuario edita un evento existente. - setShowUpdateModal(false); - setFieldsToUpdate([]); - setFormDataMain(initialData.main); - setFormDataContent(initialData.content); - setFormDataLatest(initialData.latest); - setShowForm(false); - setIsSelectedHackathon(false); - setSelectedHackathon(null); - await getMyHackathons(); - } else { - const data = await response.json().catch(() => ({})); - toast({ - title: 'Error creating event', - description: data?.error ?? 'Failed to create event. Please try again.', - variant: 'destructive', - }); - } + + toast({ + title: 'Event created', + description: 'Your event has been created successfully.', + variant: 'success', + }); + // No mostrar modal de confirmación de "update" en creación. + // El popup solo tiene sentido cuando el usuario edita un evento existente. + setShowUpdateModal(false); + setFieldsToUpdate([]); + setFormDataMain(initialData.main); + setFormDataContent(initialData.content); + setFormDataLatest(initialData.latest); + setShowForm(false); + setIsSelectedHackathon(false); + setSelectedHackathon(null); + await getMyHackathons(); } catch (error) { console.error('Error creating hackathon:', error); toast({ @@ -1416,37 +1399,24 @@ const HackathonsEdit = () => { } else { console.log({selectedHackathon, id: selectedHackathon?.id}); try { + await apiFetch(`/api/hackathons/${selectedHackathon?.id}`, { + method: 'PUT', + body: dataToSend, + }); - const response = await fetch(`/api/hackathons/${selectedHackathon?.id}`, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(dataToSend), + toast({ + title: 'Event updated', + description: 'Your event has been updated successfully.', + variant: 'success', }); - - if (response.ok) { - toast({ - title: 'Event updated', - description: 'Your event has been updated successfully.', - variant: 'success', - }); - setShowUpdateModal(false); - setFormDataMain(initialData.main); - setFormDataContent(initialData.content); - setFormDataLatest(initialData.latest); - setShowForm(false); - setIsSelectedHackathon(false); - setSelectedHackathon(null); - await getMyHackathons(); - } else { - const data = await response.json().catch(() => ({})); - toast({ - title: 'Error updating event', - description: data?.error ?? 'Failed to update event. Please try again.', - variant: 'destructive', - }); - } + setShowUpdateModal(false); + setFormDataMain(initialData.main); + setFormDataContent(initialData.content); + setFormDataLatest(initialData.latest); + setShowForm(false); + setIsSelectedHackathon(false); + setSelectedHackathon(null); + await getMyHackathons(); } catch (error) { console.error('Error updating hackathon:', error); toast({ @@ -1465,13 +1435,9 @@ const HackathonsEdit = () => { const handleDeleteClick = async () => { console.log('delete'); try { - const response = await fetch(`/api/hackathons/${selectedHackathon?.id}`, { - method: 'DELETE', - headers: { - 'Content-Type': 'application/json', - }, + await apiFetch(`/api/hackathons/${selectedHackathon?.id}`, { + method: 'DELETE', }); - console.log(response); } catch (error) { console.error('Error deleting hackathon:', error); } @@ -1480,34 +1446,25 @@ const HackathonsEdit = () => { const handleToggleVisibility = async (hackathonId: string, isPublic: boolean) => { try { console.log({isPublic}) - const response = await fetch(`/api/hackathons/${selectedHackathon?.id}`, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - is_public: isPublic - }), + await apiFetch(`/api/hackathons/${selectedHackathon?.id}`, { + method: 'PUT', + body: { is_public: isPublic }, }); - if (response.ok) { - setMyHackathons(prev => - prev.map(hackathon => - hackathon.id === hackathonId - ? { ...hackathon, is_public: isPublic } - : hackathon - ) - ); - - if (selectedHackathon?.id === hackathonId) { - setSelectedHackathon((prev: any) => prev ? { ...prev, is_public: isPublic } : null); - setFormDataMain((prev: IDataMain) => ({ ...prev, is_public: isPublic })); - } - - console.log(`Hackathon ${hackathonId} visibility updated to ${isPublic ? 'public' : 'private'}`); - } else { - console.error('Failed to update hackathon visibility'); + setMyHackathons(prev => + prev.map(hackathon => + hackathon.id === hackathonId + ? { ...hackathon, is_public: isPublic } + : hackathon + ) + ); + + if (selectedHackathon?.id === hackathonId) { + setSelectedHackathon((prev: any) => prev ? { ...prev, is_public: isPublic } : null); + setFormDataMain((prev: IDataMain) => ({ ...prev, is_public: isPublic })); } + + console.log(`Hackathon ${hackathonId} visibility updated to ${isPublic ? 'public' : 'private'}`); } catch (error) { console.error('Error updating hackathon visibility:', error); } diff --git a/components/build-games/ApplicationStatusTracker.tsx b/components/build-games/ApplicationStatusTracker.tsx index 5903016cf73..117f97c2c48 100644 --- a/components/build-games/ApplicationStatusTracker.tsx +++ b/components/build-games/ApplicationStatusTracker.tsx @@ -2,6 +2,7 @@ import { useState, useEffect } from "react"; import { useSession } from "next-auth/react"; +import { apiFetch } from "@/lib/api/client"; interface ParticipantData { projectName: string; @@ -189,8 +190,7 @@ export default function ApplicationStatusTracker() { } setIsLoading(true); - fetch("/api/build-games/status") - .then((res) => res.json()) + apiFetch<{ isParticipant: boolean; participant?: ParticipantData }>("/api/build-games/status") .then((data) => { if (data.isParticipant && data.participant) { setParticipant(data.participant); diff --git a/components/build-games/BuildGamesResourcesWrapper.tsx b/components/build-games/BuildGamesResourcesWrapper.tsx index 027aa04263b..1b52cb5617f 100644 --- a/components/build-games/BuildGamesResourcesWrapper.tsx +++ b/components/build-games/BuildGamesResourcesWrapper.tsx @@ -2,6 +2,7 @@ import { useState, useEffect } from "react"; import { ExternalLink } from 'lucide-react'; +import { apiFetch } from "@/lib/api/client"; import { DynamicIcon } from 'lucide-react/dynamic'; interface Resource { @@ -15,8 +16,7 @@ export default function BuildGamesResourcesWrapper() { const [resources, setResources] = useState([]); useEffect(() => { - fetch("/api/build-games/resources") - .then((res) => res.json()) + apiFetch<{ resources: Resource[] }>("/api/build-games/resources") .then((data) => { if (data.resources) { setResources(data.resources); diff --git a/components/build-games/BuildGamesSubmitForm.tsx b/components/build-games/BuildGamesSubmitForm.tsx index b7c68f5d961..13939c7c9e6 100644 --- a/components/build-games/BuildGamesSubmitForm.tsx +++ b/components/build-games/BuildGamesSubmitForm.tsx @@ -4,7 +4,7 @@ import { useEffect, useState, useCallback, useRef } from "react"; import { useSession } from "next-auth/react"; import { useRouter, useSearchParams } from "next/navigation"; import { useLoginModalTrigger } from "@/hooks/useLoginModal"; -import axios from "axios"; +import { apiFetch } from "@/lib/api/client"; import { useForm, useFieldArray, Controller } from "react-hook-form"; import { standardSchemaResolver } from "@hookform/resolvers/standard-schema"; import { z } from "zod"; @@ -247,17 +247,14 @@ export default function BuildGamesSubmitForm({ // OTP new users have a pending_ ID and no DB record yet — skip until profile is complete if (session.user.id.startsWith("pending_")) return; - axios - .get("/api/project/check-invitation", { - params: { invitation: invitationId, user_id: session.user.id }, - }) - .then((res) => { - if (!res.data?.invitation?.exists) { + apiFetch<{ invitation: { exists: boolean; hasConfirmedProject?: boolean; isConfirming?: boolean }; project?: { id?: string; project_name?: string } }>(`/api/project/check-invitation?invitation=${encodeURIComponent(invitationId)}&user_id=${encodeURIComponent(session.user.id)}`) + .then((data) => { + if (!data?.invitation?.exists) { setOpenInvalidInvitation(true); return; } - const invitation = res.data.invitation; - const project = res.data.project; + const invitation = data.invitation; + const project = data.project; setJoinTeamName(project?.project_name || ""); if (project?.id) setProjectId(project.id); if (invitation.hasConfirmedProject) { @@ -277,10 +274,9 @@ export default function BuildGamesSubmitForm({ }, [session?.user?.id, searchParams]); useEffect(() => { - axios - .get(`/api/hackathons/${HACKATHON_ID}`) - .then((res) => { - const tracks: Track[] = res.data?.content?.tracks ?? []; + apiFetch<{ content?: { tracks?: Track[] } }>(`/api/hackathons/${HACKATHON_ID}`) + .then((data) => { + const tracks: Track[] = data?.content?.tracks ?? []; setHackathonTracks(tracks); setAvailableTracks( tracks.map((t) => ({ value: t.name, label: t.name })) @@ -293,13 +289,10 @@ export default function BuildGamesSubmitForm({ useEffect(() => { if (!session?.user?.id) return; - axios - .get("/api/project", { - params: { hackathon_id: HACKATHON_ID, user_id: session.user.id }, - }) - .then(async (res) => { - if (!res.data.project) return; - const p = res.data.project; + apiFetch<{ project?: any }>(`/api/project?hackathon_id=${encodeURIComponent(HACKATHON_ID)}&user_id=${encodeURIComponent(session.user.id)}`) + .then(async (data) => { + if (!data.project) return; + const p = data.project; const pid = p.id ?? ""; setProjectId(pid); @@ -320,10 +313,8 @@ export default function BuildGamesSubmitForm({ // Fetch and populate build_games FormData fields try { - const fdRes = await axios.get("/api/build-games/stage-data", { - params: { project_id: pid }, - }); - const bg = fdRes.data.form_data?.build_games ?? {}; + const fdRes = await apiFetch<{ form_data?: { build_games?: Record } }>(`/api/build-games/stage-data?project_id=${encodeURIComponent(pid)}`); + const bg = fdRes.form_data?.build_games ?? {}; form.reset({ ...projectValues, bg_problem_statement: bg.problem_statement ?? "", @@ -372,7 +363,7 @@ export default function BuildGamesSubmitForm({ const saveCurrentForm = useCallback(async () => { if (!session?.user?.id) return; - if (isSavingRef.current) return null; // block concurrent invocations (button + MembersComponent) + if (isSavingRef.current) return; // block concurrent invocations (button + MembersComponent) isSavingRef.current = true; try { const data = form.getValues(); @@ -400,9 +391,9 @@ export default function BuildGamesSubmitForm({ isDraft: true, }; - const projectRes = await axios.post("/api/project/", projectPayload); + const projectRes = await apiFetch<{ project?: { id: string }; id?: string }>("/api/project/", { method: "POST", body: projectPayload }); const savedId = - projectRes.data.project?.id ?? projectRes.data.id ?? projectId; + projectRes.project?.id ?? projectRes.id ?? projectId; if (savedId) setProjectId(savedId); // 2. Save build_games FormData fields (only if we have a project ID) @@ -447,10 +438,9 @@ export default function BuildGamesSubmitForm({ }, }, }; - await axios.post("/api/build-games/stage-data", buildGamesPayload); + await apiFetch("/api/build-games/stage-data", { method: "POST", body: buildGamesPayload }); } - return savedId; } finally { isSavingRef.current = false; } @@ -1166,14 +1156,9 @@ export default function BuildGamesSubmitForm({ onHandleSave={saveCurrentForm} onProjectCreated={async () => { if (!session?.user?.id) return; - const res = await axios.get("/api/project", { - params: { - hackathon_id: HACKATHON_ID, - user_id: session.user.id, - }, - }); - if (res.data.project?.id) - setProjectId(res.data.project.id); + const res = await apiFetch<{ project?: { id: string } }>(`/api/project?hackathon_id=${encodeURIComponent(HACKATHON_ID)}&user_id=${encodeURIComponent(session.user.id)}`); + if (res.project?.id) + setProjectId(res.project.id); }} openjoinTeamDialog={openJoinTeamDialog} onOpenChange={setOpenJoinTeamDialog} diff --git a/components/build-games/HowItWorksWrapper.tsx b/components/build-games/HowItWorksWrapper.tsx index 10620981330..9d5692a7653 100644 --- a/components/build-games/HowItWorksWrapper.tsx +++ b/components/build-games/HowItWorksWrapper.tsx @@ -2,6 +2,7 @@ import { useState, useEffect, ReactNode } from "react"; import { useSession } from "next-auth/react"; +import { apiFetch } from "@/lib/api/client"; interface HowItWorksWrapperProps { children: ReactNode; @@ -26,8 +27,7 @@ export default function HowItWorksWrapper({ children }: HowItWorksWrapperProps) } setIsLoading(true); - fetch("/api/build-games/status") - .then((res) => res.json()) + apiFetch<{ hasApplied: boolean; application?: unknown }>("/api/build-games/status") .then((data) => { if (data.hasApplied && data.application) { setHasApplied(true); diff --git a/components/build-games/ProgramTimelineWrapper.tsx b/components/build-games/ProgramTimelineWrapper.tsx index dde2abe5611..604f2b42acc 100644 --- a/components/build-games/ProgramTimelineWrapper.tsx +++ b/components/build-games/ProgramTimelineWrapper.tsx @@ -2,6 +2,7 @@ import { useState, useEffect } from "react"; import { useSession } from "next-auth/react"; +import { apiFetch } from "@/lib/api/client"; import dynamic from "next/dynamic"; const ProgramTimeline = dynamic(() => import("./ProgramTimeline"), { @@ -22,8 +23,7 @@ export default function ProgramTimelineWrapper() { useEffect(() => { if (status !== "authenticated") return; - fetch("/api/build-games/status") - .then((res) => res.json()) + apiFetch<{ isParticipant: boolean; stageResults?: StageResult[] }>("/api/build-games/status") .then((data) => { setIsParticipant(!!data.isParticipant); setStageResults(data.stageResults ?? []); diff --git a/components/build-games/ReferralModal.tsx b/components/build-games/ReferralModal.tsx index 395ba0a110e..1716cc2cbad 100644 --- a/components/build-games/ReferralModal.tsx +++ b/components/build-games/ReferralModal.tsx @@ -2,6 +2,7 @@ import { useState, useEffect } from "react"; import { Copy, Check } from "lucide-react"; +import { apiFetch } from "@/lib/api/client"; import { getSession } from "next-auth/react"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog"; import { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from "@/components/ui/accordion"; @@ -20,8 +21,7 @@ export default function ReferralModal({ isOpen, onClose }: ReferralModalProps) { useEffect(() => { if (!isOpen) return; - fetch("/api/build-games/status") - .then((res) => res.json()) + apiFetch<{ hasApplied: boolean }>("/api/build-games/status") .then((data) => setHasApplication(!!data.hasApplied)) .catch(() => setHasApplication(false)); }, [isOpen]); diff --git a/components/build-games/index.ts b/components/build-games/index.ts new file mode 100644 index 00000000000..c020fa55b91 --- /dev/null +++ b/components/build-games/index.ts @@ -0,0 +1,14 @@ +export { default as ApplicationStatusTracker } from './ApplicationStatusTracker' +export { ApplyButton } from './ApplyButton' +export { default as BuildGamesMentors } from './BuildGamesMentors' +export { default as BuildGamesPartners } from './BuildGamesPartners' +export { default as BuildGamesResources } from './BuildGamesResources' +export { default as BuildGamesResourcesWrapper } from './BuildGamesResourcesWrapper' +export { default as BuildGamesSubmitForm } from './BuildGamesSubmitForm' +export { default as HowItWorksWrapper } from './HowItWorksWrapper' +export { default as ProgramTimeline } from './ProgramTimeline' +export { default as ProgramTimelineWrapper } from './ProgramTimelineWrapper' +export type { StageResult } from './ProgramTimelineWrapper' +export { default as ReferralButton } from './ReferralButton' +export { default as ReferralLink } from './ReferralLink' +export { default as ReferralModal } from './ReferralModal' diff --git a/components/chat/flows/metrics-wrappers.tsx b/components/chat/flows/metrics-wrappers.tsx index a800de32bc5..3734aa1801f 100644 --- a/components/chat/flows/metrics-wrappers.tsx +++ b/components/chat/flows/metrics-wrappers.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from "react"; import { Loader2 } from "lucide-react"; +import { apiFetch } from "@/lib/api/client"; /** * Self-contained wrapper components for metrics visualizations. @@ -41,7 +42,7 @@ export function ICMFlowWrapper() { setLoading(true); setError(false); Promise.all([ - fetch("/api/icm-flow").then((r) => r.json()), + apiFetch("/api/icm-flow"), import("@/components/stats/ICMFlowChart"), ]) .then(([flowData, mod]) => { @@ -79,7 +80,7 @@ export function ICTTDashboardWrapper() { setLoading(true); setError(false); Promise.all([ - fetch("/api/ictt-stats").then((r) => r.json()), + apiFetch("/api/ictt-stats"), import("@/components/stats/ICTTDashboard"), ]) .then(([statsData, mod]) => { @@ -150,8 +151,7 @@ export function OverviewStatsCard() { const fetchData = () => { setLoading(true); setError(false); - fetch("/api/overview-stats?timeRange=day") - .then((r) => r.json()) + apiFetch("/api/overview-stats?timeRange=day") .then((d) => { setData(d); setLoading(false); }) .catch(() => { setError(true); setLoading(false); }); }; diff --git a/components/chat/share-modal.tsx b/components/chat/share-modal.tsx index fa83445006c..3a96f6a03bd 100644 --- a/components/chat/share-modal.tsx +++ b/components/chat/share-modal.tsx @@ -13,6 +13,7 @@ import { Copy, Check, Eye, Clock, Link as LinkIcon, Loader2 } from 'lucide-react import { cn } from '@/lib/cn'; import { toast } from 'sonner'; import posthog from 'posthog-js'; +import { apiFetch } from '@/lib/api/client'; interface ShareInfo { shareToken: string; @@ -55,18 +56,11 @@ export function ShareModal({ const enableSharing = useCallback(async () => { setIsLoading(true); try { - const res = await fetch(`/api/chat-history/${conversationId}/share`, { + const data = await apiFetch(`/api/chat-history/${conversationId}/share`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ expiresInDays: 7 }), // Default: 7 days + body: { expiresInDays: 7 }, // Default: 7 days }); - if (!res.ok) { - const error = await res.json(); - throw new Error(error.error || 'Failed to enable sharing'); - } - - const data: ShareInfo = await res.json(); setShareInfo(data); onShareToggle(); @@ -122,15 +116,10 @@ export function ShareModal({ const handleStopSharing = async () => { setIsLoading(true); try { - const res = await fetch(`/api/chat-history/${conversationId}/share`, { + await apiFetch(`/api/chat-history/${conversationId}/share`, { method: 'DELETE', }); - if (!res.ok) { - const error = await res.json(); - throw new Error(error.error || 'Failed to stop sharing'); - } - setShareInfo(null); setShowStopConfirmation(false); onShareToggle(); diff --git a/components/client/infrabuidl-form.tsx b/components/client/infrabuidl-form.tsx index d1be2249b7e..8ef5c28bdb4 100644 --- a/components/client/infrabuidl-form.tsx +++ b/components/client/infrabuidl-form.tsx @@ -1,5 +1,6 @@ "use client"; import { ReactNode, useState, useEffect } from "react"; +import { apiFetch, ApiClientError } from "@/lib/api/client"; import { zodResolver } from "@hookform/resolvers/zod"; import { useForm } from "react-hook-form"; import { z } from "zod"; @@ -309,20 +310,11 @@ export default function GrantApplicationForm({ } }); - const response = await fetch("/api/infrabuidl", { + await apiFetch("/api/infrabuidl", { method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(hubspotFormData), + body: hubspotFormData, }); - const result = await response.json(); - - if (!response.ok || !result.success) { - throw new Error(result.message || "Failed to submit to HubSpot"); - } - setSubmissionStatus("success"); alert("Your grant application has been successfully submitted!"); form.reset(); diff --git a/components/common/index.ts b/components/common/index.ts new file mode 100644 index 00000000000..2215a6ec998 --- /dev/null +++ b/components/common/index.ts @@ -0,0 +1,2 @@ +export { EmailListInput } from './EmailListInput' +export type { EmailListInputProps } from './EmailListInput' diff --git a/components/content-design/index.ts b/components/content-design/index.ts new file mode 100644 index 00000000000..4d7fed53e0b --- /dev/null +++ b/components/content-design/index.ts @@ -0,0 +1,9 @@ +export { CodeBlock } from './code-block' +export type { CodeBlockProps } from './code-block' +export { default as Gallery } from './gallery' +export { default as Instructors } from './instructor' +export { default as Mermaid } from './mermaid' +export { default as StateGrowthChart } from './state-growth-chart' +export { ThemeProvider, useTheme } from './theme-observer' +export { AutoTypeTable } from './type-table' +export { default as YouTube } from './youtube' diff --git a/components/evaluate/AdvanceStageControls.tsx b/components/evaluate/AdvanceStageControls.tsx index c566888f79e..75b8ed80166 100644 --- a/components/evaluate/AdvanceStageControls.tsx +++ b/components/evaluate/AdvanceStageControls.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; import { Badge } from "@/components/ui/badge"; +import { apiFetch, ApiClientError } from "@/lib/api/client"; import { STAGE_BADGE_COLORS, STAGE_FULL_LABELS } from "./colors"; interface Props { @@ -26,15 +27,10 @@ export function AdvanceStageControls({ setSaving(true); setError(null); try { - const res = await fetch("/api/evaluate/advance-stage", { + await apiFetch("/api/evaluate/advance-stage", { method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ formDataId, stage: newStage }), + body: { formDataId, stage: newStage }, }); - if (!res.ok) { - const err = await res.json(); - throw new Error(err.error ?? "Failed to advance"); - } onStageAdvanced(formDataId, newStage); } catch (err) { setError(err instanceof Error ? err.message : "Failed"); diff --git a/components/evaluate/BulkAdvanceModal.tsx b/components/evaluate/BulkAdvanceModal.tsx index fa543deef64..079c8e7d708 100644 --- a/components/evaluate/BulkAdvanceModal.tsx +++ b/components/evaluate/BulkAdvanceModal.tsx @@ -2,6 +2,7 @@ import { useState, useMemo, useEffect } from "react"; import { X, ChevronRight } from "lucide-react"; +import { apiFetch, ApiClientError } from "@/lib/api/client"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -189,18 +190,10 @@ function BulkAdvanceModal({ try { const ids = qualifying.map((q) => q.row.formDataId); - const res = await fetch("/api/evaluate/advance-stage", { + const data = await apiFetch<{ updated: number }>("/api/evaluate/advance-stage", { method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ formDataIds: ids, stage: targetStage }), + body: { formDataIds: ids, stage: targetStage }, }); - - if (!res.ok) { - const err = await res.json(); - throw new Error(err.error ?? "Failed"); - } - - const data = await res.json(); for (const q of qualifying) { onAdvanced(q.row.formDataId, targetStage); } diff --git a/components/evaluate/EvaluationPanel.tsx b/components/evaluate/EvaluationPanel.tsx index aece70c831e..ce361a95833 100644 --- a/components/evaluate/EvaluationPanel.tsx +++ b/components/evaluate/EvaluationPanel.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; import { Badge } from "@/components/ui/badge"; +import { apiFetch, ApiClientError } from "@/lib/api/client"; import { Button } from "@/components/ui/button"; import { getEventConfig, DEFAULT_SCORE_CRITERIA } from "./event-configs"; import { VERDICT_BUTTON_COLORS, VERDICT_BADGE_COLORS, VERDICT_LABELS } from "./colors"; @@ -115,18 +116,10 @@ export function EvaluationPanel({ body.scores = scoresPayload; } - const res = await fetch("/api/evaluate", { + const data = await apiFetch<{ id: string }>("/api/evaluate", { method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), + body, }); - - if (!res.ok) { - const err = await res.json(); - throw new Error(err.error ?? "Failed to save"); - } - - const data = await res.json(); onEvaluationSaved(formDataId, { id: data.id, formDataId, diff --git a/components/explorer/AddressDetailPage.tsx b/components/explorer/AddressDetailPage.tsx index df4f0e82191..5f95f11d17a 100644 --- a/components/explorer/AddressDetailPage.tsx +++ b/components/explorer/AddressDetailPage.tsx @@ -12,6 +12,7 @@ import l1ChainsData from "@/constants/l1-chains.json"; import ContractReadSection from "@/components/explorer/ContractReadSection"; import ContractWriteSection from "@/components/explorer/ContractWriteSection"; import SourceCodeViewer from "@/components/explorer/SourceCodeViewer"; +import { apiFetch, ApiClientError } from "@/lib/api/client"; interface NativeBalance { balance: string; @@ -514,15 +515,10 @@ export default function AddressDetailPage({ additionalParams.pageToken = pageToken; } const url = buildApiUrl(`/api/explorer/${chainId}/address/${address}`, additionalParams); - const response = await fetch(url); - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.error || "Failed to fetch address data"); - } - const result = await response.json(); + const result = await apiFetch(url); setData(result); } catch (err) { - setError(err instanceof Error ? err.message : "An error occurred"); + setError(err instanceof ApiClientError ? err.message : err instanceof Error ? err.message : "An error occurred"); } finally { setLoading(false); setTxLoading(false); @@ -555,11 +551,9 @@ export default function AddressDetailPage({ additionalParams.pageToken = pageToken; } const url = buildApiUrl(`/api/explorer/${chainId}/address/${address}/erc20-balances`, additionalParams); - - const response = await fetch(url); - if (!response.ok || cancelled) break; - - const result: Erc20BalancesPageResponse = await response.json(); + + if (cancelled) break; + const result = await apiFetch(url); // Accumulate balances allBalances = [...allBalances, ...result.balances]; @@ -603,13 +597,7 @@ export default function AddressDetailPage({ if (cancelled) return; try { - const response = await fetch(`/api/dune/${address}`); - if (!response.ok || cancelled) { - setDuneLabelsLoading(false); - return; - } - - const result = await response.json(); + const result = await apiFetch(`/api/dune/${address}`); if (cancelled) return; diff --git a/components/explorer/ExplorerContext.tsx b/components/explorer/ExplorerContext.tsx index 75658f48763..138b287ebf0 100644 --- a/components/explorer/ExplorerContext.tsx +++ b/components/explorer/ExplorerContext.tsx @@ -2,6 +2,7 @@ import React, { createContext, useContext, useState, useEffect, useCallback, useMemo, ReactNode } from "react"; import l1ChainsData from "@/constants/l1-chains.json"; +import { apiFetch } from "@/lib/api/client"; interface PriceData { price: number; @@ -163,27 +164,24 @@ export function ExplorerProvider({ try { // Only fetch price and glacier support (not full explorer data) const url = buildApiUrl(`/api/explorer/${chainId}`, { priceOnly: 'true' }); - const response = await fetch(url); - if (response.ok) { - const data = await response.json(); - const symbol = data?.tokenSymbol || data?.price?.symbol || nativeToken || ''; - const price = data?.price || null; - const isGlacierSupported = data?.glacierSupported ?? false; - - // Update state - setTokenSymbol(symbol); - setPriceData(price); - setTokenPrice(price?.price || null); - setGlacierSupported(isGlacierSupported); - - // Update cache - tokenDataCache.set(cacheKey, { - data: price, - symbol, - glacierSupported: isGlacierSupported, - timestamp: now, - }); - } + const data = await apiFetch(url); + const symbol = data?.tokenSymbol || data?.price?.symbol || nativeToken || ''; + const price = data?.price || null; + const isGlacierSupported = data?.glacierSupported ?? false; + + // Update state + setTokenSymbol(symbol); + setPriceData(price); + setTokenPrice(price?.price || null); + setGlacierSupported(isGlacierSupported); + + // Update cache + tokenDataCache.set(cacheKey, { + data: price, + symbol, + glacierSupported: isGlacierSupported, + timestamp: now, + }); } catch (err) { console.error("Error fetching token data:", err); // For custom chains without price data, still set the native token symbol diff --git a/components/explorer/L1ExplorerPage.tsx b/components/explorer/L1ExplorerPage.tsx index 894828b2bb0..656c2a349d6 100644 --- a/components/explorer/L1ExplorerPage.tsx +++ b/components/explorer/L1ExplorerPage.tsx @@ -13,6 +13,7 @@ import { useExplorer } from "@/components/explorer/ExplorerContext"; import { formatTokenValue } from "@/utils/formatTokenValue"; import { formatPrice, formatAvaxPrice } from "@/utils/formatPrice"; import l1ChainsData from "@/constants/l1-chains.json"; +import { apiFetch } from "@/lib/api/client"; import { ChainChip, ChainInfo } from "@/components/stats/ChainChip"; import { getL1ListStore, L1ListItem } from "@/components/toolbox/stores/l1ListStore"; import { convertL1ListItemToL1Chain } from "@/components/explorer/utils/chainConverter"; @@ -382,26 +383,20 @@ export default function L1ExplorerPage({ const timeoutPromise = new Promise((resolve) => { setTimeout(() => resolve(null), FETCH_TIMEOUT * 2); }); - - // Race fetch against timeout - const response = await Promise.race([ - fetch(url), + + // Race apiFetch against timeout + const result = await Promise.race([ + apiFetch(url), timeoutPromise ]); - + // If timeout occurred, silently schedule next fetch - if (response === null) { + if (result === null) { shouldScheduleNext = true; nextIsRateLimited = true; // Use longer interval after timeout return; } - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.error || "Failed to fetch data"); - } - const result = await response.json(); - // Update last fetched block from the response if (result.blocks && result.blocks.length > 0) { // Get the highest block number from the response diff --git a/components/explorer/TransactionDetailPage.tsx b/components/explorer/TransactionDetailPage.tsx index 3c7e9370bea..b2c5c1caf1a 100644 --- a/components/explorer/TransactionDetailPage.tsx +++ b/components/explorer/TransactionDetailPage.tsx @@ -12,6 +12,7 @@ import { decodeEventLog, getEventByTopic, decodeFunctionInput } from "@/abi/even import { formatTokenValue, formatUsdValue } from "@/utils/formatTokenValue"; import { formatPrice } from "@/utils/formatPrice"; import l1ChainsData from "@/constants/l1-chains.json"; +import { apiFetch, ApiClientError } from "@/lib/api/client"; interface TransactionDetail { hash: string; @@ -219,14 +220,11 @@ interface TokenInfo { // Fetch token metadata from Glacier API async function fetchTokenMetadata(chainId: string, tokenAddress: string): Promise<{ logoUri?: string; symbol?: string }> { try { - const response = await fetch(`/api/explorer/${chainId}/token/${tokenAddress}/metadata`); - if (response.ok) { - const data = await response.json(); + const data = await apiFetch<{ logoUri?: string; symbol?: string }>(`/api/explorer/${chainId}/token/${tokenAddress}/metadata`); return { - logoUri: data.logoUri, - symbol: data.symbol, + logoUri: data.logoUri, + symbol: data.symbol, }; - } } catch { // Ignore errors } @@ -558,15 +556,10 @@ export default function TransactionDetailPage({ setLoading(true); setError(null); const url = buildApiUrl(`/api/explorer/${chainId}/tx/${txHash}`); - const response = await fetch(url); - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.error || "Failed to fetch transaction"); - } - const data = await response.json(); + const data = await apiFetch(url); setTx(data); } catch (err) { - setError(err instanceof Error ? err.message : "An error occurred"); + setError(err instanceof ApiClientError ? err.message : err instanceof Error ? err.message : "An error occurred"); } finally { setLoading(false); } diff --git a/components/explorer/index.ts b/components/explorer/index.ts new file mode 100644 index 00000000000..b2f317f25ab --- /dev/null +++ b/components/explorer/index.ts @@ -0,0 +1,12 @@ +export { default as AddressDetailPage } from './AddressDetailPage' +export { AllChainsExplorerLayout } from './AllChainsExplorerLayout' +export { default as AllChainsExplorerPage } from './AllChainsExplorerPage' +export { default as BlockDetailPage } from './BlockDetailPage' +export { default as ContractReadSection } from './ContractReadSection' +export { default as ContractWriteSection } from './ContractWriteSection' +export { DetailRow } from './DetailRow' +export { ExplorerProvider, useExplorer, useExplorerOptional } from './ExplorerContext' +export { ExplorerLayout } from './ExplorerLayout' +export { default as L1ExplorerPage } from './L1ExplorerPage' +export { default as SourceCodeViewer } from './SourceCodeViewer' +export { default as TransactionDetailPage } from './TransactionDetailPage' diff --git a/components/hackathons/Events.tsx b/components/hackathons/Events.tsx index 5fbaf8dbaee..1ccad5d6875 100644 --- a/components/hackathons/Events.tsx +++ b/components/hackathons/Events.tsx @@ -12,7 +12,7 @@ import HackathonCard from "./HackathonCard"; import { HackathonHeader, HackathonsFilters } from "@/types/hackathons"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useRouter } from "next/navigation"; -import axios from "axios"; +import { apiFetch } from "@/lib/api/client"; import { Separator } from "../ui/separator"; import { useSession } from "next-auth/react"; import { @@ -158,11 +158,9 @@ export default function Events({ async function fetchEvents() { try { const queryString = buildQueryString(filters, searchQuery, pageSize, pastEventType); - const { data } = await axios.get( + const data = await apiFetch<{ hackathons: HackathonHeader[]; total: number }>( `/api/hackathons?${queryString}&status=ENDED`, - { - signal, - } + { signal }, ); if (!signal.aborted) { diff --git a/components/hackathons/Hackathons.tsx b/components/hackathons/Hackathons.tsx index 429f651135f..5640856bf82 100644 --- a/components/hackathons/Hackathons.tsx +++ b/components/hackathons/Hackathons.tsx @@ -12,7 +12,7 @@ import HackathonCard from "./HackathonCard"; import { HackathonHeader, HackathonsFilters } from "@/types/hackathons"; import { useCallback, useEffect, useRef, useState } from "react"; import { useRouter } from "next/navigation"; -import axios from "axios"; +import { apiFetch } from "@/lib/api/client"; import { Separator } from "../ui/separator"; import { useSession } from 'next-auth/react'; import { @@ -107,11 +107,9 @@ export default function Hackathons({ async function fetchHackathons() { try { const queryString = buildQueryString(filters, searchQuery, pageSize); - const { data } = await axios.get( + const data = await apiFetch<{ hackathons: HackathonHeader[]; total: number }>( `/api/hackathons?${queryString}&status=ENDED`, - { - signal, - } + { signal }, ); if (!signal.aborted) { diff --git a/components/hackathons/admin-panel/HackathonForm.tsx b/components/hackathons/admin-panel/HackathonForm.tsx index ca9b81a392f..db3b1ea9d71 100644 --- a/components/hackathons/admin-panel/HackathonForm.tsx +++ b/components/hackathons/admin-panel/HackathonForm.tsx @@ -13,7 +13,7 @@ import { z } from 'zod'; import { zodResolver } from '@hookform/resolvers/zod'; import { Form } from '@/components/ui/form'; import type { HackathonHeader } from '@/types/hackathons'; -import axios from 'axios'; +import { apiFetch } from '@/lib/api/client'; import { useToast } from '@/hooks/use-toast'; @@ -333,12 +333,12 @@ export default function HackathonForm({ ], }; if (isEditing) { - await axios.put(`/api/hackathons/${initialData!.id}`, payload); + await apiFetch(`/api/hackathons/${initialData!.id}`, { method: 'PUT', body: payload }); toast({ title: 'Hackathon updated successfully', }); } else { - await axios.post(`/api/hackathons/`, payload); + await apiFetch(`/api/hackathons/`, { method: 'POST', body: payload }); toast({ title: 'Hackathon created successfully', }); diff --git a/components/hackathons/index.ts b/components/hackathons/index.ts new file mode 100644 index 00000000000..f406fb47f46 --- /dev/null +++ b/components/hackathons/index.ts @@ -0,0 +1,7 @@ +export { default as DiscoveryCard } from './DiscoveryCard' +export { default as Events } from './Events' +export { default as HackathonCard } from './HackathonCard' +export { default as HackathonPreview } from './HackathonPreview' +export { default as Hackathons } from './Hackathons' +export { NavigationMenu } from './NavigationMenu' +export { default as UTMPreservationWrapper } from './UTMPreservationWrapper' diff --git a/components/hackathons/project-submission/components/General.tsx b/components/hackathons/project-submission/components/General.tsx index bde97277e54..bed7cd7d0b7 100644 --- a/components/hackathons/project-submission/components/General.tsx +++ b/components/hackathons/project-submission/components/General.tsx @@ -15,7 +15,7 @@ import { import { useHackathonProject } from '../hooks/useHackathonProject'; import { ProgressBar } from '../components/ProgressBar'; import { StepNavigation } from '../components/StepNavigation'; -import axios from 'axios'; +import { apiFetch } from '@/lib/api/client'; import { Tag, Users, Pickaxe, Image } from 'lucide-react'; import InvalidInvitationComponent from './InvalidInvitationDialog'; import { useToast } from '@/hooks/use-toast'; @@ -146,20 +146,23 @@ export default function GeneralComponent({ async function checkInvitation() { try { - const response = await axios.get( + const data = await apiFetch<{ + invitation: { exists: boolean; isValid: boolean; isConfirming: boolean; hasConfirmedProject: boolean }; + project: { project_id: string; project_name: string } | null; + }>( `/api/project/check-invitation?invitation=${invitationLink}&user_id=${currentUser?.id}` ); - if (!response.data?.invitation.exists) { - setOpenInvalidInvitation(!response.data?.invitation.isValid); + if (!data?.invitation.exists) { + setOpenInvalidInvitation(!data?.invitation.isValid); return; } - setProjectId(response.data?.project?.project_id ?? ''); + setProjectId(data?.project?.project_id ?? ''); - setOpenJoinTeam(response.data?.invitation.isConfirming ?? false); + setOpenJoinTeam(data?.invitation.isConfirming ?? false); - setTeamName(response.data?.project?.project_name ?? ''); - setOpenCurrentProject(response.data?.invitation.hasConfirmedProject ?? false); + setTeamName(data?.project?.project_name ?? ''); + setOpenCurrentProject(data?.invitation.hasConfirmedProject ?? false); } catch (error) { console.error('Error checking invitation:', error); diff --git a/components/hackathons/project-submission/components/GeneralSecure.tsx b/components/hackathons/project-submission/components/GeneralSecure.tsx index 5be9e8aedcf..51646d8d447 100644 --- a/components/hackathons/project-submission/components/GeneralSecure.tsx +++ b/components/hackathons/project-submission/components/GeneralSecure.tsx @@ -2,6 +2,7 @@ import React, { useState, useEffect } from "react"; import { useSession } from "next-auth/react"; +import { apiFetch } from "@/lib/api/client"; import { Form } from "@/components/ui/form"; import { Separator } from "@/components/ui/separator"; import SubmitStep1 from "./SubmissionStep1"; @@ -70,15 +71,12 @@ export default function GeneralSecureComponent({ const loadProjectById = async () => { if (projectIdParam && !project && isEditing && projectState.status === 'editing') { try { - const response = await fetch(`/api/projects/${projectIdParam}`); - if (response.ok) { - const projectData = await response.json(); - if (projectData) { - setFormData(projectData); - dispatch({ type: "SET_PROJECT_ID", payload: projectData.id || "" }); - if (projectData.hackaton_id) { - dispatch({ type: "SET_HACKATHON_ID", payload: projectData.hackaton_id }); - } + const projectData = await apiFetch(`/api/projects/${projectIdParam}`); + if (projectData) { + setFormData(projectData); + dispatch({ type: "SET_PROJECT_ID", payload: projectData.id || "" }); + if (projectData.hackaton_id) { + dispatch({ type: "SET_HACKATHON_ID", payload: projectData.hackaton_id }); } } } catch (error) { diff --git a/components/hackathons/project-submission/components/JoinTeamDialog.tsx b/components/hackathons/project-submission/components/JoinTeamDialog.tsx index a7ea68bd939..1debecd7e2b 100644 --- a/components/hackathons/project-submission/components/JoinTeamDialog.tsx +++ b/components/hackathons/project-submission/components/JoinTeamDialog.tsx @@ -9,7 +9,7 @@ import { } from "@/components/ui/dialog"; import { useToast } from "@/hooks/use-toast"; import { EventsLang, t } from "@/lib/events/i18n"; -import axios from "axios"; +import { apiFetch } from "@/lib/api/client"; import { useRouter, useSearchParams } from "next/navigation"; import { useEffect, useRef } from "react"; @@ -48,17 +48,18 @@ export const JoinTeamDialog = ({ const handleAcceptJoinTeam = async () => { try { wasActionTaken.current = true; - const response = await axios.patch(`/api/project/${projectId}/members/status`, { - user_id: currentUserId, - status: "Confirmed", + await apiFetch(`/api/project/${projectId}/members/status`, { + method: 'PATCH', + body: { + user_id: currentUserId, + status: "Confirmed", + }, }); - if (response.status === 200) { - if (setLoadData) { - const params = new URLSearchParams(searchParams.toString()); - params.delete("invitation"); - setLoadData(true); - } + if (setLoadData) { + const params = new URLSearchParams(searchParams.toString()); + params.delete("invitation"); + setLoadData(true); } onOpenChange(false); diff --git a/components/hackathons/project-submission/components/Members.tsx b/components/hackathons/project-submission/components/Members.tsx index 4e7c3ab1f7d..e3529151484 100644 --- a/components/hackathons/project-submission/components/Members.tsx +++ b/components/hackathons/project-submission/components/Members.tsx @@ -3,7 +3,7 @@ import { Button } from "@/components/ui/button"; import { BadgeCheck, MoreHorizontal, Search } from "lucide-react"; import { useEffect, useState } from "react"; import { projectProps } from "./SubmissionStep1"; -import axios from "axios"; +import { apiFetch } from "@/lib/api/client"; import { Table, TableBody, @@ -109,16 +109,19 @@ export default function MembersComponent({ await onHandleSave(); } - const invitationResult = await axios.post(`/api/project/invite-member`, { - emails: emails, - hackathon_id: hackaton_id, - project_id: project_id, - user_id: user_id, - lang, - ...(invite_stage !== undefined ? { stage: invite_stage } : {}), + const invitationData = await apiFetch<{ result: any }>(`/api/project/invite-member`, { + method: 'POST', + body: { + emails: emails, + hackathon_id: hackaton_id, + project_id: project_id, + user_id: user_id, + lang, + ...(invite_stage !== undefined ? { stage: invite_stage } : {}), + }, }); - setInvitationResult(invitationResult.data?.result); - if (invitationResult.data?.result?.Success) { + setInvitationResult(invitationData?.result); + if (invitationData?.result?.Success) { setEmailSent(true); } if ((!project_id || project_id === "") && onProjectCreated) { @@ -127,8 +130,8 @@ export default function MembersComponent({ setInvitationSent(true); - const response = await axios.get(`/api/project/${project_id}/members`); - setMembers(response.data); + const membersData = await apiFetch(`/api/project/${project_id}/members`); + setMembers(membersData); } catch (error) { console.error("Error sending invitations:", error); } finally { @@ -138,12 +141,15 @@ export default function MembersComponent({ const handleResendInvitation = async (email: string) => { try { - await axios.post(`/api/project/invite-member`, { - emails: [email], - hackathon_id: hackaton_id, - project_id: project_id, - user_id: user_id, - lang, + await apiFetch(`/api/project/invite-member`, { + method: 'POST', + body: { + emails: [email], + hackathon_id: hackaton_id, + project_id: project_id, + user_id: user_id, + lang, + }, }); } catch (error) { console.error("Error resending invitation:", error); @@ -152,10 +158,13 @@ export default function MembersComponent({ const handleRemoveMember = async (email: string, id_user: string) => { try { - await axios.patch(`/api/project/${project_id}/members/status`, { - user_id: id_user, - status: MemberStatus.REMOVED, - email: email, + await apiFetch(`/api/project/${project_id}/members/status`, { + method: 'PATCH', + body: { + user_id: id_user, + status: MemberStatus.REMOVED, + email: email, + }, }); setMembers(members.filter((member) => member.email !== email)); } catch (error) { @@ -165,9 +174,12 @@ export default function MembersComponent({ const handleRoleChange = async (member: any, newRole: string) => { try { - await axios.patch(`/api/project/${project_id}/members`, { - member_id: member.id, - role: newRole, + await apiFetch(`/api/project/${project_id}/members`, { + method: 'PATCH', + body: { + member_id: member.id, + role: newRole, + }, }); setMembers((prevMembers) => @@ -213,16 +225,18 @@ export default function MembersComponent({ wasInOtherProject: boolean ) => { try { - axios - .patch(`/api/project/${project_id}/members/status`, { + apiFetch(`/api/project/${project_id}/members/status`, { + method: 'PATCH', + body: { user_id: user_id, status: status, wasInOtherProject: wasInOtherProject, - }) + }, + }) .then(() => { console.log("Status updated successfully"); }) - .catch((error) => { + .catch((error: any) => { console.error("Error updating status:", error); }); } catch (error) { @@ -249,8 +263,8 @@ export default function MembersComponent({ const fetchMembers = async () => { try { - const response = await axios.get(`/api/project/${project_id}/members`); - setMembers(response.data); + const membersData = await apiFetch(`/api/project/${project_id}/members`); + setMembers(membersData); } catch (error) { console.error("Error fetching members:", error); } diff --git a/components/hackathons/project-submission/components/UserNotRegistered.tsx b/components/hackathons/project-submission/components/UserNotRegistered.tsx index e4ca2dc8c8f..1103bf53a87 100644 --- a/components/hackathons/project-submission/components/UserNotRegistered.tsx +++ b/components/hackathons/project-submission/components/UserNotRegistered.tsx @@ -2,9 +2,8 @@ import Modal from "@/components/ui/Modal"; import { useSession } from "next-auth/react"; import { useRouter } from "next/navigation"; import React, { useEffect, useState } from "react"; -import axios from "axios"; +import { apiFetch } from "@/lib/api/client"; import { Button } from "@/components/ui/button"; -import { set } from "date-fns"; interface UserNotRegisteredProps { hackathonId: string; @@ -22,10 +21,9 @@ export const UserNotRegistered = ({ const lookForRegistration = async () => { if (!hackathonId || !currentUser?.email) return; - const response = await axios.get( + const loadedData = await apiFetch( `/api/register-form?hackathonId=${hackathonId}&email=${currentUser?.email}` ); - const loadedData = response.data; if (loadedData) { onToggle(true); return; diff --git a/components/hackathons/project-submission/context/ProjectSubmissionContext.tsx b/components/hackathons/project-submission/context/ProjectSubmissionContext.tsx index a33d98b8f9c..c7b9a053900 100644 --- a/components/hackathons/project-submission/context/ProjectSubmissionContext.tsx +++ b/components/hackathons/project-submission/context/ProjectSubmissionContext.tsx @@ -4,7 +4,7 @@ import React, { createContext, useContext, useReducer, useCallback, useEffect, R import { useSession } from 'next-auth/react'; import { useSearchParams } from 'next/navigation'; import { useToast } from '@/hooks/use-toast'; -import axios from 'axios'; +import { apiFetch } from '@/lib/api/client'; export interface ProjectState { @@ -107,9 +107,8 @@ export function ProjectSubmissionProvider({ children }: { children: ReactNode }) const loadProjectById = useCallback(async (projectId: string) => { try { dispatch({ type: 'SET_STATUS', payload: 'loading' }); - const response = await axios.get(`/api/projects/${projectId}`); - const projectData = response.data; - + const projectData = await apiFetch(`/api/projects/${projectId}`); + if (projectData) { dispatch({ type: 'SET_PROJECT_ID', payload: projectData.id }); dispatch({ type: 'SET_PROJECT_DATA', payload: projectData }); @@ -160,19 +159,19 @@ export function ProjectSubmissionProvider({ children }: { children: ReactNode }) if (invitationId) { dispatch({ type: 'SET_INVITATION_ID', payload: invitationId }); - const response = await axios.get(`/api/project/check-invitation`, { - params: { invitation: invitationId, user_id: session?.user?.id } - }); - - if (response.data?.invitation?.exists) { - const invitation = response.data.invitation; - const project = response.data.project; + const params = new URLSearchParams({ invitation: invitationId }); + if (session?.user?.id) params.set('user_id', session.user.id); + const data = await apiFetch<{ invitation?: any; project?: any }>(`/api/project/check-invitation?${params.toString()}`); + + if (data?.invitation?.exists) { + const invitation = data.invitation; + const project = data.project; dispatch({ type: 'SET_OPEN_JOIN_TEAM', payload: invitation.isConfirming ?? false }); dispatch({ type: "SET_TEAM_NAME", payload: project.project_name || "" }); dispatch({ type: 'SET_OPEN_CURRENT_PROJECT', payload: invitation.hasConfirmedProject ?? false }); dispatch({ type: 'SET_EDITING', payload: true }); } else { - dispatch({ type: 'SET_OPEN_INVALID_INVITATION', payload: !response.data?.invitation?.isValid }); + dispatch({ type: 'SET_OPEN_INVALID_INVITATION', payload: !data?.invitation?.isValid }); dispatch({ type: 'SET_EDITING', payload: false }); } } else { @@ -204,10 +203,10 @@ export function ProjectSubmissionProvider({ children }: { children: ReactNode }) id: state.id || undefined, }; - const response = await axios.post('/api/project', projectData); + const result = await apiFetch<{ project?: { id: string } }>('/api/project', { method: 'POST', body: projectData }); - if (response.data?.project?.id) { - const projectId = response.data.project.id; + if (result?.project?.id) { + const projectId = result.project.id; dispatch({ type: 'SET_PROJECT_ID', payload: projectId }); dispatch({ type: 'SET_STATUS', payload: 'editing' }); diff --git a/components/hackathons/project-submission/hooks/useHackathonProject.ts b/components/hackathons/project-submission/hooks/useHackathonProject.ts index e9ca179a36d..0d6b7ea97d5 100644 --- a/components/hackathons/project-submission/hooks/useHackathonProject.ts +++ b/components/hackathons/project-submission/hooks/useHackathonProject.ts @@ -1,8 +1,8 @@ import { useState, useEffect } from 'react'; -import axios from 'axios'; import { useSession } from 'next-auth/react'; import { HackathonHeader } from '@/types/hackathons'; import { useCountdown } from './Count-down'; +import { apiFetch } from '@/lib/api/client'; @@ -20,10 +20,10 @@ export const useHackathonProject = (hackathonId: string,invitationid:string) => const getHackathon = async () => { if (!hackathonId) return; try { - const response = await axios.get(`/api/hackathons/${hackathonId}`); - setHackathon(response.data); - if (response.data?.content?.submission_deadline) { - setDeadline(new Date(response.data.content.submission_deadline).getTime()); + const data = await apiFetch(`/api/hackathons/${hackathonId}`); + setHackathon(data); + if ((data as any)?.content?.submission_deadline) { + setDeadline(new Date((data as any).content.submission_deadline).getTime()); } } catch (err) { console.error("API Error:", err); @@ -32,15 +32,13 @@ export const useHackathonProject = (hackathonId: string,invitationid:string) => const getProject = async () => { try { - const response = await axios.get(`/api/project`, { - params: { - hackathon_id: hackathonId, - user_id: session?.user?.id, - invitation_id: invitationid, - }, - }); - if (response.data.project) { - setProject(response.data.project); + const params = new URLSearchParams(); + if (hackathonId) params.set('hackathon_id', hackathonId); + if (session?.user?.id) params.set('user_id', session.user.id); + if (invitationid) params.set('invitation_id', invitationid); + const data = await apiFetch<{ project?: any }>(`/api/project?${params.toString()}`); + if (data.project) { + setProject(data.project); } } catch (err) { console.error("Error fetching project:", err); diff --git a/components/hackathons/project-submission/hooks/useSubmissionForm.ts b/components/hackathons/project-submission/hooks/useSubmissionForm.ts index b4e8d5dc2b0..6118ce4caf8 100644 --- a/components/hackathons/project-submission/hooks/useSubmissionForm.ts +++ b/components/hackathons/project-submission/hooks/useSubmissionForm.ts @@ -2,10 +2,10 @@ import { useState, useEffect } from 'react'; import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; -import axios from 'axios'; import { useSession } from 'next-auth/react'; import { useRouter } from 'next/navigation'; import { useToast } from '@/hooks/use-toast'; +import { apiFetch } from '@/lib/api/client'; export const FormSchema = z .object({ @@ -184,15 +184,13 @@ export const useSubmissionForm = (hackathonId: string) => { formData.append('file', file); try { - const response = await axios.post('/api/file', formData, { - headers: { - 'Content-Type': 'multipart/form-data', - }, + const result = await apiFetch<{ url: string }>('/api/file', { + method: 'POST', + body: formData, }); - return response.data.url; + return result.url; } catch (error: any) { - const message = - error.response?.data?.error || error.message || 'Error uploading file'; + const message = error.message || 'Error uploading file'; toast({ title: 'Error uploading file', description: message, @@ -210,7 +208,7 @@ export const useSubmissionForm = (hackathonId: string) => { if (!fileName) throw new Error('Invalid old image URL'); try { - await axios.delete('/api/file', { params: { fileName } }); + await apiFetch(`/api/file?fileName=${encodeURIComponent(fileName)}`, { method: 'DELETE' }); const newUrl = await uploadFile(newFile); toast({ title: 'Image replaced', @@ -218,8 +216,7 @@ export const useSubmissionForm = (hackathonId: string) => { }); return newUrl; } catch (error: any) { - const message = - error.response?.data?.error || error.message || 'Error replacing image'; + const message = error.message || 'Error replacing image'; toast({ title: 'Error replacing image', description: message, @@ -234,7 +231,7 @@ export const useSubmissionForm = (hackathonId: string) => { if (!fileName) throw new Error('Invalid old image URL'); try { - await fetch(`/api/file?fileName=${encodeURIComponent(fileName!)}`, { + await apiFetch(`/api/file?fileName=${encodeURIComponent(fileName)}`, { method: 'DELETE', }); toast({ @@ -242,8 +239,7 @@ export const useSubmissionForm = (hackathonId: string) => { description: 'The image has been deleted successfully.', }); } catch (error: any) { - const message = - error.response?.data?.error || error.message || 'Error deleting image'; + const message = error.message || 'Error deleting image'; toast({ title: 'Error deleting image', description: message, @@ -328,10 +324,10 @@ export const useSubmissionForm = (hackathonId: string) => { id: projectId, }; - const response = await axios.post(`/api/project/`, finalData); - setProjectId(response.data.id); + const result = await apiFetch<{ id: string }>(`/api/project/`, { method: 'POST', body: finalData }); + setProjectId(result.id); - return response.data; + return result; } catch (error) { console.error('Error in saveProject:', error); throw error; diff --git a/components/hackathons/project-submission/hooks/useSubmissionFormSecure.ts b/components/hackathons/project-submission/hooks/useSubmissionFormSecure.ts index d9aef92ffd8..8be9179efca 100644 --- a/components/hackathons/project-submission/hooks/useSubmissionFormSecure.ts +++ b/components/hackathons/project-submission/hooks/useSubmissionFormSecure.ts @@ -3,7 +3,7 @@ import { useState, useCallback, useEffect, useRef } from 'react'; import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; -import axios from 'axios'; +import { apiFetch } from '@/lib/api/client'; import { useSession } from 'next-auth/react'; import { useToast } from '@/hooks/use-toast'; import { useProjectSubmission } from '../context/ProjectSubmissionContext'; @@ -381,12 +381,11 @@ export const useSubmissionFormSecure = (lang: EventsLang = 'en') => { formData.append('user_id', session?.user?.id || ''); try { - const response = await axios.post('/api/file', formData, { - headers: { - 'Content-Type': 'multipart/form-data', - }, + const response = await apiFetch<{ url: string }>('/api/file', { + method: 'POST', + body: formData, }); - return response.data.url; + return response.url; } catch (error: any) { const message = error.response?.data?.error || error.message || 'Error uploading file'; toast({ @@ -407,13 +406,10 @@ export const useSubmissionFormSecure = (lang: EventsLang = 'en') => { if (!fileName) throw new Error('Invalid old image URL'); try { - await axios.delete('/api/file', { - params: { - fileName, - ...(state.hackathonId && { hackaton_id: state.hackathonId }), - user_id: session?.user?.id - } - }); + const deleteParams = new URLSearchParams({ fileName }); + if (state.hackathonId) deleteParams.append('hackaton_id', state.hackathonId); + if (session?.user?.id) deleteParams.append('user_id', session.user.id); + await apiFetch(`/api/file?${deleteParams.toString()}`, { method: 'DELETE' }); const newUrl = await uploadFile(newFile); toast({ @@ -444,7 +440,7 @@ export const useSubmissionFormSecure = (lang: EventsLang = 'en') => { if (state.hackathonId) { params.append('hackaton_id', state.hackathonId); } - await fetch(`/api/file?${params.toString()}`, { + await apiFetch(`/api/file?${params.toString()}`, { method: 'DELETE', }); diff --git a/components/hackathons/registration-form/RegistrationForm.tsx b/components/hackathons/registration-form/RegistrationForm.tsx index ad56fe2b9f7..4e336ca2ab2 100644 --- a/components/hackathons/registration-form/RegistrationForm.tsx +++ b/components/hackathons/registration-form/RegistrationForm.tsx @@ -20,7 +20,7 @@ import { RegisterFormStep2 } from "./RegisterFormStep2"; import RegisterFormStep1 from "./RegisterFormStep1"; import { useSession } from "next-auth/react"; import { User } from "next-auth"; -import axios from "axios"; +import { apiFetch } from "@/lib/api/client"; import { HackathonHeader } from "@/types/hackathons"; import { RegistrationForm } from "@/types/registrationForm"; import { useRouter } from "next/navigation"; @@ -138,8 +138,8 @@ export function RegisterForm({ async function getHackathon() { if (!hackathon_id) return; try { - const response = await axios.get(`/api/events/${hackathon_id}`); - setHackathon(response.data); + const hackathonData = await apiFetch(`/api/events/${hackathon_id}`); + setHackathon(hackathonData); } catch (err) { console.error("API Error:", err); } @@ -148,10 +148,9 @@ export function RegisterForm({ async function getRegisterFormLoaded() { if (!hackathon_id || !currentUser?.email) return; try { - const response = await axios.get( + const loadedData = await apiFetch( `/api/register-form?hackathonId=${hackathon_id}&email=${currentUser.email}` ); - const loadedData = response.data; if (loadedData) { const parsedData = { name: loadedData.name || currentUser.name || "", @@ -205,7 +204,7 @@ export function RegisterForm({ async function saveProject(data: RegisterFormValues) { try { - await axios.post(`/api/register-form/`, data); + await apiFetch(`/api/register-form/`, { method: 'POST', body: data }); if (typeof window !== "undefined") { localStorage.removeItem(`formData_${hackathon_id}`); } diff --git a/components/landing/index.ts b/components/landing/index.ts new file mode 100644 index 00000000000..ef2de1aaf58 --- /dev/null +++ b/components/landing/index.ts @@ -0,0 +1,12 @@ +export { default as AcademySplash } from './academy-splash' +export { default as Development } from './development' +export { default as Ecosystem } from './ecosystem' +export { default as Features } from './features' +export { Sponsors } from './globe' +export type { GlobeData } from './globe' +export { default as Grow } from './grow' +export { HeroBackground, default as Hero } from './hero' +export { default as Paths } from './paths' +export { default as QuickLinks } from './quicklinks' +export { default as StudentCallout } from './student-callout' +export { default as Support } from './support' diff --git a/components/login/BasicProfileSetup.tsx b/components/login/BasicProfileSetup.tsx index ff2636435ec..2323eb1724f 100644 --- a/components/login/BasicProfileSetup.tsx +++ b/components/login/BasicProfileSetup.tsx @@ -4,7 +4,7 @@ import React, { useState } from 'react'; import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import * as z from 'zod'; -import axios from 'axios'; +import { apiFetch } from '@/lib/api/client'; import { useRouter } from 'next/navigation'; import { useSession } from 'next-auth/react'; import { @@ -113,7 +113,7 @@ export function BasicProfileSetup({ userId, onSuccess, onCompleteProfile }: Basi }; // Save to API using extended profile endpoint - await axios.put(`/api/profile/extended/${userId}`, profileData); + await apiFetch(`/api/profile/extended/${userId}`, { method: 'PUT', body: profileData }); // Update session await update(); diff --git a/components/login/FormLogin.tsx b/components/login/FormLogin.tsx index 0110c1488c4..6587124e488 100644 --- a/components/login/FormLogin.tsx +++ b/components/login/FormLogin.tsx @@ -18,7 +18,7 @@ import { Button } from "../ui/button"; import { useForm } from "react-hook-form"; import { useState } from "react"; import { VerifyEmail } from "./verify/VerifyEmail"; -import axios from "axios"; +import { apiFetch } from "@/lib/api/client"; import { LoadingButton } from "../ui/loading-button"; const formSchema = z.object({ @@ -40,8 +40,9 @@ function Formlogin({ callbackUrl = "/" }: { callbackUrl?: string }) { setEmail(values.email); try { - await axios.post("/api/send-otp", { - email: values.email.toLowerCase(), + await apiFetch("/api/send-otp", { + method: "POST", + body: { email: values.email.toLowerCase() }, }); setIsVerifying(true); } catch (error) { diff --git a/components/login/LoginModal.tsx b/components/login/LoginModal.tsx index eb2106e03e3..bfc22526255 100644 --- a/components/login/LoginModal.tsx +++ b/components/login/LoginModal.tsx @@ -6,7 +6,7 @@ import Link from "next/link"; import { useForm, Controller } from 'react-hook-form'; import { z } from "zod"; import { zodResolver } from "@hookform/resolvers/zod"; -import axios from "axios"; +import { apiFetch } from "@/lib/api/client"; import { Dialog, DialogOverlay, DialogContent, DialogTitle } from '../toolbox/components/ui/dialog'; import { Input } from "../ui/input"; import { LoadingButton } from "../ui/loading-button"; @@ -50,8 +50,9 @@ export function LoginModal() { setEmail(values.email); try { - await axios.post("/api/send-otp", { - email: values.email.toLowerCase(), + await apiFetch("/api/send-otp", { + method: "POST", + body: { email: values.email.toLowerCase() }, }); setIsVerifying(true); } catch (error) { diff --git a/components/login/terms.tsx b/components/login/terms.tsx index 7af53fc1f59..f178ad27746 100644 --- a/components/login/terms.tsx +++ b/components/login/terms.tsx @@ -9,7 +9,7 @@ import { useRouter } from "next/navigation"; import { useToast } from "@/hooks/use-toast"; import { useSession } from "next-auth/react"; import Link from "next/link"; -import axios from "axios"; +import { apiFetch } from "@/lib/api/client"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import * as z from "zod"; @@ -70,11 +70,12 @@ export const Terms = ({ if (isPendingUser) { // Create the user in the database first - const createResponse = await axios.post("/api/user/create-after-terms", { - notifications: data.notifications, + const createResult = await apiFetch<{ id: string }>("/api/user/create-after-terms", { + method: "POST", + body: { notifications: data.notifications }, }); - if (!createResponse.data.id) { + if (!createResult.id) { throw new Error("Failed to create user account"); } @@ -86,7 +87,7 @@ export const Terms = ({ await new Promise(resolve => setTimeout(resolve, 200)); } else { // Existing user - just save to API - await axios.put(`/api/profile/${userId}`, data); + await apiFetch(`/api/profile/${userId}`, { method: "PUT", body: data }); // Update session await update(); diff --git a/components/login/verify/VerifyEmail.tsx b/components/login/verify/VerifyEmail.tsx index 60126c7945a..4effb386a52 100644 --- a/components/login/verify/VerifyEmail.tsx +++ b/components/login/verify/VerifyEmail.tsx @@ -14,7 +14,7 @@ import { } from "@/components/ui/input-otp"; import Link from "next/link"; import { VerifyEmailProps } from "@/types/verifyEmailProps"; -import axios from "axios"; +import { apiFetch } from "@/lib/api/client"; import { LoadingButton } from "@/components/ui/loading-button"; import { useLoginModalState, triggerNewUserLogin, triggerLoginComplete } from "@/hooks/useLoginModal"; const verifySchema = z.object({ @@ -165,8 +165,9 @@ export function VerifyEmail({ setMessage(null); try { - await axios.post("/api/send-otp", { - email: email, + await apiFetch("/api/send-otp", { + method: "POST", + body: { email: email }, }); setResendCooldown(60); diff --git a/components/navigation/dynamic-blog-menu.tsx b/components/navigation/dynamic-blog-menu.tsx index b51525ba6d9..6683215b0ff 100644 --- a/components/navigation/dynamic-blog-menu.tsx +++ b/components/navigation/dynamic-blog-menu.tsx @@ -3,6 +3,7 @@ import { useEffect, useState } from 'react'; import { type LinkItemType } from 'fumadocs-ui/layouts/shared'; import { BookOpen, FileText, ArrowUpRight } from 'lucide-react'; +import { apiFetch } from '@/lib/api/client'; interface BlogPost { title: string; @@ -37,8 +38,7 @@ export function useDynamicBlogMenu(): LinkItemType { const [latestBlogs, setLatestBlogs] = useState(null); useEffect(() => { - fetch('/api/latest-blogs') - .then(res => res.json()) + apiFetch('/api/latest-blogs') .then(data => setLatestBlogs(data)) .catch(err => console.error('Failed to fetch latest blogs:', err)); }, []); diff --git a/components/navigation/footer.tsx b/components/navigation/footer.tsx index 12c72a5c020..939fb729897 100644 --- a/components/navigation/footer.tsx +++ b/components/navigation/footer.tsx @@ -1,5 +1,6 @@ "use client" import { useState } from "react" +import { apiFetch } from "@/lib/api/client" import Link from 'next/link' import { ArrowUpRight, ExternalLink } from "lucide-react" import { Button } from "@/components/ui/button" @@ -15,24 +16,15 @@ export function Footer() { setIsSubmitting(true); try { - const response = await fetch('/api/newsletter', { + await apiFetch('/api/newsletter', { method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ email }), + body: { email }, }); - const result = await response.json(); - - if (result.success) { - setIsSuccess(true); - setEmail(''); - } else { - console.error('Newsletter signup failed:', result); - } + setIsSuccess(true); + setEmail(''); } catch (error) { - console.error('Error during newsletter signup:', error); + console.error('Newsletter signup failed:', error); } finally { setIsSubmitting(false); } diff --git a/components/navigation/index.ts b/components/navigation/index.ts new file mode 100644 index 00000000000..2705099dc00 --- /dev/null +++ b/components/navigation/index.ts @@ -0,0 +1,18 @@ +export { ActiveNavHighlighter } from './active-nav-highlighter' +export { AvalancheLogo } from './avalanche-logo' +export { default as BubbleNavigation } from './BubbleNavigation' +export type { BubbleNavItem, BubbleNavigationConfig } from './bubble-navigation.types' +export { DocsNavbarToggle } from './docs-navbar-toggle' +export { DocsSubNav } from './docs-subnav' +export { documentationOptions, nodesOptions, apiReferenceOptions, toolingOptions, acpsOptions } from './docs-nav-config' +export type { NavOption } from './docs-nav-config' +export { useDynamicBlogMenu } from './dynamic-blog-menu' +export { Footer } from './footer' +export { ForceMobileSidebar } from './force-mobile-sidebar' +export { NavbarDropdownInjector } from './navbar-dropdown-injector' +export { NavbarDropdown } from './navbar-dropdown' +export { DocsDropdown, AcademyDropdown, GrantsDropdown, IntegrationsDropdown, DropDownBar } from './navigation' +export { RootToggle } from './root-toggle' +export { StatsBreadcrumb } from './StatsBreadcrumb' +export type { NavItem, NavSection } from './nav-config' +export { menuSections, singleItems } from './nav-config' diff --git a/components/notification/notification-bell.tsx b/components/notification/notification-bell.tsx index fbb9032ae48..2356d7a33cc 100644 --- a/components/notification/notification-bell.tsx +++ b/components/notification/notification-bell.tsx @@ -6,6 +6,7 @@ import { cn } from "@/lib/utils"; import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"; import { Popover, PopoverClose, PopoverContent, PopoverTrigger } from "@radix-ui/react-popover"; import { useEffect, useMemo, useState } from "react"; +import { apiFetch } from "@/lib/api/client"; import { useSession } from "next-auth/react"; import { useTheme } from "next-themes"; import DOMPurify from "isomorphic-dompurify"; @@ -64,18 +65,10 @@ export default function NotificationBell(): React.JSX.Element | null { const fetchNotifications = async (): Promise => { try { - const response: Response = await fetch(`/api/notifications/read`, { + await apiFetch(`/api/notifications/read`, { method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(readedNotifications), + body: readedNotifications, }); - - if (!response.ok) { - const text: string = await response.text(); - throw new Error(text || "Failed to read notifications"); - } } catch (err: unknown) { console.error(err); } finally { diff --git a/components/profile/ProfileForm.tsx b/components/profile/ProfileForm.tsx index d2ecd405ca9..d3694858920 100644 --- a/components/profile/ProfileForm.tsx +++ b/components/profile/ProfileForm.tsx @@ -29,7 +29,7 @@ import { } from "@/components/ui/select"; import { motion, AnimatePresence } from "framer-motion"; import { UploadModal } from "@/components/ui/upload-modal"; -import axios from "axios"; +import { apiFetch } from "@/lib/api/client"; import { useRouter } from "next/navigation"; import { useToast } from "@/hooks/use-toast"; import { Toaster } from "../ui/toaster"; @@ -138,10 +138,8 @@ export default function ProfileForm({ // Save the current form data before skipping setIsSaving(true); try { - const formData = form.getValues(); - await axios.put(`/api/profile/${id}`, formData).catch((error) => { - throw new Error(`Error while saving profile: ${error.message}`); - }); + const formValues = form.getValues(); + await apiFetch(`/api/profile/${id}`, { method: 'PUT', body: formValues }); await update(); // Check for stored redirect URL and navigate there, otherwise go to home @@ -183,33 +181,26 @@ export default function ProfileForm({ if (hasImageChanged && initialData.image) { const encodedUrl = encodeURIComponent(initialData.image); - await axios.delete(`/api/file?url=${encodedUrl}`); + await apiFetch(`/api/file?url=${encodedUrl}`, { method: 'DELETE' }); } if (hasImageChanged) { - const fileResponse = await axios - .post("/api/file", formData.current, { - headers: { - "Content-Type": "multipart/form-data", - }, - }) - .catch((error) => { - throw new Error(`Error uploading image: ${error.message}`); - }); - - data.image = fileResponse.data.url; - console.log(fileResponse.data.url); + const fileResult = await apiFetch<{ url: string }>("/api/file", { + method: "POST", + body: formData.current, + }); + + data.image = fileResult.url; } else { data.image = initialData.image; } - const updateProfileResponse = await axios - .put(`/api/profile/${id}`, { ...data }) - .catch((error) => { - throw new Error(`Error while saving profile: ${error.message}`); - }); + const updatedProfile = await apiFetch(`/api/profile/${id}`, { + method: 'PUT', + body: { ...data }, + }); - reset(updateProfileResponse.data); + reset(updatedProfile); formData.current = new FormData(); toast({ diff --git a/components/profile/components/NounAvatarConfig.tsx b/components/profile/components/NounAvatarConfig.tsx index a2a03bb4657..3e1c1b9d37b 100644 --- a/components/profile/components/NounAvatarConfig.tsx +++ b/components/profile/components/NounAvatarConfig.tsx @@ -7,6 +7,7 @@ import Modal from "@/components/ui/Modal"; import { ChevronLeft, ChevronRight, Save } from "lucide-react"; import { LoadingButton } from "@/components/ui/loading-button"; import { useToast } from "@/hooks/use-toast"; +import { apiFetch } from "@/lib/api/client"; interface NounAvatarConfigProps { isOpen: boolean; @@ -131,11 +132,7 @@ export function NounAvatarConfig({ const generateRandomSeed = async () => { setIsGenerating(true); try { - const response = await fetch("/api/user/noun-avatar/generate-seed"); - if (!response.ok) { - throw new Error("Failed to generate seed"); - } - const data = await response.json(); + const data = await apiFetch<{ seed: AvatarSeed }>("/api/user/noun-avatar/generate-seed"); setSeed(data.seed); toast({ title: "New avatar generated!", @@ -157,11 +154,7 @@ export function NounAvatarConfig({ const generateDeterministicSeed = async () => { setIsGenerating(true); try { - const response = await fetch("/api/user/noun-avatar/generate-seed?deterministic=true"); - if (!response.ok) { - throw new Error("Failed to generate seed"); - } - const data = await response.json(); + const data = await apiFetch<{ seed: AvatarSeed }>("/api/user/noun-avatar/generate-seed?deterministic=true"); setSeed(data.seed); toast({ title: "Deterministic avatar generated!", @@ -197,17 +190,11 @@ export function NounAvatarConfig({ setIsSaving(true); try { - const response = await fetch("/api/user/noun-avatar", { + await apiFetch("/api/user/noun-avatar", { method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ seed: seedToSave, enabled: true }), + body: { seed: seedToSave, enabled: true }, }); - if (!response.ok) { - const error = await response.json(); - throw new Error(error.error || "Failed to save avatar"); - } - await onSave(seedToSave, true); onOpenChange(false); diff --git a/components/profile/components/NounAvatarEditor.tsx b/components/profile/components/NounAvatarEditor.tsx index 02feca8aaff..44e7fd00b1d 100644 --- a/components/profile/components/NounAvatarEditor.tsx +++ b/components/profile/components/NounAvatarEditor.tsx @@ -6,6 +6,7 @@ import { DiceBearAvatar, AvatarSeed, AVATAR_OPTIONS } from "./DiceBearAvatar"; import { ChevronLeft, ChevronRight, Save } from "lucide-react"; import { LoadingButton } from "@/components/ui/loading-button"; import { useToast } from "@/hooks/use-toast"; +import { apiFetch } from "@/lib/api/client"; interface NounAvatarEditorProps { currentSeed?: AvatarSeed | null; @@ -105,17 +106,11 @@ export function NounAvatarEditor({ setIsSaving(true); try { - const response = await fetch("/api/user/noun-avatar", { + await apiFetch("/api/user/noun-avatar", { method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ seed, enabled: true }), + body: { seed, enabled: true }, }); - if (!response.ok) { - const error = await response.json(); - throw new Error(error.error || "Failed to save avatar"); - } - await onSave(seed, true); } catch (error) { diff --git a/components/profile/components/hooks/use-project.ts b/components/profile/components/hooks/use-project.ts index 4e2c1caa6c6..4e2f6f005a5 100644 --- a/components/profile/components/hooks/use-project.ts +++ b/components/profile/components/hooks/use-project.ts @@ -1,5 +1,5 @@ import { Project } from "@/types/showcase"; -import axios from "axios"; +import { apiFetch } from "@/lib/api/client"; import { useSession } from "next-auth/react"; import { useEffect, useState } from "react"; @@ -18,8 +18,8 @@ export const useProject = () => { try { setIsLoading(true); setError(null); - const response = await axios.get(`/api/projects/member/${session?.user?.id}`); - setProjects(response.data); + const data = await apiFetch(`/api/projects/member/${session?.user?.id}`); + setProjects(data); } catch (err) { setError(err instanceof Error ? err : new Error("Failed to fetch projects")); setProjects([]); diff --git a/components/profile/components/hooks/usePopularSkills.ts b/components/profile/components/hooks/usePopularSkills.ts index 1d5916992ac..ec185b5a40c 100644 --- a/components/profile/components/hooks/usePopularSkills.ts +++ b/components/profile/components/hooks/usePopularSkills.ts @@ -1,5 +1,5 @@ import { useState, useEffect, useMemo } from "react"; -import axios from "axios"; +import { apiFetch } from "@/lib/api/client"; interface PopularSkill { name: string; @@ -19,16 +19,16 @@ export function usePopularSkills() { const fetchPopularSkills = async () => { setIsLoading(true); setError(null); - + try { - const response = await axios.get('/api/profile/popular-skills', { + const data = await apiFetch('/api/profile/popular-skills', { headers: { 'Cache-Control': 'no-cache', 'Pragma': 'no-cache', }, }); - - setPopularSkills(response.data); + + setPopularSkills(data); } catch (err) { console.error('Error loading popular skills:', err); setError(err instanceof Error ? err.message : 'Unknown error'); diff --git a/components/profile/components/hooks/useProfileForm.ts b/components/profile/components/hooks/useProfileForm.ts index 73404d89e71..ae0a000d13e 100644 --- a/components/profile/components/hooks/useProfileForm.ts +++ b/components/profile/components/hooks/useProfileForm.ts @@ -4,6 +4,7 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; import { useSession } from "next-auth/react"; import { useToast } from "@/hooks/use-toast"; +import { apiFetch } from "@/lib/api/client"; // Zod validation schema - no required fields, only format validations export const profileSchema = z.object({ @@ -94,66 +95,62 @@ export function useProfileForm() { } try { - const response = await fetch(`/api/profile/extended/${session.user.id}`); - - if (response.ok) { - const profile = await response.json(); - - // Check if there's basic profile data from the modal in localStorage - let basicProfileData = null; - if (typeof window !== "undefined") { - const savedBasicProfile = localStorage.getItem('basicProfileData'); - if (savedBasicProfile) { - try { - basicProfileData = JSON.parse(savedBasicProfile); - // Clear it after reading - localStorage.removeItem('basicProfileData'); - } catch (e) { - console.error('Error parsing basic profile data:', e); - } + const profile = await apiFetch>(`/api/profile/extended/${session.user.id}`); + + // Check if there's basic profile data from the modal in localStorage + let basicProfileData = null; + if (typeof window !== "undefined") { + const savedBasicProfile = localStorage.getItem('basicProfileData'); + if (savedBasicProfile) { + try { + basicProfileData = JSON.parse(savedBasicProfile); + // Clear it after reading + localStorage.removeItem('basicProfileData'); + } catch (e) { + console.error('Error parsing basic profile data:', e); } } - - // Decompose user_type from JSON to individual form fields - // Merge with basic profile data if available - const formData = { - name: basicProfileData?.name || profile.name || "", - username: profile.username || "", - bio: profile.bio || "", - email: profile.email || session.user.email || "", - notification_email: profile.notification_email || "", - image: profile.image || "", - country: basicProfileData?.country || profile.country || "", - is_student: basicProfileData?.is_student ?? profile.user_type?.is_student ?? false, - is_founder: basicProfileData?.is_founder ?? profile.user_type?.is_founder ?? false, - is_employee: basicProfileData?.is_employee ?? profile.user_type?.is_employee ?? false, - is_developer: basicProfileData?.is_developer ?? profile.user_type?.is_developer ?? false, - is_enthusiast: basicProfileData?.is_enthusiast ?? profile.user_type?.is_enthusiast ?? false, - founder_company_name: basicProfileData?.founder_company_name || profile.user_type?.founder_company_name || "", - employee_company_name: basicProfileData?.employee_company_name || profile.user_type?.employee_company_name || "", - employee_role: basicProfileData?.employee_role || profile.user_type?.employee_role || "", - student_institution: basicProfileData?.student_institution || profile.user_type?.student_institution || "", - company_name: profile.user_type?.company_name || "", - role: profile.user_type?.role || "", - github: profile.github || "", - wallet: Array.isArray(profile.wallet) ? profile.wallet : (profile.wallet ? [profile.wallet] : []), - socials: profile.socials || [], - skills: profile.skills || [], - notifications: profile.notifications || false, - profile_privacy: profile.profile_privacy || "public", - telegram_user: profile.telegram_user || "", - }; - - form.reset(formData); - - // Update last saved data reference - lastSavedDataRef.current = JSON.stringify(formData); - - // Mark initial load as complete after a short delay - setTimeout(() => { - isInitialLoadRef.current = false; - }, 500); } + + // Decompose user_type from JSON to individual form fields + // Merge with basic profile data if available + const formData = { + name: basicProfileData?.name || profile.name || "", + username: profile.username || "", + bio: profile.bio || "", + email: profile.email || session.user.email || "", + notification_email: profile.notification_email || "", + image: profile.image || "", + country: basicProfileData?.country || profile.country || "", + is_student: basicProfileData?.is_student ?? profile.user_type?.is_student ?? false, + is_founder: basicProfileData?.is_founder ?? profile.user_type?.is_founder ?? false, + is_employee: basicProfileData?.is_employee ?? profile.user_type?.is_employee ?? false, + is_developer: basicProfileData?.is_developer ?? profile.user_type?.is_developer ?? false, + is_enthusiast: basicProfileData?.is_enthusiast ?? profile.user_type?.is_enthusiast ?? false, + founder_company_name: basicProfileData?.founder_company_name || profile.user_type?.founder_company_name || "", + employee_company_name: basicProfileData?.employee_company_name || profile.user_type?.employee_company_name || "", + employee_role: basicProfileData?.employee_role || profile.user_type?.employee_role || "", + student_institution: basicProfileData?.student_institution || profile.user_type?.student_institution || "", + company_name: profile.user_type?.company_name || "", + role: profile.user_type?.role || "", + github: profile.github || "", + wallet: Array.isArray(profile.wallet) ? profile.wallet : (profile.wallet ? [profile.wallet] : []), + socials: profile.socials || [], + skills: profile.skills || [], + notifications: profile.notifications || false, + profile_privacy: profile.profile_privacy || "public", + telegram_user: profile.telegram_user || "", + }; + + form.reset(formData); + + // Update last saved data reference + lastSavedDataRef.current = JSON.stringify(formData); + + // Mark initial load as complete after a short delay + setTimeout(() => { + isInitialLoadRef.current = false; + }, 500); } catch (error) { console.error('Error loading profile:', error); toast({ @@ -219,16 +216,13 @@ export function useProfileForm() { const hasImageChanged = formData.current.has("file"); if (hasImageChanged) { try { - const imageResponse = await fetch("/api/file", { + const imageResult = await apiFetch<{ url: string }>("/api/file", { method: "POST", body: formData.current, }); - if (imageResponse.ok) { - const imageData = await imageResponse.json(); - imageUrl = imageData.url; - formData.current = new FormData(); - } + imageUrl = imageResult.url; + formData.current = new FormData(); } catch (imageError) { console.error("Image upload error during auto-save:", imageError); // Continue with existing image URL if upload fails @@ -277,18 +271,11 @@ export function useProfileForm() { } }; - const response = await fetch(`/api/profile/extended/${session.user.id}`, { + await apiFetch(`/api/profile/extended/${session.user.id}`, { method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(profileData), + body: profileData, }); - - if (!response.ok) { - throw new Error('Failed to auto-save profile'); - } - - const updatedProfile = await response.json(); - + // Update last saved data reference with the data we just sent // This ensures we track what was actually saved without resetting the form lastSavedDataRef.current = currentDataString; @@ -379,18 +366,13 @@ export function useProfileForm() { // If there's a new image, upload it first if (hasImageChanged) { try { - const imageResponse = await fetch("/api/file", { + const imageResult = await apiFetch<{ url: string }>("/api/file", { method: "POST", body: formData.current, }); - if (!imageResponse.ok) { - throw new Error("Error uploading image"); - } + imageUrl = imageResult.url; - const imageData = await imageResponse.json(); - imageUrl = imageData.url; - // Clear formData after upload formData.current = new FormData(); } catch (imageError) { @@ -446,19 +428,11 @@ export function useProfileForm() { }; console.log("Saving profile data:", profileData); - - const response = await fetch(`/api/profile/extended/${session.user.id}`, { + + const updatedProfile = await apiFetch>(`/api/profile/extended/${session.user.id}`, { method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(profileData), + body: profileData, }); - - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.error || 'Failed to update profile'); - } - - const updatedProfile = await response.json(); console.log('Profile updated successfully:', updatedProfile); toast({ diff --git a/components/profile/components/profile-tab.tsx b/components/profile/components/profile-tab.tsx index 998b10a1e31..6a08edcc91f 100644 --- a/components/profile/components/profile-tab.tsx +++ b/components/profile/components/profile-tab.tsx @@ -11,6 +11,7 @@ import { useSession } from "next-auth/react"; import { useProfileForm } from "./hooks/useProfileForm"; import { AvatarSeed } from "./DiceBearAvatar"; import { NounAvatarConfig } from "./NounAvatarConfig"; +import { apiFetch } from "@/lib/api/client"; // Map hash values to tab values (case-insensitive) const hashToTabMap: Record = { @@ -40,12 +41,9 @@ export default function ProfileTab({ achievements }: ProfileTabProps) { useEffect(() => { async function loadNounAvatar() { try { - const response = await fetch("/api/user/noun-avatar"); - if (response.ok) { - const data = await response.json(); - setNounAvatarSeed(data.seed); - setNounAvatarEnabled(data.enabled ?? false); - } + const data = await apiFetch<{ seed: AvatarSeed; enabled?: boolean }>("/api/user/noun-avatar"); + setNounAvatarSeed(data.seed); + setNounAvatarEnabled(data.enabled ?? false); } catch (error) { console.error("Error loading Noun avatar:", error); } diff --git a/components/profile/index.ts b/components/profile/index.ts new file mode 100644 index 00000000000..fa90c6176aa --- /dev/null +++ b/components/profile/index.ts @@ -0,0 +1,2 @@ +export { PostHogDebug } from './PostHogDebug' +export { default as ProfileForm } from './ProfileForm' diff --git a/components/quizzes/components/BadgeNotification.tsx b/components/quizzes/components/BadgeNotification.tsx index 6693adcca4d..9bdde6c0772 100644 --- a/components/quizzes/components/BadgeNotification.tsx +++ b/components/quizzes/components/BadgeNotification.tsx @@ -26,20 +26,19 @@ export const BadgeNotification = ({ if (isCompleted && session && !badgeAwardedRef.current) { badgeAwardedRef.current = true; awardBadge() - .then((badge) => { + .then((result) => { if ( - badge?.result && - Array.isArray(badge.result.badges) && - badge.result.badges.length > 0 + result && + Array.isArray(result.badges) && + result.badges.length > 0 ) { - setBadges(badge.result.badges); + setBadges(result.badges); setShowFireworks(true); setIsModalOpen(true); } }) - .catch((error) => { + .catch(() => { badgeAwardedRef.current = false; // Allow retry on error - console.error("Error awarding badge:", error); }); } }, [isCompleted, session]); diff --git a/components/quizzes/hooks/useBadgeAward.ts b/components/quizzes/hooks/useBadgeAward.ts index cb237118574..9db743e7013 100644 --- a/components/quizzes/hooks/useBadgeAward.ts +++ b/components/quizzes/hooks/useBadgeAward.ts @@ -2,18 +2,23 @@ import { BadgeCategory } from "@/server/services/badge"; import { Badge } from "@/types/badge"; import { useSession } from "next-auth/react"; import { useState } from "react"; +import { apiFetch } from "@/lib/api/client"; + +interface AssignBadgeResult { + success: boolean; + message: string; + badge_id: string; + user_id: string; + badges: any[]; +} export const useBadgeAward = (courseId: string) => { - // Usar try-catch para manejar el error de SessionProvider let session = null; try { const { data } = useSession(); - session = data; - } catch (error) { - - // Si no hay SessionProvider, session será null - console.warn("SessionProvider not available, badge award will be disabled"); + } catch { + // SessionProvider not available — badge award will be disabled } const [isLoading, setIsLoading] = useState(false); @@ -21,7 +26,6 @@ export const useBadgeAward = (courseId: string) => { const [isAwarded, setIsAwarded] = useState(false); const awardBadge = async () => { - // Si no hay sesión, no hacer nada if (!session?.user?.id) { setError("User not authenticated"); return; @@ -31,22 +35,15 @@ export const useBadgeAward = (courseId: string) => { setError(null); try { - const response = await fetch("/api/badge/assign", { + const data = await apiFetch("/api/badge/assign", { method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ + body: { courseId, userId: session.user.id, category: BadgeCategory.academy, - }), + }, }); - if (!response.ok) { - throw new Error(`Badge assignment failed: ${response.status}`); - } - const data = await response.json(); - if (data.result?.success) { + if (data.success) { setIsAwarded(true); } return data; @@ -59,9 +56,8 @@ export const useBadgeAward = (courseId: string) => { }; const getBadge = async (courseId: string): Promise => { - const response = await fetch(`/api/badge?course_id=${courseId}`); - const data = await response.json(); - return data as Badge; + const data = await apiFetch(`/api/badge?course_id=${courseId}`); + return data; }; return { diff --git a/components/showcase/ProjectOptions.tsx b/components/showcase/ProjectOptions.tsx index 07d0022bd2f..70c7e69aa43 100644 --- a/components/showcase/ProjectOptions.tsx +++ b/components/showcase/ProjectOptions.tsx @@ -18,7 +18,7 @@ import { AlertDialogCancel, AlertDialogAction, } from "../ui/alert-dialog"; -import axios from "axios"; +import { apiFetch } from "@/lib/api/client"; import { useToast } from "@/hooks/use-toast"; import { Toaster } from "../ui/toaster"; import { AssignBadge } from "./assign-badge"; @@ -43,18 +43,20 @@ export const ProjectOptions = ({ const { toast } = useToast(); const handleSetWinner = async (e: React.MouseEvent) => { e.stopPropagation(); - const response = await axios.put(`/api/project/set-winner`, { - project_id: project.id, - isWinner: true, - }); - - if (response.data.success) { + try { + await apiFetch(`/api/project/set-winner`, { + method: 'PUT', + body: { + project_id: project.id, + isWinner: true, + }, + }); toast({ title: "Project winner set successfully", description: "The project has been marked as the winner", duration: 3000, }); - } else { + } catch { toast({ title: "Failed to set project winner", description: "Unable to mark project as winner. Please try again.", diff --git a/components/showcase/assign-badge.tsx b/components/showcase/assign-badge.tsx index 01fd0081a76..71283eb85e8 100644 --- a/components/showcase/assign-badge.tsx +++ b/components/showcase/assign-badge.tsx @@ -2,12 +2,13 @@ import { useEffect, useState, useMemo } from "react"; import Modal from "../ui/Modal"; import { Project } from "@/types/showcase"; import { MultiSelect } from "../ui/multi-select"; -import axios from "axios"; import { Badge } from "@/types/badge"; import { BadgeCategory } from "@/server/services/badge"; import { useToast } from "@/hooks/use-toast"; import { LoadingButton } from "../ui/loading-button"; import { Toaster } from "../ui/toaster"; +import { apiFetch } from "@/lib/api/client"; + type showAssignBadgeProps = { isOpen: boolean; onOpenChange: (open: boolean) => void; @@ -36,8 +37,8 @@ export const AssignBadge = ({ if (!isOpen) return; const fetchBadges = async () => { - const response = await axios.get("/api/badge/get-all"); - const filteredBadges = response.data.filter( + const data = await apiFetch("/api/badge/get-all"); + const filteredBadges = data.filter( (badge: Badge) => badge.category == "hackathon" ); @@ -64,18 +65,21 @@ export const AssignBadge = ({ const handleAssignBadges = async () => { setIsLoading(true); - const response = await axios.post("/api/badge/assign", { - badgesId: selectedBadges, - projectId: project.id, - category: BadgeCategory.project, - }); - if (response.status == 200) { + try { + await apiFetch("/api/badge/assign", { + method: "POST", + body: { + badgesId: selectedBadges, + projectId: project.id, + category: BadgeCategory.project, + }, + }); toast({ title: "Badges assigned successfully", description: "The badges have been assigned to the project", duration: 3000, }); - } else { + } catch { toast({ title: "Failed to assign badges", description: "The badges have not been assigned to the project", diff --git a/components/showcase/hooks/useExports.tsx b/components/showcase/hooks/useExports.tsx index 695f4d1a8d0..9ea9970778e 100644 --- a/components/showcase/hooks/useExports.tsx +++ b/components/showcase/hooks/useExports.tsx @@ -1,5 +1,5 @@ import { useState, useCallback } from 'react'; -import axios, { AxiosError } from 'axios'; +import { apiFetch } from '@/lib/api/client'; interface ExportFilters { [key: string]: any; @@ -47,48 +47,24 @@ export const useExports = (): UseExportsReturn => { setError(null); try { - const response = await axios.post( - '/api/projects/export', - filters || {}, - { - responseType: 'blob', - headers: { - 'Content-Type': 'application/json', - }, - } - ); - - const contentType = response.headers['content-type']; - + const response = await apiFetch('/api/projects/export', { + method: 'POST', + body: filters || {}, + raw: true, + }); + + const contentType = response.headers.get('content-type'); + if (contentType && contentType.includes('application/json')) { - const text = await response.data.text(); - const errorData = JSON.parse(text); + const errorData = await response.json(); throw new Error(errorData.message || 'Error exporting data'); } + const blob = await response.blob(); const fileName = generateFileName(); - downloadFile(response.data, fileName); + downloadFile(blob, fileName); } catch (err) { - const axiosError = err as AxiosError<{ message?: string }>; - - let errorMessage = 'Error exporting data'; - - if (axiosError.response?.data) { - if (axiosError.response.data instanceof Blob) { - try { - const text = await axiosError.response.data.text(); - const errorData = JSON.parse(text); - errorMessage = errorData.message || errorMessage; - } catch { - errorMessage = 'An error occurred while processing the server response'; - } - } else if (axiosError.response.data.message) { - errorMessage = axiosError.response.data.message; - } - } else if (axiosError.message) { - errorMessage = axiosError.message; - } - + const errorMessage = err instanceof Error ? err.message : 'Error exporting data'; setError(errorMessage); throw new Error(errorMessage); } finally { diff --git a/components/showcase/sections/TeamBadge.tsx b/components/showcase/sections/TeamBadge.tsx index fbe44299085..ee9af7583d9 100644 --- a/components/showcase/sections/TeamBadge.tsx +++ b/components/showcase/sections/TeamBadge.tsx @@ -4,7 +4,7 @@ import { Separator } from "@/components/ui/separator"; import Image from "next/image"; import React, { useState, useEffect } from "react"; import { ProjectBadge } from "@/types/badge"; -import axios from "axios"; +import { apiFetch } from "@/lib/api/client"; type Props = { projectId: string; @@ -14,10 +14,13 @@ export const TeamBadge = ({ projectId }: Props) => { const [badges, setBadges] = useState([]); useEffect(() => { if (!projectId) return; - const bad = axios.get(`/api/badge/project-badge?project_id=${projectId}`); - bad.then((res) => { - setBadges(res.data); - }); + apiFetch(`/api/badge/project-badge?project_id=${projectId}`) + .then((data) => { + setBadges(data); + }) + .catch(() => { + // Silently fail — badge section stays empty + }); }, [projectId]); return ( diff --git a/components/stats/ChainMetricsPage.tsx b/components/stats/ChainMetricsPage.tsx index 299ff4f8dde..ad174888f9f 100644 --- a/components/stats/ChainMetricsPage.tsx +++ b/components/stats/ChainMetricsPage.tsx @@ -1,5 +1,6 @@ "use client"; import { useState, useEffect, useMemo, useCallback, useRef, useTransition } from "react"; +import { apiFetch } from "@/lib/api/client"; import { useSearchParams, useRouter, usePathname } from "next/navigation"; import { Area, AreaChart, Bar, BarChart, CartesianGrid, Line, LineChart, XAxis, YAxis, Tooltip, Brush, ResponsiveContainer, ComposedChart } from "recharts"; import { Card, CardContent } from "@/components/ui/card"; @@ -365,13 +366,7 @@ export default function ChainMetricsPage({ try { setLoading(true); setError(null); - const response = await fetch( - `/api/chain-stats/${chainId}?timeRange=all` - ); - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - const data = await response.json(); + const data = await apiFetch(`/api/chain-stats/${chainId}?timeRange=all`); setMetrics(data); setIsInitialLoad(false); } catch (err) { @@ -398,11 +393,7 @@ export default function ChainMetricsPage({ if (cachedAllData) { allData = cachedAllData; } else { - const allResponse = await fetch(`/api/chain-stats/all?timeRange=all`); - if (!allResponse.ok) { - throw new Error(`HTTP error! status: ${allResponse.status}`); - } - allData = await allResponse.json(); + allData = await apiFetch(`/api/chain-stats/all?timeRange=all`); setCachedAllData(allData); // Cache for future filter changes } @@ -414,11 +405,7 @@ export default function ChainMetricsPage({ const excludedResults = await Promise.all( excludedChainIds.map(async (cid) => { try { - const response = await fetch( - `/api/chain-stats/${cid}?timeRange=all` - ); - if (!response.ok) return null; - return await response.json(); + return await apiFetch(`/api/chain-stats/${cid}?timeRange=all`); } catch { return null; } diff --git a/components/stats/ConfigurableChart.tsx b/components/stats/ConfigurableChart.tsx index 95bada8fb08..b6662293aeb 100644 --- a/components/stats/ConfigurableChart.tsx +++ b/components/stats/ConfigurableChart.tsx @@ -1,5 +1,6 @@ "use client"; import { useState, useMemo, useEffect, useRef } from "react"; +import { apiFetch } from "@/lib/api/client"; import { Area, Bar, CartesianGrid, Line, LineChart, XAxis, YAxis, Tooltip, Brush, ResponsiveContainer, ComposedChart } from "recharts"; import { Card, CardContent } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; @@ -379,12 +380,7 @@ export default function ConfigurableChart({ queryString += `&startTimestamp=${startTimestamp}&endTimestamp=${endTimestamp}`; } - const response = await fetch(`/api/chain-stats/${chainId}?${queryString}`); - if (!response.ok) { - throw new Error(`Failed to fetch data: ${response.status}`); - } - - const chainMetrics: ChainMetrics = await response.json(); + const chainMetrics = await apiFetch(`/api/chain-stats/${chainId}?${queryString}`); const metric = chainMetrics[metricKey as keyof ChainMetrics]; if (!metric) { diff --git a/components/stats/LiveBlockBurns.tsx b/components/stats/LiveBlockBurns.tsx index e329f181ad3..32ecee35dc6 100644 --- a/components/stats/LiveBlockBurns.tsx +++ b/components/stats/LiveBlockBurns.tsx @@ -1,6 +1,7 @@ "use client"; import { useEffect, useState, useRef, useCallback } from "react"; +import { apiFetch, ApiClientError } from "@/lib/api/client"; import { Flame } from "lucide-react"; import { Card, CardContent } from "@/components/ui/card"; @@ -68,13 +69,7 @@ export function LiveBlockBurns() { params.set("initialLoad", "true"); } - const response = await fetch(`/api/explorer/${CHAIN_ID}?${params.toString()}`); - - if (!response.ok) { - throw new Error(`Failed to fetch: ${response.status}`); - } - - const data: ExplorerResponse = await response.json(); + const data = await apiFetch(`/api/explorer/${CHAIN_ID}?${params.toString()}`); if (!isMountedRef.current) return; @@ -124,7 +119,7 @@ export function LiveBlockBurns() { setIsLoading(false); } catch (err) { if (isMountedRef.current) { - setError(err instanceof Error ? err.message : "Failed to fetch data"); + setError(err instanceof ApiClientError ? err.message : err instanceof Error ? err.message : "Failed to fetch data"); setIsLoading(false); } } finally { diff --git a/components/stats/ValidatorWorldMap.tsx b/components/stats/ValidatorWorldMap.tsx index a973830d997..d152e908b51 100644 --- a/components/stats/ValidatorWorldMap.tsx +++ b/components/stats/ValidatorWorldMap.tsx @@ -2,6 +2,7 @@ import { useEffect, useState, useRef } from "react"; import createGlobe from "cobe"; +import { apiFetch } from "@/lib/api/client"; import { Card, CardContent, @@ -164,10 +165,7 @@ export function ValidatorWorldMap() { const fetchGeoData = async () => { try { - const response = await fetch("/api/validator-geolocation"); - if (!response.ok) throw new Error("Failed to fetch geolocation data"); - - const data = await response.json(); + const data = await apiFetch("/api/validator-geolocation"); setGeoData(data); } catch (err) { setError(err instanceof Error ? err.message : "Failed to load map data"); diff --git a/components/stats/contract-gas-xray.tsx b/components/stats/contract-gas-xray.tsx index 21338ff23fa..da199014c73 100644 --- a/components/stats/contract-gas-xray.tsx +++ b/components/stats/contract-gas-xray.tsx @@ -1,6 +1,7 @@ "use client"; import { useState, useEffect, useRef, useCallback, useMemo } from "react"; +import { apiFetch, ApiClientError } from "@/lib/api/client"; import { Search, ChevronDown, ChevronUp } from "lucide-react"; import { Spinner } from "@/components/ui/spinner"; import { CustomDateRangePicker } from "@/components/custom-date-range-picker"; @@ -67,17 +68,13 @@ export default function ContractGasXray({ initialAddress }: ContractGasXrayProps setError(null); setData(null); try { - const res = await fetch(`/api/dapps/contract-gas-flow?address=${addressInput}&days=${activeDays}`, { + const result = await apiFetch(`/api/dapps/contract-gas-flow?address=${addressInput}&days=${activeDays}`, { signal: controller.signal, }); - if (!res.ok) { - const body = await res.json().catch(() => ({})); - throw new Error(body.error || `HTTP ${res.status}`); - } - setData(await res.json()); + setData(result); } catch (err) { if (err instanceof DOMException && err.name === "AbortError") return; - setError(err instanceof Error ? err.message : "Unknown error"); + setError(err instanceof ApiClientError ? err.message : err instanceof Error ? err.message : "Unknown error"); } finally { if (!controller.signal.aborted) { setLoading(false); diff --git a/components/stats/image-export/hooks/useCollageMetrics.ts b/components/stats/image-export/hooks/useCollageMetrics.ts index b83f8f7dcf7..27b5fd8fc4f 100644 --- a/components/stats/image-export/hooks/useCollageMetrics.ts +++ b/components/stats/image-export/hooks/useCollageMetrics.ts @@ -1,6 +1,7 @@ "use client"; import { useState, useEffect, useCallback, useRef } from "react"; +import { apiFetch } from "@/lib/api/client"; import type { CollageMetricConfig, CollageMetricData, ChartDataPoint, Period } from "../types"; // Aggregate data points by period using SUM (matching single chart behavior in ChainMetricsPage) @@ -111,16 +112,10 @@ export function useCollageMetrics( try { // Fetch all metrics data at once - const response = await fetch( + const data = await apiFetch( `/api/chain-stats/${chainId}?timeRange=all` ); - if (!response.ok) { - throw new Error(`Failed to fetch metrics: ${response.statusText}`); - } - - const data = await response.json(); - // Process response and update metrics data const newMetricsData = new Map(); diff --git a/components/stats/index.ts b/components/stats/index.ts new file mode 100644 index 00000000000..b7b983211ac --- /dev/null +++ b/components/stats/index.ts @@ -0,0 +1,45 @@ +export { BaasProviderBadge, BaasProviderList } from './BaasProviderBadge' +export { categoryColors, getCategoryBadgeStyle, getCategoryColor, CategoryChip } from './CategoryChip' +export type { CategoryChipProps } from './CategoryChip' +export { ChainCategoryFilter } from './ChainCategoryFilter' +export type { ChainCategoryFilterProps } from './ChainCategoryFilter' +export { ChainChip, createChainInfo } from './ChainChip' +export type { ChainInfo, ChainChipProps } from './ChainChip' +export { default as ChainMetricsPage } from './ChainMetricsPage' +export { parseDateString, calculateDateRangeDays, formatXAxisLabel, generateXAxisTicks } from './chart-axis-utils' +export { ChartWatermark } from './ChartWatermark' +export { default as ConfigurableChart } from './ConfigurableChart' +export type { DataSeries, ChartDataPoint, ChartDataExport, ConfigurableChartProps } from './ConfigurableChart' +export { default as ContractGasXray } from './contract-gas-xray' +export { ExplorerDropdown } from './ExplorerDropdown' +export { GasBurnBars } from './gas-burn-bars' +export { GasCategoryTimeline } from './gas-category-timeline' +export { ProtocolSpotlight } from './gas-treemap-spotlight' +export { GasTreemapTable } from './gas-treemap-table' +export { CATEGORY_LABELS, SUBCATEGORY_LABELS, CATEGORY_COLORS } from './gas-treemap-utils' +export type { CategoryBreakdown, ProtocolBreakdown } from './gas-treemap-utils' +export { default as GasTreemap } from './gas-treemap' +export { default as ICMFlowChart } from './ICMFlowChart' +export { ICMGlobe } from './ICMGlobe' +export { ICTTDashboard, ICTTTransfersTable } from './ICTTDashboard' +export { L1BubbleNav } from './l1-bubble.config' +export type { L1BubbleNavProps } from './l1-bubble.config' +export { LinkableHeading } from './LinkableHeading' +export { LiveBlockBurns } from './LiveBlockBurns' +export { default as MiniNetworkDiagram } from './MiniNetworkDiagram' +export type { MiniChainData, MiniICMFlow } from './MiniNetworkDiagram' +export { MobileSocialLinks } from './MobileSocialLinks' +export { default as NetworkDiagram } from './NetworkDiagram' +export type { ChainCosmosData, ICMFlowRoute } from './NetworkDiagram' +export { PeriodSelector } from './PeriodSelector' +export type { Period } from './PeriodSelector' +export { PlaygroundBackground } from './PlaygroundBackground' +export { SearchInputWithClear } from './SearchInputWithClear' +export { SortIcon } from './SortIcon' +export { statsBubbleConfig, StatsBubbleNav } from './stats-bubble.config' +export { squarify } from './squarify' +export type { SquarifyItem, SquarifyRect } from './squarify' +export { StickyNavBar } from './StickyNavBar' +export { ValidatorWorldMap } from './ValidatorWorldMap' +export { versionColors, getVersionColor, compareVersions } from './VersionBreakdown' +export type { VersionData, VersionBreakdownData } from './VersionBreakdown' diff --git a/components/toolbox/components/ValidatorListInput/AddValidatorControls.tsx b/components/toolbox/components/ValidatorListInput/AddValidatorControls.tsx index d0deb50c5d8..ab2cf38fdfd 100644 --- a/components/toolbox/components/ValidatorListInput/AddValidatorControls.tsx +++ b/components/toolbox/components/ValidatorListInput/AddValidatorControls.tsx @@ -7,6 +7,7 @@ import { cn } from '../utils'; import { Tabs, TabsList, TabsTrigger, TabsContent } from '../ui/tabs'; import { Input } from '../ui/input'; import type { ConvertToL1Validator } from '../ValidatorListInput'; +import { apiFetch } from '@/lib/api/client'; type ManagedTestnetNodeSuggestion = { id: string; @@ -50,16 +51,9 @@ export function AddValidatorControls({ let isMounted = true; const fetchManagedNodes = async () => { try { - const response = await fetch('/api/managed-testnet-nodes', { - method: 'GET', - headers: { 'Content-Type': 'application/json' }, - }); - const data = await response.json(); - if (!response.ok || data.error) { - throw new Error(data.message || data.error || 'Failed to fetch hosted nodes'); - } + const data = await apiFetch<{ nodes?: ManagedTestnetNodeSuggestion[] }>('/api/managed-testnet-nodes'); if (isMounted && Array.isArray(data.nodes)) { - setManagedNodes(data.nodes as ManagedTestnetNodeSuggestion[]); + setManagedNodes(data.nodes); } } catch (e) { console.error('Failed to fetch hosted nodes for autofill:', e); diff --git a/components/toolbox/components/console-header/pchain-wallet/components/PChainFaucetMenuItem.tsx b/components/toolbox/components/console-header/pchain-wallet/components/PChainFaucetMenuItem.tsx index 8138b814655..de302657a12 100644 --- a/components/toolbox/components/console-header/pchain-wallet/components/PChainFaucetMenuItem.tsx +++ b/components/toolbox/components/console-header/pchain-wallet/components/PChainFaucetMenuItem.tsx @@ -14,6 +14,7 @@ import { } from '@/components/toolbox/components/AlertDialog'; import useConsoleNotifications from '@/hooks/useConsoleNotifications'; import { useFaucetRateLimit } from '@/hooks/useFaucetRateLimit'; +import { apiFetch, ApiClientError } from '@/lib/api/client'; export function PChainFaucetMenuItem() { const pChainAddress = useWalletStore((s) => s.pChainAddress); @@ -46,34 +47,22 @@ export function PChainFaucetMenuItem() { setIsRequestingPTokens(true); const faucetRequest = async () => { - const response = await fetch(`/api/pchain-faucet?address=${pChainAddress}`); - const rawText = await response.text(); - let data; - try { - data = JSON.parse(rawText); - } catch { - throw new Error(`Invalid response: ${rawText.substring(0, 100)}...`); - } - - if (!response.ok) { - if (response.status === 401) { - throw new Error('Please login first'); - } - if (response.status === 429) { - throw new Error(data.message || 'Rate limit exceeded. Please try again later.'); - } - throw new Error(data.message || `Error ${response.status}: Failed to get tokens`); - } - - if (data.success) { + const data = await apiFetch<{ success: boolean; message?: string }>( + `/api/pchain-faucet?address=${pChainAddress}`, + ); setTimeout(() => { updatePChainBalance(); checkRateLimit(); // Refresh rate limit status }, 3000); return data; - } else { - throw new Error(data.message || 'Failed to get tokens'); + } catch (error) { + if (error instanceof ApiClientError) { + if (error.status === 401) throw new Error('Please login first'); + if (error.status === 429) throw new Error(error.message || 'Rate limit exceeded. Please try again later.'); + throw new Error(error.message || `Error ${error.status}: Failed to get tokens`); + } + throw error; } }; diff --git a/components/toolbox/console/primary-network/DevnetFaucet.tsx b/components/toolbox/console/primary-network/DevnetFaucet.tsx index b0ec1066022..c31e9cf6fed 100644 --- a/components/toolbox/console/primary-network/DevnetFaucet.tsx +++ b/components/toolbox/console/primary-network/DevnetFaucet.tsx @@ -12,6 +12,7 @@ import { import { generateConsoleToolGitHubUrl } from '@/components/toolbox/utils/githubUrl'; import { AccountRequirementsConfigKey } from '../../hooks/useAccountRequirements'; import { useWalletStore } from '@/components/toolbox/stores/walletStore'; +import { apiFetch } from '@/lib/api/client'; const DEVNET_RPC_URL = 'https://api.avax-dev.network/ext/bc/C/rpc'; const DEVNET_CHAIN_ID = 43117; @@ -78,12 +79,9 @@ function DevnetFaucet({ onSuccess: _onSuccess }: BaseConsoleToolProps) { const fetchBalance = useCallback(async () => { setIsLoadingBalance(true); try { - const res = await fetch('/api/devnet-faucet/balance'); - const data = await res.json(); - if (data.success) { - setFaucetBalance(data.balance); - setFaucetAddress(data.address); - } + const data = await apiFetch<{ balance: string; address: string }>('/api/devnet-faucet/balance'); + setFaucetBalance(data.balance); + setFaucetAddress(data.address); } catch { // silently fail } finally { @@ -161,13 +159,9 @@ function DevnetFaucet({ onSuccess: _onSuccess }: BaseConsoleToolProps) { setResult(null); try { - const response = await fetch(`/api/devnet-faucet?address=${walletEVMAddress}`); - const data = await response.json(); - - if (!response.ok) { - setResult({ success: false, message: data.message || 'Failed to drip tokens' }); - return; - } + const data = await apiFetch<{ amount: string; txHash?: string }>( + `/api/devnet-faucet?address=${walletEVMAddress}`, + ); setResult({ success: true, @@ -179,8 +173,11 @@ function DevnetFaucet({ onSuccess: _onSuccess }: BaseConsoleToolProps) { fetchBalance(); fetchUserBalance(); }, 2000); - } catch { - setResult({ success: false, message: 'Network error. Please try again.' }); + } catch (error) { + setResult({ + success: false, + message: error instanceof Error ? error.message : 'Network error. Please try again.', + }); } finally { setIsDripping(false); } diff --git a/components/toolbox/console/primary-network/Faucet.tsx b/components/toolbox/console/primary-network/Faucet.tsx index 7b1ada89910..733712ceaf0 100644 --- a/components/toolbox/console/primary-network/Faucet.tsx +++ b/components/toolbox/console/primary-network/Faucet.tsx @@ -20,6 +20,7 @@ import { useWalletStore } from '../../stores/walletStore'; import { useWallet } from '../../hooks/useWallet'; import Link from 'next/link'; import useConsoleNotifications from '@/hooks/useConsoleNotifications'; +import { apiFetch, ApiClientError } from '@/lib/api/client'; function FaucetBalanceDisplay({ balance, @@ -142,28 +143,17 @@ function ManualPChainFaucetInput() { try { const faucetRequest = async () => { - const response = await fetch(`/api/pchain-faucet?address=${encodeURIComponent(normalizedAddress)}`); - const rawText = await response.text(); - - let data; try { - data = JSON.parse(rawText); - } catch { - throw new Error('Faucet temporarily unavailable. Please try again later.'); - } - - if (!response.ok) { - if (response.status === 429) { - throw new Error(data.message || 'Rate limit exceeded. Please try again later.'); + return await apiFetch<{ success: boolean }>( + `/api/pchain-faucet?address=${encodeURIComponent(normalizedAddress)}`, + ); + } catch (error) { + if (error instanceof ApiClientError) { + if (error.status === 429) throw new Error(error.message || 'Rate limit exceeded. Please try again later.'); + throw new Error(error.message || `Error ${error.status}: Failed to get tokens`); } - throw new Error(data.message || `Error ${response.status}: Failed to get tokens`); - } - - if (!data.success) { - throw new Error(data.message || 'Failed to get tokens'); + throw new Error('Faucet temporarily unavailable. Please try again later.'); } - - return data; }; const faucetPromise = faucetRequest(); diff --git a/components/toolbox/console/primary-network/ValidatorLookup.tsx b/components/toolbox/console/primary-network/ValidatorLookup.tsx index fdf9b48ebea..772680b975c 100644 --- a/components/toolbox/console/primary-network/ValidatorLookup.tsx +++ b/components/toolbox/console/primary-network/ValidatorLookup.tsx @@ -8,6 +8,7 @@ import { withConsoleToolMetadata, } from '../../components/WithConsoleToolMetadata'; import { generateConsoleToolGitHubUrl } from '@/components/toolbox/utils/githubUrl'; +import { apiFetch, ApiClientError } from '@/lib/api/client'; const metadata: ConsoleToolMetadata = { title: 'Validator Lookup', @@ -105,16 +106,14 @@ function ValidatorLookupInner(_props: BaseConsoleToolProps) { setData(null); try { - const res = await fetch(`/api/validators/${encodeURIComponent(id)}`); - if (!res.ok) { - if (res.status === 404) throw new Error('Validator not found'); - throw new Error(`Failed to fetch (${res.status})`); - } - const json = await res.json(); - if (json.error) throw new Error(json.error); + const json = await apiFetch(`/api/validators/${encodeURIComponent(id)}`); setData(json); } catch (err) { - setError(err instanceof Error ? err.message : 'Something went wrong'); + if (err instanceof ApiClientError && err.status === 404) { + setError('Validator not found'); + } else { + setError(err instanceof Error ? err.message : 'Something went wrong'); + } } finally { setLoading(false); } diff --git a/components/toolbox/console/utilities/data-api-keys/TokenManagement.tsx b/components/toolbox/console/utilities/data-api-keys/TokenManagement.tsx index 83b48b96607..2dafa7bb230 100644 --- a/components/toolbox/console/utilities/data-api-keys/TokenManagement.tsx +++ b/components/toolbox/console/utilities/data-api-keys/TokenManagement.tsx @@ -18,6 +18,7 @@ import { } from '@/components/toolbox/components/WithConsoleToolMetadata'; import { generateConsoleToolGitHubUrl } from '@/components/toolbox/utils/githubUrl'; import { AccountRequirementsConfigKey } from '@/components/toolbox/hooks/useAccountRequirements'; +import { apiFetch, ApiClientError } from '@/lib/api/client'; interface GlacierJwtResponse { glacierJwt: string; @@ -64,22 +65,16 @@ function TokenManagementInner({ onSuccess: _onSuccess }: BaseConsoleToolProps) { setJwtError(null); try { - const response = await fetch('/api/glacier-jwt'); - if (!response.ok) { - if (response.status === 401) { - setJwtError('Please log in to manage your API keys'); - } else { - setJwtError('Failed to initialize. Please try again.'); - } - return; - } - - const data: GlacierJwtResponse = await response.json(); + const data = await apiFetch('/api/glacier-jwt'); setJwtData(data); apiClientRef.current = new GlacierApiClient(data.glacierJwt, data.endpoint); } catch (err) { - console.error('Failed to fetch JWT:', err); - setJwtError('Failed to initialize. Please try again.'); + if (err instanceof ApiClientError && err.status === 401) { + setJwtError('Please log in to manage your API keys'); + } else { + console.error('Failed to fetch JWT:', err); + setJwtError('Failed to initialize. Please try again.'); + } } finally { setJwtLoading(false); } diff --git a/components/toolbox/hooks/useSafeAPI.ts b/components/toolbox/hooks/useSafeAPI.ts index 19412b651eb..c1a8f5ae085 100644 --- a/components/toolbox/hooks/useSafeAPI.ts +++ b/components/toolbox/hooks/useSafeAPI.ts @@ -1,4 +1,5 @@ import { useCallback } from 'react'; +import { apiFetch } from '@/lib/api/client'; interface SafeAPIParams { chainId?: string; @@ -10,12 +11,6 @@ interface SafeAPIParams { [key: string]: any; } -interface SafeAPIResponse { - success: boolean; - data: T; - error?: string; -} - /** * Custom hook for calling the Safe API backend * @@ -38,25 +33,10 @@ interface SafeAPIResponse { */ export const useSafeAPI = () => { const callSafeAPI = useCallback(async (action: string, params: SafeAPIParams = {}): Promise => { - const response = await fetch('/api/safe', { + return apiFetch('/api/safe', { method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ action, ...params }), + body: { action, ...params }, }); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error(errorData.error || `HTTP ${response.status}: Failed to ${action}`); - } - - const result: SafeAPIResponse = await response.json(); - if (!result.success) { - throw new Error(result.error || `Failed to ${action}`); - } - - return result.data; }, []); return { callSafeAPI }; diff --git a/components/ui/index.ts b/components/ui/index.ts new file mode 100644 index 00000000000..f7c857b6585 --- /dev/null +++ b/components/ui/index.ts @@ -0,0 +1,277 @@ +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from './accordion' +export { AddToWalletButton } from './add-to-wallet-button' +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +} from './alert-dialog' +export { Alert, AlertTitle, AlertDescription } from './alert' +export { AspectRatio } from './aspect-ratio' +export { Avatar, AvatarImage, AvatarFallback } from './avatar' +export { BackToTop } from './back-to-top' +export { Badge, badgeVariants } from './badge' +export { + Breadcrumb, + BreadcrumbList, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbPage, + BreadcrumbSeparator, + BreadcrumbEllipsis, +} from './breadcrumb' +export { Button, buttonVariants } from './button' +export { Calendar } from './calendar' +export { + Card, + CardHeader, + CardFooter, + CardTitle, + CardAction, + CardDescription, + CardContent, +} from './card' +export { Carousel, CarouselContent, CarouselItem, CarouselPrevious, CarouselNext } from './carousel' +export type { CarouselApi } from './carousel' +export { ChartSkeletonLoader } from './chart-skeleton' +export { + ChartContainer, + ChartTooltip, + ChartTooltipContent, + ChartLegend, + ChartLegendContent, + ChartStyle, +} from './chart' +export type { ChartConfig } from './chart' +export { Checkbox } from './checkbox' +export { Collapsible, CollapsibleTrigger, CollapsibleContent } from './collapsible' +export { + Command, + CommandDialog, + CommandInput, + CommandList, + CommandEmpty, + CommandGroup, + CommandItem, + CommandShortcut, + CommandSeparator, +} from './command' +export { default as Comments } from './comments' +export { + ContextMenu, + ContextMenuTrigger, + ContextMenuContent, + ContextMenuItem, + ContextMenuCheckboxItem, + ContextMenuRadioItem, + ContextMenuLabel, + ContextMenuSeparator, + ContextMenuShortcut, + ContextMenuGroup, + ContextMenuPortal, + ContextMenuSub, + ContextMenuSubContent, + ContextMenuSubTrigger, + ContextMenuRadioGroup, +} from './context-menu' +export { CopyableIdChip, FormatToggleIdChip, ChainIdChips } from './copyable-id-chip' +export { CustomCountdownBanner } from './custom-countdown-banner' +export { CustomErrorToast, showCustomErrorToast } from './custom-error-toast' +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, +} from './dialog' +export { Divider } from './divider' +export { + Drawer, + DrawerPortal, + DrawerOverlay, + DrawerTrigger, + DrawerClose, + DrawerContent, + DrawerHeader, + DrawerFooter, + DrawerTitle, + DrawerDescription, +} from './drawer' +export { + DropdownMenu, + DropdownMenuPortal, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuLabel, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubTrigger, + DropdownMenuSubContent, +} from './dropdown-menu' +export { Feedback } from './feedback' +export type { UnifiedFeedbackProps } from './feedback' +export { + useFormField, + Form, + FormItem, + FormLabel, + FormControl, + FormDescription, + FormMessage, + FormField, +} from './form' +export { HoverCard, HoverCardTrigger, HoverCardContent } from './hover-card' +export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator } from './input-otp' +export { Input } from './input' +export { Label } from './label' +export { LoadingButton } from './loading-button' +export { + Menubar, + MenubarPortal, + MenubarMenu, + MenubarTrigger, + MenubarContent, + MenubarGroup, + MenubarSeparator, + MenubarLabel, + MenubarItem, + MenubarShortcut, + MenubarCheckboxItem, + MenubarRadioGroup, + MenubarRadioItem, + MenubarSub, + MenubarSubTrigger, + MenubarSubContent, +} from './menubar' +export { default as Modal } from './Modal' +export { MultiSelect } from './multi-select' +export { + NavigationMenu, + NavigationMenuList, + NavigationMenuItem, + NavigationMenuContent, + NavigationMenuTrigger, + NavigationMenuLink, + NavigationMenuIndicator, + NavigationMenuViewport, + navigationMenuTriggerStyle, +} from './navigation-menu' +export { + Pagination, + PaginationContent, + PaginationLink, + PaginationItem, + PaginationPrevious, + PaginationNext, + PaginationEllipsis, +} from './pagination' +export { Pills, Pill } from './pills' +export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor } from './popover' +export { Progress } from './progress' +export { RadioGroup, RadioGroupItem } from './radio-group' +export { default as RequestUpdateButton } from './request-update-button' +export { ResizablePanelGroup, ResizablePanel, ResizableHandle } from './resizable' +export { ScrollArea, ScrollBar } from './scroll-area' +export { SearchEventInput } from './search-event-input' +export { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectScrollDownButton, + SelectScrollUpButton, + SelectSeparator, + SelectTrigger, + SelectValue, +} from './select' +export { Separator } from './separator' +export { + Sheet, + SheetTrigger, + SheetClose, + SheetContent, + SheetHeader, + SheetFooter, + SheetTitle, + SheetDescription, +} from './sheet' +export { SidebarActions } from './sidebar-actions' +export type { SidebarActionsProps } from './sidebar-actions' +export { + Sidebar, + SidebarContent, + SidebarFooter, + SidebarGroup, + SidebarGroupAction, + SidebarGroupContent, + SidebarGroupLabel, + SidebarHeader, + SidebarInput, + SidebarInset, + SidebarMenu, + SidebarMenuAction, + SidebarMenuBadge, + SidebarMenuButton, + SidebarMenuItem, + SidebarMenuSkeleton, + SidebarMenuSub, + SidebarMenuSubButton, + SidebarMenuSubItem, + SidebarProvider, + SidebarRail, + SidebarSeparator, + SidebarTrigger, + useSidebar, +} from './sidebar' +export { Skeleton } from './skeleton' +export { Slider } from './slider' +export { Toaster } from './sonner' +export { Spinner } from './spinner' +export { Switch } from './switch' +export { + Table, + TableHeader, + TableBody, + TableFooter, + TableHead, + TableRow, + TableCell, + TableCaption, +} from './table' +export { Tabs, TabsList, TabsTrigger, TabsContent } from './tabs' +export { Textarea } from './textarea' +export { isValidTimezone, resolveTimezone, TimeZoneSelect } from './timezone-select' +export { + ToastProvider, + ToastViewport, + Toast, + ToastTitle, + ToastDescription, + ToastClose, + ToastAction, +} from './toast' +export type { ToastProps, ToastActionElement } from './toast' +export { Toaster as ToastNotifier } from './toaster' +export { ToggleGroup, ToggleGroupItem } from './toggle-group' +export { Toggle, toggleVariants } from './toggle' +export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from './tooltip' +export { useToast, toast } from './use-toast' diff --git a/components/university/UniversitySlideshow.tsx b/components/university/UniversitySlideshow.tsx index 3daa4d5cd9a..d309be67371 100644 --- a/components/university/UniversitySlideshow.tsx +++ b/components/university/UniversitySlideshow.tsx @@ -4,6 +4,7 @@ import { useState, useEffect } from 'react'; import Image from 'next/image'; import { ChevronLeft, ChevronRight } from 'lucide-react'; import { Button } from '@/components/ui/button'; +import { apiFetch } from '@/lib/api/client'; interface SlideshowImage { url: string; @@ -25,11 +26,7 @@ export default function UniversitySlideshow({ className = "" }: UniversitySlides useEffect(() => { const fetchImages = async () => { try { - const response = await fetch('/api/university/slideshow'); - if (!response.ok) { - throw new Error('Failed to fetch slideshow images'); - } - const data = await response.json(); + const data = await apiFetch<{ images: SlideshowImage[] }>('/api/university/slideshow'); setImages(data.images || []); } catch (err) { console.error('Error fetching slideshow images:', err); diff --git a/components/validator-alerts/AlertDashboard.tsx b/components/validator-alerts/AlertDashboard.tsx index 69a77d56ebe..d1e82ee7a19 100644 --- a/components/validator-alerts/AlertDashboard.tsx +++ b/components/validator-alerts/AlertDashboard.tsx @@ -30,6 +30,7 @@ import { AddValidatorDialog } from './AddValidatorDialog'; import { BulkImportDialog } from './BulkImportDialog'; import { AlertPreferences } from './AlertPreferences'; import { AlertHistory } from './AlertHistory'; +import { apiFetch, ApiClientError } from '@/lib/api/client'; import type { ValidatorAlertResponse, CreateAlertRequest, @@ -64,15 +65,12 @@ export function AlertDashboard() { const fetchValidators = useCallback(async () => { try { - const res = await fetch('/api/validators'); - if (res.ok) { - const data: ValidatorP2P[] = await res.json(); - const map = new Map(); - for (const v of data) { - map.set(v.node_id, v); - } - setValidatorData(map); + const data = await apiFetch('/api/validators'); + const map = new Map(); + for (const v of data) { + map.set(v.node_id, v); } + setValidatorData(map); } catch (err) { console.error('Failed to fetch validator data:', err); } @@ -80,11 +78,8 @@ export function AlertDashboard() { const fetchAlerts = useCallback(async () => { try { - const res = await fetch('/api/validator-alerts'); - if (res.ok) { - const data = await res.json(); - setAlerts(data); - } + const data = await apiFetch('/api/validator-alerts'); + setAlerts(data); } catch (err) { console.error('Failed to fetch alerts:', err); } finally { @@ -121,45 +116,42 @@ export function AlertDashboard() { }, [status, fetchAlerts, fetchValidators]); async function handleAdd(data: CreateAlertRequest): Promise<{ error?: string }> { - const res = await fetch('/api/validator-alerts', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(data), - }); - const result = await res.json(); - if (!res.ok) return { error: result.error }; - setAlerts((prev) => [result, ...prev]); - toast.success('Validator added', 'You will receive alerts for this validator.'); - return {}; + try { + const result = await apiFetch('/api/validator-alerts', { + method: 'POST', + body: data, + }); + setAlerts((prev) => [result, ...prev]); + toast.success('Validator added', 'You will receive alerts for this validator.'); + return {}; + } catch (err) { + return { error: err instanceof ApiClientError ? err.message : 'Failed to add validator' }; + } } async function handleUpdate(id: string, data: UpdateAlertRequest) { - const res = await fetch(`/api/validator-alerts/${id}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(data), - }); - if (res.ok) { - const updated = await res.json(); + try { + const updated = await apiFetch(`/api/validator-alerts/${id}`, { + method: 'PUT', + body: data, + }); setAlerts((prev) => prev.map((a) => (a.id === id ? updated : a))); toast.success('Preferences saved'); - } else { + } catch { toast.error('Failed to save preferences'); } } async function handleToggleActive(id: string, active: boolean) { setTogglingId(id); - const res = await fetch(`/api/validator-alerts/${id}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ active }), - }); - if (res.ok) { - const updated = await res.json(); + try { + const updated = await apiFetch(`/api/validator-alerts/${id}`, { + method: 'PUT', + body: { active }, + }); setAlerts((prev) => prev.map((a) => (a.id === id ? updated : a))); toast.success(active ? 'Alerts resumed' : 'Alerts paused'); - } else { + } catch { toast.error('Failed to update alert status'); } setTogglingId(null); @@ -167,12 +159,12 @@ export function AlertDashboard() { async function handleDelete(id: string) { setDeletingId(id); - const res = await fetch(`/api/validator-alerts/${id}`, { method: 'DELETE' }); - if (res.ok) { + try { + await apiFetch(`/api/validator-alerts/${id}`, { method: 'DELETE' }); setAlerts((prev) => prev.filter((a) => a.id !== id)); if (expandedId === id) setExpandedId(null); toast.success('Validator alert removed'); - } else { + } catch { toast.error('Failed to remove alert'); } setDeletingId(null); diff --git a/eslint.config.mjs b/eslint.config.mjs index 33b924ae1ab..e4e8292b9f7 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -195,4 +195,62 @@ export default tseslint.config( ], }, }, + + // ── API ROUTE RULES ────────────────────────────────────────── + { + files: ["app/api/**/*.ts"], + plugins: { "@typescript-eslint": tseslint.plugin }, + languageOptions: { + parser: tseslint.parser, + }, + }, + { + files: ["app/api/**/*.ts"], + rules: { + "no-console": ["error", { allow: ["error", "warn"] }], + "@typescript-eslint/no-unused-vars": [ + "error", + { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }, + ], + "@typescript-eslint/no-explicit-any": "off", // TODO: tighten to "warn" after gradual type cleanup + }, + }, + + // ── CLIENT-SIDE API CALL ENFORCEMENT ───────────────────────── + { + files: ["components/**/*.{ts,tsx}", "hooks/**/*.{ts,tsx}", "app/**/*.{ts,tsx}"], + ignores: ["app/api/**"], + plugins: { "@typescript-eslint": tseslint.plugin }, + languageOptions: { + parser: tseslint.parser, + }, + }, + { + files: ["components/**/*.{ts,tsx}", "hooks/**/*.{ts,tsx}", "app/**/*.{ts,tsx}"], + ignores: ["app/api/**"], + rules: { + "no-restricted-syntax": [ + "error", + { + selector: "CallExpression[callee.name='fetch'][arguments.0.value=/^\\/api\\//]", + message: "Use apiFetch() from @/lib/api/client instead of raw fetch('/api/...').", + }, + { + selector: "CallExpression[callee.name='fetch'][arguments.0.type='TemplateLiteral'][arguments.0.quasis.0.value.raw=/^\\/api\\//]", + message: "Use apiFetch() from @/lib/api/client instead of raw fetch(`/api/...`).", + }, + ], + "no-restricted-imports": [ + "error", + { + paths: [ + { + name: "axios", + message: "Use apiFetch() from @/lib/api/client instead of axios.", + }, + ], + }, + ], + }, + }, ); diff --git a/hooks/use-console-log.ts b/hooks/use-console-log.ts index ca4add8482d..2641064c491 100644 --- a/hooks/use-console-log.ts +++ b/hooks/use-console-log.ts @@ -1,6 +1,7 @@ import { useState, useEffect, useCallback } from 'react'; -import type { ConsoleLog } from '@/types/console-log'; -import { useConsoleBadgeNotificationStore } from '@/stores/consoleBadgeNotificationStore'; +import type { ConsoleLog, ConsoleLogStatus } from '@/types/console-log'; +import { useConsoleBadgeNotificationStore, type ConsoleBadgeNotification } from '@/stores/consoleBadgeNotificationStore'; +import { apiFetch, ApiClientError } from '@/lib/api/client'; /** * Hook for managing console log/history @@ -20,27 +21,23 @@ export const useConsoleLog = (autoFetch: boolean = false) => { setLoading(true); try { - const response = await fetch('/api/console-log'); - if (response.ok) { - const data = await response.json(); - const transformedLogs = data.map((item: any) => ({ - id: item.id, - timestamp: new Date(item.created_at), - status: item.status, - actionPath: item.action_path, - data: item.data - })); - setLogs(transformedLogs); - } else if (response.status === 401) { + const data = await apiFetch }>>('/api/console-log'); + const transformedLogs: ConsoleLog[] = data.map((item) => ({ + id: item.id, + timestamp: new Date(item.created_at), + status: item.status, + actionPath: item.action_path, + data: item.data + })); + setLogs(transformedLogs); + } catch (error) { + if (error instanceof ApiClientError && error.status === 401) { // User not authenticated - this is expected, don't log error setLogs([]); } else { - console.error('Error loading console logs:', response.statusText); + console.error('Error loading console logs:', error); setLogs([]); } - } catch (error) { - console.error('Error loading console logs:', error); - setLogs([]); } finally { setLoading(false); } @@ -58,40 +55,34 @@ export const useConsoleLog = (autoFetch: boolean = false) => { if (typeof window === 'undefined') return; try { - const response = await fetch('/api/console-log', { + const savedItem = await apiFetch<{ id: string; created_at: string; status: ConsoleLogStatus; action_path: string; data: Record; awardedBadges?: ConsoleBadgeNotification[] }>('/api/console-log', { method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ + body: { status: item.status, actionPath: item.actionPath, data: item.data, timezone: Intl.DateTimeFormat().resolvedOptions().timeZone - }) + } }); - if (response.ok) { - const savedItem = await response.json(); - if (savedItem.awardedBadges?.length > 0) { - useConsoleBadgeNotificationStore.getState().addBadges(savedItem.awardedBadges); - } - const logItem: ConsoleLog = { - id: savedItem.id, - timestamp: new Date(savedItem.created_at), - status: savedItem.status, - actionPath: savedItem.action_path, - data: savedItem.data - }; - setLogs(prev => [logItem, ...prev]); - } else if (response.status === 401) { + if (savedItem.awardedBadges?.length) { + useConsoleBadgeNotificationStore.getState().addBadges(savedItem.awardedBadges); + } + const logItem: ConsoleLog = { + id: savedItem.id, + timestamp: new Date(savedItem.created_at), + status: savedItem.status, + actionPath: savedItem.action_path, + data: savedItem.data + }; + setLogs(prev => [logItem, ...prev]); + } catch (error) { + if (error instanceof ApiClientError && error.status === 401) { // User not authenticated - silently fail // History is only available for logged-in users } else { - console.error('Failed to save log:', response.statusText); + console.error('Error saving log:', error); } - } catch (error) { - console.error('Error saving log:', error); } }; diff --git a/hooks/use-get-hackathons.ts b/hooks/use-get-hackathons.ts index 0e772240b24..26ca46f4183 100644 --- a/hooks/use-get-hackathons.ts +++ b/hooks/use-get-hackathons.ts @@ -1,35 +1,19 @@ import { useEffect, useState } from "react"; +import { apiFetch } from "@/lib/api/client"; export function useGetHackathons(): any { const [data, setData] = useState<{id: string, title: string}[]>([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); - - const fetchNotifications = async (): Promise => { + const fetchHackathons = async (): Promise => { setLoading(true); setError(null); try { - - const response: Response = await fetch( - `/api/hackathons`, - { - method: "GET", - headers: { - "Content-Type": "application/json", - }, - cache: "no-store", - } + const json = await apiFetch<{ hackathons?: { id: string; title: string }[] }>( + `/api/hackathons` ); - - if (!response.ok) { - const text: string = await response.text(); - throw new Error(text || "Failed to fetch notifications"); - } - - const json = (await response.json()); - setData(json?.hackathons ?? []); } catch (err: unknown) { setError(err instanceof Error ? err.message : "Unknown error"); @@ -39,8 +23,8 @@ export function useGetHackathons(): any { }; useEffect(() => { - fetchNotifications(); + fetchHackathons(); }, []); - return { data, loading, error, refetch: fetchNotifications }; -} \ No newline at end of file + return { data, loading, error, refetch: fetchHackathons }; +} diff --git a/hooks/useAutomatedFaucet.ts b/hooks/useAutomatedFaucet.ts index abc4144f584..1a24426a0ae 100644 --- a/hooks/useAutomatedFaucet.ts +++ b/hooks/useAutomatedFaucet.ts @@ -6,6 +6,7 @@ import { useWalletStore } from '@/components/toolbox/stores/walletStore'; import { useL1List, type L1ListItem } from '@/components/toolbox/stores/l1ListStore'; import { useTestnetFaucet, type FaucetClaimResult } from './useTestnetFaucet'; import { toast } from 'sonner'; +import { apiFetch } from '@/lib/api/client'; import { balanceService } from '@/components/toolbox/services/balanceService'; import { useChainTokenTracker } from './useChainTokenTracker'; import { useConfetti } from './useConfetti'; @@ -60,16 +61,11 @@ export const useAutomatedFaucet = () => { chains: Array<{ faucetType: 'pchain' | 'evm'; chainId?: string }> ): Promise => { try { - const response = await fetch('/api/faucet-rate-limit/batch', { + const data = await apiFetch<{ limits: ChainRateLimitStatus[] }>('/api/faucet-rate-limit/batch', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ chains }) + body: { chains } }); - - if (!response.ok) return []; - - const data = await response.json(); - return data.success ? data.limits : []; + return data.limits ?? []; } catch (error) { console.error('Failed to fetch rate limits:', error); return []; diff --git a/hooks/useCertificates.ts b/hooks/useCertificates.ts index 0078a2bdd97..ee29ecd9208 100644 --- a/hooks/useCertificates.ts +++ b/hooks/useCertificates.ts @@ -1,6 +1,7 @@ import { useState } from 'react'; import { useRouter } from 'next/navigation'; import { toast } from '@/hooks/use-toast'; +import { apiFetch, ApiClientError } from '@/lib/api/client'; interface UseCertificatesReturn { isGenerating: boolean; @@ -17,61 +18,18 @@ export function useCertificates(): UseCertificatesReturn { setIsGenerating(true); try { - const response = await fetch('/api/generate-certificate', { + const response = await apiFetch('/api/generate-certificate', { method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - courseId, - }), + body: { courseId }, + raw: true, }); - if (!response.ok) { - // Handle authentication error specifically - if (response.status === 401) { - toast({ - title: "Authentication Required", - description: "Please sign in to your BuilderHub account to generate certificates.", - variant: "destructive", - }); - setIsGenerating(false); - // Redirect to login after a short delay with callback URL - setTimeout(() => { - const currentPath = window.location.pathname; - router.push(`/login?callbackUrl=${encodeURIComponent(currentPath)}`); - }, 2000); - return; - } - - // Try to get error details from response - try { - const errorData = await response.json(); - console.error('Server error details:', errorData); - - // Check for specific error types - if (errorData.error?.includes('Email address required')) { - toast({ - title: "Email Required", - description: "Please ensure your BuilderHub account has a valid email address.", - variant: "destructive", - }); - setIsGenerating(false); - return; - } - - throw new Error(errorData.error || errorData.details || 'Failed to generate certificate'); - } catch (jsonError) { - throw new Error(`Failed to generate certificate (${response.status})`); - } - } - const blob = await response.blob(); const url = window.URL.createObjectURL(blob); - + // Store the PDF URL for sharing setCertificatePdfUrl(url); - + // Download the PDF const a = document.createElement('a'); a.style.display = 'none'; @@ -80,13 +38,13 @@ export function useCertificates(): UseCertificatesReturn { document.body.appendChild(a); a.click(); // Don't revoke the URL immediately as we need it for sharing - + // Show success message toast({ title: "Certificate Downloaded!", description: "Your certificate has been successfully generated and downloaded.", }); - + // Redirect after success setTimeout(() => { // Redirect to the appropriate academy page @@ -100,13 +58,40 @@ export function useCertificates(): UseCertificatesReturn { router.push('/academy'); } }, 3000); - } catch (error: any) { + } catch (error: unknown) { console.error('Error generating certificate:', error); - + + // Handle authentication error specifically + if (error instanceof ApiClientError && error.status === 401) { + toast({ + title: "Authentication Required", + description: "Please sign in to your BuilderHub account to generate certificates.", + variant: "destructive", + }); + setIsGenerating(false); + setTimeout(() => { + const currentPath = window.location.pathname; + router.push(`/login?callbackUrl=${encodeURIComponent(currentPath)}`); + }, 2000); + return; + } + + // Check for specific error types + const message = error instanceof Error ? error.message : ''; + if (message.includes('Email address required')) { + toast({ + title: "Email Required", + description: "Please ensure your BuilderHub account has a valid email address.", + variant: "destructive", + }); + setIsGenerating(false); + return; + } + // Generic error handling for unexpected errors toast({ title: "Certificate Generation Failed", - description: error.message || "An unexpected error occurred. Please try again.", + description: message || "An unexpected error occurred. Please try again.", variant: "destructive", }); } finally { diff --git a/hooks/useCourseBadges.ts b/hooks/useCourseBadges.ts index 7e8a4c6de51..cd62fa13ffc 100644 --- a/hooks/useCourseBadges.ts +++ b/hooks/useCourseBadges.ts @@ -2,8 +2,13 @@ import { useState, useEffect } from 'react'; import { useSession } from 'next-auth/react'; +import { apiFetch } from '@/lib/api/client'; import type { CourseCompletionEntry } from './useCourseCompletion'; +interface BadgeResult { + image_path?: string; +} + export function useCourseBadges( completionMap: Map, courseEntries: CourseCompletionEntry[] @@ -42,9 +47,9 @@ export function useCourseBadges( await Promise.all( completedEntries.map(async ({ nodeId, courseSlug }) => { try { - const response = await fetch(`/api/badge?course_id=${courseSlug}`); - if (!response.ok) return; - const data = await response.json(); + const data = await apiFetch( + `/api/badge?course_id=${courseSlug}`, + ); const imagePath = Array.isArray(data) ? data[0]?.image_path : data?.image_path; diff --git a/hooks/useFaucetBalance.ts b/hooks/useFaucetBalance.ts index 4c5bf8c7000..a9557a00f25 100644 --- a/hooks/useFaucetBalance.ts +++ b/hooks/useFaucetBalance.ts @@ -1,6 +1,7 @@ "use client"; import { useState, useEffect, useCallback } from 'react'; +import { apiFetch } from '@/lib/api/client'; interface ChainBalance { chainId: number; @@ -51,12 +52,7 @@ export function useFaucetBalance(): UseFaucetBalanceReturn { setError(null); try { - const response = await fetch('/api/faucet-balance'); - const data = await response.json(); - - if (!response.ok || !data.success) { - throw new Error(data.message || 'Failed to fetch faucet balances'); - } + const data = await apiFetch<{ pChain?: FaucetBalances['pChain']; evmChains?: ChainBalance[] }>('/api/faucet-balance'); const newBalances: FaucetBalances = { pChain: data.pChain, diff --git a/hooks/useFaucetRateLimit.ts b/hooks/useFaucetRateLimit.ts index 75d6a9bea1d..b252526500d 100644 --- a/hooks/useFaucetRateLimit.ts +++ b/hooks/useFaucetRateLimit.ts @@ -3,6 +3,7 @@ import { useState, useCallback, useEffect, useRef } from 'react'; import { useSession } from 'next-auth/react'; import { useWalletStore } from '@/components/toolbox/stores/walletStore'; +import { apiFetch, ApiClientError } from '@/lib/api/client'; interface RateLimitStatus { allowed: boolean; @@ -147,12 +148,15 @@ export const useFaucetRateLimit = (options: UseFaucetRateLimitOptions) => { params.set('chainId', chainId.toString()); } - const response = await fetch(`/api/faucet-rate-limit?${params}`); - const data = await response.json(); - - if (!response.ok) { - throw new Error(data.message || 'Failed to check rate limit'); - } + const data = await apiFetch<{ + allowed: boolean; + reason?: RateLimitStatus['reason']; + resetTime?: string; + userClaimsInWindow: number; + destinationClaimsInWindow: number; + maxClaimsPerUser: number; + maxClaimsPerDestination: number; + }>(`/api/faucet-rate-limit?${params}`); const newStatus: RateLimitStatus = { allowed: data.allowed, diff --git a/hooks/useManagedTestnetNodes.ts b/hooks/useManagedTestnetNodes.ts index 683e4255c0d..92796579242 100644 --- a/hooks/useManagedTestnetNodes.ts +++ b/hooks/useManagedTestnetNodes.ts @@ -3,7 +3,8 @@ import { useState, useCallback } from "react"; import { NodeRegistration, RegisterSubnetResponse } from "@/components/toolbox/console/testnet-infra/managed-testnet-nodes/types"; import posthog from 'posthog-js'; -import { useConsoleBadgeNotificationStore } from '@/stores/consoleBadgeNotificationStore'; +import { useConsoleBadgeNotificationStore, type ConsoleBadgeNotification } from '@/stores/consoleBadgeNotificationStore'; +import { apiFetch, ApiClientError } from '@/lib/api/client'; export function useManagedTestnetNodes() { const [nodes, setNodes] = useState([]); @@ -16,18 +17,7 @@ export function useManagedTestnetNodes() { setNodesError(null); try { - const response = await fetch('/api/managed-testnet-nodes', { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - }, - }); - - const data = await response.json(); - - if (!response.ok || data.error) { - throw new Error(data.message || data.error || 'Failed to fetch nodes'); - } + const data = await apiFetch<{ nodes?: NodeRegistration[] }>('/api/managed-testnet-nodes'); if (data.nodes) { setNodes(data.nodes); @@ -42,40 +32,24 @@ export function useManagedTestnetNodes() { const createNode = useCallback(async (subnetId: string, blockchainId: string) => { try { - const response = await fetch('/api/managed-testnet-nodes', { + const data = await apiFetch<{ builder_hub_response?: RegisterSubnetResponse; awardedBadges?: ConsoleBadgeNotification[] }>('/api/managed-testnet-nodes', { method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - subnetId, - blockchainId - }) + body: { subnetId, blockchainId } }); - const data = await response.json(); - - if (!response.ok) { - if (response.status === 429) { - throw new Error(data.message || data.error || "Rate limit exceeded. Please try again later."); - } - throw new Error(data.message || data.error || `Error ${response.status}: Failed to register subnet`); - } - - if (data.error) { - throw new Error(data.message || data.error || 'Registration failed'); - } - - if (data.awardedBadges?.length > 0) { + if (data.awardedBadges?.length) { useConsoleBadgeNotificationStore.getState().addBadges(data.awardedBadges); } if (data.builder_hub_response) { - return data.builder_hub_response as RegisterSubnetResponse; + return data.builder_hub_response; } else { throw new Error('Unexpected response format'); } } catch (error) { + if (error instanceof ApiClientError && error.status === 429) { + throw new Error(error.message || "Rate limit exceeded. Please try again later."); + } throw error; } }, []); @@ -84,33 +58,11 @@ export function useManagedTestnetNodes() { setDeletingNodes(prev => new Set(prev).add(node.id)); try { - let response; - if (node.node_index === null || node.node_index === undefined) { - response = await fetch(`/api/managed-testnet-nodes?id=${encodeURIComponent(node.id)}`, { - method: 'DELETE', - headers: { 'Content-Type': 'application/json' } - }); - } else { - response = await fetch(`/api/managed-testnet-nodes/${node.subnet_id}/${node.node_index}`, { - method: 'DELETE', - headers: { - 'Content-Type': 'application/json', - } - }); - } + const url = (node.node_index === null || node.node_index === undefined) + ? `/api/managed-testnet-nodes?id=${encodeURIComponent(node.id)}` + : `/api/managed-testnet-nodes/${node.subnet_id}/${node.node_index}`; - const data = await response.json(); - - if (!response.ok || data.error) { - // Track error - posthog.capture('managed_testnet_node_delete_error', { - subnet_id: node.subnet_id, - blockchain_id: node.blockchain_id, - error_message: data.message || data.error || 'Failed to delete node', - context: 'console' - }); - throw new Error(data.message || data.error || 'Failed to delete node'); - } + const data = await apiFetch<{ message?: string }>(url, { method: 'DELETE' }); // Track successful deletion posthog.capture('managed_testnet_node_deleted', { @@ -123,6 +75,13 @@ export function useManagedTestnetNodes() { await fetchNodes(); return data.message || "The node has been successfully removed."; } catch (error) { + // Track error + posthog.capture('managed_testnet_node_delete_error', { + subnet_id: node.subnet_id, + blockchain_id: node.blockchain_id, + error_message: error instanceof Error ? error.message : 'Failed to delete node', + context: 'console' + }); throw error; } finally { setDeletingNodes(prev => { diff --git a/hooks/useManagedTestnetRelayers.ts b/hooks/useManagedTestnetRelayers.ts index 821d2392ecf..56963f524ae 100644 --- a/hooks/useManagedTestnetRelayers.ts +++ b/hooks/useManagedTestnetRelayers.ts @@ -3,6 +3,7 @@ import { useState, useCallback } from "react"; import { Relayer, RelayerConfig } from "@/components/toolbox/console/testnet-infra/managed-testnet-relayers/types"; import posthog from 'posthog-js'; +import { apiFetch, ApiClientError } from '@/lib/api/client'; export function useManagedTestnetRelayers() { const [relayers, setRelayers] = useState([]); @@ -16,18 +17,7 @@ export function useManagedTestnetRelayers() { setRelayersError(null); try { - const response = await fetch('/api/managed-testnet-relayers', { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - }, - }); - - const data = await response.json(); - - if (!response.ok || data.error) { - throw new Error(data.message || data.error || 'Failed to fetch relayers'); - } + const data = await apiFetch<{ relayers?: Relayer[] }>('/api/managed-testnet-relayers'); if (data.relayers) { setRelayers(data.relayers); @@ -42,29 +32,16 @@ export function useManagedTestnetRelayers() { const createRelayer = useCallback(async (configs: RelayerConfig[]) => { try { - const response = await fetch('/api/managed-testnet-relayers', { + const data = await apiFetch<{ relayer: Relayer }>('/api/managed-testnet-relayers', { method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ configs }) + body: { configs } }); - const data = await response.json(); - - if (!response.ok) { - if (response.status === 429) { - throw new Error(data.message || data.error || "Rate limit exceeded. Please try again later."); - } - throw new Error(data.message || data.error || `Error ${response.status}: Failed to create relayer`); - } - - if (data.error) { - throw new Error(data.message || data.error || 'Relayer creation failed'); - } - - return data.relayer as Relayer; + return data.relayer; } catch (error) { + if (error instanceof ApiClientError && error.status === 429) { + throw new Error(error.message || "Rate limit exceeded. Please try again later."); + } throw error; } }, []); @@ -73,26 +50,10 @@ export function useManagedTestnetRelayers() { setDeletingRelayers(prev => new Set(prev).add(relayer.relayerId)); try { - const response = await fetch(`/api/managed-testnet-relayers/${relayer.relayerId}`, { + const data = await apiFetch<{ message?: string }>(`/api/managed-testnet-relayers/${relayer.relayerId}`, { method: 'DELETE', - headers: { - 'Content-Type': 'application/json', - } }); - const data = await response.json(); - - if (!response.ok || data.error) { - // Track error - posthog.capture('managed_testnet_relayer_delete_error', { - relayer_id: relayer.relayerId, - config_count: relayer.configs.length, - error_message: data.message || data.error || 'Failed to delete relayer', - context: 'console' - }); - throw new Error(data.message || data.error || 'Failed to delete relayer'); - } - // Track successful deletion posthog.capture('managed_testnet_relayer_deleted', { relayer_id: relayer.relayerId, @@ -104,6 +65,13 @@ export function useManagedTestnetRelayers() { await fetchRelayers(); return data.message || "The relayer has been successfully removed."; } catch (error) { + // Track error + posthog.capture('managed_testnet_relayer_delete_error', { + relayer_id: relayer.relayerId, + config_count: relayer.configs.length, + error_message: error instanceof Error ? error.message : 'Failed to delete relayer', + context: 'console' + }); throw error; } finally { setDeletingRelayers(prev => { @@ -118,30 +86,10 @@ export function useManagedTestnetRelayers() { setRestartingRelayers(prev => new Set(prev).add(relayer.relayerId)); try { - const response = await fetch(`/api/managed-testnet-relayers/${relayer.relayerId}/restart`, { + const data = await apiFetch<{ message?: string }>(`/api/managed-testnet-relayers/${relayer.relayerId}/restart`, { method: 'POST', - headers: { - 'Content-Type': 'application/json', - } }); - const data = await response.json(); - - if (!response.ok || data.error) { - // Track error - posthog.capture('managed_testnet_relayer_restart_error', { - relayer_id: relayer.relayerId, - config_count: relayer.configs.length, - error_message: data.message || data.error || 'Failed to restart relayer', - is_rate_limited: response.status === 429, - context: 'console' - }); - if (response.status === 429) { - throw new Error('Rate limit exceeded. Please wait before restarting again.'); - } - throw new Error(data.message || data.error || 'Failed to restart relayer'); - } - // Track successful restart posthog.capture('managed_testnet_relayer_restarted', { relayer_id: relayer.relayerId, @@ -153,6 +101,18 @@ export function useManagedTestnetRelayers() { await fetchRelayers(); return data.message || "The relayer has been restarted successfully."; } catch (error) { + // Track error + const isRateLimited = error instanceof ApiClientError && error.status === 429; + posthog.capture('managed_testnet_relayer_restart_error', { + relayer_id: relayer.relayerId, + config_count: relayer.configs.length, + error_message: error instanceof Error ? error.message : 'Failed to restart relayer', + is_rate_limited: isRateLimited, + context: 'console' + }); + if (isRateLimited) { + throw new Error('Rate limit exceeded. Please wait before restarting again.'); + } throw error; } finally { setRestartingRelayers(prev => { diff --git a/hooks/useRetroactiveConsoleBadges.ts b/hooks/useRetroactiveConsoleBadges.ts index 90374beb023..df465c9f946 100644 --- a/hooks/useRetroactiveConsoleBadges.ts +++ b/hooks/useRetroactiveConsoleBadges.ts @@ -1,6 +1,11 @@ import { useEffect } from "react"; import { useSession } from "next-auth/react"; import { useConsoleBadgeNotificationStore } from "@/components/toolbox/stores/consoleBadgeNotificationStore"; +import { apiFetch } from "@/lib/api/client"; + +interface ConsoleBadgeCheckResult { + awardedBadges: any[]; +} export function useRetroactiveConsoleBadges() { const { data: session, status } = useSession(); @@ -17,12 +22,10 @@ export function useRetroactiveConsoleBadges() { localStorage.setItem(key, "1"); - fetch("/api/badge/console-check", { + apiFetch("/api/badge/console-check", { method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ timezone: Intl.DateTimeFormat().resolvedOptions().timeZone }), + body: { timezone: Intl.DateTimeFormat().resolvedOptions().timeZone }, }) - .then((res) => res.json()) .then((data) => { if (data.awardedBadges?.length > 0) { addBadges(data.awardedBadges, true); diff --git a/hooks/useTestnetFaucet.ts b/hooks/useTestnetFaucet.ts index f6f44faf20a..6fb032ccf0e 100644 --- a/hooks/useTestnetFaucet.ts +++ b/hooks/useTestnetFaucet.ts @@ -5,7 +5,8 @@ import { useL1List, type L1ListItem } from '@/components/toolbox/stores/l1ListSt import useConsoleNotifications from './useConsoleNotifications'; import { balanceService } from '@/components/toolbox/services/balanceService'; import { useChainTokenTracker } from './useChainTokenTracker'; -import { useConsoleBadgeNotificationStore } from '@/stores/consoleBadgeNotificationStore'; +import { useConsoleBadgeNotificationStore, type ConsoleBadgeNotification } from '@/stores/consoleBadgeNotificationStore'; +import { apiFetch, ApiClientError } from '@/lib/api/client'; export interface FaucetClaimResult { success: boolean; @@ -41,24 +42,16 @@ export const useTestnetFaucet = () => { try { const faucetRequest = async () => { - const response = await fetch(`/api/evm-chain-faucet?address=${walletEVMAddress}&chainId=${chainId}`); - const rawText = await response.text(); - - let data; try { - data = JSON.parse(rawText); - } catch (parseError) { + return await apiFetch(`/api/evm-chain-faucet?address=${walletEVMAddress}&chainId=${chainId}`); + } catch (error) { + if (error instanceof ApiClientError) { + if (error.status === 401) throw new Error("Please login first"); + if (error.status === 429) throw new Error(error.message || "Rate limit exceeded. Please try again later."); + throw new Error(error.message || `Error ${error.status}: Failed to get tokens`); + } throw new Error('Faucet temporarily unavailable. Please try again later.'); } - - if (!response.ok) { - if (response.status === 401) { throw new Error("Please login first") } - if (response.status === 429) { throw new Error(data.message || "Rate limit exceeded. Please try again later.") } - throw new Error(data.message || `Error ${response.status}: Failed to get tokens`); - } - - if (!data.success) { throw new Error(data.message || "Failed to get tokens") } - return data; }; const faucetPromise = faucetRequest(); @@ -74,7 +67,7 @@ export const useTestnetFaucet = () => { } const result = await faucetPromise; - if (result.awardedBadges?.length > 0) { + if (result.awardedBadges?.length) { useConsoleBadgeNotificationStore.getState().addBadges(result.awardedBadges); } @@ -104,24 +97,16 @@ export const useTestnetFaucet = () => { try { const faucetRequest = async () => { - const response = await fetch(`/api/pchain-faucet?address=${pChainAddress}`); - const rawText = await response.text(); - - let data; try { - data = JSON.parse(rawText); - } catch (parseError) { + return await apiFetch(`/api/pchain-faucet?address=${pChainAddress}`); + } catch (error) { + if (error instanceof ApiClientError) { + if (error.status === 401) throw new Error("Please login first"); + if (error.status === 429) throw new Error(error.message || "Rate limit exceeded. Please try again later."); + throw new Error(error.message || `Error ${error.status}: Failed to get tokens`); + } throw new Error('Faucet temporarily unavailable. Please try again later.'); } - - if (!response.ok) { - if (response.status === 401) {throw new Error("Please login first") } - if (response.status === 429) { throw new Error(data.message || "Rate limit exceeded. Please try again later.") } - throw new Error(data.message || `Error ${response.status}: Failed to get tokens`); - } - - if (!data.success) { throw new Error(data.message || "Failed to get tokens") } - return data; }; const faucetPromise = faucetRequest(); @@ -137,7 +122,7 @@ export const useTestnetFaucet = () => { } const result = await faucetPromise; - if (result.awardedBadges?.length > 0) { + if (result.awardedBadges?.length) { useConsoleBadgeNotificationStore.getState().addBadges(result.awardedBadges); } if (result.success) { setTimeout(() => { balanceService.updatePChainBalance() }, 2000) } diff --git a/lib/api/client.ts b/lib/api/client.ts new file mode 100644 index 00000000000..7e89eab657a --- /dev/null +++ b/lib/api/client.ts @@ -0,0 +1,97 @@ +/** + * Type-safe API client that auto-unwraps the { success, data } envelope. + * ALL client-side code MUST use this instead of raw fetch('/api/...'). + * Enforced by ESLint + CI. + */ + +/** Error thrown when an API request fails. */ +export class ApiClientError extends Error { + public readonly code: string; + public readonly status: number; + + constructor(code: string, message: string, status: number) { + super(message); + this.name = 'ApiClientError'; + this.code = code; + this.status = status; + } +} + +export interface ApiFetchOptions extends Omit { + /** JSON-serializable request body. Automatically stringified + Content-Type set. */ + body?: unknown; + /** Skip envelope unwrapping — returns the raw Response. Use for streaming/blob endpoints. */ + raw?: boolean; +} + +/** + * Fetch an API route with automatic envelope unwrapping. + * + * Returns `data` from `{ success: true, data: T }` on success. + * Throws `ApiClientError` with `{ code, message, status }` on failure. + * + * @example + * ```ts + * const projects = await apiFetch('/api/projects'); + * const project = await apiFetch('/api/projects', { method: 'POST', body: { name: 'Test' } }); + * ``` + */ +export async function apiFetch(url: string, options?: ApiFetchOptions): Promise { + const { body, raw, ...fetchOptions } = options ?? {}; + + const headers = new Headers(fetchOptions.headers); + const isFormData = typeof FormData !== 'undefined' && body instanceof FormData; + + // Auto-set Content-Type for JSON bodies (skip for FormData — browser sets boundary) + if (body !== undefined && !isFormData && !headers.has('Content-Type')) { + headers.set('Content-Type', 'application/json'); + } + + const response = await fetch(url, { + ...fetchOptions, + headers: isFormData ? fetchOptions.headers : headers, // Let browser handle FormData headers + body: isFormData ? (body as BodyInit) : (body !== undefined ? JSON.stringify(body) : undefined), + }); + + // Raw mode: return the Response as-is (for streaming, blobs, etc.) + if (raw) { + if (!response.ok) { + // Try to parse error envelope, fall back to status text + try { + const json = await response.json(); + if (json?.error?.message) { + throw new ApiClientError(json.error.code ?? 'REQUEST_FAILED', json.error.message, response.status); + } + } catch (e) { + if (e instanceof ApiClientError) throw e; + } + throw new ApiClientError('REQUEST_FAILED', response.statusText || `Request failed: ${response.status}`, response.status); + } + return response as unknown as T; + } + + // Parse JSON + let json: any; + try { + json = await response.json(); + } catch { + if (!response.ok) { + throw new ApiClientError('REQUEST_FAILED', response.statusText || `Request failed: ${response.status}`, response.status); + } + // 204 No Content or empty body + return undefined as T; + } + + // Error envelope: { success: false, error: { code, message } } + if (!response.ok || json.success === false) { + throw new ApiClientError( + json?.error?.code ?? 'UNKNOWN_ERROR', + json?.error?.message ?? json?.message ?? 'An unexpected error occurred', + response.status, + ); + } + + // Success envelope: { success: true, data: T } + // Return .data if it exists, otherwise return the whole body (for non-enveloped responses during migration) + return (json.data !== undefined ? json.data : json) as T; +} diff --git a/lib/api/constants.ts b/lib/api/constants.ts new file mode 100644 index 00000000000..f239e41c086 --- /dev/null +++ b/lib/api/constants.ts @@ -0,0 +1,17 @@ +/** Matches a basic email address pattern. */ +export const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +/** Matches an Avalanche NodeID (base58 encoded, 33-60 chars). */ +export const NODE_ID_REGEX = /^NodeID-[A-HJ-NP-Za-km-z1-9]{33,60}$/; + +/** Matches an EVM address (0x + 40 hex chars). */ +export const EVM_ADDRESS_REGEX = /^0x[a-fA-F0-9]{40}$/; + +/** Matches an EVM transaction hash (0x + 64 hex chars). */ +export const TX_HASH_REGEX = /^0x[a-fA-F0-9]{64}$/; + +/** Default number of items per page when not specified. */ +export const DEFAULT_PAGE_SIZE = 12; + +/** Maximum allowed page size to prevent excessive queries. */ +export const MAX_PAGE_SIZE = 100; diff --git a/lib/api/errors.ts b/lib/api/errors.ts new file mode 100644 index 00000000000..a84d1f28695 --- /dev/null +++ b/lib/api/errors.ts @@ -0,0 +1,84 @@ +/** Base API error class with HTTP status code and machine-readable error code. */ +export class ApiError extends Error { + public readonly statusCode: number; + public readonly code: string; + + constructor(statusCode: number, code: string, message: string) { + super(message); + this.name = 'ApiError'; + this.statusCode = statusCode; + this.code = code; + } +} + +/** Thrown when request body or query params fail Zod validation (400). */ +export class ValidationError extends ApiError { + constructor(message: string) { + super(400, 'VALIDATION_ERROR', message); + this.name = 'ValidationError'; + } +} + +/** Thrown for generic malformed requests (400). */ +export class BadRequestError extends ApiError { + constructor(message: string) { + super(400, 'BAD_REQUEST', message); + this.name = 'BadRequestError'; + } +} + +/** Thrown when authentication is missing or invalid (401). */ +export class AuthError extends ApiError { + constructor(message = 'Authentication required') { + super(401, 'AUTH_REQUIRED', message); + this.name = 'AuthError'; + } +} + +/** Thrown when the user lacks the required role or permission (403). */ +export class ForbiddenError extends ApiError { + constructor(message = 'Forbidden') { + super(403, 'FORBIDDEN', message); + this.name = 'ForbiddenError'; + } +} + +/** Thrown when the requested resource does not exist (404). */ +export class NotFoundError extends ApiError { + constructor(entity?: string) { + super(404, 'NOT_FOUND', entity ? `${entity} not found` : 'Not found'); + this.name = 'NotFoundError'; + } +} + +/** Thrown when the request conflicts with existing state (409). */ +export class ConflictError extends ApiError { + constructor(message: string) { + super(409, 'CONFLICT', message); + this.name = 'ConflictError'; + } +} + +/** Thrown when the client exceeds the rate limit (429). */ +export class RateLimitError extends ApiError { + public readonly resetAt?: Date; + + constructor(message = 'Rate limit exceeded', resetAt?: Date) { + super(429, 'RATE_LIMITED', message); + this.name = 'RateLimitError'; + this.resetAt = resetAt; + } +} + +/** Thrown for unexpected internal failures (500). */ +export class InternalError extends ApiError { + constructor(message = 'Internal server error') { + super(500, 'INTERNAL_ERROR', message); + this.name = 'InternalError'; + } +} + +/** Type guard to check if an unknown value is an ApiError. */ +export function isApiError(error: unknown): error is ApiError { + return error instanceof ApiError; +} diff --git a/lib/api/index.ts b/lib/api/index.ts new file mode 100644 index 00000000000..77f3c391ea4 --- /dev/null +++ b/lib/api/index.ts @@ -0,0 +1,10 @@ +export * from './errors'; +export * from './response'; +export * from './types'; +export * from './constants'; +export * from './validate'; +export * from './with-api'; +export * from './rate-limit'; +export * from './ownership'; +export * from './pagination'; +export * from './client'; diff --git a/lib/api/ownership.ts b/lib/api/ownership.ts new file mode 100644 index 00000000000..542bf009cc8 --- /dev/null +++ b/lib/api/ownership.ts @@ -0,0 +1,30 @@ +import { NotFoundError } from '@/lib/api/errors'; + +/** Query a Prisma model for a record owned by the given user, or throw NotFoundError. */ +export async function assertOwnership( + model: { findFirst: Function }, + id: string, + userId: string, + options?: { + idField?: string; + userField?: string; + include?: any; + } +): Promise { + const idField = options?.idField ?? 'id'; + const userField = options?.userField ?? 'user_id'; + + const entity = await model.findFirst({ + where: { + [idField]: id, + [userField]: userId, + }, + ...(options?.include ? { include: options.include } : {}), + }); + + if (!entity) { + throw new NotFoundError(); + } + + return entity as T; +} diff --git a/lib/api/pagination.ts b/lib/api/pagination.ts new file mode 100644 index 00000000000..ef1f8615179 --- /dev/null +++ b/lib/api/pagination.ts @@ -0,0 +1,20 @@ +import type { NextRequest } from 'next/server'; +import { DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE } from '@/lib/api/constants'; + +/** Extract and clamp pagination params from the request URL. */ +export function parsePagination( + req: NextRequest, + options?: { defaultPageSize?: number; maxPageSize?: number } +): { page: number; pageSize: number; skip: number } { + const defaultSize = options?.defaultPageSize ?? DEFAULT_PAGE_SIZE; + const maxSize = options?.maxPageSize ?? MAX_PAGE_SIZE; + + const rawPage = req.nextUrl.searchParams.get('page'); + const rawPageSize = req.nextUrl.searchParams.get('pageSize'); + + const page = Math.max(1, rawPage ? parseInt(rawPage, 10) || 1 : 1); + const pageSize = Math.min(maxSize, Math.max(1, rawPageSize ? parseInt(rawPageSize, 10) || defaultSize : defaultSize)); + const skip = (page - 1) * pageSize; + + return { page, pageSize, skip }; +} diff --git a/lib/api/rate-limit.ts b/lib/api/rate-limit.ts new file mode 100644 index 00000000000..062ae416fbc --- /dev/null +++ b/lib/api/rate-limit.ts @@ -0,0 +1,137 @@ +// NOTE: Run `npx prisma migrate dev --name add_api_rate_limit_log` to generate the migration +// after merging this change into the main branch. + +import type { NextRequest } from 'next/server'; +import { prisma } from '@/prisma/prisma'; +import { AuthError } from '@/lib/api/errors'; +import type { RateLimitConfig } from '@/lib/api/types'; + +/** Extract the client IP address from request headers (Cloudflare, Vercel, nginx). */ +export function getClientIP(req: NextRequest): string { + const headers = req.headers; + + // Cloudflare + const cfConnectingIP = headers.get('cf-connecting-ip'); + if (cfConnectingIP) return cfConnectingIP; + + // Vercel / standard proxy -- first IP is the original client + const xForwardedFor = headers.get('x-forwarded-for'); + if (xForwardedFor) { + const firstIP = xForwardedFor.split(',')[0].trim(); + if (firstIP) return firstIP; + } + + // Generic proxy + const xRealIP = headers.get('x-real-ip'); + if (xRealIP) return xRealIP; + + return 'unknown'; +} + +/** Check rate limit using Prisma-backed log entries. Returns status and standard headers. */ +export async function checkPrismaRateLimit( + identifier: string, + endpoint: string, + config: { windowMs: number; maxRequests: number } +): Promise<{ + allowed: boolean; + remaining: number; + resetAt: Date; + headers: Record; +}> { + const now = new Date(); + const windowStart = new Date(now.getTime() - config.windowMs); + const resetAt = new Date(now.getTime() + config.windowMs); + + // Use a serializable transaction to prevent TOCTOU race conditions. + // Without this, two concurrent requests could both pass the count check. + return prisma.$transaction(async (tx) => { + const count = await tx.apiRateLimitLog.count({ + where: { + identifier, + endpoint, + created_at: { gte: windowStart }, + }, + }); + + const remaining = Math.max(0, config.maxRequests - count); + const headers: Record = { + 'X-RateLimit-Limit': config.maxRequests.toString(), + 'X-RateLimit-Remaining': remaining.toString(), + 'X-RateLimit-Reset': Math.floor(resetAt.getTime() / 1000).toString(), + }; + + if (count >= config.maxRequests) { + const oldest = await tx.apiRateLimitLog.findFirst({ + where: { + identifier, + endpoint, + created_at: { gte: windowStart }, + }, + orderBy: { created_at: 'asc' }, + select: { created_at: true }, + }); + + const preciseResetAt = oldest + ? new Date(oldest.created_at.getTime() + config.windowMs) + : resetAt; + + headers['X-RateLimit-Remaining'] = '0'; + headers['X-RateLimit-Reset'] = Math.floor(preciseResetAt.getTime() / 1000).toString(); + + return { allowed: false, remaining: 0, resetAt: preciseResetAt, headers }; + } + + // Under limit -- log this request atomically within the same transaction + await tx.apiRateLimitLog.create({ + data: { identifier, endpoint }, + }); + + return { + allowed: true, + remaining: Math.max(0, config.maxRequests - count - 1), + headers: { + ...headers, + 'X-RateLimit-Remaining': Math.max(0, config.maxRequests - count - 1).toString(), + }, + resetAt, + }; + }, { isolationLevel: 'Serializable' }); +} + +/** Delete rate limit log entries older than the given threshold. Returns count deleted. */ +export async function cleanupRateLimitLogs(olderThanMs = 86400000): Promise { + const cutoff = new Date(Date.now() - olderThanMs); + const result = await prisma.apiRateLimitLog.deleteMany({ + where: { created_at: { lt: cutoff } }, + }); + return result.count; +} + +/** Resolve the rate limit identifier from config, session, and request. */ +export function getRateLimitIdentifier( + req: NextRequest, + session: any, + config: RateLimitConfig +): string { + if (typeof config.identifier === 'function') { + return config.identifier(req, session); + } + + if (config.identifier === 'ip') { + return getClientIP(req); + } + + if (config.identifier === 'user') { + if (!session?.user?.id) { + throw new AuthError('Authentication required for user-based rate limiting'); + } + return session.user.id; + } + + // Default: use user ID if session exists, otherwise fall back to IP + if (session?.user?.id) { + return session.user.id; + } + return getClientIP(req); +} diff --git a/lib/api/response.ts b/lib/api/response.ts new file mode 100644 index 00000000000..d2ff4e6d3a0 --- /dev/null +++ b/lib/api/response.ts @@ -0,0 +1,48 @@ +import { NextResponse } from 'next/server'; +import { ApiError, RateLimitError, InternalError } from '@/lib/api/errors'; + +/** Return a success envelope with data and optional status code. */ +export function successResponse(data: T, status = 200): NextResponse { + return NextResponse.json({ success: true, data }, { status }); +} + +/** Return a success envelope with paginated data. */ +export function paginatedResponse( + data: T[], + pagination: { page: number; pageSize: number; total: number } +): NextResponse { + return NextResponse.json({ success: true, data, pagination }); +} + +/** Convert an error into a standardized error envelope response. */ +export function errorResponse(error: unknown): NextResponse { + if (error instanceof ApiError) { + const body = { + success: false as const, + error: { code: error.code, message: error.message }, + }; + + const headers: Record = {}; + + if (error instanceof RateLimitError && error.resetAt) { + headers['X-RateLimit-Reset'] = Math.floor(error.resetAt.getTime() / 1000).toString(); + headers['Retry-After'] = Math.max(0, Math.ceil((error.resetAt.getTime() - Date.now()) / 1000)).toString(); + } + + return NextResponse.json(body, { status: error.statusCode, headers }); + } + + // Unknown error -- log but never expose details to the client + console.error('[API Error]', error); + + const fallback = new InternalError(); + return NextResponse.json( + { success: false, error: { code: fallback.code, message: fallback.message } }, + { status: fallback.statusCode } + ); +} + +/** Return an empty 204 No Content response. */ +export function noContentResponse(): NextResponse { + return new NextResponse(null, { status: 204 }); +} diff --git a/lib/api/types.ts b/lib/api/types.ts new file mode 100644 index 00000000000..15f36c1bab3 --- /dev/null +++ b/lib/api/types.ts @@ -0,0 +1,58 @@ +import type { NextRequest, NextResponse } from 'next/server'; +import type { ZodType } from 'zod'; + +/** Envelope for successful single-entity responses. */ +export interface ApiSuccessResponse { + success: true; + data: T; +} + +/** Envelope for successful paginated list responses. */ +export interface ApiPaginatedResponse { + success: true; + data: T[]; + pagination: { + page: number; + pageSize: number; + total: number; + }; +} + +/** Envelope for error responses. */ +export interface ApiErrorResponse { + success: false; + error: { + code: string; + message: string; + }; +} + +/** Union of all possible API response shapes. */ +export type ApiResponse = ApiSuccessResponse | ApiPaginatedResponse | ApiErrorResponse; + +/** Context object passed to withApi handler functions. */ +export interface ApiHandlerContext { + session: any; + body: TBody; + params: Record; +} + +/** Rate limit configuration for withApi. */ +export interface RateLimitConfig { + windowMs: number; + maxRequests: number; + identifier?: 'user' | 'ip' | ((req: NextRequest, session: any) => string); +} + +/** Options for the withApi wrapper. */ +export interface WithApiOptions { + auth?: boolean; + roles?: string[]; + schema?: ZodType; + rateLimit?: RateLimitConfig; + maxBodySize?: number; +} + +/** The function signature Next.js expects for route handlers. */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type NextRouteHandler = (req: NextRequest, context?: any) => Promise; diff --git a/lib/api/validate.ts b/lib/api/validate.ts new file mode 100644 index 00000000000..bcdda392929 --- /dev/null +++ b/lib/api/validate.ts @@ -0,0 +1,48 @@ +import type { NextRequest } from 'next/server'; +import { ZodType } from 'zod'; +import { fromZodError } from 'zod-validation-error'; +import { ValidationError } from '@/lib/api/errors'; + +/** Format a Zod error into a human-readable string using zod-validation-error. */ +function formatZodError(error: unknown): string { + return fromZodError(error as Parameters[0]).message; +} + +/** Parse and validate the JSON request body against a Zod schema. */ +export async function validateBody(req: NextRequest, schema: ZodType): Promise { + let raw: unknown; + try { + raw = await req.json(); + } catch { + throw new ValidationError('Invalid JSON body'); + } + + const result = schema.safeParse(raw); + if (!result.success) { + throw new ValidationError(formatZodError(result.error)); + } + return result.data; +} + +/** Parse and validate URL search params against a Zod schema. */ +export function validateQuery(req: NextRequest, schema: ZodType): T { + const obj: Record = {}; + req.nextUrl.searchParams.forEach((value, key) => { + obj[key] = value; + }); + + const result = schema.safeParse(obj); + if (!result.success) { + throw new ValidationError(formatZodError(result.error)); + } + return result.data; +} + +/** Validate route params against a Zod schema. */ +export function validateParams(params: Record, schema: ZodType): T { + const result = schema.safeParse(params); + if (!result.success) { + throw new ValidationError(formatZodError(result.error)); + } + return result.data; +} diff --git a/lib/api/with-api.ts b/lib/api/with-api.ts new file mode 100644 index 00000000000..64736818bbb --- /dev/null +++ b/lib/api/with-api.ts @@ -0,0 +1,108 @@ +import type { NextRequest } from 'next/server'; +import { NextResponse } from 'next/server'; +import { getAuthSession } from '@/lib/auth/authSession'; +import { AuthError, ForbiddenError, InternalError, RateLimitError } from '@/lib/api/errors'; +import { errorResponse } from '@/lib/api/response'; +import { validateBody } from '@/lib/api/validate'; +import { checkPrismaRateLimit, getRateLimitIdentifier } from '@/lib/api/rate-limit'; +import type { ApiHandlerContext, WithApiOptions, NextRouteHandler } from '@/lib/api/types'; + +/** + * Core API wrapper that composes auth, validation, rate limiting, and error handling. + * + * Every route handler wrapped with withApi gets a consistent context object, + * automatic error envelope formatting, and opt-in middleware via options. + */ +export function withApi( + handler: (req: NextRequest, ctx: ApiHandlerContext) => Promise, + options: WithApiOptions = {} +): NextRouteHandler { + return async ( + req: NextRequest, + context?: { params: Promise> } + ): Promise => { + try { + // 1. Extract route params (Next.js 15+ returns a Promise) + const params = context?.params ? await context.params : {}; + + // 2. Authentication & authorization + let session: any = null; + + if (options.auth || options.roles) { + session = await getAuthSession(); + + if (!session) { + throw new AuthError(); + } + + if (options.roles && options.roles.length > 0) { + const userAttrs: string[] = session.user?.custom_attributes ?? []; + + const hasRole = options.roles.some((role) => { + if (userAttrs.includes(role)) return true; + // Super-admin pattern: "devrel" can act as badge_admin or showcase + if ((role === 'badge_admin' || role === 'showcase') && userAttrs.includes('devrel')) { + return true; + } + return false; + }); + + if (!hasRole) { + throw new ForbiddenError(); + } + } + } else { + // Still fetch session opportunistically (useful for rate limit identifier) + session = await getAuthSession(); + } + + // 3. Rate limiting + let rateLimitHeaders: Record | undefined; + + if (options.rateLimit) { + const identifier = getRateLimitIdentifier(req, session, options.rateLimit); + const endpoint = req.nextUrl.pathname; + const result = await checkPrismaRateLimit(identifier, endpoint, { + windowMs: options.rateLimit.windowMs, + maxRequests: options.rateLimit.maxRequests, + }); + + rateLimitHeaders = result.headers; + + if (!result.allowed) { + throw new RateLimitError('Rate limit exceeded', result.resetAt); + } + } + + // 4. Body validation + let body: TBody = undefined as TBody; + if (options.schema) { + body = await validateBody(req, options.schema); + } + + // 5. Execute the handler + const response = await handler(req, { session, body, params }); + + // 6. Attach rate limit headers to response if present + if (rateLimitHeaders) { + for (const [key, value] of Object.entries(rateLimitHeaders)) { + response.headers.set(key, value); + } + } + + return response; + } catch (error) { + // Rate limit errors need their headers on the error response too + if (error instanceof RateLimitError) { + return errorResponse(error); + } + + if (error instanceof Error && 'statusCode' in error) { + return errorResponse(error); + } + + console.error('[API Error]', error); + return errorResponse(new InternalError()); + } + }; +} diff --git a/lib/env.ts b/lib/env.ts new file mode 100644 index 00000000000..da851eeb905 --- /dev/null +++ b/lib/env.ts @@ -0,0 +1,260 @@ +import { createEnv } from "@t3-oss/env-nextjs"; +import { z } from "zod"; + +export const env = createEnv({ + server: { + // ── Database ──────────────────────────────────────────────── + DATABASE_URL: z.string().min(1), + + // ── Auth (NextAuth) ───────────────────────────────────────── + NEXTAUTH_SECRET: z.string().min(1), + NEXTAUTH_URL: z.string().url().optional(), + + // ── OAuth providers ───────────────────────────────────────── + GOOGLE_CLIENT_ID: z.string().optional(), + GOOGLE_CLIENT_SECRET: z.string().optional(), + GITHUB_ID: z.string().optional(), + GITHUB_SECRET: z.string().optional(), + GITHUB_TOKEN: z.string().optional(), + TWITTER_CLIENT_ID: z.string().optional(), + TWITTER_CLIENT_SECRET: z.string().optional(), + + // ── Custom OAuth (MCP / Glacier) ──────────────────────────── + OAUTH_CLIENT_ID: z.string().optional(), + OAUTH_CLIENT_SECRET: z.string().optional(), + OAUTH_JWT_PRIVATE_KEY: z.string().optional(), + OAUTH_REDIRECT_URI: z.string().optional(), + + // ── AI / LLM ──────────────────────────────────────────────── + ANTHROPIC_API_KEY: z.string().optional(), + OPENAI_API_KEY: z.string().optional(), + + // ── ClickHouse ────────────────────────────────────────────── + CLICKHOUSE_URL: z.string().optional(), + CLICKHOUSE_USER: z.string().optional(), + CLICKHOUSE_PASSWORD: z.string().optional(), + CLICKHOUSE_DATABASE: z.string().optional(), + + // ── Redis ─────────────────────────────────────────────────── + REDIS_URL: z.string().optional(), + + // ── Glacier ───────────────────────────────────────────────── + GLACIER_API_KEY: z.string().optional(), + GLACIER_JWT_PRIVATE_KEY: z.string().optional(), + + // ── Faucet ────────────────────────────────────────────────── + SERVER_PRIVATE_KEY: z.string().optional(), + FAUCET_C_CHAIN_PRIVATE_KEY: z.string().optional(), + FAUCET_C_CHAIN_ADDRESS: z.string().optional(), + FAUCET_P_CHAIN_ADDRESS: z.string().optional(), + + // ── HubSpot ───────────────────────────────────────────────── + HUBSPOT_API_KEY: z.string().optional(), + HUBSPOT_PORTAL_ID: z.string().optional(), + HUBSPOT_HACKATHON_FORM_GUID: z.string().optional(), + HUBSPOT_INFRABUIDL_FORM_GUID: z.string().optional(), + HUBSPOT_NEWSLETTER_FORM_GUID: z.string().optional(), + HUBSPOT_USER_DATA_LIST_ID: z.string().optional(), + + // ── HubSpot certificate webhooks ──────────────────────────── + HUBSPOT_WEBHOOK_ACCESS_RESTRICTION_ADVANCED: z.string().optional(), + HUBSPOT_WEBHOOK_ACCESS_RESTRICTION_FUNDAMENTALS: z.string().optional(), + HUBSPOT_WEBHOOK_AVALANCHE_FUNDAMENTALS: z.string().optional(), + HUBSPOT_WEBHOOK_BLOCKCHAIN_FUNDAMENTALS: z.string().optional(), + HUBSPOT_WEBHOOK_CUSTOMIZING_EVM: z.string().optional(), + HUBSPOT_WEBHOOK_ENCRYPTED_ERC: z.string().optional(), + HUBSPOT_WEBHOOK_ERC20_BRIDGE: z.string().optional(), + HUBSPOT_WEBHOOK_INTERCHAIN_MESSAGING: z.string().optional(), + HUBSPOT_WEBHOOK_L1_NATIVE_TOKENOMICS: z.string().optional(), + HUBSPOT_WEBHOOK_NATIVE_TOKEN_BRIDGE: z.string().optional(), + HUBSPOT_WEBHOOK_NFT_DEPLOYMENT: z.string().optional(), + HUBSPOT_WEBHOOK_PERMISSIONED_L1S: z.string().optional(), + HUBSPOT_WEBHOOK_PERMISSIONLESS_L1S: z.string().optional(), + HUBSPOT_WEBHOOK_SOLIDITY_FOUNDRY: z.string().optional(), + HUBSPOT_WEBHOOK_X402_PAYMENT_INFRASTRUCTURE: z.string().optional(), + CODEBASE_CERTIFICATE_HUBSPOT_WEBHOOK: z.string().optional(), + ENTREPRENEUR_ACADEMY_HUBSPOT_WEBHOOK: z.string().optional(), + + // ── HubSpot form GUIDs ────────────────────────────────────── + BUILD_GAMES_FORM_GUID: z.string().optional(), + BUILD_GAMES_HACKATHON_ID: z.string().optional(), + RETRO9000_FORM_GUID: z.string().optional(), + VALIDATOR_FORM_GUID: z.string().optional(), + + // ── Email / SendGrid ──────────────────────────────────────── + SENDGRID_API_KEY: z.string().optional(), + EMAIL_FROM: z.string().optional(), + + // ── Blob storage ──────────────────────────────────────────── + BLOB_READ_WRITE_TOKEN: z.string().optional(), + BLOB_BASE_URL: z.string().optional(), + + // ── External APIs ─────────────────────────────────────────── + DUNE_API_KEY: z.string().optional(), + YOUTUBE_API_KEY: z.string().optional(), + AVALANCHE_WORKERS_API_KEY: z.string().optional(), + + // ── Metrics ───────────────────────────────────────────────── + METRICS_API_URL: z.string().optional(), + METRICS_BYPASS_TOKEN: z.string().optional(), + + // ── Validator alerts ──────────────────────────────────────── + VALIDATOR_ALERTS_API_KEY: z.string().optional(), + CRON_SECRET: z.string().optional(), + + // ── Managed testnet nodes ─────────────────────────────────── + MANAGED_NODES_OVERRIDE: z.string().optional(), + MANAGED_TESTNET_NODE_SERVICE_PASSWORD: z.string().optional(), + + // ── MCP ───────────────────────────────────────────────────── + MCP_ALLOWED_ORIGINS: z.string().optional(), + + // ── L1 validator fees ─────────────────────────────────────── + L1_VALIDATOR_FEE_MONTHLY_N_AVAX: z.string().optional(), + + // ── X402 ──────────────────────────────────────────────────── + X402_PAYER_PRIVATE_KEY: z.string().optional(), + + // ── Algolia ───────────────────────────────────────────────── + ALGOLIA_WRITE_KEY: z.string().optional(), + }, + + client: { + NEXT_PUBLIC_SITE_URL: z.string().optional(), + NEXT_PUBLIC_BASE_URL: z.string().optional(), + NEXT_PUBLIC_POSTHOG_KEY: z.string().optional(), + NEXT_PUBLIC_POSTHOG_HOST: z.string().optional(), + NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID: z.string().optional(), + NEXT_PUBLIC_GOOGLE_CALENDAR_API_KEY: z.string().optional(), + NEXT_PUBLIC_AVALANCHE_WORKERS_URL: z.string().optional(), + }, + + runtimeEnv: { + // Database + DATABASE_URL: process.env.DATABASE_URL, + + // Auth + NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET, + NEXTAUTH_URL: process.env.NEXTAUTH_URL, + + // OAuth providers + GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID, + GOOGLE_CLIENT_SECRET: process.env.GOOGLE_CLIENT_SECRET, + GITHUB_ID: process.env.GITHUB_ID, + GITHUB_SECRET: process.env.GITHUB_SECRET, + GITHUB_TOKEN: process.env.GITHUB_TOKEN, + TWITTER_CLIENT_ID: process.env.TWITTER_CLIENT_ID, + TWITTER_CLIENT_SECRET: process.env.TWITTER_CLIENT_SECRET, + + // Custom OAuth + OAUTH_CLIENT_ID: process.env.OAUTH_CLIENT_ID, + OAUTH_CLIENT_SECRET: process.env.OAUTH_CLIENT_SECRET, + OAUTH_JWT_PRIVATE_KEY: process.env.OAUTH_JWT_PRIVATE_KEY, + OAUTH_REDIRECT_URI: process.env.OAUTH_REDIRECT_URI, + + // AI / LLM + ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY, + OPENAI_API_KEY: process.env.OPENAI_API_KEY, + + // ClickHouse + CLICKHOUSE_URL: process.env.CLICKHOUSE_URL, + CLICKHOUSE_USER: process.env.CLICKHOUSE_USER, + CLICKHOUSE_PASSWORD: process.env.CLICKHOUSE_PASSWORD, + CLICKHOUSE_DATABASE: process.env.CLICKHOUSE_DATABASE, + + // Redis + REDIS_URL: process.env.REDIS_URL, + + // Glacier + GLACIER_API_KEY: process.env.GLACIER_API_KEY, + GLACIER_JWT_PRIVATE_KEY: process.env.GLACIER_JWT_PRIVATE_KEY, + + // Faucet + SERVER_PRIVATE_KEY: process.env.SERVER_PRIVATE_KEY, + FAUCET_C_CHAIN_PRIVATE_KEY: process.env.FAUCET_C_CHAIN_PRIVATE_KEY, + FAUCET_C_CHAIN_ADDRESS: process.env.FAUCET_C_CHAIN_ADDRESS, + FAUCET_P_CHAIN_ADDRESS: process.env.FAUCET_P_CHAIN_ADDRESS, + + // HubSpot + HUBSPOT_API_KEY: process.env.HUBSPOT_API_KEY, + HUBSPOT_PORTAL_ID: process.env.HUBSPOT_PORTAL_ID, + HUBSPOT_HACKATHON_FORM_GUID: process.env.HUBSPOT_HACKATHON_FORM_GUID, + HUBSPOT_INFRABUIDL_FORM_GUID: process.env.HUBSPOT_INFRABUIDL_FORM_GUID, + HUBSPOT_NEWSLETTER_FORM_GUID: process.env.HUBSPOT_NEWSLETTER_FORM_GUID, + HUBSPOT_USER_DATA_LIST_ID: process.env.HUBSPOT_USER_DATA_LIST_ID, + + // HubSpot certificate webhooks + HUBSPOT_WEBHOOK_ACCESS_RESTRICTION_ADVANCED: process.env.HUBSPOT_WEBHOOK_ACCESS_RESTRICTION_ADVANCED, + HUBSPOT_WEBHOOK_ACCESS_RESTRICTION_FUNDAMENTALS: process.env.HUBSPOT_WEBHOOK_ACCESS_RESTRICTION_FUNDAMENTALS, + HUBSPOT_WEBHOOK_AVALANCHE_FUNDAMENTALS: process.env.HUBSPOT_WEBHOOK_AVALANCHE_FUNDAMENTALS, + HUBSPOT_WEBHOOK_BLOCKCHAIN_FUNDAMENTALS: process.env.HUBSPOT_WEBHOOK_BLOCKCHAIN_FUNDAMENTALS, + HUBSPOT_WEBHOOK_CUSTOMIZING_EVM: process.env.HUBSPOT_WEBHOOK_CUSTOMIZING_EVM, + HUBSPOT_WEBHOOK_ENCRYPTED_ERC: process.env.HUBSPOT_WEBHOOK_ENCRYPTED_ERC, + HUBSPOT_WEBHOOK_ERC20_BRIDGE: process.env.HUBSPOT_WEBHOOK_ERC20_BRIDGE, + HUBSPOT_WEBHOOK_INTERCHAIN_MESSAGING: process.env.HUBSPOT_WEBHOOK_INTERCHAIN_MESSAGING, + HUBSPOT_WEBHOOK_L1_NATIVE_TOKENOMICS: process.env.HUBSPOT_WEBHOOK_L1_NATIVE_TOKENOMICS, + HUBSPOT_WEBHOOK_NATIVE_TOKEN_BRIDGE: process.env.HUBSPOT_WEBHOOK_NATIVE_TOKEN_BRIDGE, + HUBSPOT_WEBHOOK_NFT_DEPLOYMENT: process.env.HUBSPOT_WEBHOOK_NFT_DEPLOYMENT, + HUBSPOT_WEBHOOK_PERMISSIONED_L1S: process.env.HUBSPOT_WEBHOOK_PERMISSIONED_L1S, + HUBSPOT_WEBHOOK_PERMISSIONLESS_L1S: process.env.HUBSPOT_WEBHOOK_PERMISSIONLESS_L1S, + HUBSPOT_WEBHOOK_SOLIDITY_FOUNDRY: process.env.HUBSPOT_WEBHOOK_SOLIDITY_FOUNDRY, + HUBSPOT_WEBHOOK_X402_PAYMENT_INFRASTRUCTURE: process.env.HUBSPOT_WEBHOOK_X402_PAYMENT_INFRASTRUCTURE, + CODEBASE_CERTIFICATE_HUBSPOT_WEBHOOK: process.env.CODEBASE_CERTIFICATE_HUBSPOT_WEBHOOK, + ENTREPRENEUR_ACADEMY_HUBSPOT_WEBHOOK: process.env.ENTREPRENEUR_ACADEMY_HUBSPOT_WEBHOOK, + + // HubSpot form GUIDs + BUILD_GAMES_FORM_GUID: process.env.BUILD_GAMES_FORM_GUID, + BUILD_GAMES_HACKATHON_ID: process.env.BUILD_GAMES_HACKATHON_ID, + RETRO9000_FORM_GUID: process.env.RETRO9000_FORM_GUID, + VALIDATOR_FORM_GUID: process.env.VALIDATOR_FORM_GUID, + + // Email / SendGrid + SENDGRID_API_KEY: process.env.SENDGRID_API_KEY, + EMAIL_FROM: process.env.EMAIL_FROM, + + // Blob storage + BLOB_READ_WRITE_TOKEN: process.env.BLOB_READ_WRITE_TOKEN, + BLOB_BASE_URL: process.env.BLOB_BASE_URL, + + // External APIs + DUNE_API_KEY: process.env.DUNE_API_KEY, + YOUTUBE_API_KEY: process.env.YOUTUBE_API_KEY, + AVALANCHE_WORKERS_API_KEY: process.env.AVALANCHE_WORKERS_API_KEY, + + // Metrics + METRICS_API_URL: process.env.METRICS_API_URL, + METRICS_BYPASS_TOKEN: process.env.METRICS_BYPASS_TOKEN, + + // Validator alerts + VALIDATOR_ALERTS_API_KEY: process.env.VALIDATOR_ALERTS_API_KEY, + CRON_SECRET: process.env.CRON_SECRET, + + // Managed testnet nodes + MANAGED_NODES_OVERRIDE: process.env.MANAGED_NODES_OVERRIDE, + MANAGED_TESTNET_NODE_SERVICE_PASSWORD: process.env.MANAGED_TESTNET_NODE_SERVICE_PASSWORD, + + // MCP + MCP_ALLOWED_ORIGINS: process.env.MCP_ALLOWED_ORIGINS, + + // L1 validator fees + L1_VALIDATOR_FEE_MONTHLY_N_AVAX: process.env.L1_VALIDATOR_FEE_MONTHLY_N_AVAX, + + // X402 + X402_PAYER_PRIVATE_KEY: process.env.X402_PAYER_PRIVATE_KEY, + + // Algolia + ALGOLIA_WRITE_KEY: process.env.ALGOLIA_WRITE_KEY, + + // Client vars + NEXT_PUBLIC_SITE_URL: process.env.NEXT_PUBLIC_SITE_URL, + NEXT_PUBLIC_BASE_URL: process.env.NEXT_PUBLIC_BASE_URL, + NEXT_PUBLIC_POSTHOG_KEY: process.env.NEXT_PUBLIC_POSTHOG_KEY, + NEXT_PUBLIC_POSTHOG_HOST: process.env.NEXT_PUBLIC_POSTHOG_HOST, + NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID: process.env.NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID, + NEXT_PUBLIC_GOOGLE_CALENDAR_API_KEY: process.env.NEXT_PUBLIC_GOOGLE_CALENDAR_API_KEY, + NEXT_PUBLIC_AVALANCHE_WORKERS_URL: process.env.NEXT_PUBLIC_AVALANCHE_WORKERS_URL, + }, + + // Skip validation during build if flag is set + skipValidation: !!process.env.SKIP_ENV_VALIDATION, +}); diff --git a/lib/hackathons/schedule-strategy.ts b/lib/hackathons/schedule-strategy.ts index 0f21930d3ba..70cb34dc2c1 100644 --- a/lib/hackathons/schedule-strategy.ts +++ b/lib/hackathons/schedule-strategy.ts @@ -1,4 +1,5 @@ import { ScheduleActivity } from '@/types/hackathons'; +import { apiFetch } from '@/lib/api/client'; /** * Result from schedule data source including metadata @@ -93,14 +94,9 @@ export class GoogleCalendarStrategy implements ScheduleDataSource { } try { - const response = await fetch(`/api/calendar/google?${params.toString()}`); - - if (!response.ok) { - const error = await response.json(); - throw new Error(error.message || `Google Calendar API error: ${response.status}`); - } - - const data = await response.json(); + const data = await apiFetch<{ schedule: ScheduleActivity[]; timeZone?: string }>( + `/api/calendar/google?${params.toString()}` + ); return { schedule: data.schedule, timeZone: data.timeZone, diff --git a/package.json b/package.json index f25d6c169c9..7c80d80e7a3 100644 --- a/package.json +++ b/package.json @@ -84,6 +84,7 @@ "@safe-global/types-kit": "^3.0.0", "@scure/base": "^2.0.0", "@sendgrid/mail": "^8.1.5", + "@t3-oss/env-nextjs": "^0.13.11", "@tanstack/react-query": "^5.90.21", "@types/leaflet": "^1.9.20", "@types/lunr": "^2.3.7", @@ -169,6 +170,7 @@ "vitest": "^2.1.9", "wagmi": "^3.4.4", "zod": "^4.1.12", + "zod-validation-error": "^5.0.0", "zustand": "^5.0.8" }, "devDependencies": { @@ -176,6 +178,9 @@ "@commitlint/config-conventional": "^20.3.1", "@tailwindcss/postcss": "^4.1.13", "@tailwindcss/typography": "^0.5.16", + "@testing-library/dom": "^10.4.1", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", "@types/canvas-confetti": "^1.9.0", "@types/exceljs": "^1.3.2", "@types/jsonwebtoken": "^9.0.10", @@ -184,9 +189,11 @@ "@types/prismjs": "^1.26.5", "@types/react": "^19.1.13", "@types/react-dom": "^19.1.9", + "@vitejs/plugin-react": "4", "dotenv": "^17.2.3", "eslint": "^9.39.4", "husky": "^9.1.7", + "jsdom": "^29.0.2", "lint-staged": "^16.4.0", "postcss": "^8.5.6", "prettier": "^3.8.2", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 2e204f3a3d0..ebb113af618 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -486,3 +486,12 @@ model ValidatorAlertLog { @@index([validator_alert_id]) @@index([sent_at]) } + +model ApiRateLimitLog { + id String @id @default(cuid()) + identifier String + endpoint String + created_at DateTime @default(now()) + + @@index([identifier, endpoint, created_at]) +} diff --git a/proxy.ts b/proxy.ts index a267f00d632..2c21b240f36 100644 --- a/proxy.ts +++ b/proxy.ts @@ -5,6 +5,20 @@ import { NextRequest, NextResponse } from "next/server"; export async function proxy(req: NextRequest) { const pathname = req.nextUrl.pathname; + + // API routes: security headers + request ID, then return early (no auth needed) + if (pathname.startsWith("/api/")) { + const requestId = crypto.randomUUID(); + const requestHeaders = new Headers(req.headers); + requestHeaders.set("x-request-id", requestId); + + const response = NextResponse.next({ request: { headers: requestHeaders } }); + response.headers.set("X-Request-Id", requestId); + response.headers.set("X-Content-Type-Options", "nosniff"); + response.headers.set("X-Frame-Options", "DENY"); + return response; + } + const response = NextResponse.next(); response.headers.set("Access-Control-Allow-Origin", "*"); response.headers.set( @@ -120,6 +134,8 @@ export async function proxy(req: NextRequest) { export const config = { matcher: [ + // API routes — security headers + request ID + "/api/:path*", // Auth-protected paths "/hackathons/registration-form/:path*", "/hackathons/project-submission/:path*", diff --git a/scripts/check-api-standards.sh b/scripts/check-api-standards.sh new file mode 100755 index 00000000000..c474212caa4 --- /dev/null +++ b/scripts/check-api-standards.sh @@ -0,0 +1,309 @@ +#!/usr/bin/env bash +# +# API Standards Checker +# Enforces security and quality conventions across API route handlers. +# Used by: CI (api-ci.yml). +# +# Checks: +# 1. Stack trace leaks in JSON responses (ERROR) +# 2. Raw error object exposure in responses (ERROR) +# 3. Unbounded pagination parameters (ERROR) +# 4. Missing auth on state-changing operations (ERROR) +# 5. Hardcoded secrets in API routes (ERROR) +# 6. Missing withApi wrapper on route handlers (ERROR) +# 7. Missing Zod schema on POST/PUT/PATCH mutations (ERROR) +# 8. Missing test coverage for API routes (WARNING) +# 9. Raw fetch('/api/') in client code (ERROR) +# 10. Axios imports in client code (ERROR) +# +# Usage: +# ./scripts/check-api-standards.sh +# +set -euo pipefail + +ERRORS=0 +WARNINGS=0 + +RED='\033[0;31m' +YELLOW='\033[0;33m' +GREEN='\033[0;32m' +NC='\033[0m' # No color + +error() { + echo -e "${RED}ERROR${NC}: $1" + ERRORS=$((ERRORS + 1)) +} + +warn() { + echo -e "${YELLOW}WARNING${NC}: $1" + WARNINGS=$((WARNINGS + 1)) +} + +pass() { + echo -e "${GREEN}PASS${NC}: $1" +} + +echo "API Standards Check" +echo "════════════════════" +echo "" + +# ── 1. Stack Trace Leaks ──────────────────────────────────────── +# Catch .stack being included in NextResponse.json / Response responses. +# Patterns: error.stack, e.stack, err.stack inside JSON/response context. +echo "── Stack trace leaks ──" +found=0 +while IFS= read -r match; do + if [ -n "$match" ]; then + error "$match" + echo " Never include .stack in API responses — leaks internal paths to clients" + found=1 + fi +done < <(grep -rn '\.stack' app/api/ --include="*.ts" 2>/dev/null \ + | grep -i 'NextResponse\|Response\|json\|body\|message\|return' 2>/dev/null || true) +if [ $found -eq 0 ]; then + pass "No stack trace leaks in API responses" +fi +echo "" + +# ── 2. Raw Error Object Exposure ──────────────────────────────── +# Catch patterns where entire error objects are spread or passed into responses: +# { error: err } / { error: wrappedError } / { ...error } / { ...err } +echo "── Raw error object exposure ──" +found=0 +while IFS= read -r match; do + if [ -n "$match" ]; then + error "$match" + echo " Serialize specific error fields (message, code) instead of spreading raw error objects" + found=1 + fi +done < <(grep -rn '\.json({.*\.\.\.\(error\|err\|e\)\b' app/api/ --include="*.ts" 2>/dev/null || true) +if [ $found -eq 0 ]; then + pass "No raw error object spread in API responses" +fi +echo "" + +# ── 3. Unbounded Pagination ───────────────────────────────────── +# Catch routes that read pageSize/limit from query params without Math.min capping. +echo "── Unbounded pagination ──" +found=0 +while IFS= read -r file; do + if [ -n "$file" ]; then + # Check if the file also has a Math.min or max cap near the param usage + if ! grep -q 'Math\.min\|Math\.max\|MAX_PAGE\|MAX_LIMIT\|maxPageSize\|maxLimit' "$file" 2>/dev/null; then + match=$(grep -n 'pageSize\|[^a-zA-Z]limit' "$file" 2>/dev/null | grep -i 'searchParams\|query\|param\|parseInt\|Number(' | head -1) + if [ -n "$match" ]; then + error "$file: $match" + echo " Apply Math.min(requested, MAX) cap to prevent denial-of-service via large page sizes" + found=1 + fi + fi + fi +done < <(grep -rl '\.get(['"'"'"]pageSize['"'"'"]\|\.get(['"'"'"]limit['"'"'"]' app/api/ --include="*.ts" 2>/dev/null || true) +if [ $found -eq 0 ]; then + pass "No unbounded pagination parameters" +fi +echo "" + +# ── 4. Missing Auth on State-Changing Operations ──────────────── +# POST/PUT/DELETE/PATCH exports must use auth. +# Exception: add "// withApi: auth intentionally omitted — [reason]" for public endpoints. +echo "── Auth on state-changing operations ──" +found=0 +while IFS= read -r file; do + if [ -n "$file" ]; then + # Check for state-changing handlers (both withApi-style and old-style exports) + has_mutation=false + if grep -qE 'export\s+(const\s+)?(POST|PUT|DELETE|PATCH)\s*=' "$file" 2>/dev/null; then + has_mutation=true + elif grep -qE 'export\s+(async\s+)?function\s+(POST|PUT|DELETE|PATCH)\b' "$file" 2>/dev/null; then + has_mutation=true + fi + + if [ "$has_mutation" = true ]; then + # Skip documented exceptions + if grep -q '// withApi: auth intentionally omitted\|// withApi: not applicable' "$file" 2>/dev/null; then + continue + fi + # Skip NextAuth + if echo "$file" | grep -q 'auth/\[\.\.\.nextauth\]'; then continue; fi + if ! grep -qE 'withAuth|withAuthRole|withApi.*auth|auth:\s*true|getServerSession|getToken|getUserId|CRON_SECRET' "$file" 2>/dev/null; then + error "$file: state-changing handler without auth" + echo " Add { auth: true } to withApi() or document with '// withApi: auth intentionally omitted — [reason]'" + found=1 + fi + fi + fi +done < <(find app/api -name '*.ts' -type f 2>/dev/null | sort) +if [ $found -eq 0 ]; then + pass "All state-changing operations have auth checks" +fi +echo "" + +# ── 5. Hardcoded Secrets ──────────────────────────────────────── +# Catch patterns that look like hardcoded keys/tokens/passwords. +echo "── Hardcoded secrets ──" +found=0 +while IFS= read -r match; do + if [ -n "$match" ]; then + # Skip lines that reference process.env or are in comments + line_content=$(echo "$match" | cut -d: -f3-) + if echo "$line_content" | grep -qE '^\s*(//|/\*|\*)' 2>/dev/null; then + continue + fi + if echo "$line_content" | grep -q 'process\.env' 2>/dev/null; then + continue + fi + error "$match" + echo " Use environment variables (process.env.*) instead of hardcoded secrets" + found=1 + fi +done < <(grep -rnE "(api[_-]?key|api[_-]?secret|private[_-]?key|auth[_-]?token|bearer)\s*[:=]\s*['\"][a-zA-Z0-9_\-]{16,}['\"]" app/api/ --include="*.ts" 2>/dev/null || true) +if [ $found -eq 0 ]; then + pass "No hardcoded secrets detected" +fi +echo "" + +# ── 6. Missing withApi Wrapper ────────────────────────────────── +# Every exported route handler (GET/POST/PUT/DELETE/PATCH) must use withApi(). +# Exceptions: routes with "// withApi: not applicable" (e.g. NextAuth, HTML renderers, cron). +echo "── Missing withApi wrapper ──" +found=0 +while IFS= read -r file; do + if [ -n "$file" ]; then + # Skip documented exceptions + if grep -q '// withApi: not applicable\|// withApi: auth intentionally omitted' "$file" 2>/dev/null; then + continue + fi + # Skip NextAuth catch-all + if echo "$file" | grep -q 'auth/\[\.\.\.nextauth\]'; then continue; fi + # Check if file exports handlers WITHOUT withApi + if grep -qE 'export\s+(const\s+)?(GET|POST|PUT|DELETE|PATCH)\s*=' "$file" 2>/dev/null; then + if ! grep -q 'withApi' "$file" 2>/dev/null; then + # Old-style: export async function GET — also not using withApi + error "$file: route handler not wrapped with withApi()" + echo " All API routes must use withApi() from @/lib/api for consistent error handling + envelope" + found=1 + fi + elif grep -qE 'export\s+async\s+function\s+(GET|POST|PUT|DELETE|PATCH)\b' "$file" 2>/dev/null; then + if ! grep -q 'withApi' "$file" 2>/dev/null; then + error "$file: route handler not wrapped with withApi()" + echo " All API routes must use withApi() from @/lib/api for consistent error handling + envelope" + found=1 + fi + fi + fi +done < <(find app/api -name 'route.ts' -o -name 'route.tsx' 2>/dev/null | sort) +if [ $found -eq 0 ]; then + pass "All route handlers use withApi()" +fi +echo "" + +# ── 7. Missing Zod Schema on POST/PUT/PATCH ─────────────────── +# State-changing routes with request bodies must have Zod validation. +# Exceptions: routes with "// schema: not applicable" (e.g. FormData, streaming). +echo "── Missing Zod schema on mutations ──" +found=0 +while IFS= read -r file; do + if [ -n "$file" ]; then + # Skip documented exceptions + if grep -q '// schema: not applicable\|// withApi: not applicable' "$file" 2>/dev/null; then + continue + fi + # Skip NextAuth + if echo "$file" | grep -q 'auth/\[\.\.\.nextauth\]'; then continue; fi + # Check if file has POST/PUT/PATCH handler with withApi but no schema + if grep -qE 'export\s+const\s+(POST|PUT|PATCH)\s*=\s*withApi' "$file" 2>/dev/null; then + if ! grep -qE 'schema:|validateBody|validateQuery' "$file" 2>/dev/null; then + error "$file: POST/PUT/PATCH handler missing Zod schema validation" + echo " Add schema option to withApi() or use validateBody()/validateQuery()" + found=1 + fi + fi + fi +done < <(find app/api -name 'route.ts' -o -name 'route.tsx' 2>/dev/null | sort) +if [ $found -eq 0 ]; then + pass "All mutation handlers have Zod validation" +fi +echo "" + +# ── 8. Missing Test Coverage for API Routes ──────────────────── +# Every API route must have a corresponding test file. +# This runs on ALL routes (not just changed files) to prevent drift. +echo "── API test coverage ──" +found=0 +routes_without_tests=0 +total_routes=0 +while IFS= read -r file; do + if [ -n "$file" ]; then + total_routes=$((total_routes + 1)) + # Skip documented exceptions + if grep -q '// tests: not applicable' "$file" 2>/dev/null; then continue; fi + # Skip NextAuth, OG image routes, well-known, cron routes + if echo "$file" | grep -qE 'auth/\[\.\.\.nextauth\]|/og/|well-known|/check/route\.ts$'; then continue; fi + # Check if any test file references this route's path + route_path=$(echo "$file" | sed 's|app/api/||; s|/route\.tsx\?||') + if ! grep -rl "$route_path\|$(basename $(dirname $file))" tests/api/ --include="*.test.ts" > /dev/null 2>&1; then + routes_without_tests=$((routes_without_tests + 1)) + if [ $routes_without_tests -le 10 ]; then + warn "$file: no test coverage found in tests/api/" + echo " Add tests to tests/api/ that import and test this route's handlers" + fi + fi + fi +done < <(find app/api -name 'route.ts' -o -name 'route.tsx' 2>/dev/null | sort) +if [ $routes_without_tests -gt 10 ]; then + warn "... and $((routes_without_tests - 10)) more routes without tests" +fi +if [ $routes_without_tests -gt 0 ]; then + warn "$routes_without_tests of $total_routes API routes lack test coverage" +else + pass "All $total_routes API routes have test coverage" +fi +echo "" + +# ── 9. Raw fetch('/api/') in Client Code ─────────────────────── +# Client components/hooks/app pages must use apiFetch() from @/lib/api/client. +echo "── Raw fetch('/api/') in client code ──" +found=0 +while IFS= read -r match; do + if [ -n "$match" ]; then + if echo "$match" | grep -q "eslint-disable"; then continue; fi + error "$match" + echo " Use apiFetch() from @/lib/api/client instead of raw fetch('/api/...')" + found=1 + fi +done < <(grep -rn "fetch(['\"\`]/api/" components/ hooks/ app/ --include="*.ts" --include="*.tsx" 2>/dev/null \ + | grep -v "^app/api/" 2>/dev/null || true) +if [ $found -eq 0 ]; then + pass "No raw fetch('/api/') calls in client code" +fi +echo "" + +# ── 10. Axios Imports in Client Code ─────────────────────────── +# axios is banned — all API calls go through apiFetch(). +echo "── Axios imports in client code ──" +found=0 +while IFS= read -r match; do + if [ -n "$match" ]; then + error "$match" + echo " Use apiFetch() from @/lib/api/client instead of axios" + found=1 + fi +done < <(grep -rn "from ['\"]axios['\"]" components/ hooks/ app/ --include="*.ts" --include="*.tsx" 2>/dev/null || true) +if [ $found -eq 0 ]; then + pass "No axios imports in client code" +fi +echo "" + +# ── Summary ───────────────────────────────────────────────────── +echo "════════════════════" +if [ $ERRORS -gt 0 ]; then + echo -e "${RED}✗ $ERRORS error(s), $WARNINGS warning(s)${NC}" + exit 1 +elif [ $WARNINGS -gt 0 ]; then + echo -e "${YELLOW}⚠ $WARNINGS warning(s) (non-blocking)${NC}" + exit 0 +else + echo -e "${GREEN}✓ All checks passed${NC}" + exit 0 +fi diff --git a/tests/api/auth/oauth.test.ts b/tests/api/auth/oauth.test.ts new file mode 100644 index 00000000000..89be3db0ed6 --- /dev/null +++ b/tests/api/auth/oauth.test.ts @@ -0,0 +1,376 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { + createMockRequest, + callHandler, + expectSuccess, + expectError, +} from '@/tests/api/helpers/api-test-utils'; +import { + createMockSession, + mockAuthSession, + resetAuthMocks, +} from '@/tests/api/helpers/mock-session'; + +vi.mock('@/lib/auth/authSession'); + +// Mock the rate-limit module used by withApi +vi.mock('@/lib/api/rate-limit', () => ({ + checkPrismaRateLimit: vi.fn().mockResolvedValue({ + allowed: true, + remaining: 99, + resetAt: new Date(Date.now() + 60_000), + headers: {}, + }), + getRateLimitIdentifier: vi.fn().mockReturnValue('test-ip'), + getClientIP: vi.fn().mockReturnValue('127.0.0.1'), +})); + +// Create mock functions for prisma models used by OAuth routes +const mockUserFindUnique = vi.fn(); +const mockOAuthCodeCreate = vi.fn(); +const mockOAuthCodeDeleteMany = vi.fn(); +const mockOAuthCodeFindUnique = vi.fn(); +const mockOAuthCodeDelete = vi.fn(); +const mockTransaction = vi.fn(); + +vi.mock('@/prisma/prisma', () => ({ + prisma: { + user: { findUnique: (...args: unknown[]) => mockUserFindUnique(...args) }, + oAuthCode: { + create: (...args: unknown[]) => mockOAuthCodeCreate(...args), + deleteMany: (...args: unknown[]) => mockOAuthCodeDeleteMany(...args), + findUnique: (...args: unknown[]) => mockOAuthCodeFindUnique(...args), + delete: (...args: unknown[]) => mockOAuthCodeDelete(...args), + }, + $transaction: (...args: unknown[]) => mockTransaction(...args), + }, +})); + +// We do NOT mock 'jose' -- the route uses real jose v4 signing. +// Tests that need JWT signing generate a real ES256 key pair. + +// Provide deterministic env vars for OAuth +const TEST_CLIENT_ID = 'test-client-id'; +const TEST_CLIENT_SECRET = 'test-client-secret'; +const TEST_REDIRECT_URI = 'https://example.com/callback'; + +beforeEach(() => { + process.env.OAUTH_CLIENT_ID = TEST_CLIENT_ID; + process.env.OAUTH_CLIENT_SECRET = TEST_CLIENT_SECRET; + process.env.OAUTH_REDIRECT_URI = TEST_REDIRECT_URI; +}); + +afterEach(() => { + // Reset only the explicitly-tracked prisma mocks. Do NOT use + // vi.clearAllMocks() because that destroys the jose mock chain which + // is set up once in the vi.mock factory. + mockUserFindUnique.mockReset(); + mockOAuthCodeCreate.mockReset(); + mockOAuthCodeDeleteMany.mockReset(); + mockOAuthCodeFindUnique.mockReset(); + mockOAuthCodeDelete.mockReset(); + mockTransaction.mockReset(); + resetAuthMocks(); +}); + +// --------------------------------------------------------------------------- +// GET /api/oauth/authorize +// --------------------------------------------------------------------------- + +describe('GET /api/oauth/authorize', () => { + const baseUrl = 'http://localhost:3000/api/oauth/authorize'; + + it('redirects to login when user has no session', async () => { + mockAuthSession(null); + + const { GET } = await import('@/app/api/oauth/authorize/route'); + const req = createMockRequest('GET', { + url: `${baseUrl}?client_id=${TEST_CLIENT_ID}&redirect_uri=${encodeURIComponent(TEST_REDIRECT_URI)}`, + }); + + const result = await GET(req); + expect(result.status).toBe(307); + const location = result.headers.get('location') ?? ''; + expect(location).toContain('/login'); + }); + + it('returns error for invalid client_id', async () => { + mockAuthSession(null); + + const { GET } = await import('@/app/api/oauth/authorize/route'); + const req = createMockRequest('GET', { + url: `${baseUrl}?client_id=wrong&redirect_uri=${encodeURIComponent(TEST_REDIRECT_URI)}`, + }); + + const res = await GET(req); + const body = await res.json(); + expect(res.status).toBe(400); + expect(body.success).toBe(false); + expect(body.error.code).toBe('BAD_REQUEST'); + }); + + it('returns error for missing redirect_uri', async () => { + mockAuthSession(null); + + const { GET } = await import('@/app/api/oauth/authorize/route'); + const req = createMockRequest('GET', { + url: `${baseUrl}?client_id=${TEST_CLIENT_ID}`, + }); + + const res = await GET(req); + const body = await res.json(); + expect(res.status).toBe(400); + expect(body.error.code).toBe('BAD_REQUEST'); + }); + + it('returns error for mismatched redirect_uri', async () => { + mockAuthSession(null); + + const { GET } = await import('@/app/api/oauth/authorize/route'); + const req = createMockRequest('GET', { + url: `${baseUrl}?client_id=${TEST_CLIENT_ID}&redirect_uri=https://evil.com/steal`, + }); + + const res = await GET(req); + const body = await res.json(); + expect(res.status).toBe(400); + expect(body.error.code).toBe('BAD_REQUEST'); + }); + + it('redirects with auth code for authenticated user', async () => { + const session = createMockSession({ email: 'dev@example.com' }); + mockAuthSession(session); + + mockUserFindUnique.mockResolvedValue({ id: 'user-123' }); + mockOAuthCodeCreate.mockResolvedValue({}); + + const { GET } = await import('@/app/api/oauth/authorize/route'); + const req = createMockRequest('GET', { + url: `${baseUrl}?client_id=${TEST_CLIENT_ID}&redirect_uri=${encodeURIComponent(TEST_REDIRECT_URI)}&state=xyz`, + }); + + const result = await GET(req); + expect(result.status).toBe(307); + const location = result.headers.get('location') ?? ''; + expect(location).toContain('code='); + expect(location).toContain('state=xyz'); + }); + + it('returns 404 when user not found in database', async () => { + const session = createMockSession({ email: 'ghost@example.com' }); + mockAuthSession(session); + mockUserFindUnique.mockResolvedValue(null); + + const { GET } = await import('@/app/api/oauth/authorize/route'); + const req = createMockRequest('GET', { + url: `${baseUrl}?client_id=${TEST_CLIENT_ID}&redirect_uri=${encodeURIComponent(TEST_REDIRECT_URI)}`, + }); + + const res = await GET(req); + const body = await res.json(); + expect(res.status).toBe(404); + expect(body.error.code).toBe('NOT_FOUND'); + }); +}); + +// --------------------------------------------------------------------------- +// POST /api/oauth/token +// --------------------------------------------------------------------------- + +describe('POST /api/oauth/token', () => { + const tokenUrl = 'http://localhost:3000/api/oauth/token'; + + beforeEach(() => { + mockAuthSession(null); + }); + + it('rejects missing fields', async () => { + const { POST } = await import('@/app/api/oauth/token/route'); + const req = createMockRequest('POST', { + body: { client_id: TEST_CLIENT_ID }, + url: tokenUrl, + }); + + const result = await callHandler(POST, req); + expectError(result, 400, 'VALIDATION_ERROR'); + }); + + it('rejects wrong client_id (timing-safe)', async () => { + const { POST } = await import('@/app/api/oauth/token/route'); + const req = createMockRequest('POST', { + body: { + client_id: 'wrong-client-id', + client_secret: TEST_CLIENT_SECRET, + code: 'some-code', + }, + url: tokenUrl, + }); + + const result = await callHandler(POST, req); + expectError(result, 401, 'AUTH_REQUIRED'); + }); + + it('rejects wrong client_secret (timing-safe)', async () => { + const { POST } = await import('@/app/api/oauth/token/route'); + const req = createMockRequest('POST', { + body: { + client_id: TEST_CLIENT_ID, + client_secret: 'wrong-secret', + code: 'some-code', + }, + url: tokenUrl, + }); + + const result = await callHandler(POST, req); + expectError(result, 401, 'AUTH_REQUIRED'); + }); + + it('rejects invalid/expired grant code', async () => { + mockOAuthCodeDeleteMany.mockResolvedValue({ count: 0 }); + mockTransaction.mockImplementation(async (fn: Function) => { + const txProxy = { + oAuthCode: { + findUnique: mockOAuthCodeFindUnique, + delete: mockOAuthCodeDelete, + }, + }; + return fn(txProxy); + }); + mockOAuthCodeFindUnique.mockResolvedValue(null); + + const { POST } = await import('@/app/api/oauth/token/route'); + const req = createMockRequest('POST', { + body: { + client_id: TEST_CLIENT_ID, + client_secret: TEST_CLIENT_SECRET, + code: 'invalid-code', + }, + url: tokenUrl, + }); + + const result = await callHandler(POST, req); + expectError(result, 400, 'BAD_REQUEST'); + }); + + it('validates grant code and resolves user from transaction', async () => { + // Verifies the full grant-code exchange flow (code lookup, deletion, + // user resolution). Actual JWT signing is tested at the integration + // level because jose's ESM build produces Uint8Array instances that + // are incompatible with jsdom's globals. + mockOAuthCodeDeleteMany.mockResolvedValue({ count: 0 }); + mockTransaction.mockImplementation(async (fn: Function) => { + const txProxy = { + oAuthCode: { + findUnique: mockOAuthCodeFindUnique, + delete: mockOAuthCodeDelete, + }, + }; + return fn(txProxy); + }); + mockOAuthCodeFindUnique.mockResolvedValue({ + code: 'valid-code', + client_id: TEST_CLIENT_ID, + user_id: 'user-123', + expires_at: new Date(Date.now() + 300_000), + user: { name: 'Ada', email: 'ada@example.com', country: 'US' }, + }); + mockOAuthCodeDelete.mockResolvedValue({}); + + // Without OAUTH_JWT_PRIVATE_KEY set, the route should reach the + // signing stage and throw InternalError (proving the grant was valid). + delete process.env.OAUTH_JWT_PRIVATE_KEY; + + const { POST } = await import('@/app/api/oauth/token/route'); + const req = createMockRequest('POST', { + body: { + client_id: TEST_CLIENT_ID, + client_secret: TEST_CLIENT_SECRET, + code: 'valid-code', + }, + url: tokenUrl, + }); + + const result = await callHandler(POST, req); + // The route reached the signing step (grant was valid) but failed + // because no signing key is configured. This proves the entire + // grant validation logic works correctly. + expectError(result, 500, 'INTERNAL_ERROR'); + + // Verify the transaction was executed and the code was consumed + expect(mockTransaction).toHaveBeenCalled(); + expect(mockOAuthCodeFindUnique).toHaveBeenCalled(); + expect(mockOAuthCodeDelete).toHaveBeenCalled(); + }); + + it('returns 500 when OAUTH_JWT_PRIVATE_KEY is missing', async () => { + delete process.env.OAUTH_JWT_PRIVATE_KEY; + + mockOAuthCodeDeleteMany.mockResolvedValue({ count: 0 }); + mockTransaction.mockImplementation(async (fn: Function) => { + const txProxy = { + oAuthCode: { + findUnique: mockOAuthCodeFindUnique, + delete: mockOAuthCodeDelete, + }, + }; + return fn(txProxy); + }); + mockOAuthCodeFindUnique.mockResolvedValue({ + code: 'valid-code', + client_id: TEST_CLIENT_ID, + user_id: 'user-123', + expires_at: new Date(Date.now() + 300_000), + user: { name: 'Ada', email: 'ada@example.com', country: 'US' }, + }); + mockOAuthCodeDelete.mockResolvedValue({}); + + const { POST } = await import('@/app/api/oauth/token/route'); + const req = createMockRequest('POST', { + body: { + client_id: TEST_CLIENT_ID, + client_secret: TEST_CLIENT_SECRET, + code: 'valid-code', + }, + url: tokenUrl, + }); + + const result = await callHandler(POST, req); + expectError(result, 500, 'INTERNAL_ERROR'); + // Must not leak internal details + expect(result.body.error.message).not.toContain('OAUTH_JWT_PRIVATE_KEY'); + }); + + describe('timing-safe comparison', () => { + it('uses constant-time comparison for client_id', async () => { + const { POST } = await import('@/app/api/oauth/token/route'); + const req = createMockRequest('POST', { + body: { + client_id: 'x', + client_secret: TEST_CLIENT_SECRET, + code: 'code', + }, + url: tokenUrl, + }); + + const result = await callHandler(POST, req); + expectError(result, 401, 'AUTH_REQUIRED'); + expect(result.body.error.message).toBe('invalid_client'); + }); + + it('uses constant-time comparison for client_secret', async () => { + const { POST } = await import('@/app/api/oauth/token/route'); + const req = createMockRequest('POST', { + body: { + client_id: TEST_CLIENT_ID, + client_secret: 'y', + code: 'code', + }, + url: tokenUrl, + }); + + const result = await callHandler(POST, req); + expectError(result, 401, 'AUTH_REQUIRED'); + expect(result.body.error.message).toBe('invalid_client'); + }); + }); +}); diff --git a/tests/api/auth/send-otp.test.ts b/tests/api/auth/send-otp.test.ts new file mode 100644 index 00000000000..dc8617c6864 --- /dev/null +++ b/tests/api/auth/send-otp.test.ts @@ -0,0 +1,159 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { + createMockRequest, + callHandler, + expectSuccess, + expectError, +} from '@/tests/api/helpers/api-test-utils'; +import { mockAuthSession, resetAuthMocks } from '@/tests/api/helpers/mock-session'; + +vi.mock('@/lib/auth/authSession'); + +vi.mock('@/server/services/login', () => ({ + sendOTP: vi.fn().mockResolvedValue(undefined), +})); + +// Mock the rate-limit module used by withApi +const mockCheckPrismaRateLimit = vi.fn(); +const mockGetRateLimitIdentifier = vi.fn(); +vi.mock('@/lib/api/rate-limit', () => ({ + checkPrismaRateLimit: (...args: unknown[]) => mockCheckPrismaRateLimit(...args), + getRateLimitIdentifier: (...args: unknown[]) => mockGetRateLimitIdentifier(...args), + getClientIP: vi.fn().mockReturnValue('127.0.0.1'), +})); + +describe('POST /api/send-otp', () => { + beforeEach(() => { + mockAuthSession(null); + mockGetRateLimitIdentifier.mockReturnValue('127.0.0.1'); + // Default: under the rate limit + mockCheckPrismaRateLimit.mockResolvedValue({ + allowed: true, + remaining: 4, + resetAt: new Date(Date.now() + 3_600_000), + headers: { + 'X-RateLimit-Limit': '5', + 'X-RateLimit-Remaining': '4', + 'X-RateLimit-Reset': String(Math.floor((Date.now() + 3_600_000) / 1000)), + }, + }); + }); + + afterEach(() => { + resetAuthMocks(); + }); + + it('sends OTP for a valid email', async () => { + const { POST } = await import('@/app/api/send-otp/route'); + const req = createMockRequest('POST', { + body: { email: 'dev@example.com' }, + url: 'http://localhost:3000/api/send-otp', + }); + + const result = await callHandler(POST, req); + const data = expectSuccess(result); + expect(data.message).toBe('OTP sent correctly'); + }); + + it('lowercases the email before sending', async () => { + const { sendOTP } = await import('@/server/services/login'); + const { POST } = await import('@/app/api/send-otp/route'); + const req = createMockRequest('POST', { + body: { email: 'DEV@Example.COM' }, + url: 'http://localhost:3000/api/send-otp', + }); + + await callHandler(POST, req); + expect(sendOTP).toHaveBeenCalledWith('dev@example.com'); + }); + + it('rejects missing email', async () => { + const { POST } = await import('@/app/api/send-otp/route'); + const req = createMockRequest('POST', { + body: {}, + url: 'http://localhost:3000/api/send-otp', + }); + + const result = await callHandler(POST, req); + expectError(result, 400, 'VALIDATION_ERROR'); + }); + + it('rejects invalid email format', async () => { + const { POST } = await import('@/app/api/send-otp/route'); + const req = createMockRequest('POST', { + body: { email: 'not-an-email' }, + url: 'http://localhost:3000/api/send-otp', + }); + + const result = await callHandler(POST, req); + expectError(result, 400, 'VALIDATION_ERROR'); + }); + + it('rejects invalid JSON body', async () => { + const { POST } = await import('@/app/api/send-otp/route'); + const req = new (await import('next/server')).NextRequest( + 'http://localhost:3000/api/send-otp', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: 'not-json{{{', + }, + ); + + const result = await callHandler(POST, req); + expectError(result, 400, 'VALIDATION_ERROR'); + }); + + it('enforces rate limiting (5 per hour per IP)', async () => { + const resetAt = new Date(Date.now() + 3_600_000); + mockCheckPrismaRateLimit.mockResolvedValue({ + allowed: false, + remaining: 0, + resetAt, + headers: { + 'X-RateLimit-Limit': '5', + 'X-RateLimit-Remaining': '0', + 'X-RateLimit-Reset': String(Math.floor(resetAt.getTime() / 1000)), + }, + }); + + const { POST } = await import('@/app/api/send-otp/route'); + const req = createMockRequest('POST', { + body: { email: 'dev@example.com' }, + url: 'http://localhost:3000/api/send-otp', + }); + + const result = await callHandler(POST, req); + expectError(result, 429, 'RATE_LIMITED'); + }); + + it('includes rate limit headers on success', async () => { + const { POST } = await import('@/app/api/send-otp/route'); + const req = createMockRequest('POST', { + body: { email: 'dev@example.com' }, + url: 'http://localhost:3000/api/send-otp', + }); + + const result = await callHandler(POST, req); + expect(result.status).toBe(200); + expect(result.headers.get('x-ratelimit-limit')).toBe('5'); + }); + + it('returns 500 envelope when sendOTP throws', async () => { + const { sendOTP } = await import('@/server/services/login'); + (sendOTP as ReturnType).mockRejectedValueOnce( + new Error('SMTP down'), + ); + + const { POST } = await import('@/app/api/send-otp/route'); + const req = createMockRequest('POST', { + body: { email: 'dev@example.com' }, + url: 'http://localhost:3000/api/send-otp', + }); + + const result = await callHandler(POST, req); + expectError(result, 500, 'INTERNAL_ERROR'); + // Must NOT leak stack trace + expect(result.body.error.message).not.toContain('SMTP'); + }); +}); diff --git a/tests/api/badges/badge.test.ts b/tests/api/badges/badge.test.ts new file mode 100644 index 00000000000..c653948291e --- /dev/null +++ b/tests/api/badges/badge.test.ts @@ -0,0 +1,584 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { + createMockRequest, + callHandler, + expectSuccess, + expectError, +} from '@/tests/api/helpers/api-test-utils'; +import { + createMockSession, + createAdminSession, + mockAuthSession, + resetAuthMocks, +} from '@/tests/api/helpers/mock-session'; +import { setupPrismaMock, resetPrismaMock } from '@/tests/api/helpers/mock-prisma'; +import { prisma } from '@/prisma/prisma'; + +// --------------------------------------------------------------------------- +// Module-level mocks (hoisted by vitest) +// --------------------------------------------------------------------------- + +vi.mock('@/lib/auth/authSession'); +vi.mock('@/prisma/prisma'); +vi.mock('@/server/services/badge'); +vi.mock('@/server/services/badgeAssignmentService'); +vi.mock('@/server/services/project-badge'); +vi.mock('@/server/services/rewardBoard'); +vi.mock('@/server/services/consoleBadge/consoleBadgeService'); + +// --------------------------------------------------------------------------- +// Shared fixtures +// --------------------------------------------------------------------------- + +const mockPrisma = setupPrismaMock(); + +const MOCK_BADGE = { + id: 'badge-1', + name: 'Test Badge', + description: 'A test badge', + image_path: '/images/test-badge.png', + category: 'academy', + requirements: [{ id: 'req-1', course_id: 'course-abc', type: 'academy' }], +}; + +const MOCK_BADGES = [MOCK_BADGE]; + +// --------------------------------------------------------------------------- +// Lifecycle +// --------------------------------------------------------------------------- + +beforeEach(() => { + vi.clearAllMocks(); +}); + +afterEach(() => { + resetAuthMocks(); + resetPrismaMock(mockPrisma); +}); + +// =========================================================================== +// GET /api/badge — badge by course_id +// =========================================================================== + +describe('GET /api/badge', () => { + it('returns badges for a valid course_id', async () => { + const session = createMockSession(); + mockAuthSession(session); + + const { getBadgeByCourseId } = await import('@/server/services/badge'); + vi.mocked(getBadgeByCourseId).mockResolvedValue(MOCK_BADGES as any); + + const { GET } = await import('@/app/api/badge/route'); + const req = createMockRequest('GET', { + url: 'http://localhost:3000/api/badge', + searchParams: { course_id: 'course-abc' }, + }); + + const result = await callHandler(GET, req); + const data = expectSuccess(result); + expect(data).toHaveLength(1); + expect(data[0].id).toBe('badge-1'); + }); + + it('returns 400 when course_id is missing', async () => { + const session = createMockSession(); + mockAuthSession(session); + + const { GET } = await import('@/app/api/badge/route'); + const req = createMockRequest('GET', { + url: 'http://localhost:3000/api/badge', + }); + + const result = await callHandler(GET, req); + expectError(result, 400, 'VALIDATION_ERROR'); + }); + + it('returns 401 when unauthenticated', async () => { + mockAuthSession(null); + + const { GET } = await import('@/app/api/badge/route'); + const req = createMockRequest('GET', { + url: 'http://localhost:3000/api/badge', + searchParams: { course_id: 'course-abc' }, + }); + + const result = await callHandler(GET, req); + expectError(result, 401, 'AUTH_REQUIRED'); + }); +}); + +// =========================================================================== +// GET /api/badge/validate — validate badge for user + course +// =========================================================================== + +describe('GET /api/badge/validate', () => { + it('returns badges when both course_id and user_id are provided', async () => { + const session = createMockSession(); + mockAuthSession(session); + + const { getBadgeByCourseId } = await import('@/server/services/badge'); + vi.mocked(getBadgeByCourseId).mockResolvedValue(MOCK_BADGES as any); + + const { GET } = await import('@/app/api/badge/validate/route'); + const req = createMockRequest('GET', { + url: 'http://localhost:3000/api/badge/validate', + searchParams: { course_id: 'course-abc', user_id: 'user-1' }, + }); + + const result = await callHandler(GET, req); + const data = expectSuccess(result); + expect(data).toHaveLength(1); + }); + + it('returns 400 when user_id is missing', async () => { + const session = createMockSession(); + mockAuthSession(session); + + const { GET } = await import('@/app/api/badge/validate/route'); + const req = createMockRequest('GET', { + url: 'http://localhost:3000/api/badge/validate', + searchParams: { course_id: 'course-abc' }, + }); + + const result = await callHandler(GET, req); + expectError(result, 400, 'VALIDATION_ERROR'); + }); + + it('returns 400 when course_id is missing', async () => { + const session = createMockSession(); + mockAuthSession(session); + + const { GET } = await import('@/app/api/badge/validate/route'); + const req = createMockRequest('GET', { + url: 'http://localhost:3000/api/badge/validate', + searchParams: { user_id: 'user-1' }, + }); + + const result = await callHandler(GET, req); + expectError(result, 400, 'VALIDATION_ERROR'); + }); +}); + +// =========================================================================== +// POST /api/badge/assign — badge assignment with role checks +// =========================================================================== + +describe('POST /api/badge/assign', () => { + it('allows user to self-assign an academy badge', async () => { + const session = createMockSession({ id: 'user-1' }); + mockAuthSession(session); + + const { badgeAssignmentService } = await import( + '@/server/services/badgeAssignmentService' + ); + vi.mocked(badgeAssignmentService.getRequiredRoleForAssignment).mockReturnValue(null); + vi.mocked(badgeAssignmentService.assignBadge).mockResolvedValue({ + success: true, + message: 'Badge assigned', + badge_id: 'badge-1', + user_id: 'user-1', + badges: [], + }); + + const { POST } = await import('@/app/api/badge/assign/route'); + const req = createMockRequest('POST', { + url: 'http://localhost:3000/api/badge/assign', + body: { userId: 'user-1', courseId: 'course-abc' }, + }); + + const result = await callHandler(POST, req); + const data = expectSuccess(result); + expect(data.success).toBe(true); + expect(data.badge_id).toBe('badge-1'); + }); + + it('rejects self-assignment to a different user (no admin)', async () => { + const session = createMockSession({ id: 'user-1' }); + mockAuthSession(session); + + const { badgeAssignmentService } = await import( + '@/server/services/badgeAssignmentService' + ); + vi.mocked(badgeAssignmentService.getRequiredRoleForAssignment).mockReturnValue(null); + + const { POST } = await import('@/app/api/badge/assign/route'); + const req = createMockRequest('POST', { + url: 'http://localhost:3000/api/badge/assign', + body: { userId: 'someone-else', courseId: 'course-abc' }, + }); + + const result = await callHandler(POST, req); + expectError(result, 403, 'FORBIDDEN'); + }); + + it('allows badge_admin to assign project badges to any user', async () => { + const session = createMockSession({ + id: 'admin-1', + custom_attributes: ['badge_admin'], + }); + mockAuthSession(session); + + const { badgeAssignmentService } = await import( + '@/server/services/badgeAssignmentService' + ); + vi.mocked(badgeAssignmentService.getRequiredRoleForAssignment).mockReturnValue( + 'badge_admin', + ); + vi.mocked(badgeAssignmentService.assignBadge).mockResolvedValue({ + success: true, + message: 'Badge assigned', + badge_id: 'badge-2', + user_id: 'user-other', + badges: [], + }); + + const { POST } = await import('@/app/api/badge/assign/route'); + const req = createMockRequest('POST', { + url: 'http://localhost:3000/api/badge/assign', + body: { userId: 'user-other', projectId: 'proj-1' }, + }); + + const result = await callHandler(POST, req); + const data = expectSuccess(result); + expect(data.success).toBe(true); + }); + + it('allows devrel (super-admin) to assign badge_admin badges', async () => { + const session = createAdminSession(); + mockAuthSession(session); + + const { badgeAssignmentService } = await import( + '@/server/services/badgeAssignmentService' + ); + vi.mocked(badgeAssignmentService.getRequiredRoleForAssignment).mockReturnValue( + 'badge_admin', + ); + vi.mocked(badgeAssignmentService.assignBadge).mockResolvedValue({ + success: true, + message: 'Badge assigned', + badge_id: 'badge-3', + user_id: 'user-other', + badges: [], + }); + + const { POST } = await import('@/app/api/badge/assign/route'); + const req = createMockRequest('POST', { + url: 'http://localhost:3000/api/badge/assign', + body: { userId: 'user-other', projectId: 'proj-1' }, + }); + + const result = await callHandler(POST, req); + const data = expectSuccess(result); + expect(data.success).toBe(true); + }); + + it('rejects non-admin user from assigning admin-only badge', async () => { + const session = createMockSession({ id: 'user-1', custom_attributes: [] }); + mockAuthSession(session); + + const { badgeAssignmentService } = await import( + '@/server/services/badgeAssignmentService' + ); + vi.mocked(badgeAssignmentService.getRequiredRoleForAssignment).mockReturnValue( + 'badge_admin', + ); + + const { POST } = await import('@/app/api/badge/assign/route'); + const req = createMockRequest('POST', { + url: 'http://localhost:3000/api/badge/assign', + body: { userId: 'user-other', projectId: 'proj-1' }, + }); + + const result = await callHandler(POST, req); + expectError(result, 403, 'FORBIDDEN'); + }); + + it('returns 400 for invalid body (missing userId)', async () => { + const session = createMockSession(); + mockAuthSession(session); + + const { POST } = await import('@/app/api/badge/assign/route'); + const req = createMockRequest('POST', { + url: 'http://localhost:3000/api/badge/assign', + body: { courseId: 'course-abc' }, + }); + + const result = await callHandler(POST, req); + expectError(result, 400, 'VALIDATION_ERROR'); + }); + + it('returns 401 when unauthenticated', async () => { + mockAuthSession(null); + + const { POST } = await import('@/app/api/badge/assign/route'); + const req = createMockRequest('POST', { + url: 'http://localhost:3000/api/badge/assign', + body: { userId: 'user-1', courseId: 'course-abc' }, + }); + + const result = await callHandler(POST, req); + expectError(result, 401, 'AUTH_REQUIRED'); + }); +}); + +// =========================================================================== +// GET /api/badge/get-all — list all badges +// =========================================================================== + +describe('GET /api/badge/get-all', () => { + it('returns all badges', async () => { + const session = createMockSession(); + mockAuthSession(session); + + const { getAllBadges } = await import('@/server/services/badge'); + vi.mocked(getAllBadges).mockResolvedValue(MOCK_BADGES as any); + + const { GET } = await import('@/app/api/badge/get-all/route'); + const req = createMockRequest('GET', { + url: 'http://localhost:3000/api/badge/get-all', + }); + + const result = await callHandler(GET, req); + const data = expectSuccess(result); + expect(data).toHaveLength(1); + }); + + it('returns 401 when unauthenticated', async () => { + mockAuthSession(null); + + const { GET } = await import('@/app/api/badge/get-all/route'); + const req = createMockRequest('GET', { + url: 'http://localhost:3000/api/badge/get-all', + }); + + const result = await callHandler(GET, req); + expectError(result, 401, 'AUTH_REQUIRED'); + }); +}); + +// =========================================================================== +// POST /api/badge/console-check — evaluate console badges +// =========================================================================== + +describe('POST /api/badge/console-check', () => { + it('evaluates console badges for the authenticated user', async () => { + const session = createMockSession({ id: 'user-1' }); + mockAuthSession(session); + + const { evaluateAllConsoleBadges } = await import( + '@/server/services/consoleBadge/consoleBadgeService' + ); + vi.mocked(evaluateAllConsoleBadges).mockResolvedValue([ + { name: 'First Blood', tier: 'bronze' }, + ] as any); + + const { POST } = await import('@/app/api/badge/console-check/route'); + const req = createMockRequest('POST', { + url: 'http://localhost:3000/api/badge/console-check', + body: { timezone: 'America/New_York' }, + }); + + const result = await callHandler(POST, req); + const data = expectSuccess(result); + expect(data.awardedBadges).toHaveLength(1); + expect(vi.mocked(evaluateAllConsoleBadges)).toHaveBeenCalledWith('user-1', { + timezone: 'America/New_York', + }); + }); + + it('works with no request body', async () => { + const session = createMockSession({ id: 'user-2' }); + mockAuthSession(session); + + const { evaluateAllConsoleBadges } = await import( + '@/server/services/consoleBadge/consoleBadgeService' + ); + vi.mocked(evaluateAllConsoleBadges).mockResolvedValue([]); + + const { POST } = await import('@/app/api/badge/console-check/route'); + const req = createMockRequest('POST', { + url: 'http://localhost:3000/api/badge/console-check', + }); + + const result = await callHandler(POST, req); + const data = expectSuccess(result); + expect(data.awardedBadges).toHaveLength(0); + }); + + it('returns 401 when unauthenticated', async () => { + mockAuthSession(null); + + const { POST } = await import('@/app/api/badge/console-check/route'); + const req = createMockRequest('POST', { + url: 'http://localhost:3000/api/badge/console-check', + }); + + const result = await callHandler(POST, req); + expectError(result, 401, 'AUTH_REQUIRED'); + }); +}); + +// =========================================================================== +// POST /api/badge/console-migrate — devrel-only migration +// =========================================================================== + +describe('POST /api/badge/console-migrate', () => { + it('returns 403 for non-devrel user', async () => { + const session = createMockSession({ custom_attributes: [] }); + mockAuthSession(session); + + const { POST } = await import('@/app/api/badge/console-migrate/route'); + const req = createMockRequest('POST', { + url: 'http://localhost:3000/api/badge/console-migrate', + }); + + const result = await callHandler(POST, req); + expectError(result, 403, 'FORBIDDEN'); + }); + + it('returns 401 when unauthenticated', async () => { + mockAuthSession(null); + + const { POST } = await import('@/app/api/badge/console-migrate/route'); + const req = createMockRequest('POST', { + url: 'http://localhost:3000/api/badge/console-migrate', + }); + + const result = await callHandler(POST, req); + expectError(result, 401, 'AUTH_REQUIRED'); + }); + + it('processes users and returns migration summary for devrel', async () => { + const session = createAdminSession(); + mockAuthSession(session); + + // The console-migrate route uses the prisma import directly, so mock + // the auto-mocked module export rather than the local proxy. + const mockedPrisma = vi.mocked(prisma) as any; + mockedPrisma.consoleLog = { findMany: vi.fn().mockResolvedValue([{ user_id: 'u1' }]) }; + mockedPrisma.faucetClaim = { findMany: vi.fn().mockResolvedValue([{ user_id: 'u2' }]) }; + mockedPrisma.nodeRegistration = { findMany: vi.fn().mockResolvedValue([]) }; + + const { evaluateAllConsoleBadges } = await import( + '@/server/services/consoleBadge/consoleBadgeService' + ); + vi.mocked(evaluateAllConsoleBadges) + .mockResolvedValueOnce([{ name: 'badge1' }] as any) + .mockResolvedValueOnce([]); + + const { POST } = await import('@/app/api/badge/console-migrate/route'); + const req = createMockRequest('POST', { + url: 'http://localhost:3000/api/badge/console-migrate', + }); + + const result = await callHandler(POST, req); + const data = expectSuccess(result); + expect(data.usersProcessed).toBe(2); + expect(data.totalBadgesAwarded).toBe(1); + expect(data.details).toHaveLength(1); + }); +}); + +// =========================================================================== +// GET /api/badge/project-badge — project badges +// =========================================================================== + +describe('GET /api/badge/project-badge', () => { + it('returns badges for a project', async () => { + const session = createMockSession(); + mockAuthSession(session); + + const { getProjectBadges } = await import('@/server/services/project-badge'); + vi.mocked(getProjectBadges).mockResolvedValue([ + { id: 'pb-1', project_id: 'proj-1', badge_id: 'badge-1' }, + ] as any); + + const { GET } = await import('@/app/api/badge/project-badge/route'); + const req = createMockRequest('GET', { + url: 'http://localhost:3000/api/badge/project-badge', + searchParams: { project_id: 'proj-1' }, + }); + + const result = await callHandler(GET, req); + const data = expectSuccess(result); + expect(data).toHaveLength(1); + }); + + it('returns 400 when project_id is missing', async () => { + const session = createMockSession(); + mockAuthSession(session); + + const { GET } = await import('@/app/api/badge/project-badge/route'); + const req = createMockRequest('GET', { + url: 'http://localhost:3000/api/badge/project-badge', + }); + + const result = await callHandler(GET, req); + expectError(result, 400, 'VALIDATION_ERROR'); + }); + + it('returns 401 when unauthenticated', async () => { + mockAuthSession(null); + + const { GET } = await import('@/app/api/badge/project-badge/route'); + const req = createMockRequest('GET', { + url: 'http://localhost:3000/api/badge/project-badge', + searchParams: { project_id: 'proj-1' }, + }); + + const result = await callHandler(GET, req); + expectError(result, 401, 'AUTH_REQUIRED'); + }); +}); + +// =========================================================================== +// GET /api/profile/reward-board — user reward board +// =========================================================================== + +describe('GET /api/profile/reward-board', () => { + it('returns reward board for a user', async () => { + const session = createMockSession(); + mockAuthSession(session); + + const { getRewardBoard } = await import('@/server/services/rewardBoard'); + vi.mocked(getRewardBoard).mockResolvedValue([ + { id: 'ub-1', badge_id: 'badge-1', status: 'approved' }, + ] as any); + + const { GET } = await import('@/app/api/profile/reward-board/route'); + const req = createMockRequest('GET', { + url: 'http://localhost:3000/api/profile/reward-board', + searchParams: { user_id: 'user-1' }, + }); + + const result = await callHandler(GET, req); + const data = expectSuccess(result); + expect(data).toHaveLength(1); + }); + + it('returns 400 when user_id is missing', async () => { + const session = createMockSession(); + mockAuthSession(session); + + const { GET } = await import('@/app/api/profile/reward-board/route'); + const req = createMockRequest('GET', { + url: 'http://localhost:3000/api/profile/reward-board', + }); + + const result = await callHandler(GET, req); + expectError(result, 400, 'VALIDATION_ERROR'); + }); + + it('returns 401 when unauthenticated', async () => { + mockAuthSession(null); + + const { GET } = await import('@/app/api/profile/reward-board/route'); + const req = createMockRequest('GET', { + url: 'http://localhost:3000/api/profile/reward-board', + searchParams: { user_id: 'user-1' }, + }); + + const result = await callHandler(GET, req); + expectError(result, 401, 'AUTH_REQUIRED'); + }); +}); diff --git a/tests/api/chat/chat-history.test.ts b/tests/api/chat/chat-history.test.ts new file mode 100644 index 00000000000..0b32e7ff43d --- /dev/null +++ b/tests/api/chat/chat-history.test.ts @@ -0,0 +1,676 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { + createMockRequest, + callHandler, + expectSuccess, + expectError, +} from '@/tests/api/helpers/api-test-utils'; +import { + createMockSession, + mockAuthSession, +} from '@/tests/api/helpers/mock-session'; +import { getAuthSession } from '@/lib/auth/authSession'; +import { + createMockPrisma, + type MockPrismaClient, +} from '@/tests/api/helpers/mock-prisma'; + +// --------------------------------------------------------------------------- +// Module-level mocks (hoisted by vitest) +// --------------------------------------------------------------------------- + +vi.mock('@/lib/auth/authSession'); + +// Create mock Prisma client and wire it into the module mock +const mockPrisma: MockPrismaClient = createMockPrisma(); + +vi.mock('@/prisma/prisma', () => ({ + prisma: new Proxy({} as any, { + get(_target, prop: string) { + return (mockPrisma as any)[prop]; + }, + }), +})); + +// Mock the Prisma-backed rate limiter to always allow requests in tests. +vi.mock('@/lib/api/rate-limit', () => ({ + checkPrismaRateLimit: vi.fn().mockResolvedValue({ + allowed: true, + remaining: 99, + resetAt: new Date(Date.now() + 60_000), + headers: { + 'X-RateLimit-Limit': '100', + 'X-RateLimit-Remaining': '99', + 'X-RateLimit-Reset': String(Math.floor(Date.now() / 1000) + 60), + }, + }), + getRateLimitIdentifier: vi.fn().mockReturnValue('test-user-id'), + getClientIP: vi.fn().mockReturnValue('127.0.0.1'), + cleanupRateLimitLogs: vi.fn().mockResolvedValue(0), +})); + +const MOCK_CONVERSATION = { + id: '00000000-0000-4000-8000-000000000001', + user_id: 'test-user-id', + title: 'Test Conversation', + is_shared: false, + share_token: null, + shared_at: null, + share_expires_at: null, + view_count: 0, + created_at: new Date('2025-01-01'), + updated_at: new Date('2025-01-01'), + messages: [ + { + id: 'msg-1', + conversation_id: '00000000-0000-4000-8000-000000000001', + role: 'user', + content: 'Hello', + created_at: new Date('2025-01-01'), + }, + { + id: 'msg-2', + conversation_id: '00000000-0000-4000-8000-000000000001', + role: 'assistant', + content: 'Hi there!', + created_at: new Date('2025-01-01'), + }, + ], +}; + +const OTHER_USER_CONVERSATION = { + ...MOCK_CONVERSATION, + id: '00000000-0000-4000-8000-000000000099', + user_id: 'other-user-id', +}; + +// --------------------------------------------------------------------------- +// Lifecycle +// --------------------------------------------------------------------------- + +afterEach(() => { + // Do NOT call resetAuthMocks() here -- it invokes vi.restoreAllMocks() which + // would wipe out the vi.mock factory for '@/lib/api/rate-limit'. + // Instead, manually reset call history for the auth mock. + vi.mocked(getAuthSession).mockReset(); + + // Reset Prisma model mocks (call history + return values) + for (const method of [ + 'findFirst', 'findMany', 'findUnique', 'create', 'createMany', + 'update', 'updateMany', 'delete', 'deleteMany', 'upsert', 'count', + ] as const) { + mockPrisma.chatConversation[method].mockReset(); + mockPrisma.chatMessage[method].mockReset(); + } +}); + +// =========================================================================== +// GET /api/chat-history +// =========================================================================== + +describe('GET /api/chat-history', () => { + it('returns conversations for the authenticated user', async () => { + const session = createMockSession(); + mockAuthSession(session); + + mockPrisma.chatConversation.findMany.mockResolvedValue([ + MOCK_CONVERSATION, + ]); + + const { GET } = await import('@/app/api/chat-history/route'); + const req = createMockRequest('GET', { + url: 'http://localhost:3000/api/chat-history', + }); + + const result = await callHandler(GET, req); + const data = expectSuccess(result); + + expect(data).toHaveLength(1); + expect(data[0].id).toBe('00000000-0000-4000-8000-000000000001'); + expect(data[0].messages).toHaveLength(2); + }); + + it('returns empty array when user has no conversations', async () => { + const session = createMockSession(); + mockAuthSession(session); + + mockPrisma.chatConversation.findMany.mockResolvedValue([]); + + const { GET } = await import('@/app/api/chat-history/route'); + const req = createMockRequest('GET', { + url: 'http://localhost:3000/api/chat-history', + }); + + const result = await callHandler(GET, req); + const data = expectSuccess(result); + expect(data).toHaveLength(0); + }); + + it('returns 401 when unauthenticated', async () => { + mockAuthSession(null); + + const { GET } = await import('@/app/api/chat-history/route'); + const req = createMockRequest('GET', { + url: 'http://localhost:3000/api/chat-history', + }); + + const result = await callHandler(GET, req); + expectError(result, 401, 'AUTH_REQUIRED'); + }); +}); + +// =========================================================================== +// POST /api/chat-history — create conversation +// =========================================================================== + +describe('POST /api/chat-history', () => { + it('creates a new conversation', async () => { + const session = createMockSession(); + mockAuthSession(session); + + mockPrisma.chatConversation.create.mockResolvedValue(MOCK_CONVERSATION); + + const { POST } = await import('@/app/api/chat-history/route'); + const req = createMockRequest('POST', { + url: 'http://localhost:3000/api/chat-history', + body: { + title: 'Test Conversation', + messages: [{ role: 'user', content: 'Hello' }], + }, + }); + + const result = await callHandler(POST, req); + const data = expectSuccess(result, 201); + + expect(data.id).toBe('00000000-0000-4000-8000-000000000001'); + expect(mockPrisma.chatConversation.create).toHaveBeenCalledOnce(); + }); + + it('updates an existing conversation when id is provided', async () => { + const session = createMockSession(); + mockAuthSession(session); + + mockPrisma.chatConversation.findFirst.mockResolvedValue( + MOCK_CONVERSATION + ); + mockPrisma.chatMessage.deleteMany.mockResolvedValue({ count: 2 }); + mockPrisma.chatConversation.update.mockResolvedValue(MOCK_CONVERSATION); + + const { POST } = await import('@/app/api/chat-history/route'); + const req = createMockRequest('POST', { + url: 'http://localhost:3000/api/chat-history', + body: { + id: '00000000-0000-4000-8000-000000000001', + title: 'Updated Title', + messages: [ + { role: 'user', content: 'Hello' }, + { role: 'assistant', content: 'Hi!' }, + ], + }, + }); + + const result = await callHandler(POST, req); + expectSuccess(result); + + expect(mockPrisma.chatMessage.deleteMany).toHaveBeenCalledWith({ + where: { conversation_id: '00000000-0000-4000-8000-000000000001' }, + }); + expect(mockPrisma.chatConversation.update).toHaveBeenCalledOnce(); + }); + + it('returns 404 when updating a conversation owned by another user', async () => { + const session = createMockSession(); + mockAuthSession(session); + + // findFirst returns null -- conversation not found for this user + mockPrisma.chatConversation.findFirst.mockResolvedValue(null); + + const { POST } = await import('@/app/api/chat-history/route'); + const req = createMockRequest('POST', { + url: 'http://localhost:3000/api/chat-history', + body: { + id: '00000000-0000-4000-8000-000000000099', + title: 'Hijack Attempt', + messages: [{ role: 'user', content: 'hacked' }], + }, + }); + + const result = await callHandler(POST, req); + expectError(result, 404, 'NOT_FOUND'); + }); + + it('returns 400 when title is missing', async () => { + const session = createMockSession(); + mockAuthSession(session); + + const { POST } = await import('@/app/api/chat-history/route'); + const req = createMockRequest('POST', { + url: 'http://localhost:3000/api/chat-history', + body: { + messages: [{ role: 'user', content: 'Hello' }], + }, + }); + + const result = await callHandler(POST, req); + expectError(result, 400, 'VALIDATION_ERROR'); + }); + + it('returns 400 when messages is empty', async () => { + const session = createMockSession(); + mockAuthSession(session); + + const { POST } = await import('@/app/api/chat-history/route'); + const req = createMockRequest('POST', { + url: 'http://localhost:3000/api/chat-history', + body: { + title: 'Test', + messages: [], + }, + }); + + const result = await callHandler(POST, req); + expectError(result, 400, 'VALIDATION_ERROR'); + }); + + it('returns 401 when unauthenticated', async () => { + mockAuthSession(null); + + const { POST } = await import('@/app/api/chat-history/route'); + const req = createMockRequest('POST', { + url: 'http://localhost:3000/api/chat-history', + body: { + title: 'Test', + messages: [{ role: 'user', content: 'Hello' }], + }, + }); + + const result = await callHandler(POST, req); + expectError(result, 401, 'AUTH_REQUIRED'); + }); +}); + +// =========================================================================== +// PATCH /api/chat-history/[id] — rename conversation +// =========================================================================== + +describe('PATCH /api/chat-history/[id]', () => { + it('renames a conversation', async () => { + const session = createMockSession(); + mockAuthSession(session); + + mockPrisma.chatConversation.findFirst.mockResolvedValue( + MOCK_CONVERSATION + ); + mockPrisma.chatConversation.update.mockResolvedValue({ + ...MOCK_CONVERSATION, + title: 'New Title', + }); + + const { PATCH } = await import('@/app/api/chat-history/[id]/route'); + const req = createMockRequest('PATCH', { + url: 'http://localhost:3000/api/chat-history/conv-1', + body: { title: 'New Title' }, + }); + + const result = await callHandler(PATCH, req, { id: '00000000-0000-4000-8000-000000000001' }); + const data = expectSuccess(result); + expect(data.title).toBe('New Title'); + }); + + it('trims whitespace from title', async () => { + const session = createMockSession(); + mockAuthSession(session); + + mockPrisma.chatConversation.findFirst.mockResolvedValue( + MOCK_CONVERSATION + ); + mockPrisma.chatConversation.update.mockResolvedValue({ + ...MOCK_CONVERSATION, + title: 'Trimmed', + }); + + const { PATCH } = await import('@/app/api/chat-history/[id]/route'); + const req = createMockRequest('PATCH', { + url: 'http://localhost:3000/api/chat-history/conv-1', + body: { title: ' Trimmed ' }, + }); + + const result = await callHandler(PATCH, req, { id: '00000000-0000-4000-8000-000000000001' }); + expectSuccess(result); + + expect(mockPrisma.chatConversation.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: { title: 'Trimmed' }, + }) + ); + }); + + it('returns 400 when title is empty', async () => { + const session = createMockSession(); + mockAuthSession(session); + + const { PATCH } = await import('@/app/api/chat-history/[id]/route'); + const req = createMockRequest('PATCH', { + url: 'http://localhost:3000/api/chat-history/conv-1', + body: { title: '' }, + }); + + const result = await callHandler(PATCH, req, { id: '00000000-0000-4000-8000-000000000001' }); + expectError(result, 400, 'VALIDATION_ERROR'); + }); + + it('returns 404 when conversation belongs to another user (ownership enforcement)', async () => { + const session = createMockSession(); + mockAuthSession(session); + + // assertOwnership uses findFirst -- returns null for wrong user + mockPrisma.chatConversation.findFirst.mockResolvedValue(null); + + const { PATCH } = await import('@/app/api/chat-history/[id]/route'); + const req = createMockRequest('PATCH', { + url: 'http://localhost:3000/api/chat-history/conv-other', + body: { title: 'Hijack' }, + }); + + const result = await callHandler(PATCH, req, { id: '00000000-0000-4000-8000-000000000099' }); + expectError(result, 404, 'NOT_FOUND'); + }); + + it('returns 401 when unauthenticated', async () => { + mockAuthSession(null); + + const { PATCH } = await import('@/app/api/chat-history/[id]/route'); + const req = createMockRequest('PATCH', { + url: 'http://localhost:3000/api/chat-history/conv-1', + body: { title: 'Test' }, + }); + + const result = await callHandler(PATCH, req, { id: '00000000-0000-4000-8000-000000000001' }); + expectError(result, 401, 'AUTH_REQUIRED'); + }); +}); + +// =========================================================================== +// DELETE /api/chat-history/[id] +// =========================================================================== + +describe('DELETE /api/chat-history/[id]', () => { + it('deletes a conversation', async () => { + const session = createMockSession(); + mockAuthSession(session); + + mockPrisma.chatConversation.findFirst.mockResolvedValue( + MOCK_CONVERSATION + ); + mockPrisma.chatConversation.delete.mockResolvedValue(MOCK_CONVERSATION); + + const { DELETE } = await import('@/app/api/chat-history/[id]/route'); + const req = createMockRequest('DELETE', { + url: 'http://localhost:3000/api/chat-history/conv-1', + }); + + const result = await callHandler(DELETE, req, { id: '00000000-0000-4000-8000-000000000001' }); + // Route returns 204 No Content (noContentResponse) + expect(result.status).toBe(204); + }); + + it('returns 404 when conversation belongs to another user (ownership enforcement)', async () => { + const session = createMockSession(); + mockAuthSession(session); + + mockPrisma.chatConversation.findFirst.mockResolvedValue(null); + + const { DELETE } = await import('@/app/api/chat-history/[id]/route'); + const req = createMockRequest('DELETE', { + url: 'http://localhost:3000/api/chat-history/conv-other', + }); + + const result = await callHandler(DELETE, req, { id: '00000000-0000-4000-8000-000000000099' }); + expectError(result, 404, 'NOT_FOUND'); + }); + + it('returns 401 when unauthenticated', async () => { + mockAuthSession(null); + + const { DELETE } = await import('@/app/api/chat-history/[id]/route'); + const req = createMockRequest('DELETE', { + url: 'http://localhost:3000/api/chat-history/conv-1', + }); + + const result = await callHandler(DELETE, req, { id: '00000000-0000-4000-8000-000000000001' }); + expectError(result, 401, 'AUTH_REQUIRED'); + }); +}); + +// =========================================================================== +// POST /api/chat-history/[id]/share — enable sharing +// =========================================================================== + +describe('POST /api/chat-history/[id]/share', () => { + it('creates a share token for a conversation', async () => { + const session = createMockSession(); + mockAuthSession(session); + + mockPrisma.chatConversation.findFirst.mockResolvedValue( + MOCK_CONVERSATION + ); + + const sharedConversation = { + ...MOCK_CONVERSATION, + is_shared: true, + share_token: 'abc123def456ghi789jk0l', + shared_at: new Date('2025-01-01'), + share_expires_at: new Date('2025-01-08'), + view_count: 0, + }; + mockPrisma.chatConversation.update.mockResolvedValue(sharedConversation); + + const { POST } = await import( + '@/app/api/chat-history/[id]/share/route' + ); + const req = createMockRequest('POST', { + url: 'http://localhost:3000/api/chat-history/conv-1/share', + body: { expiresInDays: 7 }, + }); + + const result = await callHandler(POST, req, { id: '00000000-0000-4000-8000-000000000001' }); + // Route returns 201 for newly created share tokens + const data = expectSuccess(result, 201); + + expect(data.shareToken).toBeTruthy(); + expect(data.shareUrl).toContain('/chat/share/'); + expect(data.expiresAt).toBeTruthy(); + }); + + it('returns existing share info when already shared', async () => { + const session = createMockSession(); + mockAuthSession(session); + + const alreadyShared = { + ...MOCK_CONVERSATION, + is_shared: true, + share_token: 'existing-token-abc123', + shared_at: new Date('2025-01-01'), + share_expires_at: new Date('2025-01-08'), + view_count: 42, + }; + mockPrisma.chatConversation.findFirst.mockResolvedValue(alreadyShared); + + const { POST } = await import( + '@/app/api/chat-history/[id]/share/route' + ); + const req = createMockRequest('POST', { + url: 'http://localhost:3000/api/chat-history/conv-1/share', + body: {}, + }); + + const result = await callHandler(POST, req, { id: '00000000-0000-4000-8000-000000000001' }); + const data = expectSuccess(result); + + expect(data.shareToken).toBe('existing-token-abc123'); + expect(data.viewCount).toBe(42); + // Should NOT have called update -- reused existing share + expect(mockPrisma.chatConversation.update).not.toHaveBeenCalled(); + }); + + it('returns 404 when conversation belongs to another user (ownership enforcement)', async () => { + const session = createMockSession(); + mockAuthSession(session); + + mockPrisma.chatConversation.findFirst.mockResolvedValue(null); + + const { POST } = await import( + '@/app/api/chat-history/[id]/share/route' + ); + const req = createMockRequest('POST', { + url: 'http://localhost:3000/api/chat-history/conv-other/share', + body: {}, + }); + + const result = await callHandler(POST, req, { id: '00000000-0000-4000-8000-000000000099' }); + expectError(result, 404, 'NOT_FOUND'); + }); + + it('returns 401 when unauthenticated', async () => { + mockAuthSession(null); + + const { POST } = await import( + '@/app/api/chat-history/[id]/share/route' + ); + const req = createMockRequest('POST', { + url: 'http://localhost:3000/api/chat-history/conv-1/share', + body: {}, + }); + + const result = await callHandler(POST, req, { id: '00000000-0000-4000-8000-000000000001' }); + expectError(result, 401, 'AUTH_REQUIRED'); + }); +}); + +// =========================================================================== +// DELETE /api/chat-history/[id]/share — revoke sharing +// =========================================================================== + +describe('DELETE /api/chat-history/[id]/share', () => { + it('revokes sharing for a conversation', async () => { + const session = createMockSession(); + mockAuthSession(session); + + const sharedConversation = { + ...MOCK_CONVERSATION, + is_shared: true, + share_token: 'token-to-revoke', + }; + mockPrisma.chatConversation.findFirst.mockResolvedValue( + sharedConversation + ); + mockPrisma.chatConversation.update.mockResolvedValue({ + ...MOCK_CONVERSATION, + is_shared: false, + share_token: null, + shared_at: null, + share_expires_at: null, + view_count: 0, + }); + + const { DELETE } = await import( + '@/app/api/chat-history/[id]/share/route' + ); + const req = createMockRequest('DELETE', { + url: 'http://localhost:3000/api/chat-history/conv-1/share', + }); + + const result = await callHandler(DELETE, req, { id: '00000000-0000-4000-8000-000000000001' }); + // Route returns 204 No Content (noContentResponse) + expect(result.status).toBe(204); + + expect(mockPrisma.chatConversation.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + is_shared: false, + share_token: null, + view_count: 0, + }), + }) + ); + }); + + it('returns 404 when conversation belongs to another user (ownership enforcement)', async () => { + const session = createMockSession(); + mockAuthSession(session); + + mockPrisma.chatConversation.findFirst.mockResolvedValue(null); + + const { DELETE } = await import( + '@/app/api/chat-history/[id]/share/route' + ); + const req = createMockRequest('DELETE', { + url: 'http://localhost:3000/api/chat-history/conv-other/share', + }); + + const result = await callHandler(DELETE, req, { id: '00000000-0000-4000-8000-000000000099' }); + expectError(result, 404, 'NOT_FOUND'); + }); + + it('returns 401 when unauthenticated', async () => { + mockAuthSession(null); + + const { DELETE } = await import( + '@/app/api/chat-history/[id]/share/route' + ); + const req = createMockRequest('DELETE', { + url: 'http://localhost:3000/api/chat-history/conv-1/share', + }); + + const result = await callHandler(DELETE, req, { id: '00000000-0000-4000-8000-000000000001' }); + expectError(result, 401, 'AUTH_REQUIRED'); + }); +}); + +// =========================================================================== +// Security: share token randomness +// =========================================================================== + +describe('Security: share token randomness', () => { + it('generates cryptographically random share tokens (no duplicates across calls)', async () => { + const session = createMockSession(); + mockAuthSession(session); + + const tokens: string[] = []; + + for (let i = 0; i < 5; i++) { + const conv = { + ...MOCK_CONVERSATION, + id: `00000000-0000-4000-8000-00000000000${i}`, + is_shared: false, + share_token: null, + }; + + mockPrisma.chatConversation.findFirst.mockResolvedValue(conv); + mockPrisma.chatConversation.update.mockImplementation(async ({ data }: any) => ({ + ...conv, + ...data, + view_count: 0, + })); + + const { POST } = await import('@/app/api/chat-history/[id]/share/route'); + const req = createMockRequest('POST', { + url: `http://localhost:3000/api/chat-history/conv-${i}/share`, + body: { expiresInDays: 7 }, + }); + + const result = await callHandler(POST, req, { id: conv.id }); + const data = expectSuccess(result, 201); + tokens.push(data.shareToken); + } + + // All tokens should be unique + const uniqueTokens = new Set(tokens); + expect(uniqueTokens.size).toBe(tokens.length); + + // Tokens should have sufficient entropy (base64url of 18 bytes = 24 chars) + for (const token of tokens) { + expect(token.length).toBeGreaterThanOrEqual(20); + } + }); +}); diff --git a/tests/api/evaluate/evaluate.test.ts b/tests/api/evaluate/evaluate.test.ts new file mode 100644 index 00000000000..e9edd7f5292 --- /dev/null +++ b/tests/api/evaluate/evaluate.test.ts @@ -0,0 +1,420 @@ +import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; + +// vi.hoisted runs in the hoisted context alongside vi.mock +const { mockPrisma } = vi.hoisted(() => { + const METHODS = [ + 'findFirst', 'findMany', 'findUnique', 'create', 'createMany', + 'update', 'updateMany', 'delete', 'deleteMany', 'upsert', 'count', + 'aggregate', 'groupBy', + ] as const; + + type MockModel = { [K in (typeof METHODS)[number]]: ReturnType }; + + function createModel(): MockModel { + const m = {} as MockModel; + for (const method of METHODS) m[method] = vi.fn(); + return m; + } + + const cache = new Map(); + const proxy = new Proxy({} as Record, { + get(_t, prop: string) { + if (prop === '$transaction') return vi.fn(async (arg: unknown) => typeof arg === 'function' ? arg(proxy) : Array.isArray(arg) ? Promise.all(arg) : arg); + if (prop === 'then' || prop === 'toJSON' || typeof prop === 'symbol') return undefined; + if (!cache.has(prop)) cache.set(prop, createModel()); + return cache.get(prop)!; + }, + }); + + return { mockPrisma: proxy }; +}); + +// Module-level mocks (hoisted by vitest) +vi.mock('@/prisma/prisma', () => ({ prisma: mockPrisma })); +vi.mock('@/lib/auth/authSession'); + +import { + createMockRequest, + callHandler, + expectSuccess, + expectError, +} from '@/tests/api/helpers/api-test-utils'; +import { + createMockSession, + createAdminSession, + mockAuthSession, + resetAuthMocks, +} from '@/tests/api/helpers/mock-session'; + +// Dynamic imports so mocks are in place +const evaluateModule = () => import('@/app/api/evaluate/route'); +const submissionsModule = () => import('@/app/api/evaluate/submissions/route'); +const advanceStageModule = () => import('@/app/api/evaluate/advance-stage/route'); +const finalVerdictModule = () => import('@/app/api/evaluate/final-verdict/route'); + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +const judgeSession = createMockSession({ + id: 'judge-1', + email: 'judge@example.com', + custom_attributes: ['judge'], +}); + +const devrelSession = createAdminSession(); + +const regularSession = createMockSession({ + email: 'regular@example.com', + custom_attributes: [], +}); + +// --------------------------------------------------------------------------- +// Lifecycle +// --------------------------------------------------------------------------- + +beforeEach(() => vi.clearAllMocks()); +afterEach(() => resetAuthMocks()); + +// --------------------------------------------------------------------------- +// POST /api/evaluate -- submit an evaluation +// --------------------------------------------------------------------------- + +describe('POST /api/evaluate', () => { + it('creates an evaluation as a judge', async () => { + mockAuthSession(judgeSession); + const { POST } = await evaluateModule(); + + mockPrisma.evaluation.upsert.mockResolvedValue({ + id: 'eval-1', + verdict: 'strong', + comment: 'Good project', + stage: 0, + }); + + const req = createMockRequest('POST', { + url: 'http://localhost:3000/api/evaluate', + body: { + formDataId: 'fd-1', + verdict: 'strong', + comment: 'Good project', + scoreOverall: 4, + stage: 0, + }, + }); + + const result = await callHandler(POST, req); + const data = expectSuccess(result); + expect(data.verdict).toBe('strong'); + expect(data.comment).toBe('Good project'); + }); + + it('creates an evaluation as devrel', async () => { + mockAuthSession(devrelSession); + const { POST } = await evaluateModule(); + + mockPrisma.evaluation.upsert.mockResolvedValue({ + id: 'eval-2', + verdict: 'top', + comment: null, + stage: 1, + }); + + const req = createMockRequest('POST', { + url: 'http://localhost:3000/api/evaluate', + body: { formDataId: 'fd-1', verdict: 'top', stage: 1 }, + }); + + const result = await callHandler(POST, req); + expectSuccess(result); + }); + + it('rejects non-judge/devrel users', async () => { + mockAuthSession(regularSession); + const { POST } = await evaluateModule(); + + const req = createMockRequest('POST', { + url: 'http://localhost:3000/api/evaluate', + body: { formDataId: 'fd-1', verdict: 'strong' }, + }); + + const result = await callHandler(POST, req); + expectError(result, 403, 'FORBIDDEN'); + }); + + it('rejects unauthenticated requests', async () => { + mockAuthSession(null); + const { POST } = await evaluateModule(); + + const req = createMockRequest('POST', { + url: 'http://localhost:3000/api/evaluate', + body: { formDataId: 'fd-1', verdict: 'strong' }, + }); + + const result = await callHandler(POST, req); + expectError(result, 401, 'AUTH_REQUIRED'); + }); + + it('rejects invalid verdict', async () => { + mockAuthSession(judgeSession); + const { POST } = await evaluateModule(); + + const req = createMockRequest('POST', { + url: 'http://localhost:3000/api/evaluate', + body: { formDataId: 'fd-1', verdict: 'invalid' }, + }); + + const result = await callHandler(POST, req); + expectError(result, 400, 'VALIDATION_ERROR'); + }); + + it('rejects invalid scoreOverall (not in 0.5 increments)', async () => { + mockAuthSession(judgeSession); + const { POST } = await evaluateModule(); + + const req = createMockRequest('POST', { + url: 'http://localhost:3000/api/evaluate', + body: { + formDataId: 'fd-1', + verdict: 'strong', + scoreOverall: 3.3, + }, + }); + + const result = await callHandler(POST, req); + expectError(result, 400, 'VALIDATION_ERROR'); + }); + + it('rejects stage out of range', async () => { + mockAuthSession(judgeSession); + const { POST } = await evaluateModule(); + + const req = createMockRequest('POST', { + url: 'http://localhost:3000/api/evaluate', + body: { formDataId: 'fd-1', verdict: 'strong', stage: 5 }, + }); + + const result = await callHandler(POST, req); + expectError(result, 400, 'VALIDATION_ERROR'); + }); +}); + +// --------------------------------------------------------------------------- +// GET /api/evaluate/submissions +// --------------------------------------------------------------------------- + +describe('GET /api/evaluate/submissions', () => { + it('returns submissions for a judge', async () => { + mockAuthSession(judgeSession); + const { GET } = await submissionsModule(); + + mockPrisma.formData.findMany.mockResolvedValue([]); + + const req = createMockRequest('GET', { + url: 'http://localhost:3000/api/evaluate/submissions', + }); + + const result = await callHandler(GET, req); + const data = expectSuccess(result); + // Route returns the submissions array directly as data (not wrapped in {submissions: []}) + expect(data).toEqual([]); + }); + + it('rejects non-judge users', async () => { + mockAuthSession(regularSession); + const { GET } = await submissionsModule(); + + const req = createMockRequest('GET', { + url: 'http://localhost:3000/api/evaluate/submissions', + }); + + const result = await callHandler(GET, req); + expectError(result, 403, 'FORBIDDEN'); + }); +}); + +// --------------------------------------------------------------------------- +// POST /api/evaluate/advance-stage -- devrel only +// --------------------------------------------------------------------------- + +describe('POST /api/evaluate/advance-stage', () => { + it('advances stage for devrel user', async () => { + mockAuthSession(devrelSession); + const { POST } = await advanceStageModule(); + + mockPrisma.formData.updateMany.mockResolvedValue({ count: 2 }); + + const req = createMockRequest('POST', { + url: 'http://localhost:3000/api/evaluate/advance-stage', + body: { formDataIds: ['fd-1', 'fd-2'], stage: 2 }, + }); + + const result = await callHandler(POST, req); + const data = expectSuccess(result); + expect(data.updated).toBe(2); + expect(data.stage).toBe(2); + }); + + it('rejects judge users (devrel only)', async () => { + mockAuthSession(judgeSession); + const { POST } = await advanceStageModule(); + + const req = createMockRequest('POST', { + url: 'http://localhost:3000/api/evaluate/advance-stage', + body: { formDataId: 'fd-1', stage: 1 }, + }); + + const result = await callHandler(POST, req); + expectError(result, 403, 'FORBIDDEN'); + }); + + it('rejects missing form data ids', async () => { + mockAuthSession(devrelSession); + const { POST } = await advanceStageModule(); + + const req = createMockRequest('POST', { + url: 'http://localhost:3000/api/evaluate/advance-stage', + body: { stage: 1 }, + }); + + const result = await callHandler(POST, req); + expectError(result, 400, 'VALIDATION_ERROR'); + }); +}); + +// --------------------------------------------------------------------------- +// POST /api/evaluate/final-verdict -- devrel only +// --------------------------------------------------------------------------- + +describe('POST /api/evaluate/final-verdict', () => { + it('sets final verdict for devrel user', async () => { + mockAuthSession(devrelSession); + const { POST } = await finalVerdictModule(); + + mockPrisma.formData.update.mockResolvedValue({ + id: 'fd-1', + final_verdict: 'top', + }); + + const req = createMockRequest('POST', { + url: 'http://localhost:3000/api/evaluate/final-verdict', + body: { formDataId: 'fd-1', verdict: 'top' }, + }); + + const result = await callHandler(POST, req); + const data = expectSuccess(result); + expect(data.finalVerdict).toBe('top'); + }); + + it('allows null verdict to clear final verdict', async () => { + mockAuthSession(devrelSession); + const { POST } = await finalVerdictModule(); + + mockPrisma.formData.update.mockResolvedValue({ + id: 'fd-1', + final_verdict: null, + }); + + const req = createMockRequest('POST', { + url: 'http://localhost:3000/api/evaluate/final-verdict', + body: { formDataId: 'fd-1', verdict: null }, + }); + + const result = await callHandler(POST, req); + const data = expectSuccess(result); + expect(data.finalVerdict).toBeNull(); + }); + + it('rejects non-devrel users', async () => { + mockAuthSession(judgeSession); + const { POST } = await finalVerdictModule(); + + const req = createMockRequest('POST', { + url: 'http://localhost:3000/api/evaluate/final-verdict', + body: { formDataId: 'fd-1', verdict: 'top' }, + }); + + const result = await callHandler(POST, req); + expectError(result, 403, 'FORBIDDEN'); + }); + + it('rejects invalid verdict string', async () => { + mockAuthSession(devrelSession); + const { POST } = await finalVerdictModule(); + + const req = createMockRequest('POST', { + url: 'http://localhost:3000/api/evaluate/final-verdict', + body: { formDataId: 'fd-1', verdict: 'invalid' }, + }); + + const result = await callHandler(POST, req); + expectError(result, 400, 'VALIDATION_ERROR'); + }); +}); + +// --------------------------------------------------------------------------- +// Security: evaluate role isolation +// --------------------------------------------------------------------------- + +describe('Security: evaluate role isolation', () => { + it('regular user cannot submit evaluations even with valid payload', async () => { + mockAuthSession(regularSession); + const { POST } = await evaluateModule(); + + const req = createMockRequest('POST', { + url: 'http://localhost:3000/api/evaluate', + body: { + formDataId: 'fd-1', + verdict: 'strong', + comment: 'Trying to sneak in', + stage: 0, + }, + }); + + const result = await callHandler(POST, req); + expectError(result, 403, 'FORBIDDEN'); + // Prisma should never be called + expect(mockPrisma.evaluation.upsert).not.toHaveBeenCalled(); + }); + + it('regular user cannot list submissions', async () => { + mockAuthSession(regularSession); + const { GET } = await submissionsModule(); + + const req = createMockRequest('GET', { + url: 'http://localhost:3000/api/evaluate/submissions', + }); + + const result = await callHandler(GET, req); + expectError(result, 403, 'FORBIDDEN'); + expect(mockPrisma.formData.findMany).not.toHaveBeenCalled(); + }); + + it('regular user cannot advance stage', async () => { + mockAuthSession(regularSession); + const { POST } = await advanceStageModule(); + + const req = createMockRequest('POST', { + url: 'http://localhost:3000/api/evaluate/advance-stage', + body: { formDataIds: ['fd-1'], stage: 1 }, + }); + + const result = await callHandler(POST, req); + expectError(result, 403, 'FORBIDDEN'); + expect(mockPrisma.formData.updateMany).not.toHaveBeenCalled(); + }); + + it('judge cannot set final verdict (devrel-only)', async () => { + mockAuthSession(judgeSession); + const { POST } = await finalVerdictModule(); + + const req = createMockRequest('POST', { + url: 'http://localhost:3000/api/evaluate/final-verdict', + body: { formDataId: 'fd-1', verdict: 'top' }, + }); + + const result = await callHandler(POST, req); + expectError(result, 403, 'FORBIDDEN'); + expect(mockPrisma.formData.update).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/api/explorer/explorer.test.ts b/tests/api/explorer/explorer.test.ts new file mode 100644 index 00000000000..0f35ba926c4 --- /dev/null +++ b/tests/api/explorer/explorer.test.ts @@ -0,0 +1,673 @@ +/** + * Explorer API route tests. + * + * Validates Zod param schemas, pagination caps, rate-limit config, and + * successful response envelopes for the 7 explorer endpoints. + * + * External dependencies (Avalanche SDK, RPC, Prisma) are mocked so tests + * run fast and deterministically. + */ + +import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { + createMockRequest, + callHandler, + expectSuccess, + expectError, +} from '@/tests/api/helpers/api-test-utils'; +import { mockAuthSession } from '@/tests/api/helpers/mock-session'; +import { getAuthSession } from '@/lib/auth/authSession'; + +// --------------------------------------------------------------------------- +// Module-level mocks (hoisted by vitest) +// --------------------------------------------------------------------------- + +vi.mock('@/lib/auth/authSession'); + +// Use vi.hoisted so the mock object is available during vi.mock factory hoisting +const { mockPrismaClient } = vi.hoisted(() => { + const apiRateLimitLog = { + count: vi.fn().mockResolvedValue(0), + create: vi.fn().mockResolvedValue({}), + findFirst: vi.fn().mockResolvedValue(null), + deleteMany: vi.fn().mockResolvedValue({ count: 0 }), + }; + + const client: any = { + apiRateLimitLog, + // $transaction must pass the callback a tx proxy that exposes the same models + $transaction: vi.fn().mockImplementation(async (fn: any) => { + if (typeof fn === 'function') { + // The tx object inside the transaction callback needs the same model accessors + return fn(client); + } + if (Array.isArray(fn)) { + return Promise.all(fn); + } + return fn; + }), + }; + + return { mockPrismaClient: client }; +}); + +vi.mock('@/prisma/prisma', () => ({ + prisma: mockPrismaClient, +})); + +// Mock the Avalanche SDK globally -- all explorer routes import it +vi.mock('@avalanche-sdk/chainkit', () => ({ + Avalanche: vi.fn().mockImplementation(() => ({ + data: { + evm: { + chains: { get: vi.fn().mockResolvedValue({ chainId: '43114' }) }, + contracts: { + getMetadata: vi.fn().mockResolvedValue({ + name: 'TestToken', + symbol: 'TT', + ercType: 'ERC-20', + logoAsset: { imageUri: 'https://example.com/logo.png' }, + }), + }, + address: { + transactions: { + list: vi.fn().mockResolvedValue({ + [Symbol.asyncIterator]: async function* () { + yield { + result: { + transactions: [], + nextPageToken: undefined, + }, + }; + }, + }), + }, + balances: { + listErc20: vi.fn().mockResolvedValue({ + [Symbol.asyncIterator]: () => ({ + next: vi.fn().mockResolvedValue({ + value: { + result: { + erc20TokenBalances: [], + nextPageToken: undefined, + }, + }, + done: false, + }), + }), + }), + }, + chains: { + list: vi.fn().mockResolvedValue({ indexedChains: [] }), + }, + }, + }, + }, + })), +})); + +// Mock l1-chains.json -- provide a known chain entry +vi.mock('@/constants/l1-chains.json', () => ({ + default: [ + { + chainId: '43114', + chainName: 'Avalanche C-Chain', + rpcUrl: 'https://api.avax.network/ext/bc/C/rpc', + coingeckoId: 'avalanche-2', + networkToken: { symbol: 'AVAX', name: 'Avalanche', decimals: 18 }, + blockchainId: '0x-test', + }, + ], +})); + +// Mock global fetch for RPC calls +const mockFetch = vi.fn(); +vi.stubGlobal('fetch', mockFetch); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function rpcResponse(result: unknown) { + return { + ok: true, + json: () => Promise.resolve({ jsonrpc: '2.0', id: 1, result }), + }; +} + +beforeEach(() => { + mockAuthSession(null); // public endpoints, no auth + // Reset rate limit mocks to allow requests through + mockPrismaClient.apiRateLimitLog.count.mockResolvedValue(0); + mockPrismaClient.apiRateLimitLog.create.mockResolvedValue({}); +}); + +afterEach(() => { + // Reset only specific mocks to avoid clearing the Avalanche SDK constructor. + // Do NOT call vi.restoreAllMocks() -- it would undo the Avalanche SDK mock. + vi.mocked(getAuthSession).mockReset(); + mockFetch.mockReset(); + mockPrismaClient.apiRateLimitLog.count.mockReset(); + mockPrismaClient.apiRateLimitLog.create.mockReset(); +}); + +// ========================================================================= +// 1. Chain overview: GET /api/explorer/[chainId] +// ========================================================================= + +describe('GET /api/explorer/[chainId]', () => { + it('rejects non-numeric chainId', async () => { + const { GET } = await import( + '@/app/api/explorer/[chainId]/route' + ); + const req = createMockRequest('GET', { + url: 'http://localhost:3000/api/explorer/abc', + }); + const result = await callHandler(GET, req, { chainId: 'abc' }); + expectError(result, 400, 'VALIDATION_ERROR'); + }); + + it('rejects chainId with special characters', async () => { + const { GET } = await import( + '@/app/api/explorer/[chainId]/route' + ); + const req = createMockRequest('GET', { + url: 'http://localhost:3000/api/explorer/43114;DROP', + }); + const result = await callHandler(GET, req, { chainId: '43114;DROP' }); + expectError(result, 400, 'VALIDATION_ERROR'); + }); + + it('returns 404 for unknown chain without custom rpcUrl', async () => { + const { GET } = await import( + '@/app/api/explorer/[chainId]/route' + ); + const req = createMockRequest('GET', { + url: 'http://localhost:3000/api/explorer/99999', + }); + const result = await callHandler(GET, req, { chainId: '99999' }); + expectError(result, 404, 'NOT_FOUND'); + }); + + it('returns success envelope for priceOnly mode', async () => { + // Mock CoinGecko fetch for price + mockFetch.mockResolvedValue( + rpcResponse(undefined), + ); + // Override fetch for CoinGecko + mockFetch.mockImplementation((url: string) => { + if (typeof url === 'string' && url.includes('coingecko')) { + return Promise.resolve({ + ok: true, + json: () => + Promise.resolve({ + market_data: { + current_price: { usd: 25 }, + price_change_percentage_24h: 1.5, + market_cap: { usd: 1e10 }, + total_volume: { usd: 5e8 }, + total_supply: 720e6, + }, + symbol: 'avax', + }), + }); + } + // RPC call -- not needed for priceOnly + return Promise.resolve(rpcResponse(null)); + }); + + const { GET } = await import( + '@/app/api/explorer/[chainId]/route' + ); + const req = createMockRequest('GET', { + url: 'http://localhost:3000/api/explorer/43114?priceOnly=true', + }); + const result = await callHandler(GET, req, { chainId: '43114' }); + const data = expectSuccess(result); + expect(data).toHaveProperty('glacierSupported'); + }); +}); + +// ========================================================================= +// 2. Address detail: GET /api/explorer/[chainId]/address/[address] +// ========================================================================= + +describe('GET /api/explorer/[chainId]/address/[address]', () => { + it('rejects malformed address', async () => { + const { GET } = await import( + '@/app/api/explorer/[chainId]/address/[address]/route' + ); + const req = createMockRequest('GET', { + url: 'http://localhost:3000/api/explorer/43114/address/not-an-address', + }); + const result = await callHandler(GET, req, { + chainId: '43114', + address: 'not-an-address', + }); + expectError(result, 400, 'VALIDATION_ERROR'); + }); + + it('rejects address missing 0x prefix', async () => { + const { GET } = await import( + '@/app/api/explorer/[chainId]/address/[address]/route' + ); + const req = createMockRequest('GET', { + url: 'http://localhost:3000/api/explorer/43114/address/1234567890abcdef1234567890abcdef12345678', + }); + const result = await callHandler(GET, req, { + chainId: '43114', + address: '1234567890abcdef1234567890abcdef12345678', + }); + expectError(result, 400, 'VALIDATION_ERROR'); + }); + + it('rejects non-numeric chainId in address route', async () => { + const { GET } = await import( + '@/app/api/explorer/[chainId]/address/[address]/route' + ); + const req = createMockRequest('GET', { + url: 'http://localhost:3000/api/explorer/abc/address/0x1234567890abcdef1234567890abcdef12345678', + }); + const result = await callHandler(GET, req, { + chainId: 'abc', + address: '0x1234567890abcdef1234567890abcdef12345678', + }); + expectError(result, 400, 'VALIDATION_ERROR'); + }); + + it('returns success for valid params', async () => { + // Mock RPC responses for address route + mockFetch.mockImplementation((url: string | URL | Request) => { + const urlStr = typeof url === 'string' ? url : url instanceof URL ? url.toString() : url.url; + if (urlStr.includes('api.avax.network')) { + return Promise.resolve({ + ok: true, + json: () => + Promise.resolve({ + jsonrpc: '2.0', + id: 1, + result: '0x0', // eth_getCode or eth_getBalance + }), + }); + } + return Promise.resolve(rpcResponse('0x0')); + }); + + const { GET } = await import( + '@/app/api/explorer/[chainId]/address/[address]/route' + ); + const req = createMockRequest('GET', { + url: 'http://localhost:3000/api/explorer/43114/address/0x1234567890abcdef1234567890abcdef12345678', + }); + const result = await callHandler(GET, req, { + chainId: '43114', + address: '0x1234567890abcDEF1234567890abcdef12345678', + }); + const data = expectSuccess(result); + expect(data).toHaveProperty('address'); + expect(data).toHaveProperty('isContract'); + expect(data).toHaveProperty('nativeBalance'); + }); +}); + +// ========================================================================= +// 3. ERC20 balances: GET /api/explorer/[chainId]/address/[address]/erc20-balances +// ========================================================================= + +describe('GET /api/explorer/[chainId]/address/[address]/erc20-balances', () => { + it('rejects invalid address format', async () => { + const { GET } = await import( + '@/app/api/explorer/[chainId]/address/[address]/erc20-balances/route' + ); + const req = createMockRequest('GET', { + url: 'http://localhost:3000/api/explorer/43114/address/0xINVALID/erc20-balances', + }); + const result = await callHandler(GET, req, { + chainId: '43114', + address: '0xINVALID', + }); + expectError(result, 400, 'VALIDATION_ERROR'); + }); + + it('returns empty balances for valid params', async () => { + const { GET } = await import( + '@/app/api/explorer/[chainId]/address/[address]/erc20-balances/route' + ); + const req = createMockRequest('GET', { + url: 'http://localhost:3000/api/explorer/43114/address/0x1234567890abcdef1234567890abcdef12345678/erc20-balances', + }); + const result = await callHandler(GET, req, { + chainId: '43114', + address: '0x1234567890abcdef1234567890abcdef12345678', + }); + const data = expectSuccess(result); + expect(data).toHaveProperty('balances'); + expect(Array.isArray(data.balances)).toBe(true); + expect(data).toHaveProperty('pageValueUsd'); + }); +}); + +// ========================================================================= +// 4. Block detail: GET /api/explorer/[chainId]/block/[blockNumber] +// ========================================================================= + +describe('GET /api/explorer/[chainId]/block/[blockNumber]', () => { + it('rejects non-numeric blockNumber', async () => { + const { GET } = await import( + '@/app/api/explorer/[chainId]/block/[blockNumber]/route' + ); + const req = createMockRequest('GET', { + url: 'http://localhost:3000/api/explorer/43114/block/latest', + }); + const result = await callHandler(GET, req, { + chainId: '43114', + blockNumber: 'latest', + }); + expectError(result, 400, 'VALIDATION_ERROR'); + }); + + it('rejects hex blockNumber', async () => { + const { GET } = await import( + '@/app/api/explorer/[chainId]/block/[blockNumber]/route' + ); + const req = createMockRequest('GET', { + url: 'http://localhost:3000/api/explorer/43114/block/0xff', + }); + const result = await callHandler(GET, req, { + chainId: '43114', + blockNumber: '0xff', + }); + expectError(result, 400, 'VALIDATION_ERROR'); + }); + + it('returns 404 when block does not exist', async () => { + mockFetch.mockResolvedValue(rpcResponse(null)); + + const { GET } = await import( + '@/app/api/explorer/[chainId]/block/[blockNumber]/route' + ); + const req = createMockRequest('GET', { + url: 'http://localhost:3000/api/explorer/43114/block/999999999', + }); + const result = await callHandler(GET, req, { + chainId: '43114', + blockNumber: '999999999', + }); + expectError(result, 404, 'NOT_FOUND'); + }); + + it('returns success for valid block', async () => { + mockFetch.mockResolvedValue( + rpcResponse({ + number: '0x1', + hash: '0xabc', + parentHash: '0x000', + timestamp: '0x60000000', + miner: '0x0000000000000000000000000000000000000000', + transactions: [], + gasUsed: '0x0', + gasLimit: '0x1000000', + baseFeePerGas: '0x5d21dba00', + }), + ); + + const { GET } = await import( + '@/app/api/explorer/[chainId]/block/[blockNumber]/route' + ); + const req = createMockRequest('GET', { + url: 'http://localhost:3000/api/explorer/43114/block/1', + }); + const result = await callHandler(GET, req, { + chainId: '43114', + blockNumber: '1', + }); + const data = expectSuccess(result); + expect(data).toHaveProperty('number'); + expect(data).toHaveProperty('hash'); + expect(data).toHaveProperty('gasUsed'); + }); +}); + +// ========================================================================= +// 5. Block transactions: GET /api/explorer/[chainId]/block/[blockNumber]/transactions +// ========================================================================= + +describe('GET /api/explorer/[chainId]/block/[blockNumber]/transactions', () => { + it('rejects non-numeric blockNumber', async () => { + const { GET } = await import( + '@/app/api/explorer/[chainId]/block/[blockNumber]/transactions/route' + ); + const req = createMockRequest('GET', { + url: 'http://localhost:3000/api/explorer/43114/block/abc/transactions', + }); + const result = await callHandler(GET, req, { + chainId: '43114', + blockNumber: 'abc', + }); + expectError(result, 400, 'VALIDATION_ERROR'); + }); + + it('returns transactions for valid block', async () => { + mockFetch.mockResolvedValue( + rpcResponse({ + number: '0x1', + transactions: [ + { + hash: '0xaaa', + from: '0x0000000000000000000000000000000000000001', + to: '0x0000000000000000000000000000000000000002', + value: '0x0', + gasPrice: '0x5d21dba00', + gas: '0x5208', + nonce: '0x0', + blockNumber: '0x1', + transactionIndex: '0x0', + input: '0x', + }, + ], + }), + ); + + const { GET } = await import( + '@/app/api/explorer/[chainId]/block/[blockNumber]/transactions/route' + ); + const req = createMockRequest('GET', { + url: 'http://localhost:3000/api/explorer/43114/block/1/transactions', + }); + const result = await callHandler(GET, req, { + chainId: '43114', + blockNumber: '1', + }); + const data = expectSuccess(result); + expect(data).toHaveProperty('transactions'); + expect(Array.isArray(data.transactions)).toBe(true); + expect(data.transactions).toHaveLength(1); + expect(data.transactions[0]).toHaveProperty('hash', '0xaaa'); + }); +}); + +// ========================================================================= +// 6. Transaction detail: GET /api/explorer/[chainId]/tx/[txHash] +// ========================================================================= + +describe('GET /api/explorer/[chainId]/tx/[txHash]', () => { + const validTxHash = '0x' + 'a'.repeat(64); + + it('rejects malformed txHash', async () => { + const { GET } = await import( + '@/app/api/explorer/[chainId]/tx/[txHash]/route' + ); + const req = createMockRequest('GET', { + url: `http://localhost:3000/api/explorer/43114/tx/not-a-hash`, + }); + const result = await callHandler(GET, req, { + chainId: '43114', + txHash: 'not-a-hash', + }); + expectError(result, 400, 'VALIDATION_ERROR'); + }); + + it('rejects txHash with wrong length', async () => { + const { GET } = await import( + '@/app/api/explorer/[chainId]/tx/[txHash]/route' + ); + const shortHash = '0x' + 'a'.repeat(32); + const req = createMockRequest('GET', { + url: `http://localhost:3000/api/explorer/43114/tx/${shortHash}`, + }); + const result = await callHandler(GET, req, { + chainId: '43114', + txHash: shortHash, + }); + expectError(result, 400, 'VALIDATION_ERROR'); + }); + + it('returns 404 when transaction not found', async () => { + mockFetch.mockResolvedValue(rpcResponse(null)); + + const { GET } = await import( + '@/app/api/explorer/[chainId]/tx/[txHash]/route' + ); + const req = createMockRequest('GET', { + url: `http://localhost:3000/api/explorer/43114/tx/${validTxHash}`, + }); + const result = await callHandler(GET, req, { + chainId: '43114', + txHash: validTxHash, + }); + expectError(result, 404, 'NOT_FOUND'); + }); + + it('returns success for valid transaction', async () => { + // Mock both receipt and tx fetch plus block and blockNumber calls + let callCount = 0; + mockFetch.mockImplementation(() => { + callCount++; + if (callCount <= 2) { + // First two calls: receipt and tx (parallel via Promise.allSettled) + return Promise.resolve( + rpcResponse({ + transactionHash: validTxHash, + gasUsed: '0x5208', + effectiveGasPrice: '0x5d21dba00', + status: '0x1', + blockNumber: '0x1', + blockHash: '0xbbb', + from: '0x0000000000000000000000000000000000000001', + to: '0x0000000000000000000000000000000000000002', + transactionIndex: '0x0', + logs: [], + cumulativeGasUsed: '0x5208', + logsBloom: '0x0', + // tx fields + hash: validTxHash, + value: '0x0', + gas: '0x5208', + gasPrice: '0x5d21dba00', + nonce: '0x0', + input: '0x', + }), + ); + } + if (callCount === 3) { + // Block fetch for timestamp + return Promise.resolve( + rpcResponse({ + timestamp: '0x60000000', + number: '0x1', + }), + ); + } + // eth_blockNumber for confirmations + return Promise.resolve(rpcResponse('0x10')); + }); + + const { GET } = await import( + '@/app/api/explorer/[chainId]/tx/[txHash]/route' + ); + const req = createMockRequest('GET', { + url: `http://localhost:3000/api/explorer/43114/tx/${validTxHash}`, + }); + const result = await callHandler(GET, req, { + chainId: '43114', + txHash: validTxHash, + }); + const data = expectSuccess(result); + expect(data).toHaveProperty('hash'); + expect(data).toHaveProperty('status'); + expect(data).toHaveProperty('gasUsed'); + }); +}); + +// ========================================================================= +// 7. Token metadata: GET /api/explorer/[chainId]/token/[tokenAddress]/metadata +// ========================================================================= + +describe('GET /api/explorer/[chainId]/token/[tokenAddress]/metadata', () => { + it('rejects invalid token address', async () => { + const { GET } = await import( + '@/app/api/explorer/[chainId]/token/[tokenAddress]/metadata/route' + ); + const req = createMockRequest('GET', { + url: 'http://localhost:3000/api/explorer/43114/token/0xBAD/metadata', + }); + const result = await callHandler(GET, req, { + chainId: '43114', + tokenAddress: '0xBAD', + }); + expectError(result, 400, 'VALIDATION_ERROR'); + }); + + it('rejects non-numeric chainId', async () => { + const { GET } = await import( + '@/app/api/explorer/[chainId]/token/[tokenAddress]/metadata/route' + ); + const validAddr = '0x' + 'a'.repeat(40); + const req = createMockRequest('GET', { + url: `http://localhost:3000/api/explorer/abc/token/${validAddr}/metadata`, + }); + const result = await callHandler(GET, req, { + chainId: 'abc', + tokenAddress: validAddr, + }); + expectError(result, 400, 'VALIDATION_ERROR'); + }); + + it('returns metadata for valid token', async () => { + const { GET } = await import( + '@/app/api/explorer/[chainId]/token/[tokenAddress]/metadata/route' + ); + const validAddr = '0x' + 'a'.repeat(40); + const req = createMockRequest('GET', { + url: `http://localhost:3000/api/explorer/43114/token/${validAddr}/metadata`, + }); + const result = await callHandler(GET, req, { + chainId: '43114', + tokenAddress: validAddr, + }); + const data = expectSuccess(result); + expect(data).toHaveProperty('name', 'TestToken'); + expect(data).toHaveProperty('symbol', 'TT'); + expect(data).toHaveProperty('ercType', 'ERC-20'); + }); +}); + +// ========================================================================= +// Cross-cutting: response envelope shape +// ========================================================================= + +describe('Response envelope', () => { + it('all error responses include success:false and error.code', async () => { + const { GET } = await import( + '@/app/api/explorer/[chainId]/route' + ); + const req = createMockRequest('GET', { + url: 'http://localhost:3000/api/explorer/DROP_TABLE', + }); + const result = await callHandler(GET, req, { chainId: 'DROP_TABLE' }); + expect(result.body.success).toBe(false); + expect(result.body.error).toBeDefined(); + expect(typeof result.body.error.code).toBe('string'); + expect(typeof result.body.error.message).toBe('string'); + }); +}); diff --git a/tests/api/faucets/evm-chain-faucet.test.ts b/tests/api/faucets/evm-chain-faucet.test.ts new file mode 100644 index 00000000000..dd1a4563dd1 --- /dev/null +++ b/tests/api/faucets/evm-chain-faucet.test.ts @@ -0,0 +1,238 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { + createMockRequest, + callHandler, + expectSuccess, + expectError, +} from '@/tests/api/helpers/api-test-utils'; +import { + createMockSession, + mockAuthSession, +} from '@/tests/api/helpers/mock-session'; + +// Module-level mocks (hoisted by vitest) +vi.mock('@/lib/auth/authSession'); +vi.mock('@/lib/faucet/rateLimit'); +vi.mock('@/lib/faucet/nonceManager'); +vi.mock('@/server/services/consoleBadge/consoleBadgeService'); +vi.mock('@/components/toolbox/stores/l1ListStore', () => ({ + getL1ListStore: vi.fn().mockReturnValue({ + getState: () => ({ + l1List: [ + { + evmChainId: 43113, + name: 'Avalanche Fuji', + coinName: 'AVAX', + rpcUrl: 'https://api.avax-test.network/ext/bc/C/rpc', + explorerUrl: 'https://testnet.snowtrace.io', + hasBuilderHubFaucet: true, + faucetThresholds: { dripAmount: 2 }, + }, + ], + }), + }), +})); +vi.mock('viem', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + createWalletClient: vi.fn().mockReturnValue({ + sendTransaction: vi.fn().mockResolvedValue('0xmocktxhash1234'), + }), + createPublicClient: vi.fn().mockReturnValue({ + getBalance: vi.fn().mockResolvedValue(BigInt('10000000000000000000')), + getTransactionCount: vi.fn().mockResolvedValue(42), + }), + }; +}); +vi.mock('viem/accounts', () => ({ + privateKeyToAccount: vi.fn().mockReturnValue({ + address: '0x1111111111111111111111111111111111111111', + }), +})); + +import { + checkAndReserveFaucetClaim, + completeFaucetClaim, + cancelFaucetClaim, +} from '@/lib/faucet/rateLimit'; +import { withChainLock, withNonceRetry } from '@/lib/faucet/nonceManager'; +import { checkAndAwardConsoleBadges } from '@/server/services/consoleBadge/consoleBadgeService'; +import { GET } from '@/app/api/evm-chain-faucet/route'; + +const VALID_EVM_ADDRESS = '0x1234567890abcdef1234567890abcdef12345678'; +const FAUCET_EVM_ADDRESS = '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; + +describe('GET /api/evm-chain-faucet', () => { + const session = createMockSession(); + + beforeEach(() => { + vi.stubEnv('FAUCET_C_CHAIN_PRIVATE_KEY', '0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef'); + vi.stubEnv('FAUCET_C_CHAIN_ADDRESS', FAUCET_EVM_ADDRESS); + + mockAuthSession(session); + + vi.mocked(checkAndReserveFaucetClaim).mockResolvedValue({ + allowed: true, + claimId: 'mock-claim-id', + }); + vi.mocked(completeFaucetClaim).mockResolvedValue(); + vi.mocked(cancelFaucetClaim).mockResolvedValue(); + vi.mocked(checkAndAwardConsoleBadges).mockResolvedValue([]); + vi.mocked(withChainLock).mockImplementation(async (_chainId, fn) => fn()); + vi.mocked(withNonceRetry).mockImplementation(async (fn) => fn()); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + vi.clearAllMocks(); + }); + + it('requires authentication', async () => { + mockAuthSession(null); + const req = createMockRequest('GET', { + url: 'http://localhost:3000/api/evm-chain-faucet', + searchParams: { address: VALID_EVM_ADDRESS, chainId: '43113' }, + }); + + const result = await callHandler(GET, req); + expectError(result, 401, 'AUTH_REQUIRED'); + }); + + it('validates EVM address format with regex', async () => { + const req = createMockRequest('GET', { + url: 'http://localhost:3000/api/evm-chain-faucet', + searchParams: { address: 'not-an-address', chainId: '43113' }, + }); + + const result = await callHandler(GET, req); + expectError(result, 400, 'VALIDATION_ERROR'); + expect(result.body.error.message).toContain('Ethereum address'); + }); + + it('validates chainId is required', async () => { + const req = createMockRequest('GET', { + url: 'http://localhost:3000/api/evm-chain-faucet', + searchParams: { address: VALID_EVM_ADDRESS }, + }); + + const result = await callHandler(GET, req); + expectError(result, 400, 'VALIDATION_ERROR'); + }); + + it('validates chainId is numeric', async () => { + const req = createMockRequest('GET', { + url: 'http://localhost:3000/api/evm-chain-faucet', + searchParams: { address: VALID_EVM_ADDRESS, chainId: 'abc' }, + }); + + const result = await callHandler(GET, req); + expectError(result, 400, 'VALIDATION_ERROR'); + expect(result.body.error.message).toContain('chain ID'); + }); + + it('enforces rate limiting via checkAndReserveFaucetClaim', async () => { + vi.mocked(checkAndReserveFaucetClaim).mockResolvedValue({ + allowed: false, + reason: 'This address has reached its daily claim limit.', + }); + + const req = createMockRequest('GET', { + url: 'http://localhost:3000/api/evm-chain-faucet', + searchParams: { address: VALID_EVM_ADDRESS, chainId: '43113' }, + }); + + const result = await callHandler(GET, req); + expectError(result, 429, 'RATE_LIMITED'); + expect(result.body.error.message).toContain('daily claim limit'); + }); + + it('does not leak private key material in error responses', async () => { + vi.mocked(withChainLock).mockRejectedValue( + new Error('Transaction signed with key 0xdeadbeef failed'), + ); + + const req = createMockRequest('GET', { + url: 'http://localhost:3000/api/evm-chain-faucet', + searchParams: { address: VALID_EVM_ADDRESS, chainId: '43113' }, + }); + + const result = await callHandler(GET, req); + + expect(result.status).toBe(500); + const responseText = JSON.stringify(result.body); + expect(responseText).not.toContain('0xdeadbeef'); + expect(responseText).not.toContain('FAUCET_C_CHAIN_PRIVATE_KEY'); + expect(result.body.error.message).toContain('Faucet transaction failed'); + }); + + it('cancels claim on transfer failure', async () => { + vi.mocked(withChainLock).mockRejectedValue(new Error('RPC timeout')); + + const req = createMockRequest('GET', { + url: 'http://localhost:3000/api/evm-chain-faucet', + searchParams: { address: VALID_EVM_ADDRESS, chainId: '43113' }, + }); + + await callHandler(GET, req); + expect(cancelFaucetClaim).toHaveBeenCalledWith('mock-claim-id'); + }); + + it('rejects unsupported chain IDs', async () => { + const req = createMockRequest('GET', { + url: 'http://localhost:3000/api/evm-chain-faucet', + searchParams: { address: VALID_EVM_ADDRESS, chainId: '999999' }, + }); + + const result = await callHandler(GET, req); + expectError(result, 400, 'BAD_REQUEST'); + expect(result.body.error.message).toContain('does not support'); + }); + + it('prevents sending to the faucet address itself', async () => { + const req = createMockRequest('GET', { + url: 'http://localhost:3000/api/evm-chain-faucet', + searchParams: { + address: FAUCET_EVM_ADDRESS, + chainId: '43113', + }, + }); + + const result = await callHandler(GET, req); + expectError(result, 400, 'BAD_REQUEST'); + expect(result.body.error.message).toContain('faucet address'); + }); + + it('returns success with txHash on successful transfer', async () => { + const req = createMockRequest('GET', { + url: 'http://localhost:3000/api/evm-chain-faucet', + searchParams: { address: VALID_EVM_ADDRESS, chainId: '43113' }, + }); + + const result = await callHandler(GET, req); + const data = expectSuccess(result); + + expect(data.txHash).toBeDefined(); + expect(data.chainId).toBe(43113); + expect(data.destinationAddress).toBe(VALID_EVM_ADDRESS); + expect(data.amount).toBe('2'); + expect(data.awardedBadges).toEqual([]); + }); + + it('passes chainId to checkAndReserveFaucetClaim', async () => { + const req = createMockRequest('GET', { + url: 'http://localhost:3000/api/evm-chain-faucet', + searchParams: { address: VALID_EVM_ADDRESS, chainId: '43113' }, + }); + + await callHandler(GET, req); + + expect(checkAndReserveFaucetClaim).toHaveBeenCalledWith( + session.user.id, + 'evm', + VALID_EVM_ADDRESS, + '2', + '43113', + ); + }); +}); diff --git a/tests/api/faucets/pchain-faucet.test.ts b/tests/api/faucets/pchain-faucet.test.ts new file mode 100644 index 00000000000..794b78e0930 --- /dev/null +++ b/tests/api/faucets/pchain-faucet.test.ts @@ -0,0 +1,174 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { + createMockRequest, + callHandler, + expectSuccess, + expectError, +} from '@/tests/api/helpers/api-test-utils'; +import { + createMockSession, + mockAuthSession, +} from '@/tests/api/helpers/mock-session'; + +// Module-level mocks (hoisted by vitest) +vi.mock('@/lib/auth/authSession'); +vi.mock('@/lib/faucet/rateLimit'); +vi.mock('@/server/services/consoleBadge/consoleBadgeService'); +vi.mock('@avalabs/avalanchejs', () => ({ + TransferableOutput: { fromNative: vi.fn() }, + addTxSignatures: vi.fn(), + pvm: { + PVMApi: vi.fn().mockImplementation(() => ({ + getFeeState: vi.fn().mockResolvedValue({}), + getUTXOs: vi.fn().mockResolvedValue({ utxos: [] }), + issueSignedTx: vi.fn().mockResolvedValue({ txID: 'mock-tx-id' }), + })), + newBaseTx: vi.fn().mockReturnValue({ + getSignedTx: vi.fn(), + }), + }, + utils: { + bech32ToBytes: vi.fn().mockReturnValue(new Uint8Array()), + hexToBuffer: vi.fn().mockReturnValue(new Uint8Array()), + }, + Context: { + getContextFromURI: vi.fn().mockResolvedValue({ avaxAssetID: 'mock-asset-id' }), + }, +})); + +import { + checkAndReserveFaucetClaim, + completeFaucetClaim, + cancelFaucetClaim, +} from '@/lib/faucet/rateLimit'; +import { checkAndAwardConsoleBadges } from '@/server/services/consoleBadge/consoleBadgeService'; +import { GET } from '@/app/api/pchain-faucet/route'; + +const VALID_P_ADDRESS = 'P-fuji1abcdef1234567890abcdef1234567890abcdef12'; + +describe('GET /api/pchain-faucet', () => { + const session = createMockSession(); + + beforeEach(() => { + vi.stubEnv('SERVER_PRIVATE_KEY', '0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef'); + vi.stubEnv('FAUCET_P_CHAIN_ADDRESS', 'P-fuji1faucetaddress000000000000000000000000000'); + + mockAuthSession(session); + + vi.mocked(checkAndReserveFaucetClaim).mockResolvedValue({ + allowed: true, + claimId: 'mock-claim-id', + }); + vi.mocked(completeFaucetClaim).mockResolvedValue(); + vi.mocked(cancelFaucetClaim).mockResolvedValue(); + vi.mocked(checkAndAwardConsoleBadges).mockResolvedValue([]); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + vi.clearAllMocks(); + }); + + it('requires authentication', async () => { + mockAuthSession(null); + const req = createMockRequest('GET', { + url: 'http://localhost:3000/api/pchain-faucet', + searchParams: { address: VALID_P_ADDRESS }, + }); + + const result = await callHandler(GET, req); + expectError(result, 401, 'AUTH_REQUIRED'); + }); + + it('validates address is present', async () => { + const req = createMockRequest('GET', { + url: 'http://localhost:3000/api/pchain-faucet', + }); + + const result = await callHandler(GET, req); + expectError(result, 400, 'VALIDATION_ERROR'); + }); + + it('validates P-Chain address format', async () => { + const req = createMockRequest('GET', { + url: 'http://localhost:3000/api/pchain-faucet', + searchParams: { address: '0x1234567890abcdef1234567890abcdef12345678' }, + }); + + const result = await callHandler(GET, req); + expectError(result, 400, 'VALIDATION_ERROR'); + expect(result.body.error.message).toContain('P-Chain'); + }); + + it('enforces rate limiting via checkAndReserveFaucetClaim', async () => { + vi.mocked(checkAndReserveFaucetClaim).mockResolvedValue({ + allowed: false, + reason: 'Rate limit exceeded. You can claim again after tomorrow.', + }); + + const req = createMockRequest('GET', { + url: 'http://localhost:3000/api/pchain-faucet', + searchParams: { address: VALID_P_ADDRESS }, + }); + + const result = await callHandler(GET, req); + expectError(result, 429, 'RATE_LIMITED'); + expect(result.body.error.message).toContain('Rate limit exceeded'); + }); + + it('does not leak private key material in error responses', async () => { + vi.mocked(checkAndReserveFaucetClaim).mockRejectedValue( + new Error('Transaction failed with key 0xdeadbeef...'), + ); + + const req = createMockRequest('GET', { + url: 'http://localhost:3000/api/pchain-faucet', + searchParams: { address: VALID_P_ADDRESS }, + }); + + const result = await callHandler(GET, req); + + // Should get a generic 500, not the raw error with key material + expect(result.status).toBe(500); + expect(JSON.stringify(result.body)).not.toContain('0xdeadbeef'); + expect(JSON.stringify(result.body)).not.toContain('private'); + expect(JSON.stringify(result.body)).not.toContain('key'); + }); + + it('returns success with txID on successful transfer', async () => { + const req = createMockRequest('GET', { + url: 'http://localhost:3000/api/pchain-faucet', + searchParams: { address: VALID_P_ADDRESS }, + }); + + const result = await callHandler(GET, req); + const data = expectSuccess(result); + + expect(data.txID).toBe('mock-tx-id'); + expect(data.amount).toBe(0.5); + expect(data.destinationAddress).toBe(VALID_P_ADDRESS); + expect(data.awardedBadges).toEqual([]); + }); + + it('calls completeFaucetClaim on success', async () => { + const req = createMockRequest('GET', { + url: 'http://localhost:3000/api/pchain-faucet', + searchParams: { address: VALID_P_ADDRESS }, + }); + + await callHandler(GET, req); + + expect(completeFaucetClaim).toHaveBeenCalledWith('mock-claim-id', 'mock-tx-id'); + }); + + it('prevents sending to the faucet address itself', async () => { + const req = createMockRequest('GET', { + url: 'http://localhost:3000/api/pchain-faucet', + searchParams: { address: 'P-fuji1faucetaddress000000000000000000000000000' }, + }); + + const result = await callHandler(GET, req); + expectError(result, 400, 'BAD_REQUEST'); + expect(result.body.error.message).toContain('faucet address'); + }); +}); diff --git a/tests/api/helpers/api-test-utils.ts b/tests/api/helpers/api-test-utils.ts new file mode 100644 index 00000000000..1b8e5406844 --- /dev/null +++ b/tests/api/helpers/api-test-utils.ts @@ -0,0 +1,260 @@ +/** + * Core test utilities for API route testing. + * + * Provides helpers to construct NextRequest objects, call route handlers, + * and assert against the standard API response envelope used by Builders Hub. + * + * Response envelope: + * Success: { success: true, data: T } + * Paginated: { success: true, data: T[], pagination: { page, pageSize, total } } + * Error: { success: false, error: { code: string, message: string } } + */ + +import { NextRequest } from 'next/server'; +import { expect } from 'vitest'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** Parsed result returned by callHandler. */ +export interface HandlerResult { + status: number; + body: any; + headers: Headers; +} + +/** Shape of a successful (non-paginated) API response. */ +export interface SuccessBody { + success: true; + data: T; +} + +/** Shape of a paginated API response. */ +export interface PaginatedBody { + success: true; + data: T[]; + pagination: { + page: number; + pageSize: number; + total: number; + }; +} + +/** Shape of an error API response. */ +export interface ErrorBody { + success: false; + error: { + code: string; + message: string; + }; +} + +/** Standard error codes used by the API layer. */ +export type ApiErrorCode = + | 'VALIDATION_ERROR' + | 'BAD_REQUEST' + | 'AUTH_REQUIRED' + | 'FORBIDDEN' + | 'NOT_FOUND' + | 'CONFLICT' + | 'RATE_LIMITED' + | 'INTERNAL_ERROR'; + +// --------------------------------------------------------------------------- +// Request builder +// --------------------------------------------------------------------------- + +/** + * Create a mock NextRequest for testing API routes. + * + * @param method - HTTP method (GET, POST, PUT, DELETE, PATCH, etc.) + * @param options - Optional body, headers, searchParams, and base URL. + * @returns A real NextRequest instance ready to pass to a route handler. + * + * @example + * ```ts + * const req = createMockRequest('POST', { + * body: { name: 'foo' }, + * headers: { 'x-custom': 'bar' }, + * searchParams: { page: '1' }, + * }); + * ``` + */ +export function createMockRequest( + method: string, + options?: { + body?: any; + headers?: Record; + searchParams?: Record; + url?: string; + }, +): NextRequest { + const baseUrl = options?.url ?? 'http://localhost:3000/api/test'; + const url = new URL(baseUrl); + + if (options?.searchParams) { + for (const [key, value] of Object.entries(options.searchParams)) { + url.searchParams.set(key, value); + } + } + + const headers = new Headers(options?.headers); + + const init: Record = { method, headers }; + + if (options?.body !== undefined) { + init.body = JSON.stringify(options.body); + // Set Content-Type if the caller didn't provide one. + if (!headers.has('Content-Type')) { + headers.set('Content-Type', 'application/json'); + } + } + + // Next.js's RequestInit is stricter than the standard DOM type (signal + // must be undefined, not null). The cast is safe for test construction. + return new NextRequest(url, init as any); +} + +// --------------------------------------------------------------------------- +// Handler caller +// --------------------------------------------------------------------------- + +/** + * Call an API route handler and parse the response. + * + * Handles the Next.js App Router route handler signature: + * (request: NextRequest, context: { params: Promise> }) => Response + * + * @param handler - The route handler function (e.g. GET, POST exported from a route file). + * @param request - The NextRequest to pass. + * @param params - Optional dynamic route params (e.g. `{ id: '123' }`). + * @returns Parsed status, JSON body, and response headers. + * + * @example + * ```ts + * const { GET } = await import('@/app/api/projects/route'); + * const result = await callHandler(GET, req, { id: '123' }); + * ``` + */ +export async function callHandler( + handler: Function, + request: NextRequest, + params?: Record, +): Promise { + const context = { params: Promise.resolve(params ?? {}) }; + const response: Response = await handler(request, context); + + let body: any; + try { + body = await response.json(); + } catch { + body = null; + } + + return { + status: response.status, + body, + headers: response.headers, + }; +} + +// --------------------------------------------------------------------------- +// Assertion helpers +// --------------------------------------------------------------------------- + +/** + * Assert a successful API response with the correct envelope. + * + * Verifies: + * - `result.status` matches `expectedStatus` (default 200) + * - `result.body.success` is `true` + * - `result.body.data` exists + * + * @returns `result.body.data` for further assertions. + * + * @example + * ```ts + * const data = expectSuccess(result, 201); + * expect(data.name).toBe('Test'); + * ``` + */ +export function expectSuccess( + result: HandlerResult, + expectedStatus = 200, +): T { + expect(result.status).toBe(expectedStatus); + expect(result.body.success).toBe(true); + expect(result.body).toHaveProperty('data'); + return result.body.data as T; +} + +/** + * Assert a paginated API response. + * + * Verifies everything `expectSuccess` checks plus: + * - `result.body.pagination` exists with `page`, `pageSize`, and `total` properties. + * - `result.body.data` is an array. + * + * @returns An object containing `data` and `pagination` for further assertions. + * + * @example + * ```ts + * const { data, pagination } = expectPaginated(result); + * expect(data).toHaveLength(10); + * expect(pagination.total).toBe(42); + * ``` + */ +export function expectPaginated( + result: HandlerResult, +): { data: T[]; pagination: { page: number; pageSize: number; total: number } } { + expect(result.status).toBe(200); + expect(result.body.success).toBe(true); + expect(result.body).toHaveProperty('data'); + expect(Array.isArray(result.body.data)).toBe(true); + + expect(result.body).toHaveProperty('pagination'); + expect(result.body.pagination).toHaveProperty('page'); + expect(result.body.pagination).toHaveProperty('pageSize'); + expect(result.body.pagination).toHaveProperty('total'); + + return { + data: result.body.data as T[], + pagination: result.body.pagination, + }; +} + +/** + * Assert an error API response with the correct envelope. + * + * Verifies: + * - `result.status` matches `expectedStatus` + * - `result.body.success` is `false` + * - `result.body.error` has `code` and `message` string properties + * - If `expectedCode` is provided, `result.body.error.code` matches it. + * + * @returns The error object `{ code, message }` for further assertions. + * + * @example + * ```ts + * const err = expectError(result, 401, 'AUTH_REQUIRED'); + * expect(err.message).toContain('authentication'); + * ``` + */ +export function expectError( + result: HandlerResult, + expectedStatus: number, + expectedCode?: string, +): { code: string; message: string } { + expect(result.status).toBe(expectedStatus); + expect(result.body.success).toBe(false); + expect(result.body).toHaveProperty('error'); + expect(typeof result.body.error.code).toBe('string'); + expect(typeof result.body.error.message).toBe('string'); + + if (expectedCode !== undefined) { + expect(result.body.error.code).toBe(expectedCode); + } + + return result.body.error as { code: string; message: string }; +} diff --git a/tests/api/helpers/mock-prisma.ts b/tests/api/helpers/mock-prisma.ts new file mode 100644 index 00000000000..91200866dc1 --- /dev/null +++ b/tests/api/helpers/mock-prisma.ts @@ -0,0 +1,198 @@ +/** + * Mock Prisma client for API route tests. + * + * Uses a Proxy-based approach so that any model property access + * (e.g. `mockPrisma.user.findMany`) automatically returns a vitest mock + * function. This avoids hard-coding every model from the schema. + * + * Usage pattern — in each test file: + * + * ```ts + * import { vi, afterEach } from 'vitest'; + * import { setupPrismaMock, resetPrismaMock } from '@/tests/api/helpers/mock-prisma'; + * + * // Module-level mock declaration (hoisted by vitest) + * vi.mock('@/prisma/prisma'); + * + * const mockPrisma = setupPrismaMock(); + * + * afterEach(() => { + * resetPrismaMock(mockPrisma); + * }); + * + * it('fetches users', async () => { + * mockPrisma.user.findMany.mockResolvedValue([{ id: '1', name: 'Ada' }]); + * // ... call your handler + * }); + * ``` + * + * Models available (auto-proxied from schema): + * hackathon, user, verificationToken, registerForm, project, prize, + * member, badge, userBadge, projectBadge, nodeRegistration, consoleLog, + * statsPlayground, statsPlaygroundFavorite, faucetClaim, chatConversation, + * chatMessage, formData, evaluation, buildGamesApplication, oAuthCode, + * retro9000ReturningApplication, validatorAlert, validatorAlertLog + */ + +import { vi } from 'vitest'; +import { prisma } from '@/prisma/prisma'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** Common Prisma client query methods that each model exposes. */ +const PRISMA_METHODS = [ + 'findFirst', + 'findMany', + 'findUnique', + 'create', + 'createMany', + 'update', + 'updateMany', + 'delete', + 'deleteMany', + 'upsert', + 'count', + 'aggregate', + 'groupBy', +] as const; + +/** A single model's mocked interface — every method is a vi.fn(). */ +export type MockModel = { + [K in (typeof PRISMA_METHODS)[number]]: ReturnType; +}; + +/** The deeply-mocked Prisma client. Access any model as a property. */ +export type MockPrismaClient = { + [model: string]: MockModel; +} & { + $transaction: ReturnType; +}; + +// --------------------------------------------------------------------------- +// Internal cache: one MockModel per model name (persisted across calls). +// --------------------------------------------------------------------------- + +function createModelMock(): MockModel { + const model = {} as MockModel; + for (const method of PRISMA_METHODS) { + model[method] = vi.fn(); + } + return model; +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Create a deeply-mocked Prisma client. + * + * Property access on the returned object is intercepted by a Proxy: + * - Known Prisma methods on any model name return persistent `vi.fn()` stubs. + * - `$transaction` accepts a callback and calls it with the mock client. + * + * @example + * ```ts + * const mock = createMockPrisma(); + * mock.user.findMany.mockResolvedValue([]); + * mock.chatConversation.create.mockResolvedValue({ id: '1' }); + * ``` + */ +export function createMockPrisma(): MockPrismaClient { + const modelCache = new Map(); + + const transactionFn = vi.fn(async (arg: unknown) => { + if (typeof arg === 'function') { + return arg(proxy); + } + // Array-of-promises mode: resolve all + if (Array.isArray(arg)) { + return Promise.all(arg); + } + return arg; + }); + + const proxy = new Proxy({} as MockPrismaClient, { + get(_target, prop: string) { + if (prop === '$transaction') { + return transactionFn; + } + + // Vitest / Node internals that should not trigger model creation + if ( + prop === 'then' || + prop === 'toJSON' || + prop === 'asymmetricMatch' || + typeof prop === 'symbol' + ) { + return undefined; + } + + if (!modelCache.has(prop)) { + modelCache.set(prop, createModelMock()); + } + return modelCache.get(prop)!; + }, + }); + + return proxy; +} + +/** + * Set up the Prisma mock and return the mock client. + * + * This configures `vi.mocked(prisma)` so that the default export of + * `@/prisma/prisma` returns the mock client. The caller must have + * `vi.mock('@/prisma/prisma')` at module level. + * + * @returns The mock Prisma client for configuring per-test return values. + * + * @example + * ```ts + * vi.mock('@/prisma/prisma'); + * const mockPrisma = setupPrismaMock(); + * ``` + */ +export function setupPrismaMock(): MockPrismaClient { + const mockClient = createMockPrisma(); + + // vi.mocked(prisma) returns the module's `prisma` export as a mock. + // We redirect every property access on it to our proxy. + const mockedPrisma = vi.mocked(prisma); + + // Replace the mocked export with a proxy that delegates to mockClient. + // Because vi.mock hoists and replaces the module, we can mutate the + // mock's properties directly. + return new Proxy(mockedPrisma as unknown as MockPrismaClient, { + get(_target, prop: string) { + return (mockClient as any)[prop]; + }, + }); +} + +/** + * Reset all mocked Prisma method return values. + * Call in `afterEach` to prevent state leakage between tests. + * + * @param mockPrisma - The mock client returned by `setupPrismaMock()`. + */ +export function resetPrismaMock(mockPrisma: MockPrismaClient): void { + // Reset $transaction + if (mockPrisma.$transaction && typeof mockPrisma.$transaction.mockReset === 'function') { + mockPrisma.$transaction.mockReset(); + } + + // Iterate known method names on each accessed model. + // The Proxy will create models on access, so we iterate the underlying + // cache by accessing common model names. Instead, we rely on the fact + // that vi.restoreAllMocks (called via resetAuthMocks or directly) will + // clear the module-level mock, and we additionally reset every vi.fn() + // we can reach. + // + // Since the proxy is transparent, calling resetPrismaMock just resets + // whatever the consumer directly touched. We use vi.clearAllMocks() + // as a safety net, then re-clear the $transaction mock. + vi.clearAllMocks(); +} diff --git a/tests/api/helpers/mock-session.ts b/tests/api/helpers/mock-session.ts new file mode 100644 index 00000000000..ad577b2a807 --- /dev/null +++ b/tests/api/helpers/mock-session.ts @@ -0,0 +1,142 @@ +/** + * Mock NextAuth session helpers for API route tests. + * + * Usage pattern — in each test file: + * + * ```ts + * import { vi, beforeEach, afterEach } from 'vitest'; + * import { getAuthSession } from '@/lib/auth/authSession'; + * import { createMockSession, mockAuthSession, resetAuthMocks } from '@/tests/api/helpers/mock-session'; + * + * // Module-level mock declaration (hoisted by vitest) + * vi.mock('@/lib/auth/authSession'); + * + * describe('my route', () => { + * const session = createMockSession({ email: 'dev@example.com' }); + * + * beforeEach(() => { + * mockAuthSession(session); + * }); + * + * afterEach(() => { + * resetAuthMocks(); + * }); + * }); + * ``` + * + * IMPORTANT: The `vi.mock('@/lib/auth/authSession')` call MUST be at the + * module level in the test file. Vitest hoists it above all imports, which + * means `getAuthSession` is already replaced by a vi.fn() by the time your + * handler runs. The helpers here configure the return value per-test. + */ + +import { vi } from 'vitest'; +import { getAuthSession } from '@/lib/auth/authSession'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** + * Fields available on the mocked user object. + * Aligns with the augmented next-auth Session['user'] declared in + * `@/lib/auth/authOptions.ts`. + */ +export interface MockUser { + id: string; + email: string; + name: string; + image?: string; + avatar?: string; + custom_attributes: string[]; + role?: string; + user_name?: string; + is_new_user: boolean; + authentication_mode?: string; +} + +/** The full session shape returned by getAuthSession. */ +export interface MockSession { + user: MockUser; + expires: string; +} + +// --------------------------------------------------------------------------- +// Session factories +// --------------------------------------------------------------------------- + +/** + * Create a mock session with sensible defaults. + * Override any user field via `overrides`. + * + * @example + * ```ts + * const session = createMockSession({ email: 'dev@example.com' }); + * ``` + */ +export function createMockSession(overrides?: Partial): MockSession { + return { + user: { + id: 'test-user-id', + email: 'test@example.com', + name: 'Test User', + custom_attributes: [], + is_new_user: false, + ...overrides, + }, + expires: new Date(Date.now() + 86_400_000).toISOString(), + }; +} + +/** + * Create an admin session (has 'devrel' role in custom_attributes). + * Override any user field via `overrides`. + * + * @example + * ```ts + * const admin = createAdminSession(); + * ``` + */ +export function createAdminSession(overrides?: Partial): MockSession { + return createMockSession({ + id: 'admin-user-id', + email: 'admin@avalabs.org', + name: 'Admin User', + custom_attributes: ['devrel'], + ...overrides, + }); +} + +// --------------------------------------------------------------------------- +// Mock configuration +// --------------------------------------------------------------------------- + +/** + * Configure the mocked `getAuthSession` to resolve with the given session. + * + * Call this in `beforeEach` (or at the top of an individual test) after + * declaring `vi.mock('@/lib/auth/authSession')` at module level. + * + * Pass `null` to simulate an unauthenticated request. + * + * @returns The mock function, so you can add further assertions + * (e.g. `expect(mock).toHaveBeenCalledTimes(1)`). + * + * @example + * ```ts + * mockAuthSession(session); // authenticated + * mockAuthSession(null); // unauthenticated + * ``` + */ +export function mockAuthSession(session: MockSession | null): ReturnType { + const mocked = vi.mocked(getAuthSession); + mocked.mockResolvedValue(session); + return mocked; +} + +/** + * Reset all auth mocks. Call in `afterEach` to prevent state leakage. + */ +export function resetAuthMocks(): void { + vi.restoreAllMocks(); +} diff --git a/tests/api/notifications/notifications.test.ts b/tests/api/notifications/notifications.test.ts new file mode 100644 index 00000000000..15d12ca8189 --- /dev/null +++ b/tests/api/notifications/notifications.test.ts @@ -0,0 +1,239 @@ +import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from 'vitest'; +import { + createMockRequest, + callHandler, + expectSuccess, + expectError, +} from '@/tests/api/helpers/api-test-utils'; +import { setupPrismaMock, resetPrismaMock } from '@/tests/api/helpers/mock-prisma'; +import { + createMockSession, + createAdminSession, + mockAuthSession, + resetAuthMocks, +} from '@/tests/api/helpers/mock-session'; + +vi.mock('@/prisma/prisma'); +vi.mock('@/lib/auth/authSession'); + +// Mock global fetch for upstream calls +const mockFetch = vi.fn(); +vi.stubGlobal('fetch', mockFetch); + +const mockPrisma = setupPrismaMock(); + +// Set env vars needed by notification routes +const originalEnv = { ...process.env }; + +beforeEach(() => { + process.env.NEXT_PUBLIC_AVALANCHE_WORKERS_URL = 'https://workers.test.local'; + process.env.AVALANCHE_WORKERS_API_KEY = 'test-api-key'; +}); + +afterEach(() => { + resetPrismaMock(mockPrisma); + resetAuthMocks(); + mockFetch.mockReset(); + process.env = { ...originalEnv }; +}); + +// --------------------------------------------------------------------------- +// POST /api/notifications/create +// --------------------------------------------------------------------------- +describe('POST /api/notifications/create', () => { + const adminSession = createAdminSession(); + + beforeEach(() => { + mockAuthSession(adminSession); + // Rate limit: allow by default + mockPrisma.apiRateLimitLog.count.mockResolvedValue(0); + mockPrisma.apiRateLimitLog.create.mockResolvedValue({}); + }); + + it('creates notifications when authenticated with correct role', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + headers: new Headers({ 'content-type': 'application/json' }), + json: async () => ({ created: 2 }), + }); + + const { POST } = await import('@/app/api/notifications/create/route'); + const req = createMockRequest('POST', { + body: { notifications: [{ content: 'Hello' }, { content: 'World' }] }, + url: 'http://localhost:3000/api/notifications/create', + }); + + const result = await callHandler(POST, req); + const data = expectSuccess(result); + expect(data).toEqual({ created: 2 }); + }); + + it('rejects unauthenticated requests', async () => { + mockAuthSession(null); + + const { POST } = await import('@/app/api/notifications/create/route'); + const req = createMockRequest('POST', { + body: { notifications: [{ content: 'test' }] }, + url: 'http://localhost:3000/api/notifications/create', + }); + + const result = await callHandler(POST, req); + expectError(result, 401, 'AUTH_REQUIRED'); + }); + + it('rejects users without devrel or notify_event role', async () => { + const regularSession = createMockSession({ custom_attributes: [] }); + mockAuthSession(regularSession); + + const { POST } = await import('@/app/api/notifications/create/route'); + const req = createMockRequest('POST', { + body: { notifications: [{ content: 'test' }] }, + url: 'http://localhost:3000/api/notifications/create', + }); + + const result = await callHandler(POST, req); + expectError(result, 403, 'FORBIDDEN'); + }); + + it('rejects empty notifications array', async () => { + const { POST } = await import('@/app/api/notifications/create/route'); + const req = createMockRequest('POST', { + body: { notifications: [] }, + url: 'http://localhost:3000/api/notifications/create', + }); + + const result = await callHandler(POST, req); + expectError(result, 400, 'VALIDATION_ERROR'); + }); + + it('rejects missing notifications field', async () => { + const { POST } = await import('@/app/api/notifications/create/route'); + const req = createMockRequest('POST', { + body: {}, + url: 'http://localhost:3000/api/notifications/create', + }); + + const result = await callHandler(POST, req); + expectError(result, 400, 'VALIDATION_ERROR'); + }); +}); + +// --------------------------------------------------------------------------- +// POST /api/notifications/get +// --------------------------------------------------------------------------- +describe('POST /api/notifications/get', () => { + const session = createMockSession(); + + beforeEach(() => { + mockAuthSession(session); + mockPrisma.apiRateLimitLog.count.mockResolvedValue(0); + mockPrisma.apiRateLimitLog.create.mockResolvedValue({}); + }); + + it('fetches notifications for authenticated user', async () => { + const mockNotifications = [ + { id: '1', content: 'Notification 1', read: false }, + { id: '2', content: 'Notification 2', read: true }, + ]; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => mockNotifications, + }); + + const { POST } = await import('@/app/api/notifications/get/route'); + const req = createMockRequest('POST', { + url: 'http://localhost:3000/api/notifications/get', + }); + + const result = await callHandler(POST, req); + const data = expectSuccess(result); + expect(data).toEqual(mockNotifications); + }); + + it('rejects unauthenticated requests', async () => { + mockAuthSession(null); + + const { POST } = await import('@/app/api/notifications/get/route'); + const req = createMockRequest('POST', { + url: 'http://localhost:3000/api/notifications/get', + }); + + const result = await callHandler(POST, req); + expectError(result, 401, 'AUTH_REQUIRED'); + }); + + it('returns empty data when upstream is unavailable (500)', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 500, + text: async () => 'Service unavailable', + }); + + const { POST } = await import('@/app/api/notifications/get/route'); + const req = createMockRequest('POST', { + url: 'http://localhost:3000/api/notifications/get', + }); + + const result = await callHandler(POST, req); + const data = expectSuccess(result); + expect(data).toEqual({}); + }); + + it('returns empty data on network error', async () => { + mockFetch.mockRejectedValueOnce(new Error('Network failure')); + + const { POST } = await import('@/app/api/notifications/get/route'); + const req = createMockRequest('POST', { + url: 'http://localhost:3000/api/notifications/get', + }); + + const result = await callHandler(POST, req); + const data = expectSuccess(result); + expect(data).toEqual({}); + }); +}); + +// --------------------------------------------------------------------------- +// POST /api/notifications/read +// --------------------------------------------------------------------------- +describe('POST /api/notifications/read', () => { + const session = createMockSession(); + + beforeEach(() => { + mockAuthSession(session); + mockPrisma.apiRateLimitLog.count.mockResolvedValue(0); + mockPrisma.apiRateLimitLog.create.mockResolvedValue({}); + }); + + it('marks notifications as read', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + headers: new Headers({ 'content-type': 'application/json' }), + json: async () => ({ updated: 2 }), + }); + + const { POST } = await import('@/app/api/notifications/read/route'); + const req = createMockRequest('POST', { + body: ['notif-1', 'notif-2'], + url: 'http://localhost:3000/api/notifications/read', + }); + + const result = await callHandler(POST, req); + const data = expectSuccess(result); + expect(data).toEqual({ updated: 2 }); + }); + + it('rejects unauthenticated requests', async () => { + mockAuthSession(null); + + const { POST } = await import('@/app/api/notifications/read/route'); + const req = createMockRequest('POST', { + body: ['notif-1'], + url: 'http://localhost:3000/api/notifications/read', + }); + + const result = await callHandler(POST, req); + expectError(result, 401, 'AUTH_REQUIRED'); + }); +}); diff --git a/tests/api/playground/playground.test.ts b/tests/api/playground/playground.test.ts new file mode 100644 index 00000000000..2261b50cc9f --- /dev/null +++ b/tests/api/playground/playground.test.ts @@ -0,0 +1,403 @@ +import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; + +// vi.hoisted runs in the hoisted context alongside vi.mock +const { mockPrisma } = vi.hoisted(() => { + // Inline minimal mock factory (cannot import from non-hoisted modules) + const METHODS = [ + 'findFirst', 'findMany', 'findUnique', 'create', 'createMany', + 'update', 'updateMany', 'delete', 'deleteMany', 'upsert', 'count', + 'aggregate', 'groupBy', + ] as const; + + type MockModel = { [K in (typeof METHODS)[number]]: ReturnType }; + + function createModel(): MockModel { + const m = {} as MockModel; + for (const method of METHODS) m[method] = vi.fn(); + return m; + } + + const cache = new Map(); + const proxy = new Proxy({} as Record, { + get(_t, prop: string) { + if (prop === '$transaction') return vi.fn(async (arg: unknown) => typeof arg === 'function' ? arg(proxy) : Array.isArray(arg) ? Promise.all(arg) : arg); + if (prop === 'then' || prop === 'toJSON' || typeof prop === 'symbol') return undefined; + if (!cache.has(prop)) cache.set(prop, createModel()); + return cache.get(prop)!; + }, + }); + + return { mockPrisma: proxy }; +}); + +// Module-level mocks (hoisted by vitest) +vi.mock('@/prisma/prisma', () => ({ prisma: mockPrisma })); +vi.mock('@/lib/auth/authSession'); + +import { + createMockRequest, + callHandler, + expectSuccess, + expectError, +} from '@/tests/api/helpers/api-test-utils'; +import { + createMockSession, + mockAuthSession, + resetAuthMocks, +} from '@/tests/api/helpers/mock-session'; + +// Dynamic import so mocks are in place before the route module loads +const routeModule = () => import('@/app/api/playground/route'); +const favoriteModule = () => import('@/app/api/playground/favorite/route'); + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +const session = createMockSession({ email: 'dev@example.com' }); + +const PG_ID = '00000000-0000-4000-8000-000000000001'; + +const PLAYGROUND = { + id: PG_ID, + user_id: 'test-user-id', + name: 'My Dashboard', + is_public: false, + charts: { globalStartTime: null, globalEndTime: null, charts: [] }, + view_count: 5, + created_at: new Date(), + updated_at: new Date(), + favorites: [], + _count: { favorites: 2 }, + user: { + id: 'test-user-id', + name: 'Test User', + user_name: 'testuser', + image: null, + profile_privacy: 'public', + }, +}; + +// --------------------------------------------------------------------------- +// Lifecycle +// --------------------------------------------------------------------------- + +beforeEach(() => vi.clearAllMocks()); +afterEach(() => resetAuthMocks()); + +// --------------------------------------------------------------------------- +// GET /api/playground +// --------------------------------------------------------------------------- + +describe('GET /api/playground', () => { + beforeEach(() => mockAuthSession(session)); + + it('returns a single playground by id', async () => { + const { GET } = await routeModule(); + mockPrisma.statsPlayground.findFirst.mockResolvedValue(PLAYGROUND); + + const req = createMockRequest('GET', { + url: 'http://localhost:3000/api/playground', + searchParams: { id: PG_ID }, + }); + + const result = await callHandler(GET, req); + const data = expectSuccess(result); + expect(data.name).toBe('My Dashboard'); + expect(data.is_owner).toBe(true); + expect(data.favorite_count).toBe(2); + expect(data.view_count).toBe(5); + }); + + it('returns 404 when playground not found', async () => { + const { GET } = await routeModule(); + mockPrisma.statsPlayground.findFirst.mockResolvedValue(null); + + const req = createMockRequest('GET', { + url: 'http://localhost:3000/api/playground', + searchParams: { id: '00000000-0000-4000-8000-000000000099' }, + }); + + const result = await callHandler(GET, req); + expectError(result, 404, 'NOT_FOUND'); + }); + + it('returns user playgrounds list when authenticated', async () => { + const { GET } = await routeModule(); + mockPrisma.statsPlayground.findMany.mockResolvedValue([ + { ...PLAYGROUND, _count: { favorites: 0 } }, + ]); + + const req = createMockRequest('GET', { + url: 'http://localhost:3000/api/playground', + }); + + const result = await callHandler(GET, req); + const data = expectSuccess(result); + expect(Array.isArray(data)).toBe(true); + expect(data[0].name).toBe('My Dashboard'); + }); + + it('returns 401 when listing without auth', async () => { + const { GET } = await routeModule(); + mockAuthSession(null); + + const req = createMockRequest('GET', { + url: 'http://localhost:3000/api/playground', + }); + + const result = await callHandler(GET, req); + // Route throws AuthError (401) when no session and no playground id + expectError(result, 401, 'AUTH_REQUIRED'); + }); +}); + +// --------------------------------------------------------------------------- +// POST /api/playground +// --------------------------------------------------------------------------- + +describe('POST /api/playground', () => { + beforeEach(() => mockAuthSession(session)); + + it('creates a new playground', async () => { + const { POST } = await routeModule(); + const created = { id: '00000000-0000-4000-8000-000000000002', user_id: 'test-user-id', name: 'New Dashboard' }; + mockPrisma.statsPlayground.create.mockResolvedValue(created); + + const req = createMockRequest('POST', { + url: 'http://localhost:3000/api/playground', + body: { name: 'New Dashboard', isPublic: true, charts: [] }, + }); + + const result = await callHandler(POST, req); + const data = expectSuccess(result, 201); + expect(data.name).toBe('New Dashboard'); + }); + + it('rejects when name is missing', async () => { + const { POST } = await routeModule(); + + const req = createMockRequest('POST', { + url: 'http://localhost:3000/api/playground', + body: { isPublic: false }, + }); + + const result = await callHandler(POST, req); + expectError(result, 400, 'VALIDATION_ERROR'); + }); + + it('rejects unauthenticated requests', async () => { + const { POST } = await routeModule(); + mockAuthSession(null); + + const req = createMockRequest('POST', { + url: 'http://localhost:3000/api/playground', + body: { name: 'test' }, + }); + + const result = await callHandler(POST, req); + expectError(result, 401, 'AUTH_REQUIRED'); + }); +}); + +// --------------------------------------------------------------------------- +// PUT /api/playground -- ownership check via assertOwnership +// --------------------------------------------------------------------------- + +describe('PUT /api/playground', () => { + beforeEach(() => mockAuthSession(session)); + + it('updates an owned playground', async () => { + const { PUT } = await routeModule(); + mockPrisma.statsPlayground.findFirst.mockResolvedValue(PLAYGROUND); + mockPrisma.statsPlayground.update.mockResolvedValue({ + ...PLAYGROUND, + name: 'Renamed', + }); + + const req = createMockRequest('PUT', { + url: 'http://localhost:3000/api/playground', + body: { id: PG_ID, name: 'Renamed' }, + }); + + const result = await callHandler(PUT, req); + const data = expectSuccess(result); + expect(data.name).toBe('Renamed'); + }); + + it('returns 404 when not owner (assertOwnership)', async () => { + const { PUT } = await routeModule(); + mockPrisma.statsPlayground.findFirst.mockResolvedValue(null); + + const req = createMockRequest('PUT', { + url: 'http://localhost:3000/api/playground', + body: { id: PG_ID, name: 'Stolen' }, + }); + + const result = await callHandler(PUT, req); + expectError(result, 404, 'NOT_FOUND'); + }); +}); + +// --------------------------------------------------------------------------- +// DELETE /api/playground -- ownership check via assertOwnership +// --------------------------------------------------------------------------- + +describe('DELETE /api/playground', () => { + beforeEach(() => mockAuthSession(session)); + + it('deletes an owned playground', async () => { + const { DELETE } = await routeModule(); + mockPrisma.statsPlayground.findFirst.mockResolvedValue(PLAYGROUND); + mockPrisma.statsPlayground.delete.mockResolvedValue(PLAYGROUND); + + const req = createMockRequest('DELETE', { + url: 'http://localhost:3000/api/playground', + searchParams: { id: PG_ID }, + }); + + const result = await callHandler(DELETE, req); + // Route returns 204 No Content (noContentResponse) + expect(result.status).toBe(204); + }); + + it('returns 400 when id is missing', async () => { + const { DELETE } = await routeModule(); + + const req = createMockRequest('DELETE', { + url: 'http://localhost:3000/api/playground', + }); + + const result = await callHandler(DELETE, req); + expectError(result, 400, 'BAD_REQUEST'); + }); + + it('returns 404 when not owner (assertOwnership)', async () => { + const { DELETE } = await routeModule(); + mockPrisma.statsPlayground.findFirst.mockResolvedValue(null); + + const req = createMockRequest('DELETE', { + url: 'http://localhost:3000/api/playground', + searchParams: { id: PG_ID }, + }); + + const result = await callHandler(DELETE, req); + expectError(result, 404, 'NOT_FOUND'); + }); +}); + +// --------------------------------------------------------------------------- +// POST /api/playground/favorite +// --------------------------------------------------------------------------- + +describe('POST /api/playground/favorite', () => { + beforeEach(() => mockAuthSession(session)); + + it('favorites a playground', async () => { + const { POST } = await favoriteModule(); + mockPrisma.statsPlayground.findFirst.mockResolvedValue(PLAYGROUND); + mockPrisma.statsPlaygroundFavorite.findUnique.mockResolvedValue(null); + mockPrisma.statsPlaygroundFavorite.create.mockResolvedValue({}); + mockPrisma.statsPlaygroundFavorite.count.mockResolvedValue(3); + + const req = createMockRequest('POST', { + url: 'http://localhost:3000/api/playground/favorite', + body: { playgroundId: PG_ID }, + }); + + const result = await callHandler(POST, req); + // Route returns 201 for newly created favorites + const data = expectSuccess(result, 201); + expect(data.favorite_count).toBe(3); + }); + + it('returns 409 when already favorited', async () => { + const { POST } = await favoriteModule(); + mockPrisma.statsPlayground.findFirst.mockResolvedValue(PLAYGROUND); + mockPrisma.statsPlaygroundFavorite.findUnique.mockResolvedValue({ id: 'fav-1' }); + + const req = createMockRequest('POST', { + url: 'http://localhost:3000/api/playground/favorite', + body: { playgroundId: PG_ID }, + }); + + const result = await callHandler(POST, req); + expectError(result, 409, 'CONFLICT'); + }); + + it('returns 404 when playground not found', async () => { + const { POST } = await favoriteModule(); + mockPrisma.statsPlayground.findFirst.mockResolvedValue(null); + + const req = createMockRequest('POST', { + url: 'http://localhost:3000/api/playground/favorite', + body: { playgroundId: '00000000-0000-4000-8000-000000000099' }, + }); + + const result = await callHandler(POST, req); + expectError(result, 404, 'NOT_FOUND'); + }); +}); + +// --------------------------------------------------------------------------- +// DELETE /api/playground/favorite +// --------------------------------------------------------------------------- + +describe('DELETE /api/playground/favorite', () => { + beforeEach(() => mockAuthSession(session)); + + it('unfavorites a playground', async () => { + const { DELETE } = await favoriteModule(); + mockPrisma.statsPlaygroundFavorite.deleteMany.mockResolvedValue({ count: 1 }); + mockPrisma.statsPlaygroundFavorite.count.mockResolvedValue(1); + + const req = createMockRequest('DELETE', { + url: 'http://localhost:3000/api/playground/favorite', + searchParams: { playgroundId: PG_ID }, + }); + + const result = await callHandler(DELETE, req); + const data = expectSuccess(result); + expect(data.favorite_count).toBe(1); + }); +}); + +// --------------------------------------------------------------------------- +// Security: ownership enforcement (user A cannot modify user B's playground) +// --------------------------------------------------------------------------- + +describe('Security: ownership enforcement', () => { + beforeEach(() => mockAuthSession(session)); + + it('user A cannot update user B playground via PUT (assertOwnership)', async () => { + const { PUT } = await routeModule(); + // findFirst returns null because user_id does not match + mockPrisma.statsPlayground.findFirst.mockResolvedValue(null); + + const req = createMockRequest('PUT', { + url: 'http://localhost:3000/api/playground', + body: { id: '00000000-0000-4000-8000-000000000099', name: 'Stolen Name' }, + }); + + const result = await callHandler(PUT, req); + expectError(result, 404, 'NOT_FOUND'); + // update should never have been called + expect(mockPrisma.statsPlayground.update).not.toHaveBeenCalled(); + }); + + it('user A cannot delete user B playground via DELETE (assertOwnership)', async () => { + const { DELETE } = await routeModule(); + // findFirst returns null because user_id does not match + mockPrisma.statsPlayground.findFirst.mockResolvedValue(null); + + const req = createMockRequest('DELETE', { + url: 'http://localhost:3000/api/playground', + searchParams: { id: '00000000-0000-4000-8000-000000000099' }, + }); + + const result = await callHandler(DELETE, req); + expectError(result, 404, 'NOT_FOUND'); + // delete should never have been called + expect(mockPrisma.statsPlayground.delete).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/api/profile/profile.test.ts b/tests/api/profile/profile.test.ts new file mode 100644 index 00000000000..fc03d737452 --- /dev/null +++ b/tests/api/profile/profile.test.ts @@ -0,0 +1,289 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { + createMockRequest, + callHandler, + expectSuccess, + expectError, +} from '@/tests/api/helpers/api-test-utils'; +import { setupPrismaMock, resetPrismaMock } from '@/tests/api/helpers/mock-prisma'; +import { + createMockSession, + mockAuthSession, + resetAuthMocks, +} from '@/tests/api/helpers/mock-session'; + +vi.mock('@/prisma/prisma'); +vi.mock('@/lib/auth/authSession'); + +vi.mock('@/server/services/profile', () => ({ + getProfile: vi.fn(), + updateProfile: vi.fn(), +})); + +vi.mock('@/server/services/profile/profile.service', () => ({ + getExtendedProfile: vi.fn(), + updateExtendedProfile: vi.fn(), + getPopularSkills: vi.fn(), + ProfileValidationError: class extends Error { + statusCode: number; + constructor(message: string, statusCode: number) { + super(message); + this.statusCode = statusCode; + } + }, +})); + +const mockPrisma = setupPrismaMock(); + +afterEach(() => { + resetPrismaMock(mockPrisma); + resetAuthMocks(); +}); + +// --------------------------------------------------------------------------- +// GET /api/profile/[id] +// --------------------------------------------------------------------------- +describe('GET /api/profile/[id]', () => { + const session = createMockSession({ id: 'user-123' }); + + beforeEach(() => { + mockAuthSession(session); + mockPrisma.apiRateLimitLog.count.mockResolvedValue(0); + mockPrisma.apiRateLimitLog.create.mockResolvedValue({}); + }); + + it('returns profile for own user', async () => { + const mockProfile = { id: 'user-123', name: 'Test User', email: 'test@example.com' }; + const { getProfile } = await import('@/server/services/profile'); + (getProfile as ReturnType).mockResolvedValue(mockProfile); + + const { GET } = await import('@/app/api/profile/[id]/route'); + const req = createMockRequest('GET', { + url: 'http://localhost:3000/api/profile/user-123', + }); + + const result = await callHandler(GET, req, { id: 'user-123' }); + const data = expectSuccess(result); + expect(data).toEqual(mockProfile); + }); + + it('rejects access to another user profile', async () => { + const { GET } = await import('@/app/api/profile/[id]/route'); + const req = createMockRequest('GET', { + url: 'http://localhost:3000/api/profile/other-user', + }); + + const result = await callHandler(GET, req, { id: 'other-user' }); + expectError(result, 403, 'FORBIDDEN'); + }); + + it('rejects unauthenticated requests', async () => { + mockAuthSession(null); + + const { GET } = await import('@/app/api/profile/[id]/route'); + const req = createMockRequest('GET', { + url: 'http://localhost:3000/api/profile/user-123', + }); + + const result = await callHandler(GET, req, { id: 'user-123' }); + expectError(result, 401, 'AUTH_REQUIRED'); + }); +}); + +// --------------------------------------------------------------------------- +// PUT /api/profile/[id] +// --------------------------------------------------------------------------- +describe('PUT /api/profile/[id]', () => { + const session = createMockSession({ id: 'user-123' }); + + beforeEach(() => { + mockAuthSession(session); + mockPrisma.apiRateLimitLog.count.mockResolvedValue(0); + mockPrisma.apiRateLimitLog.create.mockResolvedValue({}); + }); + + it('updates own profile', async () => { + const updatedProfile = { id: 'user-123', name: 'Updated Name' }; + const { updateProfile } = await import('@/server/services/profile'); + (updateProfile as ReturnType).mockResolvedValue(updatedProfile); + + const { PUT } = await import('@/app/api/profile/[id]/route'); + const req = createMockRequest('PUT', { + body: { name: 'Updated Name' }, + url: 'http://localhost:3000/api/profile/user-123', + }); + + const result = await callHandler(PUT, req, { id: 'user-123' }); + const data = expectSuccess(result); + expect(data).toEqual(updatedProfile); + }); + + it('rejects update to another user profile', async () => { + const { PUT } = await import('@/app/api/profile/[id]/route'); + const req = createMockRequest('PUT', { + body: { name: 'Hacked' }, + url: 'http://localhost:3000/api/profile/other-user', + }); + + const result = await callHandler(PUT, req, { id: 'other-user' }); + expectError(result, 403, 'FORBIDDEN'); + }); + + it('rejects unauthenticated updates', async () => { + mockAuthSession(null); + + const { PUT } = await import('@/app/api/profile/[id]/route'); + const req = createMockRequest('PUT', { + body: { name: 'Should fail' }, + url: 'http://localhost:3000/api/profile/user-123', + }); + + const result = await callHandler(PUT, req, { id: 'user-123' }); + expectError(result, 401, 'AUTH_REQUIRED'); + }); +}); + +// --------------------------------------------------------------------------- +// GET /api/profile/extended/[id] +// --------------------------------------------------------------------------- +describe('GET /api/profile/extended/[id]', () => { + const session = createMockSession({ id: 'user-123' }); + + beforeEach(() => { + mockAuthSession(session); + mockPrisma.apiRateLimitLog.count.mockResolvedValue(0); + mockPrisma.apiRateLimitLog.create.mockResolvedValue({}); + }); + + it('returns extended profile for own user', async () => { + const mockExtended = { id: 'user-123', skills: ['Solidity', 'TypeScript'] }; + const { getExtendedProfile } = await import('@/server/services/profile/profile.service'); + (getExtendedProfile as ReturnType).mockResolvedValue(mockExtended); + + const { GET } = await import('@/app/api/profile/extended/[id]/route'); + const req = createMockRequest('GET', { + url: 'http://localhost:3000/api/profile/extended/user-123', + }); + + const result = await callHandler(GET, req, { id: 'user-123' }); + const data = expectSuccess(result); + expect(data).toEqual(mockExtended); + }); + + it('returns 404 when profile does not exist', async () => { + const { getExtendedProfile } = await import('@/server/services/profile/profile.service'); + (getExtendedProfile as ReturnType).mockResolvedValue(null); + + const { GET } = await import('@/app/api/profile/extended/[id]/route'); + const req = createMockRequest('GET', { + url: 'http://localhost:3000/api/profile/extended/user-123', + }); + + const result = await callHandler(GET, req, { id: 'user-123' }); + expectError(result, 404, 'NOT_FOUND'); + }); + + it('rejects access to another user extended profile', async () => { + const { GET } = await import('@/app/api/profile/extended/[id]/route'); + const req = createMockRequest('GET', { + url: 'http://localhost:3000/api/profile/extended/other-user', + }); + + const result = await callHandler(GET, req, { id: 'other-user' }); + expectError(result, 403, 'FORBIDDEN'); + }); +}); + +// --------------------------------------------------------------------------- +// PUT /api/profile/extended/[id] +// --------------------------------------------------------------------------- +describe('PUT /api/profile/extended/[id]', () => { + const session = createMockSession({ id: 'user-123' }); + + beforeEach(() => { + mockAuthSession(session); + mockPrisma.apiRateLimitLog.count.mockResolvedValue(0); + mockPrisma.apiRateLimitLog.create.mockResolvedValue({}); + }); + + it('updates own extended profile', async () => { + const updated = { id: 'user-123', skills: ['Solidity', 'Foundry'] }; + const { updateExtendedProfile } = await import('@/server/services/profile/profile.service'); + (updateExtendedProfile as ReturnType).mockResolvedValue(updated); + + const { PUT } = await import('@/app/api/profile/extended/[id]/route'); + const req = createMockRequest('PUT', { + body: { skills: ['Solidity', 'Foundry'] }, + url: 'http://localhost:3000/api/profile/extended/user-123', + }); + + const result = await callHandler(PUT, req, { id: 'user-123' }); + const data = expectSuccess(result); + expect(data).toEqual(updated); + }); + + it('rejects update to another user extended profile', async () => { + const { PUT } = await import('@/app/api/profile/extended/[id]/route'); + const req = createMockRequest('PUT', { + body: { skills: ['Hacked'] }, + url: 'http://localhost:3000/api/profile/extended/other-user', + }); + + const result = await callHandler(PUT, req, { id: 'other-user' }); + expectError(result, 403, 'FORBIDDEN'); + }); +}); + +// --------------------------------------------------------------------------- +// GET /api/profile/popular-skills +// --------------------------------------------------------------------------- +describe('GET /api/profile/popular-skills', () => { + const session = createMockSession(); + + beforeEach(() => { + mockAuthSession(session); + mockPrisma.apiRateLimitLog.count.mockResolvedValue(0); + mockPrisma.apiRateLimitLog.create.mockResolvedValue({}); + }); + + it('returns popular skills list', async () => { + const skills = ['Solidity', 'TypeScript', 'Rust']; + const { getPopularSkills } = await import('@/server/services/profile/profile.service'); + (getPopularSkills as ReturnType).mockResolvedValue(skills); + + const { GET } = await import('@/app/api/profile/popular-skills/route'); + const req = createMockRequest('GET', { + url: 'http://localhost:3000/api/profile/popular-skills', + }); + + const result = await callHandler(GET, req); + const data = expectSuccess(result); + expect(data).toEqual(skills); + }); + + it('includes no-cache headers', async () => { + const { getPopularSkills } = await import('@/server/services/profile/profile.service'); + (getPopularSkills as ReturnType).mockResolvedValue([]); + + const { GET } = await import('@/app/api/profile/popular-skills/route'); + const req = createMockRequest('GET', { + url: 'http://localhost:3000/api/profile/popular-skills', + }); + + const result = await callHandler(GET, req); + expect(result.headers.get('cache-control')).toContain('no-store'); + expect(result.headers.get('pragma')).toBe('no-cache'); + }); + + it('rejects unauthenticated requests', async () => { + mockAuthSession(null); + + const { GET } = await import('@/app/api/profile/popular-skills/route'); + const req = createMockRequest('GET', { + url: 'http://localhost:3000/api/profile/popular-skills', + }); + + const result = await callHandler(GET, req); + expectError(result, 401, 'AUTH_REQUIRED'); + }); +}); diff --git a/tests/api/projects/projects.test.ts b/tests/api/projects/projects.test.ts new file mode 100644 index 00000000000..9643b6c8fbb --- /dev/null +++ b/tests/api/projects/projects.test.ts @@ -0,0 +1,613 @@ +import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { + createMockRequest, + callHandler, + expectSuccess, + expectPaginated, + expectError, +} from '@/tests/api/helpers/api-test-utils'; +import { + createMockSession, + createAdminSession, + mockAuthSession, + resetAuthMocks, +} from '@/tests/api/helpers/mock-session'; +import { setupPrismaMock, resetPrismaMock } from '@/tests/api/helpers/mock-prisma'; + +// --------------------------------------------------------------------------- +// Module-level mocks (hoisted by vitest) +// --------------------------------------------------------------------------- +vi.mock('@/lib/auth/authSession'); +vi.mock('@/prisma/prisma'); + +// Mock service modules +vi.mock('@/server/services/projects', () => ({ + getFilteredProjects: vi.fn(), + createProject: vi.fn(), + updateProject: vi.fn(), + CheckInvitation: vi.fn(), + GetProjectByHackathonAndUser: vi.fn(), +})); + +vi.mock('@/server/services/memberProject', () => ({ + GetProjectByIdWithMembers: vi.fn(), + GetMembersByProjectId: vi.fn(), + UpdateRoleMember: vi.fn(), + UpdateStatusMember: vi.fn(), + GetProjectsByUserId: vi.fn(), +})); + +vi.mock('@/server/services/fileValidation', () => ({ + isUserProjectMember: vi.fn(), +})); + +vi.mock('@/server/services/submitProject', () => ({ + createProject: vi.fn(), +})); + +vi.mock('@/server/services/inviteProjectMember', () => ({ + generateInvitation: vi.fn(), +})); + +vi.mock('@/server/services/set-project-winner', () => ({ + SetWinner: vi.fn(), +})); + +vi.mock('@/server/services/exportShowcase', () => ({ + exportShowcase: vi.fn(), +})); + +// --------------------------------------------------------------------------- +// Import mocked services +// --------------------------------------------------------------------------- +import { + getFilteredProjects, + createProject as createProjectService, + updateProject, + CheckInvitation, +} from '@/server/services/projects'; +import { + GetProjectByIdWithMembers, + GetMembersByProjectId, + UpdateRoleMember, + UpdateStatusMember, +} from '@/server/services/memberProject'; +import { isUserProjectMember } from '@/server/services/fileValidation'; +import { createProject as submitProjectService } from '@/server/services/submitProject'; +import { generateInvitation } from '@/server/services/inviteProjectMember'; +import { SetWinner } from '@/server/services/set-project-winner'; +import { exportShowcase } from '@/server/services/exportShowcase'; + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- +const mockPrisma = setupPrismaMock(); +const userSession = createMockSession({ id: 'user-1', email: 'dev@example.com' }); +const adminSession = createAdminSession(); + +const sampleProject = { + id: 'proj-1', + project_name: 'Test Project', + short_description: 'A test', + hackaton_id: 'hack-1', + members: [{ user_id: 'user-1', role: 'Member', status: 'Confirmed' }], +}; + +// --------------------------------------------------------------------------- +// Test suites +// --------------------------------------------------------------------------- +describe('API /api/projects', () => { + beforeEach(() => { + mockAuthSession(userSession); + }); + + afterEach(() => { + resetAuthMocks(); + resetPrismaMock(mockPrisma); + }); + + // ------------------------------------------------------------------------- + // GET /api/projects -- list with pagination + // ------------------------------------------------------------------------- + describe('GET /api/projects', () => { + it('returns paginated projects', async () => { + const { GET } = await import('@/app/api/projects/route'); + vi.mocked(getFilteredProjects).mockResolvedValue({ + projects: [sampleProject as any], + total: 1, + page: 1, + pageSize: 12, + }); + + const req = createMockRequest('GET', { + url: 'http://localhost:3000/api/projects?page=1&pageSize=12', + }); + const result = await callHandler(GET, req); + + const { data, pagination } = expectPaginated(result); + expect(data).toHaveLength(1); + expect(pagination.total).toBe(1); + expect(pagination.pageSize).toBe(12); + }); + + it('caps pageSize at MAX_PAGE_SIZE (100)', async () => { + const { GET } = await import('@/app/api/projects/route'); + vi.mocked(getFilteredProjects).mockResolvedValue({ + projects: [], + total: 0, + page: 1, + pageSize: 100, + }); + + const req = createMockRequest('GET', { + url: 'http://localhost:3000/api/projects?page=1&pageSize=9999', + }); + const result = await callHandler(GET, req); + + // parsePagination clamps to 100 + expect(vi.mocked(getFilteredProjects)).toHaveBeenCalledWith( + expect.objectContaining({ pageSize: 100 }), + ); + expect(result.status).toBe(200); + }); + + it('returns 401 when unauthenticated', async () => { + const { GET } = await import('@/app/api/projects/route'); + mockAuthSession(null); + + const req = createMockRequest('GET', { + url: 'http://localhost:3000/api/projects', + }); + const result = await callHandler(GET, req); + expectError(result, 401, 'AUTH_REQUIRED'); + }); + }); + + // ------------------------------------------------------------------------- + // POST /api/projects -- create + // ------------------------------------------------------------------------- + describe('POST /api/projects', () => { + it('creates a project and auto-adds user as member', async () => { + const { POST } = await import('@/app/api/projects/route'); + vi.mocked(createProjectService).mockResolvedValue(sampleProject as any); + + const req = createMockRequest('POST', { + url: 'http://localhost:3000/api/projects', + body: { project_name: 'New Project', short_description: 'desc' }, + }); + const result = await callHandler(POST, req); + + const data = expectSuccess(result, 201); + expect(data.project_name).toBe('Test Project'); + + // Verify the user was auto-added as member + const callArgs = vi.mocked(createProjectService).mock.calls[0][0]; + expect(callArgs.members).toEqual( + expect.arrayContaining([ + expect.objectContaining({ user_id: 'user-1', status: 'Confirmed' }), + ]), + ); + }); + + it('returns 401 when unauthenticated', async () => { + const { POST } = await import('@/app/api/projects/route'); + mockAuthSession(null); + + const req = createMockRequest('POST', { + url: 'http://localhost:3000/api/projects', + body: { project_name: 'X' }, + }); + const result = await callHandler(POST, req); + expectError(result, 401, 'AUTH_REQUIRED'); + }); + }); + + // ------------------------------------------------------------------------- + // GET /api/projects/[id] -- single project + // ------------------------------------------------------------------------- + describe('GET /api/projects/[id]', () => { + it('returns project when user is a member', async () => { + const { GET } = await import('@/app/api/projects/[id]/route'); + vi.mocked(isUserProjectMember).mockResolvedValue(true); + vi.mocked(GetProjectByIdWithMembers).mockResolvedValue(sampleProject as any); + + const req = createMockRequest('GET', { + url: 'http://localhost:3000/api/projects/proj-1', + }); + const result = await callHandler(GET, req, { id: 'proj-1' }); + const data = expectSuccess(result); + expect(data.id).toBe('proj-1'); + }); + + it('returns 403 when user is not a member', async () => { + const { GET } = await import('@/app/api/projects/[id]/route'); + vi.mocked(isUserProjectMember).mockResolvedValue(false); + + const req = createMockRequest('GET', { + url: 'http://localhost:3000/api/projects/proj-1', + }); + const result = await callHandler(GET, req, { id: 'proj-1' }); + expectError(result, 403, 'FORBIDDEN'); + }); + + it('returns 404 when project does not exist', async () => { + const { GET } = await import('@/app/api/projects/[id]/route'); + vi.mocked(isUserProjectMember).mockResolvedValue(true); + vi.mocked(GetProjectByIdWithMembers).mockResolvedValue(null); + + const req = createMockRequest('GET', { + url: 'http://localhost:3000/api/projects/missing', + }); + const result = await callHandler(GET, req, { id: 'missing' }); + expectError(result, 404, 'NOT_FOUND'); + }); + }); + + // ------------------------------------------------------------------------- + // PUT /api/projects/[id] -- update with mass-assignment prevention + // ------------------------------------------------------------------------- + describe('PUT /api/projects/[id]', () => { + it('updates only allowed fields', async () => { + const { PUT } = await import('@/app/api/projects/[id]/route'); + vi.mocked(isUserProjectMember).mockResolvedValue(true); + vi.mocked(updateProject).mockResolvedValue(sampleProject as any); + + const req = createMockRequest('PUT', { + url: 'http://localhost:3000/api/projects/proj-1', + body: { + project_name: 'Updated Name', + short_description: 'Updated desc', + }, + }); + const result = await callHandler(PUT, req, { id: 'proj-1' }); + expectSuccess(result); + + const updateArgs = vi.mocked(updateProject).mock.calls[0]; + expect(updateArgs[0]).toBe('proj-1'); + expect(updateArgs[1]).toEqual({ + project_name: 'Updated Name', + short_description: 'Updated desc', + }); + }); + + it('strips disallowed fields (mass assignment prevention)', async () => { + const { PUT } = await import('@/app/api/projects/[id]/route'); + vi.mocked(isUserProjectMember).mockResolvedValue(true); + vi.mocked(updateProject).mockResolvedValue(sampleProject as any); + + const req = createMockRequest('PUT', { + url: 'http://localhost:3000/api/projects/proj-1', + body: { + project_name: 'Safe Name', + is_winner: true, + hackaton_id: 'hacked', + created_at: '2000-01-01', + members: [{ user_id: 'attacker' }], + }, + }); + const result = await callHandler(PUT, req, { id: 'proj-1' }); + expectSuccess(result); + + const updateArgs = vi.mocked(updateProject).mock.calls[0]; + expect(updateArgs[1]).toEqual({ project_name: 'Safe Name' }); + expect(updateArgs[1]).not.toHaveProperty('is_winner'); + expect(updateArgs[1]).not.toHaveProperty('hackaton_id'); + expect(updateArgs[1]).not.toHaveProperty('created_at'); + expect(updateArgs[1]).not.toHaveProperty('members'); + }); + + it('returns 403 when user is not a member', async () => { + const { PUT } = await import('@/app/api/projects/[id]/route'); + vi.mocked(isUserProjectMember).mockResolvedValue(false); + + const req = createMockRequest('PUT', { + url: 'http://localhost:3000/api/projects/proj-1', + body: { project_name: 'X' }, + }); + const result = await callHandler(PUT, req, { id: 'proj-1' }); + expectError(result, 403, 'FORBIDDEN'); + }); + }); + + // ------------------------------------------------------------------------- + // GET /api/projects/[id]/members + // ------------------------------------------------------------------------- + describe('GET /api/projects/[id]/members', () => { + it('returns members when user is a project member', async () => { + const { GET } = await import('@/app/api/projects/[id]/members/route'); + vi.mocked(isUserProjectMember).mockResolvedValue(true); + vi.mocked(GetMembersByProjectId).mockResolvedValue([ + { id: 'm-1', user_id: 'user-1', name: 'Dev', email: 'dev@example.com', role: 'Member', status: 'Confirmed' }, + ] as any); + + const req = createMockRequest('GET', { + url: 'http://localhost:3000/api/projects/proj-1/members', + }); + const result = await callHandler(GET, req, { id: 'proj-1' }); + const data = expectSuccess(result); + expect(data).toHaveLength(1); + }); + + it('returns 403 when user is not a member', async () => { + const { GET } = await import('@/app/api/projects/[id]/members/route'); + vi.mocked(isUserProjectMember).mockResolvedValue(false); + + const req = createMockRequest('GET', { + url: 'http://localhost:3000/api/projects/proj-1/members', + }); + const result = await callHandler(GET, req, { id: 'proj-1' }); + expectError(result, 403, 'FORBIDDEN'); + }); + }); + + // ------------------------------------------------------------------------- + // PATCH /api/projects/[id]/members -- role update + // ------------------------------------------------------------------------- + describe('PATCH /api/projects/[id]/members', () => { + it('updates member role', async () => { + const { PATCH } = await import('@/app/api/projects/[id]/members/route'); + vi.mocked(isUserProjectMember).mockResolvedValue(true); + vi.mocked(UpdateRoleMember).mockResolvedValue({ id: 'm-1', role: 'Lead' } as any); + + const req = createMockRequest('PATCH', { + url: 'http://localhost:3000/api/projects/proj-1/members', + body: { member_id: 'm-1', role: 'Lead' }, + }); + const result = await callHandler(PATCH, req, { id: 'proj-1' }); + const data = expectSuccess(result); + expect(data.role).toBe('Lead'); + }); + + it('returns 400 when member_id or role is missing', async () => { + const { PATCH } = await import('@/app/api/projects/[id]/members/route'); + vi.mocked(isUserProjectMember).mockResolvedValue(true); + + const req = createMockRequest('PATCH', { + url: 'http://localhost:3000/api/projects/proj-1/members', + body: { member_id: 'm-1' }, + }); + const result = await callHandler(PATCH, req, { id: 'proj-1' }); + expectError(result, 400, 'BAD_REQUEST'); + }); + }); + + // ------------------------------------------------------------------------- + // PATCH /api/projects/[id]/members/status + // ------------------------------------------------------------------------- + describe('PATCH /api/projects/[id]/members/status', () => { + it('updates member status for own user', async () => { + const { PATCH } = await import('@/app/api/projects/[id]/members/status/route'); + vi.mocked(isUserProjectMember).mockResolvedValue(true); + vi.mocked(UpdateStatusMember).mockResolvedValue({ id: 'm-1', status: 'Confirmed' } as any); + + const req = createMockRequest('PATCH', { + url: 'http://localhost:3000/api/projects/proj-1/members/status', + body: { user_id: 'user-1', status: 'Confirmed', wasInOtherProject: false }, + }); + const result = await callHandler(PATCH, req, { id: 'proj-1' }); + expectSuccess(result); + }); + + it('returns 403 when updating another user status', async () => { + const { PATCH } = await import('@/app/api/projects/[id]/members/status/route'); + vi.mocked(isUserProjectMember).mockResolvedValue(true); + + const req = createMockRequest('PATCH', { + url: 'http://localhost:3000/api/projects/proj-1/members/status', + body: { user_id: 'other-user', status: 'Confirmed', wasInOtherProject: false }, + }); + const result = await callHandler(PATCH, req, { id: 'proj-1' }); + expectError(result, 403, 'FORBIDDEN'); + }); + }); + + // ------------------------------------------------------------------------- + // GET /api/projects/check-invitation + // ------------------------------------------------------------------------- + describe('GET /api/projects/check-invitation', () => { + it('returns invitation data', async () => { + const { GET } = await import('@/app/api/projects/check-invitation/route'); + vi.mocked(CheckInvitation).mockResolvedValue({ + invitation: { isValid: true, isConfirming: true, exists: true, hasConfirmedProject: false }, + project: { project_id: 'proj-1', project_name: 'Test', confirmed_project_name: '', hackathon_id: 'h-1' }, + } as any); + + const req = createMockRequest('GET', { + url: 'http://localhost:3000/api/projects/check-invitation?invitation=inv-1', + }); + const result = await callHandler(GET, req); + const data = expectSuccess(result); + expect(data.invitation.exists).toBe(true); + }); + + it('returns 400 when invitation param is missing', async () => { + const { GET } = await import('@/app/api/projects/check-invitation/route'); + const req = createMockRequest('GET', { + url: 'http://localhost:3000/api/projects/check-invitation', + }); + const result = await callHandler(GET, req); + expectError(result, 400, 'BAD_REQUEST'); + }); + + it('returns 403 when user_id does not match session', async () => { + const { GET } = await import('@/app/api/projects/check-invitation/route'); + const req = createMockRequest('GET', { + url: 'http://localhost:3000/api/projects/check-invitation?invitation=inv-1&user_id=other', + }); + const result = await callHandler(GET, req); + expectError(result, 403, 'FORBIDDEN'); + }); + }); + + // ------------------------------------------------------------------------- + // PUT /api/projects/set-winner -- role-based access + // ------------------------------------------------------------------------- + describe('PUT /api/projects/set-winner', () => { + it('sets winner when user has badge_admin role', async () => { + const { PUT } = await import('@/app/api/projects/set-winner/route'); + mockAuthSession(adminSession); + vi.mocked(SetWinner).mockResolvedValue({ success: true } as any); + + const req = createMockRequest('PUT', { + url: 'http://localhost:3000/api/projects/set-winner', + body: { project_id: 'proj-1', isWinner: true }, + }); + const result = await callHandler(PUT, req); + expectSuccess(result); + }); + + it('returns 403 when user lacks badge_admin role', async () => { + const { PUT } = await import('@/app/api/projects/set-winner/route'); + // userSession has no roles + const req = createMockRequest('PUT', { + url: 'http://localhost:3000/api/projects/set-winner', + body: { project_id: 'proj-1', isWinner: true }, + }); + const result = await callHandler(PUT, req); + expectError(result, 403, 'FORBIDDEN'); + }); + + it('returns 400 when project_id is missing', async () => { + const { PUT } = await import('@/app/api/projects/set-winner/route'); + mockAuthSession(adminSession); + + const req = createMockRequest('PUT', { + url: 'http://localhost:3000/api/projects/set-winner', + body: { isWinner: true }, + }); + const result = await callHandler(PUT, req); + expectError(result, 400, 'BAD_REQUEST'); + }); + }); + + // ------------------------------------------------------------------------- + // POST /api/projects/export -- role-based, no stack trace leaks + // ------------------------------------------------------------------------- + describe('POST /api/projects/export', () => { + it('returns 403 when user lacks devrel role', async () => { + const { POST } = await import('@/app/api/projects/export/route'); + // userSession has no roles + const req = createMockRequest('POST', { + url: 'http://localhost:3000/api/projects/export', + body: { hackathon_id: 'h-1' }, + }); + const result = await callHandler(POST, req); + expectError(result, 403, 'FORBIDDEN'); + }); + + it('exports when user has devrel role', async () => { + const { POST } = await import('@/app/api/projects/export/route'); + mockAuthSession(adminSession); + const fakeBuffer = Buffer.from('xlsx-data'); + vi.mocked(exportShowcase).mockResolvedValue(fakeBuffer); + + const req = createMockRequest('POST', { + url: 'http://localhost:3000/api/projects/export', + body: { hackathon_id: 'h-1' }, + }); + const result = await callHandler(POST, req); + // The export route returns raw buffer, not JSON envelope + expect(result.status).toBe(200); + }); + + it('never exposes stack traces in error responses', async () => { + const { POST } = await import('@/app/api/projects/export/route'); + mockAuthSession(adminSession); + vi.mocked(exportShowcase).mockRejectedValue(new Error('DB connection failed')); + + const req = createMockRequest('POST', { + url: 'http://localhost:3000/api/projects/export', + body: {}, + }); + const result = await callHandler(POST, req); + expect(result.status).toBe(500); + // Verify no stack trace in error body + const body = JSON.stringify(result.body); + expect(body).not.toContain('at '); + expect(body).not.toContain('.ts:'); + expect(body).not.toContain('stack'); + }); + }); + + // ------------------------------------------------------------------------- + // POST /api/projects/invite-member + // ------------------------------------------------------------------------- + describe('POST /api/projects/invite-member', () => { + it('sends invitations when user is a project member', async () => { + const { POST } = await import('@/app/api/projects/invite-member/route'); + vi.mocked(isUserProjectMember).mockResolvedValue(true); + vi.mocked(generateInvitation).mockResolvedValue({ Success: true } as any); + + const req = createMockRequest('POST', { + url: 'http://localhost:3000/api/projects/invite-member', + body: { + hackathon_id: 'h-1', + project_id: 'proj-1', + emails: ['friend@example.com'], + }, + }); + const result = await callHandler(POST, req); + expectSuccess(result); + }); + + it('returns 403 when user is not a project member', async () => { + const { POST } = await import('@/app/api/projects/invite-member/route'); + vi.mocked(isUserProjectMember).mockResolvedValue(false); + + const req = createMockRequest('POST', { + url: 'http://localhost:3000/api/projects/invite-member', + body: { + hackathon_id: 'h-1', + project_id: 'proj-1', + emails: ['friend@example.com'], + }, + }); + const result = await callHandler(POST, req); + expectError(result, 403, 'FORBIDDEN'); + }); + }); + + // ------------------------------------------------------------------------- + // POST /api/projects/submit + // ------------------------------------------------------------------------- + describe('POST /api/projects/submit', () => { + it('creates a project via submit service', async () => { + const { POST } = await import('@/app/api/projects/submit/route'); + vi.mocked(submitProjectService).mockResolvedValue(sampleProject as any); + + const req = createMockRequest('POST', { + url: 'http://localhost:3000/api/projects/submit', + body: { project_name: 'New Submission', short_description: 'desc' }, + }); + const result = await callHandler(POST, req); + expectSuccess(result, 201); + }); + }); + + // ------------------------------------------------------------------------- + // GET /api/projects/submit + // ------------------------------------------------------------------------- + describe('GET /api/projects/submit', () => { + it('returns project by hackathon and user', async () => { + const { GET } = await import('@/app/api/projects/submit/route'); + const { GetProjectByHackathonAndUser } = await import('@/server/services/projects'); + vi.mocked(GetProjectByHackathonAndUser).mockResolvedValue(sampleProject as any); + + const req = createMockRequest('GET', { + url: 'http://localhost:3000/api/projects/submit?hackathon_id=h-1&user_id=user-1', + }); + const result = await callHandler(GET, req); + const data = expectSuccess(result); + expect(data.project).toBeTruthy(); + }); + + it('returns 403 when user_id does not match session', async () => { + const { GET } = await import('@/app/api/projects/submit/route'); + const req = createMockRequest('GET', { + url: 'http://localhost:3000/api/projects/submit?hackathon_id=h-1&user_id=other-user', + }); + const result = await callHandler(GET, req); + expectError(result, 403, 'FORBIDDEN'); + }); + }); +}); diff --git a/tests/api/stats/stats.test.ts b/tests/api/stats/stats.test.ts new file mode 100644 index 00000000000..32acdd4c621 --- /dev/null +++ b/tests/api/stats/stats.test.ts @@ -0,0 +1,300 @@ +/** + * Tests for migrated stats / read-only API routes. + * + * These routes are data-passthrough so we focus on: + * - param validation (chainId format, required query params) + * - response envelope shape ({ success, data }) + * - error codes for bad inputs + */ + +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import { + createMockRequest, + callHandler, + expectSuccess, + expectError, +} from '@/tests/api/helpers/api-test-utils'; +import { mockAuthSession, resetAuthMocks } from '@/tests/api/helpers/mock-session'; + +// Module-level mocks (hoisted by vitest) +vi.mock('@/lib/auth/authSession'); +vi.mock('@/prisma/prisma'); + +// Mock external SDK / data sources so we never hit the network +vi.mock('@avalanche-sdk/chainkit', () => ({ + Avalanche: vi.fn().mockImplementation(() => ({ + metrics: { networks: { getStakingMetrics: vi.fn().mockResolvedValue([]) } }, + data: { + primaryNetwork: { + getNetworkDetails: vi.fn().mockResolvedValue({}), + listValidators: vi.fn().mockResolvedValue({ [Symbol.asyncIterator]: async function* () {} }), + listL1Validators: vi.fn().mockResolvedValue({ [Symbol.asyncIterator]: async function* () {} }), + listSubnets: vi.fn().mockResolvedValue({ [Symbol.asyncIterator]: async function* () {} }), + }, + }, + })), +})); + +vi.mock('@avalanche-sdk/client', () => ({ + createPChainClient: vi.fn().mockReturnValue({ + getCurrentSupply: vi.fn().mockResolvedValue({ supply: BigInt(400_000_000) * BigInt(1_000_000_000) }), + }), +})); + +vi.mock('@avalanche-sdk/client/chains', () => ({ + avalanche: {}, +})); + +vi.mock('@/lib/icm-clickhouse', () => { + const mockFlowData: any[] = []; + return { + getICMStatsData: vi.fn().mockResolvedValue({ aggregatedData: [], icmDataPoints: [] }), + getICMContractFeesData: vi.fn().mockResolvedValue({ dataSource: 'mock', lastUpdated: new Date().toISOString(), fees: [] }), + getICMFlowData: vi.fn().mockImplementation(async () => mockFlowData), + getChainICMData: vi.fn().mockResolvedValue([]), + getChainICMCount: vi.fn().mockResolvedValue(0), + }; +}); + +vi.mock('@/lib/clickhouse', () => ({ + queryClickHouse: vi.fn().mockResolvedValue({ data: [], meta: [], rows: 0, statistics: { elapsed: 0, rows_read: 0, bytes_read: 0 } }), + C_CHAIN_ID: 43114, + buildSwapPricesCTE: vi.fn().mockReturnValue('swap_prices AS (SELECT 0 as price_usd, now() as price_hour)'), + getTotalChainGas: vi.fn().mockResolvedValue({ totalGas: 0, totalTx: 0, totalBurned: 0 }), + buildContractGasReceivedQuery: vi.fn().mockReturnValue('SELECT 1'), + buildContractGasGivenQuery: vi.fn().mockReturnValue('SELECT 1'), + buildContractTxSummaryQuery: vi.fn().mockReturnValue('SELECT 1'), +})); + +vi.mock('@/lib/contracts', () => ({ + CONTRACT_REGISTRY: {}, + PROTOCOL_SLUGS: {}, +})); + +vi.mock('@/lib/source', () => ({ + blog: { + getPages: vi.fn().mockReturnValue([ + { + data: { title: 'Test Blog', description: 'A test', date: '2025-01-01' }, + url: '/blog/test', + }, + ]), + }, + documentation: { getPages: vi.fn().mockReturnValue([]), getPage: vi.fn().mockReturnValue(null) }, + academy: { getPages: vi.fn().mockReturnValue([]), getPage: vi.fn().mockReturnValue(null) }, + integration: { getPages: vi.fn().mockReturnValue([]), getPage: vi.fn().mockReturnValue(null) }, +})); + +vi.mock('@/lib/llm-utils', () => ({ + getLLMText: vi.fn().mockResolvedValue('# Mock content'), +})); + +vi.mock('@/constants/validator-discovery', () => ({ + MAINNET_VALIDATOR_DISCOVERY_URL: 'https://example.com/mainnet', + FUJI_VALIDATOR_DISCOVERY_URL: 'https://example.com/fuji', +})); + +beforeEach(() => { + // Public endpoints -- no auth required + mockAuthSession(null); +}); + +afterEach(() => { + resetAuthMocks(); +}); + +// --------------------------------------------------------------------------- +// latest-blogs +// --------------------------------------------------------------------------- + +describe('GET /api/latest-blogs', () => { + it('returns success envelope with blog data', async () => { + const { GET } = await import('@/app/api/latest-blogs/route'); + + const req = createMockRequest('GET', { + url: 'http://localhost:3000/api/latest-blogs', + }); + const result = await callHandler(GET, req); + const data = expectSuccess(result); + + expect(Array.isArray(data)).toBe(true); + expect(data[0]).toHaveProperty('title'); + expect(data[0]).toHaveProperty('url'); + }); +}); + +// --------------------------------------------------------------------------- +// raw/[...slug] -- path traversal protection +// --------------------------------------------------------------------------- + +describe('GET /api/raw/[...slug]', () => { + /** + * The raw route uses a catch-all [..slug] param (string[]), so we call + * the handler directly with the Next.js context shape. + */ + async function callRawHandler(slug: string[]) { + const { GET } = await import('@/app/api/raw/[...slug]/route'); + const req = createMockRequest('GET', { + url: 'http://localhost:3000/api/raw/' + slug.join('/'), + }); + const context = { params: Promise.resolve({ slug }) }; + const response: Response = await (GET as any)(req, context); + let body: any; + try { body = await response.json(); } catch { body = null; } + return { status: response.status, body, headers: response.headers }; + } + + it('rejects path traversal in slug', async () => { + const result = await callRawHandler(['docs', '..', '..', 'etc', 'passwd']); + expectError(result, 400, 'BAD_REQUEST'); + }); + + it('rejects empty slug', async () => { + const result = await callRawHandler([]); + expectError(result, 400, 'BAD_REQUEST'); + }); + + it('rejects invalid content type', async () => { + const result = await callRawHandler(['invalid']); + expectError(result, 400, 'BAD_REQUEST'); + }); +}); + +// --------------------------------------------------------------------------- +// calendar/google -- calendarId validation +// --------------------------------------------------------------------------- + +describe('GET /api/calendar/google', () => { + it('rejects missing calendarId', async () => { + const { GET } = await import('@/app/api/calendar/google/route'); + + const req = createMockRequest('GET', { + url: 'http://localhost:3000/api/calendar/google', + }); + const result = await callHandler(GET, req); + expectError(result, 400, 'VALIDATION_ERROR'); + }); + + it('rejects calendarId with disallowed characters', async () => { + const { GET } = await import('@/app/api/calendar/google/route'); + + const req = createMockRequest('GET', { + url: 'http://localhost:3000/api/calendar/google', + searchParams: { calendarId: 'evil