From 55d3efc0567dcd541977a4ff0d4588fc44a46644 Mon Sep 17 00:00:00 2001 From: Duong Phu Dong Date: Tue, 12 May 2026 17:14:40 +0700 Subject: [PATCH 1/4] feat(db): add mockInterviews and resumeViews schema --- src/db/schema.ts | 139 ++++++++++++++++++++++++++++++++++------------- 1 file changed, 102 insertions(+), 37 deletions(-) diff --git a/src/db/schema.ts b/src/db/schema.ts index 7df2ab4..43fb566 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -45,7 +45,7 @@ export const accounts = pgTable( primaryKey({ columns: [account.provider, account.providerAccountId], }), - ] + ], ); export const sessions = pgTable("sessions", { @@ -67,7 +67,7 @@ export const verificationTokens = pgTable( primaryKey({ columns: [verificationToken.identifier, verificationToken.token], }), - ] + ], ); // ============================================================ @@ -181,7 +181,10 @@ export const jobs = pgTable("jobs", { location: text("location"), sourceUrl: text("source_url"), jdText: text("jd_text").notNull(), - extractedKeywords: jsonb("extracted_keywords").$type().notNull().default([]), + extractedKeywords: jsonb("extracted_keywords") + .$type() + .notNull() + .default([]), createdAt: timestamp("created_at", { mode: "date" }).defaultNow().notNull(), updatedAt: timestamp("updated_at", { mode: "date" }).defaultNow().notNull(), }); @@ -211,10 +214,13 @@ export const applications = pgTable("applications", { jobId: uuid("job_id") .notNull() .references(() => jobs.id, { onDelete: "cascade" }), - resumeId: uuid("resume_id") - .references(() => resumes.id, { onDelete: "set null" }), - resumeVersionId: uuid("resume_version_id") - .references(() => resumeVersions.id, { onDelete: "set null" }), + resumeId: uuid("resume_id").references(() => resumes.id, { + onDelete: "set null", + }), + resumeVersionId: uuid("resume_version_id").references( + () => resumeVersions.id, + { onDelete: "set null" }, + ), status: text("status").notNull().default("wishlist"), appliedAt: timestamp("applied_at", { mode: "date" }), nextStepAt: timestamp("next_step_at", { mode: "date" }), @@ -243,10 +249,54 @@ export const usageEvents = pgTable("usage_events", { .notNull() .references(() => users.id, { onDelete: "cascade" }), eventName: text("event_name").notNull(), - metadata: jsonb("metadata").$type>().notNull().default({}), + metadata: jsonb("metadata") + .$type>() + .notNull() + .default({}), createdAt: timestamp("created_at", { mode: "date" }).defaultNow().notNull(), }); +export const mockInterviews = pgTable("mock_interviews", { + id: uuid("id").defaultRandom().primaryKey(), + userId: uuid("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + applicationId: uuid("application_id") + .notNull() + .references(() => applications.id, { onDelete: "cascade" }), + questions: jsonb("questions") + .$type>() + .notNull() + .default([]), + answers: jsonb("answers") + .$type< + Array<{ + questionId: number; + answer: string; + feedback: string; + score: number; + }> + >() + .notNull() + .default([]), + status: text("status").notNull().default("pending"), // pending, in_progress, completed + createdAt: timestamp("created_at", { mode: "date" }).defaultNow().notNull(), + updatedAt: timestamp("updated_at", { mode: "date" }).defaultNow().notNull(), +}); + +export const resumeViews = pgTable("resume_views", { + id: uuid("id").defaultRandom().primaryKey(), + resumeId: uuid("resume_id") + .notNull() + .references(() => resumes.id, { onDelete: "cascade" }), + viewerIpId: text("viewer_ip_id").notNull(), // hashed ip or session identifier + location: text("location"), // e.g. "San Francisco, CA" + durationSeconds: integer("duration_seconds").notNull().default(0), + clickedLinks: jsonb("clicked_links").$type().notNull().default([]), + createdAt: timestamp("created_at", { mode: "date" }).defaultNow().notNull(), + updatedAt: timestamp("updated_at", { mode: "date" }).defaultNow().notNull(), +}); + // ============================================================ // RELATIONS // ============================================================ @@ -260,54 +310,69 @@ export const usersRelations = relations(users, ({ many }) => ({ usageEvents: many(usageEvents), accounts: many(accounts), sessions: many(sessions), + mockInterviews: many(mockInterviews), })); -export const resumesRelations = relations(resumes, ({ one }) => ({ +export const resumesRelations = relations(resumes, ({ one, many }) => ({ user: one(users, { fields: [resumes.userId], references: [users.id], }), + views: many(resumeViews), })); -export const jobsRelations = relations(jobs, ({ one, many }) => ({ - user: one(users, { - fields: [jobs.userId], - references: [users.id], +export const resumeViewsRelations = relations(resumeViews, ({ one }) => ({ + resume: one(resumes, { + fields: [resumeViews.resumeId], + references: [resumes.id], }), - applications: many(applications), })); -export const resumeVersionsRelations = relations(resumeVersions, ({ one, many }) => ({ +export const jobsRelations = relations(jobs, ({ one, many }) => ({ user: one(users, { - fields: [resumeVersions.userId], + fields: [jobs.userId], references: [users.id], }), - resume: one(resumes, { - fields: [resumeVersions.resumeId], - references: [resumes.id], - }), applications: many(applications), })); -export const applicationsRelations = relations(applications, ({ one, many }) => ({ - user: one(users, { - fields: [applications.userId], - references: [users.id], - }), - job: one(jobs, { - fields: [applications.jobId], - references: [jobs.id], - }), - resume: one(resumes, { - fields: [applications.resumeId], - references: [resumes.id], +export const resumeVersionsRelations = relations( + resumeVersions, + ({ one, many }) => ({ + user: one(users, { + fields: [resumeVersions.userId], + references: [users.id], + }), + resume: one(resumes, { + fields: [resumeVersions.resumeId], + references: [resumes.id], + }), + applications: many(applications), }), - resumeVersion: one(resumeVersions, { - fields: [applications.resumeVersionId], - references: [resumeVersions.id], +); + +export const applicationsRelations = relations( + applications, + ({ one, many }) => ({ + user: one(users, { + fields: [applications.userId], + references: [users.id], + }), + job: one(jobs, { + fields: [applications.jobId], + references: [jobs.id], + }), + resume: one(resumes, { + fields: [applications.resumeId], + references: [resumes.id], + }), + resumeVersion: one(resumeVersions, { + fields: [applications.resumeVersionId], + references: [resumeVersions.id], + }), + coverLetters: many(coverLetters), }), - coverLetters: many(coverLetters), -})); +); export const coverLettersRelations = relations(coverLetters, ({ one }) => ({ user: one(users, { From da227e0c121095121eaf6509de18751bc277c6d3 Mon Sep 17 00:00:00 2001 From: Duong Phu Dong Date: Tue, 12 May 2026 17:15:12 +0700 Subject: [PATCH 2/4] feat(interviews-and-github): implement mock interviews and github auto-achievements --- src/actions/github-achievements.ts | 159 +++++++++ src/actions/mock-interview.ts | 253 ++++++++++++++ .../applications/[id]/interview/page.tsx | 80 +++++ src/app/[locale]/applications/page.tsx | 293 ++++++++++++----- .../applications/mock-interview-client.tsx | 308 ++++++++++++++++++ .../builder/forms/github-sync-modal.tsx | 111 +++++++ .../builder/forms/projects-form.tsx | 86 +++-- 7 files changed, 1195 insertions(+), 95 deletions(-) create mode 100644 src/actions/github-achievements.ts create mode 100644 src/actions/mock-interview.ts create mode 100644 src/app/[locale]/applications/[id]/interview/page.tsx create mode 100644 src/components/applications/mock-interview-client.tsx create mode 100644 src/components/builder/forms/github-sync-modal.tsx 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

- - - - + + + +