feat: Implement lesson access control logic including enrollment, prerequisite, and quiz completion checks.
This commit is contained in:
parent
6d59ec06bf
commit
0308995d8e
4 changed files with 760 additions and 1 deletions
BIN
.DS_Store
vendored
BIN
.DS_Store
vendored
Binary file not shown.
|
|
@ -0,0 +1,205 @@
|
|||
import { Get, Body, Post, Route, Tags, SuccessResponse, Response, Security, Path, Request, Query } from 'tsoa';
|
||||
import { ValidationError } from '../middleware/errorHandler';
|
||||
import { CoursesStudentService } from '../services/CoursesStudent.service';
|
||||
import {
|
||||
EnrollCourseResponse,
|
||||
ListEnrolledCoursesResponse,
|
||||
GetCourseLearningResponse,
|
||||
GetLessonContentResponse,
|
||||
CheckLessonAccessResponse,
|
||||
SaveVideoProgressResponse,
|
||||
SaveVideoProgressBody,
|
||||
GetVideoProgressResponse,
|
||||
CompleteLessonResponse,
|
||||
} from '../types/CoursesStudent.types';
|
||||
import { EnrollmentStatus } from '@prisma/client';
|
||||
|
||||
@Route('api/students')
|
||||
@Tags('CoursesStudent')
|
||||
export class CoursesStudentController {
|
||||
|
||||
private service = new CoursesStudentService();
|
||||
|
||||
/**
|
||||
* ลงทะเบียนเรียนในคอร์ส
|
||||
* Enroll in a course
|
||||
* @param courseId - รหัสคอร์ส / Course ID
|
||||
*/
|
||||
@Post('courses/{courseId}/enroll')
|
||||
@Security('jwt', ['student'])
|
||||
@SuccessResponse('200', 'Enrolled successfully')
|
||||
@Response('401', 'Invalid or expired token')
|
||||
@Response('404', 'Course not found')
|
||||
@Response('409', 'Already enrolled in this course')
|
||||
public async enrollCourse(@Request() request: any, @Path() courseId: number): Promise<EnrollCourseResponse> {
|
||||
const token = request.headers.authorization?.replace('Bearer ', '');
|
||||
if (!token) {
|
||||
throw new ValidationError('No token provided');
|
||||
}
|
||||
return await this.service.enrollCourse({ token, course_id: courseId });
|
||||
}
|
||||
|
||||
/**
|
||||
* ดึงรายการคอร์สที่ลงทะเบียนเรียน
|
||||
* Get list of enrolled courses
|
||||
* @param page - หน้าที่ต้องการดึง / Page number
|
||||
* @param limit - จำนวนรายการต่อหน้า / Items per page
|
||||
* @param status - สถานะการลงทะเบียน / Enrollment status
|
||||
*/
|
||||
@Get('courses')
|
||||
@Security('jwt', ['student'])
|
||||
@SuccessResponse('200', 'Enrolled courses retrieved successfully')
|
||||
@Response('401', 'Invalid or expired token')
|
||||
public async getEnrolledCourses(
|
||||
@Request() request: any,
|
||||
@Query() page?: number,
|
||||
@Query() limit?: number,
|
||||
@Query() status?: EnrollmentStatus
|
||||
): Promise<ListEnrolledCoursesResponse> {
|
||||
const token = request.headers.authorization?.replace('Bearer ', '');
|
||||
if (!token) {
|
||||
throw new ValidationError('No token provided');
|
||||
}
|
||||
return await this.service.GetEnrolledCourses({ token, page, limit, status });
|
||||
}
|
||||
|
||||
/**
|
||||
* ดึงข้อมูลหน้าเรียนคอร์ส (พร้อมสถานะการล็อคบทเรียน)
|
||||
* Get course learning page with lesson lock status
|
||||
* @param courseId - รหัสคอร์ส / Course ID
|
||||
*/
|
||||
@Get('courses/{courseId}/learn')
|
||||
@Security('jwt', ['student'])
|
||||
@SuccessResponse('200', 'Course learning data retrieved successfully')
|
||||
@Response('401', 'Invalid or expired token')
|
||||
@Response('403', 'Not enrolled in this course')
|
||||
@Response('404', 'Course not found')
|
||||
public async getCourseLearning(@Request() request: any, @Path() courseId: number): Promise<GetCourseLearningResponse> {
|
||||
const token = request.headers.authorization?.replace('Bearer ', '');
|
||||
if (!token) {
|
||||
throw new ValidationError('No token provided');
|
||||
}
|
||||
return await this.service.getCourseLearning({ token, course_id: courseId });
|
||||
}
|
||||
|
||||
/**
|
||||
* ดึงเนื้อหาบทเรียน (ตรวจสอบเงื่อนไขก่อนหน้า)
|
||||
* Get lesson content (checks prerequisites)
|
||||
* @param courseId - รหัสคอร์ส / Course ID
|
||||
* @param lessonId - รหัสบทเรียน / Lesson ID
|
||||
*/
|
||||
@Get('courses/{courseId}/lessons/{lessonId}')
|
||||
@Security('jwt', ['student'])
|
||||
@SuccessResponse('200', 'Lesson content retrieved successfully')
|
||||
@Response('401', 'Invalid or expired token')
|
||||
@Response('403', 'Not enrolled in this course or lesson is locked')
|
||||
@Response('404', 'Lesson not found')
|
||||
public async getLessonContent(
|
||||
@Request() request: any,
|
||||
@Path() courseId: number,
|
||||
@Path() lessonId: number
|
||||
): Promise<GetLessonContentResponse> {
|
||||
const token = request.headers.authorization?.replace('Bearer ', '');
|
||||
if (!token) {
|
||||
throw new ValidationError('No token provided');
|
||||
}
|
||||
return await this.service.getlessonContent({ token, course_id: courseId, lesson_id: lessonId });
|
||||
}
|
||||
|
||||
/**
|
||||
* ตรวจสอบสิทธิ์เข้าถึงบทเรียน (ไม่โหลดเนื้อหา)
|
||||
* Check lesson access without loading content
|
||||
* @param courseId - รหัสคอร์ส / Course ID
|
||||
* @param lessonId - รหัสบทเรียน / Lesson ID
|
||||
*/
|
||||
@Get('courses/{courseId}/lessons/{lessonId}/access-check')
|
||||
@Security('jwt', ['student'])
|
||||
@SuccessResponse('200', 'Access check completed')
|
||||
@Response('401', 'Invalid or expired token')
|
||||
@Response('404', 'Lesson not found')
|
||||
public async checkLessonAccess(
|
||||
@Request() request: any,
|
||||
@Path() courseId: number,
|
||||
@Path() lessonId: number
|
||||
): Promise<CheckLessonAccessResponse> {
|
||||
const token = request.headers.authorization?.replace('Bearer ', '');
|
||||
if (!token) {
|
||||
throw new ValidationError('No token provided');
|
||||
}
|
||||
return await this.service.checkAccessLesson({ token, course_id: courseId, lesson_id: lessonId });
|
||||
}
|
||||
|
||||
/**
|
||||
* บันทึกความคืบหน้าการดูวิดีโอ
|
||||
* Save video progress
|
||||
* @param lessonId - รหัสบทเรียน / Lesson ID
|
||||
*/
|
||||
@Post('lessons/{lessonId}/progress')
|
||||
@Security('jwt', ['student'])
|
||||
@SuccessResponse('200', 'Video progress saved successfully')
|
||||
@Response('401', 'Invalid or expired token')
|
||||
@Response('403', 'Not enrolled in this course')
|
||||
@Response('404', 'Lesson not found')
|
||||
public async saveVideoProgress(
|
||||
@Request() request: any,
|
||||
@Path() lessonId: number,
|
||||
@Body() body: SaveVideoProgressBody
|
||||
): Promise<SaveVideoProgressResponse> {
|
||||
const token = request.headers.authorization?.replace('Bearer ', '');
|
||||
if (!token) {
|
||||
throw new ValidationError('No token provided');
|
||||
}
|
||||
return await this.service.saveVideoProgress({
|
||||
token,
|
||||
lesson_id: lessonId,
|
||||
video_progress_seconds: body.video_progress_seconds,
|
||||
video_duration_seconds: body.video_duration_seconds,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* ดึงความคืบหน้าการดูวิดีโอ
|
||||
* Get video progress
|
||||
* @param lessonId - รหัสบทเรียน / Lesson ID
|
||||
*/
|
||||
@Get('lessons/{lessonId}/progress')
|
||||
@Security('jwt', ['student'])
|
||||
@SuccessResponse('200', 'Video progress retrieved successfully')
|
||||
@Response('401', 'Invalid or expired token')
|
||||
@Response('403', 'Not enrolled in this course')
|
||||
@Response('404', 'Lesson not found')
|
||||
public async getVideoProgress(
|
||||
@Request() request: any,
|
||||
@Path() lessonId: number
|
||||
): Promise<GetVideoProgressResponse> {
|
||||
const token = request.headers.authorization?.replace('Bearer ', '');
|
||||
if (!token) {
|
||||
throw new ValidationError('No token provided');
|
||||
}
|
||||
return await this.service.getVideoProgress({ token, lesson_id: lessonId });
|
||||
}
|
||||
|
||||
/**
|
||||
* ทำเครื่องหมายบทเรียนว่าเรียนจบ
|
||||
* Mark lesson as complete
|
||||
* @param courseId - รหัสคอร์ส / Course ID
|
||||
* @param lessonId - รหัสบทเรียน / Lesson ID
|
||||
*/
|
||||
@Post('courses/{courseId}/lessons/{lessonId}/complete')
|
||||
@Security('jwt', ['student'])
|
||||
@SuccessResponse('200', 'Lesson marked as complete')
|
||||
@Response('401', 'Invalid or expired token')
|
||||
@Response('403', 'Not enrolled in this course')
|
||||
@Response('404', 'Lesson not found')
|
||||
public async completeLesson(
|
||||
@Request() request: any,
|
||||
@Path() courseId: number,
|
||||
@Path() lessonId: number
|
||||
): Promise<CompleteLessonResponse> {
|
||||
const token = request.headers.authorization?.replace('Bearer ', '');
|
||||
if (!token) {
|
||||
throw new ValidationError('No token provided');
|
||||
}
|
||||
return await this.service.completeLesson({ token, lesson_id: lessonId });
|
||||
}
|
||||
}
|
||||
|
|
@ -19,6 +19,8 @@ import {
|
|||
SaveVideoProgressResponse,
|
||||
GetVideoProgressInput,
|
||||
GetVideoProgressResponse,
|
||||
CompleteLessonInput,
|
||||
CompleteLessonResponse,
|
||||
} from "../types/CoursesStudent.types";
|
||||
|
||||
export class CoursesStudentService {
|
||||
|
|
@ -363,4 +365,537 @@ export class CoursesStudentService {
|
|||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async checkAccessLesson(input: CheckLessonAccessInput): Promise<CheckLessonAccessResponse> {
|
||||
try {
|
||||
const { token, course_id, lesson_id } = input;
|
||||
const decoded = jwt.verify(token, config.jwt.secret) as { id: number; type: string };
|
||||
|
||||
// Check enrollment
|
||||
const enrollment = await prisma.enrollment.findUnique({
|
||||
where: {
|
||||
unique_enrollment: {
|
||||
user_id: decoded.id,
|
||||
course_id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!enrollment) {
|
||||
return {
|
||||
code: 200,
|
||||
message: 'Not enrolled in this course',
|
||||
data: {
|
||||
is_accessible: false,
|
||||
is_enrolled: false,
|
||||
is_locked: true,
|
||||
lock_reason: 'You are not enrolled in this course',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Get lesson with prerequisite info
|
||||
const lesson = await prisma.lesson.findUnique({
|
||||
where: { id: lesson_id },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
is_sequential: true,
|
||||
prerequisite_lesson_ids: true,
|
||||
require_pass_quiz: true,
|
||||
chapter: {
|
||||
select: {
|
||||
course_id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!lesson) {
|
||||
throw new NotFoundError('Lesson not found');
|
||||
}
|
||||
|
||||
// Verify lesson belongs to the course
|
||||
if (lesson.chapter.course_id !== course_id) {
|
||||
throw new ForbiddenError('Lesson does not belong to this course');
|
||||
}
|
||||
|
||||
// If not sequential, allow access
|
||||
if (!lesson.is_sequential) {
|
||||
return {
|
||||
code: 200,
|
||||
message: 'Lesson is accessible',
|
||||
data: {
|
||||
is_accessible: true,
|
||||
is_enrolled: true,
|
||||
is_locked: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const prerequisiteIds = lesson.prerequisite_lesson_ids as number[] | null;
|
||||
|
||||
// If no prerequisites, allow access
|
||||
if (!prerequisiteIds || prerequisiteIds.length === 0) {
|
||||
return {
|
||||
code: 200,
|
||||
message: 'Lesson is accessible',
|
||||
data: {
|
||||
is_accessible: true,
|
||||
is_enrolled: true,
|
||||
is_locked: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Get prerequisite lessons info
|
||||
const prerequisiteLessons = await prisma.lesson.findMany({
|
||||
where: {
|
||||
id: { in: prerequisiteIds },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
require_pass_quiz: true,
|
||||
quiz: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Get user's progress for prerequisite lessons
|
||||
const prerequisiteProgress = await prisma.lessonProgress.findMany({
|
||||
where: {
|
||||
user_id: decoded.id,
|
||||
lesson_id: { in: prerequisiteIds },
|
||||
},
|
||||
});
|
||||
|
||||
const progressMap = new Map(prerequisiteProgress.map(p => [p.lesson_id, p]));
|
||||
|
||||
// Check if all prerequisites are completed
|
||||
const requiredLessons: { id: number; title: { th: string; en: string }; is_completed: boolean }[] = [];
|
||||
let allCompleted = true;
|
||||
|
||||
for (const prereqLesson of prerequisiteLessons) {
|
||||
const progress = progressMap.get(prereqLesson.id);
|
||||
const isCompleted = progress?.is_completed ?? false;
|
||||
|
||||
if (!isCompleted) {
|
||||
allCompleted = false;
|
||||
}
|
||||
|
||||
requiredLessons.push({
|
||||
id: prereqLesson.id,
|
||||
title: prereqLesson.title as { th: string; en: string },
|
||||
is_completed: isCompleted,
|
||||
});
|
||||
}
|
||||
|
||||
// Check if any prerequisite requires passing quiz
|
||||
let requiredQuizPass: { lesson_id: number; quiz_id: number; title: { th: string; en: string }; is_passed: boolean } | undefined;
|
||||
|
||||
for (const prereqLesson of prerequisiteLessons) {
|
||||
if (prereqLesson.require_pass_quiz && prereqLesson.quiz) {
|
||||
// Check if user passed the quiz
|
||||
const quizAttempt = await prisma.quizAttempt.findFirst({
|
||||
where: {
|
||||
user_id: decoded.id,
|
||||
quiz_id: prereqLesson.quiz.id,
|
||||
is_passed: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!quizAttempt) {
|
||||
allCompleted = false;
|
||||
requiredQuizPass = {
|
||||
lesson_id: prereqLesson.id,
|
||||
quiz_id: prereqLesson.quiz.id,
|
||||
title: prereqLesson.quiz.title as { th: string; en: string },
|
||||
is_passed: false,
|
||||
};
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (allCompleted) {
|
||||
return {
|
||||
code: 200,
|
||||
message: 'Lesson is accessible',
|
||||
data: {
|
||||
is_accessible: true,
|
||||
is_enrolled: true,
|
||||
is_locked: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Not all prerequisites completed
|
||||
return {
|
||||
code: 200,
|
||||
message: 'Lesson is locked',
|
||||
data: {
|
||||
is_accessible: false,
|
||||
is_enrolled: true,
|
||||
is_locked: true,
|
||||
lock_reason: 'กรุณาเรียนบทเรียนก่อนหน้าให้ครบก่อน',
|
||||
required_lessons: requiredLessons,
|
||||
required_quiz_pass: requiredQuizPass,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getVideoProgress(input: GetVideoProgressInput): Promise<GetVideoProgressResponse> {
|
||||
try {
|
||||
const { token, lesson_id } = input;
|
||||
const decoded = jwt.verify(token, config.jwt.secret) as { id: number; type: string };
|
||||
|
||||
// Get lesson to find course_id
|
||||
const lesson = await prisma.lesson.findUnique({
|
||||
where: { id: lesson_id },
|
||||
select: {
|
||||
id: true,
|
||||
chapter: {
|
||||
select: { course_id: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!lesson) {
|
||||
throw new NotFoundError('Lesson not found');
|
||||
}
|
||||
|
||||
const course_id = lesson.chapter.course_id;
|
||||
|
||||
// Check enrollment
|
||||
const enrollment = await prisma.enrollment.findUnique({
|
||||
where: {
|
||||
unique_enrollment: {
|
||||
user_id: decoded.id,
|
||||
course_id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!enrollment) {
|
||||
throw new ForbiddenError('You are not enrolled in this course');
|
||||
}
|
||||
|
||||
// Get progress
|
||||
const progress = await prisma.lessonProgress.findUnique({
|
||||
where: {
|
||||
user_id_lesson_id: {
|
||||
user_id: decoded.id,
|
||||
lesson_id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Return null if no progress found
|
||||
if (!progress) {
|
||||
return {
|
||||
code: 200,
|
||||
message: 'No video progress found',
|
||||
data: null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
code: 200,
|
||||
message: 'Video progress retrieved successfully',
|
||||
data: {
|
||||
lesson_id: progress.lesson_id,
|
||||
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,
|
||||
completed_at: progress.completed_at,
|
||||
last_watched_at: progress.last_watched_at,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async saveVideoProgress(input: SaveVideoProgressInput): Promise<SaveVideoProgressResponse> {
|
||||
try {
|
||||
const { token, lesson_id, video_progress_seconds, video_duration_seconds } = input;
|
||||
const decoded = jwt.verify(token, config.jwt.secret) as { id: number; type: string };
|
||||
|
||||
// Get lesson to find course_id
|
||||
const lesson = await prisma.lesson.findUnique({
|
||||
where: { id: lesson_id },
|
||||
select: {
|
||||
id: true,
|
||||
chapter: {
|
||||
select: { course_id: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!lesson) {
|
||||
throw new NotFoundError('Lesson not found');
|
||||
}
|
||||
|
||||
const course_id = lesson.chapter.course_id;
|
||||
|
||||
// Check enrollment
|
||||
const enrollment = await prisma.enrollment.findUnique({
|
||||
where: {
|
||||
unique_enrollment: {
|
||||
user_id: decoded.id,
|
||||
course_id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!enrollment) {
|
||||
throw new ForbiddenError('You are not enrolled in this course');
|
||||
}
|
||||
|
||||
// Calculate progress percentage (avoid division by zero)
|
||||
const progressPercentage = video_duration_seconds && video_duration_seconds > 0
|
||||
? (video_progress_seconds / video_duration_seconds) * 100
|
||||
: null;
|
||||
|
||||
// Auto-complete at >= 90%
|
||||
const isCompleted = progressPercentage !== null && progressPercentage >= 90;
|
||||
|
||||
// Save progress
|
||||
const progress = await prisma.lessonProgress.upsert({
|
||||
where: {
|
||||
user_id_lesson_id: {
|
||||
user_id: decoded.id,
|
||||
lesson_id,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
user_id: decoded.id,
|
||||
lesson_id,
|
||||
video_progress_seconds,
|
||||
video_duration_seconds: video_duration_seconds ?? null,
|
||||
video_progress_percentage: progressPercentage,
|
||||
is_completed: isCompleted,
|
||||
completed_at: isCompleted ? new Date() : null,
|
||||
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(),
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
code: 200,
|
||||
message: 'Video progress saved successfully',
|
||||
data: {
|
||||
lesson_id: progress.lesson_id,
|
||||
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,
|
||||
last_watched_at: progress.last_watched_at!,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async completeLesson(input: CompleteLessonInput): Promise<CompleteLessonResponse> {
|
||||
try {
|
||||
const { token, lesson_id } = input;
|
||||
const decoded = jwt.verify(token, config.jwt.secret) as { id: number; type: string };
|
||||
|
||||
// Get lesson with chapter and course info
|
||||
const lesson = await prisma.lesson.findUnique({
|
||||
where: { id: lesson_id },
|
||||
select: {
|
||||
id: true,
|
||||
sort_order: true,
|
||||
chapter: {
|
||||
select: {
|
||||
id: true,
|
||||
course_id: true,
|
||||
sort_order: true,
|
||||
lessons: {
|
||||
where: { is_published: true },
|
||||
orderBy: { sort_order: 'asc' },
|
||||
select: { id: true, sort_order: true },
|
||||
},
|
||||
course: {
|
||||
select: {
|
||||
have_certificate: true,
|
||||
chapters: {
|
||||
where: { is_published: true },
|
||||
orderBy: { sort_order: 'asc' },
|
||||
include: {
|
||||
lessons: {
|
||||
where: { is_published: true },
|
||||
orderBy: { sort_order: 'asc' },
|
||||
select: { id: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!lesson) {
|
||||
throw new NotFoundError('Lesson not found');
|
||||
}
|
||||
|
||||
const course_id = lesson.chapter.course_id;
|
||||
|
||||
// Check enrollment
|
||||
const enrollment = await prisma.enrollment.findUnique({
|
||||
where: {
|
||||
unique_enrollment: {
|
||||
user_id: decoded.id,
|
||||
course_id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!enrollment) {
|
||||
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;
|
||||
|
||||
// Find next lesson
|
||||
const currentChapterLessons = lesson.chapter.lessons;
|
||||
const currentLessonIndex = currentChapterLessons.findIndex(l => l.id === lesson_id);
|
||||
let next_lesson_id: number | null = null;
|
||||
|
||||
if (currentLessonIndex < currentChapterLessons.length - 1) {
|
||||
// Next lesson in current chapter
|
||||
next_lesson_id = currentChapterLessons[currentLessonIndex + 1].id;
|
||||
} else {
|
||||
// Find next chapter's first lesson
|
||||
const allChapters = lesson.chapter.course.chapters;
|
||||
const currentChapterIndex = allChapters.findIndex(ch => ch.id === lesson.chapter.id);
|
||||
if (currentChapterIndex < allChapters.length - 1) {
|
||||
const nextChapter = allChapters[currentChapterIndex + 1];
|
||||
if (nextChapter.lessons.length > 0) {
|
||||
next_lesson_id = nextChapter.lessons[0].id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// Check if certificate already exists
|
||||
const existingCertificate = await prisma.certificate.findFirst({
|
||||
where: {
|
||||
user_id: decoded.id,
|
||||
course_id,
|
||||
},
|
||||
});
|
||||
|
||||
if (!existingCertificate) {
|
||||
await prisma.certificate.create({
|
||||
data: {
|
||||
user_id: decoded.id,
|
||||
course_id,
|
||||
enrollment_id: enrollment.id,
|
||||
file_path: `certificates/${course_id}/${decoded.id}/${Date.now()}.pdf`,
|
||||
issued_at: new Date(),
|
||||
},
|
||||
});
|
||||
certificate_issued = true;
|
||||
} else {
|
||||
certificate_issued = false;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
code: 200,
|
||||
message: 'Lesson completed successfully',
|
||||
data: {
|
||||
lesson_id: progress.lesson_id,
|
||||
is_completed: progress.is_completed,
|
||||
completed_at: progress.completed_at!,
|
||||
course_progress_percentage,
|
||||
is_course_completed,
|
||||
next_lesson_id,
|
||||
certificate_issued,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -286,4 +286,23 @@ export interface SaveVideoProgressBody {
|
|||
|
||||
export interface EnrollCourseBody {
|
||||
course_id: number;
|
||||
}
|
||||
}
|
||||
|
||||
export interface CompleteLessonInput {
|
||||
token: string;
|
||||
lesson_id: number;
|
||||
}
|
||||
|
||||
export interface CompleteLessonResponse {
|
||||
code: number;
|
||||
message: string;
|
||||
data?: {
|
||||
lesson_id: number;
|
||||
is_completed: boolean;
|
||||
completed_at: Date;
|
||||
course_progress_percentage: number;
|
||||
is_course_completed: boolean;
|
||||
next_lesson_id: number | null;
|
||||
certificate_issued?: boolean;
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue