feat: Implement student course management functionalities and standardize multi-language text types across course and category definitions.
This commit is contained in:
parent
d97569acbc
commit
4c9ad1cea7
6 changed files with 673 additions and 57 deletions
0
Backend/src/controllers/CoursesStudentController.ts
Normal file
0
Backend/src/controllers/CoursesStudentController.ts
Normal file
366
Backend/src/services/CoursesStudent.service.ts
Normal file
366
Backend/src/services/CoursesStudent.service.ts
Normal file
|
|
@ -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<EnrollCourseResponse> {
|
||||||
|
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<ListEnrolledCoursesResponse> {
|
||||||
|
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<GetCourseLearningResponse> {
|
||||||
|
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<GetLessonContentResponse> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,17 +1,12 @@
|
||||||
import { Course, Prisma, User } from '@prisma/client';
|
import { Course, Prisma, User } from '@prisma/client';
|
||||||
|
import { MultiLanguageText } from './index';
|
||||||
|
|
||||||
// Custom type for TSOA - avoiding complex Prisma types
|
// Custom type for TSOA - avoiding complex Prisma types
|
||||||
export interface CreateCourseInput {
|
export interface CreateCourseInput {
|
||||||
category_id?: number;
|
category_id?: number;
|
||||||
title: {
|
title: MultiLanguageText;
|
||||||
th: string;
|
|
||||||
en: string;
|
|
||||||
};
|
|
||||||
slug: string;
|
slug: string;
|
||||||
description: {
|
description: MultiLanguageText;
|
||||||
th: string;
|
|
||||||
en: string;
|
|
||||||
};
|
|
||||||
thumbnail_url?: string;
|
thumbnail_url?: string;
|
||||||
price?: number;
|
price?: number;
|
||||||
is_free?: boolean;
|
is_free?: boolean;
|
||||||
|
|
@ -48,15 +43,9 @@ export interface getmyCourse {
|
||||||
|
|
||||||
export interface UpdateCourseInput {
|
export interface UpdateCourseInput {
|
||||||
category_id?: number;
|
category_id?: number;
|
||||||
title?: {
|
title?: MultiLanguageText;
|
||||||
th: string;
|
|
||||||
en: string;
|
|
||||||
};
|
|
||||||
slug?: string;
|
slug?: string;
|
||||||
description?: {
|
description?: MultiLanguageText;
|
||||||
th: string;
|
|
||||||
en: string;
|
|
||||||
};
|
|
||||||
thumbnail_url?: string;
|
thumbnail_url?: string;
|
||||||
price?: number;
|
price?: number;
|
||||||
is_free?: boolean;
|
is_free?: boolean;
|
||||||
|
|
@ -151,4 +140,3 @@ export interface sendCourseForReview {
|
||||||
token: string;
|
token: string;
|
||||||
course_id: number;
|
course_id: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
289
Backend/src/types/CoursesStudent.types.ts
Normal file
289
Backend/src/types/CoursesStudent.types.ts
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -1,14 +1,10 @@
|
||||||
|
import { MultiLanguageText } from './index';
|
||||||
|
|
||||||
export interface Category {
|
export interface Category {
|
||||||
id: number;
|
id: number;
|
||||||
name: {
|
name: MultiLanguageText;
|
||||||
th: string;
|
|
||||||
en: string;
|
|
||||||
};
|
|
||||||
slug: string;
|
slug: string;
|
||||||
description: {
|
description: MultiLanguageText;
|
||||||
th: string;
|
|
||||||
en: string;
|
|
||||||
};
|
|
||||||
icon: string | null;
|
icon: string | null;
|
||||||
sort_order: number;
|
sort_order: number;
|
||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
|
|
@ -26,15 +22,9 @@ export interface listCategoriesResponse {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface createCategory {
|
export interface createCategory {
|
||||||
name: {
|
name: MultiLanguageText;
|
||||||
th: string;
|
|
||||||
en: string;
|
|
||||||
};
|
|
||||||
slug: string;
|
slug: string;
|
||||||
description: {
|
description: MultiLanguageText;
|
||||||
th: string;
|
|
||||||
en: string;
|
|
||||||
};
|
|
||||||
created_by: number;
|
created_by: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -43,43 +33,25 @@ export interface createCategoryResponse {
|
||||||
message: string;
|
message: string;
|
||||||
data: {
|
data: {
|
||||||
id: number;
|
id: number;
|
||||||
name: {
|
name: MultiLanguageText;
|
||||||
en: string;
|
|
||||||
th: string;
|
|
||||||
};
|
|
||||||
slug: string;
|
slug: string;
|
||||||
description: {
|
description: MultiLanguageText;
|
||||||
en: string;
|
|
||||||
th: string;
|
|
||||||
};
|
|
||||||
created_by: number;
|
created_by: number;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface updateCategory {
|
export interface updateCategory {
|
||||||
id: number;
|
id: number;
|
||||||
name: {
|
name: MultiLanguageText;
|
||||||
th: string;
|
|
||||||
en: string;
|
|
||||||
};
|
|
||||||
slug: string;
|
slug: string;
|
||||||
description: {
|
description: MultiLanguageText;
|
||||||
th: string;
|
|
||||||
en: string;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface updateCategoryResponse {
|
export interface updateCategoryResponse {
|
||||||
id: number;
|
id: number;
|
||||||
name: {
|
name: MultiLanguageText;
|
||||||
th: string;
|
|
||||||
en: string;
|
|
||||||
};
|
|
||||||
slug: string;
|
slug: string;
|
||||||
description: {
|
description: MultiLanguageText;
|
||||||
th: string;
|
|
||||||
en: string;
|
|
||||||
};
|
|
||||||
updated_by: number;
|
updated_by: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
export interface MultiLanguageText {
|
export interface MultiLanguageText {
|
||||||
|
[key: string]: string;
|
||||||
th: string;
|
th: string;
|
||||||
en: string;
|
en: string;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue