feat: add thumbnail upload support to course creation endpoint with multipart form data handling.
This commit is contained in:
parent
19844f343b
commit
cf12ef965e
3 changed files with 54 additions and 44 deletions
|
|
@ -1,4 +1,4 @@
|
||||||
import { Get, Body, Post, Route, Tags, SuccessResponse, Response, Security, Put, Path, Delete, Request, Example } from 'tsoa';
|
import { Get, Body, Post, Route, Tags, SuccessResponse, Response, Security, Put, Path, Delete, Request, Example, FormField, UploadedFile } 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 {
|
||||||
|
|
@ -82,21 +82,33 @@ export class CoursesInstructorController {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* สร้างคอร์สใหม่
|
* สร้างคอร์สใหม่ (พร้อมอัปโหลดรูป thumbnail)
|
||||||
* Create a new course (status will be DRAFT by default)
|
* Create a new course with optional thumbnail upload (status will be DRAFT by default)
|
||||||
|
* @param data - JSON string containing course data
|
||||||
|
* @param thumbnail - Optional thumbnail image file
|
||||||
*/
|
*/
|
||||||
@Post('')
|
@Post('')
|
||||||
@Security('jwt', ['instructor'])
|
@Security('jwt', ['instructor'])
|
||||||
@SuccessResponse('201', 'Course created successfully')
|
@SuccessResponse('201', 'Course created successfully')
|
||||||
@Response('400', 'Validation error')
|
@Response('400', 'Validation error')
|
||||||
@Response('500', 'Internal server error')
|
@Response('500', 'Internal server error')
|
||||||
public async createCourse(@Body() body: createCourses, @Request() request: any): Promise<createCourseResponse> {
|
public async createCourse(
|
||||||
|
@Request() request: any,
|
||||||
|
@FormField() data: string,
|
||||||
|
@UploadedFile() thumbnail?: Express.Multer.File
|
||||||
|
): Promise<createCourseResponse> {
|
||||||
const token = request.headers.authorization?.replace('Bearer ', '');
|
const token = request.headers.authorization?.replace('Bearer ', '');
|
||||||
|
if (!token) throw new ValidationError('No token provided');
|
||||||
|
|
||||||
const decoded = jwt.verify(token, config.jwt.secret) as { id: number };
|
const decoded = jwt.verify(token, config.jwt.secret) as { id: number };
|
||||||
const { error, value } = CreateCourseValidator.validate(body.data);
|
const parsed = JSON.parse(data);
|
||||||
|
const { error, value } = CreateCourseValidator.validate(parsed);
|
||||||
if (error) throw new ValidationError(error.details[0].message);
|
if (error) throw new ValidationError(error.details[0].message);
|
||||||
const course = await CoursesInstructorService.createCourse(value, decoded.id);
|
|
||||||
return course;
|
// Validate thumbnail file type if provided
|
||||||
|
if (thumbnail && !thumbnail.mimetype?.startsWith('image/')) throw new ValidationError('Only image files are allowed for thumbnail');
|
||||||
|
|
||||||
|
return await CoursesInstructorService.createCourse(value, decoded.id, thumbnail);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -111,9 +123,7 @@ export class CoursesInstructorController {
|
||||||
@Response('404', 'Course not found')
|
@Response('404', 'Course not found')
|
||||||
public async deleteCourse(@Request() request: any, @Path() courseId: number): Promise<DeleteMyCourseResponse> {
|
public async deleteCourse(@Request() request: any, @Path() courseId: number): Promise<DeleteMyCourseResponse> {
|
||||||
const token = request.headers.authorization?.replace('Bearer ', '');
|
const token = request.headers.authorization?.replace('Bearer ', '');
|
||||||
if (!token) {
|
if (!token) throw new ValidationError('No token provided')
|
||||||
throw new ValidationError('No token provided');
|
|
||||||
}
|
|
||||||
return await CoursesInstructorService.deleteCourse(token, courseId);
|
return await CoursesInstructorService.deleteCourse(token, courseId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -129,9 +139,7 @@ export class CoursesInstructorController {
|
||||||
@Response('404', 'Course not found')
|
@Response('404', 'Course not found')
|
||||||
public async submitCourse(@Request() request: any, @Path() courseId: number): Promise<submitCourseResponse> {
|
public async submitCourse(@Request() request: any, @Path() courseId: number): Promise<submitCourseResponse> {
|
||||||
const token = request.headers.authorization?.replace('Bearer ', '');
|
const token = request.headers.authorization?.replace('Bearer ', '');
|
||||||
if (!token) {
|
if (!token) throw new ValidationError('No token provided')
|
||||||
throw new ValidationError('No token provided');
|
|
||||||
}
|
|
||||||
return await CoursesInstructorService.sendCourseForReview({ token, course_id: courseId });
|
return await CoursesInstructorService.sendCourseForReview({ token, course_id: courseId });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -148,9 +156,7 @@ export class CoursesInstructorController {
|
||||||
@Response('404', 'Course not found')
|
@Response('404', 'Course not found')
|
||||||
public async getCourseApprovals(@Request() request: any, @Path() courseId: number): Promise<GetCourseApprovalsResponse> {
|
public async getCourseApprovals(@Request() request: any, @Path() courseId: number): Promise<GetCourseApprovalsResponse> {
|
||||||
const token = request.headers.authorization?.replace('Bearer ', '');
|
const token = request.headers.authorization?.replace('Bearer ', '');
|
||||||
if (!token) {
|
if (!token) throw new ValidationError('No token provided')
|
||||||
throw new ValidationError('No token provided');
|
|
||||||
}
|
|
||||||
return await CoursesInstructorService.getCourseApprovals(token, courseId);
|
return await CoursesInstructorService.getCourseApprovals(token, courseId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -166,9 +172,7 @@ export class CoursesInstructorController {
|
||||||
@Response('404', 'Instructors not found')
|
@Response('404', 'Instructors not found')
|
||||||
public async listInstructorCourses(@Request() request: any, @Path() courseId: number): Promise<listinstructorCourseResponse> {
|
public async listInstructorCourses(@Request() request: any, @Path() courseId: number): Promise<listinstructorCourseResponse> {
|
||||||
const token = request.headers.authorization?.replace('Bearer ', '');
|
const token = request.headers.authorization?.replace('Bearer ', '');
|
||||||
if (!token) {
|
if (!token) throw new ValidationError('No token provided')
|
||||||
throw new ValidationError('No token provided');
|
|
||||||
}
|
|
||||||
return await CoursesInstructorService.listInstructorsOfCourse({ token, course_id: courseId });
|
return await CoursesInstructorService.listInstructorsOfCourse({ token, course_id: courseId });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -185,9 +189,7 @@ export class CoursesInstructorController {
|
||||||
@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() userId: number): Promise<addinstructorCourseResponse> {
|
||||||
const token = request.headers.authorization?.replace('Bearer ', '');
|
const token = request.headers.authorization?.replace('Bearer ', '');
|
||||||
if (!token) {
|
if (!token) throw new ValidationError('No token provided')
|
||||||
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, user_id: userId });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -204,9 +206,7 @@ export class CoursesInstructorController {
|
||||||
@Response('404', 'Instructor not found')
|
@Response('404', 'Instructor not found')
|
||||||
public async removeInstructor(@Request() request: any, @Path() courseId: number, @Path() userId: number): Promise<removeinstructorCourseResponse> {
|
public async removeInstructor(@Request() request: any, @Path() courseId: number, @Path() userId: number): Promise<removeinstructorCourseResponse> {
|
||||||
const token = request.headers.authorization?.replace('Bearer ', '');
|
const token = request.headers.authorization?.replace('Bearer ', '');
|
||||||
if (!token) {
|
if (!token) throw new ValidationError('No token provided')
|
||||||
throw new ValidationError('No token provided');
|
|
||||||
}
|
|
||||||
return await CoursesInstructorService.removeInstructorFromCourse({ token, course_id: courseId, user_id: userId });
|
return await CoursesInstructorService.removeInstructorFromCourse({ token, course_id: courseId, user_id: userId });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -223,9 +223,7 @@ export class CoursesInstructorController {
|
||||||
@Response('404', 'Primary instructor not found')
|
@Response('404', 'Primary instructor not found')
|
||||||
public async setPrimaryInstructor(@Request() request: any, @Path() courseId: number, @Path() userId: number): Promise<setprimaryCourseInstructorResponse> {
|
public async setPrimaryInstructor(@Request() request: any, @Path() courseId: number, @Path() userId: number): Promise<setprimaryCourseInstructorResponse> {
|
||||||
const token = request.headers.authorization?.replace('Bearer ', '');
|
const token = request.headers.authorization?.replace('Bearer ', '');
|
||||||
if (!token) {
|
if (!token) throw new ValidationError('No token provided')
|
||||||
throw new ValidationError('No token provided');
|
|
||||||
}
|
|
||||||
return await CoursesInstructorService.setPrimaryInstructor({ token, course_id: courseId, user_id: userId });
|
return await CoursesInstructorService.setPrimaryInstructor({ token, course_id: courseId, user_id: userId });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -4,6 +4,7 @@ import { config } from '../config';
|
||||||
import { logger } from '../config/logger';
|
import { logger } from '../config/logger';
|
||||||
import { UnauthorizedError, ValidationError, ForbiddenError, NotFoundError } from '../middleware/errorHandler';
|
import { UnauthorizedError, ValidationError, ForbiddenError, NotFoundError } from '../middleware/errorHandler';
|
||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken';
|
||||||
|
import { uploadFile, deleteFile } from '../config/minio';
|
||||||
import {
|
import {
|
||||||
CreateCourseInput,
|
CreateCourseInput,
|
||||||
UpdateCourseInput,
|
UpdateCourseInput,
|
||||||
|
|
@ -24,8 +25,22 @@ import {
|
||||||
} from "../types/CoursesInstructor.types";
|
} from "../types/CoursesInstructor.types";
|
||||||
|
|
||||||
export class CoursesInstructorService {
|
export class CoursesInstructorService {
|
||||||
static async createCourse(courseData: CreateCourseInput, userId: number): Promise<createCourseResponse> {
|
static async createCourse(courseData: CreateCourseInput, userId: number, thumbnailFile?: Express.Multer.File): Promise<createCourseResponse> {
|
||||||
try {
|
try {
|
||||||
|
let thumbnailUrl: string | undefined;
|
||||||
|
|
||||||
|
// Upload thumbnail to MinIO if provided
|
||||||
|
if (thumbnailFile) {
|
||||||
|
const timestamp = Date.now();
|
||||||
|
const uniqueId = Math.random().toString(36).substring(2, 15);
|
||||||
|
const extension = thumbnailFile.originalname.split('.').pop() || 'jpg';
|
||||||
|
const safeFilename = `${timestamp}-${uniqueId}.${extension}`;
|
||||||
|
const filePath = `courses/thumbnails/${safeFilename}`;
|
||||||
|
|
||||||
|
await uploadFile(filePath, thumbnailFile.buffer, thumbnailFile.mimetype || 'image/jpeg');
|
||||||
|
thumbnailUrl = filePath;
|
||||||
|
}
|
||||||
|
|
||||||
// Use transaction to create course and instructor together
|
// Use transaction to create course and instructor together
|
||||||
const result = await prisma.$transaction(async (tx) => {
|
const result = await prisma.$transaction(async (tx) => {
|
||||||
// Create the course
|
// Create the course
|
||||||
|
|
@ -35,7 +50,7 @@ export class CoursesInstructorService {
|
||||||
title: courseData.title,
|
title: courseData.title,
|
||||||
slug: courseData.slug,
|
slug: courseData.slug,
|
||||||
description: courseData.description,
|
description: courseData.description,
|
||||||
thumbnail_url: courseData.thumbnail_url,
|
thumbnail_url: thumbnailUrl,
|
||||||
price: courseData.price || 0,
|
price: courseData.price || 0,
|
||||||
is_free: courseData.is_free ?? false,
|
is_free: courseData.is_free ?? false,
|
||||||
have_certificate: courseData.have_certificate ?? false,
|
have_certificate: courseData.have_certificate ?? false,
|
||||||
|
|
|
||||||
|
|
@ -221,32 +221,32 @@ export class UserService {
|
||||||
// Upload to MinIO
|
// Upload to MinIO
|
||||||
await uploadFile(filePath, file.buffer, file.mimetype || 'image/jpeg');
|
await uploadFile(filePath, file.buffer, file.mimetype || 'image/jpeg');
|
||||||
|
|
||||||
// Update user profile with avatar URL
|
// Update or create profile - store only file path
|
||||||
const avatarUrl = `${config.s3.endpoint}/${config.s3.bucket}/${filePath}`;
|
|
||||||
|
|
||||||
// Update or create profile
|
|
||||||
if (user.profile) {
|
if (user.profile) {
|
||||||
await prisma.userProfile.update({
|
await prisma.userProfile.update({
|
||||||
where: { user_id: decoded.id },
|
where: { user_id: decoded.id },
|
||||||
data: { avatar_url: avatarUrl }
|
data: { avatar_url: filePath }
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
await prisma.userProfile.create({
|
await prisma.userProfile.create({
|
||||||
data: {
|
data: {
|
||||||
user_id: decoded.id,
|
user_id: decoded.id,
|
||||||
avatar_url: avatarUrl,
|
avatar_url: filePath,
|
||||||
first_name: '',
|
first_name: '',
|
||||||
last_name: ''
|
last_name: ''
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Generate presigned URL for response
|
||||||
|
const presignedUrl = await this.getAvatarPresignedUrl(filePath);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
code: 200,
|
code: 200,
|
||||||
message: 'Avatar uploaded successfully',
|
message: 'Avatar uploaded successfully',
|
||||||
data: {
|
data: {
|
||||||
id: decoded.id,
|
id: decoded.id,
|
||||||
avatar_url: avatarUrl
|
avatar_url: presignedUrl
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -266,16 +266,13 @@ export class UserService {
|
||||||
/**
|
/**
|
||||||
* Get presigned URL for avatar
|
* Get presigned URL for avatar
|
||||||
*/
|
*/
|
||||||
private async getAvatarPresignedUrl(avatarUrl: string): Promise<string | null> {
|
private async getAvatarPresignedUrl(avatarPath: string): Promise<string> {
|
||||||
try {
|
try {
|
||||||
// Extract file path from stored URL or path
|
// avatarPath is now stored as file path directly (e.g., avatars/1/filename.jpg)
|
||||||
const filePath = avatarUrl.includes('/')
|
return await getPresignedUrl(avatarPath, 3600); // 1 hour expiry
|
||||||
? `avatars/${avatarUrl.split('/').slice(-2).join('/')}`
|
|
||||||
: avatarUrl;
|
|
||||||
return await getPresignedUrl(filePath, 3600); // 1 hour expiry
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn(`Failed to generate presigned URL for avatar: ${error}`);
|
logger.warn(`Failed to generate presigned URL for avatar: ${error}`);
|
||||||
return null;
|
return '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue