feat: add missing fields management for novels
- Implemented API routes for fetching and updating novels with missing fields. - Created a client-side interface for moderators to manage novels with missing information. - Added bulk update functionality for missing fields including author, cover, description, and genres. - Integrated genre management with the ability to create new genres on the fly. - Enhanced the home page with a carousel for displaying popular novels.
This commit is contained in:
+429
-39
@@ -9,11 +9,13 @@ import os from "os"
|
||||
import { promises as fs } from "fs"
|
||||
import { convert } from "html-to-text"
|
||||
import { slugify } from "@/lib/utils"
|
||||
import { uploadBufferToR2 } from "@/lib/r2"
|
||||
import { deleteR2ObjectByUrl, uploadBufferToR2 } from "@/lib/r2"
|
||||
|
||||
type SplitMode = "toc" | "regex"
|
||||
type SeriesMode = "none" | "existing" | "new"
|
||||
|
||||
type UserRole = "USER" | "MOD" | "ADMIN"
|
||||
|
||||
interface EpubSection {
|
||||
sourceTitle: string
|
||||
content: string
|
||||
@@ -46,6 +48,79 @@ const CHAPTER_REGEX_PRESETS: Record<string, string> = {
|
||||
const NOISE_TITLE_REGEX = /^(?:mục lục|table of contents|toc|cover|bìa|copyright)$/i
|
||||
|
||||
const SIMPLE_CHAPTER_TITLE_REGEX = /^(?:ch(?:ương|apter)?|ch\.)\s*\d+(?:\.\d+)?\s*:?$/i
|
||||
const GENERIC_SECTION_TITLE_REGEX = /^(?:m[uụ]c|section|sec\.?|part|ph[aầ]n)\s*[0-9ivxlcdm]+$/i
|
||||
const WEAK_CHAPTER_TOC_TITLE_REGEX = /^(?:ch(?:ương|apter)?|ch\.)\s*\d+(?:\.\d+)?\s*[:\-–—]\s*(?:m[uụ]c|section|sec\.?|part)\s*[0-9ivxlcdm]+$/i
|
||||
const CHAPTER_HEADING_LINE_REGEX = /^(?:\[?\s*)?(?:ch(?:ương|apter)?|ch\.)\s*([0-9]+)(?:\.[0-9]+)?(?:\s*\]?)*(?:(?:\s*[:\-–—\.]\s*|\s+)(.+))?$/i
|
||||
const GENERIC_GENRE_TOKENS = new Set([
|
||||
"book",
|
||||
"books",
|
||||
"ebook",
|
||||
"fiction",
|
||||
"literature",
|
||||
"novel",
|
||||
"story",
|
||||
"truyen",
|
||||
"tiểu thuyết",
|
||||
])
|
||||
|
||||
function isWeakTOCTitle(title: string): boolean {
|
||||
const normalized = title.trim()
|
||||
return (
|
||||
SIMPLE_CHAPTER_TITLE_REGEX.test(normalized) ||
|
||||
GENERIC_SECTION_TITLE_REGEX.test(normalized) ||
|
||||
WEAK_CHAPTER_TOC_TITLE_REGEX.test(normalized)
|
||||
)
|
||||
}
|
||||
|
||||
function pickChapterHeadingFromContent(content: string): {
|
||||
heading: string
|
||||
chapterNumber: number | null
|
||||
consumedLineIndexes: number[]
|
||||
} | null {
|
||||
const lines = content.split(/\r?\n/)
|
||||
const nonEmptyIndexes = lines
|
||||
.map((line, index) => ({ line: line.trim(), index }))
|
||||
.filter((item) => item.line.length > 0)
|
||||
|
||||
for (let i = 0; i < nonEmptyIndexes.length && i < 12; i++) {
|
||||
const current = nonEmptyIndexes[i]
|
||||
const headingLine = current.line.replace(/\s+/g, " ").trim()
|
||||
|
||||
if (!headingLine || headingLine.length > 180) continue
|
||||
if (NOISE_TITLE_REGEX.test(headingLine) || isVolumeHeading(headingLine)) continue
|
||||
|
||||
const matched = headingLine.match(CHAPTER_HEADING_LINE_REGEX)
|
||||
if (!matched) continue
|
||||
|
||||
const parsed = Number(matched[1])
|
||||
const chapterNumber = Number.isInteger(parsed) && parsed > 0 && parsed <= 50000 ? parsed : null
|
||||
const consumedLineIndexes = [current.index]
|
||||
|
||||
let heading = headingLine
|
||||
const trailingTitle = (matched[2] || "").trim()
|
||||
if (!trailingTitle) {
|
||||
const next = nonEmptyIndexes[i + 1]
|
||||
if (next) {
|
||||
const subtitle = next.line.replace(/\s+/g, " ").trim()
|
||||
if (
|
||||
subtitle.length > 0 &&
|
||||
subtitle.length <= 120 &&
|
||||
!NOISE_TITLE_REGEX.test(subtitle) &&
|
||||
!isVolumeHeading(subtitle) &&
|
||||
!CHAPTER_HEADING_LINE_REGEX.test(subtitle)
|
||||
) {
|
||||
heading = `${headingLine.replace(/[:\-–—\.\s]+$/g, "")}: ${subtitle}`
|
||||
consumedLineIndexes.push(next.index)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { heading, chapterNumber, consumedLineIndexes }
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function normalizeMetaText(value: any, fallback: string) {
|
||||
if (typeof value === "string" && value.trim().length > 0) return value.trim()
|
||||
if (Array.isArray(value)) {
|
||||
@@ -55,6 +130,146 @@ function normalizeMetaText(value: any, fallback: string) {
|
||||
return fallback
|
||||
}
|
||||
|
||||
function canReplaceNovelByRole(userRole: UserRole, userId: string, novel: { uploaderId: string | null }): boolean {
|
||||
if (userRole === "ADMIN") return true
|
||||
return novel.uploaderId === userId || novel.uploaderId === null
|
||||
}
|
||||
|
||||
function collectTextValues(input: any, bucket: string[]) {
|
||||
if (input === null || input === undefined) return
|
||||
|
||||
if (typeof input === "string") {
|
||||
bucket.push(input)
|
||||
return
|
||||
}
|
||||
|
||||
if (typeof input === "number" || typeof input === "boolean") {
|
||||
bucket.push(String(input))
|
||||
return
|
||||
}
|
||||
|
||||
if (Array.isArray(input)) {
|
||||
input.forEach((item) => collectTextValues(item, bucket))
|
||||
return
|
||||
}
|
||||
|
||||
if (typeof input === "object") {
|
||||
Object.values(input).forEach((value) => collectTextValues(value, bucket))
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeGenreCandidate(name: string): string {
|
||||
return name
|
||||
.replace(/\s+/g, " ")
|
||||
.replace(/^[\s\-–—:;,.\/|]+|[\s\-–—:;,.\/|]+$/g, "")
|
||||
.trim()
|
||||
}
|
||||
|
||||
function extractGenreCandidatesFromMetadata(metadata: any): string[] {
|
||||
if (!metadata || typeof metadata !== "object") return []
|
||||
|
||||
const rawValues: string[] = []
|
||||
const keys = Object.keys(metadata)
|
||||
const candidateKeys = keys.filter((key) => /subject|genre|tag|category/i.test(key))
|
||||
|
||||
for (const key of candidateKeys) {
|
||||
collectTextValues((metadata as Record<string, any>)[key], rawValues)
|
||||
}
|
||||
|
||||
const uniqueNames: string[] = []
|
||||
const seen = new Set<string>()
|
||||
|
||||
for (const raw of rawValues) {
|
||||
const chunks = raw
|
||||
.split(/[,;|/\n]+/)
|
||||
.map((chunk) => normalizeGenreCandidate(chunk))
|
||||
.filter(Boolean)
|
||||
|
||||
for (const name of chunks) {
|
||||
const normalized = name.toLowerCase()
|
||||
if (name.length < 2 || name.length > 80) continue
|
||||
if (GENERIC_GENRE_TOKENS.has(normalized)) continue
|
||||
if (seen.has(normalized)) continue
|
||||
|
||||
seen.add(normalized)
|
||||
uniqueNames.push(name)
|
||||
|
||||
if (uniqueNames.length >= 12) {
|
||||
return uniqueNames
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return uniqueNames
|
||||
}
|
||||
|
||||
async function resolveGenreIdsFromNames(genreNames: string[], createIfMissing: boolean): Promise<string[]> {
|
||||
const ids: string[] = []
|
||||
|
||||
for (const genreName of genreNames) {
|
||||
const existing = await prisma.genre.findFirst({
|
||||
where: { name: { equals: genreName, mode: "insensitive" } },
|
||||
select: { id: true },
|
||||
})
|
||||
|
||||
if (existing) {
|
||||
ids.push(existing.id)
|
||||
continue
|
||||
}
|
||||
|
||||
if (!createIfMissing) continue
|
||||
|
||||
const baseSlug = slugify(genreName) || `genre-${Date.now()}`
|
||||
let slug = baseSlug
|
||||
let counter = 1
|
||||
|
||||
while (await prisma.genre.findUnique({ where: { slug } })) {
|
||||
slug = `${baseSlug}-${counter}`
|
||||
counter += 1
|
||||
}
|
||||
|
||||
try {
|
||||
const created = await prisma.genre.create({
|
||||
data: {
|
||||
name: genreName,
|
||||
slug,
|
||||
},
|
||||
select: { id: true },
|
||||
})
|
||||
ids.push(created.id)
|
||||
} catch (error: any) {
|
||||
if (error?.code === "P2002") {
|
||||
const fallback = await prisma.genre.findFirst({
|
||||
where: { name: { equals: genreName, mode: "insensitive" } },
|
||||
select: { id: true },
|
||||
})
|
||||
if (fallback) {
|
||||
ids.push(fallback.id)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(new Set(ids))
|
||||
}
|
||||
|
||||
async function findNovelByTitleInsensitive(title: string) {
|
||||
return prisma.novel.findFirst({
|
||||
where: { title: { equals: title, mode: "insensitive" } },
|
||||
orderBy: { updatedAt: "desc" },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
slug: true,
|
||||
coverUrl: true,
|
||||
uploaderId: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function extractVolumeNumber(title: string): number | null {
|
||||
const matched = title.match(/(?:quy[eê]n|vol(?:ume)?|t[aạ]p|book|arc|hồi)\s*([0-9]+)/i)
|
||||
if (!matched) return null
|
||||
@@ -78,20 +293,69 @@ function extractStrictChapterNumber(title: string): number | null {
|
||||
return number
|
||||
}
|
||||
|
||||
function enhanceChapterTitleFromContent(title: string, content: string): { title: string; content: string } {
|
||||
function enhanceChapterTitleFromContent(title: string, content: string): { title: string; content: string; detectedChapterNumber: number | null } {
|
||||
const lines = content.split(/\r?\n/)
|
||||
const firstNonEmptyLineIndex = lines.findIndex((line) => line.trim().length > 0)
|
||||
if (firstNonEmptyLineIndex < 0) return { title, content }
|
||||
const baseTitle = title.trim()
|
||||
const baseDetectedChapterNumber = extractStrictChapterNumber(baseTitle)
|
||||
if (firstNonEmptyLineIndex < 0) {
|
||||
return {
|
||||
title,
|
||||
content,
|
||||
detectedChapterNumber: baseDetectedChapterNumber,
|
||||
}
|
||||
}
|
||||
|
||||
const firstLineRaw = lines[firstNonEmptyLineIndex]
|
||||
const firstLine = firstLineRaw.trim()
|
||||
if (!firstLine || firstLine.length > 140) return { title, content }
|
||||
if (!firstLine) {
|
||||
return {
|
||||
title,
|
||||
content,
|
||||
detectedChapterNumber: baseDetectedChapterNumber,
|
||||
}
|
||||
}
|
||||
|
||||
const detectedHeading = pickChapterHeadingFromContent(content)
|
||||
if (detectedHeading) {
|
||||
const shouldUseDetectedHeading =
|
||||
isWeakTOCTitle(baseTitle) ||
|
||||
baseDetectedChapterNumber === null ||
|
||||
detectedHeading.chapterNumber === baseDetectedChapterNumber
|
||||
|
||||
if (shouldUseDetectedHeading) {
|
||||
const nextLines = [...lines]
|
||||
detectedHeading.consumedLineIndexes
|
||||
.sort((a, b) => b - a)
|
||||
.forEach((index) => {
|
||||
nextLines.splice(index, 1)
|
||||
})
|
||||
|
||||
const nextContent = nextLines.join("\n").trim()
|
||||
return {
|
||||
title: detectedHeading.heading,
|
||||
content: nextContent.length > 0 ? nextContent : content,
|
||||
detectedChapterNumber: detectedHeading.chapterNumber,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (firstLine.length > 140) {
|
||||
return {
|
||||
title,
|
||||
content,
|
||||
detectedChapterNumber: baseDetectedChapterNumber,
|
||||
}
|
||||
}
|
||||
|
||||
const baseTitle = title.trim()
|
||||
const isSimpleBaseTitle = SIMPLE_CHAPTER_TITLE_REGEX.test(baseTitle)
|
||||
|
||||
if (!isSimpleBaseTitle) {
|
||||
return { title, content }
|
||||
return {
|
||||
title,
|
||||
content,
|
||||
detectedChapterNumber: baseDetectedChapterNumber,
|
||||
}
|
||||
}
|
||||
|
||||
let nextTitle = baseTitle
|
||||
@@ -103,7 +367,11 @@ function enhanceChapterTitleFromContent(title: string, content: string): { title
|
||||
// Case 2: TOC title is only "Chương N", subtitle is on next line.
|
||||
nextTitle = `${baseTitle.replace(/[:\s]+$/g, "")}: ${firstLine}`
|
||||
} else {
|
||||
return { title, content }
|
||||
return {
|
||||
title,
|
||||
content,
|
||||
detectedChapterNumber: baseDetectedChapterNumber,
|
||||
}
|
||||
}
|
||||
|
||||
const newLines = [...lines]
|
||||
@@ -113,6 +381,7 @@ function enhanceChapterTitleFromContent(title: string, content: string): { title
|
||||
return {
|
||||
title: nextTitle,
|
||||
content: nextContent.length > 0 ? nextContent : content,
|
||||
detectedChapterNumber: extractStrictChapterNumber(nextTitle) ?? baseDetectedChapterNumber,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -330,10 +599,11 @@ function buildChaptersFromTOCSections(sections: EpubSection[]): ParsedChapter[]
|
||||
}
|
||||
}
|
||||
|
||||
const enhanced = enhanceChapterTitleFromContent(cleanTitle, cleanContent)
|
||||
let volumeChapterNumber: number | null = null
|
||||
const detectedChapterNumber = extractStrictChapterNumber(cleanTitle)
|
||||
const detectedChapterNumber = enhanced.detectedChapterNumber
|
||||
if (currentVolumeNumber !== null) {
|
||||
const explicitChapter = extractChapterNumber(cleanTitle)
|
||||
const explicitChapter = extractChapterNumber(enhanced.title)
|
||||
if (explicitChapter !== null) {
|
||||
currentVolumeChapterCounter = explicitChapter
|
||||
} else {
|
||||
@@ -342,8 +612,6 @@ function buildChaptersFromTOCSections(sections: EpubSection[]): ParsedChapter[]
|
||||
volumeChapterNumber = currentVolumeChapterCounter
|
||||
}
|
||||
|
||||
const enhanced = enhanceChapterTitleFromContent(cleanTitle, cleanContent)
|
||||
|
||||
chapters.push({
|
||||
title: enhanced.title,
|
||||
content: enhanced.content,
|
||||
@@ -414,13 +682,30 @@ function withMissingChapterPlaceholders(chapters: ParsedChapter[]): {
|
||||
|
||||
for (const chapter of chapters) {
|
||||
const detected = chapter.detectedChapterNumber
|
||||
const canUseDetected =
|
||||
typeof detected === "number" &&
|
||||
detected > currentNumber &&
|
||||
detected - currentNumber <= MAX_ALLOWED_GAP
|
||||
const detectedNumber = typeof detected === "number" ? detected : null
|
||||
let canUseDetected =
|
||||
detectedNumber !== null &&
|
||||
detectedNumber > currentNumber &&
|
||||
detectedNumber - currentNumber <= MAX_ALLOWED_GAP
|
||||
|
||||
if (canUseDetected) {
|
||||
for (let missing = currentNumber + 1; missing < detected; missing++) {
|
||||
// Recover from noisy leading TOC entries such as "Mục 1" that shift numbering.
|
||||
if (!canUseDetected && detectedNumber !== null && detectedNumber > 0 && detectedNumber <= currentNumber) {
|
||||
while (
|
||||
normalized.length > 0 &&
|
||||
detectedNumber <= currentNumber &&
|
||||
normalized[normalized.length - 1].detectedChapterNumber === null &&
|
||||
!normalized[normalized.length - 1].isPlaceholder &&
|
||||
isWeakTOCTitle(normalized[normalized.length - 1].title)
|
||||
) {
|
||||
normalized.pop()
|
||||
currentNumber = Math.max(0, currentNumber - 1)
|
||||
}
|
||||
|
||||
canUseDetected = detectedNumber > currentNumber && detectedNumber - currentNumber <= MAX_ALLOWED_GAP
|
||||
}
|
||||
|
||||
if (canUseDetected && detectedNumber !== null) {
|
||||
for (let missing = currentNumber + 1; missing < detectedNumber; missing++) {
|
||||
insertedCount += 1
|
||||
normalized.push({
|
||||
title: `Chương ${missing} (Thiếu)`,
|
||||
@@ -435,7 +720,7 @@ function withMissingChapterPlaceholders(chapters: ParsedChapter[]): {
|
||||
}
|
||||
|
||||
detectedNumberAssignments += 1
|
||||
currentNumber = detected
|
||||
currentNumber = detectedNumber
|
||||
normalized.push({
|
||||
...chapter,
|
||||
finalNumber: currentNumber,
|
||||
@@ -608,6 +893,7 @@ export async function POST(req: Request) {
|
||||
const seriesMode = normalizeSeriesMode(formData.get("seriesMode"))
|
||||
const seriesIdInput = readFormText(formData, "seriesId")
|
||||
const seriesNameInput = readFormText(formData, "seriesName")
|
||||
const replaceExisting = String(formData.get("replaceExisting") || "").toLowerCase() === "true"
|
||||
|
||||
if (!epubFile) {
|
||||
return NextResponse.json({ error: "Thiếu file EPUB" }, { status: 400 })
|
||||
@@ -687,10 +973,12 @@ export async function POST(req: Request) {
|
||||
const metadataAuthor = normalizeMetaText(metadata?.creator, "Khuyết danh")
|
||||
const metadataDescRaw = normalizeMetaText(metadata?.description, "Chưa có giới thiệu")
|
||||
const metadataDesc = convert(metadataDescRaw, { wordwrap: false })
|
||||
const detectedGenreNames = extractGenreCandidatesFromMetadata(metadata)
|
||||
|
||||
const novelTitle = normalizeMetaText(readFormText(formData, "title"), metadataTitle)
|
||||
const novelAuthor = normalizeMetaText(readFormText(formData, "authorName"), metadataAuthor)
|
||||
const novelDesc = normalizeMetaText(readFormText(formData, "description"), metadataDesc)
|
||||
const importDefaultStatus = "Hoàn thành"
|
||||
|
||||
const hasDetectedVolumes = chapters.some((ch: any) => ch.volumeNumber !== null)
|
||||
|
||||
@@ -706,6 +994,7 @@ export async function POST(req: Request) {
|
||||
title: novelTitle,
|
||||
authorName: novelAuthor,
|
||||
description: novelDesc,
|
||||
detectedGenres: detectedGenreNames,
|
||||
totalChapters: chapters.length,
|
||||
},
|
||||
chaptersPreview: chapters.slice(0, 20).map((ch: any, i: number) => ({
|
||||
@@ -720,6 +1009,39 @@ export async function POST(req: Request) {
|
||||
})
|
||||
}
|
||||
|
||||
const duplicatedNovel = await findNovelByTitleInsensitive(novelTitle)
|
||||
const canReplaceDuplicated = duplicatedNovel
|
||||
? canReplaceNovelByRole(session.user.role as UserRole, session.user.id, duplicatedNovel)
|
||||
: false
|
||||
|
||||
if (duplicatedNovel && !replaceExisting) {
|
||||
return NextResponse.json({
|
||||
code: "DUPLICATE_TITLE",
|
||||
error: `Truyện \"${duplicatedNovel.title}\" đã tồn tại`,
|
||||
canReplace: canReplaceDuplicated,
|
||||
existingNovel: {
|
||||
id: duplicatedNovel.id,
|
||||
title: duplicatedNovel.title,
|
||||
slug: duplicatedNovel.slug,
|
||||
},
|
||||
}, { status: 409 })
|
||||
}
|
||||
|
||||
if (duplicatedNovel && replaceExisting && !canReplaceDuplicated) {
|
||||
return NextResponse.json({
|
||||
code: "DUPLICATE_TITLE",
|
||||
error: "Bạn không có quyền replace truyện đã tồn tại",
|
||||
canReplace: false,
|
||||
existingNovel: {
|
||||
id: duplicatedNovel.id,
|
||||
title: duplicatedNovel.title,
|
||||
slug: duplicatedNovel.slug,
|
||||
},
|
||||
}, { status: 403 })
|
||||
}
|
||||
|
||||
const resolvedGenreIds = await resolveGenreIdsFromNames(detectedGenreNames, true)
|
||||
|
||||
const selectedSeriesId = await resolveSeriesIdForEpubImport({
|
||||
mode: seriesMode,
|
||||
seriesId: seriesIdInput,
|
||||
@@ -728,55 +1050,123 @@ export async function POST(req: Request) {
|
||||
userId: session.user.id,
|
||||
})
|
||||
|
||||
// Generate base slug
|
||||
const baseSlug = slugify(novelTitle)
|
||||
|
||||
let slug = baseSlug
|
||||
let slugCounter = 1
|
||||
|
||||
// Đảm bảo slug là duy nhất
|
||||
while (await prisma.novel.findUnique({ where: { slug } })) {
|
||||
slug = `${baseSlug}-${slugCounter}`
|
||||
slugCounter++
|
||||
}
|
||||
|
||||
const coverUrl = await saveCoverBufferToR2(cover)
|
||||
|
||||
const newNovel = await prisma.novel.create({
|
||||
data: {
|
||||
let targetNovelId = duplicatedNovel?.id || ""
|
||||
let responseStatus = 201
|
||||
let replaced = false
|
||||
|
||||
if (duplicatedNovel && replaceExisting) {
|
||||
const updatedNovel = await prisma.$transaction(async (tx) => {
|
||||
await tx.novel.update({
|
||||
where: { id: duplicatedNovel.id },
|
||||
data: {
|
||||
title: novelTitle,
|
||||
authorName: novelAuthor,
|
||||
description: novelDesc,
|
||||
status: importDefaultStatus,
|
||||
coverUrl,
|
||||
seriesId: selectedSeriesId,
|
||||
totalChapters: chapters.length,
|
||||
...(session.user.role === "MOD" ? { uploaderId: session.user.id } : {}),
|
||||
},
|
||||
})
|
||||
|
||||
await tx.novelGenre.deleteMany({
|
||||
where: { novelId: duplicatedNovel.id },
|
||||
})
|
||||
|
||||
if (resolvedGenreIds.length > 0) {
|
||||
await tx.novelGenre.createMany({
|
||||
data: resolvedGenreIds.map((genreId) => ({
|
||||
novelId: duplicatedNovel.id,
|
||||
genreId,
|
||||
})),
|
||||
skipDuplicates: true,
|
||||
})
|
||||
}
|
||||
|
||||
return tx.novel.findUnique({ where: { id: duplicatedNovel.id } })
|
||||
})
|
||||
|
||||
if (!updatedNovel) {
|
||||
throw new Error("Không thể replace truyện đã tồn tại")
|
||||
}
|
||||
|
||||
targetNovelId = updatedNovel.id
|
||||
responseStatus = 200
|
||||
replaced = true
|
||||
|
||||
if (duplicatedNovel.coverUrl && duplicatedNovel.coverUrl !== coverUrl) {
|
||||
await deleteR2ObjectByUrl(duplicatedNovel.coverUrl).catch(() => { })
|
||||
}
|
||||
} else {
|
||||
// Generate base slug
|
||||
const baseSlug = slugify(novelTitle)
|
||||
let slug = baseSlug
|
||||
let slugCounter = 1
|
||||
|
||||
// Đảm bảo slug là duy nhất
|
||||
while (await prisma.novel.findUnique({ where: { slug } })) {
|
||||
slug = `${baseSlug}-${slugCounter}`
|
||||
slugCounter++
|
||||
}
|
||||
|
||||
const createData: any = {
|
||||
title: novelTitle,
|
||||
slug: slug,
|
||||
slug,
|
||||
authorName: novelAuthor,
|
||||
description: novelDesc,
|
||||
status: importDefaultStatus,
|
||||
coverUrl,
|
||||
seriesId: selectedSeriesId,
|
||||
uploaderId: session.user.id,
|
||||
totalChapters: chapters.length,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (resolvedGenreIds.length > 0) {
|
||||
createData.genres = {
|
||||
create: resolvedGenreIds.map((genreId) => ({
|
||||
genre: { connect: { id: genreId } },
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
const createdNovel = await prisma.novel.create({ data: createData })
|
||||
targetNovelId = createdNovel.id
|
||||
}
|
||||
|
||||
// Lưu chapters xuống MongoDB
|
||||
await connectToMongoDB()
|
||||
await Chapter.deleteMany({ novelId: targetNovelId })
|
||||
|
||||
const chapterDocs = chapters.map((ch: any, i: number) => ({
|
||||
novelId: newNovel.id,
|
||||
novelId: targetNovelId,
|
||||
number: ch.finalNumber || (i + 1),
|
||||
volumeNumber: ch.volumeNumber ?? null,
|
||||
volumeTitle: ch.volumeTitle ?? null,
|
||||
volumeChapterNumber: ch.volumeChapterNumber ?? null,
|
||||
title: ch.title,
|
||||
content: ch.content,
|
||||
views: 0
|
||||
views: 0,
|
||||
}))
|
||||
|
||||
if (chapterDocs.length > 0) {
|
||||
await Chapter.insertMany(chapterDocs)
|
||||
}
|
||||
|
||||
const novelAfterWrite = await prisma.novel.findUnique({ where: { id: targetNovelId } })
|
||||
if (!novelAfterWrite) {
|
||||
throw new Error("Không thể tải lại thông tin truyện sau khi import")
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
...newNovel,
|
||||
...novelAfterWrite,
|
||||
parserInfo,
|
||||
hasCoverFromEpub: !!coverUrl,
|
||||
}, { status: 201 })
|
||||
detectedGenres: detectedGenreNames,
|
||||
replaced,
|
||||
}, { status: responseStatus })
|
||||
} catch (error: any) {
|
||||
console.error("EPUB upload error:", error)
|
||||
return NextResponse.json({ error: "Lỗi xử lý file EPUB", details: error.message }, { status: 500 })
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import { getServerSession } from "next-auth/next"
|
||||
import { authOptions } from "@/lib/auth"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
|
||||
type MissingKey = "author" | "cover" | "description" | "genres"
|
||||
|
||||
const ALL_MISSING_KEYS: MissingKey[] = ["author", "cover", "description", "genres"]
|
||||
|
||||
function getScopeWhere(session: { user: { role: string; id: string } }) {
|
||||
if (session.user.role === "ADMIN") {
|
||||
return {}
|
||||
}
|
||||
|
||||
return {
|
||||
OR: [
|
||||
{ uploaderId: session.user.id },
|
||||
{ uploaderId: null },
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
function parseMissingKeys(raw: string | null): MissingKey[] {
|
||||
if (!raw || !raw.trim()) return ALL_MISSING_KEYS
|
||||
|
||||
const parsed = raw
|
||||
.split(",")
|
||||
.map((item) => item.trim().toLowerCase())
|
||||
.filter((item): item is MissingKey => ALL_MISSING_KEYS.includes(item as MissingKey))
|
||||
|
||||
if (parsed.length === 0) return ALL_MISSING_KEYS
|
||||
return Array.from(new Set(parsed))
|
||||
}
|
||||
|
||||
function buildMissingWhereForKey(key: MissingKey) {
|
||||
switch (key) {
|
||||
case "author":
|
||||
return { authorName: { equals: "" } }
|
||||
case "cover":
|
||||
return {
|
||||
OR: [
|
||||
{ coverUrl: null },
|
||||
{ coverUrl: { equals: "" } },
|
||||
],
|
||||
}
|
||||
case "description":
|
||||
return { description: { equals: "" } }
|
||||
case "genres":
|
||||
return { genres: { none: {} } }
|
||||
default:
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
function computeMissingStatus(novel: {
|
||||
authorName: string
|
||||
coverUrl: string | null
|
||||
description: string
|
||||
genres: Array<{ genre: { id: string; name: string } }>
|
||||
}) {
|
||||
const authorMissing = novel.authorName.trim().length === 0
|
||||
const coverMissing = !novel.coverUrl || novel.coverUrl.trim().length === 0
|
||||
const descriptionMissing = novel.description.trim().length === 0
|
||||
const genresMissing = novel.genres.length === 0
|
||||
|
||||
return {
|
||||
author: authorMissing,
|
||||
cover: coverMissing,
|
||||
description: descriptionMissing,
|
||||
genres: genresMissing,
|
||||
}
|
||||
}
|
||||
|
||||
function hasSelectedMissing(missingStatus: Record<MissingKey, boolean>, selected: MissingKey[]) {
|
||||
return selected.some((key) => missingStatus[key])
|
||||
}
|
||||
|
||||
export async function GET(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 q = (url.searchParams.get("q") || "").trim()
|
||||
const selectedMissing = parseMissingKeys(url.searchParams.get("missing"))
|
||||
|
||||
const andWhere: any[] = [getScopeWhere(session)]
|
||||
|
||||
if (q) {
|
||||
andWhere.push({
|
||||
OR: [
|
||||
{ title: { contains: q, mode: "insensitive" } },
|
||||
{ slug: { contains: q, mode: "insensitive" } },
|
||||
{ authorName: { contains: q, mode: "insensitive" } },
|
||||
{ series: { name: { contains: q, mode: "insensitive" } } },
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
if (selectedMissing.length > 0) {
|
||||
andWhere.push({
|
||||
OR: selectedMissing.map((key) => buildMissingWhereForKey(key)),
|
||||
})
|
||||
}
|
||||
|
||||
const novels = await (prisma as any).novel.findMany({
|
||||
where: { AND: andWhere },
|
||||
orderBy: [{ updatedAt: "desc" }],
|
||||
take: 600,
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
slug: true,
|
||||
authorName: true,
|
||||
coverUrl: true,
|
||||
description: true,
|
||||
totalChapters: true,
|
||||
updatedAt: true,
|
||||
series: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
},
|
||||
},
|
||||
genres: {
|
||||
select: {
|
||||
genre: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const items = novels
|
||||
.map((novel: any) => {
|
||||
const missing = computeMissingStatus(novel)
|
||||
|
||||
return {
|
||||
id: novel.id,
|
||||
title: novel.title,
|
||||
slug: novel.slug,
|
||||
authorName: novel.authorName,
|
||||
coverUrl: novel.coverUrl,
|
||||
description: novel.description,
|
||||
totalChapters: novel.totalChapters,
|
||||
updatedAt: novel.updatedAt,
|
||||
series: novel.series,
|
||||
genres: novel.genres.map((item: any) => item.genre),
|
||||
missing,
|
||||
}
|
||||
})
|
||||
.filter((item: any) => hasSelectedMissing(item.missing, selectedMissing))
|
||||
|
||||
return NextResponse.json({
|
||||
items,
|
||||
total: items.length,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch novels with missing fields", error)
|
||||
return NextResponse.json({ error: "Failed to fetch missing-field novels" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(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 updates = Array.isArray(body?.updates) ? body.updates : []
|
||||
|
||||
if (updates.length === 0) {
|
||||
return NextResponse.json({ error: "Thiếu danh sách cập nhật" }, { status: 400 })
|
||||
}
|
||||
|
||||
if (updates.length > 200) {
|
||||
return NextResponse.json({ error: "Chỉ hỗ trợ tối đa 200 bản ghi mỗi lần" }, { status: 400 })
|
||||
}
|
||||
|
||||
const ids = updates
|
||||
.map((item: any) => (typeof item?.id === "string" ? item.id : ""))
|
||||
.filter(Boolean)
|
||||
|
||||
if (ids.length === 0) {
|
||||
return NextResponse.json({ error: "Danh sách ID không hợp lệ" }, { status: 400 })
|
||||
}
|
||||
|
||||
const allowedRows = await (prisma as any).novel.findMany({
|
||||
where: {
|
||||
AND: [
|
||||
getScopeWhere(session),
|
||||
{ id: { in: ids } },
|
||||
],
|
||||
},
|
||||
select: { id: true },
|
||||
})
|
||||
|
||||
const allowedSet = new Set(allowedRows.map((row: any) => row.id))
|
||||
|
||||
let updatedCount = 0
|
||||
let skippedCount = 0
|
||||
const failures: Array<{ id: string; error: string }> = []
|
||||
|
||||
for (const raw of updates) {
|
||||
const id = typeof raw?.id === "string" ? raw.id : ""
|
||||
if (!id) {
|
||||
skippedCount += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (!allowedSet.has(id)) {
|
||||
failures.push({ id, error: "Không có quyền cập nhật truyện này" })
|
||||
continue
|
||||
}
|
||||
|
||||
const data: Record<string, any> = {}
|
||||
|
||||
if (typeof raw.authorName === "string") {
|
||||
data.authorName = raw.authorName.trim()
|
||||
}
|
||||
|
||||
if (typeof raw.coverUrl === "string") {
|
||||
const normalizedCover = raw.coverUrl.trim()
|
||||
data.coverUrl = normalizedCover.length > 0 ? normalizedCover : null
|
||||
} else if (raw.coverUrl === null) {
|
||||
data.coverUrl = null
|
||||
}
|
||||
|
||||
if (typeof raw.description === "string") {
|
||||
data.description = raw.description.trim()
|
||||
}
|
||||
|
||||
const hasGenreUpdate = Array.isArray(raw.genreIds)
|
||||
const genreIds: string[] = hasGenreUpdate
|
||||
? Array.from(new Set((raw.genreIds as unknown[]).filter((item): item is string => typeof item === "string" && item.trim().length > 0)))
|
||||
: []
|
||||
|
||||
if (Object.keys(data).length === 0 && !hasGenreUpdate) {
|
||||
skippedCount += 1
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
if (Object.keys(data).length > 0) {
|
||||
await (tx as any).novel.update({
|
||||
where: { id },
|
||||
data,
|
||||
})
|
||||
}
|
||||
|
||||
if (hasGenreUpdate) {
|
||||
await (tx as any).novelGenre.deleteMany({ where: { novelId: id } })
|
||||
|
||||
if (genreIds.length > 0) {
|
||||
await (tx as any).novelGenre.createMany({
|
||||
data: genreIds.map((genreId) => ({ novelId: id, genreId })),
|
||||
skipDuplicates: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
updatedCount += 1
|
||||
} catch (error: any) {
|
||||
failures.push({ id, error: error?.message || "Cập nhật thất bại" })
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
updatedCount,
|
||||
skippedCount,
|
||||
failureCount: failures.length,
|
||||
failures,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Failed to patch missing-field novels", error)
|
||||
return NextResponse.json({ error: "Failed to update novels" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user