diff --git a/Backend/prisma/schema.prisma b/Backend/prisma/schema.prisma index 212b3bf3..bc4b2267 100644 --- a/Backend/prisma/schema.prisma +++ b/Backend/prisma/schema.prisma @@ -634,6 +634,8 @@ enum AuditAction { VERIFY_EMAIL DEACTIVATE_USER ACTIVATE_USER + ERROR + WARNING } model AuditLog { diff --git a/Backend/src/controllers/AdminCourseApprovalController.ts b/Backend/src/controllers/AdminCourseApprovalController.ts index 0ad0171a..f10b99ca 100644 --- a/Backend/src/controllers/AdminCourseApprovalController.ts +++ b/Backend/src/controllers/AdminCourseApprovalController.ts @@ -1,10 +1,10 @@ import { Body, Get, Path, Post, Request, Response, Route, Security, SuccessResponse, Tags } from 'tsoa'; import { ValidationError } from '../middleware/errorHandler'; import { AdminCourseApprovalService } from '../services/AdminCourseApproval.service'; +import { RejectCourseValidator } from '../validators/AdminCourseApproval.validator'; import { ListPendingCoursesResponse, GetCourseDetailForAdminResponse, - ApproveCourseBody, ApproveCourseResponse, RejectCourseBody, RejectCourseResponse, @@ -25,10 +25,8 @@ export class AdminCourseApprovalController { @Response('403', 'Forbidden - Admin only') public async listPendingCourses(@Request() request: any): Promise { const token = request.headers.authorization?.replace('Bearer ', ''); - if (!token) { - throw new ValidationError('No token provided'); - } - return await AdminCourseApprovalService.listPendingCourses(); + if (!token) throw new ValidationError('No token provided'); + return await AdminCourseApprovalService.listPendingCourses(token); } /** @@ -44,10 +42,8 @@ export class AdminCourseApprovalController { @Response('404', 'Course not found') public async getCourseDetail(@Request() request: any, @Path() courseId: number): Promise { const token = request.headers.authorization?.replace('Bearer ', ''); - if (!token) { - throw new ValidationError('No token provided'); - } - return await AdminCourseApprovalService.getCourseDetail(courseId); + if (!token) throw new ValidationError('No token provided'); + return await AdminCourseApprovalService.getCourseDetail(token, courseId); } /** @@ -64,14 +60,12 @@ export class AdminCourseApprovalController { @Response('404', 'Course not found') public async approveCourse( @Request() request: any, - @Path() courseId: number, - @Body() body?: ApproveCourseBody + @Path() courseId: number ): Promise { const token = request.headers.authorization?.replace('Bearer ', ''); - if (!token) { - throw new ValidationError('No token provided'); - } - return await AdminCourseApprovalService.approveCourse(token, courseId, body?.comment); + if (!token) throw new ValidationError('No token provided'); + + return await AdminCourseApprovalService.approveCourse(token, courseId, undefined); } /** @@ -92,9 +86,12 @@ export class AdminCourseApprovalController { @Body() body: RejectCourseBody ): Promise { 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); } } diff --git a/Backend/src/controllers/AuditController.ts b/Backend/src/controllers/AuditController.ts index 5de912fc..a78c8d5a 100644 --- a/Backend/src/controllers/AuditController.ts +++ b/Backend/src/controllers/AuditController.ts @@ -169,8 +169,8 @@ export class AuditController { throw new ValidationError('No token provided'); } - if (days < 30) { - throw new ValidationError('Cannot delete logs newer than 30 days'); + if (days < 6) { + throw new ValidationError('Cannot delete logs newer than 6 days'); } const deleted = await auditService.deleteOldLogs(days); diff --git a/Backend/src/controllers/CategoriesController.ts b/Backend/src/controllers/CategoriesController.ts index 09a5a621..81fd8b86 100644 --- a/Backend/src/controllers/CategoriesController.ts +++ b/Backend/src/controllers/CategoriesController.ts @@ -2,6 +2,7 @@ import { Get, Body, Post, Route, Tags, SuccessResponse, Response, Delete, Contro import { ValidationError } from '../middleware/errorHandler'; import { CategoryService } from '../services/categories.service'; import { createCategory, createCategoryResponse, deleteCategoryResponse, updateCategory, updateCategoryResponse, ListCategoriesResponse } from '../types/categories.type'; +import { CreateCategoryValidator, UpdateCategoryValidator } from '../validators/categories.validator'; @Route('api/categories') @Tags('Categories') @@ -27,6 +28,11 @@ export class CategoriesAdminController { @Response('401', 'Invalid or expired token') public async createCategory(@Request() request: any, @Body() body: createCategory): Promise { 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); } @@ -36,6 +42,11 @@ export class CategoriesAdminController { @Response('401', 'Invalid or expired token') public async updateCategory(@Request() request: any, @Body() body: updateCategory): Promise { 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); } @@ -45,6 +56,6 @@ export class CategoriesAdminController { @Response('401', 'Invalid or expired token') public async deleteCategory(@Request() request: any, @Path() id: number): Promise { const token = request.headers.authorization?.replace('Bearer ', '') || ''; - return await this.categoryService.deleteCategory(id); + return await this.categoryService.deleteCategory(token, id); } } \ No newline at end of file diff --git a/Backend/src/controllers/ChaptersLessonInstructorController.ts b/Backend/src/controllers/ChaptersLessonInstructorController.ts index f0bb43fe..7ba48f5c 100644 --- a/Backend/src/controllers/ChaptersLessonInstructorController.ts +++ b/Backend/src/controllers/ChaptersLessonInstructorController.ts @@ -27,6 +27,18 @@ import { UpdateQuizResponse, UpdateQuizBody, } from '../types/ChaptersLesson.typs'; +import { + CreateChapterValidator, + UpdateChapterValidator, + ReorderChapterValidator, + CreateLessonValidator, + UpdateLessonValidator, + ReorderLessonsValidator, + AddQuestionValidator, + UpdateQuestionValidator, + ReorderQuestionValidator, + UpdateQuizValidator +} from '../validators/ChaptersLesson.validator'; const chaptersLessonService = new ChaptersLessonService(); @@ -55,6 +67,10 @@ export class ChaptersLessonInstructorController { ): Promise { const token = request.headers.authorization?.replace('Bearer ', ''); 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({ token, course_id: courseId, @@ -82,6 +98,10 @@ export class ChaptersLessonInstructorController { ): Promise { const token = request.headers.authorization?.replace('Bearer ', ''); 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({ token, course_id: courseId, @@ -125,6 +145,10 @@ export class ChaptersLessonInstructorController { ): Promise { const token = request.headers.authorization?.replace('Bearer ', ''); 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({ token, course_id: courseId, @@ -170,6 +194,10 @@ export class ChaptersLessonInstructorController { ): Promise { const token = request.headers.authorization?.replace('Bearer ', ''); 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({ token, course_id: courseId, @@ -197,6 +225,10 @@ export class ChaptersLessonInstructorController { ): Promise { const token = request.headers.authorization?.replace('Bearer ', ''); 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({ token, course_id: courseId, @@ -246,6 +278,10 @@ export class ChaptersLessonInstructorController { ): Promise { const token = request.headers.authorization?.replace('Bearer ', ''); 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({ token, course_id: courseId, @@ -275,6 +311,10 @@ export class ChaptersLessonInstructorController { ): Promise { const token = request.headers.authorization?.replace('Bearer ', ''); 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({ token, course_id: courseId, @@ -300,6 +340,10 @@ export class ChaptersLessonInstructorController { ): Promise { const token = request.headers.authorization?.replace('Bearer ', ''); 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({ token, course_id: courseId, @@ -322,6 +366,10 @@ export class ChaptersLessonInstructorController { ): Promise { const token = request.headers.authorization?.replace('Bearer ', ''); 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({ token, course_id: courseId, @@ -371,6 +419,10 @@ export class ChaptersLessonInstructorController { ): Promise { const token = request.headers.authorization?.replace('Bearer ', ''); 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({ token, course_id: courseId, diff --git a/Backend/src/controllers/CoursesInstructorController.ts b/Backend/src/controllers/CoursesInstructorController.ts index 5b698807..3657e928 100644 --- a/Backend/src/controllers/CoursesInstructorController.ts +++ b/Backend/src/controllers/CoursesInstructorController.ts @@ -2,28 +2,28 @@ import { Get, Body, Post, Route, Tags, SuccessResponse, Response, Security, Put, import { ValidationError } from '../middleware/errorHandler'; import { CoursesInstructorService } from '../services/CoursesInstructor.service'; import { - createCourses, createCourseResponse, - GetMyCourseResponse, ListMyCourseResponse, - addinstructorCourseResponse, - removeinstructorCourseResponse, - setprimaryCourseInstructorResponse, + GetMyCourseResponse, UpdateMyCourse, UpdateMyCourseResponse, DeleteMyCourseResponse, submitCourseResponse, listinstructorCourseResponse, - GetCourseApprovalsResponse, - SearchInstructorResponse, + addinstructorCourseResponse, + removeinstructorCourseResponse, + setprimaryCourseInstructorResponse, GetEnrolledStudentsResponse, + GetEnrolledStudentDetailResponse, GetQuizScoresResponse, GetQuizAttemptDetailResponse, - GetEnrolledStudentDetailResponse, + GetCourseApprovalsResponse, + SearchInstructorResponse, GetCourseApprovalHistoryResponse, setCourseDraftResponse, + CloneCourseResponse, } from '../types/CoursesInstructor.types'; -import { CreateCourseValidator } from "../validators/CoursesInstructor.validator"; +import { CreateCourseValidator, UpdateCourseValidator, CloneCourseValidator } from "../validators/CoursesInstructor.validator"; import jwt from 'jsonwebtoken'; import { config } from '../config'; @@ -41,12 +41,15 @@ export class CoursesInstructorController { @SuccessResponse('200', 'Courses retrieved successfully') @Response('401', 'Invalid or expired token') @Response('404', 'Courses not found') - public async listMyCourses(@Request() request: any): Promise { + public async listMyCourses( + @Request() request: any, + @Query() status?: 'DRAFT' | 'PENDING' | 'APPROVED' | 'REJECTED' | 'ARCHIVED' + ): Promise { const token = request.headers.authorization?.replace('Bearer ', ''); if (!token) { throw new ValidationError('No token provided'); } - return await CoursesInstructorService.listMyCourses(token); + return await CoursesInstructorService.listMyCourses({ token, status }); } /** @@ -99,9 +102,11 @@ export class CoursesInstructorController { @Response('404', 'Course not found') public async updateCourse(@Request() request: any, @Path() courseId: number, @Body() body: UpdateMyCourse): Promise { 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 } = UpdateCourseValidator.validate(body.data); + if (error) throw new ValidationError(error.details[0].message); + return await CoursesInstructorService.updateCourse(token, courseId, body.data); } @@ -174,6 +179,36 @@ export class CoursesInstructorController { return await CoursesInstructorService.deleteCourse(token, courseId); } + /** + * คัดลอกคอร์ส (Clone Course) + * Clone an existing course to a new one with copied chapters, lessons, quizzes, and attachments + * @param courseId - รหัสคอร์สต้นฉบับ / Source Course ID + * @param body - ชื่อคอร์สใหม่ / New course title + */ + @Post('{courseId}/clone') + @Security('jwt', ['instructor']) + @SuccessResponse('201', 'Course cloned successfully') + @Response('401', 'Invalid or expired token') + @Response('403', 'Not an instructor of this course') + @Response('404', 'Course not found') + public async cloneCourse( + @Request() request: any, + @Path() courseId: number, + @Body() body: { title: { th: string; en: string } } + ): Promise { + const token = request.headers.authorization?.replace('Bearer ', ''); + if (!token) throw new ValidationError('No token provided'); + + const { error } = CloneCourseValidator.validate(body); + if (error) throw new ValidationError(error.details[0].message); + + const result = await CoursesInstructorService.cloneCourse({ + token, + course_id: courseId, + title: body.title + }); + return result; + } /** * ส่งคอร์สเพื่อขออนุมัติจากแอดมิน * Submit course for admin review and approval diff --git a/Backend/src/controllers/CoursesStudentController.ts b/Backend/src/controllers/CoursesStudentController.ts index afcf80b0..87a5a613 100644 --- a/Backend/src/controllers/CoursesStudentController.ts +++ b/Backend/src/controllers/CoursesStudentController.ts @@ -16,6 +16,7 @@ import { GetQuizAttemptsResponse, } from '../types/CoursesStudent.types'; import { EnrollmentStatus } from '@prisma/client'; +import { SaveVideoProgressValidator, SubmitQuizValidator } from '../validators/CoursesStudent.validator'; @Route('api/students') @Tags('CoursesStudent') @@ -149,9 +150,11 @@ export class CoursesStudentController { @Body() body: SaveVideoProgressBody ): Promise { 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 } = SaveVideoProgressValidator.validate(body); + if (error) throw new ValidationError(error.details[0].message); + return await this.service.saveVideoProgress({ token, lesson_id: lessonId, @@ -225,9 +228,11 @@ export class CoursesStudentController { @Body() body: SubmitQuizBody ): Promise { 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 } = SubmitQuizValidator.validate(body); + if (error) throw new ValidationError(error.details[0].message); + return await this.service.submitQuiz({ token, course_id: courseId, diff --git a/Backend/src/controllers/LessonsController.ts b/Backend/src/controllers/LessonsController.ts index f054ef4e..0323f4ab 100644 --- a/Backend/src/controllers/LessonsController.ts +++ b/Backend/src/controllers/LessonsController.ts @@ -11,6 +11,7 @@ import { YouTubeVideoResponse, SetYouTubeVideoBody, } from '../types/ChaptersLesson.typs'; +import { SetYouTubeVideoValidator } from '../validators/Lessons.validator'; const chaptersLessonService = new ChaptersLessonService(); @@ -213,12 +214,8 @@ export class LessonsController { const token = request.headers.authorization?.replace('Bearer ', ''); if (!token) throw new ValidationError('No token provided'); - if (!body.youtube_video_id) { - throw new ValidationError('YouTube video ID is required'); - } - if (!body.video_title) { - throw new ValidationError('Video title is required'); - } + const { error } = SetYouTubeVideoValidator.validate(body); + if (error) throw new ValidationError(error.details[0].message); return await chaptersLessonService.setYouTubeVideo({ token, diff --git a/Backend/src/controllers/RecommendedCoursesController.ts b/Backend/src/controllers/RecommendedCoursesController.ts index 7e770c12..720bff7c 100644 --- a/Backend/src/controllers/RecommendedCoursesController.ts +++ b/Backend/src/controllers/RecommendedCoursesController.ts @@ -20,12 +20,14 @@ export class RecommendedCoursesController { @SuccessResponse('200', 'Approved courses retrieved successfully') @Response('401', 'Unauthorized') @Response('403', 'Forbidden - Admin only') - public async listApprovedCourses(@Request() request: any): Promise { + public async listApprovedCourses( + @Request() request: any, + @Query() search?: string, + @Query() categoryId?: number + ): Promise { const token = request.headers.authorization?.replace('Bearer ', ''); - if (!token) { - throw new ValidationError('No token provided'); - } - return await RecommendedCoursesService.listApprovedCourses(); + if (!token) throw new ValidationError('No token provided'); + return await RecommendedCoursesService.listApprovedCourses(token, { search, categoryId }); } /** @@ -42,10 +44,8 @@ export class RecommendedCoursesController { @Response('404', 'Course not found') public async getCourseById(@Request() request: any, @Path() courseId: number): Promise { const token = request.headers.authorization?.replace('Bearer ', ''); - if (!token) { - throw new ValidationError('No token provided'); - } - return await RecommendedCoursesService.getCourseById(courseId); + if (!token) throw new ValidationError('No token provided'); + return await RecommendedCoursesService.getCourseById(token, courseId); } /** @@ -62,13 +62,11 @@ export class RecommendedCoursesController { @Response('404', 'Course not found') public async toggleRecommended( @Request() request: any, - @Path() courseId: number, + @Path() courseId: number, @Query() is_recommended: boolean ): Promise { 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 RecommendedCoursesService.toggleRecommended(token, courseId, is_recommended); } } diff --git a/Backend/src/controllers/UserController.ts b/Backend/src/controllers/UserController.ts index b8169827..ccbe7c76 100644 --- a/Backend/src/controllers/UserController.ts +++ b/Backend/src/controllers/UserController.ts @@ -10,7 +10,8 @@ import { ChangePasswordResponse, updateAvatarResponse, SendVerifyEmailResponse, - VerifyEmailResponse + VerifyEmailResponse, + rolesResponse } from '../types/user.types'; import { ChangePassword } from '../types/auth.types'; import { profileUpdateSchema, changePasswordSchema } from "../validators/user.validator"; @@ -56,6 +57,18 @@ export class UserController { return await this.userService.updateProfile(token, body); } + @Get('roles') + @Security('jwt') + @SuccessResponse('200', 'Roles retrieved successfully') + @Response('401', 'Invalid or expired token') + public async getRoles(@Request() request: any): Promise { + const token = request.headers.authorization?.replace('Bearer ', ''); + if (!token) { + throw new ValidationError('No token provided'); + } + return await this.userService.getRoles(token); + } + /** * Change password * @summary Change user password using old password diff --git a/Backend/src/controllers/announcementsController.ts b/Backend/src/controllers/announcementsController.ts index 6a4b901c..8ac03c70 100644 --- a/Backend/src/controllers/announcementsController.ts +++ b/Backend/src/controllers/announcementsController.ts @@ -1,6 +1,7 @@ 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 { AnnouncementsService } from '../services/announcements.service'; +import { CreateAnnouncementValidator, UpdateAnnouncementValidator } from '../validators/announcements.validator'; import { ListAnnouncementResponse, CreateAnnouncementResponse, @@ -68,6 +69,10 @@ export class AnnouncementsController { // Parse JSON data field 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({ token, course_id: courseId, @@ -100,6 +105,11 @@ export class AnnouncementsController { ): Promise { const token = request.headers.authorization?.replace('Bearer ', ''); 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({ token, course_id: courseId, diff --git a/Backend/src/services/AdminCourseApproval.service.ts b/Backend/src/services/AdminCourseApproval.service.ts index 5e9da6ea..0596034c 100644 --- a/Backend/src/services/AdminCourseApproval.service.ts +++ b/Backend/src/services/AdminCourseApproval.service.ts @@ -18,7 +18,7 @@ export class AdminCourseApprovalService { /** * Get all pending courses for admin review */ - static async listPendingCourses(): Promise { + static async listPendingCourses(token: string): Promise { try { const courses = await prisma.course.findMany({ where: { status: 'PENDING' }, @@ -68,18 +68,18 @@ export class AdminCourseApprovalService { description: course.description as { th: string; en: string }, thumbnail_url: thumbnail_presigned_url, status: course.status, - created_at: course.created_at, - updated_at: course.updated_at, - created_by: course.created_by, - creator: course.creator, - 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), - latest_submission: course.courseApprovals[0] ? { + created_at: course.created_at, + updated_at: course.updated_at, + created_by: course.created_by, + creator: course.creator, + 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), + latest_submission: course.courseApprovals[0] ? { id: course.courseApprovals[0].id, submitted_by: course.courseApprovals[0].submitted_by, created_at: course.courseApprovals[0].created_at, @@ -96,6 +96,16 @@ export class AdminCourseApprovalService { }; } catch (error) { logger.error('Failed to list pending courses', { error }); + const decoded = jwt.decode(token) as { id: number } | null; + await auditService.logSync({ + userId: decoded?.id || 0, + action: AuditAction.ERROR, + entityType: 'Course', + entityId: 0, + metadata: { + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } @@ -103,7 +113,7 @@ export class AdminCourseApprovalService { /** * Get course details for admin review */ - static async getCourseDetail(courseId: number): Promise { + static async getCourseDetail(token: string, courseId: number): Promise { try { const course = await prisma.course.findUnique({ where: { id: courseId }, @@ -123,7 +133,11 @@ export class AdminCourseApprovalService { }, chapters: { orderBy: { sort_order: 'asc' }, - include: { + select: { + id: true, + title: true, + sort_order: true, + is_published: true, lessons: { orderBy: { sort_order: 'asc' }, select: { @@ -214,6 +228,16 @@ export class AdminCourseApprovalService { }; } catch (error) { logger.error('Failed to get course detail', { error }); + const decoded = jwt.decode(token) as { id: number } | null; + await auditService.logSync({ + userId: decoded?.id || 0, + action: AuditAction.ERROR, + entityType: 'Course', + entityId: courseId, + metadata: { + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } @@ -238,7 +262,7 @@ export class AdminCourseApprovalService { // Update course status prisma.course.update({ where: { id: courseId }, - data: { + data: { status: 'APPROVED', approved_by: decoded.id, approved_at: new Date() @@ -275,6 +299,17 @@ export class AdminCourseApprovalService { }; } catch (error) { logger.error('Failed to approve course', { error }); + const decoded = jwt.decode(token) as { id: number } | null; + await auditService.logSync({ + userId: decoded?.id || 0, + action: AuditAction.ERROR, + entityType: 'Course', + entityId: courseId, + metadata: { + operation: 'approve_course', + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } @@ -303,12 +338,12 @@ export class AdminCourseApprovalService { // Update course status back to REJECTED prisma.course.update({ where: { id: courseId }, - data: { + data: { status: 'REJECTED', rejection_reason: comment, approved_by: null, approved_at: null - } + } }), // Create rejection record prisma.courseApproval.create({ @@ -341,6 +376,17 @@ export class AdminCourseApprovalService { }; } catch (error) { logger.error('Failed to reject course', { error }); + const decoded = jwt.decode(token) as { id: number } | null; + await auditService.logSync({ + userId: decoded?.id || 0, + action: AuditAction.ERROR, + entityType: 'Course', + entityId: courseId, + metadata: { + operation: 'reject_course', + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } diff --git a/Backend/src/services/ChaptersLesson.service.ts b/Backend/src/services/ChaptersLesson.service.ts index 02ce0a67..003670b1 100644 --- a/Backend/src/services/ChaptersLesson.service.ts +++ b/Backend/src/services/ChaptersLesson.service.ts @@ -142,6 +142,17 @@ export class ChaptersLessonService { return { code: 200, message: 'Chapter created successfully', data: chapter as ChapterData }; } catch (error) { logger.error(`Error creating chapter: ${error}`); + const decodedToken = jwt.decode(request.token) as { id: number } | null; + await auditService.logSync({ + userId: decodedToken?.id || 0, + action: AuditAction.ERROR, + entityType: 'Chapter', + entityId: 0, + metadata: { + operation: 'create_chapter', + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } @@ -163,6 +174,17 @@ export class ChaptersLessonService { return { code: 200, message: 'Chapter updated successfully', data: chapter as ChapterData }; } catch (error) { logger.error(`Error updating chapter: ${error}`); + const decodedToken = jwt.decode(request.token) as { id: number } | null; + await auditService.logSync({ + userId: decodedToken?.id || 0, + action: AuditAction.ERROR, + entityType: 'Chapter', + entityId: request.chapter_id, + metadata: { + operation: 'update_chapter', + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } @@ -197,6 +219,17 @@ export class ChaptersLessonService { return { code: 200, message: 'Chapter deleted successfully' }; } catch (error) { logger.error(`Error deleting chapter: ${error}`); + const decodedToken = jwt.decode(request.token) as { id: number } | null; + await auditService.logSync({ + userId: decodedToken?.id || 0, + action: AuditAction.ERROR, + entityType: 'Chapter', + entityId: request.chapter_id, + metadata: { + operation: 'delete_chapter', + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } @@ -280,6 +313,17 @@ export class ChaptersLessonService { return { code: 200, message: 'Chapter reordered successfully', data: chapters as ChapterData[] }; } catch (error) { logger.error(`Error reordering chapter: ${error}`); + const decodedToken = jwt.decode(request.token) as { id: number } | null; + await auditService.logSync({ + userId: decodedToken?.id || 0, + action: AuditAction.ERROR, + entityType: 'Chapter', + entityId: request.chapter_id, + metadata: { + operation: 'reorder_chapter', + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } @@ -354,6 +398,17 @@ export class ChaptersLessonService { return { code: 200, message: 'Lesson created successfully', data: lesson as LessonData }; } catch (error) { logger.error(`Error creating lesson: ${error}`); + const decodedToken = jwt.decode(request.token) as { id: number } | null; + await auditService.logSync({ + userId: decodedToken?.id || 0, + action: AuditAction.ERROR, + entityType: 'Lesson', + entityId: 0, + metadata: { + operation: 'create_lesson', + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } @@ -494,6 +549,17 @@ export class ChaptersLessonService { return { code: 200, message: 'Lesson fetched successfully', data: lessonData as LessonData }; } catch (error) { logger.error(`Error fetching lesson: ${error}`); + const decodedToken = jwt.decode(request.token) as { id: number } | null; + await auditService.logSync({ + userId: decodedToken?.id || 0, + action: AuditAction.ERROR, + entityType: 'Lesson', + entityId: request.lesson_id, + metadata: { + operation: 'get_lesson', + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } @@ -515,6 +581,17 @@ export class ChaptersLessonService { return { code: 200, message: 'Lesson updated successfully', data: lesson as LessonData }; } catch (error) { logger.error(`Error updating lesson: ${error}`); + const decodedToken = jwt.decode(request.token) as { id: number } | null; + await auditService.logSync({ + userId: decodedToken?.id || 0, + action: AuditAction.ERROR, + entityType: 'Lesson', + entityId: request.lesson_id, + metadata: { + operation: 'update_lesson', + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } @@ -605,6 +682,17 @@ export class ChaptersLessonService { return { code: 200, message: 'Lessons reordered successfully', data: lessons as LessonData[] }; } catch (error) { logger.error(`Error reordering lessons: ${error}`); + const decodedToken = jwt.decode(request.token) as { id: number } | null; + await auditService.logSync({ + userId: decodedToken?.id || 0, + action: AuditAction.ERROR, + entityType: 'Lesson', + entityId: request.lesson_id, + metadata: { + operation: 'reorder_lessons', + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } @@ -676,6 +764,17 @@ export class ChaptersLessonService { return { code: 200, message: 'Lesson deleted successfully' }; } catch (error) { logger.error(`Error deleting lesson: ${error}`); + const decodedToken = jwt.decode(request.token) as { id: number } | null; + await auditService.logSync({ + userId: decodedToken?.id || 0, + action: AuditAction.ERROR, + entityType: 'Lesson', + entityId: request.lesson_id, + metadata: { + operation: 'delete_lesson', + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } @@ -754,6 +853,17 @@ export class ChaptersLessonService { }; } catch (error) { logger.error(`Error uploading video: ${error}`); + const decodedToken = jwt.decode(request.token) as { id: number } | null; + await auditService.logSync({ + userId: decodedToken?.id || 0, + action: AuditAction.ERROR, + entityType: 'Lesson', + entityId: request.lesson_id, + metadata: { + operation: 'upload_video', + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } @@ -836,6 +946,17 @@ export class ChaptersLessonService { }; } catch (error) { logger.error(`Error updating video: ${error}`); + const decodedToken = jwt.decode(request.token) as { id: number } | null; + await auditService.logSync({ + userId: decodedToken?.id || 0, + action: AuditAction.ERROR, + entityType: 'Lesson', + entityId: request.lesson_id, + metadata: { + operation: 'update_video', + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } @@ -917,6 +1038,17 @@ export class ChaptersLessonService { }; } catch (error) { logger.error(`Error setting YouTube video: ${error}`); + const decodedToken = jwt.decode(request.token) as { id: number } | null; + await auditService.logSync({ + userId: decodedToken?.id || 0, + action: AuditAction.ERROR, + entityType: 'Lesson', + entityId: request.lesson_id, + metadata: { + operation: 'set_youtube_video', + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } @@ -993,6 +1125,17 @@ export class ChaptersLessonService { }; } catch (error) { logger.error(`Error uploading attachment: ${error}`); + const decodedToken = jwt.decode(request.token) as { id: number } | null; + await auditService.logSync({ + userId: decodedToken?.id || 0, + action: AuditAction.ERROR, + entityType: 'LessonAttachment', + entityId: request.lesson_id, + metadata: { + operation: 'upload_attachment', + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } @@ -1051,6 +1194,17 @@ export class ChaptersLessonService { return { code: 200, message: 'Attachment deleted successfully' }; } catch (error) { logger.error(`Error deleting attachment: ${error}`); + const decodedToken = jwt.decode(request.token) as { id: number } | null; + await auditService.logSync({ + userId: decodedToken?.id || 0, + action: AuditAction.ERROR, + entityType: 'LessonAttachment', + entityId: request.attachment_id, + metadata: { + operation: 'delete_attachment', + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } @@ -1127,6 +1281,17 @@ export class ChaptersLessonService { return { code: 200, message: 'Question added successfully', data: completeQuestion as QuizQuestionData }; } catch (error) { logger.error(`Error adding question: ${error}`); + const decodedToken = jwt.decode(request.token) as { id: number } | null; + await auditService.logSync({ + userId: decodedToken?.id || 0, + action: AuditAction.ERROR, + entityType: 'Question', + entityId: 0, + metadata: { + operation: 'add_question', + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } @@ -1202,6 +1367,17 @@ export class ChaptersLessonService { return { code: 200, message: 'Question updated successfully', data: completeQuestion as QuizQuestionData }; } catch (error) { logger.error(`Error updating question: ${error}`); + const decodedToken = jwt.decode(request.token) as { id: number } | null; + await auditService.logSync({ + userId: decodedToken?.id || 0, + action: AuditAction.ERROR, + entityType: 'Question', + entityId: request.question_id, + metadata: { + operation: 'update_question', + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } @@ -1295,6 +1471,17 @@ export class ChaptersLessonService { return { code: 200, message: 'Question reordered successfully', data: questions as QuizQuestionData[] }; } catch (error) { logger.error(`Error reordering question: ${error}`); + const decodedToken = jwt.decode(request.token) as { id: number } | null; + await auditService.logSync({ + userId: decodedToken?.id || 0, + action: AuditAction.ERROR, + entityType: 'Question', + entityId: request.question_id, + metadata: { + operation: 'reorder_question', + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } @@ -1343,6 +1530,17 @@ export class ChaptersLessonService { return { code: 200, message: 'Question deleted successfully' }; } catch (error) { logger.error(`Error deleting question: ${error}`); + const decodedToken = jwt.decode(request.token) as { id: number } | null; + await auditService.logSync({ + userId: decodedToken?.id || 0, + action: AuditAction.ERROR, + entityType: 'Question', + entityId: request.question_id, + metadata: { + operation: 'delete_question', + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } diff --git a/Backend/src/services/CoursesInstructor.service.ts b/Backend/src/services/CoursesInstructor.service.ts index 244e26ac..e1b40d0c 100644 --- a/Backend/src/services/CoursesInstructor.service.ts +++ b/Backend/src/services/CoursesInstructor.service.ts @@ -10,6 +10,7 @@ import { UpdateCourseInput, createCourseResponse, GetMyCourseResponse, + ListMyCoursesInput, ListMyCourseResponse, addinstructorCourse, addinstructorCourseResponse, @@ -33,6 +34,8 @@ import { GetEnrolledStudentDetailInput, GetEnrolledStudentDetailResponse, GetCourseApprovalHistoryResponse, + CloneCourseInput, + CloneCourseResponse, setCourseDraft, setCourseDraftResponse, } from "../types/CoursesInstructor.types"; @@ -102,16 +105,27 @@ export class CoursesInstructorService { }; } catch (error) { logger.error('Failed to create course', { error }); + await auditService.logSync({ + userId: userId, + action: AuditAction.ERROR, + entityType: 'Course', + entityId: 0, // Failed to create, so no ID + metadata: { + operation: 'create_course', + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } - static async listMyCourses(token: string): Promise { + static async listMyCourses(input: ListMyCoursesInput): Promise { try { - const decoded = jwt.verify(token, config.jwt.secret) as { id: number; type: string }; + const decoded = jwt.verify(input.token, config.jwt.secret) as { id: number; type: string }; const courseInstructors = await prisma.courseInstructor.findMany({ where: { - user_id: decoded.id + user_id: decoded.id, + course: input.status ? { status: input.status } : undefined }, include: { course: true @@ -143,6 +157,17 @@ export class CoursesInstructorService { }; } catch (error) { logger.error('Failed to retrieve courses', { error }); + const decoded = jwt.decode(input.token) as { id: number } | null; + await auditService.logSync({ + userId: decoded?.id || undefined, + action: AuditAction.ERROR, + entityType: 'Course', + entityId: 0, + metadata: { + operation: 'list_my_courses', + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } @@ -200,6 +225,17 @@ export class CoursesInstructorService { }; } catch (error) { logger.error('Failed to retrieve course', { error }); + const decoded = jwt.decode(getmyCourse.token) as { id: number } | null; + await auditService.logSync({ + userId: decoded?.id || undefined, + action: AuditAction.ERROR, + entityType: 'Course', + entityId: getmyCourse.course_id, + metadata: { + operation: 'get_my_course', + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } @@ -222,6 +258,17 @@ export class CoursesInstructorService { }; } catch (error) { logger.error('Failed to update course', { error }); + const decoded = jwt.decode(token) as { id: number } | null; + await auditService.logSync({ + userId: decoded?.id || undefined, + action: AuditAction.ERROR, + entityType: 'Course', + entityId: courseId, + metadata: { + operation: 'update_course', + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } @@ -275,6 +322,17 @@ export class CoursesInstructorService { }; } catch (error) { logger.error('Failed to upload thumbnail', { error }); + const decoded = jwt.decode(token) as { id: number } | null; + await auditService.logSync({ + userId: decoded?.id || undefined, + action: AuditAction.ERROR, + entityType: 'Course', + entityId: courseId, + metadata: { + operation: 'upload_thumbnail', + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } @@ -291,6 +349,15 @@ export class CoursesInstructorService { id: courseId } }); + await auditService.logSync({ + userId: courseInstructorId.user_id, + action: AuditAction.DELETE, + entityType: 'Course', + entityId: courseId, + metadata: { + operation: 'delete_course' + } + }); return { code: 200, message: 'Course deleted successfully', @@ -298,6 +365,17 @@ export class CoursesInstructorService { }; } catch (error) { logger.error('Failed to delete course', { error }); + const decoded = jwt.decode(token) as { id: number } | null; + await auditService.logSync({ + userId: decoded?.id || undefined, + action: AuditAction.ERROR, + entityType: 'Course', + entityId: courseId, + metadata: { + operation: 'delete_course', + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } @@ -319,12 +397,32 @@ export class CoursesInstructorService { status: 'PENDING' } }); + await auditService.logSync({ + userId: decoded.id, + action: AuditAction.UPDATE, + entityType: 'Course', + entityId: sendCourseForReview.course_id, + metadata: { + operation: 'send_course_for_review' + } + }); return { code: 200, message: 'Course sent for review successfully', }; } catch (error) { logger.error('Failed to send course for review', { error }); + const decoded = jwt.decode(sendCourseForReview.token) as { id: number } | null; + await auditService.logSync({ + userId: decoded?.id || undefined, + action: AuditAction.ERROR, + entityType: 'Course', + entityId: sendCourseForReview.course_id, + metadata: { + operation: 'send_course_for_review', + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } @@ -347,6 +445,17 @@ export class CoursesInstructorService { }; } catch (error) { logger.error('Failed to set course to draft', { error }); + const decoded = jwt.decode(setCourseDraft.token) as { id: number } | null; + await auditService.logSync({ + userId: decoded?.id || undefined, + action: AuditAction.ERROR, + entityType: 'Course', + entityId: setCourseDraft.course_id, + metadata: { + operation: 'set_course_draft', + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } @@ -358,8 +467,6 @@ export class CoursesInstructorService { total: number; }> { try { - const decoded = jwt.verify(token, config.jwt.secret) as { id: number; type: string }; - // Validate instructor access await this.validateCourseInstructor(token, courseId); @@ -384,6 +491,17 @@ export class CoursesInstructorService { }; } catch (error) { logger.error('Failed to retrieve course approvals', { error }); + const decoded = jwt.decode(token) as { id: number } | null; + await auditService.logSync({ + userId: decoded?.id || undefined, + action: AuditAction.ERROR, + entityType: 'Course', + entityId: courseId, + metadata: { + operation: 'get_course_approvals', + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } @@ -445,6 +563,17 @@ export class CoursesInstructorService { }; } catch (error) { logger.error('Failed to search instructors', { error }); + const decoded = jwt.decode(input.token) as { id: number } | null; + await auditService.logSync({ + userId: decoded?.id || undefined, + action: AuditAction.ERROR, + entityType: 'Course', + entityId: input.course_id, + metadata: { + operation: 'search_instructors', + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } @@ -490,12 +619,35 @@ export class CoursesInstructorService { } }); + const decoded = jwt.decode(addinstructorCourse.token) as { id: number } | null; + await auditService.logSync({ + userId: decoded?.id || 0, + action: AuditAction.CREATE, + entityType: 'Course', + entityId: addinstructorCourse.course_id, + metadata: { + operation: 'add_instructor_to_course', + instructor_id: user.id, + } + }); + return { code: 200, message: 'Instructor added to course successfully', }; } catch (error) { logger.error('Failed to add instructor to course', { error }); + const decoded = jwt.decode(addinstructorCourse.token) as { id: number } | null; + await auditService.logSync({ + userId: decoded?.id || undefined, + action: AuditAction.ERROR, + entityType: 'Course', + entityId: addinstructorCourse.course_id, + metadata: { + operation: 'add_instructor_to_course', + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } @@ -511,12 +663,36 @@ export class CoursesInstructorService { }, } }); + + await auditService.logSync({ + userId: decoded?.id || 0, + action: AuditAction.DELETE, + entityType: 'Course', + entityId: removeinstructorCourse.course_id, + metadata: { + operation: 'remove_instructor_from_course', + instructor_id: removeinstructorCourse.user_id, + course_id: removeinstructorCourse.course_id, + } + }); + return { code: 200, message: 'Instructor removed from course successfully', }; } catch (error) { logger.error('Failed to remove instructor from course', { error }); + const decoded = jwt.decode(removeinstructorCourse.token) as { id: number } | null; + await auditService.logSync({ + userId: decoded?.id || undefined, + action: AuditAction.ERROR, + entityType: 'Course', + entityId: removeinstructorCourse.course_id, + metadata: { + operation: 'remove_instructor_from_course', + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } @@ -567,6 +743,17 @@ export class CoursesInstructorService { }; } catch (error) { logger.error('Failed to retrieve instructors of course', { error }); + const decoded = jwt.decode(listinstructorCourse.token) as { id: number } | null; + await auditService.logSync({ + userId: decoded?.id || undefined, + action: AuditAction.ERROR, + entityType: 'Course', + entityId: listinstructorCourse.course_id, + metadata: { + operation: 'list_instructors_of_course', + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } @@ -585,12 +772,36 @@ export class CoursesInstructorService { is_primary: true, } }); + + await auditService.logSync({ + userId: decoded?.id || 0, + action: AuditAction.UPDATE, + entityType: 'Course', + entityId: setprimaryCourseInstructor.course_id, + metadata: { + operation: 'set_primary_instructor', + instructor_id: setprimaryCourseInstructor.user_id, + course_id: setprimaryCourseInstructor.course_id, + } + }); + return { code: 200, message: 'Primary instructor set successfully', }; } catch (error) { logger.error('Failed to set primary instructor', { error }); + const decoded = jwt.decode(setprimaryCourseInstructor.token) as { id: number } | null; + await auditService.logSync({ + userId: decoded?.id || undefined, + action: AuditAction.ERROR, + entityType: 'Course', + entityId: setprimaryCourseInstructor.course_id, + metadata: { + operation: 'set_primary_instructor', + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } @@ -629,7 +840,6 @@ export class CoursesInstructorService { static async getEnrolledStudents(input: GetEnrolledStudentsInput): Promise { try { const { token, course_id, page = 1, limit = 20, search, status } = input; - const decoded = jwt.verify(token, config.jwt.secret) as { id: number }; // Validate instructor await this.validateCourseInstructor(token, course_id); @@ -707,6 +917,17 @@ export class CoursesInstructorService { }; } catch (error) { logger.error(`Error getting enrolled students: ${error}`); + const decoded = jwt.decode(input.token) as { id: number } | null; + await auditService.logSync({ + userId: decoded?.id || undefined, + action: AuditAction.ERROR, + entityType: 'Course', + entityId: input.course_id, + metadata: { + operation: 'get_enrolled_students', + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } @@ -758,7 +979,7 @@ export class CoursesInstructorService { // Get all enrolled students who have attempted this quiz const skip = (page - 1) * limit; - + // Get unique users who attempted this quiz const quizAttempts = await prisma.quizAttempt.findMany({ where: { quiz_id: lesson.quiz.id }, @@ -874,6 +1095,17 @@ export class CoursesInstructorService { }; } catch (error) { logger.error(`Error getting quiz scores: ${error}`); + const decoded = jwt.decode(input.token) as { id: number } | null; + await auditService.logSync({ + userId: decoded?.id || undefined, + action: AuditAction.ERROR, + entityType: 'Course', + entityId: input.course_id, + metadata: { + operation: 'get_quiz_scores', + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } @@ -885,7 +1117,6 @@ export class CoursesInstructorService { static async getQuizAttemptDetail(input: GetQuizAttemptDetailInput): Promise { try { const { token, course_id, lesson_id, student_id } = input; - const decoded = jwt.verify(token, config.jwt.secret) as { id: number }; // Validate instructor await this.validateCourseInstructor(token, course_id); @@ -988,6 +1219,17 @@ export class CoursesInstructorService { }; } catch (error) { logger.error(`Error getting quiz attempt detail: ${error}`); + const decoded = jwt.decode(input.token) as { id: number } | null; + await auditService.logSync({ + userId: decoded?.id || undefined, + action: AuditAction.ERROR, + entityType: 'Course', + entityId: input.course_id, + metadata: { + operation: 'get_quiz_attempt_detail', + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } @@ -1125,6 +1367,17 @@ export class CoursesInstructorService { }; } catch (error) { logger.error(`Error getting enrolled student detail: ${error}`); + const decoded = jwt.decode(input.token) as { id: number } | null; + await auditService.logSync({ + userId: decoded?.id || undefined, + action: AuditAction.ERROR, + entityType: 'Course', + entityId: input.course_id, + metadata: { + operation: 'get_enrolled_student_detail', + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } @@ -1181,6 +1434,241 @@ export class CoursesInstructorService { }; } catch (error) { logger.error(`Error getting course approval history: ${error}`); + const decoded = jwt.decode(token) as { id: number } | null; + await auditService.logSync({ + userId: decoded?.id || undefined, + action: AuditAction.ERROR, + entityType: 'Course', + entityId: courseId, + metadata: { + operation: 'get_course_approval_history', + error: error instanceof Error ? error.message : String(error) + } + }); + throw error; + } + } + + /** + * Clone a course (including chapters, lessons, quizzes, attachments) + */ + static async cloneCourse(input: CloneCourseInput): Promise { + try { + const { token, course_id, title } = input; + const decoded = jwt.verify(token, config.jwt.secret) as { id: number }; + + // Validate instructor + const courseInstructor = await this.validateCourseInstructor(token, course_id); + if (!courseInstructor) { + throw new ForbiddenError('You are not an instructor of this course'); + } + + // Fetch original course with all relations + const originalCourse = await prisma.course.findUnique({ + where: { id: course_id }, + include: { + chapters: { + orderBy: { sort_order: 'asc' }, + include: { + lessons: { + orderBy: { sort_order: 'asc' }, + include: { + attachments: true, + quiz: { + include: { + questions: { + include: { + choices: true + } + } + } + } + } + } + } + } + } + }); + + if (!originalCourse) { + throw new NotFoundError('Course not found'); + } + + // Use transaction for atomic creation + const newCourse = await prisma.$transaction(async (tx) => { + // 1. Create new Course + const createdCourse = await tx.course.create({ + data: { + title: title as Prisma.InputJsonValue, + slug: `${originalCourse.slug}-clone-${Date.now()}`, // Temporary slug + description: originalCourse.description ?? Prisma.JsonNull, + thumbnail_url: originalCourse.thumbnail_url, + category_id: originalCourse.category_id, + price: originalCourse.price, + is_free: originalCourse.is_free, + have_certificate: originalCourse.have_certificate, + status: 'DRAFT', // Reset status + created_by: decoded.id + } + }); + + // 2. Add Instructor (Requester as primary) + await tx.courseInstructor.create({ + data: { + course_id: createdCourse.id, + user_id: decoded.id, + is_primary: true + } + }); + + // Mapping for oldLessonId -> newLessonId for prerequisites + const lessonIdMap = new Map(); + const lessonsToUpdatePrerequisites: { newLessonId: number; oldPrerequisites: number[] }[] = []; + + // 3. Clone Chapters and Lessons + for (const chapter of originalCourse.chapters) { + const newChapter = await tx.chapter.create({ + data: { + course_id: createdCourse.id, + title: chapter.title as Prisma.InputJsonValue, + description: chapter.description ?? Prisma.JsonNull, + sort_order: chapter.sort_order, + is_published: chapter.is_published + } + }); + + for (const lesson of chapter.lessons) { + const newLesson = await tx.lesson.create({ + data: { + chapter_id: newChapter.id, + title: lesson.title as Prisma.InputJsonValue, + content: lesson.content ?? Prisma.JsonNull, + type: lesson.type, + sort_order: lesson.sort_order, + is_published: lesson.is_published, + duration_minutes: lesson.duration_minutes, + prerequisite_lesson_ids: Prisma.JsonNull // Will update later + } + }); + + lessonIdMap.set(lesson.id, newLesson.id); + + // Store prerequisites for later update + if (Array.isArray(lesson.prerequisite_lesson_ids) && lesson.prerequisite_lesson_ids.length > 0) { + lessonsToUpdatePrerequisites.push({ + newLessonId: newLesson.id, + oldPrerequisites: lesson.prerequisite_lesson_ids as number[] + }); + } + + // Clone Attachments + if (lesson.attachments && lesson.attachments.length > 0) { + await tx.lessonAttachment.createMany({ + data: lesson.attachments.map(att => ({ + lesson_id: newLesson.id, + file_name: att.file_name, + file_path: att.file_path, // Reuse file path + file_size: att.file_size, + mime_type: att.mime_type, + sort_order: att.sort_order, + description: att.description ?? Prisma.JsonNull + })) + }); + } + + // Clone Quiz + if (lesson.quiz) { + const newQuiz = await tx.quiz.create({ + data: { + lesson_id: newLesson.id, + title: lesson.quiz.title as Prisma.InputJsonValue, + description: lesson.quiz.description ?? Prisma.JsonNull, + passing_score: lesson.quiz.passing_score, + allow_multiple_attempts: lesson.quiz.allow_multiple_attempts, + time_limit: lesson.quiz.time_limit, + shuffle_questions: lesson.quiz.shuffle_questions, + shuffle_choices: lesson.quiz.shuffle_choices, + show_answers_after_completion: lesson.quiz.show_answers_after_completion, + created_by: decoded.id + } + }); + + for (const question of lesson.quiz.questions) { + await tx.question.create({ + data: { + quiz_id: newQuiz.id, + question: question.question as Prisma.InputJsonValue, + explanation: question.explanation ?? Prisma.JsonNull, + question_type: question.question_type, + score: question.score, + sort_order: question.sort_order, + choices: { + create: question.choices.map(choice => ({ + text: choice.text as Prisma.InputJsonValue, + is_correct: choice.is_correct, + sort_order: choice.sort_order + })) + } + } + }); + } + } + } + } + + // 4. Update Prerequisites + for (const item of lessonsToUpdatePrerequisites) { + const newPrerequisites = item.oldPrerequisites + .map(oldId => lessonIdMap.get(oldId)) + .filter((id): id is number => id !== undefined); + + if (newPrerequisites.length > 0) { + await tx.lesson.update({ + where: { id: item.newLessonId }, + data: { + prerequisite_lesson_ids: newPrerequisites + } + }); + } + } + + return createdCourse; + }); + + await auditService.logSync({ + userId: decoded.id, + action: AuditAction.CREATE, + entityType: 'Course', + entityId: newCourse.id, + metadata: { + operation: 'clone_course', + original_course_id: course_id, + new_course_id: newCourse.id + } + }); + + return { + code: 201, + message: 'Course cloned successfully', + data: { + id: newCourse.id, + title: newCourse.title as { th: string; en: string } + } + }; + + } catch (error) { + logger.error(`Error cloning course: ${error}`); + const decoded = jwt.decode(input.token) as { id: number } | null; + await auditService.logSync({ + userId: decoded?.id || 0, + action: AuditAction.ERROR, + entityType: 'Course', + entityId: input.course_id, + metadata: { + operation: 'clone_course', + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } diff --git a/Backend/src/services/CoursesStudent.service.ts b/Backend/src/services/CoursesStudent.service.ts index 87679ab3..986695b1 100644 --- a/Backend/src/services/CoursesStudent.service.ts +++ b/Backend/src/services/CoursesStudent.service.ts @@ -186,7 +186,20 @@ export class CoursesStudentService { }, }; } catch (error) { - logger.error(error); + logger.error(`Error enrolling in course: ${error}`); + const decoded = jwt.decode(input.token) as { id: number } | null; + await auditService.logSync({ + userId: decoded?.id || 0, + action: AuditAction.ERROR, + entityType: 'Enrollment', + entityId: 0, + metadata: { + operation: 'enroll_course', + course_id: input.course_id, + error: error instanceof Error ? error.message : String(error) + } + }); + throw error; } } @@ -261,6 +274,17 @@ export class CoursesStudentService { }; } catch (error) { logger.error(error); + const decoded = jwt.decode(input.token) as { id: number } | null; + await auditService.logSync({ + userId: decoded?.id || 0, + action: AuditAction.ERROR, + entityType: 'Enrollment', + entityId: 0, + metadata: { + operation: 'get_enrolled_courses', + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } @@ -316,6 +340,19 @@ export class CoursesStudentService { throw new ForbiddenError('You are not enrolled in this course'); } + // Update last_accessed_at (fire-and-forget — ไม่ block response) + if (enrollment.status === 'ENROLLED') { + prisma.enrollment.update({ + where: { + unique_enrollment: { + user_id: decoded.id, + course_id, + }, + }, + data: { last_accessed_at: new Date() }, + }).catch(err => logger.warn(`Failed to update last_accessed_at: ${err}`)); + } + // Get all lesson progress for this user and course const lessonIds = course.chapters.flatMap(ch => ch.lessons.map(l => l.id)); const lessonProgress = await prisma.lessonProgress.findMany({ @@ -416,6 +453,17 @@ export class CoursesStudentService { }; } catch (error) { logger.error(error); + const decoded = jwt.decode(input.token) as { id: number } | null; + await auditService.logSync({ + userId: decoded?.id || 0, + action: AuditAction.ERROR, + entityType: 'Enrollment', + entityId: 0, + metadata: { + operation: 'get_course_learning', + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } @@ -678,6 +726,17 @@ export class CoursesStudentService { }; } catch (error) { logger.error(error); + const decoded = jwt.decode(input.token) as { id: number } | null; + await auditService.logSync({ + userId: decoded?.id || 0, + action: AuditAction.ERROR, + entityType: 'Enrollment', + entityId: 0, + metadata: { + operation: 'get_course_learning', + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } @@ -866,6 +925,17 @@ export class CoursesStudentService { }; } catch (error) { logger.error(error); + const decoded = jwt.decode(input.token) as { id: number } | null; + await auditService.logSync({ + userId: decoded?.id || 0, + action: AuditAction.ERROR, + entityType: 'Enrollment', + entityId: 0, + metadata: { + operation: 'get_course_learning', + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } @@ -940,6 +1010,17 @@ export class CoursesStudentService { }; } catch (error) { logger.error(error); + const decoded = jwt.decode(input.token) as { id: number } | null; + await auditService.logSync({ + userId: decoded?.id || 0, + action: AuditAction.ERROR, + entityType: 'Enrollment', + entityId: 0, + metadata: { + operation: 'get_course_learning', + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } @@ -1037,6 +1118,17 @@ export class CoursesStudentService { }; } catch (error) { logger.error(error); + const decoded = jwt.decode(input.token) as { id: number } | null; + await auditService.logSync({ + userId: decoded?.id || 0, + action: AuditAction.ERROR, + entityType: 'Enrollment', + entityId: 0, + metadata: { + operation: 'get_course_learning', + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } @@ -1168,7 +1260,19 @@ export class CoursesStudentService { }, }; } catch (error) { - logger.error(error); + logger.error(`Error completing lesson: ${error}`); + const decoded = jwt.decode(input.token) as { id: number } | null; + await auditService.logSync({ + userId: decoded?.id || 0, + action: AuditAction.ERROR, + entityType: 'LessonProgress', + entityId: input.lesson_id, + metadata: { + operation: 'complete_lesson', + lesson_id: input.lesson_id, + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } @@ -1213,22 +1317,14 @@ export class CoursesStudentService { }, }); - if (!lesson) { - throw new NotFoundError('Lesson not found'); - } + if (!lesson) throw new NotFoundError('Lesson not found'); - if (lesson.type !== 'QUIZ') { - throw new ValidationError('This lesson is not a quiz'); - } + if (lesson.type !== 'QUIZ') throw new ValidationError('This lesson is not a quiz'); - if (!lesson.quiz) { - throw new NotFoundError('Quiz not found for this lesson'); - } + if (!lesson.quiz) throw new NotFoundError('Quiz not found for this lesson'); // Verify lesson belongs to the course - if (lesson.chapter.course_id !== course_id) { - throw new NotFoundError('Lesson not found in this course'); - } + if (lesson.chapter.course_id !== course_id) throw new NotFoundError('Lesson not found in this course'); const quiz = lesson.quiz; @@ -1332,7 +1428,20 @@ export class CoursesStudentService { }, }; } catch (error) { - logger.error(error); + logger.error(`Error submitting quiz: ${error}`); + const decoded = jwt.decode(input.token) as { id: number } | null; + await auditService.logSync({ + userId: decoded?.id || 0, + action: AuditAction.ERROR, + entityType: 'QuizAttempt', + entityId: 0, + metadata: { + operation: 'submit_quiz', + course_id: input.course_id, + lesson_id: input.lesson_id, + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } @@ -1373,22 +1482,14 @@ export class CoursesStudentService { }, }); - if (!lesson) { - throw new NotFoundError('Lesson not found'); - } + if (!lesson) throw new NotFoundError('Lesson not found'); - if (lesson.type !== 'QUIZ') { - throw new ValidationError('This lesson is not a quiz'); - } + if (lesson.type !== 'QUIZ') throw new ValidationError('This lesson is not a quiz'); - if (!lesson.quiz) { - throw new NotFoundError('Quiz not found for this lesson'); - } + if (!lesson.quiz) throw new NotFoundError('Quiz not found for this lesson'); // Verify lesson belongs to the course - if (lesson.chapter.course_id !== course_id) { - throw new NotFoundError('Lesson not found in this course'); - } + if (lesson.chapter.course_id !== course_id) throw new NotFoundError('Lesson not found in this course'); // Get all quiz attempts for this user const attempts = await prisma.quizAttempt.findMany({ @@ -1438,6 +1539,21 @@ export class CoursesStudentService { }; } catch (error) { logger.error(error); + const decoded = jwt.decode(input.token) as { id: number } | null; + if (decoded?.id) { + await auditService.logSync({ + userId: decoded.id, + action: AuditAction.ERROR, + entityType: 'QuizAttempt', + entityId: 0, + metadata: { + operation: 'get_quiz_attempts', + course_id: input.course_id, + lesson_id: input.lesson_id, + error: error instanceof Error ? error.message : String(error) + } + }); + } throw error; } } diff --git a/Backend/src/services/RecommendedCourses.service.ts b/Backend/src/services/RecommendedCourses.service.ts index b5c977c5..22440eb2 100644 --- a/Backend/src/services/RecommendedCourses.service.ts +++ b/Backend/src/services/RecommendedCourses.service.ts @@ -8,7 +8,8 @@ import { ListApprovedCoursesResponse, GetCourseByIdResponse, ToggleRecommendedResponse, - RecommendedCourseData + RecommendedCourseData, + RecommendedCourseDetailData } from '../types/RecommendedCourses.types'; import { auditService } from './audit.service'; import { AuditAction } from '@prisma/client'; @@ -18,10 +19,24 @@ export class RecommendedCoursesService { /** * List all approved courses (for admin to manage recommendations) */ - static async listApprovedCourses(): Promise { + static async listApprovedCourses( + token: string, + filters?: { search?: string; categoryId?: number } + ): Promise { try { + const { search, categoryId } = filters ?? {}; + const courses = await prisma.course.findMany({ - where: { status: 'APPROVED' }, + where: { + status: 'APPROVED', + ...(categoryId ? { category_id: categoryId } : {}), + ...(search ? { + OR: [ + { title: { path: ['th'], string_contains: search } }, + { title: { path: ['en'], string_contains: search } } + ] + } : {}) + }, orderBy: [ { is_recommended: 'desc' }, { updated_at: 'desc' } @@ -40,9 +55,9 @@ export class RecommendedCoursesService { } } }, - chapters: { - include: { - lessons: true + _count: { + select: { + chapters: true } } } @@ -81,8 +96,7 @@ export class RecommendedCoursesService { 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) + chapters_count: course._count.chapters, } as RecommendedCourseData; })); @@ -94,6 +108,19 @@ export class RecommendedCoursesService { }; } catch (error) { logger.error('Failed to list approved courses', { error }); + const decoded = jwt.decode(token) as { id: number } | null; + if (decoded?.id) { + await auditService.logSync({ + userId: decoded.id, + action: AuditAction.ERROR, + entityType: 'RecommendedCourses', + entityId: 0, + metadata: { + operation: 'list_approved_courses', + error: error instanceof Error ? error.message : String(error) + } + }); + } throw error; } } @@ -101,7 +128,7 @@ export class RecommendedCoursesService { /** * Get course by ID (for admin to view details) */ - static async getCourseById(courseId: number): Promise { + static async getCourseById(token: string, courseId: number): Promise { try { const course = await prisma.course.findUnique({ where: { id: courseId }, @@ -145,7 +172,7 @@ export class RecommendedCoursesService { } } - const data: RecommendedCourseData = { + const data: RecommendedCourseDetailData = { id: course.id, title: course.title as { th: string; en: string }, slug: course.slug, @@ -168,8 +195,15 @@ export class RecommendedCoursesService { 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) + chapters: course.chapters.map(ch => ({ + id: ch.id, + title: ch.title as { th: string; en: string }, + sort_order: ch.sort_order, + lessons: ch.lessons.map(l => ({ + id: l.id, + title: l.title as { th: string; en: string } + })) + })) }; return { @@ -179,6 +213,19 @@ export class RecommendedCoursesService { }; } catch (error) { logger.error('Failed to get course by ID', { error }); + const decoded = jwt.decode(token) as { id: number } | null; + if (decoded?.id) { + await auditService.logSync({ + userId: decoded.id, + action: AuditAction.ERROR, + entityType: 'RecommendedCourses', + entityId: 0, + metadata: { + operation: 'get_course_by_id', + error: error instanceof Error ? error.message : String(error) + } + }); + } throw error; } } @@ -229,6 +276,17 @@ export class RecommendedCoursesService { }; } catch (error) { logger.error('Failed to toggle recommended status', { error }); + const decoded = jwt.decode(token) as { id: number } | null; + await auditService.logSync({ + userId: decoded?.id || 0, + action: AuditAction.ERROR, + entityType: 'RecommendedCourses', + entityId: courseId, + metadata: { + operation: 'toggle_recommended', + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } diff --git a/Backend/src/services/announcements.service.ts b/Backend/src/services/announcements.service.ts index eb26b3d1..7e8b2d3e 100644 --- a/Backend/src/services/announcements.service.ts +++ b/Backend/src/services/announcements.service.ts @@ -20,6 +20,8 @@ import { } from '../types/announcements.types'; import { CoursesInstructorService } from './CoursesInstructor.service'; import { uploadFile, deleteFile, getPresignedUrl } from '../config/minio'; +import { auditService } from './audit.service'; +import { AuditAction } from '@prisma/client'; export class AnnouncementsService { @@ -37,9 +39,7 @@ export class AnnouncementsService { where: { id: decoded.id }, include: { role: true }, }); - if (!user) { - throw new UnauthorizedError('Invalid token'); - } + if (!user) throw new UnauthorizedError('Invalid token'); // Admin can access all courses const isAdmin = user.role.code === 'ADMIN'; @@ -130,6 +130,16 @@ export class AnnouncementsService { }; } catch (error) { logger.error(`Error listing announcements: ${error}`); + const decoded = jwt.decode(input.token) as { id: number } | null; + await auditService.logSync({ + userId: decoded?.id || 0, + action: AuditAction.ERROR, + entityType: 'Announcement', + entityId: 0, + metadata: { + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } @@ -226,6 +236,16 @@ export class AnnouncementsService { }; } catch (error) { logger.error(`Error creating announcement: ${error}`); + const decoded = jwt.decode(input.token) as { id: number } | null; + await auditService.logSync({ + userId: decoded?.id || 0, + action: AuditAction.ERROR, + entityType: 'Announcement', + entityId: 0, + metadata: { + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } @@ -300,6 +320,16 @@ export class AnnouncementsService { }; } catch (error) { logger.error(`Error updating announcement: ${error}`); + const decoded = jwt.decode(input.token) as { id: number } | null; + await auditService.logSync({ + userId: decoded?.id || 0, + action: AuditAction.ERROR, + entityType: 'Announcement', + entityId: 0, + metadata: { + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } @@ -346,6 +376,16 @@ export class AnnouncementsService { }; } catch (error) { logger.error(`Error deleting announcement: ${error}`); + const decoded = jwt.decode(input.token) as { id: number } | null; + await auditService.logSync({ + userId: decoded?.id || 0, + action: AuditAction.ERROR, + entityType: 'Announcement', + entityId: 0, + metadata: { + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } @@ -411,6 +451,16 @@ export class AnnouncementsService { }; } catch (error) { logger.error(`Error uploading attachment: ${error}`); + const decoded = jwt.decode(input.token) as { id: number } | null; + await auditService.logSync({ + userId: decoded?.id || 0, + action: AuditAction.ERROR, + entityType: 'Announcement', + entityId: 0, + metadata: { + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } @@ -458,6 +508,16 @@ export class AnnouncementsService { }; } catch (error) { logger.error(`Error deleting attachment: ${error}`); + const decoded = jwt.decode(input.token) as { id: number } | null; + await auditService.logSync({ + userId: decoded?.id || 0, + action: AuditAction.ERROR, + entityType: 'Announcement', + entityId: 0, + metadata: { + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } diff --git a/Backend/src/services/audit.service.ts b/Backend/src/services/audit.service.ts index e913be7b..52038129 100644 --- a/Backend/src/services/audit.service.ts +++ b/Backend/src/services/audit.service.ts @@ -37,19 +37,23 @@ export class AuditService { * Log พร้อม await (สำหรับ critical actions) */ async logSync(params: CreateAuditLogParams): Promise { - await prisma.auditLog.create({ - data: { - user_id: params.userId, - action: params.action, - entity_type: params.entityType, - entity_id: params.entityId, - old_value: params.oldValue, - new_value: params.newValue, - ip_address: params.ipAddress, - user_agent: params.userAgent, - metadata: params.metadata, - }, - }); + try { + await prisma.auditLog.create({ + data: { + user_id: params.userId, + action: params.action, + entity_type: params.entityType, + entity_id: params.entityId, + old_value: params.oldValue, + new_value: params.newValue, + ip_address: params.ipAddress, + user_agent: params.userAgent, + metadata: params.metadata, + }, + }); + } catch (error) { + logger.error('Failed to create audit log (sync)', { error, params }); + } } /** diff --git a/Backend/src/services/auth.service.ts b/Backend/src/services/auth.service.ts index ad4bcbbc..66899bed 100644 --- a/Backend/src/services/auth.service.ts +++ b/Backend/src/services/auth.service.ts @@ -83,167 +83,201 @@ export class AuthService { * User registration */ async register(data: RegisterRequest): Promise { - const { username, email, password, first_name, last_name, prefix, phone } = data; + try { + const { username, email, password, first_name, last_name, prefix, phone } = data; - // Check if username already exists - const existingUsername = await prisma.user.findUnique({ - where: { username } - }); + // Check if username already exists + const existingUsername = await prisma.user.findUnique({ + where: { username } + }); - if (existingUsername) { - throw new ValidationError('Username already exists'); - } - - // Check if email already exists - const existingEmail = await prisma.user.findUnique({ - where: { email } - }); - - if (existingEmail) { - throw new ValidationError('Email already exists'); - } - - // Check if phone number already exists in user profiles - const existingPhone = await prisma.userProfile.findFirst({ - where: { phone } - }); - - if (existingPhone) { - throw new ValidationError('Phone number already exists'); - } - - // Get STUDENT role - const studentRole = await prisma.role.findUnique({ - where: { code: 'STUDENT' } - }); - - if (!studentRole) { - logger.error('STUDENT role not found in database'); - throw new Error('System configuration error'); - } - - // Hash password - const hashedPassword = await bcrypt.hash(password, 10); - - // Create user with profile - const user = await prisma.user.create({ - data: { - username, - email, - password: hashedPassword, - role_id: studentRole.id, - profile: { - create: { - prefix: prefix ? prefix as Prisma.InputJsonValue : Prisma.JsonNull, - first_name, - last_name, - phone - } - } - }, - include: { - role: true, - profile: true + if (existingUsername) { + throw new ValidationError('Username already exists'); } - }); - logger.info('New user registered', { userId: user.id, username: user.username }); + // Check if email already exists + const existingEmail = await prisma.user.findUnique({ + where: { email } + }); - // Audit log - REGISTER (Student) - auditService.log({ - userId: user.id, - action: AuditAction.CREATE, - entityType: 'User', - entityId: user.id, - newValue: { username: user.username, email: user.email, role: 'STUDENT' } - }); + if (existingEmail) { + throw new ValidationError('Email already exists'); + } - return { - user: this.formatUserResponseSync(user), - message: 'Registration successful' - }; + // Check if phone number already exists in user profiles + const existingPhone = await prisma.userProfile.findFirst({ + where: { phone } + }); + + if (existingPhone) { + throw new ValidationError('Phone number already exists'); + } + + // Get STUDENT role + const studentRole = await prisma.role.findUnique({ + where: { code: 'STUDENT' } + }); + + if (!studentRole) { + logger.error('STUDENT role not found in database'); + throw new Error('System configuration error'); + } + + // Hash password + const hashedPassword = await bcrypt.hash(password, 10); + + // Create user with profile + const user = await prisma.user.create({ + data: { + username, + email, + password: hashedPassword, + role_id: studentRole.id, + profile: { + create: { + prefix: prefix ? prefix as Prisma.InputJsonValue : Prisma.JsonNull, + first_name, + last_name, + phone + } + } + }, + include: { + role: true, + profile: true + } + }); + + logger.info('New user registered', { userId: user.id, username: user.username }); + + // Audit log - REGISTER (Student) + auditService.log({ + userId: user.id, + action: AuditAction.CREATE, + entityType: 'User', + entityId: user.id, + newValue: { username: user.username, email: user.email, role: 'STUDENT' } + }); + + return { + user: this.formatUserResponseSync(user), + message: 'Registration successful' + }; + } catch (error) { + logger.error('Failed to register user', { error }); + await auditService.logSync({ + userId: 0, + action: AuditAction.ERROR, + entityType: 'User', + entityId: 0, + metadata: { + operation: 'register_user', + email: data.email, + username: data.username, + error: error instanceof Error ? error.message : String(error) + } + }); + throw error; + } } async registerInstructor(data: RegisterRequest): Promise { - const { username, email, password, first_name, last_name, prefix, phone } = data; + try { + const { username, email, password, first_name, last_name, prefix, phone } = data; - // Check if username already exists - const existingUsername = await prisma.user.findUnique({ - where: { username } - }); + // Check if username already exists + const existingUsername = await prisma.user.findUnique({ + where: { username } + }); - if (existingUsername) { - throw new ValidationError('Username already exists'); - } - - // Check if email already exists - const existingEmail = await prisma.user.findUnique({ - where: { email } - }); - - if (existingEmail) { - throw new ValidationError('Email already exists'); - } - - // Check if phone number already exists in user profiles - const existingPhone = await prisma.userProfile.findFirst({ - where: { phone } - }); - - if (existingPhone) { - throw new ValidationError('Phone number already exists'); - } - - // Get INSTRUCTOR role - const instructorRole = await prisma.role.findUnique({ - where: { code: 'INSTRUCTOR' } - }); - - if (!instructorRole) { - logger.error('INSTRUCTOR role not found in database'); - throw new Error('System configuration error'); - } - - // Hash password - const hashedPassword = await bcrypt.hash(password, 10); - - // Create user with profile - const user = await prisma.user.create({ - data: { - username, - email, - password: hashedPassword, - role_id: instructorRole.id, - profile: { - create: { - prefix: prefix ? prefix as Prisma.InputJsonValue : Prisma.JsonNull, - first_name, - last_name, - phone - } - } - }, - include: { - role: true, - profile: true + if (existingUsername) { + throw new ValidationError('Username already exists'); } - }); - logger.info('New user registered', { userId: user.id, username: user.username }); + // Check if email already exists + const existingEmail = await prisma.user.findUnique({ + where: { email } + }); - // Audit log - REGISTER (Instructor) - auditService.log({ - userId: user.id, - action: AuditAction.CREATE, - entityType: 'User', - entityId: user.id, - newValue: { username: user.username, email: user.email, role: 'INSTRUCTOR' } - }); + if (existingEmail) { + throw new ValidationError('Email already exists'); + } - return { - user: this.formatUserResponseSync(user), - message: 'Registration successful' - }; + // Check if phone number already exists in user profiles + const existingPhone = await prisma.userProfile.findFirst({ + where: { phone } + }); + + if (existingPhone) { + throw new ValidationError('Phone number already exists'); + } + + // Get INSTRUCTOR role + const instructorRole = await prisma.role.findUnique({ + where: { code: 'INSTRUCTOR' } + }); + + if (!instructorRole) { + logger.error('INSTRUCTOR role not found in database'); + throw new Error('System configuration error'); + } + + // Hash password + const hashedPassword = await bcrypt.hash(password, 10); + + // Create user with profile + const user = await prisma.user.create({ + data: { + username, + email, + password: hashedPassword, + role_id: instructorRole.id, + profile: { + create: { + prefix: prefix ? prefix as Prisma.InputJsonValue : Prisma.JsonNull, + first_name, + last_name, + phone + } + } + }, + include: { + role: true, + profile: true + } + }); + + logger.info('New user registered', { userId: user.id, username: user.username }); + + // Audit log - REGISTER (Instructor) + auditService.log({ + userId: user.id, + action: AuditAction.CREATE, + entityType: 'User', + entityId: user.id, + newValue: { username: user.username, email: user.email, role: 'INSTRUCTOR' } + }); + + return { + user: this.formatUserResponseSync(user), + message: 'Registration successful' + }; + } catch (error) { + logger.error('Failed to register instructor', { error }); + await auditService.logSync({ + userId: 0, + action: AuditAction.ERROR, + entityType: 'User', + entityId: 0, + metadata: { + operation: 'register_instructor', + email: data.email, + username: data.username, + error: error instanceof Error ? error.message : String(error) + } + }); + throw error; + } } /** diff --git a/Backend/src/services/categories.service.ts b/Backend/src/services/categories.service.ts index 2b9138b9..0e0defa2 100644 --- a/Backend/src/services/categories.service.ts +++ b/Backend/src/services/categories.service.ts @@ -5,6 +5,8 @@ import { logger } from '../config/logger'; import jwt from 'jsonwebtoken'; import { createCategory, createCategoryResponse, deleteCategoryResponse, updateCategory, updateCategoryResponse, ListCategoriesResponse, Category } from '../types/categories.type'; import { UnauthorizedError, ValidationError, ForbiddenError } from '../middleware/errorHandler'; +import { auditService } from './audit.service'; +import { AuditAction } from '@prisma/client'; export class CategoryService { async listCategories(): Promise { @@ -30,6 +32,13 @@ export class CategoryService { const newCategory = await prisma.category.create({ data: category }); + auditService.log({ + userId: decoded.id, + action: AuditAction.CREATE, + entityType: 'Category', + entityId: newCategory.id, + newValue: { name: newCategory.name as { th: string; en: string }, slug: newCategory.slug, description: newCategory.description as { th: string; en: string } }, + }); return { code: 200, message: 'Category created successfully', @@ -43,6 +52,16 @@ export class CategoryService { }; } catch (error) { logger.error('Failed to create category', { error }); + await auditService.logSync({ + userId: 0, + action: AuditAction.ERROR, + entityType: 'Category', + entityId: 0, + metadata: { + operation: 'create_category', + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } @@ -54,6 +73,13 @@ export class CategoryService { where: { id }, data: category }); + auditService.log({ + userId: decoded.id, + action: AuditAction.UPDATE, + entityType: 'Category', + entityId: id, + newValue: { name: updatedCategory.name as { th: string; en: string }, slug: updatedCategory.slug, description: updatedCategory.description as { th: string; en: string } }, + }); return { code: 200, message: 'Category updated successfully', @@ -67,21 +93,49 @@ export class CategoryService { }; } catch (error) { logger.error('Failed to update category', { error }); + await auditService.logSync({ + userId: 0, + action: AuditAction.ERROR, + entityType: 'Category', + entityId: 0, + metadata: { + operation: 'update_category', + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } - async deleteCategory(id: number): Promise { + async deleteCategory(token: string, id: number): Promise { try { + const decoded = jwt.verify(token, config.jwt.secret) as { id: number; username: string; email: string; roleCode: string }; const deletedCategory = await prisma.category.delete({ where: { id } }); + auditService.log({ + userId: decoded.id, + action: AuditAction.DELETE, + entityType: 'Category', + entityId: id, + newValue: { name: deletedCategory.name as { th: string; en: string }, slug: deletedCategory.slug, description: deletedCategory.description as { th: string; en: string } }, + }); return { code: 200, message: 'Category deleted successfully', }; } catch (error) { logger.error('Failed to delete category', { error }); + await auditService.logSync({ + userId: 0, + action: AuditAction.ERROR, + entityType: 'Category', + entityId: 0, + metadata: { + operation: 'delete_category', + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } diff --git a/Backend/src/services/certificate.service.ts b/Backend/src/services/certificate.service.ts index ab1749ae..4041ec41 100644 --- a/Backend/src/services/certificate.service.ts +++ b/Backend/src/services/certificate.service.ts @@ -16,6 +16,8 @@ import { ListMyCertificatesInput, ListMyCertificatesResponse, } from '../types/certificate.types'; +import { auditService } from './audit.service'; +import { AuditAction } from '@prisma/client'; export class CertificateService { private static TEMPLATE_PATH = path.join(__dirname, '../../assets/templates/Certificate.pdf'); @@ -54,17 +56,11 @@ export class CertificateService { }, }); - if (!enrollment) { - throw new NotFoundError('Enrollment not found'); - } + if (!enrollment) throw new NotFoundError('Enrollment not found'); - if (enrollment.status !== 'COMPLETED') { - throw new ForbiddenError('Course not completed yet'); - } + if (enrollment.status !== 'COMPLETED') throw new ForbiddenError('Course not completed yet'); - if (!enrollment.course.have_certificate) { - throw new ValidationError('This course does not offer certificates'); - } + if (!enrollment.course.have_certificate) throw new ValidationError('This course does not offer certificates'); // Check if certificate already exists const existingCertificate = await prisma.certificate.findFirst({ @@ -121,6 +117,14 @@ export class CertificateService { }, }); + auditService.log({ + userId: decoded.id, + action: AuditAction.CREATE, + entityType: 'Certificate', + entityId: certificate.id, + newValue: { file_path: certificate.file_path, issued_at: certificate.issued_at }, + }); + const downloadUrl = await getPresignedUrl(filePath, 3600); return { @@ -135,6 +139,18 @@ export class CertificateService { }; } catch (error) { logger.error('Failed to generate certificate', { error }); + const decoded = jwt.decode(input.token) as { id: number } | null; + await auditService.logSync({ + userId: decoded?.id, + action: AuditAction.ERROR, + entityType: 'Certificate', + entityId: 0, + metadata: { + operation: 'generate_certificate', + course_id: input.course_id, + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } @@ -186,6 +202,18 @@ export class CertificateService { }; } catch (error) { logger.error('Failed to get certificate', { error }); + const decoded = jwt.decode(input.token) as { id: number } | null; + await auditService.logSync({ + userId: decoded?.id, + action: AuditAction.ERROR, + entityType: 'Certificate', + entityId: 0, + metadata: { + operation: 'get_certificate', + course_id: input.course_id, + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } @@ -239,6 +267,17 @@ export class CertificateService { }; } catch (error) { logger.error('Failed to list certificates', { error }); + const decoded = jwt.decode(input.token) as { id: number } | null; + await auditService.logSync({ + userId: decoded?.id, + action: AuditAction.ERROR, + entityType: 'Certificate', + entityId: 0, + metadata: { + operation: 'list_my_certificates', + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } diff --git a/Backend/src/services/courses.service.ts b/Backend/src/services/courses.service.ts index 252744f8..6b810ca9 100644 --- a/Backend/src/services/courses.service.ts +++ b/Backend/src/services/courses.service.ts @@ -5,6 +5,8 @@ import { logger } from '../config/logger'; import { listCourseResponse, getCourseResponse, ListCoursesInput } from '../types/courses.types'; import { UnauthorizedError, ValidationError, ForbiddenError } from '../middleware/errorHandler'; import { getPresignedUrl } from '../config/minio'; +import { auditService } from './audit.service'; +import { AuditAction } from '@prisma/client'; export class CoursesService { async ListCourses(input: ListCoursesInput): Promise { @@ -82,6 +84,16 @@ export class CoursesService { }; } catch (error) { logger.error('Failed to fetch courses', { error }); + await auditService.logSync({ + userId: 0, + action: AuditAction.ERROR, + entityType: 'Course', + entityId: 0, + metadata: { + operation: 'list_courses', + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } @@ -91,7 +103,56 @@ export class CoursesService { const course = await prisma.course.findFirst({ where: { id, - status: 'APPROVED' // Only show approved courses to students + status: 'APPROVED' + }, + include: { + creator: { + select: { + id: true, + username: true, + email: true, + profile: { + select: { + first_name: true, + last_name: true, + avatar_url: true + } + } + } + }, + instructors: { + include: { + user: { + select: { + id: true, + username: true, + email: true, + profile: { + select: { + first_name: true, + last_name: true, + avatar_url: true + } + } + } + } + } + }, + category: { + select: { id: true, name: true } + }, + chapters: { + orderBy: { sort_order: 'asc' }, + select: { + id: true, + title: true, + sort_order: true, + lessons: { + orderBy: { sort_order: 'asc' }, + select: { id: true, title: true } + } + } + } } }); @@ -112,16 +173,83 @@ export class CoursesService { logger.warn(`Failed to generate presigned URL for thumbnail: ${err}`); } } + + // Generate presigned URL for creator avatar + let creator_avatar_url: string | null = null; + if (course.creator.profile?.avatar_url) { + try { + creator_avatar_url = await getPresignedUrl(course.creator.profile.avatar_url, 3600); + } catch (err) { + logger.warn(`Failed to generate presigned URL for creator avatar: ${err}`); + } + } + + // Generate presigned URLs for instructor avatars + const instructorsWithAvatar = await Promise.all(course.instructors.map(async (i) => { + let avatar_url: string | null = null; + if (i.user.profile?.avatar_url) { + try { + avatar_url = await getPresignedUrl(i.user.profile.avatar_url, 3600); + } catch (err) { + logger.warn(`Failed to generate presigned URL for instructor avatar: ${err}`); + } + } + return { + user_id: i.user_id, + is_primary: i.is_primary, + user: { + ...i.user, + profile: i.user.profile ? { + ...i.user.profile, + avatar_url + } : null + } + }; + })); + return { code: 200, message: 'Course fetched successfully', data: { ...course, + title: course.title as { th: string; en: string }, + description: course.description as { th: string; en: string }, thumbnail_url: thumbnail_presigned_url, + creator: { + ...course.creator, + profile: course.creator.profile ? { + ...course.creator.profile, + avatar_url: creator_avatar_url + } : null + }, + instructors: instructorsWithAvatar, + category: course.category ? { + id: course.category.id, + name: course.category.name as { th: string; en: string } + } : null, + chapters: course.chapters.map(ch => ({ + id: ch.id, + title: ch.title as { th: string; en: string }, + sort_order: ch.sort_order, + lessons: ch.lessons.map(l => ({ + id: l.id, + title: l.title as { th: string; en: string } + })) + })) }, }; } catch (error) { logger.error('Failed to fetch course', { error }); + await auditService.logSync({ + userId: 0, + action: AuditAction.ERROR, + entityType: 'Course', + entityId: id, + metadata: { + operation: 'get_course', + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } diff --git a/Backend/src/services/user.service.ts b/Backend/src/services/user.service.ts index da9bc274..69153d51 100644 --- a/Backend/src/services/user.service.ts +++ b/Backend/src/services/user.service.ts @@ -14,7 +14,8 @@ import { updateAvatarRequest, updateAvatarResponse, SendVerifyEmailResponse, - VerifyEmailResponse + VerifyEmailResponse, + rolesResponse } from '../types/user.types'; import nodemailer from 'nodemailer'; import { UnauthorizedError, ValidationError, ForbiddenError } from '../middleware/errorHandler'; @@ -135,6 +136,17 @@ export class UserService { throw new UnauthorizedError('Token expired'); } logger.error('Failed to change password', { error }); + const decoded = jwt.decode(token) as { id: number } | null; + await auditService.logSync({ + userId: decoded?.id || 0, + action: AuditAction.ERROR, + entityType: 'User', + entityId: decoded?.id || 0, + metadata: { + operation: 'change_password', + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } @@ -186,6 +198,41 @@ export class UserService { throw new UnauthorizedError('Token expired'); } logger.error('Failed to update profile', { error }); + const decoded = jwt.decode(token) as { id: number } | null; + await auditService.logSync({ + userId: decoded?.id || 0, + action: AuditAction.UPDATE, + entityType: 'UserProfile', + entityId: decoded?.id || 0, + metadata: { + operation: 'update_profile', + error: error instanceof Error ? error.message : String(error) + } + }); + throw error; + } + } + + async getRoles(token: string): Promise { + try { + jwt.verify(token, config.jwt.secret); + const roles = await prisma.role.findMany({ + select: { + id: true, + code: true + } + }); + return { roles }; + } catch (error) { + if (error instanceof jwt.TokenExpiredError) { + logger.error('JWT token expired:', error); + throw new UnauthorizedError('Token expired'); + } + if (error instanceof jwt.JsonWebTokenError) { + logger.error('Invalid JWT token:', error); + throw new UnauthorizedError('Invalid token'); + } + logger.error('Failed to get roles', { error }); throw error; } } @@ -252,6 +299,18 @@ export class UserService { }); } + // Audit log - UPLOAD_AVATAR + await auditService.logSync({ + userId: decoded.id, + action: AuditAction.UPLOAD_FILE, + entityType: 'User', + entityId: decoded.id, + metadata: { + operation: 'upload_avatar', + filePath + } + }); + // Generate presigned URL for response const presignedUrl = await this.getAvatarPresignedUrl(filePath); @@ -273,6 +332,18 @@ export class UserService { throw new UnauthorizedError('Token expired'); } logger.error('Failed to upload avatar', { error }); + const decoded = jwt.decode(token) as { id: number } | null; + await auditService.logSync({ + userId: decoded?.id || 0, + action: AuditAction.UPLOAD_FILE, + entityType: 'UserProfile', + entityId: decoded?.id || 0, + metadata: { + operation: 'upload_avatar', + error: error instanceof Error ? error.message : String(error) + } + }); + throw error; } } @@ -385,6 +456,17 @@ export class UserService { if (error instanceof jwt.JsonWebTokenError) throw new UnauthorizedError('Invalid token'); if (error instanceof jwt.TokenExpiredError) throw new UnauthorizedError('Token expired'); logger.error('Failed to send verification email', { error }); + const decoded = jwt.decode(token) as { id: number } | null; + await auditService.logSync({ + userId: decoded?.id || 0, + action: AuditAction.ERROR, + entityType: 'UserProfile', + entityId: decoded?.id || 0, + metadata: { + operation: 'send_verification_email', + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } @@ -415,6 +497,15 @@ export class UserService { }); logger.info('Email verified successfully', { userId: user.id, email: user.email }); + await auditService.logSync({ + userId: user.id, + action: AuditAction.VERIFY_EMAIL, + entityType: 'UserProfile', + entityId: user.id, + metadata: { + operation: 'verify_email' + } + }); return { code: 200, message: 'Email verified successfully' @@ -423,6 +514,17 @@ export class UserService { if (error instanceof jwt.JsonWebTokenError) throw new UnauthorizedError('Invalid verification token'); if (error instanceof jwt.TokenExpiredError) throw new UnauthorizedError('Verification link has expired'); logger.error('Failed to verify email', { error }); + const decoded = jwt.decode(verifyToken) as { id: number } | null; + await auditService.logSync({ + userId: decoded?.id || 0, + action: AuditAction.ERROR, + entityType: 'UserProfile', + entityId: decoded?.id || 0, + metadata: { + operation: 'verify_email', + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } diff --git a/Backend/src/services/usermanagement.service.ts b/Backend/src/services/usermanagement.service.ts index 658382c2..2bded499 100644 --- a/Backend/src/services/usermanagement.service.ts +++ b/Backend/src/services/usermanagement.service.ts @@ -39,6 +39,16 @@ export class UserManagementService { }; } catch (error) { logger.error('Failed to fetch users', { error }); + await auditService.logSync({ + userId: 0, + action: AuditAction.ERROR, + entityType: 'User', + entityId: 0, + metadata: { + operation: 'list_users', + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } @@ -61,6 +71,16 @@ export class UserManagementService { }; } catch (error) { logger.error('Failed to fetch user by ID', { error }); + await auditService.logSync({ + userId: id, + action: AuditAction.ERROR, + entityType: 'User', + entityId: id, + metadata: { + operation: 'get_user_by_id', + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } @@ -95,6 +115,17 @@ export class UserManagementService { }; } catch (error) { logger.error('Failed to update user role', { error }); + await auditService.logSync({ + userId: id, + action: AuditAction.ERROR, + entityType: 'User', + entityId: id, + metadata: { + operation: 'update_user_role', + target_role_id: role_id, + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } @@ -114,6 +145,16 @@ export class UserManagementService { }; } catch (error) { logger.error('Failed to deactivate user', { error }); + await auditService.logSync({ + userId: id, + action: AuditAction.ERROR, + entityType: 'User', + entityId: id, + metadata: { + operation: 'delete_user', + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } @@ -160,6 +201,16 @@ export class UserManagementService { throw new UnauthorizedError('Token expired'); } logger.error('Failed to deactivate account', { error }); + await auditService.logSync({ + userId: id, + action: AuditAction.ERROR, + entityType: 'User', + entityId: id, + metadata: { + operation: 'deactivate_account', + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } @@ -207,6 +258,16 @@ export class UserManagementService { throw new UnauthorizedError('Token expired'); } logger.error('Failed to activate account', { error }); + await auditService.logSync({ + userId: id, + action: AuditAction.ERROR, + entityType: 'User', + entityId: id, + metadata: { + operation: 'activate_account', + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } diff --git a/Backend/src/types/AdminCourseApproval.types.ts b/Backend/src/types/AdminCourseApproval.types.ts index fa98ea6b..d68c8c81 100644 --- a/Backend/src/types/AdminCourseApproval.types.ts +++ b/Backend/src/types/AdminCourseApproval.types.ts @@ -117,10 +117,6 @@ export interface GetCourseDetailForAdminResponse { data: CourseDetailForAdmin; } -export interface ApproveCourseBody { - comment?: string; -} - export interface ApproveCourseResponse { code: number; message: string; diff --git a/Backend/src/types/CoursesInstructor.types.ts b/Backend/src/types/CoursesInstructor.types.ts index a10dac44..cc4aa149 100644 --- a/Backend/src/types/CoursesInstructor.types.ts +++ b/Backend/src/types/CoursesInstructor.types.ts @@ -23,6 +23,11 @@ export interface createCourseResponse { data: Course; } +export interface ListMyCoursesInput { + token: string; + status?: 'DRAFT' | 'PENDING' | 'APPROVED' | 'REJECTED' | 'ARCHIVED'; +} + export interface ListMyCourseResponse { code: number; message: string; @@ -428,3 +433,18 @@ export interface GetCourseApprovalHistoryResponse { approval_history: ApprovalHistoryItem[]; }; } + +export interface CloneCourseInput { + token: string; + course_id: number; + title: MultiLanguageText; +} + +export interface CloneCourseResponse { + code: number; + message: string; + data: { + id: number; + title: MultiLanguageText; + }; +} diff --git a/Backend/src/types/RecommendedCourses.types.ts b/Backend/src/types/RecommendedCourses.types.ts index c11c6c93..48f495a7 100644 --- a/Backend/src/types/RecommendedCourses.types.ts +++ b/Backend/src/types/RecommendedCourses.types.ts @@ -1,14 +1,10 @@ import { MultiLanguageText } from './index'; -// ============================================ -// Request Types -// ============================================ - - // ============================================ // Response Types // ============================================ +/** ใช้ใน listApprovedCourses — มีแค่ chapters_count */ export interface RecommendedCourseData { id: number; title: MultiLanguageText; @@ -41,7 +37,19 @@ export interface RecommendedCourseData { }; }>; chapters_count: number; - lessons_count: number; +} + +/** ใช้ใน getCourseById — มี chapters + lessons พร้อมชื่อ */ +export interface RecommendedCourseDetailData extends Omit { + chapters: { + id: number; + title: MultiLanguageText; + sort_order: number; + lessons: { + id: number; + title: MultiLanguageText; + }[]; + }[]; } export interface ListApprovedCoursesResponse { @@ -54,7 +62,7 @@ export interface ListApprovedCoursesResponse { export interface GetCourseByIdResponse { code: number; message: string; - data: RecommendedCourseData; + data: RecommendedCourseDetailData; } export interface ToggleRecommendedResponse { diff --git a/Backend/src/types/courses.types.ts b/Backend/src/types/courses.types.ts index 42c83398..a294d7e2 100644 --- a/Backend/src/types/courses.types.ts +++ b/Backend/src/types/courses.types.ts @@ -1,4 +1,5 @@ import { Course } from '@prisma/client'; +import { MultiLanguageText } from './index'; export interface ListCoursesInput { category_id?: number; @@ -18,8 +19,47 @@ export interface listCourseResponse { totalPages: number; } +export interface CourseDetail extends Omit { + title: MultiLanguageText; + description: MultiLanguageText; + creator: { + id: number; + username: string; + email: string; + profile: { + first_name: string; + last_name: string; + avatar_url: string | null; + } | null; + }; + instructors: { + user_id: number; + is_primary: boolean; + user: { + id: number; + username: string; + email: string; + profile: { + first_name: string; + last_name: string; + avatar_url: string | null; + } | null; + }; + }[]; + category: { id: number; name: MultiLanguageText } | null; + chapters: { + id: number; + title: MultiLanguageText; + sort_order: number; + lessons: { + id: number; + title: MultiLanguageText; + }[]; + }[]; +} + export interface getCourseResponse { code: number; message: string; - data: Course | null; + data: CourseDetail | null; } diff --git a/Backend/src/types/user.types.ts b/Backend/src/types/user.types.ts index 413cb3f2..42ac8e75 100644 --- a/Backend/src/types/user.types.ts +++ b/Backend/src/types/user.types.ts @@ -59,6 +59,14 @@ export interface ProfileUpdateResponse { }; }; +export interface role { + id: number; + code: string; +} + +export interface rolesResponse { + roles: role[]; +} export interface ChangePasswordRequest { old_password: string; diff --git a/Backend/src/validators/AdminCourseApproval.validator.ts b/Backend/src/validators/AdminCourseApproval.validator.ts new file mode 100644 index 00000000..89e0a284 --- /dev/null +++ b/Backend/src/validators/AdminCourseApproval.validator.ts @@ -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' + }) +}); diff --git a/Backend/src/validators/ChaptersLesson.validator.ts b/Backend/src/validators/ChaptersLesson.validator.ts new file mode 100644 index 00000000..933a3e1a --- /dev/null +++ b/Backend/src/validators/ChaptersLesson.validator.ts @@ -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() +}); diff --git a/Backend/src/validators/CoursesInstructor.validator.ts b/Backend/src/validators/CoursesInstructor.validator.ts index fe971950..cbde5802 100644 --- a/Backend/src/validators/CoursesInstructor.validator.ts +++ b/Backend/src/validators/CoursesInstructor.validator.ts @@ -20,3 +20,38 @@ export const CreateCourseValidator = Joi.object({ is_free: 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' + }) +}); diff --git a/Backend/src/validators/CoursesStudent.validator.ts b/Backend/src/validators/CoursesStudent.validator.ts new file mode 100644 index 00000000..424c35fe --- /dev/null +++ b/Backend/src/validators/CoursesStudent.validator.ts @@ -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' + }) +}); diff --git a/Backend/src/validators/Lessons.validator.ts b/Backend/src/validators/Lessons.validator.ts new file mode 100644 index 00000000..4161ec53 --- /dev/null +++ b/Backend/src/validators/Lessons.validator.ts @@ -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' + }) +}); diff --git a/Backend/src/validators/announcements.validator.ts b/Backend/src/validators/announcements.validator.ts new file mode 100644 index 00000000..bd9ad945 --- /dev/null +++ b/Backend/src/validators/announcements.validator.ts @@ -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' + }) +}); diff --git a/Backend/src/validators/categories.validator.ts b/Backend/src/validators/categories.validator.ts new file mode 100644 index 00000000..521c9faf --- /dev/null +++ b/Backend/src/validators/categories.validator.ts @@ -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() +}); diff --git a/Backend/tests/k6/enroll-load-test.js b/Backend/tests/k6/enroll-load-test.js new file mode 100644 index 00000000..d3e1032b --- /dev/null +++ b/Backend/tests/k6/enroll-load-test.js @@ -0,0 +1,160 @@ +// Backend/tests/k6/enroll-load-test.js +// +// จำลองนักเรียนหลายคน login แล้ว enroll คอร์สพร้อมกัน +// +// Flow: +// 1. Login +// 2. Enroll คอร์ส +// 3. ตรวจสอบ enrolled courses +// +// Usage: +// k6 run -e APP_URL=http://192.168.1.137:4000 -e COURSE_ID=1 tests/k6/enroll-load-test.js + +import http from 'k6/http'; +import { check, sleep, group } from 'k6'; +import { Rate, Trend, Counter } from 'k6/metrics'; +import { SharedArray } from 'k6/data'; + +// ─── Custom Metrics ─────────────────────────────────────────────────────────── +const errorRate = new Rate('errors'); +const loginTime = new Trend('login_duration', true); +const enrollTime = new Trend('enroll_duration', true); +const enrolledCount = new Counter('successful_enrollments'); + +// ─── Load student credentials ───────────────────────────────────────────────── +const students = new SharedArray('students', function () { + return JSON.parse(open('./test-credentials.json')).students; +}); + +// ─── Config ─────────────────────────────────────────────────────────────────── +const BASE_URL = __ENV.APP_URL || 'http://192.168.1.137:4000'; +const COURSE_ID = __ENV.COURSE_ID || '1'; + +// ─── Test Options ───────────────────────────────────────────────────────────── +export const options = { + stages: [ + { duration: '20s', target: 10 }, // Ramp up + { duration: '1m', target: 30 }, // Increase + { duration: '30s', target: 50 }, // Peak: 50 คน enroll พร้อมกัน + { duration: '30s', target: 0 }, // Ramp down + ], + thresholds: { + 'login_duration': ['p(95)<2000'], // Login < 2s + 'enroll_duration': ['p(95)<1000'], // Enroll < 1s + 'errors': ['rate<0.05'], + 'http_req_failed': ['rate<0.05'], + }, +}; + +// ─── Helper ─────────────────────────────────────────────────────────────────── +function jsonHeaders(token) { + const h = { 'Content-Type': 'application/json' }; + if (token) h['Authorization'] = `Bearer ${token}`; + return h; +} + +// ─── Main ───────────────────────────────────────────────────────────────────── +export default function () { + const student = students[__VU % students.length]; + let token = null; + + // ── Step 1: Login ────────────────────────────────────────────────────────── + group('1. Login', () => { + const res = http.post( + `${BASE_URL}/api/auth/login`, + JSON.stringify({ email: student.email, password: student.password }), + { headers: jsonHeaders(null) } + ); + + loginTime.add(res.timings.duration); + errorRate.add(res.status !== 200); + + check(res, { + 'login: status 200': (r) => r.status === 200, + 'login: has token': (r) => { try { return !!r.json('data.token'); } catch { return false; } }, + }); + + if (res.status === 200) { + try { token = res.json('data.token'); } catch {} + } + }); + + if (!token) { + console.warn(`[VU ${__VU}] Login failed for ${student.email} — skipping`); + sleep(1); + return; + } + + sleep(0.5); + + // ── Step 2: Enroll ───────────────────────────────────────────────────────── + group('2. Enroll Course', () => { + const res = http.post( + `${BASE_URL}/api/students/courses/${COURSE_ID}/enroll`, + null, + { headers: jsonHeaders(token) } + ); + + enrollTime.add(res.timings.duration); + + // 200 = enrolled, 409 = already enrolled (ถือว่าโอเค) + const ok = res.status === 200 || res.status === 409; + errorRate.add(!ok); + + if (res.status === 200) enrolledCount.add(1); + + check(res, { + 'enroll: 200 or 409': (r) => r.status === 200 || r.status === 409, + 'enroll: fast response': (r) => r.timings.duration < 1000, + }); + }); + + sleep(0.5); + + // ── Step 3: Verify — ดึงรายการคอร์สที่ลงทะเบียน ───────────────────────── + group('3. Get Enrolled Courses', () => { + const res = http.get( + `${BASE_URL}/api/students/courses`, + { headers: jsonHeaders(token) } + ); + + errorRate.add(res.status !== 200); + + check(res, { + 'enrolled courses: status 200': (r) => r.status === 200, + }); + }); + + sleep(1); +} + +// ─── Summary ────────────────────────────────────────────────────────────────── +export function handleSummary(data) { + const m = data.metrics; + const avg = (k) => m[k]?.values?.avg?.toFixed(0) ?? 'N/A'; + const p95 = (k) => m[k]?.values?.['p(95)']?.toFixed(0) ?? 'N/A'; + const rate = (k) => ((m[k]?.values?.rate ?? 0) * 100).toFixed(2); + const cnt = (k) => m[k]?.values?.count ?? 0; + + return { + stdout: ` +╔══════════════════════════════════════════════════════════╗ +║ Course Enroll — Load Test ║ +╠══════════════════════════════════════════════════════════╣ +║ Course ID : ${String(COURSE_ID).padEnd(43)}║ +╠══════════════════════════════════════════════════════════╣ +║ RESPONSE TIMES (avg / p95) ║ +║ Login : ${avg('login_duration')}ms / ${p95('login_duration')}ms +║ Enroll : ${avg('enroll_duration')}ms / ${p95('enroll_duration')}ms +╠══════════════════════════════════════════════════════════╣ +║ COUNTS ║ +║ Total Requests : ${String(cnt('http_reqs')).padEnd(33)}║ +║ New Enrollments : ${String(cnt('successful_enrollments')).padEnd(33)}║ +╠══════════════════════════════════════════════════════════╣ +║ ERROR RATES ║ +║ HTTP Failed : ${(rate('http_req_failed') + '%').padEnd(39)}║ +║ Custom Errors : ${(rate('errors') + '%').padEnd(39)}║ +╚══════════════════════════════════════════════════════════╝ +`, + }; +} diff --git a/Backend/tests/k6/login-load-test.js b/Backend/tests/k6/login-load-test.js index 2a0c375d..aee4cb4a 100644 --- a/Backend/tests/k6/login-load-test.js +++ b/Backend/tests/k6/login-load-test.js @@ -31,7 +31,7 @@ export const options = { thresholds: { http_req_duration: ['p(95)<2000'], // 95% of requests < 2s errors: ['rate<0.1'], // Error rate < 10% - login_duration: ['p(95)<2000'], // 95% of logins < 2s + login_duration: ['p(95)<2000'], // 95% pof logins < 2s }, }; diff --git a/Backend/tests/k6/video-watching-load-test.js b/Backend/tests/k6/video-watching-load-test.js new file mode 100644 index 00000000..e3bb205c --- /dev/null +++ b/Backend/tests/k6/video-watching-load-test.js @@ -0,0 +1,269 @@ +// Backend/tests/k6/video-watching-load-test.js +// +// จำลองนักเรียนหลายคนดูวีดีโอพร้อมกัน (Concurrent Video Watching) +// +// Flow จริงที่ simulate: +// 1. Login ด้วย account ของ student แต่ละคน +// 2. Load หน้าเรียนคอร์ส (getCourseLearning) +// 3. เปิดบทเรียนวีดีโอ (getLessonContent) +// 4. Save progress ทุก 5 วินาที (จำลองการ watch) +// 5. เมื่อดูครบ (≥90%) → mark lesson complete +// +// Usage: +// k6 run -e APP_URL=http://localhost:4000 -e COURSE_ID=1 -e LESSON_ID=1 tests/k6/video-watching-load-test.js +// +// ปรับจำนวน VUs และ duration ได้ด้วย: +// k6 run -e APP_URL=http://localhost:4000 -e COURSE_ID=1 -e LESSON_ID=1 --vus 30 --duration 2m tests/k6/video-watching-load-test.js + +import http from 'k6/http'; +import { check, sleep, group } from 'k6'; +import { Rate, Trend, Counter } from 'k6/metrics'; +import { SharedArray } from 'k6/data'; + +// ─── Custom Metrics ─────────────────────────────────────────────────────────── +const errorRate = new Rate('errors'); +const loginTime = new Trend('login_duration', true); +const courseLearningTime = new Trend('course_learning_duration', true); +const lessonLoadTime = new Trend('lesson_load_duration', true); +const progressSaveTime = new Trend('progress_save_duration', true); +const completeLessonTime = new Trend('complete_lesson_duration', true); +const completedCount = new Counter('completed_lessons'); +const progressSaveCount = new Counter('progress_saves'); +const videoLoadTime = new Trend('video_load_duration', true); + +// ─── Load student credentials ──────────────────────────────────────────────── +// อ่านจาก test-credentials.json (50 accounts) +const students = new SharedArray('students', function () { + return JSON.parse(open('./test-credentials.json')).students; +}); + +// ─── Config ─────────────────────────────────────────────────────────────────── +const BASE_URL = __ENV.APP_URL || 'http://192.168.1.137:4000'; +const COURSE_ID = __ENV.COURSE_ID || '1'; +const LESSON_ID = __ENV.LESSON_ID || '1'; + +// วีดีโอความยาว (วินาที) — ปรับตามจริง +const VIDEO_DURATION_SECONDS = parseInt(__ENV.VIDEO_DURATION || '98'); // default 5 นาที + +// save progress interval: ทุก 5 วินาที (เหมือน client จริง) +// แต่ในการ test เราจะ simulate เร็วขึ้นโดยใช้ sleep น้อยลง +const PROGRESS_INTERVAL_SECONDS = parseInt(__ENV.PROGRESS_INTERVAL || '15'); + +// ─── Test Options ───────────────────────────────────────────────────────────── +export const options = { + stages: [ + { duration: '30s', target: 10 }, // Ramp up: 10 คนเริ่มดูวีดีโอ + { duration: '1m', target: 30 }, // Ramp up: เพิ่มเป็น 30 คน + { duration: '2m', target: 30 }, // Steady: 30 คนดูพร้อมกัน + { duration: '30s', target: 50 }, // Peak: เพิ่มเป็น 50 คน + { duration: '1m', target: 50 }, // Steady Peak: 50 คนพร้อมกัน + { duration: '30s', target: 0 }, // Ramp down + ], + thresholds: { + // Response times + 'login_duration': ['p(95)<2000'], // Login < 2s + 'course_learning_duration': ['p(95)<1000'], // Load course page < 1s + 'lesson_load_duration': ['p(95)<1000'], // Load lesson < 1s + 'video_load_duration': ['p(95)<3000'], // Fetch video from MinIO < 3s + 'progress_save_duration': ['p(95)<500'], // Save progress < 500ms (critical — บ่อย) + 'complete_lesson_duration': ['p(95)<1000'], // Complete lesson < 1s + + // Error rate + 'errors': ['rate<0.05'], // Error < 5% + 'http_req_failed': ['rate<0.05'], // HTTP error < 5% + }, +}; + +// ─── Helper ─────────────────────────────────────────────────────────────────── +function jsonHeaders(token) { + const h = { 'Content-Type': 'application/json' }; + if (token) h['Authorization'] = `Bearer ${token}`; + return h; +} + +// ─── Per-VU persistent state (จำข้ามรอบ iteration) ────────────────────────── +// ตัวแปรนี้อยู่ระดับ module → k6 สร้างแยกต่างหากสำหรับแต่ละ VU +// ค่าจะถูกจำไว้ตลอดอายุของ VU (ข้ามหลายรอบ iteration) +let vuToken = null; // token ที่ login ไว้แล้ว +let vuSetupDone = false; // เคย load course+lesson แล้วหรือยัง +let vuProgress = 0; // ตำแหน่งวีดีโอปัจจุบัน (วินาที) +let vuCompleted = false; // lesson complete แล้วหรือยัง + +// ─── Main ───────────────────────────────────────────────────────────────────── +export default function () { + const student = students[__VU % students.length]; + + // ── Step 1: Login (ทำครั้งเดียวตอน VU เริ่มต้น หรือถ้า token หาย) ───────── + if (!vuToken) { + group('1. Login', () => { + const res = http.post( + `${BASE_URL}/api/auth/login`, + JSON.stringify({ email: student.email, password: student.password }), + { headers: jsonHeaders(null) } + ); + + loginTime.add(res.timings.duration); + const ok = res.status === 200; + errorRate.add(!ok); + + check(res, { + 'login: status 200': (r) => r.status === 200, + 'login: has token': (r) => { try { return !!r.json('data.token'); } catch { return false; } }, + }); + + if (ok) { + try { vuToken = res.json('data.token'); } catch {} + } + }); + + if (!vuToken) { + console.warn(`[VU ${__VU}] Login failed for ${student.email} — skipping`); + sleep(2); + return; + } + } + + // ── Step 2 (removed): Enroll ทำผ่าน enroll-load-test.js แยกต่างหาก ───────── + + // ── Step 3+4: Setup — Load course และ open lesson (ทำครั้งเดียวต่อ VU) ───── + if (!vuSetupDone) { + group('3. Load Course Learning Page', () => { + const res = http.get( + `${BASE_URL}/api/students/courses/${COURSE_ID}/learn`, + { headers: jsonHeaders(vuToken) } + ); + courseLearningTime.add(res.timings.duration); + errorRate.add(res.status !== 200); + check(res, { 'course learn: status 200': (r) => r.status === 200 }); + }); + + sleep(1); + + let videoUrl = null; + group('4. Open Lesson', () => { + const res = http.get( + `${BASE_URL}/api/students/courses/${COURSE_ID}/lessons/${LESSON_ID}`, + { headers: jsonHeaders(vuToken) } + ); + lessonLoadTime.add(res.timings.duration); + errorRate.add(res.status !== 200); + check(res, { 'lesson: status 200': (r) => r.status === 200 }); + if (res.status === 200) { + try { videoUrl = res.json('data.video_url'); } catch {} + } + }); + + // ── Step 4.5: Fetch video จาก MinIO ────────────────────────────────────── + if (videoUrl) { + group('4.5 Fetch Video from MinIO', () => { + const res = http.get(videoUrl, { + headers: { 'Range': 'bytes=0-1048575' }, // ขอแค่ 1MB แรก + timeout: '10s', + }); + videoLoadTime.add(res.timings.duration); + const ok = res.status === 200 || res.status === 206; + errorRate.add(!ok); + check(res, { + 'minio video: 200 or 206': (r) => r.status === 200 || r.status === 206, + 'minio video: fast': (r) => r.timings.duration < 3000, + }); + }); + } else { + console.log(`[VU ${__VU}] No video_url returned — skipping MinIO fetch`); + } + + sleep(2); // รอ buffer เริ่มต้น + vuSetupDone = true; + } + + // ── Step 5: Save Progress ทีละ tick (ต่อจากตำแหน่งเดิม) ──────────────────── + // แต่ละ iteration ของ VU = ส่ง progress 1 ครั้ง แล้ว sleep ตาม interval จริง + if (!vuCompleted) { + vuProgress += PROGRESS_INTERVAL_SECONDS; + + group('5. Watch Video (Save Progress)', () => { + const res = http.post( + `${BASE_URL}/api/students/lessons/${LESSON_ID}/progress`, + JSON.stringify({ + video_progress_seconds: vuProgress, + video_duration_seconds: VIDEO_DURATION_SECONDS, + }), + { headers: jsonHeaders(vuToken) } + ); + + progressSaveTime.add(res.timings.duration); + progressSaveCount.add(1); + + const ok = res.status === 200; + errorRate.add(!ok); + check(res, { + 'progress save: status 200': (r) => r.status === 200, + 'progress save: fast': (r) => r.timings.duration < 500, + }); + + console.log(`[VU ${__VU}] progress: ${vuProgress}s / ${VIDEO_DURATION_SECONDS}s (${Math.round(vuProgress / VIDEO_DURATION_SECONDS * 100)}%)`); + }); + + // ── Step 6: Mark complete เมื่อดูครบ ≥95% ────────────────────────────── + if (vuProgress >= VIDEO_DURATION_SECONDS * 0.95) { + group('6. Complete Lesson', () => { + const res = http.post( + `${BASE_URL}/api/students/courses/${COURSE_ID}/lessons/${LESSON_ID}/complete`, + null, + { headers: jsonHeaders(vuToken) } + ); + completeLessonTime.add(res.timings.duration); + errorRate.add(res.status !== 200 && res.status !== 409); + if (res.status === 200) completedCount.add(1); + check(res, { + 'complete: status 200 or 409': (r) => r.status === 200 || r.status === 409, + }); + }); + + vuCompleted = true; + console.log(`[VU ${__VU}] ✓ Lesson completed`); + } + } + + // sleep ตาม interval จริง — ทุก VU ส่ง progress ทุก PROGRESS_INTERVAL_SECONDS วินาที + sleep(PROGRESS_INTERVAL_SECONDS); +} + +// ─── Summary ────────────────────────────────────────────────────────────────── +export function handleSummary(data) { + const m = data.metrics; + + const avg = (k) => m[k]?.values?.avg?.toFixed(0) ?? 'N/A'; + const p95 = (k) => m[k]?.values?.['p(95)']?.toFixed(0) ?? 'N/A'; + const rate = (k) => ((m[k]?.values?.rate ?? 0) * 100).toFixed(2); + const count = (k) => m[k]?.values?.count ?? 0; + + return { + stdout: ` +╔══════════════════════════════════════════════════════════╗ +║ Concurrent Video Watching — Load Test ║ +╠══════════════════════════════════════════════════════════╣ +║ Course ID : ${COURSE_ID.padEnd(44)}║ +║ Lesson ID : ${LESSON_ID.padEnd(44)}║ +║ Video : ${String(VIDEO_DURATION_SECONDS + 's').padEnd(44)}║ +╠══════════════════════════════════════════════════════════╣ +║ RESPONSE TIMES (avg / p95) ║ +║ Login : ${avg('login_duration')}ms / ${p95('login_duration')}ms${' '.repeat(Math.max(0, 20 - avg('login_duration').length - p95('login_duration').length))}║ +║ Course Learning Page: ${avg('course_learning_duration')}ms / ${p95('course_learning_duration')}ms${' '.repeat(Math.max(0, 20 - avg('course_learning_duration').length - p95('course_learning_duration').length))}║ +║ Lesson Load : ${avg('lesson_load_duration')}ms / ${p95('lesson_load_duration')}ms${' '.repeat(Math.max(0, 20 - avg('lesson_load_duration').length - p95('lesson_load_duration').length))}║ +║ MinIO Video Fetch : ${avg('video_load_duration')}ms / ${p95('video_load_duration')}ms${' '.repeat(Math.max(0, 20 - avg('video_load_duration').length - p95('video_load_duration').length))}║ +║ Save Progress : ${avg('progress_save_duration')}ms / ${p95('progress_save_duration')}ms${' '.repeat(Math.max(0, 20 - avg('progress_save_duration').length - p95('progress_save_duration').length))}║ +║ Complete Lesson : ${avg('complete_lesson_duration')}ms / ${p95('complete_lesson_duration')}ms${' '.repeat(Math.max(0, 20 - avg('complete_lesson_duration').length - p95('complete_lesson_duration').length))}║ +╠══════════════════════════════════════════════════════════╣ +║ COUNTS ║ +║ Total Requests : ${String(count('http_reqs')).padEnd(33)}║ +║ Progress Saves : ${String(count('progress_saves')).padEnd(33)}║ +║ Lessons Completed : ${String(count('completed_lessons')).padEnd(33)}║ +╠══════════════════════════════════════════════════════════╣ +║ ERROR RATES ║ +║ HTTP Failed : ${(rate('http_req_failed') + '%').padEnd(33)}║ +║ Custom Errors : ${(rate('errors') + '%').padEnd(33)}║ +╚══════════════════════════════════════════════════════════╝ +`, + }; +} diff --git a/Frontend-Learner/app.vue b/Frontend-Learner/app.vue index a1ac35c7..8070d468 100644 --- a/Frontend-Learner/app.vue +++ b/Frontend-Learner/app.vue @@ -1,20 +1,27 @@ - diff --git a/Frontend-Learner/assets/css/main.css b/Frontend-Learner/assets/css/main.css index 5a4bf8c7..960858a7 100644 --- a/Frontend-Learner/assets/css/main.css +++ b/Frontend-Learner/assets/css/main.css @@ -10,9 +10,9 @@ --bg-body: #f8fafc; --bg-surface: #ffffff; --bg-elevated: #ffffff; - --text-main: #000000; /* Pure Black for absolute clarity in light mode */ - --text-secondary: #1f2937; /* text-slate-800: Strong contrast subtext */ - --primary: #3b82f6; /* Primary Blue */ + --text-main: #000000; /* Pure Black for absolute clarity in light mode */ + --text-secondary: #1f2937; /* text-slate-800: Strong contrast subtext */ + --primary: #3b82f6; /* Primary Blue */ /* Semantic mappings */ --border-color: #e2e8f0; @@ -27,7 +27,7 @@ /* Typography */ /* Typography */ --font-main: - "Prompt", "Sarabun", "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", + "Prompt", "Inter", "Sarabun", -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Helvetica Neue", Arial, sans-serif; /* Layout */ @@ -60,17 +60,17 @@ /* Dark Mode (applied when `.dark` class is present on ) */ .dark { /* Oceanic Palette: Standardized for entire App */ - --bg-body: #020617; /* Slate-950: Main Background */ - --bg-surface: #0f172a; /* Slate-900: Sidebar & Header */ - --bg-elevated: #1e293b; /* Slate-800: Cards & Hover states */ - - --text-main: #f8fafc; /* Slate-50: Brightest for titles */ - --text-secondary: #94a3b8; /* Slate-400: Muted for subtext */ - + --bg-body: #020617; /* Slate-950: Main Background */ + --bg-surface: #0f172a; /* Slate-900: Sidebar & Header */ + --bg-elevated: #1e293b; /* Slate-800: Cards & Hover states */ + + --text-main: #f8fafc; /* Slate-50: Brightest for titles */ + --text-secondary: #94a3b8; /* Slate-400: Muted for subtext */ + --border-color: rgba(255, 255, 255, 0.06); - + --primary-light: rgba(59, 130, 246, 0.15); - + /* Neutral scale for dark mode utility usage */ --neutral-100: #1e293b; --neutral-200: #334155; @@ -634,6 +634,7 @@ ul { } .font-bold { font-weight: 700; + letter-spacing: normal; } .w-full { width: 100%; @@ -658,12 +659,12 @@ ul { .page-container { max-width: 1280px; /* max-7xl equivalent roughly */ margin: 0 auto; - padding: 2rem 1.5rem; + padding: 1rem 1.5rem; } @media (min-width: 1024px) { .page-container { - padding: 3rem 2rem; + padding: 1.5rem 2rem; } } diff --git a/Frontend-Learner/components/classroom/AnnouncementModal.vue b/Frontend-Learner/components/classroom/AnnouncementModal.vue index aedb1177..7c0b8356 100644 --- a/Frontend-Learner/components/classroom/AnnouncementModal.vue +++ b/Frontend-Learner/components/classroom/AnnouncementModal.vue @@ -90,7 +90,7 @@ const getLocalizedText = (text: any) => {
-

{{ $t('classroom.noAnnouncements', 'ไม่มีประกาศในขณะนี้') }}

+

{{ $t('classroom.noAnnouncements') }}

diff --git a/Frontend-Learner/components/classroom/CurriculumSidebar.vue b/Frontend-Learner/components/classroom/CurriculumSidebar.vue index ccd87df4..382be584 100644 --- a/Frontend-Learner/components/classroom/CurriculumSidebar.vue +++ b/Frontend-Learner/components/classroom/CurriculumSidebar.vue @@ -21,14 +21,76 @@ const emit = defineEmits<{ const { locale } = useI18n() +// State for expansion items +const chapterOpenState = ref>({}) + // Helper for localization const getLocalizedText = (text: any) => { if (!text) return '' if (typeof text === 'string') return text - const currentLocale = locale.value as 'th' | 'en' + // Safe locale access + const currentLocale = (locale?.value || 'th') as 'th' | 'en' return text[currentLocale] || text.th || text.en || '' } + +// Helper: Check if lesson is completed +const isLessonCompleted = (lesson: any) => { + return lesson.is_completed === true || lesson.progress?.is_completed === true +} + +// Reactive Chapter Completion Status +// Computes a map of chapterId -> boolean (true if all lessons are completed) +const chapterCompletionStatus = computed(() => { + const status: Record = {} + if (!props.courseData || !props.courseData.chapters) return status + + props.courseData.chapters.forEach((chapter: any) => { + if (chapter.lessons && chapter.lessons.length > 0) { + status[chapter.id] = chapter.lessons.every((l: any) => isLessonCompleted(l)) + } else { + status[chapter.id] = false + } + }) + return status +}) + +// Local Progress Calculation +const progressPercentage = computed(() => { + if (!props.courseData || !props.courseData.chapters) return 0 + let total = 0 + let completed = 0 + props.courseData.chapters.forEach((c: any) => { + c.lessons.forEach((l: any) => { + total++ + if (isLessonCompleted(l)) completed++ + }) + }) + return total > 0 ? Math.round((completed / total) * 100) : 0 +}) + +// Auto-expand chapter containing current lesson +watch(() => props.currentLessonId, (newId) => { + if (newId && props.courseData?.chapters) { + props.courseData.chapters.forEach((chapter: any) => { + const hasLesson = chapter.lessons.some((l: any) => l.id === newId) + if (hasLesson) { + chapterOpenState.value[chapter.id] = true + } + }) + } +}, { immediate: true }) + +// Initialize all chapters as open by default on load +watch(() => props.courseData, (newData) => { + if (newData?.chapters) { + newData.chapters.forEach((chapter: any) => { + if (chapterOpenState.value[chapter.id] === undefined) { + chapterOpenState.value[chapter.id] = true + } + }) + } +}, { immediate: true }) + + diff --git a/Frontend-Learner/components/classroom/VideoPlayer.vue b/Frontend-Learner/components/classroom/VideoPlayer.vue index 7dedea50..4bd0af28 100644 --- a/Frontend-Learner/components/classroom/VideoPlayer.vue +++ b/Frontend-Learner/components/classroom/VideoPlayer.vue @@ -6,6 +6,7 @@ const props = defineProps<{ src: string; + poster?: string; initialSeekTime?: number; }>(); @@ -164,9 +165,21 @@ const togglePlay = () => { return; } if (!videoRef.value) return; - if (isPlaying.value) videoRef.value.pause(); - else videoRef.value.play(); - isPlaying.value = !isPlaying.value; + if (isPlaying.value) { + videoRef.value.pause(); + isPlaying.value = false; + } else { + const playPromise = videoRef.value.play(); + if (playPromise !== undefined) { + playPromise.then(() => { + isPlaying.value = true; + }).catch(error => { + // Auto-play was prevented or play was interrupted + // We can safely ignore this error + console.log("Video play request handled:", error.name); + }); + } + } }; const handleTimeUpdate = () => { @@ -237,11 +250,12 @@ watch([volume, isMuted], () => { > -
+