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

583 lines
22 KiB
TypeScript
Raw Normal View History

import { prisma } from '../config/database';
import { Prisma } from '@prisma/client';
import { config } from '../config';
import { logger } from '../config/logger';
import { UnauthorizedError, ValidationError, ForbiddenError, NotFoundError } from '../middleware/errorHandler';
import jwt from 'jsonwebtoken';
import { uploadFile, deleteFile, getPresignedUrl } from '../config/minio';
import {
CreateCourseInput,
UpdateCourseInput,
createCourseResponse,
GetMyCourseResponse,
ListMyCourseResponse,
addinstructorCourse,
addinstructorCourseResponse,
removeinstructorCourse,
removeinstructorCourseResponse,
setprimaryCourseInstructor,
setprimaryCourseInstructorResponse,
submitCourseResponse,
listinstructorCourseResponse,
sendCourseForReview,
getmyCourse,
listinstructorCourse,
SearchInstructorInput,
SearchInstructorResponse,
} from "../types/CoursesInstructor.types";
export class CoursesInstructorService {
static async createCourse(courseData: CreateCourseInput, userId: number, thumbnailFile?: Express.Multer.File): Promise<createCourseResponse> {
try {
let thumbnailUrl: string | undefined;
// Upload thumbnail to MinIO if provided
if (thumbnailFile) {
const timestamp = Date.now();
const uniqueId = Math.random().toString(36).substring(2, 15);
const extension = thumbnailFile.originalname.split('.').pop() || 'jpg';
const safeFilename = `${timestamp}-${uniqueId}.${extension}`;
const filePath = `courses/thumbnails/${safeFilename}`;
await uploadFile(filePath, thumbnailFile.buffer, thumbnailFile.mimetype || 'image/jpeg');
thumbnailUrl = filePath;
}
// Use transaction to create course and instructor together
const result = await prisma.$transaction(async (tx) => {
// Create the course
const courseCreated = await tx.course.create({
data: {
category_id: courseData.category_id,
title: courseData.title,
slug: courseData.slug,
description: courseData.description,
thumbnail_url: thumbnailUrl,
price: courseData.price || 0,
is_free: courseData.is_free ?? false,
have_certificate: courseData.have_certificate ?? false,
created_by: userId,
status: 'DRAFT'
}
});
// Add creator as primary instructor
await tx.courseInstructor.create({
data: {
course_id: courseCreated.id,
user_id: userId,
is_primary: true,
}
});
return courseCreated;
});
return {
code: 201,
message: 'Course created successfully',
data: result
};
} catch (error) {
logger.error('Failed to create course', { error });
throw error;
}
}
static async listMyCourses(token: string): Promise<ListMyCourseResponse> {
try {
const decoded = jwt.verify(token, config.jwt.secret) as { id: number; type: string };
const courseInstructors = await prisma.courseInstructor.findMany({
where: {
user_id: decoded.id
},
include: {
course: true
}
});
const courses = await Promise.all(
courseInstructors.map(async (ci) => {
let thumbnail_presigned_url: string | null = null;
if (ci.course.thumbnail_url) {
try {
thumbnail_presigned_url = await getPresignedUrl(ci.course.thumbnail_url, 3600);
} catch (err) {
logger.warn(`Failed to generate presigned URL for thumbnail: ${err}`);
}
}
return {
...ci.course,
thumbnail_url: thumbnail_presigned_url,
};
})
);
return {
code: 200,
message: 'Courses retrieved successfully',
data: courses,
total: courses.length
};
} catch (error) {
logger.error('Failed to retrieve courses', { error });
throw error;
}
}
static async getmyCourse(getmyCourse: getmyCourse): Promise<GetMyCourseResponse> {
try {
const decoded = jwt.verify(getmyCourse.token, config.jwt.secret) as { id: number; type: string };
// Check if user is instructor of this course
const courseInstructor = await prisma.courseInstructor.findFirst({
where: {
user_id: decoded.id,
course_id: getmyCourse.course_id
},
include: {
course: {
include: {
chapters: {
include: {
lessons: {
include: {
attachments: true,
progress: true,
quiz: true
}
}
}
}
}
}
}
});
if (!courseInstructor) {
throw new ForbiddenError('You are not an instructor of this course');
}
// Generate presigned URL for thumbnail
let thumbnail_presigned_url: string | null = null;
if (courseInstructor.course.thumbnail_url) {
try {
thumbnail_presigned_url = await getPresignedUrl(courseInstructor.course.thumbnail_url, 3600);
} catch (err) {
logger.warn(`Failed to generate presigned URL for thumbnail: ${err}`);
}
}
return {
code: 200,
message: 'Course retrieved successfully',
data: {
...courseInstructor.course,
thumbnail_url: thumbnail_presigned_url,
}
};
} catch (error) {
logger.error('Failed to retrieve course', { error });
throw error;
}
}
static async updateCourse(token: string, courseId: number, courseData: UpdateCourseInput): Promise<createCourseResponse> {
try {
await this.validateCourseInstructor(token, courseId);
const course = await prisma.course.update({
where: {
id: courseId
},
data: courseData
});
return {
code: 200,
message: 'Course updated successfully',
data: course
};
} catch (error) {
logger.error('Failed to update course', { error });
throw error;
}
}
static async uploadThumbnail(token: string, courseId: number, file: Express.Multer.File): Promise<{ code: number; message: string; data: { course_id: number; thumbnail_url: string } }> {
try {
await this.validateCourseInstructor(token, courseId);
// Get current course to check for existing thumbnail
const currentCourse = await prisma.course.findUnique({
where: { id: courseId }
});
// Delete old thumbnail if exists
if (currentCourse?.thumbnail_url) {
try {
await deleteFile(currentCourse.thumbnail_url);
logger.info(`Deleted old thumbnail: ${currentCourse.thumbnail_url}`);
} catch (error) {
logger.warn(`Failed to delete old thumbnail: ${error}`);
}
}
// Generate unique filename
const timestamp = Date.now();
const uniqueId = Math.random().toString(36).substring(2, 15);
const extension = file.originalname.split('.').pop() || 'jpg';
const safeFilename = `${timestamp}-${uniqueId}.${extension}`;
const filePath = `courses/${courseId}/thumbnail/${safeFilename}`;
// Upload to MinIO
await uploadFile(filePath, file.buffer, file.mimetype || 'image/jpeg');
logger.info(`Uploaded thumbnail: ${filePath}`);
// Update course with new thumbnail path
await prisma.course.update({
where: { id: courseId },
data: { thumbnail_url: filePath }
});
// Generate presigned URL for response
const presignedUrl = await getPresignedUrl(filePath, 3600);
return {
code: 200,
message: 'Thumbnail uploaded successfully',
data: {
course_id: courseId,
thumbnail_url: presignedUrl
}
};
} catch (error) {
logger.error('Failed to upload thumbnail', { error });
throw error;
}
}
static async deleteCourse(token: string, courseId: number): Promise<createCourseResponse> {
try {
const courseInstructorId = await this.validateCourseInstructor(token, courseId);
if (!courseInstructorId.is_primary) {
throw new ForbiddenError('You have no permission to delete this course');
}
const course = await prisma.course.delete({
where: {
id: courseId
}
});
return {
code: 200,
message: 'Course deleted successfully',
data: course
};
} catch (error) {
logger.error('Failed to delete course', { error });
throw error;
}
}
static async sendCourseForReview(sendCourseForReview: sendCourseForReview): Promise<submitCourseResponse> {
try {
const decoded = jwt.verify(sendCourseForReview.token, config.jwt.secret) as { id: number; type: string };
await prisma.courseApproval.create({
data: {
course_id: sendCourseForReview.course_id,
submitted_by: decoded.id,
}
});
await prisma.course.update({
where: {
id: sendCourseForReview.course_id
},
data: {
status: 'PENDING'
}
});
return {
code: 200,
message: 'Course sent for review successfully',
};
} catch (error) {
logger.error('Failed to send course for review', { error });
throw error;
}
}
2026-01-23 13:16:41 +07:00
static async getCourseApprovals(token: string, courseId: number): Promise<{
code: number;
message: string;
data: any[];
total: number;
}> {
try {
const decoded = jwt.verify(token, config.jwt.secret) as { id: number; type: string };
// Validate instructor access
await this.validateCourseInstructor(token, courseId);
const approvals = await prisma.courseApproval.findMany({
where: { course_id: courseId },
orderBy: { created_at: 'desc' },
include: {
submitter: {
select: { id: true, username: true, email: true }
},
reviewer: {
select: { id: true, username: true, email: true }
}
}
});
return {
code: 200,
message: 'Course approvals retrieved successfully',
data: approvals,
total: approvals.length,
};
} catch (error) {
logger.error('Failed to retrieve course approvals', { error });
throw error;
}
}
static async searchInstructors(input: SearchInstructorInput): Promise<SearchInstructorResponse> {
try {
const decoded = jwt.verify(input.token, config.jwt.secret) as { id: number };
// Validate user is instructor of this course
await this.validateCourseInstructor(input.token, input.course_id);
// Get existing instructors of this course
const existingInstructors = await prisma.courseInstructor.findMany({
where: { course_id: input.course_id },
select: { user_id: true }
});
const existingUserIds = existingInstructors.map(i => i.user_id);
// Search users by email or username (only instructors role)
const users = await prisma.user.findMany({
where: {
AND: [
{
OR: [
{ email: { contains: input.query, mode: 'insensitive' } },
{ username: { contains: input.query, mode: 'insensitive' } },
]
},
{ role: { code: 'instructor' } },
{ id: { notIn: existingUserIds } } // Exclude already added instructors
]
},
include: {
profile: true
},
take: 10
});
const results = await Promise.all(users.map(async (user) => {
let avatar_url: string | null = null;
if (user.profile?.avatar_url) {
try {
avatar_url = await getPresignedUrl(user.profile.avatar_url, 3600);
} catch (err) {
logger.warn(`Failed to generate presigned URL for avatar: ${err}`);
}
}
return {
id: user.id,
username: user.username,
email: user.email,
first_name: user.profile?.first_name || null,
last_name: user.profile?.last_name || null,
avatar_url,
};
}));
return {
code: 200,
message: 'Instructors found',
data: results,
};
} catch (error) {
logger.error('Failed to search instructors', { error });
throw error;
}
}
static async addInstructorToCourse(addinstructorCourse: addinstructorCourse): Promise<addinstructorCourseResponse> {
try {
// Validate user is instructor of this course
await this.validateCourseInstructor(addinstructorCourse.token, addinstructorCourse.course_id);
// Find user by email or username
const user = await prisma.user.findFirst({
where: {
OR: [
{ email: addinstructorCourse.email_or_username },
{ username: addinstructorCourse.email_or_username },
],
role: { code: 'instructor' }
}
});
if (!user) {
throw new NotFoundError('Instructor not found with this email or username');
}
// Check if already added
const existing = await prisma.courseInstructor.findUnique({
where: {
course_id_user_id: {
course_id: addinstructorCourse.course_id,
user_id: user.id,
}
}
});
if (existing) {
throw new ValidationError('This instructor is already added to the course');
}
await prisma.courseInstructor.create({
data: {
course_id: addinstructorCourse.course_id,
user_id: user.id,
}
});
return {
code: 200,
message: 'Instructor added to course successfully',
};
} catch (error) {
logger.error('Failed to add instructor to course', { error });
throw error;
}
}
static async removeInstructorFromCourse(removeinstructorCourse: removeinstructorCourse): Promise<removeinstructorCourseResponse> {
try {
const decoded = jwt.verify(removeinstructorCourse.token, config.jwt.secret) as { id: number; type: string };
await prisma.courseInstructor.delete({
where: {
course_id_user_id: {
course_id: removeinstructorCourse.course_id,
user_id: removeinstructorCourse.user_id,
},
}
});
return {
code: 200,
message: 'Instructor removed from course successfully',
};
} catch (error) {
logger.error('Failed to remove instructor from course', { error });
throw error;
}
}
static async listInstructorsOfCourse(listinstructorCourse: listinstructorCourse): Promise<listinstructorCourseResponse> {
try {
const decoded = jwt.verify(listinstructorCourse.token, config.jwt.secret) as { id: number; type: string };
const courseInstructors = await prisma.courseInstructor.findMany({
where: {
course_id: listinstructorCourse.course_id,
},
include: {
user: {
include: {
profile: true
}
},
}
});
const data = await Promise.all(courseInstructors.map(async (ci) => {
let avatar_url: string | null = null;
if (ci.user.profile?.avatar_url) {
try {
avatar_url = await getPresignedUrl(ci.user.profile.avatar_url, 3600);
} catch (err) {
logger.warn(`Failed to generate presigned URL for avatar: ${err}`);
}
}
return {
user_id: ci.user_id,
is_primary: ci.is_primary,
user: {
id: ci.user.id,
username: ci.user.username,
email: ci.user.email,
first_name: ci.user.profile?.first_name || null,
last_name: ci.user.profile?.last_name || null,
avatar_url,
},
};
}));
return {
code: 200,
message: 'Instructors retrieved successfully',
data,
};
} catch (error) {
logger.error('Failed to retrieve instructors of course', { error });
throw error;
}
}
static async setPrimaryInstructor(setprimaryCourseInstructor: setprimaryCourseInstructor): Promise<setprimaryCourseInstructorResponse> {
try {
const decoded = jwt.verify(setprimaryCourseInstructor.token, config.jwt.secret) as { id: number; type: string };
await prisma.courseInstructor.update({
where: {
course_id_user_id: {
course_id: setprimaryCourseInstructor.course_id,
user_id: setprimaryCourseInstructor.user_id,
},
},
data: {
is_primary: true,
}
});
return {
code: 200,
message: 'Primary instructor set successfully',
};
} catch (error) {
logger.error('Failed to set primary instructor', { error });
throw error;
}
}
static async validateCourseInstructor(token: string, courseId: number): Promise<{ user_id: number; is_primary: boolean }> {
const decoded = jwt.verify(token, config.jwt.secret) as { id: number; type: string };
const courseInstructor = await prisma.courseInstructor.findFirst({
where: {
user_id: decoded.id,
course_id: courseId
}
});
if (!courseInstructor) {
throw new ForbiddenError('You are not an instructor of this course');
} else return { user_id: courseInstructor.user_id, is_primary: courseInstructor.is_primary };
}
static async validateCourseStatus(courseId: number): Promise<void> {
const course = await prisma.course.findUnique({ where: { id: courseId } });
if (!course) {
throw new NotFoundError('Course not found');
}
if (course.status === 'APPROVED' || course.status === 'PENDING') {
throw new ForbiddenError('Course is already approved Cannot Edit');
}
}
}