From 5d6cab229f46f32639fd8cfdc562adec72ad3791 Mon Sep 17 00:00:00 2001 From: JakkrapartXD Date: Fri, 23 Jan 2026 13:16:41 +0700 Subject: [PATCH] make admin approve api --- .../AdminCourseApprovalController.ts | 100 ++++++ .../CoursesInstructorController.ts | 22 +- .../services/AdminCourseApproval.service.ts | 293 ++++++++++++++++++ .../src/services/CoursesInstructor.service.ts | 37 +++ Backend/src/services/Minio.service.ts | 0 .../src/types/AdminCourseApproval.types.ts | 136 ++++++++ Backend/src/types/CoursesInstructor.types.ts | 29 ++ 7 files changed, 616 insertions(+), 1 deletion(-) create mode 100644 Backend/src/controllers/AdminCourseApprovalController.ts create mode 100644 Backend/src/services/AdminCourseApproval.service.ts delete mode 100644 Backend/src/services/Minio.service.ts create mode 100644 Backend/src/types/AdminCourseApproval.types.ts diff --git a/Backend/src/controllers/AdminCourseApprovalController.ts b/Backend/src/controllers/AdminCourseApprovalController.ts new file mode 100644 index 00000000..0ad0171a --- /dev/null +++ b/Backend/src/controllers/AdminCourseApprovalController.ts @@ -0,0 +1,100 @@ +import { Body, Get, Path, Post, Request, Response, Route, Security, SuccessResponse, Tags } from 'tsoa'; +import { ValidationError } from '../middleware/errorHandler'; +import { AdminCourseApprovalService } from '../services/AdminCourseApproval.service'; +import { + ListPendingCoursesResponse, + GetCourseDetailForAdminResponse, + ApproveCourseBody, + ApproveCourseResponse, + RejectCourseBody, + RejectCourseResponse, +} from '../types/AdminCourseApproval.types'; + +@Route('api/admin/courses') +@Tags('Admin/CourseApproval') +export class AdminCourseApprovalController { + + /** + * ดึงรายการคอร์สที่รอการอนุมัติ + * Get all courses pending for approval + */ + @Get('pending') + @Security('jwt', ['admin']) + @SuccessResponse('200', 'Pending courses retrieved successfully') + @Response('401', 'Unauthorized') + @Response('403', 'Forbidden - Admin only') + public async listPendingCourses(@Request() request: any): Promise { + const token = request.headers.authorization?.replace('Bearer ', ''); + if (!token) { + throw new ValidationError('No token provided'); + } + return await AdminCourseApprovalService.listPendingCourses(); + } + + /** + * ดึงรายละเอียดคอร์สสำหรับการตรวจสอบ + * Get course details for admin review + * @param courseId - รหัสคอร์ส / Course ID + */ + @Get('{courseId}') + @Security('jwt', ['admin']) + @SuccessResponse('200', 'Course details retrieved successfully') + @Response('401', 'Unauthorized') + @Response('403', 'Forbidden - Admin only') + @Response('404', 'Course not found') + public async getCourseDetail(@Request() request: any, @Path() courseId: number): Promise { + const token = request.headers.authorization?.replace('Bearer ', ''); + if (!token) { + throw new ValidationError('No token provided'); + } + return await AdminCourseApprovalService.getCourseDetail(courseId); + } + + /** + * อนุมัติคอร์ส + * Approve a course for publication + * @param courseId - รหัสคอร์ส / Course ID + */ + @Post('{courseId}/approve') + @Security('jwt', ['admin']) + @SuccessResponse('200', 'Course approved successfully') + @Response('400', 'Course is not pending for approval') + @Response('401', 'Unauthorized') + @Response('403', 'Forbidden - Admin only') + @Response('404', 'Course not found') + public async approveCourse( + @Request() request: any, + @Path() courseId: number, + @Body() body?: ApproveCourseBody + ): Promise { + const token = request.headers.authorization?.replace('Bearer ', ''); + if (!token) { + throw new ValidationError('No token provided'); + } + return await AdminCourseApprovalService.approveCourse(token, courseId, body?.comment); + } + + /** + * ปฏิเสธคอร์ส + * Reject a course (requires comment) + * @param courseId - รหัสคอร์ส / Course ID + */ + @Post('{courseId}/reject') + @Security('jwt', ['admin']) + @SuccessResponse('200', 'Course rejected successfully') + @Response('400', 'Course is not pending for approval or comment is required') + @Response('401', 'Unauthorized') + @Response('403', 'Forbidden - Admin only') + @Response('404', 'Course not found') + public async rejectCourse( + @Request() request: any, + @Path() courseId: number, + @Body() body: RejectCourseBody + ): Promise { + const token = request.headers.authorization?.replace('Bearer ', ''); + if (!token) { + throw new ValidationError('No token provided'); + } + return await AdminCourseApprovalService.rejectCourse(token, courseId, body.comment); + } +} diff --git a/Backend/src/controllers/CoursesInstructorController.ts b/Backend/src/controllers/CoursesInstructorController.ts index 8682d2af..2f489a21 100644 --- a/Backend/src/controllers/CoursesInstructorController.ts +++ b/Backend/src/controllers/CoursesInstructorController.ts @@ -16,7 +16,8 @@ import { UpdateMyCourseResponse, DeleteMyCourseResponse, submitCourseResponse, - listinstructorCourseResponse + listinstructorCourseResponse, + GetCourseApprovalsResponse } from '../types/CoursesInstructor.types'; import { CreateCourseValidator } from "../validators/CoursesInstructor.validator"; @@ -134,6 +135,25 @@ export class CoursesInstructorController { return await CoursesInstructorService.sendCourseForReview({ token, course_id: courseId }); } + /** + * ดึงประวัติการส่งอนุมัติคอร์สทั้งหมด + * Get all course approval history + * @param courseId - รหัสคอร์ส / Course ID + */ + @Get('{courseId}/approvals') + @Security('jwt', ['instructor']) + @SuccessResponse('200', 'Course approvals retrieved successfully') + @Response('401', 'Invalid or expired token') + @Response('403', 'You are not an instructor of this course') + @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'); + } + return await CoursesInstructorService.getCourseApprovals(token, courseId); + } + /** * ดึงรายชื่อผู้สอนทั้งหมดในคอร์ส * Get list of all instructors in a specific course diff --git a/Backend/src/services/AdminCourseApproval.service.ts b/Backend/src/services/AdminCourseApproval.service.ts new file mode 100644 index 00000000..b8f2d334 --- /dev/null +++ b/Backend/src/services/AdminCourseApproval.service.ts @@ -0,0 +1,293 @@ +import { prisma } from '../config/database'; +import { config } from '../config'; +import { logger } from '../config/logger'; +import { UnauthorizedError, ValidationError, ForbiddenError, NotFoundError } from '../middleware/errorHandler'; +import jwt from 'jsonwebtoken'; +import { + ListPendingCoursesResponse, + GetCourseDetailForAdminResponse, + ApproveCourseResponse, + RejectCourseResponse, +} from '../types/AdminCourseApproval.types'; + +export class AdminCourseApprovalService { + + /** + * Get all pending courses for admin review + */ + static async listPendingCourses(): Promise { + try { + const courses = await prisma.course.findMany({ + where: { status: 'PENDING' }, + orderBy: { updated_at: 'desc' }, + include: { + creator: { + select: { id: true, username: true, email: true } + }, + instructors: { + include: { + user: { + select: { id: true, username: true, email: true } + } + } + }, + chapters: { + include: { + lessons: true + } + }, + courseApprovals: { + where: { action: 'SUBMITTED' }, + orderBy: { created_at: 'desc' }, + take: 1, + include: { + submitter: { + select: { id: true, username: true, email: true } + } + } + } + } + }); + + const data = courses.map(course => ({ + id: course.id, + title: course.title as { th: string; en: string }, + slug: course.slug, + description: course.description as { th: string; en: string }, + thumbnail_url: course.thumbnail_url, + status: course.status, + created_at: course.created_at, + updated_at: course.updated_at, + created_by: course.created_by, + creator: course.creator, + instructors: course.instructors.map(i => ({ + user_id: i.user_id, + 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), + latest_submission: course.courseApprovals[0] ? { + id: course.courseApprovals[0].id, + submitted_by: course.courseApprovals[0].submitted_by, + created_at: course.courseApprovals[0].created_at, + submitter: course.courseApprovals[0].submitter + } : null + })); + + return { + code: 200, + message: 'Pending courses retrieved successfully', + data, + total: data.length + }; + } catch (error) { + logger.error('Failed to list pending courses', { error }); + throw error; + } + } + + /** + * Get course details for admin review + */ + static async getCourseDetail(courseId: number): Promise { + try { + const course = await prisma.course.findUnique({ + where: { id: courseId }, + include: { + category: { + select: { id: true, name: true } + }, + creator: { + select: { id: true, username: true, email: true } + }, + instructors: { + include: { + user: { + select: { id: true, username: true, email: true } + } + } + }, + chapters: { + orderBy: { sort_order: 'asc' }, + include: { + lessons: { + orderBy: { sort_order: 'asc' }, + select: { + id: true, + title: true, + type: true, + sort_order: true, + is_published: true + } + } + } + }, + courseApprovals: { + orderBy: { created_at: 'desc' }, + include: { + submitter: { + select: { id: true, username: true, email: true } + }, + reviewer: { + select: { id: true, username: true, email: true } + } + } + } + } + }); + + if (!course) { + throw new NotFoundError('Course not found'); + } + + return { + code: 200, + message: 'Course details retrieved successfully', + data: { + id: course.id, + title: course.title as { th: string; en: string }, + slug: course.slug, + description: course.description as { th: string; en: string }, + thumbnail_url: course.thumbnail_url, + price: Number(course.price), + is_free: course.is_free, + have_certificate: course.have_certificate, + status: course.status, + created_at: course.created_at, + updated_at: course.updated_at, + category: course.category ? { + id: course.category.id, + name: course.category.name as { th: string; en: string } + } : null, + creator: course.creator, + instructors: course.instructors.map(i => ({ + user_id: i.user_id, + is_primary: i.is_primary, + user: i.user + })), + chapters: course.chapters.map(ch => ({ + id: ch.id, + title: ch.title as { th: string; en: string }, + sort_order: ch.sort_order, + is_published: ch.is_published, + lessons: ch.lessons.map(l => ({ + id: l.id, + title: l.title as { th: string; en: string }, + type: l.type, + sort_order: l.sort_order, + is_published: l.is_published + })) + })), + approval_history: course.courseApprovals.map(a => ({ + id: a.id, + action: a.action, + comment: a.comment, + created_at: a.created_at, + submitter: a.submitter, + reviewer: a.reviewer + })) + } + }; + } catch (error) { + logger.error('Failed to get course detail', { error }); + throw error; + } + } + + /** + * Approve a course + */ + static async approveCourse(token: string, courseId: number, comment?: string): Promise { + try { + const decoded = jwt.verify(token, config.jwt.secret) as { id: number }; + + const course = await prisma.course.findUnique({ where: { id: courseId } }); + if (!course) { + throw new NotFoundError('Course not found'); + } + + if (course.status !== 'PENDING') { + throw new ValidationError('Course is not pending for approval'); + } + + await prisma.$transaction([ + // Update course status + prisma.course.update({ + where: { id: courseId }, + data: { status: 'APPROVED' } + }), + // Create approval record + prisma.courseApproval.create({ + data: { + course_id: courseId, + submitted_by: course.created_by, + reviewed_by: decoded.id, + action: 'APPROVED', + previous_status: course.status, + new_status: 'APPROVED', + comment: comment || null + } + }) + ]); + + return { + code: 200, + message: 'Course approved successfully' + }; + } catch (error) { + logger.error('Failed to approve course', { error }); + throw error; + } + } + + /** + * Reject a course + */ + static async rejectCourse(token: string, courseId: number, comment: string): Promise { + try { + const decoded = jwt.verify(token, config.jwt.secret) as { id: number }; + + const course = await prisma.course.findUnique({ where: { id: courseId } }); + if (!course) { + throw new NotFoundError('Course not found'); + } + + if (course.status !== 'PENDING') { + throw new ValidationError('Course is not pending for approval'); + } + + if (!comment || comment.trim() === '') { + throw new ValidationError('Comment is required when rejecting a course'); + } + + await prisma.$transaction([ + // Update course status back to DRAFT + prisma.course.update({ + where: { id: courseId }, + data: { status: 'DRAFT' } + }), + // Create rejection record + prisma.courseApproval.create({ + data: { + course_id: courseId, + submitted_by: course.created_by, + reviewed_by: decoded.id, + action: 'REJECTED', + previous_status: course.status, + new_status: 'DRAFT', + comment: comment + } + }) + ]); + + return { + code: 200, + message: 'Course rejected successfully' + }; + } catch (error) { + logger.error('Failed to reject course', { error }); + throw error; + } + } +} diff --git a/Backend/src/services/CoursesInstructor.service.ts b/Backend/src/services/CoursesInstructor.service.ts index eb61b478..eb539fe2 100644 --- a/Backend/src/services/CoursesInstructor.service.ts +++ b/Backend/src/services/CoursesInstructor.service.ts @@ -208,6 +208,43 @@ export class CoursesInstructorService { } } + static async getCourseApprovals(token: string, courseId: number): Promise<{ + code: number; + message: string; + data: any[]; + total: number; + }> { + try { + const decoded = jwt.verify(token, config.jwt.secret) as { id: number; type: string }; + + // Validate instructor access + await this.validateCourseInstructor(token, courseId); + + const approvals = await prisma.courseApproval.findMany({ + where: { course_id: courseId }, + orderBy: { created_at: 'desc' }, + include: { + submitter: { + select: { id: true, username: true, email: true } + }, + reviewer: { + select: { id: true, username: true, email: true } + } + } + }); + + return { + code: 200, + message: 'Course approvals retrieved successfully', + data: approvals, + total: approvals.length, + }; + } catch (error) { + logger.error('Failed to retrieve course approvals', { error }); + throw error; + } + } + static async addInstructorToCourse(addinstructorCourse: addinstructorCourse): Promise { diff --git a/Backend/src/services/Minio.service.ts b/Backend/src/services/Minio.service.ts deleted file mode 100644 index e69de29b..00000000 diff --git a/Backend/src/types/AdminCourseApproval.types.ts b/Backend/src/types/AdminCourseApproval.types.ts new file mode 100644 index 00000000..fa98ea6b --- /dev/null +++ b/Backend/src/types/AdminCourseApproval.types.ts @@ -0,0 +1,136 @@ +import { Course, CourseApproval, User } from '@prisma/client'; +import { MultiLanguageText } from './index'; + +// ============================================ +// Admin Course Approval Types +// ============================================ + +export interface PendingCourseData { + id: number; + title: MultiLanguageText; + slug: string; + description: MultiLanguageText; + thumbnail_url: string | null; + status: string; + created_at: Date; + updated_at: Date | null; + created_by: number; + creator: { + id: number; + username: string; + email: string; + }; + instructors: { + user_id: number; + is_primary: boolean; + user: { + id: number; + username: string; + email: string; + }; + }[]; + chapters_count: number; + lessons_count: number; + latest_submission: { + id: number; + submitted_by: number; + created_at: Date; + submitter: { + id: number; + username: string; + email: string; + }; + } | null; +} + +export interface ListPendingCoursesResponse { + code: number; + message: string; + data: PendingCourseData[]; + total: number; +} + +export interface CourseDetailForAdmin { + id: number; + title: MultiLanguageText; + slug: string; + description: MultiLanguageText; + thumbnail_url: string | null; + price: number; + is_free: boolean; + have_certificate: boolean; + status: string; + created_at: Date; + updated_at: Date | null; + category: { + id: number; + name: MultiLanguageText; + } | null; + creator: { + id: number; + username: string; + email: string; + }; + instructors: { + user_id: number; + is_primary: boolean; + user: { + id: number; + username: string; + email: string; + }; + }[]; + chapters: { + id: number; + title: MultiLanguageText; + sort_order: number; + is_published: boolean; + lessons: { + id: number; + title: MultiLanguageText; + type: string; + sort_order: number; + is_published: boolean; + }[]; + }[]; + approval_history: { + id: number; + action: string; + comment: string | null; + created_at: Date; + submitter: { + id: number; + username: string; + email: string; + }; + reviewer: { + id: number; + username: string; + email: string; + } | null; + }[]; +} + +export interface GetCourseDetailForAdminResponse { + code: number; + message: string; + data: CourseDetailForAdmin; +} + +export interface ApproveCourseBody { + comment?: string; +} + +export interface ApproveCourseResponse { + code: number; + message: string; +} + +export interface RejectCourseBody { + comment: string; +} + +export interface RejectCourseResponse { + code: number; + message: string; +} diff --git a/Backend/src/types/CoursesInstructor.types.ts b/Backend/src/types/CoursesInstructor.types.ts index f769a716..e1fe4e9c 100644 --- a/Backend/src/types/CoursesInstructor.types.ts +++ b/Backend/src/types/CoursesInstructor.types.ts @@ -140,3 +140,32 @@ export interface sendCourseForReview { token: string; course_id: number; } + +export interface CourseApprovalData { + id: number; + course_id: number; + submitted_by: number; + reviewed_by: number | null; + action: 'SUBMITTED' | 'APPROVED' | 'REJECTED' | 'REVISION_REQUESTED'; + previous_status: string | null; + new_status: string | null; + comment: string | null; + created_at: Date; + submitter: { + id: number; + username: string; + email: string; + }; + reviewer: { + id: number; + username: string; + email: string; + } | null; +} + +export interface GetCourseApprovalsResponse { + code: number; + message: string; + data: CourseApprovalData[]; + total: number; +}