Add moderation APIs and admin UI

Add moderator/admin backend APIs and client features for managing novels and chapters. New endpoints include mod chapter routes (paginated list, single GET, PUT, DELETE, and bulk optimize), mod novel routes (create, GET by id, update, delete), genre CRUD, user bookmarks, novel comments, and rating endpoints. Update EPUB import to use a shared slugify util. Enhance moderator UI: chapter manager gains pagination, bulk optimization preview/apply, edit/delete dialogs; novel client adds genre management and edit/delete flows. Also update Prisma schema, add a DB wipe script, remove unused lib/data.ts, and adjust related types/utils and bookmark context.
This commit is contained in:
2026-03-06 17:30:56 +07:00
parent ce805adb08
commit 75ed8e233b
31 changed files with 1853 additions and 687 deletions
+46
View File
@@ -0,0 +1,46 @@
import { NextResponse } from "next/server"
import { getServerSession } from "next-auth/next"
import { authOptions } from "@/lib/auth"
import connectToMongoDB from "@/lib/mongoose"
import { Chapter } from "@/lib/models/chapter"
import { prisma } from "@/lib/prisma"
export async function GET(
req: Request,
context: { params: Promise<{ id: string }> }
) {
const { id } = await context.params
const session = await getServerSession(authOptions)
if (!session || (session.user.role !== "MOD" && session.user.role !== "ADMIN")) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
try {
await connectToMongoDB()
// console.log("Fetching chapter with ID:", id)
const chapter = await Chapter.findById(id)
if (!chapter) {
// console.log("Chapter not found in DB")
return NextResponse.json({ error: "Chapter not found" }, { status: 404 })
}
// Verify the moderator owns the related novel
const novel = await prisma.novel.findFirst({
where: {
id: chapter.novelId,
uploaderId: session.user.id
}
})
if (!novel) {
return NextResponse.json({ error: "Unauthorized access to this chapter" }, { status: 403 })
}
return NextResponse.json(chapter)
} catch (error) {
console.error("GET Chapter error:", error)
return NextResponse.json({ error: "Failed to fetch chapter details" }, { status: 500 })
}
}
+51
View File
@@ -0,0 +1,51 @@
import { NextResponse } from "next/server"
import { getServerSession } from "next-auth/next"
import { authOptions } from "@/lib/auth"
import connectToMongoDB from "@/lib/mongoose"
import { Chapter } from "@/lib/models/chapter"
export async function PUT(req: Request) {
const session = await getServerSession(authOptions)
if (!session || (session.user.role !== "MOD" && session.user.role !== "ADMIN")) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
try {
const body = await req.json()
const { novelId, updates } = body
if (!novelId || !updates || !Array.isArray(updates)) {
return NextResponse.json({ error: "Tham số không hợp lệ" }, { status: 400 })
}
await connectToMongoDB()
// Prepare bulk operations for mongoose
const bulkOps = updates.map((update: any) => ({
updateOne: {
filter: { _id: update.id, novelId: novelId },
update: {
$set: {
number: update.number,
title: update.title
}
}
}
}))
if (bulkOps.length === 0) {
return NextResponse.json({ message: "Không có thay đổi nào" }, { status: 200 })
}
const result = await Chapter.bulkWrite(bulkOps)
return NextResponse.json({
message: "Cập nhật thành công",
modifiedCount: result.modifiedCount
}, { status: 200 })
} catch (error: any) {
console.error("Bulk optimize error:", error)
return NextResponse.json({ error: "Lỗi cập nhật hàng loạt", details: error.message }, { status: 500 })
}
}
+103 -2
View File
@@ -8,6 +8,8 @@ import { prisma } from "@/lib/prisma"
export async function GET(req: Request) {
const { searchParams } = new URL(req.url)
const novelId = searchParams.get("novelId")
const page = parseInt(searchParams.get("page") || "1")
const limit = parseInt(searchParams.get("limit") || "20")
if (!novelId) {
return NextResponse.json({ error: "novelId is required" }, { status: 400 })
@@ -15,8 +17,23 @@ export async function GET(req: Request) {
try {
await connectToMongoDB()
const chapters = await Chapter.find({ novelId }).sort({ number: 1 }).select("-content")
return NextResponse.json(chapters)
const skip = (page - 1) * limit
const [chapters, totalChapters] = await Promise.all([
Chapter.find({ novelId })
.sort({ number: 1 })
.skip(skip)
.limit(limit)
.select("-content"),
Chapter.countDocuments({ novelId })
])
return NextResponse.json({
chapters,
totalChapters,
totalPages: Math.ceil(totalChapters / limit),
currentPage: page
})
} catch (error) {
console.error("GET Chapter Error:", error)
return NextResponse.json({ error: "Failed to fetch chapters" }, { status: 500 })
@@ -70,3 +87,87 @@ export async function POST(req: Request) {
return NextResponse.json({ error: "Failed to create chapter" }, { status: 500 })
}
}
export async function PUT(req: Request) {
const session = await getServerSession(authOptions)
if (!session || (session.user.role !== "MOD" && session.user.role !== "ADMIN")) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
try {
const data = await req.json()
const { id, novelId, number, title, content } = data
// Xác minh truyện thuộc về Mod này
const novel = await prisma.novel.findFirst({
where: { id: novelId, uploaderId: session.user.id },
})
if (!novel) {
return NextResponse.json({ error: "Truyện không tồn tại hoặc không đủ quyền" }, { status: 403 })
}
await connectToMongoDB()
const updatedChapter = await Chapter.findOneAndUpdate(
{ _id: id, novelId },
{ number, title, content },
{ new: true }
)
if (!updatedChapter) {
return NextResponse.json({ error: "Không tìm thấy chương" }, { status: 404 })
}
return NextResponse.json(updatedChapter)
} catch (error) {
console.error("PUT Chapter Error:", error)
return NextResponse.json({ error: "Failed to update chapter" }, { status: 500 })
}
}
export async function DELETE(req: Request) {
const session = await getServerSession(authOptions)
if (!session || (session.user.role !== "MOD" && session.user.role !== "ADMIN")) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
try {
const url = new URL(req.url)
const id = url.searchParams.get("id")
const novelId = url.searchParams.get("novelId")
if (!id || !novelId) {
return NextResponse.json({ error: "Thiếu ID chương hoặc ID truyện" }, { status: 400 })
}
// Xác minh truyện thuộc về Mod này
const novel = await prisma.novel.findFirst({
where: { id: novelId, uploaderId: session.user.id },
})
if (!novel) {
return NextResponse.json({ error: "Truyện không tồn tại hoặc không đủ quyền" }, { status: 403 })
}
await connectToMongoDB()
const deletedChapter = await Chapter.findOneAndDelete({ _id: id, novelId })
if (!deletedChapter) {
return NextResponse.json({ error: "Không tìm thấy chương" }, { status: 404 })
}
// Cập nhật lại số lượng chương trong Postgres
const totalChapters = await Chapter.countDocuments({ novelId })
await prisma.novel.update({
where: { id: novelId },
data: { totalChapters },
})
return NextResponse.json({ message: "Đã xóa chương thành công" })
} catch (error) {
console.error("DELETE Chapter Error:", error)
return NextResponse.json({ error: "Failed to delete chapter" }, { status: 500 })
}
}
+2 -6
View File
@@ -8,6 +8,7 @@ import path from "path"
import os from "os"
import { promises as fs } from "fs"
import { convert } from "html-to-text"
import { slugify } from "@/lib/utils"
export async function POST(req: Request) {
const session = await getServerSession(authOptions)
@@ -71,12 +72,7 @@ export async function POST(req: Request) {
let novelDesc = metadata.description || "Chưa có giới thiệu"
// Generate base slug
const baseSlug = novelTitle
.toLowerCase()
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)+/g, "")
const baseSlug = slugify(novelTitle)
let slug = baseSlug
let slugCounter = 1
+71
View File
@@ -0,0 +1,71 @@
import { NextResponse } from "next/server"
import { getServerSession } from "next-auth/next"
import { authOptions } from "@/lib/auth"
import { prisma } from "@/lib/prisma"
import { slugify } from "@/lib/utils"
// Get all genres
export async function GET() {
try {
const genres = await prisma.genre.findMany({
orderBy: { name: "asc" }
})
return NextResponse.json(genres)
} catch (error) {
return NextResponse.json({ error: "Failed to fetch genres" }, { status: 500 })
}
}
// Admins/Mods can add new genres
export async function POST(req: Request) {
const session = await getServerSession(authOptions)
if (!session || (session.user.role !== "MOD" && session.user.role !== "ADMIN")) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
try {
const data = await req.json()
const { name, description } = data
if (!name) {
return NextResponse.json({ error: "Genre name is required" }, { status: 400 })
}
const slug = slugify(name)
const newGenre = await prisma.genre.create({
data: { name, slug, description }
})
return NextResponse.json(newGenre, { status: 201 })
} catch (error: any) {
if (error.code === 'P2002') {
return NextResponse.json({ error: "Thể loại này đã tồn tại" }, { status: 400 })
}
return NextResponse.json({ error: "Failed to create genre" }, { status: 500 })
}
}
export async function DELETE(req: Request) {
const session = await getServerSession(authOptions)
if (!session || (session.user.role !== "MOD" && session.user.role !== "ADMIN")) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
try {
const url = new URL(req.url)
const id = url.searchParams.get("id")
if (!id) {
return NextResponse.json({ error: "Thiếu ID thể loại" }, { status: 400 })
}
await prisma.genre.delete({
where: { id }
})
return NextResponse.json({ message: "Đã xóa thể loại thành công" })
} catch (error) {
return NextResponse.json({ error: "Lỗi khi xóa thể loại" }, { status: 500 })
}
}
+39
View File
@@ -0,0 +1,39 @@
import { NextResponse } from "next/server"
import { getServerSession } from "next-auth/next"
import { authOptions } from "@/lib/auth"
import { prisma } from "@/lib/prisma"
export async function GET(
req: Request,
context: { params: Promise<{ id: string }> }
) {
const { id } = await context.params
const session = await getServerSession(authOptions)
if (!session || (session.user.role !== "MOD" && session.user.role !== "ADMIN")) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
try {
const novel = await prisma.novel.findUnique({
where: {
id,
uploaderId: session.user.id,
},
include: {
genres: {
include: {
genre: true
}
}
}
})
if (!novel) {
return NextResponse.json({ error: "Novel not found" }, { status: 404 })
}
return NextResponse.json(novel)
} catch (error) {
return NextResponse.json({ error: "Failed to fetch novel details" }, { status: 500 })
}
}
+70 -9
View File
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server"
import { getServerSession } from "next-auth/next"
import { authOptions } from "@/lib/auth"
import { prisma } from "@/lib/prisma"
import { slugify } from "@/lib/utils"
export async function GET() {
const session = await getServerSession(authOptions)
@@ -28,21 +29,22 @@ export async function POST(req: Request) {
try {
const data = await req.json()
const { title, authorName, description, genreIds = [] } = data
// Tạo slug từ title
const slug = data.title
.toLowerCase()
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)+/g, "")
const slug = slugify(title)
const newNovel = await prisma.novel.create({
data: {
title: data.title,
title,
slug: slug,
authorName: data.authorName,
description: data.description,
authorName,
description,
uploaderId: session.user.id,
genres: {
create: genreIds.map((id: string) => ({
genre: { connect: { id } }
}))
}
},
})
return NextResponse.json(newNovel, { status: 201 })
@@ -50,3 +52,62 @@ export async function POST(req: Request) {
return NextResponse.json({ error: "Failed to create novel" }, { status: 500 })
}
}
export async function PUT(req: Request) {
const session = await getServerSession(authOptions)
if (!session || (session.user.role !== "MOD" && session.user.role !== "ADMIN")) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
try {
const data = await req.json()
const { id, title, authorName, description, status, genreIds } = data
// Update basic info and recreate genre relations
const updatedNovel = await prisma.novel.update({
where: { id: id, uploaderId: session.user.id }, // Make sure they own it
data: {
title,
authorName,
description,
status,
// Replace all existing genres if genreIds is provided
...(genreIds !== undefined && {
genres: {
deleteMany: {},
create: genreIds.map((gId: string) => ({
genre: { connect: { id: gId } }
}))
}
})
},
})
return NextResponse.json(updatedNovel)
} catch (error) {
return NextResponse.json({ error: "Failed to update novel" }, { status: 500 })
}
}
export async function DELETE(req: Request) {
const session = await getServerSession(authOptions)
if (!session || (session.user.role !== "MOD" && session.user.role !== "ADMIN")) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
try {
const url = new URL(req.url)
const id = url.searchParams.get("id")
if (!id) return NextResponse.json({ error: "Thiếu ID truyện" }, { status: 400 })
// Xóa truyện. (Chapters trong MongoDB nên được xóa bằng một cron job hoặc API khác để tránh block UI quá lâu,
// ở đây chúng ta chỉ xóa record của Postgres để ẩn truyện).
await prisma.novel.delete({
where: { id: id, uploaderId: session.user.id },
})
return NextResponse.json({ message: "Đã xóa truyện thành công" })
} catch (error) {
return NextResponse.json({ error: "Failed to delete novel" }, { status: 500 })
}
}
+47
View File
@@ -0,0 +1,47 @@
import { NextResponse } from "next/server"
import { getServerSession } from "next-auth/next"
import { authOptions } from "@/lib/auth"
import { prisma } from "@/lib/prisma"
export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
try {
const session = await getServerSession(authOptions)
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
const { id: novelId } = await params
const body = await req.json()
const { content, chapterId } = body
if (!content || typeof content !== "string") {
return NextResponse.json({ error: "Content is required" }, { status: 400 })
}
const newComment = await prisma.comment.create({
data: {
content: content.trim(),
userId: session.user.id,
novelId,
chapterId: chapterId || null
},
include: {
user: true
}
})
return NextResponse.json({
id: newComment.id,
userId: newComment.user.id,
username: newComment.user.name || "User",
avatarColor: newComment.user.image || "bg-primary",
novelId: newComment.novelId,
chapterId: newComment.chapterId,
content: newComment.content,
createdAt: newComment.createdAt.toISOString().split("T")[0]
})
} catch (error) {
console.error("POST Comment Error", error)
return NextResponse.json({ error: "Internal Server Error" }, { status: 500 })
}
}
+44
View File
@@ -0,0 +1,44 @@
import { NextResponse } from "next/server"
import { prisma } from "@/lib/prisma"
export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
try {
const { id } = await params
const body = await req.json()
const { score } = body
if (typeof score !== 'number' || score < 1 || score > 5) {
return NextResponse.json({ error: "Invalid score" }, { status: 400 })
}
// Fetch current rating
const novel = await prisma.novel.findUnique({
where: { id },
select: { rating: true, ratingCount: true }
})
if (!novel) {
return NextResponse.json({ error: "Novel not found" }, { status: 404 })
}
const { rating, ratingCount } = novel
const newRatingCount = ratingCount + 1
const newRating = ((rating * ratingCount) + score) / newRatingCount
const updatedNovel = await prisma.novel.update({
where: { id },
data: {
rating: newRating,
ratingCount: newRatingCount
}
})
return NextResponse.json({
rating: updatedNovel.rating,
ratingCount: updatedNovel.ratingCount
})
} catch (error) {
console.error("Rating Error", error)
return NextResponse.json({ error: "Internal Server Error" }, { status: 500 })
}
}
+108
View File
@@ -0,0 +1,108 @@
import { NextResponse } from "next/server"
import { getServerSession } from "next-auth/next"
import { authOptions } from "@/lib/auth"
import { prisma } from "@/lib/prisma"
// Lấy danh sách bookmark
export async function GET(req: Request) {
try {
const session = await getServerSession(authOptions)
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
const bookmarks = await prisma.bookmark.findMany({
where: { userId: session.user.id },
include: { novel: true },
orderBy: { createdAt: "desc" }
})
return NextResponse.json(bookmarks)
} catch (error) {
console.error("GET Bookmarks Error", error)
return NextResponse.json({ error: "Internal Server Error" }, { status: 500 })
}
}
// Thêm, cập nhật hoặc xóa bookmark
export async function POST(req: Request) {
try {
const session = await getServerSession(authOptions)
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
const body = await req.json()
const { action, novelId, lastChapterId, lastChapterNumber } = body
if (!novelId || !action) {
return NextResponse.json({ error: "Bad Request" }, { status: 400 })
}
if (action === "toggle") {
const existing = await prisma.bookmark.findUnique({
where: {
userId_novelId: {
userId: session.user.id,
novelId,
}
}
})
if (existing) {
// Xoá
await prisma.$transaction([
prisma.bookmark.delete({ where: { id: existing.id } }),
prisma.novel.update({ where: { id: novelId }, data: { bookmarkCount: { decrement: 1 } } })
])
return NextResponse.json({ status: "removed" })
} else {
// Thêm mới
const newBookmark = await prisma.$transaction(async (tx) => {
const b = await tx.bookmark.create({
data: {
userId: session.user.id,
novelId,
lastChapterId,
lastChapterNumber
}
})
await tx.novel.update({ where: { id: novelId }, data: { bookmarkCount: { increment: 1 } } })
return b
})
return NextResponse.json({ status: "added", bookmark: newBookmark })
}
} else if (action === "updateProgress") {
// Cập nhật tiến độ lưu trang
if (!lastChapterId || !lastChapterNumber) {
return NextResponse.json({ error: "Missing chapter info" }, { status: 400 })
}
const bookmark = await prisma.bookmark.upsert({
where: {
userId_novelId: {
userId: session.user.id,
novelId,
}
},
update: {
lastChapterId,
lastChapterNumber
},
create: {
userId: session.user.id,
novelId,
lastChapterId,
lastChapterNumber
}
})
return NextResponse.json({ status: "updated", bookmark })
}
return NextResponse.json({ error: "Invalid action" }, { status: 400 })
} catch (error) {
console.error("POST Bookmarks Error", error)
return NextResponse.json({ error: "Internal Server Error" }, { status: 500 })
}
}