diff --git a/.env.example b/.env.example index 7c272df..db4bfde 100644 --- a/.env.example +++ b/.env.example @@ -16,3 +16,8 @@ GITHUB_SECRET=your_github_oauth_app_client_secret # 32-byte hex string for AES-256-GCM token encryption # Generate with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" ENCRYPTION_KEY= + +# Optional Upstash Redis cache for /api/metrics GitHub responses +# Metrics still work without these values; Redis reduces repeated GitHub API calls in production. +UPSTASH_REDIS_REST_URL= +UPSTASH_REDIS_REST_TOKEN= diff --git a/package-lock.json b/package-lock.json index 467c781..3153ec8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.2.0", "dependencies": { "@supabase/supabase-js": "^2.43.4", + "@upstash/redis": "^1.38.0", "clsx": "^2.1.1", "date-fns": "^3.6.0", "jspdf": "^4.2.1", @@ -746,7 +747,6 @@ "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -1179,13 +1179,21 @@ "win32" ] }, + "node_modules/@upstash/redis": { + "version": "1.38.0", + "resolved": "https://registry.npmjs.org/@upstash/redis/-/redis-1.38.0.tgz", + "integrity": "sha512-wu+dZBptlLy0+MCUEoHmzrY/TnmgDey3+c7EbIGwrLqAvkP8yi5MWZHYGIFtAygmL4Bkz2TdFu+eU0vFPncIcg==", + "license": "MIT", + "dependencies": { + "uncrypto": "^0.1.3" + } + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1638,7 +1646,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -2508,7 +2515,6 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -2677,7 +2683,6 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -4118,7 +4123,6 @@ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", - "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -4190,7 +4194,6 @@ "resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.2.1.tgz", "integrity": "sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.28.6", "fast-png": "^6.2.0", @@ -5033,7 +5036,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -5204,7 +5206,6 @@ "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.1.tgz", "integrity": "sha512-gQCLc/vWroE8lIpleXtdJhTFDogTdZG9AjMUpVkDf2iTCNwYNWA+u16dL41TqUDJO4gm2IgrcMv3uTpjd4Pwmg==", "license": "MIT", - "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/preact" @@ -5295,7 +5296,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -5308,7 +5308,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -6362,7 +6361,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -6532,7 +6530,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -6560,6 +6557,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/uncrypto": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/uncrypto/-/uncrypto-0.1.3.tgz", + "integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==", + "license": "MIT" + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", diff --git a/package.json b/package.json index 807987b..919266e 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ }, "dependencies": { "@supabase/supabase-js": "^2.43.4", + "@upstash/redis": "^1.38.0", "clsx": "^2.1.1", "date-fns": "^3.6.0", "jspdf": "^4.2.1", diff --git a/src/app/api/metrics/contributions/route.ts b/src/app/api/metrics/contributions/route.ts index a0c2ab8..4b4a0e4 100644 --- a/src/app/api/metrics/contributions/route.ts +++ b/src/app/api/metrics/contributions/route.ts @@ -7,6 +7,12 @@ import { mergeMetrics, } from "@/lib/github-accounts"; import { GITHUB_API } from "@/lib/github"; +import { + isMetricsCacheBypassed, + METRICS_CACHE_TTL_SECONDS, + metricsCacheKey, + withMetricsCache, +} from "@/lib/metrics-cache"; import { supabaseAdmin } from "@/lib/supabase"; export const dynamic = "force-dynamic"; @@ -35,39 +41,54 @@ function mergeContributionDays( async function fetchContributionsForAccount( token: string, githubLogin: string, - days: number + days: number, + cacheContext: { bypass: boolean; userId: string } ): Promise { - const since = new Date(); - since.setDate(since.getDate() - days); - const sinceStr = toLocalDateStr(since); + const key = metricsCacheKey(cacheContext.userId, "contributions", { + days, + githubLogin, + }); - const searchRes = await fetch( - `${GITHUB_API}/search/commits?q=author:${githubLogin}+author-date:>=${sinceStr}&per_page=100&sort=author-date&order=desc`, + return withMetricsCache( { - headers: { - Authorization: `Bearer ${token}`, - Accept: "application/vnd.github+json", - }, - cache: "no-store", - } - ); + bypass: cacheContext.bypass, + key, + ttlSeconds: METRICS_CACHE_TTL_SECONDS.contributions, + }, + async () => { + const since = new Date(); + since.setDate(since.getDate() - days); + const sinceStr = toLocalDateStr(since); + + const searchRes = await fetch( + `${GITHUB_API}/search/commits?q=author:${githubLogin}+author-date:>=${sinceStr}&per_page=100&sort=author-date&order=desc`, + { + headers: { + Authorization: `Bearer ${token}`, + Accept: "application/vnd.github+json", + }, + cache: "no-store", + } + ); - if (!searchRes.ok) { - throw new Error("GitHub API error"); - } + if (!searchRes.ok) { + throw new Error("GitHub API error"); + } - const data = (await searchRes.json()) as { - total_count: number; - items: Array<{ commit: { author: { date: string } } }>; - }; + const data = (await searchRes.json()) as { + total_count: number; + items: Array<{ commit: { author: { date: string } } }>; + }; - const commitsByDay: Record = {}; - for (const item of data.items) { - const date = item.commit.author.date.slice(0, 10); - commitsByDay[date] = (commitsByDay[date] ?? 0) + 1; - } + const commitsByDay: Record = {}; + for (const item of data.items) { + const date = item.commit.author.date.slice(0, 10); + commitsByDay[date] = (commitsByDay[date] ?? 0) + 1; + } - return { days, total: data.total_count, data: commitsByDay }; + return { days, total: data.total_count, data: commitsByDay }; + } + ); } export async function GET(req: NextRequest) { @@ -78,13 +99,15 @@ export async function GET(req: NextRequest) { const days = Number(req.nextUrl.searchParams.get("days")) || 30; const accountId = req.nextUrl.searchParams.get("accountId"); + const bypass = isMetricsCacheBypassed(req); if (!accountId) { try { const result = await fetchContributionsForAccount( session.accessToken, session.githubLogin, - days + days, + { bypass, userId: session.githubId ?? session.githubLogin } ); return Response.json(result); } catch { @@ -118,7 +141,10 @@ export async function GET(req: NextRequest) { const results = await Promise.allSettled( accounts.map((account) => - fetchContributionsForAccount(account.token, account.githubLogin, days) + fetchContributionsForAccount(account.token, account.githubLogin, days, { + bypass, + userId: account.githubId, + }) ) ); @@ -140,7 +166,8 @@ export async function GET(req: NextRequest) { const result = await fetchContributionsForAccount( session.accessToken, session.githubLogin, - days + days, + { bypass, userId: session.githubId } ); return Response.json(result); } catch { @@ -169,7 +196,8 @@ export async function GET(req: NextRequest) { const result = await fetchContributionsForAccount( accountToken, accountRow.github_login, - days + days, + { bypass, userId: accountId } ); return Response.json(result); } catch { diff --git a/src/app/api/metrics/prs/route.ts b/src/app/api/metrics/prs/route.ts index fb28df2..e43334a 100644 --- a/src/app/api/metrics/prs/route.ts +++ b/src/app/api/metrics/prs/route.ts @@ -7,6 +7,12 @@ import { mergeMetrics, } from "@/lib/github-accounts"; import { GITHUB_API } from "@/lib/github"; +import { + isMetricsCacheBypassed, + METRICS_CACHE_TTL_SECONDS, + metricsCacheKey, + withMetricsCache, +} from "@/lib/metrics-cache"; import { supabaseAdmin } from "@/lib/supabase"; export const dynamic = "force-dynamic"; @@ -19,46 +25,64 @@ interface PRMetricsBase { mergeRate: number; } -async function fetchPRMetrics(token: string): Promise { - const searchRes = await fetch( - `${GITHUB_API}/search/issues?q=type:pr+author:@me&per_page=100`, - { - headers: { Authorization: `Bearer ${token}` }, - cache: "no-store", - } - ); +async function fetchPRMetrics( + token: string, + cacheContext: { bypass: boolean; userId: string } +): Promise { + const key = metricsCacheKey(cacheContext.userId, "prs"); - if (!searchRes.ok) { - throw new Error("GitHub API error"); - } - - const data = (await searchRes.json()) as { - total_count: number; - items: Array<{ state: string; created_at: string; closed_at: string | null }>; - }; + return withMetricsCache( + { + bypass: cacheContext.bypass, + key, + ttlSeconds: METRICS_CACHE_TTL_SECONDS.prs, + }, + async () => { + const searchRes = await fetch( + `${GITHUB_API}/search/issues?q=type:pr+author:@me&per_page=100`, + { + headers: { Authorization: `Bearer ${token}` }, + cache: "no-store", + } + ); + + if (!searchRes.ok) { + throw new Error("GitHub API error"); + } + + const data = (await searchRes.json()) as { + total_count: number; + items: Array<{ + state: string; + created_at: string; + closed_at: string | null; + }>; + }; - const open = data.items.filter((pr) => pr.state === "open").length; - const merged = data.items.filter((pr) => pr.state === "closed").length; - - const closedPRs = data.items.filter((pr) => pr.closed_at); - const avgReviewMs = - closedPRs.length > 0 - ? closedPRs.reduce( - (sum, pr) => - sum + - (new Date(pr.closed_at!).getTime() - - new Date(pr.created_at).getTime()), - 0 - ) / closedPRs.length - : 0; + const open = data.items.filter((pr) => pr.state === "open").length; + const merged = data.items.filter((pr) => pr.state === "closed").length; + + const closedPRs = data.items.filter((pr) => pr.closed_at); + const avgReviewMs = + closedPRs.length > 0 + ? closedPRs.reduce( + (sum, pr) => + sum + + (new Date(pr.closed_at!).getTime() - + new Date(pr.created_at).getTime()), + 0 + ) / closedPRs.length + : 0; - return { - open, - merged, - total: data.total_count, - avgReviewHours: Math.round(avgReviewMs / 3600000), - mergeRate: data.total_count > 0 ? merged / data.total_count : 0, - }; + return { + open, + merged, + total: data.total_count, + avgReviewHours: Math.round(avgReviewMs / 3600000), + mergeRate: data.total_count > 0 ? merged / data.total_count : 0, + }; + } + ); } function formatPRMetrics(metrics: PRMetricsBase) { @@ -81,10 +105,14 @@ export async function GET(req: NextRequest) { } const accountId = req.nextUrl.searchParams.get("accountId"); + const bypass = isMetricsCacheBypassed(req); if (!accountId) { try { - const result = await fetchPRMetrics(session.accessToken); + const result = await fetchPRMetrics(session.accessToken, { + bypass, + userId: session.githubId ?? session.githubLogin ?? "primary", + }); return Response.json(formatPRMetrics(result)); } catch { return Response.json({ error: "GitHub API error" }, { status: 502 }); @@ -116,7 +144,9 @@ export async function GET(req: NextRequest) { ); const results = await Promise.allSettled( - accounts.map((account) => fetchPRMetrics(account.token)) + accounts.map((account) => + fetchPRMetrics(account.token, { bypass, userId: account.githubId }) + ) ); const merged = mergeMetrics(results, (a, b) => { @@ -154,7 +184,10 @@ export async function GET(req: NextRequest) { } try { - const result = await fetchPRMetrics(token); + const result = await fetchPRMetrics(token, { + bypass, + userId: accountId === session.githubId ? session.githubId : accountId, + }); return Response.json(formatPRMetrics(result)); } catch { return Response.json({ error: "GitHub API error" }, { status: 502 }); diff --git a/src/app/api/metrics/repos/route.ts b/src/app/api/metrics/repos/route.ts index 23f6c06..43c4f99 100644 --- a/src/app/api/metrics/repos/route.ts +++ b/src/app/api/metrics/repos/route.ts @@ -7,6 +7,12 @@ import { mergeMetrics, } from "@/lib/github-accounts"; import { GITHUB_API } from "@/lib/github"; +import { + isMetricsCacheBypassed, + METRICS_CACHE_TTL_SECONDS, + metricsCacheKey, + withMetricsCache, +} from "@/lib/metrics-cache"; import { supabaseAdmin } from "@/lib/supabase"; export const dynamic = "force-dynamic"; @@ -37,46 +43,61 @@ function mergeRepoCommits( async function fetchReposForAccount( token: string, githubLogin: string, - days: number + days: number, + cacheContext: { bypass: boolean; userId: string } ): Promise { - const since = new Date(); - since.setDate(since.getDate() - days); - const sinceStr = since.toISOString().slice(0, 10); + const key = metricsCacheKey(cacheContext.userId, "repos", { + days, + githubLogin, + }); - const searchRes = await fetch( - `${GITHUB_API}/search/commits?q=author:${githubLogin}+author-date:>=${sinceStr}&per_page=100&sort=author-date&order=desc`, + return withMetricsCache( { - headers: { - Authorization: `Bearer ${token}`, - Accept: "application/vnd.github+json", - }, - cache: "no-store", + bypass: cacheContext.bypass, + key, + ttlSeconds: METRICS_CACHE_TTL_SECONDS.repos, + }, + async () => { + const since = new Date(); + since.setDate(since.getDate() - days); + const sinceStr = since.toISOString().slice(0, 10); + + const searchRes = await fetch( + `${GITHUB_API}/search/commits?q=author:${githubLogin}+author-date:>=${sinceStr}&per_page=100&sort=author-date&order=desc`, + { + headers: { + Authorization: `Bearer ${token}`, + Accept: "application/vnd.github+json", + }, + cache: "no-store", + } + ); + + if (!searchRes.ok) { + throw new Error("GitHub API error"); + } + + const data = (await searchRes.json()) as { + items: Array<{ + repository: { full_name: string; html_url: string }; + commit: { author: { date: string } }; + }>; + }; + + const repoMap: Record = {}; + for (const item of data.items) { + const name = item.repository.full_name; + repoMap[name] = (repoMap[name] ?? 0) + 1; + } + + const repos = Object.entries(repoMap) + .map(([name, commits]) => ({ name, commits })) + .sort((a, b) => b.commits - a.commits) + .slice(0, 6); + + return { repos, days }; } ); - - if (!searchRes.ok) { - throw new Error("GitHub API error"); - } - - const data = (await searchRes.json()) as { - items: Array<{ - repository: { full_name: string; html_url: string }; - commit: { author: { date: string } }; - }>; - }; - - const repoMap: Record = {}; - for (const item of data.items) { - const name = item.repository.full_name; - repoMap[name] = (repoMap[name] ?? 0) + 1; - } - - const repos = Object.entries(repoMap) - .map(([name, commits]) => ({ name, commits })) - .sort((a, b) => b.commits - a.commits) - .slice(0, 6); - - return { repos, days }; } export async function GET(req: NextRequest) { @@ -87,13 +108,15 @@ export async function GET(req: NextRequest) { const days = Number(req.nextUrl.searchParams.get("days")) || 30; const accountId = req.nextUrl.searchParams.get("accountId"); + const bypass = isMetricsCacheBypassed(req); if (!accountId) { try { const result = await fetchReposForAccount( session.accessToken, session.githubLogin, - days + days, + { bypass, userId: session.githubId ?? session.githubLogin } ); return Response.json(result); } catch { @@ -127,7 +150,10 @@ export async function GET(req: NextRequest) { const results = await Promise.allSettled( accounts.map((account) => - fetchReposForAccount(account.token, account.githubLogin, days) + fetchReposForAccount(account.token, account.githubLogin, days, { + bypass, + userId: account.githubId, + }) ) ); @@ -148,7 +174,8 @@ export async function GET(req: NextRequest) { const result = await fetchReposForAccount( session.accessToken, session.githubLogin, - days + days, + { bypass, userId: session.githubId } ); return Response.json(result); } catch { @@ -177,7 +204,8 @@ export async function GET(req: NextRequest) { const result = await fetchReposForAccount( accountToken, accountRow.github_login, - days + days, + { bypass, userId: accountId } ); return Response.json(result); } catch { diff --git a/src/app/api/metrics/streak/route.ts b/src/app/api/metrics/streak/route.ts index ae35df7..1d8f238 100644 --- a/src/app/api/metrics/streak/route.ts +++ b/src/app/api/metrics/streak/route.ts @@ -3,6 +3,12 @@ import { NextRequest } from "next/server"; import { authOptions } from "@/lib/auth"; import { getAccountToken, getAllAccounts } from "@/lib/github-accounts"; import { GITHUB_API } from "@/lib/github"; +import { + isMetricsCacheBypassed, + METRICS_CACHE_TTL_SECONDS, + metricsCacheKey, + withMetricsCache, +} from "@/lib/metrics-cache"; import { supabaseAdmin } from "@/lib/supabase"; export const dynamic = "force-dynamic"; @@ -19,37 +25,50 @@ function toDateStr(d: Date): string { async function fetchActiveDates( githubLogin: string, - token: string + token: string, + cacheContext: { bypass: boolean; userId: string } ): Promise> { - const since = new Date(); - since.setDate(since.getDate() - 90); - const sinceStr = since.toISOString().slice(0, 10); - - const searchRes = await fetch( - `${GITHUB_API}/search/commits?q=author:${githubLogin}+author-date:>=${sinceStr}&per_page=100&sort=author-date&order=desc`, + const key = metricsCacheKey(cacheContext.userId, "streak", { githubLogin }); + const dates = await withMetricsCache( { - headers: { - Authorization: `Bearer ${token}`, - Accept: "application/vnd.github+json", - }, - cache: "no-store", - } - ); + bypass: cacheContext.bypass, + key, + ttlSeconds: METRICS_CACHE_TTL_SECONDS.streak, + }, + async () => { + const since = new Date(); + since.setDate(since.getDate() - 90); + const sinceStr = since.toISOString().slice(0, 10); + + const searchRes = await fetch( + `${GITHUB_API}/search/commits?q=author:${githubLogin}+author-date:>=${sinceStr}&per_page=100&sort=author-date&order=desc`, + { + headers: { + Authorization: `Bearer ${token}`, + Accept: "application/vnd.github+json", + }, + cache: "no-store", + } + ); - if (!searchRes.ok) { - throw new Error("GitHub API error"); - } + if (!searchRes.ok) { + throw new Error("GitHub API error"); + } - const data = (await searchRes.json()) as { - items: Array<{ commit: { author: { date: string } } }>; - }; + const data = (await searchRes.json()) as { + items: Array<{ commit: { author: { date: string } } }>; + }; - const activeDates = new Set(); - for (const item of data.items) { - activeDates.add(item.commit.author.date.slice(0, 10)); - } + const activeDates = new Set(); + for (const item of data.items) { + activeDates.add(item.commit.author.date.slice(0, 10)); + } + + return Array.from(activeDates); + } + ); - return activeDates; + return new Set(dates); } function calculateStreakFromDates( @@ -123,6 +142,7 @@ export async function GET(req: NextRequest) { } const accountId = req.nextUrl.searchParams.get("accountId"); + const bypass = isMetricsCacheBypassed(req); let appUserId: string | null = null; if (accountId) { @@ -172,7 +192,8 @@ export async function GET(req: NextRequest) { try { const activeDates = await fetchActiveDates( session.githubLogin, - session.accessToken + session.accessToken, + { bypass, userId: session.githubId } ); return Response.json( calculateStreakFromDates(activeDates, freezeDates) @@ -198,7 +219,10 @@ export async function GET(req: NextRequest) { const dateResults = await Promise.allSettled( accounts.map((account) => - fetchActiveDates(account.githubLogin, account.token) + fetchActiveDates(account.githubLogin, account.token, { + bypass, + userId: account.githubId, + }) ) ); @@ -238,7 +262,10 @@ export async function GET(req: NextRequest) { } try { - const activeDates = await fetchActiveDates(resolvedLogin, resolvedToken); + const activeDates = await fetchActiveDates(resolvedLogin, resolvedToken, { + bypass, + userId: accountId, + }); return Response.json(calculateStreakFromDates(activeDates, freezeDates)); } catch { return Response.json({ error: "GitHub API error" }, { status: 502 }); diff --git a/src/lib/metrics-cache.ts b/src/lib/metrics-cache.ts new file mode 100644 index 0000000..8f2033b --- /dev/null +++ b/src/lib/metrics-cache.ts @@ -0,0 +1,106 @@ +import { Redis } from "@upstash/redis"; +import type { NextRequest } from "next/server"; + +export const METRICS_CACHE_TTL_SECONDS = { + contributions: 5 * 60, + repos: 10 * 60, + prs: 10 * 60, + streak: 2 * 60, +} as const; + +type MetricsCacheEndpoint = keyof typeof METRICS_CACHE_TTL_SECONDS; +type CacheParamValue = boolean | number | string | null | undefined; + +let redisClient: Redis | null | undefined; + +function getRedisClient(): Redis | null { + if (redisClient !== undefined) { + return redisClient; + } + + const url = process.env.UPSTASH_REDIS_REST_URL; + const token = process.env.UPSTASH_REDIS_REST_TOKEN; + + if (!url || !token) { + redisClient = null; + return redisClient; + } + + redisClient = new Redis({ url, token }); + return redisClient; +} + +export function isMetricsCacheBypassed(req: NextRequest): boolean { + const bypassParam = + req.nextUrl.searchParams.get("refresh") ?? + req.nextUrl.searchParams.get("bypassCache") ?? + req.nextUrl.searchParams.get("sync"); + const bypassHeader = req.headers.get("x-devtrack-cache-bypass"); + + return bypassParam === "1" || bypassParam === "true" || bypassHeader === "1"; +} + +export function metricsCacheKey( + userId: string, + endpoint: MetricsCacheEndpoint, + params: Record = {} +): string { + const cacheParams = new URLSearchParams(); + + Object.entries(params) + .filter(([, value]) => value !== undefined && value !== null) + .sort(([a], [b]) => a.localeCompare(b)) + .forEach(([key, value]) => cacheParams.set(key, String(value))); + + return `metrics:${userId}:${endpoint}:${cacheParams.toString() || "default"}`; +} + +export async function cacheGet(key: string): Promise { + const redis = getRedisClient(); + if (!redis) { + return null; + } + + try { + return await redis.get(key); + } catch { + return null; + } +} + +export async function cacheSet( + key: string, + value: T, + ttlSeconds: number +): Promise { + const redis = getRedisClient(); + if (!redis) { + return; + } + + try { + await redis.set(key, value, { ex: ttlSeconds }); + } catch { + // Cache failures must not break dashboard metrics. + } +} + +export async function withMetricsCache( + options: { + bypass: boolean; + key: string; + ttlSeconds: number; + }, + loadFresh: () => Promise +): Promise { + if (!options.bypass) { + const cached = await cacheGet(options.key); + if (cached !== null) { + return cached; + } + } + + const fresh = await loadFresh(); + await cacheSet(options.key, fresh, options.ttlSeconds); + return fresh; +}