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 { ValidationError } from '../middleware/errorHandler';
|
||||||
import { CoursesInstructorService } from '../services/CoursesInstructor.service';
|
import { CoursesInstructorService } from '../services/CoursesInstructor.service';
|
||||||
import {
|
import {
|
||||||
|
|
@ -6,18 +6,16 @@ import {
|
||||||
createCourseResponse,
|
createCourseResponse,
|
||||||
GetMyCourseResponse,
|
GetMyCourseResponse,
|
||||||
ListMyCourseResponse,
|
ListMyCourseResponse,
|
||||||
addinstructorCourse,
|
|
||||||
addinstructorCourseResponse,
|
addinstructorCourseResponse,
|
||||||
removeinstructorCourse,
|
|
||||||
removeinstructorCourseResponse,
|
removeinstructorCourseResponse,
|
||||||
setprimaryCourseInstructor,
|
|
||||||
setprimaryCourseInstructorResponse,
|
setprimaryCourseInstructorResponse,
|
||||||
UpdateMyCourse,
|
UpdateMyCourse,
|
||||||
UpdateMyCourseResponse,
|
UpdateMyCourseResponse,
|
||||||
DeleteMyCourseResponse,
|
DeleteMyCourseResponse,
|
||||||
submitCourseResponse,
|
submitCourseResponse,
|
||||||
listinstructorCourseResponse,
|
listinstructorCourseResponse,
|
||||||
GetCourseApprovalsResponse
|
GetCourseApprovalsResponse,
|
||||||
|
SearchInstructorResponse
|
||||||
} from '../types/CoursesInstructor.types';
|
} from '../types/CoursesInstructor.types';
|
||||||
import { CreateCourseValidator } from "../validators/CoursesInstructor.validator";
|
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 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'])
|
@Security('jwt', ['instructor'])
|
||||||
@SuccessResponse('200', 'Instructor added successfully')
|
@SuccessResponse('200', 'Instructor added successfully')
|
||||||
@Response('401', 'Invalid or expired token')
|
@Response('401', 'Invalid or expired token')
|
||||||
@Response('404', 'Instructor not found')
|
@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 ', '');
|
const token = request.headers.authorization?.replace('Bearer ', '');
|
||||||
if (!token) throw new ValidationError('No token provided')
|
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,
|
sendCourseForReview,
|
||||||
getmyCourse,
|
getmyCourse,
|
||||||
listinstructorCourse,
|
listinstructorCourse,
|
||||||
|
SearchInstructorInput,
|
||||||
|
SearchInstructorResponse,
|
||||||
} from "../types/CoursesInstructor.types";
|
} from "../types/CoursesInstructor.types";
|
||||||
|
|
||||||
export class CoursesInstructorService {
|
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> {
|
static async addInstructorToCourse(addinstructorCourse: addinstructorCourse): Promise<addinstructorCourseResponse> {
|
||||||
try {
|
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({
|
await prisma.courseInstructor.create({
|
||||||
data: {
|
data: {
|
||||||
course_id: addinstructorCourse.course_id,
|
course_id: addinstructorCourse.course_id,
|
||||||
user_id: decoded.id,
|
user_id: user.id,
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
code: 200,
|
code: 200,
|
||||||
message: 'Instructor added to course successfully',
|
message: 'Instructor added to course successfully',
|
||||||
|
|
@ -392,13 +490,41 @@ export class CoursesInstructorService {
|
||||||
course_id: listinstructorCourse.course_id,
|
course_id: listinstructorCourse.course_id,
|
||||||
},
|
},
|
||||||
include: {
|
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 {
|
return {
|
||||||
code: 200,
|
code: 200,
|
||||||
message: 'Instructors retrieved successfully',
|
message: 'Instructors retrieved successfully',
|
||||||
data: courseInstructors,
|
data,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to retrieve instructors of course', { error });
|
logger.error('Failed to retrieve instructors of course', { error });
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import {
|
||||||
import { UserResponse } from '../types/user.types';
|
import { UserResponse } from '../types/user.types';
|
||||||
import { UnauthorizedError, ValidationError, ForbiddenError } from '../middleware/errorHandler';
|
import { UnauthorizedError, ValidationError, ForbiddenError } from '../middleware/errorHandler';
|
||||||
import nodemailer from 'nodemailer';
|
import nodemailer from 'nodemailer';
|
||||||
|
import { getPresignedUrl } from '../config/minio';
|
||||||
|
|
||||||
export class AuthService {
|
export class AuthService {
|
||||||
/**
|
/**
|
||||||
|
|
@ -59,7 +60,7 @@ export class AuthService {
|
||||||
return {
|
return {
|
||||||
token,
|
token,
|
||||||
refreshToken,
|
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 });
|
logger.info('New user registered', { userId: user.id, username: user.username });
|
||||||
|
|
||||||
return {
|
return {
|
||||||
user: this.formatUserResponse(user),
|
user: this.formatUserResponseSync(user),
|
||||||
message: 'Registration successful'
|
message: 'Registration successful'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -207,10 +208,10 @@ export class AuthService {
|
||||||
logger.info('New user registered', { userId: user.id, username: user.username });
|
logger.info('New user registered', { userId: user.id, username: user.username });
|
||||||
|
|
||||||
return {
|
return {
|
||||||
user: this.formatUserResponse(user),
|
user: this.formatUserResponseSync(user),
|
||||||
message: 'Registration successful'
|
message: 'Registration successful'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Refresh access token
|
* 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 {
|
return {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
username: user.username,
|
username: user.username,
|
||||||
|
|
@ -393,6 +428,4 @@ export class AuthService {
|
||||||
} : undefined
|
} : undefined
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -90,22 +90,52 @@ export interface listCourseinstructorResponse {
|
||||||
|
|
||||||
export interface addinstructorCourse {
|
export interface addinstructorCourse {
|
||||||
token: string;
|
token: string;
|
||||||
user_id: number;
|
email_or_username: string;
|
||||||
course_id: number;
|
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 {
|
export interface addinstructorCourseResponse {
|
||||||
code: number;
|
code: number;
|
||||||
message: string;
|
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 {
|
export interface listinstructorCourseResponse {
|
||||||
code: number;
|
code: number;
|
||||||
message: string;
|
message: string;
|
||||||
data: {
|
data: {
|
||||||
user_id: number;
|
user_id: number;
|
||||||
is_primary: boolean;
|
is_primary: boolean;
|
||||||
user: User;
|
user: InstructorInfo;
|
||||||
}[];
|
}[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue