From cf12ef965efcf352afd42641790f28775e92eb0e Mon Sep 17 00:00:00 2001 From: JakkrapartXD Date: Wed, 28 Jan 2026 14:38:11 +0700 Subject: [PATCH] feat: add thumbnail upload support to course creation endpoint with multipart form data handling. --- .../CoursesInstructorController.ts | 54 +++++++++---------- .../src/services/CoursesInstructor.service.ts | 19 ++++++- Backend/src/services/user.service.ts | 25 ++++----- 3 files changed, 54 insertions(+), 44 deletions(-) diff --git a/Backend/src/controllers/CoursesInstructorController.ts b/Backend/src/controllers/CoursesInstructorController.ts index 2f489a21..6ea2be56 100644 --- a/Backend/src/controllers/CoursesInstructorController.ts +++ b/Backend/src/controllers/CoursesInstructorController.ts @@ -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 { CoursesInstructorService } from '../services/CoursesInstructor.service'; import { @@ -82,21 +82,33 @@ export class CoursesInstructorController { } /** - * สร้างคอร์สใหม่ - * Create a new course (status will be DRAFT by default) + * สร้างคอร์สใหม่ (พร้อมอัปโหลดรูป thumbnail) + * 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('') @Security('jwt', ['instructor']) @SuccessResponse('201', 'Course created successfully') @Response('400', 'Validation error') @Response('500', 'Internal server error') - public async createCourse(@Body() body: createCourses, @Request() request: any): Promise { + public async createCourse( + @Request() request: any, + @FormField() data: string, + @UploadedFile() thumbnail?: Express.Multer.File + ): Promise { 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 { 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); - 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') public async deleteCourse(@Request() request: any, @Path() courseId: number): Promise { 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.deleteCourse(token, courseId); } @@ -129,9 +139,7 @@ export class CoursesInstructorController { @Response('404', 'Course not found') public async submitCourse(@Request() request: any, @Path() courseId: number): Promise { 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.sendCourseForReview({ token, course_id: courseId }); } @@ -148,9 +156,7 @@ export class CoursesInstructorController { @Response('404', 'Course not found') public async getCourseApprovals(@Request() request: any, @Path() courseId: number): Promise { 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.getCourseApprovals(token, courseId); } @@ -166,9 +172,7 @@ export class CoursesInstructorController { @Response('404', 'Instructors not found') public async listInstructorCourses(@Request() request: any, @Path() courseId: number): Promise { 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.listInstructorsOfCourse({ token, course_id: courseId }); } @@ -185,9 +189,7 @@ export class CoursesInstructorController { @Response('404', 'Instructor not found') public async addInstructor(@Request() request: any, @Path() courseId: number, @Path() userId: number): Promise { 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 }); } @@ -204,9 +206,7 @@ export class CoursesInstructorController { @Response('404', 'Instructor not found') public async removeInstructor(@Request() request: any, @Path() courseId: number, @Path() userId: number): Promise { 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.removeInstructorFromCourse({ token, course_id: courseId, user_id: userId }); } @@ -223,9 +223,7 @@ export class CoursesInstructorController { @Response('404', 'Primary instructor not found') public async setPrimaryInstructor(@Request() request: any, @Path() courseId: number, @Path() userId: number): Promise { 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.setPrimaryInstructor({ token, course_id: courseId, user_id: userId }); } } \ No newline at end of file diff --git a/Backend/src/services/CoursesInstructor.service.ts b/Backend/src/services/CoursesInstructor.service.ts index f4aa0ab2..a1887d9e 100644 --- a/Backend/src/services/CoursesInstructor.service.ts +++ b/Backend/src/services/CoursesInstructor.service.ts @@ -4,6 +4,7 @@ import { config } from '../config'; import { logger } from '../config/logger'; import { UnauthorizedError, ValidationError, ForbiddenError, NotFoundError } from '../middleware/errorHandler'; import jwt from 'jsonwebtoken'; +import { uploadFile, deleteFile } from '../config/minio'; import { CreateCourseInput, UpdateCourseInput, @@ -24,8 +25,22 @@ import { } from "../types/CoursesInstructor.types"; export class CoursesInstructorService { - static async createCourse(courseData: CreateCourseInput, userId: number): Promise { + static async createCourse(courseData: CreateCourseInput, userId: number, thumbnailFile?: Express.Multer.File): Promise { 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 const result = await prisma.$transaction(async (tx) => { // Create the course @@ -35,7 +50,7 @@ export class CoursesInstructorService { title: courseData.title, slug: courseData.slug, description: courseData.description, - thumbnail_url: courseData.thumbnail_url, + thumbnail_url: thumbnailUrl, price: courseData.price || 0, is_free: courseData.is_free ?? false, have_certificate: courseData.have_certificate ?? false, diff --git a/Backend/src/services/user.service.ts b/Backend/src/services/user.service.ts index ca61e495..752d3b3f 100644 --- a/Backend/src/services/user.service.ts +++ b/Backend/src/services/user.service.ts @@ -221,32 +221,32 @@ export class UserService { // Upload to MinIO await uploadFile(filePath, file.buffer, file.mimetype || 'image/jpeg'); - // Update user profile with avatar URL - const avatarUrl = `${config.s3.endpoint}/${config.s3.bucket}/${filePath}`; - - // Update or create profile + // Update or create profile - store only file path if (user.profile) { await prisma.userProfile.update({ where: { user_id: decoded.id }, - data: { avatar_url: avatarUrl } + data: { avatar_url: filePath } }); } else { await prisma.userProfile.create({ data: { user_id: decoded.id, - avatar_url: avatarUrl, + avatar_url: filePath, first_name: '', last_name: '' } }); } + // Generate presigned URL for response + const presignedUrl = await this.getAvatarPresignedUrl(filePath); + return { code: 200, message: 'Avatar uploaded successfully', data: { id: decoded.id, - avatar_url: avatarUrl + avatar_url: presignedUrl } }; } catch (error) { @@ -266,16 +266,13 @@ export class UserService { /** * Get presigned URL for avatar */ - private async getAvatarPresignedUrl(avatarUrl: string): Promise { + private async getAvatarPresignedUrl(avatarPath: string): Promise { try { - // Extract file path from stored URL or path - const filePath = avatarUrl.includes('/') - ? `avatars/${avatarUrl.split('/').slice(-2).join('/')}` - : avatarUrl; - return await getPresignedUrl(filePath, 3600); // 1 hour expiry + // avatarPath is now stored as file path directly (e.g., avatars/1/filename.jpg) + return await getPresignedUrl(avatarPath, 3600); // 1 hour expiry } catch (error) { logger.warn(`Failed to generate presigned URL for avatar: ${error}`); - return null; + return ''; } }