Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/ci-trigger.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<!-- trigger CI: re-run workflows -->
190 changes: 145 additions & 45 deletions src/app/api/ai/roast/route.ts
Original file line number Diff line number Diff line change
@@ -1,53 +1,153 @@
import { NextResponse } from 'next/server';
import { GoogleGenerativeAI } from '@google/generative-ai';
import { NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { resolveAppUser } from "@/lib/server/resolveAppUser";
import genAI from "@/lib/genAI";
import crypto from "crypto";

// Initialize the Google Generative AI SDK
const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY || '');
// Optional Upstash (if available). Code falls back to an in-memory limiter/cache.
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";

export async function POST(req: Request) {
const GEMINI_KEY = process.env.GEMINI_API_KEY;

// --- Upstash / Redis init (optional) ---
let redisClient: Redis | null = null;
let upstashLimiter: Ratelimit | null = null;

if (process.env.UPSTASH_REDIS_REST_URL && process.env.UPSTASH_REDIS_REST_TOKEN) {
try {
const body = await req.json();
const { mode, stats } = body;

if (!mode || !stats) {
return NextResponse.json(
{ error: 'Mode (roast/hype) and user stats are required.' },
{ status: 400 }
);
redisClient = new Redis({
url: process.env.UPSTASH_REDIS_REST_URL,
token: process.env.UPSTASH_REDIS_REST_TOKEN,
});
upstashLimiter = new Ratelimit({
redis: redisClient,
limiter: Ratelimit.fixedWindow(5, "1 h"), // 5 requests per user per hour
});
} catch (e) {
// if Upstash setup fails, continue with memory fallback
console.warn("[roast] upstash init failed, using memory fallback", e);
redisClient = null;
upstashLimiter = null;
}
}

// --- Simple in-memory fallback structures ---
const memoryRate = new Map<string, { count: number; resetAt: number }>();
const memoryCache = new Map<string, { value: any; expiresAt: number }>();

function shortHash(obj: any) {
return crypto.createHash("sha256").update(JSON.stringify(obj)).digest("hex").slice(0, 12);
}

async function checkRate(userKey: string) {
// Try Upstash first
if (upstashLimiter && redisClient) {
try {
const res = await upstashLimiter.limit(userKey);
if (res.success) return { ok: true };
return { ok: false, retryAfter: Math.ceil(res.resetAfter / 1000) || 3600 };
} catch (e) {
console.warn("[roast] upstash limiter error, falling back to memory", e);
}
}

const model = genAI.getGenerativeModel({ model: 'gemini-2.5-flash' });
// Memory fixed-window fallback (per-process)
const now = Date.now();
const windowMs = 60 * 60 * 1000;
const limit = 5;
const state = memoryRate.get(userKey);
if (!state || state.resetAt <= now) {
memoryRate.set(userKey, { count: 1, resetAt: now + windowMs });
return { ok: true };
}
if (state.count >= limit) {
return { ok: false, retryAfter: Math.ceil((state.resetAt - now) / 1000) };
}
state.count += 1;
memoryRate.set(userKey, state);
return { ok: true };
}

let systemInstruction = '';
if (mode === 'roast') {
systemInstruction = `You are a hilariously brutal, sarcastic senior developer reviewing a junior's code stats. Roast their coding habits, commit streaks, or languages used based on the provided stats. Keep it strictly safe for work (SFW), funny, and under 3 sentences. No cursing.`;
} else if (mode === 'hype') {
systemInstruction = `You are the ultimate enthusiastic developer hype-man. Look at the user's coding stats and hype them up! Make them feel like a 10x coding god. Keep it energetic, modern, and under 3 sentences.`;
} else {
return NextResponse.json({ error: 'Invalid mode.' }, { status: 400 });
async function getCached(key: string) {
if (redisClient) {
try {
const v = await redisClient.get(key);
if (v) return JSON.parse(v as string);
} catch (e) {
console.warn("[roast] redis get error", e);
}
} else {
const e = memoryCache.get(key);
if (e && e.expiresAt > Date.now()) return e.value;
if (e) memoryCache.delete(key);
}
return null;
}

async function setCached(key: string, value: any, ttl = 300) {
if (redisClient) {
try {
await redisClient.set(key, JSON.stringify(value), { ex: ttl });
return;
} catch (e) {
console.warn("[roast] redis set error", e);
}
}
memoryCache.set(key, { value, expiresAt: Date.now() + ttl * 1000 });
}

// Basic prompt generator (adapt to repo's existing logic if needed)
function buildPrompt(mode: string, stats: any) {
return `Mode: ${mode}\nStats: ${JSON.stringify(stats)}\nPlease produce a short roast about the codebase.`;
}

const prompt = `
${systemInstruction}

User Stats:
- Commits this week: ${stats.commits || 0}
- Top Languages: ${stats.languages?.join(', ') || 'None'}
- Merged PRs: ${stats.mergedPRs || 0}
- Failed Goals: ${stats.failedGoals || 0}

Give me the ${mode}!
`;

const result = await model.generateContent(prompt);
const responseText = result.response.text();

return NextResponse.json({ message: responseText.trim() });
} catch (error) {
console.error('Gemini API Error:', error);
return NextResponse.json(
{ error: 'Failed to generate response. Please try again.' },
{ status: 500 }
);
}
}
export async function POST(req: Request) {
// 1) require authenticated session
const session = await getServerSession(authOptions);
if (!session || !session.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

// 2) resolve app user (stable id for rate-limiting)
const appUser = await resolveAppUser(session);
const userId = appUser?.id ?? session.user.email ?? session.user.name ?? "unknown";

// 3) parse body
let body: any;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
}
const stats = body?.stats ?? {};
const mode = body?.mode ?? "roast";

// 4) rate limit per user
const rateKey = `roast:ratelimit:${userId}`;
const rl = await checkRate(rateKey);
if (!rl.ok) {
return NextResponse.json({ error: "Too many requests", retryAfter: rl.retryAfter }, { status: 429 });
}

// 5) short response cache to avoid duplicate Gemini calls
const cacheKey = `roast:cache:${userId}:${mode}:${shortHash(stats)}`;
const cached = await getCached(cacheKey);
if (cached) return NextResponse.json({ ...cached, cached: true }, { status: 200 });

// 6) call the existing genAI pipeline (preserve existing behavior)
try {
const model = await genAI.getGenerativeModel(GEMINI_KEY);
const prompt = buildPrompt(mode, stats);
const generated = await model.generateContent(prompt);

// store result for a short time
await setCached(cacheKey, { result: generated }, 300);

return NextResponse.json({ result: generated }, { status: 200 });
} catch (err) {
console.error("[roast] generation error", err);
return NextResponse.json({ error: "Generation failed" }, { status: 500 });
}
}
74 changes: 74 additions & 0 deletions src/app/api/goals/achievements/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { supabaseAdmin } from "@/lib/supabase";
import { resolveAppUser } from "@/lib/resolve-user";

export const dynamic = "force-dynamic";

interface Badge {
id: string;
name: string;
description: string;
icon: string;
unlockedAt: string | null;
}

export async function GET() {
const session = await getServerSession(authOptions);
if (!session?.githubId) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}

const user = await resolveAppUser(session.githubId, session.githubLogin);
if (!user) return Response.json({ error: "User not found" }, { status: 404 });

try {
// Get goal statistics
const { data: goalHistory } = await supabaseAdmin
.from("goal_history")
.select("completed")
.eq("user_id", user.id);

const completedCount = (goalHistory ?? []).filter((h) => h.completed).length;

// Define badges
const badges: Badge[] = [
{
id: "first-goal",
name: "Goal Setter",
description: "Create your first goal",
icon: "🎯",
unlockedAt: completedCount > 0 ? new Date().toISOString() : null,
},
{
id: "goal-master",
name: "Goal Master",
description: "Complete 5 goals",
icon: "πŸ†",
unlockedAt: completedCount >= 5 ? new Date().toISOString() : null,
},
{
id: "consistency-king",
name: "Consistency King",
description: "Maintain weekly streak for 4 weeks",
icon: "πŸ‘‘",
unlockedAt: null, // Dynamic tracking needed
},
{
id: "overachiever",
name: "Overachiever",
description: "Exceed goal target by 50%",
icon: "⚑",
unlockedAt: null, // Dynamic tracking needed
},
];

return Response.json({ badges });
} catch (error) {
console.error("Failed to get achievements:", error);
return Response.json(
{ error: "Failed to fetch achievements" },
{ status: 500 }
);
}
}
54 changes: 54 additions & 0 deletions src/app/api/goals/notify/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { supabaseAdmin } from "@/lib/supabase";
import { resolveAppUser } from "@/lib/resolve-user";

export const dynamic = "force-dynamic";

export async function POST(req: Request) {
const session = await getServerSession(authOptions);
if (!session?.githubId) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}

const user = await resolveAppUser(session.githubId, session.githubLogin);
if (!user) return Response.json({ error: "User not found" }, { status: 404 });

const { goalId } = await req.json();

if (!goalId) {
return Response.json({ error: "goalId required" }, { status: 400 });
}

try {
const { data: goal } = await supabaseAdmin
.from("goals")
.select("*")
.eq("id", goalId)
.eq("user_id", user.id)
.single();

if (!goal) {
return Response.json({ error: "Goal not found" }, { status: 404 });
}

const { data: notification } = await supabaseAdmin
.from("notifications")
.insert({
user_id: user.id,
type: "goal_achievement",
message: `πŸŽ‰ Congrats! You completed "${goal.title}" goal!`,
read: false,
})
.select()
.single();

return Response.json({ notification });
} catch (error) {
console.error("Failed to create notification:", error);
return Response.json(
{ error: "Failed to create notification" },
{ status: 500 }
);
}
}
Loading
Loading