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/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.test.tsx b/app/(root)/dashboard/[username]/page.test.tsx
index 4ed6853c0..4ab49e919 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,41 @@ 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 +257,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 +296,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 +320,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 +344,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/(root)/dashboard/[username]/page.tsx b/app/(root)/dashboard/[username]/page.tsx
index 7063302b7..a1eda1ecc 100644
--- a/app/(root)/dashboard/[username]/page.tsx
+++ b/app/(root)/dashboard/[username]/page.tsx
@@ -136,36 +136,29 @@ 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}`);
+ }
+
+ const data = getFullDashboardData(username, {
+ bypassCache,
+ from: period.from,
+ to: period.to,
+ rangeLabel: period.label,
+ token: userToken,
+ excludeBots,
+ });
+
let allRepos: RepoActivityInfo[] = [];
try {
const reposData = await fetchUserRepos(username, { bypassCache, token: userToken });
@@ -196,7 +189,7 @@ async function DashboardContent({
{
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);
+
+ if (!isEmail && !isUsername) {
+ return NextResponse.json({ error: 'Invalid Email or Username 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..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,19 +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 });
+ }
+
return NextResponse.json(
{
success: true,
diff --git a/app/api/streak/route.test.ts b/app/api/streak/route.test.ts
index bc3786984..728b52321 100644
--- a/app/api/streak/route.test.ts
+++ b/app/api/streak/route.test.ts
@@ -197,6 +197,26 @@ 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 44e5e205c..cb45c205c 100644
--- a/app/api/streak/route.ts
+++ b/app/api/streak/route.ts
@@ -101,6 +101,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,
@@ -1035,7 +1050,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/app/burnout-analyzer/page.test.tsx b/app/burnout-analyzer/page.test.tsx
index 6ee7ee08c..eb7b7c089 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 object),
+ AnimatePresence: ({ children }: { children: React.ReactNode }) => <>{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);
+ 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);
+ 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), {
diff --git a/components/dashboard/DashboardClient.tsx b/components/dashboard/DashboardClient.tsx
index 0b60ee194..77e07b68b 100644
--- a/components/dashboard/DashboardClient.tsx
+++ b/components/dashboard/DashboardClient.tsx
@@ -1,7 +1,7 @@
'use client';
import { copyToClipboard } from '@/utils/clipboard';
-import { useState, useEffect, useRef, useCallback, useSyncExternalStore } from 'react';
+import { useState, useRef, useEffect, useCallback, useSyncExternalStore, use } from 'react';
import { createPortal } from 'react-dom';
import { AnimatePresence, motion } from 'framer-motion';
import DashboardSkeleton from './DashboardSkeleton';
@@ -106,8 +106,9 @@ export interface DashboardData {
rawCommits?: string[];
}
-interface DashboardClientProps {
- initialData: DashboardData;
+export interface DashboardClientProps {
+ initialDataPromise?: Promise;
+ initialData?: DashboardData;
allRepoActivity?: RepoActivityInfo[];
username: string;
compareData?: DashboardData | null;
@@ -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'
diff --git a/components/dashboard/DashboardClient.type-compiler.test.tsx b/components/dashboard/DashboardClient.type-compiler.test.tsx
index a9d0bcb87..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 } 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 +20,31 @@ 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 +64,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 +77,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>();
});
});
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/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 b1834c1d1..32734bcbb 100644
--- a/lib/validations.ts
+++ b/lib/validations.ts
@@ -233,8 +233,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({
@@ -693,6 +694,11 @@ 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'],
@@ -1240,6 +1246,11 @@ 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'],
diff --git a/services/github/webhook-handler.ts b/services/github/webhook-handler.ts
index ef4760bee..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 { CIWorkflowRun, CIInsights } from '@/types/ci-analytics';
interface WebhookPayload {
action?: string;
@@ -226,7 +225,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..ba8313006 100644
--- a/test-utils/timezone-mock.ts
+++ b/test-utils/timezone-mock.ts
@@ -4,8 +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.
* Positive values are east of UTC (ahead), negative values are west (behind).
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'");
+ });
+});
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';
// ---------------------------------------------------------------------------