diff --git a/app/api/user/goals/route.ts b/app/api/user/goals/route.ts new file mode 100644 index 000000000..5902f70d4 --- /dev/null +++ b/app/api/user/goals/route.ts @@ -0,0 +1,152 @@ +import { NextResponse } from 'next/server'; +import { z } from 'zod'; +import { validateCSRF } from '@/lib/security/csrf'; +import dbConnect from '@/lib/mongodb'; +import { User } from '@/models/User'; +import { getClientIp } from '@/utils/getClientIp'; +import { getRateLimitHeaders, RateLimiter } from '@/lib/rate-limit'; +import { githubUsernameSchema } from '@/lib/validations'; +import { sanitizeMongoPayload } from '@/utils/sanitize'; +import logger from '@/lib/logger'; + +// GET — 20 req / min per IP (read-heavy, cheap) +const goalsReadLimiter = new RateLimiter(20, 60_000, 1); + +// PATCH — 10 req / min per IP (write-side, slightly stricter) +const goalsWriteLimiter = new RateLimiter(10, 60_000, 1); + +const DEFAULT_GOALS = { monthly: 100, yearly: 1000 }; + +const patchBodySchema = z.object({ + username: githubUsernameSchema, + monthly: z.number().int().min(1).max(99_999), + yearly: z.number().int().min(1).max(9_999_999), +}); + +// --------------------------------------------------------------------------- +// GET /api/user/goals?username= +// Returns the stored goals for the user, or defaults when none are saved yet. +// --------------------------------------------------------------------------- +export async function GET(request: Request) { + const ip = getClientIp(request); + const rateLimitKey = + ip && ip !== 'unknown' ? ip : `unknown:${request.headers.get('user-agent') ?? 'no-agent'}`; + + const rateLimitResult = await goalsReadLimiter.checkWithResult(rateLimitKey); + if (!rateLimitResult.success) { + return NextResponse.json( + { error: 'Too many requests. Please try again later.' }, + { status: 429, headers: getRateLimitHeaders(rateLimitResult) } + ); + } + + const { searchParams } = new URL(request.url); + const rawUsername = searchParams.get('username')?.trim(); + + if (!rawUsername) { + return NextResponse.json({ error: 'Username is required' }, { status: 400 }); + } + + const parsed = githubUsernameSchema.safeParse(rawUsername); + if (!parsed.success) { + return NextResponse.json({ error: 'Invalid GitHub username' }, { status: 400 }); + } + + const username = parsed.data.toLowerCase(); + + // When DB is not configured, return defaults so the client degrades gracefully. + if (!process.env.MONGODB_URI) { + return NextResponse.json({ goals: DEFAULT_GOALS, source: 'default' }); + } + + try { + await dbConnect(); + const user = await User.findOne({ username }).select('goals').lean(); + + if (!user || !user.goals) { + return NextResponse.json({ goals: DEFAULT_GOALS, source: 'default' }); + } + + return NextResponse.json({ + goals: { + monthly: user.goals.monthly ?? DEFAULT_GOALS.monthly, + yearly: user.goals.yearly ?? DEFAULT_GOALS.yearly, + }, + source: 'db', + }); + } catch (error) { + logger.error('Failed to fetch user goals', { route: '/api/user/goals', error }); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +// --------------------------------------------------------------------------- +// PATCH /api/user/goals +// Body: { username, monthly, yearly } +// Upserts the goals sub-document on the user record. +// --------------------------------------------------------------------------- +export async function PATCH(request: Request) { + const ip = getClientIp(request); + const rateLimitKey = + ip && ip !== 'unknown' ? ip : `unknown:${request.headers.get('user-agent') ?? 'no-agent'}`; + + const rateLimitResult = await goalsWriteLimiter.checkWithResult(rateLimitKey); + if (!rateLimitResult.success) { + return NextResponse.json( + { error: 'Too many requests. Please try again later.' }, + { status: 429, headers: getRateLimitHeaders(rateLimitResult) } + ); + } + + // CSRF validation — mirrors track-user route + const csrfError = validateCSRF(request); + if (csrfError) { + return NextResponse.json({ error: 'Origin not allowed' }, { status: 403 }); + } + + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: 'Malformed JSON request body' }, { status: 400 }); + } + + // Sanitize MongoDB operators from body + sanitizeMongoPayload(body); + + const parsed = patchBodySchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: 'Invalid request body', details: parsed.error.flatten().fieldErrors }, + { status: 400 } + ); + } + + const { username, monthly, yearly } = parsed.data; + const trimmedUsername = username.toLowerCase(); + + // Graceful bypass when DB is not configured (dev / CI without MongoDB) + if (!process.env.MONGODB_URI) { + logger.warn('Goals save bypassed: MONGODB_URI is not set', { + environment: process.env.NODE_ENV, + }); + return NextResponse.json({ success: true, bypassed: true }); + } + + try { + await dbConnect(); + await User.updateOne( + { username: trimmedUsername }, + { + $setOnInsert: { username: trimmedUsername }, + $set: { 'goals.monthly': monthly, 'goals.yearly': yearly }, + }, + { upsert: true } + ); + + return NextResponse.json({ success: true }); + } catch (error) { + logger.error('Failed to save user goals', { route: '/api/user/goals', error }); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/components/InteractiveViewer.tsx b/components/InteractiveViewer.tsx index ba6c65a49..0f29e9459 100644 --- a/components/InteractiveViewer.tsx +++ b/components/InteractiveViewer.tsx @@ -25,20 +25,23 @@ interface ParallaxParticle { * Deterministic math prevents random values from causing SSR/CSR mismatches. */ function buildParticles(): ParallaxParticle[] { const colors = ['#10b981', '#8b5cf6', '#06b6d4', '#3b82f6', '#f59e0b']; - return Array.from({ length: PARALLAX_PARTICLE_COUNT }, (_, i): ParallaxParticle => ({ - id: i, - // Spread particles across the container using prime-number strides - x: (i * 17 + 11) % 100, - y: (i * 23 + 7) % 100, - size: 4 + (i % 5) * 2, // range: 4–12 px - // Keep opacity low so particles never obscure the badge - opacity: 0.05 + (i % 4) * 0.025, // range: 0.05–0.125 - // Vary depth so each "layer" of particles shifts by a different amount, - // creating the illusion of 3-D depth. depth 0.1 = farthest; 0.7 = nearest. - depth: 0.1 + (i % 6) * 0.1, // range: 0.1–0.6 - color: colors[i % colors.length], - isCircle: i % 4 === 0, - })); + return Array.from( + { length: PARALLAX_PARTICLE_COUNT }, + (_, i): ParallaxParticle => ({ + id: i, + // Spread particles across the container using prime-number strides + x: (i * 17 + 11) % 100, + y: (i * 23 + 7) % 100, + size: 4 + (i % 5) * 2, // range: 4–12 px + // Keep opacity low so particles never obscure the badge + opacity: 0.05 + (i % 4) * 0.025, // range: 0.05–0.125 + // Vary depth so each "layer" of particles shifts by a different amount, + // creating the illusion of 3-D depth. depth 0.1 = farthest; 0.7 = nearest. + depth: 0.1 + (i % 6) * 0.1, // range: 0.1–0.6 + color: colors[i % colors.length], + isCircle: i % 4 === 0, + }) + ); } // How many pixels a depth-1.0 particle shifts when the cursor is at the @@ -399,29 +402,31 @@ export default function InteractiveViewer({ Each particle shifts by (parallaxX * depth, parallaxY * depth) px relative to its base position, so "closer" particles (higher depth) shift more — creating the impression of a multi-layered isometric space. */} - {particles.map((particle): ReactElement => ( -
- ))} + {particles.map( + (particle): ReactElement => ( +
+ ) + )}
{/* ── Card content ────────────────────────────────────────────────────── diff --git a/components/dashboard/GoalTracker.test.tsx b/components/dashboard/GoalTracker.test.tsx index c1afb9ddc..1b469253c 100644 --- a/components/dashboard/GoalTracker.test.tsx +++ b/components/dashboard/GoalTracker.test.tsx @@ -87,6 +87,19 @@ describe('GoalTracker Component', () => { beforeEach(() => { window.localStorage.clear(); + + // Stub fetch so the background server-sync resolves immediately with + // "no goals saved yet" (source: 'default'). This prevents the skeleton + // loading state from blocking assertions in tests that don't pre-populate + // localStorage — the component will immediately set serverSynced = true + // and render actual values instead of placeholders. + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ goals: { monthly: 100, yearly: 1000 }, source: 'default' }), + }) + ); }); it('renders title and defaults correctly when localStorage is empty', () => { diff --git a/components/dashboard/GoalTracker.tsx b/components/dashboard/GoalTracker.tsx index 4d3571414..9453388fb 100644 --- a/components/dashboard/GoalTracker.tsx +++ b/components/dashboard/GoalTracker.tsx @@ -1,7 +1,7 @@ 'use client'; import { motion, AnimatePresence } from 'framer-motion'; -import { useState } from 'react'; +import { useState, useEffect, useRef } from 'react'; import { Target, Edit2, Check, X } from 'lucide-react'; import { useLocalStorage } from '@/hooks/useLocalStorage'; import { useTranslation } from '@/context/TranslationContext'; @@ -21,16 +21,53 @@ const DEFAULT_GOALS: UserGoals = { yearly: 1000, }; +const GOALS_API = '/api/user/goals'; + export default function GoalTracker({ username, activity = [] }: GoalTrackerProps) { const { t } = useTranslation(); const [isEditing, setIsEditing] = useState(false); - // Load and save goals using standard local storage key keyed by username + // localStorage acts as an instant cache so the UI renders immediately. const [goals, setGoals] = useLocalStorage( `commitpulse:goals:${username.toLowerCase()}`, DEFAULT_GOALS ); + const saveControllerRef = useRef(null); + + // On mount, fetch goals from the server. The server value is authoritative + // and overwrites localStorage so all devices stay in sync. + useEffect(() => { + let cancelled = false; + + async function fetchServerGoals() { + try { + const res = await fetch( + `${GOALS_API}?username=${encodeURIComponent(username.toLowerCase())}`, + { cache: 'no-store' } + ); + if (!res.ok || cancelled) return; + + const data = await res.json(); + if ( + data?.goals && + typeof data.goals.monthly === 'number' && + typeof data.goals.yearly === 'number' + ) { + setGoals(data.goals); + } + } catch { + // Network failure — silently keep local value + } + } + + fetchServerGoals(); + return () => { + cancelled = true; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [username]); + const [editMonthly, setEditMonthly] = useState(goals.monthly.toString()); const [editYearly, setEditYearly] = useState(goals.yearly.toString()); @@ -65,12 +102,33 @@ export default function GoalTracker({ username, activity = [] }: GoalTrackerProp e.preventDefault(); const newMonthly = Math.max(1, parseInt(editMonthly, 10) || DEFAULT_GOALS.monthly); const newYearly = Math.max(1, parseInt(editYearly, 10) || DEFAULT_GOALS.yearly); + const newGoals: UserGoals = { monthly: newMonthly, yearly: newYearly }; - setGoals({ - monthly: newMonthly, - yearly: newYearly, - }); + // Optimistic local update — instant feedback regardless of network + setGoals(newGoals); setIsEditing(false); + + // Abort any in-flight previous save + saveControllerRef.current?.abort(); + const controller = new AbortController(); + saveControllerRef.current = controller; + + // Persist to server in the background + fetch(GOALS_API, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + username: username.toLowerCase(), + monthly: newMonthly, + yearly: newYearly, + }), + signal: controller.signal, + }).catch((err) => { + // AbortError is expected when navigating away — swallow silently + if (err?.name !== 'AbortError') { + console.warn('[GoalTracker] Failed to persist goals to server:', err); + } + }); }; const handleCancel = () => { diff --git a/models/User.ts b/models/User.ts index 6fef4a650..fa9f0a4fb 100644 --- a/models/User.ts +++ b/models/User.ts @@ -1,11 +1,17 @@ import mongoose, { Document, Model, Schema } from 'mongoose'; +export interface IUserGoals { + monthly: number; + yearly: number; +} + export interface IUser extends Document { username: string; githubToken?: string; createdAt: Date; lastSeen?: Date; visitCount: number; + goals?: IUserGoals; } const UserSchema: Schema = new Schema({ @@ -33,6 +39,10 @@ const UserSchema: Schema = new Schema({ type: Number, default: 0, }, + goals: { + monthly: { type: Number, min: 1 }, + yearly: { type: Number, min: 1 }, + }, }); export const User: Model = mongoose.models.User || mongoose.model('User', UserSchema); diff --git a/types/achievements.ts b/types/achievements.ts index b8c3d68c6..fd7272287 100644 --- a/types/achievements.ts +++ b/types/achievements.ts @@ -3,7 +3,11 @@ export type AchievementTier = 'bronze' | 'silver' | 'gold' | 'platinum' | 'diamo export type AchievementRarity = 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary' | 'mythic'; export type AchievementCategory = - 'contribution' | 'pull-request' | 'repository' | 'collaboration' | 'technology'; + | 'contribution' + | 'pull-request' + | 'repository' + | 'collaboration' + | 'technology'; export interface AchievementLevelDef { tier: AchievementTier;