feat: introduce Joi validation schemas and integrate them across various controllers for categories, lessons, courses, chapters, announcements, and admin course approvals.
All checks were successful
Build and Deploy Backend / Build Backend Docker Image (push) Successful in 26s
Build and Deploy Backend / Deploy E-learning Backend to Dev Server (push) Successful in 3s
Build and Deploy Backend / Notify Deployment Status (push) Successful in 2s

This commit is contained in:
JakkrapartXD 2026-02-18 15:59:40 +07:00
parent c5aa195b13
commit b56f604890
14 changed files with 553 additions and 28 deletions

View file

@ -1,6 +1,7 @@
import { Body, Get, Path, Post, Request, Response, Route, Security, SuccessResponse, Tags } from 'tsoa'; import { Body, Get, Path, Post, Request, Response, Route, Security, SuccessResponse, Tags } from 'tsoa';
import { ValidationError } from '../middleware/errorHandler'; import { ValidationError } from '../middleware/errorHandler';
import { AdminCourseApprovalService } from '../services/AdminCourseApproval.service'; import { AdminCourseApprovalService } from '../services/AdminCourseApproval.service';
import { ApproveCourseValidator, RejectCourseValidator } from '../validators/AdminCourseApproval.validator';
import { import {
ListPendingCoursesResponse, ListPendingCoursesResponse,
GetCourseDetailForAdminResponse, GetCourseDetailForAdminResponse,
@ -65,6 +66,13 @@ export class AdminCourseApprovalController {
): Promise<ApproveCourseResponse> { ): Promise<ApproveCourseResponse> {
const token = request.headers.authorization?.replace('Bearer ', ''); const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided'); if (!token) throw new ValidationError('No token provided');
// Validate body if provided
if (body) {
const { error } = ApproveCourseValidator.validate(body);
if (error) throw new ValidationError(error.details[0].message);
}
return await AdminCourseApprovalService.approveCourse(token, courseId, body?.comment); return await AdminCourseApprovalService.approveCourse(token, courseId, body?.comment);
} }
@ -87,6 +95,11 @@ export class AdminCourseApprovalController {
): Promise<RejectCourseResponse> { ): Promise<RejectCourseResponse> {
const token = request.headers.authorization?.replace('Bearer ', ''); const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided'); if (!token) throw new ValidationError('No token provided');
// Validate body
const { error } = RejectCourseValidator.validate(body);
if (error) throw new ValidationError(error.details[0].message);
return await AdminCourseApprovalService.rejectCourse(token, courseId, body.comment); return await AdminCourseApprovalService.rejectCourse(token, courseId, body.comment);
} }
} }

View file

@ -2,6 +2,7 @@ import { Get, Body, Post, Route, Tags, SuccessResponse, Response, Delete, Contro
import { ValidationError } from '../middleware/errorHandler'; import { ValidationError } from '../middleware/errorHandler';
import { CategoryService } from '../services/categories.service'; import { CategoryService } from '../services/categories.service';
import { createCategory, createCategoryResponse, deleteCategoryResponse, updateCategory, updateCategoryResponse, ListCategoriesResponse } from '../types/categories.type'; import { createCategory, createCategoryResponse, deleteCategoryResponse, updateCategory, updateCategoryResponse, ListCategoriesResponse } from '../types/categories.type';
import { CreateCategoryValidator, UpdateCategoryValidator } from '../validators/categories.validator';
@Route('api/categories') @Route('api/categories')
@Tags('Categories') @Tags('Categories')
@ -27,6 +28,11 @@ export class CategoriesAdminController {
@Response('401', 'Invalid or expired token') @Response('401', 'Invalid or expired token')
public async createCategory(@Request() request: any, @Body() body: createCategory): Promise<createCategoryResponse> { public async createCategory(@Request() request: any, @Body() body: createCategory): Promise<createCategoryResponse> {
const token = request.headers.authorization?.replace('Bearer ', '') || ''; const token = request.headers.authorization?.replace('Bearer ', '') || '';
// Validate body
const { error } = CreateCategoryValidator.validate(body);
if (error) throw new ValidationError(error.details[0].message);
return await this.categoryService.createCategory(token, body); return await this.categoryService.createCategory(token, body);
} }
@ -36,6 +42,11 @@ export class CategoriesAdminController {
@Response('401', 'Invalid or expired token') @Response('401', 'Invalid or expired token')
public async updateCategory(@Request() request: any, @Body() body: updateCategory): Promise<updateCategoryResponse> { public async updateCategory(@Request() request: any, @Body() body: updateCategory): Promise<updateCategoryResponse> {
const token = request.headers.authorization?.replace('Bearer ', '') || ''; const token = request.headers.authorization?.replace('Bearer ', '') || '';
// Validate body
const { error } = UpdateCategoryValidator.validate(body);
if (error) throw new ValidationError(error.details[0].message);
return await this.categoryService.updateCategory(token, body.id, body); return await this.categoryService.updateCategory(token, body.id, body);
} }

View file

@ -27,6 +27,18 @@ import {
UpdateQuizResponse, UpdateQuizResponse,
UpdateQuizBody, UpdateQuizBody,
} from '../types/ChaptersLesson.typs'; } from '../types/ChaptersLesson.typs';
import {
CreateChapterValidator,
UpdateChapterValidator,
ReorderChapterValidator,
CreateLessonValidator,
UpdateLessonValidator,
ReorderLessonsValidator,
AddQuestionValidator,
UpdateQuestionValidator,
ReorderQuestionValidator,
UpdateQuizValidator
} from '../validators/ChaptersLesson.validator';
const chaptersLessonService = new ChaptersLessonService(); const chaptersLessonService = new ChaptersLessonService();
@ -55,6 +67,10 @@ export class ChaptersLessonInstructorController {
): Promise<CreateChapterResponse> { ): Promise<CreateChapterResponse> {
const token = request.headers.authorization?.replace('Bearer ', ''); const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided'); if (!token) throw new ValidationError('No token provided');
const { error } = CreateChapterValidator.validate(body);
if (error) throw new ValidationError(error.details[0].message);
return await chaptersLessonService.createChapter({ return await chaptersLessonService.createChapter({
token, token,
course_id: courseId, course_id: courseId,
@ -82,6 +98,10 @@ export class ChaptersLessonInstructorController {
): Promise<UpdateChapterResponse> { ): Promise<UpdateChapterResponse> {
const token = request.headers.authorization?.replace('Bearer ', ''); const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided'); if (!token) throw new ValidationError('No token provided');
const { error } = UpdateChapterValidator.validate(body);
if (error) throw new ValidationError(error.details[0].message);
return await chaptersLessonService.updateChapter({ return await chaptersLessonService.updateChapter({
token, token,
course_id: courseId, course_id: courseId,
@ -125,6 +145,10 @@ export class ChaptersLessonInstructorController {
): Promise<ReorderChapterResponse> { ): Promise<ReorderChapterResponse> {
const token = request.headers.authorization?.replace('Bearer ', ''); const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided'); if (!token) throw new ValidationError('No token provided');
const { error } = ReorderChapterValidator.validate(body);
if (error) throw new ValidationError(error.details[0].message);
return await chaptersLessonService.reorderChapter({ return await chaptersLessonService.reorderChapter({
token, token,
course_id: courseId, course_id: courseId,
@ -170,6 +194,10 @@ export class ChaptersLessonInstructorController {
): Promise<CreateLessonResponse> { ): Promise<CreateLessonResponse> {
const token = request.headers.authorization?.replace('Bearer ', ''); const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided'); if (!token) throw new ValidationError('No token provided');
const { error } = CreateLessonValidator.validate(body);
if (error) throw new ValidationError(error.details[0].message);
return await chaptersLessonService.createLesson({ return await chaptersLessonService.createLesson({
token, token,
course_id: courseId, course_id: courseId,
@ -197,6 +225,10 @@ export class ChaptersLessonInstructorController {
): Promise<UpdateLessonResponse> { ): Promise<UpdateLessonResponse> {
const token = request.headers.authorization?.replace('Bearer ', ''); const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided'); if (!token) throw new ValidationError('No token provided');
const { error } = UpdateLessonValidator.validate(body);
if (error) throw new ValidationError(error.details[0].message);
return await chaptersLessonService.updateLesson({ return await chaptersLessonService.updateLesson({
token, token,
course_id: courseId, course_id: courseId,
@ -246,6 +278,10 @@ export class ChaptersLessonInstructorController {
): Promise<ReorderLessonsResponse> { ): Promise<ReorderLessonsResponse> {
const token = request.headers.authorization?.replace('Bearer ', ''); const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided'); if (!token) throw new ValidationError('No token provided');
const { error } = ReorderLessonsValidator.validate(body);
if (error) throw new ValidationError(error.details[0].message);
return await chaptersLessonService.reorderLessons({ return await chaptersLessonService.reorderLessons({
token, token,
course_id: courseId, course_id: courseId,
@ -275,6 +311,10 @@ export class ChaptersLessonInstructorController {
): Promise<AddQuestionResponse> { ): Promise<AddQuestionResponse> {
const token = request.headers.authorization?.replace('Bearer ', ''); const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided'); if (!token) throw new ValidationError('No token provided');
const { error } = AddQuestionValidator.validate(body);
if (error) throw new ValidationError(error.details[0].message);
return await chaptersLessonService.addQuestion({ return await chaptersLessonService.addQuestion({
token, token,
course_id: courseId, course_id: courseId,
@ -300,6 +340,10 @@ export class ChaptersLessonInstructorController {
): Promise<UpdateQuestionResponse> { ): Promise<UpdateQuestionResponse> {
const token = request.headers.authorization?.replace('Bearer ', ''); const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided'); if (!token) throw new ValidationError('No token provided');
const { error } = UpdateQuestionValidator.validate(body);
if (error) throw new ValidationError(error.details[0].message);
return await chaptersLessonService.updateQuestion({ return await chaptersLessonService.updateQuestion({
token, token,
course_id: courseId, course_id: courseId,
@ -322,6 +366,10 @@ export class ChaptersLessonInstructorController {
): Promise<ReorderQuestionResponse> { ): Promise<ReorderQuestionResponse> {
const token = request.headers.authorization?.replace('Bearer ', ''); const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided'); if (!token) throw new ValidationError('No token provided');
const { error } = ReorderQuestionValidator.validate(body);
if (error) throw new ValidationError(error.details[0].message);
return await chaptersLessonService.reorderQuestion({ return await chaptersLessonService.reorderQuestion({
token, token,
course_id: courseId, course_id: courseId,
@ -371,6 +419,10 @@ export class ChaptersLessonInstructorController {
): Promise<UpdateQuizResponse> { ): Promise<UpdateQuizResponse> {
const token = request.headers.authorization?.replace('Bearer ', ''); const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided'); if (!token) throw new ValidationError('No token provided');
const { error } = UpdateQuizValidator.validate(body);
if (error) throw new ValidationError(error.details[0].message);
return await chaptersLessonService.updateQuiz({ return await chaptersLessonService.updateQuiz({
token, token,
course_id: courseId, course_id: courseId,

View file

@ -2,30 +2,28 @@ import { Get, Body, Post, Route, Tags, SuccessResponse, Response, Security, Put,
import { ValidationError } from '../middleware/errorHandler'; import { ValidationError } from '../middleware/errorHandler';
import { CoursesInstructorService } from '../services/CoursesInstructor.service'; import { CoursesInstructorService } from '../services/CoursesInstructor.service';
import { import {
createCourses,
createCourseResponse, createCourseResponse,
GetMyCourseResponse,
ListMyCoursesInput,
ListMyCourseResponse, ListMyCourseResponse,
addinstructorCourseResponse, GetMyCourseResponse,
removeinstructorCourseResponse,
setprimaryCourseInstructorResponse,
UpdateMyCourse, UpdateMyCourse,
UpdateMyCourseResponse, UpdateMyCourseResponse,
DeleteMyCourseResponse, DeleteMyCourseResponse,
submitCourseResponse, submitCourseResponse,
listinstructorCourseResponse, listinstructorCourseResponse,
GetCourseApprovalsResponse, addinstructorCourseResponse,
SearchInstructorResponse, removeinstructorCourseResponse,
setprimaryCourseInstructorResponse,
GetEnrolledStudentsResponse, GetEnrolledStudentsResponse,
GetEnrolledStudentDetailResponse,
GetQuizScoresResponse, GetQuizScoresResponse,
GetQuizAttemptDetailResponse, GetQuizAttemptDetailResponse,
GetEnrolledStudentDetailResponse, GetCourseApprovalsResponse,
SearchInstructorResponse,
GetCourseApprovalHistoryResponse, GetCourseApprovalHistoryResponse,
setCourseDraftResponse, setCourseDraftResponse,
CloneCourseResponse, CloneCourseResponse,
} from '../types/CoursesInstructor.types'; } from '../types/CoursesInstructor.types';
import { CreateCourseValidator } from "../validators/CoursesInstructor.validator"; import { CreateCourseValidator, UpdateCourseValidator, CloneCourseValidator } from "../validators/CoursesInstructor.validator";
import jwt from 'jsonwebtoken'; import jwt from 'jsonwebtoken';
import { config } from '../config'; import { config } from '../config';
@ -104,9 +102,11 @@ export class CoursesInstructorController {
@Response('404', 'Course not found') @Response('404', 'Course not found')
public async updateCourse(@Request() request: any, @Path() courseId: number, @Body() body: UpdateMyCourse): Promise<UpdateMyCourseResponse> { public async updateCourse(@Request() request: any, @Path() courseId: number, @Body() body: UpdateMyCourse): Promise<UpdateMyCourseResponse> {
const token = request.headers.authorization?.replace('Bearer ', ''); const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) { if (!token) throw new ValidationError('No token provided');
throw new ValidationError('No token provided');
} const { error } = UpdateCourseValidator.validate(body.data);
if (error) throw new ValidationError(error.details[0].message);
return await CoursesInstructorService.updateCourse(token, courseId, body.data); return await CoursesInstructorService.updateCourse(token, courseId, body.data);
} }
@ -199,13 +199,16 @@ export class CoursesInstructorController {
const token = request.headers.authorization?.replace('Bearer ', ''); const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided'); if (!token) throw new ValidationError('No token provided');
return await CoursesInstructorService.cloneCourse({ const { error } = CloneCourseValidator.validate(body);
if (error) throw new ValidationError(error.details[0].message);
const result = await CoursesInstructorService.cloneCourse({
token, token,
course_id: courseId, course_id: courseId,
title: body.title title: body.title
}); });
return result;
} }
/** /**
* *
* Submit course for admin review and approval * Submit course for admin review and approval

View file

@ -16,6 +16,7 @@ import {
GetQuizAttemptsResponse, GetQuizAttemptsResponse,
} from '../types/CoursesStudent.types'; } from '../types/CoursesStudent.types';
import { EnrollmentStatus } from '@prisma/client'; import { EnrollmentStatus } from '@prisma/client';
import { SaveVideoProgressValidator, SubmitQuizValidator } from '../validators/CoursesStudent.validator';
@Route('api/students') @Route('api/students')
@Tags('CoursesStudent') @Tags('CoursesStudent')
@ -149,9 +150,11 @@ export class CoursesStudentController {
@Body() body: SaveVideoProgressBody @Body() body: SaveVideoProgressBody
): Promise<SaveVideoProgressResponse> { ): Promise<SaveVideoProgressResponse> {
const token = request.headers.authorization?.replace('Bearer ', ''); const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) { if (!token) throw new ValidationError('No token provided');
throw new ValidationError('No token provided');
} const { error } = SaveVideoProgressValidator.validate(body);
if (error) throw new ValidationError(error.details[0].message);
return await this.service.saveVideoProgress({ return await this.service.saveVideoProgress({
token, token,
lesson_id: lessonId, lesson_id: lessonId,
@ -225,9 +228,11 @@ export class CoursesStudentController {
@Body() body: SubmitQuizBody @Body() body: SubmitQuizBody
): Promise<SubmitQuizResponse> { ): Promise<SubmitQuizResponse> {
const token = request.headers.authorization?.replace('Bearer ', ''); const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) { if (!token) throw new ValidationError('No token provided');
throw new ValidationError('No token provided');
} const { error } = SubmitQuizValidator.validate(body);
if (error) throw new ValidationError(error.details[0].message);
return await this.service.submitQuiz({ return await this.service.submitQuiz({
token, token,
course_id: courseId, course_id: courseId,

View file

@ -11,6 +11,7 @@ import {
YouTubeVideoResponse, YouTubeVideoResponse,
SetYouTubeVideoBody, SetYouTubeVideoBody,
} from '../types/ChaptersLesson.typs'; } from '../types/ChaptersLesson.typs';
import { SetYouTubeVideoValidator } from '../validators/Lessons.validator';
const chaptersLessonService = new ChaptersLessonService(); const chaptersLessonService = new ChaptersLessonService();
@ -213,12 +214,8 @@ export class LessonsController {
const token = request.headers.authorization?.replace('Bearer ', ''); const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided'); if (!token) throw new ValidationError('No token provided');
if (!body.youtube_video_id) { const { error } = SetYouTubeVideoValidator.validate(body);
throw new ValidationError('YouTube video ID is required'); if (error) throw new ValidationError(error.details[0].message);
}
if (!body.video_title) {
throw new ValidationError('Video title is required');
}
return await chaptersLessonService.setYouTubeVideo({ return await chaptersLessonService.setYouTubeVideo({
token, token,

View file

@ -1,6 +1,7 @@
import { Body, Delete, Get, Path, Post, Put, Query, Request, Response, Route, Security, SuccessResponse, Tags, UploadedFile, UploadedFiles, FormField } from 'tsoa'; import { Body, Delete, Get, Path, Post, Put, Query, Request, Response, Route, Security, SuccessResponse, Tags, UploadedFile, UploadedFiles, FormField } from 'tsoa';
import { ValidationError } from '../middleware/errorHandler'; import { ValidationError } from '../middleware/errorHandler';
import { AnnouncementsService } from '../services/announcements.service'; import { AnnouncementsService } from '../services/announcements.service';
import { CreateAnnouncementValidator, UpdateAnnouncementValidator } from '../validators/announcements.validator';
import { import {
ListAnnouncementResponse, ListAnnouncementResponse,
CreateAnnouncementResponse, CreateAnnouncementResponse,
@ -68,6 +69,10 @@ export class AnnouncementsController {
// Parse JSON data field // Parse JSON data field
const parsed = JSON.parse(data) as CreateAnnouncementBody; const parsed = JSON.parse(data) as CreateAnnouncementBody;
// Validate parsed data
const { error } = CreateAnnouncementValidator.validate(parsed);
if (error) throw new ValidationError(error.details[0].message);
return await announcementsService.createAnnouncement({ return await announcementsService.createAnnouncement({
token, token,
course_id: courseId, course_id: courseId,
@ -100,6 +105,11 @@ export class AnnouncementsController {
): Promise<UpdateAnnouncementResponse> { ): Promise<UpdateAnnouncementResponse> {
const token = request.headers.authorization?.replace('Bearer ', ''); const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided'); if (!token) throw new ValidationError('No token provided');
// Validate body
const { error } = UpdateAnnouncementValidator.validate(body);
if (error) throw new ValidationError(error.details[0].message);
return await announcementsService.updateAnnouncement({ return await announcementsService.updateAnnouncement({
token, token,
course_id: courseId, course_id: courseId,

View file

@ -0,0 +1,30 @@
import Joi from 'joi';
/**
* Validator for approving a course
* Comment is optional
*/
export const ApproveCourseValidator = Joi.object({
comment: Joi.string()
.max(1000)
.optional()
.messages({
'string.max': 'Comment must not exceed 1000 characters'
})
});
/**
* Validator for rejecting a course
* Comment is required when rejecting
*/
export const RejectCourseValidator = Joi.object({
comment: Joi.string()
.min(10)
.max(1000)
.required()
.messages({
'string.min': 'Comment must be at least 10 characters when rejecting a course',
'string.max': 'Comment must not exceed 1000 characters',
'any.required': 'Comment is required when rejecting a course'
})
});

View file

@ -0,0 +1,186 @@
import Joi from 'joi';
// Multi-language validation schema
const multiLangSchema = Joi.object({
th: Joi.string().required().messages({
'any.required': 'Thai text is required'
}),
en: Joi.string().required().messages({
'any.required': 'English text is required'
})
}).required();
const multiLangOptionalSchema = Joi.object({
th: Joi.string().optional(),
en: Joi.string().optional()
}).optional();
// ============================================
// Chapter Validators
// ============================================
/**
* Validator for creating a chapter
*/
export const CreateChapterValidator = Joi.object({
title: multiLangSchema.messages({
'any.required': 'Title is required'
}),
description: multiLangOptionalSchema,
sort_order: Joi.number().integer().min(0).optional()
});
/**
* Validator for updating a chapter
*/
export const UpdateChapterValidator = Joi.object({
title: multiLangOptionalSchema,
description: multiLangOptionalSchema,
sort_order: Joi.number().integer().min(0).optional(),
is_published: Joi.boolean().optional()
});
/**
* Validator for reordering a chapter
*/
export const ReorderChapterValidator = Joi.object({
sort_order: Joi.number().integer().min(0).required().messages({
'any.required': 'Sort order is required',
'number.min': 'Sort order must be at least 0'
})
});
// ============================================
// Lesson Validators
// ============================================
/**
* Validator for creating a lesson
*/
export const CreateLessonValidator = Joi.object({
title: multiLangSchema.messages({
'any.required': 'Title is required'
}),
content: multiLangOptionalSchema,
type: Joi.string().valid('VIDEO', 'QUIZ').required().messages({
'any.only': 'Type must be either VIDEO or QUIZ',
'any.required': 'Type is required'
}),
sort_order: Joi.number().integer().min(0).optional()
});
/**
* Validator for updating a lesson
*/
export const UpdateLessonValidator = Joi.object({
title: multiLangOptionalSchema,
content: multiLangOptionalSchema,
duration_minutes: Joi.number().min(0).optional().messages({
'number.min': 'Duration must be at least 0'
}),
sort_order: Joi.number().integer().min(0).optional(),
prerequisite_lesson_ids: Joi.array().items(Joi.number().integer().positive()).optional(),
is_published: Joi.boolean().optional()
});
/**
* Validator for reordering lessons
*/
export const ReorderLessonsValidator = Joi.object({
lesson_id: Joi.number().integer().positive().required().messages({
'any.required': 'Lesson ID is required'
}),
sort_order: Joi.number().integer().min(0).required().messages({
'any.required': 'Sort order is required'
})
});
// ============================================
// Quiz Question Validators
// ============================================
/**
* Validator for quiz choice
*/
const QuizChoiceValidator = Joi.object({
text: multiLangSchema.messages({
'any.required': 'Choice text is required'
}),
is_correct: Joi.boolean().required().messages({
'any.required': 'is_correct is required'
}),
sort_order: Joi.number().integer().min(0).optional()
});
/**
* Validator for adding a question to a quiz
*/
export const AddQuestionValidator = Joi.object({
question: multiLangSchema.messages({
'any.required': 'Question is required'
}),
explanation: multiLangOptionalSchema,
question_type: Joi.string()
.valid('MULTIPLE_CHOICE', 'TRUE_FALSE', 'SHORT_ANSWER')
.required()
.messages({
'any.only': 'Question type must be MULTIPLE_CHOICE, TRUE_FALSE, or SHORT_ANSWER',
'any.required': 'Question type is required'
}),
sort_order: Joi.number().integer().min(0).optional(),
choices: Joi.array().items(QuizChoiceValidator).min(1).optional().messages({
'array.min': 'At least one choice is required for multiple choice questions'
})
});
/**
* Validator for updating a question
*/
export const UpdateQuestionValidator = Joi.object({
question: multiLangOptionalSchema,
explanation: multiLangOptionalSchema,
question_type: Joi.string()
.valid('MULTIPLE_CHOICE', 'TRUE_FALSE', 'SHORT_ANSWER')
.optional()
.messages({
'any.only': 'Question type must be MULTIPLE_CHOICE, TRUE_FALSE, or SHORT_ANSWER'
}),
sort_order: Joi.number().integer().min(0).optional(),
choices: Joi.array().items(QuizChoiceValidator).min(1).optional().messages({
'array.min': 'At least one choice is required'
})
});
/**
* Validator for reordering a question
*/
export const ReorderQuestionValidator = Joi.object({
sort_order: Joi.number().integer().min(0).required().messages({
'any.required': 'Sort order is required',
'number.min': 'Sort order must be at least 0'
})
});
// ============================================
// Quiz Settings Validator
// ============================================
/**
* Validator for updating quiz settings
*/
export const UpdateQuizValidator = Joi.object({
title: multiLangOptionalSchema,
description: multiLangOptionalSchema,
passing_score: Joi.number().min(0).max(100).optional().messages({
'number.min': 'Passing score must be at least 0',
'number.max': 'Passing score must not exceed 100'
}),
time_limit: Joi.number().min(0).optional().messages({
'number.min': 'Time limit must be at least 0'
}),
shuffle_questions: Joi.boolean().optional(),
shuffle_choices: Joi.boolean().optional(),
show_answers_after_completion: Joi.boolean().optional(),
is_skippable: Joi.boolean().optional(),
allow_multiple_attempts: Joi.boolean().optional()
});

View file

@ -20,3 +20,38 @@ export const CreateCourseValidator = Joi.object({
is_free: Joi.boolean().required(), is_free: Joi.boolean().required(),
have_certificate: Joi.boolean().required(), have_certificate: Joi.boolean().required(),
}); });
/**
* Validator for updating a course
*/
export const UpdateCourseValidator = Joi.object({
category_id: Joi.number().optional(),
title: Joi.object({
th: Joi.string().optional(),
en: Joi.string().optional(),
}).optional(),
slug: Joi.string().optional(),
description: Joi.object({
th: Joi.string().optional(),
en: Joi.string().optional(),
}).optional(),
price: Joi.number().optional(),
is_free: Joi.boolean().optional(),
have_certificate: Joi.boolean().optional(),
});
/**
* Validator for cloning a course
*/
export const CloneCourseValidator = Joi.object({
title: Joi.object({
th: Joi.string().required().messages({
'any.required': 'Thai title is required'
}),
en: Joi.string().required().messages({
'any.required': 'English title is required'
})
}).required().messages({
'any.required': 'Title is required'
})
});

View file

@ -0,0 +1,38 @@
import Joi from 'joi';
/**
* Validator for saving video progress
*/
export const SaveVideoProgressValidator = Joi.object({
video_progress_seconds: Joi.number().min(0).required().messages({
'any.required': 'Video progress seconds is required',
'number.min': 'Video progress must be at least 0'
}),
video_duration_seconds: Joi.number().min(0).optional().messages({
'number.min': 'Video duration must be at least 0'
})
});
/**
* Validator for quiz answer
*/
const QuizAnswerValidator = Joi.object({
question_id: Joi.number().integer().positive().required().messages({
'any.required': 'Question ID is required',
'number.positive': 'Question ID must be positive'
}),
choice_id: Joi.number().integer().positive().required().messages({
'any.required': 'Choice ID is required',
'number.positive': 'Choice ID must be positive'
})
});
/**
* Validator for submitting quiz answers
*/
export const SubmitQuizValidator = Joi.object({
answers: Joi.array().items(QuizAnswerValidator).min(1).required().messages({
'any.required': 'Answers are required',
'array.min': 'At least one answer is required'
})
});

View file

@ -0,0 +1,15 @@
import Joi from 'joi';
/**
* Validator for setting YouTube video
*/
export const SetYouTubeVideoValidator = Joi.object({
youtube_video_id: Joi.string().required().messages({
'any.required': 'YouTube video ID is required',
'string.empty': 'YouTube video ID cannot be empty'
}),
video_title: Joi.string().required().messages({
'any.required': 'Video title is required',
'string.empty': 'Video title cannot be empty'
})
});

View file

@ -0,0 +1,72 @@
import Joi from 'joi';
/**
* Validator for creating an announcement
*/
export const CreateAnnouncementValidator = Joi.object({
title: Joi.object({
th: Joi.string().required().messages({
'any.required': 'Thai title is required'
}),
en: Joi.string().required().messages({
'any.required': 'English title is required'
})
}).required().messages({
'any.required': 'Title is required'
}),
content: Joi.object({
th: Joi.string().required().messages({
'any.required': 'Thai content is required'
}),
en: Joi.string().required().messages({
'any.required': 'English content is required'
})
}).required().messages({
'any.required': 'Content is required'
}),
status: Joi.string()
.valid('DRAFT', 'PUBLISHED', 'ARCHIVED')
.required()
.messages({
'any.only': 'Status must be one of: DRAFT, PUBLISHED, ARCHIVED',
'any.required': 'Status is required'
}),
is_pinned: Joi.boolean()
.required()
.messages({
'any.required': 'is_pinned is required'
}),
published_at: Joi.string()
.isoDate()
.optional()
.messages({
'string.isoDate': 'published_at must be a valid ISO date string'
})
});
/**
* Validator for updating an announcement
*/
export const UpdateAnnouncementValidator = Joi.object({
title: Joi.object({
th: Joi.string().optional(),
en: Joi.string().optional()
}).optional(),
content: Joi.object({
th: Joi.string().optional(),
en: Joi.string().optional()
}).optional(),
status: Joi.string()
.valid('DRAFT', 'PUBLISHED', 'ARCHIVED')
.optional()
.messages({
'any.only': 'Status must be one of: DRAFT, PUBLISHED, ARCHIVED'
}),
is_pinned: Joi.boolean().optional(),
published_at: Joi.string()
.isoDate()
.optional()
.messages({
'string.isoDate': 'published_at must be a valid ISO date string'
})
});

View file

@ -0,0 +1,58 @@
import Joi from 'joi';
/**
* Validator for creating a category
*/
export const CreateCategoryValidator = Joi.object({
name: Joi.object({
th: Joi.string().required().messages({
'any.required': 'Thai name is required'
}),
en: Joi.string().required().messages({
'any.required': 'English name is required'
})
}).required().messages({
'any.required': 'Name is required'
}),
slug: Joi.string()
.pattern(/^[a-z0-9]+(?:-[a-z0-9]+)*$/)
.required()
.messages({
'string.pattern.base': 'Slug must be lowercase with hyphens (e.g., web-development)',
'any.required': 'Slug is required'
}),
description: Joi.object({
th: Joi.string().required().messages({
'any.required': 'Thai description is required'
}),
en: Joi.string().required().messages({
'any.required': 'English description is required'
})
}).required().messages({
'any.required': 'Description is required'
}),
created_by: Joi.number().optional()
});
/**
* Validator for updating a category
*/
export const UpdateCategoryValidator = Joi.object({
id: Joi.number().required().messages({
'any.required': 'Category ID is required'
}),
name: Joi.object({
th: Joi.string().optional(),
en: Joi.string().optional()
}).optional(),
slug: Joi.string()
.pattern(/^[a-z0-9]+(?:-[a-z0-9]+)*$/)
.optional()
.messages({
'string.pattern.base': 'Slug must be lowercase with hyphens (e.g., web-development)'
}),
description: Joi.object({
th: Joi.string().optional(),
en: Joi.string().optional()
}).optional()
});