Skip to content

Commit c53ddb6

Browse files
feat: implement lesson tracking with real-time progress updates (#850)
Progress API Implementation: - Created /api/lms/progress endpoint with GET and POST methods - GET: Fetch user progress filtered by lessonId, moduleId, or courseId - POST: Mark lessons complete/incomplete and track time spent - Automatic enrollment lastActivity updates on progress changes - Enrollment verification before allowing progress tracking Lesson Page Updates (web-development/[moduleId]/[lessonId].tsx): - Replaced localStorage with real API calls for progress tracking - Fetch lesson completion status on page load - Fetch module progress for real-time sidebar stats - Mark as Complete button now saves to database - Loading states with spinner during save - Dynamic progress bars based on actual completion data - Auto-increment module progress counter on completion Database Integration: - Leverages existing Progress model (userId, lessonId, completed, timeSpent) - Unique constraint ensures one progress record per user per lesson - Tracks startedAt and completedAt timestamps - Updates course enrollment lastActivity on any progress change Student Benefits: - Progress persists across sessions and devices - Real-time progress tracking as lessons are completed - Accurate module completion percentages in sidebar - Foundation for course completion calculations - Enrollment activity tracking for engagement metrics
1 parent d49f68d commit c53ddb6

File tree

3 files changed

+314
-19
lines changed

3 files changed

+314
-19
lines changed
Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
import { NextApiResponse } from 'next';
2+
import { requireAuth, AuthenticatedRequest } from '@/lib/rbac';
3+
import prisma from '@/lib/prisma';
4+
5+
/**
6+
* GET /api/lms/progress
7+
* Fetch user's lesson progress (optionally filtered by courseId or moduleId)
8+
*
9+
* Query params:
10+
* - courseId?: string - Filter by course
11+
* - moduleId?: string - Filter by module
12+
* - lessonId?: string - Get specific lesson progress
13+
*
14+
* POST /api/lms/progress
15+
* Update lesson progress (mark as started, completed, or update time spent)
16+
*
17+
* Request body:
18+
* {
19+
* lessonId: string,
20+
* completed?: boolean,
21+
* timeSpent?: number (in minutes)
22+
* }
23+
*/
24+
export default requireAuth(async (req: AuthenticatedRequest, res: NextApiResponse) => {
25+
const userId = req.user!.id;
26+
27+
// GET - Fetch user's progress
28+
if (req.method === 'GET') {
29+
try {
30+
const { courseId, moduleId, lessonId } = req.query;
31+
32+
// Build where clause for filtering
33+
const where: any = { userId };
34+
35+
if (lessonId) {
36+
where.lessonId = lessonId as string;
37+
} else if (moduleId) {
38+
where.lesson = {
39+
moduleId: moduleId as string,
40+
};
41+
} else if (courseId) {
42+
where.lesson = {
43+
module: {
44+
courseId: courseId as string,
45+
},
46+
};
47+
}
48+
49+
const progress = await prisma.progress.findMany({
50+
where,
51+
include: {
52+
lesson: {
53+
select: {
54+
id: true,
55+
title: true,
56+
order: true,
57+
duration: true,
58+
moduleId: true,
59+
module: {
60+
select: {
61+
id: true,
62+
title: true,
63+
order: true,
64+
courseId: true,
65+
},
66+
},
67+
},
68+
},
69+
},
70+
orderBy: [
71+
{ lesson: { module: { order: 'asc' } } },
72+
{ lesson: { order: 'asc' } },
73+
],
74+
});
75+
76+
return res.status(200).json({ progress });
77+
} catch (error) {
78+
console.error('Error fetching progress:', error);
79+
return res.status(500).json({ error: 'Failed to fetch progress' });
80+
}
81+
}
82+
83+
// POST - Update lesson progress
84+
if (req.method !== 'POST') {
85+
return res.status(405).json({ error: 'Method not allowed' });
86+
}
87+
88+
try {
89+
const { lessonId, completed, timeSpent } = req.body;
90+
91+
// Validation
92+
if (!lessonId) {
93+
return res.status(400).json({ error: 'Lesson ID is required' });
94+
}
95+
96+
// Verify lesson exists
97+
const lesson = await prisma.lesson.findUnique({
98+
where: { id: lessonId },
99+
include: {
100+
module: {
101+
include: {
102+
course: {
103+
include: {
104+
enrollments: {
105+
where: {
106+
userId,
107+
status: 'ACTIVE',
108+
},
109+
},
110+
},
111+
},
112+
},
113+
},
114+
},
115+
});
116+
117+
if (!lesson) {
118+
return res.status(404).json({ error: 'Lesson not found' });
119+
}
120+
121+
// Verify user is enrolled in the course
122+
if (lesson.module.course.enrollments.length === 0) {
123+
return res.status(403).json({
124+
error: 'You must be enrolled in the course to track progress',
125+
});
126+
}
127+
128+
// Check for existing progress record
129+
const existingProgress = await prisma.progress.findUnique({
130+
where: {
131+
userId_lessonId: {
132+
userId,
133+
lessonId,
134+
},
135+
},
136+
});
137+
138+
let progressRecord;
139+
140+
if (existingProgress) {
141+
// Update existing progress
142+
const updateData: any = {};
143+
144+
if (typeof completed === 'boolean') {
145+
updateData.completed = completed;
146+
if (completed && !existingProgress.completedAt) {
147+
updateData.completedAt = new Date();
148+
} else if (!completed) {
149+
updateData.completedAt = null;
150+
}
151+
}
152+
153+
if (typeof timeSpent === 'number' && timeSpent >= 0) {
154+
updateData.timeSpent = timeSpent;
155+
}
156+
157+
progressRecord = await prisma.progress.update({
158+
where: {
159+
id: existingProgress.id,
160+
},
161+
data: updateData,
162+
include: {
163+
lesson: {
164+
select: {
165+
id: true,
166+
title: true,
167+
order: true,
168+
moduleId: true,
169+
},
170+
},
171+
},
172+
});
173+
} else {
174+
// Create new progress record
175+
progressRecord = await prisma.progress.create({
176+
data: {
177+
userId,
178+
lessonId,
179+
completed: completed || false,
180+
timeSpent: timeSpent || 0,
181+
completedAt: completed ? new Date() : null,
182+
},
183+
include: {
184+
lesson: {
185+
select: {
186+
id: true,
187+
title: true,
188+
order: true,
189+
moduleId: true,
190+
},
191+
},
192+
},
193+
});
194+
}
195+
196+
// Update enrollment lastActivity timestamp
197+
await prisma.enrollment.updateMany({
198+
where: {
199+
userId,
200+
courseId: lesson.module.courseId,
201+
},
202+
data: {
203+
lastActivity: new Date(),
204+
},
205+
});
206+
207+
res.status(existingProgress ? 200 : 201).json({
208+
progress: progressRecord,
209+
message: existingProgress
210+
? 'Progress updated successfully'
211+
: 'Progress tracking started',
212+
});
213+
} catch (error) {
214+
console.error('Error updating progress:', error);
215+
res.status(500).json({ error: 'Failed to update progress' });
216+
}
217+
});

src/pages/courses/web-development/[moduleId]/[lessonId].tsx

Lines changed: 97 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -115,13 +115,41 @@ const LessonPage: PageWithLayout = ({ lesson, module }) => {
115115
const [completed, setCompleted] = useState(false);
116116
const [showAssignment, setShowAssignment] = useState(false);
117117
const [isAIAssistantOpen, setIsAIAssistantOpen] = useState(false);
118+
const [updating, setUpdating] = useState(false);
119+
const [moduleProgress, setModuleProgress] = useState({ completed: 0, total: module.totalLessons });
118120

121+
// Fetch lesson progress and module progress on mount
119122
useEffect(() => {
120-
// TODO: Check if lesson is completed from database
121-
// For now, check localStorage for demo
122-
const lessonKey = `lesson_${lesson.id}_completed`;
123-
setCompleted(localStorage.getItem(lessonKey) === "true");
124-
}, [lesson.id]);
123+
const fetchProgress = async () => {
124+
try {
125+
// Fetch current lesson progress
126+
const lessonResponse = await fetch(`/api/lms/progress?lessonId=${lesson.id}`);
127+
const lessonData = await lessonResponse.json();
128+
129+
if (lessonResponse.ok && lessonData.progress.length > 0) {
130+
setCompleted(lessonData.progress[0].completed);
131+
}
132+
133+
// Fetch module progress
134+
const moduleResponse = await fetch(`/api/lms/progress?moduleId=${module.id}`);
135+
const moduleData = await moduleResponse.json();
136+
137+
if (moduleResponse.ok) {
138+
const completedCount = moduleData.progress.filter(
139+
(p: { completed: boolean }) => p.completed
140+
).length;
141+
setModuleProgress({
142+
completed: completedCount,
143+
total: module.totalLessons,
144+
});
145+
}
146+
} catch (error) {
147+
console.error("Error fetching progress:", error);
148+
}
149+
};
150+
151+
fetchProgress();
152+
}, [lesson.id, module.id, module.totalLessons]);
125153

126154
// Keyboard shortcut for AI Assistant ('A' key)
127155
useEffect(() => {
@@ -143,12 +171,36 @@ const LessonPage: PageWithLayout = ({ lesson, module }) => {
143171
return () => window.removeEventListener('keydown', handleKeyPress);
144172
}, []);
145173

146-
const markAsCompleted = () => {
147-
// TODO: Update database with lesson completion
148-
// For now, use localStorage for demo
149-
const lessonKey = `lesson_${lesson.id}_completed`;
150-
localStorage.setItem(lessonKey, "true");
151-
setCompleted(true);
174+
const markAsCompleted = async () => {
175+
try {
176+
setUpdating(true);
177+
const response = await fetch("/api/lms/progress", {
178+
method: "POST",
179+
headers: {
180+
"Content-Type": "application/json",
181+
},
182+
body: JSON.stringify({
183+
lessonId: lesson.id,
184+
completed: true,
185+
}),
186+
});
187+
188+
if (response.ok) {
189+
setCompleted(true);
190+
// Refresh module progress
191+
setModuleProgress((prev) => ({
192+
...prev,
193+
completed: prev.completed + 1,
194+
}));
195+
} else {
196+
const data = await response.json();
197+
console.error("Failed to mark as completed:", data.error);
198+
}
199+
} catch (error) {
200+
console.error("Error marking lesson as completed:", error);
201+
} finally {
202+
setUpdating(false);
203+
}
152204
};
153205

154206
return (
@@ -243,10 +295,11 @@ const LessonPage: PageWithLayout = ({ lesson, module }) => {
243295
<button
244296
type="button"
245297
onClick={markAsCompleted}
246-
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"
298+
disabled={updating}
299+
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"
247300
>
248-
<i className="fas fa-check tw-mr-2" />
249-
Mark as Complete
301+
<i className={`fas ${updating ? "fa-spinner fa-spin" : "fa-check"} tw-mr-2`} />
302+
{updating ? "Saving..." : "Mark as Complete"}
250303
</button>
251304
)}
252305

@@ -272,18 +325,43 @@ const LessonPage: PageWithLayout = ({ lesson, module }) => {
272325
</h3>
273326
<div className="tw-mb-2 tw-flex tw-justify-between tw-text-sm tw-text-gray-300">
274327
<span>Module Progress</span>
275-
<span>3/12 lessons</span>
328+
<span>
329+
{moduleProgress.completed}/{moduleProgress.total} lessons
330+
</span>
276331
</div>
277332
<div className="tw-mb-4 tw-h-2 tw-w-full tw-rounded-full tw-bg-gray-50">
278-
<div className="tw-h-2 tw-w-[25%] tw-rounded-full tw-bg-navy-royal" />
333+
<div
334+
className="tw-h-2 tw-rounded-full tw-bg-navy-royal tw-transition-all"
335+
style={{
336+
width: `${
337+
moduleProgress.total > 0
338+
? (moduleProgress.completed / moduleProgress.total) * 100
339+
: 0
340+
}%`,
341+
}}
342+
/>
279343
</div>
280344

281345
<div className="tw-mb-2 tw-flex tw-justify-between tw-text-sm tw-text-gray-300">
282-
<span>Overall Progress</span>
283-
<span>15%</span>
346+
<span>Module Completion</span>
347+
<span>
348+
{moduleProgress.total > 0
349+
? Math.round((moduleProgress.completed / moduleProgress.total) * 100)
350+
: 0}
351+
%
352+
</span>
284353
</div>
285354
<div className="tw-h-2 tw-w-full tw-rounded-full tw-bg-gray-50">
286-
<div className="tw-h-2 tw-w-[15%] tw-rounded-full tw-bg-gold-rich" />
355+
<div
356+
className="tw-h-2 tw-rounded-full tw-bg-gold-rich tw-transition-all"
357+
style={{
358+
width: `${
359+
moduleProgress.total > 0
360+
? (moduleProgress.completed / moduleProgress.total) * 100
361+
: 0
362+
}%`,
363+
}}
364+
/>
287365
</div>
288366
</div>
289367

0 commit comments

Comments
 (0)