diff --git a/Backend/src/controllers/AdminCourseApprovalController.ts b/Backend/src/controllers/AdminCourseApprovalController.ts index f10b99ca..fc60b670 100644 --- a/Backend/src/controllers/AdminCourseApprovalController.ts +++ b/Backend/src/controllers/AdminCourseApprovalController.ts @@ -1,10 +1,11 @@ import { Body, Get, Path, Post, Request, Response, Route, Security, SuccessResponse, Tags } from 'tsoa'; import { ValidationError } from '../middleware/errorHandler'; import { AdminCourseApprovalService } from '../services/AdminCourseApproval.service'; -import { RejectCourseValidator } from '../validators/AdminCourseApproval.validator'; +import { ApproveCourseValidator, RejectCourseValidator } from '../validators/AdminCourseApproval.validator'; import { ListPendingCoursesResponse, GetCourseDetailForAdminResponse, + ApproveCourseBody, ApproveCourseResponse, RejectCourseBody, RejectCourseResponse, @@ -60,12 +61,19 @@ export class AdminCourseApprovalController { @Response('404', 'Course not found') public async approveCourse( @Request() request: any, - @Path() courseId: number + @Path() courseId: number, + @Body() body?: ApproveCourseBody ): Promise { const token = request.headers.authorization?.replace('Bearer ', ''); if (!token) throw new ValidationError('No token provided'); - return await AdminCourseApprovalService.approveCourse(token, courseId, undefined); + // Validate body if provided + if (body) { + const { error } = ApproveCourseValidator.validate(body); + if (error) throw new ValidationError(error.details[0].message); + } + + return await AdminCourseApprovalService.approveCourse(token, courseId, body?.comment); } /** diff --git a/Backend/src/controllers/AuditController.ts b/Backend/src/controllers/AuditController.ts index a78c8d5a..5de912fc 100644 --- a/Backend/src/controllers/AuditController.ts +++ b/Backend/src/controllers/AuditController.ts @@ -169,8 +169,8 @@ export class AuditController { throw new ValidationError('No token provided'); } - if (days < 6) { - throw new ValidationError('Cannot delete logs newer than 6 days'); + if (days < 30) { + throw new ValidationError('Cannot delete logs newer than 30 days'); } const deleted = await auditService.deleteOldLogs(days); diff --git a/Backend/src/controllers/RecommendedCoursesController.ts b/Backend/src/controllers/RecommendedCoursesController.ts index 720bff7c..06bff36d 100644 --- a/Backend/src/controllers/RecommendedCoursesController.ts +++ b/Backend/src/controllers/RecommendedCoursesController.ts @@ -20,14 +20,10 @@ export class RecommendedCoursesController { @SuccessResponse('200', 'Approved courses retrieved successfully') @Response('401', 'Unauthorized') @Response('403', 'Forbidden - Admin only') - public async listApprovedCourses( - @Request() request: any, - @Query() search?: string, - @Query() categoryId?: number - ): Promise { + public async listApprovedCourses(@Request() request: any): Promise { const token = request.headers.authorization?.replace('Bearer ', ''); if (!token) throw new ValidationError('No token provided'); - return await RecommendedCoursesService.listApprovedCourses(token, { search, categoryId }); + return await RecommendedCoursesService.listApprovedCourses(token); } /** diff --git a/Backend/src/controllers/UserController.ts b/Backend/src/controllers/UserController.ts index ccbe7c76..b8169827 100644 --- a/Backend/src/controllers/UserController.ts +++ b/Backend/src/controllers/UserController.ts @@ -10,8 +10,7 @@ import { ChangePasswordResponse, updateAvatarResponse, SendVerifyEmailResponse, - VerifyEmailResponse, - rolesResponse + VerifyEmailResponse } from '../types/user.types'; import { ChangePassword } from '../types/auth.types'; import { profileUpdateSchema, changePasswordSchema } from "../validators/user.validator"; @@ -57,18 +56,6 @@ export class UserController { return await this.userService.updateProfile(token, body); } - @Get('roles') - @Security('jwt') - @SuccessResponse('200', 'Roles retrieved successfully') - @Response('401', 'Invalid or expired token') - public async getRoles(@Request() request: any): Promise { - const token = request.headers.authorization?.replace('Bearer ', ''); - if (!token) { - throw new ValidationError('No token provided'); - } - return await this.userService.getRoles(token); - } - /** * Change password * @summary Change user password using old password diff --git a/Backend/src/services/AdminCourseApproval.service.ts b/Backend/src/services/AdminCourseApproval.service.ts index 0596034c..f9446457 100644 --- a/Backend/src/services/AdminCourseApproval.service.ts +++ b/Backend/src/services/AdminCourseApproval.service.ts @@ -113,7 +113,7 @@ export class AdminCourseApprovalService { /** * Get course details for admin review */ - static async getCourseDetail(token: string, courseId: number): Promise { + static async getCourseDetail(token: string,courseId: number): Promise { try { const course = await prisma.course.findUnique({ where: { id: courseId }, @@ -133,11 +133,7 @@ export class AdminCourseApprovalService { }, chapters: { orderBy: { sort_order: 'asc' }, - select: { - id: true, - title: true, - sort_order: true, - is_published: true, + include: { lessons: { orderBy: { sort_order: 'asc' }, select: { diff --git a/Backend/src/services/CoursesStudent.service.ts b/Backend/src/services/CoursesStudent.service.ts index 986695b1..0e9e5b86 100644 --- a/Backend/src/services/CoursesStudent.service.ts +++ b/Backend/src/services/CoursesStudent.service.ts @@ -340,19 +340,6 @@ export class CoursesStudentService { throw new ForbiddenError('You are not enrolled in this course'); } - // Update last_accessed_at (fire-and-forget — ไม่ block response) - if (enrollment.status === 'ENROLLED') { - prisma.enrollment.update({ - where: { - unique_enrollment: { - user_id: decoded.id, - course_id, - }, - }, - data: { last_accessed_at: new Date() }, - }).catch(err => logger.warn(`Failed to update last_accessed_at: ${err}`)); - } - // Get all lesson progress for this user and course const lessonIds = course.chapters.flatMap(ch => ch.lessons.map(l => l.id)); const lessonProgress = await prisma.lessonProgress.findMany({ @@ -1262,17 +1249,17 @@ export class CoursesStudentService { } catch (error) { logger.error(`Error completing lesson: ${error}`); const decoded = jwt.decode(input.token) as { id: number } | null; - await auditService.logSync({ - userId: decoded?.id || 0, - action: AuditAction.ERROR, - entityType: 'LessonProgress', - entityId: input.lesson_id, - metadata: { - operation: 'complete_lesson', - lesson_id: input.lesson_id, - error: error instanceof Error ? error.message : String(error) - } - }); + await auditService.logSync({ + userId: decoded?.id || 0, + action: AuditAction.ERROR, + entityType: 'LessonProgress', + entityId: input.lesson_id, + metadata: { + operation: 'complete_lesson', + lesson_id: input.lesson_id, + error: error instanceof Error ? error.message : String(error) + } + }); throw error; } } diff --git a/Backend/src/services/RecommendedCourses.service.ts b/Backend/src/services/RecommendedCourses.service.ts index 22440eb2..9c185c8a 100644 --- a/Backend/src/services/RecommendedCourses.service.ts +++ b/Backend/src/services/RecommendedCourses.service.ts @@ -8,8 +8,7 @@ import { ListApprovedCoursesResponse, GetCourseByIdResponse, ToggleRecommendedResponse, - RecommendedCourseData, - RecommendedCourseDetailData + RecommendedCourseData } from '../types/RecommendedCourses.types'; import { auditService } from './audit.service'; import { AuditAction } from '@prisma/client'; @@ -19,24 +18,10 @@ export class RecommendedCoursesService { /** * List all approved courses (for admin to manage recommendations) */ - static async listApprovedCourses( - token: string, - filters?: { search?: string; categoryId?: number } - ): Promise { + static async listApprovedCourses(token: string): Promise { try { - const { search, categoryId } = filters ?? {}; - const courses = await prisma.course.findMany({ - where: { - status: 'APPROVED', - ...(categoryId ? { category_id: categoryId } : {}), - ...(search ? { - OR: [ - { title: { path: ['th'], string_contains: search } }, - { title: { path: ['en'], string_contains: search } } - ] - } : {}) - }, + where: { status: 'APPROVED' }, orderBy: [ { is_recommended: 'desc' }, { updated_at: 'desc' } @@ -55,9 +40,9 @@ export class RecommendedCoursesService { } } }, - _count: { - select: { - chapters: true + chapters: { + include: { + lessons: true } } } @@ -96,7 +81,8 @@ export class RecommendedCoursesService { is_primary: i.is_primary, user: i.user })), - chapters_count: course._count.chapters, + chapters_count: course.chapters.length, + lessons_count: course.chapters.reduce((sum, ch) => sum + ch.lessons.length, 0) } as RecommendedCourseData; })); @@ -172,7 +158,7 @@ export class RecommendedCoursesService { } } - const data: RecommendedCourseDetailData = { + const data: RecommendedCourseData = { id: course.id, title: course.title as { th: string; en: string }, slug: course.slug, @@ -195,15 +181,8 @@ export class RecommendedCoursesService { is_primary: i.is_primary, user: i.user })), - chapters: course.chapters.map(ch => ({ - id: ch.id, - title: ch.title as { th: string; en: string }, - sort_order: ch.sort_order, - lessons: ch.lessons.map(l => ({ - id: l.id, - title: l.title as { th: string; en: string } - })) - })) + chapters_count: course.chapters.length, + lessons_count: course.chapters.reduce((sum, ch) => sum + ch.lessons.length, 0) }; return { diff --git a/Backend/src/services/courses.service.ts b/Backend/src/services/courses.service.ts index 6b810ca9..a0d96a45 100644 --- a/Backend/src/services/courses.service.ts +++ b/Backend/src/services/courses.service.ts @@ -103,53 +103,18 @@ export class CoursesService { const course = await prisma.course.findFirst({ where: { id, - status: 'APPROVED' + status: 'APPROVED' // Only show approved courses to students }, include: { - creator: { - select: { - id: true, - username: true, - email: true, - profile: { - select: { - first_name: true, - last_name: true, - avatar_url: true - } - } - } - }, - instructors: { - include: { - user: { - select: { - id: true, - username: true, - email: true, - profile: { - select: { - first_name: true, - last_name: true, - avatar_url: true - } - } - } - } - } - }, - category: { - select: { id: true, name: true } - }, chapters: { - orderBy: { sort_order: 'asc' }, select: { id: true, title: true, - sort_order: true, lessons: { - orderBy: { sort_order: 'asc' }, - select: { id: true, title: true } + select: { + id: true, + title: true, + } } } } @@ -173,69 +138,12 @@ export class CoursesService { logger.warn(`Failed to generate presigned URL for thumbnail: ${err}`); } } - - // Generate presigned URL for creator avatar - let creator_avatar_url: string | null = null; - if (course.creator.profile?.avatar_url) { - try { - creator_avatar_url = await getPresignedUrl(course.creator.profile.avatar_url, 3600); - } catch (err) { - logger.warn(`Failed to generate presigned URL for creator avatar: ${err}`); - } - } - - // Generate presigned URLs for instructor avatars - const instructorsWithAvatar = await Promise.all(course.instructors.map(async (i) => { - let avatar_url: string | null = null; - if (i.user.profile?.avatar_url) { - try { - avatar_url = await getPresignedUrl(i.user.profile.avatar_url, 3600); - } catch (err) { - logger.warn(`Failed to generate presigned URL for instructor avatar: ${err}`); - } - } - return { - user_id: i.user_id, - is_primary: i.is_primary, - user: { - ...i.user, - profile: i.user.profile ? { - ...i.user.profile, - avatar_url - } : null - } - }; - })); - return { code: 200, message: 'Course fetched successfully', data: { ...course, - title: course.title as { th: string; en: string }, - description: course.description as { th: string; en: string }, thumbnail_url: thumbnail_presigned_url, - creator: { - ...course.creator, - profile: course.creator.profile ? { - ...course.creator.profile, - avatar_url: creator_avatar_url - } : null - }, - instructors: instructorsWithAvatar, - category: course.category ? { - id: course.category.id, - name: course.category.name as { th: string; en: string } - } : null, - chapters: course.chapters.map(ch => ({ - id: ch.id, - title: ch.title as { th: string; en: string }, - sort_order: ch.sort_order, - lessons: ch.lessons.map(l => ({ - id: l.id, - title: l.title as { th: string; en: string } - })) - })) }, }; } catch (error) { diff --git a/Backend/src/services/user.service.ts b/Backend/src/services/user.service.ts index 69153d51..918b12e3 100644 --- a/Backend/src/services/user.service.ts +++ b/Backend/src/services/user.service.ts @@ -14,8 +14,7 @@ import { updateAvatarRequest, updateAvatarResponse, SendVerifyEmailResponse, - VerifyEmailResponse, - rolesResponse + VerifyEmailResponse } from '../types/user.types'; import nodemailer from 'nodemailer'; import { UnauthorizedError, ValidationError, ForbiddenError } from '../middleware/errorHandler'; @@ -213,30 +212,6 @@ export class UserService { } } - async getRoles(token: string): Promise { - try { - jwt.verify(token, config.jwt.secret); - const roles = await prisma.role.findMany({ - select: { - id: true, - code: true - } - }); - return { roles }; - } catch (error) { - if (error instanceof jwt.TokenExpiredError) { - logger.error('JWT token expired:', error); - throw new UnauthorizedError('Token expired'); - } - if (error instanceof jwt.JsonWebTokenError) { - logger.error('Invalid JWT token:', error); - throw new UnauthorizedError('Invalid token'); - } - logger.error('Failed to get roles', { error }); - throw error; - } - } - /** * Upload avatar picture to MinIO */ diff --git a/Backend/src/types/AdminCourseApproval.types.ts b/Backend/src/types/AdminCourseApproval.types.ts index d68c8c81..fa98ea6b 100644 --- a/Backend/src/types/AdminCourseApproval.types.ts +++ b/Backend/src/types/AdminCourseApproval.types.ts @@ -117,6 +117,10 @@ export interface GetCourseDetailForAdminResponse { data: CourseDetailForAdmin; } +export interface ApproveCourseBody { + comment?: string; +} + export interface ApproveCourseResponse { code: number; message: string; diff --git a/Backend/src/types/RecommendedCourses.types.ts b/Backend/src/types/RecommendedCourses.types.ts index 48f495a7..c11c6c93 100644 --- a/Backend/src/types/RecommendedCourses.types.ts +++ b/Backend/src/types/RecommendedCourses.types.ts @@ -1,10 +1,14 @@ import { MultiLanguageText } from './index'; +// ============================================ +// Request Types +// ============================================ + + // ============================================ // Response Types // ============================================ -/** ใช้ใน listApprovedCourses — มีแค่ chapters_count */ export interface RecommendedCourseData { id: number; title: MultiLanguageText; @@ -37,19 +41,7 @@ export interface RecommendedCourseData { }; }>; chapters_count: number; -} - -/** ใช้ใน getCourseById — มี chapters + lessons พร้อมชื่อ */ -export interface RecommendedCourseDetailData extends Omit { - chapters: { - id: number; - title: MultiLanguageText; - sort_order: number; - lessons: { - id: number; - title: MultiLanguageText; - }[]; - }[]; + lessons_count: number; } export interface ListApprovedCoursesResponse { @@ -62,7 +54,7 @@ export interface ListApprovedCoursesResponse { export interface GetCourseByIdResponse { code: number; message: string; - data: RecommendedCourseDetailData; + data: RecommendedCourseData; } export interface ToggleRecommendedResponse { diff --git a/Backend/src/types/courses.types.ts b/Backend/src/types/courses.types.ts index a294d7e2..42c83398 100644 --- a/Backend/src/types/courses.types.ts +++ b/Backend/src/types/courses.types.ts @@ -1,5 +1,4 @@ import { Course } from '@prisma/client'; -import { MultiLanguageText } from './index'; export interface ListCoursesInput { category_id?: number; @@ -19,47 +18,8 @@ export interface listCourseResponse { totalPages: number; } -export interface CourseDetail extends Omit { - title: MultiLanguageText; - description: MultiLanguageText; - creator: { - id: number; - username: string; - email: string; - profile: { - first_name: string; - last_name: string; - avatar_url: string | null; - } | null; - }; - instructors: { - user_id: number; - is_primary: boolean; - user: { - id: number; - username: string; - email: string; - profile: { - first_name: string; - last_name: string; - avatar_url: string | null; - } | null; - }; - }[]; - category: { id: number; name: MultiLanguageText } | null; - chapters: { - id: number; - title: MultiLanguageText; - sort_order: number; - lessons: { - id: number; - title: MultiLanguageText; - }[]; - }[]; -} - export interface getCourseResponse { code: number; message: string; - data: CourseDetail | null; + data: Course | null; } diff --git a/Backend/src/types/user.types.ts b/Backend/src/types/user.types.ts index 42ac8e75..413cb3f2 100644 --- a/Backend/src/types/user.types.ts +++ b/Backend/src/types/user.types.ts @@ -59,14 +59,6 @@ export interface ProfileUpdateResponse { }; }; -export interface role { - id: number; - code: string; -} - -export interface rolesResponse { - roles: role[]; -} export interface ChangePasswordRequest { old_password: string; diff --git a/Backend/src/validators/ChaptersLesson.validator.ts b/Backend/src/validators/ChaptersLesson.validator.ts index 45d7687a..933a3e1a 100644 --- a/Backend/src/validators/ChaptersLesson.validator.ts +++ b/Backend/src/validators/ChaptersLesson.validator.ts @@ -79,7 +79,7 @@ export const UpdateLessonValidator = Joi.object({ 'number.min': 'Duration must be at least 0' }), sort_order: Joi.number().integer().min(0).optional(), - prerequisite_lesson_ids: Joi.array().items(Joi.number().integer().positive()).allow(null).optional(), + prerequisite_lesson_ids: Joi.array().items(Joi.number().integer().positive()).optional(), is_published: Joi.boolean().optional() }); diff --git a/Backend/tests/k6/enroll-load-test.js b/Backend/tests/k6/enroll-load-test.js deleted file mode 100644 index d3e1032b..00000000 --- a/Backend/tests/k6/enroll-load-test.js +++ /dev/null @@ -1,160 +0,0 @@ -// Backend/tests/k6/enroll-load-test.js -// -// จำลองนักเรียนหลายคน login แล้ว enroll คอร์สพร้อมกัน -// -// Flow: -// 1. Login -// 2. Enroll คอร์ส -// 3. ตรวจสอบ enrolled courses -// -// Usage: -// k6 run -e APP_URL=http://192.168.1.137:4000 -e COURSE_ID=1 tests/k6/enroll-load-test.js - -import http from 'k6/http'; -import { check, sleep, group } from 'k6'; -import { Rate, Trend, Counter } from 'k6/metrics'; -import { SharedArray } from 'k6/data'; - -// ─── Custom Metrics ─────────────────────────────────────────────────────────── -const errorRate = new Rate('errors'); -const loginTime = new Trend('login_duration', true); -const enrollTime = new Trend('enroll_duration', true); -const enrolledCount = new Counter('successful_enrollments'); - -// ─── Load student credentials ───────────────────────────────────────────────── -const students = new SharedArray('students', function () { - return JSON.parse(open('./test-credentials.json')).students; -}); - -// ─── Config ─────────────────────────────────────────────────────────────────── -const BASE_URL = __ENV.APP_URL || 'http://192.168.1.137:4000'; -const COURSE_ID = __ENV.COURSE_ID || '1'; - -// ─── Test Options ───────────────────────────────────────────────────────────── -export const options = { - stages: [ - { duration: '20s', target: 10 }, // Ramp up - { duration: '1m', target: 30 }, // Increase - { duration: '30s', target: 50 }, // Peak: 50 คน enroll พร้อมกัน - { duration: '30s', target: 0 }, // Ramp down - ], - thresholds: { - 'login_duration': ['p(95)<2000'], // Login < 2s - 'enroll_duration': ['p(95)<1000'], // Enroll < 1s - 'errors': ['rate<0.05'], - 'http_req_failed': ['rate<0.05'], - }, -}; - -// ─── Helper ─────────────────────────────────────────────────────────────────── -function jsonHeaders(token) { - const h = { 'Content-Type': 'application/json' }; - if (token) h['Authorization'] = `Bearer ${token}`; - return h; -} - -// ─── Main ───────────────────────────────────────────────────────────────────── -export default function () { - const student = students[__VU % students.length]; - let token = null; - - // ── Step 1: Login ────────────────────────────────────────────────────────── - group('1. Login', () => { - const res = http.post( - `${BASE_URL}/api/auth/login`, - JSON.stringify({ email: student.email, password: student.password }), - { headers: jsonHeaders(null) } - ); - - loginTime.add(res.timings.duration); - errorRate.add(res.status !== 200); - - check(res, { - 'login: status 200': (r) => r.status === 200, - 'login: has token': (r) => { try { return !!r.json('data.token'); } catch { return false; } }, - }); - - if (res.status === 200) { - try { token = res.json('data.token'); } catch {} - } - }); - - if (!token) { - console.warn(`[VU ${__VU}] Login failed for ${student.email} — skipping`); - sleep(1); - return; - } - - sleep(0.5); - - // ── Step 2: Enroll ───────────────────────────────────────────────────────── - group('2. Enroll Course', () => { - const res = http.post( - `${BASE_URL}/api/students/courses/${COURSE_ID}/enroll`, - null, - { headers: jsonHeaders(token) } - ); - - enrollTime.add(res.timings.duration); - - // 200 = enrolled, 409 = already enrolled (ถือว่าโอเค) - const ok = res.status === 200 || res.status === 409; - errorRate.add(!ok); - - if (res.status === 200) enrolledCount.add(1); - - check(res, { - 'enroll: 200 or 409': (r) => r.status === 200 || r.status === 409, - 'enroll: fast response': (r) => r.timings.duration < 1000, - }); - }); - - sleep(0.5); - - // ── Step 3: Verify — ดึงรายการคอร์สที่ลงทะเบียน ───────────────────────── - group('3. Get Enrolled Courses', () => { - const res = http.get( - `${BASE_URL}/api/students/courses`, - { headers: jsonHeaders(token) } - ); - - errorRate.add(res.status !== 200); - - check(res, { - 'enrolled courses: status 200': (r) => r.status === 200, - }); - }); - - sleep(1); -} - -// ─── Summary ────────────────────────────────────────────────────────────────── -export function handleSummary(data) { - const m = data.metrics; - const avg = (k) => m[k]?.values?.avg?.toFixed(0) ?? 'N/A'; - const p95 = (k) => m[k]?.values?.['p(95)']?.toFixed(0) ?? 'N/A'; - const rate = (k) => ((m[k]?.values?.rate ?? 0) * 100).toFixed(2); - const cnt = (k) => m[k]?.values?.count ?? 0; - - return { - stdout: ` -╔══════════════════════════════════════════════════════════╗ -║ Course Enroll — Load Test ║ -╠══════════════════════════════════════════════════════════╣ -║ Course ID : ${String(COURSE_ID).padEnd(43)}║ -╠══════════════════════════════════════════════════════════╣ -║ RESPONSE TIMES (avg / p95) ║ -║ Login : ${avg('login_duration')}ms / ${p95('login_duration')}ms -║ Enroll : ${avg('enroll_duration')}ms / ${p95('enroll_duration')}ms -╠══════════════════════════════════════════════════════════╣ -║ COUNTS ║ -║ Total Requests : ${String(cnt('http_reqs')).padEnd(33)}║ -║ New Enrollments : ${String(cnt('successful_enrollments')).padEnd(33)}║ -╠══════════════════════════════════════════════════════════╣ -║ ERROR RATES ║ -║ HTTP Failed : ${(rate('http_req_failed') + '%').padEnd(39)}║ -║ Custom Errors : ${(rate('errors') + '%').padEnd(39)}║ -╚══════════════════════════════════════════════════════════╝ -`, - }; -} diff --git a/Backend/tests/k6/login-load-test.js b/Backend/tests/k6/login-load-test.js index aee4cb4a..2a0c375d 100644 --- a/Backend/tests/k6/login-load-test.js +++ b/Backend/tests/k6/login-load-test.js @@ -31,7 +31,7 @@ export const options = { thresholds: { http_req_duration: ['p(95)<2000'], // 95% of requests < 2s errors: ['rate<0.1'], // Error rate < 10% - login_duration: ['p(95)<2000'], // 95% pof logins < 2s + login_duration: ['p(95)<2000'], // 95% of logins < 2s }, }; diff --git a/Backend/tests/k6/video-watching-load-test.js b/Backend/tests/k6/video-watching-load-test.js deleted file mode 100644 index e3bb205c..00000000 --- a/Backend/tests/k6/video-watching-load-test.js +++ /dev/null @@ -1,269 +0,0 @@ -// Backend/tests/k6/video-watching-load-test.js -// -// จำลองนักเรียนหลายคนดูวีดีโอพร้อมกัน (Concurrent Video Watching) -// -// Flow จริงที่ simulate: -// 1. Login ด้วย account ของ student แต่ละคน -// 2. Load หน้าเรียนคอร์ส (getCourseLearning) -// 3. เปิดบทเรียนวีดีโอ (getLessonContent) -// 4. Save progress ทุก 5 วินาที (จำลองการ watch) -// 5. เมื่อดูครบ (≥90%) → mark lesson complete -// -// Usage: -// k6 run -e APP_URL=http://localhost:4000 -e COURSE_ID=1 -e LESSON_ID=1 tests/k6/video-watching-load-test.js -// -// ปรับจำนวน VUs และ duration ได้ด้วย: -// k6 run -e APP_URL=http://localhost:4000 -e COURSE_ID=1 -e LESSON_ID=1 --vus 30 --duration 2m tests/k6/video-watching-load-test.js - -import http from 'k6/http'; -import { check, sleep, group } from 'k6'; -import { Rate, Trend, Counter } from 'k6/metrics'; -import { SharedArray } from 'k6/data'; - -// ─── Custom Metrics ─────────────────────────────────────────────────────────── -const errorRate = new Rate('errors'); -const loginTime = new Trend('login_duration', true); -const courseLearningTime = new Trend('course_learning_duration', true); -const lessonLoadTime = new Trend('lesson_load_duration', true); -const progressSaveTime = new Trend('progress_save_duration', true); -const completeLessonTime = new Trend('complete_lesson_duration', true); -const completedCount = new Counter('completed_lessons'); -const progressSaveCount = new Counter('progress_saves'); -const videoLoadTime = new Trend('video_load_duration', true); - -// ─── Load student credentials ──────────────────────────────────────────────── -// อ่านจาก test-credentials.json (50 accounts) -const students = new SharedArray('students', function () { - return JSON.parse(open('./test-credentials.json')).students; -}); - -// ─── Config ─────────────────────────────────────────────────────────────────── -const BASE_URL = __ENV.APP_URL || 'http://192.168.1.137:4000'; -const COURSE_ID = __ENV.COURSE_ID || '1'; -const LESSON_ID = __ENV.LESSON_ID || '1'; - -// วีดีโอความยาว (วินาที) — ปรับตามจริง -const VIDEO_DURATION_SECONDS = parseInt(__ENV.VIDEO_DURATION || '98'); // default 5 นาที - -// save progress interval: ทุก 5 วินาที (เหมือน client จริง) -// แต่ในการ test เราจะ simulate เร็วขึ้นโดยใช้ sleep น้อยลง -const PROGRESS_INTERVAL_SECONDS = parseInt(__ENV.PROGRESS_INTERVAL || '15'); - -// ─── Test Options ───────────────────────────────────────────────────────────── -export const options = { - stages: [ - { duration: '30s', target: 10 }, // Ramp up: 10 คนเริ่มดูวีดีโอ - { duration: '1m', target: 30 }, // Ramp up: เพิ่มเป็น 30 คน - { duration: '2m', target: 30 }, // Steady: 30 คนดูพร้อมกัน - { duration: '30s', target: 50 }, // Peak: เพิ่มเป็น 50 คน - { duration: '1m', target: 50 }, // Steady Peak: 50 คนพร้อมกัน - { duration: '30s', target: 0 }, // Ramp down - ], - thresholds: { - // Response times - 'login_duration': ['p(95)<2000'], // Login < 2s - 'course_learning_duration': ['p(95)<1000'], // Load course page < 1s - 'lesson_load_duration': ['p(95)<1000'], // Load lesson < 1s - 'video_load_duration': ['p(95)<3000'], // Fetch video from MinIO < 3s - 'progress_save_duration': ['p(95)<500'], // Save progress < 500ms (critical — บ่อย) - 'complete_lesson_duration': ['p(95)<1000'], // Complete lesson < 1s - - // Error rate - 'errors': ['rate<0.05'], // Error < 5% - 'http_req_failed': ['rate<0.05'], // HTTP error < 5% - }, -}; - -// ─── Helper ─────────────────────────────────────────────────────────────────── -function jsonHeaders(token) { - const h = { 'Content-Type': 'application/json' }; - if (token) h['Authorization'] = `Bearer ${token}`; - return h; -} - -// ─── Per-VU persistent state (จำข้ามรอบ iteration) ────────────────────────── -// ตัวแปรนี้อยู่ระดับ module → k6 สร้างแยกต่างหากสำหรับแต่ละ VU -// ค่าจะถูกจำไว้ตลอดอายุของ VU (ข้ามหลายรอบ iteration) -let vuToken = null; // token ที่ login ไว้แล้ว -let vuSetupDone = false; // เคย load course+lesson แล้วหรือยัง -let vuProgress = 0; // ตำแหน่งวีดีโอปัจจุบัน (วินาที) -let vuCompleted = false; // lesson complete แล้วหรือยัง - -// ─── Main ───────────────────────────────────────────────────────────────────── -export default function () { - const student = students[__VU % students.length]; - - // ── Step 1: Login (ทำครั้งเดียวตอน VU เริ่มต้น หรือถ้า token หาย) ───────── - if (!vuToken) { - group('1. Login', () => { - const res = http.post( - `${BASE_URL}/api/auth/login`, - JSON.stringify({ email: student.email, password: student.password }), - { headers: jsonHeaders(null) } - ); - - loginTime.add(res.timings.duration); - const ok = res.status === 200; - errorRate.add(!ok); - - check(res, { - 'login: status 200': (r) => r.status === 200, - 'login: has token': (r) => { try { return !!r.json('data.token'); } catch { return false; } }, - }); - - if (ok) { - try { vuToken = res.json('data.token'); } catch {} - } - }); - - if (!vuToken) { - console.warn(`[VU ${__VU}] Login failed for ${student.email} — skipping`); - sleep(2); - return; - } - } - - // ── Step 2 (removed): Enroll ทำผ่าน enroll-load-test.js แยกต่างหาก ───────── - - // ── Step 3+4: Setup — Load course และ open lesson (ทำครั้งเดียวต่อ VU) ───── - if (!vuSetupDone) { - group('3. Load Course Learning Page', () => { - const res = http.get( - `${BASE_URL}/api/students/courses/${COURSE_ID}/learn`, - { headers: jsonHeaders(vuToken) } - ); - courseLearningTime.add(res.timings.duration); - errorRate.add(res.status !== 200); - check(res, { 'course learn: status 200': (r) => r.status === 200 }); - }); - - sleep(1); - - let videoUrl = null; - group('4. Open Lesson', () => { - const res = http.get( - `${BASE_URL}/api/students/courses/${COURSE_ID}/lessons/${LESSON_ID}`, - { headers: jsonHeaders(vuToken) } - ); - lessonLoadTime.add(res.timings.duration); - errorRate.add(res.status !== 200); - check(res, { 'lesson: status 200': (r) => r.status === 200 }); - if (res.status === 200) { - try { videoUrl = res.json('data.video_url'); } catch {} - } - }); - - // ── Step 4.5: Fetch video จาก MinIO ────────────────────────────────────── - if (videoUrl) { - group('4.5 Fetch Video from MinIO', () => { - const res = http.get(videoUrl, { - headers: { 'Range': 'bytes=0-1048575' }, // ขอแค่ 1MB แรก - timeout: '10s', - }); - videoLoadTime.add(res.timings.duration); - const ok = res.status === 200 || res.status === 206; - errorRate.add(!ok); - check(res, { - 'minio video: 200 or 206': (r) => r.status === 200 || r.status === 206, - 'minio video: fast': (r) => r.timings.duration < 3000, - }); - }); - } else { - console.log(`[VU ${__VU}] No video_url returned — skipping MinIO fetch`); - } - - sleep(2); // รอ buffer เริ่มต้น - vuSetupDone = true; - } - - // ── Step 5: Save Progress ทีละ tick (ต่อจากตำแหน่งเดิม) ──────────────────── - // แต่ละ iteration ของ VU = ส่ง progress 1 ครั้ง แล้ว sleep ตาม interval จริง - if (!vuCompleted) { - vuProgress += PROGRESS_INTERVAL_SECONDS; - - group('5. Watch Video (Save Progress)', () => { - const res = http.post( - `${BASE_URL}/api/students/lessons/${LESSON_ID}/progress`, - JSON.stringify({ - video_progress_seconds: vuProgress, - video_duration_seconds: VIDEO_DURATION_SECONDS, - }), - { headers: jsonHeaders(vuToken) } - ); - - progressSaveTime.add(res.timings.duration); - progressSaveCount.add(1); - - const ok = res.status === 200; - errorRate.add(!ok); - check(res, { - 'progress save: status 200': (r) => r.status === 200, - 'progress save: fast': (r) => r.timings.duration < 500, - }); - - console.log(`[VU ${__VU}] progress: ${vuProgress}s / ${VIDEO_DURATION_SECONDS}s (${Math.round(vuProgress / VIDEO_DURATION_SECONDS * 100)}%)`); - }); - - // ── Step 6: Mark complete เมื่อดูครบ ≥95% ────────────────────────────── - if (vuProgress >= VIDEO_DURATION_SECONDS * 0.95) { - group('6. Complete Lesson', () => { - const res = http.post( - `${BASE_URL}/api/students/courses/${COURSE_ID}/lessons/${LESSON_ID}/complete`, - null, - { headers: jsonHeaders(vuToken) } - ); - completeLessonTime.add(res.timings.duration); - errorRate.add(res.status !== 200 && res.status !== 409); - if (res.status === 200) completedCount.add(1); - check(res, { - 'complete: status 200 or 409': (r) => r.status === 200 || r.status === 409, - }); - }); - - vuCompleted = true; - console.log(`[VU ${__VU}] ✓ Lesson completed`); - } - } - - // sleep ตาม interval จริง — ทุก VU ส่ง progress ทุก PROGRESS_INTERVAL_SECONDS วินาที - sleep(PROGRESS_INTERVAL_SECONDS); -} - -// ─── Summary ────────────────────────────────────────────────────────────────── -export function handleSummary(data) { - const m = data.metrics; - - const avg = (k) => m[k]?.values?.avg?.toFixed(0) ?? 'N/A'; - const p95 = (k) => m[k]?.values?.['p(95)']?.toFixed(0) ?? 'N/A'; - const rate = (k) => ((m[k]?.values?.rate ?? 0) * 100).toFixed(2); - const count = (k) => m[k]?.values?.count ?? 0; - - return { - stdout: ` -╔══════════════════════════════════════════════════════════╗ -║ Concurrent Video Watching — Load Test ║ -╠══════════════════════════════════════════════════════════╣ -║ Course ID : ${COURSE_ID.padEnd(44)}║ -║ Lesson ID : ${LESSON_ID.padEnd(44)}║ -║ Video : ${String(VIDEO_DURATION_SECONDS + 's').padEnd(44)}║ -╠══════════════════════════════════════════════════════════╣ -║ RESPONSE TIMES (avg / p95) ║ -║ Login : ${avg('login_duration')}ms / ${p95('login_duration')}ms${' '.repeat(Math.max(0, 20 - avg('login_duration').length - p95('login_duration').length))}║ -║ Course Learning Page: ${avg('course_learning_duration')}ms / ${p95('course_learning_duration')}ms${' '.repeat(Math.max(0, 20 - avg('course_learning_duration').length - p95('course_learning_duration').length))}║ -║ Lesson Load : ${avg('lesson_load_duration')}ms / ${p95('lesson_load_duration')}ms${' '.repeat(Math.max(0, 20 - avg('lesson_load_duration').length - p95('lesson_load_duration').length))}║ -║ MinIO Video Fetch : ${avg('video_load_duration')}ms / ${p95('video_load_duration')}ms${' '.repeat(Math.max(0, 20 - avg('video_load_duration').length - p95('video_load_duration').length))}║ -║ Save Progress : ${avg('progress_save_duration')}ms / ${p95('progress_save_duration')}ms${' '.repeat(Math.max(0, 20 - avg('progress_save_duration').length - p95('progress_save_duration').length))}║ -║ Complete Lesson : ${avg('complete_lesson_duration')}ms / ${p95('complete_lesson_duration')}ms${' '.repeat(Math.max(0, 20 - avg('complete_lesson_duration').length - p95('complete_lesson_duration').length))}║ -╠══════════════════════════════════════════════════════════╣ -║ COUNTS ║ -║ Total Requests : ${String(count('http_reqs')).padEnd(33)}║ -║ Progress Saves : ${String(count('progress_saves')).padEnd(33)}║ -║ Lessons Completed : ${String(count('completed_lessons')).padEnd(33)}║ -╠══════════════════════════════════════════════════════════╣ -║ ERROR RATES ║ -║ HTTP Failed : ${(rate('http_req_failed') + '%').padEnd(33)}║ -║ Custom Errors : ${(rate('errors') + '%').padEnd(33)}║ -╚══════════════════════════════════════════════════════════╝ -`, - }; -} diff --git a/Frontend-Learner/app.vue b/Frontend-Learner/app.vue index 8070d468..a1ac35c7 100644 --- a/Frontend-Learner/app.vue +++ b/Frontend-Learner/app.vue @@ -1,27 +1,20 @@ - diff --git a/Frontend-Learner/assets/css/main.css b/Frontend-Learner/assets/css/main.css index 7f1001f3..db18a4fb 100644 --- a/Frontend-Learner/assets/css/main.css +++ b/Frontend-Learner/assets/css/main.css @@ -27,7 +27,7 @@ /* Typography */ /* Typography */ --font-main: - "Prompt", "Inter", "Sarabun", -apple-system, BlinkMacSystemFont, "Segoe UI", + "Prompt", "Sarabun", "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Helvetica Neue", Arial, sans-serif; /* Layout */ @@ -113,9 +113,9 @@ body { background-attachment: fixed; } -/* a { +a { text-decoration: none; - color: #2563eb; + color: #3b82f6; transition: color 0.2s; } @@ -129,7 +129,7 @@ a:hover { .dark a:hover { color: #93c5fd; -} */ +} ul { list-style: none; @@ -634,7 +634,6 @@ ul { } .font-bold { font-weight: 700; - letter-spacing: normal; } .w-full { width: 100%; @@ -645,9 +644,9 @@ ul { .rounded { border-radius: var(--radius-md); } -/* .border-b { +.border-b { border-bottom: 1px solid var(--border-color); -} */ +} .load-more-wrap { display: flex; justify-content: center; diff --git a/Frontend-Learner/components/classroom/AnnouncementModal.vue b/Frontend-Learner/components/classroom/AnnouncementModal.vue index 074ae27c..7c0b8356 100644 --- a/Frontend-Learner/components/classroom/AnnouncementModal.vue +++ b/Frontend-Learner/components/classroom/AnnouncementModal.vue @@ -1,7 +1,7 @@ diff --git a/Frontend-Learner/components/classroom/VideoPlayer.vue b/Frontend-Learner/components/classroom/VideoPlayer.vue index 1614d66b..4bd0af28 100644 --- a/Frontend-Learner/components/classroom/VideoPlayer.vue +++ b/Frontend-Learner/components/classroom/VideoPlayer.vue @@ -1,7 +1,7 @@ @@ -32,7 +32,7 @@ const changeLocale = async (code: string) => { class="language-btn" :aria-label="$t('language.label')" > - + { diff --git a/Frontend-Learner/components/discovery/CategorySidebar.vue b/Frontend-Learner/components/discovery/CategorySidebar.vue index 692d26cf..15f238d4 100644 --- a/Frontend-Learner/components/discovery/CategorySidebar.vue +++ b/Frontend-Learner/components/discovery/CategorySidebar.vue @@ -1,7 +1,7 @@ diff --git a/Frontend-Learner/layouts/default.vue b/Frontend-Learner/layouts/default.vue index 6f6040da..42ef7e83 100644 --- a/Frontend-Learner/layouts/default.vue +++ b/Frontend-Learner/layouts/default.vue @@ -1,66 +1,58 @@ diff --git a/Frontend-Learner/layouts/landing.vue b/Frontend-Learner/layouts/landing.vue index 0969f278..c05167bf 100644 --- a/Frontend-Learner/layouts/landing.vue +++ b/Frontend-Learner/layouts/landing.vue @@ -23,22 +23,19 @@ onMounted(() => { - + - - + + - - - diff --git a/Frontend-Learner/nuxt.config.ts b/Frontend-Learner/nuxt.config.ts index e0617f89..b3079964 100644 --- a/Frontend-Learner/nuxt.config.ts +++ b/Frontend-Learner/nuxt.config.ts @@ -33,11 +33,8 @@ export default defineNuxtConfig({ // การตั้งค่า Quasar Framework quasar: { - iconSet: 'material-icons-outlined', extras: { - fontIcons: [ - "material-icons", - "material-icons-outlined"] // ใช้ไอคอน Material Icons, material-icons-outlined + fontIcons: ["material-icons"], }, plugins: ["Notify", "Dialog"], // เปิดใช้ Plugin Notify และ Dialog config: { @@ -69,11 +66,10 @@ export default defineNuxtConfig({ { name: "viewport", content: "width=device-width, initial-scale=1" }, ], link: [ - { rel: 'icon', type: 'image/png', href: '/img/logo.png' }, { rel: "stylesheet", // โหลด Font: Inter, Prompt, Sarabun - href: "https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&family=Prompt:wght@300;400;500;600;700;800;900&family=Sarabun:wght@300;400;500;600;700;800&family=Poppins:wght@300;400;500;600;700;800;900&display=swap", + href: "https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&family=Prompt:wght@300;400;500;600;700;800;900&family=Sarabun:wght@300;400;500;600;700;800&display=swap", }, ], }, diff --git a/Frontend-Learner/pages/auth/login.vue b/Frontend-Learner/pages/auth/login.vue index a3d6ed63..e30602df 100644 --- a/Frontend-Learner/pages/auth/login.vue +++ b/Frontend-Learner/pages/auth/login.vue @@ -23,7 +23,7 @@ const isLoading = ref(false) const rememberMe = ref(false) const showPassword = ref(false) -// ข้อมูลฟอร์มสำหรับเก็บค่าอีเมลและรหัสผ่าน +// Form data model const loginForm = reactive({ email: '', password: '' @@ -31,7 +31,7 @@ const loginForm = reactive({ type LoginField = keyof typeof loginForm -// การตั้งค่ากฎการตรวจสอบ (Validation Rules) +// Validation rules definition // กำหนดกฎการตรวจสอบข้อมูล (Validation Rules) const loginRules = { email: { @@ -108,12 +108,12 @@ const handleLogin = async () => { } - // แสดงข้อผิดพลาดตามช่องหรือแสดงข้อผิดพลาดรวม (เพื่อความปลอดภัย) - // กรณีเข้าสู่ระบบไม่สำเร็จ + // Show error on specific fields + // Show generic error for security (or specific if role mismatch) if (result.error === 'Email ไม่ถูกต้อง') { - errors.value.email = result.error // กรณี Role ไม่ตรงกัน + errors.value.email = result.error // Role mismatch case } else { - // ข้อผิดพลาดแบบเหมาสำหรับปัญหาการเข้าสู่ระบบทั่วไป (เช่น รหัสผิด, ไม่พบผู้ใช้) + // Generic login failure (401, 404, etc.) const msg = 'กรุณาเช็ค Email หรือ รหัสผ่านใหม่อีกครั้ง' errors.value.email = msg errors.value.password = msg @@ -147,7 +147,7 @@ onMounted(() => { ========================================== -->
- +
E @@ -158,10 +158,10 @@ onMounted(() => {
- +
- +
@@ -180,7 +180,7 @@ onMounted(() => { {{ errors.email }}
- +
@@ -206,7 +206,7 @@ onMounted(() => { {{ errors.password }}
- +