feat: Add user role retrieval, enhance recommended course filtering and detail, and introduce new k6 load tests.

This commit is contained in:
JakkrapartXD 2026-02-20 15:16:03 +07:00
parent e3873f616e
commit 45b9c6516b
11 changed files with 515 additions and 139 deletions

View file

@ -20,10 +20,14 @@ export class RecommendedCoursesController {
@SuccessResponse('200', 'Approved courses retrieved successfully')
@Response('401', 'Unauthorized')
@Response('403', 'Forbidden - Admin only')
public async listApprovedCourses(@Request() request: any): Promise<ListApprovedCoursesResponse> {
public async listApprovedCourses(
@Request() request: any,
@Query() search?: string,
@Query() categoryId?: number
): Promise<ListApprovedCoursesResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided');
return await RecommendedCoursesService.listApprovedCourses(token);
return await RecommendedCoursesService.listApprovedCourses(token, { search, categoryId });
}
/**

View file

@ -10,7 +10,8 @@ import {
ChangePasswordResponse,
updateAvatarResponse,
SendVerifyEmailResponse,
VerifyEmailResponse
VerifyEmailResponse,
rolesResponse
} from '../types/user.types';
import { ChangePassword } from '../types/auth.types';
import { profileUpdateSchema, changePasswordSchema } from "../validators/user.validator";
@ -56,6 +57,18 @@ 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<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

View file

@ -113,7 +113,7 @@ export class AdminCourseApprovalService {
/**
* Get course details for admin review
*/
static async getCourseDetail(token: string,courseId: number): Promise<GetCourseDetailForAdminResponse> {
static async getCourseDetail(token: string, courseId: number): Promise<GetCourseDetailForAdminResponse> {
try {
const course = await prisma.course.findUnique({
where: { id: courseId },
@ -133,7 +133,11 @@ export class AdminCourseApprovalService {
},
chapters: {
orderBy: { sort_order: 'asc' },
include: {
select: {
id: true,
title: true,
sort_order: true,
is_published: true,
lessons: {
orderBy: { sort_order: 'asc' },
select: {

View file

@ -8,7 +8,8 @@ import {
ListApprovedCoursesResponse,
GetCourseByIdResponse,
ToggleRecommendedResponse,
RecommendedCourseData
RecommendedCourseData,
RecommendedCourseDetailData
} from '../types/RecommendedCourses.types';
import { auditService } from './audit.service';
import { AuditAction } from '@prisma/client';
@ -18,10 +19,24 @@ export class RecommendedCoursesService {
/**
* List all approved courses (for admin to manage recommendations)
*/
static async listApprovedCourses(token: string): Promise<ListApprovedCoursesResponse> {
static async listApprovedCourses(
token: string,
filters?: { search?: string; categoryId?: number }
): Promise<ListApprovedCoursesResponse> {
try {
const { search, categoryId } = filters ?? {};
const courses = await prisma.course.findMany({
where: { status: 'APPROVED' },
where: {
status: 'APPROVED',
...(categoryId ? { category_id: categoryId } : {}),
...(search ? {
OR: [
{ title: { path: ['th'], string_contains: search } },
{ title: { path: ['en'], string_contains: search } }
]
} : {})
},
orderBy: [
{ is_recommended: 'desc' },
{ updated_at: 'desc' }
@ -40,9 +55,9 @@ export class RecommendedCoursesService {
}
}
},
chapters: {
include: {
lessons: true
_count: {
select: {
chapters: true
}
}
}
@ -81,8 +96,7 @@ export class RecommendedCoursesService {
is_primary: i.is_primary,
user: i.user
})),
chapters_count: course.chapters.length,
lessons_count: course.chapters.reduce((sum, ch) => sum + ch.lessons.length, 0)
chapters_count: course._count.chapters,
} as RecommendedCourseData;
}));
@ -158,7 +172,7 @@ export class RecommendedCoursesService {
}
}
const data: RecommendedCourseData = {
const data: RecommendedCourseDetailData = {
id: course.id,
title: course.title as { th: string; en: string },
slug: course.slug,
@ -181,8 +195,15 @@ export class RecommendedCoursesService {
is_primary: i.is_primary,
user: i.user
})),
chapters_count: course.chapters.length,
lessons_count: course.chapters.reduce((sum, ch) => sum + ch.lessons.length, 0)
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 }
}))
}))
};
return {

View file

@ -103,18 +103,53 @@ export class CoursesService {
const course = await prisma.course.findFirst({
where: {
id,
status: 'APPROVED' // Only show approved courses to students
status: 'APPROVED'
},
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: {
select: {
id: true,
title: true,
}
orderBy: { sort_order: 'asc' },
select: { id: true, title: true }
}
}
}
@ -138,12 +173,69 @@ 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) {

View file

@ -14,7 +14,8 @@ import {
updateAvatarRequest,
updateAvatarResponse,
SendVerifyEmailResponse,
VerifyEmailResponse
VerifyEmailResponse,
rolesResponse
} from '../types/user.types';
import nodemailer from 'nodemailer';
import { UnauthorizedError, ValidationError, ForbiddenError } from '../middleware/errorHandler';
@ -212,6 +213,30 @@ export class UserService {
}
}
async getRoles(token: string): Promise<rolesResponse> {
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
*/

View file

@ -1,14 +1,10 @@
import { MultiLanguageText } from './index';
// ============================================
// Request Types
// ============================================
// ============================================
// Response Types
// ============================================
/** ใช้ใน listApprovedCourses — มีแค่ chapters_count */
export interface RecommendedCourseData {
id: number;
title: MultiLanguageText;
@ -41,7 +37,19 @@ export interface RecommendedCourseData {
};
}>;
chapters_count: number;
lessons_count: number;
}
/** ใช้ใน getCourseById — มี chapters + lessons พร้อมชื่อ */
export interface RecommendedCourseDetailData extends Omit<RecommendedCourseData, 'chapters_count'> {
chapters: {
id: number;
title: MultiLanguageText;
sort_order: number;
lessons: {
id: number;
title: MultiLanguageText;
}[];
}[];
}
export interface ListApprovedCoursesResponse {
@ -54,7 +62,7 @@ export interface ListApprovedCoursesResponse {
export interface GetCourseByIdResponse {
code: number;
message: string;
data: RecommendedCourseData;
data: RecommendedCourseDetailData;
}
export interface ToggleRecommendedResponse {

View file

@ -1,4 +1,5 @@
import { Course } from '@prisma/client';
import { MultiLanguageText } from './index';
export interface ListCoursesInput {
category_id?: number;
@ -18,8 +19,47 @@ export interface listCourseResponse {
totalPages: number;
}
export interface CourseDetail extends Omit<Course, 'title' | 'description'> {
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: Course | null;
data: CourseDetail | null;
}

View file

@ -59,6 +59,14 @@ export interface ProfileUpdateResponse {
};
};
export interface role {
id: number;
code: string;
}
export interface rolesResponse {
roles: role[];
}
export interface ChangePasswordRequest {
old_password: string;