feat: add recommended courses and quiz multiple attempts
- Add is_recommended field to Course model - Add allow_multiple_attempts field to Quiz model - Create RecommendedCoursesController for admin management - List all approved courses - Get course by ID - Toggle recommendation status - Add is_recommended filter to CoursesService.ListCourses - Add allow_multiple_attempts to quiz update and response types - Update ChaptersLessonService.updateQuiz to support allow_multiple_attempts
This commit is contained in:
parent
623f797763
commit
f7330a7b27
17 changed files with 3963 additions and 5 deletions
235
Backend/src/services/RecommendedCourses.service.ts
Normal file
235
Backend/src/services/RecommendedCourses.service.ts
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
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(): 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 });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get course by ID (for admin to view details)
|
||||
*/
|
||||
static async getCourseById(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 });
|
||||
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: isRecommended ? AuditAction.UPDATE_COURSE : AuditAction.UPDATE_COURSE,
|
||||
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 });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue