From 11f9ad57cd09f43a486884f0449a6cadc2e02552 Mon Sep 17 00:00:00 2001 From: supalerk-ar66 Date: Thu, 12 Feb 2026 16:56:02 +0700 Subject: [PATCH 01/29] feat: Add initial internationalization files for English and Thai languages. --- Frontend-Learner/i18n/locales/en.json | 3 +++ Frontend-Learner/i18n/locales/th.json | 3 +++ 2 files changed, 6 insertions(+) diff --git a/Frontend-Learner/i18n/locales/en.json b/Frontend-Learner/i18n/locales/en.json index c680a1e6..ea57a1e2 100644 --- a/Frontend-Learner/i18n/locales/en.json +++ b/Frontend-Learner/i18n/locales/en.json @@ -40,6 +40,9 @@ "studyAgain": "Study Again", "downloadCertificate": "Download Certificate", "completed": "Completed", + "includes": "Course includes", + "fullLifetimeAccess": "Full lifetime access", + "accessOnMobile": "Access on mobile and TV", "lifetimeAccess": "Lifetime access", "unlimitedQuizzes": "Unlimited quizzes", "satisfactionGuarantee": "Satisfaction guarantee, 7-day refund", diff --git a/Frontend-Learner/i18n/locales/th.json b/Frontend-Learner/i18n/locales/th.json index 9e693b98..82ca0f4a 100644 --- a/Frontend-Learner/i18n/locales/th.json +++ b/Frontend-Learner/i18n/locales/th.json @@ -40,6 +40,9 @@ "studyAgain": "ทบทวนบทเรียน", "downloadCertificate": "ดาวน์โหลดประกาศนียบัตร", "completed": "เรียนจบเรียบร้อย", + "includes": "สิ่งที่รวมอยู่ในคอร์ส", + "fullLifetimeAccess": "เข้าเรียนได้ตลอดชีพ", + "accessOnMobile": "เรียนได้บนมือถือและแท็บเล็ต", "lifetimeAccess": "เข้าเรียนได้ตลอดชีพ", "unlimitedQuizzes": "ทำแบบทดสอบไม่จำกัด", "satisfactionGuarantee": "รับประกันความพึงพอใจ คืนเงินภายใน 7 วัน", From 21273fcaeb4dc742e549fd104108f752376e2704 Mon Sep 17 00:00:00 2001 From: supalerk-ar66 Date: Fri, 13 Feb 2026 11:42:10 +0700 Subject: [PATCH 02/29] feat: Implement the core online learning classroom interface with video player, quiz management, and announcements. --- Frontend-Learner/pages/classroom/learning.vue | 69 ++++++++++++------- Frontend-Learner/คู่มืออธิบาย/web-dev-details.md | 36 +++++++--- 2 files changed, 72 insertions(+), 33 deletions(-) diff --git a/Frontend-Learner/pages/classroom/learning.vue b/Frontend-Learner/pages/classroom/learning.vue index f6c06675..3a68f2b7 100644 --- a/Frontend-Learner/pages/classroom/learning.vue +++ b/Frontend-Learner/pages/classroom/learning.vue @@ -195,9 +195,9 @@ const resetAndNavigate = (path: string) => { // 2. Clear all localStorage localStorage.clear() - // 3. Restore whitelisted keys - Object.entries(whitelist).forEach(([key, value]) => { - localStorage.setItem(key, value) + // 3. Restore ONLY whitelisted keys + Object.keys(whitelist).forEach(key => { + localStorage.setItem(key, whitelist[key]) }) // 4. Force hard reload to the new path @@ -278,6 +278,12 @@ const loadLesson = async (lessonId: number) => { isPlaying.value = false videoProgress.value = 0 currentTime.value = 0 + initialSeekTime.value = 0 + maxWatchedTime.value = 0 + lastSavedTime.value = -1 + lastSavedTimestamp.value = 0 + lastLocalSaveTimestamp.value = 0 + currentDuration.value = 0 currentLesson.value = null // This will unmount VideoPlayer and hide content isLessonLoading.value = true @@ -332,27 +338,36 @@ const loadLesson = async (lessonId: number) => { // 2. Fetch Initial Progress (Resume Playback) if (currentLesson.value.type === 'VIDEO') { - // A. Server Progress - const progressRes = await fetchVideoProgress(lessonId) - let serverProgress = 0 - if (progressRes.success && progressRes.data?.video_progress_seconds) { - serverProgress = progressRes.data.video_progress_seconds - } - - // B. Local Progress (Buffer) - const localProgress = getLocalProgress(lessonId) + // If already completed, clear local resume point to allow fresh re-watch + const isCompleted = currentLesson.value.progress?.is_completed || false - // C. Hybrid Resume (Max Wins) - const resumeTime = Math.max(serverProgress, localProgress) - - if (resumeTime > 0) { - - initialSeekTime.value = resumeTime - maxWatchedTime.value = resumeTime - currentTime.value = resumeTime - } else { + if (isCompleted) { + const key = getLocalProgressKey(lessonId) + if (key && typeof window !== 'undefined') { + localStorage.removeItem(key) + } initialSeekTime.value = 0 maxWatchedTime.value = 0 + currentTime.value = 0 + } else { + // Not completed? Resume from where we left off + const progressRes = await fetchVideoProgress(lessonId) + let serverProgress = 0 + if (progressRes.success && progressRes.data?.video_progress_seconds) { + serverProgress = progressRes.data.video_progress_seconds + } + + const localProgress = getLocalProgress(lessonId) + const resumeTime = Math.max(serverProgress, localProgress) + + if (resumeTime > 0) { + initialSeekTime.value = resumeTime + maxWatchedTime.value = resumeTime + currentTime.value = resumeTime + } else { + initialSeekTime.value = 0 + maxWatchedTime.value = 0 + } } } } @@ -556,13 +571,19 @@ const videoSrc = computed(() => { // เมื่อวิดีโอจบ ให้บันทึกว่าเรียนจบ (Complete) const onVideoEnded = async () => { - // Safety check before saving const lesson = currentLesson.value - if (!lesson || !lesson.progress || lesson.progress.is_completed || isCompleting.value) return + if (!lesson) return + + // Clear local storage on end since it's completed + const key = getLocalProgressKey(lesson.id) + if (key && typeof window !== 'undefined') { + localStorage.removeItem(key) + } + + if (lesson.progress?.is_completed || isCompleting.value) return isCompleting.value = true try { - // Force save progress at 100% to trigger backend completion await performSaveProgress(true, false) } catch (err) { console.error('Failed to save progress on end:', err) diff --git a/Frontend-Learner/คู่มืออธิบาย/web-dev-details.md b/Frontend-Learner/คู่มืออธิบาย/web-dev-details.md index 7d65ecd1..af4494f7 100644 --- a/Frontend-Learner/คู่มืออธิบาย/web-dev-details.md +++ b/Frontend-Learner/คู่มืออธิบาย/web-dev-details.md @@ -10,9 +10,9 @@ ### 1.1 Tech Stack -- **Core:** [Nuxt 3](https://nuxt.com) (Vue 3 Composition API), TypeScript `^5.0` -- **UI Framework:** Quasar Framework (via `nuxt-quasar-ui`) -- **Styling:** Tailwind CSS (Utility) + Vanilla CSS Variables (Theming/Dark Mode) +- **Core:** [Nuxt 3](https://nuxt.com) (v`^3.11.2`), TypeScript `^5.4.5` +- **UI Framework:** Quasar Framework `^2.15.2` (via `nuxt-quasar-ui ^3.0.0`) +- **Styling:** Tailwind CSS `^6.12.0` (Utility) + Vanilla CSS Variables (Theming/Dark Mode) - **State Management:** `ref`/`reactive` (Local) + `useState` (Global/Shared State) - **Localization:** `@nuxtjs/i18n` (Supports JSON locales in `i18n/locales/`) - **Media Control:** `useMediaPrefs` (Command Pattern for global volume/mute state) @@ -57,17 +57,20 @@ - **Common (`components/common/`):** - `GlobalLoader.vue`: Loading indicator ทั่วทั้งแอป - `LanguageSwitcher.vue`: ปุ่มเปลี่ยนภาษา (TH/EN) - - `AppHeader.vue`, `MobileNav.vue`: Navigation หลัก + - `AppHeader.vue`, `MobileNav.vue`: Navigation หลัก (ใช้ร่วมกับ AppSidebar) - `FormInput.vue`: Input field มาตรฐาน +- **Layout (`components/layout/`):** + - `AppSidebar.vue`: Sidebar หลักสำหรับ Dashboard (Collapsible) + - `LandingHeader.vue`: Header เฉพาะสำหรับหน้า Landing Page - **Course (`components/course/`):** - - `CourseCard.vue`: การ์ดแสดงผลคอร์ส (ใช้ซ้ำหลายหน้า) + - `CourseCard.vue`: การ์ดแสดงผลคอร์ส รองรับ Progress และ **Glassmorphism** ในโหมดมืด - **Discovery (`components/discovery/`):** - `CategorySidebar.vue`: Sidebar ตัวกรองหมวดหมู่แบบย่อ/ขยายได้ - `CourseDetailView.vue`: หน้ารายละเอียดคอร์สขนาดใหญ่ (Video Preview + Syllabus) - **Classroom (`components/classroom/`):** - `CurriculumSidebar.vue`: Sidebar บทเรียนและสถานะการเรียน - `AnnouncementModal.vue`: Modal แสดงประกาศของคอร์ส - - `VideoPlayer.vue`: Video Player พร้อม Custom Controls + - `VideoPlayer.vue`: Video Player พร้อม Custom Controls และ YouTube Support - **User / Profile (`components/user/`, `components/profile/`):** - `UserAvatar.vue`: แสดงรูปโปรไฟล์ (รองรับ Fallback) - `ProfileEditForm.vue`: ฟอร์มแก้ไขข้อมูลส่วนตัว @@ -94,6 +97,7 @@ - **Classroom:** - `fetchCourseLearningInfo`: โครงสร้างบทเรียน (Chapters/Lessons) - `fetchLessonContent`: เนื้อหาวิดีโอ/Quiz/Attachments + - `fetchCourseAnnouncements`: ดึงข้อมูลประกาศของคอร์ส - `saveVideoProgress`: บันทึกเวลาเรียน (Sync Server) - **i18n Support:** `getLocalizedText` ตัวช่วยในการเลือกแสดงผลภาษา (TH/EN) ตาม Locale ปัจจุบันที่ผู้ใช้เลือก อัตโนมัติทั่วทั้งแอป @@ -164,6 +168,20 @@ - **Hardcoded Removal:** ทยอยลบข้อความภาษาไทยที่พิมพ์ค้างไว้ในโค้ด (เช่น ใน Sidebar หมวดหมู่) และแทนที่ด้วย i18n keys - **Boot Sequence Fix:** แก้ไขปัญหาเว็บค้าง (Error 500) ที่เกิดจากการเรียกใช้ภาษาเร็วเกินไปก่อนที่ระบบจะพร้อม (`initialization error`) -7. **Landing Page & Header Refinement:** - - **Login Button:** อัปเกรดปุ่ม "เข้าสู่ระบบ" จากลิงก์ข้อความธรรมดา ให้เป็นปุ่มแบบ Secondary ที่โดดเด่นและชัดเจนขึ้น เพื่อดึงดูดผู้ใช้งาน - - **Visual Hierarchy:** จัดลำดับความสำคัญของปุ่ม Get Started และ Login ให้สมดุลกันมากขึ้นในโหมดสว่างและโหมดมืด (Scrolled Header) +7. **Classroom & UX Optimization (Mid-February 2026):** + - **SPA Navigation for Learning:** เปลี่ยนระบบเลือกบทเรียนจากการ Reload หน้าเป็น SPA Navigation ทำให้เปลี่ยนวิดีโอ/บทเรียนได้ทันทีโดยไม่ต้อง Refresh หน้าจอ + - **Announcement Persistence:** เพิ่มระบบเช็กสถานะการอ่านประกาศ (Unread Badge) โดยบันทึกสถานะล่าสุดลง LocalStorage แยกตามผู้ใช้และคอร์ส + - **YouTube Resume:** รองรับการเรียนต่อจากจุดเดิมสำหรับวิดีโอ YouTube (Time Seeking via URL parameter) + +8. **Quiz System Enhancements:** + - **Answer Review Mode:** เพิ่มโหมดเฉลยข้อสอบหลังทำเสร็จ พร้อมการไฮไลท์สีที่ชัดเจน (เขียว = ถูก, แดง = ตอบผิด) + - **Shuffle Logic:** เพิ่มการสลับคำถามและตัวเลือก (Shuffle) เพื่อความโปร่งใสในการสอบ + - **Enhanced Feedback:** ปรับปรุง UI ผลลัพธ์การสอบให้มีความ Premium และเข้าใจง่ายขึ้น + +9. **Security & Registration Polish:** + - **Phone Validation:** เพิ่มระบบตรวจสอบเบอร์โทรศัพท์ในหน้าสมัครสมาชิก (ต้องเป็นตัวเลขและยาวไม่เกิน 10 หลัก) + - **Enrollment Alert Logic:** ปรับปรุง Logic การสมัครเรียนให้ตรวจสอบสถานะ Enrollment เดิมก่อน เพื่อป้องกัน API Error และการเรียก request ซ้ำซ้อน + +10. **Profile & Certificates:** + - **Verification Badge:** เพิ่มการแสดงผลสถานะการยืนยันอีเมลในหน้าโปรไฟล์ พร้อมปุ่มส่งอีเมลยืนยันหากยังไม่ได้ทำ + - **Certificate Flow:** ปรับปรุงระบบดาวน์โหลดใบประกาศนียบัตรให้รองรับทั้งการดึงไฟล์เดิมและสั่ง Generate ใหม่หากยังไม่มี From 45941fbe6c04ca2beddf2610a4f5035baac3b0ca Mon Sep 17 00:00:00 2001 From: JakkrapartXD Date: Fri, 13 Feb 2026 14:45:59 +0700 Subject: [PATCH 03/29] feat: Add error audit logging to instructor course operations and implement status filtering for listing courses. --- .../CoursesInstructorController.ts | 8 +- .../src/services/CoursesInstructor.service.ts | 218 +++++++++++++++++- Backend/src/services/audit.service.ts | 30 +-- Backend/src/types/CoursesInstructor.types.ts | 5 + 4 files changed, 242 insertions(+), 19 deletions(-) diff --git a/Backend/src/controllers/CoursesInstructorController.ts b/Backend/src/controllers/CoursesInstructorController.ts index 5b698807..a0f956c7 100644 --- a/Backend/src/controllers/CoursesInstructorController.ts +++ b/Backend/src/controllers/CoursesInstructorController.ts @@ -5,6 +5,7 @@ import { createCourses, createCourseResponse, GetMyCourseResponse, + ListMyCoursesInput, ListMyCourseResponse, addinstructorCourseResponse, removeinstructorCourseResponse, @@ -41,12 +42,15 @@ export class CoursesInstructorController { @SuccessResponse('200', 'Courses retrieved successfully') @Response('401', 'Invalid or expired token') @Response('404', 'Courses not found') - public async listMyCourses(@Request() request: any): Promise { + public async listMyCourses( + @Request() request: any, + @Query() status?: 'DRAFT' | 'PENDING' | 'APPROVED' | 'REJECTED' | 'ARCHIVED' + ): Promise { const token = request.headers.authorization?.replace('Bearer ', ''); if (!token) { throw new ValidationError('No token provided'); } - return await CoursesInstructorService.listMyCourses(token); + return await CoursesInstructorService.listMyCourses({ token, status }); } /** diff --git a/Backend/src/services/CoursesInstructor.service.ts b/Backend/src/services/CoursesInstructor.service.ts index 244e26ac..aeeae850 100644 --- a/Backend/src/services/CoursesInstructor.service.ts +++ b/Backend/src/services/CoursesInstructor.service.ts @@ -10,6 +10,7 @@ import { UpdateCourseInput, createCourseResponse, GetMyCourseResponse, + ListMyCoursesInput, ListMyCourseResponse, addinstructorCourse, addinstructorCourseResponse, @@ -102,16 +103,27 @@ export class CoursesInstructorService { }; } catch (error) { logger.error('Failed to create course', { error }); + await auditService.logSync({ + userId: userId, + action: AuditAction.ERROR, + entityType: 'Course', + entityId: 0, // Failed to create, so no ID + metadata: { + operation: 'create_course', + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } - static async listMyCourses(token: string): Promise { + static async listMyCourses(input: ListMyCoursesInput): Promise { try { - const decoded = jwt.verify(token, config.jwt.secret) as { id: number; type: string }; + const decoded = jwt.verify(input.token, config.jwt.secret) as { id: number; type: string }; const courseInstructors = await prisma.courseInstructor.findMany({ where: { - user_id: decoded.id + user_id: decoded.id, + course: input.status ? { status: input.status } : undefined }, include: { course: true @@ -143,6 +155,17 @@ export class CoursesInstructorService { }; } catch (error) { logger.error('Failed to retrieve courses', { error }); + const decoded = jwt.decode(input.token) as { id: number } | null; + await auditService.logSync({ + userId: decoded?.id || undefined, + action: AuditAction.ERROR, + entityType: 'Course', + entityId: 0, + metadata: { + operation: 'list_my_courses', + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } @@ -200,6 +223,17 @@ export class CoursesInstructorService { }; } catch (error) { logger.error('Failed to retrieve course', { error }); + const decoded = jwt.decode(getmyCourse.token) as { id: number } | null; + await auditService.logSync({ + userId: decoded?.id || undefined, + action: AuditAction.ERROR, + entityType: 'Course', + entityId: getmyCourse.course_id, + metadata: { + operation: 'get_my_course', + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } @@ -222,6 +256,17 @@ export class CoursesInstructorService { }; } catch (error) { logger.error('Failed to update course', { error }); + const decoded = jwt.decode(token) as { id: number } | null; + await auditService.logSync({ + userId: decoded?.id || undefined, + action: AuditAction.ERROR, + entityType: 'Course', + entityId: courseId, + metadata: { + operation: 'update_course', + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } @@ -275,6 +320,17 @@ export class CoursesInstructorService { }; } catch (error) { logger.error('Failed to upload thumbnail', { error }); + const decoded = jwt.decode(token) as { id: number } | null; + await auditService.logSync({ + userId: decoded?.id || undefined, + action: AuditAction.ERROR, + entityType: 'Course', + entityId: courseId, + metadata: { + operation: 'upload_thumbnail', + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } @@ -298,6 +354,17 @@ export class CoursesInstructorService { }; } catch (error) { logger.error('Failed to delete course', { error }); + const decoded = jwt.decode(token) as { id: number } | null; + await auditService.logSync({ + userId: decoded?.id || undefined, + action: AuditAction.ERROR, + entityType: 'Course', + entityId: courseId, + metadata: { + operation: 'delete_course', + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } @@ -325,6 +392,17 @@ export class CoursesInstructorService { }; } catch (error) { logger.error('Failed to send course for review', { error }); + const decoded = jwt.decode(sendCourseForReview.token) as { id: number } | null; + await auditService.logSync({ + userId: decoded?.id || undefined, + action: AuditAction.ERROR, + entityType: 'Course', + entityId: sendCourseForReview.course_id, + metadata: { + operation: 'send_course_for_review', + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } @@ -347,6 +425,17 @@ export class CoursesInstructorService { }; } catch (error) { logger.error('Failed to set course to draft', { error }); + const decoded = jwt.decode(setCourseDraft.token) as { id: number } | null; + await auditService.logSync({ + userId: decoded?.id || undefined, + action: AuditAction.ERROR, + entityType: 'Course', + entityId: setCourseDraft.course_id, + metadata: { + operation: 'set_course_draft', + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } @@ -384,6 +473,17 @@ export class CoursesInstructorService { }; } catch (error) { logger.error('Failed to retrieve course approvals', { error }); + const decoded = jwt.decode(token) as { id: number } | null; + await auditService.logSync({ + userId: decoded?.id || undefined, + action: AuditAction.ERROR, + entityType: 'Course', + entityId: courseId, + metadata: { + operation: 'get_course_approvals', + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } @@ -445,6 +545,17 @@ export class CoursesInstructorService { }; } catch (error) { logger.error('Failed to search instructors', { error }); + const decoded = jwt.decode(input.token) as { id: number } | null; + await auditService.logSync({ + userId: decoded?.id || undefined, + action: AuditAction.ERROR, + entityType: 'Course', + entityId: input.course_id, + metadata: { + operation: 'search_instructors', + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } @@ -496,6 +607,17 @@ export class CoursesInstructorService { }; } 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; } } @@ -517,6 +639,17 @@ export class CoursesInstructorService { }; } catch (error) { logger.error('Failed to remove instructor from course', { error }); + const decoded = jwt.decode(removeinstructorCourse.token) as { id: number } | null; + await auditService.logSync({ + userId: decoded?.id || undefined, + action: AuditAction.ERROR, + entityType: 'Course', + entityId: removeinstructorCourse.course_id, + metadata: { + operation: 'remove_instructor_from_course', + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } @@ -567,6 +700,17 @@ export class CoursesInstructorService { }; } catch (error) { logger.error('Failed to retrieve instructors of course', { error }); + const decoded = jwt.decode(listinstructorCourse.token) as { id: number } | null; + await auditService.logSync({ + userId: decoded?.id || undefined, + action: AuditAction.ERROR, + entityType: 'Course', + entityId: listinstructorCourse.course_id, + metadata: { + operation: 'list_instructors_of_course', + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } @@ -591,6 +735,17 @@ export class CoursesInstructorService { }; } 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; } } @@ -707,6 +862,17 @@ export class CoursesInstructorService { }; } catch (error) { logger.error(`Error getting enrolled students: ${error}`); + const decoded = jwt.decode(input.token) as { id: number } | null; + await auditService.logSync({ + userId: decoded?.id || undefined, + action: AuditAction.ERROR, + entityType: 'Course', + entityId: input.course_id, + metadata: { + operation: 'get_enrolled_students', + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } @@ -758,7 +924,7 @@ export class CoursesInstructorService { // Get all enrolled students who have attempted this quiz const skip = (page - 1) * limit; - + // Get unique users who attempted this quiz const quizAttempts = await prisma.quizAttempt.findMany({ where: { quiz_id: lesson.quiz.id }, @@ -874,6 +1040,17 @@ export class CoursesInstructorService { }; } catch (error) { logger.error(`Error getting quiz scores: ${error}`); + const decoded = jwt.decode(input.token) as { id: number } | null; + await auditService.logSync({ + userId: decoded?.id || undefined, + action: AuditAction.ERROR, + entityType: 'Course', + entityId: input.course_id, + metadata: { + operation: 'get_quiz_scores', + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } @@ -988,6 +1165,17 @@ export class CoursesInstructorService { }; } catch (error) { logger.error(`Error getting quiz attempt detail: ${error}`); + const decoded = jwt.decode(input.token) as { id: number } | null; + await auditService.logSync({ + userId: decoded?.id || undefined, + action: AuditAction.ERROR, + entityType: 'Course', + entityId: input.course_id, + metadata: { + operation: 'get_quiz_attempt_detail', + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } @@ -1125,6 +1313,17 @@ export class CoursesInstructorService { }; } catch (error) { logger.error(`Error getting enrolled student detail: ${error}`); + const decoded = jwt.decode(input.token) as { id: number } | null; + await auditService.logSync({ + userId: decoded?.id || undefined, + action: AuditAction.ERROR, + entityType: 'Course', + entityId: input.course_id, + metadata: { + operation: 'get_enrolled_student_detail', + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } @@ -1181,6 +1380,17 @@ 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; } } diff --git a/Backend/src/services/audit.service.ts b/Backend/src/services/audit.service.ts index e913be7b..52038129 100644 --- a/Backend/src/services/audit.service.ts +++ b/Backend/src/services/audit.service.ts @@ -37,19 +37,23 @@ export class AuditService { * Log พร้อม await (สำหรับ critical actions) */ async logSync(params: CreateAuditLogParams): Promise { - await prisma.auditLog.create({ - data: { - user_id: params.userId, - action: params.action, - entity_type: params.entityType, - entity_id: params.entityId, - old_value: params.oldValue, - new_value: params.newValue, - ip_address: params.ipAddress, - user_agent: params.userAgent, - metadata: params.metadata, - }, - }); + try { + await prisma.auditLog.create({ + data: { + user_id: params.userId, + action: params.action, + entity_type: params.entityType, + entity_id: params.entityId, + old_value: params.oldValue, + new_value: params.newValue, + ip_address: params.ipAddress, + user_agent: params.userAgent, + metadata: params.metadata, + }, + }); + } catch (error) { + logger.error('Failed to create audit log (sync)', { error, params }); + } } /** diff --git a/Backend/src/types/CoursesInstructor.types.ts b/Backend/src/types/CoursesInstructor.types.ts index a10dac44..e31e80fc 100644 --- a/Backend/src/types/CoursesInstructor.types.ts +++ b/Backend/src/types/CoursesInstructor.types.ts @@ -23,6 +23,11 @@ export interface createCourseResponse { data: Course; } +export interface ListMyCoursesInput { + token: string; + status?: 'DRAFT' | 'PENDING' | 'APPROVED' | 'REJECTED' | 'ARCHIVED'; +} + export interface ListMyCourseResponse { code: number; message: string; From af14610442dacf9d1891aa707bbe387b73382a30 Mon Sep 17 00:00:00 2001 From: JakkrapartXD Date: Thu, 12 Feb 2026 17:55:45 +0700 Subject: [PATCH 04/29] feat: Add token-based authorization to category deletion and enhance user registration with error handling and audit logging. --- Backend/prisma/schema.prisma | 2 + .../AdminCourseApprovalController.ts | 20 +- .../src/controllers/CategoriesController.ts | 2 +- .../RecommendedCoursesController.ts | 18 +- .../services/AdminCourseApproval.service.ts | 76 +++- .../src/services/ChaptersLesson.service.ts | 198 +++++++++++ .../src/services/CoursesInstructor.service.ts | 60 +++- .../src/services/CoursesStudent.service.ts | 157 +++++++-- .../services/RecommendedCourses.service.ts | 41 ++- Backend/src/services/announcements.service.ts | 66 +++- Backend/src/services/auth.service.ts | 326 ++++++++++-------- Backend/src/services/categories.service.ts | 56 ++- Backend/src/services/certificate.service.ts | 57 ++- Backend/src/services/courses.service.ts | 22 ++ Backend/src/services/user.service.ts | 77 +++++ .../src/services/usermanagement.service.ts | 61 ++++ 16 files changed, 1003 insertions(+), 236 deletions(-) diff --git a/Backend/prisma/schema.prisma b/Backend/prisma/schema.prisma index 212b3bf3..bc4b2267 100644 --- a/Backend/prisma/schema.prisma +++ b/Backend/prisma/schema.prisma @@ -634,6 +634,8 @@ enum AuditAction { VERIFY_EMAIL DEACTIVATE_USER ACTIVATE_USER + ERROR + WARNING } model AuditLog { diff --git a/Backend/src/controllers/AdminCourseApprovalController.ts b/Backend/src/controllers/AdminCourseApprovalController.ts index 0ad0171a..dac45be4 100644 --- a/Backend/src/controllers/AdminCourseApprovalController.ts +++ b/Backend/src/controllers/AdminCourseApprovalController.ts @@ -25,10 +25,8 @@ export class AdminCourseApprovalController { @Response('403', 'Forbidden - Admin only') public async listPendingCourses(@Request() request: any): Promise { const token = request.headers.authorization?.replace('Bearer ', ''); - if (!token) { - throw new ValidationError('No token provided'); - } - return await AdminCourseApprovalService.listPendingCourses(); + if (!token) throw new ValidationError('No token provided'); + return await AdminCourseApprovalService.listPendingCourses(token); } /** @@ -44,10 +42,8 @@ export class AdminCourseApprovalController { @Response('404', 'Course not found') public async getCourseDetail(@Request() request: any, @Path() courseId: number): Promise { const token = request.headers.authorization?.replace('Bearer ', ''); - if (!token) { - throw new ValidationError('No token provided'); - } - return await AdminCourseApprovalService.getCourseDetail(courseId); + if (!token) throw new ValidationError('No token provided'); + return await AdminCourseApprovalService.getCourseDetail(token, courseId); } /** @@ -68,9 +64,7 @@ export class AdminCourseApprovalController { @Body() body?: ApproveCourseBody ): 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 AdminCourseApprovalService.approveCourse(token, courseId, body?.comment); } @@ -92,9 +86,7 @@ export class AdminCourseApprovalController { @Body() body: RejectCourseBody ): Promise { const token = request.headers.authorization?.replace('Bearer ', ''); - if (!token) { - throw new ValidationError('No token provided'); - } + if (!token) throw new ValidationError('No token provided'); return await AdminCourseApprovalService.rejectCourse(token, courseId, body.comment); } } diff --git a/Backend/src/controllers/CategoriesController.ts b/Backend/src/controllers/CategoriesController.ts index 09a5a621..3c99a3b5 100644 --- a/Backend/src/controllers/CategoriesController.ts +++ b/Backend/src/controllers/CategoriesController.ts @@ -45,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(id); + return await this.categoryService.deleteCategory(token,id); } } \ No newline at end of file diff --git a/Backend/src/controllers/RecommendedCoursesController.ts b/Backend/src/controllers/RecommendedCoursesController.ts index 7e770c12..06bff36d 100644 --- a/Backend/src/controllers/RecommendedCoursesController.ts +++ b/Backend/src/controllers/RecommendedCoursesController.ts @@ -22,10 +22,8 @@ export class RecommendedCoursesController { @Response('403', 'Forbidden - Admin only') 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(); + if (!token) throw new ValidationError('No token provided'); + return await RecommendedCoursesService.listApprovedCourses(token); } /** @@ -42,10 +40,8 @@ export class RecommendedCoursesController { @Response('404', 'Course not found') public async getCourseById(@Request() request: any, @Path() courseId: number): Promise { const token = request.headers.authorization?.replace('Bearer ', ''); - if (!token) { - throw new ValidationError('No token provided'); - } - return await RecommendedCoursesService.getCourseById(courseId); + if (!token) throw new ValidationError('No token provided'); + return await RecommendedCoursesService.getCourseById(token, courseId); } /** @@ -62,13 +58,11 @@ export class RecommendedCoursesController { @Response('404', 'Course not found') public async toggleRecommended( @Request() request: any, - @Path() courseId: number, + @Path() courseId: number, @Query() is_recommended: boolean ): Promise { const token = request.headers.authorization?.replace('Bearer ', ''); - if (!token) { - throw new ValidationError('No token provided'); - } + if (!token) throw new ValidationError('No token provided'); return await RecommendedCoursesService.toggleRecommended(token, courseId, is_recommended); } } diff --git a/Backend/src/services/AdminCourseApproval.service.ts b/Backend/src/services/AdminCourseApproval.service.ts index 5e9da6ea..f9446457 100644 --- a/Backend/src/services/AdminCourseApproval.service.ts +++ b/Backend/src/services/AdminCourseApproval.service.ts @@ -18,7 +18,7 @@ export class AdminCourseApprovalService { /** * Get all pending courses for admin review */ - static async listPendingCourses(): Promise { + static async listPendingCourses(token: string): Promise { try { const courses = await prisma.course.findMany({ where: { status: 'PENDING' }, @@ -68,18 +68,18 @@ export class AdminCourseApprovalService { description: course.description as { th: string; en: string }, thumbnail_url: thumbnail_presigned_url, status: course.status, - created_at: course.created_at, - updated_at: course.updated_at, - created_by: course.created_by, - creator: course.creator, - instructors: course.instructors.map(i => ({ - user_id: i.user_id, - is_primary: i.is_primary, - user: i.user - })), - chapters_count: course.chapters.length, - lessons_count: course.chapters.reduce((sum, ch) => sum + ch.lessons.length, 0), - latest_submission: course.courseApprovals[0] ? { + created_at: course.created_at, + updated_at: course.updated_at, + created_by: course.created_by, + creator: course.creator, + instructors: course.instructors.map(i => ({ + user_id: i.user_id, + is_primary: i.is_primary, + user: i.user + })), + chapters_count: course.chapters.length, + lessons_count: course.chapters.reduce((sum, ch) => sum + ch.lessons.length, 0), + latest_submission: course.courseApprovals[0] ? { id: course.courseApprovals[0].id, submitted_by: course.courseApprovals[0].submitted_by, created_at: course.courseApprovals[0].created_at, @@ -96,6 +96,16 @@ export class AdminCourseApprovalService { }; } catch (error) { logger.error('Failed to list pending courses', { error }); + const decoded = jwt.decode(token) as { id: number } | null; + await auditService.logSync({ + userId: decoded?.id || 0, + action: AuditAction.ERROR, + entityType: 'Course', + entityId: 0, + metadata: { + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } @@ -103,7 +113,7 @@ export class AdminCourseApprovalService { /** * Get course details for admin review */ - static async getCourseDetail(courseId: number): Promise { + static async getCourseDetail(token: string,courseId: number): Promise { try { const course = await prisma.course.findUnique({ where: { id: courseId }, @@ -214,6 +224,16 @@ export class AdminCourseApprovalService { }; } catch (error) { logger.error('Failed to get course detail', { error }); + const decoded = jwt.decode(token) as { id: number } | null; + await auditService.logSync({ + userId: decoded?.id || 0, + action: AuditAction.ERROR, + entityType: 'Course', + entityId: courseId, + metadata: { + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } @@ -238,7 +258,7 @@ export class AdminCourseApprovalService { // Update course status prisma.course.update({ where: { id: courseId }, - data: { + data: { status: 'APPROVED', approved_by: decoded.id, approved_at: new Date() @@ -275,6 +295,17 @@ export class AdminCourseApprovalService { }; } catch (error) { logger.error('Failed to approve course', { error }); + const decoded = jwt.decode(token) as { id: number } | null; + await auditService.logSync({ + userId: decoded?.id || 0, + action: AuditAction.ERROR, + entityType: 'Course', + entityId: courseId, + metadata: { + operation: 'approve_course', + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } @@ -303,12 +334,12 @@ export class AdminCourseApprovalService { // Update course status back to REJECTED prisma.course.update({ where: { id: courseId }, - data: { + data: { status: 'REJECTED', rejection_reason: comment, approved_by: null, approved_at: null - } + } }), // Create rejection record prisma.courseApproval.create({ @@ -341,6 +372,17 @@ export class AdminCourseApprovalService { }; } catch (error) { logger.error('Failed to reject course', { error }); + const decoded = jwt.decode(token) as { id: number } | null; + await auditService.logSync({ + userId: decoded?.id || 0, + action: AuditAction.ERROR, + entityType: 'Course', + entityId: courseId, + metadata: { + operation: 'reject_course', + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } diff --git a/Backend/src/services/ChaptersLesson.service.ts b/Backend/src/services/ChaptersLesson.service.ts index 02ce0a67..003670b1 100644 --- a/Backend/src/services/ChaptersLesson.service.ts +++ b/Backend/src/services/ChaptersLesson.service.ts @@ -142,6 +142,17 @@ export class ChaptersLessonService { return { code: 200, message: 'Chapter created successfully', data: chapter as ChapterData }; } catch (error) { logger.error(`Error creating chapter: ${error}`); + const decodedToken = jwt.decode(request.token) as { id: number } | null; + await auditService.logSync({ + userId: decodedToken?.id || 0, + action: AuditAction.ERROR, + entityType: 'Chapter', + entityId: 0, + metadata: { + operation: 'create_chapter', + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } @@ -163,6 +174,17 @@ export class ChaptersLessonService { return { code: 200, message: 'Chapter updated successfully', data: chapter as ChapterData }; } catch (error) { logger.error(`Error updating chapter: ${error}`); + const decodedToken = jwt.decode(request.token) as { id: number } | null; + await auditService.logSync({ + userId: decodedToken?.id || 0, + action: AuditAction.ERROR, + entityType: 'Chapter', + entityId: request.chapter_id, + metadata: { + operation: 'update_chapter', + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } @@ -197,6 +219,17 @@ export class ChaptersLessonService { return { code: 200, message: 'Chapter deleted successfully' }; } catch (error) { logger.error(`Error deleting chapter: ${error}`); + const decodedToken = jwt.decode(request.token) as { id: number } | null; + await auditService.logSync({ + userId: decodedToken?.id || 0, + action: AuditAction.ERROR, + entityType: 'Chapter', + entityId: request.chapter_id, + metadata: { + operation: 'delete_chapter', + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } @@ -280,6 +313,17 @@ export class ChaptersLessonService { return { code: 200, message: 'Chapter reordered successfully', data: chapters as ChapterData[] }; } catch (error) { logger.error(`Error reordering chapter: ${error}`); + const decodedToken = jwt.decode(request.token) as { id: number } | null; + await auditService.logSync({ + userId: decodedToken?.id || 0, + action: AuditAction.ERROR, + entityType: 'Chapter', + entityId: request.chapter_id, + metadata: { + operation: 'reorder_chapter', + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } @@ -354,6 +398,17 @@ export class ChaptersLessonService { return { code: 200, message: 'Lesson created successfully', data: lesson as LessonData }; } catch (error) { logger.error(`Error creating lesson: ${error}`); + const decodedToken = jwt.decode(request.token) as { id: number } | null; + await auditService.logSync({ + userId: decodedToken?.id || 0, + action: AuditAction.ERROR, + entityType: 'Lesson', + entityId: 0, + metadata: { + operation: 'create_lesson', + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } @@ -494,6 +549,17 @@ export class ChaptersLessonService { return { code: 200, message: 'Lesson fetched successfully', data: lessonData as LessonData }; } catch (error) { logger.error(`Error fetching lesson: ${error}`); + const decodedToken = jwt.decode(request.token) as { id: number } | null; + await auditService.logSync({ + userId: decodedToken?.id || 0, + action: AuditAction.ERROR, + entityType: 'Lesson', + entityId: request.lesson_id, + metadata: { + operation: 'get_lesson', + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } @@ -515,6 +581,17 @@ export class ChaptersLessonService { return { code: 200, message: 'Lesson updated successfully', data: lesson as LessonData }; } catch (error) { logger.error(`Error updating lesson: ${error}`); + const decodedToken = jwt.decode(request.token) as { id: number } | null; + await auditService.logSync({ + userId: decodedToken?.id || 0, + action: AuditAction.ERROR, + entityType: 'Lesson', + entityId: request.lesson_id, + metadata: { + operation: 'update_lesson', + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } @@ -605,6 +682,17 @@ export class ChaptersLessonService { return { code: 200, message: 'Lessons reordered successfully', data: lessons as LessonData[] }; } catch (error) { logger.error(`Error reordering lessons: ${error}`); + const decodedToken = jwt.decode(request.token) as { id: number } | null; + await auditService.logSync({ + userId: decodedToken?.id || 0, + action: AuditAction.ERROR, + entityType: 'Lesson', + entityId: request.lesson_id, + metadata: { + operation: 'reorder_lessons', + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } @@ -676,6 +764,17 @@ export class ChaptersLessonService { return { code: 200, message: 'Lesson deleted successfully' }; } catch (error) { logger.error(`Error deleting lesson: ${error}`); + const decodedToken = jwt.decode(request.token) as { id: number } | null; + await auditService.logSync({ + userId: decodedToken?.id || 0, + action: AuditAction.ERROR, + entityType: 'Lesson', + entityId: request.lesson_id, + metadata: { + operation: 'delete_lesson', + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } @@ -754,6 +853,17 @@ export class ChaptersLessonService { }; } catch (error) { logger.error(`Error uploading video: ${error}`); + const decodedToken = jwt.decode(request.token) as { id: number } | null; + await auditService.logSync({ + userId: decodedToken?.id || 0, + action: AuditAction.ERROR, + entityType: 'Lesson', + entityId: request.lesson_id, + metadata: { + operation: 'upload_video', + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } @@ -836,6 +946,17 @@ export class ChaptersLessonService { }; } catch (error) { logger.error(`Error updating video: ${error}`); + const decodedToken = jwt.decode(request.token) as { id: number } | null; + await auditService.logSync({ + userId: decodedToken?.id || 0, + action: AuditAction.ERROR, + entityType: 'Lesson', + entityId: request.lesson_id, + metadata: { + operation: 'update_video', + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } @@ -917,6 +1038,17 @@ export class ChaptersLessonService { }; } catch (error) { logger.error(`Error setting YouTube video: ${error}`); + const decodedToken = jwt.decode(request.token) as { id: number } | null; + await auditService.logSync({ + userId: decodedToken?.id || 0, + action: AuditAction.ERROR, + entityType: 'Lesson', + entityId: request.lesson_id, + metadata: { + operation: 'set_youtube_video', + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } @@ -993,6 +1125,17 @@ export class ChaptersLessonService { }; } catch (error) { logger.error(`Error uploading attachment: ${error}`); + const decodedToken = jwt.decode(request.token) as { id: number } | null; + await auditService.logSync({ + userId: decodedToken?.id || 0, + action: AuditAction.ERROR, + entityType: 'LessonAttachment', + entityId: request.lesson_id, + metadata: { + operation: 'upload_attachment', + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } @@ -1051,6 +1194,17 @@ export class ChaptersLessonService { return { code: 200, message: 'Attachment deleted successfully' }; } catch (error) { logger.error(`Error deleting attachment: ${error}`); + const decodedToken = jwt.decode(request.token) as { id: number } | null; + await auditService.logSync({ + userId: decodedToken?.id || 0, + action: AuditAction.ERROR, + entityType: 'LessonAttachment', + entityId: request.attachment_id, + metadata: { + operation: 'delete_attachment', + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } @@ -1127,6 +1281,17 @@ export class ChaptersLessonService { return { code: 200, message: 'Question added successfully', data: completeQuestion as QuizQuestionData }; } catch (error) { logger.error(`Error adding question: ${error}`); + const decodedToken = jwt.decode(request.token) as { id: number } | null; + await auditService.logSync({ + userId: decodedToken?.id || 0, + action: AuditAction.ERROR, + entityType: 'Question', + entityId: 0, + metadata: { + operation: 'add_question', + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } @@ -1202,6 +1367,17 @@ export class ChaptersLessonService { return { code: 200, message: 'Question updated successfully', data: completeQuestion as QuizQuestionData }; } catch (error) { logger.error(`Error updating question: ${error}`); + const decodedToken = jwt.decode(request.token) as { id: number } | null; + await auditService.logSync({ + userId: decodedToken?.id || 0, + action: AuditAction.ERROR, + entityType: 'Question', + entityId: request.question_id, + metadata: { + operation: 'update_question', + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } @@ -1295,6 +1471,17 @@ export class ChaptersLessonService { return { code: 200, message: 'Question reordered successfully', data: questions as QuizQuestionData[] }; } catch (error) { logger.error(`Error reordering question: ${error}`); + const decodedToken = jwt.decode(request.token) as { id: number } | null; + await auditService.logSync({ + userId: decodedToken?.id || 0, + action: AuditAction.ERROR, + entityType: 'Question', + entityId: request.question_id, + metadata: { + operation: 'reorder_question', + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } @@ -1343,6 +1530,17 @@ export class ChaptersLessonService { return { code: 200, message: 'Question deleted successfully' }; } catch (error) { logger.error(`Error deleting question: ${error}`); + const decodedToken = jwt.decode(request.token) as { id: number } | null; + await auditService.logSync({ + userId: decodedToken?.id || 0, + action: AuditAction.ERROR, + entityType: 'Question', + entityId: request.question_id, + metadata: { + operation: 'delete_question', + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } diff --git a/Backend/src/services/CoursesInstructor.service.ts b/Backend/src/services/CoursesInstructor.service.ts index aeeae850..a2530033 100644 --- a/Backend/src/services/CoursesInstructor.service.ts +++ b/Backend/src/services/CoursesInstructor.service.ts @@ -347,6 +347,15 @@ export class CoursesInstructorService { id: courseId } }); + await auditService.logSync({ + userId: courseInstructorId.user_id, + action: AuditAction.DELETE, + entityType: 'Course', + entityId: courseId, + metadata: { + operation: 'delete_course' + } + }); return { code: 200, message: 'Course deleted successfully', @@ -386,6 +395,15 @@ 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', @@ -447,8 +465,6 @@ export class CoursesInstructorService { total: number; }> { try { - const decoded = jwt.verify(token, config.jwt.secret) as { id: number; type: string }; - // Validate instructor access await this.validateCourseInstructor(token, courseId); @@ -601,6 +617,18 @@ 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', @@ -633,6 +661,19 @@ 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', @@ -729,6 +770,19 @@ 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', @@ -784,7 +838,6 @@ export class CoursesInstructorService { static async getEnrolledStudents(input: GetEnrolledStudentsInput): Promise { try { const { token, course_id, page = 1, limit = 20, search, status } = input; - const decoded = jwt.verify(token, config.jwt.secret) as { id: number }; // Validate instructor await this.validateCourseInstructor(token, course_id); @@ -1062,7 +1115,6 @@ export class CoursesInstructorService { static async getQuizAttemptDetail(input: GetQuizAttemptDetailInput): Promise { try { const { token, course_id, lesson_id, student_id } = input; - const decoded = jwt.verify(token, config.jwt.secret) as { id: number }; // Validate instructor await this.validateCourseInstructor(token, course_id); diff --git a/Backend/src/services/CoursesStudent.service.ts b/Backend/src/services/CoursesStudent.service.ts index 87679ab3..0e9e5b86 100644 --- a/Backend/src/services/CoursesStudent.service.ts +++ b/Backend/src/services/CoursesStudent.service.ts @@ -186,7 +186,20 @@ export class CoursesStudentService { }, }; } catch (error) { - logger.error(error); + logger.error(`Error enrolling in course: ${error}`); + const decoded = jwt.decode(input.token) as { id: number } | null; + await auditService.logSync({ + userId: decoded?.id || 0, + action: AuditAction.ERROR, + entityType: 'Enrollment', + entityId: 0, + metadata: { + operation: 'enroll_course', + course_id: input.course_id, + error: error instanceof Error ? error.message : String(error) + } + }); + throw error; } } @@ -261,6 +274,17 @@ export class CoursesStudentService { }; } catch (error) { logger.error(error); + const decoded = jwt.decode(input.token) as { id: number } | null; + await auditService.logSync({ + userId: decoded?.id || 0, + action: AuditAction.ERROR, + entityType: 'Enrollment', + entityId: 0, + metadata: { + operation: 'get_enrolled_courses', + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } @@ -416,6 +440,17 @@ export class CoursesStudentService { }; } catch (error) { logger.error(error); + const decoded = jwt.decode(input.token) as { id: number } | null; + await auditService.logSync({ + userId: decoded?.id || 0, + action: AuditAction.ERROR, + entityType: 'Enrollment', + entityId: 0, + metadata: { + operation: 'get_course_learning', + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } @@ -678,6 +713,17 @@ export class CoursesStudentService { }; } catch (error) { logger.error(error); + const decoded = jwt.decode(input.token) as { id: number } | null; + await auditService.logSync({ + userId: decoded?.id || 0, + action: AuditAction.ERROR, + entityType: 'Enrollment', + entityId: 0, + metadata: { + operation: 'get_course_learning', + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } @@ -866,6 +912,17 @@ export class CoursesStudentService { }; } catch (error) { logger.error(error); + const decoded = jwt.decode(input.token) as { id: number } | null; + await auditService.logSync({ + userId: decoded?.id || 0, + action: AuditAction.ERROR, + entityType: 'Enrollment', + entityId: 0, + metadata: { + operation: 'get_course_learning', + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } @@ -940,6 +997,17 @@ export class CoursesStudentService { }; } catch (error) { logger.error(error); + const decoded = jwt.decode(input.token) as { id: number } | null; + await auditService.logSync({ + userId: decoded?.id || 0, + action: AuditAction.ERROR, + entityType: 'Enrollment', + entityId: 0, + metadata: { + operation: 'get_course_learning', + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } @@ -1037,6 +1105,17 @@ export class CoursesStudentService { }; } catch (error) { logger.error(error); + const decoded = jwt.decode(input.token) as { id: number } | null; + await auditService.logSync({ + userId: decoded?.id || 0, + action: AuditAction.ERROR, + entityType: 'Enrollment', + entityId: 0, + metadata: { + operation: 'get_course_learning', + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } @@ -1168,7 +1247,19 @@ export class CoursesStudentService { }, }; } catch (error) { - logger.error(error); + logger.error(`Error completing lesson: ${error}`); + const decoded = jwt.decode(input.token) as { id: number } | null; + await auditService.logSync({ + userId: decoded?.id || 0, + action: AuditAction.ERROR, + entityType: 'LessonProgress', + entityId: input.lesson_id, + metadata: { + operation: 'complete_lesson', + lesson_id: input.lesson_id, + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } @@ -1213,22 +1304,14 @@ export class CoursesStudentService { }, }); - if (!lesson) { - throw new NotFoundError('Lesson not found'); - } + if (!lesson) throw new NotFoundError('Lesson not found'); - if (lesson.type !== 'QUIZ') { - throw new ValidationError('This lesson is not a quiz'); - } + if (lesson.type !== 'QUIZ') throw new ValidationError('This lesson is not a quiz'); - if (!lesson.quiz) { - throw new NotFoundError('Quiz not found for this lesson'); - } + if (!lesson.quiz) throw new NotFoundError('Quiz not found for this lesson'); // Verify lesson belongs to the course - if (lesson.chapter.course_id !== course_id) { - throw new NotFoundError('Lesson not found in this course'); - } + if (lesson.chapter.course_id !== course_id) throw new NotFoundError('Lesson not found in this course'); const quiz = lesson.quiz; @@ -1332,7 +1415,20 @@ export class CoursesStudentService { }, }; } catch (error) { - logger.error(error); + logger.error(`Error submitting quiz: ${error}`); + const decoded = jwt.decode(input.token) as { id: number } | null; + await auditService.logSync({ + userId: decoded?.id || 0, + action: AuditAction.ERROR, + entityType: 'QuizAttempt', + entityId: 0, + metadata: { + operation: 'submit_quiz', + course_id: input.course_id, + lesson_id: input.lesson_id, + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } @@ -1373,22 +1469,14 @@ export class CoursesStudentService { }, }); - if (!lesson) { - throw new NotFoundError('Lesson not found'); - } + if (!lesson) throw new NotFoundError('Lesson not found'); - if (lesson.type !== 'QUIZ') { - throw new ValidationError('This lesson is not a quiz'); - } + if (lesson.type !== 'QUIZ') throw new ValidationError('This lesson is not a quiz'); - if (!lesson.quiz) { - throw new NotFoundError('Quiz not found for this lesson'); - } + if (!lesson.quiz) throw new NotFoundError('Quiz not found for this lesson'); // Verify lesson belongs to the course - if (lesson.chapter.course_id !== course_id) { - throw new NotFoundError('Lesson not found in this course'); - } + if (lesson.chapter.course_id !== course_id) throw new NotFoundError('Lesson not found in this course'); // Get all quiz attempts for this user const attempts = await prisma.quizAttempt.findMany({ @@ -1438,6 +1526,21 @@ export class CoursesStudentService { }; } catch (error) { logger.error(error); + const decoded = jwt.decode(input.token) as { id: number } | null; + if (decoded?.id) { + await auditService.logSync({ + userId: decoded.id, + action: AuditAction.ERROR, + entityType: 'QuizAttempt', + entityId: 0, + metadata: { + operation: 'get_quiz_attempts', + course_id: input.course_id, + lesson_id: input.lesson_id, + error: error instanceof Error ? error.message : String(error) + } + }); + } throw error; } } diff --git a/Backend/src/services/RecommendedCourses.service.ts b/Backend/src/services/RecommendedCourses.service.ts index b5c977c5..9c185c8a 100644 --- a/Backend/src/services/RecommendedCourses.service.ts +++ b/Backend/src/services/RecommendedCourses.service.ts @@ -18,7 +18,7 @@ export class RecommendedCoursesService { /** * List all approved courses (for admin to manage recommendations) */ - static async listApprovedCourses(): Promise { + static async listApprovedCourses(token: string): Promise { try { const courses = await prisma.course.findMany({ where: { status: 'APPROVED' }, @@ -94,6 +94,19 @@ export class RecommendedCoursesService { }; } catch (error) { logger.error('Failed to list approved courses', { error }); + const decoded = jwt.decode(token) as { id: number } | null; + if (decoded?.id) { + await auditService.logSync({ + userId: decoded.id, + action: AuditAction.ERROR, + entityType: 'RecommendedCourses', + entityId: 0, + metadata: { + operation: 'list_approved_courses', + error: error instanceof Error ? error.message : String(error) + } + }); + } throw error; } } @@ -101,7 +114,7 @@ export class RecommendedCoursesService { /** * Get course by ID (for admin to view details) */ - static async getCourseById(courseId: number): Promise { + static async getCourseById(token: string, courseId: number): Promise { try { const course = await prisma.course.findUnique({ where: { id: courseId }, @@ -179,6 +192,19 @@ export class RecommendedCoursesService { }; } catch (error) { logger.error('Failed to get course by ID', { error }); + const decoded = jwt.decode(token) as { id: number } | null; + if (decoded?.id) { + await auditService.logSync({ + userId: decoded.id, + action: AuditAction.ERROR, + entityType: 'RecommendedCourses', + entityId: 0, + metadata: { + operation: 'get_course_by_id', + error: error instanceof Error ? error.message : String(error) + } + }); + } throw error; } } @@ -229,6 +255,17 @@ export class RecommendedCoursesService { }; } catch (error) { logger.error('Failed to toggle recommended status', { error }); + const decoded = jwt.decode(token) as { id: number } | null; + await auditService.logSync({ + userId: decoded?.id || 0, + action: AuditAction.ERROR, + entityType: 'RecommendedCourses', + entityId: courseId, + metadata: { + operation: 'toggle_recommended', + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } diff --git a/Backend/src/services/announcements.service.ts b/Backend/src/services/announcements.service.ts index eb26b3d1..7e8b2d3e 100644 --- a/Backend/src/services/announcements.service.ts +++ b/Backend/src/services/announcements.service.ts @@ -20,6 +20,8 @@ import { } from '../types/announcements.types'; import { CoursesInstructorService } from './CoursesInstructor.service'; import { uploadFile, deleteFile, getPresignedUrl } from '../config/minio'; +import { auditService } from './audit.service'; +import { AuditAction } from '@prisma/client'; export class AnnouncementsService { @@ -37,9 +39,7 @@ export class AnnouncementsService { where: { id: decoded.id }, include: { role: true }, }); - if (!user) { - throw new UnauthorizedError('Invalid token'); - } + if (!user) throw new UnauthorizedError('Invalid token'); // Admin can access all courses const isAdmin = user.role.code === 'ADMIN'; @@ -130,6 +130,16 @@ export class AnnouncementsService { }; } catch (error) { logger.error(`Error listing announcements: ${error}`); + const decoded = jwt.decode(input.token) as { id: number } | null; + await auditService.logSync({ + userId: decoded?.id || 0, + action: AuditAction.ERROR, + entityType: 'Announcement', + entityId: 0, + metadata: { + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } @@ -226,6 +236,16 @@ export class AnnouncementsService { }; } catch (error) { logger.error(`Error creating announcement: ${error}`); + const decoded = jwt.decode(input.token) as { id: number } | null; + await auditService.logSync({ + userId: decoded?.id || 0, + action: AuditAction.ERROR, + entityType: 'Announcement', + entityId: 0, + metadata: { + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } @@ -300,6 +320,16 @@ export class AnnouncementsService { }; } catch (error) { logger.error(`Error updating announcement: ${error}`); + const decoded = jwt.decode(input.token) as { id: number } | null; + await auditService.logSync({ + userId: decoded?.id || 0, + action: AuditAction.ERROR, + entityType: 'Announcement', + entityId: 0, + metadata: { + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } @@ -346,6 +376,16 @@ export class AnnouncementsService { }; } catch (error) { logger.error(`Error deleting announcement: ${error}`); + const decoded = jwt.decode(input.token) as { id: number } | null; + await auditService.logSync({ + userId: decoded?.id || 0, + action: AuditAction.ERROR, + entityType: 'Announcement', + entityId: 0, + metadata: { + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } @@ -411,6 +451,16 @@ export class AnnouncementsService { }; } catch (error) { logger.error(`Error uploading attachment: ${error}`); + const decoded = jwt.decode(input.token) as { id: number } | null; + await auditService.logSync({ + userId: decoded?.id || 0, + action: AuditAction.ERROR, + entityType: 'Announcement', + entityId: 0, + metadata: { + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } @@ -458,6 +508,16 @@ export class AnnouncementsService { }; } catch (error) { logger.error(`Error deleting attachment: ${error}`); + const decoded = jwt.decode(input.token) as { id: number } | null; + await auditService.logSync({ + userId: decoded?.id || 0, + action: AuditAction.ERROR, + entityType: 'Announcement', + entityId: 0, + metadata: { + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } diff --git a/Backend/src/services/auth.service.ts b/Backend/src/services/auth.service.ts index ad4bcbbc..66899bed 100644 --- a/Backend/src/services/auth.service.ts +++ b/Backend/src/services/auth.service.ts @@ -83,167 +83,201 @@ export class AuthService { * User registration */ async register(data: RegisterRequest): Promise { - const { username, email, password, first_name, last_name, prefix, phone } = data; + try { + const { username, email, password, first_name, last_name, prefix, phone } = data; - // Check if username already exists - const existingUsername = await prisma.user.findUnique({ - where: { username } - }); + // Check if username already exists + const existingUsername = await prisma.user.findUnique({ + where: { username } + }); - if (existingUsername) { - throw new ValidationError('Username already exists'); - } - - // Check if email already exists - const existingEmail = await prisma.user.findUnique({ - where: { email } - }); - - if (existingEmail) { - throw new ValidationError('Email already exists'); - } - - // Check if phone number already exists in user profiles - const existingPhone = await prisma.userProfile.findFirst({ - where: { phone } - }); - - if (existingPhone) { - throw new ValidationError('Phone number already exists'); - } - - // Get STUDENT role - const studentRole = await prisma.role.findUnique({ - where: { code: 'STUDENT' } - }); - - if (!studentRole) { - logger.error('STUDENT role not found in database'); - throw new Error('System configuration error'); - } - - // Hash password - const hashedPassword = await bcrypt.hash(password, 10); - - // Create user with profile - const user = await prisma.user.create({ - data: { - username, - email, - password: hashedPassword, - role_id: studentRole.id, - profile: { - create: { - prefix: prefix ? prefix as Prisma.InputJsonValue : Prisma.JsonNull, - first_name, - last_name, - phone - } - } - }, - include: { - role: true, - profile: true + if (existingUsername) { + throw new ValidationError('Username already exists'); } - }); - logger.info('New user registered', { userId: user.id, username: user.username }); + // Check if email already exists + const existingEmail = await prisma.user.findUnique({ + where: { email } + }); - // Audit log - REGISTER (Student) - auditService.log({ - userId: user.id, - action: AuditAction.CREATE, - entityType: 'User', - entityId: user.id, - newValue: { username: user.username, email: user.email, role: 'STUDENT' } - }); + if (existingEmail) { + throw new ValidationError('Email already exists'); + } - return { - user: this.formatUserResponseSync(user), - message: 'Registration successful' - }; + // Check if phone number already exists in user profiles + const existingPhone = await prisma.userProfile.findFirst({ + where: { phone } + }); + + if (existingPhone) { + throw new ValidationError('Phone number already exists'); + } + + // Get STUDENT role + const studentRole = await prisma.role.findUnique({ + where: { code: 'STUDENT' } + }); + + if (!studentRole) { + logger.error('STUDENT role not found in database'); + throw new Error('System configuration error'); + } + + // Hash password + const hashedPassword = await bcrypt.hash(password, 10); + + // Create user with profile + const user = await prisma.user.create({ + data: { + username, + email, + password: hashedPassword, + role_id: studentRole.id, + profile: { + create: { + prefix: prefix ? prefix as Prisma.InputJsonValue : Prisma.JsonNull, + first_name, + last_name, + phone + } + } + }, + include: { + role: true, + profile: true + } + }); + + logger.info('New user registered', { userId: user.id, username: user.username }); + + // Audit log - REGISTER (Student) + auditService.log({ + userId: user.id, + action: AuditAction.CREATE, + entityType: 'User', + entityId: user.id, + newValue: { username: user.username, email: user.email, role: 'STUDENT' } + }); + + return { + user: this.formatUserResponseSync(user), + message: 'Registration successful' + }; + } catch (error) { + logger.error('Failed to register user', { error }); + await auditService.logSync({ + userId: 0, + action: AuditAction.ERROR, + entityType: 'User', + entityId: 0, + metadata: { + operation: 'register_user', + email: data.email, + username: data.username, + error: error instanceof Error ? error.message : String(error) + } + }); + throw error; + } } async registerInstructor(data: RegisterRequest): Promise { - const { username, email, password, first_name, last_name, prefix, phone } = data; + try { + const { username, email, password, first_name, last_name, prefix, phone } = data; - // Check if username already exists - const existingUsername = await prisma.user.findUnique({ - where: { username } - }); + // Check if username already exists + const existingUsername = await prisma.user.findUnique({ + where: { username } + }); - if (existingUsername) { - throw new ValidationError('Username already exists'); - } - - // Check if email already exists - const existingEmail = await prisma.user.findUnique({ - where: { email } - }); - - if (existingEmail) { - throw new ValidationError('Email already exists'); - } - - // Check if phone number already exists in user profiles - const existingPhone = await prisma.userProfile.findFirst({ - where: { phone } - }); - - if (existingPhone) { - throw new ValidationError('Phone number already exists'); - } - - // Get INSTRUCTOR role - const instructorRole = await prisma.role.findUnique({ - where: { code: 'INSTRUCTOR' } - }); - - if (!instructorRole) { - logger.error('INSTRUCTOR role not found in database'); - throw new Error('System configuration error'); - } - - // Hash password - const hashedPassword = await bcrypt.hash(password, 10); - - // Create user with profile - const user = await prisma.user.create({ - data: { - username, - email, - password: hashedPassword, - role_id: instructorRole.id, - profile: { - create: { - prefix: prefix ? prefix as Prisma.InputJsonValue : Prisma.JsonNull, - first_name, - last_name, - phone - } - } - }, - include: { - role: true, - profile: true + if (existingUsername) { + throw new ValidationError('Username already exists'); } - }); - logger.info('New user registered', { userId: user.id, username: user.username }); + // Check if email already exists + const existingEmail = await prisma.user.findUnique({ + where: { email } + }); - // Audit log - REGISTER (Instructor) - auditService.log({ - userId: user.id, - action: AuditAction.CREATE, - entityType: 'User', - entityId: user.id, - newValue: { username: user.username, email: user.email, role: 'INSTRUCTOR' } - }); + if (existingEmail) { + throw new ValidationError('Email already exists'); + } - return { - user: this.formatUserResponseSync(user), - message: 'Registration successful' - }; + // Check if phone number already exists in user profiles + const existingPhone = await prisma.userProfile.findFirst({ + where: { phone } + }); + + if (existingPhone) { + throw new ValidationError('Phone number already exists'); + } + + // Get INSTRUCTOR role + const instructorRole = await prisma.role.findUnique({ + where: { code: 'INSTRUCTOR' } + }); + + if (!instructorRole) { + logger.error('INSTRUCTOR role not found in database'); + throw new Error('System configuration error'); + } + + // Hash password + const hashedPassword = await bcrypt.hash(password, 10); + + // Create user with profile + const user = await prisma.user.create({ + data: { + username, + email, + password: hashedPassword, + role_id: instructorRole.id, + profile: { + create: { + prefix: prefix ? prefix as Prisma.InputJsonValue : Prisma.JsonNull, + first_name, + last_name, + phone + } + } + }, + include: { + role: true, + profile: true + } + }); + + logger.info('New user registered', { userId: user.id, username: user.username }); + + // Audit log - REGISTER (Instructor) + auditService.log({ + userId: user.id, + action: AuditAction.CREATE, + entityType: 'User', + entityId: user.id, + newValue: { username: user.username, email: user.email, role: 'INSTRUCTOR' } + }); + + return { + user: this.formatUserResponseSync(user), + message: 'Registration successful' + }; + } catch (error) { + logger.error('Failed to register instructor', { error }); + await auditService.logSync({ + userId: 0, + action: AuditAction.ERROR, + entityType: 'User', + entityId: 0, + metadata: { + operation: 'register_instructor', + email: data.email, + username: data.username, + error: error instanceof Error ? error.message : String(error) + } + }); + throw error; + } } /** diff --git a/Backend/src/services/categories.service.ts b/Backend/src/services/categories.service.ts index 2b9138b9..0e0defa2 100644 --- a/Backend/src/services/categories.service.ts +++ b/Backend/src/services/categories.service.ts @@ -5,6 +5,8 @@ import { logger } from '../config/logger'; import jwt from 'jsonwebtoken'; import { createCategory, createCategoryResponse, deleteCategoryResponse, updateCategory, updateCategoryResponse, ListCategoriesResponse, Category } from '../types/categories.type'; import { UnauthorizedError, ValidationError, ForbiddenError } from '../middleware/errorHandler'; +import { auditService } from './audit.service'; +import { AuditAction } from '@prisma/client'; export class CategoryService { async listCategories(): Promise { @@ -30,6 +32,13 @@ export class CategoryService { const newCategory = await prisma.category.create({ data: category }); + auditService.log({ + userId: decoded.id, + action: AuditAction.CREATE, + entityType: 'Category', + entityId: newCategory.id, + newValue: { name: newCategory.name as { th: string; en: string }, slug: newCategory.slug, description: newCategory.description as { th: string; en: string } }, + }); return { code: 200, message: 'Category created successfully', @@ -43,6 +52,16 @@ export class CategoryService { }; } catch (error) { logger.error('Failed to create category', { error }); + await auditService.logSync({ + userId: 0, + action: AuditAction.ERROR, + entityType: 'Category', + entityId: 0, + metadata: { + operation: 'create_category', + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } @@ -54,6 +73,13 @@ export class CategoryService { where: { id }, data: category }); + auditService.log({ + userId: decoded.id, + action: AuditAction.UPDATE, + entityType: 'Category', + entityId: id, + newValue: { name: updatedCategory.name as { th: string; en: string }, slug: updatedCategory.slug, description: updatedCategory.description as { th: string; en: string } }, + }); return { code: 200, message: 'Category updated successfully', @@ -67,21 +93,49 @@ export class CategoryService { }; } catch (error) { logger.error('Failed to update category', { error }); + await auditService.logSync({ + userId: 0, + action: AuditAction.ERROR, + entityType: 'Category', + entityId: 0, + metadata: { + operation: 'update_category', + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } - async deleteCategory(id: number): Promise { + async deleteCategory(token: string, id: number): Promise { try { + const decoded = jwt.verify(token, config.jwt.secret) as { id: number; username: string; email: string; roleCode: string }; const deletedCategory = await prisma.category.delete({ where: { id } }); + auditService.log({ + userId: decoded.id, + action: AuditAction.DELETE, + entityType: 'Category', + entityId: id, + newValue: { name: deletedCategory.name as { th: string; en: string }, slug: deletedCategory.slug, description: deletedCategory.description as { th: string; en: string } }, + }); return { code: 200, message: 'Category deleted successfully', }; } catch (error) { logger.error('Failed to delete category', { error }); + await auditService.logSync({ + userId: 0, + action: AuditAction.ERROR, + entityType: 'Category', + entityId: 0, + metadata: { + operation: 'delete_category', + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } diff --git a/Backend/src/services/certificate.service.ts b/Backend/src/services/certificate.service.ts index ab1749ae..4041ec41 100644 --- a/Backend/src/services/certificate.service.ts +++ b/Backend/src/services/certificate.service.ts @@ -16,6 +16,8 @@ import { ListMyCertificatesInput, ListMyCertificatesResponse, } from '../types/certificate.types'; +import { auditService } from './audit.service'; +import { AuditAction } from '@prisma/client'; export class CertificateService { private static TEMPLATE_PATH = path.join(__dirname, '../../assets/templates/Certificate.pdf'); @@ -54,17 +56,11 @@ export class CertificateService { }, }); - if (!enrollment) { - throw new NotFoundError('Enrollment not found'); - } + if (!enrollment) throw new NotFoundError('Enrollment not found'); - if (enrollment.status !== 'COMPLETED') { - throw new ForbiddenError('Course not completed yet'); - } + if (enrollment.status !== 'COMPLETED') throw new ForbiddenError('Course not completed yet'); - if (!enrollment.course.have_certificate) { - throw new ValidationError('This course does not offer certificates'); - } + if (!enrollment.course.have_certificate) throw new ValidationError('This course does not offer certificates'); // Check if certificate already exists const existingCertificate = await prisma.certificate.findFirst({ @@ -121,6 +117,14 @@ export class CertificateService { }, }); + auditService.log({ + userId: decoded.id, + action: AuditAction.CREATE, + entityType: 'Certificate', + entityId: certificate.id, + newValue: { file_path: certificate.file_path, issued_at: certificate.issued_at }, + }); + const downloadUrl = await getPresignedUrl(filePath, 3600); return { @@ -135,6 +139,18 @@ export class CertificateService { }; } catch (error) { logger.error('Failed to generate certificate', { error }); + const decoded = jwt.decode(input.token) as { id: number } | null; + await auditService.logSync({ + userId: decoded?.id, + action: AuditAction.ERROR, + entityType: 'Certificate', + entityId: 0, + metadata: { + operation: 'generate_certificate', + course_id: input.course_id, + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } @@ -186,6 +202,18 @@ export class CertificateService { }; } catch (error) { logger.error('Failed to get certificate', { error }); + const decoded = jwt.decode(input.token) as { id: number } | null; + await auditService.logSync({ + userId: decoded?.id, + action: AuditAction.ERROR, + entityType: 'Certificate', + entityId: 0, + metadata: { + operation: 'get_certificate', + course_id: input.course_id, + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } @@ -239,6 +267,17 @@ export class CertificateService { }; } catch (error) { logger.error('Failed to list certificates', { error }); + const decoded = jwt.decode(input.token) as { id: number } | null; + await auditService.logSync({ + userId: decoded?.id, + action: AuditAction.ERROR, + entityType: 'Certificate', + entityId: 0, + metadata: { + operation: 'list_my_certificates', + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } diff --git a/Backend/src/services/courses.service.ts b/Backend/src/services/courses.service.ts index 252744f8..0700a7fa 100644 --- a/Backend/src/services/courses.service.ts +++ b/Backend/src/services/courses.service.ts @@ -5,6 +5,8 @@ import { logger } from '../config/logger'; import { listCourseResponse, getCourseResponse, ListCoursesInput } from '../types/courses.types'; import { UnauthorizedError, ValidationError, ForbiddenError } from '../middleware/errorHandler'; import { getPresignedUrl } from '../config/minio'; +import { auditService } from './audit.service'; +import { AuditAction } from '@prisma/client'; export class CoursesService { async ListCourses(input: ListCoursesInput): Promise { @@ -82,6 +84,16 @@ export class CoursesService { }; } catch (error) { logger.error('Failed to fetch courses', { error }); + await auditService.logSync({ + userId: 0, + action: AuditAction.ERROR, + entityType: 'Course', + entityId: 0, + metadata: { + operation: 'list_courses', + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } @@ -122,6 +134,16 @@ export class CoursesService { }; } catch (error) { logger.error('Failed to fetch course', { error }); + await auditService.logSync({ + userId: 0, + action: AuditAction.ERROR, + entityType: 'Course', + entityId: id, + metadata: { + operation: 'get_course', + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } diff --git a/Backend/src/services/user.service.ts b/Backend/src/services/user.service.ts index da9bc274..918b12e3 100644 --- a/Backend/src/services/user.service.ts +++ b/Backend/src/services/user.service.ts @@ -135,6 +135,17 @@ export class UserService { throw new UnauthorizedError('Token expired'); } logger.error('Failed to change password', { error }); + const decoded = jwt.decode(token) as { id: number } | null; + await auditService.logSync({ + userId: decoded?.id || 0, + action: AuditAction.ERROR, + entityType: 'User', + entityId: decoded?.id || 0, + metadata: { + operation: 'change_password', + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } @@ -186,6 +197,17 @@ 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; } } @@ -252,6 +274,18 @@ export class UserService { }); } + // Audit log - UPLOAD_AVATAR + await auditService.logSync({ + userId: decoded.id, + action: AuditAction.UPLOAD_FILE, + entityType: 'User', + entityId: decoded.id, + metadata: { + operation: 'upload_avatar', + filePath + } + }); + // Generate presigned URL for response const presignedUrl = await this.getAvatarPresignedUrl(filePath); @@ -273,6 +307,18 @@ export class UserService { throw new UnauthorizedError('Token expired'); } logger.error('Failed to upload avatar', { error }); + const decoded = jwt.decode(token) as { id: number } | null; + await auditService.logSync({ + userId: decoded?.id || 0, + action: AuditAction.UPLOAD_FILE, + entityType: 'UserProfile', + entityId: decoded?.id || 0, + metadata: { + operation: 'upload_avatar', + error: error instanceof Error ? error.message : String(error) + } + }); + throw error; } } @@ -385,6 +431,17 @@ export class UserService { if (error instanceof jwt.JsonWebTokenError) throw new UnauthorizedError('Invalid token'); if (error instanceof jwt.TokenExpiredError) throw new UnauthorizedError('Token expired'); logger.error('Failed to send verification email', { error }); + const decoded = jwt.decode(token) as { id: number } | null; + await auditService.logSync({ + userId: decoded?.id || 0, + action: AuditAction.ERROR, + entityType: 'UserProfile', + entityId: decoded?.id || 0, + metadata: { + operation: 'send_verification_email', + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } @@ -415,6 +472,15 @@ export class UserService { }); logger.info('Email verified successfully', { userId: user.id, email: user.email }); + await auditService.logSync({ + userId: user.id, + action: AuditAction.VERIFY_EMAIL, + entityType: 'UserProfile', + entityId: user.id, + metadata: { + operation: 'verify_email' + } + }); return { code: 200, message: 'Email verified successfully' @@ -423,6 +489,17 @@ export class UserService { if (error instanceof jwt.JsonWebTokenError) throw new UnauthorizedError('Invalid verification token'); if (error instanceof jwt.TokenExpiredError) throw new UnauthorizedError('Verification link has expired'); logger.error('Failed to verify email', { error }); + const decoded = jwt.decode(verifyToken) as { id: number } | null; + await auditService.logSync({ + userId: decoded?.id || 0, + action: AuditAction.ERROR, + entityType: 'UserProfile', + entityId: decoded?.id || 0, + metadata: { + operation: 'verify_email', + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } diff --git a/Backend/src/services/usermanagement.service.ts b/Backend/src/services/usermanagement.service.ts index 658382c2..2bded499 100644 --- a/Backend/src/services/usermanagement.service.ts +++ b/Backend/src/services/usermanagement.service.ts @@ -39,6 +39,16 @@ export class UserManagementService { }; } catch (error) { logger.error('Failed to fetch users', { error }); + await auditService.logSync({ + userId: 0, + action: AuditAction.ERROR, + entityType: 'User', + entityId: 0, + metadata: { + operation: 'list_users', + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } @@ -61,6 +71,16 @@ export class UserManagementService { }; } catch (error) { logger.error('Failed to fetch user by ID', { error }); + await auditService.logSync({ + userId: id, + action: AuditAction.ERROR, + entityType: 'User', + entityId: id, + metadata: { + operation: 'get_user_by_id', + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } @@ -95,6 +115,17 @@ export class UserManagementService { }; } catch (error) { logger.error('Failed to update user role', { error }); + await auditService.logSync({ + userId: id, + action: AuditAction.ERROR, + entityType: 'User', + entityId: id, + metadata: { + operation: 'update_user_role', + target_role_id: role_id, + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } @@ -114,6 +145,16 @@ export class UserManagementService { }; } catch (error) { logger.error('Failed to deactivate user', { error }); + await auditService.logSync({ + userId: id, + action: AuditAction.ERROR, + entityType: 'User', + entityId: id, + metadata: { + operation: 'delete_user', + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } @@ -160,6 +201,16 @@ export class UserManagementService { throw new UnauthorizedError('Token expired'); } logger.error('Failed to deactivate account', { error }); + await auditService.logSync({ + userId: id, + action: AuditAction.ERROR, + entityType: 'User', + entityId: id, + metadata: { + operation: 'deactivate_account', + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } @@ -207,6 +258,16 @@ export class UserManagementService { throw new UnauthorizedError('Token expired'); } logger.error('Failed to activate account', { error }); + await auditService.logSync({ + userId: id, + action: AuditAction.ERROR, + entityType: 'User', + entityId: id, + metadata: { + operation: 'activate_account', + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } From 5442f1beb69ce7d1012ea9153290795001caa4ea Mon Sep 17 00:00:00 2001 From: Missez Date: Fri, 13 Feb 2026 11:55:12 +0700 Subject: [PATCH 05/29] feat: Introduce core admin and instructor dashboards with dedicated services, pages, and layouts. --- frontend_management/layouts/admin.vue | 9 + .../pages/admin/audit-logs.vue | 4 +- .../pages/admin/recommended-courses/index.vue | 314 ++++++++++++++++++ .../pages/instructor/courses/create.vue | 4 +- .../pages/instructor/courses/index.vue | 76 ++++- frontend_management/services/admin.service.ts | 93 ++++++ .../services/instructor.service.ts | 4 + 7 files changed, 497 insertions(+), 7 deletions(-) create mode 100644 frontend_management/pages/admin/recommended-courses/index.vue diff --git a/frontend_management/layouts/admin.vue b/frontend_management/layouts/admin.vue index e3d9546e..82872eba 100644 --- a/frontend_management/layouts/admin.vue +++ b/frontend_management/layouts/admin.vue @@ -53,6 +53,15 @@ หมวดหมู่ + + + คอร์สแนะนำ + + { const getActionColor = (action: string) => { if (!action) return 'grey'; - if (action.includes('DELETE') || action.includes('REJECT') || action.includes('DEACTIVATE')) return 'negative'; + if (action.includes('DELETE') || action.includes('REJECT') || action.includes('DEACTIVATE') || action.includes('ERROR')) return 'negative'; if (action.includes('UPDATE') || action.includes('CHANGE')) return 'warning'; if (action.includes('CREATE') || action.includes('APPROVE') || action.includes('ACTIVATE')) return 'positive'; if (action.includes('LOGIN')) return 'info'; diff --git a/frontend_management/pages/admin/recommended-courses/index.vue b/frontend_management/pages/admin/recommended-courses/index.vue new file mode 100644 index 00000000..8eeef6fb --- /dev/null +++ b/frontend_management/pages/admin/recommended-courses/index.vue @@ -0,0 +1,314 @@ + + + diff --git a/frontend_management/pages/instructor/courses/create.vue b/frontend_management/pages/instructor/courses/create.vue index 4965030a..8c1227f1 100644 --- a/frontend_management/pages/instructor/courses/create.vue +++ b/frontend_management/pages/instructor/courses/create.vue @@ -63,7 +63,7 @@
-
+
{{ stats.total }}
หลักสูตรทั้งหมด
@@ -31,6 +31,10 @@
{{ stats.draft }}
แบบร่าง
+
+
{{ stats.rejected }}
+
ถูกปฏิเสธ
+
@@ -126,7 +130,7 @@ dense icon="visibility" color="grey" - @click="navigateTo(`/instructor/courses/${course.id}`)" + @click="handleViewDetails(course)" > ดูรายละเอียด @@ -162,6 +166,36 @@
+ + + + + +
หลักสูตรถูกปฏิเสธ
+ + +
+ + +
เหตุผลการปฏิเสธ:
+
+ {{ selectedRejectionCourse?.rejection_reason || 'ไม่ระบุเหตุผล' }} +
+
+ คุณสามารถแก้ไขหลักสูตรและส่งขออนุมัติใหม่ได้ โดยการคืนสถานะเป็นแบบร่าง +
+
+ + + + + +
+
@@ -196,7 +230,8 @@ const stats = computed(() => ({ total: courses.value.length, approved: courses.value.filter(c => c.status === 'APPROVED').length, pending: courses.value.filter(c => c.status === 'PENDING').length, - draft: courses.value.filter(c => c.status === 'DRAFT').length + draft: courses.value.filter(c => c.status === 'DRAFT').length, + rejected: courses.value.filter(c => c.status === 'REJECTED').length })); // Filtered courses @@ -296,6 +331,41 @@ const confirmDelete = (course: CourseResponse) => { }); }; +// Rejection Dialog +const rejectionDialog = ref(false); +const selectedRejectionCourse = ref(null); + +const handleViewDetails = (course: CourseResponse) => { + if (course.status === 'REJECTED') { + selectedRejectionCourse.value = course; + rejectionDialog.value = true; + } else { + navigateTo(`/instructor/courses/${course.id}`); + } +}; + +const returnToDraft = async () => { + if (!selectedRejectionCourse.value) return; + + try { + const response = await instructorService.setCourseDraft(selectedRejectionCourse.value.id); + $q.notify({ + type: 'positive', + message: response.message || 'คืนสถานะเป็นแบบร่างสำเร็จ', + position: 'top' + }); + rejectionDialog.value = false; + selectedRejectionCourse.value = null; + fetchCourses(); // Refresh list + } catch (error: any) { + $q.notify({ + type: 'negative', + message: error.data?.message || 'ไม่สามารถคืนสถานะได้', + position: 'top' + }); + } +}; + // Lifecycle onMounted(() => { fetchCourses(); diff --git a/frontend_management/services/admin.service.ts b/frontend_management/services/admin.service.ts index 507f17c1..473c6ed9 100644 --- a/frontend_management/services/admin.service.ts +++ b/frontend_management/services/admin.service.ts @@ -254,6 +254,57 @@ export interface AuditLogStats { recentActivity: AuditLog[]; } +export interface RecommendedCourse { + id: number; + title: { + th: string; + en: string; + }; + slug: string; + description: { + th: string; + en: string; + }; + thumbnail_url: string | null; + price: number; + is_free: boolean; + have_certificate: boolean; + is_recommended: boolean; + status: string; + created_at: string; + updated_at: string; + category: { + id: number; + name: { + th: string; + en: string; + }; + }; + instructors: { + user_id: number; + is_primary: boolean; + user: { + id: number; + username: string; + email: string; + }; + }[]; + creator: { + id: number; + username: string; + email: string; + }; + chapters_count: number; + lessons_count: number; +} + +export interface RecommendedCoursesListResponse { + code: number; + message: string; + data: RecommendedCourse[]; + total: number; +} + // Helper function to get auth token from cookie const getAuthToken = (): string => { const tokenCookie = useCookie('token'); @@ -517,6 +568,48 @@ export const adminService = { headers: { Authorization: `Bearer ${token}` }, query: { days } }); + return response; + }, + + // ============ Recommended Courses ============ + async getRecommendedCourses(): Promise { + const config = useRuntimeConfig(); + const token = getAuthToken(); + const response = await $fetch('/api/admin/recommended-courses', { + baseURL: config.public.apiBaseUrl as string, + headers: { + Authorization: `Bearer ${token}` + } + }); + + return response.data; + }, + + async getRecommendedCourseById(id: number): Promise { + const config = useRuntimeConfig(); + const token = getAuthToken(); + const response = await $fetch>(`/api/admin/recommended-courses/${id}`, { + baseURL: config.public.apiBaseUrl as string, + headers: { + Authorization: `Bearer ${token}` + } + }); + + return response.data; + }, + + async toggleCourseRecommendation(courseId: number, isRecommended: boolean): Promise> { + const config = useRuntimeConfig(); + const token = getAuthToken(); + const response = await $fetch>(`/api/admin/recommended-courses/${courseId}/toggle`, { + method: 'PUT', + baseURL: config.public.apiBaseUrl as string, + headers: { + Authorization: `Bearer ${token}` + }, + query: { is_recommended: isRecommended } + }); + return response; } }; diff --git a/frontend_management/services/instructor.service.ts b/frontend_management/services/instructor.service.ts index be75eb83..1109816e 100644 --- a/frontend_management/services/instructor.service.ts +++ b/frontend_management/services/instructor.service.ts @@ -301,6 +301,10 @@ export const instructorService = { return await authRequest>(`/api/instructors/courses/send-review/${courseId}`, { method: 'POST' }); }, + async setCourseDraft(courseId: number): Promise> { + return await authRequest>(`/api/instructors/courses/set-draft/${courseId}`, { method: 'POST' }); + }, + async getEnrolledStudents( courseId: number, page: number = 1, From c5aa195b13de723d5bce0f7799423875b006a033 Mon Sep 17 00:00:00 2001 From: JakkrapartXD Date: Fri, 13 Feb 2026 17:41:01 +0700 Subject: [PATCH 06/29] feat: implement course cloning functionality including chapters, lessons, quizzes, and attachments for instructors. --- .../CoursesInstructorController.ts | 28 +++ .../src/services/CoursesInstructor.service.ts | 226 ++++++++++++++++++ Backend/src/services/courses.service.ts | 14 ++ Backend/src/types/CoursesInstructor.types.ts | 15 ++ 4 files changed, 283 insertions(+) diff --git a/Backend/src/controllers/CoursesInstructorController.ts b/Backend/src/controllers/CoursesInstructorController.ts index a0f956c7..3be4ee47 100644 --- a/Backend/src/controllers/CoursesInstructorController.ts +++ b/Backend/src/controllers/CoursesInstructorController.ts @@ -23,6 +23,7 @@ import { GetEnrolledStudentDetailResponse, GetCourseApprovalHistoryResponse, setCourseDraftResponse, + CloneCourseResponse, } from '../types/CoursesInstructor.types'; import { CreateCourseValidator } from "../validators/CoursesInstructor.validator"; @@ -178,6 +179,33 @@ 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'); + + return await CoursesInstructorService.cloneCourse({ + token, + course_id: courseId, + title: body.title + }); + } + /** * ส่งคอร์สเพื่อขออนุมัติจากแอดมิน * Submit course for admin review and approval diff --git a/Backend/src/services/CoursesInstructor.service.ts b/Backend/src/services/CoursesInstructor.service.ts index a2530033..e1b40d0c 100644 --- a/Backend/src/services/CoursesInstructor.service.ts +++ b/Backend/src/services/CoursesInstructor.service.ts @@ -34,6 +34,8 @@ import { GetEnrolledStudentDetailInput, GetEnrolledStudentDetailResponse, GetCourseApprovalHistoryResponse, + CloneCourseInput, + CloneCourseResponse, setCourseDraft, setCourseDraftResponse, } from "../types/CoursesInstructor.types"; @@ -1446,4 +1448,228 @@ export class CoursesInstructorService { 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/courses.service.ts b/Backend/src/services/courses.service.ts index 0700a7fa..a0d96a45 100644 --- a/Backend/src/services/courses.service.ts +++ b/Backend/src/services/courses.service.ts @@ -104,6 +104,20 @@ export class CoursesService { where: { id, status: 'APPROVED' // Only show approved courses to students + }, + include: { + chapters: { + select: { + id: true, + title: true, + lessons: { + select: { + id: true, + title: true, + } + } + } + } } }); diff --git a/Backend/src/types/CoursesInstructor.types.ts b/Backend/src/types/CoursesInstructor.types.ts index e31e80fc..cc4aa149 100644 --- a/Backend/src/types/CoursesInstructor.types.ts +++ b/Backend/src/types/CoursesInstructor.types.ts @@ -433,3 +433,18 @@ export interface GetCourseApprovalHistoryResponse { approval_history: ApprovalHistoryItem[]; }; } + +export interface CloneCourseInput { + token: string; + course_id: number; + title: MultiLanguageText; +} + +export interface CloneCourseResponse { + code: number; + message: string; + data: { + id: number; + title: MultiLanguageText; + }; +} From b56f60489077bf1b48ee47cbe474e390fc8a037f Mon Sep 17 00:00:00 2001 From: JakkrapartXD Date: Wed, 18 Feb 2026 15:59:40 +0700 Subject: [PATCH 07/29] feat: introduce Joi validation schemas and integrate them across various controllers for categories, lessons, courses, chapters, announcements, and admin course approvals. --- .../AdminCourseApprovalController.ts | 13 ++ .../src/controllers/CategoriesController.ts | 13 +- .../ChaptersLessonInstructorController.ts | 52 +++++ .../CoursesInstructorController.ts | 33 ++-- .../controllers/CoursesStudentController.ts | 17 +- Backend/src/controllers/LessonsController.ts | 9 +- .../controllers/announcementsController.ts | 10 + .../AdminCourseApproval.validator.ts | 30 +++ .../validators/ChaptersLesson.validator.ts | 186 ++++++++++++++++++ .../validators/CoursesInstructor.validator.ts | 35 ++++ .../validators/CoursesStudent.validator.ts | 38 ++++ Backend/src/validators/Lessons.validator.ts | 15 ++ .../src/validators/announcements.validator.ts | 72 +++++++ .../src/validators/categories.validator.ts | 58 ++++++ 14 files changed, 553 insertions(+), 28 deletions(-) create mode 100644 Backend/src/validators/AdminCourseApproval.validator.ts create mode 100644 Backend/src/validators/ChaptersLesson.validator.ts create mode 100644 Backend/src/validators/CoursesStudent.validator.ts create mode 100644 Backend/src/validators/Lessons.validator.ts create mode 100644 Backend/src/validators/announcements.validator.ts create mode 100644 Backend/src/validators/categories.validator.ts diff --git a/Backend/src/controllers/AdminCourseApprovalController.ts b/Backend/src/controllers/AdminCourseApprovalController.ts index dac45be4..fc60b670 100644 --- a/Backend/src/controllers/AdminCourseApprovalController.ts +++ b/Backend/src/controllers/AdminCourseApprovalController.ts @@ -1,6 +1,7 @@ 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 { ApproveCourseValidator, RejectCourseValidator } from '../validators/AdminCourseApproval.validator'; import { ListPendingCoursesResponse, GetCourseDetailForAdminResponse, @@ -65,6 +66,13 @@ export class AdminCourseApprovalController { ): Promise { const token = request.headers.authorization?.replace('Bearer ', ''); if (!token) throw new ValidationError('No token provided'); + + // Validate body if provided + if (body) { + const { error } = ApproveCourseValidator.validate(body); + if (error) throw new ValidationError(error.details[0].message); + } + return await AdminCourseApprovalService.approveCourse(token, courseId, body?.comment); } @@ -87,6 +95,11 @@ export class AdminCourseApprovalController { ): 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); + return await AdminCourseApprovalService.rejectCourse(token, courseId, body.comment); } } diff --git a/Backend/src/controllers/CategoriesController.ts b/Backend/src/controllers/CategoriesController.ts index 3c99a3b5..81fd8b86 100644 --- a/Backend/src/controllers/CategoriesController.ts +++ b/Backend/src/controllers/CategoriesController.ts @@ -2,6 +2,7 @@ import { Get, Body, Post, Route, Tags, SuccessResponse, Response, Delete, Contro import { ValidationError } from '../middleware/errorHandler'; import { CategoryService } from '../services/categories.service'; import { createCategory, createCategoryResponse, deleteCategoryResponse, updateCategory, updateCategoryResponse, ListCategoriesResponse } from '../types/categories.type'; +import { CreateCategoryValidator, UpdateCategoryValidator } from '../validators/categories.validator'; @Route('api/categories') @Tags('Categories') @@ -27,6 +28,11 @@ export class CategoriesAdminController { @Response('401', 'Invalid or expired token') public async createCategory(@Request() request: any, @Body() body: createCategory): Promise { const token = request.headers.authorization?.replace('Bearer ', '') || ''; + + // Validate body + const { error } = CreateCategoryValidator.validate(body); + if (error) throw new ValidationError(error.details[0].message); + return await this.categoryService.createCategory(token, body); } @@ -36,6 +42,11 @@ export class CategoriesAdminController { @Response('401', 'Invalid or expired token') public async updateCategory(@Request() request: any, @Body() body: updateCategory): Promise { const token = request.headers.authorization?.replace('Bearer ', '') || ''; + + // Validate body + const { error } = UpdateCategoryValidator.validate(body); + if (error) throw new ValidationError(error.details[0].message); + return await this.categoryService.updateCategory(token, body.id, body); } @@ -45,6 +56,6 @@ export class CategoriesAdminController { @Response('401', 'Invalid or expired token') public async deleteCategory(@Request() request: any, @Path() id: number): Promise { const token = request.headers.authorization?.replace('Bearer ', '') || ''; - return await this.categoryService.deleteCategory(token,id); + return await this.categoryService.deleteCategory(token, id); } } \ No newline at end of file diff --git a/Backend/src/controllers/ChaptersLessonInstructorController.ts b/Backend/src/controllers/ChaptersLessonInstructorController.ts index f0bb43fe..7ba48f5c 100644 --- a/Backend/src/controllers/ChaptersLessonInstructorController.ts +++ b/Backend/src/controllers/ChaptersLessonInstructorController.ts @@ -27,6 +27,18 @@ import { UpdateQuizResponse, UpdateQuizBody, } from '../types/ChaptersLesson.typs'; +import { + CreateChapterValidator, + UpdateChapterValidator, + ReorderChapterValidator, + CreateLessonValidator, + UpdateLessonValidator, + ReorderLessonsValidator, + AddQuestionValidator, + UpdateQuestionValidator, + ReorderQuestionValidator, + UpdateQuizValidator +} from '../validators/ChaptersLesson.validator'; const chaptersLessonService = new ChaptersLessonService(); @@ -55,6 +67,10 @@ export class ChaptersLessonInstructorController { ): Promise { const token = request.headers.authorization?.replace('Bearer ', ''); if (!token) throw new ValidationError('No token provided'); + + const { error } = CreateChapterValidator.validate(body); + if (error) throw new ValidationError(error.details[0].message); + return await chaptersLessonService.createChapter({ token, course_id: courseId, @@ -82,6 +98,10 @@ export class ChaptersLessonInstructorController { ): Promise { const token = request.headers.authorization?.replace('Bearer ', ''); if (!token) throw new ValidationError('No token provided'); + + const { error } = UpdateChapterValidator.validate(body); + if (error) throw new ValidationError(error.details[0].message); + return await chaptersLessonService.updateChapter({ token, course_id: courseId, @@ -125,6 +145,10 @@ export class ChaptersLessonInstructorController { ): Promise { const token = request.headers.authorization?.replace('Bearer ', ''); if (!token) throw new ValidationError('No token provided'); + + const { error } = ReorderChapterValidator.validate(body); + if (error) throw new ValidationError(error.details[0].message); + return await chaptersLessonService.reorderChapter({ token, course_id: courseId, @@ -170,6 +194,10 @@ export class ChaptersLessonInstructorController { ): Promise { const token = request.headers.authorization?.replace('Bearer ', ''); if (!token) throw new ValidationError('No token provided'); + + const { error } = CreateLessonValidator.validate(body); + if (error) throw new ValidationError(error.details[0].message); + return await chaptersLessonService.createLesson({ token, course_id: courseId, @@ -197,6 +225,10 @@ export class ChaptersLessonInstructorController { ): Promise { const token = request.headers.authorization?.replace('Bearer ', ''); if (!token) throw new ValidationError('No token provided'); + + const { error } = UpdateLessonValidator.validate(body); + if (error) throw new ValidationError(error.details[0].message); + return await chaptersLessonService.updateLesson({ token, course_id: courseId, @@ -246,6 +278,10 @@ export class ChaptersLessonInstructorController { ): Promise { const token = request.headers.authorization?.replace('Bearer ', ''); if (!token) throw new ValidationError('No token provided'); + + const { error } = ReorderLessonsValidator.validate(body); + if (error) throw new ValidationError(error.details[0].message); + return await chaptersLessonService.reorderLessons({ token, course_id: courseId, @@ -275,6 +311,10 @@ export class ChaptersLessonInstructorController { ): Promise { const token = request.headers.authorization?.replace('Bearer ', ''); if (!token) throw new ValidationError('No token provided'); + + const { error } = AddQuestionValidator.validate(body); + if (error) throw new ValidationError(error.details[0].message); + return await chaptersLessonService.addQuestion({ token, course_id: courseId, @@ -300,6 +340,10 @@ export class ChaptersLessonInstructorController { ): Promise { const token = request.headers.authorization?.replace('Bearer ', ''); if (!token) throw new ValidationError('No token provided'); + + const { error } = UpdateQuestionValidator.validate(body); + if (error) throw new ValidationError(error.details[0].message); + return await chaptersLessonService.updateQuestion({ token, course_id: courseId, @@ -322,6 +366,10 @@ export class ChaptersLessonInstructorController { ): Promise { const token = request.headers.authorization?.replace('Bearer ', ''); if (!token) throw new ValidationError('No token provided'); + + const { error } = ReorderQuestionValidator.validate(body); + if (error) throw new ValidationError(error.details[0].message); + return await chaptersLessonService.reorderQuestion({ token, course_id: courseId, @@ -371,6 +419,10 @@ export class ChaptersLessonInstructorController { ): Promise { const token = request.headers.authorization?.replace('Bearer ', ''); if (!token) throw new ValidationError('No token provided'); + + const { error } = UpdateQuizValidator.validate(body); + if (error) throw new ValidationError(error.details[0].message); + return await chaptersLessonService.updateQuiz({ token, course_id: courseId, diff --git a/Backend/src/controllers/CoursesInstructorController.ts b/Backend/src/controllers/CoursesInstructorController.ts index 3be4ee47..3657e928 100644 --- a/Backend/src/controllers/CoursesInstructorController.ts +++ b/Backend/src/controllers/CoursesInstructorController.ts @@ -2,30 +2,28 @@ import { Get, Body, Post, Route, Tags, SuccessResponse, Response, Security, Put, import { ValidationError } from '../middleware/errorHandler'; import { CoursesInstructorService } from '../services/CoursesInstructor.service'; import { - createCourses, createCourseResponse, - GetMyCourseResponse, - ListMyCoursesInput, ListMyCourseResponse, - addinstructorCourseResponse, - removeinstructorCourseResponse, - setprimaryCourseInstructorResponse, + GetMyCourseResponse, UpdateMyCourse, UpdateMyCourseResponse, DeleteMyCourseResponse, submitCourseResponse, listinstructorCourseResponse, - GetCourseApprovalsResponse, - SearchInstructorResponse, + addinstructorCourseResponse, + removeinstructorCourseResponse, + setprimaryCourseInstructorResponse, GetEnrolledStudentsResponse, + GetEnrolledStudentDetailResponse, GetQuizScoresResponse, GetQuizAttemptDetailResponse, - GetEnrolledStudentDetailResponse, + GetCourseApprovalsResponse, + SearchInstructorResponse, GetCourseApprovalHistoryResponse, setCourseDraftResponse, CloneCourseResponse, } from '../types/CoursesInstructor.types'; -import { CreateCourseValidator } from "../validators/CoursesInstructor.validator"; +import { CreateCourseValidator, UpdateCourseValidator, CloneCourseValidator } from "../validators/CoursesInstructor.validator"; import jwt from 'jsonwebtoken'; import { config } from '../config'; @@ -104,9 +102,11 @@ export class CoursesInstructorController { @Response('404', 'Course not found') public async updateCourse(@Request() request: any, @Path() courseId: number, @Body() body: UpdateMyCourse): Promise { const token = request.headers.authorization?.replace('Bearer ', ''); - if (!token) { - throw new ValidationError('No token provided'); - } + if (!token) throw new ValidationError('No token provided'); + + const { error } = UpdateCourseValidator.validate(body.data); + if (error) throw new ValidationError(error.details[0].message); + return await CoursesInstructorService.updateCourse(token, courseId, body.data); } @@ -199,13 +199,16 @@ export class CoursesInstructorController { const token = request.headers.authorization?.replace('Bearer ', ''); if (!token) throw new ValidationError('No token provided'); - return await CoursesInstructorService.cloneCourse({ + const { error } = CloneCourseValidator.validate(body); + if (error) throw new ValidationError(error.details[0].message); + + const result = await CoursesInstructorService.cloneCourse({ token, course_id: courseId, title: body.title }); + return result; } - /** * ส่งคอร์สเพื่อขออนุมัติจากแอดมิน * Submit course for admin review and approval diff --git a/Backend/src/controllers/CoursesStudentController.ts b/Backend/src/controllers/CoursesStudentController.ts index afcf80b0..87a5a613 100644 --- a/Backend/src/controllers/CoursesStudentController.ts +++ b/Backend/src/controllers/CoursesStudentController.ts @@ -16,6 +16,7 @@ import { GetQuizAttemptsResponse, } from '../types/CoursesStudent.types'; import { EnrollmentStatus } from '@prisma/client'; +import { SaveVideoProgressValidator, SubmitQuizValidator } from '../validators/CoursesStudent.validator'; @Route('api/students') @Tags('CoursesStudent') @@ -149,9 +150,11 @@ export class CoursesStudentController { @Body() body: SaveVideoProgressBody ): Promise { const token = request.headers.authorization?.replace('Bearer ', ''); - if (!token) { - throw new ValidationError('No token provided'); - } + if (!token) throw new ValidationError('No token provided'); + + const { error } = SaveVideoProgressValidator.validate(body); + if (error) throw new ValidationError(error.details[0].message); + return await this.service.saveVideoProgress({ token, lesson_id: lessonId, @@ -225,9 +228,11 @@ export class CoursesStudentController { @Body() body: SubmitQuizBody ): Promise { const token = request.headers.authorization?.replace('Bearer ', ''); - if (!token) { - throw new ValidationError('No token provided'); - } + if (!token) throw new ValidationError('No token provided'); + + const { error } = SubmitQuizValidator.validate(body); + if (error) throw new ValidationError(error.details[0].message); + return await this.service.submitQuiz({ token, course_id: courseId, diff --git a/Backend/src/controllers/LessonsController.ts b/Backend/src/controllers/LessonsController.ts index f054ef4e..0323f4ab 100644 --- a/Backend/src/controllers/LessonsController.ts +++ b/Backend/src/controllers/LessonsController.ts @@ -11,6 +11,7 @@ import { YouTubeVideoResponse, SetYouTubeVideoBody, } from '../types/ChaptersLesson.typs'; +import { SetYouTubeVideoValidator } from '../validators/Lessons.validator'; const chaptersLessonService = new ChaptersLessonService(); @@ -213,12 +214,8 @@ export class LessonsController { const token = request.headers.authorization?.replace('Bearer ', ''); if (!token) throw new ValidationError('No token provided'); - if (!body.youtube_video_id) { - throw new ValidationError('YouTube video ID is required'); - } - if (!body.video_title) { - throw new ValidationError('Video title is required'); - } + const { error } = SetYouTubeVideoValidator.validate(body); + if (error) throw new ValidationError(error.details[0].message); return await chaptersLessonService.setYouTubeVideo({ token, diff --git a/Backend/src/controllers/announcementsController.ts b/Backend/src/controllers/announcementsController.ts index 6a4b901c..8ac03c70 100644 --- a/Backend/src/controllers/announcementsController.ts +++ b/Backend/src/controllers/announcementsController.ts @@ -1,6 +1,7 @@ import { Body, Delete, Get, Path, Post, Put, Query, Request, Response, Route, Security, SuccessResponse, Tags, UploadedFile, UploadedFiles, FormField } from 'tsoa'; import { ValidationError } from '../middleware/errorHandler'; import { AnnouncementsService } from '../services/announcements.service'; +import { CreateAnnouncementValidator, UpdateAnnouncementValidator } from '../validators/announcements.validator'; import { ListAnnouncementResponse, CreateAnnouncementResponse, @@ -68,6 +69,10 @@ export class AnnouncementsController { // Parse JSON data field const parsed = JSON.parse(data) as CreateAnnouncementBody; + // Validate parsed data + const { error } = CreateAnnouncementValidator.validate(parsed); + if (error) throw new ValidationError(error.details[0].message); + return await announcementsService.createAnnouncement({ token, course_id: courseId, @@ -100,6 +105,11 @@ export class AnnouncementsController { ): Promise { const token = request.headers.authorization?.replace('Bearer ', ''); if (!token) throw new ValidationError('No token provided'); + + // Validate body + const { error } = UpdateAnnouncementValidator.validate(body); + if (error) throw new ValidationError(error.details[0].message); + return await announcementsService.updateAnnouncement({ token, course_id: courseId, diff --git a/Backend/src/validators/AdminCourseApproval.validator.ts b/Backend/src/validators/AdminCourseApproval.validator.ts new file mode 100644 index 00000000..89e0a284 --- /dev/null +++ b/Backend/src/validators/AdminCourseApproval.validator.ts @@ -0,0 +1,30 @@ +import Joi from 'joi'; + +/** + * Validator for approving a course + * Comment is optional + */ +export const ApproveCourseValidator = Joi.object({ + comment: Joi.string() + .max(1000) + .optional() + .messages({ + 'string.max': 'Comment must not exceed 1000 characters' + }) +}); + +/** + * Validator for rejecting a course + * Comment is required when rejecting + */ +export const RejectCourseValidator = Joi.object({ + comment: Joi.string() + .min(10) + .max(1000) + .required() + .messages({ + 'string.min': 'Comment must be at least 10 characters when rejecting a course', + 'string.max': 'Comment must not exceed 1000 characters', + 'any.required': 'Comment is required when rejecting a course' + }) +}); diff --git a/Backend/src/validators/ChaptersLesson.validator.ts b/Backend/src/validators/ChaptersLesson.validator.ts new file mode 100644 index 00000000..933a3e1a --- /dev/null +++ b/Backend/src/validators/ChaptersLesson.validator.ts @@ -0,0 +1,186 @@ +import Joi from 'joi'; + +// Multi-language validation schema +const multiLangSchema = Joi.object({ + th: Joi.string().required().messages({ + 'any.required': 'Thai text is required' + }), + en: Joi.string().required().messages({ + 'any.required': 'English text is required' + }) +}).required(); + +const multiLangOptionalSchema = Joi.object({ + th: Joi.string().optional(), + en: Joi.string().optional() +}).optional(); + +// ============================================ +// Chapter Validators +// ============================================ + +/** + * Validator for creating a chapter + */ +export const CreateChapterValidator = Joi.object({ + title: multiLangSchema.messages({ + 'any.required': 'Title is required' + }), + description: multiLangOptionalSchema, + sort_order: Joi.number().integer().min(0).optional() +}); + +/** + * Validator for updating a chapter + */ +export const UpdateChapterValidator = Joi.object({ + title: multiLangOptionalSchema, + description: multiLangOptionalSchema, + sort_order: Joi.number().integer().min(0).optional(), + is_published: Joi.boolean().optional() +}); + +/** + * Validator for reordering a chapter + */ +export const ReorderChapterValidator = Joi.object({ + sort_order: Joi.number().integer().min(0).required().messages({ + 'any.required': 'Sort order is required', + 'number.min': 'Sort order must be at least 0' + }) +}); + +// ============================================ +// Lesson Validators +// ============================================ + +/** + * Validator for creating a lesson + */ +export const CreateLessonValidator = Joi.object({ + title: multiLangSchema.messages({ + 'any.required': 'Title is required' + }), + content: multiLangOptionalSchema, + type: Joi.string().valid('VIDEO', 'QUIZ').required().messages({ + 'any.only': 'Type must be either VIDEO or QUIZ', + 'any.required': 'Type is required' + }), + sort_order: Joi.number().integer().min(0).optional() +}); + +/** + * Validator for updating a lesson + */ +export const UpdateLessonValidator = Joi.object({ + title: multiLangOptionalSchema, + content: multiLangOptionalSchema, + duration_minutes: Joi.number().min(0).optional().messages({ + 'number.min': 'Duration must be at least 0' + }), + sort_order: Joi.number().integer().min(0).optional(), + prerequisite_lesson_ids: Joi.array().items(Joi.number().integer().positive()).optional(), + is_published: Joi.boolean().optional() +}); + +/** + * Validator for reordering lessons + */ +export const ReorderLessonsValidator = Joi.object({ + lesson_id: Joi.number().integer().positive().required().messages({ + 'any.required': 'Lesson ID is required' + }), + sort_order: Joi.number().integer().min(0).required().messages({ + 'any.required': 'Sort order is required' + }) +}); + +// ============================================ +// Quiz Question Validators +// ============================================ + +/** + * Validator for quiz choice + */ +const QuizChoiceValidator = Joi.object({ + text: multiLangSchema.messages({ + 'any.required': 'Choice text is required' + }), + is_correct: Joi.boolean().required().messages({ + 'any.required': 'is_correct is required' + }), + sort_order: Joi.number().integer().min(0).optional() +}); + +/** + * Validator for adding a question to a quiz + */ +export const AddQuestionValidator = Joi.object({ + question: multiLangSchema.messages({ + 'any.required': 'Question is required' + }), + explanation: multiLangOptionalSchema, + question_type: Joi.string() + .valid('MULTIPLE_CHOICE', 'TRUE_FALSE', 'SHORT_ANSWER') + .required() + .messages({ + 'any.only': 'Question type must be MULTIPLE_CHOICE, TRUE_FALSE, or SHORT_ANSWER', + 'any.required': 'Question type is required' + }), + sort_order: Joi.number().integer().min(0).optional(), + choices: Joi.array().items(QuizChoiceValidator).min(1).optional().messages({ + 'array.min': 'At least one choice is required for multiple choice questions' + }) +}); + +/** + * Validator for updating a question + */ +export const UpdateQuestionValidator = Joi.object({ + question: multiLangOptionalSchema, + explanation: multiLangOptionalSchema, + question_type: Joi.string() + .valid('MULTIPLE_CHOICE', 'TRUE_FALSE', 'SHORT_ANSWER') + .optional() + .messages({ + 'any.only': 'Question type must be MULTIPLE_CHOICE, TRUE_FALSE, or SHORT_ANSWER' + }), + sort_order: Joi.number().integer().min(0).optional(), + choices: Joi.array().items(QuizChoiceValidator).min(1).optional().messages({ + 'array.min': 'At least one choice is required' + }) +}); + +/** + * Validator for reordering a question + */ +export const ReorderQuestionValidator = Joi.object({ + sort_order: Joi.number().integer().min(0).required().messages({ + 'any.required': 'Sort order is required', + 'number.min': 'Sort order must be at least 0' + }) +}); + +// ============================================ +// Quiz Settings Validator +// ============================================ + +/** + * Validator for updating quiz settings + */ +export const UpdateQuizValidator = Joi.object({ + title: multiLangOptionalSchema, + description: multiLangOptionalSchema, + passing_score: Joi.number().min(0).max(100).optional().messages({ + 'number.min': 'Passing score must be at least 0', + 'number.max': 'Passing score must not exceed 100' + }), + time_limit: Joi.number().min(0).optional().messages({ + 'number.min': 'Time limit must be at least 0' + }), + shuffle_questions: Joi.boolean().optional(), + shuffle_choices: Joi.boolean().optional(), + show_answers_after_completion: Joi.boolean().optional(), + is_skippable: Joi.boolean().optional(), + allow_multiple_attempts: Joi.boolean().optional() +}); diff --git a/Backend/src/validators/CoursesInstructor.validator.ts b/Backend/src/validators/CoursesInstructor.validator.ts index fe971950..cbde5802 100644 --- a/Backend/src/validators/CoursesInstructor.validator.ts +++ b/Backend/src/validators/CoursesInstructor.validator.ts @@ -20,3 +20,38 @@ export const CreateCourseValidator = Joi.object({ is_free: Joi.boolean().required(), have_certificate: Joi.boolean().required(), }); + +/** + * Validator for updating a course + */ +export const UpdateCourseValidator = Joi.object({ + category_id: Joi.number().optional(), + title: Joi.object({ + th: Joi.string().optional(), + en: Joi.string().optional(), + }).optional(), + slug: Joi.string().optional(), + description: Joi.object({ + th: Joi.string().optional(), + en: Joi.string().optional(), + }).optional(), + price: Joi.number().optional(), + is_free: Joi.boolean().optional(), + have_certificate: Joi.boolean().optional(), +}); + +/** + * Validator for cloning a course + */ +export const CloneCourseValidator = Joi.object({ + title: Joi.object({ + th: Joi.string().required().messages({ + 'any.required': 'Thai title is required' + }), + en: Joi.string().required().messages({ + 'any.required': 'English title is required' + }) + }).required().messages({ + 'any.required': 'Title is required' + }) +}); diff --git a/Backend/src/validators/CoursesStudent.validator.ts b/Backend/src/validators/CoursesStudent.validator.ts new file mode 100644 index 00000000..424c35fe --- /dev/null +++ b/Backend/src/validators/CoursesStudent.validator.ts @@ -0,0 +1,38 @@ +import Joi from 'joi'; + +/** + * Validator for saving video progress + */ +export const SaveVideoProgressValidator = Joi.object({ + video_progress_seconds: Joi.number().min(0).required().messages({ + 'any.required': 'Video progress seconds is required', + 'number.min': 'Video progress must be at least 0' + }), + video_duration_seconds: Joi.number().min(0).optional().messages({ + 'number.min': 'Video duration must be at least 0' + }) +}); + +/** + * Validator for quiz answer + */ +const QuizAnswerValidator = Joi.object({ + question_id: Joi.number().integer().positive().required().messages({ + 'any.required': 'Question ID is required', + 'number.positive': 'Question ID must be positive' + }), + choice_id: Joi.number().integer().positive().required().messages({ + 'any.required': 'Choice ID is required', + 'number.positive': 'Choice ID must be positive' + }) +}); + +/** + * Validator for submitting quiz answers + */ +export const SubmitQuizValidator = Joi.object({ + answers: Joi.array().items(QuizAnswerValidator).min(1).required().messages({ + 'any.required': 'Answers are required', + 'array.min': 'At least one answer is required' + }) +}); diff --git a/Backend/src/validators/Lessons.validator.ts b/Backend/src/validators/Lessons.validator.ts new file mode 100644 index 00000000..4161ec53 --- /dev/null +++ b/Backend/src/validators/Lessons.validator.ts @@ -0,0 +1,15 @@ +import Joi from 'joi'; + +/** + * Validator for setting YouTube video + */ +export const SetYouTubeVideoValidator = Joi.object({ + youtube_video_id: Joi.string().required().messages({ + 'any.required': 'YouTube video ID is required', + 'string.empty': 'YouTube video ID cannot be empty' + }), + video_title: Joi.string().required().messages({ + 'any.required': 'Video title is required', + 'string.empty': 'Video title cannot be empty' + }) +}); diff --git a/Backend/src/validators/announcements.validator.ts b/Backend/src/validators/announcements.validator.ts new file mode 100644 index 00000000..bd9ad945 --- /dev/null +++ b/Backend/src/validators/announcements.validator.ts @@ -0,0 +1,72 @@ +import Joi from 'joi'; + +/** + * Validator for creating an announcement + */ +export const CreateAnnouncementValidator = Joi.object({ + title: Joi.object({ + th: Joi.string().required().messages({ + 'any.required': 'Thai title is required' + }), + en: Joi.string().required().messages({ + 'any.required': 'English title is required' + }) + }).required().messages({ + 'any.required': 'Title is required' + }), + content: Joi.object({ + th: Joi.string().required().messages({ + 'any.required': 'Thai content is required' + }), + en: Joi.string().required().messages({ + 'any.required': 'English content is required' + }) + }).required().messages({ + 'any.required': 'Content is required' + }), + status: Joi.string() + .valid('DRAFT', 'PUBLISHED', 'ARCHIVED') + .required() + .messages({ + 'any.only': 'Status must be one of: DRAFT, PUBLISHED, ARCHIVED', + 'any.required': 'Status is required' + }), + is_pinned: Joi.boolean() + .required() + .messages({ + 'any.required': 'is_pinned is required' + }), + published_at: Joi.string() + .isoDate() + .optional() + .messages({ + 'string.isoDate': 'published_at must be a valid ISO date string' + }) +}); + +/** + * Validator for updating an announcement + */ +export const UpdateAnnouncementValidator = Joi.object({ + title: Joi.object({ + th: Joi.string().optional(), + en: Joi.string().optional() + }).optional(), + content: Joi.object({ + th: Joi.string().optional(), + en: Joi.string().optional() + }).optional(), + status: Joi.string() + .valid('DRAFT', 'PUBLISHED', 'ARCHIVED') + .optional() + .messages({ + 'any.only': 'Status must be one of: DRAFT, PUBLISHED, ARCHIVED' + }), + is_pinned: Joi.boolean().optional(), + published_at: Joi.string() + .isoDate() + .optional() + .messages({ + 'string.isoDate': 'published_at must be a valid ISO date string' + }) +}); diff --git a/Backend/src/validators/categories.validator.ts b/Backend/src/validators/categories.validator.ts new file mode 100644 index 00000000..521c9faf --- /dev/null +++ b/Backend/src/validators/categories.validator.ts @@ -0,0 +1,58 @@ +import Joi from 'joi'; + +/** + * Validator for creating a category + */ +export const CreateCategoryValidator = Joi.object({ + name: Joi.object({ + th: Joi.string().required().messages({ + 'any.required': 'Thai name is required' + }), + en: Joi.string().required().messages({ + 'any.required': 'English name is required' + }) + }).required().messages({ + 'any.required': 'Name is required' + }), + slug: Joi.string() + .pattern(/^[a-z0-9]+(?:-[a-z0-9]+)*$/) + .required() + .messages({ + 'string.pattern.base': 'Slug must be lowercase with hyphens (e.g., web-development)', + 'any.required': 'Slug is required' + }), + description: Joi.object({ + th: Joi.string().required().messages({ + 'any.required': 'Thai description is required' + }), + en: Joi.string().required().messages({ + 'any.required': 'English description is required' + }) + }).required().messages({ + 'any.required': 'Description is required' + }), + created_by: Joi.number().optional() +}); + +/** + * Validator for updating a category + */ +export const UpdateCategoryValidator = Joi.object({ + id: Joi.number().required().messages({ + 'any.required': 'Category ID is required' + }), + name: Joi.object({ + th: Joi.string().optional(), + en: Joi.string().optional() + }).optional(), + slug: Joi.string() + .pattern(/^[a-z0-9]+(?:-[a-z0-9]+)*$/) + .optional() + .messages({ + 'string.pattern.base': 'Slug must be lowercase with hyphens (e.g., web-development)' + }), + description: Joi.object({ + th: Joi.string().optional(), + en: Joi.string().optional() + }).optional() +}); From 3fa236cff54da2e1d3d3cac6dd7ebb9a5289d4a0 Mon Sep 17 00:00:00 2001 From: supalerk-ar66 Date: Wed, 18 Feb 2026 16:28:29 +0700 Subject: [PATCH 08/29] feat: Implement initial application layouts, global navigation, and course browsing pages with i18n support. --- .../components/layout/AppHeader.vue | 11 + .../components/layout/AppSidebar.vue | 24 +- .../components/layout/LandingHeader.vue | 51 +- .../components/layout/MobileNav.vue | 11 +- Frontend-Learner/components/user/UserMenu.vue | 13 +- Frontend-Learner/composables/useNavItems.ts | 76 ++ Frontend-Learner/i18n/locales/en.json | 2 + Frontend-Learner/i18n/locales/th.json | 2 + Frontend-Learner/layouts/default.vue | 2 +- Frontend-Learner/layouts/landing.vue | 8 +- Frontend-Learner/pages/browse/index.vue | 134 +++- Frontend-Learner/pages/browse/recommended.vue | 349 +++++++++ Frontend-Learner/pages/index.vue | 702 ++++++++++-------- Frontend-Learner/public/img/elearning.png | Bin 0 -> 245065 bytes Frontend-Learner/คู่มืออธิบาย/Error.md | 0 15 files changed, 993 insertions(+), 392 deletions(-) create mode 100644 Frontend-Learner/composables/useNavItems.ts create mode 100644 Frontend-Learner/pages/browse/recommended.vue create mode 100644 Frontend-Learner/public/img/elearning.png create mode 100644 Frontend-Learner/คู่มืออธิบาย/Error.md diff --git a/Frontend-Learner/components/layout/AppHeader.vue b/Frontend-Learner/components/layout/AppHeader.vue index f92a1353..373e8dfe 100644 --- a/Frontend-Learner/components/layout/AppHeader.vue +++ b/Frontend-Learner/components/layout/AppHeader.vue @@ -41,6 +41,17 @@ const searchText = ref('') Platform + + + diff --git a/Frontend-Learner/components/layout/AppSidebar.vue b/Frontend-Learner/components/layout/AppSidebar.vue index e8c28dac..6cdea9ef 100644 --- a/Frontend-Learner/components/layout/AppSidebar.vue +++ b/Frontend-Learner/components/layout/AppSidebar.vue @@ -5,28 +5,10 @@ * Uses Quasar QList for structure. */ +const { sidebarItems } = useNavItems() const { t } = useI18n() +const navItems = sidebarItems -const navItems = computed(() => [ - { - to: "/dashboard", - label: t('sidebar.overview'), - icon: "dashboard", // Using Material Icons names where possible or SVG paths - isSvg: false - }, - { - to: "/browse/discovery", - label: t('sidebar.browseCourses'), - icon: "explore", - isSvg: false - }, - { - to: "/dashboard/my-courses", - label: t('sidebar.myCourses'), - icon: "school", - isSvg: false - } -]); const handleNavigate = (path: string) => { if (import.meta.client) { @@ -55,7 +37,7 @@ const handleNavigate = (path: string) => { - {{ item.label }} + {{ $t(item.labelKey) }} diff --git a/Frontend-Learner/components/layout/LandingHeader.vue b/Frontend-Learner/components/layout/LandingHeader.vue index abb1f85d..dc31add9 100644 --- a/Frontend-Learner/components/layout/LandingHeader.vue +++ b/Frontend-Learner/components/layout/LandingHeader.vue @@ -27,10 +27,8 @@ onMounted(() => { :class="[isScrolled ? 'h-16 glass-nav shadow-lg' : 'h-24 bg-transparent']" >
- -
+ +
@@ -53,35 +51,28 @@ onMounted(() => { -
- +