refactor: extract lesson completion logic into shared markLessonComplete method and add course progress tracking to video and quiz responses
This commit is contained in:
parent
ab560809c7
commit
18816c4fb2
2 changed files with 129 additions and 83 deletions
|
|
@ -28,6 +28,104 @@ import { getPresignedUrl, listObjects, getVideoFolder, getAttachmentsFolder } fr
|
||||||
|
|
||||||
|
|
||||||
export class CoursesStudentService {
|
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> {
|
async enrollCourse(input: EnrollCourseInput): Promise<EnrollCourseResponse> {
|
||||||
try {
|
try {
|
||||||
const { course_id } = input;
|
const { course_id } = input;
|
||||||
|
|
@ -810,10 +908,11 @@ export class CoursesStudentService {
|
||||||
? (video_progress_seconds / video_duration_seconds) * 100
|
? (video_progress_seconds / video_duration_seconds) * 100
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
// Auto-complete at >= 90%
|
// Auto-complete at >= 90% OR when video_progress_seconds >= video_duration_seconds
|
||||||
const isCompleted = progressPercentage !== null && progressPercentage >= 90;
|
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({
|
const progress = await prisma.lessonProgress.upsert({
|
||||||
where: {
|
where: {
|
||||||
user_id_lesson_id: {
|
user_id_lesson_id: {
|
||||||
|
|
@ -827,21 +926,24 @@ export class CoursesStudentService {
|
||||||
video_progress_seconds,
|
video_progress_seconds,
|
||||||
video_duration_seconds: video_duration_seconds ?? null,
|
video_duration_seconds: video_duration_seconds ?? null,
|
||||||
video_progress_percentage: progressPercentage,
|
video_progress_percentage: progressPercentage,
|
||||||
is_completed: isCompleted,
|
is_completed: false,
|
||||||
completed_at: isCompleted ? new Date() : null,
|
|
||||||
last_watched_at: new Date(),
|
last_watched_at: new Date(),
|
||||||
},
|
},
|
||||||
update: {
|
update: {
|
||||||
video_progress_seconds,
|
video_progress_seconds,
|
||||||
video_duration_seconds: video_duration_seconds ?? null,
|
video_duration_seconds: video_duration_seconds ?? null,
|
||||||
video_progress_percentage: progressPercentage,
|
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(),
|
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 {
|
return {
|
||||||
code: 200,
|
code: 200,
|
||||||
message: 'Video progress saved successfully',
|
message: 'Video progress saved successfully',
|
||||||
|
|
@ -850,8 +952,10 @@ export class CoursesStudentService {
|
||||||
video_progress_seconds: progress.video_progress_seconds,
|
video_progress_seconds: progress.video_progress_seconds,
|
||||||
video_duration_seconds: progress.video_duration_seconds,
|
video_duration_seconds: progress.video_duration_seconds,
|
||||||
video_progress_percentage: progress.video_progress_percentage ? Number(progress.video_progress_percentage) : null,
|
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!,
|
last_watched_at: progress.last_watched_at!,
|
||||||
|
course_progress_percentage: enrollmentProgress?.progress_percentage,
|
||||||
|
is_course_completed: enrollmentProgress?.is_course_completed,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -922,46 +1026,9 @@ export class CoursesStudentService {
|
||||||
throw new ForbiddenError('You are not enrolled in this course');
|
throw new ForbiddenError('You are not enrolled in this course');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Complete lesson
|
// Mark lesson as complete and update enrollment progress
|
||||||
const progress = await prisma.lessonProgress.upsert({
|
const { lessonProgress, enrollmentProgress } = await this.markLessonComplete(decoded.id, lesson_id, course_id);
|
||||||
where: {
|
const { progress_percentage: course_progress_percentage, is_course_completed } = enrollmentProgress;
|
||||||
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;
|
|
||||||
|
|
||||||
// Find next lesson
|
// Find next lesson
|
||||||
const currentChapterLessons = lesson.chapter.lessons;
|
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
|
// Issue certificate if course completed and has certificate
|
||||||
let certificate_issued: boolean | undefined;
|
let certificate_issued: boolean | undefined;
|
||||||
if (is_course_completed && lesson.chapter.course.have_certificate) {
|
if (is_course_completed && lesson.chapter.course.have_certificate) {
|
||||||
|
|
@ -1026,9 +1081,9 @@ export class CoursesStudentService {
|
||||||
code: 200,
|
code: 200,
|
||||||
message: 'Lesson completed successfully',
|
message: 'Lesson completed successfully',
|
||||||
data: {
|
data: {
|
||||||
lesson_id: progress.lesson_id,
|
lesson_id: lessonProgress.lesson_id,
|
||||||
is_completed: progress.is_completed,
|
is_completed: lessonProgress.is_completed,
|
||||||
completed_at: progress.completed_at!,
|
completed_at: lessonProgress.completed_at!,
|
||||||
course_progress_percentage,
|
course_progress_percentage,
|
||||||
is_course_completed,
|
is_course_completed,
|
||||||
next_lesson_id,
|
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) {
|
if (isPassed) {
|
||||||
await prisma.lessonProgress.upsert({
|
const result = await this.markLessonComplete(decoded.id, lesson_id, course_id);
|
||||||
where: {
|
enrollmentProgress = result.enrollmentProgress;
|
||||||
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,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -1207,6 +1247,8 @@ export class CoursesStudentService {
|
||||||
started_at: quizAttempt.started_at,
|
started_at: quizAttempt.started_at,
|
||||||
completed_at: quizAttempt.completed_at!,
|
completed_at: quizAttempt.completed_at!,
|
||||||
answers_review: quiz.show_answers_after_completion ? answersReview : undefined,
|
answers_review: quiz.show_answers_after_completion ? answersReview : undefined,
|
||||||
|
course_progress_percentage: enrollmentProgress?.progress_percentage,
|
||||||
|
is_course_completed: enrollmentProgress?.is_course_completed,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
||||||
|
|
@ -242,6 +242,8 @@ export interface SaveVideoProgressResponse {
|
||||||
video_progress_percentage: number | null;
|
video_progress_percentage: number | null;
|
||||||
is_completed: boolean;
|
is_completed: boolean;
|
||||||
last_watched_at: Date;
|
last_watched_at: Date;
|
||||||
|
course_progress_percentage?: number;
|
||||||
|
is_course_completed?: boolean;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -362,5 +364,7 @@ export interface SubmitQuizResponse {
|
||||||
is_correct: boolean;
|
is_correct: boolean;
|
||||||
score: number;
|
score: number;
|
||||||
}[];
|
}[];
|
||||||
|
course_progress_percentage?: number;
|
||||||
|
is_course_completed?: boolean;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue