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:
JakkrapartXD 2026-02-11 13:49:43 +07:00
parent 623f797763
commit f7330a7b27
17 changed files with 3963 additions and 5 deletions

View 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);
}
}

View file

@ -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({

View 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;
}
}
}

View file

@ -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);

View file

@ -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;
}

View 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;
};
}

View file

@ -2,6 +2,7 @@ import { Course } from '@prisma/client';
export interface ListCoursesInput {
category_id?: number;
is_recommended?: boolean;
page?: number;
limit?: number;
random?: boolean;