diff --git a/src/actions/github-achievements.ts b/src/actions/github-achievements.ts new file mode 100644 index 0000000..c93b629 --- /dev/null +++ b/src/actions/github-achievements.ts @@ -0,0 +1,159 @@ +"use server"; + +import Groq from "groq-sdk"; +import { auth } from "@/auth"; +import { createRateLimiter } from "@/lib/rate-limit"; +import type { ResumeData } from "@/db/schema"; + +// 5 AI calls per hour per user for github sync +const aiRateLimiter = createRateLimiter({ limit: 5, windowSeconds: 3600 }); + +async function getAuthUserId(): Promise { + const session = await auth(); + if (!session?.user?.id) { + throw new Error("Unauthorized"); + } + return session.user.id; +} + +function getGroqClient(): Groq { + const apiKey = process.env.GROQ_API_KEY; + if (!apiKey) throw new Error("GROQ_API_KEY is not configured"); + return new Groq({ apiKey }); +} + +function stripCodeFence(text: string): string { + const trimmed = text.trim(); + if (!trimmed.startsWith("```") || !trimmed.endsWith("```")) return trimmed; + return trimmed + .replace(/^```[a-zA-Z]*\n?/, "") + .replace(/```$/, "") + .trim(); +} + +/** + * Fetches top repositories for a GitHub user and uses AI to generate + * STAR-method professional bullet points and structured project data. + */ +export async function generateGitHubAchievements( + username: string, +): Promise<{ result: ResumeData["projects"] }> { + const userId = await getAuthUserId(); + + const rateCheck = await aiRateLimiter.check(userId); + if (!rateCheck.allowed) { + throw new Error( + `Rate limit exceeded. Try again in ${rateCheck.retryAfterSeconds} seconds.`, + ); + } + + if (!username.trim()) throw new Error("GitHub username cannot be empty."); + + // 1. Fetch from GitHub API + // Using public endpoint, no token to avoid complexity, though rate limits apply + const reposResponse = await fetch( + `https://api.github.com/users/${username}/repos?sort=updated&per_page=100`, + { + headers: { + Accept: "application/vnd.github.v3+json", + "User-Agent": "Lab68-CV-Builder", + }, + }, + ); + + if (!reposResponse.ok) { + if (reposResponse.status === 404) { + throw new Error("GitHub user not found."); + } + throw new Error(`GitHub API error: ${reposResponse.statusText}`); + } + + const allRepos = await reposResponse.json(); + + if (!Array.isArray(allRepos) || allRepos.length === 0) { + throw new Error("No public repositories found for this user."); + } + + // Filter out forks and purely empty repos + const sourceRepos = allRepos.filter((r) => !r.fork); + + // Sort by stars descending, then get top 5 + const topRepos = sourceRepos + .sort((a, b) => b.stargazers_count - a.stargazers_count) + .slice(0, 5); + + if (topRepos.length === 0) { + throw new Error("No non-fork repositories found to analyze."); + } + + // 2. Prepare context for AI + // We'll give it the repo name, description, primary language, topics, stars, and URL + const repoDataText = topRepos.map((r) => ({ + name: r.name, + description: r.description, + language: r.language, + topics: r.topics, + stars: r.stargazers_count, + url: r.html_url, + homepage: r.homepage, + })); + + const groq = getGroqClient(); + + const prompt = `You are an expert technical recruiter and resume writer. +I will provide you with data from a candidate's top GitHub repositories. +Your task is to convert these repositories into professional resume projects. +For each project, generate: +- A concise, impactful description +- 2 to 3 accomplishments/highlights written in the STAR method (Situation, Task, Action, Result). Make them sound highly impressive and action-oriented. +- An array of technologies used (combine the primary language and topics). + +Here is the raw GitHub data: +${JSON.stringify(repoDataText, null, 2)} + +Ensure you return ONLY a valid JSON array matching exactly this TypeScript signature: +Array<{ + name: string; + description: string; // concise 1-2 sentence description + url: string; // The homepage url if available, or just leave empty string + githubUrl: string; // The github html_url + websiteUrl: string; // same as URL or homepage + technologies: string[]; + highlights: string[]; // 2-3 impressive STAR method bullet points +}> + +No markdown formatting, no explanations, no text outside the JSON array.`; + + const completion = await groq.chat.completions.create({ + model: "llama-3.3-70b-versatile", + messages: [{ role: "user", content: prompt }], + temperature: 0.5, + }); + + const content = completion.choices[0]?.message?.content?.trim() || "[]"; + let parsedContent; + + try { + parsedContent = JSON.parse(stripCodeFence(content)); + if (!Array.isArray(parsedContent)) { + throw new Error("Invalid format from AI"); + } + } catch (error) { + console.error("Parse error:", error, content); + throw new Error("Failed to parse AI generated projects."); + } + + // Ensure every project has a unique ID + const newProjects = parsedContent.map((proj) => ({ + id: crypto.randomUUID(), + name: proj.name || "", + description: proj.description || "", + url: proj.url || proj.websiteUrl || proj.githubUrl || "", + githubUrl: proj.githubUrl || "", + websiteUrl: proj.websiteUrl || proj.url || "", + technologies: proj.technologies || [], + highlights: proj.highlights || [], + })); + + return { result: newProjects }; +} diff --git a/src/actions/mock-interview.ts b/src/actions/mock-interview.ts new file mode 100644 index 0000000..a959755 --- /dev/null +++ b/src/actions/mock-interview.ts @@ -0,0 +1,253 @@ +"use server"; + +import Groq from "groq-sdk"; +import { auth } from "@/auth"; +import { db } from "@/db"; +import { applications, jobs, resumes, mockInterviews } from "@/db/schema"; +import { eq, and } from "drizzle-orm"; + +async function getAuthUserId(): Promise { + const session = await auth(); + if (!session?.user?.id) { + throw new Error("Unauthorized"); + } + return session.user.id; +} + +function getGroqClient(): Groq { + const apiKey = process.env.GROQ_API_KEY; + if (!apiKey) throw new Error("GROQ_API_KEY is not configured"); + return new Groq({ apiKey }); +} + +function stripCodeFence(text: string): string { + const trimmed = text.trim(); + if (!trimmed.startsWith("```") || !trimmed.endsWith("```")) return trimmed; + return trimmed + .replace(/^```[a-zA-Z]*\n?/, "") + .replace(/```$/, "") + .trim(); +} + +/** + * Generate mock interview questions based on the candidate's resume and target job description. + */ +export async function generateMockInterviewQuestions(applicationId: string) { + const userId = await getAuthUserId(); + + // 1. Fetch application, job, and resume data + const appData = await db + .select({ + jobId: applications.jobId, + resumeId: applications.resumeId, + }) + .from(applications) + .where( + and(eq(applications.id, applicationId), eq(applications.userId, userId)), + ) + .limit(1) + .then((res) => res[0]); + + if (!appData || !appData.resumeId) { + throw new Error("Application not found or no resume attached."); + } + + const job = await db + .select() + .from(jobs) + .where(and(eq(jobs.id, appData.jobId), eq(jobs.userId, userId))) + .limit(1) + .then((res) => res[0]); + + const resume = await db + .select() + .from(resumes) + .where(and(eq(resumes.id, appData.resumeId!), eq(resumes.userId, userId))) + .limit(1) + .then((res) => res[0]); + + if (!job || !resume) { + throw new Error("Job or Resume not found."); + } + + // 2. Build AI Prompt + const groq = getGroqClient(); + const prompt = `You are an expert technical recruiter and hiring manager. +Your task is to generate 5 challenging but fair interview questions tailored to the candidate's resume and the job description. +Some questions should focus on the candidate's listed experience, and some should focus on the specific skills required in the job description to see if there are any gaps. + +Job Title: ${job.title} +Company: ${job.company} +Job Description: +${job.jdText.substring(0, 2000)} + +Candidate's Resume Data: +${JSON.stringify(resume.data).substring(0, 3000)} + +Return ONLY a valid JSON array of exactly 5 objects. Each object must have these exactly properties: +"question" (string): The interview question. +"expectedContext" (string): What an ideal answer should cover (bullet points or a short paragraph). + +No extra text, no markdown. Just the JSON array.`; + + // 3. Call AI + const completion = await groq.chat.completions.create({ + model: "llama-3.3-70b-versatile", + messages: [{ role: "user", content: prompt }], + temperature: 0.7, + }); + + const content = completion.choices[0]?.message?.content?.trim() || "[]"; + let questions: Array<{ question: string; expectedContext: string }>; + + try { + questions = JSON.parse(stripCodeFence(content)); + if (!Array.isArray(questions) || questions.length !== 5) { + throw new Error("Invalid format from AI"); + } + } catch (error) { + console.error("Parse error:", error, content); + throw new Error("Failed to parse AI generated questions."); + } + + // 4. Save to mockInterviews table + const newInterview = await db + .insert(mockInterviews) + .values({ + userId, + applicationId, + questions, + answers: [], + status: "in_progress", + }) + .returning({ id: mockInterviews.id }) + .then((res) => res[0]); + + return { interviewId: newInterview.id, questions }; +} + +/** + * Evaluate a single answer and save it to the mock interview state. + */ +export async function evaluateMockInterviewAnswer( + interviewId: string, + questionIndex: number, + answerText: string, +) { + const userId = await getAuthUserId(); + + // 1. Fetch the mock interview + const interview = await db + .select() + .from(mockInterviews) + .where( + and( + eq(mockInterviews.id, interviewId), + eq(mockInterviews.userId, userId), + ), + ) + .limit(1) + .then((res) => res[0]); + + if (!interview) { + throw new Error("Interview not found."); + } + + const questionObj = interview.questions[questionIndex]; + if (!questionObj) { + throw new Error("Invalid question index."); + } + + // 2. Evaluate answer with AI + const groq = getGroqClient(); + const prompt = `You are evaluating a candidate's answer to an interview question. +Question: ${questionObj.question} +Expected Context / Ideal Answer: ${questionObj.expectedContext} + +Candidate's Answer: +"${answerText}" + +Provide critical but constructive feedback on the candidate's answer. Give a score from 1 to 10 based on how well they addressed the expected context and clarity of their answer. + +Return ONLY valid JSON (no markdown) with this exact format: +{ + "feedback": "string", + "score": number +}`; + + const completion = await groq.chat.completions.create({ + model: "llama-3.3-70b-versatile", + messages: [{ role: "user", content: prompt }], + temperature: 0.5, + }); + + const content = completion.choices[0]?.message?.content?.trim() || "{}"; + let evalResult: { feedback: string; score: number }; + + try { + evalResult = JSON.parse(stripCodeFence(content)); + if ( + typeof evalResult.feedback !== "string" || + typeof evalResult.score !== "number" + ) { + throw new Error(); + } + } catch (error) { + console.error("Parse error:", error, content); + throw new Error("Failed to parse AI evaluation."); + } + + // 3. Update the mock interview + const currentAnswers = [...interview.answers]; + const existingAnswerIndex = currentAnswers.findIndex( + (a) => a.questionId === questionIndex, + ); + + const newAnswerObj = { + questionId: questionIndex, + answer: answerText, + feedback: evalResult.feedback, + score: evalResult.score, + }; + + if (existingAnswerIndex !== -1) { + currentAnswers[existingAnswerIndex] = newAnswerObj; + } else { + currentAnswers.push(newAnswerObj); + } + + // Check if all questions are answered + const status = + currentAnswers.length === interview.questions.length + ? "completed" + : "in_progress"; + + await db + .update(mockInterviews) + .set({ + answers: currentAnswers, + status, + updatedAt: new Date(), + }) + .where(eq(mockInterviews.id, interviewId)); + + return newAnswerObj; +} + +export async function getMockInterview(interviewId: string) { + const userId = await getAuthUserId(); + const interview = await db + .select() + .from(mockInterviews) + .where( + and( + eq(mockInterviews.id, interviewId), + eq(mockInterviews.userId, userId), + ), + ) + .limit(1) + .then((res) => res[0]); + + if (!interview) throw new Error("Not found"); + return interview; +} diff --git a/src/app/[locale]/applications/[id]/interview/page.tsx b/src/app/[locale]/applications/[id]/interview/page.tsx new file mode 100644 index 0000000..3fb7627 --- /dev/null +++ b/src/app/[locale]/applications/[id]/interview/page.tsx @@ -0,0 +1,80 @@ +import { auth } from "@/auth"; +import { redirect } from "next/navigation"; +import { db } from "@/db"; +import { applications, mockInterviews } from "@/db/schema"; +import { eq, and } from "drizzle-orm"; +import MockInterviewClient from "@/components/applications/mock-interview-client"; +import { Link } from "@/i18n/routing"; +import { ArrowLeft } from "lucide-react"; + +export default async function MockInterviewPage({ + params, +}: { + params: Promise<{ id: string; locale: string }>; +}) { + const session = await auth(); + if (!session?.user?.id) { + redirect("/login"); + } + + const { id: applicationId } = await params; + + // Verify application exists and belongs to user + const application = await db + .select() + .from(applications) + .where( + and( + eq(applications.id, applicationId), + eq(applications.userId, session.user.id), + ), + ) + .limit(1) + .then((res) => res[0]); + + if (!application) { + redirect("/applications"); + } + + // Check if they already have an interview for this app + const existingInterview = await db + .select() + .from(mockInterviews) + .where( + and( + eq(mockInterviews.applicationId, applicationId), + eq(mockInterviews.userId, session.user.id), + ), + ) + .limit(1) + .then((res) => res[0]); + + return ( +
+
+ + + Back to Applications + +

+ Interactive Mock Interview +

+

+ Tailored to your resume and the target job description. We'll ask + you 5 questions, you can speak your answers via microphone or type + them, and AI will give immediate feedback on how to improve. +

+
+ + +
+ ); +} diff --git a/src/app/[locale]/applications/page.tsx b/src/app/[locale]/applications/page.tsx index 54be846..c2757ff 100644 --- a/src/app/[locale]/applications/page.tsx +++ b/src/app/[locale]/applications/page.tsx @@ -15,7 +15,10 @@ import { updateCoverLetter, } from "@/actions/cover-letter"; import { getUserResumes } from "@/actions/resume"; -import { APPLICATION_STATUSES, type ApplicationStatus } from "@/lib/application-status"; +import { + APPLICATION_STATUSES, + type ApplicationStatus, +} from "@/lib/application-status"; import { type ApplicationSort, parseApplicationFiltersFromSearchParams, @@ -43,7 +46,9 @@ const SORT_OPTIONS: Array<{ value: ApplicationSort; label: string }> = [ { value: "applied_asc", label: "Oldest applied" }, ]; -export default async function ApplicationsPage({ searchParams }: ApplicationsPageProps) { +export default async function ApplicationsPage({ + searchParams, +}: ApplicationsPageProps) { const session = await auth(); if (!session?.user) { @@ -71,7 +76,7 @@ export default async function ApplicationsPage({ searchParams }: ApplicationsPag ]); const coverLetters = await getCoverLettersByApplicationIds( - applications.map((application) => application.applicationId) + applications.map((application) => application.applicationId), ); const coverLettersByApplication = new Map(); @@ -86,7 +91,9 @@ export default async function ApplicationsPage({ searchParams }: ApplicationsPag
- APPLICATIONS // TRACKER + + APPLICATIONS // TRACKER +

Job Pipeline

- - - - + + + +