-
-
Notifications
You must be signed in to change notification settings - Fork 68
feat: implement lesson tracking with real-time progress updates #850
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,217 @@ | ||
| import { NextApiResponse } from 'next'; | ||
| import { requireAuth, AuthenticatedRequest } from '@/lib/rbac'; | ||
| import prisma from '@/lib/prisma'; | ||
|
|
||
| /** | ||
| * GET /api/lms/progress | ||
| * Fetch user's lesson progress (optionally filtered by courseId or moduleId) | ||
| * | ||
| * Query params: | ||
| * - courseId?: string - Filter by course | ||
| * - moduleId?: string - Filter by module | ||
| * - lessonId?: string - Get specific lesson progress | ||
| * | ||
| * POST /api/lms/progress | ||
| * Update lesson progress (mark as started, completed, or update time spent) | ||
| * | ||
| * Request body: | ||
| * { | ||
| * lessonId: string, | ||
| * completed?: boolean, | ||
| * timeSpent?: number (in minutes) | ||
| * } | ||
| */ | ||
| export default requireAuth(async (req: AuthenticatedRequest, res: NextApiResponse) => { | ||
| const userId = req.user!.id; | ||
|
|
||
| // GET - Fetch user's progress | ||
| if (req.method === 'GET') { | ||
| try { | ||
| const { courseId, moduleId, lessonId } = req.query; | ||
|
|
||
| // Build where clause for filtering | ||
| const where: any = { userId }; | ||
|
|
||
| if (lessonId) { | ||
| where.lessonId = lessonId as string; | ||
| } else if (moduleId) { | ||
| where.lesson = { | ||
| moduleId: moduleId as string, | ||
| }; | ||
| } else if (courseId) { | ||
| where.lesson = { | ||
| module: { | ||
| courseId: courseId as string, | ||
| }, | ||
| }; | ||
| } | ||
|
|
||
| const progress = await prisma.progress.findMany({ | ||
| where, | ||
| include: { | ||
| lesson: { | ||
| select: { | ||
| id: true, | ||
| title: true, | ||
| order: true, | ||
| duration: true, | ||
| moduleId: true, | ||
| module: { | ||
| select: { | ||
| id: true, | ||
| title: true, | ||
| order: true, | ||
| courseId: true, | ||
| }, | ||
| }, | ||
| }, | ||
| }, | ||
| }, | ||
| orderBy: [ | ||
| { lesson: { module: { order: 'asc' } } }, | ||
| { lesson: { order: 'asc' } }, | ||
| ], | ||
| }); | ||
|
|
||
| return res.status(200).json({ progress }); | ||
| } catch (error) { | ||
| console.error('Error fetching progress:', error); | ||
| return res.status(500).json({ error: 'Failed to fetch progress' }); | ||
| } | ||
| } | ||
|
|
||
| // POST - Update lesson progress | ||
| if (req.method !== 'POST') { | ||
| return res.status(405).json({ error: 'Method not allowed' }); | ||
| } | ||
|
|
||
| try { | ||
| const { lessonId, completed, timeSpent } = req.body; | ||
|
|
||
| // Validation | ||
| if (!lessonId) { | ||
| return res.status(400).json({ error: 'Lesson ID is required' }); | ||
| } | ||
|
|
||
| // Verify lesson exists | ||
| const lesson = await prisma.lesson.findUnique({ | ||
| where: { id: lessonId }, | ||
| include: { | ||
| module: { | ||
| include: { | ||
| course: { | ||
| include: { | ||
| enrollments: { | ||
| where: { | ||
| userId, | ||
| status: 'ACTIVE', | ||
| }, | ||
| }, | ||
| }, | ||
| }, | ||
| }, | ||
| }, | ||
| }, | ||
| }); | ||
|
|
||
| if (!lesson) { | ||
| return res.status(404).json({ error: 'Lesson not found' }); | ||
| } | ||
|
|
||
| // Verify user is enrolled in the course | ||
| if (lesson.module.course.enrollments.length === 0) { | ||
| return res.status(403).json({ | ||
| error: 'You must be enrolled in the course to track progress', | ||
| }); | ||
| } | ||
|
|
||
| // Check for existing progress record | ||
| const existingProgress = await prisma.progress.findUnique({ | ||
| where: { | ||
| userId_lessonId: { | ||
| userId, | ||
| lessonId, | ||
| }, | ||
| }, | ||
| }); | ||
|
|
||
| let progressRecord; | ||
|
|
||
| if (existingProgress) { | ||
| // Update existing progress | ||
| const updateData: any = {}; | ||
|
||
|
|
||
| if (typeof completed === 'boolean') { | ||
| updateData.completed = completed; | ||
| if (completed && !existingProgress.completedAt) { | ||
| updateData.completedAt = new Date(); | ||
| } else if (!completed) { | ||
| updateData.completedAt = null; | ||
| } | ||
| } | ||
|
|
||
| if (typeof timeSpent === 'number' && timeSpent >= 0) { | ||
| updateData.timeSpent = timeSpent; | ||
| } | ||
|
|
||
| progressRecord = await prisma.progress.update({ | ||
| where: { | ||
| id: existingProgress.id, | ||
| }, | ||
| data: updateData, | ||
| include: { | ||
| lesson: { | ||
| select: { | ||
| id: true, | ||
| title: true, | ||
| order: true, | ||
| moduleId: true, | ||
| }, | ||
| }, | ||
| }, | ||
| }); | ||
| } else { | ||
| // Create new progress record | ||
| progressRecord = await prisma.progress.create({ | ||
| data: { | ||
| userId, | ||
| lessonId, | ||
| completed: completed || false, | ||
| timeSpent: timeSpent || 0, | ||
| completedAt: completed ? new Date() : null, | ||
| }, | ||
| include: { | ||
| lesson: { | ||
| select: { | ||
| id: true, | ||
| title: true, | ||
| order: true, | ||
| moduleId: true, | ||
| }, | ||
| }, | ||
| }, | ||
| }); | ||
| } | ||
|
|
||
| // Update enrollment lastActivity timestamp | ||
| await prisma.enrollment.updateMany({ | ||
| where: { | ||
| userId, | ||
| courseId: lesson.module.courseId, | ||
| }, | ||
| data: { | ||
| lastActivity: new Date(), | ||
| }, | ||
| }); | ||
|
|
||
| res.status(existingProgress ? 200 : 201).json({ | ||
| progress: progressRecord, | ||
| message: existingProgress | ||
| ? 'Progress updated successfully' | ||
| : 'Progress tracking started', | ||
| }); | ||
| } catch (error) { | ||
| console.error('Error updating progress:', error); | ||
| res.status(500).json({ error: 'Failed to update progress' }); | ||
| } | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -115,13 +115,41 @@ const LessonPage: PageWithLayout = ({ lesson, module }) => { | |
| const [completed, setCompleted] = useState(false); | ||
| const [showAssignment, setShowAssignment] = useState(false); | ||
| const [isAIAssistantOpen, setIsAIAssistantOpen] = useState(false); | ||
| const [updating, setUpdating] = useState(false); | ||
| const [moduleProgress, setModuleProgress] = useState({ completed: 0, total: module.totalLessons }); | ||
|
|
||
| // Fetch lesson progress and module progress on mount | ||
| useEffect(() => { | ||
| // TODO: Check if lesson is completed from database | ||
| // For now, check localStorage for demo | ||
| const lessonKey = `lesson_${lesson.id}_completed`; | ||
| setCompleted(localStorage.getItem(lessonKey) === "true"); | ||
| }, [lesson.id]); | ||
| const fetchProgress = async () => { | ||
| try { | ||
| // Fetch current lesson progress | ||
| const lessonResponse = await fetch(`/api/lms/progress?lessonId=${lesson.id}`); | ||
| const lessonData = await lessonResponse.json(); | ||
|
|
||
| if (lessonResponse.ok && lessonData.progress.length > 0) { | ||
| setCompleted(lessonData.progress[0].completed); | ||
| } | ||
|
|
||
| // Fetch module progress | ||
| const moduleResponse = await fetch(`/api/lms/progress?moduleId=${module.id}`); | ||
| const moduleData = await moduleResponse.json(); | ||
|
|
||
| if (moduleResponse.ok) { | ||
| const completedCount = moduleData.progress.filter( | ||
| (p: { completed: boolean }) => p.completed | ||
| ).length; | ||
| setModuleProgress({ | ||
| completed: completedCount, | ||
| total: module.totalLessons, | ||
| }); | ||
| } | ||
| } catch (error) { | ||
| console.error("Error fetching progress:", error); | ||
| } | ||
| }; | ||
|
|
||
| fetchProgress(); | ||
| }, [lesson.id, module.id, module.totalLessons]); | ||
|
|
||
| // Keyboard shortcut for AI Assistant ('A' key) | ||
| useEffect(() => { | ||
|
|
@@ -143,12 +171,36 @@ const LessonPage: PageWithLayout = ({ lesson, module }) => { | |
| return () => window.removeEventListener('keydown', handleKeyPress); | ||
| }, []); | ||
|
|
||
| const markAsCompleted = () => { | ||
| // TODO: Update database with lesson completion | ||
| // For now, use localStorage for demo | ||
| const lessonKey = `lesson_${lesson.id}_completed`; | ||
| localStorage.setItem(lessonKey, "true"); | ||
| setCompleted(true); | ||
| const markAsCompleted = async () => { | ||
| try { | ||
| setUpdating(true); | ||
| const response = await fetch("/api/lms/progress", { | ||
| method: "POST", | ||
| headers: { | ||
| "Content-Type": "application/json", | ||
| }, | ||
| body: JSON.stringify({ | ||
| lessonId: lesson.id, | ||
| completed: true, | ||
| }), | ||
| }); | ||
|
|
||
| if (response.ok) { | ||
| setCompleted(true); | ||
| // Refresh module progress | ||
| setModuleProgress((prev) => ({ | ||
| ...prev, | ||
| completed: prev.completed + 1, | ||
| })); | ||
|
Comment on lines
+191
to
+194
|
||
| } else { | ||
| const data = await response.json(); | ||
| console.error("Failed to mark as completed:", data.error); | ||
| } | ||
| } catch (error) { | ||
| console.error("Error marking lesson as completed:", error); | ||
| } finally { | ||
| setUpdating(false); | ||
| } | ||
| }; | ||
|
|
||
| return ( | ||
|
|
@@ -243,10 +295,11 @@ const LessonPage: PageWithLayout = ({ lesson, module }) => { | |
| <button | ||
| type="button" | ||
| onClick={markAsCompleted} | ||
| className="tw-rounded-md tw-bg-gold-rich tw-px-6 tw-py-2 tw-font-medium tw-text-white tw-transition-colors hover:tw-bg-green-700" | ||
| disabled={updating} | ||
| className="tw-rounded-md tw-bg-gold-rich tw-px-6 tw-py-2 tw-font-medium tw-text-white tw-transition-colors hover:tw-bg-green-700 disabled:tw-cursor-not-allowed disabled:tw-opacity-50" | ||
| > | ||
| <i className="fas fa-check tw-mr-2" /> | ||
| Mark as Complete | ||
| <i className={`fas ${updating ? "fa-spinner fa-spin" : "fa-check"} tw-mr-2`} /> | ||
| {updating ? "Saving..." : "Mark as Complete"} | ||
| </button> | ||
| )} | ||
|
|
||
|
|
@@ -272,18 +325,43 @@ const LessonPage: PageWithLayout = ({ lesson, module }) => { | |
| </h3> | ||
| <div className="tw-mb-2 tw-flex tw-justify-between tw-text-sm tw-text-gray-300"> | ||
| <span>Module Progress</span> | ||
| <span>3/12 lessons</span> | ||
| <span> | ||
| {moduleProgress.completed}/{moduleProgress.total} lessons | ||
| </span> | ||
| </div> | ||
| <div className="tw-mb-4 tw-h-2 tw-w-full tw-rounded-full tw-bg-gray-50"> | ||
| <div className="tw-h-2 tw-w-[25%] tw-rounded-full tw-bg-navy-royal" /> | ||
| <div | ||
| className="tw-h-2 tw-rounded-full tw-bg-navy-royal tw-transition-all" | ||
| style={{ | ||
| width: `${ | ||
| moduleProgress.total > 0 | ||
| ? (moduleProgress.completed / moduleProgress.total) * 100 | ||
| : 0 | ||
| }%`, | ||
| }} | ||
| /> | ||
| </div> | ||
|
|
||
| <div className="tw-mb-2 tw-flex tw-justify-between tw-text-sm tw-text-gray-300"> | ||
| <span>Overall Progress</span> | ||
| <span>15%</span> | ||
| <span>Module Completion</span> | ||
| <span> | ||
| {moduleProgress.total > 0 | ||
| ? Math.round((moduleProgress.completed / moduleProgress.total) * 100) | ||
| : 0} | ||
| % | ||
| </span> | ||
| </div> | ||
| <div className="tw-h-2 tw-w-full tw-rounded-full tw-bg-gray-50"> | ||
| <div className="tw-h-2 tw-w-[15%] tw-rounded-full tw-bg-gold-rich" /> | ||
| <div | ||
| className="tw-h-2 tw-rounded-full tw-bg-gold-rich tw-transition-all" | ||
| style={{ | ||
| width: `${ | ||
| moduleProgress.total > 0 | ||
| ? (moduleProgress.completed / moduleProgress.total) * 100 | ||
| : 0 | ||
| }%`, | ||
| }} | ||
| /> | ||
| </div> | ||
| </div> | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Using
anytype defeats TypeScript's type safety. Consider usingPrisma.ProgressWhereInputor defining a proper interface for the where clause to maintain type safety.