refactor: extract lesson completion logic into shared markLessonComplete method and add course progress tracking to video and quiz responses

This commit is contained in:
JakkrapartXD 2026-01-29 17:17:15 +07:00
parent ab560809c7
commit 18816c4fb2
2 changed files with 129 additions and 83 deletions

View file

@ -28,6 +28,104 @@ import { getPresignedUrl, listObjects, getVideoFolder, getAttachmentsFolder } fr
export class CoursesStudentService {
/**
* Mark lesson as complete and update enrollment progress
* Shared function for submitQuiz, saveVideoProgress, completeLesson
*/
private async markLessonComplete(userId: number, lessonId: number, courseId: number): Promise<{
lessonProgress: { lesson_id: number; is_completed: boolean; completed_at: Date | null };
enrollmentProgress: { progress_percentage: number; is_course_completed: boolean };
}> {
const now = new Date();
// Upsert lesson progress
const lessonProgress = await prisma.lessonProgress.upsert({
where: {
user_id_lesson_id: {
user_id: userId,
lesson_id: lessonId,
},
},
create: {
user_id: userId,
lesson_id: lessonId,
is_completed: true,
completed_at: now,
},
update: {
is_completed: true,
completed_at: now,
},
});
// Get all lessons in the course
const course = await prisma.course.findUnique({
where: { id: courseId },
include: {
chapters: {
include: {
lessons: {
where: { is_published: true },
select: { id: true },
},
},
},
},
});
if (!course) {
throw new NotFoundError('Course not found');
}
const allLessonIds = course.chapters.flatMap(ch => ch.lessons.map(l => l.id));
const totalLessons = allLessonIds.length;
// Count completed lessons
const completedLessons = await prisma.lessonProgress.count({
where: {
user_id: userId,
lesson_id: { in: allLessonIds },
is_completed: true,
},
});
// Calculate progress percentage
const progressPercentage = totalLessons > 0
? Math.round((completedLessons / totalLessons) * 100)
: 0;
const isCourseCompleted = completedLessons >= totalLessons;
// Update enrollment
await prisma.enrollment.update({
where: {
unique_enrollment: {
user_id: userId,
course_id: courseId,
},
},
data: {
progress_percentage: progressPercentage,
...(isCourseCompleted ? {
status: 'COMPLETED',
completed_at: now,
} : {}),
},
});
return {
lessonProgress: {
lesson_id: lessonProgress.lesson_id,
is_completed: lessonProgress.is_completed,
completed_at: lessonProgress.completed_at,
},
enrollmentProgress: {
progress_percentage: progressPercentage,
is_course_completed: isCourseCompleted,
},
};
}
async enrollCourse(input: EnrollCourseInput): Promise<EnrollCourseResponse> {
try {
const { course_id } = input;
@ -810,10 +908,11 @@ export class CoursesStudentService {
? (video_progress_seconds / video_duration_seconds) * 100
: null;
// Auto-complete at >= 90%
const isCompleted = progressPercentage !== null && progressPercentage >= 90;
// Auto-complete at >= 90% OR when video_progress_seconds >= video_duration_seconds
const isCompleted = (progressPercentage !== null && progressPercentage >= 90) ||
!!(video_duration_seconds && video_progress_seconds >= video_duration_seconds);
// Save progress
// Save video progress (without marking complete yet)
const progress = await prisma.lessonProgress.upsert({
where: {
user_id_lesson_id: {
@ -827,21 +926,24 @@ export class CoursesStudentService {
video_progress_seconds,
video_duration_seconds: video_duration_seconds ?? null,
video_progress_percentage: progressPercentage,
is_completed: isCompleted,
completed_at: isCompleted ? new Date() : null,
is_completed: false,
last_watched_at: new Date(),
},
update: {
video_progress_seconds,
video_duration_seconds: video_duration_seconds ?? null,
video_progress_percentage: progressPercentage,
// Only set completed if not already completed
is_completed: isCompleted ? true : undefined,
completed_at: isCompleted ? new Date() : undefined,
last_watched_at: new Date(),
},
});
// If video completed, mark lesson as complete and update enrollment progress
let enrollmentProgress: { progress_percentage: number; is_course_completed: boolean } | undefined;
if (isCompleted) {
const result = await this.markLessonComplete(decoded.id, lesson_id, course_id);
enrollmentProgress = result.enrollmentProgress;
}
return {
code: 200,
message: 'Video progress saved successfully',
@ -850,8 +952,10 @@ export class CoursesStudentService {
video_progress_seconds: progress.video_progress_seconds,
video_duration_seconds: progress.video_duration_seconds,
video_progress_percentage: progress.video_progress_percentage ? Number(progress.video_progress_percentage) : null,
is_completed: progress.is_completed,
is_completed: isCompleted || progress.is_completed,
last_watched_at: progress.last_watched_at!,
course_progress_percentage: enrollmentProgress?.progress_percentage,
is_course_completed: enrollmentProgress?.is_course_completed,
},
};
} catch (error) {
@ -922,46 +1026,9 @@ export class CoursesStudentService {
throw new ForbiddenError('You are not enrolled in this course');
}
// Complete lesson
const progress = await prisma.lessonProgress.upsert({
where: {
user_id_lesson_id: {
user_id: decoded.id,
lesson_id,
},
},
create: {
user_id: decoded.id,
lesson_id,
is_completed: true,
completed_at: new Date(),
},
update: {
is_completed: true,
completed_at: new Date(),
},
});
// Get all lesson IDs in the course
const allLessons = lesson.chapter.course.chapters.flatMap(ch => ch.lessons.map(l => l.id));
const totalLessons = allLessons.length;
// Get completed lessons count
const completedLessons = await prisma.lessonProgress.count({
where: {
user_id: decoded.id,
lesson_id: { in: allLessons },
is_completed: true,
},
});
// Calculate course progress percentage
const course_progress_percentage = totalLessons > 0
? Math.round((completedLessons / totalLessons) * 100)
: 0;
// Check if course is completed
const is_course_completed = completedLessons >= totalLessons;
// Mark lesson as complete and update enrollment progress
const { lessonProgress, enrollmentProgress } = await this.markLessonComplete(decoded.id, lesson_id, course_id);
const { progress_percentage: course_progress_percentage, is_course_completed } = enrollmentProgress;
// Find next lesson
const currentChapterLessons = lesson.chapter.lessons;
@ -983,18 +1050,6 @@ export class CoursesStudentService {
}
}
// Update enrollment progress
await prisma.enrollment.update({
where: { id: enrollment.id },
data: {
progress_percentage: course_progress_percentage,
...(is_course_completed ? {
status: 'COMPLETED',
completed_at: new Date(),
} : {}),
},
});
// Issue certificate if course completed and has certificate
let certificate_issued: boolean | undefined;
if (is_course_completed && lesson.chapter.course.have_certificate) {
@ -1026,9 +1081,9 @@ export class CoursesStudentService {
code: 200,
message: 'Lesson completed successfully',
data: {
lesson_id: progress.lesson_id,
is_completed: progress.is_completed,
completed_at: progress.completed_at!,
lesson_id: lessonProgress.lesson_id,
is_completed: lessonProgress.is_completed,
completed_at: lessonProgress.completed_at!,
course_progress_percentage,
is_course_completed,
next_lesson_id,
@ -1169,26 +1224,11 @@ export class CoursesStudentService {
},
});
// If passed, upsert lesson_progress to mark lesson as completed
// If passed, mark lesson as complete and update enrollment progress
let enrollmentProgress: { progress_percentage: number; is_course_completed: boolean } | undefined;
if (isPassed) {
await prisma.lessonProgress.upsert({
where: {
user_id_lesson_id: {
user_id: decoded.id,
lesson_id,
},
},
create: {
user_id: decoded.id,
lesson_id,
is_completed: true,
completed_at: now,
},
update: {
is_completed: true,
completed_at: now,
},
});
const result = await this.markLessonComplete(decoded.id, lesson_id, course_id);
enrollmentProgress = result.enrollmentProgress;
}
return {
@ -1207,6 +1247,8 @@ export class CoursesStudentService {
started_at: quizAttempt.started_at,
completed_at: quizAttempt.completed_at!,
answers_review: quiz.show_answers_after_completion ? answersReview : undefined,
course_progress_percentage: enrollmentProgress?.progress_percentage,
is_course_completed: enrollmentProgress?.is_course_completed,
},
};
} catch (error) {

View file

@ -242,6 +242,8 @@ export interface SaveVideoProgressResponse {
video_progress_percentage: number | null;
is_completed: boolean;
last_watched_at: Date;
course_progress_percentage?: number;
is_course_completed?: boolean;
};
}
@ -362,5 +364,7 @@ export interface SubmitQuizResponse {
is_correct: boolean;
score: number;
}[];
course_progress_percentage?: number;
is_course_completed?: boolean;
};
}