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
75
Backend/src/controllers/RecommendedCoursesController.ts
Normal file
75
Backend/src/controllers/RecommendedCoursesController.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import { Body, Get, Path, Put, Request, Response, Route, Security, SuccessResponse, Tags } from 'tsoa';
|
||||
import { ValidationError } from '../middleware/errorHandler';
|
||||
import { RecommendedCoursesService } from '../services/RecommendedCourses.service';
|
||||
import {
|
||||
ListApprovedCoursesResponse,
|
||||
GetCourseByIdResponse,
|
||||
ToggleRecommendedRequest,
|
||||
ToggleRecommendedResponse
|
||||
} from '../types/RecommendedCourses.types';
|
||||
|
||||
@Route('api/admin/recommended-courses')
|
||||
@Tags('Admin/RecommendedCourses')
|
||||
export class RecommendedCoursesController {
|
||||
|
||||
/**
|
||||
* ดึงรายการคอร์สที่อนุมัติแล้วทั้งหมด (สำหรับจัดการคอร์สแนะนำ)
|
||||
* List all approved courses (for managing recommendations)
|
||||
*/
|
||||
@Get()
|
||||
@Security('jwt', ['admin'])
|
||||
@SuccessResponse('200', 'Approved courses retrieved successfully')
|
||||
@Response('401', 'Unauthorized')
|
||||
@Response('403', 'Forbidden - Admin only')
|
||||
public async listApprovedCourses(@Request() request: any): Promise<ListApprovedCoursesResponse> {
|
||||
const token = request.headers.authorization?.replace('Bearer ', '');
|
||||
if (!token) {
|
||||
throw new ValidationError('No token provided');
|
||||
}
|
||||
return await RecommendedCoursesService.listApprovedCourses();
|
||||
}
|
||||
|
||||
/**
|
||||
* ดึงรายละเอียดคอร์สตาม ID
|
||||
* Get course by ID
|
||||
* @param courseId - รหัสคอร์ส / Course ID
|
||||
*/
|
||||
@Get('{courseId}')
|
||||
@Security('jwt', ['admin'])
|
||||
@SuccessResponse('200', 'Course retrieved successfully')
|
||||
@Response('400', 'Course is not approved')
|
||||
@Response('401', 'Unauthorized')
|
||||
@Response('403', 'Forbidden - Admin only')
|
||||
@Response('404', 'Course not found')
|
||||
public async getCourseById(@Request() request: any, @Path() courseId: number): Promise<GetCourseByIdResponse> {
|
||||
const token = request.headers.authorization?.replace('Bearer ', '');
|
||||
if (!token) {
|
||||
throw new ValidationError('No token provided');
|
||||
}
|
||||
return await RecommendedCoursesService.getCourseById(courseId);
|
||||
}
|
||||
|
||||
/**
|
||||
* เปลี่ยนสถานะคอร์สแนะนำ
|
||||
* Toggle course recommendation status
|
||||
* @param courseId - รหัสคอร์ส / Course ID
|
||||
*/
|
||||
@Put('{courseId}/toggle')
|
||||
@Security('jwt', ['admin'])
|
||||
@SuccessResponse('200', 'Recommendation status updated successfully')
|
||||
@Response('400', 'Only approved courses can be recommended')
|
||||
@Response('401', 'Unauthorized')
|
||||
@Response('403', 'Forbidden - Admin only')
|
||||
@Response('404', 'Course not found')
|
||||
public async toggleRecommended(
|
||||
@Request() request: any,
|
||||
@Path() courseId: number,
|
||||
@Body() body: ToggleRecommendedRequest
|
||||
): Promise<ToggleRecommendedResponse> {
|
||||
const token = request.headers.authorization?.replace('Bearer ', '');
|
||||
if (!token) {
|
||||
throw new ValidationError('No token provided');
|
||||
}
|
||||
return await RecommendedCoursesService.toggleRecommended(token, courseId, body.is_recommended);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ export interface QuizData {
|
|||
shuffle_choices: boolean;
|
||||
show_answers_after_completion: boolean;
|
||||
is_skippable: boolean;
|
||||
allow_multiple_attempts: boolean;
|
||||
created_at: Date;
|
||||
created_by: number;
|
||||
updated_at: Date | null;
|
||||
|
|
@ -598,6 +599,7 @@ export interface UpdateQuizInput {
|
|||
shuffle_choices?: boolean;
|
||||
show_answers_after_completion?: boolean;
|
||||
is_skippable?: boolean;
|
||||
allow_multiple_attempts?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -685,4 +687,5 @@ export interface UpdateQuizBody {
|
|||
shuffle_choices?: boolean;
|
||||
show_answers_after_completion?: boolean;
|
||||
is_skippable?: boolean;
|
||||
allow_multiple_attempts?: boolean;
|
||||
}
|
||||
70
Backend/src/types/RecommendedCourses.types.ts
Normal file
70
Backend/src/types/RecommendedCourses.types.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import { MultiLanguageText } from './index';
|
||||
|
||||
// ============================================
|
||||
// Request Types
|
||||
// ============================================
|
||||
|
||||
export interface ToggleRecommendedRequest {
|
||||
is_recommended: boolean;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Response Types
|
||||
// ============================================
|
||||
|
||||
export interface RecommendedCourseData {
|
||||
id: number;
|
||||
title: MultiLanguageText;
|
||||
slug: string;
|
||||
description: MultiLanguageText;
|
||||
thumbnail_url: string | null;
|
||||
price: number;
|
||||
is_free: boolean;
|
||||
have_certificate: boolean;
|
||||
is_recommended: boolean;
|
||||
status: string;
|
||||
created_at: Date;
|
||||
updated_at: Date | null;
|
||||
creator: {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
};
|
||||
category: {
|
||||
id: number;
|
||||
name: MultiLanguageText;
|
||||
} | null;
|
||||
instructors: Array<{
|
||||
user_id: number;
|
||||
is_primary: boolean;
|
||||
user: {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
};
|
||||
}>;
|
||||
chapters_count: number;
|
||||
lessons_count: number;
|
||||
}
|
||||
|
||||
export interface ListApprovedCoursesResponse {
|
||||
code: number;
|
||||
message: string;
|
||||
data: RecommendedCourseData[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface GetCourseByIdResponse {
|
||||
code: number;
|
||||
message: string;
|
||||
data: RecommendedCourseData;
|
||||
}
|
||||
|
||||
export interface ToggleRecommendedResponse {
|
||||
code: number;
|
||||
message: string;
|
||||
data: {
|
||||
id: number;
|
||||
is_recommended: boolean;
|
||||
};
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ import { Course } from '@prisma/client';
|
|||
|
||||
export interface ListCoursesInput {
|
||||
category_id?: number;
|
||||
is_recommended?: boolean;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
random?: boolean;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue