diff --git a/src/app/api/local-coding/keys/route.ts b/src/app/api/local-coding/keys/route.ts index 23f4a0be5..f507d1d64 100644 --- a/src/app/api/local-coding/keys/route.ts +++ b/src/app/api/local-coding/keys/route.ts @@ -71,7 +71,6 @@ export async function POST(req: NextRequest) { .from("local_coding_api_keys") .insert({ user_id: user.id, - api_key: apiKeyHash, api_key_hash: apiKeyHash, name, }) diff --git a/src/app/api/local-coding/sync/route.ts b/src/app/api/local-coding/sync/route.ts index d4b2310ba..ba6788031 100644 --- a/src/app/api/local-coding/sync/route.ts +++ b/src/app/api/local-coding/sync/route.ts @@ -15,24 +15,43 @@ function hashApiKey(key: string): string { async function authenticateApiKey(apiKey: string): Promise { const keyHash = hashApiKey(apiKey); - const keyHashFilter = `api_key_hash.eq.${keyHash},api_key.eq.${keyHash}`; - const { data: keyRecord } = await supabaseAdmin + // Step 1: Try modern hashed lookup first + const { data: hashedRecord } = await supabaseAdmin .from("local_coding_api_keys") - .select("user_id") - .or(keyHashFilter) - .single(); - - if (!keyRecord) { - return null; + .select("id, user_id") + .eq("api_key_hash", keyHash) + .maybeSingle(); + + if (hashedRecord) { + await supabaseAdmin + .from("local_coding_api_keys") + .update({ last_used_at: new Date().toISOString() }) + .eq("id", hashedRecord.id); + return hashedRecord.user_id; } - await supabaseAdmin + // Step 2: Fallback for legacy plaintext keys (api_key_hash IS NULL) + const { data: legacyRecord } = await supabaseAdmin .from("local_coding_api_keys") - .update({ last_used_at: new Date().toISOString() }) - .or(keyHashFilter); + .select("id, user_id, api_key") + .is("api_key_hash", null) + .eq("api_key", apiKey) + .maybeSingle(); + + if (legacyRecord) { + // Backfill hash — migrate legacy key to hash-only verification + await supabaseAdmin + .from("local_coding_api_keys") + .update({ + api_key_hash: keyHash, + last_used_at: new Date().toISOString(), + }) + .eq("id", legacyRecord.id); + return legacyRecord.user_id; + } - return keyRecord.user_id; + return null; } function validateDays(days: number): number { diff --git a/src/app/api/project-tutor/route.ts b/src/app/api/project-tutor/route.ts new file mode 100644 index 000000000..0e9035e72 --- /dev/null +++ b/src/app/api/project-tutor/route.ts @@ -0,0 +1,160 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/lib/auth"; + +const GROQ_API_URL = "https://api.groq.com/openai/v1/chat/completions"; + +async function callGroq(prompt: string): Promise { + const apiKey = process.env.GROQ_API_KEY; + if (!apiKey) return "AI insights unavailable — GROQ_API_KEY not configured."; + + const res = await fetch(GROQ_API_URL, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify({ + model: "llama3-8b-8192", + messages: [{ role: "user", content: prompt }], + max_tokens: 1024, + temperature: 0.7, + }), + }); + + if (!res.ok) throw new Error("Groq API error"); + const data = await res.json(); + return data.choices?.[0]?.message?.content ?? "No response generated."; +} + +async function fetchRepoData(owner: string, repo: string) { + const token = process.env.GITHUB_TOKEN; + const headers: Record = { + Accept: "application/vnd.github.v3+json", + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }; + + const [repoRes, readmeRes, languagesRes] = await Promise.all([ + fetch(`https://api.github.com/repos/${owner}/${repo}`, { headers }), + fetch(`https://api.github.com/repos/${owner}/${repo}/readme`, { headers }), + fetch(`https://api.github.com/repos/${owner}/${repo}/languages`, { headers }), + ]); + + const repoData = repoRes.ok ? await repoRes.json() : {}; + const readmeData = readmeRes.ok ? await readmeRes.json() : {}; + const languages = languagesRes.ok ? await languagesRes.json() : {}; + + let readmeContent = ""; + if (readmeData.content) { + readmeContent = Buffer.from(readmeData.content, "base64").toString("utf-8").slice(0, 2000); + } + + return { repoData, readmeContent, languages }; +} + +export async function POST(req: NextRequest) { + const session = await getServerSession(authOptions); + if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + + try { + const { repoUrl, action, question } = await req.json(); + + if (!repoUrl) return NextResponse.json({ error: "repo URL required" }, { status: 400 }); + + const match = repoUrl.match(/github\.com\/([^/]+)\/([^/]+)/); + if (!match) return NextResponse.json({ error: "Invalid GitHub URL" }, { status: 400 }); + + const [, owner, repo] = match; + const { repoData, readmeContent, languages } = await fetchRepoData(owner, repo); + + const techStack = Object.keys(languages).join(", ") || "Unknown"; + const description = repoData.description || "No description"; + const repoName = repoData.name || repo; + const topics = (repoData.topics || []).join(", "); + + const context = ` +Project: ${repoName} +Description: ${description} +Tech Stack: ${techStack} +Topics: ${topics} +Stars: ${repoData.stargazers_count ?? 0} +README (excerpt): ${readmeContent} + `.trim(); + + if (action === "analyze") { + const prompt = `You are an expert software engineer helping a student prepare for technical interviews. + +Analyze this GitHub project and provide: +1. A brief project summary (2-3 sentences) +2. Key features (3-5 bullet points) +3. Tech stack breakdown and why each technology was likely chosen +4. Potential architectural decisions and tradeoffs +5. Common challenges in this type of project + +Project context: +${context} + +Format your response with clear headings using markdown.`; + + const analysis = await callGroq(prompt); + return NextResponse.json({ analysis, techStack, description: repoData.description }); + } + + if (action === "questions") { + const prompt = `You are a senior software engineer conducting technical interviews. + +Generate interview questions for this project at three difficulty levels: + +Project context: +${context} + +Generate exactly: +- 3 Easy questions (basic understanding, what/why questions) +- 3 Medium questions (implementation details, design decisions) +- 3 Advanced questions (scalability, edge cases, improvements) + +Format as JSON: +{ + "easy": ["question1", "question2", "question3"], + "medium": ["question1", "question2", "question3"], + "advanced": ["question1", "question2", "question3"] +} + +Return ONLY the JSON, no other text.`; + + const raw = await callGroq(prompt); + try { + const clean = raw.replace(/```json|```/g, "").trim(); + const questions = JSON.parse(clean); + return NextResponse.json({ questions }); + } catch { + return NextResponse.json({ + questions: { + easy: ["What is the main purpose of this project?", "What technologies did you use?", "How do you run this project locally?"], + medium: ["How did you structure your codebase?", "What was the most challenging part?", "How did you handle errors?"], + advanced: ["How would you scale this?", "What would you do differently?", "How would you add authentication?"], + } + }); + } + } + + if (action === "chat") { + const prompt = `You are an AI tutor helping a student prepare for technical interviews about their project. + +Project context: +${context} + +Student question: ${question} + +Give a clear, concise answer focused on interview preparation. Keep it under 200 words.`; + + const answer = await callGroq(prompt); + return NextResponse.json({ answer }); + } + + return NextResponse.json({ error: "Invalid action" }, { status: 400 }); + } catch (error) { + console.error("Project tutor error:", error); + return NextResponse.json({ error: "Something went wrong" }, { status: 500 }); + } +} \ No newline at end of file diff --git a/src/app/project-tutor/page.tsx b/src/app/project-tutor/page.tsx new file mode 100644 index 000000000..e5cb72a6b --- /dev/null +++ b/src/app/project-tutor/page.tsx @@ -0,0 +1,11 @@ +import { getServerSession } from "next-auth"; +import { authOptions } from "@/lib/auth"; +import { redirect } from "next/navigation"; +import ProjectTutorClient from "@/components/ProjectTutorClient"; + +export default async function ProjectTutorPage() { + const session = await getServerSession(authOptions); + if (!session) redirect("/api/auth/signin/github?callbackUrl=/project-tutor"); + + return ; +} \ No newline at end of file diff --git a/src/components/ProjectTutorClient.tsx b/src/components/ProjectTutorClient.tsx new file mode 100644 index 000000000..7835c56fc --- /dev/null +++ b/src/components/ProjectTutorClient.tsx @@ -0,0 +1,249 @@ +'use client'; + +import { useState } from 'react'; + +interface Questions { + easy: string[]; + medium: string[]; + advanced: string[]; +} + +interface ProjectTutorClientProps { + username: string; +} + +export default function ProjectTutorClient({ username }: ProjectTutorClientProps) { + const [repoUrl, setRepoUrl] = useState(''); + const [loading, setLoading] = useState(false); + const [analysis, setAnalysis] = useState(''); + const [questions, setQuestions] = useState(null); + const [chatInput, setChatInput] = useState(''); + const [chatHistory, setChatHistory] = useState<{ role: 'user' | 'ai'; text: string }[]>([]); + const [chatLoading, setChatLoading] = useState(false); + const [error, setError] = useState(''); + const [activeTab, setActiveTab] = useState<'analysis' | 'questions' | 'chat'>('analysis'); + + const handleAnalyze = async () => { + if (!repoUrl.trim()) return; + setLoading(true); + setError(''); + setAnalysis(''); + setQuestions(null); + + try { + const [analysisRes, questionsRes] = await Promise.all([ + fetch('/api/project-tutor', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ repoUrl, action: 'analyze' }), + }), + fetch('/api/project-tutor', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ repoUrl, action: 'questions' }), + }), + ]); + + const analysisData = await analysisRes.json(); + const questionsData = await questionsRes.json(); + + setAnalysis(analysisData.analysis ?? ''); + setQuestions(questionsData.questions ?? null); + } catch { + setError('Failed to analyze repository. Please try again.'); + } finally { + setLoading(false); + } + }; + + const handleChat = async () => { + if (!chatInput.trim()) return; + const userMsg = chatInput.trim(); + setChatInput(''); + setChatHistory(prev => [...prev, { role: 'user', text: userMsg }]); + setChatLoading(true); + + try { + const res = await fetch('/api/project-tutor', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ repoUrl, action: 'chat', question: userMsg }), + }); + const data = await res.json(); + setChatHistory(prev => [...prev, { role: 'ai', text: data.answer ?? 'No response.' }]); + } catch { + setChatHistory(prev => [...prev, { role: 'ai', text: 'Failed to get response. Please try again.' }]); + } finally { + setChatLoading(false); + } + }; + + const difficultyColor = { easy: '#10b981', medium: '#f59e0b', advanced: '#ef4444' }; + + return ( +
+
+ + {/* Header */} +
+
+ AI PROJECT TUTOR +
+

+ Prepare for Your Project Interview 🎯 +

+

+ Paste any GitHub repo URL to get AI-generated insights, interview questions, and a chat tutor. +

+
+ + {/* Search bar */} +
+ setRepoUrl(e.target.value)} + onKeyDown={e => e.key === 'Enter' && handleAnalyze()} + placeholder="https://github.com/username/repo" + style={{ + flex: 1, minWidth: '280px', padding: '12px 16px', + background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', + borderRadius: '10px', color: '#f1f5f9', fontSize: '0.95rem', outline: 'none', + }} + /> + +
+ + {error && ( +
+ {error} +
+ )} + + {/* Tabs */} + {(analysis || questions) && ( + <> +
+ {(['analysis', 'questions', 'chat'] as const).map(tab => ( + + ))} +
+ + {/* Analysis tab */} + {activeTab === 'analysis' && analysis && ( +
+
+                  {analysis}
+                
+
+ )} + + {/* Questions tab */} + {activeTab === 'questions' && questions && ( +
+ {(['easy', 'medium', 'advanced'] as const).map(level => ( +
+
+ + + {level} + +
+
    + {questions[level].map((q, i) => ( +
  1. {q}
  2. + ))} +
+
+ ))} +
+ )} + + {/* Chat tab */} + {activeTab === 'chat' && ( +
+
+ {chatHistory.length === 0 && ( +

+ Ask anything about your project — implementation details, design decisions, challenges faced... +

+ )} + {chatHistory.map((msg, i) => ( +
+
+ {msg.text} +
+
+ ))} + {chatLoading && ( +
+
+ Thinking... +
+
+ )} +
+
+ setChatInput(e.target.value)} + onKeyDown={e => e.key === 'Enter' && handleChat()} + placeholder="Ask about your project..." + style={{ + flex: 1, padding: '10px 14px', background: 'rgba(255,255,255,0.05)', + border: '1px solid rgba(255,255,255,0.1)', borderRadius: '8px', + color: '#f1f5f9', fontSize: '0.875rem', outline: 'none', + }} + /> + +
+
+ )} + + )} +
+
+ ); +} \ No newline at end of file