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

1093 lines
42 KiB
TypeScript
Raw Normal View History

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 { uploadFile, deleteFile, getPresignedUrl } from '../config/minio';
import {
CreateCourseInput,
UpdateCourseInput,
createCourseResponse,
GetMyCourseResponse,
ListMyCourseResponse,
addinstructorCourse,
addinstructorCourseResponse,
removeinstructorCourse,
removeinstructorCourseResponse,
setprimaryCourseInstructor,
setprimaryCourseInstructorResponse,
submitCourseResponse,
listinstructorCourseResponse,
sendCourseForReview,
getmyCourse,
listinstructorCourse,
SearchInstructorInput,
SearchInstructorResponse,
GetEnrolledStudentsInput,
GetEnrolledStudentsResponse,
GetQuizScoresInput,
GetQuizScoresResponse,
GetQuizAttemptDetailInput,
GetQuizAttemptDetailResponse,
GetEnrolledStudentDetailInput,
GetEnrolledStudentDetailResponse,
} from "../types/CoursesInstructor.types";
export class CoursesInstructorService {
static async createCourse(courseData: CreateCourseInput, userId: number, thumbnailFile?: Express.Multer.File): Promise<createCourseResponse> {
try {
let thumbnailUrl: string | undefined;
// Upload thumbnail to MinIO if provided
if (thumbnailFile) {
const timestamp = Date.now();
const uniqueId = Math.random().toString(36).substring(2, 15);
const extension = thumbnailFile.originalname.split('.').pop() || 'jpg';
const safeFilename = `${timestamp}-${uniqueId}.${extension}`;
const filePath = `courses/thumbnails/${safeFilename}`;
await uploadFile(filePath, thumbnailFile.buffer, thumbnailFile.mimetype || 'image/jpeg');
thumbnailUrl = filePath;
}
// Use transaction to create course and instructor together
const result = await prisma.$transaction(async (tx) => {
// Create the course
const courseCreated = await tx.course.create({
data: {
category_id: courseData.category_id,
title: courseData.title,
slug: courseData.slug,
description: courseData.description,
thumbnail_url: thumbnailUrl,
price: courseData.price || 0,
is_free: courseData.is_free ?? false,
have_certificate: courseData.have_certificate ?? false,
created_by: userId,
status: 'DRAFT'
}
});
// Add creator as primary instructor
await tx.courseInstructor.create({
data: {
course_id: courseCreated.id,
user_id: userId,
is_primary: true,
}
});
return courseCreated;
});
return {
code: 201,
message: 'Course created successfully',
data: result
};
} catch (error) {
logger.error('Failed to create course', { error });
throw error;
}
}
static async listMyCourses(token: string): Promise<ListMyCourseResponse> {
try {
const decoded = jwt.verify(token, config.jwt.secret) as { id: number; type: string };
const courseInstructors = await prisma.courseInstructor.findMany({
where: {
user_id: decoded.id
},
include: {
course: true
}
});
const courses = await Promise.all(
courseInstructors.map(async (ci) => {
let thumbnail_presigned_url: string | null = null;
if (ci.course.thumbnail_url) {
try {
thumbnail_presigned_url = await getPresignedUrl(ci.course.thumbnail_url, 3600);
} catch (err) {
logger.warn(`Failed to generate presigned URL for thumbnail: ${err}`);
}
}
return {
...ci.course,
thumbnail_url: thumbnail_presigned_url,
};
})
);
return {
code: 200,
message: 'Courses retrieved successfully',
data: courses,
total: courses.length
};
} catch (error) {
logger.error('Failed to retrieve courses', { error });
throw error;
}
}
static async getmyCourse(getmyCourse: getmyCourse): Promise<GetMyCourseResponse> {
try {
const decoded = jwt.verify(getmyCourse.token, config.jwt.secret) as { id: number; type: string };
// Check if user is instructor of this course
const courseInstructor = await prisma.courseInstructor.findFirst({
where: {
user_id: decoded.id,
course_id: getmyCourse.course_id
},
include: {
course: {
include: {
chapters: {
include: {
lessons: {
include: {
attachments: true,
progress: true,
quiz: true
}
}
}
}
}
}
}
});
if (!courseInstructor) {
throw new ForbiddenError('You are not an instructor of this course');
}
// Generate presigned URL for thumbnail
let thumbnail_presigned_url: string | null = null;
if (courseInstructor.course.thumbnail_url) {
try {
thumbnail_presigned_url = await getPresignedUrl(courseInstructor.course.thumbnail_url, 3600);
} catch (err) {
logger.warn(`Failed to generate presigned URL for thumbnail: ${err}`);
}
}
return {
code: 200,
message: 'Course retrieved successfully',
data: {
...courseInstructor.course,
thumbnail_url: thumbnail_presigned_url,
}
};
} catch (error) {
logger.error('Failed to retrieve course', { error });
throw error;
}
}
static async updateCourse(token: string, courseId: number, courseData: UpdateCourseInput): Promise<createCourseResponse> {
try {
await this.validateCourseInstructor(token, courseId);
const course = await prisma.course.update({
where: {
id: courseId
},
data: courseData
});
return {
code: 200,
message: 'Course updated successfully',
data: course
};
} catch (error) {
logger.error('Failed to update course', { error });
throw error;
}
}
static async uploadThumbnail(token: string, courseId: number, file: Express.Multer.File): Promise<{ code: number; message: string; data: { course_id: number; thumbnail_url: string } }> {
try {
await this.validateCourseInstructor(token, courseId);
// Get current course to check for existing thumbnail
const currentCourse = await prisma.course.findUnique({
where: { id: courseId }
});
// Delete old thumbnail if exists
if (currentCourse?.thumbnail_url) {
try {
await deleteFile(currentCourse.thumbnail_url);
logger.info(`Deleted old thumbnail: ${currentCourse.thumbnail_url}`);
} catch (error) {
logger.warn(`Failed to delete old thumbnail: ${error}`);
}
}
// Generate unique filename
const timestamp = Date.now();
const uniqueId = Math.random().toString(36).substring(2, 15);
const extension = file.originalname.split('.').pop() || 'jpg';
const safeFilename = `${timestamp}-${uniqueId}.${extension}`;
const filePath = `courses/${courseId}/thumbnail/${safeFilename}`;
// Upload to MinIO
await uploadFile(filePath, file.buffer, file.mimetype || 'image/jpeg');
logger.info(`Uploaded thumbnail: ${filePath}`);
// Update course with new thumbnail path
await prisma.course.update({
where: { id: courseId },
data: { thumbnail_url: filePath }
});
// Generate presigned URL for response
const presignedUrl = await getPresignedUrl(filePath, 3600);
return {
code: 200,
message: 'Thumbnail uploaded successfully',
data: {
course_id: courseId,
thumbnail_url: presignedUrl
}
};
} catch (error) {
logger.error('Failed to upload thumbnail', { error });
throw error;
}
}
static async deleteCourse(token: string, courseId: number): Promise<createCourseResponse> {
try {
const courseInstructorId = await this.validateCourseInstructor(token, courseId);
if (!courseInstructorId.is_primary) {
throw new ForbiddenError('You have no permission to delete this course');
}
const course = await prisma.course.delete({
where: {
id: courseId
}
});
return {
code: 200,
message: 'Course deleted successfully',
data: course
};
} catch (error) {
logger.error('Failed to delete course', { error });
throw error;
}
}
static async sendCourseForReview(sendCourseForReview: sendCourseForReview): Promise<submitCourseResponse> {
try {
const decoded = jwt.verify(sendCourseForReview.token, config.jwt.secret) as { id: number; type: string };
await prisma.courseApproval.create({
data: {
course_id: sendCourseForReview.course_id,
submitted_by: decoded.id,
}
});
await prisma.course.update({
where: {
id: sendCourseForReview.course_id
},
data: {
status: 'PENDING'
}
});
return {
code: 200,
message: 'Course sent for review successfully',
};
} catch (error) {
logger.error('Failed to send course for review', { error });
throw error;
}
}
2026-01-23 13:16:41 +07:00
static async getCourseApprovals(token: string, courseId: number): Promise<{
code: number;
message: string;
data: any[];
total: number;
}> {
try {
const decoded = jwt.verify(token, config.jwt.secret) as { id: number; type: string };
// Validate instructor access
await this.validateCourseInstructor(token, courseId);
const approvals = await prisma.courseApproval.findMany({
where: { course_id: courseId },
orderBy: { created_at: 'desc' },
include: {
submitter: {
select: { id: true, username: true, email: true }
},
reviewer: {
select: { id: true, username: true, email: true }
}
}
});
return {
code: 200,
message: 'Course approvals retrieved successfully',
data: approvals,
total: approvals.length,
};
} catch (error) {
logger.error('Failed to retrieve course approvals', { error });
throw error;
}
}
static async searchInstructors(input: SearchInstructorInput): Promise<SearchInstructorResponse> {
try {
const decoded = jwt.verify(input.token, config.jwt.secret) as { id: number };
// Get existing instructors in the course
const existingInstructors = await prisma.courseInstructor.findMany({
where: { course_id: input.course_id },
select: { user_id: true },
});
const existingInstructorIds = existingInstructors.map(i => i.user_id);
// Search all instructors by email or username, excluding self and existing course instructors
const users = await prisma.user.findMany({
where: {
OR: [
{ email: { contains: input.query, mode: 'insensitive' } },
{ username: { contains: input.query, mode: 'insensitive' } },
],
role: { code: 'INSTRUCTOR' },
id: {
notIn: [decoded.id, ...existingInstructorIds],
},
},
include: {
profile: true
},
take: 10
});
const results = await Promise.all(users.map(async (user) => {
let avatar_url: string | null = null;
if (user.profile?.avatar_url) {
try {
avatar_url = await getPresignedUrl(user.profile.avatar_url, 3600);
} catch (err) {
logger.warn(`Failed to generate presigned URL for avatar: ${err}`);
}
}
return {
id: user.id,
username: user.username,
email: user.email,
first_name: user.profile?.first_name || null,
last_name: user.profile?.last_name || null,
avatar_url,
};
}));
return {
code: 200,
message: 'Instructors found',
data: results,
};
} catch (error) {
logger.error('Failed to search instructors', { error });
throw error;
}
}
static async addInstructorToCourse(addinstructorCourse: addinstructorCourse): Promise<addinstructorCourseResponse> {
try {
// Validate user is instructor of this course
await this.validateCourseInstructor(addinstructorCourse.token, addinstructorCourse.course_id);
// Find user by email or username
const user = await prisma.user.findFirst({
where: {
OR: [
{ email: addinstructorCourse.email_or_username },
{ username: addinstructorCourse.email_or_username },
],
role: { code: 'INSTRUCTOR' }
}
});
if (!user) {
throw new NotFoundError('Instructor not found with this email or username');
}
// Check if already added
const existing = await prisma.courseInstructor.findUnique({
where: {
course_id_user_id: {
course_id: addinstructorCourse.course_id,
user_id: user.id,
}
}
});
if (existing) {
throw new ValidationError('This instructor is already added to the course');
}
await prisma.courseInstructor.create({
data: {
course_id: addinstructorCourse.course_id,
user_id: user.id,
}
});
return {
code: 200,
message: 'Instructor added to course successfully',
};
} catch (error) {
logger.error('Failed to add instructor to course', { error });
throw error;
}
}
static async removeInstructorFromCourse(removeinstructorCourse: removeinstructorCourse): Promise<removeinstructorCourseResponse> {
try {
const decoded = jwt.verify(removeinstructorCourse.token, config.jwt.secret) as { id: number; type: string };
await prisma.courseInstructor.delete({
where: {
course_id_user_id: {
course_id: removeinstructorCourse.course_id,
user_id: removeinstructorCourse.user_id,
},
}
});
return {
code: 200,
message: 'Instructor removed from course successfully',
};
} catch (error) {
logger.error('Failed to remove instructor from course', { error });
throw error;
}
}
static async listInstructorsOfCourse(listinstructorCourse: listinstructorCourse): Promise<listinstructorCourseResponse> {
try {
const decoded = jwt.verify(listinstructorCourse.token, config.jwt.secret) as { id: number; type: string };
const courseInstructors = await prisma.courseInstructor.findMany({
where: {
course_id: listinstructorCourse.course_id,
},
include: {
user: {
include: {
profile: true
}
},
}
});
const data = await Promise.all(courseInstructors.map(async (ci) => {
let avatar_url: string | null = null;
if (ci.user.profile?.avatar_url) {
try {
avatar_url = await getPresignedUrl(ci.user.profile.avatar_url, 3600);
} catch (err) {
logger.warn(`Failed to generate presigned URL for avatar: ${err}`);
}
}
return {
user_id: ci.user_id,
is_primary: ci.is_primary,
user: {
id: ci.user.id,
username: ci.user.username,
email: ci.user.email,
first_name: ci.user.profile?.first_name || null,
last_name: ci.user.profile?.last_name || null,
avatar_url,
},
};
}));
return {
code: 200,
message: 'Instructors retrieved successfully',
data,
};
} catch (error) {
logger.error('Failed to retrieve instructors of course', { error });
throw error;
}
}
static async setPrimaryInstructor(setprimaryCourseInstructor: setprimaryCourseInstructor): Promise<setprimaryCourseInstructorResponse> {
try {
const decoded = jwt.verify(setprimaryCourseInstructor.token, config.jwt.secret) as { id: number; type: string };
await prisma.courseInstructor.update({
where: {
course_id_user_id: {
course_id: setprimaryCourseInstructor.course_id,
user_id: setprimaryCourseInstructor.user_id,
},
},
data: {
is_primary: true,
}
});
return {
code: 200,
message: 'Primary instructor set successfully',
};
} catch (error) {
logger.error('Failed to set primary instructor', { error });
throw error;
}
}
static async validateCourseInstructor(token: string, courseId: number): Promise<{ user_id: number; is_primary: boolean }> {
const decoded = jwt.verify(token, config.jwt.secret) as { id: number; type: string };
const courseInstructor = await prisma.courseInstructor.findFirst({
where: {
user_id: decoded.id,
course_id: courseId
}
});
if (!courseInstructor) {
throw new ForbiddenError('You are not an instructor of this course');
} else return { user_id: courseInstructor.user_id, is_primary: courseInstructor.is_primary };
}
static async validateCourseStatus(courseId: number): Promise<void> {
const course = await prisma.course.findUnique({ where: { id: courseId } });
if (!course) {
throw new NotFoundError('Course not found');
}
if (course.status === 'APPROVED' || course.status === 'PENDING') {
throw new ForbiddenError('Course is already approved Cannot Edit');
}
}
/**
* progress
* Get all enrolled students with their progress
*/
static async getEnrolledStudents(input: GetEnrolledStudentsInput): Promise<GetEnrolledStudentsResponse> {
try {
const { token, course_id, page = 1, limit = 20, search, status } = input;
const decoded = jwt.verify(token, config.jwt.secret) as { id: number };
// Validate instructor
await this.validateCourseInstructor(token, course_id);
// Build where clause
const whereClause: any = { course_id };
// Add status filter
if (status) {
whereClause.status = status;
}
// Add search filter
if (search) {
whereClause.OR = [
{ user: { username: { contains: search, mode: 'insensitive' } } },
{ user: { email: { contains: search, mode: 'insensitive' } } },
{ user: { profile: { first_name: { contains: search, mode: 'insensitive' } } } },
{ user: { profile: { last_name: { contains: search, mode: 'insensitive' } } } },
];
}
// Get enrollments with user data
const skip = (page - 1) * limit;
const [enrollments, total] = await Promise.all([
prisma.enrollment.findMany({
where: whereClause,
include: {
user: {
include: {
profile: true,
},
},
},
orderBy: { enrolled_at: 'desc' },
skip,
take: limit,
}),
prisma.enrollment.count({ where: whereClause }),
]);
// Format response with presigned URLs for avatars
const studentsData = await Promise.all(
enrollments.map(async (enrollment) => {
let avatarUrl: string | null = null;
if (enrollment.user.profile?.avatar_url) {
try {
avatarUrl = await getPresignedUrl(enrollment.user.profile.avatar_url, 3600);
} catch (err) {
logger.warn(`Failed to generate presigned URL for avatar: ${err}`);
}
}
return {
user_id: enrollment.user.id,
username: enrollment.user.username,
email: enrollment.user.email,
first_name: enrollment.user.profile?.first_name || null,
last_name: enrollment.user.profile?.last_name || null,
avatar_url: avatarUrl,
enrolled_at: enrollment.enrolled_at,
progress_percentage: Number(enrollment.progress_percentage) || 0,
status: enrollment.status,
};
})
);
return {
code: 200,
message: 'Enrolled students retrieved successfully',
data: studentsData,
total,
page,
limit,
};
} catch (error) {
logger.error(`Error getting enrolled students: ${error}`);
throw error;
}
}
/**
* lesson quiz
* Get quiz scores of all students for a specific lesson
*/
static async getQuizScores(input: GetQuizScoresInput): Promise<GetQuizScoresResponse> {
try {
const { token, course_id, lesson_id, page = 1, limit = 20, search, is_passed } = input;
const decoded = jwt.verify(token, config.jwt.secret) as { id: number };
// Validate instructor
await this.validateCourseInstructor(token, course_id);
// 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');
}
// Calculate total score from questions
const totalScore = lesson.quiz.questions.reduce((sum, q) => sum + q.score, 0);
// Get all enrolled students who have attempted this quiz
const skip = (page - 1) * limit;
// Get unique users who attempted this quiz
const quizAttempts = await prisma.quizAttempt.findMany({
where: { quiz_id: lesson.quiz.id },
include: {
user: {
include: {
profile: true,
},
},
},
orderBy: { completed_at: 'desc' },
});
// Group attempts by user
const userAttemptsMap = new Map<number, typeof quizAttempts>();
for (const attempt of quizAttempts) {
const userId = attempt.user_id;
if (!userAttemptsMap.has(userId)) {
userAttemptsMap.set(userId, []);
}
userAttemptsMap.get(userId)!.push(attempt);
}
// Get unique users and apply filters
let filteredUserIds = Array.from(userAttemptsMap.keys());
// Apply search filter
if (search) {
filteredUserIds = filteredUserIds.filter(userId => {
const userAttempts = userAttemptsMap.get(userId)!;
const user = userAttempts[0].user;
const searchLower = search.toLowerCase();
return (
user.username.toLowerCase().includes(searchLower) ||
user.email.toLowerCase().includes(searchLower) ||
(user.profile?.first_name?.toLowerCase().includes(searchLower)) ||
(user.profile?.last_name?.toLowerCase().includes(searchLower))
);
});
}
// Apply is_passed filter
if (is_passed !== undefined) {
filteredUserIds = filteredUserIds.filter(userId => {
const userAttempts = userAttemptsMap.get(userId)!;
const userIsPassed = userAttempts.some(a => a.is_passed);
return userIsPassed === is_passed;
});
}
// Paginate
const total = filteredUserIds.length;
const paginatedUserIds = filteredUserIds.slice(skip, skip + limit);
// Format response
const studentsData = await Promise.all(
paginatedUserIds.map(async (userId) => {
const userAttempts = userAttemptsMap.get(userId)!;
const user = userAttempts[0].user;
let avatarUrl: string | null = null;
if (user.profile?.avatar_url) {
try {
avatarUrl = await getPresignedUrl(user.profile.avatar_url, 3600);
} catch (err) {
logger.warn(`Failed to generate presigned URL for avatar: ${err}`);
}
}
// Get latest attempt (sorted by attempt_number desc, first one is latest)
const latestAttempt = userAttempts[0];
const bestScore = Math.max(...userAttempts.map(a => a.score));
const isPassed = userAttempts.some(a => a.is_passed);
return {
user_id: user.id,
username: user.username,
email: user.email,
first_name: user.profile?.first_name || null,
last_name: user.profile?.last_name || null,
avatar_url: avatarUrl,
latest_attempt: latestAttempt ? {
id: latestAttempt.id,
score: latestAttempt.score,
total_score: totalScore,
is_passed: latestAttempt.is_passed,
attempt_number: latestAttempt.attempt_number,
completed_at: latestAttempt.completed_at,
} : null,
best_score: bestScore,
total_attempts: userAttempts.length,
is_passed: isPassed,
};
})
);
return {
code: 200,
message: 'Quiz scores 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,
total_score: totalScore,
students: studentsData,
},
total,
page,
limit,
};
} catch (error) {
logger.error(`Error getting quiz scores: ${error}`);
throw error;
}
}
/**
*
* Get quiz attempt detail for a specific student
*/
static async getQuizAttemptDetail(input: GetQuizAttemptDetailInput): Promise<GetQuizAttemptDetailResponse> {
try {
const { token, course_id, lesson_id, student_id } = input;
const decoded = jwt.verify(token, config.jwt.secret) as { id: number };
// Validate instructor
await this.validateCourseInstructor(token, course_id);
// 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,
},
orderBy: { sort_order: 'asc' },
},
},
},
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');
if (lesson.chapter.course_id !== course_id) throw new NotFoundError('Lesson not found in this course');
// Get student info
const student = await prisma.user.findUnique({
where: { id: student_id },
include: { profile: true },
});
if (!student) throw new NotFoundError('Student not found');
// Get latest quiz attempt
const quizAttempt = await prisma.quizAttempt.findFirst({
where: {
user_id: student_id,
quiz_id: lesson.quiz.id,
},
orderBy: { attempt_number: 'desc' },
});
if (!quizAttempt) throw new NotFoundError('Quiz attempt not found');
// Calculate total score from questions
const totalScore = lesson.quiz.questions.reduce((sum, q) => sum + q.score, 0);
// Parse answers from quiz attempt
const studentAnswers = (quizAttempt.answers as any[]) || [];
// Build answers review
const answersReview = lesson.quiz.questions.map(question => {
const studentAnswer = studentAnswers.find((a: any) => a.question_id === question.id);
const selectedChoiceId = studentAnswer?.choice_id || null;
const selectedChoice = selectedChoiceId ? question.choices.find(c => c.id === selectedChoiceId) : null;
// Check if selected choice is correct from Choice model
const isCorrect = selectedChoice?.is_correct || false;
// Score is question.score if correct, otherwise 0
const earnedScore = isCorrect ? question.score : 0;
return {
question_id: question.id,
sort_order: question.sort_order,
question_text: question.question as { th: string; en: string },
selected_choice_id: selectedChoiceId,
selected_choice_text: selectedChoice ? selectedChoice.text as { th: string; en: string } : null,
is_correct: isCorrect,
score: earnedScore,
question_score: question.score,
};
});
return {
code: 200,
message: 'Quiz attempt detail retrieved successfully',
data: {
attempt_id: quizAttempt.id,
quiz_id: quizAttempt.quiz_id,
student: {
user_id: student.id,
username: student.username,
email: student.email,
first_name: student.profile?.first_name || null,
last_name: student.profile?.last_name || null,
},
score: quizAttempt.score,
total_score: totalScore,
total_questions: quizAttempt.total_questions,
correct_answers: quizAttempt.correct_answers,
is_passed: quizAttempt.is_passed,
passing_score: lesson.quiz.passing_score,
attempt_number: quizAttempt.attempt_number,
started_at: quizAttempt.started_at,
completed_at: quizAttempt.completed_at,
answers_review: answersReview,
},
};
} catch (error) {
logger.error(`Error getting quiz attempt detail: ${error}`);
throw error;
}
}
/**
*
* Get enrolled student detail with lesson progress
*/
static async getEnrolledStudentDetail(input: GetEnrolledStudentDetailInput): Promise<GetEnrolledStudentDetailResponse> {
try {
const { token, course_id, student_id } = input;
// Validate instructor
await this.validateCourseInstructor(token, course_id);
// Get student info
const student = await prisma.user.findUnique({
where: { id: student_id },
include: { profile: true },
});
if (!student) throw new NotFoundError('Student not found');
// Get enrollment
const enrollment = await prisma.enrollment.findUnique({
where: {
unique_enrollment: {
user_id: student_id,
course_id,
},
},
});
if (!enrollment) throw new NotFoundError('Student is not enrolled in this course');
// Get course with chapters and lessons
const course = await prisma.course.findUnique({
where: { id: course_id },
include: {
chapters: {
orderBy: { sort_order: 'asc' },
include: {
lessons: {
orderBy: { sort_order: 'asc' },
},
},
},
},
});
if (!course) throw new NotFoundError('Course not found');
// Get all lesson progress for this student in this course
const lessonIds = course.chapters.flatMap(ch => ch.lessons.map(l => l.id));
const lessonProgressList = await prisma.lessonProgress.findMany({
where: {
user_id: student_id,
lesson_id: { in: lessonIds },
},
});
// Create a map for quick lookup
const progressMap = new Map(lessonProgressList.map(p => [p.lesson_id, p]));
// Build chapters with lesson progress
let totalCompletedLessons = 0;
let totalLessons = 0;
const chaptersData = course.chapters.map(chapter => {
const lessonsData = chapter.lessons.map(lesson => {
const progress = progressMap.get(lesson.id);
totalLessons++;
if (progress?.is_completed) {
totalCompletedLessons++;
}
return {
lesson_id: lesson.id,
lesson_title: lesson.title as { th: string; en: string },
lesson_type: lesson.type,
sort_order: lesson.sort_order,
is_completed: progress?.is_completed || false,
completed_at: progress?.completed_at || null,
video_progress_seconds: progress?.video_progress_seconds || null,
video_duration_seconds: progress?.video_duration_seconds || null,
video_progress_percentage: progress?.video_progress_percentage ? Number(progress.video_progress_percentage) : null,
last_watched_at: progress?.last_watched_at || null,
};
});
const completedLessons = lessonsData.filter(l => l.is_completed).length;
return {
chapter_id: chapter.id,
chapter_title: chapter.title as { th: string; en: string },
sort_order: chapter.sort_order,
lessons: lessonsData,
completed_lessons: completedLessons,
total_lessons: lessonsData.length,
};
});
// Get avatar URL
let avatarUrl: string | null = null;
if (student.profile?.avatar_url) {
try {
avatarUrl = await getPresignedUrl(student.profile.avatar_url, 3600);
} catch (err) {
logger.warn(`Failed to generate presigned URL for avatar: ${err}`);
}
}
return {
code: 200,
message: 'Enrolled student detail retrieved successfully',
data: {
student: {
user_id: student.id,
username: student.username,
email: student.email,
first_name: student.profile?.first_name || null,
last_name: student.profile?.last_name || null,
avatar_url: avatarUrl,
},
enrollment: {
enrolled_at: enrollment.enrolled_at,
progress_percentage: Number(enrollment.progress_percentage) || 0,
status: enrollment.status,
},
chapters: chaptersData,
total_completed_lessons: totalCompletedLessons,
total_lessons: totalLessons,
},
};
} catch (error) {
logger.error(`Error getting enrolled student detail: ${error}`);
throw error;
}
}
}