diff --git a/src/app/api/metrics/issues/route.ts b/src/app/api/metrics/issues/route.ts index cc6441a6..b2d1fe3d 100644 --- a/src/app/api/metrics/issues/route.ts +++ b/src/app/api/metrics/issues/route.ts @@ -8,6 +8,8 @@ import { metricsCacheKey, withMetricsCache, } from "@/lib/metrics-cache"; +import { getAccountToken } from "@/lib/github-accounts"; +import { resolveAppUser } from "@/lib/resolve-user"; export const dynamic = "force-dynamic"; @@ -17,19 +19,35 @@ export async function GET(req: NextRequest) { return Response.json({ error: "Unauthorized" }, { status: 401 }); } - // 1. Check if the user is forcing a refresh + const accountId = req.nextUrl.searchParams.get("accountId"); const bypass = isMetricsCacheBypassed(req); - - // 2. Generate a unique cache key for this user's issues - const key = metricsCacheKey(session.githubId ?? session.githubLogin, "issues"); + + let token = session.accessToken; + let userId = session.githubId ?? session.githubLogin; + + if (accountId && accountId !== session.githubId) { + if (!session.githubId) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + const userRow = await resolveAppUser(session.githubId, session.githubLogin); + if (!userRow) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + const accountToken = await getAccountToken(userRow.id, accountId); + if (!accountToken) { + return Response.json({ error: "Account not found" }, { status: 404 }); + } + token = accountToken; + userId = accountId; + } + + const key = metricsCacheKey(userId, "issues"); try { - // 3. Wrap the GitHub fetch in our bulletproof cache! const metrics = await withMetricsCache( { bypass, key, ttlSeconds: METRICS_CACHE_TTL_SECONDS.issues }, - () => fetchIssuesMetrics(session.accessToken!) + () => fetchIssuesMetrics(token!) ); - return Response.json(metrics); } catch { return Response.json({ error: "GitHub API error" }, { status: 502 }); diff --git a/src/app/api/metrics/languages/route.ts b/src/app/api/metrics/languages/route.ts index 2f6a3ff6..f9c4c99d 100644 --- a/src/app/api/metrics/languages/route.ts +++ b/src/app/api/metrics/languages/route.ts @@ -2,6 +2,9 @@ import { getServerSession } from "next-auth"; import { NextRequest } from "next/server"; import { authOptions } from "@/lib/auth"; import { isMetricsCacheBypassed, metricsCacheKey, withMetricsCache, METRICS_CACHE_TTL_SECONDS} from "@/lib/metrics-cache"; +import { getAccountToken } from "@/lib/github-accounts"; +import { supabaseAdmin } from "@/lib/supabase"; +import { resolveAppUser } from "@/lib/resolve-user"; export const dynamic = "force-dynamic"; const GITHUB_API = "https://api.github.com"; @@ -10,25 +13,53 @@ export async function GET(req: NextRequest) { const session = await getServerSession(authOptions); if (!session?.accessToken || !session.githubLogin) return Response.json({ error: "Unauthorized" }, { status: 401 }); + const accountId = req.nextUrl.searchParams.get("accountId"); const bypass = isMetricsCacheBypassed(req); - const accountId = req.nextUrl.searchParams.get("accountId"); + let token = session.accessToken; + let githubLogin = session.githubLogin; + let userId = session.githubId ?? session.githubLogin; + + if (accountId && accountId !== session.githubId) { + if (!session.githubId) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + const userRow = await resolveAppUser(session.githubId, session.githubLogin); + if (!userRow) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + const accountToken = await getAccountToken(userRow.id, accountId); + if (!accountToken) { + return Response.json({ error: "Account not found" }, { status: 404 }); + } + const { data: accountRow } = await supabaseAdmin + .from("user_github_accounts") + .select("github_login") + .eq("user_id", userRow.id) + .eq("github_id", accountId) + .single(); + if (!accountRow?.github_login) { + return Response.json({ error: "Account not found" }, { status: 404 }); + } + token = accountToken; + githubLogin = accountRow.github_login; + userId = accountId; + } const key = metricsCacheKey( - session.githubId ?? session.githubLogin, + userId, "languages" as any, - { - accountId: accountId || undefined, - } + { accountId: accountId || undefined } ); + try { const data = await withMetricsCache({ bypass, key, ttlSeconds: METRICS_CACHE_TTL_SECONDS.languages }, async () => { - const headers = { Authorization: `Bearer ${session.accessToken}`, Accept: "application/vnd.github+json" }; + const headers = { Authorization: `Bearer ${token}`, Accept: "application/vnd.github+json" }; const since = new Date(); since.setDate(since.getDate() - 90); const searchRes = await fetch( - `${GITHUB_API}/search/commits?q=author:${session.githubLogin}+author-date:>=${since.toISOString().slice(0, 10)}&per_page=100&sort=author-date&order=desc`, + `${GITHUB_API}/search/commits?q=author:${githubLogin}+author-date:>=${since.toISOString().slice(0, 10)}&per_page=100&sort=author-date&order=desc`, { headers, cache: "no-store" } ); if (!searchRes.ok) throw new Error("API Error"); @@ -41,7 +72,7 @@ export async function GET(req: NextRequest) { repoNames.map(async (repoName) => { try { const repoCacheKey = metricsCacheKey( - session.githubId || session.githubLogin || "unknown", + userId, "repo_languages" as any, { repoName } ); @@ -82,4 +113,4 @@ export async function GET(req: NextRequest) { } catch { return Response.json({ error: "GitHub API error" }, { status: 502 }); } -} +} \ No newline at end of file diff --git a/src/app/api/metrics/pr-breakdown/route.ts b/src/app/api/metrics/pr-breakdown/route.ts index 6c919b05..b52e2273 100644 --- a/src/app/api/metrics/pr-breakdown/route.ts +++ b/src/app/api/metrics/pr-breakdown/route.ts @@ -2,6 +2,8 @@ import { getServerSession } from "next-auth"; import { NextRequest } from "next/server"; import { authOptions } from "@/lib/auth"; import { isMetricsCacheBypassed, metricsCacheKey, withMetricsCache } from "@/lib/metrics-cache"; +import { getAccountToken } from "@/lib/github-accounts"; +import { resolveAppUser } from "@/lib/resolve-user"; export const dynamic = "force-dynamic"; const GITHUB_API = "https://api.github.com"; @@ -12,13 +14,34 @@ export async function GET(req: NextRequest) { const session = await getServerSession(authOptions); if (!session?.accessToken) return Response.json({ error: "Unauthorized" }, { status: 401 }); + const accountId = req.nextUrl.searchParams.get("accountId"); const bypass = isMetricsCacheBypassed(req); - const key = metricsCacheKey(session.githubId ?? "unknown", "pr-breakdown" as any); + + let token = session.accessToken; + let userId = session.githubId ?? "unknown"; + + if (accountId && accountId !== session.githubId) { + if (!session.githubId) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + const userRow = await resolveAppUser(session.githubId, session.githubLogin); + if (!userRow) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + const accountToken = await getAccountToken(userRow.id, accountId); + if (!accountToken) { + return Response.json({ error: "Account not found" }, { status: 404 }); + } + token = accountToken; + userId = accountId; + } + + const key = metricsCacheKey(userId, "pr-breakdown" as any); try { const data = await withMetricsCache({ bypass, key, ttlSeconds: 10 * 60 }, async () => { const res = await fetch(`${GITHUB_API}/search/issues?q=type:pr+author:@me&per_page=100`, { - headers: { Authorization: `Bearer ${session.accessToken}`, Accept: "application/vnd.github+json" }, + headers: { Authorization: `Bearer ${token}`, Accept: "application/vnd.github+json" }, cache: "no-store", }); if (!res.ok) throw new Error("API Error"); diff --git a/src/app/api/metrics/weekly-summary/route.ts b/src/app/api/metrics/weekly-summary/route.ts index 31341b2d..f51b149c 100644 --- a/src/app/api/metrics/weekly-summary/route.ts +++ b/src/app/api/metrics/weekly-summary/route.ts @@ -4,6 +4,9 @@ import { authOptions } from "@/lib/auth"; import { GITHUB_API } from "@/lib/github"; import { isMetricsCacheBypassed, metricsCacheKey, withMetricsCache } from "@/lib/metrics-cache"; import { dateDiffDays, toDateStr } from "@/lib/dateUtils"; +import { getAccountToken } from "@/lib/github-accounts"; +import { supabaseAdmin } from "@/lib/supabase"; +import { resolveAppUser } from "@/lib/resolve-user"; export const dynamic = "force-dynamic"; @@ -108,8 +111,40 @@ export async function GET(req: NextRequest) { return Response.json({ error: "Unauthorized" }, { status: 401 }); } + const accountId = req.nextUrl.searchParams.get("accountId"); const bypass = isMetricsCacheBypassed(req); - const key = metricsCacheKey(session.githubId ?? session.githubLogin, "weekly-summary" as any); + + let token = session.accessToken; + let githubLogin = session.githubLogin; + let userId = session.githubId ?? session.githubLogin; + + if (accountId && accountId !== session.githubId) { + if (!session.githubId) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + const userRow = await resolveAppUser(session.githubId, session.githubLogin); + if (!userRow) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + const accountToken = await getAccountToken(userRow.id, accountId); + if (!accountToken) { + return Response.json({ error: "Account not found" }, { status: 404 }); + } + const { data: accountRow } = await supabaseAdmin + .from("user_github_accounts") + .select("github_login") + .eq("user_id", userRow.id) + .eq("github_id", accountId) + .single(); + if (!accountRow?.github_login) { + return Response.json({ error: "Account not found" }, { status: 404 }); + } + token = accountToken; + githubLogin = accountRow.github_login; + userId = accountId; + } + + const key = metricsCacheKey(userId, "weekly-summary" as any); try { // Cache TTL of 5 minutes (300 seconds). @@ -129,11 +164,11 @@ export async function GET(req: NextRequest) { // per_page=100 covers most users in a single request; heavy committers // (>100 commits in 14 days) will see a capped but still representative count. const commitsRes = await fetch( - `${GITHUB_API}/search/commits?q=author:${session.githubLogin}+author-date:>=${fourteenDaysAgoStr}&per_page=100`, + `${GITHUB_API}/search/commits?q=author:${githubLogin}+author-date:>=${fourteenDaysAgoStr}&per_page=100`, { headers: { // OAuth token / PAT: required for the authenticated 30 req/min tier. - Authorization: `Bearer ${session.accessToken}`, + Authorization: `Bearer ${token}`, // Mandatory Accept header for the Commit Search endpoint. Accept: "application/vnd.github+json", }, @@ -191,7 +226,7 @@ export async function GET(req: NextRequest) { `${GITHUB_API}/search/issues?q=type:pr+author:@me+created:>=${fourteenDaysAgoStr}&per_page=100`, { headers: { - Authorization: `Bearer ${session.accessToken}`, + Authorization: `Bearer ${token}`, Accept: "application/vnd.github+json", }, cache: "no-store", @@ -238,7 +273,7 @@ export async function GET(req: NextRequest) { // requests to build the full 90-day commit date set for streak calculation. // This is the most expensive part of this handler in terms of API quota usage. // The 5-minute cache TTL above ensures these calls only happen on cache misses. - const streakDates = await fetchActiveDates(session.githubLogin!, session.accessToken!); + const streakDates = await fetchActiveDates(githubLogin!, token!); const commitDelta = commitsThisWeek - commitsPrevWeek; return { diff --git a/src/components/IssueMetrics.tsx b/src/components/IssueMetrics.tsx index 522453c1..c3f4b0e1 100644 --- a/src/components/IssueMetrics.tsx +++ b/src/components/IssueMetrics.tsx @@ -1,6 +1,7 @@ "use client"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; +import { useAccount } from "@/components/AccountContext"; interface IssueData { opened: number; @@ -12,15 +13,20 @@ interface IssueData { } export default function IssueMetrics() { + const { selectedAccount } = useAccount(); const [metrics, setMetrics] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const fetchMetrics = () => { + const fetchMetrics = useCallback(() => { setLoading(true); setError(null); - fetch("/api/metrics/issues") + const url = selectedAccount !== null + ? `/api/metrics/issues?accountId=${encodeURIComponent(selectedAccount)}` + : "/api/metrics/issues"; + + fetch(url) .then((r) => r.json()) .then((data: IssueData) => setMetrics(data)) .catch(() => @@ -29,11 +35,11 @@ export default function IssueMetrics() { ) ) .finally(() => setLoading(false)); - }; + }, [selectedAccount]); useEffect(() => { fetchMetrics(); - }, []); + }, [fetchMetrics]); const stats = metrics ? [ diff --git a/src/components/LanguageBreakdown.tsx b/src/components/LanguageBreakdown.tsx index 96e7cb7e..13abf97d 100644 --- a/src/components/LanguageBreakdown.tsx +++ b/src/components/LanguageBreakdown.tsx @@ -1,6 +1,7 @@ "use client"; import { useEffect, useState } from "react"; +import { useAccount } from "@/components/AccountContext"; interface Language { name: string; @@ -28,17 +29,21 @@ function getColor(name: string): string { } export default function LanguageBreakdown() { + const { selectedAccount } = useAccount(); const [languages, setLanguages] = useState([]); const [loading, setLoading] = useState(true); useEffect(() => { setLoading(true); - fetch("/api/metrics/languages") + const url = selectedAccount !== null + ? `/api/metrics/languages?accountId=${encodeURIComponent(selectedAccount)}` + : "/api/metrics/languages"; + fetch(url) .then((r) => r.json()) .then((d: { languages: Language[] }) => setLanguages(d.languages ?? [])) .catch(() => {}) .finally(() => setLoading(false)); - }, []); + }, [selectedAccount]); const totalPercentage = languages.reduce((sum, lang) => sum + lang.percentage, 0); const roundedTotal = Math.round(totalPercentage * 10) / 10; diff --git a/src/components/PRBreakdownChart.tsx b/src/components/PRBreakdownChart.tsx index 768139ba..104cf367 100644 --- a/src/components/PRBreakdownChart.tsx +++ b/src/components/PRBreakdownChart.tsx @@ -1,7 +1,8 @@ "use client"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { PieChart, Pie, Cell, Tooltip, ResponsiveContainer } from "recharts"; +import { useAccount } from "@/components/AccountContext"; interface PRBreakdown { draft: number; @@ -18,6 +19,7 @@ const SLICES: { key: keyof PRBreakdown; label: string; color: string }[] = [ ]; export default function PRBreakdownChart() { + const { selectedAccount } = useAccount(); const [breakdown, setBreakdown] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -29,22 +31,26 @@ export default function PRBreakdownChart() { .trim(); }; - const fetchBreakdown = () => { + const fetchBreakdown = useCallback(() => { setLoading(true); setError(null); - fetch("/api/metrics/pr-breakdown") + const url = selectedAccount !== null + ? `/api/metrics/pr-breakdown?accountId=${encodeURIComponent(selectedAccount)}` + : "/api/metrics/pr-breakdown"; + + fetch(url) .then((r) => r.json()) .then((d: PRBreakdown) => setBreakdown(d)) .catch(() => setError("We couldn't load your PR breakdown right now. Please try again in a moment.") ) .finally(() => setLoading(false)); - }; + }, [selectedAccount]); useEffect(() => { fetchBreakdown(); - }, []); + }, [fetchBreakdown]); if (loading) { return ( diff --git a/src/components/WeeklySummaryCard.tsx b/src/components/WeeklySummaryCard.tsx index 36c9c613..918a4490 100644 --- a/src/components/WeeklySummaryCard.tsx +++ b/src/components/WeeklySummaryCard.tsx @@ -1,6 +1,7 @@ "use client"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; +import { useAccount } from "@/components/AccountContext"; interface WeeklySummaryData { commits: { @@ -19,6 +20,7 @@ interface WeeklySummaryData { } export default function WeeklySummaryCard() { + const { selectedAccount } = useAccount(); const [summary, setSummary] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -28,11 +30,15 @@ export default function WeeklySummaryCard() { const maxPRs = summary ? Math.max(summary.prs.thisWeek.merged, summary.prs.lastWeek.merged, 1) : 1; const maxActiveDays = summary ? Math.max(summary.activeDays.thisWeek, summary.activeDays.lastWeek, 1) : 1; - useEffect(() => { + const fetchSummary = useCallback(() => { setLoading(true); setError(null); - fetch("/api/metrics/weekly-summary") + const url = selectedAccount !== null + ? `/api/metrics/weekly-summary?accountId=${encodeURIComponent(selectedAccount)}` + : "/api/metrics/weekly-summary"; + + fetch(url) .then((r) => { if (!r.ok) throw new Error("API error"); return r.json(); @@ -44,7 +50,11 @@ export default function WeeklySummaryCard() { ) ) .finally(() => setLoading(false)); - }, []); + }, [selectedAccount]); + + useEffect(() => { + fetchSummary(); + }, [fetchSummary]); return (