Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions client/src/lib/types/learning.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,17 @@ export interface DsaExecutionResult {
submissionId: number;
}

export interface DsaCodeReview {
timeComplexity: string;
spaceComplexity: string;
readability: {
score: number;
feedback: string;
};
edgeCases: string[];
suggestions: string[];
}

export interface DsaSubmissionSummary {
id: number;
language: DsaLanguage;
Expand Down
34 changes: 31 additions & 3 deletions client/src/module/student/dsa/DsaProblemDetailPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,13 @@ import {
ExternalLink, CheckCircle2, Circle,
Bookmark, BookmarkCheck, ChevronDown,
Building2, BarChart3, Lightbulb, StickyNote, Link2, ArrowUpRight,
History, Terminal, Lock, Crown, Code2, Flag, X,
History, Terminal, Lock, Crown, Code2, Flag, X, Sparkles,
} from "lucide-react";
import toast from "@/components/ui/toast";
import api from "../../../lib/axios";
import { queryKeys } from "../../../lib/query-keys";
import type { DsaProblemDetail, DsaLanguage, DsaExecutionResult, DsaSubmissionSummary, DsaSimilarProblem } from "../../../lib/types";
import type { DsaProblemDetail, DsaLanguage, DsaExecutionResult, DsaSubmissionSummary, DsaSimilarProblem, DsaCodeReview } from "../../../lib/types";
import { DsaAiReviewPanel } from "./components/DsaAiReviewPanel";
import { useAuthStore } from "../../../lib/auth.store";
import { SEO } from "../../../components/SEO";
import { canonicalUrl, SITE_URL } from "../../../lib/seo.utils";
Expand Down Expand Up @@ -114,7 +115,7 @@ export default function DsaProblemDetailPage() {
const [reportMessage, setReportMessage] = useState("");

const [activeTab, setActiveTab] = useState<"problem" | "code">("problem");
const [rightTab, setRightTab] = useState<"results" | "history" | "output">("results");
const [rightTab, setRightTab] = useState<"results" | "history" | "output" | "ai-review">("results");
const [language, setLanguage] = useState<DsaLanguage>("python");
const [codeMap, setCodeMap] = useState<Record<DsaLanguage, string>>({
python: DEFAULT_CODE.python,
Expand Down Expand Up @@ -247,6 +248,20 @@ export default function DsaProblemDetailPage() {
},
});

const aiReviewMutation = useMutation({
mutationFn: (submissionId: number) =>
api.post<DsaCodeReview>(`/dsa/submissions/${submissionId}/review`).then((r) => r.data),
onError: (err: { response?: { status?: number; data?: { message?: string } } }) => {
if (err?.response?.status === 429) {
toast.error(err.response?.data?.message ?? "Daily limit reached");
} else {
toast.error(err?.response?.data?.message ?? "AI review failed");
}
},
});

useEffect(() => { aiReviewMutation.reset(); }, [problem?.id, aiReviewMutation]);

const handleRun = useCallback(() => {
if (!problem || !user || !isPremium) return;
executeMutation.mutate({ problemId: problem.id, lang: language, code: codeMap[language] });
Expand Down Expand Up @@ -644,6 +659,7 @@ export default function DsaProblemDetailPage() {
{ key: "results" as const, label: "test results", icon: null },
{ key: "output" as const, label: "output", icon: Terminal },
{ key: "history" as const, label: "history", icon: History },
{ key: "ai-review" as const, label: "ai review", icon: Sparkles },
]).map(({ key, label, icon: Icon }) => (
<button
key={key}
Expand Down Expand Up @@ -672,6 +688,18 @@ export default function DsaProblemDetailPage() {
<DsaTestResults result={executeMutation.data ?? null} isRunning={executeMutation.isPending} />
) : rightTab === "output" ? (
<DsaConsoleOutput result={executeMutation.data ?? null} isRunning={executeMutation.isPending} />
) : rightTab === "ai-review" ? (
<DsaAiReviewPanel
review={aiReviewMutation.data ?? null}
isLoading={aiReviewMutation.isPending}
error={aiReviewMutation.error}
onRequestReview={() => {
if (executeMutation.data?.submissionId) {
aiReviewMutation.mutate(executeMutation.data.submissionId);
}
}}
hasSubmission={!!executeMutation.data?.submissionId}
/>
) : (
<DsaSubmissionHistory submissions={submissions ?? []} onLoadCode={handleLoadSubmission} />
)}
Expand Down
196 changes: 196 additions & 0 deletions client/src/module/student/dsa/components/DsaAiReviewPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
import { Sparkles, Clock, HardDrive, Eye, AlertTriangle, Lightbulb, RefreshCw, Loader2 } from "lucide-react";
import type { DsaCodeReview } from "../../../../lib/types";
import { Button } from "../../../../components/ui/button";

interface Props {
review: DsaCodeReview | null;
isLoading: boolean;
error: unknown;
onRequestReview: () => void;
hasSubmission: boolean;
}

const SCORE_COLOR: Record<string, string> = {
low: "text-red-600 dark:text-red-400",
mid: "text-amber-600 dark:text-amber-400",
good: "text-lime-600 dark:text-lime-400",
great: "text-emerald-600 dark:text-emerald-400",
};

function scoreColor(score: number): string {
if (score <= 3) return SCORE_COLOR.low;
if (score <= 5) return SCORE_COLOR.mid;
if (score <= 7) return SCORE_COLOR.good;
return SCORE_COLOR.great;
}

function scoreLabel(score: number): string {
if (score <= 3) return "Needs work";
if (score <= 5) return "Decent";
if (score <= 7) return "Good";
return "Excellent";
}

export function DsaAiReviewPanel({ review, isLoading, error, onRequestReview, hasSubmission }: Props) {
if (isLoading) {
return (
<div className="p-6 flex flex-col items-center justify-center gap-3 text-stone-400">
<Loader2 className="w-8 h-8 text-lime-500 animate-spin" />
<p className="text-sm font-medium">Analyzing your code...</p>
<p className="text-xs text-stone-500">This may take a few seconds</p>
</div>
);
}

if (error) {
const errMsg = (error as { response?: { data?: { message?: string } } })?.response?.data?.message
?? (error instanceof Error ? error.message : "Something went wrong");
return (
<div className="p-6 flex flex-col items-center justify-center gap-3">
<AlertTriangle className="w-6 h-6 text-red-500" />
<p className="text-sm text-red-600 dark:text-red-400 font-medium">{errMsg}</p>
<Button variant="secondary" size="sm" onClick={onRequestReview}>
<RefreshCw className="w-3 h-3 mr-1.5" /> Retry
</Button>
</div>
);
}

if (!review) {
return (
<div className="p-6 flex flex-col items-center justify-center gap-4 text-center">
<div className="w-12 h-12 rounded-lg bg-stone-100 dark:bg-stone-800 border border-stone-200 dark:border-white/10 flex items-center justify-center">
<Sparkles className="w-5 h-5 text-lime-500" />
</div>
<div>
<p className="text-sm font-semibold text-stone-900 dark:text-stone-50 mb-1">
AI Code Review
</p>
<p className="text-xs text-stone-500 dark:text-stone-400 max-w-[240px] leading-relaxed">
{hasSubmission
? "Get AI feedback on your solution — complexity, readability, edge cases, and suggestions."
: "Run your code first, then request an AI review of your submission."
}
</p>
</div>
<Button
variant="primary"
size="sm"
onClick={onRequestReview}
disabled={!hasSubmission}
>
<Sparkles className="w-3 h-3 mr-1.5" /> Get AI Review
</Button>
</div>
);
}

return (
<div className="p-3 space-y-3">
{/* Complexity */}
<div className="grid grid-cols-2 gap-2">
<div className="border border-stone-200 dark:border-white/10 rounded-lg p-3 bg-white dark:bg-stone-950">
<div className="flex items-center gap-1.5 mb-1.5">
<Clock className="w-3 h-3 text-stone-500" />
<span className="text-[10px] font-mono uppercase tracking-widest text-stone-500">time</span>
</div>
<p className="text-sm font-semibold text-stone-900 dark:text-stone-50 leading-snug">
{review.timeComplexity}
</p>
</div>
<div className="border border-stone-200 dark:border-white/10 rounded-lg p-3 bg-white dark:bg-stone-950">
<div className="flex items-center gap-1.5 mb-1.5">
<HardDrive className="w-3 h-3 text-stone-500" />
<span className="text-[10px] font-mono uppercase tracking-widest text-stone-500">space</span>
</div>
<p className="text-sm font-semibold text-stone-900 dark:text-stone-50 leading-snug">
{review.spaceComplexity}
</p>
</div>
</div>

{/* Readability */}
<div className="border border-stone-200 dark:border-white/10 rounded-lg p-3 bg-white dark:bg-stone-950">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-1.5">
<Eye className="w-3 h-3 text-stone-500" />
<span className="text-[10px] font-mono uppercase tracking-widest text-stone-500">readability</span>
</div>
<div className="flex items-center gap-2">
<span className={`text-sm font-bold tabular-nums ${scoreColor(review.readability.score)}`}>
{review.readability.score}/10
</span>
<span className={`text-[10px] font-mono uppercase tracking-wider ${scoreColor(review.readability.score)}`}>
{scoreLabel(review.readability.score)}
</span>
</div>
</div>
{/* Score bar */}
<div className="h-1.5 bg-stone-100 dark:bg-stone-800 rounded-full overflow-hidden mb-2.5">
<div
className="h-full bg-lime-500 rounded-full transition-all duration-500"
style={{ width: `${(review.readability.score / 10) * 100}%` }}
/>
</div>
<p className="text-xs text-stone-600 dark:text-stone-400 leading-relaxed">
{review.readability.feedback}
</p>
</div>

{/* Edge Cases */}
{review.edgeCases.length > 0 && (
<div className="border border-stone-200 dark:border-white/10 rounded-lg bg-white dark:bg-stone-950 overflow-hidden">
<div className="px-3 py-2 border-b border-stone-100 dark:border-white/5 bg-stone-50 dark:bg-stone-900/50">
<div className="flex items-center gap-1.5">
<AlertTriangle className="w-3 h-3 text-amber-500" />
<span className="text-[10px] font-mono uppercase tracking-widest text-stone-500">
edge cases ({review.edgeCases.length})
</span>
</div>
</div>
<ul className="divide-y divide-stone-100 dark:divide-white/5">
{review.edgeCases.map((ec, i) => (
<li key={i} className="px-3 py-2.5 text-xs text-stone-700 dark:text-stone-300 leading-relaxed flex gap-2">
<span className="text-[10px] font-mono font-bold tabular-nums text-amber-600 dark:text-amber-400 shrink-0 mt-0.5">
{String(i + 1).padStart(2, "0")}
</span>
{ec}
</li>
))}
</ul>
</div>
)}

{/* Suggestions */}
{review.suggestions.length > 0 && (
<div className="border border-stone-200 dark:border-white/10 rounded-lg bg-white dark:bg-stone-950 overflow-hidden">
<div className="px-3 py-2 border-b border-stone-100 dark:border-white/5 bg-stone-50 dark:bg-stone-900/50">
<div className="flex items-center gap-1.5">
<Lightbulb className="w-3 h-3 text-lime-500" />
<span className="text-[10px] font-mono uppercase tracking-widest text-stone-500">
suggestions ({review.suggestions.length})
</span>
</div>
</div>
<ul className="divide-y divide-stone-100 dark:divide-white/5">
{review.suggestions.map((s, i) => (
<li key={i} className="px-3 py-2.5 text-xs text-stone-700 dark:text-stone-300 leading-relaxed flex gap-2">
<span className="text-[10px] font-mono font-bold tabular-nums text-lime-600 dark:text-lime-400 shrink-0 mt-0.5">
{String(i + 1).padStart(2, "0")}
</span>
{s}
</li>
))}
</ul>
</div>
)}

{/* Re-review button */}
<div className="flex justify-center pt-1">
<Button variant="ghost" size="sm" onClick={onRequestReview}>
<RefreshCw className="w-3 h-3 mr-1.5" /> Re-analyze
</Button>
</div>
</div>
);
}
1 change: 1 addition & 0 deletions server/src/database/prisma/schema/base.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -1226,6 +1226,7 @@ enum AIServiceType {
LATEX_CHAT
EMAIL_CHAT
AI_ROADMAP_GENERATION
DSA_CODE_REVIEW
}

enum BadgeCategory {
Expand Down
10 changes: 9 additions & 1 deletion server/src/module/admin/admin.validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -347,7 +347,15 @@ export const broadcastEmailSchema = z.object({
// ==================== AI PROVIDER MANAGEMENT ====================

export const switchAIProviderSchema = z.object({
service: z.enum(["ATS_SCORE", "COVER_LETTER", "RESUME_GEN", "LATEX_CHAT","AI_ROADMAP_GENERATION"]),
service: z.enum([
"ATS_SCORE",
"COVER_LETTER",
"RESUME_GEN",
"LATEX_CHAT",
"EMAIL_CHAT",
"AI_ROADMAP_GENERATION",
"DSA_CODE_REVIEW",
]),
provider: z.enum(["GEMINI", "GROQ", "OPENROUTER", "CODESTRAL", "CLAUDE"]),
modelName: z.string().min(1, "Model name is required"),
});
32 changes: 32 additions & 0 deletions server/src/module/dsa/dsa.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import type { Request, Response, NextFunction } from "express";
import { DsaService } from "./dsa.service.js";
import { parsePagination } from "../../utils/pagination.utils.js";
import { syncLeetCodeSolvedProblems } from "./leetcode.service.js";
import { prisma } from "../../database/db.js";
import { isPremiumUser } from "../../utils/premium.utils.js";

export class DsaController {
constructor(private dsaService: DsaService) {}
Expand Down Expand Up @@ -299,4 +301,34 @@ export class DsaController {
next(err);
}
}

async generateCodeReview(req: Request, res: Response, next: NextFunction) {
try {
const userId = req.user?.id;
if (!userId) { res.status(401).json({ message: "Authentication required" }); return; }

const isPremium = await isPremiumUser(userId);
if (!isPremium) {
res.status(403).json({ message: "Premium subscription required" });
return;
}

const submissionId = parseInt(req.params.submissionId as string);
if (isNaN(submissionId)) { res.status(400).json({ message: "Invalid submission ID" }); return; }
const review = await this.dsaService.generateCodeReview(submissionId, userId);
res.json(review);
} catch (err) {
if (err instanceof Error) {
if (err.message.includes("Submission not found")) {
res.status(404).json({ message: err.message });
return;
}
if (err.message.includes("Not authorized")) {
res.status(403).json({ message: err.message });
return;
}
}
next(err);
}
}
}
1 change: 1 addition & 0 deletions server/src/module/dsa/dsa.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ dsaRouter.get("/streak", authMiddleware, requireRole("STUDENT"), (req, res, next

dsaRouter.post("/problems/:problemId/execute", authMiddleware, requireRole("STUDENT"), usageLimit("CODE_RUN"), (req, res, next) => dsaController.executeCode(req, res, next));
dsaRouter.get("/problems/:problemId/submissions", authMiddleware, requireRole("STUDENT"), (req, res, next) => dsaController.getSubmissionHistory(req, res, next));
dsaRouter.post("/submissions/:submissionId/review", authMiddleware, requireRole("STUDENT"), usageLimit("CODE_RUN"), (req, res, next) => dsaController.generateCodeReview(req, res, next));
dsaRouter.post("/sync/leetcode", authMiddleware, requireRole("STUDENT"), (req, res, next) => dsaController.syncLeetCode(req, res, next));

// Public routes (with optional auth)
Expand Down
Loading
Loading