From 4c9ad1cea7580b78e83de796630daa46985d0395 Mon Sep 17 00:00:00 2001 From: JakkrapartXD Date: Mon, 19 Jan 2026 14:14:59 +0700 Subject: [PATCH] feat: Implement student course management functionalities and standardize multi-language text types across course and category definitions. --- .../controllers/CoursesStudentController.ts | 0 .../src/services/CoursesStudent.service.ts | 366 ++++++++++++++++++ Backend/src/types/CoursesInstructor.types.ts | 22 +- Backend/src/types/CoursesStudent.types.ts | 289 ++++++++++++++ Backend/src/types/categories.type.ts | 52 +-- Backend/src/types/index.ts | 1 + 6 files changed, 673 insertions(+), 57 deletions(-) create mode 100644 Backend/src/controllers/CoursesStudentController.ts create mode 100644 Backend/src/services/CoursesStudent.service.ts create mode 100644 Backend/src/types/CoursesStudent.types.ts diff --git a/Backend/src/controllers/CoursesStudentController.ts b/Backend/src/controllers/CoursesStudentController.ts new file mode 100644 index 00000000..e69de29b diff --git a/Backend/src/services/CoursesStudent.service.ts b/Backend/src/services/CoursesStudent.service.ts new file mode 100644 index 00000000..28a7a661 --- /dev/null +++ b/Backend/src/services/CoursesStudent.service.ts @@ -0,0 +1,366 @@ +import { prisma } from '../config/database'; +import { Prisma } from '@prisma/client'; +import { config } from '../config'; +import { logger } from '../config/logger'; +import { UnauthorizedError, ValidationError, ForbiddenError, NotFoundError } from '../middleware/errorHandler'; +import jwt from 'jsonwebtoken'; +import { + EnrollCourseInput, + EnrollCourseResponse, + ListEnrolledCoursesInput, + ListEnrolledCoursesResponse, + GetCourseLearningInput, + GetCourseLearningResponse, + GetLessonContentInput, + GetLessonContentResponse, + CheckLessonAccessInput, + CheckLessonAccessResponse, + SaveVideoProgressInput, + SaveVideoProgressResponse, + GetVideoProgressInput, + GetVideoProgressResponse, +} from "../types/CoursesStudent.types"; + +export class CoursesStudentService { + async enrollCourse(input: EnrollCourseInput): Promise { + try { + const { course_id } = input; + const decoded = jwt.verify(input.token, config.jwt.secret) as { id: number; type: string }; + const enrollment = await prisma.enrollment.create({ + data: { + course_id, + user_id: decoded.id, + status: 'ENROLLED', + enrolled_at: new Date(), + }, + }); + return { + code: 200, + message: 'Enrollment successful', + data: { + enrollment_id: enrollment.id, + course_id: enrollment.course_id, + user_id: enrollment.user_id, + status: enrollment.status, + enrolled_at: enrollment.enrolled_at, + }, + }; + } catch (error) { + logger.error(error); + throw error; + } + } + + async GetEnrolledCourses(input: ListEnrolledCoursesInput): Promise { + try { + const { token } = input; + const page = input.page ?? 1; + const limit = input.limit ?? 20; + const decoded = jwt.verify(token, config.jwt.secret) as { id: number; type: string }; + const enrollments = await prisma.enrollment.findMany({ + where: { + user_id: decoded.id, + }, + include: { + course: { + select: { + id: true, + title: true, + slug: true, + thumbnail_url: true, + description: true, + } + } + }, + skip: (page - 1) * limit, + take: limit, + }); + const total = await prisma.enrollment.count({ + where: { + user_id: decoded.id, + }, + }); + + const data = enrollments.map(enrollment => ({ + id: enrollment.id, + course_id: enrollment.course_id, + course: { + id: enrollment.course.id, + title: enrollment.course.title as { th: string; en: string }, + slug: enrollment.course.slug, + thumbnail_url: enrollment.course.thumbnail_url, + description: enrollment.course.description as { th: string; en: string }, + }, + status: enrollment.status, + progress_percentage: enrollment.progress_percentage, + enrolled_at: enrollment.enrolled_at, + started_at: enrollment.started_at, + completed_at: enrollment.completed_at, + last_accessed_at: enrollment.last_accessed_at, + })); + + return { + code: 200, + message: 'Enrollments retrieved successfully', + data, + total, + page, + limit, + }; + } catch (error) { + logger.error(error); + throw error; + } + } + async getCourseLearning(input: GetCourseLearningInput): Promise { + try { + const { token, course_id } = input; + const decoded = jwt.verify(token, config.jwt.secret) as { id: number; type: string }; + + // Get course with chapters and lessons (basic info only) + const course = await prisma.course.findUnique({ + where: { id: course_id }, + include: { + chapters: { + where: { is_published: true }, + orderBy: { sort_order: 'asc' }, + include: { + lessons: { + where: { is_published: true }, + orderBy: { sort_order: 'asc' }, + select: { + id: true, + chapter_id: true, + title: true, + type: true, + duration_minutes: true, + sort_order: true, + is_published: true, + is_sequential: true, + prerequisite_lesson_ids: true, + require_pass_quiz: true, + }, + }, + }, + }, + }, + }); + + if (!course) { + throw new ForbiddenError('Course not found'); + } + + // Get enrollment + const enrollment = await prisma.enrollment.findUnique({ + where: { + unique_enrollment: { + user_id: decoded.id, + course_id, + }, + }, + }); + + if (!enrollment) { + throw new ForbiddenError('You are not enrolled in this course'); + } + + // Get all lesson progress for this user and course + const lessonIds = course.chapters.flatMap(ch => ch.lessons.map(l => l.id)); + const lessonProgress = await prisma.lessonProgress.findMany({ + where: { + user_id: decoded.id, + lesson_id: { in: lessonIds }, + }, + }); + + const progressMap = new Map(lessonProgress.map(p => [p.lesson_id, p])); + + // Build chapters with lesson lock status + const chapters = course.chapters.map(chapter => ({ + id: chapter.id, + title: chapter.title as { th: string; en: string }, + description: chapter.description as { th: string; en: string } | null, + sort_order: chapter.sort_order, + is_published: chapter.is_published, + lessons: chapter.lessons.map(lesson => { + const progress = progressMap.get(lesson.id); + const isCompleted = progress?.is_completed ?? false; + + // Check lock status + let isLocked = false; + let lockReason: string | undefined; + + if (lesson.is_sequential) { + const prereqIds = lesson.prerequisite_lesson_ids as number[] | null; + if (prereqIds && prereqIds.length > 0) { + const allPrereqCompleted = prereqIds.every(id => { + const prereqProgress = progressMap.get(id); + return prereqProgress?.is_completed; + }); + if (!allPrereqCompleted) { + isLocked = true; + lockReason = 'Complete prerequisite lessons first'; + } + } + } + + return { + id: lesson.id, + chapter_id: lesson.chapter_id, + title: lesson.title as { th: string; en: string }, + type: lesson.type as 'VIDEO' | 'QUIZ', + duration_minutes: lesson.duration_minutes, + sort_order: lesson.sort_order, + is_published: lesson.is_published, + is_sequential: lesson.is_sequential, + prerequisite_lesson_ids: lesson.prerequisite_lesson_ids as number[] | null, + require_pass_quiz: lesson.require_pass_quiz, + // Lock status + is_locked: isLocked, + lock_reason: lockReason, + // Progress + is_completed: isCompleted, + video_progress_percentage: progress?.video_progress_percentage ? Number(progress.video_progress_percentage) : undefined, + }; + }), + })); + + const total_lessons = lessonIds.length; + const completed_lessons = lessonProgress.filter(p => p.is_completed).length; + + return { + code: 200, + message: 'Course learning retrieved successfully', + data: { + course: { + id: course.id, + title: course.title as { th: string; en: string }, + slug: course.slug, + description: course.description as { th: string; en: string }, + thumbnail_url: course.thumbnail_url, + have_certificate: course.have_certificate, + }, + enrollment: { + status: enrollment.status, + progress_percentage: enrollment.progress_percentage, + enrolled_at: enrollment.enrolled_at, + started_at: enrollment.started_at, + completed_at: enrollment.completed_at, + }, + chapters, + total_lessons, + completed_lessons, + }, + }; + } catch (error) { + logger.error(error); + throw error; + } + } + + async getlessonContent(input: GetLessonContentInput): Promise { + try { + const { token, course_id, lesson_id } = input; + const decoded = jwt.verify(token, config.jwt.secret) as { id: number; type: string }; + + // Check enrollment + const enrollment = await prisma.enrollment.findUnique({ + where: { + unique_enrollment: { + user_id: decoded.id, + course_id, + }, + }, + }); + + if (!enrollment) { + throw new ForbiddenError('You are not enrolled in this course'); + } + + // Get lesson with attachments and quiz + const lesson = await prisma.lesson.findUnique({ + where: { id: lesson_id }, + include: { + attachments: { + orderBy: { sort_order: 'asc' }, + }, + quiz: true, + chapter: { + include: { + lessons: { + where: { is_published: true }, + orderBy: { sort_order: 'asc' }, + select: { id: true, sort_order: true }, + }, + }, + }, + }, + }); + + if (!lesson) { + throw new NotFoundError('Lesson not found'); + } + + // Get user's progress for this lesson + const lessonProgress = await prisma.lessonProgress.findUnique({ + where: { + user_id_lesson_id: { + user_id: decoded.id, + lesson_id, + }, + }, + }); + + // Calculate prev/next lesson IDs + const allLessons = lesson.chapter.lessons; + const currentIndex = allLessons.findIndex(l => l.id === lesson_id); + const prevLessonId = currentIndex > 0 ? allLessons[currentIndex - 1].id : null; + const nextLessonId = currentIndex < allLessons.length - 1 ? allLessons[currentIndex + 1].id : null; + + return { + code: 200, + message: 'Lesson retrieved successfully', + data: { + id: lesson.id, + chapter_id: lesson.chapter_id, + title: lesson.title as { th: string; en: string }, + content: lesson.content as { th: string; en: string } | null, + type: lesson.type as 'VIDEO' | 'QUIZ', + duration_minutes: lesson.duration_minutes, + is_sequential: lesson.is_sequential, + prerequisite_lesson_ids: lesson.prerequisite_lesson_ids as number[] | null, + require_pass_quiz: lesson.require_pass_quiz, + attachments: lesson.attachments.map(att => ({ + id: att.id, + file_name: att.file_name, + file_path: att.file_path, + file_size: att.file_size, + mime_type: att.mime_type, + description: att.description as { th: string; en: string } | null, + })), + quiz: lesson.quiz ? { + id: lesson.quiz.id, + title: lesson.quiz.title as { th: string; en: string }, + description: lesson.quiz.description as { th: string; en: string } | null, + passing_score: lesson.quiz.passing_score, + time_limit: lesson.quiz.time_limit, + shuffle_questions: lesson.quiz.shuffle_questions, + shuffle_choices: lesson.quiz.shuffle_choices, + } : null, + prev_lesson_id: prevLessonId, + next_lesson_id: nextLessonId, + }, + progress: lessonProgress ? { + is_completed: lessonProgress.is_completed, + video_progress_seconds: lessonProgress.video_progress_seconds, + video_duration_seconds: lessonProgress.video_duration_seconds, + video_progress_percentage: lessonProgress.video_progress_percentage ? Number(lessonProgress.video_progress_percentage) : null, + last_watched_at: lessonProgress.last_watched_at, + } : undefined, + }; + } catch (error) { + logger.error(error); + throw error; + } + } +} \ No newline at end of file diff --git a/Backend/src/types/CoursesInstructor.types.ts b/Backend/src/types/CoursesInstructor.types.ts index 03fb78aa..f769a716 100644 --- a/Backend/src/types/CoursesInstructor.types.ts +++ b/Backend/src/types/CoursesInstructor.types.ts @@ -1,17 +1,12 @@ import { Course, Prisma, User } from '@prisma/client'; +import { MultiLanguageText } from './index'; // Custom type for TSOA - avoiding complex Prisma types export interface CreateCourseInput { category_id?: number; - title: { - th: string; - en: string; - }; + title: MultiLanguageText; slug: string; - description: { - th: string; - en: string; - }; + description: MultiLanguageText; thumbnail_url?: string; price?: number; is_free?: boolean; @@ -48,15 +43,9 @@ export interface getmyCourse { export interface UpdateCourseInput { category_id?: number; - title?: { - th: string; - en: string; - }; + title?: MultiLanguageText; slug?: string; - description?: { - th: string; - en: string; - }; + description?: MultiLanguageText; thumbnail_url?: string; price?: number; is_free?: boolean; @@ -151,4 +140,3 @@ export interface sendCourseForReview { token: string; course_id: number; } - \ No newline at end of file diff --git a/Backend/src/types/CoursesStudent.types.ts b/Backend/src/types/CoursesStudent.types.ts new file mode 100644 index 00000000..d0d769ec --- /dev/null +++ b/Backend/src/types/CoursesStudent.types.ts @@ -0,0 +1,289 @@ +import { Course, Lesson, Chapter, LessonAttachment, Quiz, LessonProgress, EnrollmentStatus } from '@prisma/client'; +import { MultiLanguageText } from './index'; + +// Use MultiLanguageText from index.ts for consistency +export type MultiLangText = MultiLanguageText; + +// ============================================ +// Enrollment Types +// ============================================ + +export interface EnrollCourseInput { + token: string; + course_id: number; +} + +export interface EnrollCourseResponse { + code: number; + message: string; + data?: { + enrollment_id: number; + course_id: number; + user_id: number; + status: EnrollmentStatus; + enrolled_at: Date; + }; +} + +export interface ListEnrolledCoursesInput { + token: string; + page?: number; + limit?: number; + status?: EnrollmentStatus; +} + +export interface EnrolledCourseItem { + id: number; + course_id: number; + course: { + id: number; + title: MultiLangText; + slug: string; + thumbnail_url: string | null; + description: MultiLangText; + }; + status: EnrollmentStatus; + progress_percentage: number; + enrolled_at: Date; + started_at: Date | null; + completed_at: Date | null; + last_accessed_at: Date | null; +} + +export interface ListEnrolledCoursesResponse { + code: number; + message: string; + data: EnrolledCourseItem[]; + total: number; + page: number; + limit: number; +} + +// ============================================ +// Course Learning Page Types +// ============================================ + +export interface GetCourseLearningInput { + token: string; + course_id: number; +} + +export interface LessonWithLockStatus { + id: number; + chapter_id: number; + title: MultiLangText; + type: 'VIDEO' | 'QUIZ'; + duration_minutes: number | null; + sort_order: number; + is_published: boolean; + is_sequential: boolean; + prerequisite_lesson_ids: number[] | null; + require_pass_quiz: boolean; + // Lock status + is_locked: boolean; + lock_reason?: string; + // Progress + is_completed: boolean; + video_progress_percentage?: number; +} + +export interface ChapterWithLessons { + id: number; + title: MultiLangText; + description: MultiLangText | null; + sort_order: number; + is_published: boolean; + lessons: LessonWithLockStatus[]; +} + +export interface GetCourseLearningResponse { + code: number; + message: string; + data: { + course: { + id: number; + title: MultiLangText; + slug: string; + description: MultiLangText; + thumbnail_url: string | null; + have_certificate: boolean; + }; + enrollment: { + status: EnrollmentStatus; + progress_percentage: number; + enrolled_at: Date; + started_at: Date | null; + completed_at: Date | null; + }; + chapters: ChapterWithLessons[]; + total_lessons: number; + completed_lessons: number; + }; +} + +// ============================================ +// Lesson Content Types +// ============================================ + +export interface GetLessonContentInput { + token: string; + course_id: number; + lesson_id: number; +} + +export interface LessonContentData { + id: number; + chapter_id: number; + title: MultiLangText; + content: MultiLangText | null; + type: 'VIDEO' | 'QUIZ'; + duration_minutes: number | null; + is_sequential: boolean; + prerequisite_lesson_ids: number[] | null; + require_pass_quiz: boolean; + attachments: { + id: number; + file_name: string; + file_path: string; + file_size: number; + mime_type: string; + description: MultiLangText | null; + }[]; + quiz?: { + id: number; + title: MultiLangText; + description: MultiLangText | null; + passing_score: number; + time_limit: number | null; + shuffle_questions: boolean; + shuffle_choices: boolean; + } | null; + // Navigation + prev_lesson_id: number | null; + next_lesson_id: number | null; +} + +export interface GetLessonContentResponse { + code: number; + message: string; + data: LessonContentData; + progress?: { + is_completed: boolean; + video_progress_seconds: number; + video_duration_seconds: number | null; + video_progress_percentage: number | null; + last_watched_at: Date | null; + }; +} + +// ============================================ +// Lesson Access Check Types +// ============================================ + +export interface CheckLessonAccessInput { + token: string; + course_id: number; + lesson_id: number; +} + +export interface CheckLessonAccessResponse { + code: number; + message: string; + data: { + is_accessible: boolean; + is_enrolled: boolean; + is_locked: boolean; + lock_reason?: string; + required_lessons?: { + id: number; + title: MultiLangText; + is_completed: boolean; + }[]; + required_quiz_pass?: { + lesson_id: number; + quiz_id: number; + title: MultiLangText; + is_passed: boolean; + }; + }; +} + +// ============================================ +// Video Progress Types +// ============================================ + +export interface SaveVideoProgressInput { + token: string; + lesson_id: number; + video_progress_seconds: number; + video_duration_seconds?: number; +} + +export interface SaveVideoProgressResponse { + code: number; + message: string; + data?: { + lesson_id: number; + video_progress_seconds: number; + video_duration_seconds: number | null; + video_progress_percentage: number | null; + is_completed: boolean; + last_watched_at: Date; + }; +} + +export interface GetVideoProgressInput { + token: string; + lesson_id: number; +} + +export interface GetVideoProgressResponse { + code: number; + message: string; + data: { + lesson_id: number; + video_progress_seconds: number; + video_duration_seconds: number | null; + video_progress_percentage: number | null; + is_completed: boolean; + completed_at: Date | null; + last_watched_at: Date | null; + } | null; +} + +// ============================================ +// Lesson Completion Types +// ============================================ + +export interface MarkLessonCompleteInput { + token: string; + course_id: number; + lesson_id: number; +} + +export interface MarkLessonCompleteResponse { + code: number; + message: string; + data?: { + lesson_id: number; + is_completed: boolean; + completed_at: Date; + course_progress_percentage: number; + is_course_completed: boolean; + next_lesson_id: number | null; + certificate_issued?: boolean; + }; +} + +// ============================================ +// Request Body Types (for TSOA) +// ============================================ + +export interface SaveVideoProgressBody { + video_progress_seconds: number; + video_duration_seconds?: number; +} + +export interface EnrollCourseBody { + course_id: number; +} \ No newline at end of file diff --git a/Backend/src/types/categories.type.ts b/Backend/src/types/categories.type.ts index bb9ed108..1fabed79 100644 --- a/Backend/src/types/categories.type.ts +++ b/Backend/src/types/categories.type.ts @@ -1,14 +1,10 @@ +import { MultiLanguageText } from './index'; + export interface Category { id: number; - name: { - th: string; - en: string; - }; + name: MultiLanguageText; slug: string; - description: { - th: string; - en: string; - }; + description: MultiLanguageText; icon: string | null; sort_order: number; is_active: boolean; @@ -26,15 +22,9 @@ export interface listCategoriesResponse { } export interface createCategory { - name: { - th: string; - en: string; - }; + name: MultiLanguageText; slug: string; - description: { - th: string; - en: string; - }; + description: MultiLanguageText; created_by: number; } @@ -43,43 +33,25 @@ export interface createCategoryResponse { message: string; data: { id: number; - name: { - en: string; - th: string; - }; + name: MultiLanguageText; slug: string; - description: { - en: string; - th: string; - }; + description: MultiLanguageText; created_by: number; }; } export interface updateCategory { id: number; - name: { - th: string; - en: string; - }; + name: MultiLanguageText; slug: string; - description: { - th: string; - en: string; - }; + description: MultiLanguageText; } export interface updateCategoryResponse { id: number; - name: { - th: string; - en: string; - }; + name: MultiLanguageText; slug: string; - description: { - th: string; - en: string; - }; + description: MultiLanguageText; updated_by: number; } diff --git a/Backend/src/types/index.ts b/Backend/src/types/index.ts index 5b9c394c..78e4920a 100644 --- a/Backend/src/types/index.ts +++ b/Backend/src/types/index.ts @@ -1,4 +1,5 @@ export interface MultiLanguageText { + [key: string]: string; th: string; en: string; }