feat: introduce Joi validation schemas and integrate them across various controllers for categories, lessons, courses, chapters, announcements, and admin course approvals.
This commit is contained in:
parent
c5aa195b13
commit
b56f604890
14 changed files with 553 additions and 28 deletions
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -45,6 +56,6 @@ export class CategoriesAdminController {
|
||||||
@Response('401', 'Invalid or expired token')
|
@Response('401', 'Invalid or expired token')
|
||||||
public async deleteCategory(@Request() request: any, @Path() id: number): Promise<deleteCategoryResponse> {
|
public async deleteCategory(@Request() request: any, @Path() id: number): Promise<deleteCategoryResponse> {
|
||||||
const token = request.headers.authorization?.replace('Bearer ', '') || '';
|
const token = request.headers.authorization?.replace('Bearer ', '') || '';
|
||||||
return await this.categoryService.deleteCategory(token,id);
|
return await this.categoryService.deleteCategory(token, id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
30
Backend/src/validators/AdminCourseApproval.validator.ts
Normal file
30
Backend/src/validators/AdminCourseApproval.validator.ts
Normal 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'
|
||||||
|
})
|
||||||
|
});
|
||||||
186
Backend/src/validators/ChaptersLesson.validator.ts
Normal file
186
Backend/src/validators/ChaptersLesson.validator.ts
Normal 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()
|
||||||
|
});
|
||||||
|
|
@ -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'
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
|
||||||
38
Backend/src/validators/CoursesStudent.validator.ts
Normal file
38
Backend/src/validators/CoursesStudent.validator.ts
Normal 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'
|
||||||
|
})
|
||||||
|
});
|
||||||
15
Backend/src/validators/Lessons.validator.ts
Normal file
15
Backend/src/validators/Lessons.validator.ts
Normal 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'
|
||||||
|
})
|
||||||
|
});
|
||||||
72
Backend/src/validators/announcements.validator.ts
Normal file
72
Backend/src/validators/announcements.validator.ts
Normal 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'
|
||||||
|
})
|
||||||
|
});
|
||||||
58
Backend/src/validators/categories.validator.ts
Normal file
58
Backend/src/validators/categories.validator.ts
Normal 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()
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue