feat: Add user role retrieval, enhance recommended course filtering and detail, and introduce new k6 load tests.
This commit is contained in:
parent
e3873f616e
commit
45b9c6516b
11 changed files with 515 additions and 139 deletions
|
|
@ -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 });
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue