elearning/Backend/src/services/CoursesStudent.service.ts

1217 lines
No EOL
47 KiB
TypeScript

import { prisma } from '../config/database';
import { Prisma } from '@prisma/client';
import { config } from '../config';
import { logger } from '../config/logger';
import { UnauthorizedError, ValidationError, ForbiddenError, NotFoundError } from '../middleware/errorHandler';
import jwt from 'jsonwebtoken';
import {
EnrollCourseInput,
EnrollCourseResponse,
ListEnrolledCoursesInput,
ListEnrolledCoursesResponse,
GetCourseLearningInput,
GetCourseLearningResponse,
GetLessonContentInput,
GetLessonContentResponse,
CheckLessonAccessInput,
CheckLessonAccessResponse,
SaveVideoProgressInput,
SaveVideoProgressResponse,
GetVideoProgressInput,
GetVideoProgressResponse,
CompleteLessonInput,
CompleteLessonResponse,
SubmitQuizInput,
SubmitQuizResponse,
} from "../types/CoursesStudent.types";
import { getPresignedUrl, listObjects, getVideoFolder, getAttachmentsFolder } from '../config/minio';
export class CoursesStudentService {
async enrollCourse(input: EnrollCourseInput): Promise<EnrollCourseResponse> {
try {
const { course_id } = input;
const decoded = jwt.verify(input.token, config.jwt.secret) as { id: number; type: string };
const course = await prisma.course.findUnique({
where: { id: course_id },
});
if (!course) throw new NotFoundError('Course not found');
if (course.status !== 'APPROVED') throw new ForbiddenError('Cannot enroll in this course. Course is not available.');
const existingEnrollment = await prisma.enrollment.findUnique({
where: {
unique_enrollment: {
user_id: decoded.id,
course_id,
},
},
});
if (existingEnrollment) {
throw new ValidationError('Already enrolled in this course');
}
const enrollment = await prisma.enrollment.create({
data: {
course_id,
user_id: decoded.id,
status: 'ENROLLED',
enrolled_at: new Date(),
},
});
return {
code: 200,
message: 'Enrollment successful',
data: {
enrollment_id: enrollment.id,
course_id: enrollment.course_id,
user_id: enrollment.user_id,
status: enrollment.status,
enrolled_at: enrollment.enrolled_at,
},
};
} catch (error) {
logger.error(error);
throw error;
}
}
async GetEnrolledCourses(input: ListEnrolledCoursesInput): Promise<ListEnrolledCoursesResponse> {
try {
const { token } = input;
const page = input.page ?? 1;
const limit = input.limit ?? 20;
const decoded = jwt.verify(token, config.jwt.secret) as { id: number; type: string };
const enrollments = await prisma.enrollment.findMany({
where: {
user_id: decoded.id,
},
include: {
course: {
select: {
id: true,
title: true,
slug: true,
thumbnail_url: true,
description: true,
}
}
},
skip: (page - 1) * limit,
take: limit,
});
const total = await prisma.enrollment.count({
where: {
user_id: decoded.id,
},
});
const data = await Promise.all(
enrollments.map(async (enrollment) => {
let thumbnail_presigned_url: string | null = null;
if (enrollment.course.thumbnail_url) {
try {
thumbnail_presigned_url = await getPresignedUrl(enrollment.course.thumbnail_url, 3600);
} catch (err) {
logger.warn(`Failed to generate presigned URL for thumbnail: ${err}`);
}
}
return {
id: enrollment.id,
course_id: enrollment.course_id,
course: {
id: enrollment.course.id,
title: enrollment.course.title as { th: string; en: string },
slug: enrollment.course.slug,
thumbnail_url: thumbnail_presigned_url,
description: enrollment.course.description as { th: string; en: string },
},
status: enrollment.status,
progress_percentage: enrollment.progress_percentage,
enrolled_at: enrollment.enrolled_at,
started_at: enrollment.started_at,
completed_at: enrollment.completed_at,
last_accessed_at: enrollment.last_accessed_at,
};
})
);
return {
code: 200,
message: 'Enrollments retrieved successfully',
data,
total,
page,
limit,
};
} catch (error) {
logger.error(error);
throw error;
}
}
async getCourseLearning(input: GetCourseLearningInput): Promise<GetCourseLearningResponse> {
try {
const { token, course_id } = input;
const decoded = jwt.verify(token, config.jwt.secret) as { id: number; type: string };
// Get course with chapters and lessons (basic info only)
const course = await prisma.course.findUnique({
where: { id: course_id },
include: {
chapters: {
where: { is_published: true },
orderBy: { sort_order: 'asc' },
include: {
lessons: {
where: { is_published: true },
orderBy: { sort_order: 'asc' },
select: {
id: true,
chapter_id: true,
title: true,
type: true,
duration_minutes: true,
sort_order: true,
is_published: true,
is_sequential: true,
prerequisite_lesson_ids: true,
require_pass_quiz: true,
},
},
},
},
},
});
if (!course) {
throw new ForbiddenError('Course not found');
}
// Get 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 all lesson progress for this user and course
const lessonIds = course.chapters.flatMap(ch => ch.lessons.map(l => l.id));
const lessonProgress = await prisma.lessonProgress.findMany({
where: {
user_id: decoded.id,
lesson_id: { in: lessonIds },
},
});
const progressMap = new Map(lessonProgress.map(p => [p.lesson_id, p]));
// Build chapters with lesson lock status
const chapters = course.chapters.map(chapter => ({
id: chapter.id,
title: chapter.title as { th: string; en: string },
description: chapter.description as { th: string; en: string } | null,
sort_order: chapter.sort_order,
is_published: chapter.is_published,
lessons: chapter.lessons.map(lesson => {
const progress = progressMap.get(lesson.id);
const isCompleted = progress?.is_completed ?? false;
// Check lock status
let isLocked = false;
let lockReason: string | undefined;
if (lesson.is_sequential) {
const prereqIds = lesson.prerequisite_lesson_ids as number[] | null;
if (prereqIds && prereqIds.length > 0) {
const allPrereqCompleted = prereqIds.every(id => {
const prereqProgress = progressMap.get(id);
return prereqProgress?.is_completed;
});
if (!allPrereqCompleted) {
isLocked = true;
lockReason = 'Complete prerequisite lessons first';
}
}
}
return {
id: lesson.id,
chapter_id: lesson.chapter_id,
title: lesson.title as { th: string; en: string },
type: lesson.type as 'VIDEO' | 'QUIZ',
duration_minutes: lesson.duration_minutes,
sort_order: lesson.sort_order,
is_published: lesson.is_published,
is_sequential: lesson.is_sequential,
prerequisite_lesson_ids: lesson.prerequisite_lesson_ids as number[] | null,
require_pass_quiz: lesson.require_pass_quiz,
// Lock status
is_locked: isLocked,
lock_reason: lockReason,
// Progress
is_completed: isCompleted,
video_progress_percentage: progress?.video_progress_percentage ? Number(progress.video_progress_percentage) : undefined,
};
}),
}));
const total_lessons = lessonIds.length;
const completed_lessons = lessonProgress.filter(p => p.is_completed).length;
// Generate presigned URL for thumbnail
let thumbnail_presigned_url: string | null = null;
if (course.thumbnail_url) {
try {
thumbnail_presigned_url = await getPresignedUrl(course.thumbnail_url, 3600);
} catch (err) {
logger.warn(`Failed to generate presigned URL for thumbnail: ${err}`);
}
}
return {
code: 200,
message: 'Course learning retrieved successfully',
data: {
course: {
id: course.id,
title: course.title as { th: string; en: string },
slug: course.slug,
description: course.description as { th: string; en: string },
thumbnail_url: thumbnail_presigned_url,
have_certificate: course.have_certificate,
},
enrollment: {
status: enrollment.status,
progress_percentage: enrollment.progress_percentage,
enrolled_at: enrollment.enrolled_at,
started_at: enrollment.started_at,
completed_at: enrollment.completed_at,
},
chapters,
total_lessons,
completed_lessons,
},
};
} catch (error) {
logger.error(error);
throw error;
}
}
async getlessonContent(input: GetLessonContentInput): Promise<GetLessonContentResponse> {
try {
const { token, course_id, lesson_id } = input;
const decoded = jwt.verify(token, config.jwt.secret) as { id: number; type: string };
// Import MinIO functions
// 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 lesson with attachments and quiz (including questions and choices)
const lesson = await prisma.lesson.findUnique({
where: { id: lesson_id },
include: {
attachments: {
orderBy: { sort_order: 'asc' },
},
quiz: {
include: {
questions: {
orderBy: { sort_order: 'asc' },
include: {
choices: {
orderBy: { sort_order: 'asc' },
},
},
},
},
},
chapter: {
include: {
lessons: {
where: { is_published: true },
orderBy: { sort_order: 'asc' },
select: { id: true, sort_order: true },
},
},
},
},
});
if (!lesson) {
throw new NotFoundError('Lesson not found');
}
// Get user's progress for this lesson
const lessonProgress = await prisma.lessonProgress.findUnique({
where: {
user_id_lesson_id: {
user_id: decoded.id,
lesson_id,
},
},
});
// Calculate prev/next lesson IDs
const allLessons = lesson.chapter.lessons;
const currentIndex = allLessons.findIndex(l => l.id === lesson_id);
const prevLessonId = currentIndex > 0 ? allLessons[currentIndex - 1].id : null;
const nextLessonId = currentIndex < allLessons.length - 1 ? allLessons[currentIndex + 1].id : null;
// Get course_id from chapter
const chapter_course_id = lesson.chapter.course_id;
// Import additional MinIO functions
// Using MinIO functions imported above
// Get video URL from video folder (first file)
let video_url: string | null = null;
try {
const videoPrefix = getVideoFolder(chapter_course_id, lesson_id);
const videoFiles = await listObjects(videoPrefix);
if (videoFiles.length > 0) {
// Get presigned URL for the first video file
video_url = await getPresignedUrl(videoFiles[0].name, 3600);
}
} catch (err) {
logger.error(`Failed to get video from MinIO: ${err}`);
}
// Get attachments from MinIO folder
const attachmentsWithUrls: {
file_name: string;
file_path: string;
file_size: number;
mime_type: string;
presigned_url: string | null;
}[] = [];
try {
const attachmentsPrefix = getAttachmentsFolder(chapter_course_id, lesson_id);
const attachmentFiles = await listObjects(attachmentsPrefix);
for (const file of attachmentFiles) {
let presigned_url: string | null = null;
try {
presigned_url = await getPresignedUrl(file.name, 3600);
} catch (err) {
logger.error(`Failed to generate presigned URL for ${file.name}: ${err}`);
}
// Extract filename from path
const fileName = file.name.split('/').pop() || file.name;
// Guess mime type from extension
const ext = fileName.split('.').pop()?.toLowerCase() || '';
const mimeTypes: { [key: string]: string } = {
'pdf': 'application/pdf',
'doc': 'application/msword',
'docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'ppt': 'application/vnd.ms-powerpoint',
'pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'xls': 'application/vnd.ms-excel',
'xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'png': 'image/png',
'jpg': 'image/jpeg',
'jpeg': 'image/jpeg',
'gif': 'image/gif',
'mp4': 'video/mp4',
'zip': 'application/zip',
};
const mime_type = mimeTypes[ext] || 'application/octet-stream';
attachmentsWithUrls.push({
file_name: fileName,
file_path: file.name,
file_size: file.size,
mime_type,
presigned_url,
});
}
} catch (err) {
logger.error(`Failed to list attachments from MinIO: ${err}`);
}
return {
code: 200,
message: 'Lesson retrieved successfully',
data: {
id: lesson.id,
chapter_id: lesson.chapter_id,
title: lesson.title as { th: string; en: string },
content: lesson.content as { th: string; en: string } | null,
type: lesson.type as 'VIDEO' | 'QUIZ',
duration_minutes: lesson.duration_minutes,
is_sequential: lesson.is_sequential,
prerequisite_lesson_ids: lesson.prerequisite_lesson_ids as number[] | null,
require_pass_quiz: lesson.require_pass_quiz,
video_url, // Presigned URL for video
attachments: attachmentsWithUrls,
quiz: lesson.quiz ? {
id: lesson.quiz.id,
title: lesson.quiz.title as { th: string; en: string },
description: lesson.quiz.description as { th: string; en: string } | null,
passing_score: lesson.quiz.passing_score,
time_limit: lesson.quiz.time_limit,
shuffle_questions: lesson.quiz.shuffle_questions,
shuffle_choices: lesson.quiz.shuffle_choices,
questions: lesson.quiz.questions.map(q => ({
id: q.id,
question: q.question as { th: string; en: string },
question_type: q.question_type,
score: q.score,
sort_order: q.sort_order,
choices: q.choices.map(c => ({
id: c.id,
text: c.text as { th: string; en: string },
sort_order: c.sort_order,
})),
})),
} : null,
prev_lesson_id: prevLessonId,
next_lesson_id: nextLessonId,
},
progress: lessonProgress ? {
is_completed: lessonProgress.is_completed,
video_progress_seconds: lessonProgress.video_progress_seconds,
video_duration_seconds: lessonProgress.video_duration_seconds,
video_progress_percentage: lessonProgress.video_progress_percentage ? Number(lessonProgress.video_progress_percentage) : null,
last_watched_at: lessonProgress.last_watched_at,
} : undefined,
};
} catch (error) {
logger.error(error);
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;
}
}
/**
* ส่งคำตอบ Quiz และคำนวณคะแนน
* Submit quiz answers and calculate score
*/
async submitQuiz(input: SubmitQuizInput): Promise<SubmitQuizResponse> {
try {
const { token, course_id, lesson_id, answers } = input;
const decoded = jwt.verify(token, config.jwt.secret) as { id: number };
// 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 lesson and verify it's a QUIZ type
const lesson = await prisma.lesson.findUnique({
where: { id: lesson_id },
include: {
quiz: {
include: {
questions: {
include: {
choices: true,
},
},
},
},
chapter: true,
},
});
if (!lesson) {
throw new NotFoundError('Lesson not found');
}
if (lesson.type !== 'QUIZ') {
throw new ValidationError('This lesson is not a quiz');
}
if (!lesson.quiz) {
throw new NotFoundError('Quiz not found for this lesson');
}
// Verify lesson belongs to the course
if (lesson.chapter.course_id !== course_id) {
throw new NotFoundError('Lesson not found in this course');
}
const quiz = lesson.quiz;
// Get previous attempt count
const previousAttempts = await prisma.quizAttempt.count({
where: {
user_id: decoded.id,
quiz_id: quiz.id,
},
});
const attemptNumber = previousAttempts + 1;
// Calculate score
let totalScore = 0;
let earnedScore = 0;
let correctAnswers = 0;
const answersReview: {
question_id: number;
selected_choice_id: number;
correct_choice_id: number;
is_correct: boolean;
score: number;
}[] = [];
for (const question of quiz.questions) {
totalScore += question.score;
// Find the correct choice for this question
const correctChoice = question.choices.find(c => c.is_correct);
const correctChoiceId = correctChoice?.id ?? 0;
// Find student's answer for this question
const studentAnswer = answers.find(a => a.question_id === question.id);
const selectedChoiceId = studentAnswer?.choice_id ?? 0;
// Check if answer is correct
const isCorrect = selectedChoiceId === correctChoiceId;
if (isCorrect) {
earnedScore += question.score;
correctAnswers++;
}
answersReview.push({
question_id: question.id,
selected_choice_id: selectedChoiceId,
correct_choice_id: correctChoiceId,
is_correct: isCorrect,
score: isCorrect ? question.score : 0,
});
}
// Calculate percentage and check if passed
const scorePercentage = totalScore > 0 ? (earnedScore / totalScore) * 100 : 0;
const isPassed = scorePercentage >= quiz.passing_score;
// Create quiz attempt record
const now = new Date();
const quizAttempt = await prisma.quizAttempt.create({
data: {
user_id: decoded.id,
quiz_id: quiz.id,
score: earnedScore,
total_questions: quiz.questions.length,
correct_answers: correctAnswers,
is_passed: isPassed,
attempt_number: attemptNumber,
answers: answers as any, // Store student answers as JSON
started_at: now,
completed_at: now,
},
});
// If passed, upsert lesson_progress to mark lesson as completed
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,
},
});
}
return {
code: 200,
message: isPassed ? 'Quiz passed!' : 'Quiz completed',
data: {
attempt_id: quizAttempt.id,
quiz_id: quiz.id,
score: earnedScore,
total_score: totalScore,
total_questions: quiz.questions.length,
correct_answers: correctAnswers,
is_passed: isPassed,
passing_score: quiz.passing_score,
attempt_number: attemptNumber,
started_at: quizAttempt.started_at,
completed_at: quizAttempt.completed_at!,
answers_review: quiz.show_answers_after_completion ? answersReview : undefined,
},
};
} catch (error) {
logger.error(error);
throw error;
}
}
}