elearning/Backend/src/services/CoursesInstructor.service.ts
JakkrapartXD c5aa195b13
All checks were successful
Build and Deploy Backend / Build Backend Docker Image (push) Successful in 24s
Build and Deploy Backend / Deploy E-learning Backend to Dev Server (push) Successful in 3s
Build and Deploy Backend / Notify Deployment Status (push) Successful in 1s
feat: implement course cloning functionality including chapters, lessons, quizzes, and attachments for instructors.
2026-02-13 17:41:01 +07:00

1675 lines
67 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 { uploadFile, deleteFile, getPresignedUrl } from '../config/minio';
import {
CreateCourseInput,
UpdateCourseInput,
createCourseResponse,
GetMyCourseResponse,
ListMyCoursesInput,
ListMyCourseResponse,
addinstructorCourse,
addinstructorCourseResponse,
removeinstructorCourse,
removeinstructorCourseResponse,
setprimaryCourseInstructor,
setprimaryCourseInstructorResponse,
submitCourseResponse,
listinstructorCourseResponse,
sendCourseForReview,
getmyCourse,
listinstructorCourse,
SearchInstructorInput,
SearchInstructorResponse,
GetEnrolledStudentsInput,
GetEnrolledStudentsResponse,
GetQuizScoresInput,
GetQuizScoresResponse,
GetQuizAttemptDetailInput,
GetQuizAttemptDetailResponse,
GetEnrolledStudentDetailInput,
GetEnrolledStudentDetailResponse,
GetCourseApprovalHistoryResponse,
CloneCourseInput,
CloneCourseResponse,
setCourseDraft,
setCourseDraftResponse,
} from "../types/CoursesInstructor.types";
import { auditService } from './audit.service';
import { AuditAction } from '@prisma/client';
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;
});
// Audit log - CREATE Course
auditService.log({
userId: userId,
action: AuditAction.CREATE,
entityType: 'Course',
entityId: result.id,
newValue: { title: courseData.title, slug: courseData.slug, status: 'DRAFT' }
});
return {
code: 201,
message: 'Course created successfully',
data: result
};
} catch (error) {
logger.error('Failed to create course', { error });
await auditService.logSync({
userId: userId,
action: AuditAction.ERROR,
entityType: 'Course',
entityId: 0, // Failed to create, so no ID
metadata: {
operation: 'create_course',
error: error instanceof Error ? error.message : String(error)
}
});
throw error;
}
}
static async listMyCourses(input: ListMyCoursesInput): Promise<ListMyCourseResponse> {
try {
const decoded = jwt.verify(input.token, config.jwt.secret) as { id: number; type: string };
const courseInstructors = await prisma.courseInstructor.findMany({
where: {
user_id: decoded.id,
course: input.status ? { status: input.status } : undefined
},
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 });
const decoded = jwt.decode(input.token) as { id: number } | null;
await auditService.logSync({
userId: decoded?.id || undefined,
action: AuditAction.ERROR,
entityType: 'Course',
entityId: 0,
metadata: {
operation: 'list_my_courses',
error: error instanceof Error ? error.message : String(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 });
const decoded = jwt.decode(getmyCourse.token) as { id: number } | null;
await auditService.logSync({
userId: decoded?.id || undefined,
action: AuditAction.ERROR,
entityType: 'Course',
entityId: getmyCourse.course_id,
metadata: {
operation: 'get_my_course',
error: error instanceof Error ? error.message : String(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 });
const decoded = jwt.decode(token) as { id: number } | null;
await auditService.logSync({
userId: decoded?.id || undefined,
action: AuditAction.ERROR,
entityType: 'Course',
entityId: courseId,
metadata: {
operation: 'update_course',
error: error instanceof Error ? error.message : String(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 });
const decoded = jwt.decode(token) as { id: number } | null;
await auditService.logSync({
userId: decoded?.id || undefined,
action: AuditAction.ERROR,
entityType: 'Course',
entityId: courseId,
metadata: {
operation: 'upload_thumbnail',
error: error instanceof Error ? error.message : String(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
}
});
await auditService.logSync({
userId: courseInstructorId.user_id,
action: AuditAction.DELETE,
entityType: 'Course',
entityId: courseId,
metadata: {
operation: 'delete_course'
}
});
return {
code: 200,
message: 'Course deleted successfully',
data: course
};
} catch (error) {
logger.error('Failed to delete course', { error });
const decoded = jwt.decode(token) as { id: number } | null;
await auditService.logSync({
userId: decoded?.id || undefined,
action: AuditAction.ERROR,
entityType: 'Course',
entityId: courseId,
metadata: {
operation: 'delete_course',
error: error instanceof Error ? error.message : String(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'
}
});
await auditService.logSync({
userId: decoded.id,
action: AuditAction.UPDATE,
entityType: 'Course',
entityId: sendCourseForReview.course_id,
metadata: {
operation: 'send_course_for_review'
}
});
return {
code: 200,
message: 'Course sent for review successfully',
};
} catch (error) {
logger.error('Failed to send course for review', { error });
const decoded = jwt.decode(sendCourseForReview.token) as { id: number } | null;
await auditService.logSync({
userId: decoded?.id || undefined,
action: AuditAction.ERROR,
entityType: 'Course',
entityId: sendCourseForReview.course_id,
metadata: {
operation: 'send_course_for_review',
error: error instanceof Error ? error.message : String(error)
}
});
throw error;
}
}
static async setCourseDraft(setCourseDraft: setCourseDraft): Promise<setCourseDraftResponse> {
try {
await this.validateCourseInstructor(setCourseDraft.token, setCourseDraft.course_id);
await prisma.course.update({
where: {
id: setCourseDraft.course_id,
status: 'REJECTED'
},
data: {
status: 'DRAFT'
}
});
return {
code: 200,
message: 'Set course to draft successfully',
};
} catch (error) {
logger.error('Failed to set course to draft', { error });
const decoded = jwt.decode(setCourseDraft.token) as { id: number } | null;
await auditService.logSync({
userId: decoded?.id || undefined,
action: AuditAction.ERROR,
entityType: 'Course',
entityId: setCourseDraft.course_id,
metadata: {
operation: 'set_course_draft',
error: error instanceof Error ? error.message : String(error)
}
});
throw error;
}
}
static async getCourseApprovals(token: string, courseId: number): Promise<{
code: number;
message: string;
data: any[];
total: number;
}> {
try {
// 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 });
const decoded = jwt.decode(token) as { id: number } | null;
await auditService.logSync({
userId: decoded?.id || undefined,
action: AuditAction.ERROR,
entityType: 'Course',
entityId: courseId,
metadata: {
operation: 'get_course_approvals',
error: error instanceof Error ? error.message : String(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 });
const decoded = jwt.decode(input.token) as { id: number } | null;
await auditService.logSync({
userId: decoded?.id || undefined,
action: AuditAction.ERROR,
entityType: 'Course',
entityId: input.course_id,
metadata: {
operation: 'search_instructors',
error: error instanceof Error ? error.message : String(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,
}
});
const decoded = jwt.decode(addinstructorCourse.token) as { id: number } | null;
await auditService.logSync({
userId: decoded?.id || 0,
action: AuditAction.CREATE,
entityType: 'Course',
entityId: addinstructorCourse.course_id,
metadata: {
operation: 'add_instructor_to_course',
instructor_id: user.id,
}
});
return {
code: 200,
message: 'Instructor added to course successfully',
};
} catch (error) {
logger.error('Failed to add instructor to course', { error });
const decoded = jwt.decode(addinstructorCourse.token) as { id: number } | null;
await auditService.logSync({
userId: decoded?.id || undefined,
action: AuditAction.ERROR,
entityType: 'Course',
entityId: addinstructorCourse.course_id,
metadata: {
operation: 'add_instructor_to_course',
error: error instanceof Error ? error.message : String(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,
},
}
});
await auditService.logSync({
userId: decoded?.id || 0,
action: AuditAction.DELETE,
entityType: 'Course',
entityId: removeinstructorCourse.course_id,
metadata: {
operation: 'remove_instructor_from_course',
instructor_id: removeinstructorCourse.user_id,
course_id: removeinstructorCourse.course_id,
}
});
return {
code: 200,
message: 'Instructor removed from course successfully',
};
} catch (error) {
logger.error('Failed to remove instructor from course', { error });
const decoded = jwt.decode(removeinstructorCourse.token) as { id: number } | null;
await auditService.logSync({
userId: decoded?.id || undefined,
action: AuditAction.ERROR,
entityType: 'Course',
entityId: removeinstructorCourse.course_id,
metadata: {
operation: 'remove_instructor_from_course',
error: error instanceof Error ? error.message : String(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 });
const decoded = jwt.decode(listinstructorCourse.token) as { id: number } | null;
await auditService.logSync({
userId: decoded?.id || undefined,
action: AuditAction.ERROR,
entityType: 'Course',
entityId: listinstructorCourse.course_id,
metadata: {
operation: 'list_instructors_of_course',
error: error instanceof Error ? error.message : String(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,
}
});
await auditService.logSync({
userId: decoded?.id || 0,
action: AuditAction.UPDATE,
entityType: 'Course',
entityId: setprimaryCourseInstructor.course_id,
metadata: {
operation: 'set_primary_instructor',
instructor_id: setprimaryCourseInstructor.user_id,
course_id: setprimaryCourseInstructor.course_id,
}
});
return {
code: 200,
message: 'Primary instructor set successfully',
};
} catch (error) {
logger.error('Failed to set primary instructor', { error });
const decoded = jwt.decode(setprimaryCourseInstructor.token) as { id: number } | null;
await auditService.logSync({
userId: decoded?.id || undefined,
action: AuditAction.ERROR,
entityType: 'Course',
entityId: setprimaryCourseInstructor.course_id,
metadata: {
operation: 'set_primary_instructor',
error: error instanceof Error ? error.message : String(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') {
throw new ForbiddenError('Course is already approved Cannot Edit');
}
if (course.status === 'PENDING') {
throw new ForbiddenError('Course is already pending 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;
// 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}`);
const decoded = jwt.decode(input.token) as { id: number } | null;
await auditService.logSync({
userId: decoded?.id || undefined,
action: AuditAction.ERROR,
entityType: 'Course',
entityId: input.course_id,
metadata: {
operation: 'get_enrolled_students',
error: error instanceof Error ? error.message : String(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}`);
const decoded = jwt.decode(input.token) as { id: number } | null;
await auditService.logSync({
userId: decoded?.id || undefined,
action: AuditAction.ERROR,
entityType: 'Course',
entityId: input.course_id,
metadata: {
operation: 'get_quiz_scores',
error: error instanceof Error ? error.message : String(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;
// 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}`);
const decoded = jwt.decode(input.token) as { id: number } | null;
await auditService.logSync({
userId: decoded?.id || undefined,
action: AuditAction.ERROR,
entityType: 'Course',
entityId: input.course_id,
metadata: {
operation: 'get_quiz_attempt_detail',
error: error instanceof Error ? error.message : String(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}`);
const decoded = jwt.decode(input.token) as { id: number } | null;
await auditService.logSync({
userId: decoded?.id || undefined,
action: AuditAction.ERROR,
entityType: 'Course',
entityId: input.course_id,
metadata: {
operation: 'get_enrolled_student_detail',
error: error instanceof Error ? error.message : String(error)
}
});
throw error;
}
}
/**
* ดึงประวัติการขออนุมัติคอร์ส
* Get course approval history for instructor to see rejection reasons
*/
static async getCourseApprovalHistory(token: string, courseId: number): Promise<GetCourseApprovalHistoryResponse> {
try {
const decoded = jwt.verify(token, config.jwt.secret) as { id: number };
// Validate instructor access
await this.validateCourseInstructor(token, courseId);
// Get course with approval history
const course = await prisma.course.findUnique({
where: { id: courseId },
include: {
courseApprovals: {
orderBy: { created_at: 'desc' },
include: {
submitter: {
select: { id: true, username: true, email: true }
},
reviewer: {
select: { id: true, username: true, email: true }
}
}
}
}
});
if (!course) {
throw new NotFoundError('Course not found');
}
return {
code: 200,
message: 'Course approval history retrieved successfully',
data: {
course_id: course.id,
course_title: course.title as { th: string; en: string },
current_status: course.status,
approval_history: course.courseApprovals.map(a => ({
id: a.id,
action: a.action,
comment: a.comment,
created_at: a.created_at,
submitter: a.submitter,
reviewer: a.reviewer
}))
}
};
} catch (error) {
logger.error(`Error getting course approval history: ${error}`);
const decoded = jwt.decode(token) as { id: number } | null;
await auditService.logSync({
userId: decoded?.id || undefined,
action: AuditAction.ERROR,
entityType: 'Course',
entityId: courseId,
metadata: {
operation: 'get_course_approval_history',
error: error instanceof Error ? error.message : String(error)
}
});
throw error;
}
}
/**
* Clone a course (including chapters, lessons, quizzes, attachments)
*/
static async cloneCourse(input: CloneCourseInput): Promise<CloneCourseResponse> {
try {
const { token, course_id, title } = input;
const decoded = jwt.verify(token, config.jwt.secret) as { id: number };
// Validate instructor
const courseInstructor = await this.validateCourseInstructor(token, course_id);
if (!courseInstructor) {
throw new ForbiddenError('You are not an instructor of this course');
}
// Fetch original course with all relations
const originalCourse = await prisma.course.findUnique({
where: { id: course_id },
include: {
chapters: {
orderBy: { sort_order: 'asc' },
include: {
lessons: {
orderBy: { sort_order: 'asc' },
include: {
attachments: true,
quiz: {
include: {
questions: {
include: {
choices: true
}
}
}
}
}
}
}
}
}
});
if (!originalCourse) {
throw new NotFoundError('Course not found');
}
// Use transaction for atomic creation
const newCourse = await prisma.$transaction(async (tx) => {
// 1. Create new Course
const createdCourse = await tx.course.create({
data: {
title: title as Prisma.InputJsonValue,
slug: `${originalCourse.slug}-clone-${Date.now()}`, // Temporary slug
description: originalCourse.description ?? Prisma.JsonNull,
thumbnail_url: originalCourse.thumbnail_url,
category_id: originalCourse.category_id,
price: originalCourse.price,
is_free: originalCourse.is_free,
have_certificate: originalCourse.have_certificate,
status: 'DRAFT', // Reset status
created_by: decoded.id
}
});
// 2. Add Instructor (Requester as primary)
await tx.courseInstructor.create({
data: {
course_id: createdCourse.id,
user_id: decoded.id,
is_primary: true
}
});
// Mapping for oldLessonId -> newLessonId for prerequisites
const lessonIdMap = new Map<number, number>();
const lessonsToUpdatePrerequisites: { newLessonId: number; oldPrerequisites: number[] }[] = [];
// 3. Clone Chapters and Lessons
for (const chapter of originalCourse.chapters) {
const newChapter = await tx.chapter.create({
data: {
course_id: createdCourse.id,
title: chapter.title as Prisma.InputJsonValue,
description: chapter.description ?? Prisma.JsonNull,
sort_order: chapter.sort_order,
is_published: chapter.is_published
}
});
for (const lesson of chapter.lessons) {
const newLesson = await tx.lesson.create({
data: {
chapter_id: newChapter.id,
title: lesson.title as Prisma.InputJsonValue,
content: lesson.content ?? Prisma.JsonNull,
type: lesson.type,
sort_order: lesson.sort_order,
is_published: lesson.is_published,
duration_minutes: lesson.duration_minutes,
prerequisite_lesson_ids: Prisma.JsonNull // Will update later
}
});
lessonIdMap.set(lesson.id, newLesson.id);
// Store prerequisites for later update
if (Array.isArray(lesson.prerequisite_lesson_ids) && lesson.prerequisite_lesson_ids.length > 0) {
lessonsToUpdatePrerequisites.push({
newLessonId: newLesson.id,
oldPrerequisites: lesson.prerequisite_lesson_ids as number[]
});
}
// Clone Attachments
if (lesson.attachments && lesson.attachments.length > 0) {
await tx.lessonAttachment.createMany({
data: lesson.attachments.map(att => ({
lesson_id: newLesson.id,
file_name: att.file_name,
file_path: att.file_path, // Reuse file path
file_size: att.file_size,
mime_type: att.mime_type,
sort_order: att.sort_order,
description: att.description ?? Prisma.JsonNull
}))
});
}
// Clone Quiz
if (lesson.quiz) {
const newQuiz = await tx.quiz.create({
data: {
lesson_id: newLesson.id,
title: lesson.quiz.title as Prisma.InputJsonValue,
description: lesson.quiz.description ?? Prisma.JsonNull,
passing_score: lesson.quiz.passing_score,
allow_multiple_attempts: lesson.quiz.allow_multiple_attempts,
time_limit: lesson.quiz.time_limit,
shuffle_questions: lesson.quiz.shuffle_questions,
shuffle_choices: lesson.quiz.shuffle_choices,
show_answers_after_completion: lesson.quiz.show_answers_after_completion,
created_by: decoded.id
}
});
for (const question of lesson.quiz.questions) {
await tx.question.create({
data: {
quiz_id: newQuiz.id,
question: question.question as Prisma.InputJsonValue,
explanation: question.explanation ?? Prisma.JsonNull,
question_type: question.question_type,
score: question.score,
sort_order: question.sort_order,
choices: {
create: question.choices.map(choice => ({
text: choice.text as Prisma.InputJsonValue,
is_correct: choice.is_correct,
sort_order: choice.sort_order
}))
}
}
});
}
}
}
}
// 4. Update Prerequisites
for (const item of lessonsToUpdatePrerequisites) {
const newPrerequisites = item.oldPrerequisites
.map(oldId => lessonIdMap.get(oldId))
.filter((id): id is number => id !== undefined);
if (newPrerequisites.length > 0) {
await tx.lesson.update({
where: { id: item.newLessonId },
data: {
prerequisite_lesson_ids: newPrerequisites
}
});
}
}
return createdCourse;
});
await auditService.logSync({
userId: decoded.id,
action: AuditAction.CREATE,
entityType: 'Course',
entityId: newCourse.id,
metadata: {
operation: 'clone_course',
original_course_id: course_id,
new_course_id: newCourse.id
}
});
return {
code: 201,
message: 'Course cloned successfully',
data: {
id: newCourse.id,
title: newCourse.title as { th: string; en: string }
}
};
} catch (error) {
logger.error(`Error cloning course: ${error}`);
const decoded = jwt.decode(input.token) as { id: number } | null;
await auditService.logSync({
userId: decoded?.id || 0,
action: AuditAction.ERROR,
entityType: 'Course',
entityId: input.course_id,
metadata: {
operation: 'clone_course',
error: error instanceof Error ? error.message : String(error)
}
});
throw error;
}
}
}