1675 lines
67 KiB
TypeScript
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;
|
|
}
|
|
}
|
|
}
|