From cf717d926672042dbf74e73e8aac6bfc6eef00e7 Mon Sep 17 00:00:00 2001 From: armanpanigrahi59 <11309arman@gmail.com> Date: Fri, 14 Aug 2026 11:54:13 +0530 Subject: [PATCH 01/17] fix: streak user parameter validation and error SVGs --- app/api/streak/route.test.ts | 18 ++++++++++++++++++ app/api/streak/route.ts | 17 ++++++++++++++++- lib/validations.streakParamsSchema.test.ts | 15 ++++++++++++--- lib/validations.ts | 11 ++++++++++- 4 files changed, 56 insertions(+), 5 deletions(-) diff --git a/app/api/streak/route.test.ts b/app/api/streak/route.test.ts index 84197e9bc..883efb828 100644 --- a/app/api/streak/route.test.ts +++ b/app/api/streak/route.test.ts @@ -197,6 +197,24 @@ describe('GET /api/streak', () => { expect(response.headers.get('Content-Type')).toContain('image/svg+xml'); }); + it('returns JSON 400 when the user parameter is missing and format=json', async () => { + const response = await GET(makeRequest({ format: 'json' })); + expect(response.status).toBe(400); + const body = await response.json(); + expect(body).toHaveProperty('error'); + expect(body.error).toContain('Missing user parameter'); + expect(response.headers.get('Content-Type')).toContain('application/json'); + }); + + it('returns 404 JSON for a nonexistent user when format=json', async () => { + vi.mocked(fetchGitHubContributions).mockRejectedValue(new Error('Could not resolve to a User with the login of \'nonexistentuser\'')); + const response = await GET(makeRequest({ user: 'nonexistentuser', format: 'json' })); + expect(response.status).toBe(404); + const body = await response.json(); + expect(body).toHaveProperty('error'); + expect(response.headers.get('Content-Type')).toContain('application/json'); + }); + it('returns 400 when org parameter contains spaces and invalid characters', async () => { const response = await GET( makeRequest({ user: 'octocat', org: 'invalid_org_name_with_spaces' }) diff --git a/app/api/streak/route.ts b/app/api/streak/route.ts index e2159978f..0e7e7d1fd 100644 --- a/app/api/streak/route.ts +++ b/app/api/streak/route.ts @@ -99,6 +99,21 @@ export async function GET(request: Request) { Object.values(fieldErrors.fieldErrors).flat()[0] ?? fieldErrors.formErrors[0] ?? 'Invalid parameters'; + + if (searchParams.get('format') === 'json') { + return NextResponse.json( + { error: firstError }, + { + status: 400, + headers: { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + 'X-Request-ID': requestId, + }, + } + ); + } + const errTheme = resolveErrorTheme(searchParams); const errorSvg = buildInlineErrorSVG(firstError, { bg: errTheme.bg, @@ -975,7 +990,7 @@ function buildErrorResponse( } if (isNotFound) { - const match = message.match(/"([^"]+)"|login of '([^']+)'/); + const match = rawMessage.match(/"([^"]+)"|login of '([^']+)'/); const fallbackTarget = parseResult.success ? parseResult.data.org || parseResult.data.user : 'unknown'; diff --git a/lib/validations.streakParamsSchema.test.ts b/lib/validations.streakParamsSchema.test.ts index 75c8fb777..85d645fda 100644 --- a/lib/validations.streakParamsSchema.test.ts +++ b/lib/validations.streakParamsSchema.test.ts @@ -65,15 +65,24 @@ describe('streakParamsSchema', () => { expect(result.success).toBe(true); }); + it('accepts input with org but missing user', () => { + const result = streakParamsSchema.safeParse({ org: 'github' }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.org).toBe('github'); + expect(result.data.user).toBe(''); // default empty string + } + }); + // ── Invalid / negative cases ────────────────────────────────────────────── - it('fails when user is missing', () => { + it('fails when both user and org are missing', () => { const result = streakParamsSchema.safeParse({}); expect(result.success).toBe(false); }); - it('fails when user is an empty string', () => { - const result = streakParamsSchema.safeParse({ user: '' }); + it('fails when user and org are empty strings', () => { + const result = streakParamsSchema.safeParse({ user: '', org: '' }); expect(result.success).toBe(false); }); diff --git a/lib/validations.ts b/lib/validations.ts index 0034e0aa3..bf62674c5 100644 --- a/lib/validations.ts +++ b/lib/validations.ts @@ -231,8 +231,9 @@ const baseStreakParamsSchema = z.object({ // Required — missing user surfaces as "Missing" to match existing tests user: z .string({ error: 'Missing user parameter' }) - .min(1, { message: 'Missing user parameter' }) + .default('') .superRefine((val, ctx) => { + if (val === '') return; const users = val.split(',').map((u) => u.trim()); if (users.length === 0) { ctx.addIssue({ @@ -656,6 +657,10 @@ const baseStreakParamsSchema = z.object({ const TWO_YEARS_MS = 2 * 365.25 * 24 * 60 * 60 * 1000; export const streakParamsSchema = baseStreakParamsSchema + .refine( + (data) => (data.user && data.user.trim().length > 0) || (data.org && data.org.trim().length > 0), + { message: 'Missing user parameter', path: ['user'] } + ) .refine((data) => !data.from || !data.to || Date.parse(data.from) <= Date.parse(data.to), { message: '"to" date must be after or equal to "from" date', path: ['to'], @@ -1203,6 +1208,10 @@ export const animatedStreakParamsSchema = baseStreakParamsSchema .optional() .transform((val) => val || 'rise'), }) + .refine( + (data) => (data.user && data.user.trim().length > 0) || (data.org && data.org.trim().length > 0), + { message: 'Missing user parameter', path: ['user'] } + ) .refine((data) => !data.from || !data.to || Date.parse(data.from) <= Date.parse(data.to), { message: '"to" date must be after or equal to "from" date', path: ['to'], From d91612e623fda945b83ad86bc3feac40c32ff531 Mon Sep 17 00:00:00 2001 From: armanpanigrahi59 <11309arman@gmail.com> Date: Fri, 14 Aug 2026 12:10:31 +0530 Subject: [PATCH 02/17] style: fix prettier formatting --- app/api/streak/route.test.ts | 4 +++- lib/validations.ts | 6 ++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/app/api/streak/route.test.ts b/app/api/streak/route.test.ts index 5d3063b14..728b52321 100644 --- a/app/api/streak/route.test.ts +++ b/app/api/streak/route.test.ts @@ -207,7 +207,9 @@ describe('GET /api/streak', () => { }); it('returns 404 JSON for a nonexistent user when format=json', async () => { - vi.mocked(fetchGitHubContributions).mockRejectedValue(new Error('Could not resolve to a User with the login of \'nonexistentuser\'')); + vi.mocked(fetchGitHubContributions).mockRejectedValue( + new Error("Could not resolve to a User with the login of 'nonexistentuser'") + ); const response = await GET(makeRequest({ user: 'nonexistentuser', format: 'json' })); expect(response.status).toBe(404); const body = await response.json(); diff --git a/lib/validations.ts b/lib/validations.ts index 56997f2c3..e83dbb8b9 100644 --- a/lib/validations.ts +++ b/lib/validations.ts @@ -684,7 +684,8 @@ const TWO_YEARS_MS = 2 * 365.25 * 24 * 60 * 60 * 1000; export const streakParamsSchema = baseStreakParamsSchema .refine( - (data) => (data.user && data.user.trim().length > 0) || (data.org && data.org.trim().length > 0), + (data) => + (data.user && data.user.trim().length > 0) || (data.org && data.org.trim().length > 0), { message: 'Missing user parameter', path: ['user'] } ) .refine((data) => !data.from || !data.to || Date.parse(data.from) <= Date.parse(data.to), { @@ -1235,7 +1236,8 @@ export const animatedStreakParamsSchema = baseStreakParamsSchema .transform((val) => val || 'rise'), }) .refine( - (data) => (data.user && data.user.trim().length > 0) || (data.org && data.org.trim().length > 0), + (data) => + (data.user && data.user.trim().length > 0) || (data.org && data.org.trim().length > 0), { message: 'Missing user parameter', path: ['user'] } ) .refine((data) => !data.from || !data.to || Date.parse(data.from) <= Date.parse(data.to), { From 01a219f13711548cadbd0db35f514ef522dd977c Mon Sep 17 00:00:00 2001 From: armanpanigrahi59 <11309arman@gmail.com> Date: Fri, 14 Aug 2026 13:02:28 +0530 Subject: [PATCH 03/17] feat: stream dashboard components with React Suspense (#71) - Refactored dashboard data fetching to pass unawaited promises to client - Updated DashboardClient to use React.use() for suspense boundary integration - Removed blocking 3.5s opacity animation from DashboardPageWrapper - Leveraged Next.js layout streaming to instantly render dashboard shell --- app/(root)/dashboard/DashboardPageWrapper.tsx | 40 ++--------------- app/(root)/dashboard/[username]/page.tsx | 43 ++++++++----------- components/dashboard/DashboardClient.tsx | 14 ++++-- 3 files changed, 31 insertions(+), 66 deletions(-) diff --git a/app/(root)/dashboard/DashboardPageWrapper.tsx b/app/(root)/dashboard/DashboardPageWrapper.tsx index db4246562..31003e3f5 100644 --- a/app/(root)/dashboard/DashboardPageWrapper.tsx +++ b/app/(root)/dashboard/DashboardPageWrapper.tsx @@ -1,47 +1,13 @@ 'use client'; -import { useState, useSyncExternalStore } from 'react'; -import { createPortal } from 'react-dom'; -import LoadingScreen from './LoadingScreen'; - interface DashboardPageWrapperProps { children: React.ReactNode; } /** - * Wraps dashboard page content and guarantees LoadingScreen plays its full - * 3500ms animation regardless of how fast Next.js receives API data. - * - * The overlay is rendered via a React Portal directly into document.body — - * this means it escapes ALL stacking contexts (navbar, layout wrappers, etc.) - * and is unconditionally on top of everything on the page. + * Wraps dashboard page content. + * Now renders instantly to support React Suspense streaming. */ export default function DashboardPageWrapper({ children }: DashboardPageWrapperProps) { - const [ready, setReady] = useState(false); - - const mounted = useSyncExternalStore( - () => () => {}, - () => true, - () => false - ); - - return ( - <> - {/* Real page — renders immediately but stays invisible until animation ends */} -
- {children} -
- - {/* Overlay portalled into document.body — escapes every stacking context */} - {mounted && - !ready && - createPortal( setReady(true)} />, document.body)} - - ); + return <>{children}; } diff --git a/app/(root)/dashboard/[username]/page.tsx b/app/(root)/dashboard/[username]/page.tsx index 7063302b7..0235a7ab5 100644 --- a/app/(root)/dashboard/[username]/page.tsx +++ b/app/(root)/dashboard/[username]/page.tsx @@ -136,35 +136,28 @@ async function DashboardContent({ const session = await auth(); const sessionUsername = (session?.user as { username?: string })?.username ?? null; - let data; - + let fallbackProfile; try { - data = await getFullDashboardData(username, { + fallbackProfile = await fetchUserProfile(username, { bypassCache, - from: period.from, - to: period.to, - rangeLabel: period.label, token: userToken, - excludeBots, }); - } catch (error) { - if (error instanceof Error && error.message.includes('not found')) { - let fallbackProfile; - try { - fallbackProfile = await fetchUserProfile(username, { - bypassCache, - token: userToken, - }); - } catch { - return notFound(); - } - if (fallbackProfile.type === 'Organization') { - redirect(`/dashboard/org/${username}`); - } - return notFound(); - } - throw error; + } catch { + return notFound(); } + + if (fallbackProfile.type === 'Organization') { + redirect(`/dashboard/org/${username}`); + } + + let data = getFullDashboardData(username, { + bypassCache, + from: period.from, + to: period.to, + rangeLabel: period.label, + token: userToken, + excludeBots, + }); let allRepos: RepoActivityInfo[] = []; try { @@ -196,7 +189,7 @@ async function DashboardContent({ ; + initialData?: DashboardData; + allRepoActivity: RepoActivityInfo[]; username: string; compareData?: DashboardData | null; period: DashboardPeriod; @@ -330,7 +331,8 @@ function getPersonalityTags( } export default function DashboardClient({ - initialData, + initialDataPromise, + initialData: initialDataProp, allRepoActivity = [], username, compareData = null, @@ -342,6 +344,10 @@ export default function DashboardClient({ () => false, () => (process.env.NODE_ENV === 'test' ? false : true) ); + + // Use React.use() if a promise is provided, otherwise use the direct object (for tests) + const initialData = initialDataProp || use(initialDataPromise!); + const [secondUserData, setSecondUserData] = useState(compareData); const [activeTab, setActiveTab] = useState<'overview' | 'pr-insights' | 'ci-analytics'>( 'overview' From 71b429a68a03282ceb1c5189cbd9141b16cf4c1f Mon Sep 17 00:00:00 2001 From: armanpanigrahi59 <11309arman@gmail.com> Date: Fri, 14 Aug 2026 13:10:25 +0530 Subject: [PATCH 04/17] style: fix prettier formatting issues --- app/(root)/dashboard/[username]/page.tsx | 2 +- components/dashboard/DashboardClient.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/(root)/dashboard/[username]/page.tsx b/app/(root)/dashboard/[username]/page.tsx index 0235a7ab5..dadd76de9 100644 --- a/app/(root)/dashboard/[username]/page.tsx +++ b/app/(root)/dashboard/[username]/page.tsx @@ -145,7 +145,7 @@ async function DashboardContent({ } catch { return notFound(); } - + if (fallbackProfile.type === 'Organization') { redirect(`/dashboard/org/${username}`); } diff --git a/components/dashboard/DashboardClient.tsx b/components/dashboard/DashboardClient.tsx index c5aee4819..14e312379 100644 --- a/components/dashboard/DashboardClient.tsx +++ b/components/dashboard/DashboardClient.tsx @@ -344,10 +344,10 @@ export default function DashboardClient({ () => false, () => (process.env.NODE_ENV === 'test' ? false : true) ); - + // Use React.use() if a promise is provided, otherwise use the direct object (for tests) const initialData = initialDataProp || use(initialDataPromise!); - + const [secondUserData, setSecondUserData] = useState(compareData); const [activeTab, setActiveTab] = useState<'overview' | 'pr-insights' | 'ci-analytics'>( 'overview' From acf6139f61a2bc6fe30d238b1d93fe2fbb63828c Mon Sep 17 00:00:00 2001 From: armanpanigrahi59 <11309arman@gmail.com> Date: Fri, 14 Aug 2026 13:20:25 +0530 Subject: [PATCH 05/17] fix: prefer-const in page.tsx --- app/(root)/dashboard/[username]/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/(root)/dashboard/[username]/page.tsx b/app/(root)/dashboard/[username]/page.tsx index dadd76de9..a1eda1ecc 100644 --- a/app/(root)/dashboard/[username]/page.tsx +++ b/app/(root)/dashboard/[username]/page.tsx @@ -150,7 +150,7 @@ async function DashboardContent({ redirect(`/dashboard/org/${username}`); } - let data = getFullDashboardData(username, { + const data = getFullDashboardData(username, { bypassCache, from: period.from, to: period.to, From e9297696872b11004fc3654a69ece096776f68e0 Mon Sep 17 00:00:00 2001 From: armanpanigrahi59 <11309arman@gmail.com> Date: Fri, 14 Aug 2026 13:32:35 +0530 Subject: [PATCH 06/17] fix: resolve TypeScript type check errors in DashboardClient tests --- components/dashboard/DashboardClient.tsx | 4 +-- .../DashboardClient.type-compiler.test.tsx | 28 +++++++++++-------- 2 files changed, 18 insertions(+), 14 deletions(-) diff --git a/components/dashboard/DashboardClient.tsx b/components/dashboard/DashboardClient.tsx index 14e312379..77e07b68b 100644 --- a/components/dashboard/DashboardClient.tsx +++ b/components/dashboard/DashboardClient.tsx @@ -106,10 +106,10 @@ export interface DashboardData { rawCommits?: string[]; } -interface DashboardClientProps { +export interface DashboardClientProps { initialDataPromise?: Promise; initialData?: DashboardData; - allRepoActivity: RepoActivityInfo[]; + allRepoActivity?: RepoActivityInfo[]; username: string; compareData?: DashboardData | null; period: DashboardPeriod; diff --git a/components/dashboard/DashboardClient.type-compiler.test.tsx b/components/dashboard/DashboardClient.type-compiler.test.tsx index a9d0bcb87..806e789fb 100644 --- a/components/dashboard/DashboardClient.type-compiler.test.tsx +++ b/components/dashboard/DashboardClient.type-compiler.test.tsx @@ -1,6 +1,6 @@ import { describe, it, expectTypeOf } from 'vitest'; import React, { ComponentProps } from 'react'; -import DashboardClient, { ProfileMetrics, CoderProfile } from './DashboardClient'; +import DashboardClient, { ProfileMetrics, CoderProfile, DashboardClientProps } from './DashboardClient'; import type { DashboardPeriod } from '@/utils/dashboardPeriod'; describe('DashboardClient - TypeScript Compiler Validation & Schema Constraints Stability (Variation 10)', () => { @@ -16,29 +16,29 @@ describe('DashboardClient - TypeScript Compiler Validation & Schema Constraints }); it('Use type-testing assertions (expectTypeOf) to enforce field property configurations: Infer unexported props', () => { - // Extracting the props from the component - type Props = ComponentProps; + type Props = DashboardClientProps; // Assert the required fields expectTypeOf().toHaveProperty('username').toBeString(); expectTypeOf().toHaveProperty('period').toEqualTypeOf(); // Test the internal initialData structure - expectTypeOf().toHaveProperty('profile').toBeObject(); - expectTypeOf().toHaveProperty('username').toBeString(); + expectTypeOf>().toHaveProperty('profile').toBeObject(); + expectTypeOf['profile']>().toHaveProperty('username').toBeString(); }); it('Assert that invalid prop parameters are blocked during static type checking: Rejects missing props', () => { - type Props = ComponentProps; + type Props = DashboardClientProps; // Missing 'username', 'initialData', 'period' expectTypeOf>().not.toMatchTypeOf(); - // 'username' must be a string, not a number + // 'initialData' must not be missing critical nested keys expectTypeOf<{ - username: number; - initialData: Props['initialData']; + username: string; + initialData: Omit, 'profile'>; period: DashboardPeriod; + allRepoActivity: Props['allRepoActivity']; }>().not.toMatchTypeOf(); }); @@ -58,6 +58,11 @@ describe('DashboardClient - TypeScript Compiler Validation & Schema Constraints /> ); void validComponent; + + // Testing the extraction logic in edge cases + expectTypeOf['activity']>().toMatchTypeOf< + Array<{ date: string; count: number; intensity: 0 | 1 | 2 | 3 | 4 }> + >(); }); it('Verify schema validation constraints return strict validation reports: CoderProfile strict unions', () => { @@ -66,9 +71,8 @@ describe('DashboardClient - TypeScript Compiler Validation & Schema Constraints 'Early Builder ☀' | 'Weekend Warrior 🚀' | 'Consistent Runner 🏃‍♂️' >(); - // intensity in activity array has strict union 0 | 1 | 2 | 3 | 4 - type Props = ComponentProps; - type ActivityIntensity = Props['initialData']['activity'][0]['intensity']; + type Props = DashboardClientProps; + type ActivityIntensity = NonNullable['activity'][0]['intensity']; expectTypeOf().toEqualTypeOf<0 | 1 | 2 | 3 | 4>(); }); }); From fd6b6bd65f86b277f37a5146abc2346bd8d3d6b1 Mon Sep 17 00:00:00 2001 From: armanpanigrahi59 <11309arman@gmail.com> Date: Fri, 14 Aug 2026 13:34:34 +0530 Subject: [PATCH 07/17] style: fix prettier formatting in DashboardClient.type-compiler.test.tsx --- .../dashboard/DashboardClient.type-compiler.test.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/components/dashboard/DashboardClient.type-compiler.test.tsx b/components/dashboard/DashboardClient.type-compiler.test.tsx index 806e789fb..249911acd 100644 --- a/components/dashboard/DashboardClient.type-compiler.test.tsx +++ b/components/dashboard/DashboardClient.type-compiler.test.tsx @@ -1,6 +1,10 @@ import { describe, it, expectTypeOf } from 'vitest'; import React, { ComponentProps } from 'react'; -import DashboardClient, { ProfileMetrics, CoderProfile, DashboardClientProps } from './DashboardClient'; +import DashboardClient, { + ProfileMetrics, + CoderProfile, + DashboardClientProps, +} from './DashboardClient'; import type { DashboardPeriod } from '@/utils/dashboardPeriod'; describe('DashboardClient - TypeScript Compiler Validation & Schema Constraints Stability (Variation 10)', () => { @@ -24,7 +28,9 @@ describe('DashboardClient - TypeScript Compiler Validation & Schema Constraints // Test the internal initialData structure expectTypeOf>().toHaveProperty('profile').toBeObject(); - expectTypeOf['profile']>().toHaveProperty('username').toBeString(); + expectTypeOf['profile']>() + .toHaveProperty('username') + .toBeString(); }); it('Assert that invalid prop parameters are blocked during static type checking: Rejects missing props', () => { From 505eb769cec08e9858bb98f8d317477edd47d5c0 Mon Sep 17 00:00:00 2001 From: armanpanigrahi59 <11309arman@gmail.com> Date: Fri, 14 Aug 2026 18:54:58 +0530 Subject: [PATCH 08/17] test: fix React suspense and mock warnings in test suite --- app/(root)/dashboard/[username]/page.test.tsx | 63 ++++++++++++++++--- app/burnout-analyzer/page.test.tsx | 33 +++++----- 2 files changed, 71 insertions(+), 25 deletions(-) diff --git a/app/(root)/dashboard/[username]/page.test.tsx b/app/(root)/dashboard/[username]/page.test.tsx index 4ed6853c0..d28652544 100644 --- a/app/(root)/dashboard/[username]/page.test.tsx +++ b/app/(root)/dashboard/[username]/page.test.tsx @@ -1,8 +1,9 @@ import type { Metadata } from 'next'; -import { render, screen } from '@testing-library/react'; +import { Suspense } from 'react'; +import { render, screen, waitFor, act } from '@testing-library/react'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import DashboardPage, { generateMetadata } from './page'; -import { getFullDashboardData } from '@/lib/github'; +import { getFullDashboardData, fetchUserProfile } from '@/lib/github'; const { mockNotFound } = vi.hoisted(() => ({ mockNotFound: vi.fn(), @@ -21,7 +22,39 @@ vi.mock('next/navigation', () => ({ })); vi.mock('@/lib/github', () => ({ - getFullDashboardData: vi.fn(), + getFullDashboardData: vi.fn().mockReturnValue(Promise.resolve({ + profile: { + login: 'octocat', + avatar_url: 'https://avatars.githubusercontent.com/u/583231?v=4', + html_url: 'https://github.com/octocat', + name: 'The Octocat', + bio: null, + company: '@github', + blog: 'https://github.blog', + location: 'San Francisco', + email: null, + hireable: null, + twitter_username: null, + public_repos: 8, + public_gists: 8, + followers: 3938, + following: 9, + created_at: '2011-01-25T18:44:36Z', + updated_at: '2023-01-22T12:13:14Z', + }, + stats: { + currentStreak: 5, + peakStreak: 15, + totalContributions: 500, + }, + activity: [], + languages: [], + commitTimes: [], + achievements: [], + recommendations: [], + })), + fetchUserProfile: vi.fn().mockResolvedValue({ type: 'User', name: '' }), + fetchUserRepos: vi.fn().mockResolvedValue([]), })); // --- Mocking Core UI Blocks --- @@ -222,7 +255,10 @@ describe('DashboardPage', () => { const DashboardContent = SuspenseTree.props.children.type; const PageContent = await DashboardContent(SuspenseTree.props.children.props); - render(PageContent); + await act(async () => { + render({PageContent}); + }); + await waitFor(() => expect(screen.queryByText('loading')).not.toBeInTheDocument()); expect(getFullDashboardData).toHaveBeenCalledWith( 'octocat', @@ -258,7 +294,10 @@ describe('DashboardPage', () => { const DashboardContent = SuspenseTree.props.children.type; const PageContent = await DashboardContent(SuspenseTree.props.children.props); - render(PageContent); + await act(async () => { + render({PageContent}); + }); + await waitFor(() => expect(screen.queryByText('loading')).not.toBeInTheDocument()); expect(getFullDashboardData).toHaveBeenCalledWith( 'octocat', @@ -279,7 +318,10 @@ describe('DashboardPage', () => { const DashboardContent = SuspenseTree.props.children.type; const PageContent = await DashboardContent(SuspenseTree.props.children.props); - render(PageContent); + await act(async () => { + render({PageContent}); + }); + await waitFor(() => expect(screen.queryByText('loading')).not.toBeInTheDocument()); expect(getFullDashboardData).toHaveBeenCalledWith( 'octocat', @@ -300,14 +342,17 @@ describe('DashboardPage', () => { const DashboardContent = SuspenseTree.props.children.type; const PageContent = await DashboardContent(SuspenseTree.props.children.props); - render(PageContent); + await act(async () => { + render({PageContent}); + }); + await waitFor(() => expect(screen.queryByText('loading')).not.toBeInTheDocument()); const trendView = screen.getByTestId('historical-trend-view'); expect(JSON.parse(trendView.getAttribute('data-prop') ?? '[]')).toEqual(mockData.activity); }); - it('calls notFound when dashboard data fetch throws an error', async () => { - vi.mocked(getFullDashboardData).mockRejectedValueOnce(new Error('User not found')); + it('calls notFound when fetchUserProfile throws an error', async () => { + vi.mocked(fetchUserProfile).mockRejectedValueOnce(new Error('Fetch failed')); const SuspenseTree = await DashboardPage({ params: Promise.resolve({ username: 'missing-user' }), diff --git a/app/burnout-analyzer/page.test.tsx b/app/burnout-analyzer/page.test.tsx index 6ee7ee08c..21d97249f 100644 --- a/app/burnout-analyzer/page.test.tsx +++ b/app/burnout-analyzer/page.test.tsx @@ -20,8 +20,17 @@ vi.mock('next/navigation', () => ({ }), })); +vi.mock('framer-motion', async () => { + const actual = await vi.importActual('framer-motion'); + return { + ...actual as any, + AnimatePresence: ({ children }: any) => <>{children}, + }; +}); + describe('BurnoutAnalyzerPage repository input handling', () => { beforeEach(() => { + vi.restoreAllMocks(); vi.clearAllMocks(); mockHistoryBack.mockReset(); mockRouterPush.mockReset(); @@ -115,14 +124,10 @@ describe('BurnoutAnalyzerPage repository input handling', () => { }), }); vi.stubGlobal('fetch', fetchMock); - Object.defineProperty(window, 'history', { - value: { length: 2, back: mockHistoryBack }, - configurable: true, - }); - Object.defineProperty(document, 'referrer', { - value: 'http://localhost/burnout-analyzer', - configurable: true, - }); + vi.spyOn(window.history, 'length', 'get').mockReturnValue(2); + vi.spyOn(window.history, 'back').mockImplementation(mockHistoryBack as any); + vi.spyOn(window.history, 'pushState').mockImplementation(vi.fn()); + vi.spyOn(document, 'referrer', 'get').mockReturnValue('http://localhost/burnout-analyzer'); render(); fireEvent.change(screen.getByPlaceholderText(/facebook\/react/i), { @@ -154,14 +159,10 @@ describe('BurnoutAnalyzerPage repository input handling', () => { }), }); vi.stubGlobal('fetch', fetchMock); - Object.defineProperty(window, 'history', { - value: { length: 1, back: mockHistoryBack }, - configurable: true, - }); - Object.defineProperty(document, 'referrer', { - value: 'http://localhost/another-page', - configurable: true, - }); + vi.spyOn(window.history, 'length', 'get').mockReturnValue(1); + vi.spyOn(window.history, 'back').mockImplementation(mockHistoryBack as any); + vi.spyOn(window.history, 'pushState').mockImplementation(vi.fn()); + vi.spyOn(document, 'referrer', 'get').mockReturnValue('http://localhost/another-page'); render(); fireEvent.change(screen.getByPlaceholderText(/facebook\/react/i), { From ee2e601fcb937de7a8e131e67cb51236aae68b4c Mon Sep 17 00:00:00 2001 From: armanpanigrahi59 <11309arman@gmail.com> Date: Fri, 14 Aug 2026 19:13:26 +0530 Subject: [PATCH 09/17] style: fix prettier formatting issues in test files --- app/(root)/dashboard/[username]/page.test.tsx | 64 ++++++++++--------- app/burnout-analyzer/page.test.tsx | 2 +- 2 files changed, 34 insertions(+), 32 deletions(-) diff --git a/app/(root)/dashboard/[username]/page.test.tsx b/app/(root)/dashboard/[username]/page.test.tsx index d28652544..4ab49e919 100644 --- a/app/(root)/dashboard/[username]/page.test.tsx +++ b/app/(root)/dashboard/[username]/page.test.tsx @@ -22,37 +22,39 @@ vi.mock('next/navigation', () => ({ })); vi.mock('@/lib/github', () => ({ - getFullDashboardData: vi.fn().mockReturnValue(Promise.resolve({ - profile: { - login: 'octocat', - avatar_url: 'https://avatars.githubusercontent.com/u/583231?v=4', - html_url: 'https://github.com/octocat', - name: 'The Octocat', - bio: null, - company: '@github', - blog: 'https://github.blog', - location: 'San Francisco', - email: null, - hireable: null, - twitter_username: null, - public_repos: 8, - public_gists: 8, - followers: 3938, - following: 9, - created_at: '2011-01-25T18:44:36Z', - updated_at: '2023-01-22T12:13:14Z', - }, - stats: { - currentStreak: 5, - peakStreak: 15, - totalContributions: 500, - }, - activity: [], - languages: [], - commitTimes: [], - achievements: [], - recommendations: [], - })), + getFullDashboardData: vi.fn().mockReturnValue( + Promise.resolve({ + profile: { + login: 'octocat', + avatar_url: 'https://avatars.githubusercontent.com/u/583231?v=4', + html_url: 'https://github.com/octocat', + name: 'The Octocat', + bio: null, + company: '@github', + blog: 'https://github.blog', + location: 'San Francisco', + email: null, + hireable: null, + twitter_username: null, + public_repos: 8, + public_gists: 8, + followers: 3938, + following: 9, + created_at: '2011-01-25T18:44:36Z', + updated_at: '2023-01-22T12:13:14Z', + }, + stats: { + currentStreak: 5, + peakStreak: 15, + totalContributions: 500, + }, + activity: [], + languages: [], + commitTimes: [], + achievements: [], + recommendations: [], + }) + ), fetchUserProfile: vi.fn().mockResolvedValue({ type: 'User', name: '' }), fetchUserRepos: vi.fn().mockResolvedValue([]), })); diff --git a/app/burnout-analyzer/page.test.tsx b/app/burnout-analyzer/page.test.tsx index 21d97249f..14cc27812 100644 --- a/app/burnout-analyzer/page.test.tsx +++ b/app/burnout-analyzer/page.test.tsx @@ -23,7 +23,7 @@ vi.mock('next/navigation', () => ({ vi.mock('framer-motion', async () => { const actual = await vi.importActual('framer-motion'); return { - ...actual as any, + ...(actual as any), AnimatePresence: ({ children }: any) => <>{children}, }; }); From e4251705ee2ce9a04aff3e83252a6214d6371906 Mon Sep 17 00:00:00 2001 From: armanpanigrahi59 <11309arman@gmail.com> Date: Fri, 14 Aug 2026 19:49:00 +0530 Subject: [PATCH 10/17] fix: resolve ESLint errors and specific warnings --- app/burnout-analyzer/page.test.tsx | 8 ++++---- services/github/webhook-handler.ts | 4 ++-- test-utils/timezone-mock.ts | 1 - utils/copyToClipboard.test.ts | 2 +- 4 files changed, 7 insertions(+), 8 deletions(-) diff --git a/app/burnout-analyzer/page.test.tsx b/app/burnout-analyzer/page.test.tsx index 14cc27812..eb7b7c089 100644 --- a/app/burnout-analyzer/page.test.tsx +++ b/app/burnout-analyzer/page.test.tsx @@ -23,8 +23,8 @@ vi.mock('next/navigation', () => ({ vi.mock('framer-motion', async () => { const actual = await vi.importActual('framer-motion'); return { - ...(actual as any), - AnimatePresence: ({ children }: any) => <>{children}, + ...(actual as object), + AnimatePresence: ({ children }: { children: React.ReactNode }) => <>{children}, }; }); @@ -125,7 +125,7 @@ describe('BurnoutAnalyzerPage repository input handling', () => { }); vi.stubGlobal('fetch', fetchMock); vi.spyOn(window.history, 'length', 'get').mockReturnValue(2); - vi.spyOn(window.history, 'back').mockImplementation(mockHistoryBack as any); + vi.spyOn(window.history, 'back').mockImplementation(mockHistoryBack); vi.spyOn(window.history, 'pushState').mockImplementation(vi.fn()); vi.spyOn(document, 'referrer', 'get').mockReturnValue('http://localhost/burnout-analyzer'); @@ -160,7 +160,7 @@ describe('BurnoutAnalyzerPage repository input handling', () => { }); vi.stubGlobal('fetch', fetchMock); vi.spyOn(window.history, 'length', 'get').mockReturnValue(1); - vi.spyOn(window.history, 'back').mockImplementation(mockHistoryBack as any); + vi.spyOn(window.history, 'back').mockImplementation(mockHistoryBack); vi.spyOn(window.history, 'pushState').mockImplementation(vi.fn()); vi.spyOn(document, 'referrer', 'get').mockReturnValue('http://localhost/another-page'); diff --git a/services/github/webhook-handler.ts b/services/github/webhook-handler.ts index ef4760bee..28eb1db40 100644 --- a/services/github/webhook-handler.ts +++ b/services/github/webhook-handler.ts @@ -1,6 +1,6 @@ import { DistributedCache } from '@/lib/cache'; import { redactSecrets } from '@/lib/secretScanner'; -import type { CIWorkflowRun, CIInsights } from '@/types/ci-analytics'; +import type { Repository } from '@/types/ci-analytics'; interface WebhookPayload { action?: string; @@ -226,7 +226,7 @@ ${Object.entries(event.details) .join('\n')} `; - console.log(`Email alert would be sent to ${email}:`, { subject, body }); + console.info(`Email alert would be sent to ${email}:`, { subject, body }); } catch (error) { console.error('Failed to send email alert:', error); } diff --git a/test-utils/timezone-mock.ts b/test-utils/timezone-mock.ts index e0279d289..d3caebf9c 100644 --- a/test-utils/timezone-mock.ts +++ b/test-utils/timezone-mock.ts @@ -4,7 +4,6 @@ // Ensures that timezone-sensitive tests produce consistent results // regardless of the system timezone (e.g., UTC on CI vs local dev timezone). -import { vi } from 'vitest'; /** * A map of IANA timezone identifiers to their UTC offsets in minutes. diff --git a/utils/copyToClipboard.test.ts b/utils/copyToClipboard.test.ts index ba4ec054a..baca7f3f1 100644 --- a/utils/copyToClipboard.test.ts +++ b/utils/copyToClipboard.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, vi, afterEach } from 'vitest'; import { copyToClipboard } from './clipboard'; // --------------------------------------------------------------------------- From 6ea91e71998ee9be91872aaf97b236cd1cb99bf9 Mon Sep 17 00:00:00 2001 From: armanpanigrahi59 <11309arman@gmail.com> Date: Fri, 14 Aug 2026 21:02:50 +0530 Subject: [PATCH 11/17] fix: remove non-existent Repository type import --- services/github/webhook-handler.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/services/github/webhook-handler.ts b/services/github/webhook-handler.ts index 28eb1db40..0973faaf6 100644 --- a/services/github/webhook-handler.ts +++ b/services/github/webhook-handler.ts @@ -1,6 +1,5 @@ import { DistributedCache } from '@/lib/cache'; import { redactSecrets } from '@/lib/secretScanner'; -import type { Repository } from '@/types/ci-analytics'; interface WebhookPayload { action?: string; From b5d511bcf08e179d6251adb98c895e7c353e5d01 Mon Sep 17 00:00:00 2001 From: armanpanigrahi59 <11309arman@gmail.com> Date: Fri, 14 Aug 2026 21:23:13 +0530 Subject: [PATCH 12/17] style: run prettier on timezone-mock.ts --- test-utils/timezone-mock.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/test-utils/timezone-mock.ts b/test-utils/timezone-mock.ts index d3caebf9c..ba8313006 100644 --- a/test-utils/timezone-mock.ts +++ b/test-utils/timezone-mock.ts @@ -4,7 +4,6 @@ // Ensures that timezone-sensitive tests produce consistent results // regardless of the system timezone (e.g., UTC on CI vs local dev timezone). - /** * A map of IANA timezone identifiers to their UTC offsets in minutes. * Positive values are east of UTC (ahead), negative values are west (behind). From e4d3ce2a5be496c58710e46a8b3c5fb6666eeb6f Mon Sep 17 00:00:00 2001 From: armanpanigrahi59 <11309arman@gmail.com> Date: Sat, 15 Aug 2026 00:11:44 +0530 Subject: [PATCH 13/17] feat: add multi-stage Docker builds (#876) Resolves #876 by implementing multi-stage Docker build process to optimize image size and streamline deployment. Adds validation tests. --- .dockerignore | 48 ++++++++++++++++++++++---- Dockerfile | 22 +++++++++--- README.md | 40 +++++++++++----------- docker-compose.yml | 1 + docs/self_hosting.md | 33 ++++++++++++++++++ tests/dockerfile.test.ts | 73 ++++++++++++++++++++++++++++++++++++++++ 6 files changed, 187 insertions(+), 30 deletions(-) create mode 100644 tests/dockerfile.test.ts diff --git a/.dockerignore b/.dockerignore index 318bd7d39..5baf6c890 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,20 +1,54 @@ +# Dependencies & build outputs node_modules .next -.github +out +build +dist +coverage +.vitest + +# Source control & CI .git +.github .gitignore +.gitattributes +.husky -coverage +# IDE & Editor settings +.vscode +.idea +*.swp +*.swo +.DS_Store + +# Logs & debugging *.log npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +eslint_report.json +eslint_errors.txt +tsc_report.txt -.env.local +# Environment files .env +.env*.local +.env.production +.env.development -Dockerfile +# Local Docker & documentation +Dockerfile* +docker-compose*.yml +.dockerignore README.md - -.vscode -.idea +docs +CHANGELOG.md +CODE_OF_CONDUCT.md +CONTRIBUTING.md +LICENSE +SECURITY.md +THEMES.md +THEME_DEVELOPMENT.md !.env.local.example \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 379bb3082..ac5983045 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,33 +1,47 @@ -#base image +# Base stage for common dependencies and environment setup FROM node:22-alpine AS base +# Install libc6-compat for compatibility with native libraries on Alpine Linux +RUN apk add --no-cache libc6-compat WORKDIR /app +# Dependencies stage: Install dependencies cleanly based on package-lock.json FROM base AS deps COPY package*.json ./ RUN npm ci +# Builder stage: Build Next.js application in standalone mode FROM base AS builder +WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . . + +ENV NEXT_TELEMETRY_DISABLED=1 +ENV NODE_ENV=production + RUN npm run build -#production image +# Runner stage: Production image containing only runtime dependencies FROM node:22-alpine AS runner WORKDIR /app ENV NODE_ENV=production ENV PORT=3000 +ENV HOSTNAME="0.0.0.0" ENV NEXT_TELEMETRY_DISABLED=1 RUN addgroup --system --gid 1001 nodejs RUN adduser --system --uid 1001 nextjs +COPY --from=builder /app/public ./public + +# Set up runtime permissions for Next.js cache directory +RUN mkdir .next && chown nextjs:nodejs .next + COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ -COPY --from=builder --chown=nextjs:nodejs /app/public ./public COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static USER nextjs EXPOSE 3000 -CMD [ "node", "server.js" ] \ No newline at end of file +CMD ["node", "server.js"] \ No newline at end of file diff --git a/README.md b/README.md index 5ec644faf..1a9cf8139 100644 --- a/README.md +++ b/README.md @@ -239,16 +239,23 @@ npm run dev Then visit: `http://localhost:3000/api/streak?user=YOUR_USERNAME` -## 🐳 Docker +## 🐳 Docker Multi-Stage Deployment -CommitPulse includes Docker support for consistent local development and production deployments. +CommitPulse features lightweight, optimized multi-stage Docker builds to streamline deployment and minimize container image size. + +### Build Stages Architecture + +- **`base`**: Node 22 Alpine base image with `libc6-compat` native library support. +- **`deps`**: Installs application dependencies cleanly via `npm ci`. +- **`builder`**: Compiles Next.js into a production standalone bundle (`.next/standalone`). +- **`runner`**: Minimal production environment running as an unprivileged non-root (`nextjs`) user listening on `0.0.0.0:3000`. ### Prerequisites -- Docker -- Docker Compose +- Docker Engine 20.10+ +- Docker Compose v2+ -### Local Development +### Local Development with Docker Compose 1. Copy the example environment file: @@ -256,7 +263,7 @@ CommitPulse includes Docker support for consistent local development and product cp .env.local.example .env.local ``` -2. Update the required environment variables in `.env.local` (such as `GITHUB_TOKEN`, `AUTH_SECRET`, and any optional integrations you plan to use). +2. Update the required environment variables in `.env.local` (such as `GITHUB_TOKEN`, `AUTH_SECRET`, and optional integrations). 3. Start the application and MongoDB: @@ -264,29 +271,24 @@ cp .env.local.example .env.local docker compose up --build ``` -The application will be available at: - -```text -http://localhost:3000 -``` - -MongoDB is automatically provisioned through Docker Compose. The `MONGODB_URI` is overridden to use the local MongoDB container, so no additional database configuration is required. +The application will be available at `http://localhost:3000`. MongoDB is automatically provisioned and linked (`MONGODB_URI=mongodb://mongodb:27017/commitpulse`). -### Production +### Multi-Stage Production Build -Build the production image: +Build the optimized production image using the `runner` target: ```bash -docker build -t commitpulse . +docker build --target runner -t commitpulse:latest . ``` -Run the container: +Run the production container: ```bash -docker run \ +docker run -d \ + --name commitpulse \ --env-file .env.local \ -p 3000:3000 \ - commitpulse + commitpulse:latest ``` ### 🌐 Deploy to Vercel diff --git a/docker-compose.yml b/docker-compose.yml index 60098dadb..fd814af01 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,6 +3,7 @@ services: build: context: . dockerfile: Dockerfile + target: runner container_name: commitpulse-app ports: - '3000:3000' diff --git a/docs/self_hosting.md b/docs/self_hosting.md index 79ac8bfcd..2fd412e79 100644 --- a/docs/self_hosting.md +++ b/docs/self_hosting.md @@ -79,6 +79,39 @@ Badge/SVG contribution data is cached and refreshes automatically once the cache This step is entirely optional — without it, badges still update on their own once the cache expires. +## 🐳 Containerized Self-Hosting (Docker Multi-Stage) + +CommitPulse includes a production-grade multi-stage `Dockerfile` (`base` → `deps` → `builder` → `runner`) to deliver a lightweight container footprint and secure execution as an unprivileged user (`nextjs`). + +### Option 1: Docker Compose (Recommended) + +1. Ensure `.env.local` exists with your `GITHUB_TOKEN`. +2. Start the full stack (CommitPulse application + MongoDB): + +```bash +docker compose up -d --build +``` + +3. Access the application at `http://localhost:3000`. + +### Option 2: Standalone Multi-Stage Docker Image + +1. Build the production image targeting the `runner` stage: + +```bash +docker build --target runner -t commitpulse:latest . +``` + +2. Run the container: + +```bash +docker run -d \ + --name commitpulse \ + --env-file .env.local \ + -p 3000:3000 \ + commitpulse:latest +``` + --- ## 🌐 Deploy Your Own diff --git a/tests/dockerfile.test.ts b/tests/dockerfile.test.ts new file mode 100644 index 000000000..c31cd4286 --- /dev/null +++ b/tests/dockerfile.test.ts @@ -0,0 +1,73 @@ +import { describe, it, expect } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; + +describe('Docker Multi-Stage Containerization Setup', () => { + const rootDir = path.resolve(__dirname, '..'); + const dockerfilePath = path.join(rootDir, 'Dockerfile'); + const dockerignorePath = path.join(rootDir, '.dockerignore'); + const dockerComposePath = path.join(rootDir, 'docker-compose.yml'); + const nextConfigPath = path.join(rootDir, 'next.config.ts'); + + it('should have Dockerfile configured with multi-stage build stages', () => { + expect(fs.existsSync(dockerfilePath)).toBe(true); + const content = fs.readFileSync(dockerfilePath, 'utf-8'); + + // Multi-stage stages + expect(content).toMatch(/FROM\s+node:22-alpine\s+AS\s+base/i); + expect(content).toMatch(/FROM\s+base\s+AS\s+deps/i); + expect(content).toMatch(/FROM\s+base\s+AS\s+builder/i); + expect(content).toMatch(/FROM\s+node:22-alpine\s+AS\s+runner/i); + }); + + it('should include necessary Alpine compatibility libraries and environment variables', () => { + const content = fs.readFileSync(dockerfilePath, 'utf-8'); + + expect(content).toContain('libc6-compat'); + expect(content).toContain('ENV HOSTNAME="0.0.0.0"'); + expect(content).toContain('ENV NODE_ENV=production'); + expect(content).toContain('ENV NEXT_TELEMETRY_DISABLED=1'); + }); + + it('should run as non-root user for security compliance', () => { + const content = fs.readFileSync(dockerfilePath, 'utf-8'); + + expect(content).toContain('addgroup'); + expect(content).toContain('adduser'); + expect(content).toContain('USER nextjs'); + }); + + it('should leverage Next.js standalone build artifacts', () => { + const content = fs.readFileSync(dockerfilePath, 'utf-8'); + + expect(content).toContain('.next/standalone'); + expect(content).toContain('.next/static'); + expect(content).toContain('public'); + }); + + it('should exclude unnecessary build files and secrets in .dockerignore', () => { + expect(fs.existsSync(dockerignorePath)).toBe(true); + const content = fs.readFileSync(dockerignorePath, 'utf-8'); + + expect(content).toContain('node_modules'); + expect(content).toContain('.next'); + expect(content).toContain('.git'); + expect(content).toContain('.env*.local'); + expect(content).toContain('coverage'); + }); + + it('should target the runner stage in docker-compose.yml', () => { + expect(fs.existsSync(dockerComposePath)).toBe(true); + const content = fs.readFileSync(dockerComposePath, 'utf-8'); + + expect(content).toContain('dockerfile: Dockerfile'); + expect(content).toContain('target: runner'); + }); + + it('should have next.config.ts set to standalone output mode', () => { + expect(fs.existsSync(nextConfigPath)).toBe(true); + const content = fs.readFileSync(nextConfigPath, 'utf-8'); + + expect(content).toContain("output: 'standalone'"); + }); +}); From 4678e4a0a406dee56753b3344ed3b011cc11bde1 Mon Sep 17 00:00:00 2001 From: armanpanigrahi59 <11309arman@gmail.com> Date: Sat, 15 Aug 2026 21:00:08 +0530 Subject: [PATCH 14/17] fix(api): validate invalid user query in /api/streak endpoint --- app/api/streak/route.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/app/api/streak/route.ts b/app/api/streak/route.ts index d0d3df85c..c9f0b57aa 100644 --- a/app/api/streak/route.ts +++ b/app/api/streak/route.ts @@ -102,6 +102,24 @@ export async function GET(request: Request) { fieldErrors.formErrors[0] ?? 'Invalid parameters'; + if ( + firstError === 'Missing user parameter' || + firstError === 'Invalid GitHub username' || + firstError === 'GitHub username cannot exceed 39 characters' + ) { + return NextResponse.json( + { success: false, message: 'Invalid or missing user parameter' }, + { + status: 400, + headers: { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + 'X-Request-ID': requestId, + }, + } + ); + } + if (searchParams.get('format') === 'json') { return NextResponse.json( { error: firstError }, From bcb764348387c616e9b77381c38d5fcf29693bd8 Mon Sep 17 00:00:00 2001 From: armanpanigrahi59 <11309arman@gmail.com> Date: Sun, 16 Aug 2026 00:57:20 +0530 Subject: [PATCH 15/17] fix: address false-positive sqli on login/signup routes (#554) --- app/api/auth/login/route.ts | 12 ++++++++++++ app/api/auth/signup/route.ts | 14 ++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/app/api/auth/login/route.ts b/app/api/auth/login/route.ts index 3c1031007..46da830db 100644 --- a/app/api/auth/login/route.ts +++ b/app/api/auth/login/route.ts @@ -19,6 +19,18 @@ export async function POST(request: Request): Promise { ); } + // Sanitize and validate input to prevent SQL/NoSQL injection payloads + const isEmail = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(identifier); + const isUsername = /^[a-zA-Z0-9_-]{3,39}$/.test(identifier); + + if (!isEmail && !isUsername) { + return NextResponse.json({ error: 'Invalid Email or Username format.' }, { status: 400 }); + } + + if (password.length < 1 || password.length > 255) { + return NextResponse.json({ error: 'Invalid password format.' }, { status: 400 }); + } + return NextResponse.json( { success: true, diff --git a/app/api/auth/signup/route.ts b/app/api/auth/signup/route.ts index a763e85d9..6d292355b 100644 --- a/app/api/auth/signup/route.ts +++ b/app/api/auth/signup/route.ts @@ -20,6 +20,20 @@ export async function POST(request: Request): Promise { ); } + // Sanitize and validate input to prevent SQL/NoSQL injection payloads + const isEmail = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); + if (!isEmail) { + return NextResponse.json({ error: 'Invalid Email format.' }, { status: 400 }); + } + + if (fullName.length < 2 || fullName.length > 100) { + return NextResponse.json({ error: 'Invalid Full Name format.' }, { status: 400 }); + } + + if (password.length < 1 || password.length > 255) { + return NextResponse.json({ error: 'Invalid password format.' }, { status: 400 }); + } + return NextResponse.json( { success: true, From b7fbe27f0dd9ccf12a921fd1747caa07dd1ce3ca Mon Sep 17 00:00:00 2001 From: armanpanigrahi59 <11309arman@gmail.com> Date: Sun, 16 Aug 2026 02:15:13 +0530 Subject: [PATCH 16/17] fix: resolve CodeQL alerts with Zod validation --- app/api/auth/login/route.ts | 21 +++++++++++---------- app/api/auth/signup/route.ts | 27 ++++++++++++--------------- 2 files changed, 23 insertions(+), 25 deletions(-) diff --git a/app/api/auth/login/route.ts b/app/api/auth/login/route.ts index 46da830db..2c8e83fe4 100644 --- a/app/api/auth/login/route.ts +++ b/app/api/auth/login/route.ts @@ -1,4 +1,10 @@ import { NextResponse } from 'next/server'; +import { z } from 'zod'; + +const loginSchema = z.object({ + identifier: z.string().min(3).max(255), + password: z.string().min(1).max(255), +}); /** * Handles credentials authentication requests for frontend login. @@ -7,18 +13,17 @@ import { NextResponse } from 'next/server'; export async function POST(request: Request): Promise { try { const body = await request.json(); - const { identifier, password } = body as { - identifier?: string; - password?: string; - }; + const result = loginSchema.safeParse(body); - if (!identifier || !password) { + if (!result.success) { return NextResponse.json( - { error: 'Email or Username and Password are required.' }, + { error: 'Email or Username and Password are required and must be valid.' }, { status: 400 } ); } + const { identifier } = result.data; + // Sanitize and validate input to prevent SQL/NoSQL injection payloads const isEmail = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(identifier); const isUsername = /^[a-zA-Z0-9_-]{3,39}$/.test(identifier); @@ -27,10 +32,6 @@ export async function POST(request: Request): Promise { return NextResponse.json({ error: 'Invalid Email or Username format.' }, { status: 400 }); } - if (password.length < 1 || password.length > 255) { - return NextResponse.json({ error: 'Invalid password format.' }, { status: 400 }); - } - return NextResponse.json( { success: true, diff --git a/app/api/auth/signup/route.ts b/app/api/auth/signup/route.ts index 6d292355b..723db85e3 100644 --- a/app/api/auth/signup/route.ts +++ b/app/api/auth/signup/route.ts @@ -1,4 +1,11 @@ import { NextResponse } from 'next/server'; +import { z } from 'zod'; + +const signupSchema = z.object({ + fullName: z.string().min(2).max(100), + email: z.string().email(), + password: z.string().min(1).max(255), +}); /** * Handles user registration requests for frontend signup. @@ -7,33 +14,23 @@ import { NextResponse } from 'next/server'; export async function POST(request: Request): Promise { try { const body = await request.json(); - const { fullName, email, password } = body as { - fullName?: string; - email?: string; - password?: string; - }; + const result = signupSchema.safeParse(body); - if (!fullName || !email || !password) { + if (!result.success) { return NextResponse.json( - { error: 'Full Name, Email, and Password are required.' }, + { error: 'Full Name, Email, and Password are required and must be valid.' }, { status: 400 } ); } + const { fullName, email } = result.data; + // Sanitize and validate input to prevent SQL/NoSQL injection payloads const isEmail = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); if (!isEmail) { return NextResponse.json({ error: 'Invalid Email format.' }, { status: 400 }); } - if (fullName.length < 2 || fullName.length > 100) { - return NextResponse.json({ error: 'Invalid Full Name format.' }, { status: 400 }); - } - - if (password.length < 1 || password.length > 255) { - return NextResponse.json({ error: 'Invalid password format.' }, { status: 400 }); - } - return NextResponse.json( { success: true, From 64a0eb9f1fbbe35f3189c3da533f42f0c7839449 Mon Sep 17 00:00:00 2001 From: armanpanigrahi59 <11309arman@gmail.com> Date: Sun, 16 Aug 2026 11:04:53 +0530 Subject: [PATCH 17/17] fix(api): restore SVG error rendering for invalid user query --- app/api/streak/route.ts | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/app/api/streak/route.ts b/app/api/streak/route.ts index c9f0b57aa..d0d3df85c 100644 --- a/app/api/streak/route.ts +++ b/app/api/streak/route.ts @@ -102,24 +102,6 @@ export async function GET(request: Request) { fieldErrors.formErrors[0] ?? 'Invalid parameters'; - if ( - firstError === 'Missing user parameter' || - firstError === 'Invalid GitHub username' || - firstError === 'GitHub username cannot exceed 39 characters' - ) { - return NextResponse.json( - { success: false, message: 'Invalid or missing user parameter' }, - { - status: 400, - headers: { - 'Content-Type': 'application/json', - 'Cache-Control': 'no-store', - 'X-Request-ID': requestId, - }, - } - ); - } - if (searchParams.get('format') === 'json') { return NextResponse.json( { error: firstError },