feat: Implement instructor search and improve instructor management with email/username lookup and avatar presigned URLs.
This commit is contained in:
parent
38e7f1bf06
commit
f4a12c686b
4 changed files with 228 additions and 25 deletions
|
|
@ -1,4 +1,4 @@
|
|||
import { Get, Body, Post, Route, Tags, SuccessResponse, Response, Security, Put, Path, Delete, Request, Example, FormField, UploadedFile } from 'tsoa';
|
||||
import { Get, Body, Post, Route, Tags, SuccessResponse, Response, Security, Put, Path, Delete, Request, Example, FormField, UploadedFile, Query } from 'tsoa';
|
||||
import { ValidationError } from '../middleware/errorHandler';
|
||||
import { CoursesInstructorService } from '../services/CoursesInstructor.service';
|
||||
import {
|
||||
|
|
@ -6,18 +6,16 @@ import {
|
|||
createCourseResponse,
|
||||
GetMyCourseResponse,
|
||||
ListMyCourseResponse,
|
||||
addinstructorCourse,
|
||||
addinstructorCourseResponse,
|
||||
removeinstructorCourse,
|
||||
removeinstructorCourseResponse,
|
||||
setprimaryCourseInstructor,
|
||||
setprimaryCourseInstructorResponse,
|
||||
UpdateMyCourse,
|
||||
UpdateMyCourseResponse,
|
||||
DeleteMyCourseResponse,
|
||||
submitCourseResponse,
|
||||
listinstructorCourseResponse,
|
||||
GetCourseApprovalsResponse
|
||||
GetCourseApprovalsResponse,
|
||||
SearchInstructorResponse
|
||||
} from '../types/CoursesInstructor.types';
|
||||
import { CreateCourseValidator } from "../validators/CoursesInstructor.validator";
|
||||
|
||||
|
|
@ -200,20 +198,36 @@ export class CoursesInstructorController {
|
|||
}
|
||||
|
||||
/**
|
||||
* เพิ่มผู้สอนเข้าในคอร์ส
|
||||
* Add a new instructor to the course
|
||||
* ค้นหาผู้สอนสำหรับเพิ่มเข้าคอร์ส
|
||||
* Search instructors to add to course
|
||||
* @param courseId - รหัสคอร์ส / Course ID
|
||||
* @param userId - รหัสผู้ใช้ที่ต้องการเพิ่มเป็นผู้สอน / User ID to add as instructor
|
||||
* @param query - คำค้นหา (email หรือ username) / Search query (email or username)
|
||||
*/
|
||||
@Post('add-instructor/{courseId}/{userId}')
|
||||
@Get('{courseId}/search-instructors')
|
||||
@Security('jwt', ['instructor'])
|
||||
@SuccessResponse('200', 'Instructors found')
|
||||
@Response('401', 'Invalid or expired token')
|
||||
public async searchInstructors(@Request() request: any, @Path() courseId: number, @Query() query: string): Promise<SearchInstructorResponse> {
|
||||
const token = request.headers.authorization?.replace('Bearer ', '');
|
||||
if (!token) throw new ValidationError('No token provided');
|
||||
return await CoursesInstructorService.searchInstructors({ token, course_id: courseId, query });
|
||||
}
|
||||
|
||||
/**
|
||||
* เพิ่มผู้สอนเข้าในคอร์ส (ใช้ email หรือ username)
|
||||
* Add a new instructor to the course (using email or username)
|
||||
* @param courseId - รหัสคอร์ส / Course ID
|
||||
* @param emailOrUsername - email หรือ username ของผู้สอน / Instructor's email or username
|
||||
*/
|
||||
@Post('add-instructor/{courseId}/{emailOrUsername}')
|
||||
@Security('jwt', ['instructor'])
|
||||
@SuccessResponse('200', 'Instructor added successfully')
|
||||
@Response('401', 'Invalid or expired token')
|
||||
@Response('404', 'Instructor not found')
|
||||
public async addInstructor(@Request() request: any, @Path() courseId: number, @Path() userId: number): Promise<addinstructorCourseResponse> {
|
||||
public async addInstructor(@Request() request: any, @Path() courseId: number, @Path() emailOrUsername: string): Promise<addinstructorCourseResponse> {
|
||||
const token = request.headers.authorization?.replace('Bearer ', '');
|
||||
if (!token) throw new ValidationError('No token provided')
|
||||
return await CoursesInstructorService.addInstructorToCourse({ token, course_id: courseId, user_id: userId });
|
||||
return await CoursesInstructorService.addInstructorToCourse({ token, course_id: courseId, email_or_username: emailOrUsername });
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -22,6 +22,8 @@ import {
|
|||
sendCourseForReview,
|
||||
getmyCourse,
|
||||
listinstructorCourse,
|
||||
SearchInstructorInput,
|
||||
SearchInstructorResponse,
|
||||
} from "../types/CoursesInstructor.types";
|
||||
|
||||
export class CoursesInstructorService {
|
||||
|
|
@ -344,15 +346,111 @@ export class CoursesInstructorService {
|
|||
|
||||
|
||||
|
||||
static async searchInstructors(input: SearchInstructorInput): Promise<SearchInstructorResponse> {
|
||||
try {
|
||||
const decoded = jwt.verify(input.token, config.jwt.secret) as { id: number };
|
||||
|
||||
// Validate user is instructor of this course
|
||||
await this.validateCourseInstructor(input.token, input.course_id);
|
||||
|
||||
// Get existing instructors of this course
|
||||
const existingInstructors = await prisma.courseInstructor.findMany({
|
||||
where: { course_id: input.course_id },
|
||||
select: { user_id: true }
|
||||
});
|
||||
const existingUserIds = existingInstructors.map(i => i.user_id);
|
||||
|
||||
// Search users by email or username (only instructors role)
|
||||
const users = await prisma.user.findMany({
|
||||
where: {
|
||||
AND: [
|
||||
{
|
||||
OR: [
|
||||
{ email: { contains: input.query, mode: 'insensitive' } },
|
||||
{ username: { contains: input.query, mode: 'insensitive' } },
|
||||
]
|
||||
},
|
||||
{ role: { code: 'instructor' } },
|
||||
{ id: { notIn: existingUserIds } } // Exclude already added instructors
|
||||
]
|
||||
},
|
||||
include: {
|
||||
profile: true
|
||||
},
|
||||
take: 10
|
||||
});
|
||||
|
||||
const results = await Promise.all(users.map(async (user) => {
|
||||
let avatar_url: string | null = null;
|
||||
if (user.profile?.avatar_url) {
|
||||
try {
|
||||
avatar_url = await getPresignedUrl(user.profile.avatar_url, 3600);
|
||||
} catch (err) {
|
||||
logger.warn(`Failed to generate presigned URL for avatar: ${err}`);
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
first_name: user.profile?.first_name || null,
|
||||
last_name: user.profile?.last_name || null,
|
||||
avatar_url,
|
||||
};
|
||||
}));
|
||||
|
||||
return {
|
||||
code: 200,
|
||||
message: 'Instructors found',
|
||||
data: results,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Failed to search instructors', { error });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
static async addInstructorToCourse(addinstructorCourse: addinstructorCourse): Promise<addinstructorCourseResponse> {
|
||||
try {
|
||||
const decoded = jwt.verify(addinstructorCourse.token, config.jwt.secret) as { id: number; type: string };
|
||||
// Validate user is instructor of this course
|
||||
await this.validateCourseInstructor(addinstructorCourse.token, addinstructorCourse.course_id);
|
||||
|
||||
// Find user by email or username
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ email: addinstructorCourse.email_or_username },
|
||||
{ username: addinstructorCourse.email_or_username },
|
||||
],
|
||||
role: { code: 'instructor' }
|
||||
}
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new NotFoundError('Instructor not found with this email or username');
|
||||
}
|
||||
|
||||
// Check if already added
|
||||
const existing = await prisma.courseInstructor.findUnique({
|
||||
where: {
|
||||
course_id_user_id: {
|
||||
course_id: addinstructorCourse.course_id,
|
||||
user_id: user.id,
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
throw new ValidationError('This instructor is already added to the course');
|
||||
}
|
||||
|
||||
await prisma.courseInstructor.create({
|
||||
data: {
|
||||
course_id: addinstructorCourse.course_id,
|
||||
user_id: decoded.id,
|
||||
user_id: user.id,
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
code: 200,
|
||||
message: 'Instructor added to course successfully',
|
||||
|
|
@ -392,13 +490,41 @@ export class CoursesInstructorService {
|
|||
course_id: listinstructorCourse.course_id,
|
||||
},
|
||||
include: {
|
||||
user: true,
|
||||
user: {
|
||||
include: {
|
||||
profile: true
|
||||
}
|
||||
},
|
||||
}
|
||||
});
|
||||
|
||||
const data = await Promise.all(courseInstructors.map(async (ci) => {
|
||||
let avatar_url: string | null = null;
|
||||
if (ci.user.profile?.avatar_url) {
|
||||
try {
|
||||
avatar_url = await getPresignedUrl(ci.user.profile.avatar_url, 3600);
|
||||
} catch (err) {
|
||||
logger.warn(`Failed to generate presigned URL for avatar: ${err}`);
|
||||
}
|
||||
}
|
||||
return {
|
||||
user_id: ci.user_id,
|
||||
is_primary: ci.is_primary,
|
||||
user: {
|
||||
id: ci.user.id,
|
||||
username: ci.user.username,
|
||||
email: ci.user.email,
|
||||
first_name: ci.user.profile?.first_name || null,
|
||||
last_name: ci.user.profile?.last_name || null,
|
||||
avatar_url,
|
||||
},
|
||||
};
|
||||
}));
|
||||
|
||||
return {
|
||||
code: 200,
|
||||
message: 'Instructors retrieved successfully',
|
||||
data: courseInstructors,
|
||||
data,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Failed to retrieve instructors of course', { error });
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import {
|
|||
import { UserResponse } from '../types/user.types';
|
||||
import { UnauthorizedError, ValidationError, ForbiddenError } from '../middleware/errorHandler';
|
||||
import nodemailer from 'nodemailer';
|
||||
import { getPresignedUrl } from '../config/minio';
|
||||
|
||||
export class AuthService {
|
||||
/**
|
||||
|
|
@ -59,7 +60,7 @@ export class AuthService {
|
|||
return {
|
||||
token,
|
||||
refreshToken,
|
||||
user: this.formatUserResponse(user)
|
||||
user: await this.formatUserResponse(user)
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -134,7 +135,7 @@ export class AuthService {
|
|||
logger.info('New user registered', { userId: user.id, username: user.username });
|
||||
|
||||
return {
|
||||
user: this.formatUserResponse(user),
|
||||
user: this.formatUserResponseSync(user),
|
||||
message: 'Registration successful'
|
||||
};
|
||||
}
|
||||
|
|
@ -207,10 +208,10 @@ export class AuthService {
|
|||
logger.info('New user registered', { userId: user.id, username: user.username });
|
||||
|
||||
return {
|
||||
user: this.formatUserResponse(user),
|
||||
user: this.formatUserResponseSync(user),
|
||||
message: 'Registration successful'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh access token
|
||||
|
|
@ -370,9 +371,43 @@ export class AuthService {
|
|||
}
|
||||
|
||||
/**
|
||||
* Format user response
|
||||
* Format user response with presigned URL for avatar (for login)
|
||||
*/
|
||||
private formatUserResponse(user: any): UserResponse {
|
||||
private async formatUserResponse(user: any): Promise<UserResponse> {
|
||||
let avatar_url: string | null = null;
|
||||
if (user.profile?.avatar_url) {
|
||||
try {
|
||||
avatar_url = await getPresignedUrl(user.profile.avatar_url, 3600);
|
||||
} catch (err) {
|
||||
logger.warn(`Failed to generate presigned URL for avatar: ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
updated_at: user.updated_at,
|
||||
created_at: user.created_at,
|
||||
role: {
|
||||
code: user.role.code,
|
||||
name: user.role.name
|
||||
},
|
||||
profile: user.profile ? {
|
||||
prefix: user.profile.prefix,
|
||||
first_name: user.profile.first_name,
|
||||
last_name: user.profile.last_name,
|
||||
phone: user.profile.phone,
|
||||
avatar_url: avatar_url,
|
||||
birth_date: user.profile.birth_date
|
||||
} : undefined
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Format user response without presigned URL (for register)
|
||||
*/
|
||||
private formatUserResponseSync(user: any): UserResponse {
|
||||
return {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
|
|
@ -393,6 +428,4 @@ export class AuthService {
|
|||
} : undefined
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -90,22 +90,52 @@ export interface listCourseinstructorResponse {
|
|||
|
||||
export interface addinstructorCourse {
|
||||
token: string;
|
||||
user_id: number;
|
||||
email_or_username: string;
|
||||
course_id: number;
|
||||
}
|
||||
|
||||
export interface SearchInstructorInput {
|
||||
token: string;
|
||||
query: string;
|
||||
course_id: number;
|
||||
}
|
||||
|
||||
export interface SearchInstructorResult {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
first_name: string | null;
|
||||
last_name: string | null;
|
||||
avatar_url: string | null;
|
||||
}
|
||||
|
||||
export interface SearchInstructorResponse {
|
||||
code: number;
|
||||
message: string;
|
||||
data: SearchInstructorResult[];
|
||||
}
|
||||
|
||||
export interface addinstructorCourseResponse {
|
||||
code: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface InstructorInfo {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
first_name: string | null;
|
||||
last_name: string | null;
|
||||
avatar_url: string | null;
|
||||
}
|
||||
|
||||
export interface listinstructorCourseResponse {
|
||||
code: number;
|
||||
message: string;
|
||||
data: {
|
||||
user_id: number;
|
||||
is_primary: boolean;
|
||||
user: User;
|
||||
user: InstructorInfo;
|
||||
}[];
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue