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; + } }