diff --git a/Backend/prisma/schema.prisma b/Backend/prisma/schema.prisma index bc4b2267..212b3bf3 100644 --- a/Backend/prisma/schema.prisma +++ b/Backend/prisma/schema.prisma @@ -634,8 +634,6 @@ 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 f10b99ca..0ad0171a 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,8 +25,10 @@ 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(token); + if (!token) { + throw new ValidationError('No token provided'); + } + return await AdminCourseApprovalService.listPendingCourses(); } /** @@ -42,8 +44,10 @@ 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(token, courseId); + if (!token) { + throw new ValidationError('No token provided'); + } + return await AdminCourseApprovalService.getCourseDetail(courseId); } /** @@ -60,12 +64,14 @@ export class AdminCourseApprovalController { @Response('404', 'Course not found') public async approveCourse( @Request() request: any, - @Path() courseId: number + @Path() courseId: number, + @Body() body?: ApproveCourseBody ): Promise { const token = request.headers.authorization?.replace('Bearer ', ''); - if (!token) throw new ValidationError('No token provided'); - - return await AdminCourseApprovalService.approveCourse(token, courseId, undefined); + if (!token) { + throw new ValidationError('No token provided'); + } + return await AdminCourseApprovalService.approveCourse(token, courseId, body?.comment); } /** @@ -86,12 +92,9 @@ export class AdminCourseApprovalController { @Body() body: RejectCourseBody ): Promise { const token = request.headers.authorization?.replace('Bearer ', ''); - if (!token) throw new ValidationError('No token provided'); - - // Validate body - const { error } = RejectCourseValidator.validate(body); - if (error) throw new ValidationError(error.details[0].message); - + if (!token) { + throw new ValidationError('No token provided'); + } return await AdminCourseApprovalService.rejectCourse(token, courseId, body.comment); } } diff --git a/Backend/src/controllers/AuditController.ts b/Backend/src/controllers/AuditController.ts index a78c8d5a..5de912fc 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 < 6) { - throw new ValidationError('Cannot delete logs newer than 6 days'); + if (days < 30) { + throw new ValidationError('Cannot delete logs newer than 30 days'); } const deleted = await auditService.deleteOldLogs(days); diff --git a/Backend/src/controllers/CategoriesController.ts b/Backend/src/controllers/CategoriesController.ts index 81fd8b86..09a5a621 100644 --- a/Backend/src/controllers/CategoriesController.ts +++ b/Backend/src/controllers/CategoriesController.ts @@ -2,7 +2,6 @@ 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') @@ -28,11 +27,6 @@ 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); } @@ -42,11 +36,6 @@ 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); } @@ -56,6 +45,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(token, id); + return await this.categoryService.deleteCategory(id); } } \ No newline at end of file diff --git a/Backend/src/controllers/ChaptersLessonInstructorController.ts b/Backend/src/controllers/ChaptersLessonInstructorController.ts index 7ba48f5c..f0bb43fe 100644 --- a/Backend/src/controllers/ChaptersLessonInstructorController.ts +++ b/Backend/src/controllers/ChaptersLessonInstructorController.ts @@ -27,18 +27,6 @@ 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(); @@ -67,10 +55,6 @@ 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, @@ -98,10 +82,6 @@ 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, @@ -145,10 +125,6 @@ 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, @@ -194,10 +170,6 @@ 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, @@ -225,10 +197,6 @@ 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, @@ -278,10 +246,6 @@ 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, @@ -311,10 +275,6 @@ 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, @@ -340,10 +300,6 @@ 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, @@ -366,10 +322,6 @@ 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, @@ -419,10 +371,6 @@ 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 3657e928..5b698807 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, - ListMyCourseResponse, GetMyCourseResponse, + ListMyCourseResponse, + addinstructorCourseResponse, + removeinstructorCourseResponse, + setprimaryCourseInstructorResponse, UpdateMyCourse, UpdateMyCourseResponse, DeleteMyCourseResponse, submitCourseResponse, listinstructorCourseResponse, - addinstructorCourseResponse, - removeinstructorCourseResponse, - setprimaryCourseInstructorResponse, - GetEnrolledStudentsResponse, - GetEnrolledStudentDetailResponse, - GetQuizScoresResponse, - GetQuizAttemptDetailResponse, GetCourseApprovalsResponse, SearchInstructorResponse, + GetEnrolledStudentsResponse, + GetQuizScoresResponse, + GetQuizAttemptDetailResponse, + GetEnrolledStudentDetailResponse, GetCourseApprovalHistoryResponse, setCourseDraftResponse, - CloneCourseResponse, } from '../types/CoursesInstructor.types'; -import { CreateCourseValidator, UpdateCourseValidator, CloneCourseValidator } from "../validators/CoursesInstructor.validator"; +import { CreateCourseValidator } from "../validators/CoursesInstructor.validator"; import jwt from 'jsonwebtoken'; import { config } from '../config'; @@ -41,15 +41,12 @@ 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, - @Query() status?: 'DRAFT' | 'PENDING' | 'APPROVED' | 'REJECTED' | 'ARCHIVED' - ): Promise { + public async listMyCourses(@Request() request: any): Promise { const token = request.headers.authorization?.replace('Bearer ', ''); if (!token) { throw new ValidationError('No token provided'); } - return await CoursesInstructorService.listMyCourses({ token, status }); + return await CoursesInstructorService.listMyCourses(token); } /** @@ -102,11 +99,9 @@ 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'); - - const { error } = UpdateCourseValidator.validate(body.data); - if (error) throw new ValidationError(error.details[0].message); - + if (!token) { + throw new ValidationError('No token provided'); + } return await CoursesInstructorService.updateCourse(token, courseId, body.data); } @@ -179,36 +174,6 @@ 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 87a5a613..afcf80b0 100644 --- a/Backend/src/controllers/CoursesStudentController.ts +++ b/Backend/src/controllers/CoursesStudentController.ts @@ -16,7 +16,6 @@ import { GetQuizAttemptsResponse, } from '../types/CoursesStudent.types'; import { EnrollmentStatus } from '@prisma/client'; -import { SaveVideoProgressValidator, SubmitQuizValidator } from '../validators/CoursesStudent.validator'; @Route('api/students') @Tags('CoursesStudent') @@ -150,11 +149,9 @@ export class CoursesStudentController { @Body() body: SaveVideoProgressBody ): Promise { const token = request.headers.authorization?.replace('Bearer ', ''); - if (!token) throw new ValidationError('No token provided'); - - const { error } = SaveVideoProgressValidator.validate(body); - if (error) throw new ValidationError(error.details[0].message); - + if (!token) { + throw new ValidationError('No token provided'); + } return await this.service.saveVideoProgress({ token, lesson_id: lessonId, @@ -228,11 +225,9 @@ export class CoursesStudentController { @Body() body: SubmitQuizBody ): Promise { const token = request.headers.authorization?.replace('Bearer ', ''); - if (!token) throw new ValidationError('No token provided'); - - const { error } = SubmitQuizValidator.validate(body); - if (error) throw new ValidationError(error.details[0].message); - + if (!token) { + throw new ValidationError('No token provided'); + } return await this.service.submitQuiz({ token, course_id: courseId, diff --git a/Backend/src/controllers/LessonsController.ts b/Backend/src/controllers/LessonsController.ts index 0323f4ab..f054ef4e 100644 --- a/Backend/src/controllers/LessonsController.ts +++ b/Backend/src/controllers/LessonsController.ts @@ -11,7 +11,6 @@ import { YouTubeVideoResponse, SetYouTubeVideoBody, } from '../types/ChaptersLesson.typs'; -import { SetYouTubeVideoValidator } from '../validators/Lessons.validator'; const chaptersLessonService = new ChaptersLessonService(); @@ -214,8 +213,12 @@ export class LessonsController { const token = request.headers.authorization?.replace('Bearer ', ''); if (!token) throw new ValidationError('No token provided'); - const { error } = SetYouTubeVideoValidator.validate(body); - if (error) throw new ValidationError(error.details[0].message); + if (!body.youtube_video_id) { + throw new ValidationError('YouTube video ID is required'); + } + if (!body.video_title) { + throw new ValidationError('Video title is required'); + } return await chaptersLessonService.setYouTubeVideo({ token, diff --git a/Backend/src/controllers/RecommendedCoursesController.ts b/Backend/src/controllers/RecommendedCoursesController.ts index 720bff7c..7e770c12 100644 --- a/Backend/src/controllers/RecommendedCoursesController.ts +++ b/Backend/src/controllers/RecommendedCoursesController.ts @@ -20,14 +20,12 @@ export class RecommendedCoursesController { @SuccessResponse('200', 'Approved courses retrieved successfully') @Response('401', 'Unauthorized') @Response('403', 'Forbidden - Admin only') - public async listApprovedCourses( - @Request() request: any, - @Query() search?: string, - @Query() categoryId?: number - ): Promise { + public async listApprovedCourses(@Request() request: any): Promise { const token = request.headers.authorization?.replace('Bearer ', ''); - if (!token) throw new ValidationError('No token provided'); - return await RecommendedCoursesService.listApprovedCourses(token, { search, categoryId }); + if (!token) { + throw new ValidationError('No token provided'); + } + return await RecommendedCoursesService.listApprovedCourses(); } /** @@ -44,8 +42,10 @@ 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(token, courseId); + if (!token) { + throw new ValidationError('No token provided'); + } + return await RecommendedCoursesService.getCourseById(courseId); } /** @@ -62,11 +62,13 @@ 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 ccbe7c76..b8169827 100644 --- a/Backend/src/controllers/UserController.ts +++ b/Backend/src/controllers/UserController.ts @@ -10,8 +10,7 @@ import { ChangePasswordResponse, updateAvatarResponse, SendVerifyEmailResponse, - VerifyEmailResponse, - rolesResponse + VerifyEmailResponse } from '../types/user.types'; import { ChangePassword } from '../types/auth.types'; import { profileUpdateSchema, changePasswordSchema } from "../validators/user.validator"; @@ -57,18 +56,6 @@ 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 8ac03c70..6a4b901c 100644 --- a/Backend/src/controllers/announcementsController.ts +++ b/Backend/src/controllers/announcementsController.ts @@ -1,7 +1,6 @@ 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, @@ -69,10 +68,6 @@ 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, @@ -105,11 +100,6 @@ 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 0596034c..5e9da6ea 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(token: string): Promise { + static async listPendingCourses(): 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,16 +96,6 @@ 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; } } @@ -113,7 +103,7 @@ export class AdminCourseApprovalService { /** * Get course details for admin review */ - static async getCourseDetail(token: string, courseId: number): Promise { + static async getCourseDetail(courseId: number): Promise { try { const course = await prisma.course.findUnique({ where: { id: courseId }, @@ -133,11 +123,7 @@ export class AdminCourseApprovalService { }, chapters: { orderBy: { sort_order: 'asc' }, - select: { - id: true, - title: true, - sort_order: true, - is_published: true, + include: { lessons: { orderBy: { sort_order: 'asc' }, select: { @@ -228,16 +214,6 @@ 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; } } @@ -262,7 +238,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() @@ -299,17 +275,6 @@ 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; } } @@ -338,12 +303,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({ @@ -376,17 +341,6 @@ 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 003670b1..02ce0a67 100644 --- a/Backend/src/services/ChaptersLesson.service.ts +++ b/Backend/src/services/ChaptersLesson.service.ts @@ -142,17 +142,6 @@ 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; } } @@ -174,17 +163,6 @@ 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; } } @@ -219,17 +197,6 @@ 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; } } @@ -313,17 +280,6 @@ 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; } } @@ -398,17 +354,6 @@ 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; } } @@ -549,17 +494,6 @@ 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; } } @@ -581,17 +515,6 @@ 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; } } @@ -682,17 +605,6 @@ 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; } } @@ -764,17 +676,6 @@ 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; } } @@ -853,17 +754,6 @@ 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; } } @@ -946,17 +836,6 @@ 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; } } @@ -1038,17 +917,6 @@ 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; } } @@ -1125,17 +993,6 @@ 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; } } @@ -1194,17 +1051,6 @@ 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; } } @@ -1281,17 +1127,6 @@ 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; } } @@ -1367,17 +1202,6 @@ 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; } } @@ -1471,17 +1295,6 @@ 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; } } @@ -1530,17 +1343,6 @@ 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 e1b40d0c..244e26ac 100644 --- a/Backend/src/services/CoursesInstructor.service.ts +++ b/Backend/src/services/CoursesInstructor.service.ts @@ -10,7 +10,6 @@ import { UpdateCourseInput, createCourseResponse, GetMyCourseResponse, - ListMyCoursesInput, ListMyCourseResponse, addinstructorCourse, addinstructorCourseResponse, @@ -34,8 +33,6 @@ import { GetEnrolledStudentDetailInput, GetEnrolledStudentDetailResponse, GetCourseApprovalHistoryResponse, - CloneCourseInput, - CloneCourseResponse, setCourseDraft, setCourseDraftResponse, } from "../types/CoursesInstructor.types"; @@ -105,27 +102,16 @@ 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(input: ListMyCoursesInput): Promise { + static async listMyCourses(token: string): Promise { try { - const decoded = jwt.verify(input.token, config.jwt.secret) as { id: number; type: string }; + const decoded = jwt.verify(token, config.jwt.secret) as { id: number; type: string }; const courseInstructors = await prisma.courseInstructor.findMany({ where: { - user_id: decoded.id, - course: input.status ? { status: input.status } : undefined + user_id: decoded.id }, include: { course: true @@ -157,17 +143,6 @@ 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; } } @@ -225,17 +200,6 @@ 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; } } @@ -258,17 +222,6 @@ 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; } } @@ -322,17 +275,6 @@ 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; } } @@ -349,15 +291,6 @@ 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', @@ -365,17 +298,6 @@ 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; } } @@ -397,32 +319,12 @@ 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; } } @@ -445,17 +347,6 @@ 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; } } @@ -467,6 +358,8 @@ 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); @@ -491,17 +384,6 @@ 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; } } @@ -563,17 +445,6 @@ 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; } } @@ -619,35 +490,12 @@ 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; } } @@ -663,36 +511,12 @@ 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; } } @@ -743,17 +567,6 @@ 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; } } @@ -772,36 +585,12 @@ 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; } } @@ -840,6 +629,7 @@ 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); @@ -917,17 +707,6 @@ 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; } } @@ -979,7 +758,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 }, @@ -1095,17 +874,6 @@ 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; } } @@ -1117,6 +885,7 @@ 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); @@ -1219,17 +988,6 @@ 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; } } @@ -1367,17 +1125,6 @@ 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; } } @@ -1434,241 +1181,6 @@ 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 986695b1..87679ab3 100644 --- a/Backend/src/services/CoursesStudent.service.ts +++ b/Backend/src/services/CoursesStudent.service.ts @@ -186,20 +186,7 @@ export class CoursesStudentService { }, }; } catch (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) - } - }); - + logger.error(error); throw error; } } @@ -274,17 +261,6 @@ 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; } } @@ -340,19 +316,6 @@ 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({ @@ -453,17 +416,6 @@ 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; } } @@ -726,17 +678,6 @@ 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; } } @@ -925,17 +866,6 @@ 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; } } @@ -1010,17 +940,6 @@ 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; } } @@ -1118,17 +1037,6 @@ 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; } } @@ -1260,19 +1168,7 @@ export class CoursesStudentService { }, }; } catch (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) - } - }); + logger.error(error); throw error; } } @@ -1317,14 +1213,22 @@ 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; @@ -1428,20 +1332,7 @@ export class CoursesStudentService { }, }; } catch (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) - } - }); + logger.error(error); throw error; } } @@ -1482,14 +1373,22 @@ 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({ @@ -1539,21 +1438,6 @@ 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 22440eb2..b5c977c5 100644 --- a/Backend/src/services/RecommendedCourses.service.ts +++ b/Backend/src/services/RecommendedCourses.service.ts @@ -8,8 +8,7 @@ import { ListApprovedCoursesResponse, GetCourseByIdResponse, ToggleRecommendedResponse, - RecommendedCourseData, - RecommendedCourseDetailData + RecommendedCourseData } from '../types/RecommendedCourses.types'; import { auditService } from './audit.service'; import { AuditAction } from '@prisma/client'; @@ -19,24 +18,10 @@ export class RecommendedCoursesService { /** * List all approved courses (for admin to manage recommendations) */ - static async listApprovedCourses( - token: string, - filters?: { search?: string; categoryId?: number } - ): Promise { + static async listApprovedCourses(): Promise { try { - const { search, categoryId } = filters ?? {}; - const courses = await prisma.course.findMany({ - where: { - status: 'APPROVED', - ...(categoryId ? { category_id: categoryId } : {}), - ...(search ? { - OR: [ - { title: { path: ['th'], string_contains: search } }, - { title: { path: ['en'], string_contains: search } } - ] - } : {}) - }, + where: { status: 'APPROVED' }, orderBy: [ { is_recommended: 'desc' }, { updated_at: 'desc' } @@ -55,9 +40,9 @@ export class RecommendedCoursesService { } } }, - _count: { - select: { - chapters: true + chapters: { + include: { + lessons: true } } } @@ -96,7 +81,8 @@ export class RecommendedCoursesService { is_primary: i.is_primary, user: i.user })), - chapters_count: course._count.chapters, + chapters_count: course.chapters.length, + lessons_count: course.chapters.reduce((sum, ch) => sum + ch.lessons.length, 0) } as RecommendedCourseData; })); @@ -108,19 +94,6 @@ 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; } } @@ -128,7 +101,7 @@ export class RecommendedCoursesService { /** * Get course by ID (for admin to view details) */ - static async getCourseById(token: string, courseId: number): Promise { + static async getCourseById(courseId: number): Promise { try { const course = await prisma.course.findUnique({ where: { id: courseId }, @@ -172,7 +145,7 @@ export class RecommendedCoursesService { } } - const data: RecommendedCourseDetailData = { + const data: RecommendedCourseData = { id: course.id, title: course.title as { th: string; en: string }, slug: course.slug, @@ -195,15 +168,8 @@ export class RecommendedCoursesService { is_primary: i.is_primary, user: i.user })), - 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 } - })) - })) + chapters_count: course.chapters.length, + lessons_count: course.chapters.reduce((sum, ch) => sum + ch.lessons.length, 0) }; return { @@ -213,19 +179,6 @@ 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; } } @@ -276,17 +229,6 @@ 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 7e8b2d3e..eb26b3d1 100644 --- a/Backend/src/services/announcements.service.ts +++ b/Backend/src/services/announcements.service.ts @@ -20,8 +20,6 @@ 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 { @@ -39,7 +37,9 @@ 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,16 +130,6 @@ 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; } } @@ -236,16 +226,6 @@ 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; } } @@ -320,16 +300,6 @@ 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; } } @@ -376,16 +346,6 @@ 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; } } @@ -451,16 +411,6 @@ 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; } } @@ -508,16 +458,6 @@ 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 52038129..e913be7b 100644 --- a/Backend/src/services/audit.service.ts +++ b/Backend/src/services/audit.service.ts @@ -37,23 +37,19 @@ export class AuditService { * Log พร้อม await (สำหรับ critical actions) */ async logSync(params: CreateAuditLogParams): Promise { - 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 }); - } + 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, + }, + }); } /** diff --git a/Backend/src/services/auth.service.ts b/Backend/src/services/auth.service.ts index 66899bed..ad4bcbbc 100644 --- a/Backend/src/services/auth.service.ts +++ b/Backend/src/services/auth.service.ts @@ -83,201 +83,167 @@ export class AuthService { * User registration */ async register(data: RegisterRequest): Promise { - try { - const { username, email, password, first_name, last_name, prefix, phone } = data; + 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 - } - }); - - 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; + 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 + } + }); + + 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' + }; } async registerInstructor(data: RegisterRequest): Promise { - try { - const { username, email, password, first_name, last_name, prefix, phone } = data; + 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 - } - }); - - 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; + 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 + } + }); + + 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' + }; } /** diff --git a/Backend/src/services/categories.service.ts b/Backend/src/services/categories.service.ts index 0e0defa2..2b9138b9 100644 --- a/Backend/src/services/categories.service.ts +++ b/Backend/src/services/categories.service.ts @@ -5,8 +5,6 @@ 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 { @@ -32,13 +30,6 @@ 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', @@ -52,16 +43,6 @@ 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; } } @@ -73,13 +54,6 @@ 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', @@ -93,49 +67,21 @@ 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(token: string, id: number): Promise { + async deleteCategory(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 4041ec41..ab1749ae 100644 --- a/Backend/src/services/certificate.service.ts +++ b/Backend/src/services/certificate.service.ts @@ -16,8 +16,6 @@ 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'); @@ -56,11 +54,17 @@ 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({ @@ -117,14 +121,6 @@ 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 { @@ -139,18 +135,6 @@ 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; } } @@ -202,18 +186,6 @@ 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; } } @@ -267,17 +239,6 @@ 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 6b810ca9..252744f8 100644 --- a/Backend/src/services/courses.service.ts +++ b/Backend/src/services/courses.service.ts @@ -5,8 +5,6 @@ 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 { @@ -84,16 +82,6 @@ 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; } } @@ -103,56 +91,7 @@ export class CoursesService { const course = await prisma.course.findFirst({ where: { id, - 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 } - } - } - } + status: 'APPROVED' // Only show approved courses to students } }); @@ -173,83 +112,16 @@ 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 69153d51..da9bc274 100644 --- a/Backend/src/services/user.service.ts +++ b/Backend/src/services/user.service.ts @@ -14,8 +14,7 @@ import { updateAvatarRequest, updateAvatarResponse, SendVerifyEmailResponse, - VerifyEmailResponse, - rolesResponse + VerifyEmailResponse } from '../types/user.types'; import nodemailer from 'nodemailer'; import { UnauthorizedError, ValidationError, ForbiddenError } from '../middleware/errorHandler'; @@ -136,17 +135,6 @@ 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; } } @@ -198,41 +186,6 @@ 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; } } @@ -299,18 +252,6 @@ 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); @@ -332,18 +273,6 @@ 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; } } @@ -456,17 +385,6 @@ 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; } } @@ -497,15 +415,6 @@ 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' @@ -514,17 +423,6 @@ 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 2bded499..658382c2 100644 --- a/Backend/src/services/usermanagement.service.ts +++ b/Backend/src/services/usermanagement.service.ts @@ -39,16 +39,6 @@ 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; } } @@ -71,16 +61,6 @@ 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; } } @@ -115,17 +95,6 @@ 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; } } @@ -145,16 +114,6 @@ 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; } } @@ -201,16 +160,6 @@ 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; } } @@ -258,16 +207,6 @@ 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 d68c8c81..fa98ea6b 100644 --- a/Backend/src/types/AdminCourseApproval.types.ts +++ b/Backend/src/types/AdminCourseApproval.types.ts @@ -117,6 +117,10 @@ 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 cc4aa149..a10dac44 100644 --- a/Backend/src/types/CoursesInstructor.types.ts +++ b/Backend/src/types/CoursesInstructor.types.ts @@ -23,11 +23,6 @@ export interface createCourseResponse { data: Course; } -export interface ListMyCoursesInput { - token: string; - status?: 'DRAFT' | 'PENDING' | 'APPROVED' | 'REJECTED' | 'ARCHIVED'; -} - export interface ListMyCourseResponse { code: number; message: string; @@ -433,18 +428,3 @@ 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 48f495a7..c11c6c93 100644 --- a/Backend/src/types/RecommendedCourses.types.ts +++ b/Backend/src/types/RecommendedCourses.types.ts @@ -1,10 +1,14 @@ import { MultiLanguageText } from './index'; +// ============================================ +// Request Types +// ============================================ + + // ============================================ // Response Types // ============================================ -/** ใช้ใน listApprovedCourses — มีแค่ chapters_count */ export interface RecommendedCourseData { id: number; title: MultiLanguageText; @@ -37,19 +41,7 @@ export interface RecommendedCourseData { }; }>; chapters_count: number; -} - -/** ใช้ใน getCourseById — มี chapters + lessons พร้อมชื่อ */ -export interface RecommendedCourseDetailData extends Omit { - chapters: { - id: number; - title: MultiLanguageText; - sort_order: number; - lessons: { - id: number; - title: MultiLanguageText; - }[]; - }[]; + lessons_count: number; } export interface ListApprovedCoursesResponse { @@ -62,7 +54,7 @@ export interface ListApprovedCoursesResponse { export interface GetCourseByIdResponse { code: number; message: string; - data: RecommendedCourseDetailData; + data: RecommendedCourseData; } export interface ToggleRecommendedResponse { diff --git a/Backend/src/types/courses.types.ts b/Backend/src/types/courses.types.ts index a294d7e2..42c83398 100644 --- a/Backend/src/types/courses.types.ts +++ b/Backend/src/types/courses.types.ts @@ -1,5 +1,4 @@ import { Course } from '@prisma/client'; -import { MultiLanguageText } from './index'; export interface ListCoursesInput { category_id?: number; @@ -19,47 +18,8 @@ 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: CourseDetail | null; + data: Course | null; } diff --git a/Backend/src/types/user.types.ts b/Backend/src/types/user.types.ts index 42ac8e75..413cb3f2 100644 --- a/Backend/src/types/user.types.ts +++ b/Backend/src/types/user.types.ts @@ -59,14 +59,6 @@ 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 deleted file mode 100644 index 89e0a284..00000000 --- a/Backend/src/validators/AdminCourseApproval.validator.ts +++ /dev/null @@ -1,30 +0,0 @@ -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 deleted file mode 100644 index 933a3e1a..00000000 --- a/Backend/src/validators/ChaptersLesson.validator.ts +++ /dev/null @@ -1,186 +0,0 @@ -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 cbde5802..fe971950 100644 --- a/Backend/src/validators/CoursesInstructor.validator.ts +++ b/Backend/src/validators/CoursesInstructor.validator.ts @@ -20,38 +20,3 @@ 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 deleted file mode 100644 index 424c35fe..00000000 --- a/Backend/src/validators/CoursesStudent.validator.ts +++ /dev/null @@ -1,38 +0,0 @@ -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 deleted file mode 100644 index 4161ec53..00000000 --- a/Backend/src/validators/Lessons.validator.ts +++ /dev/null @@ -1,15 +0,0 @@ -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 deleted file mode 100644 index bd9ad945..00000000 --- a/Backend/src/validators/announcements.validator.ts +++ /dev/null @@ -1,72 +0,0 @@ -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 deleted file mode 100644 index 521c9faf..00000000 --- a/Backend/src/validators/categories.validator.ts +++ /dev/null @@ -1,58 +0,0 @@ -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 deleted file mode 100644 index d3e1032b..00000000 --- a/Backend/tests/k6/enroll-load-test.js +++ /dev/null @@ -1,160 +0,0 @@ -// 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 aee4cb4a..2a0c375d 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% pof logins < 2s + login_duration: ['p(95)<2000'], // 95% of logins < 2s }, }; diff --git a/Backend/tests/k6/video-watching-load-test.js b/Backend/tests/k6/video-watching-load-test.js deleted file mode 100644 index e3bb205c..00000000 --- a/Backend/tests/k6/video-watching-load-test.js +++ /dev/null @@ -1,269 +0,0 @@ -// 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 8070d468..a1ac35c7 100644 --- a/Frontend-Learner/app.vue +++ b/Frontend-Learner/app.vue @@ -1,27 +1,20 @@ - diff --git a/Frontend-Learner/assets/css/main.css b/Frontend-Learner/assets/css/main.css index 960858a7..db18a4fb 100644 --- a/Frontend-Learner/assets/css/main.css +++ b/Frontend-Learner/assets/css/main.css @@ -27,7 +27,7 @@ /* Typography */ /* Typography */ --font-main: - "Prompt", "Inter", "Sarabun", -apple-system, BlinkMacSystemFont, "Segoe UI", + "Prompt", "Sarabun", "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Helvetica Neue", Arial, sans-serif; /* Layout */ @@ -634,7 +634,6 @@ ul { } .font-bold { font-weight: 700; - letter-spacing: normal; } .w-full { width: 100%; diff --git a/Frontend-Learner/components/classroom/CurriculumSidebar.vue b/Frontend-Learner/components/classroom/CurriculumSidebar.vue index 382be584..a7ccddab 100644 --- a/Frontend-Learner/components/classroom/CurriculumSidebar.vue +++ b/Frontend-Learner/components/classroom/CurriculumSidebar.vue @@ -21,40 +21,15 @@ 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 - // Safe locale access - const currentLocale = (locale?.value || 'th') as 'th' | 'en' + const currentLocale = locale.value 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 @@ -63,34 +38,11 @@ const progressPercentage = computed(() => { props.courseData.chapters.forEach((c: any) => { c.lessons.forEach((l: any) => { total++ - if (isLessonCompleted(l)) completed++ + if (l.is_completed || l.progress?.is_completed) 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/course/CourseCard.vue b/Frontend-Learner/components/course/CourseCard.vue index 1cd55e5a..b26d9ea1 100644 --- a/Frontend-Learner/components/course/CourseCard.vue +++ b/Frontend-Learner/components/course/CourseCard.vue @@ -25,8 +25,6 @@ interface CourseCardProps { showContinue?: boolean showCertificate?: boolean showStudyAgain?: boolean - hideProgress?: boolean - hideActions?: boolean } const props = withDefaults(defineProps(), { @@ -57,7 +55,7 @@ const displayCategory = computed(() => getLocalizedText(props.category))