272 lines
10 KiB
TypeScript
272 lines
10 KiB
TypeScript
import { prisma } from '../config/database';
|
|
import { config } from '../config';
|
|
import { logger } from '../config/logger';
|
|
import { NotFoundError, ValidationError } from '../middleware/errorHandler';
|
|
import jwt from 'jsonwebtoken';
|
|
import { getPresignedUrl } from '../config/minio';
|
|
import {
|
|
ListApprovedCoursesResponse,
|
|
GetCourseByIdResponse,
|
|
ToggleRecommendedResponse,
|
|
RecommendedCourseData
|
|
} from '../types/RecommendedCourses.types';
|
|
import { auditService } from './audit.service';
|
|
import { AuditAction } from '@prisma/client';
|
|
|
|
export class RecommendedCoursesService {
|
|
|
|
/**
|
|
* List all approved courses (for admin to manage recommendations)
|
|
*/
|
|
static async listApprovedCourses(token: string): Promise<ListApprovedCoursesResponse> {
|
|
try {
|
|
const courses = await prisma.course.findMany({
|
|
where: { status: 'APPROVED' },
|
|
orderBy: [
|
|
{ is_recommended: 'desc' },
|
|
{ updated_at: 'desc' }
|
|
],
|
|
include: {
|
|
category: {
|
|
select: { id: true, name: true }
|
|
},
|
|
creator: {
|
|
select: { id: true, username: true, email: true }
|
|
},
|
|
instructors: {
|
|
include: {
|
|
user: {
|
|
select: { id: true, username: true, email: true }
|
|
}
|
|
}
|
|
},
|
|
chapters: {
|
|
include: {
|
|
lessons: true
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
const data = await Promise.all(courses.map(async (course) => {
|
|
let thumbnail_presigned_url: string | null = null;
|
|
if (course.thumbnail_url) {
|
|
try {
|
|
thumbnail_presigned_url = await getPresignedUrl(course.thumbnail_url, 3600);
|
|
} catch (err) {
|
|
logger.warn(`Failed to generate presigned URL for thumbnail: ${err}`);
|
|
}
|
|
}
|
|
|
|
return {
|
|
id: course.id,
|
|
title: course.title as { th: string; en: string },
|
|
slug: course.slug,
|
|
description: course.description as { th: string; en: string },
|
|
thumbnail_url: thumbnail_presigned_url,
|
|
price: Number(course.price),
|
|
is_free: course.is_free,
|
|
have_certificate: course.have_certificate,
|
|
is_recommended: course.is_recommended,
|
|
status: course.status,
|
|
created_at: course.created_at,
|
|
updated_at: course.updated_at,
|
|
creator: course.creator,
|
|
category: course.category ? {
|
|
id: course.category.id,
|
|
name: course.category.name as { th: string; en: string }
|
|
} : null,
|
|
instructors: course.instructors.map(i => ({
|
|
user_id: i.user_id,
|
|
is_primary: i.is_primary,
|
|
user: i.user
|
|
})),
|
|
chapters_count: course.chapters.length,
|
|
lessons_count: course.chapters.reduce((sum, ch) => sum + ch.lessons.length, 0)
|
|
} as RecommendedCourseData;
|
|
}));
|
|
|
|
return {
|
|
code: 200,
|
|
message: 'Approved courses retrieved successfully',
|
|
data,
|
|
total: data.length
|
|
};
|
|
} catch (error) {
|
|
logger.error('Failed to list approved courses', { error });
|
|
const decoded = jwt.decode(token) as { id: number } | null;
|
|
if (decoded?.id) {
|
|
await auditService.logSync({
|
|
userId: decoded.id,
|
|
action: AuditAction.ERROR,
|
|
entityType: 'RecommendedCourses',
|
|
entityId: 0,
|
|
metadata: {
|
|
operation: 'list_approved_courses',
|
|
error: error instanceof Error ? error.message : String(error)
|
|
}
|
|
});
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get course by ID (for admin to view details)
|
|
*/
|
|
static async getCourseById(token: string, courseId: number): Promise<GetCourseByIdResponse> {
|
|
try {
|
|
const course = await prisma.course.findUnique({
|
|
where: { id: courseId },
|
|
include: {
|
|
category: {
|
|
select: { id: true, name: true }
|
|
},
|
|
creator: {
|
|
select: { id: true, username: true, email: true }
|
|
},
|
|
instructors: {
|
|
include: {
|
|
user: {
|
|
select: { id: true, username: true, email: true }
|
|
}
|
|
}
|
|
},
|
|
chapters: {
|
|
include: {
|
|
lessons: true
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
if (!course) {
|
|
throw new NotFoundError('Course not found');
|
|
}
|
|
|
|
if (course.status !== 'APPROVED') {
|
|
throw new ValidationError('Course is not approved');
|
|
}
|
|
|
|
// Generate presigned URL for thumbnail
|
|
let thumbnail_presigned_url: string | null = null;
|
|
if (course.thumbnail_url) {
|
|
try {
|
|
thumbnail_presigned_url = await getPresignedUrl(course.thumbnail_url, 3600);
|
|
} catch (err) {
|
|
logger.warn(`Failed to generate presigned URL for thumbnail: ${err}`);
|
|
}
|
|
}
|
|
|
|
const data: RecommendedCourseData = {
|
|
id: course.id,
|
|
title: course.title as { th: string; en: string },
|
|
slug: course.slug,
|
|
description: course.description as { th: string; en: string },
|
|
thumbnail_url: thumbnail_presigned_url,
|
|
price: Number(course.price),
|
|
is_free: course.is_free,
|
|
have_certificate: course.have_certificate,
|
|
is_recommended: course.is_recommended,
|
|
status: course.status,
|
|
created_at: course.created_at,
|
|
updated_at: course.updated_at,
|
|
creator: course.creator,
|
|
category: course.category ? {
|
|
id: course.category.id,
|
|
name: course.category.name as { th: string; en: string }
|
|
} : null,
|
|
instructors: course.instructors.map(i => ({
|
|
user_id: i.user_id,
|
|
is_primary: i.is_primary,
|
|
user: i.user
|
|
})),
|
|
chapters_count: course.chapters.length,
|
|
lessons_count: course.chapters.reduce((sum, ch) => sum + ch.lessons.length, 0)
|
|
};
|
|
|
|
return {
|
|
code: 200,
|
|
message: 'Course retrieved successfully',
|
|
data
|
|
};
|
|
} catch (error) {
|
|
logger.error('Failed to get course by ID', { error });
|
|
const decoded = jwt.decode(token) as { id: number } | null;
|
|
if (decoded?.id) {
|
|
await auditService.logSync({
|
|
userId: decoded.id,
|
|
action: AuditAction.ERROR,
|
|
entityType: 'RecommendedCourses',
|
|
entityId: 0,
|
|
metadata: {
|
|
operation: 'get_course_by_id',
|
|
error: error instanceof Error ? error.message : String(error)
|
|
}
|
|
});
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Toggle course recommendation status
|
|
*/
|
|
static async toggleRecommended(
|
|
token: string,
|
|
courseId: number,
|
|
isRecommended: boolean
|
|
): Promise<ToggleRecommendedResponse> {
|
|
try {
|
|
const decoded = jwt.verify(token, config.jwt.secret) as { id: number };
|
|
|
|
const course = await prisma.course.findUnique({ where: { id: courseId } });
|
|
if (!course) {
|
|
throw new NotFoundError('Course not found');
|
|
}
|
|
|
|
if (course.status !== 'APPROVED') {
|
|
throw new ValidationError('Only approved courses can be recommended');
|
|
}
|
|
|
|
const updatedCourse = await prisma.course.update({
|
|
where: { id: courseId },
|
|
data: { is_recommended: isRecommended }
|
|
});
|
|
|
|
// Audit log
|
|
await auditService.logSync({
|
|
userId: decoded.id,
|
|
action: AuditAction.UPDATE,
|
|
entityType: 'Course',
|
|
entityId: courseId,
|
|
oldValue: { is_recommended: course.is_recommended },
|
|
newValue: { is_recommended: isRecommended },
|
|
metadata: { action: 'toggle_recommended' }
|
|
});
|
|
|
|
return {
|
|
code: 200,
|
|
message: `Course ${isRecommended ? 'marked as recommended' : 'unmarked as recommended'} successfully`,
|
|
data: {
|
|
id: updatedCourse.id,
|
|
is_recommended: updatedCourse.is_recommended
|
|
}
|
|
};
|
|
} catch (error) {
|
|
logger.error('Failed to toggle recommended status', { error });
|
|
const decoded = jwt.decode(token) as { id: number } | null;
|
|
await auditService.logSync({
|
|
userId: decoded?.id || 0,
|
|
action: AuditAction.ERROR,
|
|
entityType: 'RecommendedCourses',
|
|
entityId: courseId,
|
|
metadata: {
|
|
operation: 'toggle_recommended',
|
|
error: error instanceof Error ? error.message : String(error)
|
|
}
|
|
});
|
|
throw error;
|
|
}
|
|
}
|
|
}
|