diff --git a/README.md b/README.md index 5c64ca9..dcb9bb1 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,9 @@ MONGODB_URI="mongodb://user:password@localhost:27017/reader?authSource=admin" NEXTAUTH_SECRET="your-super-secret-key" NEXTAUTH_URL="http://localhost:3000" +# API backend dùng chung cho web + mobile +READER_API_ORIGIN="http://localhost:8000" + # Cấu hình Google Login GOOGLE_CLIENT_ID="your_google_client_id" GOOGLE_CLIENT_SECRET="your_google_client_secret" @@ -80,6 +83,8 @@ pnpm dev ``` Truy cập vào [http://localhost:3000](http://localhost:3000) để xem ứng dụng. +Lưu ý: các endpoint user-facing đã migrate (`/api/genres`, `/api/novels/*`, `/api/truyen/*`, `/api/chapters/*`, `/api/user/*`, `/api/auth/mobile-login`) sẽ được proxy sang `READER_API_ORIGIN`. + --- ## 🏗 Hướng dẫn Build diff --git a/app/api/dev/make-mod/route.ts b/app/api/dev/make-mod/route.ts deleted file mode 100644 index 30b58b9..0000000 --- a/app/api/dev/make-mod/route.ts +++ /dev/null @@ -1,30 +0,0 @@ -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() { - if (process.env.NODE_ENV === "production") { - return NextResponse.json({ error: "Not found" }, { status: 404 }) - } - - const session = await getServerSession(authOptions) - - if (!session || !session.user || !session.user.email) { - return NextResponse.json({ error: "Bạn phải đăng nhập trước" }, { status: 401 }) - } - - try { - const updatedUser = await prisma.user.update({ - where: { email: session.user.email }, - data: { role: "MOD" }, - }) - - return NextResponse.json({ - message: `Tài khoản ${updatedUser.email} đã được cấp quyền MOD. Xin hãy Đăng xuất và Đăng nhập lại để cập nhật phiên.`, - user: updatedUser - }) - } catch (error) { - return NextResponse.json({ error: "Lỗi hệ thống" }, { status: 500 }) - } -} diff --git a/app/api/mod/ai-tools/novel-enrich-batch/route.ts b/app/api/mod/ai-tools/novel-enrich-batch/route.ts deleted file mode 100644 index f5057c3..0000000 --- a/app/api/mod/ai-tools/novel-enrich-batch/route.ts +++ /dev/null @@ -1,174 +0,0 @@ -import { NextResponse } from "next/server" -import { getServerSession } from "next-auth/next" -import { authOptions } from "@/lib/auth" -import { prisma } from "@/lib/prisma" - -type EnrichedItem = { - id: string - title: string - originalTitle: string - authorName: string - originalAuthorName: string - description: string - coverUrl?: string - status: "Đang ra" | "Hoàn thành" | "Tạm ngưng" - genresSuggested: string[] - firstPublishYear?: number - confidence: number - source: string - sourceUrl?: string -} - -function normalizeText(value: string): string { - return value - .normalize("NFD") - .replace(/[\u0300-\u036f]/g, "") - .toLowerCase() - .replace(/[^a-z0-9\s]/g, " ") - .replace(/\s+/g, " ") - .trim() -} - -function trimText(value: string, maxLength: number): string { - if (value.length <= maxLength) return value - return `${value.slice(0, maxLength - 1).trimEnd()}...` -} - -function buildBatchPrompt(novels: any[]) { - const itemsText = novels.map(n => { - const shortDesc = trimText(n.description || "", 1500) - return `[ID: ${n.id}]\nTên: ${n.title}\nTên gốc: ${n.originalTitle || ""}\nTác giả: ${n.authorName}\nTác giả gốc: ${n.originalAuthorName || ""}\nThể loại: ${(n.genres || []).map((g: any) => g.genre.name).join(", ")}\nMô tả: ${shortDesc}` - }).join("\n\n---\n\n") - - return [ - "Bạn là biên tập viên truyện dịch tiếng Trung.", - "Nhiệm vụ: bổ sung thông tin bị thiếu (Tên gốc, Tác giả gốc, Mô tả, Thể loại) cho danh sách tác phẩm sau bằng cách tìm kiếm trên Qidian, JJWXC, v.v...", - "Trường 'description': Nếu mô tả gốc đã chi tiết, chỉ cần sửa chính tả. Nếu trống/ngắn, hãy viết mới 1 đoạn giới thiệu dài chi tiết bám sát nội dung gốc (KHÔNG tóm tắt kiểu chung chung).", - "Trạng thái (status) phải luôn là: Đang ra, Hoàn thành, hoặc Tạm ngưng.", - "Kết quả BẮT BUỘC là 1 JSON Object chứa mảng 'results'. Mỗi item trong mảng phải có key 'id' khớp với báo cáo.", - `Schema: {"results":[{"id":"","title":"","originalTitle":"","authorName":"","originalAuthorName":"","description":"","coverUrl":"","status":"Đang ra|Hoàn thành|Tạm ngưng","genresSuggested":[],"firstPublishYear":2020,"confidence":80,"source":"","sourceUrl":""}]}`, - "Dưới đây là danh sách truyện cần xử lý:\n", - itemsText - ].join("\n") -} - -function extractJsonCandidate(text: string): string { - const trimmed = text.trim() - if (!trimmed) return "" - const fenced = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i) - if (fenced?.[1]) return fenced[1].trim() - const firstBrace = trimmed.indexOf("{") - const lastBrace = trimmed.lastIndexOf("}") - if (firstBrace >= 0 && lastBrace > firstBrace) { - return trimmed.slice(firstBrace, lastBrace + 1) - } - return trimmed -} - -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: "Không có quyền truy cập" }, { status: 401 }) - } - - const { novelIds } = await req.json() - if (!Array.isArray(novelIds) || novelIds.length === 0 || novelIds.length > 20) { - return NextResponse.json({ error: "novelIds không hợp lệ hoặc quá lớn (tối đa 20)" }, { status: 400 }) - } - - const accessWhere = session.user.role === "ADMIN" - ? { id: { in: novelIds } } - : { - id: { in: novelIds }, - OR: [ - { uploaderId: session.user.id }, - { uploaderId: null }, - ], - } - - const novels = await prisma.novel.findMany({ - where: accessWhere, - select: { - id: true, - title: true, - originalTitle: true, - authorName: true, - originalAuthorName: true, - description: true, - coverUrl: true, - status: true, - genres: { select: { genre: { select: { name: true } } } }, - } - }) - - if (novels.length === 0) { - return NextResponse.json({ error: "Không tìm thấy truyện nào hợp lệ" }, { status: 404 }) - } - - const apiKey = process.env.DEEKSEEK_KEY?.trim() || process.env.DEEPSEEK_KEY?.trim() - const model = process.env.DEEPSEEK_MODEL?.trim() || "deepseek-chat" - - if (!apiKey) { - return NextResponse.json({ error: "Thiếu DEEPSEEK_KEY" }, { status: 400 }) - } - - const prompt = buildBatchPrompt(novels) - - const controller = new AbortController() - const timeout = setTimeout(() => controller.abort(), 90000) // 90 seconds for batch - const startedAt = Date.now() - - try { - const res = await fetch("https://api.deepseek.com/v1/chat/completions", { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${apiKey}`, - }, - body: JSON.stringify({ - model, - temperature: 0.2, - max_tokens: 2500, - response_format: { type: "json_object" }, - messages: [ - { role: "system", content: "You are a helpful assistant. You must output only valid standard JSON object following the prompt schema, without markdown formatting." }, - { role: "user", content: prompt }, - ], - }), - signal: controller.signal, - }) - - clearTimeout(timeout) - - if (!res.ok) { - const errorText = await res.text().catch(() => "") - throw new Error(`HTTP ${res.status}: ${errorText.slice(0, 200)}`) - } - - const data = await res.json() - const text = data.choices?.[0]?.message?.content?.trim() || "" - const jsonText = extractJsonCandidate(text) - - let parsed: any = null - try { - parsed = JSON.parse(jsonText) - } catch { - throw new Error("Phản hồi JSON bị hỏng") - } - - const results = Array.isArray(parsed?.results) ? parsed.results : [] - - return NextResponse.json({ - success: true, - latencyMs: Date.now() - startedAt, - model, - count: results.length, - results, - sourceNovels: novels, - }) - - } catch (error: any) { - clearTimeout(timeout) - return NextResponse.json({ error: `DeepSeek Error: ${error.message}` }, { status: 500 }) - } -} diff --git a/app/api/mod/ai-tools/novel-enrich/route.ts b/app/api/mod/ai-tools/novel-enrich/route.ts deleted file mode 100644 index 8fe0160..0000000 --- a/app/api/mod/ai-tools/novel-enrich/route.ts +++ /dev/null @@ -1,689 +0,0 @@ -import { NextResponse } from "next/server" -import { getServerSession } from "next-auth/next" -import { authOptions } from "@/lib/auth" -import { prisma } from "@/lib/prisma" - -type EnrichedItem = { - title: string - originalTitle: string - authorName: string - originalAuthorName: string - description: string - coverUrl?: string - status: "Đang ra" | "Hoàn thành" | "Tạm ngưng" - genresSuggested: string[] - firstPublishYear?: number - confidence: number - source: string - sourceUrl?: string -} - -type AttemptStatus = { - provider: "google" | "openrouter" | "deepseek" - model: string - status: "success" | "failed" | "skipped" - message?: string - latencyMs?: number -} - -type ProviderResult = { - provider: "google" | "openrouter" | "deepseek" - model: string - results: EnrichedItem[] -} - -type GeminiResponse = { - candidates?: Array<{ - content?: { - parts?: Array<{ text?: string }> - } - }> -} - -type ChatResponse = { - choices?: Array<{ - message?: { - content?: string - } - }> -} - -type OpenRouterModelsResponse = { - data?: Array<{ - id?: string - }> -} - -const OPENROUTER_PAUSED = (process.env.OPENROUTER_PAUSED ?? "true").toLowerCase() !== "false" - -function hasMeaningfulDescription(value: string): boolean { - const normalized = normalizeText(value) - if (!normalized || normalized.length < 40) return false - - const placeholders = [ - "chua co gioi thieu", - "khong co gioi thieu", - "dang cap nhat", - "dang update", - "updating", - "no description", - "khong ro", - "chua ro", - ] - - return !placeholders.some((item) => normalized.includes(item)) -} - -function buildGeneratedDescription( - title: string, - authorName: string, - genres: string[], - status: "Đang ra" | "Hoàn thành" | "Tạm ngưng" -): string { - const genreText = genres.length > 0 ? genres.slice(0, 3).join(", ") : "tiểu thuyết mạng" - const statusText = status === "Đang ra" ? "đang ra" : status === "Hoàn thành" ? "đã hoàn thành" : "tạm ngưng" - - return trimText( - `${title} là một tác phẩm ${genreText} của ${authorName}. Câu chuyện được hệ thống tóm lược lại từ những thông tin đã tìm thấy trên web và hiện ở trạng thái ${statusText}. Đây là đoạn mô tả thay thế được tạo tự động khi mô hình chưa trả về phần giới thiệu đủ chi tiết, giúp biên tập viên có sẵn nội dung để rà soát và chỉnh sửa tiếp.`, - 1200 - ) -} - -function normalizeText(value: string): string { - return value - .normalize("NFD") - .replace(/[\u0300-\u036f]/g, "") - .toLowerCase() - .replace(/[^a-z0-9\s]/g, " ") - .replace(/\s+/g, " ") - .trim() -} - -function trimText(value: string, maxLength: number): string { - if (value.length <= maxLength) return value - return `${value.slice(0, maxLength - 1).trimEnd()}...` -} - -function inferStatus(subjects: string[], description: string): "Đang ra" | "Hoàn thành" | "Tạm ngưng" { - const haystack = normalizeText(`${subjects.join(" ")} ${description}`) - - if (haystack.includes("completed") || haystack.includes("finished") || haystack.includes("full text")) { - return "Hoàn thành" - } - - if (haystack.includes("hiatus") || haystack.includes("on hold") || haystack.includes("paused")) { - return "Tạm ngưng" - } - - return "Đang ra" -} - -function clampNumber(value: unknown, min: number, max: number, fallback: number): number { - const num = typeof value === "number" ? value : Number(value) - if (!Number.isFinite(num)) return fallback - return Math.max(min, Math.min(max, num)) -} - -function toOptionalUrl(value: unknown): string | undefined { - if (typeof value !== "string") return undefined - const trimmed = value.trim() - if (!trimmed) return undefined - if (!/^https?:\/\//i.test(trimmed)) return undefined - return trimmed -} - -function coerceStatus(value: unknown, genres: string[], description: string): "Đang ra" | "Hoàn thành" | "Tạm ngưng" { - if (typeof value === "string") { - const normalized = normalizeText(value) - if (normalized.includes("hoan thanh") || normalized.includes("completed") || normalized.includes("finished")) { - return "Hoàn thành" - } - if (normalized.includes("tam") || normalized.includes("ngung") || normalized.includes("hiatus") || normalized.includes("paused")) { - return "Tạm ngưng" - } - if (normalized.includes("dang") || normalized.includes("ongoing")) { - return "Đang ra" - } - } - - return inferStatus(genres, description) -} - -function extractJsonCandidate(text: string): string { - const trimmed = text.trim() - if (!trimmed) return "" - - const fenced = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i) - if (fenced?.[1]) return fenced[1].trim() - - const firstBrace = trimmed.indexOf("{") - const lastBrace = trimmed.lastIndexOf("}") - if (firstBrace >= 0 && lastBrace > firstBrace) { - return trimmed.slice(firstBrace, lastBrace + 1) - } - - return trimmed -} - -function toItem(raw: any, novelTitle: string): EnrichedItem | null { - const title = typeof raw?.title === "string" ? raw.title.trim() : "" - if (!title) return null - - const originalTitle = typeof raw?.originalTitle === "string" && raw.originalTitle.trim() - ? raw.originalTitle.trim() - : title - - const authorName = typeof raw?.authorName === "string" && raw.authorName.trim() - ? raw.authorName.trim() - : "Chưa rõ" - - const originalAuthorName = typeof raw?.originalAuthorName === "string" && raw.originalAuthorName.trim() - ? raw.originalAuthorName.trim() - : authorName - - const genres = Array.isArray(raw?.genresSuggested) - ? raw.genresSuggested - .map((g: unknown) => (typeof g === "string" ? g.trim() : "")) - .filter(Boolean) - .slice(0, 10) - : [] - - const status = coerceStatus(raw?.status, genres, typeof raw?.description === "string" ? raw.description.trim() : "") - - const descriptionInput = typeof raw?.description === "string" ? raw.description.trim() : "" - const description = trimText( - hasMeaningfulDescription(descriptionInput) - ? descriptionInput - : buildGeneratedDescription(title || novelTitle, authorName, genres, status), - 1200 - ) - - const year = Number(raw?.firstPublishYear) - const firstPublishYear = Number.isFinite(year) && year >= 1000 && year <= 2100 ? year : undefined - - return { - title, - originalTitle, - authorName, - originalAuthorName, - description, - coverUrl: toOptionalUrl(raw?.coverUrl), - status, - genresSuggested: genres, - firstPublishYear, - confidence: clampNumber(raw?.confidence, 1, 99, 65), - source: typeof raw?.source === "string" && raw.source.trim() ? raw.source.trim() : "LLM Web Search", - sourceUrl: toOptionalUrl(raw?.sourceUrl), - } -} - -function parseResultsFromText(text: string, novelTitle: string): EnrichedItem[] { - const jsonText = extractJsonCandidate(text) - if (!jsonText) return [] - - let parsed: any = null - try { - parsed = JSON.parse(jsonText) - } catch { - return [] - } - - const rows = Array.isArray(parsed?.results) ? parsed.results : [] - return rows - .map((row: any) => toItem(row, novelTitle)) - .filter((row: EnrichedItem | null): row is EnrichedItem => Boolean(row)) - .slice(0, 6) -} - -function buildFallbackResult(novelTitle: string): EnrichedItem { - return { - title: novelTitle, - originalTitle: novelTitle, - authorName: "Chưa rõ", - originalAuthorName: "Chưa rõ", - description: buildGeneratedDescription(novelTitle, "Chưa rõ", [], "Đang ra"), - coverUrl: undefined, - status: "Đang ra", - genresSuggested: [], - firstPublishYear: undefined, - confidence: 30, - source: "Fallback", - sourceUrl: undefined, - } -} - -function buildPrompt(novel: { - title: string - originalTitle?: string | null - authorName: string - originalAuthorName?: string | null - description: string - genres: string[] -}) { - const shortDescription = trimText(novel.description || "", 1500) - - return [ - "Bạn là trợ lý biên tập truyện dịch từ truyện mạng Trung Quốc.", - "Hãy tìm thông tin trên web, ưu tiên các nguồn như Qidian, JJWXC, NovelUpdates, wiki fan và nhà xuất bản.", - "Trả về JSON thuần, không markdown, không giải thích thêm.", - "Bắt buộc viết các trường title, authorName, description, genresSuggested bằng tiếng Việt có dấu nếu đó là tên hoặc khái niệm đã có bản Việt hóa phổ biến.", - "Riêng originalTitle và originalAuthorName hãy giữ theo nguyên tác nếu tìm được.", - "Trường description là bắt buộc. Nếu 'Mô tả hiện tại' (dưới đây) đã dài và chi tiết, hãy giữ nguyên ý chính gốc và chỉ chỉnh sửa văn phong, bố cục cho mượt mà hoặc hấp dẫn hơn. TUYỆT ĐỐI KHÔNG tóm tắt ngắn đi nếu bản gốc đang đầy đủ.", - "Nếu 'Mô tả hiện tại' chống không hoặc quá sơ sài, hãy tìm kiếm nội dung về cốt truyện gốc trên mạng và viết một bài giới thiệu dài, chi tiết, bám sát các sự kiện thực tế trong truyện. KHÔNG viết chung chung kiểu 'đầy chông gai và thử thách'.", - "Trạng thái (status) phải luôn là 1 trong 3 giá trị: Đang ra, Hoàn thành, Tạm ngưng. Mảng genresSuggested chứa tối đa 10 thể loại phù hợp.", - `Schema: {"results":[{"title":"","originalTitle":"","authorName":"","originalAuthorName":"","description":"","coverUrl":"","status":"Đang ra|Hoàn thành|Tạm ngưng","genresSuggested":[],"firstPublishYear":2020,"confidence":80,"source":"","sourceUrl":""}]}`, - "Tối đa 3 kết quả, ưu tiên kết quả gần nhất với truyện hiện tại.", - `Tên hiện tại: ${novel.title}`, - `Tên gốc hiện tại: ${novel.originalTitle || ""}`, - `Tác giả hiện tại: ${novel.authorName}`, - `Tác giả gốc hiện tại: ${novel.originalAuthorName || ""}`, - `Thể loại hiện tại: ${novel.genres.join(", ")}`, - `Mô tả hiện tại: ${shortDescription}`, - ].join(" ") -} - -async function fetchJsonWithTimeout(url: string, init: RequestInit, timeoutMs = 25000): Promise { - const controller = new AbortController() - const timeout = setTimeout(() => controller.abort(), timeoutMs) - - try { - const res = await fetch(url, { ...init, signal: controller.signal, cache: "no-store" }) - if (!res.ok) { - const text = await res.text().catch(() => "") - throw new Error(`HTTP ${res.status}: ${text.slice(0, 220)}`) - } - return (await res.json()) as T - } finally { - clearTimeout(timeout) - } -} - -function summarizeError(error: unknown): string { - if (!(error instanceof Error)) return "Lỗi không xác định" - return error.message.slice(0, 220) -} - -async function tryGoogle(prompt: string, novelTitle: string, attempts: AttemptStatus[]): Promise { - const apiKey = process.env.GOOGLE_AI_KEY?.trim() - const model = process.env.GOOGLE_AI_MODEL?.trim() || "gemini-2.0-flash" - - if (!apiKey) { - attempts.push({ provider: "google", model, status: "skipped", message: "Thiếu GOOGLE_AI_KEY" }) - return null - } - - const startedAt = Date.now() - try { - const data = await fetchJsonWithTimeout( - `https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(model)}:generateContent?key=${encodeURIComponent(apiKey)}`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - contents: [{ role: "user", parts: [{ text: prompt }] }], - tools: [{ googleSearch: {} }], - generationConfig: { - temperature: 0.2, - maxOutputTokens: 1400, - }, - }), - }, - 22000 - ) - - const text = (data.candidates || []) - .flatMap((candidate) => candidate.content?.parts || []) - .map((part) => part.text || "") - .join("\n") - .trim() - - const results = parseResultsFromText(text, novelTitle) - if (results.length === 0) { - console.log("Google parsing failed. Raw text:", text) - throw new Error("Không có kết quả JSON hợp lệ") - } - - attempts.push({ - provider: "google", - model, - status: "success", - message: `${results.length} kết quả`, - latencyMs: Date.now() - startedAt, - }) - - return { provider: "google", model, results } - } catch (error) { - console.log("Google fetch error:", error) - attempts.push({ - provider: "google", - model, - status: "failed", - message: summarizeError(error), - latencyMs: Date.now() - startedAt, - }) - return null - } -} - -async function resolveOpenRouterFreeModels(apiKey: string): Promise { - const envModels = (process.env.OPENROUTER_FREE_MODELS || "") - .split(",") - .map((s) => s.trim()) - .filter((s) => s.endsWith(":free")) - - const dynamicModels: string[] = [] - - try { - const data = await fetchJsonWithTimeout( - "https://openrouter.ai/api/v1/models", - { - method: "GET", - headers: { - Authorization: `Bearer ${apiKey}`, - }, - }, - 12000 - ) - - for (const row of data.data || []) { - const id = (row.id || "").trim() - if (!id || !id.endsWith(":free")) continue - dynamicModels.push(id) - } - } catch { - // Ignore model-list errors and keep env list fallback. - } - - const merged = Array.from(new Set([...envModels, ...dynamicModels])) - - const preferredOrder = [ - "google/gemma-2-9b-it:free", - "meta-llama/llama-3.1-8b-instruct:free", - "qwen/qwen2.5-7b-instruct:free", - ] - - merged.sort((a, b) => { - const ai = preferredOrder.findIndex((item) => item === a) - const bi = preferredOrder.findIndex((item) => item === b) - const ar = ai === -1 ? 999 : ai - const br = bi === -1 ? 999 : bi - if (ar !== br) return ar - br - return a.localeCompare(b) - }) - - return merged.slice(0, 10) -} - -async function tryOpenRouter(prompt: string, novelTitle: string, attempts: AttemptStatus[]): Promise { - const apiKey = process.env.OPENROUTER_KEY?.trim() - if (!apiKey) { - attempts.push({ provider: "openrouter", model: "free-chain", status: "skipped", message: "Thiếu OPENROUTER_KEY" }) - return null - } - - const freeModels = await resolveOpenRouterFreeModels(apiKey) - if (freeModels.length === 0) { - attempts.push({ provider: "openrouter", model: "free-chain", status: "skipped", message: "Không có model free khả dụng" }) - return null - } - - for (const model of freeModels) { - const startedAt = Date.now() - try { - const data = await fetchJsonWithTimeout( - "https://openrouter.ai/api/v1/chat/completions", - { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${apiKey}`, - "HTTP-Referer": "http://localhost:3000", - "X-Title": "reader-mod-ai-tool", - }, - body: JSON.stringify({ - model, - temperature: 0.2, - max_tokens: 1400, - messages: [ - { role: "system", content: "Return only valid JSON." }, - { role: "user", content: prompt }, - ], - }), - }, - 22000 - ) - - const text = data.choices?.[0]?.message?.content?.trim() || "" - const results = parseResultsFromText(text, novelTitle) - if (results.length === 0) { - console.log("OpenRouter parsing failed. Raw text:", text) - throw new Error("Không có kết quả JSON hợp lệ") - } - - attempts.push({ - provider: "openrouter", - model, - status: "success", - message: `${results.length} kết quả`, - latencyMs: Date.now() - startedAt, - }) - - return { provider: "openrouter", model, results } - } catch (error) { - console.log("OpenRouter fetch error:", error) - attempts.push({ - provider: "openrouter", - model, - status: "failed", - message: summarizeError(error), - latencyMs: Date.now() - startedAt, - }) - } - } - - return null -} - -async function tryDeepSeek(prompt: string, novelTitle: string, attempts: AttemptStatus[]): Promise { - const apiKey = process.env.DEEKSEEK_KEY?.trim() || process.env.DEEPSEEK_KEY?.trim() - const model = process.env.DEEPSEEK_MODEL?.trim() || "deepseek-chat" - - if (!apiKey) { - attempts.push({ provider: "deepseek", model, status: "skipped", message: "Thiếu DEEKSEEK_KEY/DEEPSEEK_KEY" }) - return null - } - - const profiles = [ - { maxTokens: 1300, timeoutMs: 60000 }, - { maxTokens: 900, timeoutMs: 45000 }, - ] - - for (const profile of profiles) { - const startedAt = Date.now() - try { - const data = await fetchJsonWithTimeout( - "https://api.deepseek.com/v1/chat/completions", - { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${apiKey}`, - }, - body: JSON.stringify({ - model, - temperature: 0.2, - max_tokens: profile.maxTokens, - response_format: { type: "json_object" }, - messages: [ - { role: "system", content: "You are a helpful assistant. You must output only valid standard JSON object following the prompt schema, without markdown formatting." }, - { role: "user", content: prompt }, - ], - }), - }, - profile.timeoutMs - ) - - const text = data.choices?.[0]?.message?.content?.trim() || "" - const results = parseResultsFromText(text, novelTitle) - if (results.length === 0) { - console.log("DeepSeek parsing failed. Raw text:", text) - throw new Error("Không có kết quả JSON hợp lệ") - } - - attempts.push({ - provider: "deepseek", - model, - status: "success", - message: `${results.length} kết quả`, - latencyMs: Date.now() - startedAt, - }) - - return { provider: "deepseek", model, results } - } catch (error) { - console.log("DeepSeek fetch error:", error) - attempts.push({ - provider: "deepseek", - model, - status: "failed", - message: summarizeError(error), - latencyMs: Date.now() - startedAt, - }) - } - } - - return null -} - -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: "Không có quyền truy cập" }, { status: 401 }) - } - - const { searchParams } = new URL(req.url) - const novelId = searchParams.get("novelId")?.trim() || "" - - if (!novelId) { - return NextResponse.json({ error: "Thiếu novelId" }, { status: 400 }) - } - - const accessWhere = session.user.role === "ADMIN" - ? { id: novelId } - : { - id: novelId, - OR: [ - { uploaderId: session.user.id }, - { uploaderId: null }, - ], - } - - const novel = await prisma.novel.findFirst({ - where: accessWhere, - select: { - id: true, - title: true, - originalTitle: true, - slug: true, - authorName: true, - originalAuthorName: true, - description: true, - coverUrl: true, - status: true, - updatedAt: true, - genres: { - select: { - genre: { - select: { - name: true, - }, - }, - }, - }, - }, - }) - - if (!novel) { - return NextResponse.json({ error: "Không tìm thấy truyện hoặc bạn không có quyền truy cập" }, { status: 404 }) - } - - const attempts: AttemptStatus[] = [] - const currentNovel = { - id: novel.id, - title: novel.title, - originalTitle: novel.originalTitle, - slug: novel.slug, - authorName: novel.authorName, - originalAuthorName: novel.originalAuthorName, - description: novel.description, - coverUrl: novel.coverUrl, - status: novel.status, - updatedAt: novel.updatedAt, - genres: (novel.genres || []).map((row) => row.genre.name), - } - - const prompt = buildPrompt(currentNovel) - - // Tạm thời bỏ qua Google Gemini theo yêu cầu của user - attempts.push({ - provider: "google", - model: "gemini-2.0-flash", - status: "skipped", - message: "Tạm bỏ qua theo yêu cầu người dùng.", - }) - /* - const google = await tryGoogle(prompt, currentNovel.title, attempts) - if (google) { - return NextResponse.json({ - novel: currentNovel, - provider: google.provider, - model: google.model, - attempts, - count: google.results.length, - results: google.results, - }) - } - */ - - const deepseek = await tryDeepSeek(prompt, currentNovel.title, attempts) - if (deepseek) { - return NextResponse.json({ - novel: currentNovel, - provider: deepseek.provider, - model: deepseek.model, - attempts, - count: deepseek.results.length, - results: deepseek.results, - }) - } - - if (OPENROUTER_PAUSED) { - attempts.push({ - provider: "openrouter", - model: "free-chain", - status: "skipped", - message: "Tạm dừng OpenRouter theo cấu hình", - }) - } else { - const openrouter = await tryOpenRouter(prompt, currentNovel.title, attempts) - if (openrouter) { - return NextResponse.json({ - novel: currentNovel, - provider: openrouter.provider, - model: openrouter.model, - attempts, - count: openrouter.results.length, - results: openrouter.results, - }) - } - } - - return NextResponse.json({ - novel: currentNovel, - provider: "fallback", - model: "none", - attempts, - count: 1, - results: [buildFallbackResult(currentNovel.title)], - warning: "Google AI và DeepSeek đều thất bại", - }) -} diff --git a/app/api/mod/ai-tools/novel-search/route.ts b/app/api/mod/ai-tools/novel-search/route.ts deleted file mode 100644 index d4bc304..0000000 --- a/app/api/mod/ai-tools/novel-search/route.ts +++ /dev/null @@ -1,79 +0,0 @@ -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) { - const session = await getServerSession(authOptions) - if (!session || (session.user.role !== "MOD" && session.user.role !== "ADMIN")) { - return NextResponse.json({ error: "Không có quyền truy cập" }, { status: 401 }) - } - - const { searchParams } = new URL(req.url) - const q = (searchParams.get("q") || "").trim() - const page = parseInt(searchParams.get("page") || "1", 10) - const take = 24 - const skip = (Math.max(1, page) - 1) * take - - const whereScope = session.user.role === "ADMIN" - ? {} - : { - OR: [ - { uploaderId: session.user.id }, - { uploaderId: null }, - ], - } - - const isMissingFilter = searchParams.get("missing") === "true" - const baseWhereOptions: any[] = [whereScope] - - if (q.length > 0) { - baseWhereOptions.push({ - OR: [ - { title: { contains: q, mode: "insensitive" } }, - { originalTitle: { contains: q, mode: "insensitive" } }, - { authorName: { contains: q, mode: "insensitive" } }, - { originalAuthorName: { contains: q, mode: "insensitive" } }, - { slug: { contains: q, mode: "insensitive" } }, - ], - }) - } else if (!isMissingFilter) { - return NextResponse.json({ novels: [], hasMore: false }) - } - - if (isMissingFilter) { - baseWhereOptions.push({ - OR: [ - { authorName: { in: ["", "Chưa rõ"] } }, - { description: "" }, - { description: { in: ["Chưa có giới thiệu", "Không có giới thiệu", "chưa có giới thiệu", "không có", "chưa rõ", "đang cập nhật"] } } - ], - }) - } - - const rows = await prisma.novel.findMany({ - where: { - AND: baseWhereOptions, - }, - select: { - id: true, - title: true, - originalTitle: true, - slug: true, - authorName: true, - originalAuthorName: true, - description: true, - coverUrl: true, - status: true, - updatedAt: true, - }, - orderBy: [{ updatedAt: "desc" }], - take: take + 1, // Fetch one extra to know if there's a next page - skip, - }) - - const hasMore = rows.length > take - const returnRows = hasMore ? rows.slice(0, take) : rows - - return NextResponse.json({ novels: returnRows, hasMore }) -} diff --git a/app/api/mod/ai-tools/test-connection/route.ts b/app/api/mod/ai-tools/test-connection/route.ts deleted file mode 100644 index c704d9e..0000000 --- a/app/api/mod/ai-tools/test-connection/route.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { NextResponse } from "next/server" -import { getServerSession } from "next-auth/next" -import { authOptions } from "@/lib/auth" - -export async function GET() { - const session = await getServerSession(authOptions) - if (!session || (session.user.role !== "MOD" && session.user.role !== "ADMIN")) { - return NextResponse.json({ error: "Không có quyền truy cập" }, { status: 401 }) - } - - const apiKey = process.env.DEEKSEEK_KEY?.trim() || process.env.DEEPSEEK_KEY?.trim() - const model = process.env.DEEPSEEK_MODEL?.trim() || "deepseek-chat" - - if (!apiKey) { - return NextResponse.json({ error: "Chưa cấu hình API Key cho DeepSeek (DEEKSEEK_KEY / DEEPSEEK_KEY)" }, { status: 400 }) - } - - const startedAt = Date.now() - try { - const controller = new AbortController() - const timeout = setTimeout(() => controller.abort(), 15000) - - const res = await fetch("https://api.deepseek.com/v1/chat/completions", { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${apiKey}`, - }, - body: JSON.stringify({ - model, - temperature: 0.1, - max_tokens: 10, - messages: [{ role: "user", content: "Ping! Please reply with 'pong'." }], - }), - signal: controller.signal, - }) - - clearTimeout(timeout) - - if (!res.ok) { - const text = await res.text().catch(() => "") - throw new Error(`HTTP ${res.status}: ${text.slice(0, 100)}`) - } - - const data = await res.json() - const text = data.choices?.[0]?.message?.content?.trim() || "" - - return NextResponse.json({ - success: true, - message: `DeepSeek phản hồi thành công: "${text}"`, - latencyMs: Date.now() - startedAt, - model - }) - } catch (error: any) { - return NextResponse.json({ - error: `Kết nối DeepSeek thất bại: ${error.message}` - }, { status: 500 }) - } -} diff --git a/app/api/mod/chuong/[id]/route.ts b/app/api/mod/chuong/[id]/route.ts deleted file mode 100644 index 9cf2ca9..0000000 --- a/app/api/mod/chuong/[id]/route.ts +++ /dev/null @@ -1,53 +0,0 @@ -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 (or is an ADMIN) - let novelQuery: any = { id: chapter.novelId } - if (session.user.role !== "ADMIN") { - novelQuery.uploaderId = session.user.id - } - - const novel = await prisma.novel.findFirst({ - where: novelQuery - }) - - if (!novel) { - console.log("Novel not found or unauthorized:", { - chapterNovelId: chapter.novelId, - userId: session.user.id, - role: session.user.role - }) - 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 }) - } -} diff --git a/app/api/mod/chuong/bulk-delete/route.ts b/app/api/mod/chuong/bulk-delete/route.ts deleted file mode 100644 index ea027c3..0000000 --- a/app/api/mod/chuong/bulk-delete/route.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { NextResponse } from "next/server" -import { getServerSession } from "next-auth/next" -import { authOptions } from "@/lib/auth" -import { prisma } from "@/lib/prisma" -import connectToMongoDB from "@/lib/mongoose" -import { Chapter } from "@/lib/models/chapter" - -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 { novelId, fromNumber, toNumber } = data - - if (!novelId || typeof fromNumber !== "number" || typeof toNumber !== "number") { - return NextResponse.json({ error: "Dữ liệu không hợp lệ" }, { status: 400 }) - } - - if (fromNumber > toNumber) { - return NextResponse.json({ error: "Chương bắt đầu không được lớn hơn chương kết thúc" }, { status: 400 }) - } - - // Xác minh truyện thuộc về Mod này (hoặc Admin) - const novel = await prisma.novel.findUnique({ - where: { id: novelId } - }) - - if (!novel) { - return NextResponse.json({ error: "Truyện không tồn tại" }, { status: 404 }) - } - - if (session.user.role !== "ADMIN" && novel.uploaderId !== session.user.id) { - return NextResponse.json({ error: "Bạn không có quyền thao tác trên truyện này" }, { status: 403 }) - } - - await connectToMongoDB() - - // Xóa các chương trong khoảng - const deleteResult = await Chapter.deleteMany({ - novelId, - number: { $gte: fromNumber, $lte: toNumber } - }) - - // Cập nhật lại số lượng chương - const totalChapters = await Chapter.countDocuments({ novelId }) - await prisma.novel.update({ - where: { id: novelId }, - data: { totalChapters }, - }) - - return NextResponse.json({ - success: true, - deletedCount: deleteResult.deletedCount, - totalChapters - }) - } catch (error: any) { - console.error("Bulk Delete Chapters Error:", error) - return NextResponse.json({ error: "Lỗi hệ thống khi xóa chương: " + error.message }, { status: 500 }) - } -} diff --git a/app/api/mod/chuong/global-replace/route.ts b/app/api/mod/chuong/global-replace/route.ts deleted file mode 100644 index d7ae839..0000000 --- a/app/api/mod/chuong/global-replace/route.ts +++ /dev/null @@ -1,121 +0,0 @@ -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 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 body = await req.json() - const { novelId, action = "replace", findText, replaceText, matchCase = false, trashWords = "", preview = false } = body - - if (!novelId) { - return NextResponse.json({ error: "novelId is required" }, { status: 400 }) - } - - // Verify that the novel belongs to the uploader - let novelQuery: any = { id: novelId } - if (session.user.role !== "ADMIN") { - novelQuery.uploaderId = session.user.id - } - - const novel = await prisma.novel.findFirst({ - where: novelQuery, - }) - - if (!novel) { - return NextResponse.json({ error: "Truyện không tồn tại hoặc không đủ quyền" }, { status: 403 }) - } - - await connectToMongoDB() - - let patterns: { regex: RegExp, replaceWith: string }[] = [] - - if (action === "replace") { - if (!findText) return NextResponse.json({ error: "findText is required for replace action" }, { status: 400 }) - const flags = matchCase ? "g" : "gi" - const safeFindText = findText.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') - patterns.push({ regex: new RegExp(safeFindText, flags), replaceWith: replaceText || "" }) - } else if (action === "trash") { - let words: string[] = [] - if (Array.isArray(trashWords)) { - words = trashWords - } else if (typeof trashWords === "string") { - words = trashWords.split(',').map((w: string) => w.trim()).filter((w: string) => w.length > 0) - } - - if (words.length === 0) return NextResponse.json({ error: "No valid words provided" }, { status: 400 }) - - words.forEach((word: string) => { - const safeWord = word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') - patterns.push({ regex: new RegExp(safeWord, 'gi'), replaceWith: "" }) - }) - } else { - return NextResponse.json({ error: "Invalid action" }, { status: 400 }) - } - - // Find all chapters for the novel - const chapters = await Chapter.find({ novelId }).sort({ number: 1 }) - let updatedCount = 0 - let previewResults: any[] = [] - - for (const chap of chapters) { - let originalContent = chap.content || "" - let newContent = originalContent - let modified = false - - patterns.forEach(({ regex, replaceWith }) => { - if (regex.test(newContent)) { - modified = true - newContent = newContent.replace(regex, replaceWith) - } - }) - - if (modified) { - if (preview && previewResults.length < 5) { // Limit previews to 5 chapters to save payload size - // Capture a small text snippet from the first pattern match - let snippet = "" - if (patterns.length > 0) { - const match = patterns[0].regex.exec(originalContent) - if (match) { - const matchIndex = match.index - const start = Math.max(0, matchIndex - 30) - const end = Math.min(originalContent.length, matchIndex + match[0].length + 30) - snippet = "..." + originalContent.substring(start, end).replace(/\n/g, ' ') + "..." - } - } - - previewResults.push({ - chapterId: chap._id, - number: chap.number, - title: chap.title, - snippet - }) - } - - if (!preview) { - chap.content = newContent - await chap.save() - } - - updatedCount++ - } - } - - return NextResponse.json({ - message: preview ? "Preview generated" : "Success", - updatedChapters: updatedCount, - previews: previewResults - }, { status: 200 }) - - } catch (error) { - console.error("Global Replace Error:", error) - return NextResponse.json({ error: "Failed to perform global replacement" }, { status: 500 }) - } -} diff --git a/app/api/mod/chuong/optimize/route.ts b/app/api/mod/chuong/optimize/route.ts deleted file mode 100644 index 09b36bd..0000000 --- a/app/api/mod/chuong/optimize/route.ts +++ /dev/null @@ -1,77 +0,0 @@ -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 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 }) - } - - const novel = await prisma.novel.findUnique({ - where: { id: novelId }, - select: { id: true, uploaderId: true } - }) - - if (!novel) { - return NextResponse.json({ error: "Không tìm thấy truyện" }, { status: 404 }) - } - - if (session.user.role !== "ADMIN" && novel.uploaderId !== session.user.id) { - return NextResponse.json({ error: "Forbidden" }, { status: 403 }) - } - - const validUpdates = updates.filter((update: any) => - update && - typeof update.id === "string" && - typeof update.number === "number" && - typeof update.title === "string" - ) - - if (validUpdates.length === 0) { - return NextResponse.json({ message: "Không có thay đổi nào" }, { status: 200 }) - } - - await connectToMongoDB() - - // Prepare bulk operations for mongoose - const bulkOps = validUpdates.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", - matchedCount: result.matchedCount, - 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 }) - } -} diff --git a/app/api/mod/chuong/route.ts b/app/api/mod/chuong/route.ts deleted file mode 100644 index d7abff5..0000000 --- a/app/api/mod/chuong/route.ts +++ /dev/null @@ -1,189 +0,0 @@ -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" - -function toNullableNumber(value: any): number | null { - if (value === null || value === undefined || value === "") return null - const parsed = Number(value) - return Number.isFinite(parsed) ? parsed : null -} - -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 }) - } - - try { - await connectToMongoDB() - 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 }) - } -} - -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 { novelId, number, title, content, volumeNumber, volumeTitle, volumeChapterNumber } = 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() - - // Kiểm tra chương đã tồn tại - const existingChapter = await Chapter.findOne({ novelId, number }) - if (existingChapter) { - return NextResponse.json({ error: "Chương này đã tồn tại" }, { status: 400 }) - } - - const newChapter = await Chapter.create({ - novelId, - number, - volumeNumber: toNullableNumber(volumeNumber), - volumeTitle: typeof volumeTitle === "string" && volumeTitle.trim().length > 0 ? volumeTitle.trim() : null, - volumeChapterNumber: toNullableNumber(volumeChapterNumber), - title, - content, - }) - - // Cập nhật số chương trong table PostgreSQL, tự động đếm lại - const totalChapters = await Chapter.countDocuments({ novelId }) - await prisma.novel.update({ - where: { id: novelId }, - data: { totalChapters }, - }) - - return NextResponse.json(newChapter, { status: 201 }) - } catch (error) { - console.error("POST Chapter Error:", error) - 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, volumeNumber, volumeTitle, volumeChapterNumber } = 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, - volumeNumber: toNullableNumber(volumeNumber), - volumeTitle: typeof volumeTitle === "string" && volumeTitle.trim().length > 0 ? volumeTitle.trim() : null, - volumeChapterNumber: toNullableNumber(volumeChapterNumber), - }, - { 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 }) - } -} diff --git a/app/api/mod/de-cu/route.ts b/app/api/mod/de-cu/route.ts deleted file mode 100644 index f939941..0000000 --- a/app/api/mod/de-cu/route.ts +++ /dev/null @@ -1,262 +0,0 @@ -import { NextResponse } from "next/server" -import { getServerSession } from "next-auth/next" -import { authOptions } from "@/lib/auth" -import { prisma } from "@/lib/prisma" -import connectToMongoDB from "@/lib/mongoose" -import { EditorRecommendation } from "@/lib/models/editor-recommendation" - -const MAX_RECOMMENDATIONS_PER_EDITOR = 5 - -function normalizeText(value: any): string { - return typeof value === "string" ? value.trim() : "" -} - -function isAllowedModerator(role: string) { - return role === "MOD" || role === "ADMIN" -} - -export async function GET(req: Request) { - const session = await getServerSession(authOptions) - if (!session || !isAllowedModerator(session.user.role)) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) - } - - try { - const url = new URL(req.url) - const q = normalizeText(url.searchParams.get("q")) - - await connectToMongoDB() - - const docs = (await EditorRecommendation.find({}) - .sort({ createdAt: -1 }) - .limit(1000) - .lean()) as Array<{ - _id: any - novelId: string - editorId: string - createdAt?: Date - }> - - const novelIds = Array.from(new Set(docs.map((doc) => doc.novelId).filter(Boolean))) - const editorIds = Array.from(new Set(docs.map((doc) => doc.editorId).filter(Boolean))) - - const [novels, editors] = await Promise.all([ - novelIds.length > 0 - ? prisma.novel.findMany({ - where: { id: { in: novelIds } }, - select: { - id: true, - title: true, - slug: true, - authorName: true, - coverUrl: true, - status: true, - totalChapters: true, - }, - }) - : Promise.resolve([]), - editorIds.length > 0 - ? prisma.user.findMany({ - where: { id: { in: editorIds } }, - select: { id: true, name: true }, - }) - : Promise.resolve([]), - ]) - - const novelMap = new Map(novels.map((novel) => [novel.id, novel])) - const editorMap = new Map(editors.map((editor) => [editor.id, editor])) - - const recommendationCountMap = new Map() - for (const doc of docs) { - recommendationCountMap.set(doc.novelId, (recommendationCountMap.get(doc.novelId) || 0) + 1) - } - - const items = docs - .map((doc) => { - const novel = novelMap.get(doc.novelId) - if (!novel) return null - - const editor = editorMap.get(doc.editorId) - return { - id: String(doc._id), - createdAt: doc.createdAt || null, - recommendCount: recommendationCountMap.get(doc.novelId) || 0, - novel, - editor: { - id: doc.editorId, - name: editor?.name || "Biên tập viên", - }, - } - }) - .filter((item): item is NonNullable => Boolean(item)) - - const summary = Array.from(recommendationCountMap.entries()) - .map(([novelId, recommendCount]) => { - const novel = novelMap.get(novelId) - if (!novel) return null - return { novel, recommendCount } - }) - .filter((item): item is NonNullable => Boolean(item)) - .sort((a, b) => b.recommendCount - a.recommendCount) - - const myNovelIdSet = new Set( - docs.filter((doc) => doc.editorId === session.user.id).map((doc) => doc.novelId) - ) - const myRecommendationCount = myNovelIdSet.size - - const candidates = q - ? await prisma.novel.findMany({ - where: { - OR: [ - { title: { contains: q, mode: "insensitive" } }, - { slug: { contains: q, mode: "insensitive" } }, - { authorName: { contains: q, mode: "insensitive" } }, - ], - }, - select: { - id: true, - title: true, - slug: true, - authorName: true, - coverUrl: true, - status: true, - totalChapters: true, - }, - take: 20, - orderBy: [{ updatedAt: "desc" }], - }) - : [] - - const candidateRows = candidates.map((novel) => ({ - ...novel, - alreadyRecommended: myNovelIdSet.has(novel.id), - recommendCount: recommendationCountMap.get(novel.id) || 0, - })) - - return NextResponse.json({ - items, - summary, - candidates: candidateRows, - myNovelIds: Array.from(myNovelIdSet), - currentUser: { - id: session.user.id, - role: session.user.role, - recommendationCount: myRecommendationCount, - maxRecommendationCount: MAX_RECOMMENDATIONS_PER_EDITOR, - }, - }) - } catch (error) { - console.error("Failed to fetch editor recommendations", error) - return NextResponse.json({ error: "Failed to fetch recommendations" }, { status: 500 }) - } -} - -export async function POST(req: Request) { - const session = await getServerSession(authOptions) - if (!session || !isAllowedModerator(session.user.role)) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) - } - - try { - const body = await req.json() - const novelId = normalizeText(body?.novelId) - - if (!novelId) { - return NextResponse.json({ error: "Thiếu ID truyện" }, { status: 400 }) - } - - const existedNovel = await prisma.novel.findUnique({ - where: { id: novelId }, - select: { id: true }, - }) - - if (!existedNovel) { - return NextResponse.json({ error: "Truyện không tồn tại" }, { status: 404 }) - } - - await connectToMongoDB() - - const existing = (await EditorRecommendation.findOne({ - novelId, - editorId: session.user.id, - }) - .select({ _id: 1 }) - .lean()) as { _id: any } | null - - if (existing) { - return NextResponse.json({ error: "Bạn đã đề cử truyện này rồi" }, { status: 409 }) - } - - const myRecommendationCount = await EditorRecommendation.countDocuments({ - editorId: session.user.id, - }) - - if (myRecommendationCount >= MAX_RECOMMENDATIONS_PER_EDITOR) { - return NextResponse.json( - { error: `Mỗi biên tập viên chỉ được đề cử tối đa ${MAX_RECOMMENDATIONS_PER_EDITOR} truyện` }, - { status: 400 } - ) - } - - try { - const created = await EditorRecommendation.create({ - novelId, - editorId: session.user.id, - }) - - return NextResponse.json( - { - id: String(created._id), - novelId, - editorId: session.user.id, - }, - { status: 201 } - ) - } catch (error: any) { - if (error?.code === 11000) { - return NextResponse.json({ error: "Bạn đã đề cử truyện này rồi" }, { status: 409 }) - } - throw error - } - } catch (error) { - console.error("Failed to create editor recommendation", error) - return NextResponse.json({ error: "Failed to create recommendation" }, { status: 500 }) - } -} - -export async function DELETE(req: Request) { - const session = await getServerSession(authOptions) - if (!session || !isAllowedModerator(session.user.role)) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) - } - - try { - const url = new URL(req.url) - const id = normalizeText(url.searchParams.get("id")) - - if (!id) { - return NextResponse.json({ error: "Thiếu ID đề cử" }, { status: 400 }) - } - - await connectToMongoDB() - - const existed = (await EditorRecommendation.findById(id).lean()) as { - _id: any - editorId: string - } | null - - if (!existed) { - return NextResponse.json({ error: "Đề cử không tồn tại" }, { status: 404 }) - } - - if (session.user.role !== "ADMIN" && existed.editorId !== session.user.id) { - return NextResponse.json({ error: "Bạn không thể xóa đề cử của người khác" }, { status: 403 }) - } - - await EditorRecommendation.deleteOne({ _id: id }) - return NextResponse.json({ success: true }) - } catch (error) { - console.error("Failed to delete editor recommendation", error) - return NextResponse.json({ error: "Failed to delete recommendation" }, { status: 500 }) - } -} diff --git a/app/api/mod/epub/route.ts b/app/api/mod/epub/route.ts deleted file mode 100644 index e837b6a..0000000 --- a/app/api/mod/epub/route.ts +++ /dev/null @@ -1,1317 +0,0 @@ -import { NextResponse } from "next/server" -import { getServerSession } from "next-auth/next" -import { authOptions } from "@/lib/auth" -import { prisma } from "@/lib/prisma" -import connectToMongoDB from "@/lib/mongoose" -import { Chapter } from "@/lib/models/chapter" -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" -import { deleteR2ObjectByUrl, uploadBufferToR2 } from "@/lib/r2" - -export const maxDuration = 900 - -type SplitMode = "toc" | "regex" -type SeriesMode = "none" | "existing" | "new" - -type UserRole = "USER" | "MOD" | "ADMIN" - -interface EpubSection { - sourceTitle: string - content: string -} - -interface ParsedChapter { - title: string - content: string - detectedChapterNumber: number | null - finalNumber?: number - volumeNumber: number | null - volumeTitle: string | null - volumeChapterNumber: number | null - isPlaceholder?: boolean -} - -interface EpubCoverAsset { - buffer: Buffer | null - mimeType: string | null - sourceId: string | null -} - -type EpubCtor = new (epubfile: string, imagewebroot: string, chapterwebroot: string) => any - -function sanitizeEpubNavMapBranch(branch: any): any { - if (Array.isArray(branch)) { - branch.forEach((item) => sanitizeEpubNavMapBranch(item)) - return branch - } - - if (!branch || typeof branch !== "object") { - return branch - } - - if ("navLabel" in branch) { - const navLabel = (branch as any).navLabel - - if (typeof navLabel === "string") { - ;(branch as any).navLabel = navLabel - } else if (navLabel && typeof navLabel === "object") { - if (typeof navLabel.text === "string") { - ;(branch as any).navLabel = navLabel.text - } else if (navLabel.text !== undefined && navLabel.text !== null) { - ;(branch as any).navLabel = String(navLabel.text) - } else { - ;(branch as any).navLabel = "" - } - } else if (navLabel === null || navLabel === undefined) { - ;(branch as any).navLabel = "" - } else { - ;(branch as any).navLabel = String(navLabel) - } - } - - if ((branch as any).navPoint) { - sanitizeEpubNavMapBranch((branch as any).navPoint) - } - - return branch -} - -function getPatchedEpubCtor(): EpubCtor { - const loaded = require("epub2") - const EPub = (loaded?.EPub || loaded) as EpubCtor - const proto = (EPub as any)?.prototype - - if (proto && !proto.__readerSafeNavLabelPatchApplied && typeof proto.walkNavMap === "function") { - const originalWalkNavMap = proto.walkNavMap - - proto.walkNavMap = function patchedWalkNavMap(branch: any, ...args: any[]) { - const safeBranch = sanitizeEpubNavMapBranch(branch) - return originalWalkNavMap.call(this, safeBranch, ...args) - } - - proto.__readerSafeNavLabelPatchApplied = true - } - - return EPub -} - -function isRequestAbortedError(error: unknown): boolean { - if (!error) return false - - const candidate = error as { code?: unknown; message?: unknown; name?: unknown } - const code = typeof candidate.code === "string" ? candidate.code.toUpperCase() : "" - const message = typeof candidate.message === "string" ? candidate.message.toLowerCase() : "" - const name = typeof candidate.name === "string" ? candidate.name.toLowerCase() : "" - - return ( - code === "ECONNRESET" || - code === "ABORT_ERR" || - message.includes("aborted") || - message.includes("connection reset") || - name.includes("abort") - ) -} - -const CHAPTER_REGEX_PRESETS: Record = { - vi_chuong_hoi: "^(?:Chương|Hồi|Tiết|Phần|Thứ|Quyển|Ch\\.)\\s*\\d+(?:\\.\\d+)?[^\\n]*$", - mix_chapter: "^(?:Chương|Hồi|Tiết|Phần|Thứ|Quyển|Chapter|Ch\\.)\\s*\\d+(?:\\.\\d+)?[^\\n]*$", - numeric_only: "^\\d+(?:\\.\\d+)?\\s*[\\.\\:\\-\\]\\)]?(?:\\s+|$)[^\\n]*$", -} - -const LEGACY_CHAPTER_REGEX_PRESET_ALIASES: Record = { - vi_chuong: "vi_chuong_hoi", - en_chapter: "mix_chapter", - bracket_chapter: "mix_chapter", -} - -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\.|hồi|tiết|phần|thứ|quyển)\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\.|hồi|tiết|phần|thứ|quyển)\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\.|hồi|tiết|phần|thứ|quyển)?\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)) { - const first = value.find((v) => typeof v === "string" && v.trim().length > 0) - if (first) return first.trim() - } - 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)[key], rawValues) - } - - const uniqueNames: string[] = [] - const seen = new Set() - - 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 { - 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 - const parsed = Number(matched[1]) - return Number.isFinite(parsed) ? parsed : null -} - -function extractChapterNumber(title: string): number | null { - const matched = title.match(/(?:ch(?:ương|apter)?|ch\.)\s*([0-9]+(?:\.[0-9]+)?)/i) - if (!matched) return null - const parsed = Number(matched[1]) - return Number.isFinite(parsed) ? parsed : null -} - -function extractStrictChapterNumber(title: string): number | null { - const number = extractChapterNumber(title) - if (number === null) return null - if (!Number.isInteger(number)) return null - if (number <= 0) return null - if (number > 50000) return null - return number -} - -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) - 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) { - 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 isSimpleBaseTitle = SIMPLE_CHAPTER_TITLE_REGEX.test(baseTitle) - - if (!isSimpleBaseTitle) { - return { - title, - content, - detectedChapterNumber: baseDetectedChapterNumber, - } - } - - let nextTitle = baseTitle - - // Case 1: The first line already contains full chapter heading, use it directly. - if (/^(?:ch(?:ương|apter)?|ch\.)\s*\d+/i.test(firstLine) && firstLine.length > baseTitle.length + 2) { - nextTitle = firstLine - } else if (!SIMPLE_CHAPTER_TITLE_REGEX.test(firstLine) && !isVolumeHeading(firstLine) && !NOISE_TITLE_REGEX.test(firstLine)) { - // Case 2: TOC title is only "Chương N", subtitle is on next line. - nextTitle = `${baseTitle.replace(/[:\s]+$/g, "")}: ${firstLine}` - } else { - return { - title, - content, - detectedChapterNumber: baseDetectedChapterNumber, - } - } - - const newLines = [...lines] - newLines.splice(firstNonEmptyLineIndex, 1) - const nextContent = newLines.join("\n").trim() - - return { - title: nextTitle, - content: nextContent.length > 0 ? nextContent : content, - detectedChapterNumber: extractStrictChapterNumber(nextTitle) ?? baseDetectedChapterNumber, - } -} - -function isVolumeHeading(title: string): boolean { - return /^(?:quy[eê]n|vol(?:ume)?|t[aạ]p|book|arc|hồi)\s*[0-9]+(?:\s*[:-].*)?$/i.test(title.trim()) -} - -function normalizeSplitMode(value: FormDataEntryValue | null): SplitMode { - return value === "regex" ? "regex" : "toc" -} - -function normalizeSeriesMode(value: FormDataEntryValue | null): SeriesMode { - if (value === "existing") return "existing" - if (value === "new") return "new" - return "none" -} - -function readFormText(formData: FormData, key: string): string { - const value = formData.get(key) - return typeof value === "string" ? value.trim() : "" -} - -async function resolveSeriesIdForEpubImport(options: { - mode: SeriesMode - seriesId: string - seriesName: string - userRole: "USER" | "MOD" | "ADMIN" - userId: string -}) { - if (options.mode === "none") return null - - if (options.mode === "existing") { - if (!options.seriesId) { - throw new Error("Thiếu series để thêm vào") - } - - const targetSeries = await prisma.series.findFirst({ - where: options.userRole === "ADMIN" - ? { id: options.seriesId } - : { - id: options.seriesId, - OR: [ - { novels: { some: { uploaderId: options.userId } } }, - { novels: { some: { uploaderId: null } } }, - { novels: { none: {} } }, - ], - }, - select: { id: true }, - }) - - if (!targetSeries) { - throw new Error("Series không tồn tại hoặc không đủ quyền") - } - - return targetSeries.id - } - - if (!options.seriesName) { - throw new Error("Thiếu tên series mới") - } - - const existed = await prisma.series.findFirst({ - where: { name: { equals: options.seriesName, mode: "insensitive" } }, - select: { id: true }, - }) - - if (existed) return existed.id - - const baseSlug = slugify(options.seriesName) - let slug = baseSlug - let counter = 1 - - while (await prisma.series.findUnique({ where: { slug } })) { - slug = `${baseSlug}-${counter}` - counter += 1 - } - - const created = await prisma.series.create({ - data: { - name: options.seriesName, - slug, - }, - select: { id: true }, - }) - - return created.id -} - -function resolveRegexPattern(formData: FormData): { regexInput: string; regexPreset: string | null } { - const preset = readFormText(formData, "chapterRegexPreset") - const custom = readFormText(formData, "chapterRegex") - - if (custom) { - return { regexInput: custom, regexPreset: preset || "custom" } - } - - if (preset) { - const normalizedPreset = (CHAPTER_REGEX_PRESETS[preset] ? preset : LEGACY_CHAPTER_REGEX_PRESET_ALIASES[preset]) || null - if (normalizedPreset && CHAPTER_REGEX_PRESETS[normalizedPreset]) { - return { regexInput: CHAPTER_REGEX_PRESETS[normalizedPreset], regexPreset: normalizedPreset } - } - } - - return { regexInput: CHAPTER_REGEX_PRESETS.vi_chuong_hoi, regexPreset: "vi_chuong_hoi" } -} - -function buildRegexFromInput(regexInput: string): { regex: RegExp; normalized: string } { - if (!regexInput || regexInput.length > 300) { - throw new Error("Regex không hợp lệ") - } - - let pattern = regexInput - let flags = "" - - const slashWrapped = regexInput.match(/^\/(.+)\/([gimsuy]*)$/) - if (slashWrapped) { - pattern = slashWrapped[1] - flags = slashWrapped[2] - } - - const flagSet = new Set(flags.split("")) - flagSet.add("g") - flagSet.add("m") - flagSet.add("i") - const normalizedFlags = Array.from(flagSet).join("") - const regex = new RegExp(pattern, normalizedFlags) - - return { regex, normalized: `/${pattern}/${normalizedFlags}` } -} - -function enrichVolumeMetadata(chapters: Array<{ title: string; content: string }>): ParsedChapter[] { - let currentVolumeNumber: number | null = null - let currentVolumeTitle: string | null = null - let volumeChapterCounter = 0 - - return chapters.map((chapter) => { - const title = chapter.title.trim() - - const explicitVolumeNumber = extractVolumeNumber(title) - if (explicitVolumeNumber !== null) { - if (currentVolumeNumber !== explicitVolumeNumber) { - volumeChapterCounter = 0 - } - - currentVolumeNumber = explicitVolumeNumber - currentVolumeTitle = isVolumeHeading(title) - ? title - : (currentVolumeTitle || `Quyển ${explicitVolumeNumber}`) - } - - const explicitChapterNumber = extractChapterNumber(title) - let volumeChapterNumber: number | null = null - - if (currentVolumeNumber !== null) { - if (explicitChapterNumber !== null) { - volumeChapterCounter = explicitChapterNumber - } else { - volumeChapterCounter += 1 - } - volumeChapterNumber = volumeChapterCounter - } - - return { - title, - content: chapter.content, - detectedChapterNumber: extractStrictChapterNumber(title), - volumeNumber: currentVolumeNumber, - volumeTitle: currentVolumeTitle, - volumeChapterNumber, - } - }) -} - -function buildChaptersFromTOCSections(sections: EpubSection[]): ParsedChapter[] { - const chapters: ParsedChapter[] = [] - - let currentVolumeNumber: number | null = null - let currentVolumeTitle: string | null = null - let currentVolumeChapterCounter = 0 - let fallbackVolumeCounter = 0 - - for (let i = 0; i < sections.length; i++) { - const section = sections[i] - const rawTitle = section.sourceTitle || `Chương ${i + 1}` - const cleanTitle = rawTitle.replace(/\s+/g, " ").trim() - const cleanContent = section.content.trim() - - if (!cleanContent) continue - - if (isVolumeHeading(cleanTitle)) { - const extracted = extractVolumeNumber(cleanTitle) - if (extracted !== null) { - currentVolumeNumber = extracted - } else { - fallbackVolumeCounter += 1 - currentVolumeNumber = fallbackVolumeCounter - } - - currentVolumeTitle = cleanTitle - currentVolumeChapterCounter = 0 - - if (cleanContent.length <= 240) { - continue - } - } - - if (NOISE_TITLE_REGEX.test(cleanTitle) && cleanContent.length <= 240) { - continue - } - - const explicitVolumeFromTitle = extractVolumeNumber(cleanTitle) - if (explicitVolumeFromTitle !== null) { - if (currentVolumeNumber !== explicitVolumeFromTitle) { - currentVolumeChapterCounter = 0 - } - currentVolumeNumber = explicitVolumeFromTitle - if (!currentVolumeTitle || isVolumeHeading(cleanTitle)) { - currentVolumeTitle = `Quyển ${explicitVolumeFromTitle}` - } - } - - const enhanced = enhanceChapterTitleFromContent(cleanTitle, cleanContent) - let volumeChapterNumber: number | null = null - const detectedChapterNumber = enhanced.detectedChapterNumber - if (currentVolumeNumber !== null) { - const explicitChapter = extractChapterNumber(enhanced.title) - if (explicitChapter !== null) { - currentVolumeChapterCounter = explicitChapter - } else { - currentVolumeChapterCounter += 1 - } - volumeChapterNumber = currentVolumeChapterCounter - } - - chapters.push({ - title: enhanced.title, - content: enhanced.content, - detectedChapterNumber, - volumeNumber: currentVolumeNumber, - volumeTitle: currentVolumeTitle, - volumeChapterNumber, - }) - } - - return chapters -} - -function buildChaptersFromRegexSections(sections: EpubSection[], regex: RegExp): ParsedChapter[] { - const combinedText = sections - .map((section) => section.content.trim()) - .filter(Boolean) - .join("\n\n") - - const matches = Array.from(combinedText.matchAll(regex)) - if (matches.length === 0) { - return [] - } - - const parsed: Array<{ title: string; content: string }> = [] - - for (let i = 0; i < matches.length; i++) { - const match = matches[i] - if (match.index === undefined) continue - - const nextMatch = matches[i + 1] - const headingRaw = (match[1] || match[0] || "").replace(/\s+/g, " ").trim() - const sectionStart = match.index + match[0].length - const sectionEnd = nextMatch?.index ?? combinedText.length - const body = combinedText.slice(sectionStart, sectionEnd).trim() - - if (!headingRaw || body.length === 0) { - continue - } - - const enhanced = enhanceChapterTitleFromContent(headingRaw, body) - - parsed.push({ - title: enhanced.title, - content: enhanced.content, - }) - } - - return enrichVolumeMetadata(parsed) -} - -function trimLeadingBeforeChapterOne(chapters: ParsedChapter[]): { - chapters: ParsedChapter[] - trimmedCount: number -} { - if (chapters.length === 0) { - return { chapters, trimmedCount: 0 } - } - - const firstChapterOneIndex = chapters.findIndex((chapter) => { - const detected = - chapter.detectedChapterNumber ?? - extractStrictChapterNumber(chapter.title) ?? - extractChapterNumber(chapter.title) - - return detected === 1 - }) - - if (firstChapterOneIndex <= 0) { - return { chapters, trimmedCount: 0 } - } - - return { - chapters: chapters.slice(firstChapterOneIndex), - trimmedCount: firstChapterOneIndex, - } -} - -function withMissingChapterPlaceholders(chapters: ParsedChapter[]): { - chapters: ParsedChapter[] - insertedCount: number - detectedMax: number - detectedNumberAssignments: number -} { - const detectedNumbers = chapters - .map((chapter) => chapter.detectedChapterNumber) - .filter((n): n is number => typeof n === "number" && Number.isInteger(n) && n > 0) - - let insertedCount = 0 - let detectedNumberAssignments = 0 - let currentNumber = 0 - const maxDetected = detectedNumbers.length > 0 ? Math.max(...detectedNumbers) : chapters.length - const normalized: ParsedChapter[] = [] - const MAX_ALLOWED_GAP = 40 - - for (const chapter of chapters) { - const detected = chapter.detectedChapterNumber - const detectedNumber = typeof detected === "number" ? detected : null - let canUseDetected = - detectedNumber !== null && - detectedNumber > currentNumber && - (currentNumber === 0 || detectedNumber - currentNumber <= MAX_ALLOWED_GAP) - - // 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 && (currentNumber === 0 || detectedNumber - currentNumber <= MAX_ALLOWED_GAP) - } - - if (canUseDetected && detectedNumber !== null) { - if (currentNumber > 0) { - for (let missing = currentNumber + 1; missing < detectedNumber; missing++) { - insertedCount += 1 - normalized.push({ - title: `Chương ${missing} (Thiếu)`, - content: `[THIEU CHUONG ${missing}]\n\nNoi dung chuong nay dang thieu tu EPUB goc. Vui long bo sung sau.`, - detectedChapterNumber: missing, - finalNumber: missing, - volumeNumber: null, - volumeTitle: null, - volumeChapterNumber: null, - isPlaceholder: true, - }) - } - } - - detectedNumberAssignments += 1 - currentNumber = detectedNumber - normalized.push({ - ...chapter, - finalNumber: currentNumber, - volumeChapterNumber: chapter.volumeChapterNumber, - }) - continue - } - - currentNumber += 1 - normalized.push({ - ...chapter, - finalNumber: currentNumber, - volumeChapterNumber: chapter.volumeChapterNumber, - }) - } - - return { - chapters: normalized, - insertedCount, - detectedMax: maxDetected, - detectedNumberAssignments, - } -} - -async function extractCoverFromEpub(epub: any): Promise { - const manifest = epub.manifest || {} - const metadataCover = epub.metadata?.cover ? String(epub.metadata.cover) : null - - const candidateIds: string[] = [] - if (metadataCover) candidateIds.push(metadataCover) - - for (const [key, value] of Object.entries(manifest)) { - const item = value as any - const id = String(item?.id || key) - const href = String(item?.href || "") - const mediaType = String(item?.mediaType || item?.["media-type"] || "") - const properties = String(item?.properties || "") - - if ( - /cover-image/i.test(properties) || - /cover/i.test(id) || - /cover/i.test(href) - ) { - candidateIds.push(id) - continue - } - - if (/image\//i.test(mediaType) && /cover/i.test(href)) { - candidateIds.push(id) - } - } - - const uniqueCandidateIds = Array.from(new Set(candidateIds.filter(Boolean))) - if (uniqueCandidateIds.length === 0) { - return { buffer: null, mimeType: null, sourceId: null } - } - - for (const id of uniqueCandidateIds) { - const fromImage = await new Promise((resolve) => { - if (typeof epub.getImage !== "function") { - resolve({ buffer: null, mimeType: null, sourceId: null }) - return - } - - epub.getImage(id, (err: any, data: any, mimeType?: string) => { - if (err || !data) { - resolve({ buffer: null, mimeType: null, sourceId: null }) - return - } - - const buffer = Buffer.isBuffer(data) ? data : Buffer.from(data) - resolve({ buffer, mimeType: typeof mimeType === "string" ? mimeType : null, sourceId: id }) - }) - }) - - if (fromImage.buffer) { - return fromImage - } - - const fromFile = await new Promise((resolve) => { - if (typeof epub.getFile !== "function") { - resolve({ buffer: null, mimeType: null, sourceId: null }) - return - } - - epub.getFile(id, (err: any, data: any, mimeType?: string) => { - if (err || !data) { - resolve({ buffer: null, mimeType: null, sourceId: null }) - return - } - - const buffer = Buffer.isBuffer(data) ? data : Buffer.from(data) - resolve({ buffer, mimeType: typeof mimeType === "string" ? mimeType : null, sourceId: id }) - }) - }) - - if (fromFile.buffer) { - return fromFile - } - } - - return { buffer: null, mimeType: null, sourceId: null } -} - -async function saveCoverBufferToR2(cover: EpubCoverAsset): Promise { - if (!cover.buffer) return null - - return uploadBufferToR2({ - buffer: cover.buffer, - contentType: cover.mimeType, - keyPrefix: "covers/epub", - fileNameHint: cover.sourceId || undefined, - }) -} - -async function parseEpubSections(tempFilePath: string): Promise<{ metadata: any; sections: EpubSection[]; cover: EpubCoverAsset }> { - return new Promise((resolve, reject) => { - const EPub = getPatchedEpubCtor() - const epub = new EPub(tempFilePath, "", "") - - epub.on("error", (err: any) => reject(err)) - epub.on("end", async () => { - try { - const metadata = epub.metadata - const flow = epub.flow - const sections: EpubSection[] = [] - const cover = await extractCoverFromEpub(epub) - - for (let i = 0; i < flow.length; i++) { - const item = flow[i] - const text = await new Promise((res) => { - epub.getChapter(item.id, (err: any, data: string) => { - if (err) res("") - else res(data) - }) - }) - - if (!text || text.trim().length === 0) continue - - const plainText = convert(text, { wordwrap: false }).trim() - if (!plainText) continue - - sections.push({ - sourceTitle: item.title || `Mục ${i + 1}`, - content: plainText, - }) - } - - resolve({ metadata, sections, cover }) - } catch (err) { - reject(err) - } - }) - - epub.parse() - }) -} - -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 formData = await req.formData() - const epubFile = formData.get("file") as File - const previewOnly = String(formData.get("preview") || "").toLowerCase() === "true" - const splitMode = normalizeSplitMode(formData.get("splitMode")) - const seriesMode = normalizeSeriesMode(formData.get("seriesMode")) - const seriesIdInput = readFormText(formData, "seriesId") - const seriesNameInput = readFormText(formData, "seriesName") - const replaceExisting = String(formData.get("replaceExisting") || "").toLowerCase() === "true" - const appendTargetNovelId = String(formData.get("appendTargetNovelId") || "").trim() - - if (!epubFile) { - return NextResponse.json({ error: "Thiếu file EPUB" }, { status: 400 }) - } - - const buffer = Buffer.from(await epubFile.arrayBuffer()) - const tempFilePath = path.join(os.tmpdir(), `upload-${Date.now()}.epub`) - await fs.writeFile(tempFilePath, buffer) - - let parsedData: any = null - try { - const { metadata, sections, cover } = await parseEpubSections(tempFilePath) - - let regexNormalized: string | null = null - let regexPreset: string | null = null - let chapters: ParsedChapter[] = [] - - if (splitMode === "regex") { - const regexResolved = resolveRegexPattern(formData) - const compiled = buildRegexFromInput(regexResolved.regexInput) - chapters = buildChaptersFromRegexSections(sections, compiled.regex) - regexNormalized = compiled.normalized - regexPreset = regexResolved.regexPreset - - if (chapters.length === 0) { - return NextResponse.json( - { - error: "Regex không tách được chương nào. Hãy thử regex khác hoặc chuyển về TOC.", - parserInfo: { - splitMode, - chapterRegexUsed: regexNormalized, - regexPreset, - sourceSections: sections.length, - chaptersDetected: 0, - } - }, - { status: 400 } - ) - } - } else { - chapters = buildChaptersFromTOCSections(sections) - if (chapters.length === 0) { - return NextResponse.json( - { error: "Không tìm thấy chương hợp lệ từ TOC. Bạn có thể thử chế độ Regex." }, - { status: 400 } - ) - } - } - - const leadingTrimmed = trimLeadingBeforeChapterOne(chapters) - const gapFilled = withMissingChapterPlaceholders(leadingTrimmed.chapters) - - parsedData = { - metadata, - sections, - chapters: gapFilled.chapters, - cover, - parserInfo: { - splitMode, - chapterRegexUsed: regexNormalized, - regexPreset, - sourceSections: sections.length, - chaptersDetected: chapters.length, - trimmedBeforeChapterOne: leadingTrimmed.trimmedCount, - chaptersFinal: gapFilled.chapters.length, - insertedMissingChapters: gapFilled.insertedCount, - detectedMaxChapterNumber: gapFilled.detectedMax, - detectedNumberAssignments: gapFilled.detectedNumberAssignments, - } - } - } finally { - // Xóa file tạm - await fs.unlink(tempFilePath).catch(() => { }) - } - - const { metadata, chapters, parserInfo, cover } = parsedData - - const metadataTitle = normalizeMetaText(metadata?.title, "Truyện chưa đặt tên") - 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) - - if (previewOnly) { - return NextResponse.json({ - preview: true, - fileName: epubFile.name, - splitMode, - detectedStructureType: hasDetectedVolumes ? "light_novel" : "standard", - parserInfo, - hasCoverFromEpub: !!cover?.buffer, - novel: { - title: novelTitle, - authorName: novelAuthor, - description: novelDesc, - detectedGenres: detectedGenreNames, - totalChapters: chapters.length, - }, - chaptersPreview: chapters.slice(0, 20).map((ch: any, i: number) => ({ - number: ch.finalNumber || i + 1, - title: ch.title, - isPlaceholder: !!ch.isPlaceholder, - volumeNumber: ch.volumeNumber, - volumeTitle: ch.volumeTitle, - volumeChapterNumber: ch.volumeChapterNumber, - excerpt: (ch.content || "").slice(0, 180), - })), - }) - } - - let targetNovelId = "" - let responseStatus = 201 - let replaced = false - let isAppending = !!appendTargetNovelId - let finalCoverUrl: string | null = null - - if (isAppending) { - const targetNovel = await prisma.novel.findUnique({ where: { id: appendTargetNovelId } }) - if (!targetNovel || !canReplaceNovelByRole(session.user.role as UserRole, session.user.id, targetNovel)) { - return NextResponse.json({ error: "Truyện không tồn tại hoặc không đủ quyền thao tác báo cáo bổ sung." }, { status: 403 }) - } - targetNovelId = targetNovel.id - responseStatus = 200 - finalCoverUrl = targetNovel.coverUrl - } else { - 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, - seriesName: seriesNameInput, - userRole: session.user.role, - userId: session.user.id, - }) - - const coverUrl = await saveCoverBufferToR2(cover) - finalCoverUrl = coverUrl - targetNovelId = duplicatedNovel?.id || "" - - 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 { - const baseSlug = slugify(novelTitle) - let slug = baseSlug - let slugCounter = 1 - while (await prisma.novel.findUnique({ where: { slug } })) { - slug = `${baseSlug}-${slugCounter}` - slugCounter++ - } - - const createData: any = { - title: novelTitle, - 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 - } - } - - await connectToMongoDB() - - let insertedCount = 0 - let updatedCount = 0 - - if (!isAppending) { - await Chapter.deleteMany({ novelId: targetNovelId }) - const finalChapterDocs = chapters.map((ch: any, i: number) => ({ - 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, - })) - if (finalChapterDocs.length > 0) { - await Chapter.insertMany(finalChapterDocs) - insertedCount = finalChapterDocs.length - } - } else { - const bulkOps = chapters.map((ch: any) => { - const candidateNumber = ch.detectedChapterNumber || ch.finalNumber - return { - updateOne: { - filter: { novelId: targetNovelId, number: candidateNumber }, - update: { - $set: { - volumeNumber: ch.volumeNumber ?? null, - volumeTitle: ch.volumeTitle ?? null, - volumeChapterNumber: ch.volumeChapterNumber ?? null, - title: ch.title, - content: ch.content, - }, - }, - upsert: true, - } - } - }) - - if (bulkOps.length > 0) { - const writeResult = await Chapter.bulkWrite(bulkOps) - insertedCount = writeResult.upsertedCount || 0 - updatedCount = writeResult.modifiedCount || 0 - } - - const totalAfterMerge = await Chapter.countDocuments({ novelId: targetNovelId }) - await prisma.novel.update({ - where: { id: targetNovelId }, - data: { totalChapters: totalAfterMerge }, - }) - } - - 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({ - ...novelAfterWrite, - parserInfo, - hasCoverFromEpub: !!finalCoverUrl, - detectedGenres: detectedGenreNames, - replaced, - }, { status: responseStatus }) - } catch (error: any) { - if (isRequestAbortedError(error)) { - console.warn("EPUB upload aborted by client or network interruption") - return NextResponse.json({ error: "Kết nối upload bị ngắt trong lúc xử lý" }, { status: 499 }) - } - - console.error("EPUB upload error:", error) - return NextResponse.json({ error: "Lỗi xử lý file EPUB", details: error.message }, { status: 500 }) - } -} diff --git a/app/api/mod/series/route.ts b/app/api/mod/series/route.ts deleted file mode 100644 index 7ec2eb4..0000000 --- a/app/api/mod/series/route.ts +++ /dev/null @@ -1,200 +0,0 @@ -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" - -function normalizeText(value: any): string { - return typeof value === "string" ? value.trim() : "" -} - -async function resolveEditableSeries( - id: string, - session: { user: { role: "USER" | "MOD" | "ADMIN"; id: string } } -) { - return prisma.series.findFirst({ - where: session.user.role === "ADMIN" - ? { id } - : { - id, - OR: [ - { novels: { some: { uploaderId: session.user.id } } }, - { novels: { some: { uploaderId: null } } }, - { novels: { none: {} } }, - ], - }, - select: { id: true }, - }) -} - -export async function GET() { - const session = await getServerSession(authOptions) - if (!session || (session.user.role !== "MOD" && session.user.role !== "ADMIN")) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) - } - - try { - const series = await prisma.series.findMany({ - where: session.user.role === "ADMIN" - ? undefined - : { - OR: [ - { novels: { some: { uploaderId: session.user.id } } }, - { novels: { some: { uploaderId: null } } }, - { novels: { none: {} } }, - ], - }, - orderBy: { updatedAt: "desc" }, - select: { - id: true, - name: true, - slug: true, - description: true, - _count: { select: { novels: true } }, - }, - }) - - return NextResponse.json(series) - } catch { - return NextResponse.json({ error: "Failed to fetch series" }, { status: 500 }) - } -} - -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 body = await req.json() - const name = normalizeText(body?.name) - const description = normalizeText(body?.description) - - if (!name) { - return NextResponse.json({ error: "Tên series không được để trống" }, { status: 400 }) - } - - const existing = await prisma.series.findFirst({ - where: { name: { equals: name, mode: "insensitive" } }, - select: { id: true, name: true, slug: true, description: true }, - }) - - if (existing) { - return NextResponse.json(existing) - } - - const baseSlug = slugify(name) - let slug = baseSlug - let counter = 1 - - while (await prisma.series.findUnique({ where: { slug } })) { - slug = `${baseSlug}-${counter}` - counter += 1 - } - - const created = await prisma.series.create({ - data: { name, slug, description: description || null }, - select: { id: true, name: true, slug: true, description: true }, - }) - - return NextResponse.json(created, { status: 201 }) - } catch { - return NextResponse.json({ error: "Failed to create series" }, { 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 body = await req.json() - const id = normalizeText(body?.id) - const name = normalizeText(body?.name) - const description = normalizeText(body?.description) - - if (!id || !name) { - return NextResponse.json({ error: "Thiếu thông tin series" }, { status: 400 }) - } - - const target = await resolveEditableSeries(id, session as any) - if (!target) { - return NextResponse.json({ error: "Không tìm thấy series hoặc không đủ quyền" }, { status: 404 }) - } - - const duplicated = await prisma.series.findFirst({ - where: { - id: { not: id }, - name: { equals: name, mode: "insensitive" }, - }, - select: { id: true }, - }) - - if (duplicated) { - return NextResponse.json({ error: "Tên series đã tồn tại" }, { status: 409 }) - } - - const baseSlug = slugify(name) - let slug = baseSlug - let counter = 1 - - while (await prisma.series.findFirst({ where: { slug, id: { not: id } }, select: { id: true } })) { - slug = `${baseSlug}-${counter}` - counter += 1 - } - - const updated = await prisma.series.update({ - where: { id }, - data: { - name, - slug, - description: description || null, - }, - select: { - id: true, - name: true, - slug: true, - description: true, - _count: { select: { novels: true } }, - }, - }) - - return NextResponse.json(updated) - } catch { - return NextResponse.json({ error: "Failed to update series" }, { 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 = normalizeText(url.searchParams.get("id")) - - if (!id) { - return NextResponse.json({ error: "Thiếu id series" }, { status: 400 }) - } - - const target = await resolveEditableSeries(id, session as any) - if (!target) { - return NextResponse.json({ error: "Không tìm thấy series hoặc không đủ quyền" }, { status: 404 }) - } - - const usedCount = await prisma.novel.count({ where: { seriesId: id } }) - if (usedCount > 0) { - return NextResponse.json({ error: "Series đang chứa truyện, không thể xóa" }, { status: 409 }) - } - - await prisma.series.delete({ where: { id } }) - return NextResponse.json({ success: true }) - } catch { - return NextResponse.json({ error: "Failed to delete series" }, { status: 500 }) - } -} diff --git a/app/api/mod/the-loai/route.ts b/app/api/mod/the-loai/route.ts deleted file mode 100644 index 9c04dee..0000000 --- a/app/api/mod/the-loai/route.ts +++ /dev/null @@ -1,71 +0,0 @@ -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 }) - } -} diff --git a/app/api/mod/truyen/[id]/route.ts b/app/api/mod/truyen/[id]/route.ts deleted file mode 100644 index 409400e..0000000 --- a/app/api/mod/truyen/[id]/route.ts +++ /dev/null @@ -1,45 +0,0 @@ -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.findFirst({ - where: session.user.role === "ADMIN" - ? { id } - : { - id, - OR: [ - { uploaderId: session.user.id }, - { uploaderId: null }, - ], - }, - include: { - series: true, - 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 }) - } -} diff --git a/app/api/mod/truyen/[id]/trash-words/route.ts b/app/api/mod/truyen/[id]/trash-words/route.ts deleted file mode 100644 index e16ad91..0000000 --- a/app/api/mod/truyen/[id]/trash-words/route.ts +++ /dev/null @@ -1,72 +0,0 @@ -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, { params }: { params: Promise<{ id: string }> }) { - const session = await getServerSession(authOptions) - if (!session || (session.user.role !== "MOD" && session.user.role !== "ADMIN")) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) - } - - const { id: novelId } = await params - - try { - const novel = await prisma.novel.findUnique({ - where: { id: novelId }, - select: { trashWords: true, uploaderId: true } - }) - - if (!novel) { - return NextResponse.json({ error: "Not found" }, { status: 404 }) - } - - if (session.user.role !== "ADMIN" && novel.uploaderId !== session.user.id) { - return NextResponse.json({ error: "Forbidden" }, { status: 403 }) - } - - return NextResponse.json({ trashWords: novel.trashWords }) - } catch (error) { - console.error("GET Trash Words Error:", error) - return NextResponse.json({ error: "Lỗi Server" }, { status: 500 }) - } -} - -export async function PUT(req: Request, { params }: { params: Promise<{ id: string }> }) { - const session = await getServerSession(authOptions) - if (!session || (session.user.role !== "MOD" && session.user.role !== "ADMIN")) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) - } - - const { id: novelId } = await params - - try { - const novel = await prisma.novel.findUnique({ - where: { id: novelId }, - select: { id: true, uploaderId: true } - }) - - if (!novel) return NextResponse.json({ error: "Not found" }, { status: 404 }) - - if (session.user.role !== "ADMIN" && novel.uploaderId !== session.user.id) { - return NextResponse.json({ error: "Forbidden" }, { status: 403 }) - } - - const body = await req.json() - const { trashWords } = body - - if (!Array.isArray(trashWords)) { - return NextResponse.json({ error: "Mảng từ rác không hợp lệ" }, { status: 400 }) - } - - const updated = await prisma.novel.update({ - where: { id: novelId }, - data: { trashWords } - }) - - return NextResponse.json({ success: true, trashWords: updated.trashWords }) - } catch (error) { - console.error("PUT Trash Words Error:", error) - return NextResponse.json({ error: "Lỗi Server" }, { status: 500 }) - } -} diff --git a/app/api/mod/truyen/bulk/route.ts b/app/api/mod/truyen/bulk/route.ts deleted file mode 100644 index 861f350..0000000 --- a/app/api/mod/truyen/bulk/route.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { NextResponse } from "next/server" -import { getServerSession } from "next-auth/next" -import { authOptions } from "@/lib/auth" -import { prisma } from "@/lib/prisma" -import connectToMongoDB from "@/lib/mongoose" -import { Chapter } from "@/lib/models/chapter" -import { deleteR2ObjectByUrl } from "@/lib/r2" - -function normalizeIds(value: any): string[] { - if (!Array.isArray(value)) return [] - return value.filter((item) => typeof item === "string" && item.trim()).map((item) => item.trim()) -} - -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 body = await req.json() - const action = typeof body?.action === "string" ? body.action : "" - const ids = normalizeIds(body?.ids) - - if (ids.length === 0) { - return NextResponse.json({ error: "Danh sách truyện trống" }, { status: 400 }) - } - - const accessibleNovels = await prisma.novel.findMany({ - where: session.user.role === "ADMIN" - ? { id: { in: ids } } - : { - id: { in: ids }, - OR: [ - { uploaderId: session.user.id }, - { uploaderId: null }, - ], - }, - select: { - id: true, - coverUrl: true, - }, - }) - - if (accessibleNovels.length === 0) { - return NextResponse.json({ error: "Không có truyện hợp lệ để thao tác" }, { status: 404 }) - } - - const accessibleIds = accessibleNovels.map((novel) => novel.id) - - if (action === "delete") { - await connectToMongoDB() - await Chapter.deleteMany({ novelId: { $in: accessibleIds } }) - - await prisma.novel.deleteMany({ - where: { id: { in: accessibleIds } }, - }) - - await Promise.all( - accessibleNovels.map((novel) => deleteR2ObjectByUrl(novel.coverUrl).catch(() => {})) - ) - - return NextResponse.json({ success: true, deletedCount: accessibleIds.length }) - } - - return NextResponse.json({ error: "Chỉ hỗ trợ xóa hàng loạt" }, { status: 400 }) - } catch { - return NextResponse.json({ error: "Bulk operation failed" }, { status: 500 }) - } -} diff --git a/app/api/mod/truyen/missing/route.ts b/app/api/mod/truyen/missing/route.ts deleted file mode 100644 index 84da5a7..0000000 --- a/app/api/mod/truyen/missing/route.ts +++ /dev/null @@ -1,289 +0,0 @@ -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, 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 = {} - - 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 }) - } -} diff --git a/app/api/mod/truyen/route.ts b/app/api/mod/truyen/route.ts deleted file mode 100644 index d8cdbd3..0000000 --- a/app/api/mod/truyen/route.ts +++ /dev/null @@ -1,320 +0,0 @@ -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" -import connectToMongoDB from "@/lib/mongoose" -import { Chapter } from "@/lib/models/chapter" -import { deleteR2ObjectByUrl } from "@/lib/r2" - -function normalizeOptionalText(value: any): string { - return typeof value === "string" ? value.trim() : "" -} - -async function resolveSeriesIdForWrite( - seriesIdInput: any, - seriesNameInput: any, - userRole: "USER" | "MOD" | "ADMIN", - userId: string -): Promise { - const seriesId = normalizeOptionalText(seriesIdInput) - const seriesName = normalizeOptionalText(seriesNameInput) - - if (seriesId) { - const series = await prisma.series.findFirst({ - where: userRole === "ADMIN" - ? { id: seriesId } - : { - id: seriesId, - OR: [ - { novels: { some: { uploaderId: userId } } }, - { novels: { some: { uploaderId: null } } }, - { novels: { none: {} } }, - ], - }, - select: { id: true }, - }) - - if (!series) { - throw new Error("Series không tồn tại hoặc bạn không có quyền sử dụng") - } - - return series.id - } - - if (!seriesName) return null - - const existingSeries = await prisma.series.findFirst({ - where: { name: { equals: seriesName, mode: "insensitive" } }, - select: { id: true }, - }) - - if (existingSeries) { - return existingSeries.id - } - - const baseSlug = slugify(seriesName) - let slug = baseSlug - let counter = 1 - - while (await prisma.series.findUnique({ where: { slug } })) { - slug = `${baseSlug}-${counter}` - counter += 1 - } - - const createdSeries = await prisma.series.create({ - data: { - name: seriesName, - slug, - }, - select: { id: true }, - }) - - return createdSeries.id -} - -export async function GET() { - const session = await getServerSession(authOptions) - if (!session || (session.user.role !== "MOD" && session.user.role !== "ADMIN")) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) - } - - try { - const novels = await prisma.novel.findMany({ - where: session.user.role === "ADMIN" - ? undefined - : { - OR: [ - { uploaderId: session.user.id }, - { uploaderId: null }, - ], - }, - include: { - series: { - select: { id: true, name: true, slug: true } - } - }, - orderBy: { updatedAt: "desc" }, - }) - return NextResponse.json(novels) - } catch (error) { - return NextResponse.json({ error: "Failed to fetch novels" }, { status: 500 }) - } -} - -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 { title, originalTitle, authorName, originalAuthorName, description, coverUrl, genreIds = [] } = data - const seriesId = await resolveSeriesIdForWrite(data?.seriesId, data?.seriesName, session.user.role, session.user.id) - // Tạo slug từ title - const slug = slugify(title) - - const newNovel = await prisma.novel.create({ - data: { - title, - originalTitle, - slug: slug, - authorName, - originalAuthorName, - description, - coverUrl, - seriesId, - uploaderId: session.user.id, - genres: { - create: genreIds.map((id: string) => ({ - genre: { connect: { id } } - })) - } - }, - }) - return NextResponse.json(newNovel, { status: 201 }) - } catch (error) { - 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, originalTitle, authorName, originalAuthorName, description, coverUrl, status, genreIds } = data - - if (!id) { - return NextResponse.json({ error: "Thiếu ID truyện" }, { status: 400 }) - } - - const hasField = (field: string) => Object.prototype.hasOwnProperty.call(data, field) - - const targetNovel = await prisma.novel.findFirst({ - where: session.user.role === "ADMIN" - ? { id } - : { - id, - OR: [ - { uploaderId: session.user.id }, - { uploaderId: null }, - ], - }, - select: { id: true, seriesId: true }, - }) - - if (!targetNovel) { - return NextResponse.json({ error: "Truyện không tồn tại hoặc không đủ quyền" }, { status: 404 }) - } - - // Disable editing series relation from novel edit form: keep current seriesId. - const fixedSeriesId = targetNovel.seriesId - - if (fixedSeriesId) { - const sharedData: Record = {} - if (hasField("originalTitle")) sharedData.originalTitle = originalTitle - if (hasField("authorName")) sharedData.authorName = authorName - if (hasField("originalAuthorName")) sharedData.originalAuthorName = originalAuthorName - if (hasField("description")) sharedData.description = description - if (hasField("status")) sharedData.status = status - - const ownData: Record = {} - if (hasField("title")) ownData.title = title - if (hasField("coverUrl")) ownData.coverUrl = coverUrl - if (session.user.role === "MOD") ownData.uploaderId = session.user.id - - const seriesNovels = await prisma.novel.findMany({ - where: { seriesId: fixedSeriesId }, - select: { id: true }, - }) - const seriesNovelIds = seriesNovels.map((novel) => novel.id) - - const updatedNovel = await prisma.$transaction(async (tx) => { - // Sync shared metadata for all novels in the same series. - if (Object.keys(sharedData).length > 0) { - await tx.novel.updateMany({ - where: { id: { in: seriesNovelIds } }, - data: sharedData, - }) - } - - if (genreIds !== undefined) { - await tx.novelGenre.deleteMany({ - where: { novelId: { in: seriesNovelIds } }, - }) - - if (genreIds.length > 0) { - await tx.novelGenre.createMany({ - data: seriesNovelIds.flatMap((novelId) => - genreIds.map((genreId: string) => ({ novelId, genreId })) - ), - }) - } - } - - // Only current novel keeps its own title and cover. - if (Object.keys(ownData).length === 0) { - return tx.novel.findUnique({ where: { id } }) - } - - return tx.novel.update({ - where: { id }, - data: ownData, - }) - }) - - return NextResponse.json(updatedNovel) - } - - const updateData: Record = { - seriesId: fixedSeriesId, - ...(session.user.role === "MOD" && { uploaderId: session.user.id }), - } - - if (hasField("title")) updateData.title = title - if (hasField("originalTitle")) updateData.originalTitle = originalTitle - if (hasField("authorName")) updateData.authorName = authorName - if (hasField("originalAuthorName")) updateData.originalAuthorName = originalAuthorName - if (hasField("description")) updateData.description = description - if (hasField("coverUrl")) updateData.coverUrl = coverUrl - if (hasField("status")) updateData.status = status - - const updatedNovel = await prisma.novel.update({ - where: { id }, - data: { - ...updateData, - ...(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 }) - - const novel = await prisma.novel.findFirst({ - where: session.user.role === "ADMIN" - ? { id } - : { - id, - OR: [ - { uploaderId: session.user.id }, - { uploaderId: null }, - ], - }, - select: { id: true, coverUrl: true, seriesId: true } - }) - - if (!novel) { - return NextResponse.json({ error: "Truyện không tồn tại hoặc không đủ quyền" }, { status: 404 }) - } - - await connectToMongoDB() - const chapterDeleteResult = await Chapter.deleteMany({ novelId: id }) - - await prisma.novel.delete({ - where: { id }, - }) - - await deleteR2ObjectByUrl(novel.coverUrl).catch(() => { }) - - if (novel.seriesId) { - const remainingSeriesNovels = await prisma.novel.count({ where: { seriesId: novel.seriesId } }) - if (remainingSeriesNovels === 0) { - await prisma.series.delete({ where: { id: novel.seriesId } }).catch(() => { }) - } - } - - return NextResponse.json({ - message: "Đã xóa truyện và toàn bộ chương thành công", - deletedChapters: chapterDeleteResult.deletedCount || 0 - }) - } catch (error) { - return NextResponse.json({ error: "Failed to delete novel" }, { status: 500 }) - } -} diff --git a/app/api/mod/upload-cover/route.ts b/app/api/mod/upload-cover/route.ts deleted file mode 100644 index 99e5723..0000000 --- a/app/api/mod/upload-cover/route.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { NextResponse } from "next/server" -import { getServerSession } from "next-auth/next" -import { authOptions } from "@/lib/auth" -import { uploadBufferToR2 } from "@/lib/r2" - -export async function POST(req: Request) { - try { - const session = await getServerSession(authOptions) - if (!session || (session.user.role !== "MOD" && session.user.role !== "ADMIN")) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) - } - - const formData = await req.formData() - const file = formData.get("file") as File | null - - if (!file) { - return NextResponse.json({ error: "No file uploaded" }, { status: 400 }) - } - - if (!file.type.startsWith("image/")) { - return NextResponse.json({ error: "Only image files are allowed" }, { status: 400 }) - } - - const bytes = await file.arrayBuffer() - const buffer = Buffer.from(bytes) - - const url = await uploadBufferToR2({ - buffer, - contentType: file.type, - keyPrefix: "covers/manual", - fileNameHint: file.name, - }) - - return NextResponse.json({ url }) - } catch (error: any) { - console.error("Cover upload error:", error) - return NextResponse.json({ error: error.message || "Failed to upload cover" }, { status: 500 }) - } -} diff --git a/app/api/truyen/[id]/chapters/route.ts b/app/api/truyen/[id]/chapters/route.ts deleted file mode 100644 index d7901f3..0000000 --- a/app/api/truyen/[id]/chapters/route.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { NextResponse } from "next/server" -import connectToMongoDB from "@/lib/mongoose" -import { Chapter } from "@/lib/models/chapter" - -export async function GET( - req: Request, - { params }: { params: Promise<{ id: string }> } // `id` is the `novel.id` -) { - try { - const { id: novelId } = await params - - const { searchParams } = new URL(req.url) - const page = parseInt(searchParams.get("page") || "1", 10) - const limit = parseInt(searchParams.get("limit") || "100", 10) - - await connectToMongoDB() - - const skip = (page - 1) * limit - - const [chapters, totalChapters] = await Promise.all([ - Chapter.find({ novelId }) - .sort({ number: 1 }) - .skip(skip) - .limit(limit) - .select("number title createdAt volumeNumber volumeTitle volumeChapterNumber") // don't return content - .lean(), - Chapter.countDocuments({ novelId }) - ]) - - return NextResponse.json({ - chapters: chapters.map(c => ({ - id: c._id.toString(), - number: c.number, - title: c.title, - volumeNumber: (c as any).volumeNumber ?? null, - volumeTitle: (c as any).volumeTitle ?? null, - volumeChapterNumber: (c as any).volumeChapterNumber ?? null, - createdAt: (c.createdAt as Date).toISOString() - })), - totalChapters, - totalPages: Math.ceil(totalChapters / limit), - currentPage: page - }) - - } catch (error: any) { - console.error("Fetch novel chapters error:", error) - return NextResponse.json( - { error: "Không thể lấy danh sách chương" }, - { status: 500 } - ) - } -} diff --git a/app/api/truyen/[id]/comments/route.ts b/app/api/truyen/[id]/comments/route.ts deleted file mode 100644 index 47c458c..0000000 --- a/app/api/truyen/[id]/comments/route.ts +++ /dev/null @@ -1,47 +0,0 @@ -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 }) - } -} diff --git a/app/api/truyen/[id]/rate/route.ts b/app/api/truyen/[id]/rate/route.ts deleted file mode 100644 index e883b80..0000000 --- a/app/api/truyen/[id]/rate/route.ts +++ /dev/null @@ -1,44 +0,0 @@ -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 }) - } -} diff --git a/app/api/truyen/suggest/route.ts b/app/api/truyen/suggest/route.ts deleted file mode 100644 index 6dfbbfb..0000000 --- a/app/api/truyen/suggest/route.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { NextResponse } from "next/server" -import { prisma } from "@/lib/prisma" - -export async function GET(req: Request) { - try { - const url = new URL(req.url) - const q = url.searchParams.get("q")?.trim() || "" - - if (q.length < 2) { - return NextResponse.json([]) - } - - const novels = await prisma.novel.findMany({ - where: { - OR: [ - { title: { contains: q, mode: "insensitive" } }, - { authorName: { contains: q, mode: "insensitive" } }, - { series: { name: { contains: q, mode: "insensitive" } } }, - ], - }, - select: { - id: true, - title: true, - slug: true, - authorName: true, - coverUrl: true, - series: { - select: { - id: true, - name: true, - }, - }, - }, - orderBy: [{ views: "desc" }, { updatedAt: "desc" }], - take: 8, - }) - - return NextResponse.json(novels) - } catch { - return NextResponse.json({ error: "Failed to fetch suggestions" }, { status: 500 }) - } -} diff --git a/app/api/user/bookmarks/route.ts b/app/api/user/bookmarks/route.ts deleted file mode 100644 index b809670..0000000 --- a/app/api/user/bookmarks/route.ts +++ /dev/null @@ -1,181 +0,0 @@ -import { NextResponse } from "next/server" -import { getServerSession } from "next-auth/next" -import { authOptions } from "@/lib/auth" -import { prisma } from "@/lib/prisma" - -function toUTCDateOnly(value: Date): Date { - return new Date(Date.UTC(value.getUTCFullYear(), value.getUTCMonth(), value.getUTCDate())) -} - -async function upsertDailyNovelView(novelId: string, day: Date) { - const delegate = (prisma as any).novelViewDaily - if (!delegate || typeof delegate.upsert !== "function") return - - await delegate.upsert({ - where: { - novelId_day: { - novelId, - day, - }, - }, - update: { - views: { increment: 1 }, - }, - create: { - novelId, - day, - views: 1, - }, - }) -} - -// 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 }) - } - - // Lấy bookmark cũ (nếu có) - const existingBookmark = await prisma.bookmark.findUnique({ - where: { - userId_novelId: { - userId: session.user.id, - novelId, - } - } - }) - - let newReadChapters: number[] = [] - let newHasCountedView = false - let shouldIncrementNovelView = false - - if (existingBookmark) { - newReadChapters = existingBookmark.readChapters || [] - newHasCountedView = existingBookmark.hasCountedView - - // Nếu chương này chưa đọc, thêm vào mảng - if (!newReadChapters.includes(lastChapterNumber)) { - newReadChapters.push(lastChapterNumber) - } - - // Nếu đọc đủ 5 chương và chưa từng đếm view - if (newReadChapters.length >= 5 && !newHasCountedView) { - newHasCountedView = true - shouldIncrementNovelView = true - } - } else { - newReadChapters = [lastChapterNumber] - // Chưa đủ 5 chương ngay từ lần đầu tạo - } - - const bookmark = await prisma.bookmark.upsert({ - where: { - userId_novelId: { - userId: session.user.id, - novelId, - } - }, - update: { - lastChapterId, - lastChapterNumber, - readChapters: newReadChapters, - hasCountedView: newHasCountedView - }, - create: { - userId: session.user.id, - novelId, - lastChapterId, - lastChapterNumber, - readChapters: newReadChapters, - hasCountedView: newHasCountedView - } - }) - - if (shouldIncrementNovelView) { - const day = toUTCDateOnly(new Date()) - await prisma.novel.update({ - where: { id: novelId }, - data: { views: { increment: 1 } } - }) - await upsertDailyNovelView(novelId, day) - } - - 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 }) - } -} diff --git a/app/api/user/recommendations/route.ts b/app/api/user/recommendations/route.ts deleted file mode 100644 index a02912a..0000000 --- a/app/api/user/recommendations/route.ts +++ /dev/null @@ -1,170 +0,0 @@ -import { NextResponse } from "next/server" -import { getServerSession } from "next-auth/next" -import { authOptions } from "@/lib/auth" -import { prisma } from "@/lib/prisma" -import connectToMongoDB from "@/lib/mongoose" -import { UserRecommendation } from "@/lib/models/user-recommendation" - -function normalizeText(value: unknown): string { - return typeof value === "string" ? value.trim() : "" -} - -export async function GET() { - try { - const session = await getServerSession(authOptions) - if (!session?.user?.id) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) - } - - await connectToMongoDB() - - const docs = (await UserRecommendation.find({ userId: session.user.id }) - .sort({ createdAt: -1 }) - .limit(1000) - .lean()) as Array<{ - _id: any - novelId: string - createdAt?: Date - }> - - const novelIds = Array.from(new Set(docs.map((doc) => doc.novelId).filter(Boolean))) - const novels = novelIds.length - ? await prisma.novel.findMany({ - where: { id: { in: novelIds } }, - select: { - id: true, - title: true, - slug: true, - authorName: true, - coverUrl: true, - status: true, - totalChapters: true, - }, - }) - : [] - - const novelMap = new Map(novels.map((novel) => [novel.id, novel])) - - const items = docs - .map((doc) => { - const novel = novelMap.get(doc.novelId) - if (!novel) return null - - return { - id: String(doc._id), - novelId: doc.novelId, - createdAt: doc.createdAt || null, - novel, - } - }) - .filter((item): item is NonNullable => Boolean(item)) - - return NextResponse.json(items) - } catch (error) { - console.error("Failed to fetch user recommendations", error) - return NextResponse.json({ error: "Failed to fetch recommendations" }, { status: 500 }) - } -} - -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 novelId = normalizeText(body?.novelId) - - if (!novelId) { - return NextResponse.json({ error: "Thiếu ID truyện" }, { status: 400 }) - } - - const novel = await prisma.novel.findUnique({ where: { id: novelId }, select: { id: true } }) - if (!novel) { - return NextResponse.json({ error: "Truyện không tồn tại" }, { status: 404 }) - } - - await connectToMongoDB() - - try { - const existing = (await UserRecommendation.findOne({ - userId: session.user.id, - novelId, - }) - .select({ _id: 1 }) - .lean()) as { _id: any } | null - - if (existing) { - return NextResponse.json({ error: "Bạn đã đề cử truyện này rồi" }, { status: 409 }) - } - - const created = await UserRecommendation.create({ - userId: session.user.id, - novelId, - }) - - await prisma.novel.update({ - where: { id: novelId }, - data: { bookmarkCount: { increment: 1 } }, - }) - - return NextResponse.json( - { - id: String(created._id), - novelId, - }, - { status: 201 } - ) - } catch (error: any) { - if (error?.code === 11000) { - return NextResponse.json({ error: "Bạn đã đề cử truyện này rồi" }, { status: 409 }) - } - throw error - } - } catch (error) { - console.error("Failed to create user recommendation", error) - return NextResponse.json({ error: "Failed to create recommendation" }, { status: 500 }) - } -} - -export async function DELETE(req: Request) { - try { - const session = await getServerSession(authOptions) - if (!session?.user?.id) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) - } - - const url = new URL(req.url) - const novelId = normalizeText(url.searchParams.get("novelId")) - - if (!novelId) { - return NextResponse.json({ error: "Thiếu ID truyện" }, { status: 400 }) - } - - await connectToMongoDB() - - const existing = (await UserRecommendation.findOne({ - userId: session.user.id, - novelId, - }) - .select({ _id: 1 }) - .lean()) as { _id: any } | null - - if (!existing) { - return NextResponse.json({ error: "Bạn chưa đề cử truyện này" }, { status: 404 }) - } - - await UserRecommendation.deleteOne({ _id: existing._id }) - - await prisma.novel.update({ - where: { id: novelId }, - data: { bookmarkCount: { decrement: 1 } }, - }) - - return NextResponse.json({ success: true }) - } catch (error) { - console.error("Failed to delete user recommendation", error) - return NextResponse.json({ error: "Failed to delete recommendation" }, { status: 500 }) - } -} diff --git a/app/api/user/settings/route.ts b/app/api/user/settings/route.ts deleted file mode 100644 index 2222b57..0000000 --- a/app/api/user/settings/route.ts +++ /dev/null @@ -1,59 +0,0 @@ -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() { - const session = await getServerSession(authOptions) - if (!session?.user?.id) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) - } - - try { - const settings = await prisma.userSetting.findUnique({ - where: { userId: session.user.id } - }) - - return NextResponse.json(settings || {}) - } catch (error) { - console.error("GET User Settings Error", error) - return NextResponse.json({ error: "Internal Server Error" }, { status: 500 }) - } -} - -export async function POST(req: Request) { - const session = await getServerSession(authOptions) - if (!session?.user?.id) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) - } - - try { - const body = await req.json() - const { fontSize, lineHeight, letterSpacing, fontFamily } = body - - const updateData: any = {} - if (fontSize !== undefined) updateData.fontSize = Number(fontSize) - if (lineHeight !== undefined) updateData.lineHeight = Number(lineHeight) - if (letterSpacing !== undefined) updateData.letterSpacing = Number(letterSpacing) - if (fontFamily !== undefined) updateData.fontFamily = String(fontFamily) - - const createData = { - userId: session.user.id, - fontSize: fontSize !== undefined ? Number(fontSize) : 18, - lineHeight: lineHeight !== undefined ? Number(lineHeight) : 1.8, - letterSpacing: letterSpacing !== undefined ? Number(letterSpacing) : 0, - fontFamily: fontFamily !== undefined ? String(fontFamily) : "font-serif", - } - - const settings = await prisma.userSetting.upsert({ - where: { userId: session.user.id }, - update: updateData, - create: createData - }) - - return NextResponse.json(settings) - } catch (error) { - console.error("POST User Settings Error", error) - return NextResponse.json({ error: "Internal Server Error" }, { status: 500 }) - } -} diff --git a/app/mod/chuong/chapter-client.tsx b/app/mod/chuong/chapter-client.tsx index 194bf73..33e9e61 100644 --- a/app/mod/chuong/chapter-client.tsx +++ b/app/mod/chuong/chapter-client.tsx @@ -44,6 +44,7 @@ const CHAPTER_REGEX_PRESETS = [ ] interface Chapter { + id?: string _id: string number: number volumeNumber?: number | null @@ -172,7 +173,6 @@ function ChapterManager() { try { const formData = new FormData() formData.append("file", epubFile) - formData.append("title", "Append Ebook") formData.append("splitMode", epubSplitMode) formData.append("chapterRegex", epubSplitMode === "regex" ? getEpubSourceRegex() : "") formData.append("chapterRegexPreset", epubRegexPreset) @@ -190,7 +190,7 @@ function ChapterManager() { }) if (!result.ok) { - throw new Error(result.data.error || "Phân tích EPUB thất bại") + throw new Error(result.data.error || result.data.detail || "Phân tích EPUB thất bại") } setEpubPreviewData(result.data) @@ -212,7 +212,6 @@ function ChapterManager() { try { const formData = new FormData() formData.append("file", epubFile) - formData.append("title", "Append Ebook") formData.append("splitMode", epubSplitMode) formData.append("chapterRegex", epubSplitMode === "regex" ? getEpubSourceRegex() : "") formData.append("chapterRegexPreset", epubRegexPreset) @@ -229,7 +228,7 @@ function ChapterManager() { }) if (!result.ok) { - throw new Error(result.data.error || "Nhập EPUB thất bại") + throw new Error(result.data.error || result.data.detail || "Nhập EPUB thất bại") } toast.success(`Nhập EPUB thành công! Đã chêm thêm ${result.data.parserInfo?.chaptersFinal || result.data.totalChapters} chương.`, { id: toastId }) @@ -307,9 +306,10 @@ function ChapterManager() { const fetchAllChaptersForOptimize = async (): Promise => { if (!novelId) return [] - const limit = 200 + const limit = 100 let page = 1 let total = 1 + let expectedTotal = 0 const all: Chapter[] = [] while (page <= total) { @@ -319,11 +319,18 @@ function ChapterManager() { } const data = await res.json() + if (page === 1) { + expectedTotal = Number(data.totalChapters || 0) + } all.push(...(data.chapters || [])) total = data.totalPages || 1 page++ } + if (expectedTotal > 0 && all.length < expectedTotal) { + throw new Error(`Chỉ tải được ${all.length}/${expectedTotal} chương. Vui lòng thử lại để tối ưu toàn bộ truyện.`) + } + return all.sort((a, b) => a.number - b.number) } @@ -351,7 +358,7 @@ function ChapterManager() { }) const resData = await res.json() - if (!res.ok) throw new Error(resData.error || "Thêm mới thất bại") + if (!res.ok) throw new Error(resData.error || resData.detail || "Thêm mới thất bại") toast.success("Đã đăng chương mới thành công!") setOpenAdd(false) @@ -478,17 +485,27 @@ function ChapterManager() { if (optimizedChapters.length === 0) return setOptimizing(true) try { - const sourceById = new Map(optimizeSourceChapters.map((ch) => [ch._id, ch])) + const sourceById = new Map( + optimizeSourceChapters + .map((ch) => { + const key = ch._id || ch.id + return key ? [key, ch] : null + }) + .filter((row): row is [string, Chapter] => !!row) + ) const updates = optimizedChapters .filter((ch) => { - const old = sourceById.get(ch._id) + const key = ch._id || ch.id + if (!key) return false + const old = sourceById.get(key) return !!old && (old.number !== ch.number || old.title !== ch.title) }) .map((ch) => ({ - id: ch._id, + id: ch._id || ch.id, title: ch.title, number: ch.number })) + .filter((item): item is { id: string; title: string; number: number } => !!item.id) if (updates.length === 0) { toast.info("Không có thay đổi nào cần lưu") @@ -531,7 +548,7 @@ function ChapterManager() { }) if (!res.ok) { const data = await res.json() - throw new Error(data.error || "Xóa thất bại") + throw new Error(data.error || data.detail || "Xóa thất bại") } toast.success("Đã xóa chương thành công") setOpenDelete(false) diff --git a/app/mod/truyen/novel-client.tsx b/app/mod/truyen/novel-client.tsx index 7393245..f2b6964 100644 --- a/app/mod/truyen/novel-client.tsx +++ b/app/mod/truyen/novel-client.tsx @@ -835,7 +835,7 @@ export function NovelClient() { const data = await res.json() if (!res.ok) { - throw new Error(data.error || "Không thể phân tích EPUB") + throw new Error(data.error || data.detail || "Không thể phân tích EPUB") } setEpubPreviewData(data) @@ -1199,7 +1199,7 @@ export function NovelClient() { setNewSeriesName("") } if (data.genres && Array.isArray(data.genres)) { - setSelectedGenres(data.genres.map((g: any) => g.genreId)) + setSelectedGenres(data.genres.map((g: any) => g.id)) } else { setSelectedGenres([]) } diff --git a/app/tim-kiem/page.tsx b/app/tim-kiem/page.tsx index fa8b774..b9b204e 100644 --- a/app/tim-kiem/page.tsx +++ b/app/tim-kiem/page.tsx @@ -1,10 +1,12 @@ // Server component instead of client component +import Link from "next/link" import { Search } from "lucide-react" import { Input } from "@/components/ui/input" import { NovelCard } from "@/components/novel-card" import { prisma } from "@/lib/prisma" export const dynamic = "force-dynamic" +const PAGE_SIZE = 20 function collapseSeriesRows(rows: T[]): T[] { const pickedSeries = new Set() @@ -34,6 +36,7 @@ export default async function SearchPage({ const sortBy = resolvedParams.sort || "latest" const genreFilter = resolvedParams.genreFilter || "all" const statusFilter = resolvedParams.statusFilter || "all" + const requestedPage = Math.max(1, Number(resolvedParams.page || "1") || 1) // Build where clause let where: any = {} @@ -42,6 +45,7 @@ export default async function SearchPage({ where.OR = [ { title: { contains: q, mode: "insensitive" } }, { authorName: { contains: q, mode: "insensitive" } }, + { originalAuthorName: { contains: q, mode: "insensitive" } }, { series: { name: { contains: q, mode: "insensitive" } } }, ] } @@ -77,24 +81,74 @@ export default async function SearchPage({ orderBy = { updatedAt: "desc" } } - const filteredNovelsRaw = await prisma.novel.findMany({ - where, - orderBy, - include: { - series: { - select: { - id: true, - name: true, - slug: true, + let filteredNovels: any[] = [] + let totalResults = 0 + let totalPages = 1 + let currentPage = requestedPage + + if (q) { + totalResults = await prisma.novel.count({ where }) + totalPages = Math.max(1, Math.ceil(totalResults / PAGE_SIZE)) + currentPage = Math.min(currentPage, totalPages) + + filteredNovels = await prisma.novel.findMany({ + where, + orderBy, + include: { + series: { + select: { + id: true, + name: true, + slug: true, + }, }, }, - }, - take: 80, - }) + skip: (currentPage - 1) * PAGE_SIZE, + take: PAGE_SIZE, + }) + } else { + const filteredNovelsRaw = await prisma.novel.findMany({ + where, + orderBy, + include: { + series: { + select: { + id: true, + name: true, + slug: true, + }, + }, + }, + take: 500, + }) - const filteredNovels = q ? filteredNovelsRaw.slice(0, 20) : collapseSeriesRows(filteredNovelsRaw).slice(0, 20) + const collapsed = collapseSeriesRows(filteredNovelsRaw) + totalResults = collapsed.length + totalPages = Math.max(1, Math.ceil(totalResults / PAGE_SIZE)) + currentPage = Math.min(currentPage, totalPages) + filteredNovels = collapsed.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE) + } - const genres = await prisma.genre.findMany() + const genres = await prisma.genre.findMany({ orderBy: { name: "asc" } }) + + const pageRangeStart = Math.max(1, currentPage - 2) + const pageRangeEnd = Math.min(totalPages, currentPage + 2) + const pageNumbers = Array.from( + { length: pageRangeEnd - pageRangeStart + 1 }, + (_, index) => pageRangeStart + index + ) + + const buildPageHref = (page: number) => { + const params = new URLSearchParams() + + if (q) params.set("q", q) + if (sortBy !== "latest") params.set("sort", sortBy) + if (genreFilter !== "all") params.set("genreFilter", genreFilter) + if (statusFilter !== "all") params.set("statusFilter", statusFilter) + params.set("page", String(page)) + + return `/tim-kiem?${params.toString()}` + } return (
@@ -140,7 +194,9 @@ export default async function SearchPage({ {/* Results */} -

{filteredNovels.length} kết quả

+

+ {totalResults} kết quả {totalResults > 0 && `(Trang ${currentPage}/${totalPages})`} +

{filteredNovels.length === 0 ? (
@@ -154,6 +210,34 @@ export default async function SearchPage({ ))}
)} + + {totalPages > 1 && ( +
+ + Trước + + + {pageNumbers.map((page) => ( + + {page} + + ))} + + = totalPages ? "pointer-events-none opacity-50" : "hover:bg-muted"}`} + > + Sau + +
+ )}
) } diff --git a/app/truyen/[slug]/page.tsx b/app/truyen/[slug]/page.tsx index 5303786..a8a434c 100644 --- a/app/truyen/[slug]/page.tsx +++ b/app/truyen/[slug]/page.tsx @@ -101,7 +101,7 @@ export default async function NovelDetailPage({ volumeTitle: (c as any).volumeTitle ?? null, volumeChapterNumber: (c as any).volumeChapterNumber ?? null, title: c.title, - createdAt: (c.createdAt as Date).toISOString(), + createdAt: c.createdAt ? (c.createdAt as Date).toISOString() : new Date().toISOString(), views: c.views || 0, content: "" })) diff --git a/next.config.mjs b/next.config.mjs index 0013134..18665fa 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -1,4 +1,6 @@ /** @type {import('next').NextConfig} */ +const readerApiOrigin = (process.env.READER_API_ORIGIN || "http://localhost:8000").replace(/\/+$/, "") + const nextConfig = { output: "standalone", typescript: { @@ -7,6 +9,48 @@ const nextConfig = { images: { unoptimized: true, }, + async rewrites() { + return { + beforeFiles: [ + { + source: "/api/genres", + destination: `${readerApiOrigin}/api/genres`, + }, + { + source: "/api/novels/:path*", + destination: `${readerApiOrigin}/api/novels/:path*`, + }, + { + source: "/api/truyen/:path*", + destination: `${readerApiOrigin}/api/truyen/:path*`, + }, + { + source: "/api/chapters/:path*", + destination: `${readerApiOrigin}/api/chapters/:path*`, + }, + { + source: "/api/user/:path*", + destination: `${readerApiOrigin}/api/user/:path*`, + }, + { + source: "/api/auth/mobile-login", + destination: `${readerApiOrigin}/api/auth/mobile-login`, + }, + { + source: "/api/health", + destination: `${readerApiOrigin}/api/health`, + }, + { + source: "/api/mod/:path*", + destination: `${readerApiOrigin}/api/mod/:path*`, + }, + { + source: "/api/dev/:path*", + destination: `${readerApiOrigin}/api/dev/:path*`, + }, + ], + } + }, } export default nextConfig