diff --git a/src/pages/api/lms/submissions/index.ts b/src/pages/api/lms/submissions/index.ts index eefca5f98..935e12b67 100644 --- a/src/pages/api/lms/submissions/index.ts +++ b/src/pages/api/lms/submissions/index.ts @@ -3,8 +3,14 @@ import { requireAuth, AuthenticatedRequest } from '@/lib/rbac'; import prisma from '@/lib/prisma'; /** - * POST /api/lms/submissions + * GET /api/lms/submissions + * Fetch user's submissions (optionally filtered by courseId or assignmentId) + * + * Query params: + * - courseId?: string - Filter by course + * - assignmentId?: string - Filter by specific assignment * + * POST /api/lms/submissions * Submit an assignment. Students can create or update their submissions. * Only one submission per user per assignment (enforced by unique constraint). * @@ -16,21 +22,63 @@ import prisma from '@/lib/prisma'; * notes?: string, * files?: string (JSON array of file URLs) * } - * - * Response: - * { - * submission: {...}, - * message: string - * } */ export default requireAuth(async (req: AuthenticatedRequest, res: NextApiResponse) => { + const userId = req.user!.id; + + // GET - Fetch user's submissions + if (req.method === 'GET') { + try { + const { courseId, assignmentId } = req.query; + + const where: any = { userId }; + + if (assignmentId) { + where.assignmentId = assignmentId as string; + } else if (courseId) { + where.assignment = { + courseId: courseId as string, + }; + } + + const submissions = await prisma.submission.findMany({ + where, + include: { + assignment: { + select: { + id: true, + title: true, + description: true, + dueDate: true, + maxPoints: true, + course: { + select: { + id: true, + title: true, + }, + }, + }, + }, + }, + orderBy: { + submittedAt: 'desc', + }, + }); + + return res.status(200).json({ submissions }); + } catch (error) { + console.error('Error fetching submissions:', error); + return res.status(500).json({ error: 'Failed to fetch submissions' }); + } + } + + // POST - Create/update submission if (req.method !== 'POST') { return res.status(405).json({ error: 'Method not allowed' }); } try { const { assignmentId, githubUrl, liveUrl, notes, files } = req.body; - const userId = req.user!.id; // Validate required fields if (!assignmentId) { diff --git a/src/pages/assignments/submit/[assignmentId].tsx b/src/pages/assignments/submit/[assignmentId].tsx index 4f0762e0c..610c675a0 100644 --- a/src/pages/assignments/submit/[assignmentId].tsx +++ b/src/pages/assignments/submit/[assignmentId].tsx @@ -2,21 +2,28 @@ import React, { useState } from "react"; import Link from "next/link"; import { useRouter } from "next/router"; import { useSession } from "next-auth/react"; +import { getServerSession } from "next-auth/next"; +import { options } from "@/pages/api/auth/options"; import Layout01 from "@layout/layout-01"; import type { GetServerSideProps, NextPage } from "next"; import SEO from "@components/seo/page-seo"; import Breadcrumb from "@components/breadcrumb"; +import prisma from "@/lib/prisma"; type AssignmentData = { id: string; title: string; description: string; - requirements: string[]; - dueDate: string; - module: string; - lesson: string; - maxFileSize: string; - allowedFormats: string[]; + instructions: string; + dueDate: string | null; + type: string; + githubRepo: boolean; + liveDemo: boolean; + maxPoints: number; + course: { + id: string; + title: string; + }; }; type PageProps = { @@ -37,22 +44,50 @@ const AssignmentSubmissionPage: PageWithLayout = ({ assignment }) => { const router = useRouter(); const [submitting, setSubmitting] = useState(false); const [submitted, setSubmitted] = useState(false); + const [error, setError] = useState(null); const [files, setFiles] = useState(null); const [githubUrl, setGithubUrl] = useState(""); + const [liveUrl, setLiveUrl] = useState(""); const [notes, setNotes] = useState(""); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setSubmitting(true); + setError(null); try { - // TODO: Implement actual submission to API - await new Promise((resolve) => { - setTimeout(() => resolve(), 2000); - }); // Simulate API call + // TODO: Upload files to Cloudinary if provided + let filesJson = null; + if (files && files.length > 0) { + // For now, we'll store file names until Cloudinary integration is complete + filesJson = JSON.stringify( + Array.from(files).map((f) => ({ name: f.name, size: f.size })) + ); + } + + const response = await fetch("/api/lms/submissions", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + assignmentId: assignment.id, + githubUrl: githubUrl || undefined, + liveUrl: liveUrl || undefined, + notes: notes || undefined, + files: filesJson, + }), + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.error || "Failed to submit assignment"); + } + setSubmitted(true); - } catch (error) { - // Handle submission error silently for now + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to submit assignment"); } finally { setSubmitting(false); } @@ -156,7 +191,7 @@ const AssignmentSubmissionPage: PageWithLayout = ({ assignment }) => { pages={[ { path: "/", label: "home" }, { path: "/courses", label: "courses" }, - { path: "/courses/web-development", label: "web development" }, + { path: `/courses/${assignment.course.id}`, label: assignment.course.title }, ]} currentPage={`Submit: ${assignment.title}`} showTitle={false} @@ -167,7 +202,7 @@ const AssignmentSubmissionPage: PageWithLayout = ({ assignment }) => { {/* Assignment Header */}
- {assignment.module} • {assignment.lesson} + {assignment.course.title} • {assignment.type}

Submit Assignment: {assignment.title} @@ -180,37 +215,43 @@ const AssignmentSubmissionPage: PageWithLayout = ({ assignment }) => {

- Assignment Requirements + Instructions

-
    - {assignment.requirements.map((requirement) => ( -
  • - - {requirement} -
  • - ))} -
+
+

{assignment.instructions}

+
-
-

- - Important Information +
+

+ + Assignment Details

-
-

- Due Date: {assignment.dueDate} -

+
+ {assignment.dueDate && ( +

+ Due Date:{" "} + {new Date(assignment.dueDate).toLocaleDateString()} +

+ )}

- Max File Size: {assignment.maxFileSize} + Max Points: {assignment.maxPoints}

- Allowed Formats:{" "} - {assignment.allowedFormats.join(", ")} + Type: {assignment.type}

+ {assignment.githubRepo && ( +

+ + GitHub repository required +

+ )} + {assignment.liveDemo && ( +

+ + Live demo required +

+ )}
@@ -218,6 +259,14 @@ const AssignmentSubmissionPage: PageWithLayout = ({ assignment }) => { {/* Submission Form */}
+ {error && ( +
+
+ + {error} +
+
+ )}
{/* GitHub Repository URL */}
@@ -241,6 +290,27 @@ const AssignmentSubmissionPage: PageWithLayout = ({ assignment }) => {

+ {/* Live Demo URL */} +
+ + setLiveUrl(e.target.value)} + placeholder="https://your-app.vercel.app" + className="tw-block tw-w-full tw-rounded-md tw-border tw-border-gray-300 tw-px-3 tw-py-2 tw-text-ink tw-placeholder-gray-500 focus:tw-border-primary focus:tw-outline-none focus:tw-ring-1 focus:tw-ring-primary" + /> +

+ Link to your deployed application or demo +

+
+ {/* File Upload */}
@@ -334,7 +400,7 @@ const AssignmentSubmissionPage: PageWithLayout = ({ assignment }) => { type="submit" disabled={ submitting || - (!githubUrl && (!files || files.length === 0)) + (!githubUrl && !liveUrl && (!files || files.length === 0)) } className="hover:tw-bg-primary-dark tw-rounded-md tw-bg-primary tw-px-8 tw-py-3 tw-font-medium tw-text-white tw-transition-colors disabled:tw-cursor-not-allowed disabled:tw-opacity-50" > @@ -363,50 +429,42 @@ const AssignmentSubmissionPage: PageWithLayout = ({ assignment }) => { AssignmentSubmissionPage.Layout = Layout01; -export const getServerSideProps: GetServerSideProps = async ({ params }) => { - const { assignmentId } = params as { assignmentId: string }; - - // Mock assignment data - in real implementation, fetch from database - const mockAssignments: Record = { - "1-1": { - id: "1-1", - title: "Create Your First HTML Page", - description: - "Create a simple HTML page that showcases proper document structure and semantic elements.", - requirements: [ - "Use proper HTML5 document structure (DOCTYPE, html, head, body)", - "Include at least 5 different semantic elements (header, nav, main, section, footer)", - "Add a navigation menu with at least 3 links", - "Include an image with proper alt text for accessibility", - "Validate your HTML using the W3C HTML validator", - ], - dueDate: "September 5, 2025", - module: "HTML Fundamentals", - lesson: "Introduction to HTML", - maxFileSize: "10 MB", - allowedFormats: ["html", "css", "js", "zip", "pdf"], - }, - "1-2": { - id: "1-2", - title: "Build a Personal Bio Page", - description: - "Create a comprehensive personal biography page using various HTML elements and attributes.", - requirements: [ - "Use all heading levels (h1-h6) appropriately in a hierarchical structure", - "Include both ordered and unordered lists with proper content", - "Add multiple images with descriptive alt text", - "Create internal page navigation using anchor links", - "Use proper semantic structure throughout the document", - ], - dueDate: "September 8, 2025", - module: "HTML Fundamentals", - lesson: "HTML Elements and Attributes", - maxFileSize: "10 MB", - allowedFormats: ["html", "css", "js", "zip", "pdf"], - }, - }; +export const getServerSideProps: GetServerSideProps = async (context) => { + const { assignmentId } = context.params as { assignmentId: string }; + + // Check authentication + const session = await getServerSession(context.req, context.res, options); - const assignment = mockAssignments[assignmentId]; + if (!session?.user) { + return { + redirect: { + destination: `/login?callbackUrl=/assignments/submit/${assignmentId}`, + permanent: false, + }, + }; + } + + // Fetch assignment from database + const assignment = await prisma.assignment.findUnique({ + where: { id: assignmentId }, + select: { + id: true, + title: true, + description: true, + instructions: true, + dueDate: true, + type: true, + githubRepo: true, + liveDemo: true, + maxPoints: true, + course: { + select: { + id: true, + title: true, + }, + }, + }, + }); if (!assignment) { return { @@ -414,9 +472,30 @@ export const getServerSideProps: GetServerSideProps = async ({ params }; } + // Verify user is enrolled in the course + const enrollment = await prisma.enrollment.findFirst({ + where: { + userId: session.user.id, + courseId: assignment.course.id, + status: "ACTIVE", + }, + }); + + if (!enrollment) { + return { + redirect: { + destination: "/courses", + permanent: false, + }, + }; + } + return { props: { - assignment, + assignment: { + ...assignment, + dueDate: assignment.dueDate?.toISOString() || null, + }, layout: { headerShadow: true, headerFluid: false, diff --git a/src/pages/dashboard.tsx b/src/pages/dashboard.tsx index 616e79560..19762a858 100644 --- a/src/pages/dashboard.tsx +++ b/src/pages/dashboard.tsx @@ -254,6 +254,13 @@ const Dashboard: PageWithLayout = () => { View Assignments + + + My Submissions + { + const { status } = useSession(); + const router = useRouter(); + const [submissions, setSubmissions] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [filter, setFilter] = useState<"all" | "graded" | "pending">("all"); + + useEffect(() => { + if (status === "unauthenticated") { + router.push("/login?callbackUrl=/submissions"); + return; + } + + if (status === "authenticated") { + fetchSubmissions(); + } + }, [status, router]); + + const fetchSubmissions = async () => { + try { + setLoading(true); + const response = await fetch("/api/lms/submissions"); + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.error || "Failed to fetch submissions"); + } + + setSubmissions(data.submissions); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to load submissions"); + } finally { + setLoading(false); + } + }; + + const getStatusBadge = (status: string) => { + const styles = { + SUBMITTED: "tw-bg-blue-100 tw-text-blue-800", + GRADED: "tw-bg-green-100 tw-text-green-800", + RETURNED: "tw-bg-yellow-100 tw-text-yellow-800", + }; + return ( + + {status} + + ); + }; + + const filteredSubmissions = submissions.filter((submission) => { + if (filter === "all") return true; + if (filter === "graded") return submission.status === "GRADED"; + if (filter === "pending") return submission.status === "SUBMITTED"; + return true; + }); + + if (loading) { + return ( +
+
+
+

Loading submissions...

+
+
+ ); + } + + return ( + <> + + + +
+
+
+

+ My Submissions +

+

+ Track your assignment submissions and view feedback +

+
+ + + + Back to Dashboard + +
+ + {error && ( +
+
+ + {error} +
+
+ )} + + {/* Filter Buttons */} +
+ + + +
+ + {/* Submissions List */} + {filteredSubmissions.length === 0 ? ( +
+ +

+ No submissions yet +

+

+ {filter === "all" + ? "You haven't submitted any assignments yet." + : `No ${filter} submissions.`} +

+ + + Browse Courses + +
+ ) : ( +
+ {filteredSubmissions.map((submission) => ( +
+
+
+
+

+ {submission.assignment.title} +

+ {getStatusBadge(submission.status)} +
+

+ {submission.assignment.course.title} +

+
+ + {submission.score !== null && ( +
+
+ {submission.score}/{submission.assignment.maxPoints} +
+
+ {Math.round( + (submission.score / submission.assignment.maxPoints) * + 100 + )} + % +
+
+ )} +
+ +
+ {submission.githubUrl && ( +
+

+ GitHub Repository +

+ + + View Code + + +
+ )} + + {submission.liveUrl && ( +
+

+ Live Demo +

+ + + View App + + +
+ )} + +
+

+ Submitted +

+

+ {new Date(submission.submittedAt).toLocaleDateString()} at{" "} + {new Date(submission.submittedAt).toLocaleTimeString()} +

+
+
+ + {submission.notes && ( +
+

+ Your Notes +

+

+ {submission.notes} +

+
+ )} + + {submission.feedback && ( +
+

+ + Instructor Feedback + {submission.gradedAt && ( + + {new Date(submission.gradedAt).toLocaleDateString()} + + )} +

+

+ {submission.feedback} +

+
+ )} +
+ ))} +
+ )} +
+ + ); +}; + +SubmissionsPage.Layout = Layout01; + +export default SubmissionsPage;