1547 lines
No EOL
61 KiB
TypeScript
1547 lines
No EOL
61 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,
|
|
GetQuizAttemptsInput,
|
|
GetQuizAttemptsResponse,
|
|
} from "../types/CoursesStudent.types";
|
|
import { getPresignedUrl, listObjects, getVideoFolder, getAttachmentsFolder } from '../config/minio';
|
|
import { auditService } from './audit.service';
|
|
import { AuditAction } from '@prisma/client';
|
|
|
|
|
|
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;
|
|
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(),
|
|
},
|
|
});
|
|
|
|
// Audit log - ENROLL
|
|
auditService.log({
|
|
userId: decoded.id,
|
|
action: AuditAction.ENROLL,
|
|
entityType: 'Enrollment',
|
|
entityId: enrollment.id,
|
|
newValue: { course_id, user_id: decoded.id, status: 'ENROLLED' }
|
|
});
|
|
|
|
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 enrolling in course: ${error}`);
|
|
const decoded = jwt.decode(input.token) as { id: number } | null;
|
|
await auditService.logSync({
|
|
userId: decoded?.id || 0,
|
|
action: AuditAction.ERROR,
|
|
entityType: 'Enrollment',
|
|
entityId: 0,
|
|
metadata: {
|
|
operation: 'enroll_course',
|
|
course_id: input.course_id,
|
|
error: error instanceof Error ? error.message : String(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);
|
|
const decoded = jwt.decode(input.token) as { id: number } | null;
|
|
await auditService.logSync({
|
|
userId: decoded?.id || 0,
|
|
action: AuditAction.ERROR,
|
|
entityType: 'Enrollment',
|
|
entityId: 0,
|
|
metadata: {
|
|
operation: 'get_enrolled_courses',
|
|
error: error instanceof Error ? error.message : String(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);
|
|
const decoded = jwt.decode(input.token) as { id: number } | null;
|
|
await auditService.logSync({
|
|
userId: decoded?.id || 0,
|
|
action: AuditAction.ERROR,
|
|
entityType: 'Enrollment',
|
|
entityId: 0,
|
|
metadata: {
|
|
operation: 'get_course_learning',
|
|
error: error instanceof Error ? error.message : String(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 - check for YouTube or MinIO
|
|
let video_url: string | null = null;
|
|
const videoAttachment = await prisma.lessonAttachment.findFirst({
|
|
where: { lesson_id, sort_order: 0 }
|
|
});
|
|
|
|
if (videoAttachment) {
|
|
if (videoAttachment.mime_type === 'video/youtube') {
|
|
// YouTube video - build URL from video ID stored in file_path
|
|
video_url = `https://www.youtube.com/watch?v=${videoAttachment.file_path}`;
|
|
} else {
|
|
// MinIO video - get presigned URL
|
|
try {
|
|
video_url = await getPresignedUrl(videoAttachment.file_path, 3600);
|
|
} catch (err) {
|
|
logger.error(`Failed to get video from MinIO: ${err}`);
|
|
}
|
|
}
|
|
} else {
|
|
// Fallback: try to get video from MinIO folder (legacy support)
|
|
try {
|
|
const videoPrefix = getVideoFolder(chapter_course_id, lesson_id);
|
|
const videoFiles = await listObjects(videoPrefix);
|
|
if (videoFiles.length > 0) {
|
|
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}`);
|
|
}
|
|
|
|
// Check quiz attempts if this is a QUIZ lesson
|
|
let latestQuizAttempt = null;
|
|
let shouldReturnQuestions = true;
|
|
|
|
if (lesson.quiz) {
|
|
// Get latest quiz attempt for this user
|
|
latestQuizAttempt = await prisma.quizAttempt.findFirst({
|
|
where: {
|
|
user_id: decoded.id,
|
|
quiz_id: lesson.quiz.id,
|
|
},
|
|
orderBy: {
|
|
started_at: 'desc',
|
|
},
|
|
});
|
|
|
|
// If allow_multiple_attempts is false AND user has attempted
|
|
if (!lesson.quiz.allow_multiple_attempts && latestQuizAttempt) {
|
|
shouldReturnQuestions = false;
|
|
}
|
|
}
|
|
|
|
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 ? (shouldReturnQuestions ? {
|
|
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,
|
|
is_skippable: lesson.quiz.is_skippable,
|
|
show_answers_after_completion: lesson.quiz.show_answers_after_completion,
|
|
allow_multiple_attempts: lesson.quiz.allow_multiple_attempts,
|
|
latest_attempt: latestQuizAttempt ? {
|
|
score: latestQuizAttempt.score,
|
|
is_passed: latestQuizAttempt.is_passed,
|
|
attempt_number: latestQuizAttempt.attempt_number,
|
|
started_at: latestQuizAttempt.started_at,
|
|
completed_at: latestQuizAttempt.completed_at,
|
|
} : undefined,
|
|
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,
|
|
})),
|
|
})),
|
|
} : {
|
|
// Only return quiz metadata and latest score, no questions
|
|
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,
|
|
allow_multiple_attempts: lesson.quiz.allow_multiple_attempts,
|
|
latest_attempt: latestQuizAttempt ? {
|
|
score: latestQuizAttempt.score,
|
|
is_passed: latestQuizAttempt.is_passed,
|
|
attempt_number: latestQuizAttempt.attempt_number,
|
|
started_at: latestQuizAttempt.started_at,
|
|
completed_at: latestQuizAttempt.completed_at,
|
|
} : undefined,
|
|
}) : 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);
|
|
const decoded = jwt.decode(input.token) as { id: number } | null;
|
|
await auditService.logSync({
|
|
userId: decoded?.id || 0,
|
|
action: AuditAction.ERROR,
|
|
entityType: 'Enrollment',
|
|
entityId: 0,
|
|
metadata: {
|
|
operation: 'get_course_learning',
|
|
error: error instanceof Error ? error.message : String(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);
|
|
const decoded = jwt.decode(input.token) as { id: number } | null;
|
|
await auditService.logSync({
|
|
userId: decoded?.id || 0,
|
|
action: AuditAction.ERROR,
|
|
entityType: 'Enrollment',
|
|
entityId: 0,
|
|
metadata: {
|
|
operation: 'get_course_learning',
|
|
error: error instanceof Error ? error.message : String(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);
|
|
const decoded = jwt.decode(input.token) as { id: number } | null;
|
|
await auditService.logSync({
|
|
userId: decoded?.id || 0,
|
|
action: AuditAction.ERROR,
|
|
entityType: 'Enrollment',
|
|
entityId: 0,
|
|
metadata: {
|
|
operation: 'get_course_learning',
|
|
error: error instanceof Error ? error.message : String(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 >= 95% OR when video_progress_seconds >= video_duration_seconds
|
|
const isCompleted = (progressPercentage !== null && progressPercentage >= 95) ||
|
|
!!(video_duration_seconds && video_progress_seconds >= video_duration_seconds);
|
|
|
|
// Save video progress (without marking complete yet)
|
|
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: false,
|
|
last_watched_at: new Date(),
|
|
},
|
|
update: {
|
|
video_progress_seconds,
|
|
video_duration_seconds: video_duration_seconds ?? null,
|
|
video_progress_percentage: progressPercentage,
|
|
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',
|
|
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: 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) {
|
|
logger.error(error);
|
|
const decoded = jwt.decode(input.token) as { id: number } | null;
|
|
await auditService.logSync({
|
|
userId: decoded?.id || 0,
|
|
action: AuditAction.ERROR,
|
|
entityType: 'Enrollment',
|
|
entityId: 0,
|
|
metadata: {
|
|
operation: 'get_course_learning',
|
|
error: error instanceof Error ? error.message : String(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');
|
|
}
|
|
|
|
// 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;
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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: lessonProgress.lesson_id,
|
|
is_completed: lessonProgress.is_completed,
|
|
completed_at: lessonProgress.completed_at!,
|
|
course_progress_percentage,
|
|
is_course_completed,
|
|
next_lesson_id,
|
|
certificate_issued,
|
|
},
|
|
};
|
|
} catch (error) {
|
|
logger.error(`Error completing lesson: ${error}`);
|
|
const decoded = jwt.decode(input.token) as { id: number } | null;
|
|
await auditService.logSync({
|
|
userId: decoded?.id || 0,
|
|
action: AuditAction.ERROR,
|
|
entityType: 'LessonProgress',
|
|
entityId: input.lesson_id,
|
|
metadata: {
|
|
operation: 'complete_lesson',
|
|
lesson_id: input.lesson_id,
|
|
error: error instanceof Error ? error.message : String(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, mark lesson as complete and update enrollment progress
|
|
let enrollmentProgress: { progress_percentage: number; is_course_completed: boolean } | undefined;
|
|
if (isPassed) {
|
|
const result = await this.markLessonComplete(decoded.id, lesson_id, course_id);
|
|
enrollmentProgress = result.enrollmentProgress;
|
|
}
|
|
|
|
// Build response based on show_answers_after_completion setting
|
|
const showAnswers = quiz.show_answers_after_completion;
|
|
|
|
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: showAnswers ? correctAnswers : undefined,
|
|
is_passed: isPassed,
|
|
passing_score: quiz.passing_score,
|
|
attempt_number: attemptNumber,
|
|
started_at: quizAttempt.started_at,
|
|
completed_at: quizAttempt.completed_at!,
|
|
answers_review: showAnswers ? answersReview : undefined,
|
|
course_progress_percentage: enrollmentProgress?.progress_percentage,
|
|
is_course_completed: enrollmentProgress?.is_course_completed,
|
|
},
|
|
};
|
|
} catch (error) {
|
|
logger.error(`Error submitting quiz: ${error}`);
|
|
const decoded = jwt.decode(input.token) as { id: number } | null;
|
|
await auditService.logSync({
|
|
userId: decoded?.id || 0,
|
|
action: AuditAction.ERROR,
|
|
entityType: 'QuizAttempt',
|
|
entityId: 0,
|
|
metadata: {
|
|
operation: 'submit_quiz',
|
|
course_id: input.course_id,
|
|
lesson_id: input.lesson_id,
|
|
error: error instanceof Error ? error.message : String(error)
|
|
}
|
|
});
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* ดึงคะแนน Quiz ที่เคยทำของนักเรียน
|
|
* Get student's quiz attempts for a lesson
|
|
*/
|
|
async getQuizAttempts(input: GetQuizAttemptsInput): Promise<GetQuizAttemptsResponse> {
|
|
try {
|
|
const { token, course_id, lesson_id } = 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: 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');
|
|
|
|
// Get all quiz attempts for this user
|
|
const attempts = await prisma.quizAttempt.findMany({
|
|
where: {
|
|
user_id: decoded.id,
|
|
quiz_id: lesson.quiz.id,
|
|
},
|
|
orderBy: { attempt_number: 'desc' },
|
|
});
|
|
|
|
// Calculate total score from questions
|
|
const totalScore = lesson.quiz.questions.reduce((sum, q) => sum + q.score, 0);
|
|
|
|
// Format attempts data
|
|
const attemptsData = attempts.map(attempt => ({
|
|
id: attempt.id,
|
|
quiz_id: attempt.quiz_id,
|
|
score: attempt.score,
|
|
total_score: totalScore,
|
|
total_questions: attempt.total_questions,
|
|
correct_answers: attempt.correct_answers,
|
|
is_passed: attempt.is_passed,
|
|
attempt_number: attempt.attempt_number,
|
|
started_at: attempt.started_at,
|
|
completed_at: attempt.completed_at,
|
|
}));
|
|
|
|
// Find best score and latest attempt
|
|
const bestScore = attempts.length > 0
|
|
? Math.max(...attempts.map(a => a.score))
|
|
: null;
|
|
const latestAttempt = attemptsData.length > 0 ? attemptsData[0] : null;
|
|
|
|
return {
|
|
code: 200,
|
|
message: 'Quiz attempts retrieved successfully',
|
|
data: {
|
|
lesson_id: lesson.id,
|
|
lesson_title: lesson.title as { th: string; en: string },
|
|
quiz_id: lesson.quiz.id,
|
|
quiz_title: lesson.quiz.title as { th: string; en: string },
|
|
passing_score: lesson.quiz.passing_score,
|
|
attempts: attemptsData,
|
|
best_score: bestScore,
|
|
latest_attempt: latestAttempt,
|
|
},
|
|
};
|
|
} catch (error) {
|
|
logger.error(error);
|
|
const decoded = jwt.decode(input.token) as { id: number } | null;
|
|
if (decoded?.id) {
|
|
await auditService.logSync({
|
|
userId: decoded.id,
|
|
action: AuditAction.ERROR,
|
|
entityType: 'QuizAttempt',
|
|
entityId: 0,
|
|
metadata: {
|
|
operation: 'get_quiz_attempts',
|
|
course_id: input.course_id,
|
|
lesson_id: input.lesson_id,
|
|
error: error instanceof Error ? error.message : String(error)
|
|
}
|
|
});
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
} |