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
|
|
@ -1482,7 +1482,7 @@ export class ChaptersLessonService {
|
|||
*/
|
||||
async updateQuiz(request: UpdateQuizInput): Promise<UpdateQuizResponse> {
|
||||
try {
|
||||
const { token, course_id, lesson_id, title, description, passing_score, time_limit, shuffle_questions, shuffle_choices, show_answers_after_completion, is_skippable } = request;
|
||||
const { token, course_id, lesson_id, title, description, passing_score, time_limit, shuffle_questions, shuffle_choices, show_answers_after_completion, is_skippable, allow_multiple_attempts } = request;
|
||||
const decodedToken = jwt.verify(token, config.jwt.secret) as { id: number };
|
||||
await CoursesInstructorService.validateCourseStatus(course_id);
|
||||
|
||||
|
|
@ -1513,6 +1513,7 @@ export class ChaptersLessonService {
|
|||
if (shuffle_choices !== undefined) updateData.shuffle_choices = shuffle_choices;
|
||||
if (show_answers_after_completion !== undefined) updateData.show_answers_after_completion = show_answers_after_completion;
|
||||
if (is_skippable !== undefined) updateData.is_skippable = is_skippable;
|
||||
if (allow_multiple_attempts !== undefined) updateData.allow_multiple_attempts = allow_multiple_attempts;
|
||||
|
||||
// Update the quiz
|
||||
const updatedQuiz = await prisma.quiz.update({
|
||||
|
|
|
|||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -9,16 +9,20 @@ import { getPresignedUrl } from '../config/minio';
|
|||
export class CoursesService {
|
||||
async ListCourses(input: ListCoursesInput): Promise<listCourseResponse> {
|
||||
try {
|
||||
const { category_id, page = 1, limit = 10, random = false } = input;
|
||||
const { category_id, is_recommended, page = 1, limit = 10, random = false } = input;
|
||||
|
||||
const where: Prisma.CourseWhereInput = {
|
||||
status: 'APPROVED',
|
||||
status: 'APPROVED',
|
||||
};
|
||||
|
||||
if (category_id) {
|
||||
where.category_id = category_id;
|
||||
}
|
||||
|
||||
if (is_recommended !== undefined) {
|
||||
where.is_recommended = is_recommended;
|
||||
}
|
||||
|
||||
// Get total count for pagination
|
||||
const total = await prisma.course.count({ where });
|
||||
const totalPages = Math.ceil(total / limit);
|
||||
|
|
@ -28,13 +32,13 @@ export class CoursesService {
|
|||
if (random) {
|
||||
// Random mode: ดึงทั้งหมดแล้วสุ่ม
|
||||
const allCourses = await prisma.course.findMany({ where });
|
||||
|
||||
|
||||
// Fisher-Yates shuffle
|
||||
for (let i = allCourses.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[allCourses[i], allCourses[j]] = [allCourses[j], allCourses[i]];
|
||||
}
|
||||
|
||||
|
||||
// Apply pagination after shuffle
|
||||
const skip = (page - 1) * limit;
|
||||
courses = allCourses.slice(skip, skip + limit);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue