diff --git a/client/src/module/student/opensource/CoachFloatingButton.tsx b/client/src/module/student/opensource/CoachFloatingButton.tsx
new file mode 100644
index 000000000..948be6f11
--- /dev/null
+++ b/client/src/module/student/opensource/CoachFloatingButton.tsx
@@ -0,0 +1,26 @@
+import { motion } from "framer-motion";
+import { Sparkles } from "lucide-react";
+import { useCoachStore } from "./stores/coach.store";
+
+/**
+ * Floating button visible on all open-source section pages.
+ * Clicking it toggles the coach panel open/closed.
+ */
+export default function CoachFloatingButton() {
+ const { toggle, isOpen } = useCoachStore();
+
+ if (isOpen) return null;
+
+ return (
+
+
+
+ );
+}
diff --git a/client/src/module/student/opensource/ContributionCoachPanel.tsx b/client/src/module/student/opensource/ContributionCoachPanel.tsx
new file mode 100644
index 000000000..01002e446
--- /dev/null
+++ b/client/src/module/student/opensource/ContributionCoachPanel.tsx
@@ -0,0 +1,454 @@
+import { useEffect, useRef, useState, useCallback } from "react";
+import { motion, AnimatePresence } from "framer-motion";
+import {
+ X,
+ Sparkles,
+ Bookmark,
+ Trash2,
+ ChevronDown,
+ Loader2,
+ AlertCircle,
+ RefreshCw,
+} from "lucide-react";
+import { useCoachStore } from "./stores/coach.store";
+import {
+ saveCoachAdvice,
+ fetchSavedAdvice,
+ deleteCoachAdvice,
+} from "./api/coach.api";
+import type { SavedAdvice } from "./api/coach.api";
+import toast from "../../../components/ui/toast";
+
+// ── Simple markdown renderer (bold, headings, bullets, code) ──
+function renderMarkdown(md: string) {
+ return md.split("\n").map((line, i) => {
+ const trimmed = line.trimStart();
+
+ // Heading
+ if (trimmed.startsWith("### "))
+ return (
+
+ {formatInline(trimmed.slice(4))}
+
+ );
+ if (trimmed.startsWith("## "))
+ return (
+
+ {formatInline(trimmed.slice(3))}
+
+ );
+ if (trimmed.startsWith("# "))
+ return (
+
+ {formatInline(trimmed.slice(2))}
+
+ );
+
+ // Bullet
+ if (trimmed.startsWith("- ") || trimmed.startsWith("* "))
+ return (
+
+ {formatInline(trimmed.slice(2))}
+
+ );
+
+ // Numbered
+ const numbered = trimmed.match(/^(\d+)\.\s(.*)/);
+ if (numbered)
+ return (
+
+ {formatInline(numbered[2]!)}
+
+ );
+
+ // Empty
+ if (!trimmed) return ;
+
+ // Paragraph
+ return (
+
+ {formatInline(line)}
+
+ );
+ });
+}
+
+function formatInline(text: string) {
+ // Bold: **text**
+ const parts = text.split(/(\*\*[^*]+\*\*|`[^`]+`)/g);
+ return parts.map((part, i) => {
+ if (part.startsWith("**") && part.endsWith("**"))
+ return (
+
+ {part.slice(2, -2)}
+
+ );
+ if (part.startsWith("`") && part.endsWith("`"))
+ return (
+
+ {part.slice(1, -1)}
+
+ );
+ return part;
+ });
+}
+
+// ── Saved Advice Drawer ──
+function SavedAdviceSection() {
+ const [expanded, setExpanded] = useState(false);
+ const [items, setItems] = useState([]);
+ const [loading, setLoading] = useState(false);
+
+ const load = useCallback(async () => {
+ setLoading(true);
+ try {
+ const data = await fetchSavedAdvice();
+ setItems(data);
+ } catch {
+ toast.error("Failed to load saved advice");
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ if (expanded && items.length === 0) {
+ void load();
+ }
+ }, [expanded, items.length, load]);
+
+ const handleDelete = async (id: number) => {
+ try {
+ await deleteCoachAdvice(id);
+ setItems((prev) => prev.filter((a) => a.id !== id));
+ toast.success("Advice deleted");
+ } catch {
+ toast.error("Failed to delete");
+ }
+ };
+
+ return (
+
+
+
+
+ {expanded && (
+
+ {loading && (
+
+
+
+ )}
+
+ {!loading && items.length === 0 && (
+
+ No saved advice yet. Click "Save this advice" after receiving a
+ suggestion.
+
+ )}
+
+ {items.map((item) => (
+
+
+
+ {item.title}
+
+
+
+
+ {item.content.slice(0, 200)}
+ {item.content.length > 200 ? "…" : ""}
+
+
+ {new Date(item.createdAt).toLocaleDateString()}
+
+
+ ))}
+
+ )}
+
+
+ );
+}
+
+// ── Main Coach Panel ──
+export default function ContributionCoachPanel() {
+ const {
+ isOpen,
+ isLoading,
+ advice,
+ error,
+ currentTrigger,
+ pendingPayload,
+ close,
+ consumePayload,
+ clearAdvice,
+ fetchSuggestion,
+ } = useCoachStore();
+
+ const contentRef = useRef(null);
+ const [saving, setSaving] = useState(false);
+
+ const isFetchingBus = useRef(false);
+
+ // Consume pending payload — fetch suggestion
+ useEffect(() => {
+ if (!pendingPayload || isFetchingBus.current) return;
+
+ const payload = pendingPayload;
+ // Immediately clear so no other component (or local re-render) captures it
+ consumePayload();
+ isFetchingBus.current = true;
+
+ void fetchSuggestion(payload).finally(() => {
+ isFetchingBus.current = false;
+ });
+
+ return () => {
+ isFetchingBus.current = false;
+ };
+ }, [pendingPayload, consumePayload, fetchSuggestion]);
+
+ // Auto-scroll as advice arrives
+ useEffect(() => {
+ if (advice && contentRef.current) {
+ contentRef.current.scrollTop = contentRef.current.scrollHeight;
+ }
+ }, [advice]);
+
+ const handleSave = async () => {
+ if (!advice) return;
+ setSaving(true);
+ try {
+ await saveCoachAdvice({
+ content: advice,
+ trigger: currentTrigger ?? "MANUAL",
+ });
+ toast.success("Advice saved!");
+ } catch {
+ toast.error("Failed to save advice");
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ const handleRetry = () => {
+ if (!currentTrigger) return;
+ clearAdvice();
+ const payload = {
+ trigger: currentTrigger,
+ context: {}, // Note: Context might be thin on retry unless we persist it
+ };
+ void fetchSuggestion(payload);
+ };
+
+ const triggerLabel: Record = {
+ FIRST_PR_COMPLETE: "🎉 First PR Roadmap Complete",
+ REPO_BOOKMARKED: "📌 Repo Bookmarked",
+ GITHUB_CONNECTED: "🔗 GitHub Connected",
+ INACTIVITY: "👋 Welcome Back",
+ MANUAL: "💡 Coach Advice",
+ };
+
+ return (
+
+ {isOpen && (
+ <>
+ {/* Backdrop — mobile */}
+
+
+ {/* Panel */}
+
+ {/* Header */}
+
+
+
+
+
+
+
+ Contribution Coach
+
+
+ AI-powered guidance
+
+
+
+
+
+
+ {/* Trigger badge */}
+ {currentTrigger && (
+
+
+ {triggerLabel[currentTrigger] ?? "💡 Coach Advice"}
+
+
+ )}
+
+ {/* Content */}
+
+ {isLoading && !advice && (
+
+
+
+ Analyzing your profile…
+
+
+ Building personalized recommendations
+
+
+ )}
+
+ {error && (
+
+
+
+ {error}
+
+
+
+ )}
+
+ {advice && !error && (
+
{renderMarkdown(advice)}
+ )}
+
+ {!isLoading && !advice && !error && (
+
+
+
+
+
+ Your AI Contribution Coach
+
+
+ Complete guides, bookmark repos, or connect GitHub to get
+ personalized guidance.
+
+
+ )}
+
+
+ {/* Actions */}
+ {advice && !error && (
+
+
+
+
+ )}
+
+ {/* Saved advice section */}
+
+
+ >
+ )}
+
+ );
+}
diff --git a/client/src/module/student/opensource/FirstPRRoadmapPage.tsx b/client/src/module/student/opensource/FirstPRRoadmapPage.tsx
index 11f795c52..6bad57cd6 100644
--- a/client/src/module/student/opensource/FirstPRRoadmapPage.tsx
+++ b/client/src/module/student/opensource/FirstPRRoadmapPage.tsx
@@ -12,6 +12,8 @@ import {
patchFirstPRProgress,
} from "./api/opensource.api";
import guideData from "./data/open-source-guide.json";
+import { useAuthStore } from "../../../lib/auth.store";
+import { useCoachStore } from "./stores/coach.store";
// ─── Types ─────────────────────────────────────────────────────
interface Step {
@@ -29,6 +31,8 @@ export default function FirstPRRoadmapPage() {
const [showResetConfirm, setShowResetConfirm] = useState(false);
const [completed, setCompleted] = useState>(new Set());
const [isLoading, setIsLoading] = useState(true);
+ const { user } = useAuthStore();
+ const triggerCoach = useCoachStore((s) => s.triggerCoach);
useEffect(() => {
let isMounted = true;
@@ -60,6 +64,8 @@ export default function FirstPRRoadmapPage() {
const isCurrentlyCompleted = completed.has(id);
const nextCompleted = !isCurrentlyCompleted;
+ const isCompletingLastStep = nextCompleted && completed.size === STEPS.length - 1;
+
setCompleted((prev) => {
const next = new Set(prev);
if (nextCompleted) next.add(id);
@@ -67,6 +73,17 @@ export default function FirstPRRoadmapPage() {
return next;
});
+ // Trigger coach if this click completes the roadmap
+ if (isCompletingLastStep) {
+ triggerCoach({
+ trigger: "FIRST_PR_COMPLETE",
+ context: {
+ skills: user?.skills || [],
+ completedGuides: ["First Pull Request Roadmap"],
+ },
+ });
+ }
+
void patchFirstPRProgress(id, nextCompleted).catch(() => {
setCompleted((prev) => {
const rolledBack = new Set(prev);
diff --git a/client/src/module/student/opensource/OpenSourceLayout.tsx b/client/src/module/student/opensource/OpenSourceLayout.tsx
index e3f4c848e..a9c43c584 100644
--- a/client/src/module/student/opensource/OpenSourceLayout.tsx
+++ b/client/src/module/student/opensource/OpenSourceLayout.tsx
@@ -1,6 +1,8 @@
import { Fragment } from "react";
import { Outlet, useLocation, Link } from "react-router";
import { ChevronRight } from "lucide-react";
+import ContributionCoachPanel from "./ContributionCoachPanel";
+import CoachFloatingButton from "./CoachFloatingButton";
const SEGMENT_NAMES: Record = {
opensource: "Open Source",
@@ -68,6 +70,8 @@ export default function OpenSourceLayout() {
+
+
);
}
diff --git a/client/src/module/student/opensource/RepoDiscoveryPage.tsx b/client/src/module/student/opensource/RepoDiscoveryPage.tsx
index 12d4f25d6..21163fbde 100644
--- a/client/src/module/student/opensource/RepoDiscoveryPage.tsx
+++ b/client/src/module/student/opensource/RepoDiscoveryPage.tsx
@@ -42,6 +42,7 @@ import { SuggestRepoModal } from "./SuggestRepoModal";
import { useRecentlyViewedRepos } from "./useRecentlyViewedRepos";
import { RecentlyViewedSection } from "./_shared/RecentlyViewedSection";
import { Button } from "../../../components/ui/button";
+import { useCoachStore } from "./stores/coach.store";
const BOOKMARK_KEY = "oss_bookmarks";
@@ -99,6 +100,7 @@ const SKILL_LANGUAGE_MAP: Record = {
export default function RepoDiscoveryPage() {
const [searchParams, setSearchParams] = useSearchParams();
+ const triggerCoach = useCoachStore((s) => s.triggerCoach);
// Initialize filter states directly from the URL
const search = searchParams.get("q") || "";
@@ -248,11 +250,32 @@ export default function RepoDiscoveryPage() {
}, [bookmarks, showSaved]);
const toggleBookmark = (id: number) => {
+ const isBookmarking = !bookmarks.includes(id);
+
setBookmarks((prev) => {
- const next = prev.includes(id) ? prev.filter((b) => b !== id) : [...prev, id];
+ const next = isBookmarking ? [...prev, id] : prev.filter((b) => b !== id);
saveBookmarks(next);
return next;
});
+
+ if (isBookmarking) {
+ const repo = data?.repos?.find((r) => r.id === id);
+ if (repo) {
+ triggerCoach({
+ trigger: "REPO_BOOKMARKED",
+ context: {
+ skills: user?.skills || [],
+ bookmarkedRepos: [
+ {
+ name: repo.name,
+ language: repo.language,
+ domain: repo.domain || undefined,
+ },
+ ],
+ },
+ });
+ }
+ }
};
const handleShare = () => {
diff --git a/client/src/module/student/opensource/api/coach.api.ts b/client/src/module/student/opensource/api/coach.api.ts
new file mode 100644
index 000000000..390af9412
--- /dev/null
+++ b/client/src/module/student/opensource/api/coach.api.ts
@@ -0,0 +1,57 @@
+import api from "../../../../lib/axios";
+
+export type CoachTrigger =
+ | "FIRST_PR_COMPLETE"
+ | "REPO_BOOKMARKED"
+ | "GITHUB_CONNECTED"
+ | "INACTIVITY"
+ | "MANUAL";
+
+export interface CoachSuggestPayload {
+ trigger: CoachTrigger;
+ context: {
+ completedGuides?: string[];
+ skills?: string[];
+ bookmarkedRepos?: { name: string; language?: string; domain?: string }[];
+ githubUsername?: string;
+ lastActiveGuide?: string;
+ daysSinceLastActivity?: number;
+ };
+}
+
+export interface SavedAdvice {
+ id: number;
+ userId: number;
+ trigger: string;
+ title: string;
+ content: string;
+ createdAt: string;
+}
+
+export async function fetchCoachSuggestion(
+ payload: CoachSuggestPayload,
+): Promise {
+ const { data } = await api.post<{ advice: string }>(
+ "/coach/suggest",
+ payload,
+ );
+ return data.advice;
+}
+
+export async function saveCoachAdvice(body: {
+ content: string;
+ trigger: string;
+ title?: string;
+}): Promise {
+ const { data } = await api.post("/coach/save", body);
+ return data;
+}
+
+export async function fetchSavedAdvice(): Promise {
+ const { data } = await api.get<{ advice: SavedAdvice[] }>("/coach/saved");
+ return data.advice;
+}
+
+export async function deleteCoachAdvice(id: number): Promise {
+ await api.delete(`/coach/saved/${id}`);
+}
diff --git a/client/src/module/student/opensource/stores/coach.store.ts b/client/src/module/student/opensource/stores/coach.store.ts
new file mode 100644
index 000000000..9eb0045bb
--- /dev/null
+++ b/client/src/module/student/opensource/stores/coach.store.ts
@@ -0,0 +1,94 @@
+import { create } from "zustand";
+import type { CoachTrigger, CoachSuggestPayload } from "../api/coach.api";
+
+interface CoachState {
+ /** Whether the sidebar panel is open */
+ isOpen: boolean;
+ /** Loading state while fetching AI suggestion */
+ isLoading: boolean;
+ /** The generated advice markdown */
+ advice: string;
+ /** Error message, if any */
+ error: string | null;
+ /** The trigger that generated the current advice */
+ currentTrigger: CoachTrigger | null;
+ /** The pending payload waiting to be sent */
+ pendingPayload: CoachSuggestPayload | null;
+
+ open: () => void;
+ close: () => void;
+ toggle: () => void;
+
+ /** Queue a trigger — opens the panel and stores the payload for fetching */
+ triggerCoach: (payload: CoachSuggestPayload) => void;
+
+ /** Set fetching state */
+ setLoading: (loading: boolean) => void;
+
+ /** Set the advice result */
+ setAdvice: (advice: string) => void;
+
+ /** Set an error */
+ setError: (error: string | null) => void;
+
+ /** Clear the payload after it's been consumed */
+ consumePayload: () => void;
+
+ /** Reset advice state (keeps panel open) */
+ clearAdvice: () => void;
+
+ /** Comprehensive fetch action with integrated error handling */
+ fetchSuggestion: (payload: CoachSuggestPayload) => Promise;
+}
+
+export const useCoachStore = create((set, get) => ({
+ isOpen: false,
+ isLoading: false,
+ advice: "",
+ error: null,
+ currentTrigger: null,
+ pendingPayload: null,
+
+ open: () => set({ isOpen: true }),
+ close: () => set({ isOpen: false }),
+ toggle: () => set((s) => ({ isOpen: !s.isOpen })),
+
+ triggerCoach: (payload) =>
+ set((state) => {
+ // Guard: Don't trigger if already loading or has pending payload
+ if (state.isLoading || state.pendingPayload) return state;
+
+ return {
+ isOpen: true,
+ pendingPayload: payload,
+ advice: "",
+ error: null,
+ currentTrigger: payload.trigger,
+ isLoading: true,
+ };
+ }),
+
+ setLoading: (loading) => set({ isLoading: loading }),
+ setAdvice: (advice) => set({ advice, isLoading: false, error: null }),
+ setError: (error) => set({ error, isLoading: false }),
+ consumePayload: () => set({ pendingPayload: null }),
+ clearAdvice: () => set({ advice: "", error: null, currentTrigger: null }),
+
+ fetchSuggestion: async (payload) => {
+ const { setAdvice, setError, setLoading } = get();
+ setLoading(true);
+ setError(null);
+ try {
+ // Import dynamically or use the one from api.ts
+ const { fetchCoachSuggestion } = await import("../api/coach.api");
+ const result = await fetchCoachSuggestion(payload);
+ setAdvice(result);
+ } catch (err: any) {
+ console.error("[coach] fetch failed:", err);
+ const msg = err.response?.data?.message || "Failed to get coaching advice. Please check your connection.";
+ setError(msg);
+ } finally {
+ setLoading(false);
+ }
+ },
+}));
diff --git a/server/src/database/prisma/migrations/20260605150000_add_coach_advice/migration.sql b/server/src/database/prisma/migrations/20260605150000_add_coach_advice/migration.sql
new file mode 100644
index 000000000..677d49e50
--- /dev/null
+++ b/server/src/database/prisma/migrations/20260605150000_add_coach_advice/migration.sql
@@ -0,0 +1,20 @@
+-- CreateTable
+CREATE TABLE "coachAdvice" (
+ "id" SERIAL NOT NULL,
+ "userId" INTEGER NOT NULL,
+ "trigger" TEXT NOT NULL,
+ "title" TEXT NOT NULL DEFAULT 'Coach suggestion',
+ "content" TEXT NOT NULL,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "coachAdvice_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE INDEX "coachAdvice_userId_idx" ON "coachAdvice"("userId");
+
+-- CreateIndex
+CREATE INDEX "coachAdvice_createdAt_idx" ON "coachAdvice"("createdAt");
+
+-- AddForeignKey
+ALTER TABLE "coachAdvice" ADD CONSTRAINT "coachAdvice_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
diff --git a/server/src/database/prisma/schema/base.prisma b/server/src/database/prisma/schema/base.prisma
index 549131baa..3cc462fd8 100644
--- a/server/src/database/prisma/schema/base.prisma
+++ b/server/src/database/prisma/schema/base.prisma
@@ -99,6 +99,7 @@ model user {
hackathonParticipations hackathonParticipation[] @relation("UserHackathonParticipations")
contentViews contentView[] @relation("UserContentViews")
statusHistory applicationStatusHistory[] @relation("ChangerStatusHistory")
+ coachAdvice coachAdvice[] @relation("UserCoachAdvice")
@@index([role])
@@index([role, isActive])
@@index([createdAt])
@@ -1491,3 +1492,16 @@ model contentView {
@@index([createdAt])
}
+model coachAdvice {
+ id Int @id @default(autoincrement())
+ userId Int
+ trigger String
+ title String @default("Coach suggestion")
+ content String @db.Text
+ createdAt DateTime @default(now())
+ user user @relation("UserCoachAdvice", fields: [userId], references: [id], onDelete: Cascade)
+
+ @@index([userId])
+ @@index([createdAt])
+}
+
diff --git a/server/src/index.ts b/server/src/index.ts
index 4dfcb7876..e044a82e7 100644
--- a/server/src/index.ts
+++ b/server/src/index.ts
@@ -63,6 +63,7 @@ import { milestoneRouter } from "./module/milestone/milestone.routes.js";
import { roadmapRouter } from "./module/roadmap/roadmap.routes.js";
import { recommendationRouter } from "./module/recommendation/recommendation.routes.js";
import { learnRouter } from "./module/learn/learn.routes.js";
+import { coachRouter } from "./module/coach/coach.routes.js";
import analyticsRouter from "./module/analytics/analytics.routes.js";
import { healthRouter } from "./module/health/health.routes.js";
import { botSeoMiddleware } from "./middleware/bot-seo.middleware.js";
@@ -291,6 +292,7 @@ app.use("/api/milestones", milestoneRouter);
app.use("/api/roadmaps", roadmapRouter);
app.use("/api/analytics", analyticsRouter);
app.use("/api/learn", learnRouter);
+app.use("/api/coach", coachRouter);
// Contact form (public, no auth)
app.use("/api/contact", contactRouter);
diff --git a/server/src/module/coach/coach.controller.ts b/server/src/module/coach/coach.controller.ts
new file mode 100644
index 000000000..c222c1702
--- /dev/null
+++ b/server/src/module/coach/coach.controller.ts
@@ -0,0 +1,90 @@
+import type { Request, Response, NextFunction } from "express";
+import type { CoachService } from "./coach.service.js";
+import { coachSuggestSchema, coachSaveSchema } from "./coach.validation.js";
+
+export class CoachController {
+ constructor(private readonly coachService: CoachService) {}
+
+ async suggest(req: Request, res: Response, next: NextFunction) {
+ try {
+ if (!req.user) {
+ res.status(401).json({ message: "Authentication required" });
+ return;
+ }
+
+ const result = coachSuggestSchema.safeParse(req.body);
+ if (!result.success) {
+ res
+ .status(400)
+ .json({ message: "Validation failed", errors: result.error.flatten() });
+ return;
+ }
+
+ const advice = await this.coachService.suggest(result.data, req.user.id);
+ res.json({ advice });
+ } catch (err) {
+ next(err);
+ }
+ }
+
+ async saveAdvice(req: Request, res: Response, next: NextFunction) {
+ try {
+ if (!req.user) {
+ res.status(401).json({ message: "Authentication required" });
+ return;
+ }
+
+ const result = coachSaveSchema.safeParse(req.body);
+ if (!result.success) {
+ res
+ .status(400)
+ .json({ message: "Validation failed", errors: result.error.flatten() });
+ return;
+ }
+
+ const saved = await this.coachService.saveAdvice(
+ req.user.id,
+ result.data.content,
+ result.data.trigger,
+ result.data.title,
+ );
+ res.status(201).json(saved);
+ } catch (err) {
+ next(err);
+ }
+ }
+
+ async getSavedAdvice(req: Request, res: Response, next: NextFunction) {
+ try {
+ if (!req.user) {
+ res.status(401).json({ message: "Authentication required" });
+ return;
+ }
+
+ const advice = await this.coachService.getSavedAdvice(req.user.id);
+ res.json({ advice });
+ } catch (err) {
+ next(err);
+ }
+ }
+
+ async deleteAdvice(req: Request, res: Response, next: NextFunction) {
+ try {
+ if (!req.user) {
+ res.status(401).json({ message: "Authentication required" });
+ return;
+ }
+
+ const adviceId = Number(req.params["id"]);
+ if (!adviceId || isNaN(adviceId)) {
+ res.status(400).json({ message: "Invalid advice ID" });
+ return;
+ }
+
+ await this.coachService.deleteAdvice(req.user.id, adviceId);
+ res.json({ message: "Advice deleted" });
+ } catch (err) {
+ next(err);
+ }
+ }
+}
diff --git a/server/src/module/coach/coach.routes.ts b/server/src/module/coach/coach.routes.ts
new file mode 100644
index 000000000..259dfecdc
--- /dev/null
+++ b/server/src/module/coach/coach.routes.ts
@@ -0,0 +1,42 @@
+import { Router } from "express";
+import { authMiddleware } from "../../middleware/auth.middleware.js";
+import { requireRole } from "../../middleware/role.middleware.js";
+import { CoachService } from "./coach.service.js";
+import { CoachController } from "./coach.controller.js";
+
+const coachService = new CoachService();
+const coachController = new CoachController(coachService);
+
+export const coachRouter = Router();
+
+// POST /api/coach/suggest — generate AI coaching advice
+coachRouter.post(
+ "/suggest",
+ authMiddleware,
+ requireRole("STUDENT"),
+ (req, res, next) => coachController.suggest(req, res, next),
+);
+
+// POST /api/coach/save — persist a piece of advice
+coachRouter.post(
+ "/save",
+ authMiddleware,
+ requireRole("STUDENT"),
+ (req, res, next) => coachController.saveAdvice(req, res, next),
+);
+
+// GET /api/coach/saved — get all saved advice
+coachRouter.get(
+ "/saved",
+ authMiddleware,
+ requireRole("STUDENT"),
+ (req, res, next) => coachController.getSavedAdvice(req, res, next),
+);
+
+// DELETE /api/coach/saved/:id — delete a saved advice
+coachRouter.delete(
+ "/saved/:id",
+ authMiddleware,
+ requireRole("STUDENT"),
+ (req, res, next) => coachController.deleteAdvice(req, res, next),
+);
diff --git a/server/src/module/coach/coach.service.ts b/server/src/module/coach/coach.service.ts
new file mode 100644
index 000000000..727df2d24
--- /dev/null
+++ b/server/src/module/coach/coach.service.ts
@@ -0,0 +1,156 @@
+import type { CoachSuggestInput } from "./coach.validation.js";
+import { getProviderForService } from "../../lib/ai-provider-registry.js";
+import { logAIRequest } from "../../lib/ai-request-logger.js";
+import { prisma } from "../../database/db.js";
+import { createLogger } from "../../utils/logger.js";
+
+const logger = createLogger("coach.service");
+
+export class CoachService {
+ /**
+ * Generate a personalized coaching suggestion via Gemini.
+ * Returns the full Markdown text (non-streamed, single response).
+ */
+ async suggest(input: CoachSuggestInput, userId: number): Promise {
+ const prompt = this.buildPrompt(input);
+ const provider = getProviderForService("LATEX_CHAT"); // reuse existing AI config, no migration needed
+ const response = await provider.generateText(prompt);
+ logAIRequest("LATEX_CHAT", response, true, undefined, userId);
+ return response.text.trim();
+ }
+
+ /**
+ * Persist a coach advice for a user so they can reference it later.
+ */
+ async saveAdvice(
+ userId: number,
+ content: string,
+ trigger: string,
+ title?: string,
+ ) {
+ return prisma.coachAdvice.create({
+ data: {
+ userId,
+ content,
+ trigger,
+ title: title ?? this.titleFromTrigger(trigger),
+ },
+ });
+ }
+
+ /**
+ * Get all saved advice for a user, most recent first.
+ */
+ async getSavedAdvice(userId: number) {
+ return prisma.coachAdvice.findMany({
+ where: { userId },
+ orderBy: { createdAt: "desc" },
+ take: 50,
+ });
+ }
+
+ /**
+ * Delete a single saved advice entry.
+ */
+ async deleteAdvice(userId: number, adviceId: number) {
+ const advice = await prisma.coachAdvice.findUnique({
+ where: { id: adviceId },
+ });
+ if (!advice) throw new Error("Advice not found");
+ if (advice.userId !== userId) throw new Error("Not authorized");
+ await prisma.coachAdvice.delete({ where: { id: adviceId } });
+ }
+
+ // ────────────────────────────────────────────────────────────────
+ // Private helpers
+ // ────────────────────────────────────────────────────────────────
+
+ private titleFromTrigger(trigger: string): string {
+ const titles: Record = {
+ FIRST_PR_COMPLETE: "After completing First PR Roadmap",
+ REPO_BOOKMARKED: "Repo bookmark advice",
+ GITHUB_CONNECTED: "GitHub analysis & suggestions",
+ INACTIVITY: "Getting back on track",
+ MANUAL: "Coach suggestion",
+ };
+ return titles[trigger] ?? "Coach suggestion";
+ }
+
+ private buildPrompt(input: CoachSuggestInput): string {
+ const { trigger, context } = input;
+
+ const skillsBlock =
+ context.skills.length > 0
+ ? `The user's skills: ${context.skills.join(", ")}.`
+ : "The user has not listed any skills yet.";
+
+ const guidesBlock =
+ context.completedGuides.length > 0
+ ? `Completed guides: ${context.completedGuides.join(", ")}.`
+ : "";
+
+ const reposBlock =
+ context.bookmarkedRepos.length > 0
+ ? `Bookmarked repos:\n${context.bookmarkedRepos.map((r) => `- ${r.name} (${r.language ?? "unknown"}, ${r.domain ?? "general"})`).join("\n")}`
+ : "";
+
+ const githubBlock = context.githubUsername
+ ? `GitHub username: ${context.githubUsername}.`
+ : "";
+
+ let scenarioInstruction: string;
+
+ switch (trigger) {
+ case "FIRST_PR_COMPLETE":
+ scenarioInstruction = `The user just completed the "First Pull Request Roadmap". Congratulate them briefly, then suggest 3 specific beginner-friendly open-source repositories that match their tech stack and skills. For each repo, explain why it's a good fit and give one concrete first step (e.g., "Look at issue #X labeled good-first-issue"). Prioritize repos that are actively maintained and welcoming to new contributors.`;
+ break;
+
+ case "REPO_BOOKMARKED":
+ scenarioInstruction = `The user bookmarked repositories listed above. For the most recently bookmarked repo, give specific advice on how to approach contributing:
+1. Reading the README and CONTRIBUTING.md
+2. Understanding the project structure
+3. Finding good-first-issues or documentation issues
+4. Setting up the development environment
+5. How to introduce themselves to the community (if applicable)
+Keep it practical and actionable.`;
+ break;
+
+ case "GITHUB_CONNECTED":
+ scenarioInstruction = `The user just connected their GitHub account. Based on their skills and profile, suggest 3 slightly more advanced "stretch" repositories they could contribute to — projects that would help them grow. Explain what makes each challenging but achievable. Include tips on how to stand out as a contributor.`;
+ break;
+
+ case "INACTIVITY":
+ scenarioInstruction = `The user has been inactive for ${context.daysSinceLastActivity ?? 7}+ days.${
+ context.lastActiveGuide
+ ? ` They were last working on: "${context.lastActiveGuide}".`
+ : ""
+ } Write a friendly, encouraging message that:
+1. Reminds them where they left off
+2. Gives one small, 15-minute actionable step they can do right now
+3. Shares a motivational insight about the value of consistent open-source contribution
+Don't be pushy — be warm and supportive.`;
+ break;
+
+ default:
+ scenarioInstruction = `Give the user personalized next-step guidance for their open-source contribution journey based on their profile and activity.`;
+ }
+
+ return `You are the InternHack Contribution Coach — a warm, knowledgeable mentor helping students contribute to open source.
+
+USER PROFILE:
+${skillsBlock}
+${guidesBlock}
+${reposBlock}
+${githubBlock}
+
+SCENARIO:
+${scenarioInstruction}
+
+FORMATTING RULES:
+- Respond in clean Markdown (headings, bullets, bold for emphasis).
+- Keep the total response under 600 words.
+- Be specific and actionable — no generic platitudes.
+- Use a friendly, peer-mentor tone (not corporate).
+- Do NOT wrap the response in code fences or JSON.`;
+ }
+}
diff --git a/server/src/module/coach/coach.validation.ts b/server/src/module/coach/coach.validation.ts
new file mode 100644
index 000000000..f3e628a36
--- /dev/null
+++ b/server/src/module/coach/coach.validation.ts
@@ -0,0 +1,37 @@
+import { z } from "zod";
+
+export const coachSuggestSchema = z.object({
+ trigger: z.enum([
+ "FIRST_PR_COMPLETE",
+ "REPO_BOOKMARKED",
+ "GITHUB_CONNECTED",
+ "INACTIVITY",
+ "MANUAL",
+ ]),
+ context: z.object({
+ completedGuides: z.array(z.string()).optional().default([]),
+ skills: z.array(z.string()).optional().default([]),
+ bookmarkedRepos: z
+ .array(
+ z.object({
+ name: z.string(),
+ language: z.string().optional(),
+ domain: z.string().optional(),
+ }),
+ )
+ .optional()
+ .default([]),
+ githubUsername: z.string().optional(),
+ lastActiveGuide: z.string().optional(),
+ daysSinceLastActivity: z.number().optional(),
+ }),
+});
+
+export const coachSaveSchema = z.object({
+ content: z.string().min(1).max(20000),
+ trigger: z.string().min(1).max(50),
+ title: z.string().min(1).max(200).optional(),
+});
+
+export type CoachSuggestInput = z.infer;
+export type CoachSaveInput = z.infer;
diff --git a/server/src/module/opensource/opensource.controller.ts b/server/src/module/opensource/opensource.controller.ts
index 987ee3f18..511949000 100644
--- a/server/src/module/opensource/opensource.controller.ts
+++ b/server/src/module/opensource/opensource.controller.ts
@@ -244,4 +244,13 @@ export class OpensourceController {
next(err);
}
}
+
+ async getRecommendedRepos(req: Request, res: Response, next: NextFunction) {
+ try {
+ const repos = await service.getRecommendedRepos(req.user!.id);
+ res.json({ repos });
+ } catch (err) {
+ next(err);
+ }
+ }
}
diff --git a/server/src/module/opensource/opensource.routes.ts b/server/src/module/opensource/opensource.routes.ts
index ba11ae812..a28d7c730 100644
--- a/server/src/module/opensource/opensource.routes.ts
+++ b/server/src/module/opensource/opensource.routes.ts
@@ -21,6 +21,10 @@ opensourceRouter.get("/languages", (req, res, next) => controller.getLanguages(r
// Get GSoC organizations
opensourceRouter.get("/gsoc/orgs", (req, res, next) => controller.getGsocOrgs(req, res, next));
+// Get recommended repos for student based on skills
+opensourceRouter.get("/recommended", authMiddleware, requireRole("STUDENT"), (req, res, next) =>
+ controller.getRecommendedRepos(req, res, next),
+);
// NOTE: these must be registered BEFORE /:id to avoid route conflicts
opensourceRouter.post("/requests", authMiddleware, requireRole("STUDENT"), (req, res, next) =>
diff --git a/server/src/module/opensource/opensource.service.ts b/server/src/module/opensource/opensource.service.ts
index 9fca8dffd..dc7e74705 100644
--- a/server/src/module/opensource/opensource.service.ts
+++ b/server/src/module/opensource/opensource.service.ts
@@ -413,4 +413,37 @@ where["OR"] = [
});
return progress.completedStepIds;
}
+
+ async getRecommendedRepos(userId: number) {
+ // 1. Get student profile with skills
+ const student = await prisma.user.findUnique({
+ where: { id: userId },
+ select: { skills: true },
+ });
+
+ const skills = student?.skills || [];
+ if (skills.length === 0) {
+ // Return trending repos as default fallback
+ return prisma.opensourceRepo.findMany({
+ where: { trending: true },
+ take: 6,
+ orderBy: { stars: "desc" },
+ });
+ }
+
+ // 2. Fetch repos matching skills (language or techStack subset)
+ // We search for repos where the primary language is in the student's skills
+ const repos = await prisma.opensourceRepo.findMany({
+ where: {
+ OR: [
+ { language: { in: skills, mode: "insensitive" } },
+ { trending: true },
+ ],
+ },
+ take: 8,
+ orderBy: [{ trending: "desc" }, { stars: "desc" }],
+ });
+
+ return repos;
+ }
}