Compare commits

..

No commits in common. "dev" and "learner-dev-v1.1.4" have entirely different histories.

199 changed files with 5849 additions and 7538 deletions

View file

@ -24,7 +24,9 @@ export class AdminCourseApprovalController {
@Response('401', 'Unauthorized')
@Response('403', 'Forbidden - Admin only')
public async listPendingCourses(@Request() request: any): Promise<ListPendingCoursesResponse> {
return await AdminCourseApprovalService.listPendingCourses(request.user.id);
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided');
return await AdminCourseApprovalService.listPendingCourses(token);
}
/**
@ -39,7 +41,9 @@ export class AdminCourseApprovalController {
@Response('403', 'Forbidden - Admin only')
@Response('404', 'Course not found')
public async getCourseDetail(@Request() request: any, @Path() courseId: number): Promise<GetCourseDetailForAdminResponse> {
return await AdminCourseApprovalService.getCourseDetail(request.user.id, courseId);
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided');
return await AdminCourseApprovalService.getCourseDetail(token, courseId);
}
/**
@ -58,7 +62,10 @@ export class AdminCourseApprovalController {
@Request() request: any,
@Path() courseId: number
): Promise<ApproveCourseResponse> {
return await AdminCourseApprovalService.approveCourse(request.user.id, courseId, undefined);
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided');
return await AdminCourseApprovalService.approveCourse(token, courseId, undefined);
}
/**
@ -78,10 +85,13 @@ export class AdminCourseApprovalController {
@Path() courseId: number,
@Body() body: RejectCourseBody
): Promise<RejectCourseResponse> {
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(request.user.id, courseId, body.comment);
return await AdminCourseApprovalService.rejectCourse(token, courseId, body.comment);
}
}

View file

@ -40,6 +40,11 @@ export class AuditController {
@Query() page?: number,
@Query() limit?: number
): Promise<ListAuditLogsResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) {
throw new ValidationError('No token provided');
}
return await auditService.getLogs({
userId,
action,
@ -67,6 +72,11 @@ export class AuditController {
@Request() request: any,
@Path() logId: number
): Promise<AuditLogResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) {
throw new ValidationError('No token provided');
}
const log = await auditService.getLogById(logId);
if (!log) {
throw new ValidationError('Audit log not found');
@ -84,6 +94,11 @@ export class AuditController {
@Response('401', 'Unauthorized')
@Response('403', 'Forbidden - Admin only')
public async getAuditStats(@Request() request: any): Promise<AuditLogStats> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) {
throw new ValidationError('No token provided');
}
return await auditService.getStats();
}
@ -103,6 +118,11 @@ export class AuditController {
@Path() entityType: string,
@Path() entityId: number
): Promise<AuditLogResponse[]> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) {
throw new ValidationError('No token provided');
}
return await auditService.getEntityHistory(entityType, entityId);
}
@ -122,6 +142,11 @@ export class AuditController {
@Path() userId: number,
@Query() limit?: number
): Promise<AuditLogResponse[]> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) {
throw new ValidationError('No token provided');
}
return await auditService.getUserActivity(userId, limit || 50);
}
@ -139,6 +164,11 @@ export class AuditController {
@Request() request: any,
@Query() days: number = 90
): Promise<{ deleted: number; message: string }> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) {
throw new ValidationError('No token provided');
}
if (days < 6) {
throw new ValidationError('Cannot delete logs newer than 6 days');
}

View file

@ -33,6 +33,32 @@ export class AuthController {
data: {
token: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
refreshToken: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
user: {
id: 1,
username: 'admin',
email: 'admin@elearning.local',
email_verified_at: new Date('2024-01-01T00:00:00Z'),
updated_at: new Date('2024-01-01T00:00:00Z'),
created_at: new Date('2024-01-01T00:00:00Z'),
role: {
code: 'ADMIN',
name: {
th: 'ผู้ดูแลระบบ',
en: 'Administrator'
}
},
profile: {
prefix: {
th: 'นาย',
en: 'Mr.'
},
first_name: 'Admin',
last_name: 'User',
phone: null,
avatar_url: null,
birth_date: null
}
}
}
})
public async login(@Body() body: LoginRequest): Promise<LoginResponse> {

View file

@ -27,11 +27,13 @@ export class CategoriesAdminController {
@SuccessResponse('200', 'Category created successfully')
@Response('401', 'Invalid or expired token')
public async createCategory(@Request() request: any, @Body() body: createCategory): Promise<createCategoryResponse> {
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(request.user.id, body);
return await this.categoryService.createCategory(token, body);
}
@Put('{id}')
@ -39,11 +41,13 @@ export class CategoriesAdminController {
@SuccessResponse('200', 'Category updated successfully')
@Response('401', 'Invalid or expired token')
public async updateCategory(@Request() request: any, @Body() body: updateCategory): Promise<updateCategoryResponse> {
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(request.user.id, body.id, body);
return await this.categoryService.updateCategory(token, body.id, body);
}
@Delete('{id}')
@ -51,6 +55,7 @@ export class CategoriesAdminController {
@SuccessResponse('200', 'Category deleted successfully')
@Response('401', 'Invalid or expired token')
public async deleteCategory(@Request() request: any, @Path() id: number): Promise<deleteCategoryResponse> {
return await this.categoryService.deleteCategory(request.user.id, id);
const token = request.headers.authorization?.replace('Bearer ', '') || '';
return await this.categoryService.deleteCategory(token, id);
}
}

View file

@ -1,4 +1,5 @@
import { Get, Post, Route, Tags, SuccessResponse, Response, Security, Path, Request } from 'tsoa';
import { ValidationError } from '../middleware/errorHandler';
import { CertificateService } from '../services/certificate.service';
import {
GenerateCertificateResponse,
@ -20,7 +21,9 @@ export class CertificateController {
@SuccessResponse('200', 'Certificates retrieved successfully')
@Response('401', 'Invalid or expired token')
public async listMyCertificates(@Request() request: any): Promise<ListMyCertificatesResponse> {
return await this.certificateService.listMyCertificates({ userId: request.user.id });
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided');
return await this.certificateService.listMyCertificates({ token });
}
/**
@ -34,7 +37,9 @@ export class CertificateController {
@Response('401', 'Invalid or expired token')
@Response('404', 'Certificate not found')
public async getCertificate(@Request() request: any, @Path() courseId: number): Promise<GetCertificateResponse> {
return await this.certificateService.getCertificate({ userId: request.user.id, course_id: courseId });
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided');
return await this.certificateService.getCertificate({ token, course_id: courseId });
}
/**
@ -49,6 +54,8 @@ export class CertificateController {
@Response('401', 'Invalid or expired token')
@Response('404', 'Enrollment not found')
public async generateCertificate(@Request() request: any, @Path() courseId: number): Promise<GenerateCertificateResponse> {
return await this.certificateService.generateCertificate({ userId: request.user.id, course_id: courseId });
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided');
return await this.certificateService.generateCertificate({ token, course_id: courseId });
}
}

View file

@ -65,11 +65,14 @@ export class ChaptersLessonInstructorController {
@Path() courseId: number,
@Body() body: CreateChapterBody
): Promise<CreateChapterResponse> {
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({
userId: request.user.id,
token,
course_id: courseId,
title: body.title,
description: body.description,
@ -93,11 +96,14 @@ export class ChaptersLessonInstructorController {
@Path() chapterId: number,
@Body() body: UpdateChapterBody
): Promise<UpdateChapterResponse> {
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({
userId: request.user.id,
token,
course_id: courseId,
chapter_id: chapterId,
...body,
@ -119,7 +125,9 @@ export class ChaptersLessonInstructorController {
@Path() courseId: number,
@Path() chapterId: number
): Promise<DeleteChapterResponse> {
return await chaptersLessonService.deleteChapter({ userId: request.user.id, course_id: courseId, chapter_id: chapterId });
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided');
return await chaptersLessonService.deleteChapter({ token, course_id: courseId, chapter_id: chapterId });
}
/**
@ -135,11 +143,14 @@ export class ChaptersLessonInstructorController {
@Path() chapterId: number,
@Body() body: ReorderChapterBody
): Promise<ReorderChapterResponse> {
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({
userId: request.user.id,
token,
course_id: courseId,
chapter_id: chapterId,
sort_order: body.sort_order,
@ -163,7 +174,9 @@ export class ChaptersLessonInstructorController {
@Path() chapterId: number,
@Path() lessonId: number
): Promise<GetLessonResponse> {
return await chaptersLessonService.getLesson({ userId: request.user.id, course_id: courseId, chapter_id: chapterId, lesson_id: lessonId });
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided');
return await chaptersLessonService.getLesson({ token, course_id: courseId, chapter_id: chapterId, lesson_id: lessonId });
}
/**
@ -179,11 +192,14 @@ export class ChaptersLessonInstructorController {
@Path() chapterId: number,
@Body() body: CreateLessonBody
): Promise<CreateLessonResponse> {
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({
userId: request.user.id,
token,
course_id: courseId,
chapter_id: chapterId,
title: body.title,
@ -207,11 +223,14 @@ export class ChaptersLessonInstructorController {
@Path() lessonId: number,
@Body() body: UpdateLessonBody
): Promise<UpdateLessonResponse> {
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({
userId: request.user.id,
token,
course_id: courseId,
chapter_id: chapterId,
lesson_id: lessonId,
@ -239,7 +258,9 @@ export class ChaptersLessonInstructorController {
@Path() chapterId: number,
@Path() lessonId: number
): Promise<DeleteLessonResponse> {
return await chaptersLessonService.deleteLesson({ userId: request.user.id, course_id: courseId, chapter_id: chapterId, lesson_id: lessonId });
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided');
return await chaptersLessonService.deleteLesson({ token, course_id: courseId, chapter_id: chapterId, lesson_id: lessonId });
}
/**
@ -255,11 +276,14 @@ export class ChaptersLessonInstructorController {
@Path() chapterId: number,
@Body() body: ReorderLessonsBody
): Promise<ReorderLessonsResponse> {
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({
userId: request.user.id,
token,
course_id: courseId,
chapter_id: chapterId,
lesson_id: body.lesson_id,
@ -285,11 +309,14 @@ export class ChaptersLessonInstructorController {
@Path() lessonId: number,
@Body() body: AddQuestionBody
): Promise<AddQuestionResponse> {
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({
userId: request.user.id,
token,
course_id: courseId,
lesson_id: lessonId,
...body,
@ -311,11 +338,14 @@ export class ChaptersLessonInstructorController {
@Path() questionId: number,
@Body() body: UpdateQuestionBody
): Promise<UpdateQuestionResponse> {
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({
userId: request.user.id,
token,
course_id: courseId,
lesson_id: lessonId,
question_id: questionId,
@ -334,11 +364,14 @@ export class ChaptersLessonInstructorController {
@Path() questionId: number,
@Body() body: ReorderQuestionBody
): Promise<ReorderQuestionResponse> {
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({
userId: request.user.id,
token,
course_id: courseId,
lesson_id: lessonId,
question_id: questionId,
@ -360,8 +393,10 @@ export class ChaptersLessonInstructorController {
@Path() lessonId: number,
@Path() questionId: number
): Promise<DeleteQuestionResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided');
return await chaptersLessonService.deleteQuestion({
userId: request.user.id,
token,
course_id: courseId,
lesson_id: lessonId,
question_id: questionId,
@ -382,11 +417,14 @@ export class ChaptersLessonInstructorController {
@Path() lessonId: number,
@Body() body: UpdateQuizBody
): Promise<UpdateQuizResponse> {
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({
userId: request.user.id,
token,
course_id: courseId,
lesson_id: lessonId,
...body,

View file

@ -22,10 +22,12 @@ import {
GetCourseApprovalHistoryResponse,
setCourseDraftResponse,
CloneCourseResponse,
GetAllMyStudentsResponse,
} from '../types/CoursesInstructor.types';
import { CreateCourseValidator, UpdateCourseValidator, CloneCourseValidator } from "../validators/CoursesInstructor.validator";
import jwt from 'jsonwebtoken';
import { config } from '../config';
@Route('api/instructors/courses')
@Tags('CoursesInstructor')
export class CoursesInstructorController {
@ -43,7 +45,11 @@ export class CoursesInstructorController {
@Request() request: any,
@Query() status?: 'DRAFT' | 'PENDING' | 'APPROVED' | 'REJECTED' | 'ARCHIVED'
): Promise<ListMyCourseResponse> {
return await CoursesInstructorService.listMyCourses({ userId: request.user.id, status });
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) {
throw new ValidationError('No token provided');
}
return await CoursesInstructorService.listMyCourses({ token, status });
}
/**
@ -61,23 +67,9 @@ export class CoursesInstructorController {
@Path() courseId: number,
@Query() query: string
): Promise<SearchInstructorResponse> {
return await CoursesInstructorService.searchInstructors({ userId: request.user.id, query, course_id: courseId });
}
/**
* instructor
* Get all students enrolled in all of instructor's courses
*
* @returns total_enrolled total_completed
*/
@Get('my-students')
@Security('jwt', ['instructor'])
@SuccessResponse('200', 'Students retrieved successfully')
@Response('401', 'Unauthorized')
public async getMyAllStudents(
@Request() request: any
): Promise<GetAllMyStudentsResponse> {
return await CoursesInstructorService.getMyAllStudents(request.user.id);
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided');
return await CoursesInstructorService.searchInstructors({ token, query, course_id: courseId });
}
/**
@ -91,7 +83,11 @@ export class CoursesInstructorController {
@Response('401', 'Invalid or expired token')
@Response('404', 'Course not found')
public async getMyCourse(@Request() request: any, @Path() courseId: number): Promise<GetMyCourseResponse> {
return await CoursesInstructorService.getmyCourse({ userId: request.user.id, course_id: courseId });
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) {
throw new ValidationError('No token provided');
}
return await CoursesInstructorService.getmyCourse({ token, course_id: courseId });
}
/**
@ -105,10 +101,13 @@ export class CoursesInstructorController {
@Response('401', 'Invalid or expired token')
@Response('404', 'Course not found')
public async updateCourse(@Request() request: any, @Path() courseId: number, @Body() body: UpdateMyCourse): Promise<UpdateMyCourseResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided');
const { error } = UpdateCourseValidator.validate(body.data);
if (error) throw new ValidationError(error.details[0].message);
return await CoursesInstructorService.updateCourse(request.user.id, courseId, body.data);
return await CoursesInstructorService.updateCourse(token, courseId, body.data);
}
/**
@ -127,6 +126,10 @@ export class CoursesInstructorController {
@FormField() data: string,
@UploadedFile() thumbnail?: Express.Multer.File
): Promise<createCourseResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided');
const decoded = jwt.verify(token, config.jwt.secret) as { id: number };
const parsed = JSON.parse(data);
const { error, value } = CreateCourseValidator.validate(parsed);
if (error) throw new ValidationError(error.details[0].message);
@ -134,7 +137,7 @@ export class CoursesInstructorController {
// Validate thumbnail file type if provided
if (thumbnail && !thumbnail.mimetype?.startsWith('image/')) throw new ValidationError('Only image files are allowed for thumbnail');
return await CoursesInstructorService.createCourse(value, request.user.id, thumbnail);
return await CoursesInstructorService.createCourse(value, decoded.id, thumbnail);
}
/**
@ -153,9 +156,11 @@ export class CoursesInstructorController {
@Path() courseId: number,
@UploadedFile() file: Express.Multer.File
): Promise<{ code: number; message: string; data: { course_id: number; thumbnail_url: string } }> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided');
if (!file.mimetype?.startsWith('image/')) throw new ValidationError('Only image files are allowed');
return await CoursesInstructorService.uploadThumbnail(request.user.id, courseId, file);
return await CoursesInstructorService.uploadThumbnail(token, courseId, file);
}
/**
@ -169,7 +174,9 @@ export class CoursesInstructorController {
@Response('401', 'Invalid or expired token')
@Response('404', 'Course not found')
public async deleteCourse(@Request() request: any, @Path() courseId: number): Promise<DeleteMyCourseResponse> {
return await CoursesInstructorService.deleteCourse(request.user.id, courseId);
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided')
return await CoursesInstructorService.deleteCourse(token, courseId);
}
/**
@ -189,11 +196,14 @@ export class CoursesInstructorController {
@Path() courseId: number,
@Body() body: { title: { th: string; en: string } }
): Promise<CloneCourseResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided');
const { error } = CloneCourseValidator.validate(body);
if (error) throw new ValidationError(error.details[0].message);
const result = await CoursesInstructorService.cloneCourse({
userId: request.user.id,
token,
course_id: courseId,
title: body.title
});
@ -210,7 +220,9 @@ export class CoursesInstructorController {
@Response('401', 'Invalid or expired token')
@Response('404', 'Course not found')
public async submitCourse(@Request() request: any, @Path() courseId: number): Promise<submitCourseResponse> {
return await CoursesInstructorService.sendCourseForReview({ userId: request.user.id, course_id: courseId });
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided')
return await CoursesInstructorService.sendCourseForReview({ token, course_id: courseId });
}
/**
@ -224,7 +236,9 @@ export class CoursesInstructorController {
@Response('401', 'Invalid or expired token')
@Response('404', 'Course not found')
public async setCourseDraft(@Request() request: any, @Path() courseId: number): Promise<setCourseDraftResponse> {
return await CoursesInstructorService.setCourseDraft({ userId: request.user.id, course_id: courseId });
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided')
return await CoursesInstructorService.setCourseDraft({ token, course_id: courseId });
}
/**
@ -239,7 +253,9 @@ export class CoursesInstructorController {
@Response('403', 'You are not an instructor of this course')
@Response('404', 'Course not found')
public async getCourseApprovals(@Request() request: any, @Path() courseId: number): Promise<GetCourseApprovalsResponse> {
return await CoursesInstructorService.getCourseApprovals(request.user.id, courseId);
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided')
return await CoursesInstructorService.getCourseApprovals(token, courseId);
}
/**
@ -253,7 +269,9 @@ export class CoursesInstructorController {
@Response('401', 'Invalid or expired token')
@Response('404', 'Instructors not found')
public async listInstructorCourses(@Request() request: any, @Path() courseId: number): Promise<listinstructorCourseResponse> {
return await CoursesInstructorService.listInstructorsOfCourse({ userId: request.user.id, course_id: courseId });
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided')
return await CoursesInstructorService.listInstructorsOfCourse({ token, course_id: courseId });
}
/**
@ -268,7 +286,9 @@ export class CoursesInstructorController {
@Response('401', 'Invalid or expired token')
@Response('404', 'Instructor not found')
public async addInstructor(@Request() request: any, @Path() courseId: number, @Path() emailOrUsername: string): Promise<addinstructorCourseResponse> {
return await CoursesInstructorService.addInstructorToCourse({ userId: request.user.id, course_id: courseId, email_or_username: emailOrUsername });
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided')
return await CoursesInstructorService.addInstructorToCourse({ token, course_id: courseId, email_or_username: emailOrUsername });
}
/**
@ -283,7 +303,9 @@ export class CoursesInstructorController {
@Response('401', 'Invalid or expired token')
@Response('404', 'Instructor not found')
public async removeInstructor(@Request() request: any, @Path() courseId: number, @Path() userId: number): Promise<removeinstructorCourseResponse> {
return await CoursesInstructorService.removeInstructorFromCourse({ userId: request.user.id, course_id: courseId, user_id: userId });
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided')
return await CoursesInstructorService.removeInstructorFromCourse({ token, course_id: courseId, user_id: userId });
}
/**
@ -298,7 +320,9 @@ export class CoursesInstructorController {
@Response('401', 'Invalid or expired token')
@Response('404', 'Primary instructor not found')
public async setPrimaryInstructor(@Request() request: any, @Path() courseId: number, @Path() userId: number): Promise<setprimaryCourseInstructorResponse> {
return await CoursesInstructorService.setPrimaryInstructor({ userId: request.user.id, course_id: courseId, user_id: userId });
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided')
return await CoursesInstructorService.setPrimaryInstructor({ token, course_id: courseId, user_id: userId });
}
/**
@ -323,8 +347,10 @@ export class CoursesInstructorController {
@Query() search?: string,
@Query() status?: 'ENROLLED' | 'COMPLETED'
): Promise<GetEnrolledStudentsResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided');
return await CoursesInstructorService.getEnrolledStudents({
userId: request.user.id,
token,
course_id: courseId,
page,
limit,
@ -350,8 +376,10 @@ export class CoursesInstructorController {
@Path() courseId: number,
@Path() studentId: number
): Promise<GetEnrolledStudentDetailResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided');
return await CoursesInstructorService.getEnrolledStudentDetail({
userId: request.user.id,
token,
course_id: courseId,
student_id: studentId,
});
@ -382,8 +410,10 @@ export class CoursesInstructorController {
@Query() search?: string,
@Query() isPassed?: boolean
): Promise<GetQuizScoresResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided');
return await CoursesInstructorService.getQuizScores({
userId: request.user.id,
token,
course_id: courseId,
lesson_id: lessonId,
page,
@ -412,8 +442,10 @@ export class CoursesInstructorController {
@Path() lessonId: number,
@Path() studentId: number
): Promise<GetQuizAttemptDetailResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided');
return await CoursesInstructorService.getQuizAttemptDetail({
userId: request.user.id,
token,
course_id: courseId,
lesson_id: lessonId,
student_id: studentId,
@ -435,6 +467,8 @@ export class CoursesInstructorController {
@Request() request: any,
@Path() courseId: number
): Promise<GetCourseApprovalHistoryResponse> {
return await CoursesInstructorService.getCourseApprovalHistory(request.user.id, courseId);
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided');
return await CoursesInstructorService.getCourseApprovalHistory(token, courseId);
}
}

View file

@ -36,7 +36,11 @@ export class CoursesStudentController {
@Response('404', 'Course not found')
@Response('409', 'Already enrolled in this course')
public async enrollCourse(@Request() request: any, @Path() courseId: number): Promise<EnrollCourseResponse> {
return await this.service.enrollCourse({ userId: request.user.id, course_id: courseId });
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) {
throw new ValidationError('No token provided');
}
return await this.service.enrollCourse({ token, course_id: courseId });
}
/**
@ -56,7 +60,11 @@ export class CoursesStudentController {
@Query() limit?: number,
@Query() status?: EnrollmentStatus
): Promise<ListEnrolledCoursesResponse> {
return await this.service.GetEnrolledCourses({ userId: request.user.id, page, limit, status });
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) {
throw new ValidationError('No token provided');
}
return await this.service.GetEnrolledCourses({ token, page, limit, status });
}
/**
@ -71,7 +79,11 @@ export class CoursesStudentController {
@Response('403', 'Not enrolled in this course')
@Response('404', 'Course not found')
public async getCourseLearning(@Request() request: any, @Path() courseId: number): Promise<GetCourseLearningResponse> {
return await this.service.getCourseLearning({ userId: request.user.id, course_id: courseId });
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) {
throw new ValidationError('No token provided');
}
return await this.service.getCourseLearning({ token, course_id: courseId });
}
/**
@ -91,7 +103,11 @@ export class CoursesStudentController {
@Path() courseId: number,
@Path() lessonId: number
): Promise<GetLessonContentResponse> {
return await this.service.getlessonContent({ userId: request.user.id, course_id: courseId, lesson_id: lessonId });
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) {
throw new ValidationError('No token provided');
}
return await this.service.getlessonContent({ token, course_id: courseId, lesson_id: lessonId });
}
/**
@ -110,7 +126,11 @@ export class CoursesStudentController {
@Path() courseId: number,
@Path() lessonId: number
): Promise<CheckLessonAccessResponse> {
return await this.service.checkAccessLesson({ userId: request.user.id, course_id: courseId, lesson_id: lessonId });
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) {
throw new ValidationError('No token provided');
}
return await this.service.checkAccessLesson({ token, course_id: courseId, lesson_id: lessonId });
}
/**
@ -129,12 +149,14 @@ export class CoursesStudentController {
@Path() lessonId: number,
@Body() body: SaveVideoProgressBody
): Promise<SaveVideoProgressResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided');
const { error } = SaveVideoProgressValidator.validate(body);
if (error) throw new ValidationError(error.details[0].message);
return await this.service.saveVideoProgress({
userId: request.user.id,
token,
lesson_id: lessonId,
video_progress_seconds: body.video_progress_seconds,
video_duration_seconds: body.video_duration_seconds,
@ -156,7 +178,11 @@ export class CoursesStudentController {
@Request() request: any,
@Path() lessonId: number
): Promise<GetVideoProgressResponse> {
return await this.service.getVideoProgress({ userId: request.user.id, lesson_id: lessonId });
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) {
throw new ValidationError('No token provided');
}
return await this.service.getVideoProgress({ token, lesson_id: lessonId });
}
/**
@ -176,7 +202,11 @@ export class CoursesStudentController {
@Path() courseId: number,
@Path() lessonId: number
): Promise<CompleteLessonResponse> {
return await this.service.completeLesson({ userId: request.user.id, lesson_id: lessonId });
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) {
throw new ValidationError('No token provided');
}
return await this.service.completeLesson({ token, lesson_id: lessonId });
}
/**
@ -197,12 +227,14 @@ export class CoursesStudentController {
@Path() lessonId: number,
@Body() body: SubmitQuizBody
): Promise<SubmitQuizResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided');
const { error } = SubmitQuizValidator.validate(body);
if (error) throw new ValidationError(error.details[0].message);
return await this.service.submitQuiz({
userId: request.user.id,
token,
course_id: courseId,
lesson_id: lessonId,
answers: body.answers,
@ -226,8 +258,12 @@ export class CoursesStudentController {
@Path() courseId: number,
@Path() lessonId: number
): Promise<GetQuizAttemptsResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) {
throw new ValidationError('No token provided');
}
return await this.service.getQuizAttempts({
userId: request.user.id,
token,
course_id: courseId,
lesson_id: lessonId,
});

View file

@ -42,6 +42,8 @@ export class LessonsController {
@Path() lessonId: number,
@UploadedFile() video: Express.Multer.File
): Promise<VideoOperationResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided');
if (!video) {
throw new ValidationError('Video file is required');
@ -55,7 +57,7 @@ export class LessonsController {
};
return await chaptersLessonService.uploadVideo({
userId: request.user.id,
token,
course_id: courseId,
lesson_id: lessonId,
video: videoInfo,
@ -85,6 +87,8 @@ export class LessonsController {
@Path() lessonId: number,
@UploadedFile() video: Express.Multer.File
): Promise<VideoOperationResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided');
if (!video) {
throw new ValidationError('Video file is required');
@ -98,7 +102,7 @@ export class LessonsController {
};
return await chaptersLessonService.updateVideo({
userId: request.user.id,
token,
course_id: courseId,
lesson_id: lessonId,
video: videoInfo,
@ -128,6 +132,8 @@ export class LessonsController {
@Path() lessonId: number,
@UploadedFile() attachment: Express.Multer.File
): Promise<AttachmentOperationResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided');
if (!attachment) {
throw new ValidationError('Attachment file is required');
@ -141,7 +147,7 @@ export class LessonsController {
};
return await chaptersLessonService.uploadAttachment({
userId: request.user.id,
token,
course_id: courseId,
lesson_id: lessonId,
attachment: attachmentInfo,
@ -171,9 +177,11 @@ export class LessonsController {
@Path() lessonId: number,
@Path() attachmentId: number
): Promise<DeleteAttachmentResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided');
return await chaptersLessonService.deleteAttachment({
userId: request.user.id,
token,
course_id: courseId,
lesson_id: lessonId,
attachment_id: attachmentId,
@ -203,12 +211,14 @@ export class LessonsController {
@Path() lessonId: number,
@Body() body: SetYouTubeVideoBody
): Promise<YouTubeVideoResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided');
const { error } = SetYouTubeVideoValidator.validate(body);
if (error) throw new ValidationError(error.details[0].message);
return await chaptersLessonService.setYouTubeVideo({
userId: request.user.id,
token,
course_id: courseId,
lesson_id: lessonId,
youtube_video_id: body.youtube_video_id,

View file

@ -1,4 +1,5 @@
import { Get, Path, Put, Query, Request, Response, Route, Security, SuccessResponse, Tags } from 'tsoa';
import { ValidationError } from '../middleware/errorHandler';
import { RecommendedCoursesService } from '../services/RecommendedCourses.service';
import {
ListApprovedCoursesResponse,
@ -24,7 +25,9 @@ export class RecommendedCoursesController {
@Query() search?: string,
@Query() categoryId?: number
): Promise<ListApprovedCoursesResponse> {
return await RecommendedCoursesService.listApprovedCourses(request.user.id, { search, categoryId });
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided');
return await RecommendedCoursesService.listApprovedCourses(token, { search, categoryId });
}
/**
@ -40,7 +43,9 @@ export class RecommendedCoursesController {
@Response('403', 'Forbidden - Admin only')
@Response('404', 'Course not found')
public async getCourseById(@Request() request: any, @Path() courseId: number): Promise<GetCourseByIdResponse> {
return await RecommendedCoursesService.getCourseById(request.user.id, courseId);
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided');
return await RecommendedCoursesService.getCourseById(token, courseId);
}
/**
@ -60,6 +65,8 @@ export class RecommendedCoursesController {
@Path() courseId: number,
@Query() is_recommended: boolean
): Promise<ToggleRecommendedResponse> {
return await RecommendedCoursesService.toggleRecommended(request.user.id, courseId, is_recommended);
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided');
return await RecommendedCoursesService.toggleRecommended(token, courseId, is_recommended);
}
}

View file

@ -1,10 +1,12 @@
import { Get, Body, Post, Route, Tags, SuccessResponse, Response, Security, Request, Put, UploadedFile } from 'tsoa';
import { Get, Body, Post, Route, Tags, SuccessResponse, Response, Example, Controller, Security, Request, Put, UploadedFile } from 'tsoa';
import { ValidationError } from '../middleware/errorHandler';
import { UserService } from '../services/user.service';
import {
UserResponse,
ProfileResponse,
ProfileUpdate,
ProfileUpdateResponse,
ChangePasswordRequest,
ChangePasswordResponse,
updateAvatarResponse,
SendVerifyEmailResponse,
@ -21,6 +23,8 @@ export class UserController {
/**
* Get current user profile
* @summary Retrieve authenticated user's profile information
* @param request Express request object with JWT token in Authorization header
*/
@Get('me')
@SuccessResponse('200', 'User found')
@ -28,7 +32,12 @@ export class UserController {
@Response('401', 'Invalid or expired token')
@Security('jwt')
public async getMe(@Request() request: any): Promise<UserResponse> {
return await this.userService.getUserProfile(request.user.id);
// Extract token from Authorization header
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) {
throw new ValidationError('No token provided');
}
return await this.userService.getUserProfile(token);
}
@Put('me')
@ -38,20 +47,34 @@ export class UserController {
@Response('400', 'Validation error')
public async updateProfile(@Request() request: any, @Body() body: ProfileUpdate): Promise<ProfileUpdateResponse> {
const { error } = profileUpdateSchema.validate(body);
if (error) throw new ValidationError(error.details[0].message);
return await this.userService.updateProfile(request.user.id, body);
if (error) {
throw new ValidationError(error.details[0].message);
}
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) {
throw new ValidationError('No token provided');
}
return await this.userService.updateProfile(token, body);
}
@Get('roles')
@Security('jwt')
@SuccessResponse('200', 'Roles retrieved successfully')
@Response('401', 'Invalid or expired token')
public async getRoles(): Promise<rolesResponse> {
return await this.userService.getRoles();
public async getRoles(@Request() request: any): Promise<rolesResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) {
throw new ValidationError('No token provided');
}
return await this.userService.getRoles(token);
}
/**
* Change password
* @summary Change user password using old password
* @param request Express request object with JWT token in Authorization header
* @param body Old password and new password
* @returns Success message
*/
@Post('change-password')
@Security('jwt')
@ -60,12 +83,22 @@ export class UserController {
@Response('400', 'Validation error')
public async changePassword(@Request() request: any, @Body() body: ChangePassword): Promise<ChangePasswordResponse> {
const { error } = changePasswordSchema.validate(body);
if (error) throw new ValidationError(error.details[0].message);
return await this.userService.changePassword(request.user.id, body.oldPassword, body.newPassword);
if (error) {
throw new ValidationError(error.details[0].message);
}
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) {
throw new ValidationError('No token provided');
}
return await this.userService.changePassword(token, body.oldPassword, body.newPassword);
}
/**
* Upload user avatar picture
* @param request Express request object with JWT token in Authorization header
* @param file Avatar image file
*/
@Post('upload-avatar')
@Security('jwt')
@ -76,6 +109,9 @@ export class UserController {
@Request() request: any,
@UploadedFile() file: Express.Multer.File
): Promise<updateAvatarResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided');
// Validate file type (images only)
if (!file.mimetype?.startsWith('image/')) throw new ValidationError('Only image files are allowed');
@ -83,11 +119,13 @@ export class UserController {
const maxSize = 5 * 1024 * 1024; // 5MB
if (file.size > maxSize) throw new ValidationError('File size must be less than 5MB');
return await this.userService.uploadAvatarPicture(request.user.id, file);
return await this.userService.uploadAvatarPicture(token, file);
}
/**
* Send verification email to user
* @summary Send email verification link to authenticated user's email
* @param request Express request object with JWT token in Authorization header
*/
@Post('send-verify-email')
@Security('jwt')
@ -95,7 +133,9 @@ export class UserController {
@Response('401', 'Invalid or expired token')
@Response('400', 'Email already verified')
public async sendVerifyEmail(@Request() request: any): Promise<SendVerifyEmailResponse> {
return await this.userService.sendVerifyEmail(request.user.id);
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided');
return await this.userService.sendVerifyEmail(token);
}
/**

View file

@ -37,8 +37,10 @@ export class AnnouncementsController {
@Query() page?: number,
@Query() limit?: number
): Promise<ListAnnouncementResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided');
return await announcementsService.listAnnouncement({
userId: request.user.id,
token,
course_id: courseId,
page,
limit,
@ -61,6 +63,9 @@ export class AnnouncementsController {
@FormField() data: string,
@UploadedFiles() files?: Express.Multer.File[]
): Promise<CreateAnnouncementResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided');
// Parse JSON data field
const parsed = JSON.parse(data) as CreateAnnouncementBody;
@ -69,7 +74,7 @@ export class AnnouncementsController {
if (error) throw new ValidationError(error.details[0].message);
return await announcementsService.createAnnouncement({
userId: request.user.id,
token,
course_id: courseId,
title: parsed.title,
content: parsed.content,
@ -98,12 +103,15 @@ export class AnnouncementsController {
@Path() announcementId: number,
@Body() body: UpdateAnnouncementBody
): Promise<UpdateAnnouncementResponse> {
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({
userId: request.user.id,
token,
course_id: courseId,
announcement_id: announcementId,
title: body.title,
@ -131,8 +139,10 @@ export class AnnouncementsController {
@Path() courseId: number,
@Path() announcementId: number
): Promise<DeleteAnnouncementResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided');
return await announcementsService.deleteAnnouncement({
userId: request.user.id,
token,
course_id: courseId,
announcement_id: announcementId,
});
@ -156,8 +166,10 @@ export class AnnouncementsController {
@Path() announcementId: number,
@UploadedFile() file: Express.Multer.File
): Promise<UploadAnnouncementAttachmentResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided');
return await announcementsService.uploadAttachment({
userId: request.user.id,
token,
course_id: courseId,
announcement_id: announcementId,
file: file as any,
@ -183,8 +195,10 @@ export class AnnouncementsController {
@Path() announcementId: number,
@Path() attachmentId: number
): Promise<DeleteAnnouncementAttachmentResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided');
return await announcementsService.deleteAttachment({
userId: request.user.id,
token,
course_id: courseId,
announcement_id: announcementId,
attachment_id: attachmentId,
@ -214,8 +228,10 @@ export class AnnouncementsStudentController {
@Query() page?: number,
@Query() limit?: number
): Promise<ListAnnouncementResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided');
return await announcementsService.listAnnouncement({
userId: request.user.id,
token,
course_id: courseId,
page,
limit,

View file

@ -1,6 +1,8 @@
import { prisma } from '../config/database';
import { config } from '../config';
import { logger } from '../config/logger';
import { ValidationError, NotFoundError } from '../middleware/errorHandler';
import { UnauthorizedError, ValidationError, ForbiddenError, NotFoundError } from '../middleware/errorHandler';
import jwt from 'jsonwebtoken';
import { getPresignedUrl } from '../config/minio';
import {
ListPendingCoursesResponse,
@ -16,7 +18,7 @@ export class AdminCourseApprovalService {
/**
* Get all pending courses for admin review
*/
static async listPendingCourses(userId: number): Promise<ListPendingCoursesResponse> {
static async listPendingCourses(token: string): Promise<ListPendingCoursesResponse> {
try {
const courses = await prisma.course.findMany({
where: { status: 'PENDING' },
@ -94,8 +96,9 @@ 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,
userId: decoded?.id || 0,
action: AuditAction.ERROR,
entityType: 'Course',
entityId: 0,
@ -110,7 +113,7 @@ export class AdminCourseApprovalService {
/**
* Get course details for admin review
*/
static async getCourseDetail(userId: number, courseId: number): Promise<GetCourseDetailForAdminResponse> {
static async getCourseDetail(token: string, courseId: number): Promise<GetCourseDetailForAdminResponse> {
try {
const course = await prisma.course.findUnique({
where: { id: courseId },
@ -225,8 +228,9 @@ 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,
userId: decoded?.id || 0,
action: AuditAction.ERROR,
entityType: 'Course',
entityId: courseId,
@ -241,8 +245,9 @@ export class AdminCourseApprovalService {
/**
* Approve a course
*/
static async approveCourse(userId: number, courseId: number, comment?: string): Promise<ApproveCourseResponse> {
static async approveCourse(token: string, courseId: number, comment?: string): Promise<ApproveCourseResponse> {
try {
const decoded = jwt.verify(token, config.jwt.secret) as { id: number };
const course = await prisma.course.findUnique({ where: { id: courseId } });
if (!course) {
@ -259,7 +264,7 @@ export class AdminCourseApprovalService {
where: { id: courseId },
data: {
status: 'APPROVED',
approved_by: userId,
approved_by: decoded.id,
approved_at: new Date()
}
}),
@ -268,7 +273,7 @@ export class AdminCourseApprovalService {
data: {
course_id: courseId,
submitted_by: course.created_by,
reviewed_by: userId,
reviewed_by: decoded.id,
action: 'APPROVED',
previous_status: course.status,
new_status: 'APPROVED',
@ -279,7 +284,7 @@ export class AdminCourseApprovalService {
// Audit log - APPROVE_COURSE
await auditService.logSync({
userId,
userId: decoded.id,
action: AuditAction.APPROVE_COURSE,
entityType: 'Course',
entityId: courseId,
@ -294,8 +299,9 @@ 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,
userId: decoded?.id || 0,
action: AuditAction.ERROR,
entityType: 'Course',
entityId: courseId,
@ -311,8 +317,9 @@ export class AdminCourseApprovalService {
/**
* Reject a course
*/
static async rejectCourse(userId: number, courseId: number, comment: string): Promise<RejectCourseResponse> {
static async rejectCourse(token: string, courseId: number, comment: string): Promise<RejectCourseResponse> {
try {
const decoded = jwt.verify(token, config.jwt.secret) as { id: number };
const course = await prisma.course.findUnique({ where: { id: courseId } });
if (!course) {
@ -343,7 +350,7 @@ export class AdminCourseApprovalService {
data: {
course_id: courseId,
submitted_by: course.created_by,
reviewed_by: userId,
reviewed_by: decoded.id,
action: 'REJECTED',
previous_status: course.status,
new_status: 'REJECTED',
@ -354,7 +361,7 @@ export class AdminCourseApprovalService {
// Audit log - REJECT_COURSE
await auditService.logSync({
userId,
userId: decoded.id,
action: AuditAction.REJECT_COURSE,
entityType: 'Course',
entityId: courseId,
@ -369,8 +376,9 @@ 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,
userId: decoded?.id || 0,
action: AuditAction.ERROR,
entityType: 'Course',
entityId: courseId,

View file

@ -59,11 +59,14 @@ import { AuditAction } from '@prisma/client';
* Course ( Instructor Student)
* Returns: { hasAccess: boolean, role: 'INSTRUCTOR' | 'STUDENT' | null, userId: number }
*/
async function validateCourseAccess(userId: number, course_id: number): Promise<{
async function validateCourseAccess(token: string, course_id: number): Promise<{
hasAccess: boolean;
role: 'INSTRUCTOR' | 'STUDENT' | null;
userId: number;
}> {
const decodedToken = jwt.verify(token, config.jwt.secret) as { id: number };
const userId = decodedToken.id;
const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user) {
throw new UnauthorizedError('Invalid token');
@ -95,8 +98,9 @@ async function validateCourseAccess(userId: number, course_id: number): Promise<
export class ChaptersLessonService {
async listChapters(request: ChaptersRequest): Promise<ListChaptersResponse> {
try {
const { userId, course_id } = request;
const user = await prisma.user.findUnique({ where: { id: userId } });
const { token, course_id } = request;
const decodedToken = jwt.verify(token, config.jwt.secret) as { id: number };
const user = await prisma.user.findUnique({ where: { id: decodedToken.id } });
if (!user) {
throw new UnauthorizedError('Invalid token');
}
@ -113,13 +117,14 @@ export class ChaptersLessonService {
async createChapter(request: CreateChapterInput): Promise<CreateChapterResponse> {
try {
const { userId, course_id, title, description, sort_order } = request;
const { token, course_id, title, description, sort_order } = request;
const decodedToken = jwt.verify(token, config.jwt.secret) as { id: number };
await CoursesInstructorService.validateCourseStatus(course_id);
const user = await prisma.user.findUnique({ where: { id: userId } });
const user = await prisma.user.findUnique({ where: { id: decodedToken.id } });
if (!user) {
throw new UnauthorizedError('Invalid token');
}
const courseInstructor = await CoursesInstructorService.validateCourseInstructor(userId, course_id);
const courseInstructor = await CoursesInstructorService.validateCourseInstructor(token, course_id);
if (!courseInstructor) {
throw new ForbiddenError('You are not permitted to create chapter');
}
@ -127,7 +132,7 @@ export class ChaptersLessonService {
// Audit log - CREATE Chapter
auditService.log({
userId: userId,
userId: decodedToken.id,
action: AuditAction.CREATE,
entityType: 'Chapter',
entityId: chapter.id,
@ -137,8 +142,9 @@ 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: request.userId || 0,
userId: decodedToken?.id || 0,
action: AuditAction.ERROR,
entityType: 'Chapter',
entityId: 0,
@ -153,13 +159,14 @@ export class ChaptersLessonService {
async updateChapter(request: UpdateChapterInput): Promise<UpdateChapterResponse> {
try {
const { userId, course_id, chapter_id, title, description, sort_order } = request;
const { token, course_id, chapter_id, title, description, sort_order } = request;
const decodedToken = jwt.verify(token, config.jwt.secret) as { id: number };
await CoursesInstructorService.validateCourseStatus(course_id);
const user = await prisma.user.findUnique({ where: { id: userId } });
const user = await prisma.user.findUnique({ where: { id: decodedToken.id } });
if (!user) {
throw new UnauthorizedError('Invalid token');
}
const courseInstructor = await CoursesInstructorService.validateCourseInstructor(userId, course_id);
const courseInstructor = await CoursesInstructorService.validateCourseInstructor(token, course_id);
if (!courseInstructor) {
throw new ForbiddenError('You are not permitted to update chapter');
}
@ -167,8 +174,9 @@ 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: request.userId || 0,
userId: decodedToken?.id || 0,
action: AuditAction.ERROR,
entityType: 'Chapter',
entityId: request.chapter_id,
@ -183,13 +191,14 @@ export class ChaptersLessonService {
async deleteChapter(request: DeleteChapterRequest): Promise<DeleteChapterResponse> {
try {
const { userId, course_id, chapter_id } = request;
const { token, course_id, chapter_id } = request;
const decodedToken = jwt.verify(token, config.jwt.secret) as { id: number };
await CoursesInstructorService.validateCourseStatus(course_id);
const user = await prisma.user.findUnique({ where: { id: userId } });
const user = await prisma.user.findUnique({ where: { id: decodedToken.id } });
if (!user) {
throw new UnauthorizedError('Invalid token');
}
const courseInstructor = await CoursesInstructorService.validateCourseInstructor(userId, course_id);
const courseInstructor = await CoursesInstructorService.validateCourseInstructor(token, course_id);
if (!courseInstructor) {
throw new ForbiddenError('You are not permitted to delete chapter');
}
@ -197,7 +206,7 @@ export class ChaptersLessonService {
// Audit log - DELETE Chapter
auditService.log({
userId: userId,
userId: decodedToken.id,
action: AuditAction.DELETE,
entityType: 'Chapter',
entityId: chapter_id,
@ -210,8 +219,9 @@ 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: request.userId || 0,
userId: decodedToken?.id || 0,
action: AuditAction.ERROR,
entityType: 'Chapter',
entityId: request.chapter_id,
@ -226,13 +236,14 @@ export class ChaptersLessonService {
async reorderChapter(request: ReorderChapterRequest): Promise<ReorderChapterResponse> {
try {
const { userId, course_id, chapter_id, sort_order: newSortOrder } = request;
const { token, course_id, chapter_id, sort_order: newSortOrder } = request;
const decodedToken = jwt.verify(token, config.jwt.secret) as { id: number };
await CoursesInstructorService.validateCourseStatus(course_id);
const user = await prisma.user.findUnique({ where: { id: userId } });
const user = await prisma.user.findUnique({ where: { id: decodedToken.id } });
if (!user) {
throw new UnauthorizedError('Invalid token');
}
const courseInstructor = await CoursesInstructorService.validateCourseInstructor(userId, course_id);
const courseInstructor = await CoursesInstructorService.validateCourseInstructor(token, course_id);
if (!courseInstructor) {
throw new ForbiddenError('You are not permitted to reorder chapter');
}
@ -302,8 +313,9 @@ 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: request.userId || 0,
userId: decodedToken?.id || 0,
action: AuditAction.ERROR,
entityType: 'Chapter',
entityId: request.chapter_id,
@ -323,13 +335,14 @@ export class ChaptersLessonService {
*/
async createLesson(request: CreateLessonInput): Promise<CreateLessonResponse> {
try {
const { userId, course_id, chapter_id, title, content, type, sort_order } = request;
const { token, course_id, chapter_id, title, content, type, sort_order } = request;
const decodedToken = jwt.verify(token, config.jwt.secret) as { id: number };
await CoursesInstructorService.validateCourseStatus(course_id);
const user = await prisma.user.findUnique({ where: { id: userId } });
const user = await prisma.user.findUnique({ where: { id: decodedToken.id } });
if (!user) {
throw new UnauthorizedError('Invalid token');
}
const courseInstructor = await CoursesInstructorService.validateCourseInstructor(userId, course_id);
const courseInstructor = await CoursesInstructorService.validateCourseInstructor(token, course_id);
if (!courseInstructor) {
throw new ForbiddenError('You are not permitted to create lesson');
}
@ -341,6 +354,7 @@ export class ChaptersLessonService {
// If QUIZ type, create empty Quiz shell
if (type === 'QUIZ') {
const userId = decodedToken.id;
await prisma.quiz.create({
data: {
@ -362,7 +376,7 @@ export class ChaptersLessonService {
// Audit log - CREATE Lesson (QUIZ)
auditService.log({
userId: userId,
userId: decodedToken.id,
action: AuditAction.CREATE,
entityType: 'Lesson',
entityId: lesson.id,
@ -374,7 +388,7 @@ export class ChaptersLessonService {
// Audit log - CREATE Lesson
auditService.log({
userId: userId,
userId: decodedToken.id,
action: AuditAction.CREATE,
entityType: 'Lesson',
entityId: lesson.id,
@ -384,8 +398,9 @@ 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: request.userId || 0,
userId: decodedToken?.id || 0,
action: AuditAction.ERROR,
entityType: 'Lesson',
entityId: 0,
@ -404,10 +419,10 @@ export class ChaptersLessonService {
*/
async getLesson(request: GetLessonRequest): Promise<GetLessonResponse> {
try {
const { userId, course_id, lesson_id } = request;
const { token, course_id, lesson_id } = request;
// Check access for both instructor and enrolled student
const access = await validateCourseAccess(userId, course_id);
const access = await validateCourseAccess(token, course_id);
if (!access.hasAccess) {
throw new ForbiddenError('You do not have access to this course');
}
@ -534,8 +549,9 @@ 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: request.userId || 0,
userId: decodedToken?.id || 0,
action: AuditAction.ERROR,
entityType: 'Lesson',
entityId: request.lesson_id,
@ -550,13 +566,14 @@ export class ChaptersLessonService {
async updateLesson(request: UpdateLessonRequest): Promise<UpdateLessonResponse> {
try {
const { userId, course_id, lesson_id, data } = request;
const { token, course_id, lesson_id, data } = request;
const decodedToken = jwt.verify(token, config.jwt.secret) as { id: number };
await CoursesInstructorService.validateCourseStatus(course_id);
const user = await prisma.user.findUnique({ where: { id: userId } });
const user = await prisma.user.findUnique({ where: { id: decodedToken.id } });
if (!user) {
throw new UnauthorizedError('Invalid token');
}
const courseInstructor = await CoursesInstructorService.validateCourseInstructor(userId, course_id);
const courseInstructor = await CoursesInstructorService.validateCourseInstructor(token, course_id);
if (!courseInstructor) {
throw new ForbiddenError('You are not permitted to update lesson');
}
@ -564,8 +581,9 @@ 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: request.userId || 0,
userId: decodedToken?.id || 0,
action: AuditAction.ERROR,
entityType: 'Lesson',
entityId: request.lesson_id,
@ -584,13 +602,14 @@ export class ChaptersLessonService {
*/
async reorderLessons(request: ReorderLessonsRequest): Promise<ReorderLessonsResponse> {
try {
const { userId, course_id, chapter_id, lesson_id, sort_order: newSortOrder } = request;
const { token, course_id, chapter_id, lesson_id, sort_order: newSortOrder } = request;
const decodedToken = jwt.verify(token, config.jwt.secret) as { id: number };
await CoursesInstructorService.validateCourseStatus(course_id);
const user = await prisma.user.findUnique({ where: { id: userId } });
const user = await prisma.user.findUnique({ where: { id: decodedToken.id } });
if (!user) throw new UnauthorizedError('Invalid token');
const courseInstructor = await CoursesInstructorService.validateCourseInstructor(userId, course_id);
const courseInstructor = await CoursesInstructorService.validateCourseInstructor(token, course_id);
if (!courseInstructor) throw new ForbiddenError('You are not permitted to reorder lessons');
// Verify chapter exists and belongs to the course
@ -663,8 +682,9 @@ 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: request.userId || 0,
userId: decodedToken?.id || 0,
action: AuditAction.ERROR,
entityType: 'Lesson',
entityId: request.lesson_id,
@ -684,13 +704,14 @@ export class ChaptersLessonService {
*/
async deleteLesson(request: DeleteLessonRequest): Promise<DeleteLessonResponse> {
try {
const { userId, course_id, lesson_id } = request;
const { token, course_id, lesson_id } = request;
const decodedToken = jwt.verify(token, config.jwt.secret) as { id: number };
await CoursesInstructorService.validateCourseStatus(course_id);
const user = await prisma.user.findUnique({ where: { id: userId } });
const user = await prisma.user.findUnique({ where: { id: decodedToken.id } });
if (!user) throw new UnauthorizedError('Invalid token');
const courseInstructor = await CoursesInstructorService.validateCourseInstructor(userId, course_id);
const courseInstructor = await CoursesInstructorService.validateCourseInstructor(token, course_id);
if (!courseInstructor) throw new ForbiddenError('You are not permitted to delete this lesson');
// Fetch lesson with all related data
@ -730,7 +751,7 @@ export class ChaptersLessonService {
// Audit log - DELETE Lesson
auditService.log({
userId: userId,
userId: decodedToken.id,
action: AuditAction.DELETE,
entityType: 'Lesson',
entityId: lesson_id,
@ -743,8 +764,9 @@ 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: request.userId || 0,
userId: decodedToken?.id || 0,
action: AuditAction.ERROR,
entityType: 'Lesson',
entityId: request.lesson_id,
@ -767,13 +789,14 @@ export class ChaptersLessonService {
*/
async uploadVideo(request: UploadVideoInput): Promise<VideoOperationResponse> {
try {
const { userId, course_id, lesson_id, video } = request;
const { token, course_id, lesson_id, video } = request;
const decodedToken = jwt.verify(token, config.jwt.secret) as { id: number };
await CoursesInstructorService.validateCourseStatus(course_id);
const user = await prisma.user.findUnique({ where: { id: userId } });
const user = await prisma.user.findUnique({ where: { id: decodedToken.id } });
if (!user) throw new UnauthorizedError('Invalid token');
const courseInstructor = await CoursesInstructorService.validateCourseInstructor(userId, course_id);
const courseInstructor = await CoursesInstructorService.validateCourseInstructor(token, course_id);
if (!courseInstructor) throw new ForbiddenError('You are not permitted to modify this lesson');
// Verify lesson exists and is VIDEO type
@ -810,7 +833,7 @@ export class ChaptersLessonService {
// Audit log - UPLOAD_FILE (Video)
auditService.log({
userId: userId,
userId: decodedToken.id,
action: AuditAction.UPLOAD_FILE,
entityType: 'Lesson',
entityId: lesson_id,
@ -830,8 +853,9 @@ 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: request.userId || 0,
userId: decodedToken?.id || 0,
action: AuditAction.ERROR,
entityType: 'Lesson',
entityId: request.lesson_id,
@ -850,13 +874,14 @@ export class ChaptersLessonService {
*/
async updateVideo(request: UpdateVideoInput): Promise<VideoOperationResponse> {
try {
const { userId, course_id, lesson_id, video } = request;
const { token, course_id, lesson_id, video } = request;
const decodedToken = jwt.verify(token, config.jwt.secret) as { id: number };
await CoursesInstructorService.validateCourseStatus(course_id);
const user = await prisma.user.findUnique({ where: { id: userId } });
const user = await prisma.user.findUnique({ where: { id: decodedToken.id } });
if (!user) throw new UnauthorizedError('Invalid token');
const courseInstructor = await CoursesInstructorService.validateCourseInstructor(userId, course_id);
const courseInstructor = await CoursesInstructorService.validateCourseInstructor(token, course_id);
if (!courseInstructor) throw new ForbiddenError('You are not permitted to modify this lesson');
// Verify lesson exists and is VIDEO type
@ -921,8 +946,9 @@ 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: request.userId || 0,
userId: decodedToken?.id || 0,
action: AuditAction.ERROR,
entityType: 'Lesson',
entityId: request.lesson_id,
@ -941,13 +967,14 @@ export class ChaptersLessonService {
*/
async setYouTubeVideo(request: SetYouTubeVideoInput): Promise<YouTubeVideoResponse> {
try {
const { userId, course_id, lesson_id, youtube_video_id, video_title } = request;
const { token, course_id, lesson_id, youtube_video_id, video_title } = request;
const decodedToken = jwt.verify(token, config.jwt.secret) as { id: number };
await CoursesInstructorService.validateCourseStatus(course_id);
const user = await prisma.user.findUnique({ where: { id: userId } });
const user = await prisma.user.findUnique({ where: { id: decodedToken.id } });
if (!user) throw new UnauthorizedError('Invalid token');
const courseInstructor = await CoursesInstructorService.validateCourseInstructor(userId, course_id);
const courseInstructor = await CoursesInstructorService.validateCourseInstructor(token, course_id);
if (!courseInstructor) throw new ForbiddenError('You are not permitted to modify this lesson');
// Verify lesson exists and is VIDEO type
@ -1011,8 +1038,9 @@ 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: request.userId || 0,
userId: decodedToken?.id || 0,
action: AuditAction.ERROR,
entityType: 'Lesson',
entityId: request.lesson_id,
@ -1031,13 +1059,14 @@ export class ChaptersLessonService {
*/
async uploadAttachment(request: UploadAttachmentInput): Promise<AttachmentOperationResponse> {
try {
const { userId, course_id, lesson_id, attachment } = request;
const { token, course_id, lesson_id, attachment } = request;
const decodedToken = jwt.verify(token, config.jwt.secret) as { id: number };
await CoursesInstructorService.validateCourseStatus(course_id);
const user = await prisma.user.findUnique({ where: { id: userId } });
const user = await prisma.user.findUnique({ where: { id: decodedToken.id } });
if (!user) throw new UnauthorizedError('Invalid token');
const courseInstructor = await CoursesInstructorService.validateCourseInstructor(userId, course_id);
const courseInstructor = await CoursesInstructorService.validateCourseInstructor(token, course_id);
if (!courseInstructor) throw new ForbiddenError('You are not permitted to modify this lesson');
// Verify lesson exists
@ -1072,7 +1101,7 @@ export class ChaptersLessonService {
// Audit log - UPLOAD_FILE (Attachment)
auditService.log({
userId: userId,
userId: decodedToken.id,
action: AuditAction.UPLOAD_FILE,
entityType: 'LessonAttachment',
entityId: newAttachment.id,
@ -1096,8 +1125,9 @@ 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: request.userId || 0,
userId: decodedToken?.id || 0,
action: AuditAction.ERROR,
entityType: 'LessonAttachment',
entityId: request.lesson_id,
@ -1116,13 +1146,14 @@ export class ChaptersLessonService {
*/
async deleteAttachment(request: DeleteAttachmentInput): Promise<DeleteAttachmentResponse> {
try {
const { userId, course_id, lesson_id, attachment_id } = request;
const { token, course_id, lesson_id, attachment_id } = request;
const decodedToken = jwt.verify(token, config.jwt.secret) as { id: number };
await CoursesInstructorService.validateCourseStatus(course_id);
const user = await prisma.user.findUnique({ where: { id: userId } });
const user = await prisma.user.findUnique({ where: { id: decodedToken.id } });
if (!user) throw new UnauthorizedError('Invalid token');
const courseInstructor = await CoursesInstructorService.validateCourseInstructor(userId, course_id);
const courseInstructor = await CoursesInstructorService.validateCourseInstructor(token, course_id);
if (!courseInstructor) throw new ForbiddenError('You are not permitted to modify this lesson');
// Verify lesson exists
@ -1153,7 +1184,7 @@ export class ChaptersLessonService {
// Audit log - DELETE_FILE (Attachment)
auditService.log({
userId: userId,
userId: decodedToken.id,
action: AuditAction.DELETE_FILE,
entityType: 'LessonAttachment',
entityId: attachment_id,
@ -1163,8 +1194,9 @@ 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: request.userId || 0,
userId: decodedToken?.id || 0,
action: AuditAction.ERROR,
entityType: 'LessonAttachment',
entityId: request.attachment_id,
@ -1184,13 +1216,14 @@ export class ChaptersLessonService {
*/
async addQuestion(request: AddQuestionInput): Promise<AddQuestionResponse> {
try {
const { userId, course_id, lesson_id, question, explanation, question_type, sort_order, choices } = request;
const { token, course_id, lesson_id, question, explanation, question_type, sort_order, choices } = request;
const decodedToken = jwt.verify(token, config.jwt.secret) as { id: number };
await CoursesInstructorService.validateCourseStatus(course_id);
const user = await prisma.user.findUnique({ where: { id: userId } });
const user = await prisma.user.findUnique({ where: { id: decodedToken.id } });
if (!user) throw new UnauthorizedError('Invalid token');
const courseInstructor = await CoursesInstructorService.validateCourseInstructor(userId, course_id);
const courseInstructor = await CoursesInstructorService.validateCourseInstructor(token, course_id);
if (!courseInstructor) throw new ForbiddenError('You are not permitted to modify this lesson');
// Verify lesson exists and is QUIZ type
@ -1248,8 +1281,9 @@ 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: request.userId || 0,
userId: decodedToken?.id || 0,
action: AuditAction.ERROR,
entityType: 'Question',
entityId: 0,
@ -1269,13 +1303,14 @@ export class ChaptersLessonService {
*/
async updateQuestion(request: UpdateQuestionInput): Promise<UpdateQuestionResponse> {
try {
const { userId, course_id, lesson_id, question_id, question, explanation, question_type, sort_order, choices } = request;
const { token, course_id, lesson_id, question_id, question, explanation, question_type, sort_order, choices } = request;
const decodedToken = jwt.verify(token, config.jwt.secret) as { id: number };
await CoursesInstructorService.validateCourseStatus(course_id);
const user = await prisma.user.findUnique({ where: { id: userId } });
const user = await prisma.user.findUnique({ where: { id: decodedToken.id } });
if (!user) throw new UnauthorizedError('Invalid token');
const courseInstructor = await CoursesInstructorService.validateCourseInstructor(userId, course_id);
const courseInstructor = await CoursesInstructorService.validateCourseInstructor(token, course_id);
if (!courseInstructor) throw new ForbiddenError('You are not permitted to modify this lesson');
// Verify lesson exists and is QUIZ type
@ -1332,8 +1367,9 @@ 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: request.userId || 0,
userId: decodedToken?.id || 0,
action: AuditAction.ERROR,
entityType: 'Question',
entityId: request.question_id,
@ -1348,13 +1384,14 @@ export class ChaptersLessonService {
async reorderQuestion(request: ReorderQuestionInput): Promise<ReorderQuestionResponse> {
try {
const { userId, course_id, lesson_id, question_id, sort_order } = request;
const { token, course_id, lesson_id, question_id, sort_order } = request;
const decodedToken = jwt.verify(token, config.jwt.secret) as { id: number };
await CoursesInstructorService.validateCourseStatus(course_id);
const user = await prisma.user.findUnique({ where: { id: userId } });
const user = await prisma.user.findUnique({ where: { id: decodedToken.id } });
if (!user) throw new UnauthorizedError('Invalid token');
const courseInstructor = await CoursesInstructorService.validateCourseInstructor(userId, course_id);
const courseInstructor = await CoursesInstructorService.validateCourseInstructor(token, course_id);
if (!courseInstructor) throw new ForbiddenError('You are not permitted to modify this lesson');
// Verify lesson exists and is QUIZ type
@ -1434,8 +1471,9 @@ 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: request.userId || 0,
userId: decodedToken?.id || 0,
action: AuditAction.ERROR,
entityType: 'Question',
entityId: request.question_id,
@ -1455,13 +1493,14 @@ export class ChaptersLessonService {
*/
async deleteQuestion(request: DeleteQuestionInput): Promise<DeleteQuestionResponse> {
try {
const { userId, course_id, lesson_id, question_id } = request;
const { token, course_id, lesson_id, question_id } = request;
const decodedToken = jwt.verify(token, config.jwt.secret) as { id: number };
await CoursesInstructorService.validateCourseStatus(course_id);
const user = await prisma.user.findUnique({ where: { id: userId } });
const user = await prisma.user.findUnique({ where: { id: decodedToken.id } });
if (!user) throw new UnauthorizedError('Invalid token');
const courseInstructor = await CoursesInstructorService.validateCourseInstructor(userId, course_id);
const courseInstructor = await CoursesInstructorService.validateCourseInstructor(token, course_id);
if (!courseInstructor) throw new ForbiddenError('You are not permitted to modify this lesson');
// Verify lesson exists and is QUIZ type
@ -1491,8 +1530,9 @@ 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: request.userId || 0,
userId: decodedToken?.id || 0,
action: AuditAction.ERROR,
entityType: 'Question',
entityId: request.question_id,
@ -1640,13 +1680,14 @@ export class ChaptersLessonService {
*/
async updateQuiz(request: UpdateQuizInput): Promise<UpdateQuizResponse> {
try {
const { userId, course_id, lesson_id, title, description, passing_score, time_limit, shuffle_questions, shuffle_choices, show_answers_after_completion, is_skippable, allow_multiple_attempts } = request;
const { token, course_id, lesson_id, title, description, passing_score, time_limit, shuffle_questions, shuffle_choices, show_answers_after_completion, is_skippable, allow_multiple_attempts } = request;
const decodedToken = jwt.verify(token, config.jwt.secret) as { id: number };
await CoursesInstructorService.validateCourseStatus(course_id);
const user = await prisma.user.findUnique({ where: { id: userId } });
const user = await prisma.user.findUnique({ where: { id: decodedToken.id } });
if (!user) throw new UnauthorizedError('Invalid token');
const courseInstructor = await CoursesInstructorService.validateCourseInstructor(userId, course_id);
const courseInstructor = await CoursesInstructorService.validateCourseInstructor(token, course_id);
if (!courseInstructor) throw new ForbiddenError('You are not permitted to modify this lesson');
// Verify lesson exists and is QUIZ type

View file

@ -1,7 +1,9 @@
import { prisma } from '../config/database';
import { Prisma } from '@prisma/client';
import { config } from '../config';
import { logger } from '../config/logger';
import { ValidationError, ForbiddenError, NotFoundError } from '../middleware/errorHandler';
import { UnauthorizedError, ValidationError, ForbiddenError, NotFoundError } from '../middleware/errorHandler';
import jwt from 'jsonwebtoken';
import { uploadFile, deleteFile, getPresignedUrl } from '../config/minio';
import {
CreateCourseInput,
@ -25,7 +27,6 @@ import {
SearchInstructorResponse,
GetEnrolledStudentsInput,
GetEnrolledStudentsResponse,
EnrolledStudentData,
GetQuizScoresInput,
GetQuizScoresResponse,
GetQuizAttemptDetailInput,
@ -37,7 +38,6 @@ import {
CloneCourseResponse,
setCourseDraft,
setCourseDraftResponse,
GetAllMyStudentsResponse,
} from "../types/CoursesInstructor.types";
import { auditService } from './audit.service';
import { AuditAction } from '@prisma/client';
@ -121,9 +121,10 @@ export class CoursesInstructorService {
static async listMyCourses(input: ListMyCoursesInput): Promise<ListMyCourseResponse> {
try {
const decoded = jwt.verify(input.token, config.jwt.secret) as { id: number; type: string };
const courseInstructors = await prisma.courseInstructor.findMany({
where: {
user_id: input.userId,
user_id: decoded.id,
course: input.status ? { status: input.status } : undefined
},
include: {
@ -156,8 +157,9 @@ 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: input.userId,
userId: decoded?.id || undefined,
action: AuditAction.ERROR,
entityType: 'Course',
entityId: 0,
@ -172,10 +174,12 @@ export class CoursesInstructorService {
static async getmyCourse(getmyCourse: getmyCourse): Promise<GetMyCourseResponse> {
try {
const decoded = jwt.verify(getmyCourse.token, config.jwt.secret) as { id: number; type: string };
// Check if user is instructor of this course
const courseInstructor = await prisma.courseInstructor.findFirst({
where: {
user_id: getmyCourse.userId,
user_id: decoded.id,
course_id: getmyCourse.course_id
},
include: {
@ -221,8 +225,9 @@ 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: getmyCourse.userId,
userId: decoded?.id || undefined,
action: AuditAction.ERROR,
entityType: 'Course',
entityId: getmyCourse.course_id,
@ -235,9 +240,9 @@ export class CoursesInstructorService {
}
}
static async updateCourse(userId: number, courseId: number, courseData: UpdateCourseInput): Promise<createCourseResponse> {
static async updateCourse(token: string, courseId: number, courseData: UpdateCourseInput): Promise<createCourseResponse> {
try {
await this.validateCourseInstructor(userId, courseId);
await this.validateCourseInstructor(token, courseId);
const course = await prisma.course.update({
where: {
@ -253,8 +258,9 @@ 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,
userId: decoded?.id || undefined,
action: AuditAction.ERROR,
entityType: 'Course',
entityId: courseId,
@ -267,9 +273,9 @@ export class CoursesInstructorService {
}
}
static async uploadThumbnail(userId: number, courseId: number, file: Express.Multer.File): Promise<{ code: number; message: string; data: { course_id: number; thumbnail_url: string } }> {
static async uploadThumbnail(token: string, courseId: number, file: Express.Multer.File): Promise<{ code: number; message: string; data: { course_id: number; thumbnail_url: string } }> {
try {
await this.validateCourseInstructor(userId, courseId);
await this.validateCourseInstructor(token, courseId);
// Get current course to check for existing thumbnail
const currentCourse = await prisma.course.findUnique({
@ -316,8 +322,9 @@ 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,
userId: decoded?.id || undefined,
action: AuditAction.ERROR,
entityType: 'Course',
entityId: courseId,
@ -330,9 +337,9 @@ export class CoursesInstructorService {
}
}
static async deleteCourse(userId: number, courseId: number): Promise<createCourseResponse> {
static async deleteCourse(token: string, courseId: number): Promise<createCourseResponse> {
try {
const courseInstructorId = await this.validateCourseInstructor(userId, courseId);
const courseInstructorId = await this.validateCourseInstructor(token, courseId);
if (!courseInstructorId.is_primary) {
throw new ForbiddenError('You have no permission to delete this course');
}
@ -358,8 +365,9 @@ 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,
userId: decoded?.id || undefined,
action: AuditAction.ERROR,
entityType: 'Course',
entityId: courseId,
@ -374,10 +382,11 @@ export class CoursesInstructorService {
static async sendCourseForReview(sendCourseForReview: sendCourseForReview): Promise<submitCourseResponse> {
try {
const decoded = jwt.verify(sendCourseForReview.token, config.jwt.secret) as { id: number; type: string };
await prisma.courseApproval.create({
data: {
course_id: sendCourseForReview.course_id,
submitted_by: sendCourseForReview.userId,
submitted_by: decoded.id,
}
});
await prisma.course.update({
@ -389,7 +398,7 @@ export class CoursesInstructorService {
}
});
await auditService.logSync({
userId: sendCourseForReview.userId,
userId: decoded.id,
action: AuditAction.UPDATE,
entityType: 'Course',
entityId: sendCourseForReview.course_id,
@ -403,8 +412,9 @@ 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: sendCourseForReview.userId,
userId: decoded?.id || undefined,
action: AuditAction.ERROR,
entityType: 'Course',
entityId: sendCourseForReview.course_id,
@ -419,7 +429,7 @@ export class CoursesInstructorService {
static async setCourseDraft(setCourseDraft: setCourseDraft): Promise<setCourseDraftResponse> {
try {
await this.validateCourseInstructor(setCourseDraft.userId, setCourseDraft.course_id);
await this.validateCourseInstructor(setCourseDraft.token, setCourseDraft.course_id);
await prisma.course.update({
where: {
id: setCourseDraft.course_id,
@ -435,8 +445,9 @@ 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: setCourseDraft.userId,
userId: decoded?.id || undefined,
action: AuditAction.ERROR,
entityType: 'Course',
entityId: setCourseDraft.course_id,
@ -449,7 +460,7 @@ export class CoursesInstructorService {
}
}
static async getCourseApprovals(userId: number, courseId: number): Promise<{
static async getCourseApprovals(token: string, courseId: number): Promise<{
code: number;
message: string;
data: any[];
@ -457,7 +468,7 @@ export class CoursesInstructorService {
}> {
try {
// Validate instructor access
await this.validateCourseInstructor(userId, courseId);
await this.validateCourseInstructor(token, courseId);
const approvals = await prisma.courseApproval.findMany({
where: { course_id: courseId },
@ -480,8 +491,9 @@ 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,
userId: decoded?.id || undefined,
action: AuditAction.ERROR,
entityType: 'Course',
entityId: courseId,
@ -498,6 +510,8 @@ export class CoursesInstructorService {
static async searchInstructors(input: SearchInstructorInput): Promise<SearchInstructorResponse> {
try {
const decoded = jwt.verify(input.token, config.jwt.secret) as { id: number };
// Get existing instructors in the course
const existingInstructors = await prisma.courseInstructor.findMany({
where: { course_id: input.course_id },
@ -514,7 +528,7 @@ export class CoursesInstructorService {
],
role: { code: 'INSTRUCTOR' },
id: {
notIn: [input.userId, ...existingInstructorIds],
notIn: [decoded.id, ...existingInstructorIds],
},
},
include: {
@ -549,8 +563,9 @@ 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: input.userId,
userId: decoded?.id || undefined,
action: AuditAction.ERROR,
entityType: 'Course',
entityId: input.course_id,
@ -566,7 +581,7 @@ export class CoursesInstructorService {
static async addInstructorToCourse(addinstructorCourse: addinstructorCourse): Promise<addinstructorCourseResponse> {
try {
// Validate user is instructor of this course
await this.validateCourseInstructor(addinstructorCourse.userId, addinstructorCourse.course_id);
await this.validateCourseInstructor(addinstructorCourse.token, addinstructorCourse.course_id);
// Find user by email or username
const user = await prisma.user.findFirst({
@ -604,8 +619,9 @@ export class CoursesInstructorService {
}
});
const decoded = jwt.decode(addinstructorCourse.token) as { id: number } | null;
await auditService.logSync({
userId: addinstructorCourse.userId,
userId: decoded?.id || 0,
action: AuditAction.CREATE,
entityType: 'Course',
entityId: addinstructorCourse.course_id,
@ -621,8 +637,9 @@ 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: addinstructorCourse.userId,
userId: decoded?.id || undefined,
action: AuditAction.ERROR,
entityType: 'Course',
entityId: addinstructorCourse.course_id,
@ -637,6 +654,7 @@ export class CoursesInstructorService {
static async removeInstructorFromCourse(removeinstructorCourse: removeinstructorCourse): Promise<removeinstructorCourseResponse> {
try {
const decoded = jwt.verify(removeinstructorCourse.token, config.jwt.secret) as { id: number; type: string };
await prisma.courseInstructor.delete({
where: {
course_id_user_id: {
@ -647,7 +665,7 @@ export class CoursesInstructorService {
});
await auditService.logSync({
userId: removeinstructorCourse.userId,
userId: decoded?.id || 0,
action: AuditAction.DELETE,
entityType: 'Course',
entityId: removeinstructorCourse.course_id,
@ -664,8 +682,9 @@ 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: removeinstructorCourse.userId,
userId: decoded?.id || undefined,
action: AuditAction.ERROR,
entityType: 'Course',
entityId: removeinstructorCourse.course_id,
@ -680,6 +699,7 @@ export class CoursesInstructorService {
static async listInstructorsOfCourse(listinstructorCourse: listinstructorCourse): Promise<listinstructorCourseResponse> {
try {
const decoded = jwt.verify(listinstructorCourse.token, config.jwt.secret) as { id: number; type: string };
const courseInstructors = await prisma.courseInstructor.findMany({
where: {
course_id: listinstructorCourse.course_id,
@ -723,8 +743,9 @@ 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: listinstructorCourse.userId,
userId: decoded?.id || undefined,
action: AuditAction.ERROR,
entityType: 'Course',
entityId: listinstructorCourse.course_id,
@ -739,6 +760,7 @@ export class CoursesInstructorService {
static async setPrimaryInstructor(setprimaryCourseInstructor: setprimaryCourseInstructor): Promise<setprimaryCourseInstructorResponse> {
try {
const decoded = jwt.verify(setprimaryCourseInstructor.token, config.jwt.secret) as { id: number; type: string };
await prisma.courseInstructor.update({
where: {
course_id_user_id: {
@ -752,7 +774,7 @@ export class CoursesInstructorService {
});
await auditService.logSync({
userId: setprimaryCourseInstructor.userId,
userId: decoded?.id || 0,
action: AuditAction.UPDATE,
entityType: 'Course',
entityId: setprimaryCourseInstructor.course_id,
@ -769,8 +791,9 @@ 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: setprimaryCourseInstructor.userId,
userId: decoded?.id || undefined,
action: AuditAction.ERROR,
entityType: 'Course',
entityId: setprimaryCourseInstructor.course_id,
@ -783,10 +806,11 @@ export class CoursesInstructorService {
}
}
static async validateCourseInstructor(userId: number, courseId: number): Promise<{ user_id: number; is_primary: boolean }> {
static async validateCourseInstructor(token: string, courseId: number): Promise<{ user_id: number; is_primary: boolean }> {
const decoded = jwt.verify(token, config.jwt.secret) as { id: number; type: string };
const courseInstructor = await prisma.courseInstructor.findFirst({
where: {
user_id: userId,
user_id: decoded.id,
course_id: courseId
}
});
@ -815,10 +839,10 @@ export class CoursesInstructorService {
*/
static async getEnrolledStudents(input: GetEnrolledStudentsInput): Promise<GetEnrolledStudentsResponse> {
try {
const { userId, course_id, page = 1, limit = 20, search, status } = input;
const { token, course_id, page = 1, limit = 20, search, status } = input;
// Validate instructor
await this.validateCourseInstructor(userId, course_id);
await this.validateCourseInstructor(token, course_id);
// Build where clause
const whereClause: any = { course_id };
@ -893,8 +917,9 @@ 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: input.userId,
userId: decoded?.id || undefined,
action: AuditAction.ERROR,
entityType: 'Course',
entityId: input.course_id,
@ -913,10 +938,11 @@ export class CoursesInstructorService {
*/
static async getQuizScores(input: GetQuizScoresInput): Promise<GetQuizScoresResponse> {
try {
const { userId, course_id, lesson_id, page = 1, limit = 20, search, is_passed } = input;
const { token, course_id, lesson_id, page = 1, limit = 20, search, is_passed } = input;
const decoded = jwt.verify(token, config.jwt.secret) as { id: number };
// Validate instructor
await this.validateCourseInstructor(userId, course_id);
await this.validateCourseInstructor(token, course_id);
// Get lesson and verify it's a QUIZ type
const lesson = await prisma.lesson.findUnique({
@ -1069,8 +1095,9 @@ 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: input.userId,
userId: decoded?.id || undefined,
action: AuditAction.ERROR,
entityType: 'Course',
entityId: input.course_id,
@ -1089,10 +1116,10 @@ export class CoursesInstructorService {
*/
static async getQuizAttemptDetail(input: GetQuizAttemptDetailInput): Promise<GetQuizAttemptDetailResponse> {
try {
const { userId, course_id, lesson_id, student_id } = input;
const { token, course_id, lesson_id, student_id } = input;
// Validate instructor
await this.validateCourseInstructor(userId, course_id);
await this.validateCourseInstructor(token, course_id);
// Get lesson and verify it's a QUIZ type
const lesson = await prisma.lesson.findUnique({
@ -1192,8 +1219,9 @@ 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: input.userId,
userId: decoded?.id || undefined,
action: AuditAction.ERROR,
entityType: 'Course',
entityId: input.course_id,
@ -1212,10 +1240,10 @@ export class CoursesInstructorService {
*/
static async getEnrolledStudentDetail(input: GetEnrolledStudentDetailInput): Promise<GetEnrolledStudentDetailResponse> {
try {
const { userId, course_id, student_id } = input;
const { token, course_id, student_id } = input;
// Validate instructor
await this.validateCourseInstructor(userId, course_id);
await this.validateCourseInstructor(token, course_id);
// Get student info
const student = await prisma.user.findUnique({
@ -1339,8 +1367,9 @@ 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: input.userId,
userId: decoded?.id || undefined,
action: AuditAction.ERROR,
entityType: 'Course',
entityId: input.course_id,
@ -1357,10 +1386,12 @@ export class CoursesInstructorService {
*
* Get course approval history for instructor to see rejection reasons
*/
static async getCourseApprovalHistory(userId: number, courseId: number): Promise<GetCourseApprovalHistoryResponse> {
static async getCourseApprovalHistory(token: string, courseId: number): Promise<GetCourseApprovalHistoryResponse> {
try {
const decoded = jwt.verify(token, config.jwt.secret) as { id: number };
// Validate instructor access
await this.validateCourseInstructor(userId, courseId);
await this.validateCourseInstructor(token, courseId);
// Get course with approval history
const course = await prisma.course.findUnique({
@ -1403,8 +1434,9 @@ 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,
userId: decoded?.id || undefined,
action: AuditAction.ERROR,
entityType: 'Course',
entityId: courseId,
@ -1422,10 +1454,11 @@ export class CoursesInstructorService {
*/
static async cloneCourse(input: CloneCourseInput): Promise<CloneCourseResponse> {
try {
const { userId, course_id, title } = input;
const { token, course_id, title } = input;
const decoded = jwt.verify(token, config.jwt.secret) as { id: number };
// Validate instructor
const courseInstructor = await this.validateCourseInstructor(userId, course_id);
const courseInstructor = await this.validateCourseInstructor(token, course_id);
if (!courseInstructor) {
throw new ForbiddenError('You are not an instructor of this course');
}
@ -1475,7 +1508,7 @@ export class CoursesInstructorService {
is_free: originalCourse.is_free,
have_certificate: originalCourse.have_certificate,
status: 'DRAFT', // Reset status
created_by: userId
created_by: decoded.id
}
});
@ -1483,7 +1516,7 @@ export class CoursesInstructorService {
await tx.courseInstructor.create({
data: {
course_id: createdCourse.id,
user_id: userId,
user_id: decoded.id,
is_primary: true
}
});
@ -1556,7 +1589,7 @@ export class CoursesInstructorService {
shuffle_questions: lesson.quiz.shuffle_questions,
shuffle_choices: lesson.quiz.shuffle_choices,
show_answers_after_completion: lesson.quiz.show_answers_after_completion,
created_by: userId
created_by: decoded.id
}
});
@ -1603,7 +1636,7 @@ export class CoursesInstructorService {
});
await auditService.logSync({
userId: input.userId,
userId: decoded.id,
action: AuditAction.CREATE,
entityType: 'Course',
entityId: newCourse.id,
@ -1625,8 +1658,9 @@ export class CoursesInstructorService {
} catch (error) {
logger.error(`Error cloning course: ${error}`);
const decoded = jwt.decode(input.token) as { id: number } | null;
await auditService.logSync({
userId: input.userId,
userId: decoded?.id || 0,
action: AuditAction.ERROR,
entityType: 'Course',
entityId: input.course_id,
@ -1638,45 +1672,4 @@ export class CoursesInstructorService {
throw error;
}
}
/**
* instructor
* Get all enrolled students across all courses the instructor owns/teaches
*/
static async getMyAllStudents(userId: number): Promise<GetAllMyStudentsResponse> {
try {
// หา course IDs ทั้งหมดที่ instructor สอน
const instructorCourses = await prisma.courseInstructor.findMany({
where: { user_id: userId },
select: { course_id: true }
});
const courseIds = instructorCourses.map(ci => ci.course_id);
if (courseIds.length === 0) {
return { code: 200, message: 'Students retrieved successfully', total_students: 0, total_completed: 0 };
}
// unique students ทั้งหมด
const uniqueStudents = await prisma.enrollment.groupBy({
by: ['user_id'],
where: { course_id: { in: courseIds } },
});
// จำนวน enrollment ที่ COMPLETED
const totalCompleted = await prisma.enrollment.count({
where: { course_id: { in: courseIds }, status: 'COMPLETED' }
});
return {
code: 200,
message: 'Students retrieved successfully',
total_students: uniqueStudents.length,
total_completed: totalCompleted,
};
} catch (error) {
logger.error(`Error getting all students: ${error}`);
throw error;
}
}
}

View file

@ -133,7 +133,7 @@ export class CoursesStudentService {
async enrollCourse(input: EnrollCourseInput): Promise<EnrollCourseResponse> {
try {
const { course_id } = input;
const userId = input.userId;
const decoded = jwt.verify(input.token, config.jwt.secret) as { id: number; type: string };
const course = await prisma.course.findUnique({
where: { id: course_id },
@ -146,7 +146,7 @@ export class CoursesStudentService {
const existingEnrollment = await prisma.enrollment.findUnique({
where: {
unique_enrollment: {
user_id: userId,
user_id: decoded.id,
course_id,
},
},
@ -159,7 +159,7 @@ export class CoursesStudentService {
const enrollment = await prisma.enrollment.create({
data: {
course_id,
user_id: userId,
user_id: decoded.id,
status: 'ENROLLED',
enrolled_at: new Date(),
},
@ -167,11 +167,11 @@ export class CoursesStudentService {
// Audit log - ENROLL
auditService.log({
userId: userId,
userId: decoded.id,
action: AuditAction.ENROLL,
entityType: 'Enrollment',
entityId: enrollment.id,
newValue: { course_id, user_id: userId, status: 'ENROLLED' }
newValue: { course_id, user_id: decoded.id, status: 'ENROLLED' }
});
return {
@ -187,9 +187,9 @@ export class CoursesStudentService {
};
} catch (error) {
logger.error(`Error enrolling in course: ${error}`);
// userId from middleware
const decoded = jwt.decode(input.token) as { id: number } | null;
await auditService.logSync({
userId: input.userId,
userId: decoded?.id || 0,
action: AuditAction.ERROR,
entityType: 'Enrollment',
entityId: 0,
@ -206,13 +206,13 @@ export class CoursesStudentService {
async GetEnrolledCourses(input: ListEnrolledCoursesInput): Promise<ListEnrolledCoursesResponse> {
try {
// destructure input
const { token } = input;
const page = input.page ?? 1;
const limit = input.limit ?? 20;
const userId = input.userId;
const decoded = jwt.verify(token, config.jwt.secret) as { id: number; type: string };
const enrollments = await prisma.enrollment.findMany({
where: {
user_id: userId,
user_id: decoded.id,
},
include: {
course: {
@ -230,7 +230,7 @@ export class CoursesStudentService {
});
const total = await prisma.enrollment.count({
where: {
user_id: userId,
user_id: decoded.id,
},
});
@ -274,9 +274,9 @@ export class CoursesStudentService {
};
} catch (error) {
logger.error(error);
// userId from middleware
const decoded = jwt.decode(input.token) as { id: number } | null;
await auditService.logSync({
userId: input.userId,
userId: decoded?.id || 0,
action: AuditAction.ERROR,
entityType: 'Enrollment',
entityId: 0,
@ -290,8 +290,8 @@ export class CoursesStudentService {
}
async getCourseLearning(input: GetCourseLearningInput): Promise<GetCourseLearningResponse> {
try {
const { course_id } = input;
const userId = input.userId;
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({
@ -330,7 +330,7 @@ export class CoursesStudentService {
const enrollment = await prisma.enrollment.findUnique({
where: {
unique_enrollment: {
user_id: userId,
user_id: decoded.id,
course_id,
},
},
@ -345,7 +345,7 @@ export class CoursesStudentService {
prisma.enrollment.update({
where: {
unique_enrollment: {
user_id: userId,
user_id: decoded.id,
course_id,
},
},
@ -357,7 +357,7 @@ export class CoursesStudentService {
const lessonIds = course.chapters.flatMap(ch => ch.lessons.map(l => l.id));
const lessonProgress = await prisma.lessonProgress.findMany({
where: {
user_id: userId,
user_id: decoded.id,
lesson_id: { in: lessonIds },
},
});
@ -453,9 +453,9 @@ export class CoursesStudentService {
};
} catch (error) {
logger.error(error);
// userId from middleware
const decoded = jwt.decode(input.token) as { id: number } | null;
await auditService.logSync({
userId: input.userId,
userId: decoded?.id || 0,
action: AuditAction.ERROR,
entityType: 'Enrollment',
entityId: 0,
@ -470,8 +470,8 @@ export class CoursesStudentService {
async getlessonContent(input: GetLessonContentInput): Promise<GetLessonContentResponse> {
try {
const { course_id, lesson_id } = input;
const userId = input.userId;
const { token, course_id, lesson_id } = input;
const decoded = jwt.verify(token, config.jwt.secret) as { id: number; type: string };
// Import MinIO functions
@ -479,7 +479,7 @@ export class CoursesStudentService {
const enrollment = await prisma.enrollment.findUnique({
where: {
unique_enrollment: {
user_id: userId,
user_id: decoded.id,
course_id,
},
},
@ -528,7 +528,7 @@ export class CoursesStudentService {
const lessonProgress = await prisma.lessonProgress.findUnique({
where: {
user_id_lesson_id: {
user_id: userId,
user_id: decoded.id,
lesson_id,
},
},
@ -639,7 +639,7 @@ export class CoursesStudentService {
// Get latest quiz attempt for this user
latestQuizAttempt = await prisma.quizAttempt.findFirst({
where: {
user_id: userId,
user_id: decoded.id,
quiz_id: lesson.quiz.id,
},
orderBy: {
@ -726,9 +726,9 @@ export class CoursesStudentService {
};
} catch (error) {
logger.error(error);
// userId from middleware
const decoded = jwt.decode(input.token) as { id: number } | null;
await auditService.logSync({
userId: input.userId,
userId: decoded?.id || 0,
action: AuditAction.ERROR,
entityType: 'Enrollment',
entityId: 0,
@ -744,14 +744,14 @@ export class CoursesStudentService {
async checkAccessLesson(input: CheckLessonAccessInput): Promise<CheckLessonAccessResponse> {
try {
const { course_id, lesson_id } = input;
const userId = input.userId;
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: userId,
user_id: decoded.id,
course_id,
},
},
@ -845,7 +845,7 @@ export class CoursesStudentService {
// Get user's progress for prerequisite lessons
const prerequisiteProgress = await prisma.lessonProgress.findMany({
where: {
user_id: userId,
user_id: decoded.id,
lesson_id: { in: prerequisiteIds },
},
});
@ -879,7 +879,7 @@ export class CoursesStudentService {
// Check if user passed the quiz
const quizAttempt = await prisma.quizAttempt.findFirst({
where: {
user_id: userId,
user_id: decoded.id,
quiz_id: prereqLesson.quiz.id,
is_passed: true,
},
@ -925,9 +925,9 @@ export class CoursesStudentService {
};
} catch (error) {
logger.error(error);
// userId from middleware
const decoded = jwt.decode(input.token) as { id: number } | null;
await auditService.logSync({
userId: input.userId,
userId: decoded?.id || 0,
action: AuditAction.ERROR,
entityType: 'Enrollment',
entityId: 0,
@ -942,8 +942,8 @@ export class CoursesStudentService {
async getVideoProgress(input: GetVideoProgressInput): Promise<GetVideoProgressResponse> {
try {
const { lesson_id } = input;
const userId = input.userId;
const { token, lesson_id } = input;
const decoded = jwt.verify(token, config.jwt.secret) as { id: number; type: string };
// Get lesson to find course_id
const lesson = await prisma.lesson.findUnique({
@ -966,7 +966,7 @@ export class CoursesStudentService {
const enrollment = await prisma.enrollment.findUnique({
where: {
unique_enrollment: {
user_id: userId,
user_id: decoded.id,
course_id,
},
},
@ -980,7 +980,7 @@ export class CoursesStudentService {
const progress = await prisma.lessonProgress.findUnique({
where: {
user_id_lesson_id: {
user_id: userId,
user_id: decoded.id,
lesson_id,
},
},
@ -1010,9 +1010,9 @@ export class CoursesStudentService {
};
} catch (error) {
logger.error(error);
// userId from middleware
const decoded = jwt.decode(input.token) as { id: number } | null;
await auditService.logSync({
userId: input.userId,
userId: decoded?.id || 0,
action: AuditAction.ERROR,
entityType: 'Enrollment',
entityId: 0,
@ -1027,8 +1027,8 @@ export class CoursesStudentService {
async saveVideoProgress(input: SaveVideoProgressInput): Promise<SaveVideoProgressResponse> {
try {
const { lesson_id, video_progress_seconds, video_duration_seconds } = input;
const userId = input.userId;
const { token, lesson_id, video_progress_seconds, video_duration_seconds } = input;
const decoded = jwt.verify(token, config.jwt.secret) as { id: number; type: string };
// Get lesson to find course_id
const lesson = await prisma.lesson.findUnique({
@ -1051,7 +1051,7 @@ export class CoursesStudentService {
const enrollment = await prisma.enrollment.findUnique({
where: {
unique_enrollment: {
user_id: userId,
user_id: decoded.id,
course_id,
},
},
@ -1074,12 +1074,12 @@ export class CoursesStudentService {
const progress = await prisma.lessonProgress.upsert({
where: {
user_id_lesson_id: {
user_id: userId,
user_id: decoded.id,
lesson_id,
},
},
create: {
user_id: userId,
user_id: decoded.id,
lesson_id,
video_progress_seconds,
video_duration_seconds: video_duration_seconds ?? null,
@ -1098,7 +1098,7 @@ export class CoursesStudentService {
// If video completed, mark lesson as complete and update enrollment progress
let enrollmentProgress: { progress_percentage: number; is_course_completed: boolean } | undefined;
if (isCompleted) {
const result = await this.markLessonComplete(userId, lesson_id, course_id);
const result = await this.markLessonComplete(decoded.id, lesson_id, course_id);
enrollmentProgress = result.enrollmentProgress;
}
@ -1118,9 +1118,9 @@ export class CoursesStudentService {
};
} catch (error) {
logger.error(error);
// userId from middleware
const decoded = jwt.decode(input.token) as { id: number } | null;
await auditService.logSync({
userId: input.userId,
userId: decoded?.id || 0,
action: AuditAction.ERROR,
entityType: 'Enrollment',
entityId: 0,
@ -1135,8 +1135,8 @@ export class CoursesStudentService {
async completeLesson(input: CompleteLessonInput): Promise<CompleteLessonResponse> {
try {
const { lesson_id } = input;
const userId = input.userId;
const { token, lesson_id } = input;
const decoded = jwt.verify(token, config.jwt.secret) as { id: number; type: string };
// Get lesson with chapter and course info
const lesson = await prisma.lesson.findUnique({
@ -1185,7 +1185,7 @@ export class CoursesStudentService {
const enrollment = await prisma.enrollment.findUnique({
where: {
unique_enrollment: {
user_id: userId,
user_id: decoded.id,
course_id,
},
},
@ -1196,7 +1196,7 @@ export class CoursesStudentService {
}
// Mark lesson as complete and update enrollment progress
const { lessonProgress, enrollmentProgress } = await this.markLessonComplete(userId, lesson_id, course_id);
const { lessonProgress, enrollmentProgress } = await this.markLessonComplete(decoded.id, lesson_id, course_id);
const { progress_percentage: course_progress_percentage, is_course_completed } = enrollmentProgress;
// Find next lesson
@ -1225,7 +1225,7 @@ export class CoursesStudentService {
// Check if certificate already exists
const existingCertificate = await prisma.certificate.findFirst({
where: {
user_id: userId,
user_id: decoded.id,
course_id,
},
});
@ -1233,10 +1233,10 @@ export class CoursesStudentService {
if (!existingCertificate) {
await prisma.certificate.create({
data: {
user_id: userId,
user_id: decoded.id,
course_id,
enrollment_id: enrollment.id,
file_path: `certificates/${course_id}/${userId}/${Date.now()}.pdf`,
file_path: `certificates/${course_id}/${decoded.id}/${Date.now()}.pdf`,
issued_at: new Date(),
},
});
@ -1261,9 +1261,9 @@ export class CoursesStudentService {
};
} catch (error) {
logger.error(`Error completing lesson: ${error}`);
// userId from middleware
const decoded = jwt.decode(input.token) as { id: number } | null;
await auditService.logSync({
userId: input.userId,
userId: decoded?.id || 0,
action: AuditAction.ERROR,
entityType: 'LessonProgress',
entityId: input.lesson_id,
@ -1283,14 +1283,14 @@ export class CoursesStudentService {
*/
async submitQuiz(input: SubmitQuizInput): Promise<SubmitQuizResponse> {
try {
const { course_id, lesson_id, answers } = input;
const userId = input.userId;
const { token, course_id, lesson_id, answers } = input;
const decoded = jwt.verify(token, config.jwt.secret) as { id: number };
// Check enrollment
const enrollment = await prisma.enrollment.findUnique({
where: {
unique_enrollment: {
user_id: userId,
user_id: decoded.id,
course_id,
},
},
@ -1331,7 +1331,7 @@ export class CoursesStudentService {
// Get previous attempt count
const previousAttempts = await prisma.quizAttempt.count({
where: {
user_id: userId,
user_id: decoded.id,
quiz_id: quiz.id,
},
});
@ -1384,7 +1384,7 @@ export class CoursesStudentService {
const now = new Date();
const quizAttempt = await prisma.quizAttempt.create({
data: {
user_id: userId,
user_id: decoded.id,
quiz_id: quiz.id,
score: earnedScore,
total_questions: quiz.questions.length,
@ -1400,7 +1400,7 @@ export class CoursesStudentService {
// If passed, mark lesson as complete and update enrollment progress
let enrollmentProgress: { progress_percentage: number; is_course_completed: boolean } | undefined;
if (isPassed) {
const result = await this.markLessonComplete(userId, lesson_id, course_id);
const result = await this.markLessonComplete(decoded.id, lesson_id, course_id);
enrollmentProgress = result.enrollmentProgress;
}
@ -1429,9 +1429,9 @@ export class CoursesStudentService {
};
} catch (error) {
logger.error(`Error submitting quiz: ${error}`);
// userId from middleware
const decoded = jwt.decode(input.token) as { id: number } | null;
await auditService.logSync({
userId: input.userId,
userId: decoded?.id || 0,
action: AuditAction.ERROR,
entityType: 'QuizAttempt',
entityId: 0,
@ -1452,14 +1452,14 @@ export class CoursesStudentService {
*/
async getQuizAttempts(input: GetQuizAttemptsInput): Promise<GetQuizAttemptsResponse> {
try {
const { course_id, lesson_id } = input;
const userId = input.userId;
const { token, course_id, lesson_id } = input;
const decoded = jwt.verify(token, config.jwt.secret) as { id: number };
// Check enrollment
const enrollment = await prisma.enrollment.findUnique({
where: {
unique_enrollment: {
user_id: userId,
user_id: decoded.id,
course_id,
},
},
@ -1494,7 +1494,7 @@ export class CoursesStudentService {
// Get all quiz attempts for this user
const attempts = await prisma.quizAttempt.findMany({
where: {
user_id: userId,
user_id: decoded.id,
quiz_id: lesson.quiz.id,
},
orderBy: { attempt_number: 'desc' },
@ -1539,19 +1539,21 @@ export class CoursesStudentService {
};
} catch (error) {
logger.error(error);
// userId from middleware
await auditService.logSync({
userId: input.userId,
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)
}
});
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;
}
}

View file

@ -1,6 +1,8 @@
import { prisma } from '../config/database';
import { config } from '../config';
import { logger } from '../config/logger';
import { NotFoundError, ValidationError } from '../middleware/errorHandler';
import jwt from 'jsonwebtoken';
import { getPresignedUrl } from '../config/minio';
import {
ListApprovedCoursesResponse,
@ -18,7 +20,7 @@ export class RecommendedCoursesService {
* List all approved courses (for admin to manage recommendations)
*/
static async listApprovedCourses(
userId: number,
token: string,
filters?: { search?: string; categoryId?: number }
): Promise<ListApprovedCoursesResponse> {
try {
@ -106,16 +108,19 @@ export class RecommendedCoursesService {
};
} catch (error) {
logger.error('Failed to list approved courses', { error });
await auditService.logSync({
userId,
action: AuditAction.ERROR,
entityType: 'RecommendedCourses',
entityId: 0,
metadata: {
operation: 'list_approved_courses',
error: error instanceof Error ? error.message : String(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;
}
}
@ -123,7 +128,7 @@ export class RecommendedCoursesService {
/**
* Get course by ID (for admin to view details)
*/
static async getCourseById(userId: number, courseId: number): Promise<GetCourseByIdResponse> {
static async getCourseById(token: string, courseId: number): Promise<GetCourseByIdResponse> {
try {
const course = await prisma.course.findUnique({
where: { id: courseId },
@ -208,16 +213,19 @@ export class RecommendedCoursesService {
};
} catch (error) {
logger.error('Failed to get course by ID', { error });
await auditService.logSync({
userId,
action: AuditAction.ERROR,
entityType: 'RecommendedCourses',
entityId: 0,
metadata: {
operation: 'get_course_by_id',
error: error instanceof Error ? error.message : String(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;
}
}
@ -226,11 +234,12 @@ export class RecommendedCoursesService {
* Toggle course recommendation status
*/
static async toggleRecommended(
userId: number,
token: string,
courseId: number,
isRecommended: boolean
): Promise<ToggleRecommendedResponse> {
try {
const decoded = jwt.verify(token, config.jwt.secret) as { id: number };
const course = await prisma.course.findUnique({ where: { id: courseId } });
if (!course) {
@ -248,7 +257,7 @@ export class RecommendedCoursesService {
// Audit log
await auditService.logSync({
userId,
userId: decoded.id,
action: AuditAction.UPDATE,
entityType: 'Course',
entityId: courseId,
@ -267,8 +276,9 @@ 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,
userId: decoded?.id || 0,
action: AuditAction.ERROR,
entityType: 'RecommendedCourses',
entityId: courseId,

View file

@ -1,6 +1,8 @@
import { prisma } from '../config/database';
import { config } from '../config';
import { logger } from '../config/logger';
import { ForbiddenError, NotFoundError } from '../middleware/errorHandler';
import { UnauthorizedError, ForbiddenError, NotFoundError } from '../middleware/errorHandler';
import jwt from 'jsonwebtoken';
import {
ListAnnouncementResponse,
CreateAnnouncementInput,
@ -29,26 +31,27 @@ export class AnnouncementsService {
*/
async listAnnouncement(input: ListAnnouncementInput): Promise<ListAnnouncementResponse> {
try {
const { userId, course_id, page = 1, limit = 10 } = input;
const { token, course_id, page = 1, limit = 10 } = input;
const decoded = jwt.verify(token, config.jwt.secret) as { id: number; type: string };
// Check user access - instructor, admin, or enrolled student
const user = await prisma.user.findUnique({
where: { id: userId },
where: { id: decoded.id },
include: { role: true },
});
if (!user) throw new ForbiddenError('User not found');
if (!user) throw new UnauthorizedError('Invalid token');
// Admin can access all courses
const isAdmin = user.role.code === 'ADMIN';
// Check if instructor of this course
const isInstructor = await prisma.courseInstructor.findFirst({
where: { course_id, user_id: userId },
where: { course_id, user_id: decoded.id },
});
// Check if enrolled student
const isEnrolled = await prisma.enrollment.findFirst({
where: { course_id, user_id: userId },
where: { course_id, user_id: decoded.id },
});
if (!isAdmin && !isInstructor && !isEnrolled) throw new ForbiddenError('You do not have access to this course announcements');
@ -127,8 +130,9 @@ 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: input.userId,
userId: decoded?.id || 0,
action: AuditAction.ERROR,
entityType: 'Announcement',
entityId: 0,
@ -146,10 +150,11 @@ export class AnnouncementsService {
*/
async createAnnouncement(input: CreateAnnouncementInput): Promise<CreateAnnouncementResponse> {
try {
const { userId, course_id, title, content, status, is_pinned, published_at, files } = input;
const { token, course_id, title, content, status, is_pinned, published_at, files } = input;
const decoded = jwt.verify(token, config.jwt.secret) as { id: number };
// Validate instructor access
await CoursesInstructorService.validateCourseInstructor(userId, course_id);
await CoursesInstructorService.validateCourseInstructor(token, course_id);
// Determine published_at: use provided value or default to now if status is PUBLISHED
let finalPublishedAt: Date | null = null;
@ -166,7 +171,7 @@ export class AnnouncementsService {
status: status as any,
is_pinned,
published_at: finalPublishedAt,
created_by: userId,
created_by: decoded.id,
},
});
@ -231,8 +236,9 @@ 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: input.userId,
userId: decoded?.id || 0,
action: AuditAction.ERROR,
entityType: 'Announcement',
entityId: 0,
@ -250,10 +256,11 @@ export class AnnouncementsService {
*/
async updateAnnouncement(input: UpdateAnnouncementInput): Promise<UpdateAnnouncementResponse> {
try {
const { userId, course_id, announcement_id, title, content, status, is_pinned, published_at } = input;
const { token, course_id, announcement_id, title, content, status, is_pinned, published_at } = input;
const decoded = jwt.verify(token, config.jwt.secret) as { id: number };
// Validate instructor access
await CoursesInstructorService.validateCourseInstructor(userId, course_id);
await CoursesInstructorService.validateCourseInstructor(token, course_id);
// Check announcement exists and belongs to course
const existing = await prisma.announcement.findFirst({
@ -282,7 +289,7 @@ export class AnnouncementsService {
status: status as any,
is_pinned,
published_at: finalPublishedAt,
updated_by: userId,
updated_by: decoded.id,
},
include: {
attachments: true,
@ -313,8 +320,9 @@ 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: input.userId,
userId: decoded?.id || 0,
action: AuditAction.ERROR,
entityType: 'Announcement',
entityId: 0,
@ -332,10 +340,11 @@ export class AnnouncementsService {
*/
async deleteAnnouncement(input: DeleteAnnouncementInput): Promise<DeleteAnnouncementResponse> {
try {
const { userId, course_id, announcement_id } = input;
const { token, course_id, announcement_id } = input;
jwt.verify(token, config.jwt.secret) as { id: number };
// Validate instructor access
await CoursesInstructorService.validateCourseInstructor(userId, course_id);
await CoursesInstructorService.validateCourseInstructor(token, course_id);
// Check announcement exists and belongs to course
const existing = await prisma.announcement.findFirst({
@ -367,8 +376,9 @@ 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: input.userId,
userId: decoded?.id || 0,
action: AuditAction.ERROR,
entityType: 'Announcement',
entityId: 0,
@ -386,10 +396,11 @@ export class AnnouncementsService {
*/
async uploadAttachment(input: UploadAnnouncementAttachmentInput): Promise<UploadAnnouncementAttachmentResponse> {
try {
const { userId, course_id, announcement_id, file } = input;
const { token, course_id, announcement_id, file } = input;
jwt.verify(token, config.jwt.secret) as { id: number };
// Validate instructor access
await CoursesInstructorService.validateCourseInstructor(userId, course_id);
await CoursesInstructorService.validateCourseInstructor(token, course_id);
// Check announcement exists and belongs to course
const existing = await prisma.announcement.findFirst({
@ -440,8 +451,9 @@ 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: input.userId,
userId: decoded?.id || 0,
action: AuditAction.ERROR,
entityType: 'Announcement',
entityId: 0,
@ -459,10 +471,11 @@ export class AnnouncementsService {
*/
async deleteAttachment(input: DeleteAnnouncementAttachmentInput): Promise<DeleteAnnouncementAttachmentResponse> {
try {
const { userId, course_id, announcement_id, attachment_id } = input;
const { token, course_id, announcement_id, attachment_id } = input;
jwt.verify(token, config.jwt.secret) as { id: number };
// Validate instructor access
await CoursesInstructorService.validateCourseInstructor(userId, course_id);
await CoursesInstructorService.validateCourseInstructor(token, course_id);
// Check attachment exists and belongs to announcement in this course
const attachment = await prisma.announcementAttachment.findFirst({
@ -495,8 +508,9 @@ 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: input.userId,
userId: decoded?.id || 0,
action: AuditAction.ERROR,
entityType: 'Announcement',
entityId: 0,

View file

@ -74,6 +74,7 @@ export class AuthService {
data: {
token,
refreshToken,
user: await this.formatUserResponse(user)
}
};
}

View file

@ -1,7 +1,10 @@
import { prisma } from '../config/database';
import { Prisma } from '@prisma/client';
import { config } from '../config';
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';
@ -23,13 +26,14 @@ export class CategoryService {
}
}
async createCategory(userId: number, category: createCategory): Promise<createCategoryResponse> {
async createCategory(token: string, category: createCategory): Promise<createCategoryResponse> {
try {
const decoded = jwt.verify(token, config.jwt.secret) as { id: number; username: string; email: string; roleCode: string };
const newCategory = await prisma.category.create({
data: category
});
auditService.log({
userId,
userId: decoded.id,
action: AuditAction.CREATE,
entityType: 'Category',
entityId: newCategory.id,
@ -43,13 +47,13 @@ export class CategoryService {
name: newCategory.name as { th: string; en: string },
slug: newCategory.slug,
description: newCategory.description as { th: string; en: string },
created_by: userId,
created_by: decoded.id,
}
};
} catch (error) {
logger.error('Failed to create category', { error });
await auditService.logSync({
userId,
userId: 0,
action: AuditAction.ERROR,
entityType: 'Category',
entityId: 0,
@ -62,14 +66,15 @@ export class CategoryService {
}
}
async updateCategory(userId: number, id: number, category: updateCategory): Promise<updateCategoryResponse> {
async updateCategory(token: string, id: number, category: updateCategory): Promise<updateCategoryResponse> {
try {
const decoded = jwt.verify(token, config.jwt.secret) as { id: number; username: string; email: string; roleCode: string };
const updatedCategory = await prisma.category.update({
where: { id },
data: category
});
auditService.log({
userId,
userId: decoded.id,
action: AuditAction.UPDATE,
entityType: 'Category',
entityId: id,
@ -83,13 +88,13 @@ export class CategoryService {
name: updatedCategory.name as { th: string; en: string },
slug: updatedCategory.slug,
description: updatedCategory.description as { th: string; en: string },
updated_by: userId,
updated_by: decoded.id,
}
};
} catch (error) {
logger.error('Failed to update category', { error });
await auditService.logSync({
userId,
userId: 0,
action: AuditAction.ERROR,
entityType: 'Category',
entityId: 0,
@ -102,13 +107,14 @@ export class CategoryService {
}
}
async deleteCategory(userId: number, id: number): Promise<deleteCategoryResponse> {
async deleteCategory(token: string, id: number): Promise<deleteCategoryResponse> {
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,
userId: decoded.id,
action: AuditAction.DELETE,
entityType: 'Category',
entityId: id,
@ -121,7 +127,7 @@ export class CategoryService {
} catch (error) {
logger.error('Failed to delete category', { error });
await auditService.logSync({
userId,
userId: 0,
action: AuditAction.ERROR,
entityType: 'Category',
entityId: 0,

View file

@ -1,6 +1,8 @@
import { prisma } from '../config/database';
import { config } from '../config';
import { logger } from '../config/logger';
import { NotFoundError, ForbiddenError, ValidationError } from '../middleware/errorHandler';
import jwt from 'jsonwebtoken';
import { PDFDocument, rgb } from 'pdf-lib';
import fontkit from '@pdf-lib/fontkit';
import * as fs from 'fs';
@ -27,13 +29,14 @@ export class CertificateService {
*/
async generateCertificate(input: GenerateCertificateInput): Promise<GenerateCertificateResponse> {
try {
const { userId, course_id } = input;
const { token, course_id } = input;
const decoded = jwt.verify(token, config.jwt.secret) as { id: number };
// Check enrollment and completion
const enrollment = await prisma.enrollment.findUnique({
where: {
unique_enrollment: {
user_id: userId,
user_id: decoded.id,
course_id,
},
},
@ -62,7 +65,7 @@ export class CertificateService {
// Check if certificate already exists
const existingCertificate = await prisma.certificate.findFirst({
where: {
user_id: userId,
user_id: decoded.id,
course_id,
},
});
@ -100,13 +103,13 @@ export class CertificateService {
// Upload to MinIO
const timestamp = Date.now();
const filePath = `certificates/${course_id}/${userId}/${timestamp}.pdf`;
const filePath = `certificates/${course_id}/${decoded.id}/${timestamp}.pdf`;
await uploadFile(filePath, Buffer.from(pdfBytes), 'application/pdf');
// Save to database
const certificate = await prisma.certificate.create({
data: {
user_id: userId,
user_id: decoded.id,
course_id,
enrollment_id: enrollment.id,
file_path: filePath,
@ -115,7 +118,7 @@ export class CertificateService {
});
auditService.log({
userId,
userId: decoded.id,
action: AuditAction.CREATE,
entityType: 'Certificate',
entityId: certificate.id,
@ -136,8 +139,9 @@ 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: input.userId,
userId: decoded?.id,
action: AuditAction.ERROR,
entityType: 'Certificate',
entityId: 0,
@ -156,11 +160,12 @@ export class CertificateService {
*/
async getCertificate(input: GetCertificateInput): Promise<GetCertificateResponse> {
try {
const { userId, course_id } = input;
const { token, course_id } = input;
const decoded = jwt.verify(token, config.jwt.secret) as { id: number };
const certificate = await prisma.certificate.findFirst({
where: {
user_id: userId,
user_id: decoded.id,
course_id,
},
include: {
@ -197,8 +202,9 @@ 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: input.userId,
userId: decoded?.id,
action: AuditAction.ERROR,
entityType: 'Certificate',
entityId: 0,
@ -217,11 +223,12 @@ export class CertificateService {
*/
async listMyCertificates(input: ListMyCertificatesInput): Promise<ListMyCertificatesResponse> {
try {
const { userId } = input;
const { token } = input;
const decoded = jwt.verify(token, config.jwt.secret) as { id: number };
const certificates = await prisma.certificate.findMany({
where: {
user_id: userId,
user_id: decoded.id,
},
include: {
enrollment: {
@ -260,8 +267,9 @@ 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: input.userId,
userId: decoded?.id,
action: AuditAction.ERROR,
entityType: 'Certificate',
entityId: 0,

View file

@ -24,10 +24,15 @@ import { auditService } from './audit.service';
import { AuditAction } from '@prisma/client';
export class UserService {
async getUserProfile(userId: number): Promise<UserResponse> {
async getUserProfile(token: string): Promise<UserResponse> {
try {
// Decode JWT token to get user ID
const decoded = jwt.verify(token, config.jwt.secret) as { id: number; username: string; email: string; roleCode: string };
const user = await prisma.user.findUnique({
where: { id: userId },
where: {
id: decoded.id
},
include: {
profile: true,
role: true
@ -63,6 +68,14 @@ export class UserService {
} : undefined
};
} catch (error) {
if (error instanceof jwt.JsonWebTokenError) {
logger.error('Invalid JWT token:', error);
throw new UnauthorizedError('Invalid token');
}
if (error instanceof jwt.TokenExpiredError) {
logger.error('JWT token expired:', error);
throw new UnauthorizedError('Token expired');
}
logger.error('Error fetching user profile:', error);
throw error;
}
@ -71,9 +84,12 @@ export class UserService {
/**
* Change user password
*/
async changePassword(userId: number, oldPassword: string, newPassword: string): Promise<ChangePasswordResponse> {
async changePassword(token: string, oldPassword: string, newPassword: string): Promise<ChangePasswordResponse> {
try {
const user = await prisma.user.findUnique({ where: { id: userId } });
// Decode JWT token to get user ID
const decoded = jwt.verify(token, config.jwt.secret) as { id: number; username: string; email: string; roleCode: string };
const user = await prisma.user.findUnique({ where: { id: decoded.id } });
if (!user) throw new UnauthorizedError('User not found');
// Check if account is deactivated
@ -111,12 +127,21 @@ export class UserService {
message: 'Password changed successfully'
};
} catch (error) {
if (error instanceof jwt.JsonWebTokenError) {
logger.error('Invalid JWT token:', error);
throw new UnauthorizedError('Invalid token');
}
if (error instanceof jwt.TokenExpiredError) {
logger.error('JWT token expired:', error);
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,
userId: decoded?.id || 0,
action: AuditAction.ERROR,
entityType: 'User',
entityId: userId,
entityId: decoded?.id || 0,
metadata: {
operation: 'change_password',
error: error instanceof Error ? error.message : String(error)
@ -129,9 +154,12 @@ export class UserService {
/**
* Update user profile
*/
async updateProfile(userId: number, profile: ProfileUpdate): Promise<ProfileUpdateResponse> {
async updateProfile(token: string, profile: ProfileUpdate): Promise<ProfileUpdateResponse> {
try {
const user = await prisma.user.findUnique({ where: { id: userId } });
// Decode JWT token to get user ID
const decoded = jwt.verify(token, config.jwt.secret) as { id: number; username: string; email: string; roleCode: string };
const user = await prisma.user.findUnique({ where: { id: decoded.id } });
if (!user) throw new UnauthorizedError('User not found');
// Check if account is deactivated
@ -161,12 +189,21 @@ export class UserService {
}
};
} catch (error) {
if (error instanceof jwt.JsonWebTokenError) {
logger.error('Invalid JWT token:', error);
throw new UnauthorizedError('Invalid token');
}
if (error instanceof jwt.TokenExpiredError) {
logger.error('JWT token expired:', error);
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,
userId: decoded?.id || 0,
action: AuditAction.UPDATE,
entityType: 'UserProfile',
entityId: userId,
entityId: decoded?.id || 0,
metadata: {
operation: 'update_profile',
error: error instanceof Error ? error.message : String(error)
@ -176,8 +213,9 @@ export class UserService {
}
}
async getRoles(): Promise<rolesResponse> {
async getRoles(token: string): Promise<rolesResponse> {
try {
jwt.verify(token, config.jwt.secret);
const roles = await prisma.role.findMany({
select: {
id: true,
@ -186,6 +224,14 @@ export class UserService {
});
return { roles };
} catch (error) {
if (error instanceof jwt.TokenExpiredError) {
logger.error('JWT token expired:', error);
throw new UnauthorizedError('Token expired');
}
if (error instanceof jwt.JsonWebTokenError) {
logger.error('Invalid JWT token:', error);
throw new UnauthorizedError('Invalid token');
}
logger.error('Failed to get roles', { error });
throw error;
}
@ -194,11 +240,13 @@ export class UserService {
/**
* Upload avatar picture to MinIO
*/
async uploadAvatarPicture(userId: number, file: Express.Multer.File): Promise<updateAvatarResponse> {
async uploadAvatarPicture(token: string, file: Express.Multer.File): Promise<updateAvatarResponse> {
try {
const decoded = jwt.verify(token, config.jwt.secret) as { id: number };
// Check if user exists
const user = await prisma.user.findUnique({
where: { id: userId },
where: { id: decoded.id },
include: { profile: true }
});
@ -217,7 +265,7 @@ export class UserService {
const fileName = file.originalname || 'avatar';
const extension = fileName.split('.').pop() || 'jpg';
const safeFilename = `${timestamp}-${uniqueId}.${extension}`;
const filePath = `avatars/${userId}/${safeFilename}`;
const filePath = `avatars/${decoded.id}/${safeFilename}`;
// Delete old avatar if exists
if (user.profile?.avatar_url) {
@ -237,13 +285,13 @@ export class UserService {
// Update or create profile - store only file path
if (user.profile) {
await prisma.userProfile.update({
where: { user_id: userId },
where: { user_id: decoded.id },
data: { avatar_url: filePath }
});
} else {
await prisma.userProfile.create({
data: {
user_id: userId,
user_id: decoded.id,
avatar_url: filePath,
first_name: '',
last_name: ''
@ -253,10 +301,10 @@ export class UserService {
// Audit log - UPLOAD_AVATAR
await auditService.logSync({
userId,
userId: decoded.id,
action: AuditAction.UPLOAD_FILE,
entityType: 'User',
entityId: userId,
entityId: decoded.id,
metadata: {
operation: 'upload_avatar',
filePath
@ -270,17 +318,26 @@ export class UserService {
code: 200,
message: 'Avatar uploaded successfully',
data: {
id: userId,
id: decoded.id,
avatar_url: presignedUrl
}
};
} catch (error) {
if (error instanceof jwt.JsonWebTokenError) {
logger.error('Invalid JWT token:', error);
throw new UnauthorizedError('Invalid token');
}
if (error instanceof jwt.TokenExpiredError) {
logger.error('JWT token expired:', error);
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,
userId: decoded?.id || 0,
action: AuditAction.UPLOAD_FILE,
entityType: 'UserProfile',
entityId: userId,
entityId: decoded?.id || 0,
metadata: {
operation: 'upload_avatar',
error: error instanceof Error ? error.message : String(error)
@ -333,10 +390,12 @@ export class UserService {
/**
* Send verification email to user
*/
async sendVerifyEmail(userId: number): Promise<SendVerifyEmailResponse> {
async sendVerifyEmail(token: string): Promise<SendVerifyEmailResponse> {
try {
const decoded = jwt.verify(token, config.jwt.secret) as { id: number; email: string; roleCode: string };
const user = await prisma.user.findUnique({
where: { id: userId },
where: { id: decoded.id },
include: { role: true }
});
@ -394,12 +453,15 @@ export class UserService {
message: 'Verification email sent successfully'
};
} catch (error) {
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,
userId: decoded?.id || 0,
action: AuditAction.ERROR,
entityType: 'UserProfile',
entityId: userId,
entityId: decoded?.id || 0,
metadata: {
operation: 'send_verification_email',
error: error instanceof Error ? error.message : String(error)

View file

@ -98,18 +98,18 @@ export interface ChapterData {
// ============================================
export interface ChaptersRequest {
userId: number;
token: string;
course_id: number;
}
export interface GetChapterRequest {
userId: number;
token: string;
course_id: number;
chapter_id: number;
}
export interface CreateChapterInput {
userId: number;
token: string;
course_id: number;
title: MultiLanguageText;
description?: MultiLanguageText;
@ -118,13 +118,13 @@ export interface CreateChapterInput {
}
export interface CreateChapterRequest {
userId: number;
token: string;
course_id: number;
data: CreateChapterInput;
}
export interface UpdateChapterInput {
userId: number;
token: string;
course_id: number;
chapter_id: number;
title?: MultiLanguageText;
@ -134,20 +134,20 @@ export interface UpdateChapterInput {
}
export interface UpdateChapterRequest {
userId: number;
token: string;
course_id: number;
chapter_id: number;
data: UpdateChapterInput;
}
export interface DeleteChapterRequest {
userId: number;
token: string;
course_id: number;
chapter_id: number;
}
export interface ReorderChapterRequest {
userId: number;
token: string;
course_id: number;
chapter_id: number;
sort_order: number;
@ -199,7 +199,7 @@ export interface ReorderChapterResponse {
// ============================================
export interface GetLessonRequest {
userId: number;
token: string;
course_id: number;
chapter_id: number;
lesson_id: number;
@ -216,7 +216,7 @@ export interface UploadedFileInfo {
}
export interface CreateLessonInput {
userId: number;
token: string;
course_id: number;
chapter_id: number;
title: MultiLanguageText;
@ -293,7 +293,7 @@ export interface QuizChoiceData {
}
export interface CreateLessonRequest {
userId: number;
token: string;
course_id: number;
chapter_id: number;
data: CreateLessonInput;
@ -311,7 +311,7 @@ export interface UpdateLessonInput {
}
export interface UpdateLessonRequest {
userId: number;
token: string;
course_id: number;
chapter_id: number;
lesson_id: number;
@ -319,14 +319,14 @@ export interface UpdateLessonRequest {
}
export interface DeleteLessonRequest {
userId: number;
token: string;
course_id: number;
chapter_id: number;
lesson_id: number;
}
export interface ReorderLessonsRequest {
userId: number;
token: string;
course_id: number;
chapter_id: number;
lesson_id: number;
@ -365,7 +365,7 @@ export interface UpdateLessonResponse {
* Input for uploading video to a lesson
*/
export interface UploadVideoInput {
userId: number;
token: string;
course_id: number;
lesson_id: number;
video: UploadedFileInfo;
@ -375,7 +375,7 @@ export interface UploadVideoInput {
* Input for updating (replacing) video in a lesson
*/
export interface UpdateVideoInput {
userId: number;
token: string;
course_id: number;
lesson_id: number;
video: UploadedFileInfo;
@ -385,7 +385,7 @@ export interface UpdateVideoInput {
* Input for setting YouTube video to a lesson
*/
export interface SetYouTubeVideoInput {
userId: number;
token: string;
course_id: number;
lesson_id: number;
youtube_video_id: string;
@ -411,7 +411,7 @@ export interface YouTubeVideoResponse {
* Input for uploading a single attachment to a lesson
*/
export interface UploadAttachmentInput {
userId: number;
token: string;
course_id: number;
lesson_id: number;
attachment: UploadedFileInfo;
@ -421,7 +421,7 @@ export interface UploadAttachmentInput {
* Input for deleting an attachment from a lesson
*/
export interface DeleteAttachmentInput {
userId: number;
token: string;
course_id: number;
lesson_id: number;
attachment_id: number;
@ -490,7 +490,7 @@ export interface LessonWithDetailsResponse {
* Input for adding quiz to an existing QUIZ lesson
*/
export interface AddQuizToLessonInput {
userId: number;
token: string;
course_id: number;
lesson_id: number;
quiz_data: {
@ -509,7 +509,7 @@ export interface AddQuizToLessonInput {
* Input for adding a single question to a quiz lesson
*/
export interface AddQuestionInput {
userId: number;
token: string;
course_id: number;
lesson_id: number;
question: MultiLanguageText;
@ -532,7 +532,7 @@ export interface AddQuestionResponse {
* Input for updating a question
*/
export interface UpdateQuestionInput {
userId: number;
token: string;
course_id: number;
lesson_id: number;
question_id: number;
@ -556,14 +556,14 @@ export interface UpdateQuestionResponse {
* Input for deleting a question
*/
export interface DeleteQuestionInput {
userId: number;
token: string;
course_id: number;
lesson_id: number;
question_id: number;
}
export interface ReorderQuestionInput {
userId: number;
token: string;
course_id: number;
lesson_id: number;
question_id: number;
@ -588,7 +588,7 @@ export interface DeleteQuestionResponse {
* Input for updating quiz settings
*/
export interface UpdateQuizInput {
userId: number;
token: string;
course_id: number;
lesson_id: number;
title?: MultiLanguageText;

View file

@ -24,7 +24,7 @@ export interface createCourseResponse {
}
export interface ListMyCoursesInput {
userId: number;
token: string;
status?: 'DRAFT' | 'PENDING' | 'APPROVED' | 'REJECTED' | 'ARCHIVED';
}
@ -42,7 +42,7 @@ export interface GetMyCourseResponse {
}
export interface getmyCourse {
userId: number;
token: string;
course_id: number;
}
@ -94,13 +94,13 @@ export interface listCourseinstructorResponse {
}
export interface addinstructorCourse {
userId: number;
token: string;
email_or_username: string;
course_id: number;
}
export interface SearchInstructorInput {
userId: number;
token: string;
query: string;
course_id: number;
}
@ -145,12 +145,12 @@ export interface listinstructorCourseResponse {
}
export interface listinstructorCourse {
userId: number;
token: string;
course_id: number;
}
export interface removeinstructorCourse {
userId: number;
token: string;
user_id: number;
course_id: number;
}
@ -161,7 +161,7 @@ export interface removeinstructorCourseResponse {
}
export interface setprimaryCourseInstructor {
userId: number;
token: string;
user_id: number;
course_id: number;
}
@ -172,12 +172,12 @@ export interface setprimaryCourseInstructorResponse {
}
export interface sendCourseForReview {
userId: number;
token: string;
course_id: number;
}
export interface setCourseDraft {
userId: number;
token: string;
course_id: number;
}
@ -220,7 +220,7 @@ export interface GetCourseApprovalsResponse {
// ============================================
export interface GetEnrolledStudentsInput {
userId: number;
token: string;
course_id: number;
page?: number;
limit?: number;
@ -254,7 +254,7 @@ export interface GetEnrolledStudentsResponse {
// ============================================
export interface GetQuizScoresInput {
userId: number;
token: string;
course_id: number;
lesson_id: number;
page?: number;
@ -305,7 +305,7 @@ export interface GetQuizScoresResponse {
// ============================================
export interface GetQuizAttemptDetailInput {
userId: number;
token: string;
course_id: number;
lesson_id: number;
student_id: number;
@ -353,7 +353,7 @@ export interface GetQuizAttemptDetailResponse {
// ============================================
export interface GetEnrolledStudentDetailInput {
userId: number;
token: string;
course_id: number;
student_id: number;
}
@ -435,7 +435,7 @@ export interface GetCourseApprovalHistoryResponse {
}
export interface CloneCourseInput {
userId: number;
token: string;
course_id: number;
title: MultiLanguageText;
}
@ -448,14 +448,3 @@ export interface CloneCourseResponse {
title: MultiLanguageText;
};
}
// ============================================
// Get All Students across all instructor courses
// ============================================
export interface GetAllMyStudentsResponse {
code: number;
message: string;
total_students: number;
total_completed: number;
}

View file

@ -9,7 +9,7 @@ export type MultiLangText = MultiLanguageText;
// ============================================
export interface EnrollCourseInput {
userId: number;
token: string;
course_id: number;
}
@ -26,7 +26,7 @@ export interface EnrollCourseResponse {
}
export interface ListEnrolledCoursesInput {
userId: number;
token: string;
page?: number;
limit?: number;
status?: EnrollmentStatus;
@ -64,7 +64,7 @@ export interface ListEnrolledCoursesResponse {
// ============================================
export interface GetCourseLearningInput {
userId: number;
token: string;
course_id: number;
}
@ -126,7 +126,7 @@ export interface GetCourseLearningResponse {
// ============================================
export interface GetLessonContentInput {
userId: number;
token: string;
course_id: number;
lesson_id: number;
}
@ -204,7 +204,7 @@ export interface GetLessonContentResponse {
// ============================================
export interface CheckLessonAccessInput {
userId: number;
token: string;
course_id: number;
lesson_id: number;
}
@ -236,7 +236,7 @@ export interface CheckLessonAccessResponse {
// ============================================
export interface SaveVideoProgressInput {
userId: number;
token: string;
lesson_id: number;
video_progress_seconds: number;
video_duration_seconds?: number;
@ -258,7 +258,7 @@ export interface SaveVideoProgressResponse {
}
export interface GetVideoProgressInput {
userId: number;
token: string;
lesson_id: number;
}
@ -281,7 +281,7 @@ export interface GetVideoProgressResponse {
// ============================================
export interface MarkLessonCompleteInput {
userId: number;
token: string;
course_id: number;
lesson_id: number;
}
@ -314,7 +314,7 @@ export interface EnrollCourseBody {
}
export interface CompleteLessonInput {
userId: number;
token: string;
lesson_id: number;
}
@ -342,7 +342,7 @@ export interface QuizAnswerInput {
}
export interface SubmitQuizInput {
userId: number;
token: string;
course_id: number;
lesson_id: number;
answers: QuizAnswerInput[];
@ -384,7 +384,7 @@ export interface SubmitQuizResponse {
// ============================================
export interface GetQuizAttemptsInput {
userId: number;
token: string;
course_id: number;
lesson_id: number;
}

View file

@ -22,7 +22,7 @@ export interface AnnouncementAttachment {
updated_at: Date;
}
export interface ListAnnouncementResponse {
export interface ListAnnouncementResponse{
code: number;
message: string;
data: Announcement[];
@ -31,15 +31,15 @@ export interface ListAnnouncementResponse {
limit: number;
}
export interface ListAnnouncementInput {
userId: number;
export interface ListAnnouncementInput{
token: string;
course_id: number;
page?: number;
limit?: number;
}
export interface CreateAnnouncementInput {
userId: number;
export interface CreateAnnouncementInput{
token: string;
course_id: number;
title: MultiLanguageText;
content: MultiLanguageText;
@ -49,39 +49,39 @@ export interface CreateAnnouncementInput {
files?: Express.Multer.File[];
}
export interface UploadAnnouncementAttachmentInput {
userId: number;
export interface UploadAnnouncementAttachmentInput{
token: string;
course_id: number;
announcement_id: number;
file: File;
}
export interface UploadAnnouncementAttachmentResponse {
export interface UploadAnnouncementAttachmentResponse{
code: number;
message: string;
data: AnnouncementAttachment;
}
export interface DeleteAnnouncementAttachmentInput {
userId: number;
export interface DeleteAnnouncementAttachmentInput{
token: string;
course_id: number;
announcement_id: number;
attachment_id: number;
}
export interface DeleteAnnouncementAttachmentResponse {
export interface DeleteAnnouncementAttachmentResponse{
code: number;
message: string;
}
export interface CreateAnnouncementResponse {
export interface CreateAnnouncementResponse{
code: number;
message: string;
data: Announcement;
}
export interface UpdateAnnouncementInput {
userId: number;
export interface UpdateAnnouncementInput{
token: string;
course_id: number;
announcement_id: number;
title: MultiLanguageText;
@ -92,19 +92,19 @@ export interface UpdateAnnouncementInput {
attachments?: AnnouncementAttachment[];
}
export interface UpdateAnnouncementResponse {
export interface UpdateAnnouncementResponse{
code: number;
message: string;
data: Announcement;
}
export interface DeleteAnnouncementInput {
userId: number;
export interface DeleteAnnouncementInput{
token: string;
course_id: number;
announcement_id: number;
}
export interface DeleteAnnouncementResponse {
export interface DeleteAnnouncementResponse{
code: number;
message: string;
}

View file

@ -28,6 +28,7 @@ export interface LoginResponse {
data: {
token: string;
refreshToken: string;
user: UserResponse;
};
}

View file

@ -3,7 +3,7 @@
// ============================================
export interface GenerateCertificateInput {
userId: number;
token: string;
course_id: number;
}
@ -19,7 +19,7 @@ export interface GenerateCertificateResponse {
}
export interface GetCertificateInput {
userId: number;
token: string;
course_id: number;
}
@ -37,7 +37,7 @@ export interface GetCertificateResponse {
}
export interface ListMyCertificatesInput {
userId: number;
token: string;
}
export interface ListMyCertificatesResponse {

View file

@ -7,8 +7,8 @@ WORKDIR /app
# คัดลอกไฟล์จัดการ dependencies
COPY package*.json ./
# ติดตั้ง dependencies
RUN npm install
# ติดตั้ง dependencies (ใช้ npm ci เพื่อความแม่นยำของเวอร์ชัน)
RUN npm ci
# คัดลอกไฟล์ทั้งหมดในโปรเจกต์
COPY . .

View file

@ -40,7 +40,7 @@ export const useAuth = () => {
// ฟังก์ชันเข้าสู่ระบบ (Login)
const login = async (credentials: { email: string; password: string }) => {
try {
// API returns { code: 200, message: "...", data: { token, refreshToken } }
// API returns { code: 200, message: "...", data: { token, user, ... } }
const response = await $fetch<any>(`${API_BASE_URL}/auth/login`, {
method: 'POST',
body: credentials
@ -49,36 +49,17 @@ export const useAuth = () => {
if (response && response.data) {
const data = response.data
// บันทึก Token ก่อน เพื่อใช้เรียก /user/me
token.value = data.token
refreshToken.value = data.refreshToken
// ดึงข้อมูลผู้ใช้จาก /user/me (เพราะ API login ไม่ส่ง user กลับมาแล้ว)
try {
const userData = await $fetch<any>(`${API_BASE_URL}/user/me`, {
headers: {
Authorization: `Bearer ${data.token}`
}
})
// Validation: ตรวจสอบ Role ต้องเป็น STUDENT เท่านั้น
if (!userData || !userData.role || userData.role.code !== 'STUDENT') {
// ถ้า Role ไม่ใช่ STUDENT ให้ล้าง Token ออก
token.value = null
refreshToken.value = null
return { success: false, error: 'Email ไม่ถูกต้อง' }
}
// เก็บข้อมูล User ลง Cookie
user.value = userData
} catch (profileErr) {
// ดึงข้อมูลผู้ใช้ไม่สำเร็จ ให้ล้าง Token ออก
console.error('Failed to fetch user profile after login:', profileErr)
token.value = null
refreshToken.value = null
return { success: false, error: 'ไม่สามารถดึงข้อมูลผู้ใช้ได้' }
// Validation: Ensure user and role exist, then check for Role 'STUDENT'
if (!data.user || !data.user.role || data.user.role.code !== 'STUDENT') {
return { success: false, error: 'Email ไม่ถูกต้อง' }
}
token.value = data.token
refreshToken.value = data.refreshToken // บันทึก Refresh Token
// API ส่งข้อมูล profile มาใน user object
user.value = data.user
return { success: true }
}
return { success: false, error: 'No data returned' }

View file

@ -117,11 +117,7 @@
"foundTotal": "Found Total",
"items": "items",
"subtitle": "Choose to learn new skills from our curated quality courses",
"searchBtn": "Search",
"allCategory": "All",
"byInstructor": "by",
"students": "students",
"viewDetails": "View Details"
"searchBtn": "Search"
},
"myCourses": {
"title": "My Courses",

View file

@ -117,11 +117,7 @@
"foundTotal": "พบทั้งหมด",
"items": "รายการ",
"subtitle": "เลือกเรียนรู้ทักษะใหม่ๆ จากหลักสูตรคุณภาพที่คัดสรรมาเพื่อคุณ",
"searchBtn": "ค้นหา",
"allCategory": "ทั้งหมด",
"byInstructor": "โดย",
"students": "นักเรียน",
"viewDetails": "ดูรายละเอียด"
"searchBtn": "ค้นหา"
},
"myCourses": {
"title": "คอร์สของฉัน",

File diff suppressed because it is too large Load diff

View file

@ -27,7 +27,7 @@ const sortBy = ref('ยอดนิยม');
const sortOptions = ['ยอดนิยม', 'ล่าสุด', 'ราคาต่ำ-สูงสุด', 'ราคาสูง-ต่ำสุด'];
const categories = ref<any[]>([]);
const allCourses = ref<any[]>([]); // client-side
const courses = ref<any[]>([]);
const selectedCourse = ref<any>(null);
const isLoading = ref(false);
@ -76,17 +76,20 @@ const loadCategories = async () => {
if (res.success) categories.value = res.data || [];
};
const loadCourses = async () => {
const loadCourses = async (page = 1) => {
isLoading.value = true;
const categoryId = activeCategory.value === 'all' ? undefined : activeCategory.value as number;
// (limit client-side filter)
const res = await fetchCourses({
limit: 500,
category_id: categoryId,
search: searchQuery.value,
page: page,
limit: itemsPerPage,
forceRefresh: true,
});
if (res.success) {
allCourses.value = (res.data || []).map(c => {
courses.value = (res.data || []).map(c => {
const cat = categories.value.find(cat => cat.id === c.category_id);
return {
...c,
@ -97,33 +100,12 @@ const loadCourses = async () => {
reviews_count: c.total_lessons ? c.total_lessons * 123 : Math.floor(Math.random() * 2000) + 100
}
});
totalPages.value = res.totalPages || 1;
currentPage.value = res.page || 1;
}
isLoading.value = false;
};
// Computed: real-time searchQuery + activeCategory
const filteredCourses = computed(() => {
let result = allCourses.value;
//
if (activeCategory.value !== 'all') {
result = result.filter(c => c.category_id === activeCategory.value);
}
// ( th en)
if (searchQuery.value.trim()) {
const query = searchQuery.value.trim().toLowerCase();
result = result.filter(c => {
const titleTh = (c.title?.th || '').toLowerCase();
const titleEn = (c.title?.en || '').toLowerCase();
const titleStr = (typeof c.title === 'string' ? c.title : '').toLowerCase();
return titleTh.includes(query) || titleEn.includes(query) || titleStr.includes(query);
});
}
return result;
});
const selectCourse = async (id: number) => {
isLoadingDetail.value = true;
selectedCourse.value = null;
@ -155,10 +137,10 @@ watch(
activeCategory,
() => {
currentPage.value = 1;
loadCourses(1);
}
);
onMounted(async () => {
await loadCategories();
@ -168,7 +150,7 @@ onMounted(async () => {
activeCategory.value = Number(route.query.category_id);
}
await loadCourses();
await loadCourses(1);
if (route.query.course_id) {
selectCourse(Number(route.query.course_id));
@ -180,19 +162,19 @@ onMounted(async () => {
<div class="bg-[#F8F9FA] dark:bg-[#020617] min-h-screen p-4 md:p-8 transition-colors duration-300">
<div class="max-w-[1240px] mx-auto">
<!-- วนของการคนหาคอร (Catalog View) -->
<div v-if="!showDetail" class="bg-white dark:!bg-slate-900 rounded-[2rem] p-6 md:p-8 shadow-[0_2px_15px_rgb(0,0,0,0.02)] border border-slate-100 dark:!border-slate-800 min-h-[500px] mb-12 transition-colors">
<div v-if="!showDetail" class="bg-white dark:bg-slate-900 rounded-[2rem] p-6 md:p-8 shadow-[0_2px_15px_rgb(0,0,0,0.02)] border border-slate-100 dark:border-slate-800 min-h-[500px] mb-12">
<!-- วนหวและการคนหา -->
<div class="flex flex-col md:flex-row md:items-center justify-between gap-6 mb-8">
<h2 class="text-[1.35rem] font-bold text-slate-900 dark:text-[#f8fafc] tracking-tight">{{ $t('discovery.title') }}</h2>
<h2 class="text-[1.35rem] font-bold text-slate-900 dark:text-white tracking-tight">คอรสเรยนทงหมด</h2>
<div class="flex flex-wrap sm:flex-nowrap items-center gap-3 w-full md:w-auto">
<div class="relative w-full sm:w-[260px] flex-1">
<q-icon name="search" size="18px" class="absolute left-4 top-1/2 -translate-y-1/2 text-slate-400" />
<input v-model="searchQuery" class="w-full bg-slate-100 dark:bg-slate-800 border-none rounded-xl py-2.5 pl-11 pr-4 text-sm font-medium text-slate-700 dark:text-slate-200 placeholder:text-slate-400 focus:ring-2 focus:ring-blue-500/20 outline-none transition-all shadow-sm" :placeholder="$t('discovery.searchPlaceholder')" />
<q-icon name="search" size="18px" class="absolute left-4 top-1/2 -translate-y-1/2 text-slate-400 group-focus-within:text-[#3B6BE8]" />
<input v-model="searchQuery" @keyup.enter="loadCourses(1)" class="w-full bg-slate-100 dark:bg-slate-800 border-none rounded-xl py-2.5 pl-11 pr-4 text-sm font-medium text-slate-700 dark:text-slate-200 placeholder:text-slate-400 focus:ring-2 focus:ring-blue-500/20 outline-none transition-all shadow-sm" placeholder="ค้นหาคอร์ส..." />
</div>
<div class="flex items-center gap-2 shrink-0">
<button @click="viewMode = 'grid'" :class="viewMode === 'grid' ? 'bg-[#E9EFFD] dark:!bg-blue-900/40 text-[#3B6BE8] dark:!text-blue-400 border-[#3B6BE8] dark:!border-blue-400' : 'bg-white border-slate-200 dark:!bg-slate-800 dark:!border-slate-700 text-slate-400 dark:!text-slate-300 hover:bg-slate-50 dark:hover:!bg-slate-700'" class="w-[42px] h-[42px] flex items-center justify-center rounded-xl border transition-colors outline-none"><q-icon name="grid_view" size="20px" /></button>
<button @click="viewMode = 'list'" :class="viewMode === 'list' ? 'bg-[#E9EFFD] dark:!bg-blue-900/40 text-[#3B6BE8] dark:!text-blue-400 border-[#3B6BE8] dark:!border-blue-400' : 'bg-white border-slate-200 dark:!bg-slate-800 dark:!border-slate-700 text-slate-400 dark:!text-slate-300 hover:bg-slate-50 dark:hover:!bg-slate-700'" class="w-[42px] h-[42px] flex items-center justify-center rounded-xl border transition-colors outline-none"><q-icon name="view_list" size="20px" /></button>
<button @click="viewMode = 'grid'" :class="viewMode === 'grid' ? 'bg-[#E9EFFD] dark:bg-blue-900/40 text-[#3B6BE8] border-[#3B6BE8]' : 'bg-white border-slate-200 dark:bg-slate-800 dark:border-slate-700 text-slate-400 hover:bg-slate-50'" class="w-[42px] h-[42px] flex items-center justify-center rounded-xl border transition-colors outline-none"><q-icon name="grid_view" size="20px" /></button>
<button @click="viewMode = 'list'" :class="viewMode === 'list' ? 'bg-[#E9EFFD] dark:bg-blue-900/40 text-[#3B6BE8] border-[#3B6BE8]' : 'bg-white border-slate-200 dark:bg-slate-800 dark:border-slate-700 text-slate-400 hover:bg-slate-50'" class="w-[42px] h-[42px] flex items-center justify-center rounded-xl border transition-colors outline-none"><q-icon name="view_list" size="20px" /></button>
</div>
</div>
</div>
@ -203,17 +185,17 @@ onMounted(async () => {
<div class="flex flex-wrap items-center gap-3 w-full xl:w-auto">
<button
@click="activeCategory = 'all'"
:class="activeCategory === 'all' ? 'bg-[#E9EFFD] dark:!bg-blue-900/40 text-[#3B6BE8] dark:!text-blue-400 border-transparent font-bold' : 'bg-white dark:!bg-slate-800 border-slate-200 dark:!border-slate-700 text-slate-800 dark:!text-slate-200 hover:border-slate-300 dark:hover:!border-slate-600 font-medium'"
:class="activeCategory === 'all' ? 'bg-[#E9EFFD] dark:bg-blue-900/40 text-[#3B6BE8] border-transparent font-bold' : 'bg-white dark:bg-transparent border-slate-200 dark:border-slate-700 text-slate-800 dark:text-slate-300 hover:border-slate-300 font-medium'"
class="px-5 py-2.5 rounded-full border text-[13px] sm:text-[14px] flex items-center justify-center gap-2 transition-all outline-none">
<q-icon name="check_circle_outline" size="18px" :class="activeCategory === 'all' ? 'text-[#3B6BE8] dark:!text-blue-400' : 'text-slate-400 dark:!text-slate-400'"/> {{ $t('discovery.allCategory') }}
<q-icon name="check_circle_outline" size="18px" :class="activeCategory === 'all' ? 'text-[#3B6BE8]' : 'text-slate-400'"/> งหมด
</button>
<button
v-for="cat in categories" :key="cat.id"
@click="activeCategory = cat.id"
:class="activeCategory === cat.id ? 'bg-[#E9EFFD] dark:!bg-blue-900/40 text-[#3B6BE8] dark:!text-blue-400 border-transparent font-bold' : 'bg-white dark:!bg-slate-800 border-slate-200 dark:!border-slate-700 text-slate-800 dark:!text-slate-200 hover:border-slate-300 dark:hover:!border-slate-600 font-medium'"
class="px-5 py-2.5 rounded-full border text-[13px] sm:text-[14px] flex items-center justify-center gap-2 transition-all outline-none">
<q-icon :name="getCategoryIcon(cat.name)" size="18px" :class="activeCategory === cat.id ? 'text-[#3B6BE8] dark:!text-blue-400' : 'text-slate-600 dark:!text-slate-400'"/>
:class="activeCategory === cat.id ? 'bg-[#E9EFFD] dark:bg-blue-900/40 text-[#3B6BE8] border-transparent font-bold' : 'bg-white dark:bg-transparent border-slate-200 dark:border-slate-700 text-slate-800 dark:text-slate-300 hover:border-slate-300 font-medium'"
class="px-5 py-2.5 rounded-full border text-[13px] sm:text-[14px] flex items-center justify-center gap-2 transition-all outline-none bg-transparent">
<q-icon :name="getCategoryIcon(cat.name)" size="18px" :class="activeCategory === cat.id ? 'text-[#3B6BE8]' : 'text-slate-600 dark:text-slate-400'"/>
{{ getLocalizedText(cat.name) }}
</button>
</div>
@ -226,10 +208,10 @@ onMounted(async () => {
<q-spinner size="3rem" color="primary" />
</div>
<div v-else-if="filteredCourses.length > 0">
<div v-else-if="courses.length > 0">
<!-- GRID VIEW -->
<div v-if="viewMode === 'grid'" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
<div v-for="course in filteredCourses" :key="course.id" class="flex flex-col rounded-[1.5rem] bg-white dark:!bg-slate-900 border border-slate-100 dark:!border-slate-800 overflow-hidden shadow-[0_2px_10px_rgb(0,0,0,0.03)] hover:shadow-[0_8px_30px_rgb(0,0,0,0.08)] transition-all duration-300 group cursor-pointer" @click="selectCourse(course.id)">
<div v-for="course in courses" :key="course.id" class="flex flex-col rounded-[1.5rem] bg-white dark:bg-slate-900 border border-slate-100 dark:border-slate-800 overflow-hidden shadow-[0_2px_10px_rgb(0,0,0,0.03)] hover:shadow-[0_8px_30px_rgb(0,0,0,0.08)] transition-all duration-300 group cursor-pointer" @click="selectCourse(course.id)">
<!-- Thumbnail -->
<div class="relative w-full aspect-[16/10] bg-slate-100 dark:bg-slate-800 overflow-hidden">
<img :src="course.thumbnail_url" class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500" />
@ -240,7 +222,7 @@ onMounted(async () => {
<!-- Body -->
<div class="p-5 flex flex-col flex-1">
<h3 class="font-bold text-slate-900 dark:text-[#f8fafc] text-[15px] leading-snug line-clamp-2 mb-2 group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors">{{ getLocalizedText(course.title) }}</h3>
<h3 class="font-bold text-slate-900 dark:text-white text-[15px] leading-snug line-clamp-2 mb-2">{{ getLocalizedText(course.title) }}</h3>
@ -248,7 +230,8 @@ onMounted(async () => {
<div class="font-[900] text-[18px]" :class="course.is_free ? 'text-green-500' : 'text-[#2563EB] dark:text-blue-400'">
{{ course.formatted_price }}
</div>
<button class="w-[38px] h-[38px] rounded-full bg-slate-50 dark:!bg-slate-800 text-slate-400 dark:!text-slate-300 flex items-center justify-center hover:bg-blue-50 hover:text-blue-600 dark:hover:!bg-slate-700 dark:hover:!text-blue-400 border border-slate-100 dark:!border-slate-700 transition-colors shadow-sm outline-none">
<!-- Eye icon circle button -->
<button class="w-[38px] h-[38px] rounded-full bg-slate-50 dark:bg-slate-800 text-slate-400 dark:text-slate-500 flex items-center justify-center hover:bg-blue-50 hover:text-blue-600 dark:hover:bg-slate-700 border border-slate-100 dark:border-slate-700 transition-colors shadow-sm outline-none">
<q-icon name="visibility" size="18px" />
</button>
</div>
@ -258,7 +241,7 @@ onMounted(async () => {
<!-- LIST VIEW -->
<div v-else class="flex flex-col gap-5">
<div v-for="course in filteredCourses" :key="course.id" class="flex flex-col sm:flex-row rounded-[1.5rem] bg-white dark:!bg-slate-900 border border-slate-100 dark:!border-slate-800 p-3 sm:p-4 gap-4 sm:gap-6 shadow-sm hover:shadow-[0_8px_30px_rgb(0,0,0,0.06)] transition-all duration-300 group cursor-pointer" @click="selectCourse(course.id)">
<div v-for="course in courses" :key="course.id" class="flex flex-col sm:flex-row rounded-[1.5rem] bg-white dark:bg-slate-900 border border-slate-100 dark:border-slate-800 p-3 sm:p-4 gap-4 sm:gap-6 shadow-sm hover:shadow-[0_8px_30px_rgb(0,0,0,0.06)] transition-all duration-300 group cursor-pointer" @click="selectCourse(course.id)">
<div class="relative w-full sm:w-[260px] aspect-[16/10] sm:aspect-auto sm:h-[160px] rounded-[1rem] bg-slate-100 dark:bg-slate-800 overflow-hidden shrink-0">
<img :src="course.thumbnail_url" class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500" />
<div v-if="course.category_name" class="absolute top-3 left-3 bg-white/95 dark:bg-slate-900/95 backdrop-blur-md text-[#3B6BE8] dark:text-blue-400 font-bold text-[10px] px-3.5 py-1.5 rounded-full shadow-sm" style="border-radius: 9999px; padding: 4px 12px;">
@ -267,15 +250,15 @@ onMounted(async () => {
</div>
<div class="flex flex-col flex-1 py-1">
<div class="flex-1">
<h3 class="font-bold text-slate-900 dark:text-[#f8fafc] text-[16px] md:text-[18px] leading-snug line-clamp-2 md:line-clamp-1 mb-2 group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors">{{ getLocalizedText(course.title) }}</h3>
<h3 class="font-bold text-slate-900 dark:text-white text-[16px] md:text-[18px] leading-snug line-clamp-2 md:line-clamp-1 mb-2">{{ getLocalizedText(course.title) }}</h3>
</div>
<div class="mt-4 sm:mt-auto flex items-center justify-between">
<div class="font-[900] text-[20px]" :class="course.is_free ? 'text-green-500' : 'text-[#2563EB] dark:text-blue-400'">
{{ course.formatted_price }}
</div>
<button class="px-6 py-2 rounded-full bg-slate-50 text-slate-600 dark:!bg-slate-800 dark:!text-slate-300 font-bold text-[13px] flex items-center gap-2 hover:bg-blue-50 hover:text-blue-600 dark:hover:!bg-slate-700 dark:hover:!text-blue-400 border border-slate-100 dark:!border-slate-700 transition-colors">
<q-icon name="visibility" size="16px" /> {{ $t('discovery.viewDetails') }}
<button class="px-6 py-2 rounded-full bg-slate-50 text-slate-600 dark:bg-slate-800 dark:text-slate-300 font-bold text-[13px] flex items-center gap-2 hover:bg-blue-50 border border-slate-100 dark:border-slate-700 hover:text-blue-600 transition-colors">
<q-icon name="visibility" size="16px" /> รายละเอยด
</button>
</div>
</div>
@ -289,9 +272,9 @@ onMounted(async () => {
</div>
<!-- Empty State -->
<div v-else class="flex flex-col items-center justify-center py-20 bg-white dark:!bg-slate-900/50 rounded-3xl border border-dashed border-slate-200 dark:!border-slate-800">
<div v-else class="flex flex-col items-center justify-center py-20 bg-white dark:bg-slate-900/40 rounded-3xl border border-dashed border-slate-200 dark:border-slate-800">
<q-icon name="search_off" size="64px" class="text-slate-300 dark:text-slate-600 mb-4" />
<h3 class="text-xl font-bold text-slate-900 dark:!text-white mb-2">{{ $t("discovery.emptyTitle") }}</h3>
<h3 class="text-xl font-bold text-slate-900 dark:text-white mb-2">{{ $t("discovery.emptyTitle") }}</h3>
<p class="text-slate-500 dark:text-slate-400 text-center max-w-md">{{ $t("discovery.emptyDesc") }}</p>
<button class="mt-6 font-bold text-blue-600 hover:text-blue-700 transition-colors" @click="searchQuery = ''; activeCategory = 'all';">
{{ $t("discovery.showAll") }}

View file

@ -13,7 +13,6 @@ useHead({
title: 'คอร์สทั้งหมด - E-Learning System'
})
const { t } = useI18n()
const searchQuery = ref('')
const { fetchCourses } = useCourse()
const { fetchCategories, categories } = useCategory()
@ -55,7 +54,7 @@ await useAsyncData('categories-list', () => fetchCategories())
const { data: coursesResponse, pending: isLoading, error, refresh } = await useAsyncData(
'browse-courses-list',
() => {
const params: any = { limit: 500 }
const params: any = {}
if (selectedCategory.value !== 'all') {
const category = categories.value.find(c => c.slug === selectedCategory.value)
if (category) {
@ -132,11 +131,11 @@ const viewMode = ref<'grid' | 'list'>('grid')
<!-- วนหวและการคนหา -->
<div class="flex flex-col md:flex-row md:items-center justify-between gap-6 mb-8">
<h2 class="text-[1.35rem] font-bold text-slate-900 dark:text-white tracking-tight">{{ $t('discovery.title') }}</h2>
<h2 class="text-[1.35rem] font-bold text-slate-900 dark:text-white tracking-tight">คอรสเรยนทงหมด</h2>
<div class="flex flex-wrap sm:flex-nowrap items-center gap-3 w-full md:w-auto">
<div class="relative w-full sm:w-[260px] flex-1">
<q-icon name="search" size="18px" class="absolute left-4 top-1/2 -translate-y-1/2 text-slate-400 group-focus-within:text-[#3B6BE8]" />
<input v-model="searchQuery" class="w-full bg-slate-100 dark:bg-slate-800 border-none rounded-xl py-2.5 pl-11 pr-4 text-sm font-medium text-slate-700 dark:text-slate-200 placeholder:text-slate-400 focus:ring-2 focus:ring-blue-500/20 outline-none transition-all shadow-sm" :placeholder="$t('discovery.searchPlaceholder')" />
<input v-model="searchQuery" class="w-full bg-slate-100 dark:bg-slate-800 border-none rounded-xl py-2.5 pl-11 pr-4 text-sm font-medium text-slate-700 dark:text-slate-200 placeholder:text-slate-400 focus:ring-2 focus:ring-blue-500/20 outline-none transition-all shadow-sm" placeholder="ค้นหาคอร์ส..." />
</div>
<div class="flex items-center gap-2 shrink-0">
<button @click="viewMode = 'grid'" :class="viewMode === 'grid' ? 'bg-[#E9EFFD] dark:bg-blue-900/40 text-[#3B6BE8] border-[#3B6BE8]' : 'bg-white border-slate-200 dark:bg-slate-800 dark:border-slate-700 text-slate-400 hover:bg-slate-50'" class="w-[42px] h-[42px] flex items-center justify-center rounded-xl border transition-colors outline-none"><q-icon name="grid_view" size="20px" /></button>
@ -152,7 +151,7 @@ const viewMode = ref<'grid' | 'list'>('grid')
@click="selectCategory('all')"
:class="selectedCategory === 'all' ? 'bg-[#E9EFFD] dark:bg-blue-900/40 text-[#3B6BE8] border-transparent font-bold' : 'bg-white dark:bg-transparent border-slate-200 dark:border-slate-700 text-slate-800 dark:text-slate-300 hover:border-slate-300 font-medium'"
class="px-5 py-2.5 rounded-full border text-[13px] sm:text-[14px] flex items-center justify-center gap-2 transition-all outline-none">
<q-icon name="check_circle_outline" size="18px" :class="selectedCategory === 'all' ? 'text-[#3B6BE8]' : 'text-slate-400'"/> {{ $t('discovery.allCategory') }}
<q-icon name="check_circle_outline" size="18px" :class="selectedCategory === 'all' ? 'text-[#3B6BE8]' : 'text-slate-400'"/> งหมด
</button>
<button
@ -188,13 +187,13 @@ const viewMode = ref<'grid' | 'list'>('grid')
<h3 class="font-bold text-slate-900 dark:text-white text-[15px] leading-snug line-clamp-2 mb-2 group-hover:text-blue-600 transition-colors">{{ getLocalizedText(course.title) }}</h3>
<div class="flex items-center gap-2 mb-4">
<span class="text-[12px] text-slate-500 font-medium">{{ $t('discovery.byInstructor') }} {{ course.instructor_name }}</span>
<span class="text-[12px] text-slate-500 font-medium">โดย {{ course.instructor_name }}</span>
</div>
<div class="flex items-center gap-1.5 mb-5">
<q-icon name="star" class="text-amber-400" size="16px" />
<span class="text-[13px] font-bold text-slate-800 dark:text-slate-200">{{ course.rating }}</span>
<span class="text-[12px] text-slate-400">({{ course.reviews_count.toLocaleString() }} {{ $t('discovery.students') }})</span>
<span class="text-[12px] text-slate-400">({{ course.reviews_count.toLocaleString() }} กเรยน)</span>
</div>
<div class="mt-auto flex items-center justify-between">
@ -223,12 +222,12 @@ const viewMode = ref<'grid' | 'list'>('grid')
<div class="flex-1">
<h3 class="font-bold text-slate-900 dark:text-white text-[16px] md:text-[18px] leading-snug line-clamp-2 md:line-clamp-1 mb-2 group-hover:text-blue-600 transition-colors">{{ getLocalizedText(course.title) }}</h3>
<div class="flex items-center gap-2 mb-3">
<span class="text-[13px] text-slate-500 font-medium">{{ $t('discovery.byInstructor') }} {{ course.instructor_name }}</span>
<span class="text-[13px] text-slate-500 font-medium">โดย {{ course.instructor_name }}</span>
</div>
<div class="flex items-center gap-1.5 mb-2">
<q-icon name="star" class="text-amber-400" size="16px" />
<span class="text-[13px] font-bold text-slate-800 dark:text-slate-200">{{ course.rating }}</span>
<span class="text-[12px] text-slate-400">({{ course.reviews_count.toLocaleString() }} {{ $t('discovery.students') }})</span>
<span class="text-[12px] text-slate-400">({{ course.reviews_count.toLocaleString() }} กเรยน)</span>
</div>
</div>
<div class="mt-4 sm:mt-auto flex items-center justify-between">
@ -236,7 +235,7 @@ const viewMode = ref<'grid' | 'list'>('grid')
{{ course.formatted_price }}
</div>
<button class="px-6 py-2 rounded-full bg-slate-50 text-slate-600 dark:bg-slate-800 dark:text-slate-300 font-bold text-[13px] flex items-center gap-2 hover:bg-blue-50 border border-slate-100 dark:border-slate-700 hover:text-blue-600 transition-colors">
<q-icon name="visibility" size="16px" /> {{ $t('discovery.viewDetails') }}
<q-icon name="visibility" size="16px" /> รายละเอยด
</button>
</div>
</div>
@ -247,10 +246,10 @@ const viewMode = ref<'grid' | 'list'>('grid')
<!-- กรณไมพบขอมลคอร (Empty State) -->
<div v-else class="flex flex-col items-center justify-center py-20 bg-white dark:bg-slate-900/40 rounded-3xl border border-dashed border-slate-200 dark:border-slate-800">
<q-icon name="search_off" size="64px" class="text-slate-300 dark:text-slate-600 mb-4" />
<h3 class="text-xl font-bold text-slate-900 dark:text-white mb-2">{{ $t('discovery.emptyTitle') }}</h3>
<p class="text-slate-500 dark:text-slate-400 text-center max-w-md">{{ $t('discovery.emptyDesc') }}</p>
<h3 class="text-xl font-bold text-slate-900 dark:text-white mb-2">{{ searchQuery ? 'ไม่พบคอร์สที่คุณค้นหา' : 'ไม่มีคอร์สในหมวดหมู่นี้' }}</h3>
<p class="text-slate-500 dark:text-slate-400 text-center max-w-md">ลองใชคำคนหาอ หรอเลอกหมวดหมนเพอดคอรสทเรามใหบรการ</p>
<button class="mt-6 font-bold text-blue-600 hover:text-blue-700 transition-colors" @click="searchQuery = ''; selectedCategory = 'all';">
{{ $t('discovery.showAll') }}
แสดงคอรสทงหมด
</button>
</div>
</div>

File diff suppressed because one or more lines are too long

View file

@ -1,13 +1,40 @@
/**
* @file auth.spec.ts
* @description (Authentication) Login, Register, Forgot Password
*/
import { test, expect, type Page, type Locator } from '@playwright/test';
import {
BASE_URL, TEST_EMAIL, TEST_PASSWORD, TIMEOUT,
waitAppSettled, expectAnyVisible,
emailLocator, passwordLocator, loginButtonLocator,
} from './helpers';
const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:3000';
async function waitAppSettled(page: Page) {
await page.waitForLoadState('domcontentloaded');
await page.waitForLoadState('networkidle').catch(() => {});
await page.waitForTimeout(250);
}
// ---------------------------
// Helpers: Login
// ---------------------------
const LOGIN_EMAIL = 'studentedtest@example.com';
const LOGIN_PASSWORD = 'admin123';
function loginEmailLocator(page: Page): Locator {
return page.locator('input[type="email"]').or(page.getByRole('textbox', { name: /อีเมล|email/i })).first();
}
function loginPasswordLocator(page: Page): Locator {
return page.locator('input[type="password"]').or(page.getByRole('textbox', { name: /รหัสผ่าน|password/i })).first();
}
function loginButtonLocator(page: Page): Locator {
return page.getByRole('button', { name: /เข้าสู่ระบบ|login/i }).or(page.locator('button[type="submit"]')).first();
}
async function expectAnyVisible(page: Page, locators: Locator[], timeout = 20_000) {
const start = Date.now();
while (Date.now() - start < timeout) {
for (const loc of locators) {
try {
if (await loc.isVisible()) return;
} catch {}
}
await page.waitForTimeout(200);
}
throw new Error('None of the expected locators became visible.');
}
// ---------------------------
// Helpers: Register
@ -26,7 +53,6 @@ function regLoginLink(page: Page) { return page.getByRole('link', { name: 'เ
function regErrorBox(page: Page) {
return page.locator(['.q-field__messages', '.q-field__bottom', '.text-negative', '.q-notification', '.q-banner', '[role="alert"]'].join(', '));
}
async function pickPrefix(page: Page, value: 'นาย' | 'นาง' | 'นางสาว' = 'นาย') {
const combo = regPrefix(page);
await combo.selectOption({ label: value }).catch(async () => {
@ -34,7 +60,6 @@ async function pickPrefix(page: Page, value: 'นาย' | 'นาง' | 'นา
await page.getByRole('option', { name: value }).click();
});
}
function uniqueUser() {
const n = Date.now().toString().slice(-6);
const rand8 = Math.floor(Math.random() * 1e8).toString().padStart(8, '0');
@ -65,12 +90,10 @@ test.describe('ระบบยืนยันตัวตน (Authentication)',
test('Success Login แล้วเข้า /dashboard ได้', async ({ page }) => {
await page.goto(`${BASE_URL}/auth/login`, { waitUntil: 'domcontentloaded' });
await waitAppSettled(page);
await emailLocator(page).fill(TEST_EMAIL);
await passwordLocator(page).fill(TEST_PASSWORD);
await loginEmailLocator(page).fill(LOGIN_EMAIL);
await loginPasswordLocator(page).fill(LOGIN_PASSWORD);
await loginButtonLocator(page).click();
await page.waitForURL('**/dashboard', { timeout: TIMEOUT.LOGIN });
await page.waitForURL('**/dashboard', { timeout: 25_000 });
await waitAppSettled(page);
const dashboardEvidence = [
@ -79,45 +102,38 @@ test.describe('ระบบยืนยันตัวตน (Authentication)',
page.locator('img[src*="avataaars"]').first(),
page.locator('img[alt],[alt="User Avatar"]').first()
];
await expectAnyVisible(page, dashboardEvidence, TIMEOUT.PAGE_LOAD);
await expectAnyVisible(page, dashboardEvidence, 20_000);
});
test('Invalid Email - Thai characters (พิมพ์ภาษาไทย)', async ({ page }) => {
await page.goto(`${BASE_URL}/auth/login`, { waitUntil: 'domcontentloaded' });
await waitAppSettled(page);
await emailLocator(page).fill('ทดสอบภาษาไทย');
await passwordLocator(page).fill(TEST_PASSWORD);
await expect(page.getByText('ห้ามใส่ภาษาไทย').first()).toBeVisible({ timeout: TIMEOUT.ELEMENT });
await loginEmailLocator(page).fill('ทดสอบภาษาไทย');
await loginPasswordLocator(page).fill(LOGIN_PASSWORD);
const errorHint = page.getByText('ห้ามใส่ภาษาไทย');
await expect(errorHint.first()).toBeVisible({ timeout: 12_000 });
});
test('Invalid Email Format (อีเมลผิดรูปแบบ)', async ({ page }) => {
await page.goto(`${BASE_URL}/auth/login`, { waitUntil: 'domcontentloaded' });
await waitAppSettled(page);
await emailLocator(page).fill('test@domain');
await passwordLocator(page).fill(TEST_PASSWORD);
await loginEmailLocator(page).fill('test@domain');
await loginPasswordLocator(page).fill(LOGIN_PASSWORD);
await loginButtonLocator(page).click();
await waitAppSettled(page);
await expect(
page.getByText('กรุณากรอกอีเมลให้ถูกต้อง (you@example.com)').first()
).toBeVisible({ timeout: TIMEOUT.ELEMENT });
const errorHint = page.getByText('กรุณากรอกอีเมลให้ถูกต้อง (you@example.com)');
await expect(errorHint.first()).toBeVisible({ timeout: 12_000 });
});
test('Wrong Password (รหัสผ่านผิด หรืออีเมลไม่ถูกต้องในระบบ)', async ({ page }) => {
await page.goto(`${BASE_URL}/auth/login`, { waitUntil: 'domcontentloaded' });
await waitAppSettled(page);
await emailLocator(page).fill(TEST_EMAIL);
await passwordLocator(page).fill('wrong-password-123');
await loginEmailLocator(page).fill(LOGIN_EMAIL);
await loginPasswordLocator(page).fill('wrong-password-123');
await loginButtonLocator(page).click();
await waitAppSettled(page);
await expect(
page.getByText('กรุณาเช็ค Email หรือ รหัสผ่านใหม่อีกครั้ง').first()
).toBeVisible({ timeout: TIMEOUT.ELEMENT });
const errorHint = page.getByText('กรุณาเช็ค Email หรือ รหัสผ่านใหม่อีกครั้ง');
await expect(errorHint.first()).toBeVisible({ timeout: 12_000 });
});
});
@ -126,22 +142,21 @@ test.describe('ระบบยืนยันตัวตน (Authentication)',
test('หน้า Register ต้องโหลดได้ (Smoke)', async ({ page }) => {
await page.goto(`${BASE_URL}/auth/register`, { waitUntil: 'domcontentloaded' });
await waitAppSettled(page);
await expect(regHeading(page)).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD });
await expect(regSubmit(page)).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD });
await expect(regHeading(page)).toBeVisible({ timeout: 15_000 });
await expect(regSubmit(page)).toBeVisible({ timeout: 15_000 });
});
test('ลิงก์ "เข้าสู่ระบบ" ต้องกดแล้วไปหน้า login ได้', async ({ page }) => {
await page.goto(`${BASE_URL}/auth/register`, { waitUntil: 'domcontentloaded' });
await waitAppSettled(page);
await regLoginLink(page).click();
await page.waitForURL('**/auth/login', { timeout: TIMEOUT.PAGE_LOAD });
await page.waitForURL('**/auth/login', { timeout: 15_000 });
});
test('สมัครสมาชิกสำเร็จ (Happy Path) → redirect ไป /auth/login', async ({ page }) => {
const u = uniqueUser();
await page.goto(`${BASE_URL}/auth/register`, { waitUntil: 'domcontentloaded' });
await waitAppSettled(page);
await regUsername(page).fill(u.username);
await regEmail(page).fill(u.email);
await pickPrefix(page, 'นาย');
@ -153,18 +168,15 @@ test.describe('ระบบยืนยันตัวตน (Authentication)',
await regSubmit(page).click();
await waitAppSettled(page);
// รอ 3 สัญญาณ: redirect ไป login / success toast / error
const navToLogin = page.waitForURL('**/auth/login', { timeout: TIMEOUT.LOGIN, waitUntil: 'domcontentloaded' }).then(() => 'login' as const).catch(() => null);
const successToast = page.getByText(/สมัครสมาชิกสำเร็จ|success/i, { exact: false }).first().waitFor({ state: 'visible', timeout: TIMEOUT.LOGIN }).then(() => 'success' as const).catch(() => null);
const anyError = regErrorBox(page).first().waitFor({ state: 'visible', timeout: TIMEOUT.LOGIN }).then(() => 'error' as const).catch(() => null);
const navToLogin = page.waitForURL('**/auth/login', { timeout: 25_000, waitUntil: 'domcontentloaded' }).then(() => 'login' as const).catch(() => null);
const successToast = page.getByText(/สมัครสมาชิกสำเร็จ|success/i, { exact: false }).first().waitFor({ state: 'visible', timeout: 25_000 }).then(() => 'success' as const).catch(() => null);
const anyError = regErrorBox(page).first().waitFor({ state: 'visible', timeout: 25_000 }).then(() => 'error' as const).catch(() => null);
const result = await Promise.race([navToLogin, successToast, anyError]);
if (result === 'error') {
const errs = await regErrorBox(page).allInnerTexts().catch(() => []);
throw new Error(`Register failed with errors: ${errs.join(' | ')}`);
throw new Error('Register errors visible');
}
// ถ้ามี toast แต่ยัง redirect ไม่ไป ให้ navigate เอง
if (!page.url().includes('/auth/login')) {
const hasSuccess = await page.getByText(/สมัครสมาชิกสำเร็จ/i, { exact: false }).first().isVisible().catch(() => false);
if (hasSuccess) {
@ -173,28 +185,24 @@ test.describe('ระบบยืนยันตัวตน (Authentication)',
}
}
await expect(page).toHaveURL(/\/auth\/login/i, { timeout: TIMEOUT.PAGE_LOAD });
await expect(page.getByRole('heading', { name: 'เข้าสู่ระบบ' })).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD });
await expect(page.getByRole('button', { name: 'เข้าสู่ระบบ' })).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD });
await expect(page).toHaveURL(/\/auth\/login/i, { timeout: 15_000 });
await expect(page.getByRole('heading', { name: 'เข้าสู่ระบบ' })).toBeVisible({ timeout: 15_000 });
await expect(page.getByRole('button', { name: 'เข้าสู่ระบบ' })).toBeVisible({ timeout: 15_000 });
});
test('Invalid Email - ใส่ภาษาไทย ต้องขึ้น error', async ({ page }) => {
await page.goto(`${BASE_URL}/auth/register`, { waitUntil: 'domcontentloaded' });
await waitAppSettled(page);
await regEmail(page).fill('ทดสอบภาษาไทย');
await regUsername(page).click(); // blur trigger
const err = page
.getByText(/ห้ามใส่ภาษาไทย|อีเมลไม่ถูกต้อง|รูปแบบอีเมล/i, { exact: false })
.or(regErrorBox(page).filter({ hasText: /ไทย|อีเมล|email|invalid/i }));
await expect(err.first()).toBeVisible({ timeout: TIMEOUT.ELEMENT });
await regUsername(page).click();
const err = page.getByText(/ห้ามใส่ภาษาไทย|อีเมลไม่ถูกต้อง|รูปแบบอีเมล/i, { exact: false }).or(regErrorBox(page).filter({ hasText: /ไทย|อีเมล|email|invalid/i }));
await expect(err.first()).toBeVisible({ timeout: 12_000 });
});
test('Password ไม่ตรงกัน ต้องขึ้น error (ต้องกดสร้างบัญชีก่อน)', async ({ page }) => {
const u = uniqueUser();
await page.goto(`${BASE_URL}/auth/register`, { waitUntil: 'domcontentloaded' });
await waitAppSettled(page);
await regUsername(page).fill(u.username);
await regEmail(page).fill(u.email);
await pickPrefix(page, 'นาย');
@ -202,14 +210,11 @@ test.describe('ระบบยืนยันตัวตน (Authentication)',
await regLastName(page).fill(u.lastName);
await regPhone(page).fill(u.phone);
await regPassword(page).fill('Admin12345!');
await regConfirmPassword(page).fill('Admin12345?'); // mismatch
await regConfirmPassword(page).fill('Admin12345?');
await regSubmit(page).click();
await waitAppSettled(page);
const mismatchErr = page
.getByText(/รหัสผ่านไม่ตรงกัน/i, { exact: false })
.or(regErrorBox(page).filter({ hasText: /รหัสผ่านไม่ตรงกัน/i }));
await expect(mismatchErr.first()).toBeVisible({ timeout: TIMEOUT.ELEMENT });
const mismatchErr = page.getByText(/รหัสผ่านไม่ตรงกัน/i, { exact: false }).or(regErrorBox(page).filter({ hasText: /รหัสผ่านไม่ตรงกัน/i }));
await expect(mismatchErr.first()).toBeVisible({ timeout: 12_000 });
});
});
@ -230,12 +235,13 @@ test.describe('ระบบยืนยันตัวตน (Authentication)',
test('Validation: ใส่อีเมลภาษาไทยแล้วขึ้น Error', async ({ page }) => {
await forgotEmail(page).fill('ฟฟฟฟ');
await page.getByRole('heading', { name: /ลืมรหัสผ่าน/i }).click();
await expect(page.getByText(/ห้ามใส่ภาษาไทย/i).first()).toBeVisible({ timeout: TIMEOUT.ELEMENT });
const err = page.getByText(/ห้ามใส่ภาษาไทย/i).first();
await expect(err).toBeVisible({ timeout: 10_000 });
});
test('กดลิงก์กลับไปหน้า Login ได้', async ({ page }) => {
await forgotBackLink(page).click();
await page.waitForURL('**/auth/login', { timeout: TIMEOUT.ELEMENT });
await page.waitForURL('**/auth/login', { timeout: 10_000 });
await expect(page).toHaveURL(/\/auth\/login/i);
});
@ -251,10 +257,9 @@ test.describe('ระบบยืนยันตัวตน (Authentication)',
}
await route.continue();
});
await forgotEmail(page).fill('test@gmail.com');
await forgotSubmit(page).click();
await expect(page.getByText(/ส่งลิงก์เรียบร้อยแล้ว/i, { exact: false })).toBeVisible({ timeout: TIMEOUT.ELEMENT });
await expect(page.getByText(/ส่งลิงก์เรียบร้อยแล้ว/i, { exact: false })).toBeVisible({ timeout: 10_000 });
await expect(page.getByText(/กรุณาตรวจสอบกล่องจดหมาย/i, { exact: false })).toBeVisible();
});
});

View file

@ -1,175 +1,95 @@
/**
* @file classroom.spec.ts
* @description
* (Classroom, Learning & Quiz System)
*
* 2 module:
* - Classroom & Learning (Layout, Access Control, Video/Quiz area)
* - Quiz System (Start Screen, Pagination, Submit & Navigation)
*/
import { test, expect } from '@playwright/test';
import { BASE_URL, TIMEOUT, waitAppSettled, setupLogin } from './helpers';
// ==========================================
// Mock: ข้อมูล Quiz สำหรับ test
// ==========================================
async function mockQuizData(page: any) {
await page.route('**/lessons/*', async (route: any) => {
const mockQuestions = Array.from({ length: 15 }, (_, i) => ({
id: i + 1,
question: { th: `คำถามข้อที่ ${i + 1}?`, en: `Question ${i + 1}?` },
text: { th: `คำถามข้อที่ ${i + 1}?`, en: `Question ${i + 1}?` },
choices: [
{ id: i * 10 + 1, text: { th: 'ก', en: 'A' } },
{ id: i * 10 + 2, text: { th: 'ข', en: 'B' } },
{ id: i * 10 + 3, text: { th: 'ค', en: 'C' } }
]
}));
const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:3000';
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
success: true,
data: {
id: 17,
type: 'QUIZ',
quiz: {
id: 99,
title: { th: 'แบบทดสอบปลายภาค (Mock)', en: 'Final Exam (Mock)' },
time_limit: 30,
questions: mockQuestions
}
},
progress: {}
})
});
});
async function waitAppSettled(page: any) {
await page.waitForLoadState('domcontentloaded');
await page.waitForLoadState('networkidle').catch(() => {});
await page.waitForTimeout(200);
}
// ==========================================
// Tests
// ==========================================
test.describe('ระบบห้องเรียนออนไลน์และแบบทดสอบ (Classroom & Quiz)', () => {
// ฟังก์ชันจำลองล็อกอิน
async function setupLogin(page: any) {
await page.goto(`${BASE_URL}/auth/login`, { waitUntil: 'domcontentloaded' });
await waitAppSettled(page);
await page.locator('input[type="email"]').or(page.getByRole('textbox', { name: /อีเมล|email/i })).first().fill('studentedtest@example.com');
await page.locator('input[type="password"]').or(page.getByRole('textbox', { name: /รหัสผ่าน|password/i })).first().fill('admin123');
await page.getByRole('button', { name: /เข้าสู่ระบบ|login/i }).or(page.locator('button[type="submit"]')).first().click();
await page.waitForURL('**/dashboard', { timeout: 15_000 }).catch(() => {});
await waitAppSettled(page);
}
test.describe('ระบบห้องเรียนออนไลน์ (Classroom & Learning)', () => {
test.beforeEach(async ({ page }) => {
await setupLogin(page);
});
// --------------------------------------------------
// Section 1: ห้องเรียน (Classroom & Learning)
// --------------------------------------------------
test.describe('ห้องเรียน (Classroom Layout & Access)', () => {
test('6.1 เข้าห้องเรียนหลัก (Classroom Basic Layout)', async ({ page }) => {
// สมมติว่ามี Course ID: 1 ทดสอบแบบเปิดหน้าตรงๆ
await page.goto(`${BASE_URL}/classroom/learning?course_id=1`);
test('6.1 เข้าห้องเรียนหลัก (Classroom Basic Layout)', async ({ page }) => {
await page.goto(`${BASE_URL}/classroom/learning?course_id=1`);
// 1. โครงร่างของหน้า (Top Bar) ควรมีปุ่มกลับ กับไอคอนแผงด้านข้าง
const backBtn = page.getByRole('button').filter({ has: page.locator('i.q-icon', { hasText: 'arrow_back' }) }).first();
await expect(backBtn).toBeVisible({ timeout: 15_000 });
// 1. โครงร่างของหน้า — ปุ่มกลับ + ไอคอนแผงด้านข้าง
const backBtn = page.getByRole('button').filter({ has: page.locator('i.q-icon', { hasText: 'arrow_back' }) }).first();
await expect(backBtn).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD });
const menuCurriculumBtn = page.getByRole('button').filter({ has: page.locator('i.q-icon', { hasText: 'menu_open' }) }).first();
await expect(menuCurriculumBtn).toBeVisible({ timeout: 15_000 });
const menuCurriculumBtn = page.getByRole('button').filter({ has: page.locator('i.q-icon', { hasText: 'menu_open' }) }).first();
await expect(menuCurriculumBtn).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD });
// 2. Sidebar หลักสูตร
const sidebar = page.locator('.q-drawer').first();
if (!await sidebar.isVisible()) {
await menuCurriculumBtn.click();
}
await expect(sidebar).toBeVisible();
});
test('6.2 เช็คสถานะการเข้าถึงเนื้อหา (Access Control)', async ({ page }) => {
page.on('dialog', async dialog => {
expect(dialog.message()).toBeTruthy();
await dialog.accept();
});
await page.goto(`${BASE_URL}/classroom/learning?course_id=99999`);
const loadingMask = page.locator('.animate-pulse, .q-spinner');
await loadingMask.first().waitFor({ state: 'hidden', timeout: TIMEOUT.PAGE_LOAD }).catch(() => {});
});
test('6.3 การแสดงผลช่องวิดีโอ หรือ พื้นที่ทำข้อสอบ (Video / Quiz)', async ({ page }) => {
await page.goto(`${BASE_URL}/classroom/learning?course_id=1`);
const videoLocator = page.locator('video').first();
const quizLocator = page.getByText(/เริ่มทำแบบทดสอบ|แบบทดสอบ/i).first();
const errorLocator = page.getByText(/ไม่สามารถเข้าถึง/i).first();
try {
await Promise.race([
videoLocator.waitFor({ state: 'visible', timeout: TIMEOUT.PAGE_LOAD }),
quizLocator.waitFor({ state: 'visible', timeout: TIMEOUT.PAGE_LOAD }),
errorLocator.waitFor({ state: 'visible', timeout: TIMEOUT.PAGE_LOAD })
]);
const isOkay = (await videoLocator.isVisible()) || (await quizLocator.isVisible()) || (await errorLocator.isVisible());
expect(isOkay).toBeTruthy();
} catch {
await page.screenshot({ path: 'tests/e2e/screenshots/classroom-blank-state.png', fullPage: true });
}
});
// 2. เช็คว่ามีพื้นที่ Sidebar หลักสูตร (CurriculumSidebar Component) โผล่ขึ้นมาหรือมีอยู่ใน DOM
const sidebar = page.locator('.q-drawer').first();
if (!await sidebar.isVisible()) {
await menuCurriculumBtn.click();
}
await expect(sidebar).toBeVisible();
});
// --------------------------------------------------
// Section 2: แบบทดสอบ (Quiz System)
// --------------------------------------------------
test.describe('แบบทดสอบ (Quiz System)', () => {
test('6.2 เช็คสถานะการเข้าถึงเนื้อหา (Access Control)', async ({ page }) => {
// ลองสุ่ม Course ID สูงๆ ที่อาจจะไม่อนุญาตให้เรียน (ไม่มีสิทธิ์) ควรรองรับกล่องแจ้งเตือนด้วย Alert ของระบบ
// ใน learning.vue จะมีการสั่ง `alert(msg)` แต่อาจจะต้องพึ่งกลไก Intercepter
test('7.1 โหลดหน้า Quiz และเริ่มทำข้อสอบได้ (Start Screen)', async ({ page }) => {
await mockQuizData(page);
await page.goto(`${BASE_URL}/classroom/quiz?course_id=2&lesson_id=17`);
const startBtn = page.getByRole('button', { name: /เริ่มทำแบบทดสอบ|Start/i }).first();
await expect(startBtn).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD });
// กดเริ่มทำ
await startBtn.click();
// เช็คว่าหน้า Taking (คำถามข้อที่ 1) โผล่มา
const questionText = page.locator('h3').first();
await expect(questionText).toBeVisible({ timeout: TIMEOUT.ELEMENT });
page.on('dialog', async dialog => {
// หน้าต่าง Alert ถ้ามีสิทธิ์ไม่อนุญาตมันจะเด้งอันนี้
expect(dialog.message()).toBeTruthy();
await dialog.accept();
});
test('7.2 แถบข้อสอบแบ่งหน้า (Pagination — เลื่อนซ้าย/ขวา)', async ({ page }) => {
await mockQuizData(page);
await page.goto(`${BASE_URL}/classroom/quiz?course_id=2&lesson_id=17`);
await page.goto(`${BASE_URL}/classroom/learning?course_id=99999`);
const startBtn = page.getByRole('button', { name: /เริ่มทำแบบทดสอบ|Start/i }).first();
await expect(startBtn).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD });
await startBtn.click();
// ลูกศรเลื่อนหน้าข้อสอบ
const nextPaginationPageBtn = page.locator('button').filter({ has: page.locator('i.q-icon:has-text("chevron_right")') }).first();
if (await nextPaginationPageBtn.isVisible()) {
await expect(nextPaginationPageBtn).toBeEnabled();
await nextPaginationPageBtn.click();
// ข้อที่ 11 ต้องแสดง
const question11Btn = page.locator('button').filter({ hasText: /^11$/ }).first();
await expect(question11Btn).toBeVisible();
}
});
test('7.3 การแสดงผลปุ่มถัดไป/ส่งคำตอบ (Submit & Navigation UI)', async ({ page }) => {
await mockQuizData(page);
await page.goto(`${BASE_URL}/classroom/quiz?course_id=2&lesson_id=17`);
const startBtn = page.getByRole('button', { name: /เริ่มทำแบบทดสอบ|Start/i }).first();
await expect(startBtn).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD });
await startBtn.click();
// รอคำถามโหลดเสร็จ
await expect(page.locator('h3').first()).toBeVisible({ timeout: TIMEOUT.ELEMENT });
const submitBtn = page.locator('button').filter({ hasText: /(ส่งคำตอบ|Submit)/i }).first();
const nextBtn = page.locator('button').filter({ hasText: /(ถัดไป|Next)/i }).first();
// ข้อแรกต้องมีปุ่มถัดไปหรือปุ่มส่ง
await expect(submitBtn.or(nextBtn)).toBeVisible({ timeout: TIMEOUT.ELEMENT });
});
// รอดู Loading หายไป
const loadingMask = page.locator('.animate-pulse, .q-spinner');
await loadingMask.first().waitFor({ state: 'hidden', timeout: 20_000 }).catch(() => {});
});
test('6.3 การแสดงผลช่องวิดีโอ (Video Player) หรือ พื้นที่ทำข้อสอบ (Quiz)', async ({ page }) => {
// เข้าหน้าห้องเรียน Course id: 1
await page.goto(`${BASE_URL}/classroom/learning?course_id=1`);
// กรณีที่ 1: อาจแสดง Video ถ้าเป็นบทเรียนวิดีโอ
const videoLocator = page.locator('video').first();
// กรณีที่ 2: ถ้าบทแรกเป็น Quiz จะแสดงไอคอนแบบทดสอบ
const quizLocator = page.getByText(/เริ่มทำแบบทดสอบ|แบบทดสอบ/i).first();
// กรณีที่ 3: ไม่มีบทเรียนเนื้อหาใดๆ เลยให้แสดง
const errorLocator = page.getByText(/ไม่สามารถเข้าถึง/i).first();
try {
await Promise.race([
videoLocator.waitFor({ state: 'visible', timeout: 20_000 }),
quizLocator.waitFor({ state: 'visible', timeout: 20_000 }),
errorLocator.waitFor({ state: 'visible', timeout: 20_000 })
]);
const isOkay = (await videoLocator.isVisible()) || (await quizLocator.isVisible()) || (await errorLocator.isVisible());
expect(isOkay).toBeTruthy();
} catch {
// ถ้าไม่มีเลยใน 20 วิ ถือว่าหน้าอาจจะล้มเหลว หรือเป็น Content เปล่า
// ให้ลอง Capture เพื่อเก็บข้อมูลไปใช้งาน
await page.screenshot({ path: 'tests/e2e/screenshots/classroom-blank-state.png', fullPage: true });
}
});
});

View file

@ -1,103 +1,91 @@
/**
* @file discovery.spec.ts
* @description (Discovery & Browse)
*/
import { test, expect } from '@playwright/test';
import { BASE_URL, TIMEOUT, waitAppSettled } from './helpers';
const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:3000';
test.describe('หมวดหน้าค้นหาคอร์สและผลลัพธ์ (Discovery & Browse)', () => {
test.describe('ส่วนหน้าแรก (Home)', () => {
test('โหลดหน้าแรก และตรวจสอบแสดงผลครบถ้วน (Hero, Cards, Categories)', async ({ page }) => {
await page.goto(BASE_URL);
await waitAppSettled(page);
const heroTitle = page.locator('h1, h2, .hero-title').first();
await expect(heroTitle).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD });
await expect(heroTitle).toBeVisible({ timeout: 15_000 });
const ctaButton = page.locator('a[href="/browse"]').first();
if (await ctaButton.isVisible()) {
await expect(ctaButton).toBeVisible();
await expect(ctaButton).toBeVisible();
}
const courseSectionHeading = page.getByText(/คอร์สออนไลน์|Online Courses/i).first();
await expect(courseSectionHeading).toBeVisible({ timeout: TIMEOUT.ELEMENT });
await expect(courseSectionHeading).toBeVisible({ timeout: 10_000 });
const allCategoryBtn = page.getByRole('button', { name: /ทั้งหมด|All/i }).first();
await expect(allCategoryBtn).toBeVisible();
const courseCards = page.locator('div.cursor-pointer').filter({ has: page.locator('img') });
await expect(courseCards.first()).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD });
await expect(courseCards.first()).toBeVisible({ timeout: 15_000 });
expect(await courseCards.count()).toBeGreaterThan(0);
await page.screenshot({ path: 'tests/e2e/screenshots/discovery-home.png', fullPage: true });
});
});
test.describe('ส่วนค้นหาและแคตตาล็อก (Browse)', () => {
test('ค้นหาหลักสูตร (Search Course)', async ({ page }) => {
await page.goto(`${BASE_URL}/browse`);
await waitAppSettled(page);
const searchInput = page.locator('input[placeholder="ค้นหาคอร์ส..."]').first();
await expect(searchInput).toBeVisible({ timeout: TIMEOUT.ELEMENT });
await searchInput.fill('Python');
await searchInput.fill('การเขียนโปรแกรม');
await searchInput.press('Enter');
await waitAppSettled(page);
// ต้องเจออย่างใดอย่างหนึ่ง: ผลลัพธ์คอร์ส หรือ empty state
// ในหน้า browse จะใช้ <NuxtLink> ซึ่ง render เป็น tag <a>
const searchResults = page.locator('a[href*="/course/"]').filter({ has: page.locator('img') }).first();
const emptyState = page.getByText(/ไม่พบ|ไม่เจอ|No result|not found/i).first()
.or(page.locator('i.q-icon').filter({ hasText: 'search_off' }).first());
await expect(searchResults.or(emptyState)).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD });
await page.screenshot({ path: 'tests/e2e/screenshots/discovery-search.png', fullPage: true });
await expect(searchResults).toBeVisible({ timeout: 15_000 });
});
test('ตัวกรองหมวดหมู่คอร์ส (Category Filter)', async ({ page }) => {
await page.goto(`${BASE_URL}/browse`);
await waitAppSettled(page);
const categoryButton = page.locator('button').filter({ hasText: 'การออกแบบ' }).first();
if (await categoryButton.isVisible()) {
await categoryButton.click();
// ในหน้า browse จะใช้ <NuxtLink> ซึ่ง render เป็น tag <a>
const courseCard = page.locator('a[href*="/course/"]').filter({ has: page.locator('img') }).first();
await expect(courseCard).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD });
await expect(courseCard).toBeVisible({ timeout: 15_000 });
}
await page.screenshot({ path: 'tests/e2e/screenshots/discovery-filter.png', fullPage: true });
});
});
test.describe('หน้ารายละเอียดคอร์ส (Course Detail)', () => {
test('โหลดเนื้อหาวิชา (Curriculum) แถบรายละเอียดขึ้นปกติ', async ({ page }) => {
await page.goto(`${BASE_URL}/course/1`);
await waitAppSettled(page);
await page.goto(`${BASE_URL}`);
const courseCard = page.locator('div.cursor-pointer').filter({ has: page.locator('img') }).first();
await expect(courseCard).toBeVisible({ timeout: 10_000 });
// Get URL from navigating when clicking the div or finding another link. Since it's a div, we cannot easily get href.
// So let's click it or fallback to /course/1
const targetUrl = '/course/1';
await page.goto(`${BASE_URL}${targetUrl}`);
const courseTitle = page.locator('h1').first();
await expect(courseTitle).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD });
await expect(courseTitle).toBeVisible({ timeout: 15_000 });
const curriculumTab = page.getByRole('tab', { name: /เนื้อหาวิชา|ส่วนหลักสูตร|Curriculum/i }).first();
if (await curriculumTab.isVisible()) {
await curriculumTab.click();
await curriculumTab.click();
}
const lessonItems = page.locator('.q-expansion-item, .lesson-item, [role="listitem"]');
await expect(lessonItems.first()).toBeVisible().catch(() => {});
await page.screenshot({ path: 'tests/e2e/screenshots/discovery-curriculum.png', fullPage: true });
});
test('การแสดงผลปุ่ม เข้าเรียน/ลงทะเบียน (Enroll / Start Learning)', async ({ page }) => {
await page.goto(`${BASE_URL}/course/1`);
await waitAppSettled(page);
await page.goto(`${BASE_URL}`);
const courseCard = page.locator('div.cursor-pointer').filter({ has: page.locator('img') }).first();
await expect(courseCard).toBeVisible({ timeout: 10_000 });
const targetUrl = '/course/1';
await page.goto(`${BASE_URL}${targetUrl}`);
await page.waitForLoadState('networkidle').catch(() => {});
const enrollStartBtn = page.locator('button').filter({ hasText: /(เข้าสู่ระบบ|ลงทะเบียน|เข้าเรียน|เรียนต่อ|ซื้อ|ฟรี|Enroll|Start|Buy|Login)/i }).first();
await expect(enrollStartBtn).toBeVisible({ timeout: TIMEOUT.ELEMENT });
await page.screenshot({ path: 'tests/e2e/screenshots/discovery-enroll-btn.png', fullPage: true });
await expect(enrollStartBtn).toBeVisible({ timeout: 10_000 });
});
});
});

View file

@ -0,0 +1,102 @@
import { test, expect, type Page } from '@playwright/test';
const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:3000';
// ✅ หน้าจริงคือ /auth/forgot-password (อ้างอิงจากรูป)
const FORGOT_URL = `${BASE_URL}/auth/forgot-password`;
async function waitAppSettled(page: Page) {
await page.waitForLoadState('domcontentloaded');
await page.waitForLoadState('networkidle').catch(() => {});
await page.waitForTimeout(200);
}
function emailInput(page: Page) {
// เผื่อบางที input ไม่ได้ type=email แต่เป็น textbox ธรรมดา
return page.locator('input[type="email"]').or(page.getByRole('textbox')).first();
}
function submitBtn(page: Page) {
// ปุ่มในรูปเป็น “ส่งลิงก์รีเซ็ต”
return page.getByRole('button', { name: /ส่งลิงก์รีเซ็ต/i }).first();
}
function backToLoginLink(page: Page) {
// ในรูปเป็นลิงก์ “กลับไปหน้าเข้าสู่ระบบ”
return page.getByRole('link', { name: /กลับไปหน้าเข้าสู่ระบบ/i }).first();
}
test.describe('หน้าลืมรหัสผ่าน (Forgot Password)', () => {
test.beforeEach(async ({ page }) => {
await page.goto(FORGOT_URL, { waitUntil: 'domcontentloaded' });
await waitAppSettled(page);
});
test('3.1 โหลดหน้าลืมรหัสผ่านได้ครบถ้วน (Smoke Test)', async ({ page }) => {
await expect(page.getByRole('heading', { name: /ลืมรหัสผ่าน/i })).toBeVisible();
await expect(emailInput(page)).toBeVisible();
await expect(submitBtn(page)).toBeVisible();
await expect(backToLoginLink(page)).toBeVisible();
await page.screenshot({ path: 'tests/e2e/screenshots/forgot-01-smoke.png', fullPage: true });
});
test('3.2 Validation: ใส่อีเมลภาษาไทยแล้วขึ้น Error', async ({ page }) => {
await emailInput(page).fill('ฟฟฟฟ');
// trigger blur
await page.getByRole('heading', { name: /ลืมรหัสผ่าน/i }).click();
// ข้อความจริงในระบบ “ห้ามใส่ภาษาไทย”
const err = page.getByText(/ห้ามใส่ภาษาไทย/i).first();
await expect(err).toBeVisible({ timeout: 10_000 });
await page.screenshot({ path: 'tests/e2e/screenshots/forgot-02-thai-email.png', fullPage: true });
});
test('3.3 กดลิงก์กลับไปหน้า Login ได้', async ({ page }) => {
await backToLoginLink(page).click();
await page.waitForURL('**/auth/login', { timeout: 10_000 });
await expect(page).toHaveURL(/\/auth\/login/i);
await page.screenshot({ path: 'tests/e2e/screenshots/forgot-03-back-login.png', fullPage: true });
});
test('3.4 ทดลองส่งลิงก์รีเซ็ตรหัสผ่าน (API Mock)', async ({ page }) => {
// ✅ ดัก request แบบกว้างขึ้น: POST ที่ URL มี forgot/reset
await page.route('**/*', async (route) => {
const req = route.request();
const url = req.url();
const method = req.method();
const looksLikeForgotApi =
method === 'POST' &&
/forgot|reset/i.test(url) &&
// กันไม่ให้ไป intercept asset
!/\.(png|jpg|jpeg|webp|svg|css|js|map)$/i.test(url);
if (looksLikeForgotApi) {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ success: true, data: { message: 'Reset link sent' } }),
});
return;
}
await route.continue();
});
await emailInput(page).fill('test@gmail.com');
await submitBtn(page).click();
// ✅ ตรวจหน้าสำเร็จตามที่คุณคาดหวัง
await expect(page.getByText(/ส่งลิงก์เรียบร้อยแล้ว/i, { exact: false })).toBeVisible({ timeout: 10_000 });
await expect(page.getByText(/กรุณาตรวจสอบกล่องจดหมาย/i, { exact: false })).toBeVisible();
// ปุ่ม “ส่งอีกครั้ง” (ถ้ามี)
await expect(page.getByRole('button', { name: /ส่งอีกครั้ง/i })).toBeVisible({ timeout: 10_000 }).catch(() => {});
await page.screenshot({ path: 'tests/e2e/screenshots/forgot-04-mock-success.png', fullPage: true });
});
});

View file

@ -1,129 +0,0 @@
/**
* @file helpers.ts
* @description Shared E2E test helpers test
* รวม: waitAppSettled, login helpers, common locators, constants
*/
import { type Page, type Locator, expect } from '@playwright/test';
// ==========================================
// Constants
// ==========================================
export const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:3000';
export const TEST_EMAIL = 'studentedtest@example.com';
export const TEST_PASSWORD = 'admin123';
/** Timeout configs — ปรับค่าได้ที่เดียว */
export const TIMEOUT: Record<string, number> = {
/** รอหน้าโหลด */
PAGE_LOAD: 15_000,
/** รอ login + redirect */
LOGIN: 25_000,
/** รอ element แสดงผล */
ELEMENT: 12_000,
/** รอ network settle */
SETTLE: 300,
};
// ==========================================
// Wait Helpers
// ==========================================
/**
* (DOM + Network + hydration)
*/
export async function waitAppSettled(page: Page, ms = TIMEOUT.SETTLE) {
await page.waitForLoadState('domcontentloaded');
await page.waitForLoadState('networkidle').catch(() => {});
await page.waitForTimeout(ms);
}
/**
* locator array visible
* @throws locator visible timeout
*/
export async function expectAnyVisible(
page: Page,
locators: Locator[],
timeout = TIMEOUT.PAGE_LOAD
) {
const start = Date.now();
while (Date.now() - start < timeout) {
for (const loc of locators) {
try {
if (await loc.isVisible()) return;
} catch { /* locator detached / stale — ลองใหม่ */ }
}
await page.waitForTimeout(200);
}
throw new Error(
`None of the expected locators became visible within ${timeout}ms`
);
}
// ==========================================
// Login Locators
// ==========================================
export function emailLocator(page: Page): Locator {
return page
.locator('input[type="email"]')
.or(page.getByRole('textbox', { name: /อีเมล|email/i }))
.first();
}
export function passwordLocator(page: Page): Locator {
return page
.locator('input[type="password"]')
.or(page.getByRole('textbox', { name: /รหัสผ่าน|password/i }))
.first();
}
export function loginButtonLocator(page: Page): Locator {
return page
.getByRole('button', { name: /เข้าสู่ระบบ|login/i })
.or(page.locator('button[type="submit"]'))
.first();
}
// ==========================================
// Login Flow
// ==========================================
/**
* test account beforeEach tests authenticate
*
* @param page Playwright Page
* @param opts
* @param opts.assertDashboard (default: true) true assert dashboard
*
* @throws login dashboard
*/
export async function setupLogin(
page: Page,
opts: { assertDashboard?: boolean } = {}
) {
const { assertDashboard = true } = opts;
await page.goto(`${BASE_URL}/auth/login`, { waitUntil: 'domcontentloaded' });
await waitAppSettled(page);
// กรอกข้อมูล
await emailLocator(page).fill(TEST_EMAIL);
await passwordLocator(page).fill(TEST_PASSWORD);
await loginButtonLocator(page).click();
// รอ redirect ไป dashboard
await page.waitForURL('**/dashboard', { timeout: TIMEOUT.LOGIN });
await waitAppSettled(page);
if (assertDashboard) {
// ยืนยันว่าเข้า dashboard ได้จริง
const evidence = [
page.locator('.q-page-container').first(),
page.locator('.q-drawer').first(),
page.locator('img[src*="avataaars"]').first(),
page.locator('img[alt],[alt="User Avatar"]').first(),
];
await expectAnyVisible(page, evidence, TIMEOUT.PAGE_LOAD);
}
}

View file

@ -0,0 +1,122 @@
import { test, expect, type Page, type Locator } from '@playwright/test';
const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:3000';
// ใช้ account ตามที่คุณให้มา
const EMAIL = 'studentedtest@example.com';
const PASSWORD = 'admin123';
async function waitAppSettled(page: Page) {
await page.waitForLoadState('domcontentloaded');
await page.waitForLoadState('networkidle').catch(() => {});
await page.waitForTimeout(200);
}
function emailLocator(page: Page): Locator {
return page
.locator('input[type="email"]')
.or(page.getByRole('textbox', { name: /อีเมล|email/i }))
.first();
}
function passwordLocator(page: Page): Locator {
return page
.locator('input[type="password"]')
.or(page.getByRole('textbox', { name: /รหัสผ่าน|password/i }))
.first();
}
function loginButtonLocator(page: Page): Locator {
return page
.getByRole('button', { name: /เข้าสู่ระบบ|login/i })
.or(page.locator('button[type="submit"]'))
.first();
}
async function expectAnyVisible(page: Page, locators: Locator[], timeout = 20_000) {
const start = Date.now();
while (Date.now() - start < timeout) {
for (const loc of locators) {
try {
if (await loc.isVisible()) return;
} catch {}
}
await page.waitForTimeout(200);
}
throw new Error('None of the expected dashboard locators became visible.');
}
test.describe('Login -> Dashboard', () => {
test('Success Login แล้วเข้า /dashboard ได้', async ({ page }) => {
await page.goto(`${BASE_URL}/auth/login`, { waitUntil: 'domcontentloaded' });
await waitAppSettled(page);
await emailLocator(page).fill(EMAIL);
await passwordLocator(page).fill(PASSWORD);
await loginButtonLocator(page).click();
await page.waitForURL('**/dashboard', { timeout: 25_000 });
await waitAppSettled(page);
// ✅ ใช้ Locator ที่พบเจอแน่นอนใน Layout/Page โดยไม่ยึดติดกับภาษาปัจจุบัน (I18n)
const dashboardEvidence = [
// มองหา Layout container ฝั่ง Dashboard
page.locator('.q-page-container').first(),
page.locator('.q-drawer').first(),
// มองหารูปโปรไฟล์ (UserAvatar)
page.locator('img[src*="avataaars"]').first(),
page.locator('img[alt],[alt="User Avatar"]').first()
];
await expectAnyVisible(page, dashboardEvidence, 20_000);
await page.screenshot({ path: 'tests/e2e/screenshots/login-to-dashboard.png', fullPage: true });
});
test('Invalid Email - Thai characters (พิมพ์ภาษาไทย)', async ({ page }) => {
await page.goto(`${BASE_URL}/auth/login`, { waitUntil: 'domcontentloaded' });
await waitAppSettled(page);
await emailLocator(page).fill('ทดสอบภาษาไทย');
await passwordLocator(page).fill(PASSWORD);
const errorHint = page.getByText('ห้ามใส่ภาษาไทย');
await expect(errorHint.first()).toBeVisible({ timeout: 12_000 });
await page.screenshot({ path: 'tests/e2e/screenshots/login-thai-email.png', fullPage: true });
});
test('Invalid Email Format (อีเมลผิดรูปแบบ)', async ({ page }) => {
await page.goto(`${BASE_URL}/auth/login`, { waitUntil: 'domcontentloaded' });
await waitAppSettled(page);
// *สำคัญ*: HTML5 จะดักจับ invalid-email-format ตั้งแต่กด Submit (native validation)
// ทำให้ Vue Form ไม่เริ่มทำงาน
// ดังนั้นเพื่อให้ทดสอบเจอ Error จาก useFormValidation จริงๆ เราใช้ 'test@domain'
// ซึ่ง HTML5 <input type="email"> ปล่อยผ่าน แต่ /regex/ ของระบบตรวจเจอว่าไม่มี .com
await emailLocator(page).fill('test@domain');
await passwordLocator(page).fill(PASSWORD);
await loginButtonLocator(page).click();
await waitAppSettled(page);
const errorHint = page.getByText('กรุณากรอกอีเมลให้ถูกต้อง (you@example.com)');
await expect(errorHint.first()).toBeVisible({ timeout: 12_000 });
await page.screenshot({ path: 'tests/e2e/screenshots/login-invalid-email.png', fullPage: true });
});
test('Wrong Password (รหัสผ่านผิด หรืออีเมลไม่ถูกต้องในระบบ)', async ({ page }) => {
await page.goto(`${BASE_URL}/auth/login`, { waitUntil: 'domcontentloaded' });
await waitAppSettled(page);
await emailLocator(page).fill(EMAIL);
await passwordLocator(page).fill('wrong-password-123');
await loginButtonLocator(page).click();
await waitAppSettled(page);
const errorHint = page.getByText('กรุณาเช็ค Email หรือ รหัสผ่านใหม่อีกครั้ง');
await expect(errorHint.first()).toBeVisible({ timeout: 12_000 });
await page.screenshot({ path: 'tests/e2e/screenshots/login-wrong-password.png', fullPage: true });
});
});

View file

@ -0,0 +1,125 @@
import { test, expect } from '@playwright/test';
const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:3000';
async function waitAppSettled(page: any) {
await page.waitForLoadState('domcontentloaded');
await page.waitForLoadState('networkidle').catch(() => {});
await page.waitForTimeout(200);
}
// ฟังก์ชันจำลองล็อกอิน (เพราะทำข้อสอบต้องล็อกอินเสมอ)
async function setupLogin(page: any) {
await page.goto(`${BASE_URL}/auth/login`, { waitUntil: 'domcontentloaded' });
await waitAppSettled(page);
await page.locator('input[type="email"]').or(page.getByRole('textbox', { name: /อีเมล|email/i })).first().fill('studentedtest@example.com');
await page.locator('input[type="password"]').or(page.getByRole('textbox', { name: /รหัสผ่าน|password/i })).first().fill('admin123');
await page.getByRole('button', { name: /เข้าสู่ระบบ|login/i }).or(page.locator('button[type="submit"]')).first().click();
await page.waitForURL('**/dashboard', { timeout: 15_000 }).catch(() => {});
await waitAppSettled(page);
}
// ฟังก์ชัน Mock ข้อมูลข้อสอบให้ Playwright ไม่ต้องไปดึงจากฐานข้อมูลจริงๆ (เพื่อป้องกันปัญหาคอร์ส/บทเรียนไม่มีอยู่จริง)
async function mockQuizData(page: any) {
await page.route('**/lessons/*', async (route: any) => {
// สมมติข้อมูลข้อสอบจำลองให้มี 15 ข้อเพื่อเทส Pagination ได้
const mockQuestions = Array.from({ length: 15 }, (_, i) => ({
id: i + 1,
question: { th: `คำถามข้อที่ ${i + 1}?`, en: `Question ${i + 1}?` },
text: { th: `คำถามข้อที่ ${i + 1}?`, en: `Question ${i + 1}?` },
choices: [
{ id: i * 10 + 1, text: { th: 'ก', en: 'A' } },
{ id: i * 10 + 2, text: { th: 'ข', en: 'B' } },
{ id: i * 10 + 3, text: { th: 'ค', en: 'C' } }
]
}));
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
success: true,
data: {
id: 17,
type: 'QUIZ',
quiz: {
id: 99,
title: { th: 'แบบทดสอบปลายภาค (Mock)', en: 'Final Exam (Mock)' },
time_limit: 30,
questions: mockQuestions
}
},
progress: {}
})
});
});
}
test.describe('ระบบทำแบบทดสอบ (Quiz System)', () => {
test.beforeEach(async ({ page }) => {
// ต้อง Login ก่อนเรียน!
await setupLogin(page);
});
test('โหลดหน้า Quiz และคลิกระบบเริ่มทำข้อสอบได้ (Start Screen)', async ({ page }) => {
await mockQuizData(page);
// สมมติเอาที่ quiz ใน course 2 lesson 17 (ซึ่ง API เสาะหาจะถูกดักจับและ Mock ไว้ด้านบนแล้ว)
await page.goto(`${BASE_URL}/classroom/quiz?course_id=2&lesson_id=17`);
// หน้าจอ Start Screen ต้องขึ้นมา
const startBtn = page.getByRole('button', { name: /เริ่มทำแบบทดสอบ|Start/i }).first();
await expect(startBtn).toBeVisible({ timeout: 15_000 });
// ลองกดเริ่มทำ
await startBtn.click();
// เช็คว่าหน้า Taking (พื้นที่ทำข้อสอบข้อที่ 1) โผล่มา
const questionText = page.locator('h3').first(); // ชื่อคำถาม
await expect(questionText).toBeVisible({ timeout: 10_000 });
});
test('ทดสอบระบบแถบข้อสอบ แบ่งหน้า (Pagination - เลื่อนซ้าย/ขวา)', async ({ page }) => {
await mockQuizData(page);
await page.goto(`${BASE_URL}/classroom/quiz?course_id=2&lesson_id=17`);
// เข้าเมนูแบบทดสอบ
const startBtn = page.getByRole('button', { name: /เริ่มทำแบบทดสอบ|Start/i }).first();
await expect(startBtn).toBeVisible({ timeout: 15_000 });
await startBtn.click();
// เช็คว่ามีลูกศรเลื่อนหน้าข้อสอบ (Paginations) สำหรับแบบทดสอบเกิน 10 ข้อที่สร้างมาใหม่
const nextPaginationPageBtn = page.locator('button').filter({ has: page.locator('i.q-icon:has-text("chevron_right")') }).first();
if (await nextPaginationPageBtn.isVisible()) {
// หากปุ่มแสดง (บอกว่ามีข้อสอบหลายหน้า) ลองกดข้าม
await expect(nextPaginationPageBtn).toBeEnabled();
await nextPaginationPageBtn.click();
// เช็คว่ากดแล้ว ข้อที่ 11 โผล่มา
const question11Btn = page.locator('button').filter({ hasText: /^11$/ }).first();
await expect(question11Btn).toBeVisible();
}
});
test('การแสดงผลปุ่มถัดไป/ส่งคำตอบ (Submit & Navigation UI)', async ({ page }) => {
await mockQuizData(page);
await page.goto(`${BASE_URL}/classroom/quiz?course_id=2&lesson_id=17`);
const startBtn = page.getByRole('button', { name: /เริ่มทำแบบทดสอบ|Start/i }).first();
await expect(startBtn).toBeVisible({ timeout: 15_000 });
await startBtn.click();
// รอให้หน้าโหลดคำถามเสร็จก่อน ค่อยหาปุ่ม
await expect(page.locator('h3').first()).toBeVisible({ timeout: 10_000 });
const submitBtn = page.locator('button').filter({ hasText: /(ส่งคำตอบ|Submit)/i }).first();
const nextBtn = page.locator('button').filter({ hasText: /(ถัดไป|Next)/i }).first();
// แบบทดสอบข้อแรก ต้องมีปุ่ม ถัดไป(Next) หรือปุ่มส่ง ถ้ามีแค่ 1 ข้อ
await expect(submitBtn.or(nextBtn)).toBeVisible({ timeout: 10_000 });
});
});

View file

@ -0,0 +1,241 @@
import { test, expect, type Page, type Locator } from '@playwright/test';
const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:3000';
async function waitAppSettled(page: Page) {
await page.waitForLoadState('domcontentloaded');
await page.waitForLoadState('networkidle').catch(() => {});
await page.waitForTimeout(250);
}
// ===== Anchors / Scope =====
function headingRegister(page: Page) {
return page.getByRole('heading', { name: 'สร้างบัญชีผู้ใช้งาน' });
}
// ===== Inputs (ตาม snapshot ที่คุณส่งมา) =====
function usernameInput(page: Page): Locator {
// snapshot: textbox "username"
return page.getByRole('textbox', { name: 'username' }).first();
}
function emailInput(page: Page): Locator {
// snapshot: textbox "student@example.com"
return page.getByRole('textbox', { name: 'student@example.com' }).first();
}
function prefixCombobox(page: Page): Locator {
// snapshot: combobox มี option นาย/นาง/นางสาว
return page.getByRole('combobox').first();
}
function firstNameInput(page: Page): Locator {
// snapshot: label "ชื่อ *" + textbox
return page.getByText(/^ชื่อ\s*\*$/).locator('..').getByRole('textbox').first();
}
function lastNameInput(page: Page): Locator {
return page.getByText(/^นามสกุล\s*\*$/).locator('..').getByRole('textbox').first();
}
function phoneInput(page: Page): Locator {
return page.getByText(/^เบอร์โทรศัพท์\s*\*$/).locator('..').getByRole('textbox').first();
}
function passwordInput(page: Page): Locator {
// snapshot: label "รหัสผ่าน *" + textbox (มีปุ่ม visibility อยู่ข้างๆ)
return page.getByText(/^รหัสผ่าน\s*\*$/).locator('..').getByRole('textbox').first();
}
function confirmPasswordInput(page: Page): Locator {
return page.getByText(/^ยืนยันรหัสผ่าน\s*\*$/).locator('..').getByRole('textbox').first();
}
function submitButton(page: Page): Locator {
return page.getByRole('button', { name: 'สร้างบัญชี' });
}
function loginLink(page: Page): Locator {
return page.getByRole('link', { name: 'เข้าสู่ระบบ' });
}
function errorBox(page: Page): Locator {
// ทั้ง field message และ notification/toast/alert
return page.locator(
['.q-field__messages', '.q-field__bottom', '.text-negative', '.q-notification', '.q-banner', '[role="alert"]'].join(
', '
)
);
}
async function pickPrefix(page: Page, value: 'นาย' | 'นาง' | 'นางสาว' = 'นาย') {
const combo = prefixCombobox(page);
// ถ้าเป็น <select> จริง selectOption จะเวิร์คทันที
await combo.selectOption({ label: value }).catch(async () => {
// fallback: คลิกแล้วเลือก option
await combo.click();
await page.getByRole('option', { name: value }).click();
});
}
// ===== Test data =====
function uniqueUser() {
const n = Date.now().toString().slice(-6);
// ✅ แก้ปัญหา "Phone number already exists" ด้วยเบอร์สุ่มไม่ซ้ำ
// รูปแบบ 09xxxxxxxx (10 หลัก)
const rand8 = Math.floor(Math.random() * 1e8).toString().padStart(8, '0');
const phone = `09${rand8}`;
return {
username: `e2e_user_${n}`,
email: `e2e_${n}@example.com`,
firstName: 'ทดสอบ',
lastName: 'ระบบ',
phone,
password: 'Admin12345!',
};
}
// ================== TESTS ==================
test.describe('Register Page (auth/register)', () => {
test('หน้า Register ต้องโหลดได้ (Smoke)', async ({ page }) => {
await page.goto(`${BASE_URL}/auth/register`, { waitUntil: 'domcontentloaded' });
await waitAppSettled(page);
await expect(headingRegister(page)).toBeVisible({ timeout: 15_000 });
await expect(submitButton(page)).toBeVisible({ timeout: 15_000 });
await page.screenshot({ path: 'tests/e2e/screenshots/register-page.png', fullPage: true });
});
test('ลิงก์ "เข้าสู่ระบบ" ต้องกดแล้วไปหน้า login ได้', async ({ page }) => {
await page.goto(`${BASE_URL}/auth/register`, { waitUntil: 'domcontentloaded' });
await waitAppSettled(page);
await loginLink(page).click();
await page.waitForURL('**/auth/login', { timeout: 15_000 });
await page.screenshot({ path: 'tests/e2e/screenshots/register-go-login.png', fullPage: true });
});
test('สมัครสมาชิกสำเร็จ (Happy Path) → redirect ไป /auth/login', async ({ page }) => {
const u = uniqueUser();
await page.goto(`${BASE_URL}/auth/register`, { waitUntil: 'domcontentloaded' });
await waitAppSettled(page);
await usernameInput(page).fill(u.username);
await emailInput(page).fill(u.email);
await pickPrefix(page, 'นาย');
await firstNameInput(page).fill(u.firstName);
await lastNameInput(page).fill(u.lastName);
await phoneInput(page).fill(u.phone);
await passwordInput(page).fill(u.password);
await confirmPasswordInput(page).fill(u.password);
await submitButton(page).click();
await waitAppSettled(page);
// ✅ รอ 3 อย่าง: ไป login / success toast / error
const navToLogin = page
.waitForURL('**/auth/login', { timeout: 25_000, waitUntil: 'domcontentloaded' })
.then(() => 'login' as const)
.catch(() => null);
const successToast = page
.getByText(/สมัครสมาชิกสำเร็จ|success/i, { exact: false })
.first()
.waitFor({ state: 'visible', timeout: 25_000 })
.then(() => 'success' as const)
.catch(() => null);
const anyError = errorBox(page)
.first()
.waitFor({ state: 'visible', timeout: 25_000 })
.then(() => 'error' as const)
.catch(() => null);
const result = await Promise.race([navToLogin, successToast, anyError]);
// ถ้ามี error ให้ fail พร้อม log ชัดๆ
if (result === 'error') {
const errs = await errorBox(page).allInnerTexts().catch(() => []);
await page.screenshot({ path: 'tests/e2e/screenshots/register-happy-error.png', fullPage: true });
throw new Error(`Register did not redirect. Errors: ${errs.join(' | ')}`);
}
// ถ้ามีแต่ toast success แต่ยังไม่ redirect ให้ไปหน้า login เอง (ตาม flow ที่คุณต้องการ)
if (!page.url().includes('/auth/login')) {
const hasSuccess = await page
.getByText(/สมัครสมาชิกสำเร็จ/i, { exact: false })
.first()
.isVisible()
.catch(() => false);
if (hasSuccess) {
await page.goto(`${BASE_URL}/auth/login`, { waitUntil: 'domcontentloaded' });
await waitAppSettled(page);
}
}
// ✅ สุดท้ายต้องอยู่หน้า /auth/login แน่นอน
await expect(page).toHaveURL(/\/auth\/login/i, { timeout: 15_000 });
// ✅ แก้ strict mode: ระบุให้ชัดว่าเป็น heading และ button
await expect(page.getByRole('heading', { name: 'เข้าสู่ระบบ' })).toBeVisible({ timeout: 15_000 });
await expect(page.getByRole('button', { name: 'เข้าสู่ระบบ' })).toBeVisible({ timeout: 15_000 });
// optional: การ์ด TEST ACCOUNT (ถ้ามี)
await expect(page.getByText(/TEST ACCOUNT/i, { exact: false }))
.toBeVisible({ timeout: 10_000 })
.catch(() => {});
await page.screenshot({ path: 'tests/e2e/screenshots/register-redirect-login.png', fullPage: true });
});
test('Invalid Email - ใส่ภาษาไทย ต้องขึ้น error', async ({ page }) => {
await page.goto(`${BASE_URL}/auth/register`, { waitUntil: 'domcontentloaded' });
await waitAppSettled(page);
await emailInput(page).fill('ทดสอบภาษาไทย');
await usernameInput(page).click(); // blur trigger
const err = page
.getByText(/ห้ามใส่ภาษาไทย|อีเมลไม่ถูกต้อง|รูปแบบอีเมล/i, { exact: false })
.or(errorBox(page).filter({ hasText: /ไทย|อีเมล|email|invalid/i }));
await expect(err.first()).toBeVisible({ timeout: 12_000 });
await page.screenshot({ path: 'tests/e2e/screenshots/register-invalid-email-thai.png', fullPage: true });
});
test('Password ไม่ตรงกัน ต้องขึ้น error (ต้องกดสร้างบัญชีก่อน)', async ({ page }) => {
const u = uniqueUser();
await page.goto(`${BASE_URL}/auth/register`, { waitUntil: 'domcontentloaded' });
await waitAppSettled(page);
await usernameInput(page).fill(u.username);
await emailInput(page).fill(u.email);
await pickPrefix(page, 'นาย');
await firstNameInput(page).fill(u.firstName);
await lastNameInput(page).fill(u.lastName);
await phoneInput(page).fill(u.phone);
await passwordInput(page).fill('Admin12345!');
await confirmPasswordInput(page).fill('Admin12345?'); // mismatch
// ✅ ต้อง submit ก่อน error ถึงขึ้น
await submitButton(page).click();
await waitAppSettled(page);
const mismatchErr = page
.getByText(/รหัสผ่านไม่ตรงกัน/i, { exact: false })
.or(errorBox(page).filter({ hasText: /รหัสผ่านไม่ตรงกัน/i }));
await expect(mismatchErr.first()).toBeVisible({ timeout: 12_000 });
await page.screenshot({ path: 'tests/e2e/screenshots/register-password-mismatch.png', fullPage: true });
});
});

View file

@ -1,58 +1,54 @@
/**
* @file student-account.spec.ts
* @description (Student Account / Portal)
*/
import { test, expect } from '@playwright/test';
import { BASE_URL, TIMEOUT, waitAppSettled, setupLogin } from './helpers';
const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:3000';
async function waitAppSettled(page: any) {
await page.waitForLoadState('domcontentloaded');
await page.waitForLoadState('networkidle').catch(() => {});
await page.waitForTimeout(200);
}
// ฟังก์ชันจำลองล็อกอิน (เพื่อที่จะเข้า Dashboard ได้)
async function setupLogin(page: any) {
await page.goto(`${BASE_URL}/auth/login`, { waitUntil: 'domcontentloaded' });
await waitAppSettled(page);
await page.locator('input[type="email"]').or(page.getByRole('textbox', { name: /อีเมล|email/i })).first().fill('studentedtest@example.com');
await page.locator('input[type="password"]').or(page.getByRole('textbox', { name: /รหัสผ่าน|password/i })).first().fill('admin123');
await page.getByRole('button', { name: /เข้าสู่ระบบ|login/i }).or(page.locator('button[type="submit"]')).first().click();
await page.waitForURL('**/dashboard', { timeout: 15_000 }).catch(() => {});
await waitAppSettled(page);
}
test.describe('ระบบพื้นที่ส่วนตัวผู้เรียน (Student Account / Portal)', () => {
test.describe('การตั้งค่าและส่วนติดต่อผู้ใช้ (Settings & UI Theme)', () => {
test('เปลี่ยนภาษาการแสดงผล (Localisation/i18n)', async ({ page }) => {
await page.goto(BASE_URL);
await waitAppSettled(page);
const langBtn = page.getByRole('button', { name: 'Language' }).or(page.locator('button').filter({ hasText: /TH|EN/ })).first();
// หาปุ่มภาษา — ถ้าไม่เจอให้ test.skip แทนที่จะผ่านเงียบๆ
const langBtn = page.getByRole('button', { name: 'Language' })
.or(page.locator('button').filter({ hasText: /TH|EN/ }))
.first();
if (await langBtn.isVisible()) {
await langBtn.click();
const englishOpt = page.locator('text=English, text=EN').first();
await englishOpt.click();
const isLangBtnVisible = await langBtn.isVisible().catch(() => false);
if (!isLangBtnVisible) {
test.skip(true, 'Language button not found on page — skipping');
return;
const loginLink = page.locator('a[href*="login"], button').filter({ hasText: /Log in|Sign In/i });
await expect(loginLink).toBeVisible({ timeout: 5000 });
}
await langBtn.click();
const englishOpt = page.locator('text=English, text=EN').first();
await englishOpt.click();
const loginLink = page.locator('a[href*="login"], button').filter({ hasText: /Log in|Sign In/i });
await expect(loginLink).toBeVisible({ timeout: TIMEOUT.ELEMENT });
await page.screenshot({ path: 'tests/e2e/screenshots/student-i18n.png', fullPage: true });
});
test('เปลี่ยนโหมดมืดสว่าง (Theme Switcher)', async ({ page }) => {
await page.goto(BASE_URL);
await waitAppSettled(page);
// หาปุ่ม Theme — ถ้าไม่เจอให้ test.skip แทนที่จะผ่านเงียบๆ
const themeBtn = page.locator('.dark-mode-toggle, button[aria-label*="theme"]').first();
const isThemeBtnVisible = await themeBtn.isVisible().catch(() => false);
if (!isThemeBtnVisible) {
test.skip(true, 'Theme toggle button not found on page — skipping');
return;
if (await themeBtn.isVisible()) {
const htmlBefore = await page.evaluate(() => document.documentElement.className);
await themeBtn.click();
await page.waitForTimeout(500);
const htmlAfter = await page.evaluate(() => document.documentElement.className);
expect(htmlBefore).not.toEqual(htmlAfter);
}
const htmlBefore = await page.evaluate(() => document.documentElement.className);
await themeBtn.click();
await page.waitForTimeout(500);
const htmlAfter = await page.evaluate(() => document.documentElement.className);
expect(htmlBefore).not.toEqual(htmlAfter);
await page.screenshot({ path: 'tests/e2e/screenshots/student-theme.png', fullPage: true });
});
});
@ -63,77 +59,60 @@ test.describe('ระบบพื้นที่ส่วนตัวผู้
test('หน้าแรกของ Dashboard โหลดได้ปกติ', async ({ page }) => {
await page.goto(`${BASE_URL}/dashboard`);
await waitAppSettled(page, 1000);
await page.waitForTimeout(1000);
const welcomeText = page.getByText(/ยินดีต้อนรับกลับ/i, { exact: false });
const profileSummary = page.locator('.q-avatar, img[alt*="Profile"], img[src*="avatar"]').first();
await expect(welcomeText.first().or(profileSummary)).toBeVisible({ timeout: TIMEOUT.ELEMENT });
await page.screenshot({ path: 'tests/e2e/screenshots/student-dashboard.png', fullPage: true });
await expect(welcomeText.first().or(profileSummary)).toBeVisible({ timeout: 10_000 });
});
test('โหลดหน้า คอร์สของฉัน (My Courses)', async ({ page }) => {
await page.goto(`${BASE_URL}/dashboard/my-courses`);
await waitAppSettled(page);
const heading = page.locator('h2').filter({ hasText: /คอร์สของฉัน|My Courses/i }).first();
await expect(heading).toBeVisible({ timeout: TIMEOUT.ELEMENT });
await expect(heading).toBeVisible();
const searchInput = page.getByPlaceholder(/ค้นหา|Search/i).first();
await expect(searchInput).toBeVisible({ timeout: TIMEOUT.ELEMENT });
await expect(searchInput).toBeVisible({ timeout: 10_000 });
await expect(page.locator('i.q-icon').filter({ hasText: 'grid_view' }).first()).toBeVisible();
await expect(page.locator('i.q-icon').filter({ hasText: 'view_list' }).first()).toBeVisible();
await page.screenshot({ path: 'tests/e2e/screenshots/student-my-courses.png', fullPage: true });
});
test('ลองค้นหาคอร์ส (Search Input) ไม่พบข้อมูล', async ({ page }) => {
await page.goto(`${BASE_URL}/dashboard/my-courses`);
await waitAppSettled(page);
const searchInput = page.getByPlaceholder(/ค้นหา|Search/i).first();
await expect(searchInput).toBeVisible({ timeout: TIMEOUT.ELEMENT });
await expect(searchInput).toBeVisible({ timeout: 10_000 });
await searchInput.fill('คอร์สที่ไม่มีอยู่จริงแน่นอน1234');
const emptyState = page.locator('h3').filter({ hasText: /ไม่พบ|ไม่เจอ|No result/i }).first()
.or(page.locator('i.q-icon').filter({ hasText: 'search_off' }));
await expect(emptyState.first()).toBeVisible({ timeout: TIMEOUT.ELEMENT });
await page.screenshot({ path: 'tests/e2e/screenshots/student-search-empty.png', fullPage: true });
await expect(emptyState.first()).toBeVisible({ timeout: 10_000 });
});
test('แก้ไขและบันทึกข้อมูลส่วนตัว (Edit Profile)', async ({ page }) => {
await page.goto(`${BASE_URL}/dashboard/profile`);
await waitAppSettled(page, 1000);
// หา input ชื่อ — ใช้ textbox "First Name" หรือ input[type="text"] ตัวแรก
const nameInput = page.getByRole('textbox', { name: /First Name|ชื่อ/i }).first()
.or(page.locator('input[type="text"]').first());
const nameInput = page.locator('input[type="text"]').first();
const isNameVisible = await nameInput.isVisible().catch(() => false);
if (!isNameVisible) {
test.skip(true, 'Profile name input not found — skipping');
return;
if (await nameInput.isVisible()) {
const oldName = await nameInput.inputValue();
await nameInput.clear();
await nameInput.fill(`${oldName}แก้ไข`);
const saveBtn = page.getByRole('button', { name: /บันทึก/i }).first();
if(await saveBtn.isVisible()) {
await saveBtn.click();
const successNotify = page.locator('.q-notification__message, text=อัปเดตข้อมูลสำเร็จ').first();
await expect(successNotify).toBeVisible({ timeout: 5000 }).catch(() => {});
}
}
const oldName = await nameInput.inputValue();
await nameInput.clear();
await nameInput.fill(`${oldName}แก้ไข`);
// ปุ่มบันทึก — รองรับทั้งภาษาไทยและอังกฤษ
const saveBtn = page.getByRole('button', { name: /บันทึก|Save Changes|Save/i }).first();
await expect(saveBtn).toBeVisible({ timeout: TIMEOUT.ELEMENT });
await saveBtn.click();
// Toast สำเร็จ — รองรับทั้ง 2 ภาษา
const successNotify = page.getByText(/อัปเดตข้อมูลสำเร็จ|บันทึกข้อมูล|updated|saved|success/i).first();
await expect(successNotify).toBeVisible({ timeout: TIMEOUT.ELEMENT }).catch(() => {});
await page.screenshot({ path: 'tests/e2e/screenshots/student-edit-profile.png', fullPage: true });
});
});
});

View file

@ -343,12 +343,10 @@ const save = async () => {
saving.value = true;
try {
// Convert local datetime to ISO string to preserve timezone
const payload: any = { ...form.value };
const payload = { ...form.value };
if (payload.published_at) {
const localDate = new Date(payload.published_at.replace(' ', 'T'));
payload.published_at = localDate.toISOString();
} else {
delete payload.published_at;
}
if (editing.value) {
@ -449,7 +447,10 @@ const deleteAttachment = async (attachmentId: number) => {
}
};
// Date formatting function is auto-imported from utils/date.ts
const formatDate = (dateStr: string) => {
const date = new Date(dateStr);
return date.toLocaleDateString('th-TH', { day: 'numeric', month: 'short', year: 'numeric' });
};
const formatFileSize = (bytes: number) => {
if (bytes < 1024) return bytes + ' B';

View file

@ -20,7 +20,7 @@
v-for="item in history"
:key="item.id"
:title="titleMap[item.action] || item.action"
:subtitle="formatDateTime(item.created_at)"
:subtitle="formatDate(item.created_at)"
:color="colorMap[item.action] || 'grey'"
:icon="iconMap[item.action] || 'circle'"
>
@ -91,7 +91,12 @@ const getActorName = (item: ApprovalHistory) => {
return actor.username || actor.email || 'Unknown User';
};
// Date formatting function is auto-imported from utils/date.ts
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleString('th-TH', {
dateStyle: 'medium',
timeStyle: 'short'
});
};
onMounted(() => {
fetchHistory();

View file

@ -450,7 +450,14 @@ const openStudentDetail = async (studentId: number) => {
const formatDate = (dateStr: string) => {
if (!dateStr) return '-';
return formatDateTime(dateStr);
const date = new Date(dateStr);
return date.toLocaleDateString('th-TH', {
day: 'numeric',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
};
// Lifecycle

View file

@ -404,7 +404,8 @@ const getStudentStatusLabel = (status: string) => {
};
const formatEnrollDate = (dateStr: string) => {
return formatDate(dateStr);
const date = new Date(dateStr);
return date.toLocaleDateString('th-TH', { day: 'numeric', month: 'short', year: 'numeric' });
};
const getLessonTypeIcon = (type: string) => {
@ -435,7 +436,8 @@ const formatVideoTime = (seconds: number) => {
const formatCompletedDate = (dateStr: string | null) => {
if (!dateStr) return '-';
return formatDate(dateStr);
const date = new Date(dateStr);
return date.toLocaleDateString('th-TH', { day: 'numeric', month: 'short' });
};
// Fetch on mount

View file

@ -136,7 +136,7 @@
<!-- Created At Custom Column -->
<template v-slot:body-cell-created_at="props">
<q-td :props="props">
{{ formatDateTime(props.value) }}
{{ formatDate(props.value) }}
</q-td>
</template>
@ -168,8 +168,8 @@
<q-badge :color="getActionColor(selectedLog.action)">{{ selectedLog.action }}</q-badge>
</div>
<div>
<div class="text-subtitle2 text-grey">Date & Time</div>
<div>{{ formatDateTime(selectedLog.created_at) }}</div>
<div class="text-subtitle2 text-grey">Time</div>
<div>{{ formatDate(selectedLog.created_at) }}</div>
</div>
<div>
@ -241,7 +241,7 @@
</template>
<script setup lang="ts">
import { useQuasar, type QTableColumn } from 'quasar';
import { useQuasar } from 'quasar';
import { adminService, type AuditLog, type AuditLogStats } from '~/services/admin.service';
definePageMeta({
@ -284,15 +284,15 @@ const pagination = ref({
});
// Table setup
const columns: QTableColumn[] = [
{ name: 'id', label: 'ID', field: 'id', align: 'left' as const, style: 'width: 60px' },
{ name: 'action', label: 'Action', field: 'action', align: 'left' as const },
{ name: 'user', label: 'User', field: 'user', align: 'left' as const },
{ name: 'entity_type', label: 'Entity Type', field: 'entity_type', align: 'left' as const },
{ name: 'entity_id', label: 'Entity ID', field: 'entity_id', align: 'left' as const },
const columns = [
{ name: 'id', label: 'ID', field: 'id', align: 'left', style: 'width: 60px' },
{ name: 'action', label: 'Action', field: 'action', align: 'left' },
{ name: 'user', label: 'User', field: 'user', align: 'left' },
{ name: 'entity_type', label: 'Entity Type', field: 'entity_type', align: 'left' },
{ name: 'entity_id', label: 'Entity ID', field: 'entity_id', align: 'left' },
{ name: 'created_at', label: 'Date & Time', field: 'created_at', align: 'left' as const },
{ name: 'actions', label: '', field: 'actions', align: 'center' as const }
{ name: 'created_at', label: 'Time', field: 'created_at', align: 'left' },
{ name: 'actions', label: '', field: 'actions', align: 'center' }
];
// Actions options (for filtering)
@ -416,25 +416,18 @@ const tryFormatJson = (str: string | null) => {
}
};
// Date formatting function is auto-imported from utils/date.ts
const ACTION_COLOR_MAP: Record<string, string> = {
DELETE: 'negative',
REJECT: 'negative',
DEACTIVATE: 'negative',
ERROR: 'negative',
UPDATE: 'warning',
CHANGE: 'warning',
CREATE: 'positive',
APPROVE: 'positive',
ACTIVATE: 'positive',
LOGIN: 'info',
const formatDate = (date: string) => {
if (!date) return '-';
return new Date(date).toLocaleString('th-TH');
};
const getActionColor = (action: string) => {
if (!action) return 'grey';
const keyword = Object.keys(ACTION_COLOR_MAP).find((key) => action.includes(key));
return keyword ? ACTION_COLOR_MAP[keyword] : 'grey-8';
if (!action) return 'grey';
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';
return 'grey-8';
};
// Check for deep link to detail
@ -450,12 +443,10 @@ onMounted(() => {
:deep(input[type=number]::-webkit-outer-spin-button),
:deep(input[type=number]::-webkit-inner-spin-button) {
-webkit-appearance: none;
appearance: none;
margin: 0;
}
:deep(input[type=number]) {
-moz-appearance: textfield;
appearance: textfield;
}
</style>

View file

@ -233,7 +233,13 @@ const fetchCategories = async () => {
}
};
// Date formatting function is auto-imported from utils/date.ts
const formatDate = (date: string) => {
return new Date(date).toLocaleDateString('th-TH', {
year: 'numeric',
month: '2-digit',
day: '2-digit'
});
};
const resetForm = () => {
form.value = {
@ -301,17 +307,8 @@ const handleSave = async () => {
const confirmDelete = (category: CategoryResponse) => {
$q.dialog({
title: 'ยืนยันการลบ',
message: `คุณต้องการลบหมวดหมู่ "${category.name.th}" หรือไม่?<br><span style="color: red;">การลบหมวดหมู่นี้จะทำให้หมวดหมู่ถูกลบออกจากหลักสูตรทั้งหมดที่ใช้งานอยู่</span>`,
html: true,
cancel: {
label: 'ยกเลิก',
color: 'grey',
flat: true
},
ok: {
label: 'ลบหมวดหมู่',
color: 'negative'
},
message: `คุณต้องการลบหมวดหมู่ "${category.name.th}" หรือไม่?`,
cancel: true,
persistent: true
}).onOk(async () => {
try {

View file

@ -56,7 +56,7 @@
<div class="p-6">
<div class="flex flex-wrap gap-2 mb-4">
<q-badge :color="getStatusColor(course.status)" :label="getStatusLabel(course.status)" />
<q-badge color="grey" :label="course.category?.name?.th || 'ไม่มีหมวดหมู่'" />
<q-badge color="grey" :label="course.category.name.th" />
<q-badge v-if="course.is_free" color="green" label="ฟรี" />
<q-badge v-else color="blue" :label="`฿${course.price.toLocaleString()}`" />
<q-badge v-if="course.have_certificate" color="purple" label="มีใบประกาศนียบัตร" />
@ -356,7 +356,23 @@ const getActionColor = (action: string) => {
return colors[action] || 'grey';
};
// Date formatting functions are auto-imported from utils/date.ts
const formatDate = (date: string) => {
return new Date(date).toLocaleDateString('th-TH', {
day: 'numeric',
month: 'short',
year: '2-digit'
});
};
const formatDateTime = (date: string) => {
return new Date(date).toLocaleDateString('th-TH', {
day: 'numeric',
month: 'short',
year: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
};
const confirmApprove = () => {
if (!course.value) return;

View file

@ -135,7 +135,7 @@
<div v-if="course.latest_submission" class="mt-3 text-sm text-gray-500">
<q-icon name="send" size="16px" class="mr-1" />
งโดย {{ course.latest_submission.submitter.username }}
เม {{ formatDateTime(course.latest_submission.created_at) }}
เม {{ formatDate(course.latest_submission.created_at) }}
</div>
</div>
@ -203,7 +203,7 @@
<template v-slot:body-cell-submitted_at="props">
<q-td :props="props">
<div v-if="props.row.latest_submission">
<div class="text-xs">{{ formatDateTime(props.row.latest_submission.created_at) }}</div>
<div class="text-xs">{{ formatDate(props.row.latest_submission.created_at) }}</div>
<div class="text-xs text-gray-500">โดย {{ props.row.latest_submission.submitter.username }}</div>
</div>
<span v-else>-</span>
@ -298,7 +298,15 @@ const getPrimaryInstructor = (course: PendingCourse) => {
return primary?.user.username || course.creator.username;
};
// Date formatting function is auto-imported from utils/date.ts
const formatDate = (date: string) => {
return new Date(date).toLocaleDateString('th-TH', {
day: 'numeric',
month: 'short',
year: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
};
const viewCourse = (course: PendingCourse) => {
router.push(`/admin/courses/${course.id}`);

View file

@ -136,7 +136,7 @@
<p class="text-xs text-gray-500 truncate">โดย {{ course.creator.username }}</p>
</div>
<div class="text-xs text-gray-400 whitespace-nowrap">
{{ formatDateStr(course.created_at) }}
{{ formatDate(course.created_at) }}
</div>
</div>
</div>
@ -170,7 +170,7 @@
<span class="text-gray-600 mx-1">{{ formatAction(log.action) }}</span>
<span class="text-primary-700 font-medium">{{ log.entity_type }} #{{ log.entity_id }}</span>
</p>
<p class="text-xs text-gray-400 mt-0.5">{{ formatDateStr(log.created_at) }}</p>
<p class="text-xs text-gray-400 mt-0.5">{{ formatDate(log.created_at) }}</p>
</div>
</div>
</div>
@ -254,7 +254,14 @@ const fetchDashboardData = async () => {
};
// Utilities
const formatDateStr = (date: string) => formatDateTime(date);
const formatDate = (date: string) => {
return new Date(date).toLocaleDateString('th-TH', {
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit'
});
};
const getActionIcon = (action: string) => {
if (action.includes('create')) return 'add_circle';

View file

@ -301,7 +301,7 @@
<script setup lang="ts">
import { useQuasar } from 'quasar';
import { userService } from '~/services/user.service';
import { userService, type UserProfileResponse } from '~/services/user.service';
import { authService } from '~/services/auth.service';
definePageMeta({
@ -368,8 +368,20 @@ const getRoleLabel = (role: string) => {
return labels[role] || role;
};
// Use formatting utilities from utils/date.ts
// Format functions are auto-imported
const formatDate = (date: string, includeTime = true) => {
const options: Intl.DateTimeFormatOptions = {
day: 'numeric',
month: 'short',
year: '2-digit'
};
if (includeTime) {
options.hour = '2-digit';
options.minute = '2-digit';
}
return new Date(date).toLocaleDateString('th-TH', options);
};
// Avatar upload
const avatarInputRef = ref<HTMLInputElement | null>(null);
@ -413,8 +425,8 @@ const handleAvatarUpload = async (event: Event) => {
try {
const response = await userService.uploadAvatar(file);
// Force refresh profile cache and update local state
await fetchProfile(true);
// Re-fetch profile to get presigned URL from backend
await fetchProfile();
$q.notify({
type: 'positive',
@ -445,8 +457,8 @@ const handleUpdateProfile = async () => {
phone: editForm.value.phone || null
});
// Force refresh profile cache and update local state
await fetchProfile(true);
// Refresh profile data from API
await fetchProfile();
$q.notify({
type: 'positive',
@ -534,29 +546,25 @@ watch(showEditModal, (newVal) => {
}
});
// Helper to map fullProfile to local profile state
const mapProfileData = (data: typeof authStore.fullProfile) => {
if (!data) return;
profile.value = {
fullName: `${data.profile.first_name} ${data.profile.last_name}`,
email: data.email,
emailVerified: !!data.email_verified_at,
username: data.username,
phone: data.profile.phone || '',
role: data.role.code,
roleName: data.role.name.th,
avatar: '',
avatarUrl: data.profile.avatar_url,
createdAt: data.created_at
};
};
// Fetch profile uses auth store cache, force=true to refresh
const fetchProfile = async (force = false) => {
// Fetch profile from API
const fetchProfile = async () => {
loading.value = true;
try {
await authStore.fetchUserProfile(force);
mapProfileData(authStore.fullProfile);
const data = await userService.getProfile();
// Map API response to profile
profile.value = {
fullName: `${data.profile.first_name} ${data.profile.last_name}`,
email: data.email,
emailVerified: !!data.email_verified_at,
username: data.username,
phone: data.profile.phone || '',
role: data.role.code,
roleName: data.role.name.th,
avatar: '',
avatarUrl: data.profile.avatar_url,
createdAt: data.created_at
};
} catch (error) {
$q.notify({
type: 'negative',
@ -568,7 +576,7 @@ const fetchProfile = async (force = false) => {
}
};
// Load profile on mount (uses cache if available)
// Load profile on mount
onMounted(() => {
fetchProfile();
});

View file

@ -153,8 +153,7 @@
<!-- Category -->
<div class="bg-gray-50 p-4 rounded-lg gap-2">
<div class="font-bold mb-2">หมวดหม (Category):</div>
<div v-if="selectedCourse.category" class="text-gray-700 mb-2">{{ selectedCourse.category.name.th }} ({{ selectedCourse.category.name.en }})</div>
<div v-else class="text-gray-400 italic mb-2">ไมหมวดหม</div>
<div class="text-gray-700 mb-2">{{ selectedCourse.category.name.th }} ({{ selectedCourse.category.name.en }})</div>
</div>
<!-- Instructors -->
@ -263,7 +262,7 @@ const columns = [
field: (row: RecommendedCourse) => row.instructors?.find((i: any) => i.is_primary)?.user.username || '',
align: 'left' as const
},
{ name: 'category', label: 'Category', field: (row: RecommendedCourse) => row.category?.name?.th || 'ไม่มีหมวดหมู่', sortable: true, align: 'left' as const },
{ name: 'category', label: 'Category', field: (row: RecommendedCourse) => row.category.name.th, sortable: true, align: 'left' as const },
{ name: 'price', label: 'Price', field: 'price', sortable: true, align: 'right' as const },
{ name: 'is_recommended', label: 'Recommended', field: 'is_recommended', sortable: true, align: 'center' as const },
{ name: 'actions', label: 'Actions', field: 'actions', align: 'center' as const },

View file

@ -324,7 +324,13 @@ const getRoleBadgeColor = (roleCode: string) => {
return colors[roleCode] || 'grey';
};
// Date formatting function is auto-imported from utils/date.ts
const formatDate = (date: string) => {
return new Date(date).toLocaleDateString('th-TH', {
day: 'numeric',
month: 'short',
year: '2-digit'
});
};
const viewUser = (user: AdminUserResponse) => {
selectedUser.value = user;

View file

@ -449,7 +449,13 @@ const getStatusLabel = (status: string) => {
return labels[status] || status;
};
// Date formatting function is auto-imported from utils/date.ts
const formatDate = (date: string) => {
return new Date(date).toLocaleDateString('th-TH', {
day: 'numeric',
month: 'short',
year: '2-digit'
});
};
// Clone Dialog
const cloneDialog = ref(false);
const cloneLoading = ref(false);

View file

@ -64,21 +64,21 @@
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<q-card class="p-6 text-center">
<div class="text-4xl font-bold text-primary-600 mb-2">
{{ stats.totalCourses }}
{{ instructorStore.stats.totalCourses }}
</div>
<div class="text-gray-600">หลกสตรทงหมด</div>
</q-card>
<q-card class="p-6 text-center">
<div class="text-4xl font-bold text-secondary-600 mb-2">
{{ stats.totalStudents }}
{{ instructorStore.stats.totalStudents }}
</div>
<div class="text-gray-600">เรยนทงหมด</div>
</q-card>
<q-card class="p-6 text-center">
<div class="text-4xl font-bold text-accent-600 mb-2">
{{ stats.completedStudents }}
{{ instructorStore.stats.completedStudents }}
</div>
<div class="text-gray-600">เรยนจบแล</div>
</q-card>
@ -96,28 +96,28 @@
<q-icon name="check_circle" color="green" size="24px" />
<span class="font-medium text-gray-700">เผยแพรแล</span>
</div>
<span class="text-2xl font-bold text-green-600">{{ courseStatusCounts.approved }}</span>
<span class="text-2xl font-bold text-green-600">{{ instructorStore.courseStatusCounts.approved }}</span>
</div>
<div class="flex items-center justify-between p-3 bg-orange-50 rounded-lg">
<div class="flex items-center gap-3">
<q-icon name="hourglass_empty" color="orange" size="24px" />
<span class="font-medium text-gray-700">รอตรวจสอบ</span>
</div>
<span class="text-2xl font-bold text-orange-600">{{ courseStatusCounts.pending }}</span>
<span class="text-2xl font-bold text-orange-600">{{ instructorStore.courseStatusCounts.pending }}</span>
</div>
<div class="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
<div class="flex items-center gap-3">
<q-icon name="edit_note" color="grey" size="24px" />
<span class="font-medium text-gray-700">แบบราง</span>
</div>
<span class="text-2xl font-bold text-gray-600">{{ courseStatusCounts.draft }}</span>
<span class="text-2xl font-bold text-gray-600">{{ instructorStore.courseStatusCounts.draft }}</span>
</div>
<div v-if="courseStatusCounts.rejected > 0" class="flex items-center justify-between p-3 bg-red-50 rounded-lg">
<div v-if="instructorStore.courseStatusCounts.rejected > 0" class="flex items-center justify-between p-3 bg-red-50 rounded-lg">
<div class="flex items-center gap-3">
<q-icon name="cancel" color="red" size="24px" />
<span class="font-medium text-gray-700">กปฏเสธ</span>
</div>
<span class="text-2xl font-bold text-red-600">{{ courseStatusCounts.rejected }}</span>
<span class="text-2xl font-bold text-red-600">{{ instructorStore.courseStatusCounts.rejected }}</span>
</div>
</div>
</q-card-section>
@ -138,7 +138,7 @@
<div class="space-y-4">
<q-card
v-for="course in recentCourses"
v-for="course in instructorStore.recentCourses"
:key="course.id"
class="cursor-pointer hover:shadow-md transition"
@click="router.push(`/instructor/courses/${course.id}`)"
@ -172,7 +172,6 @@
<script setup lang="ts">
import { useQuasar } from 'quasar';
import { instructorService } from '~/services/instructor.service';
definePageMeta({
layout: 'instructor',
@ -180,32 +179,10 @@ definePageMeta({
});
const authStore = useAuthStore();
const instructorStore = useInstructorStore();
const router = useRouter();
const $q = useQuasar();
// Dashboard local state
const stats = ref({
totalCourses: 0,
totalStudents: 0,
completedStudents: 0
});
const courseStatusCounts = ref({
approved: 0,
pending: 0,
draft: 0,
rejected: 0
});
const recentCourses = ref<{
id: number;
title: string;
students: number;
lessons: number;
icon: string;
thumbnail: string | null;
}[]>([]);
// Navigation functions
const goToProfile = () => {
router.push('/instructor/profile');
@ -235,41 +212,9 @@ const handleLogout = () => {
});
};
// Fetch dashboard data
const fetchDashboardData = async () => {
try {
const [courses, studentStats] = await Promise.all([
instructorService.getCourses(),
instructorService.getMyStudentsStats()
]);
stats.value.totalCourses = courses.length;
stats.value.totalStudents = studentStats.total_students;
stats.value.completedStudents = studentStats.total_completed;
courseStatusCounts.value = {
approved: courses.filter(c => c.status === 'APPROVED').length,
pending: courses.filter(c => c.status === 'PENDING').length,
draft: courses.filter(c => c.status === 'DRAFT').length,
rejected: courses.filter(c => c.status === 'REJECTED').length
};
recentCourses.value = courses.slice(0, 3).map(course => ({
id: course.id,
title: course.title.th,
students: 0,
lessons: 0,
icon: 'book',
thumbnail: course.thumbnail_url || null
}));
} catch (error) {
console.error('Failed to fetch dashboard data:', error);
}
};
// Fetch data on mount
// Fetch dashboard data on mount
onMounted(() => {
instructorStore.fetchDashboardData();
authStore.fetchUserProfile();
fetchDashboardData();
});
</script>

View file

@ -301,7 +301,7 @@
<script setup lang="ts">
import { useQuasar } from 'quasar';
import { userService } from '~/services/user.service';
import { userService, type UserProfileResponse } from '~/services/user.service';
import { authService } from '~/services/auth.service';
definePageMeta({
@ -368,8 +368,20 @@ const getRoleLabel = (role: string) => {
return labels[role] || role;
};
// Use formatting utilities from utils/date.ts
// Format functions are auto-imported
const formatDate = (date: string, includeTime = true) => {
const options: Intl.DateTimeFormatOptions = {
day: 'numeric',
month: 'short',
year: '2-digit'
};
if (includeTime) {
options.hour = '2-digit';
options.minute = '2-digit';
}
return new Date(date).toLocaleDateString('th-TH', options);
};
// Avatar upload
const avatarInputRef = ref<HTMLInputElement | null>(null);
@ -413,8 +425,8 @@ const handleAvatarUpload = async (event: Event) => {
try {
const response = await userService.uploadAvatar(file);
// Force refresh profile cache and update local state
await fetchProfile(true);
// Re-fetch profile to get presigned URL from backend
await fetchProfile();
$q.notify({
type: 'positive',
@ -445,8 +457,8 @@ const handleUpdateProfile = async () => {
phone: editForm.value.phone || null
});
// Force refresh profile cache and update local state
await fetchProfile(true);
// Refresh profile data from API
await fetchProfile();
$q.notify({
type: 'positive',
@ -534,29 +546,25 @@ watch(showEditModal, (newVal) => {
}
});
// Helper to map fullProfile to local profile state
const mapProfileData = (data: typeof authStore.fullProfile) => {
if (!data) return;
profile.value = {
fullName: `${data.profile.first_name} ${data.profile.last_name}`,
email: data.email,
emailVerified: !!data.email_verified_at,
username: data.username,
phone: data.profile.phone || '',
role: data.role.code,
roleName: data.role.name.th,
avatar: '',
avatarUrl: data.profile.avatar_url,
createdAt: data.created_at
};
};
// Fetch profile uses auth store cache, force=true to refresh
const fetchProfile = async (force = false) => {
// Fetch profile from API
const fetchProfile = async () => {
loading.value = true;
try {
await authStore.fetchUserProfile(force);
mapProfileData(authStore.fullProfile);
const data = await userService.getProfile();
// Map API response to profile
profile.value = {
fullName: `${data.profile.first_name} ${data.profile.last_name}`,
email: data.email,
emailVerified: !!data.email_verified_at,
username: data.username,
phone: data.profile.phone || '',
role: data.role.code,
roleName: data.role.name.th,
avatar: '',
avatarUrl: data.profile.avatar_url,
createdAt: data.created_at
};
} catch (error) {
$q.notify({
type: 'negative',
@ -568,7 +576,7 @@ const fetchProfile = async (force = false) => {
}
};
// Load profile on mount (uses cache if available)
// Load profile on mount
onMounted(() => {
fetchProfile();
});

View file

@ -36,7 +36,7 @@
<q-input
v-model="form.email"
label="อีเมล *"
type="text"
type="email"
outlined
:rules="[
val => !!val || 'กรุณากรอกอีเมล',

View file

@ -34,11 +34,11 @@ export default defineConfig({
use: {
baseURL: 'http://localhost:3000/',// ปรับเป็น URL ที่ทดสอบ
headless: false, // false = เห็น browser ขณะรัน
screenshot: 'on', // เก็บ screenshot
screenshot: 'only-on-failure', // เก็บ screenshot เมื่อ fail
trace: 'retain-on-failure', // เก็บ trace เพื่อดีบักเมื่อ fail
launchOptions: {
slowMo: 500,
}, // ช้าลง 10 วินาที
// launchOptions: {
// slowMo: 1000,
// }, // ช้าลง 10 วินาที
},
/* ──── Setup Projects: login ครั้งเดียว แล้ว save cookies ──── */

View file

@ -3,20 +3,35 @@ export interface LoginRequest {
password: string;
}
// API Response structure (from backend) - new format: only token/refreshToken
// API Response structure (from backend)
export interface ApiLoginResponse {
token: string;
refreshToken: string;
}
// JWT Payload structure (decoded from token)
export interface JwtPayload {
id: number;
username: string;
email: string;
roleCode: string;
iat: number;
exp: number;
user: {
id: number;
username: string;
email: string;
updated_at: string;
created_at: string;
role: {
code: string;
name: {
en: string;
th: string;
};
};
profile: {
prefix: {
en: string;
th: string;
};
first_name: string;
last_name: string;
phone: string | null;
avatar_url: string | null;
birth_date: string | null;
};
};
}
// Frontend User structure
@ -40,21 +55,6 @@ export interface ApiResponse<T> {
data: T;
}
/**
* Decode JWT payload without verification (read-only)
* Verification is handled by the backend on each request
*/
function decodeJwtPayload(token: string): JwtPayload {
const base64Url = token.split('.')[1];
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
const jsonPayload = decodeURIComponent(
atob(base64).split('').map(c =>
'%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2)
).join('')
);
return JSON.parse(jsonPayload);
}
export const authService = {
async login(email: string, password: string): Promise<LoginResponse> {
const config = useRuntimeConfig();
@ -71,26 +71,22 @@ export const authService = {
const loginData = response.data;
// Decode JWT to get user info
const payload = decodeJwtPayload(loginData.token);
// Check if user role is STUDENT - block login
if (payload.roleCode === 'STUDENT') {
if (loginData.user.role.code === 'STUDENT') {
throw new Error('ไม่สามารถเข้าสู่ระบบได้ ระบบนี้สำหรับผู้สอนและผู้ดูแลระบบเท่านั้น');
}
// Return basic user info from JWT payload
// Full profile will be fetched via fetchUserProfile() in the auth store
// Transform API response to frontend format
return {
token: loginData.token,
refreshToken: loginData.refreshToken,
user: {
id: payload.id.toString(),
email: payload.email,
firstName: '',
lastName: '',
role: payload.roleCode,
avatarUrl: null
id: loginData.user.id.toString(),
email: loginData.user.email,
firstName: loginData.user.profile.first_name,
lastName: loginData.user.profile.last_name,
role: loginData.user.role.code,
avatarUrl: loginData.user.profile.avatar_url
},
message: response.message || 'เข้าสู่ระบบสำเร็จ'
};

View file

@ -610,19 +610,6 @@ export const instructorService = {
{ method: 'DELETE' }
);
},
async getMyStudentsStats(): Promise<{ total_students: number; total_completed: number }> {
const response = await authRequest<{
code: number;
message: string;
total_students: number;
total_completed: number;
}>('/api/instructors/courses/my-students');
return {
total_students: response.total_students,
total_completed: response.total_completed
};
},
async getCourseApprovalHistory(courseId: number): Promise<ApprovalHistory[]> {
const response = await authRequest<{
code: number;

View file

@ -1,6 +1,6 @@
import { defineStore } from 'pinia';
import { authService } from '~/services/auth.service';
import { userService, type UserProfileResponse } from '~/services/user.service';
import { userService } from '~/services/user.service';
interface User {
id: string;
@ -15,8 +15,7 @@ export const useAuthStore = defineStore('auth', {
state: () => ({
user: null as User | null,
token: null as string | null,
isAuthenticated: false,
fullProfile: null as UserProfileResponse | null
isAuthenticated: false
}),
getters: {
@ -62,7 +61,6 @@ export const useAuthStore = defineStore('auth', {
this.user = null;
this.token = null;
this.isAuthenticated = false;
this.fullProfile = null;
// Clear cookies
const tokenCookie = useCookie('token');
@ -128,16 +126,10 @@ export const useAuthStore = defineStore('auth', {
}
},
async fetchUserProfile(force = false) {
// Skip if already cached (unless force refresh)
if (!force && this.fullProfile) return;
async fetchUserProfile() {
try {
const response = await userService.getProfile();
// Cache raw API response
this.fullProfile = response;
// Update local user state
this.user = {
id: response.id.toString(),

View file

@ -0,0 +1,122 @@
import { defineStore } from 'pinia';
import { instructorService } from '~/services/instructor.service';
interface Course {
id: number;
title: string;
students: number;
lessons: number;
icon: string;
thumbnail: string | null;
}
interface DashboardStats {
totalCourses: number;
totalStudents: number;
completedStudents: number;
}
interface CourseStatusCounts {
approved: number;
pending: number;
draft: number;
rejected: number;
}
export const useInstructorStore = defineStore('instructor', {
state: () => ({
stats: {
totalCourses: 0,
totalStudents: 0,
completedStudents: 0
} as DashboardStats,
courseStatusCounts: {
approved: 0,
pending: 0,
draft: 0,
rejected: 0
} as CourseStatusCounts,
recentCourses: [] as Course[],
loading: false
}),
getters: {
getDashboardStats: (state) => state.stats,
getRecentCourses: (state) => state.recentCourses
},
actions: {
async fetchDashboardData() {
this.loading = true;
try {
// Fetch real courses from API
const courses = await instructorService.getCourses();
// Fetch student counts for each course
let totalStudents = 0;
let completedStudents = 0;
const courseDetails: Course[] = [];
for (const course of courses.slice(0, 5)) {
try {
// Get student counts
const studentsResponse = await instructorService.getEnrolledStudents(course.id, 1, 1);
const courseStudents = studentsResponse.total || 0;
totalStudents += courseStudents;
// Get completed count from full list (if small) or estimate
if (courseStudents > 0 && courseStudents <= 100) {
const allStudents = await instructorService.getEnrolledStudents(course.id, 1, 100);
completedStudents += allStudents.data.filter(s => s.status === 'COMPLETED').length;
}
// Get lesson count from course detail
const courseDetail = await instructorService.getCourseById(course.id);
const lessonCount = courseDetail.chapters.reduce((sum, ch) => sum + ch.lessons.length, 0);
courseDetails.push({
id: course.id,
title: course.title.th,
students: courseStudents,
lessons: lessonCount,
icon: 'book',
thumbnail: course.thumbnail_url || null
});
} catch (e) {
// Course might not have students endpoint
courseDetails.push({
id: course.id,
title: course.title.th,
students: 0,
lessons: 0,
icon: 'book',
thumbnail: course.thumbnail_url || null
});
}
}
// Update stats
this.stats.totalCourses = courses.length;
this.stats.totalStudents = totalStudents;
this.stats.completedStudents = completedStudents;
// Update course status counts
this.courseStatusCounts = {
approved: courses.filter(c => c.status === 'APPROVED').length,
pending: courses.filter(c => c.status === 'PENDING').length,
draft: courses.filter(c => c.status === 'DRAFT').length,
rejected: courses.filter(c => c.status === 'REJECTED').length
};
// Update recent courses (first 3)
this.recentCourses = courseDetails.slice(0, 3);
} catch (error) {
console.error('Failed to fetch dashboard data:', error);
} finally {
this.loading = false;
}
}
}
});

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Some files were not shown because too many files have changed in this diff Show more