make admin approve api

This commit is contained in:
JakkrapartXD 2026-01-23 13:16:41 +07:00
parent 6acb536aef
commit 5d6cab229f
7 changed files with 616 additions and 1 deletions

View file

@ -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<ListPendingCoursesResponse> {
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<GetCourseDetailForAdminResponse> {
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<ApproveCourseResponse> {
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<RejectCourseResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) {
throw new ValidationError('No token provided');
}
return await AdminCourseApprovalService.rejectCourse(token, courseId, body.comment);
}
}

View file

@ -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<GetCourseApprovalsResponse> {
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

View file

@ -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<ListPendingCoursesResponse> {
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<GetCourseDetailForAdminResponse> {
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<ApproveCourseResponse> {
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<RejectCourseResponse> {
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;
}
}
}

View file

@ -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<addinstructorCourseResponse> {

View file

@ -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;
}

View file

@ -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;
}