feat: Introduce dedicated instructor and student controllers for chapter and lesson management, including quiz questions.

This commit is contained in:
JakkrapartXD 2026-01-21 16:52:54 +07:00
parent 9a7eb50d17
commit fc3e2820cc
3 changed files with 426 additions and 18 deletions

View file

@ -0,0 +1,344 @@
import { Body, Delete, Get, Path, Post, Put, Request, Response, Route, Security, SuccessResponse, Tags } from 'tsoa';
import { ValidationError } from '../middleware/errorHandler';
import { ChaptersLessonService } from '../services/ChaptersLesson.service';
import {
ListChaptersResponse,
CreateChapterResponse,
UpdateChapterResponse,
DeleteChapterResponse,
ReorderChapterResponse,
GetLessonResponse,
CreateLessonResponse,
UpdateLessonResponse,
DeleteLessonResponse,
ReorderLessonsResponse,
AddQuestionResponse,
UpdateQuestionResponse,
DeleteQuestionResponse,
CreateChapterBody,
UpdateChapterBody,
ReorderChapterBody,
CreateLessonBody,
UpdateLessonBody,
ReorderLessonsBody,
AddQuestionBody,
UpdateQuestionBody,
} from '../types/ChaptersLesson.typs';
const chaptersLessonService = new ChaptersLessonService();
@Route('api/instructors/courses/{courseId}')
@Tags('ChaptersLessons - Instructor')
export class ChaptersLessonInstructorController {
// ============================================
// Chapter Endpoints
// ============================================
/**
* chapters course ( lessons)
* Get all chapters of a course with lessons
*/
@Get('chapters')
@Security('jwt', ['instructor'])
@SuccessResponse('200', 'Chapters retrieved successfully')
@Response('401', 'Unauthorized')
public async listChapters(@Request() request: any, @Path() courseId: number): Promise<ListChaptersResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided');
return await chaptersLessonService.listChapters({ token, course_id: courseId });
}
/**
* chapter
* Create a new chapter
*/
@Post('chapters')
@Security('jwt', ['instructor'])
@SuccessResponse('201', 'Chapter created successfully')
@Response('401', 'Unauthorized')
@Response('403', 'Forbidden')
public async createChapter(
@Request() request: any,
@Path() courseId: number,
@Body() body: CreateChapterBody
): Promise<CreateChapterResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided');
return await chaptersLessonService.createChapter({
token,
course_id: courseId,
title: body.title,
description: body.description,
sort_order: body.sort_order,
});
}
/**
* chapter
* Update a chapter
*/
@Put('chapters/{chapterId}')
@Security('jwt', ['instructor'])
@SuccessResponse('200', 'Chapter updated successfully')
@Response('401', 'Unauthorized')
@Response('403', 'Forbidden')
@Response('404', 'Chapter not found')
public async updateChapter(
@Request() request: any,
@Path() courseId: number,
@Path() chapterId: number,
@Body() body: UpdateChapterBody
): Promise<UpdateChapterResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided');
return await chaptersLessonService.updateChapter({
token,
course_id: courseId,
chapter_id: chapterId,
...body,
});
}
/**
* chapter
* Delete a chapter
*/
@Delete('chapters/{chapterId}')
@Security('jwt', ['instructor'])
@SuccessResponse('200', 'Chapter deleted successfully')
@Response('401', 'Unauthorized')
@Response('403', 'Forbidden')
@Response('404', 'Chapter not found')
public async deleteChapter(
@Request() request: any,
@Path() courseId: number,
@Path() chapterId: number
): Promise<DeleteChapterResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided');
return await chaptersLessonService.deleteChapter({ token, course_id: courseId, chapter_id: chapterId });
}
/**
* chapter
* Reorder chapter
*/
@Put('chapters/{chapterId}/reorder')
@Security('jwt', ['instructor'])
@SuccessResponse('200', 'Chapter reordered successfully')
public async reorderChapter(
@Request() request: any,
@Path() courseId: number,
@Path() chapterId: number,
@Body() body: ReorderChapterBody
): Promise<ReorderChapterResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided');
return await chaptersLessonService.reorderChapter({
token,
course_id: courseId,
chapter_id: chapterId,
sort_order: body.sort_order,
});
}
// ============================================
// Lesson Endpoints
// ============================================
/**
* lesson attachments quiz
* Get lesson with attachments and quiz
*/
@Get('chapters/{chapterId}/lessons/{lessonId}')
@Security('jwt', ['instructor'])
@SuccessResponse('200', 'Lesson retrieved successfully')
public async getLesson(
@Request() request: any,
@Path() courseId: number,
@Path() chapterId: number,
@Path() lessonId: number
): Promise<GetLessonResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided');
return await chaptersLessonService.getLesson({ token, course_id: courseId, chapter_id: chapterId, lesson_id: lessonId });
}
/**
* lesson ( QUIZ quiz shell )
* Create a new lesson (for QUIZ type, quiz shell is created automatically)
*/
@Post('chapters/{chapterId}/lessons')
@Security('jwt', ['instructor'])
@SuccessResponse('201', 'Lesson created successfully')
public async createLesson(
@Request() request: any,
@Path() courseId: number,
@Path() chapterId: number,
@Body() body: CreateLessonBody
): Promise<CreateLessonResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided');
return await chaptersLessonService.createLesson({
token,
course_id: courseId,
chapter_id: chapterId,
title: body.title,
content: body.content,
type: body.type,
sort_order: body.sort_order,
});
}
/**
* lesson
* Update a lesson
*/
@Put('chapters/{chapterId}/lessons/{lessonId}')
@Security('jwt', ['instructor'])
@SuccessResponse('200', 'Lesson updated successfully')
public async updateLesson(
@Request() request: any,
@Path() courseId: number,
@Path() chapterId: number,
@Path() lessonId: number,
@Body() body: UpdateLessonBody
): Promise<UpdateLessonResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided');
return await chaptersLessonService.updateLesson({
token,
course_id: courseId,
chapter_id: chapterId,
lesson_id: lessonId,
data: body,
});
}
// ============================================
// Video Upload Endpoints (multipart/form-data)
// Note: These endpoints should be registered with express router
// using multer middleware for actual file upload handling.
// See: src/routes/lessons.routes.ts (to be created)
// ============================================
/**
* lesson
* Delete a lesson (cannot delete if published)
*/
@Delete('chapters/{chapterId}/lessons/{lessonId}')
@Security('jwt', ['instructor'])
@SuccessResponse('200', 'Lesson deleted successfully')
public async deleteLesson(
@Request() request: any,
@Path() courseId: number,
@Path() chapterId: number,
@Path() lessonId: number
): Promise<DeleteLessonResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided');
return await chaptersLessonService.deleteLesson({ token, course_id: courseId, chapter_id: chapterId, lesson_id: lessonId });
}
/**
* lessons chapter
* Reorder lessons within a chapter
*/
@Put('chapters/{chapterId}/lessons/reorder')
@Security('jwt', ['instructor'])
@SuccessResponse('200', 'Lessons reordered successfully')
public async reorderLessons(
@Request() request: any,
@Path() courseId: number,
@Path() chapterId: number,
@Body() body: ReorderLessonsBody
): Promise<ReorderLessonsResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided');
return await chaptersLessonService.reorderLessons({
token,
course_id: courseId,
chapter_id: chapterId,
lesson_ids: body.lesson_ids,
});
}
// ============================================
// Quiz Question Endpoints
// ============================================
/**
* quiz lesson
* Add a question to a quiz lesson
*/
@Post('chapters/{chapterId}/lessons/{lessonId}/questions')
@Security('jwt', ['instructor'])
@SuccessResponse('201', 'Question added successfully')
public async addQuestion(
@Request() request: any,
@Path() courseId: number,
@Path() chapterId: number,
@Path() lessonId: number,
@Body() body: AddQuestionBody
): Promise<AddQuestionResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided');
return await chaptersLessonService.addQuestion({
token,
course_id: courseId,
lesson_id: lessonId,
...body,
});
}
/**
*
* Update a question
*/
@Put('chapters/{chapterId}/lessons/{lessonId}/questions/{questionId}')
@Security('jwt', ['instructor'])
@SuccessResponse('200', 'Question updated successfully')
public async updateQuestion(
@Request() request: any,
@Path() courseId: number,
@Path() chapterId: number,
@Path() lessonId: number,
@Path() questionId: number,
@Body() body: UpdateQuestionBody
): Promise<UpdateQuestionResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided');
return await chaptersLessonService.updateQuestion({
token,
course_id: courseId,
lesson_id: lessonId,
question_id: questionId,
...body,
});
}
/**
*
* Delete a question
*/
@Delete('chapters/{chapterId}/lessons/{lessonId}/questions/{questionId}')
@Security('jwt', ['instructor'])
@SuccessResponse('200', 'Question deleted successfully')
public async deleteQuestion(
@Request() request: any,
@Path() courseId: number,
@Path() chapterId: number,
@Path() lessonId: number,
@Path() questionId: number
): Promise<DeleteQuestionResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided');
return await chaptersLessonService.deleteQuestion({
token,
course_id: courseId,
lesson_id: lessonId,
question_id: questionId,
});
}
}

View file

@ -0,0 +1,59 @@
import { Get, Path, Request, Response, Route, Security, SuccessResponse, Tags } from 'tsoa';
import { ValidationError } from '../middleware/errorHandler';
import { ChaptersLessonService } from '../services/ChaptersLesson.service';
import {
ListChaptersResponse,
GetLessonResponse,
} from '../types/ChaptersLesson.typs';
const chaptersLessonService = new ChaptersLessonService();
@Route('api/students/courses/{courseId}')
@Tags('ChaptersLessons - Student')
export class ChaptersLessonStudentController {
// ============================================
// Chapter Endpoints (Read-only for students)
// ============================================
/**
* chapters course ( lessons) -
* Get all chapters of a course with lessons - for enrolled students
*/
@Get('chapters')
@Security('jwt', ['student'])
@SuccessResponse('200', 'Chapters retrieved successfully')
@Response('401', 'Unauthorized')
@Response('403', 'Not enrolled in this course')
public async listChapters(@Request() request: any, @Path() courseId: number): Promise<ListChaptersResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided');
return await chaptersLessonService.listChapters({ token, course_id: courseId });
}
// ============================================
// Lesson Endpoints (Read-only for students)
// ============================================
/**
* lesson attachments quiz -
* Get lesson with attachments and quiz - for enrolled students
* หมายเหตุ: จะดูได้เฉพาะ lesson is_published = true
*/
@Get('chapters/{chapterId}/lessons/{lessonId}')
@Security('jwt', ['student'])
@SuccessResponse('200', 'Lesson retrieved successfully')
@Response('401', 'Unauthorized')
@Response('403', 'Not enrolled or lesson not published')
@Response('404', 'Lesson not found')
public async getLesson(
@Request() request: any,
@Path() courseId: number,
@Path() chapterId: number,
@Path() lessonId: number
): Promise<GetLessonResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided');
return await chaptersLessonService.getLesson({ token, course_id: courseId, chapter_id: chapterId, lesson_id: lessonId });
}
}

View file

@ -23,25 +23,30 @@ export class LessonsController {
* @param video ( type=VIDEO )
* @param attachments (PDFs, , )
*/
async createLesson(req: LessonUploadRequest, res: Response, next: NextFunction): Promise<void> {
try {
const token = req.headers.authorization?.replace('Bearer ', '');
if (!token) {
throw new ValidationError('No token provided');
}
@Post('upload')
@Security('jwt', ['instructor'])
@SuccessResponse('201', 'Lesson created successfully')
@Response('400', 'Validation error')
@Response('401', 'Unauthorized')
@Response('403', 'Forbidden')
public async createLessonWithFiles(
@Request() request: any,
@Path() courseId: number,
@Path() chapterId: number,
@FormField() title: string,
@FormField() type: 'VIDEO' | 'QUIZ',
@FormField() content?: string,
@FormField() sort_order?: string,
@UploadedFile() video?: Express.Multer.File,
@UploadedFiles() attachments?: Express.Multer.File[]
): Promise<CreateLessonResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided');
const courseId = parseInt(req.params.courseId, 10);
const chapterId = parseInt(req.params.chapterId, 10);
if (isNaN(courseId) || isNaN(chapterId)) {
throw new ValidationError('Invalid course ID or chapter ID');
}
// Parse JSON fields from multipart form
const title = JSON.parse(req.body.title || '{}');
const content = req.body.content ? JSON.parse(req.body.content) : undefined;
const type = req.body.type as 'VIDEO' | 'QUIZ';
const sortOrder = req.body.sort_order ? parseInt(req.body.sort_order, 10) : undefined;
// Parse JSON fields
const parsedTitle: MultiLanguageText = JSON.parse(title);
const parsedContent = content ? JSON.parse(content) : undefined;
const sortOrder = sort_order ? parseInt(sort_order, 10) : undefined;
if (!parsedTitle.th || !parsedTitle.en) {
throw new ValidationError('Title must have both Thai (th) and English (en) values');